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,328 @@
|
|
|
1
|
+
# local-search 子技能专属配置
|
|
2
|
+
# 与 unified-search/config.yaml 解耦,local_search_adapter.py / search_v3.py 直接读取本文件。
|
|
3
|
+
|
|
4
|
+
settings:
|
|
5
|
+
default_timeout: 8
|
|
6
|
+
default_results: 5
|
|
7
|
+
max_parallel_engines: 5
|
|
8
|
+
user_agent: "Mozilla/5.0 (compatible; unified-search-local/1.0.1; +https://local)"
|
|
9
|
+
request_interval_ms: 500
|
|
10
|
+
health_check:
|
|
11
|
+
canary_query: "test"
|
|
12
|
+
ttl_minutes: 5
|
|
13
|
+
interval_minutes: 5
|
|
14
|
+
unavailable_threshold: 2
|
|
15
|
+
timeout: 8
|
|
16
|
+
max_parallel: 5
|
|
17
|
+
|
|
18
|
+
engines:
|
|
19
|
+
local_bing:
|
|
20
|
+
type: html
|
|
21
|
+
enabled: true
|
|
22
|
+
categories: [web_general]
|
|
23
|
+
url: "https://www.bing.com/search"
|
|
24
|
+
query_param: q
|
|
25
|
+
method: GET
|
|
26
|
+
timeout: 8
|
|
27
|
+
format: html
|
|
28
|
+
extra_params:
|
|
29
|
+
setlang: zh-Hans
|
|
30
|
+
|
|
31
|
+
local_google:
|
|
32
|
+
type: html
|
|
33
|
+
enabled: false
|
|
34
|
+
categories: [web_general]
|
|
35
|
+
url: "https://www.google.com/search"
|
|
36
|
+
query_param: q
|
|
37
|
+
method: GET
|
|
38
|
+
timeout: 8
|
|
39
|
+
format: html
|
|
40
|
+
extra_params:
|
|
41
|
+
hl: zh-CN
|
|
42
|
+
|
|
43
|
+
local_mojeek:
|
|
44
|
+
type: html
|
|
45
|
+
enabled: true
|
|
46
|
+
categories: [web_general]
|
|
47
|
+
url: "https://www.mojeek.com/search"
|
|
48
|
+
query_param: q
|
|
49
|
+
method: GET
|
|
50
|
+
timeout: 10
|
|
51
|
+
format: html
|
|
52
|
+
|
|
53
|
+
local_yandex:
|
|
54
|
+
type: html
|
|
55
|
+
enabled: false
|
|
56
|
+
categories: [web_general]
|
|
57
|
+
url: "https://yandex.com/search/"
|
|
58
|
+
query_param: text
|
|
59
|
+
method: GET
|
|
60
|
+
timeout: 10
|
|
61
|
+
format: html
|
|
62
|
+
|
|
63
|
+
local_startpage:
|
|
64
|
+
type: html
|
|
65
|
+
enabled: true
|
|
66
|
+
categories: [web_general]
|
|
67
|
+
url: "https://www.startpage.com/do/dsearch"
|
|
68
|
+
query_param: query
|
|
69
|
+
method: GET
|
|
70
|
+
timeout: 10
|
|
71
|
+
format: html
|
|
72
|
+
|
|
73
|
+
local_duckduckgo:
|
|
74
|
+
type: html
|
|
75
|
+
enabled: true
|
|
76
|
+
categories: [web_general]
|
|
77
|
+
url: "https://html.duckduckgo.com/html/"
|
|
78
|
+
query_param: q
|
|
79
|
+
method: GET
|
|
80
|
+
timeout: 8
|
|
81
|
+
format: html
|
|
82
|
+
|
|
83
|
+
local_baidu:
|
|
84
|
+
type: html
|
|
85
|
+
enabled: true
|
|
86
|
+
categories: [chinese]
|
|
87
|
+
url: "https://www.baidu.com/s"
|
|
88
|
+
query_param: wd
|
|
89
|
+
method: GET
|
|
90
|
+
timeout: 8
|
|
91
|
+
format: html
|
|
92
|
+
extra_params:
|
|
93
|
+
ie: utf-8
|
|
94
|
+
|
|
95
|
+
local_sogou:
|
|
96
|
+
type: html
|
|
97
|
+
enabled: true
|
|
98
|
+
categories: [chinese]
|
|
99
|
+
url: "https://www.sogou.com/web"
|
|
100
|
+
query_param: query
|
|
101
|
+
method: GET
|
|
102
|
+
timeout: 8
|
|
103
|
+
format: html
|
|
104
|
+
|
|
105
|
+
local_arxiv:
|
|
106
|
+
type: xml
|
|
107
|
+
enabled: true
|
|
108
|
+
categories: [academic]
|
|
109
|
+
url: "http://export.arxiv.org/api/query"
|
|
110
|
+
query_param: search_query
|
|
111
|
+
method: GET
|
|
112
|
+
timeout: 15
|
|
113
|
+
format: xml
|
|
114
|
+
extra_params:
|
|
115
|
+
start: 0
|
|
116
|
+
max_results: "{n}"
|
|
117
|
+
|
|
118
|
+
local_pubmed:
|
|
119
|
+
type: json
|
|
120
|
+
enabled: true
|
|
121
|
+
categories: [academic]
|
|
122
|
+
url: "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
|
|
123
|
+
query_param: term
|
|
124
|
+
method: GET
|
|
125
|
+
timeout: 12
|
|
126
|
+
format: json
|
|
127
|
+
extra_params:
|
|
128
|
+
db: pubmed
|
|
129
|
+
retmode: json
|
|
130
|
+
retmax: "{n}"
|
|
131
|
+
|
|
132
|
+
local_crossref:
|
|
133
|
+
type: json
|
|
134
|
+
enabled: true
|
|
135
|
+
categories: [academic]
|
|
136
|
+
url: "https://api.crossref.org/works"
|
|
137
|
+
query_param: query
|
|
138
|
+
method: GET
|
|
139
|
+
timeout: 12
|
|
140
|
+
format: json
|
|
141
|
+
extra_params:
|
|
142
|
+
rows: "{n}"
|
|
143
|
+
mailto: unified-search@local
|
|
144
|
+
|
|
145
|
+
local_semantic_scholar:
|
|
146
|
+
type: json
|
|
147
|
+
enabled: true
|
|
148
|
+
categories: [academic]
|
|
149
|
+
url: "https://api.semanticscholar.org/graph/v1/paper/search"
|
|
150
|
+
query_param: query
|
|
151
|
+
method: GET
|
|
152
|
+
timeout: 12
|
|
153
|
+
format: json
|
|
154
|
+
extra_params:
|
|
155
|
+
limit: "{n}"
|
|
156
|
+
fields: title,abstract,url
|
|
157
|
+
|
|
158
|
+
local_bing_news:
|
|
159
|
+
type: rss
|
|
160
|
+
enabled: true
|
|
161
|
+
categories: [news]
|
|
162
|
+
url: "https://www.bing.com/news/search"
|
|
163
|
+
query_param: q
|
|
164
|
+
method: GET
|
|
165
|
+
timeout: 8
|
|
166
|
+
format: rss
|
|
167
|
+
extra_params:
|
|
168
|
+
format: rss
|
|
169
|
+
|
|
170
|
+
local_google_news:
|
|
171
|
+
type: rss
|
|
172
|
+
enabled: true
|
|
173
|
+
categories: [news]
|
|
174
|
+
url: "https://news.google.com/rss/search"
|
|
175
|
+
query_param: q
|
|
176
|
+
method: GET
|
|
177
|
+
timeout: 8
|
|
178
|
+
format: rss
|
|
179
|
+
|
|
180
|
+
local_duckduckgo_news:
|
|
181
|
+
type: rss
|
|
182
|
+
enabled: false
|
|
183
|
+
categories: [news]
|
|
184
|
+
url: "https://duckduckgo.com/html/"
|
|
185
|
+
query_param: q
|
|
186
|
+
method: GET
|
|
187
|
+
timeout: 8
|
|
188
|
+
format: html
|
|
189
|
+
|
|
190
|
+
local_github:
|
|
191
|
+
type: json
|
|
192
|
+
enabled: true
|
|
193
|
+
categories: [code]
|
|
194
|
+
url: "https://api.github.com/search/repositories"
|
|
195
|
+
query_param: q
|
|
196
|
+
method: GET
|
|
197
|
+
timeout: 10
|
|
198
|
+
format: json
|
|
199
|
+
extra_params:
|
|
200
|
+
per_page: "{n}"
|
|
201
|
+
headers:
|
|
202
|
+
Accept: application/vnd.github.v3+json
|
|
203
|
+
User-Agent: unified-search-local/1.0.1
|
|
204
|
+
|
|
205
|
+
local_stackoverflow:
|
|
206
|
+
type: json
|
|
207
|
+
enabled: true
|
|
208
|
+
categories: [code]
|
|
209
|
+
url: "https://api.stackexchange.com/2.3/search/advanced"
|
|
210
|
+
query_param: q
|
|
211
|
+
method: GET
|
|
212
|
+
timeout: 10
|
|
213
|
+
format: json
|
|
214
|
+
extra_params:
|
|
215
|
+
site: stackoverflow
|
|
216
|
+
pagesize: "{n}"
|
|
217
|
+
order: desc
|
|
218
|
+
sort: relevance
|
|
219
|
+
|
|
220
|
+
local_gitlab:
|
|
221
|
+
type: json
|
|
222
|
+
enabled: true
|
|
223
|
+
categories: [code]
|
|
224
|
+
url: "https://gitlab.com/api/v4/search"
|
|
225
|
+
query_param: search
|
|
226
|
+
method: GET
|
|
227
|
+
timeout: 10
|
|
228
|
+
format: json
|
|
229
|
+
extra_params:
|
|
230
|
+
per_page: "{n}"
|
|
231
|
+
scope: projects
|
|
232
|
+
|
|
233
|
+
local_npm:
|
|
234
|
+
type: json
|
|
235
|
+
enabled: true
|
|
236
|
+
categories: [code]
|
|
237
|
+
url: "https://registry.npmjs.org/-/v1/search"
|
|
238
|
+
query_param: text
|
|
239
|
+
method: GET
|
|
240
|
+
timeout: 8
|
|
241
|
+
format: json
|
|
242
|
+
extra_params:
|
|
243
|
+
size: "{n}"
|
|
244
|
+
|
|
245
|
+
local_wikipedia:
|
|
246
|
+
type: json
|
|
247
|
+
enabled: true
|
|
248
|
+
categories: [reference]
|
|
249
|
+
url: "https://en.wikipedia.org/w/api.php"
|
|
250
|
+
query_param: srsearch
|
|
251
|
+
method: GET
|
|
252
|
+
timeout: 8
|
|
253
|
+
format: json
|
|
254
|
+
extra_params:
|
|
255
|
+
action: query
|
|
256
|
+
list: search
|
|
257
|
+
format: json
|
|
258
|
+
srlimit: "{n}"
|
|
259
|
+
|
|
260
|
+
local_wiktionary:
|
|
261
|
+
type: json
|
|
262
|
+
enabled: true
|
|
263
|
+
categories: [reference]
|
|
264
|
+
url: "https://en.wiktionary.org/w/api.php"
|
|
265
|
+
query_param: srsearch
|
|
266
|
+
method: GET
|
|
267
|
+
timeout: 8
|
|
268
|
+
format: json
|
|
269
|
+
extra_params:
|
|
270
|
+
action: query
|
|
271
|
+
list: search
|
|
272
|
+
format: json
|
|
273
|
+
srlimit: "{n}"
|
|
274
|
+
|
|
275
|
+
local_wikiquote:
|
|
276
|
+
type: json
|
|
277
|
+
enabled: true
|
|
278
|
+
categories: [reference]
|
|
279
|
+
url: "https://en.wikiquote.org/w/api.php"
|
|
280
|
+
query_param: srsearch
|
|
281
|
+
method: GET
|
|
282
|
+
timeout: 8
|
|
283
|
+
format: json
|
|
284
|
+
extra_params:
|
|
285
|
+
action: query
|
|
286
|
+
list: search
|
|
287
|
+
format: json
|
|
288
|
+
srlimit: "{n}"
|
|
289
|
+
|
|
290
|
+
local_imdb:
|
|
291
|
+
type: html
|
|
292
|
+
enabled: false
|
|
293
|
+
categories: [vertical]
|
|
294
|
+
url: "https://www.imdb.com/find/"
|
|
295
|
+
query_param: q
|
|
296
|
+
method: GET
|
|
297
|
+
timeout: 10
|
|
298
|
+
format: html
|
|
299
|
+
extra_params:
|
|
300
|
+
ref_: nv_sr_sm
|
|
301
|
+
|
|
302
|
+
local_goodreads:
|
|
303
|
+
type: html
|
|
304
|
+
enabled: false
|
|
305
|
+
categories: [vertical]
|
|
306
|
+
url: "https://www.goodreads.com/search"
|
|
307
|
+
query_param: q
|
|
308
|
+
method: GET
|
|
309
|
+
timeout: 10
|
|
310
|
+
format: html
|
|
311
|
+
extra_params:
|
|
312
|
+
search_type: books
|
|
313
|
+
|
|
314
|
+
local_openstreetmap:
|
|
315
|
+
type: json
|
|
316
|
+
enabled: true
|
|
317
|
+
categories: [vertical]
|
|
318
|
+
url: "https://nominatim.openstreetmap.org/search"
|
|
319
|
+
query_param: q
|
|
320
|
+
method: GET
|
|
321
|
+
timeout: 10
|
|
322
|
+
format: json
|
|
323
|
+
extra_params:
|
|
324
|
+
format: json
|
|
325
|
+
limit: "{n}"
|
|
326
|
+
addressdetails: 1
|
|
327
|
+
headers:
|
|
328
|
+
User-Agent: unified-search-local/1.0.1
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""engine_registry.py — local-search 引擎注册中心(唯一真源)
|
|
3
|
+
|
|
4
|
+
读取 sub-skills/local-search/config.yaml 与 parse_maps.yaml,维护 24+ 本地引擎的
|
|
5
|
+
元数据、分类与可用状态。新增引擎只需改 YAML,无需改代码。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import yaml # type: ignore
|
|
20
|
+
except ImportError:
|
|
21
|
+
yaml = None # type: ignore
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("local_search.engine_registry")
|
|
24
|
+
if not logger.handlers:
|
|
25
|
+
logger.setLevel(logging.WARNING)
|
|
26
|
+
logger.addHandler(logging.StreamHandler())
|
|
27
|
+
|
|
28
|
+
SKILL_DIR = Path(__file__).resolve().parent
|
|
29
|
+
CONFIG_PATH = SKILL_DIR / "config.yaml"
|
|
30
|
+
PARSE_MAPS_PATH = SKILL_DIR / "parse_maps.yaml"
|
|
31
|
+
|
|
32
|
+
# 领域分类(与 config.yaml 中 engines[*].category 对应)
|
|
33
|
+
DEFAULT_CATEGORIES = [
|
|
34
|
+
"web_general",
|
|
35
|
+
"chinese",
|
|
36
|
+
"academic",
|
|
37
|
+
"news",
|
|
38
|
+
"code",
|
|
39
|
+
"reference",
|
|
40
|
+
"vertical",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# 反爬/拦截标记(用于 health_check 快速判定)
|
|
44
|
+
ANTI_BOT_MARKERS = [
|
|
45
|
+
"captcha", "recaptcha", "robot", "robots", "cloudflare", "challenge",
|
|
46
|
+
"blocked", "verification", "please verify", "access denied",
|
|
47
|
+
"too many requests", "rate limit",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_yaml(path: Path) -> dict[str, Any]:
|
|
52
|
+
if yaml is None:
|
|
53
|
+
raise ImportError("缺少 PyYAML,请安装:pip install pyyaml")
|
|
54
|
+
try:
|
|
55
|
+
with path.open("r", encoding="utf-8") as f:
|
|
56
|
+
data = yaml.safe_load(f)
|
|
57
|
+
return data if isinstance(data, dict) else {}
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.warning(f"加载 YAML 失败 {path}: {e}")
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _default_health_state_path() -> Path:
|
|
64
|
+
base = Path(os.path.expanduser("~/.cache/unified-search"))
|
|
65
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
return base / "local_search_health.json"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EngineRegistry:
|
|
70
|
+
"""本地搜索引擎注册表。"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
config_path: Path | str | None = None,
|
|
75
|
+
parse_maps_path: Path | str | None = None,
|
|
76
|
+
health_state_path: Path | str | None = None,
|
|
77
|
+
):
|
|
78
|
+
self.config_path = Path(config_path) if config_path else CONFIG_PATH
|
|
79
|
+
self.parse_maps_path = Path(parse_maps_path) if parse_maps_path else PARSE_MAPS_PATH
|
|
80
|
+
self.health_state_path = Path(health_state_path) if health_state_path else _default_health_state_path()
|
|
81
|
+
self._config_mtime: float = 0.0
|
|
82
|
+
self._parse_mtime: float = 0.0
|
|
83
|
+
self._config: dict[str, Any] = {}
|
|
84
|
+
self._parse_maps: dict[str, Any] = {}
|
|
85
|
+
self._health: dict[str, Any] = {}
|
|
86
|
+
self._load()
|
|
87
|
+
|
|
88
|
+
def _load(self) -> None:
|
|
89
|
+
"""加载配置、解析映射和健康状态缓存。"""
|
|
90
|
+
cfg = _load_yaml(self.config_path)
|
|
91
|
+
maps = _load_yaml(self.parse_maps_path)
|
|
92
|
+
self._config = cfg
|
|
93
|
+
self._parse_maps = maps
|
|
94
|
+
try:
|
|
95
|
+
self._config_mtime = self.config_path.stat().st_mtime
|
|
96
|
+
self._parse_mtime = self.parse_maps_path.stat().st_mtime
|
|
97
|
+
except OSError:
|
|
98
|
+
pass
|
|
99
|
+
self._health = self._load_health()
|
|
100
|
+
|
|
101
|
+
def _load_health(self) -> dict[str, Any]:
|
|
102
|
+
if not self.health_state_path.exists():
|
|
103
|
+
return {}
|
|
104
|
+
try:
|
|
105
|
+
with self.health_state_path.open("r", encoding="utf-8") as f:
|
|
106
|
+
data = json.load(f)
|
|
107
|
+
if isinstance(data, dict):
|
|
108
|
+
return data
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.warning(f"加载健康状态失败: {e}")
|
|
111
|
+
return {}
|
|
112
|
+
|
|
113
|
+
def _save_health(self) -> None:
|
|
114
|
+
try:
|
|
115
|
+
self.health_state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
with self.health_state_path.open("w", encoding="utf-8") as f:
|
|
117
|
+
json.dump(self._health, f, ensure_ascii=False, indent=2)
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.warning(f"保存健康状态失败: {e}")
|
|
120
|
+
|
|
121
|
+
def reload_if_changed(self) -> bool:
|
|
122
|
+
"""如果 YAML 文件发生变更则热重载,返回是否重载。"""
|
|
123
|
+
try:
|
|
124
|
+
cfg_mtime = self.config_path.stat().st_mtime
|
|
125
|
+
parse_mtime = self.parse_maps_path.stat().st_mtime
|
|
126
|
+
except OSError:
|
|
127
|
+
return False
|
|
128
|
+
if cfg_mtime != self._config_mtime or parse_mtime != self._parse_mtime:
|
|
129
|
+
self._load()
|
|
130
|
+
return True
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
def force_reload(self) -> None:
|
|
134
|
+
"""强制重载所有配置。"""
|
|
135
|
+
self._load()
|
|
136
|
+
|
|
137
|
+
# ── 配置访问 ───────────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def settings(self) -> dict[str, Any]:
|
|
141
|
+
return self._config.get("settings", {})
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def engines(self) -> dict[str, dict[str, Any]]:
|
|
145
|
+
return self._config.get("engines", {})
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def parse_maps(self) -> dict[str, Any]:
|
|
149
|
+
return self._parse_maps
|
|
150
|
+
|
|
151
|
+
def get_engine(self, name: str) -> dict[str, Any] | None:
|
|
152
|
+
"""获取指定引擎的完整配置(含运行时可用状态)。"""
|
|
153
|
+
spec = self.engines.get(name)
|
|
154
|
+
if spec is None:
|
|
155
|
+
return None
|
|
156
|
+
merged = dict(spec)
|
|
157
|
+
merged["name"] = name
|
|
158
|
+
health = self._health.get(name, {})
|
|
159
|
+
merged["available"] = self._is_available(name, spec, health)
|
|
160
|
+
merged["last_checked"] = health.get("last_checked")
|
|
161
|
+
merged["consecutive_failures"] = health.get("consecutive_failures", 0)
|
|
162
|
+
return merged
|
|
163
|
+
|
|
164
|
+
def _is_available(self, name: str, spec: dict[str, Any], health: dict[str, Any]) -> bool:
|
|
165
|
+
"""综合 enabled 与健康状态判定可用性。"""
|
|
166
|
+
if not spec.get("enabled", True):
|
|
167
|
+
return False
|
|
168
|
+
# 没有健康记录时默认可用(由 health_check 负责探针)
|
|
169
|
+
if not health:
|
|
170
|
+
return True
|
|
171
|
+
return bool(health.get("available", True))
|
|
172
|
+
|
|
173
|
+
def list_engines(
|
|
174
|
+
self,
|
|
175
|
+
category: str | None = None,
|
|
176
|
+
available_only: bool = False,
|
|
177
|
+
enabled_only: bool = False,
|
|
178
|
+
) -> list[str]:
|
|
179
|
+
"""列出引擎名,支持按分类、可用性、启用状态过滤。"""
|
|
180
|
+
names: list[str] = []
|
|
181
|
+
for name, spec in self.engines.items():
|
|
182
|
+
if enabled_only and not spec.get("enabled", True):
|
|
183
|
+
continue
|
|
184
|
+
cats = spec.get("categories", [])
|
|
185
|
+
if isinstance(cats, str):
|
|
186
|
+
cats = [cats]
|
|
187
|
+
if category and category not in cats:
|
|
188
|
+
continue
|
|
189
|
+
if available_only:
|
|
190
|
+
eng = self.get_engine(name)
|
|
191
|
+
if not eng or not eng.get("available", True):
|
|
192
|
+
continue
|
|
193
|
+
names.append(name)
|
|
194
|
+
return names
|
|
195
|
+
|
|
196
|
+
def list_categories(self) -> list[str]:
|
|
197
|
+
"""返回实际出现的分类(保留顺序)。"""
|
|
198
|
+
seen: set[str] = set()
|
|
199
|
+
cats: list[str] = []
|
|
200
|
+
for spec in self.engines.values():
|
|
201
|
+
for c in spec.get("categories", []):
|
|
202
|
+
if isinstance(c, str) and c not in seen:
|
|
203
|
+
seen.add(c)
|
|
204
|
+
cats.append(c)
|
|
205
|
+
# 确保默认分类存在
|
|
206
|
+
for c in DEFAULT_CATEGORIES:
|
|
207
|
+
if c not in seen:
|
|
208
|
+
cats.append(c)
|
|
209
|
+
return cats
|
|
210
|
+
|
|
211
|
+
# ── 可用性管理 ───────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def get_health(self, name: str) -> dict[str, Any]:
|
|
214
|
+
return dict(self._health.get(name, {}))
|
|
215
|
+
|
|
216
|
+
def update_availability(self, name: str, available: bool, **extra: Any) -> None:
|
|
217
|
+
"""更新单个引擎的可用状态,并持久化。"""
|
|
218
|
+
now = time.time()
|
|
219
|
+
record = self._health.setdefault(name, {})
|
|
220
|
+
record["last_checked"] = now
|
|
221
|
+
record["available"] = available
|
|
222
|
+
if available:
|
|
223
|
+
record["consecutive_failures"] = 0
|
|
224
|
+
record["last_ok"] = now
|
|
225
|
+
record["fail_reason"] = None
|
|
226
|
+
else:
|
|
227
|
+
record["consecutive_failures"] = record.get("consecutive_failures", 0) + 1
|
|
228
|
+
if extra.get("fail_reason"):
|
|
229
|
+
record["fail_reason"] = extra["fail_reason"]
|
|
230
|
+
for k, v in extra.items():
|
|
231
|
+
if k not in ("last_checked", "available", "consecutive_failures"):
|
|
232
|
+
record[k] = v
|
|
233
|
+
self._save_health()
|
|
234
|
+
|
|
235
|
+
def bulk_update_availability(self, updates: dict[str, dict[str, Any]]) -> None:
|
|
236
|
+
"""批量更新可用状态。"""
|
|
237
|
+
for name, data in updates.items():
|
|
238
|
+
self.update_availability(name, data.get("available", False), **data)
|
|
239
|
+
|
|
240
|
+
def is_available(self, name: str) -> bool:
|
|
241
|
+
eng = self.get_engine(name)
|
|
242
|
+
if eng is not None:
|
|
243
|
+
return bool(eng.get("available", True))
|
|
244
|
+
# 引擎不在配置中,但健康记录存在时直接取记录状态
|
|
245
|
+
health = self._health.get(name, {})
|
|
246
|
+
return bool(health.get("available", True))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@functools.lru_cache(maxsize=1)
|
|
250
|
+
def get_registry() -> EngineRegistry:
|
|
251
|
+
return EngineRegistry()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def get_engine(name: str) -> dict[str, Any] | None:
|
|
255
|
+
return get_registry().get_engine(name)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def list_engines(
|
|
259
|
+
category: str | None = None,
|
|
260
|
+
available_only: bool = False,
|
|
261
|
+
enabled_only: bool = False,
|
|
262
|
+
) -> list[str]:
|
|
263
|
+
return get_registry().list_engines(category, available_only, enabled_only)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def update_availability(name: str, available: bool, **extra: Any) -> None:
|
|
267
|
+
get_registry().update_availability(name, available, **extra)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def list_categories() -> list[str]:
|
|
271
|
+
return get_registry().list_categories()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
import argparse
|
|
276
|
+
|
|
277
|
+
parser = argparse.ArgumentParser(description="local-search 引擎注册表调试")
|
|
278
|
+
parser.add_argument("--list", action="store_true", help="列出所有引擎")
|
|
279
|
+
parser.add_argument("--category", default=None, help="按分类过滤")
|
|
280
|
+
parser.add_argument("--available", action="store_true", help="仅可用引擎")
|
|
281
|
+
parser.add_argument("--engine", default=None, help="查看单个引擎")
|
|
282
|
+
parser.add_argument("--reload", action="store_true", help="强制重载")
|
|
283
|
+
args = parser.parse_args()
|
|
284
|
+
|
|
285
|
+
reg = get_registry()
|
|
286
|
+
if args.reload:
|
|
287
|
+
reg.force_reload()
|
|
288
|
+
|
|
289
|
+
if args.engine:
|
|
290
|
+
print(json.dumps(reg.get_engine(args.engine), ensure_ascii=False, indent=2))
|
|
291
|
+
elif args.list or args.category or args.available:
|
|
292
|
+
names = reg.list_engines(category=args.category, available_only=args.available)
|
|
293
|
+
print(json.dumps(names, ensure_ascii=False, indent=2))
|
|
294
|
+
else:
|
|
295
|
+
print(json.dumps({
|
|
296
|
+
"categories": reg.list_categories(),
|
|
297
|
+
"engines": {n: reg.get_engine(n) for n in reg.list_engines()},
|
|
298
|
+
}, ensure_ascii=False, indent=2, default=str))
|