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

feat(core): add outline-paths helper for Phase 5 双路径 fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fanghanjun 6 месяцев назад
Родитель
Сommit
21ed8f9534

+ 151 - 0
packages/core/src/__tests__/outline-paths.test.ts

@@ -0,0 +1,151 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import {
+  readStoryFrame,
+  readVolumeMap,
+  readRoleCards,
+  readCharacterContext,
+  readCurrentStateWithFallback,
+  isNewLayoutBook,
+  isCurrentStateSeedPlaceholder,
+} from "../utils/outline-paths.js";
+
+describe("outline-paths", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-test-book-"));
+    await mkdir(join(bookDir, "story"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  // --- isNewLayoutBook ---
+
+  it("isNewLayoutBook returns false when outline/story_frame.md is missing", async () => {
+    expect(await isNewLayoutBook(bookDir)).toBe(false);
+  });
+
+  it("isNewLayoutBook returns true when outline/story_frame.md exists", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# frame", "utf-8");
+    expect(await isNewLayoutBook(bookDir)).toBe(true);
+  });
+
+  // --- readStoryFrame ---
+
+  it("readStoryFrame prefers new path when present", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "段落式内容", "utf-8");
+    await writeFile(join(bookDir, "story", "story_bible.md"), "legacy content", "utf-8");
+    expect(await readStoryFrame(bookDir)).toBe("段落式内容");
+  });
+
+  it("readStoryFrame falls back to legacy story_bible.md when new path missing", async () => {
+    await writeFile(join(bookDir, "story", "story_bible.md"), "legacy 条目式", "utf-8");
+    expect(await readStoryFrame(bookDir)).toBe("legacy 条目式");
+  });
+
+  it("readStoryFrame returns fallback placeholder when both paths missing", async () => {
+    expect(await readStoryFrame(bookDir, "(未创建)")).toBe("(未创建)");
+  });
+
+  // --- readVolumeMap ---
+
+  it("readVolumeMap falls back to legacy volume_outline.md", async () => {
+    await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy 卷大纲", "utf-8");
+    expect(await readVolumeMap(bookDir)).toBe("legacy 卷大纲");
+  });
+
+  it("readVolumeMap prefers new path", async () => {
+    await mkdir(join(bookDir, "story", "outline"), { recursive: true });
+    await writeFile(join(bookDir, "story", "outline", "volume_map.md"), "段落式卷大纲", "utf-8");
+    await writeFile(join(bookDir, "story", "volume_outline.md"), "legacy", "utf-8");
+    expect(await readVolumeMap(bookDir)).toBe("段落式卷大纲");
+  });
+
+  // --- readRoleCards ---
+
+  it("readRoleCards returns empty when roles/ dir missing", async () => {
+    expect(await readRoleCards(bookDir)).toEqual([]);
+  });
+
+  it("readRoleCards reads major/minor role files", async () => {
+    const majorDir = join(bookDir, "story", "roles", "主要角色");
+    const minorDir = join(bookDir, "story", "roles", "次要角色");
+    await mkdir(majorDir, { recursive: true });
+    await mkdir(minorDir, { recursive: true });
+    await writeFile(join(majorDir, "林辞.md"), "主角内容", "utf-8");
+    await writeFile(join(minorDir, "配角A.md"), "配角内容", "utf-8");
+    const cards = await readRoleCards(bookDir);
+    expect(cards.length).toBe(2);
+    const major = cards.find((c) => c.name === "林辞");
+    const minor = cards.find((c) => c.name === "配角A");
+    expect(major).toMatchObject({ tier: "major", name: "林辞", content: "主角内容" });
+    expect(minor).toMatchObject({ tier: "minor", name: "配角A", content: "配角内容" });
+  });
+
+  // --- readCharacterContext ---
+
+  it("readCharacterContext renders role cards when present", async () => {
+    const majorDir = join(bookDir, "story", "roles", "主要角色");
+    await mkdir(majorDir, { recursive: true });
+    await writeFile(join(majorDir, "林辞.md"), "林辞的描写", "utf-8");
+    const result = await readCharacterContext(bookDir);
+    expect(result).toContain("林辞");
+    expect(result).toContain("主要角色");
+  });
+
+  it("readCharacterContext falls back to legacy character_matrix.md", async () => {
+    await writeFile(join(bookDir, "story", "character_matrix.md"), "legacy 角色矩阵", "utf-8");
+    const result = await readCharacterContext(bookDir);
+    expect(result).toBe("legacy 角色矩阵");
+  });
+
+  // --- readCurrentStateWithFallback ---
+
+  it("readCurrentStateWithFallback returns real content when not a seed", async () => {
+    await writeFile(
+      join(bookDir, "story", "current_state.md"),
+      "# 当前状态\n\n林辞已经到达京城,正在调查失踪案。",
+      "utf-8",
+    );
+    const result = await readCurrentStateWithFallback(bookDir);
+    expect(result).toContain("林辞已经到达京城");
+  });
+
+  it("readCurrentStateWithFallback derives initial state from roles when seed placeholder", async () => {
+    await writeFile(
+      join(bookDir, "story", "current_state.md"),
+      "建书时占位,运行时由 consolidator 每章追加。",
+      "utf-8",
+    );
+    const majorDir = join(bookDir, "story", "roles", "主要角色");
+    await mkdir(majorDir, { recursive: true });
+    await writeFile(
+      join(majorDir, "林辞.md"),
+      "## 当前现状\n\n刚从边关回到京城,尚未与旧友重逢。",
+      "utf-8",
+    );
+    const result = await readCurrentStateWithFallback(bookDir);
+    expect(result).toContain("林辞");
+    expect(result).toContain("初始状态");
+  });
+
+  // --- isCurrentStateSeedPlaceholder ---
+
+  it("isCurrentStateSeedPlaceholder returns true for empty or seed text", () => {
+    expect(isCurrentStateSeedPlaceholder("")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("  ")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("建书时占位,运行时追加")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("Seeded at book creation")).toBe(true);
+  });
+
+  it("isCurrentStateSeedPlaceholder returns false for real content", () => {
+    expect(isCurrentStateSeedPlaceholder("# 状态\n\n林辞已经出发,目标是西域。这是一段很长的内容。".repeat(10))).toBe(false);
+  });
+});

+ 275 - 0
packages/core/src/utils/outline-paths.ts

@@ -0,0 +1,275 @@
+/**
+ * Phase 5 (v13) path resolution — prefer the new prose outline files, fall
+ * back to legacy paths so older books keep working during transition.
+ *
+ * Maps:
+ *   story/outline/story_frame.md  →  preferred replacement for story_bible.md
+ *   story/outline/volume_map.md   →  preferred replacement for volume_outline.md
+ *   story/roles/主要角色/*.md +
+ *   story/roles/次要角色/*.md    →  preferred replacement for character_matrix.md
+ *
+ * All helpers accept a bookDir (path to a book root, containing `story/`)
+ * and return a string — either the new-file content when it exists, or the
+ * legacy file content, or an empty default placeholder.
+ */
+
+import { readFile, readdir, access } from "node:fs/promises";
+import { join } from "node:path";
+
+/**
+ * Detect whether a book uses the Phase 5 new layout (outline/story_frame.md
+ * exists on disk). If yes, story_bible.md / book_rules.md are compat shims.
+ * If no, those files ARE the authoritative source.
+ */
+export async function isNewLayoutBook(bookDir: string): Promise<boolean> {
+  try {
+    await access(join(bookDir, "story", "outline", "story_frame.md"));
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+async function readOr(path: string, fallback: string): Promise<string> {
+  try {
+    return await readFile(path, "utf-8");
+  } catch {
+    return fallback;
+  }
+}
+
+/** Read story_frame.md, falling back to legacy story_bible.md. */
+export async function readStoryFrame(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const newPath = join(bookDir, "story", "outline", "story_frame.md");
+  const legacyPath = join(bookDir, "story", "story_bible.md");
+
+  const newContent = await readOr(newPath, "");
+  if (newContent.trim()) return newContent;
+
+  return readOr(legacyPath, fallbackPlaceholder);
+}
+
+/** Read volume_map.md, falling back to legacy volume_outline.md. */
+export async function readVolumeMap(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const newPath = join(bookDir, "story", "outline", "volume_map.md");
+  const legacyPath = join(bookDir, "story", "volume_outline.md");
+
+  const newContent = await readOr(newPath, "");
+  if (newContent.trim()) return newContent;
+
+  return readOr(legacyPath, fallbackPlaceholder);
+}
+
+/** Read the rhythm principles file (zh or en variant). */
+export async function readRhythmPrinciples(bookDir: string): Promise<string> {
+  const zhPath = join(bookDir, "story", "outline", "节奏原则.md");
+  const enPath = join(bookDir, "story", "outline", "rhythm_principles.md");
+
+  const zh = await readOr(zhPath, "");
+  if (zh.trim()) return zh;
+  return readOr(enPath, "");
+}
+
+export interface RoleCard {
+  readonly tier: "major" | "minor";
+  readonly name: string;
+  readonly content: string;
+}
+
+/**
+ * Read the roles/ directory. Returns [] when no roles are present (e.g. old
+ * books still on character_matrix.md).
+ */
+export async function readRoleCards(bookDir: string): Promise<ReadonlyArray<RoleCard>> {
+  const rolesRoot = join(bookDir, "story", "roles");
+  const majorDirZh = join(rolesRoot, "主要角色");
+  const minorDirZh = join(rolesRoot, "次要角色");
+  const majorDirEn = join(rolesRoot, "major");
+  const minorDirEn = join(rolesRoot, "minor");
+
+  const cards: RoleCard[] = [];
+  await Promise.all([
+    collectRoleDir(majorDirZh, "major", cards),
+    collectRoleDir(minorDirZh, "minor", cards),
+    collectRoleDir(majorDirEn, "major", cards),
+    collectRoleDir(minorDirEn, "minor", cards),
+  ]);
+  return cards;
+}
+
+async function collectRoleDir(
+  dir: string,
+  tier: "major" | "minor",
+  out: RoleCard[],
+): Promise<void> {
+  let entries: string[];
+  try {
+    entries = await readdir(dir);
+  } catch {
+    return;
+  }
+  const reads = entries
+    .filter((entry) => entry.endsWith(".md"))
+    .map(async (entry) => {
+      const content = await readOr(join(dir, entry), "");
+      if (!content.trim()) return;
+      out.push({
+        tier,
+        name: entry.replace(/\.md$/, ""),
+        content,
+      });
+    });
+  await Promise.all(reads);
+}
+
+/**
+ * Render role cards in a format compatible with downstream consumers that
+ * previously expected character_matrix.md prose. When no role cards exist,
+ * returns the legacy character_matrix.md content or the placeholder.
+ */
+export async function readCharacterContext(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const cards = await readRoleCards(bookDir);
+  if (cards.length > 0) {
+    const groups: Record<"major" | "minor", RoleCard[]> = { major: [], minor: [] };
+    for (const card of cards) groups[card.tier].push(card);
+
+    const render = (tierCards: RoleCard[], heading: string): string => {
+      if (tierCards.length === 0) return "";
+      const sections = tierCards.map((card) => `### ${card.name}\n\n${card.content.trim()}`);
+      return `## ${heading}\n\n${sections.join("\n\n")}`;
+    };
+
+    const blocks = [
+      render(groups.major, "主要角色 / Major characters"),
+      render(groups.minor, "次要角色 / Minor characters"),
+    ].filter(Boolean);
+
+    return blocks.join("\n\n");
+  }
+
+  // Fallback: legacy character_matrix.md (may itself be a shim pointer).
+  const legacyPath = join(bookDir, "story", "character_matrix.md");
+  return readOr(legacyPath, fallbackPlaceholder);
+}
+
+// ---------------------------------------------------------------------------
+// Phase 5 consolidation: current_state.md initial fallback
+//
+// After architect consolidation (7→5 sections), current_state.md is seeded
+// with a tiny placeholder at book creation. Real content only arrives once
+// the consolidator has appended output from chapter 1 onward. Readers that
+// previously relied on architect-provided initial state (writer phase-1
+// creative prompt, continuity, chapter-analyzer, reviser, composer) should
+// substitute a derived initial-state block when the seed placeholder is all
+// that's on disk — otherwise the "## 当前状态卡" block in prompts degenerates
+// into a meta note about runtime append behaviour.
+// ---------------------------------------------------------------------------
+
+/**
+ * Marker substring emitted by architect.writeFoundationFiles when seeding
+ * current_state.md. Its presence is how readers detect "nothing real yet".
+ */
+const CURRENT_STATE_SEED_MARKERS = [
+  "建书时占位",
+  "Seeded at book creation",
+];
+
+export function isCurrentStateSeedPlaceholder(raw: string): boolean {
+  const trimmed = raw.trim();
+  if (!trimmed) return true;
+  // Heuristic: short file AND contains one of the seed markers.
+  if (trimmed.length > 600) return false;
+  return CURRENT_STATE_SEED_MARKERS.some((marker) => trimmed.includes(marker));
+}
+
+function extractCurrentStateFromRole(content: string): string | null {
+  // Accept both zh (`## 当前现状`) and en (`## Current_State` / `## Current State`).
+  const pattern = /^##\s*(?:当前现状|Current[_\s]?State)[^\n]*$/im;
+  const match = content.match(pattern);
+  if (!match || match.index === undefined) return null;
+  const after = content.slice(match.index + match[0].length);
+  // Cut at next `## ` heading (same or higher level).
+  const nextHeading = after.search(/^##\s/m);
+  const raw = nextHeading >= 0 ? after.slice(0, nextHeading) : after;
+  const text = raw.trim();
+  return text.length > 0 ? text : null;
+}
+
+function extractSeedHooksFromPendingHooks(raw: string): string[] {
+  if (!raw.trim()) return [];
+  const lines = raw.split("\n").map((line) => line.trim()).filter(Boolean);
+  const seedRows: string[] = [];
+  for (const line of lines) {
+    if (!line.startsWith("|")) continue;
+    if (/^\|\s*-/.test(line)) continue;
+    const cells = line.split("|").slice(1, -1).map((cell) => cell.trim());
+    if (cells.length < 2) continue;
+    if (cells[0]?.toLowerCase() === "hook_id" || cells[0] === "hookId") continue;
+    const startCh = Number.parseInt(cells[1] ?? "", 10);
+    if (!Number.isFinite(startCh) || startCh !== 0) continue;
+    // cells[2] type, cells[5] expected payoff, last cell notes
+    const notes = cells[cells.length - 1] ?? "";
+    const summary = [cells[0], cells[2], notes].filter(Boolean).join(" · ");
+    if (summary) seedRows.push(summary);
+  }
+  return seedRows;
+}
+
+/**
+ * Read current_state.md; when the file is only a seed placeholder (chapter 0,
+ * before consolidator has appended anything), derive an initial-state block
+ * from roles/*.Current_State + pending_hooks startChapter=0 rows so callers
+ * still have substantive content to feed into writer / analyzer prompts.
+ */
+export async function readCurrentStateWithFallback(
+  bookDir: string,
+  fallbackPlaceholder: string = "",
+): Promise<string> {
+  const storyDir = join(bookDir, "story");
+  const currentStatePath = join(storyDir, "current_state.md");
+  const raw = await readOr(currentStatePath, "");
+
+  if (!isCurrentStateSeedPlaceholder(raw)) {
+    return raw;
+  }
+
+  const [cards, pendingHooks] = await Promise.all([
+    readRoleCards(bookDir),
+    readOr(join(storyDir, "pending_hooks.md"), ""),
+  ]);
+
+  const roleLines = cards
+    .map((card) => {
+      const state = extractCurrentStateFromRole(card.content);
+      if (!state) return null;
+      const tierLabel = card.tier === "major" ? "主要" : "次要";
+      return `- ${card.name}(${tierLabel}):${state.replace(/\s+/g, " ")}`;
+    })
+    .filter((line): line is string => line !== null);
+
+  const hookLines = extractSeedHooksFromPendingHooks(pendingHooks);
+
+  if (roleLines.length === 0 && hookLines.length === 0) {
+    return raw.trim() ? raw : fallbackPlaceholder;
+  }
+
+  const parts: string[] = ["# 初始状态(第 0 章,由 roles + 种子伏笔派生)"];
+  if (roleLines.length > 0) {
+    parts.push("\n## 角色初始位置 / 处境");
+    parts.push(...roleLines);
+  }
+  if (hookLines.length > 0) {
+    parts.push("\n## 种子伏笔(startChapter = 0)");
+    parts.push(...hookLines.map((line) => `- ${line}`));
+  }
+  return parts.join("\n");
+}