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

+5 -1
View File
@@ -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,
}
+15 -2
View File
@@ -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 更像「无需再做」。
+7 -1
View File
@@ -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),
}})
+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
@@ -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,
}
}
@@ -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)
}
}
+11 -60
View File
@@ -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
}
+7 -3
View File
@@ -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
+3 -2
View File
@@ -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
+10 -8
View File
@@ -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
+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 的每分区候选上限: