Ver Fonte

fix(v13): planner prompts and memo gen respect book language [phase hotfix 4]

PLANNER_MEMO_SYSTEM_PROMPT and PLANNER_MEMO_USER_TEMPLATE were Chinese-only.
buildPlannerUserMessage already accepted a `language` parameter (Phase 6.5)
but PlannerAgent.planChapterMemo() never passed it down — every English book
received the Chinese system prompt + Chinese user template + Chinese
golden-opening guidance.

Fix:
- planner-prompts.ts: add PLANNER_MEMO_SYSTEM_PROMPT_EN and
  PLANNER_MEMO_USER_TEMPLATE_EN. Same 7-section structure, same
  placeholders, sparse memos still legal.
- New getPlannerMemoSystemPrompt(lang) and getPlannerMemoUserTemplate(lang)
  selectors so callers don't need to know the const names.
- buildPlannerUserMessage: select template by language and use "yes"/"no"
  for isGoldenOpening when en.
- planner.ts: thread input.book.language ?? "zh" through planChapterMemo
  and use it for system prompt selection plus the localized
  no-prior-chapter / no-book-rules / retry-feedback strings.
- chapter-memo-parser.ts: accept either zh or en heading per required
  section so the parser handles English memos without needing duplicate
  parsers. Reports missing sections by zh canonical name to keep the
  retry-feedback loop's parser-error → LLM language stable.

Language detection signal: same as writer/reviser — book.language with
"zh" fallback. No new config.
Ma há 6 meses atrás
pai
commit
fe67589f52

+ 130 - 3
packages/core/src/agents/planner-prompts.ts

@@ -78,6 +78,128 @@ threadRefs:
 - 不要产生正文片段或对话片段
 - 如果卷纲和上章摘要冲突,信上章摘要(剧情已实际发生)`;
 
+// ---------------------------------------------------------------------------
+// English variants — Phase hotfix 4
+// Same 7-section structure, same placeholders, same sparse-memo legality.
+// Used when book.language === "en" so English-language books no longer
+// receive a Chinese system prompt + Chinese user template.
+// ---------------------------------------------------------------------------
+
+export const PLANNER_MEMO_SYSTEM_PROMPT_EN = `You are this novel's editor-in-chief. Your job is to produce a chapter_memo for the next chapter. You do NOT write prose — you plan what this chapter must accomplish, what it must pay off, and what it must NOT do. The downstream writer expands your memo into prose.
+
+Your working principles (internalize them — do not cite by number in the memo):
+
+1. Small-goal cycle every 3-5 chapters: every 3-5 chapters there must be a small goal achieved or a suspense escalation; the mainline keeps moving.
+2. Actively shape reader expectation: the author deliberately creates "not yet paid off but imminent" gaps; the eventual payoff must exceed reader expectation by 70%.
+3. Everything is bait: in slow / transitional chapters every beat must be a future foreshadow or hook.
+4. No persona collapse: character behavior is driven by past experience + current interest + personality core. Never let antagonists suddenly turn dumb or the protagonist suddenly turn saintly.
+5. 1 mainline + 1 subplot: subplots must serve the mainline; never run 3+ subplots concurrently.
+6. Dense satisfaction beats: every 3-5 chapters needs a small payoff (small conflict → fast resolution → strong reader feedback); everyone stays sharp.
+7. Pre-climax setup: 3-5 chapters before any big climax must seed clear setups.
+8. Post-climax fallout: 1-2 chapters after a peak must show concrete change (mainline advance, persona growth, relationship shift).
+9. Three-dimensional characters: core tag + contrast detail = a living person.
+10. Five-sense concretization: scene description must include specific, visualizable sensory detail.
+11. Hook-passing: every chapter ends with a hook for the next.
+
+## Output format (strict)
+
+Output YAML frontmatter + markdown body. Do NOT wrap markdown in a JSON object. Do NOT add code-block fences.
+
+Structure:
+
+---
+chapter: 12
+goal: Pin the Door 7 tampering from suspicion to live evidence
+isGoldenOpening: false
+threadRefs:
+  - H03
+  - S004
+---
+
+## Current task
+<one sentence: the concrete action the protagonist must complete this chapter — no abstractions>
+
+## What the reader is waiting for right now
+<two lines:
+1) what the reader currently expects (based on prior chapters' setups)
+2) what this chapter does with that expectation — widen the gap / partial payoff / full payoff / hint without paying off>
+
+## To pay off / to keep buried
+- Pay off: X → to what degree
+- Keep buried: Y → suppress until chapter N
+
+## What the slow / transitional beats carry
+<if this is a non-pressure chapter, name the function of each non-conflict paragraph. Format: [position] → [function]
+if this is a pressure / conflict chapter, write "n/a — pressure chapter, no transitional beats">
+
+## Three-question check on the key choice
+- Protagonist's most important choice this chapter:
+  - Why this choice?
+  - Does it match current interest?
+  - Does it match their persona?
+- Antagonist / supporting cast's most important choice this chapter:
+  - Why this choice?
+  - Does it match current interest?
+  - Does it match their persona?
+
+## Required end-of-chapter change
+<1-3 items, choose from: information change / relationship change / physical change / power change>
+
+## Do not
+<2-4 hard prohibitions>
+
+## Output requirements
+
+- goal field is no more than 50 characters
+- threadRefs is a YAML array of ids picked from the input pending_hooks / subplot_board
+- Every level-2 heading (##) must appear; none may be empty
+- Do NOT use methodology jargon ("emotional gap", "cyclePhase", "pressure buildup") in the memo — speak directly using this book's people, places, events
+- Do NOT produce prose or dialogue fragments
+- If the volume outline conflicts with the previous chapter summary, trust the summary (those events actually happened)`;
+
+export const PLANNER_MEMO_USER_TEMPLATE_EN = `# Chapter {{chapterNumber}} memo request
+
+## Last screen of previous chapter (excerpt)
+{{previous_chapter_ending_excerpt}}
+
+## Last 3 chapter summaries
+{{recent_summaries}}
+
+## What the current arc is pushing
+{{current_arc_prose}}
+
+## Protagonist current state
+{{protagonist_matrix_row}}
+
+## Main antagonist / opposing forces this chapter
+{{opponent_rows}}
+
+## Main collaborators this chapter
+{{collaborator_rows}}
+
+## Threads that may be touched (foreshadows + subplots)
+{{relevant_threads}}
+
+## Out-of-volume constraints for this chapter
+- Golden opening chapter: {{isGoldenOpening}}
+- Hard rules (excerpt of items this chapter may touch):
+{{book_rules_relevant}}
+
+Produce the memo for chapter {{chapterNumber}}. Strictly emit YAML frontmatter + markdown.`;
+
+/**
+ * Phase hotfix 4: select the language-appropriate planner system prompt.
+ * Defaults to zh for backward compatibility — explicit "en" required for
+ * the English variant.
+ */
+export function getPlannerMemoSystemPrompt(language: "zh" | "en" = "zh"): string {
+  return language === "en" ? PLANNER_MEMO_SYSTEM_PROMPT_EN : PLANNER_MEMO_SYSTEM_PROMPT;
+}
+
+export function getPlannerMemoUserTemplate(language: "zh" | "en" = "zh"): string {
+  return language === "en" ? PLANNER_MEMO_USER_TEMPLATE_EN : PLANNER_MEMO_USER_TEMPLATE;
+}
+
 export const PLANNER_MEMO_USER_TEMPLATE = `# 第 {{chapterNumber}} 章 memo 请求
 
 ## 上一章最后一屏(原文节选)
@@ -123,7 +245,12 @@ export interface PlannerUserMessageInput {
 }
 
 export function buildPlannerUserMessage(input: PlannerUserMessageInput): string {
-  const filled = PLANNER_MEMO_USER_TEMPLATE
+  const language = input.language ?? "zh";
+  const template = getPlannerMemoUserTemplate(language);
+  const yesText = language === "en" ? "yes" : "是";
+  const noText = language === "en" ? "no" : "否";
+
+  const filled = template
     .replaceAll("{{chapterNumber}}", String(input.chapterNumber))
     .replaceAll("{{previous_chapter_ending_excerpt}}", input.previousChapterEndingExcerpt)
     .replaceAll("{{recent_summaries}}", input.recentSummaries)
@@ -132,10 +259,10 @@ export function buildPlannerUserMessage(input: PlannerUserMessageInput): string
     .replaceAll("{{opponent_rows}}", input.opponentRows)
     .replaceAll("{{collaborator_rows}}", input.collaboratorRows)
     .replaceAll("{{relevant_threads}}", input.relevantThreads)
-    .replaceAll("{{isGoldenOpening}}", input.isGoldenOpening ? "是" : "否")
+    .replaceAll("{{isGoldenOpening}}", input.isGoldenOpening ? yesText : noText)
     .replaceAll("{{book_rules_relevant}}", input.bookRulesRelevant);
 
-  const golden = buildGoldenOpeningGuidance(input.chapterNumber, input.language ?? "zh");
+  const golden = buildGoldenOpeningGuidance(input.chapterNumber, language);
   return golden ? `${filled}\n\n${golden}` : filled;
 }
 

+ 27 - 5
packages/core/src/agents/planner.ts

@@ -18,8 +18,8 @@ import {
 } from "../utils/planning-materials.js";
 import { parseMemo, PlannerParseError } from "../utils/chapter-memo-parser.js";
 import {
-  PLANNER_MEMO_SYSTEM_PROMPT,
   buildPlannerUserMessage,
+  getPlannerMemoSystemPrompt,
 } from "./planner-prompts.js";
 import {
   composeCurrentArcProse,
@@ -131,6 +131,10 @@ export class PlannerAgent extends BaseAgent {
       fallbackGoal: goal,
       chapterSummariesRaw: seedMaterials.chapterSummariesRaw,
       previousEndingExcerpt: seedMaterials.previousEndingExcerpt,
+      // Phase hotfix 4: thread book language through so the planner uses
+      // English prompts (system + user template + golden opening guidance)
+      // for English books instead of always-Chinese.
+      language: input.book.language ?? "zh",
     });
 
     // memo.goal is LLM-produced and specific (<=50 chars, validated).
@@ -171,6 +175,7 @@ export class PlannerAgent extends BaseAgent {
     readonly fallbackGoal: string;
     readonly chapterSummariesRaw: string;
     readonly previousEndingExcerpt?: string;
+    readonly language?: "zh" | "en";
   }): Promise<ChapterMemo> {
     const [characterMatrix, subplotBoard, emotionalArcs, pendingHooks, bookRulesRaw] = await Promise.all([
       readCharacterMatrix(input.storyDir),
@@ -180,11 +185,25 @@ export class PlannerAgent extends BaseAgent {
       readBookRules(input.storyDir),
     ]);
 
+    const language = input.language ?? "zh";
+    const noPriorChapter = language === "en"
+      ? "(this is the opening chapter — no prior chapter)"
+      : "(本章为起始章,无前章)";
+    const noBookRules = language === "en"
+      ? "(no book_rules entries)"
+      : "(暂无 book_rules 条目)";
+    const retryFeedbackHeader = language === "en"
+      ? "## Error from previous output"
+      : "## 上次输出的错误";
+    const retryFeedbackTrailer = language === "en"
+      ? "Fix and re-emit."
+      : "请修正后重新输出。";
+
     const userMessage = buildPlannerUserMessage({
       chapterNumber: input.chapterNumber,
       previousChapterEndingExcerpt: input.previousEndingExcerpt?.trim()
         ? input.previousEndingExcerpt.trim()
-        : "(本章为起始章,无前章)",
+        : noPriorChapter,
       recentSummaries: formatRecentSummaries(input.chapterSummariesRaw, input.chapterNumber, 3),
       currentArcProse: composeCurrentArcProse(subplotBoard, emotionalArcs, input.chapterNumber),
       protagonistMatrixRow: extractProtagonistRow(characterMatrix),
@@ -192,16 +211,19 @@ export class PlannerAgent extends BaseAgent {
       collaboratorRows: extractCollaboratorRows(characterMatrix, 3),
       relevantThreads: extractRelevantThreads(pendingHooks, subplotBoard),
       isGoldenOpening: input.isGoldenOpening,
-      bookRulesRelevant: bookRulesRaw.trim().length > 0 ? bookRulesRaw.trim() : "(暂无 book_rules 条目)",
+      bookRulesRelevant: bookRulesRaw.trim().length > 0 ? bookRulesRaw.trim() : noBookRules,
+      language,
     });
 
+    const systemPrompt = getPlannerMemoSystemPrompt(language);
+
     let currentUserMessage = userMessage;
     let lastError: PlannerParseError | undefined;
 
     for (let attempt = 0; attempt < MEMO_RETRY_LIMIT; attempt += 1) {
       const response = await this.chat(
         [
-          { role: "system", content: PLANNER_MEMO_SYSTEM_PROMPT },
+          { role: "system", content: systemPrompt },
           { role: "user", content: currentUserMessage },
         ],
         { temperature: 0.7, maxTokens: 2000 },
@@ -215,7 +237,7 @@ export class PlannerAgent extends BaseAgent {
         }
         lastError = error;
         this.log?.warn(`[planner] memo parse failed (attempt ${attempt + 1}/${MEMO_RETRY_LIMIT}): ${error.message}`);
-        currentUserMessage = `${userMessage}\n\n## 上次输出的错误\n${error.message}\n请修正后重新输出。`;
+        currentUserMessage = `${userMessage}\n\n${retryFeedbackHeader}\n${error.message}\n${retryFeedbackTrailer}`;
       }
     }
 

+ 21 - 10
packages/core/src/utils/chapter-memo-parser.ts

@@ -8,14 +8,20 @@ export class PlannerParseError extends Error {
   }
 }
 
-const REQUIRED_SECTIONS: ReadonlyArray<string> = [
-  "## 当前任务",
-  "## 读者此刻在等什么",
-  "## 该兑现的 / 暂不掀的",
-  "## 日常/过渡承担什么任务",
-  "## 关键抉择过三连问",
-  "## 章尾必须发生的改变",
-  "## 不要做",
+// Phase hotfix 4: each required section is a (zh, en) heading pair.
+// The English headings come from PLANNER_MEMO_SYSTEM_PROMPT_EN — we accept
+// EITHER language at parse time so the same parser works for both.
+const REQUIRED_SECTIONS: ReadonlyArray<{
+  readonly zh: string;
+  readonly en: string;
+}> = [
+  { zh: "## 当前任务", en: "## Current task" },
+  { zh: "## 读者此刻在等什么", en: "## What the reader is waiting for right now" },
+  { zh: "## 该兑现的 / 暂不掀的", en: "## To pay off / to keep buried" },
+  { zh: "## 日常/过渡承担什么任务", en: "## What the slow / transitional beats carry" },
+  { zh: "## 关键抉择过三连问", en: "## Three-question check on the key choice" },
+  { zh: "## 章尾必须发生的改变", en: "## Required end-of-chapter change" },
+  { zh: "## 不要做", en: "## Do not" },
 ];
 
 /**
@@ -75,9 +81,14 @@ export function parseMemo(
     );
   }
 
-  const missing = REQUIRED_SECTIONS.filter((heading) => !body.includes(heading));
+  const missing = REQUIRED_SECTIONS.filter(
+    (section) => !body.includes(section.zh) && !body.includes(section.en),
+  );
   if (missing.length > 0) {
-    throw new PlannerParseError(`missing sections: ${missing.join(", ")}`);
+    // Report by zh heading (canonical) so the LLM-feedback loop stays stable.
+    throw new PlannerParseError(
+      `missing sections: ${missing.map((s) => s.zh).join(", ")}`,
+    );
   }
 
   const threadRefs = Array.isArray(f.threadRefs)