Files
Cyrene_v2/internal/runtime/memory/working_session_test.go
T
AskaEth fbad02899a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败);
  NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**;
  新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512)
- P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64),
  组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变
- P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态);
  loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源
- 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5)
- 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 +
  n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达;
  **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原
- 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
2026-10-04 14:20:22 +08:00

108 lines
4.0 KiB
Go

package memory
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestWorkingMemory_RecordSession_Sliding_滑窗 证明会话窗口与默认窗口一样
// 是「最近 N 条」的滑窗,且只保留窗口内的最后 max 条。
func TestWorkingMemory_RecordSession_Sliding_滑窗(t *testing.T) {
t.Parallel()
w := NewWorkingMemory(3)
msgs := make([]model.Message, 0, 5)
for i := 0; i < 5; i++ {
msgs = append(msgs, model.TextMessage(model.RoleUser, string(rune('a'+i))))
}
w.RecordSession("sess-1", msgs)
win := w.SessionWindow("sess-1")
if len(win) != 3 {
t.Fatalf("会话窗口长度 = %d, want 3(滑窗)", len(win))
}
for i, want := range []string{"c", "d", "e"} {
if got := win[i].Content[0].Text; got != want {
t.Errorf("窗口[%d] = %q, want %q", i, got, want)
}
}
// 摘要由该会话窗口推导,因此非空且带消息条数。
summary := w.SummarizeSession("sess-1")
if !strings.Contains(summary, "3 条消息") {
t.Fatalf("SummarizeSession = %q, want 含 3 条消息", summary)
}
if w.Summarize() != "" {
t.Fatalf("默认窗口被会话写入污染: %q", w.Summarize())
}
// 返回的切片与内部状态脱离。
win[0].Content[0].Text = "mutated"
if again := w.SessionWindow("sess-1"); again[0].Content[0].Text != "c" {
t.Fatalf("SessionWindow 返回了共享切片: %q", again[0].Content[0].Text)
}
}
// TestWorkingMemory_SessionWindow_IsolatedPerSession_会话隔离 证明窗口
// 按会话 id 隔离 —— 这是群聊/陌生人的工作记忆不混进主人会话的前提。
func TestWorkingMemory_SessionWindow_IsolatedPerSession_会话隔离(t *testing.T) {
t.Parallel()
w := NewWorkingMemory(10)
w.RecordSession("web:owner", []model.Message{model.TextMessage(model.RoleUser, "主人的话")})
w.RecordSession("sess-group", []model.Message{model.TextMessage(model.RoleUser, "群里的话")})
owner := w.SessionWindow("web:owner")
if len(owner) != 1 || owner[0].Content[0].Text != "主人的话" {
t.Fatalf("owner 窗口 = %+v, want 只有主人的话(会话隔离)", owner)
}
group := w.SessionWindow("sess-group")
if len(group) != 1 || group[0].Content[0].Text != "群里的话" {
t.Fatalf("group 窗口 = %+v, want 只有群里的话", group)
}
if s := w.SummarizeSession("web:owner"); strings.Contains(s, "群里的话") {
t.Fatalf("owner 摘要混入了别的会话内容: %q", s)
}
if got := w.SessionWindow("never-seen"); len(got) != 0 {
t.Fatalf("未知会话窗口 = %+v, want 空", got)
}
if got := w.SummarizeSession("never-seen"); got != "" {
t.Fatalf("未知会话摘要 = %q, want 空", got)
}
// 空会话 id 是空操作:无键的写入无从隔离,绝不落到默认窗口。
w.RecordSession("", []model.Message{model.TextMessage(model.RoleUser, "无键写入")})
if got := w.Summarize(); got != "" {
t.Fatalf("空会话 id 的写入污染了默认窗口: %q", got)
}
}
// TestWorkingMemory_Sessions_Bounded_会话窗口有界 证明保留的会话窗口数
// 有上限:会话数随渠道消息无界增长,最久未用的窗口被淘汰,内存不失控。
func TestWorkingMemory_Sessions_Bounded_会话窗口有界(t *testing.T) {
t.Parallel()
w := NewWorkingMemory(10)
for i := 0; i < maxWorkingSessions; i++ {
w.RecordSession(sessionName(i), []model.Message{model.TextMessage(model.RoleUser, "x")})
}
// 超出上限后再写入一个新会话:最久未用的第一个会话被淘汰。
w.RecordSession("overflow", []model.Message{model.TextMessage(model.RoleUser, "y")})
if got := len(w.SessionWindow(sessionName(0))); got != 0 {
t.Fatalf("最久未用的会话窗口仍在(%d 条): 会话窗口数没有上限", got)
}
if got := len(w.SessionWindow("overflow")); got != 1 {
t.Fatalf("新会话窗口 = %d 条, want 1", got)
}
if got := len(w.SessionWindow(sessionName(maxWorkingSessions - 1))); got != 1 {
t.Fatalf("最近使用的会话窗口被误淘汰: %d 条", got)
}
}
// sessionName 生成第 i 个会话 id。
func sessionName(i int) string {
return "sess-" + string(rune('A'+i%26)) + string(rune('a'+i/26))
}