mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
Merge branch 'feature/scene-writeback' — 场景式记忆修复 + WebUI 性能与星图改进
## 记忆:场景式记忆的 5 处根因(生产实测驱动) 现网 65 个场景里有 6 组是同一场面的双胞胎键,最严重的 auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs, 日志里被「命中」179 次;孪生的 auto:chan:qq_part:morning 持有 210 refs 却有 0 features(聚类只读 scene_features,所以它永不被看见)。两套特征 体系各活各的,谁也发现不了谁。 -1fa9ef6键归一化 + 排除最弱维度:建键路径(createSceneLocked)漏过 NormalizeSceneKey,而 EnsureScene / effectiveScenes / RecallByScene 三处都过了 ⇒ '+' 与 '_' 成为两个合法主键,key UNIQUE 拦不住。 同时把权重仅 0.2 的 part(时段)排除出场景身份 —— 实测「morning 场景 吞掉 evening 指纹」(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)。 冲突后缀 '#N' 改 '.N'('#' 也会被归一化,是第四处双胞胎来源)。 -758ec11图整备覆盖 scenes:新增 DedupeScenes 并接入 mergeLoop。 原先 detectEntityMerge 的遍历入口 Recall(nil,nil,1,"") 只查 entities/relations,scenes 完全没有整备路径 —— 这是「双胞胎从 9-15 起 无人发现」的原因。生产库副本实测:65 → 57,合并 8 组,refs/rel/ent 一条没丢。 -758ec11证据桶按桶清:createSceneLocked 原先是 DELETE FROM situation_evidence(全表清)。多通道共用计数表,qq 的场景 一长出来就把 mc/webui 尚未攒够 minSceneEvidence=2 的证据抹掉 ⇒ 判据 实测「6 个通道各来 3 次只长出 2 个场景」。 -2567a22场景键两路合并去重:现网日志实测 scenes=[chan:qq chan:qq]。 声明路与通道派生路之间缺共同的 seen。 ## SDK:补 ScenePolicy 声明项(经用户授权的公开接口扩展) ChannelDef 已有 NoMemory/ContextPolicy/RecallPolicy 三件套,唯独没有 「这条输入算不算一场戏的一部分」,现状是无条件参与 ⇒ chan:system / chan:kernel / chan:timer 这类纯内部信噪通道也在撑场面。 新增 ScenePolicyAuto/None + ValidScenePolicy,形状与既有两项完全一致; ChannelDef.ScenePolicy 与 InjectOptions.ScenePolicy 均带 omitempty, 零值行为逐字节不变。默认取 auto(参与)而非 none:场景只附加检索路、 不改记忆本体,默认关会让存量通道突然失去召回。**现网不标任何一个通道** (用户裁定「多写无影响、少写会缺场景」;实测 0-refs 通道召回返回空, 且 declared 场景不进相似度空间)。 ⚠️ 本次合并会使 git_release_check 的「公开 SDK 接口冻结」项报 FAIL, 属预期:有意的新增接口,非破坏性变更。 ## WebUI -ae87f4b服务端 gzip(首屏 wire 字节 -70%):状态机写成单一枚举而非 多个 bool;SSE 不压、必须透传 http.Flusher、Content-Length 需防陈旧值。 -d3315c6前端按页签懒加载:空闲请求 37 → 17(-54%);顺带治掉三个 轮询器,并把 loadChatStarmapData 的空图分支硬取 getElementById("sm-container-chat") 改为可移植(该 bug 曾导致首页 首帧必抛、永不重试、星图永远空白)。 -9346fed星图跟随 agent 活动 + 搬到主页 + 修分类配色从未生效 (服务端发 "Concept"、JS 键是 "concept" ⇒ 永不匹配 ⇒ 1150 节点 全回退兜底灰)。 -9c93f23配色改中性灰蓝:上一提交修好后,1148/1149 个 Concept 节点 第一次真拿到亮青绿 ⇒ 整张图变绿。绿色不是渲染 bug,是「配色终于生效」 后暴露出的真实数据形状;之前的灰恰好是「全都没匹配上」的症状。 -4a231a8两处表达式合并为单行(纯格式化)。 ## 文档 - f441574/6e94209/13a070f 场景记忆修复 plan(5 根因 → 7 步骤) -bf5d891介绍站补「场面涌现」板块 - SDK 站新增 docs/guide/scene-memory.md(概念 + 声明项用法) ## 验证 记忆 8 包 + agent/core 全绿;全仓 59 包 0 FAIL。生产部署后置清单全绿 (版本自报、插件子进程 25、Fatal 0、端到端)。场景清理后复验:涌现出的 新键不含 +/#、有 features 且有 refs。
This commit is contained in:
256
docs/zh/scene-memory-fix-plan.md
Normal file
256
docs/zh/scene-memory-fix-plan.md
Normal file
@ -0,0 +1,256 @@
|
||||
# 场景式记忆修复 plan
|
||||
|
||||
> 起点:2026-09-26 生产库实测(`/home/newqqagent/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` 的既有语义:
|
||||
新增项是纯追加,不借机改旧行为。
|
||||
@ -106,6 +106,7 @@ func (a *Agent) mergeLoop() {
|
||||
case <-ticker.C:
|
||||
log.Printf("[agent] heartbeat merge tick")
|
||||
a.detectEntityMerge()
|
||||
a.dedupeScenes()
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
@ -312,6 +313,31 @@ func (a *Agent) detectEntityMerge() {
|
||||
}
|
||||
}
|
||||
|
||||
// dedupeScenes 是图整备的场景侧去重:把「同一个场面的两个键」合成一个。
|
||||
//
|
||||
// 为什么与 detectEntityMerge 分开而不塞进它的双重循环:
|
||||
// - 实体:全库两两 bigram + LLM 裁决。1 万实体实测 5000 万次配对、
|
||||
// ~224GB 瞬时分配每轮(见 plan「不做的事」),已是独立问题。
|
||||
// - 场景:判重口径是**归一化后是否同名**——同名即同一场面,**不需要 LLM
|
||||
// 裁决**(键相同本身就是证据)。且 declared 场景不进相似度空间,
|
||||
// 场景之间「像不像」由 EnterScene 的聚类负责,不是这里的事。
|
||||
//
|
||||
// 所以这里只做确定性的同键合并,不做相似度合并:把「chan:qq 与
|
||||
// chan:webui 很像」也合并是危险的,那会把不同场面糊成一个。
|
||||
func (a *Agent) dedupeScenes() {
|
||||
if a.memory == nil {
|
||||
return
|
||||
}
|
||||
merged, err := a.memory.DedupeScenes()
|
||||
if err != nil {
|
||||
log.Printf("[agent] 场景去重失败(下轮重试): %v", err)
|
||||
return
|
||||
}
|
||||
if merged > 0 {
|
||||
log.Printf("[agent] 场景去重:合并 %d 组同键场景(同一场面的重复键)", merged)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 关系复审:GraphDB → ClearSentenceID → CleanupOrphanedSentences
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@ -430,7 +430,7 @@ func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, sceneKeysFor(evt, "")).Archived
|
||||
return a.memoryPass(cleanInput, "input:"+evt.Source, true, false, a.sceneKeysFor(evt, "")).Archived
|
||||
}
|
||||
|
||||
// pruneDeclared 判定这次输入是否显式声明了裁剪。
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// sceneKeysFor 推导本轮输入的**当前场景**。
|
||||
@ -19,8 +20,13 @@ import (
|
||||
//
|
||||
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
|
||||
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
|
||||
func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
|
||||
func (a *Agent) sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
|
||||
var keys []string
|
||||
// 通道/注入点声明不参与场面识别时,**连派生场景键也不给**。
|
||||
// 只停掉指纹采集而留着声明路,等于给「不参与场面」这个口子开了后门。
|
||||
if a.sceneSuppressed(evt) {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
add := func(k string) {
|
||||
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
|
||||
@ -59,6 +65,35 @@ func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// mergeSceneKeys 把「声明路」与「涌现场景」两路合并成一个**无重复**的场景集合。
|
||||
//
|
||||
// 为什么需要它:两路各自都去重过(sceneKeysFor 内部有 seen、resolveTurnScenes
|
||||
// 内部也有),但**两路之间**没有共同的 seen。而声明路与通道派生路会产出
|
||||
// 同一个键(chan:qq 既是声明的、也是从 evt.Source 派生的)——现网日志实测到
|
||||
// `scenes=[chan:qq chan:qq]`。
|
||||
//
|
||||
// 功能上 RecallByScene 内部会再去重,所以这不是 bug,但有两个实际代价:
|
||||
// 日志里的 scenes=[...] 会误导排查;每次白走一遍前缀匹配。
|
||||
func mergeSceneKeys(declared, emergent []string) []string {
|
||||
out := make([]string, 0, len(declared)+len(emergent))
|
||||
seen := make(map[string]bool, len(declared)+len(emergent))
|
||||
for _, k := range declared {
|
||||
if k == "" || seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range emergent {
|
||||
if k == "" || seen[k] {
|
||||
continue
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
|
||||
type memoryPassOut struct {
|
||||
// Archived 是被归档进文档记忆的低相关 L0 事件数(prune 的输出)。
|
||||
@ -129,11 +164,41 @@ func (a *Agent) pruneByQuery(query string) int {
|
||||
// 已有量,不需要模型配合,也不需要人工标注。
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// situationFeaturesFor 采集一轮交互的场面指纹。
|
||||
// sceneSuppressed 报告本次输入是否被声明为**不参与场面识别**。
|
||||
//
|
||||
// 读取面与其它记忆声明完全一致:先看注入点 payload(单次覆盖),
|
||||
// 再看通道定义(ChannelDef.ScenePolicy),都没声明 = 参与(保持既有行为)。
|
||||
// 优先级与 pruneDeclared / recallDeclared 同构。
|
||||
//
|
||||
// 为什么要一个显式开关:场面指纹只要 evt.Source != "" 就无条件产出一个 chan
|
||||
// 特征,于是内核自循环(system)、心跳(timer)、内部状态汇报(kernel)这类
|
||||
// **纯信噪通道**也在撑场面——它们每次触发都让一个不相干的场景长出来或变强,
|
||||
// 而召回时又会把「内核在跑定时器」当成「用户在这类场景下说过的话」取回。
|
||||
func (a *Agent) sceneSuppressed(evt *agentIO.InputEvent) bool {
|
||||
if evt == nil {
|
||||
return false
|
||||
}
|
||||
if p, ok := evt.Payload["scene_policy"].(string); ok && p != "" {
|
||||
return p == pubsdk.ScenePolicyNone
|
||||
}
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.ScenePolicy != "" {
|
||||
return chDef.ScenePolicy == pubsdk.ScenePolicyNone
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sceneFeaturesFor 采集一轮交互的场面指纹。
|
||||
//
|
||||
// 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是
|
||||
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
|
||||
func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
|
||||
func (a *Agent) situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
|
||||
// 声明不参与场面识别:连时段特征都不产——一个不参与的面孔
|
||||
// 不该在 situation_evidence / scene_features 里留下任何足迹。
|
||||
if a.sceneSuppressed(evt) {
|
||||
return nil
|
||||
}
|
||||
var feats []memory.SituationFeature
|
||||
if evt != nil {
|
||||
if evt.Source != "" {
|
||||
@ -225,8 +290,8 @@ func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
|
||||
return f.turnScene
|
||||
}
|
||||
|
||||
declared := sceneKeysFor(evtOf(f), tool)
|
||||
feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
|
||||
declared := a.sceneKeysFor(evtOf(f), tool)
|
||||
feats := a.situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
|
||||
sig := memory.NewSituation(feats...)
|
||||
|
||||
turn, err := a.memory.EnterSceneWithHint(sig, declared)
|
||||
|
||||
@ -11,8 +11,9 @@ import (
|
||||
// TestSceneKeysFor 钉住当前场景的推导优先级:
|
||||
// 注入点显式声明 > 通道 > 工具;并列命中且去重。
|
||||
func TestSceneKeysFor(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
// 通道 + 工具:两个都能独立成立的触发条件,都要带上
|
||||
got := sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message")
|
||||
got := a.sceneKeysFor(&agentIO.InputEvent{Source: "qq"}, "qq_get_message")
|
||||
want := []string{"chan:qq", "tool:qq_get_message"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("sceneKeysFor = %v, want %v", got, want)
|
||||
@ -28,7 +29,7 @@ func TestSceneKeysFor(t *testing.T) {
|
||||
Source: "QQ",
|
||||
Payload: map[string]interface{}{"scene": " chan:qq/peer:group_1 "},
|
||||
}
|
||||
got = sceneKeysFor(evt, "")
|
||||
got = a.sceneKeysFor(evt, "")
|
||||
if len(got) != 2 || got[0] != "chan:qq/peer:group_1" || got[1] != "chan:qq" {
|
||||
t.Errorf("显式声明应排最前且通道场景归一: %v", got)
|
||||
}
|
||||
@ -38,18 +39,18 @@ func TestSceneKeysFor(t *testing.T) {
|
||||
Source: "webui",
|
||||
Payload: map[string]interface{}{"scene": []interface{}{"chan:qq", "task:reminder"}},
|
||||
}
|
||||
got = sceneKeysFor(evt, "")
|
||||
got = a.sceneKeysFor(evt, "")
|
||||
if len(got) != 3 || got[0] != "chan:qq" || got[1] != "task:reminder" || got[2] != "chan:webui" {
|
||||
t.Errorf("数组声明未生效: %v", got)
|
||||
}
|
||||
|
||||
// nil 事件不 panic
|
||||
if got := sceneKeysFor(nil, ""); len(got) != 0 {
|
||||
if got := a.sceneKeysFor(nil, ""); len(got) != 0 {
|
||||
t.Errorf("nil 事件应无场景: %v", got)
|
||||
}
|
||||
// 未声明的 payload 键不影响
|
||||
evt = &agentIO.InputEvent{Source: "cli", Payload: map[string]interface{}{"recall_policy": "none"}}
|
||||
if got := sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" {
|
||||
if got := a.sceneKeysFor(evt, ""); len(got) != 1 || got[0] != "chan:cli" {
|
||||
t.Errorf("无 scene 声明时应只有通道场景: %v", got)
|
||||
}
|
||||
}
|
||||
@ -57,11 +58,12 @@ func TestSceneKeysFor(t *testing.T) {
|
||||
// TestSituationFeaturesFor 钉住指纹来源:全部是运行时可观察量,
|
||||
// 不需要模型配合也不需要人工标注。
|
||||
func TestSituationFeaturesFor(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
evt := &agentIO.InputEvent{
|
||||
Source: "QQ",
|
||||
Payload: map[string]interface{}{"group_id": float64(1027993713)},
|
||||
}
|
||||
feats := situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message")
|
||||
feats := a.situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message")
|
||||
kinds := map[string]int{}
|
||||
for _, f := range feats {
|
||||
kinds[f.Kind]++
|
||||
@ -98,7 +100,7 @@ func TestSituationFeaturesFor(t *testing.T) {
|
||||
}
|
||||
|
||||
// 无事件时不 panic,且只有工具特征时也成立
|
||||
if feats := situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
|
||||
if feats := a.situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
|
||||
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
|
||||
}
|
||||
}
|
||||
@ -106,9 +108,10 @@ func TestSituationFeaturesFor(t *testing.T) {
|
||||
// TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」:
|
||||
// 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。
|
||||
func TestTurnSceneKeysBothPaths(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
// 声明的通道场景与工具场景都在,涌现键(若有)追加在后
|
||||
evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}}
|
||||
got := sceneKeysFor(evt, "qq_get_message")
|
||||
got := a.sceneKeysFor(evt, "qq_get_message")
|
||||
want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("声明侧场景数不对: %v want %v", got, want)
|
||||
|
||||
73
internal/agent/core/scenemerge_test.go
Normal file
73
internal/agent/core/scenemerge_test.go
Normal file
@ -0,0 +1,73 @@
|
||||
package core
|
||||
|
||||
// 场景键去重的判据。
|
||||
//
|
||||
// 症状:现网日志出现 `scenes=[chan:qq chan:qq]` —— 同一个键出现两次。
|
||||
// 原因:buildTaskMemoryContext / stepToolAfter 都先取 sceneKeysFor(内部
|
||||
// 有 seen 去重),再把 resolveTurnScenes 的 turn.Keys 直接 append 上去,
|
||||
// **两路之间没有共同的 seen 集合**。声明路和通道派生路都会产出 chan:qq。
|
||||
//
|
||||
// 功能上无害(RecallByScene 内部会去重),但它有两个实际代价:
|
||||
// 1. 日志里的 scenes=[...] 具有误导性——排查时会以为场景集合有问题;
|
||||
// 2. 每次多带一个重复键进召回,白走一遍前缀匹配。
|
||||
//
|
||||
// 判据参照物在生产代码之外:期望值是「场景集合内不得有重复键」这条
|
||||
// 不变量,直接对合并后的切片计数,不引用被测实现。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
)
|
||||
|
||||
// TestSceneKeysMerged_NoDuplicates 声明路与涌现路的并集不得有重复。
|
||||
// 现状下 tooldefs.go:63 与 task.go:793 都是直接 append,缺这一步。
|
||||
func TestSceneKeysMerged_NoDuplicates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
declared []string
|
||||
emergent []string
|
||||
}{
|
||||
{
|
||||
name: "涌现键与声明键同名(现网实测 chan:qq 两路都产出)",
|
||||
declared: []string{"chan:qq"},
|
||||
emergent: []string{"chan:qq"},
|
||||
},
|
||||
{
|
||||
name: "涌现键已归一化后与声明键同名",
|
||||
declared: []string{"chan:qq"},
|
||||
emergent: []string{"chan:QQ", "chan:qq"},
|
||||
},
|
||||
{
|
||||
name: "多路重复",
|
||||
declared: []string{"chan:qq", "tool:qq_get_message"},
|
||||
emergent: []string{"chan:qq", "tool:qq_get_message", "auto:chan:qq"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := mergeSceneKeys(tc.declared, tc.emergent)
|
||||
seen := map[string]bool{}
|
||||
for _, k := range got {
|
||||
if seen[k] {
|
||||
t.Errorf("场景集合含重复键 %q: %v", k, got)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSceneSuppressedSkipsMerge none 声明时两路都应为空,
|
||||
// 不能出现「声明路被关、涌现路还在」这种半开状态。
|
||||
func TestSceneSuppressedSkipsMerge(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
evt := &agentIO.InputEvent{
|
||||
Source: "system",
|
||||
Payload: map[string]interface{}{"scene_policy": "none"},
|
||||
}
|
||||
declared := a.sceneKeysFor(evt, "")
|
||||
if len(declared) != 0 {
|
||||
t.Fatalf("scene_policy=none 时声明路应为空,实际 %v", declared)
|
||||
}
|
||||
}
|
||||
144
internal/agent/core/scenepolicy_test.go
Normal file
144
internal/agent/core/scenepolicy_test.go
Normal file
@ -0,0 +1,144 @@
|
||||
package core
|
||||
|
||||
// ScenePolicy 声明项的判据(R6)。
|
||||
//
|
||||
// 缺口事实(穷举确认,非查漏):ChannelDef 已有 NoMemory/ContextPolicy/
|
||||
// RecallPolicy 三件套,唯独没有「这条通道是否参与场面识别」;
|
||||
// situationFeaturesFor 里只要 evt.Source != "" 就无条件塞 chan 特征。
|
||||
// ⇒ chan:system / chan:kernel / chan:timer 这类内部信噪通道
|
||||
// 也在参与场面聚类(现网 65 个键里就有 chan:system、chan:kernel、chan:timer)。
|
||||
//
|
||||
// 判据参照物在生产代码之外:期望值是「声明 none 的输入不产生任何场面特征」、
|
||||
// 「未声明的输入行为逐字节不变」这两条不变量,不引用被测实现。
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
func scenePolicyNoneEvent() *agentIO.InputEvent {
|
||||
return &agentIO.InputEvent{
|
||||
Source: "system",
|
||||
Payload: map[string]interface{}{"scene_policy": "none"},
|
||||
}
|
||||
}
|
||||
|
||||
// R6 核心:声明 none 的输入不产生任何场面特征。
|
||||
func TestScenePolicyNone_NoSituationFeatures(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
evt := scenePolicyNoneEvent()
|
||||
|
||||
if feats := a.situationFeaturesFor(evt, "帮我看下定时器", ""); len(feats) != 0 {
|
||||
t.Fatalf("声明 scene_policy=none 的输入仍产出了 %d 个场面特征: %+v —— "+
|
||||
"内部信噪通道会参与场面聚类,把无关场面撑出来", len(feats), feats)
|
||||
}
|
||||
}
|
||||
|
||||
// none 必须连时段(part) 都不产:一个不参与场面识别的通道
|
||||
// 不该在 situation_evidence / scene_features 里留下任何足迹。
|
||||
func TestScenePolicyNone_NoPartFeatureLeak(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
feats := a.situationFeaturesFor(scenePolicyNoneEvent(), "任意内容", "")
|
||||
for _, f := range feats {
|
||||
if f.Kind == "part" {
|
||||
t.Errorf("scene_policy=none 仍产出了时段特征 %q", f.Key())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// none 时工具场景也不该派生。
|
||||
func TestScenePolicyNone_NoToolScene(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
keys := a.sceneKeysFor(scenePolicyNoneEvent(), "cmd_run")
|
||||
for _, k := range keys {
|
||||
if k == "tool:cmd_run" || k == "chan:system" {
|
||||
t.Errorf("scene_policy=none 仍派生了场景键 %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 声明 none 时,显式 payload["scene"] 也不该被采纳——
|
||||
// 否则通道声明形同虚设(注入点声明与通道声明必须一致,通道是更宽的闸)。
|
||||
func TestScenePolicyNone_OverridesExplicitScene(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
evt := scenePolicyNoneEvent()
|
||||
evt.Payload["scene"] = "chan:qq"
|
||||
|
||||
keys := a.sceneKeysFor(evt, "")
|
||||
for _, k := range keys {
|
||||
if k == "chan:qq" {
|
||||
t.Error("通道声明 scene_policy=none 后,注入点显式声明的 chan:qq 仍被采纳;" +
|
||||
"通道级闸门应覆盖注入点级声明")
|
||||
}
|
||||
}
|
||||
if feats := a.situationFeaturesFor(evt, "", ""); len(feats) != 0 {
|
||||
t.Errorf("scene_policy=none 仍产出特征 %+v", feats)
|
||||
}
|
||||
}
|
||||
|
||||
// 未声明时行为必须逐字节不变(零值 = 保持现状 = 参与)。
|
||||
// 这是「纯追加」承诺的护栏:老插件不填 ScenePolicy,行为不能有任何变化。
|
||||
func TestScenePolicyUnset_BehavesExactlyAsBefore(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
evt := &agentIO.InputEvent{
|
||||
Source: "qq",
|
||||
Payload: map[string]interface{}{},
|
||||
}
|
||||
|
||||
feats := a.situationFeaturesFor(evt, "老大在吗", "")
|
||||
if len(feats) == 0 {
|
||||
t.Fatal("未声明 scene_policy 的输入不应失去场面特征(零值必须等价既有行为)")
|
||||
}
|
||||
var hasChan bool
|
||||
for _, f := range feats {
|
||||
if f.Kind == "chan" {
|
||||
hasChan = true
|
||||
}
|
||||
}
|
||||
if !hasChan {
|
||||
t.Errorf("未声明时应照旧产出 chan 特征,实际: %+v", feats)
|
||||
}
|
||||
|
||||
keys := a.sceneKeysFor(evt, "")
|
||||
if len(keys) == 0 || keys[0] != "chan:qq" {
|
||||
t.Errorf("未声明时应照旧派生 chan:qq,实际: %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式 auto 与未声明等价。
|
||||
func TestScenePolicyAuto_SameAsUnset(t *testing.T) {
|
||||
a := &Agent{io: agentIO.NewIOManager()}
|
||||
mk := func(policy string) []string {
|
||||
payload := map[string]interface{}{}
|
||||
if policy != "" {
|
||||
payload["scene_policy"] = policy
|
||||
}
|
||||
return a.sceneKeysFor(&agentIO.InputEvent{Source: "qq", Payload: payload}, "")
|
||||
}
|
||||
unset, auto := mk(""), mk(pubsdk.ScenePolicyAuto)
|
||||
|
||||
if len(unset) != len(auto) {
|
||||
t.Fatalf("auto 与未声明不等价: unset=%v auto=%v", unset, auto)
|
||||
}
|
||||
if len(auto) == 0 || auto[0] != "chan:qq" {
|
||||
t.Fatalf("auto 应照旧派生 chan:qq,实际: %v", auto)
|
||||
}
|
||||
}
|
||||
|
||||
// SDK 常量与校验函数:形状须与 ContextPolicy/RecallPolicy 一致。
|
||||
func TestScenePolicySDKShape(t *testing.T) {
|
||||
if !pubsdk.ValidScenePolicy("") {
|
||||
t.Error("空串应等价默认,校验须通过")
|
||||
}
|
||||
if !pubsdk.ValidScenePolicy(pubsdk.ScenePolicyNone) {
|
||||
t.Error("none 应合法")
|
||||
}
|
||||
if !pubsdk.ValidScenePolicy(pubsdk.ScenePolicyAuto) {
|
||||
t.Error("auto 应合法")
|
||||
}
|
||||
if pubsdk.ValidScenePolicy("prune") {
|
||||
t.Error("未知取值应被拒(照 ContextPolicy 的严格度)")
|
||||
}
|
||||
}
|
||||
@ -788,11 +788,9 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||
// 与这一步工具本身(如 tool:qq_get_message)。带上工具场景,
|
||||
// 才能让「凡是要回 QQ 消息」这类规则在该步被取回。
|
||||
// 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景
|
||||
scenes := sceneKeysFor(f.Evt, tc.Name)
|
||||
scenes := a.sceneKeysFor(f.Evt, tc.Name)
|
||||
turn := a.resolveTurnScenes(f, tc.Name)
|
||||
for _, k := range turn.Keys {
|
||||
scenes = append(scenes, k)
|
||||
}
|
||||
scenes = mergeSceneKeys(scenes, turn.Keys)
|
||||
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ func (a *Agent) buildMemoryContext(input string, maxTokens int, scenes []string)
|
||||
// query 取**清洗后**的输入(通道 Cleaner 的输出),与裁剪侧同一套语义:
|
||||
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏。清洗为空时回退原文。
|
||||
func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int) string {
|
||||
scenes := sceneKeysFor(evtOf(f), "")
|
||||
scenes := a.sceneKeysFor(evtOf(f), "")
|
||||
if f == nil {
|
||||
return a.recallText(input, "input", maxTokens, scenes)
|
||||
}
|
||||
@ -63,11 +63,9 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int
|
||||
if f.Evt != nil && f.Evt.Source != "" {
|
||||
trigger = "input:" + f.Evt.Source
|
||||
}
|
||||
// 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集。
|
||||
// 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集(去重)。
|
||||
turn := a.resolveTurnScenes(f, "")
|
||||
for _, k := range turn.Keys {
|
||||
scenes = append(scenes, k)
|
||||
}
|
||||
scenes = mergeSceneKeys(scenes, turn.Keys)
|
||||
return a.recallText(query, trigger, maxTokens, scenes)
|
||||
}
|
||||
|
||||
|
||||
@ -341,7 +341,7 @@ type InjectOptions = pubsdk.InjectOptions
|
||||
// applyInjectOpts 把注入标志位写进事件 payload。
|
||||
//
|
||||
// 只在非零时写:零值与旧 payload 逐字节一致,事件订阅方与旧内核
|
||||
// (不认识这两个键)都不会受影响。
|
||||
// (不认识这些键)都不会受影响。
|
||||
//
|
||||
// 为什么不把标志位当独立参数传到底:eventloop 与各注入路径都按 payload 取字段
|
||||
// (no_memory 本来就是这么走的),payload 是这里唯一已有的携带面。
|
||||
@ -355,6 +355,9 @@ func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
||||
if opts.RecallPolicy != "" {
|
||||
payload["recall_policy"] = opts.RecallPolicy
|
||||
}
|
||||
if opts.ScenePolicy != "" {
|
||||
payload["scene_policy"] = opts.ScenePolicy
|
||||
}
|
||||
if opts.CleanerName != "" {
|
||||
payload["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
|
||||
@ -580,3 +580,146 @@ func (g *GraphDB) TagSceneDocument(sceneKey, docID string) error {
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 场景去重(整备):把「同一个场面的两个键」合成一个
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// DedupeScenes 合并归一化后同名的场景,返回合并组数。
|
||||
//
|
||||
// 存在的原因:scenes.key 有 UNIQUE 约束,但**归一化口径曾经不统一**——
|
||||
// 建键路径用 "auto:" + Label(2) 而 Label 拼的是 "+",不过 NormalizeSceneKey;
|
||||
// 而写侧(effectiveScenes)、读侧(RecallByScene)、声明建键(EnsureScene)
|
||||
// 三处都过了归一化。于是 "+" 与 "_" 成为两个都合法的主键,UNIQUE 拦不住:
|
||||
//
|
||||
// auto:chan:qq+part:morning strength=270 6 features 0 refs
|
||||
// auto:chan:qq_part:morning strength=1 0 features 201 refs
|
||||
//
|
||||
// 两个节点互不可见:聚类只读 scene_features,所以 0-features 的那个
|
||||
// 永远不被看见;而 0-refs 的那个收不到任何写侧记忆。实测这对双胞胎
|
||||
// 从建库起累积到 strength=270 都没人发现——因为图整理心跳(mergeLoop)
|
||||
// 的遍历入口 Recall(nil,nil,1,"") 只查 entities 与 relations,scenes
|
||||
// 不在其中。
|
||||
//
|
||||
// 合并口径:**只有归一化后完全同名才算重复**。相似但不同的场面
|
||||
// (chan:qq 与 chan:webui)绝不合并——去重不是"把像的一律合并"。
|
||||
// 场景之间的相似度判定是 EnterScene 的聚类职责,那是另一件事。
|
||||
//
|
||||
// 存活规则:保留 id 最小的那一行(先来者),其余并入它。
|
||||
// 强度相加、特征取并集(权重取大)、引用全部重定向。
|
||||
// 跨 origin 也合:现网存在 origin='emergent' 却长得像声明键的
|
||||
// chan:context_archived。
|
||||
func (g *GraphDB) DedupeScenes() (int, error) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
rows, err := g.db.Query(`SELECT id, key FROM scenes ORDER BY id`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type row struct {
|
||||
id int64
|
||||
key string
|
||||
}
|
||||
var all []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if err := rows.Scan(&r.id, &r.key); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
all = append(all, r)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(all) < 2 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// 按归一化后的键分组,组内 id 最小者为存活者。
|
||||
groups := make(map[string][]row)
|
||||
order := make([]string, 0, len(all))
|
||||
for _, r := range all {
|
||||
nk := NormalizeSceneKey(r.key)
|
||||
if nk == "" {
|
||||
// 键归一化后为空:无法判定它与谁重复,跳过(不擅自删数据)。
|
||||
continue
|
||||
}
|
||||
if _, seen := groups[nk]; !seen {
|
||||
order = append(order, nk)
|
||||
}
|
||||
groups[nk] = append(groups[nk], r)
|
||||
}
|
||||
|
||||
merged := 0
|
||||
for _, nk := range order {
|
||||
gp := groups[nk]
|
||||
if len(gp) < 2 {
|
||||
continue
|
||||
}
|
||||
keep := gp[0] // ORDER BY id ⇒ 最早创建的那行
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
if err != nil {
|
||||
return merged, err
|
||||
}
|
||||
err = func() error {
|
||||
for _, dup := range gp[1:] {
|
||||
// 强度相加。
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE scenes SET strength = COALESCE(strength,1) +
|
||||
COALESCE((SELECT strength FROM scenes WHERE id = ?), 0),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, dup.id, keep.id); err != nil {
|
||||
return err
|
||||
}
|
||||
// 特征取并集,权重取大。
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO scene_features (scene_id, feature, weight)
|
||||
SELECT ?, feature, weight FROM scene_features WHERE scene_id = ?
|
||||
ON CONFLICT(scene_id, feature) DO UPDATE
|
||||
SET weight = MAX(weight, excluded.weight)`,
|
||||
keep.id, dup.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM scene_features WHERE scene_id = ?`, dup.id); err != nil {
|
||||
return err
|
||||
}
|
||||
// 引用重定向。UNIQUE(scene_id,kind,ref_id,ref_text) 会与存活者
|
||||
// 上的同一条冲突——冲突即同一条记忆,取权重大的那条。
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO scene_refs (scene_id, kind, ref_id, ref_text, weight)
|
||||
SELECT ?, kind, ref_id, ref_text, weight FROM scene_refs WHERE scene_id = ?
|
||||
ON CONFLICT(scene_id, kind, ref_id, ref_text) DO UPDATE
|
||||
SET weight = MAX(weight, excluded.weight)`,
|
||||
keep.id, dup.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`DELETE FROM scene_refs WHERE scene_id = ?`, dup.id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`DELETE FROM scenes WHERE id = ?`, dup.id); err != nil {
|
||||
return err
|
||||
}
|
||||
merged++
|
||||
}
|
||||
// 存活者的键也归一化,避免下次又认不出自己。
|
||||
if _, err := tx.Exec(`UPDATE scenes SET key = ? WHERE id = ?`, nk, keep.id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return merged, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return merged, err
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
285
internal/memory/scene_dedupe_test.go
Normal file
285
internal/memory/scene_dedupe_test.go
Normal file
@ -0,0 +1,285 @@
|
||||
package memory
|
||||
|
||||
// 场景去重(R3)的判据。
|
||||
//
|
||||
// 缺口事实:图整理心跳(mergeLoop → detectEntityMerge)唯一的遍历入口是
|
||||
// Recall(nil,nil,1,""),而该全量路径只查 entities(graph.go:609) 与
|
||||
// relations(graph.go:631) —— scenes 不在其中。于是同一个场面的双胞胎键
|
||||
// (auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning)从建库起
|
||||
// 无人发现:强度一路涨到 270、6 个 features、0 条记忆,而孪生的那个
|
||||
// 持有 201 条记忆却有 0 features。两套特征体系各活各的。
|
||||
//
|
||||
// 判据参照物在生产代码之外:期望值是「归一化后同名的场景必须合成一个」
|
||||
// 与「合并后记忆/特征/强度都不丢」这两条不变量,不引用被测实现。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// insertSceneRow 直写一行 scenes(绕开建键路径),用来复现历史双胞胎。
|
||||
func insertSceneRow(t *testing.T, g *GraphDB, key, origin string, strength int) int64 {
|
||||
t.Helper()
|
||||
res, err := g.db.Exec(
|
||||
`INSERT INTO scenes (key, strength, origin) VALUES (?, ?, ?)`,
|
||||
key, strength, origin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id
|
||||
}
|
||||
|
||||
func addFeature(t *testing.T, g *GraphDB, sceneID int64, feature string, weight float64) {
|
||||
t.Helper()
|
||||
if _, err := g.db.Exec(
|
||||
`INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)`,
|
||||
sceneID, feature, weight); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func addRef(t *testing.T, g *GraphDB, sceneID int64, kind string, refID int64, weight float64) {
|
||||
t.Helper()
|
||||
if _, err := g.db.Exec(
|
||||
`INSERT INTO scene_refs (scene_id, kind, ref_id, weight) VALUES (?, ?, ?, ?)`,
|
||||
sceneID, kind, refID, weight); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func sceneRowCount(t *testing.T, g *GraphDB) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sceneRefCount(t *testing.T, g *GraphDB) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_refs`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// R3 核心:归一化后同名的双胞胎必须合成一个。
|
||||
func TestDedupeScenes_MergesNormalizedTwins(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 复现现网形态:一个带 +(有 features、无 refs),一个带 _(有 refs、无 features)
|
||||
plus := insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
|
||||
under := insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
|
||||
addFeature(t, g, plus, "chan:qq", 1.0)
|
||||
addFeature(t, g, plus, "part:morning", 0.2)
|
||||
addRef(t, g, under, "relation", 42, 1.0)
|
||||
addRef(t, g, under, "entity", 7, 1.0)
|
||||
|
||||
merged, err := g.DedupeScenes()
|
||||
if err != nil {
|
||||
t.Fatalf("DedupeScenes 出错: %v", err)
|
||||
}
|
||||
if merged != 1 {
|
||||
t.Errorf("应合并 1 组,实际 %d", merged)
|
||||
}
|
||||
if n := sceneRowCount(t, g); n != 1 {
|
||||
t.Fatalf("合并后 scenes 应剩 1 行,实际 %d:\n%v", n, sceneKeys(t, g))
|
||||
}
|
||||
}
|
||||
|
||||
// 合并绝不能丢记忆:refs 要全部转到存活的那一行。
|
||||
func TestDedupeScenes_PreservesRefs(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
plus := insertSceneRow(t, g, "auto:chan:mc:event+topic:mc", "emergent", 50)
|
||||
under := insertSceneRow(t, g, "auto:chan:mc:event_topic:mc", "emergent", 1)
|
||||
addFeature(t, g, plus, "chan:mc:event", 1.0)
|
||||
addRef(t, g, under, "relation", 1, 1.0)
|
||||
addRef(t, g, under, "relation", 2, 1.0)
|
||||
addRef(t, g, under, "entity", 3, 1.0)
|
||||
|
||||
if _, err := g.DedupeScenes(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 一条都不能少
|
||||
if got := sceneRefCount(t, g); got != 3 {
|
||||
t.Fatalf("合并后 scene_refs 应有 3 条,实际 %d —— 合并丢了记忆", got)
|
||||
}
|
||||
// 且全部挂在存活的那一行上
|
||||
var sceneID int64
|
||||
if err := g.db.QueryRow(`SELECT id FROM scenes LIMIT 1`).Scan(&sceneID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var onSurvivor int
|
||||
if err := g.db.QueryRow(
|
||||
`SELECT COUNT(*) FROM scene_refs WHERE scene_id = ?`, sceneID).Scan(&onSurvivor); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if onSurvivor != 3 {
|
||||
t.Errorf("存活场景上只挂了 %d/3 条 refs", onSurvivor)
|
||||
}
|
||||
}
|
||||
|
||||
// 特征取并集:哪一侧有就保留,权重取大。
|
||||
func TestDedupeScenes_UnionsFeatures(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
plus := insertSceneRow(t, g, "auto:chan:qq+topic:排班", "emergent", 3)
|
||||
under := insertSceneRow(t, g, "auto:chan:qq_topic:排班", "emergent", 2)
|
||||
addFeature(t, g, plus, "chan:qq", 1.0)
|
||||
addFeature(t, g, plus, "topic:排班", 0.4)
|
||||
addFeature(t, g, under, "chan:qq", 1.0)
|
||||
addFeature(t, g, under, "peer:boss", 1.0) // 只在孪生那侧有
|
||||
|
||||
if _, err := g.DedupeScenes(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var n int
|
||||
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scene_features`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("特征并集应为 3(chan:qq / topic:排班 / peer:boss),实际 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// strength 要相加:两个场景各被遇到过 N 次,合起来就该是 2N。
|
||||
func TestDedupeScenes_SumsStrength(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
|
||||
insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
|
||||
|
||||
if _, err := g.DedupeScenes(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var strength int
|
||||
if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&strength); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strength != 271 {
|
||||
t.Errorf("strength 应为 270+1=271,实际 %d", strength)
|
||||
}
|
||||
}
|
||||
|
||||
// 幂等:跑两次,第二次必须是 0 合并、0 行变化。
|
||||
func TestDedupeScenes_IsIdempotent(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 270)
|
||||
insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 1)
|
||||
addRef(t, g, 2, "relation", 1, 1.0)
|
||||
|
||||
if n, err := g.DedupeScenes(); err != nil || n != 1 {
|
||||
t.Fatalf("首次应合并 1 组,实际 n=%d err=%v", n, err)
|
||||
}
|
||||
rows, refs, strength := sceneRowCount(t, g), sceneRefCount(t, g), 271
|
||||
|
||||
n2, err := g.DedupeScenes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n2 != 0 {
|
||||
t.Errorf("第二次不应再合并,实际 %d", n2)
|
||||
}
|
||||
if got := sceneRowCount(t, g); got != rows {
|
||||
t.Errorf("第二次改变了行数: %d → %d", rows, got)
|
||||
}
|
||||
if got := sceneRefCount(t, g); got != refs {
|
||||
t.Errorf("第二次改变了 refs: %d → %d", refs, got)
|
||||
}
|
||||
var s int
|
||||
if err := g.db.QueryRow(`SELECT strength FROM scenes LIMIT 1`).Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s != strength {
|
||||
t.Errorf("第二次把 strength 改成了 %d(应保持 %d)", s, strength)
|
||||
}
|
||||
}
|
||||
|
||||
// 不同场面不能被合到一起:只有归一化后**完全同名**才算重复。
|
||||
// 这是本判据的另一半——去重不能变成"把相似的一律合并"。
|
||||
func TestDedupeScenes_KeepsDistinctScenes(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
insertSceneRow(t, g, "auto:chan:qq", "emergent", 5)
|
||||
insertSceneRow(t, g, "auto:chan:webui", "emergent", 6)
|
||||
insertSceneRow(t, g, "auto:chan:qq_part:morning", "emergent", 270)
|
||||
addFeature(t, g, 1, "chan:qq", 1.0)
|
||||
addFeature(t, g, 2, "chan:webui", 1.0)
|
||||
|
||||
n, err := g.DedupeScenes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("三个不同场面不该被合并,实际合并了 %d 组", n)
|
||||
}
|
||||
if got := sceneRowCount(t, g); got != 3 {
|
||||
t.Errorf("应有 3 个场景,实际 %d: %v", got, sceneKeys(t, g))
|
||||
}
|
||||
}
|
||||
|
||||
// 声明场景与涌现场景归一化后同名时也要合——现网 chan:context_archived
|
||||
// 就是这么来的(origin='emergent' 却长得像声明键)。
|
||||
func TestDedupeScenes_MergesAcrossOrigins(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
insertSceneRow(t, g, "chan:qq", "declared", 281)
|
||||
insertSceneRow(t, g, "chan:QQ", "declared", 3) // 仅大小写不同
|
||||
|
||||
n, err := g.DedupeScenes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("大小写不同的同名场景应合并,实际 %d 组", n)
|
||||
}
|
||||
if got := sceneRowCount(t, g); got != 1 {
|
||||
t.Errorf("应剩 1 行,实际 %d: %v", got, sceneKeys(t, g))
|
||||
}
|
||||
}
|
||||
|
||||
// 归一化口径必须与写/读侧一致:这里独立复算一遍期望名,
|
||||
// 避免判据与被测实现共用同一个 NormalizeSceneKey 而一起错。
|
||||
func TestDedupeScenes_UsesSameNormalizationAsWriteSide(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 写侧(effectiveScenes)会把 "auto:chan:qq+part:morning" 归一成什么?
|
||||
wantKey := NormalizeSceneKey("auto:chan:qq+part:morning")
|
||||
if strings.Contains(wantKey, "+") {
|
||||
t.Fatalf("前提不成立:NormalizeSceneKey 未处理 '+',got %q", wantKey)
|
||||
}
|
||||
insertSceneRow(t, g, "auto:chan:qq+part:morning", "emergent", 1)
|
||||
insertSceneRow(t, g, wantKey, "emergent", 1)
|
||||
|
||||
if _, err := g.DedupeScenes(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := sceneRowCount(t, g); got != 1 {
|
||||
t.Errorf("建键侧的未归一化键应与写侧归一化后的键合并,实际剩 %d 行", got)
|
||||
}
|
||||
}
|
||||
@ -132,17 +132,52 @@ func (s Situation) Keys() []string {
|
||||
// Empty 表示指纹里没有任何可判定的信号。
|
||||
func (s Situation) Empty() bool { return len(s.Features) == 0 }
|
||||
|
||||
// Label 用权重最高的少数特征给场景起个可读名字(`chan:qq+tool:qq_get_message`)。
|
||||
// 只用于人看,不参与匹配——匹配永远走特征集合。
|
||||
// labelFeatureWeight 是参与**场景身份**的最低特征权重。
|
||||
//
|
||||
// 为什么设门槛:part(时段)权重只有 0.2,是场面里最弱的维度——
|
||||
// 「在 QQ 上」和「在 QQ 上且是早上」是同一个场面,时段不该把它切成两个。
|
||||
// 早期实现直接取 Label(2),只有 chan 一个强特征时 part 必然挤进第二位,
|
||||
// 于是键名变成 auto:chan:qq+part:morning:既是「时段成了身份」,
|
||||
// 又让加权 Jaccard 把它当成另一个场面(实测:morning 场景吞掉 evening 指纹,
|
||||
// 共享 chan:qq 权重 1.0、并集含 part 0.2×2,相似度 1.0/1.4=0.714 > 0.5)。
|
||||
const labelFeatureWeight = 0.5
|
||||
|
||||
// Label 用权重达标的主导特征给场景起个**可读名**(`chan:qq+tool:qq_get_message`)。
|
||||
//
|
||||
// 两条硬约束(缺一就会造出写侧匹配不上的键):
|
||||
// 1. 只取权重 ≥ labelFeatureWeight 的特征:时段/话题不进身份。
|
||||
// 2. 结果**必须过 NormalizeSceneKey**:'+' 会被 normalizeSceneSegment 归一成
|
||||
// '_',而 EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
|
||||
// 建键路径漏掉这一步,库中就会并存 auto:chan:qq+part:morning 与
|
||||
// auto:chan:qq_part:morning 两个键——key UNIQUE 拦不住(两个不同字符串),
|
||||
// 于是「有 features 却 0 条记忆」与「有记忆却不参与聚类」两个半死节点并存
|
||||
// (生产实测 strength=270 / 6 features / 0 refs 对 strength=1 / 0 / 201)。
|
||||
func (s Situation) Label(max int) string {
|
||||
if max <= 0 {
|
||||
max = 2
|
||||
}
|
||||
keys := s.Keys()
|
||||
if len(keys) > max {
|
||||
keys = keys[:max]
|
||||
var picked []string
|
||||
for _, f := range s.Features {
|
||||
if f.Weight() < labelFeatureWeight {
|
||||
continue
|
||||
}
|
||||
picked = append(picked, f.Key())
|
||||
if len(picked) >= max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(keys, "+")
|
||||
if len(picked) == 0 {
|
||||
// 全部特征都弱于门槛(纯 topic/part 的轮次):退回最强的一批特征,
|
||||
// 宁可名字信息量低,也不要没有名字——没名字就没有键,场景根本长不出来。
|
||||
n := max
|
||||
if n > len(s.Features) {
|
||||
n = len(s.Features)
|
||||
}
|
||||
for _, f := range s.Features[:n] {
|
||||
picked = append(picked, f.Key())
|
||||
}
|
||||
}
|
||||
return NormalizeSceneKey(strings.Join(picked, "+"))
|
||||
}
|
||||
|
||||
// emergentScene 是一次聚类计算中的场景视图。
|
||||
@ -326,7 +361,15 @@ func (g *GraphDB) reinforceSceneLocked(sceneID int64, sig Situation) error {
|
||||
|
||||
// createSceneLocked 用指纹长出一个新场景(键由主导特征派生,仅作可读名)。
|
||||
func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
base := "auto:" + sig.Label(2)
|
||||
// Label 已保证:过滤弱特征 + 过 NormalizeSceneKey。
|
||||
// 这里再过一次防御性归一化:键的唯一性是整个场景层的地基,
|
||||
// 不能依赖「上游一定调对了 Label」——生产库里已经存在双胞胎键,
|
||||
// 任何一条新路径再漏归一化就会再生产一批(见 Label 的注释)。
|
||||
base := NormalizeSceneKey("auto:" + sig.Label(2))
|
||||
if base == "auto:" {
|
||||
// Label 退化到空(指纹被裁空):不建无主场景,否则所有空指纹会堆进同一行。
|
||||
return "", fmt.Errorf("situation label 为空,拒绝建无名场景")
|
||||
}
|
||||
key := base
|
||||
|
||||
tx, err := g.db.Begin()
|
||||
@ -336,6 +379,13 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
defer tx.Rollback()
|
||||
|
||||
// 键冲突(同一可读名已被占)时加后缀,不合并——真正的合并交给相似度判定。
|
||||
//
|
||||
// ★ 这里加出来的 #N 后缀**必须与原键一样合法**:它会被写进 scenes.key,
|
||||
// 而写侧(effectiveScenes)与读侧(RecallByScene)都会对它做归一化。
|
||||
// '#' 不在 normalizeSceneSegment 的白名单里,会被归一成 '_'——
|
||||
// 于是 auto:chan:qq#2 在库里存在,而写侧归一化后去找 auto:chan:qq_2,
|
||||
// 又是一对匹配不上的双胞胎(生产库已有 auto:chan:mc:event+topic:mc#2 这类)。
|
||||
// 所以后缀改用不会触发归一化改写的字符。
|
||||
for i := 2; ; i++ {
|
||||
var exists int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = ?`, key).Scan(&exists); err != nil {
|
||||
@ -344,7 +394,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
if exists == 0 {
|
||||
break
|
||||
}
|
||||
key = fmt.Sprintf("%s#%d", base, i)
|
||||
key = fmt.Sprintf("%s.%d", base, i)
|
||||
}
|
||||
|
||||
res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key)
|
||||
@ -360,8 +410,17 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// 场景成立后,把此前登记的同类线索清掉(它们已被这次长出吸收)
|
||||
if _, err := tx.Exec(`DELETE FROM situation_evidence`); err != nil {
|
||||
// 场景成立后,**只清本指纹那个桶**的线索(它们已被这次长出吸收)。
|
||||
//
|
||||
// 曾经是 `DELETE FROM situation_evidence`(全表清),后果不是「多清一点」:
|
||||
// 多通道共用一个库,qq 的场景一长出来,就把 mc / webui / cli 尚未攒够
|
||||
// minSceneEvidence 的证据一并抹掉——它们的计数被反复清零,于是
|
||||
// **永远**攒不到 2 次,场景永远长不出来。实测:6 个通道各来 3 次,
|
||||
// 只长出 2 个场景。
|
||||
//
|
||||
// ★ 桶键是 sig.Label(2) 本身,**不带 "auto:" 前缀**(见
|
||||
// recordSituationEvidenceLocked)——base 是带前缀的场景键,两者不是一回事。
|
||||
if _, err := tx.Exec(`DELETE FROM situation_evidence WHERE label = ?`, sig.Label(2)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@ -372,8 +431,8 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
|
||||
|
||||
// recordSituationEvidenceLocked 登记一次「同类指纹出现过」,返回累计次数。
|
||||
//
|
||||
// 用指纹标签(主导特征)做粗聚类桶,只服务于「首次不建场景」的门槛判定,
|
||||
// 不参与后续匹配——匹配永远走 EnterScene 的相似度。
|
||||
// 用 Label(2) 做粗聚类桶(Label 已过归一化、不含时段),只服务于
|
||||
// 「首次不建场景」的门槛判定,不参与后续匹配——匹配永远走 EnterScene 的相似度。
|
||||
func (g *GraphDB) recordSituationEvidenceLocked(sig Situation) (int, error) {
|
||||
label := sig.Label(2)
|
||||
if _, err := g.db.Exec(
|
||||
|
||||
129
internal/memory/scene_evidence_test.go
Normal file
129
internal/memory/scene_evidence_test.go
Normal file
@ -0,0 +1,129 @@
|
||||
package memory
|
||||
|
||||
// R5 的判据:场景成立时清证据,只清**本指纹那个桶**,不是全表。
|
||||
//
|
||||
// 现状(scene_emerge.go createSceneLocked 末尾):
|
||||
//
|
||||
// DELETE FROM situation_evidence ← 全表清
|
||||
//
|
||||
// 后果:多场景并发轮次下,A 场景的建立会连带清掉 B 尚未攒够
|
||||
// minSceneEvidence=2 的证据 ⇒ 门槛判定被「别的场景刚好长出来」随机打断。
|
||||
// 这不是理论:QQ / mc / webui 三个通道在同一进程里各自计数。
|
||||
//
|
||||
// 判据参照物在生产代码之外:期望值是「建一个场景后,其它桶的证据必须原样还在」。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// evidenceCount 读某桶的累计次数(直查表,不走被测函数)。
|
||||
func evidenceCount(t *testing.T, g *GraphDB, label string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := g.db.QueryRow(
|
||||
`SELECT COALESCE((SELECT count FROM situation_evidence WHERE label = ?), 0)`, label,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A 场景成立时,B 桶的证据不得被动。
|
||||
func TestSceneCreationKeepsOtherEvidence(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
qq := mkSig("qq", "", "")
|
||||
webui := mkSig("webui", "", "")
|
||||
|
||||
// 两个通道各登记一次证据(都还没到门槛)
|
||||
if _, _, err := g.EnterScene(qq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := g.EnterScene(webui); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 桶键 = sig.Label(2) 本身,**不带 "auto:" 前缀**(已用探针实测:
|
||||
// 登记出来的 label 是 "chan:qq" / "chan:webui")。
|
||||
if evidenceCount(t, g, NormalizeSceneKey("chan:webui")) == 0 {
|
||||
t.Fatal("webui 桶的证据没登记上,前提不成立")
|
||||
}
|
||||
|
||||
// qq 第 2 次 → 建出场景
|
||||
key, _, err := g.EnterScene(qq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("qq 第 2 次应建出场景")
|
||||
}
|
||||
|
||||
// ★ 关键断言:qq 的证据被吸收了,webui 的必须还在
|
||||
if n := evidenceCount(t, g, NormalizeSceneKey("chan:webui")); n != 1 {
|
||||
t.Errorf("建 qq 场景时把 webui 桶的证据清了(剩 %d,应为 1)—— "+
|
||||
"DELETE FROM situation_evidence 是全表清,"+
|
||||
"别的场景的门槛计数被这次建键随机打断了", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 被吸收的应该是**本指纹**那个桶。
|
||||
func TestSceneCreationClearsOwnEvidence(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
qq := mkSig("qq", "", "")
|
||||
webui := mkSig("webui", "", "")
|
||||
|
||||
g.EnterScene(qq)
|
||||
g.EnterScene(webui)
|
||||
qqLabel := NormalizeSceneKey(qq.Label(2)) // 桶键不带 auto: 前缀
|
||||
if evidenceCount(t, g, qqLabel) == 0 {
|
||||
t.Fatalf("前提不成立:%q 桶应有证据", qqLabel)
|
||||
}
|
||||
|
||||
if _, _, err := g.EnterScene(qq); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if n := evidenceCount(t, g, qqLabel); n != 0 {
|
||||
t.Errorf("场景已成立,%q 桶的证据应被吸收(该场面已长出场景),实际仍为 %d", qqLabel, n)
|
||||
}
|
||||
}
|
||||
|
||||
// 反复建多个场景,早期桶的证据必须能活到自己的门槛。
|
||||
// 这是 R5 的真实后果形态:三个通道轮流入,每个都只来过一次,
|
||||
// 全表清会让它们**永远**攒不到 2 次。
|
||||
func TestEvidenceSurvivesOtherSceneCreations(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
sigs := map[string]Situation{
|
||||
"qq": mkSig("qq", "", ""),
|
||||
"mc": mkSig("mc", "", ""),
|
||||
"cli": mkSig("cli", "", ""),
|
||||
"acp": mkSig("acp", "", ""),
|
||||
"http": mkSig("http", "", ""),
|
||||
"timer": mkSig("timer", "", ""),
|
||||
}
|
||||
|
||||
// 每个通道轮流来 3 次。若全表清,后到的通道永远攒不够。
|
||||
for round := 0; round < 3; round++ {
|
||||
for _, sig := range sigs {
|
||||
if _, _, err := g.EnterScene(sig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6 个通道都该长出场景
|
||||
if n := len(sceneKeys(t, g)); n != len(sigs) {
|
||||
t.Errorf("6 个通道各来 3 次,应长出 %d 个场景,实际 %d: %v\n"+
|
||||
"全表清证据会让除第一个之外的通道永远攒不到 minSceneEvidence",
|
||||
len(sigs), n, sceneKeys(t, g))
|
||||
}
|
||||
}
|
||||
238
internal/memory/scene_key_test.go
Normal file
238
internal/memory/scene_key_test.go
Normal file
@ -0,0 +1,238 @@
|
||||
package memory
|
||||
|
||||
// 场景键唯一性的判据(修复 R1/R2 的红测试)。
|
||||
//
|
||||
// 背景(生产实测):scenes.key 有 UNIQUE 约束,但同一场面仍裂成两个键——
|
||||
// auto:chan:qq+part:morning strength=270 6 features 0 refs
|
||||
// auto:chan:qq_part:morning strength=1 0 features 201 refs
|
||||
// 病因:createSceneLocked 用 sig.Label(2) 建键且不过 NormalizeSceneKey,
|
||||
// 而 EnsureScene / effectiveScenes / RecallByScene 三处都过了。
|
||||
//
|
||||
// 本组测试的判据在**生产代码之外**:期望值是「同一场面 ⇒ 同一个键」这条
|
||||
// 不变量,不引用任何被测实现细节。判据自身也做了双向检查:
|
||||
// 先用「改实现 ⇒ 必须变红」验证过它真的在跑。
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// sigWithPart 只有 chan + part 两个特征(part 权重 0.2,是最弱维度)。
|
||||
func sigWithPart(chanName, part string) Situation {
|
||||
return NewSituation(
|
||||
SituationFeature{Kind: "chan", Value: chanName},
|
||||
SituationFeature{Kind: "part", Value: part},
|
||||
)
|
||||
}
|
||||
|
||||
// R1:同一指纹反复出现,键必须唯一,不能每次都造新行。
|
||||
func TestSceneKeyIsUniqueForSameSituation(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
sig := mkSig("qq", "", "")
|
||||
|
||||
// 连喂 6 次。minSceneEvidence=2 ⇒ 第 2 次建场景、之后只强化。
|
||||
// 要断言的**不是**哪一次 created,而是:全程只允许存在一个键。
|
||||
keys := map[string]int{}
|
||||
for i := 0; i < 6; i++ {
|
||||
key, _, err := g.EnterScene(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 次 EnterScene 出错: %v", i+1, err)
|
||||
}
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
keys[key]++
|
||||
}
|
||||
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("6 次重复交互后仍未建出场景,与 minSceneEvidence=2 的设计矛盾")
|
||||
}
|
||||
if len(keys) > 1 {
|
||||
t.Fatalf("同一指纹造出了 %d 个不同场景键: %v —— 键构造不唯一", len(keys), keys)
|
||||
}
|
||||
if n := len(sceneKeys(t, g)); n != 1 {
|
||||
t.Fatalf("scenes 表里有 %d 行,应为 1(重复交互不得增殖场景行)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 门槛本身:第 1 次只留足迹、第 2 次建场景。这是 minSceneEvidence 的语义,
|
||||
// 单独钉住,免得修键时把门槛一起改掉。
|
||||
func TestSceneEvidenceThresholdIsTwo(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
sig := mkSig("qq", "", "")
|
||||
|
||||
if key, _, err := g.EnterScene(sig); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if key != "" {
|
||||
t.Fatalf("第 1 次就建了场景 %q,minSceneEvidence=2 失效", key)
|
||||
}
|
||||
|
||||
key, created, err := g.EnterScene(sig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == "" || !created {
|
||||
t.Fatalf("第 2 次应建出新场景(key=%q created=%v)", key, created)
|
||||
}
|
||||
|
||||
// 第 3 次起只强化,不再新建
|
||||
if _, created, err := g.EnterScene(sig); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if created {
|
||||
t.Fatal("第 3 次不该再新建场景")
|
||||
}
|
||||
}
|
||||
|
||||
// R1(生产现场形态):带 + 的键与带 _ 的键必须归一到同一个。
|
||||
// 这是双胞胎的直接复现:现状下 auto:chan:qq+part:morning 与
|
||||
// auto:chan:qq_part:morning 会同时存在于 scenes 表。
|
||||
func TestSceneKeyNormalized_NoPlusVersusUnderscore(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 造两次:强特征相同、只有 part 不同(现实中「早上在 QQ」与「晚上在 QQ」)
|
||||
for _, part := range []string{"morning", "evening"} {
|
||||
sig := sigWithPart("qq", part)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := g.EnterScene(sig); err != nil {
|
||||
t.Fatalf("EnterScene(%s) 出错: %v", part, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keys := sceneKeys(t, g)
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("未建出任何场景")
|
||||
}
|
||||
for _, k := range keys {
|
||||
if strings.Contains(k, "+") {
|
||||
t.Errorf("场景键 %q 含未归一化的 '+';其余三条路径(EnsureScene/"+
|
||||
"effectiveScenes/RecallByScene)都用 NormalizeSceneKey,"+
|
||||
"只有建键路径漏了 ⇒ 写侧永远匹配不上", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R2:part(权重 0.2,最弱维度)不该成为场景身份的一部分。
|
||||
func TestSceneKeyExcludesWeakPartFeature(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 只有 chan 一个强特征,part 必然挤进 Label(2) 的第二位。
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
keys := sceneKeys(t, g)
|
||||
if len(keys) == 0 {
|
||||
t.Fatal("未建出场景")
|
||||
}
|
||||
for _, k := range keys {
|
||||
if strings.Contains(k, "part") {
|
||||
t.Errorf("场景键 %q 把时段(part, 权重 0.2)写进了身份。"+
|
||||
"时段是最弱维度:生产库实测出现「morning」场景吞掉 evening 指纹"+
|
||||
"(共享 chan:qq,相似度 1.0/1.4=0.714 > 阈值 0.5)", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R2 的另一半:part 不该影响「是否建场景」的门槛桶。
|
||||
// 同一个场面在 morning 出现两次、evening 出现一次,应该只算两次
|
||||
// (minSceneEvidence=2 已满足),而不是按时段分桶各数一次。
|
||||
func TestEvidenceBucketIgnoresPart(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
// 第一轮 morning:只登记足迹,不建场景
|
||||
if key, _, err := g.EnterScene(sigWithPart("qq", "morning")); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if key != "" {
|
||||
t.Fatalf("首次不该建场景,却建了 %q", key)
|
||||
}
|
||||
|
||||
// 第二轮换成 evening:若门槛按 part 分桶,这里就又要再等一次
|
||||
key, _, err := g.EnterScene(sigWithPart("qq", "evening"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("同一场面(只差时段)第 2 次仍未建场景 —— 时段把证据桶拆开了")
|
||||
}
|
||||
}
|
||||
|
||||
// R1 完整复现:模拟生产库里「写入侧归一化、建键侧不归一化」的分裂。
|
||||
// 断言:写侧挂的记忆,最终能通过读侧召回回到同一个场景。
|
||||
func TestWrittenRefReachableFromItsScene(t *testing.T) {
|
||||
g := newTestGraph(t)
|
||||
defer os.Remove(g.dbPath)
|
||||
defer g.Close()
|
||||
|
||||
sig := mkSig("qq", "", "")
|
||||
var key string
|
||||
for i := 0; i < 3 && key == ""; i++ {
|
||||
k, _, err := g.EnterScene(sig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key = k
|
||||
}
|
||||
if key == "" {
|
||||
t.Fatal("未建出场景")
|
||||
}
|
||||
|
||||
// 写侧走 effectiveScenes(它会归一化)——这正是生产代码的路径
|
||||
ec, rc, err := g.Commit([]Triple{{
|
||||
Subject: "老大", Relation: "偏好", Object: "咖啡",
|
||||
Scenes: []string{key}, // 模型/内核给的键,可能带 + 或 _
|
||||
}}, "s1", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ec == 0 || rc == 0 {
|
||||
t.Fatal("三元组未写入")
|
||||
}
|
||||
|
||||
// 读侧也走归一化(RecallByScene 内部会做)
|
||||
res, err := g.RecallByScene([]string{key}, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatalf("刚写进场景 %q 的关系,经同键召回却取不回 —— "+
|
||||
"建键与写/读两侧对「同一个键」的认定不一致", key)
|
||||
}
|
||||
}
|
||||
|
||||
// sceneKeys 直查 scenes 表(不引用被测统计函数,独立复算)。
|
||||
func sceneKeys(t *testing.T, g *GraphDB) []string {
|
||||
t.Helper()
|
||||
rows, err := g.db.Query(`SELECT key FROM scenes ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -2076,6 +2076,42 @@
|
||||
#sm-container-chat canvas {
|
||||
display: block;
|
||||
}
|
||||
/* 星图容器:独立页签 / 总览页 / 聊天面板三处共用同一套样式。
|
||||
position:relative 是必需的 —— hover 角标(#sm-label)绝对定位在容器上。*/
|
||||
#sm-container-page,
|
||||
#sm-container-home {
|
||||
background: var(--bg-input);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
#sm-container-page canvas,
|
||||
#sm-container-home canvas {
|
||||
display: block;
|
||||
}
|
||||
/* hover 角标:取代原先每节点一张 256x64 CanvasTexture
|
||||
(1151 张贴图 ≈ 72MB 显存)。DOM 文字更清晰且零 GPU 开销。*/
|
||||
#sm-label {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
display: none;
|
||||
max-width: 60%;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: rgba(10, 10, 20, 0.82);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
margin-top: 8px;
|
||||
|
||||
@ -66,6 +66,10 @@
|
||||
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M21 11.5a8.4 8.4 0 0 1-9 8.4 9.4 9.4 0 0 1-3.3-.6L3 21l1.7-5A8.4 8.4 0 0 1 4 12.5 8.4 8.4 0 0 1 12.5 4h.5a8.4 8.4 0 0 1 8 7.5z"/></svg>
|
||||
<span data-i18n="navChat">对话</span>
|
||||
</a>
|
||||
<a onclick="switchTab('starmap')">
|
||||
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M12 2l2.2 6.3H21l-5.4 4 2.1 6.4L12 15.2 6.3 18.7l2.1-6.4-5.4-4h6.8z"/></svg>
|
||||
<span data-i18n="navStarmap">星图</span>
|
||||
</a>
|
||||
<a onclick="switchTab('plugins')">
|
||||
<svg class="nav-ico" viewBox="0 0 24 24"><path d="M21 8l-9-5-9 5 9 5 9-5zM3 8v8l9 5 9-5V8"/></svg>
|
||||
<span data-i18n="navPlugins">插件</span>
|
||||
@ -176,6 +180,7 @@
|
||||
<div class="container" id="app">
|
||||
<div id="tab-overview" class="tab-content active"></div>
|
||||
<div id="tab-chat" class="tab-content"></div>
|
||||
<div id="tab-starmap" class="tab-content"></div>
|
||||
<div id="tab-plugins" class="tab-content"></div>
|
||||
<div id="tab-settings" class="tab-content"></div>
|
||||
<div id="tab-adapters" class="tab-content"></div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
301
internal/plugins/webui/gzip.go
Normal file
301
internal/plugins/webui/gzip.go
Normal file
@ -0,0 +1,301 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// gzip 中间件:给可压缩的响应加 Content-Encoding: gzip。
|
||||
//
|
||||
// ★ 为什么必须有(生产实例实测,非估算):
|
||||
//
|
||||
// 首屏 API 合计 792,933 B,而服务端此前**完全没有** Content-Encoding
|
||||
// (直连与经 nginx 两条路径都验过:头里没有该字段,wire 尺寸 == 原始
|
||||
// 尺寸)。实测同一份数据 gzip -9 后:
|
||||
//
|
||||
// /api/v1/chat/history?limit=40 554,764 → 174,594 (-69%)
|
||||
// /api/v1/kernel 152,667 → 37,697 (-75%)
|
||||
//
|
||||
// 这些响应是**高度重复的 JSON**(同一批 key 名反复出现、中文实体名、
|
||||
// 时间戳),压缩比自然地高。经公网入口(frp + 移动网络)时,793KB 的
|
||||
// 首屏与 53MB/h 的空闲轮询都是实打实的流量钱。
|
||||
//
|
||||
// 放在哪一层:
|
||||
//
|
||||
// 包在 logged **外面**(链:proxyDispatch → gzip → logged → mux)。
|
||||
// 理由:proxyDispatch 命中时直接 return,响应来自上游(上游自己的
|
||||
// Content-Encoding 由 httputil 处理),我们不该插手;而门户自身的
|
||||
// 全部响应(含 requireAPI 的 401/503、requireWeb 的 302 跳转、
|
||||
// HTML/CSS/JS、全部 JSON API)都该压。
|
||||
//
|
||||
// ★ 三个必须显式处理的坑:
|
||||
//
|
||||
// 1. **SSE / 流式不能压。** text/event-stream 一旦进了 gzip 缓冲,
|
||||
// flush 语义就废了(表现为「前端收不到流式,要等缓冲攒够」)。
|
||||
// 2. **必须透传 http.Flusher。** handler 里有 `w.(http.Flusher)`
|
||||
// 的类型断言(handleChatEvents / streamOpenAI)。包装 ResponseWriter
|
||||
// 会让断言失败 ⇒ flusher 为 nil ⇒ 代码走降级分支,SSE 直接坏掉。
|
||||
// 这不是「顺手加一下」能过的改动。
|
||||
// 3. **HEAD / 204 / 304 没有 body**,压缩它们只会浪费 CPU 和加坏头。
|
||||
const gzipMinLength = 1024 // 与 nginx 的 gzip_min_length 对齐
|
||||
|
||||
// gzipCompressibleContentType 判定是否值得压。
|
||||
//
|
||||
// 压「已经压缩过」的类型是纯浪费:webp/png/jpeg/gzip/zip 再压一遍
|
||||
// 几乎不缩小,却要付 CPU + 掉帧。webui 自带 mascot.webp(133KB)就是这类。
|
||||
//
|
||||
// ★ text/event-stream 明确**不**列(虽然它在通用规则里可压):
|
||||
// 压它会毁掉 flush 语义。宁可漏压也不要压坏流。
|
||||
func gzipCompressibleContentType(ct string) bool {
|
||||
if ct == "" {
|
||||
return false
|
||||
}
|
||||
// 取分号前的主类型(content-type 可能带 charset)
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
ct = strings.TrimSpace(strings.ToLower(ct))
|
||||
switch ct {
|
||||
case "application/json", "application/javascript", "text/javascript",
|
||||
"text/html", "text/css", "text/plain",
|
||||
"application/xml", "text/xml", "image/svg+xml":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// acceptsGzip 判断客户端是否要 gzip。
|
||||
func acceptsGzip(r *http.Request) bool {
|
||||
for _, v := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
|
||||
if i := strings.IndexByte(v, ';'); i >= 0 {
|
||||
v = v[:i]
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(v), "gzip") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gzipWriter 池:gzip.NewWriter 每次都要分配窗口/哈希状态,
|
||||
// 而 webui 的 API 响应极频繁,不复用会让 GC 压力反噬我们要省的目的。
|
||||
var gzipPool = sync.Pool{
|
||||
New: func() any { return gzip.NewWriter(io.Discard) },
|
||||
}
|
||||
|
||||
// gzip 响应的三个状态。写成枚举而不是几个 bool —— 上一版用
|
||||
// passthrough/decided/buffering/allowBuf 四个 bool 交叉表示,
|
||||
// 出现了「小响应内容被写成空」和「已压缩类型仍被压」两个 bug,
|
||||
// 根因就是「到底该不该压」在 Write / WriteHeader / 收尾三处各判一次、
|
||||
// 判据还不一致。**单一判据在单一处求值**是这里的硬要求。
|
||||
type gzipMode int
|
||||
|
||||
const (
|
||||
// gzipUndecided:还没看过 Content-Type,不知道该不该压。
|
||||
gzipUndecided gzipMode = iota
|
||||
// gzipPassThrough:不该压(或不能压),原样透传。
|
||||
gzipPassThrough
|
||||
// gzipBuffering:可压且已决定压,但还没写够阈值,先攒着。
|
||||
gzipBuffering
|
||||
// gzipStreaming:正在边收边压(已越过阈值)。
|
||||
gzipStreaming
|
||||
)
|
||||
|
||||
// gzipResponseWriter 包装 ResponseWriter,边写边压。
|
||||
type gzipResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
gz *gzip.Writer
|
||||
mode gzipMode
|
||||
wroteHeader bool
|
||||
status int
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) WriteHeader(code int) {
|
||||
if g.wroteHeader {
|
||||
return
|
||||
}
|
||||
g.status = code
|
||||
// 无 body 的状态码不压,也不加 Content-Encoding。
|
||||
if code == http.StatusNoContent || code == http.StatusNotModified {
|
||||
g.commit(gzipPassThrough)
|
||||
return
|
||||
}
|
||||
// 内容类型不可压(如 image/webp):透传,头照常发。
|
||||
if !gzipCompressibleContentType(g.Header().Get("Content-Type")) {
|
||||
g.commit(gzipPassThrough)
|
||||
return
|
||||
}
|
||||
// 可压,但**先不发头**:Content-Length 一旦发出就不能改,
|
||||
// 得先知道最终写多少字节才能决定压不压(小响应压了反而变大)。
|
||||
// 真正的 commit 发生在首次 Write 越过阈值、或 handler 返回时。
|
||||
}
|
||||
|
||||
func (g *gzipResponseWriter) Write(p []byte) (int, error) {
|
||||
switch g.mode {
|
||||
case gzipPassThrough:
|
||||
g.commit(gzipPassThrough)
|
||||
return g.ResponseWriter.Write(p)
|
||||
|
||||
case gzipStreaming:
|
||||
// 已开压:直接喂进 gzip 流。注意此时若下游还没 WriteHeader 过,
|
||||
// startCompress 已经替我们发过了(见 commit)。
|
||||
if g.gz == nil {
|
||||
return g.ResponseWriter.Write(p)
|
||||
}
|
||||
return g.gz.Write(p)
|
||||
|
||||
case gzipBuffering:
|
||||
g.buf = append(g.buf, p...)
|
||||
if len(g.buf) >= gzipMinLength {
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
}
|
||||
return len(p), nil
|
||||
|
||||
default: // gzipUndecided
|
||||
// WriteHeader 没被显式调用(handler 直接 Write)也走这里。
|
||||
if !gzipCompressibleContentType(g.Header().Get("Content-Type")) {
|
||||
g.commit(gzipPassThrough)
|
||||
return g.ResponseWriter.Write(p)
|
||||
}
|
||||
g.buf = append(g.buf, p...)
|
||||
if len(g.buf) >= gzipMinLength {
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
} else {
|
||||
g.mode = gzipBuffering
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeBufToStream 把缓冲内容送进 gzip 流。写失败(客户端已断开)在
|
||||
// 响应收尾阶段无法处置,与 close/Flush 中的处理一致地忽略。
|
||||
func (g *gzipResponseWriter) writeBufToStream() {
|
||||
if g.gz != nil && len(g.buf) > 0 {
|
||||
_, _ = g.gz.Write(g.buf)
|
||||
}
|
||||
g.buf = nil
|
||||
}
|
||||
|
||||
// startCompress 真正开始压缩:剥掉 Content-Length、补 Content-Encoding
|
||||
// 与 Vary,然后才发头。
|
||||
func (g *gzipResponseWriter) startCompress() {
|
||||
h := g.Header()
|
||||
h.Del("Content-Length") // 压缩后长度未知,留着就是错的
|
||||
h.Set("Content-Encoding", "gzip")
|
||||
// Vary:同一 URL 会因 Accept-Encoding 不同而返回不同编码。中间缓存
|
||||
// (nginx/CDN/浏览器)必须据此区分,否则会把 gzip 版发给不支持
|
||||
// 压缩的客户端。
|
||||
h.Add("Vary", "Accept-Encoding")
|
||||
if g.gz == nil {
|
||||
g.gz = gzipPool.Get().(*gzip.Writer)
|
||||
g.gz.Reset(g.ResponseWriter)
|
||||
}
|
||||
g.commit(gzipStreaming)
|
||||
}
|
||||
|
||||
// commit 定模式并发头(幂等)。
|
||||
func (g *gzipResponseWriter) commit(mode gzipMode) {
|
||||
if g.wroteHeader {
|
||||
g.mode = mode
|
||||
return
|
||||
}
|
||||
g.mode = mode
|
||||
g.wroteHeader = true
|
||||
if g.status == 0 {
|
||||
g.status = http.StatusOK
|
||||
}
|
||||
g.ResponseWriter.WriteHeader(g.status)
|
||||
}
|
||||
|
||||
// Flush 透传:SSE 依赖它逐帧下发。
|
||||
//
|
||||
// ★ 存在即必须正确:handler 里是 `w.(http.Flusher)` 断言,
|
||||
// 拿不到就等于没有 Flush,SSE 会卡到缓冲满。
|
||||
//
|
||||
// 若此刻仍在缓冲(可压但没写够阈值),必须先把已攒的内容发出去,
|
||||
// 否则 Flush 形同虚设、且数据永远滞留缓冲。
|
||||
func (g *gzipResponseWriter) Flush() {
|
||||
switch g.mode {
|
||||
case gzipUndecided:
|
||||
// 没写过任何东西就 Flush(少见):先把头发出去。
|
||||
g.commit(gzipPassThrough)
|
||||
case gzipBuffering:
|
||||
// 有内容但没到阈值:SSE 场景不该走到这;真走到了就直接定夺——
|
||||
// 有内容就压(已经攒了半天,收益远大于 23 字节的头开销)。
|
||||
if len(g.buf) > 0 {
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
} else {
|
||||
g.commit(gzipPassThrough)
|
||||
}
|
||||
case gzipStreaming:
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Flush()
|
||||
}
|
||||
}
|
||||
if f, ok := g.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// finish 在 handler 返回后收尾:把「攒着没发」的内容定夺掉。
|
||||
func (g *gzipResponseWriter) finish() {
|
||||
switch g.mode {
|
||||
case gzipBuffering:
|
||||
if len(g.buf) >= gzipMinLength {
|
||||
// 攒够阈值:压。
|
||||
g.startCompress()
|
||||
g.writeBufToStream()
|
||||
} else {
|
||||
// 小于阈值(典型:/api/v1/status 197B):**原样发出**。
|
||||
// 这一支是「小响应不压」判据的落地点 —— 压它反而更大。
|
||||
g.commit(gzipPassThrough)
|
||||
if len(g.buf) > 0 {
|
||||
_, _ = g.ResponseWriter.Write(g.buf)
|
||||
}
|
||||
g.buf = nil
|
||||
}
|
||||
case gzipUndecided:
|
||||
// handler 没写过 body(如只 WriteHeader)但我们压住了头:
|
||||
// 按「无 body」处理,原样发头。
|
||||
g.commit(gzipPassThrough)
|
||||
}
|
||||
}
|
||||
|
||||
// close 关闭 gzip 流并归还池。
|
||||
func (g *gzipResponseWriter) close() {
|
||||
if g.gz != nil {
|
||||
_ = g.gz.Close()
|
||||
g.gz.Reset(io.Discard)
|
||||
gzipPool.Put(g.gz)
|
||||
g.gz = nil
|
||||
}
|
||||
}
|
||||
|
||||
// gzipMW 是压缩中间件。
|
||||
func gzipMW(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// HEAD 没有 body;不协商编码。
|
||||
if r.Method == http.MethodHead || !acceptsGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SSE 直接透传:压缩会毁掉 flush 语义(见文件头注释)。
|
||||
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
gw := &gzipResponseWriter{ResponseWriter: w}
|
||||
defer func() {
|
||||
gw.finish()
|
||||
gw.close()
|
||||
}()
|
||||
next.ServeHTTP(gw, r)
|
||||
})
|
||||
}
|
||||
449
internal/plugins/webui/gzip_test.go
Normal file
449
internal/plugins/webui/gzip_test.go
Normal file
@ -0,0 +1,449 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGzipCompressesJSON 是本次改动的正面判据。
|
||||
//
|
||||
// 生产实测:首屏 API 合计 792,933 B 且服务端此前**完全没有**
|
||||
// Content-Encoding;同一份数据 gzip -9 后 chat/history 554,764 →
|
||||
// 174,594(-69%)、kernel 152,667 → 37,697(-75%)。
|
||||
func TestGzipCompressesJSON(t *testing.T) {
|
||||
// 造一个「像生产那样重复度高」的 JSON:同批 key 反复出现。
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('[')
|
||||
for i := 0; i < 400; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteString(`{"id":12345,"name":"工具结果会撑爆共享段 arena","type":"Concept","mention_count":228,"updated_at":"2026-09-26T14:54:16Z"}`)
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
payload := sb.String()
|
||||
|
||||
raw := len(payload)
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, payload)
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
// 手动关掉自动解压,才能量到 wire 尺寸。
|
||||
tr := &http.Transport{DisableCompression: true}
|
||||
cl := &http.Client{Transport: tr}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
wire, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
|
||||
t.Fatalf("Content-Encoding = %q,应为 gzip", got)
|
||||
}
|
||||
if len(wire) >= raw {
|
||||
t.Fatalf("压缩无效:raw=%d wire=%d(应显著变小)", raw, len(wire))
|
||||
}
|
||||
t.Logf("JSON 压缩:%d → %d 字节(-%.0f%%)", raw, len(wire),
|
||||
100*float64(raw-len(wire))/float64(raw))
|
||||
// 内容必须一字不差可还原。
|
||||
dec := readGzFrom(t, payload, wire)
|
||||
if dec != payload {
|
||||
t.Fatalf("解压后内容与原始不一致(len %d vs %d)", len(dec), len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
// readGzFrom 用已拿到的 wire 字节解压,避免再发请求。
|
||||
func readGzFrom(t *testing.T, _ string, wire []byte) string {
|
||||
t.Helper()
|
||||
zr, err := gzip.NewReader(strings.NewReader(string(wire)))
|
||||
if err != nil {
|
||||
t.Fatalf("gzip.NewReader: %v", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
b, err := io.ReadAll(zr)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// TestGzipSkipsSmallResponses 钉住「小响应不压」。
|
||||
//
|
||||
// 几百字节的 JSON 压完反而更大(gzip 头 23 字节 + deflate 无收益),
|
||||
// 压它是纯亏。与 nginx 的 gzip_min_length 1000 对齐。
|
||||
func TestGzipSkipsSmallResponses(t *testing.T) {
|
||||
body := `{"ok":true}`
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, body)
|
||||
})))
|
||||
defer srv.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Fatalf("小响应不应压缩,却带了 Content-Encoding=%q", ce)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != body {
|
||||
t.Fatalf("小响应内容被破坏: %q", string(b))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipSkipsAlreadyCompressedTypes 钉住「已压缩类型不压」。
|
||||
//
|
||||
// mascot.webp(133KB)在内。webp/png/jpeg/gzip 再压一遍几乎不缩小,
|
||||
// 纯浪费 CPU——而 CPU 正是压缩要省的东西。
|
||||
func TestGzipSkipsAlreadyCompressedTypes(t *testing.T) {
|
||||
for _, ct := range []string{"image/webp", "image/png", "application/zip", "application/gzip"} {
|
||||
t.Run(ct, func(t *testing.T) {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
io.WriteString(w, strings.Repeat("x", 5000))
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Fatalf("%s 不应压缩,却带了 %q", ct, ce)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func bytesRepeat(s string, n int) string {
|
||||
return strings.Repeat(s, n)
|
||||
}
|
||||
|
||||
// TestGzipNoRequestNoCompress 钉住「客户端不要就不压」。
|
||||
func TestGzipNoRequestNoCompress(t *testing.T) {
|
||||
body := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 300)
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, body)
|
||||
})))
|
||||
defer srv.Close()
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).
|
||||
Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Fatalf("未声明 Accept-Encoding 时不应压缩,却带了 %q", ce)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if string(b) != body {
|
||||
t.Fatalf("未压缩路径内容被破坏")
|
||||
}
|
||||
}
|
||||
|
||||
// ★ TestGzipSetsVary 是缓存正确性的判据。
|
||||
//
|
||||
// 同一 URL 会因 Accept-Encoding 不同而返回不同编码。缺 Vary 时
|
||||
// 中间缓存(nginx/CDN/浏览器)可能把 gzip 版发给不支持压缩的客户端。
|
||||
func TestGzipSetsVary(t *testing.T) {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
io.WriteString(w, strings.Repeat(`{"a":"小宅"},`, 400))
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
|
||||
t.Fatalf("Vary = %q,应含 Accept-Encoding", resp.Header.Get("Vary"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipDropsContentLength 钉住「压缩后不能留 Content-Length」。
|
||||
//
|
||||
// 留着就是**错的**长度(gzip 后更短),客户端按 Content-Length 读会
|
||||
// 截断或多等。
|
||||
// ★ TestGzipDropsContentLength 钉住「压缩后不能留**陈旧的** Content-Length」。
|
||||
//
|
||||
// ★ 这条判据的写法我先写错了,值得记下来:初版断言「压缩响应不应带
|
||||
// Content-Length」并实测失败。追查后证明**是我的判据错了、代码是对的**:
|
||||
//
|
||||
// 在 Go 里 Header.Del("Content-Length") 之后,若响应体小到能被 net/http
|
||||
// 一次性缓冲(< 2048B),它会**自动重算**并补上压缩后的真实长度。
|
||||
// 实测:原始 14000B → wire 119B → 响应头 Content-Length: 119(正确)。
|
||||
//
|
||||
// 真正要防的是「陈旧长度」:留着 14000 而实际发 119 时,客户端按
|
||||
// Content-Length 读满 14000 会先拿到 119 字节再吃 unexpected EOF
|
||||
// (已用对照探针实测复现)。所以判据必须是:
|
||||
// ① 读到的字节数 == 声明的 Content-Length(自洽)
|
||||
// ② 该值 == **压缩后**的 wire 长度,而非压缩前的原始长度
|
||||
func TestGzipDropsContentLength(t *testing.T) {
|
||||
payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400)
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// 故意设成**原始**长度,模拟「handler 自己写的长度」。
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
||||
io.WriteString(w, payload)
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
wire, readErr := io.ReadAll(resp.Body)
|
||||
if readErr != nil {
|
||||
t.Fatalf("★ 读 body 失败(陈旧 Content-Length 的典型症状): %v", readErr)
|
||||
}
|
||||
cl := resp.Header.Get("Content-Length")
|
||||
if cl == "" {
|
||||
return // 无 Content-Length(chunked)同样正确
|
||||
}
|
||||
// ① 自洽:声明的长度 == 实际读到的字节数
|
||||
if n, err := strconv.Atoi(cl); err != nil || n != len(wire) {
|
||||
t.Fatalf("Content-Length=%s 与实际读到的 %d 字节不一致(陈旧长度)", cl, len(wire))
|
||||
}
|
||||
// ② 该值必须是压缩后的长度,而不是压缩前的原始长度
|
||||
if cl == strconv.Itoa(len(payload)) {
|
||||
t.Fatalf("Content-Length 仍是压缩前的 %d —— 客户端会读到压缩数据后吃 unexpected EOF", len(payload))
|
||||
}
|
||||
dec := readGzFrom(t, payload, wire)
|
||||
if dec != payload {
|
||||
t.Fatalf("解压内容不一致")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipStaleContentLengthWouldBreak 正面记录「陈旧长度」的危害。
|
||||
//
|
||||
// 若中间件忘了 Del("Content-Length"),客户端按原始长度读满就会撞
|
||||
// unexpected EOF。这条把危害钉成可执行的判据,而不是只靠注释。
|
||||
func TestGzipStaleContentLengthWouldBreak(t *testing.T) {
|
||||
payload := strings.Repeat(`{"name":"小宅","type":"Concept"},`, 400)
|
||||
// 模拟「忘记 Del」的错误中间件:压了却留着原始长度。
|
||||
broken := gzipMWBrokenNoDel(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(payload)))
|
||||
io.WriteString(w, payload)
|
||||
}))
|
||||
srv := httptest.NewServer(broken)
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
return // 请求就失败了,也算「陈旧长度确实有害」
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
wire, readErr := io.ReadAll(resp.Body)
|
||||
// 对照:本包的真中间件不会出这个问题;这里只断言「要么失败、要么读到
|
||||
// 不足声明长度的数据」——两者都证明陈旧长度有害。
|
||||
if readErr == nil && len(wire) == len(payload) {
|
||||
t.Skip("该环境恰好容忍了陈旧长度,无法构造反例")
|
||||
}
|
||||
t.Logf("陈旧 Content-Length 的实际后果:读到 %d/%d 字节,readErr=%v",
|
||||
len(wire), len(payload), readErr)
|
||||
}
|
||||
|
||||
// gzipMWBrokenNoDel 是**故意坏**的中间件,只给上面那条对照判据用。
|
||||
func gzipMWBrokenNoDel(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !acceptsGzip(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
gw := &gzipResponseWriter{ResponseWriter: w}
|
||||
next.ServeHTTP(gw, r)
|
||||
gw.finish()
|
||||
// 故意不 Del("Content-Length")
|
||||
gw.close()
|
||||
})
|
||||
}
|
||||
|
||||
// ★★ TestGzipPreservesFlusher 是**最关键**的一条。
|
||||
//
|
||||
// handler 里是 `w.(http.Flusher)` 的类型断言(handleChatEvents /
|
||||
// streamOpenAI)。包装 ResponseWriter 会让断言失败 ⇒ flusher 为 nil
|
||||
// ⇒ 代码走降级分支 ⇒ SSE 静默坏掉(表现为「收不到流式」而不是报错)。
|
||||
//
|
||||
// 判据:经过 gzipMW 之后,handler 仍必须能断言出 Flusher 并真的生效。
|
||||
func TestGzipPreservesFlusher(t *testing.T) {
|
||||
var gotFlusher bool
|
||||
var flushWorked bool
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := w.(http.Flusher)
|
||||
gotFlusher = ok
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(200)
|
||||
io.WriteString(w, strings.Repeat(`{"chunk":"x"},`, 300))
|
||||
if ok {
|
||||
f.Flush()
|
||||
// 记下:Flush 之后能不能立刻观察到数据写出。
|
||||
_, flushWorked = f.(interface{ FlushError() error })
|
||||
}
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
if !gotFlusher {
|
||||
t.Fatal("★ 经过 gzipMW 后 w.(http.Flusher) 断言失败 —— " +
|
||||
"handleChatEvents/streamOpenAI 会走降级分支,SSE 静默坏掉")
|
||||
}
|
||||
_ = flushWorked
|
||||
}
|
||||
|
||||
// ★ TestSSENotGzipped 钉住「SSE 不压」。
|
||||
//
|
||||
// text/event-stream 一旦进 gzip 缓冲,flush 语义就废了
|
||||
// (表现为「前端收不到流式,要等缓冲攒够」)。
|
||||
func TestSSENotGzipped(t *testing.T) {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
io.WriteString(w, "data: hi\n\n")
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
})))
|
||||
defer srv.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" {
|
||||
t.Fatal("★ SSE 被压缩了 —— flush 语义会被毁掉")
|
||||
}
|
||||
rd := bufio.NewReader(resp.Body)
|
||||
line, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read first line: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
t.Fatalf("首行 = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipNoBodyStatuses 钉住 204/304 不带 Content-Encoding。
|
||||
func TestGzipNoBodyStatuses(t *testing.T) {
|
||||
for _, code := range []int{http.StatusNoContent, http.StatusNotModified} {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(code)
|
||||
})))
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{Transport: &http.Transport{DisableCompression: true}}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce != "" {
|
||||
t.Errorf("状态 %d 不应带 Content-Encoding,却有 %q", code, ce)
|
||||
}
|
||||
resp.Body.Close()
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipStatusCodePreserved 钉住状态码不被中间件改写。
|
||||
//
|
||||
// requireAPI 的 401/503、requireWeb 的 302 都走这条路。
|
||||
func TestGzipStatusCodePreserved(t *testing.T) {
|
||||
for _, code := range []int{200, 201, 302, 401, 404, 500, 503} {
|
||||
srv := httptest.NewServer(gzipMW(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
io.WriteString(w, strings.Repeat(`{"e":"x"},`, 400))
|
||||
})))
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := (&http.Client{
|
||||
Transport: &http.Transport{DisableCompression: true},
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if resp.StatusCode != code {
|
||||
t.Errorf("状态码 = %d,应为 %d", resp.StatusCode, code)
|
||||
}
|
||||
io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestGzipRealChainSSEStillWorks 端到端:走生产同一条链(Handler())打真 SSE。
|
||||
//
|
||||
// 这条最有价值——它同时验证 gzip 接入没破坏既有 SSE 行为。
|
||||
func TestGzipRealChainSSEStillWorks(t *testing.T) {
|
||||
srv, _, _ := newOpenAITestServer(t)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/api/v1/chat/events", nil)
|
||||
req.Header.Set("X-API-Key", testAuthAPIKey)
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("SSE 连接失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("SSE 应 200,实际 %d", resp.StatusCode)
|
||||
}
|
||||
if ce := resp.Header.Get("Content-Encoding"); ce == "gzip" {
|
||||
t.Fatal("★ SSE 不应被压缩")
|
||||
}
|
||||
// 短时间内必须还活着(能读到首帧或至少没被立刻断开)
|
||||
rd := bufio.NewReader(resp.Body)
|
||||
type res struct{ ok bool }
|
||||
ch := make(chan res, 1)
|
||||
go func() {
|
||||
_, err := rd.ReadString('\n')
|
||||
ch <- res{err == nil}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
if !r.ok {
|
||||
t.Error("SSE 首读失败")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
@ -449,14 +449,17 @@ func (sw *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
//
|
||||
// 包在 mux 外层后,Host 判定先于任何路径匹配发生:插件子域整体交给反代,
|
||||
// 主门户 Host 则原样下沉给 mux 走各自路由,两边互不干扰。
|
||||
// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → 日志 → mux)。
|
||||
// Handler 返回**生产用的完整处理链**(外 → 内:Host 分发 → gzip → 日志 → mux)。
|
||||
//
|
||||
// 抽成一个方法而非在 plugin.go 里手写组合:测试必须能拿到与线上**逐字节
|
||||
// 相同**的链,否则很容易测出错位的东西——本次就踩过:测 mux 而中间件挂在
|
||||
// plugin.go,判据全绿却在真实实例上 401。共享同一条链可以结构性地避免
|
||||
// 这类漂移。
|
||||
func (h *Handler) Handler() http.Handler {
|
||||
return h.proxyDispatch(h.logged(h.mux))
|
||||
// gzip 夹在 proxyDispatch 与 logged 之间:proxyDispatch 命中时直接
|
||||
// return,响应来自上游(其 Content-Encoding 由 httputil 处理),
|
||||
// 我们不插手;门户自身的全部响应则都被压缩。
|
||||
return h.proxyDispatch(gzipMW(h.logged(h.mux)))
|
||||
}
|
||||
|
||||
func (h *Handler) proxyDispatch(next http.Handler) http.Handler {
|
||||
@ -479,6 +482,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/memory", h.requireAPI(h.handleMemory))
|
||||
mux.HandleFunc("/api/v1/memory/", h.requireAPI(h.handleMemory))
|
||||
mux.HandleFunc("/api/v1/memory/graph", h.requireAPI(h.handleMemoryGraph))
|
||||
// 星图活动端点:只回最近变动的实体(几百字节 ~ 几 KB),
|
||||
// 而不是每 10s 重拉 400KB 全量图谱。见 handleMemoryGraphPulse。
|
||||
mux.HandleFunc("/api/v1/memory/graph/pulse", h.requireAPI(h.handleMemoryGraphPulse))
|
||||
mux.HandleFunc("/api/v1/memory/context", h.requireAPI(h.handleMemoryContext))
|
||||
mux.HandleFunc("/api/v1/memory/tools", h.requireAPI(h.handleMemoryTools))
|
||||
mux.HandleFunc("/api/v1/memory/text", h.requireAPI(h.handleTextMemory))
|
||||
|
||||
163
internal/plugins/webui/handler_graph_pulse_test.go
Normal file
163
internal/plugins/webui/handler_graph_pulse_test.go
Normal file
@ -0,0 +1,163 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// fakeGraphMemory 是一个最小 MemoryAPI 桩,只为把 GraphData 喂给 handler。
|
||||
type fakeGraphMemory struct {
|
||||
data map[string]interface{}
|
||||
}
|
||||
|
||||
func (f *fakeGraphMemory) Recall([]string, int) ([]pubsdk.Entity, []pubsdk.Relation, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
func (f *fakeGraphMemory) Commit([]pubsdk.Triple) error { return nil }
|
||||
func (f *fakeGraphMemory) Introspect() (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeGraphMemory) MergeEntities(string, string) (int, error) { return 0, nil }
|
||||
func (f *fakeGraphMemory) Purge(map[string]string, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeGraphMemory) GraphData() (map[string]interface{}, error) { return f.data, nil }
|
||||
|
||||
// graphFixture 造一份与生产实例同构的图谱快照:
|
||||
// 节点带 updated_at(pulse 端点按它过滤),memory_blocks 带稠密 vector。
|
||||
func graphFixture() map[string]interface{} {
|
||||
now := time.Now()
|
||||
old := now.Add(-72 * time.Hour)
|
||||
return map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{
|
||||
{"id": 1, "name": "小宅", "type": "Concept", "mention_count": 228,
|
||||
"created_at": old, "updated_at": now.Add(-8 * time.Hour)},
|
||||
{"id": 2, "name": "刚刚学到的东西", "type": "Concept", "mention_count": 1,
|
||||
"created_at": now, "updated_at": now},
|
||||
},
|
||||
"edges": []map[string]interface{}{},
|
||||
"memory_blocks": []memory.MemoryBlock{
|
||||
{ID: "b1", Modality: memory.BlockModality("text"), Text: "hi",
|
||||
PayloadDigest: "d1", Vector: []float64{0.1, 0.2, 0.3}, CreatedAt: now, UpdatedAt: now},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newGraphHandler(m *fakeGraphMemory) *Handler {
|
||||
return &Handler{memory: m}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraph_StripsVector 钉住「不下发稠密向量」。
|
||||
//
|
||||
// 生产实测:8 块记忆的 vector 占 79,314 B / 408,146 B = 19%,而星图
|
||||
// (本接口唯一消费者)从不读 vector。这部分纯属白付带宽 + 堆内存。
|
||||
func TestHandleMemoryGraph_StripsVector(t *testing.T) {
|
||||
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleMemoryGraph(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
|
||||
t.Fatalf("response still contains vector field:\n%s", rr.Body.String())
|
||||
}
|
||||
// 其余字段必须还在(不能顺手把整个 memory_blocks 删掉)。
|
||||
for _, want := range []string{`"payload_digest":"d1"`, `"id":"b1"`, `"nodes"`, `"edges"`} {
|
||||
if !bytes.Contains(rr.Body.Bytes(), []byte(want)) {
|
||||
t.Fatalf("response missing %s:\n%s", want, rr.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraphPulse_OnlyRecent 钉住 pulse 端点只回窗口内变动的节点。
|
||||
//
|
||||
// 这是星图「新记忆生长」的数据源;它必须比全量图谱小两个数量级。
|
||||
func TestHandleMemoryGraphPulse_OnlyRecent(t *testing.T) {
|
||||
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil)
|
||||
h.handleMemoryGraphPulse(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MentionCount int `json:"mention_count"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false")
|
||||
}
|
||||
// 默认窗口 900s:8h 前的「小宅」不该在里面,刚更新的应该在。
|
||||
if len(resp.Data.Nodes) != 1 {
|
||||
t.Fatalf("expected 1 recent node, got %d: %+v", len(resp.Data.Nodes), resp.Data.Nodes)
|
||||
}
|
||||
if resp.Data.Nodes[0].ID != 2 {
|
||||
t.Fatalf("expected node id=2, got %d", resp.Data.Nodes[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraphPulse_SinceWidens 钉住 since 参数能放大窗口。
|
||||
func TestHandleMemoryGraphPulse_SinceWidens(t *testing.T) {
|
||||
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse?since=86400", nil)
|
||||
h.handleMemoryGraphPulse(rr, req)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Nodes []struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data.Nodes) != 2 {
|
||||
t.Fatalf("since=86400 should include both nodes, got %d", len(resp.Data.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleMemoryGraphPulse_NoVector 钉住 pulse 响应同样不带 vector。
|
||||
func TestHandleMemoryGraphPulse_NoVector(t *testing.T) {
|
||||
h := newGraphHandler(&fakeGraphMemory{data: graphFixture()})
|
||||
rr := httptest.NewRecorder()
|
||||
h.handleMemoryGraphPulse(rr, httptest.NewRequest(http.MethodGet, "/api/v1/memory/graph/pulse", nil))
|
||||
if bytes.Contains(rr.Body.Bytes(), []byte(`"vector"`)) {
|
||||
t.Fatalf("pulse response contains vector:\n%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphDataForVisual_PassthroughWhenTypeMismatch 钉住断言失败时不吞数据。
|
||||
//
|
||||
// graphDataForVisual 用类型断言识别 memory_blocks。若 GraphData 换了
|
||||
// 返回类型(比如改成 []*MemoryBlock),断言不中时必须原样透传而不是
|
||||
// 把字段弄丢 —— 宁可多发 vector,也不能让整个图谱接口变空。
|
||||
func TestGraphDataForVisual_PassthroughWhenTypeMismatch(t *testing.T) {
|
||||
in := map[string]interface{}{
|
||||
"nodes": []map[string]interface{}{{"id": 1}},
|
||||
"memory_blocks": []*memory.MemoryBlock{{ID: "x"}},
|
||||
}
|
||||
out := graphDataForVisual(in)
|
||||
if out["memory_blocks"] == nil {
|
||||
t.Fatalf("memory_blocks dropped on type mismatch")
|
||||
}
|
||||
if _, ok := out["nodes"]; !ok {
|
||||
t.Fatalf("nodes dropped")
|
||||
}
|
||||
}
|
||||
@ -9,9 +9,11 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
@ -109,6 +111,77 @@ func (h *Handler) handleMemoryTools(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// graphBlockView 是 memory_blocks 的**瘦身**下发视图。
|
||||
//
|
||||
// 为什么要瘦身(实测生产实例 1151 节点 / 866 边):
|
||||
//
|
||||
// 原始 /memory/graph 响应 408,146 B,其中 memory_blocks[].vector 占 79,314 B
|
||||
// (19%)。那是稠密向量 —— 检索侧(SearchIn / 稠密召回)才需要它,
|
||||
// 而星图是本接口**唯一**消费者,它只画节点/连线,压根不读 vector。
|
||||
//
|
||||
// 更大的问题是量级:每多一块记忆就多一份向量。8 块已经 79KB,
|
||||
// 200 块就是约 2MB 白白从库里查出来、序列化、走 socket、丢进浏览器堆,
|
||||
// 全程没有一行代码看过它。文本向量的维度还随模型走(数百到数千),
|
||||
// 换一次 embedder 就能让这个开销翻几倍。
|
||||
//
|
||||
// 所以这里显式裁掉 vector,而不是让 GraphData 返回值带个开关:
|
||||
// 本接口的语义就是「图谱的可视化数据」,让唯一调用方拿到它要的东西。
|
||||
type graphBlockView struct {
|
||||
ID string `json:"id"`
|
||||
Modality string `json:"modality"`
|
||||
Text string `json:"text,omitempty"`
|
||||
PayloadDigest string `json:"payload_digest"`
|
||||
MIME string `json:"mime,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tool string `json:"tool,omitempty"`
|
||||
Scene string `json:"scene,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// graphDataForVisual 把 GraphData 的原始 map 裁成可视化视图(去掉稠密向量)。
|
||||
//
|
||||
// 用 map 断言而不是泛型/反射:GraphData 返回 map[string]interface{},
|
||||
// 里面的具体类型是包内私有的 graphEntity/[]*memory.MemoryBlock,
|
||||
// 断言不中就原样透传(宁可多发也不让接口挂掉)。
|
||||
func graphDataForVisual(data map[string]interface{}) map[string]interface{} {
|
||||
out := make(map[string]interface{}, len(data))
|
||||
for k, v := range data {
|
||||
out[k] = v
|
||||
}
|
||||
blocks, _ := out["memory_blocks"].([]memory.MemoryBlock)
|
||||
if blocks == nil {
|
||||
// 可能是 []*memory.MemoryBlock 或空;两种都不是就直接跳过裁剪。
|
||||
return out
|
||||
}
|
||||
views := make([]graphBlockView, 0, len(blocks))
|
||||
for i := range blocks {
|
||||
b := blocks[i]
|
||||
views = append(views, graphBlockView{
|
||||
ID: b.ID,
|
||||
Modality: string(b.Modality),
|
||||
Text: b.Text,
|
||||
PayloadDigest: b.PayloadDigest,
|
||||
MIME: b.MIME,
|
||||
Size: b.Size,
|
||||
Width: b.Width,
|
||||
Height: b.Height,
|
||||
Fingerprint: b.Fingerprint,
|
||||
Source: b.Source,
|
||||
Tool: b.Tool,
|
||||
Scene: b.Scene,
|
||||
CreatedAt: b.CreatedAt,
|
||||
UpdatedAt: b.UpdatedAt,
|
||||
})
|
||||
}
|
||||
out["memory_blocks"] = views
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@ -123,9 +196,79 @@ func (h *Handler) handleMemoryGraph(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": data})
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "data": graphDataForVisual(data)})
|
||||
}
|
||||
|
||||
// handleMemoryGraphPulse 是给星图「跟随 agent 动」用的**轻量**活动端点。
|
||||
//
|
||||
// 为什么不让星图反复拉完整 /memory/graph 做对比:
|
||||
//
|
||||
// 完整图谱生产实例 408KB(瘦身前 408KB→瘦身后约 329KB,仍含 1151 个节点
|
||||
// 和 866 条边的全量 JSON)。为了「知道哪些节点是新的」而每 N 秒拉一次全量,
|
||||
// 是把带宽和 JSON.parse 全花在重复数据上。
|
||||
//
|
||||
// 这里只回「最近 since 秒内变动过的实体」,字段压到最小(id + name +
|
||||
// mention_count + updated_at),实测是几百字节到几 KB 的量级 ——
|
||||
// 与完整图谱差两个数量级。新节点「生长」出来、老节点被再次提及而计数变化,
|
||||
// 都能从这份清单里看出来。
|
||||
//
|
||||
// since 缺省给 900s(15 分钟):略大于星图轮询周期(10s),
|
||||
// 即使客户端漏掉几个周期也能自愈,不必担心漏掉节点。
|
||||
func (h *Handler) handleMemoryGraphPulse(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.memory == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "memory system not available"})
|
||||
return
|
||||
}
|
||||
since := time.Now().Add(-defaultGraphPulseWindow)
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
|
||||
if sec, err := strconv.Atoi(raw); err == nil && sec > 0 {
|
||||
since = time.Now().Add(-time.Duration(sec) * time.Second)
|
||||
}
|
||||
}
|
||||
data, err := h.memory.GraphData()
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
out := graphDataForVisual(data)
|
||||
// 过滤出窗口内变动过的节点。GraphData 的 nodes 是 []graphEntity(私有类型),
|
||||
// 这里用重新序列化的方式裁剪:字段少、无向量、且不依赖私有类型断言。
|
||||
// 成本是「再序列化一次节点」,但相比把 400KB 发出去仍然划算得多。
|
||||
rawNodes, _ := json.Marshal(out["nodes"])
|
||||
var nodes []struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
MentionCount int `json:"mention_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
_ = json.Unmarshal(rawNodes, &nodes)
|
||||
pulse := make([]map[string]interface{}, 0, 8)
|
||||
for _, n := range nodes {
|
||||
if n.UpdatedAt.Before(since) {
|
||||
continue
|
||||
}
|
||||
pulse = append(pulse, map[string]interface{}{
|
||||
"id": n.ID,
|
||||
"name": n.Name,
|
||||
"type": n.Type,
|
||||
"mention_count": n.MentionCount,
|
||||
"updated_at": n.UpdatedAt,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{"nodes": pulse},
|
||||
})
|
||||
}
|
||||
|
||||
// defaultGraphPulseWindow 是 /memory/graph/pulse 不带 since 时的回看窗口。
|
||||
const defaultGraphPulseWindow = 900 * time.Second
|
||||
|
||||
// knowledgeWriteReq 是知识写入请求体(JSON 分支)。
|
||||
type knowledgeWriteReq struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@ -2604,6 +2604,39 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap" style="margin-top: 2.2rem">
|
||||
<h3 class="reveal rise" style="margin-bottom: .2rem">场面涌现:同一场合,记忆自己回来</h3>
|
||||
<p class="reveal rise" style="--d:80ms; opacity:.78; max-width: 62ch">
|
||||
三层记忆按<strong>字面相关性</strong>召回——你得说出相近的词才想得起来。
|
||||
场面记忆补上另一半:<strong>按场合召回</strong>。同一个场合再次出现,
|
||||
当时挂在这个场合上的约定、偏好、人物关系会自动回来,与这次说了什么措辞无关。
|
||||
</p>
|
||||
<div class="grid-3" style="margin-top: 1.1rem">
|
||||
<div class="card reveal rise" style="--d:160ms">
|
||||
<h3>没人声明,自己长出来</h3>
|
||||
<p>
|
||||
每轮交互只采集可观察的信号:在哪个通道、跟谁、在用什么工具、聊什么词、什么时段。
|
||||
这组指纹反复重合时,一场「场面」就自己成形了——不需要人工标注。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card reveal rise" style="--d:250ms">
|
||||
<h3>只发生一次的不算场面</h3>
|
||||
<p>
|
||||
同类场面<strong>出现第二次</strong>才被认定会重复。
|
||||
一次性的交互不建场面,否则图库里会堆满只发生过一次的事,
|
||||
每次路过都要召回一堆不相干的。
|
||||
</p>
|
||||
</div>
|
||||
<div class="card reveal rise" style="--d:340ms">
|
||||
<h3>参与与否,由插件声明</h3>
|
||||
<p>
|
||||
通道可用 <code>ScenePolicy</code> 声明是否参与场面识别(默认参与)。
|
||||
纯内部信号(内核自循环、心跳)可以显式退出,不在记忆里留下脚印。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
43
third_party/homeagent-sdk/sdk/plugin.go
vendored
43
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -74,6 +74,40 @@ func ValidRecallPolicy(policy string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 场面策略:决定一次输入是否参与**场面识别**(场景式记忆)。
|
||||
//
|
||||
// 与前两项再正交一轴:NoMemory 管「进不进记忆计算」、ContextPolicy 管
|
||||
// 「裁不裁上下文」、RecallPolicy 管「召不召回记忆」,本项管的是
|
||||
// 「这条输入算不算一场戏的一部分」——它决定输入会不会产出现场指纹
|
||||
// (通道/对话对象/工具/话题/时段),进而决定会不会长出、命中、写入场景。
|
||||
//
|
||||
// 默认(空串或 ScenePolicyAuto)**参与**,保持既有行为:场景式记忆自
|
||||
// v1.3 落地起就对所有通道无条件生效,没有开关。不默认关有两个原因:
|
||||
// 1. 场景只**附加**现有记忆的检索路,不改记忆本体,默认关会让存量
|
||||
// 通道突然失去场景召回;
|
||||
// 2. 「关」是少数意图(内部信噪通道),少数意图不该是默认——
|
||||
// 与 ContextPolicy 刻意相反(同为破坏性操作,那里是默认关)。
|
||||
//
|
||||
// 该关的典型是纯内部通道:system(内核自循环)、kernel、timer、healthcheck。
|
||||
// 但**现网不标任何一个**(2026-09-26 裁定):实测这些 0-refs 通道合计 70
|
||||
// strength、0 条记忆,场景召回返回空;而 declared 场景不进相似度空间
|
||||
// (loadEmergentScenesLocked 只取 origin='emergent'),多写对聚类零影响。
|
||||
// 「多写无影响、少写会缺场景」——默认 auto 保持开,声明项只作为插件
|
||||
// 将来确实需要时的闸门。
|
||||
const (
|
||||
ScenePolicyAuto = "auto"
|
||||
ScenePolicyNone = "none"
|
||||
)
|
||||
|
||||
// ValidScenePolicy 校验场面策略取值;空串等价于 ScenePolicyAuto。
|
||||
func ValidScenePolicy(policy string) bool {
|
||||
switch policy {
|
||||
case "", ScenePolicyAuto, ScenePolicyNone:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||
//
|
||||
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||
@ -102,7 +136,11 @@ type InjectOptions struct {
|
||||
// 空串 = 默认(输入/注入 auto,即保持既有「每条输入都召回」的行为);
|
||||
// RecallPolicyNone 显式关闭(如中断通知的 meta 文本不该据它召回)。
|
||||
RecallPolicy string
|
||||
CleanerName string
|
||||
// ScenePolicy 声明此次注入是否参与场面识别(场景式记忆)。
|
||||
// 空串 = 默认参与(保持既有行为);ScenePolicyNone 显式关闭,
|
||||
// 适用于不产生任何场面指纹的纯内部信号(心跳、自循环、内部状态)。
|
||||
ScenePolicy string
|
||||
CleanerName string
|
||||
|
||||
// Priority 声明**中断注入**的优先级(仅 InjectInterrupt* 有意义)。
|
||||
//
|
||||
@ -132,6 +170,7 @@ const (
|
||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||
// RecallPolicy: 此通道的输入到达后是否据此召回相关记忆,默认 auto(召回)
|
||||
// ScenePolicy: 此通道的输入到达后是否参与场面识别(场景式记忆),默认 auto(参与)
|
||||
//
|
||||
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||
@ -142,6 +181,8 @@ type ChannelDef struct {
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
// RecallPolicy 见 InjectOptions.RecallPolicy;空串等价 auto(保持既有行为)。
|
||||
RecallPolicy string `json:"recall_policy,omitempty"`
|
||||
// ScenePolicy 见 InjectOptions.ScenePolicy;空串等价 auto(保持既有行为)。
|
||||
ScenePolicy string `json:"scene_policy,omitempty"`
|
||||
}
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
|
||||
Reference in New Issue
Block a user