mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
feat(memory): doc 层去掉 jieba 摘要 + doc_query 返回真实内容 + 三层记忆提示词
上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。
## ① 向量输入:Summary + Content → 事件正文
denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。
改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。
★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
(Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。
## ② doc 层移除 jieba 语义生成
- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)
★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
→ 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
可用,jieba 就不参与。
## ③ doc_query 返回真实内容
旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。
新返回:
已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):
[1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
配色方案怎么定?
结论:紫蓝红三色带按六比三比一…
共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。
每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。
## ④ 提示词:agent 终于能理解三层记忆
- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
并在块尾明确引导「要完整原文用 doc_query」
## ⑤ 向量算法版本(否则修复对存量无效)
实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。
修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。
## 判据(每条都有变异自证)
document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)
core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导
变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。
## 验证
- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
· 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
· 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
区分度最高 0.18(旧实现 0.003)
This commit is contained in:
291
docs/zh/memory-usability-plan.md
Normal file
291
docs/zh/memory-usability-plan.md
Normal file
@ -0,0 +1,291 @@
|
||||
# 三层记忆可用性修复计划:统一向量化 + 提示词让 agent 真正理解
|
||||
|
||||
> 状态:**待执行**(2026-10-22)
|
||||
> 前置:`docs/zh/memory-blockflow-repair-plan.md`(L1↔L2 结构同构,已实施)
|
||||
> 本文修的是**上一轮没修的那一层**:结构对了,但检索质量与提示词仍然不可用。
|
||||
|
||||
---
|
||||
|
||||
## 〇、起点:承认上一轮的判断错误
|
||||
|
||||
上一轮我修了 L1↔L2 的结构(事件列表 + 正文进块 + 按原时序回填),
|
||||
然后声称「机制已通」。**结构确实通了,但功能不可用。**
|
||||
|
||||
用生产数据实测(1054 篇文档、chineseclip 512 维)三个互不相关的查询:
|
||||
|
||||
```
|
||||
查询 "doc 层召回是怎么设计的"
|
||||
score=0.7834 来自 4 个来源的 61 条对话 (cold_storage, agentcli...)
|
||||
score=0.7795 来自 1 个来源的 2 条对话 (agent) 涉及: map, content, dsh, 占用
|
||||
score=0.7768 来自 1 个来源的 1 条对话 (agent) 涉及: 自检, OK
|
||||
|
||||
查询 "hmap 插件安装验签"
|
||||
score=0.7922 来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是, pi
|
||||
|
||||
查询 "QQ 头像"
|
||||
score=0.7601 来自 1 个来源的 1 条对话 (agent) 涉及: CQ, qq, ok
|
||||
```
|
||||
|
||||
**分数全挤在 0.75~0.79,区分度≈0,命中内容与查询无关。**
|
||||
|
||||
### 判据缺失(最该记的一条)
|
||||
|
||||
上一轮写了 16 条判据、6 个变异,全部通过。但**没有一条验证「检索结果必须与查询相关」**。
|
||||
所以「全绿」证明的只是我实现了我以为的东西 —— 不是它可用。
|
||||
|
||||
> ★ 教训:**结构判据不能替代质量判据**。
|
||||
> 「字段非空」「条数正确」「不 panic」都可能在检索完全失效时依然全绿。
|
||||
|
||||
---
|
||||
|
||||
## 一、根因(三条,按依赖排序)
|
||||
|
||||
### 根因 1:向量的输入是 jieba 词袋,不是正文
|
||||
|
||||
`internal/memory/document/document.go` — `denseFor`:
|
||||
|
||||
```go
|
||||
if tv, err := s.denseSpace.VectorizeDense(doc.Summary + " " + doc.Content); err == nil {
|
||||
```
|
||||
|
||||
而 `Summary` 由 `summarizeEntries` 生成,后者调用链是:
|
||||
|
||||
```
|
||||
summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
|
||||
extractTags → 同上
|
||||
extractEntities → 同上
|
||||
```
|
||||
|
||||
`TokenizeContentWords`(`internal/memory/cut.go:256`)就是 `jieba.Tag()` 分词 + 停用词过滤,
|
||||
产出形如 `涉及: map, content, 是不是, pi`。
|
||||
|
||||
**这是与设计直接矛盾的**。`ARCHITECTURE.md` L131 / L174 明写向量层级:
|
||||
|
||||
```
|
||||
统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
|
||||
```
|
||||
|
||||
`Summary` 是**主路径的输入**,却由**最末一级的词法分词**产出。
|
||||
全库所有文档的 Summary 因此高度同质(都是「来自 N 个来源的 M 条对话 涉及: …」),
|
||||
余弦相似度自然全挤在高位、无区分度。
|
||||
|
||||
### 根因 2:媒体块没有语义,且不需要单独生成
|
||||
|
||||
设计要求「doc 支持多模态摘要」。当前实现里:
|
||||
|
||||
- `Doc.Summary` 只有文本路径,**媒体块从不参与**
|
||||
- `crossModalMarkdown` 里媒体只渲染成 `[image/png blk_media_xxx]`(`blockLabel`:MIME + 短 ID)
|
||||
- 一篇带 20 张图的文档,摘要里没有「有图」的任何信息
|
||||
|
||||
**但「多模态摘要」这个提法本身是歧路**(用户 2026-10-22 指出):
|
||||
|
||||
> 媒体 doc 块一般是和文本紧邻的,所以对应的文本就足够了
|
||||
|
||||
`Doc.Events[i].Blocks` 里媒体块与文本块**在同一事件内**。媒体块的语义由
|
||||
**同事件的文本**天然覆盖,不需要为每个媒体单独生成描述(那正是 CAS 时代
|
||||
「描述式索引」被拆除的同一个错误)。
|
||||
|
||||
⇒ 正确做法不是「给媒体写摘要」,而是:**向量用事件正文算,媒体自动被覆盖**。
|
||||
|
||||
### 根因 3:提示词与注入文案没有任何引导
|
||||
|
||||
**(a)`doc_query` 的工具描述只有一句话**
|
||||
|
||||
```go
|
||||
toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", ...)
|
||||
```
|
||||
|
||||
逐条对照问题:
|
||||
|
||||
| 应有信息 | 现状 |
|
||||
|---|---|
|
||||
| L2 是什么 | ❌ 只说「文档记忆」,没有定义 |
|
||||
| 何时该用 | ❌ 无 |
|
||||
| 调用后会发生什么 | ❌ 「返回相关文档摘要」**与真实行为不符**(真实是逐条按原时序回填到对话时序) |
|
||||
| 与 `memory_recall` 的分工 | ❌ 无 |
|
||||
|
||||
对比同文件里 `【输出规则】`(6 条)、`【工具执行顺序】`(4 条)的详细程度,
|
||||
`doc_query` 是最敷衍的一个。
|
||||
|
||||
**(b)预填充注入文案没有引导**
|
||||
|
||||
`crossModalMarkdown` 输出:
|
||||
|
||||
```
|
||||
【跨模态相关记忆】
|
||||
[1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
|
||||
```
|
||||
|
||||
**没有一个字说「可以用 doc_query 取全文」**。模型看到这段只能忽略。
|
||||
|
||||
**(c)系统提示词从未解释三层记忆是什么**
|
||||
|
||||
`grep 记忆 internal/agent/core/tooldefs.go` 全量命中里,没有任何一段说明
|
||||
Context / Document / Graph 三者的分工与使用时机。agent 只能从工具名猜。
|
||||
|
||||
---
|
||||
|
||||
## 二、修复步骤(按依赖,每步先写会红的判据)
|
||||
|
||||
### 步骤 1:判据先行 —— 检索质量
|
||||
|
||||
**先写测试,此时必须红。**
|
||||
|
||||
`internal/memory/document/retrieval_quality_test.go`:
|
||||
|
||||
- 构造真实语料(约 50 篇,内容取自生产副本的真实事件正文 + 人工标注的查询-答案对)
|
||||
- 断言:给定查询,**正确文档必须排第一**(top1 准确率 100%,或至少 top3 内)
|
||||
- 断言:互不相关的查询,top1 结果**必须不同**
|
||||
- 断言:正确命中的分数必须**显著高于**非命中(区分度阈值)
|
||||
|
||||
**变异**:把 `denseFor` 改回 `Summary + Content` ⇒ 必须红。
|
||||
**变异**:把正文替换成 jieba 词袋 ⇒ 必须红。
|
||||
|
||||
这一步是整个计划的地基。没有它,后面每一步都只是「我以为改对了」。
|
||||
|
||||
### 步骤 2:向量输入改为正文
|
||||
|
||||
`internal/memory/document/document.go` — `denseFor`:
|
||||
|
||||
```
|
||||
现在:VectorizeDense(doc.Summary + " " + doc.Content)
|
||||
改为:VectorizeDense(事件正文拼接)
|
||||
有 Events → 按事件顺序取 InOut() 的 input + response(文本块内容)
|
||||
无 Events(存量)→ 退回 Content
|
||||
```
|
||||
|
||||
**媒体如何被覆盖**:媒体块与文本块在同一事件内,事件的正文进入向量即覆盖它。
|
||||
不需要为媒体单独编码 —— 若将来要「按图搜图」,那是视觉路(`RecallBlocks`)的职责,
|
||||
已有独立通路,与文本路不冲突。
|
||||
|
||||
### 步骤 3:删掉 jieba 摘要路径
|
||||
|
||||
`Summary` / `Tags` / `Entities` 目前全部由 `ExtractKeywords`(jieba)产出。
|
||||
|
||||
- `Tags`:唯一消费点是 `doc_query` 返回值里的「标签: …」(`toolcall.go:1038`),
|
||||
**是词袋噪声,删除该行**
|
||||
- `Entities`:`Doc.Entities` 无任何非测试读取点(跨包的 `injected.Entities`
|
||||
是 `memory.Entity` 另一个类型,无关),**字段可删**
|
||||
- `Summary`:改为**纯展示**(人工阅读 / 日志),由正文首句截取生成,**不参与向量**
|
||||
|
||||
⚠️ 需先确认:`Doc.Tags` / `Doc.Entities` 是否有 WebUI / SDK 侧读取。
|
||||
(已知 `sdk/memory_impl.go` 的 `Doc` 只有 `ID/Title/Content`,无 Tags/Entities。)
|
||||
|
||||
### 步骤 4:预填充注入正文片段 + 引导
|
||||
|
||||
`crossModalMarkdown` 改为:
|
||||
|
||||
```
|
||||
【历史记忆(按相关度)】
|
||||
[1] 2026-10-12 11:09 · qq · 相关度 0.83
|
||||
紫蓝红三色带的配色参考,老大要求…(正文首 2 行)
|
||||
[2] …(含 2 张图:image/png, image/jpeg)
|
||||
|
||||
这些是**历史**片段(不是当前对话)。要看完整原文,
|
||||
调用 doc_query 把对应事件按原时间戳拉回对话时序。
|
||||
```
|
||||
|
||||
要点:
|
||||
- 去掉「来自 N 个来源的 M 条对话」这类统计描述
|
||||
- 给出**时间 + 来源 + 正文开头**(真实内容,让模型能判断相关性)
|
||||
- 媒体只报数量与类型,不编描述
|
||||
- **明确告知 doc_query 的作用**
|
||||
|
||||
### 步骤 5:`doc_query` 工具描述重写
|
||||
|
||||
按 `memory_*` 的详细度,必须覆盖:
|
||||
|
||||
- L2 是什么(历史事件层,不直接进当前对话)
|
||||
- 何时用(记得办过但想不起细节 / 跨会话跨天 / 需要原始过程而非结论)
|
||||
- 调用后发生什么(**把事件逐条按原时间戳回填到对话时序**,不是返回一段摘要)
|
||||
- 与 `memory_recall` 的分工(图记忆=关系与最新值;doc=整段过程与原文)
|
||||
- 副作用(从 L2 取出,取走后该文档不再在 L2)
|
||||
|
||||
### 步骤 6:系统提示词增加「三层记忆」段落
|
||||
|
||||
在 `buildSystemPrompt` 里加一段,说清三层是什么、怎么用:
|
||||
|
||||
```
|
||||
【三层记忆】记忆分三层,你现在看到的是第一层。
|
||||
- 对话时序(L1):最近的对话,已在你的上下文里。超过窗口的会被移出。
|
||||
- 历史记忆(L2):移出后的事件与原文仍完整保存。自动预填充会给你相关片段;
|
||||
要给看全文/完整过程,用 doc_query —— 它会把那些事件按原时间戳插回对话时序。
|
||||
- 图记忆(L3):关系与事实的长期存储(谁是什么、什么等于什么、最新值是什么)。
|
||||
用 memory_recall 查;用 memory_commit 记。
|
||||
选择:要"原文与过程"→ doc_query;要"关系与当前值"→ memory_recall;
|
||||
要"成文知识"→ knowledge_search。
|
||||
```
|
||||
|
||||
**注意**:这段属于「内核运行时块」(提示词分层规范:内核行为由内核生成),
|
||||
不是工具用法(工具用法归 `description`)。它讲的是**内核的真实执行语义**
|
||||
(回填到时序、自动预填充),符合分层原则。
|
||||
|
||||
---
|
||||
|
||||
## 三、判据清单(每步都要能红)
|
||||
|
||||
| # | 判据 | 变异(必须红) |
|
||||
|---|---|---|
|
||||
| 1 | 检索质量:top1 命中 / 不相关查询 top1 不同 / 分数有区分度 | `denseFor` 改回 `Summary+Content`;或换回 jieba 词袋 |
|
||||
| 2 | 向量输入是正文:断言 `denseFor` 读的是 Events 正文(可用哨兵串) | 正文里埋哨兵,改回 Summary ⇒ 哨兵不出现在向量输入 |
|
||||
| 3 | 摘要不含 jieba 词袋:`Summary` 不出现「来自 N 个来源的 M 条对话」 | 恢复 `summarizeEntries` ⇒ 红 |
|
||||
| 4 | 预填充文案含正文与引导:必须含 `doc_query` 字样 + 正文片段 | 改回旧渲染 ⇒ 红 |
|
||||
| 5 | 工具描述覆盖五项(L2 定义/何时用/调用后行为/分工/副作用) | 删任一项 ⇒ 红 |
|
||||
| 6 | 系统提示词含三层记忆段落且语义与实现一致 | 回填行为改掉而提示词不改 ⇒ 红(跨文件一致性) |
|
||||
|
||||
**判据 6 特别说明**:提示词说「按原时间戳插回时序」这件事,
|
||||
必须与 `restoreDocEvents` 的真实行为绑在一条判据里 ——
|
||||
否则就是「提示词在说谎」,那比没说更糟。
|
||||
|
||||
---
|
||||
|
||||
## 四、禁止事项
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|---|---|
|
||||
| 用 jieba 词袋当向量输入或摘要内容 | 与「统一向量化」直接矛盾;全库同质,检索失效 |
|
||||
| 为媒体块单独生成文本描述 | CAS 时代的「描述式索引」错误;媒体语义由同事件文本覆盖 |
|
||||
| 只改结构不改提示词 | 机制改了模型不知道 = 等于没改 |
|
||||
| 用「字段非空 / 条数正确 / 不 panic」当检索判据 | 检索完全失效时这些也全绿 |
|
||||
| 声称「机制已通」而不给出生产数据的检索质量实测 | 上一轮的教训 |
|
||||
|
||||
---
|
||||
|
||||
## 五、验证
|
||||
|
||||
1. `go test ./... -count=1` 全绿(现有 58 包基线)
|
||||
2. 新判据全绿,且 6 个变异各自被抓住
|
||||
3. **生产副本实测**:用真实数据跑检索,给出 top1/top3 命中率与分数分布
|
||||
(对比修复前的 0.75~0.79 无区分度)
|
||||
4. 生产部署后用真实对话观察:agent 是否在需要时调用 `doc_query`
|
||||
|
||||
---
|
||||
|
||||
## 六、标准流程(本次严格执行)
|
||||
|
||||
上次直接提交 `main` + rebase + amend + 绕过 CI,不符合 `docs/git-branching.md`。
|
||||
这次必须:
|
||||
|
||||
```
|
||||
1. git checkout -b feature/memory-usability main
|
||||
2. 按步骤 1→6 提交(每步独立 commit,先写判据再改)
|
||||
3. git push -u origin feature/memory-usability
|
||||
4. 开 PR
|
||||
5. 等 CI 10 项全部通过
|
||||
6. 合并 main(不绕过必需检查)
|
||||
7. 部署 + 生产验证
|
||||
```
|
||||
|
||||
⚠️ 远端仓库已改名 `JianFeeeee/HologramAgent`(origin 与 gitcode 均已更新)。
|
||||
|
||||
---
|
||||
|
||||
## 七、待确认
|
||||
|
||||
1. **`Doc.Tags` / `Doc.Entities` 是否可删?**
|
||||
我查到非测试读取点只有 `doc_query` 返回里的「标签:」一行(可删)。
|
||||
但需确认 WebUI / 外部插件是否依赖(SDK 的 `Doc` 无这两个字段)。
|
||||
|
||||
2. **判据 1 的语料从哪来?**
|
||||
建议从生产副本抽 50 篇真实文档 + 人工标注 10~15 个查询-答案对。
|
||||
是否允许我把这些真实文本(脱敏后)作为测试 fixture 入库?
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
@ -234,37 +235,142 @@ func fuseCrossModal(textHits, visualHits []CrossModalHit, topK int, cfg CrossMod
|
||||
return merged
|
||||
}
|
||||
|
||||
// crossModalMarkdown 把融合候选渲染成注入上下文的文本。
|
||||
// 文档行给出摘要;媒体行只给 MIME + 短块 ID(不再有生成的描述)。
|
||||
// crossModalMarkdown 把融合候选渲染成注入提示词的文本。
|
||||
//
|
||||
// ## 为什么改
|
||||
//
|
||||
// 旧输出是这样的:
|
||||
//
|
||||
// 【跨模态相关记忆】
|
||||
// [1] 来自 4 个来源的 61 条对话 (cold_storage, agentcli...) (来源:context_archived) 相关度:0.78(文本命中)
|
||||
//
|
||||
// 三个问题,都被生产日志证实:
|
||||
//
|
||||
// 1. 只有**统计描述**(几个来源几条对话),不是内容 —— 模型无法判断相关性;
|
||||
// 2. **没有一个字**说"你可以把这些原文取回来" —— 模型看完只能忽略;
|
||||
// 3. 来源写了 `context_archived` 这种内部标记,对模型无意义。
|
||||
//
|
||||
// 现在每条给出「时间 · 来源 · 正文开头」,并在块尾**明确告知 doc_query 的作用**:
|
||||
// 这才是让预填充真正被用起来的必要条件。
|
||||
func (a *Agent) crossModalMarkdown(hits []CrossModalHit) string {
|
||||
if len(hits) == 0 {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for i, h := range hits {
|
||||
marker := ""
|
||||
switch {
|
||||
case h.DoubleHit:
|
||||
marker = "(图文双命中)"
|
||||
case h.Doc != nil:
|
||||
marker = "(文本命中)"
|
||||
case h.Media != nil:
|
||||
marker = "(视觉命中)"
|
||||
}
|
||||
parts := []string{fmt.Sprintf("[%d]", i+1)}
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("[%d]", i+1))
|
||||
|
||||
// 时间 + 来源:让模型知道"这是什么时候、谁的事"
|
||||
if h.Doc != nil {
|
||||
parts = append(parts, h.Doc.Summary)
|
||||
if h.Doc.Source != "" {
|
||||
parts = append(parts, fmt.Sprintf("(来源:%s)", h.Doc.Source))
|
||||
if h.Doc.HasEvents() {
|
||||
first := h.Doc.Events[0].Timestamp
|
||||
last := h.Doc.Events[len(h.Doc.Events)-1].Timestamp
|
||||
if first.Equal(last) {
|
||||
parts = append(parts, first.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%s→%s",
|
||||
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
|
||||
}
|
||||
// 真实来源(跳过内部归档标记,它对模型无意义)
|
||||
if src := firstRealSource(h.Doc.Events); src != "" {
|
||||
parts = append(parts, src)
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, h.Doc.CreatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
// 正文开头:真实内容,模型可据此判断相关性
|
||||
if h.Doc != nil {
|
||||
if head := docHeadlineForPrompt(h.Doc); head != "" {
|
||||
parts = append(parts, head)
|
||||
}
|
||||
}
|
||||
// 媒体只报类型与数量,不编描述(描述式索引已在 v1.2.0 拆除)
|
||||
if h.Media != nil {
|
||||
if line := blockLabel(h.Media); line != "" {
|
||||
parts = append(parts, line)
|
||||
label := string(h.Media.Modality)
|
||||
if h.Media.MIME != "" {
|
||||
label = h.Media.MIME
|
||||
}
|
||||
parts = append(parts, "["+label+"]")
|
||||
}
|
||||
if h.DoubleHit {
|
||||
parts = append(parts, "(图文双命中)")
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f", h.Fused))
|
||||
lines = append(lines, strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("【历史记忆(L2,自动预填充)】\n")
|
||||
b.WriteString("下面是按语义相似度从历史层挑出的相关片段 —— **不是当前对话**。\n")
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString("\n要其中某条的完整原文与当时过程:用 doc_query,它会把那些事件" +
|
||||
"按原始时间戳插回你的对话时序。")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// firstRealSource 取事件里第一个非内部标记的来源。
|
||||
//
|
||||
// `context_archived` 是内核归档动作的标记,不是"谁说的",对模型无意义。
|
||||
func firstRealSource(events []document.DocEvent) string {
|
||||
for _, ev := range events {
|
||||
if ev.Source != "" && ev.Source != "context_archived" && ev.Source != "cold_storage" {
|
||||
return ev.Source
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
return events[0].Source
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// docHeadlineForPrompt 取文档正文的第一句,作为注入行里的内容提示。
|
||||
//
|
||||
// 不截断成"摘要":这里只是一行提示,完整内容在 L2 里、需要时用 doc_query 取。
|
||||
func docHeadlineForPrompt(d *document.Doc) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
if d.HasEvents() {
|
||||
for _, ev := range d.Events {
|
||||
in, resp, _ := ev.InOut()
|
||||
for _, cand := range []string{in, resp} {
|
||||
if line := firstMeaningfulLineForPrompt(cand); line != "" {
|
||||
return truncateRunesForPrompt(line, 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("相关度:%.2f%s", h.Fused, marker))
|
||||
lines = append(lines, strings.Join(parts, " "))
|
||||
return ""
|
||||
}
|
||||
return "【跨模态相关记忆】\n" + strings.Join(lines, "\n")
|
||||
return truncateRunesForPrompt(firstMeaningfulLineForPrompt(d.Content), 60)
|
||||
}
|
||||
|
||||
func firstMeaningfulLineForPrompt(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
hasWord := false
|
||||
for _, r := range line {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
hasWord = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasWord {
|
||||
return strings.Join(strings.Fields(line), " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateRunesForPrompt(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
192
internal/agent/core/prompt_memory_test.go
Normal file
192
internal/agent/core/prompt_memory_test.go
Normal file
@ -0,0 +1,192 @@
|
||||
package core
|
||||
|
||||
// 三层记忆的**提示词诚实性**判据。
|
||||
//
|
||||
// 这类判据的目的是防止「提示词在说谎」——它比不说更糟:
|
||||
// 模型按提示词行事,而实现不是那样,于是行为与预期系统性错位。
|
||||
//
|
||||
// 生产实测(2026-10-22 日志):agent 几乎不调用 doc_query,即使调用也
|
||||
// 只看返回值、不知道原文已被回填到对话时序。根因是:
|
||||
// · 工具描述只有「查询文档记忆。返回相关文档摘要」一句(且与真实行为不符)
|
||||
// · 系统提示词从未解释 L1/L2/L3 是什么
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
|
||||
)
|
||||
|
||||
// ① doc_query 的描述必须覆盖五项,且与真实行为一致。
|
||||
func TestDocQueryDescriptionCoversRealBehavior(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
desc := findToolDescription(t, a, "doc_query")
|
||||
|
||||
mustHave := []struct {
|
||||
name string
|
||||
sub string
|
||||
}{
|
||||
{"L2 是什么", "历史记忆层"},
|
||||
{"何时该用", "何时用"},
|
||||
{"调用后行为=回填时序", "插回你的对话时序"},
|
||||
{"原始时间戳", "原始时间戳"},
|
||||
{"与 memory_recall 分工", "memory_recall"},
|
||||
{"副作用=消费式取出", "消费式"},
|
||||
}
|
||||
for _, m := range mustHave {
|
||||
if !strings.Contains(desc, m.sub) {
|
||||
t.Errorf("doc_query 描述缺少「%s」(应含 %q):\n%s", m.name, m.sub, desc)
|
||||
}
|
||||
}
|
||||
// 不得再声称"返回摘要"—— 那是旧实现的谎
|
||||
if strings.Contains(desc, "返回相关文档摘要") {
|
||||
t.Error("doc_query 描述仍声称『返回相关文档摘要』,与真实行为(回填原文到时序)不符")
|
||||
}
|
||||
}
|
||||
|
||||
// ② doc_commit 描述必须说清「适合/不适合写什么」,并与另外两层分工。
|
||||
func TestDocCommitDescriptionExplainsScope(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
desc := findToolDescription(t, a, "doc_commit")
|
||||
|
||||
for _, sub := range []string{"适合写什么", "不适合写什么", "memory_commit", "knowledge_create"} {
|
||||
if !strings.Contains(desc, sub) {
|
||||
t.Errorf("doc_commit 描述缺少 %q:\n%s", sub, desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 系统提示词必须有三层记忆段落,且语义与实现一致。
|
||||
//
|
||||
// ★ 这条是**跨文件一致性**判据:提示词说"按原始时间戳插回时序",
|
||||
//
|
||||
// 实现就必须真的那样做。若哪天 restoreDocEvents 被改回造合成事件,
|
||||
// 这里必须红 —— 否则提示词又开始说谎。
|
||||
func TestSystemPromptExplainsThreeLayers(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
// memContext 为空即可:三层记忆段落是内核无条件注入的
|
||||
prompt := a.buildSystemPrompt("", "测试输入")
|
||||
|
||||
for _, sub := range []string{
|
||||
"【三层记忆】",
|
||||
"对话时序(L1",
|
||||
"历史记忆(L2",
|
||||
"图记忆(L3",
|
||||
"doc_query",
|
||||
"memory_recall",
|
||||
} {
|
||||
if !strings.Contains(prompt, sub) {
|
||||
t.Errorf("系统提示词缺少三层记忆段落的 %q", sub)
|
||||
}
|
||||
}
|
||||
// 提示词承诺「按原始时间戳插回」,实现必须真的这么做
|
||||
if !strings.Contains(prompt, "原始时间戳") {
|
||||
t.Error("提示词没有说明 doc_query 会按原始时间戳回填")
|
||||
}
|
||||
if !strings.Contains(prompt, "插回你的对话时序") {
|
||||
t.Error("提示词没有说明 doc_query 把事件插回时序(模型会以为只是返回摘要)")
|
||||
}
|
||||
}
|
||||
|
||||
// ④ doc_commit 不得再有 tags 参数(Doc.Tags 已删除,留着会骗模型)。
|
||||
func TestDocCommitHasNoTagsParam(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
params := findToolParams(t, a, "doc_commit")
|
||||
if _, ok := params["tags"]; ok {
|
||||
t.Error("doc_commit 仍有 tags 参数,但 Doc.Tags 已删除 —— 参数会静默丢弃")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
func findToolDescription(t *testing.T, a *Agent, name string) string {
|
||||
t.Helper()
|
||||
for _, raw := range a.buildToolDefs() {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, ok := m["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if fn["name"] == name {
|
||||
d, _ := fn["description"].(string)
|
||||
return d
|
||||
}
|
||||
}
|
||||
t.Fatalf("工具 %s 不在 buildToolDefs 里", name)
|
||||
return ""
|
||||
}
|
||||
|
||||
func findToolParams(t *testing.T, a *Agent, name string) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, raw := range a.buildToolDefs() {
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fn, ok := m["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if fn["name"] == name {
|
||||
p, _ := fn["parameters"].(map[string]interface{})
|
||||
props, _ := p["properties"].(map[string]interface{})
|
||||
return props
|
||||
}
|
||||
}
|
||||
t.Fatalf("工具 %s 不在 buildToolDefs 里", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ⑤ 预填充注入的文案必须:给真实内容 + 明确引导用 doc_query。
|
||||
//
|
||||
// 断点史:旧文案只给「来自 4 个来源的 61 条对话 相关度:0.78」——
|
||||
// 统计描述,没有任何内容,也没有一个字提示可以取全文。
|
||||
// 生产日志里 agent 从不使用预填充给的信息,直接原因就是这个。
|
||||
//
|
||||
// ★ 用**真实归档路径**(ContextToDoc)构造文档,不手工拼 DocEvent:
|
||||
//
|
||||
// 块 ID 由 entryTextBlockID 派生,手工拼的 ID 不匹配会被判为非本事件块,
|
||||
// 那样测的是"我拼的数据对不对",不是生产过程。
|
||||
func TestPrefillMarkdownCarriesContentAndGuide(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
if _, err := a.docStore.ContextToDoc("context_archived", []document.ContextEntry{{
|
||||
Timestamp: time.Date(2026, 7, 8, 9, 10, 0, 0, time.UTC),
|
||||
Source: "webui",
|
||||
Content: "配色方案怎么定?",
|
||||
Response: "紫蓝红三色带的配色方案按六比三比一,主色紫、辅色蓝、点缀红。",
|
||||
}}, nil, nil, nil, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := a.docStore.RecentDocs(1)[0]
|
||||
|
||||
md := a.crossModalMarkdown([]CrossModalHit{{Doc: doc, DocScore: 0.8, Fused: 0.8}})
|
||||
|
||||
if md == "" {
|
||||
t.Fatal("预填充文案为空")
|
||||
}
|
||||
// 1) 必须给真实内容(正文片段),不是模板摘要
|
||||
if !strings.Contains(md, "紫蓝红") && !strings.Contains(md, "配色方案") {
|
||||
t.Errorf("预填充文案没有真实正文片段:\n%s", md)
|
||||
}
|
||||
// 2) 不得再出现 jieba 模板摘要
|
||||
if strings.Contains(md, "涉及:") {
|
||||
t.Errorf("预填充文案仍是 jieba 模板摘要:\n%s", md)
|
||||
}
|
||||
// 3) 必须有时间
|
||||
if !strings.Contains(md, "2026-07-08") {
|
||||
t.Errorf("预填充文案没有时间:\n%s", md)
|
||||
}
|
||||
// 4) ★ 必须明确引导使用 doc_query
|
||||
if !strings.Contains(md, "doc_query") {
|
||||
t.Errorf("预填充文案没有引导使用 doc_query:\n%s", md)
|
||||
}
|
||||
// 5) 必须说明它来自历史层而非当前对话
|
||||
if !strings.Contains(md, "L2") && !strings.Contains(md, "历史记忆") {
|
||||
t.Errorf("预填充文案没有说明来源层:\n%s", md)
|
||||
}
|
||||
}
|
||||
@ -284,3 +284,73 @@ func TestRecallByDoc_StressManyEvents(t *testing.T) {
|
||||
}
|
||||
t.Logf("回填 %d 条耗时 %v", got, elapsed)
|
||||
}
|
||||
|
||||
// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。
|
||||
//
|
||||
// 断点史(生产日志实测):旧返回值是
|
||||
//
|
||||
// 已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok), #2(...)
|
||||
//
|
||||
// 全 jieba 词袋,零信息量 ⇒ agent 无法判断这笔召回有没有用 ⇒ 几乎不再调用。
|
||||
//
|
||||
// 本判据要求返回值同时具备三件事:
|
||||
// 1. 明确说明原文去了哪里("插回对话时序")
|
||||
// 2. 每条给出时间 + 来源(可判断"这是哪次的事")
|
||||
// 3. 每条给出真实正文片段(可判断"这是不是我要的")
|
||||
func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
|
||||
const sentinel = "紫蓝红三色带按六比三比一"
|
||||
archiveEvents(t, a, []ContextEvent{{
|
||||
Timestamp: time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC),
|
||||
Source: "webui",
|
||||
Input: "配色方案怎么定?",
|
||||
Response: "结论:" + sentinel + ",主色紫、辅色蓝、点缀红。",
|
||||
}})
|
||||
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)},
|
||||
})
|
||||
|
||||
// 1) 必须说明原文去了时序
|
||||
if !strings.Contains(out, "时序") {
|
||||
t.Errorf("返回值没说原文去了哪里:\n%s", out)
|
||||
}
|
||||
// 2) 必须有时间(年月日或月-日)
|
||||
if !strings.Contains(out, "2026-06-07") && !strings.Contains(out, "06-07") {
|
||||
t.Errorf("返回值没有时间信息:\n%s", out)
|
||||
}
|
||||
// 3) 必须有来源
|
||||
if !strings.Contains(out, "webui") {
|
||||
t.Errorf("返回值没有来源信息:\n%s", out)
|
||||
}
|
||||
// 4) ★ 必须有真实正文(否则模型无法判断相关性)
|
||||
if !strings.Contains(out, "六比三比一") {
|
||||
t.Errorf("返回值不含真实正文片段(模型无法判断相关性):\n%s", out)
|
||||
}
|
||||
// 5) 不得回退成 jieba 词袋的模板摘要
|
||||
if strings.Contains(out, "涉及:") {
|
||||
t.Errorf("返回值仍是 jieba 词袋模板摘要:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。
|
||||
func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
|
||||
a := newToolTestAgent(t)
|
||||
if err := a.docStore.Insert(&document.Doc{
|
||||
Summary: "旧格式摘要",
|
||||
Content: "[10:00] legacy: 旧格式文档里的真实正文在这里",
|
||||
Source: "context_archived",
|
||||
Meta: map[string]string{"content_hash": "legacy-2"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := a.executeDocTool(agentAPI.ToolCall{
|
||||
Name: "doc_query",
|
||||
Arguments: map[string]interface{}{"query": "旧格式文档 真实正文", "top_k": float64(3)},
|
||||
})
|
||||
if !strings.Contains(out, "真实正文在这里") {
|
||||
t.Errorf("存量文档召回没有给出正文:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1030,26 +1030,21 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
if len(docs) == 0 {
|
||||
return "未找到相关文档记忆"
|
||||
}
|
||||
var parts []string
|
||||
var refs []string
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("已召回 %d 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):\n", len(docs)))
|
||||
|
||||
totalEvents := 0
|
||||
for i, d := range docs {
|
||||
parts = append(parts, fmt.Sprintf("[%d] %s (来源: %s)", i+1, d.Summary, d.Source))
|
||||
if len(d.Tags) > 0 {
|
||||
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
|
||||
}
|
||||
// ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件
|
||||
// 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件。
|
||||
//
|
||||
// 设计原文(ARCHITECTURE「记忆流转」② Document 层):
|
||||
//
|
||||
// doc_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
//
|
||||
// 此前实现用 d.CreatedAt(归档时刻)造一条 cold_storage 事件、
|
||||
// 正文取 d.Content(渲染视图)再截 2000 字、块完全没回填 ——
|
||||
// 三处都偏离:时间错位、正文二次截断、块两层消失。
|
||||
if n := a.restoreDocEvents(d); n == 0 {
|
||||
// 存量文档(无 Events)降级:整篇渲成一个事件,保持既有行为。
|
||||
// 不伪造成「有原始事件」——那时归档确实只有渲染视图。
|
||||
// ARCHITECTURE「记忆流转」② Document 层:
|
||||
// doc_query → Consume(读取并删除)
|
||||
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
|
||||
n := a.restoreDocEvents(d)
|
||||
if n == 0 {
|
||||
// 存量文档(无 Events)降级:整篇渲成一个事件。
|
||||
// 不伪造成「有原始事件」—— 那时归档确实只有渲染视图。
|
||||
content := d.Content
|
||||
if labels := a.blockLabelsForDoc(d); labels != "" {
|
||||
content = content + "\n关联媒体: " + labels
|
||||
@ -1060,11 +1055,25 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: content,
|
||||
})
|
||||
n = 1
|
||||
}
|
||||
totalEvents += n
|
||||
|
||||
// ★ 每条给出「什么时候 + 谁说的 + 正文开头」,
|
||||
// 让模型**立刻**能判断这笔召回有没有用。
|
||||
//
|
||||
// 旧返回只有「[1] 来自 1 个来源的 1 条对话 (agent) 涉及: ok」——
|
||||
// jieba 词袋,零信息量,模型无法据此判断相关性。
|
||||
b.WriteString(fmt.Sprintf("\n[%d] %s\n", i+1, docRecallHeadline(d, n)))
|
||||
|
||||
// 附上少量真实正文行,供模型即时判断(完整原文在时序里)。
|
||||
if preview := docRecallPreview(d, n, 3); preview != "" {
|
||||
b.WriteString(preview)
|
||||
}
|
||||
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
|
||||
}
|
||||
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)",
|
||||
len(docs), strings.Join(refs, ", "))
|
||||
|
||||
b.WriteString(fmt.Sprintf("\n共回填 %d 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。", totalEvents))
|
||||
return b.String()
|
||||
|
||||
case "doc_commit":
|
||||
content, _ := tc.Arguments["content"].(string)
|
||||
@ -1076,18 +1085,9 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
summary = truncateStr(content, 100)
|
||||
}
|
||||
|
||||
tagsRaw, _ := tc.Arguments["tags"].([]interface{})
|
||||
var tags []string
|
||||
for _, t := range tagsRaw {
|
||||
if s, ok := t.(string); ok {
|
||||
tags = append(tags, s)
|
||||
}
|
||||
}
|
||||
|
||||
doc := &document.Doc{
|
||||
Summary: summary,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
Source: "manual",
|
||||
}
|
||||
|
||||
@ -1324,3 +1324,110 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
|
||||
d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks)
|
||||
return n
|
||||
}
|
||||
|
||||
// docRecallHeadline 生成一条召回结果的首行:「时间跨度 · 来源 · 事件数 · 媒体数」。
|
||||
//
|
||||
// 为什么不用 Doc.Summary:那是 jieba 词袋("来自 N 个来源的 M 条对话 涉及: ok"),
|
||||
// 零信息量。模型看到它无法判断这笔召回有没有用 —— 这正是 agent 几乎不用
|
||||
// doc_query 的直接原因之一。
|
||||
func docRecallHeadline(d *document.Doc, eventCount int) string {
|
||||
if d == nil {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
if d.HasEvents() {
|
||||
first := d.Events[0].Timestamp
|
||||
last := d.Events[len(d.Events)-1].Timestamp
|
||||
if first.Equal(last) {
|
||||
parts = append(parts, first.Format("2006-01-02 15:04"))
|
||||
} else {
|
||||
parts = append(parts, fmt.Sprintf("%s → %s",
|
||||
first.Format("01-02 15:04"), last.Format("01-02 15:04")))
|
||||
}
|
||||
// 来源集合(去重,最多 3 个)
|
||||
seen := map[string]bool{}
|
||||
var srcs []string
|
||||
for _, ev := range d.Events {
|
||||
if ev.Source != "" && !seen[ev.Source] {
|
||||
seen[ev.Source] = true
|
||||
srcs = append(srcs, ev.Source)
|
||||
if len(srcs) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(srcs) > 0 {
|
||||
parts = append(parts, strings.Join(srcs, "/"))
|
||||
}
|
||||
} else {
|
||||
parts = append(parts, d.CreatedAt.Format("2006-01-02 15:04"))
|
||||
if d.Source != "" {
|
||||
parts = append(parts, d.Source)
|
||||
}
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d 条事件", eventCount))
|
||||
if n := len(d.Blocks); n > 0 {
|
||||
media := 0
|
||||
for i := range d.Blocks {
|
||||
if d.Blocks[i].Modality != memory.BlockText {
|
||||
media++
|
||||
}
|
||||
}
|
||||
if media > 0 {
|
||||
parts = append(parts, fmt.Sprintf("含 %d 份媒体", media))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// docRecallPreview 取少量**真实正文**行,让模型即时判断相关性。
|
||||
//
|
||||
// 只取前 maxLines 行、每行截短:完整原文已在对话时序里(restoreDocEvents 回填),
|
||||
// 这里只是"先给你看一眼是什么",不是全文。
|
||||
func docRecallPreview(d *document.Doc, eventCount, maxLines int) string {
|
||||
if d == nil || maxLines <= 0 {
|
||||
return ""
|
||||
}
|
||||
var out []string
|
||||
add := func(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return len(out) < maxLines
|
||||
}
|
||||
out = append(out, " "+oneLinePreview(s, 100))
|
||||
return len(out) < maxLines
|
||||
}
|
||||
|
||||
if d.HasEvents() {
|
||||
for _, ev := range d.Events {
|
||||
in, resp, _ := ev.InOut()
|
||||
if in != "" && !add(in) {
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
if resp != "" && !add(resp) {
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 存量文档:从渲染视图取前几行
|
||||
for _, line := range strings.Split(d.Content, "\n") {
|
||||
if !add(line) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// oneLinePreview 把一段文本压成单行短摘要(换行折叠、超长截断)。
|
||||
func oneLinePreview(s string, maxRunes int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
r := []rune(s)
|
||||
if len(r) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(r[:maxRunes]) + "…"
|
||||
}
|
||||
|
||||
@ -177,6 +177,26 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt = appendPromptBlock(prompt, memContext)
|
||||
}
|
||||
|
||||
// 【三层记忆】是**内核运行时块**(提示词分层规范:内核行为由内核生成)。
|
||||
//
|
||||
// 它讲的是内核的真实执行语义(哪些内容在哪一层、何时会进上下文、
|
||||
// 哪个工具会做什么),不是工具用法 —— 工具用法归各 ToolDef.Description。
|
||||
//
|
||||
// 为什么必须有这一段:此前系统提示词从未解释 L1/L2/L3 是什么,
|
||||
// agent 只能从工具名猜。生产日志实测:agent 几乎从不调用 doc_query,
|
||||
// 即使调用了也只看返回值、不知道原文已被回填到对话时序。
|
||||
prompt = appendPromptBlock(prompt, "【三层记忆】记忆分三层,你要知道自己现在在看哪一层:\n"+
|
||||
"- **对话时序(L1,你现在看得到)**:最近的事件,按时间排列在上面那段【对话时序】里。"+
|
||||
"窗口放不下的会更早被移出,但不会丢。\n"+
|
||||
"- **历史记忆(L2,不直接给你看)**:被移出窗口的事件,连同**完整原文**(含图片/音频)保存在这里。"+
|
||||
"每次你收到新输入,内核会按语义相似度自动挑几篇相关的放在【历史记忆】块里,"+
|
||||
"但那只是片段 —— 要看某次的完整原文与过程,调用 `doc_query`:"+
|
||||
"它会把命中的事件**按原始时间戳插回你的对话时序**(不是返回一段摘要,那些事件真的回到了时间线上)。\n"+
|
||||
"- **图记忆(L3,长期)**:实体与关系(谁是什么、某值现在是多少、谁和谁什么关系),"+
|
||||
"用 `memory_recall` 查、`memory_commit` 记。\n"+
|
||||
"选择口径:要**原文与过程** → `doc_query`;要**关系与当前值** → `memory_recall`;"+
|
||||
"要**成文的可复用知识** → `knowledge_search`。")
|
||||
|
||||
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
|
||||
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
|
||||
prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+
|
||||
@ -556,24 +576,47 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
tools = append(tools, toolDefWith("doc_query", "查询文档记忆。输入查询内容,返回相关文档摘要。", map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询内容"},
|
||||
"top_k": map[string]interface{}{"type": "integer", "description": "返回数量", "default": 3},
|
||||
}, []string{"query"}, parallelOpts()))
|
||||
tools = append(tools, toolDef("doc_commit", "提交一条文档记忆。将重要信息显式写入文档记忆层。", map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "文档内容"},
|
||||
"summary": map[string]interface{}{"type": "string", "description": "摘要(可选)"},
|
||||
"tags": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "标签列表",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}, "content"))
|
||||
// ★ doc_query / doc_commit 的描述必须与**真实行为**一致。
|
||||
//
|
||||
// 旧描述只有一句「查询文档记忆。输入查询内容,返回相关文档摘要」:
|
||||
// · "文档记忆"没有定义,模型不知道 L2 是什么;
|
||||
// · "返回相关文档摘要"与真实行为**不符**(真实是把事件逐条按
|
||||
// 原始时间戳插回对话时序);
|
||||
// · 没说什么时候该用,也没说与 memory_recall 的分工。
|
||||
// 结果:生产实测 agent 几乎不调用 doc_query,即使调用也只看返回值、
|
||||
// 不知道原文已回填到时序。
|
||||
tools = append(tools, toolDefWith("doc_query",
|
||||
"从**历史记忆层**(L2)召回过去的事件原文。"+
|
||||
"\n\n**这一层是什么**:当前对话窗口里只看得到最近的事件;更早的事件被移出窗口后,"+
|
||||
"连同原文完整保存在历史层,不会丢失。"+
|
||||
"\n\n**何时用**:记得«这事办过/说过»但想不起细节;需要当时的原始过程而非结论;"+
|
||||
"跨会话、跨天追问;用户问\"上次那个\"/\"之前怎么弄的\"。只想知道某个事实的当前值、"+
|
||||
"或某个实体的关系,用 memory_recall(图记忆),不要用这个。"+
|
||||
"\n\n**调用后会发生什么**:命中的事件会**逐条按原始时间戳插回你的对话时序**"+
|
||||
"(就是【对话时序】那一段,出现在它当时的时间点上),"+
|
||||
"返回值会列出每条的时间/来源/正文开头。要引用细节,读时序里对应时间点即可。"+
|
||||
"\n\n**副作用**:召回即从历史层取出(消费式),该文档不再留在 L2。",
|
||||
map[string]interface{}{
|
||||
"query": map[string]interface{}{"type": "string", "description": "查询内容,用当时可能出现的词描述"},
|
||||
"top_k": map[string]interface{}{"type": "integer", "description": "召回多少篇(默认 3)", "default": 3},
|
||||
}, []string{"query"}, parallelOpts()))
|
||||
|
||||
tools = append(tools, toolDef("doc_commit",
|
||||
"把**整段结论/过程**写进历史记忆层(L2)。"+
|
||||
"\n\n**适合写什么**:做完一件事的完整过程与结论、排查链条、方案取舍的理由 —— "+
|
||||
"这些内容长、有上下文、以后要回看原文。"+
|
||||
"\n\n**不适合写什么**:单个事实或实体关系(谁是谁、某值等于多少、偏好)→ 用 memory_commit;"+
|
||||
"可复用的成文知识 → 用 knowledge_create。"+
|
||||
"\n\n写入后会参与后续的自动预填充与 doc_query 召回。",
|
||||
map[string]interface{}{
|
||||
"content": map[string]interface{}{"type": "string", "description": "文档内容(完整正文,不要自己截断)"},
|
||||
"summary": map[string]interface{}{"type": "string", "description": "一句话摘要(可选,留空则由正文首行生成)"},
|
||||
"media_digests": map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": "可选:这篇文档关联的媒体 digest(对话或 memory_recall 的「关联媒体」里显示的十六进制串,短的即可)。填了以后检索到这篇文档就能看到并取回原图/音频。",
|
||||
"items": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
}, "content"))
|
||||
}
|
||||
|
||||
if a.social != nil {
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/memory/vector"
|
||||
@ -34,12 +35,10 @@ type ChannelCleaner func(source string) func(string) string
|
||||
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
|
||||
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
|
||||
type Doc struct {
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"` // 渲染视图,不是原文
|
||||
Events []DocEvent `json:"events,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Entities []string `json:"entities"`
|
||||
ID string `json:"id"`
|
||||
Summary string `json:"summary"`
|
||||
Content string `json:"content"` // 渲染视图,不是原文
|
||||
Events []DocEvent `json:"events,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@ -93,9 +92,35 @@ type DocEvent struct {
|
||||
// 「把 Content 塞进一条事件」,而不是假装能回填。
|
||||
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
|
||||
|
||||
// docVectorAlgoVersion 是**文档向量算法**的版本标记。
|
||||
//
|
||||
// 为什么必需:DenseFP 只记录"哪个向量空间(模型)",不记录"用什么文本算的"。
|
||||
// 2026-10-22 把输入从 `Summary + Content` 改成"事件正文"后,指纹不变
|
||||
// ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件 ⇒ **永不重算**,
|
||||
// 修复对已有数据完全无效(实测:1055 篇存量仍是旧分数 0.78/0.79/0.76,
|
||||
// 区分度 0.003,与新算法的判据结果不符)。
|
||||
//
|
||||
// 所以把算法版本编进 DenseFP:算法一变,指纹后缀变,存量自动重算一次。
|
||||
//
|
||||
// 变更记录:
|
||||
//
|
||||
// v1 初版:VectorizeDense(Summary + Content)
|
||||
// v2 Summary 是 jieba 词袋且全库同质,改为事件正文(见 denseFor 注释)
|
||||
const docVectorAlgoVersion = "v2"
|
||||
|
||||
// Store — 文档记忆存储。
|
||||
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
|
||||
//
|
||||
// 主路径:denseSpace(稠密向量,与媒体共享的统一空间)。
|
||||
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
|
||||
//
|
||||
// ★ 关于 fallback 的定位(不要误删):
|
||||
//
|
||||
// 它是「稠密空间没加载」时的兜底,**不是**「词袋当语义」的第二套真相。
|
||||
// 设计要求(ARCHITECTURE「向量层级:统一多模态空间(主)→ 词嵌入 → TF-IDF(回退)」)
|
||||
// 明确保留这条降级链:模型不可用时服务不能整个失去检索能力。
|
||||
//
|
||||
// ★ 但它**绝不能**是主路径:一旦稠密空间可用,jieba 词袋就不该参与
|
||||
// (实测主题不同的文档两两余弦 0.85~0.95,检索等同失效 —— 见 denseFor 注释)。
|
||||
type Store struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
@ -112,7 +137,8 @@ type Store struct {
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
}
|
||||
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),供 TF-IDF 回退路径使用
|
||||
// (仅稠密空间不可用时加载;主路径不碰它)。
|
||||
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
|
||||
return &Store{
|
||||
dir: dir,
|
||||
@ -175,7 +201,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
log.Printf("[document memory] building dense index for %d docs (dim=%d)", len(s.docs), ds.Dim())
|
||||
count := 0
|
||||
for _, doc := range s.docs {
|
||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == ds.Fingerprint() {
|
||||
if doc.DenseVec != nil && len(doc.DenseVec) == ds.Dim() && doc.DenseFP == s.vectorFingerprint() {
|
||||
continue
|
||||
}
|
||||
vec := s.denseFor(doc)
|
||||
@ -183,7 +209,7 @@ func (s *Store) BuildDenseIndex(ds vector.MultimodalEmbedder) {
|
||||
continue
|
||||
}
|
||||
doc.DenseVec = vec
|
||||
doc.DenseFP = ds.Fingerprint()
|
||||
doc.DenseFP = s.vectorFingerprint()
|
||||
count++
|
||||
}
|
||||
if count > 0 {
|
||||
@ -255,6 +281,17 @@ func (s *Store) denseFor(doc *Doc) []float64 {
|
||||
return vector.FuseVectors(parts...)
|
||||
}
|
||||
|
||||
// vectorFingerprint 返回「向量空间指纹 + 文档向量算法版本」。
|
||||
//
|
||||
// 只比空间指纹不够:换算法(同一模型、不同输入文本)时必须触发重算,
|
||||
// 否则存量数据永远停留在旧算法的结果上(实测后果见 docVectorAlgoVersion)。
|
||||
func (s *Store) vectorFingerprint() string {
|
||||
if s.denseSpace == nil {
|
||||
return ""
|
||||
}
|
||||
return s.denseSpace.Fingerprint() + "|" + docVectorAlgoVersion
|
||||
}
|
||||
|
||||
// documentTextForVector 取文档用于向量化的文本:**正文**。
|
||||
//
|
||||
// 有 Events 时按事件顺序拼原文(来自块里的正文);无 Events 的存量文档
|
||||
@ -325,7 +362,7 @@ func (s *Store) Insert(doc *Doc) error {
|
||||
// 主路径:稠密向量(文本 ⊕ 媒体块)
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() && len(doc.DenseVec) == 0 {
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
doc.DenseFP = s.vectorFingerprint()
|
||||
}
|
||||
|
||||
// Fallback 路径:缓存文本,延迟训练
|
||||
@ -362,8 +399,6 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
content := contentFromEvents(events)
|
||||
contentHash := simpleHash(content)
|
||||
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
entities := extractEntities(entries, cleanFn, toolCleanFn, channelCleaner)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@ -376,12 +411,10 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
d.Events = events
|
||||
d.Source = source
|
||||
d.Summary = summary
|
||||
d.Tags = tags
|
||||
d.Entities = entities
|
||||
d.Blocks = flattenEventBlocks(events)
|
||||
d.DenseVec = s.denseFor(d)
|
||||
if s.denseSpace != nil {
|
||||
d.DenseFP = s.denseSpace.Fingerprint()
|
||||
d.DenseFP = s.vectorFingerprint()
|
||||
}
|
||||
s.dirty = true
|
||||
return d, nil
|
||||
@ -394,8 +427,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
meta["is_archived_context"] = "true"
|
||||
}
|
||||
doc := &Doc{
|
||||
ID: id, Summary: summary, Content: content, Tags: tags,
|
||||
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
ID: id, Summary: summary, Content: content,
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
|
||||
Events: events,
|
||||
Blocks: flattenEventBlocks(events),
|
||||
@ -403,7 +436,7 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
|
||||
s.docs[id] = doc
|
||||
doc.DenseVec = s.denseFor(doc)
|
||||
if s.denseSpace != nil {
|
||||
doc.DenseFP = s.denseSpace.Fingerprint()
|
||||
doc.DenseFP = s.vectorFingerprint()
|
||||
}
|
||||
text := summary + " " + content
|
||||
if s.tfidfIdx != nil {
|
||||
@ -731,128 +764,76 @@ func blocksFromEntries(entries []ContextEntry) []memory.MemoryBlock {
|
||||
return out
|
||||
}
|
||||
|
||||
// summarizeEntries 生成文档摘要 —— **纯展示用**,不参与任何向量计算。
|
||||
//
|
||||
// ★ 这里曾经调用 memory.ExtractKeywords(jieba Tag 分词 + 停用词),
|
||||
//
|
||||
// 产出形如「来自 1 个来源的 1 条对话 (agent) 涉及: map, content, 是不是」。
|
||||
// 两个后果都是实测的:
|
||||
//
|
||||
// 1. **不是摘要,是词袋清单**:模型与人都无法从中知道这篇讲什么;
|
||||
// 2. **全库同质**:同一来源的摘要逐字相同,一旦被当作向量输入
|
||||
// (denseFor 曾用 doc.Summary),四篇主题完全不同的文档两两余弦
|
||||
// 达到 0.85~0.95,检索等同失效。
|
||||
//
|
||||
// 现在改为取**首条事件的正文开头**:那是真实内容,可读、可判断。
|
||||
// 仍然只是展示视图 —— 原文在 Events 的文本块里,摘要是它的短引用。
|
||||
func summarizeEntries(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
sources := make(map[string]int)
|
||||
var topics []string
|
||||
// 优先取用户/agent 的实质正文(而非工具输出),取到即止。
|
||||
for _, e := range entries {
|
||||
sources[e.Source]++
|
||||
content := e.Content
|
||||
if channelCleaner != nil {
|
||||
if c := channelCleaner(e.Source); c != nil {
|
||||
content = c(content)
|
||||
}
|
||||
}
|
||||
words := memory.ExtractKeywords(cleanText(content))
|
||||
topics = append(topics, words...)
|
||||
for _, tr := range e.ToolResults {
|
||||
out := tr.Output
|
||||
if toolCleanFn != nil {
|
||||
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
|
||||
continue
|
||||
} else {
|
||||
out = c
|
||||
}
|
||||
}
|
||||
toolWords := memory.ExtractKeywords(out)
|
||||
topics = append(topics, toolWords...)
|
||||
if cleanText != nil {
|
||||
content = cleanText(content)
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("来自 %d 个来源的 %d 条对话", len(sources), len(entries))
|
||||
var srcList []string
|
||||
for s := range sources {
|
||||
srcList = append(srcList, s)
|
||||
}
|
||||
summary += " (" + strings.Join(srcList, ", ") + ")"
|
||||
if len(topics) > 0 {
|
||||
seen := make(map[string]bool)
|
||||
var uniq []string
|
||||
for _, t := range topics {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
uniq = append(uniq, t)
|
||||
if line := firstMeaningfulLine(content); line != "" {
|
||||
return truncateRunes(line, 80)
|
||||
}
|
||||
if e.Response != "" {
|
||||
if line := firstMeaningfulLine(e.Response); line != "" {
|
||||
return truncateRunes(line, 80)
|
||||
}
|
||||
}
|
||||
if len(uniq) > 5 {
|
||||
uniq = uniq[:5]
|
||||
}
|
||||
summary += " 涉及: " + strings.Join(uniq, ", ")
|
||||
}
|
||||
return summary
|
||||
return fmt.Sprintf("%d 条事件", len(entries))
|
||||
}
|
||||
|
||||
func extractTags(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
|
||||
tagSet := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
content := e.Content
|
||||
if channelCleaner != nil {
|
||||
if c := channelCleaner(e.Source); c != nil {
|
||||
content = c(content)
|
||||
// firstMeaningfulLine 取文本中第一行有实义的内容(跳过空行、纯符号行)。
|
||||
func firstMeaningfulLine(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// 跳过纯符号/空白
|
||||
hasWord := false
|
||||
for _, r := range line {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
hasWord = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
out := tr.Output
|
||||
if toolCleanFn != nil {
|
||||
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
|
||||
continue
|
||||
} else {
|
||||
out = c
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(out) {
|
||||
tagSet[kw] = true
|
||||
}
|
||||
if hasWord {
|
||||
return strings.Join(strings.Fields(line), " ")
|
||||
}
|
||||
}
|
||||
var tags []string
|
||||
for t := range tagSet {
|
||||
if len(tags) >= 10 {
|
||||
break
|
||||
}
|
||||
tags = append(tags, t)
|
||||
}
|
||||
return tags
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEntities(entries []ContextEntry, cleanText func(string) string, toolCleanFn func(name, output string) string, channelCleaner ChannelCleaner) []string {
|
||||
var entities []string
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
content := e.Content
|
||||
if channelCleaner != nil {
|
||||
if c := channelCleaner(e.Source); c != nil {
|
||||
content = c(content)
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(cleanText(content)) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
}
|
||||
}
|
||||
for _, tr := range e.ToolResults {
|
||||
out := tr.Output
|
||||
if toolCleanFn != nil {
|
||||
if c := toolCleanFn(tr.Name, tr.Output); c == "" {
|
||||
continue
|
||||
} else {
|
||||
out = c
|
||||
}
|
||||
}
|
||||
for _, kw := range memory.ExtractKeywords(out) {
|
||||
if len(kw) >= 2 && !seen[kw] {
|
||||
seen[kw] = true
|
||||
entities = append(entities, kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// truncateRunes 按字符(非字节)截断。
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return entities
|
||||
return string(r[:max]) + "…"
|
||||
}
|
||||
|
||||
// simpleHash 是用于「内容是否同一份」比对的快速摘要。
|
||||
|
||||
@ -26,7 +26,6 @@ func TestInsertAndQuery(t *testing.T) {
|
||||
doc := &Doc{
|
||||
Summary: "用户喜欢喝咖啡",
|
||||
Content: "用户提到他每天早上都会喝一杯黑咖啡",
|
||||
Tags: []string{"咖啡", "习惯"},
|
||||
Source: "manual",
|
||||
}
|
||||
if err := s.Insert(doc); err != nil {
|
||||
@ -175,17 +174,43 @@ func TestReindex(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeEntries 现在是**纯展示摘要**:取首条事件的正文首行。
|
||||
//
|
||||
// 它曾经用 jieba 词袋生成「来自 N 个来源的 M 条对话 涉及: …」——
|
||||
// 那是词袋清单,且全库同质(一旦当向量输入就会让检索失效)。
|
||||
func TestSummarizeEntries(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Source: "user", Content: "今天天气如何"},
|
||||
{Source: "user", Content: "今天天气如何,要不要带伞"},
|
||||
{Source: "user", Content: "明天会下雨吗"},
|
||||
}
|
||||
summary := summarizeEntries(entries, func(s string) string { return s }, nil, nil)
|
||||
if summary == "" {
|
||||
t.Error("summary should not be empty")
|
||||
t.Fatal("summary 不应为空")
|
||||
}
|
||||
if !contains(summary, "2") {
|
||||
t.Errorf("summary should mention count, got: %s", summary)
|
||||
// 必须是真实内容(首条事件正文),不是词袋统计
|
||||
if !contains(summary, "天气") {
|
||||
t.Errorf("summary 应取正文首行(含「天气」),实际: %s", summary)
|
||||
}
|
||||
if contains(summary, "涉及:") || contains(summary, "个来源") {
|
||||
t.Errorf("summary 仍是 jieba 词袋模板,实际: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
// 摘要不读工具输出:工具输出属于事件结构,不是「这篇讲什么」。
|
||||
// 旧实现会把工具输出也拿去做关键词提取,于是摘要被工具噪音污染。
|
||||
func TestSummarizeEntriesIgnoresToolOutput(t *testing.T) {
|
||||
entry := ContextEntry{
|
||||
Content: "今天天气怎么样",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "search", Output: "手机 电脑 显卡 内存"},
|
||||
},
|
||||
}
|
||||
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, nil, nil)
|
||||
if contains(summary, "手机") || contains(summary, "显卡") {
|
||||
t.Errorf("摘要被工具输出污染(应只用正文),实际: %s", summary)
|
||||
}
|
||||
if !contains(summary, "天气") {
|
||||
t.Errorf("摘要应取正文首行,实际: %s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
@ -196,16 +221,6 @@ func TestExtractKeywords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTags(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{Content: "我喜欢喝咖啡和编程"},
|
||||
}
|
||||
tags := extractTags(entries, func(s string) string { return s }, nil, nil)
|
||||
if len(tags) == 0 {
|
||||
t.Error("should extract tags")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsertEmptyDoc(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_empty_*")
|
||||
if err != nil {
|
||||
@ -346,92 +361,6 @@ func TestRemoveNonexistent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeEntriesWithToolCleanFn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
toolCleanFn func(name, output string) string
|
||||
wantTopics []string
|
||||
notTopics []string
|
||||
}{
|
||||
{
|
||||
name: "nil toolCleanFn uses raw output",
|
||||
toolCleanFn: nil,
|
||||
wantTopics: []string{"手机", "电脑"},
|
||||
notTopics: nil,
|
||||
},
|
||||
{
|
||||
name: "NoMemory returns empty skips tool output",
|
||||
toolCleanFn: func(name, output string) string {
|
||||
return ""
|
||||
},
|
||||
wantTopics: nil,
|
||||
notTopics: []string{"手机", "电脑"},
|
||||
},
|
||||
{
|
||||
name: "Cleaner applies filter",
|
||||
toolCleanFn: func(name, output string) string {
|
||||
return "电脑 编程"
|
||||
},
|
||||
wantTopics: []string{"电脑", "编程"},
|
||||
notTopics: nil,
|
||||
},
|
||||
}
|
||||
|
||||
entry := ContextEntry{
|
||||
Content: "今天天气",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "test_tool", Output: "手机 电脑"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
summary := summarizeEntries([]ContextEntry{entry}, func(s string) string { return s }, tc.toolCleanFn, nil)
|
||||
for _, w := range tc.wantTopics {
|
||||
if !contains(summary, w) {
|
||||
t.Errorf("summary should contain %q, got: %s", w, summary)
|
||||
}
|
||||
}
|
||||
for _, n := range tc.notTopics {
|
||||
if contains(summary, n) {
|
||||
t.Errorf("summary should NOT contain %q, got: %s", n, summary)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTagsWithToolCleanFn(t *testing.T) {
|
||||
entries := []ContextEntry{
|
||||
{
|
||||
Content: "对话",
|
||||
ToolResults: []ToolResultItem{
|
||||
{Name: "search", Output: "编程和咖啡"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// toolCleanFn 返回 "" → NoMemory,工具输出被跳过
|
||||
tagsSkip := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "" }, nil)
|
||||
for _, tag := range tagsSkip {
|
||||
if tag == "编程" || tag == "咖啡" {
|
||||
t.Errorf("NoMemory tool should not contribute keywords, got tag: %s", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// toolCleanFn 返回清洗文本 → 用清洗后内容提取关键词
|
||||
tagsClean := extractTags(entries, func(s string) string { return s }, func(name, output string) string { return "咖啡 编程" }, nil)
|
||||
found := false
|
||||
for _, tag := range tagsClean {
|
||||
if tag == "编程" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("cleaner output keywords should appear in tags, got: %v", tagsClean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextToDocContentPreservesRawToolOutput(t *testing.T) {
|
||||
dir, err := os.MkdirTemp("", "doc_toolclean_*")
|
||||
if err != nil {
|
||||
@ -519,7 +448,7 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
}
|
||||
s1.SetDenseSpace(sp)
|
||||
s1.BuildDenseIndex(sp)
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != sp.fp {
|
||||
if got := s1.Get("doc_persist"); got == nil || len(got.DenseVec) != 8 || got.DenseFP != s1.vectorFingerprint() {
|
||||
t.Fatalf("迁移未在内存生效: %+v", got)
|
||||
}
|
||||
// 不调用 Stop 就另开一个实例:体现「迁移当场落盘」,不依赖关停
|
||||
@ -532,10 +461,10 @@ func TestBuildDenseIndexPersistsAcrossRestart(t *testing.T) {
|
||||
if loaded == nil {
|
||||
t.Fatal("重启后文档不见了")
|
||||
}
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp {
|
||||
if len(loaded.DenseVec) != 8 || loaded.DenseFP != sp.fp+"|"+docVectorAlgoVersion {
|
||||
t.Fatalf("迁移结果未落盘:期望 dim=8 fp=%q,实际 dim=%d fp=%q"+
|
||||
"(后果:每次启动都重算同一批文档,磁盘状态永不收敛)",
|
||||
sp.fp, len(loaded.DenseVec), loaded.DenseFP)
|
||||
sp.fp+"|"+docVectorAlgoVersion, len(loaded.DenseVec), loaded.DenseFP)
|
||||
}
|
||||
// 已对齐 → 一次向量计算都不该发生
|
||||
sp2 := &fakeSpace{fp: sp.fp, dim: 8}
|
||||
|
||||
@ -379,10 +379,49 @@ func (b *bigramSpace) EmbedImageDense(img []byte, mime string) ([]float64, error
|
||||
|
||||
var _ vector.MultimodalEmbedder = (*bigramSpace)(nil)
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
// ⑥ 向量算法变更必须触发存量重算(否则修复对已有数据完全无效)。
|
||||
//
|
||||
// ★ 这是实测踩到的坑:把 denseFor 的输入从 `Summary + Content` 改成事件正文后,
|
||||
// DenseFP 仍是同一个空间指纹 ⇒ 存量文档全部命中 `DenseFP == 指纹` 的跳过条件
|
||||
// ⇒ **永不重算**。生产实测后果:1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
|
||||
// 区分度 0.003),修复等于没做。
|
||||
//
|
||||
// 判据:把 DenseFP 写成"空间指纹但无算法版本"(= 旧算法留下的状态),
|
||||
// 重新 BuildDenseIndex 必须重算。
|
||||
func TestRetrieval_AlgoVersionChangeForcesRecompute(t *testing.T) {
|
||||
s := NewStore(t.TempDir(), memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(r[:n]) + "..."
|
||||
t.Cleanup(s.Stop)
|
||||
|
||||
ds := &bigramSpace{}
|
||||
s.SetDenseSpace(ds)
|
||||
|
||||
// 模拟旧算法留下的状态:指纹是**纯空间指纹**(无 |v2 后缀)
|
||||
legacyVec := make([]float64, ds.Dim())
|
||||
legacyVec[0] = 1
|
||||
if err := s.Insert(&Doc{
|
||||
ID: "doc_legacy_vec", Summary: "旧向量", Content: "旧算法的向量",
|
||||
DenseVec: legacyVec, DenseFP: ds.Fingerprint(), // ← 无算法版本
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before := s.Get("doc_legacy_vec").DenseVec[0]
|
||||
s.BuildDenseIndex(ds)
|
||||
after := s.Get("doc_legacy_vec")
|
||||
|
||||
if after.DenseFP != s.vectorFingerprint() {
|
||||
t.Errorf("算法版本未写入 DenseFP:got %q,want %q",
|
||||
after.DenseFP, s.vectorFingerprint())
|
||||
}
|
||||
if after.DenseFP == ds.Fingerprint() {
|
||||
t.Error("DenseFP 仍是纯空间指纹 —— 换算法后存量永远不会重算")
|
||||
}
|
||||
// 已对齐后再次 BuildDenseIndex 不应重复计算
|
||||
ds2 := &bigramSpace{}
|
||||
s.SetDenseSpace(ds2)
|
||||
s.BuildDenseIndex(ds2)
|
||||
_ = before
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user