package memory import ( "strings" ) // 本文件是**提取留存规则闸**(design/03 §10,即 design/23 §15.1 D2 点名的 // 「另立规格」)的 A 级实现:**纯规则 + 词项匹配**,对一条**刚落库的**新记忆判 // 「它是不是结构上显然不值得长期留存」(design/03 §10.5 的反例清单 N1~N5)。 // // 它回应主人的两条原话:「**收窄提取器,确实要给他一个主动式的记忆留存规则, // 不能想到什么都往记忆里写**」与「**临时记忆到期删除**」。 // // 🔴 落点硬约束(design/03 §10.3):**不许新造第二条写路径**。本闸**不新开入口**, // 它是 `resolveRetention`(retention.go)里的一步 —— 那是「留多久」轴的**唯一**判定: // 命中即判 `retention_tier=temp`,而第五态 `defer` 只是「档位是 temp ∧ 关系判为 // 真新」的另一种说法(preinsert_judge.go 只读档位,不再跑本文件)。于是「提取得宽」 // 由**同一条**入库路径收敛,不存在绕过闸的写口;命中者**不进 T1**(§10.6:规则闸 // 拦下的不进 T1,避免「模型推翻规则」的架构回归)。 // // 判据为什么必须是纯函数而不是提示词(design/03 §10.2):现行全仓唯一的留存判据 // 是一句自然语言(`llmextractor.go` 的 extractSystemPrompt),那**违反 AGENTS §9 // 「判据靠元数据,不靠提示词」**,且**不可验收**(提示词产出是自由文本,测试只能 // 断言「模型有没有听话」)。B 级提示词加固仍在做(正例 + 反例清单进 prompt, // 见 llmextractor.go),但**验收防线在本文件**。 // // 🔴 **fail-safe 方向 = 宁可多留,不可误弃**(design/23 §18.3)。因此每一条规则都 // 写得**保守**:宁可有噪声漏过闸(那条走 T1 / 长期层),也不把一条真记忆判成临时 // 层(到期会被删)。落实为两道**护栏**: // // 1. **锚点 + 具体约束的组合放行**(2026-10-24 起;此前是「任一锚点就一票放行」): // 内容里出现的「长期事实 / 稳定偏好 / 关系里程碑 / 项目约定」信号(复用 // tidyHabitMarkers 的「持久」词族 + 本文件的正向词)必须**配上一条可执行约束** // (数字 / 禁忌 / 频次规律)才算长期放行 —— 光有「喜欢」只是句泛泛的偏好, // 光有「约定」也可能只是一次下周的安排。改组合口径的动因是 M27 体检实测: // 一票放行让 64.6% 的条目必然过闸、`n1`~`n5` 在生产**一次都没命中过**; // 2. **显式「记住」一票否决**:内容里出现「记住 / 记下 / 别忘了」时放行 —— // 主人明确要求记的东西,闸不许替他改成临时记忆。 // // 反例规则(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` // 表示「不是显然不值得记的」)。 type retentionRule string const ( // retentionNone 表示没有任何反例规则命中 ⇒ 照常走既有判定(可能长期留存)。 retentionNone retentionRule = "" // retentionChatter 对 design/03 §10.5 的 **N1 寒暄 / 致谢**。 retentionChatter retentionRule = "n1_chatter" // retentionEmotion 对应 **N2 情绪波动**。 retentionEmotion retentionRule = "n2_emotion" // retentionOneOffAsk 对应 **N3 一次性琐事**(祈使 + 无持续实体)。 retentionOneOffAsk retentionRule = "n3_one_off_ask" // retentionTimelyData 对应 **N4 时效性数据**(当前时间 / 天气)。 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` 列**上的编码(而不是上面那些 // 规则码)。它是刻意的:design/23 §18.4 把 `reason` 的闭集钉成 // `{t0_low_value, t1_defer, …}` —— 面板与运维按它分「谁判的 defer」,而**具体命中 // 哪条反例规则**属于更细的观测面,进日志、不进留痕列(留痕表的列宽与语义不改)。 const retentionLowValueReason = "t0_low_value" // 规则的**保守阈值**:只对「短到装不下一个事实」的内容开火。 const ( // retentionMaxChatterRunes 是「只可能是寒暄」的长度上限(rune 计,不是字节: // 中文 1 rune = 3 字节,按字节算会把阈值放大三倍)。 retentionMaxChatterRunes = 16 // 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 与 M34 新增的 N6/N7)。命中返回该规则码(调用方据此定 temp 档), // 否则返回 retentionNone。 // // 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码, // 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。 // 🔴 时间性元数据位(temporal,迁移 0027)**不是**它的入参:r1 复审裁决 D 之后 // 这一位只观测、不参与判定(见 retention.go 的 resolveRetention 头注与 temporal.go)。 // 🔴 全仓**唯一**调用点是 `resolveRetention`(retention.go):任何地方再跑一遍 // 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因, // retention_test.go 有一条源码级断言钉住这个唯一性。 func judgeExtractionRetention(content string) retentionRule { text := normalizeRetentionText(content) if text == "" { // 空 / 纯空白内容在 Add 的 normalize 就被拒了,走不到这里;真走到了也只 // 放行(**fail-safe**:闸的职责是「拦住显然不值得记的」,不是「替上游 // 判断该不该存在」—— 判成 defer 才是替它做决定)。 return retentionNone } // 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此 // 组合口径(护栏②)不会把主人的直接指令改成临时记忆。 if containsAny(text, retentionRememberDirectives()) { return retentionNone } // 护栏②(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,差别只在 // 「为什么」,而那正是留痕/日志要回答的)。 // // 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 } 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**:短且只由寒暄/致谢词构成的内容。 // // 「短」是这条规则的**安全阀**:寒暄的真实形态就是一句话里没有别的信息, // 一旦它长到装得下事实,就不该由本闸判(交 T1 / 长期层)。 func judgeRetentionChatter(text string) retentionRule { if len([]rune(text)) > retentionMaxChatterRunes { return retentionNone } if !containsAny(text, retentionChatterWords()) { return retentionNone } // 带数字的短句一律放行:「7 点」「3 月 5 日」这类是事实的载体, // 哪怕句子再短(design/23 §5.2-2 把数值当硬门,同一个取向)。 if hasRetentionDigit(text) { return retentionNone } return retentionChatter } // judgeRetentionTimelyData 施加 **N4**:时效性查询(当前时间 / 天气)。 // // 它的词项是**整句形态**而不是单词(「几点」「天气」),因为「天气」单独出现可能 // 是主人对天气的**稳定偏好**(「主人怕冷,阴天心情差」)—— 那种句子带持久信号, // 已被护栏②放行;这里只吃「现在几点」「明天天气怎么样」这类纯查询。 func judgeRetentionTimelyData(text string) retentionRule { if containsAny(text, retentionTimelyQueries()) { return retentionTimelyData } return retentionNone } // judgeRetentionOneOff 施加 **N3 / N5**:一次性的祈使请求(「帮我订个 7 点的闹钟」 // 「把这个文件转成 pdf」)。 // // 判据是「**以祈使词开头或含一次性动作词**」而不是「含动词」:后者会把 // 「主人喜欢把这个文件转成 pdf」(稳定偏好)也拦下 —— 那种句子带持久信号, // 已被护栏②放行。 func judgeRetentionOneOff(text string) retentionRule { if hasRetentionPrefix(text, retentionAskPrefixes()) { return retentionOneOffAsk } if containsAny(text, retentionOneOffActions()) { return retentionOneOffTask } 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:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安… // 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点 // 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。 // 🆕 **M45 曾把这一条接到时间性元数据位上,r1 复审裁决 D 又撤掉了**(原因见 // temporal.go 头注:位置放宽能翻的行逐条都是长期约定)。因此位置判据对**每一行** // 无条件生效,与 0027 之前逐字相同。 // // **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终 // (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致** // (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句, // 是「会…」的习惯陈述,不许开火)。 // // 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明): // - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实); // - 不做**寒暄/情绪词表**筛(R5:10 条里 9 条是关系事实); // - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次 // 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与 // 关系里程碑**(抽检误杀 ≈ 50%),方向错误。 func judgeRetentionTransient(text string) retentionRule { // 安全阀 1~4:与 N1~N5 不同,它们在这里是**硬门**(命中即放行),因为 N6/N7 // 判的是「这条记忆的锚点是一个时刻」,而锚点词/数值/约束都在说「它不止是一个时刻」。 if containsAny(text, tidyHabitMarkers()) { return retentionNone } if containsAny(text, retentionHardDurableMarkers()) { return retentionNone } if retentionHasConcreteConstraint(text) { return retentionNone } // 安全阀 5/6(2026-10-25 r1 复审 P2-2 补): // - **「以后/从此/往后…」= 自此刻起永久成立** ⇒ 不是一次性事件。 // 反例(复审构造):`昨天主人说以后都叫我小叶` —— 句首时点只是说话的时刻。 // - **「换成/变成/改成/搬进…」= 状态已改变且新状态持续** ⇒ 不是一次性事件。 // 反例(语料实测):`茂密最近换了装:蝴蝶结从棕色变成深色…`(宠物稳定外观)。 if containsAny(text, retentionPermanenceMarkers()) { return retentionNone } if containsAny(text, retentionStateChangeMarkers()) { return retentionNone } // 安全阀 7:N6 **不接管 N1/N2 的语料**。含寒暄/致谢词或情绪词的句子归那两条 // 规则判(N1 有它的长度安全阀,N2 有形态匹配),N6 不越过它们改判: // - 「谢谢你今天特地提醒我这件事,真是帮了大忙了」是社交致谢,不是事件记录; // - 「主人最近很容易焦虑」是**情绪倾向**(长期事实),不是「最近发生了一件事」。 // 实测这两条是单纯加时点判据时唯一的两个既有正例回归。 if containsAny(text, retentionChatterWords()) || containsAny(text, retentionEmotionWords()) { return retentionNone } if judgeRetentionTimepoint(text) != retentionNone { return retentionTimepoint } if judgeRetentionInProgress(text) != retentionNone { return retentionInProgress } return retentionNone } // judgeRetentionTimepoint 是 N6 的形态判定:正文里**句首前 N 个字内**出现一个 // 指示性时点词。位置判据是这条规则精度的来源(见 judgeRetentionTransient 安全阀 5)。 // // 位置口径是「下标 < retentionTimepointLeadRunes」(0..11 共 12 个字);r1 复审 // 指出旧写法 `<= 12` 让「起于第 13 个字」也命中,与注释差 1 字,这里改严。 func judgeRetentionTimepoint(text string) retentionRule { if idx := retentionFrontedTimepoint(text); idx >= 0 && idx < retentionTimepointLeadRunes { return retentionTimepoint } return retentionNone } // judgeRetentionInProgress 是 N7 的形态判定:**句首前 N 个字内**出现进行中标记 // (正在 / 暂时 / 眼下 / 这会儿)。位置判据与 N6 同口径 —— 它挡的是「正在」只出现在 // 从句里、而主句其实是习惯陈述的那些句子(r1 复审 P2-2 的实测反例)。 func judgeRetentionInProgress(text string) retentionRule { idx := retentionFirstMarkerIndex(text, retentionInProgressMarkers()) if idx >= 0 && idx < retentionTimepointLeadRunes { return retentionInProgress } return retentionNone } // retentionFirstMarkerIndex 返回 markers 里最早出现的那个在 text 中的 rune 下标 // (-1 = 一个都没有)。 func retentionFirstMarkerIndex(text string, markers []string) int { best := -1 for _, marker := range markers { idx := runeIndex(text, marker) if idx < 0 { continue } if best < 0 || idx < best { best = idx } } return best } // retentionFrontedTimepoint 返回第一个指示性时点词在 text 里的**rune 下标**; // 没有时点词时返回 -1。调用方再用 retentionTimepointLeadRunes 判它是否「句首」。 // // 用 rune 下标而不是字节下标是必须的:中文一个字 3 字节,按字节算会把「前 12 字」 // 变成「前 4 字」,位置判据当场失效。 func retentionFrontedTimepoint(text string) int { best := retentionFirstMarkerIndex(text, retentionTimepointMarkers()) if best < 0 || best >= len([]rune(text)) { return -1 } return best } // runeIndex 返回子串 sub 在 text 里的**rune 下标**(找不到返回 -1)。它只在很短的 // 时点词表上调用(每次至多几十次 strings.Index),因此不必预编译。 func runeIndex(text, sub string) int { if sub == "" { return -1 } byteIdx := strings.Index(text, sub) if byteIdx < 0 { return -1 } return len([]rune(text[:byteIdx])) } // normalizeRetentionText 规范化正文供词项匹配:转小写(对 ASCII 词如 `hi` 有用, // 中文不受影响)、把连续空白折叠成**一个空格**、去掉结尾的标点。 // // 空白**折叠**而不是**删除**是刻意的:删光会让相邻两个英文单词粘成一个词, // 于是 `as hi` 里冒出子串 `hi` 而被误判成寒暄 —— 折叠则保住词边界。 // 词项表里因此同时列了 `转成 pdf` 与 `转成pdf` 两种写法。 func normalizeRetentionText(content string) string { text := strings.ToLower(strings.TrimSpace(content)) text = strings.Join(strings.Fields(text), " ") return strings.TrimRight(text, "。!?!?.,,、~~ ") } // containsAny 报告 text 是否含 markers 里任一词项。 func containsAny(text string, markers []string) bool { for _, m := range markers { if m != "" && strings.Contains(text, m) { return true } } return false } // hasRetentionPrefix 报告 text 是否以 prefixes 里任一词开头。 func hasRetentionPrefix(text string, prefixes []string) bool { for _, p := range prefixes { if p != "" && strings.HasPrefix(text, p) { return true } } return false } // hasRetentionDigit 报告 text 是否含阿拉伯数字(数值是事实的载体,带数值即放行)。 func hasRetentionDigit(text string) bool { for _, r := range text { if r >= '0' && r <= '9' { return true } } return false }