"""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 from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection from db.pg_migrate import apply_pg_migrations from db.pg_store import PgStore from db.store import PraxisStore from server.pipeline import build_pipeline from server.vc.verification import verify_credential _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") 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) 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)) @app.get("/vc/verify/{credential_id}") async def vc_verify(credential_id: str) -> dict[str, Any]: """Public, unauthenticated VC verification endpoint (D-043). Returns {valid, status, issuer, credential, mastery, credentialTier, verifiedAt}. 404 if the credential id is not found. No PII beyond what the credential asserts. """ await _store.init() result = await verify_credential(_store, credential_id) if result is None: raise HTTPException(status_code=404, detail="credential not found") return result # ── 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())