diff --git a/packages/core/src/__tests__/forecast-schema.test.ts b/packages/core/src/__tests__/forecast-schema.test.ts new file mode 100644 index 00000000..197a925f --- /dev/null +++ b/packages/core/src/__tests__/forecast-schema.test.ts @@ -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/); + }); +}); diff --git a/packages/core/src/__tests__/forecast-store.test.ts b/packages/core/src/__tests__/forecast-store.test.ts new file mode 100644 index 00000000..276f867e --- /dev/null +++ b/packages/core/src/__tests__/forecast-store.test.ts @@ -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 { + 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//", 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"]); + }); +}); diff --git a/packages/core/src/__tests__/helpers/forecast-fixture.ts b/packages/core/src/__tests__/helpers/forecast-fixture.ts new file mode 100644 index 00000000..5e0d3827 --- /dev/null +++ b/packages/core/src/__tests__/helpers/forecast-fixture.ts @@ -0,0 +1,53 @@ +import type { ForecastBranch, NarrativeForecast } from "../../forecast/schema.js"; + +export function makeForecastBranch(overrides: Partial = {}): 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 { + 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, + }; +} diff --git a/packages/core/src/forecast/schema.ts b/packages/core/src/forecast/schema.ts new file mode 100644 index 00000000..76656e20 --- /dev/null +++ b/packages/core/src/forecast/schema.ts @@ -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; + +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; + +export const ForecastCharacterDecisionSchema = z.object({ + character: z.string().min(1), + decision: z.string().min(1), +}); +export type ForecastCharacterDecision = z.infer; + +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; + +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; + +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; + +export const ForecastStatusSchema = z.enum(["active", "stale"]); +export type ForecastStatus = z.infer; + +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(); + 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; + +// 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; + +export const ForecastModelOutputSchema = z.object({ + branches: z.array(ForecastModelBranchSchema).min(FORECAST_MIN_BRANCHES).max(FORECAST_MAX_BRANCHES), +}); +export type ForecastModelOutput = z.infer; + +/** + * 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"); +} diff --git a/packages/core/src/forecast/store.ts b/packages/core/src/forecast/store.ts new file mode 100644 index 00000000..6ae9b104 --- /dev/null +++ b/packages/core/src/forecast/store.ts @@ -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 { + 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 { + 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> { + 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 { + 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 { + 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 { + try { + await access(path); + return true; + } catch { + return false; + } +}