Răsfoiți Sursa

refactor: thread length spec into writer

Ma 7 luni în urmă
părinte
comite
cf941bac2d

+ 27 - 3
packages/core/src/__tests__/writer-parser.test.ts

@@ -1,6 +1,7 @@
 import { describe, it, expect } from "vitest";
 import { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput } from "../agents/writer-parser.js";
 import type { GenreProfile } from "../models/genre-profile.js";
+import { countChapterLength } from "../utils/length-metrics.js";
 
 const defaultGenreProfile: GenreProfile = {
   name: "测试",
@@ -20,8 +21,9 @@ function callParseOutput(
   chapterNumber: number,
   content: string,
   genreProfile: GenreProfile = defaultGenreProfile,
+  countingMode: "zh_chars" | "en_words" = "zh_chars",
 ): ParsedWriterOutput {
-  return parseWriterOutput(chapterNumber, content, genreProfile);
+  return parseWriterOutput(chapterNumber, content, genreProfile, countingMode);
 }
 
 // ---------------------------------------------------------------------------
@@ -80,11 +82,11 @@ describe("WriterAgent parseOutput", () => {
     expect(result.updatedHooks).toContain("H001");
   });
 
-  it("calculates wordCount as the length of chapter content", () => {
+  it("calculates wordCount with the shared counting helper", () => {
     const result = callParseOutput(1, fullOutput);
     const expectedContent =
       "陈风站在悬崖边,俯视着脚下的万丈深渊。\n一股强烈的吸力从深渊中传来,仿佛有什么东西在召唤他。";
-    expect(result.wordCount).toBe(expectedContent.length);
+    expect(result.wordCount).toBe(countChapterLength(expectedContent, "zh_chars"));
   });
 
   // -------------------------------------------------------------------------
@@ -219,6 +221,17 @@ describe("WriterAgent parseOutput", () => {
     // wordCount is content.length which counts each character (including punctuation)
     expect(result.wordCount).toBe(chineseContent.length);
   });
+
+  it("counts English content with the shared counting helper when requested", () => {
+    const englishContent = "He looked at the sky.";
+    const output = [
+      "=== CHAPTER_CONTENT ===",
+      englishContent,
+    ].join("\n");
+
+    const result = callParseOutput(1, output, defaultGenreProfile, "en_words");
+    expect(result.wordCount).toBe(countChapterLength(englishContent, "en_words"));
+  });
 });
 
 // ---------------------------------------------------------------------------
@@ -279,4 +292,15 @@ ${prose}`;
     expect(result.title).toBe("正常标题");
     expect(result.content).toBe("正常的章节内容,这里是完整的正文。");
   });
+
+  it("counts creative output with the shared helper when a counting mode is supplied", () => {
+    const raw = `=== CHAPTER_TITLE ===
+English Chapter
+
+=== CHAPTER_CONTENT ===
+He looked at the sky.`;
+
+    const result = parseCreativeOutput(1, raw, "en_words");
+    expect(result.wordCount).toBe(countChapterLength("He looked at the sky.", "en_words"));
+  });
 });

+ 33 - 0
packages/core/src/__tests__/writer-prompts.test.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest";
 import type { BookConfig } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
+import { LengthSpecSchema } from "../models/length-governance.js";
 import { buildWriterSystemPrompt } from "../agents/writer-prompts.js";
 
 const BOOK: BookConfig = {
@@ -53,6 +54,38 @@ describe("buildWriterSystemPrompt", () => {
     expect(prompt).not.toContain("## 黄金三章规则");
   });
 
+  it("uses target-range wording when a length spec is provided", () => {
+    const lengthSpec = LengthSpecSchema.parse({
+      target: 2200,
+      softMin: 1900,
+      softMax: 2500,
+      hardMin: 1600,
+      hardMax: 2800,
+      countingMode: "zh_chars",
+      normalizeMode: "none",
+    });
+
+    const prompt = buildWriterSystemPrompt(
+      BOOK,
+      GENRE,
+      null,
+      "# Book Rules",
+      "# Genre Body",
+      "# Style Guide\n\nKeep the prose restrained.",
+      undefined,
+      3,
+      "creative",
+      undefined,
+      "zh",
+      "governed",
+      lengthSpec,
+    );
+
+    expect(prompt).toContain("目标字数:2200");
+    expect(prompt).toContain("允许区间:1900-2500");
+    expect(prompt).not.toContain("正文不少于2200字");
+  });
+
   it("keeps hard guardrails and book/style constraints in governed mode", () => {
     const prompt = buildWriterSystemPrompt(
       BOOK,

+ 6 - 2
packages/core/src/agents/writer-parser.ts

@@ -1,5 +1,7 @@
 import type { GenreProfile } from "../models/genre-profile.js";
+import type { LengthCountingMode } from "../models/length-governance.js";
 import type { WriteChapterOutput } from "./writer.js";
+import { countChapterLength } from "../utils/length-metrics.js";
 
 export interface CreativeOutput {
   readonly title: string;
@@ -11,6 +13,7 @@ export interface CreativeOutput {
 export function parseCreativeOutput(
   chapterNumber: number,
   content: string,
+  countingMode: LengthCountingMode = "zh_chars",
 ): CreativeOutput {
   const extract = (tag: string): string => {
     const regex = new RegExp(
@@ -36,7 +39,7 @@ export function parseCreativeOutput(
   return {
     title,
     content: chapterContent,
-    wordCount: chapterContent.length,
+    wordCount: countChapterLength(chapterContent, countingMode),
     preWriteCheck: extract("PRE_WRITE_CHECK"),
   };
 }
@@ -100,6 +103,7 @@ export function parseWriterOutput(
   chapterNumber: number,
   content: string,
   genreProfile: GenreProfile,
+  countingMode: LengthCountingMode = "zh_chars",
 ): ParsedWriterOutput {
   const extract = (tag: string): string => {
     const regex = new RegExp(
@@ -115,7 +119,7 @@ export function parseWriterOutput(
     chapterNumber,
     title: extract("CHAPTER_TITLE") || `第${chapterNumber}章`,
     content: chapterContent,
-    wordCount: chapterContent.length,
+    wordCount: countChapterLength(chapterContent, countingMode),
     preWriteCheck: extract("PRE_WRITE_CHECK"),
     postSettlement: extract("POST_SETTLEMENT"),
     updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",

+ 31 - 9
packages/core/src/agents/writer-prompts.ts

@@ -1,8 +1,10 @@
 import type { BookConfig, FanficMode } from "../models/book.js";
 import type { GenreProfile } from "../models/genre-profile.js";
 import type { BookRules } from "../models/book-rules.js";
+import type { LengthSpec } from "../models/length-governance.js";
 import { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./fanfic-prompt-sections.js";
 import { buildEnglishCoreRules, buildEnglishAntiAIRules, buildEnglishCharacterMethod, buildEnglishPreWriteChecklist, buildEnglishGenreIntro } from "./en-prompt-sections.js";
+import { buildLengthSpec } from "../utils/length-metrics.js";
 
 export interface FanficContext {
   readonly fanficCanon: string;
@@ -27,19 +29,22 @@ export function buildWriterSystemPrompt(
   fanficContext?: FanficContext,
   languageOverride?: "zh" | "en",
   inputProfile: "legacy" | "governed" = "legacy",
+  lengthSpec?: LengthSpec,
 ): string {
   const isEnglish = (languageOverride ?? genreProfile.language) === "en";
   const governed = inputProfile === "governed";
+  const resolvedLengthSpec = lengthSpec ?? buildLengthSpec(book.chapterWordCount, isEnglish ? "en" : "zh");
 
   const outputSection = mode === "creative"
-    ? buildCreativeOutputFormat(book, genreProfile)
-    : buildOutputFormat(book, genreProfile);
+    ? buildCreativeOutputFormat(book, genreProfile, resolvedLengthSpec)
+    : buildOutputFormat(book, genreProfile, resolvedLengthSpec);
 
   const sections = isEnglish
     ? [
         buildEnglishGenreIntro(book, genreProfile),
         buildEnglishCoreRules(book),
         buildGovernedInputContract("en", governed),
+        buildLengthGuidance(resolvedLengthSpec, "en"),
         !governed ? buildEnglishAntiAIRules() : "",
         !governed ? buildEnglishCharacterMethod() : "",
         buildGenreRules(genreProfile, genreBody),
@@ -55,8 +60,9 @@ export function buildWriterSystemPrompt(
       ]
     : [
         buildGenreIntro(book, genreProfile),
-        buildCoreRules(book),
+        buildCoreRules(resolvedLengthSpec),
         buildGovernedInputContract("zh", governed),
+        buildLengthGuidance(resolvedLengthSpec, "zh"),
         !governed ? buildAntiAIExamples() : "",
         !governed ? buildCharacterPsychologyMethod() : "",
         !governed ? buildSupportingCharacterMethod() : "",
@@ -108,15 +114,31 @@ function buildGovernedInputContract(language: "zh" | "en", governed: boolean): s
 - 真正不能突破的只有硬护栏:世界设定、连续性事实、显式禁令。`;
 }
 
+function buildLengthGuidance(lengthSpec: LengthSpec, language: "zh" | "en"): string {
+  if (language === "en") {
+    return `## Length Guidance
+
+- Target length: ${lengthSpec.target} words
+- Acceptable range: ${lengthSpec.softMin}-${lengthSpec.softMax} words
+- Hard range: ${lengthSpec.hardMin}-${lengthSpec.hardMax} words`;
+  }
+
+  return `## 字数治理
+
+- 目标字数:${lengthSpec.target}字
+- 允许区间:${lengthSpec.softMin}-${lengthSpec.softMax}字
+- 硬区间:${lengthSpec.hardMin}-${lengthSpec.hardMax}字`;
+}
+
 // ---------------------------------------------------------------------------
 // Core rules (~25 universal rules)
 // ---------------------------------------------------------------------------
 
-function buildCoreRules(book: BookConfig): string {
+function buildCoreRules(lengthSpec: LengthSpec): string {
   return `## 核心规则
 
 1. 以简体中文工作,句子长短交替,段落适合手机阅读(3-5行/段)
-2. 每章${book.chapterWordCount}字左右
+2. 目标字数:${lengthSpec.target}字,允许区间:${lengthSpec.softMin}-${lengthSpec.softMax}字
 3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都必须在后续收回
 4. 只读必要上下文,不机械重复已有内容
 
@@ -486,7 +508,7 @@ function buildPreWriteChecklist(book: BookConfig, gp: GenreProfile): string {
 // Creative-only output format (no settlement blocks)
 // ---------------------------------------------------------------------------
 
-function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile): string {
+function buildCreativeOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpec: LengthSpec): string {
   const resourceRow = gp.numericalSystem
     ? "| 当前资源总量 | X | 与账本一致 |\n| 本章预计增量 | +X(来源) | 无增量写+0 |"
     : "";
@@ -511,7 +533,7 @@ ${preWriteTable}
 (章节标题,不含"第X章")
 
 === CHAPTER_CONTENT ===
-(正文内容,${book.chapterWordCount}字左右)
+(正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字)
 
 【重要】本次只需输出以上三个区块(PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT)。
 状态卡、伏笔池、摘要等追踪文件将由后续结算阶段处理,请勿输出。`;
@@ -521,7 +543,7 @@ ${preWriteTable}
 // Output format
 // ---------------------------------------------------------------------------
 
-function buildOutputFormat(book: BookConfig, gp: GenreProfile): string {
+function buildOutputFormat(book: BookConfig, gp: GenreProfile, lengthSpec: LengthSpec): string {
   const resourceRow = gp.numericalSystem
     ? "| 当前资源总量 | X | 与账本一致 |\n| 本章预计增量 | +X(来源) | 无增量写+0 |"
     : "";
@@ -564,7 +586,7 @@ ${preWriteTable}
 (章节标题,不含"第X章")
 
 === CHAPTER_CONTENT ===
-(正文内容,${book.chapterWordCount}字左右)
+(正文内容,目标${lengthSpec.target}字,允许区间${lengthSpec.softMin}-${lengthSpec.softMax}字)
 
 ${postSettlement}
 

+ 32 - 16
packages/core/src/agents/writer.ts

@@ -10,6 +10,8 @@ import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { validatePostWrite, type PostWriteViolation } from "./post-write-validator.js";
 import { analyzeAITells } from "./ai-tells.js";
 import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js";
+import type { LengthSpec } from "../models/length-governance.js";
+import { buildLengthSpec } from "../utils/length-metrics.js";
 import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
 import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
 import { parseCreativeOutput } from "./writer-parser.js";
@@ -25,6 +27,7 @@ export interface WriteChapterInput {
   readonly contextPackage?: ContextPackage;
   readonly ruleStack?: RuleStack;
   readonly trace?: ChapterTrace;
+  readonly lengthSpec?: LengthSpec;
   readonly wordCountOverride?: number;
   readonly temperatureOverride?: number;
 }
@@ -100,6 +103,9 @@ export class WriterAgent extends BaseAgent {
 
     const hasParentCanon = parentCanon !== "(文件尚未创建)";
     const hasFanficCanon = fanficCanonRaw !== "(文件尚未创建)";
+    const resolvedLanguage = book.language ?? genreProfile.language;
+    const targetWords = input.lengthSpec?.target ?? input.wordCountOverride ?? book.chapterWordCount;
+    const resolvedLengthSpec = input.lengthSpec ?? buildLengthSpec(targetWords, resolvedLanguage);
 
     // Build fanfic context if fanfic_canon.md exists
     const fanficContext: FanficContext | undefined = hasFanficCanon && bookRules?.fanficMode
@@ -111,11 +117,11 @@ export class WriterAgent extends BaseAgent {
       : undefined;
 
     // ── Phase 1: Creative writing (temperature 0.7) ──
-    const resolvedLanguage = book.language ?? genreProfile.language;
     const creativeSystemPrompt = buildWriterSystemPrompt(
       book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide, styleFingerprint,
       chapterNumber, "creative", fanficContext, resolvedLanguage,
       input.chapterIntent ? "governed" : "legacy",
+      resolvedLengthSpec,
     );
 
     const creativeUserPrompt = input.chapterIntent && input.contextPackage && input.ruleStack
@@ -125,7 +131,7 @@ export class WriterAgent extends BaseAgent {
           contextPackage: input.contextPackage,
           ruleStack: input.ruleStack,
           trace: input.trace,
-          wordCount: input.wordCountOverride ?? book.chapterWordCount,
+          lengthSpec: resolvedLengthSpec,
           language: book.language ?? genreProfile.language,
         })
       : (() => {
@@ -153,7 +159,7 @@ export class WriterAgent extends BaseAgent {
             ledger: genreProfile.numericalSystem ? ledger : "",
             hooks: povFilteredHooks,
             recentChapters,
-            wordCount: input.wordCountOverride ?? book.chapterWordCount,
+            lengthSpec: resolvedLengthSpec,
             externalContext: input.externalContext,
             chapterSummaries: filteredSummaries,
             subplotBoard: filteredSubplots,
@@ -171,7 +177,6 @@ export class WriterAgent extends BaseAgent {
     this.ctx.logger?.info(`Phase 1: creative writing for chapter ${chapterNumber}`);
 
     // Scale maxTokens to chapter word count (Chinese ≈ 1.5 tokens/char)
-    const targetWords = input.wordCountOverride ?? book.chapterWordCount;
     const creativeMaxTokens = Math.max(8192, Math.ceil(targetWords * 2));
 
     const creativeResponse = await this.chat(
@@ -183,10 +188,11 @@ export class WriterAgent extends BaseAgent {
     );
     const creativeUsage = creativeResponse.usage;
 
-    const creative = parseCreativeOutput(chapterNumber, creativeResponse.content);
+    const creative = parseCreativeOutput(chapterNumber, creativeResponse.content, resolvedLengthSpec.countingMode);
 
     // ── Phase 2: State settlement (temperature 0.3) ──
-    this.ctx.logger?.info(`Phase 2: state settlement for chapter ${chapterNumber} (${creative.wordCount} chars)`);
+    const lengthUnit = resolvedLengthSpec.countingMode === "en_words" ? "words" : "chars";
+    this.ctx.logger?.info(`Phase 2: state settlement for chapter ${chapterNumber} (${creative.wordCount} ${lengthUnit})`);
 
     const settleResult = await this.settle({
       book,
@@ -372,7 +378,7 @@ export class WriterAgent extends BaseAgent {
     readonly ledger: string;
     readonly hooks: string;
     readonly recentChapters: string;
-    readonly wordCount: number;
+    readonly lengthSpec: LengthSpec;
     readonly externalContext?: string;
     readonly chapterSummaries: string;
     readonly subplotBoard: string;
@@ -420,6 +426,7 @@ export class WriterAgent extends BaseAgent {
 本书是番外作品。以下正典约束不可违反,角色不得引用超出其信息边界的信息。
 ${params.parentCanon}\n`
       : "";
+    const lengthRequirementBlock = this.buildLengthRequirementBlock(params.lengthSpec, params.language ?? "zh");
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
@@ -445,8 +452,7 @@ ${params.volumeOutline}
 - Pacing must match the outline's chapter span: if 5 chapters are planned for an arc, do not compress into 1-2.
 - PRE_WRITE_CHECK must identify which outline node this chapter covers.
 
-Requirements:
-- Chapter body must be at least ${params.wordCount} words
+${lengthRequirementBlock}
 - Output PRE_WRITE_CHECK first, then the chapter
 - Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`;
     }
@@ -474,8 +480,7 @@ ${params.volumeOutline}
 - 剧情推进速度必须与卷纲规划的章节跨度匹配:如果卷纲规划某段剧情跨5章,不得在1-2章内讲完
 - PRE_WRITE_CHECK中必须明确标注本章对应的卷纲节点
 
-要求:
-- 正文不少于${params.wordCount}字
+${lengthRequirementBlock}
 - 先输出写作自检表,再写正文
       - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;
   }
@@ -486,7 +491,7 @@ ${params.volumeOutline}
     readonly contextPackage: ContextPackage;
     readonly ruleStack: RuleStack;
     readonly trace?: ChapterTrace;
-    readonly wordCount: number;
+    readonly lengthSpec: LengthSpec;
     readonly language?: "zh" | "en";
   }): string {
     const contextSections = params.contextPackage.selectedContext
@@ -510,6 +515,7 @@ ${params.volumeOutline}
     const traceNotes = params.trace && params.trace.notes.length > 0
       ? params.trace.notes.map((note) => `- ${note}`).join("\n")
       : "- none";
+    const lengthRequirementBlock = this.buildLengthRequirementBlock(params.lengthSpec, params.language ?? "zh");
 
     if (params.language === "en") {
       return `Write chapter ${params.chapterNumber}.
@@ -531,8 +537,7 @@ ${overrideLines}
 ## Trace Notes
 ${traceNotes}
 
-Requirements:
-- Chapter body must be at least ${params.wordCount} words
+${lengthRequirementBlock}
 - Output PRE_WRITE_CHECK first, then the chapter
 - Output only PRE_WRITE_CHECK, CHAPTER_TITLE, and CHAPTER_CONTENT blocks`;
     }
@@ -556,12 +561,23 @@ ${overrideLines}
 ## 追踪说明
 ${traceNotes}
 
-要求:
-- 正文不少于${params.wordCount}字
+${lengthRequirementBlock}
 - 先输出写作自检表,再写正文
 - 只需输出 PRE_WRITE_CHECK、CHAPTER_TITLE、CHAPTER_CONTENT 三个区块`;
   }
 
+  private buildLengthRequirementBlock(lengthSpec: LengthSpec, language: "zh" | "en"): string {
+    if (language === "en") {
+      return `Requirements:
+- Target length: ${lengthSpec.target} words
+- Acceptable range: ${lengthSpec.softMin}-${lengthSpec.softMax} words`;
+    }
+
+    return `要求:
+- 目标字数:${lengthSpec.target}字
+- 允许区间:${lengthSpec.softMin}-${lengthSpec.softMax}字`;
+  }
+
   private async loadRecentChapters(
     bookDir: string,
     currentChapter: number,