فهرست منبع

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 7 ماه پیش
والد
کامیت
782c3aaec2
2فایلهای تغییر یافته به همراه20 افزوده شده و 7 حذف شده
  1. 4 1
      packages/core/src/__tests__/runtime-state-store.test.ts
  2. 16 6
      packages/core/src/state/state-bootstrap.ts

+ 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"),
     ]);
 
-    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",
   );
   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;
   }
 
   const markdown = await readFile(join(params.storyDir, "chapter_summaries.md"), "utf-8").catch(() => "");
   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({
-    rows: [...deduped.values()].sort((a, b) => a.chapter - b.chapter),
+    rows: deduplicateSummaryRows(rawRows),
   });
   await writeFile(params.statePath, JSON.stringify(summariesState, null, 2), "utf-8");
   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 {
   return state.rows.reduce((max, row) => Math.max(max, row.chapter), 0);
 }