From b28f163217d68ae3c43c0d37045ddbdbd980797a Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 3 May 2026 00:55:56 +0900 Subject: [PATCH] Add conditional web tools to LLM agent --- README.md | 6 +++ src/services/ollama-llm.ts | 77 +++++++++++++++++++++++++-- src/services/web-tools.ts | 105 +++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 3 deletions(-) create mode 100644 src/services/web-tools.ts diff --git a/README.md b/README.md index 3b83ca2..ff2f0e9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,12 @@ bun run test:llm - 현재 런타임 설정 조회 - 주요 bun 명령 목록 조회 - 간단한 산술식 계산 +- 웹 검색 +- URL 본문 읽기 + +동작 원칙: +- 일반 대화는 로컬 LLM만 답변 +- 최신 정보, 뉴스, 사실 확인, 검색 요청일 때만 웹 도구 사용 ## Windows용 .env 예시 diff --git a/src/services/ollama-llm.ts b/src/services/ollama-llm.ts index 51293ed..8f691d1 100644 --- a/src/services/ollama-llm.ts +++ b/src/services/ollama-llm.ts @@ -1,5 +1,6 @@ import type { AppConfig } from "../config.js"; import type { Logger } from "../logger.js"; +import { webFetch, webSearch } from "./web-tools.js"; interface OllamaChatMessage { role: "system" | "user" | "assistant"; @@ -42,7 +43,7 @@ interface OllamaToolResultMessage { } const SYSTEM_PROMPT = - "너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라. 정확한 시간, 설정 확인, 계산이 필요하면 도구를 우선 사용해라. 너는 도구 호출 루프 안에 있으며 필요하면 여러 번 도구를 호출할 수 있다."; + "너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라. 정확한 시간, 설정 확인, 계산이 필요하면 도구를 우선 사용해라. 최신 정보, 오늘/최근 정보, 뉴스, 검색 요청, 사실 확인, 외부 웹페이지 내용이 필요한 경우에만 web_search 와 fetch_url 을 사용해라. 내부 지식만으로 충분한 일반 대화에는 웹 도구를 쓰지 마라. 너는 도구 호출 루프 안에 있으며 필요하면 여러 번 도구를 호출할 수 있다."; const TOOL_DEFINITIONS: OllamaToolDefinition[] = [ { @@ -95,6 +96,48 @@ const TOOL_DEFINITIONS: OllamaToolDefinition[] = [ }, }, }, + { + type: "function", + function: { + name: "web_search", + description: "웹 검색 결과 제목, URL, 요약을 가져온다. 최신 정보, 뉴스, 사실 확인이 필요할 때만 사용한다.", + parameters: { + type: "object", + required: ["query"], + properties: { + query: { + type: "string", + description: "검색어", + }, + max_results: { + type: "number", + description: "가져올 최대 결과 수. 보통 3~5", + }, + }, + }, + }, + }, + { + type: "function", + function: { + name: "fetch_url", + description: "주어진 URL의 페이지 제목과 본문 텍스트를 읽어온다. 검색 결과 상세 확인에 사용한다.", + parameters: { + type: "object", + required: ["url"], + properties: { + url: { + type: "string", + description: "http 또는 https URL", + }, + max_chars: { + type: "number", + description: "본문에서 가져올 최대 글자 수", + }, + }, + }, + }, + }, ]; export class OllamaLlmService { @@ -159,7 +202,7 @@ export class OllamaLlmService { } for (const call of toolCalls) { - const result = this.executeTool(call); + const result = await this.executeTool(call); this.logger.info("LLM tool call", { name: call.function.name, arguments: call.function.arguments, @@ -213,7 +256,7 @@ export class OllamaLlmService { }; } - private executeTool(call: OllamaToolCall): string { + private async executeTool(call: OllamaToolCall): Promise { switch (call.function.name) { case "get_current_time": return JSON.stringify(this.getCurrentTime()); @@ -226,6 +269,20 @@ export class OllamaLlmService { expression: this.getStringArg(call.function.arguments, "expression"), result: this.evaluateMath(this.getStringArg(call.function.arguments, "expression")), }); + case "web_search": + return JSON.stringify( + await webSearch( + this.getStringArg(call.function.arguments, "query"), + Math.min(5, Math.max(1, Math.trunc(this.getNumberArg(call.function.arguments, "max_results", 4)))), + ), + ); + case "fetch_url": + return JSON.stringify( + await webFetch( + this.getStringArg(call.function.arguments, "url"), + Math.min(10000, Math.max(1000, Math.trunc(this.getNumberArg(call.function.arguments, "max_chars", 6000)))), + ), + ); default: return JSON.stringify({ error: `unknown tool: ${call.function.name}`, @@ -294,4 +351,18 @@ export class OllamaLlmService { } return result; } + + private getNumberArg(args: Record, name: string, fallback: number): number { + const value = args[name]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallback; + } } diff --git a/src/services/web-tools.ts b/src/services/web-tools.ts new file mode 100644 index 0000000..77b3a5b --- /dev/null +++ b/src/services/web-tools.ts @@ -0,0 +1,105 @@ +interface WebSearchResult { + title: string; + url: string; + snippet: string; +} + +interface WebFetchResult { + url: string; + title: string; + content: string; +} + +function stripTags(html: string): string { + return html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " "); +} + +function decodeEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " "); +} + +function normalizeWhitespace(text: string): string { + return decodeEntities(text).replace(/\s+/g, " ").trim(); +} + +function extractTitle(html: string): string { + const match = html.match(/]*>([\s\S]*?)<\/title>/i); + return normalizeWhitespace(match?.[1] ?? ""); +} + +function extractSearchResults(html: string, maxResults: number): WebSearchResult[] { + const results: WebSearchResult[] = []; + const pattern = + /]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:]*class="result__snippet"[^>]*>|]*class="result__snippet"[^>]*>)([\s\S]*?)(?:<\/a>|<\/div>)/gi; + + for (const match of html.matchAll(pattern)) { + const url = match[1]?.trim(); + const title = normalizeWhitespace(stripTags(match[2] ?? "")); + const snippet = normalizeWhitespace(stripTags(match[3] ?? "")); + if (!url || !title) { + continue; + } + results.push({ title, url, snippet }); + if (results.length >= maxResults) { + break; + } + } + + return results; +} + +export async function webSearch(query: string, maxResults = 5): Promise<{ query: string; results: WebSearchResult[] }> { + const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + const response = await fetch(url, { + headers: { + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + }, + }); + + if (!response.ok) { + throw new Error(`web search failed: ${response.status}`); + } + + const html = await response.text(); + const results = extractSearchResults(html, Math.min(Math.max(maxResults, 1), 8)); + return { query, results }; +} + +export async function webFetch(url: string, maxChars = 6000): Promise { + if (!/^https?:\/\//i.test(url)) { + throw new Error("http 또는 https URL만 허용됩니다."); + } + + const response = await fetch(url, { + headers: { + "user-agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + }, + redirect: "follow", + }); + + if (!response.ok) { + throw new Error(`web fetch failed: ${response.status}`); + } + + const html = await response.text(); + const title = extractTitle(html); + const content = normalizeWhitespace(stripTags(html)).slice(0, Math.max(500, maxChars)); + + return { + url, + title, + content, + }; +}