Przeglądaj źródła

Merge pull request #55 from Mochocyang/cursor/optimize-chapter-memory-extract-fd70

压缩章节记忆提取:少 token、少重复写入
darknessomi 3 tygodni temu
rodzic
commit
651d5d2b40

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

@@ -0,0 +1,91 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
+import {
+  buildChapterExtractSystemPrompt,
+  buildChapterExtractUserPrompt,
+  buildOutlineExtractUserPrompt,
+  CHAPTER_EXTRACT_MAX_OUTPUT_TOKENS,
+  CHAPTER_EXTRACT_REQUEST_OVERRIDES,
+  GRAPH_EDGE_RELATION_LABELS,
+  resolveChapterExtractMaxTokens,
+  sliceChapterExtractBody,
+} from "./chapter-ingest-extract"
+import { NOVEL_RELATION_LABELS } from "./graph-adapter"
+import { ANALYSIS_OUTPUT_FRAC, MIN_LLM_OUTPUT_TOKENS } from "@/lib/context-budget"
+
+const LEGACY_CHAPTER_EXTRACT_SCHEMA_CHARS = 2_400
+
+describe("chapter extract prompt", () => {
+  it("keeps graph edge labels aligned with the graph adapter", () => {
+    expect(GRAPH_EDGE_RELATION_LABELS).toBe(Object.values(NOVEL_RELATION_LABELS).join("|"))
+  })
+
+  it("asks for compact JSON without graphNodes or verbose field comments", () => {
+    const prompt = buildChapterExtractUserPrompt(12, "正文")
+    expect(prompt).toContain('"chapterId": "chapter-12"')
+    expect(prompt).toContain("characterDetails")
+    expect(prompt).toContain("graphEdges")
+    expect(prompt).toContain(GRAPH_EDGE_RELATION_LABELS)
+    expect(prompt).not.toContain("graphNodes")
+    expect(prompt).not.toContain("弧光变化(本章中该人物的成长或变化)")
+    expect(prompt.length).toBeLessThan(LEGACY_CHAPTER_EXTRACT_SCHEMA_CHARS + "正文".length)
+  })
+
+  it("slices long chapter bodies before sending them to the model", () => {
+    const body = "甲".repeat(CHAPTER_BODY_EXCERPT_MAX_CHARS + 80)
+    expect(sliceChapterExtractBody(body)).toHaveLength(CHAPTER_BODY_EXCERPT_MAX_CHARS)
+    expect(buildChapterExtractUserPrompt(1, body)).not.toContain("甲".repeat(CHAPTER_BODY_EXCERPT_MAX_CHARS + 1))
+  })
+
+  it("keeps the system prompt short and forbids markdown fences", () => {
+    const prompt = buildChapterExtractSystemPrompt("请使用中文。")
+    expect(prompt).toContain("只输出一个 JSON 对象")
+    expect(prompt).toContain("请使用中文。")
+    expect(prompt.length).toBeLessThan(120)
+  })
+
+  it("caps extract output tokens instead of reserving 15% of the window", () => {
+    expect(resolveChapterExtractMaxTokens(204_800)).toBe(CHAPTER_EXTRACT_MAX_OUTPUT_TOKENS)
+    expect(resolveChapterExtractMaxTokens(8_192)).toBeGreaterThanOrEqual(MIN_LLM_OUTPUT_TOKENS)
+    expect(resolveChapterExtractMaxTokens(8_192)).toBe(Math.max(
+      MIN_LLM_OUTPUT_TOKENS,
+      Math.floor(8_192 * ANALYSIS_OUTPUT_FRAC),
+    ))
+    expect(CHAPTER_EXTRACT_MAX_OUTPUT_TOKENS).toBeLessThan(Math.floor(204_800 * 0.15))
+  })
+
+  it("disables thinking and skips global user memory for extraction", () => {
+    expect(CHAPTER_EXTRACT_REQUEST_OVERRIDES).toMatchObject({
+      temperature: 0.1,
+      reasoning: { mode: "off" },
+      skipUserMemory: true,
+    })
+  })
+
+  it("does not ask outline ingest for graphNodes", () => {
+    const prompt = buildOutlineExtractUserPrompt("世界观")
+    expect(prompt).toContain("graphEdges")
+    expect(prompt).not.toContain("graphNodes")
+  })
+})
+
+describe("chapter ingest reextract path", () => {
+  const source = readFileSync(resolve(__dirname, "chapter-ingest.ts"), "utf8")
+
+  it("rebuilds derived memory on reextract instead of applying incremental changes twice", () => {
+    expect(source).toContain("const isReingest = existingSnapshot != null")
+    expect(source).toContain("REINGEST_SYNC_OPTIONS")
+    expect(source).toContain("skipDerivedIncremental: true")
+    expect(source).toContain("if (isReingest)")
+    expect(source).toContain("await finalizeProjectMemoryRebuild(pp)")
+    expect(source).toContain("if (!isReingest && shouldRebuildCommunitySummaries")
+  })
+
+  it("sends extract requests with the compact overrides", () => {
+    expect(source).toContain("CHAPTER_EXTRACT_REQUEST_OVERRIDES")
+    expect(source).toContain("parseLlmJsonObject")
+    expect(source).toContain("resolveChapterExtractMaxTokens")
+  })
+})

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

@@ -0,0 +1,122 @@
+/**
+ * 章节 / 大纲记忆提取的 prompt 与 LLM 请求参数。
+ *
+ * 提取是结构化 JSON 任务:关闭 thinking、跳过全局用户记忆、限制输出 token,
+ * 并使用紧凑 schema,避免把审稿/写作链路的开销带到摄取上。
+ */
+
+import { ANALYSIS_OUTPUT_FRAC, MIN_LLM_OUTPUT_TOKENS } from "@/lib/context-budget"
+import type { RequestOverrides } from "@/lib/llm-providers"
+import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
+
+/** 与 `NOVEL_RELATION_LABELS` 中文值保持一致;图谱节点由实体列表派生,不向模型索取 graphNodes。 */
+export const GRAPH_EDGE_RELATION_LABELS =
+  "出场于|发生于|属于|持有|敌对|合作|怀疑|隐瞒|知道|不知道|推进伏笔|回收伏笔|新增伏笔|导致|揭示|影响|位于"
+
+export const CHAPTER_EXTRACT_REQUEST_OVERRIDES: RequestOverrides = {
+  temperature: 0.1,
+  reasoning: { mode: "off" },
+  skipUserMemory: true,
+}
+
+/** 单次提取 JSON 足够;避免按窗口 15% 预留下上万 output tokens。 */
+export const CHAPTER_EXTRACT_MAX_OUTPUT_TOKENS = 8_192
+
+export function resolveChapterExtractMaxTokens(maxContextSize?: number): number {
+  const windowTokens =
+    typeof maxContextSize === "number" && maxContextSize > 0 ? maxContextSize : 204_800
+  return Math.max(
+    MIN_LLM_OUTPUT_TOKENS,
+    Math.min(
+      CHAPTER_EXTRACT_MAX_OUTPUT_TOKENS,
+      Math.floor(windowTokens * ANALYSIS_OUTPUT_FRAC),
+    ),
+  )
+}
+
+export function sliceChapterExtractBody(chapterBody: string): string {
+  if (chapterBody.length <= CHAPTER_BODY_EXCERPT_MAX_CHARS) return chapterBody
+  return chapterBody.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)
+}
+
+export function buildChapterExtractSystemPrompt(langReminder: string): string {
+  const reminder = langReminder.trim()
+  return reminder
+    ? `你是小说编辑助手。只输出一个 JSON 对象,不要 markdown 围栏或其他文字。${reminder}`
+    : "你是小说编辑助手。只输出一个 JSON 对象,不要 markdown 围栏或其他文字。"
+}
+
+export function buildChapterExtractUserPrompt(chapterNumber: number, chapterBody: string): string {
+  const body = sliceChapterExtractBody(chapterBody)
+  return `从以下章节提取结构化信息。
+
+章节编号:第${chapterNumber}章
+
+章节正文:
+${body}
+
+输出 JSON:
+{
+  "chapterId": "chapter-${chapterNumber}",
+  "chapterNumber": ${chapterNumber},
+  "summary": "≤200字摘要",
+  "characters": [],
+  "characterAliases": {"正式名": ["昵称"]},
+  "locations": [],
+  "organizations": [],
+  "items": [],
+  "events": [],
+  "characterStateChanges": ["名:变化"],
+  "relationshipChanges": [],
+  "knowledgeChanges": ["名知道/不知道…"],
+  "foreshadowingChanges": ["新增/推进/回收:…"],
+  "newCanonFacts": [],
+  "timelineEvents": [],
+  "conflicts": [],
+  "endingHook": "",
+  "graphEdges": ["A->关系->B"],
+  "characterDetails": {"名": {"identity":"", "faction":"", "goals":"", "arcChange":""}},
+  "locationDetails": {"名": {"region":"", "type":"", "controller":"", "hiddenInfo":""}},
+  "organizationDetails": {"名": {"leader":"", "members":"", "goals":"", "resources":""}},
+  "itemDetails": {"名": {"holder":"", "previousHolders":"", "abilities":"", "limitations":"", "origin":""}},
+  "eventDetails": {"名": {"cause":"", "process":"", "relatedForeshadowing":"", "relatedConflicts":"", "followUpItems":""}}
+}
+
+规则:同一人物只进 characters 一次,昵称放入 characterAliases;无信息的 *Details 整段省略;graphEdges 关系必须是:${GRAPH_EDGE_RELATION_LABELS}。`
+}
+
+export function buildOutlineExtractSystemPrompt(langReminder: string): string {
+  const reminder = langReminder.trim()
+  return reminder
+    ? `你是小说编辑助手。从大纲提取初始设定,只输出一个 JSON 对象。${reminder}`
+    : "你是小说编辑助手。从大纲提取初始设定,只输出一个 JSON 对象。"
+}
+
+export function buildOutlineExtractUserPrompt(body: string): string {
+  return `请从以下大纲中提取初始设定:
+
+${body}
+
+输出 JSON:
+{
+  "chapterId": "outline-init",
+  "chapterNumber": 0,
+  "summary": "大纲摘要",
+  "characters": [],
+  "locations": [],
+  "organizations": [],
+  "items": [],
+  "events": [],
+  "characterStateChanges": ["人物初始状态"],
+  "relationshipChanges": ["人物初始关系"],
+  "knowledgeChanges": [],
+  "foreshadowingChanges": ["初始伏笔"],
+  "newCanonFacts": ["世界观正史设定"],
+  "timelineEvents": ["时间线背景"],
+  "conflicts": ["核心冲突"],
+  "endingHook": "",
+  "graphEdges": ["A->关系->B"]
+}
+
+规则:graphEdges 关系必须是:${GRAPH_EDGE_RELATION_LABELS}。无信息的数组输出 []。`
+}

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

@@ -12,4 +12,15 @@ describe("chapter ingest draft boundary", () => {
     expect(source).toContain("if (!options.allowDraft && !isFinalChapter(fm))")
     expect(source).toContain('failReason: "not_final"')
   })
+
+  it("does not persist a snapshot before syncSnapshotToMemory", () => {
+    const ingestFn = source.slice(
+      source.indexOf("export async function ingestChapter"),
+      source.indexOf("function createRetrievalStore"),
+    )
+    const saveBeforeSync = ingestFn.indexOf("await saveSnapshot(")
+    const syncCall = ingestFn.indexOf("await syncSnapshotToMemory(")
+    expect(syncCall).toBeGreaterThan(0)
+    expect(saveBeforeSync).toBe(-1)
+  })
 })

+ 98 - 252
src/lib/novel/chapter-ingest.ts

@@ -33,7 +33,15 @@ import { buildStructuredMemoryDocuments, isValidMemorySnapshot } from "./memory-
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { RetrievalStore } from "./retrieval"
 import { computeOutlineIngestBodyBudget } from "@/lib/context-budget"
-import { CHAPTER_BODY_EXCERPT_MAX_CHARS } from "./chapter-excerpts"
+import { parseLlmJsonObject } from "./book-analysis/llm-json"
+import {
+  buildChapterExtractSystemPrompt,
+  buildChapterExtractUserPrompt,
+  buildOutlineExtractSystemPrompt,
+  buildOutlineExtractUserPrompt,
+  CHAPTER_EXTRACT_REQUEST_OVERRIDES,
+  resolveChapterExtractMaxTokens,
+} from "./chapter-ingest-extract"
 
 export interface ValidationWarning {
   type: "entity_new" | "canon_conflict"
@@ -347,6 +355,7 @@ export async function ingestChapter(
   const body = parsed.body
 
   if (signal?.aborted) return { snapshot: null, failReason: "cancelled" }
+  const existingSnapshotPromise = readCurrentSnapshot(pp, chapterNumber)
   const extractedSnapshot = await extractSnapshotWithLLM(chapterNumber, body, runtimeLlmConfig, signal)
   const snapshot = extractedSnapshot ? canonicalizeSnapshotCharacters(extractedSnapshot) : null
 
@@ -354,136 +363,62 @@ export async function ingestChapter(
     return { snapshot: null, failReason: "extract_failed" as IngestFailReason }
   }
 
-  if (snapshot) {
-    try {
-      const entityWarnings = await validateEntityReferences(pp, snapshot)
-      const canonWarnings = await validateCanonConflicts(pp, snapshot)
-      snapshot.validationWarnings = [...entityWarnings, ...canonWarnings]
-      snapshot.entityIsNew = snapshot.entityIsNew || {}
-    } catch (err) {
-      console.warn("[Chapter Ingest] Validation failed:", err instanceof Error ? err.message : err)
-      snapshot.validationWarnings = []
-      snapshot.entityIsNew = {}
-    }
-    await saveSnapshot(pp, snapshot)
-    await saveChapterIngestOutput(pp, snapshot, {
-      title: typeof fm.title === "string" ? fm.title : undefined,
-    })
+  const existingSnapshot = await existingSnapshotPromise
+  const isReingest = existingSnapshot != null
+
+  try {
+    const [entityWarnings, canonWarnings] = await Promise.all([
+      validateEntityReferences(pp, snapshot),
+      validateCanonConflicts(pp, snapshot),
+    ])
+    snapshot.validationWarnings = [...entityWarnings, ...canonWarnings]
+    snapshot.entityIsNew = snapshot.entityIsNew || {}
+  } catch (err) {
+    console.warn("[Chapter Ingest] Validation failed:", err instanceof Error ? err.message : err)
+    snapshot.validationWarnings = []
+    snapshot.entityIsNew = {}
   }
 
-  const embCfg = useWikiStore.getState().embeddingConfig
-  if (embCfg.enabled && embCfg.model) {
+  await saveChapterIngestOutput(pp, snapshot, {
+    title: typeof fm.title === "string" ? fm.title : undefined,
+  })
+
+  const embedPromise = (async () => {
+    const embCfg = useWikiStore.getState().embeddingConfig
+    if (!embCfg.enabled || !embCfg.model) return
     try {
       const { embedPage } = await import("@/lib/embedding")
       const pageId = chapterPath.split(/[/\\]/).pop()?.replace(/\.md$/, "") ?? ""
-      if (pageId) {
-        const title = typeof fm?.title === "string" ? fm.title : pageId
-        await embedPage(pp, pageId, title, content, embCfg)
-      }
+      if (!pageId) return
+      const title = typeof fm?.title === "string" ? fm.title : pageId
+      await embedPage(pp, pageId, title, content, embCfg)
     } catch {
       console.warn("[Chapter Ingest] Embedding update failed, skipping")
     }
-  }
+  })()
 
-  if (snapshot) {
-    try {
-      const writtenPaths = await writeSnapshotToWiki(pp, snapshot)
-      if (writtenPaths.length > 0) {
-        console.log(`[Chapter Ingest] Wrote ${writtenPaths.length} entity pages from snapshot`)
-      }
-    } catch (err) {
-      console.warn("[Chapter Ingest] Entity page write failed:", err instanceof Error ? err.message : err)
-    }
+  const syncResult = await syncSnapshotToMemory(pp, snapshot, isReingest ? REINGEST_SYNC_OPTIONS : undefined)
 
-    try {
-      const patchPath = `${pp}/.novel/chapter-ingest-output/${String(snapshot.chapterNumber).padStart(3, "0")}.wiki-patch.json`
-      const patchJson = await readFile(patchPath)
-      const patch = JSON.parse(patchJson)
-      const patchPaths = await writePatchFieldsToWiki(pp, patch)
-      if (patchPaths.length > 0) {
-        console.log(`[Chapter Ingest] Wrote ${patchPaths.length} entity pages from wiki patch fields`)
-      }
-    } catch (err) {
-      console.warn("[Chapter Ingest] Wiki patch fields write failed:", err instanceof Error ? err.message : err)
-    }
-  }
-
-  if (snapshot && snapshot.knowledgeChanges.length > 0) {
-    try {
-      const existing = await loadCognitionState(pp) ?? emptyCognitionState()
-      const updated = mergeCognitionFromSnapshot(existing, snapshot)
-      await saveCognitionState(pp, updated)
-    } catch (err) {
-      console.warn("[Chapter Ingest] Cognition state update failed:", err instanceof Error ? err.message : err)
-    }
-  }
-
-  if (snapshot && snapshot.characterStateChanges.length > 0) {
-    try {
-      const existingChars = await loadCharacterStates(pp)
-      for (const change of snapshot.characterStateChanges) {
-        const colonIdx = change.indexOf(":")
-        if (colonIdx > 0) {
-          const charName = change.slice(0, colonIdx).trim()
-          const changeDesc = change.slice(colonIdx + 1).trim()
-          const existing = existingChars.characters.find(c => c.characterName === charName)
-          if (existing) {
-            existing.status = changeDesc
-            existing.lastUpdatedChapter = snapshot.chapterNumber
-            existing.lastUpdatedAt = new Date().toISOString()
-          } else {
-            existingChars.characters.push({
-              characterName: charName,
-              currentLocation: "",
-              status: changeDesc,
-              equipment: [],
-              abilities: [],
-              relationships: {},
-              lastUpdatedChapter: snapshot.chapterNumber,
-              lastUpdatedAt: new Date().toISOString(),
-            })
-          }
-        } else {
-          const matched = existingChars.characters.find(c => change.includes(c.characterName))
-          if (matched) {
-            matched.status = change
-            matched.lastUpdatedChapter = snapshot.chapterNumber
-            matched.lastUpdatedAt = new Date().toISOString()
-          }
-        }
-      }
-      existingChars.lastUpdated = new Date().toISOString()
-      await saveCharacterStates(pp, existingChars)
-    } catch (err) {
-      console.warn("[Chapter Ingest] Character state update failed:", err instanceof Error ? err.message : err)
-    }
-  }
-
-  if (snapshot && snapshot.foreshadowingChanges.length > 0) {
-    try {
-      const existingForeshadows = await loadForeshadowingTracker(pp)
-      applyForeshadowingChangesToStore(existingForeshadows, snapshot)
-      await saveForeshadowingTracker(pp, existingForeshadows)
-    } catch (err) {
-      console.warn("[Chapter Ingest] Foreshadowing update failed:", err instanceof Error ? err.message : err)
+  try {
+    const patchPath = `${pp}/.novel/chapter-ingest-output/${String(snapshot.chapterNumber).padStart(3, "0")}.wiki-patch.json`
+    const patchJson = await readFile(patchPath)
+    const patch = JSON.parse(patchJson)
+    const patchPaths = await writePatchFieldsToWiki(pp, patch)
+    if (patchPaths.length > 0) {
+      console.log(`[Chapter Ingest] Wrote ${patchPaths.length} entity pages from wiki patch fields`)
     }
+  } catch (err) {
+    console.warn("[Chapter Ingest] Wiki patch fields write failed:", err instanceof Error ? err.message : err)
   }
 
-  if (snapshot) {
-    try {
-      const memoryPaths = await exportStructuredMemoryToWiki(pp, snapshot)
-      if (memoryPaths.length > 0) {
-        console.log(`[Chapter Ingest] Wrote ${memoryPaths.length} structured memory pages`)
-      }
-    } catch (err) {
-      console.warn("[Chapter Ingest] Structured memory export failed:", err instanceof Error ? err.message : err)
-    }
+  if (isReingest) {
+    await finalizeProjectMemoryRebuild(pp)
   }
 
-  const syncResult = await syncSnapshotToMemory(pp, snapshot)
+  await embedPromise
 
-  // 社区摘要定期重建
-  if (snapshot && shouldRebuildCommunitySummaries(snapshot.chapterNumber, novelConfig)) {
+  // 重新提取只替换本章快照,不必顺带打一次社区摘要(那是另一次 LLM)。
+  if (!isReingest && shouldRebuildCommunitySummaries(snapshot.chapterNumber, novelConfig)) {
     const rebuildCommunitySummaries = async () => {
       try {
         await generateCommunitySummaries(pp, llmConfig, novelConfig)
@@ -647,28 +582,6 @@ function normalizeOutlineIngestError(err: unknown): Error {
   return new Error(message)
 }
 
-const OUTLINE_INGEST_JSON_TEMPLATE = `输出 JSON:
-{
-  "chapterId": "outline-init",
-  "chapterNumber": 0,
-  "summary": "大纲摘要",
-  "characters": ["初始人物"],
-  "locations": ["初始地点"],
-  "organizations": ["初始组织/势力"],
-  "items": ["关键物品"],
-  "events": ["背景事件"],
-  "characterStateChanges": ["人物初始状态"],
-  "relationshipChanges": ["人物初始关系"],
-  "knowledgeChanges": [],
-  "foreshadowingChanges": ["初始伏笔"],
-  "newCanonFacts": ["世界观正史设定"],
-  "timelineEvents": ["时间线背景"],
-  "conflicts": ["核心冲突"],
-  "endingHook": "",
-  "graphNodes": ["图谱节点列表"],
-  "graphEdges": ["图谱关系边,格式:A->关系->B。关系必须是以下之一:出场于|发生于|属于|持有|敌对|合作|怀疑|隐瞒|知道|不知道|推进伏笔|回收伏笔|新增伏笔|导致|揭示|影响|位于"]
-}`
-
 export interface OutlineIngestResult {
   snapshot: ChapterSnapshot | null
   truncated: boolean
@@ -683,11 +596,7 @@ export interface IngestOutlineOptions {
 }
 
 function buildOutlineIngestUserPrompt(body: string): string {
-  return `请从以下大纲中提取初始设定:
-
-${body}
-
-${OUTLINE_INGEST_JSON_TEMPLATE}`
+  return buildOutlineExtractUserPrompt(body)
 }
 
 async function extractSnapshotWithLLM(
@@ -698,85 +607,8 @@ async function extractSnapshotWithLLM(
 ): Promise<ChapterSnapshot | null> {
   const outputLang = getOutputLanguage()
   const langReminder = buildLanguageReminder(outputLang)
-
-  const systemPrompt = `你是一个专业的小说编辑助手。你的任务是从给定的章节正文中提取结构化信息。
-请严格按照 JSON 格式输出,不要输出任何其他内容。
-${langReminder}`
-
-  const userPrompt = `请从以下章节中提取结构化信息,输出 JSON:
-
-章节编号:第${chapterNumber}章
-
-章节正文:
-${chapterBody.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}
-
-请输出以下格式的 JSON:
-{
-  "chapterId": "chapter-${chapterNumber}",
-  "chapterNumber": ${chapterNumber},
-  "summary": "章节摘要(200字以内)",
-  "characters": ["出场人物列表"],
-  "characterAliases": { "人物正式名": ["昵称", "小名", "旧名"] },
-  "locations": ["出场地点列表"],
-  "organizations": ["出场组织列表"],
-  "items": ["出场物品列表"],
-  "events": ["关键事件列表"],
-  "characterStateChanges": ["人物状态变化描述"],
-  "relationshipChanges": ["人物关系变化描述"],
-  "knowledgeChanges": ["角色认知变化描述"],
-  "foreshadowingChanges": ["伏笔变化描述(新增/推进/回收)"],
-  "newCanonFacts": ["新增正史设定"],
-  "timelineEvents": ["时间线事件"],
-  "conflicts": ["冲突变化描述"],
-  "endingHook": "章节结尾钩子描述",
-  "graphNodes": ["图谱节点列表"],
-  "graphEdges": ["图谱关系边列表,格式:A->关系->B。关系必须是以下之一:出场于|发生于|属于|持有|敌对|合作|怀疑|隐瞒|知道|不知道|推进伏笔|回收伏笔|新增伏笔|导致|揭示|影响|位于"],
-  "characterDetails": {
-    "人物名": {
-      "identity": "身份(具体身份描述)",
-      "faction": "阵营(所属势力或立场)",
-      "goals": "目标(当前章节中的目标)",
-      "arcChange": "弧光变化(本章中该人物的成长或变化)"
-    }
-  },
-  "locationDetails": {
-    "地点名": {
-      "region": "区域(所属地理区域)",
-      "type": "类型(场景类型,如宫殿、森林、密室等)",
-      "controller": "控制者(当前控制该地点的势力或人物)",
-      "hiddenInfo": "隐藏信息(地点中的秘密或未揭示的设定)"
-    }
-  },
-  "organizationDetails": {
-    "组织名": {
-      "leader": "领导者",
-      "members": "成员(本章出现或提及的成员)",
-      "goals": "目标(组织当前的目标)",
-      "resources": "资源(组织掌控的资源)"
-    }
-  },
-  "itemDetails": {
-    "物品名": {
-      "holder": "当前持有者",
-      "previousHolders": "前持有者",
-      "abilities": "能力(物品的功能或能力)",
-      "limitations": "限制(使用限制或副作用)",
-      "origin": "来源(物品的来历)"
-    }
-  },
-  "eventDetails": {
-    "事件名": {
-      "cause": "起因(事件的触发原因)",
-      "process": "过程(事件的发展过程)",
-      "relatedForeshadowing": "关联伏笔(与此事件相关的伏笔)",
-      "relatedConflicts": "关联冲突(与此事件相关的冲突)",
-      "followUpItems": "后续事项(事件引发的后续影响或待处理事项)"
-    }
-  }
-}
-
-注意:如果同一个人物在正文里有昵称、小名、旧名或全名,请把正式名放进 characters,把其他称呼放进 characterAliases,不要把同一人物拆成多个 characters。
-注意:characterDetails、locationDetails、organizationDetails、itemDetails、eventDetails 仅在章节中确实有相关信息时才填写;如果某个字段没有相关信息,直接省略该字段即可。`
+  const systemPrompt = buildChapterExtractSystemPrompt(langReminder)
+  const userPrompt = buildChapterExtractUserPrompt(chapterNumber, chapterBody)
 
   try {
     const messages: ChatMessage[] = [
@@ -796,15 +628,16 @@ ${chapterBody.slice(0, CHAPTER_BODY_EXCERPT_MAX_CHARS)}
       },
     }
 
-    await streamChat(llmConfig, messages, callbacks, signal)
+    await streamChat(llmConfig, messages, callbacks, signal, {
+      ...CHAPTER_EXTRACT_REQUEST_OVERRIDES,
+      max_tokens: resolveChapterExtractMaxTokens(llmConfig.maxContextSize),
+    })
     if (streamError) throw streamError
 
-    const jsonMatch = result.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.match(/\{[\s\S]*\}/) ?? result.match(/\{[\s\S]*\}/)
-    if (!jsonMatch) {
+    const parsed = parseLlmJsonObject(result)
+    if (!parsed) {
       throw new Error("章节快照提取失败:模型没有返回可解析的 JSON")
     }
-
-    const parsed = JSON.parse(jsonMatch[0])
     return normalizeChapterSnapshot({
       ...parsed,
       chapterId: parsed.chapterId || `chapter-${chapterNumber}`,
@@ -1101,6 +934,14 @@ export interface SyncSnapshotToMemoryResult {
 export interface SyncSnapshotToMemoryOptions {
   deferStructuredMemoryExport?: boolean
   deferDerivedRebuild?: boolean
+  /** 跳过认知/人物/伏笔的增量合并。重新提取时应随后全量重建派生记忆。 */
+  skipDerivedIncremental?: boolean
+}
+
+const REINGEST_SYNC_OPTIONS: SyncSnapshotToMemoryOptions = {
+  skipDerivedIncremental: true,
+  deferStructuredMemoryExport: true,
+  deferDerivedRebuild: true,
 }
 
 export async function syncSnapshotToMemory(
@@ -1156,17 +997,17 @@ export async function syncSnapshotToMemory(
     } catch { /* skip errors */ }
   }
 
-  if (syncedSnapshot.knowledgeChanges.length > 0) {
+  if (!options?.skipDerivedIncremental && syncedSnapshot.knowledgeChanges.length > 0) {
     const existing = await loadCognitionState(pp) ?? emptyCognitionState()
     const updated = mergeCognitionFromSnapshot(existing, syncedSnapshot)
     await saveCognitionState(pp, updated)
   }
 
-  if (syncedSnapshot.characterStateChanges.length > 0) {
+  if (!options?.skipDerivedIncremental && syncedSnapshot.characterStateChanges.length > 0) {
     await syncCharacterStateChanges(pp, syncedSnapshot)
   }
 
-  if (syncedSnapshot.foreshadowingChanges.length > 0) {
+  if (!options?.skipDerivedIncremental && syncedSnapshot.foreshadowingChanges.length > 0) {
     await syncForeshadowingChanges(pp, syncedSnapshot)
   }
 
@@ -1478,25 +1319,24 @@ async function validateEntityReferences(
     snapshot.entityIsNew = {}
   }
 
-  for (const { key, label } of categories) {
-    for (const name of snapshot[key]) {
+  const checks = categories.flatMap(({ key, label }) =>
+    snapshot[key].map(async (name) => {
       try {
-        const filePath = `${entitiesDir}/${name}.md`
-        const exists = await fileExists(filePath)
-        snapshot.entityIsNew[name] = !exists
-        if (!exists) {
-          warnings.push({
-            type: "entity_new",
-            message: `新${label}: ${name}`,
-          })
-        }
+        const exists = await fileExists(`${entitiesDir}/${name}.md`)
+        return { name, exists, label }
       } catch {
-        snapshot.entityIsNew[name] = true
-        warnings.push({
-          type: "entity_new",
-          message: `新${label}: ${name}`,
-        })
+        return { name, exists: false, label }
       }
+    }),
+  )
+  const results = await Promise.all(checks)
+  for (const { name, exists, label } of results) {
+    snapshot.entityIsNew[name] = !exists
+    if (!exists) {
+      warnings.push({
+        type: "entity_new",
+        message: `新${label}: ${name}`,
+      })
     }
   }
 
@@ -1648,7 +1488,7 @@ export async function ingestOutline(
 
   const outputLang = getOutputLanguage()
   const langReminder = buildLanguageReminder(outputLang)
-  const systemPrompt = `你是一个专业的小说编辑助手。请从大纲中提取初始设定信息,输出 JSON。${langReminder}`
+  const systemPrompt = buildOutlineExtractSystemPrompt(langReminder)
   const promptOverhead = systemPrompt.length + buildOutlineIngestUserPrompt("").length
   const bodyBudget = computeOutlineIngestBodyBudget(runtimeLlmConfig.maxContextSize, promptOverhead)
   const truncated = content.length > bodyBudget
@@ -1669,6 +1509,7 @@ export async function ingestOutline(
   const chapterId = `outline-${outlineName}`
 
   const userPrompt = buildOutlineIngestUserPrompt(body)
+  const existingSnapshotPromise = readCurrentSnapshot(pp, outlineNumber)
 
   try {
     const messages: ChatMessage[] = [
@@ -1684,15 +1525,16 @@ export async function ingestOutline(
       onError: (error: Error) => { streamError = error },
     }
 
-    await streamChat(runtimeLlmConfig, messages, callbacks, signal)
+    await streamChat(runtimeLlmConfig, messages, callbacks, signal, {
+      ...CHAPTER_EXTRACT_REQUEST_OVERRIDES,
+      max_tokens: resolveChapterExtractMaxTokens(runtimeLlmConfig.maxContextSize),
+    })
     if (streamError) throw streamError
 
-    const jsonMatch = result.match(/```(?:json)?\s*([\s\S]*?)```/i)?.[1]?.match(/\{[\s\S]*\}/) ?? result.match(/\{[\s\S]*\}/)
-    if (!jsonMatch) {
+    const parsed = parseLlmJsonObject(result)
+    if (!parsed) {
       throw new Error("大纲摄取失败:模型没有返回可解析的 JSON")
     }
-
-    const parsed = JSON.parse(jsonMatch[0])
     const snapshot = normalizeChapterSnapshot({
       ...parsed,
       chapterId,
@@ -1716,7 +1558,11 @@ export async function ingestOutline(
       }
     }
 
-    const syncResult = await syncSnapshotToMemory(pp, snapshot)
+    const isReingest = (await existingSnapshotPromise) != null
+    const syncResult = await syncSnapshotToMemory(pp, snapshot, isReingest ? REINGEST_SYNC_OPTIONS : undefined)
+    if (isReingest) {
+      await finalizeProjectMemoryRebuild(pp)
+    }
     return {
       snapshot: { ...snapshot, memorySyncedAt: syncResult.memorySyncedAt },
       truncated,