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 ./... 全绿。
149 lines
7.9 KiB
Go
149 lines
7.9 KiB
Go
package memory
|
||
|
||
import (
|
||
"strings"
|
||
)
|
||
|
||
// 本文件是**N2 纯情绪形态**的解析器(design/03 §10.5 N2):把一句正文按
|
||
// 「[主语]? [时间]? [程度]* 情绪核心 [程度|否定|情绪|语气]*」消费掉,只有**消费干净**
|
||
// 才算「整句恰好是纯情绪陈述」。
|
||
//
|
||
// 它从 extract_retention.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)——
|
||
// 判据、词表与注释**逐字未动**;词表在 extract_retention_words.go,判定入口在
|
||
// extract_retention.go 的 judgeRetentionEmotion。
|
||
|
||
// 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
|
||
}
|