feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。

① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
   `applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
   - 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
   - `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
   - 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
     (**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
   - `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
   - `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
     习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
   - **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
     (群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
   超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
   **可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
   (`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。

验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。

⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
This commit is contained in:
AskaEth committed 2026-10-07 11:07:00 +08:00
1 parent 353293e807
commit 6d92536276
27 files changed
+2063 -197

No files matched your search

+23 -2
View File
@@ -27,6 +27,9 @@ import (
// (design/23 §9 的失败隔离同口径);
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
// 🔴 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go);
// 这条分支留着是给**库里既有的** link 行用:它们仍要能被如实判为「不可应用」,
// 并由 RejectTidyCandidatesByAction 批量拒绝(tidy_reject.go)。
//
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。
@@ -54,6 +57,11 @@ const (
tidySkipActionNotApplyable = "action_not_applyable"
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
tidySkipSourceNotActive = "source_not_active"
// tidySkipSourceRestricted 表示候选指向的记忆行来自群聊/陌生人等**只读**来源
// (design/23 §5.2-9 + §6)。规则层本就把这类条目排除在扫描外,这里是纵深防御:
// 候选行是落库数据,可能来自更早的规则版本或手工改动,而「整理不得改写这类来源」
// 是红线而不是权衡 —— 宁可如实报 skipped,也不动那一行。
tidySkipSourceRestricted = "restricted_source"
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
tidySkipSourceMissing = "source_missing"
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
@@ -154,7 +162,8 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
AffectedIDs: []string{},
}
if !cand.Action.applyable() {
// link / keep 没有执行语义(design/23 §14):如实说「不可应用」,不写任何行。
// link / keep 没有执行语义(design/23 §14;2026-10-07 起 link 也不再产出,
// 这里兜的是库里既有的 link 行):如实说「不可应用」,不写任何行。
return skipTidyApply(base, tidySkipActionNotApplyable), nil
}
now := time.Now().UTC()
@@ -170,7 +179,9 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
}
}
// applyable 报告一个动作是否有执行语义。
// applyable 报告一个动作是否有执行语义。link/keep **永远**返回 false:
// 2026-10-07 起规则层已不产 link(无落点,见 tidy_rules.go),这条判据只服务
// 库里既有的历史行与「不许假装做过」的诚实口径。
func (a TidyAction) applyable() bool {
switch a {
case TidyMerge, TidyArchive, TidyReclassify:
@@ -223,6 +234,11 @@ func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts T
if skip := tidyPartitionMismatch(cand, item); skip != "" {
return skipTidyApply(base, skip), nil
}
if nonOwnerSource(item.SourceConversation) {
// 只读来源(群聊/陌生人)绝不归档(design/23 §5.2-9/§6):纵深防御,
// 规则层本就把这类条目排除在扫描范围外。
return skipTidyApply(base, tidySkipSourceRestricted), nil
}
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
return archiveTidyItem(ctx, tx, item.ID, now)
})
@@ -286,6 +302,11 @@ func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (Memo
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
return MemoryItem{}, MemoryItem{}, skip, nil
}
// 只读来源硬门(design/23 §5.2-9/§6):两侧任一来自群聊/陌生人都不合。
// 规则层已把这类条目排除在扫描外,这里是纵深防御。
if nonOwnerSource(absorbed.SourceConversation) || nonOwnerSource(survivor.SourceConversation) {
return MemoryItem{}, MemoryItem{}, tidySkipSourceRestricted, nil
}
if absorbed.ID == survivor.ID {
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
}