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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README-ZH.md +115 -0
  3. package/README.md +124 -0
  4. package/npm/bin/research-assistant.js +70 -0
  5. package/npm/scripts/postinstall.js +112 -0
  6. package/package.json +38 -0
  7. package/pyproject.toml +84 -0
  8. package/src/research_assistant/__init__.py +7 -0
  9. package/src/research_assistant/__main__.py +6 -0
  10. package/src/research_assistant/assets/researcher_persona.md +94 -0
  11. package/src/research_assistant/assets/skills/research-assistant/SKILL.md +248 -0
  12. package/src/research_assistant/cli.py +155 -0
  13. package/src/research_assistant/commands/__init__.py +7 -0
  14. package/src/research_assistant/commands/ask.py +54 -0
  15. package/src/research_assistant/commands/doctor.py +201 -0
  16. package/src/research_assistant/commands/fetch.py +99 -0
  17. package/src/research_assistant/commands/locate.py +71 -0
  18. package/src/research_assistant/commands/search.py +161 -0
  19. package/src/research_assistant/commands/setup.py +249 -0
  20. package/src/research_assistant/commands/skills.py +60 -0
  21. package/src/research_assistant/config.py +258 -0
  22. package/src/research_assistant/errors.py +106 -0
  23. package/src/research_assistant/fetch/__init__.py +20 -0
  24. package/src/research_assistant/fetch/browser.py +613 -0
  25. package/src/research_assistant/fetch/cfbypass.py +318 -0
  26. package/src/research_assistant/fetch/html2md.py +44 -0
  27. package/src/research_assistant/fetch/normal.py +69 -0
  28. package/src/research_assistant/fetch/search_engine.py +324 -0
  29. package/src/research_assistant/fetch/snapshot.py +50 -0
  30. package/src/research_assistant/http.py +118 -0
  31. package/src/research_assistant/installer.py +270 -0
  32. package/src/research_assistant/locate/__init__.py +5 -0
  33. package/src/research_assistant/locate/engine.py +289 -0
  34. package/src/research_assistant/loginstate/__init__.py +10 -0
  35. package/src/research_assistant/loginstate/daemon.py +145 -0
  36. package/src/research_assistant/loginstate/daemon_cli.py +21 -0
  37. package/src/research_assistant/loginstate/daemonctl.py +116 -0
  38. package/src/research_assistant/loginstate/extension/background.js +167 -0
  39. package/src/research_assistant/loginstate/extension/manifest.json +15 -0
  40. package/src/research_assistant/loginstate/extension/popup.html +31 -0
  41. package/src/research_assistant/loginstate/extension/popup.js +33 -0
  42. package/src/research_assistant/output.py +140 -0
  43. package/src/research_assistant/providers/__init__.py +10 -0
  44. package/src/research_assistant/providers/base.py +93 -0
  45. package/src/research_assistant/providers/browser.py +91 -0
  46. package/src/research_assistant/providers/context7.py +171 -0
  47. package/src/research_assistant/providers/exa.py +353 -0
  48. package/src/research_assistant/providers/firecrawl.py +644 -0
  49. package/src/research_assistant/providers/openai_compat.py +293 -0
  50. package/src/research_assistant/providers/registry.py +62 -0
  51. package/src/research_assistant/providers/tavily.py +346 -0
  52. package/src/research_assistant/proxy.py +58 -0
  53. 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)