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

fix: compact imported book foundation context

Ma 6 месяцев назад
Родитель
Сommit
d6cceebbfa

+ 29 - 1
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
 import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
-import { PipelineRunner } from "../pipeline/runner.js";
+import { buildImportFoundationSource, PipelineRunner } from "../pipeline/runner.js";
 import * as llmProvider from "../llm/provider.js";
 import { StateManager } from "../state/manager.js";
 import { ArchitectAgent } from "../agents/architect.js";
@@ -41,6 +41,34 @@ const ZERO_USAGE = {
   totalTokens: 0,
 } as const;
 
+describe("buildImportFoundationSource", () => {
+  it("compacts large imported books into opening, middle anchors, ending, and title catalog", () => {
+    const chapters = Array.from({ length: 36 }, (_, index) => {
+      const n = index + 1;
+      return {
+        title: `第${n}章 标题${n}`,
+        content: `OPEN-${n}\n${"正文".repeat(3000)}\nTAIL-${n}`,
+      };
+    });
+    const fullText = chapters.map((chapter, index) => `第${index + 1}章 ${chapter.title}\n\n${chapter.content}`).join("\n\n---\n\n");
+
+    const source = buildImportFoundationSource(chapters, "zh", {
+      maxFullTextChars: 20_000,
+      chapterExcerptChars: 1_200,
+      titleCatalogChars: 2_000,
+    });
+
+    expect(source.length).toBeLessThan(fullText.length / 2);
+    expect(source).toContain("压缩资料包");
+    expect(source).toContain("完整章节将在后续顺序回放");
+    expect(source).toContain("第1章 第1章 标题1");
+    expect(source).toContain("第36章 第36章 标题36");
+    expect(source).toContain("OPEN-1");
+    expect(source).toContain("TAIL-36");
+    expect(source).not.toContain("正文".repeat(2500));
+  });
+});
+
 const CRITICAL_ISSUE: AuditIssue = {
   severity: "critical",
   category: "continuity",

+ 3 - 3
packages/core/src/agents/architect.ts

@@ -944,7 +944,7 @@ ${continuationDirective}
 ## Output contract
 Follow the consolidated 5-section === SECTION: === layout: story_frame, volume_map, roles, book_rules, pending_hooks. Do NOT emit rhythm_principles or current_state — rhythm principles live in the last paragraph of volume_map; character initial status lives in roles.Current_State; initial hooks live in pending_hooks start_chapter=0 rows; era / setting anchors (only when the genre pins to a real year) are woven into story_frame's world-tonal-ground paragraph.
 
-All prose must be derived from the source text. Do not invent settings. For volume_map, treat existing chapters as "review" (one paragraph) and continuation as prose chapter-level planning. Hook extraction must be complete (every unresolved clue).
+All prose must be derived from the source package. Do not invent settings. If the package says it is compressed, treat chapter catalog + excerpts as evidence for the foundation; the full chapters will be replayed later for detailed truth files. For volume_map, treat existing chapters as "review" (one paragraph) and continuation as prose chapter-level planning. Hook extraction must be complete for the evidence provided.
 
 All output MUST be written in English.`
       : `你是专业的网络小说架构师。从已有章节中反向推导散文密度的基础设定,同时设计续写路径。${contextBlock}${reviewFeedbackBlock}
@@ -965,11 +965,11 @@ ${continuationDirective}
 ## 输出契约
 合并后的 5 段 === SECTION: === 结构:story_frame / volume_map / roles / book_rules / pending_hooks。**不要输出 rhythm_principles 或 current_state 两个 section**——节奏原则合并进 volume_map 尾段,角色初始状态合并进 roles.当前现状,初始钩子写在 pending_hooks startChapter=0 行;环境/时代锚(只有年代文 / 历史同人 / 都市重生等真实年份题材需要)织进 story_frame.世界观底色,其他题材直接省略。
 
-所有 prose 必须从正文中推导,不得臆造。volume_map 中,已有章节作为"回顾段"(一段散文),续写部分写到章级 prose。伏笔识别要完整(所有悬而未决的线索)。`;
+所有 prose 必须从资料包中推导,不得臆造。若资料包声明为压缩包,把章节目录和正文摘录当作基础设定证据;完整章节会在后续回放阶段逐章进入 truth files。volume_map 中,已有章节作为"回顾段"(一段散文),续写部分写到章级 prose。伏笔识别以资料包提供的证据为准,尽量完整。`;
 
     const userMessage = resolvedLanguage === "en"
       ? `Generate the complete foundation for an imported ${gp.name} novel titled "${book.title}". Write everything in English.\n\n${chaptersText}`
-      : `以下是《${book.title}》的全部已有正文,请从中反向推导完整基础设定:\n\n${chaptersText}`;
+      : `以下是《${book.title}》的已有正文资料包,请从中反向推导完整基础设定:\n\n${chaptersText}`;
 
     const response = await this.chat([
       { role: "system", content: systemPrompt },

+ 158 - 9
packages/core/src/pipeline/runner.ts

@@ -65,6 +65,159 @@ function isSequenceLevelCategory(category: string): boolean {
   return SEQUENCE_LEVEL_CATEGORIES.has(category);
 }
 
+interface ImportFoundationSourceOptions {
+  readonly maxFullTextChars?: number;
+  readonly chapterExcerptChars?: number;
+  readonly titleCatalogChars?: number;
+  readonly edgeChapterCount?: number;
+  readonly middleAnchorCount?: number;
+}
+
+const DEFAULT_IMPORT_FOUNDATION_MAX_FULL_TEXT_CHARS = 80_000;
+const DEFAULT_IMPORT_CHAPTER_EXCERPT_CHARS = 6_000;
+const DEFAULT_IMPORT_TITLE_CATALOG_CHARS = 24_000;
+const DEFAULT_IMPORT_EDGE_CHAPTER_COUNT = 4;
+const DEFAULT_IMPORT_MIDDLE_ANCHOR_COUNT = 8;
+
+function formatImportedChapter(
+  chapter: { readonly title: string; readonly content: string },
+  index: number,
+  language: LengthLanguage,
+  content = chapter.content,
+): string {
+  return language === "en"
+    ? `Chapter ${index + 1}: ${chapter.title}\n\n${content}`
+    : `第${index + 1}章 ${chapter.title}\n\n${content}`;
+}
+
+function estimateImportFullTextLength(
+  chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
+): number {
+  return chapters.reduce((total, chapter) => total + chapter.title.length + chapter.content.length + 24, 0);
+}
+
+function excerptHeadTail(text: string, maxChars: number, language: LengthLanguage): string {
+  const clean = text.trim();
+  if (clean.length <= maxChars) return clean;
+  const headChars = Math.max(200, Math.floor(maxChars * 0.6));
+  const tailChars = Math.max(200, maxChars - headChars);
+  const omitted = clean.length - headChars - tailChars;
+  const marker = language === "en"
+    ? `\n\n[... ${omitted} chars omitted for import-context budget ...]\n\n`
+    : `\n\n【中间省略 ${omitted} 字,用于控制导入上下文预算】\n\n`;
+  return `${clean.slice(0, headChars).trimEnd()}${marker}${clean.slice(-tailChars).trimStart()}`;
+}
+
+function pickImportAnchorIndexes(
+  chapterCount: number,
+  edgeChapterCount: number,
+  middleAnchorCount: number,
+): ReadonlyArray<number> {
+  const selected = new Set<number>();
+  for (let i = 0; i < Math.min(edgeChapterCount, chapterCount); i++) selected.add(i);
+  for (let i = Math.max(0, chapterCount - edgeChapterCount); i < chapterCount; i++) selected.add(i);
+
+  const middleStart = Math.min(edgeChapterCount, chapterCount);
+  const middleEnd = Math.max(middleStart, chapterCount - edgeChapterCount);
+  const middleSize = middleEnd - middleStart;
+  const anchors = Math.min(middleAnchorCount, middleSize);
+  for (let i = 0; i < anchors; i++) {
+    const offset = Math.floor(((i + 1) * middleSize) / (anchors + 1));
+    selected.add(Math.min(chapterCount - 1, middleStart + offset));
+  }
+
+  return [...selected].sort((a, b) => a - b);
+}
+
+function buildTitleCatalog(
+  chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
+  language: LengthLanguage,
+  maxChars: number,
+): string {
+  const lines = chapters.map((chapter, index) =>
+    language === "en"
+      ? `- Chapter ${index + 1}: ${chapter.title} (${chapter.content.length} chars)`
+      : `- 第${index + 1}章:${chapter.title}(${chapter.content.length}字)`,
+  );
+  const joined = lines.join("\n");
+  if (joined.length <= maxChars) return joined;
+
+  const headBudget = Math.floor(maxChars * 0.55);
+  const tailBudget = maxChars - headBudget;
+  const head: string[] = [];
+  const tail: string[] = [];
+  let headChars = 0;
+  let tailChars = 0;
+  for (const line of lines) {
+    if (headChars + line.length + 1 > headBudget) break;
+    head.push(line);
+    headChars += line.length + 1;
+  }
+  for (let i = lines.length - 1; i >= 0; i--) {
+    const line = lines[i]!;
+    if (tailChars + line.length + 1 > tailBudget) break;
+    tail.unshift(line);
+    tailChars += line.length + 1;
+  }
+  const omitted = lines.length - head.length - tail.length;
+  const marker = language === "en"
+    ? `- ... ${omitted} chapter titles omitted ...`
+    : `- ……中间 ${omitted} 个章节标题省略……`;
+  return [...head, marker, ...tail].join("\n");
+}
+
+export function buildImportFoundationSource(
+  chapters: ReadonlyArray<{ readonly title: string; readonly content: string }>,
+  language: LengthLanguage,
+  options: ImportFoundationSourceOptions = {},
+): string {
+  const maxFullTextChars = options.maxFullTextChars ?? DEFAULT_IMPORT_FOUNDATION_MAX_FULL_TEXT_CHARS;
+  const chapterExcerptChars = options.chapterExcerptChars ?? DEFAULT_IMPORT_CHAPTER_EXCERPT_CHARS;
+  const titleCatalogChars = options.titleCatalogChars ?? DEFAULT_IMPORT_TITLE_CATALOG_CHARS;
+  const edgeChapterCount = options.edgeChapterCount ?? DEFAULT_IMPORT_EDGE_CHAPTER_COUNT;
+  const middleAnchorCount = options.middleAnchorCount ?? DEFAULT_IMPORT_MIDDLE_ANCHOR_COUNT;
+
+  if (estimateImportFullTextLength(chapters) <= maxFullTextChars) {
+    return chapters.map((chapter, index) => formatImportedChapter(chapter, index, language)).join("\n\n---\n\n");
+  }
+
+  const anchorIndexes = pickImportAnchorIndexes(chapters.length, edgeChapterCount, middleAnchorCount);
+  const header = language === "en"
+    ? [
+        "## Import foundation source package",
+        "",
+        `The imported book has ${chapters.length} chapters. To avoid overflowing the LLM context, this package keeps the opening chapters, ending/continuation point, selected middle anchors, and a capped title catalog. Full chapters will still be replayed sequentially after foundation generation to rebuild truth files.`,
+      ].join("\n")
+    : [
+        "## 导入基础设定压缩资料包",
+        "",
+        `本次导入共 ${chapters.length} 章。为避免超出 LLM 上下文,这里保留开篇、结尾续写点、少量中段锚点和标题目录;完整章节将在后续顺序回放中逐章分析并沉淀 truth files。`,
+      ].join("\n");
+  const catalogTitle = language === "en" ? "## Capped chapter title catalog" : "## 章节标题目录(截断)";
+  const anchorsTitle = language === "en" ? "## Source excerpts for architecture" : "## 用于反推基础设定的正文摘录";
+  const anchorText = anchorIndexes
+    .map((index) => {
+      const chapter = chapters[index]!;
+      return formatImportedChapter(
+        chapter,
+        index,
+        language,
+        excerptHeadTail(chapter.content, chapterExcerptChars, language),
+      );
+    })
+    .join("\n\n---\n\n");
+
+  return [
+    header,
+    "",
+    catalogTitle,
+    buildTitleCatalog(chapters, language, titleCatalogChars),
+    "",
+    anchorsTitle,
+    anchorText,
+  ].join("\n");
+}
+
 export interface PipelineConfig {
   readonly client: LLMClient;
   readonly model: string;
@@ -2345,23 +2498,19 @@ ${matrix}`,
           zh: `步骤 1:从 ${input.chapters.length} 章生成基础设定...`,
           en: `Step 1: Generating foundation from ${input.chapters.length} chapters...`,
         }));
-        const allText = input.chapters.map((c, i) =>
-          resolvedLanguage === "en"
-            ? `Chapter ${i + 1}: ${c.title}\n\n${c.content}`
-            : `第${i + 1}章 ${c.title}\n\n${c.content}`,
-        ).join("\n\n---\n\n");
+        const foundationSource = buildImportFoundationSource(input.chapters, resolvedLanguage);
 
         const architect = new ArchitectAgent(this.agentCtxFor("architect", input.bookId));
         const isSeries = input.importMode === "series";
         const foundation = isSeries
           ? await this.generateAndReviewFoundation({
-              generate: (reviewFeedback) => architect.generateFoundationFromImport(book, allText, undefined, reviewFeedback, { importMode: "series" }),
+              generate: (reviewFeedback) => architect.generateFoundationFromImport(book, foundationSource, undefined, reviewFeedback, { importMode: "series" }),
               reviewer: new FoundationReviewerAgent(this.agentCtxFor("foundation-reviewer", input.bookId)),
               mode: "series",
               language: resolvedLanguage === "en" ? "en" : "zh",
               stageLanguage: resolvedLanguage,
             })
-          : await architect.generateFoundationFromImport(book, allText);
+          : await architect.generateFoundationFromImport(book, foundationSource);
         await architect.writeFoundationFiles(
           bookDir,
           foundation,
@@ -2373,12 +2522,12 @@ ${matrix}`,
         await this.state.snapshotState(input.bookId, 0);
 
         // Generate style guide from imported chapters
-        if (allText.length >= 500) {
+        if (foundationSource.length >= 500) {
           log?.info(this.localize(resolvedLanguage, {
             zh: "提取原文风格指纹...",
             en: "Extracting source style fingerprint...",
           }));
-          await this.tryGenerateStyleGuide(input.bookId, allText, book.title, resolvedLanguage);
+          await this.tryGenerateStyleGuide(input.bookId, foundationSource, book.title, resolvedLanguage);
         }
 
         log?.info(this.localize(resolvedLanguage, {