Bläddra i källkod

feat(v7): M1 重建 R4b——P2 报表接口真实现 + 测试(41 接口全实现)

- report-secret-accumulation:未揭晓信息差 + 查询时算蓄积章数,按蓄积降序
- report-thread-activity --卷=N:卷→章范围→本卷开/推进/收尾条目(收尾以
  "已收尾且最后推进落在本卷"近似,threads 无 closed_chapter,注明 M2 可精确)
- report-style-drift:M1 边界——读 fingerprints 对比基线,空表返回友好中文错误;
  不实现特征提取(M3+ 体检补)。测试手工插基线+最近指纹验证对比逻辑

至此 O4 §2 的 41 个精准读取接口全部真实现,命令层逐条有测试(AC2)。
lingfengQAQ 19 timmar sedan
förälder
incheckning
0cb9e73063

+ 17 - 0
v7/src/commands/report-secret-accumulation.js

@@ -0,0 +1,17 @@
+/**
+ * report-secret-accumulation → 未揭晓信息差 + 蓄积章数(按蓄积降序)
+ * 蓄积章数 = 当前最大章号 − 登记章(派生值不物化,查询时算)。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const rows = await ctx.cache.query(
+    'SELECT id, short_title, registered_chapter FROM secrets WHERE reader_knows = 0'
+  )
+  const maxRows = await ctx.cache.query('SELECT MAX(chapter_num) AS m FROM chapters')
+  const max = maxRows[0]?.m || 0
+
+  const out = rows
+    .map((s) => ({ id: s.id, short_title: s.short_title, 蓄积章数: max - s.registered_chapter }))
+    .sort((a, b) => b.蓄积章数 - a.蓄积章数)
+  return { ok: true, output: JSON.stringify(out, null, 2) }
+}

+ 31 - 0
v7/src/commands/report-style-drift.js

@@ -0,0 +1,31 @@
+/**
+ * report-style-drift → 当前指纹 vs 基线的差异
+ * M1 边界:能读 fingerprints 表并对比基线,但不实现特征提取(表由 M3+ 体检填充)。
+ * 表为空 → 返回友好中文错误。契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const baseline = await ctx.cache.query(
+    'SELECT * FROM fingerprints WHERE is_baseline = 1 ORDER BY chapter_range_end DESC LIMIT 1'
+  )
+  const recent = await ctx.cache.query(
+    'SELECT * FROM fingerprints WHERE is_baseline = 0 ORDER BY chapter_range_end DESC LIMIT 1'
+  )
+
+  if (baseline.length === 0 || recent.length === 0) {
+    return {
+      ok: false,
+      error: '缺少指纹数据:fingerprints 表为空或不全,请先运行体检以提取文体特征(M1 不实现特征提取)。',
+    }
+  }
+
+  const b = baseline[0]
+  const r = recent[0]
+  const drift = {
+    基线章段: [b.chapter_range_start, b.chapter_range_end],
+    最近章段: [r.chapter_range_start, r.chapter_range_end],
+    avg_sentence_length_delta: (r.avg_sentence_length ?? 0) - (b.avg_sentence_length ?? 0),
+    avg_paragraph_length_delta: (r.avg_paragraph_length ?? 0) - (b.avg_paragraph_length ?? 0),
+    vocabulary_richness_delta: (r.vocabulary_richness ?? 0) - (b.vocabulary_richness ?? 0),
+  }
+  return { ok: true, output: JSON.stringify(drift, null, 2) }
+}

+ 43 - 0
v7/src/commands/report-thread-activity.js

@@ -0,0 +1,43 @@
+/**
+ * report-thread-activity --卷=N → 本卷开 / 本卷推进 / 本卷收尾 条目清单
+ * 卷 → 章号范围(查 chapters),再按 opened/last_advanced 落在范围内归类。
+ * 注:threads 无 closed_chapter,"本卷收尾"以"已收尾且最后推进落在本卷"近似(M2 增量更新后可精确)。
+ * 契约:纯返回 {ok, output?, error?}(见 design §6.2)。
+ */
+export async function run(args, options, ctx) {
+  const vol = options['卷'] && options['卷'] !== true ? parseInt(options['卷'], 10) : null
+  if (!vol) {
+    return { ok: false, error: '请用 --卷=N 指定卷号' }
+  }
+
+  const range = await ctx.cache.query(
+    'SELECT MIN(chapter_num) AS s, MAX(chapter_num) AS e FROM chapters WHERE volume_num = ?',
+    [vol]
+  )
+  const s = range[0]?.s
+  const e = range[0]?.e
+  if (s == null) {
+    return {
+      ok: true,
+      output: JSON.stringify({ 卷: vol, 本卷开: [], 本卷推进: [], 本卷收尾: [] }, null, 2),
+    }
+  }
+
+  const opened = await ctx.cache.query(
+    'SELECT id, type, short_title FROM threads WHERE opened_chapter BETWEEN ? AND ? ORDER BY id',
+    [s, e]
+  )
+  const advanced = await ctx.cache.query(
+    'SELECT id, type, short_title FROM threads WHERE last_advanced_chapter BETWEEN ? AND ? ORDER BY id',
+    [s, e]
+  )
+  const closed = await ctx.cache.query(
+    "SELECT id, type, short_title FROM threads WHERE status = '已收尾' AND last_advanced_chapter BETWEEN ? AND ? ORDER BY id",
+    [s, e]
+  )
+
+  return {
+    ok: true,
+    output: JSON.stringify({ 卷: vol, 本卷开: opened, 本卷推进: advanced, 本卷收尾: closed }, null, 2),
+  }
+}

+ 21 - 0
v7/test/commands/report-secret-accumulation.test.js

@@ -0,0 +1,21 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/report-secret-accumulation.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('report-secret-accumulation 返回未揭晓信息差 + 蓄积章数', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, true)
+  const rows = JSON.parse(r.output)
+  const one = rows.find((s) => s.id === '信息差-001')
+  assert.ok(one, `应含 信息差-001,实际:${r.output}`)
+  assert.equal(one.蓄积章数, 1) // max 2 − 登记章 1
+})

+ 34 - 0
v7/test/commands/report-style-drift.test.js

@@ -0,0 +1,34 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/report-style-drift.js'
+import { fixtureCtx } from './_helper.js'
+
+test('report-style-drift 无指纹数据 → 友好错误(M1 边界,不做特征提取)', async () => {
+  const { ctx, cleanup } = await fixtureCtx()
+  try {
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, false)
+    assert.match(r.error, /指纹|体检/)
+  } finally {
+    await cleanup()
+  }
+})
+
+test('report-style-drift 有基线+最近指纹 → 返回差异(测对比逻辑)', async () => {
+  const { ctx, cleanup } = await fixtureCtx()
+  try {
+    // M1 不做特征提取,手工插入基线 + 最近指纹,验证对比逻辑
+    await ctx.cache.query(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, vocabulary_richness, fingerprint_data) VALUES (1, 30, 1, 20.0, 0.5, '{}')"
+    )
+    await ctx.cache.query(
+      "INSERT INTO fingerprints (chapter_range_start, chapter_range_end, is_baseline, avg_sentence_length, vocabulary_richness, fingerprint_data) VALUES (31, 40, 0, 25.0, 0.6, '{}')"
+    )
+    const r = await run([], {}, ctx)
+    assert.equal(r.ok, true)
+    const drift = JSON.parse(r.output)
+    assert.ok(Math.abs(drift.avg_sentence_length_delta - 5.0) < 1e-9)
+  } finally {
+    await cleanup()
+  }
+})

+ 27 - 0
v7/test/commands/report-thread-activity.test.js

@@ -0,0 +1,27 @@
+import { test, before, after } from 'node:test'
+import assert from 'node:assert/strict'
+import { run } from '../../src/commands/report-thread-activity.js'
+import { fixtureCtx } from './_helper.js'
+
+let ctx, cleanup
+before(async () => {
+  ;({ ctx, cleanup } = await fixtureCtx())
+})
+after(async () => {
+  await cleanup()
+})
+
+test('report-thread-activity --卷=1 返回本卷开/推进/收尾', async () => {
+  const r = await run([], { 卷: '1' }, ctx)
+  assert.equal(r.ok, true)
+  const rep = JSON.parse(r.output)
+  assert.equal(rep.卷, 1)
+  const openedIds = rep.本卷开.map((t) => t.id)
+  assert.ok(openedIds.includes('伏笔-001')) // 开启章 1 在第1卷范围
+  assert.deepEqual(rep.本卷收尾, []) // fixture 无已收尾
+})
+
+test('report-thread-activity 缺 --卷 → ok=false', async () => {
+  const r = await run([], {}, ctx)
+  assert.equal(r.ok, false)
+})