feat(memory): dedupe_group 候选动作 + 留存闸组合口径与 N6/N7 时点规则(M34)

M27 体检(DevDocs/audit/记忆库价值体检-2026-10-24.md)两条根治项:

1) 新增 `dedupe_group` 候选动作(不放大 containNone):
   - 簇只认「同 scope+kind+完全相同 tag 集」或「同 scope+kind+前 12 字相同」
     (后者附加词项余弦 ≥0.70 与同 kind 收窄);
   - 每簇保留超集:内容最长 > merge_count 大 > created_at 早 > id;
   - 安全门与既有合并路径同源:受限来源/绝对时刻/时效状态/低置信一律不进簇,
     数值与否定与存活行不一致的成员被逐条剔出;临时层行(expires_at 非空)
     两个方向都不进簇(存活行被清扫会给被吸收行留悬空 merged_into);
   - 已被 archive/merge 候选覆盖的条目不重复出第二条待办(抑制);
   - 落库形态与 merge 同构:source_ids 多条 + target_id,应用为整簇原子的软合并,
     回滚复用 revertTidyMerge(逐条复原);
   - 批量应用按**行**计上限 200 行/批(maxTidyApplyRowsPerBatch),超限如实 truncated;
   - 不进自动档(人工/批量点);试算(DryRun)零写库给出簇数与可减少行数。

2) 留存闸改组合口径(治根)+ N6/N7:
   - 护栏②从「含习惯/约定词就一票放行」改为「锚点词 **且** 含具体约束
     (数字/禁忌/频次规律)」;无约束的锚点交给更窄的规则判;
   - N1/N3/N4/N5 这几条词项规则改为「无锚点时才开火」(`主人喜欢说晚安`
     这类偏好不再因寒暄词被判临时);
   - 新增 N6(指示性时点,句首前 12 字内)与 N7(进行中状态),安全阀:习惯词、
     硬性长期事实词、数值/约束、寒暄/情绪词一律按回去;
   - 命中即第五态 defer,`expires_at = 判定时刻 + tempTtlSec`(默认 7 天,
     已落在 $DATA_DIR/config/memory-tidy.json,零新 .env 键);**只开新增侧**,
     存量一条不补写(用例钉住)。

契约(加性,不改既有字段语义):tidy dry-run/run 回执加 dedupeGroups /
dedupeRowsReducible / dedupeTruncated;批量应用回执加 deduped / rowsAffected。

实测(生产库只读 mode=ro,active 3667):
- 闸:改前 2 条(0.05%) → 改后 89 条(2.43%):n6 58 / n7 29 / n1 2;
  89 条里 30 条含锚点词,逐条人读均为「约定明天中午见」「约定下周留个午后」
  这类到期即失效的安排(唯一可议:1 条把「中午互动的日常」一起带进临时层)。
- dedupe_group:281 簇 / 可减 594 行(16.2% of active);tag_set 265 / prefix12 16;
  簇大小 2:170 3:48 4:27 5:10 6:10 7:7 8:2 9:2 10:1。

验收:go build ./... + go vet ./... + go test ./... 全绿。
This commit is contained in:
AskaEth committed 2026-10-25 00:08:46 +08:00
1 parent 210c8b999f
commit 6a3f3a329a
20 files changed
+2353 -403

No files matched your search

+202 -305
View File
@@ -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", "转换成", "截图", "打开这个", "关掉这个",
"搜一下", "查一下", "翻译一下", "订个闹钟", "设个闹钟",
}
}