261 lines
9.0 KiB
Python
Executable File
261 lines
9.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Run one explicitly authorized Cell call through the tenant RabbitMQ queue."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import wave
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
def _data(path: Path) -> dict[str, Any]:
|
|
try:
|
|
value = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise ValueError(f"profile is unavailable: {path}") from exc
|
|
if not isinstance(value, dict):
|
|
raise TypeError("profile must be an object")
|
|
return value
|
|
|
|
|
|
def _path(value: str) -> Path:
|
|
candidate = Path(value)
|
|
return candidate if candidate.is_absolute() else ROOT / candidate
|
|
|
|
|
|
def _integer(value: Any, default: int, field: str, minimum: int = 1) -> int:
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError, OverflowError) as exc:
|
|
raise ValueError(f"{field} must be an integer") from exc
|
|
if parsed < minimum:
|
|
raise ValueError(f"{field} must be at least {minimum}")
|
|
return parsed
|
|
|
|
|
|
def _args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--profile", default="configs/ai-test.bailian.example.yaml")
|
|
parser.add_argument("--prompt-file", default="prompts/test-call.txt")
|
|
parser.add_argument("--callee", required=True)
|
|
parser.add_argument("--tenant-id", default=None)
|
|
parser.add_argument("--tenant-key", default=None)
|
|
parser.add_argument("--route-policy-id", default="route_policy_test")
|
|
parser.add_argument("--caller-profile-id", default="caller_profile_test")
|
|
parser.add_argument("--broker-url", default=None)
|
|
parser.add_argument("--ledger", default="/data/agent-call-cell.sqlite3")
|
|
parser.add_argument("--wait-seconds", type=int, default=240)
|
|
parser.add_argument("--allow-real-call", action="store_true")
|
|
parser.add_argument("--output", default=None)
|
|
return parser.parse_args()
|
|
|
|
|
|
def _command(
|
|
settings: dict[str, Any],
|
|
args: argparse.Namespace,
|
|
tenant_id: str,
|
|
tenant_key: str,
|
|
agent_version_id: str,
|
|
) -> dict[str, Any]:
|
|
now_timestamp = time.time()
|
|
issued_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_timestamp))
|
|
not_after = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now_timestamp + 300))
|
|
execution_id = f"exec_cell_{time.time_ns()}_{os.getpid()}"
|
|
return {
|
|
"schema_version": "1.0",
|
|
"command_type": "call.execute",
|
|
"command_id": f"cmd_cell_{execution_id}",
|
|
"tenant_id": tenant_id,
|
|
"tenant_key": tenant_key,
|
|
"trace_id": f"trace_cell_{execution_id}",
|
|
"issued_at": issued_at,
|
|
"not_after": not_after,
|
|
"payload": {
|
|
"execution_id": execution_id,
|
|
"task_id": str(settings.get("task_id", "task-ai-cell")),
|
|
"task_item_id": f"item_{execution_id}",
|
|
"task_revision": 1,
|
|
"callee": args.callee,
|
|
"route_policy_id": args.route_policy_id,
|
|
"caller_profile_id": args.caller_profile_id,
|
|
"agent_version_id": agent_version_id,
|
|
"variables": {},
|
|
"ring_timeout_ms": 30000,
|
|
"max_call_duration_ms": _integer(
|
|
settings.get("max_duration_ms", 180000),
|
|
180000,
|
|
"max_duration_ms",
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
def _recording(path: str) -> dict[str, Any] | None:
|
|
recording = Path(path)
|
|
if not recording.is_file() or recording.is_symlink():
|
|
return None
|
|
try:
|
|
with wave.open(str(recording), "rb") as source:
|
|
evidence = {
|
|
"bytes": recording.stat().st_size,
|
|
"sha256": hashlib.sha256(recording.read_bytes()).hexdigest(),
|
|
"channels": source.getnchannels(),
|
|
"sample_rate_hz": source.getframerate(),
|
|
"frames": source.getnframes(),
|
|
"valid_wav": source.getnframes() > 0 and source.getnchannels() == 1,
|
|
}
|
|
except (OSError, EOFError, wave.Error):
|
|
return None
|
|
return evidence
|
|
|
|
|
|
def _write(report: dict[str, Any], output: str | None) -> None:
|
|
payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
if output:
|
|
destination = _path(output)
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(payload, encoding="utf-8")
|
|
print(payload, end="")
|
|
|
|
|
|
def run(args: argparse.Namespace) -> dict[str, Any]:
|
|
if not args.allow_real_call:
|
|
raise ValueError("--allow-real-call is required")
|
|
from agent_call.ai_runtime import ConversationEngine, load_prompt
|
|
from agent_call.bailian import (
|
|
BailianASR,
|
|
BailianLLM,
|
|
BailianTTS,
|
|
build_bailian_config,
|
|
)
|
|
from agent_call.core import PikaBroker
|
|
from agent_call.real_cell import (
|
|
CellCallConfig,
|
|
CellExecutionLedger,
|
|
RealCellCall,
|
|
RealCellWorker,
|
|
load_cell_routes,
|
|
)
|
|
|
|
settings = _data(_path(args.profile))
|
|
authorized = {str(value) for value in settings.get("authorized_callees", [])}
|
|
if args.callee not in authorized:
|
|
raise ValueError("callee is not in the profile allow-list")
|
|
tenant_id = str(args.tenant_id or settings.get("tenant_id", "tenant-demo"))
|
|
tenant_key = str(args.tenant_key or settings.get("tenant_key", "tenant-demo-key"))
|
|
agent_version_id = str(settings.get("agent_version_id", "agent_bailian_test_v1"))
|
|
try:
|
|
prompt = load_prompt(
|
|
_path(args.prompt_file),
|
|
_integer(
|
|
settings.get("max_prompt_bytes", 32768), 32768, "max_prompt_bytes"
|
|
),
|
|
)
|
|
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
raise ValueError("prompt is unavailable") from exc
|
|
config = build_bailian_config(
|
|
agent_version_id,
|
|
prompt,
|
|
str(settings.get("model", "qwen-plus")),
|
|
settings.get("tts_model"),
|
|
settings.get("voice"),
|
|
str(settings.get("language", "zh-CN")),
|
|
settings.get("asr_model"),
|
|
str(settings.get("opening", "")),
|
|
min(3, _integer(settings.get("max_turns", 3), 3, "max_turns")),
|
|
_integer(settings.get("max_duration_ms", 180000), 180000, "max_duration_ms"),
|
|
)
|
|
engine = ConversationEngine(
|
|
config,
|
|
llm=BailianLLM.from_env(),
|
|
tts=BailianTTS.from_env(),
|
|
asr=BailianASR.from_env(),
|
|
)
|
|
broker = PikaBroker(args.broker_url or os.environ.get("RABBITMQ_URL", ""))
|
|
ledger = CellExecutionLedger(args.ledger)
|
|
executor = RealCellCall(CellCallConfig.from_env(), engine, BailianASR.from_env())
|
|
worker = RealCellWorker(
|
|
broker,
|
|
tenant_key,
|
|
ledger,
|
|
executor,
|
|
routes=load_cell_routes(os.environ.get("CELL_ROUTE_MAP_JSON")),
|
|
)
|
|
command = _command(settings, args, tenant_id, tenant_key, agent_version_id)
|
|
route = f"agent-call.tenant.{tenant_key}.call.execute"
|
|
broker.publish(
|
|
"agent-call.commands.v1", route, command, message_id=command["command_id"]
|
|
)
|
|
deadline = time.monotonic() + args.wait_seconds
|
|
event: dict[str, Any] | None = None
|
|
while time.monotonic() < deadline:
|
|
event = worker.process_once()
|
|
if event and event.get("event_type") == "call.finished":
|
|
break
|
|
time.sleep(0.2)
|
|
if event is None or event.get("event_type") != "call.finished":
|
|
raise TimeoutError("Cell call did not finish before the bounded wait")
|
|
result = ledger.result(command["payload"]["execution_id"]) or {}
|
|
result_status = event["payload"].get("status")
|
|
recording_path = result.get("recording_path")
|
|
recording = _recording(recording_path) if isinstance(recording_path, str) else None
|
|
passed = (
|
|
result_status == "completed"
|
|
and len(event["payload"].get("turns", [])) >= 3
|
|
and bool(recording and recording.get("valid_wav"))
|
|
)
|
|
report: dict[str, Any] = {
|
|
"schema_version": "1.0",
|
|
"status": "PASS" if passed else "FAIL",
|
|
"mode": "real",
|
|
"call_execution": {
|
|
"execution_id": command["payload"]["execution_id"],
|
|
"tenant_id": tenant_id,
|
|
"tenant_key": tenant_key,
|
|
"callee": args.callee,
|
|
"event_id": event["event_id"],
|
|
},
|
|
"result": event["payload"],
|
|
}
|
|
if recording is not None:
|
|
report["recording"] = recording
|
|
return report
|
|
|
|
|
|
def main() -> int:
|
|
args = _args()
|
|
try:
|
|
report = run(args)
|
|
code = 0 if report.get("status") == "PASS" else 1
|
|
except (
|
|
OSError,
|
|
RuntimeError,
|
|
TimeoutError,
|
|
TypeError,
|
|
ValueError,
|
|
KeyError,
|
|
) as exc:
|
|
report = {
|
|
"schema_version": "1.0",
|
|
"status": "FAIL",
|
|
"reason_code": type(exc).__name__,
|
|
"message": str(exc),
|
|
}
|
|
code = 1
|
|
_write(report, args.output)
|
|
return code
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|