feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
This commit is contained in:
AskaEth committed 2026-10-06 08:04:12 +08:00
1 parent fe4c702f87
commit 6e28b38b08
37 files changed
+4011 -75

No files matched your search

+26 -2
View File
@@ -320,9 +320,33 @@ MEMORY_TIDY_QUIET_END_HOUR=7
# **不是**合并判据 —— 能否合并由「事实集合包含关系(A ⊆ B)+ 规则否决」决定
# (实据:必合对 0.8330 与必不合对 0.7100 只差 0.12,单阈值双向失效)。
MEMORY_TIDY_RECALL_THRESHOLD=0.80
# 每分区每轮的扫描上限(默认 5000)与候选上限(默认 20)。
# 每分区每轮的扫描上限(默认 5000)。
MEMORY_TIDY_SCAN_LIMIT=5000
MEMORY_TIDY_CANDIDATE_LIMIT=20
# 每分区每轮的**候选**上限,默认 200。⚠️ 它是**每分区**的量,不是全库总量。
# 2026-10-06 由 20 抬到 200:首轮真跑时四个分区同时打满旧值,候选数(77)由上限
# 而不是规则决定;命中上限时响应里的 `truncated` 会带 `<分区>(candidates)`。
MEMORY_TIDY_CANDIDATE_LIMIT=200
# 每分区每轮**真正参与两两比对**的条目上限,默认 2000(性能护栏:两两相似度是
# O(n²·dim))。2026-10-06 由写死的 400 抬到 2000 并开放为 env 键:首轮真跑时
# 459 条的分区被 400 截断,于是 compared(705) < scanned(784) —— 观测口径被上限
# 决定。命中时响应里的 `truncated` 会带 `<分区>(compare)`,日志有 WARN。
MEMORY_TIDY_COMPARE_LIMIT=2000
# ---- 记忆「入库前审查」(design/23 §15.1 D3 · 切片 MT-B) ----
# 新记忆落库后,由**独立的记忆管理会话**(sess-memorytidy)在有界后台队列里判定它是
# merge / modify / create / skip;结论以**软合并**回写(status + merged_into /
# merged_from / merge_count,**可 revert、绝不改 content**)。
# 总开关(默认 true):置 false ⇒ 组合根根本不建它,写入路径行为**逐字回到**改动之前。
MEMORY_REVIEW_ENABLED=true
# 有界审查队列长度(默认 256)。⚠️ 队列满时**该条不做审查**(记忆本身早已落库,
# 绝不因队列满而丢记忆),只记一条带来源的 WARN。
MEMORY_REVIEW_QUEUE_SIZE=256
# 每条审查在同分区内最多看多少条既有记忆(top-K 上限,默认 200)。
MEMORY_REVIEW_SCAN_LIMIT=200
# 单条审查的墙钟上限(秒,默认 20):超时 ⇒ **不做任何改写**,记忆照旧保留 + WARN。
MEMORY_REVIEW_TIMEOUT_SEC=20
# 注:上面四个键 ≤0 一律**视为「用默认」**(由 memory 包 normalize 收敛,避免笔误让进程起不来);
# 召回门阈值复用 MEMORY_TIDY_RECALL_THRESHOLD,不另立键。
# ---- 唤醒式能力渐进(capacity 意图判定,design/14 §2.2) ----
# 总开关(默认 false):关闭时 loop 向模型暴露【全部】已注册工具,行为与接线前逐字节一致;
+6
View File
@@ -96,6 +96,12 @@ func run() error {
if a.memoryTidy != nil {
a.memoryTidy.Start(ctx)
}
// 入库前审查(design/23 §15.1 D3):有界后台队列,在 store 的写入路径上
// 接住每条新记忆。启动即就绪(它不做「启动即跑」,只等写入侧投递);
// 配置关闭时组合根根本没建它(nil),写入路径逐字与改动前一致。
if a.memoryReview != nil {
a.memoryReview.Start(ctx)
}
// 文档回收(切片 S7):启动期清扫一次 + 此后按 DOCUMENT_GC_INTERVAL 定时清扫。
// 它不等这一次跑完就去拉网关(见 wire_document_gc.go 的取舍 1)。
a.startDocumentGC(ctx)
+7 -3
View File
@@ -19,8 +19,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/persona/emotion"
personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/events"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/exec"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/flowgraph"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
@@ -74,8 +74,12 @@ type app struct {
retriever *memory.Retriever
// embedder 与 memoryTidy 属于记忆整理(design/23):整理只在 embedder
// 上算召回相似度,绝不接触会话/历史读数。
embedder embedding.Embedder
memoryTidy *memory.TidyService
embedder embedding.Embedder
memoryTidy *memory.TidyService
// memoryReview 是 D3「入库前审查」(design/23 §15.1 D3):新记忆落库后由
// 独立的记忆管理会话判定 merge/modify/create/skip。它同样只依赖记忆存储
// 与 embedder,拿不到任何会话/历史读数。未启用时为 nil(写入路径不变)。
memoryReview *memory.PreInsertReviewer
projStore *projectindex.Store
loop *loop.Loop
scheduler *orchestration.Scheduler
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
// 组合根。三件事只在这里定:
//
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
// 心跳并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储与 embedder —— 服务侧拿不到任何会话/历史读数,
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
// 而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 memoryTidy 同
// 一手法。未就绪时只记日志,绝不假装写过审计。
//
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
// 逐字回到本次改动之前(纯增量,无破坏性变更)。
func (a *app) wireMemoryReview() error {
cfg, err := config.LoadMemoryReviewConfig(envPath)
if err != nil {
return fmt.Errorf("memory review config: %w", err)
}
if !cfg.Enabled {
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
return nil
}
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: cfg.Enabled,
QueueSize: cfg.QueueSize,
ScanLimit: cfg.ScanLimit,
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
Threshold: a.cfg.MemoryTidy.RecallThreshold,
}, memory.ReviewDeps{
Store: a.memStore,
Embedder: a.embedder,
Audit: a.auditMemoryReview,
Log: a.log,
})
if err != nil {
return fmt.Errorf("memory ingest review: %w", err)
}
a.memStore.AttachIngestReviewer(reviewer)
a.memoryReview = reviewer
return nil
}
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
func (a *app) auditMemoryReview(who, what, result string) {
if a.gw != nil {
a.gw.AuditEvent(who, what, result)
return
}
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review audit dropped: gateway not ready",
"who", who, "what", what, "result", result)
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定 D3「入库前审查」(design/23 §15.1 D3)在**组合根**上的三件事:
// 1. 跑在与整理同一个专属隔离会话(sess-memorytidy),不是主人的对话车道;
// 2. 接线存在(服务被装配、被接到 store 的写入路径、被启动、被停止),
// 少一处就会**静默不工作**(不报错、不审查、日志里一句都没有);
// 3. 「独立会话、不碰主会话上下文」是**可验证**的:审查跑完后主会话的消息条数、
// 最后一条消息与已读游标逐字不变,且 chat_messages 里没有审查会话的行。
// reviewEmbedder 是组合根测试用的确定性 embedder:所有文本同向(余弦 1),
// 因此「同内容的两条记忆」一定被召回,审查的判定路径真的会跑到。
type reviewEmbedder struct{}
func (reviewEmbedder) Embed(_ context.Context, texts []string) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(texts))
for range texts {
out = append(out, embedding.Vector{1, 0})
}
return out, nil
}
func (reviewEmbedder) Dim() int { return 2 }
// TestMemoryReviewIsWired 是接线存在性防回归:组合根少任何一处,入库前审查
// 都会静默不工作,而这不会让别的用例变红。
//
// 刻意**不**在这里断言 `.env.example`:该文件由并行切片(MT-A)持有,
// 新增的 MEMORY_REVIEW_* 键已登记在 MTB-report.md 里,由主会话统一补模板。
func TestMemoryReviewIsWired(t *testing.T) {
t.Parallel()
seams := map[string][]string{
"wire_runtime.go": {
// 记忆栈建好后就装配审查(它只需要 store + embedder)。
"a.wireMemoryReview()",
},
"wire_memory_review.go": {
"config.LoadMemoryReviewConfig(envPath)",
"memory.NewPreInsertReviewer(memory.ReviewConfig{",
// 依赖面里**没有**任何会话/历史读数 —— 这是「独立会话、零上下文」的
// 结构性保证,而不是靠自觉。
"Audit: a.auditMemoryReview",
// 接到 store 的唯一写入路径上。
"a.memStore.AttachIngestReviewer(reviewer)",
},
"main.go": {
"a.memoryReview.Start(ctx)",
},
"wire_services.go": {
// 关库之前先停审查(它要写 memory_items)。
"a.memoryReview.Stop(ctx)",
},
}
for file, wants := range seams {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, want := range wants {
if !strings.Contains(string(src), want) {
t.Fatalf("%s 里找不到 %q —— 入库前审查没有被接上组合根", file, want)
}
}
}
// store 侧的唯一写入路径必须投递审查(否则新记忆永远不进队列)。
storeSrc, err := os.ReadFile("../../internal/runtime/memory/store.go")
if err != nil {
t.Fatalf("read memory/store.go: %v", err)
}
if !strings.Contains(string(storeSrc), "s.enqueueReview(item)") {
t.Fatal("memory/store.go 的写入路径上没有投递审查(写路径唯一)")
}
}
// TestMemoryReviewSessionIsNotConversationLane 与整理同一条口径:审查会话不落在
// 主人的对话车道("web" / "web:<uuid>")上,因此不改她的心情、不进主会话泳道。
func TestMemoryReviewSessionIsNotConversationLane(t *testing.T) {
t.Parallel()
if isConversationLane(memory.TidySessionID) {
t.Fatalf("审查会话 %q 被判成主人对话回合", memory.TidySessionID)
}
if !isConversationLane(history.MainSessionID) {
t.Fatal("主人车道判定被改坏了")
}
}
// TestMemoryReviewLeavesChatHistoryUntouched 是 design/23 §15 D3-7 的可验证版本:
// 审查真的干活(把重复行折回既有行)之后,主会话的消息条数、最后一条消息与
// read_at 游标逐字不变,且 chat_messages 里没有任何属于审查会话的行。
func TestMemoryReviewLeavesChatHistoryUntouched(t *testing.T) {
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
hist, err := history.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("history store: %v", err)
}
if _, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleUser,
Content: "今晚又要加班到很晚", CreatedAt: 1_700_000_000_000,
}); err != nil {
t.Fatalf("append user message: %v", err)
}
last, err := hist.AppendFull(ctx, history.Message{
SessionID: history.MainSessionID, Role: history.RoleAssistant,
Content: "别熬太晚,我陪你", CreatedAt: 1_700_000_001_000,
})
if err != nil {
t.Fatalf("append assistant message: %v", err)
}
if _, err := hist.MarkSessionRead(ctx, history.MainSessionID, time.UnixMilli(1_700_000_000_500)); err != nil {
t.Fatalf("mark read: %v", err)
}
before := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
content := "主人常在深夜与助手聊天,有熬夜习惯"
seed := func(at time.Time) memory.MemoryItem {
got, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9, CreatedAt: at,
SourceConversation: memory.FormatSourceConversation(history.MainSessionID, "turn-1"),
})
if err != nil {
t.Fatalf("seed memory: %v", err)
}
return got
}
seed(time.UnixMilli(1_700_000_000_000))
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
Enabled: true, QueueSize: 8, ScanLimit: 20, Timeout: 5 * time.Second, Threshold: 0.8,
}, memory.ReviewDeps{Store: store, Embedder: reviewEmbedder{}, Log: tidyTestLog()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
reviewer.Start(ctx)
defer func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = reviewer.Stop(stopCtx)
}()
dup := seed(time.UnixMilli(1_700_000_060_000))
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var status string
if err := conn.QueryRowContext(ctx,
"SELECT status FROM memory_items WHERE id = ?", dup.ID).Scan(&status); err != nil {
t.Fatalf("read dup status: %v", err)
}
if status == "merged" {
break
}
time.Sleep(5 * time.Millisecond)
}
after := readConversationFingerprint(t, ctx, conn, history.MainSessionID)
if before != after {
t.Fatalf("审查改动了主会话上下文:\n before=%+v\n after=%+v", before, after)
}
if after.lastContent != last.Content || after.lastID != last.ID {
t.Fatalf("主会话最后一条消息变了:%+v", after)
}
var reviewMsgs int
if err := conn.QueryRowContext(ctx,
"SELECT count(*) FROM chat_messages WHERE session_id = ?", memory.TidySessionID).Scan(&reviewMsgs); err != nil {
t.Fatalf("count review messages: %v", err)
}
if reviewMsgs != 0 {
t.Fatalf("审查向 chat_messages 写入了 %d 行(§15 D3-7 要求零写入)", reviewMsgs)
}
reviews, err := store.ListIngestReviews(ctx, 10)
if err != nil {
t.Fatalf("ListIngestReviews: %v", err)
}
if len(reviews) != 1 || reviews[0].Decision != memory.IngestSkip {
t.Fatalf("审查留痕 = %+v, want 恰为一条 skip", reviews)
}
}
+5
View File
@@ -118,6 +118,11 @@ func (a *app) wireMemory(ctx context.Context) error {
if err := a.wireMemoryTidy(); err != nil {
return err
}
// 入库前审查(design/23 §15.1 D3):记忆管理会话的后台队列,接在 store 的
// 唯一写入路径上。它与整理心跳并列启动(main.go / close)。
if err := a.wireMemoryReview(); err != nil {
return err
}
return nil
}
+5
View File
@@ -118,6 +118,11 @@ func (a *app) close(ctx context.Context) {
if a.memoryTidy != nil {
_ = a.memoryTidy.Stop(ctx)
}
// 入库前审查与整理同属记忆后台任务:先停它(Stop 会等正在审的那一条跑完),
// 再关库 —— 否则正在写 memory_items 的审查会撞上已关闭的连接池。
if a.memoryReview != nil {
_ = a.memoryReview.Stop(ctx)
}
if a.automation != nil {
_ = a.automation.Stop(ctx)
}
+10 -2
View File
@@ -261,8 +261,16 @@ type MemoryTidyConfig struct {
RecallThreshold float64 `env:"MEMORY_TIDY_RECALL_THRESHOLD" default:"0.80"`
// ScanLimit 是每分区每轮的扫描条数上限,默认 5000(design/23 §9)。
ScanLimit int `env:"MEMORY_TIDY_SCAN_LIMIT" default:"5000"`
// CandidateLimit 是每分区每轮的候选条数上限,默认 20(design/23 §9)。
CandidateLimit int `env:"MEMORY_TIDY_CANDIDATE_LIMIT" default:"20"`
// CandidateLimit 是**每分区**每轮的候选条数上限,默认 200(design/23 §9)。
// 2026-10-06 抬高:首轮真跑四个分区同时打满旧值 20 ⇒ 候选数(77)由上限而
// 不是规则决定,而命中上限的分区其候选总数(候选源)本就更多。
CandidateLimit int `env:"MEMORY_TIDY_CANDIDATE_LIMIT" default:"200"`
// CompareLimit 是**每分区**每轮真正参与两两比对的条目上限,默认 2000。
// 它是性能护栏(两两相似度 O(n²·dim)),旧实现把它写死为 400,导致
// 459 条的分区被截到 400 ⇒ compared(705) < scanned(784),观测口径被上限
// 决定。诚实的做法是抬高它并**如实**标记截断,而不是让「扫了 784 条」
// 这句话变成半真。
CompareLimit int `env:"MEMORY_TIDY_COMPARE_LIMIT" default:"2000"`
}
// SubagentConfig holds the sub-agent orchestration settings (design/05 §7).
+51
View File
@@ -0,0 +1,51 @@
package config
import (
"fmt"
"reflect"
)
// MemoryReviewConfig 是 D3「入库前审查」(design/23-记忆整理与去重 §15.1 D3)
// 在配置层的旋钮:新记忆落库后,由独立的记忆管理会话异步判定它是
// merge / modify / create / skip。
//
// 为什么用独立加载器而不是挂进 Config 根结构:Config 由 Load 统一解码,而
// 本切片刻意**不碰** Config / 记忆整理那组键(并行切片正在改它们)。这里复用
// 与 LoadLLMDefaults 同一手法(parseDotEnv + 同一份 `env:"…"` 解码器),
// 因此优先级与 Load 逐字一致:进程环境变量 > `.env` > 标签默认值。
// envPath 为空表示不看 `.env`(与 Load 的约定一致)。
type MemoryReviewConfig struct {
// Enabled 是入库前审查总开关,默认 true。依据:主人 2026-10-05 的裁定
// 就是「入库之前先走记忆管理会话审查,而不是反复新建」,而 T0 判定是
// **纯规则、零模型调用**,默认开启是安全的增量。置 false 时组合根不建、
// 不接、不跑 —— 写入路径行为逐字回到本次改动之前。
Enabled bool `env:"MEMORY_REVIEW_ENABLED" default:"true"`
// QueueSize 是有界审查队列的长度。队列满时该条**不做审查**:记忆本身
// 早已落库(绝不因队列满而丢记忆),只记一条带来源的 WARN。
QueueSize int `env:"MEMORY_REVIEW_QUEUE_SIZE" default:"256"`
// ScanLimit 是每次审查在同一分区内最多看多少条既有记忆(top-K 上限,
// design/23 §15 D3-4「每条待写记忆的审查有界」)。
ScanLimit int `env:"MEMORY_REVIEW_SCAN_LIMIT" default:"200"`
// TimeoutSec 是单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**,
// 记忆照旧保留 + WARN(design/23 §15 D3-5 的 fail-safe)。
TimeoutSec int `env:"MEMORY_REVIEW_TIMEOUT_SEC" default:"20"`
}
// LoadMemoryReviewConfig 读取入库前审查的旋钮。非法(负数)值不在这里报错,
// 而是与项目其它旋钮同一口径 —— **≤0 视为「用默认」**,由 memory 包 normalize
// 收敛,避免一个笔误让进程起不来。
func LoadMemoryReviewConfig(envPath string) (MemoryReviewConfig, error) {
fromEnv := map[string]string{}
if envPath != "" {
m, err := parseDotEnv(envPath)
if err != nil {
return MemoryReviewConfig{}, fmt.Errorf("config: read %s: %w", envPath, err)
}
fromEnv = m
}
var cfg MemoryReviewConfig
if err := decodeValue(reflect.ValueOf(&cfg), fromEnv); err != nil {
return MemoryReviewConfig{}, err
}
return cfg, nil
}
+44
View File
@@ -0,0 +1,44 @@
package config
import (
"testing"
)
// TestLoadMemoryReviewConfigDefaults 锁定 D3「入库前审查」(design/23 §15.1 D3)
// 的 env 契约默认值。它们必须与 memory 包的兜底默认值一致 —— 两处不一致会让
// 「没配这个键」与「配了但解析失败」走向不同的行为。
func TestLoadMemoryReviewConfigDefaults(t *testing.T) {
cfg, err := LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig() error: %v", err)
}
if !cfg.Enabled {
t.Error("MEMORY_REVIEW_ENABLED 默认应为 true(主人 2026-10-05 裁定「入库前审查」;T0 零模型调用)")
}
if cfg.QueueSize != 256 {
t.Errorf("MEMORY_REVIEW_QUEUE_SIZE = %d, want 256", cfg.QueueSize)
}
if cfg.ScanLimit != 200 {
t.Errorf("MEMORY_REVIEW_SCAN_LIMIT = %d, want 200(§15 D3-4 每条审查有界)", cfg.ScanLimit)
}
if cfg.TimeoutSec != 20 {
t.Errorf("MEMORY_REVIEW_TIMEOUT_SEC = %d, want 20(§15 D3-5 超时即不改写)", cfg.TimeoutSec)
}
}
// TestLoadMemoryReviewConfigEnvOverrides 锁定键名与类型:写进 .env 的旋钮真的
// 生效(键名写错会静默沿用默认值,这类错误只能靠这条用例抓住)。
func TestLoadMemoryReviewConfigEnvOverrides(t *testing.T) {
t.Setenv("MEMORY_REVIEW_ENABLED", "false")
t.Setenv("MEMORY_REVIEW_QUEUE_SIZE", "8")
t.Setenv("MEMORY_REVIEW_SCAN_LIMIT", "32")
t.Setenv("MEMORY_REVIEW_TIMEOUT_SEC", "3")
cfg, err := LoadMemoryReviewConfig("")
if err != nil {
t.Fatalf("LoadMemoryReviewConfig() error: %v", err)
}
if cfg.Enabled || cfg.QueueSize != 8 || cfg.ScanLimit != 32 || cfg.TimeoutSec != 3 {
t.Fatalf("MEMORY_REVIEW_* 未生效:%+v", cfg)
}
}
+5 -3
View File
@@ -28,8 +28,9 @@ func TestLoad_MemoryTidyDefaults(t *testing.T) {
if got.RecallThreshold != 0.80 {
t.Errorf("MEMORY_TIDY_RECALL_THRESHOLD = %v, want 0.80(design §13 开放点 2 的建议值)", got.RecallThreshold)
}
if got.ScanLimit != 5000 || got.CandidateLimit != 20 {
t.Errorf("配额 = %d/%d, want 5000/20(design §9)", got.ScanLimit, got.CandidateLimit)
if got.ScanLimit != 5000 || got.CandidateLimit != 200 || got.CompareLimit != 2000 {
t.Errorf("配额 = %d/%d/%d, want 5000/200/2000(scan/candidate:design §9;compare:切片 MT-A 抬高后)",
got.ScanLimit, got.CandidateLimit, got.CompareLimit)
}
}
@@ -44,6 +45,7 @@ func TestLoad_MemoryTidyEnvOverrides(t *testing.T) {
t.Setenv("MEMORY_TIDY_RECALL_THRESHOLD", "0.7")
t.Setenv("MEMORY_TIDY_SCAN_LIMIT", "10")
t.Setenv("MEMORY_TIDY_CANDIDATE_LIMIT", "3")
t.Setenv("MEMORY_TIDY_COMPARE_LIMIT", "42")
cfg, err := Load("")
if err != nil {
@@ -51,7 +53,7 @@ func TestLoad_MemoryTidyEnvOverrides(t *testing.T) {
}
got := cfg.MemoryTidy
if got.Enabled || got.IntervalSec != 3600 || got.QuietStartHour != 1 || got.QuietEndHour != 5 ||
got.RecallThreshold != 0.7 || got.ScanLimit != 10 || got.CandidateLimit != 3 {
got.RecallThreshold != 0.7 || got.ScanLimit != 10 || got.CandidateLimit != 3 || got.CompareLimit != 42 {
t.Fatalf("MEMORY_TIDY_* 未生效:%+v", got)
}
}
+465
View File
@@ -0,0 +1,465 @@
package memory
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 D3「入库前审查」(design/23-记忆整理与去重 §15.1 D3)的服务形态:
// 新记忆落库**之后**,由**独立的记忆管理会话**(复用既有的 sess-memorytidy
// 装配,见 TidySessionID)异步判定它与同分区既有记忆的关系,四态结论见
// IngestDecision。
//
// 与 design §15 D3-1/3/5 的取舍(逐条,因为设计稿这一处同时出现「写入之前」
// 与「首选异步 + 结论回写」两种表述,实现必须把口径写死):
//
// 1. **触发点在写入之后**(异步):回合收尾的写入路径上多出一次 embedding
// 网络调用就会把主人的回合拖长(§15 D3-3「不许因为审查把对话回合拖长」),
// 而 §15 D3-3 要求的正是「审查结论**回写**到 memory_items」—— 只有行
// 已存在才谈得上回写。因此本实现是「先落库、再由审查把重复条目软合并
// 回去」,在观测上与「不新建」等价(活跃行数不变),且**不阻塞任何回合**;
// 2. **失败即安全**:条目先落库 ⇒ 审查失败/超时/embedder 不可用时「记忆绝不
// 丢」是结构性成立的,不靠回滚(§15 D3-5)。失败路径**只记 WARN、绝不
// 改写任何一行**;
// 3. **零 LLM**:T0 是纯规则(design §15 D3-2 T0),T1 默认关且**本切片未
// 实现**(§15.2 开放点 1 = T1 默认关),因此「T1 关闭时零模型调用」是
// 结构性成立 —— 本服务的依赖里根本没有模型适配器。
//
// 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并
// (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。
// IngestDecision 是入库前审查的四态结论(design/23 §15.1 D3)。
type IngestDecision string
const (
// IngestSkip 表示已有**归一化后完全等价**的条目:新行折回既有行,
// 不产生第二条活跃记忆(§15 D3-2 T0-1)。
IngestSkip IngestDecision = "skip"
// IngestMerge 表示新条目是既有条目事实集合的**子集**:并入既有,
// 既有行存活(§15 D3-2 T0-2)。
IngestMerge IngestDecision = "merge"
// IngestModify 表示既有条目是新条目事实集合的子集:**新行承载更全的
// 内容**,旧行置为被合并(§15 D3-6「改写必须是新行 + 旧行置为被合并」)。
IngestModify IngestDecision = "modify"
// IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回):
// 正常新建,不做任何改写(§15 D3-2 T0-3)。
IngestCreate IngestDecision = "create"
)
// IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。
// **改判定规则必须同时升它**,否则新结论与旧留痕无法区分。
const IngestRuleVersion = "d3.1"
// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。
const (
// defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路,
// 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限。
defaultReviewTimeout = 20 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
reviewAuditWho = "system/memoryreview"
reviewAuditVerb = "memory.review.ingest"
)
var (
// ErrReviewDisabled 在审查被配置显式关闭却又被直接调用时返回。
ErrReviewDisabled = errors.New("memory/review: disabled by configuration")
// ErrReviewRestrictedSource 在待审条目来自群聊/陌生人这类**只读**上下文时
// 返回。它不是失败:这类条目凭什么都不该被审查(既不合并也不改写,
// design §6 / §15 D3-7 + AGENTS §9 的作用域隔离红线)。
ErrReviewRestrictedSource = errors.New("memory/review: restricted source is read-only")
// ErrReviewItemNotActive 在该条目已经不在册(被前一次审查合并掉)却又被
// 要求审查时返回。它是幂等保护:再判一次会把存活行的 merge_count 重复加一。
ErrReviewItemNotActive = errors.New("memory/review: item is no longer active")
)
// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
type ReviewConfig struct {
// Enabled 是总开关。关闭时组合根不建、不接、不跑 —— 写入路径行为逐字
// 回到本次改动之前。
Enabled bool
// QueueSize 是有界队列长度(≤0 用默认)。
QueueSize int
// ScanLimit 是单次审查的候选上限(≤0 用默认)。
ScanLimit int
// Timeout 是单条审查的墙钟上限(≤0 用默认)。
Timeout time.Duration
// Threshold 是候选召回的余弦门,**复用记忆整理的既有阈值**
// (config.MemoryTidy.RecallThreshold,design §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
// 非正用设计稿默认值。
Threshold float64
}
// ReviewDeps 是注入给入库前审查的依赖。Store 必需;其余可空并优雅降级。
// 刻意**没有**模型适配器:T0 是零 LLM 判定,T1 未实现(§15.2 开放点 1)。
type ReviewDeps struct {
// Store 是记忆存储。必需。
Store *Store
// Embedder 是候选召回的相似度来源。为 nil 时退回零依赖的字符二元组
// 余弦(与整理生产轮的回退同一口径,见 tidy_recall.go)。
Embedder embedding.Embedder
// Audit 是审计出口(可空;为 nil 时只记日志)。
Audit func(who, what, result string)
// Now 是墙钟来源,便于测试注入。
Now func() time.Time
// Log 是基础日志器,来源标记为 "memory/review"。
Log *slog.Logger
}
// IngestReview 是一条入库前审查留痕(design/23 §15 D3-8)。它**不含记忆正文**:
// 命中的既有 id 足以让 owner-only 的读侧回表取原文。
type IngestReview struct {
ID string
ItemID string
PersonaID string
Scope Scope
ProjectID string
Decision IngestDecision
Reason string
// Veto 是命中的否决规则(`merge`/`modify`/`skip` 时为空):它是结构化依据,
// 面板据此聚合「为什么这一条没被合并」,而不是去解析 reason 字符串。
Veto TidyVetoReason
TargetID string
MergedIDs []string
Score float64
RecallMode string
Degraded bool
RuleVer string
SessionID string
CreatedAt time.Time
}
// ReviewStats 是审查的观测计数(进程内累计,供日志/测试断言)。
type ReviewStats struct {
Reviewed int
Skipped int
Merged int
Modified int
Created int
Dropped int
Failed int
}
// PreInsertReviewer 是入库前审查服务。用 NewPreInsertReviewer 构建,用 Start
// 拉起工作协程、Stop 停它;Review 是同步入口(测试与手动核查用)。
type PreInsertReviewer struct {
cfg ReviewConfig
deps ReviewDeps
log *slog.Logger
now func() time.Time
queue chan MemoryItem
mu sync.Mutex
running bool
cancel context.CancelFunc
wg sync.WaitGroup
statsMu sync.Mutex
stats ReviewStats
}
// NewPreInsertReviewer 由 cfg 与 deps 构建审查服务。Store 为 nil 时返回
// ErrNilStore(与整理器同一口径:没有记忆存储就没有审查)。
func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer, error) {
if deps.Store == nil {
return nil, ErrNilStore
}
cfg = normalizeReviewConfig(cfg)
now := deps.Now
if now == nil {
now = time.Now
}
return &PreInsertReviewer{
cfg: cfg,
deps: deps,
log: logger.WithSource(baseTidyLog(deps.Log), "memory/review"),
now: now,
queue: make(chan MemoryItem, cfg.QueueSize),
}, nil
}
// normalizeReviewConfig 用设计稿默认值填充零值字段。
func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultReviewQueueSize
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultReviewScanLimit
}
if cfg.Timeout <= 0 {
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
cfg.Threshold = defaultTidyRecallThreshold
}
return cfg
}
// SessionID 返回审查运行的会话标识:**复用既有的 sess-memorytidy 装配**
// (design/23 §4.1 B;§15 D3 要求「走记忆管理会话」,不许造第二形态会话)。
func (r *PreInsertReviewer) SessionID() string { return TidySessionID }
// Enabled 报告审查是否被配置开启。
func (r *PreInsertReviewer) Enabled() bool { return r.cfg.Enabled }
// Start 拉起工作协程。幂等;配置关闭时只记一条日志并立即返回(关闭是显式
// 选择,绝不静默)。
func (r *PreInsertReviewer) Start(ctx context.Context) {
if !r.cfg.Enabled {
r.log.Info("memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
return
}
if ctx == nil {
ctx = context.Background()
}
r.mu.Lock()
if r.running {
r.mu.Unlock()
return
}
r.running = true
cctx, cancel := context.WithCancel(ctx)
r.cancel = cancel
r.wg.Add(1)
r.mu.Unlock()
go r.worker(cctx)
r.log.Info("memory ingest review started",
"session_id", TidySessionID,
"queue", r.cfg.QueueSize,
"scan_limit", r.cfg.ScanLimit,
"timeout", r.cfg.Timeout.String(),
"recall_threshold", r.cfg.Threshold,
"rule_version", IngestRuleVersion)
}
// Stop 停止工作协程并等待正在审的那一条跑完。幂等;ctx 在排空前被取消时返回
// 包装后的 context 错误,而不是泄漏 goroutine。
func (r *PreInsertReviewer) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
r.mu.Lock()
if !r.running {
r.mu.Unlock()
return nil
}
r.running = false
cancel := r.cancel
r.mu.Unlock()
cancel()
done := make(chan struct{})
go func() {
r.wg.Wait()
close(done)
}()
select {
case <-done:
r.log.Info("memory ingest review stopped", "stats", reviewStatsLabel(r.Stats()))
return nil
case <-ctx.Done():
return fmt.Errorf("memory/review: stop: %w", ctx.Err())
}
}
// Enqueue 把一条刚落库的记忆投入审查队列。它是**非阻塞**的:队列满时返回
// false(该条不做审查,记忆本身早已落库,绝不因此丢记忆)并记一条 WARN。
func (r *PreInsertReviewer) Enqueue(item MemoryItem) bool {
if !r.cfg.Enabled {
return false
}
select {
case r.queue <- item:
return true
default:
r.bump(func(s *ReviewStats) { s.Dropped++ })
r.log.Warn("memory ingest review queue full, item left unreviewed",
"item_id", item.ID, "scope", string(item.Scope), "queue", r.cfg.QueueSize)
return false
}
}
// Stats 返回累计计数(快照)。
func (r *PreInsertReviewer) Stats() ReviewStats {
r.statsMu.Lock()
defer r.statsMu.Unlock()
return r.stats
}
// worker 是单读者循环:一条一条审(单写者的自然形态,design §9 的单写者口径)。
func (r *PreInsertReviewer) worker(ctx context.Context) {
defer r.wg.Done()
for {
select {
case <-ctx.Done():
return
case item := <-r.queue:
r.process(ctx, item)
}
}
}
// process 审一条:单条超时由 r.cfg.Timeout 把守。失败**绝不改写任何一行**,
// 只如实记 WARN(design §15 D3-5)。
func (r *PreInsertReviewer) process(ctx context.Context, item MemoryItem) {
rctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
defer cancel()
if _, err := r.Review(rctx, item); err != nil {
if errors.Is(err, ErrReviewRestrictedSource) || errors.Is(err, ErrReviewItemNotActive) {
// 只读来源 / 已不在册:都不是失败,也都零写入。
r.log.Debug("memory ingest review skipped", "item_id", item.ID, "reason", err)
return
}
r.bump(func(s *ReviewStats) { s.Failed++ })
r.log.Warn("memory ingest review failed, memory left untouched",
"item_id", item.ID, "scope", string(item.Scope), "error", err)
}
}
// Review 同步审一条并返回留痕。它在以下情形**不做任何改写**并返回错误:
// 召回不可用(超时/取消/存储错误)、改写失败。调用方据此记 WARN,
// 而记忆本身早已落库,因此「不丢记忆」是结构性成立的。
func (r *PreInsertReviewer) Review(ctx context.Context, item MemoryItem) (IngestReview, error) {
if !r.cfg.Enabled {
return IngestReview{}, ErrReviewDisabled
}
if nonOwnerSource(item.SourceConversation) {
return IngestReview{}, ErrReviewRestrictedSource
}
// 幂等:已经不在册的行不再判(否则重复判会把存活行的 merge_count 数两次)。
status, err := r.deps.Store.reviewItemStatus(ctx, item.ID)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: item status: %w", err)
}
if status != string(tidyStatusActive) {
return IngestReview{}, ErrReviewItemNotActive
}
now := r.now().UTC()
cands, degraded, err := r.recallCandidates(ctx, item)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: recall: %w", err)
}
if err := ctx.Err(); err != nil {
// 超时/取消发生在召回之后:同样不改写(fail-safe,§15 D3-5)。
return IngestReview{}, fmt.Errorf("memory/review: %w", err)
}
verdict := judgeIngest(item, cands)
merged, err := r.applyVerdict(ctx, verdict, item, now)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: apply: %w", err)
}
rev := r.buildRecord(item, verdict, merged, degraded, now)
if err := r.deps.Store.insertIngestReview(ctx, rev); err != nil {
// 留痕失败**不改判决定**:改写已经生效,这里只如实记 WARN,绝不回滚
// 一次已经生效的合并(回滚会把审查看成比结论更权威,那更危险)。
r.log.Warn("memory ingest review record insert failed", "item_id", item.ID, "error", err)
}
r.audit(rev)
r.recordDecision(rev)
return rev, nil
}
// applyVerdict 落定判定:create 什么都不做;其余把「被吸收的那一行」软合并到
// 存活行(design §8:软删除 + merged_into + merged_from + merge_count,
// 绝不物理删除,绝不改任何一行的 content)。
func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, item MemoryItem, now time.Time) ([]string, error) {
if v.Decision == IngestCreate {
return nil, nil
}
if v.Absorbed.ID == "" || v.Existing.ID == "" {
return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision)
}
survivor := item
if v.Absorbed.ID == item.ID {
// merge / skip:被吸收的是新行,存活方是既有行。
survivor = v.Existing
}
if err := r.deps.Store.absorbIngestItem(ctx, v.Absorbed, survivor, now); err != nil {
return nil, err
}
return []string{v.Absorbed.ID}, nil
}
// buildRecord 组装留痕(design §15 D3-8:决定 / 依据 / 命中的既有 id /
// 是否降级 + 规则版本与会话标识)。
func (r *PreInsertReviewer) buildRecord(item MemoryItem, v ingestVerdict, merged []string, degraded bool, now time.Time) IngestReview {
rev := IngestReview{
ID: mustNewTidyID(),
ItemID: item.ID,
PersonaID: item.PersonaID,
Scope: item.Scope,
ProjectID: item.ProjectID,
Decision: v.Decision,
Reason: v.Reason,
Veto: v.Veto,
TargetID: v.Existing.ID,
MergedIDs: merged,
Score: v.Score,
RecallMode: v.Mode,
Degraded: degraded,
RuleVer: IngestRuleVersion,
SessionID: TidySessionID,
CreatedAt: now,
}
if rev.MergedIDs == nil {
rev.MergedIDs = []string{}
}
return rev
}
// audit 写一条审计。它**只带决定与 id,不带任何记忆正文**。
func (r *PreInsertReviewer) audit(rev IngestReview) {
result := fmt.Sprintf("decision=%s reason=%s item=%s target=%s degraded=%t score=%.3f",
rev.Decision, rev.Reason, rev.ItemID, rev.TargetID, rev.Degraded, rev.Score)
if r.deps.Audit == nil {
r.log.Debug("memory ingest review audit (no sink configured)", "what", reviewAuditVerb, "result", result)
return
}
r.deps.Audit(reviewAuditWho, reviewAuditVerb, result)
}
// recordDecision 累计观测计数(含降级计数,便于运维判读)。
func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
r.bump(func(s *ReviewStats) {
s.Reviewed++
switch rev.Decision {
case IngestSkip:
s.Skipped++
case IngestMerge:
s.Merged++
case IngestModify:
s.Modified++
default:
s.Created++
}
})
}
// bump 在互斥下修改计数(工作协程与手动 Review 都可能调用)。
func (r *PreInsertReviewer) bump(apply func(*ReviewStats)) {
r.statsMu.Lock()
defer r.statsMu.Unlock()
apply(&r.stats)
}
// reviewStatsLabel 把计数渲染成一条稳定日志串。
func reviewStatsLabel(s ReviewStats) string {
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d",
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed)
}
@@ -0,0 +1,297 @@
package memory
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 D3「入库前审查」的**隔离与异步**测试(design/23 §15 D3-3/4/5/7/8):
// 群聊/陌生人来源绝不触碰、跨分区绝不合并、写入方绝不等待审查、队列有界诚实、
// 开关关闭时行为不变、留痕如实登记降级。
// TestReviewRestrictedSourceIsNeverTouched 是 design/23 §6/§9 红线的钉死:
// 群聊/陌生人来源的条目**既不被审查、也不被合并**(只读不写)。
func TestReviewRestrictedSourceIsNeverTouched(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
groupItem := reviewAt(fixtureHabitShort, base, 0.9)
groupItem.SourceConversation = FormatSourceConversation("sess-qq-group-1", "m-1")
group := seedTidyItem(t, store, groupItem)
owner := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
// ① 隔离来源的**新条目**:概不审查。
if _, err := r.Review(ctx, group); !errors.Is(err, ErrReviewRestrictedSource) {
t.Fatalf("隔离来源审查 err = %v, want %v", err, ErrReviewRestrictedSource)
}
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 0 {
t.Fatalf("隔离来源不该留任何留痕:reviews=%+v err=%v", reviews, err)
}
if got := readReviewRow(t, store, group.ID); got.status != tidyStatusActive {
t.Fatalf("隔离来源被改写了:%+v", got)
}
// ② 隔离来源作为**候选**:不得与主人条目合并(群体来源硬门)。
rev, err := r.Review(ctx, owner)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Veto != VetoSource {
t.Fatalf("verdict = %+v, want create + source 否决", rev)
}
if got := readReviewRow(t, store, owner.ID); got.status != tidyStatusActive {
t.Fatalf("主人条目被跨来源合并了:%+v", got)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("活跃行数 = %d, want 2", n)
}
}
// TestReviewNeverCrossesPersonaOrScope 是 design/23 §15 D3-9⑤ 与 §6 硬约束:
// 跨角色 / 跨作用域**绝不合并** —— 运行期连比较都不发生,判定层再兜一次否决。
func TestReviewNeverCrossesPersonaOrScope(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
other := habitItem(fixtureHabitShort, base, 0.9)
other.PersonaID = "persona.b"
otherItem := seedTidyItem(t, store, other)
otherScope := MemoryItem{
PersonaID: "persona.a", Scope: ScopeUser, Kind: KindRelationship,
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
SourceConversation: FormatSourceConversation("web", "msg-1"),
}
seedTidyItem(t, store, otherScope)
mine := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
rev, err := r.Review(ctx, mine)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate {
t.Fatalf("跨分区被判成 %q,分区之间永不比较(§6)", rev.Decision)
}
if got := readReviewRow(t, store, otherItem.ID); got.status != tidyStatusActive {
t.Fatalf("别的角色的记忆被改写了:%+v", got)
}
// 判定层的纵深防御:直接喂一条跨角色候选,必须被硬门否决。
verdict := judgeIngest(mine, []ingestCandidate{{item: otherItem, score: 0.99, mode: tidyRecallEmbedding}})
if verdict.Decision != IngestCreate || verdict.Veto != VetoPersona {
t.Fatalf("跨角色判定 = %+v, want create + persona 否决", verdict)
}
}
// TestReviewIsNonBlockingAndAsyncPathWorks 是 design/23 §15 D3-3/D3-4:
// 写入方**绝不等待**审查;审查在后台(工作协程)按队列跑完。
func TestReviewIsNonBlockingAndAsyncPathWorks(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
store.AttachIngestReviewer(r)
// 未 Start:写入方投递后**立即返回**,审查尚未发生(异步性证据)。
start := time.Now()
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Fatalf("写入等了 %s,审查绝不能阻塞写入方", elapsed)
}
if got := readReviewRow(t, store, dup.ID); got.status != tidyStatusActive {
t.Fatalf("审查在写入方返回前就生效了,说明它是同步的:%+v", got)
}
r.Start(ctx)
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = r.Stop(stopCtx)
})
waitFor(t, func() bool {
return readReviewRow(t, store, dup.ID).status == ingestStatusMerged
}, "后台审查把重复行折回既有行")
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 2 {
t.Fatalf("survivor = %+v, want merge_count=2", got)
}
}
// waitFor 轮询等待条件成立(后台工作协程的完成信号),超时即失败。
func waitFor(t *testing.T, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等待超时:%s", what)
}
// TestReviewQueueIsBoundedAndDropsHonestly 是 design/23 §15 D3-4:审查有界,
// 队列满时**有界丢弃**(绝不阻塞写入方),且次数被如实计数。
func TestReviewQueueIsBoundedAndDropsHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true, QueueSize: 2, ScanLimit: 10, Timeout: time.Second, Threshold: 0.8,
}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
item := reviewAt(fixtureHabitShort, tidyTestTime(t), 0.9)
item.ID = "item-1"
accepted := 0
for i := 0; i < 5; i++ {
if r.Enqueue(item) {
accepted++
}
}
if accepted != 2 {
t.Fatalf("入队 = %d, want 2(队列上限)", accepted)
}
if got := r.Stats().Dropped; got != 3 {
t.Fatalf("丢弃计数 = %d, want 3(绝不静默)", got)
}
}
// TestReviewIsIdempotentPerItem 是幂等口径:同一条记忆被审第二次时**不再判定**
// (它已不在册),因此存活行的 merge_count 不会被同一条事实数两次。
func TestReviewIsIdempotentPerItem(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
if _, err := r.Review(ctx, dup); err != nil {
t.Fatalf("第一次 Review: %v", err)
}
if _, err := r.Review(ctx, dup); !errors.Is(err, ErrReviewItemNotActive) {
t.Fatalf("第二次 Review err = %v, want %v", err, ErrReviewItemNotActive)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 2 {
t.Fatalf("存活行 merge_count = %d, want 2(同一条事实只该数一次)", got.mergeCount)
}
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 1 {
t.Fatalf("审查留痕 = %+v(err=%v), want 恰为一条", reviews, err)
}
}
// TestReviewDisabledChangesNothing 是 design/23 §15 D3-7 的开关口径:
// 关闭时审查一次性拒绝,且**不接线**的 store 写入路径逐字回到今天。
func TestReviewDisabledChangesNothing(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
off, err := NewPreInsertReviewer(ReviewConfig{Enabled: false}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
off.Start(ctx) // 关闭时只记一条日志并立即返回,不起任何协程
store.AttachIngestReviewer(off)
if off.Enqueue(reviewAt(fixtureHabitShort, base, 0.9)) {
t.Fatal("关闭时不该接受审查任务")
}
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
if _, err := off.Review(ctx, dup); !errors.Is(err, ErrReviewDisabled) {
t.Fatalf("关闭时 Review err = %v, want %v", err, ErrReviewDisabled)
}
if got := off.Stats(); got.Dropped != 0 {
t.Fatalf("关闭时不该把条目记成「被丢弃」:%+v", got)
}
// 未接线的 store:连审查留痕都不该有,两条记忆都照旧在册。
if reviews, err := store.ListIngestReviews(ctx, 10); err != nil || len(reviews) != 0 {
t.Fatalf("关闭时不该有留痕:%+v", reviews)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("关闭时行为被改动了:活跃行数 = %d, want 2", n)
}
}
// TestReviewReusesTidySessionAndStaysZeroLLM 是 design/23 §15 D3 的装配口径:
// 复用既有 sess-memorytidy 装配(不造第二形态会话),且 T1 默认关 + 本切片
// 未实现 ⇒ 灰区(相似但不包含)**不调任何模型**,只按 T0 回落 create。
func TestReviewReusesTidySessionAndStaysZeroLLM(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureEnergyRedBull, base, 0.95))
gray := seedTidyItem(t, store, reviewAt(fixtureEnergyIced, base.Add(time.Minute), 0.95))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureEnergyRedBull: {1, 0},
fixtureEnergyIced: simVector(0.88),
}}, 0.80)
if r.SessionID() != TidySessionID || !strings.HasPrefix(r.SessionID(), "sess-") {
t.Fatalf("审查会话 = %q, want 复用 %q", r.SessionID(), TidySessionID)
}
rev, err := r.Review(ctx, gray)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate {
t.Fatalf("灰区被判成 %q,T1 关闭时必须保守回落 create", rev.Decision)
}
if rev.RecallMode != tidyRecallEmbedding {
t.Fatalf("召回来源 = %q, want %q", rev.RecallMode, tidyRecallEmbedding)
}
}
// TestReviewDegradedRecallIsRecordedHonestly 是 design/23 §15 D3-8 的「是否降级」:
// embedder 报错时降级到词项余弦,判定照旧,但留痕必须如实写 degraded。
func TestReviewDegradedRecallIsRecordedHonestly(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
fresh := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, failingReviewEmbedder{}, 0.80)
rev, err := r.Review(ctx, fresh)
if err != nil {
t.Fatalf("Review: %v", err)
}
if !rev.Degraded || rev.RecallMode != tidyRecallLexical {
t.Fatalf("降级未被如实登记:%+v", rev)
}
if got := readReviewRow(t, store, fresh.ID); got.status != tidyStatusActive {
t.Fatalf("降级不该丢掉新条目:%+v", got)
}
}
// failingReviewEmbedder 模拟「embedder 挂」:Embed 恒报错(design §15 D3-5 的情形之一)。
type failingReviewEmbedder struct{}
func (failingReviewEmbedder) Embed(context.Context, []string) ([]embedding.Vector, error) {
return nil, errors.New("embedder down")
}
func (failingReviewEmbedder) Dim() int { return 2 }
+157
View File
@@ -0,0 +1,157 @@
package memory
// 本文件是 D3「入库前审查」的 T0 判定内核(design/23 §15.1 D3-2):
// 对**一条新落库的记忆**与同分区既有的召回候选,给出 merge / modify /
// create / skip 四态之一,并留下可审计的依据。
//
// 判定顺序刻意与设计稿逐条对齐:
// 1. **归一化后完全等** ⇒ skip(§15 D3-2 T0-1:已有等价条目,只更新既有行的
// updated_at / confidence,绝不新建第二条);
// 2. **真包含** ⇒ merge 或 modify(§15 D3-2 T0-2 + §8 包含式判据):
// 新条目是既有条目的子集 ⇒ merge(并入既有,既有行存活);
// 既有条目是新条目的子集 ⇒ modify(**新行承载更全的内容**,旧行置为被合并,
// §15 D3-6「改写必须是新行 + 旧行置为被合并」);
// 3. 其余(互不包含 / 被规则否决 / 没有召回)⇒ create(normal 新建)。
//
// 两条不可走样:
// - **相似度只用来召回候选**,永不参与判定(design/23 §1.2「单阈值双向失效」);
// - **规则否决一票否决**,且**绝不跨作用域/跨角色/跨项目**(§5.2-7/8/9 是硬门,
// 不是权衡)—— 这里对每条候选再判一次,是纵深防御(分区过滤已在 SQL 里)。
// ingestCandidate 是一条被召回的既有记忆及其召回分数。
type ingestCandidate struct {
item MemoryItem
score float64
// mode 记录本轮相似度来源(embedding / lexical),进审查留痕(§15 D3-8)。
mode string
}
// ingestVerdict 是入库前审查的判定结果。Decision 为空表示「这条候选没有给出
// 决定性结论」(互不包含或被否决),调用方继续看下一条候选。
type ingestVerdict struct {
Decision IngestDecision
// Existing 是命中的既有条目(create 时为零值)—— 留痕里的「命中的既有 id」。
Existing MemoryItem
// Absorbed 是被软合并的那一行:merge/skip 时是**新落库的那一行**,
// modify 时是既有行(§15 D3-6 的「改写」)。
Absorbed MemoryItem
Score float64
Mode string
Reason string
// Veto 是命中的否决规则(未命中时为空)。它只影响依据,绝不改判决定。
Veto TidyVetoReason
}
// judgeIngest 对一条新落库的记忆做四态判定。cands 已按召回分数降序
// (collectTidyPairs 的确定性排序),因此同分时的选择可复现。
//
// 无论结论是什么,留痕都要带上**本轮召回来源**(§15 D3-8 的「依据」):
// 「没有命中任何包含关系」与「根本没召回到东西」是两件不同的事。
func judgeIngest(item MemoryItem, cands []ingestCandidate) ingestVerdict {
mode := ""
if len(cands) > 0 {
mode = cands[0].mode
}
if v, ok := judgeExactDuplicate(item, cands); ok {
return v
}
vetoed, vetoScore := VetoNone, 0.0
for _, c := range cands {
if veto, hit := tidyHardGate(item, c.item); hit {
vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, veto, c.score)
continue
}
v := judgeContainedIngest(item, c)
if v.Decision != "" {
return v
}
if v.Veto != VetoNone {
vetoed, vetoScore = keepStrongerVeto(vetoed, vetoScore, v.Veto, c.score)
}
}
return ingestVerdict{Decision: IngestCreate, Reason: createReason(vetoed), Veto: vetoed, Mode: mode}
}
// judgeExactDuplicate 施加 §15 D3-2 T0-1:归一化后完全等 ⇒ skip。
// 它排在否决规则**之前**:完全相同的两条就是同一条事实,即便它带时效词或
// 低置信,把它折回既有行也绝不会丢信息(而「否决 ⇒ create」会让主人报的
// 「反复新建」原样复发)。作用域/来源硬门仍然先判 —— 跨分区与群聊来源
// 凭什么都不参与。
func judgeExactDuplicate(item MemoryItem, cands []ingestCandidate) (ingestVerdict, bool) {
key := dedupeKey(item.Content)
for _, c := range cands {
if _, hit := tidyHardGate(item, c.item); hit {
continue
}
if key != dedupeKey(c.item.Content) {
continue
}
// 存活方恒为**既有行**:新行是刚落库的那一条,把它折回既有行正是
// 「不新建」的含义(design §15 D3-2 T0-1)。
return ingestVerdict{
Decision: IngestSkip,
Existing: c.item,
Absorbed: item,
Score: c.score,
Mode: c.mode,
Reason: "exact_duplicate",
}, true
}
return ingestVerdict{}, false
}
// judgeContainedIngest 判定一条候选与新条目的事实关系。Decision 为空表示
// 「无决定性结论」:互不包含(相似但并列)或被规则否决。
func judgeContainedIngest(item MemoryItem, c ingestCandidate) ingestVerdict {
if hasAbsoluteTimestamp(item.Content) || hasAbsoluteTimestamp(c.item.Content) ||
hasTemporalState(item.Content) || hasTemporalState(c.item.Content) {
return ingestVerdict{Veto: VetoTemporal}
}
if !sameNumbers(item.Content, c.item.Content) {
return ingestVerdict{Veto: VetoNumeric}
}
if item.Confidence < tidyLowConfidenceThreshold || c.item.Confidence < tidyLowConfidenceThreshold {
return ingestVerdict{Veto: VetoLowConfidence}
}
cont := judgeContainment(item.Content, c.item.Content)
if cont.Kind == containNone {
// 相似但互不包含(design §1.3 的 [1]/[3]「红牛 vs 冰美式」):
// 绝不合并 —— 对入库而言等价于「正常新建」。
return ingestVerdict{Score: c.score, Mode: c.mode}
}
if !sameNegations(item.Content, c.item.Content) {
// 事实集合看似包含,但一方带否定而另一方没有(「X」vs「戒了X」):
// 那是新事实覆盖旧事实,不是重复(design §5.2-3)。
return ingestVerdict{Veto: VetoConflict}
}
if cont.Kind == containBSubsetA {
// 既有 ⊆ 新:新行承载更全的内容,旧行置为被合并(§15 D3-6)。
return ingestVerdict{
Decision: IngestModify, Existing: c.item, Absorbed: c.item,
Score: c.score, Mode: c.mode, Reason: "fact_superset",
}
}
// 新 ⊆ 既有(含双向等价但措辞不同):并入既有,既有行存活(§15 D3-2 T0-2)。
return ingestVerdict{
Decision: IngestMerge, Existing: c.item, Absorbed: item,
Score: c.score, Mode: c.mode, Reason: "fact_subset",
}
}
// keepStrongerVeto 在两条否决之间保留「分数更高那条候选」的否决原因,
// 让留痕里的依据对应最相关的候选,而不是随手一条。
func keepStrongerVeto(cur TidyVetoReason, curScore float64, next TidyVetoReason, nextScore float64) (TidyVetoReason, float64) {
if cur == VetoNone || nextScore > curScore {
return next, nextScore
}
return cur, curScore
}
// createReason 渲染 create 的依据:没有被否决就是「没有包含关系」,
// 否则如实写出命中的否决规则(留痕可查,design §15 D3-8)。
func createReason(veto TidyVetoReason) string {
if veto == VetoNone {
return "no_contained_match"
}
return "veto:" + string(veto)
}
+103
View File
@@ -0,0 +1,103 @@
package memory
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是入库前审查的**候选召回**(design/23 §15 D3-3:用既有 recall 路径,
// 不许另造一套相似度实现)。
//
// 复用面(逐条,都是整理侧已经存在的实现):
// - 阈值:与整理同一个召回门(config.MemoryTidy.RecallThreshold,§13 开放点 2);
// - 相似度:语义 embedding 的余弦(clampUnit + cosineSim)或零依赖的词项余弦
// (lexicalSimilarity);
// - 收集与排序:collectTidyPairs —— 因此「阈值过滤 + 分数降序 + id 兜底」
// 与整理侧逐字一致,截断也可复现。
//
// 与整理侧**唯一**的差异:候选只保留「新条目 vs 既有条目」的对(整理侧是
// 分区内两两),因为入库前审查永远以刚落库的那一条为中心。
// recallCandidates 在同一分区内召回候选(分区键 = persona_id + scope +
// project_id,design §6 硬约束:分区之间永不比较)。
//
// 返回的 degraded 表示本轮相似度是从语义路径**降级**到词项余弦的(embedder
// 报错或向量条数错位):判定规则一字不改,只把「这一轮的依据不够好」如实
// 带进留痕(§15 D3-8 的「是否降级」)。
func (r *PreInsertReviewer) recallCandidates(ctx context.Context, item MemoryItem) ([]ingestCandidate, bool, error) {
partition := tidyPartition{personaID: item.PersonaID, scope: item.Scope, projectID: item.ProjectID}
existing, err := r.deps.Store.listReviewCandidates(ctx, partition, item.ID, r.cfg.ScanLimit)
if err != nil {
return nil, false, err
}
if len(existing) == 0 {
return nil, false, nil
}
// 新条目固定在首位:reviewPairs 只认它为中心。
items := make([]MemoryItem, 0, len(existing)+1)
items = append(items, item)
items = append(items, existing...)
if r.deps.Embedder == nil {
// 未配置语义 embedding:与整理生产轮同一口径 —— 用零依赖词项余弦,
// 不是降级(这是配置形态,不是故障)。
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), false, nil
}
vecs, err := embedReviewTexts(ctx, r.deps.Embedder, items)
if err != nil {
// 语义召回不可用 ⇒ 降级到与整理生产轮**同一个**词项余弦,并如实标记
// degraded(design §4.1 生产轮回退口径)。降级绝不改变判定规则,
// 也绝不改写任何一行。
r.log.Warn("memory ingest review embedding recall unavailable, falling back to lexical",
"item_id", item.ID, "scope", string(item.Scope), "error", err)
return reviewPairs(items, r.cfg.Threshold, lexicalSimFunc(items), tidyRecallLexical), true, nil
}
return reviewPairs(items, r.cfg.Threshold, vectorSimFunc(vecs), tidyRecallEmbedding), false, nil
}
// reviewPairs 用整理的收集内核(collectTidyPairs)产出候选对,再只保留以
// items[0](新条目)为中心的那些,转成审查候选。首位的 id 为空时无法区分
// 中心,直接返回空 —— 宁可少审一条,也绝不把两条既有记忆当成候选。
func reviewPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string) []ingestCandidate {
if len(items) < 2 || items[0].ID == "" {
return nil
}
var sink TidyStats
// 零值预算 = 无时间预算:入库前审查的对数由它自己的 ScanLimit 收口
// (那是 §15 D3 切片的护栏),本切片不改它的行为。
pairs, _ := collectTidyPairs(items, threshold, sim, mode, &sink, tidyBudget{})
out := make([]ingestCandidate, 0, len(pairs))
for _, p := range pairs {
switch {
case p.a.ID == items[0].ID:
out = append(out, ingestCandidate{item: p.b, score: p.score, mode: p.mode})
case p.b.ID == items[0].ID:
out = append(out, ingestCandidate{item: p.a, score: p.score, mode: p.mode})
}
}
return out
}
// embedReviewTexts 分批把条目向量化(批次上限复用整理侧的 tidyEmbedBatch:
// 不把整个分区的文本一次性交给远端 embedding API)。单批失败即整体失败,
// 由调用方决定降级还是上报,绝不半途改用混合表示。
func embedReviewTexts(ctx context.Context, emb embedding.Embedder, items []MemoryItem) ([]embedding.Vector, error) {
out := make([]embedding.Vector, 0, len(items))
for start := 0; start < len(items); start += tidyEmbedBatch {
end := min(start+tidyEmbedBatch, len(items))
texts := make([]string, 0, end-start)
for _, it := range items[start:end] {
texts = append(texts, it.Content)
}
vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, ErrTidyEmbedCount
}
out = append(out, vecs...)
}
return out, nil
}
@@ -0,0 +1,66 @@
package memory
import (
"context"
"database/sql"
"fmt"
)
// 本文件是 D3「入库前审查」(design/23 §15.1 D3)的 schema 侧。
//
// 为什么是一张新表而不是塞进 memory_tidy_candidates:设计稿要的是**每一次
// 入库前审查都留一条结构化记录**(决定 / 依据 / 命中的既有 id / 是否降级,
// §15 D3-8),而 tidy 候选表是「人工确认队列」(pending/applied/rejected),
// 语义不同、生命周期也不同 —— 审查是已经生效的事实留痕,不是待批准的提议。
// 两表共用同一套 id/时间戳口径(主键 id、created_at),词表口径见各列注释。
//
// 表结构的权威机制仍是 internal/db 的迁移(tidy 的 0019 是范例);本表**尚未
// 登记迁移**,改由 NewStore 幂等前滚,与 memory_items / tidy 两表同一手法
// (store_schema.go 的口径:记忆存储必须自包含、可在任意 *sql.DB 上使用)。
// 迁移登记留给主会话(见 MTB-report.md 的「未做」一节),以免与并行切片
// 抢迁移编号。
// createMemoryIngestReviewsTable 是入库前审查的结构化留痕(design/23 §15 D3-8)。
// 刻意**不存记忆正文**:命中的既有 id 足以让 owner-only 的读侧回表取正文,
// 而正文不进日志/不进这张表,是把「记忆正文不进日志」这条落成结构。
const createMemoryIngestReviewsTable = `
CREATE TABLE IF NOT EXISTS memory_ingest_reviews (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
persona_id TEXT NOT NULL,
scope TEXT NOT NULL,
project_id TEXT NOT NULL DEFAULT '',
decision TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
veto TEXT NOT NULL DEFAULT '',
target_id TEXT NOT NULL DEFAULT '',
merged_ids TEXT NOT NULL DEFAULT '[]',
score REAL NOT NULL DEFAULT 0,
recall_mode TEXT NOT NULL DEFAULT '',
degraded INTEGER NOT NULL DEFAULT 0,
rule_version TEXT NOT NULL DEFAULT '',
session_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`
const createMemoryIngestReviewsItemIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_ingest_reviews_item_id
ON memory_ingest_reviews (item_id)`
const createMemoryIngestReviewsCreatedIndex = `
CREATE INDEX IF NOT EXISTS idx_memory_ingest_reviews_created_at
ON memory_ingest_reviews (created_at)`
// ensureIngestReviewSchema 幂等地创建入库前审查的留痕表与索引。
func ensureIngestReviewSchema(ctx context.Context, db *sql.DB) error {
for _, ddl := range []string{
createMemoryIngestReviewsTable,
createMemoryIngestReviewsItemIndex,
createMemoryIngestReviewsCreatedIndex,
} {
if _, err := db.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("memory: ingest review schema: %w", err)
}
}
return nil
}
+366
View File
@@ -0,0 +1,366 @@
package memory
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// 本文件是入库前审查(design/23 §15.1 D3)的存储层:候选读取、软合并落定、
// 回滚与审查留痕。它复用 memory 包既有的 *sql.DB、分区口径(tidyPartition)与
// 格式化辅助,不新建存储机制,也不碰任何既有行的 content(design §8)。
//
// 唯一的写入口径(§15 D3-6「merge/modify 一律走 §8 的既有口径」):
// - 被吸收的行:status='merged' + merged_into=<存活 id>;
// - 存活行:merged_from 追加、merge_count +1、confidence 取较大者、
// updated_at 刷新(§15 D3-2 T0-1 的「只更新 updated_at/confidence」)。
// 行**永不物理删除**,因此 revert 只需把 status 复原。
// ingestStatusMerged 是「已被合并」的行状态(design/23 §10 的 status 列)。
const ingestStatusMerged = "merged"
const listReviewCandidatesSQL = `SELECT id, persona_id, role_id, project_id, scope, kind,
content, tags, confidence, source_conversation, created_at, updated_at,
decay_weight FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ? AND id <> ?
ORDER BY created_at DESC, rowid DESC LIMIT ?`
const markAbsorbedSQL = `UPDATE memory_items
SET status = ?, merged_into = ?, updated_at = ? WHERE id = ?`
const selectSurvivorMergeSQL = `SELECT merged_from, merge_count, confidence
FROM memory_items WHERE id = ?`
const updateSurvivorSQL = `UPDATE memory_items
SET merged_from = ?, merge_count = ?, confidence = ?, updated_at = ? WHERE id = ?`
const revertMergedSQL = `UPDATE memory_items
SET status = ?, merged_into = '', updated_at = ? WHERE id = ? AND status = ?`
const selectMergedIntoSQL = `SELECT merged_into FROM memory_items
WHERE id = ? AND status = ?`
const selectItemStatusSQL = `SELECT status FROM memory_items WHERE id = ?`
const insertIngestReviewSQL = `INSERT INTO memory_ingest_reviews
(id, item_id, persona_id, scope, project_id, decision, reason, veto, target_id,
merged_ids, score, recall_mode, degraded, rule_version, session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
const listIngestReviewsSQL = `SELECT id, item_id, persona_id, scope, project_id, decision,
reason, veto, target_id, merged_ids, score, recall_mode, degraded, rule_version,
session_id, created_at FROM memory_ingest_reviews
ORDER BY created_at DESC, rowid DESC LIMIT ?`
// AttachIngestReviewer 把入库前审查接到该 store 的写入路径:此后每条经 Add
// 落库的条目都会被投入审查队列(design §15 D3-1 的单一写入路径)。nil 是空操作
// (保持「未启用」的语义),可被并发调用,last-writer-wins。
func (s *Store) AttachIngestReviewer(r *PreInsertReviewer) {
if s == nil || r == nil {
return
}
s.reviewMu.Lock()
defer s.reviewMu.Unlock()
s.review = r
}
// ingestReviewer 返回当前接入的审查服务,未接入时为 nil。
func (s *Store) ingestReviewer() *PreInsertReviewer {
if s == nil {
return nil
}
s.reviewMu.RLock()
defer s.reviewMu.RUnlock()
return s.review
}
// enqueueReview 把刚落库的条目投入审查队列。非阻塞:队列满时审查方自己记
// WARN(它才是唯一知道队列状态的一侧),写入方绝不因此阻塞或失败。
func (s *Store) enqueueReview(item MemoryItem) {
if r := s.ingestReviewer(); r != nil {
r.Enqueue(item)
}
}
// reviewItemStatus 读取一条记忆当前的在册状态。审查据此保证**每条记忆只审一次**:
// 已经被前一次审查合并掉的行再判一次会把存活行的 merge_count 重复加一
// (同一条事实被数两次),因此非 active 的行直接跳过。行不存在返回 ErrNotFound。
func (s *Store) reviewItemStatus(ctx context.Context, id string) (string, error) {
var status string
err := s.db.QueryRowContext(ctx, selectItemStatusSQL, id).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("memory: read review item status: %w", err)
}
return status, nil
}
// listReviewCandidates 读取同一分区内的在册候选(最新在前)。分区过滤写在
// SQL 里,而不是读全表再在内存里筛 —— design §6 要求在分区切分层就隔断。
func (s *Store) listReviewCandidates(ctx context.Context, p tidyPartition, excludeID string, limit int) ([]MemoryItem, error) {
if limit <= 0 {
limit = defaultReviewScanLimit
}
rows, err := s.db.QueryContext(ctx, listReviewCandidatesSQL,
p.personaID, string(p.scope), p.projectID, string(tidyStatusActive), excludeID, limit)
if err != nil {
return nil, fmt.Errorf("memory: list review candidates: %w", err)
}
return scanItems(rows, "review candidate")
}
// absorbIngestItem 把 absorbed 软合并进 survivor:一个事务里做「标记被吸收 +
// 刷新存活行的合并元信息」。两条 UPDATE 都只碰 status/merged_*/merge_count/
// confidence/updated_at,**绝不改 content**(design §8 的可回滚口径)。
func (s *Store) absorbIngestItem(ctx context.Context, absorbed, survivor MemoryItem, now time.Time) error {
if absorbed.ID == "" || survivor.ID == "" || absorbed.ID == survivor.ID {
return fmt.Errorf("memory: absorb ingest item: invalid pair %q -> %q", absorbed.ID, survivor.ID)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: absorb ingest item: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, markAbsorbedSQL,
string(ingestStatusMerged), survivor.ID, formatTime(now.UTC()), absorbed.ID); err != nil {
return fmt.Errorf("memory: mark absorbed item: %w", err)
}
if err := bumpSurvivor(ctx, tx, survivor, absorbed, now); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory: absorb ingest item: commit: %w", err)
}
return nil
}
// bumpSurvivor 刷新存活行的合并元信息:merged_from 追加被吸收的 id、
// merge_count +1、confidence 取两者较大者、updated_at 刷新。
// 读-改-写在同一个事务内完成(连接池是单连接,天然串行),因此并发审查
// 不会互相覆盖。
func bumpSurvivor(ctx context.Context, tx *sql.Tx, survivor, absorbed MemoryItem, now time.Time) error {
var (
mergedFromJSON string
mergeCount int
confidence float64
)
if err := tx.QueryRowContext(ctx, selectSurvivorMergeSQL, survivor.ID).
Scan(&mergedFromJSON, &mergeCount, &confidence); err != nil {
return fmt.Errorf("memory: read survivor merge state: %w", err)
}
ids := make([]string, 0)
if strings.TrimSpace(mergedFromJSON) != "" {
if err := json.Unmarshal([]byte(mergedFromJSON), &ids); err != nil {
return fmt.Errorf("memory: decode survivor merged_from: %w", err)
}
}
if !containsIngestID(ids, absorbed.ID) {
ids = append(ids, absorbed.ID)
}
encoded, err := json.Marshal(ids)
if err != nil {
return fmt.Errorf("memory: encode survivor merged_from: %w", err)
}
if absorbed.Confidence > confidence {
// 置信度只升不降:同一条事实被再次观察到,是「它更可信」的证据,
// 而不是要把它调低(§15 D3-2 T0-1 的「只更新 confidence」)。
confidence = absorbed.Confidence
}
if _, err := tx.ExecContext(ctx, updateSurvivorSQL,
string(encoded), mergeCount+1, confidence, formatTime(now.UTC()), survivor.ID); err != nil {
return fmt.Errorf("memory: bump survivor item: %w", err)
}
return nil
}
// RevertMerged 撤销一次软合并(design/23 §8「回滚」):把被吸收行的 status 复原
// 为 active 并清空 merged_into,同时把它的 id 从存活行的 merged_from 里摘掉、
// 把 merge_count 减回去(下限 1)。行与被合并的原文都还在,因此这是无损回滚。
// 该行不在 merged 状态时返回 ErrNotFound(不假装回滚成功)。
func (s *Store) RevertMerged(ctx context.Context, id string) error {
if strings.TrimSpace(id) == "" {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("memory: revert merged item: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
var survivorID string
err = tx.QueryRowContext(ctx, selectMergedIntoSQL, id, string(ingestStatusMerged)).Scan(&survivorID)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("memory: read merged item: %w", err)
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx, revertMergedSQL,
string(tidyStatusActive), formatTime(now), id, string(ingestStatusMerged)); err != nil {
return fmt.Errorf("memory: revert merged item: %w", err)
}
if survivorID != "" {
if err := dropSurvivorLink(ctx, tx, survivorID, id, now); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("memory: revert merged item: commit: %w", err)
}
return nil
}
// dropSurvivorLink 把 id 从存活行的合并登记里摘掉:merged_from 去掉它、
// merge_count 减 1(下限 1)。存活行不存在(例如已被删除)时是空操作 ——
// 回滚的要点是「被吸收行复原」,绝不因为它而失败。
func dropSurvivorLink(ctx context.Context, tx *sql.Tx, survivorID, id string, now time.Time) error {
var (
mergedFromJSON string
mergeCount int
confidence float64
)
err := tx.QueryRowContext(ctx, selectSurvivorMergeSQL, survivorID).
Scan(&mergedFromJSON, &mergeCount, &confidence)
if errors.Is(err, sql.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("memory: read survivor for revert: %w", err)
}
ids := make([]string, 0)
if strings.TrimSpace(mergedFromJSON) != "" {
if err := json.Unmarshal([]byte(mergedFromJSON), &ids); err != nil {
return fmt.Errorf("memory: decode survivor merged_from: %w", err)
}
}
kept := make([]string, 0, len(ids))
for _, v := range ids {
if v != id {
kept = append(kept, v)
}
}
encoded, err := json.Marshal(kept)
if err != nil {
return fmt.Errorf("memory: encode survivor merged_from: %w", err)
}
next := mergeCount - 1
if next < 1 {
next = 1
}
if _, err := tx.ExecContext(ctx, updateSurvivorSQL,
string(encoded), next, confidence, formatTime(now), survivorID); err != nil {
return fmt.Errorf("memory: drop survivor link: %w", err)
}
return nil
}
// insertIngestReview 落一条审查留痕(design/23 §15 D3-8)。
func (s *Store) insertIngestReview(ctx context.Context, rev IngestReview) error {
if rev.ID == "" {
rev.ID = mustNewTidyID()
}
if rev.MergedIDs == nil {
rev.MergedIDs = []string{}
}
mergedJSON, err := json.Marshal(rev.MergedIDs)
if err != nil {
return fmt.Errorf("memory: encode review merged ids: %w", err)
}
if rev.CreatedAt.IsZero() {
rev.CreatedAt = time.Now().UTC()
}
if _, err := s.db.ExecContext(ctx, insertIngestReviewSQL,
rev.ID, rev.ItemID, rev.PersonaID, string(rev.Scope), rev.ProjectID, string(rev.Decision),
rev.Reason, string(rev.Veto), rev.TargetID, string(mergedJSON), rev.Score, rev.RecallMode,
boolToInt(rev.Degraded), rev.RuleVer, rev.SessionID, formatTime(rev.CreatedAt)); err != nil {
return fmt.Errorf("memory: insert ingest review: %w", err)
}
return nil
}
// ListIngestReviews 返回最近的审查留痕(最新在前,最多 limit 条;非正 limit
// 取 defaultReviewScanLimit)。读侧含命中 id 与依据,应由 owner-only 的
// 读侧使用(design §15 D3-8「面板可查」)。
func (s *Store) ListIngestReviews(ctx context.Context, limit int) ([]IngestReview, error) {
if limit <= 0 {
limit = defaultReviewScanLimit
}
rows, err := s.db.QueryContext(ctx, listIngestReviewsSQL, limit)
if err != nil {
return nil, fmt.Errorf("memory: list ingest reviews: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]IngestReview, 0)
for rows.Next() {
rev, err := scanIngestReview(rows)
if err != nil {
return nil, fmt.Errorf("memory: scan ingest review: %w", err)
}
out = append(out, rev)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: iterate ingest reviews: %w", err)
}
return out, nil
}
// scanIngestReview 读取一行审查留痕:merged_ids 以 JSON 数组存储,
// degraded 以 0/1 存储。
func scanIngestReview(sc rowScanner) (IngestReview, error) {
var (
rev IngestReview
scope, decision string
veto string
mergedJSON string
degraded int
createdAt string
)
if err := sc.Scan(&rev.ID, &rev.ItemID, &rev.PersonaID, &scope, &rev.ProjectID, &decision,
&rev.Reason, &veto, &rev.TargetID, &mergedJSON, &rev.Score, &rev.RecallMode, &degraded,
&rev.RuleVer, &rev.SessionID, &createdAt); err != nil {
return IngestReview{}, err
}
rev.Scope = Scope(scope)
rev.Decision = IngestDecision(decision)
rev.Veto = TidyVetoReason(veto)
rev.Degraded = degraded != 0
if err := json.Unmarshal([]byte(mergedJSON), &rev.MergedIDs); err != nil {
return IngestReview{}, fmt.Errorf("memory: decode review merged ids: %w", err)
}
if rev.MergedIDs == nil {
rev.MergedIDs = []string{}
}
parsed, err := parseTime(createdAt)
if err != nil {
return IngestReview{}, err
}
rev.CreatedAt = parsed
return rev, nil
}
// containsIngestID 报告 ids 里是否已有 id(避免 merged_from 重复登记同一条)。
func containsIngestID(ids []string, id string) bool {
for _, v := range ids {
if v == id {
return true
}
}
return false
}
// boolToInt 把布尔落成 SQLite 的 0/1。
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+278
View File
@@ -0,0 +1,278 @@
package memory
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 D3「入库前审查」(design/23 §15.1 D3)的**四态判定与 fail-safe** 测试:
// 完全等 ⇒ skip、真包含 ⇒ merge/modify、不相似/并列 ⇒ create、审查不可用 ⇒
// 一行都不改写。隔离、异步与开关口径在 preinsert_async_test.go。
// reviewRowState 是测试直接读回的一行记忆的整理列。
type reviewRowState struct {
status string
mergedInto string
mergeCount int
confidence float64
content string
}
// readReviewRow 直读一行记忆的整理列(status 等列不在 Store.Get 的读侧面上)。
func readReviewRow(t *testing.T, store *Store, id string) reviewRowState {
t.Helper()
var st reviewRowState
if err := store.db.QueryRowContext(context.Background(),
`SELECT status, merged_into, merge_count, confidence, content FROM memory_items WHERE id = ?`,
id).Scan(&st.status, &st.mergedInto, &st.mergeCount, &st.confidence, &st.content); err != nil {
t.Fatalf("read row %s: %v", id, err)
}
return st
}
// countActiveRows 数一个分区里还在册(status='active')的条目数。
func countActiveRows(t *testing.T, store *Store, personaID string) int {
t.Helper()
var n int
if err := store.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM memory_items WHERE persona_id = ? AND status = ?`,
personaID, string(tidyStatusActive)).Scan(&n); err != nil {
t.Fatalf("count active rows: %v", err)
}
return n
}
// newTestReviewer 构建固定时钟的审查服务。阈值显式写死,其余走设计稿默认值。
func newTestReviewer(t *testing.T, store *Store, emb embedding.Embedder, threshold float64) *PreInsertReviewer {
t.Helper()
r, err := NewPreInsertReviewer(ReviewConfig{
Enabled: true,
QueueSize: 8,
ScanLimit: 50,
Timeout: 5 * time.Second,
Threshold: threshold,
}, ReviewDeps{
Store: store,
Embedder: emb,
Now: func() time.Time { return tidyTestTime(t) },
Log: testLogger(),
})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
return r
}
// reviewAt 构造与既有条目同分区、可指定内容与溯源的新条目。
func reviewAt(content string, at time.Time, confidence float64) MemoryItem {
item := habitItem(content, at, confidence)
item.SourceConversation = FormatSourceConversation("web", "msg-review")
return item
}
// TestReviewExactDuplicateDoesNotCreateASecondActiveRow 是 design/23 §15 D3-9①:
// **完全等价 ⇒ 不新建**(这正是主人报的「反复新建」)。活跃行数必须仍是 1。
func TestReviewExactDuplicateDoesNotCreateASecondActiveRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
dup := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
}}, 0.80)
rev, err := r.Review(ctx, dup)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestSkip {
t.Fatalf("decision = %q, want %q", rev.Decision, IngestSkip)
}
if rev.TargetID != existing.ID {
t.Fatalf("命中的既有 id = %q, want %q", rev.TargetID, existing.ID)
}
got := readReviewRow(t, store, dup.ID)
if got.status != ingestStatusMerged || got.mergedInto != existing.ID {
t.Fatalf("重复行未折回既有行:status=%q merged_into=%q", got.status, got.mergedInto)
}
survivor := readReviewRow(t, store, existing.ID)
if survivor.status != tidyStatusActive || survivor.mergeCount != 2 {
t.Fatalf("既有行 survivor = %+v, want active 且 merge_count=2", survivor)
}
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("活跃行数 = %d, want 1(反复新建没有被治住)", n)
}
reviews, err := store.ListIngestReviews(ctx, 10)
if err != nil {
t.Fatalf("ListIngestReviews: %v", err)
}
if len(reviews) != 1 || reviews[0].Decision != IngestSkip || reviews[0].RuleVer != IngestRuleVersion {
t.Fatalf("审查留痕 = %+v, want 恰为一条 skip 且带规则版本", reviews)
}
if reviews[0].SessionID != TidySessionID {
t.Fatalf("留痕会话 = %q, want 复用 %q(记忆管理会话)", reviews[0].SessionID, TidySessionID)
}
}
// TestReviewSubsetMergesAndIsRevertable 是 design/23 §15 D3-9②:真包含 ⇒ 合并,
// 且**旧行可 revert**(软删除绝不物理删除,§8)。
func TestReviewSubsetMergesAndIsRevertable(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base, 0.9))
short := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitLong: {1, 0},
fixtureHabitShort: simVector(0.95),
}}, 0.80)
rev, err := r.Review(ctx, short)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestMerge || rev.TargetID != existing.ID {
t.Fatalf("verdict = %+v, want merge 并入超集 %s", rev, existing.ID)
}
absorbed := readReviewRow(t, store, short.ID)
if absorbed.status != ingestStatusMerged || absorbed.content != fixtureHabitShort {
t.Fatalf("被并入行 = %+v, want merged 且原文逐字保留", absorbed)
}
survivor := readReviewRow(t, store, existing.ID)
if survivor.mergeCount != 2 || survivor.content != fixtureHabitLong {
t.Fatalf("存活行 = %+v, want merge_count=2 且 content 不改", survivor)
}
if err := store.RevertMerged(ctx, short.ID); err != nil {
t.Fatalf("RevertMerged: %v", err)
}
restored := readReviewRow(t, store, short.ID)
if restored.status != tidyStatusActive || restored.mergedInto != "" || restored.content != fixtureHabitShort {
t.Fatalf("revert 后 = %+v, want active 且原文无损", restored)
}
afterRevert := readReviewRow(t, store, existing.ID)
if afterRevert.mergeCount != 1 {
t.Fatalf("revert 后存活行 merge_count = %d, want 1(登记也一并摘掉)", afterRevert.mergeCount)
}
}
// TestReviewSupersetModifiesExisting 是 design/23 §15 D3-6 的「改写」路径:
// 既有条目是新条目的子集 ⇒ **新行承载更全的内容**,旧行置为被合并。
func TestReviewSupersetModifiesExisting(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base, 0.9))
longer := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}}, 0.80)
rev, err := r.Review(ctx, longer)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestModify || rev.TargetID != existing.ID {
t.Fatalf("verdict = %+v, want modify(既有行被改写)", rev)
}
if got := readReviewRow(t, store, longer.ID); got.status != tidyStatusActive || got.content != fixtureHabitLong {
t.Fatalf("新行 = %+v, want 存活且承载更全内容", got)
}
old := readReviewRow(t, store, existing.ID)
if old.status != ingestStatusMerged || old.mergedInto != longer.ID {
t.Fatalf("旧行 = %+v, want merged 进新行", old)
}
if n := countActiveRows(t, store, "persona.a"); n != 1 {
t.Fatalf("活跃行数 = %d, want 1", n)
}
}
// TestReviewDissimilarAndParallelFactsCreate 是 design/23 §15 D3-9③ 与主人红线
// 「不要什么内容都合并」:不相似、以及**相似但并列**的事实都正常新建。
func TestReviewDissimilarAndParallelFactsCreate(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureEnergyRedBull, base, 0.95))
parallel := seedTidyItem(t, store, reviewAt(fixtureEnergyIced, base.Add(time.Minute), 0.95))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureEnergyRedBull: {1, 0},
fixtureEnergyIced: simVector(0.99),
}}, 0.80)
rev, err := r.Review(ctx, parallel)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate {
t.Fatalf("并列事实被判成 %q,主人红线是「不要什么内容都合并」", rev.Decision)
}
if n := countActiveRows(t, store, "persona.a"); n != 2 {
t.Fatalf("活跃行数 = %d, want 2", n)
}
}
// TestReviewTemporalPollutionIsNeverMerged 是 design/23 §5.2-1:含时效词
// (假期/期间…)的记忆不参与合并 —— 有期限的状态不是长期事实。
func TestReviewTemporalPollutionIsNeverMerged(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
seedTidyItem(t, store, reviewAt(fixtureHabitPlain, base, 0.9))
vacation := seedTidyItem(t, store, reviewAt(fixtureHabitVacation, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitPlain: {1, 0},
fixtureHabitVacation: simVector(0.97),
}}, 0.80)
rev, err := r.Review(ctx, vacation)
if err != nil {
t.Fatalf("Review: %v", err)
}
if rev.Decision != IngestCreate || rev.Veto != VetoTemporal {
t.Fatalf("verdict = %+v, want create + temporal 否决", rev)
}
if q := readReviewRow(t, store, vacation.ID); q.status != tidyStatusActive {
t.Fatalf("时效条目被改写了:%+v", q)
}
}
// TestReviewFailureNeverTouchesMemory 是 design/23 §15 D3-9④ 的 fail-safe:
// 审查不可用(超时/取消)⇒ **照旧保留**(一行都不改写)+ 交给调用方记 WARN。
func TestReviewFailureNeverTouchesMemory(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
existing := seedTidyItem(t, store, reviewAt(fixtureHabitLong, base, 0.9))
short := seedTidyItem(t, store, reviewAt(fixtureHabitShort, base.Add(time.Minute), 0.9))
r := newTestReviewer(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitLong: {1, 0},
fixtureHabitShort: simVector(0.95),
}}, 0.80)
// 已经取消的 context:审查必须如实失败,而不是「猜一个结论」。
dead, cancel := context.WithCancel(context.Background())
cancel()
if _, err := r.Review(dead, short); err == nil {
t.Fatal("审查在不可用时返回了成功,必须如实报错")
}
if got := readReviewRow(t, store, short.ID); got.status != tidyStatusActive {
t.Fatalf("审查失败却改写了记忆:%+v", got)
}
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
t.Fatalf("审查失败却改了 survivor:%+v", got)
}
if reviews, err := store.ListIngestReviews(context.Background(), 10); err != nil || len(reviews) != 0 {
t.Fatalf("审查失败不该留决定留痕:reviews=%+v err=%v", reviews, err)
}
}
+16
View File
@@ -161,6 +161,13 @@ type Store struct {
// 不触碰项目索引(design §3 项目记忆链接项目索引)。
projMu sync.RWMutex
proj *projectindex.Store
// reviewMu 保护 review,使入库前审查(design/23 §15.1 D3)能在装配后
// 并发接入:接入后每条经 Add 落库的条目都会被投入审查队列,由独立的
// 记忆管理会话判定 merge/modify/create/skip。review 为 nil 表示未启用
// 或未接线,此时写入路径行为逐字与接入前一致。
reviewMu sync.RWMutex
review *PreInsertReviewer
}
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
@@ -198,6 +205,11 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
if err := ensureTidySchema(ctx, db); err != nil {
return nil, err
}
// 入库前审查(design/23 §15.1 D3)的留痕表:同一口径 —— 表在这里幂等
// 创建,因此审查能力在任何 *sql.DB 上都可用;未接线审查时它只是一张空表。
if err := ensureIngestReviewSchema(ctx, db); err != nil {
return nil, err
}
if err := normalizeMemoryTimestamps(ctx, db); err != nil {
return nil, err
}
@@ -239,6 +251,10 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
}
s.indexItem(ctx, item)
s.ensureProjectIndex(ctx, item)
// 入库前审查(design/23 §15.1 D3):唯一写入路径的这一处把刚落库的条目
// 投入审查队列。投递是非阻塞的 —— 审查跑在独立的记忆管理会话里,绝不
// 让写入方(回合收尾)等它。未接线审查时是空操作。
s.enqueueReview(item)
return item, nil
}
+37 -10
View File
@@ -165,8 +165,9 @@ type tidyCandidateSink func(ctx context.Context, cands []TidyCandidate, stats *T
// tidyRecall 是**本轮的召回策略**:给定已截断的参与比对条目,产出候选对。
// 它把「相似度来源不可用时怎么办」的差异收在一处:生产轮降级到零依赖词项
// 余弦,试算轮如实上报错误。
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error)
// 余弦,试算轮如实上报错误。budget 是本轮的时间预算闸(见 tidy_budget.go),
// 由调用方一路传下来,使两条策略共享**同一个**预算(而不是各自从 ctx 再派生)。
type tidyRecall func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error)
// tidyRoundPolicy 是本轮整理的出口策略:候选去哪 + 召回来源不可用时怎么办。
// 「生产轮」与「试算轮」的**全部**差异都在这两个字段上,规则引擎其余部分
@@ -261,10 +262,14 @@ func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, er
// scanPartitions 逐个分区跑规则并把候选交给 policy.sink。它返回失败列表
// (单个分区失败不阻塞其它分区,design §9);成功时返回 nil。
//
// 时间预算(tidy_budget.go)在**轮**级派生一次:ctx 有墙钟上限(同步/异步试算)
// 时,预算用尽后的分区不再开跑,而是如实登记为 (budget) 截断。
func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition, stats *TidyStats, policy tidyRoundPolicy) []error {
budget := t.newTidyBudget(ctx)
var failures []error
for _, p := range partitions {
if err := t.runPartition(ctx, p, stats, policy); err != nil {
if err := t.runPartition(ctx, p, stats, policy, budget); err != nil {
stats.Errors++
failures = append(failures, err)
t.log.Warn("memory tidy partition failed", "partition", p.label(), "error", err)
@@ -276,21 +281,31 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats)
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget)
if err == nil {
return pairs, nil
}
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err)
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
}
// runPartition 整理一个分区(persona_id + scope + project_id)。
// 分区之间**永不比较**(design/23 §6 硬约束)。候选去处与召回降级策略都由
// policy 决定,因此生产轮与试算轮走的是**同一条**扫描→召回→判定→构造候选
// 的路径。
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy) error {
func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyStats, policy tidyRoundPolicy, budget tidyBudget) error {
// 预算用尽 ⇒ 这个分区本轮根本不看。**如实登记**(响应 truncated + WARN):
// 「没扫到」与「扫了没有候选」必须能区分,否则面板会把预算截断读成
// 「这个分区没有建议」。
if budget.exhausted() {
stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p))
t.log.Warn("memory tidy partition skipped by time budget", "partition", p.label())
return nil
}
items, err := t.deps.Store.listTidyPartitionItems(ctx, p, t.cfg.ScanLimit)
if err != nil {
return fmt.Errorf("memory/tidy: list partition %s: %w", p.label(), err)
@@ -300,16 +315,24 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
// 扫描上限命中:本分区更大的部分这一轮不看(design §9「超限分片,下一轮
// 继续」)。本实现只做「最新优先」的截断、不推进分片游标,因此这里必须
// 留下痕迹,否则「配置了 5000 却永远只看最新 5000」会无人知晓。
// 日志带**真总数**(LIMIT 之后的 len(items) 只是下限):另查一次 COUNT,
// 否则 WARN 会把「至少 5000」说成「正好 5000」。
total, totalErr := t.deps.Store.countTidyPartitionItems(ctx, p)
stats.Truncated = append(stats.Truncated, p.label()+"(scan)")
t.log.Warn("memory tidy partition hit the scan limit",
"partition", p.label(), "limit", t.cfg.ScanLimit)
if totalErr != nil {
t.log.Warn("memory tidy partition hit the scan limit (total unknown)",
"partition", p.label(), "limit", t.cfg.ScanLimit, "error", totalErr)
} else {
t.log.Warn("memory tidy partition hit the scan limit",
"partition", p.label(), "limit", t.cfg.ScanLimit, "total", total)
}
}
comparable, archives := t.classifyItems(items, stats)
// 截断与计数只做一次:召回策略拿到的是**已截断**的条目,回退重算不重复计数。
compared := t.limitCompareItems(p, comparable, stats)
pairs, err := policy.recall(ctx, p, compared, stats)
pairs, err := policy.recall(ctx, p, compared, stats, budget)
if err != nil {
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
}
@@ -327,6 +350,10 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
if len(candidates) > t.cfg.CandidateLimit {
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
// 判出的,必须能从这里读出被丢掉了多少。
t.log.Warn("memory tidy candidates truncated by per-partition limit",
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
candidates = candidates[:t.cfg.CandidateLimit]
+97
View File
@@ -0,0 +1,97 @@
package memory
import (
"context"
"time"
)
// 本文件是记忆整理**时间预算闸**(切片 MT-A):它是「上限」之外的第二道闸。
//
// 为什么需要它:抬高护栏(compare 400 → 2000)之后,一个巨型分区可以在墙钟上限
// 内烧掉几分钟。同一台开发机上实测(临时库、单分区、CompareLimit=2000):
//
// n=2000 语义比对(1024 维)≈ 23.5 秒 ← 纯 O(n²·dim) 计算
// n=2000 词项余弦回退 ≈ 40.5 秒 ← 每对都重算词项集合,CPU 更贵
//
// 而真实的语义召回是**远端调用**:真机 89.9 秒 / 744 条 ⇒ 约 117 ms/条,2000 条
// 就是 234 秒,已经越过试算的 3 分钟墙钟上限。没有这道闸,结果是「整个请求超时、
// 一条候选都拿不到」;有它,本轮在 deadline 前收尾,把**已完成分区的结果**如实
// 交出去,并把这些分区标成 (budget) 截断。
//
// 它**不改变**心跳轮:心跳跑在进程生命周期 ctx 上(没有 deadline ⇒ 没有预算),
// 只有带墙钟上限的调用方(同步 / 异步试算)才受它约束。
const (
// tidyBudgetMargin 是时间预算相对 ctx deadline 的默认收尾余量:留出投影响应、
// 写注册表的时间,使本轮被**预算**截断,而不是被硬超时打断(后者会连已算完的
// 分区一起丢掉)。
tidyBudgetMargin = 10 * time.Second
// tidyBudgetMarginDivisor 是短预算下的余量折算:总预算本身短于 tidyBudgetMargin
// 时,余量退到预算的 1/divisor。否则「调用方只给了 5 秒」会被判成「一上来就
// 预算用尽」的假截断(3 分钟的试算预算不受影响)。
tidyBudgetMarginDivisor = 10
// tidyBudgetCheckEveryPairs 是两两比对循环里检查预算的间隔(按已比较的对数)。
// 每对都问一次时钟会把 O(n²) 循环本身拖慢;按批检查足以在预算用尽后很快收手。
tidyBudgetCheckEveryPairs = 4096
)
// tidyBudget 是本轮的时间预算(由调用方的 ctx deadline 派生)。零值 = 无预算
// (ctx 没有 deadline,如心跳轮 —— 那时它的行为与从前逐字一致)。
type tidyBudget struct {
deadline time.Time
margin time.Duration
enabled bool
// now 是判定预算的时钟:生产是 time.Now,测试可注入(因此「预算用尽 ⇒ 诚实
// 截断」这条路可以确定性地验,而不必真的等 3 分钟)。零值时钟视为无预算时钟,
// 由 newTidyBudget 填上。
now func() time.Time
}
// newTidyBudget 从 ctx 的 deadline 派生本轮预算。ctx 为 nil 或无 deadline 时
// 预算关闭。
func (t *Tidier) newTidyBudget(ctx context.Context) tidyBudget {
if ctx == nil {
return tidyBudget{}
}
deadline, ok := ctx.Deadline()
if !ok {
return tidyBudget{}
}
return tidyBudget{
deadline: deadline,
margin: tidyBudgetMarginFor(deadline.Sub(t.now())),
enabled: true,
now: t.now,
}
}
// tidyBudgetMarginFor 按剩余预算算出收尾余量(见 tidyBudgetMarginDivisor)。
func tidyBudgetMarginFor(total time.Duration) time.Duration {
switch {
case total <= 0:
return 0
case total < tidyBudgetMargin:
return total / tidyBudgetMarginDivisor
default:
return tidyBudgetMargin
}
}
// exhausted 报告预算是否已用尽。它按**本轮整理器的时钟**判断(生产 = time.Now;
// 测试可注入),而不是另开一个时间源 —— 预算讲的是「还能烧多久」,与判定用的
// 时钟同源才不会互相打架。
func (b tidyBudget) exhausted() bool {
if !b.enabled || b.now == nil {
return false
}
return b.now().After(b.deadline.Add(-b.margin))
}
// budgetTruncationLabel 渲染时间预算截断的分区标签。它与 (scan)/(compare)/
// (candidates) 同一形状(进响应的 truncated 数组),只是原因不同:那些是
// **配置的配额**被打满,这个是**墙钟预算**用尽。
func budgetTruncationLabel(p tidyPartition) string {
return p.label() + "(budget)"
}
+18 -6
View File
@@ -45,14 +45,19 @@ const (
// defaultTidyScanLimit 是每分区每轮的扫描上限(design/23 §9)。
defaultTidyScanLimit = 5000
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限。它是
// 本实现的性能护栏:两两相似度是 O(n²·dim),5000 条会到十亿级乘加,
// 低峰任务也不该跑成那样。超限时按「最新优先」截断并记一条 WARN
// (绝不静默),见 tidy_recall.go。
maxTidyCompareItems = 400
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,低峰任务也不该跑成那样。
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
// 见 tidy_recall.go。
maxTidyCompareItems = 2000
// defaultTidyCandidateLimit 是每分区每轮的候选上限(design/23 §9)。
defaultTidyCandidateLimit = 20
// 2026-10-06 由 20 抬到 200:首轮真跑四个分区同时打满旧值 ⇒ 候选数由
// 上限而不是规则决定(77 是下限)。
defaultTidyCandidateLimit = 200
// tidyLowConfidenceThreshold 是置信度否决门(design/23 §5.2-6):
// confidence 不能当合并门([2]0.7 与 [5]0.8 是同一事实),但必须当否决门。
@@ -90,6 +95,9 @@ type TidyConfig struct {
ScanLimit int
// CandidateLimit 是每分区每轮候选条数上限。非正用默认值。
CandidateLimit int
// CompareLimit 是每分区每轮真正参与两两比对的条目上限(性能护栏)。
// 非正用默认值(maxTidyCompareItems)。
CompareLimit int
}
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
@@ -103,6 +111,7 @@ func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
RecallThreshold: cfg.RecallThreshold,
ScanLimit: cfg.ScanLimit,
CandidateLimit: cfg.CandidateLimit,
CompareLimit: cfg.CompareLimit,
}
}
@@ -127,6 +136,9 @@ func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
if cfg.CandidateLimit <= 0 {
cfg.CandidateLimit = defaultTidyCandidateLimit
}
if cfg.CompareLimit <= 0 {
cfg.CompareLimit = maxTidyCompareItems
}
return cfg
}
+2 -2
View File
@@ -80,8 +80,8 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) {
// strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。
// 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats)
func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget)
if err != nil {
t.log.Warn("memory tidy dry-run recall unavailable",
"partition", p.label(), "items", len(items), "error", err)
@@ -0,0 +1,247 @@
package memory
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// 本文件是记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A)。
//
// 为什么有它:真机上一轮试算是 **89.9 秒 / 744 条**,而抬高护栏(compare /
// candidate 上限)只会让它更慢;同步 HTTP 会被反代或客户端超时切断。异步把
// 「跑」与「取」拆开:`StartDryRun` 立刻返回一个 runId,结果先留在**进程内存**里,
// 由 `DryRunResult` 轮询取回。
//
// 四条不可走样的口径:
//
// 1. **加性**:默认(同步)路径一字未改 —— 异步是**另一条**入口,不是替换。
// 2. **零副作用照旧**:异步跑的是**同一个** `TidyService.DryRun`(同一套规则、
// 同一个 runMu 单写者),因此「一行都不写」是结构性成立的,不是重新实现的
// 承诺。单测在**异步路径上**再钉一次三表逐行快照。
// 3. **结果只活在进程内**:不落库、不新开端口(单二进制、零跨进程)。因此
// 进程重启后旧 runId **如实失效**(ErrTidyRunNotFound),绝不假装还在跑。
// 4. **有界**:完成的记录有 TTL(30 分钟)与条数上限;同时**只允许 1 个在跑**
// (第二个请求复用既有 runId,见 tidyRunRegistry.start)。
const (
// defaultTidyRunTTL 是**已完成**的异步试算结果在进程内的保留时长(契约
// v0.76 §4.12.1 登记的 30 分钟)。running 的记录不参与 TTL 淘汰。
defaultTidyRunTTL = 30 * time.Minute
// maxTidyRunRecords 是注册表里**已完成**记录的条数上限(TTL 之外的第二道闸):
// 瞬时失败的运行(如整理被配置关闭)可能在几秒内堆出上百条,TTL 窗口内
// 必须有一条硬上限兜住内存。淘汰「最早完成」的记录。
maxTidyRunRecords = 32
// defaultTidyAsyncRunTimeout 是调用方没给墙钟上限时的回退值,与契约 §4.12(c)
// 登记的「服务端墙钟上限 3 分钟」同值;网关显式传入时以调用方的为准。
defaultTidyAsyncRunTimeout = 3 * time.Minute
)
// ErrTidyRunNotFound 在异步试算的 runId 查不到时返回。三种原因**刻意不区分**:
// 从未存在 / 已超 TTL / 进程重启过 —— 进程内不留跨重启痕迹,因此它们在客户端
// 看来是同一件事。这里如实回「查不到」,绝不假装那次运行还在跑。
var ErrTidyRunNotFound = errors.New("memory/tidy: dry-run id not found")
// TidyAsyncStatus 是异步试算的状态(契约 v0.76 §4.12.1)。
type TidyAsyncStatus string
const (
// TidyAsyncRunning 表示这次试算还在跑(或还在等单写者锁)。
TidyAsyncRunning TidyAsyncStatus = "running"
// TidyAsyncDone 表示跑完了,Result 可用(形状 = 同步试算的结果)。
TidyAsyncDone TidyAsyncStatus = "done"
// TidyAsyncFailed 表示跑失败了,Err 是**诚实**的失败原因(由网关按同步
// 试算的错误码表投影成 code/message)。
TidyAsyncFailed TidyAsyncStatus = "failed"
)
// TidyDryRunHandle 是启动一次异步试算后立即返回的句柄。RunID 是**不透明**字符串
// (客户端只用它轮询,不要解析其结构)。
type TidyDryRunHandle struct {
RunID string
StartedAt time.Time
}
// TidyAsyncDryRun 是一次异步试算的查询结果。Result 只在 Done 时非 nil、
// Err 只在 Failed 时非零 —— 状态与载荷一一对应,不会出现「running 却带结果」。
type TidyAsyncDryRun struct {
RunID string
Status TidyAsyncStatus
StartedAt time.Time
Result *TidyDryRun
Err error
}
// tidyRunRecord 是注册表里的一条记录。result/err 的零值分别表示「还没结果」。
type tidyRunRecord struct {
id string
status TidyAsyncStatus
startedAt time.Time
finishedAt time.Time
result TidyDryRun
err error
}
// tidyRunRegistry 是异步试算结果的**进程内内存态**注册表。它并发安全;
// 生命周期与进程一致(重启即空)。
type tidyRunRegistry struct {
mu sync.Mutex
ttl time.Duration
runs map[string]*tidyRunRecord
// active 是正在跑的 runId(空 = 空闲)。它实现「同时只允许 1 个在跑」。
active string
}
// newTidyRunRegistry 构建注册表;非正 ttl 取 defaultTidyRunTTL。
func newTidyRunRegistry(ttl time.Duration) *tidyRunRegistry {
if ttl <= 0 {
ttl = defaultTidyRunTTL
}
return &tidyRunRegistry{ttl: ttl, runs: make(map[string]*tidyRunRecord)}
}
// start 登记一次运行。已经有在跑的运行时**复用**它的 runId 并返回 started=false
// ——契约 §4.12.1 给了「如实拒绝或复用既有 runId」两个选项,本实现选复用:
// 它不引入同步路径之外的新错误码,且回给客户端的 startedAt 是真的(那次运行的)。
// fresh=true 表示这次调用**新开**了一次运行,调用方据此启动后台 goroutine。
func (r *tidyRunRegistry) start(now time.Time) (rec tidyRunRecord, fresh bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.sweepLocked(now)
if r.active != "" {
if running, ok := r.runs[r.active]; ok && running.status == TidyAsyncRunning {
return *running, false
}
r.active = ""
}
id := mustNewTidyID()
started := &tidyRunRecord{id: id, status: TidyAsyncRunning, startedAt: now}
r.runs[id] = started
r.active = id
return *started, true
}
// finish 记下一次运行的结果并释放「在跑」名额。runId 未知(已被淘汰)时空操作:
// 记录已不在,结果无处可放也不该重新造一条。
func (r *tidyRunRegistry) finish(id string, result TidyDryRun, err error, now time.Time) {
r.mu.Lock()
defer r.mu.Unlock()
rec, ok := r.runs[id]
if !ok {
return
}
rec.finishedAt = now
rec.result = result
rec.err = err
if err != nil {
rec.status = TidyAsyncFailed
} else {
rec.status = TidyAsyncDone
}
if r.active == id {
r.active = ""
}
r.sweepLocked(now)
}
// get 查一条记录(顺带做一次淘汰)。未知 runId 返回 ok=false。
func (r *tidyRunRegistry) get(id string, now time.Time) (tidyRunRecord, bool) {
if id == "" {
return tidyRunRecord{}, false
}
r.mu.Lock()
defer r.mu.Unlock()
r.sweepLocked(now)
rec, ok := r.runs[id]
if !ok {
return tidyRunRecord{}, false
}
return *rec, true
}
// sweepLocked 淘汰过期与超量的**已完成**记录。调用方必须持锁。
// running 的记录永不淘汰(否则「跑完却查不到」会变成静默丢结果)。
func (r *tidyRunRegistry) sweepLocked(now time.Time) {
for id, rec := range r.runs {
if rec.status == TidyAsyncRunning {
continue
}
if now.Sub(rec.finishedAt) > r.ttl {
delete(r.runs, id)
}
}
for len(r.runs) > maxTidyRunRecords {
oldestID := ""
var oldest time.Time
for id, rec := range r.runs {
if rec.status == TidyAsyncRunning {
continue
}
if oldestID == "" || rec.finishedAt.Before(oldest) {
oldestID, oldest = id, rec.finishedAt
}
}
if oldestID == "" {
return
}
delete(r.runs, oldestID)
}
}
// StartDryRun 启动一次**异步**试算并立即返回句柄(契约 v0.76 §4.12.1)。它是加性
// 入口:默认的同步路径(DryRun)一字未改。
//
// timeout 是这次运行的墙钟上限(由调用方给,HTTP 层用它与同步请求同一口径的
// 3 分钟);非正时取 defaultTidyAsyncRunTimeout。计算跑在后台 goroutine 上,
// **不绑定任何请求上下文** —— 请求一返回连接就结束了,绑上去会当场取消这次运行。
//
// 它不因配置关闭而拒绝:关闭是显式选择,但那个选择由 DryRun 如实回报
// (ErrTidyDisabled),查询侧会看到 failed + 对应错误码,而不是一份假候选。
func (s *TidyService) StartDryRun(timeout time.Duration) (TidyDryRunHandle, error) {
if timeout <= 0 {
timeout = defaultTidyAsyncRunTimeout
}
rec, fresh := s.runs.start(s.tidier.now())
if fresh {
go s.runDryRunAsync(rec.id, timeout)
}
return TidyDryRunHandle{RunID: rec.id, StartedAt: rec.startedAt}, nil
}
// runDryRunAsync 在后台跑一轮试算并把结果放回注册表。ctx 由 timeout 派生,
// 超时即 ctx 取消 ⇒ 规则路径如实报 DeadlineExceeded ⇒ 状态为 failed。
func (s *TidyService) runDryRunAsync(runID string, timeout time.Duration) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
s.log.Info("memory tidy dry-run async started", "run_id", runID, "timeout_ms", timeout.Milliseconds())
result, err := s.DryRun(ctx)
s.runs.finish(runID, result, err, s.tidier.now())
if err != nil {
s.log.Warn("memory tidy dry-run async failed", "run_id", runID, "error", err)
return
}
s.log.Info("memory tidy dry-run async finished",
"run_id", runID, "candidates", len(result.Candidates), "duration_ms", result.DurationMS)
}
// DryRunResult 查询一次异步试算的状态(契约 v0.76 §4.12.1)。runId 未知
// (从未存在 / 超 TTL / 进程重启过)⇒ ErrTidyRunNotFound,绝不假装还在跑。
func (s *TidyService) DryRunResult(runID string) (TidyAsyncDryRun, error) {
rec, ok := s.runs.get(runID, s.tidier.now())
if !ok {
return TidyAsyncDryRun{}, fmt.Errorf("%w: %s", ErrTidyRunNotFound, runID)
}
out := TidyAsyncDryRun{RunID: rec.id, Status: rec.status, StartedAt: rec.startedAt}
if rec.status == TidyAsyncDone {
result := rec.result
out.Result = &result
}
if rec.status == TidyAsyncFailed {
out.Err = rec.err
}
return out, nil
}
@@ -0,0 +1,311 @@
package memory
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A):
//
// ① **零副作用照旧**:异步跑完后 memory_items / memory_tidy_candidates /
// memory_tidy_runs **逐行不变**(与同步试算同一条硬约束,且用的是既有的
// 逐行快照比对,不是另一套断言);
// ② **结果 = 同步结果**:异步取回的候选与同一批数据上的同步试算逐字段一致;
// ③ **同时只允许 1 个在跑**:第二个请求复用既有 runId,不另起一轮;
// ④ **有界且诚实**:完成后有 TTL(30 分钟)、进程重启后旧 runId 如实失效
// (ErrTidyRunNotFound),未知 runId 同样如此 —— 绝不假装还在跑;
// ⑤ **失败带真实原因**:语义召回不可用 / 配置关闭 / 超时都回失败态 + 原始错误,
// 绝不回一份空候选冒充「没有建议」。
// advClock 是可由测试推进的时钟(并发安全:后台 goroutine 也会读它)。
type advClock struct {
mu sync.Mutex
at time.Time
}
func newAdvClock(at time.Time) *advClock { return &advClock{at: at} }
func (c *advClock) now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.at
}
func (c *advClock) advance(d time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.at = c.at.Add(d)
}
// gateEmbedder 是「卡在召回里不放」的 embedder:它让一轮停在 embedding 调用上,
// 用来验「同时只允许 1 个在跑」。release 关闭后所有调用立即返回单位向量。
type gateEmbedder struct{ release chan struct{} }
func (g gateEmbedder) Embed(ctx context.Context, texts []string) ([]embedding.Vector, error) {
select {
case <-g.release:
case <-ctx.Done():
return nil, ctx.Err()
}
out := make([]embedding.Vector, 0, len(texts))
for range texts {
out = append(out, embedding.Vector{1, 0})
}
return out, nil
}
func (gateEmbedder) Dim() int { return 2 }
// hungEmbedder 模拟「embedding 服务半死不活」:一直等到 ctx 被取消。
type hungEmbedder struct{}
func (hungEmbedder) Embed(ctx context.Context, _ []string) ([]embedding.Vector, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (hungEmbedder) Dim() int { return 2 }
// waitTidyRun 轮询到想要的终态;超时或意外失败即让用例失败。
func waitTidyRun(t *testing.T, svc *TidyService, runID string, want TidyAsyncStatus) TidyAsyncDryRun {
t.Helper()
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
run, err := svc.DryRunResult(runID)
if err != nil {
t.Fatalf("DryRunResult(%s): %v", runID, err)
}
if run.Status == want {
return run
}
if run.Status == TidyAsyncFailed && want != TidyAsyncFailed {
t.Fatalf("运行提前失败:%v", run.Err)
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("等待 %s 超时", want)
return TidyAsyncDryRun{}
}
// TestTidyAsyncDryRun_ZeroSideEffectsAndMatchesSync 是判据 ①②:异步路径**复用**
// 同步试算的单测口径 —— 跑完后三张表逐行不变,且取回的候选与同步试算逐字段一致。
func TestTidyAsyncDryRun_ZeroSideEffectsAndMatchesSync(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
before := snapshotTidyTables(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
if handle.RunID == "" || handle.StartedAt.IsZero() {
t.Fatalf("句柄必须带 runId 与 startedAt:%+v", handle)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncDone)
if run.Result == nil {
t.Fatal("done 必须带 result(形状 = 同步试算的结果)")
}
if len(run.Result.Candidates) == 0 {
t.Fatalf("夹具应当产出候选(否则这条断言是空转):%+v", run.Result.Stats)
}
sync, err := svc.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
got := sortedSignatures(run.Result.Candidates)
want := sortedSignatures(sync.Candidates)
if strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Fatalf("异步候选与同步试算不一致:\n async=%v\n sync =%v", got, want)
}
if run.Result.RecallMode != sync.RecallMode || run.Result.Stats.Compared != sync.Stats.Compared {
t.Fatalf("异步与同步的观测口径不一致:%+v vs %+v", run.Result, sync)
}
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "memory_items", before.items, after.items)
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
}
// TestTidyAsyncDryRun_OnlyOneRunAtATimeReusesRunID 是判据 ③:同时只允许 1 个在跑,
// 第二个请求**复用**既有 runId(契约 §4.12.1 给的「如实拒绝或复用」中选了复用)。
func TestTidyAsyncDryRun_OnlyOneRunAtATimeReusesRunID(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
gate := gateEmbedder{release: make(chan struct{})}
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), gate, time.Now, nil)
first, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
second, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun(第二次): %v", err)
}
if second.RunID != first.RunID {
t.Fatalf("在跑期间第二个请求必须复用 runId(同时只允许 1 个):%q vs %q", second.RunID, first.RunID)
}
if !second.StartedAt.Equal(first.StartedAt) {
t.Fatalf("复用必须是**同一次**运行:startedAt %v vs %v", second.StartedAt, first.StartedAt)
}
run, err := svc.DryRunResult(first.RunID)
if err != nil {
t.Fatalf("DryRunResult: %v", err)
}
if run.Status != TidyAsyncRunning {
t.Fatalf("还没释放闸门就报 %q,want running", run.Status)
}
close(gate.release)
done := waitTidyRun(t, svc, first.RunID, TidyAsyncDone)
if done.Result == nil {
t.Fatal("完成后必须有结果")
}
// 上一轮跑完之后,新请求才是**新的一次**运行(runId 不重复用)。
third, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun(第三次): %v", err)
}
if third.RunID == first.RunID {
t.Fatal("上一轮已结束,新请求必须是一次新运行(runId 必须不同)")
}
waitTidyRun(t, svc, third.RunID, TidyAsyncDone)
}
// TestTidyAsyncDryRun_TTLExpiryIsHonest 是判据 ④(TTL):TTL 内可查,超时后如实
// 查不到,绝不假装还在跑。
func TestTidyAsyncDryRun_TTLExpiryIsHonest(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
clock := newAdvClock(time.Now())
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), clock.now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
waitTidyRun(t, svc, handle.RunID, TidyAsyncDone)
clock.advance(defaultTidyRunTTL - time.Minute)
if _, err := svc.DryRunResult(handle.RunID); err != nil {
t.Fatalf("TTL 内必须还能查到:%v", err)
}
clock.advance(2 * time.Minute)
if _, err := svc.DryRunResult(handle.RunID); !errors.Is(err, ErrTidyRunNotFound) {
t.Fatalf("超 TTL 后必须如实报 ErrTidyRunNotFound,got %v", err)
}
}
// TestTidyAsyncDryRun_RestartDropsRunIDs 是判据 ④(重启语义):结果只活在进程
// 内存里,**同一份存储上新建的实例**(= 进程重启)查不到旧 runId。
func TestTidyAsyncDryRun_RestartDropsRunIDs(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
first := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
handle, err := first.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
waitTidyRun(t, first, handle.RunID, TidyAsyncDone)
restarted := newTidyServiceForTest(t, store, tidyTestCfg(0.80), dryRunFixtureEmbedder(), time.Now, nil)
if _, err := restarted.DryRunResult(handle.RunID); !errors.Is(err, ErrTidyRunNotFound) {
t.Fatalf("重启后旧 runId 必须如实失效(注册表是进程内存态),got %v", err)
}
}
// TestTidyAsyncDryRun_UnknownRunIDIsHonest 钉住「未知 runId」的诚实语义。
func TestTidyAsyncDryRun_UnknownRunIDIsHonest(t *testing.T) {
t.Parallel()
store := newTestStore(t)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, time.Now, nil)
for _, id := range []string{"", "run-nonexistent", "../../etc/passwd"} {
if _, err := svc.DryRunResult(id); !errors.Is(err, ErrTidyRunNotFound) {
t.Fatalf("DryRunResult(%q) err = %v, want ErrTidyRunNotFound", id, err)
}
}
}
// TestTidyAsyncDryRun_FailureCarriesRealCause 是判据 ⑤:语义召回不可用时失败态带
// **真实原因**(ErrTidyRecallUnavailable),且没有 result —— 面板因此不会把
// 「没算出来」读成「算过了,没有建议」。
func TestTidyAsyncDryRun_FailureCarriesRealCause(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
before := snapshotTidyTables(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80),
failingEmbedder{err: errors.New("dial tcp 127.0.0.1:11434: 目标计算机积极拒绝")}, time.Now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncFailed)
if !errors.Is(run.Err, ErrTidyRecallUnavailable) {
t.Fatalf("失败原因 = %v, want ErrTidyRecallUnavailable", run.Err)
}
if run.Result != nil {
t.Fatal("失败态不得带 result(那会把「没算出来」伪装成结果)")
}
after := snapshotTidyTables(t, store)
assertRowsUnchanged(t, "memory_items", before.items, after.items)
assertRowsUnchanged(t, "memory_tidy_candidates", before.candidates, after.candidates)
assertRowsUnchanged(t, "memory_tidy_runs", before.runs, after.runs)
}
// TestTidyAsyncDryRun_DisabledFailsHonestly 钉住「配置关闭」在异步路径上的口径:
// 仍然如实回 runId(不假装排队成功),失败原因就是 ErrTidyDisabled —— 与同步
// 路径同一个错误,网关据此投影成同一张失败码表。
func TestTidyAsyncDryRun_DisabledFailsHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
cfg := tidyTestCfg(0.80)
cfg.Enabled = false
svc := newTidyServiceForTest(t, store, cfg, dryRunFixtureEmbedder(), time.Now, nil)
handle, err := svc.StartDryRun(0)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncFailed)
if !errors.Is(run.Err, ErrTidyDisabled) {
t.Fatalf("关闭时失败原因 = %v, want ErrTidyDisabled", run.Err)
}
}
// TestTidyAsyncDryRun_TimeoutFailsHonestly 钉住墙钟上限:embedding 卡死到超时 ⇒
// 失败态里的错误链含 context.DeadlineExceeded(网关据此投影成 tidy_timeout / 召回
// 不可用,见 gateway 的同一张映射表)。
func TestTidyAsyncDryRun_TimeoutFailsHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedDryRunFixture(t, store)
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), hungEmbedder{}, time.Now, nil)
handle, err := svc.StartDryRun(300 * time.Millisecond)
if err != nil {
t.Fatalf("StartDryRun: %v", err)
}
run := waitTidyRun(t, svc, handle.RunID, TidyAsyncFailed)
if !errors.Is(run.Err, context.DeadlineExceeded) {
t.Fatalf("超时的错误链必须含 context.DeadlineExceeded,got %v", run.Err)
}
}
+336
View File
@@ -0,0 +1,336 @@
package memory
import (
"context"
"fmt"
"log/slog"
"strings"
"sync"
"testing"
"time"
)
// 本文件锁定记忆整理**护栏抬高 + 诚实截断**(切片 MT-A):
//
// ① **护栏抬高后分区规模小于上限就全量比对**:compared == scanned(首轮真跑
// 的 compared(705) < scanned(784) 正是被写死的 compare 上限造成的);
// ② **命中任一上限都留痕**:响应里的 Truncated 带 `<分区>(scan|compare|
// candidates)`,日志有 WARN 且带总数/上限/分区名 —— 截断绝不静默;
// ③ **时间预算闸**:预算用尽时收手(或整分区跳过)并如实标 (budget),
// 而不是让整个请求超时、什么结果都拿不到。
// capturingHandler 把 slog 记录留下来,供「截断必须留下 WARN」这类断言使用。
type capturingHandler struct {
mu sync.Mutex
records []slog.Record
}
func (h *capturingHandler) Enabled(context.Context, slog.Level) bool { return true }
func (h *capturingHandler) Handle(_ context.Context, r slog.Record) error {
h.mu.Lock()
defer h.mu.Unlock()
h.records = append(h.records, r.Clone())
return nil
}
func (h *capturingHandler) WithAttrs([]slog.Attr) slog.Handler { return h }
func (h *capturingHandler) WithGroup(string) slog.Handler { return h }
// attrsOf 把一条记录的属性摊平成 map(测试断言用)。
func attrsOf(r slog.Record) map[string]string {
out := map[string]string{}
r.Attrs(func(a slog.Attr) bool {
out[a.Key] = a.Value.String()
return true
})
return out
}
// warnRecords 返回所有 WARN 级别的记录(消息 + 属性)。
func (h *capturingHandler) warnRecords() []slog.Record {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]slog.Record, 0, len(h.records))
for _, r := range h.records {
if r.Level == slog.LevelWarn {
out = append(out, r)
}
}
return out
}
// seedManyHabitItems 在同一条分区里落 n 条互不相同的记忆(内容都不含绝对
// 时间戳,因此都进比对而不是归档)。
func seedManyHabitItems(t *testing.T, store *Store, n int) []MemoryItem {
t.Helper()
base := tidyTestTime(t)
out := make([]MemoryItem, 0, n)
for i := 0; i < n; i++ {
content := fmt.Sprintf("%s,第 %d 次记录", fixtureHabitShort, i)
out = append(out, seedTidyItem(t, store, habitItem(content, base.Add(time.Duration(i)*time.Minute), 0.9)))
}
return out
}
// TestTidyCompareLimit_RaisesToDefaultAndComparesEverything 是判据 ①:默认护栏
// (compare 2000 / candidates 200)下,分区规模小于上限时 **compared == scanned**
// 且**没有任何截断痕迹** —— 这正是首轮真跑做不到的那件事。
func TestTidyCompareLimit_RaisesToDefaultAndComparesEverything(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedManyHabitItems(t, store, 40)
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
if got := tr.Config(); got.CompareLimit != 2000 || got.CandidateLimit != 200 {
t.Fatalf("护栏默认值 = %d/%d, want 2000/200(compare/candidates)", got.CompareLimit, got.CandidateLimit)
}
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if out.Stats.Scanned != 40 || out.Stats.Compared != out.Stats.Scanned {
t.Fatalf("scanned/compared = %d/%d, want 40/40(规模小于上限就全量比对)",
out.Stats.Scanned, out.Stats.Compared)
}
if len(out.Stats.Truncated) != 0 {
t.Fatalf("上限没被打满,不该有截断痕迹:%+v", out.Stats.Truncated)
}
}
// TestTidyCompareLimit_HitIsLoud 是判据 ②:compare 上限命中时 Compared 被截、
// Truncated 带 (compare)、WARN 带总数/上限/分区名。
func TestTidyCompareLimit_HitIsLoud(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedManyHabitItems(t, store, 6)
capture := &capturingHandler{}
cfg := tidyTestCfg(0.80)
cfg.CompareLimit = 3
tr, err := NewTidier(cfg, TidyDeps{Store: store, Log: slog.New(capture)})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if out.Stats.Scanned != 6 || out.Stats.Compared != 3 {
t.Fatalf("scanned/compared = %d/%d, want 6/3(上限必须生效)", out.Stats.Scanned, out.Stats.Compared)
}
want := "persona.a/persona/(compare)"
if !containsString(out.Stats.Truncated, want) {
t.Fatalf("截断痕迹 = %v, want 含 %q", out.Stats.Truncated, want)
}
if !hasWarn(capture, "memory tidy compare truncated by per-iteration guard", map[string]string{
"partition": "persona.a/persona/", "items": "6", "limit": "3",
}) {
t.Fatalf("compare 上限命中必须有带总数/上限/分区名的 WARN:%+v", capture.warnRecords())
}
}
// TestTidyCandidateLimit_HitIsLoud 是判据 ②(候选上限):此前候选上限命中只写
// stats,**没有 WARN** —— 面板看到的候选少于规则判出的,日志里却看不到任何线索。
func TestTidyCandidateLimit_HitIsLoud(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedDryRunFixture(t, store)
capture := &capturingHandler{}
cfg := tidyTestCfg(0.80)
cfg.CandidateLimit = 1
tr, err := NewTidier(cfg, TidyDeps{Store: store, Embedder: dryRunFixtureEmbedder(), Log: slog.New(capture)})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if len(out.Candidates) != 1 || out.Stats.Skipped != 1 {
t.Fatalf("候选上限未生效:candidates=%d skipped=%d", len(out.Candidates), out.Stats.Skipped)
}
if !containsString(out.Stats.Truncated, "persona.a/persona/(candidates)") {
t.Fatalf("截断痕迹 = %v, want 含 (candidates)", out.Stats.Truncated)
}
if !hasWarn(capture, "memory tidy candidates truncated by per-partition limit", map[string]string{
"partition": "persona.a/persona/", "candidates": "2", "limit": "1",
}) {
t.Fatalf("候选上限命中必须有 WARN(带总数/上限/分区名):%+v", capture.warnRecords())
}
}
// TestTidyScanLimit_WarnCarriesTrueTotal 是判据 ②(扫描上限):WARN 里的 total
// 必须是**真总数**(另查 COUNT),而不是被 LIMIT 截出来的下限 —— 否则「至少 5000」
// 会被写成「正好 5000」。
func TestTidyScanLimit_WarnCarriesTrueTotal(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
seedManyHabitItems(t, store, 5)
capture := &capturingHandler{}
cfg := tidyTestCfg(0.80)
cfg.ScanLimit = 2
tr, err := NewTidier(cfg, TidyDeps{Store: store, Log: slog.New(capture)})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
out, err := tr.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if out.Stats.Scanned != 2 {
t.Fatalf("scanned = %d, want 2(扫描上限)", out.Stats.Scanned)
}
if !containsString(out.Stats.Truncated, "persona.a/persona/(scan)") {
t.Fatalf("截断痕迹 = %v, want 含 (scan)", out.Stats.Truncated)
}
if !hasWarn(capture, "memory tidy partition hit the scan limit", map[string]string{
"partition": "persona.a/persona/", "limit": "2", "total": "5",
}) {
t.Fatalf("扫描上限的 WARN 必须带真总数:%+v", capture.warnRecords())
}
}
// TestTidyDryRun_BudgetExhaustedTruncatesHonestly 是判据 ③:时间预算用尽时分区的
// 结果是**如实截断**(scanned=0 + truncated 带 (budget)),而不是一个空候选数组
// 冒充「这个分区没有建议」,也不是让整个请求超时。
func TestTidyDryRun_BudgetExhaustedTruncatesHonestly(t *testing.T) {
t.Parallel()
store := newTestStore(t)
seedManyHabitItems(t, store, 3)
// 时钟推到墙钟上限之后 ⇒ 预算判为用尽(而 ctx 本身还没到期,规则路径照跑)。
clock := newAdvClock(time.Now().Add(time.Hour))
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
out, err := svc.DryRun(ctx)
if err != nil {
t.Fatalf("DryRun: %v", err)
}
if out.Stats.Scanned != 0 || len(out.Candidates) != 0 {
t.Fatalf("预算用尽的分区不该被扫描:%+v", out.Stats)
}
if !containsString(out.Stats.Truncated, "persona.a/persona/(budget)") {
t.Fatalf("预算截断必须如实登记(否则「没扫」会被读成「没有候选」):%+v", out.Stats.Truncated)
}
}
// TestCollectTidyPairs_BudgetCutsLoopHonestly 是判据 ③(循环内闸):两两比对进行中
// 预算用尽 ⇒ **收手返回已找到的对**(真结果,不是编的)并报告 cut,绝不静默少算。
func TestCollectTidyPairs_BudgetCutsLoopHonestly(t *testing.T) {
t.Parallel()
items := make([]MemoryItem, 100) // 100 条 ⇒ 4950 对 > 4096 的检查间隔
for i := range items {
items[i] = MemoryItem{ID: fmt.Sprintf("mem-%03d", i), Content: fmt.Sprintf("内容 %d", i)}
}
alwaysSim := func(int, int) float64 { return 0.95 }
expired := tidyBudget{
deadline: time.Now().Add(-time.Hour),
margin: 0,
enabled: true,
now: time.Now,
}
var cutStats TidyStats
pairs, cut := collectTidyPairs(items, 0.0, alwaysSim, tidyRecallLexical, &cutStats, expired)
if !cut {
t.Fatal("预算用尽时两两比对必须收手(否则一轮可能烧掉几分钟墙钟)")
}
if len(pairs) == 0 {
t.Fatal("收手前已找到的对是真结果,必须照常返回")
}
if cutStats.Pairs != len(pairs) {
t.Fatalf("Pairs 计数 = %d, want %d(计的就是返回的真结果)", cutStats.Pairs, len(pairs))
}
var fullStats TidyStats
all, cut := collectTidyPairs(items, 0.0, alwaysSim, tidyRecallLexical, &fullStats, tidyBudget{})
if cut {
t.Fatal("没有预算时不该报告 cut")
}
if len(all) != 100*99/2 {
t.Fatalf("无预算时必须全量比对:pairs = %d, want %d", len(all), 100*99/2)
}
}
// TestTidierCollectPairs_NotesBudgetCutAsPartition 钉住整理侧对「循环内截断」的
// 诚实登记:分区标签进 stats.Truncated,并留一条 WARN。
func TestTidierCollectPairs_NotesBudgetCutAsPartition(t *testing.T) {
t.Parallel()
store := newTestStore(t)
capture := &capturingHandler{}
tr, err := NewTidier(tidyTestCfg(0.80), TidyDeps{Store: store, Log: slog.New(capture)})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
items := make([]MemoryItem, 100)
for i := range items {
items[i] = MemoryItem{ID: fmt.Sprintf("mem-%03d", i)}
}
var stats TidyStats
p := tidyPartition{personaID: "persona.a", scope: ScopePersona}
pairs := tr.collectPairs(p, items, 0.0, func(int, int) float64 { return 0.9 },
tidyRecallLexical, &stats, tidyBudget{
deadline: time.Now().Add(-time.Hour), enabled: true, now: time.Now,
})
if len(pairs) == 0 {
t.Fatal("夹具无效:应当已找到若干对")
}
if !containsString(stats.Truncated, "persona.a/persona/(budget)") {
t.Fatalf("截断痕迹 = %v, want 含 (budget)", stats.Truncated)
}
if len(capture.warnRecords()) == 0 {
t.Fatal("时间预算截断必须留下 WARN(绝不静默)")
}
}
// hasWarn 报告是否有一条 WARN 记录消息匹配且属性包含 want。
func hasWarn(h *capturingHandler, msg string, want map[string]string) bool {
for _, r := range h.warnRecords() {
if r.Message != msg {
continue
}
attrs := attrsOf(r)
ok := true
for k, v := range want {
if attrs[k] != v {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
// containsString 报告切片里是否有某个字符串(精确匹配)。
func containsString(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}
// TestTidyTruncatedLabelsAreDistinguishable 钉住「响应能不能区分『没有更多候选』
// 与『被上限截断』」:没有截断时 Truncated 为空,命中时带**原因后缀**,
// 两种截断原因(配额 vs 预算)标签不同,运维/面板据此知道该调什么。
func TestTidyTruncatedLabelsAreDistinguishable(t *testing.T) {
t.Parallel()
if strings.TrimSpace(budgetTruncationLabel(tidyPartition{personaID: "p", scope: ScopePersona})) == "" {
t.Fatal("预算截断必须有自己的标签")
}
got := budgetTruncationLabel(tidyPartition{personaID: "p", scope: ScopePersona})
if got == "p/persona/(compare)" || got == "p/persona/(scan)" || got == "p/persona/(candidates)" {
t.Fatalf("预算截断不得复用配额截断的标签(原因不同,运维动作也不同):%q", got)
}
}
+45 -9
View File
@@ -42,12 +42,13 @@ type tidyPair struct {
//
// 返回的对按「分数降序 + id」确定性排序,使每分区候选上限的截断可复现
// (同一批数据两次运行产出同一批候选 ⇒ 幂等,design §4.1 A)。
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats) ([]tidyPair, error) {
func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
if len(items) < 2 {
return nil, nil
}
if t.deps.Embedder == nil {
return collectTidyPairs(items, t.cfg.RecallThreshold, lexicalSimFunc(items), tidyRecallLexical, stats), nil
return t.collectPairs(p, items, t.cfg.RecallThreshold,
lexicalSimFunc(items), tidyRecallLexical, stats, budget), nil
}
vecs, err := t.embedItems(ctx, items)
if err != nil {
@@ -56,22 +57,24 @@ func (t *Tidier) recallPairs(ctx context.Context, p tidyPartition, items []Memor
if len(vecs) != len(items) {
return nil, ErrTidyEmbedCount
}
return collectTidyPairs(items, t.cfg.RecallThreshold, vectorSimFunc(vecs), tidyRecallEmbedding, stats), nil
return t.collectPairs(p, items, t.cfg.RecallThreshold,
vectorSimFunc(vecs), tidyRecallEmbedding, stats, budget), nil
}
// limitCompareItems 把参与比对的条目截断到性能护栏并计入 Compared。
// 两两比对是 O(n²·dim):设计稿每分区扫描上限 5000 条(design §9),但真正参与
// 比对的最多 maxTidyCompareItems 条(最新优先),超限记 WARN 并登记到
// stats.Truncated —— 截断绝不静默。空/单条分区不计数(本来就没有对可比)。
// 比对的最多 cfg.CompareLimit 条(最新优先,默认 2000,MEMORY_TIDY_COMPARE_LIMIT),
// 超限记 WARN 并登记到 stats.Truncated —— 截断绝不静默。
// 空/单条分区不计数(本来就没有对可比)。
func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *TidyStats) []MemoryItem {
if len(items) < 2 {
return items
}
if len(items) > maxTidyCompareItems {
if len(items) > t.cfg.CompareLimit {
stats.Truncated = append(stats.Truncated, p.label()+"(compare)")
t.log.Warn("memory tidy compare truncated by per-iteration guard",
"partition", p.label(), "items", len(items), "limit", maxTidyCompareItems)
items = items[:maxTidyCompareItems]
"partition", p.label(), "items", len(items), "limit", t.cfg.CompareLimit)
items = items[:t.cfg.CompareLimit]
}
stats.Compared += len(items)
return items
@@ -79,10 +82,29 @@ func (t *Tidier) limitCompareItems(p tidyPartition, items []MemoryItem, stats *T
// collectTidyPairs 是召回内核:把相似度 ≥ 阈值的对收集起来,按分数降序
// (再按 id)确定性排序 —— 排序让「每分区候选上限」的截断可复现。
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64, mode string, stats *TidyStats) []tidyPair {
//
// 它是自由函数(入库前审查也复用同一个内核,design/23 §15 D3-3「不许另造一套
// 相似度实现」),因此**不认识分区与日志器**:cut=true 表示循环被时间预算提前
// 收手,由调用方决定怎么如实登记(整理侧见 Tidier.collectPairs)。
// budget 为零值(无 deadline)时行为与从前逐字一致。
//
// budget 用尽时已找到的对是**真结果**(不是编的),照常返回 —— 少算了多少由
// 调用方如实标记,绝不静默。
func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int) float64,
mode string, stats *TidyStats, budget tidyBudget) ([]tidyPair, bool) {
pairs := make([]tidyPair, 0)
ticks := 0
cut := false
pairLoop:
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if ticks++; ticks >= tidyBudgetCheckEveryPairs {
ticks = 0
if budget.exhausted() {
cut = true
break pairLoop
}
}
score := sim(i, j)
if score < threshold {
continue
@@ -100,6 +122,20 @@ func collectTidyPairs(items []MemoryItem, threshold float64, sim func(i, j int)
}
return pairs[i].b.ID < pairs[j].b.ID
})
return pairs, cut
}
// collectPairs 是整理侧对内核对调用:它在内核之外补上「预算截断」的**诚实登记**
// (分区标签进响应的 truncated 数组 + 一条 WARN)。截断绝不静默是这一层的职责,
// 因此内核保持纯净(preinsert 审查不需要这套标签)。
func (t *Tidier) collectPairs(p tidyPartition, items []MemoryItem, threshold float64,
sim func(i, j int) float64, mode string, stats *TidyStats, budget tidyBudget) []tidyPair {
pairs, cut := collectTidyPairs(items, threshold, sim, mode, stats, budget)
if cut {
stats.Truncated = append(stats.Truncated, budgetTruncationLabel(p))
t.log.Warn("memory tidy pair scan cut by time budget",
"partition", p.label(), "items", len(items), "pairs", len(pairs))
}
return pairs
}
+6
View File
@@ -45,6 +45,10 @@ type TidyService struct {
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
// 若撞在一起,后者排队而不是并发改写候选表。
runMu sync.Mutex
// runs 是**异步试算**结果的进程内注册表(契约 v0.76 §4.12.1;见
// tidy_dryrun_async.go):不落库、不新开端口、重启即空。
runs *tidyRunRegistry
}
// NewTidyService 由 cfg 与 deps 构建整理服务。Store 为 nil 时返回 ErrNilStore。
@@ -57,6 +61,7 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
tidier: tidier,
cfg: tidier.Config(),
log: tidier.log,
runs: newTidyRunRegistry(defaultTidyRunTTL),
}, nil
}
@@ -95,6 +100,7 @@ func (s *TidyService) Start(ctx context.Context) {
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour),
"recall_threshold", s.cfg.RecallThreshold,
"scan_limit", s.cfg.ScanLimit,
"compare_limit", s.cfg.CompareLimit,
"candidate_limit", s.cfg.CandidateLimit,
"rule_version", TidyRuleVersion,
"session_id", TidySessionID)
+15
View File
@@ -49,6 +49,9 @@ const listTidyPartitionsSQL = `SELECT DISTINCT persona_id, scope, project_id
FROM memory_items WHERE status = ?
ORDER BY persona_id, scope, project_id`
const countTidyPartitionItemsSQL = `SELECT COUNT(*) FROM memory_items
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?`
const insertTidyCandidateSQL = `INSERT OR IGNORE INTO memory_tidy_candidates
(id, persona_id, scope, project_id, kind, action, source_ids, target_id,
proposed_content, merged_content, reason, evidence, rule_version,
@@ -106,6 +109,18 @@ func (s *Store) listTidyPartitionItems(ctx context.Context, p tidyPartition, lim
return scanItems(rows, "tidy partition item")
}
// countTidyPartitionItems 数一个分区里在册条目的**真总数**。只在扫描上限命中时
// 调用一次:日志里的「总数」必须是真数,而不是被 LIMIT 截出来的 len(items)
// (那是**下限**,把「至少 5000」写成「正好 5000」就是一句谎)。
func (s *Store) countTidyPartitionItems(ctx context.Context, p tidyPartition) (int, error) {
var total int
if err := s.db.QueryRowContext(ctx, countTidyPartitionItemsSQL,
p.personaID, string(p.scope), p.projectID, string(tidyStatusActive)).Scan(&total); err != nil {
return 0, fmt.Errorf("memory: count tidy partition items: %w", err)
}
return total, nil
}
// insertTidyCandidate 落库一条候选,返回它是否是**新建**的。幂等键
// (design/23 §9)唯一:同一批数据重复运行命中已存在行时返回 (false, nil),
// 绝不重复产候选。
+5 -3
View File
@@ -178,7 +178,8 @@ func TestTidyOfflineLexicalRecall(t *testing.T) {
}
// TestTidyConfigDefaults 锁定「设计稿默认值只有一处定义」:召回门 0.80、
// 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 20(design §9/§13)。
// 每天 1 次、安静时段 23:00-07:00、每分区扫描 5000 / 候选 200 / 比对 2000
// (design §9/§13;后两个是切片 MT-A 抬高的护栏,见 tidy_config.go 的注释)。
func TestTidyConfigDefaults(t *testing.T) {
t.Parallel()
cfg := normalizeTidyConfig(TidyConfig{Enabled: true})
@@ -188,8 +189,9 @@ func TestTidyConfigDefaults(t *testing.T) {
if cfg.RecallThreshold != 0.80 {
t.Fatalf("召回门默认 = %v, want 0.80", cfg.RecallThreshold)
}
if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 20 {
t.Fatalf("配额默认 = %d/%d, want 5000/20", cfg.ScanLimit, cfg.CandidateLimit)
if cfg.ScanLimit != 5000 || cfg.CandidateLimit != 200 || cfg.CompareLimit != 2000 {
t.Fatalf("配额默认 = %d/%d/%d, want 5000/200/2000(scan/candidate/compare)",
cfg.ScanLimit, cfg.CandidateLimit, cfg.CompareLimit)
}
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("安静时段默认 = %d-%d, want 23-7", cfg.QuietStartHour, cfg.QuietEndHour)
@@ -8,7 +8,7 @@ import (
)
// 本文件承载 P3「配置只读事实面」:把面板此前**看不见、只能照模板猜**的一组调参
// 键做成只读事实展示(记忆整理 7 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
// 键做成只读事实展示(记忆整理 8 键 + 安静时段 2 键 + 后台思考心跳 3 键)。三条边界
// (AGENTS §10 与主人红线):
//
// 1. **键名单的权威来源是 `.env.example`**(不是 `.env`、也不是本文件自己发明
@@ -20,7 +20,7 @@ import (
// 面板看到的是运行实例真正在用的数,而不是 `.env` 原文。每条事实另带**声明
// 默认值**(从 `default:"…"` 标签反射读出)与 `isDefault`,于是「这项还是默认
// 值」与「这项被改过」都能如实显示,不必假装知道 `.env` 里写过什么。
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 12 个非敏感调参键,响应里连
// 3. **绝不回任何密钥 / 令牌值**:本端点只覆盖上述 13 个非敏感调参键,响应里连
// 凭据类键的「是否已配置」都不出现。
// `TestAdminConfigFacts_NeverLeaksSecretValues` 用哨兵值把这条钉死。
//
@@ -78,6 +78,7 @@ type configFactSpec struct {
// configFactSpecs 是事实表的**唯一真源**:顺序即面板显示顺序(先记忆整理,
// 后安静时段与后台思考心跳)。键名必须与 `.env.example` 逐字一致(测试钉住)。
// 条数:记忆整理 8 + 后台思考心跳 3 + 安静时段 2 = 13。
func configFactSpecs() []configFactSpec {
return []configFactSpec{
{"MEMORY_TIDY_ENABLED", func(c *config.Config) string {
@@ -101,6 +102,12 @@ func configFactSpecs() []configFactSpec {
{"MEMORY_TIDY_CANDIDATE_LIMIT", func(c *config.Config) string {
return strconv.Itoa(c.MemoryTidy.CandidateLimit)
}},
// 两两比对的性能护栏(切片 MT-A,2026-10-06):此前是写死的常量 400,
// 459 条的分区被截到 400 ⇒ compared < scanned。开放成 env 键后,面板能看到
// 「这项是不是还是默认值」,运维放大配额也不必改代码。
{"MEMORY_TIDY_COMPARE_LIMIT", func(c *config.Config) string {
return strconv.Itoa(c.MemoryTidy.CompareLimit)
}},
// 后台思考心跳(design/12 §N A4):安静窗口只降低周期性心跳的频率,
// 而 env 是**启动期**读的 ⇒ 面板照实列出「生效值 + 声明默认」并写明改需重启。
{"ENABLE_BACKGROUND_THINKING", func(c *config.Config) string {
@@ -108,7 +108,7 @@ func TestAdminConfigFacts_QuietHoursAreProcessEffective(t *testing.T) {
// TestAdminConfigFacts_BackthinkHeartbeatKeys 钉住 design/12 §N A4 新增的三条
// 后台思考心跳事实(`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` /
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 12。键名与
// `THINK_QUIET_INTERVAL_SEC`):值取**进程生效值**、总条数确实是 13。键名与
// `.env.example` 逐字一致那一半由 TestAdminConfigFacts_KeysMatchEnvExample 钉。
func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
t.Parallel()
@@ -117,8 +117,8 @@ func TestAdminConfigFacts_BackthinkHeartbeatKeys(t *testing.T) {
cfg.Background.IntervalSec = 7200
cfg.Background.QuietIntervalSec = 0 // ≤0 = 关闭降频;事实面必须如实报 0
_, _, facts := getConfigFacts(t, cfg, "")
if len(facts.Facts) != 12 {
t.Fatalf("facts = %d, want 12(记忆整理 7 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
if len(facts.Facts) != 13 {
t.Fatalf("facts = %d, want 13(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)", len(facts.Facts))
}
for key, want := range map[string]string{
"ENABLE_BACKGROUND_THINKING": "false",
@@ -147,7 +147,8 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
"MEMORY_TIDY_QUIET_END_HOUR": "7",
"MEMORY_TIDY_RECALL_THRESHOLD": "0.8",
"MEMORY_TIDY_SCAN_LIMIT": "5000",
"MEMORY_TIDY_CANDIDATE_LIMIT": "20",
"MEMORY_TIDY_CANDIDATE_LIMIT": "200",
"MEMORY_TIDY_COMPARE_LIMIT": "2000",
"ENABLE_BACKGROUND_THINKING": "true",
"THINK_INTERVAL_SEC": "900",
"THINK_QUIET_INTERVAL_SEC": "3600",
@@ -157,7 +158,7 @@ func TestAdminConfigFacts_DeclaredDefaultsAndDefaultFlag(t *testing.T) {
defaultCfg := &config.Config{
MemoryTidy: config.MemoryTidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 20,
RecallThreshold: 0.8, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
},
Background: config.BackgroundConfig{
Enabled: true, IntervalSec: 900, QuietIntervalSec: 3600,
@@ -309,7 +310,7 @@ func TestAdminConfigFacts_NeverLeaksSecretValues(t *testing.T) {
}
// TestAdminConfigFacts_KeysMatchEnvExample 是判据 ④:键名单的权威来源是
// `.env.example` —— 12 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// `.env.example` —— 13 个键必须逐字出现在模板里(键名写错一个字母,用户照面板
// 抄去改就是静默不生效,AGENTS §10 的 E6 固定动作)。
func TestAdminConfigFacts_KeysMatchEnvExample(t *testing.T) {
t.Parallel()
+25 -27
View File
@@ -2,7 +2,6 @@ package gateway
import (
"context"
"errors"
"net/http"
"time"
@@ -41,9 +40,18 @@ const tidyDryRunTimeout = 3 * time.Minute
// MemoryTidyDryRunner 是记忆整理「试算」的注入出口(契约 v0.75;切片 MT1)。
// 组合根注入的就是低峰心跳跑的那个 `*memory.TidyService` —— 同一套规则、同一份
// 配置,因此试算给出的建议与今晚 23:00 那一轮是同一口径。
//
// 契约 v0.76(切片 MT-A)在同一出口上加了**加性**的两个方法(异步试算的启动与
// 查询):它们不改变 DryRun 的语义,只是把「跑很久的那一轮」挪到后台。
type MemoryTidyDryRunner interface {
// DryRun 跑一轮整理规则并返回候选,零副作用。
DryRun(ctx context.Context) (memory.TidyDryRun, error)
// StartDryRun 启动一次异步试算并立即返回句柄(契约 v0.76 §4.12.1)。
// timeout 是这次运行的墙钟上限(非正取实现侧的兜底值)。
StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error)
// DryRunResult 查询一次异步试算的状态;runId 未知(含进程重启后)返回
// memory.ErrTidyRunNotFound。
DryRunResult(runID string) (memory.TidyAsyncDryRun, error)
}
// AdminTidyDryRunCandidate 是试算响应里的一条候选(契约 v0.75 §4.12)。
@@ -97,7 +105,15 @@ type AdminTidyDryRunResponse struct {
// adminMemoryTidyDryRun 是「试算整理建议」的处理函数。它只读语义、不审计
// (见文件头注释第 4 条),失败一律如实作答。
//
// 带 `?async=1` 时改走**加性异步**(契约 v0.76 §4.12.1,见
// admin_memory_tidy_async.go):立刻回 202 + runId,结果由 B15 轮询取回。
// 不带该参数时这条函数的行为**逐字不变** —— 老客户端(含现有面板)零影响。
func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get(tidyAsyncQueryParam) == tidyAsyncTriggerValue {
s.adminMemoryTidyDryRunAsync(w, r)
return
}
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
@@ -113,34 +129,16 @@ func (s *Server) adminMemoryTidyDryRun(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, adminTidyDryRunResponse(result))
}
// writeTidyDryRunError 把试算失败映射成**诚实**的错误码。用户可见的 message
// 只说明「哪一类原因」,原始错误(可能含内部地址/凭据片段)只进日志。
// writeTidyDryRunError 把试算失败映射成**诚实**的 HTTP 错误响应。映射表在
// tidyDryRunAppError(同步与异步共用的唯一映射点,见 admin_memory_tidy_async.go)。
func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, memory.ErrTidyDisabled):
writeError(w, &apperr.AppError{
Code: "tidy_disabled",
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false),试算同样不上报候选",
HTTPCode: http.StatusConflict,
})
case errors.Is(err, memory.ErrTidyRecallUnavailable):
s.log.Error("memory tidy dry-run recall unavailable", "err", err)
writeError(w, &apperr.AppError{
Code: "tidy_recall_unavailable",
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
HTTPCode: http.StatusInternalServerError,
})
case errors.Is(err, context.DeadlineExceeded):
s.log.Error("memory tidy dry-run timed out", "err", err)
writeError(w, &apperr.AppError{
Code: "tidy_timeout",
Message: "记忆整理试算超时(未产出候选)",
HTTPCode: http.StatusServiceUnavailable,
})
default:
s.log.Error("memory tidy dry-run failed", "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败"))
failed := tidyDryRunAppError(err)
// 配置关闭是**显式选择**,不算故障:与既有口径一致,不为它记 ERROR 日志。
// 其余失败(召回不可用 / 超时 / 内部错)都要在日志里留原始错误。
if failed.Code != "tidy_disabled" {
s.log.Error("memory tidy dry-run failed", "code", failed.Code, "err", err)
}
writeError(w, failed)
}
// adminTidyDryRunResponse 把领域结果投影成线上结构。它**不复制**任何阈值/上限:
@@ -0,0 +1,162 @@
package gateway
import (
"context"
"errors"
"net/http"
"time"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A)。
//
// 为什么有它:真机一轮试算是 **89.9 秒 / 744 条**(同量级的那轮 784 条更慢),
// 而抬高护栏(compare 400→2000 / candidate 20→200)只会让它更慢 —— 同步 HTTP
// 会被反代或客户端超时切断。异步把「跑」与「取」拆开:`?async=1` 立刻回
// `202 + runId`,客户端轮询 B15 取结果。
//
// 五条不可走样的口径:
//
// 1. **加性**:不带 `async` 时走的是**原封不动**的同步路径(B14)—— 老客户端
// 逐字不变。异步是另一条入口,不是替换。
// 2. **结果形状只有一份**:`done` 时的 `result` 就是 §4.12 同步响应的 `data`
// (同一个 `adminTidyDryRunResponse` 投影函数),绝不另造第二份 JSON。
// 3. **运行活在进程内存**(见 memory.TidyService.StartDryRun):不落库、不新开
// 端口;TTL 30 分钟、同时只允许 1 个在跑(第二个请求复用既有 runId);
// **进程重启后旧 runId 如实失效** ⇒ `404 tidy_run_not_found`,绝不假装还在跑。
// 4. **失败码沿用 §4.12**:`failed` 状态的 `error.code` 与同步路径同一张表
// (tidyDryRunAppError 是**唯一**映射点),message 同样只说明原因类别,
// 原始内部错误只进日志。
// 5. **只算不写 ⇒ 不审计**:与 B14 同门同口径,也不进 owner-only 的写路径。
// tidyAsyncQueryParam 是触发异步试算的查询参数名(契约 v0.76 §4.12.1)。
// 只有字面量 "1" 触发异步:`?async=0` / 缺省 / 其它值一律走同步(老客户端语义)。
const tidyAsyncQueryParam = "async"
// tidyAsyncTriggerValue 是触发异步的字面量(契约登记的就是 `?async=1`)。
const tidyAsyncTriggerValue = "1"
// AdminTidyDryRunAccepted 是 `POST …/tidy/dry-run?async=1` 的 202 响应
// (契约 v0.76 §4.12.1)。`runId` 是**不透明**的进程内标识(客户端只用它轮询,
// 不要解析其结构);`startedAt` 是这次运行真正开始的墙钟时刻(UTC RFC3339)——
// 复用既有运行时,它是那次运行的时间,不是本次请求的时间。
type AdminTidyDryRunAccepted struct {
RunID string `json:"runId"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
}
// AdminTidyRunError 是 `failed` 状态的诚实失败原因:`code` 沿用 §4.12 的失败码表。
type AdminTidyRunError struct {
Code string `json:"code"`
Message string `json:"message"`
}
// AdminTidyDryRunStatusResponse 是 B15 轮询响应(契约 v0.76 §4.12.1):
// `{status, result?, error?}`,`done` 时 `result` 与 §4.12 同步响应的 `data`
// **逐键同形**(含 `candidates` 恒非 null)。
type AdminTidyDryRunStatusResponse struct {
Status string `json:"status"`
// Result 只在 status == "done" 时出现;形状 = §4.12 的 data。
Result *AdminTidyDryRunResponse `json:"result,omitempty"`
// Error 只在 status == "failed" 时出现。
Error *AdminTidyRunError `json:"error,omitempty"`
}
// adminMemoryTidyDryRunStatus 是 B15 的处理函数:查询一次异步试算的状态。
// runId 未知(从未存在 / 超 TTL / 进程重启过)⇒ `404 tidy_run_not_found`。
func (s *Server) adminMemoryTidyDryRunStatus(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
}
runID := r.PathValue("runId")
run, err := s.memoryTidy.DryRunResult(runID)
if err != nil {
if errors.Is(err, memory.ErrTidyRunNotFound) {
// 如实说「查不到」:进程内存态不跨重启,旧 runId 就是不存在。
writeError(w, &apperr.AppError{
Code: "tidy_run_not_found",
Message: "该试算运行不存在或已过期(结果只活在进程内存:TTL 30 分钟,进程重启即失效)",
HTTPCode: http.StatusNotFound,
})
return
}
s.log.Error("memory tidy dry-run status failed", "run_id", runID, "err", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算状态查询失败"))
return
}
writeJSON(w, http.StatusOK, adminTidyDryRunStatusResponse(run))
}
// adminMemoryTidyDryRunAsync 是 `?async=1` 的处理函数:启动一次后台试算并立刻回
// 202(契约 v0.76 §4.12.1)。**不读 body**(与同步路径同:端点自己不带旋钮)。
//
// 它只做两件事:登记运行 + 回 runId。真正的计算在 memory.TidyService 的后台
// goroutine 上跑(它不绑定本请求的上下文 —— 请求一返回连接就结束了,绑上去会
// 当场取消这次运行)。
func (s *Server) adminMemoryTidyDryRunAsync(w http.ResponseWriter, r *http.Request) {
if s.memoryTidy == nil {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_unavailable", "记忆整理服务未接线"))
return
}
handle, err := s.memoryTidy.StartDryRun(tidyDryRunTimeout)
if err != nil {
// 同步的诚实口径照旧:连「排队」都失败时不回 202 —— 那会是一句空承诺。
s.writeTidyDryRunError(w, err)
return
}
s.log.Info("memory tidy dry-run accepted",
"run_id", handle.RunID, "started_at", handle.StartedAt.UTC().Format(time.RFC3339))
writeJSON(w, http.StatusAccepted, AdminTidyDryRunAccepted{
RunID: handle.RunID,
Status: string(memory.TidyAsyncRunning),
StartedAt: handle.StartedAt.UTC().Format(time.RFC3339),
})
}
// adminTidyDryRunStatusResponse 把领域侧的异步状态投影成线上结构。`running` 时
// 两个载荷键都缺席(**不能**回一个空 result 冒充「跑完了但没有建议」)。
func adminTidyDryRunStatusResponse(run memory.TidyAsyncDryRun) AdminTidyDryRunStatusResponse {
out := AdminTidyDryRunStatusResponse{Status: string(run.Status)}
if run.Result != nil {
projected := adminTidyDryRunResponse(*run.Result)
out.Result = &projected
}
if run.Err != nil {
failed := tidyDryRunAppError(run.Err)
out.Error = &AdminTidyRunError{Code: failed.Code, Message: failed.Message}
}
return out
}
// tidyDryRunAppError 把试算失败映射成**诚实**的错误码,是同步(HTTP 状态码)与
// 异步(failed 状态的 code/message)**唯一**的映射点 —— 两条路径的失败口径因此
// 不可能漂移。用户可见的 message 只说明「哪一类原因」,原始错误(可能含内部地址
// 或凭据片段)只进日志。
func tidyDryRunAppError(err error) *apperr.AppError {
switch {
case errors.Is(err, memory.ErrTidyDisabled):
return &apperr.AppError{
Code: "tidy_disabled",
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false),试算同样不上报候选",
HTTPCode: http.StatusConflict,
}
case errors.Is(err, memory.ErrTidyRecallUnavailable):
return &apperr.AppError{
Code: "tidy_recall_unavailable",
Message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
HTTPCode: http.StatusInternalServerError,
}
case errors.Is(err, context.DeadlineExceeded):
return &apperr.AppError{
Code: "tidy_timeout",
Message: "记忆整理试算超时(未产出候选)",
HTTPCode: http.StatusServiceUnavailable,
}
default:
return apperr.Wrap(apperr.ErrInternal, "tidy_dry_run_failed", "记忆整理试算失败")
}
}
@@ -0,0 +1,463 @@
package gateway
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理「试算」的**加性异步**(契约 v0.76 §4.12.1;切片 MT-A):
//
// ① `?async=1` ⇒ 202 + `{runId,status:"running",startedAt}`;**不带**它时逐字走
// 原同步路径(老客户端零影响);
// ② 轮询 `done` 时的 `result` 与 §4.12 同步响应的 `data` **逐字节同形**
// (同一套 JSON,不是第二份);
// ③ `failed` 的 `error.code` 与同步路径同一张表;未知/过期 runId ⇒ `404
// tidy_run_not_found`(**绝不假装还在跑**);
// ④ 鉴权与 B14 同档(owner/admin;viewer 403、匿名 401),未接线 ⇒ 500
// `tidy_unavailable` —— 一律**不**回空候选数组冒充「没有建议」;
// ⑤ 真机链路:真实 `*memory.TidyService` 上「202 → 轮询 → done」跑通,且
// **一行都不写**(三张表调用前后逐行一致)。
// tidyRunAcceptedEnvelope 是 202 响应的解码壳。
type tidyRunAcceptedEnvelope struct {
Data struct {
RunID string `json:"runId"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// tidyRunStatusEnvelope 是轮询响应的解码壳(result 保留原始字节,供同形断言)。
type tidyRunStatusEnvelope struct {
Data struct {
Status string `json:"status"`
Result json.RawMessage `json:"result"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
} `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// tidyRunRawEnvelope 只留 data 原始字节(比对同形用)。
type tidyRunRawEnvelope struct {
Data json.RawMessage `json:"data"`
}
// postTidyDryRunAsync 调一次 `?async=1` 并解码 202 壳。
func postTidyDryRunAsync(t *testing.T, deps Deps, role auth.Role) (int, []byte, tidyRunAcceptedEnvelope, *auth.JWTService, string) {
t.Helper()
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
token := ""
if role != "" {
token = signAny(t, jwt, "acct", role)
}
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
var out tidyRunAcceptedEnvelope
if len(raw) > 0 {
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, out, jwt, ts.URL
}
// getTidyRunStatus 查一次异步状态。
func getTidyRunStatus(t *testing.T, baseURL, runID, token string) (int, []byte, tidyRunStatusEnvelope) {
t.Helper()
resp, raw := doReq(t, http.MethodGet, baseURL+"/api/v1/admin/memories/tidy/dry-run/"+runID, nil, token)
var out tidyRunStatusEnvelope
if len(raw) > 0 {
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, out
}
// TestAdminMemoryTidyDryRunAsync_AcceptedShape 是判据 ①:`?async=1` 立刻回 202 +
// runId/status/startedAt,**不跑**同步试算(不占连接),且墙钟上限与同步同口径。
func TestAdminMemoryTidyDryRunAsync_AcceptedShape(t *testing.T) {
t.Parallel()
startedAt := time.Date(2026, 10, 6, 1, 2, 3, 0, time.UTC)
fake := &fakeTidyDryRunner{handle: memory.TidyDryRunHandle{RunID: "run-abc", StartedAt: startedAt}}
status, raw, env, _, _ := postTidyDryRunAsync(t, Deps{MemoryTidy: fake}, auth.RoleOwner)
if status != http.StatusAccepted {
t.Fatalf("status = %d, want 202(body=%s)", status, raw)
}
if env.Data.RunID != "run-abc" || env.Data.Status != "running" {
t.Fatalf("202 载荷不对:%+v", env.Data)
}
if _, err := time.Parse(time.RFC3339, env.Data.StartedAt); err != nil {
t.Fatalf("startedAt 必须是 RFC3339:%q(%v)", env.Data.StartedAt, err)
}
if fake.startCalls != 1 {
t.Fatalf("StartDryRun 调用次数 = %d, want 1", fake.startCalls)
}
if fake.calls != 0 {
t.Fatalf("异步入口不得再同步跑一遍:DryRun 调用次数 = %d", fake.calls)
}
if fake.timeoutGot != tidyDryRunTimeout {
t.Fatalf("墙钟上限 = %s, want %s(与同步试算同一口径)", fake.timeoutGot, tidyDryRunTimeout)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("202 不得夹带候选数组(结果只在轮询里取):%s", raw)
}
}
// TestAdminMemoryTidyDryRunAsync_ResultIsByteIdenticalToSync 是判据 ②:轮询 `done`
// 的 `result` 与同步端点的 `data` **逐字节一致** —— 客户端可以只写一份解析器。
func TestAdminMemoryTidyDryRunAsync_ResultIsByteIdenticalToSync(t *testing.T) {
t.Parallel()
result := fixtureTidyDryRun()
fake := &fakeTidyDryRunner{
result: result,
handle: memory.TidyDryRunHandle{RunID: "run-1", StartedAt: time.Now()},
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncDone, Result: &result},
}
deps := Deps{MemoryTidy: fake}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
token := signAny(t, jwt, "acct", auth.RoleOwner)
_, syncRaw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
var syncEnv tidyRunRawEnvelope
if err := json.Unmarshal(syncRaw, &syncEnv); err != nil {
t.Fatalf("decode sync: %v (%s)", err, syncRaw)
}
status, raw, env := getTidyRunStatus(t, ts.URL, "run-1", token)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200(body=%s)", status, raw)
}
if env.Data.Status != "done" {
t.Fatalf("status = %q, want done", env.Data.Status)
}
if env.Data.Error != nil {
t.Fatalf("done 不得带 error:%+v", env.Data.Error)
}
if string(env.Data.Result) != string(syncEnv.Data) {
t.Fatalf("result 必须与同步响应的 data 逐字节同形:\n poll=%s\n sync=%s", env.Data.Result, syncEnv.Data)
}
if !strings.Contains(string(env.Data.Result), `"candidates":[`) {
t.Fatalf("result 里的 candidates 必须是非 null 数组:%s", env.Data.Result)
}
}
// TestAdminMemoryTidyDryRunAsync_FailedCarriesSameCodeAsSync 是判据 ③:失败态的
// `error.code` 与同步路径同一张映射表(这里用「语义召回不可用」),且不回候选数组。
func TestAdminMemoryTidyDryRunAsync_FailedCarriesSameCodeAsSync(t *testing.T) {
t.Parallel()
fake := &fakeTidyDryRunner{
err: memory.ErrTidyRecallUnavailable,
handle: memory.TidyDryRunHandle{RunID: "run-fail", StartedAt: time.Now()},
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncFailed, Err: memory.ErrTidyRecallUnavailable},
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: fake})
token := signAny(t, jwt, "acct", auth.RoleOwner)
status, raw, env := getTidyRunStatus(t, ts.URL, "run-fail", token)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200(失败在载荷里,不是 HTTP 错误)", status)
}
if env.Data.Status != "failed" || env.Data.Error == nil {
t.Fatalf("失败态载荷不对:%s", raw)
}
if env.Data.Error.Code != "tidy_recall_unavailable" {
t.Fatalf("error.code = %q, want tidy_recall_unavailable(与同步同一张表)", env.Data.Error.Code)
}
if len(env.Data.Result) != 0 {
t.Fatalf("失败态不得带 result:%s", raw)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("失败态不得出现候选数组(那是把「没算出来」谎报成「没有建议」):%s", raw)
}
// 负对照:同步端点在同一个错误上给的是同一个 code。
_, syncRaw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run", nil, token)
if !strings.Contains(string(syncRaw), `"tidy_recall_unavailable"`) {
t.Fatalf("同步路径的失败码必须与异步一致:%s", syncRaw)
}
}
// TestAdminMemoryTidyDryRunAsync_UnknownRunIsHonest404 是判据 ③(重启/过期语义):
// 查不到的 runId 如实回 404,**不做**「还在跑」的假承诺。
func TestAdminMemoryTidyDryRunAsync_UnknownRunIsHonest404(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: &fakeTidyDryRunner{}})
token := signAny(t, jwt, "acct", auth.RoleOwner)
status, raw, env := getTidyRunStatus(t, ts.URL, "run-gone", token)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404(body=%s)", status, raw)
}
if env.Error.Code != "tidy_run_not_found" {
t.Fatalf("code = %q, want tidy_run_not_found", env.Error.Code)
}
if env.Data.Status != "" || strings.Contains(string(raw), `"running"`) {
t.Fatalf("查不到时不得回任何状态(更不许假装还在跑):%s", raw)
}
}
// TestAdminMemoryTidyDryRunAsync_RequiresOwnerOrAdmin 是判据 ④:两条端点的鉴权
// 都与 B14 同档(owner/admin 可,viewer 403,匿名 401)。
func TestAdminMemoryTidyDryRunAsync_RequiresOwnerOrAdmin(t *testing.T) {
t.Parallel()
result := fixtureTidyDryRun()
deps := Deps{MemoryTidy: &fakeTidyDryRunner{
result: result,
handle: memory.TidyDryRunHandle{RunID: "run-rbac", StartedAt: time.Now()},
status: memory.TidyAsyncDryRun{Status: memory.TidyAsyncDone, Result: &result},
}}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
status, _, _, _, _ := postTidyDryRunAsync(t, deps, role)
if status != http.StatusAccepted {
t.Fatalf("POST async role %s = %d, want 202", role, status)
}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", signAny(t, jwt, "acct", role))
if s != http.StatusOK {
t.Fatalf("GET role %s = %d, want 200", role, s)
}
}
if status, _, _, _, _ := postTidyDryRunAsync(t, deps, auth.RoleViewer); status != http.StatusForbidden {
t.Fatalf("POST async viewer = %d, want 403", status)
}
if status, _, _, _, _ := postTidyDryRunAsync(t, deps, ""); status != http.StatusUnauthorized {
t.Fatalf("POST async 匿名 = %d, want 401", status)
}
_, ts, jwt := buildEnv(t, cfgDefault(), deps)
if s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", signAny(t, jwt, "acct", auth.RoleViewer)); s != http.StatusForbidden {
t.Fatalf("GET viewer = %d, want 403", s)
}
if s, _, _ := getTidyRunStatus(t, ts.URL, "run-rbac", ""); s != http.StatusUnauthorized {
t.Fatalf("GET 匿名 = %d, want 401", s)
}
}
// TestAdminMemoryTidyDryRunAsync_UnwiredIsHonest 是判据 ④(未接线):两条端点都
// 如实回 500 tidy_unavailable,且绝不回候选数组。
func TestAdminMemoryTidyDryRunAsync_UnwiredIsHonest(t *testing.T) {
t.Parallel()
status, raw, env, _, _ := postTidyDryRunAsync(t, Deps{}, auth.RoleOwner)
if status != http.StatusInternalServerError || env.Error.Code != "tidy_unavailable" {
t.Fatalf("未接线 POST async = %d/%q, want 500/tidy_unavailable", status, env.Error.Code)
}
if strings.Contains(string(raw), `"candidates"`) {
t.Fatalf("未接线时不得回候选数组:%s", raw)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
s, rawGet, getEnv := getTidyRunStatus(t, ts.URL, "run-x", signAny(t, jwt, "acct", auth.RoleOwner))
if s != http.StatusInternalServerError || getEnv.Error.Code != "tidy_unavailable" {
t.Fatalf("未接线 GET = %d/%q, want 500/tidy_unavailable(body=%s)", s, getEnv.Error.Code, rawGet)
}
}
// TestAdminMemoryTidyDryRun_AsyncOnlyOnExplicitFlag 是判据 ①(加性):只有
// `async=1` 才走异步;缺省、`async=0`、`async=true` 一律走原同步路径。
func TestAdminMemoryTidyDryRun_AsyncOnlyOnExplicitFlag(t *testing.T) {
t.Parallel()
for _, query := range []string{"", "?async=0", "?async=true", "?async="} {
fake := &fakeTidyDryRunner{result: fixtureTidyDryRun()}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: fake})
resp, raw := doReq(t, http.MethodPost,
ts.URL+"/api/v1/admin/memories/tidy/dry-run"+query, nil, signAny(t, jwt, "acct", auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("query=%q status = %d, want 200(老客户端逐字不变)", query, resp.StatusCode)
}
if !strings.Contains(string(raw), `"dryRun":true`) {
t.Fatalf("query=%q 必须是同步响应:%s", query, raw)
}
if fake.calls != 1 || fake.startCalls != 0 {
t.Fatalf("query=%q 走了异步路径:calls=%d startCalls=%d", query, fake.calls, fake.startCalls)
}
}
}
// tidyDBRows 把三张表的内容渲染成可比较的字符串行(逐行、按主键排序)。
func tidyDBRows(t *testing.T, conn *sql.DB) []string {
t.Helper()
queries := []string{
`SELECT id || '|' || persona_id || '|' || scope || '|' || kind || '|' || content || '|' || status FROM memory_items ORDER BY id`,
`SELECT id || '|' || action || '|' || status || '|' || rule_version FROM memory_tidy_candidates ORDER BY id`,
`SELECT id || '|' || scanned || '|' || candidates || '|' || rule_version FROM memory_tidy_runs ORDER BY id`,
}
out := make([]string, 0)
for _, q := range queries {
rows, err := conn.QueryContext(context.Background(), q)
if err != nil {
t.Fatalf("dump (%s): %v", q, err)
}
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
_ = rows.Close()
t.Fatalf("scan: %v", err)
}
out = append(out, line)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
t.Fatalf("iterate: %v", err)
}
_ = rows.Close()
}
return out
}
// TestAdminMemoryTidyDryRunAsync_EndToEndIsZeroSideEffect 是判据 ⑤:真实
// `*memory.TidyService` 上走完「202 → 轮询 → done」,结果与同步端点一致,且
// **三张表逐行不变**(异步路径与同步路径同一条硬约束)。
func TestAdminMemoryTidyDryRunAsync_EndToEndIsZeroSideEffect(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), gwTestLogger())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
store, err := memory.NewStore(ctx, conn, gwTestLogger())
if err != nil {
t.Fatalf("memory.NewStore: %v", err)
}
base := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
for i, content := range []string{
"主人常在深夜与助手聊天,有熬夜习惯",
"主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
} {
if _, err := store.Add(ctx, memory.MemoryItem{
PersonaID: "persona.a", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
Content: content, Confidence: 0.9, CreatedAt: base.Add(time.Duration(i) * time.Minute),
}); err != nil {
t.Fatalf("seed: %v", err)
}
}
svc, err := memory.NewTidyService(memory.TidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
}, memory.TidyDeps{Store: store, Log: gwTestLogger()})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
before := tidyDBRows(t, conn)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: svc})
token := signAny(t, jwt, "acct", auth.RoleOwner)
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("status = %d, want 202(body=%s)", resp.StatusCode, raw)
}
var accepted tidyRunAcceptedEnvelope
if err := json.Unmarshal(raw, &accepted); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
runID := accepted.Data.RunID
if runID == "" || accepted.Data.Status != "running" {
t.Fatalf("202 载荷不对:%+v", accepted.Data)
}
// 轮询到 done(真机一轮 90 秒量级;测试夹具是毫秒级)。
var done tidyRunStatusEnvelope
deadline := time.Now().Add(20 * time.Second)
for {
if time.Now().After(deadline) {
t.Fatalf("轮询超时,最后状态 %q", done.Data.Status)
}
status, body, env := getTidyRunStatus(t, ts.URL, runID, token)
if status != http.StatusOK {
t.Fatalf("轮询 status = %d(body=%s)", status, body)
}
if env.Data.Status == "failed" {
t.Fatalf("异步试算失败:%+v", env.Data.Error)
}
if env.Data.Status == "done" {
done = env
break
}
time.Sleep(5 * time.Millisecond)
}
var result AdminTidyDryRunResponse
if err := json.Unmarshal(done.Data.Result, &result); err != nil {
t.Fatalf("decode result: %v (%s)", err, done.Data.Result)
}
if !result.DryRun || result.RuleVersion != memory.TidyRuleVersion {
t.Fatalf("result 元信息不对:%+v", result)
}
if len(result.Candidates) == 0 {
t.Fatalf("夹具应当产出候选(否则这条用例是空转):%+v", result)
}
// 同步端点在同一批数据上给出同一套结果(同一套规则、同一个投影函数)。
_, syncRaw, syncEnv := postTidyDryRun(t, Deps{MemoryTidy: svc}, auth.RoleOwner)
if syncEnv.Data.RuleVersion != result.RuleVersion || len(syncEnv.Data.Candidates) != len(result.Candidates) {
t.Fatalf("异步与同步结果不一致:\n async=%+v\n sync =%+v(%s)", result, syncEnv.Data, syncRaw)
}
after := tidyDBRows(t, conn)
if strings.Join(before, "\n") != strings.Join(after, "\n") {
t.Fatalf("试算有副作用(三张表必须逐行不变):\n before=%v\n after =%v", before, after)
}
}
// TestAdminMemoryTidyDryRunAsync_RealServiceRestartDropsRunID 钉住「进程重启 ⇒
// 旧 runId 如实失效」:同一份存储上换一个服务实例(= 重启),旧 runId 回 404。
func TestAdminMemoryTidyDryRunAsync_RealServiceRestartDropsRunID(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), gwTestLogger())
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
store, err := memory.NewStore(ctx, conn, gwTestLogger())
if err != nil {
t.Fatalf("memory.NewStore: %v", err)
}
cfg := memory.TidyConfig{
Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7,
RecallThreshold: 0.80, ScanLimit: 5000, CandidateLimit: 200, CompareLimit: 2000,
}
first, err := memory.NewTidyService(cfg, memory.TidyDeps{Store: store, Log: gwTestLogger()})
if err != nil {
t.Fatalf("NewTidyService: %v", err)
}
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: first})
token := signAny(t, jwt, "acct", auth.RoleOwner)
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/dry-run?async=1", nil, token)
if resp.StatusCode != http.StatusAccepted {
t.Fatalf("status = %d, want 202(body=%s)", resp.StatusCode, raw)
}
var accepted tidyRunAcceptedEnvelope
if err := json.Unmarshal(raw, &accepted); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
// 「重启」:同一份存储上的新实例(注册表是进程内存态,重启即空)。
restarted, err := memory.NewTidyService(cfg, memory.TidyDeps{Store: store, Log: gwTestLogger()})
if err != nil {
t.Fatalf("NewTidyService(restart): %v", err)
}
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{MemoryTidy: restarted})
status, body, env := getTidyRunStatus(t, ts2.URL, accepted.Data.RunID, signAny(t, jwt2, "acct", auth.RoleOwner))
if status != http.StatusNotFound || env.Error.Code != "tidy_run_not_found" {
t.Fatalf("重启后旧 runId = %d/%q, want 404/tidy_run_not_found(body=%s)", status, env.Error.Code, body)
}
}
@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
@@ -23,10 +25,24 @@ import (
// fakeTidyDryRunner 是端点测试用的试算替身:按注入的结果/错误作答,并记录调用
// 次数(用来钉住「关闭时连试算都不跑」)。
//
// 异步面(契约 v0.76 §4.12.1)同样以「可驱动的桩」呈现:HTTP 层的职责是把领域
// 状态**如实投影**,注册表本身的行为(TTL / 并发 / 重启语义)由 memory 包的单测
// 钉住,不在这里重复实现一遍。
type fakeTidyDryRunner struct {
result memory.TidyDryRun
err error
calls int
handle memory.TidyDryRunHandle
startErr error
startCalls int
// timeoutGot 记录网关传给 StartDryRun 的墙钟上限(钉住「与同步同一口径」)。
timeoutGot time.Duration
status memory.TidyAsyncDryRun
statusErr error
statusCalls int
}
func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
@@ -34,6 +50,29 @@ func (f *fakeTidyDryRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
return f.result, f.err
}
func (f *fakeTidyDryRunner) StartDryRun(timeout time.Duration) (memory.TidyDryRunHandle, error) {
f.startCalls++
f.timeoutGot = timeout
if f.startErr != nil {
return memory.TidyDryRunHandle{}, f.startErr
}
return f.handle, nil
}
func (f *fakeTidyDryRunner) DryRunResult(runID string) (memory.TidyAsyncDryRun, error) {
f.statusCalls++
if f.statusErr != nil {
return memory.TidyAsyncDryRun{}, f.statusErr
}
if f.status.Status == "" {
// 未登记的状态 = 查不到(真实实现里由内存注册表决定)。
return memory.TidyAsyncDryRun{}, fmt.Errorf("%w: %s", memory.ErrTidyRunNotFound, runID)
}
out := f.status
out.RunID = runID
return out, nil
}
// fixtureTidyDryRun 是一条两候选/含否决分布/含截断标签的完整试算结果。
func fixtureTidyDryRun() memory.TidyDryRun {
return memory.TidyDryRun{
+8
View File
@@ -195,7 +195,15 @@ func (s *Server) registerAdminRoutes(
// 它是**只读语义**——跑同一套整理规则把候选直接回给面板、一行都不写;因此
// 它不在 registerAdminWriteRoutes(那条路是 owner-only 的真实写入 + 审计)。
// 试算属于「算给我看」,不是「改给我看」。
//
// 切片 MT-A(**v0.76**)在同一端点上加了**加性**异步:`?async=1` ⇒ 202 + runId,
// 再用下面那条 GET 轮询;不带该参数时逐字走原同步路径。
mux.Handle("POST /api/v1/admin/memories/tidy/dry-run", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRun)))
// 异步试算的结果查询(切片 MT-A,**v0.76**):与 B14 **同门同档**(writeAuth =
// owner/admin)—— 它是同一次计算的另一种取法,绝不比同步入口更宽;同样只读
// 语义、同样**不审计**。结果只活在进程内存(TTL 30 分钟),进程重启后旧 runId
// 如实 404。
mux.Handle("GET /api/v1/admin/memories/tidy/dry-run/{runId}", writeAuth(http.HandlerFunc(s.adminMemoryTidyDryRunStatus)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))