feat(core): add narrative forecast schema and store

叙事多线推演(issue #342)v1 的数据层:
- forecast/schema.ts:Zod schema 定义 NarrativeForecast / ForecastBranch
  (前提假设、未来章节节拍、人物决策、预计变化、一致性风险、不确定性、
  意图匹配度),分支数限定 2-5,superRefine 拒绝重复 branchId;
  parseForecastModelOutput 解析模型输出(容忍代码围栏/前后缀散文/尾逗号,
  非法 JSON 或 schema 不符直接抛错,不落任何文件)
- forecast/store.ts:确定性本地存储,产物固定在
  story/runtime/narrative-forecasts/<forecastId>/ 下(forecast.json、
  comparison.md、selected-branch-plan.md);时钟与 id 工厂可注入,
  id 冲突自动加后缀;写入前先过 schema 校验,非法数据不产生半成品目录
- 测试:schema 校验边界(分支数/重复 id/越界分数/非法风险类型)、
  store 读写往返、损坏文件报错、markStale 不可变更新、路径安全
This commit is contained in:
Ma
2026-07-15 15:45:39 +08:00
parent 1420040f16
commit 4daa7b8dc8
5 changed files with 582 additions and 0 deletions
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import {
FORECAST_MAX_BRANCHES,
FORECAST_MIN_BRANCHES,
NarrativeForecastSchema,
parseForecastModelOutput,
} from "../forecast/schema.js";
import { makeForecast, makeForecastBranch } from "./helpers/forecast-fixture.js";
function modelBranches(count: number) {
return Array.from({ length: count }, (_, index) => {
const { branchId: _branchId, ...rest } = makeForecastBranch({ title: `分支${index + 1}` });
return rest;
});
}
describe("narrative forecast schema", () => {
it("accepts a complete forecast", () => {
expect(() => NarrativeForecastSchema.parse(makeForecast())).not.toThrow();
});
it("rejects fewer than the minimum branch count", () => {
const forecast = makeForecast({ branches: [makeForecastBranch()] });
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
expect(FORECAST_MIN_BRANCHES).toBe(2);
});
it("rejects more than the maximum branch count", () => {
const branches = Array.from({ length: 6 }, (_, index) =>
makeForecastBranch({ branchId: `branch-${index + 1}` }));
expect(() => NarrativeForecastSchema.parse(makeForecast({ branches }))).toThrow();
expect(FORECAST_MAX_BRANCHES).toBe(5);
});
it("rejects duplicate branch ids so sibling branches stay isolated", () => {
const forecast = makeForecast({
branches: [makeForecastBranch(), makeForecastBranch({ title: "重复 id 的分支" })],
});
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow(/branch-1/);
});
it("rejects an intent alignment score outside 0-100", () => {
const forecast = makeForecast({
branches: [
makeForecastBranch({ intentAlignment: { score: 101, rationale: "越界" } }),
makeForecastBranch({ branchId: "branch-2" }),
],
});
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
});
it("rejects unknown risk kinds", () => {
const forecast = makeForecast({
branches: [
makeForecastBranch({ risks: [{ kind: "vibes" as never, description: "不合法" }] }),
makeForecastBranch({ branchId: "branch-2" }),
],
});
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
});
it("rejects a branch with no beats", () => {
const forecast = makeForecast({
branches: [
makeForecastBranch({ beats: [] }),
makeForecastBranch({ branchId: "branch-2" }),
],
});
expect(() => NarrativeForecastSchema.parse(forecast)).toThrow();
});
});
describe("parseForecastModelOutput", () => {
it("parses a plain JSON object", () => {
const output = parseForecastModelOutput(JSON.stringify({ branches: modelBranches(2) }));
expect(output.branches).toHaveLength(2);
expect(output.branches[0]?.title).toBe("分支1");
});
it("parses JSON wrapped in a code fence", () => {
const raw = "```json\n" + JSON.stringify({ branches: modelBranches(3) }) + "\n```";
expect(parseForecastModelOutput(raw).branches).toHaveLength(3);
});
it("parses JSON surrounded by prose", () => {
const raw = `好的,以下是推演结果:\n${JSON.stringify({ branches: modelBranches(2) })}\n希望有帮助。`;
expect(parseForecastModelOutput(raw).branches).toHaveLength(2);
});
it("tolerates trailing commas", () => {
const raw = JSON.stringify({ branches: modelBranches(2) }).replace(/\]\}$/, "],}");
expect(parseForecastModelOutput(raw).branches).toHaveLength(2);
});
it("throws a JSON error on unparsable output", () => {
expect(() => parseForecastModelOutput("这不是 JSON")).toThrow(/not valid JSON/);
});
it("throws a schema error on the wrong shape", () => {
expect(() => parseForecastModelOutput(JSON.stringify({ branches: [{ title: "缺字段" }] })))
.toThrow(/schema validation/);
});
it("throws a schema error when the model returns too few branches", () => {
expect(() => parseForecastModelOutput(JSON.stringify({ branches: modelBranches(1) })))
.toThrow(/schema validation/);
});
});
@@ -0,0 +1,128 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { access, mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ForecastStore, assertSafeForecastId } from "../forecast/store.js";
import { makeForecast } from "./helpers/forecast-fixture.js";
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
describe("ForecastStore", () => {
let bookDir: string;
beforeEach(async () => {
bookDir = await mkdtemp(join(tmpdir(), "inkos-forecast-store-"));
});
afterEach(async () => {
await rm(bookDir, { recursive: true, force: true });
});
it("saves forecast.json and comparison.md under story/runtime/narrative-forecasts/<id>/", async () => {
const store = new ForecastStore(bookDir);
const forecast = makeForecast();
const saved = await store.save(forecast, "# 对比\n内容");
const expectedDir = join(bookDir, "story", "runtime", "narrative-forecasts", forecast.forecastId);
expect(saved.forecastJsonPath).toBe(join(expectedDir, "forecast.json"));
expect(saved.comparisonPath).toBe(join(expectedDir, "comparison.md"));
expect(JSON.parse(await readFile(saved.forecastJsonPath, "utf-8")).forecastId).toBe(forecast.forecastId);
expect(await readFile(saved.comparisonPath, "utf-8")).toContain("# 对比");
});
it("round-trips a forecast through save and load", async () => {
const store = new ForecastStore(bookDir);
const forecast = makeForecast();
await store.save(forecast, "cmp");
const loaded = await store.load(forecast.forecastId);
expect(loaded).toEqual(forecast);
});
it("refuses to save an invalid forecast and leaves no files behind", async () => {
const store = new ForecastStore(bookDir);
const invalid = { ...makeForecast(), branches: [] };
await expect(store.save(invalid as never, "cmp")).rejects.toThrow();
expect(await exists(join(bookDir, "story", "runtime", "narrative-forecasts"))).toBe(false);
});
it("throws a not-found error with the forecast id when loading a missing forecast", async () => {
const store = new ForecastStore(bookDir);
await expect(store.load("fc-missing")).rejects.toThrow(/fc-missing/);
});
it("throws when the stored forecast.json is corrupted", async () => {
const store = new ForecastStore(bookDir);
const dir = join(bookDir, "story", "runtime", "narrative-forecasts", "fc-bad");
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "forecast.json"), "{ not json", "utf-8");
await expect(store.load("fc-bad")).rejects.toThrow(/fc-bad/);
});
it("markStale persists a stale status without mutating the input object", async () => {
const store = new ForecastStore(bookDir);
const forecast = makeForecast();
await store.save(forecast, "cmp");
const stale = await store.markStale(forecast);
expect(stale.status).toBe("stale");
expect(forecast.status).toBe("active");
expect((await store.load(forecast.forecastId)).status).toBe("stale");
});
it("writes selected-branch-plan.md next to forecast.json", async () => {
const store = new ForecastStore(bookDir);
const forecast = makeForecast();
await store.save(forecast, "cmp");
const planPath = await store.writeSelectedPlan(forecast.forecastId, "# 分支计划");
expect(planPath).toBe(join(
bookDir, "story", "runtime", "narrative-forecasts", forecast.forecastId, "selected-branch-plan.md",
));
expect(await readFile(planPath, "utf-8")).toContain("# 分支计划");
});
it("derives deterministic forecast ids from the injected clock", async () => {
const store = new ForecastStore(bookDir, { now: () => new Date("2026-07-15T08:09:10Z") });
expect(await store.allocateForecastId()).toBe("fc-20260715-080910");
});
it("suffixes the forecast id when the directory already exists", async () => {
const store = new ForecastStore(bookDir, { now: () => new Date("2026-07-15T08:09:10Z") });
await store.save(makeForecast({ forecastId: "fc-20260715-080910" }), "cmp");
expect(await store.allocateForecastId()).toBe("fc-20260715-080910-2");
});
it("prefers the injected id factory", async () => {
const store = new ForecastStore(bookDir, { idFactory: () => "fc-custom" });
expect(await store.allocateForecastId()).toBe("fc-custom");
});
it("rejects unsafe forecast ids", () => {
expect(() => assertSafeForecastId("../escape")).toThrow();
expect(() => assertSafeForecastId("a/b")).toThrow();
expect(() => assertSafeForecastId("")).toThrow();
expect(assertSafeForecastId("fc-20260715-080910")).toBe("fc-20260715-080910");
});
it("lists saved forecast ids", async () => {
const store = new ForecastStore(bookDir);
await store.save(makeForecast({ forecastId: "fc-b" }), "cmp");
await store.save(makeForecast({ forecastId: "fc-a" }), "cmp");
expect(await store.list()).toEqual(["fc-a", "fc-b"]);
});
});
@@ -0,0 +1,53 @@
import type { ForecastBranch, NarrativeForecast } from "../../forecast/schema.js";
export function makeForecastBranch(overrides: Partial<ForecastBranch> = {}): ForecastBranch {
return {
branchId: "branch-1",
title: "主角接受提议",
premise: "假设主角在第13章接受了对手的合作提议。",
beats: [
{ chapter: 13, summary: "主角签下合作协议,盟友震怒离场。" },
{ chapter: 14, summary: "合作暴露主角软肋,对手开始渗透。" },
],
characterDecisions: [
{ character: "主角", decision: "接受提议换取短期资源" },
],
projectedChanges: {
characters: ["主角信誉受损"],
relationships: ["主角与盟友决裂"],
world: ["东城势力平衡向对手倾斜"],
hooks: ["hook-03 提前引爆"],
},
risks: [
{ kind: "character", description: "主角人设锁强调不妥协,接受提议需要强动机铺垫。" },
],
uncertainties: ["盟友是否会立即反目尚不确定"],
intentAlignment: { score: 62, rationale: "偏离作者意图中的复仇主线,但制造了新张力。" },
...overrides,
};
}
export function makeForecast(overrides: Partial<NarrativeForecast> = {}): NarrativeForecast {
return {
version: 1,
forecastId: "fc-20260101000000",
bookId: "demo-book",
createdAt: "2026-01-01T00:00:00.000Z",
language: "zh",
divergence: "主角是否接受对手的合作提议",
horizon: 5,
baseChapter: 12,
contextFingerprint: "abc123",
status: "active",
branches: [
makeForecastBranch(),
makeForecastBranch({
branchId: "branch-2",
title: "主角拒绝提议",
premise: "假设主角当场拒绝并公开对手把柄。",
intentAlignment: { score: 88, rationale: "延续复仇主线,符合当前聚焦。" },
}),
],
...overrides,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { z } from "zod";
// Narrative Forecast (issue #342): non-canonical multi-branch story projection.
// A forecast never becomes canon by itself — it is planning material stored
// under story/runtime/narrative-forecasts/ and compared by the author.
export const FORECAST_MIN_BRANCHES = 2;
export const FORECAST_MAX_BRANCHES = 5;
export const FORECAST_DEFAULT_BRANCHES = 3;
export const FORECAST_MIN_HORIZON = 1;
export const FORECAST_MAX_HORIZON = 10;
export const FORECAST_DEFAULT_HORIZON = 5;
export const ForecastRiskSchema = z.object({
kind: z.enum(["continuity", "causality", "character"]),
description: z.string().min(1),
});
export type ForecastRisk = z.infer<typeof ForecastRiskSchema>;
export const ForecastBeatSchema = z.object({
// Absolute chapter number the beat targets (baseChapter + offset).
chapter: z.number().int().min(1),
summary: z.string().min(1),
});
export type ForecastBeat = z.infer<typeof ForecastBeatSchema>;
export const ForecastCharacterDecisionSchema = z.object({
character: z.string().min(1),
decision: z.string().min(1),
});
export type ForecastCharacterDecision = z.infer<typeof ForecastCharacterDecisionSchema>;
export const ForecastProjectedChangesSchema = z.object({
characters: z.array(z.string()),
relationships: z.array(z.string()),
world: z.array(z.string()),
hooks: z.array(z.string()),
});
export type ForecastProjectedChanges = z.infer<typeof ForecastProjectedChangesSchema>;
export const ForecastIntentAlignmentSchema = z.object({
// 0-100: how well the branch matches author_intent / current_focus.
score: z.number().min(0).max(100),
rationale: z.string().min(1),
});
export type ForecastIntentAlignment = z.infer<typeof ForecastIntentAlignmentSchema>;
export const ForecastBranchSchema = z.object({
branchId: z.string().regex(/^branch-\d+$/),
title: z.string().min(1),
premise: z.string().min(1),
beats: z.array(ForecastBeatSchema).min(1),
characterDecisions: z.array(ForecastCharacterDecisionSchema),
projectedChanges: ForecastProjectedChangesSchema,
risks: z.array(ForecastRiskSchema),
uncertainties: z.array(z.string()),
intentAlignment: ForecastIntentAlignmentSchema,
});
export type ForecastBranch = z.infer<typeof ForecastBranchSchema>;
export const ForecastStatusSchema = z.enum(["active", "stale"]);
export type ForecastStatus = z.infer<typeof ForecastStatusSchema>;
export const NarrativeForecastSchema = z.object({
version: z.literal(1),
forecastId: z.string().min(1),
bookId: z.string().min(1),
createdAt: z.string().min(1),
language: z.enum(["zh", "en"]),
divergence: z.string().min(1),
horizon: z.number().int().min(FORECAST_MIN_HORIZON).max(FORECAST_MAX_HORIZON),
baseChapter: z.number().int().min(0),
contextFingerprint: z.string().min(1),
status: ForecastStatusSchema,
branches: z.array(ForecastBranchSchema).min(FORECAST_MIN_BRANCHES).max(FORECAST_MAX_BRANCHES),
}).superRefine((forecast, ctx) => {
const seen = new Set<string>();
for (const branch of forecast.branches) {
if (seen.has(branch.branchId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `duplicate branchId: ${branch.branchId}`,
path: ["branches"],
});
}
seen.add(branch.branchId);
}
});
export type NarrativeForecast = z.infer<typeof NarrativeForecastSchema>;
// What the model must return. branchIds are assigned deterministically by the
// runner (branch-1..branch-N) so the model cannot collide or skip ids.
export const ForecastModelBranchSchema = ForecastBranchSchema.omit({ branchId: true });
export type ForecastModelBranch = z.infer<typeof ForecastModelBranchSchema>;
export const ForecastModelOutputSchema = z.object({
branches: z.array(ForecastModelBranchSchema).min(FORECAST_MIN_BRANCHES).max(FORECAST_MAX_BRANCHES),
});
export type ForecastModelOutput = z.infer<typeof ForecastModelOutputSchema>;
/**
* Parse and validate the raw model response for a forecast run. Tolerates a
* code fence, surrounding prose and trailing commas; anything else is a hard
* error so an invalid response never reaches disk.
*/
export function parseForecastModelOutput(raw: string): ForecastModelOutput {
const jsonSlice = extractJsonObject(stripCodeFence(raw.trim()));
let parsed: unknown;
try {
parsed = JSON.parse(sanitizeJson(jsonSlice));
} catch (error) {
throw new Error(`narrative forecast model output is not valid JSON: ${String(error)}`);
}
try {
return ForecastModelOutputSchema.parse(parsed);
} catch (error) {
throw new Error(`narrative forecast model output failed schema validation: ${String(error)}`);
}
}
function stripCodeFence(value: string): string {
const fenced = value.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
return fenced?.[1]?.trim() ?? value;
}
function extractJsonObject(value: string): string {
const start = value.indexOf("{");
const end = value.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return value;
return value.slice(start, end + 1);
}
function sanitizeJson(value: string): string {
return value
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
.replace(/,\s*([}\]])/g, "$1");
}
+156
View File
@@ -0,0 +1,156 @@
import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { NarrativeForecastSchema, type NarrativeForecast } from "./schema.js";
// All forecast artifacts live under this directory inside a book. Nothing in
// this store may ever write outside of it — that is the v1 safety boundary
// (story/state/*.json, story/*.md control docs and chapters/ stay untouched).
export interface ForecastStoreOptions {
// Injectable clock so forecast ids and timestamps are deterministic in tests.
readonly now?: () => Date;
readonly idFactory?: () => string;
}
export function assertSafeForecastId(value: string): string {
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(value)) {
throw new Error(`Invalid forecast id: ${JSON.stringify(value)}`);
}
return value;
}
export class ForecastStore {
constructor(
private readonly bookDir: string,
private readonly options: ForecastStoreOptions = {},
) {}
get forecastsDir(): string {
return join(this.bookDir, "story", "runtime", "narrative-forecasts");
}
forecastDir(forecastId: string): string {
return join(this.forecastsDir, assertSafeForecastId(forecastId));
}
forecastJsonPath(forecastId: string): string {
return join(this.forecastDir(forecastId), "forecast.json");
}
comparisonPath(forecastId: string): string {
return join(this.forecastDir(forecastId), "comparison.md");
}
selectedPlanPath(forecastId: string): string {
return join(this.forecastDir(forecastId), "selected-branch-plan.md");
}
now(): Date {
return (this.options.now ?? (() => new Date()))();
}
/**
* Derive the next forecast id: injected factory first, otherwise a
* timestamp from the injected clock; suffix -2/-3/... if the directory
* already exists so re-runs never overwrite an earlier forecast.
*/
async allocateForecastId(): Promise<string> {
const base = this.options.idFactory
? assertSafeForecastId(this.options.idFactory())
: `fc-${formatTimestamp(this.now())}`;
let candidate = base;
for (let suffix = 2; await pathExists(this.forecastDir(candidate)); suffix += 1) {
candidate = `${base}-${suffix}`;
}
return candidate;
}
async save(
forecast: NarrativeForecast,
comparisonMarkdown: string,
): Promise<{ readonly forecastJsonPath: string; readonly comparisonPath: string }> {
// Validate before touching the filesystem so an invalid forecast never
// leaves a half-written directory behind.
const validated = NarrativeForecastSchema.parse(forecast);
const dir = this.forecastDir(validated.forecastId);
await mkdir(dir, { recursive: true });
const forecastJsonPath = this.forecastJsonPath(validated.forecastId);
const comparisonPath = this.comparisonPath(validated.forecastId);
await writeFile(forecastJsonPath, `${JSON.stringify(validated, null, 2)}\n`, "utf-8");
await writeFile(comparisonPath, `${comparisonMarkdown.trimEnd()}\n`, "utf-8");
return { forecastJsonPath, comparisonPath };
}
async load(forecastId: string): Promise<NarrativeForecast> {
const path = this.forecastJsonPath(forecastId);
let raw: string;
try {
raw = await readFile(path, "utf-8");
} catch (error) {
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") {
const available = await this.list();
throw new Error(
`Narrative forecast "${forecastId}" not found. Available forecasts: ${available.length > 0 ? available.join(", ") : "(none)"}`,
);
}
throw error;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Narrative forecast "${forecastId}" has corrupted forecast.json: ${String(error)}`);
}
try {
return NarrativeForecastSchema.parse(parsed);
} catch (error) {
throw new Error(`Narrative forecast "${forecastId}" failed schema validation: ${String(error)}`);
}
}
async list(): Promise<ReadonlyArray<string>> {
let entries: string[];
try {
entries = await readdir(this.forecastsDir);
} catch {
return [];
}
const ids: string[] = [];
for (const entry of entries) {
if (await pathExists(join(this.forecastsDir, entry, "forecast.json"))) {
ids.push(entry);
}
}
return ids.sort();
}
async markStale(forecast: NarrativeForecast): Promise<NarrativeForecast> {
const stale: NarrativeForecast = { ...forecast, status: "stale" };
await writeFile(
this.forecastJsonPath(stale.forecastId),
`${JSON.stringify(NarrativeForecastSchema.parse(stale), null, 2)}\n`,
"utf-8",
);
return stale;
}
async writeSelectedPlan(forecastId: string, markdown: string): Promise<string> {
const path = this.selectedPlanPath(forecastId);
await writeFile(path, `${markdown.trimEnd()}\n`, "utf-8");
return path;
}
}
function formatTimestamp(date: Date): string {
const iso = date.toISOString();
return `${iso.slice(0, 10).replace(/-/g, "")}-${iso.slice(11, 19).replace(/:/g, "")}`;
}
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}