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

+86 -14
View File
@@ -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