Files
HomeAgent/docs/zh/scene-memory-fix-plan.md
JianFeeeee cf14b840ef docs: 补齐文档状态分类 + 校正两轮代码改动后的失真
## 背景

上一次文档校订(b9b5ac0)之后又有两次代码提交(5194a96 / 8293282),
把 future-plan §1.1 整节变成了错的 —— 它还写着「档位没有生效点」
和「在 prepareInputTask 里加一次 activateSecurityProfile 调用」,
而后者正是被后来的收敛**否定掉的方案**。

## 新增 docs/zh/README.md(文档索引)

32 篇文档此前混着三种时态(当时实测 / 历史推演 / 设计规划),外观相同。
索引给出**状态分类与可信度规则**:

| 状态 | 能不能当事实用 |
|---|---|
| 已实现并部署 | ✅ 直接信 |
| 部分实现 | ⚠️ 只信标了"生效"的部分 |
| 设计 / 未实现 | ❌ 只能当设计意图 |
| 归档说明 | ❌ 不要改、也不要信 |

并写死**判据名可信度规则**:文档里的 `TestXxx` 不保证还存在。
给了核对命令,以及当前基线(27 条缺失的分布 —— 18 条属"设计未实现"类
文档的规划名、7 条是 §2.7.3 有意保留的历史对照表左栏、2 条属视频能力
未纳入契约)。判定标准:**在"已实现"类文档正文里找不到才是缺陷**。

## 重写 future-plan §1.1(已结案)

原节写的是错的方案。改成记录**真正的缺陷分三层**:

```
第 3 层(表面):activateSecurityProfile 零调用方
第 2 层:agent/core 在替 security 做策略决策
         (自己抠 security_profile / 自己选判定链 /
           自己写 Kind:"resident" 声明主体类别 —— 提权口子)
第 1 层(根因):security 缺少「输入接入点」
```

并补上「每 agent 一个实例」的最终形态(共享注册表 + per-agent 执行视图,
sessionProfile / longtimeProfile 两层,优先级 session > longtime 本身是安全语义)。

§〇「机制 ≠ 接线」表格里档位那一条从 🔴 改为 ✅。

## 各文档状态行

此前 14 篇缺状态行(runbook / 实测报告 / 计划类)。逐篇补上**符合实际内容**
的声明,而不是统一套一句。特别地:

- `commondev-design.md` 标题本就写「设计 · 未实现」→ 归到"设计"类
- `live-run-evidence-2026-10-04.md` → 明确标注"历史证据快照,不要用它的
  结论覆盖现在的实现判断"
- `legacy-issues.md` → 写明它与 future-plan 的分工(前者收已知问题,
  后者收能力缺失与零调用方)

## 正文里两处判据名漂移

- `TestBootCtxCloseAllIsReverseOrder` → 实际是
  `TestKernelStateCloseAllIsReverseOrder`(`BootCtx` 改名时判据跟着改的)
- `TestNilTypedBusSafety` → 标注为"规划名,仓库中不存在"

## 新增 §2.7.5 模块产物的实例化粒度

依赖图保证"设施已就绪",但没说该有几个实例 —— 这是独立决策。
记下 security 的两个答案:全局基线共享(否则插件声明对部分 agent 不生效)、
轮次状态 per-agent。并给出给未来新模块的两条判断标准。

## 安全文档

状态头补「架构形态」段(防火墙定位、per-agent 视图、两层档位);
接线状态表里 `ActivateProfile` 从 🔴 改为 ✅(2026-10-31 接通)。

## README

文档列表加索引与 future-plan 入口 —— 此前从 README 找不到 docs/zh 的任何指引。

## 核对

- docs/zh 32 篇全部有状态行
- 索引里列的文档全部存在,无遗漏
- 分类与各文档自述一致(逐篇抽查)
- go build 仍通过(纯文档改动)
2026-10-31 07:50:58 +08:00

260 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 场景式记忆修复 plan
> **状态:计划(部分落地)。** 是修复计划,不是现状描述。
> 未完成项已索引到 [`future-plan.md`](./future-plan.md) §六。
> 起点:2026-09-26 生产库实测(`${HA_DATA}/memory/graph.db`)。
> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning`
> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。
> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。
---
## 根因(三条,逐条修)
### R1 建键不过归一化(病因)
`internal/memory/scene_emerge.go:329`
```go
base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化
```
而同一层的另外三条路**都**过了归一化:
| 位置 | 是否归一化 |
|---|---|
| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ |
| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ |
| `RecallByScene`(读侧召回)`scene.go:328` | ✅ |
| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ |
`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。
`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。
实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名):
```
auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs)
auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc
auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc
auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc
auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_...
auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
```
### R2 `part`(时段,权重 0.2)污染主键与门槛
`Label(2)` 取权重最高的 2 个特征。特征顺序是
`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`,
只有当前 2 个强特征不足时 `part` 才会进键——于是出现
`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。
**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`),
所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。
实测证据(键名与实际指纹自相矛盾):
```
场景键: auto:chan:qq+part:morning strength=270
实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning
```
这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、
并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。
### R3 图整理心跳漏扫 `scenes`
`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查
`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。
所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。
**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
### R4 场景身份只有「输入通道」一个可靠维度
现网 65 个场景键的维度分布(实测):
```
auto:chan(涌现·通道主导) 36
chan: (输入通道) 26
tool: (工具) 3
peer 主导 0
topic 主导 0
```
两个独立原因叠加:
1. **采集侧恒空**:`situationFeaturesFor` 从 `evt.Payload` 找
`peer/peer_id/group_id/user_id/chat_id`,而全仓**没有任何插件在 InjectInput
时填这些键**(`group_id` 只出现在 `output.go:145` 的发送侧帮助文本里)。
⇒ 日志中 `peer` 特征出现次数为 **0**。
2. **排序上被挤掉**:`chan` 与 `peer` 权重同为 1.0,而 `NewSituation` 按权重
降序**稳定**排序,`chan` 先 append 就永远在前 ⇒ 即使采集到 peer,
`Label(2)` 也轮不到它。
后果:「跟谁对话」这个本该最强的身份信号(权重与 chan 并列)**根本进不了
场景身份**。同一件事在 QQ 和 Telegram 上会落进不同场景而无法共享。
**R4 与 R1/R2 是不同层面的问题**:R1/R2 让键构造正确,R4 决定键**能表达
什么**。R1 修完后 `auto:chan:qq` 会取代 `auto:chan:qq+part:morning`,
但它依然只认通道——所以 R4 不修,修复效果只到「正确的单一维度」。
### R5 `situation_evidence` 全表清空
`createSceneLocked` 新场景一成立就 `DELETE FROM situation_evidence`
(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未
攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。
### R6 通道侧没有「是否参与场面识别」的声明项(SDK 缺口)
`ChannelDef` 的记忆相关声明已有三件套,语义各管一轴:
```
NoMemory 进不进记忆计算
ContextPolicy 裁不裁上下文(破坏性,默认关)
RecallPolicy 召不召回记忆(只读,默认开)
```
**唯独没有「这条通道是否参与场面识别」。** 现状是**无条件参与**:
`situationFeaturesFor` 里只要 `evt.Source != ""` 就塞一个 `chan` 特征,
没有可关的开关 ⇒ 现网 `chan:system` / `chan:kernel` / `chan:timer` 这类
**纯内部信噪通道也在参与场面聚类**。
穷举确认不是查漏:编译使用的就是 `third_party/homeagent-sdk`(go.mod replace),
`plugin.go` 中 `scene` 出现 0 次,SDK 自身 git 历史 `-S'Scene' -- sdk/` 为空。
已有但未被使用的另一个口子:`Payload["scene"]`(`memorypass.go:37`)允许插件
在单次注入时声明场景键(string/[]string/[]interface{} 三形态,来自 `d98bf51`)。
现网 **0 个插件使用**,24 个 declared 场景全是 `ChannelScene(evt.Source)` 派生。
### R7 渠道本身可以覆盖多个场景
`payload["scene"]` 传 `chan:qq/peer:group_1` 这类**层级键**时,
`RecallByScene` 的 `(key = ? OR key LIKE ? || '/%')`(`scene.go:351`)
支持前缀召回——这层能力已存在,但因 R6 无人使用而闲置。
---
## 步骤
### 步骤 1:修 R1 + R2(源头,不碰存量)✅ 已完成
- [x] `Label` 只取权重 ≥ `labelFeatureWeight`(0.5) 的主导特征,结果过 `NormalizeSceneKey`
- [x] `createSceneLocked` 的 `base` 再做一次防御性归一化;label 为空时拒建无名场景
- [x] 冲突后缀 `#N` → `.N`(`'#'` 会被归一化成 `'_'`,是第四处双胞胎来源)
- [x] 判据:`scene_key_test.go` 6 例,先红后绿(4 红 1 绿 → 全绿)
- 提交:`1fa9ef6`
### 步骤 2:SDK 补 `ScenePolicy` 声明项(公开接口,可动)
按 `ContextPolicy` / `RecallPolicy` 的既有风格补齐(同一文件、同一形状):
- [ ] 常量:`ScenePolicyAuto = "auto"` / `ScenePolicyNone = "none"`
- [ ] 校验:`ValidScenePolicy(policy string) bool`(空串等价默认)
- [ ] `ChannelDef.ScenePolicy string` + json tag `scene_policy,omitempty`
- [ ] `InjectOptions.ScenePolicy string`(单次注入可覆盖通道默认)
- [ ] 内核接线:
- `internal/agent/io/channel.go:349-357` 的 payload 搬运加一条 `scene_policy`
- `situationFeaturesFor` 读到 `none` 时**不产任何特征**(连 `part` 也不产——
一个不参与场面识别的通道不该留下时段噪声)
- 声明路 `sceneKeysFor` 同样受 `none` 约束
- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
- [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响,
少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆,
召回返回空;且 declared 场景**不进**相似度空间
(`loadEmergentScenesLocked` 只取 `origin='emergent'`),
故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
### 步骤 3:修 R3(让图整理覆盖全库)✅ 已完成
- [x] `GraphDB.DedupeScenes()`:归一化后同名场景合成一个
- [x] 接到 `mergeLoop` 尾部(不塞进实体的 O(n²) 双重循环)
- [x] 判据 8 例(先红后绿)
- 生产库副本实测:65 → 57,合并 8 组,`auto:chan:qq_part:morning`
的 refs/rel/ent 一条没丢,strength 1 → 272
- 提交:`758ec11`
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
### 步骤 4:清理现网垃圾场景,让它重新生成
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
> ⚠️ **硬约束:必须先部署含 R1 修复的二进制**。反过来的话,重新涌现出来的
> 还是带 `+`/`#` 的旧键,清一场白清。
#### 部署前基线(2026-09-26 20:1x 实测)
```
PID 92115 / 启动于 Sat 19:12:43
子进程插件 25 个
近 24h 异常日志(Fatal/panic/DATA RACE): 0
场景数 65
`homed --version` 不存在(flag 未定义)⇒ 纪律清单那条用 status 接口或 ps 核对
```
#### 执行顺序
- [ ] 1. `make build`(默认 `HOMED_TAGS=onnxruntime`,cgo;校验 onnx/cgo 标记)
- [ ] 2. 备份:`sqlite3 graph.db ".backup 'graph.db.bak-<ts>'"`(**禁用 cp**,WAL 不一致)
- [ ] 3. `install -m 0755 build/homed /usr/local/bin/homed`(**禁用 cp**,会写坏运行中进程映像)
- [ ] 4. `systemctl restart homeagent`
- [ ] 5. 后置验证:进程起来 / 子进程插件数恢复 / 异常日志为 0 / 真实对话跑通
- [ ] 6. 清理:删 `origin='emergent'` 全部 + 关联 features/refs + `situation_evidence`
- [ ] 7. 等 ≥2 次同类交互让场景重新涌现
- [ ] 8. 复验:新键不含 `+`/`#`、有 features **且**有 refs、strength 合理
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
- [ ] 同步清 `situation_evidence`
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
- [ ] 复验:新场景键**不含 `+`/`#`**、有 features **且**有 refs
> 清理**不影响记忆本体**:868 条 active 关系与 1179 个实体都在
> `relations`/`entities` 表,与 `scenes` 无外键依赖。
> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留,
> 涌现重建期间它继续承担 QQ 场景召回。
### 步骤 5:修 R5(证据桶别全表清)✅ 已完成
- [x] `DELETE FROM situation_evidence` → `... WHERE label = ?`(桶键 = `sig.Label(2)`,
**不带 `auto:` 前缀**——这点修的时候差点栽)
- [x] 判据 3 例:判据实测「6 个通道各来 3 次只长出 2 个场景」
- 提交:`758ec11`
### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来)
前置:先只读调查各插件 InjectInput 时手上有什么,产出结论表再动。
`payload["scene"]` 的口子已存在(R6),插件声明比内核猜 peer 更直接。
- [ ] 调查:qq / mail-bridge / a2a / acp / webui / cli … 各自可声明什么
- [ ] 排序侧:`chan` 与 `peer` 权重同为 1.0 而 `chan` 恒在前(稳定排序),
即使采集到 peer 也进不了 `Label(2)` ⇒ 需要 peer 优先或提权
- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
### 步骤 7:验证与收口
- [ ] `go build ./...` + 全仓 `go test ./...`
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
- [ ] SDK 接口冻结检查:`git diff main -- third_party/homeagent-sdk/sdk/` 的变化
**已获用户授权**(开发阶段),但需在提交信息里写明「纯追加、omitempty、
老插件行为不变」
- [ ] 现网观察:场景命中后能查到 refs(非 0)
- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账)
- [ ] `git_release_check.sh` 无新增红项(SDK 冻结项变化属预期)
## 不做的事(防反复挂账)
- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
混进本次修复会让 diff 失焦。单独开条目。
- **不**在 R6 里动 `ValidContextPolicy` / `ValidRecallPolicy` 的既有语义:
新增项是纯追加,不借机改旧行为。