diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go index a2ff7b8..1e5967a 100644 --- a/internal/runtime/memory/tidy.go +++ b/internal/runtime/memory/tidy.go @@ -43,7 +43,10 @@ const ( // TidyArchive 表示含绝对日期/时刻的一次性事件,建议归档而非合并 // (design/23 §5.1 T0-5)。 TidyArchive TidyAction = "archive" - // TidyReclassify 表示 kind 误分类(design/23 §5.1 T0-6),只产候选不自动改。 + // TidyReclassify 表示 kind 误分类(design/23 §5.1 T0-6):只改分类列,**不碰正文**。 + // 2026-10-07「全自动挡吧」起,理由为 kind_mismatch 且形状完整(含可回滚的 + // 改前 kind)的这类候选由轮末自动档直接落地(判据见 tidy_auto.go 的 + // tidyAutoReclassifyKind);不满足判据的仍交人工。 TidyReclassify TidyAction = "reclassify" // TidyKeep 是「拿不准 = 什么都不做」(design/23 §5.2-10)。它是合法动作, // 但第一版不为它落库(只进否决计数与日志),以免候选队列被噪声填满。 @@ -183,6 +186,7 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) { "auto_applied", stats.AutoApplied, "auto_merge", stats.AutoMerged, "auto_archive", stats.AutoArchived, + "auto_reclassify", stats.AutoReclassified, "auto_eligible", stats.AutoEligible, "auto_truncated", stats.AutoTruncated, } diff --git a/internal/runtime/memory/tidy_apply.go b/internal/runtime/memory/tidy_apply.go index f28860b..b63dc3d 100644 --- a/internal/runtime/memory/tidy_apply.go +++ b/internal/runtime/memory/tidy_apply.go @@ -32,7 +32,8 @@ import ( // 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。 // // 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行; -// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。 +// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。受限来源 +// (群聊/陌生人)同样三类动作一律拒做(tidySkipSourceRestricted,design/23 §5.2-9/§6)。 // TidyApplyStatus 是一条候选应用结果的状态类别。 type TidyApplyStatus string @@ -245,8 +246,13 @@ func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts T return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive) } -// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):只改 kind 一列, +// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):**只改 kind 一列**, // 建议值来自候选证据里的 ProposedKind。 +// +// 边界(2026-10-07 纳入自动档时写死):它不能改正文、不能改作用域、不能碰第二条行。 +// 候选上的 ProposedContent / MergedContent 之类正文字段**一律不采纳** —— 落地语句 +// 只写 kind/updated_at(reclassifyTidyItemSQL),因此即使候选是手工塞进来的畸形行, +// 也只会得到一次分类修正,不会有任何一个字节的正文被写入。 func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) { base := TidyApplyResult{ CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{}, @@ -268,6 +274,13 @@ func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opt if skip := tidyPartitionMismatch(cand, item); skip != "" { return skipTidyApply(base, skip), nil } + if nonOwnerSource(item.SourceConversation) { + // 只读来源(群聊/陌生人)绝不改(design/23 §5.2-9/§6):纵深防御,规则层本就 + // 把这类条目排除在扫描范围外。archive/merge 早就有这道门,reclassify 在 + // 2026-10-07 纳入自动档时必须补上同一道 —— 三类动作的只读来源口径必须一致, + // 否则「自动档只碰主人自己的记忆」这句话就不成立。 + return skipTidyApply(base, tidySkipSourceRestricted), nil + } if item.Kind == kind { // 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里 // 用 already_decided 更像「无需再做」。 diff --git a/internal/runtime/memory/tidy_apply_test.go b/internal/runtime/memory/tidy_apply_test.go index 0088d2c..cd1f71d 100644 --- a/internal/runtime/memory/tidy_apply_test.go +++ b/internal/runtime/memory/tidy_apply_test.go @@ -299,6 +299,10 @@ func TestTidyApplyArchiveAndRevert(t *testing.T) { // TestTidyApplyReclassifyAndRevert 钉住④的另一半:reclassify 只改 kind 一列, // 内容不变;revert 把 kind 还原成候选证据里的旧值。 +// +// 本用例刻意**关掉轮末自动档**:它验的是**人工应用/回滚**这条路径,而 2026-10-07 起 +// 自动档会在 RunOnce 收尾时把这条 shape 完整的 kind_mismatch 候选当场落地(那会让 +// 「apply 得到 applied」变成 already_decided)。自动档那条在 tidy_auto_reclassify_test.go 里验。 func TestTidyApplyReclassifyAndRevert(t *testing.T) { t.Parallel() ctx := context.Background() @@ -312,7 +316,9 @@ func TestTidyApplyReclassifyAndRevert(t *testing.T) { bad := seedTidyItem(t, store, misclassified) seedTidyItem(t, store, superset) - tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + cfg := tidyTestCfg(0.80) + cfg.DisableAutoApply = true + tr := newTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{ fixtureHabitShort: {1, 0}, fixtureHabitLong: simVector(0.95), }}) diff --git a/internal/runtime/memory/tidy_auto.go b/internal/runtime/memory/tidy_auto.go index 0301735..9f3aa04 100644 --- a/internal/runtime/memory/tidy_auto.go +++ b/internal/runtime/memory/tidy_auto.go @@ -14,25 +14,38 @@ import ( // ⚠️ 与 design/23 的既有口径冲突,且**是有意为之**:§7 原写「自动应用默认关闭; // 即便将来开启,也只允许 apply 低风险动作(如 reclassify),merge 与 archive 永远 // 人工确认」,§13 开放点 5 的处置是「全部人工应用(无自动 apply)」。主人本次的 -// 「该删就删」覆盖那两条;design 文档需同步(交付报告已列出建议补的几行)。 +// 「该删就删」(2026-10-07)与同日追加的「全自动挡吧」覆盖那两条;design 文档需同步 +// (交付报告已列出建议补的几行)。 // // 五条护栏,缺一不可: // -// 1. **可自动的类别只有两类**,判据是纯函数 tidyAutoApplyEligible: -// merge = 字面重复 ∨(**事实集合等价** ∧ 相似度 ≥ 0.95);archive = 理由为 -// one_off_event **且不像习惯**(design/23 §5.2-1:一次性事件 ≠ 长期习惯, -// 习惯类绝不能被钟点误判归档)。reclassify 虽小也不在本次授权内,交人工。 -// 2. **只走既有 apply 路径**(tidy_apply.go):软动作(只改 status/merged_*, +// 1. **可自动的类别只有三类**,判据是纯函数 tidyAutoApplyEligible: +// - `merge` = 字面重复 ∨(**事实集合等价** ∧ 相似度 ≥ 0.95); +// - `archive` = 理由为 one_off_event **且不像习惯**(design/23 §5.2-1:一次性 +// 事件 ≠ 长期习惯,习惯类绝不能被钟点误判归档 —— 这条守卫是红线,不许动); +// - `reclassify` = 理由为 kind_mismatch ∧ **只**要求改分类列(2026-10-07 追加 +// 口径「全自动挡吧」)。它只改 memory_items.kind 一列:**不动正文、不删行、 +// 不影响任何召回内容**(检索读的是 content/向量,kind 只是分类标记), +// 因此与 merge(软合并)/ archive(软删)同级安全。 +// 2. **只走既有 apply 路径**(tidy_apply.go):软动作(只改 status/merged_*/kind, // 绝不改正文、绝不删行)、候选状态条件更新(幂等)、失败隔离、可 revert。 -// 3. **每轮上限**(cfg.AutoApplyPerRound):超限如实报 Truncated + 真总数, -// 绝不静默少做;下一轮继续。 +// 3. **每轮上限**(cfg.AutoApplyPerRound):三类动作**同占一个池子**,超限如实报 +// Truncated + 真总数,绝不静默少做;下一轮继续。 // 4. **审计**:走 design/23 §8 的 `memory.tidy.apply` 族,result 带 `auto=true` -// 以区分「人点的」与「整理器自己落的」;只带计数,不带任何记忆正文。 +// 以区分「人点的」与「整理器自己落的」;只带计数与理由分布,不带任何记忆正文。 // 5. **一键回到只产建议**:`$DATA_DIR/config/memory-tidy.json` 的 // `{"autoApply": false}`(不加 env 键 —— `.env` 已冻结,design/13 §2.1)。 // -// 绝不自动做的事(同样重要,写在这里以免后来者扩大范围):跨分区、跨角色、 -// 任何改正文(modify)、任何低置信 merge、任何非 one_off_event 的归档。 +// 🔴 仍然人工 / **绝不自动**的清单(写在这里以免后来者扩大范围): +// - 跨分区、跨角色:候选与目标行分区不一致 ⇒ tidySkipPartitionMismatch; +// - 受限来源(群聊 / 陌生人,design/23 §5.2-9 + §6)⇒ tidySkipSourceRestricted; +// - **任何改正文**:改内容的动作根本不在这套动作闭集里,三类自动动作的落库 +// UPDATE 也各自只写白名单列(status / merged_* / kind),正文列永不出现; +// - **低置信 merge**:规则层 VetoLowConfidence 一票否决 ⇒ 根本不成候选; +// - **非 one_off_event 理由的归档**(理由变了说明规则变了 = 拿不准,交人工); +// - 🔴 **archive 的「习惯类不归档」守卫不动**(design/23 §5.2-1):习惯类会因正文 +// 带钟点被误判成一次性事件,归档 = 从检索里消失 ⇒ 风险是「她忘掉主人的作息 +// 习惯」。它不在任何自动档授权内,改它就是偏离。 // tidyAutoApplyVerb 是自动落地的审计动作名。它与网关侧 HTTP 触发那条同族 // (gateway.tidyApplyAction 逐字同值):动作名是 design/23 §8 的契约,不是实现细节, @@ -47,6 +60,11 @@ const ( tidyAutoReasonEquivalentFacts = "fact_equivalent_high_similarity" // tidyAutoReasonOneOffEvent 表示一次性事件(含绝对日期/时刻且不像习惯)。 tidyAutoReasonOneOffEvent = "one_off_event" + // tidyAutoReasonKindMismatch 表示 kind 误分类修正(design/23 §5.1 T0-6)。 + // 它**刻意与候选理由同值**(tidyReasonKindMismatch):这条动作的自动档与人工档 + // 是同一个口径(只改分类列),没有第二个理由标签可引入 —— 写成别名而不是复制 + // 一个字面量,是为了让「两处必须一致」由编译器保证。 + tidyAutoReasonKindMismatch = tidyReasonKindMismatch ) // TidyAutoApplyBatch 是轮末自动档的一趟回执(进日志、审计与统计)。 @@ -66,6 +84,9 @@ type TidyAutoApplyBatch struct { Failed int Merged int Archived int + // Reclassified 是成功落地的 reclassify 条数(2026-10-07「全自动挡吧」起纳入 + // 自动档)。它与 Merged/Archived 同口径:只数**成功落地**的,跳过的计入 Skipped。 + Reclassified int // ByReason 是**成功落地**的条数按自动理由分解(跳过的计不计:它们没落地)。 ByReason map[string]int } @@ -182,12 +203,57 @@ func tidyAutoApplyEligible(c TidyCandidate) (bool, string) { return false, "" } return true, tidyAutoReasonOneOffEvent + case TidyReclassify: + // 2026-10-07 追加口径「全自动挡吧」:reclassify 只改 kind(分类字段), + // **不动正文、不删行、不影响召回内容** ⇒ 与 merge/archive 同级安全。 + // + // 边界写在**读字段的那一处**(tidyAutoReclassifyKind),以免后来者顺手扩大: + // 本档只采纳它的分类要求。候选上任何正文/合并后正文(ProposedContent / + // MergedContent / ContentA / ContentB)、任何指向第二条行的 target_id、 + // 任何分区或时间字段,一概不读、不采纳 —— reclassify 的授权范围只有 + // 「分类列」,它不能改正文、不能改作用域;落地路径的 UPDATE 也只写 kind。 + if _, ok := tidyAutoReclassifyKind(c); ok { + return true, tidyAutoReasonKindMismatch + } + return false, "" default: - // reclassify / link / keep 都不自动(link/keep 本来也没有执行语义)。 + // link / keep 都不自动(它们本来也没有执行语义)。 return false, "" } } +// tidyAutoReclassifyKind 报告一条候选是否**只**要求改分类,并给出它要求改成的分类。 +// 四个条件缺一不可: +// +// 1. **形状**:恰 1 个 source、且 target_id 为空。reclassify 的主体只有一行; +// 指着第二条行的「改成什么」不是分类修正(那是 merge 的形状),拒。 +// 2. **理由**必须是 kind_mismatch —— 与 archive 只认 one_off_event 同一手法: +// 理由变了说明规则变了,那属于「拿不准」,交人工。 +// 3. ProposedKind 必须是**合法分类**(memory 只有 relationship / world_event)。 +// 4. 候选自带的「改前 kind」(KindA)必须合法、且与建议值**不同**。这一条是 +// **可 revert 护栏的抓手**:回滚靠 KindA 把 kind 写回去(tidy_apply_store.go 的 +// undoTidyReclassifySQL),KindA 缺失/非法的候选一旦自动落地就**没法回滚**, +// 那违反「自动落地的每一条都能 revert」;同理 KindA == ProposedKind 的候选 +// 无事可做,让它每轮都占一次上限(又永远跳过)是纯噪声。 +// +// 除 ProposedKind / KindA 外,候选上的字段一概不参与判定(尤其 ProposedContent +// 之类的正文:reclassify 无权改正文 ⇒ 一律不采纳)。 +func tidyAutoReclassifyKind(c TidyCandidate) (Kind, bool) { + if len(c.SourceIDs) != 1 || strings.TrimSpace(c.SourceIDs[0]) == "" || + strings.TrimSpace(c.TargetID) != "" { + return "", false + } + if c.Reason != tidyReasonKindMismatch { + return "", false + } + proposed := Kind(c.Evidence.ProposedKind) + previous := Kind(c.Evidence.KindA) + if !proposed.valid() || !previous.valid() || previous == proposed { + return "", false + } + return proposed, true +} + // literalDuplicate 报告两条正文是否逐字相同(只去掉首尾空白)。 // 大小写**不**折叠、内部空白**不**压缩:「逐字相同」就该逐字比。 func literalDuplicate(a, b string) bool { @@ -244,6 +310,11 @@ func tallyTidyAutoApply(batch *TidyAutoApplyBatch, res TidyApplyResult, reason s batch.Merged++ case TidyArchive: batch.Archived++ + case TidyReclassify: + // 2026-10-07 起 reclassify 也在自动档里,因此它必须有独立的计数位: + // 少了这一格,自动改了 kind 的那些会**静默消失**在 Applied 总数里, + // 审计与日志就再也分不清「自动档做了什么」。 + batch.Reclassified++ } case TidyApplyFailed: batch.Failed++ @@ -274,6 +345,7 @@ func (t *Tidier) autoApply(ctx context.Context, stats *TidyStats) TidyAutoApplyB stats.AutoApplied = batch.Applied stats.AutoMerged = batch.Merged stats.AutoArchived = batch.Archived + stats.AutoReclassified = batch.Reclassified stats.AutoTruncated = batch.Truncated if batch.Failed > 0 { stats.Errors += batch.Failed @@ -299,8 +371,8 @@ func (t *Tidier) auditAutoApply(batch TidyAutoApplyBatch) { return } result := fmt.Sprintf( - "ok auto=true applied=%d merged=%d archived=%d skipped=%d failed=%d eligible_total=%d limit=%d truncated=%t scan_truncated=%t by_reason=%s", - batch.Applied, batch.Merged, batch.Archived, batch.Skipped, batch.Failed, + "ok auto=true applied=%d merged=%d archived=%d reclassified=%d skipped=%d failed=%d eligible_total=%d limit=%d truncated=%t scan_truncated=%t by_reason=%s", + batch.Applied, batch.Merged, batch.Archived, batch.Reclassified, batch.Skipped, batch.Failed, batch.EligibleTotal, batch.Limit, batch.Truncated, batch.ScanTruncated, tidyAutoReasonLabel(batch.ByReason)) hook := t.deps.Audit diff --git a/internal/runtime/memory/tidy_auto_harness_test.go b/internal/runtime/memory/tidy_auto_harness_test.go new file mode 100644 index 0000000..897f0d1 --- /dev/null +++ b/internal/runtime/memory/tidy_auto_harness_test.go @@ -0,0 +1,116 @@ +package memory + +import ( + "context" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件是「轮末自动落地」测试的**共用夹具**:正文夹具常量、整理器构造(带审计捕获)、 +// 自动档审计行筛选、记忆行断言辅助,以及手工落候选的构造器。两个用例文件 +// (tidy_auto_test.go 与 tidy_auto_reclassify_test.go)共用它们 —— 拆文件只为守 +// AGENTS §10「文件 ≤500 行」,用例与断言逐字未变。 + +const ( + // fixtureHabitClock 是「被钟点误判」的典型:正文里带绝对时刻(23:30)⇒ 规则层 + // 会把它当一次性事件产 archive 候选,但它明明白白是**作息习惯**(「常在」「习惯」)。 + fixtureHabitClock = "主人常在深夜23:30后还醒着,有熬夜习惯" + // fixtureDupText 是字面重复用例的正文(两侧逐字相同)。 + fixtureDupText = "主人喜欢在深夜喝冰美式" + // fixtureEquivA / fixtureEquivB 是**事实集合等价但不同措辞**的一对 + // (关键要素逐一互相包含 ⇒ containBoth,正文不同 ⇒ 不是字面重复)。 + fixtureEquivA = "主人喜欢喝红牛" + fixtureEquivB = "红牛,主人喜欢喝" +) + +// newAutoTestTidier 构建整理器并把审计出口捕获下来(自动档的审计是断言面之一)。 +func newAutoTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *[]tidyAuditRow) { + t.Helper() + rows := make([]tidyAuditRow, 0) + tr, err := NewTidier(cfg, TidyDeps{ + Store: store, + Embedder: emb, + Log: testLogger(), + Audit: func(who, what, result string) { + rows = append(rows, tidyAuditRow{who: who, what: what, result: result}) + }, + }) + if err != nil { + t.Fatalf("NewTidier: %v", err) + } + return tr, &rows +} + +// autoAuditRows 返回捕获到的 memory.tidy.apply(自动档)审计行。 +func autoAuditRows(rows *[]tidyAuditRow) []tidyAuditRow { + out := make([]tidyAuditRow, 0) + for _, r := range *rows { + if r.what == tidyAutoApplyVerb { + out = append(out, r) + } + } + return out +} + +// countArchived 数给定 id 里已归档的行数。 +func countArchived(t *testing.T, store *Store, ids []string) int { + t.Helper() + n := 0 + for _, id := range ids { + if loadTidyItemRow(t, store, id).status == tidyStatusArchived { + n++ + } + } + return n +} + +// autoReclassifyCandidate 造一条 reclassify 候选(形状 = 规则层产出的那种: +// 恰 1 个 source、无 target、理由 kind_mismatch、KindA = 该行**当前**的 kind)。 +// +// strayContent 是**故意**留给畸形用例的口子:规则层产出的 reclassify 候选 +// ProposedContent 恒为空(buildCandidates 把它置空),手工带上它就是「候选想改正文」 +// 的畸形行 —— 自动档只许采纳它的分类,正文一个字都不许采纳。 +func autoReclassifyCandidate(item MemoryItem, proposed Kind, strayContent string, at time.Time) TidyCandidate { + return TidyCandidate{ + ID: mustNewTidyID(), PersonaID: item.PersonaID, Scope: item.Scope, ProjectID: item.ProjectID, + Kind: item.Kind, Action: TidyReclassify, SourceIDs: []string{item.ID}, + ProposedContent: strayContent, Reason: tidyReasonKindMismatch, + Evidence: TidyEvidence{ + KindA: string(item.Kind), ProposedKind: string(proposed), ContentA: item.Content, + }, + RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: at, + } +} + +// insertAutoCandidateOrFail 落一条手工候选:夹具不成立(没落进去)就该当场失败, +// 否则后面的断言会验一个不存在的场景。 +func insertAutoCandidateOrFail(t *testing.T, store *Store, c TidyCandidate) { + t.Helper() + if created, err := store.insertTidyCandidate(context.Background(), c); err != nil || !created { + t.Fatalf("落候选 %s = (%t, %v), want (true, nil)", c.ID, created, err) + } +} + +// seedWorldEventItem 落一条 world_event 记忆(reclassify 用例的被改对象)。 +func seedWorldEventItem(t *testing.T, store *Store, content string, at time.Time) MemoryItem { + t.Helper() + return seedTidyItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent, + Content: content, Confidence: 0.9, CreatedAt: at, + SourceConversation: FormatSourceConversation("web", "msg-1"), + }) +} + +// autoArchiveCandidate 造一条一次性事件的归档候选(形状 = 规则层产出的那种: +// 理由 one_off_event、ProposedContent = 原文、证据 Containment=none)。 +func autoArchiveCandidate(item MemoryItem, at time.Time) TidyCandidate { + return TidyCandidate{ + ID: mustNewTidyID(), PersonaID: item.PersonaID, Scope: item.Scope, ProjectID: item.ProjectID, + Kind: item.Kind, Action: TidyArchive, SourceIDs: []string{item.ID}, + ProposedContent: item.Content, Reason: tidyReasonOneOffEvent, + Evidence: TidyEvidence{Containment: string(containNone), ContentA: item.Content}, + RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: at, + } +} diff --git a/internal/runtime/memory/tidy_auto_reclassify_test.go b/internal/runtime/memory/tidy_auto_reclassify_test.go new file mode 100644 index 0000000..1d84ec6 --- /dev/null +++ b/internal/runtime/memory/tidy_auto_reclassify_test.go @@ -0,0 +1,475 @@ +package memory + +import ( + "context" + "slices" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" +) + +// 本文件锁定轮末自动档对 **reclassify**(kind 误分类修正,design/23 §5.1 T0-6)的 +// 覆盖 —— 2026-10-07 主人追加口径「全自动挡吧」。它只改分类列:正文一字不动、 +// 可回滚(靠候选证据 KindA)、计入每轮上限、开关一关就只产建议、 +// 受限来源/跨分区一律跳过、畸形候选带的正文一律不采纳。 + +// TestTidyAutoReclassifyKindMismatch 是判据 ⑨:kind 误分类的候选由轮末自动档直接 +// 落地 —— **只改 kind 一列**,正文/状态/行数一字不动,并如实写审计与留痕。 +// +// 本用例走的是**规则层真实的产出路径**(RunOnce):包含关系成立但 kind 不同 ⇒ +// 1 条 merge(真子集,不在自动档判据内 ⇒ 留人工)+ 1 条 reclassify(自动落地)。 +func TestTidyAutoReclassifyKindMismatch(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + bad := seedWorldEventItem(t, store, fixtureHabitShort, base) + seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + + tr, rows := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.95), + }}) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.AutoApplied != 1 || stats.AutoReclassified != 1 { + t.Fatalf("自动档计数 = %+v, want applied/reclassified = 1/1", stats) + } + if stats.AutoMerged != 0 || stats.AutoArchived != 0 || stats.AutoTruncated { + t.Fatalf("自动档不该顺手动别的:%+v", stats) + } + + after := loadTidyItemRow(t, store, bad.ID) + if after.kind != string(KindRelationship) { + t.Fatalf("分类没被修:kind = %q, want %q", after.kind, KindRelationship) + } + if after.content != fixtureHabitShort { + t.Fatalf("自动 reclassify 改了正文:%q(design:只改分类列)", after.content) + } + if after.status != tidyStatusActive || after.mergedInto != "" { + t.Fatalf("自动 reclassify 动了别的列:%+v", after) + } + if got := countMemoryItems(t, store); got != 2 { + t.Fatalf("行数 = %d, want 2(绝不物理删除)", got) + } + + cands := allTidyCandidates(t, store, 10) + res := candidatesByAction(cands, TidyReclassify) + if len(res) != 1 || res[0].Status != tidyCandidateApplied { + t.Fatalf("reclassify 候选 = %+v, want 恰 1 条 applied(自动档落的)", res) + } + merges := candidatesByAction(cands, TidyMerge) + if len(merges) != 1 || merges[0].Status != tidyCandidatePending { + t.Fatalf("真子集 merge 候选 = %+v, want 仍 pending(自动档不许扩权)", merges) + } + + auto := autoAuditRows(rows) + if len(auto) != 1 { + t.Fatalf("自动档审计 = %d 条, want 1(%+v)", len(auto), *rows) + } + if !strings.Contains(auto[0].result, "auto=true") || + !strings.Contains(auto[0].result, "reclassified=1") || + !strings.Contains(auto[0].result, tidyAutoReasonKindMismatch) { + t.Fatalf("审计 result = %q, want 含 auto=true / reclassified=1 / %s", + auto[0].result, tidyAutoReasonKindMismatch) + } + if strings.Contains(auto[0].result, fixtureHabitShort) { + t.Fatal("审计里不得出现记忆正文") + } + + runs, err := store.ListTidyRuns(ctx, 1) + if err != nil { + t.Fatalf("ListTidyRuns: %v", err) + } + if len(runs) != 1 || runs[0].Applied != 1 { + t.Fatalf("运行留痕 = %+v, want applied=1(自动落地要进留痕)", runs) + } +} + +// TestTidyAutoReclassifyRevertRestoresKind 是护栏「可 revert」对 reclassify 的覆盖: +// 自动落地之后必须能把 kind 复原(靠候选证据里的 KindA),正文全程一字不动。 +func TestTidyAutoReclassifyRevertRestoresKind(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + row := seedWorldEventItem(t, store, fixtureWorkShort, base) + cand := autoReclassifyCandidate(row, KindRelationship, "", base) + insertAutoCandidateOrFail(t, store, cand) + before := loadTidyItemRow(t, store, row.ID) + + batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10}) + if err != nil { + t.Fatalf("AutoApplyTidyCandidates: %v", err) + } + if batch.Applied != 1 || batch.Reclassified != 1 || batch.Skipped != 0 { + t.Fatalf("自动落地回执 = %+v, want applied/reclassified = 1/1", batch) + } + if got := loadTidyItemRow(t, store, row.ID); got.kind != string(KindRelationship) { + t.Fatalf("kind = %q, want %q", got.kind, KindRelationship) + } + + res, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}) + if err != nil { + t.Fatalf("RevertTidyCandidate: %v", err) + } + if res.Status != TidyApplyApplied { + t.Fatalf("回滚结果 = %+v, want applied", res) + } + restored := loadTidyItemRow(t, store, row.ID) + if restored.kind != before.kind { + t.Fatalf("回滚后 kind = %q, want %q(靠候选证据 KindA 复原)", restored.kind, before.kind) + } + if restored.content != before.content || restored.status != before.status { + t.Fatalf("回滚动了别的列:%+v(before=%+v)", restored, before) + } + if got, err := store.GetTidyCandidate(ctx, cand.ID); err != nil || got.Status != tidyCandidatePending { + t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", got.Status, err) + } + if got := countMemoryItems(t, store); got != 1 { + t.Fatalf("行数 = %d, want 1(回滚绝不删行)", got) + } +} + +// TestTidyAutoReclassifyIsIdempotent 是护栏「幂等」对 reclassify 的覆盖:候选状态 +// 的条件更新是执行闸 —— 第二轮(以及直接再 apply 一次)不产生第二次副作用。 +func TestTidyAutoReclassifyIsIdempotent(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + row := seedWorldEventItem(t, store, fixtureWorkShort, base) + cand := autoReclassifyCandidate(row, KindRelationship, "", base) + insertAutoCandidateOrFail(t, store, cand) + + first, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10}) + if err != nil { + t.Fatalf("第一次 AutoApplyTidyCandidates: %v", err) + } + if first.Applied != 1 { + t.Fatalf("第一次回执 = %+v, want applied=1", first) + } + settled := dumpTidyTable(t, store, "memory_items", memoryItemsColumns) + + // 已 applied 的候选不再是 pending ⇒ 第二轮连看都不看它(EligibleTotal=0)。 + second, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10}) + if err != nil { + t.Fatalf("第二次 AutoApplyTidyCandidates: %v", err) + } + if second.EligibleTotal != 0 || second.Applied != 0 { + t.Fatalf("第二次回执 = %+v, want 零命中零落地(候选已不在待定集)", second) + } + // 直接再 apply 同一条 ⇒ 条件更新没命中 ⇒ 如实报 already_decided,且**零副作用**。 + out, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}) + if err != nil { + t.Fatalf("重复 ApplyTidyCandidate: %v", err) + } + if out.Status != TidyApplySkipped || out.Reason != tidySkipAlreadyDecided { + t.Fatalf("重复应用结果 = %+v, want skipped/%s", out, tidySkipAlreadyDecided) + } + if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); !slices.Equal(got, settled) { + t.Fatalf("重复应用产生了第二次副作用:\n got=%v\nwant=%v", got, settled) + } +} + +// TestTidyAutoPerRoundLimitCountsReclassify 是护栏「每轮上限」对 reclassify 的覆盖: +// 三类动作**同占一个池子**,超限截断如实(EligibleTotal 是真总数),下一轮把剩下的 +// 排干 —— 绝不静默少做,也绝不因为动作不同而另开一份配额。 +func TestTidyAutoPerRoundLimitCountsReclassify(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + oneA := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9)) + oneB := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9)) + reA := seedWorldEventItem(t, store, fixtureWorkShort, base.Add(2*time.Minute)) + reB := seedWorldEventItem(t, store, fixtureWorkLong, base.Add(3*time.Minute)) + + // 顺序由 created_at 决定(最新在前):reclassify 两条排在 archive 两条之前。 + for _, c := range []TidyCandidate{ + autoArchiveCandidate(oneA, base), + autoArchiveCandidate(oneB, base), + autoReclassifyCandidate(reA, KindRelationship, "", base.Add(time.Minute)), + autoReclassifyCandidate(reB, KindRelationship, "", base.Add(time.Minute)), + } { + insertAutoCandidateOrFail(t, store, c) + } + + first, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 2}) + if err != nil { + t.Fatalf("第一轮 AutoApplyTidyCandidates: %v", err) + } + if first.EligibleTotal != 4 || first.Applied != 2 || !first.Truncated { + t.Fatalf("第一轮回执 = %+v, want eligible_total=4 applied=2 truncated=true", first) + } + if first.Reclassified != 2 || first.Archived != 0 { + t.Fatalf("第一轮动作分解 = %+v, want Reclassified=2(reclassify 也算进上限)", first) + } + if got := loadTidyItemRow(t, store, reA.ID); got.kind == string(KindWorldEvent) { + t.Fatalf("被算进上限的两条 reclassify 该落地:%+v", got) + } + if got := loadTidyItemRow(t, store, oneA.ID); got.status == tidyStatusArchived { + t.Fatalf("超出上限的 archive 不该被做:%+v", got) + } + + second, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 2}) + if err != nil { + t.Fatalf("第二轮 AutoApplyTidyCandidates: %v", err) + } + if second.EligibleTotal != 2 || second.Applied != 2 || second.Truncated { + t.Fatalf("第二轮回执 = %+v, want eligible_total=2 applied=2 truncated=false(下一轮排干)", second) + } + if second.Archived != 2 { + t.Fatalf("第二轮动作分解 = %+v, want Archived=2", second) + } + if got := countArchived(t, store, []string{oneA.ID, oneB.ID}); got != 2 { + t.Fatalf("两轮后归档 = %d, want 2", got) + } +} + +// TestTidyAutoSkipsRestrictedAndForeignReclassify 钉住两类「不许自动做的事」对 +// reclassify 同样成立:受限来源(群聊/陌生人,design §5.2-9/§6)与跨分区候选在 +// **执行面**被拒 —— 候选行是落库数据,可能来自更早的规则版本或手工改动。 +func TestTidyAutoSkipsRestrictedAndForeignReclassify(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + + group := seedTidyItem(t, store, MemoryItem{ + PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent, + Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base, + SourceConversation: FormatSourceConversation("sess-telegram:group1", "msg-9"), + }) + owner := seedWorldEventItem(t, store, fixtureWorkShort, base.Add(time.Minute)) + + restricted := autoReclassifyCandidate(group, KindRelationship, "", base) + foreign := autoReclassifyCandidate(owner, KindRelationship, "", base) + foreign.Scope = ScopeUser // 候选说 user 区,行在 persona 区 + for _, c := range []TidyCandidate{restricted, foreign} { + insertAutoCandidateOrFail(t, store, c) + } + + batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10}) + if err != nil { + t.Fatalf("AutoApplyTidyCandidates: %v", err) + } + if batch.EligibleTotal != 2 { + t.Fatalf("命中判据 = %d, want 2(两条都看似高置信)", batch.EligibleTotal) + } + if batch.Applied != 0 || batch.Skipped != 2 { + t.Fatalf("回执 = %+v, want applied=0 skipped=2(执行面的硬门拦住)", batch) + } + if got := loadTidyItemRow(t, store, group.ID); got.kind != string(KindWorldEvent) { + t.Fatalf("群聊来源被改了分类:%+v(design §5.2-9 红线)", got) + } + if got := loadTidyItemRow(t, store, owner.ID); got.kind != string(KindWorldEvent) { + t.Fatalf("跨分区候选改动了行:%+v", got) + } +} + +// TestTidyAutoDisabledSkipsReclassify 是护栏「一键回到只产建议」对 reclassify 的 +// 覆盖:开关关闭时它连候选都不碰,只产建议(与 merge/archive 同一口径)。 +func TestTidyAutoDisabledSkipsReclassify(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + bad := seedWorldEventItem(t, store, fixtureHabitShort, base) + seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)) + + cfg := tidyTestCfg(0.80) + cfg.DisableAutoApply = true + tr, rows := newAutoTestTidier(t, store, cfg, stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureHabitShort: {1, 0}, + fixtureHabitLong: simVector(0.95), + }}) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.AutoApplied != 0 || stats.AutoReclassified != 0 || stats.AutoEligible != 0 { + t.Fatalf("开关关闭却仍在自动落地:%+v", stats) + } + if got := loadTidyItemRow(t, store, bad.ID); got.kind != string(KindWorldEvent) { + t.Fatalf("开关关闭时分类被改了:%+v", got) + } + // 建议照旧产出(reclassify 候选在,只是等人点)。 + res := candidatesByAction(allTidyCandidates(t, store, 10), TidyReclassify) + if len(res) != 1 || res[0].Status != tidyCandidatePending { + t.Fatalf("reclassify 候选 = %+v, want 恰 1 条 pending 待人工", res) + } + if len(autoAuditRows(rows)) != 0 { + t.Fatalf("开关关闭时不该写自动档审计:%+v", *rows) + } +} + +// TestTidyAutoReclassifyNeverAdoptsProposedContent 是判据 ⑨ 的**边界**:reclassify +// 的授权范围只有「分类列」。候选里就算带着 proposed_content / merged_content 之类 +// 的正文(畸形行、手工塞进来的行),自动档也只采纳它的分类 —— 正文一个字都不许写。 +// +// ⚠️ 变异用例:把「自动落地时只改 kind」改成「连正文也写入」(例如让 +// reclassifyTidyItem 顺手把候选的 ProposedContent 写进 content)⇒ 本用例 RED。 +func TestTidyAutoReclassifyNeverAdoptsProposedContent(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + const strayContent = "候选想改成的正文(不许被采纳)" + const strayMerged = "候选想写成的合并后正文(不许被采纳)" + + row := seedWorldEventItem(t, store, fixtureWorkShort, base) + cand := autoReclassifyCandidate(row, KindRelationship, strayContent, base) + cand.MergedContent = strayMerged + insertAutoCandidateOrFail(t, store, cand) + before := loadTidyItemRow(t, store, row.ID) + + batch, err := store.AutoApplyTidyCandidates(ctx, TidyApplyOptions{Limit: 10}) + if err != nil { + t.Fatalf("AutoApplyTidyCandidates: %v", err) + } + if batch.Applied != 1 || batch.Reclassified != 1 { + t.Fatalf("回执 = %+v, want applied/reclassified = 1/1(动作本身照做)", batch) + } + after := loadTidyItemRow(t, store, row.ID) + if after.kind != string(KindRelationship) { + t.Fatalf("分类没被修:kind = %q, want %q", after.kind, KindRelationship) + } + if after.content != before.content { + t.Fatalf("自动落地把候选的正文写进了记忆:\n got=%q\nwant=%q(reclassify 无权改正文)", + after.content, before.content) + } + for _, poison := range []string{strayContent, strayMerged} { + if strings.Contains(after.content, poison) { + t.Fatalf("正文里出现了候选带的 %q —— 畸形候选的正文被采纳了", poison) + } + } + if after.status != before.status || after.mergedInto != before.mergedInto || + after.mergedFrom != before.mergedFrom || after.mergeCount != before.mergeCount { + t.Fatalf("reclassify 动了分类以外的列:%+v(before=%+v)", after, before) + } + if got := countMemoryItems(t, store); got != 1 { + t.Fatalf("行数 = %d, want 1", got) + } +} + +// TestTidyAutoReclassifyEligibilityBoundary 逐条钉住 reclassify 的**自动判据**本身 +// (纯函数,零 IO):它只认「形状完整 + 理由 kind_mismatch + 建议分类合法 + 有可回滚 +// 的改前 kind」四种条件同时成立的那些。判据在 tidy_auto.go 的 tidyAutoReclassifyKind。 +// +// 逐条反例的意义:这几条任何一条被放宽,自动档就会开始动**拿不准**或**没法回滚**的行。 +func TestTidyAutoReclassifyEligibilityBoundary(t *testing.T) { + t.Parallel() + valid := TidyCandidate{ + ID: "cand", PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent, + Action: TidyReclassify, SourceIDs: []string{"item-1"}, Reason: tidyReasonKindMismatch, + Evidence: TidyEvidence{KindA: string(KindWorldEvent), ProposedKind: string(KindRelationship)}, + } + cases := []struct { + name string + mut func(*TidyCandidate) + want bool + }{ + {"形状完整 ⇒ 自动", func(*TidyCandidate) {}, true}, + {"带了 proposed_content(畸形正文)仍只采纳分类 ⇒ 自动", func(c *TidyCandidate) { + c.ProposedContent = "候选想改的正文" + c.MergedContent = "候选想写的合并后正文" + }, true}, + {"缺 source ⇒ 不自动", func(c *TidyCandidate) { c.SourceIDs = nil }, false}, + {"两个 source ⇒ 不自动", func(c *TidyCandidate) { + c.SourceIDs = []string{"item-1", "item-2"} + }, false}, + {"指着第二条行(target_id 非空)⇒ 不自动", func(c *TidyCandidate) { + c.TargetID = "item-2" + }, false}, + {"理由不是 kind_mismatch ⇒ 不自动", func(c *TidyCandidate) { + c.Reason = "someone_else_reason" + }, false}, + {"建议分类非法 ⇒ 不自动", func(c *TidyCandidate) { + c.Evidence.ProposedKind = "topic" + }, false}, + {"建议分类为空 ⇒ 不自动", func(c *TidyCandidate) { + c.Evidence.ProposedKind = "" + }, false}, + {"改前 kind 缺失(没法回滚)⇒ 不自动", func(c *TidyCandidate) { + c.Evidence.KindA = "" + }, false}, + {"改前 kind 非法 ⇒ 不自动", func(c *TidyCandidate) { + c.Evidence.KindA = "topic" + }, false}, + {"建议值与改前值相同(无事可做)⇒ 不自动", func(c *TidyCandidate) { + c.Evidence.ProposedKind = c.Evidence.KindA + }, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := valid + tc.mut(&c) + got, reason := tidyAutoApplyEligible(c) + if got != tc.want { + t.Fatalf("tidyAutoApplyEligible = %t (%q), want %t", got, reason, tc.want) + } + if got && reason != tidyAutoReasonKindMismatch { + t.Fatalf("自动理由 = %q, want %q", reason, tidyAutoReasonKindMismatch) + } + if !got && reason != "" { + t.Fatalf("不该自动的候选却带了理由 %q(审计会把它记成落地理由)", reason) + } + }) + } +} + +// TestTidyAutoReclassifyBeforeCompanionMerge 钉住现实里最常见的那条组合路径:规则层在 +// **kind 不同**的一对上会同时产 1 条 merge + 1 条 reclassify(design/23 §12.1 P7), +// 两条都命中自动判据时必须在**同一轮**里都落地 —— 否则被吸收行先变成 'merged',那条 +// reclassify 就永远卡在待定集里(每轮被重新选中、又永远跳过,白吃一次每轮上限)。 +// +// 它同时是「reclassify 只改分类、merge 只软合并」两条护栏的合力验证:正文一字不动、 +// 行数不变、没有候选被留在待定集里。 +func TestTidyAutoReclassifyBeforeCompanionMerge(t *testing.T) { + t.Parallel() + ctx := context.Background() + store := newTestStore(t) + base := tidyTestTime(t) + misclassified := seedWorldEventItem(t, store, fixtureDupText, base) + other := seedTidyItem(t, store, habitItem(fixtureDupText, base.Add(time.Minute), 0.9)) + + tr, _ := newAutoTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ + fixtureDupText: {1, 0}, + }}) + stats, err := tr.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if stats.AutoApplied != 2 || stats.AutoMerged != 1 || stats.AutoReclassified != 1 { + t.Fatalf("自动档计数 = %+v, want applied/merged/reclassified = 2/1/1(两条都要落地)", stats) + } + if stats.AutoTruncated { + t.Fatalf("没打满上限不该报截断:%+v", stats) + } + for _, c := range allTidyCandidates(t, store, 10) { + if c.Status == tidyCandidatePending { + t.Fatalf("有候选被留在待定集里(每轮都会白吃一次上限):%+v", c) + } + } + absorbed := loadTidyItemRow(t, store, other.ID) + if absorbed.status != ingestStatusMerged || absorbed.mergedInto != misclassified.ID { + t.Fatalf("被吸收行 = %+v, want merged into %s", absorbed, misclassified.ID) + } + if absorbed.kind != string(KindWorldEvent) { + t.Fatalf("被吸收行的 kind = %q, want %q(reclassify 该在它被吸收之前落地)", + absorbed.kind, KindWorldEvent) + } + survivor := loadTidyItemRow(t, store, misclassified.ID) + if survivor.status != tidyStatusActive || survivor.content != fixtureDupText || + absorbed.content != fixtureDupText { + t.Fatalf("正文/状态被动过:survivor=%+v absorbed=%+v", survivor, absorbed) + } + if got := countMemoryItems(t, store); got != 2 { + t.Fatalf("行数 = %d, want 2(绝不物理删除)", got) + } +} diff --git a/internal/runtime/memory/tidy_auto_test.go b/internal/runtime/memory/tidy_auto_test.go index 6957224..c3f3040 100644 --- a/internal/runtime/memory/tidy_auto_test.go +++ b/internal/runtime/memory/tidy_auto_test.go @@ -10,60 +10,23 @@ import ( ) // 本文件锁定记忆整理的**轮末自动落地**(2026-10-07 主人口径「那么多 link 是干什么的? -// 该删就删,怎么这个记忆整理机制这么保守呢」;实现见 tidy_auto.go)。八条判据: +// 该删就删,怎么这个记忆整理机制这么保守呢」+ 同日追加「全自动挡吧」;实现见 +// tidy_auto.go)。判据: // // ① 字面重复(两侧逐字相同)⇒ 自动 merge; // ② 事实集合等价 ∧ 相似度 ≥ 0.95 ⇒ 自动 merge; // ③ 低置信 ⇒ 不自动(候选在**规则层**就被否决,自动档根本看不到它); // ④ 一次性事件 ⇒ 自动 archive; -// ⑤ 习惯类被钟点误判成一次性事件 ⇒ **不**自动 archive(红线守卫); -// ⑥ 每轮上限生效、超限如实截断,下一轮继续排干; +// ⑤ 习惯类被钟点误判成一次性事件 ⇒ **不**自动 archive(红线守卫,不许动); +// ⑥ 每轮上限生效、超限如实截断,下一轮继续排干(三类动作同占一个池子); // ⑦ 开关关闭 ⇒ 回到只产建议(一条都不落地); -// ⑧ revert 能回滚自动落地的那一条(merge 与 archive 各一次)。 +// ⑧ revert 能回滚自动落地的那一条(merge 与 archive 各一次); +// ⑨ kind 误分类 ⇒ 自动 reclassify(实现见 TestTidyAutoReclassifyKindMismatch), +// 只改 kind 一列:**正文一字不动**、**回滚能把 kind 复原**、**畸形候选里的 +// proposed_content 一律不采纳**(TestTidyAutoReclassifyNeverAdoptsProposedContent)。 // -// 外加两条「不许自动做的事」:真子集合并(非字面重复/等价)不自动;跨分区与 -// 群聊来源的候选在**执行面**被拒。 - -const ( - // fixtureHabitClock 是「被钟点误判」的典型:正文里带绝对时刻(23:30)⇒ 规则层 - // 会把它当一次性事件产 archive 候选,但它明明白白是**作息习惯**(「常在」「习惯」)。 - fixtureHabitClock = "主人常在深夜23:30后还醒着,有熬夜习惯" - // fixtureDupText 是字面重复用例的正文(两侧逐字相同)。 - fixtureDupText = "主人喜欢在深夜喝冰美式" - // fixtureEquivA / fixtureEquivB 是**事实集合等价但不同措辞**的一对 - // (关键要素逐一互相包含 ⇒ containBoth,正文不同 ⇒ 不是字面重复)。 - fixtureEquivA = "主人喜欢喝红牛" - fixtureEquivB = "红牛,主人喜欢喝" -) - -// newAutoTestTidier 构建整理器并把审计出口捕获下来(自动档的审计是断言面之一)。 -func newAutoTestTidier(t *testing.T, store *Store, cfg TidyConfig, emb embedding.Embedder) (*Tidier, *[]tidyAuditRow) { - t.Helper() - rows := make([]tidyAuditRow, 0) - tr, err := NewTidier(cfg, TidyDeps{ - Store: store, - Embedder: emb, - Log: testLogger(), - Audit: func(who, what, result string) { - rows = append(rows, tidyAuditRow{who: who, what: what, result: result}) - }, - }) - if err != nil { - t.Fatalf("NewTidier: %v", err) - } - return tr, &rows -} - -// autoAuditRows 返回捕获到的 memory.tidy.apply(自动档)审计行。 -func autoAuditRows(rows *[]tidyAuditRow) []tidyAuditRow { - out := make([]tidyAuditRow, 0) - for _, r := range *rows { - if r.what == tidyAutoApplyVerb { - out = append(out, r) - } - } - return out -} +// 外加几条「不许自动做的事」:真子集合并(非字面重复/等价)不自动;跨分区与 +// 群聊/陌生人来受限来源的候选在**执行面**被拒(三类动作一视同仁,reclassify 也一样)。 // TestTidyAutoMergeLiteralDuplicate 是判据 ①:两侧正文逐字相同 ⇒ 候选由轮末自动档 // 直接落地(软合并),且落地时**只动 status/merged_***,绝不改正文、绝不删行。 @@ -467,7 +430,7 @@ func TestTidyAutoSkipsRestrictedSourceAndForeignPartition(t *testing.T) { ID: mustNewTidyID(), PersonaID: owner.PersonaID, Scope: ScopeUser, Kind: owner.Kind, Action: TidyMerge, SourceIDs: []string{owner.ID}, TargetID: group.ID, ProposedContent: owner.Content, MergedContent: group.Content, - Reason: tidyReasonFactSubset, + Reason: tidyReasonFactSubset, Evidence: TidyEvidence{ Similarity: 1, Containment: string(containBoth), ContentA: owner.Content, ContentB: owner.Content, @@ -497,15 +460,3 @@ func TestTidyAutoSkipsRestrictedSourceAndForeignPartition(t *testing.T) { t.Fatalf("跨分区候选改动了行:%+v", got) } } - -// countArchived 数给定 id 里已归档的行数。 -func countArchived(t *testing.T, store *Store, ids []string) int { - t.Helper() - n := 0 - for _, id := range ids { - if loadTidyItemRow(t, store, id).status == tidyStatusArchived { - n++ - } - } - return n -} diff --git a/internal/runtime/memory/tidy_candidate.go b/internal/runtime/memory/tidy_candidate.go index c0a3737..9c0008c 100644 --- a/internal/runtime/memory/tidy_candidate.go +++ b/internal/runtime/memory/tidy_candidate.go @@ -83,7 +83,10 @@ const ( // tidyReasonOneOffEvent 是一次性事件的归档理由,**也是自动档唯一认的归档理由**: // 理由不同(例如将来新增的归档类别)就不在新口径的授权范围内,交人工。 tidyReasonOneOffEvent = "one_off_event" - // tidyReasonKindMismatch 是 kind 误分类的理由(只产候选,不自动改)。 + // tidyReasonKindMismatch 是 kind 误分类的理由(design/23 §5.1 T0-6)。2026-10-07 + // 「全自动挡吧」起它**也是自动档认的 reclassify 理由**(判据还要形状完整 + + // 可回滚的改前 kind,见 tidy_auto.go 的 tidyAutoReclassifyKind)—— 只改分类列, + // 不碰正文。 tidyReasonKindMismatch = "kind_mismatch" ) @@ -157,8 +160,9 @@ func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate out := []TidyCandidate{base} if absorbed.Kind != target.Kind { - // kind 误分类:以存活条目(超集)的 kind 为建议值,交人工确认 - // (design/23 §5.1 T0-6「取多数票或交人工,不自动改」)。 + // kind 误分类:以存活条目(超集)的 kind 为建议值(design/23 §5.1 T0-6)。 + // 2026-10-07「全自动挡吧」起,形状完整的这类候选由**轮末自动档**直接落地 + // (只改分类列;判据见 tidy_auto.go),不完整的仍交人工。 re := base re.ID = mustNewTidyID() re.Action = TidyReclassify diff --git a/internal/runtime/memory/tidy_service.go b/internal/runtime/memory/tidy_service.go index c05b07c..a3f7fef 100644 --- a/internal/runtime/memory/tidy_service.go +++ b/internal/runtime/memory/tidy_service.go @@ -351,10 +351,11 @@ func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, func (s *TidyService) audit(stats TidyStats, trigger string) { hook := s.tidier.deps.Audit result := fmt.Sprintf( - "trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d kept=%d duration_ms=%d by_action=%s rejected_reasons=%s auto_applied=%d auto_merge=%d auto_archive=%d auto_eligible=%d auto_truncated=%t", + "trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d kept=%d duration_ms=%d by_action=%s rejected_reasons=%s auto_applied=%d auto_merge=%d auto_archive=%d auto_reclassify=%d auto_eligible=%d auto_truncated=%t", trigger, stats.RunID, stats.Scanned, stats.Candidates, stats.Duplicates, stats.Vetoed, stats.Kept, stats.DurationMS, tidyActionLabel(stats.ByAction), tidyReasonsLabel(stats.VetoReasons), - stats.AutoApplied, stats.AutoMerged, stats.AutoArchived, stats.AutoEligible, stats.AutoTruncated) + stats.AutoApplied, stats.AutoMerged, stats.AutoArchived, stats.AutoReclassified, + stats.AutoEligible, stats.AutoTruncated) if hook == nil { s.log.Info("memory tidy audit (no sink configured)", "what", "memory.tidy.run", "result", result) return diff --git a/internal/runtime/memory/tidy_types.go b/internal/runtime/memory/tidy_types.go index 28737fb..742793c 100644 --- a/internal/runtime/memory/tidy_types.go +++ b/internal/runtime/memory/tidy_types.go @@ -48,14 +48,16 @@ type TidyStats struct { // 多少对」在口径上彻底消失,面板会把「没有建议」与「有 300 对不值得动」 // 读成同一件事。 Kept int - // AutoEligible / AutoApplied / AutoMerged / AutoArchived / AutoTruncated 是轮末 - // 自动档(2026-10-07 主人口径「该删就删」)的观测口径:Eligible 是命中自动判据 - // 的**真总数**(含因每轮上限没处理的),Applied 是这一轮真的落地的条数。 - AutoEligible int - AutoApplied int - AutoMerged int - AutoArchived int - AutoTruncated bool + // AutoEligible / AutoApplied / AutoMerged / AutoArchived / AutoReclassified / + // AutoTruncated 是轮末自动档(2026-10-07 主人口径「该删就删」+「全自动挡吧」)的 + // 观测口径:Eligible 是命中自动判据的**真总数**(含因每轮上限没处理的), + // Applied 是这一轮真的落地的条数。 + AutoEligible int + AutoApplied int + AutoMerged int + AutoArchived int + AutoReclassified int + AutoTruncated bool // ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计, // 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。 ByAction map[string]int diff --git a/internal/runtime/memory/tidy_veto_test.go b/internal/runtime/memory/tidy_veto_test.go index 0647f27..1746d46 100644 --- a/internal/runtime/memory/tidy_veto_test.go +++ b/internal/runtime/memory/tidy_veto_test.go @@ -80,7 +80,11 @@ func TestTidyLowConfidenceVetoed(t *testing.T) { } // TestTidyKindMismatchAlsoSuggestsReclassify 是 design/23 §12.1 P7 的口径: -// 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选(都交人工)。 +// 包含关系成立但 kind 不同 ⇒ 既产 merge 候选,也产 reclassify 候选。 +// +// 2026-10-07「全自动挡吧」起两条的**归宿不同**:这里的 merge 是真子集(不在自动档 +// 判据内)⇒ 留待人工;reclassify 形状完整 ⇒ 由轮末自动档当场落地(只改 kind 那一行)。 +// 因此候选要按**全部状态**读,只看待办队列会把「已经自动改完了」读成「没产出」。 func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) { t.Parallel() ctx := context.Background() @@ -94,7 +98,7 @@ func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) { PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Content: fixtureHabitLong, Confidence: 0.9, CreatedAt: base.Add(time.Hour), } - seedTidyItem(t, store, short) + seededShort := seedTidyItem(t, store, short) seedTidyItem(t, store, long) tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{ @@ -104,13 +108,24 @@ func TestTidyKindMismatchAlsoSuggestsReclassify(t *testing.T) { if _, err := tr.RunOnce(ctx); err != nil { t.Fatalf("RunOnce: %v", err) } - cands, err := store.ListTidyCandidates(ctx, "", 50) - if err != nil { - t.Fatalf("ListTidyCandidates: %v", err) - } - if len(candidatesByAction(cands, TidyMerge)) != 1 || len(candidatesByAction(cands, TidyReclassify)) != 1 { + cands := allTidyCandidates(t, store, 50) + merges := candidatesByAction(cands, TidyMerge) + reclasses := candidatesByAction(cands, TidyReclassify) + if len(merges) != 1 || len(reclasses) != 1 { t.Fatalf("期望 1 条 merge + 1 条 reclassify,实际 %+v", cands) } + if reclasses[0].Status != tidyCandidateApplied { + t.Fatalf("shape 完整的 reclassify 候选该被自动档落地:%+v", reclasses[0]) + } + if merges[0].Status != tidyCandidatePending { + t.Fatalf("真子集 merge 不在自动档判据内,该留待人工:%+v", merges[0]) + } + if got := loadTidyItemRow(t, store, seededShort.ID); got.kind != string(KindRelationship) { + t.Fatalf("被误分类的行 kind = %q, want %q(自动档只改分类列)", got.kind, KindRelationship) + } + if got := loadTidyItemRow(t, store, seededShort.ID); got.content != fixtureHabitShort { + t.Fatalf("reclassify 改了正文:%q", got.content) + } } // TestTidyCandidateLimitPerPartition 锁定 design/23 §9 的每分区候选上限: