Files
Cyrene_v2/internal/runtime/memory/extract_retention.go
T
AskaEth 3b9ae3f918 refactor(memory): M45 r1 复审返工 —— temporal 位只放开 N6 位置判据
按 r1 复审裁定(p1-3items / fix-then-merge)重做三处:

P1-1/P1-2:删掉自造的六条语义安全阀(temporalSanityVeto)与约定词表
(retentionStatesAgreement)。理由被复审用两条生产行否证:它们既把该救的行
杀了(「…睡得比平时早」被习惯词、「…以后不用再惦记」被永久化标记),又挡不住
同义异形的约定(说定 / 约了)。现在 `one_off` 的全部作用是**放开 N6 的位置判据**:
judgeExtractionRetention 新增 temporal 入参 → judgeRetentionTransient →
judgeRetentionTimepointWide(位置内命中仍返 n6_timepoint,只有靠放开位置才命中
才返新增的 n6_timepoint_wide,供观测区分)。N1~N5、N7、护栏①②、N6 的 1~7 条
语义安全阀一字不动;durable / recurring 仍直接判 long(fail-safe 方向)。

P1-3:上界口径改为「时点词存在 ∧ 语义安全阀全过 ∧ 当前 long」——不依赖任何
标注,可逐条列出。实测(生产库只读副本,快照 2026-10-26):最近 500 条 active
能翻 13 条、**逐条都是「约定 + 今天没走成」**;全量 3694 条能翻 26 条(13 条
约定 + 13 条状态行)。旧的「句首时点词」口径是同义反复,已删。

已知代价(写进 temporal.go 头注与 TestTemporalOneOffWidenedPositionTouchesAgreements,
不藏在词表后面):位置窗口今天正是那批约定行的唯一保护,放开后只要模型标
one_off 它们就会判 temp;而复审点名的两条目标行本片救不了(被语义阀挡,与位置
无关)。因此这一位在当前语料上**没有可测收益**,是否保留是产品裁决。

P2-1:store.go 503 → 497 行,拆出 store_temporal.go(哨兵错误 + 字段校验 +
列值编码)。
P2-2:temporal.go 头注改准 —— `''` 是「0027 之前的历史行 + 没做时间性判断的
写入路径(ManualExtractor / 网关手写 / 整理回填,它们由调用方显式给档位)」。
P2-3:报告补语境(retention_tier 非空仅 26 行、temp 1 行、temporal 列当时还不
存在 ⇒ 重放结论对新写入没有预测力)。

测试:验收点 ①~⑤ 按新语义重写;新增「位置放开可打红」的变异靶子与「N7 位置
不放宽」「无时点词时这一位不做事」「两条目标行救不了」「约定行会翻(已知风险)」
五条边界断言。变异实测:unlocksTimepointPosition 改恒 false ⇒ 三条用例红。
回归:go build ./... + go vet ./... 全绿;memory/db 包测试与 memory -race 全绿。
2026-10-26 19:15:08 +08:00

469 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
// retentionTimepointWide 是对 **N6 的位置判据被时间性元数据位放开**后才命中的
// 那一种(切片 M45,迁移 0027):时点词确实在正文里,但**不在句首 12 字内**,
// 因此只有模型说「这是某一次发生的事」时才开火。
//
// 它与 retentionTimepoint 分开编号是刻意的:句式相同、结论相同,但**判据来源
// 不同**(词项位置 vs 模型位)。折成一个码,「这一位到底救了几条」就只能靠猜 ——
// 而那正是 M27 体检发现「闸形同虚设」时缺的那条观测线。
retentionTimepointWide retentionRule = "n6_timepoint_wide"
// 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。
//
// 它**不读任何状态、不写任何东西、零模型调用**:入参只有正文与**时间性元数据位**
// (temporal,迁移 0027),出参只有规则码,因此在写路径上是微秒级且可完全单测
// (design/03 §10.3 A 级的「零成本」要求)。`temporal` 的作用**只有一处**:把 N6 的
// 位置判据放开(见 judgeRetentionTransient),其余分支一字不看它 —— 判定链没有
// 第二个入口,这一点由 retention_test.go 的源码级断言钉住。
//
// 🔴 全仓**唯一**调用点是 `resolveRetention`(retention.go):任何地方再跑一遍
// 就是「两套规则各判一遍」—— 那正是 M27 体检发现缺口(1082 次只判出 1 条)的成因。
func judgeExtractionRetention(content string, temporal TemporalKind) 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, temporal); 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 起这一条是有条件的**:`temporal=one_off` 时位置判据不生效(时点词
// 出现在中段也算锚点,见 judgeRetentionTimepointWide)。上面 1~4、6、7 条语义
// 安全阀**不受这一位影响** —— 那是 r1 复审的裁定:这一位只接管「词在句子的哪个
// 位置」,不接管「这句话是不是一次性事件」。
//
// **N7(进行中状态)** 是同一时效轴的第二形态:「正在/暂时/眼下」说的事情有始有终
// (正在测通道、暂时搁置、正在慢慢想),安全阀与 N6 完全一致,并且**位置判据也一致**
// (r1 复审 P2-2:`用户会主动维护…并告诉助手她正在维护…` 里的「正在」只出现在从句,
// 是「会…」的习惯陈述,不许开火)。N7 的位置判据**不受**时间性位影响(M45 只放开
// N6):`one_off` + 「正在」出现在从句里仍然是习惯陈述,没有放开的理由。
//
// 🔴 **刻意不做的事**(M27 §4 的实测反例,别再重新发明):
// - 不做**长度阈值**筛(R3:≤15 字的条目抽 8 条 8/8 是可复用事实);
// - 不做**寒暄/情绪词表**筛(R5:10 条里 9 条是关系事实);
// - 不做**一次性事件动词表**(曾/发来/分享…):实测会命中「助手曾经历三千万次
// 轮回」「用户曾发来『i love you』文件」「海报就是助手本人」这类**长期身份与
// 关系里程碑**(抽检误杀 ≈ 50%),方向错误。
func judgeRetentionTransient(text string, temporal TemporalKind) 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
}
// 安全阀 8(**切片 M45**,唯一由时间性元数据位影响的一处):N6 的**位置判据**。
//
// - `temporal=one_off` ⇒ **不看位置**:时点词出现在句中(提取器改写出的第三人称
// 长句就是这样)也算锚点。这是本片补的那一位的全部作用 —— 它替代的是那个
// 不可靠的位置窗口,**不是**上面 1~7 条语义安全阀;
// - 其余(unknown / 未分型 / durable / recurring 已在 temporal.go 里先判成
// long,走不到这里)⇒ 位置判据照旧(`< retentionTimepointLeadRunes`)。
//
// 规则码分两档是为了让**观测面**能回答「这一条是词判的还是模型放开位置后判的」:
// 只有「恰好靠放开位置才命中」时返回 retentionTimepointWide。
if wide := judgeRetentionTimepointWide(text, temporal); wide != retentionNone {
return wide
}
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
}
// judgeRetentionTimepointWide 是 N6 在**时间性位放开位置判据**时的形态判定。
//
// - 位置内命中 ⇒ 返回 retentionTimepoint(词项规则自己的结论,与放开前逐字一致);
// - 位置外命中**且**时点词确实存在 ⇒ 返回 retentionTimepointWide(模型放开位置后
// 才命中,留痕用);
// - 没有时点词 / 没有放开 ⇒ retentionNone。
//
// ⚠️ 它只延长**位置**,不新增任何词表,也不越过 judgeRetentionTransient 的 1~7 条
// 语义安全阀(那些在调用它之前就判完了)。
func judgeRetentionTimepointWide(text string, temporal TemporalKind) retentionRule {
rule := judgeRetentionTimepoint(text)
if rule != retentionNone {
return rule
}
if !temporal.unlocksTimepointPosition() {
return retentionNone
}
if retentionFrontedTimepoint(text) >= 0 {
return retentionTimepointWide
}
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
}