"""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 pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection from server.pipeline import build_pipeline 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)) 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())