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,258 @@
1
+ """配置存储(TOML,~/.research-assistant/config.toml,D3)。
2
+
3
+ schema 见 backend-design data-model.md §2.1:
4
+ schema_version = 1
5
+ [[provider]] # N 个,按 type 区分
6
+ type / base_url / api_key / model / timeout / concurrency(仅 locate)
7
+ [proxy] url(空=自动检测,显式=覆盖,ADR-0007)
8
+ [browser] channel / extension_status / daemon_port / profile_strategy
9
+
10
+ 读写:tomllib 读(3.11+ 内置,3.10 回退 tomli);tomli_w 写(setup 持久化)。
11
+ Key 同时支持环境变量覆盖(RA_<TYPE>_API_KEY 等),env 优先于 config 文件。
12
+ 真实 key 绝不进源码——只从此文件或环境读取(§10.4 安全红线)。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ # tomllib 3.11+ 内置,3.10 用 tomli
24
+ if sys.version_info >= (3, 11):
25
+ import tomllib # type: ignore[import-not-found]
26
+ else: # pragma: no cover - 3.10 分支
27
+ import tomli as tomllib # type: ignore[import-not-found,no-redef]
28
+
29
+ import tomli_w
30
+
31
+ from .errors import ConfigError
32
+
33
+ SCHEMA_VERSION = 1
34
+ CONFIG_DIR_ENV = "RESEARCH_ASSISTANT_HOME"
35
+ DEFAULT_CONFIG_DIR = Path.home() / ".research-assistant"
36
+
37
+
38
+ def config_dir() -> Path:
39
+ """配置根目录:env 覆盖 → 默认 ~/.research-assistant。"""
40
+ env = os.getenv(CONFIG_DIR_ENV)
41
+ if env:
42
+ return Path(env).expanduser()
43
+ return DEFAULT_CONFIG_DIR
44
+
45
+
46
+ def config_path(override: str | os.PathLike[str] | None = None) -> Path:
47
+ if override:
48
+ return Path(override).expanduser()
49
+ return config_dir() / "config.toml"
50
+
51
+
52
+ def profiles_dir() -> Path:
53
+ """per-call 临时浏览器 profile 根目录(ADR-0005)。"""
54
+ return config_dir() / ".profiles"
55
+
56
+
57
+ def logs_dir() -> Path:
58
+ return config_dir() / ".logs"
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # 数据类
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ @dataclass
67
+ class ProviderConfig:
68
+ """单个 provider 的连接配置。"""
69
+
70
+ type: str
71
+ base_url: str = ""
72
+ api_key: str = ""
73
+ model: str = ""
74
+ timeout: int = 30
75
+ concurrency: int = 8 # 仅 locate 用
76
+
77
+ def merged_env(self) -> "ProviderConfig":
78
+ """返回被环境变量覆盖后的副本(env 优先)。"""
79
+ prefix = f"RA_{self.type.upper()}"
80
+ out = ProviderConfig(
81
+ type=self.type,
82
+ base_url=self.base_url,
83
+ api_key=self.api_key,
84
+ model=self.model,
85
+ timeout=self.timeout,
86
+ concurrency=self.concurrency,
87
+ )
88
+ env_base = os.getenv(f"{prefix}_BASE_URL")
89
+ env_key = os.getenv(f"{prefix}_API_KEY")
90
+ env_model = os.getenv(f"{prefix}_MODEL")
91
+ env_timeout = os.getenv(f"{prefix}_TIMEOUT")
92
+ env_concurrency = os.getenv(f"{prefix}_CONCURRENCY")
93
+ if env_base:
94
+ out.base_url = env_base
95
+ if env_key:
96
+ out.api_key = env_key
97
+ if env_model:
98
+ out.model = env_model
99
+ if env_timeout:
100
+ try:
101
+ out.timeout = int(env_timeout)
102
+ except ValueError:
103
+ pass
104
+ if env_concurrency:
105
+ try:
106
+ out.concurrency = int(env_concurrency)
107
+ except ValueError:
108
+ pass
109
+ return out
110
+
111
+
112
+ @dataclass
113
+ class ProxyConfig:
114
+ url: str = "" # 空 = 自动检测系统代理(ADR-0007)
115
+
116
+
117
+ @dataclass
118
+ class BrowserConfig:
119
+ channel: str = "msedge" # msedge | chrome
120
+ extension_status: str = "missing" # installed | missing
121
+ daemon_port: int = 17890 # cookie daemon HTTP 端口(WS 同端口,ADR-0006)
122
+ profile_strategy: str = "per_call_temp" # ADR-0005
123
+ max_browser_instances: int = 3 # 并发浏览器进程上限(跨 CLI 限流,防无限开)
124
+
125
+
126
+ @dataclass
127
+ class Config:
128
+ schema_version: int = SCHEMA_VERSION
129
+ providers: list[ProviderConfig] = field(default_factory=list)
130
+ proxy: ProxyConfig = field(default_factory=ProxyConfig)
131
+ browser: BrowserConfig = field(default_factory=BrowserConfig)
132
+ path: str = ""
133
+
134
+ # ---- provider 查询 ----
135
+ def provider(self, ptype: str) -> ProviderConfig | None:
136
+ """按 type 取 provider(环境变量覆盖后)。未配置返回 None。"""
137
+ for p in self.providers:
138
+ if p.type == ptype:
139
+ return p.merged_env()
140
+ # config 中无、但环境变量齐全时也能用(允许纯 env 配置)
141
+ env_prefix = f"RA_{ptype.upper()}_"
142
+ if any(k.startswith(env_prefix) for k in os.environ):
143
+ return ProviderConfig(type=ptype).merged_env()
144
+ return None
145
+
146
+ def require_provider(self, ptype: str) -> ProviderConfig:
147
+ p = self.provider(ptype)
148
+ if p is None:
149
+ raise ConfigError(
150
+ f"未配置 provider: {ptype}",
151
+ provider=ptype,
152
+ details={"hint": "运行 `research-assistant setup` 或在 config.toml 添加 [[provider]]"},
153
+ )
154
+ return p
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # 读写
159
+ # ---------------------------------------------------------------------------
160
+
161
+
162
+ def _load_raw(path: Path) -> dict[str, Any]:
163
+ if not path.exists():
164
+ return {}
165
+ try:
166
+ with path.open("rb") as f:
167
+ return tomllib.load(f)
168
+ except tomllib.TOMLDecodeError as e:
169
+ raise ConfigError(f"配置文件解析失败: {path}: {e}") from e
170
+ except OSError as e:
171
+ raise ConfigError(f"无法读取配置文件: {path}: {e}") from e
172
+
173
+
174
+ def load(path: str | os.PathLike[str] | None = None) -> Config:
175
+ """加载配置。文件不存在返回空 Config(首次使用)。"""
176
+ p = config_path(path)
177
+ raw = _load_raw(p)
178
+ cfg = Config(schema_version=int(raw.get("schema_version", SCHEMA_VERSION)), path=str(p))
179
+
180
+ for item in raw.get("provider", []):
181
+ if not isinstance(item, dict) or "type" not in item:
182
+ continue
183
+ cfg.providers.append(
184
+ ProviderConfig(
185
+ type=str(item["type"]),
186
+ base_url=str(item.get("base_url", "")),
187
+ api_key=str(item.get("api_key", "")),
188
+ model=str(item.get("model", "")),
189
+ timeout=int(item.get("timeout", 30)),
190
+ concurrency=int(item.get("concurrency", 8)),
191
+ )
192
+ )
193
+
194
+ proxy_raw = raw.get("proxy", {})
195
+ if isinstance(proxy_raw, dict):
196
+ cfg.proxy = ProxyConfig(url=str(proxy_raw.get("url", "")))
197
+
198
+ browser_raw = raw.get("browser", {})
199
+ if isinstance(browser_raw, dict):
200
+ # env RA_BROWSER_MAX_INSTANCES 优先于 config 文件
201
+ env_max = os.getenv("RA_BROWSER_MAX_INSTANCES")
202
+ max_inst = (
203
+ int(env_max)
204
+ if env_max and env_max.isdigit()
205
+ else int(browser_raw.get("max_browser_instances", 3))
206
+ )
207
+ cfg.browser = BrowserConfig(
208
+ channel=str(browser_raw.get("channel", "msedge")),
209
+ extension_status=str(browser_raw.get("extension_status", "missing")),
210
+ daemon_port=int(browser_raw.get("daemon_port", 17890)),
211
+ profile_strategy=str(browser_raw.get("profile_strategy", "per_call_temp")),
212
+ max_browser_instances=max_inst,
213
+ )
214
+
215
+ return cfg
216
+
217
+
218
+ def save(cfg: Config, path: str | os.PathLike[str] | None = None) -> Path:
219
+ """把 Config 持久化为 TOML(setup 用,幂等更新)。"""
220
+ p = config_path(path)
221
+ p.parent.mkdir(parents=True, exist_ok=True)
222
+ data: dict[str, Any] = {
223
+ "schema_version": cfg.schema_version,
224
+ "provider": [
225
+ {
226
+ "type": pc.type,
227
+ "base_url": pc.base_url,
228
+ "api_key": pc.api_key,
229
+ "model": pc.model,
230
+ "timeout": pc.timeout,
231
+ **({"concurrency": pc.concurrency} if pc.type == "locate" else {}),
232
+ }
233
+ for pc in cfg.providers
234
+ ],
235
+ "proxy": {"url": cfg.proxy.url},
236
+ "browser": {
237
+ "channel": cfg.browser.channel,
238
+ "extension_status": cfg.browser.extension_status,
239
+ "daemon_port": cfg.browser.daemon_port,
240
+ "profile_strategy": cfg.browser.profile_strategy,
241
+ "max_browser_instances": cfg.browser.max_browser_instances,
242
+ },
243
+ }
244
+ try:
245
+ with p.open("wb") as f:
246
+ f.write(tomli_w.dumps(data).encode("utf-8"))
247
+ except OSError as e:
248
+ raise ConfigError(f"无法写入配置文件: {p}: {e}") from e
249
+ return p
250
+
251
+
252
+ def mask_secret(value: str) -> str:
253
+ """遮蔽 key(doctor --show-config 用,§Security)。"""
254
+ if not value:
255
+ return ""
256
+ if len(value) <= 8:
257
+ return "***"
258
+ return f"{value[:4]}{'*' * (len(value) - 8)}{value[-4:]}"
@@ -0,0 +1,106 @@
1
+ """统一错误体系(横切约定 #1,api-contract.md §4)。
2
+
3
+ 错误体(stdout 固定 JSON,不受 --output 影响):
4
+ { "error": { "code": "...", "message": "...", "provider": "...", "details": {...} } }
5
+
6
+ 退出码(语义化,跨命令一致):
7
+ 0 成功 | 1 INTERNAL 其他 | 2 ARGS 参数错 | 3 CONFIG 配置错
8
+ 4 NETWORK 网络失败(含 PROVIDER 远端失败)| 5 ANTIBOT 反爬失败
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ # 退出码
16
+ EXIT_OK = 0
17
+ EXIT_INTERNAL = 1
18
+ EXIT_ARGS = 2
19
+ EXIT_CONFIG = 3
20
+ EXIT_NETWORK = 4
21
+ EXIT_ANTIBOT = 5
22
+
23
+ # 错误 code 枚举 → 退出码映射
24
+ CODE_TO_EXIT: dict[str, int] = {
25
+ "ARGS": EXIT_ARGS,
26
+ "CONFIG": EXIT_CONFIG,
27
+ "NETWORK": EXIT_NETWORK,
28
+ "ANTIBOT": EXIT_ANTIBOT,
29
+ "PROVIDER": EXIT_NETWORK, # 远端 provider 应用层失败,归入网络类(4)
30
+ "INTERNAL": EXIT_INTERNAL,
31
+ }
32
+
33
+
34
+ class ResearchAssistantError(Exception):
35
+ """所有 research-assistant 业务错误的基类。
36
+
37
+ Attributes:
38
+ code: 错误枚举(ARGS/CONFIG/NETWORK/ANTIBOT/PROVIDER/INTERNAL)。
39
+ message: 人类可读信息。
40
+ provider: 出错的 provider 名(可选)。
41
+ details: 附加结构化细节(可选,进入 error.details)。
42
+ """
43
+
44
+ code: str = "INTERNAL"
45
+
46
+ def __init__(
47
+ self,
48
+ message: str,
49
+ *,
50
+ provider: str | None = None,
51
+ details: dict[str, Any] | None = None,
52
+ ) -> None:
53
+ super().__init__(message)
54
+ self.message = message
55
+ self.provider = provider
56
+ self.details = details
57
+
58
+ def exit_code(self) -> int:
59
+ return CODE_TO_EXIT.get(self.code, EXIT_INTERNAL)
60
+
61
+ def to_json(self) -> dict[str, Any]:
62
+ err: dict[str, Any] = {"code": self.code, "message": self.message}
63
+ if self.provider:
64
+ err["provider"] = self.provider
65
+ if self.details:
66
+ err["details"] = self.details
67
+ return {"error": err}
68
+
69
+
70
+ class ArgsError(ResearchAssistantError):
71
+ code = "ARGS"
72
+
73
+
74
+ class ConfigError(ResearchAssistantError):
75
+ code = "CONFIG"
76
+
77
+
78
+ class NetworkError(ResearchAssistantError):
79
+ code = "NETWORK"
80
+
81
+
82
+ class AntibotError(ResearchAssistantError):
83
+ code = "ANTIBOT"
84
+
85
+
86
+ class ProviderError(ResearchAssistantError):
87
+ """远端 provider 返回了错误响应(4xx/5xx 等),与传输层 NETWORK 区分。"""
88
+
89
+ code = "PROVIDER"
90
+
91
+
92
+ def wrap_provider_http_error(provider: str, exc: Exception) -> ResearchAssistantError:
93
+ """把 httpx/网络异常归一成 NetworkError / ProviderError。
94
+
95
+ 传输层失败(连接/超时/DNS/TLS)→ NETWORK;其余按 INTERNAL 兜底。
96
+ httpx.HTTPStatusError 由各 provider 自行映射为 ProviderError(带状态码 details)。
97
+ """
98
+ import httpx
99
+
100
+ if isinstance(exc, httpx.TimeoutException):
101
+ return NetworkError(f"{provider}: 请求超时 ({exc})", provider=provider)
102
+ if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
103
+ return NetworkError(f"{provider}: 无法连接 ({exc})", provider=provider)
104
+ if isinstance(exc, httpx.HTTPError):
105
+ return NetworkError(f"{provider}: 网络错误 ({exc})", provider=provider)
106
+ return ResearchAssistantError(f"{provider}: 内部错误 ({exc})", provider=provider)
@@ -0,0 +1,20 @@
1
+ """fetch 包:跨工具爬取增强(普通接口 → 失败回退浏览器,PM §4.2)。"""
2
+
3
+ from .html2md import html_to_md
4
+ from .normal import fetch_normal
5
+ from .browser import fetch_with_browser, browser_probe
6
+ from .snapshot import write_snapshot
7
+ from . import cfbypass
8
+
9
+ # 注意:search_engine 子模块的入口函数也叫 search_engine,不在此重导出——否则
10
+ # `from ..fetch import search_engine` 拿到函数后会覆盖系统绑定的子模块(Python 同名陷阱)。
11
+ # 调用方直接 from ..fetch.search_engine import search_engine。
12
+
13
+ __all__ = [
14
+ "html_to_md",
15
+ "fetch_normal",
16
+ "fetch_with_browser",
17
+ "browser_probe",
18
+ "write_snapshot",
19
+ "cfbypass",
20
+ ]