Add conditional web tools to LLM agent

This commit is contained in:
2026-05-03 00:55:56 +09:00
parent 82f98ceb07
commit b28f163217
3 changed files with 185 additions and 3 deletions

View File

@@ -105,6 +105,12 @@ bun run test:llm
- 현재 런타임 설정 조회 - 현재 런타임 설정 조회
- 주요 bun 명령 목록 조회 - 주요 bun 명령 목록 조회
- 간단한 산술식 계산 - 간단한 산술식 계산
- 웹 검색
- URL 본문 읽기
동작 원칙:
- 일반 대화는 로컬 LLM만 답변
- 최신 정보, 뉴스, 사실 확인, 검색 요청일 때만 웹 도구 사용
## Windows용 .env 예시 ## Windows용 .env 예시

View File

@@ -1,5 +1,6 @@
import type { AppConfig } from "../config.js"; import type { AppConfig } from "../config.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import { webFetch, webSearch } from "./web-tools.js";
interface OllamaChatMessage { interface OllamaChatMessage {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
@@ -42,7 +43,7 @@ interface OllamaToolResultMessage {
} }
const SYSTEM_PROMPT = const SYSTEM_PROMPT =
"너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라. 정확한 시간, 설정 확인, 계산이 필요하면 도구를 우선 사용해라. 너는 도구 호출 루프 안에 있으며 필요하면 여러 번 도구를 호출할 수 있다."; "너는 한국어로 짧고 자연스럽게 답하는 로컬 음성 비서다. 사용자의 말에 바로 답하고, 군더더기 없는 1~3문장으로 답해라. 정확한 시간, 설정 확인, 계산이 필요하면 도구를 우선 사용해라. 최신 정보, 오늘/최근 정보, 뉴스, 검색 요청, 사실 확인, 외부 웹페이지 내용이 필요한 경우에만 web_search 와 fetch_url 을 사용해라. 내부 지식만으로 충분한 일반 대화에는 웹 도구를 쓰지 마라. 너는 도구 호출 루프 안에 있으며 필요하면 여러 번 도구를 호출할 수 있다.";
const TOOL_DEFINITIONS: OllamaToolDefinition[] = [ 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 { export class OllamaLlmService {
@@ -159,7 +202,7 @@ export class OllamaLlmService {
} }
for (const call of toolCalls) { for (const call of toolCalls) {
const result = this.executeTool(call); const result = await this.executeTool(call);
this.logger.info("LLM tool call", { this.logger.info("LLM tool call", {
name: call.function.name, name: call.function.name,
arguments: call.function.arguments, arguments: call.function.arguments,
@@ -213,7 +256,7 @@ export class OllamaLlmService {
}; };
} }
private executeTool(call: OllamaToolCall): string { private async executeTool(call: OllamaToolCall): Promise<string> {
switch (call.function.name) { switch (call.function.name) {
case "get_current_time": case "get_current_time":
return JSON.stringify(this.getCurrentTime()); return JSON.stringify(this.getCurrentTime());
@@ -226,6 +269,20 @@ export class OllamaLlmService {
expression: this.getStringArg(call.function.arguments, "expression"), expression: this.getStringArg(call.function.arguments, "expression"),
result: this.evaluateMath(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: default:
return JSON.stringify({ return JSON.stringify({
error: `unknown tool: ${call.function.name}`, error: `unknown tool: ${call.function.name}`,
@@ -294,4 +351,18 @@ export class OllamaLlmService {
} }
return result; return result;
} }
private getNumberArg(args: Record<string, unknown>, 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;
}
} }

105
src/services/web-tools.ts Normal file
View File

@@ -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(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
.replace(/<[^>]+>/g, " ");
}
function decodeEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ");
}
function normalizeWhitespace(text: string): string {
return decodeEntities(text).replace(/\s+/g, " ").trim();
}
function extractTitle(html: string): string {
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
return normalizeWhitespace(match?.[1] ?? "");
}
function extractSearchResults(html: string, maxResults: number): WebSearchResult[] {
const results: WebSearchResult[] = [];
const pattern =
/<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?(?:<a[^>]*class="result__snippet"[^>]*>|<div[^>]*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<WebFetchResult> {
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,
};
}