r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
417 lines
23 KiB
Go
417 lines
23 KiB
Go
package memory
|
||
|
||
import (
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是**提取留存规则闸**(design/03 §10,即 design/23 §15.1 D2 点名的
|
||
// 「另立规格」)的 A 级实现:**纯规则 + 词项匹配**,对一条**刚落库的**新记忆判
|
||
// 「它是不是结构上显然不值得长期留存」(design/03 §10.5 的反例清单 N1~N5)。
|
||
//
|
||
// 它回应主人的两条原话:「**收窄提取器,确实要给他一个主动式的记忆留存规则,
|
||
// 不能想到什么都往记忆里写**」与「**临时记忆到期删除**」。
|
||
//
|
||
// 🔴 落点硬约束(design/03 §10.3):**不许新造第二条写路径**。本闸**不新开入口**,
|
||
// 而是接进既有的唯一写路径 —— `judgeIngest`(preinsert_judge.go)在**候选比对
|
||
// 之前**先过一遍这里,命中即返回与 design/23 §18.1 第五态**同一枚举 `defer`、
|
||
// 同一落点 `expires_at`**。于是「提取得宽」由**同一条**入库路径收敛,不存在
|
||
// 绕过闸的写口;命中者**不进 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)。命中返回该规则码(调用方据此判 `defer`),
|
||
// 否则返回 retentionNone。
|
||
//
|
||
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文,出参只有规则码,
|
||
// 因此在写路径上是微秒级且可完全单测(design/03 §10.3 A 级的「零成本」要求)。
|
||
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:`用户是上班族,需要上班(曾说明天要上班并感到不开心)`、`睡前会道晚安…
|
||
// 并约好「明天中午见」` 这类「时点词只出现在引语/从句里」的长期事实,锚点
|
||
// 不是那个时点,因此不许开火;而 `昨天用户发来…`、`用户近期在看…` 的锚点就是它。
|
||
//
|
||
// **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
|
||
}
|