Files
praxis/server/__main__.py
T
Praxis CI de2020eee1 feat(P02): SLICE-10 P2 integration — SPA fallback, router mount, e2e tests
TASK-10-01 (G-041 binding): server/__main__.py — SpaStaticFiles custom
  subclass that returns index.html for non-file paths (NOT a catch-all
  route). G-041 OVERRIDES the plan's catch-all approach — a catch-all
  before StaticFiles would shadow asset serving. The subclass serves
  assets normally + falls back to index.html for client-side routes.
  Mounted at / AFTER all API routes so they take precedence.
TASK-10-02: server/__main__.py — mount operator API routers (cohort,
  mastery, failure_patterns, credentials) before SPA fallback. Start
  NightlyScheduler in lifespan (03:00 CT), cancel on shutdown.
TASK-10-03: tests/test_p2_aggregation_integration.py — e2e aggregation→
  endpoint (12 learners non-suppressed, 5 suppressed), nightly reconcile
  refreshes last_updated, freshness ≤ 24h. G-038 differencing-attack at
  API layer. Requires Postgres (skips if no DSN).
TASK-10-04: tests/test_p2_spa_fallback.py — 9 assertions: / → voice UI,
  /operator/* → index.html, API routes → JSON, /assets/* → StaticFiles.
  R-DASH-03/05 verified. Deviation: GET to POST-only /pipecat/webrtc
  falls through to SPA fallback (not 405) — acceptable, the POST route
  is the real entrypoint; a GET is a client-side navigation attempt.
TASK-10-05: .ciagent/VERIFY-P2.md — REQ-ID → test mapping for all 4 P2
  REQ-IDs + G-038 + G-041 + R-DASH-05.

---ci---
project: praxis
phase: 2
milestone: v0.4
status: execute
persona: backend-engineer
task: 10-01..10-05
requirements:
  covered: [REQ-DASH-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02, REQ-MT-02]
---/ci---
2026-08-04 04:35:29 +00:00

313 lines
12 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 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())