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

fix(core): #51 audit JSON fallback, #53 write_truth_file tool, #52 audit drift correction

#51: Audit JSON parse — 4-strategy fallback:
  1. Balanced brace extraction (not greedy regex)
  2. Raw JSON attempt
  3. Code block extraction (```json)
  4. Individual field regex extraction
  Handles MiniMax, Qwen, and other models with non-standard JSON output.

#53: Agent write_truth_file tool — new tool allowing agent to directly
  modify truth files (volume_outline, story_bible, book_rules, etc.).
  Whitelist-protected: only 11 allowed filenames. Enables "expand the
  outline to 800 chapters" and similar user requests.

#52: Audit drift correction — after each chapter's audit, critical and
  warning issues are appended to current_state.md as a "审计纠偏" section.
  Writer sees these corrections in the next chapter's context, breaking
  the self-reinforcing state pollution cycle. Replaces previous correction
  block on each chapter (doesn't accumulate).
Ma 7 месяцев назад
Родитель
Сommit
52e9ca7fb0

+ 87 - 27
packages/core/src/agents/continuity.ts

@@ -361,42 +361,102 @@ ${chapterContent}`;
   }
 
   private parseAuditResult(content: string): AuditResult {
-    const jsonMatch = content.match(/\{[\s\S]*\}/);
-    if (!jsonMatch) {
+    // Try multiple JSON extraction strategies (handles small/local models)
+
+    // Strategy 1: Find balanced JSON object (not greedy)
+    const balanced = this.extractBalancedJson(content);
+    if (balanced) {
+      const result = this.tryParseAuditJson(balanced);
+      if (result) return result;
+    }
+
+    // Strategy 2: Try the whole content as JSON (some models output pure JSON)
+    const trimmed = content.trim();
+    if (trimmed.startsWith("{")) {
+      const result = this.tryParseAuditJson(trimmed);
+      if (result) return result;
+    }
+
+    // Strategy 3: Look for ```json code blocks
+    const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
+    if (codeBlockMatch) {
+      const result = this.tryParseAuditJson(codeBlockMatch[1]!.trim());
+      if (result) return result;
+    }
+
+    // Strategy 4: Try to extract individual fields via regex (last resort fallback)
+    const passedMatch = content.match(/"passed"\s*:\s*(true|false)/);
+    const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
+    const summaryMatch = content.match(/"summary"\s*:\s*"([^"]*)"/);
+    if (passedMatch) {
+      const issues: AuditIssue[] = [];
+      if (issuesMatch) {
+        // Try to parse individual issue objects
+        const issuePattern = /\{[^{}]*"severity"\s*:\s*"[^"]*"[^{}]*\}/g;
+        let match: RegExpExecArray | null;
+        while ((match = issuePattern.exec(issuesMatch[1]!)) !== null) {
+          try {
+            const issue = JSON.parse(match[0]);
+            issues.push({
+              severity: issue.severity ?? "warning",
+              category: issue.category ?? "未分类",
+              description: issue.description ?? "",
+              suggestion: issue.suggestion ?? "",
+            });
+          } catch {
+            // skip malformed individual issue
+          }
+        }
+      }
       return {
-        passed: false,
-        issues: [
-          {
-            severity: "critical",
-            category: "系统错误",
-            description: "审稿输出格式异常,无法解析",
-            suggestion: "重新运行审稿",
-          },
-        ],
-        summary: "审稿输出解析失败",
+        passed: passedMatch[1] === "true",
+        issues,
+        summary: summaryMatch?.[1] ?? "",
       };
     }
 
+    return {
+      passed: false,
+      issues: [{
+        severity: "critical",
+        category: "系统错误",
+        description: "审稿输出格式异常,无法解析为 JSON",
+        suggestion: "可能是模型不支持结构化输出。尝试换一个更大的模型,或检查 API 返回格式。",
+      }],
+      summary: "审稿输出解析失败",
+    };
+  }
+
+  private extractBalancedJson(text: string): string | null {
+    const start = text.indexOf("{");
+    if (start === -1) return null;
+    let depth = 0;
+    for (let i = start; i < text.length; i++) {
+      if (text[i] === "{") depth++;
+      if (text[i] === "}") depth--;
+      if (depth === 0) return text.slice(start, i + 1);
+    }
+    return null;
+  }
+
+  private tryParseAuditJson(json: string): AuditResult | null {
     try {
-      const parsed = JSON.parse(jsonMatch[0]);
+      const parsed = JSON.parse(json);
+      if (typeof parsed.passed !== "boolean" && parsed.passed !== undefined) return null;
       return {
-        passed: Boolean(parsed.passed),
-        issues: Array.isArray(parsed.issues) ? parsed.issues : [],
+        passed: Boolean(parsed.passed ?? false),
+        issues: Array.isArray(parsed.issues)
+          ? parsed.issues.map((i: Record<string, unknown>) => ({
+              severity: (i.severity as string) ?? "warning",
+              category: (i.category as string) ?? "未分类",
+              description: (i.description as string) ?? "",
+              suggestion: (i.suggestion as string) ?? "",
+            }))
+          : [],
         summary: String(parsed.summary ?? ""),
       };
     } catch {
-      return {
-        passed: false,
-        issues: [
-          {
-            severity: "critical",
-            category: "系统错误",
-            description: "审稿 JSON 解析失败",
-            suggestion: "重新运行审稿",
-          },
-        ],
-        summary: "审稿 JSON 解析失败",
-      };
+      return null;
     }
   }
 

+ 46 - 0
packages/core/src/pipeline/agent.ts

@@ -155,6 +155,19 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["bookId", "text"],
     },
   },
+  {
+    name: "write_truth_file",
+    description: "直接修改书的真相文件(如 volume_outline.md、story_bible.md、book_rules.md、current_state.md 等)。用于扩展大纲、修改世界观、调整规则等操作。",
+    parameters: {
+      type: "object",
+      properties: {
+        bookId: { type: "string", description: "书籍ID" },
+        fileName: { type: "string", description: "文件名(如 volume_outline.md、story_bible.md、book_rules.md、current_state.md、pending_hooks.md)" },
+        content: { type: "string", description: "新的完整文件内容" },
+      },
+      required: ["bookId", "fileName", "content"],
+    },
+  },
 ];
 
 export interface AgentLoopOptions {
@@ -195,6 +208,7 @@ export async function runAgentLoop(
 | import_style | 从参考文本生成文风指南(统计+LLM分析) |
 | import_canon | 从正传导入正典参照,启用番外模式 |
 | import_chapters | 导入已有章节,反推所有真相文件,支持续写 |
+| write_truth_file | 直接修改真相文件(大纲、世界观、规则、状态等),用于扩展/调整设定 |
 
 ## 长期记忆
 
@@ -417,6 +431,38 @@ async function executeTool(
       return JSON.stringify(result);
     }
 
+    case "write_truth_file": {
+      const bookId = args.bookId as string;
+      const fileName = args.fileName as string;
+      const content = args.content as string;
+
+      // Whitelist allowed truth files
+      const ALLOWED_FILES = [
+        "story_bible.md", "volume_outline.md", "book_rules.md",
+        "current_state.md", "particle_ledger.md", "pending_hooks.md",
+        "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md",
+        "character_matrix.md", "style_guide.md",
+      ];
+
+      if (!ALLOWED_FILES.includes(fileName)) {
+        return JSON.stringify({ error: `不允许修改文件 "${fileName}"。允许的文件:${ALLOWED_FILES.join(", ")}` });
+      }
+
+      const { writeFile, mkdir } = await import("node:fs/promises");
+      const { join } = await import("node:path");
+      const bookDir = new (await import("../state/manager.js")).StateManager(config.projectRoot).bookDir(bookId);
+      const storyDir = join(bookDir, "story");
+      await mkdir(storyDir, { recursive: true });
+      await writeFile(join(storyDir, fileName), content, "utf-8");
+
+      return JSON.stringify({
+        bookId,
+        file: `story/${fileName}`,
+        written: true,
+        size: content.length,
+      });
+    }
+
     default:
       return JSON.stringify({ error: `Unknown tool: ${name}` });
   }

+ 39 - 3
packages/core/src/pipeline/runner.ts

@@ -411,9 +411,13 @@ export class PipelineRunner {
       if (!existingFile) {
         throw new Error(`Chapter ${targetChapter} file not found in ${chaptersDir} (expected filename starting with ${paddedNum})`);
       }
+      const reviseLang = book.language ?? gp.language;
+      const reviseHeading = reviseLang === "en"
+        ? `# Chapter ${targetChapter}: ${chapterMeta.title}`
+        : `# 第${targetChapter}章 ${chapterMeta.title}`;
       await writeFile(
         join(chaptersDir, existingFile),
-        `# 第${targetChapter}章 ${chapterMeta.title}\n\n${reviseOutput.revisedContent}`,
+        `${reviseHeading}\n\n${reviseOutput.revisedContent}`,
         "utf-8",
       );
 
@@ -671,7 +675,7 @@ export class PipelineRunner {
 
     // Save original state files if not revised
     if (!revised) {
-      await writer.saveChapter(bookDir, output, gp.numericalSystem);
+      await writer.saveChapter(bookDir, output, gp.numericalSystem, pipelineLang);
     }
 
     // Save new truth files (summaries, subplots, emotional arcs, character matrix)
@@ -694,7 +698,39 @@ export class PipelineRunner {
     };
     await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
 
-    // 5.5 Snapshot state for rollback support
+    // 5.5 Audit drift correction — feed audit findings back into state
+    // This prevents the writer from repeating mistakes in the next chapter
+    const driftIssues = auditResult.issues.filter(
+      (i) => i.severity === "critical" || i.severity === "warning",
+    );
+    if (driftIssues.length > 0) {
+      const storyDir = join(bookDir, "story");
+      try {
+        const statePath = join(storyDir, "current_state.md");
+        const currentState = await readFile(statePath, "utf-8").catch(() => "");
+
+        // Append drift correction section (or replace existing one)
+        const correctionHeader = "## 审计纠偏(自动生成,下一章写作前参照)";
+        const correctionBlock = [
+          correctionHeader,
+          `> 第${chapterNumber}章审计发现以下问题,下一章写作时必须避免:`,
+          ...driftIssues.map((i) => `> - [${i.severity}] ${i.category}: ${i.description}`),
+          "",
+        ].join("\n");
+
+        // Replace existing correction block or append
+        const existingCorrectionIdx = currentState.indexOf(correctionHeader);
+        const updatedState = existingCorrectionIdx >= 0
+          ? currentState.slice(0, existingCorrectionIdx) + correctionBlock
+          : currentState + "\n\n" + correctionBlock;
+
+        await writeFile(statePath, updatedState, "utf-8");
+      } catch {
+        // Non-critical — don't block pipeline if drift correction fails
+      }
+    }
+
+    // 5.6 Snapshot state for rollback support
     await this.state.snapshotState(bookId, chapterNumber);
 
     // 6. Send notification