release: v0.20 Codex-ready skill-driven core

This commit is contained in:
kai
2026-05-07 08:21:28 +08:00
parent 0644a68ecc
commit 68e45bcf41
45 changed files with 3005 additions and 157 deletions
+165 -10
View File
@@ -1,11 +1,12 @@
"""通用搜索客户端(Serper / Exa / Tavily 路由)。
"""通用搜索客户端(Tavily / Exa / Brave / Serper 路由)。
为 build_glossary.py 这类术语核查场景服务。
关键设计:
- `trust_env=False` 绕开系统 socks 代理(Clash on macOS 配 socks5 时 httpx 会 TLS EOF
- 专利 / Scholar / News 优先 Serper,保证 Google Patents / Google Scholar 路径被真正调用
- 通用网页 Exa 优先,Tavily fallback
- 通用网页 Tavily 优先,Exa/Brave fallback
- 证据发现 Exa 优先,用 highlights/text 摘录喂给 evidence packet
- 遇到配额问题自动降级或返回 empty
- 不做深度 crawl,只要摘要
"""
@@ -47,13 +48,29 @@ class ExaClient:
def __exit__(self, *_args: Any) -> None:
self.close()
def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]:
def search(
self,
query: str,
*,
num_results: int = 5,
search_type: str = "auto",
category: str | None = None,
use_highlights: bool = False,
max_characters: int = 800,
) -> list[SearchHit]:
body = {
"query": query,
"numResults": num_results,
"type": "auto",
"contents": {"text": {"maxCharacters": 800}},
"type": search_type,
"contents": {"text": {"maxCharacters": max_characters}},
}
if category:
body["category"] = category
if use_highlights:
body["contents"]["highlights"] = {
"numSentences": 2,
"highlightsPerUrl": 3,
}
r = self._client.post(
"https://api.exa.ai/search",
json=body,
@@ -64,11 +81,15 @@ class ExaClient:
data = r.json()
out: list[SearchHit] = []
for item in data.get("results", [])[:num_results]:
highlights = item.get("highlights") or []
text = item.get("text") or item.get("snippet") or ""
if highlights:
text = " | ".join(str(h).strip() for h in highlights if str(h).strip())
out.append(
SearchHit(
title=(item.get("title") or "")[:200],
url=item.get("url") or "",
snippet=(item.get("text") or item.get("snippet") or "")[:600],
snippet=text[:1000],
)
)
return out
@@ -115,10 +136,51 @@ class TavilyClient:
return out
class BraveClient:
def __init__(self, api_key: str | None = None, timeout: float = 30.0) -> None:
self.api_key = api_key or os.environ.get("BRAVE_API_KEY")
if not self.api_key:
raise SearchError("BRAVE_API_KEY not set")
self._client = httpx.Client(trust_env=False, timeout=timeout)
def close(self) -> None:
self._client.close()
def __enter__(self) -> "BraveClient":
return self
def __exit__(self, *_args: Any) -> None:
self.close()
def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]:
r = self._client.get(
"https://api.search.brave.com/res/v1/web/search",
params={"q": query, "count": min(max(num_results, 1), 20)},
headers={
"X-Subscription-Token": self.api_key,
"Accept": "application/json",
},
)
if r.status_code != 200:
raise SearchError(f"Brave HTTP {r.status_code}: {r.text[:200]}")
data = r.json()
out: list[SearchHit] = []
for item in (data.get("web") or {}).get("results", [])[:num_results]:
out.append(
SearchHit(
title=(item.get("title") or "")[:200],
url=item.get("url") or "",
snippet=(item.get("description") or "")[:600],
)
)
return out
class SearchClient:
"""统一搜索门面,支持多路由:
- `search(query)`:通用网页搜索,优先 Exa → 降级 Tavily
- `search(query)`:通用网页搜索,优先 Tavily → Exa → Brave
- `evidence(query)`:证据发现,优先 Exa highlights → Tavily → Brave
- `patents(query)`:专利检索,走 SerperGoogle Patents);失败则通用搜索补刀
- `scholar(query)`:学术论文,走 Serper Scholar;失败则通用搜索补刀
- `news(query)`:新闻检索,走 Serper News;失败则通用搜索补刀
@@ -129,6 +191,7 @@ class SearchClient:
def __init__(self, *, strict_specialized: bool = True) -> None:
self._exa: ExaClient | None = None
self._tavily: TavilyClient | None = None
self._brave: BraveClient | None = None
self._serper = None # 惰性实例化
self.strict_specialized = strict_specialized
try:
@@ -139,9 +202,13 @@ class SearchClient:
self._tavily = TavilyClient()
except SearchError:
pass
try:
self._brave = BraveClient()
except SearchError:
pass
self._has_serper_key = bool(os.environ.get("SERPER_API_KEY") or os.environ.get("SERPAPI_KEY"))
if not (self._exa or self._tavily or self._has_serper_key):
raise SearchError("no search API key available: set SERPER_API_KEY, SERPAPI_KEY, EXA_API_KEY, or TAVILY_API_KEY")
if not (self._exa or self._tavily or self._brave or self._has_serper_key):
raise SearchError("no search API key available: set SERPER_API_KEY, SERPAPI_KEY, EXA_API_KEY, TAVILY_API_KEY, or BRAVE_API_KEY")
def _get_serper(self):
"""惰性创建 SerperClient。没 key 时返回 None。"""
@@ -161,6 +228,8 @@ class SearchClient:
self._exa.close()
if self._tavily:
self._tavily.close()
if self._brave:
self._brave.close()
if self._serper and self._serper is not False:
self._serper.close()
@@ -171,17 +240,60 @@ class SearchClient:
self.close()
def search(self, query: str, *, num_results: int = 5) -> list[SearchHit]:
"""通用网页搜索。Exa 首选,Tavily 备选。"""
"""通用网页搜索。Tavily 首选,Exa/Brave 备选。"""
if self._tavily:
try:
return self._tavily.search(query, num_results=num_results)
except SearchError:
pass
if self._exa:
try:
return self._exa.search(query, num_results=num_results)
except SearchError:
pass
if self._brave:
try:
return self._brave.search(query, num_results=num_results)
except SearchError:
pass
return []
def evidence(
self,
query: str,
*,
num_results: int = 10,
category: str | None = None,
) -> list[SearchHit]:
"""Evidence discovery route.
Exa is better suited for agent-facing evidence discovery because it can
return concise highlights/text per URL. Results are still candidate
sources only; downstream packets must score and trace important hits
back to original Tier 1-2 sources before making final claims.
"""
if self._exa:
try:
return self._exa.search(
query,
num_results=num_results,
search_type="auto",
category=category,
use_highlights=True,
max_characters=1200,
)
except SearchError:
pass
if self._tavily:
try:
return self._tavily.search(query, num_results=num_results)
except SearchError:
pass
if self._brave:
try:
return self._brave.search(query, num_results=num_results)
except SearchError:
pass
return []
def patents(self, query: str, *, num_results: int = 10) -> list[SearchHit]:
@@ -253,6 +365,49 @@ class SearchClient:
raise SearchError("serper unavailable for news route; refusing silent fallback")
return self.search(query, num_results=num_results)
def fda(self, query: str, *, num_results: int = 10) -> list[SearchHit]:
"""FDA-focused discovery for warning letters and meeting records.
FDA enforcement examples are often more useful for GMP remediation than
generic web pages, so this route biases discovery toward warning
letters, inspection/enforcement pages, and meeting materials/minutes.
"""
def fda_only(hits: list[SearchHit]) -> list[SearchHit]:
return [hit for hit in hits if "fda.gov" in (hit.url or "").lower()]
focused_queries = [
f'site:fda.gov "Warning Letter" GMP pharmaceutical {query}',
f'site:fda.gov/inspections-compliance-enforcement-and-criminal-investigations "Warning Letter" {query}',
f'site:fda.gov "meeting materials" "pharmaceutical quality" {query}',
f'site:fda.gov "meeting minutes" FDA pharmaceutical quality {query}',
]
hits: list[SearchHit] = []
seen: set[str] = set()
per_query = max(2, min(num_results, 4))
for focused_query in focused_queries:
route_hits: list[SearchHit] = []
serper = self._get_serper()
if serper:
try:
route_hits = [
SearchHit(h.title, h.url, h.snippet)
for h in serper.search(focused_query, num_results=per_query)
]
except Exception as exc:
if self.strict_specialized:
raise SearchError(f"serper FDA search failed: {exc}") from exc
if not route_hits and not self.strict_specialized:
route_hits = self.search(focused_query, num_results=per_query)
for hit in fda_only(route_hits):
key = hit.url or hit.title
if not key or key in seen:
continue
seen.add(key)
hits.append(hit)
if len(hits) >= num_results:
return hits
return hits
if __name__ == "__main__":
from scripts.lib.zenmux_client import load_secrets