Files
Cyrene_v2/internal/runtime/memory/tidy_auto_harness_test.go
T
AskaEth f310173b4c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): reclassify 纳入自动档(主人 2026-10-07「全自动挡吧」)
自动落地现在三类: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」(新行为)。
2026-10-07 11:26:34 +08:00

117 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}