# tools —— 文本蒸馏流水线 把一份文本语料蒸馏成插件能读的 `style-profile.json`。零依赖,只需要 Node ≥ 22。 ```powershell node tools/distill.mjs stats --corpus <语料目录> --out stats.json node tools/distill.mjs merge --digests <分片目录> --corpus <语料目录> --stats stats.json --out style-profile.json node tools/verdict.mjs --corpus <语料目录> --text "要检查的文本" ``` ## 两轨 蒸馏分两轨,因为它们回答的问题不同: | 轨 | 命令 | 产出 | 性质 | |---|---|---|---| | **机算轨** | `stats` | `rhythm`、`punctuation`、高频短语、n-gram | **可复现的事实**。不需要读一遍就能算出来,也就不会因为模型的印象而写错 | | **人读轨** | `merge` | 情绪样本、句式模板、角色声线、风险清单 | 需要真的读。用分片精读(人工或 agent)产出 JSON digest,再合并 | **机算轨不是可有可无的。** 在一个真实项目里,凭印象写的风格档案把平均句长估成了 16 字, 实测是 28.2 字 —— 差了近一倍,节奏完全写反。数字必须来自全文。 ## 机算轨:`stats` ```powershell node tools/distill.mjs stats --corpus .\我的小说 --out stats.json ``` 输出:字符/句数统计、句长分布(平均/中位/P90/短句比/长句比)、标点频率(每千字)、 引号内容占比、高频短语、2/3/4-gram,以及标点的**定性档位**。 定性档位用**本语料自己的分布**标定,不是拍脑袋的绝对阈值: ```json "grade": { "ellipsis": "occasional", "exclamation": "occasional", "dash": "rare" } ``` 阈值偏保守(`frequent` 要 ≥8/千字)。这是有意的 —— 把感叹号标成 `frequent` 会让模型开始堆感叹号,宁可标低。 ## 人读轨:`merge` 分片精读的产物是一批 JSON digest。**注意:这一步需要你自己(或让 agent)真的读完文本**, 流水线只负责合并与校验。 ### digest 格式 ```jsonc { "range": "001-027", "chapterIndex": [{ "no": "001", "title": "……", "emotions": ["丧/颓"], "gist": "一句话情节" }], "emotionSamples": [ { "emotion": "丧/颓", "quote": "逐字原文 ≤40 字", "technique": "手法", "chapter": "003" } ], "templates": [{ "name": "…", "pattern": "可填充句式", "slots": ["A", "B"], "example": "…", "emotion": "…" }], "characterVoices": [{ "name": "…", "traits": [], "catchphrases": [], "examples": [] }], "lexicon": { "口语": [], "自嘲废柴": [], "抒情词": [] }, "styleFeatures": { "metaphorDomains": [], "sentencePatterns": [] }, "risks": ["内容风险记录"] } ``` 字段都可缺失,缺了就是空。分片粒度建议每片 25–45 章(或 5–8 万字): 再大切分质量下降,再小则跨片重复增多。 ### 两道必须的校验门 #### 保真门 —— 但「查不到」不等于「是假的」 这是这套流水线里最值得说的一件事。 分片者(哪怕是 agent)会报「我的引文 100% 逐字命中,0 mismatch」。跑一遍全量回溯, 通常会有 1% 左右对不上。但对不上的原因**有两类,处理方式完全相反**: | 类型 | 现象 | 处理 | |---|---|---| | **编造** | 全库都找不到对应 | **剔除** | | **位置标错** | 引文真实,只是标错了一章 | **改判归属,保留** | 在真实项目里,7 条「查不到」的引用中有 3 条属于第二类 —— 一律丢弃会白白损失真实素材。 所以保真门是两段式:先查声称的位置(允许 `1` / `01` / `001` 三种写法), 找不到再全库搜寻真实归属,**全库都没有才判编造**。 另外:**只抽查几十条是发现不了这个问题的**。1% 的错误率意味着抽查 60 条时大概率一条都碰不到。 必须全量回溯。 #### 安全门 —— 但硬拦截不能误杀 同样来自实战。第一版把「性」「吻」「睡」这类**单字**当硬拦截词,结果: - `{决定性一击}` 命中「性」→ 被拦 - `比如{A},比如{B}` 命中「吻」→ 被拦 17 条被拦的模板里只有 4 条真该拦。**误杀比漏放更难发现**,因为你只会看到产出变少。 现在分三级: - **`blocked`** —— 硬拦截。**只用多字、无歧义的词**(`献祭`、`魔鬼`、`割喉`…) - **`caution`** —— 降级标记。单字词在这里,允许使用但标出来供人工判断 - **`safe`** —— 其余 还有一条关键区分:**模板的生死只看模板本身,不看它的示例**。 示例只是证据,不会进提示词,不该决定模板存废。 词库另有一层过滤:限长 + 去脏话 + 去专名。因为词库要的是「助手能用的表达」, 不是原作品的台词。 ### 自定义词表 默认词表面向中文。其他语言请传自己的: ```jsonc // rules.json { "safety": { "hard": ["你", "的", "硬", "拦", "截", "词"], "caution": ["单", "字", "词"], "profanity": ["脏", "话"] }, "buckets": { "joy": ["你的情绪标签 A"], "sad": ["你的情绪标签 B"] }, "names": ["专", "名", "列", "表"] } ``` ```powershell node tools/distill.mjs merge --digests .\d --corpus .\c --rules rules.json --out p.json ``` `buckets` 决定情绪标签怎么映射到 `lexicon` 的分桶。 ## 输出侧护栏:`verdict` ```powershell node tools/verdict.mjs --corpus .\我的小说 --text "模型刚生成的这段话" node tools/verdict.mjs --corpus .\我的小说 --min-len 14 .\reply.txt Get-Content .\reply.txt -Raw | node tools/verdict.mjs --corpus .\我的小说 ``` 退出码:`0` 通过,`1` 有违规重合,`2` 参数错误 —— 方便接进 CI。 **为什么默认阈值是 12 个字符**:短的重合是通用语言(「我靠」「不知道」),不该算违规。 在 65 万字的语料上实测,12-gram 有 **99.8% 只出现一次** —— 说明「罕见度」判据几乎恒真, **真正起作用的判据就是长度**。所以本实现只看长度,不引入词频权重,行为更容易解释。 我拿两段对照验证过:按某位作者的笔法**仿写**的文字 → 0 命中;原文段落 → 全部抓到。 ## 测试 ```powershell node tools/test/run.mjs ``` 14 项。其中三项是**把上面的教训写成回归测试**: - 单字不许硬拦截(`{决定性一击}` 必须放过) - 仿写不许误报(负例对照) - 位置标错必须改判,编造必须剔除 ## 已知限制 - **哈希碰撞**:n-gram 索引存的是 32 位哈希以控制内存(65 万字约 3 MB)。 理论上存在碰撞导致的极小概率误报。对「护栏」这个用途可以接受,但它不是密码学级别的判定。 - **切句规则**是启发式的,中英混排或特殊排版可能需要调整。 - **词表是中文默认值**。其他语言必须自己提供,否则安全门形同虚设。 - **不生成 `paragraphMaxSentences`**:很多文本(尤其是每章一个长段落的整理稿) 没有真实的段落粒度,实测不出来。这个字段请按你的排版自己定。