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