runHealthCheck(v7/src/health-check/index.js,序 5 执行点,改造)
├─ assembleBookStatus(既有,不动)
├─ style-stats(新模块 v7/src/style-stats/index.js,纯函数零依赖)
│ ├─ splitSentences / splitParagraphs 分句分段
│ ├─ styleMetrics(text, exclude) 句长方差/段落分布/高频开头(单章与章段通用)
│ ├─ extractImagery(chapters, exclude) 跨章高频意象(Apriori n-gram)
│ └─ extractFingerprint(chapters, exclude) 指纹对象(内部复用上两者)
├─ 缺时间锚点检查(chapters 表 + TimelineReader 比对)
├─ fingerprints 表 upsert(基线段 + 滚动近段两行)
├─ meta 写入:imagery_top JSON(新 key)+ last_health_check_chapter(既有)
└─ 体检报告.md 渲染(替换占位节)+ 结构化 data 返回(M6 对接面)
消费方(均为小改):
- v7/src/prep/index.js:97 反复读清单 ← meta.imagery_top
- v7/src/mechanical-check/index.js 新增 2 个候选检查 ← meta.imagery_top / fingerprints 基线行
- v7/src/commands/report-style-drift.js ← fingerprints(补句长方差 delta)
体检是低频全量计算(每 50 章一次),机检是每章轻量消费——统计只在体检算一次,机检读缓存结果,零重复计算。
纯函数、无 IO、无缓存依赖;全部固定排序纯计数,无时间戳无随机——AC3「删缓存重算逐字段一致」的根基。
splitSentences(text):按 /[。!?;…]+[”』」]?/ 切分,句 = 修剪后非空段;句长 = 去空白字符数splitParagraphs(text):按 /\n+/ 切分修剪非空(网文一段一换行约定)styleMetrics(text, exclude) → { 句数, 平均句长, 句长方差, 段落数, 平均段长, 段落分布, 高频开头 }
{短(≤50字), 中(51-150), 长(151-300), 超长(>300)} 各计数与占比[{开头, 次数, 占比}],排除名册专名/别名前缀("林晚…"是人名不是句式)extractImagery(chapters: [{num, text}], exclude: Set) → [{phrase, count, chapterCount}](全量,调用方取 top-N)
extractFingerprint(chapters, exclude) → cache-design §1.5 五常用列 + fingerprint_data 完整 JSON(另含段落分布、高频开头、总字数、章数)
common_phrase_frequency:本章段内 extractImagery(chapterCount 条件放宽为 ≥1,章段内统计)top10 {phrase: count}vocabulary_richness:滑动窗口 TTR——剥标点空白后按 1000 字窗口求 unique/窗长 的平均(末窗不足用实际长度);确定性且跨章段可比(朴素 unique/total 对文本长度敏感,不可比)文体基线起/止(默认 1/30)、体检周期(默认 50)SELECT id FROM entities + SELECT alias FROM entity_aliases(同机检 checkNewProperNouns 取法,index.js:111-118)file_path 逐章读定稿文件,parseFrontMatter 剥头取正文extractImagery → meta imagery_top = JSON top20extractFingerprint → INSERT OR REPLACE INTO fingerprints(基线行 is_baseline=1,近段行 =0);历史段行自然累积 = 指纹历史(cache-design §1.5 语义),同段重算覆盖story_time 空 → 缺「故事时间」;TimelineReader 读全部卷时间线表收集「章」列章号,定稿章不在其中 → 时间线漏行。报告列章号与缺因## 高频意象(跨章)、## 句式体检、## 文体指纹漂移(基线 vs 近段各指标 + delta + 人话建议:回拉文风或改 book.yaml 基线区间,脚本不自动改)、## 缺时间锚点(全齐报「无」){ ok, filePath, maxChapter, data, error },data = { 高频意象, 句式, 指纹: {基线, 近段, delta} | null, 缺时间锚点, 悬了太久, 条目活跃率, 连续弱钩 }——M6「体检不过线」阈值判定的对接面,本任务不判定走 candidates 通道({type, value, description})——pass = issues.length === 0(index.js:41)语义零改动,既有测试不动:
checkImageryHits(body, cache, candidates):读 meta imagery_top;无 → 静默跳过;草稿命中短语 → {type:'高频意象', value:短语, description:'「空气仿佛凝固」全书已用 47 次,本章又用 2 次,建议换个写法'}checkStyleDeviation(body, cache, candidates):读 fingerprints 基线行;无 → 静默跳过;本章 styleMetrics vs 基线,平均句长偏离 ≥30% 或句长方差偏离 ≥50%(硬编码默认,参数化已出 scope)→ {type:'句式偏离', ...} 人话描述偏了什么读 meta imagery_top:有 → top10 行「-「空气仿佛凝固」全书 47 次(12 章出现),本章避免再用」;无 → 「(尚未体检,暂无数据——首次体检后自动填充)」
delta 增加 sentence_length_variance_delta;命令契约与输出格式(JSON)不变。
rebuilder.js:49 注释改「fingerprints 由体检按需重算(M5.5),重建不填」——行为不变。
imagery_top:派生物,CacheManager 既有 meta 跨重建保留逻辑自动覆盖;丢失 → 备料/机检降级占位、下次体检重建——与「体检记录丢失重测无害」同一语义runHealthCheck 返回值增 data 字段(既有消费方 router.test.js:190 只断 ok 与 meta 效果,增字段无破坏)pass = issues.length===0 误判打回,除非改 pass 语义——不动契约是更小的爆炸半径is_baseline、基线丢失。约定重合时只写基线行,指纹节如实说明"重合暂无漂移可比"(写入约定已回填 cache-design §1.5)。story_time 列)。全部增量:一个新模块 + 五处既有文件小改,单 commit 粒度分期(见 implement.md),git revert 可整体回退;无数据迁移——meta key 与 fingerprints 行是派生物,回退后自然失效,无残留危害。