|
|
@@ -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, {
|