f2a12f9fed
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete. Phases: ✓ P0 pre-execution (planning) → v0.1.6 ✓ P1 operator foundation (Postgres+auth+VC migration) → v0.1.7 ✓ P2 cohort dashboard + aggregation → v0.1.8 ✓ P3 final review + ship → v0.1.9 (= v0.4 milestone release) Requirements covered (8/8): REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline), REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard), REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC), REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h) Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041 Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward) Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill) ---ci--- project: praxis phase: 3 milestone: v0.4 status: complete phase_role: final milestone_complete: true milestone_merged_to_main: true tag: v0.1.9 requirements: covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02] partial: [] ---/ci---
313 lines
12 KiB
Python
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()) |