f04b9b3588
SLICE-01 (lead-developer): multi-stage Dockerfile (node:22-slim→python:3.12-slim), .dockerignore (excludes secrets/node_modules/.git), docker-compose.yml (port 8789, SQLite volume, env injection for all voice-service vars) SLICE-02 (backend-engineer+data-engineer): FastAPI mounts client/dist as StaticFiles at / after API routes (D-023, REQ-DEPLOY-13). G-102 MUST fix: db/store.py + db/migrate.py now read PRAXIS_DB_PATH from env so the Docker volume mount persists SQLite data. G-105 FIX: Dockerfile copies pyproject.toml before source (pip install layer cached, source changes don't invalidate). REQ-DEPLOY-01, 02, 13, 16 covered. ---ci--- project: praxis phase: 1 milestone: v0.2 status: execute slice: 01-02 wave: 1 ---/ci---
143 lines
4.8 KiB
Python
143 lines
4.8 KiB
Python
"""Praxis server entrypoint — starts the Pipecat WebRTC bot server.
|
|
|
|
Run: `python -m server`
|
|
|
|
Exposes a FastAPI app with:
|
|
GET /health — liveness
|
|
POST /pipecat/webrtc — accept a WebRTC offer SDP, start a pipeline task
|
|
|
|
The server starts and accepts connections even if upstream voice-service keys
|
|
are absent (SLICE-02 deliverable = code structure). Missing keys degrade to
|
|
no audio/no tokens at runtime, not a crash.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
from pydantic import BaseModel
|
|
|
|
# Load .env if present (dev). In production, env is injected directly.
|
|
try:
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
except ImportError: # pragma: no cover
|
|
pass
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.staticfiles import StaticFiles
|
|
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
|
|
|
from server.pipeline import build_pipeline
|
|
|
|
|
|
def _env(key: str, default: str = "") -> str:
|
|
return os.environ.get(key, default).strip()
|
|
|
|
|
|
HOST = _env("PRAXIS_HOST", "0.0.0.0")
|
|
PORT = int(_env("PRAXIS_PORT", "8789"))
|
|
|
|
|
|
class WebRTCOffer(BaseModel):
|
|
"""Client→server WebRTC offer (SDP + type)."""
|
|
|
|
sdp: str
|
|
type: str = "offer"
|
|
|
|
|
|
app = FastAPI(title="Praxis v0.1 voice server", version="0.1.0")
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"], # dev — the client is a separate Vite origin
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> dict[str, Any]:
|
|
"""Liveness probe. Reports key-provisioning status for the client."""
|
|
return {
|
|
"status": "ok",
|
|
"version": "0.1.0",
|
|
"keys": {
|
|
"deepgram": bool(_env("DEEPGRAM_API_KEY")),
|
|
"cartesia": bool(_env("CARTESIA_API_KEY")),
|
|
"ollama": bool(_env("OLLAMA_API_KEY")),
|
|
},
|
|
"tts": _env("PRAXIS_TTS", "cartesia"),
|
|
}
|
|
|
|
|
|
@app.post("/pipecat/webrtc")
|
|
async def webrtc_offer(offer: WebRTCOffer) -> dict[str, str]:
|
|
"""Accept a WebRTC offer, start a Pipecat pipeline task, return the answer.
|
|
|
|
Loads the v0.1 scenario (customer_service_refund_ca_v01) so the pipeline
|
|
uses the scenario-driven system prompt + opening line (TASK-03-07).
|
|
"""
|
|
scenario_id = _env("PRAXIS_SCENARIO", "customer_service_refund_ca_v01")
|
|
try:
|
|
connection = SmallWebRTCConnection(
|
|
ice_servers=[{"urls": "stun:stun.l.google.com:19302"}],
|
|
)
|
|
await connection.receive_offer({"sdp": offer.sdp, "type": offer.type})
|
|
await connection.accept()
|
|
answer = connection.get_answer()
|
|
# Build + run the pipeline for this connection.
|
|
pipeline, task, runner, transport, scenario_runtime = build_pipeline(
|
|
connection, scenario_id=scenario_id
|
|
)
|
|
# Run the pipeline task in the background; the runner manages its lifecycle.
|
|
import asyncio
|
|
|
|
asyncio.create_task(runner.run(task))
|
|
# Play the session-start disclaimer as the first AI utterance (D-019,
|
|
# RESEARCH.md safety baseline), then the scenario opening line.
|
|
from server.services.registry import get_guardrail
|
|
|
|
guardrail = get_guardrail()
|
|
disclaimer = guardrail.session_start_disclaimer
|
|
if scenario_runtime is not None:
|
|
logger.info(
|
|
f"Session starting with scenario {scenario_id!r}; "
|
|
f"disclaimer: {disclaimer[:50]!r}; "
|
|
f"opening line: {scenario_runtime.opening_line[:60]!r}"
|
|
)
|
|
else:
|
|
logger.info(f"Session starting (no scenario); disclaimer: {disclaimer[:50]!r}")
|
|
return {"sdp": answer["sdp"], "type": answer["type"]}
|
|
except Exception as exc:
|
|
logger.error(f"WebRTC offer failed: {exc}")
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
|
|
|
|
# ── Static client serving (D-023, REQ-DEPLOY-13) ────────────────────
|
|
# Mount client/dist as StaticFiles at "/" AFTER all API routes so they
|
|
# take precedence. html=True serves index.html for "/" (SPA root).
|
|
# The client has no React Router (single-view state machine: start→live
|
|
# →debrief), so no SPA fallback fallback route is needed per RESEARCH.md Q3.
|
|
_CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist")
|
|
if os.path.isdir(_CLIENT_DIST):
|
|
app.mount("/", StaticFiles(directory=_CLIENT_DIST, html=True), name="client")
|
|
logger.info(f"Serving client from {_CLIENT_DIST}")
|
|
else:
|
|
logger.warning(f"Client dist not found at {_CLIENT_DIST} — API-only mode")
|
|
|
|
|
|
def main() -> int:
|
|
"""Run the server with uvicorn."""
|
|
import uvicorn
|
|
|
|
logger.info(f"Praxis v0.1 voice server starting on {HOST}:{PORT}")
|
|
uvicorn.run(app, host=HOST, port=PORT, log_level="info")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main()) |