Compare commits

..
Author SHA1 Message Date
QiuSW c2b023c9fe docs: 记录冻结的 Sense Brain v1 契约 (#148 #149) 2026-08-31 09:41:33 +08:00
ila 4c35da9ef6 Merge pull request '#157' from feature/149-runtime-status-contract into dev
冻结 Brain→Sense 推理运行与健康状态契约 v1(#149)。
2026-08-31 09:34:58 +08:00
ila 30c43aa8d7 Merge pull request '#159' from feature/148-source-config-contract into dev
冻结 Sense→Brain 媒体源与区域规则配置契约 v1(#148)。
2026-08-31 09:34:43 +08:00
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
21 changed files with 951 additions and 10 deletions
+35
View File
@@ -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,2 @@
jsonschema==4.23.0
rfc8785==0.1.4
+33
View File
@@ -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()
+26 -2
View File
@@ -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 -->
+16 -2
View File
@@ -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 -->
+32 -2
View File
@@ -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 -->
+12 -2
View File
@@ -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 -->
+10 -2
View File
@@ -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 -->