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

fix(v13): decouple bookRules.body from story_frame prose to prevent prompt duplication [phase 5 hotfix 2]

ParsedBookRules.body used to hold "everything after the frontmatter."
Phase 5 cleanup #3 moved the frontmatter to outline/story_frame.md, so
body started collecting the entire 5-section outline essay — which the
writer/planner/reviser/continuity then injected under a "本书专属规则"
or "style guide" header even though the prose was neither. Combined
with the independent readStoryFrame() call each agent already makes,
the outline prose was being shipped to the LLM twice per turn.

- readBookRules(): when the source is story_frame.md, slice out ONLY
  the leading `---\n...\n---` block and pass THAT to parseBookRules.
  The prose after it is discarded. Result: ParsedBookRules.body is
  empty for new-layout books. Legacy book_rules.md is still passed
  verbatim, preserving the narrow narrative-rules semantics.
- reviser.ts / continuity.ts style_guide fallback: tighten the chain
  so that an empty (trimmed) body does NOT short-circuit the fallback
  — an empty string is not a usable style guide. Now reads
  `parsedRules?.body?.trim() || "(无文风指南)"`.
- planner-context, writer, chapter-analyzer already handled empty
  body correctly via `if (!body)` / `?? ""` guards, so no change.

Legacy books (book_rules.md with narrative body, no story_frame.md)
render byte-identically to before.
Ma 6 месяцев назад
Родитель
Сommit
5655405f13

+ 7 - 2
packages/core/src/agents/continuity.ts

@@ -414,10 +414,15 @@ export class ContinuityAuditor extends BaseAgent {
     const parsedRules = await readBookRules(bookDir);
     const bookRules = parsedRules?.rules ?? null;
 
-    // Fallback: use book_rules body when style_guide.md doesn't exist
+    // Fallback: use book_rules body when style_guide.md doesn't exist.
+    // Phase 5 hotfix 2: parsedRules.body is only populated for legacy
+    // book_rules.md sources — story_frame.md frontmatter yields an empty
+    // body, and an empty string is NOT a usable style guide. Treat
+    // missing/empty body as "no fallback available".
+    const legacyRulesBody = parsedRules?.body?.trim();
     const styleGuide = styleGuideRaw !== "(文件不存在)"
       ? styleGuideRaw
-      : (parsedRules?.body ?? "(无文风指南)");
+      : (legacyRulesBody || "(无文风指南)");
 
     const resolvedLanguage = bookLanguage ?? gp.language;
     const isEnglish = resolvedLanguage === "en";

+ 7 - 2
packages/core/src/agents/reviser.ts

@@ -150,10 +150,15 @@ export class ReviserAgent extends BaseAgent {
     const parsedRules = await readBookRules(bookDir);
     const bookRules = parsedRules?.rules ?? null;
 
-    // Fallback: use book_rules body when style_guide.md doesn't exist
+    // Fallback: use book_rules body when style_guide.md doesn't exist.
+    // Phase 5 hotfix 2: parsedRules.body is only populated for legacy
+    // book_rules.md sources — story_frame.md frontmatter yields an empty
+    // body, and an empty string is NOT a usable style guide. Treat
+    // missing/empty body as "no fallback available".
+    const legacyRulesBody = parsedRules?.body?.trim();
     const styleGuide = styleGuideRaw !== "(文件不存在)"
       ? styleGuideRaw
-      : (parsedRules?.body ?? "(无文风指南)");
+      : (legacyRulesBody || "(无文风指南)");
 
     const isEnglish = (bookLanguage ?? gp.language) === "en";
     const resolvedLanguage = isEnglish ? "en" : "zh";

+ 17 - 5
packages/core/src/agents/rules-reader.ts

@@ -93,19 +93,31 @@ export function getBuiltinGenresDir(): string {
  * still live in book_rules.md instead, so we fall back to that legacy path
  * when story_frame.md has no frontmatter (or no file at all).
  *
+ * Phase 5 hotfix 2: when the source is story_frame.md, the prose body
+ * underneath the frontmatter is NOT semantic "book rules" text — it is the
+ * 5-section outline essay. We therefore slice out ONLY the frontmatter block
+ * for parseBookRules, so ParsedBookRules.body ends up empty for new-layout
+ * books. Legacy book_rules.md still carries narrow narrative rules in its
+ * body, so we pass it through verbatim.
+ *
  * Returns null only if NEITHER source yields parseable rules.
  */
 export async function readBookRules(bookDir: string): Promise<ParsedBookRules | null> {
   const storyFrameRaw = await tryReadFile(join(bookDir, "story/outline/story_frame.md"));
-  if (storyFrameRaw && /^\s*---\s*\n/.test(storyFrameRaw)) {
-    const parsed = parseBookRules(storyFrameRaw);
-    // parseBookRules returns defaults when no YAML matches — only treat the
-    // story_frame hit as authoritative when the leading frontmatter exists.
-    if (parsed) return parsed;
+  if (storyFrameRaw) {
+    // Extract just the leading `---\n...\n---` block. Anything after it is
+    // outline prose and must NOT leak into ParsedBookRules.body.
+    const frontmatterMatch = storyFrameRaw.match(/^\s*(---\s*\n[\s\S]*?\n---\s*)(?:\n|$)/);
+    if (frontmatterMatch) {
+      // Pass only the frontmatter block (no trailing prose). parseBookRules
+      // will produce an empty body, which is exactly what we want.
+      return parseBookRules(frontmatterMatch[1]);
+    }
   }
 
   const legacyRaw = await tryReadFile(join(bookDir, "story/book_rules.md"));
   if (!legacyRaw) return null;
+  // Legacy file: body intentionally preserved (narrow narrative rules).
   return parseBookRules(legacyRaw);
 }