Files
agent-call/agent_call/ai_runtime.py
T

1091 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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",
]