mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
feat(memory): L2 与 L1 同构 —— 事件列表承载原文,正文内联在块里
块化重构(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 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
This commit is contained in:
523
internal/memory/document/blockflow_test.go
Normal file
523
internal/memory/document/blockflow_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user