feat(core): add narrative forecast agent, prompts, renderers and runner

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/<id>/,模型输出非法时不留任何文件
  - get:重算 contextFingerprint,正史变化后把 status 持久化标记为 stale
  - select:只写 selected-branch-plan.md(不改 forecast.json、不碰正史),
    选不存在的分支报错并列出可选分支
- index.ts:导出 forecast 模块公共 API
- 测试:agent 校验重试路径、create/get/select 全流程、兄弟分支隔离、
  正史文件前后快照一致、stale 标记、非法输出零残留、越界参数先于模型
  调用被拒绝
This commit is contained in:
Ma
2026-07-15 15:54:13 +08:00
parent 28a04c0a07
commit 7b009ea39d
8 changed files with 990 additions and 1 deletions
@@ -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<string>) {
const spy = vi.spyOn(
NarrativeForecastAgent.prototype as unknown as { chat: (messages: ReadonlyArray<LLMMessage>) => Promise<LLMResponse> },
"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");
});
});
@@ -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<boolean> {
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/);
});
});
@@ -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> = {}): ForecastBranch {
return {
@@ -27,6 +29,53 @@ export function makeForecastBranch(overrides: Partial<ForecastBranch> = {}): For
};
}
export function makeModelBranch(overrides: Partial<ForecastBranch> = {}): 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<void> {
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<ReadonlyMap<string, string>> {
const snapshot = new Map<string, string>();
await walk(bookDir, bookDir, snapshot);
return snapshot;
}
async function walk(root: string, dir: string, snapshot: Map<string, string>): Promise<void> {
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> = {}): NarrativeForecast {
return {
version: 1,
+72
View File
@@ -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<ForecastModelOutput> {
const messages: ReadonlyArray<LLMMessage> = [
{ 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));
}
+127
View File
@@ -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");
}
+181
View File
@@ -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>): 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<string>, none: string): string {
return items.length > 0 ? items.join("") : none;
}
function escapeCell(value: string): string {
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
}
+205
View File
@@ -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/<forecastId>/ — 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<NarrativeForecastCreateResult> {
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<NarrativeForecastGetResult> {
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<NarrativeForecastSelectResult> {
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<boolean> {
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<string> {
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;
}
+40
View File
@@ -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";