package memory import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是**时间性元数据位**(迁移 0027、temporal.go)的验收用例集。 // // 🔴 这一位的职责边界(r1 复审裁定,决定了下面每条断言的写法): // // 它**只接管 N6 的位置判据** —— 模型说「这是某一次发生的事」⇒ 时点词埋在句子中段 // 也算锚点。N6 的**全部语义安全阀照旧**(习惯词 / 硬性长期事实词 / 具体约束 / // 永久化 / 状态改变 / 寒暄情绪),N1~N5、N7、护栏①② 一字不动。 // // 因此「`one_off` ⇒ 一定 temp」**不是**本片的语义(那是任务书初版口径,r1 复审用 // 生产语料推翻了:按它实现会当天删掉 21 条长期约定)。下面的用例逐条钉住的是 // 「放开位置后能翻哪些行」与「翻不了哪些行」——后者同样是断言,因为它们是**已知的 // 边界**,不是待修的缺陷。 // temporalWideTimepointCorpus 是**只有放开位置判据才会命中**的夹具语料:时点词都在 // 12 字之后(或句中没有),语义安全阀全部不触发。 func temporalWideTimepointCorpus() []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) } // TestTemporalOneOffWidensTimepointPosition 钉住本片**唯一**的判定效果(也是变异靶子): // 时点词在句中时,`temporal=one_off` ⇒ N6 照常命中(规则码 `n6_timepoint_wide`)⇒ // 全链落库 `(temp, 非空 expires_at)`;而**同一段正文**在未分型时是 long。 // // 变异方向:把 unlocksTimepointPosition 改成恒 false(或把 temporal 参数丢掉) // ⇒ 本用例的三条断言全红。 func TestTemporalOneOffWidensTimepointPosition(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) for _, content := range temporalWideTimepointCorpus() { content := content t.Run(content, func(t *testing.T) { // 前提:这段正文在**旧规则**下是 long(时点不在句首 12 字内)。 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) } // 放开位置后:命中 N6,规则码是 **wide** 那一档(可区分「词判」与「模型放开位置」)。 item := habitItem(content, tidyTestTime(t), 0.9) item.Temporal = TemporalOneOff verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention(one_off): %v", err) } if verdict.Tier != RetentionTemp || verdict.Rule != retentionTimepointWide { t.Fatalf("temporal=one_off 的 %q = %q/%q, want temp/%q", content, verdict.Tier, verdict.Rule, retentionTimepointWide) } // 全链落库:与生产同一条写路径(Store.Add 是唯一 INSERT 路径)。 got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add: %v", err) } tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTemp { t.Fatalf("落库档位 = %q, want %q", tier, RetentionTemp) } if expires == "" { t.Fatalf("判成 temp 却没写 expires_at(两列分叉)") } if got.ExpiresAt.IsZero() { t.Fatalf("Add 返回值没回填 ExpiresAt(调用方看不见「到期到哪」)") } if nat := readTemporalColumn(t, store, got.ID); nat != TemporalOneOff { t.Fatalf("temporal 列 = %q, want %q(判定与留痕不许分叉)", nat, TemporalOneOff) } }) } } // TestTemporalOneOffKeepsFrontedTimepointRule 钉住「放开位置」不改变**已经命中**的 // 那些行:时点在句首时,规则码仍是 `n6_timepoint`(不是 wide)—— 否则留痕里就分不清 // 「词判的」与「模型放开位置后判的」,而那条观测线正是本片要交的东西。 func TestTemporalOneOffKeepsFrontedTimepointRule(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.Rule != retentionTimepoint { t.Fatalf("时点在句首时规则码 = %q, want %q(这一条不需要模型放开位置)", verdict.Rule, retentionTimepoint) } } // TestTemporalOneOffWidenedPositionTouchesAgreements 把 r1 复审指出的**已知风险** // 写成断言(而不是留一个词表去掩盖它)。 // // 事实:N6 的位置窗口**今天正是**那批「约定 + 某一次没走成」行的唯一保护 —— 它们的 // 时点词都埋在中段。放开位置之后,只要模型把这些行标成 `one_off`,它们就会判 temp、 // 7 天后被删。2026-10-27 生产重放实测:最近 500 条里**能翻的 13 条逐条都是这种约定** // (全量 3694 条里 26 条,其中 13 条是约定)。 // // 为什么不在这里加词表:r1 复审已经用两条反例否掉了词面否决 —— 它既挡不住同义异形 // 的约定(`说定`/`约了`),又会误伤该救的行。因此**安全边界落在这里,而不是代码里**: // 这些行是否被删,取决于模型把它们标成 durable(prompt 的 durable 定义里就写着 // 「约定」)还是 one_off。本用例的作用是让这条边界在 diff 里显形、可被复审追踪; // 是否接受它,是塔的裁决(见 M45 报告的决策矩阵)。 func TestTemporalOneOffWidenedPositionTouchesAgreements(t *testing.T) { t.Parallel() const content = "和主人约了傍晚出门走十分钟,今天没走成,明天补" 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 = %q —— 若这里变成 long,说明位置判据没有被放开(本片的全部作用消失);"+ "若它一直是 temp,则是已知的误弃风险,见本用例头注", content, verdict.Tier) } } // TestTemporalOneOffKeepsSemanticValves 钉住这一位**翻不了**的那两类(r1 复审点名的 // 生产行)。它们不是缺陷,是职责边界:语义安全阀判的是「这句话不止是一个时刻」, // 与「时点词在第几个字」无关,因此时间性位不参与它们的判定。 // // 如实记录(复审要求):**这两条正是 M37 说该救的行,而本片救不了它们** —— // 要救就得让模型推翻习惯词/永久化标记,那是另一件事(见报告)。 func TestTemporalOneOffKeepsSemanticValves(t *testing.T) { t.Parallel() cases := []struct{ content, blockedBy string }{ {"昨晚主人喝了一杯热牛奶才睡下,睡得比平时早", "习惯词(平时)"}, {"今天就终于把拖了一个月的 bug 修好了,以后不用再惦记", "永久化标记(以后)"}, } 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 verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention: %v", err) } if verdict.Tier == RetentionTemp { t.Fatalf("%q 被判成 temp —— 语义安全阀(%s)失效了;这一位只接管位置判据,不许动语义阀", tc.content, tc.blockedBy) } }) } } // TestTemporalOneOffInProgressPositionUnchanged 钉住 N7 的位置判据**不**被放开: // r1 复审的裁定只覆盖 N6。「正在」出现在从句里仍然是习惯陈述(「…她正在维护…」), // 时间性位没有理由替它开火。 func TestTemporalOneOffInProgressPositionUnchanged(t *testing.T) { t.Parallel() const content = "那件还没有结论的事,主人正在慢慢想" 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 被判成 temp —— N7 的位置判据不该被时间性位放开(裁定只覆盖 N6)", content) } } // TestTemporalOneOffWithoutTimepointIsInert 钉住「这一位不是无条件 temp」:正文里 // 一个时点词都没有时,放开位置也无事可做 —— 结论仍是旧的 long。 func TestTemporalOneOffWithoutTimepointIsInert(t *testing.T) { t.Parallel() const content = "桌面上的文件整理到一半被别的事打断了" 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 != RetentionLong { t.Fatalf("%q 没有时点词却判成 %q —— 这一位只放开 N6 的位置,不新造判据", content, verdict.Tier) } } // TestTemporalDurableAndRecurringStayLong 钉住任务书验收点②:`durable` / `recurring` // ⇒ `(long, 空)`,**并且覆盖规则闸的 temp 结论**(模型已答「不是一次性事件」, // 方向是 fail-safe:多留)。 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) } }) } } // TestTemporalUnknownAndUnsetFollowOldRules 钉住任务书验收点③:`unknown`(以及未分型 // 的空串)**不许自己判 temp** —— 它们必须原样交给 N1~N7,判据只有旧规则那一份。 // // 三条断言方向不同,缺一条都测不出「方向反了」: // // 1. `unknown` + 「今天下雨了」⇒ 旧规则 N6 判 temp。这是**旧规则**的结论:规则码必须 // 是 `n6_timepoint`,**绝不能**是 `n6_timepoint_wide`(后者出现就说明位置判据被 // 一个「拿不准」的答案放开了); // 2. `unknown` + 旧规则非命中的正文 ⇒ long(不许因为看着像一次性就判 temp); // 3. 未分型(空串)与 `unknown` 走同一条路(历史行的行为逐字不变)。 func TestTemporalUnknownAndUnsetFollowOldRules(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 || verdict.Rule != retentionTimepoint { t.Fatalf("unknown + 「今天下雨了」= %q/%q, want temp/%q(旧规则 N6 的结论,不是 wide)", verdict.Tier, verdict.Rule, retentionTimepoint) } }) t.Run("unknown_旧规则非命中", func(t *testing.T) { store := newTestStore(t) item := habitItem(temporalWideTimepointCorpus()[0], 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, 空) —— 拿不准就必须留", 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) } } }) } // TestTemporalOneOffDoesNotTouchRestrictedSource 钉住与受限来源的优先级:群聊 / // 陌生人的行**不分型**(空串),因此时间性位不许把它们判成 temp —— 那会造出 // 「对读面隐藏、却永远不会被后台清扫删除」的行(expire.go 跳过非 owner 来源)。 func TestTemporalOneOffDoesNotTouchRestrictedSource(t *testing.T) { t.Parallel() store := newTestStore(t) item := habitItem("今天下雨了", 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) } } // TestTemporalBitDrivesDeferThroughGate 钉住判定接线:闸开着时,位置被放开后判出的 // temp 会走**同一条**第五态 `defer` 落库(留痕 `t0_low_value`),不是另开一条路径。 func TestTemporalBitDrivesDeferThroughGate(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(temporalWideTimepointCorpus()[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 放开位置后判 temp 的留痕 = %+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("档位 = %q, want %q", tier, RetentionTemp) } // 到期时刻来自同一个判定与同一个时钟(闸注入的 now + 面板 TTL)。 if want := expiryText(base.Add(ttl)); expires != want { t.Fatalf("expires_at = %q, want %q", expires, want) } } // TestNormalizeTemporal 钉住验收点④的归一化口径:**只认四个字面值**,其余 // (空 / 中文 / 自造 / 拼写变体)一律 unknown。 // // 不做同义词映射是刻意的:猜同义词的代价是单向的(猜成 one_off 就放开位置判据), // 而「拿不准一律留」才是 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() 只应对非空串为真(「未分型」的唯一判据)") } // 只有 one_off 会放开 N6 的位置判据。 for _, t2 := range []TemporalKind{TemporalUnset, TemporalUnknown, TemporalDurable, TemporalRecurring} { if t2.unlocksTimepointPosition() { t.Fatalf("%q 不该放开位置判据", t2) } } if !TemporalOneOff.unlocksTimepointPosition() { t.Fatal("one_off 必须放开 N6 的位置判据(本片的全部作用)") } // 非法值必须被写入路径拒掉(不是静默落库成脏列值)。 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) } } }