@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.
Files changed (134) hide show
  1. package/client.js +183 -0
  2. package/cordis.patch.yml +0 -14
  3. package/index.js +21 -37
  4. package/lib/chinese-num.js +111 -0
  5. package/lib/deployed-state.js +141 -0
  6. package/lib/error-codes.js +104 -0
  7. package/lib/infra.js +569 -0
  8. package/lib/llm-wiki.js +109 -0
  9. package/lib/safe-rm.js +94 -0
  10. package/lib/scraper-utils.js +165 -0
  11. package/package.json +37 -5
  12. package/plugins/fancy-analyze/command-input.js +65 -0
  13. package/plugins/fancy-analyze/index.js +973 -0
  14. package/plugins/fancy-analyze/schema-analyze.md +379 -0
  15. package/plugins/fancy-analyze/schema-write.md +178 -0
  16. package/plugins/fancy-analyze/scripts/split_source.js +292 -0
  17. package/plugins/fancy-analyze/summary-progress.js +431 -0
  18. package/plugins/fancy-analyze/templates.js +881 -0
  19. package/plugins/fancy-bootstrap/index.js +168 -0
  20. package/plugins/fancy-bootstrap/scripts/init.js +143 -0
  21. package/plugins/fancy-bootstrap/scripts/path-utils.js +21 -0
  22. package/plugins/fancy-bootstrap/templates.js +54 -0
  23. package/plugins/fancy-import/import-core.js +1118 -0
  24. package/plugins/fancy-import/import-driver.js +581 -0
  25. package/plugins/fancy-import/index.js +730 -0
  26. package/plugins/fancy-import/prompts/01-import-prompt.md +106 -0
  27. package/plugins/fancy-import/templates.js +49 -0
  28. package/plugins/fancy-scan/index.js +319 -0
  29. package/plugins/fancy-scan/scripts/run-scan.js +520 -0
  30. package/plugins/fancy-scan/scripts/scrapers/fanqie-rank-scraper.js +794 -0
  31. package/plugins/fancy-scan/scripts/scrapers/jjwxc-rank-scraper.js +339 -0
  32. package/plugins/fancy-scan/scripts/scrapers/qimao-rank-scraper.js +611 -0
  33. package/plugins/fancy-scan/templates.js +295 -0
  34. package/plugins/fancy-story-builder/deploy-manifest.js +239 -0
  35. package/plugins/fancy-story-builder/deslop-driver.js +821 -0
  36. package/plugins/fancy-story-builder/flesh-output.js +155 -0
  37. package/plugins/fancy-story-builder/index.js +3152 -0
  38. package/plugins/fancy-story-builder/prompts/01-skeleton-prompt.md +553 -0
  39. package/plugins/fancy-story-builder/prompts/02-flesh-prompt.md +540 -0
  40. package/plugins/fancy-story-builder/prompts/03-rhythm-prompt.md +504 -0
  41. package/plugins/fancy-story-builder/prompts/04-writing-prompt.md +188 -0
  42. package/plugins/fancy-story-builder/prompts/05-deslop-prompt.md +96 -0
  43. package/plugins/fancy-story-builder/purpose-template.md +107 -0
  44. package/plugins/fancy-story-builder/references/formulas-rhythm.json +302 -0
  45. package/plugins/fancy-story-builder/references/formulas-skeleton.json +229 -0
  46. package/plugins/fancy-story-builder/references/glossary.md +585 -0
  47. package/plugins/fancy-story-builder/references/index.md +490 -0
  48. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/00-index.md +174 -0
  49. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/01-scene.md +150 -0
  50. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/02-chapter-workflow.md +99 -0
  51. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/03-opening-chapter.md +104 -0
  52. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/04-dialogue-mastery.md +274 -0
  53. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/05-genre-formulas.md +432 -0
  54. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/06-tone-ensemble.md +259 -0
  55. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/07-ledger-audit.md +222 -0
  56. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/08-diagnosis-revision.md +205 -0
  57. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/09-reading-power.md +135 -0
  58. package/plugins/fancy-story-builder/references//345/206/231/344/275/234/346/214/207/345/257/274/10-deslop.md +129 -0
  59. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/00-index.md +98 -0
  60. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/01-upgrade-rhythm.md +300 -0
  61. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/02-arc-rhythm.md +168 -0
  62. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/03-highpoint-hooks.md +189 -0
  63. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/04-satisfaction.md +226 -0
  64. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/05-foreshadow-density.md +195 -0
  65. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/06-chapter-outline.md +226 -0
  66. package/plugins/fancy-story-builder/references//350/212/202/345/245/217/346/214/207/345/257/274/07-audit.md +163 -0
  67. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/00-index.md +100 -0
  68. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/01-character.md +239 -0
  69. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/02-worldbuilding.md +245 -0
  70. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/03-relations.md +181 -0
  71. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/04-emotion-methods.md +286 -0
  72. package/plugins/fancy-story-builder/references//350/241/200/350/202/211/346/214/207/345/257/274/05-audit.md +177 -0
  73. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/00-index.md +91 -0
  74. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/01-positioning.md +208 -0
  75. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/02-packaging.md +135 -0
  76. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/03-entities.md +305 -0
  77. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/04-conflict.md +328 -0
  78. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/05-quota.md +187 -0
  79. package/plugins/fancy-story-builder/references//351/252/250/346/236/266/346/214/207/345/257/274/06-audit.md +142 -0
  80. package/plugins/fancy-story-builder/rhythm-output.js +140 -0
  81. package/plugins/fancy-story-builder/schema-setting.md +324 -0
  82. package/plugins/fancy-story-builder/scripts/ai-patterns.js +619 -0
  83. package/plugins/fancy-story-builder/scripts/check-guide-compliance.js +289 -0
  84. package/plugins/fancy-story-builder/scripts/content-quality.js +413 -0
  85. package/plugins/fancy-story-builder/scripts/deslop-validate.js +143 -0
  86. package/plugins/fancy-story-builder/scripts/error-classes.js +112 -0
  87. package/plugins/fancy-story-builder/scripts/flesh-validate.js +1189 -0
  88. package/plugins/fancy-story-builder/scripts/formula-engine.js +112 -0
  89. package/plugins/fancy-story-builder/scripts/graph-utils.js +1385 -0
  90. package/plugins/fancy-story-builder/scripts/label-coverage.js +171 -0
  91. package/plugins/fancy-story-builder/scripts/prompt-drift-check.js +441 -0
  92. package/plugins/fancy-story-builder/scripts/reading-power.js +298 -0
  93. package/plugins/fancy-story-builder/scripts/rhythm-validate.js +1216 -0
  94. package/plugins/fancy-story-builder/scripts/semantic-lint.js +377 -0
  95. package/plugins/fancy-story-builder/scripts/skeleton-quota.js +145 -0
  96. package/plugins/fancy-story-builder/scripts/skeleton-validate.js +1832 -0
  97. package/plugins/fancy-story-builder/scripts/stage-batches.js +1371 -0
  98. package/plugins/fancy-story-builder/scripts/stage-contract.js +417 -0
  99. package/plugins/fancy-story-builder/scripts/upstream-query.js +198 -0
  100. package/plugins/fancy-story-builder/scripts/writing-rules.js +167 -0
  101. package/plugins/fancy-story-builder/scripts/writing-validate.js +682 -0
  102. package/plugins/fancy-story-builder/skeleton-output.js +199 -0
  103. package/plugins/fancy-story-builder/stage-driver.js +1049 -0
  104. 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
  105. 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
  106. 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
  107. 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
  108. 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
  109. 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
  110. 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
  111. 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
  112. 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
  113. 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
  114. 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
  115. 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
  116. 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
  117. 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
  118. 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
  119. 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
  120. package/plugins/fancy-story-builder/templates.js +1248 -0
  121. package/plugins/fancy-story-builder/wiki-references.js +204 -0
  122. package/plugins/fancy-story-builder/writing-driver.js +1061 -0
  123. package/plugins/fancy-topic/index.js +704 -0
  124. package/plugins/fancy-topic/scripts/select-topic.js +274 -0
  125. package/plugins/fancy-topic/templates.js +373 -0
  126. package/commands/bootstrap/index.js +0 -160
  127. package/commands/bootstrap/init-project.js +0 -121
  128. package/commands/scan/index.js +0 -275
  129. package/commands/scan/platforms/fanqie.js +0 -146
  130. package/commands/scan/platforms/jinjiang.js +0 -143
  131. package/commands/scan/platforms/qidian.js +0 -102
  132. package/commands/scan/platforms/qimao.js +0 -127
  133. package/commands/scan/scraper.js +0 -195
  134. package/infra.js +0 -160
@@ -0,0 +1,581 @@
1
+ /**
2
+ * import-driver.js —— 导入阶段:插件驱动、逐轮反推
3
+ *
4
+ * 和写作阶段同一套骨架:**插件当司机,LLM 只按指令写文件;脚本当裁判。**
5
+ *
6
+ * 导入和正向四阶段的差别在因果方向:
7
+ * 正向:先定 quota → 照配额造实体 → 排章节 → 写正文
8
+ * 反向:旧稿已经在那里 → 反推出实体 → **反推出 quota** → 报告还缺什么
9
+ *
10
+ * 所以配额这一环必须由脚本算,不能让模型填:
11
+ * 模型的"估计"和校验器的口径对不上,导入完就会校验失败。
12
+ *
13
+ * 本模块不直接依赖 dsh 运行时:sendPrompt / waitIdle / validateFile 都由
14
+ * 调用方注入,因此可以在没有 LLM 的情况下用测试夹具驱动。
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import { makeWaitForTurn } from '#story-builder/writing-driver.js';
20
+ import { countChapterChars } from '#story-builder/scripts/content-quality.js';
21
+ import { chapterFileName } from './import-core.js';
22
+
23
+ /** 单轮最大尝试次数(含首次) */
24
+ export const IMPORT_MAX_ATTEMPTS = 3;
25
+
26
+ /** 注入正文的材料上限(每章取前 N 字,避免上下文爆掉) */
27
+ export const IMPORT_EXCERPT_CHARS = 800;
28
+
29
+ /**
30
+ * 导入要反推的六轮产物。
31
+ *
32
+ * 2026-10-27 修正:注释和报告文案一直写"四轮",实际是六轮
33
+ * (input / entities / relations / flesh / rhythm / ledger)——
34
+ * 用户照着报告数轮次对不上,以为自己漏了流程。
35
+ *
36
+ * 文件名与模板名**必须和 story-builder 的校验器一致** ——
37
+ * 校验器是按固定文件名找产物的,名字对不上就是"文件不存在"。
38
+ */
39
+ export const IMPORT_ROUNDS = [
40
+ {
41
+ // 输入快照必须**先**出来:书名 / 题材 / 核心冲突 / 硬约束
42
+ // 脚本算不出来(那是书的"自我认知",只能从旧稿里读),
43
+ // 而框架.json 的 input_snapshot 是必填项,空值会被 REQUIRED 拦下。
44
+ id: 'input',
45
+ stage: '输入快照',
46
+ dir: '导入资料',
47
+ files: [
48
+ { name: '输入快照.json', tpl: null, type: '输入快照' }
49
+ ],
50
+ validator: null,
51
+ templateDir: null
52
+ },
53
+ {
54
+ // 实体先写:配额是**从实体条数反推**出来的,
55
+ // 所以必须先有实体,脚本才能算出配额。
56
+ id: 'entities',
57
+ stage: '骨架·实体',
58
+ dir: '骨架',
59
+ files: [
60
+ { name: '实体.json', tpl: '实体模板.json', type: '实体' }
61
+ ],
62
+ validator: 'skeleton-validate.js',
63
+ templateDir: '骨架'
64
+ },
65
+ {
66
+ // 关系与层级要照着配额写(关系条数由配额决定),所以排在实体之后。
67
+ id: 'relations',
68
+ stage: '骨架·关系层级',
69
+ dir: '骨架',
70
+ files: [
71
+ { name: '关系.json', tpl: '关系模板.json', type: '关系' },
72
+ { name: '层级.json', tpl: '层级模板.json', type: '层级' }
73
+ ],
74
+ validator: 'skeleton-validate.js',
75
+ templateDir: '骨架'
76
+ },
77
+ {
78
+ id: 'flesh',
79
+ stage: '血肉',
80
+ dir: '血肉',
81
+ files: [
82
+ { name: '角色.json', tpl: '角色模板.json', type: '角色' },
83
+ { name: '冲突.json', tpl: '冲突模板.json', type: '冲突' },
84
+ { name: '势力.json', tpl: '势力模板.json', type: '势力' },
85
+ { name: '剧情.json', tpl: '剧情模板.json', type: '剧情' },
86
+ { name: '地点.json', tpl: '地点模板.json', type: '地点' },
87
+ { name: '结局.json', tpl: '结局模板.json', type: '结局' },
88
+ { name: '世界观规则.json', tpl: '世界观规则模板.json', type: '世界观规则' },
89
+ { name: '关键道具.json', tpl: '关键道具模板.json', type: '关键道具' }
90
+ ],
91
+ validator: 'flesh-validate.js',
92
+ templateDir: '血肉'
93
+ },
94
+ {
95
+ id: 'rhythm',
96
+ stage: '节奏',
97
+ dir: '节奏',
98
+ files: [
99
+ { name: '章节规划.json', tpl: '章节规划模板.json', type: '章节规划' },
100
+ { name: '时序映射.json', tpl: '时序映射模板.json', type: '时序映射' },
101
+ { name: '节奏参数.json', tpl: '节奏参数模板.json', type: '节奏参数' },
102
+ { name: '章节细纲.json', tpl: '章节细纲模板.json', type: '章节细纲' }
103
+ ],
104
+ validator: 'rhythm-validate.js',
105
+ templateDir: '节奏'
106
+ },
107
+ {
108
+ id: 'ledger',
109
+ stage: '台账',
110
+ dir: '写作/台账',
111
+ files: [
112
+ { name: '章台账.json', tpl: null, type: '章台账' },
113
+ { name: '人物台账.json', tpl: null, type: '人物台账' },
114
+ { name: '伏笔台账.json', tpl: null, type: '伏笔台账' },
115
+ { name: '时间线台账.json', tpl: null, type: '时间线台账' }
116
+ ],
117
+ validator: null,
118
+ templateDir: null
119
+ }
120
+ ];
121
+
122
+ /**
123
+ * 导入阶段的目录布局。
124
+ * @param {string} projectRoot
125
+ */
126
+ export function importDirs(projectRoot) {
127
+ const src = path.join(projectRoot, '设定', 'raw', 'sources');
128
+ return {
129
+ sources: src,
130
+ writing: path.join(src, '写作'),
131
+ ledger: path.join(src, '写作', '台账'),
132
+ skeleton: path.join(src, '骨架'),
133
+ flesh: path.join(src, '血肉'),
134
+ rhythm: path.join(src, '节奏'),
135
+ material: path.join(src, '导入资料'),
136
+ reports: path.join(projectRoot, '导入报告')
137
+ };
138
+ }
139
+
140
+ /**
141
+ * 挑出要注入提示词的正文摘要。
142
+ *
143
+ * 不把全文塞进提示词:T=800 的旧稿全文进上下文会直接爆掉。
144
+ * 每章取开头一段,够模型认出这是哪一章、什么语气、谁在说话。
145
+ *
146
+ * @param {Array<{ch:number,title:string,body:string}>} chapters
147
+ * @param {number} [maxChapters]
148
+ * @param {number} [charsPerChapter]
149
+ * @returns {Array<{ch:number,title:string,excerpt:string}>}
150
+ */
151
+ export function pickExcerpts(chapters, maxChapters = 12, charsPerChapter = IMPORT_EXCERPT_CHARS) {
152
+ const out = [];
153
+ const list = chapters || [];
154
+ // 均匀取样:开头几章 + 结尾几章 + 中间均匀分布
155
+ let picks = [];
156
+ if (list.length <= maxChapters) {
157
+ picks = list.map((c) => c.ch);
158
+ } else {
159
+ const step = (list.length - 1) / (maxChapters - 1);
160
+ for (let i = 0; i < maxChapters; i++) {
161
+ picks.push(list[Math.round(i * step)].ch);
162
+ }
163
+ picks = Array.from(new Set(picks));
164
+ }
165
+ const byCh = new Map(list.map((c) => [c.ch, c]));
166
+ for (const ch of picks) {
167
+ const c = byCh.get(ch);
168
+ if (!c) continue;
169
+ out.push({
170
+ ch: c.ch,
171
+ title: c.title,
172
+ excerpt: String(c.body || '').slice(0, charsPerChapter)
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * 把配额对象摊成「字段 → 数量」的列表(给提示词渲染表格用)。
180
+ *
181
+ * @param {Object} quota
182
+ * @returns {Array<[string, number]>}
183
+ */
184
+ export function flattenQuota(quota) {
185
+ const out = [];
186
+ const walk = (node, prefix) => {
187
+ if (!node || typeof node !== 'object') return;
188
+ for (const k of Object.keys(node)) {
189
+ const v = node[k];
190
+ const path = prefix ? prefix + '.' + k : k;
191
+ if (typeof v === 'number') out.push([path, v]);
192
+ else if (v && typeof v === 'object') walk(v, path);
193
+ }
194
+ };
195
+ walk(quota, '');
196
+ return out;
197
+ }
198
+
199
+ /**
200
+ * 构建一轮的派活提示词。
201
+ *
202
+ * @param {Object} o
203
+ * @param {Object} o.round IMPORT_ROUNDS 的一项
204
+ * @param {number} o.attempt 第几次尝试
205
+ * @param {Array} o.errors 上一轮的校验错误
206
+ * @param {Object} o.context { chapterCount, settingCount, excerpts, dirs }
207
+ * @param {string} [o.contract] 导入执行须知
208
+ * @returns {string}
209
+ */
210
+ export function buildRoundPrompt(o) {
211
+ const round = o.round;
212
+ const ctx = o.context || {};
213
+ const parts = [];
214
+
215
+ if (o.contract) {
216
+ parts.push(o.contract.trim());
217
+ parts.push('');
218
+ parts.push('---');
219
+ parts.push('');
220
+ }
221
+
222
+ parts.push('# 本轮派活:反推' + round.stage + '(第 ' + (o.attempt || 1) + ' 次尝试)');
223
+ parts.push('');
224
+
225
+ parts.push('## 旧稿在哪');
226
+ parts.push('');
227
+ parts.push('- 正文:`设定/raw/sources/写作/`(第001章.md ~ 第' +
228
+ String(ctx.chapterCount || 0).padStart(3, '0') + '章.md,共 ' + (ctx.chapterCount || 0) + ' 章)');
229
+ if (ctx.settingCount) {
230
+ parts.push('- 设定资料:`设定/raw/sources/导入资料/`(' + ctx.settingCount + ' 个文件)');
231
+ } else {
232
+ parts.push('- 设定资料:没有(旧稿只有正文)');
233
+ }
234
+ parts.push('- **先读这些文件再动笔**,不要凭想象填。');
235
+ parts.push('');
236
+
237
+ if (ctx.excerpts && ctx.excerpts.length) {
238
+ parts.push('## 正文开头节选(只做定位用,全文在上面的目录里)');
239
+ parts.push('');
240
+ for (const e of ctx.excerpts) {
241
+ parts.push('### 第 ' + e.ch + ' 章 ' + e.title);
242
+ parts.push('');
243
+ parts.push('```');
244
+ parts.push(e.excerpt);
245
+ parts.push('```');
246
+ parts.push('');
247
+ }
248
+ }
249
+
250
+ if (ctx.quota) {
251
+ parts.push('## 配额(框架.json 由插件生成,照这个写)');
252
+ parts.push('');
253
+ parts.push('| 字段 | 数量 |');
254
+ parts.push('|---|---|');
255
+ for (const line of flattenQuota(ctx.quota)) {
256
+ parts.push('| ' + line[0] + ' | ' + line[1] + ' |');
257
+ }
258
+ parts.push('');
259
+ parts.push('**实体条数必须与配额完全一致**(多一个少一个都会报 `QUOTA_MISMATCH`);' +
260
+ '关系条数按配额里对应的行数写。');
261
+ parts.push('');
262
+ }
263
+
264
+ parts.push('## 本轮要写的文件');
265
+ parts.push('');
266
+ for (const f of round.files) {
267
+ parts.push('- `设定/raw/sources/' + round.dir + '/' + f.name + '`');
268
+ }
269
+ parts.push('');
270
+ parts.push('**只写上面这几个文件。** 其余阶段的文件本轮不要动。');
271
+ parts.push('');
272
+
273
+ if (round.id === 'input') {
274
+ parts.push('## 输入快照的字段');
275
+ parts.push('');
276
+ parts.push('```json');
277
+ parts.push('{');
278
+ parts.push(' "book_name": "旧稿的书名(没有就用第一章标题)",');
279
+ parts.push(' "genre": "题材(如:都市复仇 / 玄幻修真)",');
280
+ parts.push(' "core_summary": "这本书讲什么(一两句,说清主角处境与主线)",');
281
+ parts.push(' "core_conflict": "核心冲突是什么(谁和谁,为了什么,对抗什么)",');
282
+ parts.push(' "hard_constraints": ["旧稿里明确写死的设定(没有就写空数组)"],');
283
+ parts.push(' "anti_tropes": ["旧稿刻意避开的写法(没有就写空数组)"]');
284
+ parts.push('}');
285
+ parts.push('```');
286
+ parts.push('');
287
+ parts.push('**这 6 个字段都要有实际内容**:空值会被骨架校验器以 `REQUIRED` / ' +
288
+ '`TEXT_TOO_SHORT` / `TEXT_PLACEHOLDER` 拦下。');
289
+ parts.push('');
290
+ }
291
+
292
+ if (o.errors && o.errors.length) {
293
+ parts.push('## 上一轮的校验错误(必须改掉)');
294
+ parts.push('');
295
+ for (const e of o.errors) {
296
+ parts.push('- [' + e.code + '] ' + (e.path || '(无路径)') + ':' + e.message +
297
+ (e.hint ? '(' + e.hint + ')' : ''));
298
+ }
299
+ parts.push('');
300
+ parts.push('**只改错误指向的字段**,其余部分不要动。');
301
+ parts.push('');
302
+ }
303
+
304
+ parts.push('## 交活');
305
+ parts.push('');
306
+ parts.push('写完给一段话汇报:本轮写了哪些文件、各写了多少条、哪些内容旧稿里没有(已留空)。');
307
+ parts.push('');
308
+
309
+ return parts.join('\n');
310
+ }
311
+
312
+ /**
313
+ * 判断一个产物文件是不是"至少存在且是合法 JSON"。
314
+ * 台账那轮没有模板可校验,用这条底线兜住。
315
+ *
316
+ * @param {string} p
317
+ * @returns {{ok:boolean, error:string|null}}
318
+ */
319
+ export function checkJsonFile(p) {
320
+ if (!fs.existsSync(p)) return { ok: false, error: '文件没有生成:' + p };
321
+ try {
322
+ JSON.parse(fs.readFileSync(p, 'utf-8'));
323
+ return { ok: true, error: null };
324
+ } catch (e) {
325
+ return { ok: false, error: '不是合法 JSON:' + p + '(' + e.message + ')' };
326
+ }
327
+ }
328
+
329
+ /**
330
+ * 逐轮驱动导入。
331
+ *
332
+ * @param {Object} opts
333
+ * @param {string} opts.projectRoot
334
+ * @param {Array} [opts.rounds]
335
+ * @param {string} [opts.contract]
336
+ * @param {(prompt:string) => Promise<{ok:boolean, error?:string}>} opts.sendPrompt
337
+ * @param {(files:string[]) => Promise<{ok:boolean, reason?:string, code?:string}>} opts.waitIdle
338
+ * @param {(file:string, round:Object) => Promise<{ok:boolean, errors?:Array}>} opts.validateFile
339
+ * @param {(msg:string) => void} [opts.onProgress]
340
+ * @param {(round:Object) => void} [opts.onRound] 一轮结束时的回调(用于生成配额)
341
+ * @param {number} [opts.maxAttempts]
342
+ * @returns {Promise<Object>} 汇总报告
343
+ */
344
+ export async function runImportStage(opts) {
345
+ const dirs = opts.dirs || importDirs(opts.projectRoot);
346
+ const rounds = opts.rounds || IMPORT_ROUNDS;
347
+ const maxAttempts = opts.maxAttempts || IMPORT_MAX_ATTEMPTS;
348
+
349
+ const report = {
350
+ ok: true,
351
+ error: null,
352
+ rounds: [],
353
+ failed: [],
354
+ chapters: 0,
355
+ settings: 0,
356
+ totalWords: 0
357
+ };
358
+
359
+ /** 环境类失败(投递失败/超时/被中断/没有 agent)→ 整条轮次链停掉 */
360
+ let stopAll = false;
361
+
362
+ for (const round of rounds) {
363
+ const targetDir = path.join(dirs.sources, round.dir);
364
+ const expected = round.files.map((f) => path.join(targetDir, f.name));
365
+
366
+ let attempt = 1;
367
+ let errors = [];
368
+ let lastError = null;
369
+
370
+ while (attempt <= maxAttempts) {
371
+ const prompt = buildRoundPrompt({
372
+ round,
373
+ attempt,
374
+ errors,
375
+ contract: opts.contract,
376
+ context: opts.context || {}
377
+ });
378
+
379
+ const sent = await opts.sendPrompt(prompt);
380
+ if (!sent || sent.ok !== true) {
381
+ lastError = (sent && sent.error) || '提示词投递失败';
382
+ report.ok = false;
383
+ report.error = lastError;
384
+ // 投递失败是**环境问题**(会话没了/插件不可用),不是这一轮的内容问题,
385
+ // 继续发下一轮只会继续失败,还会往会话里塞更多发不出去的话。
386
+ stopAll = true;
387
+ break;
388
+ }
389
+
390
+ const waited = await opts.waitIdle(expected);
391
+ if (!waited || waited.ok !== true) {
392
+ lastError = (waited && waited.reason) || '等待 LLM 失败';
393
+ errors = [{ code: 'NO_OUTPUT', path: round.dir, message: lastError, hint: null }];
394
+ // 等待失败**也是失败** —— 旧实现这里既不置 report.ok=false,
395
+ // 也不阻止后面的轮次继续跑,于是"一轮都没等回来"也能报"导入完成"。
396
+ report.ok = false;
397
+ if (!report.error) report.error = lastError;
398
+ // 环境类失败重试没有意义
399
+ const code = (waited && waited.code) || '';
400
+ if (code === 'TIMEOUT' || code === 'ABORTED' || code === 'NO_AGENT') {
401
+ // 而且**不能继续发下一轮**:
402
+ // ABORTED = 用户点了停止,继续塞提示词就是在用户喊停后继续消耗额度;
403
+ // TIMEOUT = 上一轮可能还在跑,下一轮会和它叠在一起;
404
+ // NO_AGENT = 根本没有 agent 在听。
405
+ // 旧实现的 break 只跳出内层 while,外层 for 照跑不误。
406
+ stopAll = true;
407
+ break;
408
+ }
409
+ attempt++;
410
+ continue;
411
+ }
412
+
413
+ // 逐文件校验
414
+ errors = [];
415
+ for (let i = 0; i < round.files.length; i++) {
416
+ const f = round.files[i];
417
+ const p = expected[i];
418
+ if (!opts.validateFile) {
419
+ const chk = checkJsonFile(p);
420
+ if (!chk.ok) errors.push({ code: 'READ', path: p, message: chk.error, hint: null });
421
+ continue;
422
+ }
423
+ const r = await opts.validateFile(p, round, f);
424
+ if (!r || !r.ok) {
425
+ for (const e of (r && r.errors) || [{ code: 'VALIDATE', path: p, message: '校验失败', hint: null }]) {
426
+ errors.push(e);
427
+ }
428
+ }
429
+ }
430
+
431
+ if (errors.length === 0) {
432
+ if (opts.onProgress) opts.onProgress(round.stage + ' 反推完成(' + round.files.length + ' 个文件)。');
433
+ break;
434
+ }
435
+ if (attempt >= maxAttempts) {
436
+ lastError = '校验未通过(' + errors.length + ' 个错误)';
437
+ break;
438
+ }
439
+ attempt++;
440
+ }
441
+
442
+ const r = {
443
+ id: round.id,
444
+ stage: round.stage,
445
+ files: round.files.map((f) => f.name),
446
+ ok: errors.length === 0 && !lastError,
447
+ errors,
448
+ error: lastError,
449
+ attempts: attempt
450
+ };
451
+ report.rounds.push(r);
452
+ if (!r.ok) {
453
+ report.failed.push(round.stage);
454
+ // 某一轮没过,整体就不算成功 —— report.ok 的语义是"这次导入成了吗"
455
+ report.ok = false;
456
+ if (opts.onProgress) opts.onProgress(round.stage + ' 反推未通过:' + (lastError || errors.length + ' 个错误'));
457
+ }
458
+ if (opts.onRound) opts.onRound(r);
459
+ if (stopAll) break;
460
+ }
461
+
462
+ return report;
463
+ }
464
+
465
+ /**
466
+ * 写导入报告(含待补清单)。
467
+ *
468
+ * 待补清单 = 校验没过的地方 + 反推不出来的地方。
469
+ * 导入的价值一半在"把旧稿变成结构化产物",另一半在
470
+ * **明确告诉用户还差什么** —— 不说清楚,用户会以为导入完了。
471
+ *
472
+ * @param {Object} o
473
+ * @param {Object} o.scan scanSource() 的结果
474
+ * @param {Object} o.report runImportStage() 的结果
475
+ * @param {Array} [o.quotaDecisions] reverseQuota() 的决定
476
+ * @param {Array} [o.gaps] 额外列出的待补项
477
+ * @returns {string} markdown
478
+ */
479
+ export function renderImportReport(o) {
480
+ const scan = o.scan || {};
481
+ const report = o.report || {};
482
+ const lines = [];
483
+
484
+ lines.push('# 导入报告');
485
+ lines.push('');
486
+
487
+ lines.push('## 一、导入了什么');
488
+ lines.push('');
489
+ lines.push('- 来源:`' + (scan.root || '') + '`');
490
+ lines.push('- 正文:' + (scan.stats ? scan.stats.chapterCount : 0) + ' 章,' +
491
+ (scan.stats ? scan.stats.totalWords : 0) + ' 字(平均每章 ' +
492
+ (scan.stats ? scan.stats.avgWords : 0) + ' 字)');
493
+ lines.push('- 设定资料:' + (scan.stats ? scan.stats.settingCount : 0) + ' 个文件');
494
+ lines.push('');
495
+
496
+ if (scan.warnings && scan.warnings.length) {
497
+ lines.push('### 扫描阶段的提醒');
498
+ lines.push('');
499
+ for (const w of scan.warnings) lines.push('- ' + w);
500
+ lines.push('');
501
+ }
502
+
503
+ // 被覆盖的正文:导入会用原始旧稿盖掉已有章节(去味成果会丢),
504
+ // 必须列出来并给出备份位置 —— 不列出来用户不会意识到覆盖发生了。
505
+ if (o.overwritten && o.overwritten.length) {
506
+ lines.push('### 覆盖了已有正文');
507
+ lines.push('');
508
+ lines.push('导入用原始旧稿覆盖了 ' + o.overwritten.length + ' 个已有章节:' +
509
+ o.overwritten.join('、') + '。');
510
+ if (o.backupDir) {
511
+ lines.push('');
512
+ lines.push('覆盖前的原文已备份到:`' + o.backupDir + '`。');
513
+ }
514
+ lines.push('');
515
+ }
516
+
517
+ lines.push('## 二、各轮反推结果');
518
+ lines.push('');
519
+ lines.push('| 轮次 | 产物 | 结果 |');
520
+ lines.push('|---|---|---|');
521
+ for (const r of (report.rounds || [])) {
522
+ lines.push('| ' + r.stage + ' | ' + r.files.join('、') + ' | ' + (r.ok ? '通过' : '未通过') + ' |');
523
+ }
524
+ lines.push('');
525
+
526
+ lines.push('## 三、配额是怎么定出来的');
527
+ lines.push('');
528
+ lines.push('| 字段 | 值 | 依据 | 说明 |');
529
+ lines.push('|---|---|---|---|');
530
+ for (const d of (o.quotaDecisions || [])) {
531
+ const sourceText = { actual: '旧稿实际规模', formula: '按公式补齐', fallback: '兜底' }[d.source] || d.source;
532
+ lines.push('| ' + d.field + ' | ' + d.value + ' | ' + sourceText + ' | ' + d.note + ' |');
533
+ }
534
+ lines.push('');
535
+
536
+ lines.push('## 四、待补清单');
537
+ lines.push('');
538
+ const gaps = [];
539
+ for (const r of (report.rounds || [])) {
540
+ if (r.ok) continue;
541
+ gaps.push('### ' + r.stage + '(' + r.errors.length + ' 个错误)');
542
+ for (const e of r.errors) {
543
+ gaps.push('- [' + e.code + '] ' + (e.path || '(无路径)') + ':' + e.message +
544
+ (e.hint ? '(' + e.hint + ')' : ''));
545
+ }
546
+ gaps.push('');
547
+ }
548
+ for (const g of (o.gaps || [])) {
549
+ gaps.push('- ' + g);
550
+ }
551
+ if (!gaps.length) {
552
+ lines.push('- 无。六轮产物都通过了校验。');
553
+ } else {
554
+ lines.push(...gaps);
555
+ }
556
+ lines.push('');
557
+
558
+ lines.push('## 五、下一步');
559
+ lines.push('');
560
+ lines.push('1. 修掉上面的待补项(可以直接重新跑 `/fancy-import` 覆盖)。');
561
+ lines.push('2. 跑 `/fancy-story-builder writing` 让台账与章节对齐(导入只登记旧稿已有的事实)。');
562
+ lines.push('3. 跑 `/fancy-story-builder deslop` 去掉旧稿里的 AI 味。');
563
+ lines.push('');
564
+
565
+ return lines.join('\n');
566
+ }
567
+
568
+ /**
569
+ * 写导入报告到磁盘。
570
+ * @returns {string} 路径(失败返回空串)
571
+ */
572
+ export function writeImportReport(dirs, content) {
573
+ try {
574
+ fs.mkdirSync(dirs.reports, { recursive: true });
575
+ const p = path.join(dirs.reports, '导入报告.md');
576
+ fs.writeFileSync(p, content, 'utf-8');
577
+ return p;
578
+ } catch {
579
+ return '';
580
+ }
581
+ }