Просмотр исходного кода

feat(knowledge): 章级 schema 与切片收口——单读规划时、FM 移除毒点/适用题材

lingfengQAQ 2 месяцев назад
Родитель
Сommit
c9a6194583

+ 1 - 1
v7/src/knowledge/index.js

@@ -227,7 +227,7 @@ export async function readEntry(packageRoot, relPath) {
     来源版本: versionedPath(rel, raw),
     fm: fm.data,
     body: fm.body,
-    规划: extractSection(fm.body, '规划这一章时') || extractSection(fm.body, '规划时'),
+    规划: extractSection(fm.body, '规划时'),
     落笔时: extractSection(fm.body, '落笔时'),
     审稿时: extractSection(fm.body, '审稿时'),
   }

+ 1 - 1
v7/test/fixtures/sample-book/定稿/正文/0002-初遇.md

@@ -11,7 +11,7 @@
 本章要写到的事:
   - 林晚寻找玉佩线索
 知识选择:
-  - 场景|冲突对话
+  - 场景|真相揭露
   - 追读|悬念钩
   - 变体|由大师兄的异常反应递进玉佩谜题
 ---

+ 70 - 0
v7/test/knowledge/chapter.test.js

@@ -92,6 +92,76 @@ test('近期历史合并定稿章与批内前章,按章倒序且不读取额
   }
 })
 
+async function fs_rm(p) {
+  await fs.rm(p, { recursive: true, force: true })
+}
+
+// 端到端样例(目标 schema 夹具):声明 → 命中 → 章档案 → 近期历史 → 软降权
+test('细纲声明到近期软降权闭环:未选候选不持久化、自定义不阻断', async () => {
+  const { queryKnowledge } = await import('../../src/knowledge/index.js')
+  const pkg = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-chapter-e2e-pkg-'))
+  const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-chapter-e2e-repo-'))
+  try {
+    const entry = (dir, file, fmLines) => fs.writeFile(
+      path.join(pkg, 'references', dir, file),
+      ['---', ...fmLines, '---', '## 规划时', '', '选用判据。', '', '## 落笔时', '', '落笔动作。', '', '## 审稿时', '', '核对判据。'].join('\n'),
+      'utf8'
+    )
+    for (const dir of ['节拍', '场景', '技法', '追读']) {
+      await fs.mkdir(path.join(pkg, 'references', dir), { recursive: true })
+    }
+    await entry('节拍', 'PA-901-蓄压释放.md', ['编号: PA-901', '名称: 蓄压释放', '一句话: 先蓄压后释放的章内曲线', '关键词:', '  - 蓄压', '  - 释放'])
+    await entry('场景', '暗市交易.md', ['名称: 暗市交易', '一句话: 以非法交易为容器的多方博弈', '关键词:', '  - 暗市', '  - 交易'])
+    await entry('场景', '未选容器.md', ['名称: 未选容器', '一句话: 本章未选择的候选场景', '关键词:', '  - 未选'])
+    await entry('技法', '限制视角.md', ['名称: 限制视角', '类别: 视角', '作用层级: 场', '触发问题:', '  - 信息误导', '一句话: 只写视角人物能确认的信息'])
+    await entry('追读', '悬念压钩.md', ['名称: 悬念压钩', '一句话: 章尾留未解悬念压力', '关键词:', '  - 悬念'])
+
+    const e2eOutline = [
+      '## 本章提案',
+      '本章节拍:PA-901 蓄压释放',
+      '本章场景:暗市交易',
+      '本章技法:限制视角、自定义镜像独白',
+      '本章追读:悬念压钩',
+      '知识变体:暗市交易只写交易破裂后的追捕',
+    ].join('\n')
+
+    // 声明命中:正式条目精确命中,自定义原样保留且不阻断
+    const resolved = await resolveChapterDeclarations(pkg, e2eOutline)
+    assert.equal(resolved.selections.find((item) => item.声明.startsWith('PA-901')).条目.名称, '蓄压释放')
+    assert.equal(resolved.selections.find((item) => item.声明 === '自定义镜像独白').条目, null)
+
+    // 章档案平铺保存最终选择与真实变体;未选候选不持久化
+    const archive = await buildChapterKnowledgeArchive(pkg, e2eOutline)
+    assert.deepEqual(archive, [
+      '节拍|蓄压释放',
+      '场景|暗市交易',
+      '技法|限制视角',
+      '技法|自定义镜像独白',
+      '追读|悬念压钩',
+      '变体|暗市交易只写交易破裂后的追捕',
+    ])
+    assert.ok(!archive.some((item) => item.includes('未选容器')))
+
+    // 近期历史:批内前章视图 → queryKnowledge 软降权与重复提醒
+    const history = await readRecentChapterKnowledge(repoPath, {
+      before: 8,
+      stagedChapters: [{ 章号: 7, frontMatter: { 知识选择: archive } }],
+    })
+    assert.ok(history.some((item) => item.维度 === '场景' && item.名称 === '暗市交易' && item.章号 === 7))
+
+    const hits = await queryKnowledge(pkg, '场景', { 问题: '本章在暗市完成交易后被人追捕', 近期: history })
+    const used = hits.find((item) => item.名称 === '暗市交易')
+    assert.ok(used, '近期用过的合适候选仍保留')
+    assert.deepEqual(used.近期使用, [{ 章号: 7, 变体: '暗市交易只写交易破裂后的追捕' }])
+    assert.match(used.重复提醒, /软降权/)
+    const unused = hits.find((item) => item.名称 === '未选容器')
+    assert.ok(!unused || !unused.近期使用, '未选候选不产生使用记录')
+  } finally {
+    await fs_rm(pkg)
+    await fs_rm(repoPath)
+  }
+})
+
 test('归档以工作区确认细纲为准;细纲不存在时保留暂存 payload', async () => {
   const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'wnw-chapter-archive-'))
   try {

+ 21 - 2
v7/test/knowledge/index.test.js

@@ -113,18 +113,37 @@ test('readEntry 三节切片;文件缺失返回 null', async () => {
   assert.ok(e.规划.length > 0)
   assert.ok(e.落笔时.length > 0)
   assert.ok(e.审稿时.length > 0)
-  assert.ok(Array.isArray(e.fm.毒点) && e.fm.毒点.length > 0)
   assert.match(e.来源版本, /^场景\/拍卖会\.md@sha256:[0-9a-f]{64}$/)
   assert.equal(await readEntry(packageRoot, '场景/不存在.md'), null)
   assert.equal(await readEntry(packageRoot, '../README.md'), null)
 })
 
+test('readEntry 规划切片只认「规划时」标题,不再双读旧标题', async () => {
+  const tmp = await mkdtemp(path.join(os.tmpdir(), 'wnw-slice-title-'))
+  try {
+    const dir = path.join(tmp, 'references', '场景')
+    await mkdir(dir, { recursive: true })
+    await writeFile(
+      path.join(dir, '旧标题.md'),
+      ['---', '名称: 旧标题', '一句话: 使用旧规划标题的条目', '---', '## 规划这一章时', '', '旧内容。', '', '## 落笔时', '', '落笔。', '', '## 审稿时', '', '核对。'].join('\n'),
+      'utf8'
+    )
+    const e = await readEntry(tmp, '场景/旧标题.md')
+    assert.equal(e.规划, '', '旧标题「规划这一章时」不应再被读取')
+    assert.ok(e.落笔时.length > 0)
+  } finally {
+    await rm(tmp, { recursive: true, force: true })
+  }
+})
+
 test('listKnowledgeEntries:只读十维直接目录,不递归吞治理文档', async () => {
   const beats = await listKnowledgeEntries(packageRoot, '节拍')
   assert.ok(beats.length > 0)
   assert.ok(beats.every((entry) => entry.文件.startsWith('节拍/')))
   assert.deepEqual(await listKnowledgeEntries(packageRoot, '桥段'), [])
-  assert.deepEqual(await listKnowledgeEntries(packageRoot, '技法'), [])
+  const techniques = await listKnowledgeEntries(packageRoot, '技法')
+  assert.ok(techniques.length > 0)
+  assert.ok(techniques.every((entry) => entry.文件.startsWith('技法/')))
 })
 
 test('queryKnowledge:按真实问题机械命中、最多三条、近期只软降权提醒', async () => {

+ 7 - 1
v7/test/knowledge/pipeline.test.js

@@ -104,6 +104,9 @@ test('备料注入:契约常驻 + 声明命中切片 + 自定义声明降级
     assert.match(c, /爆发必须改写局面/)
     assert.match(c, /### 本章场景:拍卖会/)
     assert.match(c, /竞价过程写节点不写流水/)
+    // 只注入「落笔时」切片,不带规划/审稿切片
+    assert.doesNotMatch(c, /信息位排布三件套/)
+    assert.doesNotMatch(c, /核对是否存在逐口报价/)
     assert.match(c, /### 本章追读:悬念钩/)
     // 自定义声明降级:声明本身即知识
     assert.match(c, /### 本章场景:自定义梦境回廊\n(自定义选择,按细纲声明执行)/)
@@ -152,6 +155,9 @@ test('审稿输入:冻结契约 + 声明命中的审稿切片,不回读当
     assert.match(r.input.作品契约, /主角不得靠系统白给/)
     assert.ok(r.input.知识审查.some((item) => item.startsWith('【压抑蓄力爆发·核对】')))
     assert.ok(r.input.知识审查.some((item) => item.startsWith('【拍卖会·核对】')))
+    // 只注入「审稿时」切片,不带落笔/规划切片
+    assert.ok(!r.input.知识审查.some((item) => item.includes('竞价过程写节点不写流水')))
+    assert.ok(!r.input.知识审查.some((item) => item.includes('信息位排布三件套')))
     assert.ok(r.input.知识审查.some((item) => item.includes('【技法·自定义】')))
     assert.ok(r.input.知识审查.some((item) => item.includes('【知识变体】')))
     assert.equal(r.input.毒点清单, undefined)
@@ -282,7 +288,7 @@ test('序6 DTO:技法维度使用同一候选接口,并把规划切片交给
         '  - 信息误导',
         '一句话: 只给视角人物能确认的信息',
         '---',
-        '## 规划这一章时',
+        '## 规划时',
         '先列出视角人物知道与不知道的事实。',
         '## 落笔时',
         '不得越过视角人物认知补叙真相。',

+ 54 - 6
v7/test/references/format.test.js

@@ -7,14 +7,25 @@ import { parseFrontMatter } from '../../src/storage/parsers/front-matter.js'
 import { extractSection } from '../../src/util/markdown.js'
 import { KNOWLEDGE_FILTER_VALUES, loadRoutes } from '../../src/knowledge/index.js'
 import { CANONICAL_TOPIC_NAMES } from '../../src/knowledge/contract.js'
+import { LEGACY_CHAPTER_ENTRIES } from './legacy-chapter-entries.js'
 
 /**
- * 知识库格式校验(验收 A5):front matter 可解析、分维正确、节齐全、路由键唯一、
- * 内容纪律的机器可查部分(毒点非空于章级条目)。内容质量本身走人工审查关(A6-A8),不在此测。
+ * 知识库格式校验(验收 A5):front matter 可解析、分维正确、节齐全、路由键唯一。
+ * 章级四维按 design 3.1 目标 schema 校验;旧格式存量以 LEGACY_CHAPTER_ENTRIES 显式冻结,
+ * 随批次 2/3/5 清空。内容质量本身走人工审查关(A6-A8),不在此测。
  */
 
 const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../references')
-const 章级目录 = ['节拍', '追读', '场景']
+const 章级目录 = ['节拍', '场景', '技法', '追读']
+const 章级格式 = Object.freeze({
+  节拍: Object.freeze({ 字段: ['编号', '名称', '一句话', '关键词'] }),
+  场景: Object.freeze({ 字段: ['名称', '一句话', '关键词'] }),
+  技法: Object.freeze({ 字段: ['名称', '类别', '作用层级', '触发问题', '一句话'] }),
+  追读: Object.freeze({ 字段: ['名称', '一句话', '关键词'] }),
+})
+const 技法类别受控值 = Object.freeze(['对白', '描写', '视角', '信息', '情感', '结构', '战斗', '设定执行'])
+const 技法作用层级受控值 = Object.freeze(['书', '卷', '跨章', '章', '场'])
+const 章级禁用字段 = /毒点:|适用题材:|正例:|反例:|来源:|状态:|适用技能:|大模型指令:/u
 const 书级格式 = Object.freeze({
   题材: Object.freeze({ 字段: ['名称'], 小节: ['核心承诺', '选择边界', '本书化问题', '失败模式'] }),
   流派: Object.freeze({ 字段: ['名称'], 小节: ['推进引擎', '组合边界', '本书化问题', '失败模式'] }),
@@ -74,20 +85,57 @@ test('故事对象条目只保留最小索引字段和规划时切片', async ()
   }
 })
 
-test('章级条目三节齐全(规划这一章时/落笔时/审稿时)且毒点非空', async () => {
+test('章级条目三切片齐全(规划时/落笔时/审稿时),空目录不阻断', async () => {
   for (const dir of 章级目录) {
     for (const f of await mdFiles(dir)) {
       const raw = await fs.readFile(path.join(ROOT, dir, f), 'utf8')
       const fm = parseFrontMatter(raw)
-      for (const sec of ['规划这一章时', '落笔时', '审稿时']) {
+      for (const sec of ['规划时', '落笔时', '审稿时']) {
         assert.ok(extractSection(fm.body, sec), `${dir}/${f} 缺「${sec}」节`)
       }
-      assert.ok(Array.isArray(fm.data.毒点) && fm.data.毒点.length > 0, `${dir}/${f} 毒点为空`)
+      assert.equal(extractSection(fm.body, '规划这一章时'), '', `${dir}/${f} 残留旧标题「规划这一章时」`)
       assert.ok(fm.data.一句话, `${dir}/${f} 缺 一句话(紧凑索引展示用)`)
     }
   }
 })
 
+test('章级旧格式冻结清单只减不增,且不含清单外文件', async () => {
+  for (const dir of 章级目录) {
+    const files = new Set(await mdFiles(dir))
+    for (const legacy of LEGACY_CHAPTER_ENTRIES[dir]) {
+      assert.ok(files.has(legacy), `${dir}/${legacy} 已不存在,须同步从 LEGACY_CHAPTER_ENTRIES 移除`)
+    }
+  }
+  // 批次 2/3/5 完成后对应维度清单应清空;此断言防止收口后清单被遗忘
+  assert.deepEqual(LEGACY_CHAPTER_ENTRIES.技法, [], '技法从空目录起步,不允许旧格式条目')
+})
+
+test('章级条目 front matter 只含目标 schema 字段,禁用字段与「全部」占位不得回流', async () => {
+  for (const dir of 章级目录) {
+    const legacy = new Set(LEGACY_CHAPTER_ENTRIES[dir])
+    for (const f of await mdFiles(dir)) {
+      if (legacy.has(f)) continue // 批次 2/3/5 待迁移存量,迁移后从清单删除即受本测试约束
+      const raw = await fs.readFile(path.join(ROOT, dir, f), 'utf8')
+      const fm = parseFrontMatter(raw)
+      assert.deepEqual(
+        Object.keys(fm.data).sort(),
+        [...章级格式[dir].字段].sort(),
+        `${dir}/${f} front matter 与目标 schema 不符`
+      )
+      assert.doesNotMatch(raw, 章级禁用字段, `${dir}/${f} 含已删除的无消费者字段`)
+      for (const [field, value] of Object.entries(fm.data)) {
+        const values = Array.isArray(value) ? value : [value]
+        assert.ok(values.length > 0 && values.every((item) => String(item).trim()), `${dir}/${f} 字段 ${field} 为空`)
+        assert.ok(!values.map(String).includes('全部'), `${dir}/${f} 字段 ${field} 不得使用“全部”占位`)
+      }
+      if (dir === '技法') {
+        assert.ok(技法类别受控值.includes(String(fm.data.类别)), `${dir}/${f} 类别「${fm.data.类别}」不在受控值中`)
+        assert.ok(技法作用层级受控值.includes(String(fm.data.作用层级)), `${dir}/${f} 作用层级「${fm.data.作用层级}」不在受控值中`)
+      }
+    }
+  }
+})
+
 test('书级条目只保留有消费者的字段,并按维度提供四个调用小节', async () => {
   for (const dir of 书级目录) {
     for (const f of await mdFiles(dir)) {

+ 11 - 0
v7/test/references/legacy-chapter-entries.js

@@ -0,0 +1,11 @@
+/**
+ * 批次 1 冻结的章级旧格式存量清单(front matter 尚未迁到目标 schema)。
+ * 目标 schema 见 design 3.1;本清单随批次 2(节拍)/ 3(场景)/ 5(追读)逐批清空,
+ * 清单外的任何章级条目立即按目标 schema 严格校验。禁止向本清单新增文件。
+ */
+export const LEGACY_CHAPTER_ENTRIES = Object.freeze({
+  节拍: Object.freeze([]),
+  场景: Object.freeze([]),
+  追读: Object.freeze([]),
+  技法: Object.freeze([]),
+})