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,312 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ tfidf_router.py — Unified Search v2 TF-IDF 语义路由引擎
4
+
5
+ 原理:
6
+ 1. 加载各引擎的领域文档,构建语料库
7
+ 2. 对每个引擎计算 TF-IDF 向量(代表该领域的"语义中心")
8
+ 3. 新查询来了,向量化后与各引擎算余弦相似度
9
+ 4. 结合 boost_keywords / boost_combos / 配额状态打分
10
+
11
+ 增强(v2):
12
+ - 成本感知:free=1.0 / low=0.85 / paid=0.6
13
+ - 延迟感知:基于 adaptive.py 历史数据微调
14
+ - 返回带 reason 的决策说明
15
+
16
+ 所有计算纯本地,零 API 开销,典型延迟 <5ms。
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import math
23
+ import re
24
+ from collections import Counter
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ # ── 路径 ──────────────────────────────────────────────────────────────────────
29
+
30
+ SKILL_DIR = Path(__file__).parent.parent
31
+ BACKENDS_DIR = SKILL_DIR / "backends"
32
+ DOMAIN_PROFILES_PATH = BACKENDS_DIR / "domain_profiles.json"
33
+
34
+ # ── 分词 ──────────────────────────────────────────────────────────────────────
35
+
36
+ _TOKEN_PATTERN = re.compile(r"[一-鿿]|[a-zA-Z0-9]+")
37
+
38
+
39
+ def tokenize(text: str) -> list[str]:
40
+ """简单分词:中文单字 + 英文单词,统一小写。"""
41
+ tokens = []
42
+ for match in _TOKEN_PATTERN.finditer(text.lower()):
43
+ token = match.group()
44
+ if len(token) == 1 and '一' <= token <= '鿿':
45
+ tokens.append(token)
46
+ elif len(token) > 1:
47
+ tokens.append(token)
48
+ return tokens
49
+
50
+
51
+ def tokenize_with_bigrams(text: str) -> list[str]:
52
+ """分词 + 二元组,捕捉短语信息。"""
53
+ unigrams = tokenize(text)
54
+ bigrams = [f"{unigrams[i]}_{unigrams[i+1]}" for i in range(len(unigrams) - 1)]
55
+ return unigrams + bigrams
56
+
57
+
58
+ # ── TF-IDF 计算 ────────────────────────────────────────────────────────────────
59
+
60
+ class TfidfVectorizer:
61
+ """轻量 TF-IDF 向量化器,不需要 sklearn。"""
62
+
63
+ def __init__(self):
64
+ self.idf: dict[str, float] = {}
65
+ self.vocab: set[str] = set()
66
+
67
+ def fit(self, documents: list[str]) -> None:
68
+ """从文档集合计算 IDF。"""
69
+ df: Counter = Counter()
70
+ total = len(documents)
71
+ for doc in documents:
72
+ tokens = set(tokenize_with_bigrams(doc))
73
+ self.vocab.update(tokens)
74
+ for token in tokens:
75
+ df[token] += 1
76
+ for token, count in df.items():
77
+ self.idf[token] = math.log((total + 1) / (count + 1)) + 1
78
+
79
+ def transform(self, text: str) -> dict[str, float]:
80
+ """把文本转为 TF-IDF 向量(稀疏表示)。"""
81
+ tokens = tokenize_with_bigrams(text)
82
+ tf = Counter(tokens)
83
+ total = len(tokens) if tokens else 1
84
+ return {token: (count / total) * self.idf[token]
85
+ for token, count in tf.items() if token in self.idf}
86
+
87
+
88
+ # ── 余弦相似度 ─────────────────────────────────────────────────────────────────
89
+
90
+ def cosine_similarity(vec_a: dict[str, float], vec_b: dict[str, float]) -> float:
91
+ """两个稀疏向量的余弦相似度。"""
92
+ if not vec_a or not vec_b:
93
+ return 0.0
94
+ common = set(vec_a.keys()) & set(vec_b.keys())
95
+ dot = sum(vec_a[k] * vec_b[k] for k in common)
96
+ norm_a = math.sqrt(sum(v * v for v in vec_a.values()))
97
+ norm_b = math.sqrt(sum(v * v for v in vec_b.values()))
98
+ if norm_a == 0 or norm_b == 0:
99
+ return 0.0
100
+ return dot / (norm_a * norm_b)
101
+
102
+
103
+ # ── 配额感知 ──────────────────────────────────────────────────────────────────
104
+
105
+ def _load_quota_state() -> dict:
106
+ """加载配额状态文件。"""
107
+ quota_path = Path.home() / ".cache" / "unified-search" / "quota.json"
108
+ if not quota_path.exists():
109
+ return {}
110
+ try:
111
+ return json.loads(quota_path.read_text())
112
+ except (json.JSONDecodeError, OSError):
113
+ return {}
114
+
115
+
116
+ def _quota_ratio(engine: str, quota_state: dict) -> float:
117
+ """计算配额剩余比例。返回 0.0-1.0,无记录时默认 1.0。"""
118
+ info = quota_state.get(engine)
119
+ if not info:
120
+ return 1.0
121
+ limit = info.get("limit", 100)
122
+ used = info.get("used", 0)
123
+ return max(0, limit - used) / limit
124
+
125
+
126
+ # ── 成本感知 ──────────────────────────────────────────────────────────────────
127
+
128
+ def _cost_factor(engine: str) -> float:
129
+ """获取引擎成本因子:free=1.0, low=0.85, paid=0.6。"""
130
+ tiers_path = BACKENDS_DIR / "quota_profiles.json"
131
+ if not tiers_path.exists():
132
+ return 1.0
133
+ try:
134
+ profiles = json.loads(tiers_path.read_text())
135
+ except (json.JSONDecodeError, OSError):
136
+ return 1.0
137
+ profile = profiles.get(engine, {})
138
+ tier = profile.get("cost_tier", "free")
139
+ return {"free": 1.0, "low": 0.85, "paid": 0.6}.get(tier, 1.0)
140
+
141
+
142
+ # ── 主路由引擎 ─────────────────────────────────────────────────────────────────
143
+
144
+ class SemanticRouter:
145
+ """TF-IDF 语义路由引擎(v2 增强版)。"""
146
+
147
+ def __init__(self):
148
+ self.vectorizer = TfidfVectorizer()
149
+ self.engine_names: list[str] = []
150
+ self.engine_vectors: dict[str, dict[str, float]] = {}
151
+ self.boost_keywords: dict[str, dict[str, float]] = {}
152
+ self.boost_combos: dict[str, dict[str, float]] = {}
153
+ self._loaded = False
154
+
155
+ def _ensure_loaded(self) -> None:
156
+ """懒加载领域文档,只加载一次。"""
157
+ if self._loaded:
158
+ return
159
+ if not DOMAIN_PROFILES_PATH.exists():
160
+ self._loaded = True
161
+ return
162
+ try:
163
+ profiles = json.loads(DOMAIN_PROFILES_PATH.read_text())
164
+ except (json.JSONDecodeError, OSError):
165
+ self._loaded = True
166
+ return
167
+
168
+ corpus = []
169
+ for name, profile in profiles.items():
170
+ if name.startswith("_"):
171
+ continue
172
+ self.engine_names.append(name)
173
+ self.boost_keywords[name] = profile.get("boost_keywords", {})
174
+ self.boost_combos[name] = profile.get("boost_combos", {})
175
+ corpus.append(" ".join(profile.get("documents", [])))
176
+
177
+ if corpus:
178
+ self.vectorizer.fit(corpus)
179
+ for i, name in enumerate(self.engine_names):
180
+ self.engine_vectors[name] = self.vectorizer.transform(corpus[i])
181
+ self._loaded = True
182
+
183
+ def route(self, query: str, top_k: int = 3,
184
+ quota_aware: bool = True) -> list[tuple[str, float, str]]:
185
+ """
186
+ 路由查询到最匹配的引擎。
187
+
188
+ 返回: [(engine_name, score, reason), ...] 按分数降序。
189
+ """
190
+ self._ensure_loaded()
191
+ if not self.engine_names:
192
+ return [("anysearch", 0.0, "无领域文档,回退 AnySearch")]
193
+
194
+ query_vec = self.vectorizer.transform(query)
195
+ scores = []
196
+ quota_state = _load_quota_state() if quota_aware else {}
197
+
198
+ for name in self.engine_names:
199
+ sim = cosine_similarity(query_vec, self.engine_vectors[name])
200
+ parts = []
201
+
202
+ # boost 加权
203
+ boost = 1.0
204
+ hit_kws = []
205
+ for kw, weight in self.boost_keywords.get(name, {}).items():
206
+ if kw.lower() in query.lower():
207
+ boost += weight - 1.0
208
+ hit_kws.append(kw)
209
+ if hit_kws:
210
+ parts.append(f"boost关键词:{','.join(hit_kws)}")
211
+
212
+ # 组合关键词加成
213
+ hit_combos = []
214
+ for combo, bonus in self.boost_combos.get(name, {}).items():
215
+ if all(w.lower() in query.lower() for w in combo.split()):
216
+ boost += bonus
217
+ hit_combos.append(combo)
218
+ if hit_combos:
219
+ parts.append(f"combo:{','.join(hit_combos)}")
220
+
221
+ final_score = sim * boost
222
+
223
+ # 成本感知
224
+ cf = _cost_factor(name)
225
+ if cf < 1.0:
226
+ final_score *= cf
227
+ parts.append(f"cost={cf}")
228
+
229
+ # 配额感知惩罚
230
+ if quota_aware and quota_state:
231
+ qr = _quota_ratio(name, quota_state)
232
+ if qr < 0.2:
233
+ final_score *= qr * 2
234
+ parts.append(f"配额紧张({qr:.0%})")
235
+ elif qr < 0.5:
236
+ final_score *= 0.5 + qr
237
+ parts.append(f"配额偏低({qr:.0%})")
238
+
239
+ reason = f"sim={sim:.3f}" + (f", {', '.join(parts)}" if parts else "")
240
+ scores.append((name, round(final_score, 4), reason))
241
+
242
+ scores.sort(key=lambda x: -x[1])
243
+ return scores[:top_k]
244
+
245
+ def should_parallel(self, query: str, scores: list[tuple[str, float, str]]) -> bool:
246
+ """判断是否需要多路并行搜索。"""
247
+ research_signals = [
248
+ "分析", "研究", "格局", "趋势", "深度", "全面", "详细",
249
+ "research", "analysis", "comprehensive", "deep",
250
+ "对比", "评测", "推荐", "review", "comparison"
251
+ ]
252
+ if any(kw in query.lower() for kw in research_signals):
253
+ return True
254
+ if not scores or scores[0][1] < 0.15:
255
+ return True
256
+ if len(scores) >= 2 and scores[0][1] > 0:
257
+ gap = (scores[0][1] - scores[1][1]) / scores[0][1]
258
+ if gap < 0.2:
259
+ return True
260
+ return False
261
+
262
+
263
+ # ── 模块级单例 ─────────────────────────────────────────────────────────────────
264
+
265
+ _router: Optional[SemanticRouter] = None
266
+
267
+
268
+ def get_router() -> SemanticRouter:
269
+ global _router
270
+ if _router is None:
271
+ _router = SemanticRouter()
272
+ return _router
273
+
274
+
275
+ def semantic_route(query: str, top_k: int = 3) -> list[tuple[str, float, str]]:
276
+ """便捷函数:语义路由。"""
277
+ return get_router().route(query, top_k=top_k)
278
+
279
+
280
+ def semantic_route_auto(query: str) -> str:
281
+ """便捷函数:返回最优引擎名。"""
282
+ scores = semantic_route(query, top_k=1)
283
+ return scores[0][0] if scores else "anysearch"
284
+
285
+
286
+ # ── CLI 测试 ──────────────────────────────────────────────────────────────────
287
+
288
+ if __name__ == "__main__":
289
+ import sys
290
+
291
+ test_queries = sys.argv[1:] if len(sys.argv) > 1 else [
292
+ "英伟达最新财报",
293
+ "Python 异步编程最佳实践",
294
+ "2026年AI芯片行业竞争格局",
295
+ "美联储加息对股市影响",
296
+ "笔记本电脑推荐 2026",
297
+ "小米汽车销量",
298
+ "latest AI research papers",
299
+ "transformer attention mechanism paper",
300
+ "北京旅游攻略",
301
+ "基金定投策略",
302
+ ]
303
+
304
+ router = get_router()
305
+ print(f"{'查询':<35} {'最优引擎':<12} {'分数':<8} {'Top-3':<35}")
306
+ print("-" * 100)
307
+
308
+ for q in test_queries:
309
+ scores = router.route(q, top_k=3)
310
+ best = scores[0] if scores else ("?", 0, "")
311
+ top3 = ", ".join(f"{n}:{s:.3f}" for n, s, _ in scores[:3])
312
+ print(f"{q:<35} {best[0]:<12} {best[1]:<8.4f} {top3:<35}")
@@ -0,0 +1,104 @@
1
+ ---
2
+ name: local-search
3
+ parent: unified-search
4
+ description: unified-search 的本地/零成本兜底子技能。封装基于公开页面/HTML/RSS/JSON 的 24 个本地搜索引擎,不单独响应触发词,仅由 unified-search 通过 --sub-skill local-search 或 --local-first 调用。
5
+ version: 1.0.1
6
+ ---
7
+
8
+ ## Local Search 子技能
9
+
10
+ Local Search 是 unified-search 的「零成本兜底适配器」,用于:
11
+
12
+ - 在 `--mode fast` / `--mode budget` 下优先使用本地抓取引擎,避免消耗付费 API 配额。
13
+ - 当 SearXNG 不可用时,回退到本地 HTML/JSON 解析。
14
+ - 对中文网页、新闻、代码问答、学术、参考百科等垂直域提供补充结果。
15
+
16
+ ### 设计原则
17
+
18
+ - **不单独响应触发词**:没有独立的 skill trigger,仅作为 unified-search 的子能力。
19
+ - **统一 schema**:输出与 unified-search 主 skill 完全一致,包含 `results[]`、`engines_used`、`errors`、`elapsed_ms` 等字段。
20
+ - **声明式解析**:HTML 结构变化时只需修改 `parse_maps.yaml`。
21
+ - **命名空间隔离**:本地引擎统一使用 `local_` 前缀(如 `local_bing`、`local_google`),避免与 unified-search 已有的 HTTP 引擎(`duckduckgo`、`wikipedia` 等)重名。
22
+
23
+ ### 本地引擎列表(25 个,20 个默认启用)
24
+
25
+ | unified 名称 | 类型 | 默认启用 | 说明 |
26
+ |--------------|------|----------|------|
27
+ | local_bing | html | ✅ | Bing 网页结果 |
28
+ | local_google | html | ❌ | Google 网页结果(反爬强) |
29
+ | local_mojeek | html | ✅ | Mojeek 独立索引 |
30
+ | local_yandex | html | ❌ | Yandex 搜索(反爬强) |
31
+ | local_startpage | html | ✅ | Startpage 隐私搜索 |
32
+ | local_duckduckgo | html | ✅ | DuckDuckGo HTML |
33
+ | local_baidu | html | ✅ | 百度搜索 |
34
+ | local_sogou | html | ✅ | 搜狗搜索 |
35
+ | local_arxiv | xml | ✅ | arXiv API |
36
+ | local_pubmed | json | ✅ | PubMed/EUtils |
37
+ | local_crossref | json | ✅ | Crossref API |
38
+ | local_semantic_scholar | json | ✅ | Semantic Scholar API |
39
+ | local_bing_news | rss/html | ✅ | Bing 新闻 |
40
+ | local_google_news | rss | ✅ | Google News RSS |
41
+ | local_duckduckgo_news | rss/html | ❌ | DuckDuckGo 新闻 |
42
+ | local_github | json | ✅ | GitHub Search API |
43
+ | local_stackoverflow | html/json | ✅ | StackOverflow 问题 |
44
+ | local_gitlab | json | ✅ | GitLab API |
45
+ | local_npm | json | ✅ | NPM Registry |
46
+ | local_wikipedia | json | ✅ | MediaWiki API |
47
+ | local_wiktionary | json | ✅ | Wiktionary API |
48
+ | local_wikiquote | json | ✅ | Wikiquote API |
49
+ | local_imdb | html | ❌ | IMDb 搜索 |
50
+ | local_goodreads | html | ❌ | Goodreads 搜索 |
51
+ | local_openstreetmap | json | ✅ | Nominatim API |
52
+
53
+ ### 调用方式
54
+
55
+ ```bash
56
+ # 直接调用子技能(单引擎)
57
+ python3 sub-skills/local-search/local_search_adapter.py "query" --engine local_bing
58
+
59
+ # 批量调用多个本地引擎
60
+ python3 sub-skills/local-search/local_search_adapter.py "query" \
61
+ --engine local_bing,local_baidu,local_duckduckgo
62
+
63
+ # 由 unified-search 调用
64
+ python3 scripts/search.py "query" --sub-skill local-search
65
+ python3 scripts/search.py "query" --local-first --mode fast
66
+ ```
67
+
68
+ ### 文件结构
69
+
70
+ ```
71
+ sub-skills/local-search/
72
+ ├── SKILL.md # 本文件
73
+ ├── config.yaml # 引擎基础配置(URL/超时/类型/开关)
74
+ ├── parse_maps.yaml # HTML/RSS/JSON 抽取映射
75
+ ├── engine_registry.py # 引擎注册中心(唯一真源)
76
+ ├── health_check.py # 轻量健康探针
77
+ ├── smart_router.py # 查询特征路由
78
+ ├── search_v3.py # local-search 主入口
79
+ └── local_search_adapter.py # 兼容入口
80
+ ```
81
+
82
+ ### 输出 schema
83
+
84
+ 与 unified-search 主 skill 一致:
85
+
86
+ ```json
87
+ {
88
+ "query": "string",
89
+ "engine": "local_search",
90
+ "engines": ["local_bing", "local_baidu"],
91
+ "engines_combo": ["local_bing", "local_baidu"],
92
+ "cached": false,
93
+ "cache_level": null,
94
+ "domain": null,
95
+ "elapsed_ms": 1234,
96
+ "tfidf_scores": [],
97
+ "results": [
98
+ {"title": "...", "url": "...", "snippet": "...", "score": 0.8, "source": "local_bing"}
99
+ ],
100
+ "count": 10,
101
+ "engines_used": ["local_bing", "local_baidu"],
102
+ "errors": []
103
+ }
104
+ ```