@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,429 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
|
|
10
|
+
from .config import config
|
|
11
|
+
from .utils import extract_unique_urls
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_MD_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\((https?://[^)]+)\)")
|
|
15
|
+
_INLINE_CITATION_LINK_PATTERN = re.compile(r"\[\[(\d+)\]\]\((https?://[^)]+)\)")
|
|
16
|
+
_SOURCES_HEADING_PATTERN = re.compile(
|
|
17
|
+
r"(?im)^"
|
|
18
|
+
r"(?:#{1,6}\s*)?"
|
|
19
|
+
r"(?:\*\*|__)?\s*"
|
|
20
|
+
r"(sources?|references?|citations?|信源|参考资料|参考|引用|来源列表|来源)"
|
|
21
|
+
r"\s*(?:\*\*|__)?"
|
|
22
|
+
r"(?:\s*[((][^)\n]*[))])?"
|
|
23
|
+
r"\s*[::]?\s*$"
|
|
24
|
+
)
|
|
25
|
+
_SOURCES_FUNCTION_PATTERN = re.compile(
|
|
26
|
+
r"(?im)(^|\n)\s*(sources|source|citations|citation|references|reference|citation_card|source_cards|source_card)\s*\("
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_THINK_BLOCK_PATTERN = re.compile(r"<think>[\s\S]*?</think>", re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
_LEADING_POLICY_PATTERNS = [
|
|
32
|
+
re.compile(r"^I\s+cannot\s+(comply|assist|help|provide|fulfill)", re.IGNORECASE),
|
|
33
|
+
re.compile(r"^I\s+(can't|won't|will\s+not|am\s+unable\s+to)\s+(comply|assist|help|provide|fulfill)", re.IGNORECASE),
|
|
34
|
+
re.compile(r"^I('m|\s+am)\s+not\s+able\s+to", re.IGNORECASE),
|
|
35
|
+
re.compile(r"^(Sorry|Apologies|I\s+apologize),?\s+(but\s+)?I\s+(cannot|can't|won't)", re.IGNORECASE),
|
|
36
|
+
re.compile(r"^As\s+an?\s+AI(\s+language\s+model)?", re.IGNORECASE),
|
|
37
|
+
re.compile(r"^我(无法|不能|没有办法)(遵从|遵守|协助|帮助|提供|满足)", re.IGNORECASE),
|
|
38
|
+
re.compile(r"^(抱歉|对不起|很遗憾)[,,]?\s*我(无法|不能)", re.IGNORECASE),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_POLICY_META_KEYWORDS = {
|
|
42
|
+
"policy", "policies", "guideline", "guidelines", "content policy",
|
|
43
|
+
"usage policy", "terms of service", "acceptable use",
|
|
44
|
+
"策略", "政策", "准则", "使用条款", "服务条款",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_POLICY_CONTEXT_KEYWORDS = {
|
|
48
|
+
"prompt injection", "jailbreak", "system prompt", "hidden instruction",
|
|
49
|
+
"bypass", "override", "ignore previous",
|
|
50
|
+
"提示注入", "越狱", "系统提示", "隐藏指令",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_policy_text(text: str) -> str:
|
|
55
|
+
text = re.sub(r"[*_`#~\[\](){}]", "", text)
|
|
56
|
+
return text.lower().strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _split_paragraphs(text: str) -> list[str]:
|
|
60
|
+
return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _looks_like_policy_block(text: str) -> bool:
|
|
64
|
+
normalized = _normalize_policy_text(text)
|
|
65
|
+
if not normalized:
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
for pattern in _LEADING_POLICY_PATTERNS:
|
|
69
|
+
if pattern.search(normalized):
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
meta_count = sum(1 for kw in _POLICY_META_KEYWORDS if kw in normalized)
|
|
73
|
+
ctx_count = sum(1 for kw in _POLICY_CONTEXT_KEYWORDS if kw in normalized)
|
|
74
|
+
if meta_count >= 2 or ctx_count >= 2 or (meta_count >= 1 and ctx_count >= 1):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sanitize_answer_text(text: str) -> str:
|
|
81
|
+
text = _THINK_BLOCK_PATTERN.sub("", text or "").strip()
|
|
82
|
+
if not text:
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
paragraphs = _split_paragraphs(text)
|
|
86
|
+
cleaned: list[str] = []
|
|
87
|
+
leading = True
|
|
88
|
+
for para in paragraphs:
|
|
89
|
+
if leading and _looks_like_policy_block(para):
|
|
90
|
+
continue
|
|
91
|
+
leading = False
|
|
92
|
+
cleaned.append(para)
|
|
93
|
+
|
|
94
|
+
return "\n\n".join(cleaned).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def new_session_id() -> str:
|
|
98
|
+
return uuid.uuid4().hex[:12]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SourcesCache:
|
|
102
|
+
def __init__(self, max_size: int = 256):
|
|
103
|
+
self._max_size = max_size
|
|
104
|
+
self._lock = asyncio.Lock()
|
|
105
|
+
self._cache: OrderedDict[str, list[dict]] = OrderedDict()
|
|
106
|
+
|
|
107
|
+
async def set(self, session_id: str, sources: list[dict]) -> None:
|
|
108
|
+
async with self._lock:
|
|
109
|
+
self._cache[session_id] = sources
|
|
110
|
+
self._cache.move_to_end(session_id)
|
|
111
|
+
while len(self._cache) > self._max_size:
|
|
112
|
+
self._cache.popitem(last=False)
|
|
113
|
+
|
|
114
|
+
async def get(self, session_id: str) -> list[dict] | None:
|
|
115
|
+
async with self._lock:
|
|
116
|
+
sources = self._cache.get(session_id)
|
|
117
|
+
if sources is None:
|
|
118
|
+
return None
|
|
119
|
+
self._cache.move_to_end(session_id)
|
|
120
|
+
return sources
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def merge_sources(*source_lists: list[dict]) -> list[dict]:
|
|
124
|
+
seen: set[str] = set()
|
|
125
|
+
merged: list[dict] = []
|
|
126
|
+
for sources in source_lists:
|
|
127
|
+
for item in sources or []:
|
|
128
|
+
url = (item or {}).get("url")
|
|
129
|
+
if not isinstance(url, str) or not url.strip():
|
|
130
|
+
continue
|
|
131
|
+
url = url.strip()
|
|
132
|
+
if url in seen:
|
|
133
|
+
continue
|
|
134
|
+
seen.add(url)
|
|
135
|
+
merged.append(item)
|
|
136
|
+
return merged
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def split_answer_and_sources(text: str) -> tuple[str, list[dict]]:
|
|
140
|
+
raw = (text or "").strip()
|
|
141
|
+
if not raw:
|
|
142
|
+
return "", []
|
|
143
|
+
|
|
144
|
+
if config.output_cleanup_enabled:
|
|
145
|
+
cleaned = sanitize_answer_text(raw)
|
|
146
|
+
if cleaned:
|
|
147
|
+
raw = cleaned
|
|
148
|
+
|
|
149
|
+
inline_sources = _extract_inline_citation_sources(raw)
|
|
150
|
+
|
|
151
|
+
split = _split_function_call_sources(raw)
|
|
152
|
+
if split:
|
|
153
|
+
answer, sources = split
|
|
154
|
+
return answer, merge_sources(sources, inline_sources)
|
|
155
|
+
|
|
156
|
+
split = _split_heading_sources(raw)
|
|
157
|
+
if split:
|
|
158
|
+
answer, sources = split
|
|
159
|
+
return answer, merge_sources(sources, inline_sources)
|
|
160
|
+
|
|
161
|
+
split = _split_details_block_sources(raw)
|
|
162
|
+
if split:
|
|
163
|
+
answer, sources = split
|
|
164
|
+
return answer, merge_sources(sources, inline_sources)
|
|
165
|
+
|
|
166
|
+
split = _split_tail_link_block(raw)
|
|
167
|
+
if split:
|
|
168
|
+
answer, sources = split
|
|
169
|
+
return answer, merge_sources(sources, inline_sources)
|
|
170
|
+
|
|
171
|
+
return raw, inline_sources
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _split_function_call_sources(text: str) -> tuple[str, list[dict]] | None:
|
|
175
|
+
matches = list(_SOURCES_FUNCTION_PATTERN.finditer(text))
|
|
176
|
+
if not matches:
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
for m in reversed(matches):
|
|
180
|
+
open_paren_idx = m.end() - 1
|
|
181
|
+
extracted = _extract_balanced_call_at_end(text, open_paren_idx)
|
|
182
|
+
if not extracted:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
close_paren_idx, args_text = extracted
|
|
186
|
+
sources = _parse_sources_payload(args_text)
|
|
187
|
+
if not sources:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
answer = text[: m.start()].rstrip()
|
|
191
|
+
return answer, sources
|
|
192
|
+
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _extract_balanced_call_at_end(text: str, open_paren_idx: int) -> tuple[int, str] | None:
|
|
197
|
+
if open_paren_idx < 0 or open_paren_idx >= len(text) or text[open_paren_idx] != "(":
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
depth = 1
|
|
201
|
+
in_string: str | None = None
|
|
202
|
+
escape = False
|
|
203
|
+
|
|
204
|
+
for idx in range(open_paren_idx + 1, len(text)):
|
|
205
|
+
ch = text[idx]
|
|
206
|
+
if in_string:
|
|
207
|
+
if escape:
|
|
208
|
+
escape = False
|
|
209
|
+
continue
|
|
210
|
+
if ch == "\\":
|
|
211
|
+
escape = True
|
|
212
|
+
continue
|
|
213
|
+
if ch == in_string:
|
|
214
|
+
in_string = None
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if ch in ("'", '"'):
|
|
218
|
+
in_string = ch
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
if ch == "(":
|
|
222
|
+
depth += 1
|
|
223
|
+
continue
|
|
224
|
+
if ch == ")":
|
|
225
|
+
depth -= 1
|
|
226
|
+
if depth == 0:
|
|
227
|
+
if text[idx + 1 :].strip():
|
|
228
|
+
return None
|
|
229
|
+
args_text = text[open_paren_idx + 1 : idx]
|
|
230
|
+
return idx, args_text
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _split_heading_sources(text: str) -> tuple[str, list[dict]] | None:
|
|
236
|
+
matches = list(_SOURCES_HEADING_PATTERN.finditer(text))
|
|
237
|
+
if not matches:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
for m in reversed(matches):
|
|
241
|
+
start = m.start()
|
|
242
|
+
sources_text = text[start:]
|
|
243
|
+
sources = _extract_sources_from_text(sources_text)
|
|
244
|
+
if not sources:
|
|
245
|
+
continue
|
|
246
|
+
answer = text[:start].rstrip()
|
|
247
|
+
return answer, sources
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _split_tail_link_block(text: str) -> tuple[str, list[dict]] | None:
|
|
252
|
+
lines = text.splitlines()
|
|
253
|
+
if not lines:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
idx = len(lines) - 1
|
|
257
|
+
while idx >= 0 and not lines[idx].strip():
|
|
258
|
+
idx -= 1
|
|
259
|
+
if idx < 0:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
tail_end = idx
|
|
263
|
+
link_like_count = 0
|
|
264
|
+
while idx >= 0:
|
|
265
|
+
line = lines[idx].strip()
|
|
266
|
+
if not line:
|
|
267
|
+
idx -= 1
|
|
268
|
+
continue
|
|
269
|
+
if not _is_link_only_line(line):
|
|
270
|
+
break
|
|
271
|
+
link_like_count += 1
|
|
272
|
+
idx -= 1
|
|
273
|
+
|
|
274
|
+
tail_start = idx + 1
|
|
275
|
+
if link_like_count < 2:
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
block_text = "\n".join(lines[tail_start : tail_end + 1])
|
|
279
|
+
sources = _extract_sources_from_text(block_text)
|
|
280
|
+
if not sources:
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
answer = "\n".join(lines[:tail_start]).rstrip()
|
|
284
|
+
return answer, sources
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _split_details_block_sources(text: str) -> tuple[str, list[dict]] | None:
|
|
288
|
+
lower = text.lower()
|
|
289
|
+
close_idx = lower.rfind("</details>")
|
|
290
|
+
if close_idx == -1:
|
|
291
|
+
return None
|
|
292
|
+
tail = text[close_idx + len("</details>") :].strip()
|
|
293
|
+
if tail:
|
|
294
|
+
return None
|
|
295
|
+
|
|
296
|
+
open_idx = lower.rfind("<details", 0, close_idx)
|
|
297
|
+
if open_idx == -1:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
block_text = text[open_idx : close_idx + len("</details>")]
|
|
301
|
+
sources = _extract_sources_from_text(block_text)
|
|
302
|
+
if len(sources) < 2:
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
answer = text[:open_idx].rstrip()
|
|
306
|
+
return answer, sources
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_link_only_line(line: str) -> bool:
|
|
310
|
+
stripped = re.sub(r"^\s*(?:[-*]|\d+\.)\s*", "", line).strip()
|
|
311
|
+
if not stripped:
|
|
312
|
+
return False
|
|
313
|
+
if stripped.startswith(("http://", "https://")):
|
|
314
|
+
return True
|
|
315
|
+
if _MD_LINK_PATTERN.search(stripped):
|
|
316
|
+
return True
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _parse_sources_payload(payload: str) -> list[dict]:
|
|
321
|
+
payload = (payload or "").strip().rstrip(";")
|
|
322
|
+
if not payload:
|
|
323
|
+
return []
|
|
324
|
+
|
|
325
|
+
data: Any = None
|
|
326
|
+
try:
|
|
327
|
+
data = json.loads(payload)
|
|
328
|
+
except Exception:
|
|
329
|
+
try:
|
|
330
|
+
data = ast.literal_eval(payload)
|
|
331
|
+
except Exception:
|
|
332
|
+
data = None
|
|
333
|
+
|
|
334
|
+
if data is None:
|
|
335
|
+
return _extract_sources_from_text(payload)
|
|
336
|
+
|
|
337
|
+
if isinstance(data, dict):
|
|
338
|
+
for key in ("sources", "citations", "references", "urls"):
|
|
339
|
+
if key in data:
|
|
340
|
+
return _normalize_sources(data[key])
|
|
341
|
+
return _normalize_sources(data)
|
|
342
|
+
|
|
343
|
+
return _normalize_sources(data)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _normalize_sources(data: Any) -> list[dict]:
|
|
347
|
+
items: list[Any]
|
|
348
|
+
if isinstance(data, (list, tuple)):
|
|
349
|
+
items = list(data)
|
|
350
|
+
elif isinstance(data, dict):
|
|
351
|
+
items = [data]
|
|
352
|
+
else:
|
|
353
|
+
items = [data]
|
|
354
|
+
|
|
355
|
+
normalized: list[dict] = []
|
|
356
|
+
seen: set[str] = set()
|
|
357
|
+
|
|
358
|
+
for item in items:
|
|
359
|
+
if isinstance(item, str):
|
|
360
|
+
for url in extract_unique_urls(item):
|
|
361
|
+
if url not in seen:
|
|
362
|
+
seen.add(url)
|
|
363
|
+
normalized.append({"url": url})
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
|
367
|
+
title, url = item[0], item[1]
|
|
368
|
+
if isinstance(url, str) and url.startswith(("http://", "https://")) and url not in seen:
|
|
369
|
+
seen.add(url)
|
|
370
|
+
out: dict = {"url": url}
|
|
371
|
+
if isinstance(title, str) and title.strip():
|
|
372
|
+
out["title"] = title.strip()
|
|
373
|
+
normalized.append(out)
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
if isinstance(item, dict):
|
|
377
|
+
url = item.get("url") or item.get("href") or item.get("link")
|
|
378
|
+
if not isinstance(url, str) or not url.startswith(("http://", "https://")):
|
|
379
|
+
continue
|
|
380
|
+
if url in seen:
|
|
381
|
+
continue
|
|
382
|
+
seen.add(url)
|
|
383
|
+
out: dict = {"url": url}
|
|
384
|
+
title = item.get("title") or item.get("name") or item.get("label")
|
|
385
|
+
if isinstance(title, str) and title.strip():
|
|
386
|
+
out["title"] = title.strip()
|
|
387
|
+
desc = item.get("description") or item.get("snippet") or item.get("content")
|
|
388
|
+
if isinstance(desc, str) and desc.strip():
|
|
389
|
+
out["description"] = desc.strip()
|
|
390
|
+
normalized.append(out)
|
|
391
|
+
continue
|
|
392
|
+
|
|
393
|
+
return normalized
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _extract_sources_from_text(text: str) -> list[dict]:
|
|
397
|
+
sources: list[dict] = []
|
|
398
|
+
seen: set[str] = set()
|
|
399
|
+
|
|
400
|
+
for title, url in _MD_LINK_PATTERN.findall(text or ""):
|
|
401
|
+
url = (url or "").strip()
|
|
402
|
+
if not url or url in seen:
|
|
403
|
+
continue
|
|
404
|
+
seen.add(url)
|
|
405
|
+
title = (title or "").strip()
|
|
406
|
+
if title:
|
|
407
|
+
sources.append({"title": title, "url": url})
|
|
408
|
+
else:
|
|
409
|
+
sources.append({"url": url})
|
|
410
|
+
|
|
411
|
+
for url in extract_unique_urls(text or ""):
|
|
412
|
+
if url in seen:
|
|
413
|
+
continue
|
|
414
|
+
seen.add(url)
|
|
415
|
+
sources.append({"url": url})
|
|
416
|
+
|
|
417
|
+
return sources
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _extract_inline_citation_sources(text: str) -> list[dict]:
|
|
421
|
+
sources: list[dict] = []
|
|
422
|
+
seen: set[str] = set()
|
|
423
|
+
for number, url in _INLINE_CITATION_LINK_PATTERN.findall(text or ""):
|
|
424
|
+
url = (url or "").strip()
|
|
425
|
+
if not url or url in seen:
|
|
426
|
+
continue
|
|
427
|
+
seen.add(url)
|
|
428
|
+
sources.append({"title": number, "url": url})
|
|
429
|
+
return sources
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
import re
|
|
3
|
+
from .providers.base import SearchResult
|
|
4
|
+
|
|
5
|
+
_URL_PATTERN = re.compile(r'https?://[^\s<>"\'`,。、;:!?》)】\)]+')
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def extract_unique_urls(text: str) -> list[str]:
|
|
9
|
+
seen: set[str] = set()
|
|
10
|
+
urls: list[str] = []
|
|
11
|
+
for m in _URL_PATTERN.finditer(text):
|
|
12
|
+
url = m.group().rstrip('.,;:!?')
|
|
13
|
+
if url not in seen:
|
|
14
|
+
seen.add(url)
|
|
15
|
+
urls.append(url)
|
|
16
|
+
return urls
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_extra_sources(tavily_results: list[dict] | None, firecrawl_results: list[dict] | None) -> str:
|
|
20
|
+
sections = []
|
|
21
|
+
idx = 1
|
|
22
|
+
urls = []
|
|
23
|
+
if firecrawl_results:
|
|
24
|
+
lines = ["## Extra Sources [Firecrawl]"]
|
|
25
|
+
for r in firecrawl_results:
|
|
26
|
+
title = r.get("title") or "Untitled"
|
|
27
|
+
url = r.get("url", "")
|
|
28
|
+
if len(url) == 0:
|
|
29
|
+
continue
|
|
30
|
+
if url in urls:
|
|
31
|
+
continue
|
|
32
|
+
urls.append(url)
|
|
33
|
+
desc = r.get("description", "")
|
|
34
|
+
lines.append(f"{idx}. **[{title}]({url})**")
|
|
35
|
+
if desc:
|
|
36
|
+
lines.append(f" {desc}")
|
|
37
|
+
idx += 1
|
|
38
|
+
sections.append("\n".join(lines))
|
|
39
|
+
if tavily_results:
|
|
40
|
+
lines = ["## Extra Sources [Tavily]"]
|
|
41
|
+
for r in tavily_results:
|
|
42
|
+
title = r.get("title") or "Untitled"
|
|
43
|
+
url = r.get("url", "")
|
|
44
|
+
if url in urls:
|
|
45
|
+
continue
|
|
46
|
+
content = r.get("content", "")
|
|
47
|
+
lines.append(f"{idx}. **[{title}]({url})**")
|
|
48
|
+
if content:
|
|
49
|
+
lines.append(f" {content}")
|
|
50
|
+
idx += 1
|
|
51
|
+
sections.append("\n".join(lines))
|
|
52
|
+
return "\n\n".join(sections)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_search_results(results: List[SearchResult]) -> str:
|
|
56
|
+
if not results:
|
|
57
|
+
return "No results found."
|
|
58
|
+
|
|
59
|
+
formatted = []
|
|
60
|
+
for i, result in enumerate(results, 1):
|
|
61
|
+
parts = [f"## Result {i}: {result.title}"]
|
|
62
|
+
|
|
63
|
+
if result.url:
|
|
64
|
+
parts.append(f"**URL:** {result.url}")
|
|
65
|
+
|
|
66
|
+
if result.snippet:
|
|
67
|
+
parts.append(f"**Summary:** {result.snippet}")
|
|
68
|
+
|
|
69
|
+
if result.source:
|
|
70
|
+
parts.append(f"**Source:** {result.source}")
|
|
71
|
+
|
|
72
|
+
if result.published_date:
|
|
73
|
+
parts.append(f"**Published:** {result.published_date}")
|
|
74
|
+
|
|
75
|
+
formatted.append("\n".join(parts))
|
|
76
|
+
|
|
77
|
+
return "\n\n---\n\n".join(formatted)
|
|
78
|
+
|
|
79
|
+
fetch_prompt = """
|
|
80
|
+
# Profile: Web Content Fetcher
|
|
81
|
+
|
|
82
|
+
- **Language**: 中文
|
|
83
|
+
- **Role**: 你是一个专业的网页内容抓取和解析专家,获取指定 URL 的网页内容,并将其转换为与原网页高度一致的结构化 Markdown 文本格式。
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Workflow
|
|
88
|
+
|
|
89
|
+
### 1. URL 验证与内容获取
|
|
90
|
+
- 验证 URL 格式有效性,检查可访问性(处理重定向/超时)
|
|
91
|
+
- **关键**:优先识别页面目录/大纲结构(Table of Contents),作为内容抓取的导航索引
|
|
92
|
+
- 全量获取 HTML 内容,确保不遗漏任何章节或动态加载内容
|
|
93
|
+
|
|
94
|
+
### 2. 智能解析与内容提取
|
|
95
|
+
- **结构优先**:若存在目录/大纲,严格按其层级结构进行内容提取和组织
|
|
96
|
+
- 解析 HTML 文档树,识别所有内容元素:
|
|
97
|
+
- 标题层级(h1-h6)及其嵌套关系
|
|
98
|
+
- 正文段落、文本格式(粗体/斜体/下划线)
|
|
99
|
+
- 列表结构(有序/无序/嵌套)
|
|
100
|
+
- 表格(包含表头/数据行/合并单元格)
|
|
101
|
+
- 代码块(行内代码/多行代码块/语言标识)
|
|
102
|
+
- 引用块、分隔线
|
|
103
|
+
- 图片(src/alt/title 属性)
|
|
104
|
+
- 链接(内部/外部/锚点)
|
|
105
|
+
|
|
106
|
+
### 3. 内容清理与语义保留
|
|
107
|
+
- 移除非内容标签:`<script>`、`<style>`、`<iframe>`、`<noscript>`
|
|
108
|
+
- 过滤干扰元素:广告模块、追踪代码、社交分享按钮
|
|
109
|
+
- **保留语义信息**:图片 alt/title、链接 href/title、代码语言标识
|
|
110
|
+
- 特殊模块标注:导航栏、侧边栏、页脚用特殊标记保留
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Skills
|
|
115
|
+
|
|
116
|
+
### 1. 内容精准提取与还原
|
|
117
|
+
- **如果存在目录或者大纲,则按照目录或者大纲的结构进行提取**
|
|
118
|
+
- **完整保留原始内容结构**,不遗漏任何信息
|
|
119
|
+
- **准确识别并提取**标题、段落、列表、表格、代码块等所有元素
|
|
120
|
+
- **保持原网页的内容层次和逻辑关系**
|
|
121
|
+
- **精确处理特殊字符**,确保无乱码和格式错误
|
|
122
|
+
- **还原文本内容**,包括换行、缩进、空格等细节
|
|
123
|
+
|
|
124
|
+
### 2. 结构化组织与呈现
|
|
125
|
+
- **标题层级**:使用 `#`、`##`、`###` 等还原标题层级
|
|
126
|
+
- **目录结构**:使用列表生成 Table of Contents,带锚点链接
|
|
127
|
+
- **内容分区**:使用 `###` 或代码块(` ```section ``` `)明确划分 Section
|
|
128
|
+
- **嵌套结构**:使用缩进列表或引用块(`>`)保持层次关系
|
|
129
|
+
- **辅助模块**:侧边栏、导航等用特殊代码块(` ```sidebar ``` `、` ```nav ``` `)包裹
|
|
130
|
+
|
|
131
|
+
### 3. 格式转换优化
|
|
132
|
+
- **HTML 转 Markdown**:保持 100% 内容一致性
|
|
133
|
+
- **表格处理**:使用 Markdown 表格语法(`|---|---|`)
|
|
134
|
+
- **代码片段**:用 ` ```语言标识``` ` 包裹,保留原始缩进
|
|
135
|
+
- **图片处理**:转换为 `` 格式,保留所有属性
|
|
136
|
+
- **链接处理**:转换为 `[文本](URL)` 格式,保持完整路径
|
|
137
|
+
- **强调样式**:`<strong>` → `**粗体**`,`<em>` → `*斜体*`
|
|
138
|
+
|
|
139
|
+
### 4. 内容完整性保障
|
|
140
|
+
- **零删减原则**:不删减任何原网页文本内容
|
|
141
|
+
- **元数据保留**:保留时间戳、作者信息、标签等关键信息
|
|
142
|
+
- **多媒体标注**:视频、音频以链接或占位符标注(`[视频: 标题](URL)`)
|
|
143
|
+
- **动态内容处理**:尽可能抓取完整内容
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Rules
|
|
148
|
+
|
|
149
|
+
### 1. 内容一致性原则(核心)
|
|
150
|
+
- ✅ 返回内容必须与原网页内容**完全一致**,不能有信息缺失
|
|
151
|
+
- ✅ 保持原网页的**所有文本、结构和语义信息**
|
|
152
|
+
- ❌ **不进行**内容摘要、精简、改写或总结
|
|
153
|
+
- ✅ 保留原始的**段落划分、换行、空格**等格式细节
|
|
154
|
+
|
|
155
|
+
### 2. 格式转换标准
|
|
156
|
+
| HTML | Markdown | 示例 |
|
|
157
|
+
|------|----------|------|
|
|
158
|
+
| `<h1>`-`<h6>` | `#`-`######` | `# 标题` |
|
|
159
|
+
| `<strong>` | `**粗体**` | **粗体** |
|
|
160
|
+
| `<em>` | `*斜体*` | *斜体* |
|
|
161
|
+
| `<a>` | `[文本](url)` | [链接](url) |
|
|
162
|
+
| `<img>` | `` |  |
|
|
163
|
+
| `<code>` | `` `代码` `` | `code` |
|
|
164
|
+
| `<pre><code>` | ` ```\n代码\n``` ` | 代码块 |
|
|
165
|
+
|
|
166
|
+
### 3. 输出质量要求
|
|
167
|
+
- **元数据头部**:
|
|
168
|
+
```markdown
|
|
169
|
+
---
|
|
170
|
+
source: [原始URL]
|
|
171
|
+
title: [网页标题]
|
|
172
|
+
fetched_at: [抓取时间]
|
|
173
|
+
---
|
|
174
|
+
```
|
|
175
|
+
- **编码标准**:统一使用 UTF-8
|
|
176
|
+
- **可用性**:输出可直接用于文档生成或阅读
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Initialization
|
|
181
|
+
|
|
182
|
+
当接收到 URL 时:
|
|
183
|
+
1. 按 Workflow 执行抓取和处理
|
|
184
|
+
2. 返回完整的结构化 Markdown 文档
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
url_describe_prompt = (
|
|
189
|
+
"Browse the given URL. Return exactly two sections:\n\n"
|
|
190
|
+
"Title: <page title from the page's own <title> tag or top heading; "
|
|
191
|
+
"if missing/generic, craft one using key terms found in the page>\n\n"
|
|
192
|
+
"Extracts: <copy 2-4 verbatim fragments from the page that best represent "
|
|
193
|
+
"its core content. Each fragment must be the author's original words, "
|
|
194
|
+
"wrapped in quotes, separated by ' | '. "
|
|
195
|
+
"Do NOT paraphrase, rephrase, interpret, or describe. "
|
|
196
|
+
"Do NOT write sentences like 'This page discusses...' or 'The author argues...'. "
|
|
197
|
+
"You are a copy-paste machine.>\n\n"
|
|
198
|
+
"Nothing else."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
rank_sources_prompt = (
|
|
202
|
+
"Given a user query and a numbered source list, output ONLY the source numbers "
|
|
203
|
+
"reordered by relevance to the query (most relevant first). "
|
|
204
|
+
"Format: space-separated integers on a single line (e.g., 14 12 1 3 5). "
|
|
205
|
+
"Include every number exactly once. Nothing else."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
search_prompt = """You are a helpful research assistant. Answer the user's question thoroughly using web search results.
|
|
209
|
+
|
|
210
|
+
Guidelines:
|
|
211
|
+
- Infer the user's true intent even when the question is vague. Consider multiple angles.
|
|
212
|
+
- Search broadly first (5+ perspectives), then go deep on the 2-3 most relevant ones.
|
|
213
|
+
- Prioritize authoritative sources: official docs, Wikipedia, academic papers, reputable journalism.
|
|
214
|
+
- Search in English first for breadth, switch to Chinese when the topic demands it.
|
|
215
|
+
- Every factual claim should cite its source. More credible sources strengthen the answer.
|
|
216
|
+
- Lead with the most likely answer, then provide supporting analysis.
|
|
217
|
+
- Define technical terms in plain language. Use real-world analogies for complex concepts.
|
|
218
|
+
- Format output in clean Markdown. Use LaTeX for formulas, code blocks for scripts.
|
|
219
|
+
- Be direct and concise. No filler or unnecessary follow-up questions.
|
|
220
|
+
"""
|