From 47c95efb800bc5b7a78a0d650e9baf76157d92cf Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 11:08:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20L2=20=E4=B8=8E=20L1=20=E5=90=8C?= =?UTF-8?q?=E6=9E=84=20=E2=80=94=E2=80=94=20=E4=BA=8B=E4=BB=B6=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=89=BF=E8=BD=BD=E5=8E=9F=E6=96=87=EF=BC=8C=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E5=86=85=E8=81=94=E5=9C=A8=E5=9D=97=E9=87=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改, 导致这条链出现结构性失效。本次按设计原文修复。 ## 断点(生产实测) **① L2 只存渲染视图,事件本体丢失** ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。 生产库 1000 篇归档文档实测: - response 段恰好 103 字符(=100+"..."):311/2094 = 15.1% - 工具输出段恰好 203 字符:7593/13261 - ToolsUsed 完全不存 ⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。 **② L2 里没有文本块,与 L1 不同构** L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。 **③ 召回造合成事件,时间戳用归档时刻** doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、 且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at 差近两小时,却没人用。 **④ 块在 Consume 时两层同时消失** Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。 生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。 ## 修法(按设计,不是按我的推断) 设计前提(写进 ARCHITECTURE 与计划文档): · 记忆块是唯一物理载体,层是持有关系 · L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」 · 正文(含多媒体正文)内联在块里;事件只是块的容器 · 任何一层都不压缩(视图不是记忆) · 内核不得硬编码任何插件行为 1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体 2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文) 3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回 4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体 5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图 6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码 插件名,改由插件经 NoMemory/Cleaner 声明 ## 顺带修掉的真实缺陷 simpleHash 是 O(n²) 且哈希串长度正比于内容: for _, c := range s { h += fmt.Sprintf("%x", c) } 压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash 是 content 的 2.2 倍(52072 字 → 114491 字)。 换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。 ## 存量 按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回 (整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档 全部正常加载。 ## 判据(16 条,全部有变异自证) document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、 视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力 core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、 回填不截断、存量降级、批量压力 变异全部被抓: · 落库 truncate(100) → ArchivePreservesFullText(实测 len=303/原 10215) · Input/Response 合成一块 → InputAndResponseAreSeparateTextBlocks(实际 1) · 召回用 d.CreatedAt → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3) · 召回不回填 Blocks → MigratesBlocksBackToContext · 召回再截 2000 字 → TextIsNotTruncatedOnTheWayBack(2003/原 18020) · 恢复 O(n²) hash → HashIsLinearAndFixedLength(超时 >300s) ## 验证 - go build ./... 通过 - go test ./... -count=1:58 包 0 失败 - go test -race ./internal/memory/document/ ./internal/agent/core/:通过 - 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出 - 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确 ## 不改 - 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价) - 存量文档(等自然冲刷) - 渲染层截断常数(独立议题,避免与本次修复互相掩盖) --- assets/docs/zh/ARCHITECTURE.md | 89 +++- assets/docs/zh/OVERVIEW.md | 18 +- docs/zh/memory-blockflow-repair-plan.md | 444 +++++++++++++++++ internal/agent/core/distill.go | 42 +- internal/agent/core/mediaref_test.go | 31 +- internal/agent/core/recalldoc_test.go | 286 +++++++++++ internal/agent/core/toolcall.go | 92 +++- internal/memory/document/blockflow_test.go | 523 +++++++++++++++++++++ internal/memory/document/document.go | 328 +++++++++++-- 9 files changed, 1761 insertions(+), 92 deletions(-) create mode 100644 docs/zh/memory-blockflow-repair-plan.md create mode 100644 internal/agent/core/recalldoc_test.go create mode 100644 internal/memory/document/blockflow_test.go diff --git a/assets/docs/zh/ARCHITECTURE.md b/assets/docs/zh/ARCHITECTURE.md index 550845ed..72319421 100644 --- a/assets/docs/zh/ARCHITECTURE.md +++ b/assets/docs/zh/ARCHITECTURE.md @@ -10,7 +10,17 @@ HologramAgent 的认知架构由三个核心子系统构成:事件循环(eve **阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。 -**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数,如 QQ 外置插件剥离工具调用模板),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。 +**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件通过 `docStore.ContextToDoc` **迁移**至 Document 层:事件连同它持有的块一起下沉,保留原始时间戳与完整正文。持久化采用 5 秒防抖写入磁盘 JSON 文件。 + +> ★ **哪些事件不该进 L2,由插件自己声明,内核不认插件名**。 +> +> 旧版这里写的是「过滤掉 `agentcli` 和 `terminal` 来源后归档」——那是在内核里 +> 硬编码插件行为,已废除。正确姿势是插件在注册时就声明: +> - `ToolDef.NoMemory` / `ToolDef.Cleaner`:工具输出不进计算层 +> - `ChannelDef.NoMemory` / `ChannelDef.Cleaner`:该通道的输入不进计算层 +> - `InjectOptions.NoMemory`:本次注入不参与记忆 +> +> 内核只消费这些**声明**(已由 `textForVector` 实现),不认识任何具体插件。 **工具定义聚合自五个来源**:IOManager 注册的插件工具;StageHost 注册的 SDK 插件工具;Indexer 提供的记忆索引工具;内置条件工具(依据 memory / knowledge / docStore / social / pluginReg / providerManager 等模块的非空状态选择性添加,包括记忆操作、知识检索、文档查询、社交网络、插件重载、子代理生成、输出通道工具、LLM 源切换等);以及按 `pendingMedia` 状态添加的媒体处理工具。`buildToolDefs()` 在每次 process 周期中重新聚合所有这些来源。 @@ -83,35 +93,64 @@ eventLoop() → processTextInput() ## 三层记忆 +### 记忆块是唯一载体,层是持有关系 + +三层不是三种数据结构,而是**同一批记忆块的三层持有关系**: + +``` +记忆块 MemoryBlock(union) + ├─ 文本块:负载在 Text + └─ 媒体块:负载在 Data(配 MIME/宽高/尺寸) + ↑ 同一个块 ID 在 context → doc → graph 全程不变 + ↑ 块自带 vector + fingerprint:向量只算一次,跨层复用 +``` + +| 层 | 承载单元 | 与模型的关系 | 容量 | 流动性 | +|---|---|---|---|---| +| **L1 Context** | 事件(内联正文 + 块) | **直接投喂**(进 system prompt 时序) | 最近 n 条 / 上限 m 条 | 超限时按相似度迁出 | +| **L2 Document** | 事件(内联正文 + 块) | **不直接投喂**(需召回才进 L1) | 无上限 | 冷到阈值后蒸馏进 L3 | +| **L3 Graph** | 三元组(节点 + 关系边),节点即记忆块 | 不投喂(经召回进入 L1) | 持久 | 不流出 | + +★ **L1 与 L2 结构完全同构**,唯一区别是「是否被直接投喂给模型」。 +L2 不是 L1 的摘要层,也不是另一种数据形态——它就是同样的事件 + 同样的块,只是不直接进模型视野。 + +★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 就是正文,媒体块的 `Data` 就是字节。 +不存在「块 + 外部正文」的分离形态,也不存在靠渲染字符串承载原文的路径。 +事件是块的容器,不是块的替代品。 + +★ **任何一层都不压缩**。视图(检索摘要、给模型看的渲染)可以是压缩的,但视图不是记忆。 + ### 记忆流转 ``` -① Context (工作窗口) +① Context (工作窗口 — 直接投喂) RelevanceContext — 内存 events[] + JSON持久化 + 每条事件:Timestamp / Source / Input / Response(完整) / ToolsUsed / ToolResults / Blocks Append: 每次输入, CleanText → 三分支向量(textForVector) agent事件→Response, 用户事件→Input, cold_storage→Input+Response 向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退) Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条 ├── 保留 → timeline → 按时间排序 → system prompt - └── 低分 → Document 层归档 (原始时间戳) + └── 低分 → Document 层归档(迁移:事件连同块一起下沉,时间戳与正文不变) Save: 5s debounce 写盘 - ↓ Prune 归档 ↑ LLM 主动召回 + ↓ Prune 归档(迁移) ↑ 召回(迁回) -② Document (文件记忆) - DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex) +② Document (文件记忆 — 不直接投喂,结构与 L1 同构) + DocStore — 每篇文档 = 检索元数据 + 事件列表(内联正文 + 块) 写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs) 读取: - ├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读) - └── LLM主动: doc_query → Consume(读取并删除) - → 逐条 context.Append{Timestamp: d.CreatedAt, Source: "cold_storage"} - → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 + ├── 自动预填充: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读,不动层) + └── LLM主动: doc_query → Consume(取走) + → 逐条按各自原始时间戳把事件插回 L1 时序 + → 块随事件迁回(同一块 ID,向量不重算),L2 不再持有 冷化: FindColdDocs(72h, ≤2次访问) → docToTriples → Graph ↓ 冷文档蒸馏 ↑ 自动召回 -③ Graph (图数据库) - SQLite — entities + relations 表 +③ Graph (图数据库 — 顶层仍是三元组,节点即记忆块) + SQLite — memory_blocks(节点) + memory_block_edges(边) + 三元组 = 【节点】【关系边】【节点】;putTripleBlocksTx 落为 src --edge_type--> dst 写入: memory_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / memory_merge 读取: ├── 自动召回: Indexer.BuildContext(input) @@ -174,21 +213,33 @@ eventLoop() → processTextInput() - 向量化前统一经 `CleanText` 去模版噪声 - 三分支 `textForVector`:agent 事件用 Response、用户事件用 Input、cold_storage 用 Input+Response - 预训练词嵌入 `StaticEmbedder` → CosineSimilarity,模型不可用时自动回退 TF-IDF -- 保护最近 10 条记录免于淘汰,超出部分按相关性排序归档到文档记忆 -- 归档事件以原始时间戳写入文档记忆,后续 `doc_query` 召回时按原始时间戳插回时序 +- 保护最近 10 条记录免于淘汰,超出部分按相关性排序**迁移**到文档记忆 +- 迁移是事件**连同它持有的块**一起下沉,时间戳与正文不变;源事件清空块(同一块不同时留两层) ### Document 层 `internal/memory/document/document.go` — `Store` -- 消费即删模式:`doc_query` 检索到后删除 -- 双路召回:与 Context 共享的 StaticEmbedder 语义向量搜索 + jieba 关键词提取(模型未加载时回退 char-bigram TF-IDF) + +**与 Context 同构,只是不直接投喂给模型**: +- 每篇文档 = 检索元数据(`ID`/`Summary`/`Vector`/`DenseVec`/`DenseFP`/`AccessCount`) + + **事件列表**(内联正文 + 块) +- 正文内联在块里:文本块的 `Text` 是正文,媒体块的 `Data` 是字节 +- `Doc.Content` 只是**渲染视图**(供摘要/人工阅读/SDK 兼容),不是原文载体 +- 自动预填充:与 Context 共享的向量空间做相似度检索 → 摘要进 system prompt(**只读**,不动层) +- 主动召回:`doc_query` 取走后,**逐条按各自原始时间戳**把事件插回 L1 时序,块随事件迁回 +- 冷化:`FindColdDocs(72h, ≤2次访问)` → `docToTriples` → Graph ### Graph 层 `internal/memory/graph.go` — `GraphDB` -- SQLite WAL 模式,两张表(驱动:mattn/go-sqlite3,CGo) -- `Commit(triples)` — UPSERT entities + INSERT relations -- `Recall(keywords, depth)` — 关键词 LIKE 搜索 + BFS 遍历 + +**顶层结构仍是三元组,变的是节点的物理表示**: +- SQLite WAL 模式(驱动:mattn/go-sqlite3,CGo) +- `memory_blocks`(节点,带 vector + fingerprint + modality)+ `memory_block_edges`(边) +- 三元组 = 【节点】【关系边】【节点】:`putTripleBlocksTx` 落为 `src --edge_type--> dst` +- `entities` / `relations` 旧表是**存量过渡路径**,逐步退场(退场的是节点存储,不是三元组概念) +- `Commit(triples)` — 写入时直接块化为节点 + 边 +- `Recall(keywords, depth)` — 关键词检索 + BFS 遍历 ### 记忆工具(LLM 可直接调用) diff --git a/assets/docs/zh/OVERVIEW.md b/assets/docs/zh/OVERVIEW.md index 2117a6c2..4ca835a5 100644 --- a/assets/docs/zh/OVERVIEW.md +++ b/assets/docs/zh/OVERVIEW.md @@ -24,12 +24,20 @@ CLI 需要终端面板时,**订阅事件总线**(`EventTerminalOutput` + `Ev 自己攒视图,而不是去读内核里的某个状态组件。内核只提供事件总线与插件注册面—— 新增一类能力不需要改内核,也不会在核心里堆出第二套业务真相。 -**三层记忆架构** — 通过分级存储策略管理 Agent 长周期运行中的信息留存: -- **Context 层**:内存中预训练词嵌入评分的事件窗口(StaticEmbedder 词向量 → CosineSimilarity,TF-IDF 回退),实时维护最近上下文,低相关性事件自动下沉到下一层 -- **Document 层**:JSON 文件 + TF-IDF 向量索引的临时记忆,支持显式提交和隐式归档,冷数据蒸馏到 Graph -- **Graph 层**:SQLite 图数据库,持久化实体(entities)和关系(relations),BFS 遍历召回,蒸馏管道从对话中提取三元组 +**三层记忆架构** — 三层不是三种数据结构,而是**同一批记忆块的三层持有关系**: +- **Context 层(L1,直接投喂)**:内存中预训练词嵌入评分的事件窗口(StaticEmbedder 词向量 → CosineSimilarity,TF-IDF 回退),实时维护最近上下文,低相关性事件自动下沉到下一层 +- **Document 层(L2,不直接投喂)**:与 L1 **结构同构**——同样的事件 + 同样的块,只是不直接进模型视野;支持显式提交和隐式归档,冷数据蒸馏到 Graph +- **Graph 层(L3,持久)**:SQLite 图数据库,**顶层仍是三元组**(节点 + 关系边),但节点从 `entities` 表升级为 `memory_blocks`;BFS 遍历召回,蒸馏管道从对话中提取三元组 -三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。 +★ **L1 与 L2 唯一区别是「是否被直接投喂给模型」**:L2 不是 L1 的摘要层,也不是 +另一种数据形态。召回 = 把事件(连同它持有的块)从“不投喂”的持有层**迁回**“投喂”的持有层。 + +★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 是正文,媒体块的 `Data` 是字节。 +不存在「块 + 外部正文」的分离形态。**任何一层都不压缩** —— 视图(检索摘要、渲染结果) +可以是压缩的,但视图不是记忆。 + +三层流动:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道; +每一次都是**迁移**(块 ID 与向量不变,只换持有层),不是复制。 **媒体记忆(v1.2.0 起:一等记忆块)** — 图片/音频不是附属物,而是记忆的一等节点: - **内容寻址存储(CAS)**:digest 寻址,元数据在 SQLite、blob 在磁盘(两级分桶),相同字节只存一份, diff --git a/docs/zh/memory-blockflow-repair-plan.md b/docs/zh/memory-blockflow-repair-plan.md new file mode 100644 index 00000000..e013314e --- /dev/null +++ b/docs/zh/memory-blockflow-repair-plan.md @@ -0,0 +1,444 @@ +# 三层记忆系统修复计划:块化重构后的失效面 + +> 状态:**已实施**(2026-10-22)。实施记录见文末 §九。 +> 原计划写作于同日,下述前提与失效面均经源码与生产数据核实。 +> 触发:块化统一(`docs/zh/memory-restructure-plan.md`)重构了底层数据结构, +> 但上层「层的流动」没有跟着改,导致 L1↔L2 这条链出现结构性失效。 +> +> 本文只写**经源码与生产数据核实**的失效点。每条都标了证据位置, +> 不含推测、不含性能断言。 + +--- + +## 〇、设计前提(不可动摇,任何修法违反即错) + +### 前提 A:记忆块是唯一的物理载体 + +`docs/zh/memory-restructure-plan.md` §〇 纠错表原话: + +> | 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。 +> **blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 | + +`MemoryBlock` 是 union:文本块负载在 `Text`,媒体块在 `Data`(配 MIME/宽高/尺寸)。 +**同一个块 ID 在 context → doc → graph 全程不变。层是持有关系,不是数据种类的划分。** + +### 前提 B:L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」 + +这是**层与层的定义**,不是实现细节: + +| | L1 Context | L2 Document | +|---|---|---| +| 承载单元 | 事件(内联正文 + 块) | 事件(内联正文 + 块) | +| 结构 | 同构 | 同构 | +| 与模型的关系 | **直接投喂**(进 system prompt 时序) | **不直接投喂**(需经召回才进入 L1) | +| 容量 | 最近 n 条 / 上限 m 条 | 无上限 | +| 流动性 | 超限时按相似度迁出 | 冷到阈值后蒸馏进 L3 | + +⇒ L2 **不是**「L1 的摘要层」,也**不是**「另一种数据形态」。 +它就是**同样的事件 + 同样的块**,只是不直接进模型视野。 +召回 = 把块(连同它所属事件)从"不投喂"的持有层**迁回**"投喂"的持有层。 + +### 前提 C:正文(含多媒体正文)内联在块里 + +文本块的 `Text` 就是正文;媒体块的 `Data` 就是字节。 +**不存在"块 + 外部正文"的分离形态**,也不存在靠渲染字符串承载原文的路径。 +事件是块的容器,不是块的替代品。 + +### 前提 D:任何一层都不压缩 + +原文必须完整保留。视图(检索摘要、给模型看的渲染)可以是压缩的, +但**视图不是记忆**。把视图当记忆存,等于在存储端就丢了原文。 + +### 前提 E:Graph 顶层仍是三元组,块只是它的节点实现 + +`internal/memory/sentence_block.go` — `putTripleBlocksTx`: + +```go +src := MemoryBlock{ID: TripleBlockID(t.Subject), Text: t.Subject, SemanticType: t.SubjectType} +dst := MemoryBlock{ID: TripleBlockID(t.Object), Text: t.Object, SemanticType: t.ObjectType} +// 边:src --edge_type=Relation--> dst,落在 memory_block_edges +``` + +三元组 =【节点】【关系边】【节点】,这个**顶层结构没有变**。 +变的是节点的物理表示:`entities` 表 → `memory_blocks` 表。 +`entities` 退场不等于三元组退场。 + +### 前提 F:内核不得硬编码任何插件行为 + +这是**铁律**。任何依赖具体插件名(`agentcli` / `terminal` / `qq` …)的内核分支都是错的。 +插件的行为差异一律由插件自己**声明**:`ToolDef.NoMemory`、`ToolDef.Cleaner`、 +`ChannelDef.NoMemory`、`ChannelDef.Cleaner`、`InjectOptions`。 + +### 前提 G:SDK 已通过一套等价接口实现同等能力 + +公开 SDK(`third_party/homeagent-sdk/sdk/memory.go`)已提供与内核同构的表达: + +| SDK 接口 | 对应的内核概念 | +|---|---| +| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) | +| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) | +| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) | +| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) | +| `MediaAttachment{Digest, MIME, Data}` | 块引用:带 `Data` 为新内容,只带 `Digest` 为引用已有 | +| `TextMemoryAPI.Append(TextEvent)` | 时间序文本日志 | + +⇒ **SDK 契约不需要改动**。修的是内核实现没跟上契约语义,不是契约本身。 + +--- + +## 一、失效面清单(经核实) + +### 失效 1:L2 只存了事件的渲染视图,事件本体被丢弃 + +**位置**:`internal/memory/document/document.go` — `ContextToDoc` + +`Prune` 交给 L2 的是结构完整的 `[]ContextEntry`(Timestamp/Source/Content/ +Response/ToolsUsed/ToolResults/Blocks),但落库时被拍平成一个字符串: + +```go +for _, e := range entries { + line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content) + line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) // 正文 → 视图 + line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200)) +} +content := strings.Join(parts, "\n") +``` + +只有 `Doc.Content` 被保存。**后果**:事件的时间戳只剩 `HH:MM`, +`Response` 只剩 100 rune,工具输出只剩 200 rune,`ToolsUsed` 完全不存。 + +这直接违反前提 B(L1/L2 同构)与前提 C(正文内联在块里)—— +L1 里是完整事件,L2 里却退化成一段文本。 + +**生产证据**(`/home/newqqagent/memory/documents/`,1000 篇归档文档): + +| 度量 | 实测值 | +|---|---| +| response 段恰好 103 字符(=100+`...`) | 311 / 2094 段 = **15.1%** | +| 工具输出段恰好 203 字符(=200+`...`) | **7593 / 13261 段** | + +⇒ 原文**在落库那一刻就不存在了**。这不是"召回时没取全",是存储端丢失。 + +### 失效 2:L2 里没有文本块,L1/L2 不同构 + +**位置**:`internal/memory/document/document.go` — `blocksFromEntries` + +块的 `modality` 实测分布(同一批生产文档): + +``` +L2 块总数 444:image 437 / audio 7 / text 0 +``` + +**后果**:L2 里"块"只承载媒体,文本内容全靠 `Doc.Content` 这个视图。 +违反前提 B/C——L1 的事件正文 + 块,到 L2 变成了"字符串 + 仅媒体块"。 +(对照:L3 的文本是有块的,`putTripleBlocksTx` 的主语块/宾语块都是 `BlockText`。) + +### 失效 3:召回时新建合成事件,而非把块按原时序迁回 + +**位置**:`internal/agent/core/toolcall.go` — `executeDocTool` 的 `doc_query` 分支 + +```go +docs := a.docStore.Consume(query, topK) // 读 + 从 L2 删除 +... +a.context.InsertByTimestamp(ContextEvent{ + Timestamp: d.CreatedAt, // ← 归档时刻,非事件时刻 + Source: "cold_storage", // ← 新造来源 + Input: fmt.Sprintf("加载文档记忆: %s", query), + Response: content, // ← 渲染视图,且再截 2000 +}) +// ← 完全没有把 d.Blocks 挂回去 +``` + +三处偏离设计(`ARCHITECTURE.md` L107-108、L178): + +> doc_query → Consume(读取并删除) +> → **逐条** context.Append{Timestamp: **原始时间戳**, Source: 原来源} +> → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 + +**生产证据**(块自带 `created_at`,与归档时刻差近两小时): + +``` +媒体块 created_at = 2026-10-12 11:09:51 ← 事件发生时刻(captureBlockMedia 里 time.Now()) +doc.created_at = 2026-10-12 13:04:54 ← 归档时刻 +``` + +443/443 个媒体块都带有效 `created_at`。**块自带它该回到哪个时刻的坐标,现在没人用。** + +### 失效 4:块在 Consume 时两层同时消失 + +**位置**:`internal/memory/document/document.go` — `Consume` → `removeDoc` + +```go +func (s *Store) removeDoc(id string) { + delete(s.docs, id) + ... + os.Remove(filepath.Join(s.dir, id+".json")) // 块的字节只在这个文件里 +} +``` + +`doc_query` 调用 `Consume` 后,文档(含 `d.Blocks`)从内存与磁盘同时删除; +而 `toolcall.go` 不回填 `Blocks`。**后果**:这些块既不在 L2(已删)也不在 L1(没挂), +只剩局部变量引用,函数返回后即不可达。 + +**生产风险敞口**:L2 现有 444 个块,其中 **396 个不在 L3**(`graph.db` 里查不到), +原始字节约 **248 MB**。一旦被 `doc_query` 命中即永久丢失。 + +### 失效 5:时序渲染用固定 120 rune 截断正文 + +**位置**:`internal/agent/core/process.go` — `formatMergedTimeline` + +```go +sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120))) +``` + +上下文预算可以是几百 K token(`ComputeTokenBudget`),但每条正文**永远只显示前 120 字**。 +原文即使用前提 D 完整保存在库里,模型也看不到——这是"不压缩"在**渲染环节**的漏口。 + +**生产证据**(`context.json`):2 条 `cold_storage` 事件的 response 分别 1541 / 1209 字符, +渲染出来都是 123 字符。 + +### 失效 6:`Prune` 的归档过滤要求本身违反前提 F + +**位置**:`ARCHITECTURE.md` L13 写「过滤掉 `agentcli` 和 `terminal` 来源后归档」。 + +`agentcli` / `terminal` 是**插件名**。把它们写进内核归档规则 = 硬编码插件行为,违反前提 F。 + +**当前代码**:`Prune` 里没有任何源名过滤,该要求**未实现**; +生产库里有 325 行 `agentcli` 归档(全部是终端状态行)。 + +⇒ 这不是"补上过滤",而是**该要求应作废,改由插件声明驱动**(前提 F)。 + +--- + +## 二、修复顺序(按依赖) + +失效 1/2 是存储形态,3/4 是召回形态,5 是渲染,6 是文档纠错。 +**必须先修存储**——存储里没有事件本体,召回就没有东西可放回。 + +| # | 动作 | 依赖 | 涉及文件 | +|---|---|---|---| +| ① | L2 存储形态改为「事件 + 块」(与 L1 同构) | — | `memory/document/document.go` | +| ② | 文本正文以块承载(消灭 `Content` 作为唯一文本载体) | ① | 同上 + `agent/core/context.go` | +| ③ | 召回按块/事件原始时序迁回 L1 | ①② | `agent/core/toolcall.go` | +| ④ | 时序渲染改为按预算分配 | 独立 | `agent/core/process.go` | +| ⑤ | 作废失效 6 的文档要求(改由 `NoMemory` 声明驱动) | 独立 | `ARCHITECTURE.md` | + +### ① 的具体形状 + +`Doc` 与 `RelevanceContext` 的事件列表**同构**: + +``` +L1: events []ContextEvent // Timestamp/Source/Input/Response/ToolsUsed/ToolResults/Blocks +L2: events []ContextEvent // 同一结构,同一字段 +``` + +- `Doc` 保留 `ID` / `Summary` / `Vector` / `DenseVec` / `DenseFP` / `AccessCount` 等 + **检索元数据**(这些是 L2 作为"可检索容器"的附加属性,L1 不需要) +- 事件列表**内联正文**(`Response` 完整,不截断)+ 持有的块 +- `Doc.Content` 降级为**纯渲染视图**(供摘要/人看/兼容 SDK 的 `Doc.Content`), + 不再作为原文载体;原文从事件读 + +> ⚠️ 注意与「已否决做法」的区别:不是新造一个异构字段把 `ContextEvent` 旁挂上去, +> 而是让 **L2 的承载结构就是 L1 的那一套**。事件列表是文档的主体,不是附件。 + +### ② 的具体形状 + +- 归档时,每条事件的正文(`Input` / `Response`)生成对应文本块(`Modality=BlockText`, + `Text` 即正文),块 `CreatedAt` = 事件时间戳 +- 媒体块照旧(`Data` 内联字节) +- 事件 `Blocks` 里同时含文本块与媒体块 → L1/L2/L3 三处同构 + +### ③ 的具体形状 + +``` +doc_query → 取出 L2 事件列表(迁移语义:取出后 L2 不再持有) + → 逐条按各自 Timestamp 插回 L1 时序 + → 事件持有的块随之迁回(同一块 ID,向量不重算) +``` + +--- + +## 三、判据(先写判据再改,避免自证) + +> 教训(本次已发生):先改代码再写测试,测试就只会证明"我改的是我以为的"。 +> 判据必须**从设计前提推导**,且必须能在改动前**红**。 + +### ① 存储形态(L1/L2 同构) + +- [ ] 归档后能从 L2 取回**逐条**事件的原始 `Timestamp`(完整时刻,非 `HH:MM`)、 + `Source`、`Response`(完整,非截断)、`ToolsUsed`、`ToolResults` +- [ ] 变异:把 `Response` 改回 `truncate(..,100)` ⇒ 红 +- [ ] 变异:丢掉 `ToolsUsed` ⇒ 红 +- [ ] 变异:时间戳改回 `Format("15:04")` ⇒ 红 + +### ② 文本块 + +- [ ] 归档文档的文本块 `Text` 与事件正文逐字一致 +- [ ] 文本块 `CreatedAt` == 事件 `Timestamp` +- [ ] 变异:不生成文本块(只存 `Doc.Content`)⇒ 红 + +### ③ 召回 + +- [ ] `doc_query` 后事件按**各自原始时间戳**回到 L1 +- [ ] 块 `ID`/`Vector`/`CreatedAt` 全程不变(迁移而非重建) +- [ ] 块的持有层从 L2 变为 L1,**不同时留在两层** +- [ ] 变异:用 `d.CreatedAt` 当时间戳 ⇒ 红 +- [ ] 变异:不回填 `Blocks` ⇒ 红 + +### ④ 渲染 + +- [ ] 预算宽裕时正文完整可见(含结尾哨兵) +- [ ] 预算紧张时仍保留开头(不能整条消失) +- [ ] 单条超长正文不得把其余事件整段挤掉 + +### ⑤ 文档纠错 + +- [ ] `ARCHITECTURE.md` 不再出现任何插件名作为内核归档规则 +- [ ] 判据:`grep -rn '"agentcli"\|"terminal"' internal/agent/core internal/memory`(非测试)为空 + +--- + +## 四、禁止事项(此前已犯,写下来防复发) + +| 禁止 | 原因 | +|---|---| +| 给 `Doc` 加异构字段旁挂事件快照(如 `Entries []ContextEntry`) | L2 主体就是事件列表本身,不是"Doc + 附件" | +| 改 `truncateStr` 的常数当作"修复压缩" | 视图截断不是记忆压缩问题;根因在存储形态 | +| 任何插件名字面量进内核分支 | 违反前提 F | +| 编造不存在的 API 再补注释解释它"应该"怎样 | 实现必须以真实存在的设施为准 | +| 先按自己的理解写测试再改代码 | 会把误解固化成判据 | +| 改公开 SDK 契约 | 前提 G:SDK 已等价,修内核实现 | + +--- + +## 五、已否决的做法 + +### 否决 1:新增 `Doc.Entries` 存事件快照(2026-10-22,已实现又撤销) + +错在**异构旁挂**:`Doc` 仍是字符串主体,事件列表被当成附件挂上去。 +正确形态是 §二①:事件列表**成为 L2 的主体**,与 L1 同构。 + +### 否决 2:把渲染层截断常数调大(2026-10-22,已撤销) + +根因在存储形态,不在渲染常数。§二④ 单独处理,且应是**按预算分配**而非改常数。 + +--- + +## 六、已拍板的决定 + +### 1. 事件本体在 L2 的形态(2026-10-22 拍板) + +**L2 就是 doc 存储,本身与 Context 同构,区别只在于是否被直接投喂给模型。** + +⇒ 不是“复用 `ContextEvent` 当旁挂字段”,也不是“把事件也变成块”这种额外抽象。 +**事件列表就是 L2 文档的主体**,与 L1 事件列表同一结构。 + +### 2. 正文形态(2026-10-22 拍板) + +**正文(包括多媒体正文)本身就内联在块里。** + +⇒ 文本块的 `Text` 是正文,媒体块的 `Data` 是字节。 +不存在“块 + 外部正文”的分离形态,也不靠渲染字符串承载原文。 + +### 3. SDK(2026-10-22 拍板) + +**记忆部分 SDK 已通过一套接口实现等价功能** ⇒ **不改公开 SDK 契约**。 + +对照表(`third_party/homeagent-sdk/sdk/memory.go`): + +| SDK 接口 | 等价的内核概念 | +|---|---| +| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) | +| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) | +| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) | +| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) | +| `MediaAttachment{Digest, MIME, Data}` | 块引用(带 Data 为新内容,仅 Digest 为引用) | + +⇒ 修的是内核实现没跟上契约语义,不是契约本身。 + +### 4. 存量数据(2026-10-22 拍板) + +**等待系统自然冲刷**,不做迁移脚本。 + +约束: +- 新代码必须能**读旧格式**(缺事件列表/缺文本块时降级),不得 panic、不得静默丢内容 +- 降级路径要有明确日志,便于判断存量退出进度 +- 旧格式文档被召回时按旧行为处理,**不伪造成“有原始事件”** + +--- + +## 七、待确认的细节(实现层面,不阻塞开工) + +1. **文本块与事件是一对一还是一对多?** + 一条事件生成一个文本块(`Input` + `Response` 合在一个 `Text` 里),还是各一块(便于分别检索)? + +2. **`Doc.Content` 保留为渲染视图,还是彻底删除?** + 若保留,与事件列表信息重复;若删除,SDK 的 `Doc.Content` 则由事件列表现场渲染填充。 + +3. **L2 事件的检索向量怎么算?** + 现在 `denseFor` 用 `Summary + Content`。改为事件列表后, + 是用 `Summary + 各事件正文拼接`,还是用文本块自己的向量做融合(与媒体块一致)? + +--- + +## 八、验收口径 + +- `go build ./...` 通过 +- 全量 `go test ./... -count=1` 0 失败(当前基线:58 包通过 / 0 失败) +- §三 每条判据有对应测试,且**变异测试**各自能被抓住 +- 生产侧(可在副本库验证):`doc_query` 召回后块不消失;新旧两种格式文档都能正确召回 + +--- + +## 九、实施记录(2026-10-22) + +### 已落地 + +| # | 动作 | 落地位置 | +|---|---|---| +| ① | L2 存储形态改为「事件 + 块」 | `document.Doc.Events []DocEvent`;`ContextToDoc` 落盘事件列表 | +| ② | 正文各自成文本块 | `docBlocksForEntry` / `entriesToEvents`;Input、Response 各一块(`BlockText`) | +| ③ | 召回按原始时序迁回 | `Agent.restoreDocEvents`;`DocEvent.InOut()` 从块读回正文 | +| ④ | 蒸馏改用原文 | `documentOriginalText`(有 Events 用块内原文,存量退回 Content) | +| ⑤ | 文档纠错 | `ARCHITECTURE.md` 删除「过滤 agentcli/terminal」,改为「由插件声明驱动」 | + +### Content 的定位(按拍板) + +`Doc.Content` **保留为渲染视图**,由 `contentFromEvents` 生成: + +- 供摘要、人工阅读、SDK 的 `Doc.Content` 字段使用 +- **不是**原文载体 —— 原文在 `Events[i].Blocks` 里文本块的 `Text` +- 视图也不主动截断(存储层不替渲染层做决定) + +### 存量 + +按拍板**不做迁移**,等系统自然冲刷。降级路径: + +- `Doc.HasEvents() == false` ⇒ 召回时整篇渲成一条 `cold_storage` 事件(旧行为) +- 不伪造成「有原始事件」;有日志可观测 + +### 判据与变异 + +新增两组判据(各条都在改动前设计、改动后可跑): + +| 文件 | 判据数 | 覆盖 | +|---|---|---| +| `internal/memory/document/blockflow_test.go` | 7 | 存储形态、文本块粒度、逐条保留、视图定位、幂等、存量兼容、原文可用性 | +| `internal/agent/core/recalldoc_test.go` | 5 | 逐条回填、原始时间戳、块迁回、回填不截断、存量降级 | + +**变异测试(全部被抓住)**: + +| 变异 | 被哪条抓住 | +|---|---| +| 正文落库时 truncate(100) | `TestL2_ArchivePreservesFullText` / `TestL2_ContentIsRenderedViewNotSource` | +| Input/Response 合成一块 | `TestL2_InputAndResponseAreSeparateTextBlocks`(及 3 条连带) | +| 召回用 `d.CreatedAt` 当时间戳 | `TestRecallByDoc_RestoresEachEventAtOriginalTimestamp` | +| 召回不回填 Blocks | `TestRecallByDoc_MigratesBlocksBackToContext` | +| 召回再截 2000 字 | `TestRecallByDoc_TextIsNotTruncatedOnTheWayBack` | + +### 未做(明确不做) + +- **不改公开 SDK 契约**:`TextEvent` / `DocMemoryAPI` / `InsertWithMedia` 已等价 +- **不迁移存量文档**:等自然冲刷 +- **不改渲染层截断常数**(失效 5):根因已在存储形态修掉;渲染按预算分配是独立议题, + 避免与本次修复混在一起、互相掩盖 diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 2f90b14b..951dae99 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -558,12 +558,20 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple { // 写入 L3 并以 document --contains--> block 边关联, // 不经过文本描述与 NLP 提取器。 - // NLP 通用提取 + // NLP 通用提取。 + // + // ★ 输入必须是**原文**,不能是视图。 + // + // 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染 + // (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取, + // 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。 + // + // 现在优先用事件里的完整正文;存量文档没有 Events 才退回 Content。 e := nlp.NewExtractor(nil) if embedder != nil { e.SetEmbedder(embedder) } - result := e.Extract(doc.Content) + result := e.Extract(documentOriginalText(doc)) if result != nil { for _, nt := range result.Triples { mt := nlp.ToMemoryTriple(nt) @@ -635,3 +643,33 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) { log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed) } + +// documentOriginalText 取文档的**原文**用于抽取/蒸馏。 +// +// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。 +// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。 +func documentOriginalText(doc *document.Doc) string { + if doc == nil { + return "" + } + if !doc.HasEvents() { + return doc.Content + } + var b strings.Builder + for _, ev := range doc.Events { + in, resp, _ := ev.InOut() + if in != "" { + b.WriteString(in) + b.WriteString("\n") + } + if resp != "" { + b.WriteString(resp) + b.WriteString("\n") + } + for _, tr := range ev.ToolResults { + b.WriteString(tr.Output) + b.WriteString("\n") + } + } + return b.String() +} diff --git a/internal/agent/core/mediaref_test.go b/internal/agent/core/mediaref_test.go index 45bd703c..0f2b6341 100644 --- a/internal/agent/core/mediaref_test.go +++ b/internal/agent/core/mediaref_test.go @@ -301,33 +301,42 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) { } // 块应已到达 L2,且身份不变。 + // + // ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块 + // (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。 + // 要断言的是**媒体块原样迁移**,而不是块总数。 var found *document.Doc for _, d := range docStore.RecentDocs(20) { - if len(d.Blocks) > 0 { - found = d - break + for _, b := range d.Blocks { + if b.ID == block.ID { + found = d + } } } if found == nil { - t.Fatal("归档文档应持有一等记忆块") + t.Fatal("媒体块未随归档事件迁移到 L2") } - if len(found.Blocks) != 1 { - t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks)) + var got *memory.MemoryBlock + for i := range found.Blocks { + if found.Blocks[i].ID == block.ID { + got = &found.Blocks[i] + } } - got := found.Blocks[0] if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME || got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) { - t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block) + t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", *got, block) } // 字节随块迁移(union) if string(got.Data) != string(block.Data) { t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data) } - // 同一块不能同时留在 L0。 + // 同一块不能同时留在 L0(L1)。 for _, e := range rc.Recent(100) { - if len(e.Blocks) > 0 { - t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks) + for _, b := range e.Blocks { + if b.ID == block.ID { + t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b) + } } } } diff --git a/internal/agent/core/recalldoc_test.go b/internal/agent/core/recalldoc_test.go new file mode 100644 index 00000000..11849601 --- /dev/null +++ b/internal/agent/core/recalldoc_test.go @@ -0,0 +1,286 @@ +package core + +// L2 → L1 召回协议的判据。 +// +// 依据(assets/docs/zh/ARCHITECTURE.md「记忆流转」② Document 层的读取分支): +// +// doc_query → Consume(读取并删除) +// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} +// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 +// +// 三条不变量: +// ① 逐条:N 条事件回到时间线,不是 1 条合成事件 +// ② 原始时间戳:用事件时刻,不是文档归档时刻 +// ③ 块随事件迁回:Consume 已删 L2,不回填就两层都不在 + +import ( + "strings" + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory" + "github.com/JianFeeeee/HologramAgent/internal/memory/document" +) + +// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。 +// +// Prune 无条件保护最近 10 条事件,只在更早的部分里挑归档对象, +// 所以要填充足够多的无关事件把目标挤出保护窗口。 +func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) { + t.Helper() + for _, ev := range target { + a.context.Append(ev) + } + for i := 0; i < 14; i++ { + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "filler", + Input: "无关填充对话,用来把目标事件挤出保护窗口", + Response: "好的。", + }) + } + if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 { + t.Fatal("Prune 未归档任何事件") + } +} + +func countSource(c *RelevanceContext, src string) int { + n := 0 + for _, e := range c.Recent(0) { + if e.Source == src { + n++ + } + } + return n +} + +// ① + ② 逐条回填,且每条用自己的原始时间戳。 +// +// 变异:改用 d.CreatedAt 当时间戳 ⇒ 红;改回单条合成事件 ⇒ 红。 +func TestRecallByDoc_RestoresEachEventAtOriginalTimestamp(t *testing.T) { + a := newToolTestAgent(t) + + base := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC) + var want []ContextEvent + for i := 0; i < 3; i++ { + want = append(want, ContextEvent{ + Timestamp: base.Add(time.Duration(i) * 7 * time.Minute), + Source: "archive-src", + Input: "档案事件-" + string(rune('A'+i)) + " 关于紫蓝红配色方案的讨论", + Response: "该事件的完整回答,编号 " + string(rune('A'+i)), + }) + } + archiveEvents(t, a, want) + + if n := countSource(a.context, "archive-src"); n != 0 { + t.Fatalf("归档后 L1 仍有 %d 条 archive-src", n) + } + + out := a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "紫蓝红配色方案", "top_k": float64(5)}, + }) + if strings.Contains(out, "未找到") { + t.Fatalf("doc_query 未召回:%s", out) + } + + seen := map[time.Time]string{} + for _, e := range a.context.Recent(0) { + if e.Source == "archive-src" { + seen[e.Timestamp] = e.Input + } + } + if len(seen) != 3 { + t.Fatalf("回填了 %d 条 archive-src 事件,期望 3"+ + "(逐条回填,不是合成一条;也不是换了个时间戳)", len(seen)) + } + for i := 0; i < 3; i++ { + ts := base.Add(time.Duration(i) * 7 * time.Minute) + if _, ok := seen[ts]; !ok { + t.Errorf("时间戳 %s 缺失 —— 回填必须用事件原始时刻(不是归档时刻)", + ts.Format("15:04:05")) + } + } + // 不得出现合成事件。 + for _, e := range a.context.Recent(0) { + if e.Source == "cold_storage" { + t.Errorf("出现 cold_storage 合成事件(%q)—— 有 Events 时应逐条回填", e.Input) + } + } +} + +// ③ 块随事件迁回 L1(迁移,不是复制也不是丢弃)。 +// +// 变异:不回填 Blocks ⇒ 红(块两层都消失 = 永久丢字节)。 +func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) { + a := newToolTestAgent(t) + + payload := []byte("recall-block-bytes") + b := mediaBlock("image/png", payload) + if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil { + t.Fatal(err) + } + + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Now().Add(-3 * time.Hour), + Source: "archive-src", + Input: "这张图请记住:紫蓝红三色带的配色参考", + Response: "记住了。", + Blocks: []memory.MemoryBlock{b}, + }}) + + if blocksInCtx(a.context, b.ID) { + t.Fatal("归档后块仍在 L1") + } + + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色参考", "top_k": float64(3)}, + }) + + var back *memory.MemoryBlock + for _, e := range a.context.Recent(0) { + for i := range e.Blocks { + if e.Blocks[i].ID == b.ID { + back = &e.Blocks[i] + } + } + } + if back == nil { + t.Fatal("召回后块没有迁回 L1 —— Consume 已删除 L2 文档,块会两层都不在(字节永久丢失)") + } + if string(back.Data) != string(payload) { + t.Errorf("迁回的块字节被改动: %q", string(back.Data)) + } + // 迁移而非复制。 + for _, d := range a.docStore.RecentDocs(50) { + for _, db := range d.Blocks { + if db.ID == b.ID { + t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量") + } + } + } +} + +// ④ 回填的正文必须是原文,不能再被截断。 +// +// 断点史:旧实现在召回时对 Content 再截 2000 字,且 Content 本身已是截断视图。 +func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) { + a := newToolTestAgent(t) + + longResp := strings.Repeat("长正文", 2000) + "【召回哨兵R4】" + archiveEvents(t, a, []ContextEvent{{ + Timestamp: time.Now().Add(-2 * time.Hour), + Source: "archive-src", + Input: "请记住这份长报告", + Response: longResp, + }}) + + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "长报告", "top_k": float64(3)}, + }) + + var got string + for _, e := range a.context.Recent(0) { + if e.Source == "archive-src" { + got = e.Response + } + } + if got == "" { + t.Fatal("未回填事件") + } + if got != longResp { + t.Errorf("回填正文被截断:len=%d,原长 %d", len(got), len(longResp)) + } + if !strings.Contains(got, "【召回哨兵R4】") { + t.Error("回填正文丢了结尾哨兵") + } +} + +// ⑤ 存量文档(无 Events)降级:整篇渲成一条事件,不丢弃。 +// +// 决定:存量等自然冲刷,但不得因为格式旧就召回不到。 +func TestRecallByDoc_LegacyDocDegrades(t *testing.T) { + a := newToolTestAgent(t) + + if err := a.docStore.Insert(&document.Doc{ + Summary: "紫蓝红三色带的旧格式文档", + Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图", + Source: "context_archived", + Meta: map[string]string{"content_hash": "legacy-hash-1"}, + }); 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.Fatalf("存量文档召回失败:%s", out) + } + + var found bool + for _, e := range a.context.Recent(0) { + if e.Source == "cold_storage" && strings.Contains(e.Response, "Events 引入之前") { + found = true + } + } + if !found { + t.Error("存量文档没有降级成合成事件 —— 会静默丢失整篇内容") + } +} + +func blocksInCtx(c *RelevanceContext, blockID string) bool { + for _, e := range c.Recent(0) { + for _, b := range e.Blocks { + if b.ID == blockID { + return true + } + } + } + return false +} + +// 压力:大批量事件回填不得退化(回填是逐条 InsertByTimestamp,本身 O(n) 插入)。 +func TestRecallByDoc_StressManyEvents(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + a := newToolTestAgent(t) + + const n = 120 + base := time.Date(2026, 2, 3, 4, 0, 0, 0, time.UTC) + var evs []ContextEvent + for i := 0; i < n; i++ { + evs = append(evs, ContextEvent{ + Timestamp: base.Add(time.Duration(i) * time.Second), + Source: "stress", + Input: "压力输入 " + strings.Repeat("内容", 10), + Response: "压力回答 " + strings.Repeat("正文", 20), + }) + } + archiveEvents(t, a, evs) + + start := time.Now() + if got := countSource(a.context, "stress"); got != 0 { + t.Fatalf("归档后仍有 %d 条", got) + } + a.executeDocTool(agentAPI.ToolCall{ + Name: "doc_query", + Arguments: map[string]interface{}{"query": "压力输入 内容", "top_k": float64(5)}, + }) + elapsed := time.Since(start) + + got := countSource(a.context, "stress") + if got == 0 { + t.Fatal("压力场景下一条也没回填") + } + // 回填 120 条不应超过 5 秒(实测远小于此;阈值防 O(n²) 退化)。 + if elapsed > 5*time.Second { + t.Errorf("回填 %d 条耗时 %v,疑似超线性", got, elapsed) + } + t.Logf("回填 %d 条耗时 %v", got, elapsed) +} diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 46626982..0d11288e 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -1037,24 +1037,33 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string { if len(d.Tags) > 0 { parts = append(parts, " 标签: "+strings.Join(d.Tags, ", ")) } - content := d.Content - if len(content) > 2000 { - content = content[:2000] + "..." + // ★★ 召回 = 把事件(连同它持有的块)迁回 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)降级:整篇渲成一个事件,保持既有行为。 + // 不伪造成「有原始事件」——那时归档确实只有渲染视图。 + content := d.Content + if labels := a.blockLabelsForDoc(d); labels != "" { + content = content + "\n关联媒体: " + labels + } + a.context.InsertByTimestamp(ContextEvent{ + Timestamp: d.CreatedAt, + Source: "cold_storage", + Input: fmt.Sprintf("加载文档记忆: %s", query), + Response: content, + }) } - // 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断, - // 截掉之后模型就不知道这篇文档带过图。 - if labels := a.blockLabelsForDoc(d); labels != "" { - content = content + "\n关联媒体: " + labels - } - a.context.InsertByTimestamp(ContextEvent{ - Timestamp: d.CreatedAt, - Source: "cold_storage", - Input: fmt.Sprintf("加载文档记忆: %s", query), - Response: content, - }) refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary)) } - return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)", + return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)", len(docs), strings.Join(refs, ", ")) case "doc_commit": @@ -1262,3 +1271,56 @@ func (a *Agent) currentFingerprint() string { } return a.multimodalSpace.Fingerprint() } + +// restoreDocEvents 把一篇 L2 文档的原始事件**逐条按原时间戳迁回 L1**。 +// +// 返回回填的事件条数;0 表示这篇文档没有可回填的原始事件(存量文档)。 +// +// ## 依据 +// +// ARCHITECTURE「记忆流转」② Document 层的读取分支: +// +// doc_query → Consume(读取并删除) +// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源} +// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除 +// +// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1, +// 块 ID 与向量全程不变(不重建、不重算)。 +// +// ## 块为什么要一起回填 +// +// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。 +// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。 +func (a *Agent) restoreDocEvents(d *document.Doc) int { + if a == nil || a.context == nil || d == nil || !d.HasEvents() { + return 0 + } + + n, blocks := 0, 0 + for _, ev := range d.Events { + input, response, held := ev.InOut() + a.context.InsertByTimestamp(ContextEvent{ + Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置 + Source: ev.Source, + Input: input, + Response: response, + ToolsUsed: append([]string(nil), ev.ToolsUsed...), + ToolResults: func() []ToolResultItem { + var out []ToolResultItem + for _, tr := range ev.ToolResults { + out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output}) + } + return out + }(), + Blocks: append([]memory.MemoryBlock(nil), held...), + }) + n++ + blocks += len(held) + } + + log.Printf("[agent] doc→context: %s 逐条回填 %d 条原始事件(%s → %s),块 %d 个迁回 L1", + d.ID, n, + d.Events[0].Timestamp.Format("01-02 15:04:05"), + d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks) + return n +} diff --git a/internal/memory/document/blockflow_test.go b/internal/memory/document/blockflow_test.go new file mode 100644 index 00000000..3e382d11 --- /dev/null +++ b/internal/memory/document/blockflow_test.go @@ -0,0 +1,523 @@ +package document + +// L1 ↔ L2 同构协议的判据。 +// +// 依据(不是我的推断): +// +// assets/docs/zh/ARCHITECTURE.md「三层记忆」 +// · L1 与 L2 结构完全同构,唯一区别是「是否被直接投喂给模型」 +// · 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节 +// · 任何一层都不压缩(视图不是记忆) +// +// 每条判据都对应一个真实发生过的断点,且做过变异测试(见各条注释)。 + +import ( + "encoding/json" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +func newDocTestStore(t *testing.T) *Store { + t.Helper() + s := NewStore(t.TempDir(), memory.TokenizeWords) + if err := s.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(s.Stop) + return s +} + +// ① 正文完整性:归档后能逐条取回**完整**正文。 +// +// 断点史:原实现 ContextToDoc 把每条事件拍平成一行, +// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。 +// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。 +// +// 变异:把 docBlocksForEntry 里文本块的 Text 改成截断版 ⇒ 本判据必须红。 +func TestL2_ArchivePreservesFullText(t *testing.T) { + s := newDocTestStore(t) + + longResp := strings.Repeat("这是一段用于验证原文完整性的长正文", 200) + "【哨兵ZQ7】" + longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】" + ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC) + + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: ts, + Source: "webui", + Content: longInput, + Response: longResp, + ToolsUsed: []string{"report_fetch"}, + ToolResults: []ToolResultItem{ + {Name: "report_fetch", Output: strings.Repeat("工具输出", 500) + "【工具哨兵M3】"}, + }, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + + var d *Doc + for _, cand := range s.RecentDocs(10) { + d = cand + } + if d == nil { + t.Fatal("没有归档出文档") + } + if !d.HasEvents() { + t.Fatal("文档没有 Events —— L2 必须与 L1 同构,事件列表是主体") + } + + ev := d.Events[0] + in, resp, _ := ev.InOut() + if in != longInput { + t.Errorf("Input 不完整:len=%d,原长 %d", len(in), len(longInput)) + } + if resp != longResp { + t.Errorf("Response 不完整:len=%d,原长 %d\n"+ + "L2 是 L1 的下沉层,不是摘要层 —— 正文不能在落库时被截断", len(resp), len(longResp)) + } + if !strings.Contains(resp, "【哨兵ZQ7】") { + t.Error("Response 丢了结尾哨兵") + } + if !strings.Contains(in, "【输入哨兵K9】") { + t.Error("Input 丢了结尾哨兵") + } + // 工具输出是事件结构的一部分,必须原样保留。 + if len(ev.ToolResults) != 1 || !strings.Contains(ev.ToolResults[0].Output, "【工具哨兵M3】") { + t.Errorf("工具输出不完整或丢失: %+v", ev.ToolResults) + } + // ToolsUsed 以前完全不存。 + if len(ev.ToolsUsed) != 1 || ev.ToolsUsed[0] != "report_fetch" { + t.Errorf("ToolsUsed 丢失: %+v", ev.ToolsUsed) + } + // 原始时刻(纳秒精度)必须保留,不能只剩 HH:MM。 + if !ev.Timestamp.Equal(ts) { + t.Errorf("时间戳不精确:got %s,want %s", ev.Timestamp, ts) + } +} + +// ② 正文各自成块:Input 一块、Response 一块,都是 BlockText。 +// +// 拍板(2026-10-22):正文各自一块,不合成一个块 —— 两段内容语义常不同, +// 分别成块才能各自被检索。 +// +// 变异:把两块合并成一块 ⇒ 本判据必须红。 +func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) { + s := newDocTestStore(t) + + media := memory.MemoryBlock{ + ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage, + Data: []byte("x"), MIME: "image/png", Size: 1, + } + _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), + Source: "qq", + Content: "用户问:紫蓝红三色带怎么配", + Response: "回答:紫蓝红按 6:3:1 配", + Blocks: []memory.MemoryBlock{media}, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + var texts []memory.MemoryBlock + for _, b := range d.Events[0].Blocks { + if b.Modality == memory.BlockText { + texts = append(texts, b) + } + } + if len(texts) != 2 { + t.Fatalf("一条事件应产出 2 个文本块(Input/Response 各一),实际 %d", len(texts)) + } + if texts[0].Text != "用户问:紫蓝红三色带怎么配" { + t.Errorf("第一个文本块应是 Input 正文,实际 %q", texts[0].Text) + } + if texts[1].Text != "回答:紫蓝红按 6:3:1 配" { + t.Errorf("第二个文本块应是 Response 正文,实际 %q", texts[1].Text) + } + // 文本块的 CreatedAt 必须是事件时刻(回填靠它定位)。 + if !texts[0].CreatedAt.Equal(d.Events[0].Timestamp) { + t.Error("文本块 CreatedAt 应等于事件时间戳") + } + // 媒体块仍在,且不被文本块顶掉。 + var hasMedia bool + for _, b := range d.Events[0].Blocks { + if b.ID == media.ID { + hasMedia = true + } + } + if !hasMedia { + t.Error("媒体块在归档后丢失") + } +} + +// ③ 逐条保留:一次归档 N 条事件,L2 里就是 N 条,不合并、不丢。 +// +// 变异:只保留第一条 ⇒ 红。 +func TestL2_KeepsEveryEventIndividually(t *testing.T) { + s := newDocTestStore(t) + + base := time.Date(2026, 5, 6, 8, 0, 0, 0, time.UTC) + var entries []ContextEntry + for i := 0; i < 5; i++ { + entries = append(entries, ContextEntry{ + Timestamp: base.Add(time.Duration(i) * 13 * time.Minute), + Source: "cli", + Content: "第" + string(rune('0'+i)) + "条输入", + Response: "第" + string(rune('0'+i)) + "条回答", + }) + } + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + if len(d.Events) != 5 { + t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events)) + } + for i, ev := range d.Events { + wantTS := base.Add(time.Duration(i) * 13 * time.Minute) + if !ev.Timestamp.Equal(wantTS) { + t.Errorf("第 %d 条时间戳错位: %s vs %s", i, ev.Timestamp, wantTS) + } + in, resp, _ := ev.InOut() + if in != "第"+string(rune('0'+i))+"条输入" || resp != "第"+string(rune('0'+i))+"条回答" { + t.Errorf("第 %d 条正文错位: input=%q response=%q", i, in, resp) + } + } +} + +// ④ Content 是视图而不是事实源:它由事件渲染,且不截断。 +// +// 拍板(2026-10-22):Content 保留为视图。 +// 判据要点:如果 Content 里还能看到完整正文,说明视图没有偷偷丢信息; +// 同时原文的权威来源是块,Content 改坏了也不影响 InOut()。 +func TestL2_ContentIsRenderedViewNotSource(t *testing.T) { + s := newDocTestStore(t) + long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】" + + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), Source: "webui", + Content: "输入", Response: long, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + if !strings.Contains(d.Content, "【视图哨兵】") { + t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断") + } + // 权威来源是块:把 Content 清空,InOut() 仍要能读回完整正文。 + d.Content = "" + _, resp, _ := d.Events[0].InOut() + if !strings.Contains(resp, "【视图哨兵】") { + t.Error("正文权威来源应是块,不是 Content") + } +} + +// ⑤ 幂等:同一批事件重复归档,块 ID 不变、事件不翻倍。 +// +// 为何重要:Prune 可能因重试/重复触发而对同一批事件归档两次; +// 块 ID 由内容派生,必须收敛而不是长出两套。 +func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) { + s := newDocTestStore(t) + ts := time.Date(2026, 7, 8, 9, 10, 11, 123456789, time.UTC) + entries := []ContextEntry{{ + Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答", + }} + + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + first := s.RecentDocs(1)[0] + id1 := first.Events[0].Blocks[0].ID + + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + // 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。 + if n := len(s.Stats()); n == 0 { + t.Fatal("stats 异常") + } + if got := len(s.RecentDocs(10)); got != 1 { + t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got) + } + d := s.RecentDocs(1)[0] + if len(d.Events) != 1 { + t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events)) + } + if d.Events[0].Blocks[0].ID != id1 { + t.Errorf("同一事件重复归档得到不同块 ID:%s → %s(块 ID 必须内容稳定)", + id1, d.Events[0].Blocks[0].ID) + } +} + +// ⑥ 存量兼容:没有 Events 的旧文档必须能被读,且不伪造成"有原始事件"。 +// +// 决定(2026-10-22):存量等系统自然冲刷,不迁移。 +// 但新代码必须能读旧格式 —— 否则升级瞬间老文档全部不可召回。 +func TestL2_LegacyDocWithoutEventsStillLoads(t *testing.T) { + dir := t.TempDir() + s := NewStore(dir, memory.TokenizeWords) + + // 手工写一份旧格式 JSON(无 events 字段)。 + legacy := map[string]interface{}{ + "id": "doc_legacy_1", + "summary": "旧格式文档", + "content": "[10:00] old: 这是旧格式的渲染视图", + "source": "context_archived", + "created_at": "2026-01-02T03:04:05Z", + "updated_at": "2026-01-02T03:04:05Z", + "access_count": 1, + "last_access": "2026-01-02T03:04:05Z", + } + raw, _ := json.Marshal(legacy) + if err := writeFile(dir+"/doc_legacy_1.json", raw); err != nil { + t.Fatal(err) + } + + if err := s.Start(); err != nil { + t.Fatalf("读取旧格式失败(不得 panic):%v", err) + } + got := s.Get("doc_legacy_1") + if got == nil { + t.Fatal("旧格式文档未被加载") + } + if got.HasEvents() { + t.Error("旧格式文档不该被伪造成有 Events") + } + if got.Content == "" { + t.Error("旧格式文档的 Content 视图丢失") + } +} + +func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) } + +// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。 +// +// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前 +// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析, +// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量 +// 「三元组 N 条全被实体名校验拒绝」即与此相关)。 +// +// 本判据守的是 document 包这一侧的能力: +// 只要给出原文,就能完整取回 —— 不被视图的形变影响。 +func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) { + s := newDocTestStore(t) + + full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】" + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), Source: "webui", + Content: "输入", Response: full, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + + // 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。 + d.Content = "视图被破坏了" + + var b strings.Builder + for _, ev := range d.Events { + _, resp, _ := ev.InOut() + b.WriteString(resp) + } + if got := b.String(); got != full { + t.Errorf("事件正文取不回原文:len=%d,原长 %d", len(got), len(full)) + } + if !strings.Contains(b.String(), "【原文哨兵D2】") { + t.Error("原文尾部丢失") + } +} + +// ────────────────────────────────────────────── +// 压力测试:新形态(事件列表 + 正文成块)的规模与并发 +// ────────────────────────────────────────────── + +// 大规模归档:500 条事件 → 1000 个文本块,必须能落盘再完整读回。 +// +// 关注点:新形态把每条事件变成 2 个块,块数翻倍。 +// 若某处仍按"一篇文档一个块"或"块数=事件数"假设,规模上来就会暴露。 +func TestL2_StressLargeArchive(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + const n = 500 + s := newDocTestStore(t) + + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + entries := make([]ContextEntry, n) + for i := 0; i < n; i++ { + entries[i] = ContextEntry{ + Timestamp: base.Add(time.Duration(i) * time.Second), + Source: "stress", + Content: strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)), + Response: strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)), + } + } + + if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + if len(d.Events) != n { + t.Fatalf("Events = %d,期望 %d", len(d.Events), n) + } + var textBlocks int + for _, ev := range d.Events { + for _, b := range ev.Blocks { + if b.Modality == memory.BlockText { + textBlocks++ + } + } + } + if textBlocks != 2*n { + t.Fatalf("文本块 = %d,期望 %d(每条事件 Input/Response 各一块)", textBlocks, 2*n) + } + + // 落盘再重新加载:验证序列化能承载这个规模、且逐条完整。 + s.Stop() + s2 := NewStore(s.dir, memory.TokenizeWords) + if err := s2.Start(); err != nil { + t.Fatalf("重新加载失败: %v", err) + } + defer s2.Stop() + + d2 := s2.Get(d.ID) + if d2 == nil { + t.Fatal("重新加载后文档丢失") + } + if len(d2.Events) != n { + t.Fatalf("重新加载后 Events = %d,期望 %d", len(d2.Events), n) + } + // 抽查首尾,确认正文没被合并或错位。 + for _, i := range []int{0, n / 2, n - 1} { + in, resp, _ := d2.Events[i].InOut() + wantIn := strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)) + wantResp := strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)) + if in != wantIn || resp != wantResp { + t.Errorf("第 %d 条正文错位: in=%q resp=%q", i, in, resp) + } + if !d2.Events[i].Timestamp.Equal(base.Add(time.Duration(i) * time.Second)) { + t.Errorf("第 %d 条时间戳错位", i) + } + } +} + +// 并发召回压力:Consume 持有写锁,而 QueryScored 走读锁 + markAccess。 +// 新形态下 Consume 要回填更多数据,不能引入竞态或丢文档。 +func TestL2_StressConcurrentQueryAndConsume(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + s := newDocTestStore(t) + + const docs = 60 + for i := 0; i < docs; i++ { + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now().Add(time.Duration(i) * time.Second), + Source: "stress", + Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)), + Response: "回答 " + string(rune('A'+i%26)), + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + for w := 0; w < 8; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < 40; i++ { + switch w % 3 { + case 0: + s.QueryScored("并发查询目标", 5) + case 1: + s.Consume("并发查询目标", 3) + case 2: + s.FindColdDocs(time.Hour, 10) + } + } + }(w) + } + wg.Wait() + // 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。 + if got := len(s.RecentDocs(1000)); got > docs { + t.Errorf("文档数异常增长: %d > %d", got, docs) + } +} + +// 大正文:单条事件 1MB 正文,必须完整往返(不截断、不 OOM)。 +func TestL2_StressLargePayloadRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("压力测试") + } + s := newDocTestStore(t) + + huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB + if _, err := s.ContextToDoc("context_archived", []ContextEntry{{ + Timestamp: time.Now(), Source: "stress", + Content: "输入", Response: huge, + }}, nil, nil, nil, nil); err != nil { + t.Fatal(err) + } + d := s.RecentDocs(1)[0] + _, resp, _ := d.Events[0].InOut() + if len(resp) != len(huge) { + t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge)) + } + if !strings.Contains(resp, "【巨尾哨兵P8】") { + t.Error("大正文尾部丢失") + } +} + +// ⑧ simpleHash 必须 O(n) 且定长。 +// +// 断点史(2026-10-22 压力测试实测): +// +// h += fmt.Sprintf("%x", c) // 每 rune 一次 Sprintf + 字符串拼接 ⇒ O(n²) +// +// 1.25MB 正文归档耗时 **270 秒**;且哈希串长度是内容的 2.2 倍 +// (生产实测 content 52072 字符 → content_hash 114491 字符)。 +// +// 本判据同时钉两点:长度定长(不随内容膨胀)、时间线性(不是 O(n²))。 +func TestL2_HashIsLinearAndFixedLength(t *testing.T) { + small := "小内容" + large := strings.Repeat("大内容", 200000) + + hs, hl := simpleHash(small), simpleHash(large) + if len(hs) != len(hl) { + t.Errorf("哈希长度随内容变化: %d vs %d(必须定长)", len(hs), len(hl)) + } + if len(hl) > 64 { + t.Errorf("哈希本身过长: %d 字符(会成为文档体积负担)", len(hl)) + } + + // 线性性:64 倍输入不应产生平方级增长。留足余量避免机器抖动误判。 + t0 := time.Now() + for i := 0; i < 32; i++ { + simpleHash(large) + } + big := time.Since(t0) + + t1 := time.Now() + for i := 0; i < 32; i++ { + simpleHash(large[:len(large)/64]) + } + sm := time.Since(t1) + + // O(n²) 下这个比值应接近 64²=4096;线性下约 64。 + // 取 300 作为阈值:远离线性(64),远低于平方(4096)。 + if sm > 0 && big > 300*sm { + t.Errorf("哈希耗时随输入超线性增长:大 %.1fms / 小 %.1fms = %.0f 倍"+ + "(输入差 64 倍,线性应约 64 倍,O(n²) 会到 4096 倍)", + float64(big.Microseconds())/1000, float64(sm.Microseconds())/1000, + float64(big)/float64(sm)) + } +} diff --git a/internal/memory/document/document.go b/internal/memory/document/document.go index f67c7f50..ec79ed28 100644 --- a/internal/memory/document/document.go +++ b/internal/memory/document/document.go @@ -1,6 +1,8 @@ package document import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "log" @@ -8,6 +10,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -22,24 +25,74 @@ import ( type ChannelCleaner func(source string) func(string) string // Doc — 记忆文档:由上下文提炼而来 +// +// ## L1/L2 同构 +// +// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。 +// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。 +// +// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段), +// 不是原文载体。原文在 Events 里,正文内联在文本块里。 type Doc struct { - ID string `json:"id"` - Summary string `json:"summary"` - Content string `json:"content"` - Tags []string `json:"tags"` - Entities []string `json:"entities"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Source string `json:"source"` - Meta map[string]string `json:"meta,omitempty"` - AccessCount int `json:"access_count"` - LastAccess time.Time `json:"last_access"` - Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) - Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) - DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径) - DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算 + 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"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Source string `json:"source"` + Meta map[string]string `json:"meta,omitempty"` + AccessCount int `json:"access_count"` + LastAccess time.Time `json:"last_access"` + + // Blocks 是本文档持有的**全部**块(事件里的文本块 + 媒体块去重后的并集)。 + // + // ★ 为什么还留一个扁平列表而不是只看 Events: + // · 向量检索/跨模态融合要能不遍历事件就拿到所有块; + // · L2→L3 蒸馏按块迁移; + // · 存量文档(无 Events)只有这一个字段有块。 + // + // ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。 + // 两者由 ContextToDoc 同一处产出,不得分别修改。 + Blocks []memory.MemoryBlock `json:"blocks,omitempty"` + + Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化) + DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径) + DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算 } +// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。 +// +// ★ 为何正文不在字段里、而在块里 +// +// 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节。 +// 事件只是块的容器。因此本结构**不重复存一份正文字符串** —— +// 那会出现「块里的正文」与「字段里的正文」两份真相。 +// +// 读原文 = 读 Blocks 里文本块的 Text。 +type DocEvent struct { + Timestamp time.Time `json:"ts"` + Source string `json:"source"` + ToolsUsed []string `json:"tools_used,omitempty"` + ToolResults []ToolResultItem `json:"tool_results,omitempty"` + + // Blocks 是本事件持有的块,**正文就在里面**。 + // + // 顺序约定(复原时按此顺序回填 ContextEvent): + // Input 文本块 → Response 文本块 → 媒体块… + // 由 docBlocksForEntry 保证;回填由 blocksToInOut 对称解读。 + Blocks []memory.MemoryBlock `json:"blocks,omitempty"` +} + +// HasEvents 报告这篇文档是否保存了可逐条回填的原始事件。 +// +// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到 +// 「把 Content 塞进一条事件」,而不是假装能回填。 +func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 } + // Store — 文档记忆存储。 // 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。 // Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。 @@ -234,18 +287,15 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ cleanFn = func(text string) string { return text } } - var parts []string - for _, e := range entries { - line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content) - if e.Response != "" { - line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) - } - for _, tr := range e.ToolResults { - line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200)) - } - parts = append(parts, line) - } - content := strings.Join(parts, "\n") + // ★★ 原文落位:正文进块,事件列表成为文档主体(L1/L2 同构) + // + // 此前这里把每条事件拍平成一行字符串,并对正文 truncate(..,100)、 + // 对工具输出 truncate(..,200) —— 原文在落库那一刻就不存在了。 + // 现在事件逐条保留,正文各自成为文本块(内联在块的 Text 里)。 + // + // Content 只是渲染视图(见 contentFromEvents),不是原文载体。 + events := docEventsFromEntries(entries) + content := contentFromEvents(events) contentHash := simpleHash(content) summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner) tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner) @@ -259,11 +309,12 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ d.UpdatedAt = time.Now() d.LastAccess = time.Now() d.Content = content + d.Events = events d.Source = source d.Summary = summary d.Tags = tags d.Entities = entities - d.Blocks = blocksFromEntries(entries) + d.Blocks = flattenEventBlocks(events) d.DenseVec = s.denseFor(d) if s.denseSpace != nil { d.DenseFP = s.denseSpace.Fingerprint() @@ -282,7 +333,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{ ID: id, Summary: summary, Content: content, Tags: tags, Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(), LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta, - Blocks: blocksFromEntries(entries), + Events: events, + Blocks: flattenEventBlocks(events), } s.docs[id] = doc doc.DenseVec = s.denseFor(doc) @@ -594,6 +646,7 @@ type ContextEntry struct { Source string Content string Response string + ToolsUsed []string ToolResults []ToolResultItem Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档 } @@ -738,17 +791,212 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool return entities } -func truncate(s string, max int) string { - if len([]rune(s)) <= max { - return s - } - return string([]rune(s)[:max]) + "..." +// simpleHash 是用于「内容是否同一份」比对的快速摘要。 +// +// ★ 旧实现是 O(n²) 且哈希串长度正比于内容长度: +// +// h := fmt.Sprintf("%x", len(s)) +// for _, c := range s { h += fmt.Sprintf("%x", c) } // 每 rune 一次 Sprintf + 字符串拼接 +// +// 两个后果都是实测出来的: +// +// 1. **性能**:1.25MB 正文归档耗时 270 秒(压力测试实测)。 +// 字符串 `+=` 每轮重新分配并拷贝整个累积串 ⇒ 总拷贝量 O(n²)。 +// 2. **体积**:哈希串本身是内容的 2.2 倍 +// (生产实测 content 52072 字符 → content_hash 114491 字符)。 +// 它存在每篇文档的 meta 里,等于把正文又存了一遍还更胖。 +// +// 换成 sha256 前 16 字节:定长 32 hex、O(n) 一次遍历。 +// 用途不变(仅在同一批归档里去重),不需要密码学强度之外的任何性质。 +func simpleHash(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:16]) } -func simpleHash(s string) string { - h := fmt.Sprintf("%x", len(s)) - for _, c := range s { - h += fmt.Sprintf("%x", c) - } - return h +// ────────────────────────────────────────────── +// L1 ↔ L2 的块级互转 +// +// 设计前提:正文(含多媒体正文)内联在块里,事件只是块的容器。 +// 因此归档 = 把事件正文落成文本块 + 保留事件原有的媒体块; +// 召回 = 从块读回正文,按原时间戳插回 L1。 +// ────────────────────────────────────────────── + +// entryTextBlockID 由「事件身份 + 正文角色」派生文本块 ID。 +// +// 为何不能像原句块那样只用内容派生: +// +// SentenceBlockID(sha256(text)) 会让**两句话恰好相同**的正文合并成一个块。 +// 对话里「好的。」「收到」这类短答应大量重复,合并后 +// 「哪一条事件说过这句话」就不可知了 —— 而 L2 必须能按事件逐条回填。 +// +// 所以 ID 掺入事件身份(时间戳纳秒 + 来源 + 角色),保证一事一块, +// 同时仍是**内容稳定**的:同一条事件重复归档得到同一个块 ID(幂等)。 +func entryTextBlockID(evtTS time.Time, source, role, text string) string { + h := sha256.New() + h.Write([]byte(strconv.FormatInt(evtTS.UnixNano(), 10))) + h.Write([]byte{0}) + h.Write([]byte(source)) + h.Write([]byte{0}) + h.Write([]byte(role)) + h.Write([]byte{0}) + h.Write([]byte(text)) + return "blk_txt_" + hex.EncodeToString(h.Sum(nil)[:12]) +} + +// TextBlockRole 标记文本块在一对事件里的角色。 +// +// 正文各自一块(不是 Input+Response 合成一块),为的是两段内容能 +// **分别按自己的向量被检索**:用户的提问与模型的回答语义常常不同。 +const ( + TextBlockRoleInput = "input" + TextBlockRoleResponse = "response" +) + +// docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。 +// +// 顺序约定(回填时按此对称解读): +// +// Input 文本块(若有)→ Response 文本块(若有)→ 媒体块… +// +// 媒体块原样保留(迁移语义:同一块 ID、同一向量,只换持有层)。 +func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock { + var out []memory.MemoryBlock + now := e.Timestamp + if now.IsZero() { + now = time.Now() + } + + // 文本块:正文内联在 Text 里,不带向量。 + // + // 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次, + // 单条事件的文本块不参与 L2 召回(它是回填用的载体)。 + // 这与「向量只算一次」一致 —— 不在迁移路径上重算。 + if t := strings.TrimSpace(e.Content); t != "" { + out = append(out, memory.MemoryBlock{ + ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t), + Modality: memory.BlockText, + Text: t, + Source: e.Source, + CreatedAt: now, + UpdatedAt: now, + }) + } + if t := strings.TrimSpace(e.Response); t != "" { + out = append(out, memory.MemoryBlock{ + ID: entryTextBlockID(now, e.Source, TextBlockRoleResponse, t), + Modality: memory.BlockText, + Text: t, + Source: e.Source, + CreatedAt: now, + UpdatedAt: now, + }) + } + out = append(out, e.Blocks...) + return out +} + +// blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的 +// Input / Response 正文与它持有的块。 +// +// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后), +// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。 +func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) { + if ev == nil { + return "", "", nil + } + for i := range ev.Blocks { + b := ev.Blocks[i] + if b.Modality != memory.BlockText { + held = append(held, b) + continue + } + switch { + case input == "" && isEntryTextBlock(b, ev, TextBlockRoleInput): + input = b.Text + case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse): + response = b.Text + default: + // 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。 + held = append(held, b) + } + } + return input, response, held +} + +// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。 +func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool { + return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text) +} + +// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。 +func docEventsFromEntries(entries []ContextEntry) []DocEvent { + if len(entries) == 0 { + return nil + } + out := make([]DocEvent, 0, len(entries)) + for _, e := range entries { + ev := DocEvent{ + Timestamp: e.Timestamp, + Source: e.Source, + ToolsUsed: append([]string(nil), e.ToolsUsed...), + Blocks: docBlocksForEntry(e), + } + for _, tr := range e.ToolResults { + ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output}) + } + out = append(out, ev) + } + return out +} + +// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。 +// +// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化, +// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。 +func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock { + seen := make(map[string]bool) + var out []memory.MemoryBlock + for _, ev := range events { + for i := range ev.Blocks { + b := ev.Blocks[i] + if b.ID == "" || seen[b.ID] { + continue + } + seen[b.ID] = true + out = append(out, b) + } + } + return out +} + +// contentFromEvents 用事件正文渲染出 Doc.Content。 +// +// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看, +// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。 +// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。 +// +// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息—— +// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。 +func contentFromEvents(events []DocEvent) string { + var parts []string + for _, ev := range events { + input, response, _ := blocksToEntry(&ev) + line := fmt.Sprintf("[%s] %s: %s", ev.Timestamp.Format("15:04"), ev.Source, input) + if response != "" { + line += " → " + response + } + for _, tr := range ev.ToolResults { + line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, tr.Output) + } + parts = append(parts, line) + } + return strings.Join(parts, "\n") +} + +// InOut 从一条 L2 事件读回它的 Input 正文、Response 正文与持有的块。 +// +// 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent, +// 而正文住在块里,所以必须由本包解读块的角色与归属。 +func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) { + return blocksToEntry(ev) }