Răsfoiți Sursa

fix(novel): 修正写作实体补搜候选来源

按章纲、卷纲本章内容、本章目标和完整大纲依次选择搜索输入,避免前 8000 字反复触发相同查询。

保持本地实体过滤及任务书、正文和审稿上下文逻辑不变。
darknessomi 2 săptămâni în urmă
părinte
comite
56c64889bf

+ 43 - 0
src/lib/novel/context-engine.spec.ts

@@ -5,6 +5,7 @@ import {
   annotateChapterOutlineStatus,
   contextPackToPrompt,
   pickChapterOutlineByNumber,
+  selectWritingEntitySearchOutline,
   trimContextPack,
   type ContextPack,
 } from "./context-engine"
@@ -81,6 +82,48 @@ describe("pickChapterOutlineByNumber", () => {
   })
 })
 
+describe("selectWritingEntitySearchOutline", () => {
+  it("按章纲、卷纲、本章目标、完整大纲依次选择且不拼接低优先级来源", () => {
+    const sources = {
+      chapterOutline: "第277章章纲:猎户座侦察系统投入测试。",
+      volumeContext: "第五卷:宇宙系列侦察卫星列装。",
+      chapterGoal: "第277章:猎户座投入测试。",
+      outline: "总纲:877型基洛级潜艇与971型阿库拉级核潜艇。",
+    }
+
+    expect(selectWritingEntitySearchOutline(sources)).toBe(sources.chapterOutline)
+    expect(selectWritingEntitySearchOutline({ ...sources, chapterOutline: "" })).toBe(sources.volumeContext)
+    expect(selectWritingEntitySearchOutline({
+      ...sources,
+      chapterOutline: "",
+      volumeContext: "",
+    })).toBe(sources.chapterGoal)
+    expect(selectWritingEntitySearchOutline({
+      ...sources,
+      chapterOutline: "",
+      volumeContext: "",
+      chapterGoal: "",
+    })).toBe(sources.outline)
+  })
+
+  it("不同章节只返回各自章纲,不夹带固定总纲装备", () => {
+    const outline = "总纲:877型基洛级潜艇、971型阿库拉级攻击核潜艇。"
+    const chapter276 = selectWritingEntitySearchOutline({
+      chapterOutline: "第276章章纲:武装走廊完成清场。",
+      outline,
+    })
+    const chapter277 = selectWritingEntitySearchOutline({
+      chapterOutline: "第277章章纲:猎户座侦察系统投入测试。",
+      outline,
+    })
+
+    expect(chapter276).toContain("武装走廊")
+    expect(chapter277).toContain("猎户座")
+    expect(chapter276).not.toContain("基洛级")
+    expect(chapter277).not.toContain("阿库拉级")
+  })
+})
+
 describe("trimContextPack 两级裁剪", () => {
   // Trim-level cases were authored against the old hardcoded ×4 char quota.
   // Pin English density so tokenBudget→chars stays comparable; CJK coverage

+ 29 - 0
src/lib/novel/context-engine.ts

@@ -74,6 +74,8 @@ export interface ContextPack {
   task: string
   chapterGoal: string
   outline: string
+  /** 仅供写作实体补搜提取候选名称,不注入任务书、正文或审稿提示词。 */
+  entitySearchOutline?: string
   recentChapterContents?: string[]
   recentSummaries: string[]
   previousChapterEnding: string
@@ -225,6 +227,13 @@ async function buildContextPackFromRawData(
     rawData.chapterOutline, 
     context.chapterNumber
   )
+
+  const entitySearchOutline = selectWritingEntitySearchOutline({
+    chapterOutline: rawData.chapterOutline,
+    volumeContext: rawData.volumeContext,
+    chapterGoal,
+    outline: rawData.outline,
+  })
   
   // 合并大纲信息
   const mergedOutline = joinNonEmpty([
@@ -252,6 +261,7 @@ async function buildContextPackFromRawData(
     task: context.task,
     chapterGoal,
     outline: mergedOutline,
+    entitySearchOutline,
     recentChapterContents,
     recentSummaries,
     previousChapterEnding,
@@ -400,11 +410,30 @@ export function joinNonEmpty(parts: string[], separator: string): string {
   return parts.map((part) => part.trim()).filter(Boolean).join(separator)
 }
 
+export function selectWritingEntitySearchOutline(input: {
+  chapterOutline?: string
+  volumeContext?: string
+  chapterGoal?: string
+  outline?: string
+}): string {
+  for (const candidate of [
+    input.chapterOutline,
+    input.volumeContext,
+    input.chapterGoal,
+    input.outline,
+  ]) {
+    const normalized = candidate?.trim() ?? ""
+    if (normalized) return normalized
+  }
+  return ""
+}
+
 function emptyPack(task: string): ContextPack {
   return {
     task,
     chapterGoal: "",
     outline: "",
+    entitySearchOutline: "",
     recentChapterContents: [],
     recentSummaries: [],
     previousChapterEnding: "",

+ 28 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1615,6 +1615,34 @@ describe("runDeepChapterGeneration", () => {
     expect(seenPacks.some((pack) => pack.searchResults.includes(research))).toBe(true)
   })
 
+  it("passes the search-only outline to strict-mode entity candidate extraction", async () => {
+    const collectWritingEntityWebSearch = vi.fn(async () => ({
+      markdown: "",
+      searchedNames: [] as string[],
+      notes: [] as string[],
+    }))
+    const deps = createDeps()
+    vi.mocked(deps.buildContextPack).mockResolvedValue({
+      ...contextPack,
+      outline: "总纲:877型基洛级潜艇。",
+      entitySearchOutline: "第3章章纲:雨夜旧屋。",
+    })
+
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      {},
+      { ...deps, collectWritingEntityWebSearch },
+    )
+
+    expect(collectWritingEntityWebSearch).toHaveBeenCalledWith(expect.objectContaining({
+      outline: "第3章章纲:雨夜旧屋。",
+      contextPack: expect.objectContaining({
+        outline: "总纲:877型基洛级潜艇。",
+        entitySearchOutline: "第3章章纲:雨夜旧屋。",
+      }),
+    }))
+  })
+
   it("emits web_search workflow events when strict mode finds external sources", async () => {
     const events: Array<{ type: string; name: string; result?: string; params?: Record<string, unknown> }> = []
     await runDeepChapterGeneration(

+ 1 - 1
src/lib/novel/deep-chapter-generation.ts

@@ -2416,7 +2416,7 @@ async function maybeInjectWritingEntityWebSearch(args: {
     const result = await collect({
       projectPath: args.input.projectPath,
       userRequest: args.input.userRequest,
-      outline: args.contextPack.outline,
+      outline: args.contextPack.entitySearchOutline,
       planBlueprint: args.planBlueprint,
       contextPack: args.contextPack,
       chapterNumber: args.input.chapterNumber,

+ 35 - 0
src/lib/novel/writing-entity-web-search.spec.ts

@@ -4,6 +4,7 @@ import type { ChatMessage, StreamCallbacks } from "@/lib/llm-client"
 import type { ContextPack } from "./context-engine"
 import {
   buildLocalWritingCorpus,
+  buildWritingEntityExtractionSource,
   collectWritingEntityWebSearch,
   displayWritingEntitySearchWorkflowContent,
   formatWritingEntitySearchMarkdown,
@@ -100,6 +101,40 @@ describe("writing entity local lookup", () => {
   })
 })
 
+describe("writing entity extraction source", () => {
+  it("orders user request and selected outline before plan blueprint, then caps at 8000 chars", () => {
+    const source = buildWritingEntityExtractionSource({
+      userRequest: "生成第277章",
+      outline: `第277章章纲:猎户座侦察系统。${"章".repeat(9000)}`,
+      planBlueprint: "任务蓝图:不应挤占章纲窗口。",
+      contextPack: {
+        ...pack,
+        outline: "总纲:877型基洛级潜艇。",
+      },
+    })
+
+    expect(source).toHaveLength(8000)
+    expect(source).toMatch(/^生成第277章\n\n第277章章纲:猎户座侦察系统。/)
+    expect(source).not.toContain("877型基洛级潜艇")
+    expect(source).not.toContain("任务蓝图")
+  })
+
+  it("uses the search-only outline instead of the merged outline fallback", () => {
+    const source = buildWritingEntityExtractionSource({
+      userRequest: "生成第276章",
+      planBlueprint: "",
+      contextPack: {
+        ...pack,
+        outline: "总纲:971型阿库拉级攻击核潜艇。",
+        entitySearchOutline: "第276章章纲:武装走廊完成清场。",
+      },
+    })
+
+    expect(source).toContain("武装走廊")
+    expect(source).not.toContain("阿库拉级")
+  })
+})
+
 describe("writing entity parse helpers", () => {
   it("parses extracted entity names from JSON", () => {
     expect(parseExtractedEntityNames('{"entities":["黄蓉","降龙十八掌"]}')).toEqual(["黄蓉", "降龙十八掌"])

+ 19 - 5
src/lib/novel/writing-entity-web-search.ts

@@ -90,6 +90,24 @@ export function buildLocalWritingCorpus(
     .join("\n")
 }
 
+export function buildWritingEntityExtractionSource(
+  input: Pick<
+    CollectWritingEntityWebSearchInput,
+    "userRequest" | "outline" | "planBlueprint" | "contextPack"
+  >,
+): string {
+  const outline = input.outline?.trim()
+    || input.contextPack.entitySearchOutline?.trim()
+    || input.contextPack.outline?.trim()
+    || ""
+
+  return [
+    input.userRequest.trim(),
+    outline,
+    input.planBlueprint?.trim() ?? "",
+  ].filter(Boolean).join("\n\n").slice(0, SOURCE_TEXT_CHAR_CAP)
+}
+
 export function isLocallyResolvedEntity(
   name: string,
   corpus: string,
@@ -437,11 +455,7 @@ export async function collectWritingEntityWebSearch(
 }
 
 async function extractEntityNames(input: CollectWritingEntityWebSearchInput): Promise<string[]> {
-  const source = [
-    input.userRequest.trim(),
-    input.planBlueprint?.trim() ?? "",
-    input.outline?.trim() || input.contextPack.outline?.trim() || "",
-  ].filter(Boolean).join("\n\n").slice(0, SOURCE_TEXT_CHAR_CAP)
+  const source = buildWritingEntityExtractionSource(input)
 
   const raw = await completeText(input, [
     {