Преглед на файлове

Merge pull request #57 from Mochocyang/cursor/fix-tracking-reextract-append-827b

fix: 重提取记忆时覆盖 tracking 时间线,不再追加重复行
darknessomi преди 1 месец
родител
ревизия
cc26b1afbb
променени са 4 файла, в които са добавени 191 реда и са изтрити 15 реда
  1. 1 0
      src/lib/novel/chapter-ingest-extract.spec.ts
  2. 2 1
      src/lib/novel/chapter-ingest.ts
  3. 136 0
      src/lib/novel/timeline.spec.ts
  4. 52 14
      src/lib/novel/timeline.ts

+ 1 - 0
src/lib/novel/chapter-ingest-extract.spec.ts

@@ -80,6 +80,7 @@ describe("chapter ingest reextract path", () => {
     expect(source).toContain("skipDerivedIncremental: true")
     expect(source).toContain("if (isReingest)")
     expect(source).toContain("await finalizeProjectMemoryRebuild(pp)")
+    expect(source).toContain("await rebuildTimelineFromSnapshots(projectPath, snapshots)")
     expect(source).toContain("if (!isReingest && shouldRebuildCommunitySummaries")
   })
 

+ 2 - 1
src/lib/novel/chapter-ingest.ts

@@ -28,7 +28,7 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { shouldRebuildCommunitySummaries, generateCommunitySummaries } from "./community-summary"
 import { buildChapterIngestOutput, type ChapterIngestOutput } from "./chapter-ingest-output"
 import { createChapterPipeline } from "./chapter-pipeline"
-import { mergeSnapshotTimeline } from "./timeline"
+import { mergeSnapshotTimeline, rebuildTimelineFromSnapshots } from "./timeline"
 import { buildStructuredMemoryDocuments, isValidMemorySnapshot } from "./memory-rebuild"
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { RetrievalStore } from "./retrieval"
@@ -1256,6 +1256,7 @@ export async function rebuildDerivedMemoryFromSnapshots(projectPath: string, lat
     applyForeshadowingChangesToStore(foreshadowingStore, snapshot)
   }
   await saveForeshadowingTracker(projectPath, foreshadowingStore)
+  await rebuildTimelineFromSnapshots(projectPath, snapshots)
 
   await writeStructuredMemoryDocuments(projectPath, snapshots)
 }

+ 136 - 0
src/lib/novel/timeline.spec.ts

@@ -0,0 +1,136 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const memStore = new Map<string, string>()
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(async (path: string) => {
+    const content = memStore.get(path)
+    if (content == null) throw new Error("missing")
+    return content
+  }),
+  writeFile: vi.fn(async (path: string, content: string) => {
+    memStore.set(path, content)
+  }),
+}))
+
+import {
+  getTimelineEvents,
+  mergeSnapshotTimeline,
+  rebuildTimelineFromSnapshots,
+  replaceChapterTimelineEntries,
+  timelineEntriesFromSnapshots,
+} from "./timeline"
+
+const projectPath = "E:/Novel"
+const timelinePath = "E:/Novel/.novel/timeline.json"
+
+beforeEach(() => {
+  memStore.clear()
+})
+
+describe("replaceChapterTimelineEntries", () => {
+  it("replaces the chapter instead of appending reworded events", () => {
+    const existing = [
+      { chapterNumber: 239, event: "先遣队抵达边境" },
+      { chapterNumber: 240, event: "当日晚,三处数据链直通节点完成首轮通联测试。" },
+      { chapterNumber: 240, event: "协议落地后第三天,A-50第一次进入叙利亚空域。" },
+    ]
+
+    const next = replaceChapterTimelineEntries(existing, 240, [
+      "数据链通联测试完成",
+      "A-50首次进入叙利亚空域",
+    ])
+
+    expect(next).toEqual([
+      { chapterNumber: 239, event: "先遣队抵达边境" },
+      { chapterNumber: 240, event: "数据链通联测试完成" },
+      { chapterNumber: 240, event: "A-50首次进入叙利亚空域" },
+    ])
+  })
+
+  it("clears the chapter when the new extract has no timeline events", () => {
+    const existing = [
+      { chapterNumber: 1, event: "开场" },
+      { chapterNumber: 2, event: "旧事件" },
+    ]
+    expect(replaceChapterTimelineEntries(existing, 2, [])).toEqual([
+      { chapterNumber: 1, event: "开场" },
+    ])
+  })
+
+  it("drops blank and duplicate events from the same extract", () => {
+    const next = replaceChapterTimelineEntries([], 3, [
+      "  进城  ",
+      "",
+      "进城",
+      "开战",
+    ])
+    expect(next).toEqual([
+      { chapterNumber: 3, event: "进城" },
+      { chapterNumber: 3, event: "开战" },
+    ])
+  })
+})
+
+describe("timelineEntriesFromSnapshots", () => {
+  it("rebuilds the full timeline from current snapshots", () => {
+    const entries = timelineEntriesFromSnapshots([
+      { chapterNumber: 1, timelineEvents: ["开场", "开场"] },
+      { chapterNumber: 2, timelineEvents: ["  进城  ", ""] },
+    ])
+    expect(entries).toEqual([
+      { chapterNumber: 1, event: "开场" },
+      { chapterNumber: 2, event: "进城" },
+    ])
+  })
+})
+
+describe("mergeSnapshotTimeline", () => {
+  it("overwrites the same chapter on reextract instead of appending", async () => {
+    memStore.set(timelinePath, JSON.stringify({
+      version: 1,
+      serial: 2,
+      updatedAt: "",
+      entries: [
+        { chapterNumber: 240, event: "当日晚,三处数据链直通节点完成首轮通联测试。" },
+        { chapterNumber: 240, event: "协议落地后第三天,A-50第一次进入叙利亚空域。" },
+      ],
+    }))
+
+    await mergeSnapshotTimeline(projectPath, 240, [
+      "数据链通联测试完成",
+      "A-50首次进入叙利亚空域",
+    ])
+
+    const events = await getTimelineEvents(projectPath)
+    expect(events).toEqual([
+      { chapterNumber: 240, event: "数据链通联测试完成" },
+      { chapterNumber: 240, event: "A-50首次进入叙利亚空域" },
+    ])
+  })
+})
+
+describe("rebuildTimelineFromSnapshots", () => {
+  it("drops stale chapter events that are no longer in snapshots", async () => {
+    memStore.set(timelinePath, JSON.stringify({
+      version: 1,
+      serial: 3,
+      updatedAt: "",
+      entries: [
+        { chapterNumber: 1, event: "旧开场" },
+        { chapterNumber: 240, event: "旧通联测试" },
+        { chapterNumber: 240, event: "新通联测试" },
+      ],
+    }))
+
+    await rebuildTimelineFromSnapshots(projectPath, [
+      { chapterNumber: 1, timelineEvents: ["开场"] },
+      { chapterNumber: 240, timelineEvents: ["数据链通联测试完成"] },
+    ])
+
+    expect(await getTimelineEvents(projectPath)).toEqual([
+      { chapterNumber: 1, event: "开场" },
+      { chapterNumber: 240, event: "数据链通联测试完成" },
+    ])
+  })
+})

+ 52 - 14
src/lib/novel/timeline.ts

@@ -35,29 +35,67 @@ async function saveTimeline(projectPath: string, data: TimelineFile): Promise<vo
   await writeFile(path, JSON.stringify(data, null, 2))
 }
 
+export function replaceChapterTimelineEntries(
+  entries: TimelineEntry[],
+  chapterNumber: number,
+  timelineEvents: string[] | undefined,
+): TimelineEntry[] {
+  const kept = entries.filter((entry) => entry.chapterNumber !== chapterNumber)
+  const seen = new Set<string>()
+  const next: TimelineEntry[] = []
+  for (const raw of timelineEvents ?? []) {
+    const event = raw.trim()
+    if (!event || seen.has(event)) continue
+    seen.add(event)
+    next.push({ chapterNumber, event })
+  }
+  return [...kept, ...next]
+}
+
+export function timelineEntriesFromSnapshots(
+  snapshots: Array<{ chapterNumber: number; timelineEvents?: string[] }>,
+): TimelineEntry[] {
+  const entries: TimelineEntry[] = []
+  for (const snapshot of snapshots) {
+    const seen = new Set<string>()
+    for (const raw of snapshot.timelineEvents ?? []) {
+      const event = raw.trim()
+      if (!event || seen.has(event)) continue
+      seen.add(event)
+      entries.push({ chapterNumber: snapshot.chapterNumber, event })
+    }
+  }
+  return entries
+}
+
+/**
+ * 用本章最新提取结果覆盖该章时间线,而不是按原文去重后追加。
+ * 重新提取时措辞会变,追加会留下重复行。
+ */
 export async function mergeSnapshotTimeline(
   projectPath: string,
   chapterNumber: number,
   timelineEvents: string[],
 ): Promise<void> {
-  if (!timelineEvents || timelineEvents.length === 0) return
-
   const tl = await loadTimeline(projectPath)
-
-  const existing = new Set(tl.entries.map((e) => `${e.chapterNumber}:${e.event}`))
-
-  for (const event of timelineEvents) {
-    const key = `${chapterNumber}:${event}`
-    if (!existing.has(key)) {
-      tl.serial++
-      tl.entries.push({ chapterNumber, event })
-      existing.add(key)
-    }
-  }
-
+  tl.entries = replaceChapterTimelineEntries(tl.entries, chapterNumber, timelineEvents)
+  tl.serial = tl.entries.length
   await saveTimeline(projectPath, tl)
 }
 
+export async function rebuildTimelineFromSnapshots(
+  projectPath: string,
+  snapshots: Array<{ chapterNumber: number; timelineEvents?: string[] }>,
+): Promise<void> {
+  const entries = timelineEntriesFromSnapshots(snapshots)
+  await saveTimeline(projectPath, {
+    version: 1,
+    entries,
+    serial: entries.length,
+    updatedAt: "",
+  })
+}
+
 export async function getTimelineEvents(
   projectPath: string,
 ): Promise<TimelineEntry[]> {