Add conditional web tools to LLM agent
This commit is contained in:
@@ -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<string> {
|
||||
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<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
105
src/services/web-tools.ts
Normal 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(/&/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(/<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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user