feat(memory): reclassify 纳入自动档(主人 2026-10-07「全自动挡吧」)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

自动落地现在三类:merge(字面重复 ∨ 事实等价 ∧ ≥0.95)· archive(one_off_event
且不像习惯)· **reclassify(新增)**。reclassify 只改 `kind` 一列
(`reclassifyTidyItemSQL` 只写 kind+updated_at),**不动正文、不动作用域、不碰第二条行**。

判据(纯函数 `tidyAutoReclassifyKind`,四个条件全中才自动):
① 恰 1 个 source 且无 target;② 理由 = `kind_mismatch`;③ 建议 kind 合法;
④ **改前 kind 合法且 ≠ 建议值** —— ③④ 是 **revert 抓手**:没有它自动落地就不可回滚。
**边界**:只读 ProposedKind / KindA,**一概不读** ProposedContent / MergedContent /
ContentA/B / 分区 / 时间字段。

六条护栏逐条覆盖它:软动作(同一条 apply 路径)/ 每轮上限(三类同占一个池子,
默认 50 硬顶 500,混合时截断如实)/ 审计 `memory.tidy.apply`(`auto=true`,
`reclassified=%d` + stats + 日志 + run 审计,**只带计数与理由、不带正文**)/
**可 revert**(`revertTidyReclassify` 按 KindA 写回,且只在当前 kind 仍等于建议值时生效)/
幂等(候选条件更新 + 行 `AND status='active'` 双闸)/ 开关关闭 ⇒ 连查询都不发。
另补一道本片新增的门:**受限来源**(群聊/陌生人)与跨分区的 reclassify **skipped**
(原先只有 merge/archive 有这道门)。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + vet 绿;memory/gateway 两包
测试绿;我用 -overlay 禁掉「必须真的变了」那一格 ⇒ `…EligibilityBoundary` **3 条子例变红**
(改前 kind 缺失/非法/与建议值相同 ⇒ 都"没法回滚")。子代理另做 4 个变异(连正文一起写 /
摘受限来源门 / 去掉 reclassified 计数 / 不要求可回滚抓手)全红。

🔴 **未动的红线(一行未改)**:`archive` 的「习惯类不归档」守卫(design/23 §5.2-1)
—— `looksLikeHabit` / `tidyHabitMarkers` / `tidyReasonOneOffEvent` 零改动,
`TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff` 仍绿。要连它也自动请主人明说。

⚠️ 未部署(要一次 build + restart);⚠️ design/23 的 §7「reclassify 交人工」与
§13 开放点 5「全部人工应用」**现已过期**,主会话同步。
⚠️ 已知遗留(需新授权):历史积压里指向**已被吸收行**的 reclassify 候选会每轮被选中
又永远 `source_not_active` 跳过,白占一次每轮名额;彻底解法是「永久不可用的候选自动
标 rejected」(新行为)。
This commit is contained in:
AskaEth committed 2026-10-07 11:26:34 +08:00
1 parent 6fe68ecfac
commit f310173b4c
11 files changed
+757 -98

No files matched your search

+22 -7
View File
@@ -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 的每分区候选上限: