feat: 段页式记忆流转、coldcontext 重命名与安全治理

记忆层:
- MemoryBlock 承载 FrameTS/PageSeq/PageKind/BlockRole/CallID
- coldcontext 归档与回填按完整页传递,工具调用参数和配对键不丢失
- 召回先估算总 token,超预算时按全局页相似度标记 inactive
- inactive 仅是 context 渲染期状态,不落盘、不进入 coldcontext,Prune 后重置
- Peek/确认/Remove 两阶段召回,失败保留冷上下文副本
- stablemem 记录 last_recalled_at,删块连带清理边

命名:
- internal/memory/document → internal/memory/coldcontext
- Doc/DocEvent/DocHit → ColdDoc/ColdEvent/ColdHit
- ContextToColdContext/RecentColdContexts/FindColdContexts
- 保留既有 JSON 标签、doc_ 文件前缀与 doc_count 状态键,兼容存量数据

安全治理:
- 工具策略门禁、安全配置、事件总线发布者身份与入站脱敏
- 声明式 SecurityAPI 与 QQ owner/guest profile
- 日志保留策略与相关 e2e/回归判据

验证:go build ./...、go vet ./...、go test ./...(62 包);
生产副本 e2e:7013 块迁移后 ID/向量/指纹 0 变化、0 缺失。
This commit is contained in:
JianFeeeee
2026-10-29 23:53:48 +08:00
parent a3e50b395f
commit b850feaef2
113 changed files with 6512 additions and 790 deletions

View File

@ -0,0 +1,318 @@
package coldcontext
// 页结构在**生产数据副本**上的验收(2026-10-29)。
//
// ## 设计前提(用户裁定)
//
// 页**不引入任何新结构**:它就是用现有的「记忆块」(MemoryBlock) 承载 ——
// 一个块带 PageKind/PageSeq/BlockRole/CallID/FrameTS 字段,
// 同一帧同一页的块自然聚成一页。没有 page 表、没有 Page 类型、没有第二份数据。
//
// 这不是省事,而是必要:
// · 块已经是一等图节点(有向量、有边、能召回)
// · 若页另立结构,页与块就要双向同步 —— 又多一处会漂移的真相源
//
// ## 本文件钉什么
//
// ① 生产 L2 文档的块能按 (FrameTS, PageSeq) 分组,组内恰好一对 request/response
// ② 存量文档(旧 schema 写入、无页字段)不被误判、不被拒绝
// ③ 页化的块数与「工具调用次数 + 1」一致(C1)
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// loadProdDocs 从生产 L2 副本目录加载文档(走真实 Store 的读取路径)。
func loadProdDocs(t *testing.T) *Store {
t.Helper()
src := os.Getenv("HA_PROD_DOCS")
if src == "" {
t.Skip("未设置 HA_PROD_DOCS(生产 documents 目录副本),跳过")
}
if _, err := os.Stat(src); err != nil {
t.Skip("生产文档副本不存在,跳过:" + err.Error())
}
s := NewStore(t.TempDir(), nil)
entries, err := os.ReadDir(src)
if err != nil {
t.Fatal(err)
}
loaded := 0
for _, e := range entries {
if filepath.Ext(e.Name()) != ".json" {
continue
}
raw, err := os.ReadFile(filepath.Join(src, e.Name()))
if err != nil {
continue
}
var d ColdDoc
if err := json.Unmarshal(raw, &d); err != nil {
continue
}
if d.ID == "" {
d.ID = e.Name()[:len(e.Name())-5]
}
s.docs[d.ID] = &d
loaded++
}
t.Logf("载入生产文档 %d 篇", loaded)
if loaded == 0 {
t.Skip("没有可用文档,跳过")
}
return s
}
// ① 生产 L2 的块能按页分组;存量块(无页字段)不参与分组但也不被丢弃。
func TestProdPage_BlocksGroupIntoPages(t *testing.T) {
s := loadProdDocs(t)
var totalBlocks, framedBlocks, legacyBlocks int
var pageGroups int
for _, d := range s.docs {
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
// 按 (FrameTS, PageSeq) 分组
groups := map[string][]memory.MemoryBlock{}
for _, b := range ev.Blocks {
totalBlocks++
if b.FrameTS.IsZero() {
legacyBlocks++
continue
}
framedBlocks++
key := b.FrameTS.Format("2006-01-02T15:04:05.000000000") + "|" + itoa(b.PageSeq)
groups[key] = append(groups[key], b)
}
pageGroups += len(groups)
}
}
t.Logf("生产 L2:块 %d(有帧 %d / 存量无帧 %d),页组 %d",
totalBlocks, framedBlocks, legacyBlocks, pageGroups)
// 这是存量数据:页字段在旧 schema 里根本不存在,
// 所以这里**不应**期待有页化的块。断言的重点是"不被错误标记"。
if framedBlocks > 0 {
t.Logf("注意:存量数据里出现 %d 个带帧的块(可能已被新版本归档过)", framedBlocks)
}
if totalBlocks == 0 {
t.Fatal("生产 L2 里一个块都没有 —— 读取路径坏了")
}
}
// ② + ③ 对**真实事件**跑页化,验证块数 = 工具调用数 + 1 页(C1)。
//
// 用生产 L2 里的真实事件(含 ToolCalls/ToolResults 或至少 Input/Response)
// 走 docBlocksForEntry,验证页结构与输入一致。
func TestProdPage_SplitMatchesRealEvents(t *testing.T) {
s := loadProdDocs(t)
checked, withTools := 0, 0
for _, d := range s.docs {
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
// 把 ColdEvent 转成 ContextEntry(生产回填的真实方向)
entry := ContextEntry{
Timestamp: ev.Timestamp,
Source: ev.Source,
Content: firstText(ev, memory.RoleRequest),
Response: firstText(ev, memory.RoleResponse),
ToolCalls: ev.ToolCalls,
ToolResults: ev.ToolResults,
Blocks: ev.Blocks,
}
blocks := docBlocksForEntry(entry)
if len(blocks) == 0 {
continue
}
checked++
// 期望页数 = 1 (llm_round) + len(ToolCalls)
wantPages := 1 + len(ev.ToolCalls)
byPage := map[int][]memory.MemoryBlock{}
for _, b := range blocks {
if b.PageKind == "" {
continue // 事件持有的原始块(媒体等),不算页
}
byPage[b.PageSeq] = append(byPage[b.PageSeq], b)
}
if len(byPage) > wantPages {
t.Fatalf("★ 页数超出预期: 得到 %d 页,期望 ≤%d(工具调用 %d 次)",
len(byPage), wantPages, len(ev.ToolCalls))
}
if len(ev.ToolCalls) > 0 {
withTools++
}
// 同一帧的所有页共享 FrameTS(铁律)
var frame = blocks[0].FrameTS
for _, b := range blocks {
if b.FrameTS.IsZero() {
continue
}
if !b.FrameTS.Equal(frame) {
t.Fatalf("★ 同帧块分裂: %v vs %v", b.FrameTS, frame)
}
}
if checked >= 200 {
break
}
}
if checked >= 200 {
break
}
}
t.Logf("检查 %d 条真实事件(含工具调用的 %d 条)", checked, withTools)
if checked == 0 {
t.Skip("没有可检查的事件")
}
}
func firstText(ev ColdEvent, role memory.BlockRole) string {
for _, b := range ev.Blocks {
if b.PageKind == memory.PageLLMRound && b.BlockRole == role {
return b.Text
}
}
return ""
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var buf [8]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// 用生产**真实正文**构造带工具调用的事件,验证新归档路径把页结构落在块上。
//
// ## 为什么必须用生产正文
//
// 合成正文("test"/"foo")在分词、向量、去重上都与真实文本行为不同。
// 生产里出现的是长中文段落、代码片段、路径、甚至空 Response ——
// 这些才会触发「块 ID 派生」「空页跳过」「同一帧多页」的边界。
func TestProdPage_RealTextBecomesPages(t *testing.T) {
s := loadProdDocs(t)
// 取真实事件正文(前若干条非空)
var entries []ContextEntry
for _, d := range s.docs {
if len(entries) >= 20 {
break
}
if d == nil || len(d.Events) == 0 {
continue
}
for _, ev := range d.Events {
body := firstText(ev, memory.RoleRequest)
if body == "" && len(ev.Blocks) > 0 {
body = ev.Blocks[0].Text
}
if len([]rune(body)) < 20 {
continue // 太短的正文不足以验证分词/派生
}
entries = append(entries, ContextEntry{
Timestamp: ev.Timestamp,
Source: ev.Source,
Content: body,
Response: firstText(ev, memory.RoleResponse),
// 真实数据没有 ToolCalls,这里补上以验证新路径
ToolCalls: []ToolCallItem{
{CallID: "c_real_" + itoa(len(entries)),
Name: "cmd_run",
Arguments: map[string]interface{}{"command": "df -h " + minRune(body, 12)},
RawArguments: `{"command":"df -h"}`},
},
ToolResults: []ToolResultItem{
{CallID: "c_real_" + itoa(len(entries)),
Name: "cmd_run", Output: "/dev/sda1 62% /"},
},
})
if len(entries) >= 20 {
break
}
}
}
if len(entries) == 0 {
t.Skip("没有足够长的真实正文,跳过")
}
for _, e := range entries {
blocks := docBlocksForEntry(e)
// ① llm_round 页必须有 request(正文非空)
var llmReq, llmResp int
var toolReqs, toolResps int
for _, b := range blocks {
switch {
case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleRequest:
llmReq++
case b.PageKind == memory.PageLLMRound && b.BlockRole == memory.RoleResponse:
llmResp++
case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleRequest:
toolReqs++
case b.PageKind == memory.PageToolRound && b.BlockRole == memory.RoleResponse:
toolResps++
}
}
if llmReq != 1 {
t.Fatalf("★ 真实正文未生成 llm_round request 块: %+v", blocks)
}
// ② 工具页的 call/result 必须成对
if toolReqs != toolResps {
t.Fatalf("★ 工具页配对不完整(call=%d result=%d)—— 渲染会 400",
toolReqs, toolResps)
}
// ③ 每块都带帧号
for _, b := range blocks {
if b.FrameTS.IsZero() {
t.Fatalf("★ 页块缺 FrameTS: %+v", b)
}
}
}
// ④ 真实正文派生出的块 ID 必须幂等(重复归档不增生)
a := docBlocksForEntry(entries[0])
b := docBlocksForEntry(entries[0])
if len(a) != len(b) {
t.Fatalf("重复派生块数不同: %d vs %d", len(a), len(b))
}
for i := range a {
if a[i].ID != b[i].ID {
t.Fatalf("★ 块 ID 不幂等: %q vs %q", a[i].ID, b[i].ID)
}
}
t.Logf("用 %d 条真实正文验证页化:llm_round + tool_round 结构正确", len(entries))
}
func minRune(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n])
}