| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- 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: [] }
- }
|