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

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

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

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

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

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

No files matched your search

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