Ver Fonte

feat: add interactive choice generation agents

Ma há 7 meses atrás
pai
commit
d6d6464478

+ 130 - 0
packages/core/src/__tests__/choice-auditor.test.ts

@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import { ChoiceAuditor } from "../agents/choice-auditor.js";
+
+describe("ChoiceAuditor", () => {
+  it("fails near-duplicate branch choices that do not create a real fork", async () => {
+    const auditor = new ChoiceAuditor({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const result = await auditor.auditChoices({
+      language: "zh",
+      chapterNumber: 12,
+      chapterEnding: "章末,主角必须决定是否和档案馆的看守做交易。",
+      choices: [
+        {
+          label: "接受看守的交易",
+          intent: "接受交易,先进入档案馆再说。",
+          immediateGoal: "进入档案馆。",
+          expectedCost: "欠下看守一个人情。",
+          expectedRisk: "以后会被勒索。",
+          hookPressure: "看守背后的势力伏笔推进。",
+          characterPressure: "同伴开始不信任主角。",
+          tone: "紧张",
+        },
+        {
+          label: "先答应看守的交易",
+          intent: "先答应交易,先进档案馆再说。",
+          immediateGoal: "进入档案馆。",
+          expectedCost: "暂时背上人情债。",
+          expectedRisk: "之后会被看守反制。",
+          hookPressure: "看守势力伏笔继续推进。",
+          characterPressure: "同伴对主角更不放心。",
+          tone: "紧张",
+        },
+        {
+          label: "拒绝交易改走暗门",
+          intent: "不依赖看守,自己冒险寻找暗门。",
+          immediateGoal: "绕过看守进入档案馆。",
+          expectedCost: "失去最稳的入场方式。",
+          expectedRisk: "一旦失败直接暴露。",
+          hookPressure: "主线真相推进更慢但更自主。",
+          characterPressure: "主角和同伴必须承担更大风险。",
+          tone: "压迫",
+        },
+      ],
+    });
+
+    expect(result.passed).toBe(false);
+    expect(result.issues).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          category: "duplicate_choice",
+        }),
+      ]),
+    );
+  });
+
+  it("passes a grounded choice set with distinct goals and risks", async () => {
+    const auditor = new ChoiceAuditor({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const result = await auditor.auditChoices({
+      language: "zh",
+      chapterNumber: 12,
+      chapterEnding: "章末,主角必须决定是否和档案馆的看守做交易。",
+      choices: [
+        {
+          label: "接受交易",
+          intent: "先借外力打开局面。",
+          immediateGoal: "今晚进入档案馆。",
+          expectedCost: "立刻欠下人情。",
+          expectedRisk: "监视和债务会同步到来。",
+          hookPressure: "匿名看守线被强推进。",
+          characterPressure: "队友信任下滑。",
+          tone: "紧张",
+        },
+        {
+          label: "拒绝交易潜入",
+          intent: "保留自主性,承担更高行动风险。",
+          immediateGoal: "绕开看守潜入。",
+          expectedCost: "失去最快入口。",
+          expectedRisk: "暴露概率显著升高。",
+          hookPressure: "主线推进变慢但不受制于人。",
+          characterPressure: "主角要独自承担决策后果。",
+          tone: "压迫",
+        },
+        {
+          label: "先修复队伍关系",
+          intent: "把当前矛盾拉回团队层面再决定行动。",
+          immediateGoal: "先稳住同伴。",
+          expectedCost: "错过今晚窗口。",
+          expectedRisk: "线索可能被别人先拿走。",
+          hookPressure: "关系线优先,案情线延后。",
+          characterPressure: "队友必须正面表达立场。",
+          tone: "克制",
+        },
+      ],
+    });
+
+    expect(result.passed).toBe(true);
+    expect(result.issues).toHaveLength(0);
+  });
+});

+ 92 - 0
packages/core/src/__tests__/choice-generator.test.ts

@@ -0,0 +1,92 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ChoiceGeneratorAgent } from "../agents/choice-generator.js";
+
+const ZERO_USAGE = {
+  promptTokens: 0,
+  completionTokens: 0,
+  totalTokens: 0,
+} as const;
+
+describe("ChoiceGeneratorAgent", () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("parses 2-4 structured reader choices with the required fields", async () => {
+    const agent = new ChoiceGeneratorAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 4096,
+          thinkingBudget: 0,
+          maxTokensCap: null,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
+      .mockResolvedValue({
+        content: JSON.stringify({
+          choices: [
+            {
+              label: "接受临时合作",
+              intent: "先借对方的力量进入档案库,但保留反制余地。",
+              immediateGoal: "今晚就进入档案库。",
+              expectedCost: "欠下一个必须兑现的人情。",
+              expectedRisk: "合作方会趁机监视主角。",
+              hookPressure: "匿名委托人伏笔被强推进。",
+              characterPressure: "同伴对主角的信任出现裂缝。",
+              tone: "紧张",
+            },
+            {
+              label: "绕开对方单独潜入",
+              intent: "拒绝合作,自己寻找更危险但自主的入口。",
+              immediateGoal: "在不暴露关系网的情况下潜入。",
+              expectedCost: "失去现成资源和时间窗口。",
+              expectedRisk: "一旦失败就会直接暴露身份。",
+              hookPressure: "旧案真相伏笔延后但压力升高。",
+              characterPressure: "主角的控制欲进一步加重。",
+              tone: "压迫",
+            },
+            {
+              label: "先稳住同伴再决定",
+              intent: "把行动推迟到先修复队内裂痕之后。",
+              immediateGoal: "让队伍暂时重新站到一起。",
+              expectedCost: "错过本章的最佳时机。",
+              expectedRisk: "档案可能被别人先一步转移。",
+              hookPressure: "人际关系线优先,主线推进变慢。",
+              characterPressure: "同伴必须正面表达不满。",
+              tone: "克制",
+            },
+          ],
+        }),
+        usage: ZERO_USAGE,
+      });
+
+    const result = await agent.generateChoices({
+      chapterNumber: 12,
+      chapterContent: "章末,主角拿到了进入档案库的唯一机会,但同伴并不同意他的决定。",
+      language: "zh",
+      chapterIntent: "让章末形成明确分叉。",
+      currentFocus: "档案库潜入与队伍信任裂痕",
+    });
+
+    expect(result.choices).toHaveLength(3);
+    expect(result.choices[0]).toMatchObject({
+      label: "接受临时合作",
+      intent: expect.any(String),
+      immediateGoal: expect.any(String),
+      expectedCost: expect.any(String),
+      expectedRisk: expect.any(String),
+      hookPressure: expect.any(String),
+      characterPressure: expect.any(String),
+      tone: expect.any(String),
+    });
+  });
+});

+ 141 - 0
packages/core/src/agents/choice-auditor.ts

@@ -0,0 +1,141 @@
+import { BaseAgent } from "./base.js";
+import type { GeneratedChoice } from "./choice-generator.js";
+
+export interface ChoiceAuditIssue {
+  readonly category: "invalid_choice_count" | "duplicate_choice";
+  readonly description: string;
+}
+
+export interface AuditChoicesInput {
+  readonly chapterNumber: number;
+  readonly chapterEnding: string;
+  readonly language?: "zh" | "en";
+  readonly choices: ReadonlyArray<GeneratedChoice>;
+}
+
+export interface ChoiceAuditResult {
+  readonly passed: boolean;
+  readonly issues: ReadonlyArray<ChoiceAuditIssue>;
+  readonly choices: ReadonlyArray<GeneratedChoice>;
+}
+
+export class ChoiceAuditor extends BaseAgent {
+  get name(): string {
+    return "choice-auditor";
+  }
+
+  async auditChoices(input: AuditChoicesInput): Promise<ChoiceAuditResult> {
+    const issues: ChoiceAuditIssue[] = [];
+
+    if (input.choices.length < 2 || input.choices.length > 4) {
+      issues.push({
+        category: "invalid_choice_count",
+        description: input.language === "en"
+          ? `Interactive chapters require 2-4 choices, but received ${input.choices.length}.`
+          : `互动章节必须提供 2-4 个选项,当前收到 ${input.choices.length} 个。`,
+      });
+    }
+
+    for (let leftIndex = 0; leftIndex < input.choices.length; leftIndex += 1) {
+      for (let rightIndex = leftIndex + 1; rightIndex < input.choices.length; rightIndex += 1) {
+        const left = input.choices[leftIndex]!;
+        const right = input.choices[rightIndex]!;
+        if (!isNearDuplicateChoice(left, right)) {
+          continue;
+        }
+
+        issues.push({
+          category: "duplicate_choice",
+          description: input.language === "en"
+            ? `Choice ${leftIndex + 1} and choice ${rightIndex + 1} are too similar to form a real branch.`
+            : `第 ${leftIndex + 1} 个和第 ${rightIndex + 1} 个选项过于相似,不能构成真实分叉。`,
+        });
+      }
+    }
+
+    return {
+      passed: issues.length === 0,
+      issues,
+      choices: input.choices,
+    };
+  }
+}
+
+function isNearDuplicateChoice(left: GeneratedChoice, right: GeneratedChoice): boolean {
+  const leftGoal = normalizeText(left.immediateGoal);
+  const rightGoal = normalizeText(right.immediateGoal);
+  const sameGoal = Boolean(leftGoal && leftGoal === rightGoal);
+
+  const labelSimilarity = diceSimilarity(normalizeText(left.label), normalizeText(right.label));
+  const intentSimilarity = diceSimilarity(normalizeText(left.intent), normalizeText(right.intent));
+  const riskSimilarity = diceSimilarity(normalizeText(left.expectedRisk), normalizeText(right.expectedRisk));
+  const hookSimilarity = diceSimilarity(normalizeText(left.hookPressure), normalizeText(right.hookPressure));
+  const characterSimilarity = diceSimilarity(
+    normalizeText(left.characterPressure),
+    normalizeText(right.characterPressure),
+  );
+  const sameTone = normalizeText(left.tone) === normalizeText(right.tone);
+
+  return sameGoal && sameTone && (
+    intentSimilarity >= 0.55
+    || riskSimilarity >= 0.55
+    || hookSimilarity >= 0.55
+    || characterSimilarity >= 0.55
+    || labelSimilarity >= 0.55
+  );
+}
+
+function normalizeText(value: string): string {
+  return value
+    .trim()
+    .toLowerCase()
+    .replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function diceSimilarity(left: string, right: string): number {
+  if (!left || !right) {
+    return 0;
+  }
+  if (left === right) {
+    return 1;
+  }
+
+  const leftBigrams = buildBigrams(left);
+  const rightBigrams = buildBigrams(right);
+  const leftCount = countMapEntries(leftBigrams);
+  const rightCount = countMapEntries(rightBigrams);
+  if (leftCount === 0 || rightCount === 0) {
+    return 0;
+  }
+
+  let overlap = 0;
+  for (const [bigram, count] of leftBigrams) {
+    overlap += Math.min(count, rightBigrams.get(bigram) ?? 0);
+  }
+  return (2 * overlap) / (leftCount + rightCount);
+}
+
+function buildBigrams(value: string): Map<string, number> {
+  const normalized = value.replace(/\s+/g, "");
+  const result = new Map<string, number>();
+  if (normalized.length < 2) {
+    return result;
+  }
+
+  for (let index = 0; index <= normalized.length - 2; index += 1) {
+    const bigram = normalized.slice(index, index + 2);
+    result.set(bigram, (result.get(bigram) ?? 0) + 1);
+  }
+
+  return result;
+}
+
+function countMapEntries(value: ReadonlyMap<string, number>): number {
+  let total = 0;
+  for (const count of value.values()) {
+    total += count;
+  }
+  return total;
+}

+ 178 - 0
packages/core/src/agents/choice-generator.ts

@@ -0,0 +1,178 @@
+import { z } from "zod";
+import { BaseAgent } from "./base.js";
+import { InteractiveChoiceSchema } from "../models/interactive-fiction.js";
+
+export const GeneratedChoiceSchema = InteractiveChoiceSchema.omit({
+  choiceId: true,
+  fromNodeId: true,
+  toNodeId: true,
+  selected: true,
+});
+export type GeneratedChoice = z.infer<typeof GeneratedChoiceSchema>;
+
+const GeneratedChoicesEnvelopeSchema = z.object({
+  choices: z.array(GeneratedChoiceSchema).min(2).max(4),
+});
+
+export interface GenerateChoicesInput {
+  readonly chapterNumber: number;
+  readonly chapterContent: string;
+  readonly language?: "zh" | "en";
+  readonly chapterIntent?: string;
+  readonly currentFocus?: string;
+  readonly hookPressure?: string;
+  readonly characterPressure?: string;
+}
+
+export interface GenerateChoicesOutput {
+  readonly choices: ReadonlyArray<GeneratedChoice>;
+  readonly tokenUsage?: {
+    readonly promptTokens: number;
+    readonly completionTokens: number;
+    readonly totalTokens: number;
+  };
+}
+
+export class ChoiceGeneratorAgent extends BaseAgent {
+  get name(): string {
+    return "choice-generator";
+  }
+
+  async generateChoices(input: GenerateChoicesInput): Promise<GenerateChoicesOutput> {
+    const language = input.language ?? "zh";
+    const systemPrompt = language === "en"
+      ? [
+          "You generate 2-4 grounded reader-facing branch choices for an interactive novel.",
+          "Return JSON only in the shape:",
+          "{\"choices\":[{\"label\":\"...\",\"intent\":\"...\",\"immediateGoal\":\"...\",\"expectedCost\":\"...\",\"expectedRisk\":\"...\",\"hookPressure\":\"...\",\"characterPressure\":\"...\",\"tone\":\"...\"}]}",
+          "Rules:",
+          "- choices must be meaningfully different",
+          "- no joke or obviously bad options",
+          "- each choice must be grounded in the chapter ending",
+          "- keep labels concise",
+        ].join("\n")
+      : [
+          "你负责为互动小说生成 2-4 个 grounded 的读者分支选项。",
+          "只返回 JSON,格式如下:",
+          "{\"choices\":[{\"label\":\"...\",\"intent\":\"...\",\"immediateGoal\":\"...\",\"expectedCost\":\"...\",\"expectedRisk\":\"...\",\"hookPressure\":\"...\",\"characterPressure\":\"...\",\"tone\":\"...\"}]}",
+          "规则:",
+          "- 选项必须真实分叉,不要三句同义改写",
+          "- 不要玩笑选项或明显废选项",
+          "- 所有选项都必须建立在章末局势上",
+          "- label 要短,其他字段要具体",
+        ].join("\n");
+
+    const userPrompt = [
+      language === "en" ? `Chapter ${input.chapterNumber}` : `第${input.chapterNumber}章`,
+      "",
+      language === "en" ? "## Chapter Ending" : "## 章末内容",
+      input.chapterContent.slice(0, 5000),
+      "",
+      input.chapterIntent
+        ? `${language === "en" ? "## Chapter Intent" : "## 当前章节意图"}\n${input.chapterIntent}`
+        : "",
+      input.currentFocus
+        ? `${language === "en" ? "## Current Focus" : "## 当前焦点"}\n${input.currentFocus}`
+        : "",
+      input.hookPressure
+        ? `${language === "en" ? "## Hook Pressure" : "## 伏笔压力"}\n${input.hookPressure}`
+        : "",
+      input.characterPressure
+        ? `${language === "en" ? "## Character Pressure" : "## 人物压力"}\n${input.characterPressure}`
+        : "",
+    ].filter(Boolean).join("\n");
+
+    const response = await this.chat(
+      [
+        { role: "system", content: systemPrompt },
+        { role: "user", content: userPrompt },
+      ],
+      { temperature: 0.6, maxTokens: 2048 },
+    );
+
+    const parsed = extractFirstValidJsonObject<unknown>(response.content.trim());
+    const envelope = GeneratedChoicesEnvelopeSchema.parse(parsed);
+    return {
+      choices: envelope.choices,
+      tokenUsage: response.usage,
+    };
+  }
+}
+
+function extractFirstValidJsonObject<T>(text: string): T | null {
+  if (!text) {
+    return null;
+  }
+
+  const direct = tryParseJson<T>(text);
+  if (direct) {
+    return direct;
+  }
+
+  for (let index = 0; index < text.length; index += 1) {
+    if (text[index] !== "{") continue;
+    const candidate = extractBalancedJsonObject(text, index);
+    if (!candidate) continue;
+    const parsed = tryParseJson<T>(candidate);
+    if (parsed) {
+      return parsed;
+    }
+  }
+
+  return null;
+}
+
+function tryParseJson<T>(text: string): T | null {
+  try {
+    return JSON.parse(text) as T;
+  } catch {
+    return null;
+  }
+}
+
+function extractBalancedJsonObject(text: string, start: number): string | null {
+  let depth = 0;
+  let inString = false;
+  let escaped = false;
+
+  for (let index = start; index < text.length; index += 1) {
+    const char = text[index]!;
+
+    if (inString) {
+      if (escaped) {
+        escaped = false;
+        continue;
+      }
+      if (char === "\\") {
+        escaped = true;
+        continue;
+      }
+      if (char === "\"") {
+        inString = false;
+      }
+      continue;
+    }
+
+    if (char === "\"") {
+      inString = true;
+      continue;
+    }
+
+    if (char === "{") {
+      depth += 1;
+      continue;
+    }
+
+    if (char === "}") {
+      depth -= 1;
+      if (depth === 0) {
+        return text.slice(start, index + 1);
+      }
+      if (depth < 0) {
+        return null;
+      }
+    }
+  }
+
+  return null;
+}

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

@@ -96,6 +96,19 @@ export { analyzeStyle } from "./agents/style-analyzer.js";
 export { analyzeDetectionInsights } from "./agents/detection-insights.js";
 export { validatePostWrite, detectParagraphLengthDrift, detectParagraphShapeWarnings, detectDuplicateTitle, type PostWriteViolation } from "./agents/post-write-validator.js";
 export { ChapterAnalyzerAgent, type AnalyzeChapterInput, type AnalyzeChapterOutput } from "./agents/chapter-analyzer.js";
+export {
+  ChoiceGeneratorAgent,
+  GeneratedChoiceSchema,
+  type GeneratedChoice,
+  type GenerateChoicesInput,
+  type GenerateChoicesOutput,
+} from "./agents/choice-generator.js";
+export {
+  ChoiceAuditor,
+  type ChoiceAuditIssue,
+  type AuditChoicesInput,
+  type ChoiceAuditResult,
+} from "./agents/choice-auditor.js";
 export { parseWriterOutput, parseCreativeOutput, type ParsedWriterOutput, type CreativeOutput } from "./agents/writer-parser.js";
 export { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "./agents/settler-prompts.js";
 export { parseSettlementOutput, type SettlementOutput } from "./agents/settler-parser.js";