Files
Cyrene_v2/internal/runtime/memory/temporal_test.go
T
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00

468 lines
20 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"
"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)
}
}
}