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

feat(style): extract writing style in fanfic/continuation/import flows

Three changes:

1. FanficCanonImporter now extracts a writing_style section from source
   material — narration voice, sentence rhythm, dialogue markers, imagery
   preferences, emotional expression patterns, with source-text citations.

2. initFanficBook calls generateStyleGuide after canon import so fanfic
   books start with both style_profile.json and style_guide.md.

3. importCanon reads up to 5 parent chapters and generates a style guide
   for the target book.  importChapters does the same on first run.

These ensure continuation and fanfic books have style reference data
available to the writer from the first chapter onward.
Ma 7 месяцев назад
Родитель
Сommit
7316ed7027

+ 20 - 1
packages/core/src/agents/fanfic-canon-importer.ts

@@ -6,6 +6,7 @@ export interface FanficCanonOutput {
   readonly characterProfiles: string;
   readonly keyEvents: string;
   readonly powerSystem: string;
+  readonly writingStyle: string;
   readonly fullDocument: string;
 }
 
@@ -68,10 +69,24 @@ export class FanficCanonImporter extends BaseAgent {
 力量/能力体系(如果适用)。包括等级划分、核心规则、已知限制。
 如果原作没有明确的力量体系,输出"(原作无明确力量体系)"。
 
+=== SECTION: writing_style ===
+原作写作风格特征(供同人写作模仿):
+
+1. 叙事人称与视角(第一人称/第三人称有限/全知,是否频繁切换)
+2. 句式节奏(长短句交替模式、段落平均长度感受、对话占比)
+3. 场景描写手法(五感偏好、意象选择、环境描写密度)
+4. 对话标记习惯(说/道/笑道 等用法,对话前后是否有动作/表情补充)
+5. 情绪表达方式(直白内心独白 vs 动作外化 vs 环境映射)
+6. 比喻/修辞倾向(常用比喻类型、修辞频率)
+7. 节奏转换(紧张→舒缓的过渡方式、章节结尾习惯)
+
+每项用1-2个原文例句佐证。只提取原文实际存在的特征,不要泛泛描述。
+
 提取原则:
 - 忠实于原作素材,不捏造原作中没有的信息
 - 信息不足时标注"(素材未提及)"而非编造
 - 角色语癖是最重要的字段——同人读者最在意角色"像不像"
+- 写作风格提取必须基于实际文本特征,附原文例句
 ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分提取。" : ""}`;
 
     const response = await this.chat(
@@ -95,6 +110,7 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分
     const characterProfiles = extract("character_profiles");
     const keyEvents = extract("key_events");
     const powerSystem = extract("power_system");
+    const writingStyle = extract("writing_style");
 
     const meta = [
       "---",
@@ -119,9 +135,12 @@ ${truncated ? "\n注意:原作素材过长,已截断。请基于已有部分
       "## 力量体系",
       powerSystem || "(原作无明确力量体系)",
       "",
+      "## 原作写作风格",
+      writingStyle || "(素材不足以提取风格特征)",
+      "",
       meta,
     ].join("\n");
 
-    return { worldRules, characterProfiles, keyEvents, powerSystem, fullDocument };
+    return { worldRules, characterProfiles, keyEvents, powerSystem, writingStyle, fullDocument };
   }
 }

+ 46 - 1
packages/core/src/pipeline/runner.ts

@@ -388,7 +388,13 @@ export class PipelineRunner {
     this.logStage(stageLanguage, { zh: "初始化控制文档", en: "initializing control documents" });
     await this.state.ensureControlDocuments(book.id, this.config.externalContext);
 
-    // Step 3: Initialize chapters directory + snapshot
+    // Step 3: Generate style guide from source material
+    if (sourceText.length >= 500) {
+      this.logStage(stageLanguage, { zh: "提取原作风格指纹", en: "extracting source style fingerprint" });
+      await this.generateStyleGuide(book.id, sourceText, sourceName);
+    }
+
+    // Step 4: Initialize chapters directory + snapshot
     this.logStage(stageLanguage, { zh: "创建初始快照", en: "creating initial snapshot" });
     await mkdir(join(bookDir, "chapters"), { recursive: true });
     await this.state.saveChapterIndex(book.id, []);
@@ -1581,9 +1587,38 @@ ${matrix}`,
     const canon = response.content + metaBlock;
 
     await writeFile(join(storyDir, "parent_canon.md"), canon, "utf-8");
+
+    // Also generate style guide from parent's chapter text if available
+    const parentChaptersDir = join(parentDir, "chapters");
+    const parentChapterText = await this.readParentChapterSample(parentChaptersDir);
+    if (parentChapterText.length >= 500) {
+      await this.generateStyleGuide(targetBookId, parentChapterText, parentBook.title);
+    }
+
     return canon;
   }
 
+  private async readParentChapterSample(chaptersDir: string): Promise<string> {
+    try {
+      const entries = await readdir(chaptersDir);
+      const mdFiles = entries
+        .filter((file) => file.endsWith(".md"))
+        .sort()
+        .slice(0, 5);
+      const chunks: string[] = [];
+      let totalLength = 0;
+      for (const file of mdFiles) {
+        if (totalLength >= 20000) break;
+        const content = await readFile(join(chaptersDir, file), "utf-8");
+        chunks.push(content);
+        totalLength += content.length;
+      }
+      return chunks.join("\n\n---\n\n");
+    } catch {
+      return "";
+    }
+  }
+
   // ---------------------------------------------------------------------------
   // Chapter import (for continuation writing from existing chapters)
   // ---------------------------------------------------------------------------
@@ -1630,6 +1665,16 @@ ${matrix}`,
         await this.resetImportReplayTruthFiles(bookDir, resolvedLanguage);
         await this.state.saveChapterIndex(input.bookId, []);
         await this.state.snapshotState(input.bookId, 0);
+
+        // Generate style guide from imported chapters
+        if (allText.length >= 500) {
+          log?.info(this.localize(resolvedLanguage, {
+            zh: "提取原文风格指纹...",
+            en: "Extracting source style fingerprint...",
+          }));
+          await this.generateStyleGuide(input.bookId, allText, book.title);
+        }
+
         log?.info(this.localize(resolvedLanguage, {
           zh: "基础设定已生成。",
           en: "Foundation generated.",