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

fix: make foundation review retries configurable

Ma пре 6 месеци
родитељ
комит
38c8aec273

+ 1 - 0
packages/cli/src/commands/config.ts

@@ -26,6 +26,7 @@ configCommand
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
         "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
         "inputGovernanceMode",
+        "foundation.reviewRetries",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
         "daemon.retryDelayMs", "daemon.cooldownAfterChapterMs",

+ 1 - 0
packages/cli/src/utils.ts

@@ -147,6 +147,7 @@ export function buildPipelineConfig(
     model: config.llm.model,
     projectRoot: root,
     defaultLLMConfig: config.llm,
+    foundationReviewRetries: config.foundation.reviewRetries,
     modelOverrides: config.modelOverrides,
     inputGovernanceMode: extra?.inputGovernanceMode ?? config.inputGovernanceMode,
     notifyChannels: extra?.notifyChannels ?? config.notify,

+ 62 - 0
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -551,6 +551,68 @@ describe("PipelineRunner", () => {
     }
   });
 
+  it("honors configured foundation review retry count before accepting a rejected foundation", async () => {
+    const { root, runner, bookId } = await createRunnerFixture({
+      foundationReviewRetries: 4,
+    } as Partial<ConstructorParameters<typeof PipelineRunner>[0]>);
+    const reviewer = new FoundationReviewerAgent({
+      client: {
+        provider: "openai",
+        apiFormat: "chat",
+        stream: false,
+        defaults: {
+          temperature: 0.7,
+          thinkingBudget: 0,
+        },
+      } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
+      model: "test-model",
+      projectRoot: root,
+      bookId,
+    });
+    const foundation = {
+      storyBible: "# Story Bible",
+      volumeOutline: "# Volume Outline",
+      bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules",
+      currentState: "# Current State",
+      pendingHooks: "# Pending Hooks",
+    };
+    const generate = vi.fn(async (_reviewFeedback?: string) => foundation);
+    const reviewMock = vi.mocked(FoundationReviewerAgent.prototype.review);
+
+    reviewMock.mockReset();
+    reviewMock.mockResolvedValue({
+      passed: false,
+      totalScore: 72,
+      dimensions: [],
+      overallFeedback: "仍未达到可开写标准。",
+    });
+
+    try {
+      await (runner as unknown as {
+        generateAndReviewFoundation: (params: {
+          readonly generate: (reviewFeedback?: string) => Promise<typeof foundation>;
+          readonly reviewer: FoundationReviewerAgent;
+          readonly mode: "original";
+          readonly language: "zh";
+          readonly stageLanguage: "zh";
+        }) => Promise<typeof foundation>;
+      }).generateAndReviewFoundation({
+        generate,
+        reviewer,
+        mode: "original",
+        language: "zh",
+        stageLanguage: "zh",
+      });
+
+      expect(generate).toHaveBeenCalledTimes(5);
+      expect(reviewMock).toHaveBeenCalledTimes(5);
+      expect(generate.mock.calls[1]?.[0]).toContain("仍未达到可开写标准");
+      expect(generate.mock.calls[4]?.[0]).toContain("仍未达到可开写标准");
+    } finally {
+      await rm(root, { recursive: true, force: true });
+    }
+  });
+
   it("bootstraps missing control documents for legacy books before writing", async () => {
     const { root, runner, bookId } = await createRunnerFixture();
 

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

@@ -1,7 +1,7 @@
 // Models
 export { type BookConfig, type Platform, type Genre, type BookStatus, type FanficMode, BookConfigSchema, PlatformSchema, GenreSchema, BookStatusSchema, FanficModeSchema } from "./models/book.js";
 export { type ChapterMeta, type ChapterStatus, ChapterMetaSchema, ChapterStatusSchema } from "./models/chapter.js";
-export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, InputGovernanceModeSchema } from "./models/project.js";
+export { type ProjectConfig, type LLMConfig, type NotifyChannel, type DetectionConfig, type QualityGates, type FoundationConfig, type AgentLLMOverride, type InputGovernanceMode, ProjectConfigSchema, LLMConfigSchema, AgentLLMOverrideSchema, DetectionConfigSchema, QualityGatesSchema, FoundationConfigSchema, InputGovernanceModeSchema } from "./models/project.js";
 export { type CurrentState, type ParticleLedger, type PendingHooks, type PendingHook, type LedgerEntry } from "./models/state.js";
 export { type GenreProfile, type ParsedGenreProfile, GenreProfileSchema, parseGenreProfile } from "./models/genre-profile.js";
 export { type BookRules, type ParsedBookRules, BookRulesSchema, parseBookRules, tryParseBookRulesFrontmatter } from "./models/book-rules.js";

+ 9 - 0
packages/core/src/models/project.ts

@@ -76,6 +76,12 @@ export const QualityGatesSchema = z.object({
 
 export type QualityGates = z.infer<typeof QualityGatesSchema>;
 
+export const FoundationConfigSchema = z.object({
+  reviewRetries: z.number().int().min(0).max(10).default(2),
+});
+
+export type FoundationConfig = z.infer<typeof FoundationConfigSchema>;
+
 export const AgentLLMOverrideSchema = z.object({
   model: z.string().min(1),
   provider: z.enum(["anthropic", "openai", "custom"]).optional(),
@@ -98,6 +104,9 @@ export const ProjectConfigSchema = z.object({
   llm: LLMConfigSchema,
   notify: z.array(NotifyChannelSchema).default([]),
   detection: DetectionConfigSchema.optional(),
+  foundation: FoundationConfigSchema.default({
+    reviewRetries: 2,
+  }),
   modelOverrides: z.record(z.string(), ModelOverrideValueSchema).optional(),
   inputGovernanceMode: InputGovernanceModeSchema.default("v2"),
   daemon: z.object({

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

@@ -70,6 +70,7 @@ export interface PipelineConfig {
   readonly model: string;
   readonly projectRoot: string;
   readonly defaultLLMConfig?: LLMConfig;
+  readonly foundationReviewRetries?: number;
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly externalContext?: string;
@@ -269,7 +270,7 @@ export class PipelineRunner {
     readonly stageLanguage: LengthLanguage;
     readonly maxRetries?: number;
   }): Promise<ArchitectOutput> {
-    const maxRetries = params.maxRetries ?? 2;
+    const maxRetries = params.maxRetries ?? this.config.foundationReviewRetries ?? 2;
     let foundation = await params.generate();
 
     for (let attempt = 0; attempt < maxRetries; attempt++) {

+ 1 - 0
packages/studio/src/api/server.ts

@@ -776,6 +776,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       model: overrides?.model ?? currentConfig.llm.model,
       projectRoot: root,
       defaultLLMConfig: currentConfig.llm,
+      foundationReviewRetries: currentConfig.foundation.reviewRetries,
       modelOverrides: currentConfig.modelOverrides,
       notifyChannels: currentConfig.notify,
       logger,