|
|
@@ -0,0 +1,315 @@
|
|
|
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
+import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
|
|
+import { tmpdir } from "node:os";
|
|
|
+import { join } from "node:path";
|
|
|
+import { ArchitectAgent } from "../agents/architect.js";
|
|
|
+import type { BookConfig } from "../models/book.js";
|
|
|
+
|
|
|
+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-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 SAMPLE_RESPONSE = [
|
|
|
+ "=== SECTION: story_frame ===",
|
|
|
+ "## 主题与基调",
|
|
|
+ "这本书讲的是一个被时代按在泥里的人如何选择不被改写。",
|
|
|
+ "## 主角弧线",
|
|
|
+ "主角从沉默的旁观者走向沉默的见证人——代价是离开故乡。",
|
|
|
+ "## 核心冲突与对手",
|
|
|
+ "主角相信记录真相有价值,体制相信记录真相会扰乱秩序。",
|
|
|
+ "## 世界观底色",
|
|
|
+ "这是一个湿冷的沿海城市,铁律:凡被记录的名字都会消失。",
|
|
|
+ "## 终局方向",
|
|
|
+ "最后一个镜头是主角在码头上把笔记本烧了又重新写。",
|
|
|
+ "",
|
|
|
+ "=== SECTION: volume_map ===",
|
|
|
+ "## 各卷主题与情绪曲线",
|
|
|
+ "共三卷,第一卷压,第二卷放,第三卷压回。",
|
|
|
+ "## 关键节点章",
|
|
|
+ "第 17 章让他回家——母亲病重。第 32 章揭开秘密。第 55 章最终对峙。",
|
|
|
+ "## 卷间钩子与回收",
|
|
|
+ "第一卷埋下笔记本钩子,第 32 章回收。",
|
|
|
+ "## 角色阶段性目标",
|
|
|
+ "第一卷末:主角决定留下。",
|
|
|
+ "## 卷尾必须发生的改变",
|
|
|
+ "第一卷末:身份暴露。",
|
|
|
+ "## 节奏意图",
|
|
|
+ "前 10 章高压引人。",
|
|
|
+ "",
|
|
|
+ "=== SECTION: rhythm_principles ===",
|
|
|
+ "## 原则 1:高潮间距",
|
|
|
+ "每 8-10 章一个大高潮。",
|
|
|
+ "## 原则 2:喘息频率",
|
|
|
+ "每 3 章高压后必须 1 章喘息。",
|
|
|
+ "## 原则 3:钩子密度",
|
|
|
+ "每章章末 1 个主钩,最多悬 5 章。",
|
|
|
+ "## 原则 4:信息释放节奏",
|
|
|
+ "前 1/3 释放 30%,中段 40%,后段 30%。",
|
|
|
+ "## 原则 5:爽点节奏",
|
|
|
+ "每 5 章一个智商碾压式爽点。",
|
|
|
+ "## 原则 6:情感节点递进",
|
|
|
+ "每 6 章情感关系必须实质推进一次。",
|
|
|
+ "",
|
|
|
+ "=== SECTION: roles ===",
|
|
|
+ "---ROLE---",
|
|
|
+ "tier: major",
|
|
|
+ "name: 林辞",
|
|
|
+ "---CONTENT---",
|
|
|
+ "## 核心标签",
|
|
|
+ "沉默、执拗、过度理性",
|
|
|
+ "## 反差细节",
|
|
|
+ "会在加油站给流浪狗留罐头",
|
|
|
+ "## 人物小传",
|
|
|
+ "十五岁时失去父亲,从此习惯把情绪写下来。",
|
|
|
+ "## 当前现状",
|
|
|
+ "在码头边上的旧书店做账房。",
|
|
|
+ "## 关系网络",
|
|
|
+ "与沈默是旧日同窗。",
|
|
|
+ "## 内在驱动",
|
|
|
+ "想知道父亲死前那一夜发生了什么。",
|
|
|
+ "## 成长弧光",
|
|
|
+ "从独自追查到学会把真相交给别人。",
|
|
|
+ "---ROLE---",
|
|
|
+ "tier: major",
|
|
|
+ "name: 沈默",
|
|
|
+ "---CONTENT---",
|
|
|
+ "## 核心标签",
|
|
|
+ "精致、疏离、惯于算计",
|
|
|
+ "## 反差细节",
|
|
|
+ "唯独对林辞从不说谎",
|
|
|
+ "## 人物小传",
|
|
|
+ "出身体制内家庭。",
|
|
|
+ "## 当前现状",
|
|
|
+ "新任区域办公室副职。",
|
|
|
+ "## 关系网络",
|
|
|
+ "与林辞关系复杂。",
|
|
|
+ "## 内在驱动",
|
|
|
+ "想在规则内做到最好。",
|
|
|
+ "## 成长弧光",
|
|
|
+ "被迫在规则与情义间选择。",
|
|
|
+ "---ROLE---",
|
|
|
+ "tier: minor",
|
|
|
+ "name: 老张",
|
|
|
+ "---CONTENT---",
|
|
|
+ "## 核心标签",
|
|
|
+ "油滑、念旧",
|
|
|
+ "## 反差细节",
|
|
|
+ "每年清明会独自去扫一个无名坟",
|
|
|
+ "## 当前现状",
|
|
|
+ "旧书店老板。",
|
|
|
+ "## 与主角关系",
|
|
|
+ "代父执辈。",
|
|
|
+ "",
|
|
|
+ "=== SECTION: book_rules ===",
|
|
|
+ "---",
|
|
|
+ "version: \"1.0\"",
|
|
|
+ "protagonist:",
|
|
|
+ " name: 林辞",
|
|
|
+ " personalityLock: [沉默, 执拗, 理性]",
|
|
|
+ " behavioralConstraints: [不对长辈失礼]",
|
|
|
+ "genreLock:",
|
|
|
+ " primary: urban",
|
|
|
+ " forbidden: [玄幻色彩]",
|
|
|
+ "prohibitions:",
|
|
|
+ " - 不得美化体制暴力",
|
|
|
+ "chapterTypesOverride: []",
|
|
|
+ "fatigueWordsOverride: []",
|
|
|
+ "additionalAuditDimensions: []",
|
|
|
+ "enableFullCastTracking: false",
|
|
|
+ "---",
|
|
|
+ "## 叙事视角",
|
|
|
+ "第三人称单一视角,贴着主角。",
|
|
|
+ "## 核心冲突驱动",
|
|
|
+ "参见 outline/story_frame.md 段 3。",
|
|
|
+ "",
|
|
|
+ "=== SECTION: current_state ===",
|
|
|
+ "| 字段 | 值 |",
|
|
|
+ "| --- | --- |",
|
|
|
+ "| 当前章节 | 0 |",
|
|
|
+ "| 当前位置 | 码头旧书店 |",
|
|
|
+ "| 主角状态 | 刚回乡 |",
|
|
|
+ "| 当前目标 | 查清父亲死因 |",
|
|
|
+ "| 当前限制 | 没有线索 |",
|
|
|
+ "| 当前敌我 | 尚未出现 |",
|
|
|
+ "| 当前冲突 | 自我怀疑 |",
|
|
|
+ "",
|
|
|
+ "=== SECTION: pending_hooks ===",
|
|
|
+ "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
|
|
|
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
|
|
|
+ "| H01 | 1 | 主线 | 未开启 | 0 | 32章 | 中程 | 父亲的笔记本 |",
|
|
|
+].join("\n");
|
|
|
+
|
|
|
+describe("ArchitectAgent — Phase 5 prose output", () => {
|
|
|
+ let bookDir: string;
|
|
|
+
|
|
|
+ beforeEach(async () => {
|
|
|
+ bookDir = await mkdtemp(join(tmpdir(), "inkos-phase5-arch-"));
|
|
|
+ });
|
|
|
+
|
|
|
+ afterEach(async () => {
|
|
|
+ await rm(bookDir, { recursive: true, force: true });
|
|
|
+ vi.restoreAllMocks();
|
|
|
+ });
|
|
|
+
|
|
|
+ it("parses storyFrame / volumeMap / rhythmPrinciples / roles from the response", async () => {
|
|
|
+ const agent = buildAgent();
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
+ .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
|
|
|
+
|
|
|
+ const result = await agent.generateFoundation(baseBook());
|
|
|
+
|
|
|
+ expect(result.storyFrame).toContain("主题与基调");
|
|
|
+ expect(result.volumeMap).toContain("第 17 章让他回家");
|
|
|
+ expect(result.rhythmPrinciples).toContain("高潮间距");
|
|
|
+ expect(result.roles).toBeDefined();
|
|
|
+ expect(result.roles).toHaveLength(3);
|
|
|
+
|
|
|
+ const majors = (result.roles ?? []).filter((role) => role.tier === "major");
|
|
|
+ const minors = (result.roles ?? []).filter((role) => role.tier === "minor");
|
|
|
+ expect(majors.map((role) => role.name)).toEqual(["林辞", "沈默"]);
|
|
|
+ expect(minors.map((role) => role.name)).toEqual(["老张"]);
|
|
|
+ expect(majors[0]?.content).toContain("核心标签");
|
|
|
+ expect(majors[0]?.content).toContain("反差细节");
|
|
|
+ });
|
|
|
+
|
|
|
+ it("writes outline/* prose files, roles/*, and compat shims for story_bible/character_matrix", async () => {
|
|
|
+ const agent = buildAgent();
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
+ .mockResolvedValue({ content: SAMPLE_RESPONSE, usage: ZERO_USAGE });
|
|
|
+
|
|
|
+ const result = await agent.generateFoundation(baseBook());
|
|
|
+ await agent.writeFoundationFiles(bookDir, result, false, "zh");
|
|
|
+
|
|
|
+ const storyDir = join(bookDir, "story");
|
|
|
+ const storyFrame = await readFile(join(storyDir, "outline/story_frame.md"), "utf-8");
|
|
|
+ expect(storyFrame).toContain("主题与基调");
|
|
|
+
|
|
|
+ const volumeMap = await readFile(join(storyDir, "outline/volume_map.md"), "utf-8");
|
|
|
+ expect(volumeMap).toContain("第 17 章让他回家");
|
|
|
+
|
|
|
+ const rhythm = await readFile(join(storyDir, "outline/节奏原则.md"), "utf-8");
|
|
|
+ expect(rhythm).toContain("高潮间距");
|
|
|
+
|
|
|
+ // Role files — one per character, grouped by tier
|
|
|
+ const majorFiles = await readdir(join(storyDir, "roles", "主要角色"));
|
|
|
+ expect(majorFiles.sort()).toEqual(["林辞.md", "沈默.md"]);
|
|
|
+ const minorFiles = await readdir(join(storyDir, "roles", "次要角色"));
|
|
|
+ expect(minorFiles).toEqual(["老张.md"]);
|
|
|
+
|
|
|
+ // Compat shim: story_bible.md must exist and point at outline/story_frame.md
|
|
|
+ const storyBibleShim = await readFile(join(storyDir, "story_bible.md"), "utf-8");
|
|
|
+ expect(storyBibleShim).toContain("兼容指针");
|
|
|
+ expect(storyBibleShim).toContain("outline/story_frame.md");
|
|
|
+
|
|
|
+ // Compat shim: character_matrix.md points at roles/ directory
|
|
|
+ const matrixShim = await readFile(join(storyDir, "character_matrix.md"), "utf-8");
|
|
|
+ expect(matrixShim).toContain("兼容指针");
|
|
|
+ expect(matrixShim).toContain("roles/主要角色/林辞.md");
|
|
|
+ expect(matrixShim).toContain("roles/次要角色/老张.md");
|
|
|
+
|
|
|
+ // Runtime state files still produced
|
|
|
+ const currentState = await readFile(join(storyDir, "current_state.md"), "utf-8");
|
|
|
+ expect(currentState).toContain("当前章节");
|
|
|
+ const pendingHooks = await readFile(join(storyDir, "pending_hooks.md"), "utf-8");
|
|
|
+ expect(pendingHooks).toContain("H01");
|
|
|
+
|
|
|
+ // Legacy volume_outline.md kept as a mirror of volume_map for back-compat
|
|
|
+ const legacyVolume = await readFile(join(storyDir, "volume_outline.md"), "utf-8");
|
|
|
+ expect(legacyVolume).toBe(volumeMap);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("still requires book_rules / current_state / pending_hooks to be present", async () => {
|
|
|
+ const agent = buildAgent();
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
+ .mockResolvedValue({
|
|
|
+ content: [
|
|
|
+ "=== SECTION: story_frame ===",
|
|
|
+ "# frame",
|
|
|
+ "=== SECTION: volume_map ===",
|
|
|
+ "# map",
|
|
|
+ "=== SECTION: current_state ===",
|
|
|
+ "# state",
|
|
|
+ "=== SECTION: pending_hooks ===",
|
|
|
+ "# hooks",
|
|
|
+ ].join("\n"),
|
|
|
+ usage: ZERO_USAGE,
|
|
|
+ });
|
|
|
+
|
|
|
+ await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/book_rules/i);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("requires at least one of story_frame or legacy story_bible", async () => {
|
|
|
+ const agent = buildAgent();
|
|
|
+ vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
+ .mockResolvedValue({
|
|
|
+ content: [
|
|
|
+ "=== SECTION: volume_map ===",
|
|
|
+ "# map",
|
|
|
+ "=== SECTION: book_rules ===",
|
|
|
+ "---\nversion: \"1.0\"\n---",
|
|
|
+ "=== SECTION: current_state ===",
|
|
|
+ "# state",
|
|
|
+ "=== SECTION: pending_hooks ===",
|
|
|
+ "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | 备注 |",
|
|
|
+ ].join("\n"),
|
|
|
+ usage: ZERO_USAGE,
|
|
|
+ });
|
|
|
+
|
|
|
+ await expect(agent.generateFoundation(baseBook())).rejects.toThrow(/story_frame/i);
|
|
|
+ });
|
|
|
+
|
|
|
+ it("system prompt emphasises chapter-level prose for volume_map and contrast-detail for roles", async () => {
|
|
|
+ const agent = buildAgent();
|
|
|
+ const chat = vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
|
|
|
+ .mockResolvedValue({ content: SAMPLE_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 ?? "";
|
|
|
+ expect(system).toContain("散文密度");
|
|
|
+ expect(system).toContain("写到章级 prose");
|
|
|
+ expect(system).toContain("反差细节");
|
|
|
+ expect(system).toContain("节奏原则");
|
|
|
+ expect(system).toContain("=== SECTION: story_frame ===");
|
|
|
+ expect(system).toContain("=== SECTION: volume_map ===");
|
|
|
+ expect(system).toContain("=== SECTION: rhythm_principles ===");
|
|
|
+ expect(system).toContain("=== SECTION: roles ===");
|
|
|
+ });
|
|
|
+});
|