Files
Cyrene_v2/internal/runtime/memory/tidy_dedupe_candidate.go
T
AskaEth 7700a7eda0 fix(memory): dedupe_group 补中文数字门(量纲/单位归一),修 r2 复审 p2
r2 复审 p2:`sameNumbers` 只抽 ASCII 数字 token,而本语料的数字主要写成中文
(一小时 / 十五分钟 / 第二杯 / 三千次轮回)⇒「午睡一小时 vs 十五分钟」
「咖啡不续第二杯 vs 不超过一杯」这类**数值冲突**被当成同一事实合并。

修法(新文件 internal/runtime/memory/tidy_dedupe_numbers.go):
- 抽出「**中文数字 + 单位**」对(一~九/两/半/十/百/千/万,含十五、二十四、一万两千、
  半个小时),按**量纲归一**后比较:时间=秒(分钟 60 / 小时 3600 / 天 86400 /
  周 604800 / 个月 2592000 / 年 31536000)、长度=米、文字量=个、计数=个;
- 只认**后面紧跟已知单位**的数字:`一起 / 一直 / 一些 / 一样 / 第二天(序数位置)`
  这类含数字的习语一律不产生数量 —— 不做成「有中文数字就不合并」;
- 判据是**冲突**而非差异:存活行在同量纲里没有对应数量 ⇒ 不算冲突(被吸收行多出
  的细节);等值(含单位换算后等值,如「半小时」=1800 秒)⇒ 照常合并;
  值不等且单位换算不到一起 ⇒ 按冲突处理(复审口径:宁可不合并);
- 只**增加否决**,不放宽任何既有门;ASCII 路径仍由 sameNumbers 先判,一字未动;
  `sameNumbers` 本身也未改(judgePair/judgeIngest 共用它,属于另一处 blast radius,
  已在回报里登记为后续建议)。

语料实测(生产库只读 mode=ro,active 3667):999 对里中文数字门挡掉 **17 对**,
其中真冲突:午睡十五分钟 vs 一小时 ×3、咖啡一杯 vs 第二杯 ×4、
「三千次轮回」vs「三千万次轮回」×1(ASCII 路径完全看不见这条);
其余 9 对是「单位不可换算」的保守拒绝(半碗 vs 一个包/一杯、一次 vs 两圈)。
dedupe 产出 255 簇/505 行 → **250 簇/495 行(13.5%)**。

回归用例(去掉这道门即红,已实测):
- TestDedupeGroupRejectsConflictingChineseNumbers(复审给的两对真实语料,脱敏保留原意)
- TestDedupeGroupRejectsNonConvertibleUnits(两杯 vs 三瓶 ⇒ 保守不合并)
- TestDedupeGroupSameChineseNumberStillMerges(同值/换算后同值 ⇒ 照常合并,门没退化成「有数字就不合」)
- TestTidyQuantitiesChineseNumerals(解析口径:一小时=3600s、十五分钟=900s、半小时=1800s、
  一万两千行=12000、第二天/一起/一直 不产生数量)

验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
2026-10-25 00:41:16 +08:00

112 lines
5.1 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 (
"sort"
"time"
)
// 本文件是 `dedupe_group` 的**候选构造**侧:从一簇成员里挑出可并入的行、
// 并把簇转成候选(落库形状与 merge 候选同构,因此面板/应用/回滚三条路都不用改)。
// 它从 tidy_dedupe.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行),
// 判据与注释**逐字未动**;簇形成在 tidy_dedupe.go,元信息查询在 tidy_dedupe_meta.go。
// dedupeAbsorbable 从一簇成员里挑出**可以并入存活行**的那些(逐条过 §5.2 的安全门)。
//
// 三道门都是对**存活行**比(不是两两全比):簇的语义是「这些行说的是同一件事」,
// 而同一性由存活行代表;任何一条与存活行不一致的成员,说明它讲的是**另一个事实**,
// 因此它**不进这一簇**(留待人工),而不是让整簇作废:
//
// 1. **数值**(§5.2-2,sameNumbers):数值不同即不同事实;
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
// 4. **中文数字**(§5.2-2 的补充,dedupeNumberConflicts):`sameNumbers` 只抽
// ASCII 数字 token,而语料里的数字大多写成中文(一小时 / 十五分钟 / 第二杯)——
// r2 复审实测约 5 对真实数值冲突因此被合并。这道门按**量纲 + 单位换算**比,
// 单位换算不到一起就按冲突处理(标准是「宁可不合并」)。
//
// 相似度下限对**两种簇都生效**(`dedupeGroupMinSimilarity`):tag 集相同只是
// 「同主题」的弱证据(体检报告 §1.3 的「冰美式 vs 红牛」也同 tag 集),内容差得
// 太远的一律不进簇。「同开头」比「同 tag 集」更弱,因此它用的是更严的那一档。
func dedupeAbsorbable(rule string, survivor tidyDedupeMember, members []tidyDedupeMember) []tidyDedupeMember {
out := make([]tidyDedupeMember, 0, len(members))
for _, it := range members {
if it.item.ID == survivor.item.ID {
continue
}
if !sameNumbers(it.item.Content, survivor.item.Content) {
continue
}
if !sameNegations(it.item.Content, survivor.item.Content) {
continue
}
if len(dedupeValueGaps(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if len(dedupeNumberConflicts(it.item.Content, survivor.item.Content)) > 0 {
continue
}
if lexicalSimilarity(it.item.Content, survivor.item.Content) < dedupeGroupMinSimilarity(rule) {
continue
}
out = append(out, it)
}
// 被吸收行的顺序也必须是确定的(幂等键按 sorted(source_ids) 算,但证据里的
// dedupe_members 会原样落库,顺序抖动会让同一个簇每次产出不同的证据文本)。
sort.Slice(out, func(i, j int) bool { return out[i].item.ID < out[j].item.ID })
return out
}
// dedupeGroupMinSimilarity 返回一种簇判据的相似度下限(词项余弦)。
//
// 两档刻意不同:「完全相同 tag 集」是结构化证据(同主题的强信号),容忍改写得远一点;
// 「前 12 字相同」只是「开头一样」,必须要求正文整体也相近,否则同开头不同事实
// (`助手(persona)的说话风格…` vs `助手(persona)的象征花…`)会被合掉。
func dedupeGroupMinSimilarity(rule string) float64 {
if rule == tidyDedupeRulePrefix {
return dedupeGroupPrefixSimilarity
}
return dedupeGroupTagSetSimilarity
}
// dedupeGroupCandidate 把一个簇转成候选。
//
// 落库字段与 merge 候选同构,因此既有面板/应用/回滚三条路都不用改形状:
// `source_ids` = 被吸收行(**可多条**,`applyTidyDedupeGroup` 逐条软合并),
// `target_id` = 存活行,`merged_content` = 存活行正文(**绝不改正文**)。
func dedupeGroupCandidate(cl tidyDedupeCluster, now time.Time) TidyCandidate {
absorbedIDs := make([]string, 0, len(cl.Absorbed))
members := make([]string, 0, len(cl.Absorbed))
for _, it := range cl.Absorbed {
absorbedIDs = append(absorbedIDs, it.ID)
members = append(members, it.Content)
}
return TidyCandidate{
ID: mustNewTidyID(),
PersonaID: cl.Survivor.PersonaID,
Scope: cl.Survivor.Scope,
ProjectID: cl.Survivor.ProjectID,
Kind: cl.Survivor.Kind,
Action: TidyDedupeGroup,
SourceIDs: absorbedIDs,
TargetID: cl.Survivor.ID,
ProposedContent: cl.Survivor.Content,
MergedContent: cl.Survivor.Content,
Reason: tidyReasonDedupeGroup,
Evidence: TidyEvidence{
Containment: "none",
ContentA: cl.Survivor.Content,
DedupeRule: cl.Rule,
DedupeKey: cl.Key,
ClusterSize: len(cl.Absorbed) + 1,
KeptReason: cl.KeptReason,
DedupeMembers: members,
KindA: string(cl.Survivor.Kind),
},
RuleVersion: TidyRuleVersion,
Status: tidyCandidatePending,
CreatedAt: now,
}
}