Jelajahi Sumber

merge: 合并 feat/entity-miss-web-search 到大纲保存修复分支

纳入本地实体未命中时主动启用 web_search 的改动。
darknessomi 2 bulan lalu
induk
melakukan
5584c4df00

+ 46 - 1
src/lib/agent/ai-chat-workflow-convergence.spec.ts

@@ -1,4 +1,10 @@
-import { describe, expect, it, vi } from "vitest"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(async () => []),
+}))
+
+import { listDirectory } from "@/commands/fs"
 import { createPrePluginChain } from "./pipeline"
 import { createBuildContextPackPlugin } from "./plugins/build-context-pack-plugin"
 import { createSelectSkillsPlugin } from "./plugins/select-skills-plugin"
@@ -132,6 +138,11 @@ async function runWorkflow(input: {
 }
 
 describe("AI chat workflow convergence", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    vi.mocked(listDirectory).mockResolvedValue([])
+  })
+
   it("fast polish keeps tool use minimal and does not enable write tools", async () => {
     const result = await runWorkflow({
       userMessage: "帮我润色这句话",
@@ -204,6 +215,40 @@ describe("AI chat workflow convergence", () => {
     }))
   })
 
+  it("character_query enables web search when local entity table misses the name", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([])
+
+    const result = await runWorkflow({
+      userMessage: "黄蓉是谁",
+      intent: "character_query",
+      mode: "standard",
+    })
+
+    expect(result.enabledToolNames).toEqual(expect.arrayContaining(["web_search", "read_web_page"]))
+    expect(result.selectedCapabilities).toContainEqual(expect.objectContaining({
+      kind: "web_search",
+      reason: expect.stringContaining("local entity miss"),
+    }))
+  })
+
+  it("character_query does not enable web search when local entity name is present", async () => {
+    vi.mocked(listDirectory).mockImplementation(async (path: string) => {
+      if (path.endsWith("wiki/entities")) {
+        return [{ name: "黄蓉.md", path: "/project/wiki/entities/黄蓉.md", is_dir: false }]
+      }
+      return []
+    })
+
+    const result = await runWorkflow({
+      userMessage: "黄蓉是谁",
+      intent: "character_query",
+      mode: "standard",
+    })
+
+    expect(result.enabledToolNames ?? []).not.toContain("web_search")
+    expect((result.selectedCapabilities ?? []).some((item) => item.kind === "web_search")).toBe(false)
+  })
+
   it("strict knowledge graph task can select read-only MCP capability", async () => {
     const result = await runWorkflow({
       userMessage: "use knowledge graph to analyze protagonist relationship",

+ 7 - 2
src/lib/agent/capabilities/registry.ts

@@ -48,6 +48,7 @@ const TOOL_LABELS: Record<string, string> = {
   apply_skill: "Apply Skill",
   web_search: "External Search",
   read_web_page: "Read Web Page",
+  summarize_search_results: "Summarize Search Results",
   route_task: "Route Task",
   load_context: "Load Context",
   trim_context: "Trim Context",
@@ -85,8 +86,12 @@ export function buildUserSkillCapabilities(skills: UserSkill[]): AiCapability[]
   }))
 }
 
+function isWebSearchTool(toolName: string): boolean {
+  return toolName === "web_search" || toolName === "read_web_page" || toolName === "summarize_search_results"
+}
+
 function createToolCapability(toolName: string): AiCapability {
-  const kind: CapabilityKind = toolName === "web_search" || toolName === "read_web_page"
+  const kind: CapabilityKind = isWebSearchTool(toolName)
     ? "web_search"
     : "built_in_tool"
   const permission: CapabilityPermission = toolName.startsWith("write_") ? "confirm" : "auto"
@@ -104,7 +109,7 @@ function createToolCapability(toolName: string): AiCapability {
 }
 
 function toolIntents(toolName: string): CapabilityIntent[] {
-  if (toolName === "web_search" || toolName === "read_web_page") {
+  if (isWebSearchTool(toolName)) {
     return ["external_search", ...QUERY_INTENTS, "general"]
   }
   if (toolName === "write_chapter") return WRITING_INTENTS

+ 57 - 0
src/lib/agent/capabilities/selector.spec.ts

@@ -16,6 +16,7 @@ const toolNames = [
   "write_chapter",
   "web_search",
   "read_web_page",
+  "summarize_search_results",
 ]
 
 function futureMcpCapability(): AiCapability {
@@ -114,9 +115,65 @@ describe("AI capability selector", () => {
     expect(withSearchRequest.map((item) => item.toolName)).toEqual(expect.arrayContaining([
       "web_search",
       "read_web_page",
+      "summarize_search_results",
     ]))
   })
 
+  it("enables web search for character_query when local entity misses", () => {
+    const capabilities = buildBuiltInToolCapabilities(toolNames)
+
+    const withMiss = selectCapabilities({
+      capabilities,
+      intent: "character_query",
+      mode: "standard",
+      userMessage: "黄蓉是谁",
+      localEntityMiss: true,
+    })
+    const withHit = selectCapabilities({
+      capabilities,
+      intent: "character_query",
+      mode: "standard",
+      userMessage: "黄蓉是谁",
+      localEntityMiss: false,
+    })
+    const explicitLookup = selectCapabilities({
+      capabilities,
+      intent: "character_query",
+      mode: "standard",
+      userMessage: "查一下黄蓉的背景",
+      localEntityMiss: false,
+    })
+
+    expect(withMiss.map((item) => item.toolName)).toEqual(expect.arrayContaining([
+      "web_search",
+      "read_web_page",
+      "summarize_search_results",
+    ]))
+    expect(withMiss).toContainEqual(expect.objectContaining({
+      kind: "web_search",
+      reason: expect.stringContaining("local entity miss"),
+    }))
+    expect(withHit.some((item) => item.kind === "web_search")).toBe(false)
+    expect(explicitLookup.map((item) => item.toolName)).toEqual(expect.arrayContaining([
+      "web_search",
+      "read_web_page",
+    ]))
+  })
+
+  it("does not enable web search for setting_query without miss or explicit request", () => {
+    const capabilities = buildBuiltInToolCapabilities(toolNames)
+
+    const selected = selectCapabilities({
+      capabilities,
+      intent: "setting_query",
+      mode: "standard",
+      userMessage: "这个世界的魔法体系是什么",
+      localEntityMiss: false,
+    })
+
+    expect(selected.some((item) => item.kind === "web_search")).toBe(false)
+  })
+
   it("selects chapter workflow tool for standard and strict chapter writing intents", () => {
     const capabilities = buildAvailableCapabilities({
       toolNames: ["read_chapter", "run_chapter_workflow"],

+ 12 - 3
src/lib/agent/capabilities/selector.ts

@@ -42,12 +42,16 @@ const STRICT_EXTRA_TOOLS = new Set([
   "list_deductions",
 ])
 
+const ENTITY_QUERY_INTENTS = new Set<NovelTaskIntent>(["character_query", "setting_query"])
+
 export interface SelectCapabilitiesInput {
   capabilities: AiCapability[]
   intent: NovelTaskIntent
   mode: LegacyAiWorkflowMode
   userMessage: string
   blockedSources?: DataSourceCategory[]
+  /** 本地实体表为空,或用户消息未命中任何本地实体名 */
+  localEntityMiss?: boolean
 }
 
 export function selectCapabilities(input: SelectCapabilitiesInput): SelectedCapabilityTrace[] {
@@ -81,8 +85,13 @@ function selectionReason(
     if (isExplicitSearchRequest(input.userMessage)) {
       return "user explicitly requested external search"
     }
-    if (input.mode !== "fast" && capability.intents.includes("external_search") && input.intent === "setting_query") {
-      return "task may require external information"
+    if (
+      input.mode !== "fast"
+      && input.localEntityMiss === true
+      && ENTITY_QUERY_INTENTS.has(input.intent)
+      && capability.intents.includes("external_search")
+    ) {
+      return "local entity miss may require external search"
     }
     return null
   }
@@ -157,7 +166,7 @@ function toTrace(capability: AiCapability, reason: string): SelectedCapabilityTr
 
 function isExplicitSearchRequest(message: string): boolean {
   const normalized = message.toLowerCase()
-  return /search|web|internet|online|external|latest|联网|搜索|查资料|外部|最新/.test(normalized)
+  return /search|web|internet|online|external|latest|联网|搜索|搜一下|查一下|查找|查资料|网页|外部|外部资料|最新/.test(normalized)
 }
 
 function isKnowledgeGraphRequest(message: string, intent: NovelTaskIntent): boolean {

+ 6 - 1
src/lib/agent/plugins/select-capabilities-plugin.spec.ts

@@ -1,4 +1,9 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(async () => []),
+}))
+
 import { createSelectCapabilitiesPlugin } from "./select-capabilities-plugin"
 import { buildAvailableCapabilities } from "../capabilities/registry"
 import { normalizeUserSkill } from "@/lib/novel/skill-library"

+ 12 - 1
src/lib/agent/plugins/select-capabilities-plugin.ts

@@ -2,6 +2,7 @@ import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import { buildAvailableCapabilities } from "../capabilities/registry"
 import { selectCapabilities } from "../capabilities/selector"
 import { resolveAiWorkflowMode } from "../workflow-mode"
+import { detectLocalEntityMiss } from "@/lib/novel/local-entity-names"
 
 const PLAN_PHASE_ALLOWED_TOOLS = new Set([
   "read_chapter",
@@ -18,6 +19,8 @@ const PLAN_PHASE_ALLOWED_TOOLS = new Set([
   "load_context",
   "trim_context",
   "web_search",
+  "read_web_page",
+  "summarize_search_results",
 ])
 
 export function createSelectCapabilitiesPlugin(): PrePlugin {
@@ -36,12 +39,20 @@ export function createSelectCapabilitiesPlugin(): PrePlugin {
         mcpCapabilities: input.mcpCapabilities ?? [],
       })
 
+      const mode = resolveAiWorkflowMode(input.aiWorkflowMode)
+      const needsEntityMissCheck =
+        mode !== "fast" && (route.intent === "character_query" || route.intent === "setting_query")
+      const localEntityMiss = needsEntityMissCheck
+        ? await detectLocalEntityMiss(input.projectPath, input.userMessage)
+        : false
+
       const selectedCapabilities = selectCapabilities({
         capabilities: availableCapabilities,
         intent: route.intent,
-        mode: resolveAiWorkflowMode(input.aiWorkflowMode),
+        mode,
         userMessage: input.userMessage,
         blockedSources: input.blockedSources as any,
+        localEntityMiss,
       })
 
       const isPlanPhase = Boolean(input.planExecuteEnabled)

+ 1 - 1
src/lib/agent/tools/web-search.ts

@@ -34,7 +34,7 @@ function isSearchConfigured(config: SearchApiConfig | null | undefined): config
 export function createWebSearchTool(getSearchApiConfig?: () => SearchApiConfig | null | undefined): Tool {
   return {
     name: "web_search",
-    description: "联网搜索外部资料。用户明确要求搜索、联网查询、查外部资料或最新信息时使用;未配置搜索时会返回中文降级说明。",
+    description: "联网搜索外部资料。用户明确要求搜索、联网查询、查外部资料或最新信息时使用;本地人物/设定实体查不到或信息不足时也应主动使用;未配置搜索时会返回中文降级说明。",
     category: "read",
     parameters: {
       query: { type: "string", description: "搜索关键词", required: true },

+ 68 - 0
src/lib/novel/local-entity-names.spec.ts

@@ -0,0 +1,68 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/commands/fs", () => ({
+  listDirectory: vi.fn(),
+}))
+
+import { listDirectory } from "@/commands/fs"
+import {
+  detectLocalEntityMiss,
+  hasLocalEntityMention,
+  listLocalEntityNames,
+} from "./local-entity-names"
+
+describe("local-entity-names", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it("lists markdown stems from entities/characters/concepts without reading content", async () => {
+    vi.mocked(listDirectory).mockImplementation(async (path: string) => {
+      if (path.endsWith("wiki/entities")) {
+        return [
+          { name: "黄蓉.md", path: "/project/wiki/entities/黄蓉.md", is_dir: false },
+          { name: "a.md", path: "/project/wiki/entities/a.md", is_dir: false },
+        ]
+      }
+      if (path.endsWith("wiki/characters")) {
+        return [{ name: "郭靖.md", path: "/project/wiki/characters/郭靖.md", is_dir: false }]
+      }
+      if (path.endsWith("wiki/concepts")) {
+        return [{ name: "降龙十八掌.md", path: "/project/wiki/concepts/降龙十八掌.md", is_dir: false }]
+      }
+      throw new Error(`missing dir: ${path}`)
+    })
+
+    const names = await listLocalEntityNames("/project")
+
+    expect(names).toHaveLength(3)
+    expect(names).toEqual(expect.arrayContaining(["郭靖", "黄蓉", "降龙十八掌"]))
+    expect(names).not.toContain("a")
+  })
+
+  it("returns empty list when entity directories are missing", async () => {
+    vi.mocked(listDirectory).mockRejectedValue(new Error("ENOENT"))
+    await expect(listLocalEntityNames("/project")).resolves.toEqual([])
+  })
+
+  it("detects local entity mention by substring and ignores short names", () => {
+    expect(hasLocalEntityMention("黄蓉是谁", ["黄蓉", "郭靖"])).toBe(true)
+    expect(hasLocalEntityMention("洪七公怎么样", ["黄蓉", "郭靖"])).toBe(false)
+    expect(hasLocalEntityMention("查一下 a", ["a", "黄蓉"])).toBe(false)
+    expect(hasLocalEntityMention("黄蓉是谁", [])).toBe(false)
+  })
+
+  it("treats empty table or unmatched message as local entity miss", async () => {
+    vi.mocked(listDirectory).mockResolvedValue([])
+    await expect(detectLocalEntityMiss("/project", "黄蓉是谁")).resolves.toBe(true)
+
+    vi.mocked(listDirectory).mockImplementation(async (path: string) => {
+      if (path.endsWith("wiki/entities")) {
+        return [{ name: "郭靖.md", path: "/project/wiki/entities/郭靖.md", is_dir: false }]
+      }
+      return []
+    })
+    await expect(detectLocalEntityMiss("/project", "黄蓉是谁")).resolves.toBe(true)
+    await expect(detectLocalEntityMiss("/project", "郭靖的性格")).resolves.toBe(false)
+  })
+})

+ 63 - 0
src/lib/novel/local-entity-names.ts

@@ -0,0 +1,63 @@
+import { listDirectory } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode } from "@/types/wiki"
+
+const ENTITY_DIRS = ["wiki/entities", "wiki/characters", "wiki/concepts"] as const
+const MIN_NAME_LENGTH = 2
+
+function flattenMarkdownFiles(nodes: readonly FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir && node.children) {
+      files.push(...flattenMarkdownFiles(node.children))
+      continue
+    }
+    if (!node.is_dir && node.name.toLowerCase().endsWith(".md")) {
+      files.push(node)
+    }
+  }
+  return files
+}
+
+/**
+ * 列出本地实体相关目录下的 .md 文件名(去扩展名),不读正文。
+ */
+export async function listLocalEntityNames(projectPath: string): Promise<string[]> {
+  const pp = normalizePath(projectPath)
+  const names = new Set<string>()
+
+  await Promise.all(
+    ENTITY_DIRS.map(async (relativeDir) => {
+      try {
+        const nodes = await listDirectory(`${pp}/${relativeDir}`)
+        for (const file of flattenMarkdownFiles(nodes)) {
+          const name = file.name.replace(/\.md$/i, "").trim()
+          if (name.length >= MIN_NAME_LENGTH) names.add(name)
+        }
+      } catch {
+        // 目录不存在时忽略
+      }
+    }),
+  )
+
+  return [...names].sort((a, b) => a.localeCompare(b, "zh-CN"))
+}
+
+/**
+ * 用户消息是否提到任一本地实体名(子串匹配,名称长度 ≥ 2)。
+ */
+export function hasLocalEntityMention(message: string, names: readonly string[]): boolean {
+  if (!message.trim() || names.length === 0) return false
+  return names.some((name) => name.length >= MIN_NAME_LENGTH && message.includes(name))
+}
+
+/**
+ * 人物/设定查询时:本地实体表为空,或消息未命中任何本地实体名。
+ */
+export async function detectLocalEntityMiss(
+  projectPath: string,
+  userMessage: string,
+): Promise<boolean> {
+  const names = await listLocalEntityNames(projectPath)
+  return !hasLocalEntityMention(userMessage, names)
+}

+ 2 - 2
src/lib/novel/task-router.ts

@@ -431,10 +431,10 @@ export function buildTaskDirective(route: TaskRouteResult): string {
     generate_outline: "用户要求生成大纲。请根据题材和规模生成结构化的大纲,包含分卷计划、人物设定、伏笔计划。",
     search_plot: "用户要求搜索剧情。请根据检索结果回答用户关于剧情内容的问题。",
     extract_memory: "用户要求提取章节记忆。请从章节正文中提取结构化信息(摘要、人物、事件、伏笔等)。",
-    character_query: "用户在查询人物信息。请根据已有的人物状态和认知信息回答。",
+    character_query: "用户在查询人物信息。请先用本地工具(load_context / read_memory 等)查人物状态与认知;若本地无对应实体或信息不足,主动调用 web_search,必要时再 read_web_page,再据此回答。",
     foreshadowing_query: "用户在查询伏笔状态。请列出当前所有伏笔及其状态(已埋设/推进中/已回收)。",
     timeline_query: "用户在查询时间线。请根据时间线数据回答当前时间进展。",
-    setting_query: "用户在查询设定信息。请根据正史设定和世界观回答。",
+    setting_query: "用户在查询设定信息。请先用本地工具查正史设定与世界观;若本地无对应实体或信息不足,主动调用 web_search,必要时再 read_web_page,再据此回答。",
     general_chat: "",
     story_framework_generate: "用户要求生成故事框架。请跳转到剧情推演室进行框架生成。",
     multi_agent_simulate: "用户要求多智能体推演。请跳转到剧情推演室进行推演。",