research-assistant 0.1.0
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-ZH.md +115 -0
- package/README.md +124 -0
- package/npm/bin/research-assistant.js +70 -0
- package/npm/scripts/postinstall.js +112 -0
- package/package.json +38 -0
- package/pyproject.toml +84 -0
- package/src/research_assistant/__init__.py +7 -0
- package/src/research_assistant/__main__.py +6 -0
- package/src/research_assistant/assets/researcher_persona.md +94 -0
- package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
- package/src/research_assistant/cli.py +155 -0
- package/src/research_assistant/commands/__init__.py +7 -0
- package/src/research_assistant/commands/ask.py +54 -0
- package/src/research_assistant/commands/doctor.py +201 -0
- package/src/research_assistant/commands/fetch.py +99 -0
- package/src/research_assistant/commands/locate.py +71 -0
- package/src/research_assistant/commands/search.py +161 -0
- package/src/research_assistant/commands/setup.py +249 -0
- package/src/research_assistant/commands/skills.py +60 -0
- package/src/research_assistant/config.py +258 -0
- package/src/research_assistant/errors.py +106 -0
- package/src/research_assistant/fetch/__init__.py +20 -0
- package/src/research_assistant/fetch/browser.py +613 -0
- package/src/research_assistant/fetch/cfbypass.py +318 -0
- package/src/research_assistant/fetch/html2md.py +44 -0
- package/src/research_assistant/fetch/normal.py +69 -0
- package/src/research_assistant/fetch/search_engine.py +324 -0
- package/src/research_assistant/fetch/snapshot.py +50 -0
- package/src/research_assistant/http.py +118 -0
- package/src/research_assistant/installer.py +270 -0
- package/src/research_assistant/locate/__init__.py +5 -0
- package/src/research_assistant/locate/engine.py +289 -0
- package/src/research_assistant/loginstate/__init__.py +10 -0
- package/src/research_assistant/loginstate/daemon.py +145 -0
- package/src/research_assistant/loginstate/daemon_cli.py +21 -0
- package/src/research_assistant/loginstate/daemonctl.py +116 -0
- package/src/research_assistant/loginstate/extension/background.js +167 -0
- package/src/research_assistant/loginstate/extension/manifest.json +15 -0
- package/src/research_assistant/loginstate/extension/popup.html +31 -0
- package/src/research_assistant/loginstate/extension/popup.js +33 -0
- package/src/research_assistant/output.py +140 -0
- package/src/research_assistant/providers/__init__.py +10 -0
- package/src/research_assistant/providers/base.py +93 -0
- package/src/research_assistant/providers/browser.py +91 -0
- package/src/research_assistant/providers/context7.py +171 -0
- package/src/research_assistant/providers/exa.py +353 -0
- package/src/research_assistant/providers/firecrawl.py +644 -0
- package/src/research_assistant/providers/openai_compat.py +293 -0
- package/src/research_assistant/providers/registry.py +62 -0
- package/src/research_assistant/providers/tavily.py +346 -0
- package/src/research_assistant/proxy.py +58 -0
- package/src/research_assistant/targets.py +66 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""OpenAI 兼容 provider(自写 HTTP,ADR-0010)。
|
|
2
|
+
|
|
3
|
+
既是 registry 中的 provider(命令 openai chat,供诊断/直调),也是 search/locate
|
|
4
|
+
共用的 LLM 主干(module-level chat_completion 函数)。
|
|
5
|
+
|
|
6
|
+
API(OpenAI Chat Completions 兼容):
|
|
7
|
+
POST {base_url}/chat/completions 鉴权 Authorization: Bearer <key>
|
|
8
|
+
body: {model, messages[{role,content}], temperature, stream, ...}
|
|
9
|
+
resp: {choices[{message{content}}], usage, ...}
|
|
10
|
+
|
|
11
|
+
search/locate 只消费信源字段(citations/URLs),不取综合结论(ADR-0009)。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ..config import Config, ProviderConfig
|
|
23
|
+
from ..errors import ArgsError, ProviderError
|
|
24
|
+
from .. import http as http_mod
|
|
25
|
+
from ..proxy import resolve_proxy
|
|
26
|
+
from .base import ArgSpec, Capability, Provider
|
|
27
|
+
from .registry import register
|
|
28
|
+
|
|
29
|
+
_THINK_RE = re.compile(r"<think>[\s\S]*?</think>", re.IGNORECASE)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def strip_think(text: str) -> str:
|
|
33
|
+
"""去掉 <think>...</think> 推理块(grok 等模型会带),避免污染下游解析。"""
|
|
34
|
+
if not text:
|
|
35
|
+
return ""
|
|
36
|
+
return _THINK_RE.sub("", text).strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_cfg(config: Config, provider_type: str = "openai_compat") -> ProviderConfig:
|
|
41
|
+
"""取 openai 兼容配置。locate 复用时传 provider_type='locate'。"""
|
|
42
|
+
cfg = config.require_provider(provider_type)
|
|
43
|
+
if not cfg.api_key:
|
|
44
|
+
raise ArgsError(f"{provider_type}: 缺少 api_key", provider=provider_type)
|
|
45
|
+
if not cfg.base_url:
|
|
46
|
+
raise ArgsError(f"{provider_type}: 缺少 base_url", provider=provider_type)
|
|
47
|
+
if not cfg.model:
|
|
48
|
+
raise ArgsError(f"{provider_type}: 缺少 model", provider=provider_type)
|
|
49
|
+
return cfg
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def chat_completion(
|
|
53
|
+
config: Config,
|
|
54
|
+
messages: list[dict[str, Any]],
|
|
55
|
+
*,
|
|
56
|
+
model: str | None = None,
|
|
57
|
+
temperature: float = 0.0,
|
|
58
|
+
timeout: float | None = None,
|
|
59
|
+
response_format: dict[str, Any] | None = None,
|
|
60
|
+
extra_body: dict[str, Any] | None = None,
|
|
61
|
+
provider_type: str = "openai_compat",
|
|
62
|
+
stream: bool = True,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""调用 chat/completions,返回完整响应 dict(聚合后)。search/locate 复用。
|
|
65
|
+
|
|
66
|
+
默认 stream=True:很多 OpenAI 兼容网关(尤其 grok 类)对慢模型仅流式可靠,
|
|
67
|
+
流式首 token 早到可避开网关空闲超时。聚合后返回与非流式同构的 dict。
|
|
68
|
+
"""
|
|
69
|
+
cfg = resolve_cfg(config, provider_type)
|
|
70
|
+
body: dict[str, Any] = {
|
|
71
|
+
"model": model or cfg.model,
|
|
72
|
+
"messages": messages,
|
|
73
|
+
"temperature": temperature,
|
|
74
|
+
"stream": stream,
|
|
75
|
+
}
|
|
76
|
+
if response_format:
|
|
77
|
+
body["response_format"] = response_format
|
|
78
|
+
if extra_body:
|
|
79
|
+
body.update(extra_body)
|
|
80
|
+
|
|
81
|
+
async with http_mod.make_client(
|
|
82
|
+
proxy_url=resolve_proxy(config.proxy.url),
|
|
83
|
+
timeout=timeout or cfg.timeout,
|
|
84
|
+
base_url=cfg.base_url,
|
|
85
|
+
headers={"Authorization": f"Bearer {cfg.api_key}"},
|
|
86
|
+
) as client:
|
|
87
|
+
if stream:
|
|
88
|
+
return await _stream_chat(client, body, provider_type, model or cfg.model)
|
|
89
|
+
return await http_mod.request_json(
|
|
90
|
+
client, "POST", "chat/completions", provider=provider_type, json_body=body
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _stream_chat(client: httpx.AsyncClient, body: dict[str, Any], provider_type: str, model: str) -> dict[str, Any]:
|
|
95
|
+
"""读取 SSE 流并聚合成非流式同构响应。"""
|
|
96
|
+
try:
|
|
97
|
+
async with client.stream("POST", "chat/completions", json=body) as resp:
|
|
98
|
+
if resp.status_code >= 400:
|
|
99
|
+
text = (await resp.aread()).decode("utf-8", errors="replace")[:1000]
|
|
100
|
+
raise http_mod.handle_http_error(
|
|
101
|
+
provider_type,
|
|
102
|
+
httpx.HTTPStatusError(f"{provider_type} HTTP {resp.status_code}", request=resp.request, response=resp),
|
|
103
|
+
extra_body_text=text,
|
|
104
|
+
)
|
|
105
|
+
content_parts: list[str] = []
|
|
106
|
+
citations: list[Any] = []
|
|
107
|
+
search_results: list[Any] = []
|
|
108
|
+
usage: dict[str, Any] | None = None
|
|
109
|
+
role = "assistant"
|
|
110
|
+
async for line in resp.aiter_lines():
|
|
111
|
+
if not line or not line.startswith("data:"):
|
|
112
|
+
continue
|
|
113
|
+
data_str = line[5:].strip()
|
|
114
|
+
if data_str == "[DONE]":
|
|
115
|
+
break
|
|
116
|
+
try:
|
|
117
|
+
chunk = json.loads(data_str)
|
|
118
|
+
except json.JSONDecodeError:
|
|
119
|
+
continue
|
|
120
|
+
choices = chunk.get("choices") or []
|
|
121
|
+
if choices:
|
|
122
|
+
delta = choices[0].get("delta") or {}
|
|
123
|
+
if delta.get("role"):
|
|
124
|
+
role = delta["role"]
|
|
125
|
+
if delta.get("content"):
|
|
126
|
+
content_parts.append(delta["content"])
|
|
127
|
+
# 部分网关在 delta 里带 citations / search_results
|
|
128
|
+
for key, store in (("citations", citations), ("search_results", search_results)):
|
|
129
|
+
val = delta.get(key)
|
|
130
|
+
if isinstance(val, list):
|
|
131
|
+
store.extend(val)
|
|
132
|
+
# 顶层或 usage
|
|
133
|
+
for key, store in (("citations", citations), ("search_results", search_results)):
|
|
134
|
+
val = chunk.get(key)
|
|
135
|
+
if isinstance(val, list):
|
|
136
|
+
store.extend(val)
|
|
137
|
+
if chunk.get("usage"):
|
|
138
|
+
usage = chunk.get("usage")
|
|
139
|
+
except httpx.HTTPError as e:
|
|
140
|
+
from ..errors import wrap_provider_http_error
|
|
141
|
+
|
|
142
|
+
raise wrap_provider_http_error(provider_type, e) from e
|
|
143
|
+
|
|
144
|
+
message: dict[str, Any] = {"role": role, "content": "".join(content_parts)}
|
|
145
|
+
if citations:
|
|
146
|
+
message["citations"] = citations
|
|
147
|
+
if search_results:
|
|
148
|
+
message["search_results"] = search_results
|
|
149
|
+
result: dict[str, Any] = {
|
|
150
|
+
"model": model,
|
|
151
|
+
"choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
|
|
152
|
+
}
|
|
153
|
+
if usage:
|
|
154
|
+
result["usage"] = usage
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def extract_message(response: dict[str, Any]) -> dict[str, Any]:
|
|
159
|
+
"""从 chat 响应抽取 assistant message(content + 可能的 citations/search_results)。"""
|
|
160
|
+
choices = response.get("choices") or []
|
|
161
|
+
if not choices:
|
|
162
|
+
raise ProviderError("openai_compat: 响应无 choices", provider="openai_compat")
|
|
163
|
+
return choices[0].get("message") or {}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def extract_citations(response: dict[str, Any], message: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
|
167
|
+
"""从响应各处抽取信源(URL),用于 search(只消费不综合,ADR-0009)。
|
|
168
|
+
|
|
169
|
+
覆盖各家返回方式:message.citations / message.search_results / 顶层 citations /
|
|
170
|
+
content 中的 markdown 链接与裸 URL。
|
|
171
|
+
"""
|
|
172
|
+
import re
|
|
173
|
+
|
|
174
|
+
msg = message if message is not None else extract_message(response)
|
|
175
|
+
candidates: list[dict[str, Any]] = []
|
|
176
|
+
seen: set[str] = set()
|
|
177
|
+
|
|
178
|
+
def add(url: str, title: str = "", snippet: str = "") -> None:
|
|
179
|
+
url = _clean_url(url)
|
|
180
|
+
if not url or url in seen:
|
|
181
|
+
return
|
|
182
|
+
seen.add(url)
|
|
183
|
+
item: dict[str, Any] = {"url": url}
|
|
184
|
+
if title:
|
|
185
|
+
item["title"] = title
|
|
186
|
+
if snippet:
|
|
187
|
+
item["snippet"] = snippet
|
|
188
|
+
candidates.append(item)
|
|
189
|
+
|
|
190
|
+
# message.citations(Grok 风格:URL 字符串列表)
|
|
191
|
+
for c in _as_list(msg.get("citations")):
|
|
192
|
+
if isinstance(c, str):
|
|
193
|
+
add(c)
|
|
194
|
+
elif isinstance(c, dict):
|
|
195
|
+
add(c.get("url"), c.get("title"), c.get("snippet") or c.get("content"))
|
|
196
|
+
# message.search_results
|
|
197
|
+
for c in _as_list(msg.get("search_results")):
|
|
198
|
+
if isinstance(c, dict):
|
|
199
|
+
add(c.get("url"), c.get("title"), c.get("content") or c.get("snippet"))
|
|
200
|
+
# 顶层 citations / search_results
|
|
201
|
+
for key in ("citations", "search_results"):
|
|
202
|
+
for c in _as_list(response.get(key)):
|
|
203
|
+
if isinstance(c, str):
|
|
204
|
+
add(c)
|
|
205
|
+
elif isinstance(c, dict):
|
|
206
|
+
add(c.get("url"), c.get("title"), c.get("content") or c.get("snippet"))
|
|
207
|
+
|
|
208
|
+
# content 中的 markdown 链接 [text](url) 与裸 URL(先剥离 <think> 推理块)
|
|
209
|
+
content = strip_think(msg.get("content") or "")
|
|
210
|
+
if isinstance(content, list): # 部分 provider 返回 content blocks
|
|
211
|
+
content = "\n".join(seg.get("text", "") for seg in content if isinstance(seg, dict))
|
|
212
|
+
for text, url in re.findall(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", content):
|
|
213
|
+
add(url, text)
|
|
214
|
+
for url in re.findall(r"(?<![\w/])(https?://[^\s)\]]+)", content):
|
|
215
|
+
add(url)
|
|
216
|
+
|
|
217
|
+
return candidates
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _as_list(v: Any) -> list[Any]:
|
|
221
|
+
if v is None:
|
|
222
|
+
return []
|
|
223
|
+
if isinstance(v, list):
|
|
224
|
+
return v
|
|
225
|
+
return [v]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
_URL_TRAILING = re.compile(r"[)\].,;:!*_`>'\"<]+$")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _clean_url(url: str) -> str:
|
|
232
|
+
"""清理抽取到的 URL:去首尾空白与尾部 markdown/标点(如 ** ) 等)。"""
|
|
233
|
+
url = (url or "").strip()
|
|
234
|
+
# 去掉包裹的成对引号/括号
|
|
235
|
+
for left, right in (("(", ")"), ("[", "]"), ("<", ">"), ('"', '"')):
|
|
236
|
+
if url.startswith(left) and url.endswith(right):
|
|
237
|
+
url = url[1:-1].strip()
|
|
238
|
+
# 反复去尾部标点(** 等)
|
|
239
|
+
while True:
|
|
240
|
+
m = _URL_TRAILING.search(url)
|
|
241
|
+
if not m or m.start() == 0:
|
|
242
|
+
break
|
|
243
|
+
url = url[: m.start()]
|
|
244
|
+
return url.strip()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@register
|
|
248
|
+
class OpenAICompatProvider(Provider):
|
|
249
|
+
type = "openai_compat"
|
|
250
|
+
command = "openai"
|
|
251
|
+
aliases = ["oai"]
|
|
252
|
+
help = "OpenAI-compatible chat completions (LLM backbone; raw diagnostic command)."
|
|
253
|
+
|
|
254
|
+
def capabilities(self) -> list[Capability]:
|
|
255
|
+
return [
|
|
256
|
+
Capability(
|
|
257
|
+
name="chat",
|
|
258
|
+
help="Raw chat completion (diagnostic / direct call).",
|
|
259
|
+
args=[
|
|
260
|
+
ArgSpec(["prompt"], kind="positional", help="User prompt."),
|
|
261
|
+
ArgSpec(["--system"], help="Optional system prompt."),
|
|
262
|
+
ArgSpec(["--model"], help="Override model."),
|
|
263
|
+
ArgSpec(["--temperature"], type=float, default=0.0, help="Sampling temperature."),
|
|
264
|
+
ArgSpec(["--json"], action="store_true", help="Request JSON response format."),
|
|
265
|
+
],
|
|
266
|
+
handler=self.chat,
|
|
267
|
+
),
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
async def chat(self, args: Any) -> dict[str, Any]:
|
|
271
|
+
messages: list[dict[str, Any]] = []
|
|
272
|
+
if args.system:
|
|
273
|
+
messages.append({"role": "system", "content": args.system})
|
|
274
|
+
messages.append({"role": "user", "content": args.prompt})
|
|
275
|
+
response_format = {"type": "json_object"} if args.json else None
|
|
276
|
+
data = await chat_completion(
|
|
277
|
+
self.config,
|
|
278
|
+
messages,
|
|
279
|
+
model=args.model,
|
|
280
|
+
temperature=args.temperature,
|
|
281
|
+
response_format=response_format,
|
|
282
|
+
)
|
|
283
|
+
msg = extract_message(data)
|
|
284
|
+
out: dict[str, Any] = {
|
|
285
|
+
"model": data.get("model") or args.model,
|
|
286
|
+
"content": msg.get("content"),
|
|
287
|
+
}
|
|
288
|
+
citations = extract_citations(data, msg)
|
|
289
|
+
if citations:
|
|
290
|
+
out["citations"] = citations
|
|
291
|
+
if data.get("usage"):
|
|
292
|
+
out["usage"] = data.get("usage")
|
|
293
|
+
return out
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Provider 注册表(ADR-0011)。
|
|
2
|
+
|
|
3
|
+
- @register 装饰器:provider 模块定义类时自动登记 type → class。
|
|
4
|
+
- discover():自动扫描 providers/ 包下所有模块(import 触发 @register),
|
|
5
|
+
无需在 __init__.py 显式列举——加 provider 文件即被发现。
|
|
6
|
+
- 映射:type → Provider class;command/alias → type。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import pkgutil
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .base import Provider
|
|
17
|
+
|
|
18
|
+
_REGISTRY: dict[str, type["Provider"]] = {}
|
|
19
|
+
# command/alias → type(多对一)
|
|
20
|
+
_COMMAND_INDEX: dict[str, str] = {}
|
|
21
|
+
# discover 是否已跑过。用独立标志而非"依赖 _REGISTRY 非空"判断——否则任一 provider
|
|
22
|
+
# 被其他模块预先 import(触发其 @register,使 _REGISTRY 非空)后,完整扫描会被跳过,
|
|
23
|
+
# 导致 registry 只含那个 provider(locate engine 即会预先 import openai_compat)。
|
|
24
|
+
_discovered: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register(cls: type["Provider"]) -> type["Provider"]:
|
|
28
|
+
"""类装饰器:把 Provider 子类按 type 登记,并建立 command/alias 索引。"""
|
|
29
|
+
if not cls.type:
|
|
30
|
+
raise ValueError(f"{cls.__name__} 缺少 type 属性")
|
|
31
|
+
_REGISTRY[cls.type] = cls
|
|
32
|
+
if cls.command:
|
|
33
|
+
_COMMAND_INDEX[cls.command] = cls.type
|
|
34
|
+
for alias in cls.aliases or []:
|
|
35
|
+
_COMMAND_INDEX[alias] = cls.type
|
|
36
|
+
return cls
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _discover() -> None:
|
|
40
|
+
"""导入 providers 包下所有模块(触发各模块顶层的 @register)。仅执行一次。"""
|
|
41
|
+
global _discovered
|
|
42
|
+
if _discovered:
|
|
43
|
+
return
|
|
44
|
+
_discovered = True
|
|
45
|
+
pkg = importlib.import_module(__package__)
|
|
46
|
+
path = getattr(pkg, "__path__", None)
|
|
47
|
+
if path is None: # pragma: no cover
|
|
48
|
+
return
|
|
49
|
+
for _finder, name, _is_pkg in pkgutil.iter_modules(path):
|
|
50
|
+
if name in ("base", "registry"):
|
|
51
|
+
continue
|
|
52
|
+
importlib.import_module(f"{__package__}.{name}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def all_provider_classes() -> dict[str, type["Provider"]]:
|
|
56
|
+
_discover()
|
|
57
|
+
return dict(_REGISTRY)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_type(command_or_alias: str) -> str | None:
|
|
61
|
+
_discover()
|
|
62
|
+
return _COMMAND_INDEX.get(command_or_alias)
|