"""Bounded AI conversation primitives used by the explicit Mock profile. The module is deliberately provider-neutral. A real provider is only allowed when a separately configured adapter is injected; the default factory never turns a missing provider into a Mock call silently. """ from __future__ import annotations import hashlib import io import json import math import re import threading import time import uuid import wave from collections.abc import Callable, Iterator, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, Protocol MAX_PROMPT_BYTES = 32 * 1024 MAX_VARIABLE_BYTES = 4096 _PLACEHOLDER = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") _SECRET_KEYS = { "api_key", "apikey", "access_token", "secret", "password", "authorization", "private_key", "credential", "token", } class AIConfigError(ValueError): """A local, deterministic configuration or contract failure.""" def __init__(self, code: str, detail: str) -> None: super().__init__(detail) self.code = code self.detail = detail class AIProviderError(RuntimeError): """A provider failure that must not be hidden by a fallback.""" def __init__(self, code: str, detail: str, retryable: bool = False) -> None: super().__init__(detail) self.code = code self.detail = detail self.retryable = retryable @dataclass(frozen=True) class StreamChunk: sequence: int text: str = "" audio: bytes = b"" done: bool = False late: bool = False provider_model: str | None = None class LLMStream(Protocol): def stream( self, messages: list[dict[str, str]], config: Mapping[str, Any], turn_id: str, cancelled: threading.Event, ) -> Iterator[StreamChunk]: return iter(()) class TTSStream(Protocol): def stream( self, text: str, config: Mapping[str, Any], turn_id: str, cancelled: threading.Event, ) -> Iterator[StreamChunk]: return iter(()) class ASRStream(Protocol): def transcribe( self, audio: bytes, config: Mapping[str, Any], turn_id: str ) -> Iterator[dict[str, Any]]: return iter(()) def canonical(value: Any) -> str: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) def config_digest(config: Mapping[str, Any]) -> str: return hashlib.sha256(canonical(config).encode("utf-8")).hexdigest() def _reject_sensitive_keys(value: Any, path: str = "config") -> None: if isinstance(value, Mapping): for key, child in value.items(): name = str(key).lower() if ( name in _SECRET_KEYS or name in {"url", "endpoint", "base_url"} or name.endswith("_url") ): raise AIConfigError( "AI_CONFIG_SECRET_OR_URL", f"{path}.{key} cannot contain a credential or provider URL", ) _reject_sensitive_keys(child, f"{path}.{key}") elif isinstance(value, list): for index, child in enumerate(value): _reject_sensitive_keys(child, f"{path}[{index}]") def load_prompt(path: str | Path, max_bytes: int = MAX_PROMPT_BYTES) -> str: prompt_path = Path(path) try: if not prompt_path.is_file() or prompt_path.is_symlink(): raise AIConfigError( "PROMPT_FILE_INVALID", "prompt file must be a regular file" ) raw = prompt_path.read_bytes() except AIConfigError: raise except OSError as exc: raise AIConfigError( "PROMPT_FILE_UNREADABLE", "prompt file cannot be read" ) from exc if not 1 <= len(raw) <= max_bytes: raise AIConfigError( "PROMPT_TOO_LARGE", "prompt UTF-8 bytes exceed the configured limit" ) try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: raise AIConfigError("PROMPT_NOT_UTF8", "prompt file must be UTF-8") from exc if "\x00" in text: raise AIConfigError("PROMPT_INVALID", "prompt must not contain NUL bytes") return text def render_prompt( template: str, variables: Mapping[str, Any] | None = None, allowed_variables: list[str] | tuple[str, ...] = (), ) -> str: if not isinstance(template, str) or not template: raise AIConfigError("PROMPT_INVALID", "prompt must be a non-empty string") allowed = set(allowed_variables) if len(allowed) != len(allowed_variables) or any( not isinstance(name, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) for name in allowed_variables ): raise AIConfigError( "PROMPT_VARIABLES_INVALID", "allowed prompt variables are invalid" ) variables = variables or {} unknown_values = set(variables) - allowed if unknown_values: raise AIConfigError( "PROMPT_VARIABLE_NOT_ALLOWED", f"prompt variables are not allowed: {sorted(unknown_values)}", ) def replace(match: re.Match[str]) -> str: name = match.group(1) if name not in allowed: raise AIConfigError( "PROMPT_VARIABLE_NOT_ALLOWED", f"prompt variable is not allowed: {name}" ) if name not in variables: raise AIConfigError( "PROMPT_VARIABLE_MISSING", f"prompt variable is missing: {name}" ) value = variables[name] if not isinstance(value, (str, int, float, bool)) or value is None: raise AIConfigError( "PROMPT_VARIABLE_INVALID", f"prompt variable has an unsupported type: {name}", ) rendered = str(value) if len(rendered.encode("utf-8")) > MAX_VARIABLE_BYTES: raise AIConfigError( "PROMPT_VARIABLE_TOO_LARGE", f"prompt variable is too large: {name}" ) return rendered rendered = _PLACEHOLDER.sub(replace, template) if len(rendered.encode("utf-8")) > MAX_PROMPT_BYTES: raise AIConfigError( "PROMPT_TOO_LARGE", "rendered prompt exceeds the configured limit" ) return rendered def _string(value: Any, field: str, maximum: int = 128) -> str: if ( not isinstance(value, str) or not value or len(value) > maximum or any(char.isspace() for char in value) or "://" in value ): raise AIConfigError("AI_CONFIG_INVALID", f"{field} must be a non-empty token") return value def _ensure_fields(value: Mapping[str, Any], allowed: set[str], field: str) -> None: extra = set(value) - allowed if extra: raise AIConfigError( "AI_CONFIG_FIELDS_INVALID", f"unsupported {field} fields: {sorted(extra)}" ) def normalize_agent_config( source: Mapping[str, Any], version_id: str | None = None ) -> dict[str, Any]: """Normalize legacy profile entries into one immutable AI snapshot shape.""" if not isinstance(source, Mapping): raise AIConfigError( "AI_CONFIG_INVALID", "agent configuration must be an object" ) try: config = json.loads(canonical(dict(source))) except (TypeError, ValueError) as exc: raise AIConfigError( "AI_CONFIG_INVALID", "agent configuration is not JSON-compatible" ) from exc if version_id is not None: config["agent_version_id"] = version_id if not config.get("agent_version_id"): raise AIConfigError( "AI_CONFIG_VERSION_REQUIRED", "agent_version_id is required" ) config.setdefault("immutable", True) llm = config.get("llm") if isinstance(llm, str): config["llm"] = { "provider_ref": llm, "model": "mock-chat-v1" if llm == "mock" else f"{llm}-chat", "temperature": 0.2, "max_tokens": 256, "timeout_ms": 5000, } tts = config.get("tts") if isinstance(tts, str): config["tts"] = { "provider_ref": tts, "model": "mock-tts-v1" if tts == "mock" else f"{tts}-tts", "voice": "mock-neutral" if tts == "mock" else "configured", "speed": 1.0, "format": { "encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, }, "timeout_ms": 5000, } asr = config.get("asr") if isinstance(asr, str): config["asr"] = { "provider_ref": asr, "language": "zh-CN", "input": { "encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "sample_width_bytes": 2, }, "interim": True, "timeout_ms": 5000, } prompt = config.get("prompt") if isinstance(prompt, str): config["prompt"] = {"text": prompt, "allowed_variables": []} elif prompt is None: config["prompt"] = { "text": "You are a concise telephone assistant. Answer the caller's last statement.", "allowed_variables": [], } conversation = config.get("conversation", {}) if not isinstance(conversation, Mapping): raise AIConfigError("AI_CONFIG_INVALID", "conversation must be an object") conversation = dict(conversation) config["conversation"] = conversation defaults = { "opening": "", "allow_interrupt": True, "silence_timeout_ms": 3000, "max_duration_ms": 120000, "max_turns": 20, "sentence_max_chars": 80, "max_pending_audio_chunks": 32, } for key, value in defaults.items(): conversation.setdefault(key, value) return validate_agent_config(config) def validate_agent_config(config: Mapping[str, Any]) -> dict[str, Any]: if not isinstance(config, Mapping): raise AIConfigError( "AI_CONFIG_INVALID", "agent configuration must be an object" ) _reject_sensitive_keys(config) try: result = json.loads(canonical(dict(config))) except (TypeError, ValueError) as exc: raise AIConfigError( "AI_CONFIG_INVALID", "agent configuration is not JSON-compatible" ) from exc allowed_top = { "agent_version_id", "immutable", "llm", "prompt", "tts", "asr", "conversation", "metadata", } extra = set(result) - allowed_top if extra: raise AIConfigError( "AI_CONFIG_FIELDS_INVALID", f"unsupported AI config fields: {sorted(extra)}" ) _string(result.get("agent_version_id"), "agent_version_id") if not result.get("immutable"): raise AIConfigError( "AI_CONFIG_NOT_IMMUTABLE", "published AI versions must be immutable" ) llm = result.get("llm") if not isinstance(llm, Mapping): raise AIConfigError("AI_CONFIG_INVALID", "llm configuration is required") _ensure_fields( llm, { "provider_ref", "credential_ref", "model", "temperature", "max_tokens", "timeout_ms", }, "llm", ) _string(llm.get("provider_ref"), "llm.provider_ref") model = _string(llm.get("model"), "llm.model") if llm["provider_ref"] == "mock" and not model.startswith("mock-"): raise AIConfigError( "LLM_MODEL_UNSUPPORTED", "mock LLM models must use the mock- namespace" ) for field, low, high in (("temperature", 0.0, 2.0),): if field in llm and ( type(llm[field]) not in {int, float} or not low <= llm[field] <= high ): raise AIConfigError( "LLM_PARAMETER_INVALID", f"{field} is outside provider limits" ) for field in ("max_tokens", "timeout_ms"): if field in llm and (type(llm[field]) is not int or llm[field] < 1): raise AIConfigError( "LLM_PARAMETER_INVALID", f"{field} must be a positive integer" ) prompt = result.get("prompt") if not isinstance(prompt, Mapping) or not isinstance(prompt.get("text"), str): raise AIConfigError("PROMPT_INVALID", "prompt.text is required") _ensure_fields(prompt, {"text", "allowed_variables", "max_bytes"}, "prompt") prompt_bytes = len(prompt["text"].encode("utf-8")) max_prompt = prompt.get("max_bytes", MAX_PROMPT_BYTES) if ( type(max_prompt) is not int or not 1 <= max_prompt <= MAX_PROMPT_BYTES or not 1 <= prompt_bytes <= max_prompt ): raise AIConfigError("PROMPT_TOO_LARGE", "prompt exceeds its UTF-8 byte limit") variables = prompt.get("allowed_variables", []) if not isinstance(variables, list) or len(variables) > 32: raise AIConfigError( "PROMPT_VARIABLES_INVALID", "allowed_variables must be a list of at most 32 names", ) render_prompt(prompt["text"], {}, variables) if not _PLACEHOLDER.search( prompt["text"] ) else None for match in _PLACEHOLDER.finditer(prompt["text"]): if match.group(1) not in variables: raise AIConfigError( "PROMPT_VARIABLE_NOT_ALLOWED", f"prompt variable is not allowed: {match.group(1)}", ) tts = result.get("tts") if not isinstance(tts, Mapping): raise AIConfigError("AI_CONFIG_INVALID", "tts configuration is required") _ensure_fields( tts, { "provider_ref", "credential_ref", "model", "voice", "speed", "format", "timeout_ms", }, "tts", ) _string(tts.get("provider_ref"), "tts.provider_ref") tts_model = _string(tts.get("model"), "tts.model") voice = _string(tts.get("voice"), "tts.voice") if tts["provider_ref"] == "mock" and ( not tts_model.startswith("mock-") or not voice.startswith("mock-") ): raise AIConfigError( "TTS_VOICE_INCOMPATIBLE", "mock TTS model and voice must use the mock- namespace", ) speed = tts.get("speed", 1.0) if type(speed) not in {int, float} or not 0.25 <= speed <= 3.0: raise AIConfigError( "TTS_PARAMETER_INVALID", "tts.speed is outside provider limits" ) audio_format = tts.get("format") if not isinstance(audio_format, Mapping): raise AIConfigError("TTS_FORMAT_INVALID", "tts.format is required") _ensure_fields( audio_format, {"encoding", "sample_rate_hz", "channels"}, "tts.format" ) if audio_format.get("encoding") not in {"pcm_s16le", "pcma"}: raise AIConfigError("TTS_FORMAT_INVALID", "tts.format.encoding is unsupported") if ( type(audio_format.get("sample_rate_hz")) is not int or not 8000 <= audio_format["sample_rate_hz"] <= 48000 ): raise AIConfigError( "TTS_FORMAT_INVALID", "tts sample rate is outside the supported range" ) if audio_format.get("channels") != 1: raise AIConfigError("TTS_FORMAT_INVALID", "telephone TTS must be mono") asr = result.get("asr") if not isinstance(asr, Mapping): raise AIConfigError("AI_CONFIG_INVALID", "asr configuration is required") _ensure_fields( asr, { "provider_ref", "credential_ref", "model", "language", "input", "interim", "timeout_ms", }, "asr", ) _string(asr.get("provider_ref"), "asr.provider_ref") if "model" in asr: _string(asr.get("model"), "asr.model") _string(asr.get("language"), "asr.language", 32) input_format = asr.get("input") if ( not isinstance(input_format, Mapping) or input_format.get("encoding") != "pcm_s16le" ): raise AIConfigError("ASR_FORMAT_INVALID", "ASR input must be PCM16LE") _ensure_fields( input_format, {"encoding", "sample_rate_hz", "channels", "sample_width_bytes"}, "asr.input", ) if input_format.get("channels") != 1 or input_format.get("sample_width_bytes") != 2: raise AIConfigError("ASR_FORMAT_INVALID", "ASR input must be mono 16-bit audio") if ( type(input_format.get("sample_rate_hz")) is not int or not 8000 <= input_format["sample_rate_hz"] <= 48000 ): raise AIConfigError( "ASR_FORMAT_INVALID", "ASR sample rate is outside the supported range" ) conversation = result.get("conversation") if not isinstance(conversation, Mapping): raise AIConfigError( "CONVERSATION_CONFIG_INVALID", "conversation configuration is required" ) _ensure_fields( conversation, { "opening", "allow_interrupt", "silence_timeout_ms", "max_duration_ms", "max_turns", "sentence_max_chars", "max_pending_audio_chunks", }, "conversation", ) opening = conversation.get("opening", "") if not isinstance(opening, str) or len(opening.encode("utf-8")) > MAX_PROMPT_BYTES: raise AIConfigError("CONVERSATION_CONFIG_INVALID", "opening is invalid") for field in ( "silence_timeout_ms", "max_duration_ms", "max_turns", "sentence_max_chars", "max_pending_audio_chunks", ): if type(conversation.get(field)) is not int or conversation[field] < 1: raise AIConfigError( "CONVERSATION_CONFIG_INVALID", f"conversation.{field} must be positive" ) if conversation["max_duration_ms"] > 3600000 or conversation["max_turns"] > 1000: raise AIConfigError( "CONVERSATION_CONFIG_INVALID", "conversation limits are too large" ) if type(conversation.get("allow_interrupt")) is not bool: raise AIConfigError( "CONVERSATION_CONFIG_INVALID", "allow_interrupt must be boolean" ) return result def _chunks(text: str, width: int = 8) -> list[str]: return [text[index : index + width] for index in range(0, len(text), width)] or [""] def _pcm_tone(text: str, sample_rate: int = 16000, speed: float = 1.0) -> bytes: try: duration_ms = max(50, min(8000, int(max(1, len(text)) * 42 / max(0.25, speed)))) frames = max(1, int(sample_rate * duration_ms / 1000)) seed = int(hashlib.sha256(text.encode("utf-8")).hexdigest()[:8], 16) frequency = 320 + seed % 360 output = bytearray() for index in range(frames): value = int(9000 * math.sin(2 * math.pi * frequency * index / sample_rate)) output.extend(value.to_bytes(2, "little", signed=True)) return bytes(output) except (OverflowError, TypeError, ValueError, ZeroDivisionError) as exc: raise AIConfigError( "AUDIO_GENERATION_FAILED", "mock audio could not be generated" ) from exc class MockLLM: def __init__( self, chunk_delay_s: float = 0.0, late_chunks_after_cancel: int = 2 ) -> None: self.chunk_delay_s = max(0.0, chunk_delay_s) self.late_chunks_after_cancel = max(0, late_chunks_after_cancel) def stream( self, messages: list[dict[str, str]], config: Mapping[str, Any], turn_id: str, cancelled: threading.Event, ) -> Iterator[StreamChunk]: last = messages[-1]["content"] if messages else "" reply = f"已收到:{last}。请继续。" late_left = self.late_chunks_after_cancel for sequence, part in enumerate(_chunks(reply)): late = cancelled.is_set() if late and late_left <= 0: return if late: late_left -= 1 if self.chunk_delay_s: time.sleep(self.chunk_delay_s) yield StreamChunk(sequence=sequence, text=part, late=late) yield StreamChunk(sequence=len(reply), done=True, late=cancelled.is_set()) class MockTTS: def __init__( self, chunk_size: int = 640, chunk_delay_s: float = 0.0, late_chunks_after_cancel: int = 2, ) -> None: self.chunk_size = max(64, chunk_size) self.chunk_delay_s = max(0.0, chunk_delay_s) self.late_chunks_after_cancel = max(0, late_chunks_after_cancel) def stream( self, text: str, config: Mapping[str, Any], turn_id: str, cancelled: threading.Event, ) -> Iterator[StreamChunk]: try: tts = config["tts"] sample_rate = int(tts["format"]["sample_rate_hz"]) raw = _pcm_tone(text, sample_rate, float(tts.get("speed", 1.0))) except (KeyError, TypeError, ValueError, OverflowError) as exc: raise AIProviderError( "TTS_CONFIG_INVALID", "TTS configuration cannot produce audio" ) from exc late_left = self.late_chunks_after_cancel for sequence, offset in enumerate(range(0, len(raw), self.chunk_size)): late = cancelled.is_set() if late and late_left <= 0: return if late: late_left -= 1 if self.chunk_delay_s: time.sleep(self.chunk_delay_s) yield StreamChunk( sequence=sequence, audio=raw[offset : offset + self.chunk_size], late=late, ) yield StreamChunk(sequence=len(raw), done=True, late=cancelled.is_set()) class MockASR: def transcribe( self, audio: bytes, config: Mapping[str, Any], turn_id: str ) -> Iterator[dict[str, Any]]: validate_wav(audio) fingerprint = hashlib.sha256(audio).hexdigest()[:12] text = f"mock audio {fingerprint}" yield {"turn_id": turn_id, "sequence": 0, "text": text, "is_final": False} yield {"turn_id": turn_id, "sequence": 1, "text": text, "is_final": True} def validate_wav(data: bytes) -> dict[str, int]: if not isinstance(data, bytes) or len(data) < 44: raise AIConfigError( "AUDIO_INVALID", "audio must be a non-empty WAV byte string" ) try: with wave.open(io.BytesIO(data), "rb") as wav: metadata = { "channels": wav.getnchannels(), "sample_width_bytes": wav.getsampwidth(), "sample_rate_hz": wav.getframerate(), "frames": wav.getnframes(), } except (EOFError, wave.Error) as exc: raise AIConfigError( "AUDIO_INVALID", "audio is not a readable WAV file" ) from exc if metadata["channels"] != 1 or metadata["sample_width_bytes"] != 2: raise AIConfigError("AUDIO_FORMAT_INVALID", "audio must be mono PCM16") if not 8000 <= metadata["sample_rate_hz"] <= 48000 or metadata["frames"] < 1: raise AIConfigError( "AUDIO_FORMAT_INVALID", "audio sample rate or duration is invalid" ) return metadata def pcm_to_wav(raw: bytes, sample_rate_hz: int = 16000) -> bytes: if not isinstance(raw, bytes) or not raw or len(raw) % 2: raise AIConfigError("AUDIO_INVALID", "PCM16 bytes are invalid") output = io.BytesIO() with wave.open(output, "wb") as wav: wav.setnchannels(1) wav.setsampwidth(2) wav.setframerate(sample_rate_hz) wav.writeframes(raw) return output.getvalue() def resample_pcm16(raw: bytes, input_rate_hz: int, output_rate_hz: int) -> bytes: """Use nearest-frame selection for bounded telephone-rate test audio.""" if len(raw) % 2 or input_rate_hz < 1 or output_rate_hz < 1: raise AIConfigError("AUDIO_INVALID", "PCM16 resampling arguments are invalid") if input_rate_hz == output_rate_hz: return raw samples = [ int.from_bytes(raw[offset : offset + 2], "little", signed=True) for offset in range(0, len(raw), 2) ] output_frames = max(1, round(len(samples) * output_rate_hz / input_rate_hz)) selected = bytearray() for index in range(output_frames): source = min(len(samples) - 1, round(index * input_rate_hz / output_rate_hz)) selected.extend(samples[source].to_bytes(2, "little", signed=True)) return bytes(selected) def pcm16_to_alaw( raw: bytes, input_rate_hz: int = 16000, output_rate_hz: int = 8000 ) -> bytes: """Resample PCM16 and encode the PCMA/8000 phone payload.""" raw = resample_pcm16(raw, input_rate_hz, output_rate_hz) output = bytearray() for offset in range(0, len(raw), 2): sample = int.from_bytes(raw[offset : offset + 2], "little", signed=True) sign = 0x80 if sample < 0 else 0 magnitude = min(32635, abs(sample)) if magnitude < 256: exponent = 0 mantissa = magnitude >> 4 else: exponent = 1 value = magnitude >> 8 while value > 1 and exponent < 7: value >>= 1 exponent += 1 mantissa = (magnitude >> (exponent + 3)) & 0x0F output.append((sign | (exponent << 4) | mantissa) ^ 0xD5) return bytes(output) def _segments(text: str, maximum: int) -> tuple[list[str], str]: parts: list[str] = [] current = "" for char in text: current += char if char in "。!?!?;;\n" or len(current) >= maximum: parts.append(current) current = "" return parts, current class ConversationEngine: """One bounded, cancellable conversation engine for text/audio tests and Cells.""" def __init__( self, config: Mapping[str, Any], llm: LLMStream | None = None, tts: TTSStream | None = None, asr: ASRStream | None = None, journal: Any | None = None, player_confirmed: bool = True, monotonic: Callable[[], float] = time.monotonic, ) -> None: self.config = validate_agent_config(config) adapters = {"llm": llm, "tts": tts, "asr": asr} for name, adapter in adapters.items(): if adapter is None and self.config[name]["provider_ref"] != "mock": raise AIConfigError( "REAL_PROVIDER_ADAPTER_REQUIRED", f"{name} provider requires an injected provider-specific adapter", ) self.llm = llm if llm is not None else MockLLM() self.tts = tts if tts is not None else MockTTS() self.asr = asr if asr is not None else MockASR() refs = {self.config[name]["provider_ref"] for name in adapters} self.mode = ( "mock" if refs == {"mock"} else ("real" if len(refs) == 1 else "mixed") ) self.journal = journal self.player_confirmed = player_confirmed self.monotonic = monotonic self.history: list[dict[str, str]] = [] self._lock = threading.Lock() self._active: tuple[str, threading.Event] | None = None def _event(self, event: str, **facts: Any) -> None: if self.journal is not None: self.journal.append("ai", event, mode=self.mode, **facts) def interrupt(self) -> str | None: with self._lock: active = self._active if active is not None: active[1].set() return active[0] return None def speak(self, text: str, turn_id: str | None = None) -> dict[str, Any]: if not isinstance(text, str) or not text: raise AIConfigError("TEXT_INVALID", "text to speak is required") turn_id = turn_id or f"turn_{uuid.uuid4().hex}" cancelled = threading.Event() return self._synthesize(text, turn_id, cancelled) def _synthesize( self, text: str, turn_id: str, cancelled: threading.Event ) -> dict[str, Any]: audio = bytearray() sent_chunks = 0 discarded = 0 first_audio_ms: float | None = None provider_model: str | None = None try: limit = int(self.config["conversation"]["max_pending_audio_chunks"]) except (KeyError, TypeError, ValueError, OverflowError) as exc: raise AIProviderError( "AI_CONFIG_INVALID", "audio queue limit is invalid" ) from exc pending = 0 for chunk in self.tts.stream(text, self.config, turn_id, cancelled): if chunk.provider_model is not None: provider_model = chunk.provider_model if chunk.late or cancelled.is_set(): discarded += 1 continue pending += 1 if pending > limit: raise AIProviderError( "AI_BACKPRESSURE", "pending audio queue exceeded its bound" ) if chunk.audio: if first_audio_ms is None: first_audio_ms = self.monotonic() * 1000 self._event("tts.first_audio", turn_id=turn_id) audio.extend(chunk.audio) sent_chunks += 1 pending -= 1 state = ( "cancelled" if cancelled.is_set() else ("playback_confirmed" if self.player_confirmed else "sent") ) self._event( "tts.completed", turn_id=turn_id, audio_bytes=len(audio), sent_chunks=sent_chunks, discarded_late_chunks=discarded, playback_state=state, ) return { "audio": bytes(audio), "sent_chunks": sent_chunks, "discarded_late_chunks": discarded, "playback_state": state, "first_audio_ms": first_audio_ms, "provider_model": provider_model, } def run_text( self, text: str, variables: Mapping[str, Any] | None = None ) -> dict[str, Any]: if ( not isinstance(text, str) or not text or len(text.encode("utf-8")) > 16 * 1024 ): raise AIConfigError("TEXT_INVALID", "input text is empty or too large") turn_id = f"turn_{uuid.uuid4().hex}" cancelled = threading.Event() with self._lock: self._active = (turn_id, cancelled) started_ms = self.monotonic() * 1000 reply = "" audio = bytearray() segments: list[dict[str, Any]] = [] discarded = 0 first_token_ms: float | None = None first_audio_ms: float | None = None llm_provider_model: str | None = None tts_provider_model: str | None = None try: prompt = render_prompt( self.config["prompt"]["text"], variables, self.config["prompt"]["allowed_variables"], ) messages = [ {"role": "system", "content": prompt}, *self.history, {"role": "user", "content": text}, ] self._event( "turn.started", turn_id=turn_id, input_sha256=hashlib.sha256(text.encode()).hexdigest(), ) self._event( "asr.final", turn_id=turn_id, text_sha256=hashlib.sha256(text.encode()).hexdigest(), ) pending = "" for chunk in self.llm.stream(messages, self.config, turn_id, cancelled): if chunk.provider_model is not None: llm_provider_model = chunk.provider_model if chunk.late or cancelled.is_set(): discarded += 1 continue if chunk.text: if first_token_ms is None: first_token_ms = self.monotonic() * 1000 self._event("llm.first_token", turn_id=turn_id) reply += chunk.text pending += chunk.text completed, pending = _segments( pending, int(self.config["conversation"]["sentence_max_chars"]) ) for segment in completed: synthesized = self._synthesize(segment, turn_id, cancelled) audio.extend(synthesized["audio"]) tts_provider_model = ( synthesized.get("provider_model") or tts_provider_model ) discarded += int(synthesized["discarded_late_chunks"]) if ( first_audio_ms is None and synthesized["first_audio_ms"] is not None ): first_audio_ms = synthesized["first_audio_ms"] - started_ms segments.append( { "text": segment, "audio_bytes": len(synthesized["audio"]), "playback_state": synthesized["playback_state"], } ) if cancelled.is_set(): self._event( "turn.cancelled", turn_id=turn_id, discarded_late_chunks=discarded ) return { "status": "cancelled", "turn_id": turn_id, "text": reply, "audio": bytes(audio), "segments": segments, "discarded_late_chunks": discarded, "provider_returned_model": llm_provider_model, "tts_provider_returned_model": tts_provider_model, "tts_model_evidence": { "configured_model": self.config["tts"]["model"], "provider_echoed_model": tts_provider_model is not None, }, } if pending: synthesized = self._synthesize(pending, turn_id, cancelled) audio.extend(synthesized["audio"]) tts_provider_model = ( synthesized.get("provider_model") or tts_provider_model ) discarded += int(synthesized["discarded_late_chunks"]) if first_audio_ms is None and synthesized["first_audio_ms"] is not None: first_audio_ms = synthesized["first_audio_ms"] - started_ms segments.append( { "text": pending, "audio_bytes": len(synthesized["audio"]), "playback_state": synthesized["playback_state"], } ) self.history.extend( ( {"role": "user", "content": text}, {"role": "assistant", "content": reply}, ) ) finished_ms = self.monotonic() * 1000 self._event( "turn.completed", turn_id=turn_id, response_sha256=hashlib.sha256(reply.encode()).hexdigest(), audio_bytes=len(audio), discarded_late_chunks=discarded, ) return { "status": "completed", "turn_id": turn_id, "text": reply, "audio": bytes(audio), "segments": segments, "discarded_late_chunks": discarded, "llm_first_token_ms": None if first_token_ms is None else first_token_ms - started_ms, "tts_first_audio_ms": first_audio_ms, "provider_returned_model": llm_provider_model, "tts_provider_returned_model": tts_provider_model, "tts_model_evidence": { "configured_model": self.config["tts"]["model"], "provider_echoed_model": tts_provider_model is not None, }, "duration_ms": max(0.0, finished_ms - started_ms), } except AIProviderError as exc: self._event("turn.failed", turn_id=turn_id, error_code=exc.code) return {"status": "failed", "turn_id": turn_id, "reason_code": exc.code} finally: with self._lock: if self._active is not None and self._active[0] == turn_id: self._active = None def run_audio( self, audio: bytes, variables: Mapping[str, Any] | None = None ) -> dict[str, Any]: turn_id = f"turn_{uuid.uuid4().hex}" segments = list(self.asr.transcribe(audio, self.config, turn_id)) final = next( (item for item in reversed(segments) if item.get("is_final")), None ) if ( not isinstance(final, Mapping) or not isinstance(final.get("text"), str) or not final["text"] ): raise AIProviderError("ASR_NO_FINAL", "ASR did not produce a final segment") result = self.run_text(final["text"], variables) result["asr_segments"] = segments return result def build_mock_config( version_id: str, prompt: str, model: str = "mock-chat-v1", tts_model: str = "mock-tts-v1", voice: str = "mock-neutral", ) -> dict[str, Any]: return normalize_agent_config( { "agent_version_id": version_id, "immutable": True, "llm": { "provider_ref": "mock", "model": model, "temperature": 0.2, "max_tokens": 256, "timeout_ms": 5000, }, "prompt": {"text": prompt, "allowed_variables": []}, "tts": { "provider_ref": "mock", "model": tts_model, "voice": voice, "speed": 1.0, "format": { "encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, }, "timeout_ms": 5000, }, "asr": { "provider_ref": "mock", "language": "zh-CN", "input": { "encoding": "pcm_s16le", "sample_rate_hz": 16000, "channels": 1, "sample_width_bytes": 2, }, "interim": True, "timeout_ms": 5000, }, "conversation": { "opening": "", "allow_interrupt": True, "silence_timeout_ms": 3000, "max_duration_ms": 120000, "max_turns": 20, "sentence_max_chars": 80, "max_pending_audio_chunks": 32, }, } ) __all__ = [ "AIConfigError", "AIProviderError", "ConversationEngine", "MockASR", "MockLLM", "MockTTS", "StreamChunk", "build_mock_config", "config_digest", "load_prompt", "normalize_agent_config", "pcm16_to_alaw", "pcm_to_wav", "render_prompt", "resample_pcm16", "validate_agent_config", "validate_wav", ]