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:
JianFeeeee
2026-10-22 19:49:25 +08:00
parent b1f02a22b6
commit e2cb6e70e0
9 changed files with 1052 additions and 294 deletions

View 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 入库?

View File

@ -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]) + "…"
}

View 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)
}
}

View File

@ -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)
}
}

View File

@ -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]) + "…"
}

View File

@ -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 {

View File

@ -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 是用于「内容是否同一份」比对的快速摘要。

View File

@ -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}

View File

@ -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
}