Files
Cyrene_v2/internal/runtime/memory/extract_retention.go
T
AskaEth 91101dedfb feat(memory): M45 提取器时间性元数据位 temporal 驱动留存判定
背景(M37 复审的原话):「真正卡住上游的是提取器的改写:一次性事件被写成
30~80 字的第三人称长期陈述……这不是阈值问题,是判定链缺信息位。」本片补的
就是那一位,不动 N1~N7 的任何阈值。

产出侧
- temporal.go:TemporalKind 闭集(one_off / recurring / durable / unknown,
  空串 = 未分型),normalizeTemporal 只认四个字面值,其余一律归 unknown(绝不猜
  同义词);提取提示词追加该枚举(模型只负责标注,判定在 Go 闭集 switch 里)。
- llmextractor:候选解析后归一并随 MemoryItem 带出;非法/缺失值不报错、不拖累
  同批其他候选。

落库
- 迁移 0027_memory_temporal(up: ADD COLUMN temporal TEXT NOT NULL DEFAULT '';
  down: DROP COLUMN,只撤结构、不改写任何行数据)+ NewStore 的自举前滚
  (duplicate column 是预期路径);Store.Add 的同一笔 INSERT 写该列,判定与
  留痕不分叉。

判定(仍是 resolveRetention 一处收口,judgeIngest 一字未改)
- one_off ⇒ temp(+ expires_at),但先过 temporalSanityVeto 六条语义安全阀:
  显式「记住」/ 在陈述约定 / 习惯词 / 硬性长期事实词 / 永久化 / 状态改变 ——
  命中即不给结论、落回 N1~N7。依据是离线重放的实测:去掉「记住+约定」两条时
  21 条长期约定会被判 temp(4.80%),全开后在最近 500 条窗口内翻转条数为 0。
- durable / recurring ⇒ long 且不再跑规则;unknown / 未分型 ⇒ 落回 N1~N7
  (不许因为「正文看着像今天的事」就判 temp)。
- IngestRuleVersion d3.4 → d3.5(判定结论会变,留痕必须可分)。

验证
- go build ./... + go vet ./... 全绿;go test -count=1 ./internal/runtime/memory/...
  ./internal/db/... 全绿;memory -race 全绿。
- 新增 temporal_test.go(验收点 ①~⑤ + 两条交叉护栏 + 闭集/归一化)与
  temporal_migration_test.go(0027 up/down/同形/真实启动顺序)。变异实测:
  摘掉 one_off 支路 ⇒ ① 全红;关掉 temporalSanityVeto ⇒ 三条护栏用例全红。
- 迁移测试的「回滚 N 步」改为从 schema_migrations 现算(migrateDownToBefore),
  新加一条迁移不再打红 0025/0026 的用例。
2026-10-26 18:57:38 +08:00

437 lines
24 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"
// 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 级的「零成本」要求)。
// 🔴 全仓**唯一**调用点是 `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
}
// 护栏①:显式「记住」是主人的直接指令,闸不许替他改判。它排在最前,因此
// 组合口径(护栏②)不会把主人的直接指令改成临时记忆。
//
// 切片 M45 起它的判据抽成 retentionExplicitRemember(**一处实现、两处调用**):
// 提取器的 `temporal=one_off` 元数据位(temporal.go 的 temporalVerdict)同样
// 不许推翻这条护栏 —— 否则「记住我明天三点开会」会被 7 天后删掉。
if retentionExplicitRemember(text) {
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
}
// retentionExplicitRemember 报告正文里有没有**显式要求记住**的措辞
// (「记住 / 记下 / 别忘了」,词表见 retentionRememberDirectives)。
//
// 它是护栏①的判据,也由「时间性元数据位」那一路复用(temporal.go 的
// temporalVerdict):**一条判据只有一份实现**,否则「主人的直接指令一票否决」
// 会在两条判定路上给出不同结论。
//
// 入参可以是原始正文,也可以已由 normalizeRetentionText 规范化过 ——
// 规范化是幂等的(折叠空白 + 去尾标点),重复施加不改变结果。
func retentionExplicitRemember(content string) bool {
return containsAny(normalizeRetentionText(content), retentionRememberDirectives())
}
// 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
}