1
0
Эх сурвалжийг харах

fix(writing): 修复正文补搜因判定过严而不触发

判定改为看名称性质而非模型自评知识量,跳过路径写入可见 note。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 долоо хоног өмнө
parent
commit
17331baba6

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

@@ -1809,6 +1809,28 @@ describe("runDeepChapterGeneration", () => {
     expect(failed?.result).toContain("网络超时")
     expect(failed?.result).toContain("网络超时")
   })
   })
 
 
+  it("reports a deliberate entity-search skip as completed instead of failed", async () => {
+    const events: Array<{ type: string; name: string; result?: string }> = []
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      { onWorkflowEvent: (event) => events.push(event) },
+      {
+        ...createDeps(),
+        collectWritingEntityWebSearch: vi.fn(async () => ({
+          markdown: "",
+          searchedNames: [] as string[],
+          notes: ["判定为本书自造、无需联网:林烬"],
+          items: [],
+          skipped: true,
+        })),
+      },
+    )
+
+    expect(events.some((event) => event.name === "web_search" && event.type === "error")).toBe(false)
+    const completed = events.find((event) => event.name === "web_search" && event.type === "completed")
+    expect(completed?.result).toContain("林烬")
+  })
+
   it("emits visible workflow events for the chapter multi-task loop", async () => {
   it("emits visible workflow events for the chapter multi-task loop", async () => {
     const deps = createDeps()
     const deps = createDeps()
     const events: Array<{ type: string; id: string; name: string; title: string; result?: string }> = []
     const events: Array<{ type: string; id: string; name: string; title: string; result?: string }> = []

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

@@ -2496,7 +2496,7 @@ function emitWritingEntityWebSearchWorkflow(
   startVisibleSearch: (query: string) => void,
   startVisibleSearch: (query: string) => void,
 ): void {
 ): void {
   const query = result.searchedNames.join("、");
   const query = result.searchedNames.join("、");
-  const notes = result.notes.filter((note) => note !== "未配置外部搜索");
+  const notes = result.notes;
   const hasVisibleSearch = Boolean(
   const hasVisibleSearch = Boolean(
     query || result.markdown.trim() || notes.length > 0 || (result.items?.length ?? 0) > 0,
     query || result.markdown.trim() || notes.length > 0 || (result.items?.length ?? 0) > 0,
   );
   );
@@ -2510,7 +2510,7 @@ function emitWritingEntityWebSearchWorkflow(
     ...(sources.length > 0 ? { sources } : {}),
     ...(sources.length > 0 ? { sources } : {}),
   };
   };
   const output = serializeWritingEntitySearchWorkflowResult(result);
   const output = serializeWritingEntitySearchWorkflowResult(result);
-  const failed = result.searchedNames.length === 0 && notes.length > 0;
+  const failed = !result.skipped && result.searchedNames.length === 0 && notes.length > 0;
   if (failed) {
   if (failed) {
     errorChapterWorkflowStep(callbacks, { ...spec, params }, output || notes.join("\n"));
     errorChapterWorkflowStep(callbacks, { ...spec, params }, output || notes.join("\n"));
     return;
     return;

+ 57 - 7
src/lib/novel/writing-entity-web-search.spec.ts

@@ -81,6 +81,11 @@ describe("writing entity local lookup", () => {
     expect(isLocallyResolvedEntity("降龙十八掌", corpus, ["黄蓉"])).toBe(false)
     expect(isLocallyResolvedEntity("降龙十八掌", corpus, ["黄蓉"])).toBe(false)
   })
   })
 
 
+  it("does not let a short local name short-circuit a longer compound candidate", () => {
+    expect(isLocallyResolvedEntity("洛云宗", "无关正文", ["洛云"])).toBe(false)
+    expect(isLocallyResolvedEntity("洛云", "无关正文", ["洛云宗掌门"])).toBe(true)
+  })
+
   it("does not treat names that only appear in the chapter outline as resolved", () => {
   it("does not treat names that only appear in the chapter outline as resolved", () => {
     const corpus = buildLocalWritingCorpus({
     const corpus = buildLocalWritingCorpus({
       ...pack,
       ...pack,
@@ -196,7 +201,9 @@ describe("collectWritingEntityWebSearch", () => {
     })
     })
     expect(search).not.toHaveBeenCalled()
     expect(search).not.toHaveBeenCalled()
     expect(result.markdown).toBe("")
     expect(result.markdown).toBe("")
-    expect(result.notes).toContain("未配置外部搜索")
+    expect(result.skipped).toBe(true)
+    expect(result.notes.join("\n")).toContain("未配置外部搜索")
+    expect(result.notes.join("\n")).toContain("设置 → 网页搜索")
   })
   })
 
 
   it("does not search names found in previous text or the entity table", async () => {
   it("does not search names found in previous text or the entity table", async () => {
@@ -350,9 +357,51 @@ describe("collectWritingEntityWebSearch", () => {
     })
     })
     expect(search).not.toHaveBeenCalled()
     expect(search).not.toHaveBeenCalled()
     expect(result.searchedNames).toEqual([])
     expect(result.searchedNames).toEqual([])
+    expect(result.skipped).toBe(true)
+    expect(result.notes.join("\n")).toContain("林烬")
+  })
+
+  it("reports why it skipped when every candidate is already local", async () => {
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章郭靖出场",
+      contextPack: pack,
+      streamChat: streamChatReturning(['{"entities":["郭靖"]}']),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search: vi.fn(),
+    })
+    expect(result.skipped).toBe(true)
+    expect(result.notes.join("\n")).toContain("跳过联网补搜")
+  })
+
+  it("surfaces an llm failure instead of silently reporting nothing to search", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章李鸿章出场",
+      contextPack: pack,
+      streamChat: vi.fn(async (
+        _config: LlmConfig,
+        _messages: ChatMessage[],
+        callbacks: StreamCallbacks,
+      ) => {
+        callbacks.onError(new Error("工作流模型不可用"))
+      }),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.skipped).toBeUndefined()
+    expect(result.notes.join("\n")).toContain("工作流模型不可用")
   })
   })
 
 
-  it("asks the judge to search only when real knowledge is incomplete", async () => {
+  it("asks the judge to fall back to searching when a name's nature is uncertain", async () => {
     let judgePrompt = ""
     let judgePrompt = ""
     const streamChat = vi.fn(async (
     const streamChat = vi.fn(async (
       _config: LlmConfig,
       _config: LlmConfig,
@@ -379,11 +428,12 @@ describe("collectWritingEntityWebSearch", () => {
       readPreviousBodies: async () => [],
       readPreviousBodies: async () => [],
       search: vi.fn(),
       search: vi.fn(),
     })
     })
-    expect(judgePrompt).toContain("确信真实且知识不够才搜")
-    expect(judgePrompt).toContain("已知则不搜")
-    expect(judgePrompt).toContain("不确定则不搜")
-    expect(judgePrompt).not.toContain("默认放入")
-    expect(judgePrompt).not.toContain("不确定的名字一律放入")
+    expect(judgePrompt).toContain("无法确定是真实还是自造时放入 needExternal")
+    expect(judgePrompt).toContain("判断的是名称性质,不是你的知识量")
+    expect(judgePrompt).toContain("不要用「我已经知道它是什么」当排除理由")
+    // 这两条曾让判定的通过条件变成空集:自造名走「不确定则不搜」,真实名走「已知则不搜」。
+    expect(judgePrompt).not.toContain("已知则不搜")
+    expect(judgePrompt).not.toContain("不确定则不搜")
   })
   })
 
 
   it("searches englishQuery alongside the Chinese name and dedupes by url", async () => {
   it("searches englishQuery alongside the Chinese name and dedupes by url", async () => {

+ 32 - 10
src/lib/novel/writing-entity-web-search.ts

@@ -17,6 +17,8 @@ export interface WritingEntityWebSearchResult {
   searchedNames: string[]
   searchedNames: string[]
   notes: string[]
   notes: string[]
   items?: Array<{ name: string; results: WebSearchResult[] }>
   items?: Array<{ name: string; results: WebSearchResult[] }>
+  /** 按规则主动跳过(无候选、判定无需联网、未配置搜索源),区别于检索失败。 */
+  skipped?: boolean
 }
 }
 
 
 export interface CollectWritingEntityWebSearchInput {
 export interface CollectWritingEntityWebSearchInput {
@@ -116,9 +118,11 @@ export function isLocallyResolvedEntity(
   const trimmed = name.trim()
   const trimmed = name.trim()
   if (trimmed.length < MIN_NAME_LENGTH) return true
   if (trimmed.length < MIN_NAME_LENGTH) return true
   if (corpus.includes(trimmed)) return true
   if (corpus.includes(trimmed)) return true
+  // 只认「本地条目更完整地覆盖了候选」这一个方向。反向包含会把「洛云宗」「郭靖的降龙十八掌」
+  // 这类复合名按本地的两字人名短路掉,本地其实没有它们的资料。
   return entityNames.some((entityName) => (
   return entityNames.some((entityName) => (
     entityName.length >= MIN_NAME_LENGTH
     entityName.length >= MIN_NAME_LENGTH
-    && (trimmed.includes(entityName) || entityName.includes(trimmed))
+    && entityName.includes(trimmed)
   ))
   ))
 }
 }
 
 
@@ -411,7 +415,13 @@ export async function collectWritingEntityWebSearch(
 ): Promise<WritingEntityWebSearchResult> {
 ): Promise<WritingEntityWebSearchResult> {
   const notes: string[] = []
   const notes: string[] = []
   if (!isWebSearchConfigured(input.searchApiConfig)) {
   if (!isWebSearchConfigured(input.searchApiConfig)) {
-    return { markdown: "", searchedNames: [], notes: ["未配置外部搜索"], items: [] }
+    return {
+      markdown: "",
+      searchedNames: [],
+      notes: ["未配置外部搜索,跳过联网补搜;可在「设置 → 网页搜索」配置搜索源"],
+      items: [],
+      skipped: true,
+    }
   }
   }
 
 
   throwIfAborted(input.signal)
   throwIfAborted(input.signal)
@@ -437,13 +447,19 @@ export async function collectWritingEntityWebSearch(
     const extracted = await extractEntityNames(input)
     const extracted = await extractEntityNames(input)
     const unresolved = selectUnresolvedEntities(extracted, corpus, entityNames)
     const unresolved = selectUnresolvedEntities(extracted, corpus, entityNames)
     if (unresolved.length === 0) {
     if (unresolved.length === 0) {
-      return { markdown: "", searchedNames: [], notes, items: [] }
+      notes.push(
+        extracted.length === 0
+          ? "未从任务与章纲中抽出候选实体,跳过联网补搜"
+          : `候选实体已能在前文或实体表中找到,跳过联网补搜:${extracted.join("、")}`,
+      )
+      return { markdown: "", searchedNames: [], notes, items: [], skipped: true }
     }
     }
 
 
     const needExternal = await judgeNeedExternal(input, unresolved)
     const needExternal = await judgeNeedExternal(input, unresolved)
     const queries = needExternal.slice(0, MAX_SEARCH_QUERIES)
     const queries = needExternal.slice(0, MAX_SEARCH_QUERIES)
     if (queries.length === 0) {
     if (queries.length === 0) {
-      return { markdown: "", searchedNames: [], notes, items: [] }
+      notes.push(`判定为本书自造、无需联网:${unresolved.join("、")}`)
+      return { markdown: "", searchedNames: [], notes, items: [], skipped: true }
     }
     }
 
 
     input.onSearchStart?.(launchedQueriesFor(queries))
     input.onSearchStart?.(launchedQueriesFor(queries))
@@ -519,11 +535,13 @@ async function judgeNeedExternal(
       role: "user",
       role: "user",
       content: [
       content: [
         "下列名称在本库前文和实体表都未找到。",
         "下列名称在本库前文和实体表都未找到。",
-        "确信真实且知识不够才搜:必须同时满足「明确不是本书自造、对应现实人物/地点/机构/历史事件/公开 IP/已出版作品设定」以及「内置知识不足以支撑本章写准」。",
-        "已知则不搜:内置知识已经明确它是什么、足够写准。",
-        "不确定则不搜:本书原创、占位、捏造,或无法确定是真实还是自造时,一律排除。",
-        "englishQuery 仅在已知但要补资料、且英文检索明显更好时填写;不要为自造名硬翻英文,不要把拼音当英文检索词。",
+        "判断的是名称性质,不是你的知识量:这个名称是否可能对应现实世界或已公开的资料。",
+        "要搜:现实人物、地点、机构、历史事件、装备与型号、专业术语与行业流程、公开 IP 或已出版作品的设定。",
+        "不搜:一眼可认定是本书自造的人名、门派、功法、法宝、架空地名,以及「主角」这类占位词。",
+        "无法确定是真实还是自造时放入 needExternal:搜到无关结果的代价远小于把真实设定写错。",
+        "不要用「我已经知道它是什么」当排除理由,你的记忆可能过时或细节有误。",
         '只输出 JSON:{"needExternal":[{"name":"名称","englishQuery":"English query"}]}',
         '只输出 JSON:{"needExternal":[{"name":"名称","englishQuery":"English query"}]}',
+        "englishQuery 仅在英文检索明显更好时填写(外国人名、外文机构、技术型号);不要为自造名硬翻英文,不要把拼音当英文检索词。",
         "无合适英文检索词时省略 englishQuery。",
         "无合适英文检索词时省略 englishQuery。",
         "",
         "",
         unresolved.join("\n"),
         unresolved.join("\n"),
@@ -538,18 +556,22 @@ async function completeText(
   messages: ChatMessage[],
   messages: ChatMessage[],
 ): Promise<string> {
 ): Promise<string> {
   let result = ""
   let result = ""
+  // streamChat 报错走 onError 而不 throw;不记下来的话模型不可用会静默变成「没有实体要搜」。
+  const failures: Error[] = []
   await input.streamChat(
   await input.streamChat(
     input.llmConfig,
     input.llmConfig,
     messages,
     messages,
     {
     {
       onToken: (token) => { result += token },
       onToken: (token) => { result += token },
       onDone: () => {},
       onDone: () => {},
-      onError: () => {},
+      onError: (error) => { failures.push(error) },
       onRequestTrace: input.onRequestTrace,
       onRequestTrace: input.onRequestTrace,
     },
     },
     input.signal,
     input.signal,
   )
   )
-  return result.trim()
+  const text = result.trim()
+  if (!text && failures.length > 0) throw failures[0]
+  return text
 }
 }
 
 
 function parseJsonPayload(text: string): unknown | null {
 function parseJsonPayload(text: string): unknown | null {