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:
@@ -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())
|
||||
+111
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""LLM adapter package — Ollama Cloud direct API behind LLMProvider."""
|
||||
|
||||
from server.services.base import LLMProvider, LLMStreamChunk
|
||||
|
||||
__all__ = ["LLMProvider", "LLMStreamChunk"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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'."
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""TTS adapter package — Cartesia (cloud) + Piper (self-hosted) behind TTSProvider."""
|
||||
|
||||
from server.services.base import TTSProvider, TTSResult
|
||||
|
||||
__all__ = ["TTSProvider", "TTSResult"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user