背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成 30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的 就是那一位,不动 N1~N7 的任何阈值。 产出侧 - temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown, 空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜 同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。 - llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累 同批其他候选。 落库 - 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT ''; down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚 (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与 留痕不分叉。 判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改) - one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀: 显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 —— 命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时 21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。 - durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7 (不许因为「正文看着像今天的事」就判 temp)。 - IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。 验证 - go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/... ./internal/db/... 全绿;memory -race 全绿。 - 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与 temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测: 摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。 - 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore), 新加一条迁移不再打红 0025/0026 的用例。
454 lines
20 KiB
Go
454 lines
20 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|