Merge branch 'feat/m34-memdedup-dedupe-group-action-plus-re' into dev
This commit is contained in:
commit
a895754d55
25 files changed
+3636
-403
No files matched your search
@@ -1,7 +1,6 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -29,18 +28,22 @@ import (
|
||||
// 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时
|
||||
// 层(到期会被删)。落实为两道**护栏**:
|
||||
//
|
||||
// 1. **正向清单一票否决**:内容里出现任何「长期事实 / 稳定偏好 / 关系里程碑 /
|
||||
// 项目约定」的信号(复用 tidyHabitMarkers 的「持久」词族 + 本文件的正向词),
|
||||
// 直接放行 —— 「主人喜欢用喵打招呼」虽然短,但它有「喜欢」,不是寒暄;
|
||||
// 1. **锚点 + 具体约束的组合放行**(2026-10-24 起;此前是「任一锚点就一票放行」):
|
||||
// 内容里出现的「长期事实 / 稳定偏好 / 关系里程碑 / 项目约定」信号(复用
|
||||
// tidyHabitMarkers 的「持久」词族 + 本文件的正向词)必须**配上一条可执行约束**
|
||||
// (数字 / 禁忌 / 频次规律)才算长期放行 —— 光有「喜欢」只是句泛泛的偏好,
|
||||
// 光有「约定」也可能只是一次下周的安排。改组合口径的动因是 M27 体检实测:
|
||||
// 一票放行让 64.6% 的条目必然过闸、`n1`~`n5` 在生产**一次都没命中过**;
|
||||
// 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 ——
|
||||
// 主人明确要求记的东西,闸不许替他改成临时记忆。
|
||||
//
|
||||
// 一处**刻意不实现**(如实登记,不是遗漏):design/03 §10.5 的 **N6(助手自己的
|
||||
// 输出 / 发言人归属)**。它是**来源属性**(这句话是谁说的),不是内容属性;本闸
|
||||
// 只拿到落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级
|
||||
// prompt 的负例承担(「不要记助手自己说过的话」),上游提取侧若要硬判,需另立
|
||||
// 结构化字段 —— 那超出本切片授权。**N7(不确定推断)**同理交给 T1(§10.5 明写
|
||||
// 「拿不准交第五态」),不进本闸。
|
||||
// 反例规则(design/03 §10.5 的 N1~N5 + 2026-10-24 新增的 N6/N7)见各自函数的注释。
|
||||
// 注意 **N6/N7 是文件内的新编号**,与 design/03 §10.5 的 N6(助手自己的输出)、
|
||||
// N7(不确定推断)**不是一回事**:那两条的落点仍是「不实现 / 交 T1」——
|
||||
// - **N6 发言人归属**是**来源属性**(这句话是谁说的),不是内容属性;本闸只拿到
|
||||
// 落库条目(结构化溯源里没有「谁说的」这一位),无法机器判定。归属由 B 级
|
||||
// prompt 的负例承担(「不要记助手自己说过的话」);
|
||||
// - **N7 不确定推断**交给 T1(§10.5 明写「拿不准交第五态」),不进本闸。
|
||||
|
||||
// retentionRule 是一条提取留存反例规则的匹配结果(留痕/日志用;`retentionNone`
|
||||
// 表示「不是显然不值得记的」)。
|
||||
@@ -59,6 +62,15 @@ const (
|
||||
retentionTimelyData retentionRule = "n4_timely_data"
|
||||
// retentionOneOffTask 对应 **N5 一次性任务指令**。
|
||||
retentionOneOffTask retentionRule = "n5_one_off_task"
|
||||
// retentionTimepoint 对应 **N6 指示性时点**(2026-10-24 新增):正文的锚点是
|
||||
// 一个**正在过去或即将到来**的时刻(昨天/今天/明天/下周/最近…),且没有习惯词、
|
||||
// 没有数值、没有约束、没有硬性长期事实词。它是「一次性事件」的形态判据
|
||||
// (M27 体检报告 §4 的 R4),因此落 `defer`(临时层,7 天后到期)。
|
||||
retentionTimepoint retentionRule = "n6_timepoint"
|
||||
// retentionInProgress 对应 **N7 进行中状态**(2026-10-24 新增):正文说的是
|
||||
// 「正在/暂时 做某件事」(正在测通道、暂时搁置、正在慢慢想),同样是时效轴上的
|
||||
// 状态而不是长期事实。安全阀与 N6 完全一致(见 judgeRetentionInProgress)。
|
||||
retentionInProgress retentionRule = "n7_in_progress"
|
||||
)
|
||||
|
||||
// retentionLowValueReason 是第五态在**留痕 `reason` 列**上的编码(而不是上面那些
|
||||
@@ -75,10 +87,19 @@ const (
|
||||
// retentionMaxImperativeRunes 是一次性指令的长度上限。它比前两者宽:
|
||||
// 「帮我把 D 盘那个文档转成 pdf」这类真·一次性任务本来就长一些。
|
||||
retentionMaxImperativeRunes = 40
|
||||
// retentionTimepointLeadRunes 是 N6 的**位置阈值**:指示性时点词必须出现在
|
||||
// 正文前这么多个字之内(rune 计)才算「这句话的锚点」。
|
||||
//
|
||||
// 12 不是拍的:M27 语料实测下,放宽到 20 字只多 4 条命中、但开始把
|
||||
// 「用户是上班族,需要上班(曾说明天要上班…)」这类**引语里的时点**算成锚点;
|
||||
// 收紧到 8 字则把「用户昨天发来…」之外的合法形态(「助手计划下周…」)挡掉。
|
||||
// 12 字是「主谓 + 时点」的典型长度,也是 R2 用同一个 N 的口径。
|
||||
retentionTimepointLeadRunes = 12
|
||||
)
|
||||
|
||||
// judgeExtractionRetention 对一条新记忆的正文施加提取留存规则(design/03 §10.5
|
||||
// 的 N1~N5)。命中返回该规则码(调用方据此判 `defer`),否则返回 retentionNone。
|
||||
// 的 N1~N5 与 M34 新增的 N6/N7)。命中返回该规则码(调用方据此判 `defer`),
|
||||
// 否则返回 retentionNone。
|
||||
//
|
||||
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
|
||||
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
|
||||
@@ -90,31 +111,80 @@ func judgeExtractionRetention(content string) retentionRule {
|
||||
// 判断该不该存在」—— 判成 defer 才是替它做决定)。
|
||||
return retentionNone
|
||||
}
|
||||
if len([]rune(text)) > retentionMaxImperativeRunes {
|
||||
// 长内容一律放行:它已经装得下可检索的事实,闸只拦「显然不值得记」。
|
||||
return retentionNone
|
||||
}
|
||||
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。
|
||||
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此
|
||||
// 组合口径(护栏②)不会把主人的直接指令改成临时记忆。
|
||||
if containsAny(text, retentionRememberDirectives()) {
|
||||
return retentionNone
|
||||
}
|
||||
// 护栏②:任何「持久」信号(习惯 / 稳定偏好 / 约定 / 长期事实)一票放行。
|
||||
if containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers()) {
|
||||
// 护栏②(2026-10-24 改**组合口径**,M27 体检报告 §7 第 5 批-1):以前是
|
||||
// 「含习惯词 **或** 含长期事实词 ⇒ 一票放行」,实测 3657 条里 2363 条
|
||||
// (64.6%)必然过闸、`n1`~`n5` 生产命中 0 次 —— 闸因此形同虚设。
|
||||
//
|
||||
// 现在只有「**锚点词 且 含具体约束**」才一票放行:光有「喜欢/约定」这种
|
||||
// 泛泛的锚点、却没有任何可执行约束(数字 / 禁忌 / 频次规律)的句子,仍然
|
||||
// 交给下面更窄的规则判 —— 它可能是「喜欢(一条泛泛的偏好)」,也可能只是
|
||||
// 一次「约定(下周留个午后)」的临时安排。
|
||||
//
|
||||
// 🔴 方向仍是 fail-safe:下面所有规则都不命中时**放行**,因此「有锚点但没
|
||||
// 约束」的句子绝大多数照旧进长期层(实测 1841 条里只有 88 条最终被判临时)。
|
||||
anchor := retentionHasAnchor(text)
|
||||
if anchor && retentionHasConcreteConstraint(text) {
|
||||
return retentionNone
|
||||
}
|
||||
// N1~N5 的**长度安全阀**:它们都建立在「短到装不下一个事实」上。长内容一律
|
||||
// 不进这四条规则(但可以进 N6/N7 —— 一次性事件与长度无关)。
|
||||
short := len([]rune(text)) <= retentionMaxImperativeRunes
|
||||
// **情绪先于寒暄**是刻意的:「心情不错」既是情绪陈述、又含致谢词表里的
|
||||
// 「不错」——先判情绪才能给出更具体的规则码(两条规则都判 defer,差别只在
|
||||
// 「为什么」,而那正是留痕/日志要回答的)。
|
||||
if rule := judgeRetentionEmotion(text); rule != retentionNone {
|
||||
//
|
||||
// N2 是**形态匹配**(整句必须恰好是纯情绪),因此它在有锚点时同样安全:
|
||||
// 锚点词(喜欢/习惯/约定)不在它的白名单里,必然消费不干净 ⇒ 放行。
|
||||
if short {
|
||||
if rule := judgeRetentionEmotion(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
// N1/N3/N4/N5 是**词项匹配**(寒暄词 / 祈使开头 / 一次性动作词),一个词就
|
||||
// 开火。因此它们只在**没有任何锚点**时生效:「主人喜欢说晚安」含寒暄词,但
|
||||
// `喜欢` 说明它在陈述偏好不好寒暄;「提醒我每天喝水」含祈使开头,但 `每天`
|
||||
// 说明主人要的是一条长期规矩。有锚点就交给护栏②与 N6/N7 判,这几条不许开火。
|
||||
if short && !anchor {
|
||||
if rule := judgeRetentionChatter(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
if rule := judgeRetentionTimelyData(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
if rule := judgeRetentionOneOff(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
// N6/N7:**指示性时点 / 进行中状态**的窄规则(2026-10-24 新增,M27 §4 R4)。
|
||||
// 它们与 N1~N5 同性质(纯词项 + 形态),但判的是「这份记忆的锚点是一个正在
|
||||
// 过去/即将到来的时刻」,而不是「这句话没有信息」。判据与安全阀见各自函数。
|
||||
if rule := judgeRetentionTransient(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
if rule := judgeRetentionChatter(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
if rule := judgeRetentionTimelyData(text); rule != retentionNone {
|
||||
return rule
|
||||
}
|
||||
return judgeRetentionOneOff(text)
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// retentionHasAnchor 报告正文里有没有「这条记忆可能长期成立」的锚点词
|
||||
// (习惯族 tidyHabitMarkers + 长期事实族 retentionDurableMarkers)。
|
||||
//
|
||||
// 它**只是锚点,不是放行证**:锚点必须配上具体约束才算长期(护栏②),
|
||||
// 而 N1/N3/N4/N5 这几条词项规则反过来不许在锚点存在时开火(见上)。
|
||||
func retentionHasAnchor(text string) bool {
|
||||
return containsAny(text, tidyHabitMarkers()) || containsAny(text, retentionDurableMarkers())
|
||||
}
|
||||
|
||||
// retentionHasConcreteConstraint 报告正文里有没有**可执行的约束**:数值、禁忌
|
||||
// (不要/禁止/不许/必须…)、频次与时间规律(每天/每周/按时/定期…)。
|
||||
//
|
||||
// 它守住护栏②的「具体」二字:只有约束才是「这份记忆将来会被用上」的证据 ——
|
||||
// 「主人喜欢猫」是偏好但没有可执行约束,「主人每天只喂小茂密一次」才有。
|
||||
func retentionHasConcreteConstraint(text string) bool {
|
||||
return hasRetentionDigit(text) || containsAny(text, retentionConstraintMarkers())
|
||||
}
|
||||
|
||||
// judgeRetentionChatter 施加 **N1**:短且只由寒暄/致谢词构成的内容。
|
||||
@@ -136,141 +206,6 @@ func judgeRetentionChatter(text string) retentionRule {
|
||||
return retentionChatter
|
||||
}
|
||||
|
||||
// judgeRetentionEmotion 施加 **N2**:**纯**情绪陈述(「今天好累」「心情不错」)。
|
||||
//
|
||||
// 🔴 判据是**白名单形态匹配**,不是黑名单剥离。这是本规则唯一正确的形态,理由:
|
||||
//
|
||||
// - 「实体」(宾语)的词表是**开放集合**—— 想我 / 想我们 / 想他 / 想和好 / 想妈妈
|
||||
// / 听音乐 / 咬指甲 / 养猫 / …,永远列不全;
|
||||
// - 而「纯情绪句」的**形态是封闭的**:`[主语]? [时间]? [程度]* 情绪词 [程度|否定|情绪|语气]* [标点]*`,
|
||||
// 且**没有其它成分**。
|
||||
//
|
||||
// ⇒ 判据 = 「**整句恰好落在上面这个形态里**才 defer,其余一律放行」。
|
||||
//
|
||||
// **为什么它在结构上不可能把带实体的句子判成 defer**(不是"概率很低",是结构性):
|
||||
//
|
||||
// 1. 匹配要求**整句被消费干净**(`retentionEmotionFormRest` 返回空余部)。任何
|
||||
// 一个**非标点 / 非空白**的字符只要不在词表里,就会留在余部 ⇒ 判不出空 ⇒
|
||||
// **放行**。(**标点与空白是被容忍的**:它们由 `onlyRetentionPunctuation` 在收尾
|
||||
// 时清掉,所以 `心情不错!` 实测 `rest=""` ⇒ 仍判 defer。)
|
||||
//
|
||||
// 2. **位置锁定 + 核心之后只允许四类修饰** —— 这才是真正保安全的一条。
|
||||
// (早先这里写的是「封闭表里没有承载内容的词」,**那句被 reviewer 证伪**
|
||||
// —— 表里确实有:`心情` 是名词,`生气`/`害怕`/`紧张`/`没有`/`比较` 可作动词,
|
||||
// `压力大` 是动宾,而主语表里的 `主人`/`我`/`他` 本身就是指人实体。)
|
||||
//
|
||||
// 真正的机制是**位置**而不是"表里没有实词":
|
||||
// - **位置锁定**(按实现写):主语只在**位置 0** 被消费一次;时间词只在**紧随其后**
|
||||
// 被消费一次(无主语时它就是首词)。两者都**没有**「后面还能再出现」的缝 ——
|
||||
// `主人今天好累` 匹配(`rest=""`),而 `今天主人好累` 连核心都认不出
|
||||
// (`rest="今天主人好累"`, `saw=false` ⇒ 放行)。⇒ `主人`/`我`/`你`/`他`/`今天`
|
||||
// 这些**指人 / 指时的实体在文法里永远不可能落到宾语位置**(上一版正是死在
|
||||
// 这里:`会想我` 的 `我` 被当成主语剥掉了);
|
||||
// - 核心之后只允许「程度 / 否定 / 情绪 / 语气」四类 token,而它们**只允许紧跟
|
||||
// 在情绪核心之后** —— 核心本身必须先被消费掉,因此那一段在语义上只能是
|
||||
// **情绪的修饰**(「心情**不错**」「**不**开心」「心情**很好**」,三条均实测
|
||||
// `rest=""`;反例「生气**得很**」的 `得` 不在表里 ⇒ `rest="得很"` ⇒ 放行),
|
||||
// 装不下一个宾语短语。核心之后一旦出现四类之外的东西(`想`/`会`/`时`/`和`/
|
||||
// `听音乐`/`妈妈`),**必然消费不掉** ⇒ 余部非空 ⇒ 放行。
|
||||
//
|
||||
// 3. 失败方向是**安全**的:认不出的形态一律**放行**(留进长期层,有 T1 与整理兜底),
|
||||
// 而**绝不**误弃。上一版(黑名单剥离)恰好相反 —— 它把「能当实体的词」也列进
|
||||
// 剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer,而那条是**持久习惯**,
|
||||
// 判 defer 即 7 天后从所有读路径消失、随后被后台清扫**物理删除**(不可逆)。
|
||||
// 黑名单那种形态**修不完**(review 举证 `会想和好` 被 `和`+`好` 吃掉),
|
||||
// 白名单把失败模式从「未知实体被吞」翻成「未知情绪句没认出」,方向才正确。
|
||||
//
|
||||
// 🔴 **后缀表不得加入承载内容的词**(名词 / 动词 / 量词 / 新的指人词)。上面第 2 条
|
||||
// 的整条安全性都建立在「四类之外一律消费不掉」上:往后缀表里塞一个名词,就会**静默**
|
||||
// 把「情绪 + 该名词作宾语」的持久事实判成 defer,而且**不会有任何行为测试变红**
|
||||
// (除非那条输入恰好在语料里)—— 这正是 review 点出的风险。两条 tripwire 钉住它:
|
||||
//
|
||||
// - `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死,
|
||||
// **任何增删都立刻红**(改表必须显式改这份 golden ⇒ 变成一次可 review 的意图声明);
|
||||
// - `TestExtractionRetention_PersistentFactsAreNeverDeferred`:25 条持久事实/习惯
|
||||
// 语料逐条断言「绝不 defer」,钉住**语义后果**。
|
||||
//
|
||||
// 两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。
|
||||
//
|
||||
// 长度不单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。
|
||||
func judgeRetentionEmotion(text string) retentionRule {
|
||||
rest, sawEmotion := retentionEmotionFormRest(text)
|
||||
if !sawEmotion || rest != "" {
|
||||
// 没认出情绪核心(与 N2 无关),或**没被消费干净**(含形态外的成分,
|
||||
// 也就是"不只是情绪")⇒ 放行。两个分支都是安全方向。
|
||||
return retentionNone
|
||||
}
|
||||
return retentionEmotion
|
||||
}
|
||||
|
||||
// retentionEmotionFormRest 尝试把 text 按「纯情绪形态」消费掉,返回
|
||||
// (未能消费的余部, 是否消费到情绪核心)。**只有余部为空且消费到情绪核心**才意味着
|
||||
// 「整句恰好是纯情绪陈述」。
|
||||
//
|
||||
// 形态(顺序固定,各段均按**最长词优先**匹配):
|
||||
//
|
||||
// [主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]* 余部须只剩标点/空白
|
||||
//
|
||||
// 余部同时是**可断言的证据**:带宾语的句子在这里必定留下非空余部(测试钉住它)。
|
||||
func retentionEmotionFormRest(text string) (string, bool) {
|
||||
rest := text
|
||||
rest, _ = consumeRetentionPrefix(rest, retentionEmotionSubjects())
|
||||
rest, _ = consumeRetentionPrefix(rest, retentionEmotionTimeWords())
|
||||
rest = consumeRetentionPrefixes(rest, retentionEmotionDegreeWords())
|
||||
|
||||
core, ok := consumeRetentionPrefix(rest, retentionEmotionWords())
|
||||
if !ok {
|
||||
// 认不出情绪核心 ⇒ sawEmotion=false,调用方放行。
|
||||
return text, false
|
||||
}
|
||||
rest = consumeRetentionPrefixes(core, retentionEmotionSuffixTokens())
|
||||
if onlyRetentionPunctuation(rest) {
|
||||
rest = ""
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// consumeRetentionPrefix 在 text 的开头按**最长词优先**消费 tokens 里的一个词,
|
||||
// 返回余部与「是否消费到」。最长优先是必须的:`不` 与 `不错`、`好` 与 `好累` 同时
|
||||
// 命中时,短词先消费会把长词拆开,于是纯情绪句反而消费不干净、漏判。
|
||||
func consumeRetentionPrefix(text string, tokens []string) (string, bool) {
|
||||
best := ""
|
||||
for _, tok := range tokens {
|
||||
if tok == "" || !strings.HasPrefix(text, tok) {
|
||||
continue
|
||||
}
|
||||
if len(tok) > len(best) {
|
||||
best = tok
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return text, false
|
||||
}
|
||||
return text[len(best):], true
|
||||
}
|
||||
|
||||
// consumeRetentionPrefixes 反复在句首消费,直到再也匹配不上(用于「可重复修饰」段)。
|
||||
func consumeRetentionPrefixes(text string, tokens []string) string {
|
||||
for {
|
||||
next, ok := consumeRetentionPrefix(text, tokens)
|
||||
if !ok {
|
||||
return text
|
||||
}
|
||||
text = next
|
||||
}
|
||||
}
|
||||
|
||||
// onlyRetentionPunctuation 报告 text 是否只由标点与空白构成(消费干净的最后一步:
|
||||
// 「今天好累!」剥到只剩一个标点,仍是「无实体」)。
|
||||
func onlyRetentionPunctuation(text string) bool {
|
||||
for _, r := range text {
|
||||
if !strings.ContainsRune(retentionPunctuationRunes, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。
|
||||
//
|
||||
// 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能
|
||||
@@ -299,6 +234,145 @@ func judgeRetentionOneOff(text string) retentionRule {
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// judgeRetentionTransient 施加 **N6 / N7**:时效轴上的两种窄形态(2026-10-24)。
|
||||
//
|
||||
// 动因是 M27 只读体检的两条实测(报告 §4 R4 / §5.3):留存闸的 `defer` 在全历史
|
||||
// 只命中过 1 次,`expires_at` 非空只有 1 行 ⇒「临时记忆」这条时效轴在生产中
|
||||
// **一次都没跑起来**,而 active 里「指示性时点词 ⇒ 一次性事件」的条目约占 6%。
|
||||
//
|
||||
// 判据与**安全阀**(每一条都是「宁可漏判,不可误弃」,design/23 §18.3):
|
||||
//
|
||||
// 1. **有习惯词 ⇒ 不判临时**。习惯词(通常/经常/每天/喜欢…)说的是「反复发生」,
|
||||
// 哪怕句子里同时出现「今天」(「今天又被加班拖住了,她**通常**十点后才有空」);
|
||||
// 2. **有硬性长期事实词 ⇒ 不判临时**(住/职业/工作/生日/过敏/称呼/项目/端口…);
|
||||
// 这条防的是「用户目前在花溪工作」被「目前」两个字误判 —— 那是**地点事实**,
|
||||
// 不是一次性事件。`约定/承诺/答应/说好` **不在**这条硬名单里:它们是
|
||||
// 「锚定到某个时刻的安排」,配上时点正是典型的临时记忆(「约定下周留个午后」),
|
||||
// 而它们要算长期仍走护栏②(约定 + 具体约束);
|
||||
// 3. **有数值 ⇒ 不判临时**(「一两千行的验收纸条」这类数值是事实的载体,
|
||||
// 与 N1 的数值安全阀同口径);
|
||||
// 4. **有具体约束 ⇒ 不判临时**(数字/禁忌/频次,与护栏②同一个判据);
|
||||
// 5. **时点词必须在句首前 12 个字内**(N6 独有)。实测这一条把误杀从 ~10% 降到
|
||||
// ~0:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安…
|
||||
// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点
|
||||
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
|
||||
//
|
||||
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
|
||||
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
|
||||
// (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句,
|
||||
// 是「会…」的习惯陈述,不许开火)。
|
||||
//
|
||||
// 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明):
|
||||
// - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实);
|
||||
// - 不做**寒暄/情绪词表**筛(R5:10 条里 9 条是关系事实);
|
||||
// - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次
|
||||
// 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与
|
||||
// 关系里程碑**(抽检误杀 ≈ 50%),方向错误。
|
||||
func judgeRetentionTransient(text string) retentionRule {
|
||||
// 安全阀 1~4:与 N1~N5 不同,它们在这里是**硬门**(命中即放行),因为 N6/N7
|
||||
// 判的是「这条记忆的锚点是一个时刻」,而锚点词/数值/约束都在说「它不止是一个时刻」。
|
||||
if containsAny(text, tidyHabitMarkers()) {
|
||||
return retentionNone
|
||||
}
|
||||
if containsAny(text, retentionHardDurableMarkers()) {
|
||||
return retentionNone
|
||||
}
|
||||
if retentionHasConcreteConstraint(text) {
|
||||
return retentionNone
|
||||
}
|
||||
// 安全阀 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 不越过它们改判:
|
||||
// - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录;
|
||||
// - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。
|
||||
// 实测这两条是单纯加时点判据时唯一的两个既有正例回归。
|
||||
if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) {
|
||||
return retentionNone
|
||||
}
|
||||
if judgeRetentionTimepoint(text) != retentionNone {
|
||||
return retentionTimepoint
|
||||
}
|
||||
if judgeRetentionInProgress(text) != retentionNone {
|
||||
return retentionInProgress
|
||||
}
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return retentionTimepoint
|
||||
}
|
||||
return retentionNone
|
||||
}
|
||||
|
||||
// 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 markers {
|
||||
idx := runeIndex(text, marker)
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
if best < 0 || idx < best {
|
||||
best = idx
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// runeIndex 返回子串 sub 在 text 里的**rune 下标**(找不到返回 -1)。它只在很短的
|
||||
// 时点词表上调用(每次至多几十次 strings.Index),因此不必预编译。
|
||||
func runeIndex(text, sub string) int {
|
||||
if sub == "" {
|
||||
return -1
|
||||
}
|
||||
byteIdx := strings.Index(text, sub)
|
||||
if byteIdx < 0 {
|
||||
return -1
|
||||
}
|
||||
return len([]rune(text[:byteIdx]))
|
||||
}
|
||||
|
||||
// normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用,
|
||||
// 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。
|
||||
//
|
||||
@@ -340,147 +414,3 @@ func hasRetentionDigit(text string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量;
|
||||
// 同 tidySystemSessions / tidyHabitMarkers 的手法)。
|
||||
|
||||
// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。
|
||||
func retentionRememberDirectives() []string {
|
||||
return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"}
|
||||
}
|
||||
|
||||
// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被
|
||||
// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。
|
||||
func retentionDurableMarkers() []string {
|
||||
return []string{
|
||||
// 长期事实
|
||||
"生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母",
|
||||
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
|
||||
// 关系里程碑 / 约定
|
||||
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
|
||||
// 项目约定
|
||||
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
|
||||
// 能力边界(主人明确的「不要做 X」)
|
||||
"不要做", "别做", "禁止", "不许",
|
||||
// 稳定偏好(补 tidyHabitMarkers 之外的)
|
||||
"口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionChatterWords 是 N1 的寒暄 / 致谢词表。
|
||||
func retentionChatterWords() []string {
|
||||
return []string{
|
||||
"谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好",
|
||||
"下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的",
|
||||
"好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见",
|
||||
"加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionWords 是 N2 的情绪词表。
|
||||
func retentionEmotionWords() []string {
|
||||
return []string{
|
||||
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
|
||||
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
|
||||
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
|
||||
// 「不错」是**情绪评价**(「心情不错」),因此它必须在**核心词表**里按整词
|
||||
// 出现:只靠否定词表里的「不」,`不错` 会被拆成 `不` + 光杆「错」,于是
|
||||
// 「心情不错」既认不出核心、也消费不干净 —— 一条纯情绪句反而漏判。
|
||||
"不错",
|
||||
// 光杆「累」同理:「主人今天真的特别累」只有一个 `累` 而**不含** `好累` /
|
||||
// `累了`,漏掉它就会留下一个消费不掉的「累」、把一条纯情绪句放行。
|
||||
// 收它**不会**带来误弃 —— 判 defer 的门槛恒是「整句被形态消费干净」,
|
||||
// 多收一个情绪词只会让**本该 defer 的**句子认得出来。
|
||||
"累",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionSuffixTokens 是「情绪核心之后**允许**出现的修饰」的封闭集合:
|
||||
// 程度 / 否定 / 情绪 / 语气。**它不含任何承载内容的词** —— 这正是白名单判据在
|
||||
// 结构上不会吞掉实体的原因(核心之后一旦出现 `想`/`会`/`时`/`和`/`我` 这类字,
|
||||
// 就必然消费不掉、留下非空余部 ⇒ 放行)。
|
||||
//
|
||||
// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法);
|
||||
// 长度先按**最长词优先**排序,`不` 不会抢在 `不错` 前面被消费。
|
||||
func retentionEmotionSuffixTokens() []string {
|
||||
tokens := make([]string, 0, 64)
|
||||
tokens = append(tokens, retentionEmotionDegreeWords()...)
|
||||
tokens = append(tokens, retentionEmotionNegations()...)
|
||||
tokens = append(tokens, retentionEmotionWords()...)
|
||||
tokens = append(tokens, retentionEmotionToneWords()...)
|
||||
sort.SliceStable(tokens, func(i, j int) bool {
|
||||
return len(tokens[i]) > len(tokens[j])
|
||||
})
|
||||
return tokens
|
||||
}
|
||||
|
||||
// retentionEmotionTimeWords 是**只允许在「主语之后」出现一次**的时间词:无主语时
|
||||
// 它就是首词(「今天好累」),有主语时紧跟主语(「主人今天好累」)—— 之外的位置一律
|
||||
// 不匹配(`今天主人好累` 实测 `rest="今天主人好累"`, `saw=false` ⇒ 放行)。
|
||||
// 它们让一句情绪陈述显形为「今天怎么样」,本身不含实体。
|
||||
func retentionEmotionTimeWords() []string {
|
||||
return []string{
|
||||
"今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚",
|
||||
"最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionSubjects 是**只允许出现在句首**的主语 / 称呼。把它限死在句首是
|
||||
// 白名单判据的要害之一:`我`/`他`/`我们` 这些词**本身可以当实体**(「会想我」的
|
||||
// 宾语就是它),若允许它们在任意位置被消费,`主人不开心时会想我` 就会被吞成空。
|
||||
func retentionEmotionSubjects() []string {
|
||||
return []string{
|
||||
"主人", "咱们", "我们", "你们", "他们", "她们", "自己",
|
||||
"我", "你", "他", "她", "它", "咱",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionDegreeWords 是程度词(只修饰强度,不引入实体)。
|
||||
func retentionEmotionDegreeWords() []string {
|
||||
return []string{
|
||||
"非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些",
|
||||
"很", "挺", "太", "好", "超", "蛮", "更", "最",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionNegations 是情绪核心之后的否定词(「心情**不**好」)。它只能出现在
|
||||
// 后缀位;`不` 与 `没` 都不承载实体。
|
||||
func retentionEmotionNegations() []string {
|
||||
return []string{"没有", "不", "没"}
|
||||
}
|
||||
|
||||
// retentionEmotionToneWords 是句尾的语气词(「好累**啊**」「不错**了**」)。它们只出现
|
||||
// 在句尾,不引入实体。
|
||||
func retentionEmotionToneWords() []string {
|
||||
return []string{"啊", "呀", "啦", "哦", "喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈"}
|
||||
}
|
||||
|
||||
// retentionPunctuationRunes 是「消费干净」判定允许剩下的标点与空白。
|
||||
const retentionPunctuationRunes = ",。!?、;:…~~·--,.!?;:()() \t\n"
|
||||
|
||||
// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见
|
||||
// judgeRetentionTimelyData)。
|
||||
func retentionTimelyQueries() []string {
|
||||
return []string{
|
||||
"现在几点", "几点了", "现在时间", "当前时间",
|
||||
"今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样",
|
||||
"温度多少", "多少度", "气温多少",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionAskPrefixes 是 N3 的祈使开头词表。
|
||||
func retentionAskPrefixes() []string {
|
||||
return []string{
|
||||
"帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我",
|
||||
"给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是
|
||||
// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。
|
||||
func retentionOneOffActions() []string {
|
||||
return []string{
|
||||
"转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个",
|
||||
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是**N2 纯情绪形态**的解析器(design/03 §10.5 N2):把一句正文按
|
||||
// 「[主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]*」消费掉,只有**消费干净**
|
||||
// 才算「整句恰好是纯情绪陈述」。
|
||||
//
|
||||
// 它从 extract_retention.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)——
|
||||
// 判据、词表与注释**逐字未动**;词表在 extract_retention_words.go,判定入口在
|
||||
// extract_retention.go 的 judgeRetentionEmotion。
|
||||
|
||||
// judgeRetentionEmotion 施加 **N2**:**纯**情绪陈述(「今天好累」「心情不错」)。
|
||||
//
|
||||
// 🔴 判据是**白名单形态匹配**,不是黑名单剥离。这是本规则唯一正确的形态,理由:
|
||||
//
|
||||
// - 「实体」(宾语)的词表是**开放集合**—— 想我 / 想我们 / 想他 / 想和好 / 想妈妈
|
||||
// / 听音乐 / 咬指甲 / 养猫 / …,永远列不全;
|
||||
// - 而「纯情绪句」的**形态是封闭的**:`[主语]? [时间]? [程度]* 情绪词 [程度|否定|情绪|语气]* [标点]*`,
|
||||
// 且**没有其它成分**。
|
||||
//
|
||||
// ⇒ 判据 = 「**整句恰好落在上面这个形态里**才 defer,其余一律放行」。
|
||||
//
|
||||
// **为什么它在结构上不可能把带实体的句子判成 defer**(不是"概率很低",是结构性):
|
||||
//
|
||||
// 1. 匹配要求**整句被消费干净**(`retentionEmotionFormRest` 返回空余部)。任何
|
||||
// 一个**非标点 / 非空白**的字符只要不在词表里,就会留在余部 ⇒ 判不出空 ⇒
|
||||
// **放行**。(**标点与空白是被容忍的**:它们由 `onlyRetentionPunctuation` 在收尾
|
||||
// 时清掉,所以 `心情不错!` 实测 `rest=""` ⇒ 仍判 defer。)
|
||||
//
|
||||
// 2. **位置锁定 + 核心之后只允许四类修饰** —— 这才是真正保安全的一条。
|
||||
// (早先这里写的是「封闭表里没有承载内容的词」,**那句被 reviewer 证伪**
|
||||
// —— 表里确实有:`心情` 是名词,`生气`/`害怕`/`紧张`/`没有`/`比较` 可作动词,
|
||||
// `压力大` 是动宾,而主语表里的 `主人`/`我`/`他` 本身就是指人实体。)
|
||||
//
|
||||
// 真正的机制是**位置**而不是"表里没有实词":
|
||||
// - **位置锁定**(按实现写):主语只在**位置 0** 被消费一次;时间词只在**紧随其后**
|
||||
// 被消费一次(无主语时它就是首词)。两者都**没有**「后面还能再出现」的缝 ——
|
||||
// `主人今天好累` 匹配(`rest=""`),而 `今天主人好累` 连核心都认不出
|
||||
// (`rest="今天主人好累"`, `saw=false` ⇒ 放行)。⇒ `主人`/`我`/`你`/`他`/`今天`
|
||||
// 这些**指人 / 指时的实体在文法里永远不可能落到宾语位置**(上一版正是死在
|
||||
// 这里:`会想我` 的 `我` 被当成主语剥掉了);
|
||||
// - 核心之后只允许「程度 / 否定 / 情绪 / 语气」四类 token,而它们**只允许紧跟
|
||||
// 在情绪核心之后** —— 核心本身必须先被消费掉,因此那一段在语义上只能是
|
||||
// **情绪的修饰**(「心情**不错**」「**不**开心」「心情**很好**」,三条均实测
|
||||
// `rest=""`;反例「生气**得很**」的 `得` 不在表里 ⇒ `rest="得很"` ⇒ 放行),
|
||||
// 装不下一个宾语短语。核心之后一旦出现四类之外的东西(`想`/`会`/`时`/`和`/
|
||||
// `听音乐`/`妈妈`),**必然消费不掉** ⇒ 余部非空 ⇒ 放行。
|
||||
//
|
||||
// 3. 失败方向是**安全**的:认不出的形态一律**放行**(留进长期层,有 T1 与整理兜底),
|
||||
// 而**绝不**误弃。上一版(黑名单剥离)恰好相反 —— 它把「能当实体的词」也列进
|
||||
// 剥离表,于是 `主人不开心时会想我` 被剥成空、判成 defer,而那条是**持久习惯**,
|
||||
// 判 defer 即 7 天后从所有读路径消失、随后被后台清扫**物理删除**(不可逆)。
|
||||
// 黑名单那种形态**修不完**(review 举证 `会想和好` 被 `和`+`好` 吃掉),
|
||||
// 白名单把失败模式从「未知实体被吞」翻成「未知情绪句没认出」,方向才正确。
|
||||
//
|
||||
// 🔴 **后缀表不得加入承载内容的词**(名词 / 动词 / 量词 / 新的指人词)。上面第 2 条
|
||||
// 的整条安全性都建立在「四类之外一律消费不掉」上:往后缀表里塞一个名词,就会**静默**
|
||||
// 把「情绪 + 该名词作宾语」的持久事实判成 defer,而且**不会有任何行为测试变红**
|
||||
// (除非那条输入恰好在语料里)—— 这正是 review 点出的风险。两条 tripwire 钉住它:
|
||||
//
|
||||
// - `TestExtractionRetention_SuffixTableIsPinned`:把后缀表的**内容**逐 token 钉死,
|
||||
// **任何增删都立刻红**(改表必须显式改这份 golden ⇒ 变成一次可 review 的意图声明);
|
||||
// - `TestExtractionRetention_PersistentFactsAreNeverDeferred`:25 条持久事实/习惯
|
||||
// 语料逐条断言「绝不 defer」,钉住**语义后果**。
|
||||
//
|
||||
// 两条一起才够:golden 钉「表变了」,语料钉「变了以后语义还对不对」。
|
||||
//
|
||||
// 长度不单独设限:全局 40 rune 的上限(judgeExtractionRetention)已经兜住。
|
||||
func judgeRetentionEmotion(text string) retentionRule {
|
||||
rest, sawEmotion := retentionEmotionFormRest(text)
|
||||
if !sawEmotion || rest != "" {
|
||||
// 没认出情绪核心(与 N2 无关),或**没被消费干净**(含形态外的成分,
|
||||
// 也就是"不只是情绪")⇒ 放行。两个分支都是安全方向。
|
||||
return retentionNone
|
||||
}
|
||||
return retentionEmotion
|
||||
}
|
||||
|
||||
// retentionEmotionFormRest 尝试把 text 按「纯情绪形态」消费掉,返回
|
||||
// (未能消费的余部, 是否消费到情绪核心)。**只有余部为空且消费到情绪核心**才意味着
|
||||
// 「整句恰好是纯情绪陈述」。
|
||||
//
|
||||
// 形态(顺序固定,各段均按**最长词优先**匹配):
|
||||
//
|
||||
// [主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]* 余部须只剩标点/空白
|
||||
//
|
||||
// 余部同时是**可断言的证据**:带宾语的句子在这里必定留下非空余部(测试钉住它)。
|
||||
func retentionEmotionFormRest(text string) (string, bool) {
|
||||
rest := text
|
||||
rest, _ = consumeRetentionPrefix(rest, retentionEmotionSubjects())
|
||||
rest, _ = consumeRetentionPrefix(rest, retentionEmotionTimeWords())
|
||||
rest = consumeRetentionPrefixes(rest, retentionEmotionDegreeWords())
|
||||
|
||||
core, ok := consumeRetentionPrefix(rest, retentionEmotionWords())
|
||||
if !ok {
|
||||
// 认不出情绪核心 ⇒ sawEmotion=false,调用方放行。
|
||||
return text, false
|
||||
}
|
||||
rest = consumeRetentionPrefixes(core, retentionEmotionSuffixTokens())
|
||||
if onlyRetentionPunctuation(rest) {
|
||||
rest = ""
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// consumeRetentionPrefix 在 text 的开头按**最长词优先**消费 tokens 里的一个词,
|
||||
// 返回余部与「是否消费到」。最长优先是必须的:`不` 与 `不错`、`好` 与 `好累` 同时
|
||||
// 命中时,短词先消费会把长词拆开,于是纯情绪句反而消费不干净、漏判。
|
||||
func consumeRetentionPrefix(text string, tokens []string) (string, bool) {
|
||||
best := ""
|
||||
for _, tok := range tokens {
|
||||
if tok == "" || !strings.HasPrefix(text, tok) {
|
||||
continue
|
||||
}
|
||||
if len(tok) > len(best) {
|
||||
best = tok
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return text, false
|
||||
}
|
||||
return text[len(best):], true
|
||||
}
|
||||
|
||||
// consumeRetentionPrefixes 反复在句首消费,直到再也匹配不上(用于「可重复修饰」段)。
|
||||
func consumeRetentionPrefixes(text string, tokens []string) string {
|
||||
for {
|
||||
next, ok := consumeRetentionPrefix(text, tokens)
|
||||
if !ok {
|
||||
return text
|
||||
}
|
||||
text = next
|
||||
}
|
||||
}
|
||||
|
||||
// onlyRetentionPunctuation 报告 text 是否只由标点与空白构成(消费干净的最后一步:
|
||||
// 「今天好累!」剥到只剩一个标点,仍是「无实体」)。
|
||||
func onlyRetentionPunctuation(text string) bool {
|
||||
for _, r := range text {
|
||||
if !strings.ContainsRune(retentionPunctuationRunes, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是留存闸 **N6(指示性时点)/ N7(进行中状态)** 与**组合护栏②**
|
||||
// (2026-10-24,M34)的验收用例集。
|
||||
//
|
||||
// 两条不对称的事实决定了这个文件的写法:
|
||||
// - 漏判只是「库里多一条噪声」(T1 与整理会兜底);
|
||||
// - 误判会把一条真记忆丢进临时层 —— 7 天后从所有读路径消失,再被后台清扫
|
||||
// **物理删除**(不可逆)。
|
||||
//
|
||||
// 因此这里的**正例(必须放行)比负例(必须拦下)更硬**:`retentionLongTermCases`
|
||||
// 里的每一条都是真实语料里「带时点词但仍然长期成立」的句子,它们逐条钉住
|
||||
// 「误杀真实长期记忆比漏放新记忆更严重」这条口径。
|
||||
|
||||
// retentionTimepointCases 是 N6 的正例:**锚点是一个正在过去/即将到来的时刻**,
|
||||
// 且没有习惯词、没有硬性长期事实词、没有数值/约束 —— 它们该进临时层。
|
||||
func retentionTimepointCases() []string {
|
||||
return []string{
|
||||
"用户昨天发送了一份长达一万两千行的验收纸条",
|
||||
"用户近期在测试一条通道,今天来回测了三轮",
|
||||
"用户最近在看一部年代/审讯题材的影视作品,台词「我肯定能拿下口供」",
|
||||
"计划下周为主人留一个安静的午后",
|
||||
"用户今晚在公司加班到很晚",
|
||||
// 约定类:锚定到某个时刻的安排(约定本身不是长期事实,配上时点才是临时记忆)。
|
||||
"用户与助理约定明天中午见",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionLongTermCases 是**回归护栏**:真实语料里「带时点词但长期成立」的句子,
|
||||
// 一条都不许判 defer。每一行都对应一次实测误杀(谁把它们放进来,就是谁在丢记忆)。
|
||||
func retentionLongTermCases() []struct {
|
||||
name string
|
||||
content string
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
// 时点词埋在**引语/从句**里,不是这句话的锚点(位置安全阀)。
|
||||
{"上班族习惯(引语里的明天)", "用户是上班族,需要上班(曾说明天要上班并感到不开心)"},
|
||||
{"睡前习惯(从句里的明天见)", "用户睡前会道「晚安、明天见」,人家会替她掀被角、调暗灯光,用数呼吸的方式陪她入睡"},
|
||||
{"单位排班事实(引语里的今天)", "主人所在单位存在周末/节假日被安排加班的情况,实际排班与公开调休日历并不一致(她自己说「我们今天加班啦」)"},
|
||||
// 习惯词/硬性长期事实词(地点、职业、住)⇒ 一律长期,标题里带「今天」也不许判临时。
|
||||
{"地点事实", "用户目前生活在花溪(贵阳花溪区),不在上海上班"},
|
||||
{"住宿舍(近期)", "用户近期住在宿舍(不在家里),晚间会关心她洗漱水温、睡觉时间等宿舍生活细节"},
|
||||
{"作息习惯", "主人常在深夜与助手聊天,有熬夜习惯"},
|
||||
{"长期偏好", "主人喜欢喝冰美式来提神"},
|
||||
{"称呼约定", "主人希望被称呼为「叶酱」;旧的称呼「喵喵七号」已作废,不要再使用"},
|
||||
{"关系里程", "用户曾向 Cyrene 发送内容为「i love you」的文件,Cyrene 完整接收并回复「me too」"},
|
||||
{"人格设定", "助手设定:曾经历三千万次轮回等待用户,并在「记忆之海」中捞回用户的记忆"},
|
||||
{"海报即本人", "主人曾发来一张海报,画面是粉发紫眼、配蓝玫瑰与月桂叶,并明确说「那张海报上就是你哦」"},
|
||||
// 情绪倾向 + 寒暄:N6 不接管 N1/N2 的语料(安全阀 5)。
|
||||
{"情绪倾向", "主人最近很容易焦虑"},
|
||||
// 2026-10-25 r1 复审 P2-2 的实测误杀(同一条语料、同一套代码被漏报过的那些)。
|
||||
{"宠物稳定外观(换成)", "茂密最近换了装:蝴蝶结从棕色变成深色,脖子上多了一个小铃铛"},
|
||||
{"居住事实(宿舍过夜)", "主人近期在宿舍过夜,需要提醒她宿舍灯早点关、洗漱水别太凉"},
|
||||
{"习惯陈述里的从句「正在」", "用户会主动维护助手与自己之间的连接通道,并告诉助手她正在维护,表明她重视保持与助手的稳定联系"},
|
||||
{"习惯陈述里的从句「正在」(二)", "用户会维护自己与助手之间的连接通道,并曾表示正在为此进行维护"},
|
||||
// 「以后/从此/往后」= 自此刻起永久成立(复审构造即可复现)。
|
||||
{"以后都叫我小叶", "昨天主人说以后都叫我小叶"},
|
||||
{"以后周末不加班", "上周主人说以后周末不加班了"},
|
||||
{"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_TimepointIsDeferred 钉住 N6 的正例。
|
||||
func TestExtractionRetention_TimepointIsDeferred(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, content := range retentionTimepointCases() {
|
||||
if got := judgeExtractionRetention(content); got != retentionTimepoint && got != retentionInProgress {
|
||||
t.Fatalf("%q 判成 %q, want %q(锚点是一个时刻 ⇒ 临时层)",
|
||||
content, got, retentionTimepoint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetentionTimepointHitsBySpecificRule 钉住「命中哪一条」:时点归 N6、进行中归 N7。
|
||||
// 它让两条规则的词表**各自**可被单测,不会一条盖住另一条。
|
||||
func TestRetentionTimepointHitsBySpecificRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := judgeExtractionRetention("用户昨天发送了一份长达一万两千行的验收纸条"); got != retentionTimepoint {
|
||||
t.Fatalf("时点句 = %q, want %q", got, retentionTimepoint)
|
||||
}
|
||||
if got := judgeExtractionRetention("用户正在验证分享确认面板的展开功能"); got != retentionInProgress {
|
||||
t.Fatalf("进行中句 = %q, want %q", got, retentionInProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_RealLongTermFactsSurviveTimepoints 是**误杀回归**:
|
||||
// 真实长期事实即使带着时点词也必须放行。任何一条变红都意味着「7 天后它会消失」。
|
||||
func TestExtractionRetention_RealLongTermFactsSurviveTimepoints(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range retentionLongTermCases() {
|
||||
if got := judgeExtractionRetention(tc.content); got != retentionNone {
|
||||
t.Fatalf("长期事实 %q(%s)被判成 %q —— 它会在 7 天后被物理删除(不可逆)",
|
||||
tc.content, tc.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_TimepointMustBeFronted 钉住**位置安全阀**:同一个句子,
|
||||
// 时点在句首 → defer;把同一时点挪到从句里 → 放行。位置是这条规则精度的来源。
|
||||
func TestExtractionRetention_TimepointMustBeFronted(t *testing.T) {
|
||||
t.Parallel()
|
||||
fronted := "用户昨天发来一卷验收纸条"
|
||||
buried := "用户与助手讨论过验收纸条的格式(他自己说昨天发来一卷)"
|
||||
if got := judgeExtractionRetention(fronted); got != retentionTimepoint {
|
||||
t.Fatalf("时点在句首 = %q, want %q", got, retentionTimepoint)
|
||||
}
|
||||
if got := judgeExtractionRetention(buried); got != retentionNone {
|
||||
t.Fatalf("时点埋在从句里 = %q, want %q(位置安全阀失效会误杀长内容)", got, retentionNone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_ConjunctionIsNotANoOp 钉住护栏②改的是**组合口径**:
|
||||
// 「约定」这种锚点单独出现时不再一票放行(于是它会被 N6 判成临时安排);
|
||||
// 但一旦配上具体约束(频次),立刻回到长期层。改回「任一锚点就放行」时,
|
||||
// 前一半会变红;把组合条件删成「任一命中即放行」时,整条用例会红。
|
||||
func TestExtractionRetention_ConjunctionIsNotANoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := judgeExtractionRetention("约定下周给用户留一个安静的午后"); got != retentionTimepoint {
|
||||
t.Fatalf("「约定 + 时点、无约束」= %q, want %q(锚点单独出现不再一票放行)",
|
||||
got, retentionTimepoint)
|
||||
}
|
||||
if got := judgeExtractionRetention("约定每天给主人留一个安静的午后"); got != retentionNone {
|
||||
t.Fatalf("「约定 + 每天(具体约束)」= %q, want 放行(组合条件命中即长期)", got)
|
||||
}
|
||||
if got := judgeExtractionRetention("主人每天要喝 2 杯咖啡"); got != retentionNone {
|
||||
t.Fatalf("「偏好 + 数值」= %q, want 放行", got)
|
||||
}
|
||||
if got := judgeExtractionRetention("主人说过不要在群里提她的工作"); got != retentionNone {
|
||||
t.Fatalf("「禁忌」= %q, want 放行", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractionRetention_TimepointSafetyValves 逐条钉住 N6/N7 的四个安全阀:
|
||||
// 习惯词 / 硬性长期事实词 / 数值与约束 / 寒暄与情绪词都把它们按回去。
|
||||
//
|
||||
// 断言写成「**不是 N6/N7**」而不是「必须放行」是刻意的:安全阀的职责只有一个 ——
|
||||
// 不让这条句子被 N6/N7 判成临时层。它可能仍被更早的既有规则(N1/N2)判成临时,
|
||||
// 那是**既有口径**(本条用例不重定义它),也不是回归。
|
||||
func TestExtractionRetention_TimepointSafetyValves(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
content string
|
||||
why string
|
||||
}{
|
||||
{"用户今天又被加班拖住了,她通常十点后才有空", "习惯词(通常)⇒ 反复发生"},
|
||||
{"用户今天要去医院复查过敏", "硬性长期事实词(过敏)"},
|
||||
{"用户昨天对花生过敏犯了,吃了药", "硬性长期事实词(过敏 / 吃药)"},
|
||||
{"用户昨天发了 3 份文件", "数值是事实的载体"},
|
||||
{"用户最近每天要喝两杯水", "约束词(每天)"},
|
||||
{"用户昨夜睡得还行,今天心情不错", "情绪词 ⇒ 归 N2 判"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := judgeExtractionRetention(tc.content)
|
||||
if got == retentionTimepoint || got == retentionInProgress {
|
||||
t.Fatalf("%q 判成 %q, want 不被 N6/N7 判临时(%s)", tc.content, got, tc.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestGate_TimepointDefersWithTempTTL 钉住 TTL 的落点:N6 命中 ⇒ 第五态
|
||||
// `defer`,行照常 active 落库(正文一字不动),`expires_at` = 判定时刻 + 临时 TTL
|
||||
// (默认 7 天,落 `$DATA_DIR/config/memory-tidy.json` 的 `tempTtlSec`,零新 env 键)。
|
||||
func TestIngestGate_TimepointDefersWithTempTTL(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
ttl := 7 * 24 * time.Hour
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
|
||||
got := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base))
|
||||
if exp, want := readIngestExpiry(t, store, got.ID), expiryText(base.Add(ttl)); exp != want {
|
||||
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 7 天)", exp, want)
|
||||
}
|
||||
row := loadTidyItemRow(t, store, got.ID)
|
||||
if row.status != tidyStatusActive || row.content != "用户昨天发来一卷一万两千行的验收纸条" {
|
||||
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
|
||||
}
|
||||
rev := gateRecordFor(t, store, got.ID)
|
||||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||||
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIngestGate_DeferNeverBackfillsExistingRows 钉住「**TTL 只开新增侧**」:
|
||||
// 存量行的 `expires_at` 一条都不补写。变异(在 defer 时顺手 UPDATE 整个分区)会红。
|
||||
func TestIngestGate_DeferNeverBackfillsExistingRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
// 存量行在**接入闸之前**落库:这条用例要看的是「闸跑一轮之后,存量行的
|
||||
// expires_at 仍然为空」,而不是「存量行当初怎么落的库」。
|
||||
existing := seedTidyItem(t, store, gateFixtureItem("主人有不吃早饭的习惯,需要提醒每天吃热乎的早饭", base))
|
||||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: 7 * 24 * time.Hour}, nil)
|
||||
store.AttachIngestReviewer(r)
|
||||
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
|
||||
t.Fatalf("存量行 expires_at = %q, want 空串", exp)
|
||||
}
|
||||
fresh := seedTidyItem(t, store, gateFixtureItem("用户昨天发来一卷一万两千行的验收纸条", base.Add(time.Minute)))
|
||||
if exp := readIngestExpiry(t, store, fresh.ID); exp == "" {
|
||||
t.Fatalf("新行没被写上 expires_at —— N6 没有生效")
|
||||
}
|
||||
if exp := readIngestExpiry(t, store, existing.ID); exp != "" {
|
||||
t.Fatalf("存量行被补写了 expires_at = %q —— TTL 只许开新增侧", exp)
|
||||
}
|
||||
var withExpiry int
|
||||
if err := store.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM memory_items WHERE expires_at <> ''`).Scan(&withExpiry); err != nil {
|
||||
t.Fatalf("count expires_at: %v", err)
|
||||
}
|
||||
if withExpiry != 1 {
|
||||
t.Fatalf("带 expires_at 的行 = %d, want 恰为 1(只有那条新记忆)", withExpiry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package memory
|
||||
|
||||
import "sort"
|
||||
|
||||
// 本文件是提取留存规则闸(design/03 §10)的**词表**:每一项都是**函数**而不是
|
||||
// 包级变量(AGENTS §10 禁包级全局变量;同 tidySystemSessions / tidyHabitMarkers 的手法)。
|
||||
//
|
||||
// 它从 extract_retention.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)——
|
||||
// 词表内容与逐条理由**逐字未动**;判定逻辑在 extract_retention.go。
|
||||
|
||||
// 下列词表一律是**函数**而不是包级变量(AGENTS §10 禁包级全局变量;
|
||||
// 同 tidySystemSessions / tidyHabitMarkers 的手法)。
|
||||
|
||||
// retentionRememberDirectives 是「主人明确要求记住」的信号(护栏①)。
|
||||
func retentionRememberDirectives() []string {
|
||||
return []string{"记住", "记下", "别忘了", "不要忘", "别忘记"}
|
||||
}
|
||||
|
||||
// retentionDurableMarkers 是正向清单(design/03 §10.4)的词族里、**不被
|
||||
// tidyHabitMarkers 覆盖**的那些:长期事实 / 关系里程碑 / 项目约定 / 能力边界。
|
||||
func retentionDurableMarkers() []string {
|
||||
return []string{
|
||||
// 长期事实
|
||||
//
|
||||
// 「宿舍」(2026-10-25 r1 复审 P2-2 补):语料里「近期在宿舍过夜 / 睡在宿舍」
|
||||
// 是**居住事实 + 稳定照料约定**,不含「住」字却被 N6 判成临时;收它之后
|
||||
// 这类句子与「住在宿舍」同档(都要过护栏②才放行)。
|
||||
"生日", "纪念日", "住", "宿舍", "职业", "工作", "家庭", "家人", "孩子", "父母",
|
||||
"过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄",
|
||||
// 关系里程碑 / 约定
|
||||
"约定", "承诺", "答应", "说好", "暗号", "口令", "称呼",
|
||||
// 项目约定
|
||||
"项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成",
|
||||
// 能力边界(主人明确的「不要做 X」)
|
||||
"不要做", "别做", "禁止", "不许",
|
||||
// 稳定偏好(补 tidyHabitMarkers 之外的)
|
||||
"口味", "爱好", "讨厌", "不喜欢", "偏爱", "嗜好",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionChatterWords 是 N1 的寒暄 / 致谢词表。
|
||||
func retentionChatterWords() []string {
|
||||
return []string{
|
||||
"谢谢", "多谢", "感谢", "辛苦", "麻烦你了", "早上好", "早安", "中午好",
|
||||
"下午好", "晚上好", "晚安", "你好", "您好", "在吗", "收到", "好的",
|
||||
"好呀", "好嘞", "嗯嗯", "哈哈", "嘿嘿", "呵呵", "么么", "拜拜", "再见",
|
||||
"加油", "不错", "真棒", "太棒了", "hi", "hello", "hey", "thx", "thanks",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionWords 是 N2 的情绪词表。
|
||||
func retentionEmotionWords() []string {
|
||||
return []string{
|
||||
"好累", "累了", "开心", "高兴", "难过", "不开心", "焦虑", "压力大",
|
||||
"心情", "疲惫", "难受", "无聊", "郁闷", "沮丧", "生气", "委屈",
|
||||
"烦死", "好烦", "烦躁", "兴奋", "紧张", "害怕", "孤单", "寂寞",
|
||||
// 「不错」是**情绪评价**(「心情不错」),因此它必须在**核心词表**里按整词
|
||||
// 出现:只靠否定词表里的「不」,`不错` 会被拆成 `不` + 光杆「错」,于是
|
||||
// 「心情不错」既认不出核心、也消费不干净 —— 一条纯情绪句反而漏判。
|
||||
"不错",
|
||||
// 光杆「累」同理:「主人今天真的特别累」只有一个 `累` 而**不含** `好累` /
|
||||
// `累了`,漏掉它就会留下一个消费不掉的「累」、把一条纯情绪句放行。
|
||||
// 收它**不会**带来误弃 —— 判 defer 的门槛恒是「整句被形态消费干净」,
|
||||
// 多收一个情绪词只会让**本该 defer 的**句子认得出来。
|
||||
"累",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionSuffixTokens 是「情绪核心之后**允许**出现的修饰」的封闭集合:
|
||||
// 程度 / 否定 / 情绪 / 语气。**它不含任何承载内容的词** —— 这正是白名单判据在
|
||||
// 结构上不会吞掉实体的原因(核心之后一旦出现 `想`/`会`/`时`/`和`/`我` 这类字,
|
||||
// 就必然消费不掉、留下非空余部 ⇒ 放行)。
|
||||
//
|
||||
// 它是函数而不是包级变量(AGENTS §10 禁包级全局变量;同 tidyHabitMarkers 的手法);
|
||||
// 长度先按**最长词优先**排序,`不` 不会抢在 `不错` 前面被消费。
|
||||
func retentionEmotionSuffixTokens() []string {
|
||||
tokens := make([]string, 0, 64)
|
||||
tokens = append(tokens, retentionEmotionDegreeWords()...)
|
||||
tokens = append(tokens, retentionEmotionNegations()...)
|
||||
tokens = append(tokens, retentionEmotionWords()...)
|
||||
tokens = append(tokens, retentionEmotionToneWords()...)
|
||||
sort.SliceStable(tokens, func(i, j int) bool {
|
||||
return len(tokens[i]) > len(tokens[j])
|
||||
})
|
||||
return tokens
|
||||
}
|
||||
|
||||
// retentionEmotionTimeWords 是**只允许在「主语之后」出现一次**的时间词:无主语时
|
||||
// 它就是首词(「今天好累」),有主语时紧跟主语(「主人今天好累」)—— 之外的位置一律
|
||||
// 不匹配(`今天主人好累` 实测 `rest="今天主人好累"`, `saw=false` ⇒ 放行)。
|
||||
// 它们让一句情绪陈述显形为「今天怎么样」,本身不含实体。
|
||||
func retentionEmotionTimeWords() []string {
|
||||
return []string{
|
||||
"今天", "明天", "昨天", "今晚", "昨晚", "今早", "此刻", "现在", "刚才", "刚刚",
|
||||
"最近", "这两天", "这几天", "这阵子", "这段时间", "目前", "当下", "一整天",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionSubjects 是**只允许出现在句首**的主语 / 称呼。把它限死在句首是
|
||||
// 白名单判据的要害之一:`我`/`他`/`我们` 这些词**本身可以当实体**(「会想我」的
|
||||
// 宾语就是它),若允许它们在任意位置被消费,`主人不开心时会想我` 就会被吞成空。
|
||||
func retentionEmotionSubjects() []string {
|
||||
return []string{
|
||||
"主人", "咱们", "我们", "你们", "他们", "她们", "自己",
|
||||
"我", "你", "他", "她", "它", "咱",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionDegreeWords 是程度词(只修饰强度,不引入实体)。
|
||||
func retentionEmotionDegreeWords() []string {
|
||||
return []string{
|
||||
"非常", "特别", "稍微", "真的", "真是", "十分", "比较", "有点", "有些",
|
||||
"很", "挺", "太", "好", "超", "蛮", "更", "最",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionEmotionNegations 是情绪核心之后的否定词(「心情**不**好」)。它只能出现在
|
||||
// 后缀位;`不` 与 `没` 都不承载实体。
|
||||
func retentionEmotionNegations() []string {
|
||||
return []string{"没有", "不", "没"}
|
||||
}
|
||||
|
||||
// retentionEmotionToneWords 是句尾的语气词(「好累**啊**」「不错**了**」)。它们只出现
|
||||
// 在句尾,不引入实体。
|
||||
func retentionEmotionToneWords() []string {
|
||||
return []string{"啊", "呀", "啦", "哦", "喔", "嘛", "呢", "吧", "了", "的", "哎", "唉", "嗯", "哈"}
|
||||
}
|
||||
|
||||
// retentionPunctuationRunes 是「消费干净」判定允许剩下的标点与空白。
|
||||
const retentionPunctuationRunes = ",。!?、;:…~~·--,.!?;:()() \t\n"
|
||||
|
||||
// retentionTimelyQueries 是 N4 的时效性**查询整句**词表(不是单词,理由见
|
||||
// judgeRetentionTimelyData)。
|
||||
func retentionTimelyQueries() []string {
|
||||
return []string{
|
||||
"现在几点", "几点了", "现在时间", "当前时间",
|
||||
"今天天气", "明天天气", "天气怎么样", "天气如何", "天气怎样",
|
||||
"温度多少", "多少度", "气温多少",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionAskPrefixes 是 N3 的祈使开头词表。
|
||||
func retentionAskPrefixes() []string {
|
||||
return []string{
|
||||
"帮我", "请帮", "麻烦帮", "帮个忙", "提醒我", "叫我", "替我",
|
||||
"给我订", "给我买", "帮我查", "帮我搜", "帮我订", "帮我买",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionOneOffActions 是 N5 的一次性动作词表(位置不限 —— 它们描述的是
|
||||
// 「转一次文件」这类有始有终的动作,而不是可以长期成立的偏好)。
|
||||
func retentionOneOffActions() []string {
|
||||
return []string{
|
||||
"转成pdf", "转成 pdf", "转换成", "截图", "打开这个", "关掉这个",
|
||||
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionConstraintMarkers 是「**具体约束**」的词项(护栏②与 N6/N7 共用):
|
||||
// 禁忌 / 硬性要求 / 频次与时间规律。它是「这条记忆将来会被用上」的可执行证据。
|
||||
//
|
||||
// ⚠️ 只收**能落到行动**的词:把「喜欢/讨厌」这类偏好词收进来就等于取消护栏②
|
||||
// 的意义(它们已经在 tidyHabitMarkers 里当锚点,锚点 + 锚点不构成约束)。
|
||||
func retentionConstraintMarkers() []string {
|
||||
return []string{
|
||||
// 禁忌 / 硬性要求(主人明确的「不要做 X」)
|
||||
"不要", "不准", "不许", "不能", "禁止", "必须", "一定要", "记得", "别忘",
|
||||
// 频次 / 时间规律
|
||||
"每天", "每日", "每晚", "每早", "每周", "每月", "每年", "每个",
|
||||
"按时", "定期", "定量", "固定",
|
||||
// 量化边界
|
||||
"不超过", "至少", "最多", "以内", "不少于",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionTimepointMarkers 是 N6 的**指示性时点词**(一次性事件的信号)。
|
||||
//
|
||||
// 🔴 两张表都**不收**「周期性时段词」:晚上/早上/中午/周末/工作日 —— 它们说的是
|
||||
// 规律而不是某个时刻,收进来会让人把作息习惯判成一次性事件(M27 §4 R4 实测:
|
||||
// 把这类词当锚点,误杀从 ~8% 升到 ~35%)。也**不收**「目前/现在/当下/这段时间」
|
||||
// 这类**当前状态**词:实测它们命中的是「用户目前生活在花溪」「用户现在有工作」
|
||||
// 这类**地点 / 职业事实**,正是最不该过期的一类。
|
||||
func retentionTimepointMarkers() []string {
|
||||
return []string{
|
||||
"今天", "明天", "后天", "昨天", "前天",
|
||||
"今晚", "明晚", "昨晚", "今早", "明早",
|
||||
"刚才", "刚刚",
|
||||
"这两天", "这几天", "这阵子",
|
||||
"本周", "这周", "下周", "上周", "下个月", "本月",
|
||||
"最近", "近期",
|
||||
}
|
||||
}
|
||||
|
||||
// retentionInProgressMarkers 是 N7 的「进行中状态」词(有始有终的状态,
|
||||
// 不是长期事实)。它们比时点词更弱,因此 N7 与 N6 共用同一组安全阀。
|
||||
func retentionInProgressMarkers() []string {
|
||||
return []string{"正在", "暂时", "眼下", "这会儿"}
|
||||
}
|
||||
|
||||
// retentionHardDurableMarkers 是 N6/N7 的**硬性长期事实词**:命中即放行。
|
||||
//
|
||||
// 它是 retentionDurableMarkers **减去「约定/承诺/答应/说好」** 的差集,理由:
|
||||
// 那四个词是「锚定到某个时刻的安排」,配上时点恰恰是临时记忆(「约定下周留个
|
||||
// 午后」→ 下周过去就没意义了);而「住/职业/工作/生日/过敏/称呼/项目/端口…」
|
||||
// 说的是**不会因为一个时刻而过期**的事实,标题里带「今天」也不许判临时。
|
||||
func retentionHardDurableMarkers() []string {
|
||||
appointments := map[string]struct{}{"约定": {}, "承诺": {}, "答应": {}, "说好": {}}
|
||||
markers := make([]string, 0, len(retentionDurableMarkers()))
|
||||
for _, m := range retentionDurableMarkers() {
|
||||
if _, ok := appointments[m]; ok {
|
||||
continue
|
||||
}
|
||||
markers = append(markers, m)
|
||||
}
|
||||
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{"换成", "变成", "改成", "改为", "换装", "搬进", "搬到"}
|
||||
}
|
||||
@@ -202,6 +202,9 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
|
||||
"auto_reclassify", stats.AutoReclassified,
|
||||
"auto_eligible", stats.AutoEligible,
|
||||
"auto_truncated", stats.AutoTruncated,
|
||||
"dedupe_groups", stats.DedupeGroups,
|
||||
"dedupe_rows", stats.DedupeRows,
|
||||
"dedupe_truncated", stats.DedupeTruncated,
|
||||
}
|
||||
if len(stats.Truncated) > 0 {
|
||||
fields = append(fields, "truncated", strings.Join(stats.Truncated, ","))
|
||||
@@ -305,6 +308,31 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
return fmt.Errorf("memory/tidy: recall partition %s: %w", p.label(), err)
|
||||
}
|
||||
|
||||
candidates := t.mergeCandidates(archives, pairs, stats)
|
||||
|
||||
// dedupe_group(2026-10-24):机械簇(同 tag 集 / 前 12 字相同)**不依赖相似度
|
||||
// 召回**,因此它跑在可比条目全集上(`comparable`)、**不受**比对截断影响 ——
|
||||
// 截断是给 O(n²) 的两两余弦用的护栏,不该让机械分簇跟着少看一半条目。
|
||||
//
|
||||
// 它排在包含判定**之后**是为了**抑制重复待办**:一对已经拿到 archive/merge
|
||||
// 候选的条目不再进簇 —— 否则同一对会同时出现「并入超集」与「并入簇存活行」
|
||||
// 两条候选,第二条应用时必然 skip(source_not_active),面板上看像 bug。
|
||||
// 覆盖面由前一步的**产物**决定(不是又一套判据),因此抑制是纯粹的减法。
|
||||
dedupe, dedupeStats := t.dedupeInPartition(ctx, p, comparable, candidates)
|
||||
stats.DedupeGroups += dedupeStats.Groups
|
||||
stats.DedupeRows += dedupeStats.Rows
|
||||
|
||||
// dedupe 候选追加在既有两类之后:**不改变**归档/合并在候选上限里的优先序
|
||||
// (那两类是既有的、被验证过的口径;新动作不许把它们挤出上限)。
|
||||
candidates = append(candidates, dedupe...)
|
||||
candidates = t.trimPartitionCandidates(p, candidates, len(dedupe), stats)
|
||||
policy.sink(ctx, candidates, stats)
|
||||
return nil
|
||||
}
|
||||
|
||||
// mergeCandidates 把包含判定的产物收敛成候选:归档建议在前,merge 在后;
|
||||
// 被否决的对进否决分布,互不包含的对进 Kept(**不产候选**,2026-10-07 口径)。
|
||||
func (t *Tidier) mergeCandidates(archives []TidyCandidate, pairs []tidyPair, stats *TidyStats) []TidyCandidate {
|
||||
candidates := archives
|
||||
for _, pr := range pairs {
|
||||
verdict := judgePair(pr.a, pr.b, pr.score)
|
||||
@@ -321,19 +349,39 @@ func (t *Tidier) runPartition(ctx context.Context, p tidyPartition, stats *TidyS
|
||||
}
|
||||
candidates = append(candidates, buildCandidates(pr, verdict, t.now().UTC())...)
|
||||
}
|
||||
// 每分区候选上限(design §9)对**全部候选**生效(含归档建议),
|
||||
// 超出的只登记不入队,并记进 stats.Truncated(绝不静默)。
|
||||
if len(candidates) > t.cfg.CandidateLimit {
|
||||
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则
|
||||
// 判出的,必须能从这里读出被丢掉了多少。
|
||||
t.log.Warn("memory tidy candidates truncated by per-partition limit",
|
||||
"partition", p.label(), "candidates", len(candidates), "limit", t.cfg.CandidateLimit)
|
||||
stats.Skipped += len(candidates) - t.cfg.CandidateLimit
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
candidates = candidates[:t.cfg.CandidateLimit]
|
||||
return candidates
|
||||
}
|
||||
|
||||
// trimPartitionCandidates 施加每分区候选上限(design §9),并**如实登记**被丢掉的
|
||||
// 部分:总量进 stats.Skipped 与 Truncated 标签,其中属于 dedupe 段的那部分单独进
|
||||
// stats.DedupeTruncated(新动作被截断时面板必须看得到,不许「静默少做」)。
|
||||
//
|
||||
// dedupeCount 是本批候选**尾部**属于 dedupe 的条数(调用方把 dedupe 追加在最后)。
|
||||
func (t *Tidier) trimPartitionCandidates(p tidyPartition, candidates []TidyCandidate,
|
||||
dedupeCount int, stats *TidyStats) []TidyCandidate {
|
||||
limit := t.cfg.CandidateLimit
|
||||
if len(candidates) <= limit {
|
||||
return candidates
|
||||
}
|
||||
policy.sink(ctx, candidates, stats)
|
||||
return nil
|
||||
dedupeStart := len(candidates) - dedupeCount
|
||||
// 上限切在 dedupe 段之前 ⇒ 丢掉的**全部**是 dedupe 候选;切在段内 ⇒ 丢掉尾部。
|
||||
keptDedupe := 0
|
||||
if limit > dedupeStart {
|
||||
keptDedupe = limit - dedupeStart
|
||||
}
|
||||
if keptDedupe > dedupeCount {
|
||||
keptDedupe = dedupeCount
|
||||
}
|
||||
droppedDedupe := dedupeCount - keptDedupe
|
||||
stats.DedupeTruncated += droppedDedupe
|
||||
// WARN 带总数/上限/分区名:候选被上限截断意味着面板看到的**少于**规则判出的,
|
||||
// 必须能从这里读出被丢掉了多少。
|
||||
t.log.Warn("memory tidy candidates truncated by per-partition limit",
|
||||
"partition", p.label(), "candidates", len(candidates), "limit", limit,
|
||||
"dedupe_truncated", droppedDedupe)
|
||||
stats.Skipped += len(candidates) - limit
|
||||
stats.Truncated = append(stats.Truncated, p.label()+"(candidates)")
|
||||
return candidates[:limit]
|
||||
}
|
||||
|
||||
// classifyItems 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出):
|
||||
|
||||
@@ -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 包裹,
|
||||
@@ -141,6 +145,13 @@ type TidyApplyBatch struct {
|
||||
Merged int `json:"merged"`
|
||||
Archived int `json:"archived"`
|
||||
Reclassified int `json:"reclassified"`
|
||||
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24)。它与 Merged 分开:
|
||||
// 「一对一并进」与「一簇 N 条并进」在面板上是两件事(后者一次动多行)。
|
||||
Deduped int `json:"deduped"`
|
||||
// RowsAffected 是本次真的被改动的**记忆行数**(merge/archive/reclassify 各 1 行,
|
||||
// dedupe_group 是簇里的被吸收行数)。行预算 `maxTidyApplyRowsPerBatch` 按它计,
|
||||
// 因此这个数字是「这一次点击改了多少行记忆」的直接回答。
|
||||
RowsAffected int `json:"rowsAffected"`
|
||||
}
|
||||
|
||||
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
|
||||
@@ -175,6 +186,8 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
|
||||
return s.applyTidyArchive(ctx, cand, opts, now)
|
||||
case TidyReclassify:
|
||||
return s.applyTidyReclassify(ctx, cand, opts, now)
|
||||
case TidyDedupeGroup:
|
||||
return s.applyTidyDedupeGroup(ctx, cand, opts, now)
|
||||
default:
|
||||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||||
}
|
||||
@@ -185,7 +198,7 @@ func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts Tid
|
||||
// 库里既有的历史行与「不许假装做过」的诚实口径。
|
||||
func (a TidyAction) applyable() bool {
|
||||
switch a {
|
||||
case TidyMerge, TidyArchive, TidyReclassify:
|
||||
case TidyMerge, TidyArchive, TidyReclassify, TidyDedupeGroup:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -399,11 +412,21 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy
|
||||
Truncated: total > len(cands),
|
||||
Results: make([]TidyApplyResult, 0, len(cands)),
|
||||
}
|
||||
rows := 0
|
||||
for _, cand := range cands {
|
||||
if err := ctx.Err(); err != nil {
|
||||
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
|
||||
break
|
||||
}
|
||||
// 按行计的批量上限(2026-10-24):一条 dedupe_group 候选会改 N 行,只按候选
|
||||
// 条数限批会让「一次点击改上千行记忆」。超限时**如实标 truncated** 并停下,
|
||||
// 剩下的留给下一次点击(与扫描/候选上限同一口径:绝不静默少做)。
|
||||
if cost := tidyApplyRowCost(cand); rows+cost > maxTidyApplyRowsPerBatch {
|
||||
batch.Truncated = true
|
||||
break
|
||||
} else {
|
||||
rows += cost
|
||||
}
|
||||
res, err := s.applyCandidate(ctx, cand, opts)
|
||||
if err != nil {
|
||||
res = tidyFailed(TidyApplyResult{
|
||||
@@ -412,8 +435,13 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy
|
||||
}, err)
|
||||
}
|
||||
batch.Results = append(batch.Results, res)
|
||||
batch.RowsAffected += len(res.AffectedIDs)
|
||||
tallyTidyApply(&batch, res)
|
||||
}
|
||||
if len(batch.Results) < len(cands) {
|
||||
// 循环被行预算或 ctx 打断:`len(cands)` 之内还有未处理项 ⇒ 不完整。
|
||||
batch.Truncated = true
|
||||
}
|
||||
if batch.RunID != "" && batch.Applied > 0 {
|
||||
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
|
||||
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
|
||||
@@ -423,64 +451,3 @@ func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts Tidy
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// tallyTidyApply 把一条结果计入批量回执的计数器。
|
||||
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
|
||||
switch res.Status {
|
||||
case TidyApplyApplied:
|
||||
batch.Applied++
|
||||
switch res.Action {
|
||||
case TidyMerge:
|
||||
batch.Merged++
|
||||
case TidyArchive:
|
||||
batch.Archived++
|
||||
case TidyReclassify:
|
||||
batch.Reclassified++
|
||||
}
|
||||
case TidyApplyFailed:
|
||||
batch.Failed++
|
||||
default:
|
||||
batch.Skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// skipTidyApply 造一条 skipped 结果。
|
||||
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
|
||||
base.Status = TidyApplySkipped
|
||||
base.Reason = reason
|
||||
if base.AffectedIDs == nil {
|
||||
base.AffectedIDs = []string{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
|
||||
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
|
||||
base.Status = TidyApplyFailed
|
||||
base.Reason = "apply_failed"
|
||||
base.Error = err.Error()
|
||||
if base.AffectedIDs == nil {
|
||||
base.AffectedIDs = []string{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
|
||||
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
|
||||
//
|
||||
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
|
||||
// (那是把「没做」说成「做了」)。
|
||||
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
base.Status = TidyApplyApplied
|
||||
base.AffectedIDs = affected
|
||||
return base, nil
|
||||
case errors.Is(err, errTidyCandidateDecided):
|
||||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||||
case errors.Is(err, errTidyItemNotActive):
|
||||
return skipTidyApply(base, notActiveReason), nil
|
||||
default:
|
||||
return tidyFailed(base, err), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package memory
|
||||
|
||||
import "errors"
|
||||
|
||||
// 本文件是记忆整理应用路径的**结果收敛**:把一条结果计入回执、把事务结果收敛成
|
||||
// 一条跳过/失败/已应用的结果。它从 tidy_apply.go 拆出来只为守 AGENTS §10 的
|
||||
// 文件行数上限(≤500 行),判据与注释**逐字未动**。
|
||||
|
||||
// tallyTidyApply 把一条结果计入批量回执的计数器。
|
||||
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
|
||||
switch res.Status {
|
||||
case TidyApplyApplied:
|
||||
batch.Applied++
|
||||
switch res.Action {
|
||||
case TidyMerge:
|
||||
batch.Merged++
|
||||
case TidyArchive:
|
||||
batch.Archived++
|
||||
case TidyReclassify:
|
||||
batch.Reclassified++
|
||||
case TidyDedupeGroup:
|
||||
batch.Deduped++
|
||||
}
|
||||
case TidyApplyFailed:
|
||||
batch.Failed++
|
||||
default:
|
||||
batch.Skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// skipTidyApply 造一条 skipped 结果。
|
||||
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
|
||||
base.Status = TidyApplySkipped
|
||||
base.Reason = reason
|
||||
if base.AffectedIDs == nil {
|
||||
base.AffectedIDs = []string{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
|
||||
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
|
||||
base.Status = TidyApplyFailed
|
||||
base.Reason = "apply_failed"
|
||||
base.Error = err.Error()
|
||||
if base.AffectedIDs == nil {
|
||||
base.AffectedIDs = []string{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
|
||||
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
|
||||
//
|
||||
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
|
||||
// (那是把「没做」说成「做了」)。
|
||||
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
base.Status = TidyApplyApplied
|
||||
base.AffectedIDs = affected
|
||||
return base, nil
|
||||
case errors.Is(err, errTidyCandidateDecided):
|
||||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||||
case errors.Is(err, errTidyItemNotActive):
|
||||
return skipTidyApply(base, notActiveReason), nil
|
||||
default:
|
||||
return tidyFailed(base, err), nil
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,9 @@ func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int
|
||||
// 手工撤过了,此时只把候选退回 pending,不假装改过东西)。
|
||||
func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
|
||||
switch cand.Action {
|
||||
case TidyMerge:
|
||||
case TidyMerge, TidyDedupeGroup:
|
||||
// dedupe_group 与 merge 是**同一套软合并**(被吸收行 → merged + merged_into),
|
||||
// 因此回滚逐字复用 revertTidyMerge:它本来就按 SourceIDs 逐条复原。
|
||||
return revertTidyMerge(ctx, tx, cand, now)
|
||||
case TidyArchive:
|
||||
return revertTidyArchive(ctx, tx, cand, now)
|
||||
|
||||
@@ -35,6 +35,17 @@ type TidyEvidence struct {
|
||||
// ContentA/ContentB 是两条原文,供面板 diff 预览。
|
||||
ContentA string `json:"content_a,omitempty"`
|
||||
ContentB string `json:"content_b,omitempty"`
|
||||
// 以下是 dedupe_group(2026-10-24)的**簇信息**:判据、簇键、簇大小、存活规则,
|
||||
// 以及被吸收行的正文列表(面板要能一次看清「这一簇吞了哪几条」——merge 候选
|
||||
// 只有一对,ContentA/ContentB 装得下;dedupe_group 一簇最多十来条,装不下)。
|
||||
//
|
||||
// 🔴 回滚**不需要**这些字段:回滚靠 SourceIDs + TargetID(被吸收行的
|
||||
// merged_into 必须等于 target),与 merge 候选完全同一套。簇信息只服务审计。
|
||||
DedupeRule string `json:"dedupe_rule,omitempty"`
|
||||
DedupeKey string `json:"dedupe_key,omitempty"`
|
||||
ClusterSize int `json:"cluster_size,omitempty"`
|
||||
KeptReason string `json:"kept_reason,omitempty"`
|
||||
DedupeMembers []string `json:"dedupe_members,omitempty"`
|
||||
}
|
||||
|
||||
// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 候选动作(2026-10-24,M34/M27)的**簇形成**与候选构造。
|
||||
//
|
||||
// 动因是 M27 只读体检的定量结论(`DevDocs/audit/记忆库价值体检-2026-10-24.md`):
|
||||
// active 3657 条里可复用 ≈32%,而「近似冗余」≈50%;但整理层 10-14 起**一条候选都不产**
|
||||
// —— 因为它的合并判据是**包含关系**(`judgeContainment`),而问题的形态是
|
||||
// 「同一件事换了个说法」:`助手名为 Cyrene。` 与 `助手/人格名为 Cyrene。` 互不包含。
|
||||
//
|
||||
// 🔴 **为什么不放大 `containNone`**(体检报告 §9-1 的两条路里选了后一条):
|
||||
// `containNone` 是一条**护栏**(防「冰美式 vs 红牛」这类同主题不同事实被合并),
|
||||
// 放宽它等于把护栏拆掉。因此这里新增一个**更窄**的动作:它不改变任何一对的
|
||||
// 包含判定,只在「簇」这个粒度上认两种**机械可判**的同一性:
|
||||
//
|
||||
// 1. **同 scope + 同 kind + 完全相同 tag 集**(tag 集非空,且每个 tag ≥2 字、
|
||||
// 至少 2 个 tag —— 体检报告 §7 第 2 批的建议:排除 `('作息','工作')` 这类
|
||||
// 过于通用的 tag 集,宁可少减也不误杀);
|
||||
// 2. **前 12 个字相同**(`dedupeGroupPrefixRunes`,与体检报告 R2 同一口径),
|
||||
// 并附加**相似度下限** `dedupeGroupPrefixSimilarity` 与**同 kind** 两条收窄:
|
||||
// 前者挡住「同一开头、后半句讲了别的实体」的对(`主人喜欢用「喵」…` vs
|
||||
// `主人喜欢用「汪」…` 这类同构不同事实),后者避免「合并顺手改了分类」——
|
||||
// kind 的改动仍只走 reclassify 那条路(design/23 §5.1 T0-6)。
|
||||
//
|
||||
// 分区(persona_id + scope + project_id)是**硬门**:候选只在一个分区内形成,
|
||||
// 跨 scope 的同事实(体检报告 §7 第 3 批,83 簇/421 行)**明确不做**(会动 persona
|
||||
// 写权语义,塔已拍不做)。
|
||||
//
|
||||
// 每簇的**安全门**与既有合并路径**逐条同源**(不新造一套判据):
|
||||
// - 群聊/陌生人来源 ⇒ 排除(design/23 §5.2-9、§6,纵向防御同 tidy_rules);
|
||||
// - 含绝对日期/时刻 ⇒ 排除(那些走 archive,design/23 §5.1 T0-5);
|
||||
// - 含有时限状态词(假期/期间/本周…)⇒ 排除(§5.2-1);
|
||||
// - 置信 < tidyLowConfidenceThreshold ⇒ 排除(§5.2-6);
|
||||
// - 数值 token 与存活行不一致 ⇒ 该成员不入簇(§5.2-2);
|
||||
// - 否定标记与存活行不一致 ⇒ 该成员不入簇(§5.2-3「X vs 非X 是覆盖不是重复」)。
|
||||
//
|
||||
// 保留规则(体检报告 §7 第 2 批的同一条):**内容最长(超集) > merge_count 大 >
|
||||
// created_at 早**,仍并列时按 id 字典序(确定性)。「最长」优先是因为簇里的冗余
|
||||
// 大多是「把同一件事越写越全」,最长那条通常信息量最大;merge_count 大说明它已经
|
||||
// 吸收过别的表述;两者都并列时留最早的那条(它是 first_seen,时间跨度落在它身上)。
|
||||
|
||||
const (
|
||||
// TidyDedupeGroup 是「机械簇去重」的动作名(design/23 §10 的 action 枚举新增值)。
|
||||
//
|
||||
// 它与 `merge` 的差别只在**判据**(簇的同一性 vs 事实集合的包含),落库形态完全
|
||||
// 相同:一簇一条候选,`source_ids` = 被吸收的行,`target_id` = 存活行,应用是
|
||||
// **软合并**(被吸收行 → status='merged' + merged_into),因此**可逐条 revert**。
|
||||
//
|
||||
// 🔴 **不在自动档里**:一次点击会改多行,体检报告 §7.2 要求在环核对(先做 100 簇、
|
||||
// 人读 20 簇再放开),因此它**只走人工/批量点**,不进 tidy_auto 的白名单。
|
||||
TidyDedupeGroup TidyAction = "dedupe_group"
|
||||
|
||||
// tidyReasonDedupeGroup 是 dedupe_group 候选的理由标签(落 reason 列)。
|
||||
tidyReasonDedupeGroup = "dedupe_group"
|
||||
|
||||
// dedupeGroupPrefixRunes 是「前 N 字相同」的 N(体检报告 R2 的实测口径)。
|
||||
dedupeGroupPrefixRunes = 12
|
||||
|
||||
// dedupeGroupMinTagRunes / dedupeGroupMinTagCount 是 tag 集簇的门槛:
|
||||
// 每个 tag 至少 2 个字、至少 2 个 tag。单字 tag(「喵」「住」)与单个 tag
|
||||
// 太宽,簇会退化成「同主题就合并」——那正是体检报告 §7 第 2 批警告的形态。
|
||||
dedupeGroupMinTagRunes = 2
|
||||
dedupeGroupMinTagCount = 2
|
||||
|
||||
// dedupeGroupPrefixSimilarity 是前缀簇成员的**附加**相似度下限(词项余弦,
|
||||
// 与召回的 lexicalSimilarity 同一个函数、同一个量纲)。
|
||||
//
|
||||
// 这条收窄是**必须的**:语料实测 871 对「前 12 字相同」的候选对里,相似度分布从
|
||||
// 0.11 到 0.99 —— 最低那批是「同开头、不同事实」(`助手(persona)的说话风格…`
|
||||
// vs `助手(persona)的象征花为蓝玫瑰…`,0.111),合并它们是**丢事实**。
|
||||
// 按阈值看语料(同一批 871 对):
|
||||
//
|
||||
// ≥0.60 → 40% ≥0.70 → 14% ≥0.75 → 6% ≥0.80 → 2% ≥0.85 → 1%
|
||||
//
|
||||
// 0.70 取在这里:≥0.70 的 120 对里抽 25 对人读**全是同一句话的改写**,而 0.11~0.35
|
||||
// 段全是不同事实 ⇒ 0.70 是「既能捞回改写、又不碰同开头不同事实」的那一档。
|
||||
// 它比 tidy 的召回门(0.80)低是刻意的:召回门只是「值不值得看一眼」,而这里是
|
||||
// 「要不要自动判同一簇」,两者风险不同 —— 但**两者都不是判定依据**(簇的同一性
|
||||
// 由「前 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 预览与运维判读)。
|
||||
const (
|
||||
// tidyDedupeRuleTagSet / tidyDedupeRulePrefix 是簇的判据名(可审计)。
|
||||
tidyDedupeRuleTagSet = "tag_set"
|
||||
tidyDedupeRulePrefix = "prefix12"
|
||||
|
||||
// 保留规则名(evidence.kept_reason):存活行为什么是它。
|
||||
tidyDedupeKeepLongest = "longest_content"
|
||||
tidyDedupeKeepMergeCount = "merge_count"
|
||||
tidyDedupeKeepOldest = "oldest_created_at"
|
||||
tidyDedupeKeepID = "id_order"
|
||||
)
|
||||
|
||||
// tidyDedupeKey 是簇键:判据 + 键值。两者一起进 evidence,便于复核「为什么它们同簇」。
|
||||
type tidyDedupeKey struct {
|
||||
rule string
|
||||
key string
|
||||
}
|
||||
|
||||
// tidyDedupeCluster 是一个待落候选的簇:存活行 + 被吸收行。
|
||||
type tidyDedupeCluster struct {
|
||||
Rule string
|
||||
Key string
|
||||
Survivor MemoryItem
|
||||
Absorbed []MemoryItem
|
||||
// KeptReason 是存活行胜出的那条规则(evidence.kept_reason 的来源)。
|
||||
KeptReason string
|
||||
}
|
||||
|
||||
// tidyDedupeStats 是分区内 dedupe 分簇的观测口径(进 TidyStats)。
|
||||
type tidyDedupeStats struct {
|
||||
// Groups 是**真正产出候选**的簇数。
|
||||
Groups int
|
||||
// Rows 是这些簇**可减少的 active 行数**(被吸收行合计)——「预计减少行数」。
|
||||
Rows int
|
||||
}
|
||||
|
||||
// dedupeInPartition 是 runPartition 调用点:读 merge_count、把**已被既有候选覆盖**的
|
||||
// 条目屏蔽掉,再分簇产候选。
|
||||
//
|
||||
// 屏蔽(covered)是「不产生第二条待办」的落点:一对已经拿到 archive/merge 候选的
|
||||
// 条目不再进簇。它是纯粹的减法(覆盖集合由前一步的**产物**给出),因此不是第二套判据。
|
||||
func (t *Tidier) dedupeInPartition(ctx context.Context, p tidyPartition, items []MemoryItem,
|
||||
existing []TidyCandidate) ([]TidyCandidate, tidyDedupeStats) {
|
||||
covered := make(map[string]struct{}, len(existing)*2)
|
||||
for _, c := range existing {
|
||||
for _, id := range c.SourceIDs {
|
||||
covered[id] = struct{}{}
|
||||
}
|
||||
if c.TargetID != "" {
|
||||
covered[c.TargetID] = struct{}{}
|
||||
}
|
||||
}
|
||||
// 元信息(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 skipped: item meta unavailable",
|
||||
"partition", p.label(), "error", err)
|
||||
return nil, tidyDedupeStats{}
|
||||
}
|
||||
return t.dedupeCandidates(items, meta, covered, t.now().UTC())
|
||||
}
|
||||
|
||||
// dedupeCandidates 在**一个分区内**把 items 分成机械簇并产候选。它是纯函数式的
|
||||
// (只读入参、无 IO、无模型),因此可完全单测,也在试算轮与真跑轮**共用**
|
||||
// (两条路径都从 runPartition 进来,见 tidy.go)。
|
||||
func (t *Tidier) dedupeCandidates(items []MemoryItem, meta map[string]tidyDedupeMeta,
|
||||
covered map[string]struct{}, now time.Time) ([]TidyCandidate, tidyDedupeStats) {
|
||||
clusters := dedupeClusters(items, meta, covered)
|
||||
if len(clusters) == 0 {
|
||||
return nil, tidyDedupeStats{}
|
||||
}
|
||||
out := make([]TidyCandidate, 0, len(clusters))
|
||||
stats := tidyDedupeStats{}
|
||||
for _, cl := range clusters {
|
||||
out = append(out, dedupeGroupCandidate(cl, now))
|
||||
stats.Groups++
|
||||
stats.Rows += len(cl.Absorbed)
|
||||
}
|
||||
return out, stats
|
||||
}
|
||||
|
||||
// dedupeClusters 把条目分组、按确定性顺序认领成员、并为每簇选出存活行。
|
||||
//
|
||||
// 「每一条只进一个簇」是靠**全局认领集合**保证的(大簇优先、键字典序次之):
|
||||
// 一条记忆同时出现在两个簇里会让两次应用互相打脸(第二次的 `AND status='active'`
|
||||
// 条件更新如实跳过,但面板会看到一条永远 skip 的候选)。
|
||||
func dedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) []tidyDedupeCluster {
|
||||
groups := dedupeGroupItems(items, meta, covered)
|
||||
ordered := dedupeOrderedGroups(groups)
|
||||
return claimDedupeClusters(items, meta, ordered)
|
||||
}
|
||||
|
||||
// dedupeGroupItems 把可进簇的条目按簇键归组(键 → 成员下标)。
|
||||
func dedupeGroupItems(items []MemoryItem, meta map[string]tidyDedupeMeta, covered map[string]struct{}) map[string]*tidyDedupeGroup {
|
||||
groups := make(map[string]*tidyDedupeGroup)
|
||||
for idx := range items {
|
||||
if _, taken := covered[items[idx].ID]; taken {
|
||||
continue
|
||||
}
|
||||
if meta[items[idx].ID].temporary {
|
||||
// 临时层行不许进簇(见 tidyDedupeMeta.temporary 的说明)。
|
||||
continue
|
||||
}
|
||||
if !dedupeGroupEligible(items[idx]) {
|
||||
continue
|
||||
}
|
||||
for _, key := range dedupeClusterKeys(items[idx]) {
|
||||
g := groups[key.key]
|
||||
if g == nil {
|
||||
g = &tidyDedupeGroup{rule: key.rule, key: key.key}
|
||||
groups[key.key] = g
|
||||
}
|
||||
g.members = append(g.members, idx)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// dedupeOrderedGroups 只留下真正的簇(≥2 条)并按**确定性顺序**排好:大簇优先
|
||||
// (可减行数最多的先落),同大小按 (key, rule) 字典序 —— 与 map 的随机遍历顺序无关,
|
||||
// 因此同一批数据反复运行产出同一批候选(幂等键随之稳定)。
|
||||
func dedupeOrderedGroups(groups map[string]*tidyDedupeGroup) []*tidyDedupeGroup {
|
||||
ordered := make([]*tidyDedupeGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
if len(g.members) < 2 {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, g)
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if len(ordered[i].members) != len(ordered[j].members) {
|
||||
return len(ordered[i].members) > len(ordered[j].members)
|
||||
}
|
||||
if ordered[i].key != ordered[j].key {
|
||||
return ordered[i].key < ordered[j].key
|
||||
}
|
||||
return ordered[i].rule < ordered[j].rule
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
// claimDedupeClusters 逐簇认领成员并产簇:**每一条只进一个簇**(靠全局认领集合),
|
||||
// 存活行与全部被吸收行一起认领 —— 存活行绝不能再当别簇的被吸收行。
|
||||
func claimDedupeClusters(items []MemoryItem, meta map[string]tidyDedupeMeta,
|
||||
ordered []*tidyDedupeGroup) []tidyDedupeCluster {
|
||||
claimed := make(map[int]struct{}, len(items))
|
||||
out := make([]tidyDedupeCluster, 0, len(ordered))
|
||||
for _, g := range ordered {
|
||||
free := make([]tidyDedupeMember, 0, len(g.members))
|
||||
for _, idx := range g.members {
|
||||
if _, taken := claimed[idx]; taken {
|
||||
continue
|
||||
}
|
||||
free = append(free, tidyDedupeMember{item: items[idx], mergeCount: meta[items[idx].ID].mergeCount})
|
||||
}
|
||||
if len(free) < 2 {
|
||||
continue
|
||||
}
|
||||
survivor, keptReason := pickDedupeSurvivor(free)
|
||||
absorbed := dedupeAbsorbable(g.rule, survivor, free)
|
||||
if len(absorbed) == 0 {
|
||||
continue
|
||||
}
|
||||
keep := make(map[string]struct{}, len(absorbed)+1)
|
||||
keep[survivor.item.ID] = struct{}{}
|
||||
for _, it := range absorbed {
|
||||
keep[it.item.ID] = struct{}{}
|
||||
}
|
||||
for _, idx := range g.members {
|
||||
if _, ok := keep[items[idx].ID]; ok {
|
||||
claimed[idx] = struct{}{}
|
||||
}
|
||||
}
|
||||
out = append(out, tidyDedupeCluster{
|
||||
Rule: g.rule, Key: g.key, Survivor: survivor.item, Absorbed: dedupeItems(absorbed),
|
||||
KeptReason: keptReason,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tidyDedupeGroup 是内部用的分组累加器(键 → 成员下标)。
|
||||
type tidyDedupeGroup struct {
|
||||
rule string
|
||||
key string
|
||||
members []int
|
||||
}
|
||||
|
||||
// dedupeGroupEligible 报告一条记忆能不能进机械簇。它是既有硬门/否决规则的**并集**,
|
||||
// 因此 dedupe_group 的判定面严格窄于 `judgePair`(后者还要求包含关系)。
|
||||
func dedupeGroupEligible(it MemoryItem) bool {
|
||||
if strings.TrimSpace(it.ID) == "" {
|
||||
// 没有 id 的行不可能被候选引用(候选靠 source_ids / target_id 指向真实行),
|
||||
// 把它放进簇只会产出一条永远 skip 的候选。纵深防御:这条在落库数据上不会出现。
|
||||
return false
|
||||
}
|
||||
if nonOwnerSource(it.SourceConversation) {
|
||||
// 群聊/陌生人来源只读不写(design/23 §5.2-9、§6):连簇都不进。
|
||||
return false
|
||||
}
|
||||
if hasAbsoluteTimestamp(it.Content) {
|
||||
// 一次性事件走 archive(§5.1 T0-5),不进合并。
|
||||
return false
|
||||
}
|
||||
if hasTemporalState(it.Content) {
|
||||
// 有期限状态(假期/期间/本周…)不是长期事实(§5.2-1)。
|
||||
return false
|
||||
}
|
||||
if it.Confidence < tidyLowConfidenceThreshold {
|
||||
// 低置信是**否决门**(§5.2-6):低置信条目的孪生被它挡住了 2479 对,
|
||||
// 那正是体检报告 §6.2 的最大否决项。dedupe_group 不越过这道门。
|
||||
return false
|
||||
}
|
||||
if len([]rune(it.Content)) < minTidyContentRunes {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。
|
||||
//
|
||||
// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含**
|
||||
// `merge_count`(它只写不读),而「存活行选谁」的次要判据正是它(合并次数多的那条
|
||||
// 已经吸收过别的表述,留它信息量最大)。为一个次要判据去改 13 列读面会牵动
|
||||
// Get/List*/preinsert/tidy 四处 SQL,风险远大于收益;改为按分区一次轻量查询把
|
||||
// id → merge_count 取出来(见 Store.dedupeMergeCounts)。
|
||||
type tidyDedupeMember struct {
|
||||
item MemoryItem
|
||||
mergeCount int
|
||||
}
|
||||
|
||||
// dedupeItems 把成员视图降回 MemoryItem(簇只对外暴露记忆行)。
|
||||
func dedupeItems(members []tidyDedupeMember) []MemoryItem {
|
||||
out := make([]MemoryItem, 0, len(members))
|
||||
for _, m := range members {
|
||||
out = append(out, m.item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pickDedupeSurvivor 按「内容最长 > merge_count 大 > created_at 早 > id 字典序」选存活行,
|
||||
// 并返回命中的那条规则名(进 evidence.kept_reason,回答「为什么留下的是它」)。
|
||||
func pickDedupeSurvivor(members []tidyDedupeMember) (tidyDedupeMember, string) {
|
||||
best := members[0]
|
||||
for _, it := range members[1:] {
|
||||
switch compareDedupePriority(it, best) {
|
||||
case -1:
|
||||
best = it
|
||||
case 0:
|
||||
if it.item.ID < best.item.ID {
|
||||
best = it
|
||||
}
|
||||
}
|
||||
}
|
||||
return best, dedupeKeepReason(members, best)
|
||||
}
|
||||
|
||||
// compareDedupePriority 比较两条记忆谁更该存活:-1 表示 a 优先,1 表示 b 优先,
|
||||
// 0 表示按 id 决胜(调用方处理)。
|
||||
func compareDedupePriority(a, b tidyDedupeMember) int {
|
||||
if la, lb := len([]rune(a.item.Content)), len([]rune(b.item.Content)); la != lb {
|
||||
if la > lb {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if a.mergeCount != b.mergeCount {
|
||||
if a.mergeCount > b.mergeCount {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !a.item.CreatedAt.Equal(b.item.CreatedAt) {
|
||||
if a.item.CreatedAt.Before(b.item.CreatedAt) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// dedupeKeepReason 报告存活行是靠哪一条胜出的(证据面)。三条判据依次判「是否唯一
|
||||
// 胜出」,都并列时落到最早创建(created_at)那条 —— 与 compareDedupePriority 同序。
|
||||
func dedupeKeepReason(members []tidyDedupeMember, survivor tidyDedupeMember) string {
|
||||
longest := len([]rune(survivor.item.Content))
|
||||
uniqueLongest := true
|
||||
uniqueMergeCount := true
|
||||
for _, it := range members {
|
||||
if it.item.ID == survivor.item.ID {
|
||||
continue
|
||||
}
|
||||
if len([]rune(it.item.Content)) == longest {
|
||||
uniqueLongest = false
|
||||
}
|
||||
if it.mergeCount == survivor.mergeCount {
|
||||
uniqueMergeCount = false
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case uniqueLongest:
|
||||
return tidyDedupeKeepLongest
|
||||
case uniqueMergeCount:
|
||||
return tidyDedupeKeepMergeCount
|
||||
case survivor.item.CreatedAt.Before(earliestOtherCreatedAt(members, survivor)):
|
||||
return tidyDedupeKeepOldest
|
||||
default:
|
||||
return tidyDedupeKeepID
|
||||
}
|
||||
}
|
||||
|
||||
// earliestOtherCreatedAt 返回除存活行以外最早创建的时刻(用于区分「最早胜出」与
|
||||
// 「id 决胜」这两种证据)。空簇成员只在单条簇里出现,那时调用方不会问证据。
|
||||
func earliestOtherCreatedAt(members []tidyDedupeMember, survivor tidyDedupeMember) time.Time {
|
||||
var earliest time.Time
|
||||
for _, it := range members {
|
||||
if it.item.ID == survivor.item.ID {
|
||||
continue
|
||||
}
|
||||
if earliest.IsZero() || it.item.CreatedAt.Before(earliest) {
|
||||
earliest = it.item.CreatedAt
|
||||
}
|
||||
}
|
||||
return earliest
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group`(2026-10-24,M34)的**应用与回滚**执行面。
|
||||
//
|
||||
// 它与 merge 共用同一套语义(design/23 §8 的软合并):被吸收行
|
||||
// `status='merged' + merged_into=<存活 id>`,存活行 `merged_from` 追加、`merge_count`
|
||||
// 累加;**绝不改正文、绝不删行**。因此回滚直接复用 `revertTidyMerge`(它本来就按
|
||||
// `SourceIDs` 逐条复原,不是为「一对一」写的)。
|
||||
//
|
||||
// 与单条 merge 的两点差别、以及它们的理由:
|
||||
//
|
||||
// 1. **一条候选吸收多行**(一簇 N 条)。整簇在**一个事务**里落,任何一条不在册
|
||||
// ⇒ 整簇回滚(`errTidyItemNotActive`),不会出现「吞了一半」的中间态 ——
|
||||
// 半吞的簇最坏:面板显示 applied,但被吞行里有一半还在 active 集合里,
|
||||
// 回滚也无从判断该复原哪几条。
|
||||
// 2. **按行计的分批上限**(`maxTidyApplyRowsPerBatch`)。一条候选动 N 行,若只按
|
||||
// 「候选条数」限批,一次批量应用可能在一条命令里改上千行记忆 —— 这正是体检
|
||||
// 报告 §7.2 要求分批的理由(「第 2 批必须分批 + 每批留 run_id」)。行预算在
|
||||
// `ApplyTidyCandidates` 里统一执行,超限如实报 `truncated`。
|
||||
//
|
||||
// 🔴 **不进自动档**:`tidyAutoApplyEligible` 的 switch 只认 merge/archive/reclassify,
|
||||
// 默认分支返回 false,因此 dedupe_group 永远只走人工/批量点(测试钉着这一条)。
|
||||
|
||||
// maxTidyApplyRowsPerBatch 是一次批量应用**最多改动的记忆行数**(dedupe_group 按
|
||||
// 被吸收行计)。200 的来源是体检报告 §7 第 2 批的建议值:一簇平均 3~4 行,
|
||||
// 200 行 ≈ 50 簇,一屏可核对;它比候选条数上限(100)**更严**的场合是「簇很大」,
|
||||
// 这正是需要护栏的那种数据。
|
||||
const maxTidyApplyRowsPerBatch = 200
|
||||
|
||||
// tidyApplyRowCost 返回一条候选**会改动的记忆行数**,供批量应用的按行预算使用。
|
||||
// 只有 dedupe_group 是多行动作;其余三种动作恒为 1(merge/archive/reclassify 各碰一行)。
|
||||
func tidyApplyRowCost(c TidyCandidate) int {
|
||||
if c.Action == TidyDedupeGroup && len(c.SourceIDs) > 0 {
|
||||
return len(c.SourceIDs)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// applyTidyDedupeGroup 把一簇被吸收行软合并进存活行。
|
||||
//
|
||||
// 前置校验与 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{},
|
||||
}
|
||||
if len(cand.SourceIDs) == 0 || cand.TargetID == "" {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
survivor, absorbed, skip, err := s.loadDedupeGroupRows(ctx, cand)
|
||||
if err != nil {
|
||||
return base, err
|
||||
}
|
||||
if skip != "" {
|
||||
return skipTidyApply(base, skip), nil
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
// 逐条 bump:bumpSurvivor 在事务内**重读**存活行的 merge_count,
|
||||
// 因此连续调用会把 N 条都记进 merged_from、把计数加 N 次。
|
||||
if err := bumpSurvivor(ctx, tx, survivor, it, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
affected := make([]string, 0, len(absorbed))
|
||||
for _, it := range absorbed {
|
||||
affected = append(affected, it.ID)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**应用 / 回滚**验收用例集(软合并、整簇原子、
|
||||
// 按行计的分批上限、临时层数据安全)。分簇与候选构造的用例在
|
||||
// tidy_dedupe_test.go —— 分开只为守 AGENTS §10 的文件行数上限(≤500 行)。
|
||||
|
||||
// TestTidyApplyDedupeGroupIsSoftAndRevertible 钉住应用/回滚:整簇软合并(行不删、
|
||||
// 正文不改、merge_count 累加),revert 把**每一行**都复原。
|
||||
func TestTidyApplyDedupeGroupIsSoftAndRevertible(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))),
|
||||
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*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)
|
||||
}
|
||||
|
||||
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate: %v", err)
|
||||
}
|
||||
if res.Status != TidyApplyApplied || len(res.AffectedIDs) != 2 {
|
||||
t.Fatalf("应用结果 = %+v, want applied 且影响 2 行", res)
|
||||
}
|
||||
for _, id := range cand.SourceIDs {
|
||||
row := loadTidyItemRow(t, store, id)
|
||||
if row.status != ingestStatusMerged || row.mergedInto != cand.TargetID {
|
||||
t.Fatalf("被吸收行 %s = %+v, want merged → %s", id, row, cand.TargetID)
|
||||
}
|
||||
}
|
||||
survivor := loadTidyItemRow(t, store, cand.TargetID)
|
||||
if survivor.status != tidyStatusActive {
|
||||
t.Fatalf("存活行状态 = %q, want active", survivor.status)
|
||||
}
|
||||
if got := countMemoryItems(t, store); got != 3 {
|
||||
t.Fatalf("应用后行数 = %d, want 3(软合并绝不删行)", got)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 3 {
|
||||
t.Fatalf("存活行 merge_count = %d, want 3(1 + 吸收 2)", got)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, cand.TargetID).content; got != items[2].Content {
|
||||
t.Fatalf("存活行正文被改了:%q", got)
|
||||
}
|
||||
|
||||
reverted, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("RevertTidyCandidate: %v", err)
|
||||
}
|
||||
if reverted.Status != TidyApplyApplied || len(reverted.AffectedIDs) != 2 {
|
||||
t.Fatalf("回滚结果 = %+v, want applied 且复原 2 行", reverted)
|
||||
}
|
||||
for _, id := range cand.SourceIDs {
|
||||
row := loadTidyItemRow(t, store, id)
|
||||
if row.status != tidyStatusActive || row.mergedInto != "" {
|
||||
t.Fatalf("回滚后 %s = %+v, want active 且 merged_into 清空", id, row)
|
||||
}
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 {
|
||||
t.Fatalf("回滚后 merge_count = %d, want 1", got)
|
||||
}
|
||||
if got, _ := store.GetTidyCandidate(ctx, cand.ID); got.Status != tidyCandidatePending {
|
||||
t.Fatalf("回滚后候选状态 = %q, want pending(可重新决定)", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember 钉住原子的**安全方向**:
|
||||
// 簇里有一行已不在册 ⇒ 整簇不吞(一行都不改),候选如实 skip。
|
||||
func TestTidyApplyDedupeGroupRollsBackWholeClusterOnStaleMember(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))),
|
||||
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, store, items)
|
||||
cand := cands[0]
|
||||
if created, err := store.insertTidyCandidate(ctx, cand); err != nil || !created {
|
||||
t.Fatalf("insert candidate: created=%v err=%v", created, err)
|
||||
}
|
||||
// 让簇里一个成员先被别的动作合并掉(模拟过时候选)。
|
||||
if _, err := store.db.ExecContext(ctx,
|
||||
`UPDATE memory_items SET status = ? WHERE id = ?`,
|
||||
ingestStatusMerged, cand.SourceIDs[0]); err != nil {
|
||||
t.Fatalf("stale member update: %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 != tidySkipSourceNotActive {
|
||||
t.Fatalf("结果 = %+v, want skipped/%s(整簇原子,不许吞一半)", res, tidySkipSourceNotActive)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, cand.TargetID).mergeCount; got != 1 {
|
||||
t.Fatalf("存活行 merge_count = %d, want 1(整簇回滚,一行都没改)", got)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, cand.SourceIDs[1]).status; got != tidyStatusActive {
|
||||
t.Fatalf("第二个被吸收行状态 = %q, want active(半吞禁止)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyDedupeGroupHonoursRowBudget 钉住「批量按行计、超限如实 truncated」:
|
||||
// 每簇 2 个被吸收行(行预算按被吸收行计),预算 200 ⇒ 最多 100 簇(= 200 行),
|
||||
// 第 101 簇起留给下一次点击,回执必须如实说 truncated。
|
||||
func TestTidyApplyDedupeGroupHonoursRowBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
total := maxTidyApplyRowsPerBatch/2 + 5 // 簇数多到必然撞上 200 行预算
|
||||
wantApplied := maxTidyApplyRowsPerBatch / 2 // 每簇 2 个被吸收行
|
||||
|
||||
for i := 0; i < total; i++ {
|
||||
tags := []string{"身份", "代号"}
|
||||
content := fmt.Sprintf("小茂密的第 %d 号说法:蝴蝶结是小铃铛", i)
|
||||
items := []MemoryItem{
|
||||
seedDedupeItem(t, store, dedupeTestItem(content+"甲", tags, base)),
|
||||
seedDedupeItem(t, store, dedupeTestItem(content+"乙", tags, base.Add(time.Minute))),
|
||||
seedDedupeItem(t, store, dedupeTestItem(content+"丙", tags, base.Add(2*time.Minute))),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, store, items)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("第 %d 组簇数 = %d, want 1", i, len(cands))
|
||||
}
|
||||
if created, err := store.insertTidyCandidate(ctx, cands[0]); err != nil || !created {
|
||||
t.Fatalf("insert candidate %d: created=%v err=%v", i, created, err)
|
||||
}
|
||||
}
|
||||
batch, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test", Limit: 500})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidates: %v", err)
|
||||
}
|
||||
if !batch.Truncated {
|
||||
t.Fatalf("truncated = false, want true(行预算 200 被撞上时必须如实说)")
|
||||
}
|
||||
if batch.Applied != wantApplied {
|
||||
t.Fatalf("应用簇数 = %d, want %d(200 行 ÷ 3 行/簇)", batch.Applied, wantApplied)
|
||||
}
|
||||
if batch.RowsAffected != wantApplied*2 {
|
||||
t.Fatalf("改动行数 = %d, want %d", batch.RowsAffected, wantApplied*2)
|
||||
}
|
||||
if batch.Deduped != wantApplied {
|
||||
t.Fatalf("deduped 计数 = %d, want %d", batch.Deduped, wantApplied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyDryRunReportsDedupeGroupsWithoutWriting 钉住「**先试算、不写库**」这条要求:
|
||||
// 试算轮必须给出 dedupe_group 的**簇数与可减少行数**(面板据此决定要不要落库),
|
||||
// 并且三张表逐行不变(零副作用)—— 试算与真跑共用同一条规则路径,因此这份报告
|
||||
// 就是「真跑会产出的东西」。
|
||||
func TestTidyDryRunReportsDedupeGroupsWithoutWriting(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
// 三条**互不包含**、但 tag 集完全相同的说法(否则它们会先被包含判定吃掉,
|
||||
// 那时 dedupe 会被「已覆盖」抑制掉 —— 那是对的行为,但测不到试算口径)。
|
||||
tags := []string{"宠物", "小茂密"}
|
||||
for i, content := range []string{
|
||||
"小茂密是白毛的小猫,喜欢撒娇",
|
||||
"白毛小茂密爱撒娇,是只小猫",
|
||||
"爱撒娇的小猫名叫小茂密,通体白毛",
|
||||
} {
|
||||
seedDedupeItem(t, store, dedupeTestItem(content, tags, base.Add(time.Duration(i)*time.Minute)))
|
||||
}
|
||||
before := snapshotTidyTables(t, store)
|
||||
emb := stubEmbedder{vecs: map[string]embedding.Vector{}}
|
||||
|
||||
dr, err := newTestTidier(t, store, tidyTestCfg(0.80), emb).DryRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("DryRun: %v", err)
|
||||
}
|
||||
if dr.Stats.DedupeGroups != 1 || dr.Stats.DedupeRows != 2 {
|
||||
t.Fatalf("试算口径 = groups:%d rows:%d, want 1 / 2(三行一簇,可减两行)",
|
||||
dr.Stats.DedupeGroups, dr.Stats.DedupeRows)
|
||||
}
|
||||
dedupeCands := candidatesByAction(dr.Candidates, TidyDedupeGroup)
|
||||
if len(dedupeCands) != 1 {
|
||||
t.Fatalf("试算候选里的 dedupe_group = %d 条, want 1", len(dedupeCands))
|
||||
}
|
||||
assertRowsUnchanged(t, "试算后三张表", before.items, snapshotTidyTables(t, store).items)
|
||||
after := snapshotTidyTables(t, store)
|
||||
assertRowsUnchanged(t, "试算后候选表", before.candidates, after.candidates)
|
||||
assertRowsUnchanged(t, "试算后运行表", before.runs, after.runs)
|
||||
}
|
||||
|
||||
// TestDedupeGroupNeverTouchesTemporaryRows 钉住临时层的**数据安全**不变式:
|
||||
// `expires_at` 非空的记忆(临时层)绝不进簇 —— 无论当存活行还是被吸收行。
|
||||
//
|
||||
// 为什么是红线:临时行 7 天后由后台清扫**物理删除**(design/23 §19)。若它当存活行,
|
||||
// 被吸收的长期事实会永远停在 `status='merged'` 且 `merged_into` 指向一个已不存在的
|
||||
// id(信息不可达);若它当被吸收行,一次合并就把一条正在生效的临时记忆藏了起来。
|
||||
func TestDedupeGroupNeverTouchesTemporaryRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"身份", "名字"}
|
||||
durable := seedDedupeItem(t, store, dedupeTestItem("助理的名字是 Cyrene。", tags, base))
|
||||
temporary := seedDedupeItem(t, store, dedupeTestItem("助手名为 Cyrene。", tags, base.Add(time.Minute)))
|
||||
if _, err := store.db.ExecContext(ctx,
|
||||
`UPDATE memory_items SET expires_at = ? WHERE id = ?`,
|
||||
expiryText(base.Add(7*24*time.Hour)), temporary.ID); err != nil {
|
||||
t.Fatalf("mark temporary: %v", err)
|
||||
}
|
||||
cands, stats := dedupeCandsOf(t, store, []MemoryItem{durable, temporary})
|
||||
if len(cands) != 0 || stats.Groups != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(临时层行不许进簇:存活行会让被吸收行指向一个将被删除的 id)",
|
||||
len(cands))
|
||||
}
|
||||
|
||||
// 三条时:两条长期 + 一条临时 ⇒ 正是两条长期的那一簇,临时行不在候选里。
|
||||
durable2 := seedDedupeItem(t, store, dedupeTestItem("助理/助手名为 Cyrene。", tags, base.Add(2*time.Minute)))
|
||||
cands, _ = dedupeCandsOf(t, store, []MemoryItem{durable, temporary, durable2})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1", len(cands))
|
||||
}
|
||||
for _, id := range cands[0].SourceIDs {
|
||||
if id == temporary.ID {
|
||||
t.Fatalf("临时行出现在被吸收行里:%v", cands[0].SourceIDs)
|
||||
}
|
||||
}
|
||||
if cands[0].TargetID == temporary.ID {
|
||||
t.Fatalf("临时行当上了存活行 —— 它 7 天后会被物理删除")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyRunReportsDedupeTruncation 钉住「truncated 如实」:每分区候选上限切掉了
|
||||
// 多少 dedupe 簇必须**单独**报出来。变异(不给 DedupeTruncated 计数、或按错误的
|
||||
// 段计算)会让这条用例红 —— 被截断的簇在面板上会彻底消失,那正是「静默少做」。
|
||||
func TestTidyRunReportsDedupeTruncation(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
|
||||
// 两个互不相干的簇(tag 集不同 ⇒ 两个簇),一共 2 条 dedupe 候选。
|
||||
seed := []struct {
|
||||
content string
|
||||
tags []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)))
|
||||
}
|
||||
cfg := tidyTestCfg(0.80)
|
||||
cfg.CandidateLimit = 1
|
||||
emb := stubEmbedder{vecs: map[string]embedding.Vector{}}
|
||||
stats, err := newTestTidier(t, store, cfg, emb).RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if stats.DedupeGroups != 2 {
|
||||
t.Fatalf("分簇数 = %d, want 2(分簇口径不受候选上限影响)", stats.DedupeGroups)
|
||||
}
|
||||
if stats.DedupeTruncated != 1 {
|
||||
t.Fatalf("DedupeTruncated = %d, want 1(上限 1 只容得下一簇)", stats.DedupeTruncated)
|
||||
}
|
||||
if len(stats.Truncated) == 0 {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
|
||||
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
|
||||
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
|
||||
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
|
||||
|
||||
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
|
||||
//
|
||||
// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
|
||||
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
|
||||
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
|
||||
//
|
||||
// 1. **数值**(§5.2-2,judgeDedupeNumbers):**三态**判据 ——
|
||||
// 同值 ⇒ 可合并(中英混写也认:`一小时` ≡ `60 分钟`)/同量纲不同值 ⇒ 冲突/
|
||||
// 量纲不可比 ⇒ 保守不许合并(`一小时` vs `一杯` 不是"放行")。
|
||||
// 它取代了 `sameNumbers`(后者只认 ASCII token,既合不了中英混写、也看不见中文数字);
|
||||
// 裸数字 token(无单位)仍按 `sameNumbers` 的**集合相等**口径判,一字未改。
|
||||
// 沿革:r2(中文数字冲突)→ r3-项 2(归一后同值必须能合)→ r3-项 3(不可比必须拒)。
|
||||
// 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 {
|
||||
if it.item.ID == survivor.item.ID {
|
||||
continue
|
||||
}
|
||||
if verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame {
|
||||
continue
|
||||
}
|
||||
if !sameNegations(it.item.Content, survivor.item.Content) {
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的
|
||||
// dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。
|
||||
//
|
||||
// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点;
|
||||
// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实
|
||||
// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。
|
||||
func dedupeGroupMinSimilarity(rule string) float64 {
|
||||
if rule == tidyDedupeRulePrefix {
|
||||
return dedupeGroupPrefixSimilarity
|
||||
}
|
||||
return dedupeGroupTagSetSimilarity
|
||||
}
|
||||
|
||||
// dedupeGroupCandidate 把一个簇转成候选。
|
||||
//
|
||||
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
|
||||
// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并),
|
||||
// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。
|
||||
func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate {
|
||||
absorbedIDs := make([]string, 0, len(cl.Absorbed))
|
||||
members := make([]string, 0, len(cl.Absorbed))
|
||||
for _, it := range cl.Absorbed {
|
||||
absorbedIDs = append(absorbedIDs, it.ID)
|
||||
members = append(members, it.Content)
|
||||
}
|
||||
return TidyCandidate{
|
||||
ID: mustNewTidyID(),
|
||||
PersonaID: cl.Survivor.PersonaID,
|
||||
Scope: cl.Survivor.Scope,
|
||||
ProjectID: cl.Survivor.ProjectID,
|
||||
Kind: cl.Survivor.Kind,
|
||||
Action: TidyDedupeGroup,
|
||||
SourceIDs: absorbedIDs,
|
||||
TargetID: cl.Survivor.ID,
|
||||
ProposedContent: cl.Survivor.Content,
|
||||
MergedContent: cl.Survivor.Content,
|
||||
Reason: tidyReasonDedupeGroup,
|
||||
Evidence: TidyEvidence{
|
||||
Containment: "none",
|
||||
ContentA: cl.Survivor.Content,
|
||||
DedupeRule: cl.Rule,
|
||||
DedupeKey: cl.Key,
|
||||
ClusterSize: len(cl.Absorbed) + 1,
|
||||
KeptReason: cl.KeptReason,
|
||||
DedupeMembers: members,
|
||||
KindA: string(cl.Survivor.Kind),
|
||||
},
|
||||
RuleVersion: TidyRuleVersion,
|
||||
Status: tidyCandidatePending,
|
||||
CreatedAt: now,
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**元信息查询**:按分区取出「合并次数」与「是否临时层」
|
||||
// 两列 —— 它们是存活规则的次要判据与一条数据安全护栏(临时行绝不进簇)。
|
||||
// 从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
|
||||
// 注释与判据**逐字未动**。
|
||||
|
||||
// dedupeItemMetaSQL 取一个分区内每条记忆的**分簇所需元信息**:合并次数
|
||||
// (存活规则的次要判据)+ 是否是临时层(`expires_at` 非空)。
|
||||
//
|
||||
// 两列都只读、只走分区条件,与 tidy 分区扫描同一组谓词(不含 archived/merged)。
|
||||
const dedupeItemMetaSQL = `SELECT id, merge_count, expires_at FROM memory_items
|
||||
WHERE persona_id = ? AND scope = ? AND project_id = ? AND status = ?`
|
||||
|
||||
// tidyDedupeMeta 是一条记忆的额外元信息(`MemoryItem` 的读侧 SELECT 不含这两列)。
|
||||
type tidyDedupeMeta struct {
|
||||
// mergeCount 是存活规则的次要判据(合并次数多的留)。
|
||||
mergeCount int
|
||||
// temporary 报告这条记忆在**临时层**(`expires_at` 非空,design/03 §9.6)。
|
||||
//
|
||||
// 🔴 临时层行**绝不进簇**,两个方向都不许:让一条长期事实并进一个 7 天后会被
|
||||
// 后台**物理删除**(design/23 §19)的存活行,等于把那条事实一起删掉 ——
|
||||
// 被吸收行会永远停在 `status='merged'` 且 `merged_into` 指向一个不存在的 id。
|
||||
// 临时行本身也不值得合并:它的寿命本来就只剩几天。
|
||||
temporary bool
|
||||
}
|
||||
|
||||
// dedupeItemMeta 读出一个分区的 id → 元信息映射。
|
||||
//
|
||||
// 🔴 **失败方向是「这个分区这轮不分簇」**(不是「降级继续」):`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))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory/tidy: dedupe item meta: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := make(map[string]tidyDedupeMeta, 64)
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
mergeCnt int
|
||||
expiresAt string
|
||||
)
|
||||
if err := rows.Scan(&id, &mergeCnt, &expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("memory/tidy: scan dedupe item meta: %w", err)
|
||||
}
|
||||
out[id] = tidyDedupeMeta{mergeCount: mergeCnt, temporary: strings.TrimSpace(expiresAt) != ""}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("memory/tidy: dedupe item meta rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` 的**数值门**(2026-10-25):数值冲突要同时认出 ASCII 数字
|
||||
// 与中文数字,按**量纲/单位归一**后比较,并给出**三态**结论。
|
||||
//
|
||||
// 沿革(三轮复审各修一处,别把其中任何一条当"可简化"):
|
||||
// - r2:`sameNumbers` 只抽 ASCII 数字 token,而语料里的数字主要写成中文
|
||||
// (一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒ 真冲突被当同一事实合并(实测 ~8 对);
|
||||
// - r3-项 2(**过严**):归一后**同值**、中文与 ASCII 混写的两条必须能合并
|
||||
// (`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)—— 归一化就是为此存在;
|
||||
// - r3-项 3(**过松**):**量纲不可比**(`一小时` vs `一杯`)不许"当作不算冲突"放行,
|
||||
// 必须按「比不了就不许合并」保守拒绝。
|
||||
//
|
||||
// ⇒ 判据是**三态**,不是布尔「有没有冲突」:
|
||||
//
|
||||
// 同值 ⇒ 可合并(tidyNumberSame)
|
||||
// 同量纲不同值 ⇒ 冲突,不许合并(tidyNumberConflict)
|
||||
// 量纲不可比/无可比 ⇒ 保守不许合并(tidyNumberIncomparable)
|
||||
//
|
||||
// 三态都建立在同一个前提上:**被吸收行的每个数量都要在存活行里找到等值对应**
|
||||
// (方向与被吸收 ⊆ 存活 的软合并语义一致)。找不到时,看**为什么找不到**来分态:
|
||||
// 存活行在同一量纲里有数量但值不同 ⇒ 冲突;存活行在该量纲里根本没有数量
|
||||
//(含"存活行一个数字都没有")⇒ 量纲不可比 ⇒ 保守拒绝。
|
||||
//
|
||||
// 形态判据是「**数字 + 单位**」而不是「数字」:
|
||||
// - 只认后面紧跟已知单位(小时/分钟/杯/次/份…)的数字,因此「一起」「一直」
|
||||
// 「一些」「一样」「一部分」这类含数字的常见词不会被当成数量;
|
||||
// - 没有单位的数字走**裸 token** 路径(与既有 `extractTidyFacts` 的 num 抽取同口径):
|
||||
// 两侧 token 集合必须**完全相等**(既有 `sameNumbers` 的语义,一字不改)。
|
||||
|
||||
// tidyNumberVerdict 是一对记忆的数值关系(三态)。
|
||||
type tidyNumberVerdict int
|
||||
|
||||
const (
|
||||
// tidyNumberSame:归一后数值一致(或无数量可比且裸 token 相同)⇒ 可合并。
|
||||
tidyNumberSame tidyNumberVerdict = iota
|
||||
// tidyNumberConflict:同一量纲里值不相等 ⇒ 冲突,不许合并。
|
||||
tidyNumberConflict
|
||||
// tidyNumberIncomparable:量纲不可比 / 存活行没有可比数量 ⇒ 保守不许合并。
|
||||
tidyNumberIncomparable
|
||||
)
|
||||
|
||||
// String 让证据与测试断言可读。
|
||||
func (v tidyNumberVerdict) String() string {
|
||||
switch v {
|
||||
case tidyNumberSame:
|
||||
return "same"
|
||||
case tidyNumberConflict:
|
||||
return "conflict"
|
||||
case tidyNumberIncomparable:
|
||||
return "incomparable"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// tidyQuantity 是一处「数量」:归一后的值 + 单位名 + 量纲。
|
||||
type tidyQuantity struct {
|
||||
// value 是按单位换算到**量纲基准单位**后的值(时间=秒、计数=个、长度=米)。
|
||||
value float64
|
||||
// unit 是单位名(写清冲突原因,也是「能不能换算」的判据)。
|
||||
unit string
|
||||
// dim 是量纲(time / count / length / text)。同量纲才可比。
|
||||
dim string
|
||||
// raw 是原始写法(数字 + 单位),只用于日志/测试断言。
|
||||
raw string
|
||||
}
|
||||
|
||||
// judgeDedupeNumbers 判一对记忆的数值关系(三态),并给出理由(证据/日志用)。
|
||||
//
|
||||
// 顺序:先比**裸数字 token**(与 `sameNumbers` 同语义:集合必须完全相等),
|
||||
// 再逐条比**数量**(同值 / 同量纲不同值 / 不可比)。
|
||||
func judgeDedupeNumbers(absorbed, survivor string) (tidyNumberVerdict, []string) {
|
||||
aQty, aBare := scanTidyNumbers(absorbed)
|
||||
sQty, sBare := scanTidyNumbers(survivor)
|
||||
|
||||
if !sameStringSet(aBare, sBare) {
|
||||
// 裸数字(无单位)不一致:与既有 sameNumbers 同一条口径 —— 数值不同即不同事实。
|
||||
return tidyNumberConflict, []string{fmt.Sprintf("裸数字 token 不一致:%v vs %v", aBare, sBare)}
|
||||
}
|
||||
verdict := tidyNumberSame
|
||||
reasons := make([]string, 0, 2)
|
||||
for _, q := range aQty {
|
||||
same, sameDim := false, false
|
||||
for _, h := range sQty {
|
||||
if h.dim != q.dim {
|
||||
continue
|
||||
}
|
||||
sameDim = true
|
||||
if sameQuantity(q, h) {
|
||||
same = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if same {
|
||||
continue
|
||||
}
|
||||
if sameDim {
|
||||
// 同量纲、值不相等(含单位换算不到一起)⇒ 真冲突。
|
||||
verdict = tidyNumberConflict
|
||||
reasons = append(reasons, fmt.Sprintf("%s≠同量纲的其它取值", q.raw))
|
||||
continue
|
||||
}
|
||||
// 存活行在该量纲里没有数量 ⇒ 比不了 ⇒ 保守拒绝(r3-项 3)。
|
||||
// 注意:这条**不是**「放行」,因此 `一小时` vs `一杯` 不会被合并。
|
||||
if verdict != tidyNumberConflict {
|
||||
verdict = tidyNumberIncomparable
|
||||
}
|
||||
reasons = append(reasons, fmt.Sprintf("%s 在存活行里没有同量纲数量可比", q.raw))
|
||||
}
|
||||
return verdict, reasons
|
||||
}
|
||||
|
||||
// sameQuantity 报告两处数量是否等值(归一化后比较)。
|
||||
func sameQuantity(a, b tidyQuantity) bool {
|
||||
if a.dim != b.dim {
|
||||
return false
|
||||
}
|
||||
diff := a.value - b.value
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
}
|
||||
// 浮点留一点余量:换算会乘除(半小时 = 0.5×3600 秒)。
|
||||
return diff < 0.5
|
||||
}
|
||||
|
||||
// sameStringSet 报告两个字符串切片(已排序去重)是否完全相等。
|
||||
func sameStringSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// tidyQuantities 抽出一段正文里所有的「数字 + 单位」(ASCII 与中文数字都算)。
|
||||
func tidyQuantities(text string) []tidyQuantity {
|
||||
qty, _ := scanTidyNumbers(text)
|
||||
return qty
|
||||
}
|
||||
|
||||
// scanTidyNumbers 一次扫描产出两类结果:
|
||||
// - quantities:「数字 + 已知单位」的数量(ASCII 数字与中文数字都算);
|
||||
// - bare:**没有单位**的 ASCII 数字 token(口径与 extractTidyFacts 的 nums 一致:
|
||||
// 数字之间的 : : / . - 并入同一个 token),升序去重。
|
||||
//
|
||||
// 中文数字没有单位时**不产出任何东西**(「一起」「一直」「第二天」这类习语占绝对多数)。
|
||||
func scanTidyNumbers(text string) ([]tidyQuantity, []string) {
|
||||
normalized := dedupeKey(text)
|
||||
runes := []rune(normalized)
|
||||
qty := make([]tidyQuantity, 0, 4)
|
||||
bare := make([]string, 0, 2)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
// ① 中文数字 + 单位
|
||||
if value, next, ok := parseChineseNumberAt(runes, i); ok {
|
||||
unit, dim, factor, unext, uok := tidyUnitAt(runes, next)
|
||||
if !uok || tidyOrdinalPosition(runes, i, dim) {
|
||||
i = next - 1
|
||||
continue
|
||||
}
|
||||
qty = append(qty, tidyQuantity{
|
||||
value: value * factor, unit: unit, dim: dim, raw: string(runes[i:unext]),
|
||||
})
|
||||
i = unext - 1
|
||||
continue
|
||||
}
|
||||
// ② ASCII 数字 + 单位(`60 分钟` / `15分钟` / `3 份`)
|
||||
if runes[i] < '0' || runes[i] > '9' {
|
||||
continue
|
||||
}
|
||||
token, tend := scanASCIINumberToken(runes, i)
|
||||
value, isFloat := parseASCIINumberValue(token)
|
||||
unitAt := skipASCIISpaces(runes, tend)
|
||||
if unit, dim, factor, unext, uok := tidyUnitAt(runes, unitAt); uok && isFloat {
|
||||
qty = append(qty, tidyQuantity{
|
||||
value: value * factor, unit: unit, dim: dim, raw: string(runes[i:unext]),
|
||||
})
|
||||
i = unext - 1
|
||||
continue
|
||||
}
|
||||
bare = append(bare, token)
|
||||
i = tend - 1
|
||||
}
|
||||
sort.Strings(bare)
|
||||
bare = dedupeSortedStrings(bare)
|
||||
return qty, bare
|
||||
}
|
||||
|
||||
// scanASCIINumberToken 读一个 ASCII 数字 token(数字之间允许 : : / . - 分隔,
|
||||
// 与 extractTidyFacts 的 num 抽取同一规则)。返回去掉首尾分隔符的 token 与结束下标。
|
||||
func scanASCIINumberToken(runes []rune, at int) (string, int) {
|
||||
var b strings.Builder
|
||||
i := at
|
||||
for ; i < len(runes); i++ {
|
||||
r := runes[i]
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if (r == ':' || r == ':' || r == '/' || r == '.' || r == '-') && b.Len() > 0 {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
return strings.Trim(b.String(), "::/.-"), i
|
||||
}
|
||||
|
||||
// parseASCIINumberValue 把 ASCII 数字 token 解析成数值。带分隔符的复合 token
|
||||
// (`22:38`、`10/3`、`3.5` 之外的多段写法)返回 false —— 它们不是「数量」,
|
||||
// 只作为裸 token 参与集合比较。
|
||||
func parseASCIINumberValue(token string) (float64, bool) {
|
||||
if token == "" {
|
||||
return 0, false
|
||||
}
|
||||
if v, err := strconv.ParseFloat(token, 64); err == nil {
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// skipASCIISpaces 跳过一个空白(`60 分钟` 这种写法)。
|
||||
func skipASCIISpaces(runes []rune, at int) int {
|
||||
if at < len(runes) && runes[at] == ' ' {
|
||||
return at + 1
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// dedupeSortedStrings 去掉已排序切片里的重复项。
|
||||
func dedupeSortedStrings(in []string) []string {
|
||||
if len(in) < 2 {
|
||||
return in
|
||||
}
|
||||
out := in[:1]
|
||||
for _, s := range in[1:] {
|
||||
if s != out[len(out)-1] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tidyOrdinalPosition 报告这个数字是不是「第X」式的**序数位置**(只对时间量纲的
|
||||
// 天/周/月/年成立:「第二天」是次序,不是两天)。序数 + 计数量词(第二杯)仍是数量。
|
||||
func tidyOrdinalPosition(runes []rune, idx int, dim string) bool {
|
||||
if dim != tidyDimTime || idx == 0 {
|
||||
return false
|
||||
}
|
||||
return runes[idx-1] == '第'
|
||||
}
|
||||
|
||||
// 量纲名(同量纲才可比)。
|
||||
const (
|
||||
tidyDimTime = "time"
|
||||
tidyDimCount = "count"
|
||||
tidyDimLength = "length"
|
||||
tidyDimText = "text"
|
||||
)
|
||||
|
||||
// tidyUnitAt 在 text[at:] 处匹配一个已知单位(**最长优先**:`个月` 先于 `个`)。
|
||||
// 返回单位名、量纲、换算到量纲基准的系数与匹配结束下标。
|
||||
func tidyUnitAt(text []rune, at int) (string, string, float64, int, bool) {
|
||||
best := ""
|
||||
var bestDim string
|
||||
var bestFactor float64
|
||||
bestEnd := at
|
||||
for _, u := range tidyUnitTable() {
|
||||
n := len([]rune(u.name))
|
||||
if at+n > len(text) {
|
||||
continue
|
||||
}
|
||||
if string(text[at:at+n]) != u.name {
|
||||
continue
|
||||
}
|
||||
if n > len([]rune(best)) {
|
||||
best, bestDim, bestFactor, bestEnd = u.name, u.dim, u.factor, at+n
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", "", 0, at, false
|
||||
}
|
||||
if best == "个" {
|
||||
// 「个」在「一个小时 / 一个星期」里是**量词**而不是计数单位:它后面若紧跟
|
||||
// 另一个单位,以那个单位为准(否则「一个小时」会被读成「一个」而丢掉时长)。
|
||||
if inner, dim, factor, end, ok := tidyUnitAt(text, bestEnd); ok {
|
||||
return inner, dim, factor, end, true
|
||||
}
|
||||
}
|
||||
return best, bestDim, bestFactor, bestEnd, true
|
||||
}
|
||||
|
||||
// tidyUnit 是单位表的一行。
|
||||
type tidyUnit struct {
|
||||
name string
|
||||
dim string
|
||||
factor float64
|
||||
}
|
||||
|
||||
// tidyUnitTable 是已知单位表(换算到量纲基准单位:时间=秒、计数=个、长度=米)。
|
||||
//
|
||||
// 月份/年用**约定值**(30 天 / 365 天)换算:它们在语料里是「多久」的意思,
|
||||
// 不换算就变成「比不了」,那会让「半年 vs 六个月」也被判不可比。
|
||||
func tidyUnitTable() []tidyUnit {
|
||||
return []tidyUnit{
|
||||
// 时间(基准:秒)
|
||||
{"秒钟", tidyDimTime, 1},
|
||||
{"秒", tidyDimTime, 1},
|
||||
{"分钟", tidyDimTime, 60},
|
||||
{"分", tidyDimTime, 60},
|
||||
{"小时", tidyDimTime, 3600},
|
||||
{"钟头", tidyDimTime, 3600},
|
||||
{"天", tidyDimTime, 86400},
|
||||
{"日", tidyDimTime, 86400},
|
||||
{"周", tidyDimTime, 604800},
|
||||
{"星期", tidyDimTime, 604800},
|
||||
{"礼拜", tidyDimTime, 604800},
|
||||
{"个月", tidyDimTime, 2592000},
|
||||
{"年", tidyDimTime, 31536000},
|
||||
// 计数(基准:个;不同计数单位之间**不可互换**)
|
||||
{"次", tidyDimCount, 1},
|
||||
{"遍", tidyDimCount, 1},
|
||||
{"圈", tidyDimCount, 1},
|
||||
{"步", tidyDimCount, 1},
|
||||
{"杯", tidyDimCount, 1},
|
||||
{"碗", tidyDimCount, 1},
|
||||
{"口", tidyDimCount, 1},
|
||||
{"份", tidyDimCount, 1},
|
||||
{"张", tidyDimCount, 1},
|
||||
{"条", tidyDimCount, 1},
|
||||
{"件", tidyDimCount, 1},
|
||||
{"个", tidyDimCount, 1},
|
||||
{"位", tidyDimCount, 1},
|
||||
{"只", tidyDimCount, 1},
|
||||
{"颗", tidyDimCount, 1},
|
||||
{"瓶", tidyDimCount, 1},
|
||||
{"盒", tidyDimCount, 1},
|
||||
{"包", tidyDimCount, 1},
|
||||
{"岁", tidyDimCount, 1},
|
||||
{"层", tidyDimCount, 1},
|
||||
{"段", tidyDimCount, 1},
|
||||
{"篇", tidyDimCount, 1},
|
||||
{"首", tidyDimCount, 1},
|
||||
{"集", tidyDimCount, 1},
|
||||
// 长度(基准:米)
|
||||
{"公里", tidyDimLength, 1000},
|
||||
{"千米", tidyDimLength, 1000},
|
||||
{"米", tidyDimLength, 1},
|
||||
{"厘米", tidyDimLength, 0.01},
|
||||
// 文字量(基准:个)
|
||||
{"字符", tidyDimText, 1},
|
||||
{"字节", tidyDimText, 1},
|
||||
{"行", tidyDimText, 1},
|
||||
{"字", tidyDimText, 1},
|
||||
{"页", tidyDimText, 1},
|
||||
}
|
||||
}
|
||||
|
||||
// parseChineseNumberAt 在 text[at:] 处解析一个中文数字(含「半」),返回数值与结束下标。
|
||||
//
|
||||
// 覆盖语料里真实出现的形态:一/两/三…九、十、十五、二十、二十四、一百二十、两千、
|
||||
// 一万两千、半。**不认**「几/十几/数/好几」这类模糊量词(它们不是可比数量)。
|
||||
func parseChineseNumberAt(text []rune, at int) (float64, int, bool) {
|
||||
if at >= len(text) {
|
||||
return 0, at, false
|
||||
}
|
||||
if text[at] == '半' {
|
||||
return 0.5, at + 1, true
|
||||
}
|
||||
total := 0.0 // 已结算的「万」以上段与整段合计
|
||||
section := 0.0 // 当前小节(万以下)
|
||||
digit := 0.0
|
||||
seen := false
|
||||
hasUnitRune := false
|
||||
i := at
|
||||
for ; i < len(text); i++ {
|
||||
r := text[i]
|
||||
if d, ok := chineseDigitValue(r); ok {
|
||||
digit = d
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
switch r {
|
||||
case '十':
|
||||
if !seen {
|
||||
digit = 1
|
||||
}
|
||||
section += digit * 10
|
||||
digit = 0
|
||||
seen = true
|
||||
hasUnitRune = true
|
||||
case '百':
|
||||
if !seen {
|
||||
digit = 1
|
||||
}
|
||||
section += digit * 100
|
||||
digit = 0
|
||||
seen = true
|
||||
hasUnitRune = true
|
||||
case '千':
|
||||
if !seen {
|
||||
digit = 1
|
||||
}
|
||||
section += digit * 1000
|
||||
digit = 0
|
||||
seen = true
|
||||
hasUnitRune = true
|
||||
case '万':
|
||||
if !seen {
|
||||
digit = 1
|
||||
}
|
||||
section += digit
|
||||
total += section * 10000
|
||||
section, digit = 0, 0
|
||||
seen = true
|
||||
hasUnitRune = true
|
||||
default:
|
||||
if !seen {
|
||||
return 0, at, false
|
||||
}
|
||||
total += section + digit
|
||||
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
|
||||
// 「两三」「三四」这类并列不是确定数量,按模糊处理(不认)。
|
||||
return 0, at, false
|
||||
}
|
||||
return total, i, true
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
return 0, at, false
|
||||
}
|
||||
total += section + digit
|
||||
if !hasUnitRune && len([]rune(string(text[at:i]))) > 1 {
|
||||
return 0, at, false
|
||||
}
|
||||
return total, i, true
|
||||
}
|
||||
|
||||
// chineseDigitValue 是中文数字字符到值的映射(不含十百千万半)。
|
||||
func chineseDigitValue(r rune) (float64, bool) {
|
||||
switch r {
|
||||
case '零', '〇':
|
||||
return 0, true
|
||||
case '一':
|
||||
return 1, true
|
||||
case '二', '两', '俩':
|
||||
return 2, true
|
||||
case '三':
|
||||
return 3, true
|
||||
case '四':
|
||||
return 4, true
|
||||
case '五':
|
||||
return 5, true
|
||||
case '六':
|
||||
return 6, true
|
||||
case '七':
|
||||
return 7, true
|
||||
case '八':
|
||||
return 8, true
|
||||
case '九':
|
||||
return 9, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` **数值门**的验收用例集(三态)。
|
||||
//
|
||||
// 沿革:r2 复审要「认出中文数字的数值冲突」→ r3 复审把规格收成**三态**,并指出我第一版
|
||||
// 两个方向都反了:
|
||||
// - 项 2(过严):归一后**同值**、中文与 ASCII 混写(`一小时` vs `60 分钟`)必须**能合并**;
|
||||
// - 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)必须**保守不许合并**,不是放行。
|
||||
//
|
||||
// 因此本文件的核心用例是「三态各一条」,且每条都同时断言**判定**与**簇结果** ——
|
||||
// 只断言簇结果的话,别的原因(相似度地板 / 取值门)也能让簇消失,测不出数值门。
|
||||
|
||||
// TestDedupeGroupNumberGateThreeStates 是三态的主用例。
|
||||
//
|
||||
// 旧逻辑红/新逻辑绿的证据(已实测):
|
||||
// - 同值那一条:旧代码用 `sameNumbers`(只认 ASCII token)⇒ `{}` vs `{60}` 不等 ⇒ 不合 ⇒ 红;
|
||||
// - 不可比那一条:r2 版把「存活行同量纲没有数量」当**放行** ⇒ 合了 ⇒ 红。
|
||||
func TestDedupeGroupNumberGateThreeStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
survivor string
|
||||
member string
|
||||
want tidyNumberVerdict
|
||||
wantMerged bool
|
||||
}{
|
||||
{
|
||||
name: "同值:中文与 ASCII 混写(一小时 ≡ 60 分钟)",
|
||||
survivor: "主人午睡 60 分钟,下午很清醒。",
|
||||
member: "主人午睡一小时,午后精神好。",
|
||||
want: tidyNumberSame,
|
||||
wantMerged: true,
|
||||
},
|
||||
{
|
||||
name: "同值:十五分钟 ≡ 15 分钟",
|
||||
survivor: "主人午睡 15 分钟,起来刚好。",
|
||||
member: "主人午睡十五分钟,醒来正好。",
|
||||
want: tidyNumberSame,
|
||||
wantMerged: true,
|
||||
},
|
||||
{
|
||||
name: "同量纲不同值:一小时 vs 十五分钟",
|
||||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||||
want: tidyNumberConflict,
|
||||
wantMerged: false,
|
||||
},
|
||||
{
|
||||
name: "量纲不可比:一小时 vs 一杯",
|
||||
survivor: "主人午睡时喝了一杯温牛奶,午后精神好。",
|
||||
member: "主人午睡一小时,午后精神好。",
|
||||
want: tidyNumberIncomparable,
|
||||
wantMerged: false,
|
||||
},
|
||||
}
|
||||
tags := []string{"作息", "午睡"}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := dedupeTestItem(tc.survivor, tags, base)
|
||||
b := dedupeTestItem(tc.member, tags, base.Add(time.Minute))
|
||||
verdict, reasons := judgeDedupeNumbers(b.Content, a.Content)
|
||||
if verdict != tc.want {
|
||||
t.Fatalf("判定 = %s %v, want %s(MEMBER=%q SURV=%q)",
|
||||
verdict, reasons, tc.want, b.Content, a.Content)
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b})
|
||||
if tc.wantMerged && len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1(同值必须能合并)", len(cands))
|
||||
}
|
||||
if !tc.wantMerged && len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(%s ⇒ 不许合并)", len(cands), verdict)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsConflictingChineseNumbers 是 r2 复审 **P2 的回归用例**:
|
||||
// 「午睡一小时 vs 午睡十五分钟」「咖啡不续第二杯 vs 不超过一杯」曾被当成同一事实。
|
||||
func TestDedupeGroupRejectsConflictingChineseNumbers(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
survivor string
|
||||
member string
|
||||
tags []string
|
||||
}{
|
||||
{
|
||||
name: "午睡时长(一小时 vs 十五分钟)",
|
||||
survivor: "用户有午睡习惯,与助手约定午睡一小时、到点叫醒。",
|
||||
member: "主人与人家约定:午睡十五分钟,由人家守时看着时间把她叫醒。",
|
||||
tags: []string{"作息", "午睡"},
|
||||
},
|
||||
{
|
||||
name: "咖啡杯数(第二杯 vs 一杯)",
|
||||
survivor: "用户被约定:一天咖啡不许续第二杯,下午犯困时起身走动、喝温水。",
|
||||
member: "用户有喝咖啡的习惯,助手提醒她每天不超过一杯(第二杯不许续)。",
|
||||
tags: []string{"习惯", "咖啡"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := dedupeTestItem(tc.survivor, tc.tags, base)
|
||||
b := dedupeTestItem(tc.member, tc.tags, base.Add(time.Minute))
|
||||
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
|
||||
t.Fatalf("判定 = %s, want conflict", verdict)
|
||||
}
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(数值冲突=不同事实,§5.2-2)", len(cands))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupRejectsNonConvertibleUnits 钉住「单位换算不到一起 ⇒ 宁可不合并」:
|
||||
// 两杯 vs 三瓶是同一量纲的不同取值,但杯/瓶不能互换。
|
||||
func TestDedupeGroupRejectsNonConvertibleUnits(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"习惯", "饮水"}
|
||||
a := dedupeTestItem("主人每天要喝两杯咖啡,下午不续。", tags, base)
|
||||
b := dedupeTestItem("主人每天要喝三瓶水,下午也要喝。", tags, base.Add(time.Minute))
|
||||
if verdict, _ := judgeDedupeNumbers(b.Content, a.Content); verdict != tidyNumberConflict {
|
||||
t.Fatalf("判定 = %s, want conflict(同量纲不同值)", verdict)
|
||||
}
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(单位不可换算 ⇒ 保守不合并)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupSameChineseNumberStillMerges 是数值门的**反向护栏**:
|
||||
// 值相等(含不同单位换算后相等、中英混写)照常成簇 —— 门收的是「冲突」,不是「出现数字」。
|
||||
func TestDedupeGroupSameChineseNumberStillMerges(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"作息", "午睡"}
|
||||
a := dedupeTestItem("主人午睡一小时,午后精神好。", tags, base)
|
||||
b := dedupeTestItem("主人午睡一个小时,下午很清醒。", tags, base.Add(time.Minute))
|
||||
c := dedupeTestItem("主人午睡 60 分钟,起来神清气爽。", tags, base.Add(2*time.Minute))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{a, b, c})
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1(同值照常合并 —— 门不许退化成「有数字就不合」)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyQuantitiesNumberParsing 钉住数量解析口径:中文数字、ASCII 数字、单位换算、
|
||||
// 以及「没有单位的数字走裸 token 路径」。
|
||||
func TestTidyQuantitiesNumberParsing(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
text string
|
||||
value float64
|
||||
unit string
|
||||
dim string
|
||||
ok bool
|
||||
}{
|
||||
{"主人午睡一小时", 3600, "小时", tidyDimTime, true},
|
||||
{"主人午睡十五分钟", 900, "分钟", tidyDimTime, true},
|
||||
{"主人午睡半个小时", 1800, "小时", tidyDimTime, true},
|
||||
{"一万两千行的验收纸条", 12000, "行", tidyDimText, true},
|
||||
{"每天要喝两杯咖啡", 2, "杯", tidyDimCount, true},
|
||||
// ASCII 数字 + 单位(r3 必需:`60 分钟` 与 `一小时` 要能归一)
|
||||
{"主人午睡 60 分钟", 3600, "分钟", tidyDimTime, true},
|
||||
{"主人午睡15分钟", 900, "分钟", tidyDimTime, true},
|
||||
// 序数与习语:不是数量。
|
||||
{"第二天再说", 0, "", "", false},
|
||||
{"一起散步", 0, "", "", false},
|
||||
{"一直加班", 0, "", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := tidyQuantities(tc.text)
|
||||
if !tc.ok {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("%q 应不产生数量,得到 %+v", tc.text, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("%q 没解析出数量", tc.text)
|
||||
}
|
||||
if got[0].value != tc.value || got[0].unit != tc.unit || got[0].dim != tc.dim {
|
||||
t.Fatalf("%q 解析为 %+v, want value=%v unit=%s dim=%s",
|
||||
tc.text, got[0], tc.value, tc.unit, tc.dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanTidyNumbersBareTokensMatchExistingFactNums 钉住「裸数字 token 口径没变」:
|
||||
// 当正文里**没有任何「数字 + 单位」**时,本文件的裸 token 抽取必须与既有
|
||||
// `extractTidyFacts` 的 nums 完全一致 —— 否则「数值门取代 sameNumbers」会悄悄放宽
|
||||
// 一条既有判据(`0023` vs `0024` 这类无单位数字正是靠它拦住的)。
|
||||
//
|
||||
// 有「数字 + 单位」时**刻意**不再进裸 token(那正是 r3 项 2 要的:`60 分钟` 要能与
|
||||
// `一小时` 归一),因此第二条断言把这个分歧也钉住。
|
||||
func TestScanTidyNumbersBareTokensMatchExistingFactNums(t *testing.T) {
|
||||
t.Parallel()
|
||||
// 这些输入里**没有任何「数字 + 单位」**,因此裸 token 必须与既有抽取逐项一致。
|
||||
parity := []string{
|
||||
"迁移号从 0023 起递增",
|
||||
"文件路径 D:/a/b 与端口 8080",
|
||||
"h3R3 的歌曲循环了三遍",
|
||||
"登记编号 624046 已入库",
|
||||
"2026-10-03 那条记录", // 日期 token 由 hasAbsoluteTimestamp 在分簇前就排除
|
||||
}
|
||||
for _, text := range parity {
|
||||
_, bare := scanTidyNumbers(text)
|
||||
facts := extractTidyFacts(text)
|
||||
want := make([]string, 0, len(facts.nums))
|
||||
for n := range facts.nums {
|
||||
want = append(want, n)
|
||||
}
|
||||
sort.Strings(want)
|
||||
if !sameStringSet(bare, want) {
|
||||
t.Fatalf("%q 裸 token = %v, want %v(必须与 extractTidyFacts 同口径)", text, bare, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 分歧(刻意):带单位的数字进「数量」,不再充当裸 token。
|
||||
qty, bare := scanTidyNumbers("主人午睡 60 分钟")
|
||||
if len(bare) != 0 {
|
||||
t.Fatalf("`60 分钟` 的裸 token = %v, want 空(它已归一成数量)", bare)
|
||||
}
|
||||
if len(qty) != 1 || qty[0].value != 3600 || qty[0].dim != tidyDimTime {
|
||||
t.Fatalf("`60 分钟` 解析 = %+v, want 3600 秒(时间量纲)", qty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group`(2026-10-24,M34)的验收用例集。
|
||||
//
|
||||
// 它钉住五件事,每件都能被一次**变异**打红:
|
||||
// 1. **簇判据只有两条**(同 scope+kind+完全相同 tag 集 / 前 12 字相同)——
|
||||
// 拿掉任一条、或让它跨 scope/跨 kind 成簇,用例立刻红;
|
||||
// 2. **保留规则**是「最长 > merge_count 大 > created_at 早」——改动顺序即红;
|
||||
// 3. **安全门与既有合并路径同源**:数值/否定/时效/低置信/受限来源一律不入簇;
|
||||
// 4. **幂等**:同一批数据两次分簇产出同一个幂等键(重复跑不重复产候选);
|
||||
// 5. **可应用 / 可回滚**:应用是软合并(行不删、正文不改),回滚把每一行都复原,
|
||||
// 批量应用按**行**计上限、超限如实 truncated。
|
||||
|
||||
// dedupeTestItem 造一条「可进簇」的记忆:同分区(persona.a / persona / 无项目)、
|
||||
// 置信 0.9、owner 来源(web 主会话)。tags / 内容 / 创建时刻逐项可控。
|
||||
func dedupeTestItem(content string, tags []string, at time.Time) MemoryItem {
|
||||
return MemoryItem{
|
||||
// 确定性 id:内容 + 时刻,因此「同内容的孪生」也各有自己的 id
|
||||
// (id 为空的两行会被 dedupeGroupEligible 挡在簇外 —— 那是纵深防御,
|
||||
// 但夹具不该靠它)。
|
||||
ID: fmt.Sprintf("item-%x-%d", sha1OfForTest(content), at.UnixNano()),
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
|
||||
Content: content, Tags: tags, Confidence: 0.9, CreatedAt: at,
|
||||
SourceConversation: FormatSourceConversation("web", "msg-1"),
|
||||
}
|
||||
}
|
||||
|
||||
// sha1OfForTest 给夹具造确定性 id 用(与生产 id 无关,只要在用例内唯一)。
|
||||
func sha1OfForTest(content string) []byte {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return sum[:6]
|
||||
}
|
||||
|
||||
// seedDedupeItem 落一条记忆并返回它的落库形状。
|
||||
func seedDedupeItem(t *testing.T, store *Store, item MemoryItem) MemoryItem {
|
||||
t.Helper()
|
||||
return seedTidyItem(t, store, item)
|
||||
}
|
||||
|
||||
// dedupeCandsOf 跑一次分簇并只保留 dedupe_group 候选。
|
||||
func dedupeCandsOf(t *testing.T, store *Store, items []MemoryItem) ([]TidyCandidate, tidyDedupeStats) {
|
||||
t.Helper()
|
||||
tidier, err := NewTidier(TidyConfig{}, TidyDeps{Store: store, Now: func() time.Time { return tidyTestTime(t) }})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidier: %v", err)
|
||||
}
|
||||
meta, err := store.dedupeItemMeta(context.Background(),
|
||||
tidyPartition{personaID: "persona.a", scope: ScopePersona})
|
||||
if err != nil {
|
||||
t.Fatalf("dedupeItemMeta: %v", err)
|
||||
}
|
||||
return tidier.dedupeCandidates(items, meta, nil, tidyTestTime(t))
|
||||
}
|
||||
|
||||
// TestDedupeGroupClustersByIdenticalTagSet 钉住判据一:**同 scope + 同 kind +
|
||||
// 完全相同 tag 集**的条目成簇,每簇只产**一条**候选,存活行是「内容最长」那条。
|
||||
func TestDedupeGroupClustersByIdenticalTagSet(t *testing.T) {
|
||||
t.Parallel()
|
||||
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))),
|
||||
seedDedupeItem(t, store, dedupeTestItem("助手/人格/角色名为 Cyrene。", tags, base.Add(2*time.Minute))),
|
||||
}
|
||||
cands, stats := dedupeCandsOf(t, store, items)
|
||||
if len(cands) != 1 || stats.Groups != 1 {
|
||||
t.Fatalf("簇数 = %d (stats %+v), want 1 —— 同 tag 集的三条必须合成一簇", len(cands), stats)
|
||||
}
|
||||
cand := cands[0]
|
||||
if cand.Action != TidyDedupeGroup {
|
||||
t.Fatalf("动作 = %q, want %q", cand.Action, TidyDedupeGroup)
|
||||
}
|
||||
if cand.TargetID != items[2].ID {
|
||||
t.Fatalf("存活行 = %s, want 最长的那条 %s", cand.TargetID, items[2].ID)
|
||||
}
|
||||
if len(cand.SourceIDs) != 2 || stats.Rows != 2 {
|
||||
t.Fatalf("被吸收行 = %v (rows=%d), want 2 条", cand.SourceIDs, stats.Rows)
|
||||
}
|
||||
if cand.Evidence.DedupeRule != tidyDedupeRuleTagSet {
|
||||
t.Fatalf("簇判据 = %q, want %q", cand.Evidence.DedupeRule, tidyDedupeRuleTagSet)
|
||||
}
|
||||
if cand.Evidence.ClusterSize != 3 || cand.Evidence.KeptReason != tidyDedupeKeepLongest {
|
||||
t.Fatalf("证据 = %+v, want cluster_size=3 kept_reason=%q", cand.Evidence, tidyDedupeKeepLongest)
|
||||
}
|
||||
if len(cand.Evidence.DedupeMembers) != 2 {
|
||||
t.Fatalf("证据里的被吸收正文 = %d 条, want 2(面板要能一次看清吞了哪几条)",
|
||||
len(cand.Evidence.DedupeMembers))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupClustersByPrefix12 钉住判据二:**前 12 个字相同**(换个说法的同一句)
|
||||
// 也会成簇 —— 这正是「整理层的包含判定覆盖不了改写」的那个缺口。
|
||||
func TestDedupeGroupClustersByPrefix12(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
// 两条 tag 集**不同**(否则会先被 tag 集判据吃掉),但前 12 字逐字相同。
|
||||
items := []MemoryItem{
|
||||
seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后一起散步", []string{"约定", "散步"}, base)),
|
||||
seedDedupeItem(t, store, dedupeTestItem("用户与助手约定每天晚饭后出门散步十分钟", []string{"约定", "运动"}, base.Add(time.Minute))),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, store, items)
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("簇数 = %d, want 1(前 12 字相同 ⇒ 同一簇)", len(cands))
|
||||
}
|
||||
if cands[0].Evidence.DedupeRule != tidyDedupeRulePrefix {
|
||||
t.Fatalf("簇判据 = %q, want %q", cands[0].Evidence.DedupeRule, tidyDedupeRulePrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupPrefixRequiresSimilarityFloor 钉住前缀簇的**收窄条件**:同开头
|
||||
// 但后半句讲了别的事(相似度低于下限)⇒ 不成簇。它是「同构不同事实」的护栏。
|
||||
func TestDedupeGroupPrefixRequiresSimilarityFloor(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := "助手(persona)的说话风格:亲昵、爱撒娇"
|
||||
b := "助手(persona)的象征花为蓝玫瑰和月桂叶"
|
||||
if got := lexicalSimilarity(a, b); got >= dedupeGroupPrefixSimilarity {
|
||||
t.Fatalf("夹具失效:两条相似度 %.3f 高于下限 %.2f,测不出护栏",
|
||||
got, dedupeGroupPrefixSimilarity)
|
||||
}
|
||||
items := []MemoryItem{
|
||||
dedupeTestItem(a, []string{"日常", "电影"}, tidyTestTime(t)),
|
||||
dedupeTestItem(b, []string{"日常", "回家"}, tidyTestTime(t)),
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), items)
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(相似度低于 %.2f 的同开头对不许成簇)",
|
||||
len(cands), dedupeGroupPrefixSimilarity)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupNeverCrossesPartition 钉住硬门:跨 scope / 跨 kind 的同一事实
|
||||
// **绝不成簇**(design/23 §5.2-7/8、§6;跨 scope 收敛是塔已明确「不做」的那一批)。
|
||||
func TestDedupeGroupNeverCrossesPartition(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"称呼", "亲密"}
|
||||
content := "主人喜欢被称呼为「叶酱」"
|
||||
user := dedupeTestItem(content, tags, base)
|
||||
user.Scope = ScopeUser
|
||||
persona := dedupeTestItem(content, tags, base.Add(time.Minute))
|
||||
worldEvent := dedupeTestItem(content, tags, base.Add(2*time.Minute))
|
||||
worldEvent.Kind = KindWorldEvent
|
||||
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{user, persona, worldEvent})
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(跨 scope / 跨 kind 是硬门,一条都不许成簇)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupSkipsUnsafeMembers 钉住安全门:时效 / 低置信 / 受限来源的条目
|
||||
// 连簇都不进;数值或否定与存活行不一致的成员被逐条剔出(留待人工)。
|
||||
func TestDedupeGroupSkipsUnsafeMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"作息", "睡眠"}
|
||||
// 一簇 4 条:一条正常、一条数值不同、一条否定不同、一条时效污染。
|
||||
// 数值用**ASCII 数字**:既有 sameNumbers 只认 ASCII 数字 token(中文数字
|
||||
// 「十点 vs 十一点」不在它的判据里 —— 那是既有口径的已知限制,本切片不改)。
|
||||
normal := dedupeTestItem("主人每晚 22 点后才有空聊天", tags, base)
|
||||
numeric := dedupeTestItem("主人每晚 23 点后才有空聊天", tags, base.Add(time.Minute))
|
||||
negated := dedupeTestItem("主人不再每晚 22 点后聊天", tags, base.Add(2*time.Minute))
|
||||
temporal := dedupeTestItem("主人每晚 22 点后才有空聊天,假期期间更晚", tags, base.Add(3*time.Minute))
|
||||
cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{normal, numeric, negated, temporal})
|
||||
if len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0:数值/否定不一致与时效污染的成员不许与存活行同簇(剩一条不成簇)",
|
||||
len(cands))
|
||||
}
|
||||
|
||||
// 低置信:两条**都**低置信的孪生也不许成簇(对照组用同样的内容 + 0.9 置信)。
|
||||
twin := "主人每晚 22 点后才有空聊天"
|
||||
lowA := dedupeTestItem(twin, tags, base)
|
||||
lowA.Confidence = 0.6
|
||||
lowB := dedupeTestItem(twin, tags, base.Add(time.Minute))
|
||||
lowB.Confidence = 0.6
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{lowA, lowB}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(低置信是硬否决门 §5.2-6)", len(cands))
|
||||
}
|
||||
okA := dedupeTestItem(twin, tags, base)
|
||||
okB := dedupeTestItem(twin, tags, base.Add(time.Minute))
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{okA, okB}); len(cands) != 1 {
|
||||
t.Fatalf("对照组簇数 = %d, want 1(0.9 置信的两条必须成簇,否则测不出上面的否决)", len(cands))
|
||||
}
|
||||
|
||||
// 受限来源(群聊):连簇都不进 —— 与 owner 来源的孪生也不许成簇。
|
||||
owner := dedupeTestItem(twin, tags, base)
|
||||
restricted := dedupeTestItem(twin, tags, base.Add(time.Minute))
|
||||
restricted.SourceConversation = FormatSourceConversation("sess-group-1", "msg-1")
|
||||
if cands, _ := dedupeCandsOf(t, newTestStore(t), []MemoryItem{owner, restricted}); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(群聊来源只读不写 §5.2-9)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge 钉住保留规则的三级优先序。
|
||||
func TestDedupeGroupKeepsSupersetByLengthThenMergeCountThenAge(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
tags := []string{"宠物", "猫"}
|
||||
|
||||
t.Run("等长时取 merge_count 大的", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
old := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base))
|
||||
hot := seedDedupeItem(t, store, dedupeTestItem("小茂密是白毛蝴蝶结的小猫", tags, base.Add(time.Minute)))
|
||||
if _, err := store.db.ExecContext(ctx,
|
||||
`UPDATE memory_items SET merge_count = 4 WHERE id = ?`, hot.ID); err != nil {
|
||||
t.Fatalf("bump merge_count: %v", err)
|
||||
}
|
||||
cands, _ := dedupeCandsOf(t, store, []MemoryItem{old, hot})
|
||||
if len(cands) != 1 || cands[0].TargetID != hot.ID {
|
||||
t.Fatalf("存活行 = %v, want merge_count 大的 %s", cands, hot.ID)
|
||||
}
|
||||
if cands[0].Evidence.KeptReason != tidyDedupeKeepMergeCount {
|
||||
t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepMergeCount)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("等长同等时取更早创建的", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
early := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base))
|
||||
late := seedDedupeItem(t, store, dedupeTestItem("小茂密是深色蝴蝶结的小猫", tags, base.Add(time.Minute)))
|
||||
cands, _ := dedupeCandsOf(t, store, []MemoryItem{late, early})
|
||||
if len(cands) != 1 || cands[0].TargetID != early.ID {
|
||||
t.Fatalf("存活行 = %v, want 更早创建的 %s", cands, early.ID)
|
||||
}
|
||||
if cands[0].Evidence.KeptReason != tidyDedupeKeepOldest {
|
||||
t.Fatalf("kept_reason = %q, want %q", cands[0].Evidence.KeptReason, tidyDedupeKeepOldest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestDedupeGroupIsIdempotentAndDeterministic 钉住幂等:同一批数据两次分簇,候选
|
||||
// 的幂等键、存活行、被吸收行集合**逐项相同**(重复跑不重复产候选)。
|
||||
func TestDedupeGroupIsIdempotentAndDeterministic(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
tags := []string{"约定", "散步"}
|
||||
items := []MemoryItem{
|
||||
seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟", tags, base)),
|
||||
seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分钟晒太阳", tags, base.Add(time.Minute))),
|
||||
seedDedupeItem(t, store, dedupeTestItem("和主人约定晚饭后出门走十分", tags, base.Add(2*time.Minute))),
|
||||
}
|
||||
first, _ := dedupeCandsOf(t, store, items)
|
||||
second, _ := dedupeCandsOf(t, store, items)
|
||||
if len(first) != 1 || len(second) != 1 {
|
||||
t.Fatalf("两次分簇 = %d / %d, want 1 / 1", len(first), len(second))
|
||||
}
|
||||
if tidyIdempotencyKey(first[0]) != tidyIdempotencyKey(second[0]) {
|
||||
t.Fatalf("幂等键不同:%q vs %q", tidyIdempotencyKey(first[0]), tidyIdempotencyKey(second[0]))
|
||||
}
|
||||
if first[0].TargetID != second[0].TargetID ||
|
||||
len(first[0].SourceIDs) != len(second[0].SourceIDs) {
|
||||
t.Fatalf("两次分簇结果不一致:%+v vs %+v", first[0], second[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupSuppressesPairsWithExistingCandidate 钉住「不制造第二条待办」:
|
||||
// 已被 archive/merge 候选覆盖的条目不再进簇。
|
||||
func TestDedupeGroupSuppressesPairsWithExistingCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
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})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidier: %v", err)
|
||||
}
|
||||
covered := map[string]struct{}{items[0].ID: {}, items[1].ID: {}}
|
||||
if cands, _ := tidier.dedupeCandidates(items, nil, covered, tidyTestTime(t)); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(两条都已被既有候选覆盖)", len(cands))
|
||||
}
|
||||
// 只覆盖一条:另一条单独一条也不成簇(簇至少两条)。
|
||||
if cands, _ := tidier.dedupeCandidates(items, nil, map[string]struct{}{items[0].ID: {}}, tidyTestTime(t)); len(cands) != 0 {
|
||||
t.Fatalf("簇数 = %d, want 0(剩一条不成簇)", len(cands))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeGroupIsNeverAutoApplied 钉住红线:dedupe_group **不进自动档**。
|
||||
// 一次点击改多行记忆,必须在环核对(体检报告 §7.2);变异(把它的 case 加进
|
||||
// tidyAutoApplyEligible)会让这条用例立刻红。
|
||||
func TestDedupeGroupIsNeverAutoApplied(t *testing.T) {
|
||||
t.Parallel()
|
||||
cand := TidyCandidate{
|
||||
Action: TidyDedupeGroup,
|
||||
SourceIDs: []string{"a", "b"},
|
||||
TargetID: "c",
|
||||
Reason: tidyReasonDedupeGroup,
|
||||
}
|
||||
if ok, reason := tidyAutoApplyEligible(cand); ok {
|
||||
t.Fatalf("dedupe_group 被判成可自动落地(reason=%q)—— 它必须在环核对", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是 `dedupe_group` **取值门**(r1 复审 P1)的验收用例集:
|
||||
// 同属性不同取值(`「老K」` vs `「阿七」`)、拉丁取值、以及簇级的「取值不丢」不变式。
|
||||
// 分簇/候选构造的用例在 tidy_dedupe_test.go,中文数字门在 tidy_dedupe_numbers_test.go。
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,14 @@ type TidyStats struct {
|
||||
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
|
||||
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
|
||||
ByAction map[string]int
|
||||
// DedupeGroups 是本轮产出的 `dedupe_group` 簇数;DedupeRows 是这些簇**可减少**
|
||||
// 的 active 行数(被吸收行合计)。两者是「机械可判重复能减多少行」的唯一口径
|
||||
// —— 试算轮(DryRun)也填它,因此「先看数、后落库」不必跑两次不同代码。
|
||||
DedupeGroups int
|
||||
DedupeRows int
|
||||
// DedupeTruncated 是本轮被每分区候选上限丢掉的 `dedupe_group` 条数(超限绝不
|
||||
// 静默:它同时进 Truncated 标签与 WARN)。
|
||||
DedupeTruncated int
|
||||
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
|
||||
Errors int
|
||||
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
|
||||
|
||||
@@ -98,6 +98,13 @@ type AdminTidyDryRunResponse struct {
|
||||
// Truncated 列出被护栏截断的分区标签(扫描/比对/候选上限),供运维放大配额。
|
||||
// 它**不是**候选;缺席表示本轮没有被截断。
|
||||
Truncated []string `json:"truncated,omitempty"`
|
||||
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group`(2026-10-24)的**试算口径**:
|
||||
// 机械可判重复分成了多少簇、这些簇一共能减少多少 active 行。
|
||||
// 它们是「先看数、后落库」这条要求的落点 —— 试算零写库,数字就是真跑会产出的。
|
||||
DedupeGroups int `json:"dedupeGroups"`
|
||||
DedupeRowsReducible int `json:"dedupeRowsReducible"`
|
||||
// DedupeTruncated 是本轮被每分区候选上限丢掉的 dedupe 簇数(超限绝不静默)。
|
||||
DedupeTruncated int `json:"dedupeTruncated"`
|
||||
// Candidates 恒为非 null 数组:空数组 = 「算过了,这一轮没有建议」,与
|
||||
// 「没算出来」(那种情况是错误响应)**必须能区分**。
|
||||
Candidates []AdminTidyDryRunCandidate `json:"candidates"`
|
||||
@@ -145,18 +152,21 @@ func (s *Server) writeTidyDryRunError(w http.ResponseWriter, err error) {
|
||||
// 条数与截断标签都来自领域侧已经算好的结果(Candidates 已被每分区上限截断)。
|
||||
func adminTidyDryRunResponse(result memory.TidyDryRun) AdminTidyDryRunResponse {
|
||||
out := AdminTidyDryRunResponse{
|
||||
DryRun: true,
|
||||
RecallMode: result.RecallMode,
|
||||
DurationMs: result.DurationMS,
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
Partitions: result.Stats.Partitions,
|
||||
Scanned: result.Stats.Scanned,
|
||||
Compared: result.Stats.Compared,
|
||||
Pairs: result.Stats.Pairs,
|
||||
Vetoed: result.Stats.Vetoed,
|
||||
VetoReasons: result.Stats.VetoReasons,
|
||||
Truncated: result.Stats.Truncated,
|
||||
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
|
||||
DryRun: true,
|
||||
RecallMode: result.RecallMode,
|
||||
DurationMs: result.DurationMS,
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
Partitions: result.Stats.Partitions,
|
||||
Scanned: result.Stats.Scanned,
|
||||
Compared: result.Stats.Compared,
|
||||
Pairs: result.Stats.Pairs,
|
||||
Vetoed: result.Stats.Vetoed,
|
||||
VetoReasons: result.Stats.VetoReasons,
|
||||
Truncated: result.Stats.Truncated,
|
||||
DedupeGroups: result.Stats.DedupeGroups,
|
||||
DedupeRowsReducible: result.Stats.DedupeRows,
|
||||
DedupeTruncated: result.Stats.DedupeTruncated,
|
||||
Candidates: make([]AdminTidyDryRunCandidate, 0, len(result.Candidates)),
|
||||
}
|
||||
for _, c := range result.Candidates {
|
||||
out.Candidates = append(out.Candidates, adminTidyDryRunCandidate(c))
|
||||
|
||||
@@ -309,6 +309,8 @@ func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchRespons
|
||||
Merged: batch.Merged,
|
||||
Archived: batch.Archived,
|
||||
Reclassified: batch.Reclassified,
|
||||
Deduped: batch.Deduped,
|
||||
RowsAffected: batch.RowsAffected,
|
||||
Results: make([]AdminTidyApplyResult, 0, len(batch.Results)),
|
||||
}
|
||||
for _, res := range batch.Results {
|
||||
|
||||
@@ -53,16 +53,21 @@ type AdminTidyApplyResult struct {
|
||||
// `applied+skipped+failed` 只覆盖 `limit` 之内处理过的那些 —— 两者不相等时
|
||||
// `truncated=true`(超限绝不静默)。
|
||||
type AdminTidyApplyBatchResponse struct {
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Applied int `json:"applied"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Merged int `json:"merged"`
|
||||
Archived int `json:"archived"`
|
||||
Reclassified int `json:"reclassified"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Applied int `json:"applied"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Merged int `json:"merged"`
|
||||
Archived int `json:"archived"`
|
||||
Reclassified int `json:"reclassified"`
|
||||
// Deduped 是成功落地的 `dedupe_group` 簇数(2026-10-24);RowsAffected 是这次
|
||||
// 真的改动的**记忆行数**。两者分开是因为一簇动多行:「应用了 3 条候选」与
|
||||
// 「改动了 11 行记忆」在面板上是两个不同的信息(行预算按后者计)。
|
||||
Deduped int `json:"deduped"`
|
||||
RowsAffected int `json:"rowsAffected"`
|
||||
Results []AdminTidyApplyResult `json:"results"`
|
||||
}
|
||||
|
||||
@@ -95,8 +100,14 @@ type AdminTidyRunResponse struct {
|
||||
// 挡下(同一批数据重复跑)的数量。
|
||||
CandidatesCreated int `json:"candidatesCreated"`
|
||||
CandidatesDuplicate int `json:"candidatesDuplicate"`
|
||||
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify)。
|
||||
// CandidatesByAction 是新建候选按动作的分布(merge / archive / reclassify /
|
||||
// dedupe_group,2026-10-24 起新增最后一档)。
|
||||
CandidatesByAction map[string]int `json:"candidatesByAction"`
|
||||
// DedupeGroups / DedupeRowsReducible 是 `dedupe_group` 的簇数与可减少行数;
|
||||
// DedupeTruncated 是被候选上限丢掉的那部分(超限绝不静默)。
|
||||
DedupeGroups int `json:"dedupeGroups"`
|
||||
DedupeRowsReducible int `json:"dedupeRowsReducible"`
|
||||
DedupeTruncated int `json:"dedupeTruncated"`
|
||||
}
|
||||
|
||||
// AdminTidyRejectResponse 是「按动作批量拒绝候选」的回执(契约新增,2026-10-07)。
|
||||
|
||||
@@ -96,5 +96,8 @@ func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
|
||||
CandidatesCreated: stats.Candidates,
|
||||
CandidatesDuplicate: stats.Duplicates,
|
||||
CandidatesByAction: byAction,
|
||||
DedupeGroups: stats.DedupeGroups,
|
||||
DedupeRowsReducible: stats.DedupeRows,
|
||||
DedupeTruncated: stats.DedupeTruncated,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user