Просмотр исходного кода

refactor: replace DuckDuckGo scraping with provider-native web search

- chatCompletion() now accepts webSearch option
- OpenAI Chat API: uses web_search_options (built-in)
- OpenAI Responses API: uses web_search_preview hosted tool (built-in)
- Anthropic: falls back to regular chat (no native search)
- Remove searchWeb() DuckDuckGo HTML scraping (fragile, URL parsing issues)
- Remove web_search tool from agent loop (native search handles this)
- Keep fetchUrl() for agent loop's web_fetch tool
- Simplify BaseAgent.chatWithSearch() from 50-line tool-calling loop to 1-liner
- Net -132 lines
majx_mac 8 месяцев назад
Родитель
Сommit
870db72b43

+ 10 - 76
packages/core/src/agents/base.ts

@@ -1,6 +1,5 @@
-import type { LLMClient, LLMMessage, LLMResponse, AgentMessage, ToolDefinition } from "../llm/provider.js";
-import { chatCompletion, chatWithTools } from "../llm/provider.js";
-import { searchWeb, fetchUrl } from "../utils/web-search.js";
+import type { LLMClient, LLMMessage, LLMResponse } from "../llm/provider.js";
+import { chatCompletion } from "../llm/provider.js";
 
 export interface AgentContext {
   readonly client: LLMClient;
@@ -9,31 +8,6 @@ export interface AgentContext {
   readonly bookId?: string;
 }
 
-const SEARCH_TOOLS: ReadonlyArray<ToolDefinition> = [
-  {
-    name: "search_web",
-    description: "搜索互联网验证事实。用于核实年代、人物、事件、地理等真实信息。",
-    parameters: {
-      type: "object",
-      properties: {
-        query: { type: "string", description: "搜索关键词" },
-      },
-      required: ["query"],
-    },
-  },
-  {
-    name: "fetch_url",
-    description: "抓取指定URL的文本内容,用于读取搜索结果的详情页。",
-    parameters: {
-      type: "object",
-      properties: {
-        url: { type: "string", description: "要抓取的URL" },
-      },
-      required: ["url"],
-    },
-  },
-];
-
 export abstract class BaseAgent {
   protected readonly ctx: AgentContext;
 
@@ -49,59 +23,19 @@ export abstract class BaseAgent {
   }
 
   /**
-   * Chat with web search tools enabled.
-   * The agent can call search_web and fetch_url during the conversation.
-   * Runs a tool-calling loop (max 5 rounds) until the model stops calling tools.
+   * Chat with provider-native web search enabled.
+   * OpenAI Chat API: uses web_search_options.
+   * OpenAI Responses API: uses web_search_preview hosted tool.
+   * Anthropic: falls back to regular chat (no native search).
    */
   protected async chatWithSearch(
     messages: ReadonlyArray<LLMMessage>,
     options?: { readonly temperature?: number; readonly maxTokens?: number },
   ): Promise<LLMResponse> {
-    const agentMessages: AgentMessage[] = messages.map(m => ({
-      role: m.role,
-      content: m.content,
-    }));
-
-    const maxRounds = 5;
-    for (let round = 0; round < maxRounds; round++) {
-      const result = await chatWithTools(
-        this.ctx.client, this.ctx.model, agentMessages, SEARCH_TOOLS, options,
-      );
-
-      agentMessages.push({
-        role: "assistant" as const,
-        content: result.content || null,
-        ...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
-      });
-
-      if (result.toolCalls.length === 0) {
-        return { content: result.content, usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 } };
-      }
-
-      for (const toolCall of result.toolCalls) {
-        const args = JSON.parse(toolCall.arguments) as Record<string, unknown>;
-        let toolResult: string;
-        try {
-          if (toolCall.name === "search_web") {
-            const searchResult = await searchWeb(args.query as string);
-            toolResult = JSON.stringify(searchResult);
-          } else if (toolCall.name === "fetch_url") {
-            const text = await fetchUrl(args.url as string, 6000);
-            toolResult = JSON.stringify({ url: args.url, content: text });
-          } else {
-            toolResult = JSON.stringify({ error: `Unknown tool: ${toolCall.name}` });
-          }
-        } catch (e) {
-          toolResult = JSON.stringify({ error: String(e) });
-        }
-        agentMessages.push({ role: "tool" as const, toolCallId: toolCall.id, content: toolResult });
-      }
-    }
-
-    // If we exhausted rounds, return the last assistant content
-    const lastAssistant = [...agentMessages].reverse().find(m => m.role === "assistant");
-    const content = (lastAssistant && "content" in lastAssistant && lastAssistant.content) || "";
-    return { content, usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 } };
+    return chatCompletion(this.ctx.client, this.ctx.model, messages, {
+      ...options,
+      webSearch: true,
+    });
   }
 
   abstract get name(): string;

+ 1 - 1
packages/core/src/index.ts

@@ -29,7 +29,7 @@ export { analyzeDetectionInsights } from "./agents/detection-insights.js";
 export { validatePostWrite, type PostWriteViolation } from "./agents/post-write-validator.js";
 
 // Utils
-export { searchWeb, fetchUrl, type SearchResult } from "./utils/web-search.js";
+export { fetchUrl } from "./utils/web-search.js";
 
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";

+ 11 - 2
packages/core/src/llm/provider.ts

@@ -120,6 +120,7 @@ export async function chatCompletion(
   options?: {
     readonly temperature?: number;
     readonly maxTokens?: number;
+    readonly webSearch?: boolean;
   },
 ): Promise<LLMResponse> {
   try {
@@ -131,9 +132,9 @@ export async function chatCompletion(
       return await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
     }
     if (client.apiFormat === "responses") {
-      return await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved);
+      return await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch);
     }
-    return await chatCompletionOpenAIChat(client._openai!, model, messages, resolved);
+    return await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch);
   } catch (error) {
     throw wrapLLMError(error);
   }
@@ -175,6 +176,7 @@ async function chatCompletionOpenAIChat(
   model: string,
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
+  webSearch?: boolean,
 ): Promise<LLMResponse> {
   const stream = await client.chat.completions.create({
     model,
@@ -185,6 +187,7 @@ async function chatCompletionOpenAIChat(
     temperature: options.temperature,
     max_tokens: options.maxTokens,
     stream: true,
+    ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
   });
 
   const chunks: string[] = [];
@@ -313,18 +316,24 @@ async function chatCompletionOpenAIResponses(
   model: string,
   messages: ReadonlyArray<LLMMessage>,
   options: { readonly temperature: number; readonly maxTokens: number },
+  webSearch?: boolean,
 ): Promise<LLMResponse> {
   const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
     role: m.role as "system" | "user" | "assistant",
     content: m.content,
   }));
 
+  const tools: OpenAI.Responses.Tool[] | undefined = webSearch
+    ? [{ type: "web_search_preview" as const }]
+    : undefined;
+
   const stream = await client.responses.create({
     model,
     input,
     temperature: options.temperature,
     max_output_tokens: options.maxTokens,
     stream: true,
+    ...(tools ? { tools } : {}),
   });
 
   const chunks: string[] = [];

+ 2 - 21
packages/core/src/pipeline/agent.ts

@@ -106,17 +106,6 @@ const TOOLS: ReadonlyArray<ToolDefinition> = [
       required: ["bookId"],
     },
   },
-  {
-    name: "web_search",
-    description: "搜索互联网。用于查找题材相关资料、避免套路化、获取灵感。",
-    parameters: {
-      type: "object",
-      properties: {
-        query: { type: "string", description: "搜索关键词" },
-      },
-      required: ["query"],
-    },
-  },
   {
     name: "web_fetch",
     description: "抓取指定URL的文本内容。用于读取搜索结果中的详细页面。",
@@ -185,11 +174,10 @@ export async function runAgentLoop(
 | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) |
 | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) |
 | write_draft | 写一章草稿(自动加载 genre profile + book_rules) |
-| audit_chapter | 审计章节(27维度,按题材条件启用,含AI痕迹+敏感词检测) |
+| audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) |
 | revise_chapter | 修订章节(支持 polish/rewrite/rework/spot-fix/anti-detect 五种模式) |
 | write_full_pipeline | 完整管线:写 → 审 → 改(如需要) |
 | scan_market | 扫描平台排行榜,分析市场趋势 |
-| web_search | 搜索互联网,获取题材资料或灵感 |
 | web_fetch | 抓取指定URL的文本内容 |
 | import_style | 从参考文本生成文风指南(统计+LLM分析) |
 | import_canon | 从正传导入正典参照,启用番外模式 |
@@ -217,8 +205,7 @@ export async function runAgentLoop(
 - 用户说了书名/bookId → 直接操作,不需要先 list_books
 - 每完成一步,简要汇报进展
 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照
-- 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft
-- web_search 可用于建书前调研题材、查找避免套路化的灵感`,
+- 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft`,
     },
     { role: "user", content: instruction },
   ];
@@ -367,12 +354,6 @@ async function executeTool(
       return JSON.stringify(results);
     }
 
-    case "web_search": {
-      const { searchWeb } = await import("../utils/web-search.js");
-      const result = await searchWeb(args.query as string);
-      return JSON.stringify(result);
-    }
-
     case "web_fetch": {
       const { fetchUrl } = await import("../utils/web-search.js");
       const text = await fetchUrl(args.url as string, (args.maxChars as number) ?? 8000);

+ 5 - 61
packages/core/src/utils/web-search.ts

@@ -1,67 +1,11 @@
 /**
- * Web search and URL fetch utilities for agent loop and architect.
+ * URL fetch utility for agent loop.
+ *
+ * Web search is handled natively by the LLM provider (OpenAI web_search_preview
+ * or web_search_options). This module only provides URL fetching for cases where
+ * the agent loop needs to read a specific page.
  */
 
-export interface SearchResult {
-  readonly query: string;
-  readonly results: ReadonlyArray<{ readonly title: string; readonly snippet: string; readonly url: string }>;
-}
-
-/**
- * Search the web using DuckDuckGo HTML endpoint.
- * Returns up to 8 result snippets.
- */
-export async function searchWeb(query: string): Promise<SearchResult> {
-  const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
-
-  const res = await fetch(url, {
-    headers: {
-      "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
-      "Accept": "text/html",
-    },
-    signal: AbortSignal.timeout(15000),
-  });
-
-  if (!res.ok) {
-    throw new Error(`Search failed: ${res.status} ${res.statusText}`);
-  }
-
-  const html = await res.text();
-  const results: Array<{ title: string; snippet: string; url: string }> = [];
-
-  // Parse DuckDuckGo HTML results
-  const resultBlocks = html.split(/class="result\s/g).slice(1, 9);
-
-  for (const block of resultBlocks) {
-    const titleMatch = block.match(/class="result__a"[^>]*>([\s\S]*?)<\/a>/);
-    const snippetMatch = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
-    // Extract href from the result__a anchor (contains the real URL with protocol)
-    const hrefMatch = block.match(/class="result__a"\s+href="([^"]*)"/);
-
-    const title = titleMatch?.[1]?.replace(/<[^>]*>/g, "").trim() ?? "";
-    const snippet = snippetMatch?.[1]?.replace(/<[^>]*>/g, "").trim() ?? "";
-    let resultUrl = "";
-
-    if (hrefMatch?.[1]) {
-      // DuckDuckGo wraps URLs in a redirect: //duckduckgo.com/l/?uddg=<encoded>&rut=<hash>
-      const href = hrefMatch[1];
-      const uddgMatch = href.match(/[?&]uddg=([^&]+)/);
-      resultUrl = uddgMatch ? decodeURIComponent(uddgMatch[1]) : href;
-    }
-
-    // Ensure URL has protocol prefix
-    if (resultUrl && !resultUrl.startsWith("http")) {
-      resultUrl = `https://${resultUrl}`;
-    }
-
-    if (title) {
-      results.push({ title, snippet, url: resultUrl });
-    }
-  }
-
-  return { query, results };
-}
-
 /**
  * Fetch a URL and return its text content.
  * HTML is stripped to plain text. Output is truncated to maxChars.