argo-search 2.9.8 → 2.9.9

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.
Files changed (46) hide show
  1. package/README.en.md +30 -20
  2. package/README.es.md +30 -20
  3. package/README.ja.md +30 -20
  4. package/README.ko.md +30 -20
  5. package/README.md +32 -22
  6. package/SKILL.md +1 -1
  7. package/bin/argo +4 -0
  8. package/bin/argo.js +1 -1
  9. package/config.yaml +52 -5
  10. package/docs/ARGO_INTRO.md +5 -3
  11. package/docs/RELEASE_NOTES_v2.9.9.md +78 -0
  12. package/package.json +3 -2
  13. package/packages/dsh-plugin/README.md +3 -2
  14. package/packages/dsh-plugin/dsh/client.js +98 -32
  15. package/packages/dsh-plugin/dsh/diagnostics.js +43 -0
  16. package/packages/dsh-plugin/dsh/index.js +23 -1
  17. package/packages/dsh-plugin/package.json +2 -2
  18. package/packages/dsh-plugin/test/client.test.js +2 -1
  19. package/scripts/adaptive.py +102 -42
  20. package/scripts/bench_tail_ab.py +281 -0
  21. package/scripts/bounded_run.py +91 -49
  22. package/scripts/clock_bridge.py +22 -0
  23. package/scripts/content_security.py +119 -4
  24. package/scripts/dispatch_result.py +9 -2
  25. package/scripts/engine_add.py +602 -0
  26. package/scripts/engine_dispatch.py +9 -0
  27. package/scripts/evidence_loop.py +4 -0
  28. package/scripts/fetch_quality.py +13 -0
  29. package/scripts/fetch_v3.py +3 -3
  30. package/scripts/http_client.py +24 -38
  31. package/scripts/image_vision/vision_probe +0 -0
  32. package/scripts/local_engines.py +321 -0
  33. package/scripts/matrix_search_eval.py +111 -1
  34. package/scripts/mcp_transport.py +1 -1
  35. package/scripts/net_proxy.py +27 -0
  36. package/scripts/recovery.py +7 -3
  37. package/scripts/route_combo.py +19 -5
  38. package/scripts/route_domains.py +138 -12
  39. package/scripts/safe_conn.py +52 -0
  40. package/scripts/search_entry.py +1 -0
  41. package/scripts/search_pipeline.py +20 -2
  42. package/scripts/search_rank.py +6 -0
  43. package/scripts/serp_guard.py +23 -0
  44. package/scripts/tail_completion.py +212 -0
  45. package/scripts/url_safety.py +126 -3
  46. package/scripts/usage_log.py +45 -0
@@ -0,0 +1,602 @@
1
+ #!/usr/bin/env python3
2
+ """engine_add.py — `argo engine add`:新引擎一键接入(自适应 + 快速)。
3
+
4
+ ## 为什么需要它
5
+
6
+ v2.9.8 已有声明式 spec(`engines/specs/*.yaml`)、`engine_validate`、
7
+ `engine_admission`、`engine_requires`、`adaptive`,但接入一个新引擎仍需人工多步:
8
+
9
+ 写 spec → 设 env → engine_validate → sync_backends
10
+ → 改 config.yaml 的域 combo → 补 engine_families 白名单 → 补 domain_profiles 语料
11
+
12
+ 本命令把这条链编排成一步,并**自动派生**路由所需的元数据(family / coverage /
13
+ langs / domain_hints),让新引擎「接入即可路由、自适应找域」。
14
+
15
+ ## 三档用法
16
+
17
+ # 1) 已有 spec 文件:安装 + 验证 + 准入
18
+ argo engine add --from-yaml engines/specs/myengine.yaml --admit
19
+
20
+ # 2) 只有 URL:生成 L1 模板 → 探测 → 派生 → 验证(交互式补全 headers/output_map)
21
+ argo engine add --from-url https://api.example.com/v1/search --demo "test query"
22
+
23
+ # 3) 已声明但没接入路由:只补元数据 + 验证 + 准入
24
+ argo engine add --engine myengine --admit
25
+
26
+ ## 派生规则(保守,宁缺勿假)
27
+
28
+ family ← 探测结果字段 + desc 关键词,映射到既有能力族;判不准留空(默认 web_general)
29
+ coverage ← desc / 探测结果主题,映射到既有域的 coverage 标签
30
+ langs ← 探测查询的语言 + 引擎语言参数(setlang/hl)推断
31
+ domain_hints ← TF-IDF 语料最近邻域(top-3),供路由先验与探索 boost
32
+
33
+ ## 幂等与安全
34
+
35
+ - spec 已存在时默认跳过写入(--overwrite 覆盖)
36
+ - 探测失败不阻断,如实报告(fail-open)
37
+ - --dry-run 只打印计划
38
+ - 写 spec / backends / admission 前都校验 YAML/JSON 可解析
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import re
47
+ import shutil
48
+ import sys
49
+ import time
50
+ from pathlib import Path
51
+ from typing import Any
52
+
53
+ SCRIPT_DIR = Path(__file__).resolve().parent
54
+ ROOT = SCRIPT_DIR.parent
55
+ if str(SCRIPT_DIR) not in sys.path:
56
+ sys.path.insert(0, str(SCRIPT_DIR))
57
+
58
+ from cli_io import dumps # noqa: E402
59
+
60
+ SPECS_DIR = ROOT / "engines" / "specs"
61
+ CONFIG_PATH = ROOT / "config.yaml"
62
+ DOMAIN_PROFILES_PATH = ROOT / "backends" / "domain_profiles.json"
63
+
64
+ # 探测用的通用 canary(垂类引擎用更贴的词,见 probe_engine)
65
+ DEFAULT_CANARY = "latest news"
66
+
67
+ # desc/探测结果 → 能力族 的关键词映射(保守:只映射高置信词)
68
+ _FAMILY_KEYWORDS: dict[str, tuple[str, ...]] = {
69
+ "web_general": ("网页", "搜索", "web", "search", "资讯", "新闻", "网页搜索"),
70
+ "academic": ("论文", "学术", "paper", "scholar", "期刊", "文献", "arxiv", "引用"),
71
+ "code": ("代码", "包", "库", "code", "github", "npm", "pypi", "api", "文档", "sdk"),
72
+ "finance_market": ("股票", "股价", "行情", "基金", "期货", "外汇", "quote", "market", "stock"),
73
+ "finance_macro": ("gdp", "cpi", "宏观", "经济", "通胀", "利率", "失业率", "macro", "经济数据"),
74
+ "news_flash": ("快讯", "电报", "flash", "实时", "突发", "头条"),
75
+ "social": ("社区", "知乎", "微博", "reddit", "twitter", "小红书", "b站", "论坛", "舆情"),
76
+ "knowledge": ("百科", "知识", "维基", "wikipedia", "实体", "词典", "wikidata"),
77
+ "media_book": ("电影", "图书", "音乐", "影视", "imdb", "itunes", "专辑", "书籍", "movie"),
78
+ "sports": ("体育", "足球", "篮球", "比赛", "sports", "nba", "赛事"),
79
+ "science_chem": ("化学", "分子", "化合物", "chem", "pharma", "药物"),
80
+ "science_bio": ("生物", "蛋白", "基因", "物种", "bio", "protein"),
81
+ "science_geo": ("地理", "地图", "地点", "geo", "地点位置", "poi"),
82
+ "legal": ("法律", "判例", "法规", "legal", "law", "案例"),
83
+ "archive": ("归档", "历史", "archive", "快照", "wayback"),
84
+ }
85
+
86
+ # desc/探测结果 → 域 coverage 标签
87
+ _COVERAGE_KEYWORDS: dict[str, tuple[str, ...]] = {
88
+ "general": ("通用", "general", "网页", "搜索"),
89
+ "tech": ("技术", "tech", "开发", "编程"),
90
+ "finance": ("金融", "finance", "财经"),
91
+ "stock": ("股票", "stock", "股价"),
92
+ "fund": ("基金", "fund"),
93
+ "academic": ("学术", "academic", "论文"),
94
+ "film": ("电影", "film", "影视"),
95
+ "movie": ("电影", "movie"),
96
+ "tv": ("剧集", "tv"),
97
+ "media": ("媒体", "media"),
98
+ "news": ("新闻", "news"),
99
+ "code": ("代码", "code"),
100
+ "sports": ("体育", "sports"),
101
+ }
102
+
103
+
104
+ def _log(msg: str) -> None:
105
+ print(msg, file=sys.stderr)
106
+
107
+
108
+ # ── spec 读写 ────────────────────────────────────────────────────────────────
109
+
110
+ def load_spec_file(path: Path) -> dict[str, Any]:
111
+ # 统一走 yaml_load(loader 选择单一真源,见 tests/test_yaml_loader_centralized)
112
+ from yaml_load import load as _yaml_load
113
+ data = _yaml_load(path) or {}
114
+ if not isinstance(data, dict):
115
+ raise ValueError(f"spec 不是 mapping: {path}")
116
+ return data
117
+
118
+
119
+ def spec_engine_id(spec: dict[str, Any], fallback: str = "") -> str:
120
+ return str(spec.get("engine_id") or spec.get("id") or fallback).strip()
121
+
122
+
123
+ def write_spec(spec: dict[str, Any], *, overwrite: bool = False) -> tuple[Path, bool]:
124
+ """把 spec 写入 engines/specs/<id>.yaml。返回 (路径, 是否新写)。"""
125
+ import yaml
126
+ eid = spec_engine_id(spec)
127
+ if not eid:
128
+ raise ValueError("spec 缺少 engine_id")
129
+ target = SPECS_DIR / f"{eid}.yaml"
130
+ if target.exists() and not overwrite:
131
+ return target, False
132
+ SPECS_DIR.mkdir(parents=True, exist_ok=True)
133
+ header = (
134
+ f"# {eid} — 由 `argo engine add` 接入({time.strftime('%Y-%m-%d')})。\n"
135
+ f"# 字段说明见 docs/ADDING_NEW_ENGINE.md;路由元数据(family/coverage/langs/\n"
136
+ f"# domain_hints)由 engine_add 自动派生,可手工微调。\n"
137
+ )
138
+ body = yaml.safe_dump(spec, allow_unicode=True, sort_keys=False)
139
+ target.write_text(header + body, encoding="utf-8")
140
+ return target, True
141
+
142
+
143
+ def read_domain_profiles() -> dict[str, Any]:
144
+ if DOMAIN_PROFILES_PATH.exists():
145
+ try:
146
+ return json.loads(DOMAIN_PROFILES_PATH.read_text(encoding="utf-8"))
147
+ except Exception:
148
+ return {}
149
+ return {}
150
+
151
+
152
+ def write_domain_profiles(data: dict[str, Any]) -> None:
153
+ DOMAIN_PROFILES_PATH.parent.mkdir(parents=True, exist_ok=True)
154
+ tmp = DOMAIN_PROFILES_PATH.with_suffix(".json.tmp")
155
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
156
+ tmp.replace(DOMAIN_PROFILES_PATH)
157
+
158
+
159
+ # ── 探测 ──────────────────────────────────────────────────────────────────────
160
+
161
+ def probe_engine(engine_id: str, *, query: str | None = None, n: int = 3,
162
+ timeout: float = 10.0) -> dict[str, Any]:
163
+ """跑一次真实搜索,拿到结构化结果用于派生元数据。"""
164
+ try:
165
+ from engine_validate import run_health, ENGINE_CANARY_QUERIES
166
+ except Exception as e: # pragma: no cover
167
+ return {"ok": False, "error": f"engine_validate 不可用: {type(e).__name__}"}
168
+ canary = query or ENGINE_CANARY_QUERIES.get(engine_id) or DEFAULT_CANARY
169
+ try:
170
+ health = run_health(engine_id, query=canary, n=n, timeout=timeout)
171
+ except Exception as e:
172
+ return {"ok": False, "error": f"探测异常: {type(e).__name__}: {e}"}
173
+ return {"ok": bool(health.get("ok")), "status": health.get("status"),
174
+ "query": canary, "health": health}
175
+
176
+
177
+ def _texts_from_health(health: dict[str, Any]) -> list[str]:
178
+ """从 health 结果里抽可派生主题的文本(title/snippet/desc)。"""
179
+ out: list[str] = []
180
+ results = (health or {}).get("results") or (health or {}).get("items") or []
181
+ if isinstance(results, list):
182
+ for r in results[:5]:
183
+ if isinstance(r, dict):
184
+ for k in ("title", "snippet", "summary", "description", "url", "source"):
185
+ v = r.get(k)
186
+ if isinstance(v, str) and v:
187
+ out.append(v)
188
+ return out
189
+
190
+
191
+ # ── 元数据派生 ────────────────────────────────────────────────────────────────
192
+
193
+ def derive_family(spec: dict[str, Any], health: dict[str, Any]) -> str:
194
+ """从 spec 的 family/type/desc + 探测文本推断能力族。判不准返回 ''(走默认)。"""
195
+ declared = str(spec.get("family") or "").strip()
196
+ if declared:
197
+ return declared
198
+ hay = " ".join([
199
+ str(spec.get("desc") or ""),
200
+ str(spec.get("label") or ""),
201
+ str(spec.get("type") or ""),
202
+ " ".join(_texts_from_health(health.get("health") or {})),
203
+ ]).lower()
204
+ best, best_hits = "", 0
205
+ for fam, kws in _FAMILY_KEYWORDS.items():
206
+ hits = sum(1 for kw in kws if kw.lower() in hay)
207
+ if hits > best_hits:
208
+ best, best_hits = fam, hits
209
+ return best if best_hits >= 1 else ""
210
+
211
+
212
+ def derive_coverage(spec: dict[str, Any], health: dict[str, Any]) -> list[str]:
213
+ declared = spec.get("coverage")
214
+ if isinstance(declared, list) and declared:
215
+ return [str(c) for c in declared]
216
+ hay = " ".join([
217
+ str(spec.get("desc") or ""),
218
+ " ".join(_texts_from_health(health.get("health") or {})),
219
+ ]).lower()
220
+ out: list[str] = []
221
+ for cov, kws in _COVERAGE_KEYWORDS.items():
222
+ if any(kw.lower() in hay for kw in kws) and cov not in out:
223
+ out.append(cov)
224
+ return out[:4]
225
+
226
+
227
+ def derive_langs(spec: dict[str, Any], probe_query: str | None) -> list[str]:
228
+ """从 spec.langs / 语言参数 / 探测查询推断语言能力。"""
229
+ declared = spec.get("langs")
230
+ if isinstance(declared, list) and declared:
231
+ return [str(x) for x in declared]
232
+ out: list[str] = []
233
+ if probe_query:
234
+ try:
235
+ from lang_detect import detect_language
236
+ lg = detect_language(probe_query)
237
+ if lg and lg != "other":
238
+ out.append("zh" if lg == "zh" else ("en" if lg in ("en", "latin") else lg))
239
+ except Exception:
240
+ pass
241
+ # 引擎语言参数(setlang/hl/market)→ 多语言
242
+ url = str(spec.get("url") or "")
243
+ params = {k: v for k, v in (spec.get("extra_params") or {}).items()}
244
+ if any(k in url or k in params for k in ("setlang", "hl", "market", "language", "lang")):
245
+ out.append("*") # 语言中立/多语言
246
+ seen: list[str] = []
247
+ for x in out:
248
+ if x not in seen:
249
+ seen.append(x)
250
+ return seen[:3] or ["*"]
251
+
252
+
253
+ def derive_domain_hints(spec: dict[str, Any], health: dict[str, Any], top: int = 3) -> list[str]:
254
+ """找最近邻域:TF-IDF 语料最近邻 + 同能力族域,两路并集(保序)。
255
+
256
+ 为什么两路:TF-IDF 语料是「引擎文档 vs 查询」的相似度,对新引擎不可靠
257
+ (新引擎自己的语料是刚写入的 desc,查询是探测文本,两边都稀);
258
+ 同能力族域(哪些域的 combo 里有本族引擎)是更稳的族级先验。
259
+ """
260
+ try:
261
+ from config import get_domains
262
+ domains = get_domains()
263
+ except Exception:
264
+ return []
265
+ family = str(spec.get("family") or "").strip()
266
+ hints: list[str] = []
267
+
268
+ # 路一:同能力族域(稳定先验)
269
+ if family:
270
+ try:
271
+ from engine_families import family_of
272
+ from config import get_engines
273
+ specs_all = get_engines()
274
+ fam_members: set[str] = set()
275
+ for e in (specs_all or {}):
276
+ if family_of(e, (specs_all or {}).get(e)) == family:
277
+ fam_members.add(e)
278
+ for d in domains:
279
+ name = d.get("name")
280
+ if not name:
281
+ continue
282
+ members = set((d.get("engines_combo") or []) + ([d.get("primary")] if d.get("primary") else []))
283
+ if members & fam_members and name not in hints:
284
+ hints.append(name)
285
+ if len(hints) >= top:
286
+ return hints[:top]
287
+ except Exception:
288
+ pass
289
+
290
+ # 路二:TF-IDF 最近邻(引擎 → 域)
291
+ try:
292
+ from tfidf_router import get_router
293
+ router = get_router()
294
+ router._ensure_loaded()
295
+ except Exception:
296
+ return hints[:top]
297
+ hay = " ".join([
298
+ str(spec.get("desc") or ""),
299
+ str(spec.get("label") or ""),
300
+ " ".join(_texts_from_health(health.get("health") or {})),
301
+ ]).strip()
302
+ if not hay:
303
+ return hints[:top]
304
+ try:
305
+ scores = router.route(hay, top_k=5)
306
+ except Exception:
307
+ return hints[:top]
308
+ eng_to_domain: dict[str, list[str]] = {}
309
+ for d in domains:
310
+ name = d.get("name")
311
+ if not name:
312
+ continue
313
+ for e in (d.get("engines_combo") or []) + ([d.get("primary")] if d.get("primary") else []):
314
+ if e:
315
+ eng_to_domain.setdefault(str(e), []).append(name)
316
+ for eng, score, _ in scores:
317
+ if score <= 0:
318
+ continue
319
+ for dom in eng_to_domain.get(eng, []):
320
+ if dom not in hints:
321
+ hints.append(dom)
322
+ if len(hints) >= top:
323
+ break
324
+ return hints[:top]
325
+
326
+
327
+ def build_manifest(spec: dict[str, Any], health: dict[str, Any]) -> dict[str, Any]:
328
+ """把派生结果并入 spec(不覆盖已有声明)。"""
329
+ out = dict(spec)
330
+ fam = derive_family(spec, health)
331
+ if fam and not out.get("family"):
332
+ out["family"] = fam
333
+ cov = derive_coverage(spec, health)
334
+ if cov and not out.get("coverage"):
335
+ out["coverage"] = cov
336
+ langs = derive_langs(spec, (health or {}).get("query"))
337
+ if langs and not out.get("langs"):
338
+ out["langs"] = langs
339
+ hints = derive_domain_hints(spec, health)
340
+ if hints:
341
+ out["domain_hints"] = hints
342
+ return out
343
+
344
+
345
+ # ── 语料 / 派生件 ─────────────────────────────────────────────────────────────
346
+
347
+ def update_corpus(engine_id: str, spec: dict[str, Any]) -> bool:
348
+ """把新引擎写进 domain_profiles.json(TF-IDF 语料),否则语义路由恒 0。"""
349
+ data = read_domain_profiles()
350
+ if engine_id in data:
351
+ return False
352
+ docs: list[str] = []
353
+ if isinstance(spec.get("corpus"), list):
354
+ docs = [str(x) for x in spec["corpus"] if str(x).strip()]
355
+ elif spec.get("desc"):
356
+ # 用 desc + coverage 拼一条语料(保守:不编造)
357
+ parts = [str(spec["desc"]).strip()]
358
+ if spec.get("label"):
359
+ parts.insert(0, str(spec["label"]).strip())
360
+ docs = [" ".join(parts)]
361
+ if not docs:
362
+ return False
363
+ data[engine_id] = {
364
+ "label": spec.get("label") or engine_id,
365
+ "documents": docs,
366
+ "boost_keywords": {},
367
+ "boost_combos": {},
368
+ }
369
+ write_domain_profiles(data)
370
+ return True
371
+
372
+
373
+ def run_sync_backends() -> dict[str, Any]:
374
+ """调用 sync_backends.py 派生三份 backends 文件。"""
375
+ script = SCRIPT_DIR / "sync_backends.py"
376
+ if not script.exists():
377
+ return {"ok": False, "error": "sync_backends.py 不存在"}
378
+ import subprocess
379
+ try:
380
+ r = subprocess.run([sys.executable, str(script)],
381
+ capture_output=True, text=True, timeout=120,
382
+ encoding="utf-8", errors="replace")
383
+ return {"ok": r.returncode == 0, "stdout": r.stdout[-800:],
384
+ "stderr": r.stderr[-800:]}
385
+ except Exception as e:
386
+ return {"ok": False, "error": f"{type(e).__name__}: {e}"}
387
+
388
+
389
+ # ── 域归属建议 ────────────────────────────────────────────────────────────────
390
+
391
+ def suggest_domain_assignment(engine_id: str, domain_hints: list[str],
392
+ family: str, coverage: list[str]) -> dict[str, Any]:
393
+ """给出「把该引擎加进哪个域的 combo」的建议(只建议,不改 config)。"""
394
+ if not domain_hints:
395
+ return {"suggestion": "none", "reason": "无域线索,建议先手工观察再决定"}
396
+ return {
397
+ "suggestion": "append_to_combo",
398
+ "domains": domain_hints,
399
+ "how": (
400
+ "在 config.yaml 的 domains[].engines_combo 末尾追加 "
401
+ f"'{engine_id}'(位次靠后,由 route_policy 的加槽机制保护),"
402
+ "或在 route_policy._VERTICAL_NEW_SOURCE 里登记以自动加槽。"
403
+ ),
404
+ "family": family,
405
+ "coverage": coverage,
406
+ }
407
+
408
+
409
+ # ── 主流程 ────────────────────────────────────────────────────────────────────
410
+
411
+ def cmd_add(args: argparse.Namespace) -> int:
412
+ dry = args.dry_run
413
+ spec: dict[str, Any] = {}
414
+ source = ""
415
+
416
+ # 1) 解析来源
417
+ if args.from_yaml:
418
+ p = Path(args.from_yaml)
419
+ if not p.exists():
420
+ _log(f"✗ spec 不存在: {p}")
421
+ return 2
422
+ spec = load_spec_file(p)
423
+ source = str(p)
424
+ elif args.from_url:
425
+ eid = args.engine or _slug_from_url(args.from_url)
426
+ spec = _template_from_url(args.from_url, eid, args)
427
+ source = args.from_url
428
+ elif args.engine:
429
+ # 已声明:从合并配置里取 spec
430
+ try:
431
+ from config import load_config, get_engines
432
+ eng = get_engines(load_config())
433
+ if args.engine not in eng:
434
+ _log(f"✗ 引擎未声明: {args.engine}")
435
+ return 2
436
+ spec = dict(eng[args.engine])
437
+ spec["engine_id"] = args.engine
438
+ source = f"config.yaml engines.{args.engine}"
439
+ except Exception as e:
440
+ _log(f"✗ 读取配置失败: {type(e).__name__}: {e}")
441
+ return 2
442
+ else:
443
+ _log("✗ 需要 --from-yaml / --from-url / --engine 之一")
444
+ return 2
445
+
446
+ eid = spec_engine_id(spec, args.engine or "")
447
+ if not eid:
448
+ _log("✗ 无法确定 engine_id")
449
+ return 2
450
+
451
+ plan = {
452
+ "engine_id": eid,
453
+ "source": source,
454
+ "steps": ["spec", "probe", "derive", "corpus", "validate", "admit", "sync"],
455
+ }
456
+ if dry:
457
+ print(dumps(plan))
458
+ _log("[dry-run] 未做任何写入")
459
+ return 0
460
+
461
+ # 2) spec 落盘(from-yaml/from-url 时)
462
+ if args.from_yaml or args.from_url:
463
+ try:
464
+ target, wrote = write_spec(spec, overwrite=args.overwrite)
465
+ _log(f"{'✓ 写入' if wrote else '· 已存在(跳过)'} spec: {target}")
466
+ except Exception as e:
467
+ _log(f"✗ 写 spec 失败: {type(e).__name__}: {e}")
468
+ return 1
469
+ else:
470
+ _log(f"· 使用既有声明: {source}")
471
+
472
+ # 3) 探测(缺 key 时 skipped,不阻断)
473
+ _log(f"· 探测引擎 [{eid}] …")
474
+ probe = probe_engine(eid, query=args.demo, timeout=args.timeout)
475
+ if not probe.get("ok"):
476
+ _log(f"· 探测未通过/跳过: {probe.get('status') or probe.get('error')}(继续派生,不阻断)")
477
+ else:
478
+ _log(f"✓ 探测通过: {probe.get('query')}")
479
+
480
+ # 4) 派生元数据(并入 spec 并回写)
481
+ manifest = build_manifest(spec, probe)
482
+ if args.from_yaml or args.from_url:
483
+ try:
484
+ target, wrote = write_spec(manifest, overwrite=True)
485
+ if wrote:
486
+ _log(f"✓ 派生元数据已写入: {target}")
487
+ except Exception as e:
488
+ _log(f"· 回写派生失败(不阻断): {type(e).__name__}")
489
+ else:
490
+ _log(f"· 派生元数据(建议手工并入 config.yaml): "
491
+ f"family={manifest.get('family') or '-'} "
492
+ f"coverage={manifest.get('coverage') or '-'} "
493
+ f"langs={manifest.get('langs') or '-'} "
494
+ f"domain_hints={manifest.get('domain_hints') or '-'}")
495
+
496
+ # 5) 语料(TF-IDF)
497
+ if update_corpus(eid, manifest):
498
+ _log(f"✓ 语料已写入: backends/domain_profiles.json [{eid}]")
499
+ else:
500
+ _log(f"· 语料已存在或无可写内容: backends/domain_profiles.json [{eid}]")
501
+
502
+ # 6) 验证
503
+ if args.stage != "none":
504
+ _log(f"· 验证引擎 [{eid}] stage={args.stage} …")
505
+ try:
506
+ from engine_validate import validate_engine
507
+ report = validate_engine(eid, stage=args.stage, admit=args.admit,
508
+ timeout=args.timeout)
509
+ ok = bool(report.get("ok"))
510
+ _log(f"{'✓' if ok else '✗'} 验证结果: ok={ok} "
511
+ f"stages={list((report.get('stages') or {}).keys())}")
512
+ if not ok and not args.admit:
513
+ _log(" (未 --admit,不写准入;可加 --admit 强制准入)")
514
+ except Exception as e:
515
+ _log(f"✗ 验证异常: {type(e).__name__}: {e}")
516
+ return 1
517
+
518
+ # 7) 派生件同步
519
+ if not args.no_sync:
520
+ _log("· 同步 backends …")
521
+ sync = run_sync_backends()
522
+ _log(f"{'✓' if sync.get('ok') else '✗'} sync_backends: "
523
+ f"{sync.get('ok') or sync.get('error')}")
524
+
525
+ # 8) 域归属建议
526
+ sugg = suggest_domain_assignment(
527
+ eid, manifest.get("domain_hints") or [],
528
+ manifest.get("family") or "", manifest.get("coverage") or [])
529
+ _log("· 域归属建议:")
530
+ print(dumps(sugg))
531
+
532
+ _log(f"\n完成。新引擎 [{eid}] 已接入;按上方建议把它加进域 combo 即可参与路由。")
533
+ return 0
534
+
535
+
536
+ def _slug_from_url(url: str) -> str:
537
+ m = re.search(r"https?://([^/]+)", url)
538
+ host = (m.group(1) if m else url).replace("www.", "")
539
+ slug = re.sub(r"[^a-z0-9]+", "_", host.lower()).strip("_")
540
+ return slug or "engine"
541
+
542
+
543
+ def _template_from_url(url: str, eid: str, args: argparse.Namespace) -> dict[str, Any]:
544
+ """从 URL 生成 L1 模板(headers/output_map 留待用户补全)。"""
545
+ return {
546
+ "engine_id": eid,
547
+ "type": "http",
548
+ "enabled": True,
549
+ "cost_tier": args.cost_tier,
550
+ "method": "GET",
551
+ "url": url,
552
+ "timeout": args.timeout,
553
+ "query_param": args.query_param or "q",
554
+ "canary_query": args.demo or DEFAULT_CANARY,
555
+ "desc": args.desc or f"{eid} 搜索(由 argo engine add 生成模板,请补全 output_map)",
556
+ "required_env": ([args.env_key] if args.env_key else []),
557
+ "headers": {"Accept": "application/json"},
558
+ "output_map": {
559
+ "items": args.items_path or "results",
560
+ "item_title": "title",
561
+ "item_url": "url",
562
+ "item_summary": "snippet",
563
+ },
564
+ }
565
+
566
+
567
+ def _build_parser() -> argparse.ArgumentParser:
568
+ p = argparse.ArgumentParser(
569
+ prog="argo engine add",
570
+ description="新引擎一键接入:spec → 探测 → 派生 → 语料 → 验证 → 准入 → 同步")
571
+ p.add_argument("--from-yaml", help="从现有 spec 文件接入")
572
+ p.add_argument("--from-url", help="从 API URL 生成 L1 模板接入")
573
+ p.add_argument("--engine", help="已声明的引擎 id(只补元数据/验证/准入)")
574
+ p.add_argument("--demo", help="探测用的查询词(垂类引擎给更贴的词)")
575
+ p.add_argument("--desc", help="--from-url 时的引擎描述")
576
+ p.add_argument("--query-param", help="查询参数名(默认 q)")
577
+ p.add_argument("--items-path", help="结果数组路径(默认 results)")
578
+ p.add_argument("--env-key", help="所需环境变量名(如 MYENGINE_API_KEY)")
579
+ p.add_argument("--cost-tier", default="free",
580
+ choices=["free", "low", "api", "paid"])
581
+ p.add_argument("--timeout", type=float, default=10.0)
582
+ p.add_argument("--stage", default="all",
583
+ choices=["health", "quality", "all", "none"])
584
+ p.add_argument("--admit", action="store_true", help="验证通过后写准入")
585
+ p.add_argument("--overwrite", action="store_true", help="覆盖已存在的 spec")
586
+ p.add_argument("--no-sync", action="store_true", help="不同步 backends")
587
+ p.add_argument("--dry-run", action="store_true", help="只打印计划")
588
+ return p
589
+
590
+
591
+ def main(argv: list[str] | None = None) -> int:
592
+ argv = list(sys.argv[1:] if argv is None else argv)
593
+ # `argo engine add ...` 与 `argo engine ...` 等价:剥掉首个 "add"
594
+ if argv and argv[0] == "add":
595
+ argv = argv[1:]
596
+ parser = _build_parser()
597
+ args = parser.parse_args(argv)
598
+ return cmd_add(args)
599
+
600
+
601
+ if __name__ == "__main__":
602
+ sys.exit(main())
@@ -602,6 +602,10 @@ def run_dispatch(*, query: str, retrieval_query: str, engines: list[str],
602
602
  # 早停弃置引擎已花的墙钟(并行资源浪费,不是用户等待);记账口径见
603
603
  # dispatch_result 模块注释。
604
604
  abandoned_ms: list[float] = []
605
+ # 早停弃置且仍存活的 (engine, holder, now_fn):交 tail_completion 后台
606
+ # 收割(2026-10-25)。now_fn 跟 _now 走——t0_mono 传入时整套是单调钟,
607
+ # 收割线程不能用墙钟去减 holder["t0"]。
608
+ stranded: list[tuple[str, dict[str, Any], Any]] = []
605
609
  first_result_at: float | None = None
606
610
  first_sufficient_at: float | None = None
607
611
  # 配额批次的构造在调用方:flush 必须发生在**最后一个 _ingest 之后**,
@@ -699,6 +703,10 @@ def run_dispatch(*, query: str, retrieval_query: str, engines: list[str],
699
703
  # 弃置的引擎白花的墙钟计入 wasted(见 abandoned_ms):
700
704
  # 它是「调度多花了多少」的量,不是「多等了多久」。
701
705
  abandoned_ms.append(lat_ms)
706
+ # 弃置≠丢弃交付:holder 交给后台收割(宽限内完成则记
707
+ # 学习器真实成绩 + 合并写缓存,见 tail_completion)。
708
+ # 主响应与上面这笔 cancelled 记账逐位不变。
709
+ stranded.append((eng, holder, _now))
702
710
  _ingest(eng, [],
703
711
  classify_outcome(eng, [], lat_ms, "cancelled"),
704
712
  lat_ms)
@@ -979,4 +987,5 @@ def run_dispatch(*, query: str, retrieval_query: str, engines: list[str],
979
987
  first_sufficient_ms=_acc["first_sufficient_ms"],
980
988
  tail_wait_ms=_acc["tail_wait_ms"],
981
989
  abandoned_engine_ms=_acc["abandoned_engine_ms"],
990
+ stranded=stranded,
982
991
  )
@@ -127,6 +127,10 @@ def extract_fetch_evidence(fetch_result: dict[str, Any]) -> Optional[dict[str, A
127
127
  content = fetch_result.get("content") or ""
128
128
  if not content.strip():
129
129
  return None
130
+ # 风险门禁(v2.9.8):被隔离的正文不进证据分——来源试图注入,其正文不该
131
+ # 拿到吸收分去影响排序(正文此时已被清洗,但来源可信度存疑)。
132
+ if fetch_result.get("content_quarantined"):
133
+ return None
130
134
  title = fetch_result.get("title") or ""
131
135
  url = fetch_result.get("url") or ""
132
136
  if not url:
@@ -53,6 +53,18 @@ def _assess_quality(result: dict) -> dict:
53
53
  except Exception:
54
54
  security = {}
55
55
 
56
+ # 风险门禁(v2.9.8):被隔离的内容用清洗后正文替换原始正文,使注入
57
+ # 污染的正文不进 Agent 上下文、不进证据分。正常内容零改动。
58
+ quarantined = bool(security.get("quarantined"))
59
+ if quarantined:
60
+ cleaned = security.get("cleaned_content")
61
+ if cleaned is not None:
62
+ content = cleaned
63
+ result["content"] = cleaned
64
+ result["length"] = len(cleaned)
65
+ # 隔离内容一律判否:来源试图注入,其正文不可信
66
+ content_ok = content_ok and not quarantined
67
+
56
68
  result.update({
57
69
  "quality_breakdown": quality_breakdown(content, html),
58
70
  "content_ok": content_ok,
@@ -63,6 +75,7 @@ def _assess_quality(result: dict) -> dict:
63
75
  "content_age_days": content_age_days,
64
76
  "quality_score": quality_score,
65
77
  "content_security": security,
78
+ "content_quarantined": quarantined,
66
79
  })
67
80
  return result
68
81