index.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { promises as fs } from 'node:fs'
  2. import path from 'node:path'
  3. import { assembleBookStatus } from '../prep/book-status.js'
  4. import { extractSection } from '../util/markdown.js'
  5. import { parseThreadDeclarations } from '../util/thread-declarations.js'
  6. import { assembleCharacterContext } from '../dto/character-context.js'
  7. import { parseFrontMatter } from '../storage/parsers/front-matter.js'
  8. import { TimelineReader } from '../storage/adapters/TimelineReader.js'
  9. import { SecretReader } from '../storage/adapters/SecretReader.js'
  10. import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
  11. import { writeAtomicBatch } from '../storage/atomic.js'
  12. import { validateReviewReport } from './schema.js'
  13. const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。'
  14. const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。'
  15. /**
  16. * 组装两审共享的 ReviewInput DTO(AI 不见文件路径,实施计划 §1.5 原则 1)。
  17. * 复用 M1 读接口 + M2 全书近况 + 细纲「要写到的事」。
  18. * @param {{repoPath, cache}} ctx
  19. * @param {{chapterNum: number, draftPath: string}} args
  20. */
  21. export async function assembleReviewInput(ctx, { chapterNum, draftPath }) {
  22. try {
  23. const { repoPath, cache } = ctx
  24. const 草稿全文 = await fs.readFile(path.join(repoPath, draftPath), 'utf8')
  25. // 拟条目变动:草稿 front matter 三数组声明(与机检共用同一解析,不双写)
  26. const draftFm = parseFrontMatter(草稿全文)
  27. const { declarations } = parseThreadDeclarations(draftFm.ok ? draftFm.data : null)
  28. const 拟条目变动 = declarations.map(({ type, verb, id }) => ({ type, verb, id }))
  29. let 本章要写到的事 = '(无细纲)'
  30. try {
  31. const outline = await fs.readFile(path.join(repoPath, '工作区', '细纲.md'), 'utf8')
  32. 本章要写到的事 = extractSection(outline, '本章要写到的事') || '(细纲未声明)'
  33. } catch {
  34. // 无细纲
  35. }
  36. const status = await assembleBookStatus(ctx)
  37. const 当前卷 = status.ok ? status.data.当前卷 : 1
  38. // P1-1:名册快照(正名+别名),供 AI 判新专名,不泄漏路径;同时建 aliasMap 供角色别名命中
  39. let 名册 = []
  40. const aliasMap = new Map() // 正名 → 别名[]
  41. try {
  42. const rows = await cache.query(
  43. "SELECT e.id AS 正名, group_concat(a.alias, ',') AS 别名从句 FROM entities e LEFT JOIN entity_aliases a ON a.entity_id = e.id WHERE e.type = 'character' GROUP BY e.id"
  44. )
  45. 名册 = rows.map((r) => {
  46. const 别名 = r.别名从句 ? r.别名从句.split(',').map((s) => s.trim()).filter(Boolean) : []
  47. if (别名.length) aliasMap.set(r.正名, 别名)
  48. return { 正名: r.正名, 别名 }
  49. })
  50. } catch {
  51. // 缓存不可用则略
  52. }
  53. // 相关角色:扫角色目录,正名或别名出现在草稿里的纳入(P1-1:别名也要命中)
  54. const 相关角色 = []
  55. try {
  56. const dir = path.join(repoPath, '定稿', '设定', '角色')
  57. for (const f of await fs.readdir(dir)) {
  58. if (!f.endsWith('.md')) continue
  59. const name = f.replace(/\.md$/, '')
  60. const cc = await assembleCharacterContext(ctx, name)
  61. if (!cc.ok) continue
  62. // 别名合集:名册 entity_aliases(规范源)+ 角色卡 fm.别名
  63. const aliases = new Set(aliasMap.get(name) || [])
  64. const fmAliases = cc.context.别名
  65. if (Array.isArray(fmAliases)) fmAliases.forEach((a) => a && aliases.add(a))
  66. else if (typeof fmAliases === 'string')
  67. fmAliases.split(',').forEach((a) => a.trim() && aliases.add(a.trim()))
  68. const hit = 草稿全文.includes(name) || [...aliases].some((a) => a && 草稿全文.includes(a))
  69. if (hit) 相关角色.push(cc.context)
  70. }
  71. } catch {
  72. // 无角色目录
  73. }
  74. const tl = await new TimelineReader(repoPath, cache).readVolumeRange(Math.max(1, 当前卷 - 1), 当前卷)
  75. const 时间线片段 = tl.ok ? tl.timeline.map((row) => ({ 章: row.章 ?? '', 事件: row.一句话事件 ?? '' })) : []
  76. const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
  77. const 信息差候选 = secrets.map((s) => ({ id: s.id, 短题: s.短题, 关键词: s.关键词 }))
  78. // P1-1:相关条目 = 仍在进行、且在本章前开启的条目(不泄漏 file_path)
  79. let 相关条目 = []
  80. try {
  81. const rows = await cache.query(
  82. "SELECT id, type, short_title, status, opened_chapter, last_advanced_chapter FROM threads WHERE status = '进行' AND opened_chapter <= ? ORDER BY opened_chapter",
  83. [chapterNum]
  84. )
  85. 相关条目 = rows.map((r) => ({
  86. id: r.id,
  87. type: r.type,
  88. 简述: r.short_title,
  89. 状态: r.status,
  90. 开启章: r.opened_chapter,
  91. 最后推进章: r.last_advanced_chapter,
  92. }))
  93. } catch {
  94. // 缓存不可用则略
  95. }
  96. // 草稿声明涉及的条目附履历尾部(末 3 行);未声明的维持纯元数据(控 token)
  97. const declaredIds = new Set(拟条目变动.map((d) => d.id))
  98. if (declaredIds.size) {
  99. const ledger = new ThreadLedgerReader(repoPath, cache)
  100. for (const t of 相关条目) {
  101. if (!declaredIds.has(t.id)) continue
  102. const h = await ledger.readHistory(t.id)
  103. if (h.ok && h.history.length) t.履历尾部 = h.history.slice(-3).map((x) => x.原文)
  104. }
  105. }
  106. return {
  107. ok: true,
  108. input: {
  109. 章号: chapterNum,
  110. 草稿全文,
  111. 本章要写到的事,
  112. 全书近况: status.ok ? status.markdown : '',
  113. 相关角色,
  114. 相关条目,
  115. 拟条目变动,
  116. 名册,
  117. 时间线片段,
  118. 信息差候选,
  119. },
  120. error: '',
  121. }
  122. } catch (err) {
  123. return { ok: false, input: null, error: `组装审稿输入失败:${err.message}` }
  124. }
  125. }
  126. /**
  127. * 合并两审输出 → 审稿单结构。已假定各报告经 schema 复算。
  128. * @param {{factCheck: object, editorial: object}} reports
  129. * @param {{mode: 'complete'|'degraded', chapterNum: number}} opts
  130. */
  131. export function mergeReviews({ factCheck, editorial }, { mode, chapterNum }) {
  132. const fIssues = factCheck?.issues || []
  133. const eIssues = editorial?.issues || []
  134. const issues = [...fIssues, ...eIssues]
  135. const blocking_count = issues.filter((x) => x.blocking).length
  136. return {
  137. 章号: chapterNum,
  138. mode,
  139. 模式声明: mode === 'degraded' ? 兼容声明 : 完整声明,
  140. 事实审查: factCheck,
  141. 编辑审: editorial,
  142. issues,
  143. issues_count: issues.length,
  144. blocking_count,
  145. has_blocking: blocking_count > 0,
  146. }
  147. }
  148. /**
  149. * 落盘审稿单(§8 第7步:草稿+两审意见+待确认新专名+章摘要)与原始评审报告。
  150. * @param {{repoPath}} ctx
  151. * @param {{chapterNum, merged, draft, 待确认新专名?: string[], 章摘要?: string}} args
  152. */
  153. export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '', raw = null }) {
  154. const { repoPath } = ctx
  155. const issueLines = merged.issues.length
  156. ? merged.issues
  157. .map((i) => `- [${i.severity}/${i.category}${i.blocking ? '/阻断' : ''}] ${i.location}:${i.description}(修复:${i.fix_hint})`)
  158. .join('\n')
  159. : '(无问题)'
  160. const md = [
  161. `# 第 ${chapterNum} 章审稿单`,
  162. '',
  163. `> ${merged.模式声明}`,
  164. `> 共 ${merged.issues_count} 个问题:${merged.blocking_count} 阻断。`,
  165. '',
  166. '## 两审意见',
  167. issueLines,
  168. '',
  169. '## 待确认新专名',
  170. 待确认新专名.length ? 待确认新专名.map((n) => `- ${n}`).join('\n') : '(无)',
  171. '',
  172. '## 章摘要(扫一眼可改)',
  173. 章摘要 || '(无)',
  174. '',
  175. '## 草稿',
  176. draft,
  177. '',
  178. ].join('\n')
  179. // P0-3:多文件原子落盘;P1-3:原始输出与归一化结果分存,便于回溯模型原话
  180. const files = [
  181. { path: path.join('工作区', '评审报告', '事实审查.json'), content: JSON.stringify(merged.事实审查 ?? {}, null, 2) },
  182. { path: path.join('工作区', '评审报告', '编辑审.json'), content: JSON.stringify(merged.编辑审 ?? {}, null, 2) },
  183. ]
  184. if (raw) {
  185. files.push({ path: path.join('工作区', '评审报告', '事实审查.raw.json'), content: JSON.stringify(raw.factCheck ?? {}, null, 2) })
  186. files.push({ path: path.join('工作区', '评审报告', '编辑审.raw.json'), content: JSON.stringify(raw.editorial ?? {}, null, 2) })
  187. }
  188. files.push({ path: path.join('工作区', '审稿.md'), content: md })
  189. await writeAtomicBatch(repoPath, files)
  190. const 审稿路径 = path.join(repoPath, '工作区', '审稿.md')
  191. return { ok: true, 审稿路径, error: '' }
  192. }
  193. /**
  194. * 两审编排:组装输入 → DI 注入两审 → 校验 → 合并 → 落盘。零真 AI(reviewers 由宿主壳/测试注入)。
  195. * @param {{repoPath, cache}} ctx
  196. * @param {{chapterNum, draftPath, mode, reviewers, 待确认新专名?, 章摘要?}} args
  197. */
  198. export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete', reviewers, 待确认新专名, 章摘要 }) {
  199. const inp = await assembleReviewInput(ctx, { chapterNum, draftPath })
  200. if (!inp.ok) return { ok: false, errors: [inp.error] }
  201. let rawFact
  202. let rawEdit
  203. if (mode === 'degraded') {
  204. if (typeof reviewers.degraded !== 'function') {
  205. return { ok: false, errors: ['兼容模式需要注入 degraded reviewer(单次 AI 调用)'] }
  206. }
  207. const raw = await reviewers.degraded(inp.input)
  208. rawFact = raw.factCheck ?? raw.事实审查 ?? { issues: [] }
  209. rawEdit = raw.editorial ?? raw.编辑审 ?? { issues: [] }
  210. } else {
  211. rawFact = await reviewers.factCheck(inp.input)
  212. rawEdit = await reviewers.editorial(inp.input)
  213. }
  214. const vFact = validateReviewReport(rawFact, { reviewType: 'factCheck' })
  215. const vEdit = validateReviewReport(rawEdit, { reviewType: 'editorial' })
  216. const errors = [...vFact.errors, ...vEdit.errors]
  217. if (errors.length) return { ok: false, errors }
  218. const merged = mergeReviews({ factCheck: vFact.report, editorial: vEdit.report }, { mode, chapterNum })
  219. const saved = await persistReviewReport(ctx, {
  220. chapterNum,
  221. merged,
  222. draft: inp.input.草稿全文,
  223. 待确认新专名,
  224. 章摘要,
  225. raw: { factCheck: rawFact, editorial: rawEdit },
  226. })
  227. return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] }
  228. }