@cloud411716/fancy-webnovel 1.0.46 → 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 -164
- package/commands/bootstrap/init-project.js +0 -121
- package/commands/scan/index.js +0 -283
- 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,1385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-utils.js v1.4
|
|
3
|
+
* 通用图工具库 + glossary 解析公共函数。
|
|
4
|
+
* 源码中零反引号,避免 markdown 渲染问题。
|
|
5
|
+
*
|
|
6
|
+
* v1.4 变更(修复 v1.3 的反引号遗漏):
|
|
7
|
+
* - 头注释与 P0-1 说明中不再出现字面反引号字符
|
|
8
|
+
* - stripTicks 用 String.fromCharCode(96) 生成反引号字符与全局正则
|
|
9
|
+
* (v1.3 用了正则字面量中含反引号,导致源码出现字面反引号,
|
|
10
|
+
* 违反本文件零反引号约定,并可能破坏 markdown 渲染)
|
|
11
|
+
*
|
|
12
|
+
* v1.3 变更(修复批次 P0-1 / P2-7):
|
|
13
|
+
* - P0-1:parseGlossaryFromMd 剥离字段名与类型列的反引号
|
|
14
|
+
* (原版未剥离,导致 chapterScalarFields 里存的是带反引号字符的
|
|
15
|
+
* chapter 而非 chapter,骨架/血肉的 SKELETON_CHAPTER_FORBIDDEN /
|
|
16
|
+
* FLESH_CHAPTER_FORBIDDEN 与节奏的 RHYTHM_CHAP_RANGE 全部失效)
|
|
17
|
+
* - P2-7:fieldType 为 计数 时不加入 chapterScalarFields
|
|
18
|
+
* (chapter_count 是计数不是章节号)
|
|
19
|
+
*
|
|
20
|
+
* 导出:
|
|
21
|
+
* - SimpleGraph
|
|
22
|
+
* - getIsolatedNodes
|
|
23
|
+
* - getDisconnectedCoreNodes
|
|
24
|
+
* - checkBidirectionalSymmetry
|
|
25
|
+
* - RELATION_KIND_META
|
|
26
|
+
* - relationKindCompatible
|
|
27
|
+
* - assertRelationKindMeta
|
|
28
|
+
* - parseGlossaryFromMd
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// SimpleGraph
|
|
33
|
+
// ============================================================
|
|
34
|
+
export class SimpleGraph {
|
|
35
|
+
constructor() {
|
|
36
|
+
this._nodes = new Map();
|
|
37
|
+
this._out = new Map();
|
|
38
|
+
this._in = new Map();
|
|
39
|
+
this._edges = new Map();
|
|
40
|
+
}
|
|
41
|
+
addNode(key, attrs = {}) {
|
|
42
|
+
if (this._nodes.has(key)) return;
|
|
43
|
+
this._nodes.set(key, attrs);
|
|
44
|
+
this._out.set(key, new Set());
|
|
45
|
+
this._in.set(key, new Set());
|
|
46
|
+
}
|
|
47
|
+
addEdgeWithKey(edgeKey, source, target, attrs = {}) {
|
|
48
|
+
if (!this._nodes.has(source) || !this._nodes.has(target)) return;
|
|
49
|
+
if (this._edges.has(edgeKey)) return;
|
|
50
|
+
this._edges.set(edgeKey, { source, target, attrs });
|
|
51
|
+
this._out.get(source).add(edgeKey);
|
|
52
|
+
this._in.get(target).add(edgeKey);
|
|
53
|
+
}
|
|
54
|
+
hasNode(key) { return this._nodes.has(key); }
|
|
55
|
+
getNodeAttribute(key, name) { return (this._nodes.get(key) || {})[name]; }
|
|
56
|
+
getNodeAttributes(key) { return this._nodes.get(key) || {}; }
|
|
57
|
+
degree(key) {
|
|
58
|
+
return (this._out.get(key)?.size || 0) + (this._in.get(key)?.size || 0);
|
|
59
|
+
}
|
|
60
|
+
inDegree(key) { return this._in.get(key)?.size || 0; }
|
|
61
|
+
outDegree(key) { return this._out.get(key)?.size || 0; }
|
|
62
|
+
neighbors(key) {
|
|
63
|
+
const set = new Set();
|
|
64
|
+
for (const e of this._out.get(key) || []) {
|
|
65
|
+
const t = this._edges.get(e).target;
|
|
66
|
+
if (t !== key) set.add(t);
|
|
67
|
+
}
|
|
68
|
+
for (const e of this._in.get(key) || []) {
|
|
69
|
+
const s = this._edges.get(e).source;
|
|
70
|
+
if (s !== key) set.add(s);
|
|
71
|
+
}
|
|
72
|
+
return [...set];
|
|
73
|
+
}
|
|
74
|
+
forEachEdge(cb) {
|
|
75
|
+
for (const [k, e] of this._edges) cb(k, e.attrs, e.source, e.target);
|
|
76
|
+
}
|
|
77
|
+
forEachNode(cb) {
|
|
78
|
+
for (const [k, a] of this._nodes) cb(k, a);
|
|
79
|
+
}
|
|
80
|
+
nodes() { return [...this._nodes.keys()]; }
|
|
81
|
+
edges() { return [...this._edges.values()]; }
|
|
82
|
+
get order() { return this._nodes.size; }
|
|
83
|
+
get size() { return this._edges.size; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ============================================================
|
|
87
|
+
// getIsolatedNodes
|
|
88
|
+
// ============================================================
|
|
89
|
+
export function getIsolatedNodes(graph, excludeTypes = []) {
|
|
90
|
+
const excluded = new Set(excludeTypes);
|
|
91
|
+
const result = [];
|
|
92
|
+
for (const key of graph.nodes()) {
|
|
93
|
+
const type = graph.getNodeAttribute(key, 'type');
|
|
94
|
+
if (excluded.has(type)) continue;
|
|
95
|
+
if (graph.degree(key) === 0) {
|
|
96
|
+
result.push({ key: key, type: type });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================
|
|
103
|
+
// getDisconnectedCoreNodes
|
|
104
|
+
// ============================================================
|
|
105
|
+
export function getDisconnectedCoreNodes(graph, coreTypes) {
|
|
106
|
+
const coreSet = new Set(coreTypes);
|
|
107
|
+
const coreNodes = graph.nodes().filter(k => coreSet.has(graph.getNodeAttribute(k, 'type')));
|
|
108
|
+
if (coreNodes.length <= 1) return [];
|
|
109
|
+
const visited = new Set([coreNodes[0]]);
|
|
110
|
+
const queue = [coreNodes[0]];
|
|
111
|
+
while (queue.length) {
|
|
112
|
+
const cur = queue.shift();
|
|
113
|
+
for (const nb of graph.neighbors(cur)) {
|
|
114
|
+
if (visited.has(nb)) continue;
|
|
115
|
+
if (coreSet.has(graph.getNodeAttribute(nb, 'type'))) {
|
|
116
|
+
visited.add(nb);
|
|
117
|
+
queue.push(nb);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return coreNodes.filter(k => !visited.has(k));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ============================================================
|
|
125
|
+
// checkBidirectionalSymmetry
|
|
126
|
+
// ============================================================
|
|
127
|
+
export function checkBidirectionalSymmetry(relations, opts = {}) {
|
|
128
|
+
const fromKey = opts.fromKey || 'from';
|
|
129
|
+
const toKey = opts.toKey || 'to';
|
|
130
|
+
const typeKey = opts.typeKey || 'type';
|
|
131
|
+
const mustBi = new Set(opts.mustBeBidirectionalTypes || []);
|
|
132
|
+
|
|
133
|
+
const relMap = new Map();
|
|
134
|
+
for (const r of relations) {
|
|
135
|
+
if (!r[fromKey] || !r[toKey]) continue;
|
|
136
|
+
relMap.set(r[fromKey] + '→' + r[toKey], r[typeKey]);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const oneWay = [];
|
|
140
|
+
const asymmetric = [];
|
|
141
|
+
|
|
142
|
+
for (const [k, type] of relMap) {
|
|
143
|
+
const parts = k.split('→');
|
|
144
|
+
const a = parts[0];
|
|
145
|
+
const b = parts[1];
|
|
146
|
+
const rev = b + '→' + a;
|
|
147
|
+
if (!relMap.has(rev)) {
|
|
148
|
+
if (mustBi.has(type)) {
|
|
149
|
+
oneWay.push({ from: a, to: b, type: type });
|
|
150
|
+
}
|
|
151
|
+
} else if (mustBi.has(type) && relMap.get(rev) !== type) {
|
|
152
|
+
asymmetric.push({ from: a, to: b, type: type, reverseType: relMap.get(rev) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { oneWay: oneWay, asymmetric: asymmetric };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ============================================================
|
|
160
|
+
// 角色关系类型元数据(relation_kind 词表)
|
|
161
|
+
// ============================================================
|
|
162
|
+
// 词表的值集合以 references/glossary.md 的 relation_kind 小节为唯一真源,
|
|
163
|
+
// 模板通过 enumRef 引用它。本表只补充"机器判定所需"的两条语义:
|
|
164
|
+
// direction:对称(symmetric)还是有向(directed)
|
|
165
|
+
// group:关系维度。同一维度内的一对角色只能有一种关系;跨维度可共存。
|
|
166
|
+
//
|
|
167
|
+
// 维度划分:
|
|
168
|
+
// identity 结构性身份(血缘/师徒/雇佣)—— 与任何态度关系都可同时成立
|
|
169
|
+
// stance 地位姿态(服从/提携)—— 有向,描述上下位
|
|
170
|
+
// attitude 立场态度(合作/利益交换/利用/对立/敌对/竞争/中立)—— 一对角色只能有一种
|
|
171
|
+
//
|
|
172
|
+
// 同步保障:assertRelationKindMeta 在每次校验时比对本表与 glossary 的值集合,
|
|
173
|
+
// 词表新增/删改而本表未同步时立刻报 REL_KIND_META_DRIFT,不会静默漏判。
|
|
174
|
+
export const RELATION_KIND_META = {
|
|
175
|
+
'血缘': { direction: 'symmetric', group: 'identity' },
|
|
176
|
+
'师徒': { direction: 'directed', group: 'identity' },
|
|
177
|
+
'雇佣': { direction: 'directed', group: 'identity' },
|
|
178
|
+
'服从': { direction: 'directed', group: 'stance' },
|
|
179
|
+
'提携': { direction: 'directed', group: 'stance' },
|
|
180
|
+
'合作': { direction: 'symmetric', group: 'attitude' },
|
|
181
|
+
'利益交换': { direction: 'symmetric', group: 'attitude' },
|
|
182
|
+
'利用': { direction: 'directed', group: 'attitude' },
|
|
183
|
+
'对立': { direction: 'symmetric', group: 'attitude' },
|
|
184
|
+
'敌对': { direction: 'symmetric', group: 'attitude' },
|
|
185
|
+
'竞争': { direction: 'symmetric', group: 'attitude' },
|
|
186
|
+
'中立': { direction: 'symmetric', group: 'attitude' }
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// 有向关系:同一对角色上双向互指同一个值即自相矛盾(互相服从/互相提携/互为师徒)
|
|
190
|
+
// 对称关系:双向声明同一个值是正常的(如双方都写"合作")
|
|
191
|
+
const RELATION_KIND_DIRECTED = 'directed';
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 判定"同一对角色的两个方向声明"是否相容。
|
|
195
|
+
*
|
|
196
|
+
* kindAB:A→B 声明的关系类型
|
|
197
|
+
* kindBA:B→A 声明的关系类型
|
|
198
|
+
*
|
|
199
|
+
* 返回 { ok, reason }。reason 仅在 ok=false 时非空。
|
|
200
|
+
*
|
|
201
|
+
* 规则(只做增量判定:两个方向都声明了才比较):
|
|
202
|
+
* 1. 维度不同 → 相容(血缘 + 合作、师徒 + 服从 可以同时成立)
|
|
203
|
+
* 2. 同维度且是有向关系且值相同 → 不相容(互相服从/互为师徒/互相雇佣)
|
|
204
|
+
* 3. 同属立场维度且值不同 → 不相容(同一对角色不可能既合作又对立)
|
|
205
|
+
* 4. 其余 → 相容
|
|
206
|
+
*
|
|
207
|
+
* 未知值返回 ok=true,交给 ENUM 检查报错,避免重复报同一个问题。
|
|
208
|
+
*/
|
|
209
|
+
export function relationKindCompatible(kindAB, kindBA) {
|
|
210
|
+
const a = RELATION_KIND_META[kindAB];
|
|
211
|
+
const b = RELATION_KIND_META[kindBA];
|
|
212
|
+
if (!a || !b) return { ok: true, reason: null };
|
|
213
|
+
|
|
214
|
+
if (a.group !== b.group) return { ok: true, reason: null };
|
|
215
|
+
|
|
216
|
+
if (a.direction === RELATION_KIND_DIRECTED && kindAB === kindBA) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false,
|
|
219
|
+
reason: '有向关系「' + kindAB + '」在同一对角色上双向互指,自相矛盾'
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (a.group === 'attitude' && kindAB !== kindBA) {
|
|
224
|
+
return {
|
|
225
|
+
ok: false,
|
|
226
|
+
reason: '同一对角色只能有一种立场关系,不能既是「' + kindAB + '」又是「' + kindBA + '」'
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { ok: true, reason: null };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 校验 RELATION_KIND_META 与 glossary 词表是否同步。
|
|
235
|
+
*
|
|
236
|
+
* 返回 null 表示同步;否则返回错误对象(code / message / hint)。
|
|
237
|
+
* 这是词表与元数据之间的唯一防漂移闸门。
|
|
238
|
+
*/
|
|
239
|
+
export function assertRelationKindMeta(enums) {
|
|
240
|
+
const allowed = enums && enums.relation_kind;
|
|
241
|
+
if (!Array.isArray(allowed) || allowed.length === 0) {
|
|
242
|
+
return {
|
|
243
|
+
code: 'REL_KIND_META_DRIFT',
|
|
244
|
+
message: 'glossary.md 缺少 relation_kind 枚举(或解析为空),关系类型词表不可用',
|
|
245
|
+
hint: '在 references/glossary.md 的「五、枚举值总表」中补回 relation_kind 小节'
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const metaKeys = Object.keys(RELATION_KIND_META);
|
|
249
|
+
const missingMeta = allowed.filter((k) => !RELATION_KIND_META[k]);
|
|
250
|
+
const missingEnum = metaKeys.filter((k) => !allowed.includes(k));
|
|
251
|
+
if (missingMeta.length === 0 && missingEnum.length === 0) return null;
|
|
252
|
+
|
|
253
|
+
const parts = [];
|
|
254
|
+
if (missingMeta.length > 0) {
|
|
255
|
+
parts.push('词表中有但元数据缺失:' + missingMeta.join(' / '));
|
|
256
|
+
}
|
|
257
|
+
if (missingEnum.length > 0) {
|
|
258
|
+
parts.push('元数据中有但词表缺失:' + missingEnum.join(' / '));
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
code: 'REL_KIND_META_DRIFT',
|
|
262
|
+
message: 'relation_kind 词表与关系类型元数据不同步:' + parts.join(';'),
|
|
263
|
+
hint: '同步 references/glossary.md 的 relation_kind 小节与 scripts/graph-utils.js 的 RELATION_KIND_META'
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ============================================================
|
|
268
|
+
// parseGlossaryFromMd v1.4
|
|
269
|
+
// ============================================================
|
|
270
|
+
/**
|
|
271
|
+
* 从 glossary.md 文本解析:
|
|
272
|
+
* - enums:所有枚举
|
|
273
|
+
* - chapterScalarFields:章节号"标量"字段
|
|
274
|
+
* - chapterContainerFields:章节号"容器"字段
|
|
275
|
+
*
|
|
276
|
+
* v1.4 变更:
|
|
277
|
+
* - stripTicks 用 String.fromCharCode(96) 生成反引号字符与全局正则
|
|
278
|
+
* (v1.3 用了含反引号的正则字面量,违反本文件零反引号约定)
|
|
279
|
+
* v1.3 变更:
|
|
280
|
+
* - P0-1:fieldName / fieldType 统一剥离反引号(原版未剥离,章节号拦截全失效)
|
|
281
|
+
* - P2-7:fieldType 为 计数 时不加入 chapterScalarFields
|
|
282
|
+
* - 枚举标题正则保留 u 标志 + 中文支持
|
|
283
|
+
*/
|
|
284
|
+
export function parseGlossaryFromMd(mdText) {
|
|
285
|
+
const enums = {};
|
|
286
|
+
const chapterScalarFields = new Set();
|
|
287
|
+
const chapterContainerFields = new Set();
|
|
288
|
+
|
|
289
|
+
const lines = mdText.split('\n');
|
|
290
|
+
let currentEnum = null;
|
|
291
|
+
let inChapterSection = false;
|
|
292
|
+
|
|
293
|
+
// 零反引号:用 fromCharCode 生成反引号字符与全局正则
|
|
294
|
+
// BT_CHAR 是反引号字符,BT_RE 是全局匹配反引号的正则
|
|
295
|
+
const BT_CHAR = String.fromCharCode(96);
|
|
296
|
+
const BT_RE = new RegExp(BT_CHAR, 'g');
|
|
297
|
+
const stripTicks = (s) => (s || '').replace(BT_RE, '').trim();
|
|
298
|
+
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
if (line.startsWith('## 六、')) {
|
|
301
|
+
inChapterSection = true;
|
|
302
|
+
currentEnum = null;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (line.startsWith('## 七、')) {
|
|
306
|
+
inChapterSection = false;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (inChapterSection) {
|
|
311
|
+
if (line.trim().startsWith('|')) {
|
|
312
|
+
const cells = line.split('|').map(c => c.trim());
|
|
313
|
+
if (cells.length < 4) continue;
|
|
314
|
+
// P0-1:剥离反引号
|
|
315
|
+
const fieldName = stripTicks(cells[1]);
|
|
316
|
+
const fieldType = stripTicks(cells[2]);
|
|
317
|
+
if (!fieldName || fieldName === '字段名' || /^[-:]+$/.test(fieldName)) continue;
|
|
318
|
+
const leaf = fieldName.split('.').pop().replace(/\[\]$/, '').trim();
|
|
319
|
+
if (!leaf) continue;
|
|
320
|
+
if (fieldType === '区间') {
|
|
321
|
+
chapterContainerFields.add(leaf);
|
|
322
|
+
} else if (fieldType === '计数') {
|
|
323
|
+
// P2-7:计数是章数不是第几章,不加入标量检查
|
|
324
|
+
continue;
|
|
325
|
+
} else {
|
|
326
|
+
chapterScalarFields.add(leaf);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// 枚举标题:支持英文名 + 中文括注,也支持纯中文
|
|
333
|
+
const headerMatch = line.match(/^####\s+([\w\u4e00-\u9fa5]+)/u);
|
|
334
|
+
if (headerMatch) {
|
|
335
|
+
currentEnum = headerMatch[1];
|
|
336
|
+
enums[currentEnum] = [];
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (line.startsWith('## ') || line.startsWith('### ')) {
|
|
340
|
+
currentEnum = null;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!currentEnum) continue;
|
|
344
|
+
if (line.trim().startsWith('|')) {
|
|
345
|
+
const cells = line.split('|').map(c => c.trim());
|
|
346
|
+
if (cells.length < 3) continue;
|
|
347
|
+
const val = cells[1];
|
|
348
|
+
if (!val || val === '值' || /^[-:]+$/.test(val)) continue;
|
|
349
|
+
enums[currentEnum].push(val);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
for (const k of Object.keys(enums)) {
|
|
354
|
+
enums[k] = [...new Set(enums[k])];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
enums: enums,
|
|
359
|
+
chapterScalarFields: [...chapterScalarFields],
|
|
360
|
+
chapterContainerFields: [...chapterContainerFields]
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 解析词表并校验解析结果可用。
|
|
366
|
+
*
|
|
367
|
+
* 章节号字段集是靠匹配 glossary.md 的 `## 六、` / `## 七、` 字面前缀 +
|
|
368
|
+
* "区间"/"计数" 精确类型串提取的。一旦词表标题被改动、表格列数变化,
|
|
369
|
+
* 字段集会静默变成空集 —— 所有 *_CHAPTER_FORBIDDEN 检查随之失效,
|
|
370
|
+
* 而脚本不会报任何错。这里显式兜底,避免"检查被静默禁用"。
|
|
371
|
+
*/
|
|
372
|
+
export function parseGlossaryChecked(mdText) {
|
|
373
|
+
const parsed = parseGlossaryFromMd(mdText);
|
|
374
|
+
if (parsed.chapterScalarFields.length === 0 && parsed.chapterContainerFields.length === 0) {
|
|
375
|
+
return {
|
|
376
|
+
error: '章节号字段清单解析为空:glossary.md 的「六、章节号字段清单」小节标题或表格列数可能已变更,' +
|
|
377
|
+
'导致 SKELETON_CHAPTER_FORBIDDEN / FLESH_CHAPTER_FORBIDDEN / RHYTHM_CHAP_RANGE 检查失效。' +
|
|
378
|
+
'请恢复该小节(表头需为:| 字段名 | 类型 | 含义 |,类型列取值为"单章号"/"区间"/"计数")。'
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (Object.keys(parsed.enums).length === 0) {
|
|
382
|
+
return { error: '枚举表解析为空:glossary.md 的枚举小节(#### 开头)可能已变更,导致 ENUM 校验失效。' };
|
|
383
|
+
}
|
|
384
|
+
return parsed;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ============================================================
|
|
388
|
+
// 结构化 markdown 生成(零反引号,共享给 skeleton/flesh/rhythm)
|
|
389
|
+
// ============================================================
|
|
390
|
+
const BT = String.fromCharCode(96);
|
|
391
|
+
const BT3 = BT + BT + BT;
|
|
392
|
+
|
|
393
|
+
// 英文字段名 → 中文标签(用于 flesh/rhythm/writing JSON,这些阶段字段名是英文 ID 而非中文)
|
|
394
|
+
// 设计:尽量保留原 key,输出一份中英对照,方便用户对照 JSON 源理解。
|
|
395
|
+
//
|
|
396
|
+
// 维护约定:本表必须覆盖 templates/{骨架,血肉,节奏}/*.json 里 structure.properties
|
|
397
|
+
// 出现的每一个字段名。scripts/label-coverage.js 会逐个核对,漏一个就报错
|
|
398
|
+
// (2026-10 血肉阶段产物复查时发现 283 个模板字段里有 103 个没有中文标签,
|
|
399
|
+
// 产物 md 直接显示 goal / suppression_method 之类的英文 key)。
|
|
400
|
+
export const FIELD_CN = {
|
|
401
|
+
// 通用(跨阶段通用)
|
|
402
|
+
id: 'ID', name: '名称', description: '描述', type: '类型', notes: '备注', meta: '元数据',
|
|
403
|
+
kind: '种类', level: '层级', scale: '范围', pattern: '正则',
|
|
404
|
+
// 选题输入(input_snapshot)
|
|
405
|
+
book_name: '书名', genre: '题材', core_summary: '一句话故事', core_conflict: '核心冲突',
|
|
406
|
+
hard_constraints: '硬门禁', anti_tropes: '反套路',
|
|
407
|
+
// 容量(capacity)
|
|
408
|
+
target_chapters: '目标章节', target_words: '目标字数', avg_words_per_chapter: '平均每章字数',
|
|
409
|
+
// 角色数量(character_counts)
|
|
410
|
+
protagonist: '主角数', core_supporting: '核心配角数', volume_supporting: '卷级配角数',
|
|
411
|
+
core_antagonist: '核心反派数', volume_antagonist: '卷级反派数',
|
|
412
|
+
// 冲突数量(conflict_counts)
|
|
413
|
+
core: '核心冲突数', phase: '阶段冲突数', branch: '交织冲突数',
|
|
414
|
+
// 伏笔/爽点/高潮 size
|
|
415
|
+
small: '小', medium: '中', large: '大',
|
|
416
|
+
// 世界观规则计数
|
|
417
|
+
information: '信息规则数', social: '社会规则数',
|
|
418
|
+
// 配额(quota)
|
|
419
|
+
volume_count: '卷数', branch_count: '支线数', main_line_count: '主线数',
|
|
420
|
+
faction_count: '势力数', faction_relation_count: '势力关系对数',
|
|
421
|
+
location_count: '地点数', ending_count: '结局数',
|
|
422
|
+
protagonist_count: '主角数', core_supporting_count: '核心配角数',
|
|
423
|
+
volume_supporting_count: '卷级配角数', core_antagonist_count: '核心反派数',
|
|
424
|
+
volume_antagonist_count: '卷级反派数', core_conflict_count: '核心冲突数',
|
|
425
|
+
phase_conflict_count: '阶段冲突数', branch_conflict_count: '交织冲突数',
|
|
426
|
+
small_foreshadow_count: '小伏笔数', medium_foreshadow_count: '中伏笔数',
|
|
427
|
+
large_foreshadow_count: '大伏笔数',
|
|
428
|
+
small_climax_count: '小高潮数', medium_climax_count: '中高潮数', large_climax_count: '大高潮数',
|
|
429
|
+
small_pleasure_count: '小爽点数', medium_pleasure_count: '中爽点数', large_pleasure_count: '大爽点数',
|
|
430
|
+
core_item_count: '核心道具数', branch_item_count: '支线道具数', symbolic_item_count: '象征道具数',
|
|
431
|
+
key_location_count: '关键地点数',
|
|
432
|
+
// 公式推导
|
|
433
|
+
formula: '公式', range_low: '区间下界', range_high: '区间上界', note: '备注',
|
|
434
|
+
// 卷比例
|
|
435
|
+
volume_id: '对应卷', chapter_ratio: '章节比例',
|
|
436
|
+
// 节奏参数
|
|
437
|
+
chapter_hook_rate: '钩子率', rate: '比例', is_minimum: '是否强制下限',
|
|
438
|
+
// 时序映射字段(key_nodes、appear_start/end 等)
|
|
439
|
+
node_type: '节点类型', stage_name: '阶段名', event_ref: '事件引用',
|
|
440
|
+
appear_start: '出场开始章', appear_end: '出场结束章',
|
|
441
|
+
// 通用 ID
|
|
442
|
+
char_id: '角色 ID', conflict_id: '冲突 ID', fs_id: '伏笔 ID',
|
|
443
|
+
climax_id: '高潮 ID', pleasure_id: '爽点 ID', main_id: '主线 ID',
|
|
444
|
+
branch_id: '支线 ID', loc_id: '地点 ID', item_id: '道具 ID', ending_id: '结局 ID',
|
|
445
|
+
hook_id: '钩子 ID',
|
|
446
|
+
// 关系节点
|
|
447
|
+
main_antagonist_side: '主要对手阵营',
|
|
448
|
+
// 通用字段(跨阶段)
|
|
449
|
+
age_group: '年龄段', gender: '性别', category: '类别',
|
|
450
|
+
scale: '范围', level: '层级', rule_conflict_plan: '规则冲突预案',
|
|
451
|
+
// 角色相关
|
|
452
|
+
characters: '角色', gender: '性别', age_group: '年龄段',
|
|
453
|
+
goals: '目标', short_term: '短期', medium_term: '中期', long_term: '长期',
|
|
454
|
+
core_flaw: '核心缺陷', golden_finger: '金手指',
|
|
455
|
+
information_state: '信息状态', known_secrets: '已知秘密', unknown_secrets: '未知秘密', false_beliefs: '错误信念',
|
|
456
|
+
arc_type: '弧光类型', arc_stages: '弧光阶段数', arc_change_descriptions: '弧光变化描述',
|
|
457
|
+
language_profile: '语言档案', catchphrases: '口头禅', sentence_style: '句式风格',
|
|
458
|
+
vocabulary_preference: '用词偏好', dialogue_examples: '对话示例',
|
|
459
|
+
relationships: '关系', exit_chapter_estimate: '退场章节预估', appearance_plan: '出场计划',
|
|
460
|
+
// 冲突相关
|
|
461
|
+
conflicts: '冲突', protagonist_side: '主角阵营', main_antagonist: '主要对手',
|
|
462
|
+
secondary_antagonist: '次要对手', third_party: '第三方',
|
|
463
|
+
// 冲突参与者下的 conflict 字段(ID 引用)
|
|
464
|
+
conflict: '所属冲突',
|
|
465
|
+
participating_characters: '参与角色', conflict_type: '冲突类型',
|
|
466
|
+
escalation_path: '升级路径', termination: '收束方法', related_information_gaps: '相关信息差',
|
|
467
|
+
// 势力相关
|
|
468
|
+
factions: '势力', faction_goal: '势力目标', resources: '资源',
|
|
469
|
+
internal_structure: '内部结构', suppression_methods: '压制手段', members: '成员',
|
|
470
|
+
member_name: '成员名', position: '职位', loyalty: '忠诚度',
|
|
471
|
+
// 剧情相关
|
|
472
|
+
plot: '剧情', main_drive: '主线驱动', branch_goals: '支线目标',
|
|
473
|
+
// 地点相关
|
|
474
|
+
locations: '地点', spatial_features: '空间特征', key_events: '关键事件',
|
|
475
|
+
narrative_function: '叙事作用', atmosphere: '氛围',
|
|
476
|
+
// 结局相关
|
|
477
|
+
endings: '结局', ending_type: '结局类型', conflict_resolution: '冲突解决',
|
|
478
|
+
anti_trope_notes: '反套路说明', linger: '余韵', chapter_design: '章节设计',
|
|
479
|
+
foreshadow_recovery_plan: '伏笔回收计划',
|
|
480
|
+
// 世界观规则
|
|
481
|
+
world_rules: '世界观规则', rule_content: '规则内容', scope: '适用范围',
|
|
482
|
+
exceptions: '例外情况', protagonist_impact: '对主角影响',
|
|
483
|
+
information_rules: '信息规则', social_rules: '社会规则',
|
|
484
|
+
cost: '代价', info_source: '信息来源', interruptibility: '可干扰性',
|
|
485
|
+
// 关键道具
|
|
486
|
+
items: '道具', symbolism: '象征意义', usage_scenes: '使用场景',
|
|
487
|
+
appearance: '外观', origin: '来历', functional_detail: '功能详情',
|
|
488
|
+
limit_cost: '限制/代价', related_characters: '涉及角色', related_conflict: '涉及冲突',
|
|
489
|
+
item_type: '道具类型', category: '类别',
|
|
490
|
+
// 节奏相关(rhythm)
|
|
491
|
+
hook: '钩子', rising_action: '上升动作', climax: '高潮', falling_action: '下降动作',
|
|
492
|
+
resolution: '收尾', chapter_hook_type: '钩子类型', pleasure_type: '爽点类型',
|
|
493
|
+
recovery_point: '回收点', foreshadow_id: '伏笔 ID', pleasure_id: '爽点 ID',
|
|
494
|
+
climax_id: '高潮 ID', hook_id: '钩子 ID', chapter_id: '章节 ID',
|
|
495
|
+
// 元数据
|
|
496
|
+
capacity: '容量', input_snapshot: '输入快照', quota: '配额',
|
|
497
|
+
derivations: '推导记录', volume_ratio: '卷比例',
|
|
498
|
+
branches: '支线', foreshadows: '伏笔', climaxes: '高潮', pleasures: '爽点',
|
|
499
|
+
// 节奏文件特殊字段
|
|
500
|
+
main_lines: '主线', main_progress: '主线进度', branch_progress: '支线进度',
|
|
501
|
+
interweave_chapters: '交织章节', branch_chapter_total: '支线章节总数',
|
|
502
|
+
main_progress_chapter_count: '主线节点章节数', overlap_chapter_count: '重叠章节数',
|
|
503
|
+
// 关系字段(骨架关系.json)
|
|
504
|
+
from: '起点', to: '终点', relation_kind: '关系类型', relation_type: '关系类型',
|
|
505
|
+
member_relation: '成员关系', position_level: '职位层级',
|
|
506
|
+
conflicts_with: '涉及冲突', owner_kind: '归属类型', owner_id: '归属 ID',
|
|
507
|
+
volume_ratio_target: '卷比例目标', location: '地点', item: '道具',
|
|
508
|
+
ending: '结局', main_line: '主线',
|
|
509
|
+
character: '角色', faction: '势力', chapter: '章节', theme_short: '主题简述',
|
|
510
|
+
act_index: '幕索引', arc_id: '弧光 ID', chapter_range: '章节范围',
|
|
511
|
+
// 血肉角色弧光
|
|
512
|
+
arc_changes: '弧光变化', end: '终态', changes: '变化列表', exit: '退场',
|
|
513
|
+
expected_volume: '预期卷', experience_count: '经历次数',
|
|
514
|
+
appearance: '出场', behavior_repeat_points: '行为重复要点',
|
|
515
|
+
catchphrase: '口头禅', exclusive_needs: '排他需求',
|
|
516
|
+
identity_wording: '身份措辞', info_preference: '信息偏好',
|
|
517
|
+
memory_points: '记忆要点', behavior_repeat_logic: '行为重复逻辑',
|
|
518
|
+
emotional_baseline: '情绪基线', motivation_logic: '动机逻辑',
|
|
519
|
+
speech_samples: '话语样例', taboos: '禁忌', beats: '节拍',
|
|
520
|
+
// 血肉冲突/势力/剧情/地点/结局/世界观/道具补充
|
|
521
|
+
protagonist_paradox: '主角悖论', paradox: '悖论',
|
|
522
|
+
trigger_chapter: '触发章节', resolution_chapter: '解决章节',
|
|
523
|
+
related_factions: '相关势力', entry_pressure: '进入压制',
|
|
524
|
+
exit_pressure: '退出压制', leader: '领袖', deputy: '副手',
|
|
525
|
+
financial: '财务', territory: '地盘', recruitment: '招募',
|
|
526
|
+
decision_making: '决策机制', intel_network: '情报网',
|
|
527
|
+
economy: '经济来源', resource_composition: '资源构成',
|
|
528
|
+
event_log: '事件日志', geostrategic: '地缘战略',
|
|
529
|
+
branches_anchored: '支线锚点', branch_goals_summary: '支线目标摘要',
|
|
530
|
+
// 节奏
|
|
531
|
+
chapter_appearances: '章节出场', arc_id_linked: '关联弧光 ID',
|
|
532
|
+
chapter_id_start: '起始章节', chapter_id_end: '结束章节',
|
|
533
|
+
// 节奏参数/时序映射/细纲
|
|
534
|
+
branch_chapter_total: '支线章节总数', main_progress_chapter_count: '主线节点章节数',
|
|
535
|
+
overlap_chapter_count: '重叠章节数', branch_chapter_feasible: '支线章节可行约束',
|
|
536
|
+
branch_chapter_ratio: '支线占比约束', overlap_le_branch: '重叠 <= 支线',
|
|
537
|
+
overlap_le_main: '重叠 <= 主线', main_progress_ge_1: '主线节点 ≥ 1',
|
|
538
|
+
main_progress_consistency: '主线节点一致', main_progress_le_T: '主线节点 ≤ T',
|
|
539
|
+
chapter_volume: '章节-卷对应', chapter_act: '章节-幕对应',
|
|
540
|
+
main_progress: '主线进度', branch_progress: '支线进度',
|
|
541
|
+
hook_chapter: '钩子章节', foreshadow_chapter: '伏笔章节',
|
|
542
|
+
pleasure_chapter: '爽点章节', climax_chapter: '高潮章节',
|
|
543
|
+
ending_chapter: '结局章节', volume_summary: '卷汇总',
|
|
544
|
+
// 章节细纲特有
|
|
545
|
+
function_main: '主要功能', function_aux: '辅助功能', emotion_value: '情绪值',
|
|
546
|
+
emotion_phase: '情绪阶段', satisfaction_type: '爽点类型', satisfaction_scale: '爽点规模',
|
|
547
|
+
satisfaction_audience: '爽点受众', foreshadow_action: '伏笔动作',
|
|
548
|
+
faction_appearance: '势力出场', hook_head: '章首钩', hook_tail: '章尾钩',
|
|
549
|
+
hook_strength: '钩子强度', word_target: '目标字数',
|
|
550
|
+
// 节奏章节
|
|
551
|
+
scene_focus: '场景焦点', ch: '章节号', act: '幕',
|
|
552
|
+
// 顶层计数桶(用作 key 时)
|
|
553
|
+
character_counts: '角色数量', conflict_counts: '冲突数量', foreshadow_counts: '伏笔数量',
|
|
554
|
+
climax_counts: '高潮数量', pleasure_counts: '爽点数量', world_rule_counts: '世界观规则数量',
|
|
555
|
+
item_counts: '道具数量', symbolic: '象征',
|
|
556
|
+
// relations 9 类顶层
|
|
557
|
+
character_relations: '角色关系', faction_membership: '势力成员', conflict_participants: '冲突参与',
|
|
558
|
+
branch_protagonist: '支线主角', item_owner: '道具归属', location_conflict: '地点冲突',
|
|
559
|
+
ending_character: '结局角色', main_line_branch_relation: '主线支线交织',
|
|
560
|
+
main_line_span: '主线卷分布', branch_span: '支线卷分布', acts: '幕',
|
|
561
|
+
// ---- 模板字段全集补齐(2026-10 血肉阶段产物复查)----
|
|
562
|
+
// 以下 key 全部来自 templates/{骨架,血肉,节奏}/*.json 的 structure.properties,
|
|
563
|
+
// 此前遗漏,产物 md 里会直接显示英文 key。scripts/label-coverage.js 守住不再回退。
|
|
564
|
+
// 血肉 · 世界观规则
|
|
565
|
+
content: '规则内容', applicable_scope: '适用范围', exception: '例外情况',
|
|
566
|
+
impact_on_protagonist: '对主角影响', violation_consequence: '违规后果',
|
|
567
|
+
impact_on_plot: '对剧情影响', contradiction: '矛盾点', solution: '解决方案',
|
|
568
|
+
core_rules: '核心规则',
|
|
569
|
+
// 血肉 · 关键道具
|
|
570
|
+
function: '功能', relation: '关联', core_items: '核心道具', branch_items: '支线道具',
|
|
571
|
+
symbolic_items: '象征道具', symbolic_meaning: '象征意义', usage_scenario: '使用场景',
|
|
572
|
+
details: '道具详情', limit: '限制',
|
|
573
|
+
// 血肉 · 冲突
|
|
574
|
+
core_goal: '核心目标', influence_scope: '影响范围', focus_points: '焦点',
|
|
575
|
+
surface_contradiction: '表层矛盾', deep_contradiction: '深层矛盾',
|
|
576
|
+
ultimate_contradiction: '终极矛盾', resolution_plan: '收束方案', method: '方式',
|
|
577
|
+
residual_impact: '余波影响', information_gaps: '信息差', info_name: '信息差名称',
|
|
578
|
+
who_knows: '谁知道', who_does_not_know: '谁不知道', resolution_method: '解决方式',
|
|
579
|
+
// 血肉 · 剧情
|
|
580
|
+
goal: '目标', main_relation: '主线关系', expected_outcome: '预期结果',
|
|
581
|
+
// 血肉 · 势力
|
|
582
|
+
suppression_method: '压制手段', resource_seizure_path: '资源夺取路径',
|
|
583
|
+
main_line_connection: '与主线的关系', position_description: '职位描述',
|
|
584
|
+
relation_description: '关系描述', relations: '势力关系', target_name: '对象名称',
|
|
585
|
+
// 血肉 · 地点
|
|
586
|
+
features: '特征', layout: '布局', key_elements: '关键要素',
|
|
587
|
+
key_event_descriptions: '关键事件描述', narrative_role_description: '叙事作用描述',
|
|
588
|
+
// 血肉 · 结局
|
|
589
|
+
conflict_summary: '冲突概述', anti_trope_design: '反套路设计', aftermath: '余波',
|
|
590
|
+
special_remaining: '特别留白', character_destinations: '角色去向',
|
|
591
|
+
suspense_blanks: '悬念留白', emotion_trajectory: '情绪轨迹', core_emotion: '核心情绪',
|
|
592
|
+
final_point: '最终落点', climax_design: '高潮设计', turning_design: '转折设计',
|
|
593
|
+
cost_design: '代价设计', resolution_design: '收尾设计', memorable_scenes: '难忘场景',
|
|
594
|
+
epilogue_hints: '尾声伏笔',
|
|
595
|
+
// 血肉 · 角色
|
|
596
|
+
start: '起始', turning: '转折', midpoint: '中段', speech_rhythm: '语速节奏',
|
|
597
|
+
stance: '立场', personality_tone: '性格基调', relationship_stage_tone: '关系阶段语气',
|
|
598
|
+
mirror_type: '镜像类型', stage: '阶段', personality: '性格',
|
|
599
|
+
presentation_method: '呈现方式',
|
|
600
|
+
// 骨架 · 层级
|
|
601
|
+
volumes: '卷列表',
|
|
602
|
+
// 节奏 · 时序映射
|
|
603
|
+
character_timeline: '角色时序', conflict_timeline: '冲突时序',
|
|
604
|
+
foreshadow_timeline: '伏笔时序', climax_timeline: '高潮时序',
|
|
605
|
+
pleasure_timeline: '爽点时序', hook_timeline: '钩子时序',
|
|
606
|
+
main_line_timeline: '主线时序', branch_timeline: '支线时序',
|
|
607
|
+
location_timeline: '地点时序', item_timeline: '道具时序', ending_timeline: '结局时序',
|
|
608
|
+
exit_chapter: '退场章节', escalation_nodes: '升级节点', created: '埋设章节',
|
|
609
|
+
resolved: '解决章节', buried_chapter: '埋设章节', recovered_chapter: '回收章节',
|
|
610
|
+
key_nodes: '关键节点', interweaves: '交织', foreshadow_recovery: '伏笔回收',
|
|
611
|
+
// 节奏 · 章节规划 / 章节细纲
|
|
612
|
+
chapters: '章节数组', outlines: '章节细纲',
|
|
613
|
+
// 模板字段名与旧表拼写不一致的"孪生键"(手维护表漂移出来的,2026-10 补齐)
|
|
614
|
+
conflict_solution: '冲突解决', // 旧键名 conflict_resolution
|
|
615
|
+
core_drive: '主线驱动', // 旧键名 main_drive
|
|
616
|
+
faction_relations: '势力关系',
|
|
617
|
+
function_detail: '功能详情', // 旧键名 functional_detail
|
|
618
|
+
info_gaps: '信息差', // 旧键名 information_gaps
|
|
619
|
+
related_conflicts: '涉及冲突' // 旧键名 related_conflict
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// 上下文相关的 key→label(不同父 key 同一字段名有不同语义)
|
|
623
|
+
export const CONTEXTUAL_LABEL = {
|
|
624
|
+
'derivations': { formula: '公式', range_low: '区间下界', range_high: '区间上界', note: '备注' },
|
|
625
|
+
'conflict_counts': { core: '核心冲突数', branch: '交织冲突数' },
|
|
626
|
+
'foreshadow_counts': { small: '小伏笔数', medium: '中伏笔数', large: '大伏笔数' },
|
|
627
|
+
'climax_counts': { small: '小高潮数', medium: '中高潮数', large: '大高潮数' },
|
|
628
|
+
'pleasure_counts': { small: '小爽点数', medium: '中爽点数', large: '大爽点数' },
|
|
629
|
+
'item_counts': { core: '核心道具数', branch: '支线道具数', symbolic: '象征道具数' },
|
|
630
|
+
'world_rule_counts': { core: '核心规则数', information: '信息规则数', social: '社会规则数' },
|
|
631
|
+
'character_counts': { protagonist: '主角', core_supporting: '核心配角', volume_supporting: '卷级配角',
|
|
632
|
+
core_antagonist: '核心反派', volume_antagonist: '卷级反派' },
|
|
633
|
+
// 节奏章节中的 volume 字段(章节归属卷)
|
|
634
|
+
'chapters': { volume: '归属卷', act: '归属幕', scene_focus: '场景焦点' },
|
|
635
|
+
// 关系中的 character/faction 字段(关系对象引用)
|
|
636
|
+
'character_relations': { from: '起点角色', to: '终点角色' },
|
|
637
|
+
'faction_membership': { character: '成员角色', member_relation: '意义', position_level: '职位' },
|
|
638
|
+
'conflict_participants': { conflict: '所属冲突' },
|
|
639
|
+
'branch_protagonist': { branch: '所属支线', character: '支线主角' },
|
|
640
|
+
'item_owner': { item: '所属道具', owner_id: '归属 ID', owner_kind: '归属类型' },
|
|
641
|
+
'location_conflict': { location: '所属地点' },
|
|
642
|
+
'ending_character': { ending: '所属结局', character: '角色' },
|
|
643
|
+
'main_line_branch_relation': { main_line: '主线', branch: '支线' },
|
|
644
|
+
// 层级中的卷字段
|
|
645
|
+
'main_line_span': { volume: '归属卷', main_line: '主线', chapter_ratio: '章节占比' },
|
|
646
|
+
'branch_span': { volume: '归属卷', branch: '支线', chapter_ratio: '章节占比' },
|
|
647
|
+
'acts': { volume: '归属卷', act_index: '幕索引', theme_short: '主题简述' },
|
|
648
|
+
'volumes': { id: 'ID', name: '卷名称', theme_short: '主题简述' },
|
|
649
|
+
// 同名不同义:goal 在势力里是"势力目标",在支线里是"支线目标"
|
|
650
|
+
'factions': { goal: '势力目标', relations: '势力关系' },
|
|
651
|
+
'branches': { goal: '支线目标' },
|
|
652
|
+
'main_lines': { goal: '主线目标' },
|
|
653
|
+
// 势力成员 / 势力关系(血肉阶段人读描述层)
|
|
654
|
+
'members': { name: '姓名', position_description: '职位描述', relation_description: '关系描述' },
|
|
655
|
+
'relations': { target_name: '对象名称', description: '关系描述' },
|
|
656
|
+
// 角色关系(血肉阶段人读描述层)
|
|
657
|
+
'relationships': { target_name: '关系对象', relationship_type: '关系类型', description: '关系描述' },
|
|
658
|
+
// 关系里 conflicts 指"该实体涉及的冲突",与冲突档案顶层的 conflicts 不同义
|
|
659
|
+
'item_owner': { item: '所属道具', owner_id: '归属 ID', owner_kind: '归属类型', conflicts: '涉及冲突' },
|
|
660
|
+
'location_conflict': { location: '所属地点', conflicts: '涉及冲突' },
|
|
661
|
+
// 收束方法在两条路径下语义不同
|
|
662
|
+
'escalation_path': { method: '收束方法' },
|
|
663
|
+
'memorable_scenes': { method: '实现方式' }
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// 上下文感知的 cnLabel:传入父 key 返回更精确的中文标签
|
|
667
|
+
export function cnLabelCtx(key, parentKey) {
|
|
668
|
+
if (parentKey && CONTEXTUAL_LABEL[parentKey] && CONTEXTUAL_LABEL[parentKey][key]) {
|
|
669
|
+
return CONTEXTUAL_LABEL[parentKey][key];
|
|
670
|
+
}
|
|
671
|
+
return FIELD_CN[key] || key;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// 把英文 key 翻译为中文(如果映射表里有)。
|
|
675
|
+
// cnLabelCtx(key, parentKey) —— 第一个参数是字段名,第二个是父级 key(用于上下文相关翻译)。
|
|
676
|
+
function cnLabel(key) {
|
|
677
|
+
return FIELD_CN[key] || key;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* 在一组候选名里找"最可能是想写的那个"。
|
|
682
|
+
*
|
|
683
|
+
* 借鉴 llm_wiki 的 lint 建议算法(src/lib/lint-structural-core.ts):
|
|
684
|
+
* 编辑距离 + 中文单字修正。中文实体名普遍 2-5 字,单个汉字区分度太低
|
|
685
|
+
* ("人"能匹配任何含"人"的页),所以单字名一律不参与匹配。
|
|
686
|
+
*
|
|
687
|
+
* 判定刻意保守 —— 宁可不给建议,也不要给错建议:
|
|
688
|
+
* - 候选名与目标等长:差 1 个字才算疑似
|
|
689
|
+
* - 长度差 1:最多差 2 个字才算
|
|
690
|
+
* - 长度差 >= 2:不算
|
|
691
|
+
* - 候选名是单字:不算
|
|
692
|
+
*
|
|
693
|
+
* @param {string} value 写错的名字
|
|
694
|
+
* @param {Iterable<string>} candidates 候选名集合
|
|
695
|
+
* @returns {{name: string, distance: number}|null}
|
|
696
|
+
*/
|
|
697
|
+
export function suggestClosestName(value, candidates) {
|
|
698
|
+
if (!value || typeof value !== 'string') return null;
|
|
699
|
+
const target = value.trim();
|
|
700
|
+
if (target.length <= 1) return null;
|
|
701
|
+
let best = null;
|
|
702
|
+
for (const cand of candidates) {
|
|
703
|
+
if (!cand || typeof cand !== 'string') continue;
|
|
704
|
+
const name = cand.trim();
|
|
705
|
+
if (!name || name === target) continue;
|
|
706
|
+
if (name.length <= 1) continue;
|
|
707
|
+
const diff = Math.abs(name.length - target.length);
|
|
708
|
+
if (diff > 1) continue;
|
|
709
|
+
const d = levenshtein(target, name);
|
|
710
|
+
const ok = diff === 0 ? d === 1 : d <= 2;
|
|
711
|
+
if (!ok) continue;
|
|
712
|
+
if (!best || d < best.distance || (d === best.distance && name.length > best.name.length)) {
|
|
713
|
+
best = { name: name, distance: d };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return best;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** 编辑距离(Levenshtein) */
|
|
720
|
+
export function levenshtein(a, b) {
|
|
721
|
+
const m = a.length;
|
|
722
|
+
const n = b.length;
|
|
723
|
+
if (m === 0) return n;
|
|
724
|
+
if (n === 0) return m;
|
|
725
|
+
let prev = new Array(n + 1);
|
|
726
|
+
let cur = new Array(n + 1);
|
|
727
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
728
|
+
for (let i = 1; i <= m; i++) {
|
|
729
|
+
cur[0] = i;
|
|
730
|
+
for (let j = 1; j <= n; j++) {
|
|
731
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
732
|
+
cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
733
|
+
}
|
|
734
|
+
const tmp = prev;
|
|
735
|
+
prev = cur;
|
|
736
|
+
cur = tmp;
|
|
737
|
+
}
|
|
738
|
+
return prev[n];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// 安全转义:避免字段值含 markdown 控制符破坏渲染
|
|
742
|
+
export function mdEscape(s) {
|
|
743
|
+
if (s === null || s === undefined) return '';
|
|
744
|
+
const str = String(s);
|
|
745
|
+
return str.replace(/\|/g, '\\|').replace(/\n/g, ' ');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// 判断"纯对象"(非 null、非数组的对象)
|
|
749
|
+
function isPlainObject(x) {
|
|
750
|
+
return x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* 单个值的行内文本。
|
|
755
|
+
*
|
|
756
|
+
* 铁律:对象绝不能直接 String()。String(obj) 出来是 "[object Object]",
|
|
757
|
+
* 2026-10 的血肉产物 md 里 members / relations / details 全是这个。
|
|
758
|
+
* 这里给对象一个兜底的可读身份(name / id / title / label),
|
|
759
|
+
* 连身份都没有时列出字段名,保证产物里永远不出现 [object Object]。
|
|
760
|
+
*/
|
|
761
|
+
function inlineValue(x) {
|
|
762
|
+
if (Array.isArray(x)) return x.map(inlineValue).join('、');
|
|
763
|
+
if (isPlainObject(x)) {
|
|
764
|
+
const ident = x.name || x.id || x.title || x.label || x.key;
|
|
765
|
+
if (ident) return mdEscape(ident);
|
|
766
|
+
const keys = Object.keys(x);
|
|
767
|
+
return keys.length ? '{' + keys.join('、') + '}' : '{}';
|
|
768
|
+
}
|
|
769
|
+
return mdEscape(x);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* 渲染"数组值"(某个字段的值是数组时的统一处理)。
|
|
774
|
+
*
|
|
775
|
+
* 规则:
|
|
776
|
+
* - 全标量 → 顿号分隔的一行
|
|
777
|
+
* - 含对象 → 对象元素展开成缩进子条目(父字段是条目,子元素是子弹条,层级不塌)
|
|
778
|
+
* - 混合 → 标量行内 + 对象展开成缩进子条目
|
|
779
|
+
*/
|
|
780
|
+
export function renderArrayValue(arr, indent, key, label) {
|
|
781
|
+
if (!Array.isArray(arr)) return '';
|
|
782
|
+
const pad = ' '.repeat(indent);
|
|
783
|
+
const head = '- **' + label + '**(' + key + ')';
|
|
784
|
+
if (arr.length === 0) return pad + head + ': _(空数组)_\n';
|
|
785
|
+
const objs = arr.filter(isPlainObject);
|
|
786
|
+
const scalars = arr.filter(x => !isPlainObject(x));
|
|
787
|
+
if (objs.length === 0) {
|
|
788
|
+
return pad + head + ': ' + scalars.map(inlineValue).join('、') + '\n';
|
|
789
|
+
}
|
|
790
|
+
// 父字段必须始终保留一条标题行:否则对象元素会直接挂到上一个字段下面,
|
|
791
|
+
// 从属关系就丢了(2026-10 血肉产物复查时发现 members 标题整个消失)。
|
|
792
|
+
let out = pad + head;
|
|
793
|
+
if (scalars.length) out += ': ' + scalars.map(inlineValue).join('、');
|
|
794
|
+
out += '\n';
|
|
795
|
+
// indexOffset:混合数组里对象元素的下标要跟原数组对齐("#N" 兜底名才不会错位)
|
|
796
|
+
const firstObjIdx = arr.findIndex(isPlainObject);
|
|
797
|
+
out += renderArrayItems(objs, indent + 2, key, firstObjIdx);
|
|
798
|
+
return out;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// 渲染一个对象(用缩进的子项列表)。
|
|
802
|
+
// parentKey 是当前对象所在父字段的 key;用于上下文相关字段标签(如 conflict_counts.core vs item_counts.core)。
|
|
803
|
+
export function renderObject(obj, indent, parentKey) {
|
|
804
|
+
if (obj === null || obj === undefined || typeof obj !== 'object') return '';
|
|
805
|
+
const pad = ' '.repeat(indent);
|
|
806
|
+
let out = '';
|
|
807
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
808
|
+
const label = cnLabelCtx(k, parentKey);
|
|
809
|
+
if (Array.isArray(v)) {
|
|
810
|
+
out += renderArrayValue(v, indent, k, label);
|
|
811
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
812
|
+
out += pad + '- **' + label + '**(' + k + ')\n' + renderObject(v, indent + 2, k);
|
|
813
|
+
} else {
|
|
814
|
+
out += pad + '- **' + label + '**(' + k + '): ' + mdEscape(v) + '\n';
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return out;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 取数组元素的"身份":优先 id,其次 name,再次 from→to 关系对,
|
|
822
|
+
* 再到各阶段特有的外键(ch / character / faction / item / location / ending / main_line / branch / volume_id),
|
|
823
|
+
* 最后退化成数组序号。
|
|
824
|
+
*/
|
|
825
|
+
function arrayItemIdentity(item, index, indexOffset) {
|
|
826
|
+
let id = item.id;
|
|
827
|
+
let idDesc = '';
|
|
828
|
+
if (!id && item.name) id = item.name;
|
|
829
|
+
if (!id && item.from && item.to) { id = item.from + '→' + item.to; idDesc = item.relation_kind || item.relation_type || item.member_relation || ''; }
|
|
830
|
+
if (!id && item.ch !== undefined && typeof item.ch === 'number') id = '第' + String(item.ch).padStart(3, '0') + '章';
|
|
831
|
+
if (!id && item.character) id = item.character;
|
|
832
|
+
if (!id && item.faction) id = item.faction;
|
|
833
|
+
if (!id && item.item) id = item.item;
|
|
834
|
+
if (!id && item.location) id = item.location;
|
|
835
|
+
if (!id && item.ending) id = item.ending;
|
|
836
|
+
if (!id && item.main_line) id = item.main_line + '×' + item.branch;
|
|
837
|
+
if (!id && item.branch) id = item.branch;
|
|
838
|
+
if (!id && item.volume_id) id = item.volume_id; // 卷比例数组
|
|
839
|
+
if (!id) id = '#' + (index + 1 + indexOffset);
|
|
840
|
+
return { id: String(id), idDesc: idDesc, name: item.name };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// 渲染数组元素内部的字段(不含元素自身的标题行)
|
|
844
|
+
function renderArrayItemBody(item, indent, parentKey) {
|
|
845
|
+
const pad = ' '.repeat(indent);
|
|
846
|
+
let out = '';
|
|
847
|
+
const fields = Object.entries(item).filter(([k]) => k !== 'id' && k !== 'name');
|
|
848
|
+
for (const [k, v] of fields) {
|
|
849
|
+
const label = cnLabelCtx(k, parentKey);
|
|
850
|
+
if (Array.isArray(v)) {
|
|
851
|
+
out += renderArrayValue(v, indent, k, label);
|
|
852
|
+
} else if (typeof v === 'object' && v !== null) {
|
|
853
|
+
out += pad + '- **' + label + '**(' + k + ')\n';
|
|
854
|
+
out += renderObject(v, indent + 2, k);
|
|
855
|
+
} else {
|
|
856
|
+
out += pad + '- **' + label + '**(' + k + '): ' + mdEscape(v) + '\n';
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return out;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* 嵌套数组的元素:渲染成缩进子弹条(而不是 H4 标题)。
|
|
864
|
+
*
|
|
865
|
+
* 为什么不用 H4:字段标题本身已经是一条 bullet,如果元素也是 H4,
|
|
866
|
+
* 两级就塌成同级,markdown 里 "#### 成员" 和 "#### 柳砚屏" 变成兄弟节点,
|
|
867
|
+
* 人读和 LLM 读都会丢掉从属关系。
|
|
868
|
+
*/
|
|
869
|
+
export function renderArrayItems(arr, indent, parentKey, indexOffset = 0) {
|
|
870
|
+
if (!Array.isArray(arr) || arr.length === 0) return '';
|
|
871
|
+
const pad = ' '.repeat(indent);
|
|
872
|
+
let out = '';
|
|
873
|
+
for (const [i, item] of arr.entries()) {
|
|
874
|
+
if (!isPlainObject(item)) continue;
|
|
875
|
+
const ident = arrayItemIdentity(item, i, indexOffset);
|
|
876
|
+
out += pad + '- **' + mdEscape(ident.id) + '**';
|
|
877
|
+
if (ident.idDesc) out += '(' + ident.idDesc + ')';
|
|
878
|
+
if (ident.name && ident.name !== ident.id) out += ' — ' + mdEscape(ident.name);
|
|
879
|
+
out += '\n';
|
|
880
|
+
out += renderArrayItemBody(item, indent + 2, parentKey);
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// 渲染一个数组:元素为对象时用 H4 详情展开;标量数组用 inline list
|
|
886
|
+
// 顶层数组走这里(元素是 H4);嵌套数组走 renderArrayItems(元素是子弹条)。
|
|
887
|
+
export function renderArray(arr, indent, parentKey, indexOffset = 0) {
|
|
888
|
+
if (!Array.isArray(arr) || arr.length === 0) return '';
|
|
889
|
+
const pad = ' '.repeat(indent);
|
|
890
|
+
let out = '';
|
|
891
|
+
const objs = arr.filter(isPlainObject);
|
|
892
|
+
const scalars = arr.filter(x => !isPlainObject(x));
|
|
893
|
+
// 标量元素行内一行(混合数组时对象元素另行展开,标量不能被 String(obj) 毁掉)
|
|
894
|
+
if (scalars.length) {
|
|
895
|
+
out += pad + scalars.map(inlineValue).join('、') + '\n';
|
|
896
|
+
}
|
|
897
|
+
if (objs.length === 0) return out;
|
|
898
|
+
for (const [i, item] of arr.entries()) {
|
|
899
|
+
if (!isPlainObject(item)) continue;
|
|
900
|
+
const ident = arrayItemIdentity(item, i, indexOffset);
|
|
901
|
+
out += pad + '#### ' + ident.id;
|
|
902
|
+
if (ident.idDesc) out += ' (' + ident.idDesc + ')';
|
|
903
|
+
if (ident.name && ident.name !== ident.id) out += ' — ' + mdEscape(ident.name);
|
|
904
|
+
out += '\n\n';
|
|
905
|
+
out += renderArrayItemBody(item, indent, parentKey);
|
|
906
|
+
out += '\n';
|
|
907
|
+
}
|
|
908
|
+
return out;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// ============================================================
|
|
912
|
+
// flesh / rhythm 阶段的 dispatcher:把单文件 JSON 按"已知顶层数组"切分 H2 分组
|
|
913
|
+
// ============================================================
|
|
914
|
+
|
|
915
|
+
// 血肉文件 → 顶层数组配置(用于血肉 .json dispatcher)
|
|
916
|
+
// key 是文件名(模板/血肉/<名字>模板.json),value 是该文件里真实的顶层数组字段。
|
|
917
|
+
// 两者必须与模板严格一致,否则该文件会整份落到"其他字段"兜底渲染里,结构分组全丢。
|
|
918
|
+
// (历史缺陷:势力的 faction_relations 并不存在;世界观规则漏了 rule_conflict_plan。)
|
|
919
|
+
export const FLESH_DISPATCH = {
|
|
920
|
+
'角色': ['characters'],
|
|
921
|
+
'冲突': ['conflicts'],
|
|
922
|
+
'势力': ['factions'],
|
|
923
|
+
'剧情': ['main_lines', 'branches'],
|
|
924
|
+
'地点': ['locations'],
|
|
925
|
+
'结局': ['endings'],
|
|
926
|
+
'世界观规则': ['core_rules', 'information_rules', 'social_rules', 'rule_conflict_plan'],
|
|
927
|
+
'关键道具': ['core_items', 'branch_items', 'symbolic_items', 'details']
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
// 节奏文件 → 顶层数组配置
|
|
931
|
+
// 节奏参数.json 里全是标量与对象(没有数组),留空数组 → 走通用渲染。
|
|
932
|
+
// (历史缺陷:metrics / feasibility / coverage 三个字段在模板里根本不存在,
|
|
933
|
+
// 时序映射的 11 个字段名也全是旧的,真实字段是 *_timeline。)
|
|
934
|
+
export const RHYTHM_DISPATCH = {
|
|
935
|
+
'章节规划': ['chapters'],
|
|
936
|
+
'节奏参数': [],
|
|
937
|
+
'时序映射': ['character_timeline', 'conflict_timeline', 'foreshadow_timeline',
|
|
938
|
+
'climax_timeline', 'pleasure_timeline', 'hook_timeline',
|
|
939
|
+
'main_line_timeline', 'branch_timeline', 'location_timeline',
|
|
940
|
+
'item_timeline', 'ending_timeline'],
|
|
941
|
+
'章节细纲': ['outlines']
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
// 数组 → 中文 H2 标题映射
|
|
945
|
+
const ARR_CN = {
|
|
946
|
+
characters: '角色档案', conflicts: '冲突档案', factions: '势力档案', faction_relations: '势力关系(血肉)',
|
|
947
|
+
main_lines: '主线', branches: '支线',
|
|
948
|
+
locations: '地点档案', endings: '结局档案',
|
|
949
|
+
core_rules: '核心规则', information_rules: '信息规则', social_rules: '社会规则',
|
|
950
|
+
core_items: '核心道具', branch_items: '支线道具', symbolic_items: '象征道具', details: '道具详情',
|
|
951
|
+
chapters: '章节数组', metrics: '节奏指标', feasibility: '可行性约束', coverage: '覆盖校验',
|
|
952
|
+
outlines: '章节细纲',
|
|
953
|
+
character_timeline: '角色时序', conflict_timeline: '冲突时序',
|
|
954
|
+
foreshadow_timeline: '伏笔时序', climax_timeline: '高潮时序',
|
|
955
|
+
pleasure_timeline: '爽点时序', hook_timeline: '钩子时序',
|
|
956
|
+
main_line_timeline: '主线时序', branch_timeline: '支线时序',
|
|
957
|
+
location_timeline: '地点时序', item_timeline: '道具时序', ending_timeline: '结局时序',
|
|
958
|
+
rule_conflict_plan: '规则冲突方案',
|
|
959
|
+
chapter_volume: '章节归属卷', chapter_act: '章节归属幕',
|
|
960
|
+
hook_chapter: '钩子章节映射', foreshadow_chapter: '伏笔章节映射',
|
|
961
|
+
pleasure_chapter: '爽点章节映射', climax_chapter: '高潮章节映射', ending_chapter: '结局章节映射',
|
|
962
|
+
volume_summary: '卷汇总'
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
// 渲染血肉/节奏文件:按文件类型 → 顶层 H2 → 数组元素 H4
|
|
966
|
+
export function renderByFileType(data, fileType, dispatchMap) {
|
|
967
|
+
const arrKeys = dispatchMap[fileType] || [];
|
|
968
|
+
if (arrKeys.length === 0) {
|
|
969
|
+
// 未识别文件类型,走通用渲染
|
|
970
|
+
return renderObject(data, 0);
|
|
971
|
+
}
|
|
972
|
+
let out = '';
|
|
973
|
+
for (const key of arrKeys) {
|
|
974
|
+
const arr = data[key];
|
|
975
|
+
if (!Array.isArray(arr) || arr.length === 0) continue;
|
|
976
|
+
const label = ARR_CN[key] || cnLabelCtx(key, null);
|
|
977
|
+
out += '## ' + label + '(' + arr.length + ' 项)\n\n';
|
|
978
|
+
out += renderArray(arr, 0, key) + '\n';
|
|
979
|
+
}
|
|
980
|
+
// 残留字段(如有)
|
|
981
|
+
const extraKeys = Object.keys(data).filter(k => !arrKeys.includes(k));
|
|
982
|
+
if (extraKeys.length > 0) {
|
|
983
|
+
out += '## 其他字段\n\n' + renderObject(Object.fromEntries(extraKeys.map(k => [k, data[k]])), 0) + '\n';
|
|
984
|
+
}
|
|
985
|
+
return out;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export function renderFleshByFileType(data, fileType) {
|
|
989
|
+
return renderByFileType(data, fileType, FLESH_DISPATCH);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export function renderRhythmByFileType(data, fileType) {
|
|
993
|
+
return renderByFileType(data, fileType, RHYTHM_DISPATCH);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ============================================================
|
|
997
|
+
// 摘要生成:把上游 JSON 压缩成"id+name"列表注入 prompt
|
|
998
|
+
// ============================================================
|
|
999
|
+
import { readFileSync as _readFileSync, existsSync as _existsSync } from 'node:fs';
|
|
1000
|
+
import { join as _join } from 'node:path';
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 从阶段目录里的 JSON 文件提取所有 id+name 对,每行一类。
|
|
1004
|
+
* 用法:summarizeStage(阶段目录) → "角色(3):C001 陆子仲,C002 老王..."
|
|
1005
|
+
*/
|
|
1006
|
+
export function summarizeStage(stageDir) {
|
|
1007
|
+
if (!_existsSync(stageDir)) return '(阶段目录不存在)';
|
|
1008
|
+
|
|
1009
|
+
const fileMap = {
|
|
1010
|
+
'框架': ['框架.json'],
|
|
1011
|
+
'实体': ['实体.json'],
|
|
1012
|
+
'关系': ['关系.json'],
|
|
1013
|
+
'层级': ['层级.json'],
|
|
1014
|
+
'角色': ['角色.json'],
|
|
1015
|
+
'冲突': ['冲突.json'],
|
|
1016
|
+
'势力': ['势力.json'],
|
|
1017
|
+
'剧情': ['剧情.json'],
|
|
1018
|
+
'地点': ['地点.json'],
|
|
1019
|
+
'结局': ['结局.json'],
|
|
1020
|
+
'世界观规则': ['世界观规则.json'],
|
|
1021
|
+
'关键道具': ['关键道具.json'],
|
|
1022
|
+
'章节规划': ['章节规划.json'],
|
|
1023
|
+
'节奏参数': ['节奏参数.json'],
|
|
1024
|
+
'时序映射': ['时序映射.json'],
|
|
1025
|
+
'章节细纲': ['章节细纲.json']
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
let out = '';
|
|
1029
|
+
for (const [label, filenames] of Object.entries(fileMap)) {
|
|
1030
|
+
for (const fn of filenames) {
|
|
1031
|
+
const fp = _join(stageDir, fn);
|
|
1032
|
+
if (!_existsSync(fp)) continue;
|
|
1033
|
+
try {
|
|
1034
|
+
const data = JSON.parse(_readFileSync(fp, 'utf-8'));
|
|
1035
|
+
const lines = summarizeObject(data);
|
|
1036
|
+
if (lines) out += lines + '\n';
|
|
1037
|
+
} catch { /* ignore */ }
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return out || '(没有可摘要的字段)';
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* 包装一个检查函数,把内部异常转成结构化错误。
|
|
1045
|
+
*
|
|
1046
|
+
* 专用检查假定数据已通过模板校验(类型正确),但模板校验收集到的 TYPE 错误
|
|
1047
|
+
* 并不会阻止专用检查继续跑;一旦数据结构不符(例如数组被写成对象),
|
|
1048
|
+
* 专用检查就会抛未捕获异常,整个脚本没有 JSON 输出、只剩 Node 堆栈。
|
|
1049
|
+
* 这里统一兜底,保证任何畸形输入都得到可解析的结构化结果。
|
|
1050
|
+
*/
|
|
1051
|
+
export function safeCheck(name, fn, errors) {
|
|
1052
|
+
try {
|
|
1053
|
+
fn();
|
|
1054
|
+
return true;
|
|
1055
|
+
} catch (e) {
|
|
1056
|
+
const msg = e && e.message ? e.message : String(e);
|
|
1057
|
+
errors.push({
|
|
1058
|
+
code: 'CHECK_CRASHED',
|
|
1059
|
+
path: name,
|
|
1060
|
+
message: '检查项 ' + name + ' 执行异常:' + msg,
|
|
1061
|
+
hint: '通常是数据字段类型与模板不符(先看本批的 TYPE 错误)'
|
|
1062
|
+
});
|
|
1063
|
+
return false;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/**
|
|
1068
|
+
* 读取阶段目录的 .stage-status.json,判断该阶段是否已通过全量校验。
|
|
1069
|
+
*
|
|
1070
|
+
* 阶段门禁的机器可判定部分。此前这个判定只写在提示词里,脚本从不读取上游状态,
|
|
1071
|
+
* 导致"上游没通过全量校验"照样能往下跑。
|
|
1072
|
+
*
|
|
1073
|
+
* 返回 { ok, reason, status }:
|
|
1074
|
+
* ok=false 时 reason 说明具体原因(可直接作为错误信息)
|
|
1075
|
+
*/
|
|
1076
|
+
export function readStageGate(stageDir) {
|
|
1077
|
+
const p = _join(stageDir, '.stage-status.json');
|
|
1078
|
+
if (!_existsSync(p)) {
|
|
1079
|
+
return { ok: false, reason: '缺少 .stage-status.json(该阶段还没跑过校验)', status: null };
|
|
1080
|
+
}
|
|
1081
|
+
let s;
|
|
1082
|
+
try {
|
|
1083
|
+
s = JSON.parse(_readFileSync(p, 'utf-8'));
|
|
1084
|
+
} catch (e) {
|
|
1085
|
+
return { ok: false, reason: '.stage-status.json 解析失败:' + e.message, status: null };
|
|
1086
|
+
}
|
|
1087
|
+
if (!s || typeof s !== 'object') {
|
|
1088
|
+
return { ok: false, reason: '.stage-status.json 内容不是对象', status: null };
|
|
1089
|
+
}
|
|
1090
|
+
const all = s.all_check;
|
|
1091
|
+
if (!all || typeof all !== 'object') {
|
|
1092
|
+
return { ok: false, reason: '.stage-status.json 缺 all_check(未做全量校验)', status: s };
|
|
1093
|
+
}
|
|
1094
|
+
if (all.status !== 'PASSED') {
|
|
1095
|
+
return {
|
|
1096
|
+
ok: false,
|
|
1097
|
+
reason: '上游阶段未通过全量校验(all_check.status=' + (all.status || '缺失') + ')',
|
|
1098
|
+
status: s
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
return { ok: true, reason: null, status: s };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/**
|
|
1105
|
+
* 在一批上游目录里找出阶段目录(含 .stage-status.json 的那个)。
|
|
1106
|
+
* 血肉阶段的 --upstream 指向骨架目录,节奏阶段指向骨架+血肉两个目录。
|
|
1107
|
+
*/
|
|
1108
|
+
export function findStageDirWithStatus(dirs, stageName) {
|
|
1109
|
+
for (const d of dirs || []) {
|
|
1110
|
+
if (_existsSync(_join(d, '.stage-status.json'))) return d;
|
|
1111
|
+
}
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
export function summarizeSkeleton(skeletonDir) {
|
|
1116
|
+
return summarizeStage(skeletonDir);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export function summarizeFlesh(fleshDir) {
|
|
1120
|
+
return summarizeStage(fleshDir);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
export function summarizeRhythm(rhythmDir) {
|
|
1124
|
+
return summarizeStage(rhythmDir);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// ============================================================
|
|
1128
|
+
// 任务输入快照(血肉 / 节奏阶段的「一、任务输入」数据源)
|
|
1129
|
+
// ============================================================
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* 读取骨架阶段的 input_snapshot(任务输入快照)。
|
|
1133
|
+
*
|
|
1134
|
+
* 为什么不用会话参数拼:
|
|
1135
|
+
* 骨架阶段已经把用户给的任务输入固化成了 框架.json 里的一份可校验快照
|
|
1136
|
+
* (骨架校验器会检查它跟骨架模板一致),血肉/节奏阶段直接读这份快照,
|
|
1137
|
+
* 比在提示词里另拼一份更权威,也不会出现"两边字段数对不上"。
|
|
1138
|
+
* 2026-10 之前血肉提示词只注入了书名/题材/目标章节/一句话故事 4 项,
|
|
1139
|
+
* 硬门禁、反套路、核心冲突全丢给 LLM 猜。
|
|
1140
|
+
*
|
|
1141
|
+
* 返回 null 表示"骨架还没产出框架.json 或没有 input_snapshot",
|
|
1142
|
+
* 调用方需要自己决定兜底策略(不能静默当成空输入)。
|
|
1143
|
+
*/
|
|
1144
|
+
export function readInputSnapshot(skeletonDir) {
|
|
1145
|
+
const p = _join(skeletonDir, '框架.json');
|
|
1146
|
+
if (!_existsSync(p)) return null;
|
|
1147
|
+
let data;
|
|
1148
|
+
try {
|
|
1149
|
+
data = JSON.parse(_readFileSync(p, 'utf-8'));
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
return null;
|
|
1152
|
+
}
|
|
1153
|
+
if (!data || typeof data !== 'object') return null;
|
|
1154
|
+
const snap = data.input_snapshot;
|
|
1155
|
+
if (!snap || typeof snap !== 'object' || Array.isArray(snap)) return null;
|
|
1156
|
+
const cap = (data.capacity && typeof data.capacity === 'object') ? data.capacity : {};
|
|
1157
|
+
return {
|
|
1158
|
+
book_name: snap.book_name,
|
|
1159
|
+
genre: snap.genre,
|
|
1160
|
+
core_summary: snap.core_summary,
|
|
1161
|
+
core_conflict: snap.core_conflict,
|
|
1162
|
+
hard_constraints: Array.isArray(snap.hard_constraints) ? snap.hard_constraints : [],
|
|
1163
|
+
anti_tropes: Array.isArray(snap.anti_tropes) ? snap.anti_tropes : [],
|
|
1164
|
+
target_chapters: cap.target_chapters,
|
|
1165
|
+
target_words: cap.target_words
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// 任务输入里各字段的中文名(渲染成提示词里的清单)
|
|
1170
|
+
const INPUT_FIELD_CN = {
|
|
1171
|
+
book_name: '书名', genre: '题材', core_summary: '一句话故事', core_conflict: '核心冲突',
|
|
1172
|
+
target_chapters: '目标章节', target_words: '目标字数'
|
|
1173
|
+
};
|
|
1174
|
+
// 列表型字段(渲染成编号清单)
|
|
1175
|
+
const INPUT_LIST_CN = {
|
|
1176
|
+
hard_constraints: '硬门禁',
|
|
1177
|
+
anti_tropes: '反套路'
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* 把任务输入快照渲染成提示词里的一节 markdown。
|
|
1182
|
+
*
|
|
1183
|
+
* 输出形如:
|
|
1184
|
+
* - 书名:XXX
|
|
1185
|
+
* - 题材:XXX
|
|
1186
|
+
* - 目标章节:800
|
|
1187
|
+
* - 目标字数:2400000
|
|
1188
|
+
* - 一句话故事:XXX
|
|
1189
|
+
* - 核心冲突:XXX
|
|
1190
|
+
* - 硬门禁:
|
|
1191
|
+
* 1. ...
|
|
1192
|
+
* - 反套路:
|
|
1193
|
+
* 1. ...
|
|
1194
|
+
*/
|
|
1195
|
+
export function renderInputSnapshot(snapshot) {
|
|
1196
|
+
if (!snapshot || typeof snapshot !== 'object') return '_(任务输入快照缺失)_\n';
|
|
1197
|
+
const lines = [];
|
|
1198
|
+
for (const [k, label] of Object.entries(INPUT_FIELD_CN)) {
|
|
1199
|
+
const v = snapshot[k];
|
|
1200
|
+
if (v === undefined || v === null || v === '') continue;
|
|
1201
|
+
lines.push('- ' + label + ':' + String(v).replace(/\n/g, ' '));
|
|
1202
|
+
}
|
|
1203
|
+
for (const [k, label] of Object.entries(INPUT_LIST_CN)) {
|
|
1204
|
+
const v = snapshot[k];
|
|
1205
|
+
if (!Array.isArray(v) || v.length === 0) continue;
|
|
1206
|
+
lines.push('- ' + label + ':');
|
|
1207
|
+
v.forEach((item, i) => {
|
|
1208
|
+
lines.push(' ' + (i + 1) + '. ' + String(item).replace(/\n/g, ' '));
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
return lines.length ? lines.join('\n') + '\n' : '_(任务输入快照为空)_\n';
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// 摘要里单个数组的最大条目数(防御性上限;正常数据远小于此值,
|
|
1215
|
+
// T=1000 的极限骨架全量 id+name 约 8.7KB,不会触顶)
|
|
1216
|
+
const SUMMARY_MAX_ITEMS = 2000;
|
|
1217
|
+
|
|
1218
|
+
// id+name 列表的分隔符。
|
|
1219
|
+
// 2026-10 之前用中文逗号,但实体名本身经常带逗号("断口对上的那一刻,第二次"),
|
|
1220
|
+
// 分隔符和内容混在一起,LLM 根本分不清哪段是名字、哪段是下一个条目。
|
|
1221
|
+
// 竖线在中文标题里几乎不会出现,用它做分隔能一眼切开。
|
|
1222
|
+
const SUMMARY_ITEM_SEP = ' | ';
|
|
1223
|
+
|
|
1224
|
+
// 关系型行的外键字段(按固定顺序拼接成 "A→B" 形式,保留关系两端)
|
|
1225
|
+
const RELATION_ID_KEYS = [
|
|
1226
|
+
'main_line', 'branch', 'character', 'faction', 'conflict',
|
|
1227
|
+
'item', 'location', 'ending', 'volume', 'owner_id'
|
|
1228
|
+
];
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* 从一个数组元素里提取可读身份。
|
|
1232
|
+
* 优先级:id → ch → 关系对(from→to) → 专用外键(main_line/branch/...)
|
|
1233
|
+
* 覆盖骨架 4 个文件与血肉/节奏各文件里全部已知的行结构,
|
|
1234
|
+
* 避免"文件里明明有数据、摘要里却整段丢失"的情况。
|
|
1235
|
+
*/
|
|
1236
|
+
function identityOf(it) {
|
|
1237
|
+
if (it.id !== undefined && it.id !== null && it.id !== '') {
|
|
1238
|
+
return { id: String(it.id), chSuffix: '' };
|
|
1239
|
+
}
|
|
1240
|
+
if (it.ch !== undefined && it.ch !== null) {
|
|
1241
|
+
return { id: String(it.ch), chSuffix: '章' };
|
|
1242
|
+
}
|
|
1243
|
+
if (it.from && it.to) {
|
|
1244
|
+
const rel = it.relation_kind || it.relation_type || it.member_relation || it.relation || '';
|
|
1245
|
+
return { id: it.from + '→' + it.to + (rel ? '(' + rel + ')' : ''), chSuffix: '' };
|
|
1246
|
+
}
|
|
1247
|
+
const parts = [];
|
|
1248
|
+
for (const k of RELATION_ID_KEYS) {
|
|
1249
|
+
if (it[k] !== undefined && it[k] !== null && it[k] !== '') parts.push(String(it[k]));
|
|
1250
|
+
}
|
|
1251
|
+
if (parts.length) {
|
|
1252
|
+
const extra = it.act_index !== undefined && it.act_index !== null ? '#' + it.act_index : '';
|
|
1253
|
+
return { id: parts.join('→') + extra, chSuffix: '' };
|
|
1254
|
+
}
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* 提取可读名称。没有 name 字段的行结构按语义补一个极简标识,
|
|
1260
|
+
* 让 LLM 至少能从摘要看出"这一行大概是什么"。
|
|
1261
|
+
*/
|
|
1262
|
+
function nameOf(it, key) {
|
|
1263
|
+
if (it.name) return it.name;
|
|
1264
|
+
if (key === 'chapters') {
|
|
1265
|
+
const parts = [];
|
|
1266
|
+
if (it.volume) parts.push(it.volume);
|
|
1267
|
+
if (it.act !== undefined && it.act !== null) parts.push('幕' + it.act);
|
|
1268
|
+
if (it.scene_focus) parts.push(it.scene_focus);
|
|
1269
|
+
return parts.join(' ');
|
|
1270
|
+
}
|
|
1271
|
+
if (key === 'outlines') {
|
|
1272
|
+
const parts = [];
|
|
1273
|
+
if (it.function_main) parts.push(it.function_main);
|
|
1274
|
+
if (it.satisfaction_type) parts.push(it.satisfaction_type);
|
|
1275
|
+
if (it.hook_head) parts.push(it.hook_head);
|
|
1276
|
+
return parts.join('/');
|
|
1277
|
+
}
|
|
1278
|
+
if (it.scene_focus) return it.scene_focus;
|
|
1279
|
+
if (it.theme_short) return it.theme_short;
|
|
1280
|
+
if (it.role) return it.role;
|
|
1281
|
+
if (it.title) return it.title;
|
|
1282
|
+
return '';
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
/**
|
|
1286
|
+
* quota 的分组标签。
|
|
1287
|
+
*
|
|
1288
|
+
* 2026-10 之前 quota 被 walk() 扁平化成 "volume_count=8,main_line_count=1,……"
|
|
1289
|
+
* 一长串英文 key 挤在一行,注入 prompt 后 LLM 很难一眼读出"有多少角色/多少高潮"。
|
|
1290
|
+
* 现在按"顶层标量 / 计数桶"分组,每组一行,标签用中文。
|
|
1291
|
+
*/
|
|
1292
|
+
const QUOTA_GROUP_CN = {
|
|
1293
|
+
character_counts: '角色',
|
|
1294
|
+
conflict_counts: '冲突',
|
|
1295
|
+
foreshadow_counts: '伏笔',
|
|
1296
|
+
climax_counts: '高潮',
|
|
1297
|
+
pleasure_counts: '爽点',
|
|
1298
|
+
item_counts: '道具',
|
|
1299
|
+
world_rule_counts: '世界观规则'
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
// 各计数桶内部的成员名(比 FIELD_CN 更短,桶名已经说明了"数",不需要再写一遍)
|
|
1303
|
+
const QUOTA_ITEM_CN = {
|
|
1304
|
+
character_counts: {
|
|
1305
|
+
protagonist: '主角', core_supporting: '核心配角', volume_supporting: '卷级配角',
|
|
1306
|
+
core_antagonist: '核心反派', volume_antagonist: '卷级反派'
|
|
1307
|
+
},
|
|
1308
|
+
conflict_counts: { core: '核心', phase: '阶段', branch: '交织' },
|
|
1309
|
+
foreshadow_counts: { small: '小', medium: '中', large: '大' },
|
|
1310
|
+
climax_counts: { small: '小', medium: '中', large: '大' },
|
|
1311
|
+
pleasure_counts: { small: '小', medium: '中', large: '大' },
|
|
1312
|
+
item_counts: { core: '核心', branch: '支线', symbolic: '象征' },
|
|
1313
|
+
world_rule_counts: { core: '核心', information: '信息', social: '社会' }
|
|
1314
|
+
};
|
|
1315
|
+
|
|
1316
|
+
/**
|
|
1317
|
+
* 骨架/框架里的非数组标量(capacity / quota)也要进摘要:
|
|
1318
|
+
* 写作阶段需要 T、V、章均字数;节奏阶段需要各实体配额。
|
|
1319
|
+
*/
|
|
1320
|
+
function summarizeScalars(data) {
|
|
1321
|
+
const lines = [];
|
|
1322
|
+
const cap = data.capacity;
|
|
1323
|
+
if (cap && typeof cap === 'object') {
|
|
1324
|
+
const parts = [];
|
|
1325
|
+
if (cap.target_chapters != null) parts.push('T=' + cap.target_chapters);
|
|
1326
|
+
if (cap.target_words != null) parts.push('总字数=' + cap.target_words);
|
|
1327
|
+
if (cap.avg_words_per_chapter != null) parts.push('章均字数=' + cap.avg_words_per_chapter);
|
|
1328
|
+
if (parts.length) lines.push('capacity: ' + parts.join(','));
|
|
1329
|
+
}
|
|
1330
|
+
const quota = data.quota;
|
|
1331
|
+
if (quota && typeof quota === 'object') {
|
|
1332
|
+
// 顶层标量配额(卷数、主线数、支线数……)单独一行
|
|
1333
|
+
const flat = [];
|
|
1334
|
+
const groups = [];
|
|
1335
|
+
for (const [k, v] of Object.entries(quota)) {
|
|
1336
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
1337
|
+
groups.push([k, v]);
|
|
1338
|
+
} else if (typeof v === 'number') {
|
|
1339
|
+
flat.push((FIELD_CN[k] || k) + '=' + v);
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
if (flat.length) lines.push('quota 规模: ' + flat.join('、'));
|
|
1343
|
+
for (const [gk, gv] of groups) {
|
|
1344
|
+
const items = [];
|
|
1345
|
+
for (const [k, v] of Object.entries(gv)) {
|
|
1346
|
+
if (typeof v !== 'number') continue;
|
|
1347
|
+
const label = (QUOTA_ITEM_CN[gk] && QUOTA_ITEM_CN[gk][k]) || FIELD_CN[k] || k;
|
|
1348
|
+
items.push(label + '=' + v);
|
|
1349
|
+
}
|
|
1350
|
+
if (!items.length) continue;
|
|
1351
|
+
lines.push('quota ' + (QUOTA_GROUP_CN[gk] || FIELD_CN[gk] || gk) + ': ' + items.join('、'));
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
return lines;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
function summarizeObject(data, depth = 0) {
|
|
1358
|
+
// 顶层是 dict,含若干数组
|
|
1359
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return '';
|
|
1360
|
+
const lines = summarizeScalars(data);
|
|
1361
|
+
for (const [key, val] of Object.entries(data)) {
|
|
1362
|
+
if (!Array.isArray(val)) continue;
|
|
1363
|
+
// 数组
|
|
1364
|
+
const items = val.filter(x => x && typeof x === 'object');
|
|
1365
|
+
if (items.length === 0) continue;
|
|
1366
|
+
|
|
1367
|
+
// 提取每个元素的 id+name 缩略
|
|
1368
|
+
const summaries = [];
|
|
1369
|
+
for (const it of items) {
|
|
1370
|
+
const ident = identityOf(it);
|
|
1371
|
+
if (!ident) continue;
|
|
1372
|
+
const nm = nameOf(it, key);
|
|
1373
|
+
summaries.push(`${ident.id}${nm ? ' ' + nm : ''}${ident.chSuffix}`);
|
|
1374
|
+
if (summaries.length >= SUMMARY_MAX_ITEMS) break;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
if (summaries.length === 0) continue;
|
|
1378
|
+
const truncated = items.length > summaries.length;
|
|
1379
|
+
lines.push(
|
|
1380
|
+
`${key}(${items.length}): ${summaries.join(SUMMARY_ITEM_SEP)}` +
|
|
1381
|
+
(truncated ? `...(仅列出前 ${summaries.length} 条)` : '')
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
return lines.join('\n');
|
|
1385
|
+
}
|