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

@@ -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,
}
}