From 7b009ea39d403369761dfd72a1cb304318627beb Mon Sep 17 00:00:00 2001 From: Ma Date: Wed, 15 Jul 2026 15:54:13 +0800 Subject: [PATCH] feat(core): add narrative forecast agent, prompts, renderers and runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC #342 v1 的三个操作 create / get / select: - forecast/prompts.ts:zh/en 双语提示词(系统/用户/修复三个 builder), 向模型明确要求互斥分支、只输出 JSON、字段结构与合法枚举值 - forecast/agent.ts:NarrativeForecastAgent 继承 BaseAgent,单次调用 + 一次带校验错误反馈的重试;分支数量与请求不符也算非法输出;两次都 失败直接抛错,绝不把非法输出往下传 - forecast/render.ts:从 forecast.json 确定性渲染 comparison.md(对比表 + 分支详情)与 selected-branch-plan.md(含过期警告与"不修改正史"脚注), 不需要第二次 LLM 调用 - forecast/runner.ts: - create:读上下文 → 生成分支 → 赋 branch-N id → schema 校验通过后才写 story/runtime/narrative-forecasts//,模型输出非法时不留任何文件 - get:重算 contextFingerprint,正史变化后把 status 持久化标记为 stale - select:只写 selected-branch-plan.md(不改 forecast.json、不碰正史), 选不存在的分支报错并列出可选分支 - index.ts:导出 forecast 模块公共 API - 测试:agent 校验重试路径、create/get/select 全流程、兄弟分支隔离、 正史文件前后快照一致、stale 标记、非法输出零残留、越界参数先于模型 调用被拒绝 --- .../core/src/__tests__/forecast-agent.test.ts | 92 ++++++++ .../src/__tests__/forecast-runner.test.ts | 223 ++++++++++++++++++ .../src/__tests__/helpers/forecast-fixture.ts | 51 +++- packages/core/src/forecast/agent.ts | 72 ++++++ packages/core/src/forecast/prompts.ts | 127 ++++++++++ packages/core/src/forecast/render.ts | 181 ++++++++++++++ packages/core/src/forecast/runner.ts | 205 ++++++++++++++++ packages/core/src/index.ts | 40 ++++ 8 files changed, 990 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/__tests__/forecast-agent.test.ts create mode 100644 packages/core/src/__tests__/forecast-runner.test.ts create mode 100644 packages/core/src/forecast/agent.ts create mode 100644 packages/core/src/forecast/prompts.ts create mode 100644 packages/core/src/forecast/render.ts create mode 100644 packages/core/src/forecast/runner.ts diff --git a/packages/core/src/__tests__/forecast-agent.test.ts b/packages/core/src/__tests__/forecast-agent.test.ts new file mode 100644 index 00000000..a5947432 --- /dev/null +++ b/packages/core/src/__tests__/forecast-agent.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import { NarrativeForecastAgent } from "../forecast/agent.js"; +import { makeForecastBranch } from "./helpers/forecast-fixture.js"; +import type { LLMMessage, LLMResponse } from "../llm/provider.js"; + +function llmResponse(content: string): LLMResponse { + return { content, usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 } }; +} + +function validModelJson(count: number): string { + const branches = Array.from({ length: count }, (_, index) => { + const { branchId: _branchId, ...rest } = makeForecastBranch({ title: `分支${index + 1}` }); + return rest; + }); + return JSON.stringify({ branches }); +} + +function makeAgent(): NarrativeForecastAgent { + return new NarrativeForecastAgent({ + client: { provider: "openai" } as never, + model: "fake", + projectRoot: "/tmp", + }); +} + +function spyOnChat(responses: ReadonlyArray) { + const spy = vi.spyOn( + NarrativeForecastAgent.prototype as unknown as { chat: (messages: ReadonlyArray) => Promise }, + "chat", + ); + for (const content of responses) { + spy.mockResolvedValueOnce(llmResponse(content)); + } + return spy; +} + +const INPUT = { + contextMarkdown: "# 正史上下文", + divergence: "主角是否接受提议", + branchCount: 2, + horizon: 5, + baseChapter: 12, + language: "zh" as const, +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("NarrativeForecastAgent", () => { + it("returns validated branches from a valid first response", async () => { + const spy = spyOnChat([validModelJson(2)]); + + const output = await makeAgent().generateBranches(INPUT); + + expect(output.branches).toHaveLength(2); + expect(spy).toHaveBeenCalledTimes(1); + const [messages] = spy.mock.calls[0]!; + expect(messages[0]?.role).toBe("system"); + expect(messages[1]?.content).toContain("主角是否接受提议"); + }); + + it("retries once with the validation error when the first response is invalid", async () => { + const spy = spyOnChat(["这不是 JSON", validModelJson(2)]); + + const output = await makeAgent().generateBranches(INPUT); + + expect(output.branches).toHaveLength(2); + expect(spy).toHaveBeenCalledTimes(2); + const [retryMessages] = spy.mock.calls[1]!; + expect(retryMessages.at(-2)?.role).toBe("assistant"); + expect(retryMessages.at(-1)?.content).toContain("not valid JSON"); + }); + + it("throws after two invalid responses without further retries", async () => { + const spy = spyOnChat(["垃圾输出一", "垃圾输出二"]); + + await expect(makeAgent().generateBranches(INPUT)).rejects.toThrow(/not valid JSON/); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("treats a branch count mismatch as invalid output", async () => { + const spy = spyOnChat([validModelJson(3), validModelJson(2)]); + + const output = await makeAgent().generateBranches(INPUT); + + expect(output.branches).toHaveLength(2); + expect(spy).toHaveBeenCalledTimes(2); + const [retryMessages] = spy.mock.calls[1]!; + expect(retryMessages.at(-1)?.content).toContain("2"); + }); +}); diff --git a/packages/core/src/__tests__/forecast-runner.test.ts b/packages/core/src/__tests__/forecast-runner.test.ts new file mode 100644 index 00000000..16b9e939 --- /dev/null +++ b/packages/core/src/__tests__/forecast-runner.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NarrativeForecastAgent } from "../forecast/agent.js"; +import { + createNarrativeForecast, + getNarrativeForecast, + selectNarrativeBranch, +} from "../forecast/runner.js"; +import type { AgentContext } from "../agents/base.js"; +import { + makeModelBranch, + snapshotCanonicalFiles, + writeForecastFixtureBook, +} from "./helpers/forecast-fixture.js"; + +const BOOK_ID = "demo-book"; +const FIXED_NOW = () => new Date("2026-07-15T00:00:00Z"); +const FIXED_ID = "fc-20260715-000000"; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function runtime(projectRoot: string): AgentContext { + return { client: { provider: "openai" } as never, model: "fake", projectRoot }; +} + +function stubBranches() { + return [ + makeModelBranch({ title: "接受提议" }), + makeModelBranch({ + title: "拒绝提议", + premise: "假设主角当场拒绝并公开把柄。", + projectedChanges: { + characters: ["主角声望上升"], + relationships: ["与盟友结盟加深"], + world: ["对手提前动手"], + hooks: ["hook-03 保持休眠"], + }, + }), + ]; +} + +describe("narrative forecast runner", () => { + let root: string; + let bookDir: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "inkos-forecast-run-")); + bookDir = join(root, "books", BOOK_ID); + await writeForecastFixtureBook(bookDir); + }); + afterEach(async () => { + vi.restoreAllMocks(); + await rm(root, { recursive: true, force: true }); + }); + + function stubAgent() { + return vi.spyOn(NarrativeForecastAgent.prototype, "generateBranches") + .mockResolvedValue({ branches: stubBranches() }); + } + + function createOptions() { + return { + projectRoot: root, + bookId: BOOK_ID, + divergence: "主角是否接受对手的合作提议", + branchCount: 2, + horizon: 5, + runtime: runtime(root), + determinism: { now: FIXED_NOW }, + }; + } + + it("creates forecast.json and comparison.md with assigned branch ids", async () => { + const spy = stubAgent(); + + const result = await createNarrativeForecast(createOptions()); + + expect(spy).toHaveBeenCalledTimes(1); + expect(result.forecast.forecastId).toBe(FIXED_ID); + expect(result.forecast.baseChapter).toBe(2); + expect(result.forecast.status).toBe("active"); + expect(result.forecast.branches.map((branch) => branch.branchId)).toEqual(["branch-1", "branch-2"]); + expect(result.forecast.createdAt).toBe("2026-07-15T00:00:00.000Z"); + + const onDisk = JSON.parse(await readFile(result.forecastJsonPath, "utf-8")); + expect(onDisk.contextFingerprint).toMatch(/^[0-9a-f]{64}$/); + const comparison = await readFile(result.comparisonPath, "utf-8"); + expect(comparison).toContain("接受提议"); + expect(comparison).toContain("拒绝提议"); + }); + + it("keeps sibling branches isolated in the stored forecast", async () => { + stubAgent(); + + const result = await createNarrativeForecast(createOptions()); + + const [first, second] = result.forecast.branches; + expect(first?.projectedChanges.relationships).toEqual(["主角与盟友决裂"]); + expect(second?.projectedChanges.relationships).toEqual(["与盟友结盟加深"]); + expect(first?.beats).not.toBe(second?.beats); + }); + + it("does not modify any canonical file when creating a forecast", async () => { + stubAgent(); + const before = await snapshotCanonicalFiles(bookDir); + + await createNarrativeForecast(createOptions()); + + expect(await snapshotCanonicalFiles(bookDir)).toEqual(before); + }); + + it("leaves no forecast files behind when the model output is invalid", async () => { + const chatSpy = vi.spyOn( + NarrativeForecastAgent.prototype as unknown as { chat: () => Promise<{ content: string; usage: object }> }, + "chat", + ).mockResolvedValue({ content: "不是 JSON", usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 } }); + + await expect(createNarrativeForecast(createOptions())).rejects.toThrow(/not valid JSON/); + + expect(chatSpy).toHaveBeenCalledTimes(2); + expect(await exists(join(bookDir, "story", "runtime", "narrative-forecasts"))).toBe(false); + }); + + it("rejects out-of-range branch counts and horizons before calling the model", async () => { + const spy = stubAgent(); + + await expect(createNarrativeForecast({ ...createOptions(), branchCount: 1 })).rejects.toThrow(/branchCount/); + await expect(createNarrativeForecast({ ...createOptions(), horizon: 0 })).rejects.toThrow(/horizon/); + expect(spy).not.toHaveBeenCalled(); + }); + + it("reports a fresh forecast as active", async () => { + stubAgent(); + await createNarrativeForecast(createOptions()); + + const result = await getNarrativeForecast({ projectRoot: root, bookId: BOOK_ID, forecastId: FIXED_ID }); + + expect(result.stale).toBe(false); + expect(result.forecast.status).toBe("active"); + }); + + it("marks a forecast stale after the canonical context changes", async () => { + stubAgent(); + await createNarrativeForecast(createOptions()); + await writeFile(join(bookDir, "story", "state", "current_state.json"), JSON.stringify({ facts: ["主角离开东城"] }), "utf-8"); + + const result = await getNarrativeForecast({ projectRoot: root, bookId: BOOK_ID, forecastId: FIXED_ID }); + + expect(result.stale).toBe(true); + const onDisk = JSON.parse(await readFile(result.forecastJsonPath, "utf-8")); + expect(onDisk.status).toBe("stale"); + }); + + it("selects a branch by writing only selected-branch-plan.md", async () => { + stubAgent(); + await createNarrativeForecast(createOptions()); + const forecastJsonPath = join(bookDir, "story", "runtime", "narrative-forecasts", FIXED_ID, "forecast.json"); + const forecastJsonBefore = await readFile(forecastJsonPath, "utf-8"); + const canonBefore = await snapshotCanonicalFiles(bookDir); + + const result = await selectNarrativeBranch({ + projectRoot: root, + bookId: BOOK_ID, + forecastId: FIXED_ID, + branchId: "branch-2", + determinism: { now: FIXED_NOW }, + }); + + expect(result.branch.branchId).toBe("branch-2"); + const plan = await readFile(result.planPath, "utf-8"); + expect(plan).toContain("拒绝提议"); + expect(plan).not.toContain("branch-1"); + expect(await readFile(forecastJsonPath, "utf-8")).toBe(forecastJsonBefore); + expect(await snapshotCanonicalFiles(bookDir)).toEqual(canonBefore); + }); + + it("refuses to select a branch that does not exist", async () => { + stubAgent(); + await createNarrativeForecast(createOptions()); + + await expect(selectNarrativeBranch({ + projectRoot: root, + bookId: BOOK_ID, + forecastId: FIXED_ID, + branchId: "branch-9", + })).rejects.toThrow(/branch-9[\s\S]*branch-1, branch-2/); + + expect(await exists(join( + bookDir, "story", "runtime", "narrative-forecasts", FIXED_ID, "selected-branch-plan.md", + ))).toBe(false); + }); + + it("warns in the plan when selecting from a stale forecast", async () => { + stubAgent(); + await createNarrativeForecast(createOptions()); + await writeFile(join(bookDir, "chapters", "0003_反击.md"), "第三章正文", "utf-8"); + + const result = await selectNarrativeBranch({ + projectRoot: root, + bookId: BOOK_ID, + forecastId: FIXED_ID, + branchId: "branch-1", + determinism: { now: FIXED_NOW }, + }); + + expect(result.stale).toBe(true); + expect(await readFile(result.planPath, "utf-8")).toContain("已过期"); + }); + + it("errors early when the book does not exist", async () => { + await mkdir(join(root, "books"), { recursive: true }); + await expect(createNarrativeForecast({ ...createOptions(), bookId: "nope" })).rejects.toThrow(/nope/); + }); +}); diff --git a/packages/core/src/__tests__/helpers/forecast-fixture.ts b/packages/core/src/__tests__/helpers/forecast-fixture.ts index 5e0d3827..5b1261e7 100644 --- a/packages/core/src/__tests__/helpers/forecast-fixture.ts +++ b/packages/core/src/__tests__/helpers/forecast-fixture.ts @@ -1,4 +1,6 @@ -import type { ForecastBranch, NarrativeForecast } from "../../forecast/schema.js"; +import { mkdir, readFile, readdir, writeFile } from "node:fs/promises"; +import { join, relative } from "node:path"; +import type { ForecastBranch, ForecastModelBranch, NarrativeForecast } from "../../forecast/schema.js"; export function makeForecastBranch(overrides: Partial = {}): ForecastBranch { return { @@ -27,6 +29,53 @@ export function makeForecastBranch(overrides: Partial = {}): For }; } +export function makeModelBranch(overrides: Partial = {}): ForecastModelBranch { + const { branchId: _branchId, ...rest } = makeForecastBranch(overrides); + return rest; +} + +/** Minimal canonical book on disk for forecast runner tests. */ +export async function writeForecastFixtureBook(bookDir: string): Promise { + await mkdir(join(bookDir, "chapters"), { recursive: true }); + await mkdir(join(bookDir, "story", "state"), { recursive: true }); + await mkdir(join(bookDir, "story", "outline"), { recursive: true }); + + await writeFile(join(bookDir, "book.json"), JSON.stringify({ id: "demo-book", title: "示例书", language: "zh" }), "utf-8"); + await writeFile(join(bookDir, "chapters", "0001_开局.md"), "第一章正文", "utf-8"); + await writeFile(join(bookDir, "chapters", "0002_升级.md"), "第二章正文", "utf-8"); + await writeFile(join(bookDir, "story", "state", "current_state.json"), JSON.stringify({ facts: ["主角在东城"] }), "utf-8"); + await writeFile(join(bookDir, "story", "state", "hooks.json"), JSON.stringify({ hooks: [] }), "utf-8"); + await writeFile(join(bookDir, "story", "author_intent.md"), "# 作者意图\n复仇主线", "utf-8"); + await writeFile(join(bookDir, "story", "current_focus.md"), "# 当前聚焦\n推进证据链", "utf-8"); + await writeFile(join(bookDir, "story", "current_state.md"), "# 当前状态\n主角在东城", "utf-8"); + await writeFile(join(bookDir, "story", "pending_hooks.md"), "| hook_id | 描述 |\n| --- | --- |\n| hook-03 | 遗嘱 |", "utf-8"); + await writeFile(join(bookDir, "story", "outline", "story_frame.md"), "# 故事框架\n都市复仇", "utf-8"); +} + +/** + * Snapshot every canonical file under bookDir (excluding the forecast output + * directory) so tests can assert that forecast operations never touch canon. + */ +export async function snapshotCanonicalFiles(bookDir: string): Promise> { + const snapshot = new Map(); + await walk(bookDir, bookDir, snapshot); + return snapshot; +} + +async function walk(root: string, dir: string, snapshot: Map): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const path = join(dir, entry.name); + const rel = relative(root, path); + if (rel.startsWith(join("story", "runtime", "narrative-forecasts"))) continue; + if (entry.isDirectory()) { + await walk(root, path, snapshot); + } else { + snapshot.set(rel, await readFile(path, "utf-8")); + } + } +} + export function makeForecast(overrides: Partial = {}): NarrativeForecast { return { version: 1, diff --git a/packages/core/src/forecast/agent.ts b/packages/core/src/forecast/agent.ts new file mode 100644 index 00000000..5e9c4920 --- /dev/null +++ b/packages/core/src/forecast/agent.ts @@ -0,0 +1,72 @@ +import { BaseAgent } from "../agents/base.js"; +import type { LLMMessage } from "../llm/provider.js"; +import { + parseForecastModelOutput, + type ForecastModelOutput, +} from "./schema.js"; +import { + buildForecastRepairPrompt, + buildForecastSystemPrompt, + buildForecastUserPrompt, + type ForecastLanguage, +} from "./prompts.js"; + +export interface ForecastGenerationInput { + readonly contextMarkdown: string; + readonly divergence: string; + readonly branchCount: number; + readonly horizon: number; + readonly baseChapter: number; + readonly language: ForecastLanguage; +} + +/** + * Single-call forecast generator with one validation-driven retry: if the + * first response fails JSON/schema/branch-count validation, the error is fed + * back to the model once. A second failure surfaces as a hard error — the + * runner then writes nothing to disk. + */ +export class NarrativeForecastAgent extends BaseAgent { + get name(): string { + return "narrative-forecast"; + } + + async generateBranches(input: ForecastGenerationInput): Promise { + const messages: ReadonlyArray = [ + { role: "system", content: buildForecastSystemPrompt(input.language) }, + { role: "user", content: buildForecastUserPrompt(input, input.language) }, + ]; + const maxTokens = estimateForecastMaxTokens(input.branchCount, input.horizon); + + const first = await this.chat(messages, { temperature: 0.6, maxTokens }); + let firstError: unknown; + try { + return validateGeneratedOutput(parseForecastModelOutput(first.content), input.branchCount); + } catch (error) { + firstError = error; + this.log?.warn(`[narrative-forecast] model output invalid, retrying once: ${String(error)}`); + } + + const retry = await this.chat([ + ...messages, + { role: "assistant", content: first.content }, + { role: "user", content: buildForecastRepairPrompt(String(firstError), input.language) }, + ], { temperature: 0.4, maxTokens }); + return validateGeneratedOutput(parseForecastModelOutput(retry.content), input.branchCount); + } +} + +function validateGeneratedOutput(output: ForecastModelOutput, expectedBranches: number): ForecastModelOutput { + if (output.branches.length !== expectedBranches) { + throw new Error( + `narrative forecast model returned ${output.branches.length} branches, expected exactly ${expectedBranches}.`, + ); + } + return output; +} + +// Planning material is compact; scale headroom with branch count and horizon. +// zh chars run ~1.5 tokens each, so this deliberately over-provisions for en. +function estimateForecastMaxTokens(branchCount: number, horizon: number): number { + return Math.max(8192, branchCount * (horizon * 220 + 1600)); +} diff --git a/packages/core/src/forecast/prompts.ts b/packages/core/src/forecast/prompts.ts new file mode 100644 index 00000000..709dfed1 --- /dev/null +++ b/packages/core/src/forecast/prompts.ts @@ -0,0 +1,127 @@ +// Bilingual prompt builders for the narrative forecast agent, organized the +// same way as prompts/short-fiction.ts: each builder switches on language. + +export type ForecastLanguage = "zh" | "en"; + +export interface ForecastPromptInput { + readonly contextMarkdown: string; + readonly divergence: string; + readonly branchCount: number; + readonly horizon: number; + readonly baseChapter: number; +} + +export function buildForecastSystemPrompt(language: ForecastLanguage): string { + if (language === "en") { + return [ + "You are the narrative forecast assistant for a long-form novel.", + "Task: starting from the canonical context and the author's divergence point, project several mutually isolated, non-canonical candidate futures for the author to compare.", + "Rules:", + "- Branches are mutually exclusive: each assumes a different resolution of the divergence point and must not reference or depend on sibling branches.", + "- Branches are planning material, not prose: beats describe what happens, not scene-level detail.", + "- Respect canon: every projection must stay consistent with established facts, character locks, and world rules; any necessary conflict must be listed under risks.", + "- Output exactly one JSON object. No explanations, no markdown headings, no code fences.", + ].join("\n"); + } + return [ + "你是长篇小说的叙事推演助手。", + "任务:从正史上下文和作者给出的分歧点出发,推演多个相互隔离的非正史候选未来分支,供作者并排比较。", + "规则:", + "- 分支之间互斥:每个分支对分歧点做出不同走向的假设,不得引用或依赖其他分支。", + "- 分支是规划材料,不是正文:节拍只写“发生了什么”,不写场景级细节。", + "- 尊重正史:所有推演必须与既有事实、人设锁和世界规则一致;确需冲突时必须写进 risks。", + "- 只输出一个 JSON 对象,不要输出解释、markdown 标题或代码围栏。", + ].join("\n"); +} + +export function buildForecastUserPrompt(input: ForecastPromptInput, language: ForecastLanguage): string { + const firstChapter = input.baseChapter + 1; + if (language === "en") { + return [ + input.contextMarkdown, + "", + "## Divergence point", + "", + input.divergence, + "", + "## Output requirements", + "", + `Produce exactly ${input.branchCount} candidate branches. Each branch covers roughly ${input.horizon} future chapters starting at chapter ${firstChapter}.`, + "Return JSON with exactly this shape (field names must match):", + forecastJsonShape(firstChapter, "en"), + ].join("\n"); + } + return [ + input.contextMarkdown, + "", + "## 分歧点", + "", + input.divergence, + "", + "## 输出要求", + "", + `生成恰好 ${input.branchCount} 个候选分支。每个分支覆盖从第 ${firstChapter} 章开始、约 ${input.horizon} 章的未来走向。`, + "输出 JSON,结构如下(字段名必须完全一致):", + forecastJsonShape(firstChapter, "zh"), + ].join("\n"); +} + +export function buildForecastRepairPrompt(validationError: string, language: ForecastLanguage): string { + if (language === "en") { + return [ + `Your previous output failed validation: ${validationError}`, + "Re-output the complete JSON object only, fixing the problem above. No explanations, no code fences.", + ].join("\n"); + } + return [ + `你上一次的输出未通过校验:${validationError}`, + "请修正上述问题后重新输出完整 JSON 对象,只输出 JSON,不要解释,不要代码围栏。", + ].join("\n"); +} + +function forecastJsonShape(firstChapter: number, language: ForecastLanguage): string { + if (language === "en") { + return [ + "{", + ' "branches": [', + " {", + ' "title": "short branch title",', + ' "premise": "the assumption this branch makes about the divergence point",', + ` "beats": [{ "chapter": integer chapter number starting at ${firstChapter}, "summary": "what happens in that chapter" }],`, + ' "characterDecisions": [{ "character": "name", "decision": "the key decision this character makes" }],', + ' "projectedChanges": {', + ' "characters": ["projected character state changes"],', + ' "relationships": ["projected relationship changes"],', + ' "world": ["projected world/faction changes"],', + ' "hooks": ["which hooks advance, fire, or break"]', + " },", + ' "risks": [{ "kind": "continuity|causality|character", "description": "consistency risk" }],', + ' "uncertainties": ["open uncertainties"],', + ' "intentAlignment": { "score": integer 0-100, "rationale": "how well this matches the author intent and current focus" }', + " }", + " ]", + "}", + ].join("\n"); + } + return [ + "{", + ' "branches": [', + " {", + ' "title": "分支短标题",', + ' "premise": "该分支对分歧点做出的前提与假设",', + ` "beats": [{ "chapter": 从 ${firstChapter} 开始的整数章号, "summary": "该章发生什么" }],`, + ' "characterDecisions": [{ "character": "人物名", "decision": "该人物做出的关键决策" }],', + ' "projectedChanges": {', + ' "characters": ["人物状态预计变化"],', + ' "relationships": ["关系预计变化"],', + ' "world": ["世界/势力预计变化"],', + ' "hooks": ["哪些伏笔被推进、引爆或破坏"]', + " },", + ' "risks": [{ "kind": "continuity|causality|character", "description": "一致性风险" }],', + ' "uncertainties": ["不确定因素"],', + ' "intentAlignment": { "score": 0到100的整数, "rationale": "与作者意图和当前聚焦的匹配说明" }', + " }", + " ]", + "}", + ].join("\n"); +} diff --git a/packages/core/src/forecast/render.ts b/packages/core/src/forecast/render.ts new file mode 100644 index 00000000..12c097bc --- /dev/null +++ b/packages/core/src/forecast/render.ts @@ -0,0 +1,181 @@ +import type { ForecastBranch, NarrativeForecast } from "./schema.js"; + +// Deterministic markdown renderers for forecast artifacts. Both documents are +// derived purely from forecast.json so re-rendering never needs another LLM +// call and tests stay clock-free. + +export function renderForecastComparisonMarkdown(forecast: NarrativeForecast): string { + const zh = forecast.language === "zh"; + const header = zh + ? [ + `# 叙事推演对比:${forecast.divergence}`, + "", + `- 推演 ID:${forecast.forecastId}`, + `- 书籍:${forecast.bookId}`, + `- 基准章节:第 ${forecast.baseChapter} 章`, + `- 推演跨度:约 ${forecast.horizon} 章`, + `- 生成时间:${forecast.createdAt}`, + "", + "> 本文件是非正史规划材料,不会改动正文或权威状态。", + ] + : [ + `# Narrative forecast comparison: ${forecast.divergence}`, + "", + `- Forecast id: ${forecast.forecastId}`, + `- Book: ${forecast.bookId}`, + `- Base chapter: ${forecast.baseChapter}`, + `- Horizon: ~${forecast.horizon} chapters`, + `- Created at: ${forecast.createdAt}`, + "", + "> Non-canonical planning material. Nothing here modifies prose or authoritative state.", + ]; + + const tableHeader = zh + ? ["| 分支 | 标题 | 意图匹配 | 风险数 | 前提 |", "| --- | --- | --- | --- | --- |"] + : ["| Branch | Title | Intent fit | Risks | Premise |", "| --- | --- | --- | --- | --- |"]; + const tableRows = forecast.branches.map((branch) => + `| ${branch.branchId} | ${escapeCell(branch.title)} | ${branch.intentAlignment.score} | ${branch.risks.length} | ${escapeCell(branch.premise)} |`); + + const sections = forecast.branches.map((branch) => renderBranchSection(branch, zh)); + + return [...header, "", ...tableHeader, ...tableRows, "", sections.join("\n\n")].join("\n"); +} + +export function renderSelectedBranchPlanMarkdown(input: { + readonly forecast: NarrativeForecast; + readonly branch: ForecastBranch; + readonly selectedAt: string; + readonly stale: boolean; +}): string { + const { forecast, branch } = input; + const zh = forecast.language === "zh"; + + const staleWarning = input.stale + ? (zh + ? "> ⚠️ 该推演已过期:正史章节或状态在推演生成后发生了变化。以下计划基于旧上下文,采用前请重新核对,必要时重新生成推演。" + : "> ⚠️ This forecast is stale: canonical chapters or state changed after it was generated. The plan below is based on outdated context — re-check before applying, and regenerate if needed.") + : ""; + + const header = zh + ? [ + `# 已选分支计划:${branch.title}`, + "", + `- 推演 ID:${forecast.forecastId}`, + `- 分支:${branch.branchId}`, + `- 分歧点:${forecast.divergence}`, + `- 基准章节:第 ${forecast.baseChapter} 章`, + `- 选择时间:${input.selectedAt}`, + ] + : [ + `# Selected branch plan: ${branch.title}`, + "", + `- Forecast id: ${forecast.forecastId}`, + `- Branch: ${branch.branchId}`, + `- Divergence: ${forecast.divergence}`, + `- Base chapter: ${forecast.baseChapter}`, + `- Selected at: ${input.selectedAt}`, + ]; + + const footer = zh + ? "> 本计划不修改正史。要把它应用到大纲、章节意图或权威状态,需要另行确认的操作(v1 不自动执行)。" + : "> This plan does not modify canon. Applying it to the outline, chapter intents, or authoritative state is a separate, explicitly confirmed operation (not automated in v1)."; + + return [ + ...header, + ...(staleWarning ? ["", staleWarning] : []), + "", + renderBranchSection(branch, zh, { headingLevel: 2, includeBranchId: false }), + "", + footer, + ].join("\n"); +} + +function renderBranchSection( + branch: ForecastBranch, + zh: boolean, + options: { readonly headingLevel?: number; readonly includeBranchId?: boolean } = {}, +): string { + const level = "#".repeat(options.headingLevel ?? 2); + const sub = `${level}#`; + const heading = options.includeBranchId === false + ? `${level} ${branch.title}` + : `${level} ${branch.branchId}:${branch.title}`; + + const labels = zh + ? { + premise: "前提与假设", + beats: "未来章节节拍", + decisions: "人物决策", + changes: "预计变化", + characters: "人物", + relationships: "关系", + world: "世界", + hooks: "伏笔", + risks: "一致性风险", + uncertainties: "不确定性", + alignment: "作者意图匹配度", + chapterPrefix: (n: number) => `第 ${n} 章`, + none: "(无)", + } + : { + premise: "Premise and assumptions", + beats: "Future chapter beats", + decisions: "Character decisions", + changes: "Projected changes", + characters: "Characters", + relationships: "Relationships", + world: "World", + hooks: "Hooks", + risks: "Consistency risks", + uncertainties: "Uncertainties", + alignment: "Author intent alignment", + chapterPrefix: (n: number) => `Chapter ${n}`, + none: "(none)", + }; + + const list = (items: ReadonlyArray): string => + items.length > 0 ? items.map((item) => `- ${item}`).join("\n") : labels.none; + + return [ + heading, + "", + `${sub} ${labels.premise}`, + "", + branch.premise, + "", + `${sub} ${labels.beats}`, + "", + list(branch.beats.map((beat) => `${labels.chapterPrefix(beat.chapter)}:${beat.summary}`)), + "", + `${sub} ${labels.decisions}`, + "", + list(branch.characterDecisions.map((decision) => `${decision.character}:${decision.decision}`)), + "", + `${sub} ${labels.changes}`, + "", + `- ${labels.characters}:${joinOrNone(branch.projectedChanges.characters, labels.none)}`, + `- ${labels.relationships}:${joinOrNone(branch.projectedChanges.relationships, labels.none)}`, + `- ${labels.world}:${joinOrNone(branch.projectedChanges.world, labels.none)}`, + `- ${labels.hooks}:${joinOrNone(branch.projectedChanges.hooks, labels.none)}`, + "", + `${sub} ${labels.risks}`, + "", + list(branch.risks.map((risk) => `[${risk.kind}] ${risk.description}`)), + "", + `${sub} ${labels.uncertainties}`, + "", + list([...branch.uncertainties]), + "", + `${sub} ${labels.alignment}`, + "", + `${branch.intentAlignment.score}/100 — ${branch.intentAlignment.rationale}`, + ].join("\n"); +} + +function joinOrNone(items: ReadonlyArray, none: string): string { + return items.length > 0 ? items.join(";") : none; +} + +function escapeCell(value: string): string { + return value.replace(/\|/g, "\\|").replace(/\n/g, " "); +} diff --git a/packages/core/src/forecast/runner.ts b/packages/core/src/forecast/runner.ts new file mode 100644 index 00000000..2f0c5e47 --- /dev/null +++ b/packages/core/src/forecast/runner.ts @@ -0,0 +1,205 @@ +import { access } from "node:fs/promises"; +import { join } from "node:path"; +import type { AgentContext } from "../agents/base.js"; +import { assertSafeBookId } from "../utils/book-id.js"; +import { NarrativeForecastAgent } from "./agent.js"; +import { buildForecastContext, renderForecastContextMarkdown } from "./context-builder.js"; +import { renderForecastComparisonMarkdown, renderSelectedBranchPlanMarkdown } from "./render.js"; +import { + FORECAST_DEFAULT_BRANCHES, + FORECAST_DEFAULT_HORIZON, + FORECAST_MAX_BRANCHES, + FORECAST_MAX_HORIZON, + FORECAST_MIN_BRANCHES, + FORECAST_MIN_HORIZON, + type ForecastBranch, + type NarrativeForecast, +} from "./schema.js"; +import { ForecastStore, type ForecastStoreOptions } from "./store.js"; + +// The three v1 operations from RFC #342: create / get / select. All artifacts +// stay under story/runtime/narrative-forecasts// — no operation +// here may write story/state/*.json, story/*.md control docs, or chapters/. + +export interface CreateNarrativeForecastOptions { + readonly projectRoot: string; + readonly bookId: string; + readonly divergence: string; + readonly branchCount?: number; + readonly horizon?: number; + readonly runtime: AgentContext; + readonly determinism?: ForecastStoreOptions; + readonly onProgress?: (message: string) => void; +} + +export interface NarrativeForecastCreateResult { + readonly forecast: NarrativeForecast; + readonly forecastJsonPath: string; + readonly comparisonPath: string; +} + +export async function createNarrativeForecast( + options: CreateNarrativeForecastOptions, +): Promise { + const bookId = assertSafeBookId(options.bookId, "forecast.bookId"); + const divergence = options.divergence.trim(); + if (!divergence) { + throw new Error("divergence is required: describe the decision point the forecast should branch on."); + } + const branchCount = boundedInteger( + options.branchCount, FORECAST_DEFAULT_BRANCHES, "branchCount", FORECAST_MIN_BRANCHES, FORECAST_MAX_BRANCHES, + ); + const horizon = boundedInteger( + options.horizon, FORECAST_DEFAULT_HORIZON, "horizon", FORECAST_MIN_HORIZON, FORECAST_MAX_HORIZON, + ); + const bookDir = await resolveBookDir(options.projectRoot, bookId); + + options.onProgress?.("Reading canonical context..."); + const context = await buildForecastContext({ bookDir, bookId }); + + options.onProgress?.(`Projecting ${branchCount} candidate branches...`); + const agent = new NarrativeForecastAgent(options.runtime); + const modelOutput = await agent.generateBranches({ + contextMarkdown: renderForecastContextMarkdown(context), + divergence, + branchCount, + horizon, + baseChapter: context.baseChapter, + language: context.language, + }); + + const store = new ForecastStore(bookDir, options.determinism); + const forecast: NarrativeForecast = { + version: 1, + forecastId: await store.allocateForecastId(), + bookId, + createdAt: store.now().toISOString(), + language: context.language, + divergence, + horizon, + baseChapter: context.baseChapter, + contextFingerprint: context.contextFingerprint, + status: "active", + branches: modelOutput.branches.map((branch, index) => ({ + branchId: `branch-${index + 1}`, + ...branch, + })), + }; + + options.onProgress?.("Writing forecast artifacts..."); + const paths = await store.save(forecast, renderForecastComparisonMarkdown(forecast)); + return { forecast, ...paths }; +} + +export interface GetNarrativeForecastOptions { + readonly projectRoot: string; + readonly bookId: string; + readonly forecastId: string; +} + +export interface NarrativeForecastGetResult { + readonly forecast: NarrativeForecast; + readonly stale: boolean; + readonly forecastJsonPath: string; + readonly comparisonPath: string; +} + +export async function getNarrativeForecast( + options: GetNarrativeForecastOptions, +): Promise { + const bookId = assertSafeBookId(options.bookId, "forecast.bookId"); + const bookDir = await resolveBookDir(options.projectRoot, bookId); + const store = new ForecastStore(bookDir); + + let forecast = await store.load(options.forecastId); + const stale = await isForecastStale(bookDir, bookId, forecast); + if (stale && forecast.status === "active") { + // Persist the stale marker so later readers see it without recomputing. + forecast = await store.markStale(forecast); + } + + return { + forecast, + stale, + forecastJsonPath: store.forecastJsonPath(forecast.forecastId), + comparisonPath: store.comparisonPath(forecast.forecastId), + }; +} + +export interface SelectNarrativeBranchOptions { + readonly projectRoot: string; + readonly bookId: string; + readonly forecastId: string; + readonly branchId: string; + readonly determinism?: ForecastStoreOptions; +} + +export interface NarrativeForecastSelectResult { + readonly forecast: NarrativeForecast; + readonly branch: ForecastBranch; + readonly stale: boolean; + readonly planPath: string; +} + +/** + * Select one branch: writes ONLY selected-branch-plan.md. Applying the plan + * to the outline / chapter intents / canonical state is a separate, + * user-confirmed operation outside v1. + */ +export async function selectNarrativeBranch( + options: SelectNarrativeBranchOptions, +): Promise { + const bookId = assertSafeBookId(options.bookId, "forecast.bookId"); + const bookDir = await resolveBookDir(options.projectRoot, bookId); + const store = new ForecastStore(bookDir, options.determinism); + + const forecast = await store.load(options.forecastId); + const branch = forecast.branches.find((candidate) => candidate.branchId === options.branchId); + if (!branch) { + throw new Error( + `Branch "${options.branchId}" not found in forecast "${forecast.forecastId}". ` + + `Available branches: ${forecast.branches.map((candidate) => candidate.branchId).join(", ")}`, + ); + } + + const stale = await isForecastStale(bookDir, bookId, forecast); + const planPath = await store.writeSelectedPlan( + forecast.forecastId, + renderSelectedBranchPlanMarkdown({ + forecast, + branch, + selectedAt: store.now().toISOString(), + stale, + }), + ); + + return { forecast, branch, stale, planPath }; +} + +async function isForecastStale( + bookDir: string, + bookId: string, + forecast: NarrativeForecast, +): Promise { + if (forecast.status === "stale") return true; + const context = await buildForecastContext({ bookDir, bookId }); + return context.contextFingerprint !== forecast.contextFingerprint; +} + +async function resolveBookDir(projectRoot: string, bookId: string): Promise { + const bookDir = join(projectRoot, "books", bookId); + try { + await access(join(bookDir, "book.json")); + } catch { + throw new Error(`Book "${bookId}" not found under ${join(projectRoot, "books")}.`); + } + return bookDir; +} + +function boundedInteger(value: number | undefined, fallback: number, name: string, min: number, max: number): number { + const parsed = value ?? fallback; + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be an integer between ${min} and ${max}.`); + } + return parsed; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5b92842..d328c516 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -397,6 +397,46 @@ export { type ShortFictionRunRuntimes, } from "./pipeline/short-fiction-runner.js"; +// Narrative forecast (issue #342): non-canonical multi-branch story projection +export { + FORECAST_MIN_BRANCHES, + FORECAST_MAX_BRANCHES, + FORECAST_DEFAULT_BRANCHES, + FORECAST_MIN_HORIZON, + FORECAST_MAX_HORIZON, + FORECAST_DEFAULT_HORIZON, + NarrativeForecastSchema, + ForecastBranchSchema, + parseForecastModelOutput, + type NarrativeForecast, + type ForecastBranch, + type ForecastBeat, + type ForecastRisk, + type ForecastStatus, + type ForecastModelOutput, +} from "./forecast/schema.js"; +export { ForecastStore, assertSafeForecastId, type ForecastStoreOptions } from "./forecast/store.js"; +export { + buildForecastContext, + computeContextFingerprint, + renderForecastContextMarkdown, + type ForecastContext, + type ForecastContextSections, +} from "./forecast/context-builder.js"; +export { NarrativeForecastAgent, type ForecastGenerationInput } from "./forecast/agent.js"; +export { renderForecastComparisonMarkdown, renderSelectedBranchPlanMarkdown } from "./forecast/render.js"; +export { + createNarrativeForecast, + getNarrativeForecast, + selectNarrativeBranch, + type CreateNarrativeForecastOptions, + type GetNarrativeForecastOptions, + type SelectNarrativeBranchOptions, + type NarrativeForecastCreateResult, + type NarrativeForecastGetResult, + type NarrativeForecastSelectResult, +} from "./forecast/runner.js"; + // Agent (pi-agent integration) export * from "./agent/index.js";