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

feat: add length normalizer agent

Ma 7 месяцев назад
Родитель
Сommit
6741baa885

+ 132 - 0
packages/core/src/__tests__/length-normalizer.test.ts

@@ -0,0 +1,132 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { BaseAgent } from "../agents/base.js";
+import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
+import { LengthSpecSchema } from "../models/length-governance.js";
+import { countChapterLength } from "../utils/length-metrics.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+const AGENT_CONTEXT = {
+  client: {
+    provider: "openai",
+    apiFormat: "chat",
+    stream: false,
+    defaults: {
+      temperature: 0.7,
+      maxTokens: 4096,
+      thinkingBudget: 0,
+      extra: {},
+    },
+  } as const,
+  model: "test-model",
+  projectRoot: "/tmp/inkos-length-normalizer-test",
+};
+
+function createAgent(): LengthNormalizerAgent {
+  return new LengthNormalizerAgent(AGENT_CONTEXT as never);
+}
+
+describe("LengthNormalizerAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("compresses a long draft while preserving required markers", async () => {
+    const agent = createAgent();
+    const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
+      content: "压缩后的正文。".repeat(8) + "[[KEEP_ME]]",
+      usage: ZERO_USAGE,
+    });
+    const lengthSpec = LengthSpecSchema.parse({
+      target: 220,
+      softMin: 190,
+      softMax: 250,
+      hardMin: 160,
+      hardMax: 280,
+      countingMode: "zh_chars",
+      normalizeMode: "compress",
+    });
+    const draft = "开头。" + "多余句子。".repeat(80) + "[[KEEP_ME]]";
+
+    const result = await agent.normalizeChapter({
+      chapterContent: draft,
+      lengthSpec,
+      chapterIntent: "Preserve [[KEEP_ME]] and remove redundancy.",
+      reducedControlBlock: "Avoid [[FORBIDDEN]] and keep the scene on target.",
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.applied).toBe(true);
+    expect(result.mode).toBe("compress");
+    expect(result.normalizedContent).toContain("[[KEEP_ME]]");
+    expect(result.normalizedContent).not.toContain("[[FORBIDDEN]]");
+    expect(result.finalCount).toBe(countChapterLength(result.normalizedContent, "zh_chars"));
+    expect(result.finalCount).toBeLessThan(countChapterLength(draft, "zh_chars"));
+  });
+
+  it("expands a short draft without inserting forbidden markers", async () => {
+    const agent = createAgent();
+    const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
+      content: "扩写后的正文,补足细节和过渡,但不引入禁词。",
+      usage: ZERO_USAGE,
+    });
+    const lengthSpec = LengthSpecSchema.parse({
+      target: 220,
+      softMin: 190,
+      softMax: 250,
+      hardMin: 160,
+      hardMax: 280,
+      countingMode: "zh_chars",
+      normalizeMode: "expand",
+    });
+    const draft = "开头太短。";
+
+    const result = await agent.normalizeChapter({
+      chapterContent: draft,
+      lengthSpec,
+      chapterIntent: "Keep the chapter focused on the mentor conflict.",
+      reducedControlBlock: "Forbidden marker: [[FORBIDDEN]].",
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.applied).toBe(true);
+    expect(result.mode).toBe("expand");
+    expect(result.normalizedContent).not.toContain("[[FORBIDDEN]]");
+    expect(result.finalCount).toBe(countChapterLength(result.normalizedContent, "zh_chars"));
+    expect(result.finalCount).toBeGreaterThan(countChapterLength(draft, "zh_chars"));
+  });
+
+  it("never retries normalization in the same pass", async () => {
+    const agent = createAgent();
+    const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
+      content: "仍然过长的正文。".repeat(60),
+      usage: ZERO_USAGE,
+    });
+    const lengthSpec = LengthSpecSchema.parse({
+      target: 220,
+      softMin: 190,
+      softMax: 250,
+      hardMin: 160,
+      hardMax: 280,
+      countingMode: "zh_chars",
+      normalizeMode: "compress",
+    });
+    const draft = "开头。" + "冗余句子。".repeat(100);
+
+    const result = await agent.normalizeChapter({
+      chapterContent: draft,
+      lengthSpec,
+      chapterIntent: "Preserve the scene marker [[KEEP_ME]].",
+      reducedControlBlock: "Do not invent new subplots.",
+    });
+
+    expect(chatSpy).toHaveBeenCalledTimes(1);
+    expect(result.applied).toBe(true);
+    expect(result.mode).toBe("compress");
+    expect(result.warning).toContain("outside");
+  });
+});

+ 132 - 0
packages/core/src/agents/length-normalizer.ts

@@ -0,0 +1,132 @@
+import { BaseAgent } from "./base.js";
+import type { LengthNormalizeMode, LengthSpec } from "../models/length-governance.js";
+import { countChapterLength, chooseNormalizeMode, isOutsideHardRange, isOutsideSoftRange } from "../utils/length-metrics.js";
+
+export interface NormalizeLengthInput {
+  readonly chapterContent: string;
+  readonly lengthSpec: LengthSpec;
+  readonly chapterIntent?: string;
+  readonly reducedControlBlock?: string;
+}
+
+export interface NormalizeLengthOutput {
+  readonly normalizedContent: string;
+  readonly finalCount: number;
+  readonly applied: boolean;
+  readonly mode: LengthNormalizeMode;
+  readonly warning?: string;
+  readonly tokenUsage?: {
+    readonly promptTokens: number;
+    readonly completionTokens: number;
+    readonly totalTokens: number;
+  };
+}
+
+export class LengthNormalizerAgent extends BaseAgent {
+  get name(): string {
+    return "length-normalizer";
+  }
+
+  async normalizeChapter(input: NormalizeLengthInput): Promise<NormalizeLengthOutput> {
+    const originalCount = countChapterLength(input.chapterContent, input.lengthSpec.countingMode);
+    const mode = input.lengthSpec.normalizeMode === "none"
+      ? chooseNormalizeMode(originalCount, input.lengthSpec)
+      : input.lengthSpec.normalizeMode;
+
+    if (mode === "none") {
+      return {
+        normalizedContent: input.chapterContent,
+        finalCount: originalCount,
+        applied: false,
+        mode,
+      };
+    }
+
+    const systemPrompt = this.buildSystemPrompt(mode);
+    const userPrompt = this.buildUserPrompt(input, originalCount, mode);
+    const response = await this.chat(
+      [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: userPrompt },
+      ],
+      {
+        temperature: 0.2,
+        maxTokens: Math.max(4096, Math.ceil(originalCount * 1.2)),
+      },
+    );
+
+    const normalizedContent = response.content.trim() || input.chapterContent;
+    const finalCount = countChapterLength(normalizedContent, input.lengthSpec.countingMode);
+    const warning = this.buildWarning(finalCount, input.lengthSpec);
+
+    return {
+      normalizedContent,
+      finalCount,
+      applied: true,
+      mode,
+      warning,
+      tokenUsage: response.usage,
+    };
+  }
+
+  private buildSystemPrompt(mode: LengthNormalizeMode): string {
+    const action = mode === "compress"
+      ? "compress"
+      : "expand";
+
+    return `你是一位章节长度修正器。你的任务是对章节正文做一次单次修正,只能执行一次,不得递归重写。
+
+修正目标:
+- ${action} 章节长度到给定目标区间
+- 保留章节原有事实、关键钩子、角色名和必须保留的标记
+- 不要引入新的支线、未来揭示或额外总结
+- 不要在正文外输出任何解释`;
+  }
+
+  private buildUserPrompt(
+    input: NormalizeLengthInput,
+    originalCount: number,
+    mode: LengthNormalizeMode,
+  ): string {
+    const intentBlock = input.chapterIntent
+      ? `\n## Chapter Intent\n${input.chapterIntent}\n`
+      : "";
+    const controlBlock = input.reducedControlBlock
+      ? `\n## Reduced Control Block\n${input.reducedControlBlock}\n`
+      : "";
+
+    return `请对下面正文做一次${mode === "compress" ? "压缩" : "扩写"}修正。
+
+## Length Spec
+- Target: ${input.lengthSpec.target}
+- Soft Range: ${input.lengthSpec.softMin}-${input.lengthSpec.softMax}
+- Hard Range: ${input.lengthSpec.hardMin}-${input.lengthSpec.hardMax}
+- Counting Mode: ${input.lengthSpec.countingMode}
+
+## Current Count
+${originalCount}
+
+## Correction Rules
+- 只修正一次,不要递归
+- 保留正文中的关键标记、人物名、地点名和已有事实
+- 不要凭空新增子情节
+- 不要插入解释性总结或分析
+- 输出修正后的完整正文,不要加标签
+
+${intentBlock}${controlBlock}
+## Chapter Content
+${input.chapterContent}`;
+  }
+
+  private buildWarning(finalCount: number, lengthSpec: LengthSpec): string | undefined {
+    if (!isOutsideSoftRange(finalCount, lengthSpec)) {
+      return undefined;
+    }
+
+    if (isOutsideHardRange(finalCount, lengthSpec)) {
+      return `Final count ${finalCount} is outside the hard range ${lengthSpec.hardMin}-${lengthSpec.hardMax} after one normalization pass.`;
+    }
+
+    return `Final count ${finalCount} is outside the soft range ${lengthSpec.softMin}-${lengthSpec.softMax} after one normalization pass.`;
+  }
+}

+ 1 - 0
packages/core/src/index.ts

@@ -42,6 +42,7 @@ export { createLLMClient, chatCompletion, chatWithTools, createStreamMonitor, Pa
 export { BaseAgent, type AgentContext } from "./agents/base.js";
 export { ArchitectAgent, type ArchitectOutput } from "./agents/architect.js";
 export { WriterAgent, type WriteChapterInput, type WriteChapterOutput, type TokenUsage } from "./agents/writer.js";
+export { LengthNormalizerAgent, type NormalizeLengthInput, type NormalizeLengthOutput } from "./agents/length-normalizer.js";
 export { ContinuityAuditor, type AuditResult, type AuditIssue } from "./agents/continuity.js";
 export { ReviserAgent, type ReviseOutput, type ReviseMode } from "./agents/reviser.js";
 export { RadarAgent, type RadarResult, type RadarRecommendation } from "./agents/radar.js";