Przeglądaj źródła

feat(v7): M2 P1——全书近况组装 + 备料(prepareChapterMaterials)

- assembleBookStatus:复用 M1 缓存/读端口产结构化近况 + Markdown(卷进度/
  悬了太久/连续弱钩/全书统计,派生值查询时算)
- prepareChapterMaterials:组装 工作区/本章写作材料.md 八组件(全书近况/要写
  到的事/时间线切片/信息差边界/近章结尾/文风锚点/反和解/反复读清单占位)
- fixture 补 文风/文风铁律.md(禁词/禁句式/反和解)+ 工作区/细纲.md
- _helper 加 tempBookCtx(拷 fixture 到临时目录,供写入型流程测试不污染)
lingfengQAQ 1 dzień temu
rodzic
commit
85b9750ab3

+ 68 - 0
v7/src/prep/book-status.js

@@ -0,0 +1,68 @@
+import { ThreadLedgerReader } from '../storage/adapters/ThreadLedgerReader.js'
+import { BookConfigReader } from '../storage/adapters/BookConfigReader.js'
+
+/**
+ * 组装全书近况(喂细纲 step1 与备料 step3)。复用 M1 缓存与读端口,派生值查询时算。
+ * @param {{repoPath: string, cache: object}} ctx
+ * @returns {Promise<{ok: boolean, data: object|null, markdown: string, error: string}>}
+ */
+export async function assembleBookStatus(ctx) {
+  try {
+    const { cache, repoPath } = ctx
+    const config = await new BookConfigReader(repoPath).read()
+    const bookConfig = config.ok ? config.data : {}
+
+    const volRow = await cache.query('SELECT MAX(volume_num) AS v FROM chapters')
+    const 当前卷 = volRow[0]?.v || 1
+    const inVol = await cache.query(
+      'SELECT COUNT(*) AS c FROM chapters WHERE volume_num = ?',
+      [当前卷]
+    )
+    const stats = await cache.query(
+      'SELECT COUNT(*) AS c, COALESCE(SUM(word_count), 0) AS w FROM chapters'
+    )
+    const th = await cache.query('SELECT COUNT(*) AS c FROM threads')
+    const en = await cache.query("SELECT COUNT(*) AS c FROM entities WHERE type = 'character'")
+
+    // 连续弱钩(查询时算)
+    const hookRows = await cache.query(
+      'SELECT hook_type FROM chapters ORDER BY chapter_num DESC LIMIT 20'
+    )
+    let 连续弱钩 = 0
+    for (const r of hookRows) {
+      const h = r.hook_type || ''
+      if (h.includes('弱钩') || h.endsWith('-弱')) 连续弱钩++
+      else break
+    }
+
+    // 悬了太久(查询时算,阈值从 book.yaml)
+    const overdue = await new ThreadLedgerReader(repoPath, cache).listOverdue(bookConfig)
+
+    const 卷规模 = bookConfig.卷规模 || 40
+    const data = {
+      当前卷,
+      卷内进度: { 写到: inVol[0]?.c || 0, 卷规模 },
+      总章数: stats[0].c,
+      总字数: stats[0].w,
+      条目数: th[0].c,
+      角色数: en[0].c,
+      连续弱钩,
+      悬了太久: overdue,
+    }
+
+    const overdueLine = overdue.length
+      ? overdue.map((t) => `${t.id}(悬了 ${t.overdue_count} 章)`).join('、')
+      : '无'
+    const markdown = [
+      '## 全书近况(脚本生成)',
+      `- 位置:第 ${当前卷} 卷 ${data.卷内进度.写到}/${卷规模} 章`,
+      `- 悬了太久:${overdueLine}`,
+      `- 连续弱钩:${连续弱钩} 章`,
+      `- 全书:${data.总章数} 章 / ${data.总字数} 字 / ${data.条目数} 条目 / ${data.角色数} 角色`,
+    ].join('\n')
+
+    return { ok: true, data, markdown, error: '' }
+  } catch (err) {
+    return { ok: false, data: null, markdown: '', error: `组装全书近况失败:${err.message}` }
+  }
+}

+ 103 - 3
v7/src/prep/index.js

@@ -1,3 +1,103 @@
-// 备料:组装本章写作材料(全书近况 + 要写到的事 + 精准片段),默认精准读取。
-// 占位——真实实现见 M2。
-export {}
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { assembleBookStatus } from './book-status.js'
+import { extractSection } from '../util/markdown.js'
+import { TimelineReader } from '../storage/adapters/TimelineReader.js'
+import { SecretReader } from '../storage/adapters/SecretReader.js'
+import { ChapterReader } from '../storage/adapters/ChapterReader.js'
+
+/**
+ * 备料:组装 工作区/本章写作材料.md(spec §8 step3,默认精准片段)。
+ * 八组件:全书近况 + 要写到的事 + 事实切片 + 信息差边界 + 近章结尾 + 反复读清单 + 文风锚点 + 反和解规则。
+ * @param {{repoPath: string, cache: object}} ctx
+ * @param {{chapterNum: number}} args
+ * @returns {Promise<{ok: boolean, filePath: string, content: string, error: string}>}
+ */
+export async function prepareChapterMaterials(ctx, { chapterNum }) {
+  try {
+    const { repoPath, cache } = ctx
+
+    const status = await assembleBookStatus(ctx)
+    const 当前卷 = status.ok ? status.data.当前卷 : 1
+
+    // 本章要写到的事(读细纲)
+    let 要写到的事 = '(无细纲)'
+    try {
+      const outline = await fs.readFile(path.join(repoPath, '工作区', '细纲.md'), 'utf8')
+      要写到的事 = extractSection(outline, '本章要写到的事') || '(细纲未声明)'
+    } catch {
+      // 无细纲
+    }
+
+    // 事实切片:当前卷+上一卷时间线(精准片段)
+    const tl = await new TimelineReader(repoPath, cache).readVolumeRange(
+      Math.max(1, 当前卷 - 1),
+      当前卷
+    )
+    const 时间线md =
+      tl.ok && tl.timeline.length
+        ? tl.timeline.map((row) => `- ${row.章 ?? ''} ${row.一句话事件 ?? ''}`).join('\n')
+        : '(无)'
+
+    // 信息差边界(未揭晓,勿泄)
+    const secrets = await new SecretReader(repoPath, cache).listUnrevealed()
+    const 信息差md = secrets.length
+      ? secrets.map((s) => `- ${s.id}(读者未知,本章勿泄)`).join('\n')
+      : '(无)'
+
+    // 近章结尾(近 2 章末尾 150 字,反复读防接不上)
+    const recent = await cache.query(
+      'SELECT chapter_num FROM chapters ORDER BY chapter_num DESC LIMIT 2'
+    )
+    const reader = new ChapterReader(repoPath, cache)
+    const tails = []
+    for (const r of recent.reverse()) {
+      const t = await reader.readTail(r.chapter_num, 150)
+      tails.push(`### 第${r.chapter_num}章结尾\n${t.ok ? t.text : ''}`)
+    }
+
+    // 文风锚点 + 反和解(读文风铁律)
+    let 文风锚点 = '(无文风铁律)'
+    let 反和解 = ''
+    try {
+      const fl = await fs.readFile(path.join(repoPath, '文风', '文风铁律.md'), 'utf8')
+      const 铁律 = extractSection(fl, '铁律')
+      const 节奏 = extractSection(fl, '节奏偏好')
+      文风锚点 = [铁律 && `铁律:${铁律}`, 节奏 && `节奏偏好:${节奏}`].filter(Boolean).join('\n')
+      反和解 = extractSection(fl, '反和解')
+    } catch {
+      // 无文风铁律
+    }
+
+    const parts = [
+      `# 第 ${chapterNum} 章写作材料`,
+      '',
+      status.ok ? status.markdown : '## 全书近况\n(组装失败)',
+      '',
+      `## 本章要写到的事\n${要写到的事}`,
+      '',
+      `## 事实切片:时间线(当前卷+上一卷)\n${时间线md}`,
+      '',
+      `## 信息差边界(未揭晓,勿泄)\n${信息差md}`,
+      '',
+      `## 近章结尾\n${tails.join('\n\n') || '(无)'}`,
+      '',
+      `## 文风锚点\n${文风锚点}`,
+      '',
+      反和解 ? `## 反和解规则\n${反和解}` : '## 反和解规则\n(无)',
+      '',
+      '## 反复读清单\n(M2 暂空,跨章高频意象统计随 M3+ 体检补)',
+      '',
+    ]
+    const content = parts.join('\n')
+
+    const dir = path.join(repoPath, '工作区')
+    await fs.mkdir(dir, { recursive: true })
+    const filePath = path.join(dir, '本章写作材料.md')
+    await fs.writeFile(filePath, content, 'utf8')
+
+    return { ok: true, filePath, content, error: '' }
+  } catch (err) {
+    return { ok: false, filePath: '', content: '', error: `备料失败:${err.message}` }
+  }
+}

+ 21 - 1
v7/test/commands/_helper.js

@@ -1,7 +1,7 @@
 import path from 'node:path'
 import os from 'node:os'
 import { fileURLToPath } from 'node:url'
-import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { mkdtemp, mkdir, writeFile, rm, cp } from 'node:fs/promises'
 import { CacheManager } from '../../src/cache/index.js'
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -46,3 +46,23 @@ export async function repoCtx(repoPath, files) {
     },
   }
 }
+
+/**
+ * 把 sample-book fixture 整体拷到临时目录(可写),建 ctx。
+ * 用于会写入书仓库的流程(备料写本章写作材料、定稿写定稿/git),避免污染 committed fixture。
+ */
+export async function tempBookCtx() {
+  const tmpRepo = await mkdtemp(path.join(os.tmpdir(), 'wnw-book-'))
+  await cp(fixtureRoot, tmpRepo, { recursive: true })
+  const dbDir = await mkdtemp(path.join(os.tmpdir(), 'wnw-book-db-'))
+  const cache = new CacheManager(path.join(dbDir, 'index.db'))
+  await cache.ensureReady(tmpRepo)
+  return {
+    ctx: { repoPath: tmpRepo, cache },
+    cleanup: async () => {
+      await cache.close()
+      await rm(dbDir, { recursive: true, force: true })
+      await rm(tmpRepo, { recursive: true, force: true })
+    },
+  }
+}

+ 13 - 0
v7/test/fixtures/sample-book/工作区/细纲.md

@@ -0,0 +1,13 @@
+# 第 3 章细纲
+## 全书近况(脚本生成)
+- 位置:第 1 卷 2/40 章
+- 悬了太久:无
+- 连续弱钩:0 章
+## 本章提案
+本章定位:推进章。推进伏笔-001(林晚查到玉佩线索),结尾埋强钩。
+## 本章要写到的事(确认即生效)
+- [ ] 林晚查到玉佩的第一条线索
+- [ ] 伏笔-001 推进
+- [ ] 结尾强钩
+## 备选
+B 方案:本章纯感情线过渡(定位改"过渡章"),伏笔-001 推到下章。

+ 20 - 0
v7/test/fixtures/sample-book/文风/文风铁律.md

@@ -0,0 +1,20 @@
+---
+禁词:
+  - 眸子一缩
+  - 嘴角勾起一抹
+禁句式:
+  - '不是.*而是'
+口癖:
+  - 林晚:自称"本姑娘"
+---
+## 铁律
+节奏优先,不堆砌形容词;对话推动情节。
+
+## 反和解(按题材配浓度)
+反派恶意落到实处、冲突升级到底、禁说教式和解、禁主角圣母。
+
+## 节奏偏好
+三章一小高潮,十章一大转折。
+
+## 来自否决的规则
+- 不要用天气开篇(出处:第 89/103/121 章三次否决)

+ 26 - 0
v7/test/prep/book-status.test.js

@@ -0,0 +1,26 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { assembleBookStatus } from '../../src/prep/book-status.js'
+import { fixtureCtx } from '../commands/_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('assembleBookStatus 产结构化近况 + Markdown', async () => {
+  const r = await assembleBookStatus(ctx)
+  assert.equal(r.ok, true)
+  // fixture:2 章、卷 1、卷规模 40
+  assert.equal(r.data.当前卷, 1)
+  assert.equal(r.data.卷内进度.写到, 2)
+  assert.equal(r.data.卷内进度.卷规模, 40)
+  assert.equal(r.data.总章数, 2)
+  assert.equal(r.data.连续弱钩, 0)
+  assert.ok(Array.isArray(r.data.悬了太久))
+  assert.match(r.markdown, /第\s*1\s*卷/)
+  assert.match(r.markdown, /连续弱钩/)
+})

+ 29 - 0
v7/test/prep/prepare.test.js

@@ -0,0 +1,29 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { prepareChapterMaterials } from '../../src/prep/index.js'
+import { tempBookCtx } from '../commands/_helper.js'
+import { read } from '../storage/_tmprepo.js'
+
+test('prepareChapterMaterials 组装本章写作材料(八组件锚点)并写出', async () => {
+  const { ctx, cleanup } = await tempBookCtx()
+  try {
+    const r = await prepareChapterMaterials(ctx, { chapterNum: 3 })
+    assert.equal(r.ok, true)
+    const c = r.content
+    assert.match(c, /全书近况/)
+    assert.match(c, /第\s*1\s*卷/)
+    assert.match(c, /本章要写到的事/)
+    assert.match(c, /查到玉佩/) // 来自细纲
+    assert.match(c, /信息差边界/)
+    assert.match(c, /信息差-001/) // 未揭晓信息差,勿泄
+    assert.match(c, /文风锚点/)
+    assert.match(c, /节奏/) // 来自文风铁律
+    assert.match(c, /反和解/)
+    assert.match(c, /反派恶意/) // 来自文风铁律反和解段
+
+    const onDisk = await read(ctx.repoPath, '工作区/本章写作材料.md')
+    assert.match(onDisk, /第 3 章写作材料/)
+  } finally {
+    await cleanup()
+  }
+})