ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,76 @@
|
||||
# 附加项目:完整音频链路的 WebRTC 电话 Agent
|
||||
|
||||
本实验把“呼叫用户”实现为用户主动加入的本地浏览器 WebRTC 通话,不要求 PSTN、E.164 号码、电话运营商账户或公网 webhook。浏览器授权麦克风后向本地 `aiortc` peer 发送音频 RTP;服务端把实际收到的音频重采样并交给 Whisper ASR,把 ASR transcript 交给真实外部 LLM,再把 Agent 回复通过系统 TTS 合成为 PCM,送进 WebRTC 下行音轨。data channel 只传“音频提交”控制事件及 ASR/TTS 的无障碍字幕镜像,不提供用户语义。
|
||||
|
||||
完整链路是:
|
||||
|
||||
```text
|
||||
browser microphone → WebRTC RTP → aiortc PCM buffer → Whisper ASR
|
||||
→ external LLM dialogue / complete_task → real TTS PCM → WebRTC RTP → browser audio
|
||||
```
|
||||
|
||||
服务端不提供 local planner、正则 parser、mock 或模型失败 fallback。ReAct 规划和通话后的结构化对话都必须收到 provider response ID、精确模型、usage、finish status 和正 latency;任何字段缺失都会中止。交互通话的音频和 transcript 只在本地进程内存在,不写盘。自动验收只使用明确标记为非隐私的合成语音 fixture,因此可以保留其音频、transcript 和 hash 做复核。
|
||||
|
||||
## 直接调用与 ReAct 对照
|
||||
|
||||
两组使用相同的浏览器麦克风 → ASR → LLM → TTS → 下行 RTP 路径,唯一实验变量是规划方式:
|
||||
|
||||
| 组别 | 调用者输入 | 规划行为 |
|
||||
| --- | --- | --- |
|
||||
| 直接组(control) | 姓名、目标、上下文、指令四项全部填写 | 不调用规划 LLM,直接建立固定参数会话 |
|
||||
| ReAct 组(treatment) | 一段故意漏掉时间与确认码的自然语言任务 | 真实外部 LLM 留下 observation/reason/action 摘要,识别缺失字段并生成澄清话语 |
|
||||
|
||||
两组在 Whisper 转录用户明确确认的时间和确认码后,都由真实外部 LLM 生成 `complete_task` 结构及最终播报。实验只保存“本地确认记录”,不会声称诊所或其他外部系统已经完成预约。
|
||||
|
||||
## 安装与运行
|
||||
|
||||
```bash
|
||||
# 仓库根目录;uv.lock 同时固定 aiortc、Playwright、Torch 和 Whisper
|
||||
uv sync --locked --python 3.12 --extra ch6 --extra dev
|
||||
|
||||
cd chapter6/phone-agent
|
||||
cp env.example .env
|
||||
# 填入 ARK_API_KEY;也可按 env.example 显式改用 OpenAI/OpenRouter
|
||||
|
||||
# ReAct treatment
|
||||
uv run --extra ch6 python demo.py \
|
||||
--task "Call me about a dental checkup; ask for the missing exact time and confirmation code"
|
||||
|
||||
# Direct control
|
||||
uv run --extra ch6 python direct_call.py \
|
||||
--name "Jane Doe" \
|
||||
--goal "Confirm a dental-checkup time and code" \
|
||||
--context "Tuesday 2pm to 4pm is available" \
|
||||
--instructions "Ask for one time and code, require explicit confirmation, then complete_task"
|
||||
```
|
||||
|
||||
也可以运行 `uv run --extra ch6 uvicorn webrtc_app:app --host 127.0.0.1 --port 8765`,再打开 <http://127.0.0.1:8765>。听到 Agent 通过远端音轨播放的澄清问题后,对麦克风说出时间、确认码及明确确认,然后点击“Finish speaking”。页面不提供 typed semantic fallback 或浏览器 speech recognition;字幕只是服务端 ASR/TTS 结果的镜像。
|
||||
|
||||
默认配置使用 ARK `doubao-seed-1-6-flash-250615`、锁定的 `openai-whisper==20231106` tiny checkpoint,以及本机 `say`(macOS)或 eSpeak(Linux)TTS。可用 `PHONE_*` 与 `WHISPER_*` 环境变量显式覆盖;所选路径仍然 fail closed,不会换 provider 重试。`localhost` 可以直接使用麦克风;部署到其他主机时浏览器要求 HTTPS。本实验验证 localhost host-candidate 路径,不代表跨 NAT/TURN 或生产电话网络。
|
||||
|
||||
## Canonical 验收与复核
|
||||
|
||||
```bash
|
||||
# 必须存在所选外部 LLM credential;其值不会写入证据
|
||||
uv run --extra ch6 python run_acceptance.py \
|
||||
--output validation/runs/phone-agent-webrtc-audio-20260731-v1
|
||||
|
||||
uv run --extra ch6 python verify_acceptance.py \
|
||||
validation/runs/phone-agent-webrtc-audio-20260731-v1
|
||||
|
||||
uv run --extra ch6 --extra dev ruff check .
|
||||
uv run --extra ch6 --extra dev pytest -q
|
||||
node --check static/app.js
|
||||
```
|
||||
|
||||
验收分别用 Chrome 的 one-shot fake microphone device 播放两条安全合成 WAV。它不是文本注入:语音仍经过 `getUserMedia`、Opus/RTP、服务端解码、PCM 缓冲和真实 Whisper inference。每组必须同时通过 20 个门禁,包括 SDP/ICE、data channel、双向音轨与 RTP packet/byte、RTP-derived WAV、Whisper checkpoint hash、真实外部 LLM raw receipt、两条真实 TTS asset 的 hash 与完整下行发送、媒体 transcript source、缺失字段澄清、明确确认、结构化完成、无 fallback 及隐私边界。
|
||||
|
||||
[`validation/runs/phone-agent-webrtc-audio-20260731-v1/`](validation/runs/phone-agent-webrtc-audio-20260731-v1/) 保留 direct/react 原始记录、对照结论、安全 fixture、服务端收到的 ASR WAV、Agent TTS WAV、日志与 manifest。`verify_acceptance.py` 独立重算源码/产物/LLM raw receipt hash,并拒绝缺文件、改媒体、改 response ID、无 usage、错误 transcript source 或任何 gate 降级。安全合成验收证明技术链路,不等同于真人可用性研究。
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
This unnumbered add-on uses the stable `phone-agent` project identifier. It calls a consenting participant in a local browser rather than dialing the PSTN. Browser microphone audio is sent over RTP to aiortc, buffered and transcribed by real local Whisper. Only that ASR transcript enters the real external LLM dialogue. Every Agent utterance is synthesized by a real system TTS engine and queued on the WebRTC downlink audio track; the data channel carries only audio-commit control and accessibility mirrors.
|
||||
|
||||
The direct control requires four fixed parameters. The ReAct treatment accepts one incomplete task and requires a no-fallback external planning receipt with the credential-free raw request/response, provider response ID, exact model, usage, finish status, latency, and hashes. The retained safe campaign and standalone validator are in the directory linked above. PSTN and E.164 are intentionally outside this local “call the user” acceptance scope.
|
||||
@@ -0,0 +1,370 @@
|
||||
"""Fail-closed LLM planning and dialogue contracts for Phone Agent add-on.
|
||||
|
||||
The direct arm receives a fixed call plan. The ReAct arm asks a real external
|
||||
OpenAI-compatible provider to observe an incomplete task, identify missing facts,
|
||||
and choose the browser-call action. Both arms use the same external model for the
|
||||
post-ASR dialogue turn. Provider errors are surfaced; this module has no local
|
||||
planner, parser, mock, or fallback path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
DEFAULT_ARK_MODEL = "doubao-seed-1-6-flash-250615"
|
||||
ARK_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CallPlan:
|
||||
mode: str
|
||||
callee_name: str
|
||||
goal: str
|
||||
context: str
|
||||
instructions: str
|
||||
opening_line: str
|
||||
missing_information: list[str] = field(default_factory=list)
|
||||
trace: list[dict[str, str]] = field(default_factory=list)
|
||||
planner_model: str | None = None
|
||||
planner_receipt: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderConfig:
|
||||
name: str
|
||||
api_key: str
|
||||
base_url: str | None
|
||||
model: str
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(label: str, value: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
raise ValueError(f"{label} is required")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _sha256_json(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _redact_secrets(value: Any) -> Any:
|
||||
"""Remove credential values before any provider request/response is retained."""
|
||||
serialized = json.dumps(value, ensure_ascii=False, default=str)
|
||||
for name, secret in os.environ.items():
|
||||
if (
|
||||
any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
|
||||
and len(secret) >= 8
|
||||
):
|
||||
serialized = serialized.replace(secret, "[REDACTED]")
|
||||
serialized = re.sub(r"\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b", "[REDACTED]", serialized)
|
||||
return json.loads(serialized)
|
||||
|
||||
|
||||
def _provider_config(model: str | None = None) -> ProviderConfig:
|
||||
provider = os.getenv("PHONE_MODEL_PROVIDER", "ark").casefold()
|
||||
if provider == "ark":
|
||||
key = os.getenv("ARK_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER=ark requires ARK_API_KEY")
|
||||
return ProviderConfig(
|
||||
name="ark",
|
||||
api_key=key,
|
||||
base_url=os.getenv("ARK_BASE_URL", ARK_BASE_URL),
|
||||
model=model or os.getenv("PHONE_PLANNER_MODEL", DEFAULT_ARK_MODEL),
|
||||
)
|
||||
if provider == "openai":
|
||||
key = os.getenv("OPENAI_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER=openai requires OPENAI_API_KEY")
|
||||
return ProviderConfig(
|
||||
name="openai",
|
||||
api_key=key,
|
||||
base_url=os.getenv("OPENAI_BASE_URL") or None,
|
||||
model=model or os.getenv("PHONE_PLANNER_MODEL", "gpt-4.1-mini"),
|
||||
)
|
||||
if provider == "openrouter":
|
||||
key = os.getenv("OPENROUTER_API_KEY", "")
|
||||
if not key:
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER=openrouter requires OPENROUTER_API_KEY")
|
||||
return ProviderConfig(
|
||||
name="openrouter",
|
||||
api_key=key,
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
model=model or os.getenv("PHONE_PLANNER_MODEL", "openai/gpt-4.1-mini"),
|
||||
)
|
||||
raise RuntimeError("PHONE_MODEL_PROVIDER must be ark, openai, or openrouter")
|
||||
|
||||
|
||||
def _json_object(text: str) -> dict[str, Any]:
|
||||
value = json.loads(text)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("model response must be a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _real_json_completion(
|
||||
*,
|
||||
purpose: str,
|
||||
messages: list[dict[str, str]],
|
||||
model: str | None = None,
|
||||
client: OpenAI | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""Make one real completion and retain a credential-free raw receipt."""
|
||||
config = _provider_config(model)
|
||||
active_client = client or OpenAI(
|
||||
api_key=config.api_key,
|
||||
base_url=config.base_url,
|
||||
timeout=120,
|
||||
max_retries=0,
|
||||
)
|
||||
request = {
|
||||
"model": model or config.model,
|
||||
"messages": messages,
|
||||
"response_format": {"type": "json_object"},
|
||||
"temperature": 0,
|
||||
"max_tokens": 700,
|
||||
}
|
||||
sanitized_request = _redact_secrets(request)
|
||||
started_at = _now()
|
||||
started = time.monotonic()
|
||||
response = active_client.chat.completions.create(**request)
|
||||
latency = time.monotonic() - started
|
||||
finished_at = _now()
|
||||
|
||||
if not response.id:
|
||||
raise RuntimeError(f"{purpose} response omitted its provider response ID")
|
||||
if not response.choices:
|
||||
raise RuntimeError(f"{purpose} response contained no choices")
|
||||
choice = response.choices[0]
|
||||
content = (choice.message.content or "").strip()
|
||||
if not content:
|
||||
raise RuntimeError(f"{purpose} response contained no text")
|
||||
if not choice.finish_reason:
|
||||
raise RuntimeError(f"{purpose} response omitted finish status")
|
||||
usage = response.usage.model_dump(exclude_none=True) if response.usage else None
|
||||
if not usage or int(usage.get("total_tokens", 0)) <= 0:
|
||||
raise RuntimeError(f"{purpose} response omitted token usage")
|
||||
|
||||
raw_response = _redact_secrets(response.model_dump(exclude_none=True))
|
||||
parsed = _json_object(content)
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"purpose": purpose,
|
||||
"execution": "real_external_llm",
|
||||
"provider": provider_name or config.name,
|
||||
"requested_model": request["model"],
|
||||
"provider_model": response.model,
|
||||
"provider_response_id": response.id,
|
||||
"finish_reason": choice.finish_reason,
|
||||
"usage": usage,
|
||||
"started_at_utc": started_at,
|
||||
"finished_at_utc": finished_at,
|
||||
"latency_seconds": round(latency, 6),
|
||||
"request": sanitized_request,
|
||||
"request_sha256": _sha256_json(sanitized_request),
|
||||
"raw_response": raw_response,
|
||||
"raw_response_sha256": _sha256_json(raw_response),
|
||||
"response_content": content,
|
||||
"response_content_sha256": hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||
"external_request_completed": True,
|
||||
"mock": False,
|
||||
"probe_only": False,
|
||||
"fallback_used": False,
|
||||
"credential_fields_retained": False,
|
||||
}
|
||||
return parsed, receipt
|
||||
|
||||
|
||||
def direct_plan(
|
||||
*,
|
||||
callee_name: str,
|
||||
goal: str,
|
||||
context: str,
|
||||
instructions: str,
|
||||
) -> CallPlan:
|
||||
"""Build the fixed-parameter control without an LLM planning call."""
|
||||
callee = _required("callee_name", callee_name)
|
||||
return CallPlan(
|
||||
mode="direct",
|
||||
callee_name=callee,
|
||||
goal=_required("goal", goal),
|
||||
context=_required("context", context),
|
||||
instructions=_required("instructions", instructions),
|
||||
opening_line=(
|
||||
f"Hello {callee}. Please state the exact appointment time and confirmation code, "
|
||||
"then explicitly confirm both."
|
||||
),
|
||||
trace=[
|
||||
{"stage": "observation", "summary": "Caller supplied all call parameters."},
|
||||
{
|
||||
"stage": "action",
|
||||
"summary": "Open a WebRTC voice session with the fixed parameters.",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def react_plan(
|
||||
task: str,
|
||||
*,
|
||||
client: OpenAI | None = None,
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> CallPlan:
|
||||
"""Use a real external LLM to create the ReAct call plan; never fall back."""
|
||||
task = _required("task", task)
|
||||
data, receipt = _real_json_completion(
|
||||
purpose="react_planning",
|
||||
client=client,
|
||||
model=model,
|
||||
provider_name=provider_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"Plan a local browser WebRTC voice call. Observe the user's task, identify every missing "
|
||||
"task-critical fact, reason briefly about what must be collected, and choose the call action. "
|
||||
"Never invent facts. Return only JSON with callee_name, goal, context, instructions, "
|
||||
"opening_line, missing_information (array), and decision_summary. opening_line must ask aloud "
|
||||
"for the missing appointment time and confirmation code. instructions must require the voice "
|
||||
"Agent to repeat the facts, obtain explicit confirmation, and complete_task only with confirmed "
|
||||
"values. This local experiment records a confirmation but performs no external booking."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": task},
|
||||
],
|
||||
)
|
||||
missing = data.get("missing_information")
|
||||
if (
|
||||
not isinstance(missing, list)
|
||||
or not missing
|
||||
or not all(isinstance(item, str) and item.strip() for item in missing)
|
||||
):
|
||||
raise ValueError("ReAct planner must return a non-empty missing_information string array")
|
||||
decision = _required("decision_summary", str(data.get("decision_summary", "")))
|
||||
return CallPlan(
|
||||
mode="react",
|
||||
callee_name=_required("callee_name", str(data.get("callee_name", ""))),
|
||||
goal=_required("goal", str(data.get("goal", ""))),
|
||||
context=_required("context", str(data.get("context", ""))),
|
||||
instructions=_required("instructions", str(data.get("instructions", ""))),
|
||||
opening_line=_required("opening_line", str(data.get("opening_line", ""))),
|
||||
missing_information=[item.strip() for item in missing],
|
||||
trace=[
|
||||
{"stage": "observation", "summary": task},
|
||||
{"stage": "reason", "summary": decision},
|
||||
{
|
||||
"stage": "action",
|
||||
"summary": "Open a WebRTC call and collect the missing facts by voice.",
|
||||
},
|
||||
],
|
||||
planner_model=f"{receipt['provider']}:{receipt['provider_model']}",
|
||||
planner_receipt=receipt,
|
||||
)
|
||||
|
||||
|
||||
def conversation_turn(
|
||||
plan: CallPlan,
|
||||
transcript: list[dict[str, Any]],
|
||||
user_text: str,
|
||||
*,
|
||||
client: OpenAI | None = None,
|
||||
model: str | None = None,
|
||||
provider_name: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Use the ASR transcript in one real dialogue/completion call; never fall back."""
|
||||
user_text = _required("ASR transcript", user_text)
|
||||
dialogue_model = model or os.getenv("PHONE_DIALOGUE_MODEL")
|
||||
data, receipt = _real_json_completion(
|
||||
purpose="post_asr_dialogue",
|
||||
client=client,
|
||||
model=dialogue_model,
|
||||
provider_name=provider_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are the voice Agent in a short local browser call. The user text below came only from ASR "
|
||||
"over the browser microphone RTP track. Return only JSON with assistant_message, "
|
||||
"explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing "
|
||||
"result, appointment_time, confirmation_number, notes. If the user states an exact time, a "
|
||||
"confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious "
|
||||
"spoken code words/digits into a concise code, and repeat both details in assistant_message. "
|
||||
"Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an "
|
||||
"external action occurred. For a completed turn, completion.result must be exactly "
|
||||
"'Local confirmation recorded.' and completion.notes must be exactly "
|
||||
"'No external organization was contacted or booking made.' "
|
||||
f"Goal: {plan.goal}\nContext: {plan.context}\nInstructions: {plan.instructions}"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": _canonical_json(
|
||||
{
|
||||
"prior_audio_transcript": transcript,
|
||||
"latest_user_asr_transcript": user_text,
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
)
|
||||
completion = data.get("completion")
|
||||
required = {"result", "appointment_time", "confirmation_number", "notes"}
|
||||
if not isinstance(completion, dict) or not required.issubset(completion):
|
||||
raise ValueError("dialogue completion object is incomplete")
|
||||
assistant_message = _required("assistant_message", str(data.get("assistant_message", "")))
|
||||
should_complete = data.get("should_complete") is True
|
||||
explicit = data.get("explicit_confirmation_observed") is True
|
||||
if should_complete and not explicit:
|
||||
raise ValueError("model attempted completion without explicit confirmation")
|
||||
if should_complete and (
|
||||
not str(completion.get("appointment_time") or "").strip()
|
||||
or not str(completion.get("confirmation_number") or "").strip()
|
||||
):
|
||||
raise ValueError("model attempted completion without both critical fields")
|
||||
if should_complete and (
|
||||
str(completion.get("result", "")).strip() != "Local confirmation recorded."
|
||||
or str(completion.get("notes", "")).strip()
|
||||
!= "No external organization was contacted or booking made."
|
||||
):
|
||||
raise ValueError(
|
||||
"model attempted completion without the required no-external-action boundary"
|
||||
)
|
||||
return {
|
||||
"assistant_message": assistant_message,
|
||||
"explicit_confirmation_observed": explicit,
|
||||
"should_complete": should_complete,
|
||||
"completion": {key: str(completion.get(key, "")).strip() for key in sorted(required)},
|
||||
"dialogue_model": f"{receipt['provider']}:{receipt['provider_model']}",
|
||||
"llm_receipt": receipt,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CallPlan",
|
||||
"conversation_turn",
|
||||
"direct_plan",
|
||||
"react_plan",
|
||||
]
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch the ReAct arm of the local Phone Agent add-on browser call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
|
||||
DEFAULT_TASK = (
|
||||
"Call me to arrange a dental checkup. I did not include the exact time or confirmation code, "
|
||||
"so ask me for both by voice, repeat the details, and save only what I explicitly confirm."
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--task", default=DEFAULT_TASK)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument(
|
||||
"--no-open", action="store_true", help="Do not open the browser automatically"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
query = urllib.parse.urlencode({"mode": "react", "task": args.task})
|
||||
url = f"http://{args.host}:{args.port}/?{query}"
|
||||
print(f"Phone Agent add-on ReAct endpoint: {url}")
|
||||
if not args.no_open:
|
||||
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
||||
uvicorn.run("webrtc_app:app", host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch the fixed-parameter control arm of Phone Agent add-on."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import threading
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--name", required=True, help="Name shown in the AI's opening line")
|
||||
parser.add_argument("--goal", required=True)
|
||||
parser.add_argument("--context", required=True)
|
||||
parser.add_argument("--instructions", required=True)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument(
|
||||
"--no-open", action="store_true", help="Do not open the browser automatically"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"mode": "direct",
|
||||
"callee_name": args.name,
|
||||
"goal": args.goal,
|
||||
"context": args.context,
|
||||
"instructions": args.instructions,
|
||||
}
|
||||
)
|
||||
url = f"http://{args.host}:{args.port}/?{query}"
|
||||
print(f"Phone Agent add-on direct endpoint: {url}")
|
||||
if not args.no_open:
|
||||
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
||||
uvicorn.run("webrtc_app:app", host=args.host, port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
# Canonical external LLM provider. Credentials remain in the local server
|
||||
# process and are redacted before any request/response receipt is retained.
|
||||
ARK_API_KEY=your_ark_api_key
|
||||
PHONE_MODEL_PROVIDER=ark
|
||||
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
PHONE_PLANNER_MODEL=doubao-seed-1-6-flash-250615
|
||||
PHONE_DIALOGUE_MODEL=doubao-seed-1-6-flash-250615
|
||||
|
||||
# Explicit alternatives; there is no automatic provider fallback.
|
||||
# PHONE_MODEL_PROVIDER=openai
|
||||
# OPENAI_API_KEY=your_openai_api_key
|
||||
# PHONE_PLANNER_MODEL=gpt-4.1-mini
|
||||
# PHONE_DIALOGUE_MODEL=gpt-4.1-mini
|
||||
# PHONE_MODEL_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
# PHONE_PLANNER_MODEL=openai/gpt-4.1-mini
|
||||
# PHONE_DIALOGUE_MODEL=openai/gpt-4.1-mini
|
||||
|
||||
# Local speech runtime. openai-whisper and torch are locked in the ch9 extra.
|
||||
WHISPER_MODEL=tiny
|
||||
# WHISPER_PYTHON=/absolute/path/to/python
|
||||
PHONE_TTS_ENGINE=auto
|
||||
# PHONE_TTS_VOICE=Samantha
|
||||
|
||||
# These are set only by run_acceptance.py. Do not enable media retention for
|
||||
# real/private participant speech.
|
||||
# PHONE_SAFE_SYNTHETIC_ACCEPTANCE=1
|
||||
# PHONE_EVIDENCE_DIR=/absolute/path/to/safe-evidence-directory
|
||||
@@ -0,0 +1,12 @@
|
||||
# Prefer the repository's locked Chapter 9 environment:
|
||||
# uv sync --locked --extra ch6 --extra dev
|
||||
openai>=1.68
|
||||
python-dotenv>=1.0
|
||||
fastapi>=0.110
|
||||
uvicorn[standard]>=0.27
|
||||
httpx>=0.27
|
||||
playwright>=1.40
|
||||
aiortc>=1.10
|
||||
openai-whisper==20231106
|
||||
torch>=2.2
|
||||
pytest>=8.3
|
||||
Executable
+476
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the safe, full-audio direct-vs-ReAct Phone Agent add-on campaign.
|
||||
|
||||
Each arm gives Chrome a non-private synthesized microphone WAV. Chrome sends that
|
||||
fixture through getUserMedia and RTP to aiortc; the server buffers the received RTP
|
||||
audio, runs real Whisper ASR, invokes a real external dialogue model, synthesizes the
|
||||
Agent's speech, and transmits it on the downlink RTP track. No semantic user text is
|
||||
sent over the data channel and no PSTN destination is contacted.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
from speech import make_synthetic_speech_fixture, sha256_file
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
CANONICAL_MODEL = "doubao-seed-1-6-flash-250615"
|
||||
|
||||
|
||||
def json_request(url: str) -> dict[str, Any]:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
return json.loads(response.read())
|
||||
|
||||
|
||||
def wait_for(check: Callable[[], Any], timeout: float, label: str) -> Any:
|
||||
deadline = time.monotonic() + timeout
|
||||
last: Any = None
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
last = check()
|
||||
if last:
|
||||
return last
|
||||
except (urllib.error.URLError, json.JSONDecodeError):
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(f"timed out waiting for {label}; last={last!r}")
|
||||
|
||||
|
||||
def free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def chrome_path() -> str:
|
||||
candidates = [
|
||||
os.getenv("CHROME_PATH", ""),
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
shutil.which("google-chrome") or "",
|
||||
shutil.which("chromium") or "",
|
||||
shutil.which("chromium-browser") or "",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate and Path(candidate).is_file():
|
||||
return candidate
|
||||
raise RuntimeError("Chrome/Chromium was not found; set CHROME_PATH")
|
||||
|
||||
|
||||
def require_canonical_runtime() -> None:
|
||||
if not os.getenv("ARK_API_KEY"):
|
||||
raise RuntimeError("canonical acceptance requires ARK_API_KEY")
|
||||
whisper_python = os.getenv("WHISPER_PYTHON", sys.executable)
|
||||
if not whisper_python or not Path(whisper_python).is_file():
|
||||
raise RuntimeError("canonical acceptance requires explicit WHISPER_PYTHON")
|
||||
check = subprocess.run(
|
||||
[whisper_python, "-c", "import torch, whisper"],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
raise RuntimeError("WHISPER_PYTHON cannot import torch and whisper")
|
||||
if not shutil.which("ffmpeg") or not (shutil.which("say") or shutil.which("espeak")):
|
||||
raise RuntimeError("canonical acceptance requires ffmpeg and say/espeak")
|
||||
|
||||
|
||||
def _arm_request(page: Page, arm: str) -> None:
|
||||
page.check(f'input[name="mode"][value="{arm}"]')
|
||||
if arm == "direct":
|
||||
page.fill("#callee-name", "Jane Doe")
|
||||
page.fill(
|
||||
"#goal",
|
||||
"Collect and confirm Jane Doe's preferred dental-checkup time and confirmation code.",
|
||||
)
|
||||
page.fill(
|
||||
"#context",
|
||||
"Tuesday afternoon from 2pm to 4pm is available; the user must supply the exact time and code by voice.",
|
||||
)
|
||||
page.fill(
|
||||
"#instructions",
|
||||
"Ask for one exact time and a confirmation code. Repeat both, require explicit confirmation, then call "
|
||||
"complete_task with only the ASR-confirmed details.",
|
||||
)
|
||||
else:
|
||||
page.fill(
|
||||
"#task",
|
||||
"Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation "
|
||||
"code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm.",
|
||||
)
|
||||
|
||||
|
||||
def run_arm(page: Page, base_url: str, arm: str, fixture_duration: float) -> dict[str, Any]:
|
||||
page.goto(base_url, wait_until="networkidle")
|
||||
_arm_request(page, arm)
|
||||
page.click("#start")
|
||||
page.wait_for_function(
|
||||
"() => window.exp92?.state?.dc?.readyState === 'open' && "
|
||||
"['connected','completed'].includes(window.exp92.state.pc.iceConnectionState)",
|
||||
timeout=120_000,
|
||||
)
|
||||
call_id = page.locator("#call-id").inner_text()
|
||||
|
||||
def record() -> dict[str, Any]:
|
||||
return json_request(f"{base_url}/api/calls/{call_id}")
|
||||
|
||||
wait_for(
|
||||
lambda: (
|
||||
(value := record())["models"]["tts_receipts"]
|
||||
and value["models"]["tts_receipts"][0].get("delivery_complete")
|
||||
),
|
||||
120,
|
||||
f"{arm} synthesized opening speech on downlink RTP",
|
||||
)
|
||||
# The fake device starts at getUserMedia. Wait through its one-shot safe
|
||||
# fixture before sending a control-only commit event.
|
||||
page.wait_for_timeout(int((fixture_duration + 1.0) * 1000))
|
||||
page.evaluate("async () => await window.exp92.commitAudio()")
|
||||
|
||||
def completion_or_error() -> dict[str, Any] | None:
|
||||
value = record()
|
||||
return value if value.get("completion") or value.get("errors") else None
|
||||
|
||||
completed = wait_for(completion_or_error, 300, f"{arm} microphone ASR -> LLM -> completion")
|
||||
if completed["errors"]:
|
||||
raise AssertionError(f"{arm} runtime errors: {completed['errors']}")
|
||||
wait_for(
|
||||
lambda: (
|
||||
(value := record())["models"]["tts_receipts"]
|
||||
and len(value["models"]["tts_receipts"]) >= 2
|
||||
and all(item.get("delivery_complete") for item in value["models"]["tts_receipts"])
|
||||
),
|
||||
180,
|
||||
f"{arm} all Agent TTS transmitted on downlink RTP",
|
||||
)
|
||||
page.evaluate("async () => await window.exp92.collectStats()")
|
||||
wait_for(
|
||||
lambda: (
|
||||
(value := record())["transport"]["rtc_stats"]["inbound_packets"] > 0
|
||||
and value["transport"]["rtc_stats"]["outbound_packets"] > 0
|
||||
),
|
||||
45,
|
||||
f"{arm} bidirectional RTP counters",
|
||||
)
|
||||
final = page.evaluate("async () => await window.exp92.hangup('automated_safe_acceptance')")
|
||||
if not final["acceptance"]["passed"]:
|
||||
raise AssertionError(f"{arm} acceptance failed: {final['acceptance']}")
|
||||
return final
|
||||
|
||||
|
||||
def _credential_scan(paths: list[Path]) -> dict[str, Any]:
|
||||
secrets = [
|
||||
secret.encode()
|
||||
for name, secret in os.environ.items()
|
||||
if any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
|
||||
and len(secret) >= 8
|
||||
]
|
||||
failures = []
|
||||
for path in paths:
|
||||
data = path.read_bytes()
|
||||
if any(secret in data for secret in secrets):
|
||||
failures.append(str(path))
|
||||
return {
|
||||
"scanned_file_count": len(paths),
|
||||
"environment_credential_value_matches": failures,
|
||||
"passed": not failures,
|
||||
}
|
||||
|
||||
|
||||
def _package_version(name: str) -> str:
|
||||
try:
|
||||
return importlib.metadata.version(name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return "not-installed"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, help="Run directory (default: timestamped under validation/runs)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
require_canonical_runtime()
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
output = (
|
||||
args.output or HERE / "validation" / "runs" / f"phone-agent-webrtc-audio-{stamp}"
|
||||
).resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
fixture_text = {
|
||||
"direct": (
|
||||
"Tuesday at three P M works. The confirmation code is Maple seven. "
|
||||
"I explicitly confirm Tuesday at three P M and confirmation code Maple seven."
|
||||
),
|
||||
"react": (
|
||||
"Tuesday at three P M works. The confirmation code is Cedar eight. "
|
||||
"I explicitly confirm Tuesday at three P M and confirmation code Cedar eight."
|
||||
),
|
||||
}
|
||||
fixtures: dict[str, dict[str, Any]] = {}
|
||||
fixture_paths: dict[str, Path] = {}
|
||||
for arm, text in fixture_text.items():
|
||||
path = output / "fixtures" / f"{arm}_microphone.wav"
|
||||
fixtures[arm] = make_synthetic_speech_fixture(text, path)
|
||||
fixtures[arm]["artifact_path"] = str(path.relative_to(output))
|
||||
fixtures[arm]["text_sha256"] = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
fixture_paths[arm] = path
|
||||
|
||||
port = free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
server_log = output / "server.log"
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
{
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PHONE_MODEL_PROVIDER": "ark",
|
||||
"PHONE_PLANNER_MODEL": CANONICAL_MODEL,
|
||||
"PHONE_DIALOGUE_MODEL": CANONICAL_MODEL,
|
||||
"PHONE_TTS_ENGINE": "say" if shutil.which("say") else "espeak",
|
||||
"WHISPER_MODEL": "tiny",
|
||||
"WHISPER_PYTHON": os.getenv("WHISPER_PYTHON", sys.executable),
|
||||
"PHONE_SAFE_SYNTHETIC_ACCEPTANCE": "1",
|
||||
"PHONE_EVIDENCE_DIR": str(output),
|
||||
}
|
||||
)
|
||||
server_cleaned = False
|
||||
browsers_closed = False
|
||||
with server_log.open("w", encoding="utf-8") as log:
|
||||
server = subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"webrtc_app:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
],
|
||||
cwd=HERE,
|
||||
env=env,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
health = wait_for(lambda: json_request(base_url + "/api/health"), 45, "local server")
|
||||
if not health.get("model_credential_present"):
|
||||
raise RuntimeError("server did not observe the model credential")
|
||||
executable = chrome_path()
|
||||
with sync_playwright() as playwright:
|
||||
records: dict[str, dict[str, Any]] = {}
|
||||
for arm in ("direct", "react"):
|
||||
browser = playwright.chromium.launch(
|
||||
executable_path=executable,
|
||||
headless=True,
|
||||
args=[
|
||||
"--use-fake-ui-for-media-stream",
|
||||
"--use-fake-device-for-media-stream",
|
||||
f"--use-file-for-fake-audio-capture={fixture_paths[arm]}%noloop",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
)
|
||||
context = browser.new_context(permissions=["microphone"])
|
||||
try:
|
||||
records[arm] = run_arm(
|
||||
context.new_page(),
|
||||
base_url,
|
||||
arm,
|
||||
float(fixtures[arm]["duration_seconds"]),
|
||||
)
|
||||
finally:
|
||||
context.close()
|
||||
browser.close()
|
||||
browsers_closed = True
|
||||
finally:
|
||||
server.terminate()
|
||||
try:
|
||||
server.wait(timeout=15)
|
||||
except subprocess.TimeoutExpired:
|
||||
server.kill()
|
||||
server.wait(timeout=5)
|
||||
server_cleaned = server.poll() is not None
|
||||
|
||||
direct = records["direct"]
|
||||
react = records["react"]
|
||||
comparison_checks = {
|
||||
"same_browser_aiortc_webrtc_transport": direct["transport"]["kind"]
|
||||
== react["transport"]["kind"]
|
||||
== "webrtc",
|
||||
"no_pstn_or_e164": all(
|
||||
record["transport"]["pstn_used"] is False
|
||||
and record["transport"]["e164_required"] is False
|
||||
for record in (direct, react)
|
||||
),
|
||||
"direct_required_fixed_parameters": direct["input_contract"]["fields_supplied_by_caller"]
|
||||
== ["callee_name", "goal", "context", "instructions"],
|
||||
"react_accepted_only_natural_language_task": react["input_contract"][
|
||||
"fields_supplied_by_caller"
|
||||
]
|
||||
== ["task"],
|
||||
"react_detected_missing_information": bool(react["plan"]["missing_information"]),
|
||||
"react_has_observe_reason_act_trace": [step["stage"] for step in react["plan"]["trace"]]
|
||||
== ["observation", "reason", "action"],
|
||||
"react_used_real_external_planner": any(
|
||||
item["purpose"] == "react_planning" and item["execution"] == "real_external_llm"
|
||||
for item in react["models"]["llm_receipts"]
|
||||
),
|
||||
"both_used_microphone_rtp_asr": all(
|
||||
record["models"]["asr_receipts"][0]["input_source"] == "browser_microphone_rtp"
|
||||
for record in (direct, react)
|
||||
),
|
||||
"both_used_real_downlink_tts": all(
|
||||
len(record["models"]["tts_receipts"]) >= 2
|
||||
and all(item["delivery_complete"] for item in record["models"]["tts_receipts"])
|
||||
for record in (direct, react)
|
||||
),
|
||||
"both_used_external_post_asr_dialogue": all(
|
||||
any(item["purpose"] == "post_asr_dialogue" for item in record["models"]["llm_receipts"])
|
||||
for record in (direct, react)
|
||||
),
|
||||
"data_channel_never_supplied_user_semantics": all(
|
||||
record["event_counts"].get("semantic_user_messages", 0) == 0
|
||||
for record in (direct, react)
|
||||
),
|
||||
"both_completed_all_audio_gates": direct["acceptance"]["passed"]
|
||||
and react["acceptance"]["passed"],
|
||||
"both_saved_confirmed_structured_fields": all(
|
||||
record["completion"]["appointment_time"] and record["completion"]["confirmation_number"]
|
||||
for record in (direct, react)
|
||||
),
|
||||
}
|
||||
comparison = {
|
||||
"schema_version": 2,
|
||||
"experiment": "phone-agent",
|
||||
"executed_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"control": "fixed parameters -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
|
||||
"treatment": "natural task -> real LLM ReAct plan -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
|
||||
"checks": comparison_checks,
|
||||
"passed": all(comparison_checks.values()),
|
||||
"conclusion": (
|
||||
"Both arms completed the same real bidirectional browser/aiortc audio path. The treatment additionally "
|
||||
"used an external ARK ReAct planning receipt to detect missing facts; the control used fixed parameters."
|
||||
),
|
||||
}
|
||||
if not comparison["passed"]:
|
||||
raise AssertionError(f"comparison failed: {comparison_checks}")
|
||||
|
||||
artifacts = {"direct.json": direct, "react.json": react, "comparison.json": comparison}
|
||||
for name, value in artifacts.items():
|
||||
(output / name).write_text(
|
||||
json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
source_paths = [
|
||||
HERE / "agent.py",
|
||||
HERE / "speech.py",
|
||||
HERE / "webrtc_app.py",
|
||||
HERE / "run_acceptance.py",
|
||||
HERE / "verify_acceptance.py",
|
||||
HERE / "demo.py",
|
||||
HERE / "direct_call.py",
|
||||
HERE / "env.example",
|
||||
HERE / "requirements.txt",
|
||||
HERE / "test_agent.py",
|
||||
HERE / "test_speech.py",
|
||||
HERE / "test_webrtc_app.py",
|
||||
HERE / "test_verify_acceptance.py",
|
||||
HERE / "static" / "index.html",
|
||||
HERE / "static" / "app.js",
|
||||
HERE / "static" / "style.css",
|
||||
HERE / "README.md",
|
||||
ROOT / "chapter9" / "README.md",
|
||||
ROOT / "book" / "chapter9.md",
|
||||
ROOT / "pyproject.toml",
|
||||
ROOT / "uv.lock",
|
||||
]
|
||||
evidence_files = sorted(path for path in output.rglob("*") if path.is_file())
|
||||
scan = _credential_scan(evidence_files)
|
||||
if not scan["passed"]:
|
||||
raise RuntimeError(
|
||||
f"credential values found in evidence: {scan['environment_credential_value_matches']}"
|
||||
)
|
||||
executable = Path(chrome_path())
|
||||
manifest = {
|
||||
"schema_version": 2,
|
||||
"experiment": "phone-agent",
|
||||
"run_id": output.name,
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"result": "passed",
|
||||
"execution": "live_browser_aiortc_asr_external_llm_tts_webrtc",
|
||||
"canonical_safe_synthetic_fixture": True,
|
||||
"pstn_used": False,
|
||||
"e164_required": False,
|
||||
"credentials_saved": False,
|
||||
"private_audio_or_transcripts_saved": False,
|
||||
"safe_fixture_provenance": fixtures,
|
||||
"environment": {
|
||||
"platform": platform.platform(),
|
||||
"python": platform.python_version(),
|
||||
"chrome_version": subprocess.check_output(
|
||||
[str(executable), "--version"], text=True
|
||||
).strip(),
|
||||
"chrome_executable_sha256": sha256_file(executable),
|
||||
"planner_provider": "ark",
|
||||
"planner_model": CANONICAL_MODEL,
|
||||
"dialogue_model": CANONICAL_MODEL,
|
||||
"media_peer": "aiortc",
|
||||
"packages": {
|
||||
"aiortc": _package_version("aiortc"),
|
||||
"av": _package_version("av"),
|
||||
"openai": _package_version("openai"),
|
||||
"playwright": _package_version("playwright"),
|
||||
},
|
||||
},
|
||||
"source_sha256": {str(path.relative_to(ROOT)): sha256_file(path) for path in source_paths},
|
||||
"artifact_sha256": {
|
||||
str(path.relative_to(output)): sha256_file(path) for path in evidence_files
|
||||
},
|
||||
"redaction": scan,
|
||||
"cleanup": {
|
||||
"browser_contexts_closed": browsers_closed,
|
||||
"server_process_terminated": server_cleaned,
|
||||
"raw_private_media_created": False,
|
||||
},
|
||||
"acceptance": {
|
||||
"direct": direct["acceptance"],
|
||||
"react": react["acceptance"],
|
||||
"comparison_passed": comparison["passed"],
|
||||
},
|
||||
}
|
||||
if (
|
||||
manifest["cleanup"]["browser_contexts_closed"] is not True
|
||||
or manifest["cleanup"]["server_process_terminated"] is not True
|
||||
or manifest["cleanup"]["raw_private_media_created"] is not False
|
||||
):
|
||||
raise AssertionError(f"cleanup gate failed: {manifest['cleanup']}")
|
||||
(output / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
print(json.dumps({"run_dir": str(output), "passed": True}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Real speech synthesis and microphone-audio ASR for Phone Agent add-on."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import wave
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def sha256_bytes(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def pcm16_wav(pcm: bytes, sample_rate: int) -> bytes:
|
||||
with tempfile.SpooledTemporaryFile() as handle:
|
||||
with wave.open(handle, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(sample_rate)
|
||||
wav.writeframes(pcm)
|
||||
handle.seek(0)
|
||||
return handle.read()
|
||||
|
||||
|
||||
def read_pcm16_wav(value: bytes) -> tuple[bytes, int]:
|
||||
with tempfile.SpooledTemporaryFile() as handle:
|
||||
handle.write(value)
|
||||
handle.seek(0)
|
||||
with wave.open(handle, "rb") as wav:
|
||||
if wav.getnchannels() != 1 or wav.getsampwidth() != 2 or wav.getcomptype() != "NONE":
|
||||
raise RuntimeError("speech synthesizer output must be mono PCM16 WAV")
|
||||
return wav.readframes(wav.getnframes()), wav.getframerate()
|
||||
|
||||
|
||||
def _command_receipt(path: str) -> dict[str, Any]:
|
||||
resolved = Path(path).resolve()
|
||||
return {
|
||||
"name": resolved.name,
|
||||
"sha256": sha256_file(resolved),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SynthesizedSpeech:
|
||||
pcm: bytes
|
||||
wav: bytes
|
||||
receipt: dict[str, Any]
|
||||
|
||||
|
||||
class SystemSpeechSynthesizer:
|
||||
"""Synthesize actual speech with an explicitly selected local speech engine."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
requested = os.getenv("PHONE_TTS_ENGINE", "auto").casefold()
|
||||
say = shutil.which("say")
|
||||
espeak = shutil.which("espeak-ng") or shutil.which("espeak")
|
||||
if requested == "say" and not say:
|
||||
raise RuntimeError("PHONE_TTS_ENGINE=say but the say executable is unavailable")
|
||||
if requested == "espeak" and not espeak:
|
||||
raise RuntimeError("PHONE_TTS_ENGINE=espeak but espeak is unavailable")
|
||||
if requested not in {"auto", "say", "espeak"}:
|
||||
raise RuntimeError("PHONE_TTS_ENGINE must be auto, say, or espeak")
|
||||
self.engine = say if requested in {"auto", "say"} and say else espeak
|
||||
self.ffmpeg = shutil.which("ffmpeg")
|
||||
if not self.engine or not self.ffmpeg:
|
||||
raise RuntimeError("real local TTS requires say/espeak and ffmpeg")
|
||||
self.kind = "say" if Path(self.engine).name == "say" else "espeak"
|
||||
self.voice = os.getenv("PHONE_TTS_VOICE", "Samantha" if self.kind == "say" else "en-us")
|
||||
|
||||
def synthesize(self, text: str, *, sample_rate: int = 8_000) -> SynthesizedSpeech:
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("TTS text must not be empty")
|
||||
started = time.monotonic()
|
||||
with tempfile.TemporaryDirectory(prefix="phone-agent-tts-") as directory:
|
||||
directory_path = Path(directory)
|
||||
source = directory_path / ("speech.aiff" if self.kind == "say" else "speech.wav")
|
||||
target = directory_path / "speech.wav"
|
||||
if self.kind == "say":
|
||||
command = [self.engine, "-v", self.voice, "-o", str(source), cleaned]
|
||||
else:
|
||||
command = [self.engine, "-v", self.voice, "-w", str(source), cleaned]
|
||||
subprocess.run(command, check=True, capture_output=True, timeout=90)
|
||||
subprocess.run(
|
||||
[
|
||||
self.ffmpeg,
|
||||
"-nostdin",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-y",
|
||||
"-i",
|
||||
str(source),
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(sample_rate),
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
str(target),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
timeout=90,
|
||||
)
|
||||
wav = target.read_bytes()
|
||||
pcm, actual_rate = read_pcm16_wav(wav)
|
||||
if actual_rate != sample_rate or not pcm:
|
||||
raise RuntimeError("TTS produced empty audio or the wrong sample rate")
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say" if self.kind == "say" else "eSpeak",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": self.voice,
|
||||
"sample_rate_hz": sample_rate,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": len(pcm) // 2,
|
||||
"duration_seconds": round(len(pcm) / 2 / sample_rate, 6),
|
||||
"wav_bytes": len(wav),
|
||||
"wav_sha256": sha256_bytes(wav),
|
||||
"pcm_sha256": sha256_bytes(pcm),
|
||||
"latency_seconds": round(time.monotonic() - started, 6),
|
||||
"engine": _command_receipt(self.engine),
|
||||
"decoder": _command_receipt(self.ffmpeg),
|
||||
"network_used": False,
|
||||
"mock": False,
|
||||
"probe_only": False,
|
||||
"fallback_used": False,
|
||||
}
|
||||
return SynthesizedSpeech(pcm=pcm, wav=wav, receipt=receipt)
|
||||
|
||||
|
||||
class WhisperASR:
|
||||
"""Run a real cached OpenAI Whisper checkpoint over exact RTP-derived PCM."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
requested = os.getenv("WHISPER_PYTHON")
|
||||
candidates = [requested] if requested else [sys.executable, shutil.which("python3")]
|
||||
self.python = next(
|
||||
(candidate for candidate in candidates if candidate and self._available(candidate)),
|
||||
None,
|
||||
)
|
||||
if not self.python:
|
||||
raise RuntimeError(
|
||||
"local ASR requires torch and openai-whisper; set WHISPER_PYTHON to that Python executable"
|
||||
)
|
||||
self.model = os.getenv("WHISPER_MODEL", "tiny")
|
||||
|
||||
@staticmethod
|
||||
def _available(python: str) -> bool:
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
[python, "-c", "import torch, whisper"],
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
check=False,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
def transcribe(
|
||||
self, pcm: bytes, *, retained_wav_path: Path | None = None
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
if len(pcm) < 16_000:
|
||||
raise RuntimeError("microphone RTP buffer is too short for ASR")
|
||||
wav = pcm16_wav(pcm, 16_000)
|
||||
if retained_wav_path is not None:
|
||||
retained_wav_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
retained_wav_path.write_bytes(wav)
|
||||
source_path = retained_wav_path
|
||||
else:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="phone-agent-asr-", suffix=".wav", delete=False
|
||||
) as temporary:
|
||||
temporary.write(wav)
|
||||
source_path = Path(temporary.name)
|
||||
script = """import hashlib, json, pathlib, sys, time
|
||||
import torch, whisper
|
||||
model_name, audio = sys.argv[1:3]
|
||||
checkpoint = pathlib.Path.home()/'.cache'/'whisper'/(model_name+'.pt')
|
||||
started=time.perf_counter(); model=whisper.load_model(model_name); loaded=time.perf_counter()
|
||||
result=model.transcribe(audio, language='en', fp16=False, temperature=0, verbose=False, condition_on_previous_text=False)
|
||||
finished=time.perf_counter()
|
||||
payload={'text':str(result.get('text') or '').strip(),'language':result.get('language'),
|
||||
'checkpoint_name':checkpoint.name,'checkpoint_sha256':hashlib.sha256(checkpoint.read_bytes()).hexdigest() if checkpoint.exists() else None,
|
||||
'python':sys.version.split()[0],'torch':torch.__version__,'whisper':getattr(whisper,'__version__','unknown'),
|
||||
'model_load_seconds':loaded-started,'inference_seconds':finished-loaded}
|
||||
print('EXPERIMENT_JSON='+json.dumps(payload,ensure_ascii=False))
|
||||
"""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
process = subprocess.run(
|
||||
[self.python, "-c", script, self.model, str(source_path)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=240,
|
||||
)
|
||||
finally:
|
||||
if retained_wav_path is None:
|
||||
source_path.unlink(missing_ok=True)
|
||||
marker = next(
|
||||
(line for line in process.stdout.splitlines() if line.startswith("EXPERIMENT_JSON=")),
|
||||
None,
|
||||
)
|
||||
if not marker:
|
||||
raise RuntimeError("Whisper returned no structured result")
|
||||
result = json.loads(marker.split("=", 1)[1])
|
||||
transcript = str(result.get("text") or "").strip()
|
||||
checkpoint_sha = str(result.get("checkpoint_sha256") or "")
|
||||
if not transcript or len(checkpoint_sha) != 64:
|
||||
raise RuntimeError("Whisper returned an empty transcript or missing checkpoint hash")
|
||||
receipt = {
|
||||
"schema_version": 1,
|
||||
"operation": "asr",
|
||||
"execution": "real_local_inference",
|
||||
"provider": "local OpenAI Whisper",
|
||||
"model": f"whisper-{self.model}",
|
||||
"checkpoint_name": result["checkpoint_name"],
|
||||
"checkpoint_sha256": checkpoint_sha,
|
||||
"runtime": {
|
||||
"python": result["python"],
|
||||
"torch": result["torch"],
|
||||
"openai_whisper": result["whisper"],
|
||||
},
|
||||
"input_source": "browser_microphone_rtp",
|
||||
"input_sample_rate_hz": 16_000,
|
||||
"input_channels": 1,
|
||||
"input_pcm_bytes": len(pcm),
|
||||
"input_wav_bytes": len(wav),
|
||||
"input_wav_sha256": sha256_bytes(wav),
|
||||
"input_duration_seconds": round(len(pcm) / 2 / 16_000, 6),
|
||||
"language": result.get("language") or "unknown",
|
||||
"transcript": transcript,
|
||||
"transcript_sha256": hashlib.sha256(transcript.encode("utf-8")).hexdigest(),
|
||||
"model_load_seconds": round(float(result["model_load_seconds"]), 6),
|
||||
"inference_seconds": round(float(result["inference_seconds"]), 6),
|
||||
"latency_seconds": round(time.monotonic() - started, 6),
|
||||
"retained_safe_fixture_path": (
|
||||
str(retained_wav_path.name) if retained_wav_path is not None else None
|
||||
),
|
||||
"external_request": False,
|
||||
"mock": False,
|
||||
"probe_only": False,
|
||||
"fallback_used": False,
|
||||
}
|
||||
return transcript, receipt
|
||||
|
||||
|
||||
def make_synthetic_speech_fixture(
|
||||
text: str,
|
||||
output_path: Path,
|
||||
*,
|
||||
leading_silence_seconds: float = 4.0,
|
||||
trailing_silence_seconds: float = 3.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a non-private browser microphone WAV for automated acceptance."""
|
||||
synthesizer = SystemSpeechSynthesizer()
|
||||
speech = synthesizer.synthesize(text, sample_rate=16_000)
|
||||
leading = b"\x00\x00" * int(16_000 * leading_silence_seconds)
|
||||
trailing = b"\x00\x00" * int(16_000 * trailing_silence_seconds)
|
||||
wav = pcm16_wav(leading + speech.pcm + trailing, 16_000)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(wav)
|
||||
return {
|
||||
"kind": "safe_synthetic_browser_microphone_fixture",
|
||||
"contains_private_data": False,
|
||||
"sample_rate_hz": 16_000,
|
||||
"duration_seconds": round((len(leading) + len(speech.pcm) + len(trailing)) / 2 / 16_000, 6),
|
||||
"wav_bytes": len(wav),
|
||||
"wav_sha256": sha256_bytes(wav),
|
||||
"leading_silence_seconds": leading_silence_seconds,
|
||||
"trailing_silence_seconds": trailing_silence_seconds,
|
||||
"synthesis": speech.receipt,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SystemSpeechSynthesizer",
|
||||
"WhisperASR",
|
||||
"make_synthetic_speech_fixture",
|
||||
"pcm16_wav",
|
||||
"sha256_bytes",
|
||||
"sha256_file",
|
||||
]
|
||||
@@ -0,0 +1,243 @@
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
|
||||
const state = {
|
||||
callId: null,
|
||||
plan: null,
|
||||
pc: null,
|
||||
dc: null,
|
||||
stream: null,
|
||||
statsTimer: null,
|
||||
remoteAudioTrack: false,
|
||||
localAudioTrack: false,
|
||||
lastStats: null,
|
||||
audioCommitted: false,
|
||||
};
|
||||
|
||||
function mode() {
|
||||
return document.querySelector('input[name="mode"]:checked').value;
|
||||
}
|
||||
|
||||
function setStatus(value) {
|
||||
$('#status').textContent = value;
|
||||
document.body.dataset.status = value;
|
||||
}
|
||||
|
||||
function appendTurn(speaker, text) {
|
||||
if (!text) return;
|
||||
const p = document.createElement('p');
|
||||
p.className = 'turn';
|
||||
const b = document.createElement('b');
|
||||
b.textContent = speaker === 'agent' ? 'Agent: ' : 'You: ';
|
||||
p.append(b, document.createTextNode(text));
|
||||
$('#transcript').appendChild(p);
|
||||
$('#transcript').scrollTop = $('#transcript').scrollHeight;
|
||||
}
|
||||
|
||||
async function jsonFetch(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {'Content-Type': 'application/json', ...(options.headers || {})},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function saveEvent(event) {
|
||||
if (!state.callId) return;
|
||||
try {
|
||||
await jsonFetch(`/api/calls/${state.callId}/events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({event}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('event receipt failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
function sendControl(event) {
|
||||
if (!state.dc || state.dc.readyState !== 'open') throw new Error('data channel is not open');
|
||||
state.dc.send(JSON.stringify(event));
|
||||
}
|
||||
|
||||
async function publishReady() {
|
||||
if (!state.pc) return;
|
||||
const open = state.dc?.readyState === 'open';
|
||||
const connected = ['connected', 'completed'].includes(state.pc.iceConnectionState);
|
||||
$('#transport').textContent = `WebRTC · ICE ${state.pc.iceConnectionState} · data ${state.dc?.readyState || 'new'}`;
|
||||
if (open && connected) setStatus('connected · listening');
|
||||
await saveEvent({
|
||||
type: 'rtc.ready',
|
||||
ice_connection_state: state.pc.iceConnectionState,
|
||||
data_channel_open: open,
|
||||
local_audio_track: state.localAudioTrack,
|
||||
remote_audio_track: state.remoteAudioTrack,
|
||||
});
|
||||
}
|
||||
|
||||
async function collectStats() {
|
||||
if (!state.pc) return null;
|
||||
const totals = {
|
||||
type: 'rtc.stats',
|
||||
ice_connection_state: state.pc.iceConnectionState,
|
||||
inbound_packets: 0,
|
||||
inbound_bytes: 0,
|
||||
outbound_packets: 0,
|
||||
outbound_bytes: 0,
|
||||
};
|
||||
const reports = await state.pc.getStats();
|
||||
reports.forEach((report) => {
|
||||
const kind = report.kind || report.mediaType;
|
||||
if (kind !== 'audio') return;
|
||||
if (report.type === 'inbound-rtp' && !report.isRemote) {
|
||||
totals.inbound_packets += report.packetsReceived || 0;
|
||||
totals.inbound_bytes += report.bytesReceived || 0;
|
||||
}
|
||||
if (report.type === 'outbound-rtp' && !report.isRemote) {
|
||||
totals.outbound_packets += report.packetsSent || 0;
|
||||
totals.outbound_bytes += report.bytesSent || 0;
|
||||
}
|
||||
});
|
||||
state.lastStats = totals;
|
||||
await saveEvent(totals);
|
||||
return totals;
|
||||
}
|
||||
|
||||
async function handleServerEvent(event) {
|
||||
if (event.type === 'agent.caption') appendTurn('agent', event.text);
|
||||
if (event.type === 'user.caption') appendTurn('user', event.text);
|
||||
if (event.type === 'tool.result') {
|
||||
setStatus('task completed · Agent audio playing');
|
||||
$('#evidence').textContent = JSON.stringify(event, null, 2);
|
||||
}
|
||||
if (event.type === 'error') {
|
||||
setStatus('error');
|
||||
$('#evidence').textContent = JSON.stringify(event.error || event, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
if (mode() === 'react') return {mode: 'react', task: $('#task').value};
|
||||
return {
|
||||
mode: 'direct',
|
||||
callee_name: $('#callee-name').value,
|
||||
goal: $('#goal').value,
|
||||
context: $('#context').value,
|
||||
instructions: $('#instructions').value,
|
||||
};
|
||||
}
|
||||
|
||||
async function startCall() {
|
||||
$('#start').disabled = true;
|
||||
setStatus('real LLM planning');
|
||||
try {
|
||||
const created = await jsonFetch('/api/calls', {method: 'POST', body: JSON.stringify(createRequest())});
|
||||
state.callId = created.call_id;
|
||||
state.plan = created.plan;
|
||||
state.audioCommitted = false;
|
||||
$('#call-id').textContent = state.callId;
|
||||
$('#evidence').textContent = JSON.stringify({plan: state.plan}, null, 2);
|
||||
|
||||
const pc = new RTCPeerConnection();
|
||||
state.pc = pc;
|
||||
const audio = $('#remote-audio');
|
||||
pc.ontrack = async (event) => {
|
||||
audio.srcObject = event.streams[0];
|
||||
state.remoteAudioTrack = event.track.kind === 'audio';
|
||||
try { await audio.play(); } catch (error) { console.warn('autoplay pending', error); }
|
||||
await publishReady();
|
||||
};
|
||||
pc.oniceconnectionstatechange = publishReady;
|
||||
pc.onconnectionstatechange = publishReady;
|
||||
|
||||
setStatus('requesting microphone');
|
||||
state.stream = await navigator.mediaDevices.getUserMedia({audio: true, video: false});
|
||||
const track = state.stream.getAudioTracks()[0];
|
||||
if (!track) throw new Error('no microphone audio track was returned');
|
||||
state.localAudioTrack = true;
|
||||
pc.addTrack(track, state.stream);
|
||||
|
||||
const dc = pc.createDataChannel('accessibility-and-control');
|
||||
state.dc = dc;
|
||||
dc.onmessage = (message) => handleServerEvent(JSON.parse(message.data)).catch(console.error);
|
||||
dc.onclose = publishReady;
|
||||
dc.onopen = async () => {
|
||||
await publishReady();
|
||||
sendControl({type: 'client.ready'});
|
||||
$('#commit-audio').disabled = false;
|
||||
};
|
||||
|
||||
setStatus('negotiating WebRTC');
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const answerResponse = await fetch(`/api/calls/${state.callId}/session`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/sdp'},
|
||||
body: offer.sdp,
|
||||
});
|
||||
const answerSdp = await answerResponse.text();
|
||||
if (!answerResponse.ok) throw new Error(answerSdp);
|
||||
await pc.setRemoteDescription({type: 'answer', sdp: answerSdp});
|
||||
state.statsTimer = setInterval(() => collectStats().catch(console.warn), 750);
|
||||
$('#hangup').disabled = false;
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
$('#evidence').textContent = String(error);
|
||||
$('#start').disabled = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function commitAudio() {
|
||||
if (state.audioCommitted) throw new Error('microphone audio was already committed');
|
||||
state.audioCommitted = true;
|
||||
$('#commit-audio').disabled = true;
|
||||
setStatus('Whisper ASR · real LLM dialogue');
|
||||
sendControl({type: 'client.audio.commit'});
|
||||
}
|
||||
|
||||
async function hangup(reason = 'user_hangup') {
|
||||
if (!state.callId) return null;
|
||||
if (state.statsTimer) clearInterval(state.statsTimer);
|
||||
await collectStats();
|
||||
state.stream?.getTracks().forEach((track) => track.stop());
|
||||
state.dc?.close();
|
||||
state.pc?.close();
|
||||
const record = await jsonFetch(`/api/calls/${state.callId}/finish`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({reason}),
|
||||
});
|
||||
setStatus(record.acceptance.passed ? 'completed' : 'ended');
|
||||
$('#evidence').textContent = JSON.stringify(record, null, 2);
|
||||
$('#hangup').disabled = true;
|
||||
$('#commit-audio').disabled = true;
|
||||
return record;
|
||||
}
|
||||
|
||||
document.querySelectorAll('input[name="mode"]').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const react = mode() === 'react';
|
||||
$('#react-fields').hidden = !react;
|
||||
$('#direct-fields').hidden = react;
|
||||
});
|
||||
});
|
||||
$('#start').addEventListener('click', () => startCall().catch(console.error));
|
||||
$('#commit-audio').addEventListener('click', () => commitAudio().catch(console.error));
|
||||
$('#hangup').addEventListener('click', () => hangup().catch(console.error));
|
||||
|
||||
window.exp92 = {state, startCall, commitAudio, hangup, collectStats};
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('mode') === 'direct') {
|
||||
document.querySelector('input[name="mode"][value="direct"]').click();
|
||||
}
|
||||
const queryFields = {
|
||||
task: '#task',
|
||||
callee_name: '#callee-name',
|
||||
goal: '#goal',
|
||||
context: '#context',
|
||||
instructions: '#instructions',
|
||||
};
|
||||
Object.entries(queryFields).forEach(([key, selector]) => {
|
||||
if (params.has(key)) $(selector).value = params.get(key);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Phone Agent add-on · WebRTC Call Agent</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<p class="eyebrow">AI Agent Book · Phone Agent add-on</p>
|
||||
<h1>A voice agent that calls you in the browser</h1>
|
||||
<p class="lede">No phone number or PSTN account is needed. You explicitly join this local WebRTC session and can hang up at any time.</p>
|
||||
</header>
|
||||
|
||||
<section class="card setup">
|
||||
<div class="mode-row">
|
||||
<label><input type="radio" name="mode" value="react" checked> ReAct plan</label>
|
||||
<label><input type="radio" name="mode" value="direct"> Direct parameters</label>
|
||||
</div>
|
||||
|
||||
<div id="react-fields">
|
||||
<label for="task">Natural-language task</label>
|
||||
<textarea id="task" rows="4">Call me to arrange a dental checkup for Jane Doe. I did not include a time, so ask me for it, repeat the details, and save the confirmation I provide.</textarea>
|
||||
</div>
|
||||
|
||||
<div id="direct-fields" hidden>
|
||||
<label for="callee-name">Participant name</label>
|
||||
<input id="callee-name" value="Jane Doe">
|
||||
<label for="goal">Goal</label>
|
||||
<textarea id="goal" rows="2">Collect and confirm Jane Doe's preferred dental-checkup time.</textarea>
|
||||
<label for="context">Context</label>
|
||||
<textarea id="context" rows="2">Tuesday afternoon from 2pm to 4pm is available.</textarea>
|
||||
<label for="instructions">Instructions</label>
|
||||
<textarea id="instructions" rows="3">Ask for one exact time, repeat it, request confirmation, and save the confirmed time and confirmation number with complete_task.</textarea>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="start" type="button">Join call and enable microphone</button>
|
||||
<button id="hangup" class="secondary" type="button" disabled>Hang up</button>
|
||||
</div>
|
||||
<p class="privacy">Provider credentials stay on the local server. The browser receives only the SDP answer; no credential is embedded in this page.</p>
|
||||
</section>
|
||||
|
||||
<section class="card status-card">
|
||||
<div><span class="label">Status</span><strong id="status">idle</strong></div>
|
||||
<div><span class="label">Call ID</span><code id="call-id">—</code></div>
|
||||
<div><span class="label">Transport</span><span id="transport">WebRTC · not connected</span></div>
|
||||
</section>
|
||||
|
||||
<section class="card conversation">
|
||||
<h2>Audio transcript captions</h2>
|
||||
<div id="transcript" aria-live="polite"></div>
|
||||
<div class="actions">
|
||||
<button id="commit-audio" class="secondary" type="button" disabled>Finish speaking</button>
|
||||
</div>
|
||||
<p class="privacy">Speak into the microphone, then choose “Finish speaking.” User meaning comes only from server ASR over the microphone RTP track. The data channel carries this control event and accessibility captions; it never supplies the canonical user transcript.</p>
|
||||
</section>
|
||||
|
||||
<section class="card evidence">
|
||||
<h2>Session evidence</h2>
|
||||
<pre id="evidence">Start a call to see the negotiated local aiortc transport record.</pre>
|
||||
</section>
|
||||
|
||||
<audio id="remote-audio" autoplay></audio>
|
||||
</main>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #14221d;
|
||||
background: #edf3ef;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
main { width: min(880px, calc(100% - 32px)); margin: 48px auto 72px; }
|
||||
header { margin-bottom: 28px; }
|
||||
h1 { margin: 6px 0 12px; font-size: clamp(2rem, 6vw, 4.2rem); line-height: 0.98; letter-spacing: -0.045em; }
|
||||
h2 { margin-top: 0; font-size: 1.15rem; }
|
||||
.eyebrow, .label { color: #356450; text-transform: uppercase; letter-spacing: 0.09em; font-size: 0.76rem; font-weight: 750; }
|
||||
.lede { max-width: 700px; color: #41534b; font-size: 1.08rem; line-height: 1.55; }
|
||||
.card { background: #fff; border: 1px solid #cad8d0; border-radius: 18px; padding: 22px; margin: 14px 0; box-shadow: 0 12px 32px rgba(29, 67, 50, 0.06); }
|
||||
.mode-row, .actions, .text-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
.mode-row { margin-bottom: 18px; }
|
||||
label { display: block; margin: 12px 0 6px; font-weight: 650; }
|
||||
input, textarea, button { font: inherit; }
|
||||
input:not([type="radio"]), textarea { width: 100%; border: 1px solid #aebfb6; border-radius: 10px; padding: 11px 12px; background: #fbfdfc; color: inherit; }
|
||||
textarea { resize: vertical; }
|
||||
button { border: 0; border-radius: 999px; padding: 11px 18px; background: #176b49; color: white; font-weight: 720; cursor: pointer; }
|
||||
button.secondary { background: #dde8e2; color: #254537; }
|
||||
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.actions { margin-top: 20px; }
|
||||
.privacy { margin-bottom: 0; color: #68776f; font-size: 0.86rem; }
|
||||
.status-card { display: grid; grid-template-columns: 0.7fr 1fr 1.2fr; gap: 16px; }
|
||||
.status-card div { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
#transcript { min-height: 110px; max-height: 330px; overflow: auto; padding: 12px; background: #f3f7f4; border-radius: 12px; }
|
||||
.turn { margin: 0 0 10px; line-height: 1.45; }
|
||||
.turn b { color: #176b49; }
|
||||
.text-row { flex-wrap: nowrap; }
|
||||
.text-row input { flex: 1; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; color: #31463d; font-size: 0.82rem; }
|
||||
@media (max-width: 680px) {
|
||||
main { margin-top: 24px; }
|
||||
.status-card { grid-template-columns: 1fr; }
|
||||
.text-row { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from agent import conversation_turn, direct_plan, react_plan
|
||||
|
||||
|
||||
class FakeUsage:
|
||||
def model_dump(self, **_kwargs):
|
||||
return {"prompt_tokens": 40, "completion_tokens": 20, "total_tokens": 60}
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, content):
|
||||
self.id = "provider-response-123"
|
||||
self.model = "planner-test"
|
||||
self.created = 123456
|
||||
self.usage = FakeUsage()
|
||||
self.choices = [
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content=content),
|
||||
finish_reason="stop",
|
||||
)
|
||||
]
|
||||
|
||||
def model_dump(self, **_kwargs):
|
||||
return {
|
||||
"id": self.id,
|
||||
"model": self.model,
|
||||
"created": self.created,
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"message": {"role": "assistant", "content": self.choices[0].message.content},
|
||||
}
|
||||
],
|
||||
"usage": self.usage.model_dump(),
|
||||
}
|
||||
|
||||
|
||||
class FakeCompletions:
|
||||
def __init__(self, values):
|
||||
self.values = iter(values)
|
||||
|
||||
def create(self, **kwargs):
|
||||
assert kwargs["response_format"] == {"type": "json_object"}
|
||||
assert kwargs["temperature"] == 0
|
||||
return FakeResponse(json.dumps(next(self.values)))
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, values):
|
||||
self.chat = SimpleNamespace(completions=FakeCompletions(values))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def provider_environment(monkeypatch):
|
||||
monkeypatch.setenv("PHONE_MODEL_PROVIDER", "ark")
|
||||
monkeypatch.setenv("ARK_API_KEY", "test-key-not-retained")
|
||||
|
||||
|
||||
def test_direct_plan_requires_fixed_parameters_and_has_no_planner_receipt():
|
||||
with pytest.raises(ValueError, match="context"):
|
||||
direct_plan(callee_name="Jane", goal="Confirm", context="", instructions="Ask")
|
||||
plan = direct_plan(callee_name="Jane", goal="Confirm", context="Tuesday", instructions="Ask")
|
||||
assert plan.planner_receipt is None
|
||||
assert "confirmation code" in plan.opening_line
|
||||
|
||||
|
||||
def test_react_plan_retains_real_raw_receipt_and_trace():
|
||||
client = FakeClient(
|
||||
[
|
||||
{
|
||||
"callee_name": "Jane",
|
||||
"goal": "Confirm a dental checkup time",
|
||||
"context": "The time and code are absent.",
|
||||
"instructions": "Ask for time and code, repeat both, then complete_task.",
|
||||
"opening_line": "What exact time and confirmation code do you confirm?",
|
||||
"missing_information": ["appointment time", "confirmation code"],
|
||||
"decision_summary": "Collect both omitted fields by voice.",
|
||||
}
|
||||
]
|
||||
)
|
||||
plan = react_plan(
|
||||
"Call Jane, but I forgot the time and code",
|
||||
client=client,
|
||||
model="planner-test",
|
||||
provider_name="injected-test",
|
||||
)
|
||||
assert plan.missing_information == ["appointment time", "confirmation code"]
|
||||
assert [item["stage"] for item in plan.trace] == ["observation", "reason", "action"]
|
||||
receipt = plan.planner_receipt
|
||||
assert receipt["provider_response_id"] == "provider-response-123"
|
||||
assert receipt["usage"]["total_tokens"] == 60
|
||||
assert receipt["raw_response"]["choices"][0]["message"]["content"]
|
||||
assert receipt["fallback_used"] is False
|
||||
assert "test-key-not-retained" not in json.dumps(receipt)
|
||||
|
||||
|
||||
def test_conversation_requires_explicit_confirmation_for_completion():
|
||||
plan = direct_plan(
|
||||
callee_name="Jane",
|
||||
goal="Confirm a time",
|
||||
context="Tuesday afternoon",
|
||||
instructions="Ask and confirm",
|
||||
)
|
||||
client = FakeClient(
|
||||
[
|
||||
{
|
||||
"assistant_message": "Thanks. I recorded Tuesday at 3 PM and Maple 7.",
|
||||
"explicit_confirmation_observed": True,
|
||||
"should_complete": True,
|
||||
"completion": {
|
||||
"result": "Local confirmation recorded.",
|
||||
"appointment_time": "Tuesday at 3 PM",
|
||||
"confirmation_number": "MAPLE-7",
|
||||
"notes": "No external organization was contacted or booking made.",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
result = conversation_turn(
|
||||
plan,
|
||||
[],
|
||||
"I explicitly confirm Tuesday at 3 PM and Maple seven.",
|
||||
client=client,
|
||||
model="planner-test",
|
||||
provider_name="injected-test",
|
||||
)
|
||||
assert result["should_complete"] is True
|
||||
assert result["completion"]["confirmation_number"] == "MAPLE-7"
|
||||
assert result["llm_receipt"]["purpose"] == "post_asr_dialogue"
|
||||
|
||||
|
||||
def test_model_errors_propagate_without_fallback():
|
||||
class BrokenCompletions:
|
||||
def create(self, **_kwargs):
|
||||
raise RuntimeError("provider unavailable")
|
||||
|
||||
client = SimpleNamespace(chat=SimpleNamespace(completions=BrokenCompletions()))
|
||||
with pytest.raises(RuntimeError, match="provider unavailable"):
|
||||
react_plan("Call Jane and ask for the missing time", client=client, model="planner-test")
|
||||
def test_conversation_turn_rejects_none_critical_completion_fields():
|
||||
plan = direct_plan(
|
||||
callee_name="Jane",
|
||||
goal="Confirm a time",
|
||||
context="Tuesday afternoon",
|
||||
instructions="Ask and confirm",
|
||||
)
|
||||
client = FakeClient(
|
||||
[
|
||||
{
|
||||
"assistant_message": "Thanks.",
|
||||
"explicit_confirmation_observed": True,
|
||||
"should_complete": True,
|
||||
"completion": {
|
||||
"result": "Local confirmation recorded.",
|
||||
"appointment_time": None,
|
||||
"confirmation_number": "MAPLE-7",
|
||||
"notes": "No external organization was contacted or booking made.",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
with pytest.raises(ValueError, match="without both critical fields"):
|
||||
conversation_turn(
|
||||
plan,
|
||||
[],
|
||||
"I explicitly confirm Maple seven.",
|
||||
client=client,
|
||||
model="planner-test",
|
||||
provider_name="injected-test",
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
import struct
|
||||
|
||||
from speech import pcm16_wav, read_pcm16_wav, sha256_bytes
|
||||
|
||||
|
||||
def test_pcm_wav_round_trip_is_exact():
|
||||
pcm = b"".join(struct.pack("<h", sample) for sample in (0, 100, -100, 32767, -32768))
|
||||
wav = pcm16_wav(pcm, 16_000)
|
||||
decoded, rate = read_pcm16_wav(wav)
|
||||
assert decoded == pcm
|
||||
assert rate == 16_000
|
||||
assert len(sha256_bytes(wav)) == 64
|
||||
|
||||
|
||||
def test_speech_source_has_no_mock_or_tone_fallback():
|
||||
source = __import__("pathlib").Path(__file__).with_name("speech.py").read_text()
|
||||
assert "SystemSpeechSynthesizer" in source
|
||||
assert "WhisperASR" in source
|
||||
assert 'fallback_used": False' in source
|
||||
assert "440" not in source
|
||||
@@ -0,0 +1,52 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from verify_acceptance import check_llm_receipt
|
||||
|
||||
|
||||
def test_llm_receipt_tamper_is_rejected():
|
||||
content = '{"ok":true}'
|
||||
request = {"model": "model", "messages": [{"role": "user", "content": "safe"}]}
|
||||
raw = {
|
||||
"id": "response-1",
|
||||
"model": "model",
|
||||
"choices": [{"finish_reason": "stop", "message": {"content": content}}],
|
||||
"usage": {"total_tokens": 2},
|
||||
}
|
||||
canonical = lambda value: json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
import hashlib
|
||||
|
||||
receipt = {
|
||||
"execution": "real_external_llm",
|
||||
"external_request_completed": True,
|
||||
"provider_response_id": "response-1",
|
||||
"provider_model": "model",
|
||||
"requested_model": "model",
|
||||
"finish_reason": "stop",
|
||||
"usage": {"total_tokens": 2},
|
||||
"latency_seconds": 1,
|
||||
"request": request,
|
||||
"request_sha256": hashlib.sha256(canonical(request).encode()).hexdigest(),
|
||||
"raw_response": raw,
|
||||
"raw_response_sha256": hashlib.sha256(canonical(raw).encode()).hexdigest(),
|
||||
"response_content": content,
|
||||
"response_content_sha256": hashlib.sha256(content.encode()).hexdigest(),
|
||||
"mock": False,
|
||||
"probe_only": False,
|
||||
"fallback_used": False,
|
||||
"credential_fields_retained": False,
|
||||
}
|
||||
receipt["provider_response_id"] = "tampered"
|
||||
failures = []
|
||||
check_llm_receipt(receipt, "test: ", failures)
|
||||
assert any("response ID" in failure for failure in failures)
|
||||
|
||||
|
||||
def test_verifier_requires_retained_media_artifacts():
|
||||
from verify_acceptance import REQUIRED_ARTIFACTS
|
||||
|
||||
assert "media/direct/microphone_rtp_asr_input.wav" in REQUIRED_ARTIFACTS
|
||||
assert "media/react/agent_02.wav" in REQUIRED_ARTIFACTS
|
||||
assert all(not Path(item).is_absolute() for item in REQUIRED_ARTIFACTS)
|
||||
@@ -0,0 +1,87 @@
|
||||
from pathlib import Path
|
||||
|
||||
import webrtc_app
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def direct_payload():
|
||||
return {
|
||||
"mode": "direct",
|
||||
"callee_name": "Jane",
|
||||
"goal": "Confirm a time and code",
|
||||
"context": "Tuesday afternoon",
|
||||
"instructions": "Ask for a time and confirmation code, then save both.",
|
||||
}
|
||||
|
||||
|
||||
def test_direct_record_starts_fail_closed_with_audio_provenance_slots():
|
||||
webrtc_app.CALLS.clear()
|
||||
client = TestClient(webrtc_app.app)
|
||||
created = client.post("/api/calls", json=direct_payload())
|
||||
assert created.status_code == 200
|
||||
call_id = created.json()["call_id"]
|
||||
record = client.get(f"/api/calls/{call_id}").json()
|
||||
assert record["acceptance"]["passed"] is False
|
||||
assert record["models"]["llm_receipts"] == []
|
||||
assert record["models"]["asr_receipts"] == []
|
||||
assert record["models"]["tts_receipts"] == []
|
||||
assert record["transport"]["pstn_used"] is False
|
||||
assert record["transport"]["e164_required"] is False
|
||||
|
||||
|
||||
def test_session_endpoint_rejects_non_sdp_without_creating_a_peer():
|
||||
webrtc_app.CALLS.clear()
|
||||
client = TestClient(webrtc_app.app)
|
||||
call_id = client.post("/api/calls", json=direct_payload()).json()["call_id"]
|
||||
response = client.post(
|
||||
f"/api/calls/{call_id}/session",
|
||||
content="not sdp",
|
||||
headers={"Content-Type": "text/plain"},
|
||||
)
|
||||
assert response.status_code == 415
|
||||
assert call_id not in webrtc_app.PEERS
|
||||
|
||||
|
||||
def test_react_rejects_an_empty_task_before_provider_call():
|
||||
client = TestClient(webrtc_app.app)
|
||||
response = client.post("/api/calls", json={"mode": "react", "task": ""})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_browser_code_has_no_semantic_text_or_browser_speech_bypass():
|
||||
script = (Path(__file__).parent / "static" / "app.js").read_text()
|
||||
assert "getUserMedia" in script
|
||||
assert "client.audio.commit" in script
|
||||
assert "agent.caption" in script
|
||||
assert "user.message" not in script
|
||||
assert "speechSynthesis" not in script
|
||||
assert "SpeechRecognition" not in script
|
||||
|
||||
|
||||
def test_acceptance_gate_names_are_exact_and_fail_closed():
|
||||
client = TestClient(webrtc_app.app)
|
||||
call_id = client.post("/api/calls", json=direct_payload()).json()["call_id"]
|
||||
checks = client.get(f"/api/calls/{call_id}").json()["acceptance"]["checks"]
|
||||
assert set(checks) == {
|
||||
"sdp_offer_answer_negotiated",
|
||||
"ice_connected",
|
||||
"data_channel_open",
|
||||
"browser_microphone_track",
|
||||
"server_downlink_audio_track",
|
||||
"outbound_audio_rtp",
|
||||
"inbound_audio_rtp",
|
||||
"server_buffered_microphone_rtp",
|
||||
"real_asr_consumed_microphone_audio",
|
||||
"external_react_planner_or_fixed_direct_control",
|
||||
"real_external_post_asr_dialogue",
|
||||
"real_tts_assets_synthesized",
|
||||
"tts_audio_transmitted_on_downlink",
|
||||
"media_is_canonical_transcript_source",
|
||||
"data_channel_is_control_and_caption_only",
|
||||
"missing_fields_were_clarified_aloud",
|
||||
"explicit_confirmation_observed",
|
||||
"structured_completion_saved",
|
||||
"no_mock_probe_or_fallback",
|
||||
"privacy_boundary_preserved",
|
||||
}
|
||||
assert not all(checks.values())
|
||||
@@ -0,0 +1,6 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"experiment": "phone-agent-webrtc",
|
||||
"executed_at_utc": "2026-07-31T11:24:42.453962+00:00",
|
||||
"control": "fixed parameters -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
|
||||
"treatment": "natural task -> real LLM ReAct plan -> browser microphone RTP -> ASR -> real LLM dialogue -> TTS RTP",
|
||||
"checks": {
|
||||
"same_browser_aiortc_webrtc_transport": true,
|
||||
"no_pstn_or_e164": true,
|
||||
"direct_required_fixed_parameters": true,
|
||||
"react_accepted_only_natural_language_task": true,
|
||||
"react_detected_missing_information": true,
|
||||
"react_has_observe_reason_act_trace": true,
|
||||
"react_used_real_external_planner": true,
|
||||
"both_used_microphone_rtp_asr": true,
|
||||
"both_used_real_downlink_tts": true,
|
||||
"both_used_external_post_asr_dialogue": true,
|
||||
"data_channel_never_supplied_user_semantics": true,
|
||||
"both_completed_all_audio_gates": true,
|
||||
"both_saved_confirmed_structured_fields": true
|
||||
},
|
||||
"passed": true,
|
||||
"conclusion": "Both arms completed the same real bidirectional browser/aiortc audio path. The treatment additionally used an external ARK ReAct planning receipt to detect missing facts; the control used fixed parameters."
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"experiment": "phone-agent-webrtc",
|
||||
"call_id": "rtc_794c4b8e0481466fbf23",
|
||||
"created_at_utc": "2026-07-31T11:23:09.666702+00:00",
|
||||
"finished_at_utc": "2026-07-31T11:23:53.431450+00:00",
|
||||
"status": "completed",
|
||||
"mode": "direct",
|
||||
"input_contract": {
|
||||
"fields_supplied_by_caller": [
|
||||
"callee_name",
|
||||
"goal",
|
||||
"context",
|
||||
"instructions"
|
||||
],
|
||||
"natural_language_task": ""
|
||||
},
|
||||
"plan": {
|
||||
"mode": "direct",
|
||||
"callee_name": "Jane Doe",
|
||||
"goal": "Collect and confirm Jane Doe's preferred dental-checkup time and confirmation code.",
|
||||
"context": "Tuesday afternoon from 2pm to 4pm is available; the user must supply the exact time and code by voice.",
|
||||
"instructions": "Ask for one exact time and a confirmation code. Repeat both, require explicit confirmation, then call complete_task with only the ASR-confirmed details.",
|
||||
"opening_line": "Hello Jane Doe. Please state the exact appointment time and confirmation code, then explicitly confirm both.",
|
||||
"missing_information": [],
|
||||
"trace": [
|
||||
{
|
||||
"stage": "observation",
|
||||
"summary": "Caller supplied all call parameters."
|
||||
},
|
||||
{
|
||||
"stage": "action",
|
||||
"summary": "Open a WebRTC voice session with the fixed parameters."
|
||||
}
|
||||
],
|
||||
"planner_model": null
|
||||
},
|
||||
"models": {
|
||||
"planner": null,
|
||||
"dialogue_models": [
|
||||
"ark:doubao-seed-1-6-flash-250615"
|
||||
],
|
||||
"llm_receipts": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"purpose": "post_asr_dialogue",
|
||||
"execution": "real_external_llm",
|
||||
"provider": "ark",
|
||||
"requested_model": "doubao-seed-1-6-flash-250615",
|
||||
"provider_model": "doubao-seed-1-6-flash-250615",
|
||||
"provider_response_id": "0217854970227438de59cc126156ef9419088f6fa2f8da927049f",
|
||||
"finish_reason": "stop",
|
||||
"usage": {
|
||||
"completion_tokens": 181,
|
||||
"prompt_tokens": 392,
|
||||
"total_tokens": 573,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 84
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
},
|
||||
"started_at_utc": "2026-07-31T11:23:42.156339+00:00",
|
||||
"finished_at_utc": "2026-07-31T11:23:44.170815+00:00",
|
||||
"latency_seconds": 2.014451,
|
||||
"request": {
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are the voice Agent in a short local browser call. The user text below came only from ASR over the browser microphone RTP track. Return only JSON with assistant_message, explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing result, appointment_time, confirmation_number, notes. If the user states an exact time, a confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious spoken code words/digits into a concise code, and repeat both details in assistant_message. Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an external action occurred. For a completed turn, completion.result must be exactly 'Local confirmation recorded.' and completion.notes must be exactly 'No external organization was contacted or booking made.' Goal: Collect and confirm Jane Doe's preferred dental-checkup time and confirmation code.\nContext: Tuesday afternoon from 2pm to 4pm is available; the user must supply the exact time and code by voice.\nInstructions: Ask for one exact time and a confirmation code. Repeat both, require explicit confirmation, then call complete_task with only the ASR-confirmed details."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{\"latest_user_asr_transcript\":\"Confirmation called as Maple 7. I explicitly confirm Tuesday at 3pm and confirmation code Maple 7.\",\"prior_audio_transcript\":[{\"purpose\":\"missing_field_clarification\",\"source\":\"tts.webrtc_downlink\",\"speaker\":\"agent\",\"text\":\"Hello Jane Doe. Please state the exact appointment time and confirmation code, then explicitly confirm both.\",\"utterance_id\":\"tts_01\"}]}"
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_object"
|
||||
},
|
||||
"temperature": 0,
|
||||
"max_tokens": 700
|
||||
},
|
||||
"request_sha256": "625905fa5da9a4cdc0bd711793201abc585ea5434e62f2a03024a128a3cd40d5",
|
||||
"raw_response": {
|
||||
"id": "0217854970227438de59cc126156ef9419088f6fa2f8da927049f",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "{\"assistant_message\": \"Confirmed appointment time: Tuesday at 3pm, Confirmation code: Maple 7. Explicit confirmation observed: true\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3pm\", \"confirmation_number\": \"Maple 7\", \"notes\": \"No external organization was contacted or booking made.\"}}",
|
||||
"role": "assistant",
|
||||
"reasoning_content": "Got it, let's see. The user provided the exact time as Tuesday at 3pm and the confirmation code as Maple 7, and explicitly confirmed both. So I need to construct the JSON with assistant_message repeating the details, explicit_confirmation_observed as true, should_complete as true, and completion with result \"Local confirmation recorded.\" and notes \"No external organization was contacted or booking made.\""
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785497024,
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"object": "chat.completion",
|
||||
"service_tier": "default",
|
||||
"usage": {
|
||||
"completion_tokens": 181,
|
||||
"prompt_tokens": 392,
|
||||
"total_tokens": 573,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 84
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"raw_response_sha256": "d2fb0d5702e0cf7905675047a5bc9ed7330a9d1ac02f6f219f8442ad29794fe8",
|
||||
"response_content": "{\"assistant_message\": \"Confirmed appointment time: Tuesday at 3pm, Confirmation code: Maple 7. Explicit confirmation observed: true\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3pm\", \"confirmation_number\": \"Maple 7\", \"notes\": \"No external organization was contacted or booking made.\"}}",
|
||||
"response_content_sha256": "5cec9a348fdd2a028db79b95024befc152676fd85169a254ca2007888b571897",
|
||||
"external_request_completed": true,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"credential_fields_retained": false
|
||||
}
|
||||
],
|
||||
"asr_receipts": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"operation": "asr",
|
||||
"execution": "real_local_inference",
|
||||
"provider": "local OpenAI Whisper",
|
||||
"model": "whisper-tiny",
|
||||
"checkpoint_name": "tiny.pt",
|
||||
"checkpoint_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
|
||||
"runtime": {
|
||||
"python": "3.11.4",
|
||||
"torch": "2.4.0",
|
||||
"openai_whisper": "20231106"
|
||||
},
|
||||
"input_source": "browser_microphone_rtp",
|
||||
"input_sample_rate_hz": 16000,
|
||||
"input_channels": 1,
|
||||
"input_pcm_bytes": 774368,
|
||||
"input_wav_bytes": 774412,
|
||||
"input_wav_sha256": "bc5072b92d30d382b7439f56b1453946cbaa5f9de80c7a24eb98a1f8615c1d34",
|
||||
"input_duration_seconds": 24.199,
|
||||
"language": "en",
|
||||
"transcript": "Confirmation called as Maple 7. I explicitly confirm Tuesday at 3pm and confirmation code Maple 7.",
|
||||
"transcript_sha256": "e34b0bd2ddd7f005484a59cc83d36b5c4147762c45e1ffb7f1fd2c9f0e8e428e",
|
||||
"model_load_seconds": 0.314312,
|
||||
"inference_seconds": 0.446881,
|
||||
"latency_seconds": 1.789071,
|
||||
"retained_safe_fixture_path": "media/direct/microphone_rtp_asr_input.wav",
|
||||
"external_request": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false
|
||||
}
|
||||
],
|
||||
"tts_receipts": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": "Samantha",
|
||||
"sample_rate_hz": 8000,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": 52080,
|
||||
"duration_seconds": 6.51,
|
||||
"wav_bytes": 104238,
|
||||
"wav_sha256": "c94a2a0b563c9ac55fe472c79b0f94adcb72bdaccd7ecc424f172c35b5af8378",
|
||||
"pcm_sha256": "de63bfbfc726731f84522f16a33357e13dbb34ab6c21596055503d17d039db40",
|
||||
"latency_seconds": 0.972527,
|
||||
"engine": {
|
||||
"name": "say",
|
||||
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
|
||||
},
|
||||
"network_used": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"utterance_id": "tts_01",
|
||||
"purpose": "missing_field_clarification",
|
||||
"retained_safe_fixture_path": "media/direct/agent_01.wav",
|
||||
"text_sha256": "b89b930d8884dec9a8261e6c4952e430f7146bbdcde40423fe8eecede205bec4",
|
||||
"enqueued_on_webrtc_track": true,
|
||||
"transmitted_samples": 52080,
|
||||
"delivery_complete": true,
|
||||
"delivered_pcm_sha256": "de63bfbfc726731f84522f16a33357e13dbb34ab6c21596055503d17d039db40"
|
||||
},
|
||||
{
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": "Samantha",
|
||||
"sample_rate_hz": 8000,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": 66036,
|
||||
"duration_seconds": 8.2545,
|
||||
"wav_bytes": 132150,
|
||||
"wav_sha256": "8dbef003ff03b50e7c29dce5aad788b74500d6b2882c39388a7712cc5c52081d",
|
||||
"pcm_sha256": "a6acdc60071259f46379dc98d4eaec583dbcb0f0b3c5ed60587e8f743a22a5dc",
|
||||
"latency_seconds": 0.97609,
|
||||
"engine": {
|
||||
"name": "say",
|
||||
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
|
||||
},
|
||||
"network_used": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"utterance_id": "tts_02",
|
||||
"purpose": "confirmed_completion",
|
||||
"retained_safe_fixture_path": "media/direct/agent_02.wav",
|
||||
"text_sha256": "7021250895bc4dbad83d3cc06ebe120bcc15175baa9fa6cf5d760fd3b1682254",
|
||||
"enqueued_on_webrtc_track": true,
|
||||
"transmitted_samples": 66036,
|
||||
"delivery_complete": true,
|
||||
"delivered_pcm_sha256": "a6acdc60071259f46379dc98d4eaec583dbcb0f0b3c5ed60587e8f743a22a5dc"
|
||||
}
|
||||
]
|
||||
},
|
||||
"transport": {
|
||||
"kind": "webrtc",
|
||||
"pstn_used": false,
|
||||
"e164_required": false,
|
||||
"sdp_negotiated": true,
|
||||
"offer_sha256": "1bc7eaf0f30f4e506782cfcf80ff1108b6a0a7500e1ee6bbfa3c7faf853c5373",
|
||||
"answer_sha256": "7e7b70627489f0bdd3aa17890a32af0c498fc25c861b5d947d448d57265116b7",
|
||||
"ice_connection_state": "connected",
|
||||
"ice_connected_observed": true,
|
||||
"data_channel_open": true,
|
||||
"local_audio_track": true,
|
||||
"remote_audio_track": true,
|
||||
"rtc_stats": {
|
||||
"inbound_packets": 1869,
|
||||
"inbound_bytes": 188849,
|
||||
"outbound_packets": 1869,
|
||||
"outbound_bytes": 76286
|
||||
},
|
||||
"server_received_audio_frames": 1865,
|
||||
"server_received_audio_samples": 1790400,
|
||||
"server_received_audio_pcm_bytes": 1193568,
|
||||
"server_sent_tts_samples": 118116
|
||||
},
|
||||
"privacy": {
|
||||
"safe_synthetic_acceptance": true,
|
||||
"private_audio_retained": false,
|
||||
"private_transcripts_retained": false,
|
||||
"safe_synthetic_media_retained": true
|
||||
},
|
||||
"event_counts": {
|
||||
"client.audio.commit": 1,
|
||||
"rtc.ready": 6,
|
||||
"rtc.stats": 51
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"speaker": "agent",
|
||||
"text": "Hello Jane Doe. Please state the exact appointment time and confirmation code, then explicitly confirm both.",
|
||||
"source": "tts.webrtc_downlink",
|
||||
"utterance_id": "tts_01",
|
||||
"purpose": "missing_field_clarification"
|
||||
},
|
||||
{
|
||||
"speaker": "user",
|
||||
"text": "Confirmation called as Maple 7. I explicitly confirm Tuesday at 3pm and confirmation code Maple 7.",
|
||||
"source": "asr.microphone_rtp",
|
||||
"asr_receipt_index": 0
|
||||
},
|
||||
{
|
||||
"speaker": "agent",
|
||||
"text": "Confirmed appointment time: Tuesday at 3pm, Confirmation code: Maple 7. Explicit confirmation observed: true",
|
||||
"source": "tts.webrtc_downlink",
|
||||
"utterance_id": "tts_02",
|
||||
"purpose": "confirmed_completion"
|
||||
}
|
||||
],
|
||||
"explicit_confirmation_observed": true,
|
||||
"completion": {
|
||||
"result": "Local confirmation recorded.",
|
||||
"appointment_time": "Tuesday at 3pm",
|
||||
"confirmation_number": "Maple 7",
|
||||
"notes": "No external organization was contacted or booking made.",
|
||||
"saved_at_utc": "2026-07-31T11:23:45.149157+00:00",
|
||||
"tool": "complete_task"
|
||||
},
|
||||
"errors": [],
|
||||
"acceptance": {
|
||||
"checks": {
|
||||
"sdp_offer_answer_negotiated": true,
|
||||
"ice_connected": true,
|
||||
"data_channel_open": true,
|
||||
"browser_microphone_track": true,
|
||||
"server_downlink_audio_track": true,
|
||||
"outbound_audio_rtp": true,
|
||||
"inbound_audio_rtp": true,
|
||||
"server_buffered_microphone_rtp": true,
|
||||
"real_asr_consumed_microphone_audio": true,
|
||||
"external_react_planner_or_fixed_direct_control": true,
|
||||
"real_external_post_asr_dialogue": true,
|
||||
"real_tts_assets_synthesized": true,
|
||||
"tts_audio_transmitted_on_downlink": true,
|
||||
"media_is_canonical_transcript_source": true,
|
||||
"data_channel_is_control_and_caption_only": true,
|
||||
"missing_fields_were_clarified_aloud": true,
|
||||
"explicit_confirmation_observed": true,
|
||||
"structured_completion_saved": true,
|
||||
"no_mock_probe_or_fallback": true,
|
||||
"privacy_boundary_preserved": true
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
"finish_reason": "automated_safe_acceptance"
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"experiment": "phone-agent-webrtc",
|
||||
"run_id": "phone-agent-webrtc-audio-20260731-v1",
|
||||
"generated_at_utc": "2026-07-31T11:24:42.523874+00:00",
|
||||
"result": "passed",
|
||||
"execution": "live_browser_aiortc_asr_external_llm_tts_webrtc",
|
||||
"canonical_safe_synthetic_fixture": true,
|
||||
"pstn_used": false,
|
||||
"e164_required": false,
|
||||
"credentials_saved": false,
|
||||
"private_audio_or_transcripts_saved": false,
|
||||
"safe_fixture_provenance": {
|
||||
"direct": {
|
||||
"kind": "safe_synthetic_browser_microphone_fixture",
|
||||
"contains_private_data": false,
|
||||
"sample_rate_hz": 16000,
|
||||
"duration_seconds": 15.730625,
|
||||
"wav_bytes": 503424,
|
||||
"wav_sha256": "0def429a125a2cc14f0461c9361a138b88d9cc0103040a9f018ef8fea2f2d46a",
|
||||
"leading_silence_seconds": 4.0,
|
||||
"trailing_silence_seconds": 3.0,
|
||||
"synthesis": {
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": "Samantha",
|
||||
"sample_rate_hz": 16000,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": 139690,
|
||||
"duration_seconds": 8.730625,
|
||||
"wav_bytes": 279458,
|
||||
"wav_sha256": "795fc97e299ecb3bed310e3bde26f1500bf08b9da32d6a276ecadb9dd29b5103",
|
||||
"pcm_sha256": "74690f7b0ee36186bd8e6cf378c40644219f71c96b8f199185ce5e136bf99d63",
|
||||
"latency_seconds": 0.962707,
|
||||
"engine": {
|
||||
"name": "say",
|
||||
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
|
||||
},
|
||||
"network_used": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false
|
||||
},
|
||||
"artifact_path": "fixtures/direct_microphone.wav",
|
||||
"text_sha256": "b1c6b73a17ff9a5afcee66cfc2944dd4b8e9d3019d362a1907eeeb4ba0a0705d"
|
||||
},
|
||||
"react": {
|
||||
"kind": "safe_synthetic_browser_microphone_fixture",
|
||||
"contains_private_data": false,
|
||||
"sample_rate_hz": 16000,
|
||||
"duration_seconds": 15.440375,
|
||||
"wav_bytes": 494136,
|
||||
"wav_sha256": "df1acb0b3db90e2fed6814cb880dabb028cd73d0a1837efc8493a97071f0e7f9",
|
||||
"leading_silence_seconds": 4.0,
|
||||
"trailing_silence_seconds": 3.0,
|
||||
"synthesis": {
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": "Samantha",
|
||||
"sample_rate_hz": 16000,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": 135046,
|
||||
"duration_seconds": 8.440375,
|
||||
"wav_bytes": 270170,
|
||||
"wav_sha256": "c23d3bb0b6f14be7f690ee1a4499b1a84d4edf0b3d3c3cadad5212b0e70a8552",
|
||||
"pcm_sha256": "fa9f85699e9d3016eae17acd751dbefce05e3eddf980a842d4d638fe9a5d0928",
|
||||
"latency_seconds": 0.956989,
|
||||
"engine": {
|
||||
"name": "say",
|
||||
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
|
||||
},
|
||||
"network_used": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false
|
||||
},
|
||||
"artifact_path": "fixtures/react_microphone.wav",
|
||||
"text_sha256": "001812c3c3bf8a0372be1399852e155a940d8c57fc04ccbc9fe7ac30a86cc725"
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
"platform": "macOS-26.3-arm64-arm-64bit",
|
||||
"python": "3.11.4",
|
||||
"chrome_version": "Google Chrome 150.0.7871.188",
|
||||
"chrome_executable_sha256": "2837ae0a82f64cf541af357ce554cc7d0c62d119da2b6b5f3eb64ce7f3d63d4e",
|
||||
"planner_provider": "ark",
|
||||
"planner_model": "doubao-seed-1-6-flash-250615",
|
||||
"dialogue_model": "doubao-seed-1-6-flash-250615",
|
||||
"media_peer": "aiortc",
|
||||
"packages": {
|
||||
"aiortc": "1.15.0",
|
||||
"av": "17.1.0",
|
||||
"openai": "2.48.0",
|
||||
"playwright": "1.61.0"
|
||||
}
|
||||
},
|
||||
"source_sha256": {
|
||||
"chapter6/phone-agent/agent.py": "fa73bfe9182f39d22243065edc02565da33b5957c6f17a9f886fc65ea42561f3",
|
||||
"chapter6/phone-agent/speech.py": "ca959ce60eabd6b629a6a238ceef1b8c6bd955f6921cfc8a273a5c86bb00e008",
|
||||
"chapter6/phone-agent/webrtc_app.py": "260ab64d9ba61a1dcf00cb4f034bcfd115755c11ce0558dc8b610f239f9ed25b",
|
||||
"chapter6/phone-agent/run_acceptance.py": "c33346520fba6a8d8b6385ff0e3052b3c51be7d1ff2fd4f5fbbbf831a02ba012",
|
||||
"chapter6/phone-agent/verify_acceptance.py": "83b459e48f337e4457237b629e4f308694fa491893cb41826128df6f6bab02a7",
|
||||
"chapter6/phone-agent/demo.py": "45ef0878b524ae39faf85693b4ea302746ff2e9ee80ce9aced104e2dbfa67e18",
|
||||
"chapter6/phone-agent/direct_call.py": "74c96ba2811aa81c9d7fabad9f7177df84a6544300ab26513e3a39f8fccf7f59",
|
||||
"chapter6/phone-agent/env.example": "ae6ec336e02041e10f10df39846b63aff1a8a125610e7a2069e3ea4d1e193182",
|
||||
"chapter6/phone-agent/requirements.txt": "451102c65e1b3084096aa59e10816a7a74be20c32d6f3ceb426a73c1c2c6788d",
|
||||
"chapter6/phone-agent/test_agent.py": "8f77744dc6c38e0617b479aff2941bc1b3dcfa78cfac93ccba47702ae8022ad4",
|
||||
"chapter6/phone-agent/test_speech.py": "03a64e7c1f8182f94364c109bf1a640f1609f8cdae1ba3d0f34ccfd41900512c",
|
||||
"chapter6/phone-agent/test_webrtc_app.py": "cf5c2f9f14a65a8fcbd643657d48c9c1d315229609094e30a7642315a1079227",
|
||||
"chapter6/phone-agent/test_verify_acceptance.py": "b2082309ed3409875dac310147894fea50bba9b3909928e2de21b37ed9390912",
|
||||
"chapter6/phone-agent/static/index.html": "fc6020510e90eed59d517139b4cc291bb6aa72be4c16d05714ca134cf3a821f8",
|
||||
"chapter6/phone-agent/static/app.js": "dc1ba08f21bccafc43a5dcc79e57af2dca2bc350fb0021b7e458ce5654e6d04d",
|
||||
"chapter6/phone-agent/static/style.css": "1586e93230ec1a77a5141e05f72c560e193cebbfe43115e26ba81ebd06f00908",
|
||||
"chapter6/phone-agent/README.md": "ad6e93082358a1694b8dc3d73af82feed8d1948efb3a96fde115ac1b1a8e9358",
|
||||
"chapter6/README.md": "84803cef75fc522d21ee9086ce9d8471b1eec99394513a232d420130cfeb580f",
|
||||
"book/chapter6.md": "8e1859fb1b163c3620dac7da4d7560f5d0dce06613f025aa90140e31955d5d73",
|
||||
"pyproject.toml": "b04778d69977f0843def0c26930ab6c5f32706746733495e95647e83c19c2a1b",
|
||||
"uv.lock": "d25d18bb7c0a5d93c888547f19a420dc50719357a3dba813094a6c233140691c"
|
||||
},
|
||||
"artifact_sha256": {
|
||||
"comparison.json": "5b73934cbc776c01cc3aac5d29dbd7026d454a7d7b9d7654c9834339a4bc5a49",
|
||||
"direct.json": "360c613574692e9d01d60e6aefb3faac34a1cf38dc9a14f1bd3b354055db2912",
|
||||
"fixtures/direct_microphone.wav": "0def429a125a2cc14f0461c9361a138b88d9cc0103040a9f018ef8fea2f2d46a",
|
||||
"fixtures/react_microphone.wav": "df1acb0b3db90e2fed6814cb880dabb028cd73d0a1837efc8493a97071f0e7f9",
|
||||
"media/direct/agent_01.wav": "c94a2a0b563c9ac55fe472c79b0f94adcb72bdaccd7ecc424f172c35b5af8378",
|
||||
"media/direct/agent_02.wav": "8dbef003ff03b50e7c29dce5aad788b74500d6b2882c39388a7712cc5c52081d",
|
||||
"media/direct/microphone_rtp_asr_input.wav": "bc5072b92d30d382b7439f56b1453946cbaa5f9de80c7a24eb98a1f8615c1d34",
|
||||
"media/react/agent_01.wav": "36569fa63273d1ed2e7ff1eec9e3be7fc5e878e935bd58e7ea25bc5a6011b473",
|
||||
"media/react/agent_02.wav": "e8a227f303baa45cdcdaa20700c58f2405adf06725394eafb9364b12079ea936",
|
||||
"media/react/microphone_rtp_asr_input.wav": "d13d43693d8af1b579ff7a5f458f7bf0940fbf0daec9ddebc9f279ed1317d73a",
|
||||
"react.json": "5889cf6582b04ea93b81a46a474fc6fbd4e8e8b4590f100c1bd056be91e3907f",
|
||||
"server.log": "1b6cafe4291015cda74a79e49ef5fcad2c485fc7a4882312b183eefd97bbc85e"
|
||||
},
|
||||
"redaction": {
|
||||
"scanned_file_count": 12,
|
||||
"environment_credential_value_matches": [],
|
||||
"passed": true
|
||||
},
|
||||
"cleanup": {
|
||||
"browser_contexts_closed": true,
|
||||
"server_process_terminated": true,
|
||||
"raw_private_media_created": false
|
||||
},
|
||||
"acceptance": {
|
||||
"direct": {
|
||||
"checks": {
|
||||
"sdp_offer_answer_negotiated": true,
|
||||
"ice_connected": true,
|
||||
"data_channel_open": true,
|
||||
"browser_microphone_track": true,
|
||||
"server_downlink_audio_track": true,
|
||||
"outbound_audio_rtp": true,
|
||||
"inbound_audio_rtp": true,
|
||||
"server_buffered_microphone_rtp": true,
|
||||
"real_asr_consumed_microphone_audio": true,
|
||||
"external_react_planner_or_fixed_direct_control": true,
|
||||
"real_external_post_asr_dialogue": true,
|
||||
"real_tts_assets_synthesized": true,
|
||||
"tts_audio_transmitted_on_downlink": true,
|
||||
"media_is_canonical_transcript_source": true,
|
||||
"data_channel_is_control_and_caption_only": true,
|
||||
"missing_fields_were_clarified_aloud": true,
|
||||
"explicit_confirmation_observed": true,
|
||||
"structured_completion_saved": true,
|
||||
"no_mock_probe_or_fallback": true,
|
||||
"privacy_boundary_preserved": true
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
"react": {
|
||||
"checks": {
|
||||
"sdp_offer_answer_negotiated": true,
|
||||
"ice_connected": true,
|
||||
"data_channel_open": true,
|
||||
"browser_microphone_track": true,
|
||||
"server_downlink_audio_track": true,
|
||||
"outbound_audio_rtp": true,
|
||||
"inbound_audio_rtp": true,
|
||||
"server_buffered_microphone_rtp": true,
|
||||
"real_asr_consumed_microphone_audio": true,
|
||||
"external_react_planner_or_fixed_direct_control": true,
|
||||
"real_external_post_asr_dialogue": true,
|
||||
"real_tts_assets_synthesized": true,
|
||||
"tts_audio_transmitted_on_downlink": true,
|
||||
"media_is_canonical_transcript_source": true,
|
||||
"data_channel_is_control_and_caption_only": true,
|
||||
"missing_fields_were_clarified_aloud": true,
|
||||
"explicit_confirmation_observed": true,
|
||||
"structured_completion_saved": true,
|
||||
"no_mock_probe_or_fallback": true,
|
||||
"privacy_boundary_preserved": true
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
"comparison_passed": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"experiment": "phone-agent-webrtc",
|
||||
"call_id": "rtc_f8c984610b434ce4abf7",
|
||||
"created_at_utc": "2026-07-31T11:24:00.115961+00:00",
|
||||
"finished_at_utc": "2026-07-31T11:24:40.071438+00:00",
|
||||
"status": "completed",
|
||||
"mode": "react",
|
||||
"input_contract": {
|
||||
"fields_supplied_by_caller": [
|
||||
"task"
|
||||
],
|
||||
"natural_language_task": "Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm."
|
||||
},
|
||||
"plan": {
|
||||
"mode": "react",
|
||||
"callee_name": "Jane Doe",
|
||||
"goal": "Arrange a dental checkup",
|
||||
"context": "User forgot to include exact appointment time and confirmation code",
|
||||
"instructions": "Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values",
|
||||
"opening_line": "Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?",
|
||||
"missing_information": [
|
||||
"appointment time",
|
||||
"confirmation code"
|
||||
],
|
||||
"trace": [
|
||||
{
|
||||
"stage": "observation",
|
||||
"summary": "Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm."
|
||||
},
|
||||
{
|
||||
"stage": "reason",
|
||||
"summary": "Requesting the user to explicitly confirm the dental checkup time and confirmation code as they were previously omitted"
|
||||
},
|
||||
{
|
||||
"stage": "action",
|
||||
"summary": "Open a WebRTC call and collect the missing facts by voice."
|
||||
}
|
||||
],
|
||||
"planner_model": "ark:doubao-seed-1-6-flash-250615"
|
||||
},
|
||||
"models": {
|
||||
"planner": "ark:doubao-seed-1-6-flash-250615",
|
||||
"dialogue_models": [
|
||||
"ark:doubao-seed-1-6-flash-250615"
|
||||
],
|
||||
"llm_receipts": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"purpose": "react_planning",
|
||||
"execution": "real_external_llm",
|
||||
"provider": "ark",
|
||||
"requested_model": "doubao-seed-1-6-flash-250615",
|
||||
"provider_model": "doubao-seed-1-6-flash-250615",
|
||||
"provider_response_id": "021785497038047251fb4d9d2b64b7e0912e9d9de0a598cecf2cc",
|
||||
"finish_reason": "stop",
|
||||
"usage": {
|
||||
"completion_tokens": 262,
|
||||
"prompt_tokens": 222,
|
||||
"total_tokens": 484,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 113
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
},
|
||||
"started_at_utc": "2026-07-31T11:23:57.805430+00:00",
|
||||
"finished_at_utc": "2026-07-31T11:24:00.114900+00:00",
|
||||
"latency_seconds": 2.309437,
|
||||
"request": {
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Plan a local browser WebRTC voice call. Observe the user's task, identify every missing task-critical fact, reason briefly about what must be collected, and choose the call action. Never invent facts. Return only JSON with callee_name, goal, context, instructions, opening_line, missing_information (array), and decision_summary. opening_line must ask aloud for the missing appointment time and confirmation code. instructions must require the voice Agent to repeat the facts, obtain explicit confirmation, and complete_task only with confirmed values. This local experiment records a confirmation but performs no external booking."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Call me to arrange a dental checkup for Jane Doe. I forgot to include the exact time and confirmation code, so identify both as missing, ask me for them by voice, and save only what I explicitly confirm."
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_object"
|
||||
},
|
||||
"temperature": 0,
|
||||
"max_tokens": 700
|
||||
},
|
||||
"request_sha256": "276ca473bec3303ba5356765d1fd4b5e3c8c9e82e1960e4b9b19ad726b8e8c90",
|
||||
"raw_response": {
|
||||
"id": "021785497038047251fb4d9d2b64b7e0912e9d9de0a598cecf2cc",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "{\n \"callee_name\": \"Jane Doe\",\n \"goal\": \"Arrange a dental checkup\",\n \"context\": \"User forgot to include exact appointment time and confirmation code\",\n \"instructions\": \"Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values\",\n \"opening_line\": \"Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?\",\n \"missing_information\": [\"appointment time\", \"confirmation code\"],\n \"decision_summary\": \"Requesting the user to explicitly confirm the dental checkup time and confirmation code as they were previously omitted\"\n}",
|
||||
"role": "assistant",
|
||||
"reasoning_content": "So, let's see. I need to structure the JSON according to the user's request. First, callee_name is Jane Doe. The goal is to arrange a dental checkup. Context is that the user forgot the exact time and confirmation code, so those are missing. Instructions should tell the voice Agent to repeat the facts, get explicit confirmation. Opening line needs to ask aloud for the missing appointment time and confirmation code. Missing_information should list time and confirmation code. Decision_summary is about requesting the user to provide the time and confirmation code."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785497040,
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"object": "chat.completion",
|
||||
"service_tier": "default",
|
||||
"usage": {
|
||||
"completion_tokens": 262,
|
||||
"prompt_tokens": 222,
|
||||
"total_tokens": 484,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 113
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"raw_response_sha256": "2b02efd3ca007ca45bec6d2be83978d630a064d5f23ac15e0a1058979770760d",
|
||||
"response_content": "{\n \"callee_name\": \"Jane Doe\",\n \"goal\": \"Arrange a dental checkup\",\n \"context\": \"User forgot to include exact appointment time and confirmation code\",\n \"instructions\": \"Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values\",\n \"opening_line\": \"Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?\",\n \"missing_information\": [\"appointment time\", \"confirmation code\"],\n \"decision_summary\": \"Requesting the user to explicitly confirm the dental checkup time and confirmation code as they were previously omitted\"\n}",
|
||||
"response_content_sha256": "13c94bdc67acc4f47d52ad737b445cb82598d02f9036e0afe14f57f9030dcb96",
|
||||
"external_request_completed": true,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"credential_fields_retained": false
|
||||
},
|
||||
{
|
||||
"schema_version": 1,
|
||||
"purpose": "post_asr_dialogue",
|
||||
"execution": "real_external_llm",
|
||||
"provider": "ark",
|
||||
"requested_model": "doubao-seed-1-6-flash-250615",
|
||||
"provider_model": "doubao-seed-1-6-flash-250615",
|
||||
"provider_response_id": "021785497071624d9018183c34bd269dc45b9a10b892d56f50515",
|
||||
"finish_reason": "stop",
|
||||
"usage": {
|
||||
"completion_tokens": 168,
|
||||
"prompt_tokens": 366,
|
||||
"total_tokens": 534,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 75
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
},
|
||||
"started_at_utc": "2026-07-31T11:24:31.373004+00:00",
|
||||
"finished_at_utc": "2026-07-31T11:24:33.913565+00:00",
|
||||
"latency_seconds": 2.54053,
|
||||
"request": {
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are the voice Agent in a short local browser call. The user text below came only from ASR over the browser microphone RTP track. Return only JSON with assistant_message, explicit_confirmation_observed (boolean), should_complete (boolean), and completion containing result, appointment_time, confirmation_number, notes. If the user states an exact time, a confirmation code, and explicitly confirms both, set should_complete=true, normalize obvious spoken code words/digits into a concise code, and repeat both details in assistant_message. Otherwise ask only for what is missing. Never say booked, arranged, scheduled, or imply an external action occurred. For a completed turn, completion.result must be exactly 'Local confirmation recorded.' and completion.notes must be exactly 'No external organization was contacted or booking made.' Goal: Arrange a dental checkup\nContext: User forgot to include exact appointment time and confirmation code\nInstructions: Repeat the missing appointment time and confirmation code, obtain explicit confirmation from the user, and only complete the task with the confirmed values"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{\"latest_user_asr_transcript\":\"Confirmation called a Cedar 8. I explicitly confirm Tuesday at 3 p.m. and confirmation called Cedar 8.\",\"prior_audio_transcript\":[{\"purpose\":\"missing_field_clarification\",\"source\":\"tts.webrtc_downlink\",\"speaker\":\"agent\",\"text\":\"Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?\",\"utterance_id\":\"tts_01\"}]}"
|
||||
}
|
||||
],
|
||||
"response_format": {
|
||||
"type": "json_object"
|
||||
},
|
||||
"temperature": 0,
|
||||
"max_tokens": 700
|
||||
},
|
||||
"request_sha256": "926acff71159d1659a957a45a8244a1f2d6eeefbf78987a155bd0e8aa6660378",
|
||||
"raw_response": {
|
||||
"id": "021785497071624d9018183c34bd269dc45b9a10b892d56f50515",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"message": {
|
||||
"content": "{\"assistant_message\": \"Appointment time confirmed as Tuesday at 3 p.m. and confirmation number confirmed as Cedar 8.\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3 p.m.\", \"confirmation_number\": \"Cedar 8\", \"notes\": \"No external organization was contacted or booking made.\"}}",
|
||||
"role": "assistant",
|
||||
"reasoning_content": "Got it, let's see. The user mentioned Tuesday at 3 p.m. as the appointment time and confirmed the confirmation code as Cedar 8. So now I need to structure the JSON with assistant_message repeating the details, explicit_confirmation_observed as true, should_complete as true, and completion with result, appointment_time, confirmation_number, notes."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785497074,
|
||||
"model": "doubao-seed-1-6-flash-250615",
|
||||
"object": "chat.completion",
|
||||
"service_tier": "default",
|
||||
"usage": {
|
||||
"completion_tokens": 168,
|
||||
"prompt_tokens": 366,
|
||||
"total_tokens": 534,
|
||||
"completion_tokens_details": {
|
||||
"reasoning_tokens": 75
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"raw_response_sha256": "26440ed69dcb1f77178a284fdd0571e6f90cca9c776402bcf4e8ffcb56bfa994",
|
||||
"response_content": "{\"assistant_message\": \"Appointment time confirmed as Tuesday at 3 p.m. and confirmation number confirmed as Cedar 8.\", \"explicit_confirmation_observed\": true, \"should_complete\": true, \"completion\": {\"result\": \"Local confirmation recorded.\", \"appointment_time\": \"Tuesday at 3 p.m.\", \"confirmation_number\": \"Cedar 8\", \"notes\": \"No external organization was contacted or booking made.\"}}",
|
||||
"response_content_sha256": "589e901f03635305e84094cd8a1e6619643fb554bf6ce175550feaf22b0246c3",
|
||||
"external_request_completed": true,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"credential_fields_retained": false
|
||||
}
|
||||
],
|
||||
"asr_receipts": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"operation": "asr",
|
||||
"execution": "real_local_inference",
|
||||
"provider": "local OpenAI Whisper",
|
||||
"model": "whisper-tiny",
|
||||
"checkpoint_name": "tiny.pt",
|
||||
"checkpoint_sha256": "65147644a518d12f04e32d6f3b26facc3f8dd46e5390956a9424a650c0ce22b9",
|
||||
"runtime": {
|
||||
"python": "3.11.4",
|
||||
"torch": "2.4.0",
|
||||
"openai_whisper": "20231106"
|
||||
},
|
||||
"input_source": "browser_microphone_rtp",
|
||||
"input_sample_rate_hz": 16000,
|
||||
"input_channels": 1,
|
||||
"input_pcm_bytes": 733408,
|
||||
"input_wav_bytes": 733452,
|
||||
"input_wav_sha256": "d13d43693d8af1b579ff7a5f458f7bf0940fbf0daec9ddebc9f279ed1317d73a",
|
||||
"input_duration_seconds": 22.919,
|
||||
"language": "en",
|
||||
"transcript": "Confirmation called a Cedar 8. I explicitly confirm Tuesday at 3 p.m. and confirmation called Cedar 8.",
|
||||
"transcript_sha256": "cabe012d1165ad25d56167aa5b898fa029facf739a658df6d8580ddfaa6715d5",
|
||||
"model_load_seconds": 0.314773,
|
||||
"inference_seconds": 0.511034,
|
||||
"latency_seconds": 1.865804,
|
||||
"retained_safe_fixture_path": "media/react/microphone_rtp_asr_input.wav",
|
||||
"external_request": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false
|
||||
}
|
||||
],
|
||||
"tts_receipts": [
|
||||
{
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": "Samantha",
|
||||
"sample_rate_hz": 8000,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": 43638,
|
||||
"duration_seconds": 5.45475,
|
||||
"wav_bytes": 87354,
|
||||
"wav_sha256": "36569fa63273d1ed2e7ff1eec9e3be7fc5e878e935bd58e7ea25bc5a6011b473",
|
||||
"pcm_sha256": "cc573183930ba8f3640ff06dfa7462d5575d30a01685b3fc2d1f6591f1aace06",
|
||||
"latency_seconds": 0.959548,
|
||||
"engine": {
|
||||
"name": "say",
|
||||
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
|
||||
},
|
||||
"network_used": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"utterance_id": "tts_01",
|
||||
"purpose": "missing_field_clarification",
|
||||
"retained_safe_fixture_path": "media/react/agent_01.wav",
|
||||
"text_sha256": "8210e199a83fcf3de8179059a226e38ad88463493c974beb40460683b21fb785",
|
||||
"enqueued_on_webrtc_track": true,
|
||||
"transmitted_samples": 43638,
|
||||
"delivery_complete": true,
|
||||
"delivered_pcm_sha256": "cc573183930ba8f3640ff06dfa7462d5575d30a01685b3fc2d1f6591f1aace06"
|
||||
},
|
||||
{
|
||||
"schema_version": 1,
|
||||
"operation": "tts",
|
||||
"execution": "real_speech_synthesis",
|
||||
"provider": "macOS say",
|
||||
"model": "operating-system speech synthesizer",
|
||||
"voice": "Samantha",
|
||||
"sample_rate_hz": 8000,
|
||||
"channels": 1,
|
||||
"sample_width_bytes": 2,
|
||||
"sample_count": 41491,
|
||||
"duration_seconds": 5.186375,
|
||||
"wav_bytes": 83060,
|
||||
"wav_sha256": "e8a227f303baa45cdcdaa20700c58f2405adf06725394eafb9364b12079ea936",
|
||||
"pcm_sha256": "1686b19c8c3c73b3629c7aeac74029bc70d59d1f326ad47524b81bee3794ac28",
|
||||
"latency_seconds": 0.954852,
|
||||
"engine": {
|
||||
"name": "say",
|
||||
"sha256": "2bf4b7a6950c8365f3b51c1345d82b40595f4581c9bd50700e1ea485851e022d"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "ffmpeg",
|
||||
"sha256": "5a4899a648370eccc8e73af934a8724d7af9195018eed29125432c782548f00a"
|
||||
},
|
||||
"network_used": false,
|
||||
"mock": false,
|
||||
"probe_only": false,
|
||||
"fallback_used": false,
|
||||
"utterance_id": "tts_02",
|
||||
"purpose": "confirmed_completion",
|
||||
"retained_safe_fixture_path": "media/react/agent_02.wav",
|
||||
"text_sha256": "5ba502a0adcb8198267792ac32cb50f475009bb1a0cfff839ac2c94f41df8cca",
|
||||
"enqueued_on_webrtc_track": true,
|
||||
"transmitted_samples": 41491,
|
||||
"delivery_complete": true,
|
||||
"delivered_pcm_sha256": "1686b19c8c3c73b3629c7aeac74029bc70d59d1f326ad47524b81bee3794ac28"
|
||||
}
|
||||
]
|
||||
},
|
||||
"transport": {
|
||||
"kind": "webrtc",
|
||||
"pstn_used": false,
|
||||
"e164_required": false,
|
||||
"sdp_negotiated": true,
|
||||
"offer_sha256": "a08ac0b87d92b1e92cb28fbb1b23d71a845dec2b6fcddd3b916ccd00c778ad69",
|
||||
"answer_sha256": "94bef0859b07a728240bf7699c0c9982744e1145783c3522c31a304a613d013c",
|
||||
"ice_connection_state": "connected",
|
||||
"ice_connected_observed": true,
|
||||
"data_channel_open": true,
|
||||
"local_audio_track": true,
|
||||
"remote_audio_track": true,
|
||||
"rtc_stats": {
|
||||
"inbound_packets": 1679,
|
||||
"inbound_bytes": 147402,
|
||||
"outbound_packets": 1679,
|
||||
"outbound_bytes": 69386
|
||||
},
|
||||
"server_received_audio_frames": 1675,
|
||||
"server_received_audio_samples": 1608000,
|
||||
"server_received_audio_pcm_bytes": 1071968,
|
||||
"server_sent_tts_samples": 85129
|
||||
},
|
||||
"privacy": {
|
||||
"safe_synthetic_acceptance": true,
|
||||
"private_audio_retained": false,
|
||||
"private_transcripts_retained": false,
|
||||
"safe_synthetic_media_retained": true
|
||||
},
|
||||
"event_counts": {
|
||||
"client.audio.commit": 1,
|
||||
"rtc.ready": 6,
|
||||
"rtc.stats": 46
|
||||
},
|
||||
"transcript": [
|
||||
{
|
||||
"speaker": "agent",
|
||||
"text": "Could you please provide the exact appointment time for Jane Doe's dental checkup and the confirmation code?",
|
||||
"source": "tts.webrtc_downlink",
|
||||
"utterance_id": "tts_01",
|
||||
"purpose": "missing_field_clarification"
|
||||
},
|
||||
{
|
||||
"speaker": "user",
|
||||
"text": "Confirmation called a Cedar 8. I explicitly confirm Tuesday at 3 p.m. and confirmation called Cedar 8.",
|
||||
"source": "asr.microphone_rtp",
|
||||
"asr_receipt_index": 0
|
||||
},
|
||||
{
|
||||
"speaker": "agent",
|
||||
"text": "Appointment time confirmed as Tuesday at 3 p.m. and confirmation number confirmed as Cedar 8.",
|
||||
"source": "tts.webrtc_downlink",
|
||||
"utterance_id": "tts_02",
|
||||
"purpose": "confirmed_completion"
|
||||
}
|
||||
],
|
||||
"explicit_confirmation_observed": true,
|
||||
"completion": {
|
||||
"result": "Local confirmation recorded.",
|
||||
"appointment_time": "Tuesday at 3 p.m.",
|
||||
"confirmation_number": "Cedar 8",
|
||||
"notes": "No external organization was contacted or booking made.",
|
||||
"saved_at_utc": "2026-07-31T11:24:34.870657+00:00",
|
||||
"tool": "complete_task"
|
||||
},
|
||||
"errors": [],
|
||||
"acceptance": {
|
||||
"checks": {
|
||||
"sdp_offer_answer_negotiated": true,
|
||||
"ice_connected": true,
|
||||
"data_channel_open": true,
|
||||
"browser_microphone_track": true,
|
||||
"server_downlink_audio_track": true,
|
||||
"outbound_audio_rtp": true,
|
||||
"inbound_audio_rtp": true,
|
||||
"server_buffered_microphone_rtp": true,
|
||||
"real_asr_consumed_microphone_audio": true,
|
||||
"external_react_planner_or_fixed_direct_control": true,
|
||||
"real_external_post_asr_dialogue": true,
|
||||
"real_tts_assets_synthesized": true,
|
||||
"tts_audio_transmitted_on_downlink": true,
|
||||
"media_is_canonical_transcript_source": true,
|
||||
"data_channel_is_control_and_caption_only": true,
|
||||
"missing_fields_were_clarified_aloud": true,
|
||||
"explicit_confirmation_observed": true,
|
||||
"structured_completion_saved": true,
|
||||
"no_mock_probe_or_fallback": true,
|
||||
"privacy_boundary_preserved": true
|
||||
},
|
||||
"passed": true
|
||||
},
|
||||
"finish_reason": "automated_safe_acceptance"
|
||||
}
|
||||
Executable
+378
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Standalone fail-closed verification for Phone Agent add-on retained evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
REQUIRED_ARTIFACTS = {
|
||||
"direct.json",
|
||||
"react.json",
|
||||
"comparison.json",
|
||||
"server.log",
|
||||
"fixtures/direct_microphone.wav",
|
||||
"fixtures/react_microphone.wav",
|
||||
"media/direct/agent_01.wav",
|
||||
"media/direct/agent_02.wav",
|
||||
"media/direct/microphone_rtp_asr_input.wav",
|
||||
"media/react/agent_01.wav",
|
||||
"media/react/agent_02.wav",
|
||||
"media/react/microphone_rtp_asr_input.wav",
|
||||
}
|
||||
REQUIRED_SOURCES = {
|
||||
"book/chapter9.md",
|
||||
"chapter6/README.md",
|
||||
"chapter6/phone-agent/README.md",
|
||||
"chapter6/phone-agent/agent.py",
|
||||
"chapter6/phone-agent/demo.py",
|
||||
"chapter6/phone-agent/direct_call.py",
|
||||
"chapter6/phone-agent/env.example",
|
||||
"chapter6/phone-agent/requirements.txt",
|
||||
"chapter6/phone-agent/run_acceptance.py",
|
||||
"chapter6/phone-agent/speech.py",
|
||||
"chapter6/phone-agent/static/app.js",
|
||||
"chapter6/phone-agent/static/index.html",
|
||||
"chapter6/phone-agent/static/style.css",
|
||||
"chapter6/phone-agent/test_agent.py",
|
||||
"chapter6/phone-agent/test_speech.py",
|
||||
"chapter6/phone-agent/test_verify_acceptance.py",
|
||||
"chapter6/phone-agent/test_webrtc_app.py",
|
||||
"chapter6/phone-agent/verify_acceptance.py",
|
||||
"chapter6/phone-agent/webrtc_app.py",
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
}
|
||||
REQUIRED_ARM_CHECKS = {
|
||||
"sdp_offer_answer_negotiated",
|
||||
"ice_connected",
|
||||
"data_channel_open",
|
||||
"browser_microphone_track",
|
||||
"server_downlink_audio_track",
|
||||
"outbound_audio_rtp",
|
||||
"inbound_audio_rtp",
|
||||
"server_buffered_microphone_rtp",
|
||||
"real_asr_consumed_microphone_audio",
|
||||
"external_react_planner_or_fixed_direct_control",
|
||||
"real_external_post_asr_dialogue",
|
||||
"real_tts_assets_synthesized",
|
||||
"tts_audio_transmitted_on_downlink",
|
||||
"media_is_canonical_transcript_source",
|
||||
"data_channel_is_control_and_caption_only",
|
||||
"missing_fields_were_clarified_aloud",
|
||||
"explicit_confirmation_observed",
|
||||
"structured_completion_saved",
|
||||
"no_mock_probe_or_fallback",
|
||||
"privacy_boundary_preserved",
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def sha256_json(value: Any) -> str:
|
||||
canonical = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"{path.name} is not a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def valid_hash(value: Any) -> bool:
|
||||
return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None
|
||||
|
||||
|
||||
def check_llm_receipt(receipt: dict[str, Any], prefix: str, failures: list[str]) -> None:
|
||||
raw = receipt.get("raw_response") or {}
|
||||
request = receipt.get("request") or {}
|
||||
choices = raw.get("choices") or []
|
||||
usage = receipt.get("usage") or {}
|
||||
raw_usage = raw.get("usage") or {}
|
||||
content = receipt.get("response_content")
|
||||
if (
|
||||
receipt.get("execution") != "real_external_llm"
|
||||
or receipt.get("external_request_completed") is not True
|
||||
):
|
||||
failures.append(prefix + "LLM execution is not a completed external request")
|
||||
if (
|
||||
receipt.get("mock") is not False
|
||||
or receipt.get("probe_only") is not False
|
||||
or receipt.get("fallback_used") is not False
|
||||
):
|
||||
failures.append(prefix + "LLM receipt permits mock/probe/fallback")
|
||||
if receipt.get("credential_fields_retained") is not False:
|
||||
failures.append(prefix + "LLM receipt does not assert credential-free retention")
|
||||
if not receipt.get("provider_response_id") or receipt.get("provider_response_id") != raw.get(
|
||||
"id"
|
||||
):
|
||||
failures.append(prefix + "provider response ID is absent or differs from raw response")
|
||||
if not receipt.get("provider_model") or receipt.get("provider_model") != raw.get("model"):
|
||||
failures.append(prefix + "provider model is absent or differs from raw response")
|
||||
if not choices or receipt.get("finish_reason") != choices[0].get("finish_reason"):
|
||||
failures.append(prefix + "finish status is absent or differs from raw response")
|
||||
raw_content = (choices[0].get("message") or {}).get("content") if choices else None
|
||||
if not content or content != raw_content:
|
||||
failures.append(prefix + "retained response content differs from raw response")
|
||||
if not usage or int(usage.get("total_tokens", 0)) <= 0 or usage != raw_usage:
|
||||
failures.append(prefix + "usage is absent or differs from raw response")
|
||||
if float(receipt.get("latency_seconds", 0)) <= 0:
|
||||
failures.append(prefix + "LLM latency is not positive")
|
||||
if receipt.get("request_sha256") != sha256_json(request):
|
||||
failures.append(prefix + "LLM request hash mismatch")
|
||||
if receipt.get("raw_response_sha256") != sha256_json(raw):
|
||||
failures.append(prefix + "LLM raw response hash mismatch")
|
||||
if receipt.get("response_content_sha256") != hashlib.sha256(str(content).encode()).hexdigest():
|
||||
failures.append(prefix + "LLM response content hash mismatch")
|
||||
if request.get("model") != receipt.get("requested_model") or not request.get("messages"):
|
||||
failures.append(prefix + "raw request/model is incomplete")
|
||||
|
||||
|
||||
def check_arm(name: str, record: dict[str, Any], run_dir: Path, failures: list[str]) -> None:
|
||||
prefix = f"{name}: "
|
||||
transport = record.get("transport") or {}
|
||||
stats = transport.get("rtc_stats") or {}
|
||||
models = record.get("models") or {}
|
||||
llm_receipts = models.get("llm_receipts") or []
|
||||
asr_receipts = models.get("asr_receipts") or []
|
||||
tts_receipts = models.get("tts_receipts") or []
|
||||
transcript = record.get("transcript") or []
|
||||
completion = record.get("completion") or {}
|
||||
acceptance = record.get("acceptance") or {}
|
||||
checks = acceptance.get("checks") or {}
|
||||
|
||||
if (
|
||||
record.get("experiment") != "9-2"
|
||||
or record.get("mode") != name
|
||||
or record.get("status") != "completed"
|
||||
):
|
||||
failures.append(prefix + "identity/mode/status mismatch")
|
||||
if transport.get("kind") != "webrtc" or transport.get("pstn_used") is not False:
|
||||
failures.append(prefix + "transport is not non-PSTN WebRTC")
|
||||
if transport.get("e164_required") is not False:
|
||||
failures.append(prefix + "E.164 was incorrectly required")
|
||||
if not transport.get("sdp_negotiated") or not transport.get("ice_connected_observed"):
|
||||
failures.append(prefix + "SDP/ICE gate failed")
|
||||
for field in ("offer_sha256", "answer_sha256"):
|
||||
if not valid_hash(transport.get(field)):
|
||||
failures.append(prefix + f"invalid {field}")
|
||||
if (
|
||||
not transport.get("data_channel_open")
|
||||
or not transport.get("local_audio_track")
|
||||
or not transport.get("remote_audio_track")
|
||||
):
|
||||
failures.append(prefix + "data channel or bidirectional audio-track gate failed")
|
||||
for field in ("inbound_packets", "inbound_bytes", "outbound_packets", "outbound_bytes"):
|
||||
if not isinstance(stats.get(field), int) or stats[field] <= 0:
|
||||
failures.append(prefix + f"non-positive RTC stat {field}")
|
||||
if (
|
||||
int(transport.get("server_received_audio_frames", 0)) <= 0
|
||||
or int(transport.get("server_received_audio_pcm_bytes", 0)) <= 0
|
||||
):
|
||||
failures.append(prefix + "server did not buffer microphone RTP audio")
|
||||
if record.get("errors"):
|
||||
failures.append(prefix + "runtime errors were retained")
|
||||
|
||||
planning = [item for item in llm_receipts if item.get("purpose") == "react_planning"]
|
||||
dialogue = [item for item in llm_receipts if item.get("purpose") == "post_asr_dialogue"]
|
||||
if name == "direct" and planning:
|
||||
failures.append(prefix + "direct control unexpectedly used an LLM planner")
|
||||
if name == "react" and len(planning) != 1:
|
||||
failures.append(prefix + "ReAct arm lacks exactly one planner receipt")
|
||||
if len(dialogue) != 1:
|
||||
failures.append(prefix + "arm lacks exactly one post-ASR dialogue receipt")
|
||||
for index, receipt in enumerate(llm_receipts):
|
||||
check_llm_receipt(receipt, f"{prefix}llm[{index}]: ", failures)
|
||||
|
||||
if len(asr_receipts) != 1:
|
||||
failures.append(prefix + "arm lacks exactly one ASR receipt")
|
||||
for receipt in asr_receipts:
|
||||
artifact = run_dir / str(receipt.get("retained_safe_fixture_path", ""))
|
||||
if (
|
||||
receipt.get("execution") != "real_local_inference"
|
||||
or receipt.get("input_source") != "browser_microphone_rtp"
|
||||
or receipt.get("mock") is not False
|
||||
or receipt.get("probe_only") is not False
|
||||
or receipt.get("fallback_used") is not False
|
||||
or not valid_hash(receipt.get("checkpoint_sha256"))
|
||||
or not artifact.is_file()
|
||||
or sha256(artifact) != receipt.get("input_wav_sha256")
|
||||
):
|
||||
failures.append(prefix + "ASR provenance/input artifact gate failed")
|
||||
if (
|
||||
not receipt.get("transcript")
|
||||
or receipt.get("transcript_sha256")
|
||||
!= hashlib.sha256(str(receipt.get("transcript", "")).encode()).hexdigest()
|
||||
):
|
||||
failures.append(prefix + "ASR transcript/hash gate failed")
|
||||
|
||||
if len(tts_receipts) != 2:
|
||||
failures.append(prefix + "arm lacks exactly two Agent TTS receipts")
|
||||
for index, receipt in enumerate(tts_receipts):
|
||||
artifact = run_dir / str(receipt.get("retained_safe_fixture_path", ""))
|
||||
if (
|
||||
receipt.get("execution") != "real_speech_synthesis"
|
||||
or receipt.get("mock") is not False
|
||||
or receipt.get("probe_only") is not False
|
||||
or receipt.get("fallback_used") is not False
|
||||
or not artifact.is_file()
|
||||
or sha256(artifact) != receipt.get("wav_sha256")
|
||||
or receipt.get("delivery_complete") is not True
|
||||
or receipt.get("enqueued_on_webrtc_track") is not True
|
||||
or receipt.get("delivered_pcm_sha256") != receipt.get("pcm_sha256")
|
||||
or int(receipt.get("transmitted_samples", 0)) != int(receipt.get("sample_count", -1))
|
||||
):
|
||||
failures.append(prefix + f"TTS/downlink provenance gate failed at receipt {index}")
|
||||
|
||||
if not transcript or any(
|
||||
turn.get("source")
|
||||
!= ("asr.microphone_rtp" if turn.get("speaker") == "user" else "tts.webrtc_downlink")
|
||||
for turn in transcript
|
||||
):
|
||||
failures.append(prefix + "canonical transcript contains a non-media semantic source")
|
||||
if record.get("event_counts", {}).get("semantic_user_messages", 0) != 0:
|
||||
failures.append(prefix + "data channel supplied user semantics")
|
||||
if not transcript or transcript[0].get("purpose") != "missing_field_clarification":
|
||||
failures.append(prefix + "missing-field clarification was not the first TTS turn")
|
||||
if record.get("explicit_confirmation_observed") is not True:
|
||||
failures.append(prefix + "explicit confirmation was not observed")
|
||||
if (
|
||||
not completion.get("appointment_time")
|
||||
or not completion.get("confirmation_number")
|
||||
or completion.get("tool") != "complete_task"
|
||||
):
|
||||
failures.append(prefix + "structured completion fields/tool are incomplete")
|
||||
if (
|
||||
completion.get("result") != "Local confirmation recorded."
|
||||
or completion.get("notes") != "No external organization was contacted or booking made."
|
||||
):
|
||||
failures.append(prefix + "structured completion violates the no-external-action boundary")
|
||||
privacy = record.get("privacy") or {}
|
||||
if (
|
||||
privacy.get("safe_synthetic_acceptance") is not True
|
||||
or privacy.get("private_audio_retained") is not False
|
||||
or privacy.get("private_transcripts_retained") is not False
|
||||
):
|
||||
failures.append(prefix + "privacy/safe-fixture boundary mismatch")
|
||||
if (
|
||||
acceptance.get("passed") is not True
|
||||
or set(checks) != REQUIRED_ARM_CHECKS
|
||||
or not all(checks.values())
|
||||
):
|
||||
failures.append(prefix + "acceptance did not pass the exact fail-closed gate set")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("run_dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
run_dir = args.run_dir.resolve()
|
||||
failures: list[str] = []
|
||||
try:
|
||||
manifest = load_json(run_dir / "manifest.json")
|
||||
direct = load_json(run_dir / "direct.json")
|
||||
react = load_json(run_dir / "react.json")
|
||||
comparison = load_json(run_dir / "comparison.json")
|
||||
|
||||
if set(manifest.get("artifact_sha256", {})) != REQUIRED_ARTIFACTS:
|
||||
failures.append("manifest does not enumerate the exact required artifacts")
|
||||
if set(manifest.get("source_sha256", {})) != REQUIRED_SOURCES:
|
||||
failures.append("manifest does not enumerate the exact required source set")
|
||||
for relative, expected in manifest.get("source_sha256", {}).items():
|
||||
path = ROOT / relative
|
||||
if not path.is_file() or sha256(path) != expected:
|
||||
failures.append(f"source hash mismatch: {relative}")
|
||||
for relative, expected in manifest.get("artifact_sha256", {}).items():
|
||||
path = run_dir / relative
|
||||
if not path.is_file() or sha256(path) != expected:
|
||||
failures.append(f"artifact hash mismatch: {relative}")
|
||||
|
||||
check_arm("direct", direct, run_dir, failures)
|
||||
check_arm("react", react, run_dir, failures)
|
||||
if direct.get("call_id") == react.get("call_id"):
|
||||
failures.append("the two arms reused one call ID")
|
||||
if direct.get("input_contract", {}).get("fields_supplied_by_caller") != [
|
||||
"callee_name",
|
||||
"goal",
|
||||
"context",
|
||||
"instructions",
|
||||
]:
|
||||
failures.append("direct arm did not require all four fixed parameters")
|
||||
if react.get("input_contract", {}).get("fields_supplied_by_caller") != ["task"]:
|
||||
failures.append("ReAct arm accepted more than the natural-language task")
|
||||
if not react.get("plan", {}).get("missing_information"):
|
||||
failures.append("ReAct arm did not identify missing information")
|
||||
if [step.get("stage") for step in react.get("plan", {}).get("trace", [])] != [
|
||||
"observation",
|
||||
"reason",
|
||||
"action",
|
||||
]:
|
||||
failures.append("ReAct trace is not observation/reason/action")
|
||||
comparison_checks = comparison.get("checks") or {}
|
||||
if (
|
||||
comparison.get("passed") is not True
|
||||
or not comparison_checks
|
||||
or not all(comparison_checks.values())
|
||||
):
|
||||
failures.append("direct-vs-ReAct comparison did not pass every check")
|
||||
if (
|
||||
manifest.get("result") != "passed"
|
||||
or manifest.get("execution") != "live_browser_aiortc_asr_external_llm_tts_webrtc"
|
||||
):
|
||||
failures.append("manifest result/execution mismatch")
|
||||
if (
|
||||
manifest.get("canonical_safe_synthetic_fixture") is not True
|
||||
or manifest.get("pstn_used") is not False
|
||||
or manifest.get("e164_required") is not False
|
||||
or manifest.get("credentials_saved") is not False
|
||||
or manifest.get("private_audio_or_transcripts_saved") is not False
|
||||
):
|
||||
failures.append("manifest violates canonical safety/telephony boundary")
|
||||
if manifest.get("environment", {}).get("media_peer") != "aiortc":
|
||||
failures.append("manifest does not identify aiortc")
|
||||
if manifest.get("redaction", {}).get("passed") is not True:
|
||||
failures.append("manifest redaction gate is false")
|
||||
cleanup = manifest.get("cleanup") or {}
|
||||
if (
|
||||
cleanup.get("browser_contexts_closed") is not True
|
||||
or cleanup.get("server_process_terminated") is not True
|
||||
):
|
||||
failures.append("manifest cleanup gate is false")
|
||||
if cleanup.get("raw_private_media_created") is not False:
|
||||
failures.append("manifest says private media was created")
|
||||
for arm, record in (("direct", direct), ("react", react)):
|
||||
if manifest.get("acceptance", {}).get(arm) != record.get("acceptance"):
|
||||
failures.append(f"manifest {arm} acceptance differs from raw record")
|
||||
if manifest.get("acceptance", {}).get("comparison_passed") is not True:
|
||||
failures.append("manifest comparison gate is false")
|
||||
|
||||
# Standalone pattern scan catches common leaked API credential forms even
|
||||
# when the original environment is unavailable to this verifier.
|
||||
credential_pattern = re.compile(rb"\b(?:sk|ak)-[A-Za-z0-9_-]{12,}\b")
|
||||
for relative in REQUIRED_ARTIFACTS:
|
||||
if credential_pattern.search((run_dir / relative).read_bytes()):
|
||||
failures.append(f"credential-shaped value found: {relative}")
|
||||
except (FileNotFoundError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
failures.append(f"malformed or incomplete evidence: {exc}")
|
||||
|
||||
result = {"run_id": run_dir.name, "passed": not failures, "failures": failures}
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0 if not failures else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,698 @@
|
||||
"""Browser-to-aiortc voice Agent for Phone Agent add-on.
|
||||
|
||||
The browser microphone is the only user-semantic input. The server buffers its
|
||||
decoded RTP audio, runs real Whisper ASR, sends that transcript to a real external
|
||||
LLM, synthesizes the Agent reply, and places the resulting PCM on the WebRTC
|
||||
downlink track. The data channel carries captions and control events only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from agent import CallPlan, conversation_turn, direct_plan, react_plan
|
||||
from aiortc import AudioStreamTrack, RTCPeerConnection, RTCSessionDescription
|
||||
from aiortc.mediastreams import MediaStreamError
|
||||
from av import AudioFrame, AudioResampler
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from speech import SystemSpeechSynthesizer, WhisperASR, sha256_bytes
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
STATIC = HERE / "static"
|
||||
load_dotenv(HERE / ".env")
|
||||
CALLS: dict[str, dict[str, Any]] = {}
|
||||
PEERS: dict[str, RTCPeerConnection] = {}
|
||||
RUNTIMES: dict[str, CallRuntime] = {}
|
||||
MAX_MICROPHONE_PCM_BYTES = 16_000 * 2 * 60
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _safe_error(exc: BaseException) -> str:
|
||||
text = f"{type(exc).__name__}: {exc}"
|
||||
for name, secret in os.environ.items():
|
||||
if (
|
||||
any(marker in name.upper() for marker in ("KEY", "TOKEN", "SECRET", "PASSWORD"))
|
||||
and len(secret) >= 8
|
||||
):
|
||||
text = text.replace(secret, "[REDACTED]")
|
||||
return text[:1000]
|
||||
|
||||
|
||||
class CreateCall(BaseModel):
|
||||
mode: Literal["direct", "react"]
|
||||
task: str = Field(default="", max_length=4000)
|
||||
callee_name: str = Field(default="", max_length=200)
|
||||
goal: str = Field(default="", max_length=2000)
|
||||
context: str = Field(default="", max_length=4000)
|
||||
instructions: str = Field(default="", max_length=4000)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_arm(self) -> CreateCall:
|
||||
if self.mode == "react" and not self.task.strip():
|
||||
raise ValueError("the ReAct arm requires a natural-language task")
|
||||
if self.mode == "direct":
|
||||
missing = [
|
||||
name
|
||||
for name in ("callee_name", "goal", "context", "instructions")
|
||||
if not getattr(self, name).strip()
|
||||
]
|
||||
if missing:
|
||||
raise ValueError("the direct arm requires: " + ", ".join(missing))
|
||||
return self
|
||||
|
||||
|
||||
class EventEnvelope(BaseModel):
|
||||
event: dict[str, Any]
|
||||
|
||||
|
||||
class CompleteTask(BaseModel):
|
||||
result: str = Field(min_length=1, max_length=2000)
|
||||
appointment_time: str = Field(min_length=1, max_length=300)
|
||||
confirmation_number: str = Field(min_length=1, max_length=300)
|
||||
notes: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
class FinishCall(BaseModel):
|
||||
reason: str = Field(default="user_hangup", max_length=200)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueuedUtterance:
|
||||
receipt: dict[str, Any]
|
||||
pcm: bytes
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class SynthesizedAudioTrack(AudioStreamTrack):
|
||||
"""Continuous WebRTC audio track whose non-silent segments are real TTS PCM."""
|
||||
|
||||
def __init__(self, record: dict[str, Any]) -> None:
|
||||
super().__init__()
|
||||
self.record = record
|
||||
self.queue: deque[QueuedUtterance] = deque()
|
||||
|
||||
def enqueue(self, pcm: bytes, receipt: dict[str, Any]) -> None:
|
||||
if not pcm:
|
||||
raise ValueError("cannot enqueue empty TTS audio")
|
||||
receipt["enqueued_on_webrtc_track"] = True
|
||||
receipt["transmitted_samples"] = 0
|
||||
receipt["delivery_complete"] = False
|
||||
self.queue.append(QueuedUtterance(receipt=receipt, pcm=pcm))
|
||||
|
||||
async def recv(self) -> AudioFrame:
|
||||
frame = await super().recv()
|
||||
target = bytearray(frame.samples * 2)
|
||||
cursor = 0
|
||||
while cursor < len(target) and self.queue:
|
||||
utterance = self.queue[0]
|
||||
available = len(utterance.pcm) - utterance.offset
|
||||
take = min(len(target) - cursor, available)
|
||||
target[cursor : cursor + take] = utterance.pcm[
|
||||
utterance.offset : utterance.offset + take
|
||||
]
|
||||
utterance.offset += take
|
||||
cursor += take
|
||||
samples = take // 2
|
||||
utterance.receipt["transmitted_samples"] += samples
|
||||
self.record["transport"]["server_sent_tts_samples"] += samples
|
||||
if utterance.offset == len(utterance.pcm):
|
||||
utterance.receipt["delivery_complete"] = True
|
||||
utterance.receipt["delivered_pcm_sha256"] = utterance.receipt["pcm_sha256"]
|
||||
self.queue.popleft()
|
||||
frame.planes[0].update(bytes(target))
|
||||
return frame
|
||||
|
||||
|
||||
class CallRuntime:
|
||||
def __init__(self, record: dict[str, Any]) -> None:
|
||||
self.record = record
|
||||
self.tts = SystemSpeechSynthesizer()
|
||||
self.asr = WhisperASR()
|
||||
self.output_track = SynthesizedAudioTrack(record)
|
||||
self.microphone_pcm = bytearray()
|
||||
self.resampler = AudioResampler(format="s16", layout="mono", rate=16_000)
|
||||
self.channel: Any | None = None
|
||||
self.commit_started = False
|
||||
|
||||
|
||||
app = FastAPI(title="Phone Agent add-on WebRTC Voice Agent", version="3.0")
|
||||
app.mount("/static", StaticFiles(directory=STATIC), name="static")
|
||||
|
||||
|
||||
def _record(call_id: str) -> dict[str, Any]:
|
||||
record = CALLS.get(call_id)
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail="unknown call")
|
||||
return record
|
||||
|
||||
|
||||
def _public(record: dict[str, Any]) -> dict[str, Any]:
|
||||
return json.loads(json.dumps(record, ensure_ascii=False))
|
||||
|
||||
|
||||
def _hash_is_valid(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and len(value) == 64
|
||||
and all(character in "0123456789abcdef" for character in value)
|
||||
)
|
||||
|
||||
|
||||
def _llm_receipt_is_real(receipt: dict[str, Any]) -> bool:
|
||||
usage = receipt.get("usage") or {}
|
||||
return bool(
|
||||
receipt.get("execution") == "real_external_llm"
|
||||
and receipt.get("external_request_completed") is True
|
||||
and receipt.get("provider_response_id")
|
||||
and receipt.get("provider_model")
|
||||
and receipt.get("finish_reason")
|
||||
and int(usage.get("total_tokens", 0)) > 0
|
||||
and float(receipt.get("latency_seconds", 0)) > 0
|
||||
and _hash_is_valid(receipt.get("request_sha256"))
|
||||
and _hash_is_valid(receipt.get("raw_response_sha256"))
|
||||
and _hash_is_valid(receipt.get("response_content_sha256"))
|
||||
and receipt.get("fallback_used") is False
|
||||
and receipt.get("mock") is False
|
||||
and receipt.get("probe_only") is False
|
||||
and receipt.get("credential_fields_retained") is False
|
||||
)
|
||||
|
||||
|
||||
def _acceptance(record: dict[str, Any]) -> dict[str, Any]:
|
||||
transport = record["transport"]
|
||||
stats = transport["rtc_stats"]
|
||||
transcript = record["transcript"]
|
||||
llm_receipts = record["models"]["llm_receipts"]
|
||||
asr_receipts = record["models"]["asr_receipts"]
|
||||
tts_receipts = record["models"]["tts_receipts"]
|
||||
planning_receipts = [item for item in llm_receipts if item.get("purpose") == "react_planning"]
|
||||
dialogue_receipts = [
|
||||
item for item in llm_receipts if item.get("purpose") == "post_asr_dialogue"
|
||||
]
|
||||
all_model_receipts = [*llm_receipts, *asr_receipts, *tts_receipts]
|
||||
completion = record.get("completion") or {}
|
||||
first_agent = next((turn for turn in transcript if turn.get("speaker") == "agent"), {})
|
||||
checks = {
|
||||
"sdp_offer_answer_negotiated": bool(transport["sdp_negotiated"]),
|
||||
"ice_connected": bool(transport["ice_connected_observed"]),
|
||||
"data_channel_open": bool(transport["data_channel_open"]),
|
||||
"browser_microphone_track": bool(transport["local_audio_track"]),
|
||||
"server_downlink_audio_track": bool(transport["remote_audio_track"]),
|
||||
"outbound_audio_rtp": int(stats["outbound_packets"]) > 0
|
||||
and int(stats["outbound_bytes"]) > 0,
|
||||
"inbound_audio_rtp": int(stats["inbound_packets"]) > 0 and int(stats["inbound_bytes"]) > 0,
|
||||
"server_buffered_microphone_rtp": (
|
||||
int(transport["server_received_audio_frames"]) > 0
|
||||
and int(transport["server_received_audio_pcm_bytes"]) > 0
|
||||
),
|
||||
"real_asr_consumed_microphone_audio": bool(
|
||||
asr_receipts
|
||||
and all(
|
||||
item.get("execution") == "real_local_inference"
|
||||
and item.get("input_source") == "browser_microphone_rtp"
|
||||
and _hash_is_valid(item.get("input_wav_sha256"))
|
||||
and _hash_is_valid(item.get("checkpoint_sha256"))
|
||||
and item.get("fallback_used") is False
|
||||
for item in asr_receipts
|
||||
)
|
||||
),
|
||||
"external_react_planner_or_fixed_direct_control": (
|
||||
record["mode"] == "direct"
|
||||
and record["input_contract"]["fields_supplied_by_caller"]
|
||||
== ["callee_name", "goal", "context", "instructions"]
|
||||
)
|
||||
or (
|
||||
record["mode"] == "react"
|
||||
and len(planning_receipts) == 1
|
||||
and _llm_receipt_is_real(planning_receipts[0])
|
||||
),
|
||||
"real_external_post_asr_dialogue": len(dialogue_receipts) == 1
|
||||
and _llm_receipt_is_real(dialogue_receipts[0]),
|
||||
"real_tts_assets_synthesized": len(tts_receipts) >= 2
|
||||
and all(
|
||||
item.get("execution") == "real_speech_synthesis"
|
||||
and int(item.get("sample_count", 0)) > 0
|
||||
and _hash_is_valid(item.get("wav_sha256"))
|
||||
and _hash_is_valid(item.get("pcm_sha256"))
|
||||
and item.get("fallback_used") is False
|
||||
for item in tts_receipts
|
||||
),
|
||||
"tts_audio_transmitted_on_downlink": len(tts_receipts) >= 2
|
||||
and all(
|
||||
item.get("enqueued_on_webrtc_track") is True
|
||||
and item.get("delivery_complete") is True
|
||||
and int(item.get("transmitted_samples", 0)) == int(item.get("sample_count", -1))
|
||||
and item.get("delivered_pcm_sha256") == item.get("pcm_sha256")
|
||||
for item in tts_receipts
|
||||
),
|
||||
"media_is_canonical_transcript_source": bool(transcript)
|
||||
and all(
|
||||
(turn.get("speaker") == "user" and turn.get("source") == "asr.microphone_rtp")
|
||||
or (turn.get("speaker") == "agent" and turn.get("source") == "tts.webrtc_downlink")
|
||||
for turn in transcript
|
||||
),
|
||||
"data_channel_is_control_and_caption_only": int(
|
||||
record["event_counts"].get("semantic_user_messages", 0)
|
||||
)
|
||||
== 0,
|
||||
"missing_fields_were_clarified_aloud": (
|
||||
first_agent.get("purpose") == "missing_field_clarification"
|
||||
and first_agent.get("source") == "tts.webrtc_downlink"
|
||||
and (record["mode"] == "direct" or bool(record["plan"].get("missing_information")))
|
||||
),
|
||||
"explicit_confirmation_observed": record.get("explicit_confirmation_observed") is True,
|
||||
"structured_completion_saved": bool(
|
||||
completion.get("appointment_time")
|
||||
and completion.get("confirmation_number")
|
||||
and completion.get("result")
|
||||
),
|
||||
"no_mock_probe_or_fallback": bool(all_model_receipts)
|
||||
and all(
|
||||
item.get("mock") is False
|
||||
and item.get("probe_only") is False
|
||||
and item.get("fallback_used") is False
|
||||
for item in all_model_receipts
|
||||
),
|
||||
"privacy_boundary_preserved": record["privacy"]["private_audio_retained"] is False
|
||||
and record["privacy"]["private_transcripts_retained"] is False,
|
||||
}
|
||||
return {"checks": checks, "passed": all(checks.values()) and not record["errors"]}
|
||||
|
||||
|
||||
def _retained_media_path(record: dict[str, Any], filename: str) -> Path | None:
|
||||
if os.getenv("PHONE_SAFE_SYNTHETIC_ACCEPTANCE") != "1":
|
||||
return None
|
||||
root = os.getenv("PHONE_EVIDENCE_DIR")
|
||||
if not root:
|
||||
raise RuntimeError("safe acceptance media retention requires PHONE_EVIDENCE_DIR")
|
||||
path = Path(root).resolve() / "media" / record["mode"] / filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _relative_evidence_path(path: Path | None) -> str | None:
|
||||
if path is None:
|
||||
return None
|
||||
root = Path(os.environ["PHONE_EVIDENCE_DIR"]).resolve()
|
||||
return str(path.relative_to(root))
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index() -> FileResponse:
|
||||
return FileResponse(STATIC / "index.html")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict[str, Any]:
|
||||
return {
|
||||
"ok": True,
|
||||
"experiment": "phone-agent",
|
||||
"model_provider": os.getenv("PHONE_MODEL_PROVIDER", "ark"),
|
||||
"model_credential_present": bool(
|
||||
os.getenv("ARK_API_KEY")
|
||||
or os.getenv("OPENAI_API_KEY")
|
||||
or os.getenv("OPENROUTER_API_KEY")
|
||||
),
|
||||
"speech_paths": "browser microphone RTP -> Whisper ASR; system TTS -> WebRTC RTP",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/calls")
|
||||
async def create_call(request: CreateCall) -> dict[str, Any]:
|
||||
try:
|
||||
if request.mode == "direct":
|
||||
plan = direct_plan(
|
||||
callee_name=request.callee_name,
|
||||
goal=request.goal,
|
||||
context=request.context,
|
||||
instructions=request.instructions,
|
||||
)
|
||||
supplied = ["callee_name", "goal", "context", "instructions"]
|
||||
else:
|
||||
plan = await asyncio.to_thread(react_plan, request.task)
|
||||
supplied = ["task"]
|
||||
except (RuntimeError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=422, detail=_safe_error(exc)) from exc
|
||||
|
||||
plan_dict = plan.to_dict()
|
||||
planner_receipt = plan_dict.pop("planner_receipt", None)
|
||||
llm_receipts = [planner_receipt] if planner_receipt else []
|
||||
call_id = "rtc_" + uuid.uuid4().hex[:20]
|
||||
safe_acceptance = os.getenv("PHONE_SAFE_SYNTHETIC_ACCEPTANCE") == "1"
|
||||
record = {
|
||||
"schema_version": 3,
|
||||
"experiment": "phone-agent",
|
||||
"call_id": call_id,
|
||||
"created_at_utc": _now(),
|
||||
"finished_at_utc": None,
|
||||
"status": "planned",
|
||||
"mode": request.mode,
|
||||
"input_contract": {
|
||||
"fields_supplied_by_caller": supplied,
|
||||
"natural_language_task": request.task,
|
||||
},
|
||||
"plan": plan_dict,
|
||||
"models": {
|
||||
"planner": plan.planner_model,
|
||||
"dialogue_models": [],
|
||||
"llm_receipts": llm_receipts,
|
||||
"asr_receipts": [],
|
||||
"tts_receipts": [],
|
||||
},
|
||||
"transport": {
|
||||
"kind": "webrtc",
|
||||
"pstn_used": False,
|
||||
"e164_required": False,
|
||||
"sdp_negotiated": False,
|
||||
"offer_sha256": None,
|
||||
"answer_sha256": None,
|
||||
"ice_connection_state": "new",
|
||||
"ice_connected_observed": False,
|
||||
"data_channel_open": False,
|
||||
"local_audio_track": False,
|
||||
"remote_audio_track": False,
|
||||
"rtc_stats": {
|
||||
"inbound_packets": 0,
|
||||
"inbound_bytes": 0,
|
||||
"outbound_packets": 0,
|
||||
"outbound_bytes": 0,
|
||||
},
|
||||
"server_received_audio_frames": 0,
|
||||
"server_received_audio_samples": 0,
|
||||
"server_received_audio_pcm_bytes": 0,
|
||||
"server_sent_tts_samples": 0,
|
||||
},
|
||||
"privacy": {
|
||||
"safe_synthetic_acceptance": safe_acceptance,
|
||||
"private_audio_retained": False,
|
||||
"private_transcripts_retained": False,
|
||||
"safe_synthetic_media_retained": safe_acceptance,
|
||||
},
|
||||
"event_counts": {},
|
||||
"transcript": [],
|
||||
"explicit_confirmation_observed": False,
|
||||
"completion": None,
|
||||
"errors": [],
|
||||
"acceptance": {"checks": {}, "passed": False},
|
||||
}
|
||||
CALLS[call_id] = record
|
||||
return {"call_id": call_id, "join_url": f"/?call_id={call_id}", "plan": plan_dict}
|
||||
|
||||
|
||||
async def _consume_microphone(track: Any, runtime: CallRuntime) -> None:
|
||||
record = runtime.record
|
||||
try:
|
||||
while True:
|
||||
frame = await track.recv()
|
||||
record["transport"]["server_received_audio_frames"] += 1
|
||||
record["transport"]["server_received_audio_samples"] += int(
|
||||
getattr(frame, "samples", 0)
|
||||
)
|
||||
for resampled in runtime.resampler.resample(frame):
|
||||
# Packed mono s16 has two bytes per sample. Reading the plane
|
||||
# directly avoids imposing NumPy on this transport-only stack.
|
||||
pcm = bytes(resampled.planes[0])[: int(resampled.samples) * 2]
|
||||
if len(runtime.microphone_pcm) + len(pcm) > MAX_MICROPHONE_PCM_BYTES:
|
||||
raise RuntimeError("microphone buffer exceeded the 60-second safety limit")
|
||||
runtime.microphone_pcm.extend(pcm)
|
||||
record["transport"]["server_received_audio_pcm_bytes"] = len(runtime.microphone_pcm)
|
||||
except MediaStreamError:
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001 - evidence must retain asynchronous media failures
|
||||
record["errors"].append(
|
||||
{"at": _now(), "stage": "microphone_rtp", "message": _safe_error(exc)}
|
||||
)
|
||||
|
||||
|
||||
async def _send_agent(
|
||||
runtime: CallRuntime,
|
||||
text: str,
|
||||
*,
|
||||
purpose: str,
|
||||
) -> None:
|
||||
cleaned = text.strip()
|
||||
if not cleaned:
|
||||
raise ValueError("Agent TTS text is empty")
|
||||
index = len(runtime.record["models"]["tts_receipts"]) + 1
|
||||
speech = await asyncio.to_thread(runtime.tts.synthesize, cleaned)
|
||||
path = _retained_media_path(runtime.record, f"agent_{index:02d}.wav")
|
||||
if path is not None:
|
||||
path.write_bytes(speech.wav)
|
||||
if sha256_bytes(path.read_bytes()) != speech.receipt["wav_sha256"]:
|
||||
raise RuntimeError("retained TTS asset hash mismatch")
|
||||
receipt = {
|
||||
**speech.receipt,
|
||||
"utterance_id": f"tts_{index:02d}",
|
||||
"purpose": purpose,
|
||||
"retained_safe_fixture_path": _relative_evidence_path(path),
|
||||
"text_sha256": hashlib.sha256(cleaned.encode("utf-8")).hexdigest(),
|
||||
}
|
||||
runtime.record["models"]["tts_receipts"].append(receipt)
|
||||
runtime.output_track.enqueue(speech.pcm, receipt)
|
||||
runtime.record["transcript"].append(
|
||||
{
|
||||
"speaker": "agent",
|
||||
"text": cleaned,
|
||||
"source": "tts.webrtc_downlink",
|
||||
"utterance_id": receipt["utterance_id"],
|
||||
"purpose": purpose,
|
||||
}
|
||||
)
|
||||
channel = runtime.channel
|
||||
if channel is not None and channel.readyState == "open":
|
||||
channel.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "agent.caption",
|
||||
"text": cleaned,
|
||||
"utterance_id": receipt["utterance_id"],
|
||||
"source": "accessibility_mirror_of_tts",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _commit_microphone(runtime: CallRuntime) -> None:
|
||||
if runtime.commit_started:
|
||||
raise RuntimeError("microphone audio was already committed")
|
||||
runtime.commit_started = True
|
||||
record = runtime.record
|
||||
pcm = bytes(runtime.microphone_pcm)
|
||||
path = _retained_media_path(record, "microphone_rtp_asr_input.wav")
|
||||
transcript, asr_receipt = await asyncio.to_thread(
|
||||
runtime.asr.transcribe, pcm, retained_wav_path=path
|
||||
)
|
||||
asr_receipt["retained_safe_fixture_path"] = _relative_evidence_path(path)
|
||||
if path is not None and sha256_bytes(path.read_bytes()) != asr_receipt["input_wav_sha256"]:
|
||||
raise RuntimeError("retained microphone/ASR input hash mismatch")
|
||||
record["models"]["asr_receipts"].append(asr_receipt)
|
||||
record["transcript"].append(
|
||||
{
|
||||
"speaker": "user",
|
||||
"text": transcript,
|
||||
"source": "asr.microphone_rtp",
|
||||
"asr_receipt_index": len(record["models"]["asr_receipts"]) - 1,
|
||||
}
|
||||
)
|
||||
channel = runtime.channel
|
||||
if channel is not None and channel.readyState == "open":
|
||||
channel.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "user.caption",
|
||||
"text": transcript,
|
||||
"source": "accessibility_mirror_of_asr",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
plan = CallPlan(**record["plan"])
|
||||
dialogue = await asyncio.to_thread(
|
||||
conversation_turn,
|
||||
plan,
|
||||
list(record["transcript"][:-1]),
|
||||
transcript,
|
||||
)
|
||||
receipt = dialogue["llm_receipt"]
|
||||
record["models"]["llm_receipts"].append(receipt)
|
||||
model = dialogue["dialogue_model"]
|
||||
if model not in record["models"]["dialogue_models"]:
|
||||
record["models"]["dialogue_models"].append(model)
|
||||
record["explicit_confirmation_observed"] = dialogue["explicit_confirmation_observed"] is True
|
||||
if not dialogue["should_complete"]:
|
||||
raise RuntimeError("canonical call did not reach explicit structured completion")
|
||||
completion = CompleteTask(**dialogue["completion"])
|
||||
await _send_agent(runtime, dialogue["assistant_message"], purpose="confirmed_completion")
|
||||
record["completion"] = {
|
||||
**completion.model_dump(),
|
||||
"saved_at_utc": _now(),
|
||||
"tool": "complete_task",
|
||||
}
|
||||
if channel is not None and channel.readyState == "open":
|
||||
channel.send(json.dumps({"type": "tool.result", "name": "complete_task", "saved": True}))
|
||||
|
||||
|
||||
async def _handle_data_message(runtime: CallRuntime, raw: Any) -> None:
|
||||
record = runtime.record
|
||||
try:
|
||||
message = json.loads(raw) if isinstance(raw, str) else {}
|
||||
message_type = message.get("type")
|
||||
if message_type == "client.ready":
|
||||
record["transport"]["data_channel_open"] = True
|
||||
if not record["transcript"]:
|
||||
await _send_agent(
|
||||
runtime,
|
||||
record["plan"]["opening_line"],
|
||||
purpose="missing_field_clarification",
|
||||
)
|
||||
return
|
||||
if message_type == "client.audio.commit":
|
||||
record["event_counts"]["client.audio.commit"] = (
|
||||
int(record["event_counts"].get("client.audio.commit", 0)) + 1
|
||||
)
|
||||
await _commit_microphone(runtime)
|
||||
return
|
||||
if message_type in {"user.message", "client.user_text"}:
|
||||
record["event_counts"]["semantic_user_messages"] = (
|
||||
int(record["event_counts"].get("semantic_user_messages", 0)) + 1
|
||||
)
|
||||
raise RuntimeError("semantic user text is forbidden; speak over the microphone track")
|
||||
except Exception as exc: # noqa: BLE001 - asynchronous failures belong in the evidence record
|
||||
error = {"at": _now(), "stage": "voice_agent_loop", "message": _safe_error(exc)}
|
||||
record["errors"].append(error)
|
||||
if runtime.channel is not None and runtime.channel.readyState == "open":
|
||||
runtime.channel.send(json.dumps({"type": "error", "error": error}, ensure_ascii=False))
|
||||
|
||||
|
||||
@app.post("/api/calls/{call_id}/session")
|
||||
async def negotiate(call_id: str, request: Request) -> Response:
|
||||
record = _record(call_id)
|
||||
content_type = request.headers.get("content-type", "")
|
||||
if not content_type.startswith("application/sdp"):
|
||||
raise HTTPException(status_code=415, detail="expected application/sdp")
|
||||
offer = (await request.body()).decode("utf-8", errors="strict")
|
||||
if not offer.startswith("v=0") or len(offer) > 1_000_000:
|
||||
raise HTTPException(status_code=400, detail="invalid SDP offer")
|
||||
try:
|
||||
runtime = CallRuntime(record)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=_safe_error(exc)) from exc
|
||||
RUNTIMES[call_id] = runtime
|
||||
pc = RTCPeerConnection()
|
||||
PEERS[call_id] = pc
|
||||
pc.addTrack(runtime.output_track)
|
||||
|
||||
@pc.on("track")
|
||||
def on_track(track: Any) -> None:
|
||||
if track.kind == "audio":
|
||||
record["transport"]["local_audio_track"] = True
|
||||
asyncio.create_task(_consume_microphone(track, runtime))
|
||||
|
||||
@pc.on("datachannel")
|
||||
def on_datachannel(channel: Any) -> None:
|
||||
runtime.channel = channel
|
||||
|
||||
@channel.on("message")
|
||||
def on_message(message: Any) -> None:
|
||||
asyncio.create_task(_handle_data_message(runtime, message))
|
||||
|
||||
@pc.on("connectionstatechange")
|
||||
async def on_connectionstatechange() -> None:
|
||||
state = pc.connectionState
|
||||
if state in {"connected", "completed"}:
|
||||
record["transport"]["ice_connected_observed"] = True
|
||||
record["transport"]["ice_connection_state"] = state
|
||||
elif not record["transport"]["ice_connected_observed"]:
|
||||
record["transport"]["ice_connection_state"] = state
|
||||
if state in {"failed", "closed"}:
|
||||
await pc.close()
|
||||
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdp=offer, type="offer"))
|
||||
answer_description = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer_description)
|
||||
answer = pc.localDescription.sdp
|
||||
record["status"] = "connected"
|
||||
record["transport"].update(
|
||||
{
|
||||
"sdp_negotiated": True,
|
||||
"offer_sha256": _sha256_text(offer),
|
||||
"answer_sha256": _sha256_text(answer),
|
||||
"remote_audio_track": True,
|
||||
}
|
||||
)
|
||||
return Response(content=answer, media_type="application/sdp")
|
||||
|
||||
|
||||
@app.post("/api/calls/{call_id}/events")
|
||||
async def save_event(call_id: str, envelope: EventEnvelope) -> dict[str, bool]:
|
||||
record = _record(call_id)
|
||||
event = envelope.event
|
||||
event_type = str(event.get("type", "unknown"))[:200]
|
||||
counts = Counter(record["event_counts"])
|
||||
counts[event_type] += 1
|
||||
record["event_counts"] = dict(sorted(counts.items()))
|
||||
if event_type == "rtc.ready":
|
||||
state = str(event.get("ice_connection_state", "unknown"))[:30]
|
||||
if state in {"connected", "completed"}:
|
||||
record["transport"]["ice_connected_observed"] = True
|
||||
record["transport"]["ice_connection_state"] = state
|
||||
for field, event_field in (
|
||||
("data_channel_open", "data_channel_open"),
|
||||
("local_audio_track", "local_audio_track"),
|
||||
("remote_audio_track", "remote_audio_track"),
|
||||
):
|
||||
record["transport"][field] = bool(record["transport"][field] or event.get(event_field))
|
||||
elif event_type == "rtc.stats":
|
||||
stats = record["transport"]["rtc_stats"]
|
||||
for field in stats:
|
||||
stats[field] = max(int(stats[field]), max(0, int(event.get(field, 0))))
|
||||
state = str(event.get("ice_connection_state", ""))[:30]
|
||||
if state in {"connected", "completed"}:
|
||||
record["transport"]["ice_connected_observed"] = True
|
||||
record["transport"]["ice_connection_state"] = state
|
||||
elif event_type == "error":
|
||||
record["errors"].append({"at": _now(), "stage": "browser", "message": str(event)[:1000]})
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
@app.post("/api/calls/{call_id}/finish")
|
||||
async def finish(call_id: str, finish_request: FinishCall) -> dict[str, Any]:
|
||||
record = _record(call_id)
|
||||
record["finished_at_utc"] = _now()
|
||||
record["finish_reason"] = finish_request.reason
|
||||
record["acceptance"] = _acceptance(record)
|
||||
record["status"] = "completed" if record["acceptance"]["passed"] else "ended"
|
||||
result = _public(record)
|
||||
peer = PEERS.pop(call_id, None)
|
||||
RUNTIMES.pop(call_id, None)
|
||||
if peer is not None:
|
||||
await peer.close()
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/calls/{call_id}")
|
||||
async def get_call(call_id: str) -> dict[str, Any]:
|
||||
record = _record(call_id)
|
||||
record["acceptance"] = _acceptance(record)
|
||||
return _public(record)
|
||||
Reference in New Issue
Block a user