From 6e28b38b08cb1d6485a081707531fc0158c8278f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 08:04:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E6=8A=A4=E6=A0=8F=E6=8A=AC?= =?UTF-8?q?=E9=AB=98+=E6=97=B6=E9=97=B4=E9=A2=84=E7=AE=97=E9=97=B8+?= =?UTF-8?q?=E8=AF=95=E7=AE=97=E5=8A=A0=E6=80=A7=E5=BC=82=E6=AD=A5=20?= =?UTF-8?q?=E4=B8=8E=20D3=20=E5=85=A5=E5=BA=93=E5=89=8D=E5=AE=A1=E6=9F=A5?= =?UTF-8?q?=EF=BC=88=E8=80=A6=E5=90=88=E4=B8=A4=E7=89=87=E4=B8=80=E8=B5=B7?= =?UTF-8?q?=E8=90=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【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 无该文件权限,由我按其代码语义补写)。 --- .env.example | 28 +- cmd/cyrene/main.go | 6 + cmd/cyrene/wire.go | 10 +- cmd/cyrene/wire_memory_review.go | 69 +++ cmd/cyrene/wire_memory_review_test.go | 203 ++++++++ cmd/cyrene/wire_runtime.go | 5 + cmd/cyrene/wire_services.go | 5 + internal/config/config.go | 12 +- internal/config/memory_review.go | 51 ++ internal/config/memory_review_test.go | 44 ++ internal/config/memory_tidy_test.go | 8 +- internal/runtime/memory/preinsert.go | 465 ++++++++++++++++++ .../runtime/memory/preinsert_async_test.go | 297 +++++++++++ internal/runtime/memory/preinsert_judge.go | 157 ++++++ internal/runtime/memory/preinsert_recall.go | 103 ++++ internal/runtime/memory/preinsert_schema.go | 66 +++ internal/runtime/memory/preinsert_store.go | 366 ++++++++++++++ internal/runtime/memory/preinsert_test.go | 278 +++++++++++ internal/runtime/memory/store.go | 16 + internal/runtime/memory/tidy.go | 47 +- internal/runtime/memory/tidy_budget.go | 97 ++++ internal/runtime/memory/tidy_config.go | 24 +- internal/runtime/memory/tidy_dryrun.go | 4 +- internal/runtime/memory/tidy_dryrun_async.go | 247 ++++++++++ .../runtime/memory/tidy_dryrun_async_test.go | 311 ++++++++++++ internal/runtime/memory/tidy_guard_test.go | 336 +++++++++++++ internal/runtime/memory/tidy_recall.go | 54 +- internal/runtime/memory/tidy_service.go | 6 + internal/runtime/memory/tidy_store.go | 15 + internal/runtime/memory/tidy_veto_test.go | 8 +- .../services/gateway/admin_config_facts.go | 11 +- .../gateway/admin_config_facts_test.go | 13 +- .../services/gateway/admin_memory_tidy.go | 52 +- .../gateway/admin_memory_tidy_async.go | 162 ++++++ .../gateway/admin_memory_tidy_async_test.go | 463 +++++++++++++++++ .../gateway/admin_memory_tidy_test.go | 39 ++ internal/services/gateway/routes.go | 8 + 37 files changed, 4011 insertions(+), 75 deletions(-) create mode 100644 cmd/cyrene/wire_memory_review.go create mode 100644 cmd/cyrene/wire_memory_review_test.go create mode 100644 internal/config/memory_review.go create mode 100644 internal/config/memory_review_test.go create mode 100644 internal/runtime/memory/preinsert.go create mode 100644 internal/runtime/memory/preinsert_async_test.go create mode 100644 internal/runtime/memory/preinsert_judge.go create mode 100644 internal/runtime/memory/preinsert_recall.go create mode 100644 internal/runtime/memory/preinsert_schema.go create mode 100644 internal/runtime/memory/preinsert_store.go create mode 100644 internal/runtime/memory/preinsert_test.go create mode 100644 internal/runtime/memory/tidy_budget.go create mode 100644 internal/runtime/memory/tidy_dryrun_async.go create mode 100644 internal/runtime/memory/tidy_dryrun_async_test.go create mode 100644 internal/runtime/memory/tidy_guard_test.go create mode 100644 internal/services/gateway/admin_memory_tidy_async.go create mode 100644 internal/services/gateway/admin_memory_tidy_async_test.go diff --git a/.env.example b/.env.example index 13ee701..42c8712 100644 --- a/.env.example +++ b/.env.example @@ -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 向模型暴露【全部】已注册工具,行为与接线前逐字节一致; diff --git a/cmd/cyrene/main.go b/cmd/cyrene/main.go index 892ce9a..ffa8563 100644 --- a/cmd/cyrene/main.go +++ b/cmd/cyrene/main.go @@ -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) diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index e5fcab8..3f344b6 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -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 diff --git a/cmd/cyrene/wire_memory_review.go b/cmd/cyrene/wire_memory_review.go new file mode 100644 index 0000000..bb5b708 --- /dev/null +++ b/cmd/cyrene/wire_memory_review.go @@ -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) +} diff --git a/cmd/cyrene/wire_memory_review_test.go b/cmd/cyrene/wire_memory_review_test.go new file mode 100644 index 0000000..4c05f01 --- /dev/null +++ b/cmd/cyrene/wire_memory_review_test.go @@ -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:")上,因此不改她的心情、不进主会话泳道。 +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) + } +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index bc54e86..42068b4 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -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 } diff --git a/cmd/cyrene/wire_services.go b/cmd/cyrene/wire_services.go index 608b97c..05dc763 100644 --- a/cmd/cyrene/wire_services.go +++ b/cmd/cyrene/wire_services.go @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index 7715e40..0feb203 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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). diff --git a/internal/config/memory_review.go b/internal/config/memory_review.go new file mode 100644 index 0000000..9eb2f88 --- /dev/null +++ b/internal/config/memory_review.go @@ -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 +} diff --git a/internal/config/memory_review_test.go b/internal/config/memory_review_test.go new file mode 100644 index 0000000..26508f1 --- /dev/null +++ b/internal/config/memory_review_test.go @@ -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) + } +} diff --git a/internal/config/memory_tidy_test.go b/internal/config/memory_tidy_test.go index c9251a9..f163694 100644 --- a/internal/config/memory_tidy_test.go +++ b/internal/config/memory_tidy_test.go @@ -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) } } diff --git a/internal/runtime/memory/preinsert.go b/internal/runtime/memory/preinsert.go new file mode 100644 index 0000000..bceb9a2 --- /dev/null +++ b/internal/runtime/memory/preinsert.go @@ -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) +} diff --git a/internal/runtime/memory/preinsert_async_test.go b/internal/runtime/memory/preinsert_async_test.go new file mode 100644 index 0000000..2cbf39b --- /dev/null +++ b/internal/runtime/memory/preinsert_async_test.go @@ -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 } diff --git a/internal/runtime/memory/preinsert_judge.go b/internal/runtime/memory/preinsert_judge.go new file mode 100644 index 0000000..01a5c57 --- /dev/null +++ b/internal/runtime/memory/preinsert_judge.go @@ -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) +} diff --git a/internal/runtime/memory/preinsert_recall.go b/internal/runtime/memory/preinsert_recall.go new file mode 100644 index 0000000..817fbcb --- /dev/null +++ b/internal/runtime/memory/preinsert_recall.go @@ -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 +} diff --git a/internal/runtime/memory/preinsert_schema.go b/internal/runtime/memory/preinsert_schema.go new file mode 100644 index 0000000..0f50073 --- /dev/null +++ b/internal/runtime/memory/preinsert_schema.go @@ -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 +} diff --git a/internal/runtime/memory/preinsert_store.go b/internal/runtime/memory/preinsert_store.go new file mode 100644 index 0000000..2d41d06 --- /dev/null +++ b/internal/runtime/memory/preinsert_store.go @@ -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, °raded, + &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 +} diff --git a/internal/runtime/memory/preinsert_test.go b/internal/runtime/memory/preinsert_test.go new file mode 100644 index 0000000..ea99d3c --- /dev/null +++ b/internal/runtime/memory/preinsert_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/store.go b/internal/runtime/memory/store.go index 18c0538..48bbb8f 100644 --- a/internal/runtime/memory/store.go +++ b/internal/runtime/memory/store.go @@ -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 } diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go index b1217bc..fd4941d 100644 --- a/internal/runtime/memory/tidy.go +++ b/internal/runtime/memory/tidy.go @@ -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] diff --git a/internal/runtime/memory/tidy_budget.go b/internal/runtime/memory/tidy_budget.go new file mode 100644 index 0000000..c3e170a --- /dev/null +++ b/internal/runtime/memory/tidy_budget.go @@ -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)" +} diff --git a/internal/runtime/memory/tidy_config.go b/internal/runtime/memory/tidy_config.go index 47dd90e..6047a1d 100644 --- a/internal/runtime/memory/tidy_config.go +++ b/internal/runtime/memory/tidy_config.go @@ -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 } diff --git a/internal/runtime/memory/tidy_dryrun.go b/internal/runtime/memory/tidy_dryrun.go index 27ac617..7c1f2a8 100644 --- a/internal/runtime/memory/tidy_dryrun.go +++ b/internal/runtime/memory/tidy_dryrun.go @@ -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) diff --git a/internal/runtime/memory/tidy_dryrun_async.go b/internal/runtime/memory/tidy_dryrun_async.go new file mode 100644 index 0000000..87f741e --- /dev/null +++ b/internal/runtime/memory/tidy_dryrun_async.go @@ -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 +} diff --git a/internal/runtime/memory/tidy_dryrun_async_test.go b/internal/runtime/memory/tidy_dryrun_async_test.go new file mode 100644 index 0000000..8cb059f --- /dev/null +++ b/internal/runtime/memory/tidy_dryrun_async_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/tidy_guard_test.go b/internal/runtime/memory/tidy_guard_test.go new file mode 100644 index 0000000..bca483d --- /dev/null +++ b/internal/runtime/memory/tidy_guard_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/tidy_recall.go b/internal/runtime/memory/tidy_recall.go index 2ce082d..99e3d64 100644 --- a/internal/runtime/memory/tidy_recall.go +++ b/internal/runtime/memory/tidy_recall.go @@ -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 } diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index 251ec54..92f5ca2 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -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) diff --git a/internal/runtime/memory/tidy_store.go b/internal/runtime/memory/tidy_store.go index 8bfa871..c449355 100644 --- a/internal/runtime/memory/tidy_store.go +++ b/internal/runtime/memory/tidy_store.go @@ -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), // 绝不重复产候选。 diff --git a/internal/runtime/memory/tidy_veto_test.go b/internal/runtime/memory/tidy_veto_test.go index f46425b..1c1e083 100644 --- a/internal/runtime/memory/tidy_veto_test.go +++ b/internal/runtime/memory/tidy_veto_test.go @@ -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) diff --git a/internal/services/gateway/admin_config_facts.go b/internal/services/gateway/admin_config_facts.go index a3ca8e8..72989e6 100644 --- a/internal/services/gateway/admin_config_facts.go +++ b/internal/services/gateway/admin_config_facts.go @@ -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 { diff --git a/internal/services/gateway/admin_config_facts_test.go b/internal/services/gateway/admin_config_facts_test.go index 01781a0..de83ce0 100644 --- a/internal/services/gateway/admin_config_facts_test.go +++ b/internal/services/gateway/admin_config_facts_test.go @@ -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() diff --git a/internal/services/gateway/admin_memory_tidy.go b/internal/services/gateway/admin_memory_tidy.go index 613d8cb..f504cba 100644 --- a/internal/services/gateway/admin_memory_tidy.go +++ b/internal/services/gateway/admin_memory_tidy.go @@ -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 把领域结果投影成线上结构。它**不复制**任何阈值/上限: diff --git a/internal/services/gateway/admin_memory_tidy_async.go b/internal/services/gateway/admin_memory_tidy_async.go new file mode 100644 index 0000000..24eeaf7 --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_async.go @@ -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", "记忆整理试算失败") + } +} diff --git a/internal/services/gateway/admin_memory_tidy_async_test.go b/internal/services/gateway/admin_memory_tidy_async_test.go new file mode 100644 index 0000000..3eed16d --- /dev/null +++ b/internal/services/gateway/admin_memory_tidy_async_test.go @@ -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) + } +} diff --git a/internal/services/gateway/admin_memory_tidy_test.go b/internal/services/gateway/admin_memory_tidy_test.go index b29ca07..b743cd2 100644 --- a/internal/services/gateway/admin_memory_tidy_test.go +++ b/internal/services/gateway/admin_memory_tidy_test.go @@ -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{ diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 01fe11c..8f127b4 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -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)))