Explorar o código

feat(v7): M3 P3——AI 态 DTO 缝(序0/1/4/6 备 M4 上下文)

- dto.buildDto(ctx,序,base):为 AI 态组装上下文 DTO,M3 只备料不调 AI;
  每个 DTO 标 `期望产物` 告诉 M4 该产出什么、由 M3 落盘
  - 序0 修复确认:failures
  - 序1 建书引导:缺什么(book.yaml/总纲)
  - 序4 卷复盘:卷 + 全书近况 + 悬了太久
  - 序6 起草细纲:nextChapter + 全书近况(复用 M2 assembleBookStatus)
- determineNextState 的 4 个 AI 态返回接入 buildDto
- 测试 4 例(各 AI 态 dto 字段齐全 + needsAI)
lingfengQAQ hai 2 días
pai
achega
e22501c8ce

+ 63 - 0
v7/src/state-machine/dto.js

@@ -0,0 +1,63 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { assembleBookStatus } from '../prep/book-status.js'
+
+/**
+ * 为 AI 态组装上下文 DTO(M3 只备料,不调 AI)。M4 吃 DTO 出结构化产物,
+ * 产物回流由 M3 落盘(M4 不碰文件)。每个 DTO 标注 `期望产物` 告诉 M4 该产出什么。
+ * @param {{repoPath, cache}} ctx
+ * @param {number} 序
+ * @param {object} base 路由已知信息(failures / 卷 / nextChapter)
+ */
+export async function buildDto(ctx, 序, base = {}) {
+  switch (序) {
+    case 0:
+      return {
+        state: 'repair-confirm',
+        failures: base.failures || [],
+        期望产物: '逐个给出「保留作者意图」的修复方案,作者确认后由 M3 写回',
+      }
+    case 1:
+      return {
+        state: 'create-book',
+        缺: await whatsMissing(ctx),
+        期望产物: '问答生成 book.yaml + 总纲 + 第一卷卷纲(由 M3 落盘 + 登记 books.jsonl)',
+      }
+    case 4: {
+      const status = await assembleBookStatus(ctx)
+      return {
+        state: 'volume-review',
+        卷: base.卷,
+        全书近况: status.ok ? status.markdown : '',
+        悬了太久: status.ok ? status.data.悬了太久 : [],
+        期望产物: '卷摘要 + 下卷卷纲 + 伏笔机会候选(作者勾选后 M3 生成条目)',
+      }
+    }
+    case 6: {
+      const status = await assembleBookStatus(ctx)
+      return {
+        state: 'draft-outline',
+        nextChapter: base.nextChapter,
+        全书近况: status.ok ? status.markdown : '',
+        期望产物: '工作区/细纲.md(含本章定位声明 + 本章要写到的事 + 备选,由 M3 落盘)',
+      }
+    }
+    default:
+      return { state: base.state || 'unknown' }
+  }
+}
+
+async function whatsMissing(ctx) {
+  const missing = []
+  for (const [label, rel] of [
+    ['book.yaml', 'book.yaml'],
+    ['总纲', '大纲/总纲.md'],
+  ]) {
+    try {
+      await fs.access(path.join(ctx.repoPath, rel))
+    } catch {
+      missing.push(label)
+    }
+  }
+  return missing
+}

+ 7 - 6
v7/src/state-machine/index.js

@@ -1,5 +1,6 @@
 import { checkGitHealth } from './git-health.js'
 import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+import { buildDto } from './dto.js'
 import * as d from './detectors.js'
 
 /**
@@ -15,12 +16,12 @@ export async function determineNextState(ctx) {
   // 序0 修复确认(检测=脚本,提议=AI)
   const failures = await d.detectParseFailures(repoPath)
   if (failures.length) {
-    return mk(0, 'repair-confirm', true, `检测到 ${failures.length} 个源文件解析失败,需逐个修复确认。`, gitHealth, { failures })
+    return mk(0, 'repair-confirm', true, `检测到 ${failures.length} 个源文件解析失败,需逐个修复确认。`, gitHealth, await buildDto(ctx, 0, { failures }))
   }
 
   // 序1 建书引导
   if (await d.bookMissing(repoPath)) {
-    return mk(1, 'create-book', true, '当前目录还没有书,进入建书引导。', gitHealth, {})
+    return mk(1, 'create-book', true, '当前目录还没有书,进入建书引导。', gitHealth, await buildDto(ctx, 1, {}))
   }
 
   // 序2 手改补登
@@ -42,9 +43,9 @@ export async function determineNextState(ctx) {
 
   // 序4 卷复盘(卷末章;对谈=AI)
   if (maxChapter > 0 && maxChapter % 卷规模 === 0) {
-    return mk(4, 'volume-review', true, `第 ${maxChapter} 章是卷末,进入卷复盘。`, gitHealth, {
+    return mk(4, 'volume-review', true, `第 ${maxChapter} 章是卷末,进入卷复盘。`, gitHealth, await buildDto(ctx, 4, {
       卷: Math.floor(maxChapter / 卷规模),
-    })
+    }))
   }
 
   // 序5 体检(脚本项;指纹推 M3+)
@@ -53,9 +54,9 @@ export async function determineNextState(ctx) {
   }
 
   // 序6 起草新章细纲(近况=脚本,拟提案=AI)
-  return mk(6, 'draft-outline', true, `起草第 ${maxChapter + 1} 章细纲。`, gitHealth, {
+  return mk(6, 'draft-outline', true, `起草第 ${maxChapter + 1} 章细纲。`, gitHealth, await buildDto(ctx, 6, {
     nextChapter: maxChapter + 1,
-  })
+  }))
 }
 
 function mk(序, state, needsAI, message, gitHealth, dto) {

+ 57 - 0
v7/test/state-machine/dto.test.js

@@ -0,0 +1,57 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { determineNextState } from '../../src/state-machine/index.js'
+import { buildDto } from '../../src/state-machine/dto.js'
+import { makeGitBook, chapter } from './_helper.js'
+
+test('序6 起草细纲:dto 含全书近况 + nextChapter,needsAI=true', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': 'spec_version: "7.0"\n书名: 测\n卷规模: 40\n体检周期: 50\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-起.md': chapter(1),
+  })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 6)
+    assert.equal(r.needsAI, true)
+    assert.equal(r.dto.nextChapter, 2)
+    assert.match(r.dto.全书近况, /第\s*1\s*卷/)
+    assert.ok(r.dto.期望产物.includes('细纲'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序1 建书引导:dto.缺 含 book.yaml,needsAI=true', async () => {
+  const { ctx, cleanup } = await makeGitBook({ '大纲/占位.md': 'x' })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 1)
+    assert.equal(r.needsAI, true)
+    assert.ok(r.dto.缺.includes('book.yaml'))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('序0 修复确认:dto.failures 指向坏文件', async () => {
+  const { ctx, cleanup } = await makeGitBook({
+    'book.yaml': '书名: 测\n',
+    '大纲/总纲.md': '# 总纲',
+    '定稿/正文/0001-起.md': chapter(1),
+    '定稿/正文/0002-坏.md': '---\n章号: 2\n标题: [未闭合\n---\nx',
+  })
+  try {
+    const r = await determineNextState(ctx)
+    assert.equal(r.序, 0)
+    assert.ok(r.dto.failures.length >= 1)
+    assert.ok(r.dto.failures.some((f) => f.file.includes('0002')))
+  } finally {
+    await cleanup()
+  }
+})
+
+test('buildDto 默认分支:非 AI 态返回最简 dto', async () => {
+  const dto = await buildDto({}, 2, { state: 'relink-manual-edits' })
+  assert.equal(dto.state, 'relink-manual-edits')
+})