"""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 contextlib import asynccontextmanager 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, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection from slowapi.errors import RateLimitExceeded from slowapi import _rate_limit_exceeded_handler from db.pg_migrate import apply_pg_migrations from db.pg_store import PgStore from db.store import PraxisStore from server.auth.cookies import get_session_middleware_kwargs from server.auth.rate_limit import limiter from server.auth.routes import router as auth_router from server.pipeline import build_pipeline from server.vc.issuer_keys import _load_root_key from server.vc.migrate_keys import migrate_issuer_keys from server.vc.verification import verify_credential from starlette.middleware.sessions import SessionMiddleware _store = PraxisStore() 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")) @asynccontextmanager async def lifespan(app: FastAPI): """v0.4 — create the asyncpg Postgres pool on startup, close on shutdown. Graceful degradation (D-050, REQ-NFR-MT-01): if PRAXIS_PG_DSN is unset, the server starts without Postgres — the learner voice loop (SQLite) is unaffected. app.state.pg_pool / app.state.pg_store are None in that case and auth/operator routes return 503. """ dsn = os.environ.get("PRAXIS_PG_DSN", "").strip() if not dsn: logger.warning( "PRAXIS_PG_DSN not set — starting without Postgres (dev/no-pool mode). " "Operator auth + cohort endpoints will be unavailable (503). " "Learner voice loop (SQLite) is unaffected." ) app.state.pg_pool = None app.state.pg_store = None try: yield finally: return import asyncpg logger.info("Creating asyncpg Postgres pool (min=1, max=10, D-050)") pool = await asyncpg.create_pool( dsn=dsn, min_size=1, max_size=10, command_timeout=10, ) app.state.pg_pool = pool app.state.pg_store = PgStore(pool) try: applied = await apply_pg_migrations(pool) if applied: logger.info(f"Postgres migrations applied: {applied}") else: logger.info("Postgres migrations up to date") # VC key migration (TASK-06-03, R-VC-MIG-01, G-027) — runs once on # first boot, idempotent. Non-fatal on failure (v0.3 SQLite path # remains intact for verification). await _maybe_migrate_issuer_keys() try: yield finally: pass finally: await pool.close() logger.info("Postgres pool closed") 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", lifespan=lifespan) # slowapi rate-limit state + 429 handler (D-041, TASK-03-03). app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) app.add_middleware( CORSMiddleware, allow_origins=["*"], # dev — the client is a separate Vite origin allow_methods=["*"], allow_headers=["*"], ) # SessionMiddleware (signed cookies, D-056) — added AFTER CORS so it is # the outermost middleware (signs cookies before CORS headers are added). app.add_middleware(SessionMiddleware, **get_session_middleware_kwargs()) @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)) @app.get("/vc/verify/{credential_id}") async def vc_verify(credential_id: str) -> dict[str, Any]: """Public, unauthenticated VC verification endpoint (D-043, G-011). Two-store fallback (G-011, binding contract): (a) If Postgres is available (app.state.pg_store), use it for issuer key lookup (active + superseded keys). (b) If the credential is not in Postgres issued_credentials, fall back to SQLite (v0.3 credentials remain in SQLite — D-051). (c) If Postgres is NOT available, use the v0.3 SQLite path for both. The VC key migration (TASK-04-03) runs once on first boot (idempotent) inside the lifespan — see _maybe_migrate_issuer_keys. """ await _store.init() pg_store = getattr(app.state, "pg_store", None) result = await verify_credential( _store, credential_id, pg_store=pg_store, sqlite_store=_store, ) if result is None: raise HTTPException(status_code=404, detail="credential not found") return result async def _maybe_migrate_issuer_keys() -> None: """Run the VC key migration on first boot (TASK-06-03, R-VC-MIG-01). Idempotent — no-op if Postgres already has an active issuer key. G-027: if SQLite has no v0.3 active key (fresh deploy), skips archive and only generates a fresh v0.4 keypair. """ pg_store = getattr(app.state, "pg_store", None) if pg_store is None: return try: await _store.init() root_key = _load_root_key() result = await migrate_issuer_keys(_store, pg_store, root_key) if result["new_key_id"] is not None: logger.info( f"VC key migration: archived v0.3 key={result['archived_key_id']}, " f"generated fresh v0.4 key={result['new_key_id']}" ) else: logger.info("VC key migration: active key already present (no-op)") except Exception as exc: logger.error(f"VC key migration failed (non-fatal — v0.3 path intact): {exc}") # ── Operator auth routes (TASK-06-02, D-057) ─────────────────────────── # Mounted BEFORE the StaticFiles mount so /api/operator/* is matched by # the router (routes-before-static-mount constraint, carry-forward v0.2). app.include_router(auth_router) # ── 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())