Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b023c9fe | ||
|
|
4c35da9ef6 | ||
|
|
30c43aa8d7 | ||
|
|
a22d3ce0f1 | ||
|
|
2e61167500 | ||
|
|
54c58551ae | ||
|
|
67391acb16 | ||
|
|
e4fed702c4 |
@@ -0,0 +1,47 @@
|
||||
# Brain → Sense 运行与健康状态契约 v1
|
||||
|
||||
本目录是 Brain 运行状态到 Sense 运维投影的版本化事实源。Brain 只发布脱敏状态事实;Sense 不读取 Brain 的缓存、数据库或内部运行对象,也不能借此契约执行远程命令。
|
||||
|
||||
## 消息与时间语义
|
||||
|
||||
- `schema_version` 固定为 `yovision.runtime-status/v1`。生产者必须先通过 `runtime-status.schema.json` 再发布。
|
||||
- `status_id` 是消息幂等键;`sequence` 在单个 `brain_instance_ref` 内单调递增。重复消息可忽略;小于当前已保存 sequence 的消息不得覆盖投影。
|
||||
- `observed_at` 是 Brain 完成该次观测的 UTC RFC 3339 时间,不是 Sense 的接收时间。允许最大 30 秒未来时钟偏差;超过时拒绝该消息,并保留最后已知投影。
|
||||
- Brain 的推荐发布周期是 30 秒。Sense 以 `evaluation_time - observed_at > 90 秒` 推导 `stale`;恰好 90 秒仍为 fresh。`stale` 和 `offline` 都是 Sense 的传输/时间投影,不是 Brain 写入的运行状态。
|
||||
- 未收到任何有效状态时显示 `not_received`;传输断开但最后状态未过期时显示 `offline_fresh`;传输断开或无新消息且超过 90 秒时显示 `offline_stale` / `stale`,同时保留最后已知状态及其观测时间。
|
||||
|
||||
## 状态机
|
||||
|
||||
Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举:
|
||||
|
||||
| 状态 | 含义 | 允许的下一状态 |
|
||||
|---|---|---|
|
||||
| `unconfigured` | 尚无可运行配置 | `starting`, `stopped` |
|
||||
| `starting` | 已接受启动,资源准备中 | `running`, `degraded`, `failed`, `stopped` |
|
||||
| `running` | 正常提供推理 | `degraded`, `failed`, `stopped` |
|
||||
| `degraded` | 仍提供有限服务 | `running`, `failed`, `stopped` |
|
||||
| `failed` | 无法继续提供服务 | `starting`, `stopped` |
|
||||
| `stopped` | 已有序停止 | `starting`, `unconfigured` |
|
||||
|
||||
首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。
|
||||
|
||||
## 配置流与 revision
|
||||
|
||||
`configurations` 按 #148 的配置流报告,可以为空,也可以包含多个配置。每项 `config_id` 必须唯一,并与 `yovision.source-config/v1` 的 `config_id` 一致;重复 ID 使整条状态无效,不能覆盖最后已知投影。`applied_revision` 是 Brain 对该配置流已实际应用的 integer revision。Sense 必须逐个 `config_id` 与自己已投递的期望 revision 比较:相等为 synchronized,不相等为 mismatch;Sense 的期望 revision 不进入本消息,避免产生第二事实源。
|
||||
|
||||
- `not_configured`:尚未应用该配置,revision 必须为 null。
|
||||
- `applying`:正在应用;revision 为 null 或仍在运行的上一个 revision。
|
||||
- `applied`:应用成功,revision 必须是大于等于 1 的整数。
|
||||
- `rejected`:本次应用被拒绝;revision 为 null 或最后成功 revision,且必须带稳定错误码。
|
||||
|
||||
## 兼容与回退
|
||||
|
||||
- v1 字段语义冻结,未知字段被拒绝。新增可选字段或错误码前必须更新本契约及双方测试;改变字段语义或删除字段发布新主版本。
|
||||
- 消费者必须按 `schema_version` 先分派到对应版本验证器。未知主版本停止摄取并记录 `UNSUPPORTED_SCHEMA_VERSION`,不得清空或覆盖最后已知投影。
|
||||
- 回退时 Sense 停止摄取新版本,继续使用上一冻结版本的 adapter 和最后已知投影。回退不触发 Brain 重启或运行态修改。
|
||||
|
||||
## 安全边界
|
||||
|
||||
只允许 Schema 列出的字段。逻辑引用不允许 `/` 或 `\\`,因此不能携带绝对路径。消息不得包含凭据/token、堆栈、内部路径、用户会话、客户视频/图像、人脸信息或业务 Alert。结构化错误只传稳定错误码,不传自由文本错误详情。
|
||||
|
||||
错误码、映射责任和可复制验证分别见 `error-codes.md`、`mapping.md` 与 `../../tests/runtime-status-v1/README.md`。
|
||||
@@ -0,0 +1,16 @@
|
||||
# v1 稳定错误码
|
||||
|
||||
生产者可以发布以下稳定错误码。消费者遇到符合格式但尚未认识的 v1 错误码时显示“未识别的远端错误”,保留原始代码用于排障,不把它转换成业务 Alert。
|
||||
|
||||
| 错误码 | 责任域 | 含义 |
|
||||
|---|---|---|
|
||||
| `CONFIG_INVALID` | 配置 | 配置结构或值无效 |
|
||||
| `CONFIG_REVISION_UNAVAILABLE` | 配置 | 指定 revision 无法取得 |
|
||||
| `INPUT_UNREACHABLE` | 输入 | 逻辑输入暂时不可达 |
|
||||
| `INPUT_DECODE_FAILED` | 输入 | 输入解码失败 |
|
||||
| `MODEL_LOAD_FAILED` | 模型 | 模型载入失败 |
|
||||
| `INFERENCE_FAILED` | 推理 | 推理管线失败 |
|
||||
| `RESOURCE_PRESSURE` | 运行 | 资源压力导致降级 |
|
||||
| `INTERNAL_COMPONENT_FAILED` | 运行 | 内部组件失败;不随消息暴露组件路径或堆栈 |
|
||||
|
||||
`UNSUPPORTED_SCHEMA_VERSION`、`FUTURE_OBSERVATION`、`OUT_OF_ORDER_STATUS` 与 `INVALID_STATUS_TRANSITION` 是 Sense adapter 的本地摄取错误,不由 Brain 发布。
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d205",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"alert": { "kind": "intrusion" }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d202",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"access_token": "forbidden-example"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d206",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 47,
|
||||
"observed_at": "2026-08-31T00:05:00Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "gate-primary", "apply_state": "rejected", "applied_revision": 20, "error_code": "CONFIG_INVALID" }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["CONFIG_INVALID"],
|
||||
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d203",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "C:\\models\\private.pt", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v2",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d201",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d204",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"user_session": { "user": "forbidden" }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d103",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 43,
|
||||
"observed_at": "2026-08-31T00:01:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 20, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 40.0, "queue_depth": 0, "latency_ms": 22.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d107",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 46,
|
||||
"observed_at": "2026-08-31T00:04:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "new-stream", "apply_state": "not_configured", "applied_revision": null, "error_code": null },
|
||||
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null },
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "warehouse", "apply_state": "rejected", "applied_revision": 3, "error_code": "CONFIG_INVALID" }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["CONFIG_INVALID"],
|
||||
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d102",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 42,
|
||||
"observed_at": "2026-08-31T00:00:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["RESOURCE_PRESSURE"],
|
||||
"metrics": { "load_percent": 91.5, "queue_depth": 7, "latency_ms": 115.0 }
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"input_ref": "camera-gate-01",
|
||||
"state": "degraded",
|
||||
"error_codes": ["INPUT_DECODE_FAILED"],
|
||||
"metrics": { "load_percent": 5.2, "queue_depth": 3, "latency_ms": 92.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d106",
|
||||
"brain_instance_ref": "brain-east-02",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "unconfigured", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 0, "queue_depth": 0, "latency_ms": 0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d104",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 44,
|
||||
"observed_at": "2026-08-31T00:01:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["INPUT_UNREACHABLE"],
|
||||
"metrics": { "load_percent": 30.0, "queue_depth": 1, "latency_ms": 30.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d105",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 45,
|
||||
"observed_at": "2026-08-31T00:04:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 36.0, "queue_depth": 0, "latency_ms": 20.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d101",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 41,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 38.5, "queue_depth": 0, "latency_ms": 21.4 }
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"input_ref": "camera-gate-01",
|
||||
"state": "running",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 5.2, "queue_depth": 0, "latency_ms": 18.1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Brain → Sense mapper 字段责任
|
||||
|
||||
| 契约字段 | Brain 生产者责任 | Sense 消费者投影责任 |
|
||||
|---|---|---|
|
||||
| `schema_version` | 固定发布 `yovision.runtime-status/v1` | 先按主版本分派;未知版本不覆盖最后投影 |
|
||||
| `status_id` | 每次观测生成唯一幂等键 | 去重,不把重复消息当成新观测 |
|
||||
| `brain_instance_ref` | 发布部署时分配的逻辑引用 | 映射到内部 edge node;不把它当数据库主键 |
|
||||
| `sequence` | 同实例单调递增 | 拒绝倒序消息,保留最后已知投影 |
|
||||
| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 |
|
||||
| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 |
|
||||
| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 |
|
||||
| `configurations[]` | 每个 `config_id` 报告真实应用结果和 integer revision;同一消息内 ID 唯一 | 按 `config_id` 与 Sense 内部期望 revision 比较;拒绝重复 ID,不回写 Brain 状态 |
|
||||
| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert |
|
||||
| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 |
|
||||
|
||||
## 契约测试责任
|
||||
|
||||
- Brain:对所有发布消息执行 Schema 校验;覆盖各运行状态、配置应用结果、降级/失败以及敏感字段拒绝。
|
||||
- Sense:使用同一有效/无效样例;覆盖版本分派、幂等与倒序、30 秒未来偏差、90 秒陈旧边界、状态迁移、offline/recovery、revision mismatch 及回退不覆盖最后投影。
|
||||
- 协调契约:`contracts/tests/runtime-status-v1/test_contract.py` 是双方最小共同测试。产品 adapter 仍需在各自工单中增加本地模型映射测试。
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/runtime-status/v1/runtime-status.schema.json",
|
||||
"title": "YoVision Brain runtime status v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"status_id",
|
||||
"brain_instance_ref",
|
||||
"sequence",
|
||||
"observed_at",
|
||||
"runtime",
|
||||
"model",
|
||||
"configurations",
|
||||
"health",
|
||||
"inputs"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "yovision.runtime-status/v1" },
|
||||
"status_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"brain_instance_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"sequence": { "type": "integer", "minimum": 0 },
|
||||
"observed_at": { "type": "string", "format": "date-time" },
|
||||
"runtime": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["state", "version"],
|
||||
"properties": {
|
||||
"state": { "$ref": "#/$defs/runtimeState" },
|
||||
"version": { "$ref": "#/$defs/version" },
|
||||
"started_at": { "type": ["string", "null"], "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["model_ref", "version"],
|
||||
"properties": {
|
||||
"model_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"version": { "$ref": "#/$defs/version" }
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"type": "array",
|
||||
"maxItems": 4096,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["config_id", "apply_state", "applied_revision", "error_code"],
|
||||
"properties": {
|
||||
"config_id": { "$ref": "#/$defs/configId" },
|
||||
"apply_state": {
|
||||
"type": "string",
|
||||
"enum": ["not_configured", "applying", "applied", "rejected"]
|
||||
},
|
||||
"applied_revision": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1
|
||||
},
|
||||
"error_code": { "$ref": "#/$defs/nullableErrorCode" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "not_configured" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "null" } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "applied" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "integer", "minimum": 1 } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "rejected" } }
|
||||
},
|
||||
"then": { "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["overall", "error_codes", "metrics"],
|
||||
"properties": {
|
||||
"overall": {
|
||||
"type": "string",
|
||||
"enum": ["healthy", "degraded", "unhealthy"]
|
||||
},
|
||||
"error_codes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 32,
|
||||
"items": { "$ref": "#/$defs/errorCode" }
|
||||
},
|
||||
"metrics": { "$ref": "#/$defs/metrics" }
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"maxItems": 4096,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["input_ref", "state", "error_codes", "metrics"],
|
||||
"properties": {
|
||||
"input_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"state": { "$ref": "#/$defs/runtimeState" },
|
||||
"error_codes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 16,
|
||||
"items": { "$ref": "#/$defs/errorCode" }
|
||||
},
|
||||
"metrics": { "$ref": "#/$defs/metrics" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"configId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
|
||||
},
|
||||
"logicalRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"
|
||||
},
|
||||
"runtimeState": {
|
||||
"type": "string",
|
||||
"enum": ["unconfigured", "starting", "running", "degraded", "failed", "stopped"]
|
||||
},
|
||||
"errorCode": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
|
||||
},
|
||||
"nullableErrorCode": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["load_percent", "queue_depth", "latency_ms"],
|
||||
"properties": {
|
||||
"load_percent": { "type": "number", "minimum": 0, "maximum": 100 },
|
||||
"queue_depth": { "type": "integer", "minimum": 0 },
|
||||
"latency_ms": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sense → Brain 媒体源与区域规则配置契约 v1
|
||||
|
||||
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
|
||||
|
||||
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
|
||||
|
||||
## 文件
|
||||
|
||||
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
|
||||
- `examples/valid/`:可接受的 active 与待重校准快照。
|
||||
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
|
||||
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
|
||||
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
|
||||
|
||||
## 消费规则
|
||||
|
||||
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
|
||||
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
|
||||
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
|
||||
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
|
||||
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
|
||||
6. `effective_at` 不得早于 `published_at`。
|
||||
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
|
||||
|
||||
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
|
||||
|
||||
## 可复制验证
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
& contracts\tests\source-config-v1\run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
|
||||
@@ -0,0 +1,29 @@
|
||||
# v1 兼容、迁移与回退
|
||||
|
||||
## 兼容规则
|
||||
|
||||
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
|
||||
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
|
||||
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
|
||||
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
|
||||
|
||||
## revision 与生效
|
||||
|
||||
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
|
||||
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
|
||||
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
|
||||
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
|
||||
|
||||
## 支持周期
|
||||
|
||||
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
|
||||
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
|
||||
|
||||
## 回退
|
||||
|
||||
1. 停止分发有问题的新主版本或新 revision。
|
||||
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
|
||||
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
|
||||
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
|
||||
|
||||
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"coordinate-out-of-range.json": "schema",
|
||||
"credential-field.json": "secret",
|
||||
"internal-path.json": "internal path",
|
||||
"profile-binding-mismatch.json": "profile binding",
|
||||
"query-token.json": "secret",
|
||||
"unknown-major-version.json": "unknown schema"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v2",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {
|
||||
"id": "site-east"
|
||||
},
|
||||
"logical_device": {
|
||||
"id": "entry-camera-01"
|
||||
},
|
||||
"profile": {
|
||||
"id": "main-stream",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"encoding": "H264",
|
||||
"frame_rate": 25
|
||||
},
|
||||
"media": {
|
||||
"ref": "media:site-east/entry-01/main",
|
||||
"transport": "rtsp"
|
||||
},
|
||||
"rule_set": {
|
||||
"version": "entry-rules-7",
|
||||
"state": "active",
|
||||
"profile_binding": {
|
||||
"profile_id": "main-stream",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"id": "danger-yard",
|
||||
"version": 3,
|
||||
"kind": "danger_area",
|
||||
"enabled": true,
|
||||
"points": [
|
||||
{"x": 0.12, "y": 0.18},
|
||||
{"x": 0.82, "y": 0.18},
|
||||
{"x": 0.76, "y": 0.78},
|
||||
{"x": 0.18, "y": 0.72}
|
||||
]
|
||||
}
|
||||
],
|
||||
"directional_lines": [
|
||||
{
|
||||
"id": "entry-line",
|
||||
"version": 2,
|
||||
"kind": "directional_line",
|
||||
"enabled": true,
|
||||
"start": {"x": 0.2, "y": 0.5},
|
||||
"end": {"x": 0.8, "y": 0.5},
|
||||
"trigger_direction": "left_to_right"
|
||||
}
|
||||
]
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 8,
|
||||
"published_at": "2026-08-31T01:00:00Z",
|
||||
"effective_at": "2026-08-31T01:00:00Z",
|
||||
"site": {
|
||||
"id": "site-east"
|
||||
},
|
||||
"logical_device": {
|
||||
"id": "entry-camera-01"
|
||||
},
|
||||
"profile": {
|
||||
"id": "main-stream-v2",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"encoding": "H265",
|
||||
"frame_rate": 20
|
||||
},
|
||||
"media": {
|
||||
"ref": "media:site-east/entry-01/main-v2",
|
||||
"transport": "rtsp"
|
||||
},
|
||||
"rule_set": {
|
||||
"version": "entry-rules-8",
|
||||
"state": "recalibration_required",
|
||||
"profile_binding": {
|
||||
"profile_id": "main-stream-v2",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"areas": [],
|
||||
"directional_lines": []
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 生产者与消费者 mapper 字段表
|
||||
|
||||
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
|
||||
|
||||
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|
||||
|---|---|---|
|
||||
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
|
||||
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
|
||||
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
|
||||
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
|
||||
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
|
||||
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
|
||||
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
|
||||
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
|
||||
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
|
||||
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
|
||||
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
|
||||
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
|
||||
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
|
||||
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
|
||||
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
|
||||
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
|
||||
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
|
||||
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
|
||||
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
|
||||
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
|
||||
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
|
||||
|
||||
## 测试责任
|
||||
|
||||
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
|
||||
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
|
||||
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
|
||||
|
||||
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
|
||||
"title": "YoVision Sense to Brain source configuration snapshot v1",
|
||||
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"config_id",
|
||||
"revision",
|
||||
"published_at",
|
||||
"effective_at",
|
||||
"site",
|
||||
"logical_device",
|
||||
"profile",
|
||||
"media",
|
||||
"rule_set",
|
||||
"integrity"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "yovision.source-config/v1"
|
||||
},
|
||||
"config_id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"effective_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"site": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logical_device": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/$defs/profile"
|
||||
},
|
||||
"media": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ref", "transport"],
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
|
||||
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
|
||||
},
|
||||
"transport": {
|
||||
"enum": ["rtsp"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rule_set": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"state",
|
||||
"profile_binding",
|
||||
"areas",
|
||||
"directional_lines"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"state": {
|
||||
"enum": ["active", "disabled", "recalibration_required"]
|
||||
},
|
||||
"profile_binding": {
|
||||
"$ref": "#/$defs/profile_binding"
|
||||
},
|
||||
"areas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/area_rule"
|
||||
},
|
||||
"maxItems": 1024
|
||||
},
|
||||
"directional_lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/directional_line_rule"
|
||||
},
|
||||
"maxItems": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "value"],
|
||||
"properties": {
|
||||
"algorithm": {
|
||||
"const": "sha256"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
|
||||
"propertyNames": {
|
||||
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"stable_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
|
||||
},
|
||||
"positive_integer": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"positive_number": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "width", "height", "encoding", "frame_rate"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"encoding": {
|
||||
"enum": ["H264", "H265", "MJPEG"]
|
||||
},
|
||||
"frame_rate": {
|
||||
"$ref": "#/$defs/positive_number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_binding": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "width", "height"],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"point": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"area_rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "kind", "enabled", "points"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"kind": {
|
||||
"const": "danger_area"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"directional_line_rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"kind",
|
||||
"enabled",
|
||||
"start",
|
||||
"end",
|
||||
"trigger_direction"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"kind": {
|
||||
"const": "directional_line"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"start": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"end": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"trigger_direction": {
|
||||
"enum": ["left_to_right", "right_to_left"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# yovision.runtime-status/v1 契约测试
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s contracts/tests/runtime-status-v1 -p "test_*.py" -v
|
||||
```
|
||||
|
||||
测试只使用 Python 标准库,不安装依赖、不访问网络。它对冻结 Schema 的已用关键字执行验证,并覆盖状态迁移、时间/陈旧边界、offline/recovery、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、未知主版本、倒序消息、回退保留和敏感字段拒绝。产品 adapter 还需在各自工单中运行本地模型映射测试。
|
||||
@@ -0,0 +1,318 @@
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TEST_DIR = Path(__file__).resolve().parent
|
||||
CONTRACT_DIR = TEST_DIR.parents[1] / "runtime-status" / "v1"
|
||||
SCHEMA = json.loads((CONTRACT_DIR / "runtime-status.schema.json").read_text(encoding="utf-8"))
|
||||
VALID_DIR = CONTRACT_DIR / "examples" / "valid"
|
||||
INVALID_DIR = CONTRACT_DIR / "examples" / "invalid"
|
||||
|
||||
|
||||
def parse_datetime(value):
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("not a string")
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timezone is required")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def matches_type(value, expected):
|
||||
if expected == "null":
|
||||
return value is None
|
||||
if expected == "object":
|
||||
return isinstance(value, dict)
|
||||
if expected == "array":
|
||||
return isinstance(value, list)
|
||||
if expected == "string":
|
||||
return isinstance(value, str)
|
||||
if expected == "integer":
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
if expected == "number":
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
if expected == "boolean":
|
||||
return isinstance(value, bool)
|
||||
raise AssertionError(f"unsupported schema type in test validator: {expected}")
|
||||
|
||||
|
||||
def resolve_ref(ref):
|
||||
if not ref.startswith("#/"):
|
||||
raise AssertionError(f"external refs are not supported: {ref}")
|
||||
node = SCHEMA
|
||||
for part in ref[2:].split("/"):
|
||||
node = node[part.replace("~1", "/").replace("~0", "~")]
|
||||
return node
|
||||
|
||||
|
||||
def validate(instance, schema=None, path="$", errors=None):
|
||||
schema = SCHEMA if schema is None else schema
|
||||
errors = [] if errors is None else errors
|
||||
if "$ref" in schema:
|
||||
return validate(instance, resolve_ref(schema["$ref"]), path, errors)
|
||||
|
||||
for subschema in schema.get("allOf", []):
|
||||
validate(instance, subschema, path, errors)
|
||||
if "if" in schema:
|
||||
condition_errors = validate(instance, schema["if"], path, [])
|
||||
branch = schema.get("then") if not condition_errors else schema.get("else")
|
||||
if branch is not None:
|
||||
validate(instance, branch, path, errors)
|
||||
|
||||
if "type" in schema:
|
||||
allowed = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
||||
if not any(matches_type(instance, expected) for expected in allowed):
|
||||
errors.append(f"{path}: expected {allowed}")
|
||||
return errors
|
||||
|
||||
if "const" in schema and instance != schema["const"]:
|
||||
errors.append(f"{path}: expected constant {schema['const']!r}")
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
errors.append(f"{path}: value is not in enum")
|
||||
|
||||
if isinstance(instance, dict):
|
||||
required = schema.get("required", [])
|
||||
for name in required:
|
||||
if name not in instance:
|
||||
errors.append(f"{path}: missing required property {name}")
|
||||
properties = schema.get("properties", {})
|
||||
if schema.get("additionalProperties") is False:
|
||||
for name in instance:
|
||||
if name not in properties:
|
||||
errors.append(f"{path}: additional property {name}")
|
||||
for name, value in instance.items():
|
||||
if name in properties:
|
||||
validate(value, properties[name], f"{path}.{name}", errors)
|
||||
|
||||
if isinstance(instance, list):
|
||||
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||||
errors.append(f"{path}: too many items")
|
||||
if schema.get("uniqueItems"):
|
||||
encoded = [json.dumps(item, sort_keys=True) for item in instance]
|
||||
if len(encoded) != len(set(encoded)):
|
||||
errors.append(f"{path}: duplicate items")
|
||||
if "items" in schema:
|
||||
for index, value in enumerate(instance):
|
||||
validate(value, schema["items"], f"{path}[{index}]", errors)
|
||||
|
||||
if isinstance(instance, str):
|
||||
if "minLength" in schema and len(instance) < schema["minLength"]:
|
||||
errors.append(f"{path}: string is too short")
|
||||
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||||
errors.append(f"{path}: string is too long")
|
||||
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
|
||||
errors.append(f"{path}: pattern mismatch")
|
||||
if schema.get("format") == "date-time":
|
||||
try:
|
||||
parse_datetime(instance)
|
||||
except (TypeError, ValueError):
|
||||
errors.append(f"{path}: invalid date-time")
|
||||
|
||||
if isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
||||
if "minimum" in schema and instance < schema["minimum"]:
|
||||
errors.append(f"{path}: below minimum")
|
||||
if "maximum" in schema and instance > schema["maximum"]:
|
||||
errors.append(f"{path}: above maximum")
|
||||
return errors
|
||||
|
||||
|
||||
def load(path):
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def validate_contract(instance):
|
||||
errors = validate(instance)
|
||||
configurations = instance.get("configurations") if isinstance(instance, dict) else None
|
||||
if isinstance(configurations, list):
|
||||
config_ids = [item.get("config_id") for item in configurations if isinstance(item, dict)]
|
||||
duplicates = {config_id for config_id in config_ids if config_ids.count(config_id) > 1}
|
||||
if duplicates:
|
||||
errors.append(f"$.configurations: duplicate config_id {sorted(duplicates)!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def freshness(observed_at, evaluation_time):
|
||||
age = evaluation_time - parse_datetime(observed_at)
|
||||
if age < timedelta(seconds=-30):
|
||||
return "future_rejected"
|
||||
return "stale" if age > timedelta(seconds=90) else "fresh"
|
||||
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
"unconfigured": {"starting", "stopped"},
|
||||
"starting": {"running", "degraded", "failed", "stopped"},
|
||||
"running": {"degraded", "failed", "stopped"},
|
||||
"degraded": {"running", "failed", "stopped"},
|
||||
"failed": {"starting", "stopped"},
|
||||
"stopped": {"starting", "unconfigured"},
|
||||
}
|
||||
|
||||
|
||||
def may_transition(previous, current):
|
||||
return previous == current or current in ALLOWED_TRANSITIONS[previous]
|
||||
|
||||
|
||||
def may_replace(previous, candidate, evaluation_time):
|
||||
if candidate["schema_version"] != "yovision.runtime-status/v1":
|
||||
return False
|
||||
if validate_contract(candidate):
|
||||
return False
|
||||
if freshness(candidate["observed_at"], evaluation_time) == "future_rejected":
|
||||
return False
|
||||
if candidate["brain_instance_ref"] != previous["brain_instance_ref"]:
|
||||
return False
|
||||
if candidate["sequence"] <= previous["sequence"]:
|
||||
return False
|
||||
return may_transition(previous["runtime"]["state"], candidate["runtime"]["state"])
|
||||
|
||||
|
||||
class RuntimeStatusV1ContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.running = load(VALID_DIR / "running.json")
|
||||
|
||||
def test_schema_declares_frozen_version_and_closed_objects(self):
|
||||
self.assertEqual(SCHEMA["$schema"], "https://json-schema.org/draft/2020-12/schema")
|
||||
self.assertEqual(SCHEMA["properties"]["schema_version"]["const"], "yovision.runtime-status/v1")
|
||||
self.assertFalse(SCHEMA["additionalProperties"])
|
||||
for name in ("runtime", "model", "health"):
|
||||
self.assertFalse(SCHEMA["properties"][name]["additionalProperties"])
|
||||
self.assertFalse(SCHEMA["properties"]["configurations"]["items"]["additionalProperties"])
|
||||
|
||||
def test_all_valid_examples_satisfy_schema(self):
|
||||
paths = sorted(VALID_DIR.glob("*.json"))
|
||||
self.assertGreaterEqual(len(paths), 7)
|
||||
for path in paths:
|
||||
with self.subTest(path=path.name):
|
||||
self.assertEqual(validate_contract(load(path)), [])
|
||||
|
||||
def test_all_invalid_examples_are_rejected(self):
|
||||
paths = sorted(INVALID_DIR.glob("*.json"))
|
||||
self.assertGreaterEqual(len(paths), 6)
|
||||
for path in paths:
|
||||
with self.subTest(path=path.name):
|
||||
self.assertNotEqual(validate_contract(load(path)), [])
|
||||
|
||||
def test_every_runtime_state_is_schema_valid(self):
|
||||
for state in ALLOWED_TRANSITIONS:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["runtime"]["state"] = state
|
||||
with self.subTest(state=state):
|
||||
self.assertEqual(validate_contract(message), [])
|
||||
|
||||
def test_state_transition_matrix(self):
|
||||
self.assertTrue(may_transition("unconfigured", "starting"))
|
||||
self.assertTrue(may_transition("starting", "running"))
|
||||
self.assertTrue(may_transition("running", "degraded"))
|
||||
self.assertTrue(may_transition("degraded", "running"))
|
||||
self.assertTrue(may_transition("running", "failed"))
|
||||
self.assertTrue(may_transition("failed", "stopped"))
|
||||
self.assertFalse(may_transition("unconfigured", "running"))
|
||||
self.assertFalse(may_transition("stopped", "running"))
|
||||
|
||||
def test_stale_and_future_boundaries(self):
|
||||
observed = parse_datetime(self.running["observed_at"])
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=90)), "fresh")
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed + timedelta(seconds=91)), "stale")
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=30)), "fresh")
|
||||
self.assertEqual(freshness(self.running["observed_at"], observed - timedelta(seconds=31)), "future_rejected")
|
||||
|
||||
def test_offline_keeps_last_known_and_recovery_replaces_it(self):
|
||||
last_known = load(VALID_DIR / "offline-last-known.json")
|
||||
evaluation = parse_datetime(last_known["observed_at"]) + timedelta(seconds=180)
|
||||
self.assertEqual(freshness(last_known["observed_at"], evaluation), "stale")
|
||||
self.assertEqual(last_known["runtime"]["state"], "degraded")
|
||||
recovered = load(VALID_DIR / "recovered.json")
|
||||
self.assertTrue(may_replace(last_known, recovered, parse_datetime(recovered["observed_at"])))
|
||||
|
||||
def test_unknown_version_and_out_of_order_do_not_replace_projection(self):
|
||||
unknown = load(INVALID_DIR / "unknown-major.json")
|
||||
evaluation = parse_datetime(self.running["observed_at"])
|
||||
self.assertFalse(may_replace(self.running, unknown, evaluation))
|
||||
older = copy.deepcopy(self.running)
|
||||
older["sequence"] = self.running["sequence"] - 1
|
||||
self.assertFalse(may_replace(self.running, older, evaluation))
|
||||
duplicate = load(INVALID_DIR / "duplicate-config-id.json")
|
||||
self.assertFalse(may_replace(self.running, duplicate, parse_datetime(duplicate["observed_at"])))
|
||||
|
||||
def test_configuration_revision_mismatch_is_consumer_derived(self):
|
||||
message = load(VALID_DIR / "config-mismatch.json")
|
||||
desired_revisions = {"gate-primary": 21}
|
||||
self.assertEqual(validate_contract(message), [])
|
||||
report = message["configurations"][0]
|
||||
self.assertNotEqual(report["applied_revision"], desired_revisions[report["config_id"]])
|
||||
self.assertNotIn("desired_revision", report)
|
||||
|
||||
def test_configuration_apply_state_invariants(self):
|
||||
valid_cases = [
|
||||
("not_configured", None, None),
|
||||
("applying", None, None),
|
||||
("applying", 1, None),
|
||||
("applied", 1, None),
|
||||
("rejected", None, "CONFIG_INVALID"),
|
||||
("rejected", 1, "CONFIG_INVALID"),
|
||||
]
|
||||
for apply_state, revision, error_code in valid_cases:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["configurations"] = [{
|
||||
"config_id": "gate-primary",
|
||||
"apply_state": apply_state,
|
||||
"applied_revision": revision,
|
||||
"error_code": error_code,
|
||||
}]
|
||||
with self.subTest(apply_state=apply_state):
|
||||
self.assertEqual(validate_contract(message), [])
|
||||
|
||||
invalid_cases = [
|
||||
("not_configured", 1, None),
|
||||
("applied", None, None),
|
||||
("rejected", 1, None),
|
||||
]
|
||||
for apply_state, revision, error_code in invalid_cases:
|
||||
message = copy.deepcopy(self.running)
|
||||
message["configurations"] = [{
|
||||
"config_id": "gate-primary",
|
||||
"apply_state": apply_state,
|
||||
"applied_revision": revision,
|
||||
"error_code": error_code,
|
||||
}]
|
||||
with self.subTest(invalid_apply_state=apply_state):
|
||||
self.assertNotEqual(validate_contract(message), [])
|
||||
|
||||
def test_empty_multiple_and_duplicate_configuration_streams(self):
|
||||
empty = load(VALID_DIR / "empty-configurations.json")
|
||||
multiple = load(VALID_DIR / "configuration-states.json")
|
||||
duplicate = load(INVALID_DIR / "duplicate-config-id.json")
|
||||
self.assertEqual(validate_contract(empty), [])
|
||||
self.assertEqual(validate_contract(multiple), [])
|
||||
self.assertEqual(len(multiple["configurations"]), 4)
|
||||
self.assertTrue(any("duplicate config_id" in error for error in validate_contract(duplicate)))
|
||||
|
||||
def test_sensitive_and_business_fields_are_rejected_by_name(self):
|
||||
for forbidden in ("access_token", "password", "credential", "internal_path", "stack", "user_session", "video", "face", "alert"):
|
||||
message = copy.deepcopy(self.running)
|
||||
message[forbidden] = "forbidden"
|
||||
with self.subTest(forbidden=forbidden):
|
||||
self.assertTrue(any("additional property" in error for error in validate_contract(message)))
|
||||
|
||||
def test_logical_references_reject_paths(self):
|
||||
for value in ("C:\\models\\private.pt", "/srv/models/private.pt", "../private.pt"):
|
||||
message = copy.deepcopy(self.running)
|
||||
message["model"]["model_ref"] = value
|
||||
with self.subTest(value=value):
|
||||
self.assertNotEqual(validate_contract(message), [])
|
||||
|
||||
def test_mapper_responsibilities_are_documented(self):
|
||||
mapping = (CONTRACT_DIR / "mapping.md").read_text(encoding="utf-8")
|
||||
for field in ("schema_version", "status_id", "brain_instance_ref", "sequence", "observed_at", "runtime.*", "model.*", "configurations[]", "health.*", "inputs[]"):
|
||||
self.assertIn(f"`{field}`", mapping)
|
||||
self.assertIn("Brain", mapping)
|
||||
self.assertIn("Sense", mapping)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,2 @@
|
||||
jsonschema==4.23.0
|
||||
rfc8785==0.1.4
|
||||
@@ -0,0 +1,33 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$testDirectory = $PSScriptRoot
|
||||
$requirements = Join-Path $testDirectory 'requirements.txt'
|
||||
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$workDirectory = Join-Path $tempRoot ("yovision-source-config-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
|
||||
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $workDirectory | Out-Null
|
||||
$virtualEnvironment = Join-Path $workDirectory '.venv'
|
||||
python -m venv $virtualEnvironment
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
|
||||
|
||||
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
|
||||
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
||||
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||
& $python -m pip install --quiet --requirement $requirements
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned contract-test dependencies.' }
|
||||
|
||||
& $python -m unittest discover -s $testDirectory -p 'test_*.py' -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Source-config v1 contract tests failed.' }
|
||||
}
|
||||
finally {
|
||||
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
|
||||
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "Refusing to remove a temporary directory outside $tempRoot"
|
||||
}
|
||||
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
|
||||
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import rfc8785
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
CONTRACT_ROOT = REPOSITORY_ROOT / "contracts" / "source-config" / "v1"
|
||||
SCHEMA_PATH = CONTRACT_ROOT / "source-config.schema.json"
|
||||
VALID_ROOT = CONTRACT_ROOT / "examples" / "valid"
|
||||
INVALID_ROOT = CONTRACT_ROOT / "examples" / "invalid"
|
||||
FORBIDDEN_KEY = re.compile(r"(?:credential|password|secret|token|username|cookie|jwt)", re.IGNORECASE)
|
||||
FORBIDDEN_MEDIA_CHARACTER = re.compile(r"[?@#\\]")
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise AssertionError(f"{path} must contain a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
SCHEMA = load_json(SCHEMA_PATH)
|
||||
VALIDATOR = Draft202012Validator(SCHEMA, format_checker=FormatChecker())
|
||||
|
||||
|
||||
def integrity_value(payload: dict[str, Any]) -> str:
|
||||
content = copy.deepcopy(payload)
|
||||
content.pop("integrity", None)
|
||||
return hashlib.sha256(rfc8785.dumps(content)).hexdigest()
|
||||
|
||||
|
||||
def set_integrity(payload: dict[str, Any]) -> None:
|
||||
payload["integrity"] = {"algorithm": "sha256", "value": integrity_value(payload)}
|
||||
|
||||
|
||||
def reject_secrets(value: Any, path: str = "config") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if FORBIDDEN_KEY.search(str(key)):
|
||||
raise ValueError(f"secret field is forbidden at {path}.{key}")
|
||||
reject_secrets(child, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
reject_secrets(child, f"{path}[{index}]")
|
||||
|
||||
|
||||
def polygon_area(points: list[dict[str, float]]) -> float:
|
||||
return abs(
|
||||
sum(
|
||||
point["x"] * points[(index + 1) % len(points)]["y"]
|
||||
- points[(index + 1) % len(points)]["x"] * point["y"]
|
||||
for index, point in enumerate(points)
|
||||
)
|
||||
/ 2
|
||||
)
|
||||
|
||||
|
||||
def validate_payload(payload: dict[str, Any]) -> None:
|
||||
if payload.get("schema_version") != "yovision.source-config/v1":
|
||||
raise ValueError("unknown schema major version")
|
||||
|
||||
reject_secrets(payload)
|
||||
media_ref = str(payload.get("media", {}).get("ref", ""))
|
||||
if (
|
||||
FORBIDDEN_MEDIA_CHARACTER.search(media_ref)
|
||||
or "://" in media_ref
|
||||
or re.match(r"^[A-Za-z]:", media_ref)
|
||||
):
|
||||
raise ValueError("secret, query, authority, or internal path in media reference")
|
||||
|
||||
errors = sorted(VALIDATOR.iter_errors(payload), key=lambda error: list(error.absolute_path))
|
||||
if errors:
|
||||
first = errors[0]
|
||||
location = ".".join(str(part) for part in first.absolute_path) or "config"
|
||||
raise ValueError(f"schema validation failed at {location}: {first.message}")
|
||||
|
||||
profile = payload["profile"]
|
||||
binding = payload["rule_set"]["profile_binding"]
|
||||
if (binding["profile_id"], binding["width"], binding["height"]) != (
|
||||
profile["id"],
|
||||
profile["width"],
|
||||
profile["height"],
|
||||
):
|
||||
raise ValueError("profile binding does not match the media profile")
|
||||
|
||||
published_at = datetime.fromisoformat(payload["published_at"].replace("Z", "+00:00"))
|
||||
effective_at = datetime.fromisoformat(payload["effective_at"].replace("Z", "+00:00"))
|
||||
if effective_at < published_at:
|
||||
raise ValueError("effective_at precedes published_at")
|
||||
|
||||
rule_set = payload["rule_set"]
|
||||
rules = [*rule_set["areas"], *rule_set["directional_lines"]]
|
||||
identifiers = [rule["id"] for rule in rules]
|
||||
if len(identifiers) != len(set(identifiers)):
|
||||
raise ValueError("rule ids must be unique across the rule set")
|
||||
if rule_set["state"] == "recalibration_required" and any(rule["enabled"] for rule in rules):
|
||||
raise ValueError("recalibration-required rules must not remain enabled")
|
||||
|
||||
for area in rule_set["areas"]:
|
||||
if polygon_area(area["points"]) <= 1e-12:
|
||||
raise ValueError(f"area {area['id']} is a degenerate polygon")
|
||||
for line in rule_set["directional_lines"]:
|
||||
if line["start"] == line["end"]:
|
||||
raise ValueError(f"directional line {line['id']} has identical endpoints")
|
||||
|
||||
if payload["integrity"]["value"] != integrity_value(payload):
|
||||
raise ValueError("integrity digest mismatch")
|
||||
|
||||
|
||||
def validate_transition(previous: dict[str, Any], current: dict[str, Any]) -> None:
|
||||
validate_payload(previous)
|
||||
validate_payload(current)
|
||||
if previous["config_id"] != current["config_id"]:
|
||||
raise ValueError("config_id cannot change within one revision stream")
|
||||
if current["revision"] <= previous["revision"]:
|
||||
raise ValueError("revision must increase strictly")
|
||||
|
||||
previous_profile = previous["profile"]
|
||||
current_profile = current["profile"]
|
||||
profile_changed = any(
|
||||
previous_profile[field] != current_profile[field]
|
||||
for field in ("id", "width", "height", "encoding")
|
||||
)
|
||||
previous_rule_versions = sorted(
|
||||
(rule["id"], rule["version"])
|
||||
for rule in [*previous["rule_set"]["areas"], *previous["rule_set"]["directional_lines"]]
|
||||
)
|
||||
current_rule_versions = sorted(
|
||||
(rule["id"], rule["version"])
|
||||
for rule in [*current["rule_set"]["areas"], *current["rule_set"]["directional_lines"]]
|
||||
)
|
||||
if (
|
||||
profile_changed
|
||||
and previous_rule_versions == current_rule_versions
|
||||
and current["rule_set"]["state"] != "recalibration_required"
|
||||
):
|
||||
raise ValueError("profile changed without rule recalibration state or new rule versions")
|
||||
|
||||
|
||||
class SourceConfigV1ContractTests(unittest.TestCase):
|
||||
def test_schema_is_valid_draft_2020_12(self) -> None:
|
||||
Draft202012Validator.check_schema(SCHEMA)
|
||||
|
||||
def test_all_valid_examples_pass_schema_semantics_and_integrity(self) -> None:
|
||||
examples = sorted(VALID_ROOT.glob("*.json"))
|
||||
self.assertGreaterEqual(len(examples), 2)
|
||||
for path in examples:
|
||||
with self.subTest(path=path.name):
|
||||
validate_payload(load_json(path))
|
||||
|
||||
def test_invalid_examples_fail_for_the_declared_reason(self) -> None:
|
||||
expected = load_json(INVALID_ROOT / "expected-errors.json")
|
||||
self.assertGreaterEqual(len(expected), 6)
|
||||
for filename, reason in expected.items():
|
||||
with self.subTest(path=filename):
|
||||
with self.assertRaisesRegex(ValueError, str(reason)):
|
||||
validate_payload(load_json(INVALID_ROOT / filename))
|
||||
|
||||
def test_tampering_is_detected_after_other_validation(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["revision"] += 1
|
||||
with self.assertRaisesRegex(ValueError, "integrity digest mismatch"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_namespaced_optional_extensions_are_compatible_but_not_secret_bearing(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["extensions"] = {"example.analytics": {"samplingHint": "balanced"}}
|
||||
set_integrity(payload)
|
||||
validate_payload(payload)
|
||||
|
||||
payload["extensions"] = {"example.analytics": {"accessToken": "forbidden"}}
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "secret field"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_profile_revision_and_recalibration_semantics_are_safe(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["profile_binding"]["width"] = 1280
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "profile binding"):
|
||||
validate_payload(payload)
|
||||
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["state"] = "recalibration_required"
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "must not remain enabled"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_revision_stream_rejects_stale_and_unrecalibrated_profile_change(self) -> None:
|
||||
previous = load_json(VALID_ROOT / "active.json")
|
||||
current = copy.deepcopy(previous)
|
||||
current["revision"] = previous["revision"]
|
||||
set_integrity(current)
|
||||
with self.assertRaisesRegex(ValueError, "revision must increase"):
|
||||
validate_transition(previous, current)
|
||||
|
||||
current["revision"] += 1
|
||||
current["profile"].update({"id": "main-stream-v2", "width": 1280, "height": 720})
|
||||
current["rule_set"]["profile_binding"].update(
|
||||
{"profile_id": "main-stream-v2", "width": 1280, "height": 720}
|
||||
)
|
||||
set_integrity(current)
|
||||
with self.assertRaisesRegex(ValueError, "without rule recalibration"):
|
||||
validate_transition(previous, current)
|
||||
|
||||
validate_transition(previous, load_json(VALID_ROOT / "recalibration-required.json"))
|
||||
|
||||
def test_rule_geometry_and_global_ids_are_semantically_validated(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["areas"][0]["points"] = [
|
||||
{"x": 0, "y": 0},
|
||||
{"x": 0.5, "y": 0.5},
|
||||
{"x": 1, "y": 1},
|
||||
]
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "degenerate polygon"):
|
||||
validate_payload(payload)
|
||||
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["rule_set"]["directional_lines"][0]["id"] = payload["rule_set"]["areas"][0]["id"]
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "ids must be unique"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_effective_time_cannot_precede_publication(self) -> None:
|
||||
payload = load_json(VALID_ROOT / "active.json")
|
||||
payload["effective_at"] = "2026-08-30T23:59:59Z"
|
||||
set_integrity(payload)
|
||||
with self.assertRaisesRegex(ValueError, "precedes"):
|
||||
validate_payload(payload)
|
||||
|
||||
def test_shared_payload_does_not_claim_either_product_internal_model(self) -> None:
|
||||
for path in sorted(VALID_ROOT.glob("*.json")):
|
||||
serialized = json.dumps(load_json(path), ensure_ascii=False).lower()
|
||||
self.assertNotIn("brain.internal.input", serialized)
|
||||
self.assertNotIn("streamuri", serialized)
|
||||
self.assertNotIn("profiletoken", serialized)
|
||||
self.assertNotIn("database", serialized)
|
||||
self.assertNotRegex(serialized, r"[a-z]:\\")
|
||||
|
||||
def test_mapper_documents_both_product_test_responsibilities(self) -> None:
|
||||
mapper = (CONTRACT_ROOT / "mapper-fields.md").read_text(encoding="utf-8")
|
||||
self.assertIn("Sense 生产者契约测试", mapper)
|
||||
self.assertIn("Brain 消费者契约测试", mapper)
|
||||
self.assertIn("brain.internal.input/v1", mapper)
|
||||
self.assertIn("admissionProfile.StreamURI", mapper)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 14b961599d6954357142713a5667fb37d38e86b7
|
||||
synchronized_at: 2026-08-29T12:37:31Z
|
||||
wiki_revision: 3327861dbd77ae63fd10246533b87e0b99a69220
|
||||
synchronized_at: 2026-08-31T01:38:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -296,3 +296,27 @@ Brain 解码层位于 `Brain/src/yovision_brain/decode/`,只依赖 #11 的内
|
||||
|
||||
内部候选包含逻辑输入引用、规则/模型版本、发生时间、匿名框和解释原因,不包含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或证据引用。该格式不是 Brain→Bell 共享契约;Bell API、Outbox、机器身份、证据和跨项目投递必须由协调工单另行实现。
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain v1 契约边界
|
||||
|
||||
- Sense→Brain 配置:`contracts/source-config/v1/source-config.schema.json`;版本 `yovision.source-config/v1`。
|
||||
- Brain→Sense 状态:`contracts/runtime-status/v1/runtime-status.schema.json`;版本 `yovision.runtime-status/v1`。
|
||||
- 共同测试:`contracts/tests/source-config-v1/`、`contracts/tests/runtime-status-v1/`。
|
||||
- 生产者/消费者 mapper 责任分别记录在 `mapper-fields.md` 与 `mapping.md`;产品 adapter 后续由 #152 实现。
|
||||
|
||||
数据流固定为:
|
||||
|
||||
```text
|
||||
Sense Device/Profile/Area 内部事实
|
||||
→ source-config/v1 mapper
|
||||
→ Brain adapter(后续 #152)
|
||||
→ Brain 内部配置与运行
|
||||
→ runtime-status/v1 mapper
|
||||
→ Sense 只读运维投影(后续 #152)
|
||||
```
|
||||
|
||||
共享契约统一使用 snake_case 与 `schema_version: yovision.<contract>/v1`。源配置使用 `config_id + integer revision`;运行状态以 `configurations[]` 按 `config_id` 回报实际应用 revision。未知主版本、重复配置 ID、倒序状态、摘要失败或敏感字段必须拒绝,且不得覆盖最后已知有效配置/投影。
|
||||
|
||||
协议不得包含摄像头凭据、RTSP URL、query token、内部绝对路径、数据库模型、用户/JWT/Cookie 或 Bell Alert 语义。当前只冻结契约,没有新增网络端点、机器身份或跨端 connector。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 27749cbf699093d997284afc277ea53e73a5876f
|
||||
synchronized_at: 2026-08-29T12:37:41Z
|
||||
wiki_revision: feca453cd0873c3ad4cef243acfada08ef3f1e33
|
||||
synchronized_at: 2026-08-31T01:38:28Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -233,3 +233,17 @@ synchronized_at: 2026-08-29T12:37:41Z
|
||||
- 确认和恢复都要求 6–256 字符原因、当前版本和允许的状态;旧版本或错误状态返回冲突。所有动作写入独立流转历史和 GoAdmin 操作审计。
|
||||
- 运维告警永远设置为 Sense 内部运维记录,不创建本地安全事件或 Bell Alert,不进入跨项目 Outbox,也不实现通知升级。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain 配置与状态规则
|
||||
|
||||
- **配置流**:由稳定 `config_id` 和严格递增的正整数 `revision` 标识;revision 不得复用或倒退。
|
||||
- **无凭据媒体引用**:`media.ref` 是由后续 connector 解析的不透明逻辑引用,不是 RTSP URL、本机路径或数据库主键。
|
||||
- **Profile 绑定**:规则集必须与 Profile ID、宽高一致;Profile 变化必须形成新 revision,并在需要时标记 `recalibration_required`,旧几何不得静默重投影。
|
||||
- **规则坐标**:区域与方向线使用 0–1 归一化坐标,规则 ID 在同一规则集内唯一;退化多边形和重合线端点无效。
|
||||
- **完整性**:源配置对移除 `integrity` 后的 JCS 表示计算 SHA-256;校验失败保留上一有效 revision。
|
||||
- **配置应用状态**:Brain 在 `configurations[]` 中按 `config_id` 报告 `not_configured/applying/applied/rejected` 与实际 `applied_revision`;同一消息重复 ID 整条拒绝。
|
||||
- **状态时序**:Brain 实例 sequence 单调递增;Sense 拒绝倒序消息。观测时间超过约定 90 秒时由 Sense 标记陈旧,不用未知值覆盖最后已知投影。
|
||||
- **状态边界**:运行/健康错误只形成 Sense 运维投影,不是业务 Event 或 Bell Alert;不得包含用户会话、凭据、内部路径或客户视频。
|
||||
- **版本兼容**:v1 只接受已冻结语义;破坏性字段或语义变化发布新主版本。未知主版本停止摄取并保留上一有效事实。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: e6068ff0e42765d32ff4e0ee0e8e51cf7d79b7da
|
||||
synchronized_at: 2026-08-29T12:37:58Z
|
||||
wiki_revision: 43eb0f69fbb4d573a5cd1c61b97dbc9fc9d2a49b
|
||||
synchronized_at: 2026-08-31T01:38:33Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -624,3 +624,33 @@ Brain\.venv\Scripts\python.exe -m yovision_brain.app --config Brain\tests\fixtur
|
||||
|
||||
CLI 将内部事件 JSON Lines 写入 stdout,并把 completed/cancelled、帧数、检测数和事件数摘要写入 stderr。配置文件必须显式提供,当前使用 JSON;无命中正常返回零事件,读取/配置/模块失败返回非零且不回显机器路径。命令不启动 Sense/Bell、不连接摄像头或网络。
|
||||
<!-- brain-local-events-v1:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain v1 契约验证
|
||||
|
||||
源/规则配置契约:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/source-config-v1/run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录创建隔离虚拟环境,按固定依赖运行 Schema、跨字段语义、JCS/SHA-256、版本/重校准和秘密拒绝测试,结束后清理所属临时目录。
|
||||
|
||||
运行状态契约不需要第三方包:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/runtime-status-v1/test_contract.py
|
||||
```
|
||||
|
||||
测试覆盖六态运行状态、30 秒未来时间偏差、90 秒陈旧边界、空/多配置流、四种配置应用状态、重复 `config_id`、integer revision mismatch、倒序消息、未知主版本、回退保留和敏感字段拒绝。
|
||||
|
||||
仓库级复核:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tests -v
|
||||
python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
```
|
||||
|
||||
这些命令只验证冻结契约,不验证 #152 产品 adapter、真实网络传输、机器身份、现场断网恢复或端到端链路。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Product-Requirements
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Product-Requirements.-
|
||||
wiki_revision: eac307b5aa55ff770ff034c53a65b70dc01cb00d
|
||||
synchronized_at: 2026-08-29T12:39:32Z
|
||||
wiki_revision: 28dce0d89804013411f8111ba421c93e9a1dfa2c
|
||||
synchronized_at: 2026-08-31T01:39:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 产品需求
|
||||
@@ -272,3 +272,13 @@ BRN-002 的首个解码阶段已通过工单 #13 验收。Brain 通过可替换
|
||||
|
||||
内部候选只含逻辑输入引用、规则/模型版本、发生时间、匿名观测和解释原因,不含摄像头凭据、客户隐私、人脸、生物特征、机器绝对路径或伪造证据。该格式不是正式 Brain→Bell 契约;证据、机器身份、Outbox/可靠投递和跨项目 E2E 仍须协调工单实现。
|
||||
<!-- brain-local-events-delivery:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain 首批冻结契约
|
||||
|
||||
工单 #148、#149 已于 2026-08-31 通过用户验收并合入 `dev`。Sense→Brain 源/规则配置的唯一共享事实源为 `contracts/source-config/v1/`,版本标识为 `yovision.source-config/v1`;Brain→Sense 运行状态的唯一共享事实源为 `contracts/runtime-status/v1/`,版本标识为 `yovision.runtime-status/v1`。
|
||||
|
||||
源配置按 `config_id + integer revision` 形成不可复用的配置流,携带逻辑站点/设备/Profile、无凭据媒体引用、归一化区域/方向线、规则版本与完整性摘要。运行状态按同一 `config_id` 在 `configurations[]` 中报告实际应用 revision,并包含 Brain 实例、运行/模型版本、健康、输入和稳定错误码。
|
||||
|
||||
这两项只冻结协议和测试,不表示 #152 connector 已实现。Sense 与 Brain 仍可独立运行;Brain 不读取 Sense 数据库,Sense 不读取 Brain 内部状态。既有 `brain.internal.*`、Sense GORM 模型和运维投影继续是项目内部实现,不得直接作为共享协议。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: b21bbc64f323f465b78b557537c38e334de31f45
|
||||
synchronized_at: 2026-08-29T12:41:01Z
|
||||
wiki_revision: 0a772b0511044d98430ebd93304faa3dee57183d
|
||||
synchronized_at: 2026-08-31T01:39:35Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# YoVision 部署与运维
|
||||
@@ -134,3 +134,11 @@ Sense\start_sense.bat
|
||||
|
||||
运维告警排错不得粘贴设备地址、Stream URI、摄像头凭据、JWT、Cookie 或数据库连接。需要回退时可停止使用刷新/处置入口,但不得删除 `sense_ops_alerts` 或 `sense_ops_alert_transitions` 历史;规则语义变化必须另建工单。
|
||||
<!-- sense-ops-alerts:end -->
|
||||
|
||||
<!-- sense-brain-contracts-v1:start -->
|
||||
## Sense↔Brain 契约部署边界
|
||||
|
||||
`yovision.source-config/v1` 与 `yovision.runtime-status/v1` 已冻结,但当前没有因此新增监听端口、服务进程、机器凭据或根级编排。#148/#149 只交付 `contracts/**` Schema、样例、兼容说明和契约测试;实际 Sense↔Brain 传输、认证、超时、退避、重启恢复及配置/状态 adapter 由后续 #151、#152 实现和验收。
|
||||
|
||||
因此现阶段部署仍按 Sense、Brain 各自独立入口进行,不得手工共享数据库、用户 JWT/Cookie、摄像头凭据、文件目录或临时 JSON 字段来提前打通。需要停用或回退时保持两端独立运行,并保留上一已确认的配置与最后已知状态;未知协议主版本必须停止摄取而不是覆盖投影。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
Reference in New Issue
Block a user