Use pinned shared Gitee contracts as the sole runtime source
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
[submodule "contracts/schema"]
|
||||
path = contracts/schema
|
||||
url = git@gitee.com:zzmbac/sip-contracts.git
|
||||
branch = main
|
||||
@@ -3,6 +3,7 @@
|
||||
## 宪法
|
||||
|
||||
- 任何涉及文件的调研或修改,如果当前是 git 仓库,需要先同步远程提交到本地,避免调研过时问题。
|
||||
- SaaS↔Dispatcher 最终契约唯一来源为 `git@gitee.com:zzmbac/sip-contracts.git`,本项目只通过 `contracts/schema` Git submodule 引用,不再维护 `contracts/local` 或独立 Schema 副本。每次调研或修改契约前必须执行 `make contracts-update` 获取并快进到共享仓库最新 `main`,失败即停止,不覆盖现存修改、不回退旧文件。所有契约变更必须同步修改本项目的 submodule 指针、实现、测试及相关文档,并通知 SaaS 开发人员;共享仓库单独提交不算完成。普通构建只使用固定提交,不自动联网追新。流程见 [`docs/contracts.md`](docs/contracts.md)。
|
||||
- 基于 TDD 进行功能的开发与业务变更,单元测试覆盖率要保证 65% 以上
|
||||
- 任何时候我提出任何需求均需要理解并**结构化复述后与我进行确认,避免理解偏差**。
|
||||
- 不要在代码里藏兜底逻辑来吞掉错误、隐藏问题。出了问题就应该让它爆出来,否则你永远找不到真实问题。
|
||||
@@ -78,7 +79,7 @@
|
||||
## 当前范围与权威入口
|
||||
|
||||
- P01–P08 及 K01–K16 已完成**项目内隔离 Mock** 核验;本轮把分散的人类可读契约、文档与第三方对接合为唯一当前规范,不重审已确认规则。若未来另获启动开发/审查子 Agent 授权,必须按使用者指定的 `gpt-5.6-luna`、`max` 思考和 `fast: true` 逐项核验并显式配置,不静默换模型、降档或关闭 fast。
|
||||
- 唯一现行 SaaS↔Dispatcher 业务规范是 [`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md);当前项目内 Schema、拓扑、正反例及来源/hash 在 [`contracts/local/`](contracts/local/);内部 Agent RPC 在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。
|
||||
- 唯一现行 SaaS↔Dispatcher 业务规范、Schema、拓扑和正常示例由共享仓库统一管理;本项目的来源/hash、验证脚本、错误示例及历史验证来源放在 `contracts/`,不提交到共享仓库或嵌入运行契约。在本项目的共享入口为 [`contracts/schema/`](contracts/schema/README.md);[`docs/thirds/saas-dispatcher.md`](docs/thirds/saas-dispatcher.md) 只保留导航,不维护另一份规范。内部 Agent RPC 仍在 [`proto/agent/agent.proto`](proto/agent/agent.proto)。本地验收与外部缺口见 [`docs/evidence/saas-dispatcher-p08-acceptance.md`](docs/evidence/saas-dispatcher-p08-acceptance.md)。Markdown 不代替机器合同或外部签收,也不另外维护一份平行字段定义。
|
||||
- 已由当前合同来源清单固定哈希的历史提案与计划保留**原字节**于 [`docs/archive/sources/`](docs/archive/sources/README.md),使用者原有未提交的两份旧对接文档也按原字节归档;旧上游 v1 在 [`docs/archive/upstream/`](docs/archive/upstream/README.md) 可离线校验,但不嵌入运行合同。旧 F/W 工作包、旧 MQ-only 合同及归档不作为当前运行入口。固定 MQ `v1`、HTTP `/internal/v1/dispatcher/...` 和业务 revision 是现行通信规则,不是自有实现代次;不得为历史路径新建兼容或回退。
|
||||
- 当前业务范围仍仅**单节点、单 Dispatcher、单 Agent、单 Cell、单租户**。根命令只接受显式 `agent`/`dispatcher`;`mixed` 和裸 `real` 仍拒绝;隔离 `mock`、只读 `sip-only` 和需主机核验/正式获批指令的 `nonprod-real` 为彼此独立的入口。2026-10-05 正式 SaaS Mock 已驱动真实 Agent/Asterisk 发出五通 SIP INVITE,均由线路返回 480,已证实终结并提交结果但均未接通、未见 LLM 应答;数企→15003164745 受当日 3/3 门禁限制尚未在修复版完成试拨,不能声称六组通过。2026-10-04 获批的同一数企/号码两次单次试拨操作均未观测到 SIP 包或最终结果,第二次已有 Dispatcher 派发回执而 Agent 错误根因未知。第二次派发回执经使用者授权后已私密持久化并确认 MQ,最终结果仍缺失;版本 `d2c4a99` 已通过来源/哈希、非呼出时段不拨号抓包预检与版本级只读主机核验并安装;SaaS、Dispatcher、Agent 在测试机已启动,但没有投递新呼叫,Asterisk 仍为零活动通话。SaaS 测试服务已从获批的新私有快照经 HTTPS 提供租户额度 2/revision 2;2026-10-04 旧事件 `call-669f8829-a111-476d-88c7-3de604bf6dc6` 经使用者单独确认,仅在原始 SQLite 备份、只读通道/抓包核实及操作者证据先私密落盘后,人工标记 Dispatcher inbox 为 `finished` 并释放占用(现为 0),Agent 原 `unknown` 幂等日记、派发回执及原始证据均保留;没有 Agent 签发终结事实或最终结果,不能据此声称真实呼叫成功。不得自动重拨、清理未知执行或以编译/本机 Mock 通过宣称真实通话可用。另有严格隔离的 `--mode sip-only`:只允许 Dispatcher 读完整 SIP、持久接纳归属 `sip.config`、经已激活双向 TLS Agent 会话将完整快照应用到原生 Asterisk,并从运行态核对版本;不发现任务、不启动业务呼叫、不开放准入、不处理其他业务控制。测试机已凭使用者批准,将三条历史登记地址以**测试快照显式声明的** UDP/IP 鉴权、无需 REGISTER 配置写入并核对 Asterisk 运行态;供应商尚未确认这些实际线路是否满足上述参数,虽已观察到 SIP 480,但尚未证实线路可接通或完成生产签收。没有真实 SaaS、management、真实通话或生产签收;真实百炼 LLM 与 OSS 已各做一次**不拨号、独立的最小连接/写入诊断**(见 [`docs/evidence/real-ai-oss-one-shot-20261003.md`](docs/evidence/real-ai-oss-one-shot-20261003.md)),这不是获批任务的 ASR/LLM/TTS、录音和上传全链路签收。生产发布包仍为 `production_approval=false`。任何本机 Mock 或 SIP-only 核验均不授权真实呼叫。
|
||||
- 使用者批准独立的测试专用 `deploys/test/saas-mock/` 仅模拟缺失的 SaaS:从 0600 的静态快照提供五类正式 HTTP 配置,在专用 RabbitMQ vhost 中由模拟 SaaS 预建现行拓扑;不在 Dispatcher 内注入快照;默认不发布外呼消息,只有受限脚本已为同一 `event_id`/线路/原始号码启动抓包后,才可显式单次 MQ 投递;不替代 Agent/Asterisk/AI/OSS。测试用正式入口必须同时具备只读配置、专用 MQ、真实 Agent/Asterisk/AI/录音/OSS、逐通确认和拨号前活跃抓包证据,缺一项不得试拨。此模拟不构成真实 SaaS 签收。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: fmt test test-race coverage vet build build-sip-call security release release-check-local mq-integration-local proto-lint proto-generate proto-check contract-check acceptance-local check
|
||||
.PHONY: fmt test test-race coverage vet build build-sip-call security release release-check-local mq-integration-local proto-lint proto-generate proto-check contract-check contracts-update acceptance-local check
|
||||
|
||||
GO ?= go
|
||||
BINARY ?= dist/sip-go-agent
|
||||
@@ -51,6 +51,9 @@ proto-check:
|
||||
contract-check:
|
||||
./scripts/check-contracts.sh
|
||||
|
||||
contracts-update:
|
||||
./scripts/update-contracts.sh
|
||||
|
||||
acceptance-local:
|
||||
./scripts/acceptance-local.sh
|
||||
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
|
||||
## 唯一当前接口
|
||||
|
||||
- [SaaS↔Dispatcher 通信说明](docs/thirds/saas-dispatcher.md);机器合同为 [`contracts/local/`](contracts/local/) 的 Schema、拓扑、正反例和来源/hash。SaaS 的 SIP、任务、归属任务清单、租户额度和 AI 供应商配置只经五类只读 HTTP;呼叫、控制、必要回执与每通话唯一结果只经固定 `v1` RabbitMQ 通信名。固定 HTTP `/internal/v1/dispatcher/...`、MQ `v1` 及业务 revision 是有效合同,不是旧实现代次。
|
||||
- [SaaS↔Dispatcher 通信说明](docs/thirds/saas-dispatcher.md);最终契约由 [`sip-contracts`](https://gitee.com/zzmbac/sip-contracts) 统一管理,本项目只通过 [`contracts/schema`](contracts/schema/README.md) Git submodule 引用同一份规范、Schema、拓扑和正常示例;验证材料单独放在 `contracts/`,不维护另一份业务定义。SaaS 的 SIP、任务、归属任务清单、租户额度和 AI 供应商配置只经五类只读 HTTP;呼叫、控制、必要回执与每通话唯一结果只经固定 `v1` RabbitMQ 通信名。固定 HTTP `/internal/v1/dispatcher/...`、MQ `v1` 及业务 revision 是有效合同,不是旧实现代次。
|
||||
- Dispatcher 独立 UUID/资源归属、SaaS 预建的控制/任务队列、共享持久结果队列及本地 SQLite 权威任务/额度/inbox/outbox;未知通话不释放占用、不重拨、重启继续原消息身份。Agent 与 Dispatcher 通过双向 TLS Unary gRPC 和预绑定身份/会话代际交互,业务恢复落私有文件。内部联系见 [`proto/agent/agent.proto`](proto/agent/agent.proto)。
|
||||
- 任务内不可变获批 AI 参数支持 ASR-only 与 ASR+LLM+TTS;录音经短期授权由 Agent **一次** HTTPS PUT 至 OSS,明确失败才按受控文件恢复,结果未知不自动再传,最终事实进入 MQ。批准 Mock 的 SIP revision 回报不证明 Asterisk 已实际加载。
|
||||
- 历史来源和使用者修改的旧文档按原字节保存在 [`docs/archive/sources/`](docs/archive/sources/README.md),上游 v1 保存在 [`docs/archive/upstream/`](docs/archive/upstream/README.md) 并可离线校验;旧阶段计划已归档。它们不是另一个当前版本或运行回退。
|
||||
|
||||
## 本地验证(不触碰真实线路)
|
||||
|
||||
首次获取:`git clone --recurse-submodules <本项目仓库地址>`;已有项目执行 `git submodule update --init --recursive`。
|
||||
每次调研或修改契约前执行 `make contracts-update`;所有契约变更必须同步本项目及 SaaS 实现,见 [维护说明](docs/contracts.md)。
|
||||
|
||||
```sh
|
||||
make check # 格式、Proto、当前合同/历史来源、race、vet、构建、三项实际运行的隔离 MQ 集成测试
|
||||
make release-check-local # 本地制品、hash、当前拓扑、Mock-only/不覆盖既有文件的包检查
|
||||
@@ -22,7 +25,7 @@ make release-check-local # 本地制品、hash、当前拓扑、Mock-only/不
|
||||
|
||||
## 导航
|
||||
|
||||
- [唯一现行规范](docs/thirds/saas-dispatcher.md) · [当前机器合同](contracts/local/manifest.json) · [P01–P08 本地验收](docs/evidence/saas-dispatcher-p08-acceptance.md)
|
||||
- [唯一现行规范](docs/thirds/saas-dispatcher.md) · [共享契约文件用途表](contracts/schema/README.md) · [契约更新步骤](docs/contracts.md) · [P01–P08 本地验收](docs/evidence/saas-dispatcher-p08-acceptance.md)
|
||||
- [文档索引](docs/README.md) · [物理部署及独立 Asterisk 约束](deploys/README.md) · [项目开发与安全约束](AGENTS.md)
|
||||
|
||||
现存 SQLite、spool、outbox 与 Agent 恢复文件不自动清空或迁移;发现旧状态拒绝启动并保留原文件,必须先厘清未交付事实。仓库不保存真实密钥、完整用户音频或完整对话。
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T) {
|
||||
|
||||
examples := map[string][]byte{}
|
||||
for _, name := range []string{"config-read-sip.json", "task-discovery-page.json", "task-discovery-end.json", "config-read-task-asr.json", "config-read-providers.json", "config-read-quota.json"} {
|
||||
body, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name))
|
||||
body, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ func TestDispatcherCommandStartsWithIsolatedMQHTTPAndAgent(t *testing.T) {
|
||||
t.Fatalf("isolated Dispatcher stopped despite a live process: %v", err)
|
||||
default:
|
||||
}
|
||||
commandBody, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", "mq-execute.json"))
|
||||
commandBody, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", "mq-execute.json"))
|
||||
if err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func TestDispatcherConfigurationClientReadsApprovedSIPWithExplicitIdentity(t *testing.T) {
|
||||
const dispatcherID = "c046b893-8628-4589-ae50-619d049248a6"
|
||||
const secret = "synthetic-placeholder-not-a-credential"
|
||||
fixture, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", "config-read-sip.json"))
|
||||
fixture, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", "config-read-sip.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,18 @@
|
||||
# 契约目录维护规则
|
||||
|
||||
## 目录边界
|
||||
|
||||
- `schema/` 是共享仓库 `git@gitee.com:zzmbac/sip-contracts.git` 的 Git submodule,只保留 SaaS↔Dispatcher 对接所需的业务规范、Schema、MQ 拓扑和正常示例。
|
||||
- `schema/README.md` 将 Schema 和 Examples 分为两张用途表;业务规范 Markdown 和 `mq-topology.json` 在正文单独链接说明,不列入表格,不列 README 自身,不放本项目维护或验证说明。
|
||||
- 本项目的验证材料留在当前目录:`manifest.json`、`verify.py`、`test_verify.py`、Go 测试、`examples/invalid/`、`archive/sources/`。不得重新写入共享仓库或嵌入运行契约。
|
||||
- `archive/sources/` 中两份历史来源保留原字节;不作为当前规范、运行配置或真实签收依据。
|
||||
- 共享 Schema 是唯一字段定义,不在本地复制或维护另一份 Schema。
|
||||
|
||||
## 同步与检查
|
||||
|
||||
- 调研或修改契约前必须在项目根目录执行 `make contracts-update`;失败即停止,保留现存改动。
|
||||
- 普通构建只使用固定 submodule 提交,不自动联网追新,不回退旧契约。
|
||||
- `manifest.json` 是本项目来源及文件完整性记录,不是 SaaS 消息或配置。对接文件变更经核对后,可显式执行 `python3 contracts/verify.py --write` 更新当前规范与契约包哈希;历史来源哈希不可改写。
|
||||
- 共享变更须提交并推送共享仓库,再同步本项目 submodule 指针、验证记录、实现、测试和文档,并通知 SaaS 开发人员。共享仓库单独提交不算完成。
|
||||
- 交付前执行 `make contract-check`、`make check` 和 `make release-check-local`;本地模拟验证不代替真实 SaaS、线路或生产签收。
|
||||
- 不暂存、提交、覆盖或清理使用者无关修改。具体同步流程见 `../docs/contracts.md`。
|
||||
@@ -0,0 +1,161 @@
|
||||
# SaaS↔Dispatcher 通信调整与去版本标识计划
|
||||
|
||||
## 1. 已确认范围与完成定义
|
||||
|
||||
### 1.1 依据及优先级
|
||||
|
||||
1. 用户已确认 [`docs/thirds/v0.5-proposal.md`](thirds/v0.5-proposal.md) 是与 SaaS 负责人最终协商的数据通信结构。文件名中的 `proposal` 不再表示整个方向待批准;现有实现、历史计划和旧 Schema 与之冲突时,以该文档为准。本轮逐项补充确认以 §3.1–§3.2 为最新规则;原始通信文档本轮不修改,后续由 P01 同步这些已确认内容,不重新审批已解决问题。
|
||||
2. 用户补充确认:**项目尚未发版,移除自有代码、文件名及目录名中用于区分实现代次的 Vx 标识,禁止版本迭代。** 不再新增另一代实现、另一套版本化契约、兼容入口或自动回退;保留唯一现行实现。
|
||||
3. **通信名称与实现代次分开处理**:MQ exchange、queue、routing/binding key 中已约定的版本标识固定为 `v1`,HTTP `/internal/v1/dispatcher/...` 路径保持双方约定。它们不随文件、代码或 Schema 的变化递增,也不能在清理代码名称时被误删。
|
||||
4. 第三方依赖及其 module/import 路径版本、Go/工具链版本、HTTP/AMQP/UUID 等标准中的版本标识,以及业务 `revision`、配置身份、消息身份和数据迁移顺序号,不属于实现代次清理。保留这些值不等于允许项目版本迭代。
|
||||
5. 文档内部真正互相矛盾或不足以确定执行行为的部分,按 §3 定点确认;不能以“当前代码需要”为由要求 SaaS 恢复已取消字段,也不重新审批已确认方向。
|
||||
|
||||
### 1.2 本轮与后续实施的边界
|
||||
|
||||
- **本轮仅审查、修订本计划**;不修改代码、Schema、样例、配置、其他文档或已有数据,不执行任何重命名。当前计划文件名保留,后续去版本标识时统一改名。
|
||||
- 后续实施覆盖 MQ、五类只读 HTTP、任务/控制/结果处理、Agent 执行快照,以及所有相关自有文件和标识;不是只替换 MQ 字符串。
|
||||
- 单节点、单 Agent、单 Cell、单租户仍是本地验收范围。真实 SaaS/management、真实 MQ、供应商、ECS、生产切换和真实拨号分别授权、验收,不能用 Mock 结果代替。
|
||||
- 后续完成条件:受影响的协议歧义已有明确答案,唯一现行契约与实现一致,§5 工作包完成,§6 验收通过。已读文档、已有 Mock、改完名称均不等于完成通信调整。
|
||||
|
||||
## 2. 审查结论与计划纠正
|
||||
|
||||
以下是对原计划的审查结果;“纠正”指本计划已修订,**不表示实现已修复**。
|
||||
|
||||
| 编号 | 级别 | 原计划问题 | 本计划的纠正 |
|
||||
| --- | --- | --- | --- |
|
||||
| R01 | 高 | §1/P01 仍要求“独立版本”“版本化机器契约”,并讨论将来版本升级,与未发版、禁止版本迭代冲突。 | 使用唯一无代次名称的当前契约、类型、校验器和清单;直接修正当前内容并更新来源/hash,不创建下一版本,不按版本号分支解析。 |
|
||||
| R02 | 高 | 只列 MQ `.v3`→`.v1`,没有覆盖 Go 符号、文件/目录、Proto、生成物、Schema 引用、SQL、脚本及文档名称。 | 增加 §4 全范围清理和 §6 残留检查;通信固定 `v1` 是明确例外,不是保留 `V3Broker` 等代码名的理由。 |
|
||||
| R03 | 高 | C02–C05/P03/P05 将旧实现要求的 `tenant_key`、`agent_version_id`、SIP revision、授权期限等缺失一概当成对方应补齐的字段;还将明文 `credential` 误设计为凭据引用。 | 按最终字段重整解析、持久绑定和 Agent 交付。用户已确认 `credential` 直接返回明文凭据,按供应商 SDK 要求使用,不需要 ref、引用解析、凭据交换服务或额外安全架构。旧字段不再是隐含入站必填项;既有归属与实际加载检查不变,不伪造缺失值或新增对外字段。 |
|
||||
| R04 | 高 | 对 `call.execute` 回执、`call.execute.result`、`recording.uploaded` 的处理仍偏向保留旧路径,缺少明确的对外事件替换边界。 | 以最终文档确定唯一对外事件清单,调整信封及关联方式;旧 `command.result`、`call.result` 和分散事件不能作为别名或并行通知继续外发。录音上传与待交付事实仍须可靠保存。 |
|
||||
| R05 | 中 | C01–C09 全部绑定“双方确认”,把 JSON 排版错误、Mock 引用值不一致、旧字段删除,与真实协议歧义混为同一总阻塞。 | 已确认规则列入 §3.1–§3.2,不再列为待批准;本轮待确认问题已按 §3.3 关闭,不将整项工作重新置于待批准状态;未来发现新的真实缺口才定点确认。 |
|
||||
| R06 | 中 | P02 把启动被动检查描述成可以核验全部绑定。当前 AMQP 被动声明不能枚举或证明完整绑定关系。 | 区分 exchange/queue 存在性检查、实际消息路由测试和 mandatory/return/confirm;不能把 exchange 存在或 confirm 成功写成指定队列已收到。 |
|
||||
| R07 | 高 | 未处理改名碰撞、嵌入/生成/hash 引用及持久数据,直接去后缀可能覆盖现有同名实现或使恢复入口失效。 | 大规模清理前建分支、做旧→新映射并按职责合并;不覆盖同名文件,不自动清库,不删除未交付执行/上传/outbox 记录;见 §4.3。 |
|
||||
|
||||
## 3. 已确认规则与确认状态
|
||||
|
||||
### 3.1 已确认,不再重复询问
|
||||
|
||||
| 编号 | 事项 | 明确规则及实现边界 |
|
||||
| --- | --- | --- |
|
||||
| K01 队列组织 | 外呼队列与原来一致;SaaS 共用一个结果队列。 | 沿用现有每 D 独立控制队列、按任务消费外呼的组织方式,外呼路由 `d.<D>.task.<task_id>.in`、队列 `agent-call.d.<D>.task.<task_id>.v1`;不改成按租户混合消费。取消“每 D 独立结果队列”的目标,SaaS 将结果收进同一个队列。D 身份、任务归属和精确路由仍保留;SaaS 管理队列/绑定,D 不建队。所有版本后缀固定 v1。 |
|
||||
| K02 身份与结构字段 | tenant_id 使用数字;schema_version 删除。 | HTTP/MQ 相应字段统一校验数字类型,不再接受字符串租户 ID 作为新接口格式;移除 schema_version 字段及其版本选择分支,不替换成另一个固定版本值。消息身份仍保留,不能因删除结构版本字段而破坏内部幂等。 |
|
||||
| K03 任务发现 | 逐页读取返回的 cursor;带 cursor 且任务数量为零的响应表示已取全。 | 不因非空页数量较少就判断结束;非空页持久成功后才使用返回 cursor 继续查询。首次/重启沿用全量初始化和控制积压优先的流程,不恢复旧代次持久游标;失败关新准入,控制和结果恢复照常处理。不强加旧 snapshot_id/watermark/mode=snapshot。 |
|
||||
| K04 任务范围 | 任务不会删除,只会启用、停用、暂停;不考虑切换 Dispatcher。停用对应终止 stop,同一 task_id 不允许再次启用;需要恢复的任务使用 pause/resume。 | 不设计任务删除通知、移出 tombstone、归属迁移、跨 D 接管或终止后恢复。状态变化持久保存,HTTP 旧 running 或 resume 不能解除已终止状态;暂停才可经最新配置核验后恢复。 |
|
||||
| K05 控制缺省与回执 | active_call_policy 未提供时默认 hangup;回执在调度到 Agent 后返回。 | 缺省挂断不再当成非法配置;显式不合法值仍拒绝。回执表示已调度,不等同于活动通话已全部挂断/排空;未成功调度不能回成功。实际通话终结与释放资源仍独立核实。 |
|
||||
| K06 外呼回执与关联 | dispatched 表示已发出呼叫指令;最终结果 payload 中 task_id+手机号即可对应。 | 对外按 task_id/callee 关联,不强加 call_id/source_command_id 等新字段。内部继续保留每次执行的事件身份、消息身份和幂等记录,不把 task_id+callee 擅自改成永久禁止同号码再次执行的唯一键。回执不表示已接通。 |
|
||||
| K07 SIP 变更 | 全量 SIP 接口补充 revision 字段。 | 读取和加载核验使用该 revision,与 sip.config 通知对齐;不再以“全量缺少 revision”阻塞计划。业务 revision 保留,不属于自有实现代次清理;仍须证明实际加载,不能只更新数据库中的编号。 |
|
||||
| K08 凭据 | credential 直接返回明文,不需要 ref。 | D 将凭据交给 Agent 的对应 SDK,不引入凭据引用解析、交换服务或额外安全架构。provider_ref 仅用于选择 provider。真实凭据不写日志/样例的既有要求不变。 |
|
||||
| K09 挂断关键词 | 仅用户侧 ASR 最终识别文本包含任意配置关键词时挂断。 | 按字面包含匹配,等最终识别文本,不使用中间结果触发;不引入语义模型或额外归一化。助手回复、提示词、开场白和 TTS 内容不得触发;重复识别/通知不得造成重复执行终结操作。 |
|
||||
| K10 录音与最终结果 | OSS 只上传录音。正常上传成功即回报,不生成本地录音或外呼记录文件;只有 OSS 上传失败,才把录音及结果恢复信息保存本地。48 小时从首次失败后的两类恢复文件均保存完成时起算;重试间隔 1 分钟起、逐次翻倍、最长每小时一次。 | 有有效录音时,上传成功后才回最终结果;录音生成失败按 K15 的明确例外回报。48 小时耗尽仍上传失败则停止自动重试,保留文件,暂不回报,等待人工处理。本地结果信息只用于恢复 MQ 回报,不上传 OSS。此最新确认取代“所有通话先落盘”的旧表述;完整流程见 §3.2。 |
|
||||
| K11 错误码表达 | 有真实 SIP 状态码则 reason_code 返回原数字;没有 SIP 状态码时 reason_code=null,reason_message 说明原因。 | 不新建本地数字错误码,不把本地拒绝、SDK HTTP 错误、录音或 OSS 错误伪装成 SIP 状态码;文字说明保留必要原因,但不带真实凭据或原始敏感报文。通话事件/状态见 K13–K14,录音生成失败见 K15,失败恢复文件无法保存见 K16;这些处理规则均已确认。 |
|
||||
| K12 规则不满足时等待 | 不在允许时段等调度规则不满足时,暂停该任务的呼叫调度,直到规则允许后继续;不把暂时不能调度直接判成呼叫失败。 | 保留尚未执行的外呼,不因等待而丢弃、伪造结果或重复拨号;恢复前重新检查规则。规则等待与人工 pause/stop 分开,不能自动解除人工暂停或终止。单号码问题按 K13 处理;本规则不授权真实拨号或放宽现行真实门禁。 |
|
||||
| K13 单号码错误 | 某号码不在白名单或格式错误,返回 call.execute 拒绝回执:status=rejected、reason_code=null、reason_message 说明原因;不发 call.execute.result,不暂停整个任务。 | 不拨号、不创建录音/上传任务,不发 dispatched;继续处理其他正常号码。保留处理与消息身份记录,不无记录丢弃,不通过清洗/替换号码绕过校验。此规则不用于 K12 的时段/额度等待。 |
|
||||
| K14 通话结果状态 | 已发出指令后的最终 outcome 使用 answered、no_answer、failed:确实接通过为 answered;已发起但忙线/拒接/无人接听且确认结束为 no_answer;Agent/Asterisk 等执行故障、已确认未接通且执行结束为 failed。 | 后续异常不抹掉已接通事实;SIP 码/文字原因按 K11。仍不能确认是否接通或结束时继续核实,不伪造最终结果、不重拨、不释放未知占用。录音上传失败不改变通话 outcome,按 K10 等待上传/人工处理。 |
|
||||
| K15 录音生成失败例外 | 已确认通话结束,但预期录音无法生成时,仍回报真实通话结果,recording={},reason_message 明确说明录音生成失败。 | 不把正常接通改成 failed,不伪造录音/OSS 路径或 SIP 错误码;没有可上传内容,不进入 OSS 上传或 48 小时重试,不为此新建本地业务文件。此例外只针对录音生成失败,不用于已有录音的 OSS 上传失败或重试超期。 |
|
||||
| K16 上传失败且无法保存恢复文件 | 已有录音,但 OSS 上传失败后本地恢复文件也无法完整保存时,明确报错,暂不返回最终结果,等待人工修复。 | 保留已有内容,不伪称已有完整恢复副本;恢复文件完整保存后才能进入 K10 的重试流程并开始计时。未落盘内容不能保证跨进程退出恢复;不得擅自套用 K15 的空录音回报例外或无副本地宣称已开始可靠重试。 |
|
||||
|
||||
### 3.2 录音上传、失败落盘与回报顺序
|
||||
|
||||
1. **正常路径不落业务文件**:OSS 只上传录音内容;正常情况下直接上传,成功后回报单份最终通话结果,不生成本地录音文件或外呼记录信息文件。本地结果 JSON 不上传 OSS,也不新增该类资产/通知。不得先写临时文件再删除,并声称“没有落盘”。
|
||||
2. **仅上传失败才落盘**:OSS 上传失败后,将录音和用于恢复 MQ 回报的外呼记录信息一起保存本地。录音路径与原 OSS bucket/对象路径对应,结果恢复信息关联同一执行和目标;保持原文件、原对象目标和原消息身份,不因重试新建资产。若恢复文件无法完整保存,按 K16 明确报错、保留已有内容、暂不返回最终结果,等待人工修复;不能声称已有完整可恢复副本,也不启动依赖该副本的 48 小时重试。
|
||||
3. **确认通话终结**:释放已经确认结束的通话资源及占用,不等待 OSS 或最终结果发布。仍未确认终结的执行不能因上传窗口到期而释放;派发回执按 K05/K06 正常发送,不跟随最终结果等待录音。
|
||||
4. **失败上传重试**:间隔依次为 **1、2、4、8、16、32、60 分钟,之后保持每 60 分钟一次**;**从首次 OSS 上传失败后,录音和结果恢复信息均保存完成的时刻起算 48 小时**。起点固定记录一次,后续失败、修改重试进度、等待或重启均不延长截止时间。恢复记录保存原目标、尝试进度、窗口时间和下次尝试信息;不能用 SDK 默认重试覆盖此节奏。
|
||||
5. **上传成功后回报**:经 D 的既有可靠 MQ 交付返回最终结果;D 的 SQLite 状态/outbox 持久化不因“正常路径不落业务文件”而取消。MQ 发送失败只恢复原消息的交付,不再次上传已确认成功的录音、不重新拨号,也不把 MQ 故障当成 OSS 上传失败来生成录音缓存。48 小时不是删除待交付 MQ 结果的期限。
|
||||
6. **满 48 小时仍未成功**:停止自动上传重试;本地录音、结果恢复信息及进度继续保留,标记待人工处理;**不返回最终通话结果、不伪造 uploaded、不自动发 unavailable、不删除文件、不自动开启下一个 48 小时窗口**。SaaS 在人工处理并确认上传成功前收不到该最终结果。人工处置入口另行明确,不自动创建管理后台或补传协议。
|
||||
7. **取代旧行为**:删除“所有通话先写录音/结果文件”的前提;对已失败落盘的上传,取代旧 `call.ended_at + 15m` 自动收口/返回 unavailable 的逻辑,不留旧超时兜底。上传授权的有效期与重试窗口分开;Agent 仍经现有 D↔A RPC 显式取得有效上传授权,目标不变,不向 SaaS 申请 OSS TOKEN。
|
||||
8. **分清失败来源**:SIP 响应不能替录音生成、失败落盘或 OSS 上传报告状态。上传失败不把正常接通的通话改成呼叫失败;没有真实 SIP 状态码时按 K11 使用 reason_code=null 并说明原因,不编造状态码。录音无法生成按 K15 返回真实通话结果、recording={},并说明生成失败;上传失败后的恢复文件也写不出时按 K16 报错待人工、暂不回报,不套用空录音例外。
|
||||
9. **无录音路径与恢复边界**:单号码白名单/格式错误只按 K13 回拒绝回执,不进入最终通话结果或录音流程。已发起但无应答且未生成录音,按 recording 空对象返回最终结果,不等待不存在的文件上传,也不生成本地业务文件。正常路径不落盘意味着在上传成功或失败恢复文件保存完成前,进程异常退出时不能保证恢复内存中的录音;文档和验收不得承诺此阶段录音零丢失,也不能为掩盖限制偷偷恢复预写盘。上传重试不授权重拨、换线或真实测试。
|
||||
|
||||
### 3.3 本轮确认已完成
|
||||
|
||||
本轮列出的待确认问题已经逐项答复,当前清单没有剩余待用户确认项。K01–K16 为本轮明确规则,不再重复审批。
|
||||
|
||||
待完成的是 P01–P08 的契约同步、代码调整和验收,不是再次确认这些业务方向;本轮仍只修改计划,原始通信文档、Schema 和代码尚未同步。人工处理是明确报错并保留现有内容,不表示已实现新的人工恢复工具、后台或额外补传协议。
|
||||
|
||||
后续实施若发现新的真实字段冲突或现有组件无法满足要求,应带证据单独确认,只阻塞依赖该答案的部分;不能猜默认值、静默降级或重新启用废弃路径。
|
||||
|
||||
实施处理规则:
|
||||
|
||||
- 注释、Markdown 粗体键、重复冒号和缺逗号等先整理为合法 JSON,保持原业务含义,保留来源记录;这些排版问题不各自形成外部审批门槛。本轮不修改原文。
|
||||
- `provider_ref: "mock"` 与 provider 清单示例不匹配时,在隔离样例中提供一致且按角色可解析的引用;不能把一个示例值硬编码为生产默认值。
|
||||
- `hangup_keywords` 按 K09 检查用户最终识别文本是否包含关键词;对象、方式和时机均已确认,不再列为待批准。必须验证真实控制行为,不把“字段能解析”当成“功能已实现”。
|
||||
- 最终文档没有列出的旧对外事件不默认保留。确有额外必需事件时,必须明确修正同一份当前约定,不能通过兼容层暗中外发。
|
||||
- 每项答复直接修正唯一现行契约和测试;不产生下一版文件,不扩展为对已确认方向的再次审批。
|
||||
|
||||
### 3.4 已确定的字段变化,不再列为待批准事项
|
||||
|
||||
| 部分 | 必须调整的内容 |
|
||||
| --- | --- |
|
||||
| MQ 信封 | 按消息类型使用最终文档的 event_id/event_type、身份与时间字段;tenant_id 统一为数字,schema_version 删除。不能沿用旧信封统一要求的 tenant_key/trace_id/aggregate_*。例如 sip.config 示例没有 tenant_id/issued_at,不能因旧解码器需要而强迫添加。外呼最终 payload 按 task_id/callee 对应,不强加新的执行关联字段;内部追踪和幂等身份仍保留,但不擅自外发。 |
|
||||
| 任务与额度 | 删除对外 `agent_version_id` 等已移除字段依赖;保留真实业务 `task_revision/quota_revision`,它们不是实现代次。`agent.immutable` 及任务内完整 AI 配置必须实际绑定;ASR-only 不能被旧 full_ai 必填字段拒绝。 |
|
||||
| AI provider | 新增全量 `providers` 读取,按 `provider_ref/role/enabled/adapter/endpoint/credential` 解析;`credential` 是 SaaS 直接返回的明文凭据字符串,传给对应供应商 SDK,不改成对象或 `credential_ref`,不增加引用解析、凭据交换或密钥管理服务。`provider_ref` 仍仅用于选择 provider,不是凭据引用。禁用、缺失或角色不符的 provider 不可执行;真实凭据不写日志/样例的既有要求不变。 |
|
||||
| SIP 读取与未知值 | 全量响应新增业务 revision,与变更通知及实际加载核验对应。transport/auth_mode/registration_required/max_concurrent_calls 的 null 按原文表示尚未确认;读取可以表达未知,执行不能据此默认 UDP、免鉴权、不注册或无限并发。 |
|
||||
| 回执与最终事件 | 任务回执仍是 `task.control`,外呼开始回执仍是 `call.execute`,结果是 `call.execute.result`;保留各自事件 ID 语义,不沿用旧 `command.result/call.result` 信封。最终时间字段为 `issued_at`,不是旧 `occurred_at`。 |
|
||||
| 结果字段 | 当前 `call_result_v01.go` 使用字符串 `reason_code`,最终文档为 `null` 或数值(无应答示例为 `480`),并出现 `reason_message`;须调整类型及校验。无应答按例发送空 `transcript`、`opt_out: false`、`recording: {}`,不能强制套旧 `not_created` 结构;上传成功按约定输出 OSS 字段及哈希,旧本地执行/录音 ID 不强加到对外消息。 |
|
||||
|
||||
## 4. 去版本标识的完整范围
|
||||
|
||||
### 4.1 清理对象与改名原则
|
||||
|
||||
初筛已发现 235 个含代次式路径的候选文件,分布在 `internal/`、`contracts/`、`docs/`、`proto/`、生成目录等;这是候选清单,不能据此机械删除。还须检查无版本文件名内部的 Go 符号、JSON 标识、嵌入路径、SQL 和脚本引用。
|
||||
|
||||
| 范围 | 当前已核实的例子 | 目标及同步检查 |
|
||||
| --- | --- | --- |
|
||||
| Go 源码、测试及符号 | `internal/mq/amqp_v3.go`、`V3Broker`、`internal/dispatcher/task_queue_v3.go`、`task_control_v3.go`、`task_runtime_v3.go`、`LocalV01Runtime`、`call_result_v01.go`、`internal/configread/discovery_v04.go`;store 的代次式方法和测试 | 使用职责名,如 `Broker`、`TaskRuntime`、`task_queue.go`、`task_control.go`、`call_result.go`、`discovery.go`。先处理现存同名文件/符号,再统一调用点、测试、错误/日志标签;不保留类型别名或转发包装。 |
|
||||
| 当前契约与加载入口 | `contracts/upstream/v1/`、`docs/contracts/*-v0.x*.schema.json`、`local-contract-manifest-v0.x.json`、`contracts/contracts.go`、`internal/contract/contract.go` 的加载/校验路径 | 按领域维护唯一无代次文件,例如 `call-result.schema.json`、`config-read.schema.json`、`local-contract-manifest.json`。同步 `$id/$ref`、示例、Schema 选择器、编译缓存键、`go:embed` 和来源/hash;删除按旧版本加载的分支。来源事实中的原始外部版本不伪造、不改写。 |
|
||||
| 自有 Proto 与生成物 | `proto/agent/v1/agent.proto`、`gen/go/agent/v1/` | 目标为 `proto/agent/agent.proto`、`gen/go/agent/` 等无代次路径;同步自有 package、`go_package`、服务全名、导入、生成配置和 `scripts/check-proto.sh`。必须重新生成,不手改生成文件;D/A 同步构建,不能保留旧服务别名。去名本身不随意改变字段号及业务语义。 |
|
||||
| SQLite、恢复文件与检查脚本 | `internal/store/migrations/019_local_v04_discovery.sql`、相关 store 方法、状态/结果恢复入口,以及引用这些路径的脚本 | 清理迁移文件名称、确含实现代次的自有表/列/索引/记录类型及测试标识;保留有实际含义的迁移顺序号、业务 revision 和幂等身份。不得因为改名而丢失已执行事实或重做拨号/上传。 |
|
||||
| 部署、构建及验收入口 | Makefile、脚本、CI、配置样例、夹具、测试选择条件、hash 清单中的旧路径/命令 | 同步更新可执行引用;普通构建、检查和隔离验收不读取旧代次路径,不因缺文件跳过检查。各模式必须显式,不用改名引入 Mock/real 回退。 |
|
||||
| 文档、计划和归档文件名 | 本计划、`docs/thirds/v0.5-proposal.md`、相关计划、契约、验收证据和 `AGENTS.md` 中的引用 | 本计划后续改为 `docs/plan-saas-dispatcher.md`,当前通信标准改为 `docs/thirds/saas-dispatcher.md`;同步所有引用及权威入口。多份同主题材料只保留一个当前入口;有必要的历史证据按主题/事实日期归档,不再按版本并行维护,也不再参与运行校验。 |
|
||||
|
||||
### 4.2 必须保留的例外
|
||||
|
||||
- 双方已确认的 MQ 固定 `v1` 名称、HTTP 固定 `/internal/v1/...` 路径。它们是外部通信约定;自有 Go 类型/函数和文件不得因此命名为 `V1...`。
|
||||
- 第三方依赖、标准协议和工具链的合法版本,例如 SDK module 路径中的 `/v2`、Go 1.27.1、UUID v4、IPv4/IPv6;真实供应商 endpoint 和 model ID 也必须保留原值。禁止为了通过名称扫描而改依赖路径、供应商请求参数、协议行为或工具链基线。自有 Mock 的 `mock-chat-v1/mock-tts-v1` 等代次式名称不借此豁免。
|
||||
- 真实业务 revision、配置/任务身份、哈希、消息 ID、迁移顺序号,以及外部原始来源记录。它们不得成为“仍保留多代实现”的借口;自有历史实现代次不能冒充业务字段。
|
||||
- 例外必须按具体用途核实;不能将整个目录、所有 `v1` 字符串或所有测试一律放行。
|
||||
|
||||
### 4.3 改名与数据保留边界
|
||||
|
||||
1. 大规模清理前新建分支,记录工作树和可追溯基线;不得暂存、提交、移动或清理与本任务无关的用户改动。
|
||||
2. 建立路径/符号的旧→新映射,覆盖大小写、`V01/V04`、`v0_1`、`v0.1` 等形式。当前同时有 `amqp.go` 与 `amqp_v3.go`,不能简单去后缀覆盖;先保留必要行为、移除废弃路径,再统一职责名。
|
||||
3. 每批改名同步修正代码调用、嵌入、生成、构建和测试引用;改路径后的 hash 清单必须重新核验,不能只改条目文字。
|
||||
4. 不为旧实现保留兼容层、备用消费路径、旧 Schema 解析器或自动切换机制。有效的可靠性测试转到唯一现行实现,不能通过删测试消除失败。
|
||||
5. **不自动清空或重建现有 SQLite/spool/outbox。** 新结构先在独立空目录中验收;若现有持久记录受名称变化影响,须在受控停机前明确其状态及处置。存在未完成执行、未知占用、待交付结果或上传恢复记录时,未有获授权且验证可行的处理方式不得切换;不以项目未发版为由假定数据可丢。
|
||||
6. 当前源码、文件名、目录名和运行入口清理结束后,再做全仓残留检查;仅搜 MQ 后缀或仅看 `git diff` 不能作为完成证明。
|
||||
|
||||
## 5. 调整工作包与逐项验收
|
||||
|
||||
| 工作包 | 调整内容和依赖 | 验收标准 |
|
||||
| --- | --- | --- |
|
||||
| P01 唯一通信契约 | 将原始通信文档与 §3.1–§3.2 的最新确认同步到唯一字段/事件/路由清单;规范化 JSON,形成无代次 Schema、正反例和来源/hash。采用共享结果队列、数字 tenant_id、删除 schema_version、全量 SIP revision 及已确认回报顺序;不新增下一版本。 | 已确认事项不再列为待批准;旧必填字段不混入新接口;未知/非法字段拒绝;K01–K16 同步到当前唯一契约和测试;实现中新发现的问题按 §3.3 带证据单独确认,不能猜默认值。 |
|
||||
| P02 无代次骨架与引用整理 | 建分支,完成 §4 路径/符号清单及碰撞处理;按职责统一核心接口、存储调用和 Proto/生成路径。依赖已明确的契约部分;大范围改动须分批回归。 | 无覆盖同名文件、旧类型别名、重复服务或失效导入;生成物可重复生成,内部 RPC 参数/语义检查通过;旧运行代次入口关闭而非自动回退。 |
|
||||
| P03 五类 HTTP 与新数据模型 | 调整 SIP、任务、任务发现、租户额度和 ai-providers 五类读取;tenant_id 使用数字,去掉 schema_version/agent_version_id 的旧要求;SIP 全量读取增加 revision。按 agent/provider_ref/明文 credential 交付,保持 HTTP Header 大小写无关语义。 | 五类读取均有正反例;字符串租户 ID 不再作为新格式接纳;schema_version 不再是选择器或必填项;全量与通知 revision 能核对;0/false、数组保真;错误归属、HTTP/结构错误不能回退旧 MQ 配置或过期数据。 |
|
||||
| P04 MQ、任务发现与控制接纳 | 保留原外呼队列并改用 SaaS 共享结果队列;SaaS 建队,D 只消费/发布。发现翻页到带 cursor 的空清单;不开发删除/跨 D 迁移。按 K12 对规则不满足的任务暂停调度、保留待执行呼叫,条件允许后继续;K13 的单号码错误只回 call.execute rejected,不发最终结果、不暂停任务。policy 缺省 hangup,业务回执在调度 Agent 后返回,dispatched 须已发出呼叫指令。 | 持久 inbox 后才做 RabbitMQ ACK;规则等待不能借 ACK 丢掉未执行呼叫,业务回执不冒充已拨出。控制积压未清不准入,非空短页不提前结束;恢复前核对当前规则,不用过期许可放行。自动恢复只针对规则等待,不能覆盖人工 pause/stop;已发出或未知的执行不再次拨号。停用同 ID 不可再启用,不擅加 CAS/控制去重。 |
|
||||
| P05 Agent 配置与实际执行 | 明文 credential 经 D 交付 Agent 并用于 SDK,无 ref 查找/凭据交换;适配新快照,移除旧实现版本依赖。ASR-only 不强迫带 LLM/TTS;完整 AI 配置实传 SDK/控制器;opening 与 hangup_keywords 有实际行为,后者仅响应用户文本。SIP 变更关准入、排空并核验 revision 对应的实际加载。 | 两种 AI 场景运行;已接纳呼叫固定任务/provider 快照;同 task_revision 异内容拒绝;缺失/禁用/角色错误 provider 或无效凭据拒绝;只有用户最终识别文本包含关键词才触发挂断;中间文本、TTS/助手文字不触发;无真实加载证据不放行真实执行。 |
|
||||
| P06 正常直传、失败落盘与结果 | OSS 只接收录音;正常上传成功直接回报,不写录音/结果业务文件;调整现有录音及上传入口对文件路径的依赖,复用现有库/SDK,不另写协议。仅 OSS 上传失败时保存录音和结果恢复信息,路径对应原目标,并按 §3.2 启动 48 小时重试。窗口耗尽保留待人工,不发最终结果。D 状态/outbox 同事务;移除旧事件别名、总是预写盘及旧 15 分钟 unavailable 路径。 | 正常路径与无录音路径无业务文件写入,不能靠先写后删达标;OSS 不接收结果 JSON。失败落盘后可重启恢复原目标/消息/窗口;MQ 失败不重新 PUT。满 48 小时停止自动重试,不删文件、不自动回失败结果;资源按实际通话结束释放,派发回执不等上传。最终 outcome 按 K14 区分,未知执行不伪造结果;录音生成失败按 K15 回真实结果、空录音和明确原因;恢复文件写入失败按 K16 报错、保留内容、待人工且不回最终结果;不宣称未落盘录音能跨崩溃恢复。 |
|
||||
| P07 全仓清理与当前入口更新 | 随 P02–P06 每批处理 §4 改名/引用;删除废弃运行路径、旧 Schema 选择器和重复清单;同步当前契约、AGENTS、脚本、Makefile、嵌入与 hash,特别纠正旧“每 D 结果队列”和“15 分钟自动收口”等冲突规则。 | 当前入口唯一,无未批准代次残留;合法例外明确;普通构建/测试不读旧路径。记录已确认的新规则,不把本地实现或文档更新写成真实外部联调通过。 |
|
||||
| P08 端到端及交付验证 | 按已确认规则更新隔离 SaaS/MQ Mock 与验收入口,执行 §6;使用独立目录、可控时间和既有测试资源,不修改现存数据。 | 正常直传回报、录音生成失败空录音回报、OSS 失败落盘后重试成功、48 小时耗尽待人工不回报、恢复文件写入失败待人工不回报五条路径均通过;不靠真实等待 48 小时或真实 OSS 消费验收。部署/诊断门禁照常;区分本地通过、外部未验证及阻塞。 |
|
||||
|
||||
依赖顺序:P01 明确契约 → P02 建立无代次入口 → P03/P04/P05/P06 按接口依赖逐项完成 → P07 全仓收口 → P08 总验收。P07 的引用修正随每批工作同步完成,不留到最后才修编译和测试。本轮业务确认已完成;未来确有新增问题时按 §3.3 定点确认,不因已解决的问题重复停工,也不开不确定的执行准入。
|
||||
|
||||
## 6. 验收清单与完成证据
|
||||
|
||||
以下均为后续实施要求;本轮文档修改不将任何一项标成实现通过。
|
||||
|
||||
| 编号 | 检查 | 必须留存的结果 |
|
||||
| --- | --- | --- |
|
||||
| A01 契约一致性 | 对照最终正文逐项检查五类 HTTP、MQ 命令、控制/执行回执、最终结果、provider/agent 新字段;Schema 正反例全部执行。 | 字段/消息→Schema→测试映射;JSON 可解析;没有仅因旧代码需要而保留的必填字段、未经约定的新字段或版本分支。 |
|
||||
| A02 无代次名称 | 扫描全部自有源码、符号、注释/日志标签、文件/目录、SQL、脚本、样例和当前文档引用,覆盖 `V1/V2/V3/V01/V04/v0.x/v0_1` 等形式;人工核对例外与误报。 | 候选清单、旧→新映射、删除/保留理由及复扫结果;未批准的实现代次残留为零。不能靠把命名改成另一种代次格式或放行整个目录达标。 |
|
||||
| A03 固定通信名称 | 检查实际 publish/consume 的 exchange、queue、routing/binding key 和 HTTP 路径;以不同内部配置/Schema 内容运行同一流程。 | 始终使用约定固定 `v1`;不从代码/文件/Schema 版本推导拓扑,不存在 `.v3` 等旧拓扑的自动回退。 |
|
||||
| A04 引用及生成一致性 | 检查 import、Proto package/服务名、go_package、嵌入、Schema 引用、生成配置、脚本、文档链接和 manifest hash。 | 构建和重新生成成功;生成后无意外差异;无悬空引用、覆盖冲突或旧路径依赖。外部来源事实与本地整理产物的 hash 各自可核验。 |
|
||||
| A05 配置读取与失效 | 覆盖数字/字符串 tenant_id、删除 schema_version、SIP 全量 revision 与通知对应、错误归属/字段、未知或禁用 provider、角色错配、无效凭据、网络失败、0/false 和合法 null。用虚构 credential 验证原值直达 SDK,无 ref 查找服务。 | 合法且满足准入条件的新结构可执行;字符串租户 ID、旧 schema_version 等不作为兼容格式放行;未知配置不默认可执行;无旧 MQ/过期缓存回退;日志/样例/证据不含真实密钥、TOKEN 或完整音频/对话。 |
|
||||
| A06 队列所有权与路由 | 验证原外呼/控制队列组织及 SaaS 单个共享结果队列;D 无 configure 权限;故障覆盖缺资源、错绑定、不可路由、return、confirm 丢失和重连。 | D 不建队/绑定/删除,不要求每 D 独立结果队列;来源身份仍能区分;以实际接收证明路由,不把 exchange 存在或 confirm 当目的队列收到。D1/D2 仅作隔离夹具,不冒充多 D 业务运行。 |
|
||||
| A07 控制与发现竞态 | 覆盖带 cursor 的空页完成、非空短页继续、读取/持久化失败、重启全量与控制积压;覆盖 policy 缺省挂断、显式 drain/hangup、业务回执调度时机、旧 running 页、重投/乱序及 SIP revision 变更。 | 无任务删除/跨 D 迁移流程;非空页不能提前完成;未调度 Agent 不回成功,回执不冒充活动通话已结束;HTTP 不覆盖已应用控制,不删 SaaS 队列。stop 后同 ID resume 或 HTTP running 均不能恢复,只有暂停任务可以恢复。 |
|
||||
| A08 执行与 AI 行为 | 覆盖重复/历史执行消息、同号码不同呼叫、白名单/时段/额度/期限、两类 AI 场景、开场白及 SDK 实参;挂断词分别输入用户最终文本、仅中间结果命中、助手文本和 TTS 内容,核对字面包含规则与重复通知行为。 | 接纳与实际发出前门禁有效;任务级规则不满足时等待而非丢弃,恢复时重新校验,不自动解除人工 pause/stop;单号码白名单/格式错误只回 call.execute rejected、不发最终结果,不阻塞后续正常号码;dispatched 确已发出呼叫指令但不代表接通。已发出/未知执行不重拨,无自动换线或旧音频重播;仅用户最终文本可触发关键词。真实路径仍受 09:00(含)–20:00(不含)Asia/Shanghai 限制,Mock 不授权真实拨号。 |
|
||||
| A09 结果与录音 | 分别验证正常上传成功不写业务文件、OSS 失败才保存录音/结果恢复信息、无录音直接回报;OSS 接收端断言只有录音内容。用可控时间核对 1、2、4、8、16、32、60 分钟节奏及封顶,48 小时从首次失败后的两类恢复文件保存完成起算。验证 K14 的 answered/no_answer/failed 及未知时不发最终结果;验证 K15 录音生成失败仍回真实结果、recording={} 并注明原因;验证 K16 恢复文件写入失败明确报错、保留内容、待人工且不发最终结果,不假装已开始可靠重试。 | 文件写入观测证明正常路径没有先写后删;有有效录音时上传成功后才发最终结果,无录音及 K15 生成失败例外不等待上传;派发回执不等上传;满 48 小时停止自动上传、保留待人工、不发 unavailable/失败结果,不恢复旧 15 分钟逻辑。MQ 失败不触发录音落盘/重复 PUT;无应答无录音不等待上传。 |
|
||||
| A10 数据保护与恢复 | 对失败后已落盘的录音/结果信息、重试窗口/进度,以及既有 inbox、未知占用、上传成功事实和 outbox 注入故障;检查 D 的可靠消息持久化没有被正常路径不落业务文件的规则取消。 | 失败落盘后重启不重置 48 小时、不换对象、不重拨;授权过期经现有 D 接口取有效授权且目标不变。已持久到 D 的状态/outbox 同事务,MQ 失败恢复原消息。满 48 小时不删缓存,不给 MQ 交付加删除期限;未落盘录音的进程崩溃不可恢复边界明确,不伪称零丢失。资源按通话终结释放,现有数据未经明确处置不清库。 |
|
||||
| A11 测试与构建 | 按 TDD 开发;执行格式化检查、现有 contract/proto 检查、`go vet ./...`、`go test -race ./...`、构建及隔离端到端。 | 核验 Go 1.27.1;单元测试覆盖率 ≥65%,报告注明范围,不靠排除修改代码达标;每批合并后回归,无跳过必需部署/诊断步骤。 |
|
||||
| A12 交付边界 | 对照 P01–P08、§3 答复和上述检查逐项填写证据,不沿用旧版本通过记录代签。 | 当前文档/代码入口唯一;本地验收、真实 SaaS/MQ、真实 Agent/Asterisk 加载及供应商/生产验证分开列明。未获授权不执行真实切换、消费或拨号。 |
|
||||
|
||||
本轮文档交付核验:只修改本文件;检查文字规则、表格、现有引用及工作树差异。不运行或宣称完成上述代码测试、部署和通信验收。
|
||||
@@ -0,0 +1,446 @@
|
||||
SaaS Dispatcher 对接文档 v0.5
|
||||
相关占位说明:
|
||||
`/internal/v1/dispatcher/xxxxx` 路由按系统当前架构路由进行定义即可
|
||||
`X-DISPATCHER-ID`/`X-DISPATCHER-SECRET-KEY` HEADER 头KEY 获取忽略大小写
|
||||
Dispatcher 角色下称 D
|
||||
触发顺序
|
||||
1.1 MQ 发布消费规则
|
||||
RabbitMQ 有发布入口 exchange → 发布时指定的 routing key → 预先绑定的 queue → D 消费四步;
|
||||
```Plain Text
|
||||
SaaS→D exchange: agent-call.dispatchers.v1
|
||||
routing key: d.<dispatcher_id>.t.<tenant_id>.in
|
||||
binding key: d.<dispatcher_id>.t.<tenant_id>.in
|
||||
queue: agent-call.d.<dispatcher_id>.t.<tenant_id>.v1
|
||||
consumer: 对应 Dispatcher
|
||||
D→SaaS exchange: agent-call.saas.v1
|
||||
routing key: d.<dispatcher_id>.t.<tenant_id>.out
|
||||
queue: agent-call.saas.events.v1
|
||||
consumer: SaaS
|
||||
```
|
||||
1.2 本轮任务队列与事件路由
|
||||
硬边界:所有 exchange/queue/binding 均由 SaaS 创建、维护和退役;D 只消费 SaaS 创建的任务/控制队列,并向 SaaS 创建的结果 exchange 发布,不声明、绑定或删除队列。
|
||||
```Plain Text
|
||||
SaaS 创建并绑定:
|
||||
exchange: agent-call.dispatchers.v1 (topic, durable)
|
||||
task routing: d.<dispatcher_id>.task.<task_id>.in
|
||||
task queue: agent-call.d.<dispatcher_id>.task.<task_id>.v1
|
||||
control route: d.<dispatcher_id>.control.in
|
||||
control queue: agent-call.d.<dispatcher_id>.control.v1
|
||||
dead-letter: agent-call.dead-letter.v1
|
||||
D -> SaaS exchange: agent-call.saas.v1 (topic, durable)
|
||||
result route: d.<dispatcher_id>.out
|
||||
SaaS result queue: agent-call.saas.d.<dispatcher_id>.v1
|
||||
```
|
||||
HTTP请求
|
||||
SIP列表配置
|
||||
```HTTP
|
||||
GET /internal/v1/dispatcher/sip HTTP/1.1
|
||||
Host: <SaaS 服务地址>
|
||||
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
|
||||
X-DISPATCHER-SECRET-KEY: <密钥>
|
||||
```
|
||||
响应:
|
||||
```JSON
|
||||
{
|
||||
"resource": "sip_config", // 资源类型:SIP 配置
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6", // 这份配置所属的 Dispatcher
|
||||
"trunks": [ // 该 Dispatcher 获批的线路列表
|
||||
{
|
||||
"trunk_id": "trunk-mock", // 线路的唯一标识
|
||||
"provider_id": "provider-mock", // SIP 服务商标识
|
||||
"codec": "PCMA", // 线路使用的语音编码
|
||||
"dial_prefix": "", // 该线路拨号时添加的被叫前缀;空串表示不添加
|
||||
"enabled": true, // 是否启用这条线路
|
||||
"server_host": "sip.example.invalid", // SIP 服务端地址;此处是 Mock 地址
|
||||
"server_port": 5060, // SIP 服务端端口
|
||||
"transport": null, // 传输方式:udp、tcp、tls;null 表示尚未确认
|
||||
"auth_mode": null, // 鉴权方式:ip、digest、none;null 表示尚未确认
|
||||
"registration_required": null, // 是否需要 SIP 注册;null 表示尚未确认
|
||||
"max_concurrent_calls": null, // 分配给该 Dispatcher 的线路并发上限;null 表示尚未确认
|
||||
"caller_profiles": [ // 这条线路可用的主叫配置
|
||||
{
|
||||
"caller_profile_id": "caller-profile-mock", // 主叫配置标识,供任务引用
|
||||
"caller_id": "BD00000000" // 实际主叫标识,保留原值及字母
|
||||
}
|
||||
],
|
||||
"schedule": { // 这条线路允许外呼的每周时段
|
||||
"time_zone": "Asia/Shanghai", // 判断时段使用的时区
|
||||
"weekly_windows": { // 每天可配置多个时段;空数组表示当天不允许外呼
|
||||
"monday": [{"start": "09:00", "end": "20:00"}], // 周一
|
||||
"tuesday": [{"start": "09:00", "end": "20:00"}], // 周二
|
||||
"wednesday": [{"start": "09:00", "end": "20:00"}], // 周三
|
||||
"thursday": [{"start": "09:00", "end": "20:00"}], // 周四
|
||||
"friday": [{"start": "09:00", "end": "20:00"}], // 周五
|
||||
"saturday": [], // 周六不允许外呼
|
||||
"sunday": [] // 周日不允许外呼
|
||||
} }
|
||||
} ]
|
||||
}
|
||||
```
|
||||
AI 服务商全量读取
|
||||
`GET /internal/v1/dispatcher/ai-providers`
|
||||
```JSON
|
||||
{
|
||||
"resource": "ai_providers",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"providers": [
|
||||
{
|
||||
"provider_ref": "asr-provider-a",
|
||||
"role": "asr",
|
||||
"enabled": true,
|
||||
"adapter": "volcengine_asr",
|
||||
"endpoint": "https://asr.example.invalid",
|
||||
"credential": "managed-asr-a"
|
||||
},
|
||||
{
|
||||
"provider_ref": "llm-provider-a",
|
||||
"role": "llm",
|
||||
"enabled": true,
|
||||
"adapter": "openai_compatible",
|
||||
"endpoint": "https://llm.example.invalid/v1",
|
||||
"credential": "managed-llm-a"
|
||||
},
|
||||
{
|
||||
"provider_ref": "tts-provider-a",
|
||||
"role": "tts",
|
||||
"enabled": true,
|
||||
"adapter": "volcengine_tts",
|
||||
"endpoint": "https://tts.example.invalid",
|
||||
"credential": "managed-tts-a"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
任务配置
|
||||
```HTTP
|
||||
GET /internal/v1/dispatcher/task/{task_id} HTTP/1.1
|
||||
Host: <SaaS 服务地址>
|
||||
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
|
||||
X-DISPATCHER-SECRET-KEY: <密钥>
|
||||
```
|
||||
响应体:
|
||||
仅 ASR 模式(Agent配置仅返回 ASR即可)
|
||||
ASR + LLM + TTS 模式
|
||||
```JSON
|
||||
{
|
||||
**"resource"**: **"task_config"**,
|
||||
**"dispatcher_id"**: **"c046b893-8628-4589-ae50-619d049248a6"**,
|
||||
**"tenant_id"**: **"tenant-id-mock"**,
|
||||
**"task_id"**: **"task-mock"**,
|
||||
**"task_revision"**: **2**,
|
||||
**"status"**: **"running"**,
|
||||
**"name"**: **"Mock task"**,
|
||||
**"max_concurrent_calls"**: **2**,
|
||||
**"ring_timeout_ms"**: **30000**,
|
||||
**"max_call_duration_ms"**: **120000**,
|
||||
**"route_policy_id"**: **"route-mock"**,
|
||||
**"caller_profile_id"**: **"caller-profile-mock"**,
|
||||
**"allowed_trunk_ids"**: [
|
||||
**"trunk-mock"**
|
||||
],
|
||||
**"schedule"**: {
|
||||
**"time_zone"**: **"Asia/Shanghai"**,
|
||||
**"starts_at"**: **"2026-09-21T00:00:00+08:00"**,
|
||||
**"ends_at"**: **null**,
|
||||
**"weekly_windows"**: {
|
||||
**"monday"**: [
|
||||
{
|
||||
**"start"**: **"09:00"**,
|
||||
**"end"**: **"11:00"**
|
||||
},
|
||||
{
|
||||
**"start"**: **"14:00"**,
|
||||
**"end"**: **"18:00"**
|
||||
}
|
||||
],
|
||||
**"tuesday"**: [
|
||||
{
|
||||
**"start"**: **"09:00"**,
|
||||
**"end"**: **"18:00"**
|
||||
}
|
||||
],
|
||||
**"wednesday"**: [
|
||||
{
|
||||
**"start"**: **"09:00"**,
|
||||
**"end"**: **"18:00"**
|
||||
}
|
||||
],
|
||||
**"thursday"**: [
|
||||
{
|
||||
**"start"**: **"09:00"**,
|
||||
**"end"**: **"18:00"**
|
||||
}
|
||||
],
|
||||
**"friday"**: [
|
||||
{
|
||||
**"start"**: **"09:00"**,
|
||||
**"end"**: **"18:00"**
|
||||
}
|
||||
],
|
||||
**"saturday"**: [
|
||||
|
||||
],
|
||||
**"sunday"**: [
|
||||
|
||||
]
|
||||
},
|
||||
**"excluded_dates"**: [
|
||||
**"2026-10-01"**,
|
||||
**"2026-10-02"**
|
||||
]
|
||||
},
|
||||
**"agent"**: {
|
||||
**"immutable"**: **true**,
|
||||
**"mode"**: **"full_ai"**,
|
||||
**"llm"**: {
|
||||
**"provider_ref"**: **"mock"**,
|
||||
**"model"**: **"mock-chat-v1"**,
|
||||
**"temperature"**: **0.2**,
|
||||
**"max_tokens"**: **256**,
|
||||
**"timeout_ms"**: **5000**
|
||||
},
|
||||
**"prompt"**: {
|
||||
**"text"**: **"Mock prompt for an isolated test."**,
|
||||
**"allowed_variables"**: [
|
||||
|
||||
],
|
||||
**"max_bytes"**: **32768**
|
||||
},
|
||||
**"tts"**: {
|
||||
**"provider_ref"**: **"mock"**,
|
||||
**"model"**: **"mock-tts-v1"**,
|
||||
**"voice"**: **"mock-neutral"**,
|
||||
**"speed"**: **1**,
|
||||
**"format"**: {
|
||||
**"encoding"**: **"pcm_s16le"**,
|
||||
**"sample_rate_hz"**: **16000**,
|
||||
**"channels"**: **1**
|
||||
},
|
||||
**"timeout_ms"**: **5000**
|
||||
},
|
||||
**"asr"**: {
|
||||
**"provider_ref"**: **"mock"**,
|
||||
**"language"**: **"zh-CN"**,
|
||||
**"input"**: {
|
||||
**"encoding"**: **"pcm_s16le"**,
|
||||
**"sample_rate_hz"**: **16000**,
|
||||
**"channels"**: **1**,
|
||||
**"sample_width_bytes"**: **2**
|
||||
},
|
||||
**"interim"**: **true**,
|
||||
**"timeout_ms"**: **5000**
|
||||
},
|
||||
**"conversation"**: {
|
||||
**"opening"**: **"开场白"**,
|
||||
**"hangup_keywords"**: [
|
||||
**"不用了"**,
|
||||
**"请挂机"**
|
||||
],
|
||||
**"allow_interrupt"**: **true**,
|
||||
**"silence_timeout_ms"**: **3000**,
|
||||
**"max_duration_ms"**: **120000**,
|
||||
**"max_turns"**: **20**,
|
||||
**"sentence_max_chars"**: **80**,
|
||||
**"max_pending_audio_chunks"**: **32**
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
任务列表
|
||||
```HTTP
|
||||
GET /internal/v1/dispatcher/tasks HTTP/1.1
|
||||
Host: <SaaS 内网地址>
|
||||
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
|
||||
X-DISPATCHER-SECRET-KEY: <密钥>
|
||||
```
|
||||
增量请求
|
||||
```HTTP
|
||||
GET /internal/v1/dispatcher/tasks?after=1042 HTTP/1.1
|
||||
Host: <SaaS 内网地址>
|
||||
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
|
||||
X-DISPATCHER-SECRET-KEY: <密钥>
|
||||
```
|
||||
响应:
|
||||
```JSON
|
||||
{
|
||||
"schema_version": "task-discovery.v0.2-proposal",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"cursor": "1042",
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "task-a",
|
||||
"tenant_id": 1001,
|
||||
"status": "running",
|
||||
"task_revision": 1
|
||||
},
|
||||
{
|
||||
"task_id": "task-old",
|
||||
"tenant_id": 1001,
|
||||
"status": "stopped",
|
||||
"task_revision": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
按租户 ID 获取配额信息
|
||||
```HTTP
|
||||
GET /internal/v1/dispatcher/tenant/{tenant-id}/quota HTTP/1.1
|
||||
Host: <SaaS 内网地址>
|
||||
X-DISPATCHER-id: c046b893-8628-4589-ae50-619d049248a6
|
||||
X-DISPATCHER-SECRET-KEY: <密钥>
|
||||
```
|
||||
```JSON
|
||||
{
|
||||
"resource": "tenant_quota",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"tenant_id": "tenant-id-mock",
|
||||
"quota_revision": 1,
|
||||
"max_concurrent_calls": 3
|
||||
}
|
||||
```
|
||||
控制事件
|
||||
> 队列:agent\-call\.d\.\<dispatcher\_id\>\.control\.v1
|
||||
>
|
||||
>
|
||||
SIP 线路变更
|
||||
```JSON
|
||||
{
|
||||
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
|
||||
"event_type": "sip.config",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"payload": {
|
||||
"trunk_id": "trunk-mock",
|
||||
"change_type": "disabled", // enabled/removed/created
|
||||
"revision": 8
|
||||
}
|
||||
}
|
||||
```
|
||||
任务控制
|
||||
```JSON
|
||||
{
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"tenant_id": 1001,
|
||||
"issued_at": "2026-09-21T00:00:00Z",
|
||||
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
|
||||
"event_type":: "task.control",
|
||||
"payload": {
|
||||
"task_id": "task-a",
|
||||
"action": "pause", // resume , stop
|
||||
"reason": "local-test",
|
||||
"options": { // 附加控制参数
|
||||
"active_call_policy": "drain", // 活跃通话策略,仅stop,pause事件生效 [drain|hangup]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
任务控制处理回执
|
||||
> 队列 agent\-call\.saas\.v1
|
||||
>
|
||||
>
|
||||
```JSON
|
||||
{
|
||||
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
|
||||
"event_type":: "task.control",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"tenant_id": 1001,
|
||||
"payload": {
|
||||
"status": "applied"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
外呼任务队列
|
||||
> 队列:agent\-call\.d\.\<dispatcher\_id\>\.t\.\<tenant\_id\>\.v1
|
||||
>
|
||||
>
|
||||
呼出
|
||||
```JSON
|
||||
{
|
||||
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
|
||||
"event_type":: "call.execute",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"tenant_id": 1001,
|
||||
"issued_at": "2026-09-18T10:00:00+08:00"
|
||||
"payload": {
|
||||
"task_id": "task-a",
|
||||
"callee": "15003164745"
|
||||
}
|
||||
}
|
||||
```
|
||||
回执
|
||||
> 队列 agent\-call\.saas\.v1
|
||||
>
|
||||
>
|
||||
开始调度执行
|
||||
```JSON
|
||||
{
|
||||
"event_id": "7cd23165-c88e-4f36-8cf2-b5e9ac67ecdb",
|
||||
"event_type":: "call.execute",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"tenant_id": 1001,
|
||||
"issued_at": "2026-09-18T10:00:00+08:00"
|
||||
"payload": {
|
||||
"status": "dispatched",
|
||||
}
|
||||
}
|
||||
```
|
||||
完成结果上报
|
||||
成功
|
||||
```JSON
|
||||
{
|
||||
"event_id": "call-result-001",
|
||||
"event_type": "call.execute.result",
|
||||
"dispatcher_id": "c046b893-8628-4589-ae50-619d049248a6",
|
||||
"tenant_id": 1001,
|
||||
"issued_at": "2026-09-18T10:10:15+08:00",
|
||||
"payload": {
|
||||
"task_id": "task-a",
|
||||
"caller_profile_id": "caller-a",
|
||||
"callee": "15003164745",
|
||||
"trunk_id": "trunk-a",
|
||||
"started_at": "2026-09-18T10:00:00+08:00",
|
||||
"ended_at": "2026-09-18T10:10:00+08:00",
|
||||
"duration_ms": 600000,
|
||||
"outcome": "answered",
|
||||
"reason_code": null,
|
||||
"transcript": [
|
||||
{
|
||||
"turn_id": "turn-1",
|
||||
"segment_id": "segment-1",
|
||||
"role": "user",
|
||||
"text": "示例转写内容",
|
||||
"start_ms": 1000,
|
||||
"end_ms": 2500
|
||||
}
|
||||
],
|
||||
"opt_out": false,
|
||||
"recording": {
|
||||
"status": "uploaded",
|
||||
"bucket": "example-bucket",
|
||||
"object_key": "calls/tenant-a/call-a.wav",
|
||||
"format": "wav",
|
||||
"channels": 1,
|
||||
"sample_rate_hz": 8000,
|
||||
"duration_ms": 600000,
|
||||
"size_bytes": 9600000,
|
||||
"checksum_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
无应答
|
||||
```JSON
|
||||
{
|
||||
// ...
|
||||
"payload": {
|
||||
//...
|
||||
"outcome": "no_answer",
|
||||
"reason_code": 480,
|
||||
"reason_message": "SIP ring timeout Reason",
|
||||
"transcript": [],
|
||||
"opt_out": false,
|
||||
"recording": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+68
-8
@@ -1,24 +1,84 @@
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
)
|
||||
|
||||
// Files contains only the active local machine contract.
|
||||
// Files contains the shared contract at the project's pinned submodule commit.
|
||||
// Runtime code never reads a checkout or resolves schema refs online.
|
||||
//
|
||||
//go:embed local/*.json local/examples
|
||||
//go:embed schema/*.json schema/examples
|
||||
var Files embed.FS
|
||||
|
||||
// ReadCurrent reads the sole active project-local contract, with no fallback
|
||||
// to a historical bundle if a name is missing or invalid.
|
||||
// ReadCurrent reads only shared root schemas and MQ topology.
|
||||
// There is no fallback to historical contracts or project-local copies.
|
||||
func ReadCurrent(name string) ([]byte, error) {
|
||||
switch name {
|
||||
case "config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json", "manifest.json":
|
||||
return Files.ReadFile(path.Join("local", name))
|
||||
default:
|
||||
if name != path.Base(name) || strings.Contains(name, "\\") {
|
||||
return nil, fmt.Errorf("invalid current contract %q", name)
|
||||
}
|
||||
if name != "mq-topology.json" && !strings.HasSuffix(name, ".schema.json") {
|
||||
return nil, fmt.Errorf("invalid current contract %q", name)
|
||||
}
|
||||
return Files.ReadFile(path.Join("schema", name))
|
||||
}
|
||||
|
||||
type offlineSchemaLoader struct{}
|
||||
|
||||
func (offlineSchemaLoader) Load(url string) (any, error) {
|
||||
return nil, fmt.Errorf("unregistered shared schema reference %q; network loading is disabled", url)
|
||||
}
|
||||
|
||||
// CompileCurrent registers all canonical split schemas before compiling an
|
||||
// entrypoint. Unregistered references fail explicitly, without network access.
|
||||
func CompileCurrent(name string) (*jsonschema.Schema, error) {
|
||||
if _, err := ReadCurrent(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasSuffix(name, ".schema.json") {
|
||||
return nil, fmt.Errorf("not a schema: %q", name)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
compiler.UseLoader(offlineSchemaLoader{})
|
||||
files, err := fs.Glob(Files, "schema/*.schema.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resource string
|
||||
for _, file := range files {
|
||||
data, err := Files.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode shared schema %s: %w", file, err)
|
||||
}
|
||||
object, ok := doc.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("shared schema %s must be an object", file)
|
||||
}
|
||||
id, ok := object["$id"].(string)
|
||||
if !ok || id == "" {
|
||||
return nil, fmt.Errorf("shared schema %s is missing $id", file)
|
||||
}
|
||||
if err := compiler.AddResource(id, doc); err != nil {
|
||||
return nil, fmt.Errorf("register shared schema %s: %w", file, err)
|
||||
}
|
||||
if path.Base(file) == name {
|
||||
resource = id
|
||||
}
|
||||
}
|
||||
schema, err := compiler.Compile(resource)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compile shared schema %s: %w", name, err)
|
||||
}
|
||||
return schema, nil
|
||||
}
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
package contracts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||
"git.ipao.vip/rogee/go-sip/contracts"
|
||||
)
|
||||
|
||||
// The current, project-owned contracts have one schema per transport surface.
|
||||
// Shared canonical definitions are composed into one entrypoint per surface.
|
||||
// Historical upstream bundles are not fallback validators for these inputs.
|
||||
func TestCurrentContractExamples(t *testing.T) {
|
||||
for _, surface := range []string{"config-read", "task-discovery", "mq"} {
|
||||
t.Run(surface, func(t *testing.T) {
|
||||
root := filepath.Join("local", surface+".schema.json")
|
||||
raw, err := os.ReadFile(root)
|
||||
schema, err := contracts.CompileCurrent(surface + ".schema.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
const base = "https://go-sip.local/contracts/current/"
|
||||
if err := compiler.AddResource(base+surface+".schema.json", value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema, err := compiler.Compile(base + surface + ".schema.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
positives, err := filepath.Glob(filepath.Join("local", "examples", surface+"-*.json"))
|
||||
positives, err := filepath.Glob(filepath.Join("schema", "examples", surface+"-*.json"))
|
||||
if err != nil || len(positives) == 0 {
|
||||
t.Fatalf("missing positive examples: %v", err)
|
||||
}
|
||||
negatives, err := filepath.Glob(filepath.Join("local", "examples", "invalid", surface+"-*.json"))
|
||||
negatives, err := filepath.Glob(filepath.Join("examples", "invalid", surface+"-*.json"))
|
||||
if err != nil || len(negatives) == 0 {
|
||||
t.Fatalf("missing negative examples: %v", err)
|
||||
}
|
||||
|
||||
@@ -28,13 +28,21 @@ func TestOneCurrentHumanContractAndArchivedSources(t *testing.T) {
|
||||
t.Fatalf("historical source %s missing from archive: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"v0.5-proposal.md", "plan-saas-dispatcher-v05-v0.1.md"} {
|
||||
if _, err := os.Stat(filepath.Join("archive", "sources", name)); err != nil {
|
||||
t.Fatalf("contract verification source %s missing from local archive: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("schema", "saas-dispatcher.md")); err != nil {
|
||||
t.Fatalf("shared human-readable contract missing: %v", err)
|
||||
}
|
||||
if _, err := contracts.Files.ReadFile("upstream/v1/mq.schema.json"); err == nil {
|
||||
t.Fatal("retired upstream v1 contract still embedded with current runtime contracts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentContractDirectoryExcludesHistoricalGenerations(t *testing.T) {
|
||||
entries, err := os.ReadDir("local")
|
||||
entries, err := os.ReadDir("schema")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ func TestRuntimeBundleExcludesHistoricalLocalContracts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCurrentContractRead(t *testing.T) {
|
||||
for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json", "manifest.json"} {
|
||||
for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json"} {
|
||||
data, err := contracts.ReadCurrent(name)
|
||||
if err != nil || len(data) == 0 {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"", "v0.4/task-discovery-v0.4-proposal.schema.json", "../upstream/v1/mq.schema.json", "examples/mq-execute.json", "arbitrary.json"} {
|
||||
for _, name := range []string{"", "v0.4/task-discovery-v0.4-proposal.schema.json", "../upstream/v1/mq.schema.json", "examples/mq-execute.json", "arbitrary.json", "manifest.json"} {
|
||||
_, err := contracts.ReadCurrent(name)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid") {
|
||||
t.Fatalf("unexpected read of %q: %v", name, err)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCurrentContractTopology(t *testing.T) {
|
||||
Result route `json:"result"`
|
||||
} `json:"saas"`
|
||||
}
|
||||
data, err := os.ReadFile("local/mq-topology.json")
|
||||
data, err := os.ReadFile("schema/mq-topology.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/current/config-read.schema.json",
|
||||
"title": "Project-local read-only Dispatcher configuration; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/sip"},
|
||||
{"$ref": "#/$defs/providers"},
|
||||
{"$ref": "#/$defs/task"},
|
||||
{"$ref": "#/$defs/quota"},
|
||||
{"$ref": "#/$defs/error"}
|
||||
],
|
||||
"$defs": {
|
||||
"dispatcher_id": {"type": "string", "format": "uuid"},
|
||||
"tenant_id": {"type": "integer", "minimum": 1},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"sip": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["resource", "dispatcher_id", "trunks"],
|
||||
"properties": {
|
||||
"resource": {"const": "sip_config"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"revision": {"type": "integer", "minimum": 1},
|
||||
"trunks": {"type": "array", "items": {"$ref": "#/$defs/trunk"}}
|
||||
}
|
||||
},
|
||||
"trunk": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["trunk_id", "provider_id", "codec", "dial_prefix", "enabled", "server_host", "server_port", "transport", "auth_mode", "registration_required", "max_concurrent_calls", "caller_id", "schedule"],
|
||||
"properties": {
|
||||
"trunk_id": {"type": "string", "minLength": 1},
|
||||
"provider_id": {"type": "string", "minLength": 1},
|
||||
"codec": {"const": "PCMA"},
|
||||
"dial_prefix": {"type": "string"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"server_host": {"type": "string", "minLength": 1},
|
||||
"server_port": {"type": "integer", "minimum": 1, "maximum": 65535},
|
||||
"transport": {"type": ["string", "null"], "pattern": "^(?:[uU][dD][pP]|[tT][cC][pP]|[tT][lL][sS])$"},
|
||||
"auth_mode": {"type": ["string", "null"], "pattern": "^(?:[iI][pP]|[dD][iI][gG][eE][sS][tT]|[nN][oO][nN][eE])$"},
|
||||
"registration_required": {"type": ["boolean", "null"]},
|
||||
"max_concurrent_calls": {"type": ["integer", "null"], "minimum": 1},
|
||||
"caller_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"},
|
||||
"schedule": {"$ref": "#/$defs/weekly_schedule"}
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["resource", "dispatcher_id", "providers"],
|
||||
"properties": {
|
||||
"resource": {"const": "ai_providers"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"providers": {"type": "array", "items": {"$ref": "#/$defs/provider"}}
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["provider_id", "provider_code", "api_key"],
|
||||
"properties": {
|
||||
"provider_id": {"type": "string", "minLength": 1},
|
||||
"provider_code": {"type": "string", "minLength": 1},
|
||||
"name": {"type": "string"},
|
||||
"api_endpoint": {"type": "string"},
|
||||
"ws_endpoint": {"type": "string"},
|
||||
"api_key": {"type": "string", "minLength": 1, "$comment": "Connection credential; never log its content."},
|
||||
"extra_config": {"type": "object"}
|
||||
},
|
||||
"anyOf": [
|
||||
{"required": ["api_endpoint"], "properties": {"api_endpoint": {"minLength": 1}}},
|
||||
{"required": ["ws_endpoint"], "properties": {"ws_endpoint": {"minLength": 1}}}
|
||||
]
|
||||
},
|
||||
"task": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["resource", "dispatcher_id", "tenant_id", "task_id", "task_revision", "status", "max_concurrent_calls", "ring_timeout_ms", "max_call_duration_ms", "route_policy_id", "allowed_trunk_ids", "schedule", "agent"],
|
||||
"properties": {
|
||||
"resource": {"const": "task_config"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"tenant_id": {"$ref": "#/$defs/tenant_id"},
|
||||
"task_id": {"$ref": "#/$defs/task_id"},
|
||||
"task_revision": {"type": "integer", "minimum": 1},
|
||||
"status": {"enum": ["running", "paused", "stopped"]},
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"max_concurrent_calls": {"type": "integer", "minimum": 1},
|
||||
"ring_timeout_ms": {"type": "integer", "minimum": 1},
|
||||
"max_call_duration_ms": {"type": "integer", "minimum": 1},
|
||||
"route_policy_id": {"type": "string", "minLength": 1},
|
||||
"allowed_trunk_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "minLength": 1}},
|
||||
"schedule": {"$ref": "#/$defs/task_schedule"},
|
||||
"agent": {"$ref": "#/$defs/agent"}
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["resource", "dispatcher_id", "tenant_id", "quota_revision", "max_concurrent_calls"],
|
||||
"properties": {
|
||||
"resource": {"const": "tenant_quota"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"tenant_id": {"$ref": "#/$defs/tenant_id"},
|
||||
"quota_revision": {"type": "integer", "minimum": 1},
|
||||
"max_concurrent_calls": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["resource", "error"],
|
||||
"properties": {
|
||||
"resource": {"const": "error"},
|
||||
"error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string", "minLength": 1}, "message": {"type": "string", "minLength": 1}}}
|
||||
}
|
||||
},
|
||||
"task_schedule": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["time_zone", "starts_at", "ends_at", "weekly_windows", "excluded_dates"],
|
||||
"properties": {
|
||||
"time_zone": {"const": "Asia/Shanghai"},
|
||||
"starts_at": {"type": ["string", "null"], "format": "date-time"},
|
||||
"ends_at": {"type": ["string", "null"], "format": "date-time"},
|
||||
"weekly_windows": {"$ref": "#/$defs/weekly_windows"},
|
||||
"excluded_dates": {"type": "array", "uniqueItems": true, "items": {"type": "string", "format": "date"}}
|
||||
}
|
||||
},
|
||||
"weekly_schedule": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["time_zone", "weekly_windows"],
|
||||
"properties": {"time_zone": {"const": "Asia/Shanghai"}, "weekly_windows": {"$ref": "#/$defs/weekly_windows"}}
|
||||
},
|
||||
"weekly_windows": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
|
||||
"properties": {
|
||||
"monday": {"$ref": "#/$defs/windows"}, "tuesday": {"$ref": "#/$defs/windows"}, "wednesday": {"$ref": "#/$defs/windows"}, "thursday": {"$ref": "#/$defs/windows"}, "friday": {"$ref": "#/$defs/windows"}, "saturday": {"$ref": "#/$defs/windows"}, "sunday": {"$ref": "#/$defs/windows"}
|
||||
}
|
||||
},
|
||||
"windows": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["start", "end"], "properties": {"start": {"type": "string", "pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"}, "end": {"type": "string", "pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$"}}}},
|
||||
"agent": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["immutable", "mode", "asr"],
|
||||
"properties": {
|
||||
"immutable": {"const": true},
|
||||
"mode": {"enum": ["asr_only", "full_ai"]},
|
||||
"asr": {"$ref": "#/$defs/asr"},
|
||||
"llm": {"$ref": "#/$defs/llm"},
|
||||
"tts": {"$ref": "#/$defs/tts"},
|
||||
"prompt": {"$ref": "#/$defs/prompt"},
|
||||
"conversation": {"$ref": "#/$defs/conversation"}
|
||||
},
|
||||
"allOf": [
|
||||
{"if": {"properties": {"mode": {"const": "full_ai"}}}, "then": {"required": ["llm", "tts", "prompt", "conversation"]}},
|
||||
{"if": {"properties": {"mode": {"const": "asr_only"}}}, "then": {"not": {"anyOf": [{"required": ["llm"]}, {"required": ["tts"]}, {"required": ["prompt"]}]}}}
|
||||
]
|
||||
},
|
||||
"asr": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["provider_id", "language", "input"],
|
||||
"properties": {
|
||||
"provider_id": {"type": "string", "minLength": 1},
|
||||
"model": {"type": "string", "minLength": 1},
|
||||
"language": {"type": "string", "minLength": 1},
|
||||
"interim": {"type": "boolean"},
|
||||
"timeout_ms": {"type": "integer", "minimum": 1},
|
||||
"input": {"$ref": "#/$defs/audio_input"}
|
||||
}
|
||||
},
|
||||
"audio_input": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["encoding", "sample_rate_hz", "channels", "sample_width_bytes"],
|
||||
"properties": {
|
||||
"encoding": {"const": "pcm_s16le"}, "sample_rate_hz": {"type": "integer", "minimum": 8000}, "channels": {"const": 1}, "sample_width_bytes": {"const": 2}
|
||||
}
|
||||
},
|
||||
"llm": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["provider_id", "model"],
|
||||
"properties": {
|
||||
"provider_id": {"type": "string", "minLength": 1}, "model": {"type": "string", "minLength": 1}, "temperature": {"type": "number", "minimum": 0, "maximum": 2}, "max_tokens": {"type": "integer", "minimum": 1}, "timeout_ms": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["provider_id", "protocol", "model", "voice", "language_type", "format"],
|
||||
"properties": {
|
||||
"protocol": {"enum": ["dashscope_tts_http", "dashscope_task_websocket"], "$comment": "Task selects the protocol; never infer it from model names or endpoint availability."},
|
||||
"provider_id": {"type": "string", "minLength": 1}, "model": {"type": "string", "minLength": 1}, "voice": {"type": "string", "minLength": 1}, "language_type": {"type": "string", "minLength": 1}, "speed": {"type": "number", "minimum": 0.25, "maximum": 3}, "timeout_ms": {"type": "integer", "minimum": 1}, "format": {"type": "object", "additionalProperties": false, "required": ["encoding", "sample_rate_hz", "channels"], "properties": {"encoding": {"enum": ["pcm_s16le", "pcma"]}, "sample_rate_hz": {"type": "integer", "minimum": 8000}, "channels": {"const": 1}}}
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["text", "allowed_variables"],
|
||||
"properties": {"text": {"type": "string", "minLength": 1}, "allowed_variables": {"type": "array", "uniqueItems": true, "items": {"type": "string", "minLength": 1}}, "max_bytes": {"type": "integer", "minimum": 1}}
|
||||
},
|
||||
"hangup_keyword": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["name", "triggers", "closingRemark"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "pattern": "\\S"},
|
||||
"triggers": {"type": "array", "minItems": 1, "items": {"type": "string", "minLength": 1, "pattern": "\\S"}},
|
||||
"closingRemark": {"type": "string", "minLength": 1, "pattern": "\\S"}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"properties": {"opening": {"type": "string"}, "hangup_keywords": {"type": "array", "items": {"$ref": "#/$defs/hangup_keyword"}}, "allow_interrupt": {"type": "boolean"}, "silence_timeout_ms": {"type": "integer", "minimum": 1}, "max_duration_ms": {"type": "integer", "minimum": 1}, "max_turns": {"type": "integer", "minimum": 1}, "sentence_max_chars": {"type": "integer", "minimum": 1}, "max_pending_audio_chunks": {"type": "integer", "minimum": 1}}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"resource":"ai_providers","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","providers":[{"provider_id":"bailian-example","provider_code":"ali_bailian","name":"Isolated Bailian fixture","api_endpoint":"https://llm.example.invalid/v1","ws_endpoint":"wss://speech.example.invalid/api-ws/v1/inference","api_key":"example-only-not-a-real-secret","extra_config":{}}]}
|
||||
@@ -1 +0,0 @@
|
||||
{"resource":"error","error":{"code":"resource_not_found","message":"The requested resource is not available"}}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"resource":"ai_providers",
|
||||
"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6",
|
||||
"providers":[
|
||||
{"provider_id":"asr-example","provider_code":"volcengine","api_endpoint":"https://asr.example.invalid","api_key":"example-only-not-a-real-secret"},
|
||||
{"provider_id":"llm-example","provider_code":"openai_compatible","api_endpoint":"https://llm.example.invalid/v1","api_key":"example-only-not-a-real-secret"},
|
||||
{"provider_id":"tts-example","provider_code":"ali_bailian","api_endpoint":"https://tts.example.invalid/api/v1/services/aigc/multimodal-generation/generation","api_key":"example-only-not-a-real-secret"}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"resource":"tenant_quota","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"quota_revision":1,"max_concurrent_calls":3}
|
||||
@@ -1 +0,0 @@
|
||||
{"resource":"sip_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","trunks":[]}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"resource":"sip_config",
|
||||
"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6",
|
||||
"revision":8,
|
||||
"trunks":[{
|
||||
"trunk_id":"trunk-mock","provider_id":"provider-mock","codec":"PCMA","dial_prefix":"","enabled":true,
|
||||
"server_host":"sip.example.invalid","server_port":5060,
|
||||
"transport":null,"auth_mode":null,"registration_required":null,"max_concurrent_calls":null,
|
||||
"caller_id":"BD00000000",
|
||||
"schedule":{"time_zone":"Asia/Shanghai","weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]}}
|
||||
}]
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,
|
||||
"task_id":"task-asr","task_revision":1,"status":"running","name":"ASR example",
|
||||
"max_concurrent_calls":2,"ring_timeout_ms":30000,"max_call_duration_ms":120000,
|
||||
"route_policy_id":"route-mock","allowed_trunk_ids":["trunk-mock"],
|
||||
"schedule":{"time_zone":"Asia/Shanghai","starts_at":"2026-09-21T00:00:00+08:00","ends_at":null,"weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":[]},
|
||||
"agent":{"immutable":true,"mode":"asr_only","asr":{"provider_id":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"interim":false,"timeout_ms":5000}}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,
|
||||
"task_id":"task-full","task_revision":2,"status":"running","name":"Full AI example",
|
||||
"max_concurrent_calls":2,"ring_timeout_ms":30000,"max_call_duration_ms":120000,
|
||||
"route_policy_id":"route-mock","allowed_trunk_ids":["trunk-mock"],
|
||||
"schedule":{"time_zone":"Asia/Shanghai","starts_at":"2026-09-21T00:00:00+08:00","ends_at":null,"weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":["2026-10-01"]},
|
||||
"agent":{
|
||||
"immutable":true,"mode":"full_ai",
|
||||
"asr":{"provider_id":"asr-example","language":"zh-CN","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"interim":true,"timeout_ms":5000},
|
||||
"llm":{"provider_id":"llm-example","model":"example-chat","temperature":0,"max_tokens":256,"timeout_ms":5000},
|
||||
"tts":{"provider_id":"tts-example","protocol":"dashscope_tts_http","model":"qwen3-tts-flash","voice":"Cherry","language_type":"Chinese","speed":1,"format":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1},"timeout_ms":5000},
|
||||
"prompt":{"text":"Example only","allowed_variables":[],"max_bytes":32768},
|
||||
"conversation":{"opening":"Example greeting","hangup_keywords":[{"name":"结束通话","triggers":["不用了"],"closingRemark":"好的,祝您生活愉快。"}],"allow_interrupt":false,"silence_timeout_ms":3000,"max_duration_ms":120000,"max_turns":20,"sentence_max_chars":80,"max_pending_audio_chunks":32}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"resource":"task_config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,
|
||||
"task_id":"task-protocol-models","task_revision":2,"status":"running","name":"Isolated protocol fixture",
|
||||
"max_concurrent_calls":2,"ring_timeout_ms":30000,"max_call_duration_ms":120000,
|
||||
"route_policy_id":"route-mock","allowed_trunk_ids":["trunk-mock"],
|
||||
"schedule":{"time_zone":"Asia/Shanghai","starts_at":"2026-09-21T00:00:00+08:00","ends_at":null,"weekly_windows":{"monday":[{"start":"09:00","end":"20:00"}],"tuesday":[],"wednesday":[],"thursday":[],"friday":[],"saturday":[],"sunday":[]},"excluded_dates":[]},
|
||||
"agent":{
|
||||
"immutable":true,"mode":"full_ai",
|
||||
"asr":{"provider_id":"bailian-example","model":"fixture-asr-vnext","language":"en-US","input":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1,"sample_width_bytes":2},"timeout_ms":5000},
|
||||
"llm":{"provider_id":"bailian-example","model":"fixture-chat-vnext","temperature":0,"max_tokens":256,"timeout_ms":5000},
|
||||
"tts":{"provider_id":"bailian-example","protocol":"dashscope_task_websocket","model":"fixture-tts-vnext","voice":"fixture-speaker","language_type":"English","speed":1.25,"format":{"encoding":"pcm_s16le","sample_rate_hz":16000,"channels":1},"timeout_ms":5000},
|
||||
"prompt":{"text":"Isolated protocol fixture.","allowed_variables":[],"max_bytes":32768},
|
||||
"conversation":{"opening":"Hello.","hangup_keywords":[],"allow_interrupt":false,"silence_timeout_ms":3000,"max_duration_ms":120000,"max_turns":20,"sentence_max_chars":80,"max_pending_audio_chunks":32}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"payload":{"status":"applied"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"control-edit-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"edit","reason":"configuration changed"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"control-start-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"start","reason":"created"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"control-example","event_type":"task.control","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","action":"pause","reason":"operator","options":{"active_call_policy":"drain"}}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"status":"dispatched"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"status":"rejected","reason_code":null,"reason_message":"outside permitted time"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"call-example","event_type":"call.execute","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:00:00Z","payload":{"task_id":"task-asr","callee":"15003164745"}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"call-result-example","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"failed","reason_code":null,"status_line":null,"raw":null,"sip_capture_error":null,"reason_message":"recording storage unavailable","transcript":[],"opt_out":false,"recording":{}}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"call-result-example-2","event_type":"call.execute.result","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","tenant_id":1001,"issued_at":"2026-09-21T01:02:00Z","payload":{"task_id":"task-asr","callee":"15003164745","trunk_id":"trunk-mock","started_at":"2026-09-21T01:00:00Z","ended_at":"2026-09-21T01:01:00Z","duration_ms":60000,"outcome":"answered","reason_code":null,"status_line":null,"raw":null,"sip_capture_error":null,"reason_message":"answered, SIP status unavailable in isolated Mock","transcript":[{"turn_id":"turn-1","segment_id":"segment-1","role":"user","text":"Example utterance","start_ms":0,"end_ms":1000}],"opt_out":true,"recording":{"status":"uploaded","bucket":"example-bucket","object_key":"example/recording.wav","format":"wav","channels":1,"sample_rate_hz":16000,"duration_ms":60000,"size_bytes":64000,"checksum_sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}}
|
||||
@@ -1 +0,0 @@
|
||||
{"event_id":"sip-change-example","event_type":"sip.config","dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","payload":{"trunk_id":"trunk-mock","change_type":"disabled","revision":8}}
|
||||
@@ -1 +0,0 @@
|
||||
{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-end-token","tasks":[]}
|
||||
@@ -1 +0,0 @@
|
||||
{"dispatcher_id":"c046b893-8628-4589-ae50-619d049248a6","cursor":"opaque-page-token-1","tasks":[{"task_id":"task-asr","tenant_id":1001,"status":"running","task_revision":1}]}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"scope": "project-local-current",
|
||||
"status": "isolated-mock-only-external-unverified",
|
||||
"sources": {
|
||||
"docs/archive/sources/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada",
|
||||
"docs/archive/sources/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87",
|
||||
"docs/thirds/saas-dispatcher.md": "fbe73272923f505b00e1b0f6177cfa13c4a29ffab27c1cac15bed5b460e2cec2"
|
||||
},
|
||||
"bundle_sha256": "f23f612db4863ceec7c3be0c8e71e636dee18e9f0d89cf0dc0db948e439be2ea",
|
||||
"bundle_algorithm": "sha256 of sorted relative-path + space + sha256(file) + newline; only root-level JSON and examples/**/*.json, excluding manifest.json"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"ownership": "saas",
|
||||
"dispatcher": {
|
||||
"exchange": "agent-call.dispatchers.v1",
|
||||
"exchange_type": "topic",
|
||||
"durable": true,
|
||||
"control": {
|
||||
"routing_key": "d.<dispatcher_id>.control.in",
|
||||
"binding_key": "d.<dispatcher_id>.control.in",
|
||||
"queue": "agent-call.d.<dispatcher_id>.control.v1"
|
||||
},
|
||||
"task": {
|
||||
"routing_key": "d.<dispatcher_id>.task.<task_id>.in",
|
||||
"binding_key": "d.<dispatcher_id>.task.<task_id>.in",
|
||||
"queue": "agent-call.d.<dispatcher_id>.task.<task_id>.v1"
|
||||
}
|
||||
},
|
||||
"saas": {
|
||||
"exchange": "agent-call.saas.v1",
|
||||
"exchange_type": "topic",
|
||||
"durable": true,
|
||||
"result": {
|
||||
"routing_key": "d.<dispatcher_id>.out",
|
||||
"binding_key": "d.<dispatcher_id>.out",
|
||||
"queue": "agent-call.saas.events.v1"
|
||||
}
|
||||
},
|
||||
"dead_letter_exchange": "agent-call.dead-letter.v1",
|
||||
"provisioning_note": "SaaS owns exchanges, queues and exact bindings. For every active dispatcher bind its exact d.<dispatcher_id>.out key to the same SaaS result queue; Dispatcher has no configure permission. The queue name is the isolated Mock fixture, not a requirement on SaaS deployment naming."
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/current/mq.schema.json",
|
||||
"title": "Project-local Dispatcher MQ messages; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/sip_change"},
|
||||
{"$ref": "#/$defs/control_in"},
|
||||
{"$ref": "#/$defs/control_ack"},
|
||||
{"$ref": "#/$defs/execute_in"},
|
||||
{"$ref": "#/$defs/execute_ack"},
|
||||
{"$ref": "#/$defs/execute_result"}
|
||||
],
|
||||
"$defs": {
|
||||
"event_id": {"type": "string", "minLength": 1},
|
||||
"dispatcher_id": {"type": "string", "format": "uuid"},
|
||||
"tenant_id": {"type": "integer", "minimum": 1},
|
||||
"issued_at": {"type": "string", "format": "date-time"},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"sip_change": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "dispatcher_id", "payload"],
|
||||
"properties": {
|
||||
"event_id": {"$ref": "#/$defs/event_id"},
|
||||
"event_type": {"const": "sip.config"},
|
||||
"dispatcher_id": {"$ref": "#/$defs/dispatcher_id"},
|
||||
"payload": {"type": "object", "additionalProperties": false, "required": ["trunk_id", "change_type", "revision"], "properties": {
|
||||
"trunk_id": {"type": "string", "minLength": 1}, "change_type": {"enum": ["enabled", "disabled", "removed", "created"]}, "revision": {"type": "integer", "minimum": 1}
|
||||
}}
|
||||
}
|
||||
},
|
||||
"control_in": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
|
||||
"properties": {
|
||||
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "task.control"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"},
|
||||
"payload": {"type": "object", "additionalProperties": false, "required": ["task_id", "action", "reason"], "properties": {
|
||||
"task_id": {"$ref": "#/$defs/task_id"}, "action": {"enum": ["start", "pause", "resume", "stop", "edit"]}, "reason": {"type": "string", "minLength": 1},
|
||||
"options": {"type": "object", "additionalProperties": false, "properties": {"active_call_policy": {"enum": ["drain", "hangup"]}}}
|
||||
}, "allOf": [{"if": {"properties": {"action": {"const": "edit"}}}, "then": {"not": {"required": ["options"]}}}] }
|
||||
}
|
||||
},
|
||||
"control_ack": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "payload"],
|
||||
"properties": {
|
||||
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "task.control"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"},
|
||||
"payload": {"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"type": "string", "minLength": 1}}}
|
||||
}
|
||||
},
|
||||
"execute_in": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
|
||||
"properties": {
|
||||
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"},
|
||||
"payload": {"type": "object", "additionalProperties": false, "required": ["task_id", "callee"], "properties": {"task_id": {"$ref": "#/$defs/task_id"}, "callee": {"type": "string", "minLength": 1, "description": "SaaS event original callee; Dispatcher rejects non-ASCII digits or more than 32 digits with an application receipt after persisting the event"}}}
|
||||
}
|
||||
},
|
||||
"execute_ack": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
|
||||
"properties": {
|
||||
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"},
|
||||
"payload": {"oneOf": [
|
||||
{"type": "object", "additionalProperties": false, "required": ["status"], "properties": {"status": {"const": "dispatched"}}},
|
||||
{"type": "object", "additionalProperties": false, "required": ["status", "reason_code", "reason_message"], "properties": {"status": {"const": "rejected"}, "reason_code": {"type": "null"}, "reason_message": {"type": "string", "minLength": 1}}}
|
||||
]}
|
||||
}
|
||||
},
|
||||
"execute_result": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "dispatcher_id", "tenant_id", "issued_at", "payload"],
|
||||
"properties": {
|
||||
"event_id": {"$ref": "#/$defs/event_id"}, "event_type": {"const": "call.execute.result"}, "dispatcher_id": {"$ref": "#/$defs/dispatcher_id"}, "tenant_id": {"$ref": "#/$defs/tenant_id"}, "issued_at": {"$ref": "#/$defs/issued_at"},
|
||||
"payload": {"type": "object", "additionalProperties": false,
|
||||
"required": ["task_id", "callee", "trunk_id", "started_at", "ended_at", "duration_ms", "outcome", "reason_code", "status_line", "raw", "sip_capture_error", "transcript", "opt_out", "recording"],
|
||||
"properties": {
|
||||
"task_id": {"$ref": "#/$defs/task_id"}, "callee": {"type": "string", "minLength": 1}, "trunk_id": {"type": "string", "minLength": 1},
|
||||
"started_at": {"$ref": "#/$defs/issued_at"}, "ended_at": {"$ref": "#/$defs/issued_at"}, "duration_ms": {"type": "integer", "minimum": 0},
|
||||
"outcome": {"enum": ["answered", "no_answer", "failed"]},
|
||||
"reason_code": {"type": ["integer", "null"], "minimum": 100, "maximum": 699},
|
||||
"reason_message": {"type": "string", "minLength": 1},
|
||||
"status_line": {"type": ["string", "null"], "pattern": "^SIP/2\\.0 [1-6][0-9]{2} .+", "maxLength": 256},
|
||||
"raw": {"type": ["string", "null"], "minLength": 12, "maxLength": 65535},
|
||||
"sip_capture_error": {"type": ["string", "null"], "minLength": 1, "maxLength": 256},
|
||||
"transcript": {"type": "array", "items": {"type": "object", "additionalProperties": false, "required": ["turn_id", "segment_id", "role", "text", "start_ms", "end_ms"], "properties": {
|
||||
"turn_id": {"type": "string", "minLength": 1}, "segment_id": {"type": "string", "minLength": 1}, "role": {"enum": ["user", "assistant"]}, "text": {"type": "string"}, "start_ms": {"type": "integer", "minimum": 0}, "end_ms": {"type": "integer", "minimum": 0}
|
||||
}}},
|
||||
"opt_out": {"type": "boolean"},
|
||||
"recording": {"oneOf": [
|
||||
{"type": "object", "additionalProperties": false, "maxProperties": 0},
|
||||
{"$ref": "#/$defs/recording_uploaded"}
|
||||
]}
|
||||
},
|
||||
"allOf": [
|
||||
{"if": {"properties": {"reason_code": {"type": "null"}}, "required": ["reason_code"]}, "then": {"required": ["reason_message"]}}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording_uploaded": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["status", "bucket", "object_key", "format", "channels", "sample_rate_hz", "duration_ms", "size_bytes", "checksum_sha256"],
|
||||
"properties": {
|
||||
"status": {"const": "uploaded"}, "bucket": {"type": "string", "minLength": 1}, "object_key": {"type": "string", "minLength": 1}, "format": {"type": "string", "minLength": 1}, "channels": {"type": "integer", "minimum": 1}, "sample_rate_hz": {"type": "integer", "minimum": 1}, "duration_ms": {"type": "integer", "minimum": 0}, "size_bytes": {"type": "integer", "minimum": 0}, "checksum_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/current/task-discovery.schema.json",
|
||||
"title": "Project-local Dispatcher task list; external SaaS compatibility unverified",
|
||||
"oneOf": [{"$ref": "#/$defs/page"}, {"$ref": "#/$defs/error"}],
|
||||
"$defs": {
|
||||
"page": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["dispatcher_id", "cursor", "tasks"],
|
||||
"properties": {
|
||||
"schema_version": {},
|
||||
"control_seq": {},
|
||||
"dispatcher_id": {"type": "string", "format": "uuid"},
|
||||
"cursor": {"type": "string", "minLength": 1},
|
||||
"tasks": {"type": "array", "items": {"$ref": "#/$defs/task"}}
|
||||
},
|
||||
"$comment": "A page containing zero tasks and a cursor ends enumeration. Non-empty short pages never end enumeration. Persist non-empty pages before using their returned cursor."
|
||||
},
|
||||
"task": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["task_id", "tenant_id", "status", "task_revision"],
|
||||
"properties": {
|
||||
"schema_version": {},
|
||||
"control_seq": {},
|
||||
"task_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"},
|
||||
"tenant_id": {"type": "integer", "minimum": 1},
|
||||
"status": {"enum": ["running", "paused", "stopped"]},
|
||||
"task_revision": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["resource", "error"],
|
||||
"properties": {
|
||||
"resource": {"const": "error"},
|
||||
"error": {"type": "object", "additionalProperties": false, "required": ["code", "message"], "properties": {"code": {"type": "string", "minLength": 1}, "message": {"type": "string", "minLength": 1}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"scope": "shared-saas-dispatcher-current",
|
||||
"status": "isolated-mock-only-external-unverified",
|
||||
"sources": {
|
||||
"archive/sources/v0.5-proposal.md": "612fdaee50aff6aa7fbef16c2d469d99857646c6d2235617d0e67f6098cd7ada",
|
||||
"archive/sources/plan-saas-dispatcher-v05-v0.1.md": "666f39e56ea9f4b55661efcac82edd6f9729848e2d60e5f24cdf5aa3ac97ee87",
|
||||
"schema/saas-dispatcher.md": "ca4b4ce87ab2dea2cf1690543cde84ccd6a91d6ab838823bc8b5b0164d77ff2f"
|
||||
},
|
||||
"bundle_sha256": "7c14ee604a61c013ebc62446a2addeafa6e1923f7cbac54dced77b6c4bf34736",
|
||||
"bundle_algorithm": "sha256 of sorted path relative to schema/ + space + sha256(file) + newline; only schema root-level JSON and schema/examples/**/*.json; verification files and invalid fixtures excluded"
|
||||
}
|
||||
Submodule
+1
Submodule contracts/schema added at 9bbf281675
@@ -1,71 +0,0 @@
|
||||
# 按接口拆分的 JSON Schema
|
||||
|
||||
此目录提供五类 HTTP 响应和六类 MQ 消息的独立校验入口,使用 JSON Schema Draft 2020-12。重复且约束相同的结构通过相对 `$ref` 引用 `common.schema.json`,仅单个入口使用的结构保留在该入口的 `$defs` 中。
|
||||
|
||||
此目录不替换或删除 `contracts/local/` 的原文件,不修改现有程序的校验入口、嵌入资源或来源清单。现行定义仍以 `contracts/local/` 和 `docs/thirds/saas-dispatcher.md` 为准,拆分副本必须随其同步;单元测试核对任务 TTS 定义完全一致,防止协议字段等内容漂移。程序仍使用原有 Schema;此目录尚未接入运行或发布流程,不构成外部 SaaS 签收。
|
||||
|
||||
## HTTP 响应
|
||||
|
||||
下列入口校验响应 JSON 正文,不校验请求路径、查询参数或 HTTP 状态码。每个入口仅接受自己的成功响应或公共错误响应。
|
||||
|
||||
| 接口 | Schema | 现有示例(相对本目录) |
|
||||
| --- | --- | --- |
|
||||
| `GET /internal/v1/dispatcher/sip` | [`http-sip.schema.json`](http-sip.schema.json) | [`config-read-sip.json`](../local/examples/config-read-sip.json)、[`config-read-sip-no-revision.json`](../local/examples/config-read-sip-no-revision.json) |
|
||||
| `GET /internal/v1/dispatcher/task/:task_id` | [`http-task-detail.schema.json`](http-task-detail.schema.json) | [`config-read-task-full.json`](../local/examples/config-read-task-full.json)、[`config-read-task-asr.json`](../local/examples/config-read-task-asr.json) |
|
||||
| `GET /internal/v1/dispatcher/tasks` | [`http-task-list.schema.json`](http-task-list.schema.json) | [`task-discovery-page.json`](../local/examples/task-discovery-page.json)、[`task-discovery-end.json`](../local/examples/task-discovery-end.json) |
|
||||
| `GET /internal/v1/dispatcher/tenant/:tenant_id/quota` | [`http-tenant-quota.schema.json`](http-tenant-quota.schema.json) | [`config-read-quota.json`](../local/examples/config-read-quota.json) |
|
||||
| `GET /internal/v1/dispatcher/ai-providers` | [`http-ai-providers.schema.json`](http-ai-providers.schema.json) | [`config-read-providers.json`](../local/examples/config-read-providers.json)、[`config-read-bailian-providers.json`](../local/examples/config-read-bailian-providers.json) |
|
||||
|
||||
公共错误响应示例:[`config-read-error.json`](../local/examples/config-read-error.json)。
|
||||
|
||||
## MQ 消息
|
||||
|
||||
按消息类型和方向选择入口。`task.control` 的请求和回执共用事件名称,`call.execute` 的请求和派发回执也共用事件名称,不能仅依据 `event_type` 选择 Schema。
|
||||
|
||||
| 消息 | 方向 | Schema | 现有示例(相对本目录) |
|
||||
| --- | --- | --- | --- |
|
||||
| `sip.config` | SaaS → Dispatcher | [`mq-sip-config.schema.json`](mq-sip-config.schema.json) | [`mq-sip-change.json`](../local/examples/mq-sip-change.json) |
|
||||
| `task.control` 请求 | SaaS → Dispatcher | [`mq-task-control-request.schema.json`](mq-task-control-request.schema.json) | [`mq-control.json`](../local/examples/mq-control.json)、[`mq-control-start.json`](../local/examples/mq-control-start.json)、[`mq-control-edit.json`](../local/examples/mq-control-edit.json) |
|
||||
| `task.control` 回执 | Dispatcher → SaaS | [`mq-task-control-receipt.schema.json`](mq-task-control-receipt.schema.json) | [`mq-control-ack.json`](../local/examples/mq-control-ack.json) |
|
||||
| `call.execute` 请求 | SaaS → Dispatcher | [`mq-call-execute-request.schema.json`](mq-call-execute-request.schema.json) | [`mq-execute.json`](../local/examples/mq-execute.json) |
|
||||
| `call.execute` 派发/拒绝回执 | Dispatcher → SaaS | [`mq-call-execute-receipt.schema.json`](mq-call-execute-receipt.schema.json) | [`mq-execute-ack.json`](../local/examples/mq-execute-ack.json)、[`mq-execute-rejected.json`](../local/examples/mq-execute-rejected.json) |
|
||||
| `call.execute.result` 最终结果 | Dispatcher → SaaS | [`mq-call-execute-result.schema.json`](mq-call-execute-result.schema.json) | [`mq-result-uploaded.json`](../local/examples/mq-result-uploaded.json)、[`mq-result-no-recording.json`](../local/examples/mq-result-no-recording.json) |
|
||||
|
||||
## 公共定义与引用
|
||||
|
||||
[`common.schema.json`](common.schema.json) 仅包含 `$defs`,不是独立消息的校验入口:
|
||||
|
||||
- `dispatcher_id`、`tenant_id`、`task_id`:公共身份字段约束。
|
||||
- `event_id`、`issued_at`:MQ 事件身份和时间约束。
|
||||
- `task_status`:任务详情与任务列表共用的任务状态。
|
||||
- `error`:公共 HTTP 错误响应。
|
||||
- `weekly_windows`、`windows`:任务和线路共用的每周时段。
|
||||
|
||||
引用示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
任务时段和线路时段仍分别定义:任务保留有效起止时间和排除日期,线路不包含排除日期。任务详情内的 AI、SIP 线路和最终结果中的录音结构不增加跨入口抽象。
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 根据上表选择一个入口文件,而不是把所有入口合并为一个 `oneOf`。
|
||||
2. 向校验器注册所用入口及其公共依赖。各文件的 `$id` 是稳定的资源标识,不是要求联网访问的下载地址;注册时使用文件内的 `$id`;若按本地文件 URI 加载,须将这些 `$id` 映射到对应的本地资源。
|
||||
3. 离线解析 `./common.schema.json`。不得静默忽略缺失引用或改为联网下载;缺少依赖必须报错。移动或分发文件时保留相对目录关系。
|
||||
4. 开启 `format` 校验,使 `uuid`、`date-time`、`date` 的检查与现有 Go 校验入口一致。现有 `github.com/santhosh-tekuri/jsonschema/v6` 支持 `AddResource`、`Compile` 和 `AssertFormat`,无需新增依赖。
|
||||
5. 对响应或消息的 JSON 正文执行校验。资源归属、任务授权、时段关系、线路可用性等业务检查仍由程序执行,Schema 通过不代表业务获准。
|
||||
|
||||
## 来源及维护边界
|
||||
|
||||
- 五个配置响应(含公共错误、身份和时段定义)的来源:[`../local/config-read.schema.json`](../local/config-read.schema.json);任务列表来源:[`../local/task-discovery.schema.json`](../local/task-discovery.schema.json)。
|
||||
- 六个 MQ 入口的来源:[`../local/mq.schema.json`](../local/mq.schema.json)。
|
||||
- 拆分仅改变定义位置和引用方式,不增加、删除或放宽原有字段约束。任务列表对 `schema_version`、`control_seq` 的现有忽略规则也保持不变。
|
||||
- HTTP 入口保留原有公共错误分支;MQ 入口严格区分原有消息分支。MQ 回执字段按原 Schema 保留,不自行补充字段。
|
||||
- 此目录不引用旧目录中的 Schema;旧目录只作为拆分来源和现有示例位置。独立分发时仅需所用入口及 `common.schema.json`。
|
||||
- 原文件、程序和清单仍保持原样。未来如需接入此目录或改变字段,必须另行确认范围,并同步核验新旧定义,不能假定本次已完成切换。
|
||||
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/common.schema.json",
|
||||
"title": "Shared HTTP and MQ definitions; not a standalone message schema",
|
||||
"$defs": {
|
||||
"dispatcher_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"tenant_id": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_-]{1,128}$"
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"resource",
|
||||
"error"
|
||||
],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"const": "error"
|
||||
},
|
||||
"error": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"weekly_windows": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday"
|
||||
],
|
||||
"properties": {
|
||||
"monday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
},
|
||||
"tuesday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
},
|
||||
"wednesday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
},
|
||||
"thursday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
},
|
||||
"friday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
},
|
||||
"saturday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
},
|
||||
"sunday": {
|
||||
"$ref": "#/$defs/windows"
|
||||
}
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"start",
|
||||
"end"
|
||||
],
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:[01][0-9]|2[0-3]):[0-5][0-9]$"
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:(?:[01][0-9]|2[0-3]):[0-5][0-9]|24:00)$"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"issued_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"task_status": {
|
||||
"enum": [
|
||||
"running",
|
||||
"paused",
|
||||
"stopped"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/http-ai-providers.schema.json",
|
||||
"title": "GET /internal/v1/dispatcher/ai-providers response; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"resource",
|
||||
"dispatcher_id",
|
||||
"providers"
|
||||
],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"const": "ai_providers"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/provider"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "./common.schema.json#/$defs/error"
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"provider": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"provider_id",
|
||||
"provider_code",
|
||||
"api_key"
|
||||
],
|
||||
"properties": {
|
||||
"provider_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"provider_code": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"ws_endpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"$comment": "Connection credential; never log its content."
|
||||
},
|
||||
"extra_config": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"api_endpoint"
|
||||
],
|
||||
"properties": {
|
||||
"api_endpoint": {
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"ws_endpoint"
|
||||
],
|
||||
"properties": {
|
||||
"ws_endpoint": {
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/http-sip.schema.json",
|
||||
"title": "GET /internal/v1/dispatcher/sip response; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"resource",
|
||||
"dispatcher_id",
|
||||
"trunks"
|
||||
],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"const": "sip_config"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"trunks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/trunk"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "./common.schema.json#/$defs/error"
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"trunk": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"trunk_id",
|
||||
"provider_id",
|
||||
"codec",
|
||||
"dial_prefix",
|
||||
"enabled",
|
||||
"server_host",
|
||||
"server_port",
|
||||
"transport",
|
||||
"auth_mode",
|
||||
"registration_required",
|
||||
"max_concurrent_calls",
|
||||
"caller_id",
|
||||
"schedule"
|
||||
],
|
||||
"properties": {
|
||||
"trunk_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"provider_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"codec": {
|
||||
"const": "PCMA"
|
||||
},
|
||||
"dial_prefix": {
|
||||
"type": "string"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"server_host": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"server_port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"transport": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"pattern": "^(?:[uU][dD][pP]|[tT][cC][pP]|[tT][lL][sS])$"
|
||||
},
|
||||
"auth_mode": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"pattern": "^(?:[iI][pP]|[dD][iI][gG][eE][sS][tT]|[nN][oO][nN][eE])$"
|
||||
},
|
||||
"registration_required": {
|
||||
"type": [
|
||||
"boolean",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"max_concurrent_calls": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 1
|
||||
},
|
||||
"caller_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"
|
||||
},
|
||||
"schedule": {
|
||||
"$ref": "#/$defs/weekly_schedule"
|
||||
}
|
||||
}
|
||||
},
|
||||
"weekly_schedule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"time_zone",
|
||||
"weekly_windows"
|
||||
],
|
||||
"properties": {
|
||||
"time_zone": {
|
||||
"const": "Asia/Shanghai"
|
||||
},
|
||||
"weekly_windows": {
|
||||
"$ref": "./common.schema.json#/$defs/weekly_windows"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/http-task-detail.schema.json",
|
||||
"title": "GET /internal/v1/dispatcher/task/:task_id response; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"resource",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"task_revision",
|
||||
"status",
|
||||
"max_concurrent_calls",
|
||||
"ring_timeout_ms",
|
||||
"max_call_duration_ms",
|
||||
"route_policy_id",
|
||||
"allowed_trunk_ids",
|
||||
"schedule",
|
||||
"agent"
|
||||
],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"const": "task_config"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"task_id": {
|
||||
"$ref": "./common.schema.json#/$defs/task_id"
|
||||
},
|
||||
"task_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"status": {
|
||||
"$ref": "./common.schema.json#/$defs/task_status"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"max_concurrent_calls": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"ring_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_call_duration_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"route_policy_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"allowed_trunk_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
"$ref": "#/$defs/task_schedule"
|
||||
},
|
||||
"agent": {
|
||||
"$ref": "#/$defs/agent"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "./common.schema.json#/$defs/error"
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"task_schedule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"time_zone",
|
||||
"starts_at",
|
||||
"ends_at",
|
||||
"weekly_windows",
|
||||
"excluded_dates"
|
||||
],
|
||||
"properties": {
|
||||
"time_zone": {
|
||||
"const": "Asia/Shanghai"
|
||||
},
|
||||
"starts_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"ends_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"weekly_windows": {
|
||||
"$ref": "./common.schema.json#/$defs/weekly_windows"
|
||||
},
|
||||
"excluded_dates": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"immutable",
|
||||
"mode",
|
||||
"asr"
|
||||
],
|
||||
"properties": {
|
||||
"immutable": {
|
||||
"const": true
|
||||
},
|
||||
"mode": {
|
||||
"enum": [
|
||||
"asr_only",
|
||||
"full_ai"
|
||||
]
|
||||
},
|
||||
"asr": {
|
||||
"$ref": "#/$defs/asr"
|
||||
},
|
||||
"llm": {
|
||||
"$ref": "#/$defs/llm"
|
||||
},
|
||||
"tts": {
|
||||
"$ref": "#/$defs/tts"
|
||||
},
|
||||
"prompt": {
|
||||
"$ref": "#/$defs/prompt"
|
||||
},
|
||||
"conversation": {
|
||||
"$ref": "#/$defs/conversation"
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "full_ai"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"llm",
|
||||
"tts",
|
||||
"prompt",
|
||||
"conversation"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "asr_only"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"not": {
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"llm"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"tts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"asr": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"provider_id",
|
||||
"language",
|
||||
"input"
|
||||
],
|
||||
"properties": {
|
||||
"provider_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"interim": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"input": {
|
||||
"$ref": "#/$defs/audio_input"
|
||||
}
|
||||
}
|
||||
},
|
||||
"audio_input": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"encoding",
|
||||
"sample_rate_hz",
|
||||
"channels",
|
||||
"sample_width_bytes"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"const": "pcm_s16le"
|
||||
},
|
||||
"sample_rate_hz": {
|
||||
"type": "integer",
|
||||
"minimum": 8000
|
||||
},
|
||||
"channels": {
|
||||
"const": 1
|
||||
},
|
||||
"sample_width_bytes": {
|
||||
"const": 2
|
||||
}
|
||||
}
|
||||
},
|
||||
"llm": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"provider_id",
|
||||
"model"
|
||||
],
|
||||
"properties": {
|
||||
"provider_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"provider_id",
|
||||
"protocol",
|
||||
"model",
|
||||
"voice",
|
||||
"language_type",
|
||||
"format"
|
||||
],
|
||||
"properties": {
|
||||
"protocol": {
|
||||
"enum": ["dashscope_tts_http", "dashscope_task_websocket"],
|
||||
"$comment": "Task selects the protocol; never infer it from model names or endpoint availability."
|
||||
},
|
||||
"provider_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"voice": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"language_type": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"speed": {
|
||||
"type": "number",
|
||||
"minimum": 0.25,
|
||||
"maximum": 3
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"format": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"encoding",
|
||||
"sample_rate_hz",
|
||||
"channels"
|
||||
],
|
||||
"properties": {
|
||||
"encoding": {
|
||||
"enum": [
|
||||
"pcm_s16le",
|
||||
"pcma"
|
||||
]
|
||||
},
|
||||
"sample_rate_hz": {
|
||||
"type": "integer",
|
||||
"minimum": 8000
|
||||
},
|
||||
"channels": {
|
||||
"const": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"text",
|
||||
"allowed_variables"
|
||||
],
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"allowed_variables": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"max_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"hangup_keyword": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"triggers",
|
||||
"closingRemark"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"triggers": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
},
|
||||
"closingRemark": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"opening": {
|
||||
"type": "string"
|
||||
},
|
||||
"hangup_keywords": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/hangup_keyword"
|
||||
}
|
||||
},
|
||||
"allow_interrupt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"silence_timeout_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_duration_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_turns": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"sentence_max_chars": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_pending_audio_chunks": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/http-task-list.schema.json",
|
||||
"title": "GET /internal/v1/dispatcher/tasks response; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"dispatcher_id",
|
||||
"cursor",
|
||||
"tasks"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {},
|
||||
"control_seq": {},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"cursor": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/task"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$comment": "A page containing zero tasks and a cursor ends enumeration. Non-empty short pages never end enumeration. Persist non-empty pages before using their returned cursor."
|
||||
},
|
||||
{
|
||||
"$ref": "./common.schema.json#/$defs/error"
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"task": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"task_id",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"task_revision"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {},
|
||||
"control_seq": {},
|
||||
"task_id": {
|
||||
"$ref": "./common.schema.json#/$defs/task_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "./common.schema.json#/$defs/task_status"
|
||||
},
|
||||
"task_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/http-tenant-quota.schema.json",
|
||||
"title": "GET /internal/v1/dispatcher/tenant/:tenant_id/quota response; external SaaS compatibility unverified",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"resource",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"quota_revision",
|
||||
"max_concurrent_calls"
|
||||
],
|
||||
"properties": {
|
||||
"resource": {
|
||||
"const": "tenant_quota"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"quota_revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"max_concurrent_calls": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "./common.schema.json#/$defs/error"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/mq-call-execute-receipt.schema.json",
|
||||
"title": "Dispatcher to SaaS: call.execute dispatch/rejection receipt; external SaaS compatibility unverified",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event_id",
|
||||
"event_type",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"issued_at",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"$ref": "./common.schema.json#/$defs/event_id"
|
||||
},
|
||||
"event_type": {
|
||||
"const": "call.execute"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"issued_at": {
|
||||
"$ref": "./common.schema.json#/$defs/issued_at"
|
||||
},
|
||||
"payload": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "dispatched"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"status",
|
||||
"reason_code",
|
||||
"reason_message"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "rejected"
|
||||
},
|
||||
"reason_code": {
|
||||
"type": "null"
|
||||
},
|
||||
"reason_message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/mq-call-execute-request.schema.json",
|
||||
"title": "SaaS to Dispatcher: call.execute request; external SaaS compatibility unverified",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event_id",
|
||||
"event_type",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"issued_at",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"$ref": "./common.schema.json#/$defs/event_id"
|
||||
},
|
||||
"event_type": {
|
||||
"const": "call.execute"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"issued_at": {
|
||||
"$ref": "./common.schema.json#/$defs/issued_at"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"task_id",
|
||||
"callee"
|
||||
],
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"$ref": "./common.schema.json#/$defs/task_id"
|
||||
},
|
||||
"callee": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "SaaS event original callee; Dispatcher rejects non-ASCII digits or more than 32 digits with an application receipt after persisting the event"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/mq-call-execute-result.schema.json",
|
||||
"title": "Dispatcher to SaaS: call.execute.result final result; external SaaS compatibility unverified",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event_id",
|
||||
"event_type",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"issued_at",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"$ref": "./common.schema.json#/$defs/event_id"
|
||||
},
|
||||
"event_type": {
|
||||
"const": "call.execute.result"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"issued_at": {
|
||||
"$ref": "./common.schema.json#/$defs/issued_at"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"task_id",
|
||||
"callee",
|
||||
"trunk_id",
|
||||
"started_at",
|
||||
"ended_at",
|
||||
"duration_ms",
|
||||
"outcome",
|
||||
"reason_code",
|
||||
"status_line",
|
||||
"raw",
|
||||
"sip_capture_error",
|
||||
"transcript",
|
||||
"opt_out",
|
||||
"recording"
|
||||
],
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"$ref": "./common.schema.json#/$defs/task_id"
|
||||
},
|
||||
"callee": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"trunk_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"started_at": {
|
||||
"$ref": "./common.schema.json#/$defs/issued_at"
|
||||
},
|
||||
"ended_at": {
|
||||
"$ref": "./common.schema.json#/$defs/issued_at"
|
||||
},
|
||||
"duration_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"outcome": {
|
||||
"enum": [
|
||||
"answered",
|
||||
"no_answer",
|
||||
"failed"
|
||||
]
|
||||
},
|
||||
"reason_code": {
|
||||
"type": [
|
||||
"integer",
|
||||
"null"
|
||||
],
|
||||
"minimum": 100,
|
||||
"maximum": 699
|
||||
},
|
||||
"reason_message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"status_line": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"pattern": "^SIP/2\\.0 [1-6][0-9]{2} .+",
|
||||
"maxLength": 256
|
||||
},
|
||||
"raw": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"minLength": 12,
|
||||
"maxLength": 65535
|
||||
},
|
||||
"sip_capture_error": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"minLength": 1,
|
||||
"maxLength": 256
|
||||
},
|
||||
"transcript": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"turn_id",
|
||||
"segment_id",
|
||||
"role",
|
||||
"text",
|
||||
"start_ms",
|
||||
"end_ms"
|
||||
],
|
||||
"properties": {
|
||||
"turn_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"segment_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"role": {
|
||||
"enum": [
|
||||
"user",
|
||||
"assistant"
|
||||
]
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"start_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"end_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"opt_out": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"recording": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"maxProperties": 0
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/recording_uploaded"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"reason_code": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reason_code"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"reason_message"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"recording_uploaded": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"status",
|
||||
"bucket",
|
||||
"object_key",
|
||||
"format",
|
||||
"channels",
|
||||
"sample_rate_hz",
|
||||
"duration_ms",
|
||||
"size_bytes",
|
||||
"checksum_sha256"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "uploaded"
|
||||
},
|
||||
"bucket": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"object_key": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"channels": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"sample_rate_hz": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"duration_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"size_bytes": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"checksum_sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/mq-sip-config.schema.json",
|
||||
"title": "SaaS to Dispatcher: sip.config notification; external SaaS compatibility unverified",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event_id",
|
||||
"event_type",
|
||||
"dispatcher_id",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"$ref": "./common.schema.json#/$defs/event_id"
|
||||
},
|
||||
"event_type": {
|
||||
"const": "sip.config"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"trunk_id",
|
||||
"change_type",
|
||||
"revision"
|
||||
],
|
||||
"properties": {
|
||||
"trunk_id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"change_type": {
|
||||
"enum": [
|
||||
"enabled",
|
||||
"disabled",
|
||||
"removed",
|
||||
"created"
|
||||
]
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/mq-task-control-receipt.schema.json",
|
||||
"title": "Dispatcher to SaaS: task.control receipt; external SaaS compatibility unverified",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event_id",
|
||||
"event_type",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"$ref": "./common.schema.json#/$defs/event_id"
|
||||
},
|
||||
"event_type": {
|
||||
"const": "task.control"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://go-sip.local/contracts/schema/mq-task-control-request.schema.json",
|
||||
"title": "SaaS to Dispatcher: task.control request; external SaaS compatibility unverified",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"event_id",
|
||||
"event_type",
|
||||
"dispatcher_id",
|
||||
"tenant_id",
|
||||
"issued_at",
|
||||
"payload"
|
||||
],
|
||||
"properties": {
|
||||
"event_id": {
|
||||
"$ref": "./common.schema.json#/$defs/event_id"
|
||||
},
|
||||
"event_type": {
|
||||
"const": "task.control"
|
||||
},
|
||||
"dispatcher_id": {
|
||||
"$ref": "./common.schema.json#/$defs/dispatcher_id"
|
||||
},
|
||||
"tenant_id": {
|
||||
"$ref": "./common.schema.json#/$defs/tenant_id"
|
||||
},
|
||||
"issued_at": {
|
||||
"$ref": "./common.schema.json#/$defs/issued_at"
|
||||
},
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"task_id",
|
||||
"action",
|
||||
"reason"
|
||||
],
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"$ref": "./common.schema.json#/$defs/task_id"
|
||||
},
|
||||
"action": {
|
||||
"enum": [
|
||||
"start",
|
||||
"pause",
|
||||
"resume",
|
||||
"stop",
|
||||
"edit"
|
||||
]
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"active_call_policy": {
|
||||
"enum": [
|
||||
"drain",
|
||||
"hangup"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"const": "edit"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"not": {
|
||||
"required": [
|
||||
"options"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,38 @@
|
||||
package contracts_test
|
||||
package contracts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStandaloneTaskTTSMatchesCurrentContract(t *testing.T) {
|
||||
load := func(path string) map[string]any {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err = json.Unmarshal(body, &schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return schema
|
||||
func TestEverySharedSchemaCompilesOffline(t *testing.T) {
|
||||
files, err := fs.Glob(Files, "schema/*.schema.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current := load("local/config-read.schema.json")
|
||||
exported := load("schema/http-task-detail.schema.json")
|
||||
expected := current["$defs"].(map[string]any)["tts"]
|
||||
actual := exported["$defs"].(map[string]any)["tts"]
|
||||
if !reflect.DeepEqual(expected, actual) {
|
||||
t.Fatal("standalone task TTS schema has drifted from contracts/local; update the exported copy, not the runtime contract")
|
||||
if len(files) < 15 {
|
||||
t.Fatalf("missing shared schemas: %d", len(files))
|
||||
}
|
||||
for _, file := range files {
|
||||
t.Run(file, func(t *testing.T) {
|
||||
if _, err := CompileCurrent(strings.TrimPrefix(file, "schema/")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileCurrentRejectsInvalidNames(t *testing.T) {
|
||||
for _, name := range []string{"", "../mq.schema.json", "mq-topology.json", "manifest.json", "missing.schema.json"} {
|
||||
if _, err := CompileCurrent(name); err == nil {
|
||||
t.Fatalf("accepted invalid schema %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnregisteredSchemaReferencesCannotReachNetwork(t *testing.T) {
|
||||
if _, err := (offlineSchemaLoader{}).Load("https://example.invalid/schema.json"); err == nil || !strings.Contains(err.Error(), "network loading is disabled") {
|
||||
t.Fatalf("unregistered reference did not fail explicitly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestSharedContractIsOnlyRuntimeSource(t *testing.T) {
|
||||
if err != nil || !strings.HasPrefix(string(out), "160000 ") {
|
||||
t.Fatalf("contracts/schema must be a Git submodule: %s (%v)", out, err)
|
||||
}
|
||||
for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq.topology.json", "manifest.json"} {
|
||||
for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json", "mq-topology.json"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
embedded, err := ReadCurrent(name)
|
||||
if err != nil {
|
||||
@@ -33,6 +33,17 @@ func TestSharedContractIsOnlyRuntimeSource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedContractExcludesLocalVerificationMaterials(t *testing.T) {
|
||||
for _, name := range []string{"manifest.json", "verify.py", "test_verify.py", "examples/invalid/mq-string-tenant.json", "docs/archive/sources/v0.5-proposal.md"} {
|
||||
if _, err := Files.ReadFile("schema/" + name); !os.IsNotExist(err) {
|
||||
t.Errorf("verification material %s is embedded or cannot be checked: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := ReadCurrent("manifest.json"); err == nil || !strings.Contains(err.Error(), "invalid current contract") {
|
||||
t.Fatalf("local verification manifest must not be a runtime contract: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedEntrypointsReferenceCanonicalDefinitions(t *testing.T) {
|
||||
for _, name := range []string{"config-read.schema.json", "task-discovery.schema.json", "mq.schema.json"} {
|
||||
data, err := os.ReadFile("schema/" + name)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from verify import verify_bundle
|
||||
|
||||
|
||||
class ContractVerificationTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name) / 'contracts'
|
||||
source = Path(__file__).parent
|
||||
self.root.mkdir()
|
||||
for name in ('schema', 'archive', 'examples'):
|
||||
shutil.copytree(source / name, self.root / name,
|
||||
ignore=shutil.ignore_patterns('.git', '__pycache__'))
|
||||
shutil.copyfile(source / 'manifest.json', self.root / 'manifest.json')
|
||||
|
||||
def test_current_bundle(self):
|
||||
verify_bundle(self.root)
|
||||
|
||||
def test_changed_schema_requires_manifest_update(self):
|
||||
path = self.root / 'schema/http-sip.schema.json'
|
||||
path.write_text(path.read_text() + '\n')
|
||||
with self.assertRaisesRegex(ValueError, 'bundle hash mismatch'):
|
||||
verify_bundle(self.root)
|
||||
|
||||
def test_missing_schema_reference_is_rejected_even_with_updated_hash(self):
|
||||
path = self.root / 'schema/config-read.schema.json'
|
||||
data = json.loads(path.read_text())
|
||||
data['oneOf'][0]['$ref'] = './missing.schema.json#'
|
||||
path.write_text(json.dumps(data))
|
||||
with self.assertRaisesRegex(ValueError, 'missing schema reference'):
|
||||
verify_bundle(self.root, write=True)
|
||||
|
||||
def test_invalid_pointer_is_rejected(self):
|
||||
path = self.root / 'schema/config-read.schema.json'
|
||||
data = json.loads(path.read_text())
|
||||
data['oneOf'][0]['$ref'] = './http-sip.schema.json#/$defs/missing'
|
||||
path.write_text(json.dumps(data))
|
||||
with self.assertRaisesRegex(ValueError, 'invalid schema pointer'):
|
||||
verify_bundle(self.root, write=True)
|
||||
|
||||
def test_archived_source_is_not_rehashed(self):
|
||||
path = next((self.root / 'archive/sources').glob('*.md'))
|
||||
path.write_text(path.read_text() + '\n')
|
||||
with self.assertRaisesRegex(ValueError, 'source hash mismatch'):
|
||||
verify_bundle(self.root, write=True)
|
||||
|
||||
def test_changed_business_document_requires_manifest_update(self):
|
||||
path = self.root / 'schema/saas-dispatcher.md'
|
||||
path.write_text(path.read_text() + '\n')
|
||||
with self.assertRaisesRegex(ValueError, 'source hash mismatch'):
|
||||
verify_bundle(self.root)
|
||||
verify_bundle(self.root, write=True)
|
||||
verify_bundle(self.root)
|
||||
|
||||
def test_shared_directory_contains_only_integration_files(self):
|
||||
shared = self.root / 'schema'
|
||||
allowed = {'README.md', 'saas-dispatcher.md', 'mq-topology.json', 'examples'}
|
||||
for path in shared.iterdir():
|
||||
if path.name not in allowed and not path.name.endswith('.schema.json'):
|
||||
self.fail(f'non-integration file in shared repository: {path.name}')
|
||||
self.assertFalse((shared / 'examples/invalid').exists())
|
||||
self.assertTrue((self.root / 'examples/invalid').is_dir())
|
||||
|
||||
def test_readme_separates_schemas_examples_and_reference_links(self):
|
||||
shared = self.root / 'schema'
|
||||
readme = (shared / 'README.md').read_text()
|
||||
introduction, schemas, examples = re.split(
|
||||
r'^## (?:Schema 契约|Examples)\n', readme, flags=re.MULTILINE)
|
||||
self.assertNotIn('|', introduction)
|
||||
self.assertEqual(set(re.findall(r'\]\(([^)]+)\)', introduction)),
|
||||
{'saas-dispatcher.md', 'mq-topology.json'})
|
||||
for section, expected in (
|
||||
(schemas, {p.name for p in shared.glob('*.schema.json')}),
|
||||
(examples, {p.relative_to(shared).as_posix()
|
||||
for p in (shared / 'examples').glob('*.json')})):
|
||||
rows = section.strip().splitlines()
|
||||
self.assertEqual(rows[:2], ['| 文件 | 作用 |', '| --- | --- |'])
|
||||
self.assertTrue(all(re.fullmatch(
|
||||
r'\| \[`[^`]+`\]\([^)]+\) \| .+ \|', row) for row in rows[2:]))
|
||||
links = re.findall(r'\[`([^`]+)`\]\(([^)]+)\)', section)
|
||||
self.assertTrue(all(label == target for label, target in links))
|
||||
self.assertEqual(len(links), len(expected), 'duplicate or missing file rows')
|
||||
self.assertEqual({target for _, target in links}, expected)
|
||||
links = re.findall(r'\]\(([^)]+)\)', readme)
|
||||
expected = {p.relative_to(shared).as_posix() for p in shared.rglob('*')
|
||||
if p.is_file() and p.name not in {'.git', 'README.md'}}
|
||||
self.assertEqual(len(links), len(set(links)), 'duplicate file links')
|
||||
self.assertEqual(set(links), expected)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline reference/provenance checks; --write updates current document/bundle hashes."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def verify_bundle(root, write=False):
|
||||
shared = root / 'schema'
|
||||
manifest_path = root / 'manifest.json'
|
||||
manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||
for relative, expected in manifest['sources'].items():
|
||||
actual = digest(root / relative)
|
||||
if write and relative == 'schema/saas-dispatcher.md':
|
||||
manifest['sources'][relative] = actual
|
||||
elif actual != expected:
|
||||
raise ValueError(f'source hash mismatch: {relative}')
|
||||
|
||||
schemas = {p.name: json.loads(p.read_text(encoding='utf-8')) for p in shared.glob('*.schema.json')}
|
||||
def check_refs(value, name):
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
check_refs(item, name)
|
||||
elif isinstance(value, dict):
|
||||
if '$ref' in value:
|
||||
ref = value['$ref']
|
||||
filename, _, pointer = ref.partition('#')
|
||||
target_name = filename.removeprefix('./') if filename else name
|
||||
if target_name not in schemas:
|
||||
raise ValueError(f'missing schema reference: {name}: {ref}')
|
||||
target = schemas[target_name]
|
||||
if pointer:
|
||||
if not pointer.startswith('/'):
|
||||
raise ValueError(f'invalid schema pointer: {name}: {ref}')
|
||||
try:
|
||||
for part in pointer[1:].split('/'):
|
||||
key = part.replace('~1', '/').replace('~0', '~')
|
||||
target = target[int(key)] if isinstance(target, list) else target[key]
|
||||
except (KeyError, ValueError, IndexError, TypeError) as exc:
|
||||
raise ValueError(f'invalid schema pointer: {name}: {ref}') from exc
|
||||
for item in value.values():
|
||||
check_refs(item, name)
|
||||
for name, schema in schemas.items():
|
||||
check_refs(schema, name)
|
||||
|
||||
paths = sorted((p for p in shared.rglob('*.json') if
|
||||
p.parent == shared or p.relative_to(shared).parts[0] == 'examples'),
|
||||
key=lambda p: p.relative_to(shared).as_posix())
|
||||
if len(paths) < 10:
|
||||
raise ValueError('missing contract examples or schemas')
|
||||
for path in paths:
|
||||
json.loads(path.read_text(encoding='utf-8'))
|
||||
listing = ''.join(f'{p.relative_to(shared).as_posix()} {digest(p)}\n' for p in paths)
|
||||
actual = hashlib.sha256(listing.encode('utf-8')).hexdigest()
|
||||
if write:
|
||||
manifest['bundle_sha256'] = actual
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
||||
elif actual != manifest['bundle_sha256']:
|
||||
raise ValueError(f'contract bundle hash mismatch: {actual} != {manifest["bundle_sha256"]}')
|
||||
return len(paths)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--write', action='store_true')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
count = verify_bundle(Path(__file__).resolve().parent, args.write)
|
||||
except (ValueError, OSError) as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
print(f'shared contract: {count} JSON files; source hashes, bundle hash and offline references valid')
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
## 数据
|
||||
|
||||
准备仅自己可读的目录,包含 `sip.json`、`providers.json`、`quota.json` 和 `tasks/*.json`;所有 JSON 文件须为普通文件且权限为 `0600`。分别对应 [`contracts/local/`](../../../contracts/local/) 的 `sip_config`、`ai_providers`、`tenant_quota`、`task_config`;每份快照的 `dispatcher_id` 必须相同,任务须属于同一租户且文件名为 `<task_id>.json`。启动时全部校验并读入内存;更改文件后须重新启动,不热替换在途任务。现有 `contracts/local/examples/` **仅用于隔离 Mock 测试**,不得直接复制成真实拨号授权。
|
||||
准备仅自己可读的目录,包含 `sip.json`、`providers.json`、`quota.json` 和 `tasks/*.json`;所有 JSON 文件须为普通文件且权限为 `0600`。分别对应 共享仓库的 [`contracts/schema/`](../../../contracts/schema/README.md) 的 `sip_config`、`ai_providers`、`tenant_quota`、`task_config`;每份快照的 `dispatcher_id` 必须相同,任务须属于同一租户且文件名为 `<task_id>.json`。启动时全部校验并读入内存;更改文件后须重新启动,不热替换在途任务。现有 `contracts/schema/examples/` **仅用于隔离 Mock 测试**,不得直接复制成真实拨号授权。
|
||||
|
||||
## 结束语配置与 MQ 数据边界
|
||||
|
||||
- `tasks/<task_id>.json` 使用现行对象数组形式的 `agent.conversation.hangup_keywords`;配置示例见 [`config-read-task-full.json`](../../../contracts/local/examples/config-read-task-full.json),唯一字段定义见 [`config-read.schema.json`](../../../contracts/local/config-read.schema.json)。旧字符串数组、缺失或空的组名/触发词/结束语会导致启动拒绝。
|
||||
- `tasks/<task_id>.json` 使用现行对象数组形式的 `agent.conversation.hangup_keywords`;配置示例见 [`config-read-task-full.json`](../../../contracts/schema/examples/config-read-task-full.json),唯一字段定义见 [`config-read.schema.json`](../../../contracts/schema/config-read.schema.json)。旧字符串数组、缺失或空的组名/触发词/结束语会导致启动拒绝。
|
||||
- 新配置由正式 HTTP 任务接口返回;Mock 不自行生成、覆盖或注入结束语。Agent 使用该任务快照合成结束语,播完后挂断。
|
||||
- 单次投递的 `call.execute.payload` 仍仅包含 `task_id` 和原始 `callee`,不得增加 `agent`、`hangup_keywords` 或 `closingRemark`;任务、线路、AI 参数和结束语均由 HTTP 快照决定。MQ 字段以 [`mq.schema.json`](../../../contracts/local/mq.schema.json) 为准。
|
||||
- 单次投递的 `call.execute.payload` 仍仅包含 `task_id` 和原始 `callee`,不得增加 `agent`、`hangup_keywords` 或 `closingRemark`;任务、线路、AI 参数和结束语均由 HTTP 快照决定。MQ 字段以 [`mq.schema.json`](../../../contracts/schema/mq.schema.json) 为准。
|
||||
- 本地 HTTP 与真实临时 RabbitMQ 测试均核对这一区分;本地测试不更新测试机私有快照,也不授权实际呼叫。
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -28,7 +28,7 @@ func testDataDir(t *testing.T) string {
|
||||
"quota.json": "config-read-quota.json",
|
||||
"tasks/task-full.json": "config-read-task-full.json",
|
||||
} {
|
||||
original, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "local", "examples", source))
|
||||
original, err := os.ReadFile(filepath.Join("..", "..", "..", "contracts", "schema", "examples", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestOneShotResultMustMatchSourceIdentityAndPersistBeforeReceipt(t *testing.
|
||||
sourceID := "event-once-1"
|
||||
resultID := expectedResultEventID(data.dispatcherID, sourceID)
|
||||
result := map[string]any{}
|
||||
example, err := os.ReadFile("../../../contracts/local/examples/mq-result-uploaded.json")
|
||||
example, err := os.ReadFile("../../../contracts/schema/examples/mq-result-uploaded.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func TestSaaSMockAwaitsExactResultBeforeStoppingCapture(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eventID := "test-" + uuid.NewString()
|
||||
result, err := os.ReadFile("../../../contracts/local/examples/mq-result-uploaded.json")
|
||||
result, err := os.ReadFile("../../../contracts/schema/examples/mq-result-uploaded.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# 文档入口
|
||||
|
||||
- **唯一现行人类可读规范:** [`thirds/saas-dispatcher.md`](thirds/saas-dispatcher.md),包含当前 SaaS↔Dispatcher 契约、业务规则、第三方交互和本地验收边界。
|
||||
- **可执行的机器合同:** [`../contracts/local/manifest.json`](../contracts/local/manifest.json) 及同目录 Schema、MQ 拓扑和正反例;内部 Agent RPC 以 [`../proto/agent/agent.proto`](../proto/agent/agent.proto) 为准。Markdown 不替代机器校验。
|
||||
- **唯一最终契约:** [`sip-contracts`](https://gitee.com/zzmbac/sip-contracts),由 SaaS 与本项目共同维护;本项目用 [`../contracts/schema/`](../contracts/schema/README.md) Git submodule 固定提交,不另存独立定义。维护与修改前更新步骤见 [`contracts.md`](contracts.md)。内部 Agent RPC 以 [`../proto/agent/agent.proto`](../proto/agent/agent.proto) 为准。Markdown 不替代机器校验。
|
||||
- **可核查的验收事实:** [`evidence/saas-dispatcher-p08-acceptance.md`](evidence/saas-dispatcher-p08-acceptance.md)。P01–P08 只在单节点/单 Agent/单 Cell/单租户的隔离 Mock 中验证;真实 SaaS/management、MQ 应用收讫、OSS/AI/SIP/Asterisk/ECS 和生产均未签收。
|
||||
- **归档来源与历史:** [`archive/sources/README.md`](archive/sources/README.md) 保存合同来源及使用者修改的旧文档原件;[`archive/upstream/README.md`](archive/upstream/README.md) 保存可离线核验的上游 v1;[`archive/plan-saas-dispatcher-completed.md`](archive/plan-saas-dispatcher-completed.md) 是已完成的阶段计划,不是第二份现行规范。其它旧证据和工作包均只供追溯,不作为兼容回退;其指向已清理旧提案的原路径需通过合并前提交 `d85480c` 的 Git 历史查阅,不能当作现行链接。
|
||||
- **部署与开发约束:** [`../deploys/README.md`](../deploys/README.md)、[`../AGENTS.md`](../AGENTS.md)。实际新主机/版本/Cell 验证及任何真实试拨必须另获授权并执行部署、状态、拨号前受限抓包与诊断门禁。
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 共享契约维护
|
||||
|
||||
## 唯一来源与目录边界
|
||||
|
||||
最终契约仓库:`git@gitee.com:zzmbac/sip-contracts.git`,分支 `main`。
|
||||
SaaS 开发人员与本项目共同维护业务规范、Schema、MQ 拓扑和正常示例。
|
||||
本项目的 `contracts/schema` 是 Git submodule,只记录消费的确定提交,不维护另一份本地定义。
|
||||
原 `contracts/local` 已移除;`docs/thirds/saas-dispatcher.md` 只提供导航。
|
||||
内部 `proto/agent/agent.proto` 仍属于本项目,不移入 SaaS 业务契约。
|
||||
|
||||
| 位置 | 用途 |
|
||||
| --- | --- |
|
||||
| `contracts/schema/` | 双方对接所需的规范、Schema、拓扑和正常示例;README 分开列出 Schema 和 Examples 用途,业务规范及 MQ 拓扑在正文单独链接说明。 |
|
||||
| `contracts/manifest.json` | 本项目记录的当前规范、冻结历史来源和对接文件完整性 hash;不是 SaaS 对接数据。 |
|
||||
| `contracts/verify.py`、`contracts/test_verify.py` | 离线检查 Schema 引用、来源和文件完整性,并验证共享目录及 README 的范围。 |
|
||||
| `contracts/examples/invalid/` | 本项目拒绝错误消息和配置的测试材料,不作为正常对接示例。 |
|
||||
| `contracts/archive/sources/` | 两份历史来源的原字节,只用于验证,不作为当前规范。 |
|
||||
| `contracts/AGENTS.md`、`contracts/*_test.go` | 本项目维护规则与契约测试。 |
|
||||
|
||||
## 获取项目
|
||||
|
||||
```sh
|
||||
git clone --recurse-submodules git@gitee.com:zzmbac/go-sip-agent.git
|
||||
# 或在已有项目中获取固定契约版本:
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
构建仅将固定提交的 Schema、拓扑和正常示例嵌入制品,不包含本项目 manifest、错误示例或历史来源,
|
||||
不在运行时读取 Git checkout 或在线加载引用。普通 `make check` 不自动联网追新。
|
||||
缺失/未初始化契约、错误来源、未提交修改、指针不一致、hash 错误或未登记的引用均明确失败,没有旧文件兜底。
|
||||
发布清单分别记录共享仓库地址、准确提交、拓扑 hash 和本项目验证 manifest 的 hash。
|
||||
|
||||
## 每次调研或修改契约之前
|
||||
|
||||
```sh
|
||||
# 同步本项目自己的远程分支;失败或分叉时停止,不覆盖现有修改。
|
||||
git fetch origin
|
||||
# 依据当前分支的上游进行 --ff-only 合并。
|
||||
make contracts-update
|
||||
```
|
||||
|
||||
`contracts-update` 获取共享仓库最新 `main` 并只允许快进;更新前验证当前指针、来源及干净状态,
|
||||
更新后使用本项目 `contracts/` 中的验证材料检查共享契约。检查通过后仅暂存 `contracts/schema` 新指针。
|
||||
脚本不提交其他文件,不自动改写本项目 manifest;若新提交与本项目记录的 hash 不同,检查失败,
|
||||
须核对差异后按下述流程显式更新验证记录,不得回退到旧文件。
|
||||
网络错误、分叉或现有契约修改时停止;不自动 stash/reset,不改写已有历史。
|
||||
若指针有意改变但尚未暂存,须先核对该提交再 `git add contracts/schema`,不能用脚本掩盖未知版本。
|
||||
|
||||
## 修改与同步交付
|
||||
|
||||
1. 确认已完成上述更新;在共享仓库修改对应 Schema、业务规范、拓扑或正常示例。
|
||||
定义只维护一次,聚合入口只引用独立 Schema;README 将 Schema 与 Examples 分表,业务规范和 MQ 拓扑使用独立链接说明。
|
||||
2. 在本项目补充错误示例或测试,并核对共享文件变更后显式更新验证记录:
|
||||
|
||||
```sh
|
||||
python3 contracts/verify.py --write
|
||||
python3 contracts/verify.py
|
||||
python3 -m unittest discover -s contracts -p 'test_*.py' -v
|
||||
go test ./contracts
|
||||
```
|
||||
|
||||
`--write` 只更新现行规范/bundle hash,不允许重算冻结历史来源来掩盖改写。
|
||||
3. 提交并推送共享仓库,只选择本次修改的对接文件:
|
||||
|
||||
```sh
|
||||
git -C contracts/schema add <本次契约文件>
|
||||
git -C contracts/schema commit -m '<契约变更说明>'
|
||||
git -C contracts/schema push origin HEAD:main
|
||||
git add contracts/schema
|
||||
make contracts-update
|
||||
```
|
||||
|
||||
4. 同步本项目实现、测试和文档;运行 `make contract-check`、`make check`、`make release-check-local`。
|
||||
5. 明确暂存本次项目改动及 `contracts/schema` 指针,提交本项目;通知 SaaS 开发人员按同一 commit SHA 同步。
|
||||
|
||||
**仅推送契约仓库不算完成。所有契约变更必须同步修改本项目;双方评审和联调需记录同一准确提交。**
|
||||
已有 P01–P08、A01–A12、K01–K16 项目内证据见 `docs/evidence/saas-dispatcher-p08-acceptance.md`。
|
||||
本次目录整理不改变业务规则、不部署、不拨号;Mock、hash 或离线验证不替代 SaaS、供应商或生产签收。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 生产与测试统一诊断工具安装(2026-10-08)
|
||||
|
||||
## 授权与规则
|
||||
|
||||
使用者明确批准:在既有测试机安装 tshark 并确保 tcpdump 具备;修改环境安装脚本,生产和测试均预装,不按环境跳过。安装不代表启用抓包,仍需显式 `--debug/-D` 和既有操作者权限;没有更改一般用户抓包权限、用户组或 sudo 规则。
|
||||
|
||||
本次仅在登记测试机执行安装,没有操作其他生产主机;没有真实外呼、AI 调用或现场抓包,没有更新业务制品或重启服务。
|
||||
|
||||
## 安装脚本
|
||||
|
||||
新增 `deploys/cell/install-diagnostics.sh`:root-only、无生产/测试切换或跳过参数,非交互使用系统 apt 安装 tcpdump/tshark。包索引、安装、版本检查和 SIP/RTP dissector 校验任何一步失败都明确退出,不降级。
|
||||
|
||||
- Go 部署包包含此 helper,由 `deploys/install.sh` 完成既有包/平台/发布授权校验后无条件调用。
|
||||
- 原生 Asterisk 包包含 helper 和独立 SHA-256;用户安装器完成原有平台/制品/不覆盖检查后,通过现有 `sudo -n` 执行 helper,再改变用户安装目录。
|
||||
- 没有放松 Debian/架构限制或生产发布门禁,`--nonprod` 仍只属于原生平台核验,不影响工具预装。
|
||||
- helper SHA-256:`59325fb184e4b117a50c29614cf680c8808e63c0589026bd1b9fd767864dd41f`。
|
||||
|
||||
## 测试机事实
|
||||
|
||||
SSH 指纹按登记值严格核对后,将相同 helper 私密暂存、核对 hash,再执行安装。
|
||||
|
||||
- Debian 13;tcpdump **4.99.5**、TShark **4.4.19**。
|
||||
- apt 操作:0 个已有软件包升级,19 个新包安装;没有执行系统升级或业务服务操作。
|
||||
- 真实 tshark 协议能力检查通过;sip-call 所需 **17 个** SIP/SDP/RTP/网络字段全部存在。
|
||||
- Asterisk 保持 active,MainPID **777461** 未改变;活动通道 **0**。
|
||||
- `pjsip.conf` 仍为 `c319476e7e4962008899a11ae0461db282eed3d63871b2d8adcd143812408cf8`,没有修改线路。
|
||||
- 抓包权限未改动,未开启实际抓包。主机缺少 getcap,因此没有据工具存在推断 tcpdump 的一般用户抓包权限。
|
||||
|
||||
私密安装日志及核验在本机 `.local/diagnostics-install-20261008-VqOqz7/`;现场补充记录为 `~/sip-call/diagnostics-verification-20261008.json`(0600)。原工具 `verification.json` 保留初始部署时间点的历史事实,不覆盖;现场 README 已同步当前工具状态。
|
||||
|
||||
## 本地验证与边界
|
||||
|
||||
`scripts/check-install-diagnostics.sh` 在 network-none 的 root 容器内用隔离包命令验证:production/development/test 都调用相同安装命令;拒绝环境切换和非 root;索引/安装/工具执行/缺 RTP 协议的失败均不吞;两个安装入口及两种包均包含 helper。已加入本地验收。
|
||||
|
||||
验证期间主工作区被另一条合同迁移工作切换并改动,当前合同目录/测试出现不一致;本任务没有覆盖、撤销或修补这些无关变动,也不声称该并行工作区已整体通过。
|
||||
|
||||
本任务使用独立 worktree `31466bd` + 已提交 `0f5efc6031d230eec475b6f08caed339bd268f84` 中仅七个安装/验收脚本的变更进行稳定核验:
|
||||
|
||||
- Shell 语法、安装专项、格式和原有 Proto/合同校验通过。
|
||||
- 全仓 `go test -race ./... -count=1 -timeout=120s`、`go vet -x ./...`、覆盖率测试通过。
|
||||
- 排除生成代码的业务单元覆盖率 **70.6%**。
|
||||
- 两个制品构建和本地发布门禁通过;真实隔离 RabbitMQ/HTTPS/mTLS Mock 端到端通过。
|
||||
- 完整 make 汇总曾超时;初次主工作区还有一次既有 ASR fixture 的 2 秒 deadline 超时,专项连续五次复核通过,未改 AI 源码或放宽时限。上述独立步骤逐项核验,不能把超时汇总当成成功记录。
|
||||
|
||||
这只证明安装逻辑、工具能力和隔离验证;不证明真实线路、现场逐通抓证或生产发布签收。实际 `-D` 仍须在每次执行时通过工具与抓包权限/就绪检查。
|
||||
@@ -1,57 +1,16 @@
|
||||
# SaaS ↔ Dispatcher:项目内唯一现行通信约定
|
||||
# SaaS ↔ Dispatcher 契约入口
|
||||
|
||||
> 本文是 SaaS↔Dispatcher **唯一当前人类可读规范**,合并了已确认的业务规则、第三方交互及本地验收边界。字段、路由、正反例的唯一可执行依据仍为 [`contracts/local/`](../../contracts/local/);内部 RPC 以 [`proto/agent/agent.proto`](../../proto/agent/agent.proto) 为准,不在 Markdown 复制第二套 Schema。历史提案与批准记录保留在 [`archive/sources/`](../archive/sources/README.md),不构成并行版本。**P01–P08 仅完成项目内隔离 Mock 验证;真实 SaaS/management、供应商与生产均未签收,本文不授权真实外呼。**
|
||||
唯一最终契约由 [sip-contracts](https://gitee.com/zzmbac/sip-contracts) 统一维护:
|
||||
`git@gitee.com:zzmbac/sip-contracts.git`。
|
||||
|
||||
## HTTP:五类只读配置
|
||||
本项目通过 `contracts/schema` Git submodule 使用确定提交,不再维护独立的业务规范、Schema、拓扑或正常示例;本项目验证材料单独放在 `contracts/`。
|
||||
|
||||
只由归属 D 使用 `X-DISPATCHER-ID` 和 `X-DISPATCHER-SECRET-KEY` 读取;HTTP header 名大小写无关。返回中的 `dispatcher_id` 须等于本 D,任务/配额的 `tenant_id` 是正整数;不接受旧字符串租户 ID。不在日志、样例或证据中打印真实凭据。业务正文只使用当前 Schema,任务列表中的 `schema_version`/`control_seq` 仅作忽略的元数据,不参与业务;其它业务正文不接受这些旧字段,也不接受旧版 `tenant_key`、`agent_version_id`、授权期限或内容摘要等删除字段;也不根据字段做版本分支或从 HTTP 失败回退旧 MQ 配置。
|
||||
- [现行业务规范](../../contracts/schema/saas-dispatcher.md)
|
||||
- [共享文件用途表:Schema、拓扑与正常示例](../../contracts/schema/README.md)
|
||||
- [本项目来源及完整性记录](../../contracts/manifest.json)
|
||||
- [修改前更新与双方同步步骤](../contracts.md)
|
||||
- [本项目内部 Agent RPC](../../proto/agent/agent.proto)
|
||||
- [项目内隔离 Mock 验收与外部缺口](../evidence/saas-dispatcher-p08-acceptance.md)
|
||||
|
||||
| 资源 | 方法和路径 | 应用规则 | 正例 |
|
||||
| --- | --- | --- | --- |
|
||||
| SIP 全量 | `GET /internal/v1/dispatcher/sip` | SaaS `revision` 可省略,最新读取的完整快照覆盖旧配置,不以其大小拒绝更新;D 持久分配内部加载代次,重启/重试不丢失待加载内容,并用该代次核对 Agent/Asterisk 实际加载。`transport/auth_mode` 判定忽略大小写;未知 `transport/auth_mode/registration_required/max_concurrent_calls` 为 `null`,不能作为可执行线路默认值;变更时关准入、排空并核验 Agent/Asterisk 实际加载 | [`sip`](../../contracts/local/examples/config-read-sip.json) |
|
||||
| AI provider 全量 | `GET /internal/v1/dispatcher/ai-providers` | 启动时一次读取全局列表,本进程全部任务共用到下一次重启;运行中 start/resume 不重复拉取,服务商变更须重启后才生效。任务以 `provider_id` 关联连接记录并决定 ASR/LLM/TTS 用途及模型;provider 只提供供应商标识和 `api_endpoint/ws_endpoint/api_key` 等连接信息,不要求 `role/adapter/enabled`。按明确供应商及任务调用协议选择已有实现,模型 ID 是原样请求参数,不作本地型号白名单或协议选择依据;TTS 必须在任务中明确 `protocol`,provider 不增加 role/adapter。缺失连接、未知供应商/协议或不支持的参数显式拒绝;服务端拒绝模型时如实失败,不切换协议/模型重试 | [`providers`](../../contracts/local/examples/config-read-providers.json) |
|
||||
| 任务 | `GET /internal/v1/dispatcher/task/{task_id}` | 归属、状态和 `task_revision` 校验后持久绑定同一 `agent`/provider 快照;不可变摘要由本项目计算。同一 revision 内容不同拒绝;ASR-only 只需 ASR,不强制 LLM/TTS;0 和 false 原样保留 | [`ASR-only`](../../contracts/local/examples/config-read-task-asr.json) · [`full AI`](../../contracts/local/examples/config-read-task-full.json) |
|
||||
| 任务列表 | `GET /internal/v1/dispatcher/tasks`,后续 `?after=<cursor>` | `schema_version/control_seq` 忽略且不作版本或去重依据;其它未知字段仍拒绝。首次/重启完整读到 **带 cursor 且 tasks=[]** 的终止页;非空短页不能提前结束。非空页持久成功后才使用下一 cursor;控制队列积压处理前不开新任务准入。不使用旧 snapshot_id/watermark/mode=snapshot | [`page`](../../contracts/local/examples/task-discovery-page.json) · [`end`](../../contracts/local/examples/task-discovery-end.json) |
|
||||
| 租户额度 | `GET /internal/v1/dispatcher/tenant/{tenant_id}/quota` | `quota_revision` 为业务修订号;未知占用不得算成已释放 | [`quota`](../../contracts/local/examples/config-read-quota.json) |
|
||||
|
||||
HTTP 非 200、正文不合法、缺字段、归属冲突、缓存失效或读取失败时拒绝新的相关准入并记录脱敏错误;已持久接纳的执行继续使用原快照。错误体示例 [`error`](../../contracts/local/examples/config-read-error.json),不能当成功配置解析。任务仅有运行/暂停/终止;终止是不可逆 stop,同一 `task_id` 不再启用。任务配置可在运行或暂停时通过 `task.control.edit` 更新:D 按事件重读该任务及租户额度,校验归属、修订号与 AI 绑定后持久切换未来呼叫所用快照;已发出的通话始终保留原快照,`edit` 不改变暂停/停止状态,也不下发 Agent 控制。旧修订号不得覆盖新修订号,停止的任务不能被 `edit` 恢复。启动/重启时完整读取一次任务列表及独立的全量 SIP;运行中不定时刷新任务列表或任务详情,新增任务由 `start` 触发读取,`resume` 仍重读最新任务和租户额度后解除人工暂停。start/resume/edit 均沿用启动时的 AI 服务商列表和已生效 SIP 快照,不另行读取或向 Agent 核验全量 SIP。HTTP 旧 running 不得覆盖已持久的 pause/stop;resume 经最新任务配置核验后只能解除人工暂停。SIP 无常规定时拉取;`sip.config` 持久关准入并触发 SIP 全量读取,等待已接纳呼叫结束及 Agent 加载核验后,将新 SIP 绑定到本地任务快照(不重读任务列表),然后才可恢复准入。通知待处理时可重试,读取或校验失败不回退旧 SIP,也不开准入。
|
||||
|
||||
## MQ:固定 v1 传输,唯一事件清单
|
||||
|
||||
RabbitMQ 是 Topic,**SaaS 独占创建、绑定、退役 exchange/queue,D 只消费/发布,并仅在 stop 时对自己负责的任务队列执行 purge;不创建、删除或绑定队列,无 configure 权限**。机器拓扑见 [`mq-topology.json`](../../contracts/local/mq-topology.json)。外呼维持每 D、每任务独立的 `d.<dispatcher_id>.task.<task_id>.in` 路由及 `agent-call.d.<dispatcher_id>.task.<task_id>.v1` 队列;每 D 单独控制路由 `d.<dispatcher_id>.control.in` 及队列 `agent-call.d.<dispatcher_id>.control.v1`。SaaS 将所有 D 的输出 `d.<dispatcher_id>.out` **精确绑定至同一个结果队列**(本地 Mock 名 `agent-call.saas.events.v1`,不是强制 SaaS 实际队列名)。入站 exchange `agent-call.dispatchers.v1`;出站 exchange `agent-call.saas.v1`;死信 exchange `agent-call.dead-letter.v1`。不广播后靠正文过滤,不使用独立通配段,不把被动声明/发布 confirm 冒充指定队列已收到:须以实际路由、mandatory return 与 confirm 联合验证。
|
||||
|
||||
`event_id` 是入站/回执关联和内部防重复处理的消息身份;`dispatcher_id` 是 D 身份,`tenant_id` 是正整数。**不是所有事件共用一个统一必填信封**:`sip.config` 没有 tenant_id/issued_at;回执按其示例字段,最终结果使用 `issued_at` 而非 `occurred_at`。正文的 `schema_version` 已移除。只接受以下外发事件及必要入站事件;所有字段直接按 [`mq.schema.json`](../../contracts/local/mq.schema.json) 和正例校验:
|
||||
|
||||
| 事件 | 方向和处理规则 | 正例 |
|
||||
| --- | --- | --- |
|
||||
| `sip.config` | SaaS→D;通知触发全量重新读取和本地加载代次核验,通知 revision 不是 HTTP 快照的新旧门槛,不用通知正文代替全量 | [`notification`](../../contracts/local/examples/mq-sip-change.json) |
|
||||
| `task.control` | SaaS→D;start/pause/resume/stop/edit,无控制去重/CAS 字段。start 读取新任务配置及租户配额并持久绑定,无 Agent 控制动作;resume 重读最新任务配置和额度后解除人工暂停;edit 重读并持久更新已有任务的配置和额度,不改变控制状态,不向 Agent 发动作,不接收 `options`,已发出通话沿用原快照;三者均沿用启动时 AI 服务商列表及已生效 SIP 快照,不重复拉取全局服务商或 SIP,也不额外核验 SIP;pause/stop 发给 Agent,省略 `active_call_policy` 默认 **hangup**,显式仅 drain/hangup。配置读取、归属或 AI 核验失败时不接纳,SIP 待处理时新呼叫仍关闭;stop 先停止该任务消费者(使在途未确认消息回队列),持久关闭任务准入并抑制本地待执行指令,再对该任务队列执行 purge 清除当时待投递消息;purge 失败不发送成功回执、保持准入关闭并重试原控制消息。SaaS 须停止继续投递已 stop 的任务;purge 不阻止之后新投递,这些消息不执行。成功操作后才回应用回执,回执不是已完成活跃通话排空/挂断、SaaS 已停止投递或未来消息不存在的证据;stop 同 ID 不可恢复 | [`start`](../../contracts/local/examples/mq-control-start.json) · [`edit`](../../contracts/local/examples/mq-control-edit.json) · [`control`](../../contracts/local/examples/mq-control.json) · [`ack`](../../contracts/local/examples/mq-control-ack.json) |
|
||||
| `call.execute` | SaaS→D 仅 `{task_id,callee}`;一次指令保留独立消息/执行身份,路由/AI/时限从绑定任务读取,主叫由绑定 SIP 线路快照的唯一 `caller_id` 决定。`callee` 原值由通过归属及任务校验的 SaaS 事件确定,不从任务快照或本地号码列表另选;`dispatched` 表示已发出呼叫指令,**不表示接通**;号码格式不合规则回 `rejected,reason_code:null,reason_message`、不拨号不发最终结果、不暂停整任务 | [`execute`](../../contracts/local/examples/mq-execute.json) · [`dispatched`](../../contracts/local/examples/mq-execute-ack.json) · [`rejected`](../../contracts/local/examples/mq-execute-rejected.json) |
|
||||
| `call.execute.result` | D→SaaS;按 `task_id` + 原号码关联,每次呼叫仅一份最终结果;不新增外部 call_id/source_command_id;真正终结且录音成功上传、无录音或预期录音生成失败后才发送 | [`uploaded`](../../contracts/local/examples/mq-result-uploaded.json) · [`empty`](../../contracts/local/examples/mq-result-no-recording.json) |
|
||||
|
||||
旧 `command.result`、`call.result`、分散通话/转写/拒联事件、`recording.uploaded` 不再作为对外并行通知或兼容别名。D 在 inbox 持久后 ACK;状态/outbox 同事务;结果发布使用原消息身份可靠交付;confirm 不是 SaaS 应用收讫。消息年龄不让旧命令绕过准入;未来 issued_at 不提前接纳。重复投递/未知执行不触发再次拨号。
|
||||
|
||||
## 调度、AI 与真实结果(K01–K09、K11–K14)
|
||||
|
||||
- AI 的用途、模型及可表达参数由任务指定,provider 只提供连接信息;复用已实现的火山 ASR、OpenAI 兼容 LLM、百炼 task-based ASR/TTS 和百炼 HTTP TTS 协议,不为每个型号或音色维护代码白名单。TTS 的 `protocol` 必填:`dashscope_tts_http` 选择 HTTP 生成与音频引用下载,`dashscope_task_websocket` 选择 WebSocket task;不从 model 名、地址是否存在或失败结果猜测/切换协议。HTTP 协议当前不能表达倍速,只接受明确 speed=1;task TTS 原样提交 voice/rate(0.5–2),语言标签/BCP 47 转为明确 ISO 语言提示,显式 Auto 不发送提示,不能表达的语言拒绝。百炼 task ASR 的 model/language 必须明确,当前协议适配不能表达 interim 开关,显式提供即拒绝而不忽略;支持任务选定 8/16 kHz PCM16:16 kHz 发送原始 Agent PCM,8 kHz 才显式转换。电话 TTS 输出仍要求单声道 16 kHz PCM16;转换或完整性失败明确报错,不截断/重标格式。task-based 请求只在 task-finished 后返回最终用户文本/完整音频。每段只启动一次请求/task,不隐式重试;超时、身份不匹配、失败或不完整结果不返回半成品,不回退其它协议或模型。新增同协议模型仅修改任务配置,但模型实际可用性由供应商返回事实决定;本地 Mock 不构成任意型号或真实通话签收。
|
||||
- 每条 SaaS SIP 线路只配置一个原值 `caller_id`;任务只选获准线路,不再带 `caller_profile_id`,线路不再带 `caller_profiles`。Dispatcher 将线路主叫绑定到每次执行;Agent 在 Asterisk PJSIP 线路同时设置 `from_user` 与 `callerid`,核对实际加载的出站身份后才准入,不以 ARI 创建通道后的 `CALLERID(num)` 代替 SIP `From`。主叫不是 Digest 用户名,不从环境、CLI 或被叫前缀推断。被叫号码只来自归属当前 D、租户及已接纳任务的 `call.execute.payload.callee` 原值;不在 Dispatcher、SaaS Mock 或抓证脚本另设固定号码列表,也不在任务快照增加号码列表。Dispatcher 与非生产发布/抓证入口只接受 1–32 位 ASCII 数字的原始号码,不能把线路前缀当成该号码的本地替代值。已选 SIP trunk、任务与线路每周时段、任务排除日期、任务/租户/线路额度、任务与 AI 较小通话时限均在接纳及实际发呼叫指令前检查。线路字段未知则 fail-closed;选线后固定、不自动重拨/换线。隔离 Mock 中规则暂不满足时保留待执行指令、暂停该任务的调度,规则允许后重验;与人工 pause/stop 分离,不能自动解除人为停止。非生产真实路径不另设固定的 `09:00`–`20:00` 时间门禁或每线路每原始号码每日 3 次上限;任务/线路时段与额度以 SaaS 已校验快照为准。逐通抓证、Agent 活跃凭证及使用者逐次授权仍须满足;格式有效或本地 Mock 收件均不构成真实拨号授权。
|
||||
- 接通事实为真时 `outcome=answered`(后续异常不抹掉接通);已发起但忙线、拒接、无人接听且确定结束为 `no_answer`;确认未接通并由 Agent/Asterisk 执行故障终结为 `failed`;未知状态保持未知占用,不能伪造结束、结果或自动重拨。真实 SIP 状态码原样数字写入 `reason_code`,无真实 SIP 码则 `null` 并以 `reason_message` 说明;禁止本地虚构数字错误码。`call.execute.result.payload` 的 `status_line`、`raw` 与 `sip_capture_error` 始终存在:仅将经同一 ARI 通道拨号前取得的 SIP Call-ID 与 HEP INVITE 事务严格关联的最终响应写入原样状态行、完整原样报文与状态码;`raw` 不拼装、不截断,不能从目标号码、时间、挂断原因或 ARI HTTP 状态猜测。无 SIP 响应时前两项为 `null`;若已发起 SIP 但镜像/关联/解码失败,第三项须写明确错误,已确认结束仍报告真实结果并释放额度,不以原文缺失伪装为通话未知。原始报文只进入受控结果通道,不写日志、仓库或长期测试证据。无应答且没有录音时 `transcript=[]`、`opt_out=false`、`recording={}`。
|
||||
- `agent.conversation.hangup_keywords` 使用对象数组,字段及正例以现行 Schema 为准;旧字符串数组直接拒绝。只有**用户侧 ASR 最终识别文本**字面包含某组 `triggers` 才触发,同时命中多组按配置顺序取第一组;使用该组 `closingRemark` 合成语音,完整播放后主动挂断,不调用 LLM 生成结束语。匹配后不再开启新一轮对话,重复识别不得重播或重复挂断;中间识别、助手回复、开场白、TTS 均不能触发。合成或播放失败时明确报错并尝试结束通话,不重试、不把未播放音频报告为已播放;挂断结果不明仍保留未知事实。组名、触发词列表及结束语均必填且非空;ASR-only 不配置或合成结束语。同一任务 revision 不同内容拒绝准入;provider 连接缺失或选定模型无法表达获批参数时不可调用。Mock 参数验证不等于真实供应商验收。
|
||||
|
||||
## 录音与最终结果(K10、K15、K16)
|
||||
|
||||
1. 有效录音先直接 PUT 至 OSS,**只上传录音**;成功即持久入 D 的最终结果/outbox,正常路径不生成录音或结果业务文件(不得先写临时文件再删)。无应答无录音直接发真实结果,`recording={}`;录音**生成失败**仍报告真实通话 outcome、空录音与明确 `reason_message`,不走 OSS/重试,不伪造 SIP 码。
|
||||
2. OSS 上传首次失败时,**录音和结果恢复信息两者均保存成功**后才建立固定重试起点/48 小时截止,保持原 bucket、object_key 和消息身份。本地结果文件仅用于恢复 MQ 回报,不上传 OSS。任一恢复文件不能完整保存:显式报错,保留已有内容,暂不发送最终结果、也不宣称有可恢复副本,等待人工修复。
|
||||
3. 从首次两文件完整保存起重试:间隔为 1、2、4、8、16、32、60 分钟,其后每 60 分钟一次;重启/失败不重置起点,SDK 默认自动重试不得改变节奏。每次经 D↔A RPC 显式领取有效上传授权,不换对象/不向 SaaS 申请 TOKEN。48 小时仍不成功:停止自动重试、保留两文件和进度、标记待人工,**不发**伪造 uploaded/unavailable 或最终结果,也不自动重开窗口。
|
||||
4. 上传成功后仅恢复原最终结果消息的 MQ 可靠交付,MQ 失败不重新 PUT、不新建资产、不重拨;48 小时是 OSS 重试窗口,**不是** MQ outbox 的清除期限。已确认结束的通话资源及时释放,不等待 OSS/MQ;未知执行不释放。进程在正常上传尚未成功、失败恢复两文件尚未完整保存前退出,内存录音可能丢失,不能声称零丢失,也不能为了隐藏限制悄悄预写盘。
|
||||
|
||||
## Agent RPC、会话与恢复边界
|
||||
|
||||
- D↔A 只采用预绑定身份和双向 TLS Unary gRPC;当前八个方法见 [`agent.proto`](../../proto/agent/agent.proto)。Agent 不直连 SaaS、没有业务数据库;D 的 SQLite 持久保存任务/额度/inbox/outbox,Agent 执行及上传恢复文件私有。旧 SQLite/Agent 状态发现后只读失败关闭,不自动删除或迁移;未知通话占用不得因超时、新 boot 或重启自行释放。
|
||||
- Agent 会话提前续期;仅对**已验证 mTLS、同 Agent/Cell/boot/Dispatcher epoch、相邻且仍有效的代际拒绝**,`RequestRecordingUpload`、`ReportCallEnded`、`ReportCallResult` 可在六秒或调用方更早期限内,沿原事件/operation/幂等键重报原事实。其它拒绝、超时及结果不明停止重报,不自动重拨、重传不确定 OSS PUT 或放宽最新代际栅栏;详细错误边界见 [`proto/ERRORS.md`](../../proto/ERRORS.md)。
|
||||
|
||||
## 校验、来源与验收界限
|
||||
|
||||
- 当前字段及结构:[`config-read.schema.json`](../../contracts/local/config-read.schema.json)、[`task-discovery.schema.json`](../../contracts/local/task-discovery.schema.json)、[`mq.schema.json`](../../contracts/local/mq.schema.json);正反例在 `contracts/local/examples/`,来源路径和 SHA-256 在 [`manifest.json`](../../contracts/local/manifest.json)。历史原件按原字节存入 [`archive/sources/`](../archive/sources/README.md),旧上游 v1 存入 [`archive/upstream/`](../archive/upstream/README.md);它们不嵌入当前运行合同,不提供回退入口。`make check` 校验当前合同、历史来源、Proto、格式、race、vet、构建及隔离 MQ 实际收件。
|
||||
- 已完成的 P01–P08、A01–A12、K01–K16 项目内证据见 [`P08 对照`](../evidence/saas-dispatcher-p08-acceptance.md);本地手写代码覆盖率为 72.0%,发布清单 `production_approval=false`。本地 Mock、假工具诊断和 SHA-256 不证明真实 SaaS/management/MQ 应用收讫、OSS/AI/SIP/Asterisk/ECS、现场抓包或生产已通过。外部真实接入、部署和拨号均需另行授权、签收和验证。
|
||||
- JSON 样例是隔离 Mock 虚构数据;`example-only-not-a-real-secret` **不是凭据**。严禁将真实凭据、完整用户音频或完整对话放入源码/日志/证据。运行时须按接入方权限与实际加载事实再核验,不以机器 Schema 通过取代拨号授权。
|
||||
每次调研或修改契约前执行 `make contracts-update`;共享契约变更必须同步修改本项目并通知 SaaS 开发人员。
|
||||
旧来源仅供离线追溯,不提供运行回退;本地验证不代表真实 SaaS、线路或生产签收,也不授权拨号。
|
||||
|
||||
@@ -17,7 +17,7 @@ func currentFixture(t *testing.T, mode string) (configread.Task, map[string]conf
|
||||
if mode == "asr_only" {
|
||||
name = "config-read-task-asr.json"
|
||||
}
|
||||
raw, err := os.ReadFile("../../contracts/local/examples/" + name)
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func currentFixture(t *testing.T, mode string) (configread.Task, map[string]conf
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err = os.ReadFile("../../contracts/local/examples/config-read-providers.json")
|
||||
raw, err = os.ReadFile("../../contracts/schema/examples/config-read-providers.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestEvaluateTaskAndLineIntersectionAtBoundaries(t *testing.T) {
|
||||
func TestEvaluateApprovedTaskAndLineConfigShape(t *testing.T) {
|
||||
readSchedule := func(name string, dest any) {
|
||||
t.Helper()
|
||||
body, err := contracts.Files.ReadFile("local/" + name)
|
||||
body, err := contracts.Files.ReadFile("schema/" + name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,11 +7,11 @@ import (
|
||||
)
|
||||
|
||||
func TestExecutionBindingSHA256CoversTaskProvidersAndSIPRevision(t *testing.T) {
|
||||
task, err := os.ReadFile("../../contracts/local/examples/config-read-task-full.json")
|
||||
task, err := os.ReadFile("../../contracts/schema/examples/config-read-task-full.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
providers, err := os.ReadFile("../../contracts/local/examples/config-read-providers.json")
|
||||
providers, err := os.ReadFile("../../contracts/schema/examples/config-read-providers.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestSIPOptionalRevisionAndCaseInsensitiveSelectors(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../contracts/local/examples/config-read-sip.json")
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/config-read-sip.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestSIPOptionalRevisionAndCaseInsensitiveSelectors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTaskDiscoveryIgnoresOnlyNamedLegacyMetadata(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../contracts/local/examples/task-discovery-page.json")
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/task-discovery-page.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ import (
|
||||
|
||||
func example(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json"))
|
||||
root := filepath.Join("..", "..", "contracts", "schema")
|
||||
if strings.HasPrefix(name, "invalid/") {
|
||||
root = filepath.Join("..", "..", "contracts")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "examples", name+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
var compiledSchemas sync.Map
|
||||
|
||||
// ValidateCurrent checks only the current project-local HTTP/MQ contract.
|
||||
// ValidateCurrent checks only the pinned shared HTTP/MQ contract.
|
||||
// An absent schema is an error; it never falls back to historical bundles.
|
||||
func ValidateCurrent(surface string, raw []byte) error {
|
||||
var name string
|
||||
@@ -33,24 +33,10 @@ func ValidateCurrent(surface string, raw []byte) error {
|
||||
if ok {
|
||||
schema = cached.(*jsonschema.Schema)
|
||||
} else {
|
||||
data, err := contracts.ReadCurrent(name)
|
||||
schema, err = contracts.CompileCurrent(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode current schema %s: %w", name, err)
|
||||
}
|
||||
compiler := jsonschema.NewCompiler()
|
||||
compiler.AssertFormat()
|
||||
resource := "https://go-sip.local/contracts/current/" + name
|
||||
if err := compiler.AddResource(resource, doc); err != nil {
|
||||
return fmt.Errorf("register %s: %w", name, err)
|
||||
}
|
||||
schema, err = compiler.Compile(resource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compile %s: %w", name, err)
|
||||
}
|
||||
actual, _ := compiledSchemas.LoadOrStore(key, schema)
|
||||
schema = actual.(*jsonschema.Schema)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestRejectsRetiredCallerProfiles(t *testing.T) {
|
||||
{"config-read", "config-read-task-asr.json", `"route_policy_id":"route-mock"`, `"route_policy_id":"route-mock","caller_profile_id":"legacy"`},
|
||||
{"mq", "mq-result-no-recording.json", `"task_id":"task-asr"`, `"task_id":"task-asr","caller_profile_id":"legacy"`},
|
||||
} {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", tc.file))
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", tc.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -46,7 +46,11 @@ func TestValidateCurrent(t *testing.T) {
|
||||
{"mq", "invalid/mq-legacy-schema-version.json", false},
|
||||
} {
|
||||
t.Run(tc.file, func(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", tc.file))
|
||||
root := filepath.Join("..", "..", "contracts")
|
||||
if tc.valid {
|
||||
root = filepath.Join(root, "schema")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, "examples", tc.file))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
func configExample(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json"))
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", name+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ const testDispatcherID = "c046b893-8628-4589-ae50-619d049248a6"
|
||||
|
||||
func currentMessage(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json"))
|
||||
root := filepath.Join("..", "..", "contracts", "schema")
|
||||
if strings.HasPrefix(name, "invalid/") {
|
||||
root = filepath.Join("..", "..", "contracts")
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(root, "examples", name+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestAgentDefiniteNoDialStatusHasExplicitMachineReadableEvidence(t *testing.
|
||||
|
||||
func approvedTestRequest(t *testing.T, now time.Time) *agentpb.ExecuteApprovedRequest {
|
||||
t.Helper()
|
||||
taskJSON, err := os.ReadFile("../../contracts/local/examples/config-read-task-asr.json")
|
||||
taskJSON, err := os.ReadFile("../../contracts/schema/examples/config-read-task-asr.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func approvedTestRequest(t *testing.T, now time.Time) *agentpb.ExecuteApprovedRe
|
||||
if err := json.Unmarshal(taskJSON, &task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawProviders, err := os.ReadFile("../../contracts/local/examples/config-read-providers.json")
|
||||
rawProviders, err := os.ReadFile("../../contracts/schema/examples/config-read-providers.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
|
||||
}
|
||||
now := time.Date(2026, 9, 20, 10, 0, 0, 0, time.UTC)
|
||||
req := approvedTestRequest(t, now)
|
||||
fullJSON, err := os.ReadFile("../../contracts/local/examples/config-read-task-full.json")
|
||||
fullJSON, err := os.ReadFile("../../contracts/schema/examples/config-read-task-full.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func TestApprovedFullAIUsesBoundProviderSDKsAndOnlyFinalUserKeyword(t *testing.T
|
||||
return nil
|
||||
})
|
||||
client := mockApprovedGRPC(t, agent)
|
||||
sipJSON, err := os.ReadFile("../../contracts/local/examples/config-read-sip.json")
|
||||
sipJSON, err := os.ReadFile("../../contracts/schema/examples/config-read-sip.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestApprovedDispatcherToAgentUnaryMockRetainsSnapshotAndOneShotCall(t *test
|
||||
orig := &dispatcher.ApprovedOriginator{DispatcherID: req.DispatcherId, Client: client,
|
||||
Meta: func(context.Context) (*agentpb.RequestMeta, error) { return testMeta("inspect-loaded", "", 1), nil },
|
||||
}
|
||||
sipJSON, err := os.ReadFile("../../contracts/local/examples/config-read-sip.json")
|
||||
sipJSON, err := os.ReadFile("../../contracts/schema/examples/config-read-sip.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ func TestApprovedCallRunnerSynthesizesOpeningBeforeMediaCapture(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("Bailian TTS conversion requires ffmpeg")
|
||||
}
|
||||
raw, err := os.ReadFile("../../contracts/local/examples/config-read-task-full.json")
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/config-read-task-full.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func recordingRPCFixture(t *testing.T) (*RecordingServer, *store.Store, context.
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
readExample := func(name string, dst any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", name+".json"))
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "contracts", "schema", "examples", name+".json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestApplySIPOnlyAllowsActivatedSIPService(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := os.ReadFile("../../contracts/local/examples/config-read-sip.json")
|
||||
body, err := os.ReadFile("../../contracts/schema/examples/config-read-sip.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestLocalSIPPendingContentSurvivesRestartAndForeignOwnerIsRejected(t *testi
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile("../../contracts/local/examples/config-read-sip-no-revision.json")
|
||||
raw, err := os.ReadFile("../../contracts/schema/examples/config-read-sip-no-revision.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestLocalSIPPendingContentSurvivesRestartAndForeignOwnerIsRejected(t *testi
|
||||
if err != nil || same.Revision != 1 {
|
||||
t.Fatalf("equivalent snapshot changed generation: %+v %v", same, err)
|
||||
}
|
||||
raw, err = os.ReadFile("../../contracts/local/examples/config-read-sip.json")
|
||||
raw, err = os.ReadFile("../../contracts/schema/examples/config-read-sip.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user