Files
Cyrene_v2/internal/runtime/memory/extract_retention_transient_test.go
T
AskaEth 6a3f3a329a feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)
M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
2026-10-25 00:08:46 +08:00

217 lines
12 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"
)
// 本文件是留存闸 **N6(指示性时点)/ N7(进行中状态)** 与**组合护栏②**
// (2026-10-24,M34)的验收用例集。
//
// 两条不对称的事实决定了这个文件的写法:
// - 漏判只是「库里多一条噪声」(T1 与整理会兜底);
// - 误判会把一条真记忆丢进临时层 —— 7 天后从所有读路径消失,再被后台清扫
// **物理删除**(不可逆)。
//
// 因此这里的**正例(必须放行)比负例(必须拦下)更硬**:`retentionLongTermCases`
// 里的每一条都是真实语料里「带时点词但仍然长期成立」的句子,它们逐条钉住
// 「误杀真实长期记忆比漏放新记忆更严重」这条口径。
// retentionTimepointCases 是 N6 的正例:**锚点是一个正在过去/即将到来的时刻**,
// 且没有习惯词、没有硬性长期事实词、没有数值/约束 —— 它们该进临时层。
func retentionTimepointCases() []string {
return []string{
"用户昨天发送了一份长达一万两千行的验收纸条",
"用户近期在测试一条通道,今天来回测了三轮",
"用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」",
"计划下周为主人留一个安静的午后",
"用户今晚睡宿舍,不在家",
// 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。
"用户与助理约定明天中午见",
}
}
// retentionLongTermCases 是**回归护栏**:真实语料里「带时点词但长期成立」的句子,
// 一条都不许判 defer。每一行都对应一次实测误杀(谁把它们放进来,就是谁在丢记忆)。
func retentionLongTermCases() []struct {
name string
content string
} {
return []struct {
name string
content string
}{
// 时点词埋在**引语/从句**里,不是这句话的锚点(位置安全阀)。
{"上班族习惯(引语里的明天)", "用户是上班族,需要上班(曾说明天要上班并感到不开心)"},
{"睡前习惯(从句里的明天见)", "用户睡前会道「晚安、明天见」,人家会替她掀被角、调暗灯光,用数呼吸的方式陪她入睡"},
{"单位排班事实(引语里的今天)", "主人所在单位存在周末/节假日被安排加班的情况,实际排班与公开调休日历并不一致(她自己说「我们今天加班啦」)"},
// 习惯词/硬性长期事实词(地点、职业、住)⇒ 一律长期,标题里带「今天」也不许判临时。
{"地点事实", "用户目前生活在花溪(贵阳花溪区),不在上海上班"},
{"住宿舍(近期)", "用户近期住在宿舍(不在家里),晚间会关心她洗漱水温、睡觉时间等宿舍生活细节"},
{"作息习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
{"长期偏好", "主人喜欢喝冰美式来提神"},
{"称呼约定", "主人希望被称呼为「叶酱」;旧的称呼「喵喵七号」已作废,不要再使用"},
{"关系里程", "用户曾向 Cyrene 发送内容为「i love you」的文件,Cyrene 完整接收并回复「me too」"},
{"人格设定", "助手设定:曾经历三千万次轮回等待用户,并在「记忆之海」中捞回用户的记忆"},
{"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"},
// 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。
{"情绪倾向", "主人最近很容易焦虑"},
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
}
}
// TestExtractionRetention_TimepointIsDeferred 钉住 N6 的正例。
func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
t.Parallel()
for _, content := range retentionTimepointCases() {
if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress {
t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)",
content, got, retentionTimepoint)
}
}
}
// TestRetentionTimepointHitsBySpecificRule 钉住「命中哪一条」:时点归 N6、进行中归 N7。
// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。
func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress)
}
}
// TestExtractionRetention_RealLongTermFactsSurviveTimepoints 是**误杀回归**:
// 真实长期事实即使带着时点词也必须放行。任何一条变红都意味着「7 天后它会消失」。
func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) {
t.Parallel()
for _, tc := range retentionLongTermCases() {
if got := judgeExtractionRetention(tc.content); got != retentionNone {
t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)",
tc.content, tc.name, got)
}
}
}
// TestExtractionRetention_TimepointMustBeFronted 钉住**位置安全阀**:同一个句子,
// 时点在句首 → defer;把同一时点挪到从句里 → 放行。位置是这条规则精度的来源。
func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
t.Parallel()
fronted := "用户昨天发来一卷验收纸条"
buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)"
if got := judgeExtractionRetention(fronted); got != retentionTimepoint {
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
}
if got := judgeExtractionRetention(buried); got != retentionNone {
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
}
}
// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**:
// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排);
// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时,
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
t.Parallel()
if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
got, retentionTimepoint)
}
if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone {
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
}
if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone {
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
}
if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone {
t.Fatalf("「禁忌」= %q, want 放行", got)
}
}
// TestExtractionRetention_TimepointSafetyValves 逐条钉住 N6/N7 的四个安全阀:
// 习惯词 / 硬性长期事实词 / 数值与约束 / 寒暄与情绪词都把它们按回去。
//
// 断言写成「**不是 N6/N7**」而不是「必须放行」是刻意的:安全阀的职责只有一个 ——
// 不让这条句子被 N6/N7 判成临时层。它可能仍被更早的既有规则(N1/N2)判成临时,
// 那是**既有口径**(本条用例不重定义它),也不是回归。
func TestExtractionRetention_TimepointSafetyValves(t *testing.T) {
t.Parallel()
cases := []struct {
content string
why string
}{
{"用户今天又被加班拖住了,她通常十点后才有空", "习惯词(通常)⇒ 反复发生"},
{"用户今天要去医院复查过敏", "硬性长期事实词(过敏)"},
{"用户昨天对花生过敏犯了,吃了药", "硬性长期事实词(过敏 / 吃药)"},
{"用户昨天发了 3 份文件", "数值是事实的载体"},
{"用户最近每天要喝两杯水", "约束词(每天)"},
{"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"},
}
for _, tc := range cases {
got := judgeExtractionRetention(tc.content)
if got == retentionTimepoint || got == retentionInProgress {
t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why)
}
}
}
// TestIngestGate_TimepointDefersWithTempTTL 钉住 TTL 的落点:N6 命中 ⇒ 第五态
// `defer`,行照常 active 落库(正文一字不动),`expires_at` = 判定时刻 + 临时 TTL
// (默认 7 天,落 `$DATA_DIR/config/memory-tidy.json` 的 `tempTtlSec`,零新 env 键)。
func TestIngestGate_TimepointDefersWithTempTTL(t *testing.T) {
t.Parallel()
store := newTestStore(t)
base := tidyTestTime(t)
ttl := 7 * 24 * time.Hour
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
store.AttachIngestReviewer(r)
got := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base))
if exp, want := readIngestExpiry(t, store, got.ID), expiryText(base.Add(ttl)); exp != want {
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 7 天)", exp, want)
}
row := loadTidyItemRow(t, store, got.ID)
if row.status != tidyStatusActive || row.content != "用户昨天发来一卷一万两千行的验收纸条" {
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
}
rev := gateRecordFor(t, store, got.ID)
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
}
}
// TestIngestGate_DeferNeverBackfillsExistingRows 钉住「**TTL 只开新增侧**」:
// 存量行的 `expires_at` 一条都不补写。变异(在 defer 时顺手 UPDATE 整个分区)会红。
func TestIngestGate_DeferNeverBackfillsExistingRows(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
// 存量行在**接入闸之前**落库:这条用例要看的是「闸跑一轮之后,存量行的
// expires_at 仍然为空」,而不是「存量行当初怎么落的库」。
existing := seedTidyItem(t, store, gateFixtureItem("主人有不吃早饭的习惯,需要提醒每天吃热乎的早饭", base))
r := newGateReviewer(t, store, ReviewConfig{TempTTL: 7 * 24 * time.Hour}, nil)
store.AttachIngestReviewer(r)
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
t.Fatalf("存量行 expires_at = %q, want 空串", exp)
}
fresh := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base.Add(time.Minute)))
if exp := readIngestExpiry(t, store, fresh.ID); exp == "" {
t.Fatalf("新行没被写上 expires_at —— N6 没有生效")
}
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
t.Fatalf("存量行被补写了 expires_at = %q —— TTL 只许开新增侧", exp)
}
var withExpiry int
if err := store.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM memory_items WHERE expires_at <> ''`).Scan(&withExpiry); err != nil {
t.Fatalf("count expires_at: %v", err)
}
if withExpiry != 1 {
t.Fatalf("带 expires_at 的行 = %d, want 恰为 1(只有那条新记忆)", withExpiry)
}
}