docs: record three-SIP validation evidence
management-images / build-and-publish (push) Successful in 45s
management-images / build-and-publish (push) Successful in 45s
This commit is contained in:
@@ -123,7 +123,7 @@
|
||||
|
||||
- 本地:Go 1.26、Python 3.11+、Node、Docker Engine/Compose;云操作另需 `aliyun` CLI 和北京区域权限。
|
||||
- 云创建权限至少涉及 ECS Describe/RunInstances、VPC DescribeEipAddresses/AssociateEipAddress;已有 VSwitch、安全组、镜像和 SSH KeyPair。不得自动开放全部端口。
|
||||
- Asterisk:镜像固定 digest;已提供的 SIP 参数见上节,其余供应商/备用地址及接入规则仍待补充。ARI 默认仅回环访问;跨机器使用管理网和受控 TLS。RTP、防火墙、NAT、编解码必须真机验证。
|
||||
- Asterisk:镜像固定 digest;已提供的 SIP 参数见上节,其余供应商/备用地址及接入规则仍待补充。ARI 默认仅回环访问;跨机器使用管理网和受控 TLS。RTP、防火墙、NAT、编解码必须真机验证。诊断阶段的 UDP 放行必须按供应商 SIP/SDP 媒体源 IP 限定,禁止 `0.0.0.0/0`;第一组实测 183 SDP 的媒体源为 `61.132.228.245`,仅可作为临时诊断源,验证后应收窄或移除。
|
||||
- ASR Web:服务访问令牌、百炼/火山凭据;默认仅宿主机回环暴露。远程麦克风需要 HTTPS,或通过 SSH 隧道访问 localhost;不要求用户关闭浏览器安全机制。
|
||||
- 业务对接仍需 RabbitMQ VHost/队列/ACL、用户发布的消息契约、OSS 上传与 OSS ID 规范。测试台音频/识别输出不是已接入 SaaS 的 MQ 结果。
|
||||
- LLM/TTS:供应商、协议、模型、参数、取消/打断与音频契约均待用户新规范;不得静默调用旧实现或宣称完整对话已验证。
|
||||
|
||||
+26
-17
@@ -66,9 +66,7 @@ class CellRoute:
|
||||
if not isinstance(value, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value
|
||||
):
|
||||
raise CellCallError(
|
||||
"ROUTE_CONFIG_INVALID", f"{field_name} is invalid"
|
||||
)
|
||||
raise CellCallError("ROUTE_CONFIG_INVALID", f"{field_name} is invalid")
|
||||
if not isinstance(self.trunk_id, str) or not re.fullmatch(
|
||||
r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", self.trunk_id
|
||||
):
|
||||
@@ -77,8 +75,12 @@ class CellRoute:
|
||||
r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", self.caller_id
|
||||
):
|
||||
raise CellCallError("ROUTE_CONFIG_INVALID", "caller_id is invalid")
|
||||
if not isinstance(self.dial_prefix, str) or len(self.dial_prefix) > 32 or (
|
||||
self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix)
|
||||
if (
|
||||
not isinstance(self.dial_prefix, str)
|
||||
or len(self.dial_prefix) > 32
|
||||
or (
|
||||
self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix)
|
||||
)
|
||||
):
|
||||
raise CellCallError("ROUTE_CONFIG_INVALID", "dial_prefix is invalid")
|
||||
|
||||
@@ -89,9 +91,13 @@ def load_cell_routes(raw: str | None) -> dict[str, CellRoute]:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise CellCallError("ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON is invalid") from exc
|
||||
raise CellCallError(
|
||||
"ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON is invalid"
|
||||
) from exc
|
||||
if not isinstance(data, Mapping):
|
||||
raise CellCallError("ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON must be an object")
|
||||
raise CellCallError(
|
||||
"ROUTE_CONFIG_INVALID", "CELL_ROUTE_MAP_JSON must be an object"
|
||||
)
|
||||
routes: dict[str, CellRoute] = {}
|
||||
for route_policy_id, value in data.items():
|
||||
if not isinstance(value, Mapping):
|
||||
@@ -104,7 +110,8 @@ def load_cell_routes(raw: str | None) -> dict[str, CellRoute]:
|
||||
dial_prefix = value.get("dial_prefix", "")
|
||||
if not isinstance(caller_profile_id, str):
|
||||
raise CellCallError(
|
||||
"ROUTE_CONFIG_INVALID", f"route {route_policy_id!r} has invalid caller profile"
|
||||
"ROUTE_CONFIG_INVALID",
|
||||
f"route {route_policy_id!r} has invalid caller profile",
|
||||
)
|
||||
if not isinstance(trunk_id, str):
|
||||
raise CellCallError(
|
||||
@@ -560,7 +567,11 @@ class RealCellCall:
|
||||
"CALLEE_INVALID", "callee must be the original digit string"
|
||||
)
|
||||
selected_route = route or CellRoute(
|
||||
"legacy", "legacy", self.config.trunk_id, self.config.caller_id, self.config.dial_prefix
|
||||
"legacy",
|
||||
"legacy",
|
||||
self.config.trunk_id,
|
||||
self.config.caller_id,
|
||||
self.config.dial_prefix,
|
||||
)
|
||||
self._reset_call()
|
||||
self.engine.history.clear()
|
||||
@@ -1205,13 +1216,12 @@ class RealCellWorker:
|
||||
self.broker.ack(message)
|
||||
return event
|
||||
|
||||
def _route(
|
||||
self, route_policy_id: str, caller_profile_id: str
|
||||
) -> CellRoute | None:
|
||||
def _route(self, route_policy_id: str, caller_profile_id: str) -> CellRoute | None:
|
||||
if not route_policy_id and not caller_profile_id:
|
||||
if self.routes:
|
||||
raise CellCallError(
|
||||
"ROUTE_POLICY_REQUIRED", "route policy is required on a multi-trunk Cell"
|
||||
"ROUTE_POLICY_REQUIRED",
|
||||
"route policy is required on a multi-trunk Cell",
|
||||
)
|
||||
return None
|
||||
if not route_policy_id or not caller_profile_id:
|
||||
@@ -1234,13 +1244,12 @@ class RealCellWorker:
|
||||
)
|
||||
if route.caller_profile_id != caller_profile_id:
|
||||
raise CellCallError(
|
||||
"CALLER_PROFILE_MISMATCH", "caller profile is not bound to the route policy"
|
||||
"CALLER_PROFILE_MISMATCH",
|
||||
"caller profile is not bound to the route policy",
|
||||
)
|
||||
return route
|
||||
|
||||
def _command(
|
||||
self, body: Any
|
||||
) -> tuple[str, str, str, str, str, str]:
|
||||
def _command(self, body: Any) -> tuple[str, str, str, str, str, str]:
|
||||
if not isinstance(body, Mapping) or body.get("command_type") != "call.execute":
|
||||
raise CellCallError("COMMAND_INVALID", "message is not call.execute")
|
||||
if body.get("tenant_key") != self.tenant_key:
|
||||
|
||||
@@ -136,7 +136,9 @@ def _trunks(cfg):
|
||||
)
|
||||
return rows
|
||||
if "primary" in cfg or "backup" in cfg:
|
||||
raise ValueError("use trunks instead of mixing arbitrary trunks with primary/backup")
|
||||
raise ValueError(
|
||||
"use trunks instead of mixing arbitrary trunks with primary/backup"
|
||||
)
|
||||
raw = cfg["trunks"]
|
||||
if not isinstance(raw, list) or not raw:
|
||||
raise ValueError("trunks must be a non-empty list")
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"record_version": "1.0",
|
||||
"recorded_at_utc": "2026-09-17T07:50:38Z",
|
||||
"scope": "One packet-captured real outbound attempt per independently selectable SIP line.",
|
||||
"code_commit": "e65cfbe",
|
||||
"deployment": {
|
||||
"region": "cn-beijing",
|
||||
"instance_id": "i-2zeimnh6hq53q4srr3o2",
|
||||
"public_eip": "123.56.71.98",
|
||||
"asterisk_container": "agent-call-asterisk",
|
||||
"trunks_loaded": ["provider-primary", "provider-second", "provider-third"],
|
||||
"ari_after_fix": "HTTP and WebSocket smoke tests passed; ari.conf installed readable by asterisk (0640)."
|
||||
},
|
||||
"security": {
|
||||
"diagnostic_udp": "source-limited provider SIP/media addresses only",
|
||||
"global_udp_open": false,
|
||||
"provider_rules_added": [
|
||||
"sgr-2ze2gmif9nlbravo9b9x",
|
||||
"sgr-2ze2gmif9nlbravo9b9w"
|
||||
],
|
||||
"observed_first_provider_media_source": "61.132.228.245"
|
||||
},
|
||||
"ai": {
|
||||
"mode": "real",
|
||||
"stack": "Bailian ASR + Qwen LLM + CosyVoice TTS",
|
||||
"credentials": "runtime environment only; no secret recorded"
|
||||
},
|
||||
"tests": [
|
||||
{
|
||||
"line": "first",
|
||||
"route_policy_id": "route_policy_sip_first",
|
||||
"caller_profile_id": "caller_profile_sip_first",
|
||||
"trunk_id": "provider-primary",
|
||||
"provider": "61.132.228.221:5060",
|
||||
"caller_id": "BD93205882",
|
||||
"original_callee": "15003164745",
|
||||
"effective_target": "708915003164745",
|
||||
"status": "FAIL",
|
||||
"reason_code": "CALL_NOT_ANSWERED",
|
||||
"sip_exchange": ["INVITE", "100 Trying", "183 Session Progress", "486 Busy Here", "ACK"],
|
||||
"rtp_in": 0,
|
||||
"rtp_out": 0,
|
||||
"ai_turns": 0,
|
||||
"recording_valid": false,
|
||||
"recording_bytes": 44,
|
||||
"report": ".local/agent-call-three-tests/results-20260917/first/call-report.json",
|
||||
"pcap": ".local/agent-call-three-tests/results-20260917/first/capture.pcap",
|
||||
"pcap_sha256": "e8cf6aa5d5f039cc93cb81db1a390da9f56f249eb92e7ee6d2b6819772aa6034"
|
||||
},
|
||||
{
|
||||
"line": "second",
|
||||
"route_policy_id": "route_policy_sip_second",
|
||||
"caller_profile_id": "caller_profile_sip_second",
|
||||
"trunk_id": "provider-second",
|
||||
"provider": "60.171.24.90:5060",
|
||||
"caller_id": "mbkq",
|
||||
"original_callee": "18625770806",
|
||||
"effective_target": "18625770806",
|
||||
"status": "FAIL",
|
||||
"reason_code": "CALL_NOT_ANSWERED",
|
||||
"sip_exchange": ["INVITE", "100 Trying", "404 Not Found", "ACK"],
|
||||
"rtp_in": 0,
|
||||
"rtp_out": 0,
|
||||
"ai_turns": 0,
|
||||
"recording_valid": false,
|
||||
"recording_bytes": 44,
|
||||
"report": ".local/agent-call-three-tests/results-20260917/second/call-report.json",
|
||||
"pcap": ".local/agent-call-three-tests/results-20260917/second/capture.pcap",
|
||||
"pcap_sha256": "2a799c3c32754dc2cadee7c064076cb94578b08cdc88766f93140ff25d5cc25c"
|
||||
},
|
||||
{
|
||||
"line": "third",
|
||||
"route_policy_id": "route_policy_sip_third",
|
||||
"caller_profile_id": "caller_profile_sip_third",
|
||||
"trunk_id": "provider-third",
|
||||
"provider": "160.202.254.79:5060",
|
||||
"caller_id": "KQ91526",
|
||||
"original_callee": "18625770806",
|
||||
"effective_target": "mka75518625770806",
|
||||
"status": "FAIL",
|
||||
"reason_code": "CALL_NOT_ANSWERED",
|
||||
"sip_exchange": ["INVITE", "100 Trying", "480 Temporarily Unavailable", "ACK"],
|
||||
"rtp_in": 0,
|
||||
"rtp_out": 0,
|
||||
"ai_turns": 0,
|
||||
"recording_valid": false,
|
||||
"recording_bytes": 44,
|
||||
"report": ".local/agent-call-three-tests/results-20260917/third/call-report.json",
|
||||
"pcap": ".local/agent-call-three-tests/results-20260917/third/capture.pcap",
|
||||
"pcap_sha256": "1c5229757cd53b9384f0221b429f4629f0cd60e7f00bcb575b4bf8eb4e0416f8"
|
||||
}
|
||||
],
|
||||
"conclusion": {
|
||||
"route_selection": "verified: each task used its intended preloaded trunk",
|
||||
"signaling": "verified through provider-specific SIP captures",
|
||||
"answer": "not reached on any attempt",
|
||||
"rtp": "not exercised because no call reached 200 OK with SDP",
|
||||
"llm_asr_tts": "not exercised; no media or AI turns were possible",
|
||||
"acceptance": "blocked pending provider-side answer/caller authorization and a post-answer RTP test"
|
||||
},
|
||||
"notes": [
|
||||
"No automatic retry, redial, or trunk failover was performed.",
|
||||
"The 486, 404, and 480 responses are provider/pre-answer failures and do not establish a post-answer RTP diagnosis.",
|
||||
"The first provider advertised media address 61.132.228.245 in 183 SDP; it was added as a source-limited diagnostic address afterward."
|
||||
]
|
||||
}
|
||||
@@ -294,7 +294,11 @@ class RealCellTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3")
|
||||
fake_result = SimpleNamespace(
|
||||
as_dict=lambda: {"call_id": "call-route", "status": "failed", "turns": []}
|
||||
as_dict=lambda: {
|
||||
"call_id": "call-route",
|
||||
"status": "failed",
|
||||
"turns": [],
|
||||
}
|
||||
)
|
||||
|
||||
class FakeExecutor:
|
||||
|
||||
Reference in New Issue
Block a user