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

fix(core): optional ledger restore and fallback parser for local models

- restoreState: particle_ledger.md moved from required to optional,
  fixes rewrite crash for non-numerical genres (#37)
- parseCreativeOutput: 3-tier fallback when === TAG === parsing fails:
  markdown heading, 正文/内容 label, longest prose block (>100 chars).
  Fixes empty chapters with local/small models like Qwen/Ollama (#13)
- fallbackExtractTitle: tries # 第N章 heading and 章节标题: label
Ma 7 месяцев назад
Родитель
Сommit
8e70bab6f5
2 измененных файлов с 68 добавлено и 6 удалено
  1. 62 2
      packages/core/src/agents/writer-parser.ts
  2. 6 4
      packages/core/src/state/manager.ts

+ 62 - 2
packages/core/src/agents/writer-parser.ts

@@ -20,16 +20,76 @@ export function parseCreativeOutput(
     return match?.[1]?.trim() ?? "";
   };
 
-  const chapterContent = extract("CHAPTER_CONTENT");
+  let chapterContent = extract("CHAPTER_CONTENT");
+
+  // Fallback: if === TAG === parsing fails (common with local/small models),
+  // try to extract usable content from the raw output
+  if (!chapterContent) {
+    chapterContent = fallbackExtractContent(content);
+  }
+
+  let title = extract("CHAPTER_TITLE");
+  if (!title) {
+    title = fallbackExtractTitle(content, chapterNumber);
+  }
 
   return {
-    title: extract("CHAPTER_TITLE") || `第${chapterNumber}章`,
+    title,
     content: chapterContent,
     wordCount: chapterContent.length,
     preWriteCheck: extract("PRE_WRITE_CHECK"),
   };
 }
 
+/**
+ * Fallback content extraction when === CHAPTER_CONTENT === tag is missing.
+ * Tries common patterns from local/small models, then falls back to
+ * stripping metadata and returning the longest prose block.
+ */
+function fallbackExtractContent(raw: string): string {
+  // Try markdown heading: # 第N章 ... followed by content
+  const headingMatch = raw.match(/^#\s*第\d+章[^\n]*\n+([\s\S]+)/m);
+  if (headingMatch) {
+    return headingMatch[1]!.trim();
+  }
+
+  // Try "正文" or "内容" labeled section
+  const labelMatch = raw.match(/(?:正文|内容|章节内容)[::]\s*\n+([\s\S]+)/);
+  if (labelMatch) {
+    return labelMatch[1]!.trim();
+  }
+
+  // Last resort: strip lines that look like metadata/tags, keep the rest
+  const lines = raw.split("\n");
+  const proseLines = lines.filter((line) => {
+    const trimmed = line.trim();
+    // Skip tag-like lines, empty lines at boundaries, and short key-value lines
+    if (/^===\s*[A-Z_]+\s*===/.test(trimmed)) return false;
+    if (/^(PRE_WRITE_CHECK|CHAPTER_TITLE|章节标题|写作自检)[::]/.test(trimmed)) return false;
+    return true;
+  });
+  const result = proseLines.join("\n").trim();
+  // Only use fallback if we got meaningful content (>100 chars)
+  return result.length > 100 ? result : "";
+}
+
+/**
+ * Fallback title extraction when === CHAPTER_TITLE === tag is missing.
+ */
+function fallbackExtractTitle(raw: string, chapterNumber: number): string {
+  // Try: # 第N章 Title
+  const headingMatch = raw.match(/^#\s*第\d+章\s*(.+)/m);
+  if (headingMatch) {
+    return headingMatch[1]!.trim();
+  }
+  // Try: 章节标题:Title or CHAPTER_TITLE: Title (without === delimiters)
+  const labelMatch = raw.match(/(?:章节标题|CHAPTER_TITLE)[::]\s*(.+)/);
+  if (labelMatch) {
+    return labelMatch[1]!.trim();
+  }
+  return `第${chapterNumber}章`;
+}
+
 export type ParsedWriterOutput = Omit<WriteChapterOutput, "postWriteErrors" | "postWriteWarnings">;
 
 /**

+ 6 - 4
packages/core/src/state/manager.ts

@@ -145,9 +145,11 @@ export class StateManager {
       "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
     ];
     try {
-      // The first 3 files are required; the rest are optional (may not exist in older snapshots)
-      const requiredFiles = files.slice(0, 3);
-      const optionalFiles = files.slice(3);
+      // current_state.md and pending_hooks.md are required;
+      // particle_ledger.md is optional (numericalSystem=false genres don't have it)
+      // the rest are optional (may not exist in older snapshots)
+      const requiredFiles = ["current_state.md", "pending_hooks.md"];
+      const optionalFiles = files.filter((f) => !requiredFiles.includes(f));
 
       await Promise.all(
         requiredFiles.map(async (f) => {
@@ -162,7 +164,7 @@ export class StateManager {
             const content = await readFile(join(snapshotDir, f), "utf-8");
             await writeFile(join(storyDir, f), content, "utf-8");
           } catch {
-            // Optional file missing in older snapshots — skip
+            // Optional file missing — skip
           }
         }),
       );