ec397f2c65
v0.5 (Live Assist — on-the-job voice companion) milestone complete. 4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail, v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final review + ship, v0.1.13 = milestone release). 16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog. 469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety). 8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed. G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for human legal review before assist surface go-live. ---ci--- project: praxis phase: 3 milestone: v0.5 status: complete requirements: covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09] partial: [] ---/ci---
212 lines
6.6 KiB
Python
212 lines
6.6 KiB
Python
"""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).
|
|
|
|
category values:
|
|
- ok / blocked_legal / blocked_financial / blocked_medical /
|
|
blocked_impersonation / blocked_off_role / blocked_pii (v0.1 CS guardrail)
|
|
- blocked_direct_script / blocked_imperative / blocked_false_authority /
|
|
coaching / neutral (v0.5 LiveAssistGuardrail — D-068)
|
|
"""
|
|
|
|
allowed: bool
|
|
reason: str = ""
|
|
filtered_text: str | None = None
|
|
category: str = "ok" # see category values above
|
|
extra: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class GuardrailContext:
|
|
"""Context passed to a guardrail check.
|
|
|
|
role: 'system' | 'user' | 'assistant' | 'debrief' | 'assist' (v0.5 — REQ-IDEATE-02).
|
|
The 'assist' role is the LiveAssistGuardrail's context (in-loop guardrail
|
|
processor, post-LLM, pre-TTS).
|
|
"""
|
|
|
|
role: Literal["system", "user", "assistant", "debrief", "assist"] = "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",
|
|
] |