Ver Fonte

fix(planner): remove double retrieval in groundwork

Ma há 7 meses atrás
pai
commit
98943a1ab1

+ 4 - 1
packages/core/src/__tests__/planner-prompts.test.ts

@@ -40,6 +40,7 @@ const MATERIALS: PlanningMaterials = {
     },
   ],
   previousEndingHook: "The unsigned transfer sheet becomes urgent.",
+  previousEndingExcerpt: "Lin Yue folded the transfer sheet and realized the warehouse door had already been opened from inside.",
   memorySelection: {
     summaries: [],
     hooks: [],
@@ -51,7 +52,7 @@ const MATERIALS: PlanningMaterials = {
 };
 
 describe("planner prompt builders", () => {
-  it("forbids meta-language leakage in the system prompt", () => {
+  it("includes an explicit meta-language ban list in the system prompt", () => {
     const prompt = buildPlannerSystemPrompt("zh");
 
     expect(prompt).toContain("ChapterBrief");
@@ -70,6 +71,8 @@ describe("planner prompt builders", () => {
 
     expect(prompt).toContain("## Previous Ending Hook");
     expect(prompt).toContain("unsigned transfer sheet");
+    expect(prompt).toContain("## Previous Ending Excerpt");
+    expect(prompt).toContain("warehouse door");
     expect(prompt).toContain("## Recent Chronicle");
     expect(prompt).toContain("## Active Hooks");
     expect(prompt).not.toContain("mustKeep");

+ 12 - 0
packages/core/src/__tests__/planning-materials.test.ts

@@ -14,6 +14,7 @@ describe("gatherPlanningMaterials", () => {
     bookDir = join(root, "books", "harbor-book");
     storyDir = join(bookDir, "story");
     await mkdir(storyDir, { recursive: true });
+    await mkdir(join(bookDir, "chapters"), { recursive: true });
 
     await Promise.all([
       writeFile(join(storyDir, "author_intent.md"), "# Author Intent\n\nStay on the mentor-debt line.\n", "utf-8"),
@@ -45,6 +46,16 @@ describe("gatherPlanningMaterials", () => {
         ].join("\n"),
         "utf-8",
       ),
+      writeFile(
+        join(bookDir, "chapters", "0003_Canal Shadow.md"),
+        [
+          "# 第3章 Canal Shadow",
+          "",
+          "林月把那张转运单塞进袖口,没再回头。",
+          "雨水顺着码头铁皮往下淌,阿盛在后面只说了一句:仓库今晚会出事。",
+        ].join("\n"),
+        "utf-8",
+      ),
     ]);
   });
 
@@ -64,6 +75,7 @@ describe("gatherPlanningMaterials", () => {
     expect(result.currentFocus).toContain("harbor ledger");
     expect(result.recentSummaries).toHaveLength(2);
     expect(result.previousEndingHook).toContain("warehouse");
+    expect(result.previousEndingExcerpt).toContain("仓库今晚会出事");
     expect(result.activeHooks).toEqual([
       expect.objectContaining({
         hookId: "H019",

+ 7 - 0
packages/core/src/agents/planner-prompts.ts

@@ -8,6 +8,10 @@ export interface PlannerPromptInput {
   readonly materials: PlanningMaterials;
 }
 
+// TODO(v10): wire this prompt builder into the live planner path once the
+// LLM planner + legacy fallback lands. Right now it is a verified contract,
+// not an active execution path.
+
 export function buildPlannerSystemPrompt(language: "zh" | "en"): string {
   if (language === "en") {
     return [
@@ -83,6 +87,9 @@ export function buildPlannerUserPrompt(input: PlannerPromptInput): string {
     "## Previous Ending Hook",
     materials.previousEndingHook ?? "(none)",
     "",
+    "## Previous Ending Excerpt",
+    materials.previousEndingExcerpt ?? "(none)",
+    "",
     "## Recent Chronicle",
     renderRecentSummaries(materials),
     "",

+ 19 - 21
packages/core/src/agents/planner.ts

@@ -11,7 +11,10 @@ import {
 } from "../utils/memory-retrieval.js";
 import { analyzeChapterCadence } from "../utils/chapter-cadence.js";
 import { buildPlannerHookAgenda } from "../utils/hook-agenda.js";
-import { gatherPlanningMaterials } from "../utils/planning-materials.js";
+import {
+  gatherPlanningMaterials,
+  loadPlanningSeedMaterials,
+} from "../utils/planning-materials.js";
 
 export interface PlanChapterInput {
   readonly book: BookConfig;
@@ -37,16 +40,12 @@ export class PlannerAgent extends BaseAgent {
     const runtimeDir = join(storyDir, "runtime");
     await mkdir(runtimeDir, { recursive: true });
 
-    const volumeOutline = await this.readFileOrDefault(join(storyDir, "volume_outline.md"));
-
-    const outlineNode = this.findOutlineNode(volumeOutline, input.chapterNumber);
-    const matchedOutlineAnchor = this.hasMatchedOutlineAnchor(volumeOutline, input.chapterNumber);
-    const seedMaterials = await gatherPlanningMaterials({
+    const seedMaterials = await loadPlanningSeedMaterials({
       bookDir: input.bookDir,
       chapterNumber: input.chapterNumber,
-      goal: this.deriveGoal(input.externalContext, "", "", outlineNode, input.chapterNumber),
-      outlineNode,
     });
+    const outlineNode = this.findOutlineNode(seedMaterials.volumeOutline, input.chapterNumber);
+    const matchedOutlineAnchor = this.hasMatchedOutlineAnchor(seedMaterials.volumeOutline, input.chapterNumber);
     const goal = this.deriveGoal(
       input.externalContext,
       seedMaterials.currentFocus,
@@ -58,7 +57,7 @@ export class PlannerAgent extends BaseAgent {
     const mustKeep = this.collectMustKeep(seedMaterials.currentState, seedMaterials.storyBible);
     const mustAvoid = this.collectMustAvoid(seedMaterials.currentFocus, parsedRules.rules.prohibitions);
     const styleEmphasis = this.collectStyleEmphasis(seedMaterials.authorIntent, seedMaterials.currentFocus);
-    const conflicts = this.collectConflicts(input.externalContext, seedMaterials.currentFocus, outlineNode, volumeOutline);
+    const conflicts = this.collectConflicts(input.externalContext, seedMaterials.currentFocus, outlineNode, seedMaterials.volumeOutline);
     const planningAnchor = conflicts.length > 0 ? undefined : outlineNode;
     const materials = await gatherPlanningMaterials({
       bookDir: input.bookDir,
@@ -66,6 +65,7 @@ export class PlannerAgent extends BaseAgent {
       goal,
       outlineNode: planningAnchor,
       mustKeep,
+      seed: seedMaterials,
     });
     const memorySelection = materials.memorySelection;
     const activeHookCount = memorySelection.activeHooks.filter(
@@ -78,15 +78,15 @@ export class PlannerAgent extends BaseAgent {
       language: input.book.language ?? "zh",
     });
     const directives = this.buildStructuredDirectives({
-          chapterNumber: input.chapterNumber,
-          language: input.book.language,
-          volumeOutline,
-          outlineNode,
-          matchedOutlineAnchor,
-          chapterSummaries: materials.chapterSummariesRaw,
-        });
-
-    // ── Skill injection: translate writing methodology into concrete rules ──
+      chapterNumber: input.chapterNumber,
+      language: input.book.language,
+      volumeOutline: seedMaterials.volumeOutline,
+      outlineNode,
+      matchedOutlineAnchor,
+      chapterSummaries: materials.chapterSummariesRaw,
+    });
+
+    // TODO(v10): delete this skill-to-mustKeep bridge once the LLM planner lands.
     this.injectStructuralSkills({
       chapterNumber: input.chapterNumber,
       language: input.book.language ?? "zh",
@@ -130,9 +130,7 @@ export class PlannerAgent extends BaseAgent {
     return {
       intent,
       intentMarkdown,
-      plannerInputs: [
-        ...materials.plannerInputs,
-      ],
+      plannerInputs: materials.plannerInputs,
       runtimePath,
     };
   }

+ 80 - 22
packages/core/src/utils/planning-materials.ts

@@ -1,4 +1,4 @@
-import { readFile } from "node:fs/promises";
+import { readFile, readdir } from "node:fs/promises";
 import { join } from "node:path";
 import type { StoredHook, StoredSummary } from "../state/memory-db.js";
 import {
@@ -7,7 +7,7 @@ import {
   type MemorySelection,
 } from "./memory-retrieval.js";
 
-export interface PlanningMaterials {
+export interface PlanningSeedMaterials {
   readonly storyDir: string;
   readonly authorIntent: string;
   readonly currentFocus: string;
@@ -18,8 +18,12 @@ export interface PlanningMaterials {
   readonly chapterSummariesRaw: string;
   readonly outlineNode?: string;
   readonly recentSummaries: ReadonlyArray<StoredSummary>;
-  readonly activeHooks: ReadonlyArray<StoredHook>;
   readonly previousEndingHook?: string;
+  readonly previousEndingExcerpt?: string;
+}
+
+export interface PlanningMaterials extends PlanningSeedMaterials {
+  readonly activeHooks: ReadonlyArray<StoredHook>;
   readonly memorySelection: MemorySelection;
   readonly plannerInputs: ReadonlyArray<string>;
 }
@@ -32,13 +36,42 @@ async function readFileOrDefault(path: string): Promise<string> {
   }
 }
 
-export async function gatherPlanningMaterials(params: {
+async function readPreviousEndingExcerpt(
+  bookDir: string,
+  chapterNumber: number,
+): Promise<string | undefined> {
+  const previousChapter = chapterNumber - 1;
+  if (previousChapter < 1) {
+    return undefined;
+  }
+
+  const chaptersDir = join(bookDir, "chapters");
+  const padded = String(previousChapter).padStart(4, "0");
+  try {
+    const files = await readdir(chaptersDir);
+    const match = files.find((file) => file.startsWith(padded) && file.endsWith(".md"));
+    if (!match) {
+      return undefined;
+    }
+    const markdown = await readFile(join(chaptersDir, match), "utf-8");
+    const body = markdown
+      .split("\n")
+      .slice(1)
+      .join("\n")
+      .trim();
+    if (!body) {
+      return undefined;
+    }
+    return body.slice(-320).trim();
+  } catch {
+    return undefined;
+  }
+}
+
+export async function loadPlanningSeedMaterials(params: {
   readonly bookDir: string;
   readonly chapterNumber: number;
-  readonly goal: string;
-  readonly outlineNode?: string;
-  readonly mustKeep?: ReadonlyArray<string>;
-}): Promise<PlanningMaterials> {
+}): Promise<PlanningSeedMaterials> {
   const storyDir = join(params.bookDir, "story");
   const sourcePaths = {
     authorIntent: join(storyDir, "author_intent.md"),
@@ -58,6 +91,7 @@ export async function gatherPlanningMaterials(params: {
     chapterSummariesRaw,
     bookRulesRaw,
     currentState,
+    previousEndingExcerpt,
   ] = await Promise.all([
     readFileOrDefault(sourcePaths.authorIntent),
     readFileOrDefault(sourcePaths.currentFocus),
@@ -66,21 +100,12 @@ export async function gatherPlanningMaterials(params: {
     readFileOrDefault(sourcePaths.chapterSummaries),
     readFileOrDefault(sourcePaths.bookRules),
     readFileOrDefault(sourcePaths.currentState),
+    readPreviousEndingExcerpt(params.bookDir, params.chapterNumber),
   ]);
 
   const chapterSummaries = parseChapterSummariesMarkdown(chapterSummariesRaw)
     .filter((summary) => summary.chapter < params.chapterNumber)
     .sort((left, right) => right.chapter - left.chapter);
-  const recentSummaries = chapterSummaries.slice(0, 4).sort((left, right) => left.chapter - right.chapter);
-  const previousEndingHook = chapterSummaries[0]?.hookActivity || undefined;
-
-  const memorySelection = await retrieveMemorySelection({
-    bookDir: params.bookDir,
-    chapterNumber: params.chapterNumber,
-    goal: params.goal,
-    outlineNode: params.outlineNode,
-    mustKeep: params.mustKeep,
-  });
 
   return {
     storyDir,
@@ -91,14 +116,47 @@ export async function gatherPlanningMaterials(params: {
     bookRulesRaw,
     currentState,
     chapterSummariesRaw,
+    recentSummaries: chapterSummaries.slice(0, 4).sort((left, right) => left.chapter - right.chapter),
+    previousEndingHook: chapterSummaries[0]?.hookActivity || undefined,
+    previousEndingExcerpt,
+  };
+}
+
+export async function gatherPlanningMaterials(params: {
+  readonly bookDir: string;
+  readonly chapterNumber: number;
+  readonly goal: string;
+  readonly outlineNode?: string;
+  readonly mustKeep?: ReadonlyArray<string>;
+  readonly seed?: PlanningSeedMaterials;
+}): Promise<PlanningMaterials> {
+  const seed = params.seed ?? await loadPlanningSeedMaterials({
+    bookDir: params.bookDir,
+    chapterNumber: params.chapterNumber,
+  });
+
+  const memorySelection = await retrieveMemorySelection({
+    bookDir: params.bookDir,
+    chapterNumber: params.chapterNumber,
+    goal: params.goal,
+    outlineNode: params.outlineNode,
+    mustKeep: params.mustKeep,
+  });
+
+  return {
+    ...seed,
     outlineNode: params.outlineNode,
-    recentSummaries,
     activeHooks: memorySelection.activeHooks,
-    previousEndingHook,
     memorySelection,
     plannerInputs: [
-      ...Object.values(sourcePaths),
-      join(storyDir, "pending_hooks.md"),
+      join(seed.storyDir, "author_intent.md"),
+      join(seed.storyDir, "current_focus.md"),
+      join(seed.storyDir, "story_bible.md"),
+      join(seed.storyDir, "volume_outline.md"),
+      join(seed.storyDir, "chapter_summaries.md"),
+      join(seed.storyDir, "book_rules.md"),
+      join(seed.storyDir, "current_state.md"),
+      join(seed.storyDir, "pending_hooks.md"),
       ...(memorySelection.dbPath ? [memorySelection.dbPath] : []),
     ],
   };