package memory import ( "sort" "strings" "unicode" ) // 本文件是 `dedupe_group` 的**取值门**(2026-10-25,r1 复审 P1 的落点): // 合并前必须确认「被吸收行里的**取值**」在存活行里一个不少。 // // 动因是一次**会毁数据**的实测:生产语料里 `tag_set` 簇(同 tag 集)把 // `用户的昵称是「叶酱」。` 并进了 `主人的昵称是「喵喵七号」…` 的存活行 —— 两条 // **同属性、不同取值**(design/23 §5.2-4/§5.2-5 的绝不合并清单),而同库另有 4 行 // 明确写着「喵喵七号已作废、应称叶酱」。一次 apply 就会让当前事实消失,并留下与 // 库内其它行直接矛盾的结论。 // // 根因不是某条词表写漏,而是**判据缺一件**:design/23 §5.1-3 要求「专有名词、 // 数值、否定词」逐一比对,而实现只有后两件(`sameNumbers` / `sameNegations`)。 // 本文件的取值门补的就是「专有名词」那一件,因此它对**两种簇判据都生效** //(不是只对 prefix12 生效的相似度下限那种补丁)。 // // 方向是**单向**的(被吸收 ⊆ 存活),与软合并的语义同向:合并后存活行的正文 // 原样保留,所以**存活行多出来的取值不会丢**;会丢的只有「被吸收行有、存活行没有」 // 的那些取值。因此判据是「被吸收行的每个取值都出现在存活行里」,而不是「两侧取值 // 集合相等」—— 后者会把「超集吸收子集」这个正常方向也挡掉。 // dedupeValueGaps 返回被吸收行里**在存活行正文中缺失**的取值(空 = 可以合并)。 // // 取值有两类,都是 design/23 §5.1-3 说的「专有名词」的可机械抓手: // // 1. **引号内取值**(`「」` / `『』` / `“”` / `‘’` / ASCII `"`):昵称、暗号、口令、 // 代号、台词这类「同一属性的一组取值」在语料里几乎总被引号括起来 —— // `「喵喵七号」` vs `「叶酱」` 就是这一类的正例; // 2. **拉丁 token**(≥2 个字母的 ASCII 词,含 `h3R3` / `AAA` 这种混排): // 人名 / 型号 / 代号 / 英文标题。中文正文里它们不会被分词歧义吃掉。 // // **刻意不收**的:ASCII 单引号 `'`(英文所有格 `assistant's` 会被误配成引号对, // 于是整句剩余部分都要在存活行里出现 —— 方向虽安全,但会把大量英文条目挡在簇外); // 中文数字(`十点` vs `十一点`):既有 `sameNumbers` 只认 ASCII 数字 token, // 扩它属于改既有口径,不在本切片。 // // 比较用**归一化正文**(小写 + 空白折叠,同 dedupeKey):取值比对不该被大小写或 // 多余空格左右。 func dedupeValueGaps(absorbed, survivor string) []string { haystack := dedupeKey(survivor) if haystack == "" { return []string{"(empty survivor)"} } gaps := make([]string, 0, 2) seen := make(map[string]struct{}, 4) for _, value := range dedupeValues(absorbed) { if _, dup := seen[value]; dup { continue } seen[value] = struct{}{} if !strings.Contains(haystack, value) { gaps = append(gaps, value) } } sort.Strings(gaps) return gaps } // 取值门的两个阈值。 const ( // dedupeQuoteMaxRunes 是「当作取值」的引号内容长度上限(rune 计):取值门的靶子 // 是短标识(昵称/暗号/代号/型号),一整段引文作为取值只会把「同一件事的不同引述」 // 全挡掉。超过它的引号内容**不产生取值**(即不用它否决合并)。 dedupeQuoteMaxRunes = 24 // dedupeLatinMinRunes 是「当作取值」的拉丁 token 最小长度:单字母(`a`、`x`) // 在正文里到处都是,收它等于给每个簇加一道随机否决。 dedupeLatinMinRunes = 2 ) // dedupeValues 抽出一段正文里的**取值**(引号内取值 + 拉丁 token),已归一化为小写。 // // 引号配对按「开 → 就近的闭」:中文引号有明确的开关字符,配不上对的那一段 // 整个丢弃(解析失败时**不产生取值**,因此不会凭它否决合并 —— 少一条取值门 // 比凭空造一条安全门更不容易误伤)。 func dedupeValues(content string) []string { text := dedupeKey(content) if text == "" { return nil } out := make([]string, 0, 4) out = append(out, dedupeQuotedValues(text)...) out = append(out, dedupeLatinTokens(text)...) return out } // dedupeQuotePairs 是引号的开关对(同一字符既是开也是闭的只有 ASCII `"`)。 func dedupeQuotePairs() [][2]rune { return [][2]rune{ {'「', '」'}, {'『', '』'}, {'“', '”'}, {'‘', '’'}, {'"', '"'}, } } // dedupeQuotedValues 抽出所有**成对**引号里的内容(去首尾空白、丢弃空串与超长串)。 // // 超长串(> dedupeQuoteMaxRunes)丢弃是刻意的:一整段引文(台词、段落)作为取值门 // 会把「同一件事的不同引述」全挡掉,而取值门的靶子是**短标识**(昵称/暗号/代号)。 func dedupeQuotedValues(text string) []string { runes := []rune(text) out := make([]string, 0, 2) for i := 0; i < len(runes); i++ { open := runes[i] var closer rune switch open { case '「': closer = '」' case '『': closer = '』' case '“': closer = '”' case '‘': closer = '’' case '"': closer = '"' default: continue } for j := i + 1; j < len(runes); j++ { if runes[j] != closer { continue } value := strings.TrimSpace(string(runes[i+1 : j])) if value != "" && len([]rune(value)) <= dedupeQuoteMaxRunes { out = append(out, value) } i = j break } } return out } // dedupeLatinTokens 抽出 ≥2 个字母的 ASCII 词(含 `h3R3` 这类字母+数字混排)。 func dedupeLatinTokens(text string) []string { out := make([]string, 0, 2) var cur []rune hasLetter := false flush := func() { if hasLetter && len(cur) >= dedupeLatinMinRunes { out = append(out, string(cur)) } cur = nil hasLetter = false } for _, r := range text { if r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)) { cur = append(cur, r) if unicode.IsLetter(r) { hasLetter = true } continue } flush() } flush() return out }