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。
112 lines
5.1 KiB
Go
112 lines
5.1 KiB
Go
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,
|
||
}
|
||
}
|