1
0

orchestration.test.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. import { test } from 'node:test'
  2. import assert from 'node:assert/strict'
  3. import { promises as fs } from 'node:fs'
  4. import path from 'node:path'
  5. import { assembleReviewInput, mergeReviews, runReviews } from '../../src/review/index.js'
  6. import { makeGitBook, chapter } from '../state-machine/_helper.js'
  7. const charCard = (name, 境界) => `---\n姓名: ${name}\n状态: 在世\n位置: 青云宗\n境界: ${境界}\n---\n## 设定\n。`
  8. async function makeReviewBook() {
  9. return makeGitBook({
  10. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n',
  11. '定稿/正文/0001-起.md': chapter(1, '过去的事。'),
  12. '定稿/设定/角色/林晚.md': charCard('林晚', '练气三层'),
  13. '定稿/设定/时间线/第01卷.md': '| 章 | 一句话事件 |\n| --- | --- |\n| 1 | 林晚得玉佩 |\n',
  14. '工作区/细纲.md': '## 本章要写到的事\n林晚突破练气四层。\n',
  15. '工作区/草稿.md': '林晚运转功法,突破到练气四层。她握紧青霜剑。',
  16. })
  17. }
  18. const fcIssue = (over = {}) => ({
  19. severity: 'high', category: 'setting', location: '第1段',
  20. description: '境界矛盾', evidence: '正文 vs 角色卡', fix_hint: '改回', blocking: false, ...over,
  21. })
  22. const edIssue = (over = {}) => ({
  23. severity: 'low', category: 'pacing', location: '全章',
  24. description: '节奏平', evidence: '无爽点', fix_hint: '加钩子', blocking: false, ...over,
  25. })
  26. test('assembleReviewInput:DTO 含草稿+要写到的事+相关角色,不泄漏路径', async () => {
  27. const { ctx, cleanup } = await makeReviewBook()
  28. try {
  29. const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
  30. assert.equal(r.ok, true)
  31. assert.match(r.input.草稿全文, /突破到练气四层/)
  32. assert.match(r.input.本章要写到的事, /练气四层/)
  33. assert.ok(r.input.相关角色.some((c) => c.正名 === '林晚'))
  34. const json = JSON.stringify(r.input)
  35. assert.ok(!json.includes(ctx.repoPath), '不泄漏仓库绝对路径')
  36. assert.ok(!json.includes('定稿/设定'), '不泄漏内部目录路径')
  37. } finally { await cleanup() }
  38. })
  39. test('mergeReviews:降级模式含兼容声明', () => {
  40. const m = mergeReviews(
  41. { factCheck: { issues: [] }, editorial: { issues: [] } },
  42. { mode: 'degraded', chapterNum: 2 }
  43. )
  44. assert.match(m.模式声明, /兼容模式/)
  45. assert.match(m.模式声明, /隔离度/)
  46. })
  47. test('mergeReviews:完整模式 + 合并计数', () => {
  48. const m = mergeReviews(
  49. {
  50. factCheck: { issues: [fcIssue({ severity: 'critical', blocking: true })] },
  51. editorial: { issues: [edIssue()] },
  52. },
  53. { mode: 'complete', chapterNum: 2 }
  54. )
  55. assert.equal(m.issues_count, 2)
  56. assert.equal(m.blocking_count, 1)
  57. assert.equal(m.has_blocking, true)
  58. assert.match(m.模式声明, /完整/)
  59. })
  60. test('runReviews:DI 注入两审 → 校验+合并+落盘审稿单与评审报告', async () => {
  61. const { ctx, cleanup, root } = await makeReviewBook()
  62. try {
  63. const reviewers = {
  64. factCheck: async (input) => ({ chapter: input.章号, issues: [fcIssue()] }),
  65. editorial: async (input) => ({ chapter: input.章号, issues: [edIssue()] }),
  66. }
  67. const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
  68. assert.equal(r.ok, true)
  69. const 审稿 = await fs.readFile(path.join(root, '工作区', '审稿.md'), 'utf8')
  70. assert.match(审稿, /突破到练气四层/, '审稿单含草稿')
  71. assert.match(审稿, /setting/)
  72. const factJson = await fs.readFile(path.join(root, '工作区', '评审报告', '事实审查.json'), 'utf8')
  73. assert.match(factJson, /setting/)
  74. } finally { await cleanup() }
  75. })
  76. test('runReviews:降级模式 → 审稿单含兼容声明', async () => {
  77. const { ctx, cleanup, root } = await makeReviewBook()
  78. try {
  79. let calls = 0
  80. const reviewers = {
  81. degraded: async () => {
  82. calls++
  83. return { factCheck: { issues: [] }, editorial: { issues: [] } }
  84. },
  85. factCheck: async () => {
  86. throw new Error('降级模式不应单独调用事实审查')
  87. },
  88. editorial: async () => {
  89. throw new Error('降级模式不应单独调用编辑审')
  90. },
  91. }
  92. const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'degraded', reviewers })
  93. assert.equal(r.ok, true)
  94. assert.equal(calls, 1, '降级模式预算应为单次 AI 调用')
  95. const 审稿 = await fs.readFile(path.join(root, '工作区', '审稿.md'), 'utf8')
  96. assert.match(审稿, /兼容模式/)
  97. } finally { await cleanup() }
  98. })
  99. test('runReviews:审稿单越界 category → ok=false 带错', async () => {
  100. const { ctx, cleanup } = await makeReviewBook()
  101. try {
  102. const reviewers = {
  103. factCheck: async () => ({ issues: [fcIssue({ category: 'pacing' })] }), // pacing 不属事实审查
  104. editorial: async () => ({ issues: [] }),
  105. }
  106. const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
  107. assert.equal(r.ok, false)
  108. assert.ok(r.errors.length > 0)
  109. } finally { await cleanup() }
  110. })
  111. test('P1-1:草稿用别名命中角色 + 名册/相关条目进 DTO', async () => {
  112. // 名册有 林晚(正名)/晚晚(别名);草稿只用别名「晚晚」→ 旧逻辑漏,新逻辑应纳入
  113. const { ctx, cleanup } = await makeGitBook({
  114. 'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n',
  115. '定稿/正文/0001-起.md': chapter(1, '过去的事。'),
  116. '定稿/设定/名册.md': '| 正名 | 别名 | 类型 | 首现章 |\n|------|------|------|--------|\n| 林晚 | 晚晚 | character | 1 |\n',
  117. '定稿/设定/角色/林晚.md': charCard('林晚', '练气三层'),
  118. '大纲/伏笔/伏笔-001-x.md': '---\n强度: 高\n状态: 进行\n开启章: 1\n---\n## 履历\n- 第1章:推进\n',
  119. '工作区/细纲.md': '## 本章要写到的事\n晚晚突破。\n',
  120. '工作区/草稿.md': '晚晚运转功法,突破到练气四层。她握紧青霜剑。',
  121. })
  122. try {
  123. const r = await assembleReviewInput(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md' })
  124. assert.equal(r.ok, true, r.error)
  125. // 草稿只用别名「晚晚」,正名「林晚」没出现 → 必须靠别名命中
  126. assert.ok(!r.input.草稿全文.includes('林晚'), '前置:草稿确实不含正名')
  127. assert.ok(r.input.相关角色.some((c) => c.正名 === '林晚'), '别名命中应纳入林晚')
  128. assert.ok(r.input.名册.some((m) => m.正名 === '林晚' && m.别名.includes('晚晚')), '名册带别名')
  129. assert.ok(r.input.相关条目.some((t) => t.id?.startsWith('伏笔-001')), '相关条目带进行中的伏笔')
  130. // 不泄漏路径
  131. const json = JSON.stringify(r.input)
  132. assert.ok(!json.includes(ctx.repoPath) && !json.includes('定稿/设定'))
  133. } finally { await cleanup() }
  134. })
  135. test('P1-3:原始输出与归一化结果分存(.raw.json 保留模型原话)', async () => {
  136. const { ctx, cleanup, root } = await makeReviewBook()
  137. try {
  138. // stub 返回 critical+blocking:false;归一化会把 blocking 改 true,raw 保留 false
  139. const reviewers = {
  140. factCheck: async (input) => ({
  141. chapter: input.章号,
  142. ai_meta: 'raw-marker',
  143. issues: [fcIssue({ severity: 'critical', blocking: false })],
  144. }),
  145. editorial: async () => ({ issues: [] }),
  146. }
  147. const r = await runReviews(ctx, { chapterNum: 2, draftPath: '工作区/草稿.md', mode: 'complete', reviewers })
  148. assert.equal(r.ok, true)
  149. const raw = JSON.parse(await fs.readFile(path.join(root, '工作区', '评审报告', '事实审查.raw.json'), 'utf8'))
  150. const norm = JSON.parse(await fs.readFile(path.join(root, '工作区', '评审报告', '事实审查.json'), 'utf8'))
  151. assert.equal(raw.issues[0].blocking, false, 'raw 保留 AI 原话 blocking:false')
  152. assert.equal(norm.issues[0].blocking, true, '归一化把 critical 改 blocking:true')
  153. assert.equal(raw.ai_meta, 'raw-marker', 'raw 保留 AI 额外字段')
  154. } finally { await cleanup() }
  155. })