@blxzer/cursor-trellis 0.1.3 → 0.2.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +45 -23
  3. package/dist/configurators/workflow.d.ts +1 -1
  4. package/dist/configurators/workflow.d.ts.map +1 -1
  5. package/dist/configurators/workflow.js +20 -9
  6. package/dist/configurators/workflow.js.map +1 -1
  7. package/dist/migrations/manifests/0.1.3.json +9 -0
  8. package/dist/migrations/manifests/0.1.4.json +9 -0
  9. package/dist/templates/markdown/agents.md +1 -1
  10. package/dist/templates/markdown/index.d.ts +1 -0
  11. package/dist/templates/markdown/index.d.ts.map +1 -1
  12. package/dist/templates/markdown/index.js +1 -0
  13. package/dist/templates/markdown/index.js.map +1 -1
  14. package/dist/templates/markdown/prompts/run-semantic-slice-12.md.txt +13 -29
  15. package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +0 -49
  16. package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +0 -85
  17. package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +7 -21
  18. package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +6 -10
  19. package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +7 -13
  20. package/dist/templates/shared-hooks/inject-retrieval-plan.py +42 -23
  21. package/dist/templates/shared-hooks/inject-subagent-context.py +6 -2
  22. package/dist/templates/shared-hooks/session-start.py +17 -9
  23. package/dist/templates/trellis/index.d.ts +7 -4
  24. package/dist/templates/trellis/index.d.ts.map +1 -1
  25. package/dist/templates/trellis/index.js +11 -7
  26. package/dist/templates/trellis/index.js.map +1 -1
  27. package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +2 -2
  28. package/dist/templates/trellis/scripts/common/config.py +1 -1
  29. package/dist/templates/trellis/scripts/common/parent_orchestration.py +27 -15
  30. package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +1 -1
  31. package/dist/templates/trellis/scripts/common/smart_search_resolve.py +28 -3
  32. package/dist/templates/trellis/scripts/common/task_gates.py +372 -73
  33. package/dist/templates/trellis/scripts/common/task_map.py +29 -1
  34. package/dist/templates/trellis/workflow.md +9 -53
  35. package/package.json +5 -44
  36. package/bin/smart-search.js +0 -79
  37. package/dist/templates/trellis/scripts/aggregate_retrieval_telemetry.py +0 -342
  38. package/dist/templates/trellis/scripts/batch_plan_envelope.py +0 -105
  39. package/vendor/smart-search/LICENSE +0 -21
  40. package/vendor/smart-search/README.md +0 -412
  41. package/vendor/smart-search/README.zh-CN.md +0 -430
  42. package/vendor/smart-search/npm/bin/smart-search.js +0 -63
  43. package/vendor/smart-search/npm/scripts/postinstall.js +0 -87
  44. package/vendor/smart-search/npm/scripts/resolve-prerelease-version.js +0 -108
  45. package/vendor/smart-search/npm/scripts/set-package-version.js +0 -35
  46. package/vendor/smart-search/npm/scripts/sync-python-version.js +0 -22
  47. package/vendor/smart-search/npm/scripts/test-wrapper-repair.js +0 -99
  48. package/vendor/smart-search/npm/scripts/test.js +0 -85
  49. package/vendor/smart-search/package.json +0 -42
  50. package/vendor/smart-search/pyproject.toml +0 -37
  51. package/vendor/smart-search/skills/smart-search-cli/SKILL.md +0 -387
  52. package/vendor/smart-search/skills/smart-search-cli/agents/openai.yaml +0 -3
  53. package/vendor/smart-search/skills/smart-search-cli/examples/batch-search.md +0 -98
  54. package/vendor/smart-search/skills/smart-search-cli/examples/evidence-gathering.md +0 -89
  55. package/vendor/smart-search/skills/smart-search-cli/references/cli-contract.md +0 -337
  56. package/vendor/smart-search/src/smart_search/__init__.py +0 -1
  57. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/SKILL.md +0 -387
  58. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +0 -3
  59. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/batch-search.md +0 -98
  60. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/evidence-gathering.md +0 -89
  61. package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +0 -337
  62. package/vendor/smart-search/src/smart_search/cli.py +0 -2003
  63. package/vendor/smart-search/src/smart_search/config.py +0 -580
  64. package/vendor/smart-search/src/smart_search/logger.py +0 -43
  65. package/vendor/smart-search/src/smart_search/providers/__init__.py +0 -16
  66. package/vendor/smart-search/src/smart_search/providers/base.py +0 -41
  67. package/vendor/smart-search/src/smart_search/providers/context7.py +0 -141
  68. package/vendor/smart-search/src/smart_search/providers/exa.py +0 -206
  69. package/vendor/smart-search/src/smart_search/providers/jina.py +0 -136
  70. package/vendor/smart-search/src/smart_search/providers/openai_compatible.py +0 -370
  71. package/vendor/smart-search/src/smart_search/providers/zhipu.py +0 -143
  72. package/vendor/smart-search/src/smart_search/service.py +0 -2865
  73. package/vendor/smart-search/src/smart_search/sources.py +0 -429
  74. package/vendor/smart-search/src/smart_search/utils.py +0 -220
@@ -1,2865 +0,0 @@
1
- import asyncio
2
- import hashlib
3
- import json
4
- import re
5
- import time
6
- from pathlib import Path
7
- from typing import Any
8
- from urllib.parse import urlparse
9
-
10
- import httpx
11
-
12
- from .config import config
13
- from .logger import log_info
14
- from .providers.context7 import Context7Provider
15
- from .providers.exa import ExaSearchProvider
16
- from .providers.jina import JinaReaderProvider
17
- from .providers.openai_compatible import OpenAICompatibleSearchProvider, get_local_time_info
18
- from .providers.zhipu import ZhipuWebSearchProvider
19
- from .sources import merge_sources, new_session_id, split_answer_and_sources
20
- from .utils import search_prompt
21
-
22
-
23
- _AVAILABLE_MODELS_CACHE: dict[tuple[str, str], list[str]] = {}
24
- _AVAILABLE_MODELS_LOCK = asyncio.Lock()
25
- SOURCE_PROVENANCE_WARNING = (
26
- "extra_sources are retrieved in parallel and are not automatically used to verify generated content; "
27
- "use fetch on key URLs for claim-level evidence."
28
- )
29
- MINIMUM_PROFILE_ERROR = (
30
- "最低配置不满足:必须至少配置 main_search、docs_search、web_fetch 三类能力各一个 provider。"
31
- )
32
- OPENAI_COMPATIBLE_DIAGNOSE_COMMAND = "smart-search diagnose openai-compatible --format markdown"
33
- DOCS_INTENT_ASCII_KEYWORDS = {
34
- "api",
35
- "sdk",
36
- "library",
37
- "framework",
38
- "docs",
39
- "documentation",
40
- "reference",
41
- "guide",
42
- "tutorial",
43
- "quickstart",
44
- "example",
45
- "examples",
46
- "usage",
47
- "manual",
48
- "changelog",
49
- "release notes",
50
- "migration",
51
- "react",
52
- "next.js",
53
- "vue",
54
- "python",
55
- "prisma",
56
- "langchain",
57
- "openai",
58
- "context7",
59
- }
60
- DOCS_INTENT_TEXT_KEYWORDS = {
61
- "接口",
62
- "文档",
63
- "库",
64
- "框架",
65
- "函数",
66
- "参数",
67
- "配置",
68
- "教程",
69
- "指南",
70
- "示例",
71
- "用法",
72
- "快速开始",
73
- "迁移",
74
- "变更日志",
75
- "版本说明",
76
- }
77
- DOCS_INTENT_KEYWORDS = DOCS_INTENT_ASCII_KEYWORDS | DOCS_INTENT_TEXT_KEYWORDS
78
- ZH_CURRENT_KEYWORDS = {
79
- "今天",
80
- "最新",
81
- "国内",
82
- "中国",
83
- "政策",
84
- "新闻",
85
- "实时",
86
- "刚刚",
87
- "本周",
88
- "本月",
89
- "战报",
90
- "比分",
91
- "赛程",
92
- "赛果",
93
- "季后赛",
94
- "比赛",
95
- "nba",
96
- "足球",
97
- "篮球",
98
- }
99
- FETCH_INTENT_KEYWORDS = {"http://", "https://"}
100
- DEEP_ALLOWED_TOOLS = {
101
- "search",
102
- "exa-search",
103
- "exa-similar",
104
- "context7-library",
105
- "context7-docs",
106
- "fetch",
107
- "map",
108
- }
109
- DEEP_TRIGGER_KEYWORDS = {
110
- "深度搜索",
111
- "深度调研",
112
- "深入搜索",
113
- "deep search",
114
- "deep research",
115
- "核验",
116
- "验证",
117
- "交叉验证",
118
- "选型",
119
- "对比",
120
- "评测",
121
- }
122
- DEEP_HIGH_COMPLEXITY_KEYWORDS = {
123
- "对比",
124
- "选型",
125
- "核验",
126
- "验证",
127
- "为什么",
128
- "架构",
129
- "方案",
130
- "趋势",
131
- "优缺点",
132
- "风险",
133
- "区别",
134
- "怎么选",
135
- "compare",
136
- "comparison",
137
- "evaluate",
138
- "architecture",
139
- "tradeoff",
140
- "trade-off",
141
- "risk",
142
- }
143
- DEEP_RECENT_KEYWORDS = {
144
- "最近",
145
- "最新",
146
- "当前",
147
- "现在",
148
- "今天",
149
- "实时",
150
- "刚刚",
151
- "本周",
152
- "本月",
153
- "recent",
154
- "latest",
155
- "current",
156
- "today",
157
- }
158
- DEEP_CURRENT_KEYWORDS = {"今天", "实时", "刚刚", "当前", "现在", "today", "current", "live", "realtime"}
159
- DEEP_CHINA_KEYWORDS = {"中国", "国内", "中文", "政策", "监管", "公告", "A股", "港股"}
160
- DEEP_EXA_DISCOVERY_KEYWORDS = {
161
- "官方",
162
- "官网",
163
- "论文",
164
- "paper",
165
- "papers",
166
- "research paper",
167
- "产品页",
168
- "product page",
169
- "可信站点",
170
- "trusted",
171
- "known domain",
172
- "known domains",
173
- "site:",
174
- "白皮书",
175
- "standard",
176
- "standards",
177
- }
178
- RESEARCH_ROUTE_POLICY_VERSION = "research-router-v2-bilingual-no-zhipu"
179
- RESEARCH_JS_HEAVY_KEYWORDS = {
180
- "js-heavy",
181
- "javascript",
182
- "dynamic",
183
- "动态页面",
184
- "浏览器渲染",
185
- "登录页",
186
- "cloudflare",
187
- "screenshot",
188
- "ocr",
189
- "扫描",
190
- }
191
- RESEARCH_PDF_KEYWORDS = {"pdf", "arxiv", "论文", "paper", ".pdf"}
192
- RESEARCH_PROFILE_ORDER = {
193
- "main_search": ["openai-compatible"],
194
- "web_search": ["tavily", "firecrawl"],
195
- "docs_search": ["context7", "exa"],
196
- "web_fetch": ["tavily", "jina", "firecrawl"],
197
- "site_map": ["tavily"],
198
- "synthesis": ["main-search"],
199
- }
200
- PROVIDER_PROFILES: dict[str, dict[str, Any]] = {
201
- "openai-compatible": {
202
- "capability": "main_search",
203
- "strengths": ["broad synthesis", "relay compatibility"],
204
- "exclusions": ["xAI server tools"],
205
- "fallback_group": "main_search",
206
- "minimum_profile_role": "main_search",
207
- "quality_filters": ["source extraction required for high-risk claims"],
208
- "route_reasons": ["relay-compatible primary synthesis"],
209
- },
210
- "context7": {
211
- "capability": "docs_search",
212
- "strengths": ["library docs", "API docs", "framework docs", "versioned snippets"],
213
- "exclusions": ["general news", "generic web facts"],
214
- "fallback_group": "docs_search",
215
- "minimum_profile_role": "docs_search",
216
- "quality_filters": ["library id required", "content required before citation"],
217
- "route_reasons": ["docs/API evidence", "framework reference"],
218
- },
219
- "exa": {
220
- "capability": "docs_search",
221
- "strengths": ["official domains", "papers", "product pages", "trusted low-noise discovery", "similar pages"],
222
- "exclusions": ["default second hop for every high-risk claim"],
223
- "fallback_group": "docs_search",
224
- "minimum_profile_role": "docs_search",
225
- "quality_filters": ["URL required", "fetch before proof citation"],
226
- "route_reasons": ["official low-noise discovery", "paper/product discovery"],
227
- },
228
- "zhipu": {
229
- "capability": "web_search",
230
- "deprecated": True,
231
- "strengths": ["legacy Chinese/current web-search command compatibility"],
232
- "exclusions": ["web_fetch", "chat model selection"],
233
- "fallback_group": "web_search",
234
- "minimum_profile_role": "",
235
- "quality_filters": ["URL required", "fetch before proof citation"],
236
- "route_reasons": ["deprecated; not used by default routing"],
237
- },
238
- "tavily": {
239
- "capability": "web_search",
240
- "capabilities": ["web_search", "web_fetch", "site_map"],
241
- "strengths": ["broad source discovery", "site map", "URL extract"],
242
- "exclusions": ["docs semantic replacement"],
243
- "fallback_group": "web_search/web_fetch/site_map",
244
- "minimum_profile_role": "web_fetch",
245
- "quality_filters": ["non-empty normalized result", "non-empty extracted content"],
246
- "route_reasons": ["broad source discovery", "site map", "URL fetch"],
247
- },
248
- "jina": {
249
- "capability": "web_fetch",
250
- "strengths": ["known public URL", "PDF", "arXiv", "clean markdown", "ReaderLM-v2 with key"],
251
- "exclusions": ["general search provider", "anonymous standard minimum profile"],
252
- "fallback_group": "web_fetch",
253
- "minimum_profile_role": "web_fetch_with_key",
254
- "quality_filters": ["non-empty markdown", "challenge page rejection", "ReaderLM-v2 requires key"],
255
- "route_reasons": ["known URL extraction", "PDF/arXiv extraction"],
256
- },
257
- "firecrawl": {
258
- "capability": "web_fetch",
259
- "capabilities": ["web_search", "web_fetch"],
260
- "strengths": ["robust scrape fallback", "JS-heavy pages", "dynamic pages", "OCR/PDF/structured extraction"],
261
- "exclusions": ["docs semantic replacement"],
262
- "fallback_group": "web_search/web_fetch",
263
- "minimum_profile_role": "web_fetch",
264
- "quality_filters": ["non-empty normalized result", "non-empty extracted content"],
265
- "route_reasons": ["JS-heavy fetch", "dynamic/browser-like extraction", "robust fetch fallback"],
266
- },
267
- "main-search": {
268
- "capability": "synthesis",
269
- "strengths": ["evidence-only final synthesis"],
270
- "exclusions": ["live source discovery during research synthesis"],
271
- "fallback_group": "synthesis",
272
- "minimum_profile_role": "",
273
- "quality_filters": ["fetched evidence only", "no provider calls during synthesis"],
274
- "route_reasons": ["evidence-only synthesis"],
275
- },
276
- }
277
- MAIN_SEARCH_FALLBACK_CHAIN = ["openai-compatible"]
278
- MAIN_SEARCH_PROVIDER_ALIASES = {
279
- "openai-compatible": {"openai-compatible", "openai", "chat-completions", "primary"},
280
- }
281
-
282
-
283
- def _elapsed_ms(start: float) -> float:
284
- return round((time.time() - start) * 1000, 2)
285
-
286
-
287
- def _normalize_domain_filter(value: str | list[str] | tuple[str, ...] | None) -> list[str] | None:
288
- if not value:
289
- return None
290
-
291
- raw_parts = [value] if isinstance(value, str) else [str(item) for item in value if item]
292
- domains: list[str] = []
293
- for part in raw_parts:
294
- domains.extend(item.strip() for item in re.split(r"[\s,]+", part) if item.strip())
295
- return domains or None
296
-
297
-
298
- def _empty_search_result(
299
- start: float,
300
- session_id: str,
301
- query: str,
302
- error_type: str,
303
- error: str,
304
- primary_api_mode: str = "",
305
- extra: dict[str, Any] | None = None,
306
- ) -> dict[str, Any]:
307
- data: dict[str, Any] = {
308
- "ok": False,
309
- "error_type": error_type,
310
- "error": error,
311
- "session_id": session_id,
312
- "query": query,
313
- "primary_api_mode": primary_api_mode,
314
- "content": "",
315
- "sources": [],
316
- "sources_count": 0,
317
- "primary_sources": [],
318
- "primary_sources_count": 0,
319
- "extra_sources": [],
320
- "extra_sources_count": 0,
321
- "source_warning": "",
322
- "routing_decision": {},
323
- "providers_used": [],
324
- "provider_attempts": [],
325
- "fallback_used": False,
326
- "validation_level": "",
327
- "elapsed_ms": _elapsed_ms(start),
328
- }
329
- if extra:
330
- data.update(extra)
331
- return data
332
-
333
-
334
- def _attempt(
335
- capability: str,
336
- provider: str,
337
- status: str,
338
- start: float,
339
- result_count: int = 0,
340
- error_type: str = "",
341
- error: str = "",
342
- ) -> dict[str, Any]:
343
- return {
344
- "capability": capability,
345
- "provider": provider,
346
- "status": status,
347
- "error_type": error_type,
348
- "error": error,
349
- "elapsed_ms": _elapsed_ms(start),
350
- "result_count": result_count,
351
- }
352
-
353
-
354
- def _normalize_source_results(results: list[dict] | None, provider: str) -> list[dict]:
355
- normalized: list[dict] = []
356
- for item in results or []:
357
- url = (item.get("url") or item.get("link") or "").strip()
358
- if not url:
359
- continue
360
- out = {"url": url, "provider": item.get("provider") or provider}
361
- title = (item.get("title") or "").strip()
362
- if title:
363
- out["title"] = title
364
- desc = (item.get("description") or item.get("content") or item.get("snippet") or "").strip()
365
- if desc:
366
- out["description"] = desc
367
- published = item.get("published_date") or item.get("publishedDate") or item.get("publish_date")
368
- if published:
369
- out["published_date"] = published
370
- source = item.get("source") or item.get("media")
371
- if source:
372
- out["source"] = source
373
- normalized.append(out)
374
- return normalized
375
-
376
-
377
- def _provider_names_from_attempts(attempts: list[dict]) -> list[str]:
378
- names: list[str] = []
379
- for attempt in attempts:
380
- provider = attempt.get("provider")
381
- if attempt.get("status") == "ok" and provider and provider not in names:
382
- names.append(provider)
383
- return names
384
-
385
-
386
- def _fallback_used(attempts: list[dict]) -> bool:
387
- by_capability: dict[str, list[dict]] = {}
388
- for attempt in attempts:
389
- capability = attempt.get("capability", "")
390
- if attempt.get("status") in {"ok", "empty", "error"}:
391
- by_capability.setdefault(capability, []).append(attempt)
392
- for capability_attempts in by_capability.values():
393
- previous_failed = False
394
- previous_provider = ""
395
- for attempt in capability_attempts:
396
- provider = attempt.get("provider", "")
397
- status = attempt.get("status")
398
- if previous_failed:
399
- return True
400
- if previous_provider and provider and provider != previous_provider:
401
- return True
402
- previous_failed = status in {"empty", "error"}
403
- previous_provider = provider or previous_provider
404
- return False
405
-
406
-
407
- def provider_profiles() -> dict[str, dict[str, Any]]:
408
- return {provider: dict(profile) for provider, profile in PROVIDER_PROFILES.items()}
409
-
410
-
411
- def _provider_supports_capability(provider: str, capability: str) -> bool:
412
- profile = PROVIDER_PROFILES.get(provider, {})
413
- capabilities = set(profile.get("capabilities") or [profile.get("capability", "")])
414
- return capability in capabilities
415
-
416
-
417
- def _provider_configured(provider: str) -> bool:
418
- if provider == "openai-compatible":
419
- return bool(config.openai_compatible_api_url and config.openai_compatible_api_key)
420
- if provider == "context7":
421
- return bool(config.context7_api_key)
422
- if provider == "exa":
423
- return bool(config.exa_api_key)
424
- if provider == "zhipu":
425
- return bool(config.zhipu_api_key)
426
- if provider == "tavily":
427
- return bool(config.tavily_api_key)
428
- if provider == "jina":
429
- return bool(config.jina_api_key)
430
- if provider == "firecrawl":
431
- return bool(config.firecrawl_api_key)
432
- if provider == "main-search":
433
- return bool(config.openai_compatible_api_url and config.openai_compatible_api_key)
434
- return False
435
-
436
-
437
- def _configured_for_capability(capability: str, capability_status: dict[str, Any] | None = None) -> list[str]:
438
- if capability_status is not None:
439
- configured = set(capability_status.get(capability, {}).get("configured") or [])
440
- return [
441
- provider
442
- for provider in RESEARCH_PROFILE_ORDER.get(capability, [])
443
- if provider in configured and _provider_supports_capability(provider, capability)
444
- ]
445
- return [provider for provider in RESEARCH_PROFILE_ORDER.get(capability, []) if _provider_configured(provider)]
446
-
447
-
448
- def _bilingual_search_queries(query: str) -> list[dict[str, str]]:
449
- question = query.strip()
450
- return [
451
- {
452
- "locale": "zh",
453
- "label": "Chinese-language sources",
454
- "query": f"中文搜索,优先检索中文来源,并回答原问题:{question}",
455
- },
456
- {
457
- "locale": "en",
458
- "label": "English-language sources",
459
- "query": f"Search English-language sources and answer the original question: {question}",
460
- },
461
- ]
462
-
463
-
464
- def _safe_provider_overrides() -> tuple[list[str], list[str], list[str]]:
465
- known = set(PROVIDER_PROFILES)
466
- preferred = [provider for provider in config.research_preferred_providers if provider in known]
467
- disabled = [provider for provider in config.research_disabled_providers if provider in known]
468
- invalid = [
469
- provider
470
- for provider in config.research_preferred_providers + config.research_disabled_providers
471
- if provider not in known
472
- ]
473
- return preferred, disabled, invalid
474
-
475
-
476
- def _apply_research_overrides(capability: str, providers: list[str]) -> list[str]:
477
- preferred, disabled, _ = _safe_provider_overrides()
478
- allowed = [
479
- provider
480
- for provider in providers
481
- if provider not in disabled and _provider_supports_capability(provider, capability)
482
- ]
483
- ordered = [
484
- provider
485
- for provider in preferred
486
- if provider in allowed and _provider_supports_capability(provider, capability)
487
- ]
488
- ordered.extend(provider for provider in allowed if provider not in ordered)
489
- return ordered
490
-
491
-
492
- def _research_fetch_order(query: str, url: str = "", capability_status: dict[str, Any] | None = None) -> list[str]:
493
- providers = _configured_for_capability("web_fetch", capability_status)
494
- target = f"{query} {url}".lower()
495
- if _contains_any(target, RESEARCH_JS_HEAVY_KEYWORDS):
496
- preferred = ["firecrawl", "tavily", "jina"]
497
- elif _contains_any(target, RESEARCH_PDF_KEYWORDS) or url.lower().endswith(".pdf"):
498
- preferred = ["jina", "tavily", "firecrawl"]
499
- elif url or _extract_urls(query):
500
- preferred = ["jina", "tavily", "firecrawl"]
501
- else:
502
- preferred = providers
503
- ordered = [provider for provider in preferred if provider in providers]
504
- ordered.extend(provider for provider in providers if provider not in ordered)
505
- return _apply_research_overrides("web_fetch", ordered)
506
-
507
-
508
- def _research_route_signals(question: str, plan: dict[str, Any]) -> dict[str, Any]:
509
- intent = plan.get("intent_signals") or {}
510
- text = question.lower()
511
- return {
512
- "docs_api_intent": bool(intent.get("docs_api_intent")) or _is_docs_intent(question),
513
- "official_low_noise_intent": _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS),
514
- "current_or_locale_intent": intent.get("recency_requirement") in {"recent", "current"}
515
- or intent.get("locale_domain_scope") == "china"
516
- or _is_zh_current_intent(question),
517
- "known_url": bool(intent.get("known_url")) or bool(_extract_urls(question)),
518
- "pdf_or_arxiv_intent": _contains_any(question, RESEARCH_PDF_KEYWORDS),
519
- "js_heavy_intent": _contains_any(question, RESEARCH_JS_HEAVY_KEYWORDS),
520
- "claim_risk": intent.get("claim_risk", "medium"),
521
- "cross_validation_need": intent.get("cross_validation_need", "normal"),
522
- "raw_query": text,
523
- }
524
-
525
-
526
- def _research_capability_routes(
527
- question: str,
528
- plan: dict[str, Any],
529
- fallback: str,
530
- capability_status: dict[str, Any] | None = None,
531
- ) -> dict[str, Any]:
532
- signals = _research_route_signals(question, plan)
533
- _, _, invalid_overrides = _safe_provider_overrides()
534
- routes: dict[str, Any] = {
535
- "signals": signals,
536
- "fallback_mode": fallback,
537
- "route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
538
- "invalid_provider_overrides": invalid_overrides,
539
- "capabilities": {},
540
- }
541
-
542
- web_search = _configured_for_capability("web_search", capability_status)
543
- ordered = [provider for provider in ["tavily", "firecrawl"] if provider in web_search]
544
- routes["capabilities"]["web_search"] = {
545
- "providers": _apply_research_overrides("web_search", ordered),
546
- "reason": (
547
- "bilingual current/locale evidence"
548
- if signals["current_or_locale_intent"]
549
- else "bilingual broad source discovery"
550
- ),
551
- }
552
-
553
- docs = _configured_for_capability("docs_search", capability_status)
554
- docs_order = [provider for provider in ["context7", "exa"] if provider in docs]
555
- if signals["official_low_noise_intent"] and not signals["docs_api_intent"]:
556
- docs_order = [provider for provider in ["exa", "context7"] if provider in docs]
557
- routes["capabilities"]["docs_search"] = {
558
- "providers": _apply_research_overrides("docs_search", docs_order),
559
- "reason": "docs/API evidence" if signals["docs_api_intent"] else "official low-noise discovery",
560
- }
561
-
562
- fetch_order = _research_fetch_order(question, capability_status=capability_status)
563
- routes["capabilities"]["web_fetch"] = {
564
- "providers": fetch_order,
565
- "reason": "JS-heavy fetch" if signals["js_heavy_intent"] else ("known URL/PDF extraction" if signals["known_url"] or signals["pdf_or_arxiv_intent"] else "evidence extraction"),
566
- }
567
-
568
- return routes
569
-
570
-
571
- def _research_evidence_item(
572
- *,
573
- url: str,
574
- provider: str,
575
- title: str = "",
576
- content: str = "",
577
- source_type: str = "fetched_page",
578
- subquestion_id: str = "",
579
- ) -> dict[str, Any]:
580
- digest = hashlib.sha1(f"{url}\n{provider}\n{title}".encode("utf-8")).hexdigest()[:12]
581
- return {
582
- "id": f"e{digest}",
583
- "url": url,
584
- "title": title or url,
585
- "provider": provider,
586
- "source_type": source_type,
587
- "subquestion_id": subquestion_id,
588
- "content": content,
589
- "content_len": len(content or ""),
590
- "verified": bool(content and content.strip()),
591
- }
592
-
593
-
594
- def _citation_items(evidence_items: list[dict[str, Any]]) -> list[dict[str, str]]:
595
- citations: list[dict[str, str]] = []
596
- seen: set[str] = set()
597
- for item in evidence_items:
598
- url = item.get("url", "")
599
- if not url or url in seen:
600
- continue
601
- seen.add(url)
602
- citations.append({
603
- "url": url,
604
- "title": item.get("title") or url,
605
- "provider": item.get("provider") or "",
606
- })
607
- return citations
608
-
609
-
610
- def _evidence_only_synthesis(question: str, evidence_items: list[dict[str, Any]], gaps: list[dict[str, Any]]) -> str:
611
- if not evidence_items:
612
- return (
613
- f"未能为 `{question}` 获取可引用的页面正文证据。"
614
- "本次 research 已停止在降级状态,未对缺证据的结论做断言。"
615
- )
616
- lines = [f"Research result for: {question}", ""]
617
- lines.append("Evidence-backed findings:")
618
- for index, item in enumerate(evidence_items, 1):
619
- content = re.sub(r"\s+", " ", (item.get("content") or "").strip())
620
- excerpt = content[:360]
621
- lines.append(f"{index}. {item.get('title') or item.get('url')} ({item.get('provider')})")
622
- if excerpt:
623
- lines.append(f" Evidence excerpt: {excerpt}")
624
- lines.append(f" Source: {item.get('url')}")
625
- if gaps:
626
- lines.extend(["", "Unverified gaps:"])
627
- for gap in gaps:
628
- lines.append(f"- {gap.get('subquestion_id', '')}: {gap.get('reason', '')}")
629
- return "\n".join(lines).strip()
630
-
631
-
632
- def _select_candidate_urls(sources: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
633
- selected: list[dict[str, Any]] = []
634
- seen: set[str] = set()
635
- for source in sources:
636
- url = (source.get("url") or "").strip()
637
- if not url or url.startswith("context7:") or url in seen:
638
- continue
639
- seen.add(url)
640
- selected.append(source)
641
- if len(selected) >= limit:
642
- break
643
- return selected
644
-
645
-
646
- def _artifact_path(evidence_root: str, name: str) -> Path:
647
- return Path(evidence_root) / name
648
-
649
-
650
- def _write_research_artifact(evidence_root: str, name: str, data: Any) -> None:
651
- root = Path(evidence_root)
652
- root.mkdir(parents=True, exist_ok=True)
653
- path = _artifact_path(evidence_root, name)
654
- if isinstance(data, str):
655
- path.write_text(data, encoding="utf-8")
656
- else:
657
- path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
658
-
659
-
660
- def _is_docs_intent(query: str) -> bool:
661
- q = query.lower()
662
- if any(keyword in q for keyword in DOCS_INTENT_TEXT_KEYWORDS):
663
- return True
664
- for keyword in DOCS_INTENT_ASCII_KEYWORDS:
665
- pattern = re.escape(keyword).replace(r"\ ", r"\s+")
666
- if re.search(rf"(?<![a-z0-9_]){pattern}(?![a-z0-9_])", q):
667
- return True
668
- return False
669
-
670
-
671
- def _is_zh_current_intent(query: str) -> bool:
672
- q = query.lower()
673
- return any(keyword in q for keyword in ZH_CURRENT_KEYWORDS)
674
-
675
-
676
- def _is_fetch_intent(query: str) -> bool:
677
- q = query.lower()
678
- return any(keyword in q for keyword in FETCH_INTENT_KEYWORDS)
679
-
680
-
681
- def _contains_any(query: str, keywords: set[str]) -> bool:
682
- q = query.lower()
683
- return any(keyword.lower() in q for keyword in keywords)
684
-
685
-
686
- def _extract_urls(query: str) -> list[str]:
687
- urls = []
688
- for match in re.findall(r"https?://[^\s<>\]\)\"']+", query):
689
- cleaned = match.rstrip(".,;,。;)")
690
- if cleaned:
691
- urls.append(cleaned)
692
- return urls
693
-
694
-
695
- def _slugify_query(query: str) -> str:
696
- slug = re.sub(r"https?://", "", query.lower())
697
- slug = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "-", slug, flags=re.IGNORECASE)
698
- slug = slug.strip("-")
699
- return slug[:48] or "deep-research"
700
-
701
-
702
- def _default_evidence_dir(query: str) -> str:
703
- timestamp = time.strftime("%Y%m%d-%H%M")
704
- return str(config.evidence_dir / f"{timestamp}-{_slugify_query(query)}")
705
-
706
-
707
- def _quote_arg(value: str) -> str:
708
- escaped = value.replace("`", "``").replace("$", "`$").replace('"', '`"')
709
- return f'"{escaped}"'
710
-
711
-
712
- def _path_join(base: str, filename: str) -> str:
713
- return str(Path(base) / filename)
714
-
715
-
716
- def _deep_step(
717
- step_id: str,
718
- subquestion_id: str,
719
- tool: str,
720
- purpose: str,
721
- command: str,
722
- output_path: str,
723
- ) -> dict[str, str]:
724
- return {
725
- "id": step_id,
726
- "subquestion_id": subquestion_id,
727
- "tool": tool,
728
- "purpose": purpose,
729
- "command": command,
730
- "output_path": output_path,
731
- }
732
-
733
-
734
- def _deep_capability(capability: str, tools: list[str], reason: str) -> dict[str, Any]:
735
- return {"capability": capability, "tools": tools, "reason": reason}
736
-
737
-
738
- def _deep_subquestion(sub_id: str, question: str, reason: str, required_capabilities: list[str]) -> dict[str, Any]:
739
- return {
740
- "id": sub_id,
741
- "question": question,
742
- "reason": reason,
743
- "required_capabilities": required_capabilities,
744
- }
745
-
746
-
747
- def _deep_budget(value: str) -> str:
748
- budget = (value or "standard").strip().lower()
749
- return budget if budget in {"quick", "standard", "deep"} else "standard"
750
-
751
-
752
- def _is_deep_complex(query: str, budget: str) -> bool:
753
- q = re.sub(r"https?://[^\s<>\]\)\"']+", "", query)
754
- object_separators = len(re.findall(r"[/、,,]| 和 | 与 | vs | VS | versus ", q))
755
- return budget == "deep" or _contains_any(query, DEEP_HIGH_COMPLEXITY_KEYWORDS) or object_separators >= 2
756
-
757
-
758
- def build_deep_research_plan(query: str, budget: str = "standard", evidence_dir: str = "") -> dict[str, Any]:
759
- start = time.time()
760
- question = query.strip()
761
- budget = _deep_budget(budget)
762
- evidence_root = evidence_dir.strip() or _default_evidence_dir(question)
763
- urls = _extract_urls(question)
764
- known_url = bool(urls)
765
- docs_intent = _is_docs_intent(question)
766
- zh_current_intent = _is_zh_current_intent(question)
767
- recency_requirement = "none"
768
- if _contains_any(question, DEEP_CURRENT_KEYWORDS) or zh_current_intent:
769
- recency_requirement = "current"
770
- elif _contains_any(question, {"行情", "价格", "走势", "币圈", "股票", "市场"}) and _contains_any(question, DEEP_RECENT_KEYWORDS):
771
- recency_requirement = "current"
772
- elif _contains_any(question, DEEP_RECENT_KEYWORDS):
773
- recency_requirement = "recent"
774
- locale_domain_scope = "china" if _contains_any(question, DEEP_CHINA_KEYWORDS) else "global"
775
- if known_url:
776
- locale_domain_scope = "known_domains"
777
- claim_risk = "high" if recency_requirement in {"recent", "current"} or _contains_any(question, {"核验", "验证", "真假", "价格", "行情", "财经", "医疗", "政策", "监管", "risk"}) else "medium"
778
- cross_validation_need = "high" if claim_risk == "high" or _contains_any(question, {"对比", "选型", "核验", "验证", "compare", "versus"}) else "normal"
779
- authority_need = "high" if docs_intent or claim_risk == "high" or _contains_any(question, {"官方", "文档", "论文", "标准", "政策", "监管", "official"}) else "normal"
780
- complex_query = _is_deep_complex(question, budget)
781
- difficulty = "high" if complex_query else "standard"
782
-
783
- intent_signals = {
784
- "recency_requirement": recency_requirement,
785
- "docs_api_intent": docs_intent,
786
- "locale_domain_scope": locale_domain_scope,
787
- "known_url": known_url,
788
- "source_authority_need": authority_need,
789
- "claim_risk": claim_risk,
790
- "cross_validation_need": cross_validation_need,
791
- "breadth_depth_budget": budget,
792
- }
793
-
794
- decomposition: list[dict[str, Any]] = []
795
- capability_plan: list[dict[str, Any]] = []
796
- steps: list[dict[str, str]] = []
797
-
798
- def add_step(sub_id: str, tool: str, purpose: str, command: str, filename: str) -> None:
799
- step_id = f"s{len(steps) + 1}"
800
- steps.append(_deep_step(step_id, sub_id, tool, purpose, command, _path_join(evidence_root, filename)))
801
-
802
- def next_filename(suffix: str) -> str:
803
- return f"{len(steps) + 1:02d}-{suffix}"
804
-
805
- def command_search(q: str, extra_sources: int = 2, filename: str = "") -> str:
806
- output_name = filename or next_filename("search.json")
807
- return f"smart-search search {_quote_arg(q)} --validation balanced --extra-sources {extra_sources} --format json --output {_quote_arg(_path_join(evidence_root, output_name))}"
808
-
809
- def command_exa(q: str) -> str:
810
- return f"smart-search exa-search {_quote_arg(q)} --num-results 5 --format json --output {_quote_arg(_path_join(evidence_root, next_filename('exa.json')))}"
811
-
812
- def command_fetch(target: str = "<key-url>") -> str:
813
- return f"smart-search fetch {_quote_arg(target)} --format markdown --output {_quote_arg(_path_join(evidence_root, next_filename('fetch.md')))}"
814
-
815
- def add_bilingual_search_steps(sub_id: str, purpose: str, extra_sources: int) -> None:
816
- for variant in _bilingual_search_queries(question):
817
- filename = next_filename(f"search-{variant['locale']}.json")
818
- add_step(
819
- sub_id,
820
- "search",
821
- f"{purpose}: {variant['label']}",
822
- command_search(variant["query"], extra_sources, filename),
823
- filename,
824
- )
825
-
826
- def has_capability(name: str) -> bool:
827
- return any(item.get("capability") == name for item in capability_plan)
828
-
829
- if known_url:
830
- url = urls[0]
831
- parsed = urlparse(url)
832
- host = parsed.netloc or "provided URL"
833
- decomposition.append(
834
- _deep_subquestion(
835
- "sq1",
836
- f"这个已知来源页面本身说了什么?{url}",
837
- "用户已经给出 URL,Deep Research 必须先抓正文再扩展。",
838
- ["page_evidence"],
839
- )
840
- )
841
- decomposition.append(
842
- _deep_subquestion(
843
- "sq2",
844
- f"围绕 {host} 还需要哪些相邻来源或交叉来源?",
845
- "已知好 URL 适合用相似页面和广泛发现扩展证据。",
846
- ["adjacent_source_discovery", "broad_discovery"],
847
- )
848
- )
849
- capability_plan.extend(
850
- [
851
- _deep_capability("page_evidence", ["fetch"], "Fetch the user-provided URL before making claims."),
852
- _deep_capability("adjacent_source_discovery", ["exa-similar"], "Find pages adjacent to the known source."),
853
- _deep_capability("broad_discovery", ["search"], "Broaden the context if the fetched page leaves gaps."),
854
- ]
855
- )
856
- add_step("sq1", "fetch", "fetch user supplied URL first", f"smart-search fetch {_quote_arg(url)} --format markdown --output {_quote_arg(_path_join(evidence_root, '01-fetch.md'))}", "01-fetch.md")
857
- add_step("sq2", "exa-similar", "find adjacent sources from the provided URL", f"smart-search exa-similar {_quote_arg(url)} --num-results 5 --format json --output {_quote_arg(_path_join(evidence_root, '02-similar.json'))}", "02-similar.json")
858
- add_bilingual_search_steps("sq2", "broad discovery for missing context", 1)
859
- else:
860
- decomposition.append(
861
- _deep_subquestion(
862
- "sq1",
863
- f"{question} 的整体问题轮廓和候选来源是什么?",
864
- "先做 broad discovery,避免一开始把问题拆错。",
865
- ["broad_discovery"],
866
- )
867
- )
868
- capability_plan.append(_deep_capability("broad_discovery", ["search"], "Find the initial answer shape and candidate sources."))
869
- add_bilingual_search_steps("sq1", "bilingual broad discovery and routing metadata", 1 if budget == "quick" else 3)
870
-
871
- if docs_intent:
872
- decomposition.append(
873
- _deep_subquestion(
874
- "sq2",
875
- f"{question} 的官方文档、API 或 SDK 证据在哪里?",
876
- "docs/API intent should resolve the library docs first, with Exa only as official-domain discovery.",
877
- ["docs_source_discovery", "page_evidence"],
878
- )
879
- )
880
- capability_plan.append(
881
- _deep_capability(
882
- "docs_source_discovery",
883
- ["context7-library", "context7-docs"],
884
- "Resolve official library/API documentation first; use Exa only for official-domain or supplemental discovery.",
885
- )
886
- )
887
- library_hint = " ".join(re.findall(r"[A-Za-z][A-Za-z0-9_.-]*", question)[:2]) or "<library-name>"
888
- add_step(
889
- "sq2",
890
- "context7-library",
891
- "resolve library id for docs/API intent",
892
- f"smart-search context7-library {_quote_arg(library_hint)} {_quote_arg(question)} --format json --output {_quote_arg(_path_join(evidence_root, next_filename('context7-library.json')))}",
893
- next_filename("context7-library.json"),
894
- )
895
- add_step(
896
- "sq2",
897
- "context7-docs",
898
- "retrieve docs after selecting the best library_id",
899
- f"smart-search context7-docs {_quote_arg('<library_id>')} {_quote_arg(question)} --format json --output {_quote_arg(_path_join(evidence_root, next_filename('context7-docs.json')))}",
900
- next_filename("context7-docs.json"),
901
- )
902
- if _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS):
903
- capability_plan.append(
904
- _deep_capability(
905
- "official_domain_discovery",
906
- ["exa-search"],
907
- "Use Exa for official-domain or low-noise supplemental docs discovery.",
908
- )
909
- )
910
- add_step("sq2", "exa-search", "official-domain docs source discovery", command_exa(f"{question} official docs"), next_filename("exa.json"))
911
-
912
- if recency_requirement != "none" or locale_domain_scope == "china":
913
- sub_id = f"sq{len(decomposition) + 1}"
914
- decomposition.append(
915
- _deep_subquestion(
916
- sub_id,
917
- f"{question} 的最新或中文/国内来源如何交叉验证?",
918
- "Current or China-scoped prompts use bilingual search instead of Zhipu reinforcement.",
919
- ["current_or_locale_source_discovery"],
920
- )
921
- )
922
- capability_plan.append(
923
- _deep_capability("current_or_locale_source_discovery", ["search"], "Use Chinese and English broad search for current or locale-sensitive evidence.")
924
- )
925
-
926
- if complex_query:
927
- while len(decomposition) < (2 if budget != "deep" else 4):
928
- sub_id = f"sq{len(decomposition) + 1}"
929
- if len(decomposition) == 1:
930
- sub_question = f"{question} 里有哪些主要选项、说法或路线需要分别验证?"
931
- reason = "Complex prompts need explicit comparison targets before final synthesis."
932
- caps = ["cross_validation"]
933
- elif len(decomposition) == 2:
934
- sub_question = f"{question} 的成本、风险、限制和适用边界是什么?"
935
- reason = "High-difficulty research needs downside and boundary checks."
936
- caps = ["low_noise_source_discovery", "page_evidence"]
937
- else:
938
- sub_question = f"基于已抓取证据,{question} 应该如何形成可执行结论?"
939
- reason = "A deep budget should reserve one synthesis-oriented gap check subquestion."
940
- caps = ["gap_check"]
941
- decomposition.append(_deep_subquestion(sub_id, sub_question, reason, caps))
942
- if not has_capability("cross_validation"):
943
- capability_plan.append(
944
- _deep_capability("cross_validation", ["search"], "Compare independent sources before final claims; supplemental tools depend on intent.")
945
- )
946
- if budget == "deep" and _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS):
947
- add_step("sq3", "exa-search", "low-noise evidence for tradeoffs and risks", command_exa(f"{question} risks limitations comparison"), next_filename("exa.json"))
948
-
949
- if cross_validation_need == "high":
950
- if not has_capability("cross_validation"):
951
- capability_plan.append(
952
- _deep_capability("cross_validation", ["search"], "Compare independent sources before final claims; supplemental tools depend on intent.")
953
- )
954
- target_subquestion = decomposition[-1]["id"] if decomposition else "sq1"
955
- cross_validation_tools = next((item["tools"] for item in capability_plan if item.get("capability") == "cross_validation"), [])
956
- if recency_requirement != "none" or locale_domain_scope == "china" or zh_current_intent:
957
- if "search" not in cross_validation_tools:
958
- cross_validation_tools.append("search")
959
- if not any(
960
- step["tool"] == "search" and "English-language sources" in step.get("purpose", "")
961
- for step in steps
962
- ):
963
- add_bilingual_search_steps(target_subquestion, "current or locale-specific cross-source discovery", 2)
964
- elif docs_intent:
965
- if "context7-library" not in cross_validation_tools:
966
- cross_validation_tools.extend(["context7-library", "context7-docs"])
967
- elif _contains_any(question, DEEP_EXA_DISCOVERY_KEYWORDS):
968
- if "exa-search" not in cross_validation_tools:
969
- cross_validation_tools.append("exa-search")
970
- if not any(step["tool"] == "exa-search" for step in steps):
971
- add_step(target_subquestion, "exa-search", "official-domain or low-noise cross-source discovery", command_exa(question), next_filename("exa.json"))
972
-
973
- capability_plan.append(_deep_capability("page_evidence", ["fetch"], "Fetch key URLs before claim-level conclusions."))
974
- add_step("sq1" if len(decomposition) == 1 else decomposition[-1]["id"], "fetch", "fetch key URLs before final claims", command_fetch(), next_filename("fetch.md"))
975
-
976
- for item in capability_plan:
977
- item["tools"] = [tool for tool in item["tools"] if tool in DEEP_ALLOWED_TOOLS]
978
- steps = [step for step in steps if step["tool"] in DEEP_ALLOWED_TOOLS]
979
- if budget == "quick" and len(decomposition) > 2:
980
- decomposition = decomposition[:2]
981
- if budget == "quick" and len(steps) > 4:
982
- limited_steps = steps[:4]
983
- if not any(step["tool"] == "fetch" for step in limited_steps):
984
- first_fetch = next((step for step in steps if step["tool"] == "fetch"), None)
985
- if first_fetch:
986
- first_fetch = dict(first_fetch)
987
- fetch_path = _path_join(evidence_root, "04-fetch.md")
988
- first_fetch["command"] = f"smart-search fetch {_quote_arg('<key-url>')} --format markdown --output {_quote_arg(fetch_path)}"
989
- first_fetch["output_path"] = fetch_path
990
- limited_steps = steps[:3] + [first_fetch]
991
- steps = limited_steps[:4]
992
- if budget == "quick":
993
- valid_subquestion_ids = {item["id"] for item in decomposition}
994
- fallback_subquestion_id = decomposition[-1]["id"] if decomposition else "sq1"
995
- for index, step in enumerate(steps, start=1):
996
- step["id"] = f"s{index}"
997
- if step.get("subquestion_id") not in valid_subquestion_ids:
998
- step["subquestion_id"] = fallback_subquestion_id
999
-
1000
- return {
1001
- "ok": True,
1002
- "mode": "deep_research",
1003
- "query_mode": "research",
1004
- "question": question,
1005
- "trigger_source": "explicit_cli",
1006
- "difficulty": difficulty,
1007
- "intent_signals": intent_signals,
1008
- "decomposition": decomposition,
1009
- "capability_plan": capability_plan,
1010
- "evidence_policy": "fetch_before_claim",
1011
- "preflight": {
1012
- "tool": "doctor",
1013
- "command": "smart-search doctor --format json",
1014
- "when": "configuration or provider availability is uncertain",
1015
- "executed_during_planning": False,
1016
- },
1017
- "steps": steps,
1018
- "gap_check": {
1019
- "required": True,
1020
- "rule": "fetch missing evidence for key claims or downgrade unsupported claims to unverified candidates",
1021
- "unsupported_claim_action": "downgrade_to_unverified_candidate",
1022
- },
1023
- "final_answer_policy": "cite fetched evidence, list unverified candidates, and include key commands",
1024
- "usage_boundary": {
1025
- "search": "smart-search search runs live fast/broad search immediately.",
1026
- "research": "smart-search research builds this plan internally, then runs the staged discover/fetch/synthesis workflow.",
1027
- "execution": "research executes the listed steps with existing CLI commands, then performs gap_check.",
1028
- },
1029
- "allowed_tools": sorted(DEEP_ALLOWED_TOOLS),
1030
- "evidence_dir": evidence_root,
1031
- "elapsed_ms": _elapsed_ms(start),
1032
- }
1033
-
1034
-
1035
- async def research(
1036
- query: str,
1037
- budget: str = "deep",
1038
- evidence_dir: str = "",
1039
- fallback: str = "auto",
1040
- ) -> dict[str, Any]:
1041
- start = time.time()
1042
- question = query.strip()
1043
- fallback_mode = (fallback or "auto").strip().lower()
1044
- if fallback_mode not in {"auto", "off"}:
1045
- return {
1046
- "ok": False,
1047
- "error_type": "parameter_error",
1048
- "error": f"Invalid fallback mode: {fallback_mode}",
1049
- "question": question,
1050
- "mode": "deep_research_execution",
1051
- "route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
1052
- "elapsed_ms": _elapsed_ms(start),
1053
- }
1054
-
1055
- minimum = validate_minimum_profile()
1056
- if not minimum.get("ok"):
1057
- return {
1058
- "ok": False,
1059
- "error_type": minimum.get("error_type", "config_error"),
1060
- "error": minimum.get("error", MINIMUM_PROFILE_ERROR),
1061
- "question": question,
1062
- "mode": "deep_research_execution",
1063
- "minimum_profile_ok": False,
1064
- "capability_status": minimum.get("capability_status", {}),
1065
- "final_answer": "",
1066
- "citations": [],
1067
- "evidence_items": [],
1068
- "gap_check": {
1069
- "status": "failed",
1070
- "gaps": [{"subquestion_id": "", "reason": "minimum profile is missing required capabilities"}],
1071
- },
1072
- "provider_attempts": [],
1073
- "fallback_used": False,
1074
- "degraded": True,
1075
- "route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
1076
- "evidence_dir": evidence_dir,
1077
- "elapsed_ms": _elapsed_ms(start),
1078
- }
1079
-
1080
- plan = build_deep_research_plan(question, budget=_deep_budget(budget or "deep"), evidence_dir=evidence_dir)
1081
- evidence_root = plan.get("evidence_dir") or _default_evidence_dir(question)
1082
- routes = _research_capability_routes(question, plan, fallback_mode)
1083
- provider_attempts: list[dict[str, Any]] = []
1084
- discovery_sources: list[dict[str, Any]] = []
1085
- evidence_items: list[dict[str, Any]] = []
1086
- stage_results: list[dict[str, Any]] = []
1087
- gaps: list[dict[str, Any]] = []
1088
-
1089
- _write_research_artifact(evidence_root, "00-plan.json", plan)
1090
-
1091
- urls = _extract_urls(question)
1092
- fetch_order = routes["capabilities"]["web_fetch"]["providers"]
1093
- if urls:
1094
- for index, url in enumerate(urls, 1):
1095
- fetch_result, attempts = await _run_web_fetch_fallback(url, fallback=fallback_mode, preferred_order=fetch_order)
1096
- provider_attempts.extend(attempts)
1097
- stage_results.append({"stage": "known_url_fetch", "url": url, "ok": bool(fetch_result), "provider_attempts": attempts})
1098
- if fetch_result:
1099
- item = _research_evidence_item(
1100
- url=fetch_result["url"],
1101
- provider=fetch_result["provider"],
1102
- title=fetch_result["url"],
1103
- content=fetch_result["content"],
1104
- subquestion_id="sq1",
1105
- )
1106
- evidence_items.append(item)
1107
- _write_research_artifact(evidence_root, f"{index:02d}-fetch-{fetch_result['provider']}.md", fetch_result["content"])
1108
- else:
1109
- gaps.append({"subquestion_id": "sq1", "reason": f"failed to fetch known URL: {url}", "url": url})
1110
-
1111
- signals = routes["signals"]
1112
- if signals["docs_api_intent"]:
1113
- docs_providers = routes["capabilities"]["docs_search"]["providers"]
1114
- selected_docs_providers = docs_providers[:1] if fallback_mode == "off" else docs_providers
1115
- if not selected_docs_providers:
1116
- gaps.append({"subquestion_id": "sq2", "reason": "no configured docs_search provider for docs/API evidence"})
1117
- for provider in selected_docs_providers:
1118
- step_start = time.time()
1119
- if provider == "context7":
1120
- data = await context7_library(question, question)
1121
- if data.get("ok") and data.get("results"):
1122
- provider_attempts.append(_attempt("docs_search", "context7", "ok", step_start, result_count=len(data.get("results") or [])))
1123
- stage_results.append({"stage": "docs_discovery", "provider": "context7", "ok": True, "result_count": len(data.get("results") or [])})
1124
- library_id = (data.get("results") or [{}])[0].get("id", "")
1125
- if library_id:
1126
- docs_start = time.time()
1127
- docs_data = await context7_docs(library_id, question)
1128
- if docs_data.get("ok") and docs_data.get("content"):
1129
- provider_attempts.append(_attempt("docs_search", "context7", "ok", docs_start, result_count=1))
1130
- item = _research_evidence_item(
1131
- url=f"context7:{library_id}",
1132
- provider="context7",
1133
- title=library_id,
1134
- content=docs_data.get("content", ""),
1135
- source_type="docs",
1136
- subquestion_id="sq2",
1137
- )
1138
- evidence_items.append(item)
1139
- _write_research_artifact(evidence_root, "docs-context7.md", docs_data.get("content", ""))
1140
- break
1141
- docs_status = "error" if docs_data.get("error_type") else "empty"
1142
- provider_attempts.append(_attempt("docs_search", "context7", docs_status, docs_start, error_type=docs_data.get("error_type", ""), error=docs_data.get("error", "")))
1143
- if fallback_mode == "off":
1144
- break
1145
- continue
1146
- status = "error" if data.get("error_type") in {"auth_error", "timeout", "network_error", "runtime_error"} else "empty"
1147
- provider_attempts.append(_attempt("docs_search", "context7", status, step_start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1148
- elif provider == "exa":
1149
- data = await exa_search(question, num_results=5, include_highlights=True)
1150
- if data.get("ok"):
1151
- sources = _normalize_source_results(data.get("results"), "exa")
1152
- if sources:
1153
- provider_attempts.append(_attempt("docs_search", "exa", "ok", step_start, result_count=len(sources)))
1154
- discovery_sources.extend(sources)
1155
- stage_results.append({"stage": "docs_discovery", "provider": "exa", "ok": True, "result_count": len(sources)})
1156
- break
1157
- provider_attempts.append(_attempt("docs_search", "exa", "error" if data.get("error_type") else "empty", step_start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1158
-
1159
- should_run_web_discovery = (
1160
- signals["current_or_locale_intent"]
1161
- or signals["cross_validation_need"] == "high"
1162
- or (not evidence_items and not discovery_sources)
1163
- ) and not (urls and fallback_mode == "off")
1164
- if should_run_web_discovery:
1165
- web_provider_order = routes["capabilities"]["web_search"]["providers"]
1166
- if web_provider_order:
1167
- web_sources, attempts = await _run_bilingual_web_search(
1168
- question,
1169
- count=5,
1170
- providers=",".join(web_provider_order),
1171
- fallback=fallback_mode,
1172
- )
1173
- provider_attempts.extend(attempts)
1174
- discovery_sources.extend(web_sources)
1175
- stage_results.append({"stage": "web_discovery", "ok": bool(web_sources), "result_count": len(web_sources), "provider_attempts": attempts})
1176
- else:
1177
- gaps.append({"subquestion_id": "", "reason": "no configured web_search provider for discovery"})
1178
-
1179
- exa_in_selected_docs_route = "exa" in routes["capabilities"]["docs_search"]["providers"]
1180
- if (
1181
- fallback_mode != "off"
1182
- and signals["official_low_noise_intent"]
1183
- and exa_in_selected_docs_route
1184
- and not any(source.get("provider") == "exa" for source in discovery_sources)
1185
- ):
1186
- exa_start = time.time()
1187
- data = await exa_search(question, num_results=5, include_highlights=True)
1188
- if data.get("ok"):
1189
- sources = _normalize_source_results(data.get("results"), "exa")
1190
- if sources:
1191
- provider_attempts.append(_attempt("docs_search", "exa", "ok", exa_start, result_count=len(sources)))
1192
- discovery_sources.extend(sources)
1193
- else:
1194
- provider_attempts.append(_attempt("docs_search", "exa", "error", exa_start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1195
-
1196
- candidates = _select_candidate_urls(discovery_sources, limit=6)
1197
- fetched_urls = {item.get("url") for item in evidence_items}
1198
- no_new_evidence = True
1199
- for index, candidate in enumerate(candidates, 1):
1200
- url = candidate.get("url", "")
1201
- if not url or url in fetched_urls:
1202
- continue
1203
- order = _research_fetch_order(question, url)
1204
- fetch_result, attempts = await _run_web_fetch_fallback(url, fallback=fallback_mode, preferred_order=order)
1205
- provider_attempts.extend(attempts)
1206
- stage_results.append({"stage": "candidate_fetch", "url": url, "ok": bool(fetch_result), "provider_attempts": attempts})
1207
- if fetch_result:
1208
- no_new_evidence = False
1209
- fetched_urls.add(url)
1210
- content = fetch_result.get("content", "")
1211
- item = _research_evidence_item(
1212
- url=fetch_result["url"],
1213
- provider=fetch_result["provider"],
1214
- title=candidate.get("title") or fetch_result["url"],
1215
- content=content,
1216
- subquestion_id=candidate.get("subquestion_id", ""),
1217
- )
1218
- evidence_items.append(item)
1219
- _write_research_artifact(evidence_root, f"fetch-{index:02d}-{fetch_result['provider']}.md", content)
1220
- elif fallback_mode == "off":
1221
- gaps.append({"subquestion_id": "", "reason": f"fetch failed with fallback off: {url}", "url": url})
1222
-
1223
- if not evidence_items:
1224
- gaps.append({"subquestion_id": "", "reason": "no fetched/read evidence items were produced"})
1225
- elif no_new_evidence and not urls and candidates:
1226
- gaps.append({"subquestion_id": "", "reason": "discovery produced candidates but no new fetch evidence converged"})
1227
-
1228
- covered = bool(evidence_items)
1229
- gap_status = "closed" if covered and not gaps else ("degraded" if evidence_items else "failed")
1230
- citations = _citation_items(evidence_items)
1231
- final_answer = _evidence_only_synthesis(question, evidence_items, gaps)
1232
- result = {
1233
- "ok": bool(evidence_items),
1234
- "error_type": "" if evidence_items else "evidence_error",
1235
- "error": "" if evidence_items else "research could not obtain fetched evidence",
1236
- "mode": "deep_research_execution",
1237
- "query_mode": "research",
1238
- "question": question,
1239
- "budget": _deep_budget(budget or "deep"),
1240
- "research_plan": plan,
1241
- "routing_decision": routes,
1242
- "stage_results": stage_results,
1243
- "discovery_sources": discovery_sources,
1244
- "final_answer": final_answer,
1245
- "content": final_answer,
1246
- "citations": citations,
1247
- "evidence_items": evidence_items,
1248
- "gap_check": {
1249
- "status": gap_status,
1250
- "gaps": gaps,
1251
- "stop_reason": "evidence_converged" if gap_status == "closed" else ("degraded_with_gaps" if evidence_items else "provider_exhausted"),
1252
- },
1253
- "provider_attempts": provider_attempts,
1254
- "providers_used": _provider_names_from_attempts(provider_attempts),
1255
- "fallback_used": _fallback_used(provider_attempts),
1256
- "degraded": bool(gaps),
1257
- "route_policy_version": RESEARCH_ROUTE_POLICY_VERSION,
1258
- "evidence_dir": evidence_root,
1259
- "minimum_profile_ok": minimum.get("ok", False),
1260
- "capability_status": minimum.get("capability_status", {}),
1261
- "elapsed_ms": _elapsed_ms(start),
1262
- }
1263
- _write_research_artifact(evidence_root, "summary.json", result)
1264
- return result
1265
-
1266
-
1267
- def get_capability_status() -> dict[str, Any]:
1268
- main_configured = _configured_main_search_provider_ids()
1269
- status = {
1270
- "main_search": {
1271
- "configured": main_configured,
1272
- "fallback_chain": MAIN_SEARCH_FALLBACK_CHAIN,
1273
- "ok": bool(main_configured),
1274
- },
1275
- "web_search": {
1276
- "configured": [
1277
- name
1278
- for name, enabled in [
1279
- ("tavily", bool(config.tavily_api_key)),
1280
- ("firecrawl", bool(config.firecrawl_api_key)),
1281
- ]
1282
- if enabled
1283
- ],
1284
- "fallback_chain": ["tavily", "firecrawl"],
1285
- "deprecated_configured": ["zhipu"] if config.zhipu_api_key else [],
1286
- },
1287
- "docs_search": {
1288
- "configured": [
1289
- name
1290
- for name, enabled in [
1291
- ("context7", bool(config.context7_api_key)),
1292
- ("exa", bool(config.exa_api_key)),
1293
- ]
1294
- if enabled
1295
- ],
1296
- "fallback_chain": ["context7", "exa"],
1297
- },
1298
- "web_fetch": {
1299
- "configured": [
1300
- name
1301
- for name, enabled in [
1302
- ("tavily", bool(config.tavily_api_key)),
1303
- ("jina", bool(config.jina_api_key)),
1304
- ("firecrawl", bool(config.firecrawl_api_key)),
1305
- ]
1306
- if enabled
1307
- ],
1308
- "fallback_chain": ["tavily", "jina", "firecrawl"],
1309
- },
1310
- }
1311
- for capability in ("web_search", "docs_search", "web_fetch"):
1312
- status[capability]["ok"] = bool(status[capability]["configured"])
1313
- return status
1314
-
1315
-
1316
- def _minimum_profile_result(profile: str, capability_status: dict[str, Any]) -> dict[str, Any]:
1317
- required = [] if profile == "off" else ["main_search", "docs_search", "web_fetch"]
1318
- missing = [capability for capability in required if not capability_status.get(capability, {}).get("ok")]
1319
- return {
1320
- "ok": not missing,
1321
- "error_type": "config_error" if missing else "",
1322
- "error": f"{MINIMUM_PROFILE_ERROR} 缺失能力: {', '.join(missing)}" if missing else "",
1323
- "profile": profile,
1324
- "required": required,
1325
- "missing": missing,
1326
- "capability_status": capability_status,
1327
- }
1328
-
1329
-
1330
- def validate_minimum_profile() -> dict[str, Any]:
1331
- try:
1332
- profile = config.minimum_profile
1333
- except ValueError as e:
1334
- return {"ok": False, "error_type": "parameter_error", "error": str(e), "missing": []}
1335
- return _minimum_profile_result(profile, get_capability_status())
1336
-
1337
-
1338
- def _parse_provider_filter(providers: str = "auto") -> set[str] | None:
1339
- if not providers or providers.strip().lower() == "auto":
1340
- return None
1341
- return {item.strip().lower() for item in providers.split(",") if item.strip()}
1342
-
1343
-
1344
- def _provider_allowed(provider_id: str, provider_filter: set[str] | None) -> bool:
1345
- if provider_filter is None:
1346
- return True
1347
- aliases = MAIN_SEARCH_PROVIDER_ALIASES.get(provider_id, {provider_id})
1348
- return bool(provider_filter.intersection(aliases))
1349
-
1350
-
1351
- def _configured_main_search_provider_ids() -> list[str]:
1352
- configured: set[str] = set()
1353
-
1354
- if config.openai_compatible_api_url and config.openai_compatible_api_key:
1355
- configured.add("openai-compatible")
1356
-
1357
- return [provider for provider in MAIN_SEARCH_FALLBACK_CHAIN if provider in configured]
1358
-
1359
-
1360
- def _main_search_provider_configs(model_override: str = "", providers: str = "auto") -> list[dict[str, Any]]:
1361
- provider_filter = _parse_provider_filter(providers)
1362
- by_provider: dict[str, dict[str, Any]] = {}
1363
-
1364
- if config.openai_compatible_api_url and config.openai_compatible_api_key:
1365
- by_provider["openai-compatible"] = {
1366
- "provider": "openai-compatible",
1367
- "mode": "chat-completions",
1368
- "api_url": config.openai_compatible_api_url,
1369
- "api_key": config.openai_compatible_api_key,
1370
- "model": model_override or config.openai_compatible_model,
1371
- "stream": config.openai_compatible_stream,
1372
- "tools": [],
1373
- "source": "OPENAI_COMPATIBLE_*",
1374
- }
1375
-
1376
- return [
1377
- by_provider[provider]
1378
- for provider in MAIN_SEARCH_FALLBACK_CHAIN
1379
- if provider in by_provider and _provider_allowed(provider, provider_filter)
1380
- ]
1381
-
1382
-
1383
- def _main_search_providers(provider_configs: list[dict[str, Any]], fallback: str) -> list[Any]:
1384
- selected = provider_configs if fallback != "off" else provider_configs[:1]
1385
- providers: list[Any] = []
1386
- for provider_config in selected:
1387
- providers.append(
1388
- OpenAICompatibleSearchProvider(
1389
- provider_config["api_url"],
1390
- provider_config["api_key"],
1391
- provider_config["model"],
1392
- provider_config.get("stream", False),
1393
- )
1394
- )
1395
- return providers
1396
-
1397
-
1398
- async def fetch_available_models(api_url: str, api_key: str) -> list[str]:
1399
- models_url = f"{api_url.rstrip('/')}/models"
1400
- async with httpx.AsyncClient(timeout=10.0) as client:
1401
- response = await client.get(
1402
- models_url,
1403
- headers={
1404
- "Authorization": f"Bearer {api_key}",
1405
- "Content-Type": "application/json",
1406
- },
1407
- )
1408
- response.raise_for_status()
1409
- data = response.json()
1410
-
1411
- models: list[str] = []
1412
- for item in (data or {}).get("data", []) or []:
1413
- if isinstance(item, dict) and isinstance(item.get("id"), str):
1414
- models.append(item["id"])
1415
- return models
1416
-
1417
-
1418
- async def get_available_models_cached(api_url: str, api_key: str) -> list[str]:
1419
- key = (api_url, api_key)
1420
- async with _AVAILABLE_MODELS_LOCK:
1421
- if key in _AVAILABLE_MODELS_CACHE:
1422
- return _AVAILABLE_MODELS_CACHE[key]
1423
-
1424
- try:
1425
- models = await fetch_available_models(api_url, api_key)
1426
- except Exception:
1427
- models = []
1428
-
1429
- async with _AVAILABLE_MODELS_LOCK:
1430
- _AVAILABLE_MODELS_CACHE[key] = models
1431
- return models
1432
-
1433
-
1434
- def extra_results_to_sources(
1435
- tavily_results: list[dict] | None,
1436
- firecrawl_results: list[dict] | None,
1437
- ) -> list[dict]:
1438
- sources: list[dict] = []
1439
- seen: set[str] = set()
1440
-
1441
- if firecrawl_results:
1442
- for r in firecrawl_results:
1443
- url = (r.get("url") or "").strip()
1444
- if not url or url in seen:
1445
- continue
1446
- seen.add(url)
1447
- item: dict = {"url": url, "provider": "firecrawl"}
1448
- title = (r.get("title") or "").strip()
1449
- if title:
1450
- item["title"] = title
1451
- desc = (r.get("description") or "").strip()
1452
- if desc:
1453
- item["description"] = desc
1454
- sources.append(item)
1455
-
1456
- if tavily_results:
1457
- for r in tavily_results:
1458
- url = (r.get("url") or "").strip()
1459
- if not url or url in seen:
1460
- continue
1461
- seen.add(url)
1462
- item = {"url": url, "provider": "tavily"}
1463
- title = (r.get("title") or "").strip()
1464
- if title:
1465
- item["title"] = title
1466
- content = (r.get("content") or "").strip()
1467
- if content:
1468
- item["description"] = content
1469
- sources.append(item)
1470
-
1471
- return sources
1472
-
1473
-
1474
- async def _run_web_fetch_fallback(
1475
- url: str,
1476
- fallback: str = "auto",
1477
- preferred_order: list[str] | None = None,
1478
- ) -> tuple[dict[str, Any] | None, list[dict]]:
1479
- attempts: list[dict] = []
1480
- providers = []
1481
- if config.tavily_api_key:
1482
- providers.append("tavily")
1483
- if config.jina_api_key:
1484
- providers.append("jina")
1485
- if config.firecrawl_api_key:
1486
- providers.append("firecrawl")
1487
- if preferred_order:
1488
- allowed = {provider for provider in providers}
1489
- ordered = [provider for provider in preferred_order if provider in allowed]
1490
- ordered.extend(provider for provider in providers if provider not in ordered)
1491
- providers = ordered
1492
- if fallback == "off":
1493
- providers = providers[:1]
1494
-
1495
- for provider in providers:
1496
- start = time.time()
1497
- try:
1498
- if provider == "tavily":
1499
- content = await call_tavily_extract(url)
1500
- elif provider == "jina":
1501
- data = await jina_fetch(url)
1502
- content = data.get("content") if data.get("ok") else None
1503
- if not data.get("ok"):
1504
- status = "error" if data.get("error_type") in {"auth_error", "config_error", "parameter_error", "quality_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
1505
- attempts.append(_attempt("web_fetch", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1506
- continue
1507
- else:
1508
- content = await call_firecrawl_scrape(url)
1509
- if content and content.strip():
1510
- attempts.append(_attempt("web_fetch", provider, "ok", start, result_count=1))
1511
- return {
1512
- "ok": True,
1513
- "url": url,
1514
- "provider": provider,
1515
- "content": content,
1516
- }, attempts
1517
- attempts.append(_attempt("web_fetch", provider, "empty", start))
1518
- except Exception as e:
1519
- attempts.append(_attempt("web_fetch", provider, "error", start, error_type="runtime_error", error=str(e)))
1520
- return None, attempts
1521
-
1522
-
1523
- async def _run_web_search_fallback(
1524
- query: str,
1525
- count: int = 5,
1526
- providers: str = "auto",
1527
- fallback: str = "auto",
1528
- ) -> tuple[list[dict], list[dict]]:
1529
- provider_filter = _parse_provider_filter(providers)
1530
- attempts: list[dict] = []
1531
- configured: list[str] = []
1532
- if config.tavily_api_key:
1533
- configured.append("tavily")
1534
- if config.firecrawl_api_key:
1535
- configured.append("firecrawl")
1536
- if provider_filter is not None and "zhipu" in provider_filter and config.zhipu_api_key:
1537
- configured.append("zhipu")
1538
- if provider_filter is not None:
1539
- configured = [p for p in configured if p in provider_filter]
1540
- if fallback == "off":
1541
- configured = configured[:1]
1542
-
1543
- for provider in configured:
1544
- start = time.time()
1545
- try:
1546
- if provider == "zhipu":
1547
- data = await zhipu_search(query, count=count)
1548
- if data.get("ok"):
1549
- sources = _normalize_source_results(data.get("results"), "zhipu")
1550
- if sources:
1551
- attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
1552
- return sources, attempts
1553
- status = "error" if data.get("error_type") in {"rate_limited", "auth_error", "timeout", "network_error", "runtime_error"} else "empty"
1554
- attempts.append(_attempt("web_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1555
- elif provider == "tavily":
1556
- results = await call_tavily_search(query, count)
1557
- sources = _normalize_source_results(results, "tavily")
1558
- if sources:
1559
- attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
1560
- return sources, attempts
1561
- attempts.append(_attempt("web_search", provider, "empty", start))
1562
- elif provider == "firecrawl":
1563
- results = await call_firecrawl_search(query, count)
1564
- sources = _normalize_source_results(results, "firecrawl")
1565
- if sources:
1566
- attempts.append(_attempt("web_search", provider, "ok", start, result_count=len(sources)))
1567
- return sources, attempts
1568
- attempts.append(_attempt("web_search", provider, "empty", start))
1569
- except Exception as e:
1570
- attempts.append(_attempt("web_search", provider, "error", start, error_type="runtime_error", error=str(e)))
1571
- return [], attempts
1572
-
1573
-
1574
- async def _run_bilingual_web_search(
1575
- query: str,
1576
- count: int = 5,
1577
- providers: str = "auto",
1578
- fallback: str = "auto",
1579
- ) -> tuple[list[dict], list[dict]]:
1580
- all_sources: list[dict] = []
1581
- all_attempts: list[dict] = []
1582
- for variant in _bilingual_search_queries(query):
1583
- sources, attempts = await _run_web_search_fallback(
1584
- variant["query"],
1585
- count=count,
1586
- providers=providers,
1587
- fallback=fallback,
1588
- )
1589
- for source in sources:
1590
- source.setdefault("query_locale", variant["locale"])
1591
- all_sources = merge_sources(all_sources, sources)
1592
- all_attempts.extend(attempts)
1593
- return all_sources, all_attempts
1594
-
1595
-
1596
- async def _run_docs_search_fallback(
1597
- query: str,
1598
- providers: str = "auto",
1599
- fallback: str = "auto",
1600
- ) -> tuple[list[dict], list[dict]]:
1601
- provider_filter = _parse_provider_filter(providers)
1602
- attempts: list[dict] = []
1603
- configured: list[str] = []
1604
- if config.context7_api_key:
1605
- configured.append("context7")
1606
- if config.exa_api_key:
1607
- configured.append("exa")
1608
- if provider_filter is not None:
1609
- configured = [p for p in configured if p in provider_filter]
1610
- if fallback == "off":
1611
- configured = configured[:1]
1612
-
1613
- for provider in configured:
1614
- start = time.time()
1615
- try:
1616
- if provider == "exa":
1617
- data = await exa_search(query, num_results=5, include_highlights=True)
1618
- if data.get("ok"):
1619
- sources = _normalize_source_results(data.get("results"), "exa")
1620
- if sources:
1621
- attempts.append(_attempt("docs_search", provider, "ok", start, result_count=len(sources)))
1622
- return sources, attempts
1623
- status = "error" if data.get("error_type") in {"auth_error", "parameter_error", "rate_limited", "timeout", "network_error", "runtime_error"} else "empty"
1624
- attempts.append(_attempt("docs_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1625
- elif provider == "context7":
1626
- data = await context7_library(query, query)
1627
- if data.get("ok"):
1628
- sources = [
1629
- {
1630
- "url": f"context7:{item.get('id')}",
1631
- "title": item.get("title") or item.get("id") or "Context7",
1632
- "description": item.get("description") or "",
1633
- "provider": "context7",
1634
- }
1635
- for item in data.get("results", [])
1636
- if item.get("id")
1637
- ]
1638
- if sources:
1639
- attempts.append(_attempt("docs_search", provider, "ok", start, result_count=len(sources)))
1640
- return sources, attempts
1641
- status = "error" if data.get("error_type") in {"auth_error", "timeout", "network_error", "runtime_error"} else "empty"
1642
- attempts.append(_attempt("docs_search", provider, status, start, error_type=data.get("error_type", ""), error=data.get("error", "")))
1643
- except Exception as e:
1644
- attempts.append(_attempt("docs_search", provider, "error", start, error_type="runtime_error", error=str(e)))
1645
- return [], attempts
1646
-
1647
-
1648
- async def call_tavily_extract(url: str) -> str | None:
1649
- api_key = config.tavily_api_key
1650
- if not api_key:
1651
- return None
1652
- endpoint = f"{config.tavily_api_url.rstrip('/')}/extract"
1653
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
1654
- body = {"urls": [url], "format": "markdown"}
1655
- try:
1656
- async with httpx.AsyncClient(timeout=60.0) as client:
1657
- response = await client.post(endpoint, headers=headers, json=body)
1658
- response.raise_for_status()
1659
- data = response.json()
1660
- if data.get("results") and len(data["results"]) > 0:
1661
- content = data["results"][0].get("raw_content", "")
1662
- return content if content and content.strip() else None
1663
- return None
1664
- except Exception:
1665
- return None
1666
-
1667
-
1668
- async def call_tavily_search(query: str, max_results: int = 6) -> list[dict] | None:
1669
- api_key = config.tavily_api_key
1670
- if not api_key:
1671
- return None
1672
- endpoint = f"{config.tavily_api_url.rstrip('/')}/search"
1673
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
1674
- body = {
1675
- "query": query,
1676
- "max_results": max_results,
1677
- "search_depth": "advanced",
1678
- "include_raw_content": False,
1679
- "include_answer": False,
1680
- }
1681
- try:
1682
- async with httpx.AsyncClient(timeout=90.0) as client:
1683
- response = await client.post(endpoint, headers=headers, json=body)
1684
- response.raise_for_status()
1685
- data = response.json()
1686
- results = data.get("results", [])
1687
- return [
1688
- {
1689
- "title": r.get("title", ""),
1690
- "url": r.get("url", ""),
1691
- "content": r.get("content", ""),
1692
- "score": r.get("score", 0),
1693
- }
1694
- for r in results
1695
- ] if results else None
1696
- except Exception:
1697
- return None
1698
-
1699
-
1700
- async def call_firecrawl_search(query: str, limit: int = 14) -> list[dict] | None:
1701
- api_key = config.firecrawl_api_key
1702
- if not api_key:
1703
- return None
1704
- endpoint = f"{config.firecrawl_api_url.rstrip('/')}/search"
1705
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
1706
- body = {"query": query, "limit": limit}
1707
- try:
1708
- async with httpx.AsyncClient(timeout=90.0) as client:
1709
- response = await client.post(endpoint, headers=headers, json=body)
1710
- response.raise_for_status()
1711
- data = response.json()
1712
- results = data.get("data", {}).get("web", [])
1713
- return [
1714
- {
1715
- "title": r.get("title", ""),
1716
- "url": r.get("url", ""),
1717
- "description": r.get("description", ""),
1718
- }
1719
- for r in results
1720
- ] if results else None
1721
- except Exception:
1722
- return None
1723
-
1724
-
1725
- async def call_firecrawl_scrape(url: str, ctx=None) -> str | None:
1726
- api_key = config.firecrawl_api_key
1727
- if not api_key:
1728
- return None
1729
- endpoint = f"{config.firecrawl_api_url.rstrip('/')}/scrape"
1730
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
1731
- for attempt in range(config.retry_max_attempts):
1732
- body = {
1733
- "url": url,
1734
- "formats": ["markdown"],
1735
- "timeout": 60000,
1736
- "waitFor": (attempt + 1) * 1500,
1737
- }
1738
- try:
1739
- async with httpx.AsyncClient(timeout=90.0) as client:
1740
- response = await client.post(endpoint, headers=headers, json=body)
1741
- response.raise_for_status()
1742
- data = response.json()
1743
- markdown = data.get("data", {}).get("markdown", "")
1744
- if markdown and markdown.strip():
1745
- return markdown
1746
- await log_info(ctx, f"Firecrawl: markdown为空, 重试 {attempt + 1}/{config.retry_max_attempts}", config.debug_enabled)
1747
- except Exception as e:
1748
- await log_info(ctx, f"Firecrawl error: {e}", config.debug_enabled)
1749
- return None
1750
- return None
1751
-
1752
-
1753
- async def call_jina_reader(url: str) -> dict[str, Any]:
1754
- raw = await JinaReaderProvider(
1755
- config.jina_reader_api_url,
1756
- config.jina_api_key,
1757
- config.jina_respond_with,
1758
- config.jina_timeout,
1759
- ).fetch(url)
1760
- return await _decode_provider_json(raw, provider="jina")
1761
-
1762
-
1763
- async def call_tavily_map(
1764
- url: str,
1765
- instructions: str = "",
1766
- max_depth: int = 1,
1767
- max_breadth: int = 20,
1768
- limit: int = 50,
1769
- timeout: int = 150,
1770
- ) -> dict[str, Any]:
1771
- api_key = config.tavily_api_key
1772
- if not api_key:
1773
- return {
1774
- "ok": False,
1775
- "error_type": "config_error",
1776
- "error": "TAVILY_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set TAVILY_API_KEY <key>`。",
1777
- }
1778
-
1779
- endpoint = f"{config.tavily_api_url.rstrip('/')}/map"
1780
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
1781
- body = {"url": url, "max_depth": max_depth, "max_breadth": max_breadth, "limit": limit, "timeout": timeout}
1782
- if instructions:
1783
- body["instructions"] = instructions
1784
- try:
1785
- async with httpx.AsyncClient(timeout=float(timeout + 10)) as client:
1786
- response = await client.post(endpoint, headers=headers, json=body)
1787
- response.raise_for_status()
1788
- data = response.json()
1789
- return {
1790
- "ok": True,
1791
- "base_url": data.get("base_url", ""),
1792
- "results": data.get("results", []),
1793
- "response_time": data.get("response_time", 0),
1794
- }
1795
- except httpx.TimeoutException:
1796
- return {"ok": False, "error_type": "network_error", "error": f"映射超时: 请求超过{timeout}秒"}
1797
- except httpx.HTTPStatusError as e:
1798
- return {"ok": False, "error_type": "network_error", "error": f"HTTP错误: {e.response.status_code} - {e.response.text[:200]}"}
1799
- except Exception as e:
1800
- return {"ok": False, "error_type": "network_error", "error": f"映射错误: {str(e)}"}
1801
-
1802
-
1803
- async def search(
1804
- query: str,
1805
- platform: str = "",
1806
- model: str = "",
1807
- extra_sources: int = 0,
1808
- validation: str = "",
1809
- fallback: str = "",
1810
- providers: str = "auto",
1811
- stream: bool | None = None,
1812
- ) -> dict[str, Any]:
1813
- start = time.time()
1814
- session_id = new_session_id()
1815
- try:
1816
- validation_level = (validation or config.validation_level).strip().lower()
1817
- fallback_mode = (fallback or config.fallback_mode).strip().lower()
1818
- if validation_level not in config._ALLOWED_VALIDATION_LEVELS:
1819
- raise ValueError(f"Invalid validation level: {validation_level}")
1820
- if fallback_mode not in config._ALLOWED_FALLBACK_MODES:
1821
- raise ValueError(f"Invalid fallback mode: {fallback_mode}")
1822
- except ValueError as e:
1823
- return _empty_search_result(start, session_id, query, "parameter_error", str(e))
1824
-
1825
- minimum = validate_minimum_profile()
1826
- if not minimum.get("ok"):
1827
- return _empty_search_result(
1828
- start,
1829
- session_id,
1830
- query,
1831
- minimum.get("error_type", "config_error"),
1832
- minimum.get("error", MINIMUM_PROFILE_ERROR),
1833
- extra={
1834
- "capability_status": minimum.get("capability_status", {}),
1835
- "minimum_profile_ok": False,
1836
- "validation_level": validation_level,
1837
- },
1838
- )
1839
-
1840
- try:
1841
- main_provider_configs = _main_search_provider_configs(model_override=model, providers=providers)
1842
- except ValueError as e:
1843
- return _empty_search_result(start, session_id, query, "parameter_error", str(e), extra={"validation_level": validation_level})
1844
-
1845
- if not main_provider_configs:
1846
- return _empty_search_result(
1847
- start,
1848
- session_id,
1849
- query,
1850
- "config_error",
1851
- "No configured main_search provider matches --providers.",
1852
- extra={
1853
- "validation_level": validation_level,
1854
- "capability_status": minimum.get("capability_status", {}),
1855
- "minimum_profile_ok": minimum.get("ok", False),
1856
- },
1857
- )
1858
-
1859
- primary_api_mode = main_provider_configs[0]["mode"]
1860
- if stream is not None:
1861
- for provider_config in main_provider_configs:
1862
- if provider_config["provider"] == "openai-compatible":
1863
- provider_config["stream"] = stream
1864
-
1865
- has_tavily = bool(config.tavily_api_key)
1866
- has_firecrawl = bool(config.firecrawl_api_key)
1867
- tavily_count = 0
1868
- firecrawl_count = 0
1869
- if extra_sources > 0:
1870
- if has_tavily and has_firecrawl:
1871
- tavily_count = max(1, round(extra_sources * 0.6))
1872
- firecrawl_count = extra_sources - tavily_count
1873
- elif has_tavily:
1874
- tavily_count = extra_sources
1875
- elif has_firecrawl:
1876
- firecrawl_count = extra_sources
1877
-
1878
- docs_intent = _is_docs_intent(query)
1879
- zh_current_intent = _is_zh_current_intent(query)
1880
- bilingual_web_search = True
1881
- web_current_intent = zh_current_intent
1882
- fetch_urls = _extract_urls(query)
1883
- fetch_intent = bool(fetch_urls) or _is_fetch_intent(query)
1884
- supplemental_paths: list[str] = []
1885
- if docs_intent:
1886
- supplemental_paths.append("docs_search")
1887
- if bilingual_web_search:
1888
- supplemental_paths.append("web_search")
1889
- if fetch_intent:
1890
- supplemental_paths.append("web_fetch")
1891
- selected_main_provider_configs = main_provider_configs if fallback_mode != "off" else main_provider_configs[:1]
1892
- routing_decision = {
1893
- "docs_intent": docs_intent,
1894
- "zh_current_intent": zh_current_intent,
1895
- "web_current_intent": web_current_intent,
1896
- "bilingual_web_search": bilingual_web_search,
1897
- "bilingual_query_locales": [item["locale"] for item in _bilingual_search_queries(query)],
1898
- "fetch_intent": fetch_intent,
1899
- "supplemental_paths": supplemental_paths,
1900
- "validation_level": validation_level,
1901
- "fallback_mode": fallback_mode,
1902
- "providers": providers,
1903
- "main_search_chain": [item["provider"] for item in selected_main_provider_configs],
1904
- "openai_compatible_stream": next((bool(item.get("stream")) for item in selected_main_provider_configs if item["provider"] == "openai-compatible"), False),
1905
- }
1906
-
1907
- provider_attempts: list[dict] = []
1908
- main_providers = _main_search_providers(main_provider_configs, fallback_mode)
1909
- primary_start = time.time()
1910
- primary_result = None
1911
- successful_main_config: dict[str, Any] | None = None
1912
- last_primary_error: dict[str, Any] | None = None
1913
- for provider_config, search_provider in zip(selected_main_provider_configs, main_providers):
1914
- primary_start = time.time()
1915
- try:
1916
- candidate_result = await search_provider.search(query, platform)
1917
- if candidate_result:
1918
- primary_result = candidate_result
1919
- successful_main_config = provider_config
1920
- provider_attempts.append(_attempt("main_search", search_provider.get_provider_name(), "ok", primary_start, result_count=1))
1921
- break
1922
- last_primary_error = _primary_search_error_result(
1923
- start,
1924
- session_id,
1925
- query,
1926
- provider_config["mode"],
1927
- "network_error",
1928
- f"{search_provider.get_provider_name()} 返回空结果",
1929
- )
1930
- provider_attempts.append(_attempt("main_search", search_provider.get_provider_name(), "empty", primary_start))
1931
- except Exception as e:
1932
- error_result = _primary_search_exception_result(start, session_id, query, provider_config["mode"], search_provider.get_provider_name(), e)
1933
- last_primary_error = error_result
1934
- provider_attempts.append(
1935
- _attempt(
1936
- "main_search",
1937
- search_provider.get_provider_name(),
1938
- "error",
1939
- primary_start,
1940
- error_type=error_result["error_type"],
1941
- error=error_result["error"],
1942
- )
1943
- )
1944
- if primary_result is None:
1945
- result = last_primary_error or _primary_search_error_result(start, session_id, query, primary_api_mode, "network_error", "搜索失败或无结果")
1946
- result["provider_attempts"] = provider_attempts
1947
- result["providers_used"] = _provider_names_from_attempts(provider_attempts)
1948
- result["fallback_used"] = _fallback_used(provider_attempts)
1949
- result["routing_decision"] = routing_decision
1950
- result["validation_level"] = validation_level
1951
- result["minimum_profile_ok"] = minimum.get("ok", False)
1952
- result["capability_status"] = minimum.get("capability_status", {})
1953
- return result
1954
-
1955
- successful_main_config = successful_main_config or selected_main_provider_configs[0]
1956
- primary_api_mode = successful_main_config["mode"]
1957
- effective_model = successful_main_config["model"]
1958
-
1959
- coros: list[Any] = []
1960
- if tavily_count:
1961
- coros.append(call_tavily_search(query, tavily_count))
1962
- if firecrawl_count:
1963
- coros.append(call_firecrawl_search(query, firecrawl_count))
1964
-
1965
- gathered = await asyncio.gather(*coros, return_exceptions=True)
1966
- primary_result = primary_result or ""
1967
- tavily_results: list[dict] | None = None
1968
- firecrawl_results: list[dict] | None = None
1969
- idx = 0
1970
- if tavily_count:
1971
- tavily_results = None if isinstance(gathered[idx], BaseException) else gathered[idx]
1972
- idx += 1
1973
- if firecrawl_count:
1974
- firecrawl_results = None if isinstance(gathered[idx], BaseException) else gathered[idx]
1975
-
1976
- answer, primary_sources = split_answer_and_sources(primary_result)
1977
- extra_source_items = extra_results_to_sources(tavily_results, firecrawl_results)
1978
- for item_provider, results in (("tavily", tavily_results), ("firecrawl", firecrawl_results)):
1979
- if results:
1980
- provider_attempts.append(_attempt("web_search", item_provider, "ok", start, result_count=len(results)))
1981
-
1982
- supplemental_sources: list[dict] = []
1983
- if validation_level in {"balanced", "strict"}:
1984
- if docs_intent:
1985
- docs_sources, docs_attempts = await _run_docs_search_fallback(query, providers=providers, fallback=fallback_mode)
1986
- provider_attempts.extend(docs_attempts)
1987
- supplemental_sources.extend(docs_sources)
1988
- if bilingual_web_search:
1989
- web_sources, web_attempts = await _run_bilingual_web_search(query, count=max(1, extra_sources or 3), providers=providers, fallback=fallback_mode)
1990
- provider_attempts.extend(web_attempts)
1991
- supplemental_sources.extend(web_sources)
1992
- if fetch_intent:
1993
- fetch_url = fetch_urls[0] if fetch_urls else query.strip()
1994
- fetch_result, fetch_attempts = await _run_web_fetch_fallback(fetch_url, fallback=fallback_mode)
1995
- provider_attempts.extend(fetch_attempts)
1996
- if fetch_result:
1997
- supplemental_sources.append({"url": fetch_result["url"], "provider": fetch_result["provider"], "description": fetch_result["content"][:300]})
1998
-
1999
- extra_source_items = merge_sources(extra_source_items, supplemental_sources)
2000
- sources = merge_sources(primary_sources, extra_source_items)
2001
- ok = bool(answer or sources)
2002
- if validation_level == "strict" and not sources:
2003
- ok = False
2004
- return {
2005
- "ok": ok,
2006
- "error_type": "" if ok else ("evidence_error" if validation_level == "strict" else "network_error"),
2007
- "error": "" if ok else ("strict 模式证据不足" if validation_level == "strict" else "搜索失败或无结果"),
2008
- "session_id": session_id,
2009
- "query": query,
2010
- "platform": platform,
2011
- "model": effective_model,
2012
- "primary_api_mode": primary_api_mode,
2013
- "content": answer,
2014
- "sources": sources,
2015
- "sources_count": len(sources),
2016
- "primary_sources": primary_sources,
2017
- "primary_sources_count": len(primary_sources),
2018
- "extra_sources": extra_source_items,
2019
- "extra_sources_count": len(extra_source_items),
2020
- "source_warning": SOURCE_PROVENANCE_WARNING if extra_source_items else "",
2021
- "routing_decision": routing_decision,
2022
- "providers_used": _provider_names_from_attempts(provider_attempts),
2023
- "provider_attempts": provider_attempts,
2024
- "fallback_used": _fallback_used(provider_attempts),
2025
- "validation_level": validation_level,
2026
- "minimum_profile_ok": minimum.get("ok", False),
2027
- "capability_status": minimum.get("capability_status", {}),
2028
- "elapsed_ms": _elapsed_ms(start),
2029
- }
2030
-
2031
-
2032
- def _primary_search_exception_result(
2033
- start: float,
2034
- session_id: str,
2035
- query: str,
2036
- primary_api_mode: str,
2037
- provider_name: str,
2038
- exc: BaseException,
2039
- ) -> dict[str, Any]:
2040
- if isinstance(exc, httpx.TimeoutException):
2041
- return _primary_search_error_result(
2042
- start,
2043
- session_id,
2044
- query,
2045
- primary_api_mode,
2046
- "network_error",
2047
- f"{provider_name} 请求超时: {str(exc)}",
2048
- )
2049
- if isinstance(exc, httpx.HTTPStatusError):
2050
- body = exc.response.text[:300] if exc.response is not None else str(exc)
2051
- status = exc.response.status_code if exc.response is not None else "unknown"
2052
- return _primary_search_error_result(
2053
- start,
2054
- session_id,
2055
- query,
2056
- primary_api_mode,
2057
- "network_error",
2058
- f"{provider_name} HTTP {status}: {body}",
2059
- )
2060
- if isinstance(exc, httpx.RequestError):
2061
- return _primary_search_error_result(
2062
- start,
2063
- session_id,
2064
- query,
2065
- primary_api_mode,
2066
- "network_error",
2067
- f"{provider_name} 网络错误: {str(exc)}",
2068
- )
2069
- return _primary_search_error_result(
2070
- start,
2071
- session_id,
2072
- query,
2073
- primary_api_mode,
2074
- "runtime_error",
2075
- f"{provider_name} 运行错误: {str(exc)}",
2076
- )
2077
-
2078
-
2079
- def _primary_search_error_result(
2080
- start: float,
2081
- session_id: str,
2082
- query: str,
2083
- primary_api_mode: str,
2084
- error_type: str,
2085
- error: str,
2086
- ) -> dict[str, Any]:
2087
- return {
2088
- "ok": False,
2089
- "error_type": error_type,
2090
- "error": error,
2091
- "session_id": session_id,
2092
- "query": query,
2093
- "primary_api_mode": primary_api_mode,
2094
- "content": "",
2095
- "sources": [],
2096
- "sources_count": 0,
2097
- "primary_sources": [],
2098
- "primary_sources_count": 0,
2099
- "extra_sources": [],
2100
- "extra_sources_count": 0,
2101
- "source_warning": "",
2102
- "elapsed_ms": _elapsed_ms(start),
2103
- }
2104
-
2105
-
2106
- async def fetch(url: str) -> dict[str, Any]:
2107
- start = time.time()
2108
- fetch_result, attempts = await _run_web_fetch_fallback(url)
2109
- if fetch_result:
2110
- return {
2111
- **fetch_result,
2112
- "provider_attempts": attempts,
2113
- "fallback_used": _fallback_used(attempts),
2114
- "elapsed_ms": _elapsed_ms(start),
2115
- }
2116
-
2117
- if not (config.tavily_api_key or config.jina_api_key or config.firecrawl_api_key):
2118
- error = "TAVILY_API_KEY、JINA_API_KEY 和 FIRECRAWL_API_KEY 均未配置"
2119
- error_type = "config_error"
2120
- else:
2121
- error = "所有提取服务均未能获取内容"
2122
- error_type = "network_error"
2123
- return {
2124
- "ok": False,
2125
- "url": url,
2126
- "provider": "",
2127
- "content": "",
2128
- "error_type": error_type,
2129
- "error": error,
2130
- "provider_attempts": attempts,
2131
- "fallback_used": _fallback_used(attempts),
2132
- "elapsed_ms": _elapsed_ms(start),
2133
- }
2134
-
2135
-
2136
- async def map_site(
2137
- url: str,
2138
- instructions: str = "",
2139
- max_depth: int = 1,
2140
- max_breadth: int = 20,
2141
- limit: int = 50,
2142
- timeout: int = 150,
2143
- ) -> dict[str, Any]:
2144
- start = time.time()
2145
- result = await call_tavily_map(url, instructions, max_depth, max_breadth, limit, timeout)
2146
- result.setdefault("url", url)
2147
- result.setdefault("elapsed_ms", _elapsed_ms(start))
2148
- return result
2149
-
2150
-
2151
- async def exa_search(
2152
- query: str,
2153
- num_results: int = 5,
2154
- search_type: str = "neural",
2155
- include_text: bool = False,
2156
- include_highlights: bool = False,
2157
- start_published_date: str = "",
2158
- include_domains: str | list[str] | tuple[str, ...] = "",
2159
- exclude_domains: str | list[str] | tuple[str, ...] = "",
2160
- category: str = "",
2161
- ) -> dict[str, Any]:
2162
- api_key = config.exa_api_key
2163
- if not api_key:
2164
- return {
2165
- "ok": False,
2166
- "error_type": "config_error",
2167
- "error": "EXA_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set EXA_API_KEY <key>`。",
2168
- }
2169
-
2170
- provider = ExaSearchProvider(config.exa_base_url, api_key, config.exa_timeout)
2171
- include_domain_list = _normalize_domain_filter(include_domains)
2172
- exclude_domain_list = _normalize_domain_filter(exclude_domains)
2173
-
2174
- raw = await provider.search(
2175
- query=query,
2176
- num_results=num_results,
2177
- search_type=search_type,
2178
- include_text=include_text,
2179
- include_highlights=include_highlights,
2180
- start_published_date=start_published_date or None,
2181
- include_domains=include_domain_list,
2182
- exclude_domains=exclude_domain_list,
2183
- category=category or None,
2184
- )
2185
- try:
2186
- data = json.loads(raw)
2187
- except json.JSONDecodeError:
2188
- return {"ok": False, "error_type": "parse_error", "error": raw}
2189
- if not data.get("ok", False):
2190
- data.setdefault("error_type", "network_error")
2191
- return data
2192
-
2193
-
2194
- async def _decode_provider_json(raw: str, provider: str = "jina") -> dict[str, Any]:
2195
- try:
2196
- return json.loads(raw)
2197
- except json.JSONDecodeError:
2198
- return {"ok": False, "provider": provider, "error_type": "parse_error", "error": raw}
2199
-
2200
-
2201
- async def jina_fetch(url: str) -> dict[str, Any]:
2202
- return await call_jina_reader(url)
2203
-
2204
-
2205
- async def exa_find_similar(url: str, num_results: int = 5) -> dict[str, Any]:
2206
- api_key = config.exa_api_key
2207
- if not api_key:
2208
- return {
2209
- "ok": False,
2210
- "error_type": "config_error",
2211
- "error": "EXA_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set EXA_API_KEY <key>`。",
2212
- }
2213
-
2214
- provider = ExaSearchProvider(config.exa_base_url, api_key, config.exa_timeout)
2215
- raw = await provider.find_similar(url=url, num_results=num_results)
2216
- try:
2217
- data = json.loads(raw)
2218
- except json.JSONDecodeError:
2219
- return {"ok": False, "error_type": "parse_error", "error": raw}
2220
- if not data.get("ok", False):
2221
- data.setdefault("error_type", "network_error")
2222
- return data
2223
-
2224
-
2225
- async def zhipu_search(
2226
- query: str,
2227
- count: int = 10,
2228
- search_engine: str = "",
2229
- search_recency_filter: str = "noLimit",
2230
- search_domain_filter: str = "",
2231
- content_size: str = "medium",
2232
- ) -> dict[str, Any]:
2233
- api_key = config.zhipu_api_key
2234
- if not api_key:
2235
- return {
2236
- "ok": False,
2237
- "error_type": "config_error",
2238
- "error": "ZHIPU_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set ZHIPU_API_KEY <key>`。",
2239
- }
2240
- provider = ZhipuWebSearchProvider(
2241
- config.zhipu_api_url,
2242
- api_key,
2243
- search_engine or config.zhipu_search_engine,
2244
- config.zhipu_timeout,
2245
- )
2246
- raw = await provider.search(
2247
- query=query,
2248
- count=count,
2249
- search_engine=search_engine or None,
2250
- search_recency_filter=search_recency_filter,
2251
- search_domain_filter=search_domain_filter,
2252
- content_size=content_size,
2253
- )
2254
- try:
2255
- data = json.loads(raw)
2256
- except json.JSONDecodeError:
2257
- return {"ok": False, "error_type": "parse_error", "error": raw}
2258
- if not data.get("ok", False):
2259
- data.setdefault("error_type", "network_error")
2260
- return data
2261
-
2262
-
2263
- async def context7_library(name: str, query: str = "") -> dict[str, Any]:
2264
- api_key = config.context7_api_key
2265
- if not api_key:
2266
- return {
2267
- "ok": False,
2268
- "error_type": "config_error",
2269
- "error": "CONTEXT7_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set CONTEXT7_API_KEY <key>`。",
2270
- }
2271
- provider = Context7Provider(config.context7_base_url, api_key, config.context7_timeout)
2272
- raw = await provider.library(name, query)
2273
- try:
2274
- data = json.loads(raw)
2275
- except json.JSONDecodeError:
2276
- return {"ok": False, "error_type": "parse_error", "error": raw}
2277
- if not data.get("ok", False):
2278
- data.setdefault("error_type", "network_error")
2279
- return data
2280
-
2281
-
2282
- async def context7_docs(library_id: str, query: str) -> dict[str, Any]:
2283
- api_key = config.context7_api_key
2284
- if not api_key:
2285
- return {
2286
- "ok": False,
2287
- "error_type": "config_error",
2288
- "error": "CONTEXT7_API_KEY 未配置。请运行 `smart-search setup`,或使用 `smart-search config set CONTEXT7_API_KEY <key>`。",
2289
- }
2290
- provider = Context7Provider(config.context7_base_url, api_key, config.context7_timeout)
2291
- raw = await provider.docs(library_id, query)
2292
- try:
2293
- data = json.loads(raw)
2294
- except json.JSONDecodeError:
2295
- return {"ok": False, "error_type": "parse_error", "error": raw}
2296
- if not data.get("ok", False):
2297
- data.setdefault("error_type", "network_error")
2298
- return data
2299
-
2300
-
2301
- async def _test_primary_chat_completion(api_url: str, api_key: str, model: str) -> dict[str, Any]:
2302
- chat_url = f"{api_url.rstrip('/')}/chat/completions"
2303
- start = time.time()
2304
- async with httpx.AsyncClient(timeout=20.0) as client:
2305
- response = await client.post(
2306
- chat_url,
2307
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json, text/event-stream"},
2308
- json={
2309
- "model": model,
2310
- "messages": [{"role": "user", "content": "Reply with exactly: ok"}],
2311
- "stream": False,
2312
- "max_tokens": 8,
2313
- },
2314
- )
2315
- response_time = _elapsed_ms(start)
2316
- content_type = response.headers.get("content-type", "")
2317
- if response.status_code != 200:
2318
- return {
2319
- "status": "warning",
2320
- "message": f"HTTP {response.status_code}: {response.text[:100]}",
2321
- "response_time_ms": response_time,
2322
- "http_status": response.status_code,
2323
- "content_type": content_type,
2324
- "has_content": bool(response.text.strip()),
2325
- }
2326
- return {
2327
- "status": "ok",
2328
- "message": f"聊天接口可用 (HTTP {response.status_code})",
2329
- "response_time_ms": response_time,
2330
- "http_status": response.status_code,
2331
- "content_type": content_type,
2332
- "has_content": bool(response.text.strip()),
2333
- }
2334
-
2335
-
2336
- def _diagnose_check_result(
2337
- *,
2338
- name: str,
2339
- status: str,
2340
- message: str,
2341
- start: float,
2342
- http_status: int | None = None,
2343
- content_type: str = "",
2344
- has_content: bool = False,
2345
- stream: bool | None = None,
2346
- ) -> dict[str, Any]:
2347
- result: dict[str, Any] = {
2348
- "name": name,
2349
- "status": status,
2350
- "message": message,
2351
- "response_time_ms": _elapsed_ms(start),
2352
- "has_content": has_content,
2353
- }
2354
- if http_status is not None:
2355
- result["http_status"] = http_status
2356
- if content_type:
2357
- result["content_type"] = content_type
2358
- if stream is not None:
2359
- result["stream"] = stream
2360
- return result
2361
-
2362
-
2363
- def _openai_compatible_diagnosis(quick: dict[str, Any], no_stream: dict[str, Any], stream: dict[str, Any]) -> tuple[bool, str, str]:
2364
- quick_ok = quick.get("status") == "ok"
2365
- no_stream_ok = no_stream.get("status") == "ok"
2366
- stream_ok = stream.get("status") == "ok"
2367
- search_timeout = no_stream.get("status") == "timeout" or stream.get("status") == "timeout"
2368
-
2369
- if no_stream_ok and stream_ok:
2370
- return (
2371
- True,
2372
- "OpenAI-compatible 主链路正常。",
2373
- "真实 search 形态的 stream=false 和 stream=true 都能返回。若用户仍卡住,更可能是调用方、PATH、超时设置或上游偶发波动。",
2374
- )
2375
- if stream_ok and not no_stream_ok:
2376
- return (
2377
- False,
2378
- "非流式请求不稳定,流式请求可用。",
2379
- "建议设置 `OPENAI_COMPATIBLE_STREAM=true`,或临时使用 `smart-search search ... --stream`。",
2380
- )
2381
- if no_stream_ok and not stream_ok:
2382
- return (
2383
- False,
2384
- "流式请求不稳定,非流式请求可用。",
2385
- "建议设置 `OPENAI_COMPATIBLE_STREAM=false`,或临时使用 `smart-search search ... --no-stream`。",
2386
- )
2387
- if quick_ok and search_timeout:
2388
- return (
2389
- False,
2390
- "小请求能通,但真实 search 形态超时。",
2391
- "这通常是上游模型或中转站在处理 smart-search 的完整 prompt 时卡住;建议换模型/中转,或把本诊断报告贴给维护者。",
2392
- )
2393
- if quick_ok:
2394
- return (
2395
- False,
2396
- "小请求能通,但真实 search 形态失败。",
2397
- "这更像上游模型/中转站对 smart-search 请求形态不兼容;建议换模型/中转,或把本诊断报告贴给维护者。",
2398
- )
2399
- return (
2400
- False,
2401
- "OpenAI-compatible 基础请求不可用。",
2402
- "请先检查 API URL、API key、模型名和网络;修好后再运行本诊断命令。",
2403
- )
2404
-
2405
-
2406
- async def _probe_openai_compatible_search_shape(
2407
- api_url: str,
2408
- api_key: str,
2409
- model: str,
2410
- *,
2411
- stream: bool,
2412
- timeout_seconds: float,
2413
- ) -> dict[str, Any]:
2414
- name = "真实 search 请求 (stream=true)" if stream else "真实 search 请求 (stream=false)"
2415
- start = time.time()
2416
- payload = {
2417
- "model": model,
2418
- "messages": [
2419
- {"role": "system", "content": search_prompt},
2420
- {"role": "user", "content": get_local_time_info() + "\nping"},
2421
- ],
2422
- "stream": stream,
2423
- }
2424
- headers = {
2425
- "Authorization": f"Bearer {api_key}",
2426
- "Content-Type": "application/json",
2427
- "Accept": "application/json, text/event-stream",
2428
- "User-Agent": "smart-search/diagnose",
2429
- }
2430
- timeout = httpx.Timeout(connect=6.0, read=timeout_seconds, write=10.0, pool=None)
2431
- try:
2432
- async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=config.ssl_verify_enabled) as client:
2433
- if stream:
2434
- async with client.stream(
2435
- "POST",
2436
- f"{api_url.rstrip('/')}/chat/completions",
2437
- headers=headers,
2438
- json=payload,
2439
- ) as response:
2440
- content_type = response.headers.get("content-type", "")
2441
- response.raise_for_status()
2442
- has_content = False
2443
- async for line in response.aiter_lines():
2444
- stripped = line.strip()
2445
- if not stripped:
2446
- continue
2447
- if not stripped.startswith("data:"):
2448
- continue
2449
- if stripped in ("data: [DONE]", "data:[DONE]"):
2450
- continue
2451
- try:
2452
- data = json.loads(stripped[5:].lstrip())
2453
- except json.JSONDecodeError:
2454
- continue
2455
- choices = data.get("choices", []) if isinstance(data, dict) else []
2456
- if not choices:
2457
- continue
2458
- delta = choices[0].get("delta", {})
2459
- if isinstance(delta, dict) and str(delta.get("content") or "").strip():
2460
- has_content = True
2461
- break
2462
- message = choices[0].get("message", {})
2463
- if isinstance(message, dict) and str(message.get("content") or "").strip():
2464
- has_content = True
2465
- break
2466
- status = "ok" if has_content else "empty"
2467
- message = f"HTTP {response.status_code}; {'收到流式内容' if has_content else '未收到内容'}"
2468
- return _diagnose_check_result(
2469
- name=name,
2470
- status=status,
2471
- message=message,
2472
- start=start,
2473
- http_status=response.status_code,
2474
- content_type=content_type,
2475
- has_content=has_content,
2476
- stream=stream,
2477
- )
2478
-
2479
- response = await client.post(
2480
- f"{api_url.rstrip('/')}/chat/completions",
2481
- headers=headers,
2482
- json=payload,
2483
- )
2484
- content_type = response.headers.get("content-type", "")
2485
- response.raise_for_status()
2486
- content = await OpenAICompatibleSearchProvider(api_url, api_key, model, stream=False)._parse_completion_response(response)
2487
- has_content = bool(content.strip())
2488
- status = "ok" if has_content else "empty"
2489
- message = f"HTTP {response.status_code}; {'收到内容' if has_content else '返回为空'}"
2490
- return _diagnose_check_result(
2491
- name=name,
2492
- status=status,
2493
- message=message,
2494
- start=start,
2495
- http_status=response.status_code,
2496
- content_type=content_type,
2497
- has_content=has_content,
2498
- stream=stream,
2499
- )
2500
- except httpx.TimeoutException as e:
2501
- return _diagnose_check_result(name=name, status="timeout", message=f"请求超时: {e}", start=start, stream=stream)
2502
- except httpx.HTTPStatusError as e:
2503
- body = e.response.text[:200] if e.response is not None else str(e)
2504
- status_code = e.response.status_code if e.response is not None else None
2505
- content_type = e.response.headers.get("content-type", "") if e.response is not None else ""
2506
- return _diagnose_check_result(
2507
- name=name,
2508
- status="warning",
2509
- message=f"HTTP {status_code}: {body}",
2510
- start=start,
2511
- http_status=status_code,
2512
- content_type=content_type,
2513
- stream=stream,
2514
- )
2515
- except httpx.RequestError as e:
2516
- return _diagnose_check_result(name=name, status="error", message=f"网络错误: {e}", start=start, stream=stream)
2517
- except Exception as e:
2518
- return _diagnose_check_result(name=name, status="error", message=f"运行错误: {e}", start=start, stream=stream)
2519
-
2520
-
2521
- async def diagnose_openai_compatible(timeout_seconds: float = 30.0) -> dict[str, Any]:
2522
- start = time.time()
2523
- api_url = config.openai_compatible_api_url
2524
- api_key = config.openai_compatible_api_key
2525
- model = config.openai_compatible_model
2526
- info = config.config_path_info()
2527
- result: dict[str, Any] = {
2528
- "ok": False,
2529
- "provider": "openai-compatible",
2530
- "api_url": api_url or "未配置",
2531
- "api_key": config._mask_api_key(api_key) if api_key else "未配置",
2532
- "model": model,
2533
- "configured_stream": config.openai_compatible_stream,
2534
- "timeout_seconds": timeout_seconds,
2535
- "config_file": info.get("config_file", ""),
2536
- "config_dir_source": info.get("config_dir_source", ""),
2537
- "checks": [],
2538
- "next_command": OPENAI_COMPATIBLE_DIAGNOSE_COMMAND,
2539
- }
2540
- missing = []
2541
- if not api_url:
2542
- missing.append("OPENAI_COMPATIBLE_API_URL")
2543
- if not api_key:
2544
- missing.append("OPENAI_COMPATIBLE_API_KEY")
2545
- if missing:
2546
- result.update(
2547
- {
2548
- "error_type": "config_error",
2549
- "error": "缺少 OpenAI-compatible 配置: " + ", ".join(missing),
2550
- "summary": "OpenAI-compatible 配置不完整。",
2551
- "recommendation": "请先运行 `smart-search setup`,或用 `smart-search config set` 填好缺失项。",
2552
- "missing": missing,
2553
- "elapsed_ms": _elapsed_ms(start),
2554
- }
2555
- )
2556
- return result
2557
-
2558
- try:
2559
- quick = await _test_primary_chat_completion(api_url, api_key, model)
2560
- except httpx.TimeoutException as e:
2561
- quick = {"status": "timeout", "message": f"轻量 chat 请求超时: {e}"}
2562
- except httpx.RequestError as e:
2563
- quick = {"status": "error", "message": f"轻量 chat 网络错误: {e}"}
2564
- except Exception as e:
2565
- quick = {"status": "error", "message": f"轻量 chat 运行错误: {e}"}
2566
- quick_check = {
2567
- "name": "轻量 chat 请求",
2568
- "status": quick.get("status", "error"),
2569
- "message": quick.get("message", ""),
2570
- "response_time_ms": quick.get("response_time_ms"),
2571
- "http_status": quick.get("http_status"),
2572
- "content_type": quick.get("content_type", ""),
2573
- "has_content": bool(quick.get("has_content", quick.get("status") == "ok")),
2574
- }
2575
- result["checks"].append(quick_check)
2576
- no_stream = await _probe_openai_compatible_search_shape(api_url, api_key, model, stream=False, timeout_seconds=timeout_seconds)
2577
- result["checks"].append(no_stream)
2578
- stream = await _probe_openai_compatible_search_shape(api_url, api_key, model, stream=True, timeout_seconds=timeout_seconds)
2579
- result["checks"].append(stream)
2580
-
2581
- ok, summary, recommendation = _openai_compatible_diagnosis(quick_check, no_stream, stream)
2582
- result.update(
2583
- {
2584
- "ok": ok,
2585
- "error_type": "" if ok else "network_error",
2586
- "error": "" if ok else summary,
2587
- "summary": summary,
2588
- "recommendation": recommendation,
2589
- "elapsed_ms": _elapsed_ms(start),
2590
- }
2591
- )
2592
- return result
2593
-
2594
-
2595
- async def _test_primary_connection(api_url: str, api_key: str, model: str) -> dict[str, Any]:
2596
- chat_test = await _test_primary_chat_completion(api_url, api_key, model)
2597
-
2598
- models_url = f"{api_url.rstrip('/')}/models"
2599
- start = time.time()
2600
- try:
2601
- async with httpx.AsyncClient(timeout=10.0) as client:
2602
- response = await client.get(
2603
- models_url,
2604
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
2605
- )
2606
- response_time = _elapsed_ms(start)
2607
- if response.status_code != 200:
2608
- models_test = {"status": "warning", "message": f"HTTP {response.status_code}: {response.text[:100]}", "response_time_ms": response_time}
2609
- else:
2610
- models_test = {"status": "ok", "message": f"成功获取模型列表 (HTTP {response.status_code})", "response_time_ms": response_time}
2611
- try:
2612
- models_data = response.json()
2613
- model_names = [m["id"] for m in models_data.get("data", []) if isinstance(m, dict) and "id" in m]
2614
- models_test["message"] += f",共 {len(model_names)} 个模型"
2615
- if model_names:
2616
- models_test["available_models"] = model_names
2617
- except Exception:
2618
- pass
2619
- except httpx.HTTPError as e:
2620
- models_test = {"status": "warning", "message": f"模型列表接口请求失败: {e}", "response_time_ms": _elapsed_ms(start)}
2621
-
2622
- if chat_test.get("status") != "ok":
2623
- models_state = "可用" if models_test.get("status") == "ok" else "不可用"
2624
- return {
2625
- "status": "warning",
2626
- "message": f"聊天接口不可用: {chat_test.get('message', '')};模型列表接口{models_state}: {models_test['message']}",
2627
- "response_time_ms": chat_test.get("response_time_ms", models_test.get("response_time_ms")),
2628
- "models_endpoint_test": models_test,
2629
- "chat_completion_test": chat_test,
2630
- }
2631
-
2632
- if models_test.get("status") != "ok":
2633
- return {
2634
- "status": "ok",
2635
- "message": f"{chat_test['message']};模型列表接口不可用: {models_test['message']}",
2636
- "response_time_ms": chat_test.get("response_time_ms"),
2637
- "models_endpoint_test": models_test,
2638
- "chat_completion_test": chat_test,
2639
- }
2640
-
2641
- result: dict[str, Any] = {
2642
- "status": "ok",
2643
- "message": f"{chat_test['message']};{models_test['message']}",
2644
- "response_time_ms": chat_test.get("response_time_ms"),
2645
- "models_endpoint_test": models_test,
2646
- "chat_completion_test": chat_test,
2647
- }
2648
- if "available_models" in models_test:
2649
- result["available_models"] = models_test["available_models"]
2650
- return result
2651
-
2652
-
2653
- async def _test_main_provider_connection(provider_config: dict[str, Any]) -> dict[str, Any]:
2654
- return await _test_primary_connection(provider_config["api_url"], provider_config["api_key"], provider_config["model"])
2655
-
2656
-
2657
- async def _safe_test_main_provider_connection(provider_config: dict[str, Any]) -> dict[str, Any]:
2658
- try:
2659
- return await _test_main_provider_connection(provider_config)
2660
- except httpx.TimeoutException:
2661
- return {"status": "timeout", "message": f"{provider_config['provider']} 请求超时,请检查网络连接或 API URL"}
2662
- except httpx.RequestError as e:
2663
- return {"status": "error", "message": f"{provider_config['provider']} 网络错误: {str(e)}"}
2664
- except Exception as e:
2665
- return {"status": "error", "message": f"{provider_config['provider']} 未知错误: {str(e)}"}
2666
-
2667
-
2668
- async def _test_exa_connection() -> dict[str, Any]:
2669
- exa_key = config.exa_api_key
2670
- if not exa_key:
2671
- return {"status": "not_configured", "message": "EXA_API_KEY 未设置,Exa 搜索功能不可用"}
2672
- start = time.time()
2673
- async with httpx.AsyncClient(timeout=10.0) as client:
2674
- resp = await client.post(
2675
- f"{config.exa_base_url.rstrip('/')}/search",
2676
- headers={"x-api-key": exa_key, "content-type": "application/json"},
2677
- json={"query": "test", "numResults": 1, "type": "keyword"},
2678
- )
2679
- response_time = _elapsed_ms(start)
2680
- if resp.status_code == 200:
2681
- return {"status": "ok", "message": "Exa API 可用 (HTTP 200)", "response_time_ms": response_time}
2682
- return {"status": "warning", "message": f"HTTP {resp.status_code}: {resp.text[:100]}", "response_time_ms": response_time}
2683
-
2684
-
2685
- async def _test_tavily_connection() -> dict[str, Any]:
2686
- tavily_key = config.tavily_api_key
2687
- if not tavily_key:
2688
- return {"status": "not_configured", "message": "TAVILY_API_KEY 未设置,Tavily 功能不可用"}
2689
- start = time.time()
2690
- timeout = httpx.Timeout(connect=6.0, read=config.tavily_timeout, write=10.0, pool=None)
2691
- async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=config.ssl_verify_enabled) as client:
2692
- resp = await client.post(
2693
- f"{config.tavily_api_url.rstrip('/')}/search",
2694
- headers={"Authorization": f"Bearer {tavily_key}", "Content-Type": "application/json"},
2695
- json={"query": "test", "max_results": 1, "search_depth": "basic"},
2696
- )
2697
- response_time = _elapsed_ms(start)
2698
- if resp.status_code == 200:
2699
- return {"status": "ok", "message": "Tavily API 可用 (HTTP 200)", "response_time_ms": response_time}
2700
- return {"status": "warning", "message": f"HTTP {resp.status_code}: {resp.text[:100]}", "response_time_ms": response_time}
2701
-
2702
-
2703
- async def _test_jina_connection() -> dict[str, Any]:
2704
- if config.jina_respond_with and not config.jina_api_key:
2705
- return {"status": "config_error", "message": "JINA_RESPOND_WITH requires JINA_API_KEY"}
2706
- if not config.jina_api_key:
2707
- return {"status": "not_configured", "message": "JINA_API_KEY 未设置,Jina 不满足 standard web_fetch;匿名 Reader 只能作为显式实验使用"}
2708
- start = time.time()
2709
- data = await jina_fetch("https://example.com")
2710
- response_time = _elapsed_ms(start)
2711
- if data.get("ok"):
2712
- return {"status": "ok", "message": "Jina Reader 可用", "response_time_ms": response_time}
2713
- error_type = data.get("error_type", "")
2714
- status = error_type if error_type in {"auth_error", "config_error", "parameter_error", "rate_limited", "timeout"} else "warning"
2715
- return {"status": status, "message": data.get("error", "Jina Reader 不可用"), "response_time_ms": response_time}
2716
-
2717
-
2718
- async def _test_zhipu_connection() -> dict[str, Any]:
2719
- if not config.zhipu_api_key:
2720
- return {"status": "not_configured", "message": "ZHIPU_API_KEY 未设置,智谱搜索功能不可用"}
2721
- result = await zhipu_search("test", count=1)
2722
- if result.get("ok"):
2723
- return {"status": "ok", "message": "智谱 Web Search 可用", "response_time_ms": result.get("elapsed_ms", 0)}
2724
- return {"status": "warning", "message": result.get("error", "智谱 Web Search 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
2725
-
2726
-
2727
- async def _test_context7_connection() -> dict[str, Any]:
2728
- if not config.context7_api_key:
2729
- return {"status": "not_configured", "message": "CONTEXT7_API_KEY 未设置,Context7 功能不可用"}
2730
- result = await context7_library("react", "hooks")
2731
- if result.get("ok"):
2732
- return {"status": "ok", "message": "Context7 API 可用", "response_time_ms": result.get("elapsed_ms", 0)}
2733
- return {"status": "warning", "message": result.get("error", "Context7 API 不可用"), "response_time_ms": result.get("elapsed_ms", 0)}
2734
-
2735
-
2736
- async def doctor() -> dict[str, Any]:
2737
- info = config.get_config_info()
2738
-
2739
- main_provider_configs: list[dict[str, Any]] = []
2740
- try:
2741
- main_provider_configs = _main_search_provider_configs()
2742
- info["main_search_connection_tests"] = {}
2743
- for provider_config in main_provider_configs:
2744
- info["main_search_connection_tests"][provider_config["provider"]] = await _safe_test_main_provider_connection(provider_config)
2745
- if main_provider_configs:
2746
- first_provider = main_provider_configs[0]
2747
- info["primary_api_mode"] = first_provider["mode"]
2748
- info["primary_connection_test"] = info["main_search_connection_tests"][first_provider["provider"]]
2749
- else:
2750
- info["primary_connection_test"] = {"status": "config_error", "message": MINIMUM_PROFILE_ERROR}
2751
- except ValueError as e:
2752
- info["main_search_connection_tests"] = {}
2753
- info["primary_connection_test"] = {"status": "config_error", "message": str(e)}
2754
- except Exception as e:
2755
- info["main_search_connection_tests"] = {}
2756
- info["primary_connection_test"] = {"status": "error", "message": f"未知错误: {str(e)}"}
2757
-
2758
- try:
2759
- info["exa_connection_test"] = await _test_exa_connection()
2760
- except httpx.TimeoutException:
2761
- info["exa_connection_test"] = {"status": "timeout", "message": "Exa API 请求超时"}
2762
- except Exception as e:
2763
- info["exa_connection_test"] = {"status": "error", "message": str(e)}
2764
-
2765
- try:
2766
- info["tavily_connection_test"] = await _test_tavily_connection()
2767
- except httpx.TimeoutException:
2768
- info["tavily_connection_test"] = {"status": "timeout", "message": "Tavily API 请求超时"}
2769
- except Exception as e:
2770
- info["tavily_connection_test"] = {"status": "error", "message": str(e)}
2771
-
2772
- try:
2773
- info["jina_connection_test"] = await _test_jina_connection()
2774
- except httpx.TimeoutException:
2775
- info["jina_connection_test"] = {"status": "timeout", "message": "Jina Reader 请求超时"}
2776
- except Exception as e:
2777
- info["jina_connection_test"] = {"status": "error", "message": str(e)}
2778
-
2779
- if config.firecrawl_api_key:
2780
- info["firecrawl_connection_test"] = {"status": "configured", "message": "FIRECRAWL_API_KEY 已设置"}
2781
- else:
2782
- info["firecrawl_connection_test"] = {"status": "not_configured", "message": "FIRECRAWL_API_KEY 未设置,Firecrawl 功能不可用"}
2783
-
2784
- try:
2785
- info["zhipu_connection_test"] = await _test_zhipu_connection()
2786
- except httpx.TimeoutException:
2787
- info["zhipu_connection_test"] = {"status": "timeout", "message": "智谱 API 请求超时"}
2788
- except Exception as e:
2789
- info["zhipu_connection_test"] = {"status": "error", "message": str(e)}
2790
-
2791
- try:
2792
- info["context7_connection_test"] = await _test_context7_connection()
2793
- except httpx.TimeoutException:
2794
- info["context7_connection_test"] = {"status": "timeout", "message": "Context7 API 请求超时"}
2795
- except Exception as e:
2796
- info["context7_connection_test"] = {"status": "error", "message": str(e)}
2797
-
2798
- minimum = validate_minimum_profile()
2799
- info["capability_status"] = minimum.get("capability_status", get_capability_status())
2800
- info["minimum_profile_ok"] = minimum.get("ok", False)
2801
- info["minimum_profile_missing"] = minimum.get("missing", [])
2802
- main_connection_tests = info.get("main_search_connection_tests") or {}
2803
- main_search_statuses = [item.get("status") for item in main_connection_tests.values() if isinstance(item, dict)]
2804
- primary_test = info.get("primary_connection_test", {})
2805
- primary_status = primary_test.get("status")
2806
- main_search_ok = any(status == "ok" for status in main_search_statuses) if main_connection_tests else primary_status == "ok"
2807
- info["ok"] = main_search_ok and minimum.get("ok", False)
2808
- if info["ok"]:
2809
- info["error_type"] = ""
2810
- info["error"] = ""
2811
- elif info.get("config_parameter_errors"):
2812
- info["error"] = "; ".join(info["config_parameter_errors"])
2813
- info["error_type"] = "parameter_error"
2814
- elif not minimum.get("ok", False):
2815
- info["error"] = minimum.get("error", MINIMUM_PROFILE_ERROR)
2816
- info["error_type"] = minimum.get("error_type", "config_error")
2817
- else:
2818
- info["error"] = primary_test.get("message", "Primary connection check failed")
2819
- if primary_status == "config_error":
2820
- info["error_type"] = "config_error"
2821
- elif primary_status in {"timeout", "error", "warning"}:
2822
- info["error_type"] = "network_error"
2823
- else:
2824
- info["error_type"] = "runtime_error"
2825
- return info
2826
-
2827
-
2828
- def config_path() -> dict[str, Any]:
2829
- return config.config_path_info()
2830
-
2831
-
2832
- def config_list(show_secrets: bool = False) -> dict[str, Any]:
2833
- return {
2834
- "ok": True,
2835
- "config_file": str(config.config_file),
2836
- "values": config.get_saved_config(masked=not show_secrets),
2837
- }
2838
-
2839
-
2840
- def config_set(key: str, value: str) -> dict[str, Any]:
2841
- try:
2842
- config.set_config_value(key, value)
2843
- except ValueError as e:
2844
- return {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": str(config.config_file)}
2845
- saved = config.get_saved_config(masked=True)
2846
- return {
2847
- "ok": True,
2848
- "config_file": str(config.config_file),
2849
- "key": key.strip().upper(),
2850
- "value": saved.get(key.strip().upper(), ""),
2851
- }
2852
-
2853
-
2854
- def config_unset(key: str) -> dict[str, Any]:
2855
- try:
2856
- config.unset_config_value(key)
2857
- except ValueError as e:
2858
- return {"ok": False, "error_type": "parameter_error", "error": str(e), "config_file": str(config.config_file), "key": key.strip().upper()}
2859
- return {"ok": True, "config_file": str(config.config_file), "key": key.strip().upper()}
2860
-
2861
-
2862
- def write_output(path: str | Path, content: str) -> None:
2863
- target = Path(path)
2864
- target.parent.mkdir(parents=True, exist_ok=True)
2865
- target.write_text(content, encoding="utf-8")