package memory import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集,逐条对应 // 任务书的验收点: // // ① `one_off` 的夹具语料 ⇒ 全链落库 `(temp, 非空 expires_at)`; // ② `durable` / `recurring` ⇒ `(long, 空)`; // ③ `unknown` 且内容像一次性 ⇒ **仍按旧规则**(本条不含新的猜测:命中的规则码 // 必须是 N* 词项规则的码,绝不是时间性位的码); // ④ 模型返回非法值 / 空值 ⇒ 归 `unknown`,且**不报错**、不拖累同批候选; // ⑤ 变异:把 temporalVerdict 的 one_off 支路摘掉 ⇒ ① 必红(下面三条 ① 的断言 // 都以「档位是 temp」为判据,摘掉支路后它们全部退化成 long)。 // // 🔴 这份语料的一半(`one_off` 那三条)**刻意挑成旧规则的非命中形态**:它们在 // `Temporal` 为空时判 long、带上 `one_off` 时判 temp —— 于是「这一位真的改了什么」 // 可以被单独观察,不会与 N1~N7 的效果混在一起(M37 的实测缺口正是「分不清是谁判的」)。 // temporalOneOffCorpus 是一次性事件的夹具语料:长句陈述形态、无句首时点词、 // **不含约定/承诺词**(含约定词的句子由 retentionStatesAgreement 挡回旧规则, // 见 TestTemporalOneOffDoesNotOverrideStatedAgreement)—— 正是 M37 复盘点名的 // 「一次性事件被写成 30~80 字第三人称陈述」那种形态。 func temporalOneOffCorpus() []string { return []string{ "桌面上的文件整理到一半被别的事打断了", "傍晚出门走十分钟的散步这一回没走成", "那条链路断线是主人自己排查到根因的", } } // readTemporalColumn 直读一行的 `temporal` 列(该列与 expires_at 一样是**只写不读** // 的列:四条列表读路径的 SELECT 不带它,见 store.go 的 Temporal 注释)。 func readTemporalColumn(t *testing.T, store *Store, id string) TemporalKind { t.Helper() var raw string if err := store.db.QueryRowContext(context.Background(), `SELECT temporal FROM memory_items WHERE id = ?`, id).Scan(&raw); err != nil { t.Fatalf("read temporal column %s: %v", id, err) } return TemporalKind(raw) } // TestTemporalOneOffLandsTempWithExpiry 钉住验收点①(也是变异点⑤的靶子): // 时间性位 `one_off` ⇒ 全链落库 `(temp, 非空 expires_at)`,且这一位真的写进了 // `temporal` 列(判定与留痕不分叉)。 // // 「全链」= 与生产同一条写路径:`Store.Add`(唯一 INSERT 路径)→ resolveRetention // → planIngest → 一笔 INSERT。测试**不绕开** Add,也不用任何测试专用的旁路。 func TestTemporalOneOffLandsTempWithExpiry(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) for _, content := range temporalOneOffCorpus() { content := content t.Run(content, func(t *testing.T) { // 先证明这段正文**不含** N1~N7 的命中:否则本用例就分不清是规则还是 // 时间性位判的 temp(M37 的缺口就是分不清)。 bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) if err != nil { t.Fatalf("resolveRetention(bare): %v", err) } if bare.Tier != RetentionLong { t.Fatalf("夹具语料 %q 在旧规则下就判成 %q(want long)—— 这条夹具证明不了时间性位的作用,换一条", content, bare.Tier) } item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add(one_off): %v", err) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTemp { t.Fatalf("%q with temporal=one_off 落库档位 = %q, want %q", content, tier, RetentionTemp) } if expires == "" { t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", content) } if got.ExpiresAt.IsZero() { t.Fatalf("%q 的 Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)", content) } if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { t.Fatalf("%q 的 temporal 列 = %q, want %q(判定与留痕不许分叉)", content, nat, TemporalOneOff) } }) } } // TestTemporalOneOffDefersThroughGate 钉住「判定接线」:闸开着时,`one_off` 判出的 // temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),而不是另开一条路径。 // // 本用例同时是「不要造第二个判定入口」的观测点:`judgeIngest` 只读档位,因此这里 // 除了档位列以外不额外断言任何规则重判的痕迹 —— 重判会让 // TestRetentionJudgementHasSingleRuleCallSite 先红。 func TestTemporalOneOffDefersThroughGate(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) ttl := time.Hour r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil) store.AttachIngestReviewer(r) item := habitItem(temporalOneOffCorpus()[0], base, 0.9) item.Temporal = TemporalOneOff got, err := store.Add(context.Background(), item) if err != nil { t.Fatalf("Add: %v", err) } rev := gateRecordFor(t, store, got.ID) if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason { t.Fatalf("one_off 条目的留痕 = %+v, want defer + %q", rev, retentionLowValueReason) } if rev.RuleVer != IngestRuleVersion { t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTemp { t.Fatalf("one_off 条目档位 = %q, want %q", tier, RetentionTemp) } // 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。 if want := expiryText(base.Add(ttl)); expires != want { t.Fatalf("expires_at = %q, want %q", expires, want) } } // TestTemporalDurableAndRecurringStayLong 钉住验收点②:`durable` / `recurring` ⇒ // `(long, 空)`,**并且覆盖规则闸的 temp 结论**。 // // 语料故意取一条旧规则会判 temp 的正文(N6 指示性时点):「模型已经答了这不是 // 一次性事件」时,规则不许把它改判成临时层 —— 方向是 fail-safe(多留一条), // 与「规则闸拦下的不许被 T1 推翻」并不矛盾:那一条防的是**放宽**(模型把规则判成 // 临时的改成长期),方向相反。 func TestTemporalDurableAndRecurringStayLong(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) const content = "用户昨天发来一卷验收纸条" // 前提:这条正文在旧规则下确实判 temp(否则本用例证明不了「覆盖」)。 bare, err := resolveRetention(habitItem(content, tidyTestTime(t), 0.9)) if err != nil { t.Fatalf("resolveRetention(bare): %v", err) } if bare.Tier != RetentionTemp || bare.Rule != retentionTimepoint { t.Fatalf("前提不成立:%q 旧规则判 %q/%q,want temp/%q", content, bare.Tier, bare.Rule, retentionTimepoint) } for _, temporal := range []TemporalKind{TemporalDurable, TemporalRecurring} { temporal := temporal t.Run(string(temporal), func(t *testing.T) { item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = temporal got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add(%q): %v", temporal, err) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionLong { t.Fatalf("temporal=%q 落库档位 = %q, want %q", temporal, tier, RetentionLong) } if expires != "" { t.Fatalf("temporal=%q 写了 expires_at = %q(非 temp 档必须为空)", temporal, expires) } if nat := readTemporalColumn(t, store, got.ID); nat != temporal { t.Fatalf("temporal 列 = %q, want %q", nat, temporal) } }) } } // TestTemporalUnknownFallsThroughToRules 钉住验收点③,也是最容易做偏的一条: // `unknown`(以及未分型的空串)**不许自己判 temp** —— 它必须原样交给 N1~N7, // 判据只有旧规则那一份。 // // 三条断言方向不同,缺一条都测不出「方向反了」: // // 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:命中的 // 规则码必须是 `n6_timepoint`,**绝不能**是时间性位的 `temporal_one_off` —— // 后者出现就说明有人把「看起来像今日事」变成了新的判据; // 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp); // 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。 func TestTemporalUnknownFallsThroughToRules(t *testing.T) { t.Parallel() ctx := context.Background() t.Run("unknown_旧规则命中", func(t *testing.T) { item := habitItem("今天下雨了", tidyTestTime(t), 0.9) item.Temporal = TemporalUnknown verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention: %v", err) } if verdict.Tier != RetentionTemp { t.Fatalf("unknown + 「今天下雨了」= %q, want %q(旧规则 N6 的结论)", verdict.Tier, RetentionTemp) } if verdict.Rule != retentionTimepoint { t.Fatalf("命中的规则 = %q, want %q —— 若出现 %q 说明 unknown 被当成了「一次性」的新判据", verdict.Rule, retentionTimepoint, retentionTemporalTempRule) } }) t.Run("unknown_旧规则非命中", func(t *testing.T) { store := newTestStore(t) item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9) item.Temporal = TemporalUnknown got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add(unknown): %v", err) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionLong || expires != "" { t.Fatalf("unknown 且旧规则不命中时落库 = (%q, %q), want (%q, 空) —— 「看着像今日事」不许判 temp", tier, expires, RetentionLong) } }) t.Run("未分型_与unknown同路", func(t *testing.T) { for _, temporal := range []TemporalKind{TemporalUnset, TemporalUnknown} { item := habitItem("谢谢啦", tidyTestTime(t), 0.9) item.Temporal = temporal verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention(%q): %v", temporal, err) } if verdict.Tier != RetentionTemp || verdict.Rule != retentionChatter { t.Fatalf("temporal=%q 时 %q 判成 %q/%q, want temp/%q(历史行的行为必须逐字不变)", temporal, item.Content, verdict.Tier, verdict.Rule, retentionChatter) } } }) } // TestTemporalOneOffDoesNotOverrideExplicitRemember 钉住交叉规则:显式「记住」的 // 一票否决(extract_retention.go 护栏①)对时间性位**同样有效**。 // // 没有这条,模型把「记住我明天三点开会」标成 one_off 就会让主人在 7 天后丢掉自己 // 明确要求记的东西 —— 那是 fail-safe 方向的反面,且不可逆。 func TestTemporalOneOffDoesNotOverrideExplicitRemember(t *testing.T) { t.Parallel() item := habitItem("记住:明天下午三点开会,别迟到", tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention: %v", err) } if verdict.Tier != RetentionLong { t.Fatalf("显式「记住」+ one_off = %q, want %q(护栏① 一票否决对元数据位同样有效)", verdict.Tier, RetentionLong) } } // TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 / // 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出 // 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。 func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) { t.Parallel() store := newTestStore(t) item := habitItem(temporalOneOffCorpus()[2], tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff item.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1") got, err := store.Add(context.Background(), item) if err != nil { t.Fatalf("Add: %v", err) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTierUnset || expires != "" { t.Fatalf("受限来源 + one_off 落库 = (%q, %q), want (未分型, 空)", tier, expires) } } // TestTemporalOneOffDoesNotOverrideStatedAgreement 钉住 `one_off` 支路的第二条 // 例外,也是本片最要紧的一条 fail-safe:**正文在陈述一份约定时,`one_off` 不许把它 // 推进临时层**。 // // 用例内容逐字取自 M37 复审的量化证据(把 N6 时点窗口放宽到 24 字会多命中 11 条, // 逐条都是「同一条长期约定的重复陈述」): // // 「一起晒太阳」暂时搁置 ×5 → 今天是「约定 + 暂缓」 // 「傍晚出门走十分钟…今天没走成明天补上」×6 → 今天是「约定 + 某一次没走成」 // // 这两种句子**确实在描述某一次**,因此模型很容易标成 one_off;若代码照单全收, // 7 天后被删掉的就是约定本身 —— M37 的原话:「放宽阈值会在 7 天后把约定本身删掉, // 违背 fail-safe」。 func TestTemporalOneOffDoesNotOverrideStatedAgreement(t *testing.T) { t.Parallel() agreements := []string{ "两人之间有一个「晒太阳」的约定,目前暂缓,等主人想起来再提", "和主人有「晚饭后出门走十分钟」的约定;今天没走成,算欠着,明天补上", "和叶酱约好每天出门走十分钟;今天太阳先落山没走成,这十分钟欠着", } for _, content := range agreements { content := content t.Run(content, func(t *testing.T) { item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention: %v", err) } if verdict.Tier == RetentionTemp { t.Fatalf("%q 因 temporal=one_off 被判成 temp —— 这是长期约定,7 天后会被删掉(fail-safe 反了)", content) } if !retentionStatesAgreement(content) { t.Fatalf("retentionStatesAgreement(%q) = false —— 例外没认出这条约定", content) } }) } } // TestTemporalOneOffDoesNotOverrideDurableShape 钉住 `one_off` 支路的语义安全阀 // (temporalSanityVeto 的后四条):正文自己在说「习惯 / 长期事实 / 自此刻起永久 / // 状态已改变」时,模型标的一次性**不给结论**,落回旧规则。 // // 语料全部取自 2026-10-27 对生产库 active 集合的离线重放里**真实出现过**的形态 // (它们是「上界候选」的主要来源:被时点词捎带的状态事实)。判成 temp 的后果是 // 7 天后被删,因此这些形态必须留住。 func TestTemporalOneOffDoesNotOverrideDurableShape(t *testing.T) { t.Parallel() cases := []struct{ content, why string }{ {"主人有每天吃早餐的习惯,几乎不会省略早餐。", "习惯词(反复发生,与一次性互斥)"}, {"用户近期住宿舍(昨天住宿舍,可能今晚还在),需要关心住宿情况。", "硬性长期事实词(居住)"}, {"茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了个小铃铛。", "状态改变(新状态会持续)"}, {"主人昨天说以后都叫我小叶", "永久化标记(自此刻起一直成立)"}, } for _, tc := range cases { tc := tc t.Run(tc.content, func(t *testing.T) { item := habitItem(tc.content, tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff if !temporalSanityVeto(tc.content) { t.Fatalf("temporalSanityVeto(%q) = false(%s)", tc.content, tc.why) } verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention: %v", err) } if verdict.Tier == RetentionTemp { t.Fatalf("%q 因 temporal=one_off 被判成 temp —— %s,删掉是误弃", tc.content, tc.why) } }) } } // TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余 // (空 / 中文 / 自造 / 拼写变体)一律 unknown。 // // 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就删记忆), // 而「拿不准一律 long」才是 fail-safe。 func TestNormalizeTemporal(t *testing.T) { t.Parallel() cases := []struct { raw string want TemporalKind }{ {"one_off", TemporalOneOff}, {"ONE_OFF", TemporalOneOff}, {" durable \n", TemporalDurable}, {"recurring", TemporalRecurring}, {"unknown", TemporalUnknown}, {"", TemporalUnknown}, {" ", TemporalUnknown}, {"一次性", TemporalUnknown}, {"one-off", TemporalUnknown}, {"oneoff", TemporalUnknown}, {"forever", TemporalUnknown}, } for _, tc := range cases { if got := normalizeTemporal(tc.raw); got != tc.want { t.Fatalf("normalizeTemporal(%q) = %q, want %q", tc.raw, got, tc.want) } } } // TestTemporalKindClosedSet 钉住闭集本身:四个业务值 + 未分型都合法,其他都不合法 // (Add 的 validate 会拒掉它们 —— 那是程序错误,不是模型输出)。 func TestTemporalKindClosedSet(t *testing.T) { t.Parallel() for _, ok := range []TemporalKind{TemporalUnset, TemporalOneOff, TemporalRecurring, TemporalDurable, TemporalUnknown} { if !ok.valid() { t.Fatalf("%q 应在闭集内", ok) } } for _, bad := range []TemporalKind{"one-off", "sometimes", "ONE_OFF", "float"} { if bad.valid() { t.Fatalf("%q 不应在闭集内", bad) } } if TemporalUnknown.assigned() != true || TemporalUnset.assigned() != false { t.Fatal("assigned() 只应对非空串为真(「未分型」的唯一判据)") } // 非法值必须被写入路径拒掉(不是静默落库成脏列值)。 item := habitItem("一条正文", tidyTestTime(t), 0.9) item.Temporal = TemporalKind("sometimes") if err := item.normalize(); err == nil { t.Fatal("validate 接受了非法的时间性位(应返回 ErrInvalidTemporal)") } } // TestLLMExtractorReadsTemporal 钉住验收点④的另一半:提取器**真的**在读模型给的 // 这一位,且非法 / 缺失值不产生错误、只归 unknown(不拖累同批其他候选)。 func TestLLMExtractorReadsTemporal(t *testing.T) { t.Parallel() adapter := &mockAdapter{fn: func(_ context.Context, _ model.Request, _ model.Options) (model.Response, error) { return model.Response{Content: `[ {"content":"候选一","temporal":"one_off"}, {"content":"候选二","temporal":"durable"}, {"content":"候选三","temporal":"Recurring"}, {"content":"候选四","temporal":"forever"}, {"content":"候选五"} ]`}, nil }} ex := NewLLMExtractor(adapter, "persona.a", "role.1") items, err := ex.Extract(context.Background(), []model.Message{ model.TextMessage(model.RoleUser, "随便聊两句"), }) if err != nil { t.Fatalf("Extract: %v(非法 temporal 绝不能让整批提取失败)", err) } want := map[string]TemporalKind{ "候选一": TemporalOneOff, "候选二": TemporalDurable, "候选三": TemporalRecurring, "候选四": TemporalUnknown, "候选五": TemporalUnknown, } if len(items) != len(want) { t.Fatalf("候选数 = %d, want %d", len(items), len(want)) } for _, it := range items { if it.Temporal != want[it.Content] { t.Fatalf("%q 的 temporal = %q, want %q", it.Content, it.Temporal, want[it.Content]) } } } // TestExtractionPromptCarriesTemporalEnum 钉住「提示词与闭集不漂移」:模型能看到的 // 取值必须与 normalizeTemporal 认的四个值逐字一致(提示词只是辅助,但取值口径漂了 // 就会让每条候选都被归成 unknown,这一位等于白加)。 func TestExtractionPromptCarriesTemporalEnum(t *testing.T) { t.Parallel() for _, want := range []string{"temporal", "one_off", "recurring", "durable", "unknown"} { if !strings.Contains(extractSystemPrompt, want) { t.Fatalf("提取提示词里缺 %q —— 模型无从给出这一位", want) } } }