v0.12: stabilize search routing and profile-driven phase4 pipeline
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user