Files
praxis/server/services/base.py
T
Praxis CI fbd6602814 docs(milestone): complete v0.1 foundation
---ci---
phase: 0
milestone: v0.1
status: complete
---/ci---
2026-08-01 13:32:48 +00:00

201 lines
6.2 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)."""
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",
]