Преглед изворни кода

feat: normalize draft length before audit

Ma пре 7 месеци
родитељ
комит
6a930f985a

+ 9 - 0
packages/core/src/__tests__/length-metrics.test.ts

@@ -54,6 +54,15 @@ describe("length metrics", () => {
     expect(spec.hardMax).toBe(2800);
   });
 
+  it("scales the conservative bands for smaller targets", () => {
+    const spec = buildLengthSpec(220, "zh");
+
+    expect(spec.softMin).toBe(190);
+    expect(spec.softMax).toBe(250);
+    expect(spec.hardMin).toBe(160);
+    expect(spec.hardMax).toBe(280);
+  });
+
   it("detects soft and hard range drift", () => {
     const spec = buildLengthSpec(2200, "zh");
 

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

@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
 import { tmpdir } from "node:os";
 import { join } from "node:path";
@@ -8,11 +8,13 @@ import { ArchitectAgent } from "../agents/architect.js";
 import { PlannerAgent } from "../agents/planner.js";
 import { ComposerAgent } from "../agents/composer.js";
 import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
+import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
 import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
 import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import type { BookConfig } from "../models/book.js";
 import type { ChapterMeta } from "../models/chapter.js";
+import { countChapterLength } from "../utils/length-metrics.js";
 
 const ZERO_USAGE = {
   promptTokens: 0,
@@ -135,6 +137,18 @@ async function createRunnerFixture(
 }
 
 describe("PipelineRunner", () => {
+  beforeEach(() => {
+    vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
+      async ({ chapterContent, lengthSpec }) => ({
+        normalizedContent: chapterContent,
+        finalCount: countChapterLength(chapterContent, lengthSpec.countingMode),
+        applied: false,
+        mode: "none",
+        tokenUsage: ZERO_USAGE,
+      }),
+    );
+  });
+
   afterEach(() => {
     vi.restoreAllMocks();
   });
@@ -443,6 +457,110 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("normalizes overlong writer output once before audit", async () => {
+    const { root, runner, bookId } = await createRunnerFixture();
+    const overlongDraft = "冗余句子。".repeat(60);
+    const normalizedDraft = "压缩后的正文。".repeat(12);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: overlongDraft,
+        wordCount: overlongDraft.length,
+      }),
+    );
+    const normalizeChapter = vi.mocked(
+      LengthNormalizerAgent.prototype.normalizeChapter,
+    ).mockResolvedValue({
+      normalizedContent: normalizedDraft,
+      finalCount: normalizedDraft.length,
+      applied: true,
+      mode: "compress",
+      tokenUsage: ZERO_USAGE,
+    });
+    const auditChapter = vi.spyOn(
+      ContinuityAuditor.prototype,
+      "auditChapter",
+    ).mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: normalizedDraft,
+        wordCount: normalizedDraft.length,
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("normalizes short writer output once before audit", async () => {
+    const { root, runner, bookId } = await createRunnerFixture();
+    const shortDraft = "短句。".repeat(20);
+    const normalizedDraft = "补足后的正文。".repeat(15);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        chapterNumber: 1,
+        content: shortDraft,
+        wordCount: shortDraft.length,
+      }),
+    );
+    const normalizeChapter = vi.mocked(
+      LengthNormalizerAgent.prototype.normalizeChapter,
+    ).mockResolvedValue({
+      normalizedContent: normalizedDraft,
+      finalCount: normalizedDraft.length,
+      applied: true,
+      mode: "expand",
+      tokenUsage: ZERO_USAGE,
+    });
+    const auditChapter = vi.spyOn(
+      ContinuityAuditor.prototype,
+      "auditChapter",
+    ).mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: normalizedDraft,
+        wordCount: normalizedDraft.length,
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
+        chapterContent: shortDraft,
+        lengthSpec: expect.objectContaining({
+          target: 220,
+          softMin: 190,
+          softMax: 250,
+        }),
+      });
+      expect(auditChapter.mock.calls[0]?.[1]).toBe(normalizedDraft);
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("preserves the legacy fallback when input governance mode is legacy", async () => {
     const { root, runner, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",

+ 103 - 12
packages/core/src/pipeline/runner.ts

@@ -9,6 +9,7 @@ import { ArchitectAgent } from "../agents/architect.js";
 import { PlannerAgent } from "../agents/planner.js";
 import { ComposerAgent } from "../agents/composer.js";
 import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
+import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
 import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
 import { ContinuityAuditor } from "../agents/continuity.js";
 import { ReviserAgent, type ReviseMode } from "../agents/reviser.js";
@@ -24,6 +25,8 @@ import type { WebhookEvent } from "../notify/webhook.js";
 import type { AgentContext } from "../agents/base.js";
 import type { AuditResult, AuditIssue } from "../agents/continuity.js";
 import type { RadarResult } from "../agents/radar.js";
+import type { LengthSpec } from "../models/length-governance.js";
+import { buildLengthSpec, countChapterLength, isOutsideSoftRange } from "../utils/length-metrics.js";
 import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -295,6 +298,10 @@ export class PipelineRunner {
       );
 
       const { profile: gp } = await this.loadGenreProfile(book.genre);
+      const lengthSpec = buildLengthSpec(
+        wordCount ?? book.chapterWordCount,
+        book.language ?? gp.language,
+      );
 
       const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
       const output = await writer.writeChapter({
@@ -302,38 +309,58 @@ export class PipelineRunner {
         bookDir,
         chapterNumber,
         ...writeInput,
+        lengthSpec,
         ...(wordCount ? { wordCountOverride: wordCount } : {}),
       });
+      let totalUsage: TokenUsageSummary = output.tokenUsage ?? {
+        promptTokens: 0,
+        completionTokens: 0,
+        totalTokens: 0,
+      };
+      const normalizedDraft = await this.normalizeDraftLengthIfNeeded({
+        bookId,
+        chapterNumber,
+        chapterContent: output.content,
+        lengthSpec,
+        chapterIntent: writeInput.chapterIntent,
+      });
+      totalUsage = PipelineRunner.addUsage(totalUsage, normalizedDraft.tokenUsage);
+      const draftOutput: WriteChapterOutput = {
+        ...output,
+        content: normalizedDraft.content,
+        wordCount: normalizedDraft.wordCount,
+        tokenUsage: totalUsage,
+      };
 
       // Save chapter file
       const chaptersDir = join(bookDir, "chapters");
       const paddedNum = String(chapterNumber).padStart(4, "0");
-      const sanitized = output.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
+      const sanitized = draftOutput.title.replace(/[/\\?%*:|"<>]/g, "").replace(/\s+/g, "_").slice(0, 50);
       const filename = `${paddedNum}_${sanitized}.md`;
       const filePath = join(chaptersDir, filename);
 
       const resolvedLang = book.language ?? gp.language;
       const heading = resolvedLang === "en"
-        ? `# Chapter ${chapterNumber}: ${output.title}`
-        : `# 第${chapterNumber}章 ${output.title}`;
-      await writeFile(filePath, `${heading}\n\n${output.content}`, "utf-8");
+        ? `# Chapter ${chapterNumber}: ${draftOutput.title}`
+        : `# 第${chapterNumber}章 ${draftOutput.title}`;
+      await writeFile(filePath, `${heading}\n\n${draftOutput.content}`, "utf-8");
 
       // Save truth files
-      await writer.saveChapter(bookDir, output, gp.numericalSystem, resolvedLang);
-      await writer.saveNewTruthFiles(bookDir, output);
+      await writer.saveChapter(bookDir, draftOutput, gp.numericalSystem, resolvedLang);
+      await writer.saveNewTruthFiles(bookDir, draftOutput);
 
       // Update index
       const existingIndex = await this.state.loadChapterIndex(bookId);
       const now = new Date().toISOString();
       const newEntry: ChapterMeta = {
         number: chapterNumber,
-        title: output.title,
+        title: draftOutput.title,
         status: "drafted",
-        wordCount: output.wordCount,
+        wordCount: draftOutput.wordCount,
         createdAt: now,
         updatedAt: now,
         auditIssues: [],
-        ...(output.tokenUsage ? { tokenUsage: output.tokenUsage } : {}),
+        ...(draftOutput.tokenUsage ? { tokenUsage: draftOutput.tokenUsage } : {}),
       };
       await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
 
@@ -341,11 +368,17 @@ export class PipelineRunner {
       await this.state.snapshotState(bookId, chapterNumber);
 
       await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
-        title: output.title,
-        wordCount: output.wordCount,
+        title: draftOutput.title,
+        wordCount: draftOutput.wordCount,
       });
 
-      return { chapterNumber, title: output.title, wordCount: output.wordCount, filePath, tokenUsage: output.tokenUsage };
+      return {
+        chapterNumber,
+        title: draftOutput.title,
+        wordCount: draftOutput.wordCount,
+        filePath,
+        tokenUsage: draftOutput.tokenUsage,
+      };
     } finally {
       await releaseLock();
     }
@@ -625,6 +658,10 @@ export class PipelineRunner {
         }
       : undefined;
     const { profile: gp } = await this.loadGenreProfile(book.genre);
+    const lengthSpec = buildLengthSpec(
+      wordCount ?? book.chapterWordCount,
+      book.language ?? gp.language,
+    );
 
     // 1. Write chapter
     const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
@@ -633,6 +670,7 @@ export class PipelineRunner {
       bookDir,
       chapterNumber,
       ...writeInput,
+      lengthSpec,
       ...(wordCount ? { wordCountOverride: wordCount } : {}),
       ...(temperatureOverride ? { temperatureOverride } : {}),
     });
@@ -673,6 +711,17 @@ export class PipelineRunner {
       }
     }
 
+    const normalizedBeforeAudit = await this.normalizeDraftLengthIfNeeded({
+      bookId,
+      chapterNumber,
+      chapterContent: finalContent,
+      lengthSpec,
+      chapterIntent: writeInput.chapterIntent,
+    });
+    totalUsage = PipelineRunner.addUsage(totalUsage, normalizedBeforeAudit.tokenUsage);
+    finalContent = normalizedBeforeAudit.content;
+    finalWordCount = normalizedBeforeAudit.wordCount;
+
     // 2b. LLM audit
     const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
     const llmAudit = await auditor.auditChapter(
@@ -1253,6 +1302,48 @@ ${matrix}`,
     };
   }
 
+  private async normalizeDraftLengthIfNeeded(params: {
+    bookId: string;
+    chapterNumber: number;
+    chapterContent: string;
+    lengthSpec: LengthSpec;
+    chapterIntent?: string;
+  }): Promise<{
+    content: string;
+    wordCount: number;
+    tokenUsage?: TokenUsageSummary;
+  }> {
+    const writerCount = countChapterLength(
+      params.chapterContent,
+      params.lengthSpec.countingMode,
+    );
+    if (!isOutsideSoftRange(writerCount, params.lengthSpec)) {
+      return {
+        content: params.chapterContent,
+        wordCount: writerCount,
+      };
+    }
+
+    const normalizer = new LengthNormalizerAgent(
+      this.agentCtxFor("length-normalizer", params.bookId),
+    );
+    const normalized = await normalizer.normalizeChapter({
+      chapterContent: params.chapterContent,
+      lengthSpec: params.lengthSpec,
+      chapterIntent: params.chapterIntent,
+    });
+
+    this.config.logger?.info(
+      `Length normalization before audit for chapter ${params.chapterNumber}: ${writerCount} -> ${normalized.finalCount}`,
+    );
+
+    return {
+      content: normalized.normalizedContent,
+      wordCount: normalized.finalCount,
+      tokenUsage: normalized.tokenUsage,
+    };
+  }
+
   private async createGovernedArtifacts(
     book: BookConfig,
     bookDir: string,

+ 11 - 4
packages/core/src/utils/length-metrics.ts

@@ -2,6 +2,7 @@ import type { LengthCountingMode, LengthNormalizeMode, LengthSpec } from "../mod
 
 export type LengthLanguage = "zh" | "en";
 
+const REFERENCE_TARGET = 2200;
 const SOFT_RANGE_DELTA = 300;
 const HARD_RANGE_DELTA = 600;
 
@@ -23,10 +24,12 @@ export function buildLengthSpec(
   target: number,
   language: LengthLanguage = "zh",
 ): LengthSpec {
-  const softMin = Math.max(1, target - SOFT_RANGE_DELTA);
-  const softMax = target + SOFT_RANGE_DELTA;
-  const hardMin = Math.max(1, target - HARD_RANGE_DELTA);
-  const hardMax = target + HARD_RANGE_DELTA;
+  const softDelta = scaleRangeDelta(target, SOFT_RANGE_DELTA);
+  const hardDelta = Math.max(softDelta, scaleRangeDelta(target, HARD_RANGE_DELTA));
+  const softMin = Math.max(1, target - softDelta);
+  const softMax = target + softDelta;
+  const hardMin = Math.max(1, target - hardDelta);
+  const hardMax = target + hardDelta;
 
   return {
     target,
@@ -39,6 +42,10 @@ export function buildLengthSpec(
   };
 }
 
+function scaleRangeDelta(target: number, referenceDelta: number): number {
+  return Math.max(1, Math.floor((target * referenceDelta) / REFERENCE_TARGET));
+}
+
 export function isOutsideSoftRange(
   count: number,
   spec: Pick<LengthSpec, "softMin" | "softMax">,