@waterwx/dsh-novel-forge 1.1.8 → 1.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/src/run.ts ADDED
@@ -0,0 +1,330 @@
1
+ /**
2
+ * 生产单(Production Run):批量章节生产的标准执行器。
3
+ * 职责:计划补足 → 逐章生成(完整质量门)→ 被拒分级处理(豁免/修订+验证/待人工)→ 断点续跑。
4
+ * 与路由层解耦:只依赖 engine 导出函数与磁盘状态,自身不碰 HTTP。
5
+ * 串行纪律:单例执行器 + working 锁;每章重新从磁盘加载,写前合并易变字段。
6
+ */
7
+ import { existsSync, mkdirSync, readFileSync, copyFileSync, writeFileSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import type { Context } from '@deepseek-ai/cordis'
10
+ import type { ChapterPlan, NovelConfig, ProjectState, ReviewReport, RunState } from './protocol.ts'
11
+ import {
12
+ loadProject,
13
+ saveProject,
14
+ mergeVolatileFromDisk,
15
+ planChapters,
16
+ generateChapterStream,
17
+ summarizeAndExtractFacts,
18
+ markForeshadowPlanted,
19
+ reviewChapter,
20
+ reviewChapterText,
21
+ authorReviewChapter,
22
+ autoLinkPlotlines,
23
+ readChapterFile,
24
+ rewriteChapterStream,
25
+ chapterFileName,
26
+ } from './engine.ts'
27
+ import { loadBookshelf } from './bookshelf.ts'
28
+
29
+ /** 生产单 checkpoint 文件名(放在书目录下)。 */
30
+ function runStateFile(outputDir: string): string {
31
+ return join(outputDir, 'run-state.json')
32
+ }
33
+
34
+ export class ProductionRunner {
35
+ private state: RunState | null = null
36
+ private working = false
37
+ private pauseRequested = false
38
+ private stopRequested = false
39
+ /** 生产单绑定目录(start 时快照;所有读写固定用它,防止运行中切书导致写错目录)。 */
40
+ private bookDir: string | null = null
41
+
42
+ constructor(private deps: { ctx: Context; getConfig: () => NovelConfig }) {}
43
+
44
+ /** 当前生产单状态(内存优先;web 重启后从磁盘恢复)。 */
45
+ status(): RunState | null {
46
+ if (this.state !== null) return this.state
47
+ // 重启恢复:先找当前激活书目录,再扫书架所有书目录(防切书后找不到)。
48
+ const candidates: string[] = [this.deps.getConfig().outputDir]
49
+ const bookshelf = loadBookshelf()
50
+ for (const b of bookshelf.books) if (!candidates.includes(b.outputDir)) candidates.push(b.outputDir)
51
+ for (const outputDir of candidates) {
52
+ const file = runStateFile(outputDir)
53
+ if (!existsSync(file)) continue
54
+ try {
55
+ const raw = readFileSync(file, 'utf8')
56
+ const parsed = JSON.parse(raw) as RunState
57
+ if (parsed?.runId === undefined) continue
58
+ // 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
59
+ if (parsed.status === 'running') parsed.status = 'paused'
60
+ this.state = parsed
61
+ this.bookDir = outputDir
62
+ return this.state
63
+ } catch { /* 该目录 run-state 损坏则跳过 */ }
64
+ }
65
+ return null
66
+ }
67
+
68
+ private persist(): void {
69
+ if (this.state === null) return
70
+ const outputDir = this.bookDir ?? this.deps.getConfig().outputDir
71
+ mkdirSync(outputDir, { recursive: true })
72
+ writeFileSync(runStateFile(outputDir), JSON.stringify(this.state, null, 2), 'utf8')
73
+ }
74
+
75
+ private log(text: string): void {
76
+ if (this.state === null) return
77
+ this.state.log.push({ at: new Date().toISOString(), text })
78
+ if (this.state.log.length > 300) this.state.log = this.state.log.slice(-300)
79
+ this.state.updatedAt = new Date().toISOString()
80
+ }
81
+
82
+ /** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
83
+ async start(startNo: number, endNo: number): Promise<RunState> {
84
+ const config = this.deps.getConfig()
85
+ if (this.working) throw new Error('生产单正在运行中,请先暂停或停止')
86
+ let project = loadProject(config.outputDir)
87
+ if (project === undefined) throw new Error('输出目录中没有项目')
88
+
89
+ // 计划补足:目标区间超出已有计划 → 续写规划追加。
90
+ if (endNo > project.chapters.length) {
91
+ const need = endNo - project.chapters.length
92
+ this.log(`计划不足,追加 ${need} 章计划…`)
93
+ const chapters = await planChapters(this.deps.ctx, config, project, need, undefined, config.outputDir)
94
+ mergeVolatileFromDisk(config.outputDir, project)
95
+ project.chapters.push(...chapters)
96
+ project.updatedAt = new Date().toISOString()
97
+ saveProject(config.outputDir, project)
98
+ this.log(`计划已追加,全书 ${project.chapters.length} 章`)
99
+ }
100
+
101
+ this.state = {
102
+ runId: `run-${Date.now().toString(36)}`,
103
+ startNo,
104
+ endNo,
105
+ status: 'running',
106
+ currentNo: startNo,
107
+ stats: { generated: 0, revised: 0, exempted: 0, regenerated: 0, error: 0 },
108
+ pendingManual: [],
109
+ log: [],
110
+ startedAt: new Date().toISOString(),
111
+ updatedAt: new Date().toISOString(),
112
+ }
113
+ this.pauseRequested = false
114
+ this.stopRequested = false
115
+ // 绑定生产单目录(快照):运行期间切书不影响本单的读写目标。
116
+ this.bookDir = config.outputDir
117
+ this.working = true
118
+ this.persist()
119
+ void this.loop()
120
+ return this.state
121
+ }
122
+
123
+ pause(): void { this.pauseRequested = true }
124
+ resume(): void {
125
+ if (this.state === null || this.state.status !== 'paused') return
126
+ this.state.status = 'running'
127
+ this.working = true
128
+ this.pauseRequested = false
129
+ this.stopRequested = false
130
+ this.persist()
131
+ void this.loop()
132
+ }
133
+ stop(): void { this.stopRequested = true }
134
+
135
+ /** 主循环:从 currentNo 扫描到 endNo,逐章处理;支持暂停/停止。 */
136
+ private async loop(): Promise<void> {
137
+ // 全部读写固定使用生产单绑定目录(bookDir),绝不跟随当前激活书。
138
+ const config = this.deps.getConfig()
139
+ const outputDir = this.bookDir ?? config.outputDir
140
+ try {
141
+ while (this.state !== null) {
142
+ if (this.stopRequested) {
143
+ this.stopRequested = false
144
+ this.state.status = 'stopped'
145
+ this.log('生产单已停止')
146
+ this.persist()
147
+ break
148
+ }
149
+ if (this.pauseRequested) {
150
+ this.pauseRequested = false
151
+ this.state.status = 'paused'
152
+ this.log('生产单已暂停(随时可继续)')
153
+ this.persist()
154
+ break
155
+ }
156
+ const project = loadProject(outputDir)
157
+ if (project === undefined) {
158
+ this.state.status = 'error'
159
+ this.state.error = '项目丢失'
160
+ this.persist()
161
+ break
162
+ }
163
+ // 找下一个需要处理的章(approved 快进)。
164
+ let next: ChapterPlan | undefined
165
+ for (let no = this.state.currentNo; no <= this.state.endNo; no++) {
166
+ const ch = project.chapters.find(c => c.no === no)
167
+ if (ch === undefined) continue
168
+ if (ch.status === 'approved') { this.state.currentNo = no; continue }
169
+ next = ch
170
+ break
171
+ }
172
+ if (next === undefined) {
173
+ this.state.status = 'done'
174
+ this.log(`生产单完成:${this.state.startNo}-${this.state.endNo} 章处理完毕`)
175
+ this.persist()
176
+ break
177
+ }
178
+ this.state.currentNo = next.no
179
+ await this.processChapter(project, next)
180
+ this.persist()
181
+ }
182
+ } catch (error) {
183
+ if (this.state !== null) {
184
+ this.state.status = 'error'
185
+ this.state.error = (error as Error).message
186
+ this.log(`生产单异常:${(error as Error).message}`)
187
+ this.persist()
188
+ }
189
+ } finally {
190
+ this.working = false
191
+ }
192
+ }
193
+
194
+ private async processChapter(project: ProjectState, chapter: ChapterPlan): Promise<void> {
195
+ if (chapter.status === 'pending' || chapter.status === 'error' || chapter.status === 'written') {
196
+ await this.produce(project, chapter)
197
+ } else if (chapter.status === 'rejected') {
198
+ await this.handleRejected(project, chapter)
199
+ } else if (chapter.status === 'generating') {
200
+ // 中断残留:复位重生成。
201
+ chapter.status = 'pending'
202
+ await this.produce(project, chapter)
203
+ }
204
+ }
205
+
206
+ /** 完整质量门:生成 → 摘要+事实 → 伏笔标记 → 审稿 → 作者复盘。 */
207
+ private async produce(project: ProjectState, chapter: ChapterPlan): Promise<void> {
208
+ const { ctx, getConfig } = this.deps
209
+ const config = getConfig()
210
+ const outputDir = this.bookDir ?? config.outputDir
211
+ const no = chapter.no
212
+ const wasError = chapter.status === 'error'
213
+ chapter.status = 'generating'
214
+ chapter.error = undefined
215
+ chapter.generatingAt = new Date().toISOString()
216
+ mergeVolatileFromDisk(outputDir, project)
217
+ saveProject(outputDir, project)
218
+ this.log(wasError ? `第${no}章《${chapter.title}》重新生成…` : `第${no}章《${chapter.title}》生成…`)
219
+ try {
220
+ for await (const step of generateChapterStream(ctx, config, project, outputDir, no)) { /* drain */ }
221
+ try { await summarizeAndExtractFacts(ctx, config, project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run summary/facts:', (e as Error).message) }
222
+ try { markForeshadowPlanted(project, outputDir, no) } catch (e) { console.warn('[dsh-novel-forge] run foreshadow:', (e as Error).message) }
223
+ if (config.autoReview ?? true) {
224
+ const report = await reviewChapter(ctx, config, project, outputDir, no)
225
+ if (this.state !== null) this.state.stats[wasError ? 'regenerated' : 'generated']++
226
+ this.log(`第${no}章 审稿 ${report.score}分 ${report.passed ? '✅ 通过' : '⚠️ 被拒'}`)
227
+ } else {
228
+ chapter.status = 'approved'
229
+ mergeVolatileFromDisk(outputDir, project)
230
+ saveProject(outputDir, project)
231
+ }
232
+ if (config.autoAuthorReview ?? true) {
233
+ try {
234
+ const body = readChapterFile(outputDir, chapter)
235
+ if (body !== undefined) {
236
+ let prevTail = ''
237
+ if (no > 1) {
238
+ const prev = project.chapters.find(c => c.no === no - 1)
239
+ if (prev !== undefined) prevTail = (readChapterFile(outputDir, prev) ?? '').replace(/^#.*$/m, '').trim().slice(-600)
240
+ }
241
+ const review = await authorReviewChapter(ctx, config, project, no, body, prevTail)
242
+ chapter.authorReview = review
243
+ if (review.advancedLines !== undefined) autoLinkPlotlines(project, no, review.advancedLines)
244
+ mergeVolatileFromDisk(outputDir, project)
245
+ saveProject(outputDir, project)
246
+ }
247
+ } catch (e) { console.warn('[dsh-novel-forge] run author review:', (e as Error).message) }
248
+ }
249
+ } catch (error) {
250
+ chapter.status = 'error'
251
+ chapter.error = (error as Error).message
252
+ mergeVolatileFromDisk(outputDir, project)
253
+ saveProject(outputDir, project)
254
+ if (this.state !== null) this.state.stats.error++
255
+ this.log(`第${no}章 失败:${(error as Error).message}`)
256
+ }
257
+ }
258
+
259
+ /** 被拒分级处理:无 high 豁免;有 high 按意见修订(最多 2 轮)+ 验证模式;仍不过 → 待人工。 */
260
+ private async handleRejected(project: ProjectState, chapter: ChapterPlan): Promise<void> {
261
+ const { ctx, getConfig } = this.deps
262
+ const config = getConfig()
263
+ const outputDir = this.bookDir ?? config.outputDir
264
+ const no = chapter.no
265
+ const report = chapter.review
266
+ const highs = (report?.issues ?? []).filter(i => i.severity === 'high')
267
+ if (highs.length === 0) {
268
+ chapter.status = 'approved'
269
+ project.updatedAt = new Date().toISOString()
270
+ saveProject(outputDir, project)
271
+ if (this.state !== null) this.state.stats.exempted++
272
+ this.log(`第${no}章 豁免通过(无 high)`)
273
+ return
274
+ }
275
+ this.log(`第${no}章 修订(${highs.length} 个 high)…`)
276
+ for (let round = 1; round <= 2; round++) {
277
+ const instr = '按审稿意见修订(优先处理):\n' + highs.map(h => `[${h.severity}] ${h.item}${h.suggestion !== '' ? `\n建议:${h.suggestion}` : ''}`).join('\n\n')
278
+ try {
279
+ for await (const step of rewriteChapterStream(ctx, config, project, outputDir, no, instr, undefined)) { /* drain */ }
280
+ } catch (error) {
281
+ this.log(`第${no}章 第${round}轮修订出错:${(error as Error).message}`)
282
+ continue
283
+ }
284
+ // rewrite 已写盘(pendingDraft + saveProject),重载拿最新草稿。
285
+ const fresh = loadProject(outputDir)
286
+ const freshCh = fresh?.chapters.find(c => c.no === no)
287
+ const draft = freshCh?.pendingDraft
288
+ if (draft === undefined || draft.length < 50) {
289
+ this.log(`第${no}章 第${round}轮草稿缺失,重试`)
290
+ continue
291
+ }
292
+ const verify = await reviewChapterText(ctx, config, fresh!, draft, chapter.review)
293
+ const highs2 = (verify.issues ?? []).filter(i => i.severity === 'high')
294
+ if (verify.passed || highs2.length === 0) {
295
+ // 应用草稿(与 draft/apply 路由同逻辑):备份原稿 → 落盘 → 定状态。
296
+ this.applyDraft(fresh!, freshCh!, draft, verify)
297
+ if (this.state !== null) this.state.stats.revised++
298
+ this.log(`第${no}章 第${round}轮修订通过(${verify.score}分)`)
299
+ return
300
+ }
301
+ this.log(`第${no}章 第${round}轮仍不过(${highs2.length} high)`)
302
+ }
303
+ if (this.state !== null) this.state.pendingManual.push(no)
304
+ this.log(`第${no}章 ⚠️ 两轮修订仍不过 → 保留草稿待人工`)
305
+ }
306
+
307
+ private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
308
+ const config = this.deps.getConfig()
309
+ const fileName = chapterFileName(chapter)
310
+ mkdirSync(config.outputDir, { recursive: true })
311
+ const targetPath = join(config.outputDir, fileName)
312
+ if (existsSync(targetPath)) {
313
+ copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
314
+ }
315
+ writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
316
+ chapter.pendingDraft = undefined
317
+ chapter.chars = draft.length
318
+ chapter.file = fileName
319
+ if (typeof report.score === 'number') {
320
+ chapter.review = report
321
+ chapter.status = report.passed === true ? 'approved' : 'rejected'
322
+ } else {
323
+ chapter.status = 'written'
324
+ chapter.review = undefined
325
+ }
326
+ chapter.error = undefined
327
+ project.updatedAt = new Date().toISOString()
328
+ saveProject(config.outputDir, project)
329
+ }
330
+ }