@waterwx/dsh-novel-forge 1.3.1 → 1.3.2-alpha

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 (161) hide show
  1. package/README.md +288 -279
  2. package/lib/client.js +56309 -12221
  3. package/lib/index.js +14239 -5595
  4. package/lib/types/action-guard.d.ts +61 -0
  5. package/lib/types/ai-scan.d.ts +36 -0
  6. package/lib/types/assets.d.ts +24 -9
  7. package/lib/types/assistant.d.ts +29 -0
  8. package/lib/types/author-assets.d.ts +25 -0
  9. package/lib/types/bookshelf.d.ts +11 -1
  10. package/lib/types/client/api.d.ts +186 -10
  11. package/lib/types/client/chip-prefs.d.ts +36 -0
  12. package/lib/types/client/docx.d.ts +2 -0
  13. package/lib/types/client/index.d.ts +1 -1
  14. package/lib/types/client/llmLive.d.ts +31 -0
  15. package/lib/types/client/locales.d.ts +28 -67
  16. package/lib/types/client/panel/AdaptModeView.d.ts +5 -0
  17. package/lib/types/client/panel/AiTag.d.ts +1 -0
  18. package/lib/types/client/panel/AssistantTab.d.ts +4 -1
  19. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  20. package/lib/types/client/panel/AuthorHome.d.ts +34 -0
  21. package/lib/types/client/panel/BookAnalysisView.d.ts +4 -0
  22. package/lib/types/client/panel/CmdPalette.d.ts +12 -0
  23. package/lib/types/client/panel/CreateBookView.d.ts +5 -1
  24. package/lib/types/client/panel/DirectorView.d.ts +7 -0
  25. package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +4 -0
  26. package/lib/types/client/panel/IdeaInspirationView.d.ts +6 -0
  27. package/lib/types/client/panel/ImportModal.d.ts +7 -0
  28. package/lib/types/client/panel/KnowledgeBaseView.d.ts +4 -0
  29. package/lib/types/client/panel/LiveFeedLog.d.ts +13 -0
  30. package/lib/types/client/panel/MarketRadarView.d.ts +5 -0
  31. package/lib/types/client/panel/ModelManager.d.ts +14 -0
  32. package/lib/types/client/panel/NovelPanel.d.ts +6 -1
  33. package/lib/types/client/panel/PageHeader.d.ts +18 -0
  34. package/lib/types/client/panel/ProgressConsole.d.ts +17 -0
  35. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  36. package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
  37. package/lib/types/client/panel/ReasoningSection.d.ts +10 -0
  38. package/lib/types/client/panel/SettingsView.d.ts +19 -0
  39. package/lib/types/client/panel/ShelfView.d.ts +12 -1
  40. package/lib/types/client/panel/SubPage.d.ts +27 -0
  41. package/lib/types/client/panel/TensionView.d.ts +18 -0
  42. package/lib/types/client/panel/TimelineView.d.ts +18 -0
  43. package/lib/types/client/panel/helpers.d.ts +5 -3
  44. package/lib/types/client/panel/toast.d.ts +2 -0
  45. package/lib/types/client/panel/views.d.ts +25 -7
  46. package/lib/types/client/run-status.d.ts +21 -0
  47. package/lib/types/client/text.d.ts +9 -0
  48. package/lib/types/docx-core.d.ts +16 -0
  49. package/lib/types/docx.d.ts +2 -3
  50. package/lib/types/engine.d.ts +186 -45
  51. package/lib/types/global-assets.d.ts +6 -0
  52. package/lib/types/index.d.ts +22 -8
  53. package/lib/types/llm-live.d.ts +75 -0
  54. package/lib/types/llm-retry.d.ts +38 -0
  55. package/lib/types/market-radar-scan.d.ts +19 -0
  56. package/lib/types/market-radar-sources.d.ts +31 -0
  57. package/lib/types/migrate-output.d.ts +25 -0
  58. package/lib/types/novel-context.d.ts +80 -0
  59. package/lib/types/prompt-slots.d.ts +57 -0
  60. package/lib/types/prompt-utils.d.ts +35 -0
  61. package/lib/types/prompting.d.ts +10 -0
  62. package/lib/types/protocol.d.ts +1362 -166
  63. package/lib/types/review-policy.d.ts +97 -0
  64. package/lib/types/revision.d.ts +76 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/run.d.ts +23 -1
  67. package/lib/types/snapshots.d.ts +46 -0
  68. package/lib/types/stage-contract.d.ts +60 -0
  69. package/lib/types/tension.d.ts +53 -0
  70. package/lib/types/timeline.d.ts +37 -0
  71. package/package.json +52 -28
  72. package/src/action-guard.ts +127 -0
  73. package/src/ai-scan.ts +179 -0
  74. package/src/assets.ts +1806 -520
  75. package/src/assistant.ts +1098 -770
  76. package/src/author-assets.ts +149 -0
  77. package/src/bookshelf.ts +187 -151
  78. package/src/client/api.ts +378 -21
  79. package/src/client/chip-prefs.ts +121 -0
  80. package/src/client/docx.ts +37 -69
  81. package/src/client/index.ts +3 -1
  82. package/src/client/llmLive.ts +111 -0
  83. package/src/client/locales.ts +28 -253
  84. package/src/client/mount.tsx +1 -1
  85. package/src/client/panel/AdaptModeView.tsx +535 -0
  86. package/src/client/panel/AiTag.tsx +7 -0
  87. package/src/client/panel/AssetsTab.tsx +448 -458
  88. package/src/client/panel/AssistantTab.tsx +355 -301
  89. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  90. package/src/client/panel/AuthorHome.tsx +309 -0
  91. package/src/client/panel/BookAnalysisView.tsx +63 -0
  92. package/src/client/panel/CmdPalette.tsx +102 -0
  93. package/src/client/panel/CreateBookView.tsx +366 -348
  94. package/src/client/panel/DirectorView.tsx +200 -0
  95. package/src/client/panel/GlobalAssetLibraryView.tsx +168 -0
  96. package/src/client/panel/IdeaInspirationView.tsx +76 -0
  97. package/src/client/panel/ImportModal.tsx +303 -0
  98. package/src/client/panel/KnowledgeBaseView.tsx +125 -0
  99. package/src/client/panel/LiveFeedLog.tsx +204 -0
  100. package/src/client/panel/MarketRadarView.tsx +377 -0
  101. package/src/client/panel/ModelManager.tsx +205 -0
  102. package/src/client/panel/NovelPanel.tsx +3135 -2651
  103. package/src/client/panel/PageHeader.tsx +37 -0
  104. package/src/client/panel/ProgressConsole.tsx +63 -0
  105. package/src/client/panel/PromptSlotsView.tsx +97 -0
  106. package/src/client/panel/ReaderView.tsx +306 -306
  107. package/src/client/panel/ReasoningSection.tsx +31 -0
  108. package/src/client/panel/RunPanel.tsx +64 -32
  109. package/src/client/panel/SettingsView.tsx +354 -0
  110. package/src/client/panel/ShelfView.tsx +266 -236
  111. package/src/client/panel/SubPage.tsx +42 -0
  112. package/src/client/panel/TensionView.tsx +212 -0
  113. package/src/client/panel/TimelineView.tsx +208 -0
  114. package/src/client/panel/WorldTab.tsx +202 -202
  115. package/src/client/panel/helpers.ts +7 -5
  116. package/src/client/panel/panel.module.css +4481 -1655
  117. package/src/client/panel/toast.ts +51 -0
  118. package/src/client/panel/views.tsx +401 -314
  119. package/src/client/run-status.ts +171 -0
  120. package/src/client/sidebar-entry.ts +1 -1
  121. package/src/client/text.ts +28 -0
  122. package/src/docx-core.ts +53 -0
  123. package/src/docx.ts +44 -83
  124. package/src/engine.ts +4672 -2943
  125. package/src/global-assets.ts +59 -0
  126. package/src/index.ts +89 -33
  127. package/src/llm-live.ts +260 -0
  128. package/src/llm-retry.ts +77 -0
  129. package/src/market-radar-scan.ts +41 -0
  130. package/src/market-radar-sources.ts +173 -0
  131. package/src/migrate-output.ts +76 -0
  132. package/src/novel-context.ts +286 -0
  133. package/src/prompt-slots.ts +125 -0
  134. package/src/prompting.ts +26 -0
  135. package/src/protocol.ts +1388 -158
  136. package/src/review-policy.ts +161 -0
  137. package/src/revision.ts +283 -0
  138. package/src/routes.ts +1934 -434
  139. package/src/run.ts +118 -21
  140. package/src/snapshots.ts +174 -0
  141. package/src/stage-contract.ts +189 -0
  142. package/src/tension.ts +251 -0
  143. package/src/timeline.ts +159 -0
  144. package/lib/client.js.map +0 -1
  145. package/lib/index.js.map +0 -1
  146. package/lib/types/comic-presets.d.ts +0 -12
  147. package/scripts/assistant-frames.txt +0 -8
  148. package/scripts/diagnose-adapter.mts +0 -81
  149. package/scripts/diagnose-assistant.mjs +0 -58
  150. package/scripts/diagnose-bible.mjs +0 -72
  151. package/scripts/diagnose-harness.mts +0 -75
  152. package/scripts/novel-forge-restart.log +0 -10
  153. package/scripts/novel-forge-web.stderr.log +0 -2
  154. package/scripts/novel-forge-web.stdout.log +0 -1
  155. package/scripts/outline-sample.txt +0 -449
  156. package/scripts/probe.mjs +0 -6
  157. package/scripts/restart-web.ps1 +0 -88
  158. package/scripts/smoke.mts +0 -72
  159. package/scripts/upload-github.ps1 +0 -51
  160. package/src/client/panel/BookshelfBar.tsx +0 -170
  161. package/src/comic-presets.ts +0 -67
@@ -0,0 +1,161 @@
1
+ /**
2
+ * 审稿规则注册表
3
+ *
4
+ * 生成用 compact 模式(核心规则),审稿用 full 模式(全部规则),
5
+ * 消除 reviewSystemPrompt 内嵌规则与 renderAllAssets() 两套打架的问题。
6
+ */
7
+
8
+ /** 通用写作资产规则(兼容 ProjectAssets 中的各类规则) */
9
+ export interface WritingRule {
10
+ name: string
11
+ description: string
12
+ type?: string
13
+ enabled?: boolean
14
+ }
15
+
16
+ export interface ReviewRule {
17
+ id: string
18
+ /** 规则维度 */
19
+ dimension: 'character' | 'setting' | 'redline' | 'writing' | 'pacing' | 'logic' | 'anti-ai' | 'presentation' | 'compliance'
20
+ /** 严重度 */
21
+ severity: 'high' | 'medium' | 'low'
22
+ /** 规则类型:forbidden=禁止类(命中即问题),encourage=鼓励类(不命中不算错) */
23
+ type: 'forbidden' | 'encourage'
24
+ /** 规则描述 */
25
+ description: string
26
+ /** 是否启用 */
27
+ enabled: boolean
28
+ /** 作用域 */
29
+ scope: Array<'writing' | 'review' | 'polish' | 'all'>
30
+ }
31
+
32
+ /** 内置审稿维度(结构化评分用) */
33
+ export const REVIEW_DIMENSIONS = [
34
+ { id: 'character', name: '人设一致性', weight: 0.2 },
35
+ { id: 'setting', name: '设定一致性', weight: 0.15 },
36
+ { id: 'redline', name: '红线检查', weight: 0.15 },
37
+ { id: 'writing', name: '文笔质量', weight: 0.15 },
38
+ { id: 'pacing', name: '节奏与爽点', weight: 0.1 },
39
+ { id: 'logic', name: '逻辑漏洞', weight: 0.1 },
40
+ { id: 'anti-ai', name: '反 AI 规则', weight: 0.05 },
41
+ { id: 'presentation', name: '呈现方式', weight: 0.05 },
42
+ { id: 'compliance', name: '内容合规', weight: 0.05 },
43
+ ] as const
44
+
45
+ export type ReviewDimensionId = typeof REVIEW_DIMENSIONS[number]['id']
46
+
47
+ /** 维度结构化评分 */
48
+ export interface DimensionScore {
49
+ dimension: ReviewDimensionId
50
+ score: number // 0-100
51
+ note: string
52
+ }
53
+
54
+ /**
55
+ * 渲染审稿规则(compact 或 full 模式)
56
+ *
57
+ * compact: 只渲染禁止类 + high 严重度(用于生成时的约束)
58
+ * full: 渲染全部启用规则(用于审稿时的检查清单)
59
+ */
60
+ export function renderReviewRules(
61
+ assets: WritingRule[] | undefined,
62
+ mode: 'compact' | 'full' = 'full',
63
+ ): string {
64
+ const lines: string[] = []
65
+
66
+ // 内置核心规则(所有模式都注入)
67
+ const coreRules = [
68
+ { dimension: 'character', desc: '角色行为符合角色卡设定(性格/目标/知情度/说话方式)' },
69
+ { dimension: 'setting', desc: '金手指规则、战力体系、世界观与道藏一致' },
70
+ { dimension: 'redline', desc: '不触犯本书写作红线' },
71
+ { dimension: 'compliance', desc: '不触犯内容合规红线(最高优先级)' },
72
+ { dimension: 'logic', desc: '无前后矛盾、时间线错误、对话失真' },
73
+ { dimension: 'presentation', desc: '不纯内心推理铺陈;反派有行动力;重要配角有辨识度' },
74
+ ]
75
+
76
+ if (mode === 'compact') {
77
+ lines.push('写作约束(核心规则):')
78
+ for (const r of coreRules) {
79
+ lines.push(`- ${r.desc}`)
80
+ }
81
+ } else {
82
+ lines.push('审稿维度(逐条审查):')
83
+ REVIEW_DIMENSIONS.forEach((d, i) => {
84
+ const core = coreRules.find(c => c.dimension === d.id)
85
+ lines.push(`${i + 1}. ${d.name}:${core?.desc ?? '按维度标准审查'}`)
86
+ })
87
+ lines.push('')
88
+ lines.push('反 AI 规则:逐条核对下方资产规则,命中即列为问题。')
89
+ lines.push('呈现方式:整章是否纯内心推理铺陈(无对话/无对抗);反派是否纯背景板;重要配角是否无名标签化——命中即列为问题。')
90
+ lines.push('内容合规(最高优先级):逐条核对合规红线,任何一条命中(含影射、暗示、详细描写)必须列为 high。')
91
+ }
92
+
93
+ // 注入用户自定义资产规则
94
+ if (assets !== undefined && assets.length > 0) {
95
+ const forbidden = assets.filter(a => a.type === 'forbidden' || a.type === 'rule')
96
+ const encourage = assets.filter(a => a.type === 'encourage' || a.type === 'style')
97
+ if (forbidden.length > 0) {
98
+ lines.push('')
99
+ lines.push('禁止类规则(命中即问题):')
100
+ for (const a of forbidden) {
101
+ lines.push(`- ${a.name}:${a.description}`)
102
+ }
103
+ }
104
+ if (encourage.length > 0 && mode === 'full') {
105
+ lines.push('')
106
+ lines.push('鼓励类规则(不命中不算错,作为低优先级建议):')
107
+ for (const a of encourage) {
108
+ lines.push(`- ${a.name}:${a.description}`)
109
+ }
110
+ }
111
+ }
112
+
113
+ return lines.join('\n')
114
+ }
115
+
116
+ /**
117
+ * 从审稿 issues 中按维度统计
118
+ * 用于结构化评分和 UI 展示。
119
+ */
120
+ export function groupIssuesByDimension(
121
+ issues: Array<{ severity: string; item: string; dimension?: string }>,
122
+ ): Record<string, Array<{ severity: string; item: string }>> {
123
+ const result: Record<string, Array<{ severity: string; item: string }>> = {}
124
+ for (const issue of issues) {
125
+ const dim = issue.dimension ?? inferDimension(issue.item)
126
+ if (result[dim] === undefined) result[dim] = []
127
+ result[dim]!.push({ severity: issue.severity, item: issue.item })
128
+ }
129
+ return result
130
+ }
131
+
132
+ /** 根据问题描述推断维度(兼容旧报告,没有 dimension 字段时用) */
133
+ function inferDimension(item: string): ReviewDimensionId {
134
+ if (/人设|角色|性格|口吻|知情|OOC/.test(item)) return 'character'
135
+ if (/设定|世界观|战力|金手指|规则|道藏/.test(item)) return 'setting'
136
+ if (/红线|后宫|擦边|碾压|圣母/.test(item)) return 'redline'
137
+ if (/语病|翻译腔|套话|文笔|流水账|AI/.test(item)) return 'writing'
138
+ if (/节奏|拖沓|灌水|爽点|钩子|悬念/.test(item)) return 'pacing'
139
+ if (/矛盾|时间线|逻辑|对话失真/.test(item)) return 'logic'
140
+ if (/合规|敏感|色情|暴力|政治|未成年/.test(item)) return 'compliance'
141
+ if (/内心推理|背景板|标签化|瘦高个|灰衣人/.test(item)) return 'presentation'
142
+ return 'writing'
143
+ }
144
+
145
+ /**
146
+ * 计算维度加权总分
147
+ * 用于审稿报告的结构化评分。
148
+ */
149
+ export function calculateWeightedScore(
150
+ dimensions: DimensionScore[],
151
+ ): number {
152
+ let total = 0
153
+ let weightSum = 0
154
+ for (const d of dimensions) {
155
+ const def = REVIEW_DIMENSIONS.find(x => x.id === d.dimension)
156
+ const weight = def?.weight ?? 0.1
157
+ total += d.score * weight
158
+ weightSum += weight
159
+ }
160
+ return weightSum > 0 ? Math.round(total / weightSum) : 60
161
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * 修订指令合并层(arbitration)。
3
+ *
4
+ * 问题:审稿、时间线、张力三条通道各自给建议,如果各自改一轮,会出现「改完 A 又违反 B」
5
+ * 的互相打脸:同一章可能被改三遍,最后一轮的改动把第一轮的修正覆盖掉。
6
+ *
7
+ * 规则(与面板/生产单共享同一份实现,避免两头各写一套):
8
+ * 1. 同一章只交付**一份**修订指令(本模块负责汇总,调用方不得自行拼装)。
9
+ * 2. 固定优先级,不做权重打分(可解释、可复现):
10
+ * 0 审稿 high(必改,是硬门)
11
+ * 1 时间线 high(硬矛盾:时间倒流、地点瞬移)
12
+ * 2 张力 medium(曲线偏差,建议性最强)
13
+ * 3 其余(审稿 medium / 时间线 medium)
14
+ * 4 低优先(审稿 low)
15
+ * 3. 一次修订最多吃 REVISION_ITEM_CAP 条,超出按优先级截断;截断数量显式回传,不静默丢弃。
16
+ * 4. 每章修订轮次上限 MAX_REVISION_ROUNDS,超出转人工待办。
17
+ * 5. 验证基准 = 本轮实际下发的全部条目(审稿 + 时间线 + 张力),
18
+ * 这样「复核」核对的就是「我们要求改的东西」,而不是只核对审稿那部分。
19
+ */
20
+
21
+ import type {
22
+ ProjectState,
23
+ ReviewIssue,
24
+ ReviewReport,
25
+ RevisionItem,
26
+ RevisionSource,
27
+ Severity,
28
+ TimelineIssue,
29
+ TensionIssue,
30
+ } from './protocol.js'
31
+ import { detectTimelineIssues } from './timeline.js'
32
+ import { detectTensionIssues } from './tension.js'
33
+
34
+ /** 每章修订轮次上限:两轮仍不过即转人工,不做「无限自动修改」。 */
35
+ export const MAX_REVISION_ROUNDS = 2
36
+ /** 单次修订指令最多携带的条目数(防止指令过长把正文挤出上下文)。 */
37
+ export const REVISION_ITEM_CAP = 8
38
+
39
+ export const REVISION_SOURCE_LABELS: Record<RevisionSource, string> = {
40
+ review: '审稿',
41
+ timeline: '时间线',
42
+ tension: '张力',
43
+ }
44
+
45
+ /** 固定优先级:数值越小越先改。 */
46
+ export function revisionPriority(source: RevisionSource, severity: Severity): number {
47
+ if (source === 'review') return severity === 'high' ? 0 : severity === 'medium' ? 3 : 4
48
+ if (source === 'timeline') return severity === 'high' ? 1 : 3
49
+ // 张力只产出 medium(曲线偏差是建议,不是违规)。
50
+ return severity === 'high' ? 2 : 2
51
+ }
52
+
53
+ /** 同优先级的来源次序:审稿 > 时间线 > 张力(同级时先改让本章"判不过"的那一类)。 */
54
+ const SOURCE_RANK: Record<RevisionSource, number> = { review: 0, timeline: 1, tension: 2 }
55
+
56
+ /**
57
+ * 条目在**来源内部**的原始序号(id 末段)。
58
+ * 必须按数值比较:字符串比较会得到 0,1,10,11,2… 的顺序,
59
+ * 于是截断时先丢「第 3 条意见」却留下「第 11 条」——等于把靠前的意见丢了。
60
+ */
61
+ function sequenceOf(item: RevisionItem): number {
62
+ const tail = item.id.slice(item.id.lastIndexOf(':') + 1)
63
+ const n = Number.parseInt(tail, 10)
64
+ return Number.isFinite(n) ? n : 0
65
+ }
66
+
67
+ /** 去重键:同源 + 归一化问题描述(忽略空白与标点差异)。 */
68
+ function dedupeKey(item: RevisionItem): string {
69
+ return `${item.source}|${item.item.replace(/[\s,。、;:,.!?!?"'"'()()【】\[\]]/g, '').slice(0, 60)}`
70
+ }
71
+
72
+ /** 排序 + 去重 + 截断。去重时保留优先级更高的那条,并累加 count。 */
73
+ export function mergeRevisionItems(
74
+ items: RevisionItem[],
75
+ cap = REVISION_ITEM_CAP,
76
+ ): { items: RevisionItem[]; omitted: number } {
77
+ const sorted = [...items].sort((a, b) => {
78
+ const byPriority = revisionPriority(a.source, a.severity) - revisionPriority(b.source, b.severity)
79
+ if (byPriority !== 0) return byPriority
80
+ if (a.chapterNo !== b.chapterNo) return a.chapterNo - b.chapterNo
81
+ if (a.source !== b.source) return SOURCE_RANK[a.source] - SOURCE_RANK[b.source]
82
+ const bySequence = sequenceOf(a) - sequenceOf(b)
83
+ if (bySequence !== 0) return bySequence
84
+ return a.id.localeCompare(b.id)
85
+ })
86
+ const seen = new Map<string, RevisionItem>()
87
+ const unique: RevisionItem[] = []
88
+ for (const item of sorted) {
89
+ const key = dedupeKey(item)
90
+ const dup = seen.get(key)
91
+ if (dup !== undefined) {
92
+ dup.count = (dup.count ?? 1) + 1
93
+ continue
94
+ }
95
+ seen.set(key, item)
96
+ unique.push(item)
97
+ }
98
+ const kept = unique.slice(0, cap)
99
+ return { items: kept, omitted: Math.max(0, unique.length - kept.length) }
100
+ }
101
+
102
+ /** 时间线/张力问题是否落在目标章上(chapters 为空视为全书级,跟随目标章)。 */
103
+ function matchesChapter(chapters: number[] | undefined, target: number | undefined): boolean {
104
+ if (target === undefined) return true
105
+ const list = chapters ?? []
106
+ if (list.length === 0) return true
107
+ return list.includes(target)
108
+ }
109
+
110
+ function pickChapterNo(chapters: number[] | undefined, target: number | undefined, fallback: number): number {
111
+ if (target !== undefined && matchesChapter(chapters, target)) return target
112
+ return chapters?.[0] ?? fallback
113
+ }
114
+
115
+ function timelineToItems(issues: TimelineIssue[], target: number | undefined): RevisionItem[] {
116
+ const out: RevisionItem[] = []
117
+ issues.forEach((issue, i) => {
118
+ if (!matchesChapter(issue.chapters, target)) return
119
+ out.push({
120
+ id: `timeline:${i}:${pickChapterNo(issue.chapters, target, 0)}`,
121
+ source: 'timeline',
122
+ severity: issue.severity,
123
+ chapterNo: pickChapterNo(issue.chapters, target, 0),
124
+ item: issue.item,
125
+ suggestion: issue.suggestion,
126
+ })
127
+ })
128
+ return out
129
+ }
130
+
131
+ function tensionToItems(issues: TensionIssue[], target: number | undefined): RevisionItem[] {
132
+ const out: RevisionItem[] = []
133
+ issues.forEach((issue, i) => {
134
+ if (!matchesChapter(issue.chapters, target)) return
135
+ out.push({
136
+ id: `tension:${i}:${pickChapterNo(issue.chapters, target, 0)}`,
137
+ source: 'tension',
138
+ severity: issue.severity,
139
+ chapterNo: pickChapterNo(issue.chapters, target, 0),
140
+ item: issue.item,
141
+ suggestion: issue.suggestion,
142
+ })
143
+ })
144
+ return out
145
+ }
146
+
147
+ export interface RevisionCollectOptions {
148
+ /** 目标章;缺省表示全书(用于整本批量修订)。 */
149
+ chapterNo?: number
150
+ /** 显式指定的审稿意见;缺省取该章已落盘的审稿报告。 */
151
+ reviewIssues?: ReviewIssue[]
152
+ /** 是否纳入时间线矛盾(默认纳入)。 */
153
+ includeTimeline?: boolean
154
+ /** 是否纳入张力偏差(默认纳入)。 */
155
+ includeTension?: boolean
156
+ }
157
+
158
+ /** 汇总一章(或全书)的三类建议。纯函数,不改项目状态。 */
159
+ export function collectRevisionItems(project: ProjectState, options: RevisionCollectOptions = {}): RevisionItem[] {
160
+ const target = options.chapterNo
161
+ const items: RevisionItem[] = []
162
+ const chapters = (project.chapters ?? []).filter(c => target === undefined || c.no === target)
163
+
164
+ const explicit = options.reviewIssues
165
+ if (explicit !== undefined) {
166
+ explicit.forEach((issue, i) => {
167
+ items.push({
168
+ id: `review:${target ?? 0}:${i}`,
169
+ source: 'review',
170
+ severity: issue.severity,
171
+ chapterNo: target ?? 0,
172
+ item: issue.item,
173
+ suggestion: issue.suggestion,
174
+ })
175
+ })
176
+ } else {
177
+ for (const chapter of chapters) {
178
+ for (const [i, issue] of (chapter.review?.issues ?? []).entries()) {
179
+ items.push({
180
+ id: `review:${chapter.no}:${i}`,
181
+ source: 'review',
182
+ severity: issue.severity,
183
+ chapterNo: chapter.no,
184
+ item: issue.item,
185
+ suggestion: issue.suggestion,
186
+ })
187
+ }
188
+ }
189
+ }
190
+
191
+ if (options.includeTimeline ?? true) items.push(...timelineToItems(detectTimelineIssues(project), target))
192
+ if (options.includeTension ?? true) items.push(...tensionToItems(detectTensionIssues(project), target))
193
+ return items
194
+ }
195
+
196
+ export interface RevisionPlan {
197
+ /** 已排序、去重、截断后的条目。 */
198
+ items: RevisionItem[]
199
+ /** 下发给模型的修订指令(唯一入口,调用方不要自行拼装)。 */
200
+ instruction: string
201
+ counts: Record<RevisionSource, number>
202
+ /** 因超出上限被截断的条数(显式回传,便于提示作者)。 */
203
+ omitted: number
204
+ /** 验证基准:把本轮下发的全部条目当作「上一轮意见」,复核时逐条核对。 */
205
+ baseline: ReviewReport
206
+ }
207
+
208
+ export interface BuildRevisionPlanOptions extends RevisionCollectOptions {
209
+ /** 审稿基线报告(提供后 baseline 会保留其分数与时间)。 */
210
+ baseReport?: ReviewReport
211
+ /** 指令抬头(默认「按合并建议修订」)。 */
212
+ title?: string
213
+ cap?: number
214
+ }
215
+
216
+ /** 渲染修订指令。格式与历史「按审稿意见修订」保持同族,模型无需重新学习。 */
217
+ export function renderRevisionInstruction(items: RevisionItem[], title = '按合并建议修订'): string {
218
+ const counts: Record<RevisionSource, number> = { review: 0, timeline: 0, tension: 0 }
219
+ for (const item of items) counts[item.source] += item.count ?? 1
220
+ const header = `(审稿 ${counts.review} · 时间线 ${counts.timeline} · 张力 ${counts.tension})`
221
+ const lines = items.map(item => {
222
+ const tag = `【${REVISION_SOURCE_LABELS[item.source]}·${item.severity}】`
223
+ const where = item.chapterNo > 0 ? `第${item.chapterNo}章 ` : ''
224
+ const repeat = (item.count ?? 1) > 1 ? `(合并同类 ${item.count} 条)` : ''
225
+ const suggestion = item.suggestion.trim() === '' ? '' : `\n建议:${item.suggestion.trim()}`
226
+ return `${tag}${where}${item.item}${repeat}${suggestion}`
227
+ })
228
+ return `${title}${header}:\n${lines.join('\n')}`
229
+ }
230
+
231
+ /**
232
+ * 构造验证基准:把时间线/张力条目转成「上一轮意见」的编号项,
233
+ * 与审稿意见合并成一份报告。复核模型据此逐条核对,避免
234
+ * 「审稿说已解决、时间线说没改」这类互相打架的判定。
235
+ */
236
+ export function buildRevisionBaseline(items: RevisionItem[], base?: ReviewReport): ReviewReport {
237
+ const issues: ReviewIssue[] = items.map(item => ({
238
+ severity: item.severity,
239
+ item: item.source === 'review' ? item.item : `[${REVISION_SOURCE_LABELS[item.source]}] ${item.item}`,
240
+ suggestion: item.suggestion,
241
+ }))
242
+ return {
243
+ score: base?.score ?? 0,
244
+ passed: false,
245
+ verdict: base?.verdict ?? '本轮修订基准',
246
+ issues,
247
+ riskScore: base?.riskScore,
248
+ aiFlavor: base?.aiFlavor,
249
+ aiPhrases: base?.aiPhrases,
250
+ reviewedAt: base?.reviewedAt ?? new Date().toISOString(),
251
+ }
252
+ }
253
+
254
+ /** 汇总 → 排序 → 指令 → 基准。面板与生产单都走这里。 */
255
+ export function buildRevisionPlan(project: ProjectState, options: BuildRevisionPlanOptions = {}): RevisionPlan {
256
+ const collected = collectRevisionItems(project, options)
257
+ const merged = mergeRevisionItems(collected, options.cap ?? REVISION_ITEM_CAP)
258
+ const counts: Record<RevisionSource, number> = { review: 0, timeline: 0, tension: 0 }
259
+ for (const item of merged.items) counts[item.source] += item.count ?? 1
260
+ return {
261
+ items: merged.items,
262
+ instruction: renderRevisionInstruction(merged.items, options.title),
263
+ counts,
264
+ omitted: merged.omitted,
265
+ baseline: buildRevisionBaseline(merged.items, options.baseReport),
266
+ }
267
+ }
268
+
269
+ /** 一行摘要,用于日志与面板提示("审稿 3 · 时间线 1 · 张力 1,另截断 2 条")。 */
270
+ export function describeRevisionPlan(plan: RevisionPlan): string {
271
+ const parts: string[] = []
272
+ if (plan.counts.review > 0) parts.push(`审稿 ${plan.counts.review}`)
273
+ if (plan.counts.timeline > 0) parts.push(`时间线 ${plan.counts.timeline}`)
274
+ if (plan.counts.tension > 0) parts.push(`张力 ${plan.counts.tension}`)
275
+ const head = parts.length === 0 ? '无可修订条目' : parts.join(' · ')
276
+ return plan.omitted > 0 ? `${head}(另截断 ${plan.omitted} 条)` : head
277
+ }
278
+
279
+ /** 待办文本(生产单只提示不阻塞时写入项目待办,供作者人工决定)。 */
280
+ export function revisionTodoText(item: RevisionItem): string {
281
+ const where = item.chapterNo > 0 ? `第${item.chapterNo}章` : '全书'
282
+ return `[${REVISION_SOURCE_LABELS[item.source]}] ${where}:${item.item}`
283
+ }