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 }