Add Brave web search support
This commit is contained in:
@@ -14,7 +14,8 @@ from openai import AsyncOpenAI
|
||||
|
||||
from app.ai.parser import AIInvocation
|
||||
from app.ai.prompts import PromptBook
|
||||
from app.ai.tools import FILE_TOOL_DISPATCH, FILE_TOOL_SCHEMAS, PYTHON_EXEC_SCHEMA
|
||||
from app.ai.tools import FILE_TOOL_DISPATCH, FILE_TOOL_SCHEMAS, PYTHON_EXEC_SCHEMA, WEB_SEARCH_SCHEMA
|
||||
from app.ai.web_search import BraveWebSearchClient, format_results
|
||||
from app.config import AIConfig, AIModelConfig, AIProviderConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -62,6 +63,7 @@ class AIService:
|
||||
self.prompt_book = PromptBook(config.prompt.root)
|
||||
self._clients: dict[str, AsyncOpenAI] = {}
|
||||
self._system_prompt: str | None = None
|
||||
self.web_search = BraveWebSearchClient(config.web_search)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
return None
|
||||
@@ -210,7 +212,10 @@ class AIService:
|
||||
return messages
|
||||
|
||||
async def _build_tool_list(self) -> list[dict]:
|
||||
return FILE_TOOL_SCHEMAS + [PYTHON_EXEC_SCHEMA]
|
||||
tools = FILE_TOOL_SCHEMAS + [PYTHON_EXEC_SCHEMA]
|
||||
if self.web_search.available:
|
||||
tools.append(WEB_SEARCH_SCHEMA)
|
||||
return tools
|
||||
|
||||
def _get_allowed_roots(self) -> tuple[Path, ...]:
|
||||
# [0] = base for resolution (project root); [1:] = permitted subdirs
|
||||
@@ -339,6 +344,9 @@ class AIService:
|
||||
try:
|
||||
if name == "python_exec":
|
||||
result = await self._execute_python_exec(arguments, ctx)
|
||||
elif name == "web_search":
|
||||
response = await self.web_search.search(arguments)
|
||||
result = format_results(response, include_heading=True)
|
||||
elif name == "send_file":
|
||||
result = await self._execute_send_file(arguments, ctx, allowed_roots)
|
||||
elif name in FILE_TOOL_DISPATCH:
|
||||
|
||||
@@ -219,6 +219,60 @@ PYTHON_EXEC_SCHEMA: dict[str, Any] = {
|
||||
},
|
||||
}
|
||||
|
||||
WEB_SEARCH_SCHEMA: dict[str, Any] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": (
|
||||
"Search the web with Brave Search for current or source-backed information. "
|
||||
"Returns result titles, URLs, snippets, dates when available, and optional extra snippets."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query, max 400 characters / 50 words.",
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of web results to return, 1-20.",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": "Two-letter result country code, e.g. US, PL, DE.",
|
||||
},
|
||||
"search_lang": {
|
||||
"type": "string",
|
||||
"description": "Search language code, e.g. en, pl, de.",
|
||||
},
|
||||
"ui_lang": {
|
||||
"type": "string",
|
||||
"description": "UI language for response metadata, e.g. en-US.",
|
||||
},
|
||||
"safesearch": {
|
||||
"type": "string",
|
||||
"enum": ["off", "moderate", "strict"],
|
||||
"description": "Adult content filtering level.",
|
||||
},
|
||||
"freshness": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional recency filter: pd, pw, pm, py, or YYYY-MM-DDtoYYYY-MM-DD."
|
||||
),
|
||||
},
|
||||
"extra_snippets": {
|
||||
"type": "boolean",
|
||||
"description": "Include additional excerpts per result when available.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ToolExecutor = Callable[[dict, tuple[Path, ...]], Awaitable[str]]
|
||||
|
||||
FILE_TOOL_DISPATCH: dict[str, ToolExecutor] = {
|
||||
|
||||
244
app/ai/web_search.py
Normal file
244
app/ai/web_search.py
Normal file
@@ -0,0 +1,244 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import AIWebSearchConfig
|
||||
|
||||
SAFESEARCH_VALUES = {"off", "moderate", "strict"}
|
||||
FRESHNESS_RE = re.compile(r"^(pd|pw|pm|py|\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2})$")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebSearchQuery:
|
||||
query: str
|
||||
count: int
|
||||
offset: int
|
||||
country: str
|
||||
search_lang: str
|
||||
ui_lang: str
|
||||
safesearch: str
|
||||
freshness: str | None = None
|
||||
extra_snippets: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebSearchResult:
|
||||
title: str
|
||||
url: str
|
||||
description: str = ""
|
||||
age: str = ""
|
||||
page_age: str = ""
|
||||
extra_snippets: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebSearchResponse:
|
||||
query: str
|
||||
results: list[WebSearchResult]
|
||||
altered_query: str | None = None
|
||||
more_results_available: bool = False
|
||||
|
||||
|
||||
def clamp_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
parsed = default
|
||||
return min(max(parsed, minimum), maximum)
|
||||
|
||||
|
||||
def sanitize_query(query: str) -> str:
|
||||
normalized = " ".join(str(query).split())
|
||||
if not normalized:
|
||||
raise ValueError("web_search: 'query' is required")
|
||||
words = normalized.split()
|
||||
if len(words) > 50:
|
||||
normalized = " ".join(words[:50])
|
||||
if len(normalized) > 400:
|
||||
normalized = normalized[:400].rstrip()
|
||||
if not normalized:
|
||||
raise ValueError("web_search: 'query' is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_safesearch(value: Any, *, default: str) -> str:
|
||||
safesearch = str(value or default).strip().lower()
|
||||
if safesearch not in SAFESEARCH_VALUES:
|
||||
raise ValueError("web_search: 'safesearch' must be one of: off, moderate, strict")
|
||||
return safesearch
|
||||
|
||||
|
||||
def normalize_freshness(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
freshness = str(value).strip()
|
||||
if not freshness:
|
||||
return None
|
||||
if not FRESHNESS_RE.match(freshness):
|
||||
raise ValueError(
|
||||
"web_search: 'freshness' must be pd, pw, pm, py, or YYYY-MM-DDtoYYYY-MM-DD"
|
||||
)
|
||||
return freshness
|
||||
|
||||
|
||||
def build_query(config: AIWebSearchConfig, arguments: dict[str, Any]) -> WebSearchQuery:
|
||||
return WebSearchQuery(
|
||||
query=sanitize_query(str(arguments.get("query") or arguments.get("q") or "")),
|
||||
count=clamp_int(arguments.get("count"), default=config.count, minimum=1, maximum=20),
|
||||
offset=clamp_int(arguments.get("offset"), default=0, minimum=0, maximum=9),
|
||||
country=str(arguments.get("country") or config.country).strip().upper() or config.country,
|
||||
search_lang=str(arguments.get("search_lang") or config.search_lang).strip().lower()
|
||||
or config.search_lang,
|
||||
ui_lang=str(arguments.get("ui_lang") or config.ui_lang).strip() or config.ui_lang,
|
||||
safesearch=normalize_safesearch(arguments.get("safesearch"), default=config.safesearch),
|
||||
freshness=normalize_freshness(arguments.get("freshness")),
|
||||
extra_snippets=bool(arguments.get("extra_snippets", config.extra_snippets)),
|
||||
)
|
||||
|
||||
|
||||
def parse_response(query: str, payload: dict[str, Any]) -> WebSearchResponse:
|
||||
raw_query = payload.get("query") if isinstance(payload.get("query"), dict) else {}
|
||||
raw_web = payload.get("web") if isinstance(payload.get("web"), dict) else {}
|
||||
raw_results = raw_web.get("results") if isinstance(raw_web.get("results"), list) else []
|
||||
|
||||
results: list[WebSearchResult] = []
|
||||
for item in raw_results:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or "").strip()
|
||||
url = str(item.get("url") or "").strip()
|
||||
if not title and not url:
|
||||
continue
|
||||
snippets = item.get("extra_snippets")
|
||||
extra_snippets = [
|
||||
str(snippet).strip()
|
||||
for snippet in snippets
|
||||
if isinstance(snippet, str) and snippet.strip()
|
||||
] if isinstance(snippets, list) else []
|
||||
results.append(
|
||||
WebSearchResult(
|
||||
title=title or url,
|
||||
url=url,
|
||||
description=str(item.get("description") or "").strip(),
|
||||
age=str(item.get("age") or "").strip(),
|
||||
page_age=str(item.get("page_age") or "").strip(),
|
||||
extra_snippets=extra_snippets,
|
||||
)
|
||||
)
|
||||
|
||||
altered = raw_query.get("altered") if isinstance(raw_query, dict) else None
|
||||
return WebSearchResponse(
|
||||
query=query,
|
||||
results=results,
|
||||
altered_query=str(altered).strip() if altered else None,
|
||||
more_results_available=bool(raw_query.get("more_results_available", False))
|
||||
if isinstance(raw_query, dict)
|
||||
else False,
|
||||
)
|
||||
|
||||
|
||||
def format_results(response: WebSearchResponse, *, include_heading: bool = True) -> str:
|
||||
lines: list[str] = []
|
||||
if include_heading:
|
||||
heading = f"Search results for: {response.query}"
|
||||
if response.altered_query:
|
||||
heading += f" (spellchecked: {response.altered_query})"
|
||||
lines.append(heading)
|
||||
|
||||
if not response.results:
|
||||
lines.append("No results found.")
|
||||
return "\n".join(lines)
|
||||
|
||||
for index, result in enumerate(response.results, start=1):
|
||||
lines.append(f"{index}. {result.title}")
|
||||
if result.url:
|
||||
lines.append(result.url)
|
||||
meta = result.age or result.page_age
|
||||
if meta:
|
||||
lines.append(f"Date: {meta}")
|
||||
if result.description:
|
||||
lines.append(result.description)
|
||||
for snippet in result.extra_snippets[:5]:
|
||||
lines.append(f"- {snippet}")
|
||||
lines.append("")
|
||||
|
||||
if not lines[-1]:
|
||||
lines.pop()
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class BraveWebSearchClient:
|
||||
def __init__(self, config: AIWebSearchConfig) -> None:
|
||||
self.config = config
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.config.available
|
||||
|
||||
async def search(self, arguments: dict[str, Any]) -> WebSearchResponse:
|
||||
if not self.config.enabled:
|
||||
raise ValueError("Brave web search is disabled")
|
||||
if not self.config.api_key:
|
||||
raise ValueError("Brave web search is not configured. Set BRAVE_API_KEY.")
|
||||
|
||||
query = build_query(self.config, arguments)
|
||||
params: dict[str, Any] = {
|
||||
"q": query.query,
|
||||
"count": query.count,
|
||||
"offset": query.offset,
|
||||
"country": query.country,
|
||||
"search_lang": query.search_lang,
|
||||
"ui_lang": query.ui_lang,
|
||||
"safesearch": query.safesearch,
|
||||
"text_decorations": False,
|
||||
"spellcheck": True,
|
||||
}
|
||||
if query.freshness:
|
||||
params["freshness"] = query.freshness
|
||||
if query.extra_snippets:
|
||||
params["extra_snippets"] = True
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip",
|
||||
"X-Subscription-Token": self.config.api_key,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.config.timeout) as client:
|
||||
response = await client.get(self.config.endpoint, params=params, headers=headers)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ValueError(f"Brave web search request failed: {exc}") from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise ValueError(_format_error_response(response))
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError as exc:
|
||||
raise ValueError("Brave web search returned invalid JSON") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Brave web search returned an unexpected response")
|
||||
|
||||
return parse_response(query.query, payload)
|
||||
|
||||
|
||||
def _format_error_response(response: httpx.Response) -> str:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return f"Brave web search failed with HTTP {response.status_code}: {response.text[:200]}"
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = error.get("detail") or error.get("message") or error.get("code")
|
||||
if detail:
|
||||
return f"Brave web search failed with HTTP {response.status_code}: {detail}"
|
||||
message = payload.get("message")
|
||||
if message:
|
||||
return f"Brave web search failed with HTTP {response.status_code}: {message}"
|
||||
return f"Brave web search failed with HTTP {response.status_code}"
|
||||
Reference in New Issue
Block a user