Files

184 lines
6.2 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": "Exa -> Tavily generic web discovery",
"scholar": "Serper Scholar -> generic fallback",
"patents": "Serper Google Patents -> site:patents.google.com fallback",
"news": "Serper News -> generic fallback",
}
PROFILE_ROUTES = {
"biomed_literature": ["scholar", "general"],
"patent_heavy": ["patents", "general"],
"china_market": ["news", "general"],
"investment": ["news", "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 == "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)
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("--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())