195 lines
6.9 KiB
Python
195 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Unified search gateway for Deep Research agents.
|
|
|
|
This script is the stable project-owned entrypoint that agents should call
|
|
instead of vendor MCP tools. MCP search remains optional, while this gateway
|
|
keeps routing behavior reproducible across OpenCode, Codex, and future
|
|
adapters.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
if str(REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|
|
|
from scripts.lib.search_client import SearchClient, SearchError, SearchHit
|
|
from scripts.lib.zenmux_client import load_secrets
|
|
|
|
|
|
ROUTE_HELP = {
|
|
"general": "Tavily -> Exa -> Brave generic web discovery",
|
|
"evidence": "Exa highlights -> Tavily -> Brave controlled evidence discovery",
|
|
"scholar": "Serper Scholar -> generic fallback",
|
|
"patents": "Serper Google Patents -> site:patents.google.com fallback",
|
|
"news": "Serper News -> generic fallback",
|
|
"fda": "FDA-focused discovery for warning letters, enforcement pages, and meeting materials",
|
|
}
|
|
|
|
PROFILE_ROUTES = {
|
|
"biomed_literature": ["scholar", "evidence", "general"],
|
|
"patent_heavy": ["patents", "evidence", "general"],
|
|
"china_market": ["news", "evidence", "general"],
|
|
"investment": ["news", "evidence", "general"],
|
|
}
|
|
|
|
PROFILE_QUERY_PREFIX = {
|
|
"china_market": "(China OR Chinese OR 中国 OR 国内)",
|
|
}
|
|
|
|
|
|
def search_route(client: SearchClient, route: str, query: str, args: argparse.Namespace) -> list[SearchHit]:
|
|
if route == "general":
|
|
return client.search(query, num_results=args.num_results)
|
|
if route == "evidence":
|
|
return client.evidence(query, num_results=args.num_results, category=args.exa_category)
|
|
if route == "scholar":
|
|
return client.scholar(query, num_results=args.num_results, year_low=args.year_low)
|
|
if route == "patents":
|
|
return client.patents(query, num_results=args.num_results)
|
|
if route == "news":
|
|
return client.news(query, num_results=args.num_results, time_range=args.time_range)
|
|
if route == "fda":
|
|
return client.fda(query, num_results=args.num_results)
|
|
raise SystemExit(f"unknown route: {route}")
|
|
|
|
|
|
def emit_markdown(route_hits: list[tuple[str, list[SearchHit]]], query: str) -> None:
|
|
print(f"# Search Results: {query}")
|
|
for route, hits in route_hits:
|
|
print()
|
|
print(f"## Route: {route} ({ROUTE_HELP[route]})")
|
|
if not hits:
|
|
print("No results.")
|
|
continue
|
|
for i, hit in enumerate(hits, start=1):
|
|
print(f"{i}. {hit.title or '(untitled)'}")
|
|
print(f" - URL: {hit.url}")
|
|
if hit.snippet:
|
|
print(f" - Snippet: {hit.snippet}")
|
|
|
|
|
|
def emit_json(route_hits: list[tuple[str, list[SearchHit]]], query: str) -> None:
|
|
data = {
|
|
"query": query,
|
|
"routes": [
|
|
{
|
|
"route": route,
|
|
"route_help": ROUTE_HELP[route],
|
|
"results": [asdict(hit) for hit in hits],
|
|
}
|
|
for route, hits in route_hits
|
|
],
|
|
}
|
|
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
|
|
|
|
def emit_trace_markdown(route_trace: list[dict[str, str]]) -> None:
|
|
print()
|
|
print("## Route Trace")
|
|
for item in route_trace:
|
|
print(f"- {item['route']}: {item['status']} ({item['detail']})")
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="Deep Research search gateway")
|
|
parser.add_argument("query", help="Search query")
|
|
parser.add_argument(
|
|
"--route",
|
|
choices=sorted(ROUTE_HELP),
|
|
default="general",
|
|
help="Single search route to run",
|
|
)
|
|
parser.add_argument(
|
|
"--profile",
|
|
choices=sorted(PROFILE_ROUTES),
|
|
help="Run a strategy profile instead of a single route",
|
|
)
|
|
parser.add_argument("--num-results", type=int, default=10)
|
|
parser.add_argument(
|
|
"--exa-category",
|
|
choices=["research paper", "news", "company", "financial report", "github", "tweet", "personal site", "pdf"],
|
|
help="Optional Exa category for the evidence route",
|
|
)
|
|
parser.add_argument("--year-low", type=int, help="Lower year bound for scholar searches")
|
|
parser.add_argument("--time-range", choices=["d", "w", "m", "y"], help="Serper news time range")
|
|
parser.add_argument("--json", action="store_true", help="Emit JSON instead of Markdown")
|
|
parser.add_argument("--dry-run", action="store_true", help="Show planned routes without calling APIs")
|
|
parser.add_argument(
|
|
"--strict-specialized",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=True,
|
|
help="Fail fast if scholar/news/patents cannot use Serper",
|
|
)
|
|
parser.add_argument("--trace", action="store_true", help="Include route execution trace")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args()
|
|
load_secrets()
|
|
|
|
routes = PROFILE_ROUTES[args.profile] if args.profile else [args.route]
|
|
query = args.query
|
|
if args.profile in PROFILE_QUERY_PREFIX:
|
|
query = f"{PROFILE_QUERY_PREFIX[args.profile]} {query}"
|
|
|
|
if args.dry_run:
|
|
for route in routes:
|
|
print(f"{route}: {ROUTE_HELP[route]}")
|
|
if query != args.query:
|
|
print(f"query_rewritten: {query}")
|
|
return 0
|
|
|
|
try:
|
|
with SearchClient(strict_specialized=args.strict_specialized) as client:
|
|
route_hits = []
|
|
route_trace: list[dict[str, str]] = []
|
|
for route in routes:
|
|
try:
|
|
hits = search_route(client, route, query, args)
|
|
route_hits.append((route, hits))
|
|
route_trace.append({"route": route, "status": "ok", "detail": f"hits={len(hits)}"})
|
|
except SearchError as exc:
|
|
route_hits.append((route, []))
|
|
route_trace.append({"route": route, "status": "failed", "detail": str(exc)})
|
|
if route != "general":
|
|
continue
|
|
raise
|
|
except SearchError as exc:
|
|
raise SystemExit(f"search failed: {exc}") from exc
|
|
|
|
if args.json:
|
|
data = {
|
|
"query": query,
|
|
"original_query": args.query,
|
|
"strict_specialized": args.strict_specialized,
|
|
"routes": [
|
|
{
|
|
"route": route,
|
|
"route_help": ROUTE_HELP[route],
|
|
"results": [asdict(hit) for hit in hits],
|
|
}
|
|
for route, hits in route_hits
|
|
],
|
|
"trace": route_trace if args.trace else [],
|
|
}
|
|
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
else:
|
|
emit_markdown(route_hits, query)
|
|
if args.trace:
|
|
emit_trace_markdown(route_trace)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|