# 项目合并(Project Merge)· 2026-10-02 > 起点:同一个项目的不同产出被记成了好多个「项目名」—— 依琪的真实库里 > **242 条记录 / 58 个项目名**,其中「卫龙榴莲辣条」一族就占了 **9 个名字、18 条记录**。 > 终点:规则层**纯本地**聚类出候选分组 → 用户确认后批量改挂到规范名 → 原名保留成 tag → 可撤销。 > > **本模块只出建议,绝不自动合并**(为什么这条是铁律,见第三节的反例)。 > 代码:`lib/project-merge.js`(纯函数)· `lib/store.js`(执行 / 撤销)· `lib/http.js`(两个端点) > 测试:`test/project-merge.test.mjs`(38 条断言) --- ## 一、要解决什么问题 真实数据(依琪 2026-10-02 的库,数字取自 `lib/project-merge.js` 文件头): ``` 242 条记录 / 58 个项目名 其中「卫龙榴莲辣条」一族 9 个名字、18 条记录: 卫龙榴莲辣条 · 卫龙榴莲辣条营销创意 · 卫龙榴莲辣条IP · 卫龙榴莲辣条短剧 · 卫龙榴莲辣条营销方案 · 卫龙榴莲辣条留恋计划 · 卫龙榴莲辣条·留恋计划 · 卫龙榴莲辣条-抽象梗campaign · 卫龙榴莲辣条臭味杯 ``` 这 9 个名字其实**是一个项目**。更刺眼的是最后两类: - `卫龙榴莲辣条·留恋计划` 与 `卫龙榴莲辣条留恋计划` **只差一个 `·`** —— 这不是「相似」,是**同一个字符串被存了两遍**,属于零风险的假同名。 - `营销创意` / `IP` / `短剧` 是**作品类型**,不是项目名;它们被混进了项目字段。 所以目标是两件事,顺序不能反: 1. **先把「只差分隔符」这种确定性重复合掉**(exact); 2. 再把「根名 + 后缀」这类结构合掉,**同时把后缀信息保留下来**(转成 tag,不丢信息)。 至于「语义上是不是同一个项目」——那是下一期 AI 的事,规则层不猜。 --- ## 二、四档规则与阈值 `findMergeGroups(entries, options)` 用**并查集**把所有「像同一个」的名字连成连通分量, 每条边带一个规则标签,**整组的置信度取组内最弱的一环**(保守标注,不拿最硬的那条糊弄用户)。 | 档位 | 判定 | 默认勾选 | 给人看的理由 | |:--|:--|:--|:--| | `exact` | 归一化后**完全相同** | ✅ true | 只差分隔符 / 大小写 / 全角半角 | | `contains` | 一个归一化名**整段包含**另一个 | ✅ true(**弱根除外**,见下) | 一个名字完整包含了另一个 | | `prefix` | **共同前缀**过三道闸门 | ❌ false | 共同前缀占了大部分(照规则判断) | | `tokens` | 按分隔符/驼峰切词,**Jaccard ≥ 0.5** | ❌ false | 切出的词大部分重合 | 阈值(`lib/project-merge.js` 里 `findMergeGroups` 的默认值,全部可覆写): | 参数 | 默认 | 作用 | |:--|:--|:--| | `minNormalizedLength` | 2 | 归一化后短于 2 个字符的名字不参与(`A` 这种太短,巧合太多) | | `minSharedPrefix` | **4** | prefix 档的**绝对**前缀下限 | | `prefixRatio` | **0.6** | 共同前缀占**较短名**的比例下限 | | `prefixSpanRatio` | **0.4** | 共同前缀占**较长名**的比例下限 | | `minRootLength` | **3** | `contains` 档的**根名**长度下限;短于此则「弱根降级」 | | `tokenJaccard` | **0.5** | tokens 档阈值 | | `minRecords` | 2 | 组内记录数(`count` 求和)少于这个就不值得报 | 另外,`''`、`(未分类)`、`(无项目)`、`未分类`、`无项目` 这几个哨兵名**不是真项目**,直接跳过 (`NON_PROJECT_NAMES`)。 ### `contains` 档的「弱根降级」(`minRootLength`,2026-10-02 补) `contains` 是**默认勾选**的档,所以**短根名在这一档比 `prefix` 更危险**: ``` [{AI:1}, {AI绘画:3}, {AI写作:2}] → 合成一组,canonical「AI」,默认勾选 ❌ ``` `AI绘画` 与 `AI写作` 明显是两个项目,只是恰好都碰到 `AI` 这个词。 修法**不是**硬性过滤,而是**降级**:根名(被所有其他成员包含的那个)归一化后短于 `minRootLength`(默认 3)时,这一组仍然**报出来**,但 `weakRoot: true`、 `defaultChecked: false`,`reason` 里如实写明「根名太短,容易撞名,请核对」。 > 为什么选「降级」而不是「不报」:藏起来等于这条永远没人处理; > 而人(或下一期的 AI)看一眼就能判断。**候选人肉核对 > 静默丢弃。** > 也不做成硬过滤 —— 真有人的项目就叫两个字的(例如 `学业`),不该被规则判死。 ### 为什么是这些阈值 **① 归一化用白名单 `\p{L}\p{N}`(只留字母数字,含 CJK),先 NFKC 再小写。** 世界的分隔符列不完,而「只留字母数字」天然把 `·`、`-`、`_`、`/`、空格、全角括号一次清干净。 NFKC 负责 `ABC → abc` 与兼容字符折叠;CJK 属于 `\p{L}` 所以**不会被误删**。 **② `prefix` 的三道闸门缺一不可。** 每一条都是被真实反例逼出来的(下一节展开): - **绝对长度 ≥ 4**:`dsh` 只有 3 个字符,直接出局; - **占较短名 ≥ 60%**:抓 `三角洲口琴工作台 / 三角洲口琴工具链`(共同前缀 `三角洲口琴工`,6/8 = 75%); - **占较长名 ≥ 40%**:防「长尾短前缀」—— 前缀占短名比例够高、但相对长名只是一小截, 那通常是命名空间前缀(`dsh-`、`lib-`),不是共同项目名。 **③ `tokens` 阈值 0.5 的来历**:`SVG motion studies` 与 `Standalone SVG Motion` 切出的词是 `{svg, motion, studies}` 与 `{standalone, svg, motion}`,交集 2、并集 4 → 恰好 0.5。 再高一档就抓不到这个真实例子,再低一档就开始把无关的英文项目连起来。 长度 < 2 的词直接丢掉(单个字母的偶然重合不算证据)。 **④ `minRecords` 默认 2**,且统计的是**记录数之和**而不是名字个数: 两个名字各 1 条也算一组(用户确实想合),但如果一组名字加起来都没有 2 条记录,报了也没意义。 ### 规范名(canonical)怎么选 优先级写在 `findMergeGroups` 里,**不是「谁记录多谁当规范名」**: 1. **根名优先**:某个成员的归一化名是**其余每一个成员**的子串 —— 它就是天然的规范名。 `卫龙榴莲辣条` 族里 `卫龙榴莲辣条` 满足;而记录数最多的 `卫龙榴莲辣条营销创意`(5 条 > 根名 2 条) **不能当选** —— 「营销创意」是作品类型,不是项目名。此时 `canonicalIsRoot === true`。 2. 没有唯一根名时(`三角洲口琴工作台 / 工具链` 谁也不含谁;两个 exact 同名字互相都「包含」, 根名不唯一)退而求其次:**记录数最多 → 并列取最短 → 再并列取字典序**,保证结果可重现。 此时 `canonicalIsRoot === false`,面板可以提示「这组没有天然根名,请你确认合并到哪个」。 组的输出顺序也是稳定的:**先按置信度(稳的在前)→ 再按影响面(总记录数多的在前)→ 再按规范名字典序**。 同样输入跑两次,`JSON.stringify` 完全一致(测试 C6 钉住了这一点)。 --- ## 三、⚠️ 反例:为什么「共享前缀就合」会出事 真实数据里就有这么一族 —— 15 个名字共享 `dsh` 前缀,但**是 15 个不同的项目**: ``` dsh-artifact-library(27) dsh-lan-connect(3) dsh-browser(1) dsh-compaction-tuner(1) dsh-yiqi-memory(1) dsh-shield 插件安全护栏(1) DSH 扩展(1) DSH本地模型接入(3) DSH-AE接入(1) DSH 内建机制调研(1) DSH 记忆系统调研(1) DSH 自身(1) DSH 环境维护(1) 依琪的 DSH 探索(1) 依琪的工作环境维护(2) ``` **实测**(`test/project-merge.test.mjs` 的 D 段,默认阈值):`findMergeGroups(这 15 个).groups.length === 0`。 当初的翻车经过(`lib/project-merge.js` 文件头有记录,2026-10-02 实测): - 第一版只有「前缀占**短名** ≥60%」这一道闸门。 归一化后 `DSH 自身 → dsh自身`(5 字符)、`DSH 扩展 → dsh扩展`(5 字符), 共同前缀 `dsh` 正好是 5 的 **60%** → 判定成立; 再靠并查集**链式传染**,**15 个名字里有 13 个被连成一坨**(实测 `groups.length === 1`, 成员 13 个、`merged === 12`)。合了就是灾难。 - 加上**绝对下限 ≥4** 之后,`dsh`(3 字符)直接出局;而真正该合的 `三角洲口琴工`(6 字符)、`ComfyUI`(7 字符)都远在 4 之上,不受影响。 - 若只把 `minSharedPrefix` 从 4 调到 3(其余闸门保留),误合立刻回来 (实测会出现一组 4 个名字:`DSH 扩展 / DSH 环境维护 / DSH 自身 / DSH-AE接入`)。 所以测试 D 段钉了两件事: - **D1**:15 个名字默认阈值下**必须 0 组**(绝对下限的回归守卫); - **D2**:把 `minSharedPrefix` 调到 3 后**必须出现误合** —— 证明 D1 守的那道下限确实是承重的, 而不是一个「删掉也不会红」的摆设。 > 设计取舍:规则层宁可漏(把该合的留给人工/AI 补),不可错(合错是**批量**改数据)。 --- ## 四、合并的语义 ### 4.1 原名转 tag(为什么) 执行合并时,命中记录的 `project` 改成规范名,同时把**原项目名追加进 `tags`**: ``` 卫龙榴莲辣条营销创意 → project: 卫龙榴莲辣条 tags: [..., 卫龙榴莲辣条营销创意] ``` 因为 `营销创意` 这层信息**不能丢** —— 它是**作品类型**,本来就该是一个可筛选的维度。 项目列表干净了,而「只看营销创意那几条」依然做得到(按 tag 过滤)。 HTTP 端点的 `keepVariantAsTag` 缺省就是 `true`(`body.keepVariantAsTag !== false`); 传 `false` 则只改 `project`、不写 tag,且 `tagsAdded` 为 0。 若原名**已经在 tags 里**,不会重复追加(`tagsAdded` 也不虚报)。 具体规则(`store.mergeProjects`): - 只动 `trashed_at === null` 的记录 —— **回收站里的记录不参与**(它们保留旧名字; 将来若被恢复,项目名还是旧的,这是已知限制)。 - 未命中的记录**一个字段都不动**。 - 返回 `{ ok:true, to, from, changed, tagsAdded }`:`changed` 是命中并改挂的记录数, `tagsAdded` 是真正新增了 tag 的记录数(可能小于 `changed`)。 - 被拒时 `{ ok:false, error }`,情况有四种,error 都是可读中文: 缺 `to` / 缺 `names` / `to` 与 `names` 相同(没有可改的记录)/ 一个记录都没命中。 ### 4.2 可撤销(确切语义) 合并是一次**批量改写**(可能一次动 18 条),不能撤销的批量操作没人敢点。 执行合并时把每条记录的 `{ id, project:合并前, tags:合并前 }` 快照写进 `store.meta.lastProjectMerge` 并**落盘到 `meta.json`** —— 重启后撤销入口还在(不是假撤销)。 `store.undoProjectMerge()` 的语义,如实说明: - 把快照里每条记录的 `project` 与 `tags` **整份还原**到合并前的样子,返回 `restored` 条数; - 快照里的记录若已被删除(进回收站/硬删),**跳过**,不影响其余记录的还原; - 撤销后 `lastProjectMerge` 置 `null`(**只保留最近一次**,与「撤销上一步」的直觉一致); - 所以**连撤两次,第二次一定 `ok:false`**; - ⚠️ 如果合并之后你又**手动改了这些记录的标签**,那些改动会被一起回退 —— 这是「撤销」应有的语义(回到那个时刻),不是 bug。 **撤销入口只在「刚合完」这个窗口里有意义**,面板上会写明这次合并了哪几个名字。 --- ## 五、API 契约 ### 5.1 候选(规则层,纯本地、不调模型) ``` GET /ext/artifacts/suggest-cleanup → 200,store.suggestCleanup() 的完整结果 ``` 本次新增的三个字段: | 字段 | 类型 | 含义 | |:--|:--|:--| | `projectMerges` | `Array` | 候选分组,结构见下;无候选时 `[]` | | `projectsScanned` | `number` | 参与扫描的项目名个数(只数**非回收**、非哨兵、归一化长度 ≥2 的名字) | | `projectMergeUndo` | `object \| null` | 最近一次合并的撤销入口;`null` = 没有可撤销的 | `projectMerges` 每组的结构: ```jsonc { "canonical": "卫龙榴莲辣条", // 建议合并到的规范名(必然是组内某个现有名字,不会凭空造名) "canonicalIsRoot": true, // 规范名是不是组里那个「根」;false = 没有天然根名,请人工确认 "confidence": "contains", // exact | contains | prefix | tokens(组内最弱的一环) "reason": "一个名字完整包含了另一个", "defaultChecked": true, // 面板默认勾不勾(只有 exact/contains 为 true) "total": 10, // 组内记录数之和 "names": [ { "name": "卫龙榴莲辣条营销创意", "count": 5 }, ... ], // 按记录数降序 "from": [ "卫龙榴莲辣条营销创意", "卫龙榴莲辣条IP" ] // 除规范名外会被并进来的名字 } ``` 组的顺序:置信度从稳到松 → 总记录数从多到少 → 规范名字典序。 ### 5.2 执行 / 撤销(写操作,仅限本机 127.0.0.1) ``` POST /ext/artifacts/projects/merge body: { "names": ["卫龙榴莲辣条营销创意"], "to": "卫龙榴莲辣条", "keepVariantAsTag": true } 200: { "ok": true, "to": "...", "from": [...], "changed": 2, "tagsAdded": 2 } 400: { "ok": false, "reason": "merge-rejected", "error": "<可读原因>", "message": "<同 error>" } POST /ext/artifacts/projects/merge-undo 200: { "ok": true, "restored": 2, "to": "卫龙榴莲辣条" } 400: { "ok": false, "reason": "nothing-to-undo", "error": "没有可撤销的项目合并", "message": "..." } ``` - `GET /suggest-cleanup` 在局域网只读白名单里;两个 POST **不在**,非回环来源一律 403 `local-only`。 - 失败一律带结构化 `reason`(调用方不必解析人类可读文本)。 --- ## 六、已知限制 1. **规则层不做语义判断。** 现在完全本地、不调模型;「这两组名字业务上是不是一个项目」 这类判断是**下一期 AI/人工**的事。规则层只保证「确定性重复不漏 + 不乱合」。 2. **`prefix` / `tokens` 两档默认不勾选**,必须人工核对。它们抓的是启发式相似, 存在误报可能(尤其英文项目名)。 3. **没有「跨库」合并。** 只在同一个 store 内改挂;两个不同数据目录之间没有合并能力。 4. **只有最近一次合并可撤销。** 第二次合并会覆盖上一份快照;`lastProjectMerge` 只有一份。 5. **撤销是「回到那个时刻」。** 合并后对这些记录标签的其它改动会一起被回退(见 4.2)。 6. **回收站记录不参与合并**,因此它们的项目名保持旧值;恢复后也不会自动跟随规范名。 7. **归一化后长度 < 2 的名字、以及 `(未分类)` 等哨兵名不参与扫描**,也就不会出现在候选里。 8. 规范名只能是组内**已存在**的名字,不会自动生成新名字。 --- ## 七、测试 ``` node test/project-merge.test.mjs # 44 通过 / 0 失败 ``` 44 条断言,覆盖:归一化(含 `·` 假同名、全角、CJK)→ 切词/前缀/Jaccard → 四档规则与阈值、规范名选根名、输出稳定性 → **D 段 DSH 回归守卫(默认 0 组 + 调小阈值必红)** → **C9–C11 弱根降级**(`AI` 不得默认勾选;正常根名不受影响;`minRootLength` 可调)→ store 合并/不参与/边界/撤销/落盘重启 → HTTP 两成功两被拒 → **G1–G3 历史记录缺 `trashed_at`/`tags` 的回填**(修复前 `list()` 可见 0 条)。 ### 两个平台的实测 | 平台 | 全量(18 个文件) | 合并相关真机行为 | |:---|:---|:---| | Linux(Node v22) | **599 通过 / 0 失败** | 活实例(独立 profile)上真点过合并与撤销;无头 Chromium 截图逐组核对 | | Windows(Node v25.2.1) | **596 通过 / 0 失败** | `卫龙组 → canonical=卫龙榴莲辣条`、`弱根组 → weakRoot=true / defaultChecked=false`、`DSH 反例 → 0 组`、含中文项目名的合并+撤销均正确 | > 两侧差的 3 条 = `platform-port` 里 4 条 Linux 专用启动器断言在 Windows 上按平台跳过, > 而 Windows 侧 `scope-guards` 多跑 1 条真机采样。`599 − 4 + 1 = 596`。