"""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", ]