|
@@ -11,16 +11,44 @@ import { GraphState } from '../graph';
|
|
|
import { bookStore } from '../book-generator.store';
|
|
import { bookStore } from '../book-generator.store';
|
|
|
import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
|
|
import { callLLMWithMessages, ChatMessage } from '../../../services/llm';
|
|
|
import { callLLMWithRetry, executeNodeWithTimeout, FAULT_TOLERANCE_CONFIG } from '../fault-tolerance';
|
|
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 { getScaleConfig } from '../book-type-config';
|
|
|
import { PROGRESS } from '../utils';
|
|
import { PROGRESS } from '../utils';
|
|
|
import { evaluateOutlineQuality } from '../utils/outline-quality';
|
|
import { evaluateOutlineQuality } from '../utils/outline-quality';
|
|
|
import { validateInstructionConsistency, InstructionNode } from '../utils/instruction-consistency';
|
|
import { validateInstructionConsistency, InstructionNode } from '../utils/instruction-consistency';
|
|
|
import { prisma } from '../../../models';
|
|
import { prisma } from '../../../models';
|
|
|
|
|
+import { jsonrepair } from 'jsonrepair';
|
|
|
|
|
+import fs from 'fs';
|
|
|
|
|
+import path from 'path';
|
|
|
|
|
|
|
|
/** 大纲质量自动重试的最低分数阈值 */
|
|
/** 大纲质量自动重试的最低分数阈值 */
|
|
|
const OUTLINE_RETRY_THRESHOLD = 75;
|
|
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')}`;
|
|
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}
|
|
${planSection}
|
|
|
|
|
|
|
|
## 本书约束
|
|
## 本书约束
|
|
|
-- 总字数约${totalWords}字,目标约${targetChapters}章
|
|
|
|
|
|
|
+- 总字数约${totalWords}字
|
|
|
|
|
+${chapterReq}
|
|
|
- 大纲层级:${levelDesc[genLevel] || levelDesc[2]}
|
|
- 大纲层级:${levelDesc[genLevel] || levelDesc[2]}
|
|
|
- genLevel 当前值:${genLevel}`;
|
|
- genLevel 当前值:${genLevel}`;
|
|
|
|
|
|
|
@@ -105,12 +142,20 @@ function buildRichOutlineRetryMessages(
|
|
|
? `\n\n## ⚠️ 上次生成的问题(请务必修复)\n${previousWarnings.map(w => `- ${w}`).join('\n')}`
|
|
? `\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}
|
|
${planSection}
|
|
|
${warningsSection}
|
|
${warningsSection}
|
|
|
|
|
|
|
|
## 本书约束
|
|
## 本书约束
|
|
|
-- 总字数约${totalWords}字,目标约${targetChapters}章
|
|
|
|
|
|
|
+- 总字数约${totalWords}字
|
|
|
|
|
+${chapterReq}
|
|
|
- 大纲层级:${levelDesc[genLevel] || levelDesc[2]}
|
|
- 大纲层级:${levelDesc[genLevel] || levelDesc[2]}
|
|
|
- genLevel 当前值:${genLevel}
|
|
- 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
|
|
* 解析富信息大纲 JSON
|
|
|
* 尝试多种解析策略,确保能从 AI 响应中提取有效 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);
|
|
console.log('[RichOutline] 原始响应长度:', jsonStr.length);
|
|
|
if (jsonStr.length > 2000) {
|
|
if (jsonStr.length > 2000) {
|
|
@@ -146,85 +509,107 @@ function parseRichOutline(jsonStr: string): any | null {
|
|
|
try {
|
|
try {
|
|
|
let cleaned = jsonStr.trim();
|
|
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]*\}/);
|
|
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
|
if (match) {
|
|
if (match) {
|
|
|
try {
|
|
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) {
|
|
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) {
|
|
} 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) {
|
|
} 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(
|
|
return callLLMWithRetry(
|
|
|
buildRichOutlineMessages(title, description, state.bookScale, state.genLevel, bookPlan),
|
|
buildRichOutlineMessages(title, description, state.bookScale, state.genLevel, bookPlan),
|
|
|
undefined,
|
|
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
|
|
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)=====
|
|
// ===== 质量评估 + 低分自动重试(Issue 12)=====
|
|
|
let quality = evaluateOutlineQuality(
|
|
let quality = evaluateOutlineQuality(
|
|
@@ -291,20 +681,21 @@ export async function richOutlineNode(state: typeof GraphState.State): Promise<P
|
|
|
quality.warnings.map(w => w.message)
|
|
quality.warnings.map(w => w.message)
|
|
|
),
|
|
),
|
|
|
undefined,
|
|
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
|
|
FAULT_TOLERANCE_CONFIG.nodeTimeout.generate_outline
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
- const retryOutline = parseRichOutline(retryResponse);
|
|
|
|
|
- if (!retryOutline) {
|
|
|
|
|
|
|
+ const retryResult = parseRichOutline(retryResponse, state.bookId);
|
|
|
|
|
+ if (!retryResult.outline) {
|
|
|
console.warn('[RichOutline] 重试解析失败,使用原始大纲');
|
|
console.warn('[RichOutline] 重试解析失败,使用原始大纲');
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const retryQuality = evaluateOutlineQuality(
|
|
const retryQuality = evaluateOutlineQuality(
|
|
|
- retryOutline.chapters,
|
|
|
|
|
|
|
+ retryResult.outline.chapters,
|
|
|
getScaleConfig(state.bookScale),
|
|
getScaleConfig(state.bookScale),
|
|
|
state.genLevel
|
|
state.genLevel
|
|
|
);
|
|
);
|
|
@@ -312,7 +703,7 @@ export async function richOutlineNode(state: typeof GraphState.State): Promise<P
|
|
|
|
|
|
|
|
// 用更好的那份
|
|
// 用更好的那份
|
|
|
if (retryQuality.score > quality.score) {
|
|
if (retryQuality.score > quality.score) {
|
|
|
- outline = retryOutline;
|
|
|
|
|
|
|
+ outline = retryResult.outline;
|
|
|
quality = retryQuality;
|
|
quality = retryQuality;
|
|
|
} else {
|
|
} else {
|
|
|
console.log('[RichOutline] 重试未改善,保留原始大纲');
|
|
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 };
|
|
return { error: error instanceof Error ? error.message : '失败', finished: true };
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
+}
|