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

fix: validate chapter truth against authority context

Ma 6 месяцев назад
Родитель
Сommit
334731f110

+ 45 - 0
packages/core/src/__tests__/state-validator-agent.test.ts

@@ -83,6 +83,51 @@ describe("StateValidatorAgent", () => {
     expect(options?.maxTokens).toBeUndefined();
   });
 
+  it("passes authority truth context into the cross-file validation prompt", async () => {
+    const agent = new StateValidatorAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          maxTokens: 8192,
+          thinkingBudget: 0,
+          extra: {},
+        },
+      },
+      model: "test-model",
+      projectRoot: process.cwd(),
+    });
+
+    const chatSpy = vi.spyOn(
+      agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> },
+      "chat",
+    ).mockResolvedValue({ content: "PASS", usage: ZERO_USAGE });
+
+    await agent.validate(
+      "正文确认:第五条规则才是天黑后不准出宿舍。",
+      2,
+      "old state",
+      "new state: 第一条规则已被批注",
+      "old hooks",
+      "new hooks",
+      "zh",
+      {
+        storyFrame: "简介里写过:规则一:天黑后不准出宿舍。",
+        bookRules: "硬规则:规则编号必须以前文正文确立版本为准。",
+        chapterSummaries: "第1章:发现第五条规则的漏洞。",
+      },
+    );
+
+    const messages = chatSpy.mock.calls[0]?.[0] as Array<{ role: string; content: string }>;
+    expect(messages[0]?.content).toContain("truth files");
+    expect(messages[0]?.content).toContain("numbered");
+    expect(messages[1]?.content).toContain("## Authority / Cross-Truth Context");
+    expect(messages[1]?.content).toContain("规则一:天黑后不准出宿舍");
+    expect(messages[1]?.content).toContain("第1章:发现第五条规则的漏洞");
+  });
+
   it("throws when the validator model returns an empty response", async () => {
     const agent = new StateValidatorAgent({
       client: {

+ 46 - 0
packages/core/src/agents/state-validator.ts

@@ -10,6 +10,12 @@ export interface ValidationResult {
   readonly passed: boolean;
 }
 
+export interface StateValidationAuthorityContext {
+  readonly storyFrame?: string;
+  readonly bookRules?: string;
+  readonly chapterSummaries?: string;
+}
+
 /**
  * Validates Settler output by comparing old and new truth files via LLM.
  * Catches contradictions, missing state changes, and temporal inconsistencies.
@@ -31,6 +37,7 @@ export class StateValidatorAgent extends BaseAgent {
     oldHooks: string,
     newHooks: string,
     language: "zh" | "en" = "zh",
+    authorityContext?: StateValidationAuthorityContext,
   ): Promise<ValidationResult> {
     const stateDiff = this.computeDiff(oldState, newState, "State Card");
     const hooksDiff = this.computeDiff(oldHooks, newHooks, "Hooks Pool");
@@ -53,6 +60,7 @@ Given the chapter text and the CHANGES made to truth files (state card + hooks p
 3. Temporal impossibility — character moves locations without transition, injury heals without time passing
 4. Hook anomaly — a hook disappeared without being marked resolved, or a new hook has no basis in the chapter
 5. Retroactive edit — truth file change implies something happened in a PREVIOUS chapter, not the current one
+6. Cross-truth key-setting conflict — numbered rules, named laws, ranks, identities, locations, or relationship labels in the new truth files contradict the chapter text or the authority context
 
 Output format (simple, NOT JSON):
 - First line: exactly PASS or FAIL (nothing else on this line)
@@ -76,8 +84,12 @@ IMPORTANT: Output FAIL ONLY for hard contradictions — facts that directly conf
 - Hook management differences that don't contradict text
 These should be warnings with PASS, not FAIL.`;
 
+    const authorityBlock = this.buildAuthorityContextBlock(authorityContext);
+
     const userPrompt = `Chapter ${chapterNumber} validation:
 
+${authorityBlock}
+
 ## State Card Changes
 ${stateDiff || "(no changes)"}
 
@@ -120,6 +132,40 @@ ${chapterContent.slice(0, 6000)}`;
     return parts.join("\n");
   }
 
+  private buildAuthorityContextBlock(authorityContext?: StateValidationAuthorityContext): string {
+    if (!authorityContext) return "## Authority / Cross-Truth Context\n(no authority context provided)";
+
+    const storyFrame = this.truncateHead(authorityContext.storyFrame ?? "", 3500);
+    const bookRules = this.truncateHead(authorityContext.bookRules ?? "", 2000);
+    const chapterSummaries = this.truncateTail(authorityContext.chapterSummaries ?? "", 3500);
+
+    return [
+      "## Authority / Cross-Truth Context",
+      "Authority priority: current chapter text > runtime truth files/current summaries > story_frame/book_rules > legacy story_bible intro or marketing-style prose. If the current chapter establishes a numbered/name mapping, new truth files must follow that mapping instead of preserving an older intro-only version.",
+      "",
+      "### story_frame / legacy story_bible excerpt",
+      storyFrame || "(empty)",
+      "",
+      "### book_rules excerpt",
+      bookRules || "(empty)",
+      "",
+      "### recent chapter_summaries excerpt",
+      chapterSummaries || "(empty)",
+    ].join("\n");
+  }
+
+  private truncateHead(text: string, maxChars: number): string {
+    const trimmed = text.trim();
+    if (trimmed.length <= maxChars) return trimmed;
+    return `${trimmed.slice(0, maxChars).trimEnd()}\n\n[...truncated...]`;
+  }
+
+  private truncateTail(text: string, maxChars: number): string {
+    const trimmed = text.trim();
+    if (trimmed.length <= maxChars) return trimmed;
+    return `[...truncated...]\n\n${trimmed.slice(-maxChars).trimStart()}`;
+  }
+
   private parseResult(content: string): ValidationResult {
     const trimmed = content.trim();
     if (!trimmed) {

+ 3 - 1
packages/core/src/pipeline/chapter-truth-validation.ts

@@ -1,5 +1,5 @@
 import type { AuditIssue, AuditResult } from "../agents/continuity.js";
-import type { ValidationResult, StateValidatorAgent } from "../agents/state-validator.js";
+import type { StateValidationAuthorityContext, ValidationResult, StateValidatorAgent } from "../agents/state-validator.js";
 import type { WriteChapterOutput, WriterAgent } from "../agents/writer.js";
 import type { BookConfig } from "../models/book.js";
 import type { ContextPackage, RuleStack } from "../models/input-governance.js";
@@ -25,6 +25,7 @@ export async function validateChapterTruthPersistence(params: {
     readonly oldHooks: string;
     readonly oldLedger: string;
   };
+  readonly authorityContext?: StateValidationAuthorityContext;
   readonly reducedControlInput?: {
     chapterIntent: string;
     contextPackage: ContextPackage;
@@ -55,6 +56,7 @@ export async function validateChapterTruthPersistence(params: {
       params.previousTruth.oldHooks,
       persistenceOutput.updatedHooks,
       params.language,
+      params.authorityContext,
     );
   } catch (error) {
     params.logger?.warn(`State validation error for chapter ${params.chapterNumber}: ${String(error)}`);

+ 9 - 1
packages/core/src/pipeline/runner.ts

@@ -1735,10 +1735,13 @@ export class PipelineRunner {
     // 4.1 Validate settler output before writing
     this.logStage(stageLanguage, { zh: "校验真相文件变更", en: "validating truth file updates" });
     const storyDir = join(bookDir, "story");
-    const [oldState, oldHooks, oldLedger] = await Promise.all([
+    const [oldState, oldHooks, oldLedger, authorityStoryFrame, authorityBookRules, authorityChapterSummaries] = await Promise.all([
       readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
       readFile(join(storyDir, "particle_ledger.md"), "utf-8").catch(() => ""),
+      readStoryFrame(bookDir).catch(() => ""),
+      readFile(join(storyDir, "book_rules.md"), "utf-8").catch(() => ""),
+      readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
     ]);
     const validator = new StateValidatorAgent(this.agentCtxFor("state-validator", bookId));
     const truthValidation = await validateChapterTruthPersistence({
@@ -1756,6 +1759,11 @@ export class PipelineRunner {
         oldHooks,
         oldLedger,
       },
+      authorityContext: {
+        storyFrame: authorityStoryFrame,
+        bookRules: authorityBookRules,
+        chapterSummaries: authorityChapterSummaries,
+      },
       reducedControlInput,
       language: pipelineLang,
       logWarn: (message) => this.logWarn(pipelineLang, message),