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,437 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
health_check.py — 引擎健康检查
|
|
4
|
+
|
|
5
|
+
职责:
|
|
6
|
+
- 遍历三层引擎注册表所有引擎
|
|
7
|
+
- 标记可用/降级/不可用
|
|
8
|
+
- 按层级筛选可用引擎
|
|
9
|
+
- 输出健康报告供路由决策使用
|
|
10
|
+
|
|
11
|
+
调用方式:
|
|
12
|
+
- 内部:from health_check import health_check_all, get_available_engines_by_tier
|
|
13
|
+
- CLI:python3 health_check.py [--tier T1] [--json]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
# 添加 scripts 目录到 import 路径
|
|
26
|
+
SCRIPT_DIR = Path(__file__).resolve().parent.parent / "scripts"
|
|
27
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import yaml
|
|
31
|
+
except ImportError:
|
|
32
|
+
yaml = None # type: ignore
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
REGISTRY_PATH = Path(__file__).resolve().parent.parent / "backends" / "engine_registry.yaml"
|
|
36
|
+
|
|
37
|
+
# 本地搜索模块路径(优雅降级,缺失时不阻断)
|
|
38
|
+
# 默认路径为项目内 local-search/ 子目录,可通过环境变量 LOCAL_SEARCH_PATH 覆盖
|
|
39
|
+
_LOCAL_SEARCH_V3_PATH = Path(os.environ.get(
|
|
40
|
+
"LOCAL_SEARCH_PATH",
|
|
41
|
+
str(Path(__file__).resolve().parent.parent / "local-search" / "search_v3.py")
|
|
42
|
+
))
|
|
43
|
+
_SEARXNG_BRIDGE_PATH = Path(os.environ.get(
|
|
44
|
+
"SEARXNG_BRIDGE_PATH",
|
|
45
|
+
str(Path(__file__).resolve().parent.parent / "local-search" / "searxng_bridge.py")
|
|
46
|
+
))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_registry() -> list[dict[str, Any]]:
|
|
50
|
+
"""加载引擎注册表 YAML。"""
|
|
51
|
+
if not REGISTRY_PATH.exists():
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
if yaml is None:
|
|
55
|
+
# 无 PyYAML 时尝试简易解析(仅处理基本结构)
|
|
56
|
+
return _parse_yaml_light()
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
|
|
60
|
+
data = yaml.safe_load(f)
|
|
61
|
+
except Exception:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
if not isinstance(data, dict):
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
return data.get("engines", [])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_yaml_light() -> list[dict[str, Any]]:
|
|
71
|
+
"""简易 YAML 解析器回退 — 仅提取 engines 列表。"""
|
|
72
|
+
try:
|
|
73
|
+
text = REGISTRY_PATH.read_text(encoding="utf-8")
|
|
74
|
+
# 查找 engines: 段落后面的条目
|
|
75
|
+
# 这是一个非常基础的解析,PyYAML 不可用时才使用
|
|
76
|
+
return []
|
|
77
|
+
except Exception:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _check_t1_engine(name: str, timeout: float = 5) -> dict[str, Any]:
|
|
82
|
+
"""检查 T1 引擎健康状态。
|
|
83
|
+
|
|
84
|
+
通过 engines.py 的 search() 函数快速探测。
|
|
85
|
+
"""
|
|
86
|
+
result = {
|
|
87
|
+
"engine": name,
|
|
88
|
+
"tier": "T1",
|
|
89
|
+
"available": False,
|
|
90
|
+
"latency_ms": 0,
|
|
91
|
+
"error": None,
|
|
92
|
+
"status": "unavailable",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
from engines import search, get_registry
|
|
97
|
+
registry = get_registry()
|
|
98
|
+
if name not in registry:
|
|
99
|
+
result["error"] = f"引擎 {name} 未在 engines.py 注册表中"
|
|
100
|
+
result["status"] = "unavailable"
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
fn = registry[name]
|
|
104
|
+
t0 = time.time()
|
|
105
|
+
# 快速探测:只请求 1 条结果
|
|
106
|
+
try:
|
|
107
|
+
results = fn("test", n=1, _timeout=timeout)
|
|
108
|
+
except TypeError:
|
|
109
|
+
results = fn("test", n=1)
|
|
110
|
+
|
|
111
|
+
elapsed = round((time.time() - t0) * 1000)
|
|
112
|
+
result["latency_ms"] = elapsed
|
|
113
|
+
|
|
114
|
+
if results and isinstance(results, list) and len(results) > 0:
|
|
115
|
+
result["available"] = True
|
|
116
|
+
result["status"] = "ok"
|
|
117
|
+
result["sample"] = results[0]
|
|
118
|
+
else:
|
|
119
|
+
result["error"] = "返回空结果"
|
|
120
|
+
result["status"] = "degraded"
|
|
121
|
+
|
|
122
|
+
except ImportError:
|
|
123
|
+
result["error"] = "engines.py 不可导入"
|
|
124
|
+
result["status"] = "unavailable"
|
|
125
|
+
except Exception as e:
|
|
126
|
+
result["error"] = f"{type(e).__name__}: {e}"
|
|
127
|
+
result["status"] = "unavailable"
|
|
128
|
+
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _check_t2_engine(name: str, timeout: float = 5) -> dict[str, Any]:
|
|
133
|
+
"""检查 T2 引擎健康状态。
|
|
134
|
+
|
|
135
|
+
通过 search_v3.check_engine_health() 探测。
|
|
136
|
+
引擎名去掉 local/ 前缀后传给 check_engine_health。
|
|
137
|
+
"""
|
|
138
|
+
local_name = name.replace("local/", "") if name.startswith("local/") else name
|
|
139
|
+
|
|
140
|
+
result = {
|
|
141
|
+
"engine": name,
|
|
142
|
+
"tier": "T2",
|
|
143
|
+
"available": False,
|
|
144
|
+
"latency_ms": 0,
|
|
145
|
+
"error": None,
|
|
146
|
+
"status": "unavailable",
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
sys.path.insert(0, str(_LOCAL_SEARCH_V3_PATH.parent))
|
|
151
|
+
from search_v3 import check_engine_health
|
|
152
|
+
health = check_engine_health(local_name, timeout=timeout)
|
|
153
|
+
result["available"] = health.get("available", False)
|
|
154
|
+
result["latency_ms"] = health.get("latency_ms", 0)
|
|
155
|
+
result["error"] = health.get("error")
|
|
156
|
+
result["status"] = health.get("status", "unavailable")
|
|
157
|
+
if health.get("sample"):
|
|
158
|
+
result["sample"] = health["sample"]
|
|
159
|
+
except ImportError:
|
|
160
|
+
result["error"] = "search_v3.py 不可导入"
|
|
161
|
+
result["status"] = "unavailable"
|
|
162
|
+
except Exception as e:
|
|
163
|
+
result["error"] = f"{type(e).__name__}: {e}"
|
|
164
|
+
result["status"] = "unavailable"
|
|
165
|
+
|
|
166
|
+
return result
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _check_t3_engine(name: str, timeout: float = 5) -> dict[str, Any]:
|
|
170
|
+
"""检查 T3 引擎健康状态。
|
|
171
|
+
|
|
172
|
+
通过 searxng_bridge 检查 SearXNG 是否可达,然后检查引擎是否在列表中。
|
|
173
|
+
"""
|
|
174
|
+
result = {
|
|
175
|
+
"engine": name,
|
|
176
|
+
"tier": "T3",
|
|
177
|
+
"available": False,
|
|
178
|
+
"latency_ms": 0,
|
|
179
|
+
"error": None,
|
|
180
|
+
"status": "unavailable",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
sys.path.insert(0, str(_SEARXNG_BRIDGE_PATH.parent))
|
|
185
|
+
from searxng_bridge import fetch_searxng_engine_list
|
|
186
|
+
|
|
187
|
+
t0 = time.time()
|
|
188
|
+
engine_data = fetch_searxng_engine_list(timeout=timeout)
|
|
189
|
+
elapsed = round((time.time() - t0) * 1000)
|
|
190
|
+
result["latency_ms"] = elapsed
|
|
191
|
+
|
|
192
|
+
if engine_data.get("error"):
|
|
193
|
+
result["error"] = engine_data["error"]
|
|
194
|
+
result["status"] = "unavailable"
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
# 检查指定引擎是否在垂直可用列表中
|
|
198
|
+
searxng_engine_name = name.replace("searxng/", "")
|
|
199
|
+
available_verticals = engine_data.get("available_vertical", [])
|
|
200
|
+
available_names = {e.get("name", "").lower() for e in available_verticals}
|
|
201
|
+
|
|
202
|
+
if searxng_engine_name.lower() in available_names or \
|
|
203
|
+
searxng_engine_name.lower().replace("_", " ") in available_names:
|
|
204
|
+
result["available"] = True
|
|
205
|
+
result["status"] = "ok"
|
|
206
|
+
else:
|
|
207
|
+
# 也检查是否在 category_map 中
|
|
208
|
+
cmap = engine_data.get("category_map", {})
|
|
209
|
+
found = False
|
|
210
|
+
for cat_engines in cmap.values():
|
|
211
|
+
if searxng_engine_name.lower() in [e.lower() for e in cat_engines]:
|
|
212
|
+
found = True
|
|
213
|
+
break
|
|
214
|
+
if found:
|
|
215
|
+
result["available"] = True
|
|
216
|
+
result["status"] = "ok"
|
|
217
|
+
else:
|
|
218
|
+
result["error"] = f"引擎 {searxng_engine_name} 不在 SearXNG 可用垂直列表中"
|
|
219
|
+
result["status"] = "degraded"
|
|
220
|
+
|
|
221
|
+
except ImportError:
|
|
222
|
+
result["error"] = "searxng_bridge.py 不可导入"
|
|
223
|
+
result["status"] = "unavailable"
|
|
224
|
+
except Exception as e:
|
|
225
|
+
result["error"] = f"{type(e).__name__}: {e}"
|
|
226
|
+
result["status"] = "unavailable"
|
|
227
|
+
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# 健康检查映射
|
|
232
|
+
_CHECKERS = {
|
|
233
|
+
"T1": _check_t1_engine,
|
|
234
|
+
"T2": _check_t2_engine,
|
|
235
|
+
"T3": _check_t3_engine,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def health_check_all(
|
|
240
|
+
timeout: float = 5,
|
|
241
|
+
tiers: list[str] | None = None,
|
|
242
|
+
fast: bool = False,
|
|
243
|
+
) -> dict[str, Any]:
|
|
244
|
+
"""遍历注册表所有引擎,标记可用/降级/不可用。
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
timeout: 单引擎探测超时(秒)
|
|
248
|
+
tiers: 要检查的层级列表(None = 全部)
|
|
249
|
+
fast: 快速模式 — 仅检查推荐引擎
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
{
|
|
253
|
+
"ok": [...],
|
|
254
|
+
"degraded": [...],
|
|
255
|
+
"unavailable": [...],
|
|
256
|
+
"by_tier": {"T1": [...], "T2": [...], "T3": [...]},
|
|
257
|
+
"summary": {"total": N, "ok_count": N, "degraded_count": N, ...},
|
|
258
|
+
"elapsed_ms": N,
|
|
259
|
+
}
|
|
260
|
+
"""
|
|
261
|
+
t0_total = time.time()
|
|
262
|
+
|
|
263
|
+
registry = _load_registry()
|
|
264
|
+
if not registry:
|
|
265
|
+
return {
|
|
266
|
+
"ok": [], "degraded": [], "unavailable": [],
|
|
267
|
+
"by_tier": {}, "summary": {"total": 0, "ok_count": 0,
|
|
268
|
+
"degraded_count": 0, "unavailable_count": 0},
|
|
269
|
+
"elapsed_ms": 0, "error": "引擎注册表为空或不可读",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
target_tiers = set(tiers) if tiers else {"T1", "T2", "T3"}
|
|
273
|
+
|
|
274
|
+
ok_list: list[dict] = []
|
|
275
|
+
degraded_list: list[dict] = []
|
|
276
|
+
unavailable_list: list[dict] = []
|
|
277
|
+
by_tier: dict[str, list[dict]] = {"T1": [], "T2": [], "T3": []}
|
|
278
|
+
|
|
279
|
+
for engine in registry:
|
|
280
|
+
name = engine.get("name", "")
|
|
281
|
+
tier = engine.get("tier", "")
|
|
282
|
+
status = engine.get("status", "unknown")
|
|
283
|
+
|
|
284
|
+
if tier not in target_tiers:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# 快速模式:仅检查推荐引擎 + 状态为 ok 的引擎
|
|
288
|
+
if fast:
|
|
289
|
+
is_recommended = engine.get("recommended", False)
|
|
290
|
+
is_ok = status == "ok"
|
|
291
|
+
if not (is_recommended or is_ok):
|
|
292
|
+
engine["_checked"] = False
|
|
293
|
+
engine["_skip_reason"] = "非推荐引擎(快速模式)"
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
# 如果注册表已标记为 disabled,直接跳过
|
|
297
|
+
if status == "disabled":
|
|
298
|
+
engine["_checked"] = False
|
|
299
|
+
engine["_skip_reason"] = "已禁用"
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
# 对于 degraded 状态的引擎(如 Baidu),不做探测直接使用注册表状态
|
|
303
|
+
if status == "degraded":
|
|
304
|
+
check_result = {
|
|
305
|
+
"engine": name,
|
|
306
|
+
"tier": tier,
|
|
307
|
+
"available": False,
|
|
308
|
+
"latency_ms": engine.get("latency_ms", 0),
|
|
309
|
+
"error": engine.get("note", "已降级"),
|
|
310
|
+
"status": "degraded",
|
|
311
|
+
"_checked": True,
|
|
312
|
+
"_from_registry": True,
|
|
313
|
+
}
|
|
314
|
+
degraded_list.append(check_result)
|
|
315
|
+
by_tier.setdefault(tier, []).append(check_result)
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
# 执行健康检查
|
|
319
|
+
checker = _CHECKERS.get(tier)
|
|
320
|
+
if checker:
|
|
321
|
+
check_result = checker(name, timeout=timeout)
|
|
322
|
+
check_result["_checked"] = True
|
|
323
|
+
else:
|
|
324
|
+
check_result = {
|
|
325
|
+
"engine": name,
|
|
326
|
+
"tier": tier,
|
|
327
|
+
"available": False,
|
|
328
|
+
"latency_ms": 0,
|
|
329
|
+
"error": f"未知层级: {tier}",
|
|
330
|
+
"status": "unavailable",
|
|
331
|
+
"_checked": True,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
# 分类
|
|
335
|
+
st = check_result.get("status", "unavailable")
|
|
336
|
+
if st == "ok":
|
|
337
|
+
ok_list.append(check_result)
|
|
338
|
+
elif st in ("degraded", "slow"):
|
|
339
|
+
degraded_list.append(check_result)
|
|
340
|
+
else:
|
|
341
|
+
unavailable_list.append(check_result)
|
|
342
|
+
|
|
343
|
+
by_tier.setdefault(tier, []).append(check_result)
|
|
344
|
+
|
|
345
|
+
elapsed = round((time.time() - t0_total) * 1000)
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"ok": ok_list,
|
|
349
|
+
"degraded": degraded_list,
|
|
350
|
+
"unavailable": unavailable_list,
|
|
351
|
+
"by_tier": by_tier,
|
|
352
|
+
"summary": {
|
|
353
|
+
"total": len(ok_list) + len(degraded_list) + len(unavailable_list),
|
|
354
|
+
"ok_count": len(ok_list),
|
|
355
|
+
"degraded_count": len(degraded_list),
|
|
356
|
+
"unavailable_count": len(unavailable_list),
|
|
357
|
+
},
|
|
358
|
+
"elapsed_ms": elapsed,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def get_available_engines_by_tier(
|
|
363
|
+
tier: str,
|
|
364
|
+
include_degraded: bool = False,
|
|
365
|
+
) -> list[str]:
|
|
366
|
+
"""按层级筛选可用引擎名称列表。
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
tier: 层级名("T1" / "T2" / "T3")
|
|
370
|
+
include_degraded: 是否包含降级但仍可用的引擎
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
引擎名称列表(仅为 ok 状态,若 include_degraded 则包含 degraded)
|
|
374
|
+
"""
|
|
375
|
+
registry = _load_registry()
|
|
376
|
+
names: list[str] = []
|
|
377
|
+
|
|
378
|
+
for engine in registry:
|
|
379
|
+
if engine.get("tier") != tier:
|
|
380
|
+
continue
|
|
381
|
+
status = engine.get("status", "unknown")
|
|
382
|
+
if status == "ok":
|
|
383
|
+
names.append(engine.get("name", ""))
|
|
384
|
+
elif include_degraded and status in ("degraded", "slow"):
|
|
385
|
+
names.append(engine.get("name", ""))
|
|
386
|
+
|
|
387
|
+
return names
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ── CLI 调试用 ─────────────────────────────────────────────────────────────────
|
|
391
|
+
def _cli():
|
|
392
|
+
import argparse
|
|
393
|
+
|
|
394
|
+
parser = argparse.ArgumentParser(description="引擎健康检查")
|
|
395
|
+
parser.add_argument("--tier", "-t", choices=["T1", "T2", "T3"],
|
|
396
|
+
help="仅检查指定层级")
|
|
397
|
+
parser.add_argument("--json", action="store_true", help="JSON 格式输出")
|
|
398
|
+
parser.add_argument("--fast", action="store_true", help="快速模式(仅检查推荐引擎)")
|
|
399
|
+
parser.add_argument("--list", action="store_true", help="列出注册表所有引擎(不探测)")
|
|
400
|
+
args = parser.parse_args()
|
|
401
|
+
|
|
402
|
+
if args.list:
|
|
403
|
+
registry = _load_registry()
|
|
404
|
+
if args.tier:
|
|
405
|
+
registry = [e for e in registry if e.get("tier") == args.tier]
|
|
406
|
+
for e in registry:
|
|
407
|
+
print(f"[{e.get('tier', '?')}] {e.get('name', '?')} "
|
|
408
|
+
f"({e.get('status', '?')}) — {e.get('desc', '')}")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
tiers = [args.tier] if args.tier else None
|
|
412
|
+
result = health_check_all(timeout=5, tiers=tiers, fast=args.fast)
|
|
413
|
+
|
|
414
|
+
if args.json:
|
|
415
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
416
|
+
else:
|
|
417
|
+
print(f"健康检查完成 — 耗时 {result['elapsed_ms']}ms")
|
|
418
|
+
summary = result["summary"]
|
|
419
|
+
print(f" 可用: {summary['ok_count']} 降级: {summary['degraded_count']} "
|
|
420
|
+
f"不可用: {summary['unavailable_count']} 总计: {summary['total']}")
|
|
421
|
+
print()
|
|
422
|
+
if result["ok"]:
|
|
423
|
+
print("✅ 可用:")
|
|
424
|
+
for e in result["ok"]:
|
|
425
|
+
print(f" [{e['tier']}] {e['engine']} ({e['latency_ms']}ms)")
|
|
426
|
+
if result["degraded"]:
|
|
427
|
+
print("⚠️ 降级:")
|
|
428
|
+
for e in result["degraded"]:
|
|
429
|
+
print(f" [{e['tier']}] {e['engine']} — {e.get('error', '')}")
|
|
430
|
+
if result["unavailable"]:
|
|
431
|
+
print("❌ 不可用:")
|
|
432
|
+
for e in result["unavailable"]:
|
|
433
|
+
print(f" [{e['tier']}] {e['engine']} — {e.get('error', '')}")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
if __name__ == "__main__":
|
|
437
|
+
_cli()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
health_probe.py — 引擎健康探针(v2.5 新增)
|
|
4
|
+
|
|
5
|
+
对 HTTP 引擎做可达性检查,结果写入 SQLite 供 route.py 查询。
|
|
6
|
+
- 启动时全量探测
|
|
7
|
+
- 每 5 分钟后台自动刷新
|
|
8
|
+
- 连续 2 次失败标记 unavailable,1 次成功恢复
|
|
9
|
+
|
|
10
|
+
用法:
|
|
11
|
+
python3 health_probe.py # 单次探测
|
|
12
|
+
python3 health_probe.py --watch # 后台持续监控(每 5 分钟)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sqlite3
|
|
20
|
+
import sys
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
28
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from config import load_config, get_engines
|
|
32
|
+
except ImportError:
|
|
33
|
+
from config import load_config, get_engines
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── 路径 ──────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
DB_PATH = Path.home() / ".cache" / "unified-search" / "health.db"
|
|
39
|
+
PROBE_INTERVAL = 300 # 5 分钟
|
|
40
|
+
PROBE_TIMEOUT = 1.5 # 单次探测超时(快,不能拖慢启动)
|
|
41
|
+
MAX_FAILURES = 2 # 连续失败次数阈值
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── SQLite 存储 ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
def _connect():
|
|
47
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
conn = sqlite3.connect(str(DB_PATH), timeout=5)
|
|
49
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
50
|
+
return conn
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _init_db():
|
|
54
|
+
with _connect() as conn:
|
|
55
|
+
conn.execute("""
|
|
56
|
+
CREATE TABLE IF NOT EXISTS engine_health (
|
|
57
|
+
engine TEXT PRIMARY KEY,
|
|
58
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
59
|
+
consecutive_failures INTEGER DEFAULT 0,
|
|
60
|
+
last_probe_at REAL DEFAULT 0,
|
|
61
|
+
last_latency_ms REAL DEFAULT 0,
|
|
62
|
+
last_error TEXT DEFAULT ''
|
|
63
|
+
)
|
|
64
|
+
""")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _probe_http(url: str, timeout: float = PROBE_TIMEOUT) -> tuple[bool, float, str]:
|
|
68
|
+
"""探测 HTTP 引擎:发起 HEAD 请求检查可达性。"""
|
|
69
|
+
try:
|
|
70
|
+
req = urllib.request.Request(url, method="HEAD")
|
|
71
|
+
t0 = time.time()
|
|
72
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
73
|
+
elapsed = (time.time() - t0) * 1000
|
|
74
|
+
return True, elapsed, ""
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return False, 0, str(e)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _probe_cli(cmd: list[str], timeout: float = 2) -> tuple[bool, float, str]:
|
|
80
|
+
"""探测 CLI 引擎:检查命令是否存在。"""
|
|
81
|
+
import subprocess
|
|
82
|
+
if not cmd:
|
|
83
|
+
return False, 0, "empty command"
|
|
84
|
+
exe = cmd[0]
|
|
85
|
+
if isinstance(exe, str) and exe.startswith("~"):
|
|
86
|
+
exe = str(Path(exe).expanduser())
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(["which", exe], capture_output=True, text=True, timeout=timeout)
|
|
89
|
+
if result.returncode == 0:
|
|
90
|
+
return True, 0, ""
|
|
91
|
+
return False, 0, f"command not found: {exe}"
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return False, 0, str(e)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def probe_all_engines():
|
|
97
|
+
"""探测所有已启用的引擎。"""
|
|
98
|
+
_init_db()
|
|
99
|
+
cfg = load_config()
|
|
100
|
+
engines = get_engines(cfg)
|
|
101
|
+
results = {}
|
|
102
|
+
|
|
103
|
+
for name, spec in engines.items():
|
|
104
|
+
engine_type = spec.get("type", "")
|
|
105
|
+
url = spec.get("url", "")
|
|
106
|
+
cmd = spec.get("cmd", [])
|
|
107
|
+
|
|
108
|
+
if engine_type == "http" and url:
|
|
109
|
+
ok, latency, err = _probe_http(url)
|
|
110
|
+
elif engine_type == "cli" and cmd:
|
|
111
|
+
ok, latency, err = _probe_cli(cmd)
|
|
112
|
+
else:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# 更新数据库
|
|
116
|
+
with _connect() as conn:
|
|
117
|
+
row = conn.execute(
|
|
118
|
+
"SELECT consecutive_failures FROM engine_health WHERE engine = ?",
|
|
119
|
+
(name,),
|
|
120
|
+
).fetchone()
|
|
121
|
+
|
|
122
|
+
if ok:
|
|
123
|
+
new_status = "available"
|
|
124
|
+
new_failures = 0
|
|
125
|
+
else:
|
|
126
|
+
prev_failures = row[0] if row else 0
|
|
127
|
+
new_failures = prev_failures + 1
|
|
128
|
+
new_status = "unavailable" if new_failures >= MAX_FAILURES else "degraded"
|
|
129
|
+
|
|
130
|
+
conn.execute(
|
|
131
|
+
"""INSERT OR REPLACE INTO engine_health
|
|
132
|
+
(engine, status, consecutive_failures, last_probe_at, last_latency_ms, last_error)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
134
|
+
(name, new_status, new_failures, time.time(), latency, err),
|
|
135
|
+
)
|
|
136
|
+
conn.commit()
|
|
137
|
+
|
|
138
|
+
results[name] = {
|
|
139
|
+
"status": "available" if ok else "unavailable",
|
|
140
|
+
"latency_ms": round(latency, 1),
|
|
141
|
+
"error": err,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return results
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get_engine_status(engine: str) -> dict[str, Any]:
|
|
148
|
+
"""查询单个引擎的健康状态。"""
|
|
149
|
+
_init_db()
|
|
150
|
+
with _connect() as conn:
|
|
151
|
+
row = conn.execute(
|
|
152
|
+
"SELECT status, consecutive_failures, last_probe_at, last_latency_ms FROM engine_health WHERE engine = ?",
|
|
153
|
+
(engine,),
|
|
154
|
+
).fetchone()
|
|
155
|
+
if not row:
|
|
156
|
+
return {"status": "unknown", "consecutive_failures": 0, "available": True}
|
|
157
|
+
return {
|
|
158
|
+
"status": row[0],
|
|
159
|
+
"consecutive_failures": row[1],
|
|
160
|
+
"last_probe_at": row[2],
|
|
161
|
+
"last_latency_ms": row[3],
|
|
162
|
+
"available": row[0] != "unavailable",
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_all_status() -> dict[str, dict[str, Any]]:
|
|
167
|
+
"""获取所有引擎的健康状态。"""
|
|
168
|
+
_init_db()
|
|
169
|
+
with _connect() as conn:
|
|
170
|
+
rows = conn.execute(
|
|
171
|
+
"SELECT engine, status, consecutive_failures, last_probe_at, last_latency_ms FROM engine_health"
|
|
172
|
+
).fetchall()
|
|
173
|
+
return {
|
|
174
|
+
row[0]: {
|
|
175
|
+
"status": row[1],
|
|
176
|
+
"consecutive_failures": row[2],
|
|
177
|
+
"last_probe_at": row[3],
|
|
178
|
+
"last_latency_ms": row[4],
|
|
179
|
+
"available": row[1] != "unavailable",
|
|
180
|
+
}
|
|
181
|
+
for row in rows
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def watch_background():
|
|
186
|
+
"""后台持续监控模式。在后台线程中运行,不阻塞 MCP 启动。"""
|
|
187
|
+
def _loop():
|
|
188
|
+
# 首次探测延迟 10 秒,让 MCP 先完成 initialize 握手
|
|
189
|
+
time.sleep(10)
|
|
190
|
+
try:
|
|
191
|
+
probe_all_engines()
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
while True:
|
|
195
|
+
time.sleep(PROBE_INTERVAL)
|
|
196
|
+
try:
|
|
197
|
+
probe_all_engines()
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
t = threading.Thread(target=_loop, daemon=True)
|
|
202
|
+
t.start()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
if "--watch" in sys.argv:
|
|
209
|
+
print("启动后台健康监控(每 5 分钟)...", file=sys.stderr)
|
|
210
|
+
watch_background()
|
|
211
|
+
try:
|
|
212
|
+
while True:
|
|
213
|
+
time.sleep(60)
|
|
214
|
+
except KeyboardInterrupt:
|
|
215
|
+
pass
|
|
216
|
+
else:
|
|
217
|
+
results = probe_all_engines()
|
|
218
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|