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

test(v13): cover consolidated architect output invariants [phase 5 consolidation 4]

New suite architect-phase5-consolidated.test.ts locks the 5-section
contract in place:
  - prompt advertises exactly story_frame / volume_map / roles /
    book_rules / pending_hooks — no rhythm_principles, no current_state
  - prompt forbids duplication (主角弧线 only in roles, 世界铁律 only in
    story_frame.世界观底色, 节奏原则 only in volume_map closing paragraph)
  - prompt carries explicit per-section char budgets (≤3k / ≤5k / ≤8k /
    ≤500 / ≤2k)
  - English prompt mirrors the zh contract
  - book_rules prompt block asks for YAML only (no 叙事视角 / 核心冲突驱动
    prose blocks)
  - parser no longer requires current_state in architect output and
    seeds current_state.md with a detectable placeholder
  - legacy 7-section outputs still parse (backward compat)
  - readCurrentStateWithFallback derives initial state from roles + seed
    hooks when current_state.md is only the placeholder
  - isCurrentStateSeedPlaceholder distinguishes seed vs runtime content

architect-phase5.test.ts updated:
  - 'requires book_rules / current_state / pending_hooks' renamed to
    'requires book_rules / roles / pending_hooks' — current_state is
    now optional, roles is required
  - system-prompt assertion flips to expect NO rhythm_principles and NO
    current_state SECTION headers
Ma 6 месяцев назад
Родитель
Сommit
dd5b017e35

+ 393 - 0
packages/core/src/__tests__/architect-phase5-consolidated.test.ts

@@ -0,0 +1,393 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ArchitectAgent } from "../agents/architect.js";
+import {
+  readCurrentStateWithFallback,
+  isCurrentStateSeedPlaceholder,
+} from "../utils/outline-paths.js";
+import type { BookConfig } from "../models/book.js";
+
+// ---------------------------------------------------------------------------
+// Phase 5 consolidation invariants (7 sections → 5 sections).
+//
+// These tests lock in the contract so future edits can't silently regress back
+// to the 7-section layout that was causing gpt-5.4 to drop tail sections and
+// fail book creation 30%+ of the time:
+//
+//   1. The architect prompt advertises exactly 5 SECTION headers.
+//   2. The prompt FORBIDS duplication of protagonist-arc across story_frame
+//      and roles, and forbids re-emitting rhythm_principles / current_state.
+//   3. It carries explicit per-section budget markers.
+//   4. current_state section is no longer REQUIRED in architect output.
+//   5. book_rules prompt tells the LLM "YAML only, no prose".
+//   6. Legacy 7-section outputs still parse (backward compat).
+//   7. writeFoundationFiles seeds current_state.md with a marker placeholder
+//      when architect produced no initial state.
+//   8. readCurrentStateWithFallback derives a substitute block from
+//      roles/*.Current_State + pending_hooks startChapter=0 rows when the
+//      seed placeholder is still on disk.
+// ---------------------------------------------------------------------------
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+function buildAgent(): ArchitectAgent {
+  return new ArchitectAgent({
+    client: {
+      provider: "openai",
+      apiFormat: "chat",
+      stream: false,
+      defaults: {
+        temperature: 0.7,
+        maxTokens: 4096,
+        thinkingBudget: 0,
+        maxTokensCap: null,
+        extra: {},
+      },
+    },
+    model: "test-model",
+    projectRoot: process.cwd(),
+  });
+}
+
+function baseBook(): BookConfig {
+  return {
+    id: "phase5-consolidated-book",
+    title: "Phase5 合并测试书",
+    platform: "other",
+    genre: "urban",
+    status: "active",
+    targetChapters: 60,
+    chapterWordCount: 2200,
+    language: "zh",
+    createdAt: "2026-04-15T00:00:00.000Z",
+    updatedAt: "2026-04-15T00:00:00.000Z",
+  };
+}
+
+const CONSOLIDATED_RESPONSE = [
+  "=== SECTION: story_frame ===",
+  "## 主题与基调",
+  "一段主题散文,结尾指向主角卡:本书主角是林辞,完整弧线详见 roles/主要角色/林辞.md。",
+  "## 核心冲突与对手定性",
+  "主角 vs 体制。对手有自己的逻辑。",
+  "## 世界观底色",
+  "湿冷的沿海城市,记录者与被记录者的对撞。含本书铁律:凡被记录的名字都会消失。",
+  "## 终局方向",
+  "最后一个镜头:码头日出。",
+  "",
+  "=== SECTION: volume_map ===",
+  "## 各卷主题与情绪曲线",
+  "三卷结构。",
+  "## 关键节点章",
+  "第 17 章让他回家。",
+  "## 卷间钩子与回收承诺",
+  "第 1 卷埋笔记本。",
+  "## 角色阶段性目标",
+  "卷一末:主角决定留下。",
+  "## 卷尾必须发生的改变",
+  "身份暴露。",
+  "## 节奏原则与本书具体化",
+  "1. 高潮间距:每 8-10 章一个大高潮。",
+  "2. 喘息频率:3 章高压后插 1 章喘息。",
+  "3. 钩子密度:每章章末 1 个主钩。",
+  "4. 信息释放节奏:前 1/3 释放 30%。",
+  "5. 爽点节奏:每 5 章一个智商爽点。",
+  "6. 情感节点递进:每 6 章一次。",
+  "",
+  "=== SECTION: roles ===",
+  "---ROLE---",
+  "tier: major",
+  "name: 林辞",
+  "---CONTENT---",
+  "## 核心标签",
+  "沉默、执拗",
+  "## 反差细节",
+  "会给流浪狗留罐头",
+  "## 人物小传",
+  "十五岁时失去父亲。",
+  "## 主角弧线(起点 → 终点 → 代价)",
+  "从沉默的旁观者走向沉默的见证人——代价是离开故乡。",
+  "## 当前现状",
+  "第 0 章时在码头边上的旧书店做账房,最近最烦心的是账本对不上。",
+  "## 关系网络",
+  "与沈默是旧友。",
+  "## 内在驱动",
+  "想知道父亲死前那一夜发生了什么。",
+  "## 成长弧光",
+  "从独行到托付。",
+  "---ROLE---",
+  "tier: major",
+  "name: 沈默",
+  "---CONTENT---",
+  "## 核心标签",
+  "精致、疏离",
+  "## 反差细节",
+  "唯独对林辞从不说谎",
+  "## 人物小传",
+  "体制内家庭。",
+  "## 当前现状",
+  "新任区域办公室副职,第 0 章刚上任一周。",
+  "## 关系网络",
+  "与林辞复杂。",
+  "## 内在驱动",
+  "在规则内做到最好。",
+  "## 成长弧光",
+  "被迫选择。",
+  "",
+  "=== SECTION: book_rules ===",
+  "---",
+  "version: \"1.0\"",
+  "protagonist:",
+  "  name: 林辞",
+  "  personalityLock: [沉默, 执拗]",
+  "  behavioralConstraints: [不对长辈失礼]",
+  "prohibitions:",
+  "  - 不得美化体制暴力",
+  "chapterTypesOverride: []",
+  "fatigueWordsOverride: []",
+  "additionalAuditDimensions: []",
+  "enableFullCastTracking: false",
+  "---",
+  "",
+  "=== SECTION: pending_hooks ===",
+  "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 上游依赖 | 回收卷 | 核心 | 半衰期 | 备注 |",
+  "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
+  "| H01 | 1 | 主线 | 未开启 | 0 | 终章揭晓 | 终局 | 无 | 第3卷终章前 | 是 | 80 | 父亲的笔记本 |",
+  "| H02 | 0 | 初始世界 | 未开启 | 0 | 首卷中段 | 近期 | 无 | 第1卷中段 | 否 | 20 | 初始状态:体制已监视码头 |",
+].join("\n");
+
+describe("Phase 5 consolidation — 7→5 sections, prompt contract", () => {
+  it("the zh prompt advertises exactly 5 SECTION headers and NO rhythm_principles / current_state", async () => {
+    const agent = buildAgent();
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    await agent.generateFoundation(baseBook());
+
+    const messages = chat.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    const system = messages[0]?.content ?? "";
+
+    const headers = [...system.matchAll(/^=== SECTION: ([a-z_]+) ===$/gim)]
+      .map((match) => match[1]);
+    expect(headers).toEqual([
+      "story_frame",
+      "volume_map",
+      "roles",
+      "book_rules",
+      "pending_hooks",
+    ]);
+  });
+
+  it("the prompt forbids duplication across sections (dedup rule)", async () => {
+    const agent = buildAgent();
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    await agent.generateFoundation(baseBook());
+    const system = (chat.mock.calls[0]?.[0] as Array<{ content: string }>)[0]?.content ?? "";
+
+    // Protagonist arc: authoritative home is roles
+    expect(system).toContain("主角弧线只写在 roles");
+    // World hard rules: authoritative home is story_frame.世界观底色
+    expect(system).toContain("世界铁律只写在 story_frame.世界观底色");
+    // Rhythm principles: authoritative home is volume_map's closing paragraph
+    expect(system).toContain("节奏原则只写在 volume_map 最后一段");
+  });
+
+  it("the prompt carries explicit per-section char budget markers", async () => {
+    const agent = buildAgent();
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    await agent.generateFoundation(baseBook());
+    const system = (chat.mock.calls[0]?.[0] as Array<{ content: string }>)[0]?.content ?? "";
+
+    expect(system).toContain("story_frame ≤ 3000 chars");
+    expect(system).toContain("volume_map ≤ 5000 chars");
+    expect(system).toContain("roles 总 ≤ 8000 chars");
+    expect(system).toContain("book_rules ≤ 500 chars");
+    expect(system).toContain("pending_hooks ≤ 2000 chars");
+  });
+
+  it("the English prompt also carries the 5-section / dedup / budget rules", async () => {
+    const agent = buildAgent();
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    const enBook: BookConfig = { ...baseBook(), language: "en" };
+    await agent.generateFoundation(enBook);
+    const system = (chat.mock.calls[0]?.[0] as Array<{ content: string }>)[0]?.content ?? "";
+
+    const headers = [...system.matchAll(/^=== SECTION: ([a-z_]+) ===$/gim)]
+      .map((match) => match[1]);
+    expect(headers).toEqual([
+      "story_frame",
+      "volume_map",
+      "roles",
+      "book_rules",
+      "pending_hooks",
+    ]);
+    expect(system).toContain("story_frame ≤ 3000 chars");
+    expect(system).toContain("YAML only");
+  });
+
+  it("book_rules prompt block instructs YAML only, no prose", async () => {
+    const agent = buildAgent();
+    const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    await agent.generateFoundation(baseBook());
+    const system = (chat.mock.calls[0]?.[0] as Array<{ content: string }>)[0]?.content ?? "";
+
+    expect(system).toContain("只输出 YAML frontmatter 一块——零散文");
+    // The pre-consolidation prompt used to ask for `## 叙事视角` AND
+    // `## 核心冲突驱动` prose blocks inside book_rules — those are gone.
+    expect(system).not.toMatch(/=== SECTION: book_rules ===[\s\S]*?## 叙事视角/);
+    expect(system).not.toMatch(/=== SECTION: book_rules ===[\s\S]*?## 核心冲突驱动/);
+  });
+});
+
+describe("Phase 5 consolidation — parser accepts 5-section output", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-phase5-cons-"));
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+    vi.restoreAllMocks();
+  });
+
+  it("accepts a response with no current_state section and still writes current_state.md as seed", async () => {
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    const out = await agent.generateFoundation(baseBook());
+    await agent.writeFoundationFiles(bookDir, out, false, "zh");
+
+    // Architect output carries an empty currentState string (no section emitted)
+    expect(out.currentState).toBe("");
+
+    // current_state.md on disk is the seed placeholder, not empty.
+    const seed = await readFile(join(bookDir, "story/current_state.md"), "utf-8");
+    expect(isCurrentStateSeedPlaceholder(seed)).toBe(true);
+    expect(seed).toContain("建书时占位");
+  });
+
+  it("preserves legacy 7-section input (current_state + rhythm_principles still present)", async () => {
+    const legacyResponse = [
+      "=== SECTION: story_frame ===",
+      "# frame",
+      "=== SECTION: volume_map ===",
+      "# map",
+      "=== SECTION: rhythm_principles ===",
+      "# legacy rhythm — accepted but no longer required",
+      "=== SECTION: roles ===",
+      "---ROLE---",
+      "tier: major",
+      "name: 林辞",
+      "---CONTENT---",
+      "## 核心标签",
+      "沉默",
+      "## 反差细节",
+      "会给流浪狗留罐头",
+      "## 人物小传",
+      "过往。",
+      "## 当前现状",
+      "码头账房。",
+      "## 关系网络",
+      "无。",
+      "## 内在驱动",
+      "查清真相。",
+      "## 成长弧光",
+      "从独行到托付。",
+      "=== SECTION: book_rules ===",
+      "---",
+      "version: \"1.0\"",
+      "---",
+      "",
+      "## 叙事视角",
+      "第三人称(legacy prose body — parser accepts but it no longer drives anything)",
+      "=== SECTION: current_state ===",
+      "| 字段 | 值 |",
+      "| --- | --- |",
+      "| 当前章节 | 0 |",
+      "| 当前位置 | 码头 |",
+      "=== SECTION: pending_hooks ===",
+      "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
+      "| --- | --- | --- | --- | --- | --- | --- | --- |",
+      "| H01 | 1 | 主线 | 未开启 | 0 | 终章 | 终局 | 父亲的笔记本 |",
+    ].join("\n");
+
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: legacyResponse, usage: ZERO_USAGE });
+
+    const out = await agent.generateFoundation(baseBook());
+
+    // Legacy content is preserved as-is
+    expect(out.currentState).toContain("当前位置");
+    expect(out.rhythmPrinciples).toContain("legacy rhythm");
+    expect((out.roles ?? []).length).toBeGreaterThanOrEqual(1);
+  });
+});
+
+describe("Phase 5 consolidation — readCurrentStateWithFallback derives initial state", () => {
+  let bookDir: string;
+
+  beforeEach(async () => {
+    bookDir = await mkdtemp(join(tmpdir(), "inkos-phase5-fallback-"));
+  });
+
+  afterEach(async () => {
+    await rm(bookDir, { recursive: true, force: true });
+  });
+
+  it("returns a derived block built from roles/*.Current_State + seed hooks when current_state.md is a seed placeholder", async () => {
+    const agent = buildAgent();
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({ content: CONSOLIDATED_RESPONSE, usage: ZERO_USAGE });
+
+    const out = await agent.generateFoundation(baseBook());
+    await agent.writeFoundationFiles(bookDir, out, false, "zh");
+
+    const derived = await readCurrentStateWithFallback(bookDir, "(missing)");
+    // Derived block should mention the role names and their Current_State text.
+    expect(derived).toContain("初始状态");
+    expect(derived).toContain("林辞");
+    expect(derived).toContain("码头边上的旧书店做账房");
+    expect(derived).toContain("沈默");
+    expect(derived).toContain("新任区域办公室副职");
+    // Seed hook row startChapter=0 surfaces in the derived block.
+    expect(derived).toContain("H02");
+  });
+
+  it("returns the file content as-is when current_state.md already has runtime content", async () => {
+    const storyDir = join(bookDir, "story");
+    await mkdir(storyDir, { recursive: true });
+    const runtime = "# 当前状态\n\n- 第 5 章后主角正式加入合作社。\n- 与体制的关系:明面协作、暗中抵抗。\n";
+    await writeFile(join(storyDir, "current_state.md"), runtime, "utf-8");
+
+    const derived = await readCurrentStateWithFallback(bookDir, "(missing)");
+    expect(derived).toBe(runtime);
+  });
+
+  it("isCurrentStateSeedPlaceholder correctly identifies seeds vs real content", () => {
+    expect(isCurrentStateSeedPlaceholder("")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("# 当前状态\n\n> 建书时占位。后续章节补。\n")).toBe(true);
+    expect(isCurrentStateSeedPlaceholder("# Current State\n\n> Seeded at book creation.\n")).toBe(true);
+    // A real consolidator-appended block — no seed marker
+    expect(isCurrentStateSeedPlaceholder("# 当前状态\n\n- 主角现状一二三\n- 体制关系四五六\n")).toBe(false);
+    // A long file that happens to contain the seed marker in prose — NOT a seed
+    const longContent = "# 当前状态\n\n" + "一段很长的实际内容。".repeat(200) + "\n建书时占位\n";
+    expect(isCurrentStateSeedPlaceholder(longContent)).toBe(false);
+  });
+});

+ 16 - 6
packages/core/src/__tests__/architect-phase5.test.ts

@@ -255,7 +255,7 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
     await expect(readFile(join(storyDir, "volume_outline.md"), "utf-8")).rejects.toThrow();
   });
 
-  it("still requires book_rules / current_state / pending_hooks to be present", async () => {
+  it("still requires book_rules / roles / pending_hooks to be present (current_state is optional after consolidation)", async () => {
     const agent = buildAgent();
     vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
       .mockResolvedValue({
@@ -264,15 +264,15 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
           "# frame",
           "=== SECTION: volume_map ===",
           "# map",
-          "=== SECTION: current_state ===",
-          "# state",
           "=== SECTION: pending_hooks ===",
           "# hooks",
         ].join("\n"),
         usage: ZERO_USAGE,
       });
 
+    // book_rules + roles both missing — the error message lists them.
     await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/book_rules/i);
+    await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/roles/i);
   });
 
   it("requires at least one of story_frame or legacy story_bible", async () => {
@@ -282,10 +282,15 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
         content: [
           "=== SECTION: volume_map ===",
           "# map",
+          "=== SECTION: roles ===",
+          "---ROLE---",
+          "tier: major",
+          "name: X",
+          "---CONTENT---",
+          "## 核心标签",
+          "只是占位",
           "=== SECTION: book_rules ===",
           "---\nversion: \"1.0\"\n---",
-          "=== SECTION: current_state ===",
-          "# state",
           "=== SECTION: pending_hooks ===",
           "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
         ].join("\n"),
@@ -310,7 +315,12 @@ describe("ArchitectAgent — Phase 5 prose output", () => {
     expect(system).toContain("节奏原则");
     expect(system).toContain("=== SECTION: story_frame ===");
     expect(system).toContain("=== SECTION: volume_map ===");
-    expect(system).toContain("=== SECTION: rhythm_principles ===");
+    // Phase 5 consolidation: rhythm_principles is now merged into volume_map's
+    // closing paragraph and NO LONGER a standalone SECTION header in the prompt.
+    expect(system).not.toContain("=== SECTION: rhythm_principles ===");
+    expect(system).not.toContain("=== SECTION: current_state ===");
     expect(system).toContain("=== SECTION: roles ===");
+    expect(system).toContain("=== SECTION: book_rules ===");
+    expect(system).toContain("=== SECTION: pending_hooks ===");
   });
 });