Bladeren bron

refactor(pipeline): extract persisted governed plan loader

Ma 7 maanden geleden
bovenliggende
commit
2a5ab2cbc0

+ 96 - 0
packages/core/src/__tests__/persisted-governed-plan.test.ts

@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadPersistedPlan, relativeToBookDir } from "../pipeline/persisted-governed-plan.js";
+
+describe("persisted governed plan helpers", () => {
+  it("parses a persisted intent markdown file into a reusable plan", async () => {
+    const bookDir = await mkdtemp(join(tmpdir(), "inkos-persisted-plan-"));
+    const runtimeDir = join(bookDir, "story", "runtime");
+    await mkdir(runtimeDir, { recursive: true });
+
+    const runtimePath = join(runtimeDir, "chapter-0007.intent.md");
+    await writeFile(
+      runtimePath,
+      [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        "Bring the focus back to the mentor oath conflict.",
+        "",
+        "## Outline Node",
+        "Track the mentor oath fallout.",
+        "",
+        "## Must Keep",
+        "- Lin Yue keeps the oath token hidden.",
+        "- Mentor debt stays unresolved.",
+        "",
+        "## Must Avoid",
+        "- Open a new guild-route mystery.",
+        "",
+        "## Style Emphasis",
+        "- restrained prose",
+        "",
+        "## Conflicts",
+        "- duty: repay the oath without exposing the token",
+        "- trust: keep the mentor debt personal",
+        "",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    try {
+      const plan = await loadPersistedPlan(bookDir, 7);
+
+      expect(plan).not.toBeNull();
+      expect(plan?.runtimePath).toBe(runtimePath);
+      expect(plan?.intent.goal).toBe("Bring the focus back to the mentor oath conflict.");
+      expect(plan?.intent.outlineNode).toBe("Track the mentor oath fallout.");
+      expect(plan?.intent.mustKeep).toEqual([
+        "Lin Yue keeps the oath token hidden.",
+        "Mentor debt stays unresolved.",
+      ]);
+      expect(plan?.intent.mustAvoid).toEqual(["Open a new guild-route mystery."]);
+      expect(plan?.intent.styleEmphasis).toEqual(["restrained prose"]);
+      expect(plan?.intent.conflicts).toEqual([
+        { type: "duty", resolution: "repay the oath without exposing the token" },
+        { type: "trust", resolution: "keep the mentor debt personal" },
+      ]);
+      expect(plan?.plannerInputs).toEqual([runtimePath]);
+    } finally {
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  });
+
+  it("rejects persisted intents whose goal is still a placeholder", async () => {
+    const bookDir = await mkdtemp(join(tmpdir(), "inkos-persisted-plan-invalid-"));
+    const runtimeDir = join(bookDir, "story", "runtime");
+    await mkdir(runtimeDir, { recursive: true });
+
+    await writeFile(
+      join(runtimeDir, "chapter-0003.intent.md"),
+      [
+        "# Chapter Intent",
+        "",
+        "## Goal",
+        "(describe the goal here)",
+        "",
+      ].join("\n"),
+      "utf-8",
+    );
+
+    try {
+      await expect(loadPersistedPlan(bookDir, 3)).resolves.toBeNull();
+    } finally {
+      await rm(bookDir, { recursive: true, force: true });
+    }
+  });
+
+  it("normalizes persisted artifact paths relative to the book directory", () => {
+    expect(relativeToBookDir(
+      "/tmp/book",
+      "/tmp/book/story/runtime/chapter-0001.intent.md",
+    )).toBe("story/runtime/chapter-0001.intent.md");
+  });
+});

+ 101 - 0
packages/core/src/pipeline/persisted-governed-plan.ts

@@ -0,0 +1,101 @@
+import { readFile } from "node:fs/promises";
+import { join, relative } from "node:path";
+import type { PlanChapterOutput } from "../agents/planner.js";
+import { ChapterIntentSchema } from "../models/input-governance.js";
+
+export async function loadPersistedPlan(
+  bookDir: string,
+  chapterNumber: number,
+): Promise<PlanChapterOutput | null> {
+  const runtimePath = join(
+    bookDir,
+    "story",
+    "runtime",
+    `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`,
+  );
+
+  try {
+    const intentMarkdown = await readFile(runtimePath, "utf-8");
+    const sections = parseIntentSections(intentMarkdown);
+    const goal = readIntentScalar(sections, "Goal");
+    if (!goal || isInvalidPersistedIntentScalar(goal)) return null;
+
+    const outlineNode = readIntentScalar(sections, "Outline Node");
+    if (outlineNode && outlineNode !== "(not found)" && isInvalidPersistedIntentScalar(outlineNode)) {
+      return null;
+    }
+    const conflicts = readIntentList(sections, "Conflicts")
+      .map((line) => {
+        const separator = line.indexOf(":");
+        if (separator < 0) return null;
+
+        const type = line.slice(0, separator).trim();
+        const resolution = line.slice(separator + 1).trim();
+        if (!type || !resolution) return null;
+        return { type, resolution };
+      })
+      .filter((conflict): conflict is { type: string; resolution: string } => conflict !== null);
+
+    return {
+      intent: ChapterIntentSchema.parse({
+        chapter: chapterNumber,
+        goal,
+        outlineNode: outlineNode && outlineNode !== "(not found)" ? outlineNode : undefined,
+        mustKeep: readIntentList(sections, "Must Keep"),
+        mustAvoid: readIntentList(sections, "Must Avoid"),
+        styleEmphasis: readIntentList(sections, "Style Emphasis"),
+        conflicts,
+      }),
+      intentMarkdown,
+      plannerInputs: [runtimePath],
+      runtimePath,
+    };
+  } catch {
+    return null;
+  }
+}
+
+export function relativeToBookDir(bookDir: string, absolutePath: string): string {
+  return relative(bookDir, absolutePath).replaceAll("\\", "/");
+}
+
+function parseIntentSections(markdown: string): Map<string, string[]> {
+  const sections = new Map<string, string[]>();
+  let current: string | null = null;
+
+  for (const line of markdown.split("\n")) {
+    if (line.startsWith("## ")) {
+      current = line.slice(3).trim();
+      sections.set(current, []);
+      continue;
+    }
+
+    if (!current) continue;
+    sections.get(current)?.push(line);
+  }
+
+  return sections;
+}
+
+function readIntentScalar(sections: Map<string, string[]>, name: string): string | undefined {
+  const lines = sections.get(name) ?? [];
+  const value = lines.map((line) => line.trim()).find((line) => line.length > 0);
+  return value && value !== "- none" ? value : undefined;
+}
+
+function readIntentList(sections: Map<string, string[]>, name: string): string[] {
+  return (sections.get(name) ?? [])
+    .map((line) => line.trim())
+    .filter((line) => line.startsWith("-") && line !== "- none")
+    .map((line) => line.replace(/^-\s*/, ""));
+}
+
+function isInvalidPersistedIntentScalar(value: string): boolean {
+  const normalized = value.trim();
+  if (!normalized) return true;
+  if (/^[*_`~::|.-]+$/.test(normalized)) return true;
+  return (
+    /^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized)
+    || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized)
+  );
+}

+ 9 - 102
packages/core/src/pipeline/runner.ts

@@ -27,13 +27,13 @@ import type { AgentContext } from "../agents/base.js";
 import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
 import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js";
-import { ChapterIntentSchema, type ContextPackage, type RuleStack } from "../models/input-governance.js";
+import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js";
 import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js";
 import { loadNarrativeMemorySeed, loadSnapshotCurrentStateFacts } from "../state/runtime-state-store.js";
 import { rewriteStructuredStateFromMarkdown } from "../state/state-bootstrap.js";
 import { readFile, readdir, writeFile, mkdir, rename, rm, stat } from "node:fs/promises";
-import { join, relative } from "node:path";
+import { join } from "node:path";
 import {
   buildStateDegradedPersistenceOutput,
   buildStateDegradedReviewNote,
@@ -41,6 +41,7 @@ import {
   resolveStateDegradedBaseStatus,
   retrySettlementAfterValidationFailure,
 } from "./chapter-state-recovery.js";
+import { loadPersistedPlan, relativeToBookDir } from "./persisted-governed-plan.js";
 
 export interface PipelineConfig {
   readonly client: LLMClient;
@@ -575,7 +576,7 @@ export class PipelineRunner {
     return {
       bookId,
       chapterNumber,
-      intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
+      intentPath: relativeToBookDir(bookDir, plan.runtimePath),
       goal: plan.intent.goal,
       conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
     };
@@ -599,12 +600,12 @@ export class PipelineRunner {
     return {
       bookId,
       chapterNumber,
-      intentPath: this.relativeToBookDir(bookDir, plan.runtimePath),
+      intentPath: relativeToBookDir(bookDir, plan.runtimePath),
       goal: plan.intent.goal,
       conflicts: plan.intent.conflicts.map((conflict) => `${conflict.type}: ${conflict.resolution}`),
-      contextPath: this.relativeToBookDir(bookDir, composed.contextPath),
-      ruleStackPath: this.relativeToBookDir(bookDir, composed.ruleStackPath),
-      tracePath: this.relativeToBookDir(bookDir, composed.tracePath),
+      contextPath: relativeToBookDir(bookDir, composed.contextPath),
+      ruleStackPath: relativeToBookDir(bookDir, composed.ruleStackPath),
+      tracePath: relativeToBookDir(bookDir, composed.tracePath),
     };
   }
 
@@ -2732,7 +2733,7 @@ ${matrix}`,
       options?.reuseExistingIntentWhenContextMissing &&
       (!externalContext || externalContext.trim().length === 0)
     ) {
-      const persisted = await this.loadPersistedPlan(bookDir, chapterNumber);
+      const persisted = await loadPersistedPlan(bookDir, chapterNumber);
       if (persisted) return persisted;
     }
 
@@ -2745,100 +2746,6 @@ ${matrix}`,
     });
   }
 
-  private async loadPersistedPlan(bookDir: string, chapterNumber: number): Promise<PlanChapterOutput | null> {
-    const runtimePath = join(
-      bookDir,
-      "story",
-      "runtime",
-      `chapter-${String(chapterNumber).padStart(4, "0")}.intent.md`,
-    );
-
-    try {
-      const intentMarkdown = await readFile(runtimePath, "utf-8");
-      const sections = this.parseIntentSections(intentMarkdown);
-      const goal = this.readIntentScalar(sections, "Goal");
-      if (!goal || this.isInvalidPersistedIntentScalar(goal)) return null;
-
-      const outlineNode = this.readIntentScalar(sections, "Outline Node");
-      if (outlineNode && outlineNode !== "(not found)" && this.isInvalidPersistedIntentScalar(outlineNode)) {
-        return null;
-      }
-      const conflicts = this.readIntentList(sections, "Conflicts")
-        .map((line) => {
-          const separator = line.indexOf(":");
-          if (separator < 0) return null;
-
-          const type = line.slice(0, separator).trim();
-          const resolution = line.slice(separator + 1).trim();
-          if (!type || !resolution) return null;
-          return { type, resolution };
-        })
-        .filter((conflict): conflict is { type: string; resolution: string } => conflict !== null);
-
-      return {
-        intent: ChapterIntentSchema.parse({
-          chapter: chapterNumber,
-          goal,
-          outlineNode: outlineNode && outlineNode !== "(not found)" ? outlineNode : undefined,
-          mustKeep: this.readIntentList(sections, "Must Keep"),
-          mustAvoid: this.readIntentList(sections, "Must Avoid"),
-          styleEmphasis: this.readIntentList(sections, "Style Emphasis"),
-          conflicts,
-        }),
-        intentMarkdown,
-        plannerInputs: [runtimePath],
-        runtimePath,
-      };
-    } catch {
-      return null;
-    }
-  }
-
-  private parseIntentSections(markdown: string): Map<string, string[]> {
-    const sections = new Map<string, string[]>();
-    let current: string | null = null;
-
-    for (const line of markdown.split("\n")) {
-      if (line.startsWith("## ")) {
-        current = line.slice(3).trim();
-        sections.set(current, []);
-        continue;
-      }
-
-      if (!current) continue;
-      sections.get(current)?.push(line);
-    }
-
-    return sections;
-  }
-
-  private readIntentScalar(sections: Map<string, string[]>, name: string): string | undefined {
-    const lines = sections.get(name) ?? [];
-    const value = lines.map((line) => line.trim()).find((line) => line.length > 0);
-    return value && value !== "- none" ? value : undefined;
-  }
-
-  private readIntentList(sections: Map<string, string[]>, name: string): string[] {
-    return (sections.get(name) ?? [])
-      .map((line) => line.trim())
-      .filter((line) => line.startsWith("-") && line !== "- none")
-      .map((line) => line.replace(/^-\s*/, ""));
-  }
-
-  private isInvalidPersistedIntentScalar(value: string): boolean {
-    const normalized = value.trim();
-    if (!normalized) return true;
-    if (/^[*_`~::|.-]+$/.test(normalized)) return true;
-    return (
-      /^\((describe|briefly describe|write)\b[\s\S]*\)$/i.test(normalized)
-      || /^((?:在这里描述|描述|填写|写下)[\s\S]*)$/u.test(normalized)
-    );
-  }
-
-  private relativeToBookDir(bookDir: string, absolutePath: string): string {
-    return relative(bookDir, absolutePath).replaceAll("\\", "/");
-  }
-
   private async emitWebhook(
     event: WebhookEvent,
     bookId: string,