@waterwx/dsh-novel-forge 2.1.2 → 2.1.4
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 +757 -457
- package/lib/index.js +1255 -848
- package/lib/types/ai-scan.d.ts +14 -0
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/panel/StyleView.d.ts +18 -0
- package/lib/types/engine.d.ts +26 -0
- package/lib/types/protocol.d.ts +27 -0
- package/lib/types/style-fingerprint.d.ts +78 -0
- package/package.json +1 -1
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 = [
|
|
@@ -983,12 +1546,7 @@ const BUILTIN_ANTI_AI_RULES = [
|
|
|
983
1546
|
name: "句式重复率偏高",
|
|
984
1547
|
avoid: "连续使用同构句式(「首先…然后…接着…最后…」),读起来机械。",
|
|
985
1548
|
fix: "拉开句长与起句方式,打散结构。",
|
|
986
|
-
detectPatterns: [
|
|
987
|
-
"首先",
|
|
988
|
-
"然后",
|
|
989
|
-
"接着",
|
|
990
|
-
"最后"
|
|
991
|
-
],
|
|
1549
|
+
detectPatterns: ["首先"],
|
|
992
1550
|
builtin: true,
|
|
993
1551
|
key: "risk-repeated-sentence-structure",
|
|
994
1552
|
severity: "forbidden",
|
|
@@ -2539,895 +3097,631 @@ const BUILTIN_PROGRESSION_MODES = [
|
|
|
2539
3097
|
mandatorySignals: [
|
|
2540
3098
|
"资源压力",
|
|
2541
3099
|
"选择代价",
|
|
2542
|
-
"规则利用"
|
|
2543
|
-
],
|
|
2544
|
-
antiSignals: ["危机廉价化", "困境反复在同一类里打转"],
|
|
2545
|
-
resolutionStyle: "靠吃透规则、判断局势与有限资源调度破局。"
|
|
2546
|
-
},
|
|
2547
|
-
{
|
|
2548
|
-
key: "story_mode_romantic_tension",
|
|
2549
|
-
name: "恋爱拉扯",
|
|
2550
|
-
template: "拉扯要有温差和推进,不能只靠误会拖延。",
|
|
2551
|
-
driver: "通过双向吸引与现实阻力之间的反复拉扯维持情感张力。",
|
|
2552
|
-
readerExpectation: "读者不断看到关系升温、试探失控和情绪回收。",
|
|
2553
|
-
payoffs: [
|
|
2554
|
-
"试探靠近",
|
|
2555
|
-
"边界碰撞",
|
|
2556
|
-
"情绪失衡",
|
|
2557
|
-
"关系推进"
|
|
2558
|
-
],
|
|
2559
|
-
risks: ["只有误会拖延", "关系没有实质变化"],
|
|
2560
|
-
primary: false,
|
|
2561
|
-
progressionUnits: [
|
|
2562
|
-
"试探靠近",
|
|
2563
|
-
"边界碰撞",
|
|
2564
|
-
"情绪失衡",
|
|
2565
|
-
"关系推进"
|
|
2566
|
-
],
|
|
2567
|
-
allowedConflictForms: [
|
|
2568
|
-
"暧昧错位",
|
|
2569
|
-
"表达失败",
|
|
2570
|
-
"现实阻力",
|
|
2571
|
-
"价值观差异"
|
|
2572
|
-
],
|
|
2573
|
-
forbiddenConflictForms: [
|
|
2574
|
-
"故意降智不说人话",
|
|
2575
|
-
"拖太久不推进",
|
|
2576
|
-
"关系线被主线彻底盖住"
|
|
2577
|
-
],
|
|
2578
|
-
conflictCeiling: "medium",
|
|
2579
|
-
chapterUnit: "单章推进一个情感试探、认知变化或关系边界松动。",
|
|
2580
|
-
volumeReward: "卷末让关系进入新阶段,或完成一次高浓度情绪确认。",
|
|
2581
|
-
mandatorySignals: [
|
|
2582
|
-
"暧昧感",
|
|
2583
|
-
"双向张力",
|
|
2584
|
-
"靠近兑现"
|
|
2585
|
-
],
|
|
2586
|
-
antiSignals: ["只有误会拖延", "关系没有实质变化"],
|
|
2587
|
-
resolutionStyle: "靠互动升级、情绪承认与行动选择推进关系。"
|
|
2588
|
-
},
|
|
2589
|
-
{
|
|
2590
|
-
key: "story_mode_chasing_wife",
|
|
2591
|
-
name: "追妻火葬场",
|
|
2592
|
-
template: "核心是追偿和重建,不是单方面反复伤害。",
|
|
2593
|
-
driver: "通过失去后的追偿、悔改和关系重建制造强烈情绪牵引。",
|
|
2594
|
-
readerExpectation: "读者持续获得‘该追的在追、该还的在还、该痛的有回应’的满足。",
|
|
2595
|
-
payoffs: [
|
|
2596
|
-
"后悔显现",
|
|
2597
|
-
"追偿行动",
|
|
2598
|
-
"关系试炼",
|
|
2599
|
-
"重建兑现"
|
|
2600
|
-
],
|
|
2601
|
-
risks: ["只会口头道歉", "修复关系毫无代价"],
|
|
2602
|
-
primary: false,
|
|
2603
|
-
progressionUnits: [
|
|
2604
|
-
"后悔显现",
|
|
2605
|
-
"追偿行动",
|
|
2606
|
-
"关系试炼",
|
|
2607
|
-
"重建兑现"
|
|
2608
|
-
],
|
|
2609
|
-
allowedConflictForms: [
|
|
2610
|
-
"信任断裂",
|
|
2611
|
-
"旧伤反扑",
|
|
2612
|
-
"补偿落差",
|
|
2613
|
-
"现实阻碍"
|
|
2614
|
-
],
|
|
2615
|
-
forbiddenConflictForms: [
|
|
2616
|
-
"追偿只停留在嘴上",
|
|
2617
|
-
"反复受伤却始终没有出口",
|
|
2618
|
-
"被追的一方彻底失去主动权"
|
|
2619
|
-
],
|
|
2620
|
-
conflictCeiling: "medium",
|
|
2621
|
-
chapterUnit: "单章推进一次追偿动作、一次旧伤碰撞或一次关系松动。",
|
|
2622
|
-
volumeReward: "卷末形成清晰的关系修复阶段或强力情绪回收。",
|
|
2623
|
-
mandatorySignals: [
|
|
2624
|
-
"悔意落地",
|
|
2625
|
-
"行动补偿",
|
|
2626
|
-
"边界重建"
|
|
2627
|
-
],
|
|
2628
|
-
antiSignals: ["只会口头道歉", "修复关系毫无代价"],
|
|
2629
|
-
resolutionStyle: "用持续行动、承担代价与尊重边界推进关系重建。"
|
|
2630
|
-
},
|
|
2631
|
-
{
|
|
2632
|
-
key: "story_mode_ensemble_bond",
|
|
2633
|
-
name: "群像羁绊",
|
|
2634
|
-
template: "群像不是名单堆砌,而是每个角色都要在关系网里发挥作用。",
|
|
2635
|
-
driver: "通过多人互动和羁绊变化让故事始终有新的关系火花。",
|
|
2636
|
-
readerExpectation: "读者不断获得角色之间的新连接、站队变化和情感共振。",
|
|
2637
|
-
payoffs: [
|
|
2638
|
-
"关系串联",
|
|
2639
|
-
"群体事件",
|
|
2640
|
-
"立场变化",
|
|
2641
|
-
"羁绊加深"
|
|
2642
|
-
],
|
|
2643
|
-
risks: ["角色只报名字不干活", "群像散架,成了独角戏"],
|
|
2644
|
-
primary: false,
|
|
2645
|
-
progressionUnits: [
|
|
2646
|
-
"关系串联",
|
|
2647
|
-
"群体事件",
|
|
2648
|
-
"立场变化",
|
|
2649
|
-
"羁绊加深"
|
|
2650
|
-
],
|
|
2651
|
-
allowedConflictForms: [
|
|
2652
|
-
"立场差异",
|
|
2653
|
-
"责任分歧",
|
|
2654
|
-
"情感错位",
|
|
2655
|
-
"团队压力"
|
|
2656
|
-
],
|
|
2657
|
-
forbiddenConflictForms: [
|
|
2658
|
-
"群像角色长期工具化",
|
|
2659
|
-
"只剩主角独走",
|
|
2660
|
-
"人物关系始终原样不动"
|
|
2661
|
-
],
|
|
2662
|
-
conflictCeiling: "medium",
|
|
2663
|
-
chapterUnit: "单章推进一个群体互动节点或关键角色对之间的关系变化。",
|
|
2664
|
-
volumeReward: "卷末让群像结构更稳,或让队伍/关系网进入新阶段。",
|
|
2665
|
-
mandatorySignals: [
|
|
2666
|
-
"群体互动",
|
|
2667
|
-
"关系联动",
|
|
2668
|
-
"角色互相成就"
|
|
2669
|
-
],
|
|
2670
|
-
antiSignals: ["角色只报名字不干活", "群像散架,成了独角戏"],
|
|
2671
|
-
resolutionStyle: "靠团队互动、共同经历与角色选择推动关系演化。"
|
|
2672
|
-
},
|
|
2673
|
-
{
|
|
2674
|
-
key: "story_mode_growth_leveling",
|
|
2675
|
-
name: "升级成长",
|
|
2676
|
-
template: "升级不只写结果,还要写门槛、代价和兑现。",
|
|
2677
|
-
driver: "通过清晰可感的成长曲线和阶段突破持续拉动阅读。",
|
|
2678
|
-
readerExpectation: "读者不断看到能力、地位或认知层级获得实质提升。",
|
|
2679
|
-
payoffs: [
|
|
2680
|
-
"积累条件",
|
|
2681
|
-
"卡点受阻",
|
|
2682
|
-
"完成突破",
|
|
2683
|
-
"展示提升"
|
|
2684
|
-
],
|
|
2685
|
-
risks: ["升级像流水账", "能力提升不带来剧情后果"],
|
|
2686
|
-
primary: false,
|
|
2687
|
-
progressionUnits: [
|
|
2688
|
-
"积累条件",
|
|
2689
|
-
"卡点受阻",
|
|
2690
|
-
"完成突破",
|
|
2691
|
-
"展示提升"
|
|
2692
|
-
],
|
|
2693
|
-
allowedConflictForms: [
|
|
2694
|
-
"瓶颈",
|
|
2695
|
-
"试炼",
|
|
2696
|
-
"资源不足",
|
|
2697
|
-
"同层竞争"
|
|
2698
|
-
],
|
|
2699
|
-
forbiddenConflictForms: [
|
|
2700
|
-
"成长全靠旁白略过",
|
|
2701
|
-
"突破既无代价也无铺垫",
|
|
2702
|
-
"升级之后拿不到任何兑现"
|
|
2703
|
-
],
|
|
2704
|
-
conflictCeiling: "high",
|
|
2705
|
-
chapterUnit: "单章推进一次积累、一次卡点试探或一次成长兑现。",
|
|
2706
|
-
volumeReward: "卷末完成一个清晰等级、境界或阶段跃迁。",
|
|
2707
|
-
mandatorySignals: [
|
|
2708
|
-
"成长门槛",
|
|
2709
|
-
"突破反馈",
|
|
2710
|
-
"阶段变化"
|
|
3100
|
+
"规则利用"
|
|
2711
3101
|
],
|
|
2712
|
-
antiSignals: ["
|
|
2713
|
-
resolutionStyle: "
|
|
3102
|
+
antiSignals: ["危机廉价化", "困境反复在同一类里打转"],
|
|
3103
|
+
resolutionStyle: "靠吃透规则、判断局势与有限资源调度破局。"
|
|
2714
3104
|
},
|
|
2715
3105
|
{
|
|
2716
|
-
key: "
|
|
2717
|
-
name: "
|
|
2718
|
-
template: "
|
|
2719
|
-
driver: "
|
|
2720
|
-
readerExpectation: "
|
|
3106
|
+
key: "story_mode_romantic_tension",
|
|
3107
|
+
name: "恋爱拉扯",
|
|
3108
|
+
template: "拉扯要有温差和推进,不能只靠误会拖延。",
|
|
3109
|
+
driver: "通过双向吸引与现实阻力之间的反复拉扯维持情感张力。",
|
|
3110
|
+
readerExpectation: "读者不断看到关系升温、试探失控和情绪回收。",
|
|
2721
3111
|
payoffs: [
|
|
2722
|
-
"
|
|
2723
|
-
"
|
|
2724
|
-
"
|
|
2725
|
-
"
|
|
3112
|
+
"试探靠近",
|
|
3113
|
+
"边界碰撞",
|
|
3114
|
+
"情绪失衡",
|
|
3115
|
+
"关系推进"
|
|
2726
3116
|
],
|
|
2727
|
-
risks: ["
|
|
3117
|
+
risks: ["只有误会拖延", "关系没有实质变化"],
|
|
2728
3118
|
primary: false,
|
|
2729
3119
|
progressionUnits: [
|
|
2730
|
-
"
|
|
2731
|
-
"
|
|
2732
|
-
"
|
|
2733
|
-
"
|
|
3120
|
+
"试探靠近",
|
|
3121
|
+
"边界碰撞",
|
|
3122
|
+
"情绪失衡",
|
|
3123
|
+
"关系推进"
|
|
2734
3124
|
],
|
|
2735
3125
|
allowedConflictForms: [
|
|
2736
|
-
"
|
|
2737
|
-
"
|
|
2738
|
-
"
|
|
2739
|
-
"
|
|
3126
|
+
"暧昧错位",
|
|
3127
|
+
"表达失败",
|
|
3128
|
+
"现实阻力",
|
|
3129
|
+
"价值观差异"
|
|
2740
3130
|
],
|
|
2741
3131
|
forbiddenConflictForms: [
|
|
2742
|
-
"
|
|
2743
|
-
"
|
|
2744
|
-
"
|
|
3132
|
+
"故意降智不说人话",
|
|
3133
|
+
"拖太久不推进",
|
|
3134
|
+
"关系线被主线彻底盖住"
|
|
2745
3135
|
],
|
|
2746
|
-
conflictCeiling: "
|
|
2747
|
-
chapterUnit: "
|
|
2748
|
-
volumeReward: "
|
|
3136
|
+
conflictCeiling: "medium",
|
|
3137
|
+
chapterUnit: "单章推进一个情感试探、认知变化或关系边界松动。",
|
|
3138
|
+
volumeReward: "卷末让关系进入新阶段,或完成一次高浓度情绪确认。",
|
|
2749
3139
|
mandatorySignals: [
|
|
2750
|
-
"
|
|
2751
|
-
"
|
|
2752
|
-
"
|
|
3140
|
+
"暧昧感",
|
|
3141
|
+
"双向张力",
|
|
3142
|
+
"靠近兑现"
|
|
2753
3143
|
],
|
|
2754
|
-
antiSignals: ["
|
|
2755
|
-
resolutionStyle: "
|
|
3144
|
+
antiSignals: ["只有误会拖延", "关系没有实质变化"],
|
|
3145
|
+
resolutionStyle: "靠互动升级、情绪承认与行动选择推进关系。"
|
|
2756
3146
|
},
|
|
2757
3147
|
{
|
|
2758
|
-
key: "
|
|
2759
|
-
name: "
|
|
2760
|
-
template: "
|
|
2761
|
-
driver: "
|
|
2762
|
-
readerExpectation: "
|
|
3148
|
+
key: "story_mode_chasing_wife",
|
|
3149
|
+
name: "追妻火葬场",
|
|
3150
|
+
template: "核心是追偿和重建,不是单方面反复伤害。",
|
|
3151
|
+
driver: "通过失去后的追偿、悔改和关系重建制造强烈情绪牵引。",
|
|
3152
|
+
readerExpectation: "读者持续获得‘该追的在追、该还的在还、该痛的有回应’的满足。",
|
|
2763
3153
|
payoffs: [
|
|
2764
|
-
"
|
|
2765
|
-
"
|
|
2766
|
-
"
|
|
2767
|
-
"
|
|
3154
|
+
"后悔显现",
|
|
3155
|
+
"追偿行动",
|
|
3156
|
+
"关系试炼",
|
|
3157
|
+
"重建兑现"
|
|
2768
3158
|
],
|
|
2769
|
-
risks: ["
|
|
3159
|
+
risks: ["只会口头道歉", "修复关系毫无代价"],
|
|
2770
3160
|
primary: false,
|
|
2771
3161
|
progressionUnits: [
|
|
2772
|
-
"
|
|
2773
|
-
"
|
|
2774
|
-
"
|
|
2775
|
-
"
|
|
3162
|
+
"后悔显现",
|
|
3163
|
+
"追偿行动",
|
|
3164
|
+
"关系试炼",
|
|
3165
|
+
"重建兑现"
|
|
2776
3166
|
],
|
|
2777
3167
|
allowedConflictForms: [
|
|
2778
|
-
"
|
|
2779
|
-
"
|
|
2780
|
-
"
|
|
2781
|
-
"
|
|
3168
|
+
"信任断裂",
|
|
3169
|
+
"旧伤反扑",
|
|
3170
|
+
"补偿落差",
|
|
3171
|
+
"现实阻碍"
|
|
2782
3172
|
],
|
|
2783
3173
|
forbiddenConflictForms: [
|
|
2784
|
-
"
|
|
2785
|
-
"
|
|
2786
|
-
"
|
|
3174
|
+
"追偿只停留在嘴上",
|
|
3175
|
+
"反复受伤却始终没有出口",
|
|
3176
|
+
"被追的一方彻底失去主动权"
|
|
2787
3177
|
],
|
|
2788
|
-
conflictCeiling: "
|
|
2789
|
-
chapterUnit: "
|
|
2790
|
-
volumeReward: "
|
|
3178
|
+
conflictCeiling: "medium",
|
|
3179
|
+
chapterUnit: "单章推进一次追偿动作、一次旧伤碰撞或一次关系松动。",
|
|
3180
|
+
volumeReward: "卷末形成清晰的关系修复阶段或强力情绪回收。",
|
|
2791
3181
|
mandatorySignals: [
|
|
2792
|
-
"
|
|
2793
|
-
"
|
|
2794
|
-
"
|
|
2795
|
-
],
|
|
2796
|
-
antiSignals: ["关卡同质化", "只有数值叠加,没有机制可言"],
|
|
2797
|
-
resolutionStyle: "靠识别规则、团队配合与关键执行完成通关。"
|
|
2798
|
-
}
|
|
2799
|
-
];
|
|
2800
|
-
/** 剧情桥段库:可复用情节套路(作者阅读经验沉淀,非某本书的剧情线)。 */
|
|
2801
|
-
const BUILTIN_PLOT_BEATS = [
|
|
2802
|
-
{
|
|
2803
|
-
key: "play-weak",
|
|
2804
|
-
name: "扮猪吃虎",
|
|
2805
|
-
category: "装逼打脸",
|
|
2806
|
-
summary: "主角示弱/隐藏实力,关键时刻亮出獠牙,反差打脸。",
|
|
2807
|
-
position: "前期/中期",
|
|
2808
|
-
preconditions: ["主角有远超表面的实力", "有旁观者低估他"],
|
|
2809
|
-
payoffSource: [
|
|
2810
|
-
"身份反差",
|
|
2811
|
-
"实力揭晓时众人错愕",
|
|
2812
|
-
"质疑者被打脸"
|
|
3182
|
+
"悔意落地",
|
|
3183
|
+
"行动补偿",
|
|
3184
|
+
"边界重建"
|
|
2813
3185
|
],
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
applicableGenres: [
|
|
2817
|
-
"都市",
|
|
2818
|
-
"玄幻",
|
|
2819
|
-
"仙侠"
|
|
2820
|
-
]
|
|
2821
|
-
},
|
|
2822
|
-
{
|
|
2823
|
-
key: "face-slap",
|
|
2824
|
-
name: "打脸",
|
|
2825
|
-
category: "装逼打脸",
|
|
2826
|
-
summary: "挑衅者趾高气扬,主角一击让其灰头土脸,情绪宣泄。",
|
|
2827
|
-
position: "任何",
|
|
2828
|
-
preconditions: ["挑衅者有明确的优越感", "主角有强出一截的底牌"],
|
|
2829
|
-
payoffSource: ["当众羞辱反杀", "围观者态度反转"],
|
|
2830
|
-
combos: ["扮猪吃虎", "大比"],
|
|
2831
|
-
taboos: ["连续无新意打脸", "羞辱过头读者不适"],
|
|
2832
|
-
applicableGenres: [
|
|
2833
|
-
"都市",
|
|
2834
|
-
"玄幻",
|
|
2835
|
-
"仙侠"
|
|
2836
|
-
]
|
|
2837
|
-
},
|
|
2838
|
-
{
|
|
2839
|
-
key: "broken-engagement",
|
|
2840
|
-
name: "退婚/反悔",
|
|
2841
|
-
category: "身份逆袭",
|
|
2842
|
-
summary: "被退婚/被轻视→用实力打回,顺势立目标。",
|
|
2843
|
-
position: "开局/前期",
|
|
2844
|
-
preconditions: ["主角处于弱势", "退婚方势利"],
|
|
2845
|
-
payoffSource: ["被轻贱→反杀", "立下目标"],
|
|
2846
|
-
combos: ["打脸", "金手指亮相"],
|
|
2847
|
-
taboos: ["拖大几十章才洗", "女主误会太久"],
|
|
2848
|
-
applicableGenres: [
|
|
2849
|
-
"玄幻",
|
|
2850
|
-
"都市",
|
|
2851
|
-
"豪门"
|
|
2852
|
-
]
|
|
2853
|
-
},
|
|
2854
|
-
{
|
|
2855
|
-
key: "auction-bargain",
|
|
2856
|
-
name: "拍卖会捡漏",
|
|
2857
|
-
category: "机缘",
|
|
2858
|
-
summary: "主角在拍卖会上用独到眼光/信息差捞到被低估的宝物。",
|
|
2859
|
-
position: "中期",
|
|
2860
|
-
preconditions: ["主角有识货能力/金手指", "现场有争夺者"],
|
|
2861
|
-
payoffSource: ["捡漏爽", "争夺者懊悔"],
|
|
2862
|
-
combos: ["渔翁得利", "宝物升级"],
|
|
2863
|
-
taboos: ["全程顺风顺水无波折", "宝物无后续用处"],
|
|
2864
|
-
applicableGenres: [
|
|
2865
|
-
"玄幻",
|
|
2866
|
-
"仙侠",
|
|
2867
|
-
"都市"
|
|
2868
|
-
]
|
|
2869
|
-
},
|
|
2870
|
-
{
|
|
2871
|
-
key: "secret-realm",
|
|
2872
|
-
name: "秘境/夺宝",
|
|
2873
|
-
category: "机缘",
|
|
2874
|
-
summary: "秘境开启,主角入内夺宝/悟道,或遇强敌/阴谋。",
|
|
2875
|
-
position: "中期/后期",
|
|
2876
|
-
preconditions: ["秘境有明确利益", "竞争者众多"],
|
|
2877
|
-
payoffSource: ["机缘得宝", "危机中成长"],
|
|
2878
|
-
combos: ["夺宝", "绝境翻盘"],
|
|
2879
|
-
taboos: ["宝物白给", "危机虎头蛇尾"],
|
|
2880
|
-
applicableGenres: [
|
|
2881
|
-
"仙侠",
|
|
2882
|
-
"玄幻",
|
|
2883
|
-
"悬疑"
|
|
2884
|
-
]
|
|
2885
|
-
},
|
|
2886
|
-
{
|
|
2887
|
-
key: "misunderstanding",
|
|
2888
|
-
name: "误会/解释不清",
|
|
2889
|
-
category: "情感拉扯",
|
|
2890
|
-
summary: "一方误解另一方的行为,情绪拉满后解开。",
|
|
2891
|
-
position: "任何",
|
|
2892
|
-
preconditions: ["信息不对称", "两方都在意关系"],
|
|
2893
|
-
payoffSource: ["误会造痛", "和解破冰"],
|
|
2894
|
-
combos: ["关系拉扯", "身份揭露"],
|
|
2895
|
-
taboos: ["误会拖太久", "为虐而虐"],
|
|
2896
|
-
applicableGenres: [
|
|
2897
|
-
"都市",
|
|
2898
|
-
"情感",
|
|
2899
|
-
"古言"
|
|
2900
|
-
]
|
|
2901
|
-
},
|
|
2902
|
-
{
|
|
2903
|
-
key: "last-stand",
|
|
2904
|
-
name: "绝境翻盘",
|
|
2905
|
-
category: "战斗",
|
|
2906
|
-
summary: "主角被逼到绝境,借助底牌/意志完成反杀。",
|
|
2907
|
-
position: "高潮",
|
|
2908
|
-
preconditions: ["主角实力明显劣势", "有破局底牌"],
|
|
2909
|
-
payoffSource: ["绝处逢生", "反派嚣张后被打脸"],
|
|
2910
|
-
combos: ["金手指爆发", "打脸"],
|
|
2911
|
-
taboos: ["开挂太突兀", "翻盘无代价"],
|
|
2912
|
-
applicableGenres: [
|
|
2913
|
-
"玄幻",
|
|
2914
|
-
"武侠",
|
|
2915
|
-
"无限流"
|
|
2916
|
-
]
|
|
2917
|
-
},
|
|
2918
|
-
{
|
|
2919
|
-
key: "tournament",
|
|
2920
|
-
name: "大比/比试",
|
|
2921
|
-
category: "升级打脸",
|
|
2922
|
-
summary: "宗门/势力大比,主角一路过关,暴露实力、收获名声。",
|
|
2923
|
-
position: "中期/后期",
|
|
2924
|
-
preconditions: ["有正式舞台", "有看客与对手"],
|
|
2925
|
-
payoffSource: ["连胜升级", "众目睽睽下打脸"],
|
|
2926
|
-
combos: ["打脸", "身份揭露"],
|
|
2927
|
-
taboos: ["比赛水太多", "对手全送脸"],
|
|
2928
|
-
applicableGenres: [
|
|
2929
|
-
"仙侠",
|
|
2930
|
-
"玄幻",
|
|
2931
|
-
"体育竞技"
|
|
2932
|
-
]
|
|
3186
|
+
antiSignals: ["只会口头道歉", "修复关系毫无代价"],
|
|
3187
|
+
resolutionStyle: "用持续行动、承担代价与尊重边界推进关系重建。"
|
|
2933
3188
|
},
|
|
2934
3189
|
{
|
|
2935
|
-
key: "
|
|
2936
|
-
name: "
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
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: "第一次开书想先把推进感与爽点兑现跑顺,可以直接拿它起手,之后在这个底子上改。"
|
|
3190
|
+
key: "story_mode_ensemble_bond",
|
|
3191
|
+
name: "群像羁绊",
|
|
3192
|
+
template: "群像不是名单堆砌,而是每个角色都要在关系网里发挥作用。",
|
|
3193
|
+
driver: "通过多人互动和羁绊变化让故事始终有新的关系火花。",
|
|
3194
|
+
readerExpectation: "读者不断获得角色之间的新连接、站队变化和情感共振。",
|
|
3195
|
+
payoffs: [
|
|
3196
|
+
"关系串联",
|
|
3197
|
+
"群体事件",
|
|
3198
|
+
"立场变化",
|
|
3199
|
+
"羁绊加深"
|
|
3200
|
+
],
|
|
3201
|
+
risks: ["角色只报名字不干活", "群像散架,成了独角戏"],
|
|
3202
|
+
primary: false,
|
|
3203
|
+
progressionUnits: [
|
|
3204
|
+
"关系串联",
|
|
3205
|
+
"群体事件",
|
|
3206
|
+
"立场变化",
|
|
3207
|
+
"羁绊加深"
|
|
3208
|
+
],
|
|
3209
|
+
allowedConflictForms: [
|
|
3210
|
+
"立场差异",
|
|
3211
|
+
"责任分歧",
|
|
3212
|
+
"情感错位",
|
|
3213
|
+
"团队压力"
|
|
3214
|
+
],
|
|
3215
|
+
forbiddenConflictForms: [
|
|
3216
|
+
"群像角色长期工具化",
|
|
3217
|
+
"只剩主角独走",
|
|
3218
|
+
"人物关系始终原样不动"
|
|
3219
|
+
],
|
|
3220
|
+
conflictCeiling: "medium",
|
|
3221
|
+
chapterUnit: "单章推进一个群体互动节点或关键角色对之间的关系变化。",
|
|
3222
|
+
volumeReward: "卷末让群像结构更稳,或让队伍/关系网进入新阶段。",
|
|
3223
|
+
mandatorySignals: [
|
|
3224
|
+
"群体互动",
|
|
3225
|
+
"关系联动",
|
|
3226
|
+
"角色互相成就"
|
|
3227
|
+
],
|
|
3228
|
+
antiSignals: ["角色只报名字不干活", "群像散架,成了独角戏"],
|
|
3229
|
+
resolutionStyle: "靠团队互动、共同经历与角色选择推动关系演化。"
|
|
3108
3230
|
},
|
|
3109
3231
|
{
|
|
3110
|
-
key: "
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3232
|
+
key: "story_mode_growth_leveling",
|
|
3233
|
+
name: "升级成长",
|
|
3234
|
+
template: "升级不只写结果,还要写门槛、代价和兑现。",
|
|
3235
|
+
driver: "通过清晰可感的成长曲线和阶段突破持续拉动阅读。",
|
|
3236
|
+
readerExpectation: "读者不断看到能力、地位或认知层级获得实质提升。",
|
|
3237
|
+
payoffs: [
|
|
3238
|
+
"积累条件",
|
|
3239
|
+
"卡点受阻",
|
|
3240
|
+
"完成突破",
|
|
3241
|
+
"展示提升"
|
|
3242
|
+
],
|
|
3243
|
+
risks: ["升级像流水账", "能力提升不带来剧情后果"],
|
|
3244
|
+
primary: false,
|
|
3245
|
+
progressionUnits: [
|
|
3246
|
+
"积累条件",
|
|
3247
|
+
"卡点受阻",
|
|
3248
|
+
"完成突破",
|
|
3249
|
+
"展示提升"
|
|
3250
|
+
],
|
|
3251
|
+
allowedConflictForms: [
|
|
3252
|
+
"瓶颈",
|
|
3253
|
+
"试炼",
|
|
3254
|
+
"资源不足",
|
|
3255
|
+
"同层竞争"
|
|
3256
|
+
],
|
|
3257
|
+
forbiddenConflictForms: [
|
|
3258
|
+
"成长全靠旁白略过",
|
|
3259
|
+
"突破既无代价也无铺垫",
|
|
3260
|
+
"升级之后拿不到任何兑现"
|
|
3261
|
+
],
|
|
3262
|
+
conflictCeiling: "high",
|
|
3263
|
+
chapterUnit: "单章推进一次积累、一次卡点试探或一次成长兑现。",
|
|
3264
|
+
volumeReward: "卷末完成一个清晰等级、境界或阶段跃迁。",
|
|
3265
|
+
mandatorySignals: [
|
|
3266
|
+
"成长门槛",
|
|
3267
|
+
"突破反馈",
|
|
3268
|
+
"阶段变化"
|
|
3269
|
+
],
|
|
3270
|
+
antiSignals: ["升级像流水账", "能力提升不带来剧情后果"],
|
|
3271
|
+
resolutionStyle: "靠积累、训练、实战与关键顿悟完成成长突破。"
|
|
3114
3272
|
},
|
|
3115
3273
|
{
|
|
3116
|
-
key: "
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3274
|
+
key: "story_mode_adventure_exploration",
|
|
3275
|
+
name: "探索冒险",
|
|
3276
|
+
template: "探索要带来真正的新信息、新选择和新危险。",
|
|
3277
|
+
driver: "通过不断进入未知区域、接触新规则和发现新秘密保持阅读新鲜感。",
|
|
3278
|
+
readerExpectation: "读者持续获得开图、发现与未知感兑现。",
|
|
3279
|
+
payoffs: [
|
|
3280
|
+
"进入新区域",
|
|
3281
|
+
"发现新规则",
|
|
3282
|
+
"遭遇风险",
|
|
3283
|
+
"带出新认知"
|
|
3284
|
+
],
|
|
3285
|
+
risks: ["探索空转", "新区域没有存在的必要"],
|
|
3286
|
+
primary: false,
|
|
3287
|
+
progressionUnits: [
|
|
3288
|
+
"进入新区域",
|
|
3289
|
+
"发现新规则",
|
|
3290
|
+
"遭遇风险",
|
|
3291
|
+
"带出新认知"
|
|
3292
|
+
],
|
|
3293
|
+
allowedConflictForms: [
|
|
3294
|
+
"环境威胁",
|
|
3295
|
+
"未知规则",
|
|
3296
|
+
"探索竞争",
|
|
3297
|
+
"文明差异"
|
|
3298
|
+
],
|
|
3299
|
+
forbiddenConflictForms: [
|
|
3300
|
+
"场景只是换皮",
|
|
3301
|
+
"探索不产出新信息",
|
|
3302
|
+
"一直原地打转,世界边界不动"
|
|
3303
|
+
],
|
|
3304
|
+
conflictCeiling: "high",
|
|
3305
|
+
chapterUnit: "单章推进一次新发现、一次环境应对或一次地图探索。",
|
|
3306
|
+
volumeReward: "卷末打开新的区域层级、秘密入口或冒险目标。",
|
|
3307
|
+
mandatorySignals: [
|
|
3308
|
+
"未知感",
|
|
3309
|
+
"发现感",
|
|
3310
|
+
"地图扩展"
|
|
3311
|
+
],
|
|
3312
|
+
antiSignals: ["探索空转", "新区域没有存在的必要"],
|
|
3313
|
+
resolutionStyle: "靠观察、应对、适应与局部突破打开更深探索。"
|
|
3120
3314
|
},
|
|
3121
3315
|
{
|
|
3122
|
-
key: "
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3316
|
+
key: "story_mode_dungeon_challenge",
|
|
3317
|
+
name: "副本闯关",
|
|
3318
|
+
template: "副本不是重复打怪,要写清目标、机制、风险和通关奖励。",
|
|
3319
|
+
driver: "通过阶段性关卡挑战和规则破解形成连续的推进快感。",
|
|
3320
|
+
readerExpectation: "读者不断获得过关、破局和奖励兑现的满足。",
|
|
3321
|
+
payoffs: [
|
|
3322
|
+
"进入副本",
|
|
3323
|
+
"识别机制",
|
|
3324
|
+
"破解难点",
|
|
3325
|
+
"通关结算"
|
|
3326
|
+
],
|
|
3327
|
+
risks: ["关卡同质化", "只有数值叠加,没有机制可言"],
|
|
3328
|
+
primary: false,
|
|
3329
|
+
progressionUnits: [
|
|
3330
|
+
"进入副本",
|
|
3331
|
+
"识别机制",
|
|
3332
|
+
"破解难点",
|
|
3333
|
+
"通关结算"
|
|
3334
|
+
],
|
|
3335
|
+
allowedConflictForms: [
|
|
3336
|
+
"关卡机制",
|
|
3337
|
+
"时间压力",
|
|
3338
|
+
"队伍配合",
|
|
3339
|
+
"资源限制"
|
|
3340
|
+
],
|
|
3341
|
+
forbiddenConflictForms: [
|
|
3342
|
+
"副本机制全靠硬抗",
|
|
3343
|
+
"通关后拿不到奖励反馈",
|
|
3344
|
+
"关卡反复刷到失去新意"
|
|
3345
|
+
],
|
|
3346
|
+
conflictCeiling: "high",
|
|
3347
|
+
chapterUnit: "单章推进一个关卡节点、一个机制破解或一次通关反馈。",
|
|
3348
|
+
volumeReward: "卷末完成一段完整副本征程,获得显著奖励或更高阶入口。",
|
|
3349
|
+
mandatorySignals: [
|
|
3350
|
+
"机制感",
|
|
3351
|
+
"闯关反馈",
|
|
3352
|
+
"奖励兑现"
|
|
3353
|
+
],
|
|
3354
|
+
antiSignals: ["关卡同质化", "只有数值叠加,没有机制可言"],
|
|
3355
|
+
resolutionStyle: "靠识别规则、团队配合与关键执行完成通关。"
|
|
3126
3356
|
}
|
|
3127
3357
|
];
|
|
3128
|
-
/**
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
"
|
|
3143
|
-
"
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
"
|
|
3155
|
-
|
|
3156
|
-
"
|
|
3157
|
-
"
|
|
3158
|
-
"
|
|
3159
|
-
"
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
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
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3358
|
+
/** 剧情桥段库:可复用情节套路(作者阅读经验沉淀,非某本书的剧情线)。 */
|
|
3359
|
+
const BUILTIN_PLOT_BEATS = [
|
|
3360
|
+
{
|
|
3361
|
+
key: "play-weak",
|
|
3362
|
+
name: "扮猪吃虎",
|
|
3363
|
+
category: "装逼打脸",
|
|
3364
|
+
summary: "主角示弱/隐藏实力,关键时刻亮出獠牙,反差打脸。",
|
|
3365
|
+
position: "前期/中期",
|
|
3366
|
+
preconditions: ["主角有远超表面的实力", "有旁观者低估他"],
|
|
3367
|
+
payoffSource: [
|
|
3368
|
+
"身份反差",
|
|
3369
|
+
"实力揭晓时众人错愕",
|
|
3370
|
+
"质疑者被打脸"
|
|
3371
|
+
],
|
|
3372
|
+
combos: ["打脸", "身份揭露"],
|
|
3373
|
+
taboos: ["示弱太久读者憋屈", "反转无铺垫"],
|
|
3374
|
+
applicableGenres: [
|
|
3375
|
+
"都市",
|
|
3376
|
+
"玄幻",
|
|
3377
|
+
"仙侠"
|
|
3378
|
+
]
|
|
3379
|
+
},
|
|
3380
|
+
{
|
|
3381
|
+
key: "face-slap",
|
|
3382
|
+
name: "打脸",
|
|
3383
|
+
category: "装逼打脸",
|
|
3384
|
+
summary: "挑衅者趾高气扬,主角一击让其灰头土脸,情绪宣泄。",
|
|
3385
|
+
position: "任何",
|
|
3386
|
+
preconditions: ["挑衅者有明确的优越感", "主角有强出一截的底牌"],
|
|
3387
|
+
payoffSource: ["当众羞辱反杀", "围观者态度反转"],
|
|
3388
|
+
combos: ["扮猪吃虎", "大比"],
|
|
3389
|
+
taboos: ["连续无新意打脸", "羞辱过头读者不适"],
|
|
3390
|
+
applicableGenres: [
|
|
3391
|
+
"都市",
|
|
3392
|
+
"玄幻",
|
|
3393
|
+
"仙侠"
|
|
3394
|
+
]
|
|
3395
|
+
},
|
|
3396
|
+
{
|
|
3397
|
+
key: "broken-engagement",
|
|
3398
|
+
name: "退婚/反悔",
|
|
3399
|
+
category: "身份逆袭",
|
|
3400
|
+
summary: "被退婚/被轻视→用实力打回,顺势立目标。",
|
|
3401
|
+
position: "开局/前期",
|
|
3402
|
+
preconditions: ["主角处于弱势", "退婚方势利"],
|
|
3403
|
+
payoffSource: ["被轻贱→反杀", "立下目标"],
|
|
3404
|
+
combos: ["打脸", "金手指亮相"],
|
|
3405
|
+
taboos: ["拖大几十章才洗", "女主误会太久"],
|
|
3406
|
+
applicableGenres: [
|
|
3407
|
+
"玄幻",
|
|
3408
|
+
"都市",
|
|
3409
|
+
"豪门"
|
|
3410
|
+
]
|
|
3411
|
+
},
|
|
3412
|
+
{
|
|
3413
|
+
key: "auction-bargain",
|
|
3414
|
+
name: "拍卖会捡漏",
|
|
3415
|
+
category: "机缘",
|
|
3416
|
+
summary: "主角在拍卖会上用独到眼光/信息差捞到被低估的宝物。",
|
|
3417
|
+
position: "中期",
|
|
3418
|
+
preconditions: ["主角有识货能力/金手指", "现场有争夺者"],
|
|
3419
|
+
payoffSource: ["捡漏爽", "争夺者懊悔"],
|
|
3420
|
+
combos: ["渔翁得利", "宝物升级"],
|
|
3421
|
+
taboos: ["全程顺风顺水无波折", "宝物无后续用处"],
|
|
3422
|
+
applicableGenres: [
|
|
3423
|
+
"玄幻",
|
|
3424
|
+
"仙侠",
|
|
3425
|
+
"都市"
|
|
3426
|
+
]
|
|
3427
|
+
},
|
|
3428
|
+
{
|
|
3429
|
+
key: "secret-realm",
|
|
3430
|
+
name: "秘境/夺宝",
|
|
3431
|
+
category: "机缘",
|
|
3432
|
+
summary: "秘境开启,主角入内夺宝/悟道,或遇强敌/阴谋。",
|
|
3433
|
+
position: "中期/后期",
|
|
3434
|
+
preconditions: ["秘境有明确利益", "竞争者众多"],
|
|
3435
|
+
payoffSource: ["机缘得宝", "危机中成长"],
|
|
3436
|
+
combos: ["夺宝", "绝境翻盘"],
|
|
3437
|
+
taboos: ["宝物白给", "危机虎头蛇尾"],
|
|
3438
|
+
applicableGenres: [
|
|
3439
|
+
"仙侠",
|
|
3440
|
+
"玄幻",
|
|
3441
|
+
"悬疑"
|
|
3442
|
+
]
|
|
3443
|
+
},
|
|
3444
|
+
{
|
|
3445
|
+
key: "misunderstanding",
|
|
3446
|
+
name: "误会/解释不清",
|
|
3447
|
+
category: "情感拉扯",
|
|
3448
|
+
summary: "一方误解另一方的行为,情绪拉满后解开。",
|
|
3449
|
+
position: "任何",
|
|
3450
|
+
preconditions: ["信息不对称", "两方都在意关系"],
|
|
3451
|
+
payoffSource: ["误会造痛", "和解破冰"],
|
|
3452
|
+
combos: ["关系拉扯", "身份揭露"],
|
|
3453
|
+
taboos: ["误会拖太久", "为虐而虐"],
|
|
3454
|
+
applicableGenres: [
|
|
3455
|
+
"都市",
|
|
3456
|
+
"情感",
|
|
3457
|
+
"古言"
|
|
3458
|
+
]
|
|
3459
|
+
},
|
|
3460
|
+
{
|
|
3461
|
+
key: "last-stand",
|
|
3462
|
+
name: "绝境翻盘",
|
|
3463
|
+
category: "战斗",
|
|
3464
|
+
summary: "主角被逼到绝境,借助底牌/意志完成反杀。",
|
|
3465
|
+
position: "高潮",
|
|
3466
|
+
preconditions: ["主角实力明显劣势", "有破局底牌"],
|
|
3467
|
+
payoffSource: ["绝处逢生", "反派嚣张后被打脸"],
|
|
3468
|
+
combos: ["金手指爆发", "打脸"],
|
|
3469
|
+
taboos: ["开挂太突兀", "翻盘无代价"],
|
|
3470
|
+
applicableGenres: [
|
|
3471
|
+
"玄幻",
|
|
3472
|
+
"武侠",
|
|
3473
|
+
"无限流"
|
|
3474
|
+
]
|
|
3475
|
+
},
|
|
3476
|
+
{
|
|
3477
|
+
key: "tournament",
|
|
3478
|
+
name: "大比/比试",
|
|
3479
|
+
category: "升级打脸",
|
|
3480
|
+
summary: "宗门/势力大比,主角一路过关,暴露实力、收获名声。",
|
|
3481
|
+
position: "中期/后期",
|
|
3482
|
+
preconditions: ["有正式舞台", "有看客与对手"],
|
|
3483
|
+
payoffSource: ["连胜升级", "众目睽睽下打脸"],
|
|
3484
|
+
combos: ["打脸", "身份揭露"],
|
|
3485
|
+
taboos: ["比赛水太多", "对手全送脸"],
|
|
3486
|
+
applicableGenres: [
|
|
3487
|
+
"仙侠",
|
|
3488
|
+
"玄幻",
|
|
3489
|
+
"体育竞技"
|
|
3490
|
+
]
|
|
3491
|
+
},
|
|
3492
|
+
{
|
|
3493
|
+
key: "identity-reveal",
|
|
3494
|
+
name: "身份揭露",
|
|
3495
|
+
category: "反转",
|
|
3496
|
+
summary: "主角/配角的隐藏身份在关键时点揭开,颠覆认知。",
|
|
3497
|
+
position: "中期/后期",
|
|
3498
|
+
preconditions: ["身份有可埋伏的线索", "揭开时机能引爆情绪"],
|
|
3499
|
+
payoffSource: ["认知颠覆", "立场反转"],
|
|
3500
|
+
combos: ["打脸", "误会解开"],
|
|
3501
|
+
taboos: ["毫无伏笔硬揭", "身份设定无意义"],
|
|
3502
|
+
applicableGenres: [
|
|
3503
|
+
"悬疑",
|
|
3504
|
+
"古言",
|
|
3505
|
+
"玄幻"
|
|
3506
|
+
]
|
|
3507
|
+
}
|
|
3277
3508
|
];
|
|
3278
|
-
/**
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
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));
|
|
3509
|
+
/** 默认(空)项目写作资产。 */
|
|
3510
|
+
function emptyProjectAssets() {
|
|
3511
|
+
return {
|
|
3512
|
+
auxiliaryProgressions: [],
|
|
3513
|
+
antiAiRules: [],
|
|
3514
|
+
styleAssets: []
|
|
3515
|
+
};
|
|
3316
3516
|
}
|
|
3317
|
-
/**
|
|
3318
|
-
function
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3517
|
+
/** 合并项目资产与内置库:返回「生效的反 AI 规则」(内置全局 + 项目自定义)。 */
|
|
3518
|
+
function effectiveAntiAiRules(assets) {
|
|
3519
|
+
const custom = assets?.antiAiRules ?? [];
|
|
3520
|
+
const keyOf = (r) => r.key ?? r.name;
|
|
3521
|
+
const customKeys = new Set(custom.filter((r) => keyOf(r) !== "").map(keyOf));
|
|
3522
|
+
return [...BUILTIN_ANTI_AI_RULES.filter((r) => !customKeys.has(keyOf(r))), ...custom];
|
|
3523
|
+
}
|
|
3524
|
+
/** 内置库种子化 upsert(对齐上游 SystemResourceBootstrapService 精神)。
|
|
3525
|
+
* missing_only 仅补齐缺失的全局基线规则;sync_existing 还会按 key 刷新已内置规则的结构化字段。 */
|
|
3526
|
+
function ensureBuiltinAssets(assets, mode = "missing_only") {
|
|
3527
|
+
const base = assets ?? emptyProjectAssets();
|
|
3528
|
+
const keyOf = (r) => r.key ?? r.name;
|
|
3529
|
+
const existing = base.antiAiRules ?? [];
|
|
3530
|
+
const byKey = new Map(existing.map((r) => [keyOf(r), r]));
|
|
3531
|
+
const out = [];
|
|
3532
|
+
let changed = false;
|
|
3533
|
+
for (const r of existing) {
|
|
3534
|
+
const builtin = BUILTIN_ANTI_AI_RULES.find((b) => (b.key ?? b.name) === keyOf(r));
|
|
3535
|
+
if (mode === "sync_existing" && builtin !== void 0) {
|
|
3536
|
+
const refreshed = {
|
|
3537
|
+
...builtin,
|
|
3538
|
+
name: r.name,
|
|
3539
|
+
avoid: r.avoid,
|
|
3540
|
+
fix: r.fix,
|
|
3541
|
+
detectPatterns: r.detectPatterns ?? builtin.detectPatterns,
|
|
3542
|
+
enabled: r.enabled ?? builtin.enabled,
|
|
3543
|
+
scope: r.scope
|
|
3544
|
+
};
|
|
3545
|
+
out.push(refreshed);
|
|
3546
|
+
if (JSON.stringify(refreshed) !== JSON.stringify(r)) changed = true;
|
|
3547
|
+
byKey.set(keyOf(r), refreshed);
|
|
3548
|
+
} else out.push(r);
|
|
3325
3549
|
}
|
|
3326
|
-
|
|
3550
|
+
for (const b of BUILTIN_ANTI_AI_RULES) if (b.globalBaselineEnabled === true && !byKey.has(b.key ?? b.name)) {
|
|
3551
|
+
out.push(b);
|
|
3552
|
+
byKey.set(b.key ?? b.name, b);
|
|
3553
|
+
changed = true;
|
|
3554
|
+
}
|
|
3555
|
+
if (!changed) return base;
|
|
3556
|
+
return {
|
|
3557
|
+
...base,
|
|
3558
|
+
antiAiRules: out
|
|
3559
|
+
};
|
|
3327
3560
|
}
|
|
3328
|
-
/**
|
|
3329
|
-
function
|
|
3330
|
-
|
|
3331
|
-
|
|
3561
|
+
/** 把生效规则渲染成提示词块(禁止/风险/鼓励三档分列,压缩省 token)。 */
|
|
3562
|
+
function renderAntiAiRules(assets) {
|
|
3563
|
+
const rules = effectiveAntiAiRules(assets).filter((r) => r.enabled !== false);
|
|
3564
|
+
if (rules.length === 0) return "";
|
|
3565
|
+
const clip = (value, max) => value.length > max ? value.slice(0, max) + "…" : value;
|
|
3566
|
+
const category = (r) => {
|
|
3567
|
+
if (r.severity === "encourage" || r.name.startsWith("鼓励") || r.avoid.startsWith("(鼓励类)")) return "encourage";
|
|
3568
|
+
if (r.severity === "risk") return "risk";
|
|
3569
|
+
return "forbidden";
|
|
3570
|
+
};
|
|
3571
|
+
const instruction = (r) => r.promptInstruction !== void 0 && r.promptInstruction !== "" ? r.promptInstruction : r.avoid;
|
|
3572
|
+
const forbidden = rules.filter((r) => category(r) === "forbidden");
|
|
3573
|
+
const risk = rules.filter((r) => category(r) === "risk");
|
|
3574
|
+
const encourage = rules.filter((r) => category(r) === "encourage");
|
|
3575
|
+
const editLine = (r) => r.fix !== "" ? `;修正——${clip(r.fix, 50)}` : "";
|
|
3576
|
+
const lines = [];
|
|
3577
|
+
lines.push("==================== 反 AI 规则(写作时必须遵守的表达边界) ====================");
|
|
3578
|
+
if (forbidden.length > 0) {
|
|
3579
|
+
lines.push("禁止类(命中即问题,审稿时列为 high/medium):");
|
|
3580
|
+
for (const r of forbidden) lines.push(`- ${r.name}:${clip(instruction(r), 90)}${editLine(r)}${r.riskLevel !== void 0 && r.riskLevel !== "high" ? `(严重度 ${r.riskLevel})` : ""}`);
|
|
3581
|
+
}
|
|
3582
|
+
if (risk.length > 0) {
|
|
3583
|
+
lines.push("风险类(可能扣分,审稿按风险等级提示,不硬性阻塞):");
|
|
3584
|
+
for (const r of risk) lines.push(`- ${r.name}:${clip(instruction(r), 90)}(${r.riskLevel ?? "medium"}${r.autoRewrite === false ? " · 不自动改写" : ""})${editLine(r)}`);
|
|
3585
|
+
}
|
|
3586
|
+
if (encourage.length > 0) {
|
|
3587
|
+
lines.push("鼓励类(希望出现,不命中不算错,审稿时只作低优先级建议、不阻塞通过):");
|
|
3588
|
+
for (const r of encourage) lines.push(`- ${r.name}:${clip(r.fix !== "" ? r.fix : r.avoid, 90)}`);
|
|
3589
|
+
}
|
|
3590
|
+
return lines.join("\n");
|
|
3332
3591
|
}
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
const
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
if (
|
|
3341
|
-
const
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3592
|
+
/** 渲染题材与推进模式提示词块。 */
|
|
3593
|
+
function renderGenreAndProgression(assets) {
|
|
3594
|
+
const sections = [];
|
|
3595
|
+
if (assets?.genre !== void 0) {
|
|
3596
|
+
sections.push("==================== 题材基底(本书的题材定位与读者期待) ====================");
|
|
3597
|
+
sections.push(`题材:${assets.genre.name}`);
|
|
3598
|
+
if (assets.genre.description !== "") sections.push(`读者期待:${assets.genre.description}`);
|
|
3599
|
+
if (assets.genre.template !== void 0 && assets.genre.template !== "") sections.push(`写法指引:${assets.genre.template}`);
|
|
3600
|
+
const walk = (node, depth) => {
|
|
3601
|
+
for (const child of node.children) {
|
|
3602
|
+
sections.push(`${" ".repeat(depth)}- ${child.name}:${child.description}`);
|
|
3603
|
+
if (child.template !== void 0 && child.template !== "") sections.push(`${" ".repeat(depth + 1)}写法指引:${child.template}`);
|
|
3604
|
+
walk(child, depth + 1);
|
|
3605
|
+
}
|
|
3606
|
+
};
|
|
3607
|
+
walk(assets.genre, 1);
|
|
3348
3608
|
}
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
const
|
|
3353
|
-
|
|
3354
|
-
const
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
});
|
|
3609
|
+
const modes = [...assets?.primaryProgression !== void 0 ? [assets.primaryProgression] : [], ...assets?.auxiliaryProgressions ?? []];
|
|
3610
|
+
if (modes.length > 0) {
|
|
3611
|
+
sections.push("==================== 推进模式(读者为什么继续看) ====================");
|
|
3612
|
+
for (const mode of modes) {
|
|
3613
|
+
const tag = mode.primary ? "(主推进)" : "(辅助)";
|
|
3614
|
+
const progressionUnits = mode.progressionUnits ?? [];
|
|
3615
|
+
const allowedConflictForms = mode.allowedConflictForms ?? [];
|
|
3616
|
+
const forbiddenConflictForms = mode.forbiddenConflictForms ?? [];
|
|
3617
|
+
const mandatorySignals = mode.mandatorySignals ?? [];
|
|
3618
|
+
const antiSignals = mode.antiSignals ?? [];
|
|
3619
|
+
sections.push(`- 模式「${mode.name}」${tag}:驱动力——${mode.driver}`);
|
|
3620
|
+
sections.push(` 读者期待:${mode.readerExpectation}`);
|
|
3621
|
+
if (mode.template !== void 0 && mode.template !== "") sections.push(` 写法指引:${mode.template}`);
|
|
3622
|
+
if (progressionUnits.length > 0) sections.push(` 推进单位:${progressionUnits.join(" → ")}`);
|
|
3623
|
+
if (mode.conflictCeiling !== void 0) sections.push(` 冲突上限:${mode.conflictCeiling}`);
|
|
3624
|
+
if (allowedConflictForms.length > 0) sections.push(` 允许冲突:${allowedConflictForms.join("、")}`);
|
|
3625
|
+
if (forbiddenConflictForms.length > 0) sections.push(` 避免冲突:${forbiddenConflictForms.join("、")}`);
|
|
3626
|
+
if (mode.chapterUnit !== void 0 && mode.chapterUnit !== "") sections.push(` 单章单位:${mode.chapterUnit}`);
|
|
3627
|
+
if (mode.volumeReward !== void 0 && mode.volumeReward !== "") sections.push(` 卷末回报:${mode.volumeReward}`);
|
|
3628
|
+
if (mandatorySignals.length > 0) sections.push(` 必达信号:${mandatorySignals.join("、")}`);
|
|
3629
|
+
if (antiSignals.length > 0) sections.push(` 跑偏信号:${antiSignals.join("、")}`);
|
|
3630
|
+
if (mode.payoffs.length > 0) sections.push(` 常见兑现:${mode.payoffs.join("、")}`);
|
|
3631
|
+
if (mode.risks.length > 0) sections.push(` 节奏风险(避免):${mode.risks.join("、")}`);
|
|
3361
3632
|
}
|
|
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
3633
|
}
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
const
|
|
3389
|
-
|
|
3390
|
-
const
|
|
3391
|
-
const
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
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);
|
|
3634
|
+
return sections.join("\n");
|
|
3635
|
+
}
|
|
3636
|
+
/** 渲染写法资产提示词块(规则去重,省 token)。 */
|
|
3637
|
+
function renderStyleAssets(assets) {
|
|
3638
|
+
const styles = assets?.styleAssets ?? [];
|
|
3639
|
+
if (styles.length === 0) return "";
|
|
3640
|
+
const sections = ["==================== 写法资产(本书的叙事风格约束) ===================="];
|
|
3641
|
+
for (const style of styles) {
|
|
3642
|
+
sections.push(`【${style.name}】`);
|
|
3643
|
+
const unique = (rules) => [...new Set(rules)];
|
|
3644
|
+
if (style.proseRules.length > 0) sections.push("叙述与节奏:\n" + unique(style.proseRules).map((r) => `- ${r}`).join("\n"));
|
|
3645
|
+
if (style.dialogueRules.length > 0) sections.push("台词风格:\n" + unique(style.dialogueRules).map((r) => `- ${r}`).join("\n"));
|
|
3646
|
+
if (style.descriptionRules.length > 0) sections.push("描写与情绪:\n" + unique(style.descriptionRules).map((r) => `- ${r}`).join("\n"));
|
|
3647
|
+
if (style.boundaries.length > 0) sections.push("表达边界:\n" + unique(style.boundaries).map((r) => `- ${r}`).join("\n"));
|
|
3648
|
+
if (style.sourceText !== void 0 && style.sourceText !== "") {
|
|
3649
|
+
const targets = renderFingerprintTargets(extractStyleFingerprint(style.sourceText));
|
|
3650
|
+
if (targets !== "") sections.push(targets);
|
|
3651
|
+
}
|
|
3404
3652
|
}
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3653
|
+
return sections.join("\n");
|
|
3654
|
+
}
|
|
3655
|
+
/** 渲染全部写作资产提示词(供生成/规划/审稿注入)。 */
|
|
3656
|
+
function renderAllAssets(assets) {
|
|
3657
|
+
return [
|
|
3658
|
+
renderGenreAndProgression(assets),
|
|
3659
|
+
renderStyleAssets(assets),
|
|
3660
|
+
renderAntiAiRules(assets)
|
|
3661
|
+
].filter((part) => part !== "").join("\n\n");
|
|
3662
|
+
}
|
|
3663
|
+
/** 起始风格画像库:无样本文本也能快速绑定一套写法(4 档起步写法)。 */
|
|
3664
|
+
const BUILTIN_STARTER_STYLE_PROFILES = [
|
|
3665
|
+
{
|
|
3666
|
+
key: "starter-power-up",
|
|
3667
|
+
templateKey: "power-up-escalation",
|
|
3668
|
+
name: "开书快跑·目标推进型",
|
|
3669
|
+
description: "第一次开书想先把推进感与爽点兑现跑顺,可以直接拿它起手,之后在这个底子上改。"
|
|
3670
|
+
},
|
|
3671
|
+
{
|
|
3672
|
+
key: "starter-suspense",
|
|
3673
|
+
templateKey: "suspense-pressure",
|
|
3674
|
+
name: "开书快跑·信息遮蔽型",
|
|
3675
|
+
description: "写给异常、规则、调查、危险逼近这类故事:先把压迫感与信息差立起来。"
|
|
3676
|
+
},
|
|
3677
|
+
{
|
|
3678
|
+
key: "starter-emotional",
|
|
3679
|
+
templateKey: "emotional-tension",
|
|
3680
|
+
name: "开书快跑·关系拉扯型",
|
|
3681
|
+
description: "写给关系推进、误读拉扯类故事:先拿一套能直接开写的关系表达底子。"
|
|
3682
|
+
},
|
|
3683
|
+
{
|
|
3684
|
+
key: "starter-daily",
|
|
3685
|
+
templateKey: "immersive-daily",
|
|
3686
|
+
name: "开书快跑·日常浸没型",
|
|
3687
|
+
description: "写给治愈、陪伴、生活经营、轻缓成长类故事:优先保证生活感与沉浸感。"
|
|
3413
3688
|
}
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3689
|
+
];
|
|
3690
|
+
/** 根据指纹风险推荐仿写预设:low→imitate / medium→balanced / high→transfer。 */
|
|
3691
|
+
function recommendStylePreset(fingerprintRisk) {
|
|
3692
|
+
if (fingerprintRisk === "low") return "imitate";
|
|
3693
|
+
if (fingerprintRisk === "high") return "transfer";
|
|
3694
|
+
return "balanced";
|
|
3695
|
+
}
|
|
3696
|
+
/** 写法引擎:从样本文本提取风格资产的系统提示词(含 preset / 指纹 / 净化管线)。 */
|
|
3697
|
+
function styleEngineSystemPrompt() {
|
|
3698
|
+
return [
|
|
3699
|
+
"你是一位资深网文文风分析师。你会收到一段样本文本,请提炼出可复用的叙事风格规则,供后续章节保持同一种味道,同时给出「仿写预设」与「净化」建议。",
|
|
3700
|
+
"要求:",
|
|
3701
|
+
"1. 从样本中归纳,不要泛泛而谈;每条规则都要能落到具体写法(句式、用词、视角、节奏、对话方式、描写密度)。",
|
|
3702
|
+
"2. 台词风格要说明角色说话的语气特征与常用表达方式。",
|
|
3703
|
+
"3. 表达边界要写明这段风格「不会怎么做」(如:不用华丽辞藻、不写长段心理独白、不用成语堆砌)。",
|
|
3704
|
+
"4. 给出 writingGuidance:净化后的写作指引,即把规则改写成可安全用于生成的具体指导(去掉会暴露来源的具体人名/地名/独特句式)。",
|
|
3705
|
+
"5. 给出 forbiddenEntities:样本文本里不可照搬的原作特有实体与标志性表达(人名、地名、核心设定句、独特口头禅等)。",
|
|
3706
|
+
"6. 判断预设 preset:imitate=高保真仿写(保留全部特征),balanced=平衡(保留可迁移特征、降指纹),transfer=迁移(只取骨架、彻底去指纹)。",
|
|
3707
|
+
"7. 判断 fingerprintRisk:low/medium/high,表示照搬本样本会被识别为抄原作的风险。",
|
|
3708
|
+
"8. 输出必须是合法 JSON 对象,不要输出任何其他文字。",
|
|
3709
|
+
"输出结构:",
|
|
3710
|
+
"{\"proseRules\": [\"叙述视角与句式节奏规则\"], \"dialogueRules\": [\"台词风格规则\"], \"descriptionRules\": [\"描写密度与情绪表达规则\"], \"boundaries\": [\"表达边界\"], \"preset\": \"imitate|balanced|transfer\", \"fingerprintRisk\": \"low|medium|high\", \"writingGuidance\": [\"净化后的写作指引\"], \"forbiddenEntities\": [\"不可照搬的原作特有实体\"]}"
|
|
3711
|
+
].join("\n");
|
|
3712
|
+
}
|
|
3713
|
+
/** 写作公式提取系统提示词(分层:basic 骨架 / standard 完整 / deep 逐句细化)。 */
|
|
3714
|
+
function styleFormulaSystemPrompt(depth) {
|
|
3715
|
+
return [
|
|
3716
|
+
"你是一位网文写作公式提炼师。你会收到一段样本文本,请把它浓缩成\"这套文怎么写\"的写作公式。",
|
|
3717
|
+
depth === "basic" ? "只提取最核心的叙事骨架与推进公式(1 段即可)。" : depth === "deep" ? "逐句/逐段细化:开场如何起、对话如何写、节奏如何控、爽点如何递进(分小节列出)。" : "提取完整可复用的写法公式(含结构、句式、节奏、对话、爽点密度)。",
|
|
3718
|
+
"要求:",
|
|
3719
|
+
"1. 聚焦 focusAreas 指定的重点(如 开场/对话/节奏/爽点密度/段落结构)。",
|
|
3720
|
+
"2. formula 用 Markdown 分小节;applyGuidance 说明生成/改写时如何套用(1-3 句)。",
|
|
3721
|
+
"3. 只输出合法 JSON 对象,不要任何其他文字。",
|
|
3722
|
+
"输出结构:",
|
|
3723
|
+
"{\"name\": \"公式名\", \"focusAreas\": [\"重点域\"], \"formula\": \"Markdown 公式正文\", \"applyGuidance\": \"套用指引\"}"
|
|
3724
|
+
].join("\n");
|
|
3431
3725
|
}
|
|
3432
3726
|
//#endregion
|
|
3433
3727
|
//#region src/llm-live.ts
|
|
@@ -5048,6 +5342,7 @@ const NOVEL_API = {
|
|
|
5048
5342
|
revisionPlan: "/api/dsh-novel-forge/revision/plan",
|
|
5049
5343
|
/** 张力曲线(GET 数据 / POST 设置)。 */
|
|
5050
5344
|
tension: "/api/dsh-novel-forge/tension",
|
|
5345
|
+
styleHistory: "/api/dsh-novel-forge/style-history",
|
|
5051
5346
|
/** 提示词槽位(GET 列表 / POST 写入)。 */
|
|
5052
5347
|
promptSlots: "/api/dsh-novel-forge/prompt-slots",
|
|
5053
5348
|
/** 故事时间线(GET 列表 / POST 操作)。 */
|
|
@@ -6412,6 +6707,7 @@ function reviewSystemPrompt(project) {
|
|
|
6412
6707
|
"{\"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
6708
|
"tension 是本章张力强度评分(不是质量分):对决/揭秘/生死抉择偏高,铺垫章、日常章、过渡章偏低;同一本书内保持同一尺度。",
|
|
6414
6709
|
"反 AI 类 issue 尽量给出 ruleName/ruleType/category/excerpt/reason/canAutoRewrite,便于统计与自动改写。",
|
|
6710
|
+
"风格符合度:若下方给出「风格指纹比对」,以它为准判断本章是否偏离绑定写法——偏差显著时按 writing 维度、category 用「风格」上报;比对里说没有显著偏差时,不要报风格问题。",
|
|
6415
6711
|
"AI 套话高频模板词示例(集中出现必须整体降密度):仿佛、似乎、极其、完美、深不见底、形成了、莫名、无法形容、难以言喻、精心雕琢、肤光胜雪、眉目如画、歌舞升平、觥筹交错、妙语连珠、不可名状、另一层真相、命运、真相。",
|
|
6416
6712
|
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
6417
6713
|
"重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
|
|
@@ -6435,6 +6731,60 @@ function reviewSystemPrompt(project) {
|
|
|
6435
6731
|
sections.push("以上九条为硬性底线:正文中任何一条命中(含影射、暗示、详细展开)都必须列为 high,并给出改写建议;作者自定义红线不得豁免这九条。");
|
|
6436
6732
|
return sections.join("\n");
|
|
6437
6733
|
}
|
|
6734
|
+
/**
|
|
6735
|
+
* 风格基线的取样上限与取法。
|
|
6736
|
+
*
|
|
6737
|
+
* **等距抽样,不取最近 N 章**:实测「最近 12 章」在《还债疯了》上把段落均长的误报从 33
|
|
6738
|
+
* 抬到 98 —— 最近那一卷的行文与全书不同时,中位数就不代表全书。等距抽样覆盖整本。
|
|
6739
|
+
*/
|
|
6740
|
+
const STYLE_BASELINE_CHAPTERS = 12;
|
|
6741
|
+
/** 低于此章数时中位数没有代表性,退回单一样本参照。 */
|
|
6742
|
+
const STYLE_BASELINE_MIN_CHAPTERS = 3;
|
|
6743
|
+
/**
|
|
6744
|
+
* 解析本书的风格基线 —— **审稿与漂移曲线共用这一份**,保证两条路同源。
|
|
6745
|
+
*
|
|
6746
|
+
* 优先用本书**已过审章节的中位数**:单一样本的噪声会整个混进比对(样本碰巧长什么样,
|
|
6747
|
+
* 后面所有章都在跟它比),多章中位数对离群值不敏感。不足 3 章时退回单一样本。
|
|
6748
|
+
* @param outputDir - 书目录;缺省时只能用写法样本。
|
|
6749
|
+
* @param project - 当前书项目状态。
|
|
6750
|
+
* @returns 基线;既无已过审章节也无带样本文本的写法资产时返回 undefined。
|
|
6751
|
+
*/
|
|
6752
|
+
function styleBaseline(outputDir, project) {
|
|
6753
|
+
if (outputDir !== void 0) {
|
|
6754
|
+
const approved = project.chapters.filter((c) => c.status === "approved" && c.file !== "");
|
|
6755
|
+
if (approved.length >= STYLE_BASELINE_MIN_CHAPTERS) {
|
|
6756
|
+
const step = Math.max(1, Math.floor(approved.length / STYLE_BASELINE_CHAPTERS));
|
|
6757
|
+
const picked = approved.filter((_, index) => index % step === 0).slice(0, STYLE_BASELINE_CHAPTERS);
|
|
6758
|
+
const samples = [];
|
|
6759
|
+
for (const chapter of picked) {
|
|
6760
|
+
const raw = readChapterFile(outputDir, chapter);
|
|
6761
|
+
if (raw === void 0) continue;
|
|
6762
|
+
const fp = extractStyleFingerprint(stripChapterHeadings(raw));
|
|
6763
|
+
if (fp.sentences > 0) samples.push(fp);
|
|
6764
|
+
}
|
|
6765
|
+
if (samples.length >= STYLE_BASELINE_MIN_CHAPTERS) return {
|
|
6766
|
+
fingerprint: mergeFingerprints(samples),
|
|
6767
|
+
label: "本书已过审 " + samples.length + " 章的中位数"
|
|
6768
|
+
};
|
|
6769
|
+
}
|
|
6770
|
+
}
|
|
6771
|
+
for (const style of project.assets?.styleAssets ?? []) {
|
|
6772
|
+
const sample = style.sourceText;
|
|
6773
|
+
if (sample === void 0 || sample === "") continue;
|
|
6774
|
+
const fingerprint = extractStyleFingerprint(sample);
|
|
6775
|
+
if (fingerprint.sentences === 0) continue;
|
|
6776
|
+
return {
|
|
6777
|
+
fingerprint,
|
|
6778
|
+
label: "绑定写法资产的样本文本"
|
|
6779
|
+
};
|
|
6780
|
+
}
|
|
6781
|
+
}
|
|
6782
|
+
function styleFidelityBlock(project, bodyText, outputDir) {
|
|
6783
|
+
const baseline = styleBaseline(outputDir, project);
|
|
6784
|
+
if (baseline === void 0) return "";
|
|
6785
|
+
const actual = extractStyleFingerprint(bodyText);
|
|
6786
|
+
return renderFingerprintComparison(baseline.fingerprint, actual, compareStyleFingerprint(baseline.fingerprint, actual), baseline.label);
|
|
6787
|
+
}
|
|
6438
6788
|
/** Run the AI review on one chapter. */
|
|
6439
6789
|
async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
6440
6790
|
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
@@ -6443,6 +6793,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
6443
6793
|
if (body === void 0) throw new NovelActionError("contract", `章节 ${chapterNo} 的正文文件不存在`);
|
|
6444
6794
|
const bodyText = stripChapterHeadings(body);
|
|
6445
6795
|
const aiScan = scanAiFlavor(bodyText, effectiveAntiAiRules(project.assets));
|
|
6796
|
+
const styleBlock = styleFidelityBlock(project, bodyText, outputDir);
|
|
6446
6797
|
const blocks = renderContextBlocks(buildChapterContext(project, chapter, outputDir, { stage: "review" }));
|
|
6447
6798
|
const crossChapter = [
|
|
6448
6799
|
blocks.continuityBlock,
|
|
@@ -6454,6 +6805,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
6454
6805
|
`本章标题:《${chapter.title}》`,
|
|
6455
6806
|
`本章剧情要点:${chapter.beats}`,
|
|
6456
6807
|
`==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
|
|
6808
|
+
styleBlock,
|
|
6457
6809
|
crossChapter,
|
|
6458
6810
|
"==================== 章节正文 ====================",
|
|
6459
6811
|
bodyText
|
|
@@ -6515,11 +6867,13 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
|
|
|
6515
6867
|
async function reviewChapterText(ctx, config, project, text, previousReport) {
|
|
6516
6868
|
const bodyText = text.slice(0, 2e4);
|
|
6517
6869
|
const aiScan = scanAiFlavor(bodyText, effectiveAntiAiRules(project.assets));
|
|
6870
|
+
const styleBlock = styleFidelityBlock(project, bodyText);
|
|
6518
6871
|
const user = [
|
|
6519
6872
|
`书名:《${project.bookName}》`,
|
|
6520
6873
|
previousReport !== void 0 ? "==================== 上一轮审稿意见(逐条核对是否已解决) ====================\n" + previousReport.issues.map((it, i) => `${i + 1}. [${it.severity}] ${it.item}${it.suggestion !== "" ? ` → ${it.suggestion}` : ""}`).join("\n") : "",
|
|
6521
6874
|
previousReport !== void 0 ? "==================== 修订稿(上一轮审稿后按意见修订的正文) ====================" : "==================== 待审查正文 ====================",
|
|
6522
6875
|
`==================== 本地 AI 味扫描(事实锚点,你只需复核判断,不必再逐字统计) ====================\n${aiScan.summary}`,
|
|
6876
|
+
styleBlock,
|
|
6523
6877
|
bodyText
|
|
6524
6878
|
].join("\n");
|
|
6525
6879
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
@@ -13605,6 +13959,58 @@ function makeRoutes(deps) {
|
|
|
13605
13959
|
writeJson(res, 200, { scan: scanAiFlavor(text.slice(0, 2e4)) });
|
|
13606
13960
|
}
|
|
13607
13961
|
};
|
|
13962
|
+
/**
|
|
13963
|
+
* 风格漂移曲线:逐章风格指标 + 参照基线。
|
|
13964
|
+
*
|
|
13965
|
+
* 数据**即时算、不落盘**:语料就是章节文件本身,存一份快照只会带来陈旧问题。
|
|
13966
|
+
* 基线与审稿同源(styleBaseline),所以图上看到的就是审稿时判偏离用的那把尺子。
|
|
13967
|
+
*/
|
|
13968
|
+
const styleHistoryRoute = {
|
|
13969
|
+
kind: "exact",
|
|
13970
|
+
path: NOVEL_API.styleHistory,
|
|
13971
|
+
handler: (req, res) => {
|
|
13972
|
+
if (!guard(req, res, "GET")) return;
|
|
13973
|
+
const outputDir = resolveOutputDir(getConfig(), new URL(req.url ?? "/", "http://localhost").searchParams.get("bookId") ?? void 0);
|
|
13974
|
+
const project = loadProject(outputDir);
|
|
13975
|
+
if (project === void 0) {
|
|
13976
|
+
writeJson(res, 200, {
|
|
13977
|
+
baselineLabel: "",
|
|
13978
|
+
baseline: null,
|
|
13979
|
+
points: []
|
|
13980
|
+
});
|
|
13981
|
+
return;
|
|
13982
|
+
}
|
|
13983
|
+
const baseline = styleBaseline(outputDir, project);
|
|
13984
|
+
const points = [];
|
|
13985
|
+
for (const chapter of project.chapters) {
|
|
13986
|
+
if (chapter.file === void 0 || chapter.file === "") continue;
|
|
13987
|
+
const raw = readChapterFile(outputDir, chapter);
|
|
13988
|
+
if (raw === void 0) continue;
|
|
13989
|
+
const fingerprint = extractStyleFingerprint(stripChapterHeadings(raw));
|
|
13990
|
+
const measured = fingerprint.sentences > 0;
|
|
13991
|
+
points.push({
|
|
13992
|
+
no: chapter.no,
|
|
13993
|
+
title: chapter.title,
|
|
13994
|
+
measured,
|
|
13995
|
+
avgSentenceLength: fingerprint.avgSentenceLength,
|
|
13996
|
+
shortSentenceRatio: fingerprint.shortSentenceRatio,
|
|
13997
|
+
dialogueRatio: fingerprint.dialogueRatio,
|
|
13998
|
+
avgParagraphLength: fingerprint.avgParagraphLength,
|
|
13999
|
+
significant: baseline !== void 0 && measured ? compareStyleFingerprint(baseline.fingerprint, fingerprint).filter((d) => d.significant).map((d) => d.metric + (d.direction === "higher" ? "↑" : "↓")) : []
|
|
14000
|
+
});
|
|
14001
|
+
}
|
|
14002
|
+
writeJson(res, 200, {
|
|
14003
|
+
baselineLabel: baseline?.label ?? "",
|
|
14004
|
+
baseline: baseline === void 0 ? null : {
|
|
14005
|
+
avgSentenceLength: baseline.fingerprint.avgSentenceLength,
|
|
14006
|
+
shortSentenceRatio: baseline.fingerprint.shortSentenceRatio,
|
|
14007
|
+
dialogueRatio: baseline.fingerprint.dialogueRatio,
|
|
14008
|
+
avgParagraphLength: baseline.fingerprint.avgParagraphLength
|
|
14009
|
+
},
|
|
14010
|
+
points
|
|
14011
|
+
});
|
|
14012
|
+
}
|
|
14013
|
+
};
|
|
13608
14014
|
/** 热门题材雷达:输入平台/题材/榜单文本 → 信号 + 生产底座 + 开书创意。 */
|
|
13609
14015
|
const marketRadarRoute = {
|
|
13610
14016
|
kind: "exact",
|
|
@@ -16048,6 +16454,7 @@ function makeRoutes(deps) {
|
|
|
16048
16454
|
bookshelfImportDirRoute,
|
|
16049
16455
|
bookshelfImportTextRoute,
|
|
16050
16456
|
bookshelfImportTextPreviewRoute,
|
|
16457
|
+
styleHistoryRoute,
|
|
16051
16458
|
resetRoute,
|
|
16052
16459
|
auditRoute,
|
|
16053
16460
|
charactersRefreshRoute,
|