Explorar el Código

fix: harden validator parsing and status counts

Ma hace 7 meses
padre
commit
2f86af72f2

+ 72 - 0
packages/cli/src/__tests__/cli-integration.test.ts

@@ -345,6 +345,78 @@ describe("CLI integration", () => {
       const output = run(["status", "legacy-status-hint"]);
       const output = run(["status", "legacy-status-hint"]);
       expect(output).toContain("legacy format");
       expect(output).toContain("legacy format");
     });
     });
+
+    it("reports persisted chapter file count instead of runtime progress when state runs ahead", async () => {
+      const bookId = "ahead-status";
+      const bookDir = join(projectDir, "books", bookId);
+      const chaptersDir = join(bookDir, "chapters");
+      const stateDir = join(bookDir, "story", "state");
+
+      await mkdir(chaptersDir, { recursive: true });
+      await mkdir(stateDir, { recursive: true });
+      await writeFile(
+        join(bookDir, "book.json"),
+        JSON.stringify({
+          id: bookId,
+          title: "Ahead Status Book",
+          platform: "other",
+          genre: "other",
+          status: "active",
+          targetChapters: 10,
+          chapterWordCount: 2200,
+          createdAt: "2026-03-29T00:00:00.000Z",
+          updatedAt: "2026-03-29T00:00:00.000Z",
+        }, null, 2),
+        "utf-8",
+      );
+      await writeFile(join(chaptersDir, "0001_First.md"), "# 第1章 First\n\nOnly persisted chapter.", "utf-8");
+      await writeFile(
+        join(chaptersDir, "index.json"),
+        JSON.stringify([
+          {
+            number: 1,
+            title: "First",
+            status: "ready-for-review",
+            wordCount: 42,
+            createdAt: "2026-03-29T00:00:00.000Z",
+            updatedAt: "2026-03-29T00:00:00.000Z",
+            auditIssues: [],
+            lengthWarnings: [],
+          },
+        ], null, 2),
+        "utf-8",
+      );
+      await Promise.all([
+        writeFile(
+          join(stateDir, "manifest.json"),
+          JSON.stringify({
+            schemaVersion: 2,
+            language: "zh",
+            lastAppliedChapter: 4,
+            projectionVersion: 1,
+            migrationWarnings: [],
+          }, null, 2),
+          "utf-8",
+        ),
+        writeFile(
+          join(stateDir, "current_state.json"),
+          JSON.stringify({
+            chapter: 4,
+            facts: [],
+          }, null, 2),
+          "utf-8",
+        ),
+        writeFile(join(stateDir, "hooks.json"), JSON.stringify({ hooks: [] }, null, 2), "utf-8"),
+        writeFile(join(stateDir, "chapter_summaries.json"), JSON.stringify({ rows: [] }, null, 2), "utf-8"),
+      ]);
+
+      const output = run(["status", bookId]);
+      expect(output).toContain("Chapters: 1 / 10");
+      expect(output).not.toContain("Chapters: 4 / 10");
+
+      const json = JSON.parse(run(["status", bookId, "--json"]));
+      expect(json.books[0]?.chapters).toBe(1);
+    });
   });
   });
 
 
   describe("inkos doctor", () => {
   describe("inkos doctor", () => {

+ 3 - 3
packages/cli/src/commands/status.ts

@@ -33,7 +33,7 @@ export const statusCommand = new Command("status")
         const book = await state.loadBookConfig(id);
         const book = await state.loadBookConfig(id);
         const index = await state.loadChapterIndex(id);
         const index = await state.loadChapterIndex(id);
         const migrationHint = await getLegacyMigrationHint(root, id);
         const migrationHint = await getLegacyMigrationHint(root, id);
-        const nextChapter = await state.getNextChapterNumber(id);
+        const persistedChapterCount = await state.getPersistedChapterCount(id);
         const { profile: genreProfile } = await readGenreProfile(root, book.genre);
         const { profile: genreProfile } = await readGenreProfile(root, book.genre);
         const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
         const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
 
 
@@ -53,7 +53,7 @@ export const statusCommand = new Command("status")
           status: book.status,
           status: book.status,
           genre: book.genre,
           genre: book.genre,
           platform: book.platform,
           platform: book.platform,
-          chapters: nextChapter - 1,
+          chapters: persistedChapterCount,
           targetChapters: book.targetChapters,
           targetChapters: book.targetChapters,
           totalWords,
           totalWords,
           avgWordsPerChapter: avgWords,
           avgWordsPerChapter: avgWords,
@@ -76,7 +76,7 @@ export const statusCommand = new Command("status")
           log(`  ${book.title} (${id})`);
           log(`  ${book.title} (${id})`);
           log(`    Status: ${book.status}`);
           log(`    Status: ${book.status}`);
           log(`    Platform: ${book.platform} | Genre: ${book.genre}`);
           log(`    Platform: ${book.platform} | Genre: ${book.genre}`);
-          log(`    Chapters: ${nextChapter - 1} / ${book.targetChapters}`);
+          log(`    Chapters: ${persistedChapterCount} / ${book.targetChapters}`);
           log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
           log(`    Words: ${totalWords.toLocaleString()} (avg ${avgWords}/ch)`);
           log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
           log(`    Approved: ${approved} | Pending: ${pending} | Failed: ${failed}`);
           if (migrationHint) {
           if (migrationHint) {

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

@@ -2144,6 +2144,73 @@ describe("PipelineRunner", () => {
     await rm(root, { recursive: true, force: true });
     await rm(root, { recursive: true, force: true });
   });
   });
 
 
+  it("still persists the chapter when the state validator appends markdown after a valid JSON verdict", async () => {
+    vi.restoreAllMocks();
+    vi.spyOn(LengthNormalizerAgent.prototype, "normalizeChapter").mockImplementation(
+      async ({ chapterContent, lengthSpec }) => ({
+        normalizedContent: chapterContent,
+        finalCount: countChapterLength(chapterContent, lengthSpec.countingMode),
+        applied: false,
+        mode: "none",
+        tokenUsage: ZERO_USAGE,
+      }),
+    );
+
+    const { root, runner, state, bookId } = await createRunnerFixture({
+      inputGovernanceMode: "legacy",
+    });
+    const chaptersDir = join(state.bookDir(bookId), "chapters");
+    const finalBody = "Validated chapter body that should still persist.";
+
+    vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
+      createWriterOutput({
+        content: finalBody,
+        wordCount: finalBody.length,
+      }),
+    );
+    vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
+      createAuditResult({
+        passed: true,
+        issues: [],
+        summary: "clean",
+      }),
+    );
+    vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
+      createAnalyzedOutput({
+        content: finalBody,
+        wordCount: finalBody.length,
+      }),
+    );
+    vi.spyOn(
+      StateValidatorAgent.prototype as unknown as {
+        chat: (...args: unknown[]) => Promise<{ content: string; usage: typeof ZERO_USAGE }>;
+      },
+      "chat",
+    ).mockResolvedValue({
+      content: [
+        "{\"warnings\":[],\"passed\":true}",
+        "",
+        "## Notes",
+        "Trailing markdown can include } braces and should not abort persistence.",
+      ].join("\n"),
+      usage: ZERO_USAGE,
+    });
+
+    const result = await runner.writeNextChapter(bookId);
+
+    expect(result.chapterNumber).toBe(1);
+    await expect(readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8"))
+      .resolves.toContain(finalBody);
+    await expect(state.loadChapterIndex(bookId)).resolves.toEqual([
+      expect.objectContaining({
+        number: 1,
+        title: "Test Chapter",
+      }),
+    ]);
+
+    await rm(root, { recursive: true, force: true });
+  });
+
   it("preserves the revised chapter content when final truth rebuild omits CHAPTER_CONTENT", async () => {
   it("preserves the revised chapter content when final truth rebuild omits CHAPTER_CONTENT", async () => {
     const { root, runner, state, bookId } = await createRunnerFixture({
     const { root, runner, state, bookId } = await createRunnerFixture({
       inputGovernanceMode: "legacy",
       inputGovernanceMode: "legacy",

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

@@ -12,6 +12,49 @@ describe("StateValidatorAgent", () => {
     vi.restoreAllMocks();
     vi.restoreAllMocks();
   });
   });
 
 
+  it("accepts a valid JSON object even when the model appends markdown with extra braces", async () => {
+    const agent = new StateValidatorAgent({
+      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: [
+          "{\"warnings\":[],\"passed\":true}",
+          "",
+          "## Notes",
+          "Trailing markdown can still mention braces like } without changing the verdict.",
+        ].join("\n"),
+        usage: ZERO_USAGE,
+      });
+
+    await expect(agent.validate(
+      "Chapter body.",
+      3,
+      "old state",
+      "new state",
+      "old hooks",
+      "new hooks",
+      "en",
+    )).resolves.toEqual({
+      warnings: [],
+      passed: true,
+    });
+  });
+
   it("throws when the validator model returns an empty response", async () => {
   it("throws when the validator model returns an empty response", async () => {
     const agent = new StateValidatorAgent({
     const agent = new StateValidatorAgent({
       client: {
       client: {

+ 79 - 6
packages/core/src/agents/state-validator.ts

@@ -113,16 +113,15 @@ ${chapterContent.slice(0, 6000)}`;
       throw new Error("LLM returned empty response");
       throw new Error("LLM returned empty response");
     }
     }
 
 
-    const jsonMatch = trimmed.match(/\{[\s\S]*\}/);
-    if (!jsonMatch) {
+    const parsed = extractFirstValidJsonObject<{
+      warnings?: Array<{ category?: string; description?: string }>;
+      passed?: boolean;
+    }>(trimmed);
+    if (!parsed) {
       throw new Error("State validator returned invalid JSON");
       throw new Error("State validator returned invalid JSON");
     }
     }
 
 
     try {
     try {
-      const parsed = JSON.parse(jsonMatch[0]) as {
-        warnings?: Array<{ category?: string; description?: string }>;
-        passed?: boolean;
-      };
       if (typeof parsed.passed !== "boolean") {
       if (typeof parsed.passed !== "boolean") {
         throw new Error("missing boolean 'passed' field");
         throw new Error("missing boolean 'passed' field");
       }
       }
@@ -143,3 +142,77 @@ ${chapterContent.slice(0, 6000)}`;
     }
     }
   }
   }
 }
 }
+
+function extractFirstValidJsonObject<T>(text: string): T | 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;
+}

+ 18 - 0
packages/core/src/state/manager.ts

@@ -220,6 +220,24 @@ export class StateManager {
     return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
     return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 1;
   }
   }
 
 
+  async getPersistedChapterCount(bookId: string): Promise<number> {
+    const chaptersDir = join(this.bookDir(bookId), "chapters");
+    const chapterNumbers = new Set<number>();
+
+    try {
+      const files = await readdir(chaptersDir);
+      for (const file of files) {
+        const match = file.match(/^(\d+)_.*\.md$/);
+        if (!match) continue;
+        chapterNumbers.add(parseInt(match[1]!, 10));
+      }
+    } catch {
+      return 0;
+    }
+
+    return chapterNumbers.size;
+  }
+
   async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
   async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
     const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
     const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
     try {
     try {