@waterwx/dsh-novel-forge 2.1.1 → 2.1.2
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/client.js +455 -455
- package/lib/index.js +217 -77
- package/lib/types/ai-scan.d.ts +66 -6
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -3163,41 +3163,104 @@ function styleFormulaSystemPrompt(depth) {
|
|
|
3163
3163
|
}
|
|
3164
3164
|
//#endregion
|
|
3165
3165
|
//#region src/ai-scan.ts
|
|
3166
|
-
/**
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
"
|
|
3192
|
-
"
|
|
3193
|
-
"
|
|
3194
|
-
"
|
|
3195
|
-
"
|
|
3196
|
-
"
|
|
3197
|
-
"
|
|
3198
|
-
"
|
|
3199
|
-
"
|
|
3200
|
-
|
|
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);
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* 对话引号:ASCII 双引号 + 全角弯引号(中文书稿主流)+ 单弯引号 + 角括号。
|
|
3261
|
+
* 历史实现只认第一种与后两种,漏掉弯引号 —— 那正是 5/6 本书在用的。
|
|
3262
|
+
*/
|
|
3263
|
+
const DIALOGUE_PATTERN = /(?:"[^"]*"|\u201C[^\u201D]*\u201D|\u2018[^\u2019]*\u2019|\u300C[^\u300D]*\u300D|\u300E[^\u300F]*\u300F)/g;
|
|
3201
3264
|
/** 解释性叙事开头模式 */
|
|
3202
3265
|
const EXPOSITORY_STARTS = [
|
|
3203
3266
|
"原来",
|
|
@@ -3212,28 +3275,103 @@ const EXPOSITORY_STARTS = [
|
|
|
3212
3275
|
"事实上",
|
|
3213
3276
|
"实际上"
|
|
3214
3277
|
];
|
|
3215
|
-
|
|
3216
|
-
|
|
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));
|
|
3316
|
+
}
|
|
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);
|
|
3325
|
+
}
|
|
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)));
|
|
3332
|
+
}
|
|
3333
|
+
function scanAiFlavor(text, rules) {
|
|
3334
|
+
const paragraphs = paragraphsOf(text);
|
|
3217
3335
|
const totalChars = text.length;
|
|
3218
3336
|
const clicheHits = [];
|
|
3219
|
-
let
|
|
3220
|
-
for (const word of
|
|
3337
|
+
let weightedTotal = 0;
|
|
3338
|
+
for (const word of Object.keys(PATTERN_COVERAGE)) {
|
|
3221
3339
|
const count = countOccurrences(text, word);
|
|
3222
|
-
if (count
|
|
3223
|
-
|
|
3224
|
-
|
|
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;
|
|
3348
|
+
}
|
|
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,
|
|
3225
3359
|
count
|
|
3226
3360
|
});
|
|
3227
|
-
clicheTotal += count;
|
|
3228
3361
|
}
|
|
3362
|
+
if (matches.length > 0) ruleHits.push({
|
|
3363
|
+
name: rule.name,
|
|
3364
|
+
severity: rule.severity,
|
|
3365
|
+
matches
|
|
3366
|
+
});
|
|
3229
3367
|
}
|
|
3230
|
-
clicheHits.sort((a, b) => b.count - a.count);
|
|
3231
3368
|
const paraLengths = paragraphs.map((p) => p.length);
|
|
3232
3369
|
const avgLen = paraLengths.length > 0 ? paraLengths.reduce((a, b) => a + b, 0) / paraLengths.length : 0;
|
|
3233
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;
|
|
3234
3372
|
let maxConsecutive = 0;
|
|
3235
3373
|
let currentConsecutive = 0;
|
|
3236
|
-
for (const p of paragraphs) if (EXPOSITORY_STARTS.some((s) => p.startsWith(s)) || p.length > 150 &&
|
|
3374
|
+
for (const p of paragraphs) if (EXPOSITORY_STARTS.some((s) => p.startsWith(s)) || p.length > 150 && !/["\u201C\u300C\u300E]/.test(p)) {
|
|
3237
3375
|
currentConsecutive++;
|
|
3238
3376
|
maxConsecutive = Math.max(maxConsecutive, currentConsecutive);
|
|
3239
3377
|
} else currentConsecutive = 0;
|
|
@@ -3247,48 +3385,50 @@ function scanAiFlavor(text) {
|
|
|
3247
3385
|
const sentenceRepetitionRate = sentences.length > 0 ? repeatedStarters / sentences.length : 0;
|
|
3248
3386
|
const longParagraphCount = paraLengths.filter((l) => l > 300).length;
|
|
3249
3387
|
const shortParagraphCount = paraLengths.filter((l) => l < 20).length;
|
|
3250
|
-
const dialogueChars = (text.match(
|
|
3388
|
+
const dialogueChars = (text.match(DIALOGUE_PATTERN) ?? []).join("").length;
|
|
3251
3389
|
const dialogueRatio = totalChars > 0 ? dialogueChars / totalChars : 0;
|
|
3252
|
-
|
|
3253
|
-
const clicheDensity =
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
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));
|
|
3260
3400
|
const issues = [];
|
|
3261
|
-
if (clicheDensity >
|
|
3262
|
-
const
|
|
3263
|
-
issues.push(
|
|
3264
|
-
}
|
|
3265
|
-
if (
|
|
3266
|
-
if (
|
|
3267
|
-
if (
|
|
3268
|
-
if (
|
|
3269
|
-
|
|
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);
|
|
3404
|
+
}
|
|
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);
|
|
3413
|
+
}
|
|
3414
|
+
const head = "本地 AI 味扫描(AI 味指数 " + aiScore + "/100,共 " + paragraphs.length + " 个自然段)";
|
|
3415
|
+
const summary = issues.length > 0 ? head + ":\n" + issues.map((i) => "- " + i).join("\n") : head + ":未发现明显问题。";
|
|
3270
3416
|
return {
|
|
3271
3417
|
aiScore,
|
|
3272
3418
|
clicheHits,
|
|
3419
|
+
paragraphCount: paragraphs.length,
|
|
3273
3420
|
paragraphLengthVariance: Math.round(variance),
|
|
3421
|
+
paragraphLengthCv: Math.round(cv * 100) / 100,
|
|
3274
3422
|
consecutiveExpositoryParagraphs: maxConsecutive,
|
|
3275
|
-
sentenceRepetitionRate: Math.round(sentenceRepetitionRate *
|
|
3423
|
+
sentenceRepetitionRate: Math.round(sentenceRepetitionRate * 1e3) / 1e3,
|
|
3276
3424
|
longParagraphCount,
|
|
3277
3425
|
shortParagraphCount,
|
|
3278
3426
|
dialogueRatio: Math.round(dialogueRatio * 100) / 100,
|
|
3427
|
+
ruleHits,
|
|
3428
|
+
scoreParts,
|
|
3279
3429
|
summary
|
|
3280
3430
|
};
|
|
3281
3431
|
}
|
|
3282
|
-
function countOccurrences(text, word) {
|
|
3283
|
-
if (word.length === 0) return 0;
|
|
3284
|
-
let count = 0;
|
|
3285
|
-
let idx = text.indexOf(word);
|
|
3286
|
-
while (idx !== -1) {
|
|
3287
|
-
count++;
|
|
3288
|
-
idx = text.indexOf(word, idx + word.length);
|
|
3289
|
-
}
|
|
3290
|
-
return count;
|
|
3291
|
-
}
|
|
3292
3432
|
//#endregion
|
|
3293
3433
|
//#region src/llm-live.ts
|
|
3294
3434
|
/** 帧缓冲上限(新连接重放用)。 */
|
|
@@ -6265,7 +6405,7 @@ function reviewSystemPrompt(project) {
|
|
|
6265
6405
|
"4. 文笔质量:语病、翻译腔、AI 套话(\"不禁\"\"仿佛\"\"一时间\"等高频词滥用)、流水账。",
|
|
6266
6406
|
"5. 节奏与爽点:本章是否有推进、有钩子,是否拖沓灌水。",
|
|
6267
6407
|
"6. 逻辑漏洞:前后矛盾、时间线错误、对话失真。",
|
|
6268
|
-
"7. 反 AI
|
|
6408
|
+
"7. 反 AI 规则:下方「本地 AI 味扫描」里已给出**本轮确定性命中的规则与次数**,直接以它为事实依据判级(禁止类命中即列为问题;鼓励类只作低优先级建议、不阻塞通过)。未列出的规则说明本章无确定性命中,不必逐条复述,也不要凭印象补报。",
|
|
6269
6409
|
"8. 呈现方式:整章是否纯内心推理铺陈(无对话/无对抗,推理全靠解说);反派是否纯背景板无行动;重要配角是否无名标签化(瘦高个/灰衣人全程代称)——命中即列为问题。",
|
|
6270
6410
|
"9. 内容合规(最高优先级):逐条核对下方「内容合规红线」,任何一条命中(含影射、暗示、详细描写)必须列为 high,并给出改写建议。",
|
|
6271
6411
|
"输出必须是合法 JSON 对象,不要输出任何其他文字:",
|
|
@@ -6302,7 +6442,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
6302
6442
|
const body = readChapterFile(outputDir, chapter);
|
|
6303
6443
|
if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
|
|
6304
6444
|
const bodyText = stripChapterHeadings(body);
|
|
6305
|
-
const aiScan = scanAiFlavor(bodyText);
|
|
6445
|
+
const aiScan = scanAiFlavor(bodyText, effectiveAntiAiRules(project.assets));
|
|
6306
6446
|
const blocks = renderContextBlocks(buildChapterContext(project, chapter, outputDir, { stage: "review" }));
|
|
6307
6447
|
const crossChapter = [
|
|
6308
6448
|
blocks.continuityBlock,
|
|
@@ -6374,7 +6514,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
6374
6514
|
*/
|
|
6375
6515
|
async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
6376
6516
|
const bodyText = text.slice(0, 2e4);
|
|
6377
|
-
const aiScan = scanAiFlavor(bodyText);
|
|
6517
|
+
const aiScan = scanAiFlavor(bodyText, effectiveAntiAiRules(project.assets));
|
|
6378
6518
|
const user = [
|
|
6379
6519
|
`书名:《${project.bookName}》`,
|
|
6380
6520
|
previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
|
package/lib/types/ai-scan.d.ts
CHANGED
|
@@ -3,28 +3,88 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 用途:生成/润色后自动扫描,结果作为「事实锚点」注入审稿提示词,
|
|
5
5
|
* 让 LLM 做判断而非机械统计。也可用于 UI 展示 AI 味指标。
|
|
6
|
+
*
|
|
7
|
+
* ## 度量口径
|
|
8
|
+
*
|
|
9
|
+
* 下面五条口径全部用 **1279 章 / 6 本已发布书稿**实测标定过。前两条是**已修复的
|
|
10
|
+
* 真实缺陷**(不是调参偏好),后三条是把不可比的量纲换成可比的。
|
|
11
|
+
*
|
|
12
|
+
* 1. **段落**:书稿正文的自然段由**单换行**分隔(run.ts 写盘即如此),历史实现按
|
|
13
|
+
* /\n{2,}/ 切 —— 在真实书稿上整章只切出 **2 段**(1279 章无一例外),于是段落方差、
|
|
14
|
+
* 过长段、连续解说段三项指标全部失效,其中「连续解说段 >= 3」永不成立。
|
|
15
|
+
* 现按 /\n+/ 切,并剔除 # 标题行。实测每章 p50 = 68 段,段落均长 p50 = 50 字。
|
|
16
|
+
* 2. **对话**:中文书稿主流用全角弯引号(语料 6 本里 **5 本**如此:番茄线上正文、
|
|
17
|
+
* 还债疯了、我养的废柴师妹、归墟玉主、保质期),历史实现的字符类只认 ASCII 双引号
|
|
18
|
+
* 与角括号,使这 5 本的对话占比**恒为 0**,并因此每章误加 10 分。
|
|
19
|
+
* 3. **段落整齐度**:用变异系数 CV = sigma / mean,不用方差 —— 方差的量纲是「字符平方」,
|
|
20
|
+
* 随段长平方缩放,段落均长 50 与均长 500 的章用同一个绝对阈值不可比。
|
|
21
|
+
* 语料 CV:p5 0.55 / p50 0.69 / p95 0.87。
|
|
22
|
+
* 4. **套话按稀有度加权**:历史实现把「重度 / 轻度」两档等同计分,而实测「知道」覆盖
|
|
23
|
+
* 78.4% 的已发布章节、「微微」48.2%,「难以言喻」0.0%、「不由得」0.1%。把前者与后者
|
|
24
|
+
* 等同,会让所有正常章节的基线虚高。现按相对语料的覆盖率给权重(见 PATTERN_COVERAGE)。
|
|
25
|
+
* 5. **短章不再爆炸**:密度分母取 max(字数, 2000) —— 历史实现里同样「1 次套话」,在
|
|
26
|
+
* 444 字的章值 18 分、在 3576 字的章值 2.2 分,同一现象 8 倍权重差。
|
|
27
|
+
*
|
|
28
|
+
* 分项满分与语料分位点的对应关系集中在 {@link SCORE_SPEC},便于日后重新标定。
|
|
6
29
|
*/
|
|
7
30
|
export interface AiScanResult {
|
|
8
31
|
/** 总评分 0-100,越高越像 AI 写的 */
|
|
9
32
|
aiScore: number;
|
|
10
|
-
/**
|
|
33
|
+
/** 命中的套话及次数(weight 为该词按语料稀有度折算的权重,0 = 功能词) */
|
|
11
34
|
clicheHits: Array<{
|
|
12
35
|
word: string;
|
|
13
36
|
count: number;
|
|
37
|
+
weight: number;
|
|
14
38
|
}>;
|
|
15
|
-
/**
|
|
39
|
+
/** 判定用的自然段数(口径可自查:真实书稿每章数十段) */
|
|
40
|
+
paragraphCount: number;
|
|
41
|
+
/** 段落长度方差(保留原字段,注意其量纲随段长平方缩放,判定请用 cv) */
|
|
16
42
|
paragraphLengthVariance: number;
|
|
17
|
-
/**
|
|
43
|
+
/** 段落长度变异系数 sigma / mean:与段长规模无关,整齐度判定用这个 */
|
|
44
|
+
paragraphLengthCv: number;
|
|
45
|
+
/** 连续解释性叙事段数(>=3 视为问题) */
|
|
18
46
|
consecutiveExpositoryParagraphs: number;
|
|
19
47
|
/** 句式重复率(相同开头句式占比) */
|
|
20
48
|
sentenceRepetitionRate: number;
|
|
21
|
-
/** 过长段落数(>300字) */
|
|
49
|
+
/** 过长段落数(>300 字) */
|
|
22
50
|
longParagraphCount: number;
|
|
23
|
-
/** 过短段落数(<20字) */
|
|
51
|
+
/** 过短段落数(<20 字) */
|
|
24
52
|
shortParagraphCount: number;
|
|
25
53
|
/** 对话占比(0-1) */
|
|
26
54
|
dialogueRatio: number;
|
|
55
|
+
/** 作者生效规则里的确定性命中(按规则逐条统计;未传规则时为空) */
|
|
56
|
+
ruleHits: AiScanRuleHit[];
|
|
57
|
+
/** 各分项得分:便于自查「这章凭什么这么多分」,也是标定与单测的抓手 */
|
|
58
|
+
scoreParts: AiScanScoreParts;
|
|
27
59
|
/** 问题摘要,可直接注入审稿提示词 */
|
|
28
60
|
summary: string;
|
|
29
61
|
}
|
|
30
|
-
|
|
62
|
+
/** 评分分项(每项满分与语料分位点的对应见 SCORE_SPEC)。 */
|
|
63
|
+
export interface AiScanScoreParts {
|
|
64
|
+
/** 套话/模式:满分对应语料加权密度 p99(2.478/千字,63 模式并集口径) */
|
|
65
|
+
cliche: number;
|
|
66
|
+
/** 段落过于整齐(CV 低于语料 p5) */
|
|
67
|
+
uniformParagraphs: number;
|
|
68
|
+
/** 连续解释性叙事 */
|
|
69
|
+
expository: number;
|
|
70
|
+
/** 句首重复率(零点取语料 p50 = 0.37,满分取 p99 = 0.63) */
|
|
71
|
+
repetition: number;
|
|
72
|
+
/** 对话占比过低 */
|
|
73
|
+
lowDialogue: number;
|
|
74
|
+
}
|
|
75
|
+
/** 扫描器需要的规则输入(结构类型,避免 ai-scan 反向依赖协议层)。 */
|
|
76
|
+
export interface AiScanRuleInput {
|
|
77
|
+
name: string;
|
|
78
|
+
severity?: 'forbidden' | 'risk' | 'encourage';
|
|
79
|
+
detectPatterns?: readonly string[];
|
|
80
|
+
}
|
|
81
|
+
/** 一条规则在本章的确定性命中(只报命中的规则)。 */
|
|
82
|
+
export interface AiScanRuleHit {
|
|
83
|
+
name: string;
|
|
84
|
+
severity?: 'forbidden' | 'risk' | 'encourage';
|
|
85
|
+
matches: Array<{
|
|
86
|
+
pattern: string;
|
|
87
|
+
count: number;
|
|
88
|
+
}>;
|
|
89
|
+
}
|
|
90
|
+
export declare function scanAiFlavor(text: string, rules?: readonly AiScanRuleInput[]): AiScanResult;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterwx/dsh-novel-forge",
|
|
3
3
|
"description": "AI 编译纯文本小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan volumes & chapters with LLM, generate chapter by chapter (3000-4000 chars each), auto AI review & book audit, and export the full book as Markdown/TXT into your chosen output folder.",
|
|
4
|
-
"version": "2.1.
|
|
4
|
+
"version": "2.1.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "waterwx",
|
|
7
7
|
"repository": {
|