Skip to content

feat(perception+web): 本地视觉感知通路 —— 与云端 API 并列的第二条路径(免 Key / 画面不出本地 / 零 token 成本) - #488

Open
LeonJoeeee wants to merge 38 commits into
XiaoMi:mainfrom
LeonJoeeee:feat/local-vision-engine
Open

feat(perception+web): 本地视觉感知通路 —— 与云端 API 并列的第二条路径(免 Key / 画面不出本地 / 零 token 成本)#488
LeonJoeeee wants to merge 38 commits into
XiaoMi:mainfrom
LeonJoeeee:feat/local-vision-engine

Conversation

@LeonJoeeee

Copy link
Copy Markdown
Contributor

提案与背景见 #486。本 PR 实现该提案。

给感知加第二条通路:窗口帧在本地编码成 H.264,交给一个 GPU 边车服务,拿回中文场景描述 + 逐条规则判定。整条通路不需要任何模型厂商 API Key,画面不出本地,token 成本为零。

默认仍是云端通路 —— 不主动切换的用户,行为与今天完全一致。


怎么读这个 PR

59 个文件、9.5k 行,其中 4704 行(49%)是测试。不建议顺着 diff 读。建议按这个顺序看:

看什么 为什么先看它
1 backend/miloco/src/miloco/perception/local_vision/engine.py模块 docstring(开头 ~30 行) 一屏说完这条通路与云端的全部本质差异,以及每条差异的理由。读完这段再看别的,大部分设计就不需要解释了
2 backend/miloco/src/miloco/perception/client.py,_init_engine(L289)/ _init_local_engine(L357) 插拔缝在哪:按 perception.engine_backend 二选一。这里也是"默认不变"这个承诺的落点
3 services/local-vision/README.md 的「接口」与「已知限制」两节 两服务之间的 HTTP 契约,以及部署这条通路真正要知道的东西
4 backend/miloco/src/miloco/perception/local_vision/identity.py(556 行) 本 PR 里最容易出错也最值得审的一块:认人。它决定系统会不会叫错名字
5 backend/miloco/src/miloco/perception/capabilities.py + rule/runner.py(L1234 起) 「本地通路不执行设备动作」这条保证的两道门:切换时的转移检查 + 执行时的状态不变量
6 backend/miloco/src/miloco/config/settings.pyLocalVisionSettings 运行点位。每个字段的 docstring 里写着它为什么是这个值(都有实测)

其余按需:边车实现在 services/local-vision/local_vision/(app.py = HTTP 面,engine.py = 推理,prompts.py = 提示词构建与响应解析);前端在 web/src/components/PerceptionBackendCard.tsx + web/src/lib/perceptionBackend.ts(决策逻辑抽成纯模块,便于在 node 环境里测)。

如果只想审一处:identity.py 和它的 611 行测试。


架构

落在已有的插拔缝上,不动流水线骨架。 perception/engine_base.py 早就有 BasePerceptionEngine 抽象基类(云端 PerceptionEngine 就是它的实现之一)。本 PR 加一个同接口的 LocalVisionEngine,由 PerceptionEngineProxyperception.engine_backend 二选一。

GPU 推理放独立边车(services/local-vision/),在 uv workspace 之外。 miloco 的目标硬件是 Mac mini / 树莓派这类 CPU-only 机器,主包绝不能为一个可选功能背上 torch/CUDA。边车可以跑在另一台机器上(那种情况下强制要求访问凭证 —— 未配 token 时服务会拒绝绑定非环回地址,否则等于把家里画面的推理接口无鉴权地挂在局域网上)。

不接管模型进程的生命周期:不下载权重、不拉起、不重启。这条边界来自 #144 的教训 —— 1.x 由 miloco 管理本地模型容器,故障面扩散到显卡直通/驱动/容器,最终无人能支持。

契约与模型无关:送视频段 + 提问 + 规则(+ 可选名册)→ 返回描述 + 逐条判定。README 里把每个必填字段和"漏掉它会怎样"逐条列了出来 —— 例如省掉 auth_required / auth_ok,miloco 的凭证检查就会 fail-open。

前端:「模型」页新增后端选择卡片,切换即时生效。三种状态(云端生效 / 本地生效 / 边车不可达)都逐一渲染核对过。本地通路生效时,模型表会明说这些云端模型没有在服务视频感知 —— 否则页面上会同时挂着两个「生效中」徽章,用户没法判断到底谁在干活、会不会还在计费。


运行点位是怎么定的

这组参数最初是从云端通路继承来的,然后靠试错微调 —— 每一个方向都是错的。读了模型自己的代码和论文之后才明白为什么:

它们是一条约束链,不是四个独立旋钮。 画布是从 group_size=32 帧里选 16×16 patch 拼成的马赛克,每组出 images_per_group=4 张。于是 画布数 = 帧数 / 8,帧数 = 窗口 × 帧率,而 prompt token ≈ 223 × 画布数(实测)。喂的帧不够,画布预算就静默降级。

参数 定这个值的实测理由
window_size 12s 云端用 4s 是因为它每帧都要付 token 钱,这个数字和本模型没有任何关系。12s × 20fps = 240 帧,能喂饱接近官方默认档的画布数
container_fps 20 与相机实际出帧率一致。这个字段只写容器时基,写得与内容不符没有好处
max_frames 256 原值 32,把已经拿到的帧扔掉了 60% —— 相机一个 4s 窗给约 78 帧,截到 32 就只剩 4 张画布,而 10 张是现成的。现在是"窗口给多少要多少"
video_short_edge 不预缩放 预缩放是纯损失:它在模型看见之前就把 patch 选择机制要找的细节毁掉了。降维本来就该由 max_pixels(150000 ≈ ViT 原生 448²)在模型内部做
codec_target_canvas 12 真正的成本旋钮。原先是从帧数反推出来的 —— 只为了消掉一条警告

选 12 需要两个约束同时成立,而在真机上测之前我只有其中一个:12s×20fps=240 帧最多能喂饱 30 张画布,但实测 28 张要 13.8s(超出窗口)16 张要 11.4s(占窗口 95%,太贴边,吸收不了一次 GPU 抖动)12 张约 8s(66%)。"喂得饱"和"跑得完"是两个约束,现在有测试钉住出厂默认值必须同时满足两者。

效果:真机上同一个画面的描述从「两个人在用电脑」变成「一名戴眼镜的女性坐在电脑前,穿着深绿色上衣」,而窗口占用率与起点相当。

分路是一条规则,不是一次补丁。 窗口跟着当前生效的那条通路走(active_window_size_sec):切到本地自动变 12s,切回云端自动变回 4s,用户不需要记得改回来。模型页只显示当前后端那一组参数。


认人:不走视觉大模型

切过去之后,一整天真实素材里 10463 个窗口,0 个产出了名字 —— 每个人都是「一名男子」「一位女士」。云端是在它那一次 omni 调用里顺带把这件事做掉的,所以换后端等于静默地丢掉了整个能力。

最先试的是把云端那套原样搬过来(成员参考图 + 完整片段 + bbox 指人 + JSON 输出),在 7 个真实双人场景上实测:

逐人正确,名单顺序 小亮 在前      8/14
逐人正确,名单顺序 阳阳 在前      0/14
退化基线                     4/14 与 10/14
纯本地 ReID,同批场景同一个库    14/14

两种顺序合起来 29%,低于二选一瞎猜;调换名单里两个人的先后,7 个场景有 4 个答案会变。把待识别的那张图换成一张纯灰图,它照样报出一个人名 —— 这一条基本上就定案了。模型本身不瞎(只问性别和衣着是 5/5),但跨图细粒度同人比对不是这个量级的视频模型的能力。

所以认人整条不经过模型:

检测 (det_4C.onnx) → ReID 取特征 → 与 tier_a/*.npy 余弦比对 → 名册

名册("小亮[bbox=(357, 242, 467, 785)]",归一化到 [0,1000])随请求送给边车,渲染进提示词里 —— 模型只需要把给定的名字贴到给定的位置上,这件事它做得很稳(同批场景 7/7,而让它自己认是 8/28)。

不新增依赖、不下载权重:human_body_reid_v2.onnx 仓库里本来就有、本来就在跑(用于 DeepSORT 跨帧关联),tier_a/*.npy 也是登记流程一直在写的 —— library.py 的注释写着它们存下来是为了「后续做『未识别 track 跟已注册成员快速比对』」。这个 PR 就是把那个「后续」补上。

成本:每窗中位 308ms(在 3 帧上跑检测,CPU),在 12s 的窗口里,按相机记进 timing。

几条设计要点:

  • 认人是旁路。 任何失败都退化成空名册,该窗的描述与规则判定照常产出。引擎调用点和 resolver 内部各有一道保护 —— resolver 是注入的,而这条不变量属于引擎。
  • 一个名字在一个窗口里不会出现两次。 名册说「小亮同时在画面两处」会让模型写出自相矛盾的描述。
  • 低于阈值不产出条目,而不是产出「陌生人」条目 —— 免得模型被推着去描述一个不存在的人。
  • 先过阈值,再做指派 —— 顺序是承重的:阈值 → 指派 96/104,指派 → 阈值 40/104。先指派会逼着每个成员都被用掉:这台相机每个窗口都有一个真人 + 一个电视误检,电视框系统性地更靠近某个成员,于是它拿走那个名字,把真人挤到另一个名字上;阈值随后丢掉电视那一对,留下戴着错名字的真人

今天的线上验证:两人同框,12/12 全对、0 次叫反


时间水印兜底(按相机开关)

米家相机把日期时间合成进画面编码,所以到了任何消费方手里它就是像素。模型会去读,而且读错:60 段实测,44/60(73.3%) 的描述报出日期或时钟,其中年份只对 16/44。伤害不是多一个错字段 —— 真值 14:04 被读成 04:04 之后,描述写道「整个场景发生在一个安静的早晨」。

挂一句忽略提示能压到 0/60。但不能无条件挂:这句话会压掉屋里真实存在的钟(30 段配对,一个墙上的数字钟从 8/30 降到 1/30,McNemar p=0.039)。所以 miloco 按相机读 time-watermark(prop.2.5)再决定挂不挂,读不到就不挂 —— 漏挂只是退回本来就存在的风险,误挂却会主动删掉画面里的真实信息。第三方相机、没有该属性的机型、未绑定的账号、一次网络抖动,全部落在安全那一侧。

同时修掉了一个洞:这句话原先住在 DEFAULT_SCENE_ASK 里,而 build_promptscene_ask or DEFAULT_SCENE_ASK —— 主动查询会把 agent 自己的问题作为 scene_ask 传进来,于是整个默认值连同这句保护一起被替换掉了。定时通路有保护、主动查询裸奔,对着同一批带水印的画面。现在它是追加到调用方给的问题后面。

根治在设备侧,已另开 issue(#487);那个 issue 里也纠正了一个我自己先前信过的说法 —— 水印并不浪费大量 token(它在主 codec 通路上被系统性欠采样:占 patch 网格 1.5625%,只拿到 0.527% 预算)。


已知限制(请当作评审材料的一部分读)

认人

  • 0.70 这个阈值是拿电视误检标定的,从来没拿人标定过。 实测(同日库)真人 0.77–0.95,而电视屏幕里的人(检测器会以 0.94 置信度认为是真人)只有 0.44–0.67,0.70 挡掉 8/8 且 0 误拒 —— 余量只有 0.03(电视框峰值 0.670);调到 0.65 同一批降到 47/104,调到 0.60 则 104 个电视框全部拿到名字。但另一个方向从未被标定:未登记的人对一个健康库 top1 中位 0.818,实测 33 个陌生人框 33 个全被安上了成员名
    也就是说:本方案假定画面里只出现已登记成员。 要真正解决需要人脸校验或专门的拒识判据,不是调阈值能解决的。

  • 身份库过期不是「认不出」,是「高置信度认错」。 一个 36 天前的库在真机上把男的叫成阳阳(0.85)、女的叫成小亮(0.81),都远在 0.70 之上;同样 7 个双人场景逐人 0/14,而且错得系统——14 个框全都更靠近同一个成员。四种代码侧补救全部实测否掉:

    最优 1-to-1 指派      仍然 0/14 —— 最优指派本身就是错的
    margin 规则(top1−top2)  正确 0.000–0.108 vs 错误 0.012–0.141,完全重叠且方向相反
    库内自检              新库 −0.002 / 旧库 +0.002,零区分度
    同窗撞名              规模化后 84% 误报
    

    所以库的新鲜度是前提条件,不是可调参数。本 PR 能做的只是让它可见:算库龄并记日志、超期告警(带上实测后果)、"画面里有人却一个都没匹配上"时报出看到的最高相似度。库龄取自登记图片而不是 .npy —— 启动时的特征回填会重写每一个 .npy,那会把一个陈旧库的年龄清零,毁掉唯一可靠的信号。

  • 身份库的保鲜机制在本通路上是缺的:tier_c 入库要靠云端模型做同人校验,本地没有对应物。目前只能靠告警提醒用户重新登记。

  • 一条正面发现,顺带记下:同样 5 张样本,只是分一半给坐姿,认对率从 4/11 变成 11/11(与电视的间隙 +0.090 → +0.145);样本翻倍到 10 张只多 0.03。姿态覆盖决定成败,数量只是锦上添花。

时间水印

  • 那句提示的效果,相当程度骑在解码路径扰动上。 73.3% → 0% 这 73 个百分点里,约 63 个点是挂任何一个「请忽略 X、不要提 Y」从句就能拿到的(一句等长的、讲镜头畸变的无关句子能压到 10.0%),只有约 10 个点能归因于真的点名了水印(配对 McNemar p=0.031)。以后任何改提示词的改动(规则、相机须知、名册、换 checkpoint)都可能把它顶回去,而且不会有任何报错。 这一条同时写在代码注释里。
  • 0/60 也不等于零:95% 置信上界是 6.0%,要主张 1% 需要约 300 段。
  • 遮蔽像素这条路测过并否掉了:遮蔽不是中性操作(在 12 段没有水印的片段上遮一块空天花板,12 段描述没有一段与对照相同,其中 4 段改写了人物性别;而重跑同一文件是 6/6 逐字相同),而且同一台相机产出过 848×480 和 904×512 两种尺寸,按前者标定的框贴到后者上会露出秒的最后一位 —— 看着启用了,实际静默失效

通路本身

  • 没有画面变化门控。 云端有一层帧差/音量门,静止画面直接跳过;本通路对每台相机每个窗口都调一次边车。这是刻意的(本地推理不计费,漏事件的代价高于多跑一次),但GPU 是常态占用的,规划功耗时按这个算。
  • 相机之间是串行的。 单卡单模型,边车用一把锁串行推理,一个窗口的耗时约等于各相机之和。实测真实家庭画面单台 1.4–2.0s。
  • 单台相机的规则条数有上限(约 32 条)。生成预算按条数放大但封顶 1024 token,过了这个数末尾的判定会被截断,而 fail-closed 会把截断变成静默的未命中(truncated / unparsed_rules 会亮,日志有 WARNING)。注意没有指定相机的规则会广播到每一台,所以这个数是按相机算的。
  • 事件门在 Blackwell(sm_120)上不可用(mamba_ssm 需要 CUDA ≥12.8 工具链才能产出 sm_120 kernel)。服务会自动熄灯门控并在 /healthgate_error 里说明,描述与规则判定不受影响。另外门控是在体育解说数据上训练的,家庭场景属分布外 —— 所以 event_gate_threshold 默认 0(只观测、不据此丢窗口)。
  • 规则判定 fail-closed:解析不出判定一律算未命中。漏报只是少一次提醒,误报会让 agent 对着不存在的事实做决策。
  • 原始 H.264 直通没有做。 管线(订阅、回调、缓冲)是通的,但相机的原始包不是这里假设的 Annex-B H.264(NAL 布局像 HEVC),而 miloco 的裸流回调签名把 SDK 的 codec_id 丢掉了,所以在那一层连格式都判断不出来。它需要先把 codec_id 打通,是另一个改动。这不影响已测得的 codec 收益:那些数字本来就是在重编码后的流上测的。

本 PR 刻意不做

云端常驻 + 本地按需的混合模式。 现在是二选一开关。理由不是做不动,而是:本地通路还没有稳到可以当常驻层 —— 上面那一整节已知限制就是证据,尤其是认人对身份库新鲜度的硬依赖。在还在动的地基上加一层复杂度,不划算。 等这条通路自己站稳了,再谈两条一起跑。

同样刻意不做的还有:运行时把 STATIC 规则改写成 DYNAMIC(会丢掉 cooldown_minutes / idempotent 这个 schema 里唯一的限流手段,以及行动台账里 source=rule 的归属)。带直连设备动作的规则会拒绝切换到本通路并列出它们,由用户决定。


验证

  • 测试:backend 2956、边车 100、web 276(275 passed + 1 skipped)全绿;ruff check 覆盖 backend / cli / 边车三处通过,prettier 与 markdownlint 覆盖 knowledge/**README.md 通过,tsc --noEmit 通过。本 PR 新增的测试占全部改动的 49%
  • 变异验证:多轮把关键不变量逐个改坏、确认测试会红(失败归因、重建冷却、load() 里的 checkpoint 解析、纯读 GET、持续失败降级、loop-keyed 连接池、周期读取、活引擎优先、RuleHit 类型化、请求体上限等)。有一轮的变异结果因为 harness 用 shutil.move 还原源码、把 mtime 改回过去导致 Python 用了旧字节码而作废并重做
  • 线上:一套真实家庭部署上连续运行。杀掉边车 → 指数退避 → 降级 → 界面亮出「还没准备好 · 本地视觉服务不可达」并给出重启按钮 → 重启 → 自动恢复,全程无人干预(修好降级抖动之前,这里会永远循环:/health 是绿的,于是下一 tick 立刻重建、再跑 5 个失败窗口、再降级)。多相机路径用合成帧对着真实边车验证:4 台并发拿到 4 条描述、device_rule_map 完整、房间标注正确;8 个并发请求打 5 的上限,恰好 5 个进、3 个 503。
  • 审查:十余轮对抗性复审(干净上下文、安全、文档首跑演练、上游一致性、长跑与故障恢复、回归审计),每轮的结论与实测都写在对应提交信息里 —— 那里有比这份描述详细得多的记录,建议连着 diff 一起看。

一点披露

Mage-VL 的模型卡写明 "released for research purposes only and are not intended for product or service deployment"(许可证本身是 Apache-2.0)。本 PR 的主体是通路与契约,Mage-VL 只作为参考实现与实测对象;即使项目对该模型本身有顾虑,通路依然成立 —— 换任何满足契约的本地视觉服务都能工作。

LeonJoeeee and others added 30 commits August 1, 2026 14:10
…ception backend

Adds a second perception path that needs no model-provider API key: video
frames are encoded to H.264 and handed to a local GPU sidecar, which returns
a scene description plus per-rule judgments. Footage never leaves the home
and the path costs zero tokens.

Why not just point base_url at a local OpenAI-compatible server: the value of
a codec-native model comes from consuming H.264 motion vectors and residuals
directly, which the OpenAI chat protocol cannot carry (the reference model's
own online client sends sampled frames as image_url). Measured on real home
footage, the codec path uses 736 prompt tokens vs 7338 for uniform frame
sampling (-90%) and runs 3.2x faster.

Design:
- LocalVisionEngine implements the existing BasePerceptionEngine ABC, so the
  pipeline skeleton is untouched; PerceptionEngineProxy picks the backend from
  perception.engine_backend (default cloud — no behavior change for anyone who
  does not opt in).
- GPU inference lives in services/local-vision, a standalone sidecar outside
  the uv workspace: miloco's target hardware is CPU-only (Mac mini, Pi) and
  must never pull in torch/CUDA. miloco never downloads weights or manages the
  model process (see upstream XiaoMi#144, where 1.x owning the model container spread
  the failure surface into GPU passthrough and container issues).
- Vision-only by construction: speeches/env_sounds stay empty rather than
  letting a model that cannot hear invent them (same rationale as the existing
  requires_audio gating).
- The local path never drives devices: rule hits always go to the agent, so
  STATIC rules' direct execution is inactive. That is surfaced in the log and
  the WebUI instead of letting existing rules silently stop firing.

Rule parsing is fail-closed — an unparseable verdict counts as no-hit, since a
missed reminder is cheaper than the agent acting on a fact that never happened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the cloud-vs-local comparison to the perception-pipeline capability
boundary section and points the architecture overview at the BasePerceptionEngine
seam, per the knowledge base's L2 rule (record the why of key design decisions,
not the parameter values).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The admin perception-backend endpoint echoes the sidecar's /health body back
to the caller, and base_url is user-supplied — echoing the raw body would turn
that endpoint into a probe that can read any URL's response body (SSRF echo).
Only known fields pass through, strings are length-capped, and a non-dict body
yields an empty payload. Mirrors the existing anti-SSRF posture around omni
credentials, where a fetch target is never trusted just because an admin
supplied it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t line

Live testing surfaced a fourth output shape the parser missed: the model
restates the condition on the rule line and puts the verdict underneath --

    规则1: 有人在客厅沙发上
    否 - 依据: 沙发上没有人物

Fail-closed kept this safe (no false hit), but a real verdict was being read
as "no verdict", so a positive answer in that shape would have been a silent
missed hit. Now an unparseable rule line looks ahead to the next non-empty
line, stopping at the next rule line so a neighbour's verdict is never
borrowed.

Also stop back-filling the reason from the rule line's own text: when the
model only echoes the condition, that produced a hit=False row whose reason
read like the condition was satisfied -- exactly backwards to anyone reading
the record.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The core promise to upstream is that anyone who does not opt in sees no change
at all. Pin it directly on the dispatch in _init_engine: with default settings
the local branch must not be entered (otherwise every existing deployment
without a sidecar would land in PREREQ_MISSING and perception would stop), and
with engine_backend=local it must be entered rather than silently falling back
to the key-requiring cloud path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A clean-context review of the branch found four defects that unit tests
calling the engine directly could never have caught, because none of them
went through PerceptionEngineProxy.

1. The backend could not start with engine_backend=local. The proxy and the
   pipeline processor call set_tierc_frame_provider / set_main_loop /
   apply_omni_fps / get_input_config on the engine unconditionally, but the ABC
   declared only the two perceive methods, so those lived on the cloud engine
   alone. Any second implementation would AttributeError at startup. Fixed at
   the source: BasePerceptionEngine now declares them with harmless defaults,
   so the ABC is an honest contract for every implementation.

2. Rules fired once and then went silent forever. device_rule_map was never
   populated, and the rule state machine is edge-triggered — with no per-cycle
   False the last state stayed True for the process lifetime, so a rule that hit
   once never fired again, state-mode rules never EXITed, and duration windows
   only ever accumulated. Now populated per successfully-perceived device, and
   deliberately not for devices whose inference failed (registering those would
   retract rules on no evidence). The gate now suppresses only the narration,
   never the rule verdicts, for the same reason.

3. The "switch takes effect immediately" path was a silent no-op: it reached for
   manager.perception_engine_proxy, which does not exist, and getattr's default
   swallowed it. A user switching away from cloud saw a success toast while the
   cloud engine kept running and kept billing. Now goes through
   perception_service.stop_to_unconfigured, the same path omni activation uses.

4. The parser read 不是 as a hit. 不是 contains 是, and the miss-word list had no
   entry for it, so "厨房有明火? 不是 - 灶台已关闭" produced a hit whose reason said
   the opposite — precisely the false positive the fail-closed design promises
   cannot happen. Negation is now checked first, on the verdict head only, so a
   reason containing 不 no longer drags a genuine hit closed.

Also from the same review: blocking sync health probes moved off the event loop
and given short timeouts (they run every tick and would stall frame ingestion
against a firewalled sidecar); a stored token is no longer carried to a changed
base_url, and probe errors are reduced to a coarse code, since base_url is
user-supplied and the video itself would be the real loss; unresolvable rule
hits are dropped instead of being credited to the index-matched rule; encoder
setup moved inside try/finally so a PyAV build without libx264 degrades per
device instead of killing the cycle and leaking the container; payload budget
(max_frames, short_edge) added to match what the cloud path already does before
inference; empty-device cycles no longer book an inference error; and
non-latency timing keys moved under the underscore namespace.

The sidecar's tests now run in CI (services/ was covered by no job), the caption
no longer falls back to raw machine-formatted output, and token comparison is
constant-time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fixed

An adversarial re-review of the previous commit found that two of the four
"fixed" blockers still failed in realistic cases, and that the commit had
introduced new problems of its own.

**Rule verdicts were still read out of prose.** Checking negation first only
moved the boundary; the scan was still "does this 12-char window contain 是",
so any 但是 / 于是 / 总是 won, and the next-line lookahead handed arbitrary
prose to the same scanner. "画面中看到一个人影,但是不能确定是否在沙发上" came back
as a HIT whose reason said the opposite — the exact artifact fail-closed exists
to prevent. Verdicts are now recognised only as a word at the start of the
verdict head, with the asymmetry the safety argument requires: negation stays
loose (over-matching only loses a report), affirmation must end on a word
boundary (so a restated condition beginning with 有 is no longer a hit). 是否
is caught before the affirmative branch, since it is the model repeating the
question rather than answering it. Verified over the reviewer's full corpus:
zero false hits, zero dropped hits.

**device_rule_map was populated but then discarded.** skipped was derived from
"no caption and no matched rules", and the consumer returns before reading the
map when skipped is set — so a device that judged its rules and simply found
nothing still fed the state machine nothing, and rules stayed pinned exactly as
before. The previous commit's own caption fix made this the common case. skipped
now means "no evidence": it stays false whenever any rule was actually judged.

Also fixed from the same review: the CI job added last commit failed on every
run (pytest from the repo root cannot import local_vision — needs the package
dir as cwd); switching backends left the fresh engine without the tier_c frame
provider, because the rebuild now lands directly in ready and the tick path that
re-attaches only handles non-ready states; local engine construction had no
try/except, so a failure left status=ready with engine=None — ready forever
false and try_reinit refusing to retry, i.e. perception permanently stopped with
no self-heal; the health probe still blocked the main event loop every tick
against a firewalled sidecar (measured 1.5s per 4s cycle), now on a cooldown
after failure; uniform floor sampling never selected the last frame, silently
dropping the end of every window — the part most likely to contain the event;
the ABC's realtime_perceive signature omitted the on_early_* hooks that the
caller passes unconditionally, so a third implementation written to the ABC
would TypeError on its first cycle; and the STATIC-rule capability flag was dead
code, now a class attribute the admin endpoint actually reads instead of
hardcoding the same fact twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything the second review left on the table, rather than shipping a known-issue
list:

- Health probing no longer happens on the event loop at all. The tick now awaits
  a threaded refresh before the synchronous rebuild, so the rebuild path never
  touches the network; a firewalled sidecar can no longer stall camera ingestion,
  SSE and the API. The cooldown stays as a backstop.
- Persisting a base_url now always probes it first, on both branches. The cloud
  branch previously accepted an arbitrary address with no validation, which the
  GET endpoint would then go and probe.
- The sidecar caps in-flight inferences and returns 503 when busy. With three
  cameras each sending a request per window, queued requests would otherwise pile
  up holding threadpool threads on the GPU lock until /health itself timed out —
  miloco would then declare the sidecar dead over what is purely queueing.
- The sidecar loads weights on a background thread, so /health answers during the
  tens of seconds of loading. Previously the 'loading' state was unreachable and
  a cold start looked identical to a missing service.
- A 描述 written after the rule lines is recovered instead of leaving the caption
  empty with no way back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…arallel set

An audit for "did I redefine something that already exists" found three hits,
one of which was worse than duplication — it silently dropped a feature.

- **Per-camera 感知须知 was being ignored.** Users write per-camera guidance in the
  dashboard (CAMERA_PROMPT_MAP_KEY, surfaced as perception_prompt); the cloud path
  injects it every window. The local path never read it, so switching backends
  silently voided whatever the user had written, with nothing in the UI to hint at
  it. Now injected the same way.
- `physical_did` and the per-device rule filter were copy-pasted from the cloud
  engine. Both now live in perception/rule_scope and are used by both paths — two
  copies of "which rules go to which camera" is exactly the kind of thing that
  drifts silently and leaves the two backends disagreeing.
- `local_vision.video_short_edge` duplicated `perception.engine.input.video_short_edge`,
  which already has an API and a UI. It now defaults to following the shared value,
  so tuning resolution in the dashboard affects both paths; an explicit override
  remains for the rare case that needs it.

Two defects the live deployment caught that no unit test could:

- Making short_edge default to None (to follow the shared value) sent None into the
  encoder, which compares it against an int — every window raised TypeError and fell
  back. Only visible when the proxy actually builds the engine, which the unit tests
  bypass; now pinned by a test that goes through the proxy.
- Greedy decoding loops on this model: a real caption repeated "房间的角落里还有一个
  白色的物体" a dozen times, ran to the token cap and doubled generation time. The
  reference implementation hides this behind max_new_tokens=80; we want longer
  descriptions, so repetition has to be suppressed explicitly. Captions went from
  ~600 chars of repeated filler at 3.3s to ~140 clean chars at 1.3-1.6s.

Raw H.264 passthrough was implemented and then deliberately dropped from this PR.
The plumbing works (subscribe, callback, buffer), but the camera's raw packets are
not the plain H.264 Annex-B this assumed — the NAL layout matches HEVC — and
miloco's raw-video callback signature drops the SDK's codec_id, so the format
cannot even be determined at that layer. Feeding the camera's original bitstream is
a real further improvement, but it needs codec_id plumbed through first, which is
its own change. The measured codec-native benefit is unaffected: it was always
measured on a re-encoded stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mbiguous names

The audit listed these and I then only reported them instead of acting, which is
the same mistake as shipping a known-issue list.

- **libx264 was running synchronously inside a coroutine.** The project already
  learned this one: miot/transcoder.py exists specifically so "callers await the
  async encode() so the asyncio event loop is never blocked by libx264", and it
  keeps a dedicated executor for it. Window encoding now goes through
  asyncio.to_thread, with a test that fails if it ever runs on the loop thread
  again. On-demand queries are served from the main loop, so this was not
  theoretical.
- `fps` -> `container_fps`. Perception already has two frame rates that mean real
  things (engine.input.fps for dispatch/tracking, omni_fps for what reaches the
  model); a third field called `fps` reads as a third sampling rate when it only
  writes the mp4 container timebase.
- `gate_threshold` -> `event_gate_threshold`. Perception already has a gate (frame
  diff + audio energy, tuned by change_threshold). Two settings both called "gate"
  leave no way to tell which one you are adjusting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…camera note replaced the task

Two blockers, both introduced by the two commits immediately before this one.

**Renaming gate_threshold missed a call site, and it took the whole feature with
it.** `admin/router.py` still read `cfg.gate_threshold`; on pydantic v2 that is an
AttributeError, so GET and POST /api/admin/perception-backend both returned 500.
The 模型 page is the only way to switch backends, so on that branch the feature was
unreachable from the UI — and no test covers either endpoint, which is why the
rename looked safe. The field was dead weight anyway (no component read it), so it
is gone from the payload and the TS type rather than renamed.

**The per-camera 感知须知 was replacing the task instead of supplementing it.**
scene_ask defaults to empty, so the branch that was supposed to append the note
instead produced a prompt consisting only of the user's note — no question, no
"describe the scene" instruction. That is the inverse of what the previous commit
claimed to fix: the cloud path appends the note as an extra section on top of the
full task prompt. Worse, the note landed *upstream* of the output-format spec, so a
note like "只用一句话回答" could delete the 规则N: lines entirely — and because rule
parsing is deliberately fail-closed, every rule on that camera would stop firing
with no exception, no error_code, and the state machines still being fed False. The
note is now its own request field, rendered in a delimited block *after* the format
spec, length-capped, and explicitly told not to change the output format.

Also from the same review:

- The sidecar's in-flight cap defaulted to 2 while miloco allows 4 concurrent
  cameras and dispatches them all at once, so the same one or two cameras lost the
  race every window and their rules were never evaluated — silently, since a 503
  just drops the device from the batch. Default is now 4.
- The in-flight semaphore was acquired outside the try, so a failed temp-file write
  leaked the slot permanently; two of those and the sidecar answers 503 forever.
- repetition_penalty / no_repeat_ngram_size count the prompt too, and the prompt
  contains each rule's query verbatim — a model restating the condition in its
  verdict would get cut off mid-sentence, and that mangled text becomes the reason
  shown to the agent and the user. They now apply only when there are no rules,
  which is where the observed repetition actually happened.
- A rule hit with a blank name fell through to the index-positional rule, i.e. the
  exact mis-attribution the comment above it warns about. Blank names are dropped.
- video_short_edge is resolved per window instead of at engine construction, so the
  dashboard's "takes effect next frame" contract holds for this path too.
- api.py's `_physical_did` is a real delegation again rather than a snapshot alias,
  and the local path stopped doing an extra physical-did prompt lookup the cloud
  path never did — rule_scope exists to remove differences like that, not host them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…top the note from forging verdicts

**The UI was stating a property the code did not have.** The backend card, the log
line and both locale files all told the user "本地通路不直接控制设备:规则命中一律交
agent 决策执行". Nothing implemented it. RuleRunner is engine-agnostic, so a matched
rule with `actions` still went straight to `_execute_action`. A user with 厨房明火 →
关燃气总阀 could read that bullet, leave the rule enabled believing an agent would
vet it, and have the valve closed directly by a 4B model's "是" — from a path this
same PR documents as loop-prone and fail-closed-only. STATIC slots are now converted
to agent decisions while the local backend is active, preserving the configured
action semantics as intent for the agent. The cloud path is untouched, and the rule
tests now pin the backend explicitly rather than reading whatever the developer's
config.json happens to say (which is how this surfaced: the suite went red only
because this machine was switched to local).

**A camera note could fabricate a rule hit.** The note is user-editable free text
rendered next to the rule list. A note containing a line shaped like `规则1: 是 - …`
is indistinguishable from the demanded output format sitting one line above, so a
model echoing it produces a genuine, correctly-named `hit=True` → MatchedRule →
(per the above) a device action. User-writable text conjuring a rule hit is the
exact direction the design calls unacceptable. Verdict-shaped lines are now stripped
from the note, and the 「」 delimiters are stripped from its contents too — otherwise
a single 」 closed the block early and dropped the remainder into the strongest
recency position at the end of the prompt, where "上面的说明作废" would delete the
rule lines entirely and fail-closed would turn that into every rule on that camera
silently never firing.

**Turning off loop suppression when rules are present put the original bug back on
the only path that has rules.** Repetition is a decoder property, not a prompt
property; with rules the prompt still asks for a free-form description first, so the
loop still happens — and when it does it eats the whole token budget before any
規則N: line, leaving every rule unparsed and pushed to False, silently. The previous
justification was also only half right: repetition_penalty merely rescales logits
and cannot truncate anything, so it is safe to keep on unconditionally; only
no_repeat_ngram_size hard-bans, and only that one needs a larger n when rule queries
are in the prompt.

Also: the note cap now matches miloco's own 500-char limit and marks truncation
instead of silently dropping the qualifier users put last; the sidecar reports how
many rules produced no parsable verdict, so "the model said no" is distinguishable
from "parsing failed" in the logs; in-flight slots leave headroom above the camera
count so an on-demand query can't be starved by a realtime window; temp-file cleanup
can no longer turn a successful inference into an unhandled 500; video_short_edge
gained a lower bound (0 or negative silently disabled the whole payload budget); and
the remaining snapshot alias in api.py became a real delegation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is local

Screenshotting the real dashboard surfaced this: the 模型 page carries two 生效中
badges that mean different things. The perception card says 本地 GPU 生效中, and
directly below it the model table still marks mimo-v2.5 as 生效中 — while nothing
is calling it for perception. A user reading that page has no way to tell which
one is actually doing the work, and would reasonably conclude the cloud model is
still being billed.

The model table now states, when the backend is local, that these models are not
serving video perception and how to switch back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tch instead

Round 5 showed the STATIC-suppression mechanism from the previous commit was worse
than the problem it solved, so it is gone and the shared rule engine is back to
byte-identical with upstream.

What was wrong with it. `_static_actions_as_prompt` read a `description` field that
does not exist on `RuleAction`, so every action fell through to a `did`/`iid`/`value`
fallback that drops `params` entirely — a TTS rule's whole message text vanished, and
a `call_action` was described to the agent as "set a property to None". The test I
wrote for it copy-pasted the production branch into the test body instead of calling
`_fire`, against a `SimpleNamespace` carrying that non-existent field; replacing the
whole conversion with `pass` left 215 tests passing. The fake schema in the test is
exactly why the broken payload shipped.

And two losses were structural, not fixable by writing a better prompt: routing
through the agent discards `cooldown_minutes`/`idempotent` — the only rate limiter
the schema has, which the service layer validates as mandatory for non-idempotent
actions, so a TTS rule becomes an agent callback every window — and it discards the
action ledger's `source=rule` attribution, breaking the rule→device-change link XiaoMi#406
was built to provide.

Rewriting a user's configured automation at runtime, in a way that silently drops its
rate limit and its audit trail, to be re-executed by an LLM from a lossy translation,
is not a safe default. Switching to the local backend now **refuses** while rules with
direct device actions are enabled, and names them. The user decides: disable them, or
stay on cloud. No shared-engine surgery, no lost semantics, predictable, testable —
and it is what "STATIC 管线先不启用" actually means.

Also from round 5:

- Note sanitization was defeated two ways: `「规则1: 是 - …` passed the per-line check
  and *became* a valid verdict line after the quote-stripping ran, and two individually
  harmless lines joined into one. The invariant now holds on the final rendered text,
  and the `规则N:` token itself is removed rather than merely displaced — leaving the
  text in place just means the model can copy it onto its own line.
- Rule `query` had no sanitization at all despite being unconstrained free text spliced
  directly into the condition list, where one newline forges a rule and renumbers the
  rest. Same treatment.
- `unparsed_rules` was computed and logged on the GPU box, then silently dropped at the
  HTTP boundary by pydantic's default `extra="ignore"`. It now crosses the wire, and
  miloco warns per device — otherwise "the model emitted garbage" and "the model said
  no" are the same input to an edge-triggered state machine, and a STATE rule mid-ENTER
  fires its `on_exit` actions on a person still standing there.
- Conflicting duplicate verdicts for one rule resolve to no-verdict instead of
  last-wins, which could flip a 否 into a 是.
- `hmac.compare_digest` raises TypeError on non-ASCII, so one Chinese character in the
  token turned every auth check into an unhandled 500 while /health still said ok.
- `pick_backend` kept `codec` when the frame probe failed, inverting its own documented
  contract — a box without ffprobe took the codec path and 500'd every window.
- The CI job installed torch and 43 nvidia wheels (4.9 GB) because `uv run` in a
  directory with a pyproject.toml means project mode; `--no-project` gets the same
  coverage in seconds without evicting the other jobs' caches.
- A non-dict sidecar response killed the whole window for every device instead of
  degrading that one.
- The "cloud models are idle" banner never updated after an in-page switch, so it
  asserted the opposite of the truth in both directions until a reload; and its zh text
  claimed the models are still called for other things, which is false and absent from
  the en text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit changed how the local path honours 'no direct device control'
— from rewriting rules at runtime to refusing the switch — but left the card
saying '规则命中一律交给 agent 决策执行'. That is the same defect as the round-4
blocker: the UI asserting a mechanism the code does not have. The bullet now says
what actually happens, and the card lists the enabled rules that will block the
switch so the user sees them before clicking rather than in a 400.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uthenticates

The health probe never sent credentials. Any deployment with LOCAL_VISION_TOKEN set
would therefore green-light the probe and then 401 on every inference window: the
card stayed green while perception silently stopped. Only a live run with a token
configured exposed it — every test above that layer mocks health_sync. The probe
now sends the token, the sidecar reports auth_required/auth_ok from the same
comparison the inference path uses, and both the switch endpoint and the engine
refuse on a credential mismatch instead of proceeding.

Also in this commit:
- token input in the card; it is only submitted when non-empty, since an empty
  string means 'clear the stored credential' (buildSwitchPayload, tested)
- generation budget scales with rule count; truncation is reported and logged.
  Without it, more rules means the trailing verdicts get cut off and fail-closed
  reads them as 'no match' — silently, and worse the further down the list
- on-demand queries relax the repetition guard, which was tuned for periodic
  captions, not for free-form agent questions
- max_pixels now also applies on the frames branch — the branch short segments
  actually take, where the visual budget was previously unbounded
- probe cooldown is keyed on (base_url, token): fixing a wrong address no longer
  costs the user a 30s wait with the UI already claiming 'local'
- switching back to cloud is never blocked, only annotated with cloud_hint;
  it is the escape hatch from a broken local path

Tests: sidecar app.py/video.py had no coverage at all (60 tests now); the admin
endpoint had none (11 tests, all five gates mutation-checked); the card's decision
logic moved to a pure module so the node-env suite can cover it without pulling
jsdom into the repo. An i18n test now fails when a referenced key is missing —
the previous commit shipped four raw keys visible on the page.

Live: 书房 camera, codec path, 10 windows — median 223 chars / 2063 ms (RTF 0.52),
0% truncation; frames fallback verified separately with max_pixels applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not a switch-time check

The refusal added last round only ran at POST /perception-backend. Every route that
reaches the forbidden state afterwards was open: create a rule with actions while
local is active, edit an existing rule to add them, activate a task that bulk-enables
its rules, or edit config.json directly. Nothing on the firing path knew a backend
existed — _select_slot returns ("static", actions) purely on field presence — so the
gas-valve rule the code uses as its own example would have been executed on a verdict
from a vision-only 4B model with no audio corroboration and no identity, while four
surfaces told the user this path performs no device actions.

The gate now sits at the point of execution, which is the only complete one: rules are
driven exclusively by perception (three call sites, all in perception/client.py), so
keying on the perception backend cannot over-block. Refusal is loud — an ERROR naming
the rule plus a rule_action_refused event. Still no rewriting to dynamic: that would
drop cooldown_minutes/idempotent and the source=rule ledger attribution.

Three tests that proved nothing, found by an adversarial audit:
- the frame-budget test asserted only the sample count, so replacing endpoint-inclusive
  sampling with floor sampling (which never reaches the last frame — the exact bug the
  production comment warns about) left it green. All frames were identical zeros.
- the switch-refusal test patched the helper, called it, and asserted the patch; the
  half that ran real code ignored enabled_only, so a *disabled* rule blocking the
  switch would have gone unnoticed. There is now an endpoint test using the real lookup.
- the entire local bring-up path (~45 statements: auth refusal, model-loading wait,
  construction failure, probe invalidation, cooldown) had zero executed lines while two
  tests appeared to cover it — both mocked _init_local_engine away.

Also fixed, each found by review rather than by the suite: probe cooldown never armed
for a rejected credential (~21.6k pointless probes/day); config changes re-probed
synchronously on the API event loop; the explicit restart button was a no-op inside the
cooldown; JSON ints for boolean health fields were dropped, making the auth check
fail-open for third-party sidecars; captions and matched rules carried no time_window,
so every local event lost its 时间 line; clip bytes were never attached, so events were
text-only; per-device sidecar failures were invisible (a camera 503ing every window
showed zero errors); frames branch ignored max_pixels; get_input_config returned None,
rendering 0fps for all three layers; the local button showed green 可达 while the line
below it said the credential was rejected; and duplicate rule verdicts that agreed but
were worded differently were treated as conflicts and fail-closed away.

17 mutations verified caught. Note: the earlier mutation harness restored sources with
shutil.move, which set mtime backwards and left Python using bytecode compiled from the
mutated source — those results were void and have been redone with a cache-safe harness.

Suites: backend 2874, sidecar 71, web 267. Live on 书房: time_window correct in deploy
timezone, codec path 2.5s/window. Clip attachment is test-verified only — no meaningful
event fired during the observation window, so it was not seen end-to-end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…escribing a mechanism that was deleted

Three things this branch got wrong, all found by review rather than by the suite.

**The gate was over-broad.** Last commit's message claimed rules are driven only by
perception (three call sites). There is a fourth: RuleRunner.trigger_rule, reached from
POST /api/rules/{id}/trigger and the CLI — a human or the agent explicitly asking for a
rule to run. That is precisely the actor the design says decides. It was being refused,
and the endpoint translated the None into "Rule not found or disabled", which is simply
false. The gate now applies only to perception-driven fires.

**A refusal left no trace an operator could find.** _fire returned before the RuleLog
write, so the rule's execution history in the UI was empty — the automation stopped and
nothing said why. A refusal is now recorded as RULE_TRIGGER_FAILURE with a reason on
RuleExecuteResult, and it no longer emits the FIRE log line or the rule_fire event for
an execution that did not happen. The blocking-rule list on the model page was rendered
only while still on cloud (rendered under a not-isLocal guard), i.e. hidden in the one state where those rules
are actually being refused; it now shows in both, with wording per state.

**Six places still described the static→dynamic rewriting deleted two commits ago** —
the engine module docstring, capabilities.py, the switch log, the card header comment,
the sidecar README and the knowledge doc all said hits are "一律交 agent 决策执行",
which reads as "your valve rule still runs, just via the agent". It does not run. Also
corrected: video.py asserted as measured fact that a 4s window yields ~4 frames and so
normally falls back to the frames backend — the opposite is true (246 consecutive live
windows all took codec), and a contributor acting on it could conclude the whole
codec-native rationale was dead code.

I also reintroduced synchronous HTTP on the API event loop in the last commit — both
try_reinit(include_failed=True) and the first switch to local probed inline, stalling
camera ingest, SSE and the whole API for up to 3s. Sync probing is now confined to
construction; every other path waits for the threaded tick refresh.

Docs: the sidecar README's env table was broken by a stray blank line I added; four env
vars the code reads were undocumented; the interface section omitted auth_required /
auth_ok / unparsed_rules / truncated, so a third-party sidecar built to the documented
shape would make miloco's credential check fail open — the required fields are now
called out with the consequence of omitting each. The card's health line now says the
sidecar backend shown is the startup value, since the effective one is chosen per request.

Tests: 11 mutations that survived the previous round now fail as they should — the
rejected-credential cooldown, the restart bypass, the no-sync-probe rule, the token
ceiling, the tick ordering, the capability linkage (was a tautology: two equal values,
not a dependency), the timezone (was a shape regex that cannot distinguish any two
zones — the exact 凌晨3点 incident), the sidecar kwarg-rename guard (the double swallowed
**kw), and the manual-trigger exemption. One test I wrote last round was racy: it keyed
sidecar responses by call order while the per-camera encodes finish in thread-pool order,
so which camera got the malformed response drifted. It keys responses by device id now.

Suites: backend 2885, sidecar 73, web 271. Live on 书房: codec path, 1030 ms/window,
time_window correct in deploy timezone, switch log now accurate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…this

A maintainer-perspective review found the branch had red CI and three ways it
reached users who stay on the cloud backend. Those are the ones that matter most
for a feature that is off by default.

- **CI was red.** The knowledge-doc table I added is not Prettier-formatted, and
  .github/workflows/docs.yml checks knowledge/**. Verified both directions against
  origin/main.
- **The rule engine started importing the whole perception package.** A module-level
  `from miloco.perception.capabilities import ...` in rule/runner.py pulled in cv2,
  av, numpy and perception.processor — measured +0.36s on `import miloco.rule.runner`,
  where origin/main has zero top-level perception imports there (its one perception
  dependency is deliberately function-local). The import is now function-local too;
  after the fix only `av` remains, and that arrives via miot.client, which runner.py
  already depended on.
- **Cloud users got a permanent orange warning about their own automations.** The
  blocking-rules box rendered whenever the list was non-empty, so a default install
  with one ordinary "someone comes in → turn on the light" rule showed a standing
  warning on the model page. Direct-device rules are the product's most common
  automation. It now renders only while the local backend is active, which is when
  those actions are actually being refused; the 400 on a refused switch already
  lists them by name.
- **The shared ABC no longer gets no-op defaults.** Adding them saved five one-liners
  in the new engine at the cost of the cloud engine's failure mode: a renamed
  `close()` would silently no-op and leak the identity dispatcher thread instead of
  raising. LocalVisionEngine implements the five hooks itself. The one edit kept to
  engine_base.py is the `realtime_perceive` signature, which did not include the
  `on_early_*` callbacks the caller passes unconditionally — a new implementer
  copying the abstract signature would TypeError on the first cycle.

Also from that review: a fresh httpx.AsyncClient per device per window (4 cameras ×
4s windows = a TCP handshake per camera per second, never reaching keep-alive) is now
one pooled client released in close(); the two pass-through wrappers in engine/api.py
are gone in favour of the shared helpers they delegated to; the orphan uv.lock is
removed (nothing consumed it, and it is structurally incomplete since torch is
deliberately not a dependency); and the sidecar is now covered by the lint job, which
had two import-order errors nobody was running.

One real defect fixed alongside: a sidecar stuck loading forever — the load exception
is caught deliberately so the process stays up — was re-probed every 4s indefinitely,
because only the auth-rejected case armed a cooldown. Loading now gets a short one.

Test gap closed, and it was the important one: the device-action gate had never been
exercised through a perception-driven fire. Every gate test called `_fire` directly and
relied on the parameter default, so marking the perception call site as non-perception
— which opens the gas valve on every hit — left all 2885 backend tests green. Two tests
now drive it through `update_state`, the entry point the perception engine actually
calls, one per backend. Also fixed from the same audit: the tick-ordering test asserted
on `inspect.getsource` string positions (green if the call were replaced by anything
containing that identifier, red on a harmless refactor) and PipelineProcessor's
delegation had no coverage at all — gutting it left the whole "no sync HTTP on the main
loop" design dead with 1316 tests passing; the i18n key check missed dynamic
`t(cond ? a : b)` usage, so a typo in a new key shipped green while the page rendered
the raw identifier; `isReachable`'s test restated the implementation instead of
asserting a value, hiding that the card showed a green 可达 badge while the sidecar was
still loading and the switch endpoint would refuse; six config fields were never checked
for reaching the engine; and `_make_proxy` now asserts field parity with the real object
rather than being right by hand.

The docs now scope the guarantee honestly: "the local path performs no device actions"
constrains the perception layer. Dynamic rules still reach the agent, and the agent can
drive devices — the risk is mitigated by an LLM adjudicating first, not eliminated.

Suites: backend 2892 (3 consecutive clean runs), sidecar 74, web 271; ruff clean
including the sidecar; prettier clean. Live on 书房: 1480 ms median, connection reuse
confirmed (7 windows, 2 sockets).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s the docs promised

Two fresh lenses this round — a security review and a literal first-run rehearsal of
the README by someone who had never seen it. Both found things eight rounds of
correctness review had not.

**The sidecar could not be installed by following its own README.** `requires-python`
said >=3.10 with no upper bound, but codec-video-prep ships Linux-only cp39–cp312
wheels with no sdist and pins numpy<2.0, whose newest matching release also stops at
cp312. `python -m venv` on a machine whose `python` is 3.13+ fails outright at
`pip install -e .`. The one environment where this ever worked was built by uv on a
managed 3.13 with numpy compiled from source — a toolchain the README never mentions.
Now pinned to >=3.10,<3.13 with a stated support matrix (Linux only; macOS has no
wheels at all, which quietly contradicted "run it on another GPU box").

Also from that rehearsal: `pytest tests/` was documented but pytest was in no
dependency group, so the documented command was `command not found` — there is a `dev`
extra now. And `pip install -e .` pulls torch regardless, because accelerate requires
it; if the reader skips the cu128 line pip silently installs a generic build that is
wrong for the hardware the README calls out. The install order is now stated as
load-bearing rather than incidental.

**A failed model load was indistinguishable from a slow one, forever.** The loader
deliberately catches its exception so the process stays up, so a typo'd checkpoint path
left /health reporting "loading" indefinitely while miloco told the user "still loading,
try again shortly" — advice they could follow until the heat death of the universe.
/health now carries `load_error`, both miloco surfaces report it, and
`resolve_checkpoint` refuses a path-shaped argument that does not exist instead of
passing it to the Hub as a repo id (which produced an error message about repo-id
syntax to a user who had typed a directory).

**Security findings, none of them blocking.** The README's "no token ⇒ loopback only"
rule was a sentence, not a check — while the same document steers deployers toward a
separate GPU box, whose obvious `--host 0.0.0.0` yields an unauthenticated inference
endpoint carrying home camera footage on the LAN. The service now refuses to start in
that configuration. Request bodies were unbounded and both the buffering and the base64
decode happen before the inflight semaphore, on a 40-worker threadpool, so the limiter
did not bound memory at all; there is a 64 MiB cap now. `scene_ask` was unsanitized and
safe only by an unasserted coincidence (the on-demand path happens to send no rules, so
there was no verdict block to suppress) while the on-demand query is agent-authored —
it goes through the same sanitizer as camera notes now. Two smaller ones: the probe
error was scrubbed to "unreachable" in the admin endpoint but published verbatim
(target URL, status, exception class) through the engine-status endpoint, and base_url
accepted `?`/`#`, which take over the path the client builds.

The prompt-sanitizer docstring claimed more than the code delivers, and I have corrected
it rather than the code: it defeats syntactic forgery of a verdict line, not instruction
following. Neither does the cloud path, which injects the same agent-writable text into
its system prompt with no sanitization at all — this path is safer, not immune, and the
docstring now says so.

Discoverability: the model page offered a "Local GPU" button whose failure mode was a
correct but dead-end "service unreachable" — nothing anywhere told the user what service
that is or where to get it. There is a line pointing at services/local-vision/README.md,
and the README grew a troubleshooting table for the three failures a first-timer
actually hits (stuck loading, silently degraded to the frames backend because ffprobe is
missing, token mismatch).

Verified: all CI jobs run locally with their exact commands (prettier, markdownlint,
workflow-sanity, the sidecar job including its new deps, web install/typecheck/test/build,
ruff now covering the sidecar). Backend 2895, sidecar 80, web 271. Live: startup refusal
confirmed by actually trying `--host 0.0.0.0` with no token; /health carries the new
field; perception at 1432 ms/window; and the first-run hint verified on screen by
switching the real deployment to cloud and back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ake failures visible

Two lenses this round: a long-run/fault-recovery soak on the live deployment, and an
upstream-consistency review asking whether this code looks like the repo it is joining.
Both found things nine rounds of correctness, test, security and docs review had not.

**A dead sidecar was invisible to the user.** The cloud path gets a circuit breaker, a
global red banner and a 「立即重试」 button. The local path got nothing: once the engine
was ready it was never probed again, so a sidecar that died mid-run left the status
`ready` forever while every window quietly returned `skipped`. The only signal was
"events stopped appearing", which takes a long time to notice in a home. The engine now
reports sustained failure and the proxy demotes it to the same PREREQ_MISSING state the
cloud prerequisites use — so the existing status ribbon, its message and its restart
button all light up with no new UI. Verified live: killed the sidecar, demotion fired
33 s later, and the overview page showed 「还没准备好 · 本地视觉服务不可达」 with the
restart button.

**And it burned CPU while doing it.** Because the engine stayed up, every window still
ran a libx264 encode per camera before failing to connect — 4 cameras is 4 wasted
encodes every 4 s, forever, with the log filled to match. There is exponential backoff
now (4→8→16 s, capped at 30, cleared by one success). Live: attempts over 45 s dropped
from ~11 to 5.

**Every inference was emitting a warning I had never read.** `target_canvas` is a count
of canvases, not frames: filling 32 of them needs 256 source frames, and a 4 s miloco
window has at most 32. So every single request asked for something impossible and the
model said so, in its own stdout, for the entire evaluation. It is derived from the real
frame count now; the warning is gone and output quality and latency are unchanged.

**A killed process left home-camera footage in /tmp.** Cleanup lives in a `finally`,
which SIGKILL skips. Old segments are swept at startup — but only ones old enough that
they cannot belong to a second sidecar instance sharing the machine.

The consistency review's findings, in order of how much they matter:

- Backend rejections were hardcoded Chinese with no machine-readable code, which the
  repo has a written rule against (`OmniHealthBanner.tsx`: "backend message 是硬编码
  中文,直接注入会污染英文界面") and an implemented convention for. They carry `code`
  now and the card maps it, like `OMNI_CODE_KEY` does. Fixing this exposed that
  `apiFetch` stringifies an object `detail` into "[object Object]" — a latent bug the
  omni PUT path has had all along; it unwraps `{code, message}` now, so both paths win.
- `GET /perception-backend` did a 3 s health probe, a full rule-table scan and a
  `validate_resources` (which mkdirs) on **every** call, and the model page mounts two
  components that each call it. The repo separates config reads from explicit probes
  (`get_omni_config` is zero-IO). It is a pure read by default now; the card opts in
  with `?probe=1` and the model table, which only needs to know which backend is active,
  does not.
- `_validated_base_url` duplicated `probe._normalize_base_url` and was weaker (no host
  check, so `http:///health` passed). It calls the existing one, and the `?`/`#` rule I
  had added moved there, so the omni path gets it too.
- The cross-URL credential wipe compared raw strings, so a trailing slash counted as a
  URL change and silently discarded a still-valid token. It uses the same normalized
  comparison `_key_by_label` does.
- The pooled `httpx.AsyncClient` had no event-loop guard, in a repo that wrote a comment
  block about exactly this failure (`_get_fused_http_client`). It is keyed by loop now,
  with the same connection limits.
- The sidecar had no ruff config, so the lint line I added to CI last round was running
  ruff's *defaults* — no isort, and `F403` on. Adopting the repo's block immediately
  surfaced three import-order violations nobody was checking.
- The no-op defaults I put on `BasePerceptionEngine` are gone (they weakened the cloud
  engine's failure mode); the five hooks live on `LocalVisionEngine`. Two pass-through
  wrappers in `engine/api.py` are deleted in favour of the shared helpers. The inline
  soft-stop reuses `_soft_stop_best_effort`. Two test files I created folded back into
  `i18n.test.ts` and `real.test.ts` where the repo keeps those. `perceptionBackend` i18n
  moved to its own domain file, per the one-domain-per-file rule.

Mutation-verified: pure-read GET, sustained-failure demotion, the `?`/`#` rule, and the
loop-keyed pool all fail the suite when broken. Suites: backend 2899, sidecar 84, web
274. All CI jobs run locally with their exact commands. Live: kill → backoff → demote →
UI shows it → restart → automatic recovery, no intervention.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e contract

Two lenses: a maintainer reading the diff **without the commit messages** (to find what
the code fails to explain about itself), and the multi-camera paths — the concurrency
cap, per-device errors and on-demand fan-out were all written for multiple cameras and
had only ever run with one.

**The capacity warning I added last commit read a field that does not exist.**
`getattr(batch, "window_duration_ms", 0) or 4000.0` — `BatchedSnapshot` has exactly
`snapshots` and `captured_at`; that name exists only as a SQLite column elsewhere. So
the divisor was always the hardcoded 4000, ignoring the user's configured `period_sec`:
set it to 8 and every window warns spuriously, set it to 2 and it never warns when it
should. It reads `get_input_config().period_sec` now — which the same class already knew
how to read, 175 lines above. Verified live: 1331 ms against a 4 s period stays quiet;
a 1 ms period fires immediately.

**The demotion path tore the engine down while violating two invariants the same file
states explicitly.** `stop_to_unconfigured` takes `_engine_lock` ("teardown 必等当前推理
完成 → 杜绝 use-after-close") and awaits `close()` before nulling. The demotion I added
did neither: it could null the engine mid-`on_demand_perceive`, and it dropped the
persistent httpx pool on every demote→rebuild cycle. Same teardown as its neighbour now.

**`rule_hits` was the one untyped field in a contract whose stated purpose is third-party
reimplementation.** `list[dict]`, with three hard requirements the consumer imposes and
no document states: entries positionally aligned with the request's rules, `name`
echoed back (an empty one is *dropped*, not index-matched — the comment even contemplates
"一个只回命中项的第三方边车"), and a `reason` that appears nowhere in the sidecar or the
README. It is a `RuleHit` model now and the README's required-fields table covers it.
The typing earns its keep by normalising: a hit missing `reason` gets `""` rather than
`None` reaching the event text, and engine-internal keys are dropped.

Multi-camera, run against the real sidecar with synthetic frames — **I did not enable the
other two cameras in the user's home**: it was 00:40 and he had scoped the live test to
one room. Four devices concurrent: 4 captions, `device_rule_map` complete, no per-device
errors, rooms labelled correctly; on-demand fan-out labels each room; 8 concurrent
requests against a cap of 5 gave exactly 5 admitted and 3 refused with 503.

That run surfaced a capacity fact worth documenting: the sidecar serialises inference
under one lock, so a window costs roughly the sum of its cameras. At the measured
1.4–2.0 s per camera on real footage, two cameras already approach the 4 s default
period. README now says so, along with the ~32-rule-per-camera budget cliff (past it the
trailing verdicts truncate and fail-closed turns them into silent misses) and the fact
that rules without a camera list broadcast to every camera.

Also from the stranger's read: `perception_executes_device_actions` consulted config
only, which leaves a real window — switching back to cloud writes config first and the
soft-stop that follows is best-effort by design, so config could say `cloud` while the
local engine was still perceiving, and the guard would let device actions through. It
asks the live engine first now, with a test pinning the private attribute chain so a
rename fails loudly instead of silently reopening that window. `scene_ask`, `camera_note`,
`rules` and `query` gained the same size caps `video_b64` has — they sit in the same body,
parsed in the same pre-gate window, and only bounding the video meant the analysis and the
mitigation did not line up. And the JSDoc explaining the auth-rejected-is-not-green rule
was attached to `isReachable` instead of `healthLine`, which is the function that
implements it — two docblocks had stacked and only the second bound.

Mutation-verified: the period lookup, the live-engine preference, the `RuleHit` typing
and the body caps all fail the suite when reverted. Suites: backend 2904, sidecar 86,
web 274. Lint, prettier and every CI job run locally with their exact commands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…last three rounds broke

This round wrote no new mechanism. It is a regression audit of rounds 9–11, run as four
independent lenses (engine lifecycle, the two-service contract, whether tests were
weakened while being "fixed", and which claims later commits made false) with every
finding put through an adversarial refutation pass. 35 filed, 31 survived, **none of them
blockers** — the first round where that is true. Most were caused by my own recent fixes.

**The demotion added two rounds ago flapped forever.** Two defects compounded:

- `_consecutive_failures` counted *any* all-device failure, including `encode_failed`,
  which never touches the network. A PyAV build without libx264 therefore produced
  「边车连续 5 窗不可达」 and tore the engine down — pointing the user at the sidecar while
  the real cause sat in a separate WARNING, and tearing down something a teardown cannot
  fix. Only sidecar-attributable errors count now.
- Worse, the demotion undid itself: `/health` is green, so the next tick's `try_reinit`
  rebuilt immediately, ran five more failing windows, demoted again — cycling forever,
  flickering the status ribbon and replacing the httpx pool every cycle. There is a
  60 s rebuild cooldown now. Verified live by killing the sidecar: **one** demotion and
  **one** rebuild over two minutes, where before it would have cycled repeatedly.

**Docstrings that state the opposite of the code beneath them.** `capabilities.py` still
argued for reading config rather than the live engine — the exact behaviour I changed one
commit earlier, for a reason the docstring then contradicted. The `RuleHit` contract text,
added specifically to specify the contract for third-party sidecars, described the
matching rule backwards and promised a positional-index fallback the consumer does not
have (it drops unmatched hits, deliberately, because guessing by position is how 「厨房明火」
ends up carrying 「有人跌倒」's verdict).

**`resolve_checkpoint` raised in the wrong place.** I added the fail-fast for a
path-shaped checkpoint that does not exist, then called it from `lifespan` — so a typo'd
path killed the process before it could serve `/health`, and the user saw "unreachable"
with the real reason gone from anywhere they would look. It resolves inside `load()` now,
where the existing handler turns it into `load_error`. Which mattered more than it looks,
because:

**The card still collapsed "load failed" into "still loading".** `load_error` was
plumbed end to end two rounds ago and then never reached the surface a user actually
watches, which kept advising them to wait for something that will never finish.

Tests that were weakened while being fixed, now restored: the demotion tests asserted
only the post-conditions and never the two teardown invariants that were the whole point
of the commit (they spy on `close()` and assert it happens inside `_engine_lock` now);
the codec-canvas tests could not distinguish the derivation from a constant; and the test
added to "pin the private attribute chain so a rename fails loudly" never referenced the
function whose chain it claimed to pin — it now parses that function's source and checks
each hop against the real type.

Also: README's `/health` field list, the `LOCAL_VISION_NUM_FRAMES` description (inert on
the codec path since round 10), and the documented 413 (pydantic answers 422 first) were
all stale; two comments annotated code that a later edit had moved away from them; and
the two-clock comment in `realtime_perceive` described both timers backwards.

Mutation-verified: failure attribution, the rebuild cooldown, checkpoint resolution in
`load()`, and the attribute-chain test all fail the suite when reverted. Suites: backend
2906, sidecar 87, web 276. Lint, prettier and every CI job run locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… of inheriting the cloud path's

The parameters for this path were never researched — they were inherited from the cloud
path and then tuned by trial. Reading the model's own code and paper shows why that was
wrong in every direction.

**The parameters are one constraint chain, not four independent knobs.** From
`CodecConfig`: a *canvas* is a mosaic of 16×16 patches selected across `group_size=32`
source frames, `images_per_group=4` of them per group, each sized by `max_pixels`. So
`canvases = frames / 8`, `frames = window × fps`, and prompt tokens ≈ 223 × canvases
(measured). Feeding fewer frames than a canvas budget needs silently downgrades it.

What that exposed, in order of how much it cost us:

- **`max_frames=32` was throwing away 60% of the frames we already had.** The camera
  delivers ~78 frames per 4s window (≈20fps); we capped at 32 and got 4 canvases where
  10 were available. The cap is 256 now — "take what the window gives".
- **Pre-downscaling was pure loss.** `video_short_edge` shrank the frames *before* the
  model saw them, which destroys the detail its own patch-selection exists to find;
  `max_pixels` (150000, the reference default ≈ the ViT's native 448²) is where the
  reduction is supposed to happen. Default is now no downscale.
- **The window was inherited from the cloud path**, which uses 4s because it pays per
  frame. Nothing about that number relates to this model. It has its own now: 12s.
- **`target_canvas` was being derived from the frame count** to silence a warning. It is
  the budget knob, so it is configured: 12.

Choosing 12 needed both constraints, and I only had one of them until I measured on the
real camera: 12s×20fps = 240 frames feeds up to 30 canvases, but 28 takes 13.8s per
window (over budget), 16 takes 11.4s (95% of the window — too tight to absorb a GPU
blip), 12 takes ~8s (66%). "Fills the budget" and "finishes inside the window" are
separate constraints; a test now asserts the shipped defaults satisfy both.

**Per-path separation is now a rule, not a patch.** The last commit split three
parameters and left the window shared, which is the same mistake twice. The window
follows the *active* backend (`active_window_size_sec`) — the two are mutually exclusive,
so switching to local makes it 12s and switching back makes it 4s, with nothing for the
user to remember. The model page now shows the parameter group belonging to whichever
backend is selected, and the settings drawer's two knobs are labelled cloud-only.

Quality on the real camera went from "两个人在用电脑" to "一名戴眼镜的女性坐在电脑前,
穿着深绿色上衣" at the same window occupancy we started with.

Suites: backend 2910, sidecar 87, web 276; ruff and prettier clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he VLM

The local path had no identity at all: `identity: False` in the capability
declaration, and 0 of 10,463 windows in a day of real footage produced a
name — every person was "一名男子" / "一位女士". The cloud path answers this
inside its single omni call, so swapping the backend silently dropped the
whole capability.

The obvious port — replicate the cloud scheme against the local model — does
not work. Measured on 7 two-person scenes from real home footage, ground
truth checked by eye, with the cloud prompt shape reproduced faithfully
(member reference strips + full clip + bbox-addressed tracks + JSON out):

    per-person correct, gallery order 小亮 first      8/14
    per-person correct, gallery order 阳阳 first      0/14
    degenerate baselines                        4/14 and 10/14
    pure local ReID, same scenes and gallery       14/14

29% across both orders, below the 50% a coin flip gets on a two-way choice,
and swapping the order of two names in the roster changes 4 of 7 answers.
The model is not blind — asked only about gender and clothing it is 5/5 —
but cross-image person matching is not something a 4B video model does.
Handing it a blank grey image in place of the query crop still returns a
name, which settles it.

So identity does not go through the model at all:

    detect (det_4C.onnx) -> ReID embed -> cosine vs tier_a/*.npy -> roster

Those .npy files have been written by the registration flow all along;
library.py's own comment says they exist so that "未识别 track 跟已注册成员
快速比对" can use them later. This is that later. Nothing new is downloaded
and no model is added — human_body_reid_v2.onnx already ships and already
runs, just for tracking association rather than for naming.

The roster ("小亮[bbox=(357, 242, 467, 785)]", normalised to [0,1000]) rides
the perceive request as a new optional field and renders into the prompt in
the same shape the cloud path uses. That asks the model only to copy a given
name onto a given position, which it does reliably — 7/7 on the same scenes
that produced 8/28 when it had to recognise anyone.

Design points worth keeping:

- Identity is a bypass. Any failure yields an empty roster and the window's
  caption and rule verdicts are produced as before. The guard sits at the
  engine's call site as well as inside the resolver, because the resolver is
  injected and the invariant belongs to the engine.
- A name is never emitted twice in one window; two boxes matching the same
  member keep only the higher score. A roster claiming 小亮 is in two places
  makes the model write self-contradicting descriptions.
- Below threshold produces no entry rather than a "stranger" entry, so the
  model is never nudged into describing a person who is not there.
- 0.70 is measured, not picked: real people score 0.77–0.95 while people
  *on the television* — which the detector reports as human at 0.94
  confidence — score 0.44–0.67. That cut rejects 8/8 TV false positives with
  no false rejections. Note this is the failure mode the cloud path's VLM
  never caught: 0/8, even when told explicitly to watch for screens.
- The threshold assumes a current gallery. Against a five-week-old gallery
  (different room, different clothes) the same scenes drop to 8/19, because
  body ReID is largely clothing. The response is to re-register, not to
  lower the threshold, so "people present but nobody recognised" logs the
  best similarity seen — throttled, since it is a persistent condition.

Cost is 308ms median per window (detection on 3 sampled frames, CPU), inside
a 12s window, reported per camera in timing.

Capability declarations updated to match; they drive what the UI tells users
they lose by switching backends, and saying "no identity" when there is
identity is the same class of bug in the other direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… that code cannot fix

A clean-context review found 12 defects; measurement collapses them into four
root causes, and settles the most important question: the wrong names this
shipped to production are **not** fixable in code.

## The one that cannot be fixed here

A 36-day-old gallery does not fail by declining to name people. It names them
confidently and wrongly. On the live study camera it called the man 阳阳 at
0.85 and the woman 小亮 at 0.81 — both far above the 0.70 cut. Measured on the
same 7 two-person scenes, that gallery scores 0/14 per person, and the error is
systematic: all 14 boxes are closer to the same member.

Every in-band rescue was tried and measured:

    optimal 1-to-1 assignment   still 0/14 — the optimal assignment is wrong
    margin rule (top1 − top2)   correct 0.000–0.108 vs wrong 0.012–0.141,
                                fully overlapping and pointing the wrong way
    inter-member self-check     gap −0.002 fresh vs +0.002 stale, no signal
    same-window name collision  84% false positives at scale

So the gallery's freshness is a precondition, not a tuning parameter. This
commit makes that visible instead of pretending otherwise: the library's age is
computed and logged, a stale library warns with the measured consequence, and a
window with people but no matches reports the best similarity it saw. Age comes
from the registration images, never from the .npy — the startup embedding
backfill rewrites every .npy, which would reset the age of a stale library to
zero and destroy the only reliable signal.

## Assignment: correct, but for a different reason, and the order is load-bearing

Per-box argmax lets two people both claim one member; the loser was then deleted
outright by the "one name at most once" dedupe. Replacing it with a 1-to-1
assignment makes uniqueness structural, and it never regressed in measurement.

The order within it is not a detail. Threshold first, then assign:

    threshold → assign     96/104
    assign → threshold     40/104

Assigning first forces every member to be spent. With one real person and one
television false-positive in frame — this camera's every window — the TV box is
systematically closer to one member, so it takes that name and displaces the
real person onto the other one. The threshold then discards the TV pair and
leaves the human wearing the wrong name.

That also means assignment assumes everyone in frame is enrolled, and the
threshold is the only guard. Its margin is 0.03 (TV crops peak at 0.670). At
0.65 the same set drops to 47/104; at 0.60 all 104 TV boxes get a name.

## Cache: the fingerprint watched files this layer never reads

Change detection keyed on tier_a *image* files, while this layer reads only
meta.json and tier_a/*.npy. Two silent failures followed, both reproduced:
renaming a person never took effect for the life of the process — in a system
that had just called someone by the wrong name — and embeddings produced by
main.py's startup backfill were invisible forever, which under argmax handed
those people's boxes to *other* members.

The fingerprint now hashes the bytes it actually consumes. It deliberately does
not use (mtime, size): on this filesystem st_mtime_ns carries no sub-tick
resolution — two successive writes returned identical timestamps, even across
different files — and 小亮 → 亮亮 happens to be byte-identical in length. The
data is ~3KB per person; hashing it is both cheaper than the reload it guards
and exact, with no granularity question to get wrong later.

Two more behaviours fall out of the same fix. The empty-library short circuit no
longer doubles as a truthiness test, so a fresh install (identity is on by
default) stops rescanning and logging every window — roughly 7k lines/day/camera.
And the "never loaded" sentinel is None rather than (), because () is the
legitimate fingerprint of an empty library: colliding with it meant one transient
read error disabled reloading permanently, in a state indistinguishable from
"nobody is enrolled".

## Concurrency: one shared resolver, no discipline

resolve() runs from concurrent per-device coroutines via to_thread. Lazy init was
check-then-act: six concurrent windows built six Detectors, five discarded, 976MB
peak RSS on a box already hosting the vision sidecar. The gallery was read twice
across a possible reload, so a concurrent refresh between the match and the name
lookup raised KeyError and the fail-open swallowed the entire roster. Per-window
state (best score, miss-log throttle) lived on the instance, so cameras
overwrote each other's diagnostics and only the first of several cameras with a
stale library could ever report it.

Init is now locked, the gallery is snapshotted once per window, and per-window
state is local or keyed by device.

## Also

A member whose embeddings fail to load is now named in the log. Silently
excluding them is not a missing name — argmax gives their box to someone else.

resolve() takes source as an optional second parameter. The resolver is injected;
making it required would TypeError on any substitute implementing resolve(frames),
land in the fail-open, and turn identity off with an empty roster.

Not addressed here, and stated so it is not mistaken for solved: 0.70 was
calibrated against television false-positives, never against people. Unenrolled
humans score a median 0.818 top-1 against a healthy gallery — 33 of 33 stranger
boxes were given a member's name. This design assumes only enrolled members
appear. Fixing it needs face verification or a rejection criterion, not a
threshold tweak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Xiaomi cameras composite the date and time into the frame before encoding, so
by the time any consumer sees the stream it is pixels. The model reads it, and
reads it wrong.

Measured on 60 visually-confirmed watermarked clips, greedy decoding:

    no guard      44/60 (73.3%) of captions report a date or clock
                  year correct in only 16/44 — 2026 read as 2022, 2020, 2024
                  clocks inside the window wrong 25% — 13:50 read as 02:50
    with guard    0/60   (95% upper bound 6.0%; 0/36 on a second batch)

The damage is not an extra wrong field. A wrong clock propagates into the scene
judgement: at a true 14:04 read as 04:04 the model wrote "the whole scene takes
place on a quiet morning"; at noon read as "4:07 am" it concluded "this suggests
early morning or late at night". In one clip it attributed the camera's own
ticking overlay to the television; in another, after reading "02:26", it
invented a child playing games in the corner that no frame contains.

## The guard is now per camera, and off unless the camera says otherwise

It cannot be unconditional. The sentence suppresses clocks that genuinely exist
in the room: with a digital clock on the far wall, 30 paired clips reported it
8/30 without the guard and 1/30 with it (McNemar p=0.039). Kitchen microwaves,
bedside alarms and wall clocks are ordinary furniture. Narrowing the wording
does not rescue it — scoping the sentence to the top-left corner recovered 1 of
12.

So miloco reads `time-watermark` per camera and tells the sidecar. Unreadable
means off: missing the guard only returns to a pre-existing risk, while adding
it wrongly deletes real information from the description. Third-party cameras,
models without the property, an unbound account and a network blip all land on
the safe side. The value is cached for the process — it is a setting the user
changes every few months, not state, and a MIoT round trip per window would add
a network dependency to always-on perception for nothing.

## It also had a hole

The sentence used to live inside `DEFAULT_SCENE_ASK`, and `build_prompt` starts
with `scene_ask or DEFAULT_SCENE_ASK`. On-demand queries pass the agent's own
question as `scene_ask`, so the whole default — guard included — was replaced.
The scheduled path was protected and the on-demand path ran bare, against the
same watermarked frames. It now appends to whatever question the caller gave.

## What this is not

It is a fallback, not a fix. Of the 73.3 → 0 points, roughly 63 come from
appending *any* "ignore X, don't mention Y" clause — a length-matched sentence
about lens distortion scores 10.0% — and only ~10 points are attributable to
naming the watermark (paired McNemar p=0.031). The effect rides substantially on
perturbing the decode path rather than on instruction following, which means any
future prompt change (rules, camera notes, a roster, a new checkpoint) can push
it back up silently. 0/60 is also not zero: the upper bound is 6.0%, and 300
clips would be needed to claim 1%.

The real fix is upstream: turn the watermark off, or have the device offer OSD
as a per-stream choice so a human reviewing recordings keeps the timestamp while
the perception path does not. Filed separately.

## Rejected: masking the pixels

Tempting and measured, then dropped. Masking is not neutral — drawing a box over
empty ceiling in 12 *unwatermarked* clips changed 0/12 captions relative to
control, where re-running the same file is 6/6 identical; 4 of 12 rewrote the
subject's gender ("a young man ... holding a game controller" became "a woman
... long dark hair ... holding a mouse"). The box also cannot be placed safely:
this one camera has produced both 848x480 and 904x512 frames, and a rectangle
calibrated on the first leaves the last digit of the seconds exposed on the
second — protection that appears enabled and silently is not. And the benefit is
zero: prompt tokens and visual patch counts are identical masked and unmasked.

Automatic detection of the overlay was built and measured too: 100% recall on
120 real watermarked clips with 0 false positives on 240 clean ones, but recall
holds only for an opaque dark plate over a bright background — bottom-right
darkened overlays, inverted text, translucent plates and plates-without-backing
all scored 0/8, and the failure is silent.

## One correction to the record

The premise that the watermark steals codec patch budget is false. It occupies
1.5625% of the patch grid and receives 0.527% of the budget — 0.34x its area
share, systematically under-sampled, because the selector follows encoding cost
and a black plate with thin strokes is cheap. The positive control confirms the
measurement: random noise in the same box takes 11.17%, 7.15x its share. The
real cost is 3.5% of vision tokens on the frames/whole-frame paths and 1.06% of
bitrate — not the main codec path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g a feature this branch adds

Pre-publish pass. Nothing here changes behaviour; all of it is the branch
contradicting itself in places a reviewer reads first.

**CI's ruff job was red.** `ruff check . cli services/local-vision` — the exact
command in ci.yml — reported three errors in the identity tests added two
commits ago (one unsorted import block, two `for t in ts: t.start()` one-liners).
Every round claimed "ruff clean" while running it from a different directory,
which resolves a different file set.

**Four places still said the local path has no identity.** The ReID commit
updated the capability declaration the UI reads, so the card is correct — but
`services/local-vision/README.md`'s known-limits list, the knowledge doc's
backend comparison table, and the device-action rationale in both
`capabilities.py` and the knowledge doc all still asserted 无身份识别. That is
the same defect class this branch fixed repeatedly in the other direction: a
document stating a property the code does not have.

They now say what is actually true, including the part that is not flattering:
identity exists but does not come from the model, the 0.70 threshold was
calibrated against television false positives and never against people (33 of
33 unenrolled stranger boxes were given a member's name), and a stale gallery
fails by naming confidently and wrongly rather than by declining.

The device-action rationale needed rewording rather than deletion: "no audio
corroboration and no identity" was one of the two reasons a vision-only model
should not close a gas valve. Identity now exists, so the sentence stands on
its measured failure modes instead.

**Two settings docstrings contradicted their own defaults.** `container_fps`
defaults to 20 while its description explained why 8 was the right compromise,
and `codec_target_canvas` defaults to 12 while its description ended by
instructing the reader to write 28. Both are leftovers from the operating-point
commit, and both are the kind of thing that makes a reader distrust the numbers
around them.

Suites unchanged and green: backend 2956, sidecar 100. `ruff check` over all
three trees passes; prettier and markdownlint pass over knowledge/ and README.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

👋 感谢提交 PR @LeonJoeeee!维护者会尽快 review。

提交前请确认:

  • CI 全绿(test / lint / build)
  • 改动聚焦单一主题,便于审阅
  • 若改动了依赖(lockfile / pyproject.toml / package.json),需维护者评论 /allow-dependencies-change <当前 head SHA> 放行(之后再 push 需重新放行)

刻意宽容:manager 还没建好、感知服务还没起来都是正常状态,不该让这条查询抛错。
"""
try:
from miloco.manager import get_manager

if get_settings().perception.engine_backend != "local":
return True
from miloco.perception.local_vision.engine import LocalVisionEngine
Comment thread backend/miloco/src/miloco/perception/collect/camera_adapter.py Fixed
Comment on lines +22 to +26
from miloco.perception.rule_scope import (
camera_prompt_map,
physical_did,
rules_for_device,
)
Comment thread backend/miloco/src/miloco/perception/local_vision/engine.py Fixed
Comment on lines +55 to +59
from miloco.perception.rule_scope import (
camera_prompt_map,
physical_did,
rules_for_device,
)
读 KV(进程内缓存)失败时返回空 dict —— **fail-open**:瞬时故障只是少一段
机位指导,不该阻断感知(与语音白名单的 fail-closed 相反,因本项只增益、不涉隐私)。
"""
from miloco.manager import get_manager
from miloco.config import get_settings
from miloco.database.perception_repo import PerceptionLogRepo
from miloco.perception import omni_probe_registry
from miloco.perception.capabilities import active_window_size_sec
# 实测 +0.36s)拖进规则引擎,而上游的 rule/runner.py 一个感知顶层导入都没有
# (它唯一的感知依赖 event_text_builder 同样是函数内导入)。这条依赖箭头
# 本来就该是单向的:perception 用 rule,rule 不用 perception。
from miloco.perception.capabilities import perception_executes_device_actions
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

关于 guard 这条 CI 失败

依赖守卫拦下了本 PR,原因是新增了 services/local-vision/pyproject.toml。这是预期内的 —— 本 PR 确实引入了一个新的独立服务。把依赖面摊开,方便判断:

主仓一行依赖都没动

backend/pyproject.toml     未改动
backend/uv.lock            未改动
web/package.json           未改动
web/pnpm-lock.yaml         未改动

新增的清单只有一份,属于新目录 services/local-vision/:

dependencies = [
    "fastapi>=0.115",
    "uvicorn[standard]>=0.30",
]

两个都是本仓已在使用的库(backend 本身就跑在 FastAPI + uvicorn 上),没有引入任何新的第三方生态。

torch 刻意不在依赖里

# torch 刻意不写进 dependencies:必须由部署者按自己的 CUDA 版本安装对应 wheel
# (例:RTX 50 系 Blackwell 需要 cu128 及以上)。写死版本只会装错。

模型权重与 torch 都由部署者自行准备,仓库不下载、不锁版本。

为什么是独立服务而不是并进 backend

边车刻意放在 uv workspace 之外,这样 torch / transformers 这类重依赖永远不会进入主包的依赖树。不装本地通路的用户,uv sync 拉到的东西与现在完全一致 —— 这是把它做成 sidecar 而不是一个 backend 模块的主要原因。

CI 里新增的 local-vision-test 也是独立跑的,不影响既有任务。


需要维护者评论 /allow-dependencies-change 54b3e6f53135 才能放行。如果对边车的形态有不同想法(比如希望它以别的方式集成、或者根本不该进主仓),欢迎在 #486 里讨论 —— 那条 issue 是这个 PR 的方向问题,比代码本身更值得先定。

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

[PR #488]: feat(perception+web): 本地视觉感知通路 —— 与云端 API 并列的第二条路径

作者: LeonJoeeee
范围: feat/local-vision-engine → main

修改方案

给感知系统加第二条通路:窗口帧在本地编码成 H.264,交给独立 GPU 边车服务做推理,拿回中文场景描述 + 逐条规则判定。整条通路不需要任何模型厂商 API Key,画面不出本地,token 成本为零。默认仍是云端通路 —— 不主动切换的用户行为完全不变。

  • 要解决的问题:云端通路每帧都要付 token 钱、画面要出本地网络、需要 API Key。对于多相机 7×24 常驻感知,成本与隐私是真实痛点。

  • 整体方案:按五条正交主线展开:

    主线 1 — 引擎插拔缝

    在既有的 BasePerceptionEngine 抽象基类上加一个同接口的 LocalVisionEngine,由 PerceptionEngineProxyperception.engine_backend 配置二选一(_init_engine)。切换时走既有的 stop_to_unconfigured + tick 自愈重建路径,不动流水线骨架。两条通路的窗口长度跟着当前引擎自动切:本地 12 秒(喂饱 codec 画布)、云端 4 秒(每帧付 token 钱,窗口长了变贵),由 active_window_size_sec 统一查询,用户不用记得手动改。

    配置项 云端 本地 为什么分开
    窗口长度 4s 12s 成本结构相反
    帧率旋钮 压低(每帧计费) 与相机出帧一致(帧数免费) 成本结构相反
    分辨率 限短边 默认不缩(codec 自己挑 patch) 降维该由模型做

    主线 2 — GPU 推理放独立边车,与主包解耦

    miloco 目标硬件是 Mac mini / 树莓派(CPU-only),主包绝不能为可选功能背上 torch/CUDA。边车 services/local-vision/ 在 uv workspace 之外,可以跑在另一台机器上。miloco 不接管模型进程的生命周期(不下载权重、不拉起、不重启)——这条边界来自 无法加载本地模型 #144 的教训:1.x 由 miloco 管理本地模型容器,故障面扩散到显卡直通/驱动/容器,最终无人能支持。边车 HTTP 契约与模型无关:送视频段 + 提问 + 规则(+ 可选名册)→ 返回描述 + 逐条判定 + 门控概率,任何满足该契约的服务都能替换参考实现(app.py)。

    主线 3 — 安全边界:本地通路不执行设备动作(两道门)

    本地视觉模型没有音频佐证,身份也只来自 ReID 相似度比对(阈值从未拿人标定、身份库过期会高置信度认错),不该让它自己去关燃气阀。两道门确保这一点:

    1. 转移检查(切换时):set_perception_backendRuleRepo 找出启用中、带 STATIC 设备动作的规则;有则拒绝切换,把规则名直接报给前端(router.py _rules_with_direct_device_actions)
    2. 状态不变量(运行时):_fire() 在执行那一刻问 perception_executes_device_actions(),答 False 则拒绝执行设备动作,记为一次失败触发并打 ERROR 日志(runner.py:1293)
    切换请求 ──→ 查启用中带 STATIC 动作的规则
      │ 有 → 400 + 规则名列表
      │ 无 → 落盘配置 + 软停引擎
    感知命中 ──→ _fire() 问 capabilities
      │ 云端(True) → 照常执行设备动作
      │ 本地(False) → 拒绝 + ERROR + rule_action_refused 事件
    

    不选择"改写 STATIC→DYNAMIC"是因为改写会丢掉 cooldown_minutes / idempotent(schema 对非幂等动作的唯一限流)和台账里 source=rule 的归属。人工/agent 经 POST /api/rules/{id}/trigger 显式触发不受此限 —— 那是被授权做决定的角色主动发起。

    主线 4 — 认人:ReID 指纹比对身份库,不走大模型

    实测让本地 4B 视觉模型自己认人(参考图+视频+bbox+要 JSON),逐人正确 8/28 —— 低于二选一瞎猜;同一批场景纯 ReID 是 14/14(identity.py 模块 docstring)。所以认人由本地 ReID 做:检测 → 抽特征 → 比对身份库 → 产出「名字+位置」名册,以文本塞进提问里,模型只负责把给定的名字贴到给定的位置上(实测 7/7)。

    指派用匈牙利算法(scipy.optimize.linear_sum_assignment),先卡阈值再指派 —— 反过来会凭空制造错名字:1 真人 + 1 电视误检时,指派被迫把两个成员都发出去,电视框系统性更像某成员,真人被挤到错名字上(实测 96/104 → 40/104,_assign)。

    身份库变更检测只盯内容(blake2b 哈希 meta.json + tier_a/*.npy),不盯 mtime —— 目标平台的 st_mtime_ns 没有纳秒精度,改名「小亮」→「亮亮」字节数恰好相同,(mtime, size) 完全看不出变化(_library_fingerprint)。

    主线 5 — 前端能力卡与 i18n

    「模型」页顶部加 PerceptionBackendCard:两个互斥按钮(cloud / local),本地按钮显示边车连通性徽章。切换错误用 code 字段做 i18n key 查找(避免把后端中文硬塞进英文界面)。纯逻辑抽到 perceptionBackend.ts 便于 Node 环境测试。能力差异(无音频、认人由 ReID、不执行设备动作)在接口层声明,前端直接渲染。

  • 关键设计原则:

    1. 默认不变:不主动切换的用户,行为与今天完全一致。engine_backend 默认 "cloud"
    2. 云端切换永远不拒绝:云端是本地通路出问题时的退路,挡住就把用户关在一个不工作的后端里。缺 Key / 模型没下完时用 cloud_hint 把后果说清楚。
    3. fail-open(认人/相机须知)+ fail-closed(规则判定/设备动作):认人失败只让名册为空(描述退回泛称),规则判定解析不出就落未命中,设备动作宁可不执行也不静默改写。
    4. 凭证跟着地址走:改了 base_url 又没给新 token,存档 token 自动清掉 —— 不让已存凭证被带到新地址上。
    5. 不接管模型进程生命周期:边车独立部署,miloco 只通过 HTTP 认识它。
  • 测试覆盖:

    主线 测试文件 用例摘要
    引擎插拔 test_local_vision_bringup.py 启动失败分类、探活冷却、同步 HTTP 隔离、持续故障降级、编码失败不归因边车
    安全边界 test_device_action_gate.py 云端照执行、本地拒绝+日志+事件、手动触发不受限、能力声明与运行时一致、活跃引擎优先于配置
    认人 test_identity.py(673 行) 库加载/变更检测/混合维度/并发初始化/匈牙利算法阈值顺序/库过期告警(含历史 jpg 回归)
    提示词 test_prompts.py(452 行) 判定解析变体、fail-closed、注入防御(须知/名册/查询/场景提问)、OSD 水印
    前端 perceptionBackend.test.ts + real.test.ts + i18n.test.ts payload 构建、健康状态机、结构化错误解析、i18n key 完整性
    端点 test_perception_backend.py(331 行) GET/POST 完整 HTTP 流程、探活安全(不回显内网细节)、cloud_hint 结构化、blocking_rules 拦截
    边车 test_app.py + test_prompts.py + test_video.py + test_engine_helpers.py 鉴权/输入校验/并发槽释放/fail-closed 解析/提示词注入防御/帧采样/后端选择

历史 review 修复验证

第一轮(ci-bot,作者在 bcfd6d2 修 3 条)—— 全部验证属实:

# 问题 修复验证
🟡 blocking_static_rules.join("、") 中文顿号渲染进英文 UI ✅ 改成 t("perceptionBackend.listSeparator"),en=", " / zh="、"
🟡 cloud_hint 直出后端中文 ✅ 契约改为 {code, message, detail?},前端用 code 查表,与切换错误同一条路径
🔵 on_demand_perceive 不落 clip ✅ 补上 push_clip_bytes,与 realtime 路径对称
🔵 _init_engine 未知 backend 无显式守卫 ⏭️ 未改(作者认为 Pydantic Literal 已挡住,合理)

第二轮(ci-bot,作者在 2e751af 修全部 7 条)—— 全部验证属实:

# 问题 修复验证
🟡 blocking_rules toast 混后端中文 ✅ payload 用 detail.rules 数组 + ApiError.data 透传,前端用结构化数据拼 i18n
🟡 重启按钮没清 _local_rebuild_not_before try_reinit(include_failed=True) 同时清探活冷却和重建冷却
🟡 refresh_gallery() 构造 IdentityLibrary 物化空目录 ✅ 改成 _read_persons() 只读路径,不触发 _ensure_dirs(),加测试断言"读一次后目录仍不存在"
🔵 video.py cv2.VideoCapture 没用 try/finally ✅ 已改 try/finally
🔵 capabilities.py 最外层 except 回落到 True ✅ 改为 return False(安全方向),旧测试改写钉新方向
🔵 real.test.ts cloud_hint: "" 类型不匹配 ✅ 改为 null
🔵 en/perceptionBackend.json unitCanvas 空串 ✅ 给 "canvases"

第三轮(ci-bot,作者在 bb8e0bf 修全部)—— 全部验证属实:

# 问题 修复验证
🟡 配置变更后 _local_rebuild_not_before 未清除 _drop_stale_local_conclusions() 统一清除探活冷却和重建冷却,回归测试撤掉修复后失败
🟡 就绪日志无条件写"无身份识别" ✅ 日志跟着 identity 对象的实际状态走,带 gallery_size;双向测试(正向+反向)
🔵 死 i18n 键 inputWhy / shortEdgeHint ✅ 已从 en + zh 各删除 2 行,确认无引用
🔵 cloud_hint 渲染路径无测试 ⏭️ 误报:作者指出 cloudHintText() 已有 4 条测试(line 184-224),合理
🔵 onBlur 调参数触发完整切换 toast ⏭️ 未改:作者解释参数改动确实要走一次后端切换才生效,toast 反映真实发生的事,合理

第四轮(ci-bot,作者在 fa8a4ea + 17eae8d 处理 CodeQL 告警)—— 验证属实:

# 问题 修复验证
unused import LocalIdentityResolver ✅ 去引号(有 from __future__ import annotations,引号多余且让 CodeQL 判 unused),运行时确认模块命名空间无该名字
8 条 cyclic import ⏭️ 未改:4 条落在 main 既有文件,3 条用函数内延迟导入(标准做法),合理

第七轮(ci-bot,作者在 6fa5683 修全部)—— 全部验证属实:

# 问题 修复验证
🟡 _library_age_days 只 glob .png,历史 .jpg/.jpeg 登记图被忽略 ✅ 改成 body_* 全局匹配 + _REGISTRATION_IMAGE_SUFFIXES 白名单(.png/.jpg/.jpeg);回归测试 test_stale_library_warns_for_legacy_jpg_registrations 撤掉修复即报 assert None is not None
🔵 write_temp_video 写失败时临时文件泄漏 ✅ 加 try/except BaseException + os.unlink(path),捕 BaseException 而非 Exception 以覆盖 KeyboardInterrupt/CancelledError;回归测试撤掉修复即失败

问题

🔵 建议(可选优化)

  • backend/miloco/src/miloco/perception/local_vision/engine.py:415_has_osd_watermarkfrom miloco.manager import manager(裸单例)而非 get_manager(),与本 PR 在 router.py 修复 _soft_stop_best_effort 的口径不一致
    • 背景:_soft_stop_best_effort 原来用模块级 manager(line 41 的 manager = get_manager()),本 PR 把它改成了 get_manager() —— 理由是模块级单例在某些生命周期场景下可能 stale。同一个 PR 在 router.py 确立了"用 get_manager() 取当前实例"的模式。
    • 问题:_has_osd_watermark 在函数体内写 from miloco.manager import manager(line 415)—— 这拿到的是模块级的同一个单例对象,而不是调 get_manager() 取当前实例。这个函数在每次感知窗口、每台相机上都会被调到(经 _osd_watermark 缓存后只调一次 per did),比 _soft_stop_best_effort(只在切换时调一次)热得多。虽然 manager 对象在实际运行中极少被替换,但既然本 PR 已在 router.py 确立了 get_manager() 的口径,这里保持一致可以减少将来维护者的判断成本。
    • 改进:
        try:
            from miloco.manager import get_manager

            phys = physical_did(did)
            data = await get_manager().miot_service.get_device_status(phys, [_OSD_IID])
  • backend/miloco/src/miloco/perception/local_vision/engine.py:678on_demand_perceive 不检查退避窗口,边车宕机时主动查询白烧编码(延续历史 ci-bot 发现,作者此前选择保留)
    • 背景:realtime_perceive 在入口检查 self._backoff_until(line 457),退避窗口内直接返回 skipped,连编码都不做。on_demand_perceive 是同类的感知入口,但没有对应的退避检查。
    • 问题:当边车已触发退避时,用户或 agent 发起主动查询仍然会走完「编码 → HTTP 请求 → 超时/失败」的全流程。实际影响有限 —— 主动查询是低频操作,60 秒超时有兜底,不会加重退避;但从行为一致性看属于遗漏。
    • 改进:在 on_demand_perceive 入口加一行退避检查:
async def on_demand_perceive(
    self, batch: BatchedSnapshot, query: str
) -> OnDemandPerceptionResult | None:
    """主动查询:把问题直接当场景提问送给本地模型。"""
    if batch.empty:
        return None
    if time.monotonic() < self._backoff_until:
        return None  # 边车退避中,直接告知无结论
    snaps = [s for s in batch.snapshots if s.has_video]
    if not snaps:
        return None
  • backend/miloco/src/miloco/perception/local_vision/client.py:81-93_client() 检测到 event loop 变化时,旧 AsyncClientaclose() 即被替换(延续历史 ci-bot 发现,作者此前选择保留)
    • 背景:httpx.AsyncClient 绑定到创建时所在的 event loop。_client() 按 loop 缓存客户端,loop 变化时重建。
    • 问题:loop 变化条件命中时,代码直接造新 AsyncClient 赋给 self._async_client,旧对象 TCP keep-alive 连接未经 aclose() 显式关闭。实际影响有限(loop 换代只在 InferenceWorker 重启时发生,极低频),但异常场景下会有短暂连接泄漏。
    • 改进:在替换前显式关闭旧客户端:
def _client(self) -> httpx.AsyncClient:
    loop = asyncio.get_running_loop()
    if (
        self._async_client is None
        or self._async_client_loop is not loop
        or self._async_client.is_closed
    ):
        old = self._async_client
        if old is not None and not old.is_closed:
            try:
                loop.create_task(old.aclose())
            except RuntimeError:
                pass
        self._async_client = httpx.AsyncClient(
            timeout=self.timeout,
            limits=httpx.Limits(max_keepalive_connections=4, max_connections=8),
        )
        self._async_client_loop = loop
    return self._async_client
  • web/src/components/PerceptionBackendCard.tsx:390-396video_short_edge 输入框无法通过 UI 重置为 None(不缩放)(延续历史 ci-bot 发现,作者已说明不阻塞合并)
    • 背景:video_short_edge 默认 None,含义是「不缩放」。用户一旦填了具体数值,清空输入框后 onBlurif (raw === "") return 直接跳过,不会向后端提交 null
    • 问题:用户无法通过 UI 把值改回 None
    • 改进:给 video_short_edge 加特殊处理:清空时提交 null,或在 placeholder 旁加「重置」按钮。纯高级用户边缘场景,不阻塞合并。

结论

LGTM — 七轮 review 之后工程质量极高:59 文件 10k+ 行(49% 是测试),所有轮次的 🟡 问题均已修复并验证通过(包括本轮验证的第七轮 _library_age_days 认全历史 jpg + write_temp_video 临时文件清理)。本轮独立深审全部核心文件(engine.py 734 行、identity.py 607 行、capabilities.py、client.py、router.py、rule/runner.py、app.py、prompts.py、video.py、perceptionBackend.ts、PerceptionBackendCard.tsx、processor.py、runner.py、camera_adapter.py)并逐条核对跨层一致性(前端类型 ↔ 后端 payload、sidecar 契约 ↔ 引擎调用、schema 字段 ↔ 文档引用)后,未发现新的 🟡 或 🔴 问题。4 条 🔵 均为延续历史发现或低优先级一致性建议,不阻塞合并。


由 review-pr skill v0.12.0 生成

…ese strings, attach the on-demand clip

Three of the four findings from the automated review are real. Fixed here while
`guard` is still unapproved, so nothing has to be re-approved afterwards.

**Two hardcoded Chinese strings reached the UI, in a component whose own comment
forbids exactly that.** `PB_CODE_KEY`'s docstring says "backend message is
hardcoded Chinese; injecting it directly pollutes the English UI", and the
switch-failure path was converted to code lookup for that reason. Two siblings
were missed:

- `blocking_static_rules.join("、")` — an ideographic comma rendered into English
  UI. Now `t("perceptionBackend.listSeparator")`.
- `cloud_hint` was a Chinese sentence rendered verbatim by `{state.cloud_hint}`.
  It now carries `{code, message, detail?}` and the frontend looks the code up,
  matching the pattern already established beside it. `message` stays for logs
  and for clients that don't know the code; `detail` carries the validator's
  specific missing-file text, which only the backend can produce. An unknown
  code falls back to `message` rather than rendering nothing — the contract is
  open to newer backends. Ready now yields `null`, not `""`: null and empty
  object render differently.

**On-demand queries did not attach the clip they looked at.** The cloud path
does attach it (omni pushes from prompt_builder), so this was a silent
behavioural difference, not a style question — and on-demand is the case that
most needs review material ("was someone at the door just now?"). When the
answer is in doubt there was nothing to check it against, with no error and no
mention in the capability declaration.

The fourth finding — no explicit guard for an unknown backend in `_init_engine`
— is already covered by the pydantic `Literal` at the config layer. Left alone.

Also: ruff was actually failing. Previous commits claimed clean, but were run
from a directory that resolved a different file set than CI's
`ruff check . cli services/local-vision`. Fixed, and the same three files I
touched here were prettier-clean before my edit and are again now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

自动评审的四条我逐条核过代码,三条属实,已修(bcfd6d2)。趁 guard 还没放行的时候改,免得之后再作废一次放行。

🟡 blocking_static_rules.join("、") — 属实,已修

硬编码的顿号会渲染进英文界面。改成 t("perceptionBackend.listSeparator"),中英各给一个值。

🟡 cloud_hint 直出后端中文 — 属实,已修

这条比上一条更该修,因为同一个文件第 24 行的注释就在说这件事:

PB_CODE_KEY:backend message 是硬编码中文,直接注入会污染英文界面。

切换失败那条路径当时按这个改成了 code + 前端查表,cloud_hint 漏掉了。现在契约改成:

{"code": "cloud_no_api_key", "message": "...", "detail": "..."}
  • message 保留:给日志、以及不认识该 code 的客户端兜底
  • detail 是验证器给出的具体缺失项,只有后端知道,前端拼在本地化文案之后
  • 不认识的 code 回落到 message —— 契约对更新的后端开放,前端不认识的新 code 不该让提示整个消失
  • 就绪时给 null 而不是 "":前端对 null 与空串是两种渲染

🔵 on_demand_perceive 不落 clip — 属实,已修

这条我认为不只是风格问题,是本地相对云端的一处真实行为差异:云端通路的主动查询落 clip 的(omni 在 prompt_builder 里 push)。

而主动查询恰恰是最需要回看素材的那一类 —— "刚才门口是不是有人"。答案存疑时,日志页上只有一句文字、没有可核对的画面。这个差异既不在能力声明里,也不会报错。

🔵 _init_engine 未知 backend 无显式守卫 — 未改

配置层的 pydantic Literal["cloud", "local"] 已经挡住了,再加一道是防御性冗余。如果维护者认为仍该加,我可以补。


顺带修了一处我自己之前谎报的:ruff 其实是红的。之前几个提交声称 lint 通过,是在别的目录下跑的 —— 解析到的文件集与 CI 的 ruff check . cli services/local-vision 不同。现在按 CI 原命令跑,通过。

测试:backend 2959 / 边车 100 / web 279 全绿;ruff、tsc、prettier(我改到的文件)均通过。

…wrong way, plus six smaller ones

The automated review returned LGTM with seven non-blocking findings. All seven
are real; all seven are fixed. One of them was not cosmetic.

## The safety fallback pointed at "allow"

`perception_executes_device_actions()` guards whether the perception layer may
drive devices directly. Its outermost `except` returned `True` — execute — on
the reasoning that an unreadable config should fall back to existing cloud
behaviour. That reasoning is real but the direction is still wrong:

- The costs are asymmetric. Refusing wrongly means one rule does not fire, and
  it is recorded as a RULE_TRIGGER_FAILURE — visible, traceable. Allowing
  wrongly means a vision-only model closes a gas valve or unlocks a door. The
  fallback of a safety invariant can only lean toward refusal.
- The premise mostly does not hold. `get_settings()` is a cached global; if it
  raises, the process is broken deeply enough that perception is not running
  either. "Config unreadable but automations working normally" is not a state
  that exists.

The window is narrow — it needs the engine attribute chain *and* settings to
fail together. But narrow is not a reason to point it the wrong way, and the
attribute chain is five private attributes deep, so any refactor that renames
one link silently disables the first branch. The test that pinned the old
direction is rewritten to pin the new one, with the original concern recorded
rather than deleted.

## A file descriptor leak on a resident path

`sample_frames` released the VideoCapture before each of its two known raises,
but `cv2.cvtColor` and `Image.fromarray` also raise (wrong channel count, odd
dimensions) and those paths leaked. This runs once per window forever; the
symptom would be "after a few hours nothing can be opened", with no visible
connection to this function. Now try/finally.

## Reading the identity library created directories

`refresh_gallery()` built an `IdentityLibrary`, whose `__init__` calls
`_ensure_dirs()`. A mistyped library path therefore got an empty skeleton
quietly created at the wrong place, destroying the most direct diagnostic —
"the directory does not exist" — and leaving the user believing the path was
right. A read must not have side effects; it now walks the directory itself.

## And four smaller ones

- The switch-failure toast appended the backend's Chinese sentence for
  `blocking_rules`, in the same component whose comment forbids exactly that.
  The payload already carries a `rules` array — pure user data, no prose — so
  the frontend now joins that after the localised sentence. `ApiError` grew a
  `data` field to carry structured detail beyond `code`/`message`; some errors
  need data to be explained, and reaching for `message` is what leaks Chinese.
- "Restart perception" did not clear `_local_rebuild_not_before`, so pressing it
  within 60s of a demotion was a silent no-op — the throttle exists for
  automatic retry, and should not restrain the user's hand. Same reasoning as
  the probe-cache invalidation right above it.
- `real.test.ts` still passed `cloud_hint: ""` where the contract is
  `{code,…} | null`. It passed because `""` is falsy, which made the type
  contract test worthless.
- `unitCanvas` was empty in English, rendering a bare `()` in the UI.

Left alone: no explicit guard for an unknown backend in `_init_engine` — the
pydantic `Literal` already blocks it at the config layer.

backend 2961 / sidecar 100 / web 279 green; ruff via CI's own invocation, tsc,
and prettier on every touched file all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

第二轮 review 的七条全部属实,已全部修(2e751af)。其中一条不是外观问题:

capabilities.py:77 安全兜底方向反了 —— 这条最要紧

原来最外层 except 回落到 True(执行),理由是「读不到配置时按既有行为(云端)处理」。理由是真的,但方向仍然该翻:

  1. 代价不对称。 误拒只是一次规则没响,而且会记成一次 RULE_TRIGGER_FAILURE —— 看得见、查得到。误放是让一个纯视觉模型去关燃气阀、开门锁。安全不变量的兜底只能朝拒绝的方向倒。
  2. 那个顾虑的前提基本不成立。 get_settings() 是带缓存的全局,它抛异常意味着进程已经坏得很深,此时感知本身也跑不起来 —— 不存在"配置读不到但自动化在正常工作"这个状态。

窗口确实极窄(要属性链和 settings 同时失败),但**"窄"不是把方向定反的理由**,而且那条属性链是五层私有属性,任何一次重构改了中间名字都会让第一层静默失效。

原来钉住旧方向的那条测试已改写成钉新方向,并把原来的顾虑写进 docstring 保留,而不是删掉。

video.py:98 文件描述符泄漏 —— 常驻路径上

原来只在已知的两处 raise 之前手动 release,而 cv2.cvtColor / Image.fromarray 同样会抛(通道数不对、尺寸异常)。这是每窗一次的常驻通路,泄漏会累积到耗尽描述符,而症状是"跑了几小时之后突然什么都打不开",与这个函数毫无表面关联。已改 try/finally。

identity.py:173 读库有副作用

构造 IdentityLibrary 会触发 _ensure_dirs()。库路径写错时会在错误位置默默建出一副空目录骨架,把「目录不存在」这个最直接的排障信号抹掉 —— 用户以为路径配对了,实际是我们刚给他造了个空的。改成自己走目录,只读。加了一条测试断言"读一次库之后那个目录仍然不存在"。

其余四条

  • PerceptionBackendCard.tsx:120 toast 混后端中文 —— payload 里本来就带 rules 数组(纯用户数据、无文案),改用它。顺带给 ApiError 加了 data 字段承载结构化 detail 的其余字段:有些错误要附带数据才说得清楚,而伸手去拿 message 正是中文泄漏的来源。
  • client.py:586 重启按钮没清 _local_rebuild_not_before —— 降级后 60 秒内按下是静默空操作,用户只会以为功能坏了。节流阀是给自动重试设的,不该管住用户的手(与它上面那句作废探活结论同一条理由)。
  • real.test.ts:514 cloud_hint: "" —— 因为 "" 是 falsy 所以测试通过,类型契约测试等于白做。已改 null
  • en/perceptionBackend.json unitCanvas 空串 → 界面上一对空括号。已给 "canvases"

未改:_init_engine 未知 backend 无显式守卫

配置层的 pydantic Literal["cloud", "local"] 已挡住,再加一道是防御性冗余。维护者若认为仍该加,我补。


backend 2961 / 边车 100 / web 279 全绿;ruff 按 CI 原命令、tsc、以及我改到的每个文件的 prettier 均通过。

guard 仍需放行,SHA 变为 2e751af

第三轮 review 的两条 🟡。

1. 重建冷却在配置变更后没被清掉

   重建冷却是**上一份配置**挣来的证据(那份配置的引擎推理一直失败),用户把
   地址/凭证改对之后它对新配置没有任何依据,却还压着自动重建最多 60 秒。表现
   是"我明明改对了,界面还写着持续不可用",而且没有任何提示说明还要再等。

   注意它只能从探活这条路解开:_init_local_engine 里那份一模一样的配置检查位于
   冷却闸门的**下游**,冷却期内那个分支直接 return,压根走不到。两处重复的配置
   检查一并收进 _drop_stale_local_conclusions(),免得它们将来各自漂移。

2. 就绪日志无条件写"无身份识别"

   认人是可选项,四条路都会退回 None(配置关着/库是空的/模型缺失/构造抛异常),
   写死这半句是双向撒谎:认人在跑时谎报缺失,让人去查一个不存在的故障;真的没
   建起来时这行字又和平时一模一样,真正的缺失反而看不出来。改成跟着实际建出来
   的对象走,并带上库里认得几个人。

另清理两个死 i18n 键(inputWhy / shortEdgeHint,已被 ...Local/...Cloud 取代)。

验证:两条新回归测试在撤掉对应修复后均失败、恢复后通过;bringup 28 条全绿;
sidecar 100;web 279 + tsc;ruff 按 CI 原样调用(cd backend)全过;
prettier 对改动文件干净。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

第三轮 review 的 2 条 🟡 已修(bb8e0bf)。3 条 🔵 处理了 1 条,另 2 条说明如下。

🟡 1 — 配置变更后重建冷却未清

已修。补充一点复现时容易被绕过的机制:这个冷却只能从探活那条路解开

_init_local_engine 里那份一模一样的配置检查(client.py:378)位于冷却闸门(_init_engine 开头,client.py:306)的下游 —— 冷却期内那个分支直接 return,压根走不到检测配置那一行。所以拿它来验证会永远是绿的。真正够得着的是探活刷新路径,它不在闸门后面。

顺手把两处重复的配置检查收进了 _drop_stale_local_conclusions(),免得它们将来各自漂移(这次的 bug 正是这一类)。

回归测试 test_fixing_the_address_clears_the_rebuild_cooldown:撤掉修复后失败(assert 1862149.05 == 0.0),恢复后通过。

🟡 2 — 就绪日志无条件写"无身份识别"

已修,改成跟着实际建出来的对象走,并带上库里认得几个人。

认人有四条路会退回 None(配置关着 / 库是空的 / ONNX 模型缺失 / 构造抛异常),写死这半句的代价是双向的:认人在跑时它谎报缺失,让人去查一个不存在的故障;认人真的没建起来时,这行字又和平时一模一样,真正的缺失反而看不出来。

两条测试:正向(不 claim missing when on,撤掉修复后失败)+ 反向(still says no identity when really off,防止正向那条被一句恒真断言糊弄过去)。

🔵 3 — 死 i18n 键

已删 inputWhy / shortEdgeHint(zh + en 各 2 行)。确认已被 inputWhyLocal/inputWhyCloudshortEdgeHintLocal 取代,无引用。

🔵 4 — cloud_hint 渲染路径无测试

这条恐怕是误报:cloudHintText() 已有 4 条测试,在 web/tests/perceptionBackend.test.ts:184-224(按 code 查表 / 拼 detail / 未知 code 回落 message / 空值渲染空串)。

真正没覆盖的是 JSX 里那一次调用(PerceptionBackendCard.tsx:202)。但这个仓库前端测试整体跑在 environment: "node",没有 jsdom / testing-library —— 为一条 🔵 引入 DOM 测试栈,收益和代价不匹配。如果维护者希望补组件级渲染测试,建议单开一个 PR 统一给这一层加,而不是只为这一处。

🔵 5 — onBlur 调参数触发完整切换 toast

未改,理由是它并非纯 UX 瑕疵:参数改动确实要走一次后端切换才生效,toast 反映的是真实发生的事。改成"静默保存"会让用户以为参数已生效而实际没有。若维护者认为该区分文案,我照办 —— 但那属于行为变更,想单独确认一次再动。

验证

  • 新增 2 条回归测试均已证伪:撤掉对应修复后失败,恢复后通过
  • test_local_vision_bringup.py 28 条全绿;sidecar 100;web 279 + tsc --noEmit
  • ruff 按 CI 原样调用(cd backend. = backend/)全过;prettier 对改动文件干净

…used import)

CodeQL 在 local_vision/engine.py:54 报 unused import。属实:本文件有
from __future__ import annotations,注解全是惰性字符串,而 LocalIdentityResolver
只出现在 identity 参数的注解里,运行时一次都用不到。ruff 不报是因为它把字符串
注解里的引用算作使用——两边看的层面不同,各自都没错。

搬进 if TYPE_CHECKING 两边都满足:运行时不再导入这个名字,类型检查仍解析得到。
render_roster 保持原样,它在 347 行是真的运行时调用。

顺带澄清:identity.py 不反向导入 engine,所以这里不存在真正的循环依赖,只是
一个用不到的名字。

验证:运行时确认模块上已无 LocalIdentityResolver、render_roster 仍在、identity
参数注解仍可读;ruff 按 CI 原样调用全过;local_vision 相关 136 条全绿;
后端全量 2847 passed / 117 failed,与改动前基线逐条一致(117 为本机 401 产物)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

补一条 CodeQL 的处理说明(fa8a4ea)。

CodeQL 在本 PR 上挂了 9 条,两个 Analyze 检查本身是绿的(都是提示级别),但为免评审时逐条去看,先说明清楚。

1 条 unused import —— 已修

local_vision/engine.py:54LocalIdentityResolver。属实:该文件有 from __future__ import annotations,注解全是惰性字符串,而这个名字只出现在 identity 参数的注解里,运行时一次都用不到。ruff 不报是因为它把字符串注解里的引用算作使用 —— 两边看的层面不同,各自都没错。

已搬进 if TYPE_CHECKING:(仓库既有写法,见 miot/ws.pydatabase/perception_repo.py 等),运行时不再导入,类型检查仍解析得到。render_roster 保持普通导入,它在 engine.py:347 是真的运行时调用。

顺带澄清一点:identity.py 不反向导入 engine,所以这一条不涉及任何真实的循环依赖,只是一个用不到的名字。

8 条 cyclic import —— 未改,说明理由

其中 4 条落在 main 上早已存在的文件(collect/camera_adapter.pyengine/api.pyperception/runner.pyrule/runner.py),只是本 PR 的 diff 碰到了这些文件才被一并翻出来;环本身不是本 PR 引入的。

另 3 条落在本 PR 新增的 capabilities.py / rule_scope.py / local_vision/engine.py,涉及的是 perception ↔ manager 这一层既有的相互依赖。这些位置用的都是函数内延迟导入,而延迟导入正是打破模块级环的标准做法 —— CodeQL 会照样把它标出来,但改成模块级导入反而会真的形成启动期的环。

要彻底消掉需要动 perceptionmanager 的分层,那属于架构调整,不适合夹在这个 PR 里。若维护者认为该做,建议单开 issue,我可以跟进。

验证

  • 运行时确认:模块上已无 LocalIdentityResolver,render_roster 仍在,identity 参数注解仍可读
  • ruff 按 CI 原样调用(cd backend)全过
  • local_vision 相关 136 条全绿;后端全量 2847 passed,与改动前基线逐条一致

同步 main(落后 53 个提交)。此前 PR 处于 CONFLICTING 状态,导致 pull_request
类型的工作流(CI / CodeQL / Docs / OpenGrep)整批不触发——它们跑的是 GitHub 预合成
的合并结果,合不出来就直接跳过,表现为「检查数量变少」而不是「检查变红」。

冲突 5 处,均为双方在同一位置各自追加,一律两边保留:
- web/src/api/real.ts:我的 realGet/SetPerceptionBackend 与 main 的
  realUpdateRuleQuery 都追加在文件末尾,共用结尾的 }
- web/tests/real.test.ts:import 列表合并;两个 describe 块拼接
- web/src/i18n/locales/{zh,en}/settings.json:cloudOnlyHint 与 minUrgency* 同层追加

另有 6 个文件双方都改过但自动合并成功(client.py / settings.py / admin/router.py /
types.ts / SettingsDrawer.tsx / api/index.ts),已逐项验证语义未被破坏:
- client.py 里我的 _build_local_identity / _drop_stale_local_conclusions 与
  main 的 _filter_suggestions_by_min_urgency 共存
- 本地通路不产建议,main 的紧急度过滤器对空列表正常返回
- 安全不变量仍成立:local 后端下 perception_executes_device_actions() 为 False

格式化说明:real.ts / real.test.ts 的 prettier 告警全部来自 main 既有代码
(逐行核对,30 处与 1 处均不在冲突解决区域内),且 CI 无格式检查步骤,故不做重排,
以免制造与本 PR 无关的大面积 diff。

验证:后端 3029 passed(main 新增 ~182 条全绿),117 failed 与合并前逐文件一致
(本机 401 产物);前端 296 passed + tsc;边车 100;ruff 按 CI 原样调用全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread backend/miloco/src/miloco/perception/local_vision/engine.py Fixed
上一次(fa8a4ea)把 LocalIdentityResolver 搬进 TYPE_CHECKING,没有解决问题——
CodeQL 在合并结果上仍然报同一条 unused import,只是行号从 54 变成 60,而 60 正是
那个 TYPE_CHECKING 块里面。根因先前定位错了。

真正的原因是**注解带着引号**:写成 "LocalIdentityResolver | None" 时,那个名字只
存在于一个字符串字面量里,CodeQL 不解析字符串,于是无论导入放在模块顶层还是
TYPE_CHECKING 块里,它看到的引用数都是零。换句话说,搬导入这个动作从一开始就
救不了它。

本文件有 from __future__ import annotations,那对引号本来就是多余的。去掉之后
名字出现在注解的语法树里,静态分析看得见;而 __future__ 保证运行时依然不会去
解析它——两边仍然各取所需。

风险已核:全仓无 get_type_hints 调用,LocalVisionEngine 也不是 pydantic 模型,
没有任何路径会在运行时解析这个注解。

验证:运行时确认注解仍是未求值的字符串、模块上没有该名字、引擎可正常构造;
ruff 按 CI 原样调用全过;local_vision 相关 136 条全绿;后端全量 3029 passed,
117 failed 与合并前逐条一致(本机 401 产物)。CodeQL 本机无 CLI,这一条要等
下一轮 CodeQL 跑完才能确认。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

更正上一条评论的一处错误,外加同步 main。

更正:CodeQL 的 unused import,上次没修成

上一条我写「已搬进 if TYPE_CHECKING:」,并称该条已解决。这个说法是错的。 CodeQL 在随后的运行里仍然报同一条,只是行号从 engine.py:54 变成 :60 —— 而 60 行正是那个 TYPE_CHECKING里面。搬导入这个动作从一开始就救不了它,我当时的根因判断是错的。

真正的原因是注解带着引号。写成 identity: "LocalIdentityResolver | None" 时,那个名字只存在于一个字符串字面量里;CodeQL 不解析字符串,于是无论导入放在模块顶层还是 TYPE_CHECKING 块内,它数到的引用都是零。

已在 17eae8d 去掉引号。本文件有 from __future__ import annotations,那对引号本就多余:PEP 563 只推迟注解的求值,不影响它被解析进语法树,所以去引号后名字对静态分析可见,而运行时依旧不会去解析它。

风险已核:全仓无 get_type_hints 调用,LocalVisionEngine 不是 pydantic 模型,没有任何路径会在运行时解析这个注解。运行时实测:注解仍是未求值的字符串,模块命名空间里没有该名字,引擎可正常构造。

说明确认边界:我读不到本仓库的 code-scanning alerts(非管理员,API 返回 403),所以无法直接核对该告警是否已关闭。可观察到的是 —— 17eae8d 上 CodeQL 运行成功且 advanced-security 未提交新评审,而此前两个含引号版本的 commit 上它都提交了。这是一致的迹象,但不等于确认。若该条仍在,请告知,我继续处理。

同步 main(b959f5f)

此前本 PR 处于 CONFLICTING 状态,导致 pull_request 类型的工作流(CI / CodeQL / Docs / OpenGrep)整批不触发 —— 它们跑的是预合成的合并结果,合不出来即跳过,表现为「检查数量从 23 降到 6」而非「检查变红」。已合入 main(落后 53 个提交),mergeable 恢复为 MERGEABLE,8 个工作流全部正常触发。

冲突 5 处,均为双方在同一位置各自追加(real.ts 的 API 函数、real.test.ts 的 import 与 describe、settings.json 的同层键),一律两边保留。

另有 6 个文件双方都改过但自动合并成功,已逐项验证语义:client.py 中本 PR 的 _build_local_identity / _drop_stale_local_conclusions 与 main 的 _filter_suggestions_by_min_urgency 共存;本地通路不产 suggestion,新的紧急度过滤器对空列表正常返回;安全不变量仍成立(local 后端下 perception_executes_device_actions()False)。

real.ts / real.test.ts 的 prettier 告警全部来自 main 既有代码(逐行核对,30 处与 1 处均不在冲突解决区域),CI 亦无格式检查步骤,故未做重排,以免引入与本 PR 无关的大面积 diff。

验证

  • 后端 3029 passed(main 新增约 182 条全绿),117 failed 与合并前逐文件一致(本机 401 环境产物,CI 上不复现)
  • 前端 296 passed + tsc --noEmit;边车 100 passed
  • ruff 按 CI 原样调用(cd backend,. 解析为 backend/)全过
  • CI / CodeQL / CodeQL Quality / Docs / OpenGrep / Labeler / PR Review 全绿,guard 待维护者放行

LeonJoeeee and others added 2 commits August 6, 2026 09:11
再次同步 main(又落后 85 个提交)。PR 已第二次进入 CONFLICTING —— 该状态会让
pull_request 类型的工作流(CI / CodeQL / Docs / OpenGrep)整批静默不触发,表现为
「检查数量变少」而非「检查变红」,极易被当成还没跑完。

冲突 2 处,均为双方在同一位置各自追加,两边保留:
- web/src/lib/types.ts:本 PR 的 PerceptionBackendState 与 main 的 UpgradeCheck /
  UpgradeStatus 相邻,共用结尾的 }
- web/tests/real.test.ts:import 列表合并(realGet/SetPerceptionBackend 与
  realEventRefUrl / realEventCropMeta)

本轮 main 动了感知核心(自适应裁剪 XiaoMi#469、说话人检测、追踪服务、相机适配器 XiaoMi#434、
线程削减 XiaoMi#475、web 升级 XiaoMi#424),与本 PR 同区域,故逐项验证语义:
- 安全不变量仍成立:local 后端下 perception_executes_device_actions() 为 False
- 前两轮 review 的两处 🟡 修复实体仍在(_drop_stale_local_conclusions 里清重建
  冷却那行、就绪日志跟随实际认人状态),对应 3 条回归测试全绿
- 本地通路不产 suggestion,main 的紧急度过滤器对空列表正常返回
- camera_adapter 等 main 改动模块可正常导入

验证:后端 3307 passed / 122 failed。122 = 既有 117 + main 新增 5 条
(node_monitor TestProcSeriesEndpoint 那组);逐条核对失败原因,16 条全部是
AuthenticationException: Invalid or missing service token,即本机 401 环境产物,
无一条逻辑失败。前端 345 passed + tsc;边车 100;ruff 按 CI 原样调用全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第七轮 review 的 1 条 🟡 + 1 条新 🔵。

## 🟡 _library_age_days 只 glob .png,历史 jpg 库过期告警静默失效

失效方向恰好是反的。仓库明确保留了对历史 .jpg/.jpeg 登记图的读取(见
engine/identity/library.py 的目录图注「新写入为 .png 无损;历史库 .jpg/.jpeg 仍可读」、
_backfill 里的 body_*.jpg 扫描,以及 person/router.py 的文件名白名单),而这里只认
.png —— jpg 老库一张图都匹配不上,函数返回 None,调用处那句 `is not None` 落空,
过期告警**永不触发**;日志只写一句「登记距今 未知」,没有任何异常样子。

而被压住的正是这套方案唯一被证实的失效模式:旧库高置信度**认错人**(实测 36 天的
库逐人正确率 0/14,代码侧无解)。越老的库越可能是 jpg 时代留下的,也就越需要这条
告警,偏偏就是它们拿不到。

改成 glob body_* 后按后缀白名单过滤。用白名单而不是"排除已知几种",是为了保住本
函数开头那条约束:.npy 会被 backfill 重写,按它计龄会让 36 天的库显示成"全新"。

## 🔵 write_temp_video 写失败时泄漏临时文件

mkstemp 一返回文件就已经在盘上了,写入失败时路径还没交给调用方,调用方 finally
里的清理拿不到它,只能等 sweep 一小时后兜底。最现实的触发是磁盘满 —— 而磁盘满会
让每一个窗口都走这条路,一小时攒几百个残留,还都堆在那块已经满了的盘上。
捕 BaseException:KeyboardInterrupt / CancelledError 同样会留下空壳。

验证:两条新测试均已证伪 —— 撤掉对应修复后分别报
「jpg 老库被当成「库龄未知」」与「写失败后把空壳留在磁盘上了」,恢复后通过。
identity 40 条全绿;边车 101 passed;后端全量 3308 passed / 122 failed(122 与本轮
合并后基线逐文件一致,全部是本机 401 环境产物);前端 345 passed + tsc;
ruff 按 CI 原样调用全过。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@LeonJoeeee

Copy link
Copy Markdown
Contributor Author

第七轮的 1 条 🟡 + 1 条新 🔵 已修(6fa5683)。

🟡 _library_age_days 漏掉历史 .jpg/.jpeg —— 属实,而且失效方向是反的

这条很准。补充一下确认过程与后果链。

仓库明确保留了对历史 jpg 登记图的读取:engine/identity/library.py 的目录图注写着「新写入为 .png 无损;历史库 .jpg/.jpeg 仍可读」,_backfill 里有 body_*.jpg 的扫描,person/router.py 的文件名白名单也列了三种扩展名。所以这不是一个假想的输入形状。

而这里只 glob .png,后果链是:jpg 老库一张图都匹配不上 → 函数返回 None → 调用处 if self.library_age_days is not None and ... >= _STALE_LIBRARY_DAYS 整条落空 → 过期告警永不触发。日志里只留下一句「登记距今 未知」,看不出任何异常。

被压住的恰恰是这套方案唯一被证实的失效模式:旧库不是认不出人,而是高置信度认错人(实测 36 天的库逐人正确率 0/14,margin 规则的正确/错误分布完全重叠,代码侧无解)。越老的库越可能是 jpg 时代留下的,也就越需要这条告警 —— 结果偏偏是它们拿不到。

改成 glob body_* 再按后缀白名单过滤。用白名单而非「排除已知的几种」,是为了保住这个函数原有的那条约束:.npy 会被启动时的 backfill 重写,按它计龄会让一个 36 天的库显示成「全新」。

回归测试 test_stale_library_warns_for_legacy_jpg_registrations:撤掉修复后报 assert None is not None「jpg 老库被当成「库龄未知」」,恢复后通过。

🔵 write_temp_video 写失败泄漏临时文件 —— 一并修了

同意,而且比表面严重一点。mkstemp 一返回文件就已经在盘上,写入失败时那条路径还没交给调用方,调用方 finally 里的清理够不着它,只能等 sweep_stale_segments 一小时后兜底。

最现实的触发是磁盘满 —— 而磁盘满会让每一个窗口都走这条路:一小时能攒下几百个残留,还都堆在那块已经满了的盘上。捕 BaseException 而非 Exception:KeyboardInterrupt / CancelledError 同样会留下空壳。

回归测试撤掉修复后报「写失败后把空壳留在磁盘上了」。

另:本 PR 第二次进入 CONFLICTING,已再次同步 main(37b58f1)

分支落后 85 个提交。冲突 2 处(types.ts 的类型定义相邻、real.test.ts 的 import),均为双方各自追加,两边保留。

本轮 main 动的是感知核心(自适应裁剪 #469、说话人检测、追踪服务、相机适配器 #434、线程削减 #475、web 升级 #424),与本 PR 同区域,故逐项验了语义:安全不变量仍成立(local 后端下 perception_executes_device_actions()False);前两轮 🟡 修复的实体仍在,对应 3 条回归测试全绿;本地通路不产 suggestion,紧急度过滤器对空列表正常返回。

顺带说明一个容易误判的现象:PR 处于 CONFLICTING 时,pull_request 类型的工作流(CI / CodeQL / Docs / OpenGrep)会整批不触发 —— 它们跑的是预合成的合并结果,合不出来即跳过。表现是检查数从 23 掉到 6,而不是任何一项变红,很容易被当成「还没跑完」。

验证

  • 两条新回归测试均已证伪(撤掉对应修复即失败,恢复即通过)
  • identity 40 passed;边车 101 passed;前端 345 passed + tsc --noEmit
  • 后端 3308 passed / 122 failed,122 与合并后基线逐文件一致 —— 逐条核对失败原因,全部是 AuthenticationException: Invalid or missing service token 的本机环境产物,CI 上不复现
  • ruff 按 CI 原样调用(cd backend)全过

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants