/why + /why_query 可解释性命令(含记忆敏感闸) #91

Merged
Yushu merged 2 commits from feature/83-why-commands into main 2026-07-28 09:17:07 +00:00
Member

Closes #83

PRD #77 的最后一片,也是 design.md「上线路线」六个阶段里最后一块没落地的。

四个 grill 决策

  • /why 只看本 chat:不提供跨 chat 参数,也就不存在「在自己的群里查别人私聊」这条路径。
  • /why_query 的情感坐标默认取当下真实值、可选覆盖:最常见的用法是「现在这条查询为什么召不回那条记忆」,不该逼人先拍两个数字 —— 拍错会得到误导性结果,而这个命令存在的意义就是给出可信诊断。
  • 打分明细走重构:抽出 score_breakdownscore_events 改成建在它之上。同一个公式只剩一份实现 —— 两份会在改权重逻辑时只改一边,调参工具就会讲一个与真实排序脱节的故事。既有 17 条召回测试零改动通过。
  • 条目「已不存在」与「不可见」分开计数:合成一个数会让排查的人分不清「我权限不够」和「记忆被清理了」。

关于敏感闸,说实话

AC 要求两个命令都按「命令在哪个 chat 发起」重过 is_event_visible。实现了,但我第一版给出的理由是错的,规格轴核实后已改直:

  • 我写的是「条目可能事后被改成敏感或被删」。实际上 EventModel 只有 insert/select、Delta 压缩只写不删;而 /why 只读本 chat 快照、召回当时已用同一个纯函数滤过 —— 所以 /why 那条路上的两个计数在当前代码里可证不可达。这是我在 #79 犯过的同一个错(把不可达分支包装成 AC 覆盖)。
  • 闸该留,真实理由是另外三条:① get_events 自己不过滤(取数与「给谁看」刻意分开,这是契约的一半);② /why_query 那条真的可达 —— 它直接搜全库,且持久化版走 Qdrant 侧另一份过滤实现;③ 前向兼容(ADR-0010 的「忘记我」会让第一条立刻变可达)。

变异验证抓到的两处

  • /why_query 的敏感闸测试原本是空转的SentLog.search_events 在存储层已经过滤,命令里那道冗余闸从未执行 —— 删掉它测试全绿。补了会泄漏的存储替身(同 issue #69 _LeakySearchStorage 既有做法)绕过上游,闸才真正被触达。
  • _VERDICT_WORDS 的键全写错了GateVerdict 的取值是 silent_level1/silent_budget,我写成了 suppressed_by_* —— 任何一次沉默都会渲染成裸的内部标识,而那恰恰是这条命令存在的唯一理由。我原来那条测试是 "proceed" in text or "开口" in text or ... 的析取,fallback 吐出标识照样通过。已改成逐裁决断言「不含内部标识」+ get_args 穷举守卫,并删掉一个永不命中的死键(串了 CostPool 的取值)。

另有一处变异是语义等价的:current_chat_id=chat_id 换成 snapshot.chat_id 不会挂 —— /why 按本 chat 取快照,两者恒等。记录在此,避免下次误判成漏网。

两轴 review 后补的

  • PersistentStorage.get_events 原本零测试覆盖,而它手写的 participants join + mood 重建正是生产路径。新增契约测试文件(两套实现共用同一套断言),含「两列都空必须还是 None、不能变成 MoodTag(0,0)」这条 issue #67 的既有语义。
  • /why 现在也渲染未决意图(PRD 字段清单里它与记忆条目并列,少了它答不了「它为什么突然提起那件事」)。
  • 解析器边界:valence=nan/inf 此前被接受,会经 mood_similarity 污染总分让排序失序、输出印 nan看海 valence=1 此前被静默吞进查询文本(正是 docstring 自己说拒绝做的事)。两者都改成明确拒绝。
  • arise_why_query_candidates 的 docstring 与代码互斥,改的是文档(代码符合 AC)。
  • 去重:RecallWeights 的构造抽成 _recall_weights(config)(漏改一处的表现恰恰是「调参工具与真实召回脱节」);storage.py 手写的行→EventRecord 抽成 _to_event_record,mood 判定复用既有 _event_mood(此前是同一条规则的第三份拷贝)。

验证

1401 测试全绿。

Closes #83 PRD #77 的最后一片,也是 design.md「上线路线」六个阶段里最后一块没落地的。 ## 四个 grill 决策 - **`/why` 只看本 chat**:不提供跨 chat 参数,也就不存在「在自己的群里查别人私聊」这条路径。 - **`/why_query` 的情感坐标默认取当下真实值、可选覆盖**:最常见的用法是「现在这条查询为什么召不回那条记忆」,不该逼人先拍两个数字 —— 拍错会得到误导性结果,而这个命令存在的意义就是给出可信诊断。 - **打分明细走重构**:抽出 `score_breakdown`,`score_events` 改成建在它之上。同一个公式只剩一份实现 —— 两份会在改权重逻辑时只改一边,调参工具就会讲一个与真实排序脱节的故事。既有 17 条召回测试零改动通过。 - **条目「已不存在」与「不可见」分开计数**:合成一个数会让排查的人分不清「我权限不够」和「记忆被清理了」。 ## 关于敏感闸,说实话 AC 要求两个命令都按「命令在哪个 chat 发起」重过 `is_event_visible`。实现了,但**我第一版给出的理由是错的**,规格轴核实后已改直: - 我写的是「条目可能事后被改成敏感或被删」。实际上 `EventModel` 只有 insert/select、Delta 压缩只写不删;而 `/why` 只读本 chat 快照、召回当时已用同一个纯函数滤过 —— 所以 **`/why` 那条路上的两个计数在当前代码里可证不可达**。这是我在 #79 犯过的同一个错(把不可达分支包装成 AC 覆盖)。 - 闸该留,真实理由是另外三条:① `get_events` 自己不过滤(取数与「给谁看」刻意分开,这是契约的一半);② **`/why_query` 那条真的可达** —— 它直接搜全库,且持久化版走 Qdrant 侧另一份过滤实现;③ 前向兼容(ADR-0010 的「忘记我」会让第一条立刻变可达)。 ## 变异验证抓到的两处 - **`/why_query` 的敏感闸测试原本是空转的**:`SentLog.search_events` 在存储层已经过滤,命令里那道冗余闸从未执行 —— 删掉它测试全绿。补了会泄漏的存储替身(同 issue #69 `_LeakySearchStorage` 既有做法)绕过上游,闸才真正被触达。 - **`_VERDICT_WORDS` 的键全写错了**:`GateVerdict` 的取值是 `silent_level1`/`silent_budget`,我写成了 `suppressed_by_*` —— 任何一次**沉默**都会渲染成裸的内部标识,而那恰恰是这条命令存在的唯一理由。我原来那条测试是 `"proceed" in text or "开口" in text or ...` 的析取,fallback 吐出标识照样通过。已改成逐裁决断言「不含内部标识」+ `get_args` 穷举守卫,并删掉一个永不命中的死键(串了 `CostPool` 的取值)。 另有一处变异是**语义等价**的:`current_chat_id=chat_id` 换成 `snapshot.chat_id` 不会挂 —— `/why` 按本 chat 取快照,两者恒等。记录在此,避免下次误判成漏网。 ## 两轴 review 后补的 - `PersistentStorage.get_events` 原本**零测试覆盖**,而它手写的 participants join + mood 重建正是生产路径。新增契约测试文件(两套实现共用同一套断言),含「两列都空必须还是 `None`、不能变成 `MoodTag(0,0)`」这条 issue #67 的既有语义。 - `/why` 现在也渲染**未决意图**(PRD 字段清单里它与记忆条目并列,少了它答不了「它为什么突然提起那件事」)。 - 解析器边界:`valence=nan`/`inf` 此前被接受,会经 `mood_similarity` 污染总分让排序失序、输出印 `nan`;`看海 valence=1` 此前被静默吞进查询文本(正是 docstring 自己说拒绝做的事)。两者都改成明确拒绝。 - `arise_why_query_candidates` 的 docstring 与代码互斥,改的是文档(代码符合 AC)。 - 去重:`RecallWeights` 的构造抽成 `_recall_weights(config)`(漏改一处的表现恰恰是「调参工具与真实召回脱节」);`storage.py` 手写的行→`EventRecord` 抽成 `_to_event_record`,mood 判定复用既有 `_event_mood`(此前是同一条规则的第三份拷贝)。 ## 验证 1401 测试全绿。
PRD #77 的最后一片,也是 design.md 上线路线六个阶段的最后一块。

四个 grill 决策:
- `/why` **只看本 chat**:不提供跨 chat 参数,也就不存在「在自己的群里查别人私聊」
  这条路径。敏感闸仍然必要且非空转 —— 快照只存条目标识、内容是渲染时现查的,条目
  可能在那次决策之后被改成敏感或被删掉。
- `/why_query` 的情感坐标**默认取当下真实值、可选覆盖**:最常见的用法是「现在这条
  查询为什么召不回那条记忆」,不该逼人先拍两个数字 —— 拍错会得到误导性结果,而这个
  命令存在的意义就是给出可信诊断。
- 打分明细走**重构**:抽出 `score_breakdown`,`score_events` 改成建在它之上。同一个
  公式只剩一份实现 —— 两份会在改权重逻辑时只改一边,调参工具就会讲一个与真实排序
  脱节的故事。既有 17 条召回测试零改动通过。
- 条目**已不存在**与**不可见**分开计数:不可见 = 有这条但不能给你看;不存在 = 没了。
  合成一个数会让排查的人分不清「我权限不够」和「记忆被清理了」。

新增 `EventStoragePort.get_events(ids)`(按标识批量取,查不到就不出现在返回里)。

敏感闸的验证按 AC 要求做足:
- 每条「不该出现」都配一条「该出现」的反向对照(闸判的是敏感度,不是「凡是别处来
  的都藏起来」)。
- 变异验证:把 `partition_recalled_events` 里那行闸删掉 → 2 条挂;`/why` 整个跳过
  分区 → 2 条挂。

**一处变异抓到我自己的空转测试**:`/why_query` 那道闸删掉后**全绿** —— 因为
`SentLog.search_events` 在存储层已经过滤了,命令里那道冗余闸从未执行。这正是
issue #69 抓到过的同型空转。补了会泄漏的存储替身(同 `_LeakySearchStorage` 既有做法)
绕过上游,闸才真正被触达;补测试后同一变异挂 1 条。留这道冗余的理由:持久化版走的是
Qdrant 侧的过滤条件(另一份实现),写漏一个条件就会把敏感条目送到这条对外出口上。

另一处变异是**语义等价**的:把 `current_chat_id=chat_id` 换成 `snapshot.chat_id`
不会挂 —— 因为 `/why` 按本 chat 取快照,两者恒等。记录在此,避免下次误以为那是漏网。

`/why_query` 的 embedding 消耗记进 embedding 池并受熔断约束;熔断时明确失败、不绕过
(断言假客户端调用计数为零,不是断言「没有输出」)。

1369 测试全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
真 bug(我自查与标准轴独立撞上):**`_VERDICT_WORDS` 的键全写错了**。`GateVerdict`
的取值是 `silent_level1`/`silent_budget`,我写成了 `suppressed_by_*` —— 意味着任何一次
**沉默**都渲染成裸的内部标识 `silent_level1`,而那恰恰是这条命令存在的唯一理由
(「它刚才为什么不理我」)。我原来那条测试写成 `"proceed" in text or "开口" in text
or ...` 的析取,fallback 吐出内部标识照样通过。已改成逐裁决参数化断言「输出里不含
内部标识」,并补 `get_args` 穷举守卫(同 test_cost/test_breaker 既有先例)。
`_TRIGGER_WORDS` 里那个永不命中的 `"proactive"` 死键(串了 `CostPool` 的取值)也一并
删掉,守卫会挡住这类。

**理由错误(规格轴核实)**:我给敏感闸写的理由「条目可能事后被改成敏感或被删」是
**事实错误** —— `EventModel` 只有 insert/select,Delta 压缩只写不删;而 `/why` 只读
本 chat 快照、召回当时已用同一个纯函数滤过。所以 `/why` 那条路上的两个计数在当前
代码里**可证不可达**。这是我在 #79 犯过的同一个错(把不可达分支包装成 AC 覆盖)。
闸该留(AC 要求 + 纵深防御),但三处 docstring 的理由已改直:`get_events` 自己不过滤
是契约的一半、`/why_query` 那条真的可达(且持久化版走 Qdrant 另一份过滤实现)、
以及前向兼容(ADR-0010 的「忘记我」会让第一条立刻变可达)。

解析器边界(标准轴实测):
- `valence=nan` / `inf` 此前被 `float()` 接受,会经 `mood_similarity` 一路污染总分
  (`min(nan, 1.0)` 仍是 nan),让排序失序、输出印出 `nan` —— 一个调参工具给出错误
  排序比它不工作更糟。加 `isfinite` + [-1,1] 范围校验(`AXIS_BOUND`)。
- `看海 valence=1`(坐标在查询文本里)此前被静默吞进查询文本,**正是 docstring 自己
  说拒绝做的那件事**,那段噪声还会一起被 embed。改成明确拒绝 + 回用法说明。

补齐(规格轴):
- `PersistentStorage.get_events` 原本零测试覆盖,而它手写的 participants join + mood
  重建正是生产路径。新增契约测试文件,两套实现共用同一套断言(PRD 测试决策),含
  「两列都空必须还是 None、不能变成 MoodTag(0,0)」这条 issue #67 的既有语义。
- `/why` 现在也渲染**未决意图**:PRD 的字段清单里它与记忆条目并列,少了它答不了
  「它为什么突然提起那件事」。不需要额外可见性闸(未决意图本就是 per-chat 作用域)。
- `arise_why_query_candidates` 的 docstring 与代码互斥(写着「展示上限不是召回上限」,
  代码直接传给 `search_events(limit=)`)。代码符合 AC,改的是文档。

去重(标准轴):`RecallWeights` 的 9 行构造在前台召回与 `/why_query` 各一份 —— 抽成
`_recall_weights(config)`。收益不是省 9 行:config 加一个权重字段时漏改一处,表现恰恰
是「调参工具展示的排序与真实召回脱节」,而那正是 `score_breakdown` 抽取要防的另一半。
`storage.py` 手写的行→EventRecord 抽成 `_to_event_record`,mood 两列判定复用既有
`_event_mood`(此前那是同一条规则的第三份拷贝)。

1401 测试全绿。变异验证:把裁决键改回错的那个 → 3 条挂。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KumaAgent changed title from C:/Program Files/Git/why + /why_query 可解释性命令(含记忆敏感闸) to /why + /why_query 可解释性命令(含记忆敏感闸) 2026-07-28 09:13:20 +00:00
Yushu merged commit d76274cba4 into main 2026-07-28 09:17:07 +00:00
Sign in to join this conversation.
No description provided.