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

feat: add RollingSummarizer, MilestoneAudit, and event consistency check

Implement background agents for rolling chapter summaries every 5 chapters
and full milestone audits every 20 chapters. Add event_consistency as the
9th continuity check item and wire chapterEvents into Step6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nonvioce 6 месяцев назад
Родитель
Сommit
1ced88c06b

+ 82 - 0
src/app/api/novel/audit/route.ts

@@ -0,0 +1,82 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+import { promises as fs } from 'fs';
+import path from 'path';
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  if (!body) return NextResponse.json({ success: false, error: '无效请求' }, { status: 400 });
+
+  const { projectPath, chapterIndex, apiSettings } = body;
+  if (!projectPath || !isPathSafe(projectPath) || !apiSettings) {
+    return NextResponse.json({ success: false, error: '缺少必要参数' }, { status: 400 });
+  }
+
+  let ironRules = '', styleAnchor = '';
+  try {
+    ironRules = await fs.readFile(path.join(projectPath, 'iron-rules.md'), 'utf-8').catch(() => '');
+    styleAnchor = await fs.readFile(path.join(projectPath, 'style-anchor.md'), 'utf-8').catch(() => '');
+  } catch { /* ignore */ }
+
+  let summaries = '';
+  try {
+    const summaryDir = path.join(projectPath, 'world_state', 'rolling-summaries');
+    const files = (await fs.readdir(summaryDir).catch(() => [] as string[])).sort();
+    for (const file of files) {
+      summaries += await fs.readFile(path.join(summaryDir, file), 'utf-8') + '\n\n';
+    }
+  } catch { /* ignore */ }
+
+  const prompt = `对第1-${chapterIndex}章进行一次全书锚定审计。
+
+## 项目铁律
+${ironRules || '(未设置)'}
+
+## 风格锚定(摘要)
+${styleAnchor.slice(0, 2000) || '(未设置)'}
+
+## 滚动摘要
+${summaries.slice(0, 5000)}
+
+## 审计维度
+1. 角色漂移:主角性格/语言/行为是否在过程中逐渐偏离初始设定?
+2. 设定漂移:世界规则是否在后期出现不一致或隐性修改?
+3. 风格漂移:文风是否逐渐趋向 AI 模式化?
+4. 伏笔缺口:已埋未收的伏笔是否过多?
+
+## 输出格式
+只返回 JSON:
+{"characterDrift":{"severity":"ok|warning|issue","detail":"具体情况"},"settingDrift":{"severity":"ok|warning|issue","detail":"具体情况"},"styleDrift":{"severity":"ok|warning|issue","detail":"具体情况"},"foreshadowingGap":{"severity":"ok|warning|issue","detail":"具体情况"},"overallAssessment":"总体评估和建议"}`;
+
+  try {
+    const response = await fetch(apiSettings.apiUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiSettings.apiKey}` },
+      body: JSON.stringify({ model: apiSettings.model, messages: [{ role: 'user', content: prompt }], max_tokens: 2000 }),
+      signal: AbortSignal.timeout(55000),
+    });
+
+    if (!response.ok) {
+      return NextResponse.json({ success: false, error: `AI 请求失败: ${response.status}` }, { status: 502 });
+    }
+
+    const data = await response.json();
+    const text = data.choices?.[0]?.message?.content || '';
+    const jsonMatch = text.match(/\{[\s\S]*\}/);
+    const audit = jsonMatch ? JSON.parse(jsonMatch[0]) : null;
+
+    if (audit) {
+      const auditDir = path.join(projectPath, 'logs', 'audit');
+      await fs.mkdir(auditDir, { recursive: true });
+      await fs.writeFile(
+        path.join(auditDir, `milestone-${chapterIndex}.json`),
+        JSON.stringify({ ...audit, chapterRange: `1-${chapterIndex}` }, null, 2),
+        'utf-8'
+      );
+    }
+
+    return NextResponse.json({ success: true, audit });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}

+ 66 - 0
src/app/api/novel/summarize/route.ts

@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { isPathSafe } from '@/app/lib/novel-server-utils';
+import { promises as fs } from 'fs';
+import path from 'path';
+
+export async function POST(request: NextRequest) {
+  const body = await request.json().catch(() => null);
+  if (!body) return NextResponse.json({ success: false, error: '无效请求' }, { status: 400 });
+
+  const { projectPath, startChapter, endChapter, apiSettings } = body;
+  if (!projectPath || !isPathSafe(projectPath) || !apiSettings) {
+    return NextResponse.json({ success: false, error: '缺少必要参数' }, { status: 400 });
+  }
+
+  const chaptersDir = path.join(projectPath, 'chapters');
+  let chapterTexts = '';
+  try {
+    const files = await fs.readdir(chaptersDir);
+    for (const file of files) {
+      const match = file.match(/^chapter_(\d+)_/);
+      if (match) {
+        const idx = parseInt(match[1]);
+        if (idx >= startChapter && idx <= endChapter) {
+          const content = await fs.readFile(path.join(chaptersDir, file), 'utf-8');
+          chapterTexts += `\n## 第${idx}章\n${content.slice(0, 1500)}\n`;
+        }
+      }
+    }
+  } catch {
+    return NextResponse.json({ success: false, error: '无法读取章节文件' }, { status: 500 });
+  }
+
+  const isCompression = endChapter - startChapter > 10;
+  const prompt = isCompression
+    ? `将以下第${startChapter}-${endChapter}章的内容压缩为"卷梗概"(不超过800字),保留:主线关键事件、重要角色变化、核心伏笔状态。\n\n${chapterTexts.slice(0, 8000)}\n\n直接输出压缩后的卷梗概,不要JSON格式。`
+    : `为第${startChapter}-${endChapter}章生成一个章节组摘要(不超过500字),重点记录:每个关键事件的简要描述、角色变化、新埋的伏笔。\n\n${chapterTexts.slice(0, 6000)}\n\n直接输出章节组摘要,不要JSON格式。`;
+
+  try {
+    const response = await fetch(apiSettings.apiUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiSettings.apiKey}` },
+      body: JSON.stringify({ model: apiSettings.model, messages: [{ role: 'user', content: prompt }], max_tokens: isCompression ? 1500 : 1000 }),
+      signal: AbortSignal.timeout(55000),
+    });
+
+    if (!response.ok) {
+      return NextResponse.json({ success: false, error: `AI 请求失败: ${response.status}` }, { status: 502 });
+    }
+
+    const data = await response.json();
+    const summary = data.choices?.[0]?.message?.content || '';
+
+    const arcIdx = Math.ceil(startChapter / 5);
+    const summaryDir = path.join(projectPath, 'world_state', 'rolling-summaries');
+    await fs.mkdir(summaryDir, { recursive: true });
+    await fs.writeFile(
+      path.join(summaryDir, `arc-${String(arcIdx).padStart(2, '0')}.md`),
+      `# 第${startChapter}-${endChapter}章${isCompression ? ' 卷梗概' : ' 章节摘要'}\n\n${summary}`,
+      'utf-8'
+    );
+
+    return NextResponse.json({ success: true, summary, arcIndex: arcIdx });
+  } catch (error) {
+    return NextResponse.json({ success: false, error: (error as Error).message }, { status: 500 });
+  }
+}

+ 1 - 0
src/app/features/novel-writer/components/ChapterDraftPipeline.tsx

@@ -424,6 +424,7 @@ export default function ChapterDraftPipeline({
               chapterIndex={chapterIndex}
               chapterContent={pipelineState.chapterContent}
               apiSettings={apiSettings}
+              chapterEvents={chapterEvents}
               onComplete={handleStep6Complete}
               onBack={() => setCurrentStep(5)}
             />

+ 22 - 1
src/app/features/novel-writer/components/NovelContextManager.tsx

@@ -213,6 +213,27 @@ export default function NovelContextManager({
         await saveChapter(content, chapterTitle);
         setSavedNotification(true);
         setTimeout(() => setSavedNotification(false), 3000);
+
+        // Background agents: RollingSummarizer every 5 chapters, MilestoneAudit every 20
+        const newChapterIndex = chapters.length + 1;
+        if (apiSettings) {
+          if (newChapterIndex % 5 === 0) {
+            const startChapter = newChapterIndex - 4;
+            fetch('/api/novel/summarize', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ projectPath, startChapter, endChapter: newChapterIndex, apiSettings }),
+            }).catch(() => {});
+          }
+          if (newChapterIndex > 0 && newChapterIndex % 20 === 0) {
+            fetch('/api/novel/audit', {
+              method: 'POST',
+              headers: { 'Content-Type': 'application/json' },
+              body: JSON.stringify({ projectPath, chapterIndex: newChapterIndex, apiSettings }),
+            }).catch(() => {});
+          }
+        }
+
         // Marathon mode: auto-start next chapter after short delay
         if (marathonMode) {
           setTimeout(() => setShowPipeline(true), 1000);
@@ -221,7 +242,7 @@ export default function NovelContextManager({
         // silently ignore — chapter was still written
       }
     },
-    [saveChapter, marathonMode]
+    [saveChapter, marathonMode, chapters.length, apiSettings, projectPath]
   );
 
   const handlePipelineCancel = () => {

+ 9 - 0
src/app/features/novel-writer/components/pipeline/Step6ContinuityCheck.tsx

@@ -3,6 +3,7 @@
 import { useState } from 'react';
 import { Shield, ArrowLeft, Check } from 'lucide-react';
 import { CONTINUITY_CHECK_ITEMS, buildContinuityCheckPrompt } from '@/app/lib/chapter-prompts';
+import type { EventNode } from '@/app/lib/novel-types';
 
 interface ApiSettings {
   apiUrl: string;
@@ -15,6 +16,7 @@ interface Step6ContinuityCheckProps {
   chapterIndex: number;
   chapterContent: string;
   apiSettings: ApiSettings | null;
+  chapterEvents?: EventNode[];
   onComplete: () => void;
   onBack: () => void;
 }
@@ -30,6 +32,7 @@ export default function Step6ContinuityCheck({
   chapterIndex,
   chapterContent,
   apiSettings,
+  chapterEvents,
   onComplete,
   onBack,
 }: Step6ContinuityCheckProps) {
@@ -76,6 +79,12 @@ export default function Step6ContinuityCheck({
       }
       contextParts.push(`### 本章内容 (第${chapterIndex}章)\n${chapterContent}`);
 
+      if (chapterEvents && chapterEvents.length > 0) {
+        contextParts.push('### 本章事件节点\n' + chapterEvents.map(e =>
+          `- ${e.title}: ${e.description}`
+        ).join('\n'));
+      }
+
       const prompt = buildContinuityCheckPrompt(Array.from(selected), contextParts.join('\n\n'));
 
       const requestBody = {

+ 1 - 0
src/app/lib/chapter-prompts.ts

@@ -88,6 +88,7 @@ export const CONTINUITY_CHECK_ITEMS = [
   { id: 'emotional_debt', label: '情绪债务', desc: '前文遗留的情绪承诺是否兑现', checkPrompt: '追踪情绪债务账本。检查:前文建立的"需要回应"的情感时刻是否被回应、是否有角色的情感状态在无解释的情况下突变、新产生的情感承诺是否被记录。' },
   { id: 'ai_patterns', label: 'AI 模式化', desc: '模板句式、假深度、空泛收束等 AI 特征', checkPrompt: '扫描 AI 模式化痕迹。检查:是否有"不仅……更……"等模板对比句式、"在这个充满……的世界里"等假深度开头、段落收束是否总是"这一切都表明……"式空泛总结、是否有抽象套话替代具体描写。' },
   { id: 'paragraph_drift', label: '段落漂移', desc: '段落模式是否偏离项目设定', checkPrompt: '检查段落模式。当前项目默认 web-serial-natural 模式(叙事段2-4句,单句段为强调/揭示/冲击服务)。检查:是否有无意义的单句段链、同一说话人的连续台词是否被过度拆分、长段落是否变为了惰性说明墙。' },
+  { id: 'event_consistency', label: '事件一致', desc: '本章内容是否覆盖了分配的事件节点', checkPrompt: '检查本章内容与事件图的对应关系。检查:本章分配的事件节点是否都在内容中体现、内容推进的事件状态是否与事件图一致、是否有事件遗漏。' },
 ];
 
 /** 构建连续性检查 Prompt */