453 lines
18 KiB
Python
Executable File
453 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""One-command text, audio, or explicit MQ AI probe.
|
|
|
|
Mock is isolated by default. The Bailian adapter uses only server-side
|
|
BAILIAN_* environment variables; real call mode still fails closed until the
|
|
Cell/media authorization and real-call evidence gate is explicitly arranged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import importlib
|
|
import json
|
|
import sys
|
|
import tempfile
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
_ai_runtime = importlib.import_module("agent_call.ai_runtime")
|
|
_core = importlib.import_module("agent_call.core")
|
|
_bailian = importlib.import_module("agent_call.bailian")
|
|
AIConfigError = _ai_runtime.AIConfigError
|
|
AIProviderError = _ai_runtime.AIProviderError
|
|
ConversationEngine = _ai_runtime.ConversationEngine
|
|
build_mock_config = _ai_runtime.build_mock_config
|
|
config_digest = _ai_runtime.config_digest
|
|
load_prompt = _ai_runtime.load_prompt
|
|
pcm_to_wav = _ai_runtime.pcm_to_wav
|
|
validate_wav = _ai_runtime.validate_wav
|
|
BailianASR = _bailian.BailianASR
|
|
BailianLLM = _bailian.BailianLLM
|
|
BailianTTS = _bailian.BailianTTS
|
|
build_bailian_config = _bailian.build_bailian_config
|
|
AgentCallService = _core.AgentCallService
|
|
ServiceError = _core.ServiceError
|
|
|
|
DEFAULT_PROFILE = ROOT / "configs" / "ai-test.example.yaml"
|
|
|
|
|
|
def _load_data(path: Path) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
try:
|
|
import yaml # type: ignore[import-not-found]
|
|
except ImportError as exc:
|
|
raise AIConfigError(
|
|
"PROFILE_FORMAT_UNSUPPORTED",
|
|
"profile must be JSON-compatible YAML when PyYAML is unavailable",
|
|
) from exc
|
|
value = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
if not isinstance(value, dict):
|
|
raise AIConfigError("PROFILE_INVALID", "profile must be an object")
|
|
return value
|
|
|
|
|
|
def _path(value: str) -> Path:
|
|
candidate = Path(value)
|
|
return candidate if candidate.is_absolute() else ROOT / candidate
|
|
|
|
|
|
def _iso_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def _safe_result(result: dict[str, Any]) -> dict[str, Any]:
|
|
clean = dict(result)
|
|
if "audio" in clean:
|
|
audio = clean.pop("audio")
|
|
if isinstance(audio, bytes):
|
|
clean["audio_bytes"] = len(audio)
|
|
clean["audio_sha256"] = hashlib.sha256(audio).hexdigest()
|
|
if isinstance(clean.get("segments"), list):
|
|
clean["segments"] = [
|
|
{key: value for key, value in segment.items() if key != "audio"}
|
|
for segment in clean["segments"]
|
|
if isinstance(segment, dict)
|
|
]
|
|
return clean
|
|
|
|
|
|
def _write_report(report: dict[str, Any], output: str | None) -> None:
|
|
payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
if output:
|
|
destination = _path(output)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(payload, encoding="utf-8")
|
|
print(payload, end="")
|
|
|
|
|
|
def _args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("kind", choices=("text", "audio", "call"))
|
|
parser.add_argument("--profile", default=str(DEFAULT_PROFILE))
|
|
parser.add_argument("--mode", choices=("mock", "mixed", "real"))
|
|
parser.add_argument("--prompt-file", default="prompts/test-call.txt")
|
|
parser.add_argument(
|
|
"--input", help="UTF-8 text for text mode or mono PCM16 WAV for audio mode"
|
|
)
|
|
parser.add_argument("--model", default=None)
|
|
parser.add_argument("--tts-model", default=None)
|
|
parser.add_argument("--voice", default=None)
|
|
parser.add_argument("--language", default=None)
|
|
parser.add_argument(
|
|
"--max-duration", type=int, default=None, help="call duration limit in seconds"
|
|
)
|
|
parser.add_argument("--tenant-id", default=None)
|
|
parser.add_argument("--task-id", default=None)
|
|
parser.add_argument("--callee", default=None)
|
|
parser.add_argument("--agent-version-id", default=None)
|
|
parser.add_argument(
|
|
"--db",
|
|
default=None,
|
|
help="SQLite path for call mode or durable config evidence",
|
|
)
|
|
parser.add_argument("--output", default=None, help="JSON report path")
|
|
parser.add_argument(
|
|
"--output-audio", default=None, help="WAV response path for text/audio mode"
|
|
)
|
|
parser.add_argument(
|
|
"--allow-real-call",
|
|
action="store_true",
|
|
help="explicitly acknowledge that call mode could place a call; mock never dials",
|
|
)
|
|
parser.add_argument(
|
|
"--human-listening",
|
|
choices=("pass", "fail", "not_run"),
|
|
default="not_run",
|
|
help="manual listening result; it is evidence only, not a provider claim",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _config(
|
|
settings: dict[str, Any], args: argparse.Namespace, mode: str
|
|
) -> dict[str, Any]:
|
|
prompt = load_prompt(
|
|
_path(args.prompt_file), int(settings.get("max_prompt_bytes", 32768))
|
|
)
|
|
version_id = args.agent_version_id or settings.get(
|
|
"agent_version_id", "agent_test_v1"
|
|
)
|
|
if mode == "mock":
|
|
config = build_mock_config(
|
|
str(version_id),
|
|
prompt,
|
|
str(args.model or settings.get("model", "mock-chat-v1")),
|
|
str(args.tts_model or settings.get("tts_model", "mock-tts-v1")),
|
|
str(args.voice or settings.get("voice", "mock-neutral")),
|
|
)
|
|
else:
|
|
config = build_bailian_config(
|
|
str(version_id),
|
|
prompt,
|
|
str(args.model or settings.get("model", "qwen-plus")),
|
|
args.tts_model or settings.get("tts_model"),
|
|
args.voice or settings.get("voice"),
|
|
str(args.language or settings.get("language", "zh-CN")),
|
|
settings.get("asr_model"),
|
|
str(settings.get("opening", "")),
|
|
int(settings.get("max_turns", 20)),
|
|
int(settings.get("max_duration_ms", 120000)),
|
|
)
|
|
return config
|
|
|
|
|
|
def _base_report(
|
|
settings: dict[str, Any], args: argparse.Namespace, config: dict[str, Any]
|
|
) -> dict[str, Any]:
|
|
provider_modes = settings.get("provider_modes", {})
|
|
return {
|
|
"schema_version": "1.0",
|
|
"created_at": _iso_now(),
|
|
"mode": settings.get("mode", "mock"),
|
|
"requested_mode": args.mode or settings.get("mode", "mock"),
|
|
"provider_modes": provider_modes,
|
|
"agent_version_id": config["agent_version_id"],
|
|
"agent_config_sha256": config_digest(config),
|
|
"prompt_sha256": hashlib.sha256(
|
|
config["prompt"]["text"].encode("utf-8")
|
|
).hexdigest(),
|
|
"prompt_bytes": len(config["prompt"]["text"].encode("utf-8")),
|
|
"llm": {
|
|
"provider_ref": config["llm"]["provider_ref"],
|
|
"requested_model": config["llm"]["model"],
|
|
"provider_returned_model": None,
|
|
"provider_returned_model_verified": False,
|
|
},
|
|
"tts": {
|
|
"provider_ref": config["tts"]["provider_ref"],
|
|
"requested_model": config["tts"]["model"],
|
|
"provider_returned_model": None,
|
|
"provider_returned_model_verified": False,
|
|
"voice": config["tts"]["voice"],
|
|
"voice_listening_verified": False,
|
|
"format": config["tts"]["format"],
|
|
},
|
|
"asr": {
|
|
"provider_ref": config["asr"]["provider_ref"],
|
|
"language": config["asr"]["language"],
|
|
},
|
|
"human_listening": args.human_listening,
|
|
}
|
|
|
|
|
|
def _run_real_cell(
|
|
args: argparse.Namespace, settings: dict[str, Any], service_settings: dict[str, Any]
|
|
) -> tuple[int, dict[str, Any]]:
|
|
tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo"))
|
|
tenant = next(
|
|
(
|
|
item
|
|
for item in service_settings.get("tenants", [])
|
|
if item.get("tenant_id") == tenant_id
|
|
),
|
|
None,
|
|
)
|
|
if not isinstance(tenant, dict) or not isinstance(tenant.get("tenant_key"), str):
|
|
raise AIConfigError(
|
|
"TENANT_NOT_CONFIGURED", "real Cell call tenant is not configured"
|
|
)
|
|
runner = importlib.import_module("scripts.run_real_cell_call")
|
|
real_args = argparse.Namespace(
|
|
profile=args.profile,
|
|
prompt_file=args.prompt_file,
|
|
callee=args.callee or settings.get("callee", ""),
|
|
tenant_id=tenant_id,
|
|
tenant_key=tenant["tenant_key"],
|
|
broker_url=None,
|
|
ledger=args.db or str(ROOT / ".local" / "agent-call-ai" / "real-cell.sqlite3"),
|
|
wait_seconds=240,
|
|
allow_real_call=args.allow_real_call,
|
|
output=None,
|
|
)
|
|
report = runner.run(real_args)
|
|
return (0 if report.get("status") == "PASS" else 1), report
|
|
|
|
|
|
def _run(args: argparse.Namespace) -> tuple[int, dict[str, Any]]:
|
|
settings = _load_data(_path(args.profile))
|
|
service_profile = _path(
|
|
str(settings.get("service_profile", "docs/contracts/mock-profile.json"))
|
|
)
|
|
service_settings = _load_data(service_profile)
|
|
requested_mode = args.mode or str(
|
|
settings.get("mode", service_settings.get("mode", "mock"))
|
|
)
|
|
if requested_mode not in {"mock", "mixed", "real"}:
|
|
raise AIConfigError("MODE_INVALID", "mode must be mock, mixed, or real")
|
|
if args.kind == "call" and requested_mode == "real":
|
|
if not args.allow_real_call:
|
|
return 2, {
|
|
"schema_version": "1.0",
|
|
"status": "BLOCKED",
|
|
"requested_mode": requested_mode,
|
|
"reason_code": "CALL_AUTHORIZATION_REQUIRED",
|
|
"message": "call mode requires --allow-real-call before the RabbitMQ Cell worker is started.",
|
|
}
|
|
return _run_real_cell(args, settings, service_settings)
|
|
if requested_mode != "mock" and args.kind == "call":
|
|
return 2, {
|
|
"schema_version": "1.0",
|
|
"status": "BLOCKED",
|
|
"requested_mode": requested_mode,
|
|
"reason_code": "REAL_CALL_MODE_REQUIRED",
|
|
"message": "Only the explicit real profile may start the Cell/media executor; no call was placed.",
|
|
}
|
|
config = _config(settings, args, requested_mode)
|
|
report_settings = dict(settings)
|
|
report_settings["mode"] = requested_mode
|
|
report_settings["provider_modes"] = settings.get(
|
|
"provider_modes", service_settings.get("provider_modes", {})
|
|
)
|
|
report = _base_report(report_settings, args, config)
|
|
db_path: str
|
|
temporary_db: tempfile.TemporaryDirectory[str] | None = None
|
|
if args.db:
|
|
db_path = str(_path(args.db))
|
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
else:
|
|
temporary_db = tempfile.TemporaryDirectory(prefix="agent-call-ai-")
|
|
db_path = str(Path(temporary_db.name) / "state.sqlite3")
|
|
service = AgentCallService(
|
|
db_path=db_path, profile_path=service_profile, start_background=False
|
|
)
|
|
try:
|
|
tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo"))
|
|
published = service.publish_agent_version(
|
|
tenant_id,
|
|
config["agent_version_id"],
|
|
config,
|
|
actor_id="ai-test-cli",
|
|
)
|
|
trusted = service.get_agent_version(tenant_id, config["agent_version_id"])
|
|
report["publication"] = published
|
|
report["trusted_snapshot"] = {
|
|
"agent_version_id": trusted["agent_version_id"],
|
|
"content_sha256": trusted["content_sha256"],
|
|
"immutable": trusted["immutable"],
|
|
}
|
|
engine_kwargs: dict[str, Any] = {"journal": service.journal}
|
|
if requested_mode != "mock":
|
|
engine_kwargs["llm"] = BailianLLM.from_env()
|
|
engine_kwargs["tts"] = BailianTTS.from_env()
|
|
if trusted["config"]["asr"]["provider_ref"] != "mock":
|
|
engine_kwargs["asr"] = BailianASR.from_env()
|
|
engine = ConversationEngine(trusted["config"], **engine_kwargs)
|
|
if args.kind == "text":
|
|
if args.input is None:
|
|
raise AIConfigError(
|
|
"TEXT_INPUT_REQUIRED", "--input is required for text mode"
|
|
)
|
|
result = engine.run_text(args.input)
|
|
elif args.kind == "audio":
|
|
if args.input is None:
|
|
raise AIConfigError(
|
|
"AUDIO_INPUT_REQUIRED", "--input is required for audio mode"
|
|
)
|
|
audio_path = _path(args.input)
|
|
audio = audio_path.read_bytes()
|
|
result = engine.run_audio(audio)
|
|
else:
|
|
if not args.allow_real_call:
|
|
report.update(
|
|
{
|
|
"status": "BLOCKED",
|
|
"reason_code": "CALL_AUTHORIZATION_REQUIRED",
|
|
"message": "call mode requires --allow-real-call even though the selected profile is Mock.",
|
|
}
|
|
)
|
|
return 2, report
|
|
callee = str(args.callee or settings.get("callee", ""))
|
|
authorized = {
|
|
str(value) for value in settings.get("authorized_callees", [callee])
|
|
}
|
|
if callee not in authorized:
|
|
raise AIConfigError(
|
|
"CALLEE_NOT_AUTHORIZED",
|
|
"callee is not in the explicit test allow-list",
|
|
)
|
|
fixture = _load_data(ROOT / "docs/contracts/examples/call.execute.json")
|
|
now = datetime.now(timezone.utc)
|
|
fixture["command_id"] = f"cmd_ai_{uuid.uuid4().hex}"
|
|
fixture["trace_id"] = f"trace_ai_{uuid.uuid4().hex}"
|
|
fixture["issued_at"] = now.isoformat().replace("+00:00", "Z")
|
|
fixture["not_after"] = (
|
|
now.replace(year=now.year + 1).isoformat().replace("+00:00", "Z")
|
|
)
|
|
fixture["tenant_id"] = tenant_id
|
|
fixture["tenant_key"] = next(
|
|
item["tenant_key"]
|
|
for item in service.profile["tenants"]
|
|
if item["tenant_id"] == tenant_id
|
|
)
|
|
fixture["payload"]["execution_id"] = f"exec_ai_{uuid.uuid4().hex}"
|
|
fixture["payload"]["task_id"] = str(
|
|
args.task_id or settings.get("task_id", "task-demo")
|
|
)
|
|
fixture["payload"]["callee"] = callee
|
|
fixture["payload"]["agent_version_id"] = config["agent_version_id"]
|
|
if args.max_duration is not None:
|
|
if args.max_duration < 1:
|
|
raise AIConfigError(
|
|
"MAX_DURATION_INVALID", "--max-duration must be positive"
|
|
)
|
|
fixture["payload"]["max_call_duration_ms"] = args.max_duration * 1000
|
|
published_command = service.publish_execute(fixture)
|
|
service.wait_for_idle(10)
|
|
call_row = service.store.one(
|
|
"SELECT call_id FROM calls WHERE execution_id=?",
|
|
(fixture["payload"]["execution_id"],),
|
|
)
|
|
if call_row is None:
|
|
raise ServiceError(
|
|
"CALL_NOT_CREATED", "mock executor did not reserve a call"
|
|
)
|
|
result = service.get_call(tenant_id, call_row["call_id"])
|
|
report["mq_publication"] = published_command
|
|
report["call_id"] = call_row["call_id"]
|
|
clean = _safe_result(result)
|
|
if result.get("provider_returned_model"):
|
|
report["llm"]["provider_returned_model"] = result["provider_returned_model"]
|
|
report["llm"]["provider_returned_model_verified"] = True
|
|
if result.get("tts_provider_returned_model"):
|
|
report["tts"]["provider_returned_model"] = result[
|
|
"tts_provider_returned_model"
|
|
]
|
|
report["tts"]["provider_returned_model_verified"] = True
|
|
if isinstance(result.get("tts_model_evidence"), dict):
|
|
report["tts"]["model_evidence"] = result["tts_model_evidence"]
|
|
if args.kind in {"text", "audio"}:
|
|
raw_audio = result.get("audio", b"")
|
|
if isinstance(raw_audio, bytes) and raw_audio:
|
|
wav = pcm_to_wav(
|
|
raw_audio, int(config["tts"]["format"]["sample_rate_hz"])
|
|
)
|
|
validate_wav(wav)
|
|
if args.output_audio:
|
|
destination = _path(args.output_audio)
|
|
else:
|
|
destination = (
|
|
ROOT
|
|
/ ".local"
|
|
/ "agent-call-ai"
|
|
/ f"response-{uuid.uuid4().hex}.wav"
|
|
)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_bytes(wav)
|
|
report["audio_output"] = {
|
|
"path": str(destination),
|
|
"bytes": len(wav),
|
|
"sha256": hashlib.sha256(wav).hexdigest(),
|
|
}
|
|
report["result"] = clean
|
|
result_status = (
|
|
clean.get("status") or result.get("outcome") or result.get("call_state")
|
|
)
|
|
report["status"] = (
|
|
"PASS" if result_status in {"completed", "succeeded", "ended"} else "FAIL"
|
|
)
|
|
return (0 if report["status"] == "PASS" else 1), report
|
|
finally:
|
|
service.store.close()
|
|
if temporary_db is not None:
|
|
temporary_db.cleanup()
|
|
|
|
|
|
def main() -> int:
|
|
args = _args()
|
|
try:
|
|
code, report = _run(args)
|
|
except (AIConfigError, AIProviderError, ServiceError, OSError, ValueError) as exc:
|
|
code = 1
|
|
report = {
|
|
"schema_version": "1.0",
|
|
"status": "FAIL",
|
|
"reason_code": getattr(exc, "code", type(exc).__name__),
|
|
"message": str(exc),
|
|
}
|
|
_write_report(report, args.output)
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|