Răsfoiți Sursa

feat: apply length governance to reviser

Ma 7 luni în urmă
părinte
comite
02ecef1c0a

+ 79 - 2
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -379,7 +379,7 @@ describe("PipelineRunner", () => {
     );
 
     try {
-      await runner.writeNextChapter(bookId);
+      await runner.writeNextChapter(bookId, 220);
 
       expect(planChapter).toHaveBeenCalledTimes(1);
       expect(composeChapter).toHaveBeenCalledTimes(1);
@@ -432,7 +432,7 @@ describe("PipelineRunner", () => {
     );
 
     try {
-      await runner.writeNextChapter(bookId);
+      await runner.writeNextChapter(bookId, 220);
 
       expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
         chapterIntent: expect.stringContaining("# Chapter Intent"),
@@ -451,7 +451,84 @@ describe("PipelineRunner", () => {
         ruleStack: expect.objectContaining({
           activeOverrides: expect.any(Array),
         }),
+        lengthSpec: expect.objectContaining({
+          target: 220,
+          softMin: 190,
+          softMax: 250,
+        }),
+      });
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
+  it("normalizes revised output once before re-audit when it leaves the target band", async () => {
+    const { root, runner, bookId } = await createRunnerFixture();
+    const writerDraft = "中段正文。".repeat(40);
+    const overlongRevision = "修订后正文。".repeat(60);
+    const normalizedRevision = "归一正文。".repeat(40);
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: writerDraft,
+        wordCount: writerDraft.length,
+      }),
+    );
+    const auditChapter = vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: false,
+          issues: [CRITICAL_ISSUE],
+          summary: "needs revision",
+        }),
+      )
+      .mockResolvedValueOnce(
+        createAuditResult({
+          passed: true,
+          issues: [],
+          summary: "clean",
+        }),
+      );
+    const reviseChapter = vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
+      createReviseOutput({
+        revisedContent: overlongRevision,
+        wordCount: overlongRevision.length,
+      }),
+    );
+    const normalizeChapter = vi.mocked(
+      LengthNormalizerAgent.prototype.normalizeChapter,
+    ).mockResolvedValue({
+      normalizedContent: normalizedRevision,
+      finalCount: normalizedRevision.length,
+      applied: true,
+      mode: "compress",
+      tokenUsage: ZERO_USAGE,
+    });
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: normalizedRevision,
+        wordCount: normalizedRevision.length,
+      }),
+    );
+
+    try {
+      await runner.writeNextChapter(bookId, 220);
+
+      expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
+        lengthSpec: expect.objectContaining({
+          target: 220,
+          softMin: 190,
+          softMax: 250,
+        }),
+      });
+      expect(normalizeChapter).toHaveBeenCalledTimes(1);
+      expect(normalizeChapter.mock.calls[0]?.[0]).toMatchObject({
+        chapterContent: overlongRevision,
+        lengthSpec: expect.objectContaining({
+          target: 220,
+        }),
       });
+      expect(auditChapter.mock.calls[1]?.[1]).toBe(normalizedRevision);
     } finally {
       await rm(root, { recursive: true, force: true });
     }

+ 91 - 0
packages/core/src/__tests__/reviser.test.ts

@@ -0,0 +1,91 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ReviserAgent } from "../agents/reviser.js";
+import { buildLengthSpec } from "../utils/length-metrics.js";
+import type { AuditIssue } from "../agents/continuity.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+const CRITICAL_ISSUE: AuditIssue = {
+  severity: "critical",
+  category: "continuity",
+  description: "Fix the broken continuity",
+  suggestion: "Repair the contradiction",
+};
+
+describe("ReviserAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("tells the model to preserve the target range when a length spec is provided", async () => {
+    const root = await mkdtemp(join(tmpdir(), "inkos-reviser-test-"));
+    const bookDir = join(root, "book");
+    await mkdir(join(bookDir, "story"), { recursive: true });
+
+    const agent = new ReviserAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: root,
+    });
+
+    const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
+      content: [
+        "=== FIXED_ISSUES ===",
+        "- repaired",
+        "",
+        "=== REVISED_CONTENT ===",
+        "修订后的正文。",
+        "",
+        "=== UPDATED_STATE ===",
+        "状态卡",
+        "",
+        "=== UPDATED_HOOKS ===",
+        "伏笔池",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    try {
+      await agent.reviseChapter(
+        bookDir,
+        "原始正文。",
+        1,
+        [CRITICAL_ISSUE],
+        "spot-fix",
+        "xuanhuan",
+        {
+          lengthSpec: buildLengthSpec(220, "zh"),
+        },
+      );
+
+      const messages = chatSpy.mock.calls[0]?.[0] as
+        | ReadonlyArray<{ content: string }>
+        | undefined;
+      const systemPrompt = messages?.[0]?.content ?? "";
+      const userPrompt = messages?.[1]?.content ?? "";
+
+      expect(systemPrompt).toContain("保持章节字数在目标区间内");
+      expect(userPrompt).toContain("目标字数:220");
+      expect(userPrompt).toContain("允许区间:190-250");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+});

+ 15 - 2
packages/core/src/agents/reviser.ts

@@ -1,9 +1,11 @@
 import { BaseAgent } from "./base.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 type { AuditIssue } from "./continuity.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
 import { readGenreProfile, readBookRules } from "./rules-reader.js";
+import { countChapterLength } from "../utils/length-metrics.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
@@ -58,6 +60,7 @@ export class ReviserAgent extends BaseAgent {
       chapterIntent?: string;
       contextPackage?: ContextPackage;
       ruleStack?: RuleStack;
+      lengthSpec?: LengthSpec;
     },
   ): Promise<ReviseOutput> {
     const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([
@@ -95,6 +98,9 @@ export class ReviserAgent extends BaseAgent {
     const protagonistBlock = bookRules?.protagonist
       ? `\n\n主角人设锁定:${bookRules.protagonist.name},${bookRules.protagonist.personalityLock.join("、")}。修改不得违反人设。`
       : "";
+    const lengthGuardrail = options?.lengthSpec
+      ? `\n8. 保持章节字数在目标区间内;只有在修复关键问题确实需要时才允许轻微偏离`
+      : "";
 
     const isEnglish = gp.language === "en";
     const langPrefix = isEnglish
@@ -112,6 +118,7 @@ export class ReviserAgent extends BaseAgent {
 5. 不改变剧情走向和核心冲突
 6. 保持原文的语言风格和节奏
 7. 修改后同步更新状态卡${gp.numericalSystem ? "、账本" : ""}、伏笔池
+${lengthGuardrail}
 
 输出格式:
 
@@ -156,6 +163,9 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
     const reducedControlBlock = options?.chapterIntent && options.contextPackage && options.ruleStack
       ? this.buildReducedControlBlock(options.chapterIntent, options.contextPackage, options.ruleStack)
       : "";
+    const lengthGuidanceBlock = options?.lengthSpec
+      ? `\n## 字数护栏\n目标字数:${options.lengthSpec.target}\n允许区间:${options.lengthSpec.softMin}-${options.lengthSpec.softMax}\n极限区间:${options.lengthSpec.hardMin}-${options.lengthSpec.hardMax}\n如果修正后超出允许区间,请优先压缩冗余解释、重复动作和弱信息句,不得新增支线或删掉核心事实。\n`
+      : "";
     const styleGuideBlock = reducedControlBlock.length === 0
       ? `\n## 文风指南\n${styleGuide}`
       : "";
@@ -170,7 +180,7 @@ ${currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${hooks}
-${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}
+${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
 
 ## 待修正章节
 ${chapterContent}`;
@@ -186,7 +196,10 @@ ${chapterContent}`;
     );
 
     const output = this.parseOutput(response.content, gp);
-    return { ...output, tokenUsage: response.usage };
+    const wordCount = options?.lengthSpec
+      ? countChapterLength(output.revisedContent, options.lengthSpec.countingMode)
+      : output.wordCount;
+    return { ...output, wordCount, tokenUsage: response.usage };
   }
 
   private parseOutput(content: string, gp: GenreProfile): ReviseOutput {

+ 41 - 10
packages/core/src/pipeline/runner.ts

@@ -510,15 +510,31 @@ export class PipelineRunner {
       }
 
       const { profile: gp } = await this.loadGenreProfile(book.genre);
+      const lengthSpec = buildLengthSpec(
+        book.chapterWordCount,
+        book.language ?? gp.language,
+      );
 
       const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
       const reviseOutput = await reviser.reviseChapter(
-        bookDir, content, targetChapter, auditResult.issues, mode, book.genre,
+        bookDir,
+        content,
+        targetChapter,
+        auditResult.issues,
+        mode,
+        book.genre,
+        { lengthSpec },
       );
 
       if (reviseOutput.revisedContent.length === 0) {
         throw new Error("Reviser returned empty content");
       }
+      const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
+        bookId,
+        chapterNumber: targetChapter,
+        chapterContent: reviseOutput.revisedContent,
+        lengthSpec,
+      });
 
       // Save revised chapter file
       const chaptersDir = join(bookDir, "chapters");
@@ -534,7 +550,7 @@ export class PipelineRunner {
         : `# 第${targetChapter}章 ${chapterMeta.title}`;
       await writeFile(
         join(chaptersDir, existingFile),
-        `${reviseHeading}\n\n${reviseOutput.revisedContent}`,
+        `${reviseHeading}\n\n${normalizedRevision.content}`,
         "utf-8",
       );
 
@@ -556,7 +572,7 @@ export class PipelineRunner {
           ? {
               ...ch,
               status: "ready-for-review" as ChapterMeta["status"],
-              wordCount: reviseOutput.wordCount,
+              wordCount: normalizedRevision.wordCount,
               updatedAt: new Date().toISOString(),
             }
           : ch,
@@ -567,13 +583,13 @@ export class PipelineRunner {
       await this.state.snapshotState(bookId, targetChapter);
 
       await this.emitWebhook("revision-complete", bookId, targetChapter, {
-        wordCount: reviseOutput.wordCount,
+        wordCount: normalizedRevision.wordCount,
         fixedCount: reviseOutput.fixedIssues.length,
       });
 
       return {
         chapterNumber: targetChapter,
-        wordCount: reviseOutput.wordCount,
+        wordCount: normalizedRevision.wordCount,
         fixedIssues: reviseOutput.fixedIssues,
       };
     } finally {
@@ -701,7 +717,10 @@ export class PipelineRunner {
         spotFixIssues,
         "spot-fix",
         book.genre,
-        reducedControlInput,
+        {
+          ...reducedControlInput,
+          lengthSpec,
+        },
       );
       totalUsage = PipelineRunner.addUsage(totalUsage, fixResult.tokenUsage);
       if (fixResult.revisedContent.length > 0) {
@@ -755,22 +774,34 @@ export class PipelineRunner {
           auditResult.issues,
           "spot-fix",
           book.genre,
-          reducedControlInput,
+          {
+            ...reducedControlInput,
+            lengthSpec,
+          },
         );
         totalUsage = PipelineRunner.addUsage(totalUsage, reviseOutput.tokenUsage);
 
         if (reviseOutput.revisedContent.length > 0) {
+          const normalizedRevision = await this.normalizeDraftLengthIfNeeded({
+            bookId,
+            chapterNumber,
+            chapterContent: reviseOutput.revisedContent,
+            lengthSpec,
+            chapterIntent: writeInput.chapterIntent,
+          });
+          totalUsage = PipelineRunner.addUsage(totalUsage, normalizedRevision.tokenUsage);
+
           // Guard: reject revision if AI markers increased
           const preMarkers = analyzeAITells(finalContent);
-          const postMarkers = analyzeAITells(reviseOutput.revisedContent);
+          const postMarkers = analyzeAITells(normalizedRevision.content);
           const preCount = preMarkers.issues.length;
           const postCount = postMarkers.issues.length;
 
           if (postCount > preCount) {
             // Revision made text MORE AI-like — discard it, keep original
           } else {
-            finalContent = reviseOutput.revisedContent;
-            finalWordCount = reviseOutput.wordCount;
+            finalContent = normalizedRevision.content;
+            finalWordCount = normalizedRevision.wordCount;
             revised = true;
           }