"""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 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.cohort.nightly import NightlyScheduler from server.operator.cohort import router as cohort_router from server.operator.credentials import router as credentials_router from server.operator.failure_patterns import router as failure_router from server.operator.mastery import router as mastery_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 from starlette.responses import FileResponse from starlette.staticfiles import StaticFiles from starlette.exceptions import HTTPException as StarletteHTTPException _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) nightly = NightlyScheduler() app.state.nightly_scheduler = nightly 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() # v0.4 P2 (D-054, REQ-NFR-DASH-02): start the nightly reconciliation # scheduler at 03:00 CT. Cancelled on shutdown. await nightly.start(app.state.pg_store) logger.info("Nightly cohort reconciliation scheduler started (03:00 CT)") try: yield finally: await nightly.stop() 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) # ── Operator API cohort endpoints (TASK-10-02, D-053, D-057) ────────── # Auth-gated via Depends(current_operator) inside each router. Mounted # BEFORE the SPA StaticFiles fallback so /api/operator/* is matched by the # API routers, not the SPA fallback. app.include_router(cohort_router) app.include_router(mastery_router) app.include_router(failure_router) app.include_router(credentials_router) # ── SPA StaticFiles fallback (G-041 binding, TASK-10-01, R-DASH-03/05) ─ # Custom StaticFiles subclass that returns index.html for non-file paths # (SPA client-side routing). G-041 OVERRIDES the plan's catch-all route — # a @app.get("/{path:path}") catch-all before StaticFiles would shadow # asset serving (assertion 8 in TASK-10-04). This subclass serves assets # normally (JS/CSS) and falls back to index.html for client-side routes # (/operator/dashboard, /operator/login). API routes registered above take # precedence over the mount. class SpaStaticFiles(StaticFiles): async def get_response(self, path: str, scope): try: return await super().get_response(path, scope) except (StarletteHTTPException, HTTPException) as e: if getattr(e, "status_code", None) == 404: import os index = os.path.join(self.directory, "index.html") if os.path.isfile(index): return FileResponse(index) raise # Mount client/dist at "/" AFTER all API routes so they take precedence. # html=True serves index.html for "/" (SPA root). The SpaStaticFiles # subclass serves index.html for unknown paths (React Router routes). _CLIENT_DIST = _env("PRAXIS_CLIENT_DIST", "client/dist") if os.path.isdir(_CLIENT_DIST): app.mount("/", SpaStaticFiles(directory=_CLIENT_DIST, html=True), name="spa") logger.info(f"Serving client from {_CLIENT_DIST} (SPA fallback enabled)") 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())