r3 复审指出我第一版中文数字门两个方向都反了(规格本身被复审自己收回重写):
- 项 2(过严):归一后**同值**、中文与 ASCII 混写的两条合不了
(`一小时` vs `60 分钟`、`十五分钟` vs `15 分钟`)。根因是判定仍以 `sameNumbers`
(只抽 ASCII token)为闸:`{}` vs `{60}` 不等 ⇒ 直接否决,归一化没机会生效。
- 项 3(过松):**量纲不可比**(`一小时` vs `一杯`)被当成"不算冲突"⇒ 会合并,
与「比不了单位就不许合并」相反。
修法:数值门从「布尔冲突」改成**三态**,并取代 `sameNumbers` 作为 dedupe 的数值判据:
| 结果 | 含义 | 合并 |
|---|---|---|
| tidyNumberSame | 归一后同值(含中英混写、单位换算:一小时 ≡ 60 分钟) | ✅ |
| tidyNumberConflict | 同量纲、值不相等(含单位不可互换:两杯 vs 三瓶) | ❌ |
| tidyNumberIncomparable | 量纲不可比 / 存活行没有同量纲数量可比(一小时 vs 一杯) | ❌ 保守拒绝 |
配套:
- 数量抽取同时支持 **ASCII 与中文数字 + 单位**(`60 分钟` / `15分钟` / `十五分钟` /
`半个小时` / `一万两千行`),并新增 `字符`/`字节` 两个文字量单位;
- **裸数字 token(无单位)**仍按 `sameNumbers` 的集合相等口径判(`0023` vs `0024`
这类靠它拦住),并有 parity 用例钉住「与 extractTidyFacts 的 nums 逐项一致」;
- 三态里 `incomparable` 只**收紧**,不放宽任何既有门;`sameNumbers` 函数本身未改
(judgePair/judgeIngest 共用,另处 blast radius,已登记建议)。
回归用例(三态各一条 + 修前红/修后绿,均实测):
- TestDedupeGroupNumberGateThreeStates 四个子用例(两条同值含混写、同量纲不同值、量纲不可比),
同时断言判定与簇结果;
· 变异 = 退回 `sameNumbers`(项 2 旧行为)⇒ 两条「同值」子用例红;
· 变异 = 不可比当放行(项 3 旧行为)⇒ 「量纲不可比」子用例红。
- TestDedupeGroupRejectsConflictingChineseNumbers / RejectsNonConvertibleUnits /
SameChineseNumberStillMerges(r2 三项保持绿)+ TestTidyQuantitiesNumberParsing +
TestScanTidyNumbersBareTokensMatchExistingFactNums(裸 token 口径不变)。
语料实测(生产库只读 mode=ro,active 3667,999 对已过否定/取值/相似度门的成员对):
三态分布 same=942 / conflict=25 / incomparable=32;dedupe 产出
`250 簇/495 行` → **248 簇/489 行(13.3%)**。incomparable 那 32 对是「四行歌词 vs
一段歌词」「一个项目进度代号 vs 项目进度代号为「w49」」这类:按「比不了就不许合并」
一律拒绝,聚合上只少减 6 行(簇成员互有重叠)。
验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
110 lines
5.2 KiB
Go
110 lines
5.2 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,judgeDedupeNumbers):**三态**判据 ——
|
||
// 同值 ⇒ 可合并(中英混写也认:`一小时` ≡ `60 分钟`)/同量纲不同值 ⇒ 冲突/
|
||
// 量纲不可比 ⇒ 保守不许合并(`一小时` vs `一杯` 不是"放行")。
|
||
// 它取代了 `sameNumbers`(后者只认 ASCII token,既合不了中英混写、也看不见中文数字);
|
||
// 裸数字 token(无单位)仍按 `sameNumbers` 的**集合相等**口径判,一字未改。
|
||
// 沿革:r2(中文数字冲突)→ r3-项 2(归一后同值必须能合)→ r3-项 3(不可比必须拒)。
|
||
// 2. **否定**(§5.2-3,sameNegations):「X」vs「非X」是覆盖不是重复;
|
||
// 3. **取值**(§5.2-4/§5.2-5,dedupeValueGaps):被吸收行的引号取值 / 拉丁 token
|
||
// 必须在存活行里一个不少 —— 这条是 r1 复审 P1 的落点(`「叶酱」` 不许并进
|
||
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
|
||
//
|
||
// 相似度下限对**两种簇都生效**(`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 verdict, _ := judgeDedupeNumbers(it.item.Content, survivor.item.Content); verdict != tidyNumberSame {
|
||
continue
|
||
}
|
||
if !sameNegations(it.item.Content, survivor.item.Content) {
|
||
continue
|
||
}
|
||
if len(dedupeValueGaps(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,
|
||
}
|
||
}
|