import { promises as fs } from 'node:fs' import path from 'node:path' import { assembleBookStatus } from '../prep/book-status.js' import { extractSection } from '../util/markdown.js' import { parseThreadDeclarations } from '../util/thread-declarations.js' import { assembleCharacterContext } from '../dto/character-context.js' import { parseFrontMatter } from '../storage/parsers/front-matter.js' import { TimelineReader } from '../storage/adapters/TimelineReader.js' import { SecretReader } from '../storage/adapters/SecretReader.js' import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js' import { writeAtomicBatch } from '../storage/atomic.js' import { validateReviewReport } from './schema.js' const 兼容声明 = '本次使用兼容模式(单上下文顺序审稿),审稿隔离度低于完整两审模式。' const 完整声明 = '完整两审模式(事实审查/编辑审各自独立上下文)。' /** * 组装两审共享的 ReviewInput DTO(AI 不见文件路径,实施计划 §1.5 原则 1)。 * 复用 M1 读接口 + M2 全书近况 + 细纲「要写到的事」。 * @param {{repoPath, cache}} ctx * @param {{chapterNum: number, draftPath: string}} args */ export async function assembleReviewInput(ctx, { chapterNum, draftPath }) { try { const { repoPath, cache } = ctx const 草稿全文 = await fs.readFile(path.join(repoPath, draftPath), 'utf8') // 拟条目变动:草稿 front matter 三数组声明(与机检共用同一解析,不双写) const draftFm = parseFrontMatter(草稿全文) const { declarations } = parseThreadDeclarations(draftFm.ok ? draftFm.data : null) const 拟条目变动 = declarations.map(({ type, verb, id }) => ({ type, verb, id })) let 本章要写到的事 = '(无细纲)' try { const outline = await fs.readFile(path.join(repoPath, '工作区', '细纲.md'), 'utf8') 本章要写到的事 = extractSection(outline, '本章要写到的事') || '(细纲未声明)' } catch { // 无细纲 } const status = await assembleBookStatus(ctx) const 当前卷 = status.ok ? status.data.当前卷 : 1 // P1-1:名册快照(正名+别名),供 AI 判新专名,不泄漏路径;同时建 aliasMap 供角色别名命中 let 名册 = [] const aliasMap = new Map() // 正名 → 别名[] try { const rows = await cache.query( "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" ) 名册 = rows.map((r) => { const 别名 = r.别名从句 ? r.别名从句.split(',').map((s) => s.trim()).filter(Boolean) : [] if (别名.length) aliasMap.set(r.正名, 别名) return { 正名: r.正名, 别名 } }) } catch { // 缓存不可用则略 } // 相关角色:扫角色目录,正名或别名出现在草稿里的纳入(P1-1:别名也要命中) const 相关角色 = [] try { const dir = path.join(repoPath, '定稿', '设定', '角色') for (const f of await fs.readdir(dir)) { if (!f.endsWith('.md')) continue const name = f.replace(/\.md$/, '') const cc = await assembleCharacterContext(ctx, name) if (!cc.ok) continue // 别名合集:名册 entity_aliases(规范源)+ 角色卡 fm.别名 const aliases = new Set(aliasMap.get(name) || []) const fmAliases = cc.context.别名 if (Array.isArray(fmAliases)) fmAliases.forEach((a) => a && aliases.add(a)) else if (typeof fmAliases === 'string') fmAliases.split(',').forEach((a) => a.trim() && aliases.add(a.trim())) const hit = 草稿全文.includes(name) || [...aliases].some((a) => a && 草稿全文.includes(a)) if (hit) 相关角色.push(cc.context) } } catch { // 无角色目录 } const tl = await new TimelineReader(repoPath, cache).readVolumeRange(Math.max(1, 当前卷 - 1), 当前卷) const 时间线片段 = tl.ok ? tl.timeline.map((row) => ({ 章: row.章 ?? '', 事件: row.一句话事件 ?? '' })) : [] const secrets = await new SecretReader(repoPath, cache).listUnrevealed() const 信息差候选 = secrets.map((s) => ({ id: s.id, 短题: s.短题, 关键词: s.关键词 })) // P1-1:相关条目 = 仍在进行、且在本章前开启的条目(不泄漏 file_path) let 相关条目 = [] try { const rows = await cache.query( "SELECT id, type, short_title, status, opened_chapter, last_advanced_chapter FROM threads WHERE status = '进行' AND opened_chapter <= ? ORDER BY opened_chapter", [chapterNum] ) 相关条目 = rows.map((r) => ({ id: r.id, type: r.type, 简述: r.short_title, 状态: r.status, 开启章: r.opened_chapter, 最后推进章: r.last_advanced_chapter, })) } catch { // 缓存不可用则略 } // 草稿声明涉及的条目附履历尾部(末 3 行);未声明的维持纯元数据(控 token) const declaredIds = new Set(拟条目变动.map((d) => d.id)) if (declaredIds.size) { const ledger = new ThreadLedgerReader(repoPath, cache) for (const t of 相关条目) { if (!declaredIds.has(t.id)) continue const h = await ledger.readHistory(t.id) if (h.ok && h.history.length) t.履历尾部 = h.history.slice(-3).map((x) => x.原文) } } return { ok: true, input: { 章号: chapterNum, 草稿全文, 本章要写到的事, 全书近况: status.ok ? status.markdown : '', 相关角色, 相关条目, 拟条目变动, 名册, 时间线片段, 信息差候选, }, error: '', } } catch (err) { return { ok: false, input: null, error: `组装审稿输入失败:${err.message}` } } } /** * 合并两审输出 → 审稿单结构。已假定各报告经 schema 复算。 * @param {{factCheck: object, editorial: object}} reports * @param {{mode: 'complete'|'degraded', chapterNum: number}} opts */ export function mergeReviews({ factCheck, editorial }, { mode, chapterNum }) { const fIssues = factCheck?.issues || [] const eIssues = editorial?.issues || [] const issues = [...fIssues, ...eIssues] const blocking_count = issues.filter((x) => x.blocking).length return { 章号: chapterNum, mode, 模式声明: mode === 'degraded' ? 兼容声明 : 完整声明, 事实审查: factCheck, 编辑审: editorial, issues, issues_count: issues.length, blocking_count, has_blocking: blocking_count > 0, } } /** * 落盘审稿单(§8 第7步:草稿+两审意见+待确认新专名+章摘要)与原始评审报告。 * @param {{repoPath}} ctx * @param {{chapterNum, merged, draft, 待确认新专名?: string[], 章摘要?: string}} args */ export async function persistReviewReport(ctx, { chapterNum, merged, draft, 待确认新专名 = [], 章摘要 = '', raw = null }) { const { repoPath } = ctx const issueLines = merged.issues.length ? merged.issues .map((i) => `- [${i.severity}/${i.category}${i.blocking ? '/阻断' : ''}] ${i.location}:${i.description}(修复:${i.fix_hint})`) .join('\n') : '(无问题)' const md = [ `# 第 ${chapterNum} 章审稿单`, '', `> ${merged.模式声明}`, `> 共 ${merged.issues_count} 个问题:${merged.blocking_count} 阻断。`, '', '## 两审意见', issueLines, '', '## 待确认新专名', 待确认新专名.length ? 待确认新专名.map((n) => `- ${n}`).join('\n') : '(无)', '', '## 章摘要(扫一眼可改)', 章摘要 || '(无)', '', '## 草稿', draft, '', ].join('\n') // P0-3:多文件原子落盘;P1-3:原始输出与归一化结果分存,便于回溯模型原话 const files = [ { path: path.join('工作区', '评审报告', '事实审查.json'), content: JSON.stringify(merged.事实审查 ?? {}, null, 2) }, { path: path.join('工作区', '评审报告', '编辑审.json'), content: JSON.stringify(merged.编辑审 ?? {}, null, 2) }, ] if (raw) { files.push({ path: path.join('工作区', '评审报告', '事实审查.raw.json'), content: JSON.stringify(raw.factCheck ?? {}, null, 2) }) files.push({ path: path.join('工作区', '评审报告', '编辑审.raw.json'), content: JSON.stringify(raw.editorial ?? {}, null, 2) }) } files.push({ path: path.join('工作区', '审稿.md'), content: md }) await writeAtomicBatch(repoPath, files) const 审稿路径 = path.join(repoPath, '工作区', '审稿.md') return { ok: true, 审稿路径, error: '' } } /** * 两审编排:组装输入 → DI 注入两审 → 校验 → 合并 → 落盘。零真 AI(reviewers 由宿主壳/测试注入)。 * @param {{repoPath, cache}} ctx * @param {{chapterNum, draftPath, mode, reviewers, 待确认新专名?, 章摘要?}} args */ export async function runReviews(ctx, { chapterNum, draftPath, mode = 'complete', reviewers, 待确认新专名, 章摘要 }) { const inp = await assembleReviewInput(ctx, { chapterNum, draftPath }) if (!inp.ok) return { ok: false, errors: [inp.error] } let rawFact let rawEdit if (mode === 'degraded') { if (typeof reviewers.degraded !== 'function') { return { ok: false, errors: ['兼容模式需要注入 degraded reviewer(单次 AI 调用)'] } } const raw = await reviewers.degraded(inp.input) rawFact = raw.factCheck ?? raw.事实审查 ?? { issues: [] } rawEdit = raw.editorial ?? raw.编辑审 ?? { issues: [] } } else { rawFact = await reviewers.factCheck(inp.input) rawEdit = await reviewers.editorial(inp.input) } const vFact = validateReviewReport(rawFact, { reviewType: 'factCheck' }) const vEdit = validateReviewReport(rawEdit, { reviewType: 'editorial' }) const errors = [...vFact.errors, ...vEdit.errors] if (errors.length) return { ok: false, errors } const merged = mergeReviews({ factCheck: vFact.report, editorial: vEdit.report }, { mode, chapterNum }) const saved = await persistReviewReport(ctx, { chapterNum, merged, draft: inp.input.草稿全文, 待确认新专名, 章摘要, raw: { factCheck: rawFact, editorial: rawEdit }, }) return { ok: true, merged, 审稿路径: saved.审稿路径, errors: [] } }