fix(memory): dedupe_group 补取值门 + 临时层双击复核;闸补 3 类长期事实安全阀(M34 r1 复审)
r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
This commit is contained in:
1 parent
6a3f3a329a
commit
54e32304f2
12 files changed
+704
-147
No files matched your search
@@ -258,7 +258,9 @@ func judgeRetentionOneOff(text string) retentionRule {
|
||||
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
|
||||
//
|
||||
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
|
||||
// (正在测通道、暂时搁置、正在慢慢想),安全阀 1~4 与 N6 完全一致。
|
||||
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
|
||||
// (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句,
|
||||
// 是「会…」的习惯陈述,不许开火)。
|
||||
//
|
||||
// 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明):
|
||||
// - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实);
|
||||
@@ -278,7 +280,18 @@ func judgeRetentionTransient(text string) retentionRule {
|
||||
if retentionHasConcreteConstraint(text) {
|
||||
return retentionNone
|
||||
}
|
||||
// 安全阀 5:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条
|
||||
// 安全阀 5/6(2026-10-25 r1 复审 P2-2 补):
|
||||
// - **「以后/从此/往后…」= 自此刻起永久成立** ⇒ 不是一次性事件。
|
||||
// 反例(复审构造):`昨天主人说以后都叫我小叶` —— 句首时点只是说话的时刻。
|
||||
// - **「换成/变成/改成/搬进…」= 状态已改变且新状态持续** ⇒ 不是一次性事件。
|
||||
// 反例(语料实测):`茂密最近换了装:蝴蝶结从棕色变成深色…`(宠物稳定外观)。
|
||||
if containsAny(text, retentionPermanenceMarkers()) {
|
||||
return retentionNone
|
||||
}
|
||||
if containsAny(text, retentionStateChangeMarkers()) {
|
||||
return retentionNone
|
||||
}
|
||||
// 安全阀 7:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条
|
||||
// 规则判(N1 有它的长度安全阀,N2 有形态匹配),N6 不越过它们改判:
|
||||
// - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录;
|
||||
// - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。
|
||||
@@ -289,7 +302,7 @@ func judgeRetentionTransient(text string) retentionRule {
|
||||
if judgeRetentionTimepoint(text) != retentionNone {
|
||||
return retentionTimepoint
|
||||
}
|
||||
if containsAny(text, retentionInProgressMarkers()) {
|
||||
if judgeRetentionInProgress(text) != retentionNone {
|
||||
return retentionInProgress
|
||||
}
|
||||
return retentionNone
|
||||
@@ -297,22 +310,32 @@ func judgeRetentionTransient(text string) retentionRule {
|
||||
|
||||
// judgeRetentionTimepoint 是 N6 的形态判定:正文里**句首前 N 个字内**出现一个
|
||||
// 指示性时点词。位置判据是这条规则精度的来源(见 judgeRetentionTransient 安全阀 5)。
|
||||
//
|
||||
// 位置口径是「下标 < retentionTimepointLeadRunes」(0..11 共 12 个字);r1 复审
|
||||
// 指出旧写法 `<= 12` 让「起于第 13 个字」也命中,与注释差 1 字,这里改严。
|
||||
func judgeRetentionTimepoint(text string) retentionRule {
|
||||
if idx := retentionFrontedTimepoint(text); idx >= 0 && idx <= retentionTimepointLeadRunes {
|
||||
if idx := retentionFrontedTimepoint(text); idx >= 0 && idx < retentionTimepointLeadRunes {
|
||||
return retentionTimepoint
|
||||
}
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**;
|
||||
// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。
|
||||
//
|
||||
// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」
|
||||
// 变成「前 4 字」,位置判据当场失效。
|
||||
func retentionFrontedTimepoint(text string) int {
|
||||
runes := []rune(text)
|
||||
// judgeRetentionInProgress 是 N7 的形态判定:**句首前 N 个字内**出现进行中标记
|
||||
// (正在 / 暂时 / 眼下 / 这会儿)。位置判据与 N6 同口径 —— 它挡的是「正在」只出现在
|
||||
// 从句里、而主句其实是习惯陈述的那些句子(r1 复审 P2-2 的实测反例)。
|
||||
func judgeRetentionInProgress(text string) retentionRule {
|
||||
idx := retentionFirstMarkerIndex(text, retentionInProgressMarkers())
|
||||
if idx >= 0 && idx < retentionTimepointLeadRunes {
|
||||
return retentionInProgress
|
||||
}
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// retentionFirstMarkerIndex 返回 markers 里最早出现的那个在 text 中的 rune 下标
|
||||
// (-1 = 一个都没有)。
|
||||
func retentionFirstMarkerIndex(text string, markers []string) int {
|
||||
best := -1
|
||||
for _, marker := range retentionTimepointMarkers() {
|
||||
for _, marker := range markers {
|
||||
idx := runeIndex(text, marker)
|
||||
if idx < 0 {
|
||||
continue
|
||||
@@ -321,7 +344,17 @@ func retentionFrontedTimepoint(text string) int {
|
||||
best = idx
|
||||
}
|
||||
}
|
||||
if best < 0 || best >= len(runes) {
|
||||
return best
|
||||
}
|
||||
|
||||
// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**;
|
||||
// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。
|
||||
//
|
||||
// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」
|
||||
// 变成「前 4 字」,位置判据当场失效。
|
||||
func retentionFrontedTimepoint(text string) int {
|
||||
best := retentionFirstMarkerIndex(text, retentionTimepointMarkers())
|
||||
if best < 0 || best >= len([]rune(text)) {
|
||||
return -1
|
||||
}
|
||||
return best
|
||||
|
||||
@@ -26,7 +26,7 @@ func retentionTimepointCases() []string {
|
||||
"用户近期在测试一条通道,今天来回测了三轮",
|
||||
"用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」",
|
||||
"计划下周为主人留一个安静的午后",
|
||||
"用户今晚睡宿舍,不在家",
|
||||
"用户今晚在公司加班到很晚",
|
||||
// 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。
|
||||
"用户与助理约定明天中午见",
|
||||
}
|
||||
@@ -57,6 +57,14 @@ func retentionLongTermCases() []struct {
|
||||
{"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"},
|
||||
// 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。
|
||||
{"情绪倾向", "主人最近很容易焦虑"},
|
||||
// 2026-10-25 r1 复审 P2-2 的实测误杀(同一条语料、同一套代码被漏报过的那些)。
|
||||
{"宠物稳定外观(换成)", "茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了一个小铃铛"},
|
||||
{"居住事实(宿舍过夜)", "主人近期在宿舍过夜,需要提醒她宿舍灯早点关、洗漱水别太凉"},
|
||||
{"习惯陈述里的从句「正在」", "用户会主动维护助手与自己之间的连接通道,并告诉助手她正在维护,表明她重视保持与助手的稳定联系"},
|
||||
{"习惯陈述里的从句「正在」(二)", "用户会维护自己与助手之间的连接通道,并曾表示正在为此进行维护"},
|
||||
// 「以后/从此/往后」= 自此刻起永久成立(复审构造即可复现)。
|
||||
{"以后都叫我小叶", "昨天主人说以后都叫我小叶"},
|
||||
{"以后周末不加班", "上周主人说以后周末不加班了"},
|
||||
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,11 @@ func retentionRememberDirectives() []string {
|
||||
func retentionDurableMarkers() []string {
|
||||
return []string{
|
||||
// 长期事实
|
||||
"生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母",
|
||||
//
|
||||
// 「宿舍」(2026-10-25 r1 复审 P2-2 补):语料里「近期在宿舍过夜 / 睡在宿舍」
|
||||
// 是**居住事实 + 稳定照料约定**,不含「住」字却被 N6 判成临时;收它之后
|
||||
// 这类句子与「住在宿舍」同档(都要过护栏②才放行)。
|
||||
"生日", "纪念日", "住", "宿舍", "职业", "工作", "家庭", "家人", "孩子", "父母",
|
||||
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
|
||||
// 关系里程碑 / 约定
|
||||
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
|
||||
@@ -210,3 +214,29 @@ func retentionHardDurableMarkers() []string {
|
||||
}
|
||||
return markers
|
||||
}
|
||||
|
||||
// retentionPermanenceMarkers 是「**自此刻起永久成立**」的显式标记:出现它就绝不是
|
||||
// 一次性事件(2026-10-25 r1 复审 P2-2 补)。
|
||||
//
|
||||
// 反例(复审构造即可复现,旧实现会判成临时 ⇒ 7 天后物理删除):
|
||||
//
|
||||
// "昨天主人说以后都叫我小叶"
|
||||
// "上周主人说以后周末不加班了"
|
||||
//
|
||||
// 句首的「昨天/上周」是**说话的时刻**,而「以后…」说的是从那一刻起一直成立的东西 ——
|
||||
// N6 只看时点词就把它们判成临时,方向正好反了。
|
||||
func retentionPermanenceMarkers() []string {
|
||||
return []string{"以后", "往后", "今后", "从此", "自此", "从今以后"}
|
||||
}
|
||||
|
||||
// retentionStateChangeMarkers 是「**状态已经改变**」的标记:新状态会持续下去,
|
||||
// 因此不是一次性事件(2026-10-25 r1 复审 P2-2 补)。
|
||||
//
|
||||
// 反例(语料实测被误判临时):`茂密最近换了装:蝴蝶结从棕色变成深色…` ——
|
||||
// 宠物/物品的稳定外观与配饰是长期事实,只是「最近」换过一次。
|
||||
//
|
||||
// 与 design/23 §5.2-3 的关系:那里说「改成了Y」是**覆盖**不是重复(合并语义),
|
||||
// 与这里「覆盖后的新状态是长期的」并不矛盾 —— 两条都在说「变化之后有稳定的新事实」。
|
||||
func retentionStateChangeMarkers() []string {
|
||||
return []string{"换成", "变成", "改成", "改为", "换装", "搬进", "搬到"}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ const (
|
||||
tidySkipPartitionMismatch = "partition_mismatch"
|
||||
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
|
||||
tidySkipInvalidCandidate = "invalid_candidate"
|
||||
// tidySkipTemporaryRow 表示簇里有行在**临时层**(`expires_at` 非空):
|
||||
// 临时行 7 天后会被后台物理删除(design/23 §19),既不许当存活行(会让被吸收行
|
||||
// 指向一个不存在的 id),也不值得当被吸收行(它本来就要消失)。整簇原子的 skip。
|
||||
tidySkipTemporaryRow = "temporary_row"
|
||||
)
|
||||
|
||||
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
|
||||
|
||||
@@ -84,6 +84,27 @@ const (
|
||||
// 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性
|
||||
// 由「前 12 字」给出),这条下限只是把明显不同的对排除掉。
|
||||
dedupeGroupPrefixSimilarity = 0.70
|
||||
|
||||
// dedupeGroupTagSetSimilarity 是 **tag 集簇**成员的相似度下限(r1 复审 P1 要求
|
||||
// 「tag_set 簇也要过相似度下限,不能只有 prefix12 走这道门」)。
|
||||
//
|
||||
// ⚠️ 它取 **0.10**,比前缀簇那档(0.70)低一个量级,而且**刻意如此** ——
|
||||
// 语料实测(active 3667、已过取值门/数值门/否定门的 503 对 tag 集成员对):
|
||||
//
|
||||
// floor 0.10 → 保留 98% floor 0.20 → 86% floor 0.30 → 63%
|
||||
// floor 0.40 → 40% floor 0.50 → 20%
|
||||
//
|
||||
// 而**挡住的那 60% 全是同一事实的改写**,不是「不同事实」——例如
|
||||
// `用户可能需要提醒吃早饭,容易空着肚子出门。`(0.047)对
|
||||
// `早餐习惯:吃半碗炒面,需搭配牛奶或鸡蛋…`,或 `叶酱有工作,白天要上班…`(0.109)
|
||||
// 对 `主人是上班族,工作日上班、单位有食堂…`。原因是语料里同一事实的多种写法
|
||||
// 共享的是**语义**而不是二元组,词项余弦天然偏低。
|
||||
//
|
||||
// ⇒ 这道门的定位被数据改了:它只挡「正文几乎没有任何共同词项」的退化对
|
||||
// (同 tag 却各说各话),**不是**同一性判据。同一性由三件套决定:数值门、
|
||||
// 否定门、(P1 新增的)**取值门**。抬高这道门只会删掉合法去重、并不会多挡住
|
||||
// 危险对 —— 需要更准的判据时该走 T1 模型层(不在本切片)。
|
||||
dedupeGroupTagSetSimilarity = 0.10
|
||||
)
|
||||
|
||||
// dedupe_group 的证据字段(进 TidyEvidence,供面板 diff 预览与运维判读)。
|
||||
@@ -139,13 +160,19 @@ func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items [
|
||||
covered[c.TargetID] = struct{}{}
|
||||
}
|
||||
}
|
||||
// merge_count 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到
|
||||
// created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。
|
||||
// 元信息(merge_count + `expires_at` 是否非空)**不是可降级的旁支**:它承载的是
|
||||
// 「临时层行绝不进簇」这条数据安全红线(临时行 7 天后会被后台物理删除,
|
||||
// design/23 §19;让长期事实并进一个将被删除的存活行 = 信息不可达)。
|
||||
//
|
||||
// 因此查询失败时**这个分区这一轮不分簇**(fail-safe 的方向是「少做」而不是
|
||||
// 「猜」):成本只是下一轮再来,而猜错的代价是不可逆的。
|
||||
// 这是 r1 复审 P2-1 的落点 —— 旧实现降级成 nil map 后 `temporary` 恒为 false,
|
||||
// 恰好把那道红线绕开了。
|
||||
meta, err := t.deps.Store.dedupeItemMeta(ctx, p)
|
||||
if err != nil {
|
||||
t.log.Warn("memory tidy dedupe item meta unavailable, clustering without merge_count/temporary flags",
|
||||
t.log.Warn("memory tidy dedupe skipped: item meta unavailable",
|
||||
"partition", p.label(), "error", err)
|
||||
meta = nil
|
||||
return nil, tidyDedupeStats{}
|
||||
}
|
||||
return t.dedupeCandidates(items, meta, covered, t.now().UTC())
|
||||
}
|
||||
@@ -307,84 +334,6 @@ func dedupeGroupEligible(it MemoryItem) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立
|
||||
// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。
|
||||
func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey {
|
||||
out := make([]tidyDedupeKey, 0, 2)
|
||||
if key := dedupeTagKey(it); key != "" {
|
||||
out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key})
|
||||
}
|
||||
if key := dedupePrefixKey(it); key != "" {
|
||||
out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。
|
||||
//
|
||||
// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**:
|
||||
// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键
|
||||
// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** ——
|
||||
// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证;
|
||||
// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉
|
||||
// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。
|
||||
func dedupeTagKey(it MemoryItem) string {
|
||||
tags := normalizedTidyTags(it.Tags)
|
||||
if len(tags) < dedupeGroupMinTagCount {
|
||||
return ""
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if len([]rune(tag)) < dedupeGroupMinTagRunes {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e")
|
||||
}
|
||||
|
||||
// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。
|
||||
func dedupePartitionPrefix(it MemoryItem) string {
|
||||
return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e"
|
||||
}
|
||||
|
||||
// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定
|
||||
// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。
|
||||
func normalizedTidyTags(tags []string) []string {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
out := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
t := strings.TrimSpace(tag)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[t]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。
|
||||
//
|
||||
// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene`
|
||||
// 不该因为多一个空格就落到两个簇。
|
||||
//
|
||||
// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成
|
||||
// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档)
|
||||
// 已经在管的事,不需要第二个动作重复它。
|
||||
func dedupePrefixKey(it MemoryItem) string {
|
||||
runes := []rune(dedupeKey(it.Content))
|
||||
if len(runes) < dedupeGroupPrefixRunes {
|
||||
return ""
|
||||
}
|
||||
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes])
|
||||
}
|
||||
|
||||
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
|
||||
//
|
||||
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含**
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -45,8 +47,9 @@ func tidyApplyRowCost(c TidyCandidate) int {
|
||||
|
||||
// applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。
|
||||
//
|
||||
// 前置校验与 merge 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。
|
||||
// 任何一条不通过都如实 skip(不假装做过),且**一行都不写**。
|
||||
// 前置校验与 merge 逐条同源(分区硬门、只读来源硬门、行必须存在、**临时层不复核**),
|
||||
// 全部在 loadDedupeGroupRows 里;任何一条不通过都如实 skip(不假装做过),
|
||||
// 且**一行都不写**。
|
||||
func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||||
base := TidyApplyResult{
|
||||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||||
@@ -54,41 +57,13 @@ func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, op
|
||||
if len(cand.SourceIDs) == 0 || cand.TargetID == "" {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
survivor, err := s.Get(ctx, cand.TargetID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||||
}
|
||||
survivor, absorbed, skip, err := s.loadDedupeGroupRows(ctx, cand)
|
||||
if err != nil {
|
||||
return base, err
|
||||
}
|
||||
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
|
||||
if skip != "" {
|
||||
return skipTidyApply(base, skip), nil
|
||||
}
|
||||
if nonOwnerSource(survivor.SourceConversation) {
|
||||
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
|
||||
// 规则层本就把这类条目排除在分簇之外。
|
||||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||||
}
|
||||
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
|
||||
for _, id := range cand.SourceIDs {
|
||||
if id == cand.TargetID {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
it, gerr := s.Get(ctx, id)
|
||||
if errors.Is(gerr, ErrNotFound) {
|
||||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||||
}
|
||||
if gerr != nil {
|
||||
return base, gerr
|
||||
}
|
||||
if skip := tidyPartitionMismatch(cand, it); skip != "" {
|
||||
return skipTidyApply(base, skip), nil
|
||||
}
|
||||
if nonOwnerSource(it.SourceConversation) {
|
||||
return skipTidyApply(base, tidySkipSourceRestricted), nil
|
||||
}
|
||||
absorbed = append(absorbed, it)
|
||||
}
|
||||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, it := range absorbed {
|
||||
if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil {
|
||||
@@ -108,3 +83,88 @@ func (s *Store) applyTidyDedupeGroup(ctx context.Context, cand TidyCandidate, op
|
||||
}
|
||||
return finishTidyApply(base, affected, err, tidySkipSourceNotActive)
|
||||
}
|
||||
|
||||
// loadDedupeGroupRows 读出存活行与被吸收行并做**全部前置校验**;skip 非空表示应如实
|
||||
// 跳过(原因由它给出),此时两行都不可用。
|
||||
//
|
||||
// 四类校验,缺一不可:
|
||||
// 1. **分区硬门**(§5.2-7/8):候选与行必须同 persona/scope/project;
|
||||
// 2. **只读来源硬门**(§5.2-9、§6):群聊/陌生人来源绝不改写;
|
||||
// 3. **临时层复核**(r1 复审 P2-1):`expires_at` 非空的行既不许当存活行(会被
|
||||
// 物理删除,design/23 §19 ⇒ 被吸收行悬空)也不许当被吸收行(本来就要消失)——
|
||||
// 候选从生成到被人点是过时候选,必须在落库前按主键再判一次;
|
||||
// 4. 形状:source 不得等于 target。
|
||||
func (s *Store) loadDedupeGroupRows(ctx context.Context, cand TidyCandidate) (MemoryItem, []MemoryItem, string, error) {
|
||||
empty := MemoryItem{}
|
||||
survivor, err := s.Get(ctx, cand.TargetID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return empty, nil, tidySkipSourceMissing, nil
|
||||
}
|
||||
if err != nil {
|
||||
return empty, nil, "", err
|
||||
}
|
||||
if skip := dedupeRowGuard(cand, survivor); skip != "" {
|
||||
return empty, nil, skip, nil
|
||||
}
|
||||
if temporary, terr := s.tidyItemTemporary(ctx, survivor.ID); terr != nil {
|
||||
return empty, nil, "", terr
|
||||
} else if temporary {
|
||||
return empty, nil, tidySkipTemporaryRow, nil
|
||||
}
|
||||
absorbed := make([]MemoryItem, 0, len(cand.SourceIDs))
|
||||
for _, id := range cand.SourceIDs {
|
||||
if id == cand.TargetID {
|
||||
return empty, nil, tidySkipInvalidCandidate, nil
|
||||
}
|
||||
it, gerr := s.Get(ctx, id)
|
||||
if errors.Is(gerr, ErrNotFound) {
|
||||
return empty, nil, tidySkipSourceMissing, nil
|
||||
}
|
||||
if gerr != nil {
|
||||
return empty, nil, "", gerr
|
||||
}
|
||||
if skip := dedupeRowGuard(cand, it); skip != "" {
|
||||
return empty, nil, skip, nil
|
||||
}
|
||||
if temporary, terr := s.tidyItemTemporary(ctx, it.ID); terr != nil {
|
||||
return empty, nil, "", terr
|
||||
} else if temporary {
|
||||
return empty, nil, tidySkipTemporaryRow, nil
|
||||
}
|
||||
absorbed = append(absorbed, it)
|
||||
}
|
||||
return survivor, absorbed, "", nil
|
||||
}
|
||||
|
||||
// dedupeRowGuard 是「这一行能不能被这条候选改动」的静态判据(分区 + 只读来源)。
|
||||
func dedupeRowGuard(cand TidyCandidate, it MemoryItem) string {
|
||||
if skip := tidyPartitionMismatch(cand, it); skip != "" {
|
||||
return skip
|
||||
}
|
||||
if nonOwnerSource(it.SourceConversation) {
|
||||
// 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御,
|
||||
// 规则层本就把这类条目排除在分簇之外。
|
||||
return tidySkipSourceRestricted
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tidyItemTemporarySQL 只读一行的 `expires_at`(临时层判据,design/03 §9.6)。
|
||||
const tidyItemTemporarySQL = `SELECT expires_at FROM memory_items WHERE id = ?`
|
||||
|
||||
// tidyItemTemporary 报告一条记忆是否在**临时层**(`expires_at` 非空)。
|
||||
//
|
||||
// 它服务应用侧的**过时复核**:`MemoryItem` 的读侧 SELECT 不含这一列(该列只写不读),
|
||||
// 因此候选生成时用分区级元信息判一次、真正落库前再按主键判一次 —— 两次都要,
|
||||
// 因为「候选生成」与「人点 apply」之间隔着任意长的墙钟(r1 复审 P2-1)。
|
||||
func (s *Store) tidyItemTemporary(ctx context.Context, id string) (bool, error) {
|
||||
var expiresAt string
|
||||
err := s.db.QueryRowContext(ctx, tidyItemTemporarySQL, id).Scan(&expiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: read item expiry %s: %w", id, err)
|
||||
}
|
||||
return strings.TrimSpace(expiresAt) != "", nil
|
||||
}
|
||||
@@ -181,8 +181,8 @@ func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) {
|
||||
tags := []string{"宠物", "小茂密"}
|
||||
for i, content := range []string{
|
||||
"小茂密是白毛的小猫,喜欢撒娇",
|
||||
"茂密的蝴蝶结最近换成了深色",
|
||||
"茂密爱坐在窗边看外面的风景",
|
||||
"白毛小茂密爱撒娇,是只小猫",
|
||||
"爱撒娇的小猫名叫小茂密,通体白毛",
|
||||
} {
|
||||
seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute)))
|
||||
}
|
||||
@@ -263,9 +263,9 @@ func TestTidyRunReportsDedupeTruncation(t *testing.T) {
|
||||
tags []string
|
||||
}{
|
||||
{"小茂密是白毛的小猫,喜欢撒娇", []string{"宠物", "小茂密"}},
|
||||
{"茂密的蝴蝶结最近换成了深色", []string{"宠物", "小茂密"}},
|
||||
{"主人对花溪的住处很满意", []string{"地点", "住处"}},
|
||||
{"主人说花溪的住处很安静", []string{"地点", "住处"}},
|
||||
{"白毛小茂密爱撒娇,是只小猫", []string{"宠物", "小茂密"}},
|
||||
{"主人对花溪的住处赞不绝口", []string{"地点", "住处"}},
|
||||
{"花溪的住处让主人很满意,安静", []string{"地点", "住处"}},
|
||||
}
|
||||
for i, s := range seed {
|
||||
seedDedupeItem(t, store, dedupeTestItem(s.content, s.tags, base.Add(time.Duration(i)*time.Minute)))
|
||||
@@ -287,3 +287,67 @@ func TestTidyRunReportsDedupeTruncation(t *testing.T) {
|
||||
t.Fatalf("Truncated 为空 —— 被上限切掉的部分必须如实登记分区标签")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyDedupeGroupSkipsTemporaryRows 是 r1 复审 **P2-1 后半**的回归:
|
||||
// 候选生成之后某一行才变成临时层(`expires_at` 非空)时,apply **必须整簇 skip**。
|
||||
//
|
||||
// 为什么必须有这道复核:候选从生成到「人点 apply」之间隔着任意长的墙钟。若存活行
|
||||
// 在那之后进了临时层,7 天后它被物理删除(design/23 §19),被吸收行就会永远停在
|
||||
// `status='merged'` 且 `merged_into` 指向一个不存在的 id。
|
||||
func TestTidyApplyDedupeGroupSkipsTemporaryRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
base := tidyTestTime(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
markTarget bool
|
||||
}{
|
||||
{"存活行变成临时层", true},
|
||||
{"被吸收行变成临时层", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
tags := []string{"身份", "名字"}
|
||||
items := []MemoryItem{
|
||||
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
|
||||
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, store, items)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1", len(cands))
|
||||
}
|
||||
cand := cands[0]
|
||||
if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created {
|
||||
t.Fatalf("insert candidate: created=%v err=%v", created, err)
|
||||
}
|
||||
// 候选落库之后、apply 之前:把目标/被吸收行变成临时层。
|
||||
victim := cand.SourceIDs[0]
|
||||
if tc.markTarget {
|
||||
victim = cand.TargetID
|
||||
}
|
||||
if _, err := store.db.ExecContext(ctx,
|
||||
`UPDATE memory_items SET expires_at = ? WHERE id = ?`,
|
||||
expiryText(base.Add(7*24*time.Hour)), victim); err != nil {
|
||||
t.Fatalf("mark temporary: %v", err)
|
||||
}
|
||||
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate: %v", err)
|
||||
}
|
||||
if res.Status != TidyApplySkipped || res.Reason != tidySkipTemporaryRow {
|
||||
t.Fatalf("结果 = %+v, want skipped/%s", res, tidySkipTemporaryRow)
|
||||
}
|
||||
for _, id := range cand.SourceIDs {
|
||||
if got := loadTidyItemRow(t, store, id).status; got != tidyStatusActive {
|
||||
t.Fatalf("被吸收行 %s 状态 = %q, want active(整簇 skip,一行都不写)", id, got)
|
||||
}
|
||||
}
|
||||
if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending {
|
||||
t.Fatalf("候选状态 = %q, want pending(skip 不该消费掉候选)", got.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,19 @@ import (
|
||||
|
||||
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
|
||||
//
|
||||
// 数值与否定是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
|
||||
// 而同一性由存活行代表;任何一条与存活行数值/否定不一致的成员,说明它讲的是
|
||||
// **另一个事实**(§5.2-2/§5.2-3),因此它**不进这一簇**(留待人工),而不是让整簇作废。
|
||||
// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
|
||||
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
|
||||
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
|
||||
//
|
||||
// 前缀簇还要过相似度下限(见 dedupeGroupPrefixSimilarity);tag 集簇不需要 ——
|
||||
// 完全相同的 tag 集本身就是强证据,而前缀簇的「同开头」弱得多。
|
||||
// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实;
|
||||
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
|
||||
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
|
||||
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
|
||||
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
|
||||
//
|
||||
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
|
||||
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得
|
||||
// 太远的一律不进簇。「同开头」比「同 tag 集」更弱,因此它用的是更严的那一档。
|
||||
func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember {
|
||||
out := make([]tidyDedupeMember, 0, len(members))
|
||||
for _, it := range members {
|
||||
@@ -30,8 +37,10 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu
|
||||
if !sameNegations(it.item.Content, survivor.item.Content) {
|
||||
continue
|
||||
}
|
||||
if rule == tidyDedupeRulePrefix &&
|
||||
lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupPrefixSimilarity {
|
||||
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
|
||||
continue
|
||||
}
|
||||
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
|
||||
continue
|
||||
}
|
||||
out = append(out, it)
|
||||
@@ -42,6 +51,18 @@ func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedu
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。
|
||||
//
|
||||
// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点;
|
||||
// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实
|
||||
// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。
|
||||
func dedupeGroupMinSimilarity(rule string) float64 {
|
||||
if rule == tidyDedupeRulePrefix {
|
||||
return dedupeGroupPrefixSimilarity
|
||||
}
|
||||
return dedupeGroupTagSetSimilarity
|
||||
}
|
||||
|
||||
// dedupeGroupCandidate 把一个簇转成候选。
|
||||
//
|
||||
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**簇键**:一条记忆凭什么与另一条同簇。
|
||||
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
|
||||
// 判据与注释**逐字未动**。
|
||||
|
||||
// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立
|
||||
// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。
|
||||
func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey {
|
||||
out := make([]tidyDedupeKey, 0, 2)
|
||||
if key := dedupeTagKey(it); key != "" {
|
||||
out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key})
|
||||
}
|
||||
if key := dedupePrefixKey(it); key != "" {
|
||||
out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。
|
||||
//
|
||||
// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**:
|
||||
// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键
|
||||
// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** ——
|
||||
// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证;
|
||||
// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉
|
||||
// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。
|
||||
func dedupeTagKey(it MemoryItem) string {
|
||||
tags := normalizedTidyTags(it.Tags)
|
||||
if len(tags) < dedupeGroupMinTagCount {
|
||||
return ""
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if len([]rune(tag)) < dedupeGroupMinTagRunes {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e")
|
||||
}
|
||||
|
||||
// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。
|
||||
func dedupePartitionPrefix(it MemoryItem) string {
|
||||
return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e"
|
||||
}
|
||||
|
||||
// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定
|
||||
// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。
|
||||
func normalizedTidyTags(tags []string) []string {
|
||||
if len(tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(tags))
|
||||
out := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
t := strings.TrimSpace(tag)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[t]; dup {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。
|
||||
//
|
||||
// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene`
|
||||
// 不该因为多一个空格就落到两个簇。
|
||||
//
|
||||
// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成
|
||||
// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档)
|
||||
// 已经在管的事,不需要第二个动作重复它。
|
||||
func dedupePrefixKey(it MemoryItem) string {
|
||||
runes := []rune(dedupeKey(it.Content))
|
||||
if len(runes) < dedupeGroupPrefixRunes {
|
||||
return ""
|
||||
}
|
||||
return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes])
|
||||
}
|
||||
@@ -33,12 +33,10 @@ type tidyDedupeMeta struct {
|
||||
|
||||
// dedupeItemMeta 读出一个分区的 id → 元信息映射。
|
||||
//
|
||||
// 失败方向上它是**可降级**的(调用方在失败时用空映射继续分簇):存活行的第一判据
|
||||
// 是「内容最长」,merge_count 只在前者并列时决定胜负。但**降级会让「临时行」这个
|
||||
// 判断失效** —— 因此调用方在降级时必须记 WARN,并且降级路径下 `temporary` 恒为
|
||||
// false 意味着「可能把临时行当长期行」;这是**宁可少做也不误删**的反面,
|
||||
// 所以这里把降级方向定成「继续分簇 + WARN」,而不是「整个分区不分簇」:
|
||||
// 判错的方向是「多留一条冗余」(可 revert),不是「丢一条记忆」。
|
||||
// 🔴 **失败方向是「这个分区这轮不分簇」**(不是「降级继续」):`temporary` 承载的是
|
||||
// 「临时行绝不进簇」这条数据安全红线,而 nil map 会让 `temporary` 恒为 false ——
|
||||
// 那等于把红线绕开(r1 复审 P2-1 实测的正是这个口子)。merge_count 缺了确实只影响
|
||||
// 「等长时留哪条」,但两列同源同一次查询,为一条次要判据去承担一条红线风险不划算。
|
||||
func (s *Store) dedupeItemMeta(ctx context.Context, p tidyPartition) (map[string]tidyDedupeMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, dedupeItemMetaSQL,
|
||||
p.personaID, string(p.scope), p.projectID, string(tidyStatusActive))
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -308,3 +309,137 @@ func TestDedupeGroupIsNeverAutoApplied(t *testing.T) {
|
||||
t.Fatalf("dedupe_group 被判成可自动落地(reason=%q)—— 它必须在环核对", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsSameTagDifferentValue 是 r1 复审 **P1-1 的回归用例**:
|
||||
// 同 tag 集、但**取值不同**(`「老K」` vs `「阿七」`)的两条**不许同簇**。
|
||||
//
|
||||
// 语料原型(脱敏):生产库里 `用户的昵称是「叶酱」。` 曾被并进
|
||||
// `主人的昵称是「喵喵七号」…` 的存活行,而同库另有 4 行明确写着「旧的称呼
|
||||
// 『喵喵七号』已作废,不要再使用」—— 一次 apply 就会让当前事实消失,并留下与库内
|
||||
// 其它行直接矛盾的结论。因此这条用例钉的不是「少减一点」,而是「不许毁数据」。
|
||||
func TestDedupeGroupRejectsSameTagDifferentValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"昵称", "称呼"}
|
||||
|
||||
// 取值不同:只剩存活行一条 ⇒ 不成簇、一条候选都不产。
|
||||
survivor := dedupeTestItem("主人的昵称是「老K」,是相处中慢慢形成的亲昵称呼;具体来历不明,不可编造。", tags, base)
|
||||
staleA := dedupeTestItem("用户的昵称是「阿七」。", tags, base.Add(time.Minute))
|
||||
staleB := dedupeTestItem("主人被称呼为「阿七」,助手平时就这么叫她。", tags, base.Add(2*time.Minute))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, staleA, staleB})
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(取值不同=不同事实,§5.2-4/§5.2-5)", len(cands))
|
||||
}
|
||||
for _, m := range []MemoryItem{staleA, staleB} {
|
||||
if gaps := dedupeValueGaps(m.Content, survivor.Content); len(gaps) == 0 {
|
||||
t.Fatalf("取值门没认出 %q 里的取值与存活行不同", m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// 对照组:同样的 tag 集 + **相同取值**,必须照常成簇(证明上面那条不是因为门太宽)。
|
||||
sameValue := dedupeTestItem("主人被称呼为「老K」,助手平时就这么叫她。", tags, base.Add(3*time.Minute))
|
||||
cands, _ = dedupeCandsOf(t, newTestStore(t), []MemoryItem{survivor, sameValue})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("对照组簇数 = %d, want 1(同 tag 同取值必须成簇)", len(cands))
|
||||
}
|
||||
if cands[0].TargetID != survivor.ID {
|
||||
t.Fatalf("对照组存活行 = %s, want 最长的那条 %s", cands[0].TargetID, survivor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupLatinTokenIsAlsoAValue 钉住取值门的第二类取值:**拉丁 token**
|
||||
// (人名 / 型号 / 代号)。`助手名为 Cyrene` 与 `助手名为 Alice` 同 tag 集,但取值不同。
|
||||
func TestDedupeGroupLatinTokenIsAlsoAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"身份", "名字"}
|
||||
keep := dedupeTestItem("助手名为 Cyrene,主人习惯这么叫我。", tags, base)
|
||||
other := dedupeTestItem("助手名为 Alice,主人习惯这么叫我。", tags, base.Add(time.Minute))
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{keep, other}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(拉丁取值不同=不同事实)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupNeverLosesAValue 是**簇级不变式**:任何产出的候选,被吸收行里的
|
||||
// 每个取值(引号取值 + 拉丁 token)都必须出现在存活行正文里 —— 否则那条取值会随
|
||||
// 合并从 active 集合里消失。它比单点用例强:新增的簇判据 / 门顺序变化都要过它。
|
||||
func TestDedupeGroupNeverLosesAValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"暗号", "约定"}
|
||||
items := []MemoryItem{
|
||||
dedupeTestItem("用户与助手的验收暗号是「青柠」", tags, base),
|
||||
dedupeTestItem("用户与助手的验收暗号是「青柠」,用于确认文档摄取正常", tags, base.Add(time.Minute)),
|
||||
dedupeTestItem("验收暗号「青柠」用于确认文档摄取功能正常", tags, base.Add(2*time.Minute)),
|
||||
// 同一 tag 集但取值不同(不许进簇)。
|
||||
dedupeTestItem("用户与助手的验收暗号是「柠檬」", tags, base.Add(3*time.Minute)),
|
||||
// 同 tag 集、取值包含了额外信息(不许进簇:那条取值会丢)。
|
||||
dedupeTestItem("验收暗号「青柠」配合口令「开门」一起使用", tags, base.Add(4*time.Minute)),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1", len(cands))
|
||||
}
|
||||
survivor := cands[0].MergedContent
|
||||
for _, member := range cands[0].Evidence.DedupeMembers {
|
||||
for _, value := range dedupeValues(member) {
|
||||
if !strings.Contains(dedupeKey(survivor), value) {
|
||||
t.Fatalf("取值 %q 会随合并消失(member=%q survivor=%q)", value, member, survivor)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, member := range cands[0].Evidence.DedupeMembers {
|
||||
if strings.Contains(member, "柠檬") || strings.Contains(member, "开门") {
|
||||
t.Fatalf("不该被吸收的成员进了簇:%q", member)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeInPartitionSkipsWhenMetaUnavailable 是 r1 复审 **P2-1 前半**的回归:
|
||||
// 元信息查询失败时**整个分区这一轮不分簇**(旧实现降级成 nil map,而 nil map 上
|
||||
// `temporary` 恒为 false ⇒ 把「临时行绝不进簇」那条红线绕开了)。
|
||||
func TestDedupeInPartitionSkipsWhenMetaUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"身份", "名字"}
|
||||
items := []MemoryItem{
|
||||
seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base)),
|
||||
seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute))),
|
||||
}
|
||||
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Log: testLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidier: %v", err)
|
||||
}
|
||||
// 让元信息查询失败:关掉底层库(查询随之报错)。
|
||||
if err := store.db.Close(); err != nil {
|
||||
t.Fatalf("close db: %v", err)
|
||||
}
|
||||
cands, stats := tidier.dedupeInPartition(ctx,
|
||||
tidyPartition{personaID: "persona.a", scope: ScopePersona}, items, nil)
|
||||
if len(cands) != 0 || stats.Groups != 0 {
|
||||
t.Fatalf("簇数 = %d (stats %+v), want 0(元信息不可得 ⇒ 本轮不做,而不是猜)",
|
||||
len(cands), stats)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupTagSetAlsoNeedsSimilarityFloor 钉住「tag 集簇也要过相似度下限」
|
||||
// (r1 复审 P1 修法第 2 条):同 tag 集但**正文几乎没有任何共同词项**的两条不许成簇。
|
||||
//
|
||||
// 这条下限取值刻意很低(0.10,见 dedupeGroupTagSetSimilarity 的实测依据):语料里
|
||||
// 同一事实的多种写法的相似度本来就在 0.05~0.4 之间,抬高它只会删掉合法去重。
|
||||
// 因此本用例用**零重叠**的极端对钉住「门确实在」,而不是钉某个档位。
|
||||
func TestDedupeGroupTagSetAlsoNeedsSimilarityFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"日常", "杂项"}
|
||||
a := dedupeTestItem("主人喜欢冰美式,夏天要加冰", tags, base)
|
||||
b := dedupeTestItem("仓库里的迁移脚本必须先过静态检查", tags, base.Add(time.Minute))
|
||||
if got := lexicalSimilarity(a.Content, b.Content); got >= dedupeGroupTagSetSimilarity {
|
||||
t.Fatalf("夹具失效:两条相似度 %.3f 已高于下限 %.2f", got, dedupeGroupTagSetSimilarity)
|
||||
}
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(同 tag 集但正文零重叠 ⇒ 不成簇)", len(cands))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**取值门**(2026-10-25,r1 复审 P1 的落点):
|
||||
// 合并前必须确认「被吸收行里的**取值**」在存活行里一个不少。
|
||||
//
|
||||
// 动因是一次**会毁数据**的实测:生产语料里 `tag_set` 簇(同 tag 集)把
|
||||
// `用户的昵称是「叶酱」。` 并进了 `主人的昵称是「喵喵七号」…` 的存活行 —— 两条
|
||||
// **同属性、不同取值**(design/23 §5.2-4/§5.2-5 的绝不合并清单),而同库另有 4 行
|
||||
// 明确写着「喵喵七号已作废、应称叶酱」。一次 apply 就会让当前事实消失,并留下与
|
||||
// 库内其它行直接矛盾的结论。
|
||||
//
|
||||
// 根因不是某条词表写漏,而是**判据缺一件**:design/23 §5.1-3 要求「专有名词、
|
||||
// 数值、否定词」逐一比对,而实现只有后两件(`sameNumbers` / `sameNegations`)。
|
||||
// 本文件的取值门补的就是「专有名词」那一件,因此它对**两种簇判据都生效**
|
||||
//(不是只对 prefix12 生效的相似度下限那种补丁)。
|
||||
//
|
||||
// 方向是**单向**的(被吸收 ⊆ 存活),与软合并的语义同向:合并后存活行的正文
|
||||
// 原样保留,所以**存活行多出来的取值不会丢**;会丢的只有「被吸收行有、存活行没有」
|
||||
// 的那些取值。因此判据是「被吸收行的每个取值都出现在存活行里」,而不是「两侧取值
|
||||
// 集合相等」—— 后者会把「超集吸收子集」这个正常方向也挡掉。
|
||||
|
||||
// dedupeValueGaps 返回被吸收行里**在存活行正文中缺失**的取值(空 = 可以合并)。
|
||||
//
|
||||
// 取值有两类,都是 design/23 §5.1-3 说的「专有名词」的可机械抓手:
|
||||
//
|
||||
// 1. **引号内取值**(`「」` / `『』` / `“”` / `‘’` / ASCII `"`):昵称、暗号、口令、
|
||||
// 代号、台词这类「同一属性的一组取值」在语料里几乎总被引号括起来 ——
|
||||
// `「喵喵七号」` vs `「叶酱」` 就是这一类的正例;
|
||||
// 2. **拉丁 token**(≥2 个字母的 ASCII 词,含 `h3R3` / `AAA` 这种混排):
|
||||
// 人名 / 型号 / 代号 / 英文标题。中文正文里它们不会被分词歧义吃掉。
|
||||
//
|
||||
// **刻意不收**的:ASCII 单引号 `'`(英文所有格 `assistant's` 会被误配成引号对,
|
||||
// 于是整句剩余部分都要在存活行里出现 —— 方向虽安全,但会把大量英文条目挡在簇外);
|
||||
// 中文数字(`十点` vs `十一点`):既有 `sameNumbers` 只认 ASCII 数字 token,
|
||||
// 扩它属于改既有口径,不在本切片。
|
||||
//
|
||||
// 比较用**归一化正文**(小写 + 空白折叠,同 dedupeKey):取值比对不该被大小写或
|
||||
// 多余空格左右。
|
||||
func dedupeValueGaps(absorbed, survivor string) []string {
|
||||
haystack := dedupeKey(survivor)
|
||||
if haystack == "" {
|
||||
return []string{"(empty survivor)"}
|
||||
}
|
||||
gaps := make([]string, 0, 2)
|
||||
seen := make(map[string]struct{}, 4)
|
||||
for _, value := range dedupeValues(absorbed) {
|
||||
if _, dup := seen[value]; dup {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
if !strings.Contains(haystack, value) {
|
||||
gaps = append(gaps, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(gaps)
|
||||
return gaps
|
||||
}
|
||||
|
||||
// 取值门的两个阈值。
|
||||
const (
|
||||
// dedupeQuoteMaxRunes 是「当作取值」的引号内容长度上限(rune 计):取值门的靶子
|
||||
// 是短标识(昵称/暗号/代号/型号),一整段引文作为取值只会把「同一件事的不同引述」
|
||||
// 全挡掉。超过它的引号内容**不产生取值**(即不用它否决合并)。
|
||||
dedupeQuoteMaxRunes = 24
|
||||
|
||||
// dedupeLatinMinRunes 是「当作取值」的拉丁 token 最小长度:单字母(`a`、`x`)
|
||||
// 在正文里到处都是,收它等于给每个簇加一道随机否决。
|
||||
dedupeLatinMinRunes = 2
|
||||
)
|
||||
|
||||
// dedupeValues 抽出一段正文里的**取值**(引号内取值 + 拉丁 token),已归一化为小写。
|
||||
//
|
||||
// 引号配对按「开 → 就近的闭」:中文引号有明确的开关字符,配不上对的那一段
|
||||
// 整个丢弃(解析失败时**不产生取值**,因此不会凭它否决合并 —— 少一条取值门
|
||||
// 比凭空造一条安全门更不容易误伤)。
|
||||
func dedupeValues(content string) []string {
|
||||
text := dedupeKey(content)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, 4)
|
||||
out = append(out, dedupeQuotedValues(text)...)
|
||||
out = append(out, dedupeLatinTokens(text)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeQuotePairs 是引号的开关对(同一字符既是开也是闭的只有 ASCII `"`)。
|
||||
func dedupeQuotePairs() [][2]rune {
|
||||
return [][2]rune{
|
||||
{'「', '」'},
|
||||
{'『', '』'},
|
||||
{'“', '”'},
|
||||
{'‘', '’'},
|
||||
{'"', '"'},
|
||||
}
|
||||
}
|
||||
|
||||
// dedupeQuotedValues 抽出所有**成对**引号里的内容(去首尾空白、丢弃空串与超长串)。
|
||||
//
|
||||
// 超长串(> dedupeQuoteMaxRunes)丢弃是刻意的:一整段引文(台词、段落)作为取值门
|
||||
// 会把「同一件事的不同引述」全挡掉,而取值门的靶子是**短标识**(昵称/暗号/代号)。
|
||||
func dedupeQuotedValues(text string) []string {
|
||||
runes := []rune(text)
|
||||
out := make([]string, 0, 2)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
open := runes[i]
|
||||
var closer rune
|
||||
switch open {
|
||||
case '「':
|
||||
closer = '」'
|
||||
case '『':
|
||||
closer = '』'
|
||||
case '“':
|
||||
closer = '”'
|
||||
case '‘':
|
||||
closer = '’'
|
||||
case '"':
|
||||
closer = '"'
|
||||
default:
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(runes); j++ {
|
||||
if runes[j] != closer {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(string(runes[i+1 : j]))
|
||||
if value != "" && len([]rune(value)) <= dedupeQuoteMaxRunes {
|
||||
out = append(out, value)
|
||||
}
|
||||
i = j
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeLatinTokens 抽出 ≥2 个字母的 ASCII 词(含 `h3R3` 这类字母+数字混排)。
|
||||
func dedupeLatinTokens(text string) []string {
|
||||
out := make([]string, 0, 2)
|
||||
var cur []rune
|
||||
hasLetter := false
|
||||
flush := func() {
|
||||
if hasLetter && len(cur) >= dedupeLatinMinRunes {
|
||||
out = append(out, string(cur))
|
||||
}
|
||||
cur = nil
|
||||
hasLetter = false
|
||||
}
|
||||
for _, r := range text {
|
||||
if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) {
|
||||
cur = append(cur, r)
|
||||
if unicode.IsLetter(r) {
|
||||
hasLetter = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
flush()
|
||||
}
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user