@shirlytaylor73/smart-search 0.2.0-beta.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/README.zh-CN.md +180 -0
  4. package/npm/bin/smart-search.js +68 -0
  5. package/npm/scripts/postinstall.js +87 -0
  6. package/npm/scripts/resolve-prerelease-version.js +108 -0
  7. package/npm/scripts/set-package-version.js +35 -0
  8. package/npm/scripts/sync-python-version.js +22 -0
  9. package/npm/scripts/test-wrapper-repair.js +137 -0
  10. package/npm/scripts/test.js +76 -0
  11. package/package.json +42 -0
  12. package/pyproject.toml +36 -0
  13. package/skills/smart-search-cli/SKILL.md +41 -0
  14. package/skills/smart-search-cli/agents/openai.yaml +3 -0
  15. package/skills/smart-search-cli/references/cli-contract.md +7 -0
  16. package/skills/smart-search-cli/references/cli-core.md +5 -0
  17. package/skills/smart-search-cli/references/command-patterns.md +12 -0
  18. package/skills/smart-search-cli/references/provider-routing.md +3 -0
  19. package/skills/smart-search-cli/references/regression-release.md +28 -0
  20. package/skills/smart-search-cli/references/setup-config.md +3 -0
  21. package/src/smart_search/__init__.py +1 -0
  22. package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
  23. package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
  24. package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
  25. package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
  26. package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
  27. package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
  28. package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
  29. package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
  30. package/src/smart_search/cli.py +3118 -0
  31. package/src/smart_search/config.py +809 -0
  32. package/src/smart_search/embedding_presets.py +40 -0
  33. package/src/smart_search/intent_router.py +757 -0
  34. package/src/smart_search/logger.py +43 -0
  35. package/src/smart_search/providers/__init__.py +20 -0
  36. package/src/smart_search/providers/base.py +41 -0
  37. package/src/smart_search/providers/context7.py +141 -0
  38. package/src/smart_search/providers/exa.py +206 -0
  39. package/src/smart_search/providers/jina.py +136 -0
  40. package/src/smart_search/providers/openai_compatible.py +541 -0
  41. package/src/smart_search/providers/xai_responses.py +117 -0
  42. package/src/smart_search/providers/zhipu.py +143 -0
  43. package/src/smart_search/providers/zhipu_mcp.py +230 -0
  44. package/src/smart_search/service.py +3445 -0
  45. package/src/smart_search/skill_installer.py +342 -0
  46. package/src/smart_search/sources.py +429 -0
  47. package/src/smart_search/utils.py +220 -0
@@ -0,0 +1,143 @@
1
+ import json
2
+ import time
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
7
+
8
+ from .base import BaseSearchProvider
9
+ from ..config import config
10
+ from ..logger import log_info
11
+
12
+
13
+ RETRYABLE_STATUS_CODES = {408, 500, 502, 503, 504}
14
+
15
+
16
+ def _is_retryable_exception(exc) -> bool:
17
+ if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError)):
18
+ return True
19
+ if isinstance(exc, httpx.HTTPStatusError):
20
+ return exc.response.status_code in RETRYABLE_STATUS_CODES
21
+ return False
22
+
23
+
24
+ def _normalize_result(item: dict[str, Any]) -> dict[str, Any]:
25
+ return {
26
+ "title": item.get("title") or "",
27
+ "url": item.get("link") or item.get("url") or "",
28
+ "description": item.get("content") or "",
29
+ "provider": "zhipu",
30
+ "source": item.get("media") or "",
31
+ "published_date": item.get("publish_date") or "",
32
+ "icon": item.get("icon") or "",
33
+ "refer": item.get("refer") or "",
34
+ }
35
+
36
+
37
+ def _error_payload(exc: Exception) -> dict[str, Any]:
38
+ if isinstance(exc, httpx.HTTPStatusError):
39
+ status_code = exc.response.status_code
40
+ if status_code == 429:
41
+ error_type = "rate_limited"
42
+ elif status_code in {401, 403}:
43
+ error_type = "auth_error"
44
+ else:
45
+ error_type = "network_error"
46
+ return {"error_type": error_type, "error": f"HTTP {status_code}: {exc.response.reason_phrase}"}
47
+ if isinstance(exc, httpx.TimeoutException):
48
+ return {"error_type": "timeout", "error": "request timed out"}
49
+ if isinstance(exc, httpx.RequestError):
50
+ return {"error_type": "network_error", "error": str(exc)}
51
+ return {"error_type": "runtime_error", "error": str(exc)}
52
+
53
+
54
+ class ZhipuWebSearchProvider(BaseSearchProvider):
55
+ def __init__(
56
+ self,
57
+ api_url: str,
58
+ api_key: str,
59
+ search_engine: str = "search_std",
60
+ timeout: float = 30.0,
61
+ ):
62
+ super().__init__(api_url.rstrip("/"), api_key)
63
+ self.search_engine = search_engine
64
+ self.timeout = timeout
65
+
66
+ def get_provider_name(self) -> str:
67
+ return "Zhipu Web Search"
68
+
69
+ async def search(
70
+ self,
71
+ query: str,
72
+ count: int = 10,
73
+ search_engine: str | None = None,
74
+ search_intent: bool = True,
75
+ search_domain_filter: str = "",
76
+ search_recency_filter: str = "noLimit",
77
+ content_size: str = "medium",
78
+ user_id: str = "",
79
+ ctx=None,
80
+ ) -> str:
81
+ endpoint = f"{self.api_url}/paas/v4/web_search"
82
+ headers = {
83
+ "Authorization": f"Bearer {self.api_key}",
84
+ "Content-Type": "application/json",
85
+ "Accept": "application/json",
86
+ }
87
+ payload: dict[str, Any] = {
88
+ "search_query": query[:70],
89
+ "search_engine": search_engine or self.search_engine,
90
+ "search_intent": search_intent,
91
+ "count": count,
92
+ "search_recency_filter": search_recency_filter,
93
+ "content_size": content_size,
94
+ }
95
+ if search_domain_filter:
96
+ payload["search_domain_filter"] = search_domain_filter
97
+ if user_id:
98
+ payload["user_id"] = user_id
99
+
100
+ await log_info(ctx, f"Zhipu search: {query}", config.debug_enabled)
101
+ start_time = time.time()
102
+ try:
103
+ data = await self._request_with_retry(endpoint, headers, payload)
104
+ elapsed_ms = round((time.time() - start_time) * 1000, 2)
105
+ results = [_normalize_result(item) for item in data.get("search_result", []) or []]
106
+ output = {
107
+ "ok": True,
108
+ "query": query,
109
+ "provider": "zhipu",
110
+ "search_engine": payload["search_engine"],
111
+ "results": results,
112
+ "total": len(results),
113
+ "search_intent": data.get("search_intent", []),
114
+ "request_id": data.get("request_id", ""),
115
+ "elapsed_ms": elapsed_ms,
116
+ }
117
+ except Exception as e:
118
+ elapsed_ms = round((time.time() - start_time) * 1000, 2)
119
+ error = _error_payload(e)
120
+ output = {
121
+ "ok": False,
122
+ "query": query,
123
+ "provider": "zhipu",
124
+ "error_type": error["error_type"],
125
+ "error": error["error"],
126
+ "elapsed_ms": elapsed_ms,
127
+ }
128
+ return json.dumps(output, ensure_ascii=False, indent=2)
129
+
130
+ async def _request_with_retry(self, endpoint: str, headers: dict, payload: dict) -> dict[str, Any]:
131
+ timeout = httpx.Timeout(connect=6.0, read=self.timeout, write=10.0, pool=None)
132
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
133
+ async for attempt in AsyncRetrying(
134
+ stop=stop_after_attempt(config.retry_max_attempts + 1),
135
+ wait=wait_random_exponential(multiplier=config.retry_multiplier, max=config.retry_max_wait),
136
+ retry=retry_if_exception(_is_retryable_exception),
137
+ reraise=True,
138
+ ):
139
+ with attempt:
140
+ response = await client.post(endpoint, headers=headers, json=payload)
141
+ response.raise_for_status()
142
+ return response.json()
143
+ return {}
@@ -0,0 +1,230 @@
1
+ import json
2
+ import re
3
+ import time
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ def _elapsed_ms(start: float) -> float:
10
+ return round((time.time() - start) * 1000, 2)
11
+
12
+
13
+ def _error_payload(exc: Exception) -> dict[str, str]:
14
+ if isinstance(exc, httpx.HTTPStatusError):
15
+ status_code = exc.response.status_code
16
+ if status_code in {401, 403}:
17
+ error_type = "auth_error"
18
+ elif status_code == 429:
19
+ error_type = "rate_limited"
20
+ else:
21
+ error_type = "network_error"
22
+ body = (exc.response.text or exc.response.reason_phrase or "")[:300]
23
+ return {"error_type": error_type, "error": f"HTTP {status_code}: {body}"}
24
+ if isinstance(exc, httpx.TimeoutException):
25
+ return {"error_type": "timeout", "error": "request timed out"}
26
+ if isinstance(exc, httpx.RequestError):
27
+ return {"error_type": "network_error", "error": str(exc)}
28
+ return {"error_type": "runtime_error", "error": str(exc)}
29
+
30
+
31
+ def _mask_secret(text: str, secret: str) -> str:
32
+ return text.replace(secret, "***") if secret else text
33
+
34
+
35
+ def _extract_text(result: dict[str, Any]) -> str:
36
+ content = result.get("content") or []
37
+ if isinstance(content, list):
38
+ parts = []
39
+ for item in content:
40
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
41
+ parts.append(item["text"])
42
+ return "\n".join(parts).strip()
43
+ if isinstance(content, str):
44
+ return content.strip()
45
+ return ""
46
+
47
+
48
+ def _parse_sse_or_json(response: httpx.Response) -> dict[str, Any]:
49
+ content_type = response.headers.get("content-type", "")
50
+ if "json" in content_type:
51
+ return response.json()
52
+ data_lines = []
53
+ for line in response.text.splitlines():
54
+ line = line.strip()
55
+ if line.startswith("data:"):
56
+ data_lines.append(line.removeprefix("data:").strip())
57
+ for line in reversed(data_lines):
58
+ if not line or line == "[DONE]":
59
+ continue
60
+ try:
61
+ return json.loads(line)
62
+ except json.JSONDecodeError:
63
+ continue
64
+ return response.json()
65
+
66
+
67
+ def _parse_markdown_results(text: str, provider: str) -> list[dict[str, str]]:
68
+ results: list[dict[str, str]] = []
69
+ current: dict[str, str] | None = None
70
+ for line in text.splitlines():
71
+ heading = re.match(r"^#{2,4}\s+(?:\d+[.)]\s*)?(.+?)\s*$", line.strip())
72
+ if heading:
73
+ if current:
74
+ results.append(current)
75
+ current = {"title": heading.group(1).strip(), "url": "", "description": "", "provider": provider}
76
+ continue
77
+ url_match = re.search(r"https?://[^\s)>\]]+", line)
78
+ if url_match:
79
+ if current is None:
80
+ current = {"title": url_match.group(0), "url": "", "description": "", "provider": provider}
81
+ current["url"] = current.get("url") or url_match.group(0).rstrip(".,")
82
+ continue
83
+ if current is not None and line.strip() and not line.startswith("#"):
84
+ current["description"] = (current.get("description", "") + " " + line.strip()).strip()
85
+ if current:
86
+ results.append(current)
87
+ if results:
88
+ return results
89
+ urls = re.findall(r"https?://[^\s)>\]]+", text)
90
+ return [{"title": url, "url": url, "description": "", "provider": provider} for url in dict.fromkeys(urls)]
91
+
92
+
93
+ def _content_error(text: str) -> tuple[str, str] | None:
94
+ stripped = (text or "").strip()
95
+ if not stripped:
96
+ return None
97
+ if stripped.lower().startswith("mcp error"):
98
+ lowered = stripped.lower()
99
+ error_type = "auth_error" if "-401" in stripped or "api key" in lowered else "provider_error"
100
+ return error_type, stripped
101
+ decoded: Any = stripped
102
+ for _ in range(2):
103
+ if not isinstance(decoded, str):
104
+ break
105
+ try:
106
+ decoded = json.loads(decoded)
107
+ except json.JSONDecodeError:
108
+ break
109
+ if isinstance(decoded, dict) and decoded.get("error"):
110
+ return "provider_error", str(decoded.get("error"))
111
+ return None
112
+
113
+
114
+ class ZhipuMCPProvider:
115
+ def __init__(self, api_url: str, api_key: str, timeout: float = 30.0, provider_id: str = "zhipu-mcp"):
116
+ self.api_url = api_url.rstrip("/")
117
+ self.api_key = api_key
118
+ self.timeout = timeout
119
+ self.provider_id = provider_id
120
+
121
+ async def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
122
+ start = time.time()
123
+ if not self.api_key:
124
+ return json.dumps(
125
+ {
126
+ "ok": False,
127
+ "provider": self.provider_id,
128
+ "tool": name,
129
+ "error_type": "config_error",
130
+ "error": "ZHIPU_MCP_API_KEY is not configured.",
131
+ "elapsed_ms": _elapsed_ms(start),
132
+ },
133
+ ensure_ascii=False,
134
+ indent=2,
135
+ )
136
+
137
+ payload = {
138
+ "jsonrpc": "2.0",
139
+ "id": 1,
140
+ "method": "tools/call",
141
+ "params": {"name": name, "arguments": arguments},
142
+ }
143
+ headers = {
144
+ "Authorization": f"Bearer {self.api_key}",
145
+ "Content-Type": "application/json",
146
+ "Accept": "application/json, text/event-stream",
147
+ }
148
+
149
+ try:
150
+ timeout = httpx.Timeout(connect=6.0, read=self.timeout, write=10.0, pool=None)
151
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
152
+ response = await client.post(self.api_url, headers=headers, json=payload)
153
+ response.raise_for_status()
154
+ data = _parse_sse_or_json(response)
155
+ output = self._normalize_response(name, arguments, data, start)
156
+ except Exception as e:
157
+ error = _error_payload(e)
158
+ output = {
159
+ "ok": False,
160
+ "provider": self.provider_id,
161
+ "tool": name,
162
+ "error_type": error["error_type"],
163
+ "error": _mask_secret(error["error"], self.api_key),
164
+ "elapsed_ms": _elapsed_ms(start),
165
+ }
166
+ return json.dumps(output, ensure_ascii=False, indent=2)
167
+
168
+ def _normalize_response(self, name: str, arguments: dict[str, Any], data: dict[str, Any], start: float) -> dict[str, Any]:
169
+ if "error" in data:
170
+ error = data.get("error") or {}
171
+ message = error.get("message") if isinstance(error, dict) else str(error)
172
+ return {
173
+ "ok": False,
174
+ "provider": self.provider_id,
175
+ "tool": name,
176
+ "error_type": "provider_error",
177
+ "error": message or "Zhipu MCP JSON-RPC error",
178
+ "elapsed_ms": _elapsed_ms(start),
179
+ }
180
+
181
+ result = data.get("result") or {}
182
+ text = _extract_text(result)
183
+ content_error = _content_error(text)
184
+ is_error = bool(result.get("isError")) or bool(content_error)
185
+ output: dict[str, Any] = {
186
+ "ok": not is_error,
187
+ "provider": self.provider_id,
188
+ "tool": name,
189
+ "content": text,
190
+ "raw_content": text,
191
+ "elapsed_ms": _elapsed_ms(start),
192
+ }
193
+ for key in ("query", "url", "repo", "path", "ref"):
194
+ if arguments.get(key):
195
+ output[key] = arguments[key]
196
+ if name == "webReader":
197
+ output["url"] = arguments.get("url", "")
198
+ else:
199
+ results = [] if is_error else _parse_markdown_results(text, self.provider_id)
200
+ output["results"] = results
201
+ output["total"] = len(results)
202
+ if is_error:
203
+ output["error_type"] = content_error[0] if content_error else "provider_error"
204
+ output["error"] = content_error[1] if content_error else (text or "Zhipu MCP tool returned isError=true")
205
+ return output
206
+
207
+ async def web_search(self, query: str, count: int = 5) -> str:
208
+ del count
209
+ return await self.call_tool("web_search_prime", {"search_query": query})
210
+
211
+ async def web_reader(self, url: str) -> str:
212
+ return await self.call_tool("webReader", {"url": url})
213
+
214
+ async def search_doc(self, repo: str, query: str, max_results: int = 5) -> str:
215
+ del max_results
216
+ return await self.call_tool("search_doc", {"repo_name": repo, "query": query})
217
+
218
+ async def get_repo_structure(self, repo: str, path: str = "", ref: str = "") -> str:
219
+ arguments = {"repo_name": repo}
220
+ if path:
221
+ arguments["dir_path"] = path
222
+ if ref:
223
+ arguments["ref"] = ref
224
+ return await self.call_tool("get_repo_structure", arguments)
225
+
226
+ async def read_file(self, repo: str, path: str, ref: str = "") -> str:
227
+ arguments = {"repo_name": repo, "file_path": path}
228
+ if ref:
229
+ arguments["ref"] = ref
230
+ return await self.call_tool("read_file", arguments)