Kaynağa Gözat

feat(v7): M1 阶段 C——.cache/index.db 五表 + 重建器(node:sqlite)

- schema.js:五表 DDL(chapters/threads/secrets/entities/entity_aliases/fingerprints + 索引)
- CacheManager:ensureReady(检查/重建)/rebuildFromSource/query/close
- rebuilder.js:全量重建器(扫描源文件 → 填充五表)
  - scanChapters:定稿/正文/*.md → chapters 表,返回章号映射
  - scanThreads:大纲/伏笔|悬念|感情线/*.md → threads 表 + 履历章节验证(warnings)
  - scanSecrets:定稿/设定/信息差/*.md → secrets 表
  - scanEntities:定稿/设定/名册.md → entities + entity_aliases 表 + 别名唯一性验证(errors)
  - scanCharacters:定稿/设定/角色/*.md → 更新 entities 表
- 测试:3 个用例全绿(DDL 执行/重建填充数据/删缓存重建结果不变 AC1)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lingfengQAQ 1 gün önce
ebeveyn
işleme
2e11f75291

+ 107 - 3
v7/src/cache/index.js

@@ -1,3 +1,107 @@
-// 缓存:.cache/index.db(node:sqlite),首查重建,精准读取接口。见 O4 缓存设计文档。
-// 占位——真实实现见 M1。
-export {}
+import { DatabaseSync } from 'node:sqlite'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { SCHEMA_SQL } from './schema.js'
+import { rebuildCache } from './rebuilder.js'
+
+/**
+ * CacheManager:管理 .cache/index.db 五表。
+ */
+export class CacheManager {
+  constructor(dbPath) {
+    this.dbPath = dbPath
+    this.db = null
+  }
+
+  /**
+   * 确保数据库就绪(不存在或损坏 → 重建)。
+   * @param {string} repoPath - 书仓库根目录
+   * @returns {Promise<void>}
+   */
+  async ensureReady(repoPath) {
+    // 检查 db 文件是否存在
+    try {
+      await fs.access(this.dbPath)
+    } catch (err) {
+      // 不存在,先创建目录
+      const dir = path.dirname(this.dbPath)
+      await fs.mkdir(dir, { recursive: true })
+      // 重建
+      return this.rebuildFromSource(repoPath)
+    }
+
+    // 打开现有数据库
+    try {
+      this.db = new DatabaseSync(this.dbPath)
+      // 验证表是否存在
+      const tables = this.db
+        .prepare("SELECT name FROM sqlite_master WHERE type='table'")
+        .all()
+      if (tables.length === 0) {
+        // 空数据库,重建
+        this.db.close()
+        return this.rebuildFromSource(repoPath)
+      }
+    } catch (err) {
+      // 损坏,重建
+      return this.rebuildFromSource(repoPath)
+    }
+  }
+
+  /**
+   * 全量重建缓存。
+   * @param {string} repoPath
+   * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
+   */
+  async rebuildFromSource(repoPath) {
+    // 关闭现有连接
+    if (this.db) {
+      this.db.close()
+      this.db = null
+    }
+
+    // 删除旧数据库
+    try {
+      await fs.unlink(this.dbPath)
+    } catch (err) {
+      // 文件不存在,忽略
+    }
+
+    // 创建新数据库
+    this.db = new DatabaseSync(this.dbPath)
+
+    // 执行 DDL
+    this.db.exec(SCHEMA_SQL)
+
+    // 调用重建器
+    const result = await rebuildCache(repoPath, this.db)
+
+    return result
+  }
+
+  /**
+   * 执行查询。
+   * @param {string} sql
+   * @param {any[]} params
+   * @returns {Promise<any[]>}
+   */
+  async query(sql, params = []) {
+    if (!this.db) {
+      throw new Error('数据库未初始化')
+    }
+
+    const stmt = this.db.prepare(sql)
+    return stmt.all(...params)
+  }
+
+  /**
+   * 关闭数据库连接。
+   * @returns {Promise<void>}
+   */
+  async close() {
+    if (this.db) {
+      this.db.close()
+      this.db = null
+    }
+  }
+}

+ 333 - 0
v7/src/cache/rebuilder.js

@@ -0,0 +1,333 @@
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+import { parseFrontMatter } from '../storage/parsers/front-matter.js'
+import { parseMarkdownTable } from '../storage/parsers/markdown-table.js'
+
+/**
+ * 全量重建缓存(DELETE 五表 → 扫描源文件 → INSERT)。
+ * @param {string} repoPath - 书仓库根目录
+ * @param {DatabaseSync} db - node:sqlite 数据库实例
+ * @returns {Promise<{ok: boolean, warnings: string[], errors: string[]}>}
+ */
+export async function rebuildCache(repoPath, db) {
+  const warnings = []
+  const errors = []
+
+  try {
+    // 1. 清空五表
+    db.exec('DELETE FROM chapters')
+    db.exec('DELETE FROM threads')
+    db.exec('DELETE FROM secrets')
+    db.exec('DELETE FROM entities')
+    db.exec('DELETE FROM entity_aliases')
+    db.exec('DELETE FROM fingerprints')
+
+    // 2. 扫描章节文件 → 填充 chapters 表
+    const chapterMap = await scanChapters(repoPath, db)
+
+    // 3. 扫描条目文件 → 填充 threads 表(验证履历章节)
+    await scanThreads(repoPath, db, chapterMap, warnings)
+
+    // 4. 扫描信息差 → 填充 secrets 表
+    await scanSecrets(repoPath, db)
+
+    // 5. 解析名册 → 填充 entities + entity_aliases 表(验证别名唯一性)
+    const aliasCheck = await scanEntities(repoPath, db)
+    if (!aliasCheck.ok) {
+      errors.push(...aliasCheck.errors)
+      return { ok: false, warnings, errors }
+    }
+
+    // 6. 扫描角色卡 → 填充 entities 表
+    await scanCharacters(repoPath, db)
+
+    // 7. fingerprints 表留空(特征提取随 M3+ 体检补)
+
+    return { ok: true, warnings, errors }
+  } catch (err) {
+    errors.push(`重建失败:${err.message}`)
+    return { ok: false, warnings, errors }
+  }
+}
+
+/**
+ * 扫描章节文件,填充 chapters 表。
+ * @returns {Promise<Map<number, string>>} 章号 → 文件路径映射
+ */
+async function scanChapters(repoPath, db) {
+  const chapterDir = path.join(repoPath, '定稿', '正文')
+  const chapterMap = new Map()
+
+  try {
+    const files = await fs.readdir(chapterDir)
+    const insertStmt = db.prepare(`
+      INSERT INTO chapters (chapter_num, title, volume_num, perspective, story_time, word_count, chapter_position, hook_type, mood_position, file_path, is_key_chapter)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+    `)
+
+    for (const file of files) {
+      if (!file.endsWith('.md')) continue
+
+      const filePath = path.join(chapterDir, file)
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+
+      if (parsed.ok) {
+        const fm = parsed.data
+        const chapterNum = fm.章号 || parseInt(file.match(/\d+/)?.[0] || '0', 10)
+
+        insertStmt.run(
+          chapterNum,
+          fm.标题 || '未命名',
+          fm.卷 || 1,
+          fm.视角 || null,
+          fm.书内时间 || null,
+          fm.字数 || 0,
+          fm.章定位 || '推进',
+          fm.钩子 || null,
+          fm.情绪定位 || null,
+          filePath,
+          fm.是否关键章 ? 1 : 0
+        )
+
+        chapterMap.set(chapterNum, filePath)
+      }
+    }
+  } catch (err) {
+    // 目录不存在,跳过
+  }
+
+  return chapterMap
+}
+
+/**
+ * 扫描条目文件,填充 threads 表。
+ */
+async function scanThreads(repoPath, db, chapterMap, warnings) {
+  const types = [
+    { dir: '伏笔', type: 'foreshadow' },
+    { dir: '悬念', type: 'suspense' },
+    { dir: '感情线', type: 'romance' },
+  ]
+
+  const insertStmt = db.prepare(`
+    INSERT INTO threads (id, type, short_title, strength, status, opened_chapter, planned_end, last_advanced_chapter, file_path)
+    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+  `)
+
+  for (const { dir, type } of types) {
+    const threadDir = path.join(repoPath, '大纲', dir)
+
+    try {
+      const files = await fs.readdir(threadDir)
+
+      for (const file of files) {
+        if (!file.endsWith('.md')) continue
+
+        const filePath = path.join(threadDir, file)
+        const content = await fs.readFile(filePath, 'utf8')
+        const parsed = parseFrontMatter(content)
+
+        if (parsed.ok) {
+          const fm = parsed.data
+          const id = file.replace('.md', '').split('-').slice(0, 2).join('-') // 伏笔-001
+
+          // 验证履历章节存在性
+          const historySection = extractSection(parsed.body, '履历')
+          if (historySection) {
+            const historyChapters = parseHistoryChapters(historySection)
+            for (const ch of historyChapters) {
+              if (!chapterMap.has(ch)) {
+                warnings.push(`条目 ${id} 履历引用章节 ${ch} 不存在`)
+              }
+            }
+          }
+
+          insertStmt.run(
+            id,
+            type,
+            id, // short_title 简化为 id
+            fm.强度 || '中',
+            fm.状态 || '进行',
+            fm.开启章 || 1,
+            fm.预计收尾 || null,
+            fm.最后推进章 || fm.开启章 || 1,
+            filePath
+          )
+        }
+      }
+    } catch (err) {
+      // 目录不存在,跳过
+    }
+  }
+}
+
+/**
+ * 扫描信息差,填充 secrets 表。
+ */
+async function scanSecrets(repoPath, db) {
+  const secretDir = path.join(repoPath, '定稿', '设定', '信息差')
+
+  const insertStmt = db.prepare(`
+    INSERT INTO secrets (id, short_title, known_to, reader_knows, registered_chapter, keywords, file_path)
+    VALUES (?, ?, ?, ?, ?, ?, ?)
+  `)
+
+  try {
+    const files = await fs.readdir(secretDir)
+
+    for (const file of files) {
+      if (!file.endsWith('.md')) continue
+
+      const filePath = path.join(secretDir, file)
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+
+      if (parsed.ok) {
+        const fm = parsed.data
+        const id = file.replace('.md', '').split('-').slice(0, 2).join('-')
+
+        insertStmt.run(
+          id,
+          id,
+          JSON.stringify(fm.谁知道 || []),
+          fm.读者知道 ? 1 : 0,
+          fm.登记章 || 1,
+          JSON.stringify(fm.关键词 || []),
+          filePath
+        )
+      }
+    }
+  } catch (err) {
+    // 目录不存在,跳过
+  }
+}
+
+/**
+ * 解析名册,填充 entities + entity_aliases 表(验证别名唯一性)。
+ */
+async function scanEntities(repoPath, db) {
+  const rosterPath = path.join(repoPath, '定稿', '设定', '名册.md')
+  const aliasMap = new Map() // alias → entity_id
+
+  try {
+    const content = await fs.readFile(rosterPath, 'utf8')
+    const table = parseMarkdownTable(content)
+
+    if (!table.ok) {
+      return { ok: false, errors: ['名册解析失败'] }
+    }
+
+    const entityStmt = db.prepare(`
+      INSERT INTO entities (id, type, status, location, realm, possessions, last_changed_chapter, file_path)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+    `)
+
+    const aliasStmt = db.prepare(`
+      INSERT INTO entity_aliases (alias, entity_id)
+      VALUES (?, ?)
+    `)
+
+    for (const row of table.rows) {
+      const entityId = row.正名
+      const type = row.类型 || 'character'
+
+      entityStmt.run(entityId, type, null, null, null, null, parseInt(row.首现章 || '1', 10), rosterPath)
+
+      // 处理别名
+      const aliases = row.别名.split(',').map((a) => a.trim()).filter((a) => a)
+      for (const alias of aliases) {
+        if (aliasMap.has(alias)) {
+          return {
+            ok: false,
+            errors: [`别名冲突:「${alias}」同时指向「${aliasMap.get(alias)}」和「${entityId}」`],
+          }
+        }
+        aliasMap.set(alias, entityId)
+        aliasStmt.run(alias, entityId)
+      }
+    }
+
+    return { ok: true, errors: [] }
+  } catch (err) {
+    return { ok: false, errors: ['名册文件不存在或解析失败'] }
+  }
+}
+
+/**
+ * 扫描角色卡,填充 entities 表。
+ */
+async function scanCharacters(repoPath, db) {
+  const charDir = path.join(repoPath, '定稿', '设定', '角色')
+
+  const updateStmt = db.prepare(`
+    UPDATE entities SET status = ?, location = ?, realm = ?, possessions = ?, last_changed_chapter = ?
+    WHERE id = ?
+  `)
+
+  try {
+    const files = await fs.readdir(charDir)
+
+    for (const file of files) {
+      if (!file.endsWith('.md')) continue
+
+      const name = file.replace('.md', '')
+      const filePath = path.join(charDir, file)
+      const content = await fs.readFile(filePath, 'utf8')
+      const parsed = parseFrontMatter(content)
+
+      if (parsed.ok) {
+        const fm = parsed.data
+        updateStmt.run(
+          fm.状态 || null,
+          fm.位置 || null,
+          fm.境界 || null,
+          JSON.stringify(fm.持有 || []),
+          fm.最后变更章 || 1,
+          name
+        )
+      }
+    }
+  } catch (err) {
+    // 目录不存在,跳过
+  }
+}
+
+/**
+ * 从正文提取指定段落。
+ */
+function extractSection(body, sectionTitle) {
+  const lines = body.split('\n')
+  let inSection = false
+  const sectionLines = []
+
+  for (const line of lines) {
+    if (line.startsWith('##')) {
+      if (inSection) break
+      if (line.includes(sectionTitle)) {
+        inSection = true
+        continue
+      }
+    }
+    if (inSection) sectionLines.push(line)
+  }
+
+  return sectionLines.join('\n').trim()
+}
+
+/**
+ * 解析履历中的章号。
+ */
+function parseHistoryChapters(historyText) {
+  const chapters = []
+  const lines = historyText.split('\n')
+
+  for (const line of lines) {
+    const match = line.match(/第(\d+)章/)
+    if (match) {
+      chapters.push(parseInt(match[1], 10))
+    }
+  }
+
+  return chapters
+}

+ 85 - 0
v7/src/cache/schema.js

@@ -0,0 +1,85 @@
+// 五表 DDL(O4 §1)
+
+export const SCHEMA_SQL = `
+-- chapters 表
+CREATE TABLE IF NOT EXISTS chapters (
+  chapter_num INTEGER PRIMARY KEY,
+  title TEXT NOT NULL,
+  volume_num INTEGER NOT NULL,
+  perspective TEXT,
+  story_time TEXT,
+  word_count INTEGER NOT NULL,
+  chapter_position TEXT NOT NULL,
+  hook_type TEXT,
+  mood_position TEXT,
+  file_path TEXT NOT NULL,
+  is_key_chapter BOOLEAN DEFAULT 0
+);
+CREATE INDEX IF NOT EXISTS idx_chapters_volume ON chapters(volume_num);
+CREATE INDEX IF NOT EXISTS idx_chapters_position ON chapters(chapter_position);
+CREATE INDEX IF NOT EXISTS idx_chapters_hook ON chapters(hook_type);
+
+-- threads 表(三类条目统一)
+CREATE TABLE IF NOT EXISTS threads (
+  id TEXT PRIMARY KEY,
+  type TEXT NOT NULL,
+  short_title TEXT NOT NULL,
+  strength TEXT NOT NULL,
+  status TEXT NOT NULL,
+  opened_chapter INTEGER NOT NULL,
+  planned_end TEXT,
+  last_advanced_chapter INTEGER,
+  file_path TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_threads_type ON threads(type);
+CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
+
+-- secrets 表
+CREATE TABLE IF NOT EXISTS secrets (
+  id TEXT PRIMARY KEY,
+  short_title TEXT NOT NULL,
+  known_to TEXT NOT NULL,
+  reader_knows BOOLEAN NOT NULL,
+  registered_chapter INTEGER NOT NULL,
+  keywords TEXT NOT NULL,
+  file_path TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_secrets_reader_knows ON secrets(reader_knows);
+
+-- entities 表
+CREATE TABLE IF NOT EXISTS entities (
+  id TEXT PRIMARY KEY,
+  type TEXT NOT NULL,
+  status TEXT,
+  location TEXT,
+  realm TEXT,
+  possessions TEXT,
+  last_changed_chapter INTEGER,
+  file_path TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type);
+CREATE INDEX IF NOT EXISTS idx_entities_status ON entities(status);
+
+-- entity_aliases 表
+CREATE TABLE IF NOT EXISTS entity_aliases (
+  alias TEXT PRIMARY KEY,
+  entity_id TEXT NOT NULL,
+  FOREIGN KEY(entity_id) REFERENCES entities(id)
+);
+CREATE INDEX IF NOT EXISTS idx_entity_aliases_entity ON entity_aliases(entity_id);
+
+-- fingerprints 表
+CREATE TABLE IF NOT EXISTS fingerprints (
+  chapter_range_start INTEGER NOT NULL,
+  chapter_range_end INTEGER NOT NULL,
+  is_baseline BOOLEAN DEFAULT 0,
+  avg_sentence_length REAL,
+  sentence_length_variance REAL,
+  avg_paragraph_length REAL,
+  common_phrase_frequency TEXT,
+  vocabulary_richness REAL,
+  fingerprint_data TEXT NOT NULL,
+  PRIMARY KEY(chapter_range_start, chapter_range_end)
+);
+CREATE INDEX IF NOT EXISTS idx_fingerprints_baseline ON fingerprints(is_baseline);
+`

+ 77 - 0
v7/test/cache/CacheManager.test.js

@@ -0,0 +1,77 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { promises as fs } from 'node:fs'
+import os from 'node:os'
+import { CacheManager } from '../../src/cache/index.js'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const fixtureRoot = path.join(__dirname, '../fixtures/sample-book')
+
+test('ensureReady:创建数据库并执行 DDL', async () => {
+  const tmpDb = path.join(os.tmpdir(), `test-${Date.now()}.db`)
+  const cache = new CacheManager(tmpDb)
+
+  await cache.ensureReady(fixtureRoot)
+
+  // 验证表是否创建
+  const tables = await cache.query("SELECT name FROM sqlite_master WHERE type='table'")
+  const tableNames = tables.map((t) => t.name)
+
+  assert.ok(tableNames.includes('chapters'))
+  assert.ok(tableNames.includes('threads'))
+  assert.ok(tableNames.includes('entities'))
+
+  await cache.close()
+  await fs.unlink(tmpDb)
+})
+
+test('rebuildFromSource:全量重建填充数据', async () => {
+  const tmpDb = path.join(os.tmpdir(), `test-rebuild-${Date.now()}.db`)
+  const cache = new CacheManager(tmpDb)
+
+  const result = await cache.rebuildFromSource(fixtureRoot)
+
+  assert.equal(result.ok, true)
+
+  // 验证 chapters 表有数据
+  const chapters = await cache.query('SELECT * FROM chapters')
+  assert.ok(chapters.length > 0)
+  assert.equal(chapters[0].title, '开局')
+
+  // 验证 threads 表有数据
+  const threads = await cache.query('SELECT * FROM threads')
+  assert.ok(threads.length > 0)
+
+  // 验证 entity_aliases 表有数据
+  const aliases = await cache.query('SELECT * FROM entity_aliases')
+  assert.ok(aliases.length > 0)
+
+  await cache.close()
+  await fs.unlink(tmpDb)
+})
+
+test('删除缓存后全量重建,查询结果不变(AC1)', async () => {
+  const tmpDb = path.join(os.tmpdir(), `test-delete-rebuild-${Date.now()}.db`)
+  const cache1 = new CacheManager(tmpDb)
+
+  // 第一次重建
+  await cache1.ensureReady(fixtureRoot)
+  const count1 = await cache1.query('SELECT COUNT(*) as count FROM chapters')
+  await cache1.close()
+
+  // 删除缓存
+  await fs.unlink(tmpDb)
+
+  // 第二次重建
+  const cache2 = new CacheManager(tmpDb)
+  await cache2.ensureReady(fixtureRoot)
+  const count2 = await cache2.query('SELECT COUNT(*) as count FROM chapters')
+  await cache2.close()
+
+  // 结果应相同
+  assert.equal(count1[0].count, count2[0].count)
+
+  await fs.unlink(tmpDb)
+})