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, } }