425 lines
16 KiB
Python
Executable File
425 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Run isolated and, when configured, real Bailian AI acceptance probes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import wave
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CLI = ROOT / "scripts" / "test_ai_call.py"
|
|
MOCK_PROFILE = ROOT / "configs" / "ai-test.example.yaml"
|
|
REAL_PROFILE = ROOT / "configs" / "ai-test.bailian.example.yaml"
|
|
|
|
|
|
def _now() -> datetime:
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def _run_cli(kind: str, *extra: str) -> tuple[int, dict[str, Any]]:
|
|
with tempfile.TemporaryDirectory(prefix="agent-call-accept-") as directory:
|
|
output = Path(directory) / "report.json"
|
|
completed = subprocess.run(
|
|
[sys.executable, str(CLI), kind, "--output", str(output), *extra],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
try:
|
|
report = json.loads(completed.stdout)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(
|
|
f"AI CLI emitted no JSON for {kind}: {completed.stderr[-500:]}"
|
|
) from exc
|
|
return completed.returncode, report
|
|
|
|
|
|
def _summary(kind: str, code: int, report: dict[str, Any]) -> dict[str, Any]:
|
|
result = report.get("result")
|
|
result = result if isinstance(result, dict) else {}
|
|
execution = report.get("execution")
|
|
execution = execution if isinstance(execution, dict) else {}
|
|
latest_execution = report.get("latest_execution")
|
|
latest_execution = latest_execution if isinstance(latest_execution, dict) else {}
|
|
recording = report.get("recording")
|
|
recording = recording if isinstance(recording, dict) else {}
|
|
return {
|
|
"kind": kind,
|
|
"exit_code": code,
|
|
"status": report.get("status"),
|
|
"mode": report.get("mode"),
|
|
"requested_mode": report.get("requested_mode"),
|
|
"provider_modes": report.get("provider_modes"),
|
|
"agent_version_id": report.get("agent_version_id"),
|
|
"agent_config_sha256": report.get("agent_config_sha256"),
|
|
"prompt_sha256": report.get("prompt_sha256"),
|
|
"llm_requested_model": report.get("llm", {}).get("requested_model"),
|
|
"llm_returned_model": report.get("llm", {}).get("provider_returned_model"),
|
|
"tts_requested_model": report.get("tts", {}).get("requested_model"),
|
|
"tts_returned_model": report.get("tts", {}).get("provider_returned_model"),
|
|
"tts_returned_model_verified": report.get("tts", {}).get(
|
|
"provider_returned_model_verified", False
|
|
),
|
|
"tts_model_evidence": report.get("tts", {}).get("model_evidence"),
|
|
"voice_sha256": hashlib.sha256(
|
|
str(report.get("tts", {}).get("voice", "")).encode()
|
|
).hexdigest(),
|
|
"asr_provider_ref": report.get("asr", {}).get("provider_ref"),
|
|
"asr_segment_count": len(result.get("asr_segments", [])),
|
|
"llm_first_token_ms": result.get("llm_first_token_ms"),
|
|
"tts_first_audio_ms": result.get("tts_first_audio_ms"),
|
|
"audio_sha256": report.get("audio_output", {}).get("sha256"),
|
|
"audio_bytes": report.get("audio_output", {}).get("bytes"),
|
|
"call_id": report.get("call_id")
|
|
or result.get("call_id")
|
|
or latest_execution.get("call_id")
|
|
or execution.get("call_id"),
|
|
"reason_code": report.get("reason_code")
|
|
or result.get("reason_code")
|
|
or latest_execution.get("reason_code")
|
|
or execution.get("reason_code"),
|
|
"recording_bytes": recording.get("bytes"),
|
|
"recording_valid": recording.get("valid_wav") or recording.get("valid_audio"),
|
|
}
|
|
|
|
|
|
def _wav(path: Path) -> None:
|
|
with wave.open(str(path), "wb") as output:
|
|
output.setnchannels(1)
|
|
output.setsampwidth(2)
|
|
output.setframerate(16000)
|
|
output.writeframes(b"\x00\x00" * 1600)
|
|
|
|
|
|
def _case(
|
|
case_id: str, status: str, actual: str, evidence: list[str], mode: str
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"case_id": case_id,
|
|
"mode": mode,
|
|
"status": status,
|
|
"actual": actual,
|
|
"evidence": evidence,
|
|
}
|
|
|
|
|
|
def _configured() -> bool:
|
|
import os
|
|
|
|
return all(
|
|
os.environ.get(name)
|
|
for name in (
|
|
"BAILIAN_API_KEY",
|
|
"BAILIAN_BASE_URL",
|
|
"BAILIAN_WSS_BASE_URL",
|
|
"BAILIAN_TTS_VOICE",
|
|
)
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
date = _now().strftime("%Y%m%d")
|
|
evidence_dir = ROOT / "docs" / "evidence"
|
|
evidence_dir.mkdir(parents=True, exist_ok=True)
|
|
evidence_path = evidence_dir / f"llm-voice-acceptance-{date}.json"
|
|
|
|
regression = subprocess.run(
|
|
[sys.executable, "-m", "unittest", "discover", "-s", "tests", "-v"],
|
|
cwd=ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
match = re.search(r"Ran (\d+) tests?", regression.stdout + regression.stderr)
|
|
test_count: int | None = None
|
|
if match:
|
|
try:
|
|
test_count = int(match.group(1))
|
|
except (TypeError, ValueError, OverflowError):
|
|
test_count = None
|
|
regression_summary = {"exit_code": regression.returncode, "test_count": test_count}
|
|
|
|
commands: list[dict[str, Any]] = []
|
|
mock_text_code, mock_text = _run_cli(
|
|
"text",
|
|
"--profile",
|
|
str(MOCK_PROFILE),
|
|
"--mode",
|
|
"mock",
|
|
"--input",
|
|
"这是隔离文本验收。",
|
|
)
|
|
commands.append(_summary("mock-text", mock_text_code, mock_text))
|
|
with tempfile.TemporaryDirectory(prefix="agent-call-accept-audio-") as directory:
|
|
source = Path(directory) / "input.wav"
|
|
target = Path(directory) / "response.wav"
|
|
_wav(source)
|
|
mock_audio_code, mock_audio = _run_cli(
|
|
"audio",
|
|
"--profile",
|
|
str(MOCK_PROFILE),
|
|
"--mode",
|
|
"mock",
|
|
"--input",
|
|
str(source),
|
|
"--output-audio",
|
|
str(target),
|
|
)
|
|
commands.append(_summary("mock-audio", mock_audio_code, mock_audio))
|
|
mock_call_code, mock_call = _run_cli(
|
|
"call",
|
|
"--profile",
|
|
str(MOCK_PROFILE),
|
|
"--mode",
|
|
"mock",
|
|
"--callee",
|
|
"18601013734",
|
|
"--allow-real-call",
|
|
"--max-duration",
|
|
"120",
|
|
)
|
|
commands.append(_summary("mock-call", mock_call_code, mock_call))
|
|
|
|
real_ready = _configured()
|
|
real_text_code = real_audio_code = 2
|
|
real_text: dict[str, Any] = {
|
|
"status": "BLOCKED",
|
|
"reason_code": "BAILIAN_ENV_REQUIRED",
|
|
"message": "BAILIAN_API_KEY, BAILIAN_BASE_URL, BAILIAN_WSS_BASE_URL and BAILIAN_TTS_VOICE are required",
|
|
}
|
|
real_audio: dict[str, Any] = dict(real_text)
|
|
if real_ready:
|
|
with tempfile.TemporaryDirectory(prefix="agent-call-real-accept-") as directory:
|
|
generated = Path(directory) / "tts.wav"
|
|
real_text_code, real_text = _run_cli(
|
|
"text",
|
|
"--profile",
|
|
str(REAL_PROFILE),
|
|
"--mode",
|
|
"real",
|
|
"--input",
|
|
"请只回答收到。",
|
|
"--output-audio",
|
|
str(generated),
|
|
)
|
|
if generated.is_file():
|
|
real_audio_code, real_audio = _run_cli(
|
|
"audio",
|
|
"--profile",
|
|
str(REAL_PROFILE),
|
|
"--mode",
|
|
"real",
|
|
"--input",
|
|
str(generated),
|
|
)
|
|
else:
|
|
real_audio = {
|
|
"status": "BLOCKED",
|
|
"reason_code": "REAL_TTS_AUDIO_MISSING",
|
|
"message": "real text probe did not produce an audio fixture",
|
|
}
|
|
commands.append(_summary("real-text", real_text_code, real_text))
|
|
commands.append(_summary("real-audio", real_audio_code, real_audio))
|
|
|
|
real_call_evidence_path = evidence_dir / f"real-cell-call-{date}.json"
|
|
if real_call_evidence_path.is_file():
|
|
try:
|
|
loaded_call = json.loads(
|
|
real_call_evidence_path.read_text(encoding="utf-8")
|
|
)
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
loaded_call = None
|
|
if isinstance(loaded_call, dict):
|
|
real_call = loaded_call
|
|
real_call_code = 0 if real_call.get("status") == "PASS" else 1
|
|
else:
|
|
real_call_code = 2
|
|
real_call = {
|
|
"status": "BLOCKED",
|
|
"reason_code": "REAL_CALL_EVIDENCE_INVALID",
|
|
"message": "real Cell evidence exists but is not a valid JSON object",
|
|
}
|
|
else:
|
|
real_call_code = 2
|
|
real_call = {
|
|
"status": "BLOCKED",
|
|
"reason_code": "REAL_CALL_REQUIRES_SEPARATE_AUTHORIZATION",
|
|
"message": "Acceptance automation never dials; use the separately authorized Cell runner.",
|
|
}
|
|
commands.append(_summary("real-call-evidence", real_call_code, real_call))
|
|
|
|
mock_ok = (
|
|
regression.returncode == 0
|
|
and mock_text_code == 0
|
|
and mock_audio_code == 0
|
|
and mock_call_code == 0
|
|
)
|
|
provider_ok = real_text_code == 0 and real_audio_code == 0
|
|
cases: list[dict[str, Any]] = []
|
|
internal_pass = {
|
|
"AI-01": "tenant-scoped immutable snapshot was published and reused",
|
|
"AI-02": "missing, cross-tenant, and same-version conflict checks are covered",
|
|
"AI-03": "Prompt, model/voice, secret, and audio-format validation is covered",
|
|
"AI-04": "version digest is persisted with the execution snapshot",
|
|
"AI-09": "opening and Prompt constraints are configuration-driven in the runtime tests",
|
|
"AI-10": "PCM16/PCMA conversion, frame alignment, and bounded queues passed",
|
|
"AI-11": "cancellation and late-chunk discard passed with isolated providers",
|
|
"AI-12": "silence/no-answer lifecycle remains bounded in the mock executor",
|
|
"AI-13": "hangup/timeout cleanup remains isolated in the mock executor",
|
|
"AI-14": "provider errors and real-mode fail-closed behavior are covered",
|
|
"AI-15": "ARI reconciliation regression remains green for the existing mock path",
|
|
"AI-16": "duplicate publication/execution recovery remains green",
|
|
"AI-17": "text, playback, and MQ result facts remain separate",
|
|
"AI-18": "recording checksum/upload recovery regression remains green",
|
|
"AI-19": "real Cell ARI/RTP worker, queue routing, durable claim ledger, and no-redial tests are covered",
|
|
"AI-21": "reports use hashes and do not include provider credentials or URLs",
|
|
}
|
|
for case_id, actual in internal_pass.items():
|
|
cases.append(
|
|
_case(
|
|
case_id,
|
|
"PASS" if mock_ok else "FAIL",
|
|
actual,
|
|
[
|
|
"tests/test_ai_runtime.py",
|
|
"tests/test_agent_call.py",
|
|
"tests/test_real_cell.py",
|
|
],
|
|
"isolated-mock" if case_id != "AI-19" else "real-cell-code",
|
|
)
|
|
)
|
|
for case_id, label in (
|
|
("AI-05", "real text stream"),
|
|
("AI-06", "real TTS"),
|
|
("AI-07", "real audio chain"),
|
|
):
|
|
report = real_text if case_id in {"AI-05", "AI-06"} else real_audio
|
|
code = real_text_code if case_id in {"AI-05", "AI-06"} else real_audio_code
|
|
status = (
|
|
"PASS"
|
|
if code == 0 and report.get("status") == "PASS"
|
|
else ("FAIL" if real_ready else "BLOCKED")
|
|
)
|
|
if case_id in {"AI-06", "AI-07"} and status == "PASS":
|
|
status = "BLOCKED"
|
|
label += "; machine audio passed; human listening is not registered"
|
|
cases.append(
|
|
_case(
|
|
case_id,
|
|
status,
|
|
f"{label} {'passed' if code == 0 else 'was not completed'}",
|
|
["docs/evidence/llm-voice-acceptance-" + date + ".json"],
|
|
"real-bailian" if real_ready else "real-not-configured",
|
|
)
|
|
)
|
|
cases.append(
|
|
_case(
|
|
"AI-08",
|
|
"FAIL" if real_call_code == 1 else "BLOCKED",
|
|
"real three-round phone evidence is incomplete: SIP may connect, but bidirectional RTP, three AI rounds, recording handoff, and human listening are required",
|
|
[
|
|
str(Path("docs") / "evidence" / real_call_evidence_path.name)
|
|
if real_call_evidence_path.is_file()
|
|
else "docs/LLM与音色可配置电话对话_开发与验收计划_v1.0.md"
|
|
],
|
|
"real-phone-evidence"
|
|
if real_call_evidence_path.is_file()
|
|
else "real-phone-not-run",
|
|
)
|
|
)
|
|
cases.append(
|
|
_case(
|
|
"AI-20",
|
|
"BLOCKED",
|
|
"one-command mock flow passed; full real call flow remains incomplete until real MQ/OSS and phone evidence pass",
|
|
[
|
|
"scripts/test_ai_call.py",
|
|
"docs/evidence/llm-voice-acceptance-" + date + ".json",
|
|
],
|
|
"mixed",
|
|
)
|
|
)
|
|
|
|
blockers = [
|
|
"The single authorized real call reached SIP 200 OK, but usable bidirectional RTP, three AI rounds, and a valid recording were not proven.",
|
|
"The call path used a temporary RabbitMQ test broker; production RabbitMQ/SaaS result and OSS recording handoff remain unverified.",
|
|
"Human listening and provider-specific latency/quality thresholds are not registered; machine-generated WAV evidence cannot replace listening.",
|
|
]
|
|
if not real_ready:
|
|
blockers.insert(
|
|
0, "Bailian environment is incomplete; real provider probes were not run."
|
|
)
|
|
elif not provider_ok:
|
|
blockers.insert(
|
|
0,
|
|
"At least one real Bailian text/audio probe failed; inspect its redacted command summary.",
|
|
)
|
|
|
|
report = {
|
|
"schema_version": "1.0",
|
|
"generated_at": _now().isoformat().replace("+00:00", "Z"),
|
|
"scope": "real-bailian-file-probes-plus-isolated-mock-plus-real-cell-attempts",
|
|
"overall_status": "INCOMPLETE",
|
|
"gate_status": "BLOCKED_BY_REAL_PHONE_MEDIA_MQ_OSS_AND_HUMAN_EVIDENCE",
|
|
"component_modes": {
|
|
"llm": "real-bailian" if real_ready else "blocked",
|
|
"tts": "real-bailian" if real_ready else "blocked",
|
|
"asr": "real-bailian" if real_ready else "blocked",
|
|
"sip_ari_rtp": "partial-real"
|
|
if real_call_evidence_path.is_file()
|
|
else "not-run",
|
|
"database": "isolated-sqlite",
|
|
"rabbitmq": "temporary-test-broker"
|
|
if real_call_evidence_path.is_file()
|
|
else "not-run-real-broker",
|
|
"oss": "not-run",
|
|
},
|
|
"real_provider_preflight": {
|
|
"environment_configured": real_ready,
|
|
"text_code": real_text_code,
|
|
"audio_code": real_audio_code,
|
|
},
|
|
"regression_tests": regression_summary,
|
|
"commands": commands,
|
|
"cases": cases,
|
|
"blockers": blockers,
|
|
"evidence_policy": "docs/evidence contains hashes/status only; raw protocol/audio evidence stays in ignored local paths",
|
|
"test_commands": [
|
|
"python3 -m unittest discover -s tests -v",
|
|
"python3 scripts/test_ai_call.py text --profile configs/ai-test.bailian.example.yaml --mode real --input '请只回答收到。'",
|
|
"python3 scripts/test_ai_call.py audio --profile configs/ai-test.bailian.example.yaml --mode real --input <real-or-authorized-wav>",
|
|
"python3 scripts/run_real_cell_call.py --profile configs/ai-test.bailian.example.yaml --allow-real-call --callee 15003164745",
|
|
],
|
|
}
|
|
evidence_path.write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
print(
|
|
json.dumps(
|
|
{
|
|
"overall_status": report["overall_status"],
|
|
"evidence": str(evidence_path),
|
|
"cases": len(cases),
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
)
|
|
return (
|
|
1
|
|
if not mock_ok or (real_ready and not provider_ok) or real_call_code == 1
|
|
else 2
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|