@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.
- package/CHANGELOG.md +58 -0
- package/README.md +45 -23
- package/dist/configurators/workflow.d.ts +1 -1
- package/dist/configurators/workflow.d.ts.map +1 -1
- package/dist/configurators/workflow.js +20 -9
- package/dist/configurators/workflow.js.map +1 -1
- package/dist/migrations/manifests/0.1.3.json +9 -0
- package/dist/migrations/manifests/0.1.4.json +9 -0
- package/dist/templates/markdown/agents.md +1 -1
- package/dist/templates/markdown/index.d.ts +1 -0
- package/dist/templates/markdown/index.d.ts.map +1 -1
- package/dist/templates/markdown/index.js +1 -0
- package/dist/templates/markdown/index.js.map +1 -1
- package/dist/templates/markdown/prompts/run-semantic-slice-12.md.txt +13 -29
- package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +0 -49
- package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +0 -85
- package/dist/templates/markdown/spec/guides/cursor-semantic-compliance.md.txt +7 -21
- package/dist/templates/markdown/spec/guides/cursor-subagent-policy.md.txt +6 -10
- package/dist/templates/markdown/spec/guides/retrieval-daily-guide.md.txt +7 -13
- package/dist/templates/shared-hooks/inject-retrieval-plan.py +42 -23
- package/dist/templates/shared-hooks/inject-subagent-context.py +6 -2
- package/dist/templates/shared-hooks/session-start.py +17 -9
- package/dist/templates/trellis/index.d.ts +7 -4
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +11 -7
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/common/codebase_retrieval_router.py +2 -2
- package/dist/templates/trellis/scripts/common/config.py +1 -1
- package/dist/templates/trellis/scripts/common/parent_orchestration.py +27 -15
- package/dist/templates/trellis/scripts/common/retrieval_agent_instructions.py +1 -1
- package/dist/templates/trellis/scripts/common/smart_search_resolve.py +28 -3
- package/dist/templates/trellis/scripts/common/task_gates.py +372 -73
- package/dist/templates/trellis/scripts/common/task_map.py +29 -1
- package/dist/templates/trellis/workflow.md +9 -53
- package/package.json +5 -44
- package/bin/smart-search.js +0 -79
- package/dist/templates/trellis/scripts/aggregate_retrieval_telemetry.py +0 -342
- package/dist/templates/trellis/scripts/batch_plan_envelope.py +0 -105
- package/vendor/smart-search/LICENSE +0 -21
- package/vendor/smart-search/README.md +0 -412
- package/vendor/smart-search/README.zh-CN.md +0 -430
- package/vendor/smart-search/npm/bin/smart-search.js +0 -63
- package/vendor/smart-search/npm/scripts/postinstall.js +0 -87
- package/vendor/smart-search/npm/scripts/resolve-prerelease-version.js +0 -108
- package/vendor/smart-search/npm/scripts/set-package-version.js +0 -35
- package/vendor/smart-search/npm/scripts/sync-python-version.js +0 -22
- package/vendor/smart-search/npm/scripts/test-wrapper-repair.js +0 -99
- package/vendor/smart-search/npm/scripts/test.js +0 -85
- package/vendor/smart-search/package.json +0 -42
- package/vendor/smart-search/pyproject.toml +0 -37
- package/vendor/smart-search/skills/smart-search-cli/SKILL.md +0 -387
- package/vendor/smart-search/skills/smart-search-cli/agents/openai.yaml +0 -3
- package/vendor/smart-search/skills/smart-search-cli/examples/batch-search.md +0 -98
- package/vendor/smart-search/skills/smart-search-cli/examples/evidence-gathering.md +0 -89
- package/vendor/smart-search/skills/smart-search-cli/references/cli-contract.md +0 -337
- package/vendor/smart-search/src/smart_search/__init__.py +0 -1
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/SKILL.md +0 -387
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/agents/openai.yaml +0 -3
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/batch-search.md +0 -98
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/examples/evidence-gathering.md +0 -89
- package/vendor/smart-search/src/smart_search/assets/skills/smart-search-cli/references/cli-contract.md +0 -337
- package/vendor/smart-search/src/smart_search/cli.py +0 -2003
- package/vendor/smart-search/src/smart_search/config.py +0 -580
- package/vendor/smart-search/src/smart_search/logger.py +0 -43
- package/vendor/smart-search/src/smart_search/providers/__init__.py +0 -16
- package/vendor/smart-search/src/smart_search/providers/base.py +0 -41
- package/vendor/smart-search/src/smart_search/providers/context7.py +0 -141
- package/vendor/smart-search/src/smart_search/providers/exa.py +0 -206
- package/vendor/smart-search/src/smart_search/providers/jina.py +0 -136
- package/vendor/smart-search/src/smart_search/providers/openai_compatible.py +0 -370
- package/vendor/smart-search/src/smart_search/providers/zhipu.py +0 -143
- package/vendor/smart-search/src/smart_search/service.py +0 -2865
- package/vendor/smart-search/src/smart_search/sources.py +0 -429
- package/vendor/smart-search/src/smart_search/utils.py +0 -220
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
import httpx
|
|
2
|
-
import json
|
|
3
|
-
import logging
|
|
4
|
-
from datetime import datetime, timezone
|
|
5
|
-
from email.utils import parsedate_to_datetime
|
|
6
|
-
from typing import List, Optional
|
|
7
|
-
from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
|
|
8
|
-
from tenacity.wait import wait_base
|
|
9
|
-
from .base import BaseSearchProvider, SearchResult
|
|
10
|
-
from ..utils import search_prompt, fetch_prompt, url_describe_prompt, rank_sources_prompt
|
|
11
|
-
from ..logger import log_info
|
|
12
|
-
from ..config import config
|
|
13
|
-
|
|
14
|
-
_logger = logging.getLogger(__name__)
|
|
15
|
-
_ssl_warning_emitted = False
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def get_local_time_info() -> str:
|
|
19
|
-
try:
|
|
20
|
-
local_tz = datetime.now().astimezone().tzinfo
|
|
21
|
-
local_now = datetime.now(local_tz)
|
|
22
|
-
except Exception:
|
|
23
|
-
local_now = datetime.now(timezone.utc)
|
|
24
|
-
|
|
25
|
-
weekdays_cn = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
|
|
26
|
-
weekday = weekdays_cn[local_now.weekday()]
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
f"[Current Time Context]\n"
|
|
30
|
-
f"- Date: {local_now.strftime('%Y-%m-%d')} ({weekday})\n"
|
|
31
|
-
f"- Time: {local_now.strftime('%H:%M:%S')}\n"
|
|
32
|
-
f"- Timezone: {local_now.tzname() or 'Local'}\n"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def _is_retryable_exception(exc) -> bool:
|
|
40
|
-
if isinstance(exc, (httpx.TimeoutException, httpx.NetworkError, httpx.ConnectError, httpx.RemoteProtocolError)):
|
|
41
|
-
return True
|
|
42
|
-
if isinstance(exc, httpx.HTTPStatusError):
|
|
43
|
-
return exc.response.status_code in RETRYABLE_STATUS_CODES
|
|
44
|
-
return False
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
class _WaitWithRetryAfter(wait_base):
|
|
48
|
-
|
|
49
|
-
def __init__(self, multiplier: float, max_wait: int):
|
|
50
|
-
self._base_wait = wait_random_exponential(multiplier=multiplier, max=max_wait)
|
|
51
|
-
self._protocol_error_base = 3.0
|
|
52
|
-
|
|
53
|
-
def __call__(self, retry_state):
|
|
54
|
-
if retry_state.outcome and retry_state.outcome.failed:
|
|
55
|
-
exc = retry_state.outcome.exception()
|
|
56
|
-
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
|
|
57
|
-
retry_after = self._parse_retry_after(exc.response)
|
|
58
|
-
if retry_after is not None:
|
|
59
|
-
return retry_after
|
|
60
|
-
if isinstance(exc, httpx.RemoteProtocolError):
|
|
61
|
-
return self._base_wait(retry_state) + self._protocol_error_base
|
|
62
|
-
return self._base_wait(retry_state)
|
|
63
|
-
|
|
64
|
-
def _parse_retry_after(self, response: httpx.Response) -> Optional[float]:
|
|
65
|
-
header = response.headers.get("Retry-After")
|
|
66
|
-
if not header:
|
|
67
|
-
return None
|
|
68
|
-
header = header.strip()
|
|
69
|
-
|
|
70
|
-
if header.isdigit():
|
|
71
|
-
return float(header)
|
|
72
|
-
|
|
73
|
-
try:
|
|
74
|
-
retry_dt = parsedate_to_datetime(header)
|
|
75
|
-
if retry_dt.tzinfo is None:
|
|
76
|
-
retry_dt = retry_dt.replace(tzinfo=timezone.utc)
|
|
77
|
-
delay = (retry_dt - datetime.now(timezone.utc)).total_seconds()
|
|
78
|
-
return max(0.0, delay)
|
|
79
|
-
except (TypeError, ValueError):
|
|
80
|
-
return None
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class OpenAICompatibleSearchProvider(BaseSearchProvider):
|
|
84
|
-
def __init__(self, api_url: str, api_key: str, model: str = "grok-4-fast", stream: bool = False):
|
|
85
|
-
super().__init__(api_url, api_key)
|
|
86
|
-
self.model = model
|
|
87
|
-
self.stream = stream
|
|
88
|
-
|
|
89
|
-
def get_provider_name(self) -> str:
|
|
90
|
-
return "OpenAI-compatible"
|
|
91
|
-
|
|
92
|
-
def _build_api_headers(self) -> dict:
|
|
93
|
-
return {
|
|
94
|
-
"Authorization": f"Bearer {self.api_key}",
|
|
95
|
-
"Content-Type": "application/json",
|
|
96
|
-
"Accept": "application/json, text/event-stream",
|
|
97
|
-
"User-Agent": "smart-search/0.1.0",
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
def _get_ssl_verify(self) -> bool:
|
|
101
|
-
global _ssl_warning_emitted
|
|
102
|
-
verify = config.ssl_verify_enabled
|
|
103
|
-
if not verify and not _ssl_warning_emitted:
|
|
104
|
-
_ssl_warning_emitted = True
|
|
105
|
-
_logger.warning("SSL_VERIFY=false: OpenAI-compatible API 请求已禁用 SSL 证书验证,存在安全风险")
|
|
106
|
-
return verify
|
|
107
|
-
|
|
108
|
-
async def search(self, query: str, platform: str = "", ctx=None) -> List[SearchResult]:
|
|
109
|
-
headers = self._build_api_headers()
|
|
110
|
-
platform_prompt = ""
|
|
111
|
-
|
|
112
|
-
if platform:
|
|
113
|
-
platform_prompt = "\n\nYou should search the web for the information you need, and focus on these platform: " + platform + "\n"
|
|
114
|
-
|
|
115
|
-
time_context = get_local_time_info() + "\n"
|
|
116
|
-
|
|
117
|
-
payload = {
|
|
118
|
-
"model": self.model,
|
|
119
|
-
"messages": [
|
|
120
|
-
{
|
|
121
|
-
"role": "system",
|
|
122
|
-
"content": search_prompt,
|
|
123
|
-
},
|
|
124
|
-
{"role": "user", "content": time_context + query + platform_prompt},
|
|
125
|
-
],
|
|
126
|
-
"stream": self.stream,
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
await log_info(ctx, f"platform_prompt: { query + platform_prompt}", config.debug_enabled)
|
|
130
|
-
|
|
131
|
-
if self.stream:
|
|
132
|
-
return await self._execute_stream_with_retry(headers, payload, ctx)
|
|
133
|
-
return await self._execute_completion_with_retry(headers, payload, ctx)
|
|
134
|
-
|
|
135
|
-
async def fetch(self, url: str, ctx=None) -> str:
|
|
136
|
-
headers = self._build_api_headers()
|
|
137
|
-
payload = {
|
|
138
|
-
"model": self.model,
|
|
139
|
-
"messages": [
|
|
140
|
-
{
|
|
141
|
-
"role": "system",
|
|
142
|
-
"content": fetch_prompt,
|
|
143
|
-
},
|
|
144
|
-
{"role": "user", "content": url + "\n获取该网页内容并返回其结构化Markdown格式" },
|
|
145
|
-
],
|
|
146
|
-
"stream": self.stream,
|
|
147
|
-
}
|
|
148
|
-
if self.stream:
|
|
149
|
-
return await self._execute_stream_with_retry(headers, payload, ctx)
|
|
150
|
-
return await self._execute_completion_with_retry(headers, payload, ctx)
|
|
151
|
-
|
|
152
|
-
async def _parse_streaming_response(self, response, ctx=None) -> str:
|
|
153
|
-
content = ""
|
|
154
|
-
full_body_buffer = []
|
|
155
|
-
|
|
156
|
-
async for line in response.aiter_lines():
|
|
157
|
-
line = line.strip()
|
|
158
|
-
if not line:
|
|
159
|
-
continue
|
|
160
|
-
|
|
161
|
-
full_body_buffer.append(line)
|
|
162
|
-
|
|
163
|
-
if line.startswith("data:"):
|
|
164
|
-
if line in ("data: [DONE]", "data:[DONE]"):
|
|
165
|
-
continue
|
|
166
|
-
try:
|
|
167
|
-
json_str = line[5:].lstrip()
|
|
168
|
-
data = json.loads(json_str)
|
|
169
|
-
choices = data.get("choices", [])
|
|
170
|
-
if choices and len(choices) > 0:
|
|
171
|
-
delta = choices[0].get("delta", {})
|
|
172
|
-
if "content" in delta:
|
|
173
|
-
content += delta["content"]
|
|
174
|
-
except (json.JSONDecodeError, IndexError):
|
|
175
|
-
continue
|
|
176
|
-
|
|
177
|
-
if not content and full_body_buffer:
|
|
178
|
-
try:
|
|
179
|
-
full_text = "".join(full_body_buffer)
|
|
180
|
-
data = json.loads(full_text)
|
|
181
|
-
if "choices" in data and len(data["choices"]) > 0:
|
|
182
|
-
message = data["choices"][0].get("message", {})
|
|
183
|
-
content = message.get("content", "")
|
|
184
|
-
except json.JSONDecodeError:
|
|
185
|
-
pass
|
|
186
|
-
|
|
187
|
-
await log_info(ctx, f"content: {content}", config.debug_enabled)
|
|
188
|
-
|
|
189
|
-
return content
|
|
190
|
-
|
|
191
|
-
async def _execute_stream_with_retry(self, headers: dict, payload: dict, ctx=None) -> str:
|
|
192
|
-
timeout = httpx.Timeout(connect=6.0, read=120.0, write=10.0, pool=None)
|
|
193
|
-
|
|
194
|
-
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=self._get_ssl_verify()) as client:
|
|
195
|
-
async for attempt in AsyncRetrying(
|
|
196
|
-
stop=stop_after_attempt(config.retry_max_attempts + 1),
|
|
197
|
-
wait=_WaitWithRetryAfter(config.retry_multiplier, config.retry_max_wait),
|
|
198
|
-
retry=retry_if_exception(_is_retryable_exception),
|
|
199
|
-
reraise=True,
|
|
200
|
-
):
|
|
201
|
-
with attempt:
|
|
202
|
-
async with client.stream(
|
|
203
|
-
"POST",
|
|
204
|
-
f"{self.api_url}/chat/completions",
|
|
205
|
-
headers=headers,
|
|
206
|
-
json=payload,
|
|
207
|
-
) as response:
|
|
208
|
-
response.raise_for_status()
|
|
209
|
-
return await self._parse_streaming_response(response, ctx)
|
|
210
|
-
|
|
211
|
-
async def _parse_completion_response(self, response: httpx.Response, ctx=None) -> str:
|
|
212
|
-
"""解析非流式 completion 响应,兼容 JSON 和 SSE 文本 fallback"""
|
|
213
|
-
content = ""
|
|
214
|
-
body_text = response.text or ""
|
|
215
|
-
sources: list[dict] = []
|
|
216
|
-
|
|
217
|
-
try:
|
|
218
|
-
data = response.json()
|
|
219
|
-
except Exception:
|
|
220
|
-
data = None
|
|
221
|
-
|
|
222
|
-
if isinstance(data, dict):
|
|
223
|
-
sources = self._extract_citations(data)
|
|
224
|
-
choices = data.get("choices", [])
|
|
225
|
-
if choices:
|
|
226
|
-
message = choices[0].get("message", {})
|
|
227
|
-
if isinstance(message, dict):
|
|
228
|
-
content = message.get("content", "") or ""
|
|
229
|
-
message_citations = self._normalize_citations(message.get("citations"))
|
|
230
|
-
if message_citations:
|
|
231
|
-
sources = self._merge_citations(sources, message_citations)
|
|
232
|
-
|
|
233
|
-
# SSE fallback: 部分中转站即使设置 stream=False 仍可能返回 SSE 格式
|
|
234
|
-
if not content and body_text.lstrip().startswith("data:"):
|
|
235
|
-
class _LineResponse:
|
|
236
|
-
def __init__(self, text: str):
|
|
237
|
-
self._lines = text.splitlines()
|
|
238
|
-
|
|
239
|
-
async def aiter_lines(self):
|
|
240
|
-
for line in self._lines:
|
|
241
|
-
yield line
|
|
242
|
-
|
|
243
|
-
content = await self._parse_streaming_response(_LineResponse(body_text), ctx)
|
|
244
|
-
|
|
245
|
-
if content and sources:
|
|
246
|
-
content = f"{content.rstrip()}\n\nsources({json.dumps(sources, ensure_ascii=False)})"
|
|
247
|
-
|
|
248
|
-
await log_info(ctx, f"content: {content}", config.debug_enabled)
|
|
249
|
-
|
|
250
|
-
return content
|
|
251
|
-
|
|
252
|
-
def _extract_citations(self, data: dict) -> list[dict]:
|
|
253
|
-
sources = self._normalize_citations(data.get("citations"))
|
|
254
|
-
for choice in data.get("choices", []) or []:
|
|
255
|
-
if not isinstance(choice, dict):
|
|
256
|
-
continue
|
|
257
|
-
message = choice.get("message")
|
|
258
|
-
if isinstance(message, dict):
|
|
259
|
-
sources = self._merge_citations(sources, self._normalize_citations(message.get("citations")))
|
|
260
|
-
return sources
|
|
261
|
-
|
|
262
|
-
def _normalize_citations(self, citations) -> list[dict]:
|
|
263
|
-
if not citations:
|
|
264
|
-
return []
|
|
265
|
-
if not isinstance(citations, list):
|
|
266
|
-
citations = [citations]
|
|
267
|
-
|
|
268
|
-
normalized: list[dict] = []
|
|
269
|
-
seen: set[str] = set()
|
|
270
|
-
for item in citations:
|
|
271
|
-
source: dict = {}
|
|
272
|
-
if isinstance(item, str):
|
|
273
|
-
url = item.strip()
|
|
274
|
-
if not url.startswith(("http://", "https://")):
|
|
275
|
-
continue
|
|
276
|
-
source["url"] = url
|
|
277
|
-
elif isinstance(item, dict):
|
|
278
|
-
url = item.get("url") or item.get("href") or item.get("link")
|
|
279
|
-
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
280
|
-
continue
|
|
281
|
-
source["url"] = url
|
|
282
|
-
title = item.get("title") or item.get("name") or item.get("label")
|
|
283
|
-
if isinstance(title, str) and title.strip():
|
|
284
|
-
source["title"] = title.strip()
|
|
285
|
-
else:
|
|
286
|
-
continue
|
|
287
|
-
|
|
288
|
-
if source["url"] in seen:
|
|
289
|
-
continue
|
|
290
|
-
seen.add(source["url"])
|
|
291
|
-
normalized.append(source)
|
|
292
|
-
return normalized
|
|
293
|
-
|
|
294
|
-
def _merge_citations(self, *source_lists: list[dict]) -> list[dict]:
|
|
295
|
-
merged: list[dict] = []
|
|
296
|
-
seen: set[str] = set()
|
|
297
|
-
for source_list in source_lists:
|
|
298
|
-
for item in source_list or []:
|
|
299
|
-
url = item.get("url")
|
|
300
|
-
if not isinstance(url, str) or not url or url in seen:
|
|
301
|
-
continue
|
|
302
|
-
seen.add(url)
|
|
303
|
-
merged.append(item)
|
|
304
|
-
return merged
|
|
305
|
-
|
|
306
|
-
async def _execute_completion_with_retry(self, headers: dict, payload: dict, ctx=None) -> str:
|
|
307
|
-
"""执行带重试机制的非流式 HTTP 请求,兼容上游返回 JSON 或 SSE 文本"""
|
|
308
|
-
timeout = httpx.Timeout(connect=6.0, read=120.0, write=10.0, pool=None)
|
|
309
|
-
|
|
310
|
-
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, verify=self._get_ssl_verify()) as client:
|
|
311
|
-
async for attempt in AsyncRetrying(
|
|
312
|
-
stop=stop_after_attempt(config.retry_max_attempts + 1),
|
|
313
|
-
wait=_WaitWithRetryAfter(config.retry_multiplier, config.retry_max_wait),
|
|
314
|
-
retry=retry_if_exception(_is_retryable_exception),
|
|
315
|
-
reraise=True,
|
|
316
|
-
):
|
|
317
|
-
with attempt:
|
|
318
|
-
response = await client.post(
|
|
319
|
-
f"{self.api_url}/chat/completions",
|
|
320
|
-
headers=headers,
|
|
321
|
-
json=payload,
|
|
322
|
-
)
|
|
323
|
-
response.raise_for_status()
|
|
324
|
-
return await self._parse_completion_response(response, ctx)
|
|
325
|
-
|
|
326
|
-
async def describe_url(self, url: str, ctx=None) -> dict:
|
|
327
|
-
headers = self._build_api_headers()
|
|
328
|
-
payload = {
|
|
329
|
-
"model": self.model,
|
|
330
|
-
"messages": [
|
|
331
|
-
{"role": "system", "content": url_describe_prompt},
|
|
332
|
-
{"role": "user", "content": url},
|
|
333
|
-
],
|
|
334
|
-
"stream": False,
|
|
335
|
-
}
|
|
336
|
-
result = await self._execute_completion_with_retry(headers, payload, ctx)
|
|
337
|
-
title, extracts = url, ""
|
|
338
|
-
for line in result.strip().splitlines():
|
|
339
|
-
if line.startswith("Title:"):
|
|
340
|
-
title = line[6:].strip() or url
|
|
341
|
-
elif line.startswith("Extracts:"):
|
|
342
|
-
extracts = line[9:].strip()
|
|
343
|
-
return {"title": title, "extracts": extracts, "url": url}
|
|
344
|
-
|
|
345
|
-
async def rank_sources(self, query: str, sources_text: str, total: int, ctx=None) -> list[int]:
|
|
346
|
-
"""让 OpenAI-compatible 模型按查询相关度对信源排序,返回排序后的序号列表"""
|
|
347
|
-
headers = self._build_api_headers()
|
|
348
|
-
payload = {
|
|
349
|
-
"model": self.model,
|
|
350
|
-
"messages": [
|
|
351
|
-
{"role": "system", "content": rank_sources_prompt},
|
|
352
|
-
{"role": "user", "content": f"Query: {query}\n\n{sources_text}"},
|
|
353
|
-
],
|
|
354
|
-
"stream": False,
|
|
355
|
-
}
|
|
356
|
-
result = await self._execute_completion_with_retry(headers, payload, ctx)
|
|
357
|
-
order: list[int] = []
|
|
358
|
-
seen: set[int] = set()
|
|
359
|
-
for token in result.strip().split():
|
|
360
|
-
try:
|
|
361
|
-
n = int(token)
|
|
362
|
-
if 1 <= n <= total and n not in seen:
|
|
363
|
-
seen.add(n)
|
|
364
|
-
order.append(n)
|
|
365
|
-
except ValueError:
|
|
366
|
-
continue
|
|
367
|
-
for i in range(1, total + 1):
|
|
368
|
-
if i not in seen:
|
|
369
|
-
order.append(i)
|
|
370
|
-
return order
|
|
@@ -1,143 +0,0 @@
|
|
|
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 {}
|