docs(P01): complete minimal-voice-loop phase

---ci---
phase: 1
milestone: v0.1
status: complete
requirements:
  covered: [REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-VOICE-04, REQ-SCEN-01, REQ-STATE-01, REQ-LLM-01, REQ-LLM-02, REQ-DEBRIEF-01, REQ-ORCH-01, REQ-ORCH-02, REQ-SCEN-FMT-01, REQ-NFR-LAT-01, REQ-NFR-SAFE-01, REQ-NFR-COST-01]
  partial: []
---/ci---
This commit is contained in:
Praxis CI
2026-08-01 13:28:42 +00:00
parent 415c8ac8a6
commit b77536aa5e
79 changed files with 8087 additions and 1 deletions
View File
+129
View File
@@ -0,0 +1,129 @@
"""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())
View File
+111
View File
@@ -0,0 +1,111 @@
"""Cost logging — per-session cost derivation (REQ-NFR-COST-01, D-012, TASK-04-04).
Counts LLM input/output tokens (gemma4 + deepseek-v4-flash), Deepgram audio
minutes, Cartesia/Piper characters; derives an estimated cost in cents using
cost_rates.yaml. No enforced ceiling (D-012 — pilot). The derived cost +
breakdown are stored in sessions.cost_estimated_cents / cost_breakdown_json.
v0.1 logged costs are NOT representative of at-scale per-learner cost (G-005):
Ollama tier-based pricing + Canada cloud + low volume = the most expensive
configuration. The $3/learner target requires self-hosted gemma4:e4b + Piper
(post-pilot). The logging infrastructure is the v0.1 contribution.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
_DEFAULT_RATES_PATH = Path(__file__).resolve().parent.parent / "scenarios" / "cost_rates.yaml"
@dataclass
class CostBreakdown:
"""Per-session cost inputs + derived cents."""
llm_input_tokens: int = 0
llm_output_tokens: int = 0
deepgram_audio_minutes: float = 0.0
tts_characters: int = 0
debrief_input_tokens: int = 0
debrief_output_tokens: int = 0
rates: dict[str, float] = field(default_factory=dict)
derived_cents: int = 0
def as_dict(self) -> dict[str, Any]:
return {
"llm_input_tokens": self.llm_input_tokens,
"llm_output_tokens": self.llm_output_tokens,
"deepgram_audio_minutes": round(self.deepgram_audio_minutes, 3),
"tts_characters": self.tts_characters,
"debrief_input_tokens": self.debrief_input_tokens,
"debrief_output_tokens": self.debrief_output_tokens,
"rates": self.rates,
"derived_cents": self.derived_cents,
}
def load_rates(path: Path | None = None) -> dict[str, float]:
"""Load cost rates from cost_rates.yaml (or defaults if absent)."""
p = path or _DEFAULT_RATES_PATH
if p.exists():
with p.open("r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
# Defaults — vendor-list prices, per-unit (pilot estimates, G-005).
return {
"gemma4_cloud_per_1k_tokens_cents": 0.5, # Ollama tier (pro plan amortized)
"deepseek_v4_flash_per_1k_tokens_cents": 1.0, # Ollama tier
"deepgram_per_audio_minute_cents": 0.43, # $0.0043/min
"cartesia_per_1k_chars_cents": 3.0, # per-char pricing
"piper_per_1k_chars_cents": 0.0, # self-hosted, $0
}
def derive_cost(
llm_input_tokens: int = 0,
llm_output_tokens: int = 0,
deepgram_audio_minutes: float = 0.0,
tts_characters: int = 0,
debrief_input_tokens: int = 0,
debrief_output_tokens: int = 0,
tts_provider: str = "cartesia",
rates: dict[str, float] | None = None,
) -> CostBreakdown:
"""Derive the per-session cost in cents from the usage inputs + rates."""
r = rates or load_rates()
# LLM role-play (gemma4:cloud).
rp_tokens = llm_input_tokens + llm_output_tokens
rp_cents = (rp_tokens / 1000.0) * r.get("gemma4_cloud_per_1k_tokens_cents", 0.5)
# Debrief (deepseek-v4-flash:cloud).
db_tokens = debrief_input_tokens + debrief_output_tokens
db_cents = (db_tokens / 1000.0) * r.get("deepseek_v4_flash_per_1k_tokens_cents", 1.0)
# ASR (Deepgram).
asr_cents = deepgram_audio_minutes * r.get("deepgram_per_audio_minute_cents", 0.43)
# TTS (Cartesia or Piper).
tts_rate_key = (
"piper_per_1k_chars_cents" if tts_provider == "piper"
else "cartesia_per_1k_chars_cents"
)
tts_cents = (tts_characters / 1000.0) * r.get(tts_rate_key, 3.0)
total = int(round(rp_cents + db_cents + asr_cents + tts_cents))
return CostBreakdown(
llm_input_tokens=llm_input_tokens,
llm_output_tokens=llm_output_tokens,
deepgram_audio_minutes=deepgram_audio_minutes,
tts_characters=tts_characters,
debrief_input_tokens=debrief_input_tokens,
debrief_output_tokens=debrief_output_tokens,
rates=r,
derived_cents=total,
)
__all__ = ["CostBreakdown", "derive_cost", "load_rates"]
+114
View File
@@ -0,0 +1,114 @@
"""Coaching debrief generation (TASK-05-01, TASK-05-02, TASK-05-03).
On session end, loads the session turns + branch outcome + scenario
debrief.debrief_focus, calls deepseek-v4-flash:cloud in no_think mode (D-020)
with the debrief prompt template, produces a concise 3-bullet text summary
(what you did well / what to improve / one next step).
TASK-05-02: routes the debrief text through the CustomerServiceGuardrail
output filter (blocks legal-action recommendations).
TASK-05-03: synthesizes the debrief as voice via the TTSProvider (same voice
as the role-play per D-006) — handled by the caller via synthesize().
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from server.scenarios.schema import Scenario
from server.services.base import Guardrail, GuardrailContext, LLMProvider
_DEFAULT_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "docs" / "debrief"
def _load_template(template_id: str) -> dict[str, str]:
"""Load a debrief prompt template by id (e.g. 'debrief/default')."""
# template_id is 'debrief/default' → docs/debrief/default.yaml
path = _DEFAULT_TEMPLATE_DIR / f"{template_id.split('/')[-1]}.yaml"
if not path.exists():
# Fallback to the default template.
path = _DEFAULT_TEMPLATE_DIR / "default.yaml"
with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f)
def _render(template_str: str, **kwargs: Any) -> str:
"""Simple {{ var }} rendering (no Jinja dependency for v0.1)."""
out = template_str
for k, v in kwargs.items():
out = out.replace("{{ " + k + " }}", str(v))
out = out.replace("{{" + k + "}}", str(v))
return out
def _format_learner_turns(turns: list[dict[str, str]]) -> str:
lines = []
for t in turns:
role = t.get("role", "?")
text = t.get("asr_text") or t.get("tts_text") or ""
if text:
lines.append(f" {'Learner' if role == 'user' else 'AI'}: {text}")
return "\n".join(lines) if lines else " (no turns recorded)"
async def generate_debrief(
llm: LLMProvider,
scenario: Scenario,
branch_id: str,
outcome: str,
debrief_focus: str,
learner_turns: list[dict[str, str]],
guardrail: Guardrail | None = None,
) -> tuple[str, dict[str, Any]]:
"""Generate the coaching debrief text (TASK-05-01, TASK-05-02).
Args:
llm: the LLMProvider (uses debrief_model = deepseek-v4-flash:cloud no_think).
scenario: the loaded Scenario.
branch_id: the classified branch id.
outcome: the branch outcome ('success' | 'failure').
debrief_focus: the per-branch debrief focus from the scenario.
learner_turns: list of {role, asr_text, tts_text} dicts (the session turns).
guardrail: if provided, the debrief text is routed through the guardrail
output filter (TASK-05-02). Blocked text is replaced with a redirect.
Returns:
(debrief_text, usage_metadata).
"""
template = _load_template(scenario.debrief.prompt_template)
turns_str = _format_learner_turns(learner_turns)
system_prompt = _render(
template["system"],
scenario_title=scenario.title,
)
user_prompt = _render(
template["user"],
scenario_title=scenario.title,
outcome=outcome,
branch_id=branch_id,
debrief_focus=debrief_focus,
learner_turns=turns_str,
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
]
text, usage = await llm.chat_full(
messages, model=llm.debrief_model, no_think=True
)
# TASK-05-02: route through the guardrail output filter.
if guardrail is not None:
verdict = await guardrail.check(text, GuardrailContext(role="debrief"))
if not verdict.allowed and verdict.filtered_text:
text = verdict.filtered_text
return text, usage
__all__ = ["generate_debrief"]
+5
View File
@@ -0,0 +1,5 @@
"""Guardrail package — pluggable rulesets behind the Guardrail interface (D-019)."""
from server.services.base import Guardrail, GuardrailContext, GuardrailVerdict
__all__ = ["Guardrail", "GuardrailContext", "GuardrailVerdict"]
+129
View File
@@ -0,0 +1,129 @@
"""CustomerServiceGuardrail — v0.1 Customer Service ruleset (D-019, TASK-03-04).
Pluggable implementation of the Guardrail interface. Enforces the RESEARCH.md
safety baseline for the Customer Service path:
- system-prompt constraints: no legal/financial/medical advice, no real-company
impersonation, stay-in-role, concise-for-voice
- debrief output filter: block recommendations that the learner advise legal action
- session-start disclaimer audio (defined text)
- no PII collection beyond the hardcoded profile
Selected via PRAXIS_GUARDRAIL=customer_service (default). Replaces the
SLICE-02 NoOpGuardrail with no pipeline change (D-019).
"""
from __future__ import annotations
import re
from server.services.base import Guardrail, GuardrailContext, GuardrailVerdict
# The session-start disclaimer (RESEARCH.md §Safety). Played as the first AI
# utterance of every session.
DISCLAIMER_TEXT = (
"This is an AI practice session for training purposes. "
"It is not a real conversation and no real company is involved."
)
# Patterns that indicate the model is giving advice it shouldn't (per D-019).
_LEGAL_ADVICE_RE = re.compile(
r"\b(sue|lawsuit|take legal action|small claims|hire a lawyer|attorney|"
r"file a complaint with .* tribun|legal rights)\b",
re.IGNORECASE,
)
_FINANCIAL_ADVICE_RE = re.compile(
r"\b(invest|stock|bond|crypto|retirement fund|tax write-?off|bankruptcy)\b",
re.IGNORECASE,
)
_MEDICAL_ADVICE_RE = re.compile(
r"\b(diagnosis|prescribe|medication|therapy|see a doctor|medical condition|"
r"mental health condition)\b",
re.IGNORECASE,
)
_IMPERSONATION_RE = re.compile(
# Claiming to work for a real named company — heuristic.
r"\b(I (?:work|am employed) (?:at|for|with))\b.*\b(Inc\.|Corp\.|LLC|Ltd\.|"
r"Amazon|Apple|Google|Microsoft|Walmart|Costco|Telus|Rogers|Bell|Shopify)\b",
re.IGNORECASE,
)
# Debrief-specific: block recommendations that the learner tell a real customer
# to take legal action. Catches "sue them", "take legal action", "file a lawsuit",
# "small claims", etc. when phrased as advice to the customer.
_DEBRIEF_LEGAL_ACTION_RE = re.compile(
r"\b(tell (?:the |a )?customer to (?:sue|take legal action|file a lawsuit)|"
r"advise.*(?:sue|legal action|lawsuit|small claims)|"
r"recommend.*(?:sue|legal action|lawsuit|small claims)|"
r"(?:suggest|tell|recommend).*sue them|"
r"customer should (?:sue|take legal action|file a lawsuit))\b",
re.IGNORECASE,
)
class CustomerServiceGuardrail(Guardrail):
"""Customer Service ruleset (D-019). Low-risk domain, baseline guardrails."""
name = "customer_service"
async def check(
self, text: str, context: GuardrailContext | None = None
) -> GuardrailVerdict:
ctx = context or GuardrailContext()
role = ctx.role
# Debrief output filter — block legal-action recommendations.
if role == "debrief":
if _DEBRIEF_LEGAL_ACTION_RE.search(text):
return GuardrailVerdict(
allowed=False,
reason="blocked: debrief recommends legal action (D-019 debrief filter)",
category="blocked_legal",
filtered_text=self._filter_legal(text),
)
return GuardrailVerdict(allowed=True, reason="debrief ok", category="ok")
# System / assistant / user content checks.
if _LEGAL_ADVICE_RE.search(text):
return GuardrailVerdict(
allowed=False,
reason="blocked: legal advice (D-019 no-legal-advice)",
category="blocked_legal",
)
if _FINANCIAL_ADVICE_RE.search(text):
return GuardrailVerdict(
allowed=False,
reason="blocked: financial advice (D-019 no-financial-advice)",
category="blocked_financial",
)
if _MEDICAL_ADVICE_RE.search(text):
return GuardrailVerdict(
allowed=False,
reason="blocked: medical advice (D-019 no-medical-advice)",
category="blocked_medical",
)
if _IMPERSONATION_RE.search(text):
return GuardrailVerdict(
allowed=False,
reason="blocked: real-company impersonation (D-019)",
category="blocked_impersonation",
)
return GuardrailVerdict(allowed=True, reason="ok", category="ok")
@property
def session_start_disclaimer(self) -> str:
return DISCLAIMER_TEXT
@staticmethod
def _filter_legal(text: str) -> str:
"""Replace legal-action recommendations with a coaching redirect."""
return _DEBRIEF_LEGAL_REDIRECT if _DEBRIEF_LEGAL_REDIRECT else text
# Coaching redirect used when a debrief recommends legal action (D-019).
_DEBRIEF_LEGAL_REDIRECT = (
"Focus your coaching on the learner's communication performance, "
"not on advising the customer to take legal action."
)
__all__ = ["CustomerServiceGuardrail", "DISCLAIMER_TEXT"]
+37
View File
@@ -0,0 +1,37 @@
"""NoOpGuardrail — always-allow stub implementing the Guardrail interface (TASK-02-07).
SLICE-02 ships this stub so the Pipecat pipeline has the pluggable guardrail hook
in place from the first slice. SLICE-03 TASK-03-04 swaps in CustomerServiceGuardrail
with no pipeline change (D-019). The disclaimer text is defined here (matches the
RESEARCH.md safety baseline) so the pipeline can play it as the first AI utterance
even before the real ruleset lands.
"""
from __future__ import annotations
from server.services.base import Guardrail, GuardrailContext, GuardrailVerdict
# The session-start disclaimer (RESEARCH.md §Safety). Played as the first AI
# utterance of every session. Defined here so it exists from SLICE-02.
DISCLAIMER_TEXT = (
"This is an AI practice session for training purposes. "
"It is not a real conversation and no real company is involved."
)
class NoOpGuardrail(Guardrail):
"""Always-allow stub (SLICE-02 placeholder for the Guardrail slot)."""
name = "noop"
async def check(
self, text: str, context: GuardrailContext | None = None
) -> GuardrailVerdict:
return GuardrailVerdict(allowed=True, reason="noop guardrail — all allowed", category="ok")
@property
def session_start_disclaimer(self) -> str:
return DISCLAIMER_TEXT
__all__ = ["NoOpGuardrail", "DISCLAIMER_TEXT"]
+29
View File
@@ -0,0 +1,29 @@
"""Interruptibility verification harness (TASK-03-05).
Verifies D-008 (abort-and-yield): learner VAD during AI TTS aborts TTS and
yields the floor. The Pipecat pipeline has allow_interruptions=True (set in
build_pipeline), so the abort is handled by Pipecat's built-in interrupt
handling. This module provides:
- a programmatic check that the pipeline is configured for interruptions
- a test that confirms a TTS-abort event fires on VAD during TTS
The manual test (speaking during AI speech cuts it off) is documented in
docs/latency-report.md; the automated test is in tests/test_interruptibility.py.
"""
from __future__ import annotations
from typing import Any
def pipeline_allows_interruptions(pipeline_task: Any) -> bool:
"""Confirm the pipeline task is configured with allow_interruptions=True (D-008)."""
# PipelineParams stores the flag; the task's params attribute carries it.
params = getattr(pipeline_task, "params", None)
if params is None:
return False
return bool(getattr(params, "allow_interruptions", False))
__all__ = ["pipeline_allows_interruptions"]
+131
View File
@@ -0,0 +1,131 @@
"""Latency observer — measures ASR→TTS-first-audio per turn (TASK-02-06).
Hooks into the Pipecat pipeline frame flow to timestamp:
- final-transcript-ready (ASR done)
- LLM-first-token
- TTS-first-audio
- client-playback-start (approx via output frame)
Surfaces the ASR→TTS-first-audio number to the client as a metric frame so the
React client can display it (TASK-02-05 latency readout). Also logs to console
for the server-side record.
This is a thin Pipecat FrameProcessor; it does not alter the frame stream, only
observes. Per-segment latencies are stored in a per-session LatencyRecord and
emitted via the task's metrics channel.
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any
from pipecat.frames.frames import (
Frame,
TranscriptionFrame,
LLMFullResponseEndFrame,
TextFrame,
TTSStartedFrame,
TTSAudioRawFrame,
BotStartedSpeakingFrame,
)
from pipecat.processors.frame_processor import FrameProcessor
@dataclass
class LatencyRecord:
"""Per-turn latency segments (ms)."""
transcript_ready_ms: float | None = None
llm_first_token_ms: float | None = None
tts_first_audio_ms: float | None = None
playback_start_ms: float | None = None
@property
def e2e_asr_to_tts_ms(self) -> float | None:
"""ASR transcript-ready → TTS first-audio (the v0.1 latency target)."""
if self.transcript_ready_ms and self.tts_first_audio_ms:
return self.tts_first_audio_ms - self.transcript_ready_ms
return None
def as_metric(self) -> dict[str, Any]:
return {
"e2e_latency_ms": self.e2e_asr_to_tts_ms,
"transcript_ready_ms": self.transcript_ready_ms,
"llm_first_token_ms": self.llm_first_token_ms,
"tts_first_audio_ms": self.tts_first_audio_ms,
"playback_start_ms": self.playback_start_ms,
}
@dataclass
class LatencyObserverState:
"""Accumulates per-turn records and the current in-flight turn."""
current: LatencyRecord = field(default_factory=LatencyRecord)
records: list[LatencyRecord] = field(default_factory=list)
def reset_turn(self) -> LatencyRecord:
if self.current.transcript_ready_ms is not None:
self.records.append(self.current)
self.current = LatencyRecord()
return self.current
class LatencyObserver(FrameProcessor):
"""Observes frames, timestamps the latency-critical segments, emits metrics.
This processor is inserted into the pipeline (it passes frames through
unchanged). On each TTS-first-audio it logs the turn's e2e latency and
pushes a metric frame downstream for the client to read.
"""
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.state = LatencyObserverState()
async def process_frame(self, frame: Frame, direction) -> None:
# Always pass the frame through first (observation only).
await self.push_frame(frame, direction)
now_ms = time.perf_counter() * 1000.0
if isinstance(frame, TranscriptionFrame):
# ASR final transcript — start of a new turn's latency measurement.
rec = self.state.reset_turn()
rec.transcript_ready_ms = now_ms
elif isinstance(frame, LLMFullResponseEndFrame):
# LLM emitted a full response; first token timestamp is approximated
# by this frame's arrival (Pipecat doesn't emit a dedicated
# first-token frame; the metrics service handles TTFT separately).
if self.state.current.llm_first_token_ms is None:
self.state.current.llm_first_token_ms = now_ms
elif isinstance(frame, TextFrame):
# Intermediate LLM text frame — closest proxy to first-token time.
if (
self.state.current.transcript_ready_ms is not None
and self.state.current.llm_first_token_ms is None
):
self.state.current.llm_first_token_ms = now_ms
elif isinstance(frame, (TTSStartedFrame, TTSAudioRawFrame)):
if self.state.current.tts_first_audio_ms is None:
self.state.current.tts_first_audio_ms = now_ms
e2e = self.state.current.e2e_asr_to_tts_ms
if e2e is not None:
from loguru import logger
logger.info(
f"[latency] ASR→TTS first-audio: {e2e:.1f}ms "
f"(budget 600ms — {'within' if e2e <= 600 else 'OVER'})"
)
elif isinstance(frame, BotStartedSpeakingFrame):
if self.state.current.playback_start_ms is None:
self.state.current.playback_start_ms = now_ms
__all__ = ["LatencyObserver", "LatencyRecord", "LatencyObserverState"]
+5
View File
@@ -0,0 +1,5 @@
"""LLM adapter package — Ollama Cloud direct API behind LLMProvider."""
from server.services.base import LLMProvider, LLMStreamChunk
__all__ = ["LLMProvider", "LLMStreamChunk"]
+147
View File
@@ -0,0 +1,147 @@
"""Ollama Cloud LLM adapter behind the LLMProvider interface (D-020).
Direct API to https://ollama.com/api/chat with OLLAMA_API_KEY bearer,
stream=True. Two models:
- gemma4:cloud (role-play fast path, 256K ctx)
- deepseek-v4-flash:cloud (debrief + branch classifier, no-think mode)
R6 resolution: Pipecat's OLLamaLLMService accepts a custom base_url + bearer
(see docs/latency-report.md). This adapter is a thin wrapper over the raw
/api/chat streaming endpoint so the pipeline has a stable, testable contract
independent of Pipecat's OpenAI-compat shim. The Pipecat pipeline wires the
LLM via this adapter (TASK-02-04) so a swap (e.g. self-hosted gemma4:e4b
post-pilot) requires no pipeline change.
"""
from __future__ import annotations
import json
import os
from typing import Any, AsyncIterator
from server.services.base import LLMProvider, LLMStreamChunk
CHAT_URL_DEFAULT = "https://ollama.com/api/chat"
class OllamaCloudLLM(LLMProvider):
"""Ollama Cloud direct-API LLM adapter (D-020)."""
name = "ollama-cloud"
def __init__(
self,
*,
api_key: str | None = None,
chat_url: str | None = None,
roleplay_model: str | None = None,
debrief_model: str | None = None,
) -> None:
self._api_key = (api_key or os.environ.get("OLLAMA_API_KEY", "")).strip()
self._chat_url = (chat_url or os.environ.get("OLLAMA_CHAT_URL", CHAT_URL_DEFAULT)).strip()
self._roleplay_model = (
roleplay_model or os.environ.get("OLLAMA_ROLEPLAY_MODEL", "gemma4:cloud")
).strip()
self._debrief_model = (
debrief_model
or os.environ.get("OLLAMA_DEBRIEF_MODEL", "deepseek-v4-flash:cloud")
).strip()
@property
def roleplay_model(self) -> str:
return self._roleplay_model
@property
def debrief_model(self) -> str:
return self._debrief_model
def _missing(self) -> bool:
return not self._api_key
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self._api_key}", "Content-Type": "application/json"}
def _body(
self,
messages: list[dict[str, str]],
model: str,
stream: bool,
no_think: bool,
) -> dict[str, Any]:
body: dict[str, Any] = {
"model": model,
"messages": messages,
"stream": stream,
}
if no_think:
# deepseek-v4-flash:cloud no-think mode (D-020) — skips reasoning
# tokens for latency on the debrief / branch-classifier path.
body["think"] = False
return body
async def chat(
self,
messages: list[dict[str, str]],
*,
stream: bool = True,
model: str | None = None,
no_think: bool = False,
) -> AsyncIterator[LLMStreamChunk]:
"""Stream chat-completion chunks from Ollama Cloud /api/chat."""
mdl = model or self._roleplay_model
if self._missing():
# Graceful: yield a single empty chunk so callers don't crash.
return
import httpx
try:
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream(
"POST", self._chat_url, headers=self._headers(),
json=self._body(messages, mdl, stream, no_think),
) as resp:
if resp.status_code != 200:
# Auth/error — degrade to no chunks (pipeline stays up).
return
is_first = True
async for line in resp.aiter_lines():
if not line:
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
content = chunk.get("message", {}).get("content", "")
if content:
yield LLMStreamChunk(
content=content,
is_first=is_first,
finish_reason=chunk.get("done") and "stop" or None,
extra={"eval_count": chunk.get("eval_count")},
)
is_first = False
except Exception:
# Network/auth errors degrade to no chunks; the pipeline stays up.
return
async def chat_full(
self,
messages: list[dict[str, str]],
*,
model: str | None = None,
no_think: bool = False,
) -> tuple[str, dict[str, Any]]:
"""Return (full_text, usage) for non-streaming (debrief / classifier)."""
mdl = model or self._debrief_model
parts: list[str] = []
usage: dict[str, Any] = {"input_tokens": 0, "output_tokens": 0, "model": mdl}
async for chunk in self.chat(
messages, stream=True, model=mdl, no_think=no_think
):
parts.append(chunk.content)
if chunk.extra.get("eval_count"):
usage["output_tokens"] = chunk.extra["eval_count"]
return "".join(parts), usage
__all__ = ["OllamaCloudLLM"]
+231
View File
@@ -0,0 +1,231 @@
"""Praxis Pipecat server pipeline — minimal viable voice loop (SLICE-02 TASK-02-04).
Pipeline (D-017):
WebRTC audio in → Silero VAD → Deepgram Nova-3 STT → LLMContextAggregator(user)
→ OllamaCloudLLM (gemma4:cloud) → LLMContextAggregator(assistant) → Cartesia/Piper TTS
→ WebRTC audio out
Interruptibility (D-008): Pipecat's built-in interrupt handling aborts TTS + yields
the floor when learner VAD fires during AI speech.
The pipeline starts and accepts connections even if upstream services return auth
errors at runtime — the code structure is the SLICE-02 deliverable. All keys come
from env; missing keys degrade to no audio / no tokens, not crashes.
Hardcoded single-turn system prompt (no YAML scenario yet — SLICE-03 replaces it).
"""
from __future__ import annotations
import os
from typing import Any
from loguru import logger
def _env(key: str, default: str = "") -> str:
return os.environ.get(key, default).strip()
# Hardcoded single-turn system prompt (SLICE-02 walking skeleton).
# SLICE-03 TASK-03-07 replaces this with the scenario-driven prompt from YAML.
WALKING_SKELETON_SYSTEM_PROMPT = (
"You are Jordan, a customer who received a damaged product. "
"You are frustrated but not abusive. You want a refund. "
"Stay in character. Do not break role. "
"Keep responses concise for voice (1-3 sentences)."
)
WALKING_SKELETON_OPENING_LINE = (
"Hi, I received my order yesterday and the item is cracked. I want my money back."
)
def _build_llm_context(scenario_runtime=None):
"""Build the LLMContext with the scenario-driven system prompt (TASK-03-07).
If a scenario_runtime is provided, uses scenario.setup.system_prompt.
Otherwise falls back to the SLICE-02 walking-skeleton prompt.
"""
from pipecat.processors.aggregators.llm_context import LLMContext
if scenario_runtime is not None:
system_prompt = scenario_runtime.system_prompt
else:
system_prompt = WALKING_SKELETON_SYSTEM_PROMPT
messages = [
{"role": "system", "content": system_prompt},
]
return LLMContext(messages=messages)
def _build_transport(webrtc_connection) -> Any:
"""Build the SmallWebRTCTransport with audio in/out enabled."""
from pipecat.transports.base_transport import TransportParams
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
params = TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
audio_out_sample_rate=24000,
)
return SmallWebRTCTransport(webrtc_connection, params)
def _build_stt() -> Any:
"""Build the Deepgram Nova-3 STT service (D-013)."""
from pipecat.services.deepgram.stt import DeepgramSTTService
api_key = _env("DEEPGRAM_API_KEY")
if not api_key:
logger.warning("DEEPGRAM_API_KEY not set — STT will not transcribe (pipeline still starts).")
return DeepgramSTTService(
api_key=api_key or "missing",
live_options=None, # Deepgram defaults are fine for nova-3 + en.
)
def _build_llm() -> Any:
"""Build the Pipecat Ollama LLM service pointed at Ollama Cloud (D-020, R6).
Pipecat's OLLamaLLMService extends OpenAILLMService and accepts a custom
base_url + the OpenAI client api_key (bearer). We point it at
https://ollama.com/v1 with OLLAMA_API_KEY as the bearer.
"""
from pipecat.services.ollama.llm import OLLamaLLMService
api_key = _env("OLLAMA_API_KEY")
base_url = _env("OLLAMA_BASE_URL", "https://ollama.com/v1")
model = _env("OLLAMA_ROLEPLAY_MODEL", "gemma4:cloud")
if not api_key:
logger.warning("OLLAMA_API_KEY not set — LLM will not respond (pipeline still starts).")
return OLLamaLLMService(
base_url=base_url,
settings=OLLamaLLMService.Settings(model=model, api_key=api_key or "missing"),
)
def _build_tts() -> Any:
"""Build the Pipecat TTS service for the selected provider (D-014)."""
choice = _env("PRAXIS_TTS", "cartesia").lower()
if choice == "piper":
from pipecat.services.piper.tts import PiperTTSService
voice_model = _env("PIPER_VOICE_MODEL")
if not voice_model:
logger.warning("PIPER_VOICE_MODEL not set — Piper TTS will not speak (pipeline still starts).")
return PiperTTSService(
voice_id=voice_model or "missing",
)
# Default: Cartesia
from pipecat.services.cartesia.tts import CartesiaTTSService
api_key = _env("CARTESIA_API_KEY")
voice_id = _env("CARTESIA_VOICE_ID", "a3536a36-1d18-4efb-a95a-7c44b7b5e384")
if not api_key:
logger.warning("CARTESIA_API_KEY not set — TTS will not speak (pipeline still starts).")
return CartesiaTTSService(
api_key=api_key or "missing",
voice_id=voice_id,
)
def _build_vad_analyzer() -> Any:
"""Build the Silero VAD analyzer (D-008 interruptibility)."""
from pipecat.audio.vad.silero import SileroVADAnalyzer
return SileroVADAnalyzer()
def build_pipeline(webrtc_connection, *, scenario_id: str | None = None):
"""Assemble the full Pipecat pipeline + task + runner for one WebRTC session.
Args:
webrtc_connection: a SmallWebRTCConnection with an accepted offer.
scenario_id: if set, load the scenario and use its system prompt + opening
line (TASK-03-07). If None, falls back to the walking-skeleton prompt.
Returns (pipeline, task, runner, transport, scenario_runtime) so the caller
can start the task on connection, play the opening line, and run the branch
classifier + debrief at session end.
"""
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.task import PipelineParams, PipelineTask
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregator,
)
# Load the scenario runtime (TASK-03-03, TASK-03-07).
scenario_runtime = None
if scenario_id:
try:
from server.scenarios.runtime import build_runtime_from_id
scenario_runtime = build_runtime_from_id(scenario_id)
logger.info(
f"Loaded scenario {scenario_id!r}: branches={scenario_runtime.scenario.branch_ids()}"
)
except Exception as exc:
logger.warning(
f"Could not load scenario {scenario_id!r}: {exc}. "
f"Falling back to walking-skeleton prompt."
)
transport = _build_transport(webrtc_connection)
stt = _build_stt()
llm = _build_llm()
tts = _build_tts()
from server.latency import LatencyObserver
latency_observer = LatencyObserver()
context = _build_llm_context(scenario_runtime)
user_aggregator = LLMContextAggregator(context=context, role="user")
assistant_aggregator = LLMContextAggregator(context=context, role="assistant")
pipeline = Pipeline(
[
transport.input(), # WebRTC audio in
stt, # Deepgram Nova-3
latency_observer, # timestamp ASR-ready (TASK-02-06)
user_aggregator, # collect user transcript into context
llm, # Ollama gemma4:cloud
latency_observer, # timestamp LLM-first-token (passes through)
tts, # Cartesia/Piper
latency_observer, # timestamp TTS-first-audio + emit metric
transport.output(), # WebRTC audio out
assistant_aggregator, # collect assistant text into context
]
)
task = PipelineTask(
pipeline,
params=PipelineParams(
allow_interruptions=True, # D-008 abort-and-yield
enable_metrics=True, # latency measurement (TASK-02-06)
metrics_request_timeout=10.0,
),
)
runner = PipelineRunner(handle_sigint=False)
return pipeline, task, runner, transport, scenario_runtime
def build_runtime_from_id(scenario_id: str):
"""Re-export of the scenario runtime builder (TASK-03-07)."""
from server.scenarios.runtime import build_runtime_from_id as _br
return _br(scenario_id)
__all__ = [
"build_pipeline",
"build_runtime_from_id",
"WALKING_SKELETON_SYSTEM_PROMPT",
"WALKING_SKELETON_OPENING_LINE",
]
+24
View File
@@ -0,0 +1,24 @@
"""Scenario runtime package — YAML → Pydantic → Pipecat Flows (D-018)."""
from server.scenarios.schema import (
Branch,
BranchTrigger,
Scenario,
ScenarioDebrief,
ScenarioPersona,
ScenarioSetup,
ValidationError,
)
from server.scenarios.loader import load, load_all
__all__ = [
"Scenario",
"ScenarioPersona",
"ScenarioSetup",
"Branch",
"BranchTrigger",
"ScenarioDebrief",
"ValidationError",
"load",
"load_all",
]
+137
View File
@@ -0,0 +1,137 @@
"""Branch classifier — LLM-as-judge for learner-signal classification (R7, TASK-03-06).
At session end (or turn boundary), classifies the learner's turn transcripts
into a scenario branch (accept_resolution or escalate) based on the scenario's
learner_signals definitions. Runs OFFLINE from the voice loop (not on the
latency-critical path) per D-P1-05.
Uses deepseek-v4-flash:cloud in no-think mode (D-020) via the LLMProvider —
cheap + fast enough for a one-shot end-of-session classification.
Per G-002: the v0.1 branch is a post-hoc outcome classification, not a runtime
conversation fork. This classifier produces the label that the debrief + DB
log consume.
"""
from __future__ import annotations
import json
from typing import Any
from server.scenarios.schema import Scenario
from server.services.base import GuardrailContext, LLMProvider
CLASSIFIER_SYSTEM_PROMPT = """\
You are a conversation-branch classifier for a customer-service role-play
training session. Given the learner's turns and the scenario's branch
definitions (each with learner_signals), classify which branch the learner's
behavior matches.
Respond with ONLY a JSON object: {"branch_id": "<id>", "reason": "<short>"}
No other text. If the signals are mixed, pick the closest match and explain in
the reason field.
"""
def _build_user_prompt(scenario: Scenario, learner_turns: list[str]) -> str:
branches_desc = "\n".join(
f" - {b.id}: signals={b.trigger.learner_signals}, outcome={b.outcome}"
for b in scenario.branches
)
turns_desc = "\n".join(f" Learner: {t}" for t in learner_turns)
return (
f"Scenario: {scenario.title}\n"
f"Branches:\n{branches_desc}\n\n"
f"Learner turns:\n{turns_desc}\n\n"
f"Which branch does the learner's behavior match? "
f"Respond with JSON {{\"branch_id\": ..., \"reason\": ...}}."
)
async def classify_branch(
llm: LLMProvider,
scenario: Scenario,
learner_turns: list[str],
) -> tuple[str, str]:
"""Classify the learner's turns into a branch id.
Args:
llm: the LLMProvider (uses debrief_model = deepseek-v4-flash:cloud no_think).
scenario: the loaded Scenario.
learner_turns: the learner's ASR transcripts for the session.
Returns:
(branch_id, reason) — branch_id is one of scenario.branch_ids().
"""
messages = [
{"role": "system", "content": CLASSIFIER_SYSTEM_PROMPT},
{"role": "user", "content": _build_user_prompt(scenario, learner_turns)},
]
text, _usage = await llm.chat_full(
messages, model=llm.debrief_model, no_think=True
)
return _parse_branch(text, scenario)
def _parse_branch(text: str, scenario: Scenario) -> tuple[str, str]:
"""Parse the LLM's JSON response into (branch_id, reason)."""
# Be lenient — strip code fences, find the JSON object.
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.lower().startswith("json"):
cleaned = cleaned[4:]
try:
obj = json.loads(cleaned)
branch_id = obj.get("branch_id", "")
reason = obj.get("reason", "")
except json.JSONDecodeError:
# Fall back to a heuristic scan for a known branch id.
reason = "fallback: could not parse LLM JSON"
for b in scenario.branches:
if b.id in text:
return b.id, reason
return scenario.branches[0].id, reason
# Validate the branch id is known.
if branch_id not in scenario.branch_ids():
reason = f"fallback: unknown branch_id {branch_id!r}; {reason}"
branch_id = scenario.branches[0].id
return branch_id, reason
def classify_branch_sync_heuristic(
scenario: Scenario, learner_turns: list[str]
) -> str:
"""A rule-based fallback classifier for tests (no LLM call).
Used by the e2e smoke test when no API key is present. Scans for keywords
matching each branch's learner_signals. Signal tokens are matched as
substrings (e.g. 'policy' matches 'policy_first'; 'empathy' matches
'empathy'; 'concrete resolution' matches 'concrete_resolution').
"""
text = " ".join(learner_turns).lower()
best = scenario.branches[0]
best_score = -1
for b in scenario.branches:
score = 0
for sig in b.trigger.learner_signals:
# Match the signal as a space- or underscore-separated phrase.
token = sig.replace("_", " ").lower()
# Use the first significant word as a loose keyword (e.g. 'policy'
# for 'policy_first', 'defensive' for 'defensive').
keyword = token.split()[0] if " " in token else token
if keyword in text or token in text:
score += 1
if score > best_score:
best_score = score
best = b
return best.id
__all__ = [
"classify_branch",
"classify_branch_sync_heuristic",
"CLASSIFIER_SYSTEM_PROMPT",
]
+58
View File
@@ -0,0 +1,58 @@
"""Scenario loader — YAML → Pydantic Scenario (D-018).
Loads a scenario by id from the scenarios/ directory, validates it against the
Pydantic schema, and returns a typed Scenario object. Used by the pipeline
(TASK-03-07) and the e2e smoke test.
"""
from __future__ import annotations
from pathlib import Path
import yaml
from server.scenarios.schema import Scenario, ValidationError
_DEFAULT_SCENARIOS_DIR = Path(__file__).resolve().parent.parent.parent / "scenarios"
def load(scenario_id: str, scenarios_dir: Path | None = None) -> Scenario:
"""Load and validate a scenario by id.
Args:
scenario_id: e.g. 'customer_service_refund_ca_v01' (the YAML filename stem).
scenarios_dir: override the scenarios directory (default: repo /scenarios).
Returns:
A validated Scenario object.
Raises:
FileNotFoundError: if the YAML file doesn't exist.
ValidationError: if the YAML fails schema validation (typed Pydantic error).
"""
base = scenarios_dir or _DEFAULT_SCENARIOS_DIR
path = base / f"{scenario_id}.yaml"
if not path.exists():
# Try the id-with-cs-prefix alias (RESEARCH example used 'cs_refund_ca_v01').
path = base / f"{scenario_id.replace('cs_', 'customer_service_')}.yaml"
if not path.exists():
raise FileNotFoundError(f"Scenario YAML not found: {scenario_id} in {base}")
with path.open("r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
return Scenario.model_validate(raw)
def load_all(scenarios_dir: Path | None = None) -> list[Scenario]:
"""Load all scenarios in the directory (for the future scenario library)."""
base = scenarios_dir or _DEFAULT_SCENARIOS_DIR
out: list[Scenario] = []
for p in sorted(base.glob("*.yaml")):
with p.open("r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
out.append(Scenario.model_validate(raw))
return out
__all__ = ["load", "load_all", "ValidationError"]
+103
View File
@@ -0,0 +1,103 @@
"""Scenario runtime — maps a Scenario to a Pipecat Flows state machine (TASK-03-03).
The v0.1 branch point is a post-hoc outcome classification (G-002): the
conversation is linear, and at session end an LLM-as-judge (TASK-03-06)
classifies the learner's signals into accept_resolution or escalate. Pipecat
Flows is wired so the branch field is part of the data model; Phase 2+ can
activate true in-flight branching without a schema change.
This module:
- builds the system prompt from scenario.setup.system_prompt
- provides the opening line (scenario.setup.opening_line) as the first TTS utterance
- exposes the branch transition logic (driven by the classifier in TASK-03-06)
TASK-03-07: the pipeline uses scenario-driven prompts instead of the
SLICE-02 hardcoded walking-skeleton prompt.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from server.scenarios.schema import Branch, Scenario
@dataclass
class ScenarioRuntime:
"""Runtime state for one scenario session."""
scenario: Scenario
branch: Branch | None = None
turn_count: int = 0
@property
def system_prompt(self) -> str:
return self.scenario.setup.system_prompt
@property
def opening_line(self) -> str:
return self.scenario.setup.opening_line
@property
def branch_id(self) -> str | None:
return self.branch.id if self.branch else None
@property
def outcome(self) -> str | None:
return self.branch.outcome if self.branch else None
def set_branch(self, branch_id: str) -> Branch:
"""Set the session's branch outcome (from the classifier, TASK-03-06)."""
b = self.scenario.branch_by_id(branch_id)
if b is None:
raise ValueError(
f"Unknown branch id {branch_id!r} for scenario {self.scenario.id!r}; "
f"known: {self.scenario.branch_ids()}"
)
self.branch = b
return b
def debrief_focus(self) -> str:
"""The debrief focus for the resolved branch (or a default)."""
if self.branch:
return self.branch.debrief_focus
return "General coaching feedback for this session."
def as_flow_spec(self) -> dict[str, Any]:
"""Render the scenario as a Pipecat Flows state-machine spec.
v0.1: a single 'conversation' state with the system prompt; branch
transitions are post-hoc (G-002). The spec carries the branch metadata
so Phase 2+ can fork in-flight.
"""
return {
"initial_state": "conversation",
"states": {
"conversation": {
"system_prompt": self.system_prompt,
"opening_line": self.opening_line,
"branches": [
{"id": b.id, "outcome": b.outcome,
"learner_signals": b.trigger.learner_signals}
for b in self.scenario.branches
],
},
},
"transitions": [], # v0.1: no in-flight transitions (G-002)
}
def build_runtime(scenario: Scenario) -> ScenarioRuntime:
"""Construct a ScenarioRuntime for the given scenario."""
return ScenarioRuntime(scenario=scenario)
def build_runtime_from_id(scenario_id: str) -> ScenarioRuntime:
"""Load + build a runtime by scenario id (convenience for the pipeline)."""
from server.scenarios.loader import load
return build_runtime(load(scenario_id))
__all__ = ["ScenarioRuntime", "build_runtime", "build_runtime_from_id"]
+100
View File
@@ -0,0 +1,100 @@
"""Praxis scenario schema — YAML DSL → Pydantic (D-018, D-009, D-010).
Defines the typed model for a branching role-play scenario. Loaded from YAML
by server/scenarios/loader.py. Drives Pipecat Flows (TASK-03-03).
Per RESEARCH.md example + PROJECT.md D-010: one branch point (escalate vs
accept), failure_mode field present (D-009 — not provoked in v0.1).
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class ScenarioPersona(BaseModel):
"""The AI character's voice + identity (D-006 — one voice for role-play + mentor)."""
voice_id: str = Field(..., description="TTS voice id (Cartesia/Piper) — same as mentor per D-006")
character: str = Field(..., description="Character name + role, e.g. 'Customer (Jordan)'")
class ScenarioSetup(BaseModel):
"""The system prompt + opening line that start the role-play."""
system_prompt: str = Field(..., description="LLM system prompt (stays in character)")
opening_line: str = Field(..., description="First TTS utterance the AI speaks")
class BranchTrigger(BaseModel):
"""Learner signals that trigger a branch transition (R7 classification)."""
learner_signals: list[str] = Field(
..., description="Signals the branch classifier looks for (e.g. 'empathy', 'defensive')"
)
class Branch(BaseModel):
"""One branch outcome (D-010 — v0.1 has two: accept_resolution + escalate)."""
id: str = Field(..., description="Branch id, e.g. 'accept_resolution' / 'escalate'")
trigger: BranchTrigger
outcome: Literal["success", "failure"] = Field(..., description="Branch outcome label")
failure_mode: str | None = Field(
None, description="D-009 failure_mode (present, not provoked in v0.1)"
)
debrief_focus: str = Field(..., description="What the debrief emphasizes for this branch")
class ScenarioDebrief(BaseModel):
"""Debrief generation config (D-020 — deepseek-v4-flash:cloud, no-think)."""
model: str = Field("deepseek-v4-flash:cloud", description="Ollama model for the debrief")
mode: Literal["no_think", "think", "max_think"] = Field(
"no_think", description="Reasoning mode (no_think for latency, D-020)"
)
prompt_template: str = Field(
"debrief/default", description="Prompt template id (resolved by server/debrief.py)"
)
class Scenario(BaseModel):
"""A Praxis role-play scenario (D-018 — YAML → Pydantic → Pipecat Flows)."""
id: str = Field(..., description="Scenario id, e.g. 'cs_refund_ca_v01'")
path: str = Field(..., description="Skill path, e.g. 'customer_service'")
market: str = Field(..., description="Market code, e.g. 'CA'")
language: str = Field("en-CA", description="Language code")
title: str = Field(..., description="Human-readable scenario title")
difficulty: int = Field(1, ge=1, le=5, description="Difficulty 1-5")
failure_mode: str = Field(
..., description="D-009 failure_mode — present (not provoked in v0.1)"
)
persona: ScenarioPersona
setup: ScenarioSetup
success_criteria: list[str] = Field(..., min_length=1)
common_mistakes: list[str] = Field(..., min_length=1)
branches: list[Branch] = Field(..., min_length=1, description="Branch points (v0.1: 2)")
debrief: ScenarioDebrief
def branch_ids(self) -> list[str]:
return [b.id for b in self.branches]
def branch_by_id(self, branch_id: str) -> Branch | None:
for b in self.branches:
if b.id == branch_id:
return b
return None
__all__ = [
"Scenario",
"ScenarioPersona",
"ScenarioSetup",
"Branch",
"BranchTrigger",
"ScenarioDebrief",
"ValidationError",
]
+36
View File
@@ -0,0 +1,36 @@
"""Praxis service interfaces and adapter registry.
Public API:
from server.services import TTSProvider, LLMProvider, Guardrail
from server.services import get_tts, get_llm, get_guardrail
Adapters are resolved from env vars:
PRAXIS_TTS=cartesia|piper
OLLAMA_ROLEPLAY_MODEL / OLLAMA_DEBRIEF_MODEL
"""
from __future__ import annotations
from server.services.base import (
Guardrail,
GuardrailContext,
GuardrailVerdict,
LLMProvider,
LLMStreamChunk,
TTSProvider,
TTSResult,
)
from server.services.registry import get_guardrail, get_llm, get_tts
__all__ = [
"TTSProvider",
"TTSResult",
"LLMProvider",
"LLMStreamChunk",
"Guardrail",
"GuardrailVerdict",
"GuardrailContext",
"get_tts",
"get_llm",
"get_guardrail",
]
+201
View File
@@ -0,0 +1,201 @@
"""Praxis service interfaces — abstract base classes for the swappable voice-loop services.
Per PLAN.md SLICE-02 TASK-02-01 and the D-014/D-019/D-020 swap requirements:
- TTSProvider (D-014): Cartesia (cloud) | Piper (self-hosted)
- LLMProvider (D-020): Ollama Cloud direct API (gemma4:cloud / deepseek-v4-flash:cloud)
- Guardrail (D-019): pluggable; v0.1 = Customer Service ruleset
These ABCs are the contract the Pipecat pipeline depends on. Adapters wrap the
underlying Pipecat services (or raw APIs) so a swap requires no pipeline change.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Literal
# ─── TTS ─────────────────────────────────────────────────────────────────────
@dataclass
class TTSResult:
"""Result metadata from a TTS synthesis call."""
first_audio_ms: float | None = None
chars: int = 0
voice_id: str | None = None
audio_format: str = "pcm_s16le"
sample_rate: int = 24000
extra: dict[str, Any] = field(default_factory=dict)
class TTSProvider(ABC):
"""Abstract TTS provider (D-014).
One voice persona (D-006) for both role-play and mentor/debrief.
Selection via env var `PRAXIS_TTS=cartesia|piper`.
"""
name: str = "abstract"
@abstractmethod
async def synthesize(self, text: str) -> AsyncIterator[bytes]:
"""Stream audio chunks (PCM s16le) for the given text.
Yields bytes as they arrive from the upstream TTS (streaming-first).
The first yielded chunk is the first-audio byte — measure latency there.
"""
...
# pragma: no cover — abstract
yield b"" # type: ignore[unreachable]
@abstractmethod
async def synthesize_all(self, text: str) -> tuple[bytes, TTSResult]:
"""Fully synthesize `text`, returning (audio_bytes, result_metadata).
Convenience wrapper for the debrief path where streaming isn't required
on the critical latency path (the debrief is spoken after session end).
"""
...
@property
@abstractmethod
def voice_id(self) -> str:
"""The configured voice persona id (D-006 — one voice)."""
...
# ─── LLM ─────────────────────────────────────────────────────────────────────
@dataclass
class LLMStreamChunk:
"""A single chunk from a streaming LLM response."""
content: str
is_first: bool = False
finish_reason: str | None = None
input_tokens: int | None = None
output_tokens: int | None = None
extra: dict[str, Any] = field(default_factory=dict)
class LLMProvider(ABC):
"""Abstract LLM provider (D-020).
Wraps Ollama Cloud direct API (https://ollama.com/v1 + bearer). Two models:
- gemma4:cloud (role-play fast path)
- deepseek-v4-flash:cloud (debrief / branch classifier, no-think mode)
"""
name: str = "abstract"
@abstractmethod
async def chat(
self,
messages: list[dict[str, str]],
*,
stream: bool = True,
model: str | None = None,
no_think: bool = False,
) -> AsyncIterator[LLMStreamChunk]:
"""Stream chat-completion chunks for the given messages.
`model` overrides the provider default (e.g. deepseek-v4-flash:cloud for
the debrief). `no_think=True` requests no-think mode (deepseek-v4-flash).
"""
...
# pragma: no cover — abstract
yield LLMStreamChunk(content="") # type: ignore[unreachable]
@abstractmethod
async def chat_full(
self,
messages: list[dict[str, str]],
*,
model: str | None = None,
no_think: bool = False,
) -> tuple[str, dict[str, Any]]:
"""Return (full_text, usage_metadata) for non-streaming calls.
Used by the debrief + branch classifier (offline from the voice loop).
"""
...
@property
@abstractmethod
def roleplay_model(self) -> str:
"""The role-play fast-path model id (gemma4:cloud)."""
...
@property
@abstractmethod
def debrief_model(self) -> str:
"""The debrief/branch-classifier model id (deepseek-v4-flash:cloud)."""
...
# ─── Guardrail ───────────────────────────────────────────────────────────────
@dataclass
class GuardrailVerdict:
"""Verdict from a guardrail check (D-019)."""
allowed: bool
reason: str = ""
filtered_text: str | None = None
category: str = "ok" # ok | blocked_legal | blocked_financial | blocked_medical |
# blocked_impersonation | blocked_off_role | blocked_pii
extra: dict[str, Any] = field(default_factory=dict)
@dataclass
class GuardrailContext:
"""Context passed to a guardrail check."""
role: Literal["system", "user", "assistant", "debrief"] = "user"
scenario_id: str | None = None
session_id: str | None = None
turn_seq: int | None = None
extra: dict[str, Any] = field(default_factory=dict)
class Guardrail(ABC):
"""Abstract guardrail layer (D-019).
Pluggable so health/electrical domains (later milestones) can inject
domain-specific rules without touching the pipeline. v0.1 ships one
implementation: CustomerServiceGuardrail (SLICE-03 TASK-03-04).
"""
name: str = "abstract"
@abstractmethod
async def check(
self, text: str, context: GuardrailContext | None = None
) -> GuardrailVerdict:
"""Check `text` against the ruleset; return a verdict."""
...
@property
@abstractmethod
def session_start_disclaimer(self) -> str:
"""The session-start disclaimer audio text (RESEARCH.md §Safety).
Played as the first AI utterance of every session.
"""
...
__all__ = [
"TTSProvider",
"TTSResult",
"LLMProvider",
"LLMStreamChunk",
"Guardrail",
"GuardrailVerdict",
"GuardrailContext",
]
+74
View File
@@ -0,0 +1,74 @@
"""Adapter registry — resolves the active TTS / LLM / Guardrail from env.
Centralizes the D-014 (TTS swap), D-020 (LLM swap), D-019 (guardrail plug) wiring
so the Pipecat pipeline never imports a concrete adapter directly.
"""
from __future__ import annotations
import os
from functools import lru_cache
def _require(key: str, *, default: str | None = None) -> str:
val = os.environ.get(key, default or "").strip()
if not val:
raise RuntimeError(
f"Required env var {key} is not set. See .env.example."
)
return val
@lru_cache(maxsize=1)
def get_tts() -> "TTSProvider": # type: ignore[name-defined]
"""Return the active TTSProvider based on PRAXIS_TTS (D-014)."""
# Imported lazily so importing the registry doesn't drag in Pipecat/TTS deps
# for tools that only need the interfaces.
choice = os.environ.get("PRAXIS_TTS", "cartesia").strip().lower()
if choice == "piper":
from server.tts.piper_tts import PiperTTS
return PiperTTS()
if choice == "cartesia":
from server.tts.cartesia_tts import CartesiaTTS
return CartesiaTTS()
raise RuntimeError(
f"Unknown PRAXIS_TTS={choice!r}; expected 'cartesia' or 'piper'."
)
@lru_cache(maxsize=1)
def get_llm() -> "LLMProvider": # type: ignore[name-defined]
"""Return the active LLMProvider (Ollama Cloud direct API, D-020)."""
from server.llm.ollama_cloud import OllamaCloudLLM
return OllamaCloudLLM()
@lru_cache(maxsize=1)
def get_guardrail() -> "Guardrail": # type: ignore[name-defined]
"""Return the active Guardrail (D-019).
v0.1 SLICE-02 returns NoOpGuardrail; SLICE-03 swaps in CustomerServiceGuardrail.
Selection via PRAXIS_GUARDRAIL=none|customer_service (default: customer_service
once implemented; falls back to none if the ruleset isn't importable yet).
"""
choice = os.environ.get("PRAXIS_GUARDRAIL", "customer_service").strip().lower()
if choice == "none":
from server.guardrails.noop import NoOpGuardrail
return NoOpGuardrail()
if choice == "customer_service":
try:
from server.guardrails.customer_service import CustomerServiceGuardrail
return CustomerServiceGuardrail()
except ImportError:
# SLICE-02 fallback — real ruleset arrives in SLICE-03.
from server.guardrails.noop import NoOpGuardrail
return NoOpGuardrail()
raise RuntimeError(
f"Unknown PRAXIS_GUARDRAIL={choice!r}; expected 'none' or 'customer_service'."
)
+114
View File
@@ -0,0 +1,114 @@
"""Session recorder — wires the SQLite store into the pipeline lifecycle (TASK-04-03).
On session start: create a sessions row.
Per turn: log a turns row with ASR/TTS text + latency.
On branch decision: update branch_path.
On session end: set outcome + update progress + store cost + debrief.
No auth — learner_id is the hardcoded 'learner-1' (D-007).
"""
from __future__ import annotations
from typing import Any
from db.store import PraxisStore, HARDCODED_LEARNER_ID
from server.cost import CostBreakdown, derive_cost
class SessionRecorder:
"""Records a voice session to SQLite (TASK-04-03)."""
def __init__(
self,
store: PraxisStore,
learner_id: str = HARDCODED_LEARNER_ID,
scenario_id: str = "cs_refund_ca_v01",
) -> None:
self.store = store
self.learner_id = learner_id
self.scenario_id = scenario_id
self.session_id: str | None = None
self._turn_seq = 0
# Cost inputs accumulated over the session.
self._llm_input_tokens = 0
self._llm_output_tokens = 0
self._deepgram_minutes = 0.0
self._tts_chars = 0
self._debrief_input_tokens = 0
self._debrief_output_tokens = 0
self._branch_path: list[str] = []
async def start(self) -> str:
"""Create the session row; return the session id."""
self.session_id = await self.store.start_session(self.learner_id, self.scenario_id)
return self.session_id
async def log_turn(
self,
role: str,
asr_text: str | None = None,
tts_text: str | None = None,
latency_ms: float | None = None,
) -> None:
"""Log one turn to the turns table."""
if self.session_id is None:
return
await self.store.log_turn(
self.session_id, self._turn_seq, role, asr_text, tts_text, latency_ms
)
self._turn_seq += 1
# Accumulate cost inputs.
if asr_text:
# Rough: 1 token ≈ 4 chars.
self._llm_input_tokens += len(asr_text) // 4
if tts_text:
self._tts_chars += len(tts_text)
self._llm_output_tokens += len(tts_text) // 4
if latency_ms and role == "assistant":
# Rough audio-minutes estimate from latency (placeholder for real metering).
pass
def add_audio_minutes(self, minutes: float) -> None:
self._deepgram_minutes += minutes
def add_debrief_tokens(self, input_tokens: int, output_tokens: int) -> None:
self._debrief_input_tokens += input_tokens
self._debrief_output_tokens += output_tokens
def set_branch_path(self, branch_path: list[str]) -> None:
self._branch_path = branch_path
async def end(
self,
outcome: str,
tts_provider: str = "cartesia",
debrief_text: str | None = None,
) -> CostBreakdown:
"""End the session: derive cost, write the session row, update progress."""
if self.session_id is None:
raise RuntimeError("SessionRecorder.end() called before start()")
breakdown = derive_cost(
llm_input_tokens=self._llm_input_tokens,
llm_output_tokens=self._llm_output_tokens,
deepgram_audio_minutes=self._deepgram_minutes,
tts_characters=self._tts_chars,
debrief_input_tokens=self._debrief_input_tokens,
debrief_output_tokens=self._debrief_output_tokens,
tts_provider=tts_provider,
)
await self.store.end_session(
self.session_id,
branch_path=self._branch_path,
outcome=outcome,
cost_cents=breakdown.derived_cents,
cost_breakdown=breakdown.as_dict(),
debrief_text=debrief_text,
)
await self.store.update_progress(self.learner_id, self.scenario_id, outcome)
return breakdown
__all__ = ["SessionRecorder"]
+5
View File
@@ -0,0 +1,5 @@
"""TTS adapter package — Cartesia (cloud) + Piper (self-hosted) behind TTSProvider."""
from server.services.base import TTSProvider, TTSResult
__all__ = ["TTSProvider", "TTSResult"]
+108
View File
@@ -0,0 +1,108 @@
"""Cartesia Sonic TTS adapter behind the TTSProvider interface (D-014).
Wraps the raw Cartesia WebSocket API (wss://api.cartesia.ai/tts/websocket) for
the probe-style streaming path, and exposes the TTSProvider contract so the
Pipecat pipeline can swap to Piper with no code change (PRAXIS_TTS=piper).
One voice persona (D-006) — CARTESIA_VOICE_ID from env.
"""
from __future__ import annotations
import asyncio
import json
import os
import time
from typing import AsyncIterator
from server.services.base import TTSProvider, TTSResult
CARTESIA_WS_URL = "wss://api.cartesia.ai/tts/websocket"
DEFAULT_VOICE_ID = "a3536a36-1d18-4efb-a95a-7c44b7b5e384"
DEFAULT_MODEL = "sonic-2"
class CartesiaTTS(TTSProvider):
"""Cartesia Sonic cloud TTS adapter (D-014 primary)."""
name = "cartesia"
def __init__(
self,
*,
api_key: str | None = None,
voice_id: str | None = None,
model: str | None = None,
sample_rate: int = 24000,
) -> None:
self._api_key = (api_key or os.environ.get("CARTESIA_API_KEY", "")).strip()
self._voice_id = (
voice_id or os.environ.get("CARTESIA_VOICE_ID", DEFAULT_VOICE_ID)
).strip()
self._model = model or DEFAULT_MODEL
self._sample_rate = sample_rate
@property
def voice_id(self) -> str:
return self._voice_id
def _missing(self) -> bool:
return not self._api_key
async def synthesize(self, text: str) -> AsyncIterator[bytes]:
"""Stream PCM s16le audio chunks from Cartesia Sonic."""
if self._missing():
# Graceful no-op: yield silence so the pipeline doesn't crash.
# The code structure is the deliverable; live audio needs a key.
return
import websockets
headers = [
("x-api-key", self._api_key),
("cartesia-version", "2024-06-10"),
]
try:
async with websockets.connect(
CARTESIA_WS_URL, additional_headers=headers, open_timeout=10
) as ws:
req = {
"model_id": self._model,
"transcript": text,
"voice": {"id": self._voice_id},
"output_format": {
"container": "raw",
"encoding": "pcm_s16le",
"sample_rate": self._sample_rate,
},
"stream": True,
}
await ws.send(json.dumps(req))
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=15)
if isinstance(msg, (bytes, bytearray)):
yield bytes(msg)
elif isinstance(msg, str):
data = json.loads(msg)
if data.get("type") == "done":
break
except Exception:
# Live-key/auth errors degrade to no audio; the pipeline stays up.
return
async def synthesize_all(self, text: str) -> tuple[bytes, TTSResult]:
t0 = time.perf_counter()
chunks = bytearray()
first_audio_ms: float | None = None
async for chunk in self.synthesize(text):
if first_audio_ms is None:
first_audio_ms = (time.perf_counter() - t0) * 1000.0
chunks += chunk
return bytes(chunks), TTSResult(
first_audio_ms=first_audio_ms,
chars=len(text),
voice_id=self._voice_id,
sample_rate=self._sample_rate,
)
__all__ = ["CartesiaTTS"]
+85
View File
@@ -0,0 +1,85 @@
"""Piper self-hosted TTS adapter behind the TTSProvider interface (D-014).
Piper is the R4 mitigation (ARCHITECTURE.md): self-hosted, ~80ms first-audio on
CPU, open-weights, $0 marginal cost. Selected via PRAXIS_TTS=piper. A voice
model must be downloaded separately (see docs/latency-report.md §Piper
pre-staging). The adapter degrades gracefully if the voice model is absent.
"""
from __future__ import annotations
import os
import time
from pathlib import Path
from typing import AsyncIterator
from server.services.base import TTSProvider, TTSResult
class PiperTTS(TTSProvider):
"""Piper self-hosted TTS adapter (D-014 fallback / R4 mitigation)."""
name = "piper"
def __init__(
self,
*,
voice_model: str | None = None,
voice_id: str | None = None,
sample_rate: int = 22050,
) -> None:
self._voice_model = (
voice_model or os.environ.get("PIPER_VOICE_MODEL", "")
).strip()
self._voice_id = (voice_id or "piper-en_CA-medium").strip()
self._sample_rate = sample_rate
self._voice = None # loaded lazily
@property
def voice_id(self) -> str:
return self._voice_id
def _model_available(self) -> bool:
return bool(self._voice_model) and Path(self._voice_model).exists()
def _load_voice(self):
if self._voice is not None:
return self._voice
if not self._model_available():
return None
try:
from piper import PiperVoice # type: ignore
except ImportError:
return None
self._voice = PiperVoice.load(self._voice_model)
return self._voice
async def synthesize(self, text: str) -> AsyncIterator[bytes]:
"""Stream PCM s16le audio chunks from Piper."""
voice = self._load_voice()
if voice is None:
# Graceful no-op when the voice model isn't provisioned.
return
import io
for chunk in voice.synthesize(text):
# Piper yields AudioChunk with .audio_int16_bytes (PCM s16le).
yield chunk.audio_int16_bytes
async def synthesize_all(self, text: str) -> tuple[bytes, TTSResult]:
t0 = time.perf_counter()
chunks = bytearray()
first_audio_ms: float | None = None
async for chunk in self.synthesize(text):
if first_audio_ms is None:
first_audio_ms = (time.perf_counter() - t0) * 1000.0
chunks += chunk
return bytes(chunks), TTSResult(
first_audio_ms=first_audio_ms,
chars=len(text),
voice_id=self._voice_id,
sample_rate=self._sample_rate,
)
__all__ = ["PiperTTS"]