argo-search 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/backends/domain_profiles.json +364 -0
- package/backends/engine_registry.yaml +505 -0
- package/backends/quota_profiles.json +395 -0
- package/bin/argo.js +54 -0
- package/config.yaml +554 -0
- package/package.json +43 -0
- package/scripts/adaptive.py +179 -0
- package/scripts/benchmark.py +124 -0
- package/scripts/cache.py +374 -0
- package/scripts/clarify.py +689 -0
- package/scripts/config.py +262 -0
- package/scripts/crawl.py +73 -0
- package/scripts/engines.py +386 -0
- package/scripts/evidence.py +381 -0
- package/scripts/extract.py +69 -0
- package/scripts/fetch.py +118 -0
- package/scripts/health_check.py +437 -0
- package/scripts/health_probe.py +218 -0
- package/scripts/mcp_diag.py +81 -0
- package/scripts/mcp_server.py +488 -0
- package/scripts/query_rewriter.py +278 -0
- package/scripts/quota.py +196 -0
- package/scripts/research.py +499 -0
- package/scripts/route.py +341 -0
- package/scripts/search.py +508 -0
- package/scripts/search_types.py +72 -0
- package/scripts/tfidf_router.py +312 -0
- package/sub-skills/local-search/SKILL.md +104 -0
- package/sub-skills/local-search/config.yaml +328 -0
- package/sub-skills/local-search/engine_registry.py +298 -0
- package/sub-skills/local-search/health_check.py +347 -0
- package/sub-skills/local-search/local_search_adapter.py +56 -0
- package/sub-skills/local-search/parse_maps.yaml +184 -0
- package/sub-skills/local-search/search_v3.py +558 -0
- package/sub-skills/local-search/smart_router.py +215 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
search.py — Unified Search v2 CLI 主入口 & 执行编排
|
|
4
|
+
|
|
5
|
+
职责:
|
|
6
|
+
- 解析命令行参数
|
|
7
|
+
- 通过 route.py 做路由决策(含预算模式)
|
|
8
|
+
- 通过 cache.py 做双层缓存
|
|
9
|
+
- 通过 engines.py 执行引擎搜索
|
|
10
|
+
- RRF 融合 + Bocha Reranker 精排
|
|
11
|
+
- 通过 adaptive.py 记录引擎表现
|
|
12
|
+
- 输出统一 JSON / 文本格式
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import Any, Callable, Optional
|
|
25
|
+
|
|
26
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
sys.path.insert(0, SCRIPT_DIR)
|
|
28
|
+
|
|
29
|
+
from cache import SearchCache
|
|
30
|
+
from route import route_query
|
|
31
|
+
from engines import search as engine_search, available_engines
|
|
32
|
+
from config import get_execution_config, get_cost_factor
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── 进度阶段 ──────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
class Stage(str, Enum):
|
|
38
|
+
START = "start"
|
|
39
|
+
CACHE_HIT = "cache_hit"
|
|
40
|
+
ROUTING = "routing"
|
|
41
|
+
SEARCHING = "searching"
|
|
42
|
+
MERGING = "merging"
|
|
43
|
+
DONE = "done"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── RRF 融合 ───────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def rrf_merge(ranked_lists: list[list[dict[str, Any]]], k: int = 60) -> list[dict[str, Any]]:
|
|
49
|
+
"""Reciprocal Rank Fusion 合并多引擎结果。"""
|
|
50
|
+
scores: dict[str, float] = {}
|
|
51
|
+
items: dict[str, dict[str, Any]] = {}
|
|
52
|
+
|
|
53
|
+
for results in ranked_lists:
|
|
54
|
+
for i, r in enumerate(results):
|
|
55
|
+
url = r.get("url", "") or f"__title__:{r.get('title', '')}"
|
|
56
|
+
scores[url] = scores.get(url, 0.0) + 1.0 / (k + i + 1)
|
|
57
|
+
if url not in items:
|
|
58
|
+
items[url] = dict(r)
|
|
59
|
+
else:
|
|
60
|
+
if r.get("score", 0) > items[url].get("score", 0):
|
|
61
|
+
items[url].update(r)
|
|
62
|
+
sources = {items[url].get("source", ""), r.get("source", "")}
|
|
63
|
+
items[url]["source"] = "/".join(s for s in sources if s)
|
|
64
|
+
|
|
65
|
+
ranked = sorted(scores.items(), key=lambda x: -x[1])
|
|
66
|
+
return [items[url] for url, _ in ranked]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def deduplicate_by_url(results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
70
|
+
"""URL 去重。"""
|
|
71
|
+
seen: set[str] = set()
|
|
72
|
+
out = []
|
|
73
|
+
for r in results:
|
|
74
|
+
key = r.get("url", "") or f"title:{r.get('title', '')}"
|
|
75
|
+
if key not in seen:
|
|
76
|
+
seen.add(key)
|
|
77
|
+
out.append(r)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── Bocha Reranker ──────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def rerank_results(query: str, results: list[dict[str, Any]],
|
|
84
|
+
top_n: int = 10, timeout: float = 5) -> list[dict[str, Any]]:
|
|
85
|
+
"""使用博查语义排序模型对搜索结果二次精排。"""
|
|
86
|
+
if not results or len(results) <= 1:
|
|
87
|
+
return results
|
|
88
|
+
|
|
89
|
+
api_key = os.environ.get("BOCHA_API_KEY", "")
|
|
90
|
+
if not api_key:
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
documents = []
|
|
94
|
+
for r in results:
|
|
95
|
+
doc_text = f"{r.get('title', '')} {r.get('snippet', '')}".strip()
|
|
96
|
+
documents.append(doc_text or "empty")
|
|
97
|
+
|
|
98
|
+
import urllib.request
|
|
99
|
+
payload = json.dumps({
|
|
100
|
+
"model": "gte-rerank", "query": query,
|
|
101
|
+
"documents": documents[:50],
|
|
102
|
+
"top_n": min(top_n, len(documents)),
|
|
103
|
+
"return_documents": False,
|
|
104
|
+
}).encode("utf-8")
|
|
105
|
+
|
|
106
|
+
req = urllib.request.Request(
|
|
107
|
+
"https://api.bocha.cn/v1/rerank", data=payload,
|
|
108
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
109
|
+
)
|
|
110
|
+
try:
|
|
111
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
112
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
113
|
+
rerank_results_list = data.get("data", {}).get("results", [])
|
|
114
|
+
if not rerank_results_list:
|
|
115
|
+
return results
|
|
116
|
+
scored = []
|
|
117
|
+
for rr in rerank_results_list:
|
|
118
|
+
idx = rr.get("index", -1)
|
|
119
|
+
score = rr.get("relevance_score", 0)
|
|
120
|
+
if 0 <= idx < len(results):
|
|
121
|
+
item = dict(results[idx])
|
|
122
|
+
orig_score = item.get("score", 0) or 0
|
|
123
|
+
item["score"] = round(score * 0.7 + orig_score * 0.3, 4)
|
|
124
|
+
scored.append(item)
|
|
125
|
+
if scored:
|
|
126
|
+
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
|
|
127
|
+
return scored[:top_n]
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
return results
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── 执行层 ─────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def execute_search(query: str, decision: dict[str, Any], max_results: int,
|
|
136
|
+
timeout: int, depth: str, cache: SearchCache, skip_cache: bool,
|
|
137
|
+
mode: str = "auto",
|
|
138
|
+
on_progress: Optional[Callable[[Stage, dict[str, Any]], None]] = None) -> dict[str, Any]:
|
|
139
|
+
"""执行搜索:缓存 → 引擎 → 融合 → 精排 → 写缓存。"""
|
|
140
|
+
domain = decision.get("domain") or "general"
|
|
141
|
+
engine_label = decision.get("engine", "auto")
|
|
142
|
+
engines_combo = decision.get("engines_combo", decision.get("engines", [engine_label]))
|
|
143
|
+
engines = engines_combo
|
|
144
|
+
parallel = decision.get("parallel", False) and len(engines) > 1
|
|
145
|
+
|
|
146
|
+
if on_progress:
|
|
147
|
+
on_progress(Stage.START, {"query": query})
|
|
148
|
+
|
|
149
|
+
cache_engine_key = "+".join(sorted(engines)) if len(engines) > 1 else engines[0]
|
|
150
|
+
|
|
151
|
+
if on_progress:
|
|
152
|
+
on_progress(Stage.ROUTING, {"domain": domain, "engine": engine_label, "engines": engines})
|
|
153
|
+
|
|
154
|
+
# 缓存命中
|
|
155
|
+
if not skip_cache:
|
|
156
|
+
hit = cache.get(query, cache_engine_key, max_results, domain=domain)
|
|
157
|
+
if hit:
|
|
158
|
+
if on_progress:
|
|
159
|
+
on_progress(Stage.CACHE_HIT, {"cache_level": hit.get("_cache_level", "L?")})
|
|
160
|
+
tfidf_scores = decision.get("tfidf_scores", [])
|
|
161
|
+
if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
|
|
162
|
+
tfidf_scores = []
|
|
163
|
+
return {
|
|
164
|
+
"query": query, "engine": engine_label, "engines": engines,
|
|
165
|
+
"engines_combo": engines_combo, "cached": True,
|
|
166
|
+
"cache_level": hit.get("_cache_level", "L?"),
|
|
167
|
+
"domain": domain, "elapsed_ms": 0,
|
|
168
|
+
"tfidf_scores": tfidf_scores,
|
|
169
|
+
"results": hit.get("results", []),
|
|
170
|
+
"count": len(hit.get("results", [])),
|
|
171
|
+
"mode": mode,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if on_progress:
|
|
175
|
+
on_progress(Stage.SEARCHING, {"engines": engines})
|
|
176
|
+
|
|
177
|
+
t0 = time.time()
|
|
178
|
+
raw_results: dict[str, list[dict[str, Any]]] = {}
|
|
179
|
+
|
|
180
|
+
exec_cfg = get_execution_config()
|
|
181
|
+
retry_count = exec_cfg.get("retry_count", 0)
|
|
182
|
+
|
|
183
|
+
def _exec_engine(eng: str, retries: int = retry_count) -> list[dict[str, Any]]:
|
|
184
|
+
for attempt in range(retries + 1):
|
|
185
|
+
res = engine_search(query, eng, n=max_results, timeout=timeout, depth=depth, mode=mode)
|
|
186
|
+
if res and any("error" not in r for r in res):
|
|
187
|
+
return res
|
|
188
|
+
if depth != "balanced":
|
|
189
|
+
return engine_search(query, eng, n=max_results, timeout=timeout, depth="balanced", mode=mode)
|
|
190
|
+
return res if res else []
|
|
191
|
+
|
|
192
|
+
if parallel:
|
|
193
|
+
with ThreadPoolExecutor(max_workers=min(len(engines), 3)) as ex:
|
|
194
|
+
futures = {ex.submit(_exec_engine, eng): eng for eng in engines}
|
|
195
|
+
try:
|
|
196
|
+
for fut in as_completed(futures, timeout=timeout + 2):
|
|
197
|
+
eng = futures[fut]
|
|
198
|
+
try:
|
|
199
|
+
raw_results[eng] = fut.result()
|
|
200
|
+
except Exception as e:
|
|
201
|
+
raw_results[eng] = [{"error": str(e), "source": eng}]
|
|
202
|
+
except TimeoutError:
|
|
203
|
+
for fut in futures:
|
|
204
|
+
if not fut.done():
|
|
205
|
+
fut.cancel()
|
|
206
|
+
eng = futures[fut]
|
|
207
|
+
raw_results[eng] = [{"error": "timeout", "source": eng}]
|
|
208
|
+
for fut in futures:
|
|
209
|
+
if not fut.done():
|
|
210
|
+
fut.cancel()
|
|
211
|
+
else:
|
|
212
|
+
for eng in engines:
|
|
213
|
+
res = _exec_engine(eng)
|
|
214
|
+
raw_results[eng] = res
|
|
215
|
+
if res and any("error" not in r for r in res):
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
elapsed = int((time.time() - t0) * 1000)
|
|
219
|
+
|
|
220
|
+
# 融合
|
|
221
|
+
valid_lists = [res for res in raw_results.values()
|
|
222
|
+
if res and not any("error" in r for r in res)]
|
|
223
|
+
if len(valid_lists) > 1:
|
|
224
|
+
merged = rrf_merge(valid_lists)[:max_results]
|
|
225
|
+
elif valid_lists:
|
|
226
|
+
merged = deduplicate_by_url(valid_lists[0])[:max_results]
|
|
227
|
+
else:
|
|
228
|
+
merged = []
|
|
229
|
+
|
|
230
|
+
# Reranker 精排
|
|
231
|
+
if merged and len(merged) > 1:
|
|
232
|
+
merged = rerank_results(query, merged, top_n=max_results)
|
|
233
|
+
|
|
234
|
+
# 按 score 排序
|
|
235
|
+
if merged:
|
|
236
|
+
merged.sort(key=lambda r: abs(r.get("score", 0) or 0), reverse=True)
|
|
237
|
+
merged = merged[:max_results]
|
|
238
|
+
|
|
239
|
+
# 内嵌可信度评分(快速版本:authority + freshness,不含 cross_validation)
|
|
240
|
+
if merged:
|
|
241
|
+
try:
|
|
242
|
+
from evidence import score_authority, score_freshness
|
|
243
|
+
for r in merged:
|
|
244
|
+
url = r.get("url", "")
|
|
245
|
+
source = r.get("source", "")
|
|
246
|
+
auth = score_authority(url, source)
|
|
247
|
+
fresh = score_freshness(r)
|
|
248
|
+
r["authority"] = auth["score"]
|
|
249
|
+
r["authority_tier"] = auth["tier"]
|
|
250
|
+
r["freshness"] = fresh["score"]
|
|
251
|
+
except Exception:
|
|
252
|
+
pass # evidence 模块不可用时跳过
|
|
253
|
+
|
|
254
|
+
if on_progress:
|
|
255
|
+
on_progress(Stage.MERGING, {"count": len(merged)})
|
|
256
|
+
|
|
257
|
+
result_payload = {
|
|
258
|
+
"results": merged, "engines_used": list(raw_results.keys()), "domain": domain,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
# 写缓存
|
|
262
|
+
if not skip_cache:
|
|
263
|
+
effective_ttl = None
|
|
264
|
+
if elapsed > 2000:
|
|
265
|
+
multiplier = min(2 ** (elapsed // 2000), 8)
|
|
266
|
+
base_ttl = cache.resolve_ttl(domain)
|
|
267
|
+
effective_ttl = base_ttl * multiplier
|
|
268
|
+
cache.set(query, cache_engine_key, max_results, result_payload,
|
|
269
|
+
domain=domain, ttl=effective_ttl)
|
|
270
|
+
|
|
271
|
+
# 记录自适应学习数据
|
|
272
|
+
try:
|
|
273
|
+
from adaptive import get_learner
|
|
274
|
+
learner = get_learner()
|
|
275
|
+
for eng, res in raw_results.items():
|
|
276
|
+
success = bool(res and any("error" not in r for r in res))
|
|
277
|
+
latency = elapsed / max(len(raw_results), 1)
|
|
278
|
+
cost = get_cost_factor(eng)
|
|
279
|
+
learner.record(eng, success=success, latency_ms=latency, cost=0.0 if cost >= 0.85 else 0.001)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
282
|
+
|
|
283
|
+
if on_progress:
|
|
284
|
+
on_progress(Stage.DONE, {"count": len(merged), "elapsed_ms": elapsed})
|
|
285
|
+
|
|
286
|
+
tfidf_scores = decision.get("tfidf_scores", [])
|
|
287
|
+
if tfidf_scores and all(s.get("score", 0) == 0 for s in tfidf_scores):
|
|
288
|
+
tfidf_scores = []
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"query": query, "engine": engine_label, "engines": engines,
|
|
292
|
+
"engines_combo": engines_combo, "cached": False,
|
|
293
|
+
"domain": domain, "elapsed_ms": elapsed,
|
|
294
|
+
"tfidf_scores": tfidf_scores, "results": merged,
|
|
295
|
+
"count": len(merged), "engines_used": list(raw_results.keys()),
|
|
296
|
+
"errors": _collect_errors(raw_results), "mode": mode,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _collect_errors(raw_results: dict[str, list[dict[str, Any]]]) -> list[str]:
|
|
301
|
+
errors = []
|
|
302
|
+
for eng, res in raw_results.items():
|
|
303
|
+
for r in res:
|
|
304
|
+
if isinstance(r, dict) and "error" in r:
|
|
305
|
+
errors.append(f"{eng}: {r['error']}")
|
|
306
|
+
return errors
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ── 统一入口 ──────────────────────────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def super_search(query: str, engine: str = "auto", n: int = 5, explain: bool = False,
|
|
312
|
+
skip_cache: bool = False, timeout: int = 10,
|
|
313
|
+
depth: str = "fast", mode: str = "auto", local_first: bool = False,
|
|
314
|
+
rewrite: bool = True, cache: Any = None) -> dict[str, Any]:
|
|
315
|
+
"""统一搜索便捷入口。
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
query: 搜索查询词
|
|
319
|
+
engine: 指定引擎(默认 auto)
|
|
320
|
+
n: 最大结果数
|
|
321
|
+
explain: 是否输出路由解释
|
|
322
|
+
skip_cache: 是否跳过缓存
|
|
323
|
+
timeout: 超时
|
|
324
|
+
depth: 搜索深度
|
|
325
|
+
mode: 预算模式
|
|
326
|
+
local_first: 强制本地优先
|
|
327
|
+
rewrite: 是否自动改写查询(默认 True)
|
|
328
|
+
"""
|
|
329
|
+
cache = cache if cache is not None else SearchCache()
|
|
330
|
+
rewrite_result = None
|
|
331
|
+
|
|
332
|
+
# 查询改写:追加领域关键词提升搜索质量
|
|
333
|
+
if rewrite:
|
|
334
|
+
try:
|
|
335
|
+
from query_rewriter import rewrite_query as do_rewrite
|
|
336
|
+
rewrite_result = do_rewrite(query)
|
|
337
|
+
if rewrite_result["rewritten"] and rewrite_result["confidence"] >= 0.7:
|
|
338
|
+
query = rewrite_result["rewritten"]
|
|
339
|
+
except Exception:
|
|
340
|
+
pass # 改写失败不影响搜索
|
|
341
|
+
|
|
342
|
+
if local_first:
|
|
343
|
+
decision = route_query(query, engine_override="local_search", mode=mode)
|
|
344
|
+
else:
|
|
345
|
+
decision = route_query(query, engine_override=engine, mode=mode)
|
|
346
|
+
if explain:
|
|
347
|
+
combo = decision.get('engines_combo', decision.get('engines', []))
|
|
348
|
+
print(
|
|
349
|
+
f"[路由] {decision['reason']} → engine={decision['engine']} "
|
|
350
|
+
f"combo={combo} domain={decision.get('domain')} "
|
|
351
|
+
f"tfidf={decision.get('tfidf_scores', [])} mode={mode}",
|
|
352
|
+
file=sys.stderr,
|
|
353
|
+
)
|
|
354
|
+
result = execute_search(query=query, decision=decision, max_results=n,
|
|
355
|
+
timeout=timeout, depth=depth, cache=cache,
|
|
356
|
+
skip_cache=skip_cache, mode=mode)
|
|
357
|
+
if rewrite_result and rewrite_result["rewritten"]:
|
|
358
|
+
result["rewritten_query"] = {
|
|
359
|
+
"original": rewrite_result["original"],
|
|
360
|
+
"rewritten": rewrite_result["rewritten"],
|
|
361
|
+
"confidence": rewrite_result["confidence"],
|
|
362
|
+
"reason": rewrite_result["reason"],
|
|
363
|
+
}
|
|
364
|
+
return result
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ── 输出格式化 ─────────────────────────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
def format_text_output(results: dict[str, Any]) -> str:
|
|
370
|
+
lines = []
|
|
371
|
+
count = results.get("count", 0)
|
|
372
|
+
elapsed = results.get("elapsed_ms", 0)
|
|
373
|
+
engine = results.get("engine", "?")
|
|
374
|
+
cached = results.get("cached", False)
|
|
375
|
+
cache_level = results.get("cache_level", "")
|
|
376
|
+
domain = results.get("domain", "")
|
|
377
|
+
mode = results.get("mode", "auto")
|
|
378
|
+
|
|
379
|
+
header = f"=== {count} results ({elapsed}ms via {engine})"
|
|
380
|
+
if cached:
|
|
381
|
+
header += f" [CACHE {cache_level}]"
|
|
382
|
+
elif domain:
|
|
383
|
+
header += f" [domain:{domain}]"
|
|
384
|
+
if mode != "auto":
|
|
385
|
+
header += f" [mode:{mode}]"
|
|
386
|
+
lines.append(header)
|
|
387
|
+
|
|
388
|
+
for err in results.get("errors", [])[:3]:
|
|
389
|
+
lines.append(f" [ERROR] {err}")
|
|
390
|
+
|
|
391
|
+
for r in results.get("results", []):
|
|
392
|
+
score = r.get("score", 0)
|
|
393
|
+
title = r.get("title", "?")[:80]
|
|
394
|
+
url = r.get("url", "")
|
|
395
|
+
prefix = f"[{score:.2f}]" if score else "[?]"
|
|
396
|
+
lines.append(f" {prefix} {title}")
|
|
397
|
+
if url:
|
|
398
|
+
lines.append(f" {url}")
|
|
399
|
+
snippet = r.get("snippet", "")
|
|
400
|
+
if snippet:
|
|
401
|
+
lines.append(f" {snippet[:120]}")
|
|
402
|
+
|
|
403
|
+
return "\n".join(lines)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ── CLI 主入口 ─────────────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
def main():
|
|
409
|
+
parser = argparse.ArgumentParser(
|
|
410
|
+
description="Unified Search v2 — 统一搜索 CLI",
|
|
411
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
412
|
+
epilog="""
|
|
413
|
+
示例:
|
|
414
|
+
python3 search.py "python async"
|
|
415
|
+
python3 search.py "英伟达财报" --explain --json
|
|
416
|
+
python3 search.py "基金推荐" --mode fast
|
|
417
|
+
python3 search.py "AAPL" --engine anysearch --domain finance --sub_domain finance.us_stock
|
|
418
|
+
""",
|
|
419
|
+
)
|
|
420
|
+
parser.add_argument("query", nargs="?")
|
|
421
|
+
parser.add_argument("--engine", "-e", default="auto")
|
|
422
|
+
parser.add_argument("--max-results", "-n", type=int, default=5)
|
|
423
|
+
parser.add_argument("--depth", "-d", default="fast",
|
|
424
|
+
choices=["fast", "balanced", "deep"])
|
|
425
|
+
parser.add_argument("--no-cache", action="store_true")
|
|
426
|
+
parser.add_argument("--explain", action="store_true")
|
|
427
|
+
parser.add_argument("--json", action="store_true", dest="json_output")
|
|
428
|
+
parser.add_argument("--timeout", "-t", type=int, default=10)
|
|
429
|
+
parser.add_argument("--list-engines", action="store_true")
|
|
430
|
+
parser.add_argument("--mode", default="auto",
|
|
431
|
+
choices=["fast", "auto", "deep", "budget"],
|
|
432
|
+
help="预算模式: fast=免费优先, auto=成本感知, deep=质量优先, budget=配额控制")
|
|
433
|
+
parser.add_argument("--local-first", action="store_true",
|
|
434
|
+
help="强制优先使用 local_search 零成本聚合引擎")
|
|
435
|
+
parser.add_argument("--domain", default="", help="AnySearch 垂直域")
|
|
436
|
+
parser.add_argument("--sub_domain", default="", help="AnySearch 子域")
|
|
437
|
+
parser.add_argument("--progress", action="store_true")
|
|
438
|
+
|
|
439
|
+
args = parser.parse_args()
|
|
440
|
+
|
|
441
|
+
if args.list_engines:
|
|
442
|
+
print(json.dumps(available_engines(), ensure_ascii=False, indent=2))
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
if not args.query:
|
|
446
|
+
parser.error("必须提供搜索关键词")
|
|
447
|
+
|
|
448
|
+
cache = SearchCache()
|
|
449
|
+
if args.local_first:
|
|
450
|
+
decision = route_query(args.query, engine_override="local_search", mode=args.mode)
|
|
451
|
+
else:
|
|
452
|
+
decision = route_query(args.query, engine_override=args.engine, mode=args.mode)
|
|
453
|
+
|
|
454
|
+
# 查询改写
|
|
455
|
+
rewrite_result = None
|
|
456
|
+
try:
|
|
457
|
+
from query_rewriter import rewrite_query as do_rewrite
|
|
458
|
+
rewrite_result = do_rewrite(args.query)
|
|
459
|
+
if rewrite_result["rewritten"] and rewrite_result["confidence"] >= 0.7:
|
|
460
|
+
search_query = rewrite_result["rewritten"]
|
|
461
|
+
else:
|
|
462
|
+
search_query = args.query
|
|
463
|
+
except Exception:
|
|
464
|
+
search_query = args.query
|
|
465
|
+
|
|
466
|
+
if args.explain:
|
|
467
|
+
combo = decision.get('engines_combo', decision.get('engines', []))
|
|
468
|
+
print(
|
|
469
|
+
f"[路由] {decision['reason']} → engine={decision['engine']} "
|
|
470
|
+
f"combo={combo} domain={decision.get('domain')} "
|
|
471
|
+
f"tfidf={decision.get('tfidf_scores', [])} mode={args.mode}",
|
|
472
|
+
file=sys.stderr,
|
|
473
|
+
)
|
|
474
|
+
if rewrite_result and rewrite_result["rewritten"]:
|
|
475
|
+
print(
|
|
476
|
+
f"[改写] {rewrite_result['original']} → {rewrite_result['rewritten']} "
|
|
477
|
+
f"({rewrite_result['confidence']:.0%})",
|
|
478
|
+
file=sys.stderr,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
on_progress = None
|
|
482
|
+
if args.progress:
|
|
483
|
+
def on_progress(stage: Stage, data: dict[str, Any]):
|
|
484
|
+
print(f"[progress] {stage.value} {data}", file=sys.stderr)
|
|
485
|
+
|
|
486
|
+
results = execute_search(
|
|
487
|
+
query=search_query, decision=decision, max_results=args.max_results,
|
|
488
|
+
timeout=args.timeout, depth=args.depth, cache=cache,
|
|
489
|
+
skip_cache=args.no_cache, mode=args.mode, on_progress=on_progress,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
if rewrite_result and rewrite_result["rewritten"]:
|
|
493
|
+
results["rewritten_query"] = {
|
|
494
|
+
"original": rewrite_result["original"],
|
|
495
|
+
"rewritten": rewrite_result["rewritten"],
|
|
496
|
+
"confidence": rewrite_result["confidence"],
|
|
497
|
+
"reason": rewrite_result["reason"],
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if args.json_output:
|
|
501
|
+
public = {k: v for k, v in results.items() if not k.startswith("_")}
|
|
502
|
+
print(json.dumps(public, ensure_ascii=False, indent=2))
|
|
503
|
+
else:
|
|
504
|
+
print(format_text_output(results))
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == "__main__":
|
|
508
|
+
main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
search_types.py — 统一类型系统
|
|
4
|
+
|
|
5
|
+
定义 SearchResult 数据类和 normalize_result 统一转换,
|
|
6
|
+
将所有引擎的原始输出统一为结构化格式。
|
|
7
|
+
|
|
8
|
+
字段规范:
|
|
9
|
+
- title: 结果标题(必填,截断到 200 字符)
|
|
10
|
+
- url: 结果链接(可选)
|
|
11
|
+
- snippet: 摘要/片段(截断到 300 字符)
|
|
12
|
+
- score: 相关性评分(默认 0.5)
|
|
13
|
+
- source: 引擎来源
|
|
14
|
+
- metadata: 元数据字典
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field, asdict
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SearchResult:
|
|
24
|
+
"""统一的搜索结果数据类。"""
|
|
25
|
+
title: str
|
|
26
|
+
url: str = ""
|
|
27
|
+
snippet: str = ""
|
|
28
|
+
score: float = 0.5
|
|
29
|
+
source: str = ""
|
|
30
|
+
metadata: dict = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, Any]:
|
|
33
|
+
"""转为字典,省略空值/默认值字段。"""
|
|
34
|
+
d: dict[str, Any] = {"title": self.title}
|
|
35
|
+
if self.url:
|
|
36
|
+
d["url"] = self.url
|
|
37
|
+
if self.snippet:
|
|
38
|
+
d["snippet"] = self.snippet[:300]
|
|
39
|
+
if self.score != 0.5:
|
|
40
|
+
d["score"] = round(self.score, 3)
|
|
41
|
+
if self.source:
|
|
42
|
+
d["source"] = self.source
|
|
43
|
+
if self.metadata:
|
|
44
|
+
d["metadata"] = self.metadata
|
|
45
|
+
return d
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_result(item: dict, engine: str, default_score: float = 0.5) -> SearchResult:
|
|
49
|
+
"""将任意引擎输出统一为 SearchResult。
|
|
50
|
+
|
|
51
|
+
处理字段名混乱(score/relevance_score/snippet/content/summary 混用)。
|
|
52
|
+
"""
|
|
53
|
+
snippet = (
|
|
54
|
+
item.get("snippet")
|
|
55
|
+
or item.get("content")
|
|
56
|
+
or item.get("summary")
|
|
57
|
+
or item.get("description", "")
|
|
58
|
+
)
|
|
59
|
+
raw_score = item.get("score", item.get("relevance_score", default_score))
|
|
60
|
+
try:
|
|
61
|
+
score = float(raw_score) if isinstance(raw_score, (int, float, str)) else default_score
|
|
62
|
+
except (ValueError, TypeError):
|
|
63
|
+
score = default_score
|
|
64
|
+
|
|
65
|
+
return SearchResult(
|
|
66
|
+
title=item.get("title", "")[:200],
|
|
67
|
+
url=item.get("url", ""),
|
|
68
|
+
snippet=snippet[:300] if snippet else "",
|
|
69
|
+
score=score,
|
|
70
|
+
source=item.get("source", engine),
|
|
71
|
+
metadata=item.get("metadata", {}),
|
|
72
|
+
)
|