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.
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ route.py — Unified Search v2 三层路由决策
4
+
5
+ 路由策略:
6
+ 1. 用户指定引擎 → 直接返回
7
+ 2. TF-IDF 语义路由(二元组 + boost + cost + quota)
8
+ 3. 正则硬规则匹配(config.yaml domains)
9
+ 4. 融合决策:正则 + TF-IDF 验证 → 高置信度
10
+ 5. budget 模式:过滤付费引擎
11
+
12
+ 每种决策都带 reason 字符串。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import time
19
+ from typing import Any
20
+
21
+ try:
22
+ from config import load_config, get_engines, get_domains, get_cost_factor
23
+ from tfidf_router import semantic_route, get_router
24
+ from quota import get_quota_manager
25
+ except ImportError:
26
+ import sys
27
+ from pathlib import Path
28
+ sys.path.insert(0, str(Path(__file__).parent))
29
+ from config import load_config, get_engines, get_domains, get_cost_factor
30
+ from tfidf_router import semantic_route, get_router
31
+ from quota import get_quota_manager
32
+
33
+ # 自适应学习(可选依赖)
34
+ try:
35
+ from adaptive import get_learner
36
+ _adaptive_learner = get_learner()
37
+ except Exception:
38
+ _adaptive_learner = None
39
+
40
+
41
+ # ── 特征提取 ──────────────────────────────────────────────────────────────────
42
+
43
+ _RE_CHINESE = re.compile(r"[一-鿿]")
44
+ _RE_COMPARE = re.compile(r"\b(vs|versus)\b|(对比|比较|区别|相比|哪个好)", re.I)
45
+ _RE_TECH = re.compile(
46
+ r"\b(api|python|javascript|typescript|code|react|vue|node|rust|go|"
47
+ r"golang|docker|kubernetes|linux|git|sql|error|bug|debug|exception|"
48
+ r"function|class|async|thread|database|algorithm|programming|framework|library)\b|"
49
+ r"(函数|方法|类|库|框架|报错|调试|编程|代码|开发|技术|源码|架构)", re.I)
50
+ _RE_QUESTION = re.compile(
51
+ r"\b(how|what|why|when|where|which|who)\b|"
52
+ r"(怎么|什么|为什么|如何|哪里|哪个|谁|多少|几|吗|呢)", re.I)
53
+ _RE_DEPTH = re.compile(
54
+ r"\b(deep|comprehensive|review|survey|research|paper|thesis)\b|"
55
+ r"(对比分析|深度|全面|详细|深入|系统|完整|综述|研究|探究|详解|论文)", re.I)
56
+
57
+ _ENGINE_NAMES = {
58
+ "anysearch": "AnySearch", "wigolo": "Wigolo", "tavily": "Tavily",
59
+ "zhihu": "知乎", "eastmoney": "东方财富", "byted": "字节搜索",
60
+ "arxiv": "arXiv", "searxng": "SearXNG", "felo": "Felo",
61
+ "bocha": "博查", "openalex": "OpenAlex", "crossref": "Crossref",
62
+ "github": "GitHub", "wikipedia": "Wikipedia", "metaso": "秘塔",
63
+ "wolframalpha": "WolframAlpha", "brave": "Brave",
64
+ "duckduckgo": "DuckDuckGo", "uapi": "UAPI", "semantic_scholar": "Semantic Scholar",
65
+ }
66
+
67
+
68
+ def extract_features(query: str) -> dict[str, Any]:
69
+ """提取查询特征向量。"""
70
+ total = len(query)
71
+ chinese = len(_RE_CHINESE.findall(query))
72
+ ratio = chinese / max(total, 1)
73
+ return {
74
+ "chinese_ratio": ratio,
75
+ "english_ratio": 1.0 - ratio,
76
+ "length": total,
77
+ "has_compare": bool(_RE_COMPARE.search(query)),
78
+ "has_technical": bool(_RE_TECH.search(query)),
79
+ "has_question": bool(_RE_QUESTION.search(query)),
80
+ "has_depth_word": bool(_RE_DEPTH.search(query)),
81
+ }
82
+
83
+
84
+ def _feature_labels(features: dict[str, Any]) -> str:
85
+ labels = []
86
+ cr = features.get("chinese_ratio", 0)
87
+ if cr > 0.6:
88
+ labels.append("中文")
89
+ elif cr < 0.1:
90
+ labels.append("英文")
91
+ for key, name in (("has_technical", "技术向"), ("has_compare", "对比分析"),
92
+ ("has_depth_word", "深度研究"), ("has_question", "问答型")):
93
+ if features.get(key):
94
+ labels.append(name)
95
+ return " + ".join(labels) if labels else "通用查询"
96
+
97
+
98
+ # ── 域匹配 ────────────────────────────────────────────────────────────────────
99
+
100
+ def _compile_domain_patterns(domains: list[dict[str, Any]]) -> list[dict[str, Any]]:
101
+ compiled = []
102
+ for idx, domain in enumerate(domains):
103
+ patterns = domain.get("patterns", [])
104
+ if isinstance(patterns, str):
105
+ patterns = []
106
+ regexes = []
107
+ for p in patterns:
108
+ try:
109
+ regexes.append(re.compile(p))
110
+ except re.error:
111
+ continue
112
+ compiled.append({**domain, "_idx": idx, "_compiled": regexes})
113
+ return compiled
114
+
115
+
116
+ def match_domain(query: str, domains: list[dict[str, Any]] | None = None) -> dict[str, Any] | None:
117
+ """按 config.yaml domains 顺序匹配;无命中返回 catch-all。"""
118
+ if domains is None:
119
+ domains = get_domains()
120
+ compiled = _compile_domain_patterns(domains)
121
+ catch_all: dict[str, Any] | None = None
122
+ for domain in compiled:
123
+ if not domain.get("patterns", []):
124
+ catch_all = domain
125
+ continue
126
+ for regex in domain["_compiled"]:
127
+ if regex.search(query):
128
+ return domain
129
+ return catch_all
130
+
131
+
132
+ def _get_engines_combo(domain: dict[str, Any], enabled: set[str], mode: str = "auto") -> list[str]:
133
+ """从域配置获取 engines_combo,过滤不可用/付费(budget 模式)。"""
134
+ combo = domain.get("engines_combo", [])
135
+ if combo:
136
+ filtered = [e for e in combo if e in enabled]
137
+ else:
138
+ primary = domain.get("primary", "anysearch")
139
+ fallback = domain.get("fallback")
140
+ engines = [primary]
141
+ if fallback and fallback != primary:
142
+ engines.append(fallback)
143
+ filtered = [e for e in engines if e in enabled]
144
+
145
+ # budget/fast 模式过滤付费引擎
146
+ if mode in ("fast", "budget"):
147
+ quota_mgr = get_quota_manager()
148
+ filtered = [e for e in filtered if quota_mgr.is_available(e, mode=mode)]
149
+
150
+ # fast/budget 模式优先前置 local_search(零成本兜底)
151
+ if mode in ("fast", "budget") and "local_search" in enabled and "local_search" not in filtered:
152
+ filtered.insert(0, "local_search")
153
+
154
+ # fast 模式:只保留免费/本地引擎,最多 2 个,确保速度和零成本
155
+ if mode == "fast":
156
+ from config import get_cost_factor
157
+ filtered = [e for e in filtered if get_cost_factor(e) >= 0.85]
158
+ if "local_search" in filtered:
159
+ filtered.remove("local_search")
160
+ filtered.insert(0, "local_search")
161
+ filtered = filtered[:2]
162
+
163
+ # 自适应学习过滤:排除近期表现差的引擎(成功率 < 30% 或综合评分 < 0.3)
164
+ if _adaptive_learner is not None and len(filtered) > 1:
165
+ original = filtered[:]
166
+ filtered = [e for e in filtered if _adaptive_learner.get_score(e) >= 0.3]
167
+ if not filtered:
168
+ filtered = original
169
+ elif len(filtered) < len(original) and "local_search" in enabled and "local_search" not in filtered:
170
+ filtered.append("local_search")
171
+
172
+ # 健康探针过滤:排除已知不可达的 HTTP 引擎
173
+ try:
174
+ from health_probe import get_engine_status
175
+ healthy = [e for e in filtered if get_engine_status(e).get("available", True)]
176
+ if healthy:
177
+ filtered = healthy
178
+ except Exception:
179
+ pass # 健康探针模块不可用时跳过
180
+
181
+ return filtered
182
+
183
+
184
+ # ── 路由主函数 ─────────────────────────────────────────────────────────────────
185
+
186
+ def route_query(query: str, engine_override: str = "auto",
187
+ mode: str = "auto") -> dict[str, Any]:
188
+ """路由决策主函数。
189
+
190
+ Args:
191
+ query: 查询词
192
+ engine_override: 用户指定引擎
193
+ mode: 预算模式 (fast/auto/deep/budget)
194
+
195
+ Returns:
196
+ dict: {engine, engines, engines_combo, reason, confidence, domain, ...}
197
+ """
198
+ start = time.perf_counter()
199
+
200
+ def _done(**kw: Any) -> dict[str, Any]:
201
+ base = {"elapsed_ms": round((time.perf_counter() - start) * 1000, 3)}
202
+ base.update(kw)
203
+ return base
204
+
205
+ if engine_override != "auto":
206
+ return _done(
207
+ engine=engine_override, engines=[engine_override],
208
+ engines_combo=[engine_override],
209
+ reason=f"用户指定: {engine_override}", confidence=1.0,
210
+ features={}, domain=None, parallel=False, mode=mode,
211
+ )
212
+
213
+ features = extract_features(query)
214
+ cfg = load_config()
215
+ enabled = set(get_engines(cfg).keys())
216
+
217
+ # TF-IDF 语义路由
218
+ tfidf_best = None
219
+ tfidf_scores = []
220
+ try:
221
+ tfidf_scores = semantic_route(query, top_k=3)
222
+ if tfidf_scores:
223
+ tfidf_best = tfidf_scores[0][0]
224
+ except Exception:
225
+ pass
226
+
227
+ # 预算模式过滤可用引擎
228
+ quota_mgr = get_quota_manager()
229
+ if mode in ("fast", "budget"):
230
+ enabled = {e for e in enabled if quota_mgr.is_available(e, mode=mode)}
231
+
232
+ # 正则硬规则匹配
233
+ domain = match_domain(query, get_domains(cfg))
234
+
235
+ if domain:
236
+ engines_combo = _get_engines_combo(domain, enabled, mode)
237
+ if not engines_combo:
238
+ # 域内引擎全被过滤,回退
239
+ engines_combo = [e for e in ["local_search", "anysearch", "duckduckgo"] if e in enabled]
240
+ if not engines_combo:
241
+ engines_combo = sorted(enabled)[:2] if enabled else ["anysearch"]
242
+
243
+ # TF-IDF 验证 + catch-all 修复
244
+ tfidf_best_score = tfidf_scores[0][1] if tfidf_scores else 0.0
245
+ is_catch_all = not domain.get("patterns", []) # 无模式 = 兜底域
246
+
247
+ if tfidf_best and tfidf_best in engines_combo:
248
+ confidence = 0.95
249
+ elif tfidf_best and tfidf_best != engines_combo[0]:
250
+ confidence = 0.8
251
+ # catch-all 域 + TF-IDF 高置信度推荐 → 注入推荐引擎到首位
252
+ if is_catch_all and tfidf_best_score > 0.15 and tfidf_best in enabled:
253
+ engines_combo = [tfidf_best] + [e for e in engines_combo if e != tfidf_best]
254
+ confidence = 0.85
255
+ else:
256
+ confidence = 0.9
257
+ # catch-all 域 + TF-IDF 推荐但不在 combo 中 → 前置
258
+ if is_catch_all and tfidf_best and tfidf_best_score > 0.15 and tfidf_best in enabled:
259
+ engines_combo.insert(0, tfidf_best)
260
+ confidence = 0.8
261
+
262
+ parallel = bool(domain.get("parallel", False)) or len(engines_combo) > 2
263
+ # fast 模式强制串行,先 local_search 成功即避免额外 HTTP 开销
264
+ if mode == "fast":
265
+ parallel = False
266
+
267
+ return _done(
268
+ engine=engines_combo[0],
269
+ engines=engines_combo,
270
+ engines_combo=engines_combo,
271
+ engines_fallback=[e for e in enabled if e not in engines_combo],
272
+ reason=(
273
+ f"{_feature_labels(features)} → 命中域 [{domain.get('name', '?')}]"
274
+ + (f" [TF-IDF→{tfidf_best}]" if tfidf_best else "")
275
+ + (f" [TF-IDF覆写catch-all]" if is_catch_all and tfidf_best and tfidf_best_score > 0.15 and tfidf_best in engines_combo else "")
276
+ + f" → {_ENGINE_NAMES.get(engines_combo[0], engines_combo[0])}"
277
+ ),
278
+ confidence=confidence, features=features,
279
+ domain=domain.get("name"), parallel=parallel,
280
+ tfidf_scores=[{"engine": n, "score": s} for n, s, _ in tfidf_scores],
281
+ mode=mode,
282
+ )
283
+
284
+ # 正则未命中,用 TF-IDF 结果
285
+ if tfidf_best and tfidf_best in enabled:
286
+ engines_combo = [tfidf_best]
287
+ if "anysearch" in enabled and "anysearch" not in engines_combo:
288
+ engines_combo.append("anysearch")
289
+ engines_combo = [e for e in engines_combo if e in enabled]
290
+
291
+ return _done(
292
+ engine=engines_combo[0],
293
+ engines=engines_combo,
294
+ engines_combo=engines_combo,
295
+ reason=f"TF-IDF 语义路由 → {_ENGINE_NAMES.get(tfidf_best, tfidf_best)} (正则未命中)",
296
+ confidence=0.85, features=features, domain=None,
297
+ parallel=len(engines_combo) > 1,
298
+ tfidf_scores=[{"engine": n, "score": s} for n, s, _ in tfidf_scores],
299
+ mode=mode,
300
+ )
301
+
302
+ # 兜底
303
+ fallback_combo = [e for e in ["local_search", "anysearch", "duckduckgo"] if e in enabled]
304
+ if not fallback_combo:
305
+ fallback_combo = sorted(enabled)[:2] if enabled else ["anysearch"]
306
+
307
+ return _done(
308
+ engine=fallback_combo[0],
309
+ engines=fallback_combo,
310
+ engines_combo=fallback_combo,
311
+ engines_fallback=[],
312
+ reason=f"无匹配域,回退 {_ENGINE_NAMES.get(fallback_combo[0], fallback_combo[0])}",
313
+ confidence=0.3, features=features, domain=None, parallel=False,
314
+ tfidf_scores=[{"engine": n, "score": s} for n, s, _ in tfidf_scores],
315
+ mode=mode,
316
+ )
317
+
318
+
319
+ # ── CLI ────────────────────────────────────────────────────────────────────────
320
+
321
+ def _cli():
322
+ import argparse
323
+ parser = argparse.ArgumentParser(description="Unified Search v2 路由器")
324
+ parser.add_argument("query")
325
+ parser.add_argument("--engine", default="auto")
326
+ parser.add_argument("--mode", default="auto", choices=["fast", "auto", "deep", "budget"])
327
+ parser.add_argument("--json", action="store_true")
328
+ args = parser.parse_args()
329
+ decision = route_query(args.query, engine_override=args.engine, mode=args.mode)
330
+ if args.json:
331
+ print(json.dumps(decision, ensure_ascii=False, indent=2))
332
+ else:
333
+ print(f"引擎: {decision['engine']}")
334
+ print(f"组合: {decision.get('engines_combo', decision['engines'])}")
335
+ print(f"原因: {decision['reason']}")
336
+ print(f"置信度: {decision['confidence']:.2f}")
337
+ print(f"耗时: {decision.get('elapsed_ms', 0):.3f} ms")
338
+
339
+
340
+ if __name__ == "__main__":
341
+ _cli()