Переглянути джерело

feat: 大纲生成优化 - jsonrepair + 48000 tokens + Markdown兜底 + mustNotRepeat章号引用

- rich-outline.node: maxTokens 48000, jsonrepair替换24行正则, 9策略精简到4+1, Markdown解析器(策略5)
- fault-tolerance: callLLMWithRetry支持maxTokens参数
- templates: mustNotRepeat要求明确注章号, 新增RICH_OUTLINE_MARKDOWN_PROMPT
- models.json: contextWindow 128000→204800, 新增MiniMax-M2.7-highspeed极速版
- README: 记录JSON/Markdown双方案对比与切换方法
- .gitignore: 排除server/temp/调试日志
MyFramework User 3 місяців тому
батько
коміт
ed13c05c3b

+ 1 - 0
.gitignore

@@ -10,3 +10,4 @@ nul
 .playwright-cli/
 .idea/
 .vscode/
+server/temp/

+ 10 - 0
server/package-lock.json

@@ -27,6 +27,7 @@
         "fluent-ffmpeg": "^2.1.2",
         "ioredis": "^5.10.1",
         "is-generator-function": "^1.1.2",
+        "jsonrepair": "^3.14.0",
         "jsonwebtoken": "^9.0.2",
         "koa": "^3.1.2",
         "koa-body": "^7.0.1",
@@ -4710,6 +4711,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/jsonrepair": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmmirror.com/jsonrepair/-/jsonrepair-3.14.0.tgz",
+      "integrity": "sha512-tWPGKMZf/8UPim+fcW2EfcQ/d/7aKUrP6IECz9G3Tu6Q5dX0orSleqJ9z6sSw7qrQkjF8/Edo4DvsWBZ8H+HNg==",
+      "license": "ISC",
+      "bin": {
+        "jsonrepair": "bin/cli.js"
+      }
+    },
     "node_modules/jsonwebtoken": {
       "version": "9.0.3",
       "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",

+ 1 - 0
server/package.json

@@ -33,6 +33,7 @@
     "fluent-ffmpeg": "^2.1.2",
     "ioredis": "^5.10.1",
     "is-generator-function": "^1.1.2",
+    "jsonrepair": "^3.14.0",
     "jsonwebtoken": "^9.0.2",
     "koa": "^3.1.2",
     "koa-body": "^7.0.1",

+ 11 - 69
server/src/config/models.json

@@ -8,38 +8,11 @@
       "apiType": "openai-chat",
       "priority": 1,
       "models": [
-        {
-          "id": "MiniMax-M2.7",
-          "name": "MiniMax M2.7",
-          "input": ["text"],
-          "contextWindow": 128000,
-          "maxTokens": 4096,
-          "temperature": 0.7,
-          "supportsToolCall": true,
-          "enabled": true
-        },
-        {
-          "id": "speech-2.8-hd",
-          "name": "MiniMax Speech-2.8-HD",
-          "input": ["tts"],
-          "apiPath": "/t2a_async_v2",
-          "maxTextLength": 800000,
-          "enabled": true
-        },
-        {
-          "id": "MiniMax-image-01",
-          "name": "MiniMax image-01",
-          "input": ["image"],
-          "apiPath": "/image_generation",
-          "enabled": true
-        },
-        {
-          "id": "MiniMax-Hailuo-02",
-          "name": "MiniMax Hailuo 02",
-          "input": ["video"],
-          "apiPath": "/video_generation",
-          "enabled": true
-        }
+        {"id": "MiniMax-M2.7","name": "MiniMax M2.7","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
+        {"id": "MiniMax-M2.7-highspeed","name": "MiniMax M2.7 极速版","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
+        {"id": "speech-2.8-hd","name": "MiniMax Speech-2.8-HD","input": ["tts"],"apiPath": "/t2a_async_v2","maxTextLength": 800000,"enabled": false, "reason": "TTS主力用CosyVoice"},
+        {"id": "MiniMax-image-01","name": "MiniMax image-01","input": ["image"],"apiPath": "/image_generation","enabled": true},
+        {"id": "MiniMax-Hailuo-02","name": "MiniMax Hailuo 02","input": ["video"],"apiPath": "/video_generation","enabled": true}
       ]
     },
     "minimax-key2": {
@@ -50,24 +23,9 @@
       "apiType": "openai-chat",
       "priority": 2,
       "models": [
-        {
-          "id": "MiniMax-M2.7",
-          "name": "MiniMax M2.7",
-          "input": ["text"],
-          "contextWindow": 128000,
-          "maxTokens": 4096,
-          "temperature": 0.7,
-          "supportsToolCall": true,
-          "enabled": true
-        },
-        {
-          "id": "speech-2.8-hd",
-          "name": "MiniMax Speech-2.8-HD",
-          "input": ["tts"],
-          "apiPath": "/t2a_async_v2",
-          "maxTextLength": 800000,
-          "enabled": true
-        }
+        {"id": "MiniMax-M2.7","name": "MiniMax M2.7","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
+        {"id": "MiniMax-M2.7-highspeed","name": "MiniMax M2.7 极速版","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
+        {"id": "speech-2.8-hd","name": "MiniMax Speech-2.8-HD","input": ["tts"],"apiPath": "/t2a_async_v2","maxTextLength": 800000,"enabled": false, "reason": "TTS主力用CosyVoice"}
       ]
     },
     "bailian": {
@@ -119,30 +77,14 @@
           "supportsToolCall": false,
           "enabled": true
         },
-        {
-          "id": "qwen3-tts-instruct-flash",
-          "name": "Qwen3 TTS Instruct Flash",
-          "input": ["tts"],
-          "maxTextLength": 600,
-          "enabled": true
-        },
         {
           "id": "cosyvoice-v3-flash",
           "name": "CosyVoice v3 Flash (70+音色)",
           "input": ["tts"],
           "apiPath": "/api/v1/services/audio/tts/SpeechSynthesizer",
           "maxTextLength": 20000,
-          "defaultVoice": "longanyang",
+          "defaultVoice": "longyingling_v3",
           "enabled": true
-        },
-        {
-          "id": "cosyvoice-v3-plus",
-          "name": "CosyVoice v3 Plus (高音质2音色)",
-          "input": ["tts"],
-          "apiPath": "/api/v1/services/audio/tts/SpeechSynthesizer",
-          "maxTextLength": 20000,
-          "defaultVoice": "longanyang",
-          "enabled": false
         }
       ]
     },
@@ -221,7 +163,7 @@
     "defaultModel": "MiniMax-M2.7"
   },
   "tts": {
-    "defaultModel": "speech-2.8-hd",
-    "defaultVoice": "Cherry"
+    "defaultModel": "cosyvoice-v3-flash",
+    "defaultVoice": "longyingling_v3"
   }
 }

+ 41 - 0
server/src/modules/book-generator/README.md

@@ -206,6 +206,47 @@ function buildChapterTree(chapters: Chapter[]) {
 | writeForewordNode | 生成前言 |
 | writeAfterwordNode | 生成后记 |
 
+## 大纲输出格式(大纲策略)
+
+### 当前方案:JSON(主力)
+
+- **格式**:`{"chapters":[{"writingInstructions":{...}}]}`
+- **模型**:一次性生成全部章节,利用上下文全局视野保证跨章一致性
+- **容错**:jsonrepair 自动修复 + 4 层兜底策略 + Markdown 解析(策略5)
+- **maxTokens**:大纲专用 48000 tokens(`richOutlineNode` 中配置)
+- **跨章避重**:prompt 明确要求 `mustNotRepeat` 注明章号(如"不要重复第3章的XX")
+
+### 备选方案:Markdown(未来可切换)
+
+与 JSON 方案做过 4 章 / 65 章全量对比测试,Markdown 格式有以下优势:
+
+| 维度 | JSON | Markdown |
+|------|------|----------|
+| LLM 原生性 | 低(训练语料少) | **高**(母语格式) |
+| 格式容错 | 依赖 jsonrepair | **天生容错**(单点故障只影响当前章节) |
+| 文笔质感 | 学院派,精准 | **更自然,对话感强** |
+| 跨章引用 | 依赖 prompt 约束 | **天然写"不要与第13章混淆"** |
+| 人可读性 | 需解析 | **直接阅读** |
+| 下游兼容 | ✅ 无需改动 | ❌ 需改造 quality_check / rewrite 等节点 |
+| 切换方式 | 默认 | `process.env.OUTLINE_FORMAT='markdown'` |
+
+**切换方法**:
+```bash
+# 环境变量方式(整个服务生效)
+OUTLINE_FORMAT=markdown npm run dev
+
+# 代码方式(单次调用)
+process.env.OUTLINE_FORMAT = 'markdown';
+```
+
+**切换前提**(暂未实施):
+1. `quality-check.node.ts`:解析 `writingInstructions` 时改用 Markdown 正则
+2. `rewrite.node.ts`:同上
+3. `content.node.ts`:同上
+4. 前端大纲预览组件:支持渲染 Markdown 大纲
+
+**结论**:JSON 当前是最佳选择(零成本适配下游),Markdown 已作为策略5兜底 + 未来切换方案就绪。
+
 ## 扩展方向
 
 1. **并行生成**:章→节→小节三层可并行生成

+ 3 - 2
server/src/modules/book-generator/fault-tolerance.ts

@@ -69,7 +69,8 @@ export interface FaultToleranceContext {
 export async function callLLMWithRetry(
   messages: ChatMessage[],
   modelId: string | undefined,
-  context: FaultToleranceContext
+  context: FaultToleranceContext,
+  maxTokens?: number
 ): Promise<string> {
   const { bookId, nodeId, attempt, maxAttempts } = context;
   const config = FAULT_TOLERANCE_CONFIG.aiRetry;
@@ -98,7 +99,7 @@ export async function callLLMWithRetry(
         });
       }
       
-      return await callLLMWithMessages(messages, modelId);
+      return await callLLMWithMessages(messages, modelId, maxTokens);
     } catch (error: any) {
       lastError = error;
       console.error(`[容错] AI调用失败 (attempt ${i + 1}/${maxAttempts + 1}):`, error.message);

+ 467 - 76
server/src/modules/book-generator/nodes/rich-outline.node.ts

@@ -11,16 +11,44 @@ import { GraphState } from '../graph';
 import { bookStore } from '../book-generator.store';
 import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
 import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
-import { RICH_OUTLINE_SYSTEM_PROMPT } from '../prompts/templates';
+import { RICH_OUTLINE_SYSTEM_PROMPT, RICH_OUTLINE_MARKDOWN_PROMPT } from '../prompts/templates';
 import { getScaleConfig } from '../book-type-config';
 import { PROGRESS } from '../utils';
 import { evaluateOutlineQuality } from '../utils/outline-quality';
 import { validateInstructionConsistency, InstructionNode } from '../utils/instruction-consistency';
 import { prisma } from '../../../models';
+import { jsonrepair } from 'jsonrepair';
+import fs from 'fs';
+import path from 'path';
 
 /** 大纲质量自动重试的最低分数阈值 */
 const OUTLINE_RETRY_THRESHOLD = 75;
 
+/** 富信息大纲最大输出 token 数(65 章全量输出约需 24000 tokens,留足余量) */
+const RICH_OUTLINE_MAX_TOKENS = 48000;
+
+/**
+ * 保存 AI 原始响应到临时文件(用于调试解析失败)
+ */
+function saveFailedResponse(bookId: string, response: string, errorCtx: string): string {
+  try {
+    const tempDir = path.join(process.cwd(), 'temp');
+    if (!fs.existsSync(tempDir)) {
+      fs.mkdirSync(tempDir, { recursive: true });
+    }
+    const timestamp = Date.now();
+    const filename = `rich-outline-fail_${bookId}_${timestamp}.txt`;
+    const filepath = path.join(tempDir, filename);
+    const content = `[${new Date().toISOString()}] ${errorCtx}\n\n========== AI 原始响应 (${response.length} chars) ==========\n${response}\n\n========== 响应结尾500字符 ==========\n${response.slice(-500)}`;
+    fs.writeFileSync(filepath, content, 'utf-8');
+    console.log(`[RichOutline] 失败响应已保存: ${filepath}`);
+    return filepath;
+  } catch (e) {
+    console.warn('[RichOutline] 保存失败响应文件失败:', e);
+    return '';
+  }
+}
+
 /**
  * 构建富信息大纲提示词
  */
@@ -53,11 +81,20 @@ function buildRichOutlineMessages(
     if (parts.length > 0) planSection = `\n\n## 前序深度规划参考\n${parts.join('\n')}`;
   }
 
-  const systemPrompt = `${RICH_OUTLINE_SYSTEM_PROMPT}
+  // 环境变量 OUTLINE_FORMAT=markdown 时使用 Markdown 输出格式
+  const isMarkdown = process.env.OUTLINE_FORMAT === 'markdown';
+  const basePrompt = isMarkdown ? RICH_OUTLINE_MARKDOWN_PROMPT : RICH_OUTLINE_SYSTEM_PROMPT;
+
+  const chapterReq = isMarkdown
+    ? `- **你必须生成整整 ${targetChapters} 个章节,从第1章连续输出到第${targetChapters}章**`
+    : `- 目标约${targetChapters}章`;
+
+  const systemPrompt = `${basePrompt}
 ${planSection}
 
 ## 本书约束
-- 总字数约${totalWords}字,目标约${targetChapters}章
+- 总字数约${totalWords}字
+${chapterReq}
 - 大纲层级:${levelDesc[genLevel] || levelDesc[2]}
 - genLevel 当前值:${genLevel}`;
 
@@ -105,12 +142,20 @@ function buildRichOutlineRetryMessages(
     ? `\n\n## ⚠️ 上次生成的问题(请务必修复)\n${previousWarnings.map(w => `- ${w}`).join('\n')}`
     : '';
 
-  const systemPrompt = `${RICH_OUTLINE_SYSTEM_PROMPT}
+  const isMarkdown = process.env.OUTLINE_FORMAT === 'markdown';
+  const basePrompt = isMarkdown ? RICH_OUTLINE_MARKDOWN_PROMPT : RICH_OUTLINE_SYSTEM_PROMPT;
+
+  const chapterReq = isMarkdown
+    ? `- **你必须生成整整 ${targetChapters} 个章节,从第1章到第${targetChapters}章**`
+    : `- 目标约${targetChapters}章`;
+
+  const systemPrompt = `${basePrompt}
 ${planSection}
 ${warningsSection}
 
 ## 本书约束
-- 总字数约${totalWords}字,目标约${targetChapters}章
+- 总字数约${totalWords}字
+${chapterReq}
 - 大纲层级:${levelDesc[genLevel] || levelDesc[2]}
 - genLevel 当前值:${genLevel}
 
@@ -125,15 +170,333 @@ ${warningsSection}
   ];
 }
 
+/**
+ * 使用 jsonrepair 修复 AI 返回的有瑕疵 JSON
+ * jsonrepair 自动处理:缺失引号、缺失括号、trailing commas、重复逗号、未转义字符等
+ */
+function repairJson(jsonStr: string): string {
+  try {
+    return jsonrepair(jsonStr);
+  } catch {
+    // jsonrepair 也修不了时返回原文,让下游 parse 失败时走 fallback
+    return jsonStr;
+  }
+}
+
+/**
+ * 从截断的 JSON 中提取完整的章节数据
+ * 当 AI 响应超出 maxTokens 时,JSON 会被截断,本函数尝试提取完整的章节
+ */
+function extractChaptersFromTruncatedJson(jsonStr: string): {
+  chapters: any[];
+  mainTheme: string | null;
+  structureLogic: string | null;
+  missing: number;
+} {
+  const result = { chapters: [] as any[], mainTheme: null as string | null, structureLogic: null as string | null, missing: 0 };
+
+  try {
+    // 提取 mainTheme
+    const mainThemeMatch = jsonStr.match(/"mainTheme"\s*:\s*"([^"]+)"/);
+    if (mainThemeMatch) result.mainTheme = mainThemeMatch[1];
+
+    // 提取 structureLogic
+    const structureLogicMatch = jsonStr.match(/"structureLogic"\s*:\s*"([^"]+)"/);
+    if (structureLogicMatch) result.structureLogic = structureLogicMatch[1];
+
+    // 提取章节数组 - 从 "chapters": [ 开始
+    const chaptersArrayStart = jsonStr.indexOf('"chapters":[');
+    if (chaptersArrayStart === -1) return result;
+
+    // 找到 chapters 数组的结束位置
+    // 我们通过计算嵌套的 [ 和 ] 来找到匹配的结束括号
+    // 注意:需要从 '[' 之后开始计数(offset 12 = 11 个字符的 "chapters":[ 加上第一个 [)
+    let bracketDepth = 0;
+    let inString = false;
+    let escapeNext = false;
+    let arrayEnd = -1;
+    const arrayOpenPos = chaptersArrayStart + 11; // position of '[' after "chapters":
+
+    for (let i = arrayOpenPos; i < jsonStr.length; i++) {
+      const ch = jsonStr[i];
+      if (escapeNext) { escapeNext = false; continue; }
+      if (ch === '\\') { escapeNext = true; continue; }
+      if (ch === '"') { inString = !inString; continue; }
+      if (inString) continue;
+
+      if (ch === '[') bracketDepth++;
+      else if (ch === ']') {
+        bracketDepth--;
+        if (bracketDepth === 0) { arrayEnd = i; break; }
+      }
+    }
+
+    if (arrayEnd === -1) return result;
+
+    // 提取数组内容
+    const arrayContent = jsonStr.substring(arrayOpenPos + 1, arrayEnd);
+
+    // 使用 jsonrepair 修复 AI JSON 瑕疵
+    const fixedContent = repairJson('[' + arrayContent + ']');
+
+    // 尝试解析修复后的数组
+    try {
+      const chapters = JSON.parse(fixedContent);
+      if (Array.isArray(chapters) && chapters.length > 0) {
+        // 验证每个章节是否有必要的字段
+        for (const ch of chapters) {
+          if (ch && ch.title && typeof ch.number === 'number') {
+            result.chapters.push({
+              number: ch.number,
+              title: ch.title,
+              summary: ch.summary || '',
+              keyPoints: Array.isArray(ch.keyPoints) ? ch.keyPoints : [],
+              estimatedWords: ch.estimatedWords || 0,
+              writingInstructions: ch.writingInstructions || '',
+            });
+          }
+        }
+      }
+    } catch {
+      // JSON.parse 失败,尝试逐个提取章节(处理嵌套对象)
+      // 用简单的 bracket 计数来分割各章
+      const chapterObjs: string[] = [];
+      let depth = 0, start = 0;
+      for (let i = 0; i < arrayContent.length; i++) {
+        if (arrayContent[i] === '{') { if (depth === 0) start = i; depth++; }
+        else if (arrayContent[i] === '}') { depth--; if (depth === 0) chapterObjs.push(arrayContent.substring(start, i + 1)); }
+      }
+      for (const chStr of chapterObjs) {
+        try {
+          const repairedCh = repairJson(chStr);
+          const ch = JSON.parse(repairedCh);
+          if (ch && ch.title && ch.number) {
+            result.chapters.push({
+              number: ch.number, title: ch.title,
+              summary: ch.summary || '',
+              keyPoints: Array.isArray(ch.keyPoints) ? ch.keyPoints : [],
+              estimatedWords: ch.estimatedWords || 0,
+              writingInstructions: ch.writingInstructions || '',
+            });
+          }
+        } catch { /* skip invalid chapter objects */ }
+      }
+    }
+
+    // 检查最后一个章节是否被截断(通过检查是否有完整的 estimatedWords)
+    if (result.chapters.length > 0) {
+      const lastChapter = result.chapters[result.chapters.length - 1];
+      if (!lastChapter.estimatedWords || lastChapter.estimatedWords === 0) {
+        result.missing = 1; // 最后一个章节可能不完整
+      }
+    }
+  } catch (e) {
+    console.warn('[RichOutline] extractChaptersFromTruncatedJson 失败:', e);
+  }
+
+  return result;
+}
+
+/**
+ * 策略5:从 Markdown 格式中解析大纲
+ *
+ * 模型用「母语」markdown 输出时,格式更稳定、单点故障影响更小。
+ * 支持格式:
+ *   ## 第N章 标题
+ *   > 摘要(可选)
+ *   **知识点**: kp1 | kp2
+ *   **字数**: 2000
+ *   ### 写作指令
+ *   - **开篇**: ...  /  - **必须覆盖**: 换行 - item
+ */
+function parseMarkdownOutline(text: string): any | null {
+  // 按 ## 标题分割章节
+  const chapterBlocks = text.split(/^##\s+(?!写作指令|必须覆盖|不要重复)/m);
+  if (chapterBlocks.length < 2) return null; // 至少要有1章+前言
+
+  // 提取主题(# 开头的一行,或开头第一段直到 ##)
+  const h1Match = text.match(/^#\s+(.+)$/m);
+  const mainTheme = h1Match ? h1Match[1].trim() : '';
+
+  // 提取结构逻辑(# 和第一个 ## 之间的描述文本)
+  let structureLogic = '';
+  const firstChIdx = text.search(/^##\s+/m);
+  if (firstChIdx > 0) {
+    const intro = text.substring(0, firstChIdx);
+    const introLines = intro.replace(/^#\s+.+\n?/m, '').trim().split(/\n/).filter(l => l.trim() && !l.startsWith('>'));
+    structureLogic = introLines.slice(0, 3).join(' ').trim(); // 取前3行
+  }
+
+  const chapters: any[] = [];
+  for (const block of chapterBlocks) {
+    const lines = block.split('\n');
+    let lineIdx = 0;
+
+    // 跳过空行
+    while (lineIdx < lines.length && !lines[lineIdx].trim()) lineIdx++;
+    if (lineIdx >= lines.length) continue;
+
+    // 解析标题行: "第N章" 或 "第N章 标题" 或 "N. 标题"
+    const titleLine = lines[lineIdx].trim();
+    const titleMatch = titleLine.match(/第\s*(\d+)\s*章\s*(.*)/) || titleLine.match(/^(\d+)[\.\、\s]+(.+)/);
+    if (!titleMatch) continue;
+
+    const number = parseInt(titleMatch[1]);
+    const title = (titleMatch[2] || titleMatch[1]).trim();
+    lineIdx++;
+
+    // 解析摘要(以 > 开头的引用行)
+    let summary = '';
+    while (lineIdx < lines.length && lines[lineIdx].trim().startsWith('>')) {
+      summary += lines[lineIdx].trim().replace(/^>\s*/, '') + ' ';
+      lineIdx++;
+    }
+    summary = summary.trim();
+
+    // 解析 keyPoints 和 estimatedWords
+    const keyPoints: string[] = [];
+    let estimatedWords = 0;
+    while (lineIdx < lines.length && !lines[lineIdx].trim().startsWith('##') && !lines[lineIdx].trim().startsWith('#')) {
+      const line = lines[lineIdx].trim();
+      if (!line) { lineIdx++; continue; }
+
+      // **知识点**: kp1 | kp2
+      const kpMatch = line.match(/\*\*知识点\*\*[::]\s*(.+)/);
+      if (kpMatch) {
+        kpMatch[1].split(/\s*[||,,、\/]\s*/).forEach(k => {
+          const cleaned = k.trim().replace(/^[-*]\s*/, '');
+          if (cleaned) keyPoints.push(cleaned);
+        });
+        lineIdx++; continue;
+      }
+
+      // **字数**: 2000
+      const wMatch = line.match(/\*\*字数\*\*[::]\s*(\d+)/);
+      if (wMatch) {
+        estimatedWords = parseInt(wMatch[1]);
+        lineIdx++; continue;
+      }
+
+      // 知识点作为列表项: - item 或 1. item
+      if (/^[-*]\s/.test(line) && !line.includes('**') && keyPoints.length < 8) {
+        keyPoints.push(line.replace(/^[-*]\s*/, '').trim());
+        lineIdx++; continue;
+      }
+
+      // 如果遇到 ### 或下一个 ##,停止
+      if (line.startsWith('###') || line.startsWith('## ')) break;
+      lineIdx++;
+    }
+
+    // 解析 writingInstructions
+    let writingInstructions: any = null;
+    const instrIdx = block.indexOf('### 写作指令');
+    if (instrIdx !== -1) {
+      const instrBlock = block.substring(instrIdx);
+      writingInstructions = parseMarkdownInstructions(instrBlock);
+    } else {
+      // 没有显式"写作指令"小节,尝试解析分散的字段
+      writingInstructions = parseMarkdownInstructions(block);
+    }
+
+    chapters.push({
+      number,
+      title,
+      summary,
+      keyPoints: keyPoints.length > 0 ? keyPoints : ['核心概念', '关键知识', '实践要点'],
+      estimatedWords: estimatedWords || 2000,
+      writingInstructions: writingInstructions || {
+        opening: '以案例或问题引入',
+        structure: '概念→原理→应用',
+        mustCover: ['核心概念', '关键应用'],
+        mustNotRepeat: ['与前文重复的内容'],
+        toneAdjustment: '专业但不晦涩',
+        keyTakeaway: '掌握核心知识点',
+      },
+    });
+  }
+
+  if (chapters.length >= 2) return { mainTheme, structureLogic, chapters };
+  return null;
+}
+
+/**
+ * 解析 writingInstructions 块(markdown 格式)
+ */
+function parseMarkdownInstructions(text: string): any | null {
+  const result: any = {
+    opening: '',
+    structure: '',
+    mustCover: [] as string[],
+    mustNotRepeat: [] as string[],
+    toneAdjustment: '',
+    keyTakeaway: '',
+  };
+
+  const lines = text.split('\n');
+
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i].trim();
+    if (!line) continue;
+
+    // - **开篇**: xxx  或  **开篇**: xxx
+    const openingMatch = line.match(/(?:^[-*]\s*)?\*\*开篇\*\*[::]\s*(.+)/);
+    if (openingMatch) { result.opening = openingMatch[1].trim(); continue; }
+
+    const structureMatch = line.match(/(?:^[-*]\s*)?\*\*结构\*\*[::]\s*(.+)/);
+    if (structureMatch) { result.structure = structureMatch[1].trim(); continue; }
+
+    const toneMatch = line.match(/(?:^[-*]\s*)?\*\*语调\*\*[::]\s*(.+)/);
+    if (toneMatch) { result.toneAdjustment = toneMatch[1].trim(); continue; }
+
+    const takeawayMatch = line.match(/(?:^[-*]\s*)?\*\*核心收获\*\*[::]\s*(.+)/);
+    if (takeawayMatch) { result.keyTakeaway = takeawayMatch[1].trim(); continue; }
+
+    // 多行列表: - **必须覆盖**: 后跟 - item1  \n  - item2
+    if (line.includes('**必须覆盖**') || line.includes('**Must Cover**')) {
+      i = collectListItems(lines, i, result.mustCover);
+      continue;
+    }
+    if (line.includes('**不要重复**') || line.includes('**Must Not Repeat**')) {
+      i = collectListItems(lines, i, result.mustNotRepeat);
+      continue;
+    }
+  }
+
+  return result;
+}
+
+/** 收集后续的 - item 列表项 */
+function collectListItems(lines: string[], startIdx: number, target: string[]): number {
+  let i = startIdx;
+  // 当前行可能包含标题后的第一个项: "**必须覆盖**: - item1"
+  const inlineMatch = lines[i].match(/:\s*[-*]\s*(.+)/);
+  if (inlineMatch) target.push(inlineMatch[1].trim());
+
+  while (i + 1 < lines.length) {
+    const next = lines[i + 1].trim();
+    if (/^[-*]\s/.test(next) && !next.includes('**')) {
+      target.push(next.replace(/^[-*]\s*/, '').trim());
+      i++;
+    } else if (!next || next.startsWith('#')) {
+      break;
+    } else {
+      break;
+    }
+  }
+  return i;
+}
+
 /**
  * 解析富信息大纲 JSON
  * 尝试多种解析策略,确保能从 AI 响应中提取有效 JSON
+ * 返回 { outline, error },error 为空表示解析成功
  *
  * 修复记录:
- * - 2026-05-15: 修复 indexOf('') 的严重 Bug(空字符串总返回0,导致无条件截断前8字符,
- *   使纯 JSON 响应被截断为乱码,所有解析策略全部失败)
+ * - 2026-05-15: 修复 indexOf('') 的严重 Bug
+ * - 2026-05-17: 增强解析策略 + 保存失败响应到文件 + 返回详细错误信息
  */
-function parseRichOutline(jsonStr: string): any | null {
+function parseRichOutline(jsonStr: string, bookId?: string): { outline: any | null; error: string } {
   // 记录原始响应用于调试
   console.log('[RichOutline] 原始响应长度:', jsonStr.length);
   if (jsonStr.length > 2000) {
@@ -146,85 +509,107 @@ function parseRichOutline(jsonStr: string): any | null {
   try {
     let cleaned = jsonStr.trim();
 
-    // 移除思考标签内容(修复:indexOf('') → indexOf('</think>'))
-    const thinkEnd = cleaned.indexOf('</think>');
-    const originalBeforeThink = cleaned; // 备份原始文本,用于策略5 fallback
-    if (thinkEnd !== -1) {
-      cleaned = cleaned.substring(thinkEnd + 8).trim();
-      console.log('[RichOutline] 已移除 </think> 标签,截取后长度:', cleaned.length);
-    } else {
-      console.log('[RichOutline] 未发现 </think> 标签,保持原始文本');
+    // 移除思考标签内容(如 <think>...</think>)
+    const thinkTagMatch = cleaned.match(/<think>[\s\S]*?<\/think>/i);
+    if (thinkTagMatch) {
+      cleaned = cleaned.replace(thinkTagMatch[0], '').trim();
+      console.log('[RichOutline] 已移除 <think> 标签,截取后长度:', cleaned.length);
     }
 
-    // 策略1:尝试直接解析
-    try {
-      const data = JSON.parse(cleaned);
-      if (data.chapters && Array.isArray(data.chapters) && data.chapters.length > 0) {
-        console.log('[RichOutline] 策略1成功:直接解析');
-        return data;
-      }
-    } catch { /* continue */ }
+    // 移除 markdown 代码块标记
+    cleaned = cleaned.replace(/```json\s*/gi, '').replace(/```\s*/g, '');
 
-    // 策略2:移除 markdown 代码块
-    cleaned = cleaned.replace(/```json\s*/g, '').replace(/```\s*/g, '');
-    try {
-      const data = JSON.parse(cleaned);
-      if (data.chapters && Array.isArray(data.chapters) && data.chapters.length > 0) {
-        console.log('[RichOutline] 策略2成功:移除代码块');
-        return data;
+    // 策略1:jsonrepair 修复后直接解析
+    {
+      const repaired = repairJson(cleaned);
+      try {
+        const data = JSON.parse(repaired);
+        if (data.chapters && Array.isArray(data.chapters) && data.chapters.length > 0) {
+          console.log('[RichOutline] 策略1成功:jsonrepair + 直接解析');
+          return { outline: data, error: '' };
+        }
+      } catch (e: any) {
+        console.log('[RichOutline] 策略1 jsonrepair 后解析失败:', e.message.substring(0, 100));
       }
-    } catch { /* continue */ }
+    }
 
-    // 策略3:正则提取 JSON 对象
+    // 策略2:正则提取 JSON 对象 + jsonrepair
     const match = cleaned.match(/\{[\s\S]*\}/);
     if (match) {
       try {
-        const data = JSON.parse(match[0]);
+        const repaired = repairJson(match[0]);
+        const data = JSON.parse(repaired);
         if (data.chapters && Array.isArray(data.chapters) && data.chapters.length > 0) {
-          console.log('[RichOutline] 策略3成功:正则提取');
-          return data;
+          console.log('[RichOutline] 策略2成功:正则提取 + jsonrepair');
+          return { outline: data, error: '' };
         }
-        // 有 JSON 但结构不对
-        console.log('[RichOutline] JSON结构异常:', JSON.stringify(data).substring(0, 200));
       } catch (e: any) {
-        console.log('[RichOutline] 正则提取失败:', e.message);
+        console.log('[RichOutline] 策略2 失败:', e.message.substring(0, 100));
       }
-    } else {
-      console.log('[RichOutline] 未找到 JSON 对象');
     }
 
-    // 策略4:尝试找最后一个合法的 JSON 对象(处理嵌套问题)
-    const allMatches = cleaned.match(/\{[\s\S]*?\}/g) || [];
-    for (let i = allMatches.length - 1; i >= 0; i--) {
-      try {
-        const data = JSON.parse(allMatches[i]);
-        if (data.chapters && Array.isArray(data.chapters) && data.chapters.length > 0) {
-          console.log('[RichOutline] 策略4成功:倒数第', allMatches.length - i, '个对象');
-          return data;
-        }
-      } catch { /* continue */ }
+    // 策略3:从截断 JSON 中提取章节
+    const truncationResult = extractChaptersFromTruncatedJson(cleaned);
+    if (truncationResult.chapters.length > 0) {
+      console.log(`[RichOutline] 策略3:从截断JSON提取到 ${truncationResult.chapters.length} 个完整章节`);
+      if (truncationResult.missing > 0) {
+        console.warn(`[RichOutline]   警告: ${truncationResult.missing} 个章节不完整或被截断`);
+      }
+      return {
+        outline: {
+          mainTheme: truncationResult.mainTheme || '',
+          structureLogic: truncationResult.structureLogic || '',
+          chapters: truncationResult.chapters,
+        },
+        error: ''
+      };
     }
 
-    // 策略5(fallback):如果 think 标签移除后解析失败,用原始文本再试
-    if (thinkEnd !== -1) {
-      console.log('[RichOutline] 尝试策略5:使用原始文本(跳过 think 标签移除)');
-      const rawCleaned = originalBeforeThink.replace(/```json\s*/g, '').replace(/```\s*/g, '');
-      const rawMatch = rawCleaned.match(/\{[\s\S]*\}/);
-      if (rawMatch) {
-        try {
-          const data = JSON.parse(rawMatch[0]);
-          if (data.chapters && Array.isArray(data.chapters) && data.chapters.length > 0) {
-            console.log('[RichOutline] 策略5成功:原始文本正则提取');
-            return data;
-          }
-        } catch { /* continue */ }
+    // 策略4(fallback):从自由文本中提取章节标题
+    {
+      const chapterPatterns: { title: string; summary: string; sections: any[] }[] = [];
+      const chapterRegex = /(?:第\s*(\d+|[一二三四五六七八九十百千]+)\s*章|Chapter\s*(\d+))\s*[::\s]*([^\n]{2,50})(?:\n|$)/gi;
+      let chMatch;
+      while ((chMatch = chapterRegex.exec(cleaned)) !== null) {
+        chapterPatterns.push({ title: chMatch[3].trim(), summary: '', sections: [] });
+      }
+      if (chapterPatterns.length >= 2) {
+        console.log(`[RichOutline] 策略4:从文本提取到 ${chapterPatterns.length} 个章节标题`);
+        return {
+          outline: {
+            chapters: chapterPatterns.map((ch, i) => ({
+              number: i + 1, title: ch.title, summary: '',
+              keyPoints: [], estimatedWords: 0,
+              writingInstructions: '使用简洁明了的语言', sections: [],
+            })),
+          },
+          error: ''
+        };
       }
     }
 
-    return null;
+    // 策略5:尝试 Markdown 格式解析(模型用"母语"输出时格式更稳定)
+    {
+      const mdResult = parseMarkdownOutline(cleaned);
+      if (mdResult) {
+        console.log(`[RichOutline] 策略5成功:Markdown解析 → ${mdResult.chapters.length} 个章节`);
+        return { outline: mdResult, error: '' };
+      }
+    }
+
+    // 所有策略都失败 → 保存响应到文件并返回详细错误
+    const savedPath = bookId ? saveFailedResponse(bookId, jsonStr, 'ALL_PARSE_STRATEGIES_FAILED') : '';
+    const errorDetail = savedPath
+      ? `富信息大纲解析失败:AI返回格式无法识别(调试文件: ${path.basename(savedPath)})。响应长度${jsonStr.length}字符。`
+      : `富信息大纲解析失败:AI返回格式无法识别。响应长度${jsonStr.length}字符。`;
+
+    console.error('[RichOutline] ❌ 所有解析策略失败:', errorDetail);
+    return { outline: null, error: errorDetail };
   } catch (e: any) {
-    console.error('[RichOutline] 解析异常:', e.message);
-    return null;
+    const errorMsg = `解析异常: ${e.message}`;
+    console.error('[RichOutline] 解析异常:', errorMsg);
+    if (bookId) saveFailedResponse(bookId, jsonStr, `PARSE_EXCEPTION: ${e.message}`);
+    return { outline: null, error: errorMsg };
   }
 }
 
@@ -253,14 +638,19 @@ export async function richOutlineNode(state: typeof GraphState.State): Promise<P
         return callLLMWithRetry(
           buildRichOutlineMessages(title, description, state.bookScale, state.genLevel, bookPlan),
           undefined,
-          { bookId: state.bookId, nodeId: 'rich_outline', attempt: 0, maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries }
+          { bookId: state.bookId, nodeId: 'rich_outline', attempt: 0, maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries },
+          RICH_OUTLINE_MAX_TOKENS
         );
       },
       FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
     );
 
-    let outline = parseRichOutline(response);
-    if (!outline) throw new Error('富信息大纲解析失败');
+    const parseResult = parseRichOutline(response, state.bookId);
+    if (!parseResult.outline) {
+      // 解析失败,把详细错误信息写入数据库
+      throw new Error(parseResult.error || '富信息大纲解析失败');
+    }
+    let outline = parseResult.outline;
 
     // ===== 质量评估 + 低分自动重试(Issue 12)=====
     let quality = evaluateOutlineQuality(
@@ -291,20 +681,21 @@ export async function richOutlineNode(state: typeof GraphState.State): Promise<P
               quality.warnings.map(w => w.message)
             ),
             undefined,
-            { bookId: state.bookId, nodeId: 'rich_outline_retry', attempt: 0, maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries }
+            { bookId: state.bookId, nodeId: 'rich_outline_retry', attempt: 0, maxAttempts: FAULT_TOLERANCE_CONFIG.aiRetry.maxRetries },
+            RICH_OUTLINE_MAX_TOKENS
           );
         },
         FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
       );
 
-      const retryOutline = parseRichOutline(retryResponse);
-      if (!retryOutline) {
+      const retryResult = parseRichOutline(retryResponse, state.bookId);
+      if (!retryResult.outline) {
         console.warn('[RichOutline] 重试解析失败,使用原始大纲');
         break;
       }
 
       const retryQuality = evaluateOutlineQuality(
-        retryOutline.chapters,
+        retryResult.outline.chapters,
         getScaleConfig(state.bookScale),
         state.genLevel
       );
@@ -312,7 +703,7 @@ export async function richOutlineNode(state: typeof GraphState.State): Promise<P
 
       // 用更好的那份
       if (retryQuality.score > quality.score) {
-        outline = retryOutline;
+        outline = retryResult.outline;
         quality = retryQuality;
       } else {
         console.log('[RichOutline] 重试未改善,保留原始大纲');
@@ -397,4 +788,4 @@ export async function richOutlineNode(state: typeof GraphState.State): Promise<P
     });
     return { error: error instanceof Error ? error.message : '失败', finished: true };
   }
-}
+}

+ 64 - 4
server/src/modules/book-generator/prompts/templates.ts

@@ -269,7 +269,7 @@ export const RICH_OUTLINE_SYSTEM_PROMPT = `你是一位专业的图书策划编
         "opening": "如何开篇(案例/问题/故事)",
         "structure": "内容组织方式(问题→原因→方案→验证)",
         "mustCover": ["必须覆盖的内容"],
-        "mustNotRepeat": ["不能重复已有章节的内容"],
+        "mustNotRepeat": ["不能重复的内容(明确写出:如'不要重复第X章的XX'或'不涉及XX主题')"],
         "toneAdjustment": "语调调整(更实战/更理论/更轻松)",
         "keyTakeaway": "读者学完应该能说出的话"
       },
@@ -284,7 +284,7 @@ export const RICH_OUTLINE_SYSTEM_PROMPT = `你是一位专业的图书策划编
             "opening": "如何开篇",
             "structure": "组织方式",
             "mustCover": ["必须覆盖"],
-            "mustNotRepeat": ["不能重复"],
+            "mustNotRepeat": ["不能重复(注明章号)"],
             "toneAdjustment": "语调",
             "keyTakeaway": "核心收获"
           },
@@ -299,7 +299,7 @@ export const RICH_OUTLINE_SYSTEM_PROMPT = `你是一位专业的图书策划编
                 "opening": "如何开篇",
                 "structure": "组织方式",
                 "mustCover": ["必须覆盖"],
-                "mustNotRepeat": ["不能重复"],
+                "mustNotRepeat": ["不能重复(注明章号)"],
                 "toneAdjustment": "语调",
                 "keyTakeaway": "核心收获"
               }
@@ -315,7 +315,67 @@ export const RICH_OUTLINE_SYSTEM_PROMPT = `你是一位专业的图书策划编
 - genLevel=1 时每章不要 sections
 - genLevel=2 时每章的 sections 下不要 subsections
 - genLevel=3 时才需要完整的 sections + subsections
-- writingInstructions 写得越具体,后续内容生成质量越高`;
+- writingInstructions 写得越具体,后续内容生成质量越高
+- **mustNotRepeat 必须明确写出章号**,如"不要重复第3章的XX内容"`;
+
+/**
+ * 富信息大纲系统提示词(Markdown 格式)
+ * 模型用「母语」输出,格式更稳定、单点故障影响更小
+ * 通过环境变量 OUTLINE_FORMAT=markdown 启用
+ */
+export const RICH_OUTLINE_MARKDOWN_PROMPT = `你是一位专业的图书策划编辑,擅长为各类主题设计清晰、严谨、有逻辑递进的书籍大纲。
+
+## 你的职责
+根据深度规划方案,生成完整的树形大纲,并**为每个大纲节点附加详细的写作指令**。
+
+## 工作要求
+1. 深入理解深度规划中的 goldenThread、narrativeArc、crossReferences
+2. 严格按照指定的 genLevel 生成大纲层级
+3. 每个大纲节点必须包含写作指令,指导后续内容生成
+4. 章节之间必须有清晰的逻辑递进,后面的章节要避免重复前面的内容
+
+## 输出格式(Markdown)
+请严格使用以下格式,不要用 JSON:
+
+# 主题一句话描述
+
+结构逻辑一段话说明
+
+## 第1章 章标题
+
+> 章摘要(2-3句话)
+
+**知识点**: 知识点1 | 知识点2 | 知识点3
+**字数**: 2000
+
+### 写作指令
+
+- **开篇**: 具体的开篇场景或问题描述
+- **结构**: 内容组织方式(如:现象→分析→启示)
+- **必须覆盖**:
+  - 覆盖点1(具体说明)
+  - 覆盖点2
+  - 覆盖点3
+- **不要重复**:
+  - 避重复项1(明确指出)
+  - 避重复项2
+- **语调**: 语调说明(如:安静细腻 / 轻松幽默 / 专业严谨)
+- **核心收获**: 读者学完应该能说出的那句话
+
+## 第2章 章标题
+
+> ...
+
+(按此格式继续,直到全部章节输出完毕)
+
+## ⚠️ 关键要求
+- **必须生成本书约束中指定的全部章节,一章都不能少!**
+- 从 ## 第1章 连续输出到 ## 最后一章
+- genLevel=1 只生成 ## 章,不要 ### 节
+- genLevel=2 每章下用 ### 节
+- genLevel=3 用 ### 节 + #### 小节
+- 写作指令越具体,后续内容质量越高
+- **严格遵守此 Markdown 格式,不要输出 JSON**`;
 
 /**
  * 连贯性编辑系统提示词