diff --git a/internal/runtime/memory/extract_retention.go b/internal/runtime/memory/extract_retention.go index 23b751b..ec00ade 100644 --- a/internal/runtime/memory/extract_retention.go +++ b/internal/runtime/memory/extract_retention.go @@ -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,112 @@ 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(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终 +// (正在测通道、暂时搁置、正在慢慢想),安全阀 1~4 与 N6 完全一致。 +// +// 🔴 **刻意不做的事**(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:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条 + // 规则判(N1 有它的长度安全阀,N2 有形态匹配),N6 不越过它们改判: + // - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录; + // - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。 + // 实测这两条是单纯加时点判据时唯一的两个既有正例回归。 + if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) { + return retentionNone + } + if judgeRetentionTimepoint(text) != retentionNone { + return retentionTimepoint + } + if containsAny(text, retentionInProgressMarkers()) { + return retentionInProgress + } + return retentionNone +} + +// judgeRetentionTimepoint 是 N6 的形态判定:正文里**句首前 N 个字内**出现一个 +// 指示性时点词。位置判据是这条规则精度的来源(见 judgeRetentionTransient 安全阀 5)。 +func judgeRetentionTimepoint(text string) retentionRule { + if idx := retentionFrontedTimepoint(text); idx >= 0 && idx <= retentionTimepointLeadRunes { + return retentionTimepoint + } + return retentionNone +} + +// retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**; +// 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。 +// +// 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」 +// 变成「前 4 字」,位置判据当场失效。 +func retentionFrontedTimepoint(text string) int { + runes := []rune(text) + best := -1 + for _, marker := range retentionTimepointMarkers() { + idx := runeIndex(text, marker) + if idx < 0 { + continue + } + if best < 0 || idx < best { + best = idx + } + } + if best < 0 || best >= len(runes) { + 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 +381,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", "转换成", "截图", "打开这个", "关掉这个", - "搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟", - } -} diff --git a/internal/runtime/memory/extract_retention_emotion.go b/internal/runtime/memory/extract_retention_emotion.go new file mode 100644 index 0000000..8a94fce --- /dev/null +++ b/internal/runtime/memory/extract_retention_emotion.go @@ -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 +} diff --git a/internal/runtime/memory/extract_retention_transient_test.go b/internal/runtime/memory/extract_retention_transient_test.go new file mode 100644 index 0000000..4e38ca8 --- /dev/null +++ b/internal/runtime/memory/extract_retention_transient_test.go @@ -0,0 +1,216 @@ +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)。 + {"情绪倾向", "主人最近很容易焦虑"}, + {"长句致谢", "谢谢你今天特地提醒我这件事,真是帮了大忙了"}, + } +} + +// 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) + } +} diff --git a/internal/runtime/memory/extract_retention_words.go b/internal/runtime/memory/extract_retention_words.go new file mode 100644 index 0000000..d1aca67 --- /dev/null +++ b/internal/runtime/memory/extract_retention_words.go @@ -0,0 +1,212 @@ +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{ + // 长期事实 + "生日", "纪念日", "住", "职业", "工作", "家庭", "家人", "孩子", "父母", + "过敏", "忌口", "健康状况", "疾病", "吃药", "身高", "体重", "年龄", + // 关系里程碑 / 约定 + "约定", "承诺", "答应", "说好", "暗号", "口令", "称呼", + // 项目约定 + "项目", "迁移号", "端口", "路径", "命名", "编号", "约定俗成", + // 能力边界(主人明确的「不要做 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 +} diff --git a/internal/runtime/memory/tidy.go b/internal/runtime/memory/tidy.go index a7e954f..6e2b7d4 100644 --- a/internal/runtime/memory/tidy.go +++ b/internal/runtime/memory/tidy.go @@ -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 把分区内条目分流成**可比对**与**归档候选**(跳过的不产出): diff --git a/internal/runtime/memory/tidy_apply.go b/internal/runtime/memory/tidy_apply.go index b63dc3d..cb4f446 100644 --- a/internal/runtime/memory/tidy_apply.go +++ b/internal/runtime/memory/tidy_apply.go @@ -141,6 +141,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 +182,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 +194,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 +408,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 +431,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 +447,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 - } -} diff --git a/internal/runtime/memory/tidy_apply_result.go b/internal/runtime/memory/tidy_apply_result.go new file mode 100644 index 0000000..32ba301 --- /dev/null +++ b/internal/runtime/memory/tidy_apply_result.go @@ -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 + } +} diff --git a/internal/runtime/memory/tidy_apply_store.go b/internal/runtime/memory/tidy_apply_store.go index de8d71c..2b60879 100644 --- a/internal/runtime/memory/tidy_apply_store.go +++ b/internal/runtime/memory/tidy_apply_store.go @@ -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) diff --git a/internal/runtime/memory/tidy_candidate.go b/internal/runtime/memory/tidy_candidate.go index 9c0008c..167fa0a 100644 --- a/internal/runtime/memory/tidy_candidate.go +++ b/internal/runtime/memory/tidy_candidate.go @@ -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)。 diff --git a/internal/runtime/memory/tidy_dedupe.go b/internal/runtime/memory/tidy_dedupe.go new file mode 100644 index 0000000..cf05ba3 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe.go @@ -0,0 +1,492 @@ +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 +) + +// 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 是存活行的**次要**判据(「内容最长」并列时才用它)。缺它时降级到 + // created_at 仍能给出确定性的存活行,因此这里降级继续、但如实记 WARN。 + meta, err := t.deps.Store.dedupeItemMeta(ctx, p) + if err != nil { + t.log.Warn("memory tidy dedupe item meta unavailable, clustering without merge_count/temporary flags", + "partition", p.label(), "error", err) + meta = nil + } + 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 +} + +// dedupeClusterKeys 返回一条记忆的簇键(tag 集 / 前 12 字)。两条判据都可能不成立 +// (tag 集不合格 / 内容短于 12 字),那时只返回另一条。 +func dedupeClusterKeys(it MemoryItem) []tidyDedupeKey { + out := make([]tidyDedupeKey, 0, 2) + if key := dedupeTagKey(it); key != "" { + out = append(out, tidyDedupeKey{rule: tidyDedupeRuleTagSet, key: key}) + } + if key := dedupePrefixKey(it); key != "" { + out = append(out, tidyDedupeKey{rule: tidyDedupeRulePrefix, key: key}) + } + return out +} + +// dedupeTagKey 是「同分区 + 同 kind + 完全相同 tag 集」的键。tag 集为空/太通用时返回空串。 +// +// 键里带**分区**(persona/scope/project)与 kind,两个都是**纵深防御**: +// - 调用方本来就按分区逐块调用(design/23 §6「分区之间永不比较」),但候选键 +// 自带分区后,任何跨分区的输入(手工调用、将来别的入口)也**无法成簇** —— +// 跨 scope 收敛是塔已明确不做的那一批,它不许只靠调用纪律保证; +// - kind 是**内容类别**(relationship / world_event):跨 kind 的簇合并会顺手改掉 +// 被吸收行的分类语义,而分类改动只该走 reclassify(§5.1 T0-6)。 +func dedupeTagKey(it MemoryItem) string { + tags := normalizedTidyTags(it.Tags) + if len(tags) < dedupeGroupMinTagCount { + return "" + } + for _, tag := range tags { + if len([]rune(tag)) < dedupeGroupMinTagRunes { + return "" + } + } + return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + strings.Join(tags, "\x1e") +} + +// dedupePartitionPrefix 是簇键里的分区段(persona + scope + project)。 +func dedupePartitionPrefix(it MemoryItem) string { + return it.PersonaID + "\x1e" + string(it.Scope) + "\x1e" + it.ProjectID + "\x1e" +} + +// normalizedTidyTags 归一化 tag 集:去空白、去空项、去重、排序。排序是幂等键稳定 +// 的前提(同一 tag 集的不同书写顺序必须落到同一个键)。 +func normalizedTidyTags(tags []string) []string { + if len(tags) == 0 { + return nil + } + seen := make(map[string]struct{}, len(tags)) + out := make([]string, 0, len(tags)) + for _, tag := range tags { + t := strings.TrimSpace(tag) + if t == "" { + continue + } + if _, dup := seen[t]; dup { + continue + } + seen[t] = struct{}{} + out = append(out, t) + } + sort.Strings(out) + return out +} + +// dedupePrefixKey 是「**同分区同 kind** + 前 12 个字相同」的键(按**归一化正文**取前 12 个 rune)。 +// +// 归一化复用 dedupeKey(小写 + 空白折叠):`助手名为 Cyrene。` 与 `助手名为 Cyrene` +// 不该因为多一个空格就落到两个簇。 +// +// 短于 12 字的内容返回空串**而不受理**:那时「前 12 字」等于全文,簇会退化成 +// 「逐字重复」——那是 §15 D3-2 T0-1 的 `skip`(入库闸)与 `merge`(字面重复自动档) +// 已经在管的事,不需要第二个动作重复它。 +func dedupePrefixKey(it MemoryItem) string { + runes := []rune(dedupeKey(it.Content)) + if len(runes) < dedupeGroupPrefixRunes { + return "" + } + return dedupePartitionPrefix(it) + string(it.Kind) + "\x1f" + string(runes[:dedupeGroupPrefixRunes]) +} + +// tidyDedupeMember 是分簇用的条目视图:记忆行 + merge_count。 +// +// 为什么要单独带 merge_count:`MemoryItem` 的读侧 SELECT(store.go 的 13 列)**不含** +// `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 +} diff --git a/internal/runtime/memory/tidy_dedupe_apply.go b/internal/runtime/memory/tidy_dedupe_apply.go new file mode 100644 index 0000000..bd7eb0c --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_apply.go @@ -0,0 +1,110 @@ +package memory + +import ( + "context" + "database/sql" + "errors" + "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 逐条同源:分区硬门、只读来源硬门、目标行必须存在且与候选同分区。 +// 任何一条不通过都如实 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, err := s.Get(ctx, cand.TargetID) + if errors.Is(err, ErrNotFound) { + return skipTidyApply(base, tidySkipSourceMissing), nil + } + if err != nil { + return base, err + } + if skip := tidyPartitionMismatch(cand, survivor); skip != "" { + return skipTidyApply(base, skip), nil + } + if nonOwnerSource(survivor.SourceConversation) { + // 只读来源(群聊/陌生人)绝不改写(design/23 §5.2-9/§6):纵深防御, + // 规则层本就把这类条目排除在分簇之外。 + return skipTidyApply(base, tidySkipSourceRestricted), nil + } + absorbed := make([]MemoryItem, 0, len(cand.SourceIDs)) + for _, id := range cand.SourceIDs { + if id == cand.TargetID { + return skipTidyApply(base, tidySkipInvalidCandidate), nil + } + it, gerr := s.Get(ctx, id) + if errors.Is(gerr, ErrNotFound) { + return skipTidyApply(base, tidySkipSourceMissing), nil + } + if gerr != nil { + return base, gerr + } + if skip := tidyPartitionMismatch(cand, it); skip != "" { + return skipTidyApply(base, skip), nil + } + if nonOwnerSource(it.SourceConversation) { + return skipTidyApply(base, tidySkipSourceRestricted), nil + } + absorbed = append(absorbed, it) + } + err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error { + for _, it := range absorbed { + if err := markTidyAbsorbed(ctx, tx, it.ID, survivor.ID, now); err != nil { + 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) +} diff --git a/internal/runtime/memory/tidy_dedupe_apply_test.go b/internal/runtime/memory/tidy_dedupe_apply_test.go new file mode 100644 index 0000000..110008a --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_apply_test.go @@ -0,0 +1,289 @@ +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 为空 —— 被上限切掉的部分必须如实登记分区标签") + } +} diff --git a/internal/runtime/memory/tidy_dedupe_candidate.go b/internal/runtime/memory/tidy_dedupe_candidate.go new file mode 100644 index 0000000..7c31881 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_candidate.go @@ -0,0 +1,83 @@ +package memory + +import ( + "sort" + "time" +) + +// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、 +// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。 +// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行), +// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。 + +// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。 +// +// 数值与否定是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」, +// 而同一性由存活行代表;任何一条与存活行数值/否定不一致的成员,说明它讲的是 +// **另一个事实**(§5.2-2/§5.2-3),因此它**不进这一簇**(留待人工),而不是让整簇作废。 +// +// 前缀簇还要过相似度下限(见 dedupeGroupPrefixSimilarity);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 !sameNumbers(it.item.Content, survivor.item.Content) { + continue + } + if !sameNegations(it.item.Content, survivor.item.Content) { + continue + } + if rule == tidyDedupeRulePrefix && + lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupPrefixSimilarity { + 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 +} + +// 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, + } +} diff --git a/internal/runtime/memory/tidy_dedupe_meta.go b/internal/runtime/memory/tidy_dedupe_meta.go new file mode 100644 index 0000000..ee99c11 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_meta.go @@ -0,0 +1,65 @@ +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 → 元信息映射。 +// +// 失败方向上它是**可降级**的(调用方在失败时用空映射继续分簇):存活行的第一判据 +// 是「内容最长」,merge_count 只在前者并列时决定胜负。但**降级会让「临时行」这个 +// 判断失效** —— 因此调用方在降级时必须记 WARN,并且降级路径下 `temporary` 恒为 +// false 意味着「可能把临时行当长期行」;这是**宁可少做也不误删**的反面, +// 所以这里把降级方向定成「继续分簇 + WARN」,而不是「整个分区不分簇」: +// 判错的方向是「多留一条冗余」(可 revert),不是「丢一条记忆」。 +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 +} diff --git a/internal/runtime/memory/tidy_dedupe_test.go b/internal/runtime/memory/tidy_dedupe_test.go new file mode 100644 index 0000000..7ebdb39 --- /dev/null +++ b/internal/runtime/memory/tidy_dedupe_test.go @@ -0,0 +1,310 @@ +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) + } +} diff --git a/internal/runtime/memory/tidy_types.go b/internal/runtime/memory/tidy_types.go index 742793c..9324353 100644 --- a/internal/runtime/memory/tidy_types.go +++ b/internal/runtime/memory/tidy_types.go @@ -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 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。 diff --git a/internal/services/gateway/admin_memory_tidy.go b/internal/services/gateway/admin_memory_tidy.go index f504cba..f73b36b 100644 --- a/internal/services/gateway/admin_memory_tidy.go +++ b/internal/services/gateway/admin_memory_tidy.go @@ -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)) diff --git a/internal/services/gateway/admin_memory_tidy_apply.go b/internal/services/gateway/admin_memory_tidy_apply.go index 054dd76..6a1757d 100644 --- a/internal/services/gateway/admin_memory_tidy_apply.go +++ b/internal/services/gateway/admin_memory_tidy_apply.go @@ -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 { diff --git a/internal/services/gateway/admin_memory_tidy_apply_dto.go b/internal/services/gateway/admin_memory_tidy_apply_dto.go index a5cd8e3..46e1676 100644 --- a/internal/services/gateway/admin_memory_tidy_apply_dto.go +++ b/internal/services/gateway/admin_memory_tidy_apply_dto.go @@ -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)。 diff --git a/internal/services/gateway/admin_memory_tidy_run.go b/internal/services/gateway/admin_memory_tidy_run.go index eb3b6f9..87c3aa4 100644 --- a/internal/services/gateway/admin_memory_tidy_run.go +++ b/internal/services/gateway/admin_memory_tidy_run.go @@ -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, } }