Browse Source

refactor(v13): ChapterMemoSchema interface — threads, goal cap, body required [phase 3/8 part 1]

Prepare the ChapterMemo interface for the Phase 3 LLM rewrite:

- rename hookRefs -> threadRefs (threads = hooks + subplots; the memo
  now references both)
- cap goal at 50 chars (goal overflow must retry, not silently truncate)
- require non-empty body (Phase 3 will fill it with 7-section prose;
  the old `body: ""` stub is no longer valid)

Cascade the rename through composer retrieval, narrative-control render,
and all mock plans in composer/writer/pipeline-runner/pipeline-agent
tests. The models.test cases are rewritten against the new schema
constraints (goal-length rejection, empty-body rejection).

No behavior change yet — the planner still returns a stub body in this
commit; the LLM rewrite lands next.
Ma 6 months ago
parent
commit
d404aa298e

+ 3 - 3
packages/core/src/__tests__/composer.test.ts

@@ -72,7 +72,7 @@ describe("ComposerAgent", () => {
         goal: "Bring the focus back to the mentor conflict.",
         isGoldenOpening: false,
         body: "",
-        hookRefs: [],
+        threadRefs: [],
       },
       intentMarkdown: "# Chapter Intent\n",
       plannerInputs: [
@@ -542,7 +542,7 @@ describe("ComposerAgent", () => {
           ...plan.memo,
           chapter: 10,
           goal: "Bring the focus back to the mentor oath conflict.",
-          hookRefs: ["mentor-oath"],
+          threadRefs: ["mentor-oath"],
         },
       },
     });
@@ -606,7 +606,7 @@ describe("ComposerAgent", () => {
           ...plan.memo,
           chapter: 8,
           goal: "Follow the black ring pressure.",
-          hookRefs: ["black-ring"],
+          threadRefs: ["black-ring"],
         },
       },
     });

+ 30 - 11
packages/core/src/__tests__/models.test.ts

@@ -535,30 +535,49 @@ describe("ChapterIntentSchema", () => {
 });
 
 describe("ChapterMemoSchema", () => {
-  it("accepts a memo with body prose", () => {
+  it("accepts a memo with prose body and threadRefs", () => {
     const result = ChapterMemoSchema.parse({
       chapter: 12,
-      goal: "Force the heroine to choose between the ledger and the mentor debt.",
+      goal: "把七号门被动过手脚钉成现场实证",
       isGoldenOpening: true,
-      body: "Seven-section memo body (Phase 3 fills this with prose).",
-      hookRefs: ["H019", "H045"],
+      body: "## 当前任务\n主角进入七号门……\n## 不要做\n不要让对手降智。",
+      threadRefs: ["H019", "S004"],
     });
 
     expect(result.isGoldenOpening).toBe(true);
-    expect(result.body).toContain("memo body");
-    expect(result.hookRefs).toEqual(["H019", "H045"]);
+    expect(result.body).toContain("当前任务");
+    expect(result.threadRefs).toEqual(["H019", "S004"]);
   });
 
-  it("accepts a stub memo with empty body", () => {
+  it("defaults isGoldenOpening and threadRefs when omitted", () => {
     const result = ChapterMemoSchema.parse({
       chapter: 3,
-      goal: "Lock the protagonist into the first irreversible choice.",
-      body: "",
+      goal: "让主角做下第一次不可逆选择",
+      body: "## 当前任务\n主角落下决定。",
     });
 
     expect(result.isGoldenOpening).toBe(false);
-    expect(result.body).toBe("");
-    expect(result.hookRefs).toEqual([]);
+    expect(result.threadRefs).toEqual([]);
+  });
+
+  it("rejects goal longer than 50 chars", () => {
+    expect(() =>
+      ChapterMemoSchema.parse({
+        chapter: 1,
+        goal: "a".repeat(51),
+        body: "## 当前任务\nx",
+      }),
+    ).toThrow();
+  });
+
+  it("rejects empty body", () => {
+    expect(() =>
+      ChapterMemoSchema.parse({
+        chapter: 1,
+        goal: "xxx",
+        body: "",
+      }),
+    ).toThrow();
   });
 });
 

+ 1 - 1
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -71,7 +71,7 @@ describe("agent pipeline tools", () => {
         goal,
         isGoldenOpening: false,
         body: "",
-        hookRefs: [] as string[],
+        threadRefs: [] as string[],
       };
       const intentMarkdown = [
         "# Chapter Intent",

+ 4 - 4
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -209,7 +209,7 @@ describe("PipelineRunner", () => {
         goal,
         isGoldenOpening: false,
         body: "",
-        hookRefs: [] as string[],
+        threadRefs: [] as string[],
       };
       const intentMarkdown = [
         "# Chapter Intent",
@@ -582,7 +582,7 @@ describe("PipelineRunner", () => {
         goal,
         isGoldenOpening: true,
         body: "",
-        hookRefs: [] as string[],
+        threadRefs: [] as string[],
       };
       const intentMarkdown = [
         "# Chapter Intent",
@@ -1256,7 +1256,7 @@ describe("PipelineRunner", () => {
           goal: result.intent.goal,
           isGoldenOpening: true,
           body: "",
-          hookRefs: [],
+          threadRefs: [],
         },
       };
     });
@@ -1400,7 +1400,7 @@ describe("PipelineRunner", () => {
           goal,
           isGoldenOpening: false,
           body: "",
-          hookRefs: [],
+          threadRefs: [],
         },
         intentMarkdown: intentMd,
         plannerInputs: [runtimePath],

+ 3 - 3
packages/core/src/__tests__/writer.test.ts

@@ -906,7 +906,7 @@ describe("WriterAgent", () => {
           goal: "Force Mara back toward the ledger trail.",
           isGoldenOpening: false,
           body: "",
-          hookRefs: ["ledger-fragment"],
+          threadRefs: ["ledger-fragment"],
         },
         contextPackage: {
           chapter: 4,
@@ -1036,7 +1036,7 @@ describe("WriterAgent", () => {
           goal: "Push Mara back toward the archive ledger.",
           isGoldenOpening: false,
           body: "",
-          hookRefs: ["ledger-fragment"],
+          threadRefs: ["ledger-fragment"],
         },
         contextPackage: {
           chapter: 4,
@@ -1185,7 +1185,7 @@ describe("WriterAgent", () => {
           goal: "Push Mara back toward the archive ledger.",
           isGoldenOpening: false,
           body: "本章要做的是推进 ledger-fragment tension at the archive.",
-          hookRefs: ["mentor-oath", "ledger-fragment"],
+          threadRefs: ["mentor-oath", "ledger-fragment"],
         },
         contextPackage: {
           chapter: 4,

+ 2 - 2
packages/core/src/agents/composer.ts

@@ -205,7 +205,7 @@ function deriveRetrievalHints(plan: PlanChapterOutput): string[] {
   return [
     plan.intent.goal,
     plan.intent.outlineNode,
-    ...plan.memo.hookRefs,
+    ...plan.memo.threadRefs,
   ].filter((value): value is string => Boolean(value));
 }
 
@@ -315,7 +315,7 @@ async function buildHookDebtEntries(
     }>,
   language: "zh" | "en",
 ): Promise<ContextPackage["selectedContext"]> {
-    const targetHookIds = [...new Set(plan.memo.hookRefs)];
+    const targetHookIds = [...new Set(plan.memo.threadRefs)];
     if (targetHookIds.length === 0) {
       return [];
     }

+ 3 - 3
packages/core/src/models/input-governance.ts

@@ -2,10 +2,10 @@ import { z } from "zod";
 
 export const ChapterMemoSchema = z.object({
   chapter: z.number().int().min(1),
-  goal: z.string().min(1),
+  goal: z.string().min(1).max(50),
   isGoldenOpening: z.boolean().default(false),
-  body: z.string(),
-  hookRefs: z.array(z.string()).default([]),
+  body: z.string().min(1),
+  threadRefs: z.array(z.string()).default([]),
 });
 
 export type ChapterMemo = z.infer<typeof ChapterMemoSchema>;

+ 3 - 3
packages/core/src/utils/narrative-control.ts

@@ -63,9 +63,9 @@ export function renderMemoAsNarrativeBlock(
     sections.push(`## ${isEn ? "Memo" : "本章备忘"}\n${s(memo.body)}`);
   }
 
-  if (memo.hookRefs.length > 0) {
-    const hooks = memo.hookRefs.map((hook) => `- ${hook}`).join("\n");
-    sections.push(`## ${isEn ? "Hook Refs" : "关联伏笔"}\n${hooks}`);
+  if (memo.threadRefs.length > 0) {
+    const threads = memo.threadRefs.map((id) => `- ${id}`).join("\n");
+    sections.push(`## ${isEn ? "Thread Refs" : "关联线索"}\n${threads}`);
   }
 
   if (memo.isGoldenOpening) {