docs: record three-SIP validation evidence
management-images / build-and-publish (push) Successful in 45s

This commit is contained in:
2026-09-17 16:30:52 +08:00
parent e65cfbe3af
commit 627ce92041
5 changed files with 141 additions and 20 deletions
+1 -1
View File
@@ -123,7 +123,7 @@
- 本地:Go 1.26、Python 3.11+、Node、Docker Engine/Compose;云操作另需 `aliyun` CLI 和北京区域权限。 - 本地:Go 1.26、Python 3.11+、Node、Docker Engine/Compose;云操作另需 `aliyun` CLI 和北京区域权限。
- 云创建权限至少涉及 ECS Describe/RunInstances、VPC DescribeEipAddresses/AssociateEipAddress;已有 VSwitch、安全组、镜像和 SSH KeyPair。不得自动开放全部端口。 - 云创建权限至少涉及 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;不要求用户关闭浏览器安全机制。 - ASR Web:服务访问令牌、百炼/火山凭据;默认仅宿主机回环暴露。远程麦克风需要 HTTPS,或通过 SSH 隧道访问 localhost;不要求用户关闭浏览器安全机制。
- 业务对接仍需 RabbitMQ VHost/队列/ACL、用户发布的消息契约、OSS 上传与 OSS ID 规范。测试台音频/识别输出不是已接入 SaaS 的 MQ 结果。 - 业务对接仍需 RabbitMQ VHost/队列/ACL、用户发布的消息契约、OSS 上传与 OSS ID 规范。测试台音频/识别输出不是已接入 SaaS 的 MQ 结果。
- LLM/TTS:供应商、协议、模型、参数、取消/打断与音频契约均待用户新规范;不得静默调用旧实现或宣称完整对话已验证。 - LLM/TTS:供应商、协议、模型、参数、取消/打断与音频契约均待用户新规范;不得静默调用旧实现或宣称完整对话已验证。
+26 -17
View File
@@ -66,9 +66,7 @@ class CellRoute:
if not isinstance(value, str) or not re.fullmatch( if not isinstance(value, str) or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}", value
): ):
raise CellCallError( raise CellCallError("ROUTE_CONFIG_INVALID", f"{field_name} is invalid")
"ROUTE_CONFIG_INVALID", f"{field_name} is invalid"
)
if not isinstance(self.trunk_id, str) or not re.fullmatch( 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 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 r"[A-Za-z0-9_.+\- ]+(?:<[A-Za-z0-9_.+\- ]+>)?", self.caller_id
): ):
raise CellCallError("ROUTE_CONFIG_INVALID", "caller_id is invalid") raise CellCallError("ROUTE_CONFIG_INVALID", "caller_id is invalid")
if not isinstance(self.dial_prefix, str) or len(self.dial_prefix) > 32 or ( if (
self.dial_prefix and not re.fullmatch(r"[A-Za-z0-9]+", self.dial_prefix) 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") raise CellCallError("ROUTE_CONFIG_INVALID", "dial_prefix is invalid")
@@ -89,9 +91,13 @@ def load_cell_routes(raw: str | None) -> dict[str, CellRoute]:
try: try:
data = json.loads(raw) data = json.loads(raw)
except (TypeError, json.JSONDecodeError) as exc: 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): 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] = {} routes: dict[str, CellRoute] = {}
for route_policy_id, value in data.items(): for route_policy_id, value in data.items():
if not isinstance(value, Mapping): 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", "") dial_prefix = value.get("dial_prefix", "")
if not isinstance(caller_profile_id, str): if not isinstance(caller_profile_id, str):
raise CellCallError( 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): if not isinstance(trunk_id, str):
raise CellCallError( raise CellCallError(
@@ -560,7 +567,11 @@ class RealCellCall:
"CALLEE_INVALID", "callee must be the original digit string" "CALLEE_INVALID", "callee must be the original digit string"
) )
selected_route = route or CellRoute( 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._reset_call()
self.engine.history.clear() self.engine.history.clear()
@@ -1205,13 +1216,12 @@ class RealCellWorker:
self.broker.ack(message) self.broker.ack(message)
return event return event
def _route( def _route(self, route_policy_id: str, caller_profile_id: str) -> CellRoute | None:
self, route_policy_id: str, caller_profile_id: str
) -> CellRoute | None:
if not route_policy_id and not caller_profile_id: if not route_policy_id and not caller_profile_id:
if self.routes: if self.routes:
raise CellCallError( 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 return None
if not route_policy_id or not caller_profile_id: if not route_policy_id or not caller_profile_id:
@@ -1234,13 +1244,12 @@ class RealCellWorker:
) )
if route.caller_profile_id != caller_profile_id: if route.caller_profile_id != caller_profile_id:
raise CellCallError( 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 return route
def _command( def _command(self, body: Any) -> tuple[str, str, str, str, str, str]:
self, body: Any
) -> tuple[str, str, str, str, str, str]:
if not isinstance(body, Mapping) or body.get("command_type") != "call.execute": if not isinstance(body, Mapping) or body.get("command_type") != "call.execute":
raise CellCallError("COMMAND_INVALID", "message is not call.execute") raise CellCallError("COMMAND_INVALID", "message is not call.execute")
if body.get("tenant_key") != self.tenant_key: if body.get("tenant_key") != self.tenant_key:
+3 -1
View File
@@ -136,7 +136,9 @@ def _trunks(cfg):
) )
return rows return rows
if "primary" in cfg or "backup" in cfg: 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"] raw = cfg["trunks"]
if not isinstance(raw, list) or not raw: if not isinstance(raw, list) or not raw:
raise ValueError("trunks must be a non-empty list") 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."
]
}
+5 -1
View File
@@ -294,7 +294,11 @@ class RealCellTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3") ledger = CellExecutionLedger(Path(directory) / "ledger.sqlite3")
fake_result = SimpleNamespace( 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: class FakeExecutor: