@waterwx/dsh-novel-forge 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -95,6 +95,569 @@ function alignPlotlineProgress(items, lines) {
95
95
  }));
96
96
  }
97
97
  //#endregion
98
+ //#region src/ai-scan.ts
99
+ /**
100
+ * 命中模式 -> 它在「已发布书稿语料」中被多少比例的章节命中过。
101
+ *
102
+ * ## 这张表是什么
103
+ *
104
+ * 它是**度量基线**,不是观点:覆盖率越高,说明该写法越接近作者/题材的常态表达,
105
+ * 越不适合当作 AI 味证据。覆盖率 >= 50% 记权重 0(视为功能词),0% 记 1。
106
+ *
107
+ * ## 模式从哪来
108
+ *
109
+ * 并集共 **63 个** = 反 AI 规则里作者声明的 detectPatterns(42 个)+ 度量基线词表。
110
+ * 实测「最后」(72.3%)、「然后」(70.1%) 的覆盖率比「微微」(48.2%) 还高 —— 它们是中文
111
+ * 叙事的正常连接词,权重为 0;而「生活就是」「归根结底」「人总要学会」这类结构性升华句
112
+ * 覆盖率是 **0.000**,才是真正的高价值信号。原先的实现根本没在看后者。
113
+ *
114
+ * ## 与规则的关系(刻意解耦)
115
+ *
116
+ * aiScore 是**独立标定的测量**:用户删掉一条规则,并不会让正文的 AI 味变少,
117
+ * 所以度量不依赖规则的当前状态。规则命中另走 {@link AiScanResult.ruleHits},
118
+ * 按作者**生效的规则**逐条确定性统计。两者都注入审稿,但互不污染。
119
+ *
120
+ * 覆盖率为 0 的 17 个模式也显式列出,避免「表里没有 = 没在看」的误读。
121
+ * 更换语料或题材后应重新统计。
122
+ */
123
+ const PATTERN_COVERAGE = {
124
+ "知道": .784,
125
+ "最后": .723,
126
+ "然后": .701,
127
+ "微微": .482,
128
+ "轻轻": .481,
129
+ "感觉": .449,
130
+ "气息": .392,
131
+ "终于": .306,
132
+ "接着": .304,
133
+ "缓缓": .303,
134
+ "嘴角": .292,
135
+ "眼神": .174,
136
+ "明白": .167,
137
+ "眉头": .145,
138
+ "似乎": .144,
139
+ "身影": .142,
140
+ "力量": .127,
141
+ "光芒": .087,
142
+ "好像": .084,
143
+ "仿佛": .075,
144
+ "其实": .07,
145
+ "显然": .068,
146
+ "脑海": .05,
147
+ "这就是": .045,
148
+ "告诉你": .041,
149
+ "然而": .027,
150
+ "默默": .025,
151
+ "这说明": .02,
152
+ "一时间": .019,
153
+ "他感到": .011,
154
+ "这意味着": .011,
155
+ "心中": .01,
156
+ "脑海中": .008,
157
+ "不由自主": .007,
158
+ "顿时": .005,
159
+ "不禁": .005,
160
+ "说到底": .005,
161
+ "其实就是": .005,
162
+ "心里想": .005,
163
+ "他明白了": .003,
164
+ "无法形容": .002,
165
+ "他意识到": .002,
166
+ "她意识到": .002,
167
+ "她明白了": .002,
168
+ "宛如": .002,
169
+ "不由得": .001,
170
+ "她感到": 0,
171
+ "生活就是": 0,
172
+ "命运总会": 0,
173
+ "归根结底": 0,
174
+ "我们都应该": 0,
175
+ "人总要学会": 0,
176
+ "真正重要的是": 0,
177
+ "我们现在要": 0,
178
+ "接下来就": 0,
179
+ "首先": 0,
180
+ "心中暗道": 0,
181
+ "暗自思忖": 0,
182
+ "心中暗想": 0,
183
+ "心里暗道": 0,
184
+ "犹如": 0,
185
+ "好似": 0,
186
+ "难以言喻": 0
187
+ };
188
+ /** 覆盖率 -> 权重:覆盖率 >= 50% 记为功能词(权重 0),0% 记 1。 */
189
+ function patternWeight(coverage) {
190
+ return Math.max(0, 1 - coverage / .5);
191
+ }
192
+ /**
193
+ * 对话引号:ASCII 双引号 + 全角弯引号(中文书稿主流)+ 单弯引号 + 角括号。
194
+ * 历史实现只认第一种与后两种,漏掉弯引号 —— 那正是 5/6 本书在用的。
195
+ */
196
+ const DIALOGUE_PATTERN = /(?:"[^"]*"|\u201C[^\u201D]*\u201D|\u2018[^\u2019]*\u2019|\u300C[^\u300D]*\u300D|\u300E[^\u300F]*\u300F)/g;
197
+ /**
198
+ * 对话字符数(含引号本身)。
199
+ *
200
+ * 导出供写法指纹复用:两处必须同一口径,否则「对话占比」在两个模块里会给出不同的数。
201
+ * @param text - 任意正文。
202
+ * @returns 对话部分的总字符数。
203
+ */
204
+ function dialogueCharsOf(text) {
205
+ return (text.match(DIALOGUE_PATTERN) ?? []).join("").length;
206
+ }
207
+ /** 解释性叙事开头模式 */
208
+ const EXPOSITORY_STARTS = [
209
+ "原来",
210
+ "因为",
211
+ "由于",
212
+ "所以",
213
+ "因此",
214
+ "于是",
215
+ "这就是",
216
+ "也就是说",
217
+ "换句话说",
218
+ "事实上",
219
+ "实际上"
220
+ ];
221
+ /**
222
+ * 分项满分与语料分位点的对应关系(重新标定时只改这里)。
223
+ *
224
+ * - clicheFullDensity 2.478 = 语料加权密度 p99(63 模式并集口径);p50 为 0.61、p75 为 1.02
225
+ * - cvTooUniform 0.45 低于语料 CV p5(0.55),即只把最整齐的 0.6% 判为问题
226
+ * - repetitionZeroAt 0.37 = 语料句首重复率 p50;repetitionFullAt 0.63 = p99
227
+ * (中文「句首二字」天然高频,原来的固定阈值 0.15 几乎每章都命中、形同虚设)
228
+ */
229
+ const SCORE_SPEC = {
230
+ clicheMax: 40,
231
+ clicheFullDensity: 2.478,
232
+ cvTooUniform: .45,
233
+ cvMax: 15,
234
+ expositoryMax: 20,
235
+ expositoryPerParagraph: 5,
236
+ repetitionMax: 15,
237
+ repetitionZeroAt: .37,
238
+ repetitionFullAt: .63,
239
+ lowDialogueThreshold: .05,
240
+ lowDialogueMax: 10,
241
+ densityFloorChars: 2e3,
242
+ /**
243
+ * 规则命中的区分度门槛:只报「对该作者而言不常见」的模式。
244
+ *
245
+ * 实测必要性:「句式重复率偏高」这条规则的 detectPatterns 是
246
+ * ['首先','然后','接着','最后'],而「最后」覆盖 72.3%、「然后」覆盖 70.1% 的已发布
247
+ * 章节 —— 不设门槛时它会在 **94.4%** 的章上命中,等于每章都告诉模型「你违反了这条
248
+ * 规则」,比不报还糟。该规则的本质是「比率偏高」,应由 sentenceRepetitionRate 度量。
249
+ */
250
+ ruleHitCoverageMax: .1
251
+ };
252
+ /**
253
+ * 按真实书稿的换行形态切自然段:单换行分隔,剔除 # 标题行。
254
+ * @param text - 章节正文(可含标题行)。
255
+ * @returns 自然段数组(已 trim、去空)。
256
+ */
257
+ function paragraphsOf(text) {
258
+ return text.split(/\n+/).map((p) => p.trim()).filter((p) => p.length > 0 && !/^#{1,6}\s/.test(p));
259
+ }
260
+ /** 子串出现次数(不重叠)。 */
261
+ function countOccurrences$1(text, word) {
262
+ if (word.length === 0) return 0;
263
+ let count = 0;
264
+ let at = text.indexOf(word);
265
+ while (at !== -1) {
266
+ count += 1;
267
+ at = text.indexOf(word, at + word.length);
268
+ }
269
+ return count;
270
+ }
271
+ /** 把 x 线性映射到 [0, 1] 并夹逼。 */
272
+ function ratio(x, zeroAt, fullAt) {
273
+ if (fullAt === zeroAt) return 0;
274
+ return Math.max(0, Math.min(1, (x - zeroAt) / (fullAt - zeroAt)));
275
+ }
276
+ function scanAiFlavor(text, rules) {
277
+ const paragraphs = paragraphsOf(text);
278
+ const totalChars = text.length;
279
+ const clicheHits = [];
280
+ let weightedTotal = 0;
281
+ for (const word of Object.keys(PATTERN_COVERAGE)) {
282
+ const count = countOccurrences$1(text, word);
283
+ if (count === 0) continue;
284
+ const weight = patternWeight(PATTERN_COVERAGE[word] ?? 0);
285
+ clicheHits.push({
286
+ word,
287
+ count,
288
+ weight
289
+ });
290
+ weightedTotal += count * weight;
291
+ }
292
+ clicheHits.sort((a, b) => b.count * b.weight - a.count * a.weight);
293
+ const ruleHits = [];
294
+ for (const rule of rules ?? []) {
295
+ const matches = [];
296
+ for (const pattern of rule.detectPatterns ?? []) {
297
+ const count = countOccurrences$1(text, pattern);
298
+ if (count === 0) continue;
299
+ if ((PATTERN_COVERAGE[pattern] ?? 0) >= SCORE_SPEC.ruleHitCoverageMax) continue;
300
+ matches.push({
301
+ pattern,
302
+ count
303
+ });
304
+ }
305
+ if (matches.length > 0) ruleHits.push({
306
+ name: rule.name,
307
+ severity: rule.severity,
308
+ matches
309
+ });
310
+ }
311
+ const paraLengths = paragraphs.map((p) => p.length);
312
+ const avgLen = paraLengths.length > 0 ? paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length : 0;
313
+ const variance = paraLengths.length > 0 ? paraLengths.reduce((sum, len) => sum + Math.pow(len - avgLen, 2), 0) / paraLengths.length : 0;
314
+ const cv = avgLen > 0 ? Math.sqrt(variance) / avgLen : 0;
315
+ let maxConsecutive = 0;
316
+ let currentConsecutive = 0;
317
+ for (const p of paragraphs) if (EXPOSITORY_STARTS.some((s) => p.startsWith(s)) || p.length > 150 && !/["\u201C\u300C\u300E]/.test(p)) {
318
+ currentConsecutive++;
319
+ maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
320
+ } else currentConsecutive = 0;
321
+ const sentences = text.split(/[。!?!?]/).map((s) => s.trim()).filter((s) => s.length > 0);
322
+ const starterCounts = /* @__PURE__ */ new Map();
323
+ for (const s of sentences) {
324
+ const starter = s.slice(0, 2);
325
+ starterCounts.set(starter, (starterCounts.get(starter) ?? 0) + 1);
326
+ }
327
+ const repeatedStarters = Array.from(starterCounts.values()).filter((c) => c >= 3).reduce((a, b) => a + b, 0);
328
+ const sentenceRepetitionRate = sentences.length > 0 ? repeatedStarters / sentences.length : 0;
329
+ const longParagraphCount = paraLengths.filter((l) => l > 300).length;
330
+ const shortParagraphCount = paraLengths.filter((l) => l < 20).length;
331
+ const dialogueChars = dialogueCharsOf(text);
332
+ const dialogueRatio = totalChars > 0 ? dialogueChars / totalChars : 0;
333
+ const densityDenom = Math.max(totalChars, SCORE_SPEC.densityFloorChars);
334
+ const clicheDensity = weightedTotal / densityDenom * 1e3;
335
+ const scoreParts = {
336
+ cliche: Math.min(SCORE_SPEC.clicheMax, clicheDensity / SCORE_SPEC.clicheFullDensity * SCORE_SPEC.clicheMax),
337
+ uniformParagraphs: paragraphs.length >= 8 && cv < SCORE_SPEC.cvTooUniform ? SCORE_SPEC.cvMax : 0,
338
+ expository: Math.min(SCORE_SPEC.expositoryMax, maxConsecutive * SCORE_SPEC.expositoryPerParagraph),
339
+ repetition: SCORE_SPEC.repetitionMax * ratio(sentenceRepetitionRate, SCORE_SPEC.repetitionZeroAt, SCORE_SPEC.repetitionFullAt),
340
+ lowDialogue: dialogueRatio < SCORE_SPEC.lowDialogueThreshold && totalChars > 1e3 ? SCORE_SPEC.lowDialogueMax : 0
341
+ };
342
+ const aiScore = Math.min(100, Math.round(scoreParts.cliche + scoreParts.uniformParagraphs + scoreParts.expository + scoreParts.repetition + scoreParts.lowDialogue));
343
+ const issues = [];
344
+ if (clicheDensity > 1) {
345
+ const top = clicheHits.filter((h) => h.weight > 0).slice(0, 5).map((h) => h.word + "x" + h.count).join("、");
346
+ issues.push("套话/模板句偏多(按语料稀有度加权 " + clicheDensity.toFixed(2) + "/千字,p50 为 0.61):" + top);
347
+ }
348
+ if (scoreParts.uniformParagraphs > 0) issues.push("段落长度过于整齐(CV " + cv.toFixed(2) + " < " + SCORE_SPEC.cvTooUniform + ",已发布语料 p5 为 0.55)");
349
+ if (maxConsecutive >= 3) issues.push("连续 " + maxConsecutive + " 段解释性叙事,缺少对话/动作");
350
+ if (sentenceRepetitionRate > .5) issues.push("句式重复率 " + (sentenceRepetitionRate * 100).toFixed(0) + "%,开头句式单一");
351
+ if (longParagraphCount > 3) issues.push(longParagraphCount + " 段超过 300 字,段落过长");
352
+ if (scoreParts.lowDialogue > 0) issues.push("对话占比过低(" + (dialogueRatio * 100).toFixed(1) + "%),整章偏叙述");
353
+ if (ruleHits.length > 0) {
354
+ const brief = ruleHits.slice(0, 5).map((h) => h.name + "(" + h.matches.map((m) => m.pattern + "x" + m.count).join("、") + ")").join(";");
355
+ issues.push("确定性规则命中 " + ruleHits.length + " 条:" + brief);
356
+ }
357
+ const head = "本地 AI 味扫描(AI 味指数 " + aiScore + "/100,共 " + paragraphs.length + " 个自然段)";
358
+ const summary = issues.length > 0 ? head + ":\n" + issues.map((i) => "- " + i).join("\n") : head + ":未发现明显问题。";
359
+ return {
360
+ aiScore,
361
+ clicheHits,
362
+ paragraphCount: paragraphs.length,
363
+ paragraphLengthVariance: Math.round(variance),
364
+ paragraphLengthCv: Math.round(cv * 100) / 100,
365
+ consecutiveExpositoryParagraphs: maxConsecutive,
366
+ sentenceRepetitionRate: Math.round(sentenceRepetitionRate * 1e3) / 1e3,
367
+ longParagraphCount,
368
+ shortParagraphCount,
369
+ dialogueRatio: Math.round(dialogueRatio * 100) / 100,
370
+ ruleHits,
371
+ scoreParts,
372
+ summary
373
+ };
374
+ }
375
+ //#endregion
376
+ //#region src/style-fingerprint.ts
377
+ /**
378
+ * 写法资产的确定性风格指纹(离线)。
379
+ *
380
+ * ## 为什么需要它
381
+ *
382
+ * 写法资产(StyleAsset)目前只有一堆由 LLM 写出来的**文字规则** —— proseRules /
383
+ * dialogueRules / descriptionRules。生成时整段注入,润色时要求「不得丢失」,
384
+ * 但**全书没有任何一处检查成稿是否符合**:规则是定性描述,模型每次理解都可能不同,
385
+ * 作者也无从判断「这一章到底偏离了多少」。
386
+ *
387
+ * 这里把风格拆成**可算的指标**:句均长、句长起伏、短句占比、对话占比、段落均长、
388
+ * 意象密度。同一套指标两头用:
389
+ *
390
+ * 1. **生成端**当数字目标 —— 「句均长 18-24 字、短句占比 >= 35%」比「句式凝练」可执行得多;
391
+ * 2. **审稿端**做符合度比对 —— 拿本章指标与写法样本的指标对照,给出偏差事实。
392
+ *
393
+ * ## 与 AI 味扫描的关系
394
+ *
395
+ * 两者共用段落与引号的**口径**(从 ai-scan.ts 引入,避免又出现两份拷贝),但目的相反:
396
+ * ai-scan 抓「像 AI 写的」,这里算「像不像你绑定的写法」。同一份文本可以两者都算。
397
+ *
398
+ * ## 为什么从样本文本算,而不是再让 LLM 描述一遍
399
+ *
400
+ * StyleAsset.sourceText 已经把提取时用的样本持久化了(routes.ts 截前 3000 字)。
401
+ * 从**同一份样本**算出的指标是确定的、可复算的、可解释的;再让模型「描述一下风格」,
402
+ * 只会得到又一段无法校验的文字。
403
+ */
404
+ /**
405
+ * 各指标的容忍度 —— 取自**真实分布**的 p90,即每项指标约 10% 的章会被判偏离。
406
+ *
407
+ * 标定方式:对 6 本书各自取「第 1 章前 3000 字」当样本,算同书其余各章相对样本的
408
+ * 相对偏差,取 p90。**手工拍的容忍度在两个方向上都错了**:
409
+ *
410
+ * - 对话占比:实测偏差 p50 就有 0.539、p90 达 1.25。原先定 0.50 —— 几乎每章都命中。
411
+ * - 句长起伏:实测 p90 只有 0.159。原先定 0.35 —— 几乎不触发。
412
+ *
413
+ * 换语料或换题材后应重新标定(脚本见 CHANGELOG 2.1.3)。
414
+ */
415
+ const TOLERANCE = {
416
+ avgSentenceLength: .42,
417
+ sentenceLengthCv: .16,
418
+ shortSentenceRatio: .66,
419
+ dialogueRatio: 1.25,
420
+ avgParagraphLength: .58,
421
+ imageryPer1000: 1.39
422
+ };
423
+ /**
424
+ * 只用绝对阈值的指标(阈值单位同该指标本身)。
425
+ *
426
+ * 意象密度必须走这条:实测同一本书内**多数章节根本没有意象标记词**,相对偏差恒为
427
+ * -100%,完全退化;而绝对差 p90 = 1.39 次/千字是有区分度的。
428
+ */
429
+ const TARGET_ABSOLUTE_ONLY = /* @__PURE__ */ new Set(["imageryPer1000"]);
430
+ /**
431
+ * 判定用的**绝对下限**(单位同指标;0 = 不用下限,只看相对偏差)。
432
+ *
433
+ * 为什么需要它:参照值接近 0 时相对容忍度会退化。实测《归墟玉主》——中位数基线的对话
434
+ * 占比约 0.5%,于是一章写到 3% 就是「+500%」,误报从 115 章涨到 175 章;而 2.5 个百分
435
+ * 点在创作上毫无意义。**参照值可能接近 0 的指标**(两个比率 + 意象密度)必须叠加绝对下限。
436
+ *
437
+ * 取值 = 实测(1279 章)各指标绝对偏差的 p90:短句占比 0.198、对话占比 0.164、意象密度 1.39。
438
+ * 句均长 / 段落均长 / 句长起伏的参照值结构性不可能接近 0,保持纯相对判定。
439
+ */
440
+ const DEVIATION_FLOOR = {
441
+ avgSentenceLength: 0,
442
+ sentenceLengthCv: 0,
443
+ shortSentenceRatio: .198,
444
+ dialogueRatio: .164,
445
+ avgParagraphLength: 0,
446
+ imageryPer1000: 1.39
447
+ };
448
+ /** 指标中文名(提示词里直接用)。 */
449
+ const LABEL = {
450
+ avgSentenceLength: "句均长",
451
+ sentenceLengthCv: "句长起伏",
452
+ shortSentenceRatio: "短句占比",
453
+ dialogueRatio: "对话占比",
454
+ avgParagraphLength: "段落均长",
455
+ imageryPer1000: "意象密度"
456
+ };
457
+ /** 意象/比喻标记词(描写密度的确定性代理,不做语义判断)。 */
458
+ const IMAGERY_MARKERS = [
459
+ "仿佛",
460
+ "好像",
461
+ "如同",
462
+ "犹如",
463
+ "宛如",
464
+ "好似",
465
+ "似的"
466
+ ];
467
+ /** 短句阈值(字符)。 */
468
+ const SHORT_SENTENCE_CHARS = 12;
469
+ /** 低于此字符数时指标没有统计意义,一律返回零值而不是噪声。 */
470
+ const MIN_CHARS = 200;
471
+ /** 子串出现次数(不重叠)。 */
472
+ function countOccurrences(text, word) {
473
+ if (word.length === 0) return 0;
474
+ let count = 0;
475
+ let at = text.indexOf(word);
476
+ while (at !== -1) {
477
+ count += 1;
478
+ at = text.indexOf(word, at + word.length);
479
+ }
480
+ return count;
481
+ }
482
+ /** 四舍五入到 n 位小数。 */
483
+ function round(value, digits) {
484
+ const factor = Math.pow(10, digits);
485
+ return Math.round(value * factor) / factor;
486
+ }
487
+ /** 空指纹(文本过短时返回,避免用噪声做风格判断)。 */
488
+ function emptyFingerprint(chars) {
489
+ return {
490
+ chars,
491
+ sentences: 0,
492
+ avgSentenceLength: 0,
493
+ sentenceLengthCv: 0,
494
+ shortSentenceRatio: 0,
495
+ dialogueRatio: 0,
496
+ avgParagraphLength: 0,
497
+ imageryPer1000: 0
498
+ };
499
+ }
500
+ /**
501
+ * 从文本抽取风格指纹。
502
+ * @param text - 任意正文(样本文本或章节正文;调用方需先剥掉标题行)。
503
+ * @returns 风格指标;文本短于 {@link MIN_CHARS} 时返回零值。
504
+ */
505
+ function extractStyleFingerprint(text) {
506
+ const chars = text.length;
507
+ if (chars < MIN_CHARS) return emptyFingerprint(chars);
508
+ const sentences = text.split(/[。!?!?…]/).map((s) => s.trim()).filter((s) => s.length > 0);
509
+ const lens = sentences.map((s) => s.length);
510
+ const avg = lens.length > 0 ? lens.reduce((a, b) => a + b, 0) / lens.length : 0;
511
+ const variance = lens.length > 0 ? lens.reduce((sum, l) => sum + Math.pow(l - avg, 2), 0) / lens.length : 0;
512
+ const cv = avg > 0 ? Math.sqrt(variance) / avg : 0;
513
+ const shortRatio = lens.length > 0 ? lens.filter((l) => l <= SHORT_SENTENCE_CHARS).length / lens.length : 0;
514
+ const paragraphs = paragraphsOf(text);
515
+ const paraAvg = paragraphs.length > 0 ? paragraphs.reduce((a, p) => a + p.length, 0) / paragraphs.length : 0;
516
+ let imagery = 0;
517
+ for (const marker of IMAGERY_MARKERS) imagery += countOccurrences(text, marker);
518
+ return {
519
+ chars,
520
+ sentences: sentences.length,
521
+ avgSentenceLength: round(avg, 1),
522
+ sentenceLengthCv: round(cv, 2),
523
+ shortSentenceRatio: round(shortRatio, 3),
524
+ dialogueRatio: round(dialogueCharsOf(text) / chars, 3),
525
+ avgParagraphLength: round(paraAvg, 1),
526
+ imageryPer1000: round(imagery / chars * 1e3, 2)
527
+ };
528
+ }
529
+ /**
530
+ * 比对两份指纹。
531
+ *
532
+ * 参照值为 0 的指标直接跳过 —— 例如样本里本来就没有对话,此时「相对偏差」没有意义,
533
+ * 硬算会得到无穷大,反而制造假警报。
534
+ * @param reference - 参照指纹(写法样本)。
535
+ * @param actual - 本章指纹。
536
+ * @returns 每个可比指标一条结果(含是否显著)。
537
+ */
538
+ function compareStyleFingerprint(reference, actual) {
539
+ if (reference.chars < MIN_CHARS) return [];
540
+ if (actual.sentences === 0) return [];
541
+ const out = [];
542
+ for (const metric of Object.keys(TOLERANCE)) {
543
+ const ref = reference[metric];
544
+ if (ref <= 0) continue;
545
+ const act = actual[metric];
546
+ const relative = (act - ref) / ref;
547
+ out.push({
548
+ metric,
549
+ label: LABEL[metric],
550
+ reference: ref,
551
+ actual: act,
552
+ relative: round(relative, 3),
553
+ direction: relative >= 0 ? "higher" : "lower",
554
+ significant: Math.abs(act - ref) > Math.max(TOLERANCE[metric] * ref, DEVIATION_FLOOR[metric])
555
+ });
556
+ }
557
+ return out;
558
+ }
559
+ /** 取中位数。
560
+ * @param values - 数值数组。
561
+ * @returns 中位数;空数组返回 0。
562
+ */
563
+ function median(values) {
564
+ if (values.length === 0) return 0;
565
+ const sorted = [...values].sort((a, b) => a - b);
566
+ const mid = Math.floor(sorted.length / 2);
567
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
568
+ }
569
+ /** 指标的小数位数(与 extractStyleFingerprint 的取整保持一致)。 */
570
+ function digitsOf(metric) {
571
+ if (metric === "sentenceLengthCv" || metric === "imageryPer1000") return 2;
572
+ if (metric === "shortSentenceRatio" || metric === "dialogueRatio") return 3;
573
+ return 1;
574
+ }
575
+ /**
576
+ * 合并多份指纹:逐指标取**中位数**。
577
+ *
578
+ * 为什么是中位数而不是平均值:单章指标会被极端章拉偏(一个通篇无对话的章能把平均对话
579
+ * 占比拽下去),中位数对离群值不敏感。这也正是「用本书已过审章节当中位数基线」比
580
+ * 「用单一样本」更稳的原因 —— 样本自身长什么样会整个混进参照里。
581
+ * @param items - 多章指纹(过短的会被剔除,见 sentences === 0)。
582
+ * @returns 合并后的参照指纹;没有可用输入时返回零值指纹。
583
+ */
584
+ function mergeFingerprints(items) {
585
+ const usable = items.filter((fp) => fp.sentences > 0);
586
+ if (usable.length === 0) return emptyFingerprint(0);
587
+ if (usable.length === 1) return usable[0];
588
+ const pick = (metric) => round(median(usable.map((fp) => fp[metric])), digitsOf(metric));
589
+ return {
590
+ chars: usable.reduce((sum, fp) => sum + fp.chars, 0),
591
+ sentences: usable.reduce((sum, fp) => sum + fp.sentences, 0),
592
+ avgSentenceLength: pick("avgSentenceLength"),
593
+ sentenceLengthCv: pick("sentenceLengthCv"),
594
+ shortSentenceRatio: pick("shortSentenceRatio"),
595
+ dialogueRatio: pick("dialogueRatio"),
596
+ avgParagraphLength: pick("avgParagraphLength"),
597
+ imageryPer1000: pick("imageryPer1000")
598
+ };
599
+ }
600
+ /** 按指标类型格式化数值(比率型用百分比)。 */
601
+ function formatMetric(metric, value) {
602
+ if (metric === "dialogueRatio" || metric === "shortSentenceRatio") return (value * 100).toFixed(1) + "%";
603
+ if (metric === "sentenceLengthCv" || metric === "imageryPer1000") return value.toFixed(2);
604
+ return value.toFixed(1);
605
+ }
606
+ /**
607
+ * 渲染**生成端**的风格目标(区间形式)。
608
+ * @param reference - 写法样本的指纹。
609
+ * @returns 可注入写作 prompt 的文本;样本过短时返回空串。
610
+ */
611
+ function renderFingerprintTargets(reference) {
612
+ if (reference.chars < MIN_CHARS) return "";
613
+ const items = [];
614
+ const add = (metric, suffix) => {
615
+ const value = reference[metric];
616
+ const tolerance = TOLERANCE[metric];
617
+ if (TARGET_ABSOLUTE_ONLY.has(metric)) {
618
+ if (value <= 0) return;
619
+ items.push("- " + LABEL[metric] + ":0–" + formatMetric(metric, value + tolerance) + suffix);
620
+ return;
621
+ }
622
+ const lo = Math.max(0, value * (1 - tolerance));
623
+ const hi = value * (1 + tolerance);
624
+ items.push("- " + LABEL[metric] + ":" + formatMetric(metric, lo) + "–" + formatMetric(metric, hi) + suffix);
625
+ };
626
+ add("avgSentenceLength", " 字");
627
+ add("sentenceLengthCv", "(越大越参差)");
628
+ add("shortSentenceRatio", "(<=12 字)");
629
+ add("dialogueRatio", "");
630
+ add("avgParagraphLength", " 字");
631
+ add("imageryPer1000", " 次/千字");
632
+ if (items.length === 0) return "";
633
+ return [
634
+ "==================== 风格目标(确定性指标,取自绑定的写法样本) ====================",
635
+ "本章正文应落在这几个区间内。不是硬性红线,但明显越界会被审稿判为偏离绑定写法:",
636
+ ...items
637
+ ].join("\n");
638
+ }
639
+ /**
640
+ * 渲染**审稿端**的风格比对(事实锚点)。
641
+ *
642
+ * 没有显著偏差时会**明确写出「没有可量化的风格偏离」**——否则模型会为了凑维度而
643
+ * 凭印象报一条「文风不符」,那正是这类审查最容易产出的假问题。
644
+ * @param reference - 写法样本的指纹。
645
+ * @param actual - 本章指纹。
646
+ * @param deviations - {@link compareStyleFingerprint} 的结果。
647
+ * @returns 可注入审稿 prompt 的文本;样本过短时返回空串。
648
+ */
649
+ function renderFingerprintComparison(reference, actual, deviations, sourceLabel) {
650
+ if (reference.chars < MIN_CHARS || actual.sentences === 0) return "";
651
+ const lines = ["==================== 风格指纹比对(确定性事实) ====================", "参照物:" + (sourceLabel ?? "绑定写法资产的样本文本") + "(" + reference.chars + " 字);本章正文 " + actual.chars + " 字。"];
652
+ const significant = deviations.filter((d) => d.significant);
653
+ if (significant.length === 0) lines.push("各项指标都在容忍度内:**本章没有可量化的风格偏离**。不要凭印象报「文风不符」。");
654
+ else {
655
+ lines.push("以下指标超出容忍度(按此判定偏离,不要额外猜测):");
656
+ for (const d of significant) lines.push("- " + d.label + ":参照 " + formatMetric(d.metric, d.reference) + " → 本章 " + formatMetric(d.metric, d.actual) + "(" + (d.relative >= 0 ? "+" : "") + (d.relative * 100).toFixed(0) + "%," + (d.direction === "higher" ? "偏高" : "偏低") + ")");
657
+ }
658
+ return lines.join("\n");
659
+ }
660
+ //#endregion
98
661
  //#region src/assets.ts
99
662
  /** 预置叙事风格模板(8 套,一键绑定无需样本文本)。 */
100
663
  const BUILTIN_STYLE_TEMPLATES = [
@@ -2939,495 +3502,231 @@ const BUILTIN_PLOT_BEATS = [
2939
3502
  position: "中期/后期",
2940
3503
  preconditions: ["身份有可埋伏的线索", "揭开时机能引爆情绪"],
2941
3504
  payoffSource: ["认知颠覆", "立场反转"],
2942
- combos: ["打脸", "误会解开"],
2943
- taboos: ["毫无伏笔硬揭", "身份设定无意义"],
2944
- applicableGenres: [
2945
- "悬疑",
2946
- "古言",
2947
- "玄幻"
2948
- ]
2949
- }
2950
- ];
2951
- /** 默认(空)项目写作资产。 */
2952
- function emptyProjectAssets() {
2953
- return {
2954
- auxiliaryProgressions: [],
2955
- antiAiRules: [],
2956
- styleAssets: []
2957
- };
2958
- }
2959
- /** 合并项目资产与内置库:返回「生效的反 AI 规则」(内置全局 + 项目自定义)。 */
2960
- function effectiveAntiAiRules(assets) {
2961
- const custom = assets?.antiAiRules ?? [];
2962
- const keyOf = (r) => r.key ?? r.name;
2963
- const customKeys = new Set(custom.filter((r) => keyOf(r) !== "").map(keyOf));
2964
- return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customKeys.has(keyOf(r))), ...custom];
2965
- }
2966
- /** 内置库种子化 upsert(对齐上游 SystemResourceBootstrapService 精神)。
2967
- * missing_only 仅补齐缺失的全局基线规则;sync_existing 还会按 key 刷新已内置规则的结构化字段。 */
2968
- function ensureBuiltinAssets(assets, mode = "missing_only") {
2969
- const base = assets ?? emptyProjectAssets();
2970
- const keyOf = (r) => r.key ?? r.name;
2971
- const existing = base.antiAiRules ?? [];
2972
- const byKey = new Map(existing.map((r) => [keyOf(r), r]));
2973
- const out = [];
2974
- let changed = false;
2975
- for (const r of existing) {
2976
- const builtin = BUILTIN_ANTI_AI_RULES.find((b) => (b.key ?? b.name) === keyOf(r));
2977
- if (mode === "sync_existing" && builtin !== void 0) {
2978
- const refreshed = {
2979
- ...builtin,
2980
- name: r.name,
2981
- avoid: r.avoid,
2982
- fix: r.fix,
2983
- detectPatterns: r.detectPatterns ?? builtin.detectPatterns,
2984
- enabled: r.enabled ?? builtin.enabled,
2985
- scope: r.scope
2986
- };
2987
- out.push(refreshed);
2988
- if (JSON.stringify(refreshed) !== JSON.stringify(r)) changed = true;
2989
- byKey.set(keyOf(r), refreshed);
2990
- } else out.push(r);
2991
- }
2992
- for (const b of BUILTIN_ANTI_AI_RULES) if (b.globalBaselineEnabled === true && !byKey.has(b.key ?? b.name)) {
2993
- out.push(b);
2994
- byKey.set(b.key ?? b.name, b);
2995
- changed = true;
2996
- }
2997
- if (!changed) return base;
2998
- return {
2999
- ...base,
3000
- antiAiRules: out
3001
- };
3002
- }
3003
- /** 把生效规则渲染成提示词块(禁止/风险/鼓励三档分列,压缩省 token)。 */
3004
- function renderAntiAiRules(assets) {
3005
- const rules = effectiveAntiAiRules(assets).filter((r) => r.enabled !== false);
3006
- if (rules.length === 0) return "";
3007
- const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
3008
- const category = (r) => {
3009
- if (r.severity === "encourage" || r.name.startsWith("鼓励") || r.avoid.startsWith("(鼓励类)")) return "encourage";
3010
- if (r.severity === "risk") return "risk";
3011
- return "forbidden";
3012
- };
3013
- const instruction = (r) => r.promptInstruction !== void 0 && r.promptInstruction !== "" ? r.promptInstruction : r.avoid;
3014
- const forbidden = rules.filter((r) => category(r) === "forbidden");
3015
- const risk = rules.filter((r) => category(r) === "risk");
3016
- const encourage = rules.filter((r) => category(r) === "encourage");
3017
- const editLine = (r) => r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : "";
3018
- const lines = [];
3019
- lines.push("==================== 反 AI 规则(写作时必须遵守的表达边界) ====================");
3020
- if (forbidden.length > 0) {
3021
- lines.push("禁止类(命中即问题,审稿时列为 high/medium):");
3022
- for (const r of forbidden) lines.push(`- ${r.name}:${clip(instruction(r), 90)}${editLine(r)}${r.riskLevel !== void 0 && r.riskLevel !== "high" ? `(严重度 ${r.riskLevel})` : ""}`);
3023
- }
3024
- if (risk.length > 0) {
3025
- lines.push("风险类(可能扣分,审稿按风险等级提示,不硬性阻塞):");
3026
- for (const r of risk) lines.push(`- ${r.name}:${clip(instruction(r), 90)}(${r.riskLevel ?? "medium"}${r.autoRewrite === false ? " · 不自动改写" : ""})${editLine(r)}`);
3027
- }
3028
- if (encourage.length > 0) {
3029
- lines.push("鼓励类(希望出现,不命中不算错,审稿时只作低优先级建议、不阻塞通过):");
3030
- for (const r of encourage) lines.push(`- ${r.name}:${clip(r.fix !== "" ? r.fix : r.avoid, 90)}`);
3031
- }
3032
- return lines.join("\n");
3033
- }
3034
- /** 渲染题材与推进模式提示词块。 */
3035
- function renderGenreAndProgression(assets) {
3036
- const sections = [];
3037
- if (assets?.genre !== void 0) {
3038
- sections.push("==================== 题材基底(本书的题材定位与读者期待) ====================");
3039
- sections.push(`题材:${assets.genre.name}`);
3040
- if (assets.genre.description !== "") sections.push(`读者期待:${assets.genre.description}`);
3041
- if (assets.genre.template !== void 0 && assets.genre.template !== "") sections.push(`写法指引:${assets.genre.template}`);
3042
- const walk = (node, depth) => {
3043
- for (const child of node.children) {
3044
- sections.push(`${" ".repeat(depth)}- ${child.name}:${child.description}`);
3045
- if (child.template !== void 0 && child.template !== "") sections.push(`${" ".repeat(depth + 1)}写法指引:${child.template}`);
3046
- walk(child, depth + 1);
3047
- }
3048
- };
3049
- walk(assets.genre, 1);
3050
- }
3051
- const modes = [...assets?.primaryProgression !== void 0 ? [assets.primaryProgression] : [], ...assets?.auxiliaryProgressions ?? []];
3052
- if (modes.length > 0) {
3053
- sections.push("==================== 推进模式(读者为什么继续看) ====================");
3054
- for (const mode of modes) {
3055
- const tag = mode.primary ? "(主推进)" : "(辅助)";
3056
- const progressionUnits = mode.progressionUnits ?? [];
3057
- const allowedConflictForms = mode.allowedConflictForms ?? [];
3058
- const forbiddenConflictForms = mode.forbiddenConflictForms ?? [];
3059
- const mandatorySignals = mode.mandatorySignals ?? [];
3060
- const antiSignals = mode.antiSignals ?? [];
3061
- sections.push(`- 模式「${mode.name}」${tag}:驱动力——${mode.driver}`);
3062
- sections.push(` 读者期待:${mode.readerExpectation}`);
3063
- if (mode.template !== void 0 && mode.template !== "") sections.push(` 写法指引:${mode.template}`);
3064
- if (progressionUnits.length > 0) sections.push(` 推进单位:${progressionUnits.join(" → ")}`);
3065
- if (mode.conflictCeiling !== void 0) sections.push(` 冲突上限:${mode.conflictCeiling}`);
3066
- if (allowedConflictForms.length > 0) sections.push(` 允许冲突:${allowedConflictForms.join("、")}`);
3067
- if (forbiddenConflictForms.length > 0) sections.push(` 避免冲突:${forbiddenConflictForms.join("、")}`);
3068
- if (mode.chapterUnit !== void 0 && mode.chapterUnit !== "") sections.push(` 单章单位:${mode.chapterUnit}`);
3069
- if (mode.volumeReward !== void 0 && mode.volumeReward !== "") sections.push(` 卷末回报:${mode.volumeReward}`);
3070
- if (mandatorySignals.length > 0) sections.push(` 必达信号:${mandatorySignals.join("、")}`);
3071
- if (antiSignals.length > 0) sections.push(` 跑偏信号:${antiSignals.join("、")}`);
3072
- if (mode.payoffs.length > 0) sections.push(` 常见兑现:${mode.payoffs.join("、")}`);
3073
- if (mode.risks.length > 0) sections.push(` 节奏风险(避免):${mode.risks.join("、")}`);
3074
- }
3075
- }
3076
- return sections.join("\n");
3077
- }
3078
- /** 渲染写法资产提示词块(规则去重,省 token)。 */
3079
- function renderStyleAssets(assets) {
3080
- const styles = assets?.styleAssets ?? [];
3081
- if (styles.length === 0) return "";
3082
- const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
3083
- for (const style of styles) {
3084
- sections.push(`【${style.name}】`);
3085
- const unique = (rules) => [...new Set(rules)];
3086
- if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + unique(style.proseRules).map((r) => `- ${r}`).join("\n"));
3087
- if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
3088
- if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
3089
- if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
3090
- }
3091
- return sections.join("\n");
3092
- }
3093
- /** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
3094
- function renderAllAssets(assets) {
3095
- return [
3096
- renderGenreAndProgression(assets),
3097
- renderStyleAssets(assets),
3098
- renderAntiAiRules(assets)
3099
- ].filter((part) => part !== "").join("\n\n");
3100
- }
3101
- /** 起始风格画像库:无样本文本也能快速绑定一套写法(4 档起步写法)。 */
3102
- const BUILTIN_STARTER_STYLE_PROFILES = [
3103
- {
3104
- key: "starter-power-up",
3105
- templateKey: "power-up-escalation",
3106
- name: "开书快跑·目标推进型",
3107
- description: "第一次开书想先把推进感与爽点兑现跑顺,可以直接拿它起手,之后在这个底子上改。"
3108
- },
3109
- {
3110
- key: "starter-suspense",
3111
- templateKey: "suspense-pressure",
3112
- name: "开书快跑·信息遮蔽型",
3113
- description: "写给异常、规则、调查、危险逼近这类故事:先把压迫感与信息差立起来。"
3114
- },
3115
- {
3116
- key: "starter-emotional",
3117
- templateKey: "emotional-tension",
3118
- name: "开书快跑·关系拉扯型",
3119
- description: "写给关系推进、误读拉扯类故事:先拿一套能直接开写的关系表达底子。"
3120
- },
3121
- {
3122
- key: "starter-daily",
3123
- templateKey: "immersive-daily",
3124
- name: "开书快跑·日常浸没型",
3125
- description: "写给治愈、陪伴、生活经营、轻缓成长类故事:优先保证生活感与沉浸感。"
3505
+ combos: ["打脸", "误会解开"],
3506
+ taboos: ["毫无伏笔硬揭", "身份设定无意义"],
3507
+ applicableGenres: [
3508
+ "悬疑",
3509
+ "古言",
3510
+ "玄幻"
3511
+ ]
3126
3512
  }
3127
3513
  ];
3128
- /** 根据指纹风险推荐仿写预设:low→imitate / medium→balanced / high→transfer。 */
3129
- function recommendStylePreset(fingerprintRisk) {
3130
- if (fingerprintRisk === "low") return "imitate";
3131
- if (fingerprintRisk === "high") return "transfer";
3132
- return "balanced";
3133
- }
3134
- /** 写法引擎:从样本文本提取风格资产的系统提示词(含 preset / 指纹 / 净化管线)。 */
3135
- function styleEngineSystemPrompt() {
3136
- return [
3137
- "你是一位资深网文文风分析师。你会收到一段样本文本,请提炼出可复用的叙事风格规则,供后续章节保持同一种味道,同时给出「仿写预设」与「净化」建议。",
3138
- "要求:",
3139
- "1. 从样本中归纳,不要泛泛而谈;每条规则都要能落到具体写法(句式、用词、视角、节奏、对话方式、描写密度)。",
3140
- "2. 台词风格要说明角色说话的语气特征与常用表达方式。",
3141
- "3. 表达边界要写明这段风格「不会怎么做」(如:不用华丽辞藻、不写长段心理独白、不用成语堆砌)。",
3142
- "4. 给出 writingGuidance:净化后的写作指引,即把规则改写成可安全用于生成的具体指导(去掉会暴露来源的具体人名/地名/独特句式)。",
3143
- "5. 给出 forbiddenEntities:样本文本里不可照搬的原作特有实体与标志性表达(人名、地名、核心设定句、独特口头禅等)。",
3144
- "6. 判断预设 preset:imitate=高保真仿写(保留全部特征),balanced=平衡(保留可迁移特征、降指纹),transfer=迁移(只取骨架、彻底去指纹)。",
3145
- "7. 判断 fingerprintRisk:low/medium/high,表示照搬本样本会被识别为抄原作的风险。",
3146
- "8. 输出必须是合法 JSON 对象,不要输出任何其他文字。",
3147
- "输出结构:",
3148
- "{\"proseRules\": [\"叙述视角与句式节奏规则\"], \"dialogueRules\": [\"台词风格规则\"], \"descriptionRules\": [\"描写密度与情绪表达规则\"], \"boundaries\": [\"表达边界\"], \"preset\": \"imitate|balanced|transfer\", \"fingerprintRisk\": \"low|medium|high\", \"writingGuidance\": [\"净化后的写作指引\"], \"forbiddenEntities\": [\"不可照搬的原作特有实体\"]}"
3149
- ].join("\n");
3150
- }
3151
- /** 写作公式提取系统提示词(分层:basic 骨架 / standard 完整 / deep 逐句细化)。 */
3152
- function styleFormulaSystemPrompt(depth) {
3153
- return [
3154
- "你是一位网文写作公式提炼师。你会收到一段样本文本,请把它浓缩成\"这套文怎么写\"的写作公式。",
3155
- depth === "basic" ? "只提取最核心的叙事骨架与推进公式(1 段即可)。" : depth === "deep" ? "逐句/逐段细化:开场如何起、对话如何写、节奏如何控、爽点如何递进(分小节列出)。" : "提取完整可复用的写法公式(含结构、句式、节奏、对话、爽点密度)。",
3156
- "要求:",
3157
- "1. 聚焦 focusAreas 指定的重点(如 开场/对话/节奏/爽点密度/段落结构)。",
3158
- "2. formula 用 Markdown 分小节;applyGuidance 说明生成/改写时如何套用(1-3 句)。",
3159
- "3. 只输出合法 JSON 对象,不要任何其他文字。",
3160
- "输出结构:",
3161
- "{\"name\": \"公式名\", \"focusAreas\": [\"重点域\"], \"formula\": \"Markdown 公式正文\", \"applyGuidance\": \"套用指引\"}"
3162
- ].join("\n");
3514
+ /** 默认(空)项目写作资产。 */
3515
+ function emptyProjectAssets() {
3516
+ return {
3517
+ auxiliaryProgressions: [],
3518
+ antiAiRules: [],
3519
+ styleAssets: []
3520
+ };
3163
3521
  }
3164
- //#endregion
3165
- //#region src/ai-scan.ts
3166
- /**
3167
- * 命中模式 -> 它在「已发布书稿语料」中被多少比例的章节命中过。
3168
- *
3169
- * ## 这张表是什么
3170
- *
3171
- * 它是**度量基线**,不是观点:覆盖率越高,说明该写法越接近作者/题材的常态表达,
3172
- * 越不适合当作 AI 味证据。覆盖率 >= 50% 记权重 0(视为功能词),0% 记 1。
3173
- *
3174
- * ## 模式从哪来
3175
- *
3176
- * 并集共 **63 个** = 反 AI 规则里作者声明的 detectPatterns(42 个)+ 度量基线词表。
3177
- * 实测「最后」(72.3%)、「然后」(70.1%) 的覆盖率比「微微」(48.2%) 还高 —— 它们是中文
3178
- * 叙事的正常连接词,权重为 0;而「生活就是」「归根结底」「人总要学会」这类结构性升华句
3179
- * 覆盖率是 **0.000**,才是真正的高价值信号。原先的实现根本没在看后者。
3180
- *
3181
- * ## 与规则的关系(刻意解耦)
3182
- *
3183
- * aiScore 是**独立标定的测量**:用户删掉一条规则,并不会让正文的 AI 味变少,
3184
- * 所以度量不依赖规则的当前状态。规则命中另走 {@link AiScanResult.ruleHits},
3185
- * 按作者**生效的规则**逐条确定性统计。两者都注入审稿,但互不污染。
3186
- *
3187
- * 覆盖率为 0 的 17 个模式也显式列出,避免「表里没有 = 没在看」的误读。
3188
- * 更换语料或题材后应重新统计。
3189
- */
3190
- const PATTERN_COVERAGE = {
3191
- "知道": .784,
3192
- "最后": .723,
3193
- "然后": .701,
3194
- "微微": .482,
3195
- "轻轻": .481,
3196
- "感觉": .449,
3197
- "气息": .392,
3198
- "终于": .306,
3199
- "接着": .304,
3200
- "缓缓": .303,
3201
- "嘴角": .292,
3202
- "眼神": .174,
3203
- "明白": .167,
3204
- "眉头": .145,
3205
- "似乎": .144,
3206
- "身影": .142,
3207
- "力量": .127,
3208
- "光芒": .087,
3209
- "好像": .084,
3210
- "仿佛": .075,
3211
- "其实": .07,
3212
- "显然": .068,
3213
- "脑海": .05,
3214
- "这就是": .045,
3215
- "告诉你": .041,
3216
- "然而": .027,
3217
- "默默": .025,
3218
- "这说明": .02,
3219
- "一时间": .019,
3220
- "他感到": .011,
3221
- "这意味着": .011,
3222
- "心中": .01,
3223
- "脑海中": .008,
3224
- "不由自主": .007,
3225
- "顿时": .005,
3226
- "不禁": .005,
3227
- "说到底": .005,
3228
- "其实就是": .005,
3229
- "心里想": .005,
3230
- "他明白了": .003,
3231
- "无法形容": .002,
3232
- "他意识到": .002,
3233
- "她意识到": .002,
3234
- "她明白了": .002,
3235
- "宛如": .002,
3236
- "不由得": .001,
3237
- "她感到": 0,
3238
- "生活就是": 0,
3239
- "命运总会": 0,
3240
- "归根结底": 0,
3241
- "我们都应该": 0,
3242
- "人总要学会": 0,
3243
- "真正重要的是": 0,
3244
- "我们现在要": 0,
3245
- "接下来就": 0,
3246
- "首先": 0,
3247
- "心中暗道": 0,
3248
- "暗自思忖": 0,
3249
- "心中暗想": 0,
3250
- "心里暗道": 0,
3251
- "犹如": 0,
3252
- "好似": 0,
3253
- "难以言喻": 0
3254
- };
3255
- /** 覆盖率 -> 权重:覆盖率 >= 50% 记为功能词(权重 0),0% 记 1。 */
3256
- function patternWeight(coverage) {
3257
- return Math.max(0, 1 - coverage / .5);
3522
+ /** 合并项目资产与内置库:返回「生效的反 AI 规则」(内置全局 + 项目自定义)。 */
3523
+ function effectiveAntiAiRules(assets) {
3524
+ const custom = assets?.antiAiRules ?? [];
3525
+ const keyOf = (r) => r.key ?? r.name;
3526
+ const customKeys = new Set(custom.filter((r) => keyOf(r) !== "").map(keyOf));
3527
+ return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customKeys.has(keyOf(r))), ...custom];
3258
3528
  }
3259
- /**
3260
- * 对话引号:ASCII 双引号 + 全角弯引号(中文书稿主流)+ 单弯引号 + 角括号。
3261
- * 历史实现只认第一种与后两种,漏掉弯引号 —— 那正是 5/6 本书在用的。
3262
- */
3263
- const DIALOGUE_PATTERN = /(?:"[^"]*"|\u201C[^\u201D]*\u201D|\u2018[^\u2019]*\u2019|\u300C[^\u300D]*\u300D|\u300E[^\u300F]*\u300F)/g;
3264
- /** 解释性叙事开头模式 */
3265
- const EXPOSITORY_STARTS = [
3266
- "原来",
3267
- "因为",
3268
- "由于",
3269
- "所以",
3270
- "因此",
3271
- "于是",
3272
- "这就是",
3273
- "也就是说",
3274
- "换句话说",
3275
- "事实上",
3276
- "实际上"
3277
- ];
3278
- /**
3279
- * 分项满分与语料分位点的对应关系(重新标定时只改这里)。
3280
- *
3281
- * - clicheFullDensity 2.478 = 语料加权密度 p99(63 模式并集口径);p50 为 0.61、p75 为 1.02
3282
- * - cvTooUniform 0.45 低于语料 CV p5(0.55),即只把最整齐的 0.6% 判为问题
3283
- * - repetitionZeroAt 0.37 = 语料句首重复率 p50;repetitionFullAt 0.63 = p99
3284
- * (中文「句首二字」天然高频,原来的固定阈值 0.15 几乎每章都命中、形同虚设)
3285
- */
3286
- const SCORE_SPEC = {
3287
- clicheMax: 40,
3288
- clicheFullDensity: 2.478,
3289
- cvTooUniform: .45,
3290
- cvMax: 15,
3291
- expositoryMax: 20,
3292
- expositoryPerParagraph: 5,
3293
- repetitionMax: 15,
3294
- repetitionZeroAt: .37,
3295
- repetitionFullAt: .63,
3296
- lowDialogueThreshold: .05,
3297
- lowDialogueMax: 10,
3298
- densityFloorChars: 2e3,
3299
- /**
3300
- * 规则命中的区分度门槛:只报「对该作者而言不常见」的模式。
3301
- *
3302
- * 实测必要性:「句式重复率偏高」这条规则的 detectPatterns 是
3303
- * ['首先','然后','接着','最后'],而「最后」覆盖 72.3%、「然后」覆盖 70.1% 的已发布
3304
- * 章节 —— 不设门槛时它会在 **94.4%** 的章上命中,等于每章都告诉模型「你违反了这条
3305
- * 规则」,比不报还糟。该规则的本质是「比率偏高」,应由 sentenceRepetitionRate 度量。
3306
- */
3307
- ruleHitCoverageMax: .1
3308
- };
3309
- /**
3310
- * 按真实书稿的换行形态切自然段:单换行分隔,剔除 # 标题行。
3311
- * @param text - 章节正文(可含标题行)。
3312
- * @returns 自然段数组(已 trim、去空)。
3313
- */
3314
- function paragraphsOf(text) {
3315
- return text.split(/\n+/).map((p) => p.trim()).filter((p) => p.length > 0 && !/^#{1,6}\s/.test(p));
3529
+ /** 内置库种子化 upsert(对齐上游 SystemResourceBootstrapService 精神)。
3530
+ * missing_only 仅补齐缺失的全局基线规则;sync_existing 还会按 key 刷新已内置规则的结构化字段。 */
3531
+ function ensureBuiltinAssets(assets, mode = "missing_only") {
3532
+ const base = assets ?? emptyProjectAssets();
3533
+ const keyOf = (r) => r.key ?? r.name;
3534
+ const existing = base.antiAiRules ?? [];
3535
+ const byKey = new Map(existing.map((r) => [keyOf(r), r]));
3536
+ const out = [];
3537
+ let changed = false;
3538
+ for (const r of existing) {
3539
+ const builtin = BUILTIN_ANTI_AI_RULES.find((b) => (b.key ?? b.name) === keyOf(r));
3540
+ if (mode === "sync_existing" && builtin !== void 0) {
3541
+ const refreshed = {
3542
+ ...builtin,
3543
+ name: r.name,
3544
+ avoid: r.avoid,
3545
+ fix: r.fix,
3546
+ detectPatterns: r.detectPatterns ?? builtin.detectPatterns,
3547
+ enabled: r.enabled ?? builtin.enabled,
3548
+ scope: r.scope
3549
+ };
3550
+ out.push(refreshed);
3551
+ if (JSON.stringify(refreshed) !== JSON.stringify(r)) changed = true;
3552
+ byKey.set(keyOf(r), refreshed);
3553
+ } else out.push(r);
3554
+ }
3555
+ for (const b of BUILTIN_ANTI_AI_RULES) if (b.globalBaselineEnabled === true && !byKey.has(b.key ?? b.name)) {
3556
+ out.push(b);
3557
+ byKey.set(b.key ?? b.name, b);
3558
+ changed = true;
3559
+ }
3560
+ if (!changed) return base;
3561
+ return {
3562
+ ...base,
3563
+ antiAiRules: out
3564
+ };
3316
3565
  }
3317
- /** 子串出现次数(不重叠)。 */
3318
- function countOccurrences(text, word) {
3319
- if (word.length === 0) return 0;
3320
- let count = 0;
3321
- let at = text.indexOf(word);
3322
- while (at !== -1) {
3323
- count += 1;
3324
- at = text.indexOf(word, at + word.length);
3566
+ /** 把生效规则渲染成提示词块(禁止/风险/鼓励三档分列,压缩省 token)。 */
3567
+ function renderAntiAiRules(assets) {
3568
+ const rules = effectiveAntiAiRules(assets).filter((r) => r.enabled !== false);
3569
+ if (rules.length === 0) return "";
3570
+ const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
3571
+ const category = (r) => {
3572
+ if (r.severity === "encourage" || r.name.startsWith("鼓励") || r.avoid.startsWith("(鼓励类)")) return "encourage";
3573
+ if (r.severity === "risk") return "risk";
3574
+ return "forbidden";
3575
+ };
3576
+ const instruction = (r) => r.promptInstruction !== void 0 && r.promptInstruction !== "" ? r.promptInstruction : r.avoid;
3577
+ const forbidden = rules.filter((r) => category(r) === "forbidden");
3578
+ const risk = rules.filter((r) => category(r) === "risk");
3579
+ const encourage = rules.filter((r) => category(r) === "encourage");
3580
+ const editLine = (r) => r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : "";
3581
+ const lines = [];
3582
+ lines.push("==================== 反 AI 规则(写作时必须遵守的表达边界) ====================");
3583
+ if (forbidden.length > 0) {
3584
+ lines.push("禁止类(命中即问题,审稿时列为 high/medium):");
3585
+ for (const r of forbidden) lines.push(`- ${r.name}:${clip(instruction(r), 90)}${editLine(r)}${r.riskLevel !== void 0 && r.riskLevel !== "high" ? `(严重度 ${r.riskLevel})` : ""}`);
3325
3586
  }
3326
- return count;
3327
- }
3328
- /** 把 x 线性映射到 [0, 1] 并夹逼。 */
3329
- function ratio(x, zeroAt, fullAt) {
3330
- if (fullAt === zeroAt) return 0;
3331
- return Math.max(0, Math.min(1, (x - zeroAt) / (fullAt - zeroAt)));
3587
+ if (risk.length > 0) {
3588
+ lines.push("风险类(可能扣分,审稿按风险等级提示,不硬性阻塞):");
3589
+ for (const r of risk) lines.push(`- ${r.name}:${clip(instruction(r), 90)}(${r.riskLevel ?? "medium"}${r.autoRewrite === false ? " · 不自动改写" : ""})${editLine(r)}`);
3590
+ }
3591
+ if (encourage.length > 0) {
3592
+ lines.push("鼓励类(希望出现,不命中不算错,审稿时只作低优先级建议、不阻塞通过):");
3593
+ for (const r of encourage) lines.push(`- ${r.name}:${clip(r.fix !== "" ? r.fix : r.avoid, 90)}`);
3594
+ }
3595
+ return lines.join("\n");
3332
3596
  }
3333
- function scanAiFlavor(text, rules) {
3334
- const paragraphs = paragraphsOf(text);
3335
- const totalChars = text.length;
3336
- const clicheHits = [];
3337
- let weightedTotal = 0;
3338
- for (const word of Object.keys(PATTERN_COVERAGE)) {
3339
- const count = countOccurrences(text, word);
3340
- if (count === 0) continue;
3341
- const weight = patternWeight(PATTERN_COVERAGE[word] ?? 0);
3342
- clicheHits.push({
3343
- word,
3344
- count,
3345
- weight
3346
- });
3347
- weightedTotal += count * weight;
3597
+ /** 渲染题材与推进模式提示词块。 */
3598
+ function renderGenreAndProgression(assets) {
3599
+ const sections = [];
3600
+ if (assets?.genre !== void 0) {
3601
+ sections.push("==================== 题材基底(本书的题材定位与读者期待) ====================");
3602
+ sections.push(`题材:${assets.genre.name}`);
3603
+ if (assets.genre.description !== "") sections.push(`读者期待:${assets.genre.description}`);
3604
+ if (assets.genre.template !== void 0 && assets.genre.template !== "") sections.push(`写法指引:${assets.genre.template}`);
3605
+ const walk = (node, depth) => {
3606
+ for (const child of node.children) {
3607
+ sections.push(`${" ".repeat(depth)}- ${child.name}:${child.description}`);
3608
+ if (child.template !== void 0 && child.template !== "") sections.push(`${" ".repeat(depth + 1)}写法指引:${child.template}`);
3609
+ walk(child, depth + 1);
3610
+ }
3611
+ };
3612
+ walk(assets.genre, 1);
3348
3613
  }
3349
- clicheHits.sort((a, b) => b.count * b.weight - a.count * a.weight);
3350
- const ruleHits = [];
3351
- for (const rule of rules ?? []) {
3352
- const matches = [];
3353
- for (const pattern of rule.detectPatterns ?? []) {
3354
- const count = countOccurrences(text, pattern);
3355
- if (count === 0) continue;
3356
- if ((PATTERN_COVERAGE[pattern] ?? 0) >= SCORE_SPEC.ruleHitCoverageMax) continue;
3357
- matches.push({
3358
- pattern,
3359
- count
3360
- });
3614
+ const modes = [...assets?.primaryProgression !== void 0 ? [assets.primaryProgression] : [], ...assets?.auxiliaryProgressions ?? []];
3615
+ if (modes.length > 0) {
3616
+ sections.push("==================== 推进模式(读者为什么继续看) ====================");
3617
+ for (const mode of modes) {
3618
+ const tag = mode.primary ? "(主推进)" : "(辅助)";
3619
+ const progressionUnits = mode.progressionUnits ?? [];
3620
+ const allowedConflictForms = mode.allowedConflictForms ?? [];
3621
+ const forbiddenConflictForms = mode.forbiddenConflictForms ?? [];
3622
+ const mandatorySignals = mode.mandatorySignals ?? [];
3623
+ const antiSignals = mode.antiSignals ?? [];
3624
+ sections.push(`- 模式「${mode.name}」${tag}:驱动力——${mode.driver}`);
3625
+ sections.push(` 读者期待:${mode.readerExpectation}`);
3626
+ if (mode.template !== void 0 && mode.template !== "") sections.push(` 写法指引:${mode.template}`);
3627
+ if (progressionUnits.length > 0) sections.push(` 推进单位:${progressionUnits.join(" → ")}`);
3628
+ if (mode.conflictCeiling !== void 0) sections.push(` 冲突上限:${mode.conflictCeiling}`);
3629
+ if (allowedConflictForms.length > 0) sections.push(` 允许冲突:${allowedConflictForms.join("、")}`);
3630
+ if (forbiddenConflictForms.length > 0) sections.push(` 避免冲突:${forbiddenConflictForms.join("、")}`);
3631
+ if (mode.chapterUnit !== void 0 && mode.chapterUnit !== "") sections.push(` 单章单位:${mode.chapterUnit}`);
3632
+ if (mode.volumeReward !== void 0 && mode.volumeReward !== "") sections.push(` 卷末回报:${mode.volumeReward}`);
3633
+ if (mandatorySignals.length > 0) sections.push(` 必达信号:${mandatorySignals.join("、")}`);
3634
+ if (antiSignals.length > 0) sections.push(` 跑偏信号:${antiSignals.join("、")}`);
3635
+ if (mode.payoffs.length > 0) sections.push(` 常见兑现:${mode.payoffs.join("、")}`);
3636
+ if (mode.risks.length > 0) sections.push(` 节奏风险(避免):${mode.risks.join("、")}`);
3361
3637
  }
3362
- if (matches.length > 0) ruleHits.push({
3363
- name: rule.name,
3364
- severity: rule.severity,
3365
- matches
3366
- });
3367
- }
3368
- const paraLengths = paragraphs.map((p) => p.length);
3369
- const avgLen = paraLengths.length > 0 ? paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length : 0;
3370
- const variance = paraLengths.length > 0 ? paraLengths.reduce((sum, len) => sum + Math.pow(len - avgLen, 2), 0) / paraLengths.length : 0;
3371
- const cv = avgLen > 0 ? Math.sqrt(variance) / avgLen : 0;
3372
- let maxConsecutive = 0;
3373
- let currentConsecutive = 0;
3374
- for (const p of paragraphs) if (EXPOSITORY_STARTS.some((s) => p.startsWith(s)) || p.length > 150 && !/["\u201C\u300C\u300E]/.test(p)) {
3375
- currentConsecutive++;
3376
- maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
3377
- } else currentConsecutive = 0;
3378
- const sentences = text.split(/[。!?!?]/).map((s) => s.trim()).filter((s) => s.length > 0);
3379
- const starterCounts = /* @__PURE__ */ new Map();
3380
- for (const s of sentences) {
3381
- const starter = s.slice(0, 2);
3382
- starterCounts.set(starter, (starterCounts.get(starter) ?? 0) + 1);
3383
3638
  }
3384
- const repeatedStarters = Array.from(starterCounts.values()).filter((c) => c >= 3).reduce((a, b) => a + b, 0);
3385
- const sentenceRepetitionRate = sentences.length > 0 ? repeatedStarters / sentences.length : 0;
3386
- const longParagraphCount = paraLengths.filter((l) => l > 300).length;
3387
- const shortParagraphCount = paraLengths.filter((l) => l < 20).length;
3388
- const dialogueChars = (text.match(DIALOGUE_PATTERN) ?? []).join("").length;
3389
- const dialogueRatio = totalChars > 0 ? dialogueChars / totalChars : 0;
3390
- const densityDenom = Math.max(totalChars, SCORE_SPEC.densityFloorChars);
3391
- const clicheDensity = weightedTotal / densityDenom * 1e3;
3392
- const scoreParts = {
3393
- cliche: Math.min(SCORE_SPEC.clicheMax, clicheDensity / SCORE_SPEC.clicheFullDensity * SCORE_SPEC.clicheMax),
3394
- uniformParagraphs: paragraphs.length >= 8 && cv < SCORE_SPEC.cvTooUniform ? SCORE_SPEC.cvMax : 0,
3395
- expository: Math.min(SCORE_SPEC.expositoryMax, maxConsecutive * SCORE_SPEC.expositoryPerParagraph),
3396
- repetition: SCORE_SPEC.repetitionMax * ratio(sentenceRepetitionRate, SCORE_SPEC.repetitionZeroAt, SCORE_SPEC.repetitionFullAt),
3397
- lowDialogue: dialogueRatio < SCORE_SPEC.lowDialogueThreshold && totalChars > 1e3 ? SCORE_SPEC.lowDialogueMax : 0
3398
- };
3399
- const aiScore = Math.min(100, Math.round(scoreParts.cliche + scoreParts.uniformParagraphs + scoreParts.expository + scoreParts.repetition + scoreParts.lowDialogue));
3400
- const issues = [];
3401
- if (clicheDensity > 1) {
3402
- const top = clicheHits.filter((h) => h.weight > 0).slice(0, 5).map((h) => h.word + "x" + h.count).join("、");
3403
- issues.push("套话/模板句偏多(按语料稀有度加权 " + clicheDensity.toFixed(2) + "/千字,p50 为 0.61):" + top);
3639
+ return sections.join("\n");
3640
+ }
3641
+ /** 渲染写法资产提示词块(规则去重,省 token)。 */
3642
+ function renderStyleAssets(assets) {
3643
+ const styles = assets?.styleAssets ?? [];
3644
+ if (styles.length === 0) return "";
3645
+ const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
3646
+ for (const style of styles) {
3647
+ sections.push(`【${style.name}】`);
3648
+ const unique = (rules) => [...new Set(rules)];
3649
+ if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + unique(style.proseRules).map((r) => `- ${r}`).join("\n"));
3650
+ if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
3651
+ if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
3652
+ if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
3653
+ if (style.sourceText !== void 0 && style.sourceText !== "") {
3654
+ const targets = renderFingerprintTargets(extractStyleFingerprint(style.sourceText));
3655
+ if (targets !== "") sections.push(targets);
3656
+ }
3404
3657
  }
3405
- if (scoreParts.uniformParagraphs > 0) issues.push("段落长度过于整齐(CV " + cv.toFixed(2) + " < " + SCORE_SPEC.cvTooUniform + ",已发布语料 p5 为 0.55)");
3406
- if (maxConsecutive >= 3) issues.push("连续 " + maxConsecutive + " 段解释性叙事,缺少对话/动作");
3407
- if (sentenceRepetitionRate > .5) issues.push("句式重复率 " + (sentenceRepetitionRate * 100).toFixed(0) + "%,开头句式单一");
3408
- if (longParagraphCount > 3) issues.push(longParagraphCount + " 段超过 300 字,段落过长");
3409
- if (scoreParts.lowDialogue > 0) issues.push("对话占比过低(" + (dialogueRatio * 100).toFixed(1) + "%),整章偏叙述");
3410
- if (ruleHits.length > 0) {
3411
- const brief = ruleHits.slice(0, 5).map((h) => h.name + "(" + h.matches.map((m) => m.pattern + "x" + m.count).join("、") + ")").join(";");
3412
- issues.push("确定性规则命中 " + ruleHits.length + " 条:" + brief);
3658
+ return sections.join("\n");
3659
+ }
3660
+ /** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
3661
+ function renderAllAssets(assets) {
3662
+ return [
3663
+ renderGenreAndProgression(assets),
3664
+ renderStyleAssets(assets),
3665
+ renderAntiAiRules(assets)
3666
+ ].filter((part) => part !== "").join("\n\n");
3667
+ }
3668
+ /** 起始风格画像库:无样本文本也能快速绑定一套写法(4 档起步写法)。 */
3669
+ const BUILTIN_STARTER_STYLE_PROFILES = [
3670
+ {
3671
+ key: "starter-power-up",
3672
+ templateKey: "power-up-escalation",
3673
+ name: "开书快跑·目标推进型",
3674
+ description: "第一次开书想先把推进感与爽点兑现跑顺,可以直接拿它起手,之后在这个底子上改。"
3675
+ },
3676
+ {
3677
+ key: "starter-suspense",
3678
+ templateKey: "suspense-pressure",
3679
+ name: "开书快跑·信息遮蔽型",
3680
+ description: "写给异常、规则、调查、危险逼近这类故事:先把压迫感与信息差立起来。"
3681
+ },
3682
+ {
3683
+ key: "starter-emotional",
3684
+ templateKey: "emotional-tension",
3685
+ name: "开书快跑·关系拉扯型",
3686
+ description: "写给关系推进、误读拉扯类故事:先拿一套能直接开写的关系表达底子。"
3687
+ },
3688
+ {
3689
+ key: "starter-daily",
3690
+ templateKey: "immersive-daily",
3691
+ name: "开书快跑·日常浸没型",
3692
+ description: "写给治愈、陪伴、生活经营、轻缓成长类故事:优先保证生活感与沉浸感。"
3413
3693
  }
3414
- const head = "本地 AI 味扫描(AI 味指数 " + aiScore + "/100,共 " + paragraphs.length + " 个自然段)";
3415
- const summary = issues.length > 0 ? head + ":\n" + issues.map((i) => "- " + i).join("\n") : head + ":未发现明显问题。";
3416
- return {
3417
- aiScore,
3418
- clicheHits,
3419
- paragraphCount: paragraphs.length,
3420
- paragraphLengthVariance: Math.round(variance),
3421
- paragraphLengthCv: Math.round(cv * 100) / 100,
3422
- consecutiveExpositoryParagraphs: maxConsecutive,
3423
- sentenceRepetitionRate: Math.round(sentenceRepetitionRate * 1e3) / 1e3,
3424
- longParagraphCount,
3425
- shortParagraphCount,
3426
- dialogueRatio: Math.round(dialogueRatio * 100) / 100,
3427
- ruleHits,
3428
- scoreParts,
3429
- summary
3430
- };
3694
+ ];
3695
+ /** 根据指纹风险推荐仿写预设:low→imitate / medium→balanced / high→transfer。 */
3696
+ function recommendStylePreset(fingerprintRisk) {
3697
+ if (fingerprintRisk === "low") return "imitate";
3698
+ if (fingerprintRisk === "high") return "transfer";
3699
+ return "balanced";
3700
+ }
3701
+ /** 写法引擎:从样本文本提取风格资产的系统提示词(含 preset / 指纹 / 净化管线)。 */
3702
+ function styleEngineSystemPrompt() {
3703
+ return [
3704
+ "你是一位资深网文文风分析师。你会收到一段样本文本,请提炼出可复用的叙事风格规则,供后续章节保持同一种味道,同时给出「仿写预设」与「净化」建议。",
3705
+ "要求:",
3706
+ "1. 从样本中归纳,不要泛泛而谈;每条规则都要能落到具体写法(句式、用词、视角、节奏、对话方式、描写密度)。",
3707
+ "2. 台词风格要说明角色说话的语气特征与常用表达方式。",
3708
+ "3. 表达边界要写明这段风格「不会怎么做」(如:不用华丽辞藻、不写长段心理独白、不用成语堆砌)。",
3709
+ "4. 给出 writingGuidance:净化后的写作指引,即把规则改写成可安全用于生成的具体指导(去掉会暴露来源的具体人名/地名/独特句式)。",
3710
+ "5. 给出 forbiddenEntities:样本文本里不可照搬的原作特有实体与标志性表达(人名、地名、核心设定句、独特口头禅等)。",
3711
+ "6. 判断预设 preset:imitate=高保真仿写(保留全部特征),balanced=平衡(保留可迁移特征、降指纹),transfer=迁移(只取骨架、彻底去指纹)。",
3712
+ "7. 判断 fingerprintRisk:low/medium/high,表示照搬本样本会被识别为抄原作的风险。",
3713
+ "8. 输出必须是合法 JSON 对象,不要输出任何其他文字。",
3714
+ "输出结构:",
3715
+ "{\"proseRules\": [\"叙述视角与句式节奏规则\"], \"dialogueRules\": [\"台词风格规则\"], \"descriptionRules\": [\"描写密度与情绪表达规则\"], \"boundaries\": [\"表达边界\"], \"preset\": \"imitate|balanced|transfer\", \"fingerprintRisk\": \"low|medium|high\", \"writingGuidance\": [\"净化后的写作指引\"], \"forbiddenEntities\": [\"不可照搬的原作特有实体\"]}"
3716
+ ].join("\n");
3717
+ }
3718
+ /** 写作公式提取系统提示词(分层:basic 骨架 / standard 完整 / deep 逐句细化)。 */
3719
+ function styleFormulaSystemPrompt(depth) {
3720
+ return [
3721
+ "你是一位网文写作公式提炼师。你会收到一段样本文本,请把它浓缩成\"这套文怎么写\"的写作公式。",
3722
+ depth === "basic" ? "只提取最核心的叙事骨架与推进公式(1 段即可)。" : depth === "deep" ? "逐句/逐段细化:开场如何起、对话如何写、节奏如何控、爽点如何递进(分小节列出)。" : "提取完整可复用的写法公式(含结构、句式、节奏、对话、爽点密度)。",
3723
+ "要求:",
3724
+ "1. 聚焦 focusAreas 指定的重点(如 开场/对话/节奏/爽点密度/段落结构)。",
3725
+ "2. formula 用 Markdown 分小节;applyGuidance 说明生成/改写时如何套用(1-3 句)。",
3726
+ "3. 只输出合法 JSON 对象,不要任何其他文字。",
3727
+ "输出结构:",
3728
+ "{\"name\": \"公式名\", \"focusAreas\": [\"重点域\"], \"formula\": \"Markdown 公式正文\", \"applyGuidance\": \"套用指引\"}"
3729
+ ].join("\n");
3431
3730
  }
3432
3731
  //#endregion
3433
3732
  //#region src/llm-live.ts
@@ -6412,6 +6711,7 @@ function reviewSystemPrompt(project) {
6412
6711
  "{\"score\": 0-100的整数, \"riskScore\": 0-100的整数(越高越需人工处理,可结合本地AI味指数), \"tension\": 0-100的整数(本章张力强度:对抗/揭示/抉择/代价;铺垫与日常章应明显偏低), \"verdict\": \"一句话总评\", \"issues\": [{\"severity\": \"high|medium|low\", \"dimension\": \"character|setting|redline|writing|pacing|logic|anti-ai|presentation|compliance\", \"item\": \"问题描述\", \"suggestion\": \"修改建议\", \"ruleName\": \"命中的反AI规则名(见反AI规则清单)\", \"ruleType\": \"forbidden|risk|encourage\", \"category\": \"套话|句式|段落|心理|设定|节奏|对话|其他\", \"excerpt\": \"命中的原文摘录(不超过50字)\", \"reason\": \"判定理由\", \"canAutoRewrite\": true|false}]}",
6413
6712
  "tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
6414
6713
  "反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
6714
+ "风格符合度:若下方给出「风格指纹比对」,以它为准判断本章是否偏离绑定写法——偏差显著时按 writing 维度、category 用「风格」上报;比对里说没有显著偏差时,不要报风格问题。",
6415
6715
  "AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
6416
6716
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
6417
6717
  "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
@@ -6435,6 +6735,54 @@ function reviewSystemPrompt(project) {
6435
6735
  sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
6436
6736
  return sections.join("\n");
6437
6737
  }
6738
+ /**
6739
+ * 风格基线的取样上限与取法。
6740
+ *
6741
+ * **等距抽样,不取最近 N 章**:实测「最近 12 章」在《还债疯了》上把段落均长的误报从 33
6742
+ * 抬到 98 —— 最近那一卷的行文与全书不同时,中位数就不代表全书。等距抽样覆盖整本。
6743
+ */
6744
+ const STYLE_BASELINE_CHAPTERS = 12;
6745
+ /** 低于此章数时中位数没有代表性,退回单一样本参照。 */
6746
+ const STYLE_BASELINE_MIN_CHAPTERS = 3;
6747
+ /**
6748
+ * 风格符合度事实块:拿本章指标与**本书已过审章节的中位数**(或绑定写法样本)对照。
6749
+ *
6750
+ * 取第一个带 sourceText 的写法资产做参照(提取时 routes.ts 会存下样本前 3000 字)。
6751
+ * 样本过短(指标无统计意义)时返回空串——宁可不给,也不给噪声。
6752
+ * @param project - 当前书项目状态。
6753
+ * @param bodyText - 本章正文(已剥标题)。
6754
+ * @returns 可注入审稿 prompt 的文本;没有可用参照时为空串。
6755
+ */
6756
+ function styleFidelityBlock(project, bodyText, outputDir) {
6757
+ if (outputDir !== void 0) {
6758
+ const approved = project.chapters.filter((c) => c.status === "approved" && c.file !== "");
6759
+ if (approved.length >= STYLE_BASELINE_MIN_CHAPTERS) {
6760
+ const step = Math.max(1, Math.floor(approved.length / STYLE_BASELINE_CHAPTERS));
6761
+ const picked = approved.filter((_, index) => index % step === 0).slice(0, STYLE_BASELINE_CHAPTERS);
6762
+ const samples = [];
6763
+ for (const chapter of picked) {
6764
+ const raw = readChapterFile(outputDir, chapter);
6765
+ if (raw === void 0) continue;
6766
+ const fp = extractStyleFingerprint(stripChapterHeadings(raw));
6767
+ if (fp.sentences > 0) samples.push(fp);
6768
+ }
6769
+ if (samples.length >= STYLE_BASELINE_MIN_CHAPTERS) {
6770
+ const reference = mergeFingerprints(samples);
6771
+ const actual = extractStyleFingerprint(bodyText);
6772
+ return renderFingerprintComparison(reference, actual, compareStyleFingerprint(reference, actual), "本书已过审 " + samples.length + " 章的中位数");
6773
+ }
6774
+ }
6775
+ }
6776
+ for (const style of project.assets?.styleAssets ?? []) {
6777
+ const sample = style.sourceText;
6778
+ if (sample === void 0 || sample === "") continue;
6779
+ const reference = extractStyleFingerprint(sample);
6780
+ if (reference.sentences === 0) continue;
6781
+ const actual = extractStyleFingerprint(bodyText);
6782
+ return renderFingerprintComparison(reference, actual, compareStyleFingerprint(reference, actual));
6783
+ }
6784
+ return "";
6785
+ }
6438
6786
  /** Run the AI review on one chapter. */
6439
6787
  async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
6440
6788
  const chapter = project.chapters.find((c) => c.no === chapterNo);
@@ -6443,6 +6791,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
6443
6791
  if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
6444
6792
  const bodyText = stripChapterHeadings(body);
6445
6793
  const aiScan = scanAiFlavor(bodyText, effectiveAntiAiRules(project.assets));
6794
+ const styleBlock = styleFidelityBlock(project, bodyText, outputDir);
6446
6795
  const blocks = renderContextBlocks(buildChapterContext(project, chapter, outputDir, { stage: "review" }));
6447
6796
  const crossChapter = [
6448
6797
  blocks.continuityBlock,
@@ -6454,6 +6803,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
6454
6803
  `本章标题:《${chapter.title}》`,
6455
6804
  `本章剧情要点:${chapter.beats}`,
6456
6805
  `==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
6806
+ styleBlock,
6457
6807
  crossChapter,
6458
6808
  "==================== 章节正文 ====================",
6459
6809
  bodyText
@@ -6515,11 +6865,13 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
6515
6865
  async function reviewChapterText(ctx, config, project, text, previousReport) {
6516
6866
  const bodyText = text.slice(0, 2e4);
6517
6867
  const aiScan = scanAiFlavor(bodyText, effectiveAntiAiRules(project.assets));
6868
+ const styleBlock = styleFidelityBlock(project, bodyText);
6518
6869
  const user = [
6519
6870
  `书名:《${project.bookName}》`,
6520
6871
  previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
6521
6872
  previousReport !== void 0 ? "==================== 修订稿(上一轮审稿后按意见修订的正文) ====================" : "==================== 待审查正文 ====================",
6522
6873
  `==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
6874
+ styleBlock,
6523
6875
  bodyText
6524
6876
  ].join("\n");
6525
6877
  const raw = parseJsonObject(await complete(ctx, config, {