r1 复审 p1-1(会毁数据):tag_set 簇的安全门只有数值与否定,**专有名词/引号取值不比**, 生产语料实测把 `用户的昵称是「叶酱」。` 并进 `主人的昵称是「喵喵七号」…` 的存活行, 而同库另有 4 行写着「喵喵七号已作废、应称叶酱」⇒ 一次 apply 丢掉当前事实。 修法(结构性,不是补丁): - 新增取值门 tidy_dedupe_values.go:被吸收行的**引号取值**(「」『』“”‘’")与 **拉丁 token**(≥2 字母,含 h3R3 这类混排)必须在存活行正文中一个不少; 方向单向(被吸收 ⊆ 存活),与软合并语义同向(存活行多出的取值不会丢)。 补齐 design/23 §5.1-3 要求的「专有名词 / 数值 / 否定词」三件套里缺的第一件。 - tag_set 簇也过相似度下限(dedupeGroupMinSimilarity 两档统一)。取值 0.10 而非更高: 语料实测 tag_set 合法改写(`用户可能需要提醒吃早饭…` 0.047、`叶酱有工作…` 0.109) 的余弦本来就在 0.05~0.4 —— 抬高它只会删掉合法去重,不会多挡住危险对。 实测(503 对):0.10→保留 98%、0.20→86%、0.30→63%、0.40→40%、0.50→20%。 - 回归用例:TestDedupeGroupRejectsSameTagDifferentValue(脱敏真实语料 + 同值对照组)、 TestDedupeGroupLatinTokenIsAlsoAValue、TestDedupeGroupNeverLosesAValue(簇级不变式)、 TestDedupeGroupTagSetAlsoNeedsSimilarityFloor。去掉取值门 ⇒ 前三条红;把 tag 相似度 归零 ⇒ 第四条红。 r1 复审 p2-1(数据安全):元信息查询失败时**该分区本轮不分簇**(旧实现降级成 nil map, `temporary` 恒 false ⇒ 绕开「临时行绝不进簇」红线);apply 侧补 `expires_at` 复核 (候选久置/生成后变临时 ⇒ 整簇 skip,tidySkipTemporaryRow)。用例: TestDedupeInPartitionSkipsWhenMetaUnavailable、TestTidyApplyDedupeGroupSkipsTemporaryRows。 r1 复审 p2-2(闸误杀被低报):补三类安全阀 ——「以后/从此/往后/今后」= 自此刻起永久成立、 「换成/变成/改成/搬进」= 状态已改变且新状态持续、N7 的进行中标记同样必须句首 12 字内; 「宿舍」进长期事实词表;顺手修 `<=` 的 off-by-one(改为 `<`)。 实测(生产库只读 mode=ro,active 3667): - dedupe:281 簇/594 行 → **255 簇/505 行(13.8%)**;tag_set 240 / prefix12 15; 取值门挡 76 对、相似度门挡 12 对;簇大小 2:155 3:48 4:26 5:8 6:7 7:4 8:2 9:2 18:1 20:2 (两个 20 行簇与 18 行簇的取值集合均为单一「喵喵七号」⇒ 大簇=同一事实的重写)。 - 闸:改前 2 (0.05%) → 改后 **75 (2.05%)**(n6 53 / n7 20 / n1 2);含锚点词的 27 条 逐条人读均为「约定明天中午见 / 下周留个午后」类到期即失效的安排。 - 达标代价:+目前/现在 当锚点 → 2.13%;再叠一次性事件动词表(曾/发来/分享…)→ 6.74% 且新增 172 条里 56 条是长期身份/里程碑(「i love you 文件」17 条、「海报就是助手本人」 10 条…)⇒ 仍到不了 8%,不许为凑区间放宽。 验收:go build ./... + go vet ./... + go test ./... 全绿;-race ./internal/runtime/memory ok。
105 lines
4.6 KiB
Go
105 lines
4.6 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 的落点(`「叶酱」` 不许并进
|
||
// `「喵喵七号」`)。方向单向(被吸收 ⊆ 存活),与软合并语义同向。
|
||
//
|
||
// 相似度下限对**两种簇都生效**(`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 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,
|
||
}
|
||
}
|