@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.
- package/LICENSE +21 -0
- package/README.md +188 -0
- package/README.zh-CN.md +180 -0
- package/npm/bin/smart-search.js +68 -0
- package/npm/scripts/postinstall.js +87 -0
- package/npm/scripts/resolve-prerelease-version.js +108 -0
- package/npm/scripts/set-package-version.js +35 -0
- package/npm/scripts/sync-python-version.js +22 -0
- package/npm/scripts/test-wrapper-repair.js +137 -0
- package/npm/scripts/test.js +76 -0
- package/package.json +42 -0
- package/pyproject.toml +36 -0
- package/skills/smart-search-cli/SKILL.md +41 -0
- package/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/skills/smart-search-cli/references/cli-core.md +5 -0
- package/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/skills/smart-search-cli/references/regression-release.md +28 -0
- package/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/__init__.py +1 -0
- package/src/smart_search/assets/skills/smart-search-cli/SKILL.md +41 -0
- package/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +7 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/cli-core.md +5 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/command-patterns.md +12 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/provider-routing.md +3 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/regression-release.md +28 -0
- package/src/smart_search/assets/skills/smart-search-cli/references/setup-config.md +3 -0
- package/src/smart_search/cli.py +3118 -0
- package/src/smart_search/config.py +809 -0
- package/src/smart_search/embedding_presets.py +40 -0
- package/src/smart_search/intent_router.py +757 -0
- package/src/smart_search/logger.py +43 -0
- package/src/smart_search/providers/__init__.py +20 -0
- package/src/smart_search/providers/base.py +41 -0
- package/src/smart_search/providers/context7.py +141 -0
- package/src/smart_search/providers/exa.py +206 -0
- package/src/smart_search/providers/jina.py +136 -0
- package/src/smart_search/providers/openai_compatible.py +541 -0
- package/src/smart_search/providers/xai_responses.py +117 -0
- package/src/smart_search/providers/zhipu.py +143 -0
- package/src/smart_search/providers/zhipu_mcp.py +230 -0
- package/src/smart_search/service.py +3445 -0
- package/src/smart_search/skill_installer.py +342 -0
- package/src/smart_search/sources.py +429 -0
- 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)
|