按 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 全绿。
468 lines
20 KiB
Go
468 lines
20 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|