@cloud411716/fancy-webnovel 1.0.45 → 2.2.0
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/client.js +183 -0
- package/cordis.patch.yml +0 -14
- package/index.js +21 -37
- package/lib/chinese-num.js +111 -0
- package/lib/deployed-state.js +141 -0
- package/lib/error-codes.js +104 -0
- package/lib/infra.js +569 -0
- package/lib/llm-wiki.js +109 -0
- package/lib/safe-rm.js +94 -0
- package/lib/scraper-utils.js +165 -0
- package/package.json +37 -5
- package/plugins/fancy-analyze/command-input.js +65 -0
- package/plugins/fancy-analyze/index.js +973 -0
- package/plugins/fancy-analyze/schema-analyze.md +379 -0
- package/plugins/fancy-analyze/schema-write.md +178 -0
- package/plugins/fancy-analyze/scripts/split_source.js +292 -0
- package/plugins/fancy-analyze/summary-progress.js +431 -0
- package/plugins/fancy-analyze/templates.js +881 -0
- package/plugins/fancy-bootstrap/index.js +168 -0
- package/plugins/fancy-bootstrap/scripts/init.js +143 -0
- package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
- package/plugins/fancy-bootstrap/templates.js +54 -0
- package/plugins/fancy-import/import-core.js +1118 -0
- package/plugins/fancy-import/import-driver.js +581 -0
- package/plugins/fancy-import/index.js +730 -0
- package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
- package/plugins/fancy-import/templates.js +49 -0
- package/plugins/fancy-scan/index.js +319 -0
- package/plugins/fancy-scan/scripts/run-scan.js +520 -0
- package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
- package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
- package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
- package/plugins/fancy-scan/templates.js +295 -0
- package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
- package/plugins/fancy-story-builder/deslop-driver.js +821 -0
- package/plugins/fancy-story-builder/flesh-output.js +155 -0
- package/plugins/fancy-story-builder/index.js +3152 -0
- package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
- package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
- package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
- package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
- package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
- package/plugins/fancy-story-builder/purpose-template.md +107 -0
- package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
- package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
- package/plugins/fancy-story-builder/references/glossary.md +585 -0
- package/plugins/fancy-story-builder/references/index.md +490 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
- package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
- package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
- package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
- package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
- package/plugins/fancy-story-builder/rhythm-output.js +140 -0
- package/plugins/fancy-story-builder/schema-setting.md +324 -0
- package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
- package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
- package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
- package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
- package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
- package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
- package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
- package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
- package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
- package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
- package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
- package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
- package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
- package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
- package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
- package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
- package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
- package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
- package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
- package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
- package/plugins/fancy-story-builder/skeleton-output.js +199 -0
- package/plugins/fancy-story-builder/stage-driver.js +1049 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//346/227/266/345/272/217/346/230/240/345/260/204/346/250/241/346/235/277.json +204 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/347/273/206/347/272/262/346/250/241/346/235/277.json +34 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//347/253/240/350/212/202/350/247/204/345/210/222/346/250/241/346/235/277.json +24 -0
- package/plugins/fancy-story-builder/templates//350/212/202/345/245/217//350/212/202/345/245/217/345/217/202/346/225/260/346/250/241/346/235/277.json +27 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//344/270/226/347/225/214/350/247/202/350/247/204/345/210/231/346/250/241/346/235/277.json +66 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/205/263/351/224/256/351/201/223/345/205/267/346/250/241/346/235/277.json +76 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/206/262/347/252/201/346/250/241/346/235/277.json +72 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/211/247/346/203/205/346/250/241/346/235/277.json +37 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/212/277/345/212/233/346/250/241/346/235/277.json +53 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//345/234/260/347/202/271/346/250/241/346/235/277.json +40 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//347/273/223/345/261/200/346/250/241/346/235/277.json +79 -0
- package/plugins/fancy-story-builder/templates//350/241/200/350/202/211//350/247/222/350/211/262/346/250/241/346/235/277.json +132 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/205/263/347/263/273/346/250/241/346/235/277.json +133 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/256/236/344/275/223/346/250/241/346/235/277.json +160 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//345/261/202/347/272/247/346/250/241/346/235/277.json +65 -0
- package/plugins/fancy-story-builder/templates//351/252/250/346/236/266//346/241/206/346/236/266/346/250/241/346/235/277.json +134 -0
- package/plugins/fancy-story-builder/templates.js +1248 -0
- package/plugins/fancy-story-builder/wiki-references.js +204 -0
- package/plugins/fancy-story-builder/writing-driver.js +1061 -0
- package/plugins/fancy-topic/index.js +704 -0
- package/plugins/fancy-topic/scripts/select-topic.js +274 -0
- package/plugins/fancy-topic/templates.js +373 -0
- package/commands/bootstrap/index.js +0 -160
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -275
- package/commands/scan/platforms/fanqie.js +0 -146
- package/commands/scan/platforms/jinjiang.js +0 -143
- package/commands/scan/platforms/qidian.js +0 -102
- package/commands/scan/platforms/qimao.js +0 -127
- package/commands/scan/scraper.js +0 -195
- package/infra.js +0 -160
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* semantic-lint.js —— 血肉阶段的语义检查(LLM 层)
|
|
4
|
+
*
|
|
5
|
+
* 对照 llm_wiki 的实现(src/lib/lint.ts):
|
|
6
|
+
* runStructuralLint → fancy-story-builder 的 flesh-validate.js(确定性检查)
|
|
7
|
+
* runSemanticLint → 本文件(LLM 检查,可选开启)
|
|
8
|
+
*
|
|
9
|
+
* 为什么要有这一层:
|
|
10
|
+
* flesh-validate 的 37 个错误码全是**结构**检查 —— id 在不在、名字对不对、
|
|
11
|
+
* 枚举合不合法、配额对不对得上、关系有没有声明。都是能用脚本判死的问题。
|
|
12
|
+
* "内容咬不咬合"没人查:同一个角色的两段描述对不对得上、
|
|
13
|
+
* 伏笔的埋设和回收是不是同一件事、关系类型和描述里的态度是不是矛盾。
|
|
14
|
+
* 这类问题只有 LLM 能查。
|
|
15
|
+
*
|
|
16
|
+
* 关键设计(都是照 llm_wiki 抄的,因为它踩过坑):
|
|
17
|
+
* 1. **LLM 报的"缺失"必须本地复核**(对应 lint.ts:73 missingPageAlreadyExists)
|
|
18
|
+
* LLM 不可靠地交叉引用实体列表,会把已经存在的实体报成缺失。
|
|
19
|
+
* 本地用实体表复核一遍,报错的直接丢弃。
|
|
20
|
+
* 2. **输出协议严格**(对应 lint.ts:202 LINT_BLOCK_REGEX)
|
|
21
|
+
* ---LINT: type | severity | 标题--- ... ---END LINT---
|
|
22
|
+
* 只接受白名单里的 type / severity,其余归一到 default。
|
|
23
|
+
* 3. **"没有就不输出"**(对应 ingest.ts 的 review 提示词)
|
|
24
|
+
* 提示词里明确要求:没有值得报的就不要输出任何内容。
|
|
25
|
+
* 不然 LLM 会为了满足格式而编造问题。
|
|
26
|
+
* 4. **PAGES 指向的实体要核对**(对应 lint.ts:73 的思路,但方向相反)
|
|
27
|
+
* LLM 可能报一个不存在的实体名,这类 finding 丢掉。
|
|
28
|
+
*
|
|
29
|
+
* 这个模块**不调 LLM**:它只负责
|
|
30
|
+
* - 生成提示词(buildSemanticLintPrompt)
|
|
31
|
+
* - 解析 LLM 的输出(parseLintBlocks)
|
|
32
|
+
* - 本地复核(reviewLintFindings)
|
|
33
|
+
* 调 LLM 由插件侧(index.js)用会话模型做,跟写作阶段一个套路。
|
|
34
|
+
*/
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
// ============================================================
|
|
39
|
+
// 协议
|
|
40
|
+
// ============================================================
|
|
41
|
+
|
|
42
|
+
// LLM 只允许报这几种类型;别的归一到 default
|
|
43
|
+
export const LINT_TYPES = ['contradiction', 'missing-detail', 'stale', 'suggestion'];
|
|
44
|
+
export const LINT_TYPE_DEFAULT = 'suggestion';
|
|
45
|
+
|
|
46
|
+
// 严重度:error 会阻断,warning 要人看一眼,info 只是知道
|
|
47
|
+
export const LINT_SEVERITIES = ['error', 'warning', 'info'];
|
|
48
|
+
export const LINT_SEVERITY_DEFAULT = 'warning';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* LINT 块的解析正则。
|
|
52
|
+
*
|
|
53
|
+
* 2026-10-27 修:标题部分原来是 `([^-\n]+?)` —— **不允许出现连字符**。
|
|
54
|
+
* 于是"C001-C004 的师徒关系前后矛盾"这种完全正常的标题匹配不上,
|
|
55
|
+
* 整条 finding 被静默丢掉(模型觉得自己报过了,其实没人看到)。
|
|
56
|
+
* 现在标题允许连字符,只是不允许换行。
|
|
57
|
+
*/
|
|
58
|
+
const LINT_BLOCK_REGEX = /---LINT:\s*(\w[\w-]*)\s*\|\s*(\w+)\s*\|\s*([^\n]+?)\s*---\n([\s\S]*?)---END LINT---/g;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 解析 LLM 输出的 LINT 块。
|
|
62
|
+
*
|
|
63
|
+
* 形如:
|
|
64
|
+
* ---LINT: contradiction | warning | C001 与 C004 的师徒关系前后矛盾---
|
|
65
|
+
* 描述…
|
|
66
|
+
* PAGES: 角色.json#C001, 角色.json#C004
|
|
67
|
+
* ---END LINT---
|
|
68
|
+
*
|
|
69
|
+
* @returns {Array<{type,severity,title,description,pages,options,raw}>}
|
|
70
|
+
*/
|
|
71
|
+
export function parseLintBlocks(text) {
|
|
72
|
+
const out = [];
|
|
73
|
+
if (!text || typeof text !== 'string') return out;
|
|
74
|
+
const matches = text.matchAll(LINT_BLOCK_REGEX);
|
|
75
|
+
for (const m of matches) {
|
|
76
|
+
const rawType = m[1].trim().toLowerCase();
|
|
77
|
+
const rawSeverity = m[2].trim().toLowerCase();
|
|
78
|
+
const title = m[3].trim();
|
|
79
|
+
const body = m[4] || '';
|
|
80
|
+
|
|
81
|
+
const type = LINT_TYPES.includes(rawType) ? rawType : LINT_TYPE_DEFAULT;
|
|
82
|
+
const severity = LINT_SEVERITIES.includes(rawSeverity) ? rawSeverity : LINT_SEVERITY_DEFAULT;
|
|
83
|
+
|
|
84
|
+
const pagesMatch = body.match(/^PAGES:\s*(.+)$/m);
|
|
85
|
+
const pages = pagesMatch
|
|
86
|
+
? pagesMatch[1].split(',').map((p) => p.trim()).filter(Boolean)
|
|
87
|
+
: [];
|
|
88
|
+
const optionsMatch = body.match(/^OPTIONS:\s*(.+)$/m);
|
|
89
|
+
const options = optionsMatch
|
|
90
|
+
? optionsMatch[1].split('|').map((o) => o.trim()).filter(Boolean)
|
|
91
|
+
: [];
|
|
92
|
+
const description = body
|
|
93
|
+
.replace(/^PAGES:.*$/m, '')
|
|
94
|
+
.replace(/^OPTIONS:.*$/m, '')
|
|
95
|
+
.trim();
|
|
96
|
+
|
|
97
|
+
out.push({
|
|
98
|
+
type: type,
|
|
99
|
+
severity: severity,
|
|
100
|
+
title: title,
|
|
101
|
+
description: description,
|
|
102
|
+
pages: pages,
|
|
103
|
+
options: options,
|
|
104
|
+
raw: m[0]
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================================
|
|
111
|
+
// 本地复核
|
|
112
|
+
// ============================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 复核 LLM 报出来的 finding,丢掉不可信的。
|
|
116
|
+
*
|
|
117
|
+
* 丢弃规则:
|
|
118
|
+
* 1. type 是 missing-detail,但被指认的实体**已经存在** → 丢
|
|
119
|
+
* (LLM 不可靠地交叉引用实体列表,会把已存在的报成缺失 —— llm_wiki lint.ts:73 同款)
|
|
120
|
+
* 2. PAGES 里指的实体**全都不存在** → 丢(LLM 报了不存在的实体)
|
|
121
|
+
* 3. 标题或描述为空 → 丢(没有信息量的 finding)
|
|
122
|
+
*
|
|
123
|
+
* @param {Array} findings parseLintBlocks 的结果
|
|
124
|
+
* @param {object} ctx { entityIds: Set<string>, entityNames: Set<string> }
|
|
125
|
+
* @returns {{ kept: Array, dropped: Array }}
|
|
126
|
+
*/
|
|
127
|
+
export function reviewLintFindings(findings, ctx) {
|
|
128
|
+
const context = ctx || {};
|
|
129
|
+
const entityIds = context.entityIds || new Set();
|
|
130
|
+
const entityNames = context.entityNames || new Set();
|
|
131
|
+
const kept = [];
|
|
132
|
+
const dropped = [];
|
|
133
|
+
|
|
134
|
+
for (const f of findings) {
|
|
135
|
+
if (!f.title || !f.description) {
|
|
136
|
+
dropped.push({ finding: f, reason: '标题或描述为空' });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 1. missing-detail 复核:被指认的实体在不在
|
|
141
|
+
//
|
|
142
|
+
// "实体都存在 → 丢"是**刻意设计**,不是 bug:
|
|
143
|
+
// LLM 不可靠地交叉引用实体列表,会把已存在的实体报成"缺失",
|
|
144
|
+
// 所以这里把这种情况整个丢掉(与 llm_wiki lint.ts:73 同款)。
|
|
145
|
+
// 代价是"实体存在但缺关键信息"这类 finding 也被一并丢掉 ——
|
|
146
|
+
// 2026-10-27 复核后确认:这是跨项目一致的取舍,不改。
|
|
147
|
+
if (f.type === 'missing-detail' && f.pages.length) {
|
|
148
|
+
const allExist = f.pages.every((p) => {
|
|
149
|
+
const key = pageKey(p);
|
|
150
|
+
return entityIds.has(key) || entityNames.has(key);
|
|
151
|
+
});
|
|
152
|
+
if (allExist) {
|
|
153
|
+
dropped.push({ finding: f, reason: 'missing-detail 指向的实体都存在,判定有误' });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 2. PAGES 指向的实体得存在(至少一个)
|
|
159
|
+
if (f.pages.length) {
|
|
160
|
+
const anyExist = f.pages.some((p) => {
|
|
161
|
+
const key = pageKey(p);
|
|
162
|
+
return entityIds.has(key) || entityNames.has(key);
|
|
163
|
+
});
|
|
164
|
+
if (!anyExist) {
|
|
165
|
+
dropped.push({ finding: f, reason: 'PAGES 指向的实体都不存在' });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
kept.push(f);
|
|
171
|
+
}
|
|
172
|
+
return { kept, dropped };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 把 "角色.json#C001" 或 "C001" 统一成实体标识 */
|
|
176
|
+
function pageKey(p) {
|
|
177
|
+
if (!p) return '';
|
|
178
|
+
const s = String(p).trim();
|
|
179
|
+
const hash = s.indexOf('#');
|
|
180
|
+
return hash >= 0 ? s.slice(hash + 1).trim() : s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ============================================================
|
|
184
|
+
// 提示词
|
|
185
|
+
// ============================================================
|
|
186
|
+
|
|
187
|
+
// 每个实体摘要里截多长。llm_wiki 取的是 frontmatter + 前 500 字符,这里取 300。
|
|
188
|
+
const SUMMARY_CHARS = 300;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 把一个血肉实体的关键字段压成一段摘要。
|
|
192
|
+
* 只取"有信息量"的字段:描述、目标、矛盾、关系等,长文本截断。
|
|
193
|
+
*/
|
|
194
|
+
function summarizeEntity(item, idKeys) {
|
|
195
|
+
const parts = [];
|
|
196
|
+
const textFields = [];
|
|
197
|
+
const collect = (v, prefix) => {
|
|
198
|
+
if (typeof v === 'string') {
|
|
199
|
+
if (v.trim()) textFields.push(prefix + ':' + v.trim().slice(0, SUMMARY_CHARS));
|
|
200
|
+
} else if (Array.isArray(v)) {
|
|
201
|
+
for (const x of v) collect(x, prefix);
|
|
202
|
+
} else if (v && typeof v === 'object') {
|
|
203
|
+
for (const [k, x] of Object.entries(v)) {
|
|
204
|
+
if (idKeys.has(k)) continue;
|
|
205
|
+
collect(x, prefix ? prefix + '.' + k : k);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
for (const [k, v] of Object.entries(item)) {
|
|
210
|
+
if (k === 'id' || k === 'name') continue;
|
|
211
|
+
collect(v, k);
|
|
212
|
+
}
|
|
213
|
+
parts.push(textFields.join(';'));
|
|
214
|
+
return parts.join('');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* 生成语义检查的提示词。
|
|
219
|
+
*
|
|
220
|
+
* @param {object} ctx
|
|
221
|
+
* fleshEntities: [{ file, key, index, id, name, item }] 血肉实体
|
|
222
|
+
* skeletonRelations: Array<{from,to,kind}> 骨架关系(id 级)
|
|
223
|
+
* entityIds: Set<string> entityNames: Set<string>
|
|
224
|
+
*/
|
|
225
|
+
export function buildSemanticLintPrompt(ctx) {
|
|
226
|
+
const context = ctx || {};
|
|
227
|
+
const fleshEntities = context.fleshEntities || [];
|
|
228
|
+
const skeletonRelations = context.skeletonRelations || [];
|
|
229
|
+
const idKeys = new Set(['id', 'name']);
|
|
230
|
+
|
|
231
|
+
const lines = [];
|
|
232
|
+
lines.push('## 血肉实体清单');
|
|
233
|
+
lines.push('');
|
|
234
|
+
for (const e of fleshEntities) {
|
|
235
|
+
const summary = summarizeEntity(e.item, idKeys);
|
|
236
|
+
lines.push('- [' + e.file + '#' + (e.id || e.name) + '] ' + e.name + (summary ? ' —— ' + summary : ''));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let relText = '';
|
|
240
|
+
if (skeletonRelations.length) {
|
|
241
|
+
const relLines = skeletonRelations.slice(0, 200).map((r) => '- ' + r.from + ' → ' + r.to + '(' + r.kind + ')');
|
|
242
|
+
relText = '\n\n## 骨架已确定的关系(不得与之矛盾)\n\n' + relLines.join('\n');
|
|
243
|
+
if (skeletonRelations.length > 200) {
|
|
244
|
+
relText += '\n- …… 其余 ' + (skeletonRelations.length - 200) + ' 条省略';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const prompt = [
|
|
249
|
+
'你是设定审校。给你一份小说血肉阶段的实体清单(来自骨架与血肉的 JSON 产物),请找出**真正的语义问题**。',
|
|
250
|
+
'',
|
|
251
|
+
'## 只报这四类问题',
|
|
252
|
+
'',
|
|
253
|
+
'- contradiction:两处描述互相矛盾(同一角色的两段说法对不上、同一件事有两种互相排斥的记述)',
|
|
254
|
+
'- missing-detail:该写但没写、导致这个实体立不住的关键信息(只报**真的缺失**,不是"可以更详细")',
|
|
255
|
+
'- stale:内容与骨架已确定的事实冲突(骨架里角色是 X,血肉里写成 Y)',
|
|
256
|
+
'- suggestion:会实质性改善设定的建议',
|
|
257
|
+
'',
|
|
258
|
+
'## 不要报什么',
|
|
259
|
+
'',
|
|
260
|
+
'- 不要报"可以更详细""建议加强描写"这类空泛意见',
|
|
261
|
+
'- 不要报你自己不确定是不是问题的东西',
|
|
262
|
+
'- 不要重复报同一个问题',
|
|
263
|
+
'- 骨架与血肉的 id、名字、枚举、配额**已经由确定性脚本校验过了**,不要重复报这些',
|
|
264
|
+
'',
|
|
265
|
+
'## 输出格式(严格)',
|
|
266
|
+
'',
|
|
267
|
+
'每条问题一个块,块之间空行,不要输出任何其它文字:',
|
|
268
|
+
'',
|
|
269
|
+
'---LINT: <contradiction|missing-detail|stale|suggestion> | <error|warning|info> | <一句话标题>---',
|
|
270
|
+
'<两三句话说清楚:哪里矛盾 / 缺什么 / 冲突在哪,给出具体引用(写清是哪几个实体的哪句话)',
|
|
271
|
+
'PAGES: <文件名#实体id>, <文件名#实体id>',
|
|
272
|
+
'---END LINT---',
|
|
273
|
+
'',
|
|
274
|
+
'要求:',
|
|
275
|
+
'- 最多 8 条,优先高信号问题',
|
|
276
|
+
'- **如果没有值得报的问题,就不要输出任何内容**(不要为了凑数硬编)',
|
|
277
|
+
'- PAGES 里的实体 id 必须来自上面的清单',
|
|
278
|
+
'- severity:error=设定自相矛盾必须改,warning=大概率要改,info=可以斟酌',
|
|
279
|
+
'',
|
|
280
|
+
'## 待审实体清单',
|
|
281
|
+
'',
|
|
282
|
+
lines.join('\n') + relText
|
|
283
|
+
].join('\n');
|
|
284
|
+
|
|
285
|
+
return prompt;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================================
|
|
289
|
+
// 工具
|
|
290
|
+
// ============================================================
|
|
291
|
+
|
|
292
|
+
/** 从血肉目录读出实体清单(给提示词和复核用) */
|
|
293
|
+
export function collectFleshEntities(fleshDir) {
|
|
294
|
+
const FILES = {
|
|
295
|
+
'角色.json': ['characters'],
|
|
296
|
+
'冲突.json': ['conflicts'],
|
|
297
|
+
'势力.json': ['factions'],
|
|
298
|
+
'剧情.json': ['main_lines', 'branches'],
|
|
299
|
+
'地点.json': ['locations'],
|
|
300
|
+
'结局.json': ['endings'],
|
|
301
|
+
'世界观规则.json': ['core_rules', 'information_rules', 'social_rules'],
|
|
302
|
+
'关键道具.json': ['core_items', 'branch_items', 'symbolic_items', 'details']
|
|
303
|
+
};
|
|
304
|
+
const entities = [];
|
|
305
|
+
for (const [file, keys] of Object.entries(FILES)) {
|
|
306
|
+
const p = path.join(fleshDir, file);
|
|
307
|
+
if (!fs.existsSync(p)) continue;
|
|
308
|
+
let data;
|
|
309
|
+
try {
|
|
310
|
+
data = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
311
|
+
} catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
for (const key of keys) {
|
|
315
|
+
const list = data[key];
|
|
316
|
+
if (!Array.isArray(list)) continue;
|
|
317
|
+
for (const [index, item] of list.entries()) {
|
|
318
|
+
if (!item || typeof item !== 'object') continue;
|
|
319
|
+
entities.push({
|
|
320
|
+
file: file,
|
|
321
|
+
key: key,
|
|
322
|
+
index: index,
|
|
323
|
+
id: item.id ? String(item.id) : '',
|
|
324
|
+
name: item.name ? String(item.name) : '',
|
|
325
|
+
item: item
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return entities;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 从骨架关系表读出 id 级关系(给提示词当"不得矛盾"的事实) */
|
|
334
|
+
export function collectSkeletonRelations(skeletonDir) {
|
|
335
|
+
const p = path.join(skeletonDir, '关系.json');
|
|
336
|
+
if (!fs.existsSync(p)) return [];
|
|
337
|
+
let data;
|
|
338
|
+
try {
|
|
339
|
+
data = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
340
|
+
} catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const r of (data.character_relations || [])) {
|
|
345
|
+
if (r && r.from && r.to) out.push({ from: r.from, to: r.to, kind: r.relation_kind || '' });
|
|
346
|
+
}
|
|
347
|
+
for (const r of (data.faction_relations || [])) {
|
|
348
|
+
if (r && r.from && r.to) out.push({ from: r.from, to: r.to, kind: r.relation_type || '' });
|
|
349
|
+
}
|
|
350
|
+
for (const r of (data.faction_membership || [])) {
|
|
351
|
+
if (r && r.character && r.faction) out.push({ from: r.character, to: r.faction, kind: r.member_relation || '' });
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** 从骨架实体表建 id / name 集合(给本地复核用) */
|
|
357
|
+
export function collectEntityKeys(skeletonDir) {
|
|
358
|
+
const p = path.join(skeletonDir, '实体.json');
|
|
359
|
+
const entityIds = new Set();
|
|
360
|
+
const entityNames = new Set();
|
|
361
|
+
if (!fs.existsSync(p)) return { entityIds, entityNames };
|
|
362
|
+
let data;
|
|
363
|
+
try {
|
|
364
|
+
data = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
365
|
+
} catch {
|
|
366
|
+
return { entityIds, entityNames };
|
|
367
|
+
}
|
|
368
|
+
for (const list of Object.values(data)) {
|
|
369
|
+
if (!Array.isArray(list)) continue;
|
|
370
|
+
for (const item of list) {
|
|
371
|
+
if (!item || typeof item !== 'object') continue;
|
|
372
|
+
if (item.id) entityIds.add(String(item.id));
|
|
373
|
+
if (item.name) entityNames.add(String(item.name));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { entityIds, entityNames };
|
|
377
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skeleton-quota.js —— 骨架阶段「配额 ↔ 实体数组」的映射(校验器与插件共用)
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这个模块:
|
|
5
|
+
* 骨架的 `框架.json.quota` 声明"每类要多少个实体",
|
|
6
|
+
* 校验器按这张映射表核对 `quota` 与 `实体.json` 的实际条数(QUOTA_MISMATCH)。
|
|
7
|
+
* 插件要用同一张表做两件事:
|
|
8
|
+
* 1. 算每个实体数组该写几条 —— 批次规模与提示词里的条数都靠它
|
|
9
|
+
* 2. 把 `quota.xxx` 形态的错误归因回**对应实体数组**的批次
|
|
10
|
+
* (错误 path 指向框架,但根因常常是实体写少了)
|
|
11
|
+
*
|
|
12
|
+
* 放在这里而不是让插件自己抄一份:
|
|
13
|
+
* 两边各抄一份的话,校验器改了口径插件不会跟着改,
|
|
14
|
+
* 归因就会指错地方(模型被派去改一个没问题的批次)。
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* quota 路径 → 实体数组 + 过滤条件。
|
|
21
|
+
*
|
|
22
|
+
* 过滤条件是"按类型/类别/量级统计",例如
|
|
23
|
+
* quota.character_counts.protagonist = 实际 characters 里 type==='主角' 的条数
|
|
24
|
+
*/
|
|
25
|
+
export const QUOTA_MAP = {
|
|
26
|
+
'character_counts.protagonist': { arr: 'characters', filter: c => c.type === '主角' },
|
|
27
|
+
'character_counts.core_supporting': { arr: 'characters', filter: c => c.type === '贯穿级核心配角' },
|
|
28
|
+
'character_counts.volume_supporting': { arr: 'characters', filter: c => c.type === '卷级重要配角' },
|
|
29
|
+
'character_counts.core_antagonist': { arr: 'characters', filter: c => c.type === '贯穿级核心反派' },
|
|
30
|
+
'character_counts.volume_antagonist': { arr: 'characters', filter: c => c.type === '卷级反派' },
|
|
31
|
+
'conflict_counts.core': { arr: 'conflicts', filter: c => c.type === '核心' },
|
|
32
|
+
'conflict_counts.phase': { arr: 'conflicts', filter: c => c.type === '阶段' },
|
|
33
|
+
'conflict_counts.branch': { arr: 'conflicts', filter: c => c.type === '交织' },
|
|
34
|
+
'faction_count': { arr: 'factions', filter: () => true },
|
|
35
|
+
'location_count': { arr: 'locations', filter: () => true },
|
|
36
|
+
'ending_count': { arr: 'endings', filter: () => true },
|
|
37
|
+
'main_line_count': { arr: 'main_lines', filter: () => true },
|
|
38
|
+
'branch_count': { arr: 'branches', filter: () => true },
|
|
39
|
+
'foreshadow_counts.small': { arr: 'foreshadows', filter: c => c.type === '小伏笔' },
|
|
40
|
+
'foreshadow_counts.medium': { arr: 'foreshadows', filter: c => c.type === '中伏笔' },
|
|
41
|
+
'foreshadow_counts.large': { arr: 'foreshadows', filter: c => c.type === '大伏笔' },
|
|
42
|
+
'climax_counts.small': { arr: 'climaxes', filter: c => c.type === '小高潮' },
|
|
43
|
+
'climax_counts.medium': { arr: 'climaxes', filter: c => c.type === '中高潮' },
|
|
44
|
+
'climax_counts.large': { arr: 'climaxes', filter: c => c.type === '大高潮' },
|
|
45
|
+
'pleasure_counts.small': { arr: 'pleasures', filter: c => c.scale === '小爽点' },
|
|
46
|
+
'pleasure_counts.medium': { arr: 'pleasures', filter: c => c.scale === '中爽点' },
|
|
47
|
+
'pleasure_counts.large': { arr: 'pleasures', filter: c => c.scale === '大爽点' },
|
|
48
|
+
'item_counts.core': { arr: 'items', filter: c => c.category === '核心道具' },
|
|
49
|
+
'item_counts.branch': { arr: 'items', filter: c => c.category === '支线道具' },
|
|
50
|
+
'item_counts.symbolic': { arr: 'items', filter: c => c.category === '象征道具' },
|
|
51
|
+
'world_rule_counts.core': { arr: 'world_rules', filter: c => c.category === '核心规则' },
|
|
52
|
+
'world_rule_counts.information': { arr: 'world_rules', filter: c => c.category === '信息规则' },
|
|
53
|
+
'world_rule_counts.social': { arr: 'world_rules', filter: c => c.category === '社会规则' }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 配额路径 → 实体数组名。
|
|
58
|
+
* 归因用:错误的 path 形如 "quota.character_counts.protagonist",
|
|
59
|
+
* 要落到 characters 数组对应的批次上。
|
|
60
|
+
*/
|
|
61
|
+
const QUOTA_PATH_TO_ARRAY = {};
|
|
62
|
+
for (const quotaPath of Object.keys(QUOTA_MAP)) {
|
|
63
|
+
QUOTA_PATH_TO_ARRAY[quotaPath] = QUOTA_MAP[quotaPath].arr;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读嵌套值(支持 "a.b.c" 形态)。
|
|
68
|
+
*/
|
|
69
|
+
export function getNested(obj, dotted) {
|
|
70
|
+
if (!obj || typeof obj !== 'object') return undefined;
|
|
71
|
+
const parts = String(dotted).split('.');
|
|
72
|
+
let cur = obj;
|
|
73
|
+
for (const p of parts) {
|
|
74
|
+
if (!cur || typeof cur !== 'object') return undefined;
|
|
75
|
+
cur = cur[p];
|
|
76
|
+
}
|
|
77
|
+
return cur;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 把 quota 展开成「每个实体数组的分项配额」。
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} quota 框架.json 的 quota 对象
|
|
84
|
+
* @returns {Object} { characters: { protagonist: 2, core_supporting: 8 }, ... }
|
|
85
|
+
* 只包含 quota 里确实是数字的项(模型还没填的项直接不出现)
|
|
86
|
+
*/
|
|
87
|
+
export function quotaCountsByArray(quota) {
|
|
88
|
+
const out = {};
|
|
89
|
+
if (!quota || typeof quota !== 'object') return out;
|
|
90
|
+
for (const quotaPath of Object.keys(QUOTA_MAP)) {
|
|
91
|
+
const v = getNested(quota, quotaPath);
|
|
92
|
+
if (typeof v !== 'number' || !isFinite(v)) continue;
|
|
93
|
+
const arr = QUOTA_MAP[quotaPath].arr;
|
|
94
|
+
if (!out[arr]) out[arr] = {};
|
|
95
|
+
// 分项名取路径最后一段(character_counts.protagonist → protagonist)
|
|
96
|
+
const key = quotaPath.indexOf('.') >= 0 ? quotaPath.slice(quotaPath.lastIndexOf('.') + 1) : quotaPath;
|
|
97
|
+
out[arr][key] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 每个实体数组的配额总数。
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} quota 框架.json 的 quota 对象
|
|
106
|
+
* @returns {Object} { characters: 40, conflicts: 29, ... }
|
|
107
|
+
*/
|
|
108
|
+
export function quotaArrayTotals(quota) {
|
|
109
|
+
const byArray = quotaCountsByArray(quota);
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const arr of Object.keys(byArray)) {
|
|
112
|
+
let n = 0;
|
|
113
|
+
for (const k of Object.keys(byArray[arr])) n += byArray[arr][k];
|
|
114
|
+
out[arr] = n;
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 某个配额路径属于哪个实体数组(归因用)。
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
export function quotaPathToArray(quotaPath) {
|
|
124
|
+
if (!quotaPath) return null;
|
|
125
|
+
return QUOTA_PATH_TO_ARRAY[quotaPath] || null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 从框架.json 读 quota。
|
|
130
|
+
* @returns {Object} quota(读不到返回空对象)
|
|
131
|
+
*/
|
|
132
|
+
export function readQuota(stageDir) {
|
|
133
|
+
const data = readJsonSafe(path.join(stageDir, '框架.json'));
|
|
134
|
+
if (!data || typeof data !== 'object') return {};
|
|
135
|
+
return (data.quota && typeof data.quota === 'object') ? data.quota : {};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function readJsonSafe(p) {
|
|
139
|
+
try {
|
|
140
|
+
if (!fs.existsSync(p)) return null;
|
|
141
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|