Files
praxis/server/__main__.py
T
Praxis CI 4d39596a7d feat(milestone): merge phase/01 mastery-core → milestone/v0.3-mastery-scoring
Phase 1 complete. Mastery scoring + competency rubrics + VC issuer shipped.
9 slices, 5 waves, 238 tests passing, 13/13 REQ-IDs covered.
4/4 grill MUST conditions satisfied. VERIFY: APPROVE_WITH_NOTES.

---ci---
project: praxis
phase: 1
milestone: v0.3
status: complete
requirements:
  covered: [REQ-MAST-01, REQ-MAST-02, REQ-MAST-03, REQ-SCEN-02, REQ-SCEN-03, REQ-SCEN-04, REQ-PATH-02, REQ-NFR-MAST-01, REQ-NFR-MAST-02, REQ-NFR-VC-01, REQ-NFR-VC-02, REQ-NFR-IRT-01]
  partial: []
---/ci---
2026-08-04 00:03:13 +00:00

162 lines
5.4 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 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.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"))
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")
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())