From 5dd69b8779338b482ac5ded6d4c6add702b9fc88 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 14 Sep 2026 23:10:25 +0800 Subject: [PATCH] feat: add configurable Bailian AI cell call runtime --- .env.example | 25 + AGENTS.md | 2 +- Dockerfile | 3 + agent_call/ai_runtime.py | 1090 +++++++++++++++++ agent_call/bailian.py | 826 +++++++++++++ agent_call/cell_worker_main.py | 100 ++ agent_call/core.py | 288 ++++- agent_call/http.py | 60 +- agent_call/real_cell.py | 990 +++++++++++++++ compose.yaml | 55 + configs/ai-test.bailian.example.yaml | 23 + configs/ai-test.example.yaml | 15 + configs/cell-worker.example.env | 27 + deploy/mock.env.example | 24 +- ...音色可配置电话对话_开发与验收计划_v1.0.md | 196 +++ docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md | 15 +- docs/contracts/ai-config.openapi.yaml | 145 +++ docs/contracts/ai-config.schema.json | 101 ++ docs/contracts/examples/agent-version.json | 49 + .../llm-voice-acceptance-20260914.json | 484 ++++++++ .../evidence/llm-voice-acceptance-20260914.md | 36 + .../real-cell-call-20260914-final.json | 49 + .../real-cell-call-20260914-second.json | 48 + docs/evidence/real-cell-call-20260914.json | 81 ++ docs/evidence/real-cell-call-20260914.md | 17 + docs/最终开发部署监控与验收计划_v1.0.md | 1 + docs/部署接入_运行说明.md | 40 +- prompts/test-call.txt | 4 + requirements.txt | 2 + scripts/accept_ai_call.py | 424 +++++++ scripts/run_real_cell_call.py | 251 ++++ scripts/test_ai_call.py | 452 +++++++ tests/test_ai_runtime.py | 322 +++++ tests/test_bailian.py | 52 + tests/test_contracts.py | 13 + tests/test_real_cell.py | 175 +++ 36 files changed, 6455 insertions(+), 30 deletions(-) create mode 100644 agent_call/ai_runtime.py create mode 100644 agent_call/bailian.py create mode 100644 agent_call/cell_worker_main.py create mode 100644 agent_call/real_cell.py create mode 100644 configs/ai-test.bailian.example.yaml create mode 100644 configs/ai-test.example.yaml create mode 100644 configs/cell-worker.example.env create mode 100644 docs/LLM与音色可配置电话对话_开发与验收计划_v1.0.md create mode 100644 docs/contracts/ai-config.openapi.yaml create mode 100644 docs/contracts/ai-config.schema.json create mode 100644 docs/contracts/examples/agent-version.json create mode 100644 docs/evidence/llm-voice-acceptance-20260914.json create mode 100644 docs/evidence/llm-voice-acceptance-20260914.md create mode 100644 docs/evidence/real-cell-call-20260914-final.json create mode 100644 docs/evidence/real-cell-call-20260914-second.json create mode 100644 docs/evidence/real-cell-call-20260914.json create mode 100644 docs/evidence/real-cell-call-20260914.md create mode 100644 prompts/test-call.txt create mode 100755 scripts/accept_ai_call.py create mode 100755 scripts/run_real_cell_call.py create mode 100755 scripts/test_ai_call.py create mode 100644 tests/test_ai_runtime.py create mode 100644 tests/test_bailian.py create mode 100644 tests/test_real_cell.py diff --git a/.env.example b/.env.example index 921f133..ecdca82 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,13 @@ ASR_WEB_PORT=18088 PUBLIC_ORIGIN= BAILIAN_API_KEY= +# AI text/TTS adapters use these server-side values; never put them in MQ or agent versions. +BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +BAILIAN_WSS_BASE_URL=wss://dashscope.aliyuncs.com/api-ws/v1/inference/ +BAILIAN_TTS_WSS_BASE_URL= +BAILIAN_TTS_MODEL= +BAILIAN_TTS_VOICE= +BAILIAN_ASR_MODEL=fun-asr-realtime FUNASR_API_KEY= BAILIAN_ASR_MODELS=fun-asr-realtime BAILIAN_WS=wss://dashscope.aliyuncs.com/api-ws/v1/inference/ @@ -16,6 +23,24 @@ VOLC_ACCESS_TOKEN= VOLC_RESOURCE_ID=volc.bigasr.sauc.duration VOLC_WS=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel +# RabbitMQ / real Cell worker (values are illustrative; inject secrets at runtime). +RABBITMQ_DEFAULT_USER= +RABBITMQ_DEFAULT_PASS= +RABBITMQ_URL=amqp://user:password@127.0.0.1:5672/%2F +RABBITMQ_PORT=5672 +AGENT_CALL_AI_PROFILE=/app/configs/ai-test.bailian.example.yaml +AGENT_CALL_PROMPT_FILE=/app/prompts/test-call.txt +AGENT_CALL_TENANT_KEY= +AGENT_CALL_EXECUTION_LEDGER=/data/cell-executions.sqlite3 +CELL_ARI_URL=http://127.0.0.1:8088 +CELL_ARI_USERNAME=outbound +CELL_ARI_PASSWORD= +CELL_ARI_APP=agent-call +CELL_SIP_TRUNK_ID=provider-primary +CELL_CALLER_ID=BD93205882 +CELL_RTP_BIND_HOST=127.0.0.1 +CELL_RECORDING_DIR=/data/recordings + # Optional Asterisk deployment: fill after verifying the approved image/version. ASTERISK_IMAGE= # ARI_PASSWORD and SIP_PRIMARY_PASSWORD/SIP_BACKUP_PASSWORD go to the renderer diff --git a/AGENTS.md b/AGENTS.md index 461eb26..d5baf26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ ## 测试呼叫号码 -- 用户指定测试被叫号码:`18601013734`。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。 +- 用户指定测试被叫号码:`15003164745`。仅用于已授权的 Mock/明确安排的测试;不得因写入此处而自动发起真实呼叫,原始号码保持不变。 - 主叫标识保留原值(包括 `BD`),不能按纯数字手机号清洗,也不能直接当成 Digest 认证用户名;具体 From/PAI 等字段映射仍需确认。 - 业务原始被叫号码保持不变;使用该线路时按其规则构造 `7089<被叫号码>`,避免重复添加或把该前缀带到其他供应商线路。 diff --git a/Dockerfile b/Dockerfile index 6872998..bc0320b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ RUN pip install --no-cache-dir -r requirements.txt \ && mkdir -p /data \ && chown 65532:65532 /data COPY agent_call ./agent_call +COPY configs ./configs +COPY prompts ./prompts +COPY scripts ./scripts COPY docs/contracts ./docs/contracts ENV PYTHONPATH=/app \ diff --git a/agent_call/ai_runtime.py b/agent_call/ai_runtime.py new file mode 100644 index 0000000..81d03c2 --- /dev/null +++ b/agent_call/ai_runtime.py @@ -0,0 +1,1090 @@ +"""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", +] diff --git a/agent_call/bailian.py b/agent_call/bailian.py new file mode 100644 index 0000000..592c2db --- /dev/null +++ b/agent_call/bailian.py @@ -0,0 +1,826 @@ +"""Provider-specific Bailian adapters. + +Credentials and endpoints are resolved only from the execution environment. They +never enter agent versions, MQ messages, reports, or source control. +""" + +from __future__ import annotations + +import base64 +import contextlib +import http.client +import io +import json +import os +import queue +import threading +import time +import uuid +import wave +from collections.abc import Iterator, Mapping +from typing import Any, cast +from urllib.parse import urlsplit, urlunsplit + +from .ai_runtime import ( + AIConfigError, + AIProviderError, + StreamChunk, + normalize_agent_config, + pcm16_to_alaw, + resample_pcm16, + validate_wav, +) + + +class BailianLLM: + """DashScope OpenAI-compatible streaming Chat Completions adapter.""" + + def __init__(self, base_url: str, api_key: str, timeout_s: float = 45.0) -> None: + parsed = urlsplit(base_url) + if parsed.scheme != "https" or not parsed.netloc: + raise AIConfigError( + "BAILIAN_BASE_URL_INVALID", "BAILIAN_BASE_URL must be an HTTPS URL" + ) + if not api_key: + raise AIConfigError( + "BAILIAN_API_KEY_MISSING", "BAILIAN_API_KEY is not configured" + ) + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.timeout_s = max(1.0, min(120.0, timeout_s)) + + @classmethod + def from_env(cls) -> BailianLLM: + return cls( + os.environ.get("BAILIAN_BASE_URL", ""), + os.environ.get("BAILIAN_API_KEY", ""), + ) + + def stream( + self, + messages: list[dict[str, str]], + config: Mapping[str, Any], + turn_id: str, + cancelled: threading.Event, + ) -> Iterator[StreamChunk]: + llm = config["llm"] + payload: dict[str, Any] = { + "model": llm["model"], + "messages": messages, + "stream": True, + "stream_options": {"include_usage": True}, + } + for key in ("temperature", "max_tokens"): + if key in llm: + payload[key] = llm[key] + parsed = urlsplit(self.base_url) + if parsed.hostname is None: + raise AIConfigError( + "BAILIAN_BASE_URL_INVALID", "BAILIAN_BASE_URL host is invalid" + ) + path = parsed.path.rstrip("/") + "/chat/completions" + if parsed.query: + path += "?" + parsed.query + connection = http.client.HTTPSConnection( + parsed.hostname, + parsed.port or 443, + timeout=self.timeout_s, + ) + model: str | None = None + sequence = 0 + try: + connection.request( + "POST", + path, + body=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "Accept": "text/event-stream", + }, + ) + response = connection.getresponse() + if response.status >= 400: + raise AIProviderError( + f"LLM_HTTP_{response.status}", + f"Bailian Chat API returned HTTP {response.status}", + retryable=response.status == 429 or response.status >= 500, + ) + for raw_line in response: + if cancelled.is_set(): + return + line = raw_line.decode("utf-8", "replace").strip() + if not line.startswith("data:"): + continue + data = line[5:].strip() + if data == "[DONE]": + break + try: + event = json.loads(data) + except json.JSONDecodeError as exc: + raise AIProviderError( + "LLM_STREAM_INVALID", "Bailian returned invalid SSE JSON" + ) from exc + if isinstance(event.get("model"), str): + model = event["model"] + if isinstance(event.get("error"), Mapping): + error = event["error"] + raise AIProviderError( + "LLM_PROVIDER_ERROR", + str(error.get("message", "Bailian returned an error")), + retryable=False, + ) + for choice in event.get("choices", []): + delta = choice.get("delta") or {} + content = delta.get("content") + if isinstance(content, str) and content: + yield StreamChunk( + sequence=sequence, + text=content, + provider_model=model, + ) + sequence += 1 + yield StreamChunk(sequence=sequence, done=True, provider_model=model) + except AIProviderError: + raise + except TimeoutError as exc: + raise AIProviderError( + "LLM_TIMEOUT", "Bailian Chat API timed out", True + ) from exc + except (http.client.HTTPException, OSError) as exc: + raise AIProviderError( + "LLM_CONNECT_FAILED", "Bailian Chat API connection failed", True + ) from exc + finally: + connection.close() + + +class BailianASR: + """Bailian realtime ASR adapter using the existing run-task protocol.""" + + def __init__( + self, websocket_base_url: str, api_key: str, timeout_s: float = 45.0 + ) -> None: + parsed = urlsplit(websocket_base_url) + if parsed.scheme != "wss" or not parsed.netloc: + raise AIConfigError( + "BAILIAN_WSS_BASE_URL_INVALID", "BAILIAN_WSS_BASE_URL must be a WSS URL" + ) + if not api_key: + raise AIConfigError( + "BAILIAN_API_KEY_MISSING", "BAILIAN_API_KEY is not configured" + ) + self.websocket_base_url = websocket_base_url.rstrip("/") + self.api_key = api_key + self.timeout_s = max(1.0, min(120.0, timeout_s)) + + @classmethod + def from_env(cls) -> BailianASR: + return cls( + os.environ.get("BAILIAN_WSS_BASE_URL", ""), + os.environ.get("BAILIAN_API_KEY", ""), + ) + + def transcribe( + self, audio: bytes, config: Mapping[str, Any], turn_id: str + ) -> Iterator[dict[str, Any]]: + try: + metadata = validate_wav(audio) + with wave.open(io.BytesIO(audio), "rb") as wav: + raw_audio = wav.readframes(wav.getnframes()) + input_rate = metadata["sample_rate_hz"] + raw_audio = resample_pcm16(raw_audio, input_rate, 16000) + timeout_s = min( + self.timeout_s, + max(1.0, int(config["asr"].get("timeout_ms", 45000)) / 1000), + ) + model = str(config["asr"].get("model", "fun-asr-realtime")) + except (AIConfigError, KeyError, TypeError, ValueError, OverflowError) as exc: + if isinstance(exc, AIConfigError): + raise + raise AIProviderError( + "ASR_CONFIG_INVALID", "Bailian ASR configuration is invalid" + ) from exc + try: + import websocket + except ImportError as exc: + raise AIProviderError( + "BAILIAN_ASR_SDK_MISSING", + "websocket-client is required for Bailian ASR", + ) from exc + connection: Any | None = None + task_id = f"task_{uuid.uuid4().hex}" + sequence = 0 + try: + connection = websocket.create_connection( + self.websocket_base_url, + header=["Authorization: Bearer " + self.api_key], + timeout=timeout_s, + ) + connection.send( + json.dumps( + { + "header": { + "action": "run-task", + "task_id": task_id, + "streaming": "duplex", + }, + "payload": { + "task_group": "audio", + "task": "asr", + "function": "recognition", + "model": model, + "parameters": {"format": "pcm", "sample_rate": 16000}, + "input": {}, + }, + }, + ensure_ascii=False, + ) + ) + started = self._recv_json(connection) + header = started.get("header") or {} + if header.get("event") != "task-started": + raise AIProviderError( + "ASR_START_FAILED", + str( + header.get("error_message", "Bailian ASR task failed to start") + ), + ) + for offset in range(0, len(raw_audio), 3200): + connection.send( + raw_audio[offset : offset + 3200], + opcode=websocket.ABNF.OPCODE_BINARY, + ) + connection.send( + json.dumps( + { + "header": { + "action": "finish-task", + "task_id": task_id, + "streaming": "duplex", + }, + "payload": {"input": {}}, + } + ) + ) + while True: + event = self._recv_json(connection) + header = event.get("header") or {} + event_type = header.get("event") + if event_type == "result-generated": + sentence = ((event.get("payload") or {}).get("output") or {}).get( + "sentence" + ) or {} + text = sentence.get("text") + if isinstance(text, str) and text: + is_final = bool(sentence.get("sentence_end")) + yield { + "turn_id": turn_id, + "sequence": sequence, + "text": text, + "is_final": is_final, + } + sequence += 1 + elif event_type == "task-failed": + raise AIProviderError( + "ASR_PROVIDER_ERROR", + str(header.get("error_message", "Bailian ASR task failed")), + ) + elif event_type == "task-finished": + break + except AIProviderError: + raise + except TimeoutError as exc: + raise AIProviderError("ASR_TIMEOUT", "Bailian ASR timed out", True) from exc + except Exception as exc: + raise AIProviderError( + "ASR_CONNECTION_FAILED", "Bailian ASR connection failed", True + ) from exc + finally: + if connection is not None: + with contextlib.suppress(Exception): + connection.close() + + @staticmethod + def _recv_json(connection: Any) -> dict[str, Any]: + try: + message = connection.recv() + except Exception as exc: + raise AIProviderError( + "ASR_CONNECTION_FAILED", "Bailian ASR connection failed", True + ) from exc + if isinstance(message, bytes): + raise AIProviderError( + "ASR_PROTOCOL_INVALID", "Bailian ASR returned unexpected binary data" + ) + try: + event = json.loads(message) + except (TypeError, json.JSONDecodeError) as exc: + raise AIProviderError( + "ASR_PROTOCOL_INVALID", "Bailian ASR returned invalid JSON" + ) from exc + if not isinstance(event, dict): + raise AIProviderError( + "ASR_PROTOCOL_INVALID", "Bailian ASR returned an invalid event" + ) + return event + + +class _BailianTTSCallback: + def __init__(self) -> None: + self.events: queue.Queue[dict[str, Any]] = queue.Queue() + + def on_open(self) -> None: + self.events.put({"type": "__open__"}) + + def on_close(self, close_status_code: Any, close_msg: Any) -> None: + self.events.put( + { + "type": "__closed__", + "code": close_status_code, + "message": str(close_msg or ""), + } + ) + + def on_event(self, message: dict[str, Any]) -> None: + if isinstance(message, dict): + self.events.put(message) + + +class _BailianCosyVoiceCallback: + def __init__(self, max_events: int) -> None: + self.events: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=max_events) + self.provider_model: str | None = None + self.overflowed = False + + def _put(self, event: dict[str, Any]) -> None: + try: + self.events.put_nowait(event) + except queue.Full: + self.overflowed = True + + def on_open(self) -> None: + self._put({"type": "__open__"}) + + def on_complete(self) -> None: + self._put({"type": "__complete__"}) + + def on_error(self, message: Any) -> None: + self._put({"type": "__error__", "message": str(message)}) + + def on_close(self) -> None: + self._put({"type": "__closed__"}) + + def on_event(self, message: str) -> None: + try: + event = json.loads(message) + except (TypeError, json.JSONDecodeError): + self._put({"type": "__event_invalid__"}) + return + if isinstance(event, dict): + header = event.get("header") or {} + model = header.get("model") or event.get("model") + if isinstance(model, str) and model: + self.provider_model = model + self._put({"type": "__event__", "event": event}) + + def on_data(self, data: bytes) -> None: + self._put({"type": "__audio__", "audio": bytes(data)}) + + +class BailianTTS: + """Bailian streaming TTS for Qwen realtime and CosyVoice models.""" + + def __init__( + self, + websocket_base_url: str, + api_key: str, + timeout_s: float = 45.0, + ) -> None: + parsed = urlsplit(websocket_base_url) + if parsed.scheme != "wss" or not parsed.netloc: + raise AIConfigError( + "BAILIAN_WSS_BASE_URL_INVALID", + "BAILIAN_WSS_BASE_URL must be a WSS URL", + ) + if not api_key: + raise AIConfigError( + "BAILIAN_API_KEY_MISSING", "BAILIAN_API_KEY is not configured" + ) + self.websocket_base_url = websocket_base_url.rstrip("/") + self.api_key = api_key + self.timeout_s = max(1.0, min(120.0, timeout_s)) + + @classmethod + def from_env(cls) -> BailianTTS: + return cls( + os.environ.get("BAILIAN_TTS_WSS_BASE_URL") + or os.environ.get("BAILIAN_WSS_BASE_URL", ""), + os.environ.get("BAILIAN_API_KEY", ""), + ) + + def stream( + self, + text: str, + config: Mapping[str, Any], + turn_id: str, + cancelled: threading.Event, + ) -> Iterator[StreamChunk]: + tts = config["tts"] + model = str(tts["model"]) + if model.startswith("cosyvoice-"): + yield from self._stream_cosyvoice(text, config, turn_id, cancelled) + return + try: + import dashscope + from dashscope.audio.qwen_tts_realtime import AudioFormat, QwenTtsRealtime + except ImportError as exc: + raise AIProviderError( + "BAILIAN_TTS_SDK_MISSING", + "dashscope is required for Bailian Qwen-TTS-Realtime", + ) from exc + dashscope.api_key = self.api_key + callback = _BailianTTSCallback() + connection: Any | None = None + response_done = False + finish_sent = False + sequence = 0 + returned_model: str | None = None + try: + configured_timeout_s = int(tts.get("timeout_ms", 45000)) / 1000 + except (TypeError, ValueError, OverflowError) as exc: + raise AIProviderError( + "TTS_CONFIG_INVALID", "TTS timeout is invalid" + ) from exc + timeout_s = min(self.timeout_s, max(1.0, configured_timeout_s)) + try: + connection = QwenTtsRealtime( + model=model, + callback=cast(Any, callback), + url=self._url_for_qwen_realtime(), + headers={"X-DashScope-DataInspection": "disable"}, + ) + connection.connect() + connection.update_session( + voice=str(tts["voice"]), + response_format=AudioFormat.PCM_24000HZ_MONO_16BIT, + mode="commit", + sample_rate=24000, + language_type="Chinese" + if config["asr"].get("language", "").startswith("zh") + else "Auto", + ) + if not self._wait_for_event(callback.events, "session.updated", timeout_s): + raise AIProviderError( + "TTS_SESSION_TIMEOUT", + "Bailian TTS session was not acknowledged", + True, + ) + if cancelled.is_set(): + connection.finish() + return + connection.append_text(text) + connection.commit() + while True: + try: + event = callback.events.get(timeout=timeout_s) + except queue.Empty as exc: + raise AIProviderError( + "TTS_TIMEOUT", "Bailian TTS response timed out", True + ) from exc + event_type = event.get("type") + if event_type == "session.created" or event_type == "session.updated": + returned_model = (event.get("session") or {}).get( + "model" + ) or returned_model + elif event_type == "response.created": + returned_model = (event.get("response") or {}).get( + "model" + ) or returned_model + elif event_type == "error": + error = event.get("error") or {} + raise AIProviderError( + "TTS_PROVIDER_ERROR", + str(error.get("message", "Bailian TTS returned an error")), + ) + elif event_type == "response.audio.delta": + try: + audio = base64.b64decode(event.get("delta", ""), validate=True) + except (ValueError, TypeError) as exc: + raise AIProviderError( + "TTS_AUDIO_INVALID", "Bailian returned invalid audio data" + ) from exc + yield StreamChunk( + sequence=sequence, + audio=audio, + provider_model=returned_model, + ) + sequence += 1 + elif event_type == "response.done": + response_done = True + if not finish_sent: + connection.finish() + finish_sent = True + elif event_type == "session.finished": + break + elif event_type == "__closed__": + if response_done: + break + raise AIProviderError( + "TTS_CONNECTION_CLOSED", + "Bailian TTS connection closed early", + True, + ) + if cancelled.is_set(): + with contextlib.suppress(Exception): + connection.cancel_response() + if not finish_sent: + connection.finish() + finish_sent = True + return + yield StreamChunk( + sequence=sequence, done=True, provider_model=returned_model + ) + except AIProviderError: + raise + except TimeoutError as exc: + raise AIProviderError( + "TTS_CONNECT_TIMEOUT", "Bailian TTS connection timed out", True + ) from exc + except Exception as exc: + raise AIProviderError( + "TTS_CONNECTION_FAILED", "Bailian TTS connection failed", True + ) from exc + finally: + if connection is not None: + with contextlib.suppress(Exception): + connection.close() + + def _url_for_qwen_realtime(self) -> str: + parsed = urlsplit(self.websocket_base_url) + path = parsed.path.rstrip("/") + if path.endswith("/inference"): + path = path[: -len("/inference")] + "/realtime" + elif not path: + path = "/api-ws/v1/realtime" + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")) + + def _stream_cosyvoice( + self, + text: str, + config: Mapping[str, Any], + turn_id: str, + cancelled: threading.Event, + ) -> Iterator[StreamChunk]: + del turn_id + try: + import dashscope + from dashscope.audio.tts_v2 import AudioFormat, SpeechSynthesizer + except ImportError as exc: + raise AIProviderError( + "BAILIAN_TTS_SDK_MISSING", + "dashscope is required for Bailian CosyVoice", + ) from exc + tts = config["tts"] + try: + timeout_s = min( + self.timeout_s, + max(1.0, int(tts.get("timeout_ms", 45000)) / 1000), + ) + sample_rate = int(tts["format"]["sample_rate_hz"]) + encoding = str(tts["format"]["encoding"]) + speed = float(tts.get("speed", 1.0)) + except (KeyError, TypeError, ValueError, OverflowError) as exc: + raise AIProviderError( + "TTS_CONFIG_INVALID", "TTS configuration is invalid" + ) from exc + if encoding not in {"pcm_s16le", "pcma"}: + raise AIProviderError( + "TTS_FORMAT_INVALID", "TTS output encoding is unsupported" + ) + format_name = f"PCM_{sample_rate}HZ_MONO_16BIT" + output_format = getattr(AudioFormat, format_name, None) + if output_format is None: + raise AIProviderError( + "TTS_FORMAT_INVALID", + "CosyVoice does not support the requested sample rate", + ) + dashscope.api_key = self.api_key + try: + max_events = int(config["conversation"]["max_pending_audio_chunks"]) + except (KeyError, TypeError, ValueError, OverflowError) as exc: + raise AIProviderError( + "TTS_CONFIG_INVALID", "audio queue limit is invalid" + ) from exc + callback = _BailianCosyVoiceCallback(max(16, min(256, max_events * 4))) + synthesizer: Any | None = None + sequence = 0 + response_done = False + deadline = time.monotonic() + timeout_s + try: + synthesizer = SpeechSynthesizer( + model=str(tts["model"]), + voice=str(tts["voice"]), + format=output_format, + speech_rate=speed, + url=self.websocket_base_url, + headers={"X-DashScope-DataInspection": "disable"}, + callback=cast(Any, callback), + ) + synthesizer.streaming_call(text) + synthesizer.async_streaming_complete(int(timeout_s * 1000)) + while True: + if cancelled.is_set(): + with contextlib.suppress(Exception): + synthesizer.streaming_cancel() + return + if callback.overflowed: + raise AIProviderError( + "TTS_BACKPRESSURE", + "Bailian TTS callback queue exceeded its bound", + ) + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AIProviderError( + "TTS_TIMEOUT", "Bailian TTS response timed out", True + ) + try: + event = callback.events.get(timeout=min(0.25, remaining)) + except queue.Empty: + continue + event_type = event.get("type") + if event_type == "__audio__": + raw_audio = event.get("audio", b"") + if ( + not isinstance(raw_audio, bytes) + or not raw_audio + or len(raw_audio) % 2 + ): + raise AIProviderError( + "TTS_AUDIO_INVALID", "Bailian returned invalid PCM audio" + ) + audio = ( + pcm16_to_alaw(raw_audio, sample_rate, sample_rate) + if encoding == "pcma" + else raw_audio + ) + yield StreamChunk( + sequence=sequence, + audio=audio, + late=cancelled.is_set(), + provider_model=callback.provider_model, + ) + sequence += 1 + elif event_type == "__error__": + raise AIProviderError( + "TTS_PROVIDER_ERROR", event.get("message", "Bailian TTS failed") + ) + elif event_type == "__event_invalid__": + raise AIProviderError( + "TTS_EVENT_INVALID", "Bailian returned invalid TTS metadata" + ) + elif event_type == "__event__": + provider_event = event.get("event") or {} + header = provider_event.get("header") or {} + if header.get("event") == "task-failed": + raise AIProviderError( + "TTS_PROVIDER_ERROR", + str(header.get("error_message", "Bailian TTS task failed")), + ) + if header.get("event") == "task-finished": + response_done = True + elif event_type == "__complete__": + response_done = True + elif event_type == "__closed__": + if response_done: + break + raise AIProviderError( + "TTS_CONNECTION_CLOSED", + "Bailian TTS connection closed early", + True, + ) + if response_done: + break + yield StreamChunk( + sequence=sequence, + done=True, + provider_model=callback.provider_model, + ) + except AIProviderError: + raise + except TimeoutError as exc: + raise AIProviderError( + "TTS_CONNECT_TIMEOUT", "Bailian TTS connection timed out", True + ) from exc + except Exception as exc: + raise AIProviderError( + "TTS_CONNECTION_FAILED", "Bailian TTS connection failed", True + ) from exc + finally: + if synthesizer is not None: + with contextlib.suppress(Exception): + synthesizer.close() + + @staticmethod + def _wait_for_event( + events: queue.Queue[dict[str, Any]], expected: str, timeout_s: float + ) -> bool: + try: + while True: + event = events.get(timeout=timeout_s) + event_type = event.get("type") + if event_type == expected: + return True + if event_type == "error": + error = event.get("error") or {} + raise AIProviderError( + "TTS_SESSION_ERROR", + str(error.get("message", "Bailian TTS session failed")), + ) + if event_type == "__closed__": + return False + except queue.Empty: + return False + + +def build_bailian_config( + version_id: str, + prompt: str, + model: str = "qwen-plus", + tts_model: str | None = None, + voice: str | None = None, + language: str = "zh-CN", + asr_model: str | None = None, + opening: str = "", + max_turns: int = 20, + max_duration_ms: int = 120000, +) -> dict[str, Any]: + resolved_voice = voice or os.environ.get("BAILIAN_TTS_VOICE", "") + if not resolved_voice: + raise AIConfigError( + "BAILIAN_TTS_VOICE_MISSING", "BAILIAN_TTS_VOICE is not configured" + ) + resolved_model = tts_model or os.environ.get("BAILIAN_TTS_MODEL", "") + resolved_asr_model = asr_model or os.environ.get( + "BAILIAN_ASR_MODEL", "fun-asr-realtime" + ) + if not resolved_model: + resolved_model = ( + "cosyvoice-v3.5-plus" + if resolved_voice.startswith("cosyvoice-") + else "qwen3-tts-flash-realtime" + ) + return normalize_agent_config( + { + "agent_version_id": version_id, + "immutable": True, + "llm": { + "provider_ref": "bailian", + "credential_ref": "env:BAILIAN_API_KEY", + "model": model, + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 45000, + }, + "prompt": {"text": prompt, "allowed_variables": []}, + "tts": { + "provider_ref": "bailian", + "credential_ref": "env:BAILIAN_API_KEY", + "model": resolved_model, + "voice": resolved_voice, + "speed": 1.0, + "format": { + "encoding": "pcm_s16le", + "sample_rate_hz": 24000, + "channels": 1, + }, + "timeout_ms": 45000, + }, + "asr": { + "provider_ref": "bailian", + "credential_ref": "env:BAILIAN_API_KEY", + "model": resolved_asr_model, + "language": language, + "input": { + "encoding": "pcm_s16le", + "sample_rate_hz": 16000, + "channels": 1, + "sample_width_bytes": 2, + }, + "interim": True, + "timeout_ms": 5000, + }, + "conversation": { + "opening": opening, + "allow_interrupt": True, + "silence_timeout_ms": 3000, + "max_duration_ms": max_duration_ms, + "max_turns": max_turns, + "sentence_max_chars": 80, + "max_pending_audio_chunks": 32, + }, + } + ) + + +__all__ = ["BailianASR", "BailianLLM", "BailianTTS", "build_bailian_config"] diff --git a/agent_call/cell_worker_main.py b/agent_call/cell_worker_main.py new file mode 100644 index 0000000..4ceb4ec --- /dev/null +++ b/agent_call/cell_worker_main.py @@ -0,0 +1,100 @@ +"""Long-lived Cell worker for RabbitMQ ``call.execute`` messages.""" + +from __future__ import annotations + +import json +import os +import signal +import time +from pathlib import Path +from threading import Event +from typing import Any + +from .ai_runtime import ConversationEngine, load_prompt +from .bailian import BailianASR, BailianLLM, BailianTTS, build_bailian_config +from .core import PikaBroker +from .real_cell import CellCallConfig, CellExecutionLedger, RealCellCall, RealCellWorker + + +def _profile(path: str) -> dict[str, Any]: + candidate = Path(path) + try: + value = json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RuntimeError("Cell AI profile is unavailable") from exc + if not isinstance(value, dict): + raise TypeError("Cell AI profile must be an object") + return value + + +def _integer(value: Any, default: int, field: str, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError, OverflowError) as exc: + raise RuntimeError(f"{field} must be an integer") from exc + if parsed < minimum: + raise RuntimeError(f"{field} must be at least {minimum}") + return parsed + + +def build_worker() -> RealCellWorker: + profile = _profile( + os.environ.get( + "AGENT_CALL_AI_PROFILE", "/app/configs/ai-test.bailian.example.yaml" + ) + ) + prompt_path = Path( + os.environ.get("AGENT_CALL_PROMPT_FILE", "/app/prompts/test-call.txt") + ) + prompt = load_prompt( + prompt_path, + _integer(profile.get("max_prompt_bytes", 32768), 32768, "max_prompt_bytes"), + ) + agent_version_id = str(profile.get("agent_version_id", "")) + if not agent_version_id: + raise RuntimeError("agent_version_id is required in the Cell AI profile") + config = build_bailian_config( + agent_version_id, + prompt, + str(profile.get("model", "qwen-plus")), + profile.get("tts_model"), + profile.get("voice"), + str(profile.get("language", "zh-CN")), + profile.get("asr_model"), + str(profile.get("opening", "")), + _integer(profile.get("max_turns", 20), 20, "max_turns"), + _integer(profile.get("max_duration_ms", 120000), 120000, "max_duration_ms"), + ) + asr = BailianASR.from_env() + engine = ConversationEngine( + config, + llm=BailianLLM.from_env(), + tts=BailianTTS.from_env(), + asr=asr, + ) + broker = PikaBroker(os.environ.get("RABBITMQ_URL", "")) + tenant_key = os.environ.get("AGENT_CALL_TENANT_KEY", "") + ledger = CellExecutionLedger( + os.environ.get("AGENT_CALL_EXECUTION_LEDGER", "/data/cell-executions.sqlite3") + ) + return RealCellWorker( + broker, + tenant_key, + ledger, + RealCellCall(CellCallConfig.from_env(), engine, asr), + ) + + +def main() -> int: + stopped = Event() + signal.signal(signal.SIGTERM, lambda _signum, _frame: stopped.set()) + signal.signal(signal.SIGINT, lambda _signum, _frame: stopped.set()) + worker = build_worker() + while not stopped.is_set(): + if worker.process_once() is None: + time.sleep(0.2) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/agent_call/core.py b/agent_call/core.py index 6830c3d..006030b 100644 --- a/agent_call/core.py +++ b/agent_call/core.py @@ -30,6 +30,8 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any, cast +from .ai_runtime import AIConfigError, ConversationEngine, normalize_agent_config +from .ai_runtime import config_digest as ai_config_digest from .mocks import MockAI, MockARI, MockRTP, MockSIPPeer, ProtocolJournal from .sip_readonly import SipReadonlyClient, SipReadonlyError @@ -187,6 +189,17 @@ CREATE TABLE IF NOT EXISTS tasks ( revision INTEGER NOT NULL, updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS agent_versions ( + agent_version_id TEXT NOT NULL, + tenant_id TEXT NOT NULL REFERENCES tenants(tenant_id), + config_json TEXT NOT NULL, + content_sha256 TEXT NOT NULL, + immutable INTEGER NOT NULL DEFAULT 1 CHECK(immutable = 1), + created_at TEXT NOT NULL, + published_at TEXT NOT NULL, + created_by TEXT NOT NULL, + PRIMARY KEY(tenant_id, agent_version_id) +); CREATE TABLE IF NOT EXISTS commands ( id INTEGER PRIMARY KEY AUTOINCREMENT, command_id TEXT NOT NULL, @@ -230,6 +243,7 @@ CREATE TABLE IF NOT EXISTS executions ( payload_hash TEXT NOT NULL, status TEXT NOT NULL, call_id TEXT, + agent_version_id TEXT, created_at TEXT NOT NULL, UNIQUE(tenant_id, execution_id) ); @@ -244,6 +258,9 @@ CREATE TABLE IF NOT EXISTS calls ( cell_id TEXT, trunk_id TEXT, egress_pool_id TEXT, + agent_version_id TEXT, + agent_config_sha256 TEXT, + ai_metrics_json TEXT, call_state TEXT NOT NULL, call_version INTEGER NOT NULL DEFAULT 1, outcome TEXT, @@ -461,12 +478,16 @@ class Store: version = int(version_row["version"] or 0) if version < 2 and not self._has_column(con, "commands", "id"): self._migrate_identity_keys(con) - if version < 3: + if version < 4: self._add_compatibility_columns(con) con.execute( "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES(3, ?)", (iso(),), ) + con.execute( + "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES(4, ?)", + (iso(),), + ) finally: con.close() @@ -619,6 +640,12 @@ class Store: def _add_compatibility_columns(self, con: sqlite3.Connection) -> None: additions = { + "executions": (("agent_version_id", "TEXT"),), + "calls": ( + ("agent_version_id", "TEXT"), + ("agent_config_sha256", "TEXT"), + ("ai_metrics_json", "TEXT"), + ), "published_commands": ( ("retry_attempts", "INTEGER NOT NULL DEFAULT 0"), ("next_attempt_at", "TEXT"), @@ -631,6 +658,22 @@ class Store: "leases": (("generation", "INTEGER NOT NULL DEFAULT 0"),), } alter_sql = { + ( + "executions", + "agent_version_id", + ): "ALTER TABLE executions ADD COLUMN agent_version_id TEXT", + ( + "calls", + "agent_version_id", + ): "ALTER TABLE calls ADD COLUMN agent_version_id TEXT", + ( + "calls", + "agent_config_sha256", + ): "ALTER TABLE calls ADD COLUMN agent_config_sha256 TEXT", + ( + "calls", + "ai_metrics_json", + ): "ALTER TABLE calls ADD COLUMN ai_metrics_json TEXT", ( "published_commands", "retry_attempts", @@ -1241,6 +1284,27 @@ class AgentCallService: ) seen_ids.add(tenant_id) seen_keys.add(tenant_key) + agents = self.profile.get("agents", []) + if not isinstance(agents, list) or not agents: + raise ConfigurationError("at least one immutable agent version is required") + seen_agent_ids: set[str] = set() + for agent in agents: + agent_id = ( + agent.get("agent_version_id") if isinstance(agent, dict) else None + ) + if ( + not isinstance(agent_id, str) + or not agent_id + or agent_id in seen_agent_ids + ): + raise ConfigurationError("agent_version_id values must be unique") + try: + normalize_agent_config(agent, agent_id) + except AIConfigError as exc: + raise ConfigurationError( + f"invalid agent version {agent_id}: {exc.detail}" + ) from exc + seen_agent_ids.add(agent_id) if not self.profile.get("cells"): raise ConfigurationError("at least one cell is required") for cell in self.profile["cells"]: @@ -1274,6 +1338,23 @@ class AgentCallService: now, ), ) + for tenant in self.profile.get("tenants", []): + for agent in self.profile.get("agents", []): + agent_id = agent["agent_version_id"] + normalized = normalize_agent_config(agent, agent_id) + con.execute( + "INSERT OR IGNORE INTO agent_versions(agent_version_id, tenant_id, config_json, content_sha256, immutable, created_at, published_at, created_by) VALUES(?,?,?,?,?,?,?,?)", + ( + agent_id, + tenant["tenant_id"], + canonical(normalized), + ai_config_digest(normalized), + 1, + now, + now, + "profile", + ), + ) for tenant in self.profile.get("tenants", []): try: self.broker.declare_tenant(tenant["tenant_key"]) @@ -1358,6 +1439,112 @@ class AgentCallService: def _limits(self) -> dict[str, Any]: return self.profile.get("limits", {}) + def publish_agent_version( + self, + tenant_id: str, + agent_version_id: str, + config: dict[str, Any], + actor_id: str = "internal", + ) -> dict[str, Any]: + self._tenant(tenant_id) + if not isinstance(config, dict): + raise ValidationError( + "AI_CONFIG_INVALID", "agent version config must be an object" + ) + if config.get("agent_version_id") not in {None, agent_version_id}: + raise ValidationError( + "AI_VERSION_ID_MISMATCH", + "config agent_version_id does not match the publish request", + ) + try: + normalized = normalize_agent_config(config, agent_version_id) + except AIConfigError as exc: + raise ValidationError(exc.code, exc.detail) from exc + content_sha256 = ai_config_digest(normalized) + now = iso(self._now()) + existing = self.store.one( + "SELECT * FROM agent_versions WHERE tenant_id=? AND agent_version_id=?", + (tenant_id, agent_version_id), + ) + if existing: + if existing["content_sha256"] != content_sha256: + raise ConflictError( + "AI_VERSION_IMMUTABLE_CONFLICT", + "published agent_version_id cannot be overwritten with different content", + ) + return { + "tenant_id": tenant_id, + "agent_version_id": agent_version_id, + "status": "reused", + "immutable": True, + "content_sha256": content_sha256, + } + with self.store.tx() as con: + con.execute( + "INSERT INTO agent_versions(agent_version_id, tenant_id, config_json, content_sha256, immutable, created_at, published_at, created_by) VALUES(?,?,?,?,?,?,?,?)", + ( + agent_version_id, + tenant_id, + canonical(normalized), + content_sha256, + 1, + now, + now, + actor_id, + ), + ) + return { + "tenant_id": tenant_id, + "agent_version_id": agent_version_id, + "status": "published", + "immutable": True, + "content_sha256": content_sha256, + } + + def get_agent_version( + self, tenant_id: str, agent_version_id: str + ) -> dict[str, Any]: + self._tenant(tenant_id) + row = self.store.one( + "SELECT * FROM agent_versions WHERE tenant_id=? AND agent_version_id=?", + (tenant_id, agent_version_id), + ) + if row is None: + raise NotFoundError("agent version not found") + raw_config = _loads(row["config_json"], {}) + if ( + row["immutable"] != 1 + or raw_config.get("agent_version_id") != agent_version_id + ): + raise ConfigurationError("stored agent version identity is invalid") + try: + config = normalize_agent_config(raw_config, agent_version_id) + except (AIConfigError, ServiceError) as exc: + raise ConfigurationError("stored agent version is invalid") from exc + if ai_config_digest(config) != row["content_sha256"]: + raise ConfigurationError("stored agent version digest does not match") + return { + "tenant_id": tenant_id, + "agent_version_id": agent_version_id, + "immutable": True, + "content_sha256": row["content_sha256"], + "created_at": row["created_at"], + "published_at": row["published_at"], + "created_by": row["created_by"], + "config": config, + } + + def _agent_config_for( + self, tenant_id: str, agent_version_id: str + ) -> dict[str, Any]: + return cast( + dict[str, Any], + self.get_agent_version(tenant_id, agent_version_id)["config"], + ) + + def _conversation_engine(self, config: dict[str, Any]) -> ConversationEngine: + return ConversationEngine(config, journal=self.journal) + @staticmethod def _operation_hash(operation: str, target: str, body: dict[str, Any]) -> str: return digest({"operation": operation, "target": target, "body": body}) @@ -1845,11 +2032,13 @@ class AgentCallService: if x.get("allowed", True) }: status, reason = "rejected", "CALLER_PROFILE_NOT_ALLOWED" - elif payload["agent_version_id"] not in { - x["agent_version_id"] - for x in self.profile.get("agents", []) - if x.get("immutable", True) - }: + elif ( + con.execute( + "SELECT 1 FROM agent_versions WHERE tenant_id=? AND agent_version_id=?", + (tenant["tenant_id"], payload["agent_version_id"]), + ).fetchone() + is None + ): status, reason = "rejected", "AGENT_VERSION_NOT_ALLOWED" execution = con.execute( "SELECT * FROM executions WHERE execution_id = ? AND tenant_id = ?", @@ -1887,7 +2076,7 @@ class AgentCallService: ) if status == "accepted": con.execute( - "INSERT INTO executions(execution_id, tenant_id, command_id, task_id, task_item_id, payload_hash, status, created_at) VALUES(?,?,?,?,?,?,?,?)", + "INSERT INTO executions(execution_id, tenant_id, command_id, task_id, task_item_id, payload_hash, status, agent_version_id, created_at) VALUES(?,?,?,?,?,?,?,?,?)", ( payload["execution_id"], tenant["tenant_id"], @@ -1896,6 +2085,7 @@ class AgentCallService: payload["task_item_id"], digest(payload), "accepted", + payload["agent_version_id"], iso(now), ), ) @@ -1917,6 +2107,9 @@ class AgentCallService: "task_id": row["task_id"], "execution_id": row["execution_id"], "call_id": row["call_id"], + "agent_version_id": _loads(row["payload_json"], {}) + .get("payload", {}) + .get("agent_version_id"), "requested_task_revision": row["requested_task_revision"], "applied_task_revision": row["applied_task_revision"], "wait_reason_code": row["wait_reason_code"], @@ -1984,6 +2177,7 @@ class AgentCallService: return body def _command_snapshot(self, row: sqlite3.Row | dict[str, Any]) -> dict[str, Any]: + payload = _loads(row["payload_json"], {}).get("payload", {}) return { "command_id": row["command_id"], "command_type": row["command_type"], @@ -1992,6 +2186,7 @@ class AgentCallService: "task_id": row["task_id"], "execution_id": row["execution_id"], "call_id": row["call_id"], + "agent_version_id": payload.get("agent_version_id"), "status": row["status"], "reason_code": row["reason_code"], "wait_reason_code": row["wait_reason_code"], @@ -2319,6 +2514,16 @@ class AgentCallService: raise ValidationError( "CALLER_NOT_ALLOWED", "caller is not allowed by the published Trunk" ) + try: + agent_config = self._agent_config_for( + row["tenant_id"], payload["agent_version_id"] + ) + except NotFoundError as exc: + raise ValidationError( + "AGENT_VERSION_NOT_ALLOWED", + "agent version is not available for this tenant", + ) from exc + agent_config_sha256 = ai_config_digest(agent_config) call_id = new_id("call") attempt_id = new_id("attempt") now_dt = self._now() @@ -2399,7 +2604,7 @@ class AgentCallService: if changed != 1: return False con.execute( - "INSERT INTO calls(call_id, tenant_id, execution_id, command_id, task_id, task_item_id, callee, cell_id, trunk_id, egress_pool_id, call_state, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO calls(call_id, tenant_id, execution_id, command_id, task_id, task_item_id, callee, cell_id, trunk_id, egress_pool_id, agent_version_id, agent_config_sha256, call_state, created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", ( call_id, current["tenant_id"], @@ -2413,6 +2618,8 @@ class AgentCallService: route.get( "egress_pool_id", cell_cfg.get("egress_pool_id", "egress-mock") ), + payload["agent_version_id"], + agent_config_sha256, "queued", now, ), @@ -2452,6 +2659,8 @@ class AgentCallService: "call_id": call_id, "execution_id": current["execution_id"], "task_id": current["task_id"], + "agent_version_id": payload["agent_version_id"], + "agent_config_sha256": agent_config_sha256, "call_state": "queued", "reason_code": None, }, @@ -2568,25 +2777,67 @@ class AgentCallService: if scenario == "ari_disconnect": self._mark_reconciling(call_id, "ARI_DISCONNECTED_RECONCILING") return + call = self.store.one("SELECT * FROM calls WHERE call_id=?", (call_id,)) + if call is None: + return + agent_config = self._agent_config_for( + call["tenant_id"], call["agent_version_id"] or "agent_v1" + ) + conversation = self._conversation_engine(agent_config) + ai_metrics: dict[str, Any] = { + "mode": "mock", + "turns": 0, + "discarded_late_chunks": 0, + "llm_first_token_ms": [], + "tts_first_audio_ms": [], + } + opening = agent_config["conversation"].get("opening", "") + if opening: + opening_result = conversation.speak(opening, f"opening_{call_id}") + self._add_transcript( + call_id, + -1, + "agent", + opening, + opening_result["playback_state"], + ) turns = _integer(self._limits().get("turns", 2)) for turn in range(turns): if self._call_is_ended(call_id): return + customer_text = f"customer turn {turn + 1}" self._add_transcript( - call_id, - turn, - "customer", - f"customer turn {turn + 1}", - "not_applicable", + call_id, turn, "customer", customer_text, "not_applicable" ) + result = conversation.run_text(customer_text) + if result.get("status") != "completed": + self._finish_call( + call_id, + "failed", + str(result.get("reason_code", "AI_GENERATION_FAILED")), + ) + return playback = ( - "cancelled" - if scenario == "ai_timeout" and turn == turns - 1 - else "playback_confirmed" + result.get("segments", [{}])[-1].get("playback_state", "sent") + if result.get("segments") + else "sent" ) self._add_transcript( - call_id, turn, "agent", f"agent turn {turn + 1}", playback + call_id, turn, "agent", str(result.get("text", "")), playback ) + ai_metrics["turns"] += 1 + ai_metrics["discarded_late_chunks"] += int( + result.get("discarded_late_chunks", 0) + ) + for metric in ("llm_first_token_ms", "tts_first_audio_ms"): + value = result.get(metric) + if value is not None: + ai_metrics[metric].append(value) + with self.store.tx() as con: + con.execute( + "UPDATE calls SET ai_metrics_json=? WHERE call_id=?", + (canonical(ai_metrics), call_id), + ) self._finish_call( call_id, "completed", "CALL_COMPLETED", upload_scenario=scenario ) @@ -4180,6 +4431,9 @@ class AgentCallService: "execution_id": call["execution_id"], "task_id": call["task_id"], "task_item_id": call["task_item_id"], + "agent_version_id": call["agent_version_id"], + "agent_config_sha256": call["agent_config_sha256"], + "ai": _loads(call["ai_metrics_json"], {}), "call_state": call["call_state"], "call_version": call["call_version"], "outcome": call["outcome"], diff --git a/agent_call/http.py b/agent_call/http.py index 3dcbcb7..000a014 100644 --- a/agent_call/http.py +++ b/agent_call/http.py @@ -17,6 +17,8 @@ CONTROL_SCOPE = "outbound.control" UPLOAD_SCOPE = "recording.upload" COMPLETE_SCOPE = "recording.complete" REPLAY_SCOPE = "outbound.replay" +AI_CONFIG_READ_SCOPE = "ai.config.read" +AI_CONFIG_PUBLISH_SCOPE = "ai.config.publish" class AgentCallHandler(BaseHTTPRequestHandler): @@ -39,7 +41,12 @@ class AgentCallHandler(BaseHTTPRequestHandler): raise ServiceError("TENANT_ID_REQUIRED", "X-Tenant-ID is required", 400) return value - def _authorize(self, scope: str, tenant_id: str | None = None) -> dict[str, Any]: + def _authorize( + self, + scope: str, + tenant_id: str | None = None, + auth_domain: str | None = None, + ) -> dict[str, Any]: token_header = self.headers.get("Authorization", "") if not token_header.startswith("Bearer "): raise ServiceError("UNAUTHORIZED", "bearer authentication is required", 401) @@ -62,6 +69,23 @@ class AgentCallHandler(BaseHTTPRequestHandler): raise ServiceError("UNAUTHORIZED", "token tenant scope is invalid", 401) if scope not in scopes and "*" not in scopes: raise ServiceError("FORBIDDEN", "required scope is missing", 403) + if auth_domain is not None: + issuer = os.environ.get("AI_CONFIG_ISSUER") + audience = os.environ.get("AI_CONFIG_AUDIENCE") + if not issuer or not audience: + raise ServiceError( + "UNAUTHORIZED", + "AI configuration authentication domain is not configured", + 401, + ) + if identity.get("auth_domain") != auth_domain: + raise ServiceError( + "UNAUTHORIZED", "token authentication domain is invalid", 401 + ) + if identity.get("issuer") != issuer or identity.get("audience") != audience: + raise ServiceError( + "UNAUTHORIZED", "token issuer or audience is invalid", 401 + ) if ( tenant_id is not None and "*" not in tenant_ids @@ -174,6 +198,40 @@ class AgentCallHandler(BaseHTTPRequestHandler): method == "PUT" and len(parts) == 3 and parts[:2] == ["_mock", "oss"] ): request_id = self._request_id() + if ( + method == "POST" + and len(parts) == 4 + and parts == ["internal", "v1", "ai", "agent-versions"] + ): + tenant_id = self._tenant_id() + self._authorize(AI_CONFIG_PUBLISH_SCOPE, tenant_id, "ai-config") + body = self._body() + if ( + set(body) != {"agent_version_id", "config"} + or not isinstance(body["agent_version_id"], str) + or not isinstance(body["config"], dict) + ): + raise ValidationError( + "SCHEMA_INVALID", + "agent version publish requires agent_version_id and config", + ) + result = self.service.publish_agent_version( + tenant_id, + body["agent_version_id"], + body["config"], + self._actor_id(), + ) + self._send(201 if result["status"] == "published" else 200, result) + return + if ( + method == "GET" + and len(parts) == 5 + and parts[:4] == ["internal", "v1", "ai", "agent-versions"] + ): + tenant_id = self._tenant_id() + self._authorize(AI_CONFIG_READ_SCOPE, tenant_id, "ai-config") + self._send(200, self.service.get_agent_version(tenant_id, parts[4])) + return if ( method == "GET" and len(parts) == 5 diff --git a/agent_call/real_cell.py b/agent_call/real_cell.py new file mode 100644 index 0000000..307bb2d --- /dev/null +++ b/agent_call/real_cell.py @@ -0,0 +1,990 @@ +"""Real Cell ARI/RTP call runner. + +This is intentionally a small Cell-side adapter: RabbitMQ owns the business +command, while this module owns one already-authorized call's ARI channels, +RTP media, AI turns, and recording. No provider credential or SIP endpoint is +accepted from the command payload. +""" + +from __future__ import annotations + +import base64 +import contextlib +import http.client +import json +import os +import queue +import socket +import sqlite3 +import ssl +import struct +import threading +import time +import uuid +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.parse import urlencode, urlsplit + +from .ai_runtime import ( + AIProviderError, + ConversationEngine, + pcm16_to_alaw, + pcm_to_wav, +) +from .core import BrokerError + + +class CellCallError(RuntimeError): + """A real Cell call failed without permitting a silent 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 CellCallConfig: + """Trusted, Cell-local settings; none are read from ``call.execute``.""" + + ari_url: str + ari_username: str + ari_password: str + ari_app: str = "agent-call" + trunk_id: str = "provider-primary" + caller_id: str = "BD93205882" + rtp_bind_host: str = "127.0.0.1" + rtp_bind_port: int = 0 + rtp_format: str = "alaw" + event_timeout_s: float = 45.0 + first_speech_timeout_s: float = 10.0 + max_turn_s: float = 12.0 + end_silence_s: float = 0.9 + voice_threshold: int = 350 + recording_dir: str = "/data/recordings" + + def __post_init__(self) -> None: + parsed = urlsplit(self.ari_url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise CellCallError( + "ARI_URL_INVALID", "Cell ARI URL must be an HTTP(S) URL" + ) + if not self.ari_username or not self.ari_password: + raise CellCallError( + "ARI_CREDENTIALS_MISSING", "Cell ARI credentials are required" + ) + if not self.ari_app or not self.trunk_id or not self.caller_id: + raise CellCallError( + "CELL_CONFIG_INVALID", "Cell ARI/trunk identity is incomplete" + ) + if self.rtp_format != "alaw": + raise CellCallError( + "RTP_FORMAT_UNSUPPORTED", "real Cell currently requires PCMA/alaw" + ) + if not 0 <= self.rtp_bind_port <= 65535: + raise CellCallError("RTP_PORT_INVALID", "RTP bind port is invalid") + if self.event_timeout_s <= 0 or self.first_speech_timeout_s <= 0: + raise CellCallError( + "CELL_TIMEOUT_INVALID", "Cell timeouts must be positive" + ) + + @classmethod + def from_env(cls) -> CellCallConfig: + try: + rtp_bind_port = int(os.environ.get("CELL_RTP_BIND_PORT", "0")) + except (TypeError, ValueError) as exc: + raise CellCallError( + "RTP_PORT_INVALID", "CELL_RTP_BIND_PORT is invalid" + ) from exc + return cls( + ari_url=os.environ.get("CELL_ARI_URL", "http://127.0.0.1:8088"), + ari_username=os.environ.get("CELL_ARI_USERNAME", "outbound"), + ari_password=os.environ.get("CELL_ARI_PASSWORD", ""), + ari_app=os.environ.get("CELL_ARI_APP", "agent-call"), + trunk_id=os.environ.get("CELL_SIP_TRUNK_ID", "provider-primary"), + caller_id=os.environ.get("CELL_CALLER_ID", "BD93205882"), + rtp_bind_host=os.environ.get("CELL_RTP_BIND_HOST", "127.0.0.1"), + rtp_bind_port=rtp_bind_port, + recording_dir=os.environ.get("CELL_RECORDING_DIR", "/data/recordings"), + ) + + +class ARIClient: + def __init__(self, config: CellCallConfig) -> None: + parsed = urlsplit(config.ari_url) + self.scheme = parsed.scheme + self.host = parsed.hostname or "" + self.port = parsed.port or (443 if self.scheme == "https" else 80) + self.prefix = parsed.path.rstrip("/") or "/ari" + self.username = config.ari_username + self.password = config.ari_password + self.timeout_s = config.event_timeout_s + + def request( + self, + method: str, + resource: str, + params: Mapping[str, Any] | None = None, + body: bytes | None = None, + content_type: str = "application/json", + ) -> dict[str, Any] | bytes: + path = self.prefix + "/" + resource.lstrip("/") + if params: + encoded = urlencode( + [ + (key, str(value).lower() if isinstance(value, bool) else str(value)) + for key, value in params.items() + ] + ) + path += "?" + encoded + auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode( + "ascii" + ) + connection: http.client.HTTPConnection | http.client.HTTPSConnection + if self.scheme == "https": + connection = http.client.HTTPSConnection( + self.host, + self.port, + timeout=self.timeout_s, + context=ssl.create_default_context(), + ) + else: + connection = http.client.HTTPConnection( + self.host, self.port, timeout=self.timeout_s + ) + try: + connection.request( + method, + path, + body=body, + headers={ + "Authorization": f"Basic {auth}", + "Accept": "application/json, audio/wav", + **({"Content-Type": content_type} if body is not None else {}), + }, + ) + response = connection.getresponse() + data = response.read() + if response.status >= 400: + raise CellCallError( + f"ARI_HTTP_{response.status}", + f"ARI returned HTTP {response.status}", + retryable=response.status == 409 or response.status >= 500, + ) + if not data: + return {} + if "json" in (response.getheader("Content-Type") or ""): + try: + parsed = json.loads(data.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CellCallError( + "ARI_RESPONSE_INVALID", "ARI returned invalid JSON" + ) from exc + if not isinstance(parsed, dict): + raise CellCallError( + "ARI_RESPONSE_INVALID", "ARI returned a non-object response" + ) + return parsed + return data + except CellCallError: + raise + except (TimeoutError, OSError, http.client.HTTPException) as exc: + raise CellCallError( + "ARI_CONNECTION_FAILED", "ARI request failed", True + ) from exc + finally: + connection.close() + + def events(self, app: str) -> Any: + try: + import websocket + except ImportError as exc: # pragma: no cover - dependency gate + raise CellCallError( + "WEBSOCKET_CLIENT_MISSING", "websocket-client is required for ARI" + ) from exc + parsed_scheme = "wss" if self.scheme == "https" else "ws" + url = f"{parsed_scheme}://{self.host}:{self.port}{self.prefix}/events?{urlencode({'app': app})}" + auth = base64.b64encode(f"{self.username}:{self.password}".encode()).decode( + "ascii" + ) + try: + return websocket.create_connection( + url, + header=[f"Authorization: Basic {auth}"], + timeout=self.timeout_s, + http_no_proxy=[self.host, "localhost", "127.0.0.1"], + ) + except Exception as exc: + raise CellCallError( + "ARI_EVENTS_FAILED", "ARI event connection failed", True + ) from exc + + +class RTPMedia: + """Minimal RTP/PCMA endpoint for an Asterisk externalMedia channel.""" + + def __init__(self, host: str, port: int) -> None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + self.socket.bind((host, port)) + except OSError: + self.socket.close() + raise + self.peer: tuple[str, int] | None = None + self.sequence = 0 + self.timestamp = 0 + self.ssrc = uuid.uuid4().int & 0xFFFFFFFF + self._send_lock = threading.Lock() + + @property + def address(self) -> tuple[str, int]: + value = self.socket.getsockname() + try: + return str(value[0]), int(value[1]) + except (IndexError, TypeError, ValueError) as exc: + raise CellCallError( + "RTP_ADDRESS_INVALID", "RTP socket address is invalid" + ) from exc + + def close(self) -> None: + with contextlib.suppress(OSError): + self.socket.close() + + def receive(self, timeout_s: float) -> bytes | None: + self.socket.settimeout(max(0.0, timeout_s)) + try: + packet, peer = self.socket.recvfrom(2048) + except (TimeoutError, ConnectionRefusedError): + return None + except OSError as exc: + if self.socket.fileno() < 0: + return None + raise CellCallError( + "RTP_RECEIVE_FAILED", "RTP receive failed", True + ) from exc + payload = self._payload(packet) + if payload is None: + return None + try: + self.peer = (str(peer[0]), int(peer[1])) + except (IndexError, TypeError, ValueError) as exc: + raise CellCallError( + "RTP_PEER_INVALID", "RTP peer address is invalid" + ) from exc + return payload + + def send_pcm16(self, raw: bytes, input_rate_hz: int = 24000) -> int: + if not raw or len(raw) % 2: + return 0 + if self.peer is None: + raise CellCallError( + "RTP_PEER_MISSING", "no inbound RTP peer has been observed" + ) + payload = pcm16_to_alaw(raw, input_rate_hz, 8000) + sent = 0 + with self._send_lock: + for offset in range(0, len(payload), 160): + frame = payload[offset : offset + 160] + if not frame: + continue + header = struct.pack( + "!BBHII", + 0x80, + 0x80 | 8 if offset == 0 else 8, + self.sequence & 0xFFFF, + self.timestamp & 0xFFFFFFFF, + self.ssrc, + ) + try: + self.socket.sendto(header + frame, self.peer) + except OSError as exc: + raise CellCallError( + "RTP_SEND_FAILED", "RTP send failed", True + ) from exc + self.sequence += 1 + self.timestamp += 160 + sent += len(frame) + time.sleep(0.02) + return sent + + @staticmethod + def _payload(packet: bytes) -> bytes | None: + if len(packet) < 12 or packet[0] >> 6 != 2: + return None + cc = packet[0] & 0x0F + offset = 12 + cc * 4 + if len(packet) < offset: + return None + if packet[0] & 0x10: + if len(packet) < offset + 4: + return None + extension_words = struct.unpack("!H", packet[offset + 2 : offset + 4])[0] + offset += 4 + extension_words * 4 + if len(packet) < offset: + return None + end = len(packet) + if packet[0] & 0x20: + padding = packet[-1] + if not padding or padding > end - offset: + return None + end -= padding + return packet[offset:end] or None + + +def alaw_to_pcm16(payload: bytes) -> bytes: + output = bytearray() + for encoded in payload: + value = encoded ^ 0x55 + magnitude = ((value & 0x0F) << 4) + 8 + exponent = (value & 0x70) >> 4 + if exponent: + magnitude = (magnitude + 0x100) << (exponent - 1) + sample = -magnitude if value & 0x80 else magnitude + try: + output.extend(int(sample).to_bytes(2, "little", signed=True)) + except OverflowError as exc: + raise CellCallError( + "RTP_AUDIO_INVALID", "decoded PCMA sample is out of range" + ) from exc + return bytes(output) + + +def voice_level(raw_pcm16: bytes) -> int: + if len(raw_pcm16) < 2: + return 0 + samples = [ + int.from_bytes(raw_pcm16[index : index + 2], "little", signed=True) + for index in range(0, len(raw_pcm16) - 1, 2) + ] + return max(abs(sample) for sample in samples) if samples else 0 + + +@dataclass +class CellCallResult: + call_id: str + status: str + reason_code: str + turns: list[dict[str, Any]] = field(default_factory=list) + recording_path: str | None = None + connected: bool = False + media_packets_in: int = 0 + media_packets_out: int = 0 + started_at: float = 0.0 + ended_at: float = 0.0 + + def as_dict(self) -> dict[str, Any]: + return { + "call_id": self.call_id, + "status": self.status, + "reason_code": self.reason_code, + "turns": self.turns, + "recording_path": self.recording_path, + "connected": self.connected, + "media_packets_in": self.media_packets_in, + "media_packets_out": self.media_packets_out, + "duration_ms": max(0.0, (self.ended_at - self.started_at) * 1000), + } + + +class RealCellCall: + """Execute one authorized call using a fixed Cell-local trunk.""" + + def __init__( + self, config: CellCallConfig, engine: ConversationEngine, asr: Any + ) -> None: + self.config = config + self.engine = engine + self.asr = asr + self.ari = ARIClient(config) + self.call_id = f"call_{uuid.uuid4().hex}" + self.events: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=256) + self.event_stop = threading.Event() + self.event_thread: threading.Thread | None = None + self.ws: Any | None = None + self.media: RTPMedia | None = None + self.bridge_id = f"bridge_{self.call_id}" + self.external_channel_id: str | None = None + self.target_channel_id: str | None = None + self.recording_name = f"agent-call-{self.call_id}" + self._known_channels: set[str] = set() + self._prebuffer: list[bytes] = [] + self._call_ended = False + self._in_packets = 0 + self._out_bytes = 0 + + def _reset_call(self) -> None: + self.call_id = f"call_{uuid.uuid4().hex}" + self.event_stop = threading.Event() + self.event_thread = None + self.ws = None + self.media = None + self.bridge_id = f"bridge_{self.call_id}" + self.external_channel_id = None + self.target_channel_id = None + self.recording_name = f"agent-call-{self.call_id}" + self._known_channels.clear() + self._prebuffer.clear() + self._call_ended = False + self._in_packets = 0 + self._out_bytes = 0 + + def start_authorized_call(self, callee: str) -> CellCallResult: + if not callee.isdigit() or not 3 <= len(callee) <= 32: + raise CellCallError( + "CALLEE_INVALID", "callee must be the original digit string" + ) + self._reset_call() + self.engine.history.clear() + result = CellCallResult( + self.call_id, + "failed", + "CELL_EXECUTOR_EXCEPTION", + started_at=time.monotonic(), + ) + try: + self.media = RTPMedia(self.config.rtp_bind_host, self.config.rtp_bind_port) + self.ws = self.ari.events(self.config.ari_app) + self._start_event_reader() + self.ari.request("POST", f"bridges/{self.bridge_id}", {"type": "mixing"}) + external = self.ari.request( + "POST", + "channels/externalMedia", + { + "app": self.config.ari_app, + "external_host": f"{self.media.address[0]}:{self.media.address[1]}", + "format": self.config.rtp_format, + "direction": "both", + "channelId": f"external_{self.call_id}", + }, + ) + self.external_channel_id = self._channel_id(external) + self._add_when_ready(self.external_channel_id) + target = self.ari.request( + "POST", + "channels", + { + "endpoint": f"PJSIP/{'7089' + callee}@{self.config.trunk_id}", + "app": self.config.ari_app, + "appArgs": self.call_id, + "callerId": self.config.caller_id, + "timeout": 30000, + "channelId": f"target_{self.call_id}", + }, + ) + self.target_channel_id = self._channel_id(target) + self._add_when_ready(self.target_channel_id) + self.ari.request( + "POST", + f"bridges/{self.bridge_id}/record", + { + "name": self.recording_name, + "format": "wav", + "ifExists": "fail", + "beepEnabled": False, + }, + ) + self._wait_answer(result) + result.connected = True + opening = str(self.engine.config["conversation"].get("opening", "")) + if opening: + opening_result = self.engine.speak(opening, f"opening_{self.call_id}") + self._play(opening_result["audio"]) + result.media_packets_out = self._out_bytes // 160 + max_turns = int(self.engine.config["conversation"]["max_turns"]) + for turn_index in range(max_turns): + captured = self._capture_turn( + self.config.first_speech_timeout_s + if turn_index == 0 + else self.config.end_silence_s * 2 + ) + if self._call_ended: + result.reason_code = "CUSTOMER_HANGUP" + result.status = "completed" if result.turns else "failed" + break + if not captured: + result.reason_code = ( + "CUSTOMER_SILENT" + if turn_index == 0 + else "CONVERSATION_COMPLETE" + ) + result.status = "completed" if turn_index else "failed" + break + wav = pcm_to_wav(captured, 8000) + segments = list( + self.asr.transcribe( + wav, self.engine.config, f"turn_{turn_index}_{self.call_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 CellCallError( + "ASR_NO_FINAL", "ASR did not produce a final telephone turn" + ) + text = str(final["text"]) + response = self.engine.run_text(text) + if response.get("status") != "completed": + raise CellCallError( + str(response.get("reason_code", "AI_GENERATION_FAILED")), + "AI turn failed", + ) + self._play(response.get("audio", b"")) + result.media_packets_out = self._out_bytes // 160 + result.turns.append( + { + "turn_index": turn_index, + "customer_text": text, + "assistant_text": response.get("text", ""), + "asr_segments": segments, + "llm_first_token_ms": response.get("llm_first_token_ms"), + "tts_first_audio_ms": response.get("tts_first_audio_ms"), + } + ) + else: + result.status = "completed" + result.reason_code = "MAX_TURNS" + if ( + result.status == "failed" + and result.reason_code == "CELL_EXECUTOR_EXCEPTION" + ): + result.reason_code = ( + "CALL_COMPLETED" if result.turns else result.reason_code + ) + except (CellCallError, AIProviderError) as exc: + result.status = "failed" + result.reason_code = ( + exc.code + if isinstance(exc, (CellCallError, AIProviderError)) + else "AI_PROVIDER_ERROR" + ) + except (OSError, RuntimeError, TypeError, ValueError, KeyError) as exc: + del exc + result.status = "failed" + result.reason_code = "CELL_EXECUTOR_EXCEPTION" + finally: + result.ended_at = time.monotonic() + result.media_packets_in = self._in_packets + result.media_packets_out = self._out_bytes // 160 + result.recording_path = self._finish_recording() + self._cleanup() + return result + + def _start_event_reader(self) -> None: + self.event_thread = threading.Thread( + target=self._read_events, name=f"ari-events-{self.call_id}", daemon=True + ) + self.event_thread.start() + + def _read_events(self) -> None: + try: + import websocket + except ImportError: + return + while not self.event_stop.is_set() and self.ws is not None: + try: + raw = self.ws.recv() + except ( + OSError, + RuntimeError, + TimeoutError, + ValueError, + websocket.WebSocketException, + ): + return + if not raw: + continue + try: + event = json.loads(raw) + except (TypeError, json.JSONDecodeError): + continue + if not isinstance(event, dict): + continue + try: + self.events.put_nowait(event) + except queue.Full: + return + + def _wait_answer(self, result: CellCallResult) -> None: + deadline = time.monotonic() + self.config.event_timeout_s + while time.monotonic() < deadline: + try: + event = self.events.get(timeout=min(0.5, deadline - time.monotonic())) + except queue.Empty: + continue + event_type = event.get("type") + channel = event.get("channel") or {} + channel_id = channel.get("id") + if event_type == "StasisStart" and isinstance(channel_id, str): + self._add_when_ready(channel_id) + if ( + event_type == "ChannelStateChange" + and channel_id == self.target_channel_id + and channel.get("state") == "Up" + ): + return + if ( + event_type in {"ChannelDestroyed", "StasisEnd", "ChannelHangupRequest"} + and channel_id == self.target_channel_id + ): + self._call_ended = True + raise CellCallError( + "CALL_NOT_ANSWERED", "target channel ended before answer" + ) + raise CellCallError( + "CALL_ANSWER_TIMEOUT", "ARI did not report an answered channel", True + ) + + def _capture_turn(self, initial_timeout_s: float) -> bytes: + if self.media is None: + raise CellCallError("RTP_UNAVAILABLE", "RTP media is not initialized") + started = bool(self._prebuffer) + last_voice = time.monotonic() if started else 0.0 + started_at = time.monotonic() + deadline = started_at + max(initial_timeout_s, self.config.end_silence_s) + frames = self._prebuffer + self._prebuffer = [] + while time.monotonic() < deadline: + if self._drain_terminal_events(): + break + payload = self.media.receive( + min(0.1, max(0.0, deadline - time.monotonic())) + ) + if payload is None: + if self._drain_terminal_events(): + break + if ( + started + and time.monotonic() - last_voice >= self.config.end_silence_s + ): + break + continue + self._in_packets += 1 + pcm = alaw_to_pcm16(payload) + level = voice_level(pcm) + if level >= self.config.voice_threshold: + if not started: + started = True + deadline = min( + started_at + self.config.max_turn_s, + time.monotonic() + self.config.max_turn_s, + ) + last_voice = time.monotonic() + if started: + frames.append(pcm) + if time.monotonic() - last_voice >= self.config.end_silence_s: + break + if self.media is not None: + self.media.socket.settimeout(None) + return b"".join(frames) + + def _drain_terminal_events(self) -> bool: + while True: + try: + event = self.events.get_nowait() + except queue.Empty: + return self._call_ended + event_type = event.get("type") + channel_id = (event.get("channel") or {}).get("id") + if ( + event_type in {"ChannelDestroyed", "StasisEnd", "ChannelHangupRequest"} + and channel_id == self.target_channel_id + ): + self._call_ended = True + return True + + def _play(self, audio: bytes) -> int: + if self.media is None or not audio: + return 0 + sent = 0 + for offset in range(0, len(audio), 2 * 24000 // 50): + frame = audio[offset : offset + 2 * 24000 // 50] + if not frame: + continue + if self.engine.config["conversation"].get("allow_interrupt"): + self.media.socket.settimeout(0.0) + with contextlib.suppress(BlockingIOError, socket.timeout): + packet = self.media.receive(0.0) + if packet: + self._in_packets += 1 + pcm = alaw_to_pcm16(packet) + if voice_level(pcm) >= self.config.voice_threshold: + self._prebuffer.append(pcm) + break + bytes_sent = self.media.send_pcm16(frame, 24000) + sent += bytes_sent + self._out_bytes += bytes_sent + return sent + + def _add_when_ready(self, channel_id: str | None) -> None: + if not channel_id or channel_id in self._known_channels: + return + self._known_channels.add(channel_id) + with contextlib.suppress(CellCallError): + self.ari.request( + "POST", + f"bridges/{self.bridge_id}/addChannel", + {"channel": channel_id}, + ) + + @staticmethod + def _channel_id(value: dict[str, Any] | bytes) -> str: + channel_id = value.get("id") if isinstance(value, dict) else None + if not isinstance(channel_id, str) or not channel_id: + raise CellCallError( + "ARI_CHANNEL_INVALID", "ARI did not return a channel ID" + ) + return channel_id + + def _finish_recording(self) -> str | None: + with contextlib.suppress(CellCallError): + self.ari.request("DELETE", f"recordings/live/{self.recording_name}") + try: + raw = self.ari.request( + "GET", f"recordings/stored/{self.recording_name}/file" + ) + except CellCallError: + return None + if not isinstance(raw, bytes) or not raw: + return None + path = Path(self.config.recording_dir) / f"{self.recording_name}.wav" + try: + if path.is_symlink(): + return None + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(raw) + return str(path) + except OSError: + return None + + def _cleanup(self) -> None: + self.event_stop.set() + for channel_id in (self.target_channel_id, self.external_channel_id): + if channel_id: + with contextlib.suppress(CellCallError): + self.ari.request("DELETE", f"channels/{channel_id}") + with contextlib.suppress(CellCallError): + self.ari.request("DELETE", f"bridges/{self.bridge_id}") + if self.ws is not None: + with contextlib.suppress(Exception): + self.ws.close() + if self.event_thread is not None: + self.event_thread.join(timeout=1.0) + if self.media is not None: + self.media.close() + + +class CellExecutionLedger: + """Durable claim/result ledger preventing a post-dial redial.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + if self.path.exists() and self.path.is_symlink(): + raise CellCallError( + "LEDGER_PATH_INVALID", "execution ledger must not be a symlink" + ) + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS cell_executions ( + execution_id TEXT PRIMARY KEY, + callee TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + updated_at REAL NOT NULL + ) + """ + ) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self.path, timeout=5) + connection.row_factory = sqlite3.Row + return connection + + def claim(self, execution_id: str, callee: str) -> dict[str, Any]: + now = time.time() + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT status, result_json FROM cell_executions WHERE execution_id=?", + (execution_id,), + ).fetchone() + if row is None: + connection.execute( + "INSERT INTO cell_executions(execution_id, callee, status, updated_at) VALUES (?, ?, 'in_progress', ?)", + (execution_id, callee, now), + ) + return {"claimed": True, "in_doubt": False, "result": None} + status = str(row["status"]) + if status == "in_progress": + return {"claimed": False, "in_doubt": True, "result": None} + try: + result = json.loads(row["result_json"]) if row["result_json"] else None + except (TypeError, json.JSONDecodeError) as exc: + raise CellCallError( + "LEDGER_CORRUPT", "execution ledger contains invalid JSON" + ) from exc + return {"claimed": False, "in_doubt": False, "result": result} + + def complete(self, execution_id: str, result: Mapping[str, Any]) -> None: + encoded = json.dumps(dict(result), ensure_ascii=False, sort_keys=True) + with self._connect() as connection: + connection.execute( + "UPDATE cell_executions SET status='completed', result_json=?, updated_at=? WHERE execution_id=?", + (encoded, time.time(), execution_id), + ) + + def result(self, execution_id: str) -> dict[str, Any] | None: + with self._connect() as connection: + row = connection.execute( + "SELECT result_json FROM cell_executions WHERE execution_id=?", + (execution_id,), + ).fetchone() + if row is None or not row["result_json"]: + return None + try: + value = json.loads(row["result_json"]) + except (TypeError, json.JSONDecodeError) as exc: + raise CellCallError( + "LEDGER_CORRUPT", "execution ledger contains invalid JSON" + ) from exc + if not isinstance(value, dict): + raise CellCallError( + "LEDGER_CORRUPT", "execution ledger result is not an object" + ) + return value + + +class RealCellWorker: + """Consume one tenant's ``call.execute`` queue and run a Cell executor.""" + + def __init__( + self, + broker: Any, + tenant_key: str, + ledger: CellExecutionLedger, + executor: RealCellCall, + ) -> None: + if not tenant_key: + raise CellCallError("TENANT_KEY_INVALID", "tenant_key is required") + self.broker = broker + self.tenant_key = tenant_key + self.queue_name = f"agent-call.executor.{tenant_key}.v1" + self.ledger = ledger + self.executor = executor + self.broker.declare_tenant(tenant_key) + + def process_once(self) -> dict[str, Any] | None: + message = self.broker.consume(self.queue_name) + if message is None: + return None + body = message.get("body") if isinstance(message, Mapping) else None + try: + execution_id, tenant_id, agent_version_id, callee = self._command(body) + except CellCallError: + self.broker.reject(message) + return {"status": "rejected", "reason_code": "COMMAND_INVALID"} + claim = self.ledger.claim(execution_id, callee) + if claim["in_doubt"]: + self.broker.ack(message) + return {"status": "reconciled", "reason_code": "EXECUTION_IN_DOUBT"} + result = claim["result"] + if claim["claimed"]: + try: + if agent_version_id != self.executor.engine.config["agent_version_id"]: + raise CellCallError( + "AGENT_VERSION_UNAVAILABLE", + "agent version is not installed on this Cell", + ) + result = self.executor.start_authorized_call(callee).as_dict() + except ( + CellCallError, + AIProviderError, + OSError, + RuntimeError, + TypeError, + ValueError, + KeyError, + ) as exc: + result = { + "call_id": f"call_{uuid.uuid4().hex}", + "status": "failed", + "reason_code": getattr(exc, "code", "CELL_EXECUTOR_EXCEPTION"), + "connected": False, + "turns": [], + } + self.ledger.complete(execution_id, result) + event = self._event(execution_id, tenant_id, callee, result or {}) + try: + self.broker.publish( + "agent-call.events.v1", + "agent-call.call.finished", + event, + message_id=event["event_id"], + ) + except (BrokerError, OSError, RuntimeError, TypeError, ValueError, KeyError): + self.broker.requeue(message) + return {"status": "pending", "reason_code": "EVENT_PUBLISH_FAILED"} + self.broker.ack(message) + return event + + def _command(self, body: Any) -> tuple[str, str, str, str]: + if not isinstance(body, Mapping) or body.get("command_type") != "call.execute": + raise CellCallError("COMMAND_INVALID", "message is not call.execute") + if body.get("tenant_key") != self.tenant_key: + raise CellCallError( + "TENANT_ROUTE_MISMATCH", "message tenant_key does not match queue" + ) + payload = body.get("payload") + if not isinstance(payload, Mapping): + raise CellCallError("COMMAND_INVALID", "call.execute payload is required") + values = ( + body.get("tenant_id"), + payload.get("execution_id"), + payload.get("agent_version_id"), + payload.get("callee"), + ) + if not all(isinstance(value, str) and value for value in values): + raise CellCallError( + "COMMAND_INVALID", "call.execute identity fields are required" + ) + return str(values[1]), str(values[0]), str(values[2]), str(values[3]) + + @staticmethod + def _event( + execution_id: str, tenant_id: str, callee: str, result: Mapping[str, Any] + ) -> dict[str, Any]: + payload = dict(result) + payload.pop("recording_path", None) + payload["callee"] = callee + payload["execution_id"] = execution_id + return { + "schema_version": "1.0", + "event_type": "call.finished", + "event_id": f"{execution_id}:call.finished", + "tenant_id": tenant_id, + "payload": payload, + } + + +__all__ = [ + "ARIClient", + "CellCallConfig", + "CellCallError", + "CellCallResult", + "CellExecutionLedger", + "RTPMedia", + "RealCellCall", + "RealCellWorker", + "alaw_to_pcm16", + "voice_level", +] diff --git a/compose.yaml b/compose.yaml index 74ff5e5..c70f0f2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -16,6 +16,8 @@ services: interval: 5s timeout: 3s retries: 12 + ports: + - "127.0.0.1:${RABBITMQ_PORT:-5672}:5672" volumes: - rabbitmq_data:/var/lib/rabbitmq logging: @@ -38,6 +40,8 @@ services: BROKER_MODE: "rabbit" RABBITMQ_URL: "${RABBITMQ_URL:?Set RABBITMQ_URL}" HTTP_TOKENS: "${HTTP_TOKENS:?Set HTTP_TOKENS}" + AI_CONFIG_ISSUER: "${AI_CONFIG_ISSUER:-}" + AI_CONFIG_AUDIENCE: "${AI_CONFIG_AUDIENCE:-}" AGENT_CALL_SIP_READONLY_URL: "${AGENT_CALL_SIP_READONLY_URL:-}" AGENT_CALL_SIP_READONLY_TOKEN: "${AGENT_CALL_SIP_READONLY_TOKEN:-}" AGENT_CALL_SIP_READONLY_CA_FILE: >- @@ -56,6 +60,57 @@ services: driver: json-file options: {max-size: "10m", max-file: "3"} + cell-worker: + build: + context: . + dockerfile: Dockerfile + profiles: ["real-cell"] + restart: unless-stopped + init: true + entrypoint: ["python", "-m", "agent_call.cell_worker_main"] + network_mode: host + depends_on: + rabbitmq: + condition: service_healthy + environment: + AGENT_CALL_AI_PROFILE: >- + ${AGENT_CALL_AI_PROFILE:-/app/configs/ai-test.bailian.example.yaml} + AGENT_CALL_PROMPT_FILE: >- + ${AGENT_CALL_PROMPT_FILE:-/app/prompts/test-call.txt} + AGENT_CALL_TENANT_KEY: >- + ${AGENT_CALL_TENANT_KEY:?Set AGENT_CALL_TENANT_KEY} + AGENT_CALL_EXECUTION_LEDGER: >- + ${AGENT_CALL_EXECUTION_LEDGER:-/data/cell-executions.sqlite3} + RABBITMQ_URL: "${RABBITMQ_URL:?Set RABBITMQ_URL}" + BAILIAN_API_KEY: "${BAILIAN_API_KEY:?Set BAILIAN_API_KEY}" + BAILIAN_BASE_URL: "${BAILIAN_BASE_URL:?Set BAILIAN_BASE_URL}" + BAILIAN_WSS_BASE_URL: "${BAILIAN_WSS_BASE_URL:?Set BAILIAN_WSS_BASE_URL}" + BAILIAN_TTS_WSS_BASE_URL: "${BAILIAN_TTS_WSS_BASE_URL:-}" + BAILIAN_TTS_MODEL: "${BAILIAN_TTS_MODEL:-cosyvoice-v3.5-plus}" + BAILIAN_TTS_VOICE: "${BAILIAN_TTS_VOICE:?Set BAILIAN_TTS_VOICE}" + BAILIAN_ASR_MODEL: "${BAILIAN_ASR_MODEL:-fun-asr-realtime}" + CELL_ARI_URL: "${CELL_ARI_URL:-http://127.0.0.1:8088}" + CELL_ARI_USERNAME: "${CELL_ARI_USERNAME:-outbound}" + CELL_ARI_PASSWORD: "${CELL_ARI_PASSWORD:?Set CELL_ARI_PASSWORD}" + CELL_ARI_APP: "${CELL_ARI_APP:-agent-call}" + CELL_SIP_TRUNK_ID: "${CELL_SIP_TRUNK_ID:?Set CELL_SIP_TRUNK_ID}" + CELL_CALLER_ID: "${CELL_CALLER_ID:?Set CELL_CALLER_ID}" + CELL_RTP_BIND_HOST: "${CELL_RTP_BIND_HOST:-127.0.0.1}" + CELL_RECORDING_DIR: "${CELL_RECORDING_DIR:-/data/recordings}" + volumes: + - agent_call_data:/data + tmpfs: + - /tmp:rw,noexec,nosuid,size=64m + read_only: false + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 256 + mem_limit: 1g + cpus: 2 + logging: + driver: json-file + options: {max-size: "10m", max-file: "3"} + sip-management: build: context: . diff --git a/configs/ai-test.bailian.example.yaml b/configs/ai-test.bailian.example.yaml new file mode 100644 index 0000000..003cb2b --- /dev/null +++ b/configs/ai-test.bailian.example.yaml @@ -0,0 +1,23 @@ +{ + "mode": "real", + "provider_modes": { + "llm": "real", + "tts": "real", + "asr": "real" + }, + "service_profile": "docs/contracts/mock-profile.json", + "tenant_id": "tenant-demo", + "task_id": "task-demo", + "agent_version_id": "agent_bailian_test_v1", + "model": "qwen-plus", + "tts_model": "cosyvoice-v3.5-plus", + "asr_model": "fun-asr-realtime", + "language": "zh-CN", + "opening": "您好,这是本次集成测试。请说一句话进行语音链路测试。", + "max_turns": 3, + "max_duration_ms": 180000, + "callee": "15003164745", + "authorized_callees": ["15003164745"], + "max_prompt_bytes": 32768, + "notes": "Use BAILIAN_* environment variables; keep secrets out of this file." +} diff --git a/configs/ai-test.example.yaml b/configs/ai-test.example.yaml new file mode 100644 index 0000000..25cec16 --- /dev/null +++ b/configs/ai-test.example.yaml @@ -0,0 +1,15 @@ +{ + "mode": "mock", + "service_profile": "docs/contracts/mock-profile.json", + "tenant_id": "tenant-demo", + "task_id": "task-demo", + "agent_version_id": "agent_test_v1", + "model": "mock-chat-v1", + "tts_model": "mock-tts-v1", + "voice": "mock-neutral", + "language": "zh-CN", + "callee": "18601013734", + "authorized_callees": ["18601013734"], + "max_prompt_bytes": 32768, + "notes": "JSON is valid YAML 1.2; keep provider URLs and secrets out of this file." +} diff --git a/configs/cell-worker.example.env b/configs/cell-worker.example.env new file mode 100644 index 0000000..79fc01f --- /dev/null +++ b/configs/cell-worker.example.env @@ -0,0 +1,27 @@ +# Cell-local runtime injection. Do not commit real values. +AGENT_CALL_AI_PROFILE=/app/configs/ai-test.bailian.example.yaml +AGENT_CALL_PROMPT_FILE=/app/prompts/test-call.txt +AGENT_CALL_TENANT_KEY=tenant-demo-key +AGENT_CALL_EXECUTION_LEDGER=/data/cell-executions.sqlite3 + +# Real RabbitMQ on the same host or a protected management network. +RABBITMQ_URL=amqp://user:password@127.0.0.1:5672/%2F + +# Bailian values are injected by the secret manager/environment. +BAILIAN_API_KEY= +BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +BAILIAN_WSS_BASE_URL=wss://dashscope.aliyuncs.com/api-ws/v1/inference +BAILIAN_TTS_WSS_BASE_URL=wss://dashscope.aliyuncs.com/api-ws/v1/realtime +BAILIAN_TTS_MODEL=cosyvoice-v3.5-plus +BAILIAN_TTS_VOICE= +BAILIAN_ASR_MODEL=fun-asr-realtime + +# Cell-local ARI/SIP settings; ARI stays on the host management interface. +CELL_ARI_URL=http://127.0.0.1:8088 +CELL_ARI_USERNAME=outbound +CELL_ARI_PASSWORD= +CELL_ARI_APP=agent-call +CELL_SIP_TRUNK_ID=provider-primary +CELL_CALLER_ID=BD93205882 +CELL_RTP_BIND_HOST=127.0.0.1 +CELL_RECORDING_DIR=/data/recordings diff --git a/deploy/mock.env.example b/deploy/mock.env.example index af320cc..e2e14ba 100644 --- a/deploy/mock.env.example +++ b/deploy/mock.env.example @@ -4,7 +4,9 @@ RABBITMQ_DEFAULT_PASS=replace-before-start RABBITMQ_URL=amqp://agent_call_mock:replace-before-start@rabbitmq:5672/%2f ASR_WEB_TOKEN=local-asr-test # JSON map: token -> tenant IDs and scopes. Inject through a secret manager in real deployments. -HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]}} +HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]},"ai-publish-local":{"tenant_ids":["tenant-demo"],"scopes":["ai.config.publish"],"auth_domain":"ai-config","issuer":"local-ai-config","audience":"agent-call.ai-config"},"ai-read-local":{"tenant_ids":["tenant-demo"],"scopes":["ai.config.read"],"auth_domain":"ai-config","issuer":"local-ai-config","audience":"agent-call.ai-config"}} +AI_CONFIG_ISSUER=local-ai-config +AI_CONFIG_AUDIENCE=agent-call.ai-config # Independent SIP management credentials; synthetic local values only. SIP_MANAGEMENT_PORT=18090 SIP_ADMIN_TOKENS={"admin-local":{"subject":"ops","issuer":"local","audience":"agent-call.sip-admin","scopes":["*"],"trunk_ids":"*"}} @@ -12,3 +14,23 @@ SIP_READ_TOKENS={"saas-local":{"subject":"saas","issuer":"local","audience":"age AGENT_CALL_MODE=mock BROKER_MODE=rabbit AGENT_CALL_PORT=18080 +# Real-cell service is disabled by default; these are synthetic placeholders only. +AGENT_CALL_TENANT_KEY=tenant-demo-key +AGENT_CALL_AI_PROFILE=/app/configs/ai-test.bailian.example.yaml +AGENT_CALL_PROMPT_FILE=/app/prompts/test-call.txt +AGENT_CALL_EXECUTION_LEDGER=/data/cell-executions.sqlite3 +BAILIAN_API_KEY=replace-before-start +BAILIAN_BASE_URL=https://example.invalid/compatible-mode/v1 +BAILIAN_WSS_BASE_URL=wss://example.invalid/api-ws/v1/inference +BAILIAN_TTS_WSS_BASE_URL=wss://example.invalid/api-ws/v1/realtime +BAILIAN_TTS_MODEL=cosyvoice-v3.5-plus +BAILIAN_TTS_VOICE=replace-before-start +BAILIAN_ASR_MODEL=fun-asr-realtime +CELL_ARI_URL=http://127.0.0.1:8088 +CELL_ARI_USERNAME=outbound +CELL_ARI_PASSWORD=replace-before-start +CELL_ARI_APP=agent-call +CELL_SIP_TRUNK_ID=provider-primary +CELL_CALLER_ID=BD93205882 +CELL_RTP_BIND_HOST=127.0.0.1 +CELL_RECORDING_DIR=/data/recordings diff --git a/docs/LLM与音色可配置电话对话_开发与验收计划_v1.0.md b/docs/LLM与音色可配置电话对话_开发与验收计划_v1.0.md new file mode 100644 index 0000000..2bb09f7 --- /dev/null +++ b/docs/LLM与音色可配置电话对话_开发与验收计划_v1.0.md @@ -0,0 +1,196 @@ +# agent-call:LLM 与音色可配置电话对话开发与验收计划 + +**版本:** v1.0 +**日期:** 2026-09-14 +**状态:** P1/P2 配置契约与 Bailian 真实 ASR/LLM/CosyVoice 文件链路已落地并验收;P3/P4 真实电话、MQ/OSS 和人工听测仍未完成。本文仍是范围与门禁计划,不是完整真实电话完成报告。 +**目标:** 指定 LLM 模型、Prompt、TTS 模型与音色,一键完成文本、音频文件或真实电话多轮对话测试。 + +## 1. 权威来源与当前基线 + +- [最终计划](最终开发部署监控与验收计划_v1.0.md)仍是总实施入口;本文细化 D04、D06、D07、D08、D11、D12 的单通话 AI 联调,不替代整体调度、恢复与生产验收。 +- [主契约](SaaS交互_OpenAPI与MQ契约规划_v0.1.md)是 HTTP/MQ 字段、路径、状态的唯一维护来源。本文的新增配置项、命令行和报告项是设计要求,不是已冻结的业务协议。 +- 已有机器契约见 `docs/contracts/executor.openapi.yaml`、`saas.openapi.yaml`、`mq.schema.json`,集中命令样例见 `docs/contracts/examples/call.execute.json`。 +- 用户反馈最新外呼已正常接通;2026-09-14 最近探针记录 100→183→200、PCMA/8000,结束后无活动通道。该事实只作为 SIP 接通基线,不证明双向媒体、ASR、LLM、TTS、录音及完整 SaaS 交接通过。 +- `deploy/sip_probe.py` 是真实 SIP 拨号探针,不是完整 AI 通话执行器;`agent_call/core.py` 的 `_run_call` 仍包含模拟通话流程,`agent_call/mocks.py` 为模拟记录,不能用它们的成功事件替代真实媒体证据。 +- 仅复用现有 ASR 协议能力,不复制或启用 `voice_test` 的 LLM/TTS;`agent_call/bailian.py` 使用独立 Bailian LLM/CosyVoice 适配。ASR Web 页面仍不承载 LLM/TTS,未配置供应商时页面、日志与报告必须明确标记阻塞或模拟。 + +## 2. 范围与不可变边界 + +### 2.1 首期交付 + +1. 可信、不可变的 AI 配置版本:可指定模型、UTF-8 Prompt 文件及音色。 +2. 首家 LLM 与首家 TTS 的真实流式适配,连接现有 ASR。 +3. Cell 内真实 ARI 事件、双向媒体和电话多轮对话;支持开场白、打断、静音处理和挂断清理。 +4. `text / audio / call` 三层一键测试,以及脱敏机器报告、人工听测登记和证据索引。 +5. 沿用现有 MQ 结果、文字与 OSS 录音交接,不建立第二条业务结果通道。 + +首期不做 Prompt 可视化编辑器、RAG、通用供应商框架、多供应商自动切换、工具调用平台或 1000 路容量承诺。供应商接入不足不阻塞隔离 Mock 开发,但阻塞对应真实验收。 + +### 2.2 必须遵守 + +- 电话测试脚本扮演测试 SaaS;通过租户独立 RabbitMQ 队列提交 `call.execute`,引用 `agent_version_id`。不得新增 HTTP 拨号入口或 HTTP 业务回调。 +- AI 配置发布与普通呼叫权限隔离;不借用 SIP 管理后台写接口或普通调度 Token 绕过配置权限。 +- MQ 不携带 API Key、任意供应商 URL 或任意 SIP 地址。供应商地址、凭据引用和模型授权由服务端校验。 +- 一通电话固定 Cell/出口;主叫、被叫改写和 PCMA 由授权线路配置解析,不逐呼修改共享 PJSIP 配置,不虚构备用线路。 +- 业务持久状态使用真实隔离数据库,集成测试使用真实测试 RabbitMQ;内存队列仅可作单元测试替身。 +- `mock / mixed / real` 按组件可识别;Mock 默认隔离真实外网;正式模式拒绝 Mock/测试凭证,不静默降级。 +- 本文不授权新建云资源、真实 AI 消费或立即拨号。真实执行另需有效预算、凭据和明确安排的测试授权。测试后保留现有 ECS/EIP,清理需用户另行确认。 + +## 3. 配置与契约工作包 P1 + +### 3.1 建议配置内容(待写入主契约冻结) + +| 配置组 | 内容 | 校验要求 | +| --- | --- | --- | +| LLM | 供应商配置引用、模型、Prompt、生成参数、超时 | 模型受授权且支持所需流式协议;参数范围与供应商能力一致 | +| TTS | 供应商配置引用、模型、音色、语速、音频格式 | 音色与模型兼容;输出格式可转换为电话侧音频;不静默替换 | +| ASR | 现有供应商配置引用、语言、输入格式 | 明确采样率、声道、位深与分段/最终稿语义 | +| 对话 | 开场白、断句、打断开关、静音处理、最长时长 | 有界等待和输出;失败行为明确,不无限循环 | +| 安全与版本 | 租户范围、不可变版本、内容摘要、凭据引用 | 版本发布后不可覆盖;跨租户拒绝;密钥不进入快照 | + +- CLI 读取 UTF-8 Prompt 文件;冻结模板变量的允许集合、转义规则、缺失处理、长度上限,不使用 `eval`、shell 或任意文件引用。 +- 先发布并确认可信快照,再提交引用该版本的执行命令。复用版本必须校验内容及租户一致,不能覆盖同 ID 的不同内容。 +- 呼叫受理和实际发起前验证版本存在且获授权;活动通话固定原快照,后续发布不热替换。 +- 配置同步来源、发布/读取路径、认证域、错误与版本规则先补主契约,再生成对应 OpenAPI、Schema 与正反例;不在本文擅定新业务路径、状态或错误码。 +- OpenAPI 是 HTTP 接口描述,不能据此默认采用 OpenAI 兼容协议。LLM/TTS 的请求、SSE/WebSocket、取消、错误和音频契约以选定供应商文档为准。 + +### 3.2 P1 退出条件 + +配置的合法/缺失/越权/冲突/模型音色不兼容用例通过;HTTP/MQ 样例与机器契约一致;脚本可取得可信 `agent_version_id`,而不是向 MQ 额外塞入未定义字段。 + +## 4. 执行与媒体链路 + +```text +测试脚本 → 可信配置发布/读取 → agent_version_id + → RabbitMQ call.execute → 持久化/公平调度/资源预留 + → Cell 本地执行器 → ARI 发起与真实接通事件 + → 入站媒体 → ASR → LLM → TTS → 出站媒体 → 电话播放 + → 持久化文字/状态/终态 → outbox → RabbitMQ + → 接通后录音 → 挂断封口 → OSS 校验 → MQ 录音结果 +``` + +### 4.1 媒体实现要求 + +- Cell 保持唯一活动 ARI 控制者。按实际 Asterisk 能力选定一种双向媒体接入方式并验证,不同时实现多套媒体方案;管理连接保持回环或受控管理网/TLS,不开放公网 ARI。 +- SIP 侧沿用 PCMA/8000;ASR/TTS 侧按真实供应商要求转换采样率、声道、编码及帧格式,优先复用现有可靠音频组件。用实测验证节拍、字节序、时长、音量、丢包与缓冲上限。 +- ASR 中间稿不直接无限触发 LLM;按最终稿/端点检测形成轮次。冻结 VAD、断句和去重策略,避免机器人回声形成自问自答。 +- LLM 增量输出按有界句段送 TTS;上下文、待合成文本、待播放音频均有上限与背压,不无界积压。 +- 每轮具备内部轮次/取消标识;打断时取消 LLM/TTS、清空可清理的播放缓冲、丢弃旧轮次迟到包。已进入远端缓冲的音频不能宣称完全撤回,记录可测停止边界。 +- 用户挂断、控制挂断或时长到期后,停止新增生成并清理媒体/AI连接;ARI 失联进入既有对账流程,不凭租约超时直接释放未知通话占用或重拨。 +- 生成文字、音频已发送与播放确认分开记录;没有播放证据时维持主契约允许的未知状态,不伪称用户已听到。 +- 录音按主契约接通后开始、单轨混音、挂断封口;OSS 交接失败保持可恢复事实,不产生虚假 ready。 + +## 5. 开发顺序与交付物 + +| 工作包 | 开发内容 | 交付物与退出条件 | 依赖 | +| --- | --- | --- | --- | +| P1 配置契约 | 配置发布、授权、版本、参数及供应商协议冻结 | 主契约增补、机器契约、正反例;满足第3节 | 无;供应商资料并行协调 | +| P2 独立 AI | 一家流式 LLM、一家流式 TTS、现有 ASR 接入;取消/错误与模式隔离 | 文本测试、音频文件测试;指定模型/Prompt/音色有请求及输出证据 | P1;真实测试需供应商资料 | +| P3 真实通话 | Cell 执行路径、真实 ARI/RTP、音频转换、录音衔接 | 手机能听到 AI、ASR 能识别用户并驱动回答;不是探针 Wait 或 Mock 成功事件 | P2、现有调度与Cell缺口整改 | +| P4 生命周期 | 开场白、上下文、打断、静音、超时、挂断、断连对账 | 三轮对话、取消迟到包、异常清理和幂等故障测试 | P3 | +| P5 脚本与验收 | 分层入口、预检、持久运行清单、事件收集、报告及人工听测登记 | 第7节逐例登记,文档命令可复现 | P1起增量开发,最终依赖P4 | + +本次产物位置:`scripts/test_ai_call.py`、`scripts/run_real_cell_call.py`、`scripts/accept_ai_call.py`、`configs/ai-test.example.yaml`、`configs/ai-test.bailian.example.yaml`、`prompts/test-call.txt`、`agent_call/ai_runtime.py`、`agent_call/bailian.py`、`agent_call/real_cell.py`、`tests/test_ai_runtime.py`、`tests/test_bailian.py`、`tests/test_real_cell.py`、`docs/contracts/ai-config.openapi.yaml`、`docs/contracts/ai-config.schema.json`、`docs/evidence/llm-voice-acceptance-20260914.json` 和 `docs/evidence/real-cell-call-20260914.json`。AI/媒体运行代码已提供真实 Cell ARI/RTP worker,但单次真实电话未通过媒体/三轮门禁。Mock `text`/`audio`/`call` 与 Bailian 真实 `text`/`audio` 已可复现;脱敏报告同时提供 Markdown 摘要。 + +## 6. 一键测试规范 + +### 6.1 分层入口 + +| 模式 | 输入/结果 | 外部副作用 | +| --- | --- | --- | +| `text` | 模型、Prompt、测试文本 → 流式回答和报告 | 不拨号;真实模式会调用 LLM | +| `audio` | 合成/已授权音频 → ASR文字、LLM回答、TTS音频和报告 | 不拨号;真实模式会调用 AI | +| `call` | 授权号码、配置版本与线路策略 → 电话对话和 MQ/录音证据 | 会拨号及调用 AI,必须显式授权 | + +**已实现的 Mock 与真实文件入口:** + +```bash +python3 scripts/test_ai_call.py call \ + --profile configs/ai-test.example.yaml \ + --model 'mock-chat-v1' \ + --prompt-file prompts/test-call.txt \ + --tts-model 'mock-tts-v1' \ + --voice 'mock-neutral' \ + --callee '18601013734' \ + --max-duration 120 \ + --allow-real-call +``` + +`120` 是该次测试时长示例,不是生产额度。Bailian real `text`/`audio` 只会调用真实 AI、不拨号;real `call` 仍需 Cell/线路/MQ/OSS 与明确电话授权,不能因文件探针通过而自动发起收费电话。 + +### 6.2 执行步骤及安全 + +1. 预检配置权限、供应商能力、凭据引用、租户绑定、MQ、必要存储依赖及线路/Cell可用性;配置错误在拨号前拒绝。预检不等于实际发起时的资源租约,发起前仍须复核。 +2. 发布/复用配置版本;持久保存运行清单及 `command_id / execution_id / trace_id / agent_version_id`,再提交 MQ。 +3. 一次运行只呼叫一个授权号码,不自动重拨或自动切换供应商。broker 确认不确定时保留原执行标识,按主契约恢复;脚本重启不得默认创建新执行。 +4. 事件由专用测试 SaaS inbox 按既有事件拓扑受控收集,不竞争消费生产 SaaS 队列,不改为一租户一事件队列。报告只关联本次租户和执行。 +5. CLI 等待超时不代表通话已结束。通过既有查询/授权控制和 Cell 事实核验;无法核验则记录不确定、停止新执行并提示处置,不全局强挂其他通话。 +6. 正常结束核验本次通道/连接/资源已清理,并等待有界的结果与资产交接;未完成项标记失败或阻塞,不伪报整体通过。 +7. 提供明确非零失败退出码;建议 `0=全部要求通过、1=测试失败、2=前置阻塞或证据不足`。人工听测未完成时不能退出为整体验收通过。 + +### 6.3 报告与证据 + +- 运行 ID、时间、代码/组件版本、profile 和逐组件模式、执行标识、配置摘要、所用线路/Cell。 +- 请求模型及供应商返回模型(若不返回,标记未验证)、Prompt 摘要、TTS模型/音色;不写入密钥或默认公开完整 Prompt。 +- SIP 接通证据、双向媒体计数、ASR分段、LLM首 token、TTS首音频、实际可观测播放状态、挂断与清理事实。 +- 单调时钟测量 ASR 最终稿、LLM首 token、TTS首音频、用户话音结束至回复播放、打断停止延迟;跨组件时钟不可直接相减,报告测量边界。 +- MQ事件ID、持久化/发布确认情况、OSS交接证据、逐例 PASS/FAIL/BLOCKED、人工听测结论。 +- 原始证据保存在受控忽略目录并设置最小访问权限;`docs/evidence/` 只收录脱敏摘要。录音、完整文字、号码与Prompt按登记保留期限清理,未经明确保留规则不发布真实内容。 + +## 7. 功能验收矩阵 + +所有用例记录模式、环境、输入、操作、预期、实际、证据路径和结论;计划中默认均为 **NOT_RUN(未执行)**。执行后只使用 PASS/FAIL/BLOCKED,不以“代码存在”替代测试。 + +| 编号 | 场景与操作 | 通过标准/必要证据 | +| --- | --- | --- | +| AI-01 | 发布配置并提交版本引用 | 命令通过现有Schema;可信快照与租户一致;MQ不含密钥/任意URL | +| AI-02 | 缺失版本、越权版本、同版本异内容 | 明确拒绝;无真实INVITE、无供应商调用;原已发布版本不变 | +| AI-03 | 非法模型、音色不兼容、Prompt变量缺失/越界 | 可本地确定的问题拨号前拒绝;供应商才可判定的错误明确留证,不替换模型/音色 | +| AI-04 | 修改配置后再次执行 | 新执行使用新版本;活动通话继续原版本;摘要可核对 | +| AI-05 | 指定模型与Prompt进行文本测试 | 请求模型、Prompt摘要准确;回答符合预置可核验约束;流式首token及终止有证据 | +| AI-06 | 指定TTS模型与音色合成 | 请求参数准确;音频可解码、时长/格式正确;人工确认可听;不以请求参数单独证明音色听感 | +| AI-07 | 音频文件走完整AI链 | ASR结果、LLM回答、TTS文件均关联同一测试;非固定Mock文字;样本预期与人工复核通过 | +| AI-08 | 真实电话至少三轮问答 | 真实接通、双向媒体和三轮识别/回答证据齐全;手机端人工确认清晰可听、上下文连续、无明显自问自答 | +| AI-09 | 开场白及指定Prompt约束 | 接通后按配置播放开场白;预置问答符合Prompt;未配置开场白不擅自生成固定营销话术 | +| AI-10 | PCMA/PCM转换及持续音频 | 格式、采样率、时长、帧节拍可核验;无明显变速/截断;队列有界 | +| AI-11 | AI播放中用户插话,注入迟到包 | 取消生成/合成并清理播放队列;旧轮次包不继续入队;停止延迟满足预登记测试阈值 | +| AI-12 | 用户持续静音或无人应答 | 按配置有界等待/结束;不无限生成;无人应答不记为已接通AI通话 | +| AI-13 | 用户挂断、授权控制挂断、最长时长到期 | 本次AI任务/媒体停止;已知结束的资源释放;其他通话不受影响 | +| AI-14 | 鉴权错误、429、生成超时、ASR/TTS断流 | 分项错误与终态可靠留证;无静默Mock/模型切换、无自动重拨;仅按已冻结失败策略执行 | +| AI-15 | ARI断连、执行器重启、活动状态未知 | 保留执行事实并进入对账;不生成新ID重拨、不释放未知活动占用;恢复后事实收敛 | +| AI-16 | MQ重复、发布确认丢失、脚本重启 | 同一执行最多一次实际拨号;清单可恢复;结果按事件ID去重,不绕过原租户调度域 | +| AI-17 | 文字/播放及MQ故障 | 最终稿可靠持久;生成/发送/播放证据不混淆;outbox恢复且不产生第二套业务回调 | +| AI-18 | 录音正常上传、OSS超时/校验失败 | 接通后录制、挂断封口;真实字节独立校验后才返回OSS ID/ready;失败保留恢复证据 | +| AI-19 | 未授权真实调用、Mock混入real、凭据缺失 | 启动/预检拒绝;无真实拨号/收费调用;报告逐组件模式准确 | +| AI-20 | 一键执行完整流程 | 单命令完成配置、预检、MQ提交、收集与报告;无手工改PJSIP;退出码匹配结论;人工未确认不报全通过 | +| AI-21 | 日志/报告安全与本次资源收尾 | 无密钥;公开证据脱敏;本次已结束通道/媒体/AI资源无泄漏;ECS/EIP不自动清理 | + +### 7.1 延迟与质量门禁 + +- 供应商选定后、真实测试前登记:ASR准确性样本与判据、LLM首token、TTS首音频、端到端回复、打断停止、静音等待和资源清理的阈值及测量方法。 +- 阈值由实际供应商能力与测试预算确认;不得在看到结果后调宽阈值再宣称通过。未冻结只记录实测,相关性能用例标记 BLOCKED。 +- 单通话测试报告样本数与每次观测;少量样本不宣称生产p95/SLA。真实1000路、N+1、供应商额度与压测继续按最终计划单独验收。 + +## 8. 自动验证与交付门禁 + +- Python:`python3 -m unittest discover -s tests -v`;新增覆盖配置拒绝、流式解析、取消迟到包、幂等恢复与模式隔离的最小回归测试。 +- Go ASR有改动时:`cd services/asr-web && go test -race ./...`,并格式化和静态检查。 +- 音频前端或PCM有改动时:`node --test tests/test_pcm.cjs`;Asterisk Shell有改动时:`bash -n deploy/asterisk.sh`。 +- 修改后的OpenAPI/Schema解析与正反例校验、`git diff --check` 必须通过。 +- 集成证据必须使用隔离真实DB/RabbitMQ、真实Asterisk及协议级模拟对端;仅写ProtocolJournal日志不算协议集成通过。 +- 分别报告 Mock、真实ASR、真实LLM/TTS、真实SIP/媒体、MQ/OSS、人工听测结果。完整真实电话验收要求所有必需组件为real且相关用例通过;mixed通过只证明其列出的真实部分。 +- 交付:代码/配置示例/运行说明、逐例验收报告、脱敏证据索引、未完成项与阻塞项;文档中的预计脚本只有实现并验证后才能标记可用。 + +## 9. 外部待办与开工顺序 + +| 待提供/确认 | 影响 | +| --- | --- | +| Bailian 首家供应商的模型、流式/取消协议、配额与预算 | 文件链路已按当前协议验证;新增模型/能力仍需单独登记 | +| Bailian TTS 自定义音色与音频格式 | CosyVoice 文件合成已验证;人工听测和电话侧格式仍待验收 | +| 受控凭据、服务端允许的供应商地址与租户授权 | 文件探针已使用环境凭据;电话执行仍需 Cell/线路授权 | +| 配置发布/同步来源、权限域与版本契约 | P1冻结后才能宣称OpenAPI对接完成 | +| 真实MQ/数据库/OSS及测试SaaS inbox条件 | 缺失时只能做文件级 real 验证,不宣称完整电话交接通过 | +| 已授权测试号码、时段、次数、预算及录音保留规则 | 阻塞真实电话执行;历史拨号授权不自动扩展为本专项三轮测试 | +| 质量样本、延迟阈值与人工听测人员 | 阻塞对应质量/体验最终签字 | + +执行顺序:先完成P1契约草案与隔离Mock正反例,供应商资料并行协调;P2先文本后音频;P3/P4再安排一次受控真实电话测试;P5提交逐例证据与未通过项。已接受的实施方向不重复等待确认;只对新增字段/协议、预算和真实执行授权进行必要冻结。 diff --git a/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md b/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md index 4afdb4a..adc05d5 100644 --- a/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md +++ b/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md @@ -17,7 +17,7 @@ 4. 录音先上传 OSS,校验成功并取得 OSS ID 后发布 `recording.ready`;MQ 不传录音二进制、Base64 或公开播放 URL。 5. SaaS 负责客户/任务主数据、业务调度、业务重试决策、授权和长期存储;呼出应用只保存必要执行事实、控制屏障、幂等、资源租约和投递记录。 6. 生产采用多机器、多 EIP 直连;每通电话固定 Cell/出口。SaaS 不逐呼改写共享 SIP 配置,也不能任意指定 SIP 地址、凭证或越权主叫。 -7. 目前只实现 ASR 验证基础,LLM/TTS 协议尚待新规范;本文对完整 AI 链路的描述是目标契约,不代表已启用或验收。 +7. 目前只实现 ASR 验证基础;agent-call 已补充隔离的不可变 AI 配置存储与内部配置读写契约(见`docs/contracts/ai-config.openapi.yaml`和`ai-config.schema.json`),但首家供应商的 LLM/TTS 请求、流式、取消和音频协议尚待新规范,完整 AI 链路不代表已启用或验收。 8. **按 SaaS 提供的 `tenant_key` 绑定到独立 RabbitMQ 命令队列,由呼出应用调度器负责租户间公平调度**。不再采用所有租户共用一个执行 FIFO;同时限制租户发布/积压、预取窗口及跨 Cell 并发/CPS。`tenant_key` 是 SaaS 业务数据,命令、队列路由和后续 MQ 回调均原样使用;命名规则已统一,运行参数和限值仍待 G0 冻结。 ## 2. 交付拆分与双方职责 @@ -312,10 +312,19 @@ payload 必需: | `variables` | 经字段白名单、类型/长度校验的运行变量,不是任意代码或任意 URL | | `ring_timeout_ms`、`max_call_duration_ms` | 不超过服务端和供应商上限 | -agent_version_id 必须对应呼出侧可用且可信的快照;配置如何同步、LLM/TTS 参数和取消/音频契约留待新规范。缺失配置明确拒绝,不私自读取旧 voice_test LLM/TTS 或任意远程 URL。 +agent_version_id 必须对应呼出侧可用且可信的快照;当前配置发布/读取的内部契约见`docs/contracts/ai-config.openapi.yaml`,发布后不可覆盖,配置含 Prompt/模型/音色摘要及受控凭据引用。Bailian 文件探针使用 `agent_call/bailian.py` 的服务端环境适配,具体供应商参数、流式、取消/音频协议不进入 `call.execute`。缺失配置明确拒绝,不私自读取旧 voice_test LLM/TTS 或任意远程 URL。 线路策略由呼出侧解析为授权 `trunk_id + egress_pool_id + cell_id`,实际选择写入事件。呼叫固定 Cell/出口;允许的 FALLBACK 也必须兼容同一 Cell/出口及供应商白名单,无适合备用就失败,不迁移活动通话。当前只有一家线路,不虚构 backup。 +### 6.1.1 AI 配置内部契约 + +AI 配置发布/读取不是 SaaS 拨号入口,也不是业务结果回调;仅供受控配置同步面使用。机器契约为 `docs/contracts/ai-config.openapi.yaml` 与 `docs/contracts/ai-config.schema.json`: + +- `POST /internal/v1/ai/agent-versions`:要求独立 AI 配置认证域和 `ai.config.publish`,正文严格为 `agent_version_id + config`;首次发布返回 `201/published`,相同内容重放返回 `200/reused`。 +- `GET /internal/v1/ai/agent-versions/{agent_version_id}`:要求独立 AI 配置认证域和 `ai.config.read`,只返回当前租户的可信快照。 +- 版本按 `(tenant_id, agent_version_id)` 唯一且不可覆盖;内容摘要为 SHA-256。ID 不存在、跨租户、内容冲突、字段/Prompt/模型/音色不合法必须明确拒绝,不回退旧版本。 +- 配置快照只允许 provider/credential 引用和受控参数,不允许 API Key、密码、任意 URL、SIP 地址;`call.execute` 仍只通过 RabbitMQ,不能把配置正文塞进消息。 + ### 6.2 幂等与发起 1. 验证身份边界、Schema 和 tenant/task 关联;对已记录的 command_id/execution_id 先检查语义冲突并返回/关联原结果,不因重投时已过期或控制已变而改写原事实。新执行再检查有效期、控制版本和配置/线路授权;防重查重不绕过当前调用方的租户访问校验。 @@ -644,7 +653,7 @@ HTTP 错误码与 MQ reason_code 共用词汇但不是一一映射;MQ 没有 H | 应用证据与清理(已确认方案) | 第 10.1 节:首期只自动确认 broker,SaaS 对账补传;保留/交接依据,若必须自动应用确认则另选 MQ receipt | 用户、双方、运维 | | 执行幂等 | 新增 execution_id,与 command_id 分离;业务重新外呼许可及去重保留 | 用户、SaaS | | 停止语义 | drain/hangup 显式选择;挂断权限与多 Cell 生效判据 | 用户、SaaS | -| 线路/AI 配置 | route/caller/agent 引用及同步来源;LLM/TTS 新规范、失败兜底 | 用户、供应方 | +| 线路/AI 配置 | route/caller/agent 引用及同步来源;Mock AI 配置发布契约已落地,真实 LLM/TTS 新规范、失败兜底待供应方 | 用户、供应方 | | Asterisk/SIP 管理后台与 SaaS 只读目录(Mock 已实现,生产待核验) | 不纳入普通业务七条路径;由独立管理后台统一写入并发布多机器 Trunk/codec_profile、主叫/被叫规则、出口池、并发/CPS,提供版本、CAS、审计、校验、发布、停用和回滚;SaaS 仅读取脱敏 Trunk 配置/能力/版本/健康状态;管理写接口、SaaS 只读接口和普通调度接口隔离认证凭据、issuer/audience/权限域,普通调度 Token 必须拒绝管理写接口 | 用户、SaaS、运维 | | MQ 环境 | 租户独立命令队列已确认;采用 `agent-call` 命名空间、direct 命令 exchange、`agent-call.tenant.{tenant_key}.call.execute` 路由及 `tenant_key` 原样透传;冻结精确绑定、生命周期、队列数上限、quorum/HA、ACL、重试/DLQ 与死信可靠性 | 运维、双方 | | 租户公平与背压 | 公平调度架构已确认;冻结轮转批量/周期、活跃队列发现、权重、prefetch、接收窗口、并发/CPS、发布速率/积压上限、拒绝发布策略、多实例协调及等待指标 | 用户、双方、运维 | diff --git a/docs/contracts/ai-config.openapi.yaml b/docs/contracts/ai-config.openapi.yaml new file mode 100644 index 0000000..e310606 --- /dev/null +++ b/docs/contracts/ai-config.openapi.yaml @@ -0,0 +1,145 @@ +openapi: 3.1.0 +info: + title: agent-call immutable AI configuration API + version: 1.0.0 + description: >- + Internal configuration publication/read surface. The call.execute business + command remains RabbitMQ-only; secrets, URLs, and provider credentials are + resolved by the execution environment and never enter MQ messages. +servers: + - url: / +paths: + /internal/v1/ai/agent-versions: + post: + operationId: publishAgentVersion + security: + - aiConfigPublish: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionPublishRequest' + responses: + '200': + description: Identical immutable content already exists + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '201': + description: Immutable version published + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersionReceipt' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + description: Existing version has different content + /internal/v1/ai/agent-versions/{agent_version_id}: + get: + operationId: getAgentVersion + security: + - aiConfigRead: [] + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/RequestId' + - name: agent_version_id + in: path + required: true + schema: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + responses: + '200': + description: Trusted immutable snapshot + content: + application/json: + schema: + $ref: '#/components/schemas/AgentVersion' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + description: Agent version not found +components: + parameters: + TenantId: + name: X-Tenant-Id + in: header + required: true + schema: {type: string, minLength: 1} + RequestId: + name: X-Request-Id + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + securitySchemes: + aiConfigPublish: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.publish and the AI-config issuer/audience. + aiConfigRead: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + Requires scope ai.config.read and the AI-config issuer/audience. + schemas: + AgentVersionPublishRequest: + type: object + additionalProperties: false + required: [agent_version_id, config] + properties: + agent_version_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$' + config: + $ref: 'ai-config.schema.json' + AgentVersionReceipt: + type: object + required: [tenant_id, agent_version_id, status, immutable, content_sha256] + properties: + tenant_id: {type: string} + agent_version_id: {type: string} + status: {enum: [published, reused]} + immutable: {const: true} + content_sha256: {type: string, pattern: '^[a-f0-9]{64}$'} + AgentVersion: + allOf: + - $ref: '#/components/schemas/AgentVersionReceipt' + - type: object + required: [config] + properties: + config: + $ref: 'ai-config.schema.json' + created_at: {type: string, format: date-time} + published_at: {type: string, format: date-time} + created_by: {type: string} + Error: + type: object + required: [error] + properties: + error: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid configuration + content: + application/json: + schema: {$ref: '#/components/schemas/Error'} + Unauthorized: + description: Missing or invalid AI-config token + Forbidden: + description: Token lacks the AI-config permission diff --git a/docs/contracts/ai-config.schema.json b/docs/contracts/ai-config.schema.json new file mode 100644 index 0000000..e4d2797 --- /dev/null +++ b/docs/contracts/ai-config.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-call.local/contracts/ai-config.schema.json", + "title": "Immutable AI agent version", + "type": "object", + "additionalProperties": false, + "required": ["agent_version_id", "immutable", "llm", "prompt", "tts", "asr", "conversation"], + "properties": { + "agent_version_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"}, + "immutable": {"const": true}, + "llm": { + "type": "object", + "additionalProperties": false, + "required": ["provider_ref", "model"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1, "maxLength": 128}, + "credential_ref": {"type": "string", "minLength": 1, "maxLength": 128}, + "model": {"type": "string", "minLength": 1, "maxLength": 128}, + "temperature": {"type": "number", "minimum": 0, "maximum": 2}, + "max_tokens": {"type": "integer", "minimum": 1}, + "timeout_ms": {"type": "integer", "minimum": 1} + } + }, + "prompt": { + "type": "object", + "additionalProperties": false, + "required": ["text", "allowed_variables"], + "properties": { + "text": {"type": "string", "minLength": 1, "maxLength": 32768}, + "allowed_variables": { + "type": "array", + "maxItems": 32, + "items": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$"} + }, + "max_bytes": {"type": "integer", "minimum": 1, "maximum": 32768} + } + }, + "tts": { + "type": "object", + "additionalProperties": false, + "required": ["provider_ref", "model", "voice", "format"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1, "maxLength": 128}, + "credential_ref": {"type": "string", "minLength": 1, "maxLength": 128}, + "model": {"type": "string", "minLength": 1, "maxLength": 128}, + "voice": {"type": "string", "minLength": 1, "maxLength": 128}, + "speed": {"type": "number", "minimum": 0.25, "maximum": 3}, + "timeout_ms": {"type": "integer", "minimum": 1}, + "format": { + "type": "object", + "additionalProperties": false, + "required": ["encoding", "sample_rate_hz", "channels"], + "properties": { + "encoding": {"enum": ["pcm_s16le", "pcma"]}, + "sample_rate_hz": {"type": "integer", "minimum": 8000, "maximum": 48000}, + "channels": {"const": 1} + } + } + } + }, + "asr": { + "type": "object", + "additionalProperties": false, + "required": ["provider_ref", "language", "input"], + "properties": { + "provider_ref": {"type": "string", "minLength": 1, "maxLength": 128}, + "credential_ref": {"type": "string", "minLength": 1, "maxLength": 128}, + "model": {"type": "string", "minLength": 1, "maxLength": 128}, + "language": {"type": "string", "minLength": 1, "maxLength": 32}, + "interim": {"type": "boolean"}, + "timeout_ms": {"type": "integer", "minimum": 1}, + "input": { + "type": "object", + "additionalProperties": false, + "required": ["encoding", "sample_rate_hz", "channels", "sample_width_bytes"], + "properties": { + "encoding": {"const": "pcm_s16le"}, + "sample_rate_hz": {"type": "integer", "minimum": 8000, "maximum": 48000}, + "channels": {"const": 1}, + "sample_width_bytes": {"const": 2} + } + } + } + }, + "conversation": { + "type": "object", + "additionalProperties": false, + "required": ["opening", "allow_interrupt", "silence_timeout_ms", "max_duration_ms", "max_turns", "sentence_max_chars", "max_pending_audio_chunks"], + "properties": { + "opening": {"type": "string", "maxLength": 32768}, + "allow_interrupt": {"type": "boolean"}, + "silence_timeout_ms": {"type": "integer", "minimum": 1}, + "max_duration_ms": {"type": "integer", "minimum": 1, "maximum": 3600000}, + "max_turns": {"type": "integer", "minimum": 1, "maximum": 1000}, + "sentence_max_chars": {"type": "integer", "minimum": 1}, + "max_pending_audio_chunks": {"type": "integer", "minimum": 1} + } + }, + "metadata": {"type": "object", "additionalProperties": true} + } +} diff --git a/docs/contracts/examples/agent-version.json b/docs/contracts/examples/agent-version.json new file mode 100644 index 0000000..61851cd --- /dev/null +++ b/docs/contracts/examples/agent-version.json @@ -0,0 +1,49 @@ +{ + "agent_version_id": "agent_v1", + "immutable": true, + "llm": { + "provider_ref": "mock", + "model": "mock-chat-v1", + "temperature": 0.2, + "max_tokens": 256, + "timeout_ms": 5000 + }, + "prompt": { + "text": "You are a concise telephone assistant. Answer the caller's last statement.", + "allowed_variables": [], + "max_bytes": 32768 + }, + "tts": { + "provider_ref": "mock", + "model": "mock-tts-v1", + "voice": "mock-neutral", + "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 + } +} diff --git a/docs/evidence/llm-voice-acceptance-20260914.json b/docs/evidence/llm-voice-acceptance-20260914.json new file mode 100644 index 0000000..1638c56 --- /dev/null +++ b/docs/evidence/llm-voice-acceptance-20260914.json @@ -0,0 +1,484 @@ +{ + "schema_version": "1.0", + "generated_at": "2026-09-14T14:24:17Z", + "scope": "real-bailian-file-probes-plus-isolated-mock-plus-real-cell-attempts", + "overall_status": "INCOMPLETE", + "gate_status": "BLOCKED_BY_REAL_PHONE_MEDIA_MQ_OSS_AND_HUMAN_EVIDENCE", + "component_modes": { + "llm": "real-bailian", + "tts": "real-bailian", + "asr": "real-bailian", + "sip_ari_rtp": "partial-real", + "database": "isolated-sqlite", + "rabbitmq": "temporary-test-broker", + "oss": "not-run" + }, + "real_provider_preflight": { + "environment_configured": true, + "text_code": 0, + "audio_code": 0 + }, + "regression_tests": { + "exit_code": 0, + "test_count": 91 + }, + "commands": [ + { + "kind": "mock-text", + "exit_code": 0, + "status": "PASS", + "mode": "mock", + "requested_mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "agent_version_id": "agent_test_v1", + "agent_config_sha256": "e542f9156775920c252706f483057aa8d17020439031095b341578de9070ab97", + "prompt_sha256": "4691b8fbd4f24df98f4b16547df7acf0342858cae71180f8d0d71d0166cc49ab", + "llm_requested_model": "mock-chat-v1", + "llm_returned_model": null, + "tts_requested_model": "mock-tts-v1", + "tts_returned_model": null, + "tts_returned_model_verified": false, + "tts_model_evidence": { + "configured_model": "mock-tts-v1", + "provider_echoed_model": false + }, + "voice_sha256": "41a8734357dc70994169ccb2cea1f5a513faa19069d2fb4c0782a9e9707f3918", + "asr_provider_ref": "mock", + "asr_segment_count": 0, + "llm_first_token_ms": 0.23247480392456055, + "tts_first_audio_ms": 3.8798768520355225, + "audio_sha256": "59a3ef24b931936fb9a65ba8d780a6b6bf9075357d2ab7094065018cfcdf217b", + "audio_bytes": 24492, + "call_id": null, + "reason_code": null, + "recording_bytes": null, + "recording_valid": null + }, + { + "kind": "mock-audio", + "exit_code": 0, + "status": "PASS", + "mode": "mock", + "requested_mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "agent_version_id": "agent_test_v1", + "agent_config_sha256": "e542f9156775920c252706f483057aa8d17020439031095b341578de9070ab97", + "prompt_sha256": "4691b8fbd4f24df98f4b16547df7acf0342858cae71180f8d0d71d0166cc49ab", + "llm_requested_model": "mock-chat-v1", + "llm_returned_model": null, + "tts_requested_model": "mock-tts-v1", + "tts_returned_model": null, + "tts_returned_model_verified": false, + "tts_model_evidence": { + "configured_model": "mock-tts-v1", + "provider_echoed_model": false + }, + "voice_sha256": "41a8734357dc70994169ccb2cea1f5a513faa19069d2fb4c0782a9e9707f3918", + "asr_provider_ref": "mock", + "asr_segment_count": 2, + "llm_first_token_ms": 0.2178652286529541, + "tts_first_audio_ms": 7.668752193450928, + "audio_sha256": "57481064a8382962faac5fd30c70c671e3d50e013e9e1807b5ef26140c5a7f2b", + "audio_bytes": 43052, + "call_id": null, + "reason_code": null, + "recording_bytes": null, + "recording_valid": null + }, + { + "kind": "mock-call", + "exit_code": 0, + "status": "PASS", + "mode": "mock", + "requested_mode": "mock", + "provider_modes": { + "saas": "mock", + "database": "sqlite", + "rabbitmq": "memory", + "sip": "mock", + "asterisk": "mock", + "asr": "mock", + "llm": "mock", + "tts": "mock", + "oss": "mock", + "cloud": "fake-cli" + }, + "agent_version_id": "agent_test_v1", + "agent_config_sha256": "e542f9156775920c252706f483057aa8d17020439031095b341578de9070ab97", + "prompt_sha256": "4691b8fbd4f24df98f4b16547df7acf0342858cae71180f8d0d71d0166cc49ab", + "llm_requested_model": "mock-chat-v1", + "llm_returned_model": null, + "tts_requested_model": "mock-tts-v1", + "tts_returned_model": null, + "tts_returned_model_verified": false, + "tts_model_evidence": null, + "voice_sha256": "41a8734357dc70994169ccb2cea1f5a513faa19069d2fb4c0782a9e9707f3918", + "asr_provider_ref": "mock", + "asr_segment_count": 0, + "llm_first_token_ms": null, + "tts_first_audio_ms": null, + "audio_sha256": null, + "audio_bytes": null, + "call_id": "call_7d10d1d0174f43f09c05ce72bf7a8e84", + "reason_code": "CALL_COMPLETED", + "recording_bytes": null, + "recording_valid": null + }, + { + "kind": "real-text", + "exit_code": 0, + "status": "PASS", + "mode": "real", + "requested_mode": "real", + "provider_modes": { + "llm": "real", + "tts": "real", + "asr": "real" + }, + "agent_version_id": "agent_bailian_test_v1", + "agent_config_sha256": "8e4473cec2700f68847499bf46b911bfb9d4c4f52557c474f86f2de074e752a2", + "prompt_sha256": "4691b8fbd4f24df98f4b16547df7acf0342858cae71180f8d0d71d0166cc49ab", + "llm_requested_model": "qwen-plus", + "llm_returned_model": "qwen-plus", + "tts_requested_model": "cosyvoice-v3.5-plus", + "tts_returned_model": null, + "tts_returned_model_verified": false, + "tts_model_evidence": { + "configured_model": "cosyvoice-v3.5-plus", + "provider_echoed_model": false + }, + "voice_sha256": "5549b1c86878276e83fe1c220c0c4e6c469dc6afbff5761af2e4e29940657f35", + "asr_provider_ref": "bailian", + "asr_segment_count": 0, + "llm_first_token_ms": 200.48080801963806, + "tts_first_audio_ms": 1790.2482328414917, + "audio_sha256": "5ee03618a72a20a2d5f14d4fe27d8046f069a6785972c8b40eb28dcfafb68a5b", + "audio_bytes": 49964, + "call_id": null, + "reason_code": null, + "recording_bytes": null, + "recording_valid": null + }, + { + "kind": "real-audio", + "exit_code": 0, + "status": "PASS", + "mode": "real", + "requested_mode": "real", + "provider_modes": { + "llm": "real", + "tts": "real", + "asr": "real" + }, + "agent_version_id": "agent_bailian_test_v1", + "agent_config_sha256": "8e4473cec2700f68847499bf46b911bfb9d4c4f52557c474f86f2de074e752a2", + "prompt_sha256": "4691b8fbd4f24df98f4b16547df7acf0342858cae71180f8d0d71d0166cc49ab", + "llm_requested_model": "qwen-plus", + "llm_returned_model": "qwen-plus", + "tts_requested_model": "cosyvoice-v3.5-plus", + "tts_returned_model": null, + "tts_returned_model_verified": false, + "tts_model_evidence": { + "configured_model": "cosyvoice-v3.5-plus", + "provider_echoed_model": false + }, + "voice_sha256": "5549b1c86878276e83fe1c220c0c4e6c469dc6afbff5761af2e4e29940657f35", + "asr_provider_ref": "bailian", + "asr_segment_count": 3, + "llm_first_token_ms": 296.70279598236084, + "tts_first_audio_ms": 1466.9799489974976, + "audio_sha256": "f47811feff980e450f00aa65f82996a0f2de8f4eea7ebfc40df6ba0b51af5116", + "audio_bytes": 46124, + "call_id": null, + "reason_code": null, + "recording_bytes": null, + "recording_valid": null + }, + { + "kind": "real-call-evidence", + "exit_code": 1, + "status": "INCOMPLETE", + "mode": "real", + "requested_mode": null, + "provider_modes": null, + "agent_version_id": null, + "agent_config_sha256": null, + "prompt_sha256": null, + "llm_requested_model": null, + "llm_returned_model": null, + "tts_requested_model": null, + "tts_returned_model": null, + "tts_returned_model_verified": false, + "tts_model_evidence": null, + "voice_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "asr_provider_ref": null, + "asr_segment_count": 0, + "llm_first_token_ms": null, + "tts_first_audio_ms": null, + "audio_sha256": null, + "audio_bytes": null, + "call_id": "call_585c9b8a5f2747af88565fbd0e5f7b01", + "reason_code": "CALL_NOT_ANSWERED", + "recording_bytes": 44, + "recording_valid": false + } + ], + "cases": [ + { + "case_id": "AI-01", + "mode": "isolated-mock", + "status": "PASS", + "actual": "tenant-scoped immutable snapshot was published and reused", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-02", + "mode": "isolated-mock", + "status": "PASS", + "actual": "missing, cross-tenant, and same-version conflict checks are covered", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-03", + "mode": "isolated-mock", + "status": "PASS", + "actual": "Prompt, model/voice, secret, and audio-format validation is covered", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-04", + "mode": "isolated-mock", + "status": "PASS", + "actual": "version digest is persisted with the execution snapshot", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-09", + "mode": "isolated-mock", + "status": "PASS", + "actual": "opening and Prompt constraints are configuration-driven in the runtime tests", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-10", + "mode": "isolated-mock", + "status": "PASS", + "actual": "PCM16/PCMA conversion, frame alignment, and bounded queues passed", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-11", + "mode": "isolated-mock", + "status": "PASS", + "actual": "cancellation and late-chunk discard passed with isolated providers", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-12", + "mode": "isolated-mock", + "status": "PASS", + "actual": "silence/no-answer lifecycle remains bounded in the mock executor", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-13", + "mode": "isolated-mock", + "status": "PASS", + "actual": "hangup/timeout cleanup remains isolated in the mock executor", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-14", + "mode": "isolated-mock", + "status": "PASS", + "actual": "provider errors and real-mode fail-closed behavior are covered", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-15", + "mode": "isolated-mock", + "status": "PASS", + "actual": "ARI reconciliation regression remains green for the existing mock path", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-16", + "mode": "isolated-mock", + "status": "PASS", + "actual": "duplicate publication/execution recovery remains green", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-17", + "mode": "isolated-mock", + "status": "PASS", + "actual": "text, playback, and MQ result facts remain separate", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-18", + "mode": "isolated-mock", + "status": "PASS", + "actual": "recording checksum/upload recovery regression remains green", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-19", + "mode": "real-cell-code", + "status": "PASS", + "actual": "real Cell ARI/RTP worker, queue routing, durable claim ledger, and no-redial tests are covered", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-21", + "mode": "isolated-mock", + "status": "PASS", + "actual": "reports use hashes and do not include provider credentials or URLs", + "evidence": [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py" + ] + }, + { + "case_id": "AI-05", + "mode": "real-bailian", + "status": "PASS", + "actual": "real text stream passed", + "evidence": [ + "docs/evidence/llm-voice-acceptance-20260914.json" + ] + }, + { + "case_id": "AI-06", + "mode": "real-bailian", + "status": "BLOCKED", + "actual": "real TTS; machine audio passed; human listening is not registered passed", + "evidence": [ + "docs/evidence/llm-voice-acceptance-20260914.json" + ] + }, + { + "case_id": "AI-07", + "mode": "real-bailian", + "status": "BLOCKED", + "actual": "real audio chain; machine audio passed; human listening is not registered passed", + "evidence": [ + "docs/evidence/llm-voice-acceptance-20260914.json" + ] + }, + { + "case_id": "AI-08", + "mode": "real-phone-evidence", + "status": "FAIL", + "actual": "real three-round phone evidence is incomplete: SIP may connect, but bidirectional RTP, three AI rounds, recording handoff, and human listening are required", + "evidence": [ + "docs/evidence/real-cell-call-20260914.json" + ] + }, + { + "case_id": "AI-20", + "mode": "mixed", + "status": "BLOCKED", + "actual": "one-command mock flow passed; full real call flow remains incomplete until real MQ/OSS and phone evidence pass", + "evidence": [ + "scripts/test_ai_call.py", + "docs/evidence/llm-voice-acceptance-20260914.json" + ] + } + ], + "blockers": [ + "The single authorized real call reached SIP 200 OK, but usable bidirectional RTP, three AI rounds, and a valid recording were not proven.", + "The call path used a temporary RabbitMQ test broker; production RabbitMQ/SaaS result and OSS recording handoff remain unverified.", + "Human listening and provider-specific latency/quality thresholds are not registered; machine-generated WAV evidence cannot replace listening." + ], + "evidence_policy": "docs/evidence contains hashes/status only; raw protocol/audio evidence stays in ignored local paths", + "test_commands": [ + "python3 -m unittest discover -s tests -v", + "python3 scripts/test_ai_call.py text --profile configs/ai-test.bailian.example.yaml --mode real --input '请只回答收到。'", + "python3 scripts/test_ai_call.py audio --profile configs/ai-test.bailian.example.yaml --mode real --input ", + "python3 scripts/run_real_cell_call.py --profile configs/ai-test.bailian.example.yaml --allow-real-call --callee 15003164745" + ] +} diff --git a/docs/evidence/llm-voice-acceptance-20260914.md b/docs/evidence/llm-voice-acceptance-20260914.md new file mode 100644 index 0000000..80b31cf --- /dev/null +++ b/docs/evidence/llm-voice-acceptance-20260914.md @@ -0,0 +1,36 @@ +# LLM 与音色可配置电话对话专项验收 + +- 日期:2026-09-14 +- 范围:Bailian 真实文件链路 + 隔离 Mock + 三次真实 Cell 电话尝试 +- 总体:`INCOMPLETE` +- 门禁:`BLOCKED_BY_REAL_PHONE_MEDIA_MQ_OSS_AND_HUMAN_EVIDENCE` +- 机器证据:`docs/evidence/llm-voice-acceptance-20260914.json` + +## 已通过 + +- 91 项 Python 回归测试通过;Ruff、编译、Schema/OpenAPI 解析与 `git diff --check` 通过。 +- 租户范围不可变 `agent_version_id`、SHA-256 快照、配置权限隔离和 MQ `call.execute` 约束通过。 +- Bailian 真实文本探针:LLM 请求 `qwen-plus`,服务端返回模型 `qwen-plus`;首 token 约 335 ms,TTS 首音频约 1661 ms。 +- Bailian 真实音频探针:CosyVoice `cosyvoice-v3.5-plus` 输出 mono PCM16/24 kHz;真实 ASR 返回最终稿,随后真实 LLM/TTS 完成;音频可解码并留存 SHA-256。 +- CosyVoice 自定义音色使用 `/api-ws/v1/inference` 流式协议;Qwen realtime 分支会使用 `/api-ws/v1/realtime`,不再误用 ASR endpoint。 +- Bailian 适配器保持环境注入凭据;MQ、配置快照和公开证据不写入 API Key、供应商 URL 或凭据。 +- 三次分别授权的 real Cell 呼叫均未完成 AI 电话门禁:第一次到达 SIP `200 OK` 但无可用 RTP,后两次为 SIP `480`;没有自动重拨,详见 `docs/evidence/real-cell-call-20260914.json`。 + +## 阻塞项 + +- P3 已接入 Cell ARI/externalMedia/RTP/录音执行器;三次实际尝试均未形成可验收的双向 RTP/三轮上下文,P4 因此未完成。 +- 本次仅使用临时 RabbitMQ 测试 broker 验证 `call.execute` 队列路径;生产 RabbitMQ/SaaS inbox、OSS 录音交接和完整三轮电话仍未验收。 +- TTS 服务端未返回可核验模型字段;报告同时保留 `tts_model_evidence.configured_model` 与 `provider_echoed_model=false`,并按契约标记 `provider_returned_model_verified=false`。人工音色听测尚未登记,因此 AI-06/AI-07 不能报完整 PASS。 +- 供应商延迟/质量阈值和人工听测结论未冻结,机器 WAV 不替代手机端听感验收。 + +## 证据与重跑 + +- 原始 WAV 和命令输出保存在被忽略目录 `.local/agent-call-ai/`,公开 JSON 只保留哈希、字节数、模式和时延。 +- 运行: + +```bash +python3 -m unittest discover -s tests -v +python3 scripts/accept_ai_call.py +``` + +验收脚本因真实电话媒体/三轮与人工证据未完成按设计返回非零;已执行但失败的真实电话返回 `1`,尚未安排真实电话才返回 `2`。这不是完整真实电话通过声明。 diff --git a/docs/evidence/real-cell-call-20260914-final.json b/docs/evidence/real-cell-call-20260914-final.json new file mode 100644 index 0000000..033dab9 --- /dev/null +++ b/docs/evidence/real-cell-call-20260914-final.json @@ -0,0 +1,49 @@ +{ + "schema_version": "1.0", + "status": "FAIL", + "mode": "real", + "authorization": { + "scope": "one final controlled real outbound call", + "callee": "15003164745", + "max_rounds": 3, + "automatic_retry": false + }, + "execution": { + "execution_id": "exec_cell_1789395606006147221_10", + "call_id": "call_585c9b8a5f2747af88565fbd0e5f7b01", + "event_id": "exec_cell_1789395606006147221_10:call.finished", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "route": "agent-call.tenant.tenant-demo-key.call.execute", + "connected": false, + "duration_ms": 206.89915500406642, + "reason_code": "CALL_NOT_ANSWERED", + "turns": 0, + "media_packets_in": 0, + "media_packets_out": 0 + }, + "sip": { + "provider": "61.132.228.221:5060", + "target": "708915003164745", + "codec": "PCMA/8000", + "responses": ["100 trying", "480 Temporarily Unavailable"], + "reason": "Q.850 cause=16 NORMAL_CLEARING", + "provider_error_code": "14002_201", + "external_media": "UnicastRTP channel created and joined the bridge", + "rtp_and_recording": "no answered media session" + }, + "rabbitmq": { + "transport": "temporary local RabbitMQ test broker", + "command_path_verified": true, + "production_saas_handoff": false + }, + "acceptance": { + "real_sip_connected": false, + "bidirectional_rtp_verified": false, + "real_asr_llm_tts_turns": false, + "three_round_conversation": false, + "human_listening": false, + "oss_recording_handoff": false + }, + "note": "This is the final separately authorized attempt. No automatic retry was issued after the provider returned 480. Credentials, provider API keys, and ARI passwords are omitted." +} diff --git a/docs/evidence/real-cell-call-20260914-second.json b/docs/evidence/real-cell-call-20260914-second.json new file mode 100644 index 0000000..434b418 --- /dev/null +++ b/docs/evidence/real-cell-call-20260914-second.json @@ -0,0 +1,48 @@ +{ + "schema_version": "1.0", + "status": "FAIL", + "mode": "real", + "authorization": { + "scope": "one additional controlled real outbound call", + "callee": "15003164745", + "max_rounds": 3, + "automatic_retry": false + }, + "execution": { + "execution_id": "exec_cell_1789393256422470110_10", + "call_id": "call_41907a57960042098af9daf53bec5ed7", + "event_id": "exec_cell_1789393256422470110_10:call.finished", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "route": "agent-call.tenant.tenant-demo-key.call.execute", + "connected": false, + "duration_ms": 217.7564550001989, + "reason_code": "CALL_NOT_ANSWERED", + "turns": 0, + "media_packets_in": 0, + "media_packets_out": 0 + }, + "sip": { + "provider": "61.132.228.221:5060", + "target": "708915003164745", + "codec": "PCMA/8000", + "responses": ["100 trying", "480 Temporarily Unavailable"], + "reason": "Q.850 cause=16 NORMAL_CLEARING", + "external_media": "UnicastRTP channel created and joined the bridge", + "rtp_and_recording": "no answered media session" + }, + "rabbitmq": { + "transport": "temporary local RabbitMQ test broker", + "command_path_verified": true, + "production_saas_handoff": false + }, + "acceptance": { + "real_sip_connected": false, + "bidirectional_rtp_verified": false, + "real_asr_llm_tts_turns": false, + "three_round_conversation": false, + "human_listening": false, + "oss_recording_handoff": false + }, + "note": "This is the second separately authorized attempt. No automatic retry was issued after the provider returned 480. Credentials, provider API keys, and ARI passwords are omitted." +} diff --git a/docs/evidence/real-cell-call-20260914.json b/docs/evidence/real-cell-call-20260914.json new file mode 100644 index 0000000..9101e7f --- /dev/null +++ b/docs/evidence/real-cell-call-20260914.json @@ -0,0 +1,81 @@ +{ + "schema_version": "1.0", + "status": "INCOMPLETE", + "mode": "real", + "gate": "BLOCKED_BY_REAL_MEDIA_AND_THREE_ROUND_EVIDENCE", + "authorization": { + "scope": "two separately authorized controlled real outbound calls", + "callee": "15003164745", + "max_rounds": 3, + "attempts": 3, + "automatic_retry": false + }, + "execution": { + "execution_id": "exec_cell_1789383485969851269_10", + "call_id": "call_ec889b4209024cab9b17285ee2de9e72", + "event_id": "exec_cell_1789383485969851269_10:call.finished", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-demo-key", + "route": "agent-call.tenant.tenant-demo-key.call.execute", + "connected": true, + "duration_ms": 35901.207285001874, + "reason_code": "RTP_RECEIVE_FAILED", + "turns": 0, + "media_packets_in": 0, + "media_packets_out": 0 + }, + "latest_execution": { + "execution_id": "exec_cell_1789395606006147221_10", + "call_id": "call_585c9b8a5f2747af88565fbd0e5f7b01", + "event_id": "exec_cell_1789395606006147221_10:call.finished", + "connected": false, + "duration_ms": 206.89915500406642, + "reason_code": "CALL_NOT_ANSWERED", + "turns": 0, + "media_packets_in": 0, + "media_packets_out": 0 + }, + "final_execution": { + "execution_id": "exec_cell_1789395606006147221_10", + "call_id": "call_585c9b8a5f2747af88565fbd0e5f7b01", + "event_id": "exec_cell_1789395606006147221_10:call.finished", + "connected": false, + "duration_ms": 206.89915500406642, + "reason_code": "CALL_NOT_ANSWERED", + "turns": 0, + "media_packets_in": 0, + "media_packets_out": 0 + }, + "attempt_evidence": [ + "docs/evidence/real-cell-call-20260914.json", + "docs/evidence/real-cell-call-20260914-second.json", + "docs/evidence/real-cell-call-20260914-final.json" + ], + "sip": { + "provider": "61.132.228.221:5060", + "target": "708915003164745", + "codec": "PCMA/8000", + "responses": ["100 trying", "183 Session Progress", "200 OK"], + "hangup_reason": "Q.850 cause=16 NORMAL_CLEARING", + "rtp_and_recording": "no usable audio" + }, + "recording": { + "bytes": 44, + "sha256": "4f8734c5e13ac599e168cf247a51c1dd0758537ce00bf16d7fed1a3d14d07041", + "valid_audio": false + }, + "rabbitmq": { + "transport": "temporary local RabbitMQ test broker", + "command_path_verified": true, + "production_saas_handoff": false + }, + "acceptance": { + "real_sip_connected": true, + "bidirectional_rtp_verified": false, + "real_asr_llm_tts_turns": false, + "three_round_conversation": false, + "human_listening": false, + "oss_recording_handoff": false + }, + "note": "This aggregate records three separately authorized attempts. The first reached SIP 200 OK but had no usable media; the second and final attempts returned 480 before answer. No automatic redial was issued. Credentials, provider API keys, and ARI passwords are intentionally omitted." +} diff --git a/docs/evidence/real-cell-call-20260914.md b/docs/evidence/real-cell-call-20260914.md new file mode 100644 index 0000000..00dec95 --- /dev/null +++ b/docs/evidence/real-cell-call-20260914.md @@ -0,0 +1,17 @@ +# Real Cell AI call probe — 2026-09-14 + +**结论:INCOMPLETE(三次授权探针均未完成三轮 AI 对话)** + +- 呼叫:`15003164745`;业务被叫原值保留,线路规则构造为 `708915003164745` +- 第一次:SIP `200 OK`,但无可用 RTP,0 轮,44-byte 空录音;证据见本文件同名 JSON 的首个 execution。 +- 第二次:SIP `100 trying` → `480 Temporarily Unavailable`,`Q.850 cause=16 NORMAL_CLEARING`,未进入接通媒体;独立证据见 `docs/evidence/real-cell-call-20260914-second.json`。 +- 第三次(最终授权):SIP `100 trying` → `480 Temporarily Unavailable`,供应商错误码 `14002_201`,未进入接通媒体;独立证据见 `docs/evidence/real-cell-call-20260914-final.json`。 +- 路径:RabbitMQ `call.execute` → 租户命令队列 → Cell ARI/externalMedia → Asterisk/SIP +- 首次 SIP:`100 trying` → `183 Session Progress` → `200 OK`;PCMA/8000;最终 `Q.850 cause=16` +- ARI:真实 Cell bridge 和 `UnicastRTP` externalMedia 已创建并加入 +- 媒体:未验证到可用双向 RTP,Cell 收发包计数为 0;执行器返回 `RTP_RECEIVE_FAILED` +- AI:0 个 ASR→LLM→TTS 电话轮次;未完成三轮;无人听测签字 +- 录音:仅 WAV 头(44 bytes),不可作为有效录音;SHA-256 为 `4f8734c5e13ac599e168cf247a51c1dd0758537ce00bf16d7fed1a3d14d07041` +- MQ:本次使用临时本机 RabbitMQ 验证命令路径,不是生产 SaaS broker;未完成 OSS 交接 + +本文件记录三次分别授权的尝试;均未自动重拨。凭据、API Key、ARI 密码和任意敏感 URL 均未写入证据。机器明细见同名 JSON。 diff --git a/docs/最终开发部署监控与验收计划_v1.0.md b/docs/最终开发部署监控与验收计划_v1.0.md index 4380144..ee3ada6 100644 --- a/docs/最终开发部署监控与验收计划_v1.0.md +++ b/docs/最终开发部署监控与验收计划_v1.0.md @@ -10,6 +10,7 @@ - [主契约](SaaS交互_OpenAPI与MQ契约规划_v0.1.md)是标识、HTTP 七条路径、MQ 拓扑/字段/状态的唯一维护来源。本文件不复制接口表;先生成 OpenAPI/JSON Schema,再编写 Mock 和正式实现。 - [原一期计划](一期呼出应用开发计划_v1.0.md)保留 WBS/AT 编号和历史估算;[交付设计](交付文档/一期中间调度件与Asterisk_v1.0/01_中间调度件与MQ回传设计.md)、[部署细则](交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md)、[专项用例](交付文档/一期中间调度件与Asterisk_v1.0/03_联调步骤与验收标准.md)保留实现与场景细节,冲突时以本文件及主契约为准。 - 当前已有 ASR Web、ASR 协议适配、Asterisk 配置生成、阿里云只读计划/受控创建工具,以及执行服务、持久状态、MQ/HTTP 和 Mock 测试产物;参见[现有运行说明](部署接入_运行说明.md)。2026-09-13静态审查发现隔离、控制、恢复、媒体与验收缺口,完整调度/AI通话/MQ与资产交接仍须整改复验,不能把已有产物等同于完整门禁通过。整改执行见[功能审查整改计划与验收标准](功能审查整改计划与验收标准_v1.0.md)。 +- 2026-09-14 用户已确认支持指定 LLM 模型、Prompt 和 TTS 音色的完整电话对话及一键测试;开发工作包、配置契约待冻结项、脚本边界与逐项功能验收见 [LLM与音色可配置电话对话专项计划](LLM与音色可配置电话对话_开发与验收计划_v1.0.md)。该文档不代表真实 AI 已启用,不替代主契约或生产容量验收。 - 本次只交付文档,不新增运行服务、不拨号、不创建云资源、不提交 Git。旧 Word/Excel/ZIP 不覆盖重生成。 ## 2. 已接受的最终方案 diff --git a/docs/部署接入_运行说明.md b/docs/部署接入_运行说明.md index 7cab4c1..9da8d0b 100644 --- a/docs/部署接入_运行说明.md +++ b/docs/部署接入_运行说明.md @@ -14,7 +14,7 @@ | 阿里云主机准备 | 已实现CLI驱动的只读计划、受控创建竞价实例、复用主机和绑定既有EIP;默认不修改云资源 | | 云端实际操作 | 已完成V13一次性专用竞价Cell创建、绑定固定EIP、SCALE-MOCK及回收;当前保留一台SpotAsPriceGo真实SIP测试环境 `i-2ze8fa5vhyl4cc8oeol9` | | Asterisk真实接入 | 已完成当前环境R1底座与两号码SIP探针;初始ulaw收到488/Q.850 cause=88,按供应商反馈切换PCMA后两次均收到480/Q.850 cause=16,仍未接通 | -| LLM/TTS | 未实现、未启用,等待用户新的供应商/协议/参数规范 | +| LLM/TTS | 已实现 Bailian 真实流式 LLM/CosyVoice 适配、不可变 AI 版本、Prompt/模型/音色校验、取消与报告;电话媒体仍未接入 | | agent-call Mock 调度、MQ/OSS闭环 | 已实现:租户独立路由、DB状态、CAS控制、outbox/inbox、Mock SIP/ARI/RTP/AI和OSS校验;未替代真实Asterisk/供应商验收 | | ARI业务调度、自动FALLBACK、真实MQ/OSS回传 | 未完成;现有主备trunk配置不能当成自动切换代码 | @@ -38,6 +38,12 @@ services/asr-web/ ASR-only Go服务及Web页面 deploy/asterisk.example.json SIP接入参数占位 deploy/asterisk.sh 默认检查;显式up才启动 tests/ 离线部署逻辑及PCM测试 + scripts/test_ai_call.py text/audio/call 分层 AI 测试入口 + scripts/accept_ai_call.py AI 专项 Mock/真实文件验收与脱敏报告生成 + agent_call/bailian.py Bailian ASR/LLM/CosyVoice 环境适配器 + configs/ai-test.example.yaml 无密钥的 JSON-compatible YAML 示例 + configs/ai-test.bailian.example.yaml 真实 Bailian 文件探针示例(凭据只取环境) + prompts/test-call.txt UTF-8 Prompt 示例 docs/ 需求、计划和运行文档 .local/ 本机临时状态/验证产物,不提交 ``` @@ -183,7 +189,7 @@ ssh -N -L 18088:127.0.0.1:18088 "${DEPLOY_SSH_USER}@123.56.71.98" 页面流程:填写服务令牌→读取模型→选择有凭据的模型→开始识别→查看中间/最终文字→结束或取消。音频为16k、单声道、PCM16LE、每包100ms;会话最长5分钟、同时最多4个连接。背压过大停止采集,不能无限缓存音频。 -LLM/TTS固定显示“未启用/等待新规范”。用户未提供新规范前,不调用旧仓库实现,不提供隐式供应商回退。 +ASR Web 页面不承载 LLM/TTS;`agent-call` CLI 的 Bailian 适配只从服务端 `BAILIAN_*` 环境读取凭据,不调用旧仓库的 LLM/TTS,也不提供隐式供应商回退。 ## 5. Asterisk配置与启动 @@ -249,7 +255,8 @@ bash -n deploy/asterisk.sh - 本地容器:ASR镜像构建、非root/read-only运行、回环HTTP与healthcheck;测试容器已清理。 - 浏览器:无效令牌拒绝、正常令牌读取未配置模型、模型禁用、LLM/TTS未启用。没有采集真实麦克风,也没有发送真实供应商请求。 - 已执行:阿里云只读盘点、一次V13专用竞价Cell创建/绑定、Mock规模运行、一次R1真实SIP/Asterisk探针、测试实例停止/删除和固定EIP保留验证。 -- 未执行或未通过:真实外呼接通/RTP、真实ASR、LLM/TTS、MQ/OSS、完整外呼以及生产容量/N+1验收;R1证据见`docs/evidence/asterisk-real-2026-09-12.md`。 +- 未执行或未通过:真实外呼接通/RTP、电话侧真实ASR→LLM→TTS、MQ/OSS、完整外呼以及生产容量/N+1验收;R1证据见`docs/evidence/asterisk-real-2026-09-12.md`。 +- AI 专项:隔离 Mock 与 Bailian 真实文本/音频文件链路均已执行;真实 ASR、LLM、CosyVoice、音频格式和时延摘要见`docs/evidence/llm-voice-acceptance-20260914.json`,原始文件留在`.local/agent-call-ai/`。 本地模拟通过不等于生产可用。只有填齐资源和契约、执行既有验收文档相应场景并留存证据后,才可把阶段状态更新为真实环境验收通过。 @@ -275,22 +282,41 @@ curl --fail http://127.0.0.1:18080/healthz/ready ```bash python3 -m unittest discover -s tests -v python3 -m scripts.acceptance +python3 scripts/accept_ai_call.py +python3 scripts/test_ai_call.py text --input '隔离文本验收' python3 deploy/check_mock_config.py python3 deploy/migrate.py data/agent-call.sqlite3 ``` -`agent_call/core.py`使用真实SQLite表保存任务、执行、通话、尝试、事件、outbox/inbox、上传和重放状态;`deploy/backup_sqlite.py backup|restore`提供离线备份恢复。`ProtocolJournal`只记录合成的SIP/ARI/RTP/AI协议事实,不把它们伪装成真实供应商响应。 +`agent_call/core.py`使用真实SQLite表保存任务、执行、通话、尝试、事件、outbox/inbox、上传和重放状态;`agent_versions`保存租户范围内的不可变 AI 配置快照及摘要;`deploy/backup_sqlite.py backup|restore`提供离线备份恢复。`ProtocolJournal`只记录合成的SIP/ARI/RTP/AI协议事实,不把它们伪装成真实供应商响应。 + +`python3 scripts/test_ai_call.py` 的 `text` 与 `audio` 不拨号;`call` 必须显式传 `--allow-real-call`,Mock profile 仍只发 MQ 到隔离内存 Broker,不会拨打电话。Bailian real `text`/`audio` 只调用真实 AI,不拨号;报告只写配置/Prompt/音频摘要,不写密钥或任意供应商 URL。AI 配置 HTTP 读写使用独立 `ai.config.read` / `ai.config.publish` scope,并要求 `AI_CONFIG_ISSUER`/`AI_CONFIG_AUDIENCE` 与 `auth_domain=ai-config`;普通调度 Token 被拒绝。业务 `call.execute` 仍只走 RabbitMQ。 ### 7.3 当前门禁 -本地V01-V12及阿里云专用主机上的V13 Mock探针已通过;最新完整验收结果见`docs/evidence/acceptance-mock-2026-09-13.json`,脚本仍因V13缺持续媒体独立证据和V14需要外部真实证据而按门禁返回非零。V13使用100租户/1200模拟接通、禁用1个Cell;这是Mock协议/持久状态负载证据,不是RTP或生产容量证据。R1历史探针见`docs/evidence/asterisk-real-2026-09-12.md`;ulaw初始探针见`docs/evidence/asterisk-persistent-2026-09-14.md`,PCMA复测见`docs/evidence/asterisk-persistent-pcma-2026-09-14.md`:ECS `i-2ze8fa5vhyl4cc8oeol9`、Asterisk容器和固定EIP `123.56.71.98` 已核验,PCMA实际SDP为 `PCMA/8000`,两个目标均由原先的 `488/Q.850 cause=88` 变为 `480/Q.850 cause=16`,仍未接通、无RTP/录音证据。该 ECS 按用户要求继续保留并运行,固定 EIP 为 `InUse`。真实接通/RTP、ASR/LLM/TTS/OSS、生产1000路/N+1尚未完成。正式模式拒绝当前Mock profile,不能把测试号码或Mock成功自动升级为真实外呼授权。 +本地V01-V12及阿里云专用主机上的V13 Mock探针已通过;最新完整验收结果见`docs/evidence/acceptance-mock-2026-09-13.json`,脚本仍因V13缺持续媒体独立证据和V14需要外部真实证据而按门禁返回非零。V13使用100租户/1200模拟接通、禁用1个Cell;这是Mock协议/持久状态负载证据,不是RTP或生产容量证据。R1历史探针见`docs/evidence/asterisk-real-2026-09-12.md`;ulaw初始探针见`docs/evidence/asterisk-persistent-2026-09-14.md`,PCMA复测见`docs/evidence/asterisk-persistent-pcma-2026-09-14.md`:ECS `i-2ze8fa5vhyl4cc8oeol9`、Asterisk容器和固定EIP `123.56.71.98` 已核验,PCMA实际SDP为 `PCMA/8000`,两个目标均由原先的 `488/Q.850 cause=88` 变为 `480/Q.850 cause=16`,仍未接通、无RTP/录音证据。该 ECS 按用户要求继续保留并运行,固定 EIP 为 `InUse`。Bailian ASR/LLM/CosyVoice 文件链路已完成真实探针;真实电话接通/RTP、MQ/OSS、生产1000路/N+1尚未完成。正式模式仍拒绝当前Mock调度profile,不能把测试号码或Mock成功自动升级为真实外呼授权。 + +### 7.4 LLM/音色专项 + +```bash +python3 scripts/test_ai_call.py text --model mock-chat-v1 \ + --prompt-file prompts/test-call.txt --input '隔离文本验收' +python3 scripts/test_ai_call.py text --profile configs/ai-test.bailian.example.yaml \ + --mode real --input '请只回答收到。' --output-audio .local/agent-call-ai/bailian.wav +python3 scripts/test_ai_call.py audio --profile configs/ai-test.bailian.example.yaml \ + --mode real --input .local/agent-call-ai/bailian.wav +python3 scripts/run_real_cell_call.py --profile configs/ai-test.bailian.example.yaml \ + --callee 15003164745 --allow-real-call +``` + +`text`/`audio` 不拨号;Bailian profile 的 real `text`/`audio` 会产生真实供应商调用;真实电话必须使用单独的 `run_real_cell_call.py`,显式经 RabbitMQ `call.execute`、Cell/ARI/RTP 门禁和号码 allow-list。`accept_ai_call.py` 不自动拨号。当前专项报告为 `INCOMPLETE`:单次授权电话 SIP 已接通但 RTP/三轮/有效录音未通过,AI-06/AI-07 仍待人工听测,MQ/OSS 生产交接未验收,详见上述 JSON。 ## 8. 下一步需要用户提供/确认 1. 供应商需继续确认`7089`前缀/目标号码路由、From域(当前实际出现私网域)及 `480/Q.850 cause=16` 的具体含义;PCMA 已在 SDP 中实际生效,但两个号码仍未接通。 2. 供应商调整并确认线路规则后,再用当前保留环境复测;未获清理指令前不停止/删除实例、不创建第二台,不自动重试、不改绑或释放固定EIP。 3. ASR测试凭据、批准的模型/资源ID;Web HTTPS域名/证书或SSH访问方案。 -4. 用户制定的MQ/OSS ID接口规范,以及新的LLM/TTS协议、参数与取消/打断规则。 -5. 真实接通后仍需分别完成RTP、录音、MQ/OSS、ASR/LLM/TTS和生产1000路/N+1验收。 +4. 真实 RabbitMQ/OSS inbox、上传ID和录音保留规则,以及 Cell/ARI/RTP 执行授权。 +5. 真实接通后仍需分别完成RTP、录音、MQ/OSS、三轮电话人工听测和生产1000路/N+1验收。 PCMA 复测已完成;在供应商进一步确认 `480/cause=16` 对应的路由/号码可达性前,不自动再次拨打真实号码,也不声称完成整个平台。当前竞价测试实例按用户要求保留,后续清理需单独明确授权。 diff --git a/prompts/test-call.txt b/prompts/test-call.txt new file mode 100644 index 0000000..ad26942 --- /dev/null +++ b/prompts/test-call.txt @@ -0,0 +1,4 @@ +You are a concise telephone assistant for a controlled integration test. +Answer the caller's latest statement in one short sentence. Never invent a +call result, payment, appointment, or external action. If the caller asks for +an action outside this test, say that the request is not enabled. diff --git a/requirements.txt b/requirements.txt index 4e0fae9..1196946 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ jsonschema>=4.20,<5 pika>=1.3,<2 +dashscope>=1.25,<2 +websocket-client>=1.9,<2 diff --git a/scripts/accept_ai_call.py b/scripts/accept_ai_call.py new file mode 100755 index 0000000..08c8068 --- /dev/null +++ b/scripts/accept_ai_call.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""Run isolated and, when configured, real Bailian AI acceptance probes.""" + +from __future__ import annotations + +import hashlib +import json +import re +import subprocess +import sys +import tempfile +import wave +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +CLI = ROOT / "scripts" / "test_ai_call.py" +MOCK_PROFILE = ROOT / "configs" / "ai-test.example.yaml" +REAL_PROFILE = ROOT / "configs" / "ai-test.bailian.example.yaml" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _run_cli(kind: str, *extra: str) -> tuple[int, dict[str, Any]]: + with tempfile.TemporaryDirectory(prefix="agent-call-accept-") as directory: + output = Path(directory) / "report.json" + completed = subprocess.run( + [sys.executable, str(CLI), kind, "--output", str(output), *extra], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + try: + report = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"AI CLI emitted no JSON for {kind}: {completed.stderr[-500:]}" + ) from exc + return completed.returncode, report + + +def _summary(kind: str, code: int, report: dict[str, Any]) -> dict[str, Any]: + result = report.get("result") + result = result if isinstance(result, dict) else {} + execution = report.get("execution") + execution = execution if isinstance(execution, dict) else {} + latest_execution = report.get("latest_execution") + latest_execution = latest_execution if isinstance(latest_execution, dict) else {} + recording = report.get("recording") + recording = recording if isinstance(recording, dict) else {} + return { + "kind": kind, + "exit_code": code, + "status": report.get("status"), + "mode": report.get("mode"), + "requested_mode": report.get("requested_mode"), + "provider_modes": report.get("provider_modes"), + "agent_version_id": report.get("agent_version_id"), + "agent_config_sha256": report.get("agent_config_sha256"), + "prompt_sha256": report.get("prompt_sha256"), + "llm_requested_model": report.get("llm", {}).get("requested_model"), + "llm_returned_model": report.get("llm", {}).get("provider_returned_model"), + "tts_requested_model": report.get("tts", {}).get("requested_model"), + "tts_returned_model": report.get("tts", {}).get("provider_returned_model"), + "tts_returned_model_verified": report.get("tts", {}).get( + "provider_returned_model_verified", False + ), + "tts_model_evidence": report.get("tts", {}).get("model_evidence"), + "voice_sha256": hashlib.sha256( + str(report.get("tts", {}).get("voice", "")).encode() + ).hexdigest(), + "asr_provider_ref": report.get("asr", {}).get("provider_ref"), + "asr_segment_count": len(result.get("asr_segments", [])), + "llm_first_token_ms": result.get("llm_first_token_ms"), + "tts_first_audio_ms": result.get("tts_first_audio_ms"), + "audio_sha256": report.get("audio_output", {}).get("sha256"), + "audio_bytes": report.get("audio_output", {}).get("bytes"), + "call_id": report.get("call_id") + or result.get("call_id") + or latest_execution.get("call_id") + or execution.get("call_id"), + "reason_code": report.get("reason_code") + or result.get("reason_code") + or latest_execution.get("reason_code") + or execution.get("reason_code"), + "recording_bytes": recording.get("bytes"), + "recording_valid": recording.get("valid_wav") or recording.get("valid_audio"), + } + + +def _wav(path: Path) -> None: + with wave.open(str(path), "wb") as output: + output.setnchannels(1) + output.setsampwidth(2) + output.setframerate(16000) + output.writeframes(b"\x00\x00" * 1600) + + +def _case( + case_id: str, status: str, actual: str, evidence: list[str], mode: str +) -> dict[str, Any]: + return { + "case_id": case_id, + "mode": mode, + "status": status, + "actual": actual, + "evidence": evidence, + } + + +def _configured() -> bool: + import os + + return all( + os.environ.get(name) + for name in ( + "BAILIAN_API_KEY", + "BAILIAN_BASE_URL", + "BAILIAN_WSS_BASE_URL", + "BAILIAN_TTS_VOICE", + ) + ) + + +def main() -> int: + date = _now().strftime("%Y%m%d") + evidence_dir = ROOT / "docs" / "evidence" + evidence_dir.mkdir(parents=True, exist_ok=True) + evidence_path = evidence_dir / f"llm-voice-acceptance-{date}.json" + + regression = subprocess.run( + [sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + match = re.search(r"Ran (\d+) tests?", regression.stdout + regression.stderr) + test_count: int | None = None + if match: + try: + test_count = int(match.group(1)) + except (TypeError, ValueError, OverflowError): + test_count = None + regression_summary = {"exit_code": regression.returncode, "test_count": test_count} + + commands: list[dict[str, Any]] = [] + mock_text_code, mock_text = _run_cli( + "text", + "--profile", + str(MOCK_PROFILE), + "--mode", + "mock", + "--input", + "这是隔离文本验收。", + ) + commands.append(_summary("mock-text", mock_text_code, mock_text)) + with tempfile.TemporaryDirectory(prefix="agent-call-accept-audio-") as directory: + source = Path(directory) / "input.wav" + target = Path(directory) / "response.wav" + _wav(source) + mock_audio_code, mock_audio = _run_cli( + "audio", + "--profile", + str(MOCK_PROFILE), + "--mode", + "mock", + "--input", + str(source), + "--output-audio", + str(target), + ) + commands.append(_summary("mock-audio", mock_audio_code, mock_audio)) + mock_call_code, mock_call = _run_cli( + "call", + "--profile", + str(MOCK_PROFILE), + "--mode", + "mock", + "--callee", + "18601013734", + "--allow-real-call", + "--max-duration", + "120", + ) + commands.append(_summary("mock-call", mock_call_code, mock_call)) + + real_ready = _configured() + real_text_code = real_audio_code = 2 + real_text: dict[str, Any] = { + "status": "BLOCKED", + "reason_code": "BAILIAN_ENV_REQUIRED", + "message": "BAILIAN_API_KEY, BAILIAN_BASE_URL, BAILIAN_WSS_BASE_URL and BAILIAN_TTS_VOICE are required", + } + real_audio: dict[str, Any] = dict(real_text) + if real_ready: + with tempfile.TemporaryDirectory(prefix="agent-call-real-accept-") as directory: + generated = Path(directory) / "tts.wav" + real_text_code, real_text = _run_cli( + "text", + "--profile", + str(REAL_PROFILE), + "--mode", + "real", + "--input", + "请只回答收到。", + "--output-audio", + str(generated), + ) + if generated.is_file(): + real_audio_code, real_audio = _run_cli( + "audio", + "--profile", + str(REAL_PROFILE), + "--mode", + "real", + "--input", + str(generated), + ) + else: + real_audio = { + "status": "BLOCKED", + "reason_code": "REAL_TTS_AUDIO_MISSING", + "message": "real text probe did not produce an audio fixture", + } + commands.append(_summary("real-text", real_text_code, real_text)) + commands.append(_summary("real-audio", real_audio_code, real_audio)) + + real_call_evidence_path = evidence_dir / f"real-cell-call-{date}.json" + if real_call_evidence_path.is_file(): + try: + loaded_call = json.loads( + real_call_evidence_path.read_text(encoding="utf-8") + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + loaded_call = None + if isinstance(loaded_call, dict): + real_call = loaded_call + real_call_code = 0 if real_call.get("status") == "PASS" else 1 + else: + real_call_code = 2 + real_call = { + "status": "BLOCKED", + "reason_code": "REAL_CALL_EVIDENCE_INVALID", + "message": "real Cell evidence exists but is not a valid JSON object", + } + else: + real_call_code = 2 + real_call = { + "status": "BLOCKED", + "reason_code": "REAL_CALL_REQUIRES_SEPARATE_AUTHORIZATION", + "message": "Acceptance automation never dials; use the separately authorized Cell runner.", + } + commands.append(_summary("real-call-evidence", real_call_code, real_call)) + + mock_ok = ( + regression.returncode == 0 + and mock_text_code == 0 + and mock_audio_code == 0 + and mock_call_code == 0 + ) + provider_ok = real_text_code == 0 and real_audio_code == 0 + cases: list[dict[str, Any]] = [] + internal_pass = { + "AI-01": "tenant-scoped immutable snapshot was published and reused", + "AI-02": "missing, cross-tenant, and same-version conflict checks are covered", + "AI-03": "Prompt, model/voice, secret, and audio-format validation is covered", + "AI-04": "version digest is persisted with the execution snapshot", + "AI-09": "opening and Prompt constraints are configuration-driven in the runtime tests", + "AI-10": "PCM16/PCMA conversion, frame alignment, and bounded queues passed", + "AI-11": "cancellation and late-chunk discard passed with isolated providers", + "AI-12": "silence/no-answer lifecycle remains bounded in the mock executor", + "AI-13": "hangup/timeout cleanup remains isolated in the mock executor", + "AI-14": "provider errors and real-mode fail-closed behavior are covered", + "AI-15": "ARI reconciliation regression remains green for the existing mock path", + "AI-16": "duplicate publication/execution recovery remains green", + "AI-17": "text, playback, and MQ result facts remain separate", + "AI-18": "recording checksum/upload recovery regression remains green", + "AI-19": "real Cell ARI/RTP worker, queue routing, durable claim ledger, and no-redial tests are covered", + "AI-21": "reports use hashes and do not include provider credentials or URLs", + } + for case_id, actual in internal_pass.items(): + cases.append( + _case( + case_id, + "PASS" if mock_ok else "FAIL", + actual, + [ + "tests/test_ai_runtime.py", + "tests/test_agent_call.py", + "tests/test_real_cell.py", + ], + "isolated-mock" if case_id != "AI-19" else "real-cell-code", + ) + ) + for case_id, label in ( + ("AI-05", "real text stream"), + ("AI-06", "real TTS"), + ("AI-07", "real audio chain"), + ): + report = real_text if case_id in {"AI-05", "AI-06"} else real_audio + code = real_text_code if case_id in {"AI-05", "AI-06"} else real_audio_code + status = ( + "PASS" + if code == 0 and report.get("status") == "PASS" + else ("FAIL" if real_ready else "BLOCKED") + ) + if case_id in {"AI-06", "AI-07"} and status == "PASS": + status = "BLOCKED" + label += "; machine audio passed; human listening is not registered" + cases.append( + _case( + case_id, + status, + f"{label} {'passed' if code == 0 else 'was not completed'}", + ["docs/evidence/llm-voice-acceptance-" + date + ".json"], + "real-bailian" if real_ready else "real-not-configured", + ) + ) + cases.append( + _case( + "AI-08", + "FAIL" if real_call_code == 1 else "BLOCKED", + "real three-round phone evidence is incomplete: SIP may connect, but bidirectional RTP, three AI rounds, recording handoff, and human listening are required", + [ + str(Path("docs") / "evidence" / real_call_evidence_path.name) + if real_call_evidence_path.is_file() + else "docs/LLM与音色可配置电话对话_开发与验收计划_v1.0.md" + ], + "real-phone-evidence" + if real_call_evidence_path.is_file() + else "real-phone-not-run", + ) + ) + cases.append( + _case( + "AI-20", + "BLOCKED", + "one-command mock flow passed; full real call flow remains incomplete until real MQ/OSS and phone evidence pass", + [ + "scripts/test_ai_call.py", + "docs/evidence/llm-voice-acceptance-" + date + ".json", + ], + "mixed", + ) + ) + + blockers = [ + "The single authorized real call reached SIP 200 OK, but usable bidirectional RTP, three AI rounds, and a valid recording were not proven.", + "The call path used a temporary RabbitMQ test broker; production RabbitMQ/SaaS result and OSS recording handoff remain unverified.", + "Human listening and provider-specific latency/quality thresholds are not registered; machine-generated WAV evidence cannot replace listening.", + ] + if not real_ready: + blockers.insert( + 0, "Bailian environment is incomplete; real provider probes were not run." + ) + elif not provider_ok: + blockers.insert( + 0, + "At least one real Bailian text/audio probe failed; inspect its redacted command summary.", + ) + + report = { + "schema_version": "1.0", + "generated_at": _now().isoformat().replace("+00:00", "Z"), + "scope": "real-bailian-file-probes-plus-isolated-mock-plus-real-cell-attempts", + "overall_status": "INCOMPLETE", + "gate_status": "BLOCKED_BY_REAL_PHONE_MEDIA_MQ_OSS_AND_HUMAN_EVIDENCE", + "component_modes": { + "llm": "real-bailian" if real_ready else "blocked", + "tts": "real-bailian" if real_ready else "blocked", + "asr": "real-bailian" if real_ready else "blocked", + "sip_ari_rtp": "partial-real" + if real_call_evidence_path.is_file() + else "not-run", + "database": "isolated-sqlite", + "rabbitmq": "temporary-test-broker" + if real_call_evidence_path.is_file() + else "not-run-real-broker", + "oss": "not-run", + }, + "real_provider_preflight": { + "environment_configured": real_ready, + "text_code": real_text_code, + "audio_code": real_audio_code, + }, + "regression_tests": regression_summary, + "commands": commands, + "cases": cases, + "blockers": blockers, + "evidence_policy": "docs/evidence contains hashes/status only; raw protocol/audio evidence stays in ignored local paths", + "test_commands": [ + "python3 -m unittest discover -s tests -v", + "python3 scripts/test_ai_call.py text --profile configs/ai-test.bailian.example.yaml --mode real --input '请只回答收到。'", + "python3 scripts/test_ai_call.py audio --profile configs/ai-test.bailian.example.yaml --mode real --input ", + "python3 scripts/run_real_cell_call.py --profile configs/ai-test.bailian.example.yaml --allow-real-call --callee 15003164745", + ], + } + evidence_path.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + print( + json.dumps( + { + "overall_status": report["overall_status"], + "evidence": str(evidence_path), + "cases": len(cases), + }, + ensure_ascii=False, + ) + ) + return ( + 1 + if not mock_ok or (real_ready and not provider_ok) or real_call_code == 1 + else 2 + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/run_real_cell_call.py b/scripts/run_real_cell_call.py new file mode 100755 index 0000000..5398285 --- /dev/null +++ b/scripts/run_real_cell_call.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Run one explicitly authorized Cell call through the tenant RabbitMQ queue.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +import wave +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def _data(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"profile is unavailable: {path}") from exc + if not isinstance(value, dict): + raise TypeError("profile must be an object") + return value + + +def _path(value: str) -> Path: + candidate = Path(value) + return candidate if candidate.is_absolute() else ROOT / candidate + + +def _integer(value: Any, default: int, field: str, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError(f"{field} must be an integer") from exc + if parsed < minimum: + raise ValueError(f"{field} must be at least {minimum}") + return parsed + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", default="configs/ai-test.bailian.example.yaml") + parser.add_argument("--prompt-file", default="prompts/test-call.txt") + parser.add_argument("--callee", required=True) + parser.add_argument("--tenant-id", default=None) + parser.add_argument("--tenant-key", default=None) + parser.add_argument("--broker-url", default=None) + parser.add_argument("--ledger", default="/data/agent-call-cell.sqlite3") + parser.add_argument("--wait-seconds", type=int, default=240) + parser.add_argument("--allow-real-call", action="store_true") + parser.add_argument("--output", default=None) + return parser.parse_args() + + +def _command( + settings: dict[str, Any], + args: argparse.Namespace, + tenant_id: str, + tenant_key: str, + agent_version_id: str, +) -> dict[str, Any]: + now_timestamp = time.time() + issued_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_timestamp)) + not_after = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_timestamp + 300)) + execution_id = f"exec_cell_{time.time_ns()}_{os.getpid()}" + return { + "schema_version": "1.0", + "command_type": "call.execute", + "command_id": f"cmd_cell_{execution_id}", + "tenant_id": tenant_id, + "tenant_key": tenant_key, + "trace_id": f"trace_cell_{execution_id}", + "issued_at": issued_at, + "not_after": not_after, + "payload": { + "execution_id": execution_id, + "task_id": str(settings.get("task_id", "task-ai-cell")), + "task_item_id": f"item_{execution_id}", + "task_revision": 1, + "callee": args.callee, + "route_policy_id": "route_policy_test", + "caller_profile_id": "caller_profile_test", + "agent_version_id": agent_version_id, + "variables": {}, + "ring_timeout_ms": 30000, + "max_call_duration_ms": _integer( + settings.get("max_duration_ms", 180000), + 180000, + "max_duration_ms", + ), + }, + } + + +def _recording(path: str) -> dict[str, Any] | None: + recording = Path(path) + if not recording.is_file() or recording.is_symlink(): + return None + try: + with wave.open(str(recording), "rb") as source: + evidence = { + "bytes": recording.stat().st_size, + "sha256": hashlib.sha256(recording.read_bytes()).hexdigest(), + "channels": source.getnchannels(), + "sample_rate_hz": source.getframerate(), + "frames": source.getnframes(), + "valid_wav": source.getnframes() > 0 and source.getnchannels() == 1, + } + except (OSError, EOFError, wave.Error): + return None + return evidence + + +def _write(report: dict[str, Any], output: str | None) -> None: + payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n" + if output: + destination = _path(output) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(payload, encoding="utf-8") + print(payload, end="") + + +def run(args: argparse.Namespace) -> dict[str, Any]: + if not args.allow_real_call: + raise ValueError("--allow-real-call is required") + from agent_call.ai_runtime import ConversationEngine, load_prompt + from agent_call.bailian import ( + BailianASR, + BailianLLM, + BailianTTS, + build_bailian_config, + ) + from agent_call.core import PikaBroker + from agent_call.real_cell import ( + CellCallConfig, + CellExecutionLedger, + RealCellCall, + RealCellWorker, + ) + + settings = _data(_path(args.profile)) + authorized = {str(value) for value in settings.get("authorized_callees", [])} + if args.callee not in authorized: + raise ValueError("callee is not in the profile allow-list") + tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo")) + tenant_key = str(args.tenant_key or settings.get("tenant_key", "tenant-demo-key")) + agent_version_id = str(settings.get("agent_version_id", "agent_bailian_test_v1")) + try: + prompt = load_prompt( + _path(args.prompt_file), + _integer( + settings.get("max_prompt_bytes", 32768), 32768, "max_prompt_bytes" + ), + ) + except (OSError, UnicodeDecodeError, ValueError) as exc: + raise ValueError("prompt is unavailable") from exc + config = build_bailian_config( + agent_version_id, + prompt, + str(settings.get("model", "qwen-plus")), + settings.get("tts_model"), + settings.get("voice"), + str(settings.get("language", "zh-CN")), + settings.get("asr_model"), + str(settings.get("opening", "")), + min(3, _integer(settings.get("max_turns", 3), 3, "max_turns")), + _integer(settings.get("max_duration_ms", 180000), 180000, "max_duration_ms"), + ) + engine = ConversationEngine( + config, + llm=BailianLLM.from_env(), + tts=BailianTTS.from_env(), + asr=BailianASR.from_env(), + ) + broker = PikaBroker(args.broker_url or os.environ.get("RABBITMQ_URL", "")) + ledger = CellExecutionLedger(args.ledger) + executor = RealCellCall(CellCallConfig.from_env(), engine, BailianASR.from_env()) + worker = RealCellWorker(broker, tenant_key, ledger, executor) + command = _command(settings, args, tenant_id, tenant_key, agent_version_id) + route = f"agent-call.tenant.{tenant_key}.call.execute" + broker.publish( + "agent-call.commands.v1", route, command, message_id=command["command_id"] + ) + deadline = time.monotonic() + args.wait_seconds + event: dict[str, Any] | None = None + while time.monotonic() < deadline: + event = worker.process_once() + if event and event.get("event_type") == "call.finished": + break + time.sleep(0.2) + if event is None or event.get("event_type") != "call.finished": + raise TimeoutError("Cell call did not finish before the bounded wait") + result = ledger.result(command["payload"]["execution_id"]) or {} + result_status = event["payload"].get("status") + recording_path = result.get("recording_path") + recording = _recording(recording_path) if isinstance(recording_path, str) else None + passed = ( + result_status == "completed" + and len(event["payload"].get("turns", [])) >= 3 + and bool(recording and recording.get("valid_wav")) + ) + report: dict[str, Any] = { + "schema_version": "1.0", + "status": "PASS" if passed else "FAIL", + "mode": "real", + "call_execution": { + "execution_id": command["payload"]["execution_id"], + "tenant_id": tenant_id, + "tenant_key": tenant_key, + "callee": args.callee, + "event_id": event["event_id"], + }, + "result": event["payload"], + } + if recording is not None: + report["recording"] = recording + return report + + +def main() -> int: + args = _args() + try: + report = run(args) + code = 0 if report.get("status") == "PASS" else 1 + except ( + OSError, + RuntimeError, + TimeoutError, + TypeError, + ValueError, + KeyError, + ) as exc: + report = { + "schema_version": "1.0", + "status": "FAIL", + "reason_code": type(exc).__name__, + "message": str(exc), + } + code = 1 + _write(report, args.output) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_ai_call.py b/scripts/test_ai_call.py new file mode 100755 index 0000000..b939d9e --- /dev/null +++ b/scripts/test_ai_call.py @@ -0,0 +1,452 @@ +#!/usr/bin/env python3 +"""One-command text, audio, or explicit MQ AI probe. + +Mock is isolated by default. The Bailian adapter uses only server-side +BAILIAN_* environment variables; real call mode still fails closed until the +Cell/media authorization and real-call evidence gate is explicitly arranged. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib +import json +import sys +import tempfile +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +_ai_runtime = importlib.import_module("agent_call.ai_runtime") +_core = importlib.import_module("agent_call.core") +_bailian = importlib.import_module("agent_call.bailian") +AIConfigError = _ai_runtime.AIConfigError +AIProviderError = _ai_runtime.AIProviderError +ConversationEngine = _ai_runtime.ConversationEngine +build_mock_config = _ai_runtime.build_mock_config +config_digest = _ai_runtime.config_digest +load_prompt = _ai_runtime.load_prompt +pcm_to_wav = _ai_runtime.pcm_to_wav +validate_wav = _ai_runtime.validate_wav +BailianASR = _bailian.BailianASR +BailianLLM = _bailian.BailianLLM +BailianTTS = _bailian.BailianTTS +build_bailian_config = _bailian.build_bailian_config +AgentCallService = _core.AgentCallService +ServiceError = _core.ServiceError + +DEFAULT_PROFILE = ROOT / "configs" / "ai-test.example.yaml" + + +def _load_data(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + try: + import yaml # type: ignore[import-not-found] + except ImportError as exc: + raise AIConfigError( + "PROFILE_FORMAT_UNSUPPORTED", + "profile must be JSON-compatible YAML when PyYAML is unavailable", + ) from exc + value = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise AIConfigError("PROFILE_INVALID", "profile must be an object") + return value + + +def _path(value: str) -> Path: + candidate = Path(value) + return candidate if candidate.is_absolute() else ROOT / candidate + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _safe_result(result: dict[str, Any]) -> dict[str, Any]: + clean = dict(result) + if "audio" in clean: + audio = clean.pop("audio") + if isinstance(audio, bytes): + clean["audio_bytes"] = len(audio) + clean["audio_sha256"] = hashlib.sha256(audio).hexdigest() + if isinstance(clean.get("segments"), list): + clean["segments"] = [ + {key: value for key, value in segment.items() if key != "audio"} + for segment in clean["segments"] + if isinstance(segment, dict) + ] + return clean + + +def _write_report(report: dict[str, Any], output: str | None) -> None: + payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n" + if output: + destination = _path(output) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(payload, encoding="utf-8") + print(payload, end="") + + +def _args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("kind", choices=("text", "audio", "call")) + parser.add_argument("--profile", default=str(DEFAULT_PROFILE)) + parser.add_argument("--mode", choices=("mock", "mixed", "real")) + parser.add_argument("--prompt-file", default="prompts/test-call.txt") + parser.add_argument( + "--input", help="UTF-8 text for text mode or mono PCM16 WAV for audio mode" + ) + parser.add_argument("--model", default=None) + parser.add_argument("--tts-model", default=None) + parser.add_argument("--voice", default=None) + parser.add_argument("--language", default=None) + parser.add_argument( + "--max-duration", type=int, default=None, help="call duration limit in seconds" + ) + parser.add_argument("--tenant-id", default=None) + parser.add_argument("--task-id", default=None) + parser.add_argument("--callee", default=None) + parser.add_argument("--agent-version-id", default=None) + parser.add_argument( + "--db", + default=None, + help="SQLite path for call mode or durable config evidence", + ) + parser.add_argument("--output", default=None, help="JSON report path") + parser.add_argument( + "--output-audio", default=None, help="WAV response path for text/audio mode" + ) + parser.add_argument( + "--allow-real-call", + action="store_true", + help="explicitly acknowledge that call mode could place a call; mock never dials", + ) + parser.add_argument( + "--human-listening", + choices=("pass", "fail", "not_run"), + default="not_run", + help="manual listening result; it is evidence only, not a provider claim", + ) + return parser.parse_args() + + +def _config( + settings: dict[str, Any], args: argparse.Namespace, mode: str +) -> dict[str, Any]: + prompt = load_prompt( + _path(args.prompt_file), int(settings.get("max_prompt_bytes", 32768)) + ) + version_id = args.agent_version_id or settings.get( + "agent_version_id", "agent_test_v1" + ) + if mode == "mock": + config = build_mock_config( + str(version_id), + prompt, + str(args.model or settings.get("model", "mock-chat-v1")), + str(args.tts_model or settings.get("tts_model", "mock-tts-v1")), + str(args.voice or settings.get("voice", "mock-neutral")), + ) + else: + config = build_bailian_config( + str(version_id), + prompt, + str(args.model or settings.get("model", "qwen-plus")), + args.tts_model or settings.get("tts_model"), + args.voice or settings.get("voice"), + str(args.language or settings.get("language", "zh-CN")), + settings.get("asr_model"), + str(settings.get("opening", "")), + int(settings.get("max_turns", 20)), + int(settings.get("max_duration_ms", 120000)), + ) + return config + + +def _base_report( + settings: dict[str, Any], args: argparse.Namespace, config: dict[str, Any] +) -> dict[str, Any]: + provider_modes = settings.get("provider_modes", {}) + return { + "schema_version": "1.0", + "created_at": _iso_now(), + "mode": settings.get("mode", "mock"), + "requested_mode": args.mode or settings.get("mode", "mock"), + "provider_modes": provider_modes, + "agent_version_id": config["agent_version_id"], + "agent_config_sha256": config_digest(config), + "prompt_sha256": hashlib.sha256( + config["prompt"]["text"].encode("utf-8") + ).hexdigest(), + "prompt_bytes": len(config["prompt"]["text"].encode("utf-8")), + "llm": { + "provider_ref": config["llm"]["provider_ref"], + "requested_model": config["llm"]["model"], + "provider_returned_model": None, + "provider_returned_model_verified": False, + }, + "tts": { + "provider_ref": config["tts"]["provider_ref"], + "requested_model": config["tts"]["model"], + "provider_returned_model": None, + "provider_returned_model_verified": False, + "voice": config["tts"]["voice"], + "voice_listening_verified": False, + "format": config["tts"]["format"], + }, + "asr": { + "provider_ref": config["asr"]["provider_ref"], + "language": config["asr"]["language"], + }, + "human_listening": args.human_listening, + } + + +def _run_real_cell( + args: argparse.Namespace, settings: dict[str, Any], service_settings: dict[str, Any] +) -> tuple[int, dict[str, Any]]: + tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo")) + tenant = next( + ( + item + for item in service_settings.get("tenants", []) + if item.get("tenant_id") == tenant_id + ), + None, + ) + if not isinstance(tenant, dict) or not isinstance(tenant.get("tenant_key"), str): + raise AIConfigError( + "TENANT_NOT_CONFIGURED", "real Cell call tenant is not configured" + ) + runner = importlib.import_module("scripts.run_real_cell_call") + real_args = argparse.Namespace( + profile=args.profile, + prompt_file=args.prompt_file, + callee=args.callee or settings.get("callee", ""), + tenant_id=tenant_id, + tenant_key=tenant["tenant_key"], + broker_url=None, + ledger=args.db or str(ROOT / ".local" / "agent-call-ai" / "real-cell.sqlite3"), + wait_seconds=240, + allow_real_call=args.allow_real_call, + output=None, + ) + report = runner.run(real_args) + return (0 if report.get("status") == "PASS" else 1), report + + +def _run(args: argparse.Namespace) -> tuple[int, dict[str, Any]]: + settings = _load_data(_path(args.profile)) + service_profile = _path( + str(settings.get("service_profile", "docs/contracts/mock-profile.json")) + ) + service_settings = _load_data(service_profile) + requested_mode = args.mode or str( + settings.get("mode", service_settings.get("mode", "mock")) + ) + if requested_mode not in {"mock", "mixed", "real"}: + raise AIConfigError("MODE_INVALID", "mode must be mock, mixed, or real") + if args.kind == "call" and requested_mode == "real": + if not args.allow_real_call: + return 2, { + "schema_version": "1.0", + "status": "BLOCKED", + "requested_mode": requested_mode, + "reason_code": "CALL_AUTHORIZATION_REQUIRED", + "message": "call mode requires --allow-real-call before the RabbitMQ Cell worker is started.", + } + return _run_real_cell(args, settings, service_settings) + if requested_mode != "mock" and args.kind == "call": + return 2, { + "schema_version": "1.0", + "status": "BLOCKED", + "requested_mode": requested_mode, + "reason_code": "REAL_CALL_MODE_REQUIRED", + "message": "Only the explicit real profile may start the Cell/media executor; no call was placed.", + } + config = _config(settings, args, requested_mode) + report_settings = dict(settings) + report_settings["mode"] = requested_mode + report_settings["provider_modes"] = settings.get( + "provider_modes", service_settings.get("provider_modes", {}) + ) + report = _base_report(report_settings, args, config) + db_path: str + temporary_db: tempfile.TemporaryDirectory[str] | None = None + if args.db: + db_path = str(_path(args.db)) + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + else: + temporary_db = tempfile.TemporaryDirectory(prefix="agent-call-ai-") + db_path = str(Path(temporary_db.name) / "state.sqlite3") + service = AgentCallService( + db_path=db_path, profile_path=service_profile, start_background=False + ) + try: + tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo")) + published = service.publish_agent_version( + tenant_id, + config["agent_version_id"], + config, + actor_id="ai-test-cli", + ) + trusted = service.get_agent_version(tenant_id, config["agent_version_id"]) + report["publication"] = published + report["trusted_snapshot"] = { + "agent_version_id": trusted["agent_version_id"], + "content_sha256": trusted["content_sha256"], + "immutable": trusted["immutable"], + } + engine_kwargs: dict[str, Any] = {"journal": service.journal} + if requested_mode != "mock": + engine_kwargs["llm"] = BailianLLM.from_env() + engine_kwargs["tts"] = BailianTTS.from_env() + if trusted["config"]["asr"]["provider_ref"] != "mock": + engine_kwargs["asr"] = BailianASR.from_env() + engine = ConversationEngine(trusted["config"], **engine_kwargs) + if args.kind == "text": + if args.input is None: + raise AIConfigError( + "TEXT_INPUT_REQUIRED", "--input is required for text mode" + ) + result = engine.run_text(args.input) + elif args.kind == "audio": + if args.input is None: + raise AIConfigError( + "AUDIO_INPUT_REQUIRED", "--input is required for audio mode" + ) + audio_path = _path(args.input) + audio = audio_path.read_bytes() + result = engine.run_audio(audio) + else: + if not args.allow_real_call: + report.update( + { + "status": "BLOCKED", + "reason_code": "CALL_AUTHORIZATION_REQUIRED", + "message": "call mode requires --allow-real-call even though the selected profile is Mock.", + } + ) + return 2, report + callee = str(args.callee or settings.get("callee", "")) + authorized = { + str(value) for value in settings.get("authorized_callees", [callee]) + } + if callee not in authorized: + raise AIConfigError( + "CALLEE_NOT_AUTHORIZED", + "callee is not in the explicit test allow-list", + ) + fixture = _load_data(ROOT / "docs/contracts/examples/call.execute.json") + now = datetime.now(timezone.utc) + fixture["command_id"] = f"cmd_ai_{uuid.uuid4().hex}" + fixture["trace_id"] = f"trace_ai_{uuid.uuid4().hex}" + fixture["issued_at"] = now.isoformat().replace("+00:00", "Z") + fixture["not_after"] = ( + now.replace(year=now.year + 1).isoformat().replace("+00:00", "Z") + ) + fixture["tenant_id"] = tenant_id + fixture["tenant_key"] = next( + item["tenant_key"] + for item in service.profile["tenants"] + if item["tenant_id"] == tenant_id + ) + fixture["payload"]["execution_id"] = f"exec_ai_{uuid.uuid4().hex}" + fixture["payload"]["task_id"] = str( + args.task_id or settings.get("task_id", "task-demo") + ) + fixture["payload"]["callee"] = callee + fixture["payload"]["agent_version_id"] = config["agent_version_id"] + if args.max_duration is not None: + if args.max_duration < 1: + raise AIConfigError( + "MAX_DURATION_INVALID", "--max-duration must be positive" + ) + fixture["payload"]["max_call_duration_ms"] = args.max_duration * 1000 + published_command = service.publish_execute(fixture) + service.wait_for_idle(10) + call_row = service.store.one( + "SELECT call_id FROM calls WHERE execution_id=?", + (fixture["payload"]["execution_id"],), + ) + if call_row is None: + raise ServiceError( + "CALL_NOT_CREATED", "mock executor did not reserve a call" + ) + result = service.get_call(tenant_id, call_row["call_id"]) + report["mq_publication"] = published_command + report["call_id"] = call_row["call_id"] + clean = _safe_result(result) + if result.get("provider_returned_model"): + report["llm"]["provider_returned_model"] = result["provider_returned_model"] + report["llm"]["provider_returned_model_verified"] = True + if result.get("tts_provider_returned_model"): + report["tts"]["provider_returned_model"] = result[ + "tts_provider_returned_model" + ] + report["tts"]["provider_returned_model_verified"] = True + if isinstance(result.get("tts_model_evidence"), dict): + report["tts"]["model_evidence"] = result["tts_model_evidence"] + if args.kind in {"text", "audio"}: + raw_audio = result.get("audio", b"") + if isinstance(raw_audio, bytes) and raw_audio: + wav = pcm_to_wav( + raw_audio, int(config["tts"]["format"]["sample_rate_hz"]) + ) + validate_wav(wav) + if args.output_audio: + destination = _path(args.output_audio) + else: + destination = ( + ROOT + / ".local" + / "agent-call-ai" + / f"response-{uuid.uuid4().hex}.wav" + ) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(wav) + report["audio_output"] = { + "path": str(destination), + "bytes": len(wav), + "sha256": hashlib.sha256(wav).hexdigest(), + } + report["result"] = clean + result_status = ( + clean.get("status") or result.get("outcome") or result.get("call_state") + ) + report["status"] = ( + "PASS" if result_status in {"completed", "succeeded", "ended"} else "FAIL" + ) + return (0 if report["status"] == "PASS" else 1), report + finally: + service.store.close() + if temporary_db is not None: + temporary_db.cleanup() + + +def main() -> int: + args = _args() + try: + code, report = _run(args) + except (AIConfigError, AIProviderError, ServiceError, OSError, ValueError) as exc: + code = 1 + report = { + "schema_version": "1.0", + "status": "FAIL", + "reason_code": getattr(exc, "code", type(exc).__name__), + "message": str(exc), + } + _write_report(report, args.output) + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ai_runtime.py b/tests/test_ai_runtime.py new file mode 100644 index 0000000..fe01c89 --- /dev/null +++ b/tests/test_ai_runtime.py @@ -0,0 +1,322 @@ +from __future__ import annotations + +import copy +import http.client +import json +import os +import tempfile +import threading +import time +import unittest +from pathlib import Path + +from agent_call.ai_runtime import ( + AIConfigError, + AIProviderError, + ConversationEngine, + MockLLM, + MockTTS, + build_mock_config, + config_digest, + load_prompt, + pcm16_to_alaw, + pcm_to_wav, + render_prompt, + validate_agent_config, + validate_wav, +) +from agent_call.core import ( + AgentCallService, + ConflictError, + InMemoryBroker, + NotFoundError, + iso, + utcnow, +) +from agent_call.http import make_server + +ROOT = Path(__file__).resolve().parents[1] + + +class FailingLLM: + def stream(self, messages, config, turn_id, cancelled): + raise AIProviderError( + "LLM_TIMEOUT", "synthetic provider timeout", retryable=True + ) + + +class AIRuntimeTests(unittest.TestCase): + def config(self, version: str = "ai_test_v1") -> dict: + return build_mock_config(version, load_prompt(ROOT / "prompts/test-call.txt")) + + def test_prompt_is_utf8_bounded_and_variables_are_explicit(self) -> None: + prompt = "你好,${customer_name}。" + self.assertEqual( + render_prompt(prompt, {"customer_name": "甲"}, ["customer_name"]), + "你好,甲。", + ) + with self.assertRaisesRegex(AIConfigError, "not allowed"): + render_prompt(prompt, {"other": "甲"}, ["customer_name"]) + with self.assertRaisesRegex(AIConfigError, "missing"): + render_prompt(prompt, {}, ["customer_name"]) + with tempfile.NamedTemporaryFile() as handle: + handle.write("坏".encode("gbk")) + handle.flush() + with self.assertRaises(AIConfigError): + load_prompt(handle.name) + + def test_immutable_config_rejects_secrets_and_model_voice_mismatch(self) -> None: + config = self.config() + config["llm"]["api_key"] = "must-not-be-stored" + with self.assertRaisesRegex(AIConfigError, "credential or provider URL"): + validate_agent_config(config) + config = self.config() + config["tts"]["voice"] = "vendor-voice" + with self.assertRaisesRegex(AIConfigError, "mock TTS"): + validate_agent_config(config) + + def test_real_provider_never_falls_back_to_mock(self) -> None: + config = self.config() + config["llm"]["provider_ref"] = "real-llm" + with self.assertRaisesRegex(AIConfigError, "provider-specific adapter"): + ConversationEngine(config) + + def test_opening_and_provider_failure_are_explicit(self) -> None: + config = self.config() + config["conversation"]["opening"] = "配置开场白。" + engine = ConversationEngine(config) + opening = engine.speak(config["conversation"]["opening"]) + self.assertEqual(opening["playback_state"], "playback_confirmed") + self.assertGreater(len(opening["audio"]), 0) + failed = ConversationEngine(config, llm=FailingLLM()).run_text("触发错误") + self.assertEqual(failed["status"], "failed") + self.assertEqual(failed["reason_code"], "LLM_TIMEOUT") + + def test_text_stream_produces_first_token_audio_and_phone_codec(self) -> None: + engine = ConversationEngine(self.config(), llm=MockLLM(), tts=MockTTS()) + result = engine.run_text("请回声测试。") + self.assertEqual(result["status"], "completed") + self.assertTrue(result["text"]) + self.assertGreater( + result["audio_bytes"] if "audio_bytes" in result else len(result["audio"]), + 0, + ) + self.assertIsNotNone(result["llm_first_token_ms"]) + self.assertIsNotNone(result["tts_first_audio_ms"]) + self.assertEqual( + result["tts_model_evidence"]["configured_model"], + self.config()["tts"]["model"], + ) + self.assertFalse(result["tts_model_evidence"]["provider_echoed_model"]) + wav = pcm_to_wav(result["audio"]) + self.assertEqual(validate_wav(wav)["channels"], 1) + pcma = pcm16_to_alaw(result["audio"]) + self.assertEqual(len(pcma), len(result["audio"]) // 4) + + def test_audio_stream_uses_input_fingerprint_not_fixed_text(self) -> None: + first = pcm_to_wav(b"\x00\x00" * 1600) + second = pcm_to_wav(b"\x01\x00" * 1600) + engine = ConversationEngine(self.config()) + first_result = engine.run_audio(first) + second_result = engine.run_audio(second) + self.assertNotEqual( + first_result["asr_segments"][-1]["text"], + second_result["asr_segments"][-1]["text"], + ) + + def test_cancel_discards_late_provider_chunks(self) -> None: + engine = ConversationEngine( + self.config(), + llm=MockLLM(chunk_delay_s=0.01, late_chunks_after_cancel=3), + tts=MockTTS(chunk_delay_s=0.01, late_chunks_after_cancel=3), + ) + result: dict = {} + worker = threading.Thread( + target=lambda: result.update(engine.run_text("长文本")) + ) + worker.start() + time.sleep(0.025) + self.assertIsNotNone(engine.interrupt()) + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(result.get("status"), "cancelled") + self.assertGreater(result.get("discarded_late_chunks", 0), 0) + + def test_version_publish_is_idempotent_and_immutable(self) -> None: + with tempfile.TemporaryDirectory() as directory: + service = AgentCallService( + db_path=Path(directory) / "state.sqlite3", + object_dir=Path(directory) / "objects", + broker=InMemoryBroker(), + ) + try: + config = self.config("ai_store_v1") + first = service.publish_agent_version( + "tenant-demo", "ai_store_v1", config + ) + again = service.publish_agent_version( + "tenant-demo", "ai_store_v1", config + ) + self.assertEqual(first["content_sha256"], again["content_sha256"]) + changed = copy.deepcopy(config) + changed["prompt"]["text"] = "different" + with self.assertRaises(ConflictError): + service.publish_agent_version("tenant-demo", "ai_store_v1", changed) + with self.assertRaises(NotFoundError): + service.get_agent_version("tenant-demo", "missing_version") + with self.assertRaises(NotFoundError): + service.get_agent_version("tenant-b", "ai_store_v1") + snapshot = service.get_agent_version("tenant-demo", "ai_store_v1") + self.assertTrue(snapshot["immutable"]) + self.assertEqual( + snapshot["content_sha256"], config_digest(snapshot["config"]) + ) + finally: + service.store.close() + + def test_call_persists_selected_version_and_digest(self) -> None: + with tempfile.TemporaryDirectory() as directory: + service = AgentCallService( + db_path=Path(directory) / "state.sqlite3", + object_dir=Path(directory) / "objects", + broker=InMemoryBroker(), + ) + try: + config = self.config("ai_call_v1") + service.publish_agent_version("tenant-demo", "ai_call_v1", config) + fixture = json.loads( + (ROOT / "docs/contracts/examples/call.execute.json").read_text( + encoding="utf-8" + ) + ) + fixture["command_id"] = "ai_call_cmd" + fixture["trace_id"] = "ai_call_trace" + fixture["issued_at"] = iso(utcnow()) + fixture["payload"]["execution_id"] = "ai_call_exec" + fixture["payload"]["agent_version_id"] = "ai_call_v1" + service.publish_execute(fixture) + service.wait_for_idle() + call = service.store.one( + "SELECT * FROM calls WHERE execution_id=?", ("ai_call_exec",) + ) + self.assertIsNotNone(call) + self.assertEqual(call["agent_version_id"], "ai_call_v1") + self.assertEqual(call["agent_config_sha256"], config_digest(config)) + snapshot = service.get_call("tenant-demo", call["call_id"]) + self.assertEqual(snapshot["ai"]["turns"], 2) + self.assertEqual(len(snapshot["ai"]["llm_first_token_ms"]), 2) + finally: + service.store.close() + + +class AIConfigHTTPTests(unittest.TestCase): + def setUp(self) -> None: + self.temp = tempfile.TemporaryDirectory() + self.previous_tokens = os.environ.get("HTTP_TOKENS") + self.previous_issuer = os.environ.get("AI_CONFIG_ISSUER") + self.previous_audience = os.environ.get("AI_CONFIG_AUDIENCE") + os.environ["AI_CONFIG_ISSUER"] = "issuer-ai-test" + os.environ["AI_CONFIG_AUDIENCE"] = "audience-ai-test" + os.environ["HTTP_TOKENS"] = json.dumps( + { + "publish": { + "tenant_ids": ["tenant-demo"], + "scopes": ["ai.config.publish"], + "auth_domain": "ai-config", + "issuer": "issuer-ai-test", + "audience": "audience-ai-test", + }, + "read": { + "tenant_ids": ["tenant-demo"], + "scopes": ["ai.config.read"], + "auth_domain": "ai-config", + "issuer": "issuer-ai-test", + "audience": "audience-ai-test", + }, + "normal": {"tenant_ids": ["tenant-demo"], "scopes": ["outbound.read"]}, + "wrong-domain": { + "tenant_ids": ["tenant-demo"], + "scopes": ["ai.config.read"], + "auth_domain": "ai-config", + "issuer": "wrong-issuer", + "audience": "audience-ai-test", + }, + } + ) + self.service = AgentCallService( + db_path=Path(self.temp.name) / "state.sqlite3", + object_dir=Path(self.temp.name) / "objects", + broker=InMemoryBroker(), + ) + self.server = make_server(self.service, "127.0.0.1", 0) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.port = self.server.server_address[1] + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=2) + self.service.stop() + self.service.store.close() + if self.previous_tokens is None: + os.environ.pop("HTTP_TOKENS", None) + else: + os.environ["HTTP_TOKENS"] = self.previous_tokens + for name, previous in ( + ("AI_CONFIG_ISSUER", self.previous_issuer), + ("AI_CONFIG_AUDIENCE", self.previous_audience), + ): + if previous is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous + self.temp.cleanup() + + def request( + self, method: str, path: str, token: str, body: dict | None = None + ) -> tuple[int, dict]: + headers = { + "Authorization": f"Bearer {token}", + "X-Tenant-ID": "tenant-demo", + "X-Request-ID": "ai-http-test", + } + encoded = None + if body is not None: + encoded = json.dumps(body, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + connection = http.client.HTTPConnection("127.0.0.1", self.port, timeout=3) + try: + connection.request(method, path, body=encoded, headers=headers) + response = connection.getresponse() + return response.status, json.loads(response.read().decode("utf-8")) + finally: + connection.close() + + def test_config_scope_is_separate_from_normal_control_scope(self) -> None: + config = build_mock_config("http_ai_v1", "test") + body = {"agent_version_id": "http_ai_v1", "config": config} + status, receipt = self.request( + "POST", "/internal/v1/ai/agent-versions", "publish", body + ) + self.assertEqual(status, 201) + self.assertTrue(receipt["immutable"]) + status, snapshot = self.request( + "GET", "/internal/v1/ai/agent-versions/http_ai_v1", "read" + ) + self.assertEqual(status, 200) + self.assertEqual(snapshot["config"]["agent_version_id"], "http_ai_v1") + status, denied = self.request( + "POST", "/internal/v1/ai/agent-versions", "normal", body + ) + self.assertEqual(status, 403) + self.assertEqual(denied["code"], "FORBIDDEN") + status, denied = self.request( + "GET", "/internal/v1/ai/agent-versions/http_ai_v1", "wrong-domain" + ) + self.assertEqual(status, 401) + self.assertEqual(denied["code"], "UNAUTHORIZED") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_bailian.py b/tests/test_bailian.py new file mode 100644 index 0000000..01c66b0 --- /dev/null +++ b/tests/test_bailian.py @@ -0,0 +1,52 @@ +import json +import os +import queue +import unittest +from unittest.mock import patch + +from agent_call.bailian import ( + BailianTTS, + _BailianCosyVoiceCallback, + build_bailian_config, +) + + +class BailianAdapterTests(unittest.TestCase): + def test_qwen_realtime_url_is_derived_from_asr_endpoint(self) -> None: + adapter = BailianTTS("wss://example.test/api-ws/v1/inference/", "secret") + self.assertEqual( + adapter._url_for_qwen_realtime(), + "wss://example.test/api-ws/v1/realtime", + ) + + def test_config_selects_cosyvoice_for_custom_voice(self) -> None: + with patch.dict( + os.environ, + { + "BAILIAN_TTS_VOICE": "cosyvoice-v3.5-plus-bailian-test", + "BAILIAN_TTS_MODEL": "", + }, + clear=False, + ): + config = build_bailian_config("agent_test", "回答收到。") + self.assertEqual(config["tts"]["model"], "cosyvoice-v3.5-plus") + self.assertEqual(config["asr"]["provider_ref"], "bailian") + self.assertEqual(config["asr"]["model"], "fun-asr-realtime") + + def test_cosyvoice_callback_records_returned_model_when_present(self) -> None: + callback = _BailianCosyVoiceCallback(4) + callback.on_event(json.dumps({"header": {"model": "cosyvoice-test"}})) + self.assertEqual(callback.provider_model, "cosyvoice-test") + + def test_cosyvoice_callback_is_bounded(self) -> None: + callback = _BailianCosyVoiceCallback(1) + callback.on_data(b"\x00\x00") + callback.on_complete() + self.assertTrue(callback.overflowed) + self.assertEqual(callback.events.get_nowait()["type"], "__audio__") + with self.assertRaises(queue.Empty): + callback.events.get_nowait() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 1607d8c..39f67bd 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -48,6 +48,19 @@ class ContractTests(unittest.TestCase): "/internal/v1/outbound/recording-uploads/{upload_id}/complete:", saas ) + def test_ai_config_contract_and_example(self) -> None: + schema = read_json(ROOT / "docs/contracts/ai-config.schema.json") + example = read_json(ROOT / "docs/contracts/examples/agent-version.json") + self.assertEqual(list(Draft202012Validator(schema).iter_errors(example)), []) + openapi = (ROOT / "docs/contracts/ai-config.openapi.yaml").read_text( + encoding="utf-8" + ) + self.assertIn("openapi: 3.1.0", openapi) + self.assertIn("ai.config.publish", openapi) + self.assertIn("ai.config.read", openapi) + self.assertIn("/internal/v1/ai/agent-versions:", openapi) + self.assertNotIn("api_key", json.dumps(example, ensure_ascii=False)) + def test_command_fixture_and_invalid_version(self) -> None: schema = read_json(ROOT / "docs/contracts/mq.schema.json") fixture = read_json(ROOT / "docs/contracts/examples/call.execute.json") diff --git a/tests/test_real_cell.py b/tests/test_real_cell.py new file mode 100644 index 0000000..64f8266 --- /dev/null +++ b/tests/test_real_cell.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +import socket +import struct +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from typing import cast + +from agent_call.real_cell import ( + CellCallConfig, + CellCallError, + CellExecutionLedger, + RealCellCall, + RealCellWorker, + RTPMedia, + alaw_to_pcm16, + voice_level, +) + + +class FakeBroker: + def __init__(self, messages: list[dict]) -> None: + self.messages = list(messages) + self.acked: list[dict] = [] + self.requeued: list[dict] = [] + self.rejected: list[dict] = [] + self.published: list[dict] = [] + + def declare_tenant(self, tenant_key: str) -> None: + self.tenant_key = tenant_key + + def consume(self, _queue: str) -> dict | None: + return self.messages.pop(0) if self.messages else None + + def ack(self, message: dict) -> None: + self.acked.append(message) + + def requeue(self, message: dict) -> None: + self.requeued.append(message) + + def reject(self, message: dict) -> None: + self.rejected.append(message) + + def publish( + self, _exchange: str, _route: str, body: dict, **_kwargs: object + ) -> None: + self.published.append(body) + + +class RealCellTests(unittest.TestCase): + def test_pcma_decode_and_rtp_payload(self) -> None: + media = RTPMedia("127.0.0.1", 0) + receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + receiver.bind(("127.0.0.1", 0)) + try: + media.peer = ("127.0.0.1", receiver.getsockname()[1]) + sent = media.send_pcm16(b"\x00\x00" * 160, 8000) + packet, _ = receiver.recvfrom(512) + self.assertEqual(sent, 160) + self.assertEqual(len(packet), 172) + self.assertEqual(packet[1] & 0x7F, 8) + self.assertEqual(packet[12:], b"\xd5" * 160) + self.assertEqual(len(alaw_to_pcm16(packet[12:])), 320) + self.assertEqual(voice_level(alaw_to_pcm16(packet[12:])), 8) + finally: + media.close() + receiver.close() + + def test_rtp_payload_handles_extension_and_padding(self) -> None: + payload = b"abc" + packet = struct.pack("!BBHII", 0xB0, 8, 1, 2, 3) + packet += struct.pack("!HH", 0, 1) + b"xxxx" + payload + b"\x00\x00\x03" + self.assertEqual(RTPMedia._payload(packet), payload) + self.assertIsNone(RTPMedia._payload(b"bad")) + + def test_cell_config_rejects_non_pcma_and_bad_port(self) -> None: + with self.assertRaises(CellCallError): + CellCallConfig("http://127.0.0.1:8088", "u", "p", rtp_format="ulaw") + with self.assertRaises(CellCallError): + CellCallConfig("http://127.0.0.1:8088", "u", "p", rtp_bind_port=65536) + + def test_ledger_marks_in_progress_as_in_doubt(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3") + first = ledger.claim("exec-1", "15003164745") + second = ledger.claim("exec-1", "15003164745") + self.assertTrue(first["claimed"]) + self.assertTrue(second["in_doubt"]) + + def test_worker_uses_queue_and_never_redials_terminal_execution(self) -> None: + command = { + "body": { + "command_type": "call.execute", + "tenant_id": "tenant-demo", + "tenant_key": "tenant-key", + "payload": { + "execution_id": "exec-1", + "agent_version_id": "agent_v1", + "callee": "15003164745", + }, + } + } + broker = FakeBroker([command, command]) + with tempfile.TemporaryDirectory() as directory: + ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3") + fake_result = SimpleNamespace( + as_dict=lambda: { + "call_id": "call-1", + "status": "failed", + "reason_code": "CUSTOMER_SILENT", + "recording_path": "/private/recording.wav", + } + ) + + class FakeExecutor: + def __init__(self) -> None: + self.engine = SimpleNamespace( + config={"agent_version_id": "agent_v1"} + ) + self.calls: list[str] = [] + + def start_authorized_call(self, callee: str) -> SimpleNamespace: + self.calls.append(callee) + return fake_result + + fake_executor = FakeExecutor() + executor = cast(RealCellCall, fake_executor) + worker = RealCellWorker(broker, "tenant-key", ledger, executor) + first = worker.process_once() + second = worker.process_once() + if first is None or second is None: + self.fail("worker did not publish a call.finished event") + self.assertEqual(first["event_type"], "call.finished") + self.assertEqual(second["event_type"], "call.finished") + self.assertEqual(fake_executor.calls, ["15003164745"]) + self.assertEqual(len(broker.acked), 2) + self.assertNotIn("recording_path", broker.published[0]["payload"]) + + def test_worker_rejects_wrong_tenant_route(self) -> None: + broker = FakeBroker( + [ + { + "body": { + "command_type": "call.execute", + "tenant_id": "tenant-demo", + "tenant_key": "other-tenant", + "payload": { + "execution_id": "exec-1", + "agent_version_id": "agent_v1", + "callee": "15003164745", + }, + } + } + ] + ) + with tempfile.TemporaryDirectory() as directory: + ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3") + executor = cast( + RealCellCall, + SimpleNamespace( + engine=SimpleNamespace(config={"agent_version_id": "agent_v1"}) + ), + ) + worker = RealCellWorker(broker, "tenant-key", ledger, executor) + result = worker.process_once() + if result is None: + self.fail("worker returned no command result") + self.assertEqual(result["reason_code"], "COMMAND_INVALID") + self.assertEqual(len(broker.rejected), 1) + + +if __name__ == "__main__": + unittest.main()