Initial AI search agent
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
JINA_API_KEY=
|
||||
LLM_API_KEY=
|
||||
LLM_API_STYLE=openai_chat
|
||||
LLM_ENDPOINT=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
|
||||
MODEL_NAME=qwen3.5-flash
|
||||
SEARCH_PROVIDER=bing_html
|
||||
TOP_K_PAGES=5
|
||||
MAX_PAGE_CHARS=5000
|
||||
MAX_CONCURRENCY=5
|
||||
SEARCH_TIMEOUT=20
|
||||
READER_TIMEOUT=25
|
||||
MODEL_TIMEOUT=90
|
||||
RETRY_ATTEMPTS=2
|
||||
AUDIT_LOG_PATH=logs/audit.jsonl
|
||||
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
@@ -0,0 +1,127 @@
|
||||
# AI Search Agent
|
||||
|
||||
独立实现的 AI 搜索 Agent,不复用仓库里原有的 `search_agent`。
|
||||
这个版本按“较完整的最佳实践骨架”组织,不是最小脚本:
|
||||
|
||||
1. 用 `Bing HTML` 做网页搜索
|
||||
2. 用 `r.jina.ai` 并发深读前几条结果
|
||||
3. 用可切换的 LLM 生成结构化回答
|
||||
4. 暴露 CLI 和 FastAPI 两种入口
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
ai_search_agent/
|
||||
├── ai_search_agent/
|
||||
│ ├── agent.py
|
||||
│ ├── api.py
|
||||
│ ├── config.py
|
||||
│ ├── jina.py
|
||||
│ ├── llm_client.py
|
||||
│ ├── models.py
|
||||
│ ├── parsers.py
|
||||
│ ├── prompts.py
|
||||
│ └── __init__.py
|
||||
├── .env.example
|
||||
├── tests/
|
||||
│ └── test_parsers.py
|
||||
├── main.py
|
||||
├── README.md
|
||||
├── serve.py
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
```bash
|
||||
export JINA_API_KEY=your-jina-key
|
||||
export LLM_API_KEY=your-llm-key
|
||||
export LLM_API_STYLE=openai_chat
|
||||
export LLM_ENDPOINT=https://dashscope-intl.aliyuncs.com/compatible-mode/v1
|
||||
export MODEL_NAME=qwen3.5-flash
|
||||
export SEARCH_PROVIDER=bing_html
|
||||
export TOP_K_PAGES=5
|
||||
export MAX_PAGE_CHARS=5000
|
||||
export MAX_CONCURRENCY=5
|
||||
export SEARCH_TIMEOUT=20
|
||||
export READER_TIMEOUT=25
|
||||
export MODEL_TIMEOUT=90
|
||||
export RETRY_ATTEMPTS=2
|
||||
export AUDIT_LOG_PATH=logs/audit.jsonl
|
||||
```
|
||||
|
||||
如果切回 xAI:
|
||||
|
||||
```bash
|
||||
export JINA_API_KEY=your-jina-key
|
||||
export LLM_API_KEY=your-xai-key
|
||||
export LLM_API_STYLE=xai_responses
|
||||
export MODEL_NAME=grok-4.20-multi-agent-0309
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
cd ai_search_agent
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
python main.py "PydanticAI 适合哪些场景?"
|
||||
```
|
||||
|
||||
输出 JSON:
|
||||
|
||||
```bash
|
||||
python main.py "PydanticAI 适合哪些场景?" --json
|
||||
```
|
||||
|
||||
## 启动 API 服务
|
||||
|
||||
```bash
|
||||
uvicorn serve:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
请求示例:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8080/search \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query":"PydanticAI 适合哪些场景?","top_k_pages":4}'
|
||||
```
|
||||
|
||||
## 设计说明
|
||||
|
||||
- `SearchClient.search()` 固定走 `bing_html`
|
||||
- `JinaClient.read_pages()` 只负责通过 `r.jina.ai` 并发抓取页面内容,并带重试
|
||||
- `LLMClient.answer()` 支持 `xai_responses` 和 `openai_chat` 两种模式
|
||||
- 每次请求都会生成 `audit_id`,并把审计记录落到 `logs/audit.jsonl`
|
||||
- 模型输出约束为 JSON 形状,解析失败时退化为纯文本答案
|
||||
- `AISearchAgent.run()` 统一编排搜索、抓取、归纳和来源回填
|
||||
- `create_app()` 复用同一套业务链路,不重复实现 API 逻辑
|
||||
|
||||
## 返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "PydanticAI 适合哪些场景?",
|
||||
"answer": {
|
||||
"summary": "...",
|
||||
"key_points": ["..."],
|
||||
"caveats": ["..."],
|
||||
"citations": [{"title": "...", "url": "https://..."}]
|
||||
},
|
||||
"search_results": [],
|
||||
"pages": []
|
||||
}
|
||||
```
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 还没做 SSE 流式输出
|
||||
- 还没做多轮搜索规划和反思
|
||||
- Bing HTML 解析依赖当前结果页 DOM 结构
|
||||
@@ -0,0 +1 @@
|
||||
"""Standalone AI search agent built on Jina + xAI."""
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import Settings
|
||||
from .jina import JinaClient
|
||||
from .llm_client import LLMClient
|
||||
from .models import AnswerPayload, Citation, PageContent, SearchRequest, SearchResponse, SearchResult
|
||||
from .prompts import SYSTEM_PROMPT
|
||||
from .search_client import SearchClient
|
||||
|
||||
|
||||
class AISearchAgent:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.jina = JinaClient(settings)
|
||||
self.llm = LLMClient(settings)
|
||||
self.search = SearchClient(settings)
|
||||
|
||||
async def run(self, request: SearchRequest) -> SearchResponse:
|
||||
top_k_pages = request.top_k_pages or self.settings.top_k_pages
|
||||
max_page_chars = request.max_page_chars or self.settings.max_page_chars
|
||||
|
||||
raw_search_text, search_results = await self.search.search(request.query)
|
||||
selected_results = search_results[: max(top_k_pages * 3, top_k_pages)]
|
||||
pages = await self.jina.read_pages(selected_results, max_page_chars=max_page_chars)
|
||||
usable_pages = [page for page in pages if page.fetched and page.usable]
|
||||
|
||||
if not usable_pages:
|
||||
return SearchResponse(
|
||||
query=request.query,
|
||||
answer=AnswerPayload(
|
||||
summary="未读取到可用正文内容,已拒绝调用模型总结。",
|
||||
key_points=[],
|
||||
caveats=[
|
||||
"所有候选页面都未能提取到有效正文,可能是 403、451、反爬或页面过短。",
|
||||
"请更换查询词,或增加可抓取来源。",
|
||||
],
|
||||
citations=[],
|
||||
),
|
||||
search_results=selected_results,
|
||||
pages=pages,
|
||||
content_ready=False,
|
||||
llm_called=False,
|
||||
error="NO_USABLE_CONTENT",
|
||||
)
|
||||
|
||||
pages_for_model = usable_pages[:top_k_pages]
|
||||
results_for_model = [
|
||||
result for result in selected_results if any(page.url == result.url for page in pages_for_model)
|
||||
]
|
||||
answer = await self.llm.answer(
|
||||
SYSTEM_PROMPT,
|
||||
self._build_user_prompt(request.query, raw_search_text, results_for_model, pages_for_model),
|
||||
allowed_urls={page.url for page in pages_for_model if page.fetched},
|
||||
)
|
||||
|
||||
if not answer.citations:
|
||||
answer = self._backfill_citations(answer, results_for_model)
|
||||
|
||||
return SearchResponse(
|
||||
query=request.query,
|
||||
answer=answer,
|
||||
search_results=results_for_model,
|
||||
pages=pages,
|
||||
content_ready=True,
|
||||
llm_called=True,
|
||||
)
|
||||
|
||||
def _build_user_prompt(
|
||||
self,
|
||||
query: str,
|
||||
raw_search_text: str,
|
||||
search_results: list[SearchResult],
|
||||
pages: list[PageContent],
|
||||
) -> str:
|
||||
result_lines: list[str] = []
|
||||
for result in search_results:
|
||||
result_lines.append(f"[Result {result.rank}] {result.title or 'Untitled'}")
|
||||
result_lines.append(f"URL: {result.url}")
|
||||
if result.snippet:
|
||||
result_lines.append(f"Snippet: {result.snippet}")
|
||||
|
||||
page_lines: list[str] = []
|
||||
for index, page in enumerate(pages, start=1):
|
||||
page_lines.append(f"[Page {index}] {page.title or page.url}")
|
||||
page_lines.append(f"URL: {page.url}")
|
||||
if page.fetched:
|
||||
page_lines.append(page.content)
|
||||
else:
|
||||
page_lines.append(f"Fetch error: {page.error}")
|
||||
|
||||
return f"""User query:
|
||||
{query}
|
||||
|
||||
Search digest:
|
||||
{raw_search_text}
|
||||
|
||||
Selected search results:
|
||||
{chr(10).join(result_lines)}
|
||||
|
||||
Fetched page excerpts:
|
||||
{chr(10).join(page_lines)}
|
||||
"""
|
||||
|
||||
def _backfill_citations(
|
||||
self,
|
||||
answer: AnswerPayload,
|
||||
search_results: list[SearchResult],
|
||||
) -> AnswerPayload:
|
||||
fallback = [
|
||||
Citation(title=item.title or item.url, url=item.url)
|
||||
for item in search_results[:3]
|
||||
]
|
||||
return answer.model_copy(update={"citations": fallback})
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .agent import AISearchAgent
|
||||
from .audit import AuditLogger
|
||||
from .config import Settings
|
||||
from .models import SearchRequest
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = Settings.from_env()
|
||||
agent = AISearchAgent(settings)
|
||||
audit_logger = AuditLogger(settings.audit_log_path)
|
||||
app = FastAPI(title="AI Search Agent", version="1.0.0")
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/search")
|
||||
async def search(request: SearchRequest) -> JSONResponse:
|
||||
response = await agent.run(request)
|
||||
audit_id = audit_logger.write(request, response)
|
||||
response = response.model_copy(update={"audit_id": audit_id})
|
||||
return JSONResponse(content=response.model_dump())
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from .models import SearchRequest, SearchResponse
|
||||
|
||||
|
||||
class AuditLogger:
|
||||
def __init__(self, log_path: str | Path):
|
||||
self.log_path = Path(log_path)
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def write(self, request: SearchRequest, response: SearchResponse) -> str:
|
||||
audit_id = uuid4().hex
|
||||
record = {
|
||||
"audit_id": audit_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"request": request.model_dump(),
|
||||
"response_summary": {
|
||||
"content_ready": response.content_ready,
|
||||
"llm_called": response.llm_called,
|
||||
"error": response.error,
|
||||
"search_result_count": len(response.search_results),
|
||||
"page_count": len(response.pages),
|
||||
"usable_page_count": len([p for p in response.pages if p.usable]),
|
||||
"citation_count": len(response.answer.citations),
|
||||
},
|
||||
"search_results": [
|
||||
{
|
||||
"rank": item.rank,
|
||||
"title": item.title,
|
||||
"url": item.url,
|
||||
}
|
||||
for item in response.search_results
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"url": item.url,
|
||||
"title": item.title,
|
||||
"fetched": item.fetched,
|
||||
"usable": item.usable,
|
||||
"error": item.error,
|
||||
}
|
||||
for item in response.pages
|
||||
],
|
||||
}
|
||||
with self.log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
return audit_id
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Settings(BaseModel):
|
||||
jina_api_key: str
|
||||
llm_api_key: str
|
||||
llm_model: str = "qwen3.5-flash"
|
||||
llm_api_style: str = "openai_chat"
|
||||
search_provider: str = "bing_html"
|
||||
bing_search_url: str = "https://www.bing.com/search"
|
||||
reader_base_url: str = "https://r.jina.ai/"
|
||||
llm_endpoint: str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
|
||||
search_timeout: float = 20.0
|
||||
reader_timeout: float = 25.0
|
||||
model_timeout: float = 90.0
|
||||
top_k_pages: int = Field(default=5, ge=1, le=10)
|
||||
max_page_chars: int = Field(default=5000, ge=500, le=20000)
|
||||
max_concurrency: int = Field(default=5, ge=1, le=20)
|
||||
retry_attempts: int = Field(default=2, ge=0, le=5)
|
||||
audit_log_path: str = "logs/audit.jsonl"
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
load_dotenv()
|
||||
jina_api_key = os.getenv("JINA_API_KEY", "").strip()
|
||||
llm_api_key = (
|
||||
os.getenv("LLM_API_KEY", "").strip()
|
||||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||||
or os.getenv("XAI_API_KEY", "").strip()
|
||||
)
|
||||
|
||||
if not jina_api_key:
|
||||
raise ValueError("Missing JINA_API_KEY")
|
||||
if not llm_api_key:
|
||||
raise ValueError("Missing LLM_API_KEY")
|
||||
|
||||
llm_api_style = os.getenv("LLM_API_STYLE", "openai_chat").strip()
|
||||
llm_model = (
|
||||
os.getenv("MODEL_NAME", "").strip()
|
||||
or os.getenv("LLM_MODEL", "").strip()
|
||||
or os.getenv("XAI_MODEL", "qwen3.5-flash").strip()
|
||||
)
|
||||
llm_endpoint = (
|
||||
os.getenv("LLM_ENDPOINT", "").strip()
|
||||
or os.getenv("OPENAI_BASE_URL", "").strip()
|
||||
or os.getenv("LLM_BASE_URL", "").strip()
|
||||
)
|
||||
|
||||
if llm_api_style == "openai_chat":
|
||||
llm_endpoint = llm_endpoint or "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
if not llm_endpoint.endswith("/chat/completions"):
|
||||
llm_endpoint = llm_endpoint.rstrip("/") + "/chat/completions"
|
||||
else:
|
||||
llm_endpoint = llm_endpoint or "https://api.x.ai/v1/responses"
|
||||
|
||||
return cls(
|
||||
jina_api_key=jina_api_key,
|
||||
llm_api_key=llm_api_key,
|
||||
llm_model=llm_model,
|
||||
llm_api_style=llm_api_style,
|
||||
search_provider=os.getenv("SEARCH_PROVIDER", "bing_html").strip(),
|
||||
llm_endpoint=llm_endpoint,
|
||||
search_timeout=float(os.getenv("SEARCH_TIMEOUT", "20")),
|
||||
reader_timeout=float(os.getenv("READER_TIMEOUT", "25")),
|
||||
model_timeout=float(os.getenv("MODEL_TIMEOUT", "90")),
|
||||
top_k_pages=int(os.getenv("TOP_K_PAGES", "5")),
|
||||
max_page_chars=int(os.getenv("MAX_PAGE_CHARS", "5000")),
|
||||
max_concurrency=int(os.getenv("MAX_CONCURRENCY", "5")),
|
||||
retry_attempts=int(os.getenv("RETRY_ATTEMPTS", "2")),
|
||||
audit_log_path=os.getenv("AUDIT_LOG_PATH", "logs/audit.jsonl").strip(),
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings
|
||||
from .models import PageContent, SearchResult
|
||||
from .parsers import trim_text
|
||||
|
||||
|
||||
class JinaClient:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self._semaphore = asyncio.Semaphore(settings.max_concurrency)
|
||||
|
||||
@property
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.settings.jina_api_key}",
|
||||
"X-Respond-With": "readerlm-v2",
|
||||
"X-Retain-Images": "none",
|
||||
"X-Retain-Links": "gpt-oss",
|
||||
}
|
||||
|
||||
async def read_pages(self, results: list[SearchResult], max_page_chars: int) -> list[PageContent]:
|
||||
tasks = [self.read_page(result.url, result.title, max_page_chars) for result in results]
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def read_page(self, url: str, fallback_title: str, max_page_chars: int) -> PageContent:
|
||||
reader_url = f"{self.settings.reader_base_url}{url}"
|
||||
try:
|
||||
text = await self._get_text(
|
||||
reader_url,
|
||||
timeout=self.settings.reader_timeout,
|
||||
extra_headers={"Accept": "text/plain"},
|
||||
use_reader_headers=False,
|
||||
)
|
||||
title = ""
|
||||
first_line = text.splitlines()[0].strip() if text else ""
|
||||
if first_line.startswith("#"):
|
||||
title = first_line.lstrip("#").strip()
|
||||
|
||||
return PageContent(
|
||||
url=url,
|
||||
title=title or fallback_title,
|
||||
content=trim_text(text.strip(), max_page_chars),
|
||||
fetched=True,
|
||||
usable=self._is_usable_content(text),
|
||||
)
|
||||
except Exception as exc:
|
||||
return PageContent(
|
||||
url=url,
|
||||
title=fallback_title,
|
||||
content="",
|
||||
fetched=False,
|
||||
usable=False,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
def _is_usable_content(self, text: str) -> bool:
|
||||
normalized = text.strip()
|
||||
if len(normalized) < 300:
|
||||
return False
|
||||
|
||||
blocked_markers = [
|
||||
"403: Forbidden",
|
||||
"451 Unavailable For Legal Reasons",
|
||||
"Target URL returned error 403",
|
||||
"Target URL returned error 451",
|
||||
"enable javascript",
|
||||
"captcha",
|
||||
]
|
||||
lowered = normalized.lower()
|
||||
return not any(marker.lower() in lowered for marker in blocked_markers)
|
||||
|
||||
async def _get_text(
|
||||
self,
|
||||
url: str,
|
||||
timeout: float,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
use_reader_headers: bool = True,
|
||||
) -> str:
|
||||
headers = dict(self._headers) if use_reader_headers else {}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
|
||||
last_error: Exception | None = None
|
||||
for _ in range(self.settings.retry_attempts + 1):
|
||||
try:
|
||||
async with self._semaphore:
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
response = await client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Settings
|
||||
from .models import AnswerPayload
|
||||
from .parsers import extract_json_object
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
async def answer(self, system_prompt: str, user_prompt: str, allowed_urls: set[str]) -> AnswerPayload:
|
||||
if self.settings.llm_api_style == "openai_chat":
|
||||
text = await self._call_openai_chat(system_prompt, user_prompt)
|
||||
else:
|
||||
text = await self._call_xai_responses(system_prompt, user_prompt)
|
||||
|
||||
parsed = self._parse_answer(text)
|
||||
filtered_citations = [item for item in parsed.citations if item.url in allowed_urls]
|
||||
return parsed.model_copy(update={"citations": filtered_citations})
|
||||
|
||||
async def _call_xai_responses(self, system_prompt: str, user_prompt: str) -> str:
|
||||
return await asyncio.to_thread(self._call_xai_responses_sync, system_prompt, user_prompt)
|
||||
|
||||
def _call_xai_responses_sync(self, system_prompt: str, user_prompt: str) -> str:
|
||||
payload = {
|
||||
"model": self.settings.llm_model,
|
||||
"input": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
}
|
||||
with httpx.Client(timeout=self.settings.model_timeout) as client:
|
||||
response = client.post(
|
||||
self.settings.llm_endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.settings.llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
output_text: list[str] = []
|
||||
for item in data.get("output", []):
|
||||
for content in item.get("content", []):
|
||||
if content.get("type") == "output_text":
|
||||
output_text.append(content.get("text", ""))
|
||||
return "\n".join(part for part in output_text if part).strip()
|
||||
|
||||
async def _call_openai_chat(self, system_prompt: str, user_prompt: str) -> str:
|
||||
return await asyncio.to_thread(self._call_openai_chat_sync, system_prompt, user_prompt)
|
||||
|
||||
def _call_openai_chat_sync(self, system_prompt: str, user_prompt: str) -> str:
|
||||
payload = {
|
||||
"model": self.settings.llm_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.2,
|
||||
}
|
||||
with httpx.Client(timeout=self.settings.model_timeout) as client:
|
||||
response = client.post(
|
||||
self.settings.llm_endpoint,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.settings.llm_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
def _parse_answer(self, text: str) -> AnswerPayload:
|
||||
raw_json = extract_json_object(text)
|
||||
if raw_json:
|
||||
try:
|
||||
data = json.loads(raw_json)
|
||||
return AnswerPayload.model_validate(data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return AnswerPayload(summary=text.strip(), key_points=[], caveats=[], citations=[])
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str = Field(min_length=1, description="User search query")
|
||||
top_k_pages: int | None = Field(default=None, ge=1, le=10)
|
||||
max_page_chars: int | None = Field(default=None, ge=500, le=20000)
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
url: str
|
||||
title: str = ""
|
||||
snippet: str = ""
|
||||
rank: int = 0
|
||||
|
||||
|
||||
class PageContent(BaseModel):
|
||||
url: str
|
||||
title: str = ""
|
||||
content: str = ""
|
||||
fetched: bool = True
|
||||
usable: bool = True
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class Citation(BaseModel):
|
||||
title: str = ""
|
||||
url: str
|
||||
|
||||
|
||||
class AnswerPayload(BaseModel):
|
||||
summary: str
|
||||
key_points: list[str] = Field(default_factory=list)
|
||||
caveats: list[str] = Field(default_factory=list)
|
||||
citations: list[Citation] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
query: str
|
||||
answer: AnswerPayload
|
||||
search_results: list[SearchResult] = Field(default_factory=list)
|
||||
pages: list[PageContent] = Field(default_factory=list)
|
||||
content_ready: bool = True
|
||||
llm_called: bool = False
|
||||
error: str | None = None
|
||||
audit_id: str | None = None
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .models import SearchResult
|
||||
|
||||
MARKDOWN_LINK_RE = re.compile(r"\[(?P<title>[^\]]+)\]\((?P<url>https?://[^\s)]+)\)")
|
||||
URL_RE = re.compile(r"https?://[^\s)>\]]+")
|
||||
|
||||
|
||||
def extract_markdown_links(text: str) -> list[SearchResult]:
|
||||
results: list[SearchResult] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for rank, match in enumerate(MARKDOWN_LINK_RE.finditer(text), start=1):
|
||||
url = match.group("url").strip()
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
start = max(0, match.start() - 160)
|
||||
end = min(len(text), match.end() + 160)
|
||||
snippet = " ".join(text[start:end].split())
|
||||
results.append(
|
||||
SearchResult(
|
||||
title=match.group("title").strip(),
|
||||
url=url,
|
||||
snippet=snippet,
|
||||
rank=rank,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def extract_urls(text: str) -> list[str]:
|
||||
urls: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for match in URL_RE.finditer(text):
|
||||
url = match.group(0).rstrip(".,")
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
urls.append(url)
|
||||
|
||||
return urls
|
||||
|
||||
|
||||
def trim_text(text: str, limit: int) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit].rstrip() + "\n...[truncated]"
|
||||
|
||||
|
||||
def extract_json_object(text: str) -> str | None:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
return None
|
||||
return text[start : end + 1]
|
||||
@@ -0,0 +1,15 @@
|
||||
SYSTEM_PROMPT = """You are an evidence-grounded web research assistant.
|
||||
Return valid JSON only with this shape:
|
||||
{
|
||||
"summary": "short answer in the user's language",
|
||||
"key_points": ["point 1", "point 2"],
|
||||
"caveats": ["optional limitation"],
|
||||
"citations": [{"title": "source title", "url": "https://..."}]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use only the supplied search digest and page excerpts.
|
||||
- Do not invent citations.
|
||||
- Prefer concise, factual writing.
|
||||
- If the evidence is incomplete or conflicting, mention it in caveats.
|
||||
"""
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .config import Settings
|
||||
from .models import SearchResult
|
||||
class SearchClient:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
|
||||
async def search(self, query: str) -> tuple[str, list[SearchResult]]:
|
||||
return await self._bing_search(query)
|
||||
|
||||
async def _bing_search(self, query: str) -> tuple[str, list[SearchResult]]:
|
||||
url = f"{self.settings.bing_search_url}?q={quote(query)}"
|
||||
async with httpx.AsyncClient(timeout=self.settings.search_timeout, follow_redirects=True) as client:
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
html = response.text
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
results: list[SearchResult] = []
|
||||
for rank, item in enumerate(soup.select("li.b_algo"), start=1):
|
||||
link = item.select_one("h2 a")
|
||||
if not link:
|
||||
continue
|
||||
href = (link.get("href") or "").strip()
|
||||
if not href.startswith("http"):
|
||||
continue
|
||||
snippet_node = item.select_one(".b_caption p")
|
||||
results.append(
|
||||
SearchResult(
|
||||
title=link.get_text(" ", strip=True),
|
||||
url=href,
|
||||
snippet=snippet_node.get_text(" ", strip=True) if snippet_node else "",
|
||||
rank=rank,
|
||||
)
|
||||
)
|
||||
if len(results) >= self.settings.top_k_pages * 2:
|
||||
break
|
||||
|
||||
if not results:
|
||||
raise ValueError("No Bing HTML search results parsed")
|
||||
return html, results
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from ai_search_agent.agent import AISearchAgent
|
||||
from ai_search_agent.config import Settings
|
||||
from ai_search_agent.models import SearchRequest
|
||||
|
||||
|
||||
async def _run_query(args: argparse.Namespace) -> None:
|
||||
settings = Settings.from_env()
|
||||
agent = AISearchAgent(settings)
|
||||
result = await agent.run(
|
||||
SearchRequest(
|
||||
query=args.query,
|
||||
top_k_pages=args.top_k_pages,
|
||||
max_page_chars=args.max_page_chars,
|
||||
)
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result.model_dump(), ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
print(result.answer.summary)
|
||||
if result.answer.key_points:
|
||||
print("\nKey Points:")
|
||||
for item in result.answer.key_points:
|
||||
print(f"- {item}")
|
||||
if result.answer.caveats:
|
||||
print("\nCaveats:")
|
||||
for item in result.answer.caveats:
|
||||
print(f"- {item}")
|
||||
if result.answer.citations:
|
||||
print("\nSources:")
|
||||
for item in result.answer.citations:
|
||||
print(f"- {item.title}: {item.url}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Standalone AI search agent")
|
||||
parser.add_argument("query", help="Search query")
|
||||
parser.add_argument("--json", action="store_true", help="Print full JSON output")
|
||||
parser.add_argument("--top-k-pages", type=int, default=None, help="How many pages to fetch")
|
||||
parser.add_argument("--max-page-chars", type=int, default=None, help="Max chars per page")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(_run_query(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi>=0.115.0
|
||||
httpx>=0.28.0
|
||||
pydantic>=2.10.0
|
||||
python-dotenv>=1.0.1
|
||||
uvicorn>=0.34.0
|
||||
beautifulsoup4>=4.12.0
|
||||
@@ -0,0 +1,37 @@
|
||||
import unittest
|
||||
|
||||
from ai_search_agent.parsers import extract_json_object, extract_markdown_links, extract_urls, trim_text
|
||||
|
||||
|
||||
class ParserTests(unittest.TestCase):
|
||||
def test_extract_markdown_links_deduplicates(self) -> None:
|
||||
text = """
|
||||
[Example](https://example.com)
|
||||
[Example Again](https://example.com)
|
||||
[Docs](https://docs.example.com/path)
|
||||
"""
|
||||
results = extract_markdown_links(text)
|
||||
|
||||
self.assertEqual(
|
||||
[item.url for item in results],
|
||||
["https://example.com", "https://docs.example.com/path"],
|
||||
)
|
||||
|
||||
def test_extract_urls(self) -> None:
|
||||
text = "See https://a.example.com, https://b.example.com/docs."
|
||||
self.assertEqual(
|
||||
extract_urls(text),
|
||||
["https://a.example.com", "https://b.example.com/docs"],
|
||||
)
|
||||
|
||||
def test_trim_text(self) -> None:
|
||||
self.assertEqual(trim_text("abc", 5), "abc")
|
||||
self.assertTrue(trim_text("abcdef", 3).startswith("abc"))
|
||||
|
||||
def test_extract_json_object(self) -> None:
|
||||
text = "prefix {\"summary\": \"ok\"} suffix"
|
||||
self.assertEqual(extract_json_object(text), "{\"summary\": \"ok\"}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user