Просмотр исходного кода

fix(planner): normalize LLM brief output for schema compliance

Three issues found during bench:
1. maxTokens 1600→4096: methodology prompt causes richer output
2. LLM omits chapter field: inject from caller context
3. LLM deviates from schema: normalize hookPlan.note→targetEffect,
   unknown movements→advance, object propsAndSetting→string[]
4. Tighten output requirements: explicit field names and types in
   both zh/en prompts
Ma 7 месяцев назад
Родитель
Сommit
d594f2c477
2 измененных файлов с 97 добавлено и 26 удалено
  1. 25 19
      packages/core/src/agents/planner-prompts.ts
  2. 72 7
      packages/core/src/agents/planner.ts

+ 25 - 19
packages/core/src/agents/planner-prompts.ts

@@ -226,24 +226,30 @@ export function buildPlannerUserPrompt(input: PlannerPromptInput): string {
   ].join("\n");
   ].join("\n");
 }
 }
 
 
-const ZH_OUTPUT_REQUIREMENTS = `按核心原则产出 ChapterBrief JSON。字段指引:
-
-- goal: 本章主角想要的具体动作。不是情绪,不是抽象状态。
-- chapterType: 从下列选一个——冲突 / 推进 / 过渡 / 反转 / 爽点 / 刀子 / 揭露 / 黄金开场
-- beatOutline: 3-5 个 beat。每个 instruction 是一句场景描述,不是标签。
-  phase 从 opening / development / reversal / payoff / hook 选。
-- hookPlan: 只列这章实际会动的 hook,一般 1-2 个为佳。
+const ZH_OUTPUT_REQUIREMENTS = `按核心原则产出 ChapterBrief JSON。严格使用以下字段名和类型:
+
+- goal (string): 本章主角想要的具体动作。不是情绪,不是抽象状态。
+- chapterType (string): 从下列选一个——冲突 / 推进 / 过渡 / 反转 / 爽点 / 刀子 / 揭露 / 黄金开场
+- beatOutline (array): 3-5 个对象,每个必须有 phase 和 instruction 两个字段。
+  phase 从 "opening" / "development" / "reversal" / "payoff" / "hook" 选。
+  instruction 是一句场景描述,不是标签。
+- hookPlan (array): 只列这章实际会动的 hook,一般 1-2 个。
+  每个对象必须有 hookId (string)、movement (string)、targetEffect (string) 三个字段。
   其他 active hook 省略,用 dormantReason 解释为什么按兵不动。
   其他 active hook 省略,用 dormantReason 解释为什么按兵不动。
-- propsAndSetting: 本章实际出现在页面上的人/物/地。不列未出场的。
-- isGoldenOpening: 前 3 章设 true,其他 false。`;
-
-const EN_OUTPUT_REQUIREMENTS = `Produce ChapterBrief JSON following the core principles. Field guide:
-
-- goal: The concrete action the protagonist wants this chapter. Not an emotion, not an abstract state.
-- chapterType: Pick one — conflict / progression / transition / reversal / payoff / knife-twist / reveal / golden-opening
-- beatOutline: 3-5 beats. Each instruction is a scene-level description, not a label.
-  phase from opening / development / reversal / payoff / hook.
-- hookPlan: Only list hooks that actually move this chapter, typically 1-2.
+- propsAndSetting (string[]): 本章出现在页面上的人/物/地,平铺为字符串数组。
+- dormantReason (string, 可选): 其他 hook 按兵不动的原因。
+- isGoldenOpening (boolean): 前 3 章设 true,其他 false。`;
+
+const EN_OUTPUT_REQUIREMENTS = `Produce ChapterBrief JSON following the core principles. Use EXACTLY these field names and types:
+
+- goal (string): The concrete action the protagonist wants this chapter. Not an emotion, not an abstract state.
+- chapterType (string): Pick one — conflict / progression / transition / reversal / payoff / knife-twist / reveal / golden-opening
+- beatOutline (array): 3-5 objects, each with phase (string) and instruction (string).
+  phase from "opening" / "development" / "reversal" / "payoff" / "hook".
+  instruction is a scene-level description, not a label.
+- hookPlan (array): Only list hooks that actually move this chapter, typically 1-2.
+  Each object must have hookId (string), movement (string), targetEffect (string).
   Omit other active hooks; explain in dormantReason why they stay dormant.
   Omit other active hooks; explain in dormantReason why they stay dormant.
-- propsAndSetting: People/items/locations that appear on-page this chapter. Don't list absent ones.
-- isGoldenOpening: true for first 5 chapters, false otherwise.`;
+- propsAndSetting (string[]): People/items/locations on-page, as a flat string array.
+- dormantReason (string, optional): Why other hooks stay dormant.
+- isGoldenOpening (boolean): true for first 5 chapters, false otherwise.`;

+ 72 - 7
packages/core/src/agents/planner.ts

@@ -167,15 +167,18 @@ export class PlannerAgent extends BaseAgent {
       maxTokens: 4096,
       maxTokens: 4096,
     });
     });
 
 
-    const parsed = this.tryParseChapterBrief(response.content);
+    this.log?.info(`[planner] Raw brief response length: ${response.content.length} chars`);
+    const parsed = this.tryParseChapterBrief(response.content, params.input.chapterNumber);
     if (!parsed) {
     if (!parsed) {
-      throw new Error(`Planner LLM returned invalid ChapterBrief JSON. Raw output: ${response.content.slice(0, 200)}`);
+      this.log?.warn(`[planner] Full raw output:\n${response.content}`);
+      throw new Error(`Planner LLM returned invalid ChapterBrief JSON. Raw output: ${response.content.slice(0, 500)}`);
     }
     }
     return parsed;
     return parsed;
   }
   }
 
 
-  private tryParseChapterBrief(text: string): ChapterBrief | null {
-    const direct = this.tryParseExactChapterBrief(text);
+  private tryParseChapterBrief(text: string, chapterNumber: number): ChapterBrief | null {
+    const inject = (t: string) => this.injectChapterNumber(t, chapterNumber);
+    const direct = this.tryParseExactChapterBrief(inject(text));
     if (direct) {
     if (direct) {
       return direct;
       return direct;
     }
     }
@@ -183,17 +186,79 @@ export class PlannerAgent extends BaseAgent {
     if (!candidate) {
     if (!candidate) {
       return null;
       return null;
     }
     }
-    return this.tryParseExactChapterBrief(candidate);
+    return this.tryParseExactChapterBrief(inject(candidate));
+  }
+
+  /** Ensure the parsed JSON has a chapter field — LLMs often omit it. */
+  private injectChapterNumber(text: string, chapterNumber: number): string {
+    try {
+      const obj = JSON.parse(text);
+      if (typeof obj === "object" && obj !== null && obj.chapter === undefined) {
+        obj.chapter = chapterNumber;
+        return JSON.stringify(obj);
+      }
+    } catch { /* not valid JSON yet, pass through */ }
+    return text;
   }
   }
 
 
   private tryParseExactChapterBrief(text: string): ChapterBrief | null {
   private tryParseExactChapterBrief(text: string): ChapterBrief | null {
     try {
     try {
-      return ChapterBriefSchema.parse(JSON.parse(text));
-    } catch {
+      const raw = JSON.parse(text);
+      const normalized = this.normalizeBriefJson(raw);
+      return ChapterBriefSchema.parse(normalized);
+    } catch (err) {
+      if (err instanceof Error && err.name === "ZodError") {
+        this.log?.warn(`[planner] Zod validation errors: ${err.message}`);
+      }
       return null;
       return null;
     }
     }
   }
   }
 
 
+  private static readonly VALID_MOVEMENTS = new Set([
+    "quiet-hold", "refresh", "advance", "partial-payoff", "full-payoff",
+  ]);
+
+  /**
+   * Normalize common LLM deviations from the ChapterBrief schema:
+   * - hookPlan[].note → hookPlan[].targetEffect
+   * - hookPlan[].movement unknown values → "advance"
+   * - propsAndSetting as object → flatten to string[]
+   */
+  private normalizeBriefJson(raw: Record<string, unknown>): Record<string, unknown> {
+    // Normalize hookPlan
+    if (Array.isArray(raw.hookPlan)) {
+      raw.hookPlan = raw.hookPlan.map((hook: Record<string, unknown>) => {
+        const normalized = { ...hook };
+        // rename note → targetEffect
+        if (normalized.note && !normalized.targetEffect) {
+          normalized.targetEffect = normalized.note;
+          delete normalized.note;
+        }
+        // clamp unknown movements to "advance"
+        if (typeof normalized.movement === "string" && !PlannerAgent.VALID_MOVEMENTS.has(normalized.movement)) {
+          normalized.movement = "advance";
+        }
+        return normalized;
+      });
+    }
+
+    // Normalize propsAndSetting: flatten object to string[]
+    if (raw.propsAndSetting && !Array.isArray(raw.propsAndSetting)) {
+      const obj = raw.propsAndSetting as Record<string, unknown>;
+      const flattened: string[] = [];
+      for (const values of Object.values(obj)) {
+        if (Array.isArray(values)) {
+          flattened.push(...values.filter((v): v is string => typeof v === "string"));
+        } else if (typeof values === "string") {
+          flattened.push(values);
+        }
+      }
+      raw.propsAndSetting = flattened;
+    }
+
+    return raw;
+  }
+
   private buildStructuredDirectives(input: {
   private buildStructuredDirectives(input: {
     readonly chapterNumber: number;
     readonly chapterNumber: number;
     readonly language?: string;
     readonly language?: string;