Files
AskaEth 91a3209c9b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 记忆整理 v1(design/23)——低峰定期扫描 + 事实包含判定 + 规则否决 + 只产建议队列(不自动应用)
2026-10-05 11:29:36 +08:00

170 lines
7.0 KiB
Go
Raw Permalink 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 (
"testing"
)
// 本文件逐条锁定 design/23 §5.1/§5.2 的内容判据(包含、时效、数值、来源),
// 因为它们是「该合 / 不该合」的全部依据 —— 判据错了,整理就会丢信息。
// TestJudgeContainment子集与并列 覆盖 design/23 §1.3 的核心区分:
// 子集(同一事实的碎片)判为包含;并列事实(红牛 vs 冰美式)判为互不包含。
func TestJudgeContainment子集与并列(t *testing.T) {
t.Parallel()
if c := judgeContainment(fixtureHabitShort, fixtureHabitLong); c.Kind != containASubsetB {
t.Fatalf("碎片 ⊂ 完整 = %q, want %q", c.Kind, containASubsetB)
}
if c := judgeContainment(fixtureHabitLong, fixtureHabitShort); c.Kind != containBSubsetA {
t.Fatalf("反向包含 = %q, want %q", c.Kind, containBSubsetA)
}
if c := judgeContainment(fixtureHabitShort, fixtureHabitShort); c.Kind != containBoth {
t.Fatalf("同一内容 = %q, want %q", c.Kind, containBoth)
}
if c := judgeContainment(fixtureEnergyRedBull, fixtureEnergyIced); c.Kind != containNone {
t.Fatalf("并列偏好 = %q, want %q(相似不等于包含)", c.Kind, containNone)
}
if len(judgeContainment(fixtureEnergyRedBull, fixtureEnergyIced).Missing) == 0 {
t.Fatal("互不包含时必须给出缺失要素证据(diff 预览要用)")
}
}
// TestJudgeContainmentEmoji要素 覆盖 design/23 §1.3 的 emoji 判据:emoji 是
// 关键要素,因此「多一个 emoji」的一方不可能是子集。
//
// ⚠️ 与 design/23 §12.1 P4 的差异如实记录在这里:P4 把「[9] 有 🎁、[24] 无」
// 期望为 `link`(非包含)。但按设计稿 §1.3 的核心规则(A ⊆ B 才并入),
// **少 🎁 的那条确实是子集** —— 它的每一个关键要素都在对方里,合并不会丢信息。
// 本实现跟随核心规则(产 merge 建议,且第一版全部交人工确认、不自动应用),
// 并把「各有一个对方没有的 emoji」([4] 的 🎁 vs [20] 的 👀)判为互不包含 ⇒ link。
// 该不一致已在交付报告里列为「要登记的契约/设计变更点」。
func TestJudgeContainmentEmoji要素(t *testing.T) {
t.Parallel()
withGift := "主人送过助手礼物🎁,助手很开心"
withoutGift := "主人送过助手礼物,助手很开心"
if c := judgeContainment(withGift, withoutGift); c.Kind != containBSubsetA {
t.Fatalf("含 🎁 的一方不是子集,无 🎁 的一方是:got %q, want %q", c.Kind, containBSubsetA)
}
if c := judgeContainment(withoutGift, withGift); c.Kind != containASubsetB {
t.Fatalf("少 🎁 的一方是子集:got %q, want %q", c.Kind, containASubsetB)
}
if c := judgeContainment("主人送过助手礼物🎁", "主人送过助手礼物👀"); c.Kind != containNone {
t.Fatalf("各有一个对方没有的 emoji ⇒ 互不包含,got %q", c.Kind)
}
}
// TestHasAbsoluteTimestamp 覆盖 design/23 §5.1 T0-5:绝对日期/时刻是一次性
// 事件的信号,而「22点后 / 十点多」这类周期性表述不是。
func TestHasAbsoluteTimestamp(t *testing.T) {
t.Parallel()
cases := []struct {
content string
want bool
}{
{fixtureOneOffEvent, true},
{"2026-09-08 的晚上", true},
{"国庆黄金周 10/3 假期第三天", true},
{"9月8日那晚聊到很晚", true},
{"周二晚上他还在加班", true},
{"主人常在晚上22点后保持活跃", false},
{"主人常熬夜到十点多", false},
{"每周二晚上一起看剧", false},
{fixtureHabitLong, false},
}
for _, tc := range cases {
if got := hasAbsoluteTimestamp(tc.content); got != tc.want {
t.Errorf("hasAbsoluteTimestamp(%q) = %v, want %v", tc.content, got, tc.want)
}
}
}
// TestHasTemporalState 覆盖 design/23 §5.2-1:有期限的状态词(假期/本周…)
// 命中即否决合并。
func TestHasTemporalState(t *testing.T) {
t.Parallel()
for _, content := range []string{
fixtureHabitVacation, "国庆假期期间处于休假状态", "本周在赶项目", "这几天暂时没空",
} {
if !hasTemporalState(content) {
t.Errorf("hasTemporalState(%q) = false, want true", content)
}
}
for _, content := range []string{
fixtureHabitLong, fixtureEnergyRedBull, "主人喜欢喝冰美式",
} {
if hasTemporalState(content) {
t.Errorf("hasTemporalState(%q) = true, want false", content)
}
}
}
// TestSameNumbers 覆盖 design/23 §5.2-2:数值不同即不同事实。
func TestSameNumbers(t *testing.T) {
t.Parallel()
if !sameNumbers("服务跑在 8080 端口", "服务跑在 8080 端口上") {
t.Fatal("同一数值应视为一致")
}
if sameNumbers("服务跑在 8080 端口", "服务跑在 9090 端口") {
t.Fatal("数值不同必须判定不一致")
}
if sameNumbers("主人常在深夜加班", fixtureHabitLong) == false {
t.Fatal("两边都无数值时视为一致(无数值可比)")
}
}
// TestNonOwnerSource 覆盖 design/23 §5.2-9/§6:群聊/陌生人来源只读不写,
// 而本进程的系统会话(backthink / 整理自身)与主人链路必须放行。
func TestNonOwnerSource(t *testing.T) {
t.Parallel()
cases := []struct {
source string
want bool
}{
{FormatSourceConversation("sess-telegram:group1", "m1"), true},
{FormatSourceConversation("sess-kook:3bef", ""), true},
{FormatSourceConversation(backthinkSessionID, "m2"), false},
{FormatSourceConversation(TidySessionID, ""), false},
{FormatSourceConversation("web", "m3"), false},
{FormatSourceConversation("web:sub-1", ""), false},
{"主会话 2026-09-08 深夜", false}, // 裁决前的 LLM 自由文本:无法机器判定,放行
{"", false},
}
for _, tc := range cases {
if got := nonOwnerSource(tc.source); got != tc.want {
t.Errorf("nonOwnerSource(%q) = %v, want %v", tc.source, got, tc.want)
}
}
}
// TestExtractTidyFacts 锁定事实要素抽取的两个关键形状:时刻是**一个**数值
// token(22:38),emoji 是词项。
func TestExtractTidyFacts(t *testing.T) {
t.Parallel()
facts := extractTidyFacts("2026年9月8日周二晚22:38,主人送了我🎁")
for _, want := range []string{"22:38", "2026", "9"} {
if _, ok := facts.nums[want]; !ok {
t.Errorf("数值要素缺 %q:%v", want, facts.nums)
}
}
if _, ok := facts.terms["🎁"]; !ok {
t.Errorf("emoji 应是词项:%v", facts.terms)
}
if _, ok := facts.terms["熬夜"]; ok {
t.Errorf("不存在的词项不该出现:%v", facts.terms)
}
}
// TestTidyNegationConflictVetoed 覆盖 design/23 §5.2-3:X 与非X 是覆盖关系,
// 不是重复,即使事实集合看起来包含也必须否决合并。
func TestTidyNegationConflictVetoed(t *testing.T) {
t.Parallel()
before := habitItem("主人喜欢喝红牛提神", tidyTestTime(t), 0.9)
after := habitItem("主人不喜欢喝红牛提神", tidyTestTime(t), 0.9)
verdict := judgePair(before, after, 0.98)
if verdict.Veto != VetoConflict {
t.Fatalf("冲突对 veto = %q, want %q", verdict.Veto, VetoConflict)
}
if verdict.Action != "" {
t.Fatalf("冲突对不得带动作,got %q(冲突是覆盖,要走人工)", verdict.Action)
}
}