Ver código fonte

fix(writing): 写作补搜仅在真实且知识不够时触发

不确定或自造名称不再默认联网,避免搜到垃圾结果。
需要补资料且英文更好时,同一实体中英双搜并按 URL 去重。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 3 semanas atrás
pai
commit
94297ad1c2

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

@@ -13,6 +13,7 @@ import {
   isWebSearchConfigured,
   parseExtractedEntityNames,
   parseNeedExternalNames,
+  parseNeedExternalQueries,
   parseWritingEntitySearchWorkflowResult,
   selectUnresolvedEntities,
   serializeWritingEntitySearchWorkflowResult,
@@ -145,6 +146,27 @@ describe("writing entity parse helpers", () => {
     expect(parseNeedExternalNames('{"needExternal":["黄蓉","原创甲"]}', ["黄蓉", "降龙十八掌"])).toEqual(["黄蓉"])
     expect(parseNeedExternalNames('{"entities":[{"name":"黄蓉","needExternal":true},{"name":"林烬","needExternal":false}]}', ["黄蓉", "林烬"])).toEqual(["黄蓉"])
   })
+
+  it("parses structured needExternal queries with optional englishQuery", () => {
+    expect(parseNeedExternalQueries('{"needExternal":["李鸿章"]}', ["李鸿章"])).toEqual([
+      { name: "李鸿章" },
+    ])
+    expect(parseNeedExternalQueries(
+      '{"needExternal":[{"name":"鲁茨科伊","englishQuery":"Alexander Rutskoy"},{"name":"林烬"}]}',
+      ["鲁茨科伊", "林烬"],
+    )).toEqual([
+      { name: "鲁茨科伊", englishQuery: "Alexander Rutskoy" },
+      { name: "林烬" },
+    ])
+    expect(parseNeedExternalQueries(
+      '{"needExternal":[{"name":"李鸿章","englishQuery":"李鸿章"}]}',
+      ["李鸿章"],
+    )).toEqual([{ name: "李鸿章" }])
+    expect(parseNeedExternalQueries(
+      '[{"name":"鲁茨科伊","englishQuery":"Alexander Rutskoy"}]',
+      ["鲁茨科伊"],
+    )).toEqual([{ name: "鲁茨科伊", englishQuery: "Alexander Rutskoy" }])
+  })
 })
 
 describe("isWebSearchConfigured", () => {
@@ -329,6 +351,99 @@ describe("collectWritingEntityWebSearch", () => {
     expect(search).not.toHaveBeenCalled()
     expect(result.searchedNames).toEqual([])
   })
+
+  it("asks the judge to search only when real knowledge is incomplete", async () => {
+    let judgePrompt = ""
+    const streamChat = vi.fn(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+    ) => {
+      const user = messages.find((message) => message.role === "user")?.content ?? ""
+      if (user.includes("下列名称")) {
+        judgePrompt = messages.map((message) => message.content).join("\n")
+        callbacks.onToken('{"needExternal":[]}')
+      } else {
+        callbacks.onToken('{"entities":["李鸿章"]}')
+      }
+      callbacks.onDone()
+    })
+    await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章李鸿章出场",
+      contextPack: pack,
+      streamChat,
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search: vi.fn(),
+    })
+    expect(judgePrompt).toContain("确信真实且知识不够才搜")
+    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 () => {
+    const onSearchStart = vi.fn()
+    const search = vi.fn(async (query: string) => {
+      const shared = {
+        title: "鲁茨科伊",
+        url: "https://example.test/rutskoy",
+        snippet: "公开资料摘要",
+        source: "example.test",
+      }
+      if (query === "Alexander Rutskoy") {
+        return [
+          shared,
+          {
+            title: "Alexander Rutskoy",
+            url: "https://example.test/rutskoy-en",
+            snippet: "English-only snippet",
+            source: "example.test",
+          },
+        ]
+      }
+      return [shared]
+    })
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章鲁茨科伊出场",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["鲁茨科伊"]}',
+        '{"needExternal":[{"name":"鲁茨科伊","englishQuery":"Alexander Rutskoy"}]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+      onSearchStart,
+    })
+    expect(onSearchStart).toHaveBeenCalledWith(["鲁茨科伊", "Alexander Rutskoy"])
+    expect(search).toHaveBeenCalledTimes(2)
+    expect(search).toHaveBeenCalledWith("鲁茨科伊", configuredSearch, 8)
+    expect(search).toHaveBeenCalledWith("Alexander Rutskoy", configuredSearch, 8)
+    expect(result.searchedNames).toEqual(["鲁茨科伊", "Alexander Rutskoy"])
+    expect(result.items?.[0]?.name).toBe("鲁茨科伊")
+    expect(result.items?.[0]?.results).toEqual([
+      {
+        title: "鲁茨科伊",
+        url: "https://example.test/rutskoy",
+        snippet: "公开资料摘要",
+        source: "example.test",
+      },
+      {
+        title: "Alexander Rutskoy",
+        url: "https://example.test/rutskoy-en",
+        snippet: "English-only snippet",
+        source: "example.test",
+      },
+    ])
+  })
 })
 
 describe("formatWritingEntitySearchMarkdown", () => {

+ 91 - 22
src/lib/novel/writing-entity-web-search.ts

@@ -145,16 +145,29 @@ export function parseExtractedEntityNames(text: string): string[] {
   return uniqueNames(names).slice(0, MAX_EXTRACTED_ENTITIES)
 }
 
+export interface WritingEntitySearchQuery {
+  name: string
+  englishQuery?: string
+}
+
 export function parseNeedExternalNames(text: string, candidates: readonly string[]): string[] {
+  return parseNeedExternalQueries(text, candidates).map((item) => item.name)
+}
+
+export function parseNeedExternalQueries(
+  text: string,
+  candidates: readonly string[],
+): WritingEntitySearchQuery[] {
   const allowed = new Set(candidates.map((name) => name.trim()).filter(Boolean))
   const parsed = parseJsonPayload(text)
   if (!parsed) return []
 
-  const selected: string[] = []
-  const add = (value: unknown) => {
+  const selected: WritingEntitySearchQuery[] = []
+  const add = (value: unknown, englishSource?: Record<string, unknown>) => {
     const name = String(value ?? "").trim()
-    if (!name || !allowed.has(name) || selected.includes(name)) return
-    selected.push(name)
+    if (!name || !allowed.has(name) || selected.some((item) => item.name === name)) return
+    const englishQuery = englishSource ? readEnglishQuery(englishSource, name) : undefined
+    selected.push(englishQuery ? { name, englishQuery } : { name })
   }
 
   if (Array.isArray(parsed)) {
@@ -163,7 +176,13 @@ export function parseNeedExternalNames(text: string, candidates: readonly string
       else if (item && typeof item === "object") {
         const record = item as Record<string, unknown>
         if (record.needExternal === false) continue
-        if (record.needExternal === true || record.search === true) add(record.name)
+        if (
+          record.needExternal === true
+          || record.search === true
+          || typeof record.name === "string"
+        ) {
+          add(record.name, record)
+        }
       }
     }
     return selected
@@ -178,7 +197,7 @@ export function parseNeedExternalNames(text: string, candidates: readonly string
       else if (item && typeof item === "object") {
         const entry = item as Record<string, unknown>
         if (entry.needExternal === false) continue
-        add(entry.name)
+        add(entry.name, entry)
       }
     }
   }
@@ -186,7 +205,7 @@ export function parseNeedExternalNames(text: string, candidates: readonly string
     for (const item of record.entities) {
       if (!item || typeof item !== "object") continue
       const entry = item as Record<string, unknown>
-      if (entry.needExternal === true || entry.search === true) add(entry.name)
+      if (entry.needExternal === true || entry.search === true) add(entry.name, entry)
     }
   }
   return selected
@@ -427,23 +446,33 @@ export async function collectWritingEntityWebSearch(
       return { markdown: "", searchedNames: [], notes, items: [] }
     }
 
-    input.onSearchStart?.(queries)
+    input.onSearchStart?.(launchedQueriesFor(queries))
 
     const items: Array<{ name: string; results: WebSearchResult[] }> = []
-    for (const name of queries) {
+    const searchedNames: string[] = []
+    for (const query of queries) {
       throwIfAborted(input.signal)
-      try {
-        const results = await search(name, input.searchApiConfig, 8)
-        items.push({ name, results })
-      } catch (error) {
-        rethrowIfUserAbort(error, input.signal)
-        notes.push(`搜索「${name}」失败:${error instanceof Error ? error.message : String(error)}`)
+      const results: WebSearchResult[] = []
+      let searchedThisEntity = false
+      for (const term of launchedQueriesFor([query])) {
+        throwIfAborted(input.signal)
+        try {
+          mergeSearchResults(results, await search(term, input.searchApiConfig, 8))
+          searchedNames.push(term)
+          searchedThisEntity = true
+        } catch (error) {
+          rethrowIfUserAbort(error, input.signal)
+          notes.push(`搜索「${term}」失败:${error instanceof Error ? error.message : String(error)}`)
+        }
+      }
+      if (searchedThisEntity) {
+        items.push({ name: query.name, results })
       }
     }
 
     return {
       markdown: formatWritingEntitySearchMarkdown(items),
-      searchedNames: items.map((item) => item.name),
+      searchedNames,
       notes,
       items,
     }
@@ -465,8 +494,9 @@ async function extractEntityNames(input: CollectWritingEntityWebSearchInput): Pr
     {
       role: "user",
       content: [
-        "从以下文本提取需要核实的专有名称,最多 20 个。",
+        "从以下文本提取专有名称,最多 20 个。",
         "不要提取章节号、普通动词、纯原创占位词如「主角」。",
+        "自造行动、功法、人名仍可抽出,是否联网由下一步判定,不要暗示这些名称都需要核实。",
         '只输出 JSON:{"entities":["名称"]}',
         "",
         source || "(无文本)",
@@ -479,7 +509,7 @@ async function extractEntityNames(input: CollectWritingEntityWebSearchInput): Pr
 async function judgeNeedExternal(
   input: CollectWritingEntityWebSearchInput,
   unresolved: readonly string[],
-): Promise<string[]> {
+): Promise<WritingEntitySearchQuery[]> {
   const raw = await completeText(input, [
     {
       role: "system",
@@ -489,15 +519,18 @@ async function judgeNeedExternal(
       role: "user",
       content: [
         "下列名称在本库前文和实体表都未找到。",
-        "默认放入 needExternal。公开 IP、真实历史或现实地名机构、功法武器等专有设定、以及你不确定的名字,一律放入。",
-        "仅当能明确判断它是本书原创人名或占位词、按大纲即可自编、且网上几乎不可能有对应公开资料时,才排除。",
-        '只输出 JSON:{"needExternal":["名称"]}',
+        "确信真实且知识不够才搜:必须同时满足「明确不是本书自造、对应现实人物/地点/机构/历史事件/公开 IP/已出版作品设定」以及「内置知识不足以支撑本章写准」。",
+        "已知则不搜:内置知识已经明确它是什么、足够写准。",
+        "不确定则不搜:本书原创、占位、捏造,或无法确定是真实还是自造时,一律排除。",
+        "englishQuery 仅在已知但要补资料、且英文检索明显更好时填写;不要为自造名硬翻英文,不要把拼音当英文检索词。",
+        '只输出 JSON:{"needExternal":[{"name":"名称","englishQuery":"English query"}]}',
+        "无合适英文检索词时省略 englishQuery。",
         "",
         unresolved.join("\n"),
       ].join("\n"),
     },
   ])
-  return parseNeedExternalNames(raw, unresolved)
+  return parseNeedExternalQueries(raw, unresolved)
 }
 
 async function completeText(
@@ -575,3 +608,39 @@ function uniqueNames(names: readonly string[]): string[] {
   }
   return output
 }
+
+function readEnglishQuery(record: Record<string, unknown>, name: string): string | undefined {
+  const raw = record.englishQuery ?? record.english ?? record.queryEn
+  const query = typeof raw === "string" ? raw.trim() : ""
+  if (!query || query === name) return undefined
+  return query
+}
+
+function launchedQueriesFor(queries: readonly WritingEntitySearchQuery[]): string[] {
+  const launched: string[] = []
+  for (const query of queries) {
+    launched.push(query.name)
+    if (query.englishQuery && query.englishQuery !== query.name) {
+      launched.push(query.englishQuery)
+    }
+  }
+  return launched
+}
+
+function mergeSearchResults(target: WebSearchResult[], incoming: readonly WebSearchResult[]): void {
+  const seen = new Set(
+    target.map((result) => normalizeSearchResultKey(result)).filter(Boolean),
+  )
+  for (const result of incoming) {
+    const key = normalizeSearchResultKey(result)
+    if (key && seen.has(key)) continue
+    if (key) seen.add(key)
+    target.push(result)
+  }
+}
+
+function normalizeSearchResultKey(result: WebSearchResult): string {
+  const url = result.url.trim().toLowerCase()
+  if (url) return url
+  return `${result.title.trim()}\0${result.snippet.trim()}`
+}