Explorar el Código

fix: auto-repair duplicate chapter summaries in persisted JSON

Bootstrap now deduplicates summary rows even when loading from existing
JSON (not just when bootstrapping from markdown). Stale JSON with
duplicates from interrupted retries is repaired on load instead of
causing "duplicate_summary_chapter" validation errors.
Ma hace 7 meses
padre
commit
782c3aaec2

+ 4 - 1
packages/core/src/__tests__/runtime-state-store.test.ts

@@ -210,6 +210,9 @@ describe("runtime-state-store memory helpers", () => {
       }, null, 2), "utf-8"),
       }, null, 2), "utf-8"),
     ]);
     ]);
 
 
-    await expect(loadRuntimeStateSnapshot(bookDir)).rejects.toThrow(/duplicate_summary_chapter/i);
+    // Duplicates are auto-repaired (deduped, keeping last occurrence), not rejected
+    const snapshot = await loadRuntimeStateSnapshot(bookDir);
+    expect(snapshot.chapterSummaries.rows).toHaveLength(1);
+    expect(snapshot.chapterSummaries.rows[0]?.title).toBe("重复河埠对账");
   });
   });
 });
 });

+ 16 - 6
packages/core/src/state/state-bootstrap.ts

@@ -271,18 +271,20 @@ async function loadOrBootstrapSummaries(params: {
     "chapter_summaries.json",
     "chapter_summaries.json",
   );
   );
   if (existing) {
   if (existing) {
+    // Always deduplicate even when loading from JSON (stale data may have duplicates)
+    const dedupedExisting = deduplicateSummaryRows(existing.rows);
+    if (dedupedExisting.length < existing.rows.length) {
+      const repaired = ChapterSummariesStateSchema.parse({ rows: dedupedExisting });
+      await writeFile(params.statePath, JSON.stringify(repaired, null, 2), "utf-8");
+      return repaired;
+    }
     return existing;
     return existing;
   }
   }
 
 
   const markdown = await readFile(join(params.storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
   const markdown = await readFile(join(params.storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
   const rawRows = parseChapterSummariesMarkdown(markdown);
   const rawRows = parseChapterSummariesMarkdown(markdown);
-  // Deduplicate: keep last occurrence per chapter number (latest is most up-to-date)
-  const deduped = new Map<number, (typeof rawRows)[number]>();
-  for (const row of rawRows) {
-    deduped.set(row.chapter, row);
-  }
   const summariesState = ChapterSummariesStateSchema.parse({
   const summariesState = ChapterSummariesStateSchema.parse({
-    rows: [...deduped.values()].sort((a, b) => a.chapter - b.chapter),
+    rows: deduplicateSummaryRows(rawRows),
   });
   });
   await writeFile(params.statePath, JSON.stringify(summariesState, null, 2), "utf-8");
   await writeFile(params.statePath, JSON.stringify(summariesState, null, 2), "utf-8");
   params.createdFiles.push("chapter_summaries.json");
   params.createdFiles.push("chapter_summaries.json");
@@ -419,6 +421,14 @@ async function loadJsonIfValid<T>(
   }
   }
 }
 }
 
 
+function deduplicateSummaryRows<T extends { chapter: number }>(rows: ReadonlyArray<T>): T[] {
+  const byChapter = new Map<number, T>();
+  for (const row of rows) {
+    byChapter.set(row.chapter, row);
+  }
+  return [...byChapter.values()].sort((a, b) => a.chapter - b.chapter);
+}
+
 function maxSummaryChapter(state: ChapterSummariesState): number {
 function maxSummaryChapter(state: ChapterSummariesState): number {
   return state.rows.reduce((max, row) => Math.max(max, row.chapter), 0);
   return state.rows.reduce((max, row) => Math.max(max, row.chapter), 0);
 }
 }