sciverse 0.3.0__py3-none-any.whl

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.
sciverse/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """SciVerse Agent Tools - Python SDK."""
2
+ from sciverse.client import AgentToolsClient
3
+ from sciverse.credentials import (
4
+ credentials_path,
5
+ delete_credentials,
6
+ load_credentials,
7
+ resolve_endpoint,
8
+ resolve_token,
9
+ save_credentials,
10
+ )
11
+ from sciverse.tools import OPENAI_TOOLS, ANTHROPIC_TOOLS, TOOLS_VERSION
12
+
13
+ __version__ = "0.3.0"
14
+ __all__ = [
15
+ "AgentToolsClient",
16
+ "OPENAI_TOOLS",
17
+ "ANTHROPIC_TOOLS",
18
+ "TOOLS_VERSION",
19
+ "credentials_path",
20
+ "delete_credentials",
21
+ "load_credentials",
22
+ "resolve_endpoint",
23
+ "resolve_token",
24
+ "save_credentials",
25
+ ]
sciverse/cli.py ADDED
@@ -0,0 +1,142 @@
1
+ """`sciverse` CLI —— 主要为用户提供本地凭据管理。
2
+
3
+ 设计选择:当前不实现完整 OAuth device-code flow(需要后端 + 控制台前端配套)。
4
+ 走"token paste"模式 —— 体验类似 `aws configure` / `gh auth login (--with-token)`,
5
+ 用户从控制台复制 token 粘贴,CLI 保存到 ~/.sciverse/credentials.json (0600)。
6
+ 后续后端就绪后可无痛升级到 device-code(保留同样命令接口)。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ import webbrowser
13
+ from typing import Sequence
14
+
15
+ from .credentials import (
16
+ DEFAULT_ENDPOINT,
17
+ credentials_path,
18
+ delete_credentials,
19
+ load_credentials,
20
+ save_credentials,
21
+ )
22
+
23
+ CONSOLE_TOKEN_URL = "https://sciverse.space/tokens"
24
+
25
+
26
+ def _cmd_login(args: argparse.Namespace) -> int:
27
+ print("SciVerse 登录")
28
+ print("-" * 40)
29
+ print(f"1. 浏览器将打开控制台 token 页:{CONSOLE_TOKEN_URL}")
30
+ print("2. 登录后创建 / 复制一个 API Token(形如 sv-xxx)")
31
+ print("3. 把 token 粘贴回这里")
32
+ print()
33
+
34
+ if not args.no_browser:
35
+ try:
36
+ webbrowser.open(CONSOLE_TOKEN_URL)
37
+ except Exception:
38
+ print(f"[warn] 无法自动打开浏览器,请手动访问 {CONSOLE_TOKEN_URL}")
39
+
40
+ # 用 getpass 避免 token 在 shell history 留痕
41
+ if args.token:
42
+ token = args.token.strip()
43
+ else:
44
+ try:
45
+ from getpass import getpass
46
+ token = getpass("Token: ").strip()
47
+ except (EOFError, KeyboardInterrupt):
48
+ print("\n已取消", file=sys.stderr)
49
+ return 1
50
+
51
+ if not token:
52
+ print("错误:token 为空", file=sys.stderr)
53
+ return 1
54
+ if not token.startswith("sv-"):
55
+ print(
56
+ "[warn] token 看起来不像 SciVerse 格式(应以 sv- 开头),"
57
+ "但仍按用户输入保存。",
58
+ file=sys.stderr,
59
+ )
60
+
61
+ endpoint = args.endpoint or DEFAULT_ENDPOINT
62
+ path = save_credentials(token, endpoint)
63
+ print(f"✓ 凭据已保存到 {path}(权限 0600)")
64
+ print(f" endpoint: {endpoint}")
65
+ print()
66
+ print("现在 Python / TS SDK / MCP server 不需要再传 token —— 会自动读取本文件。")
67
+ print("更换 token:再跑一次 `sciverse auth login`。")
68
+ print("删除:`sciverse auth logout`。")
69
+ return 0
70
+
71
+
72
+ def _cmd_logout(_: argparse.Namespace) -> int:
73
+ if delete_credentials():
74
+ print(f"✓ 已删除 {credentials_path()}")
75
+ return 0
76
+ print(f"凭据文件不存在:{credentials_path()}", file=sys.stderr)
77
+ return 1
78
+
79
+
80
+ def _cmd_status(_: argparse.Namespace) -> int:
81
+ path = credentials_path()
82
+ creds = load_credentials()
83
+ if creds is None:
84
+ print(f"未登录。运行 `sciverse auth login` 完成登录。")
85
+ print(f"凭据文件路径(未创建):{path}")
86
+ return 1
87
+ token = creds.get("token", "")
88
+ masked = (token[:6] + "…" + token[-4:]) if len(token) > 12 else "***"
89
+ print(f"已登录")
90
+ print(f" 凭据文件: {path}")
91
+ print(f" token: {masked}")
92
+ print(f" endpoint: {creds.get('endpoint', DEFAULT_ENDPOINT)}")
93
+ print(f" 保存时间: {creds.get('saved_at', '?')}")
94
+ return 0
95
+
96
+
97
+ def _build_parser() -> argparse.ArgumentParser:
98
+ parser = argparse.ArgumentParser(
99
+ prog="sciverse",
100
+ description="SciVerse 开发者工具 CLI。",
101
+ )
102
+ sub = parser.add_subparsers(dest="command", required=True)
103
+
104
+ auth = sub.add_parser("auth", help="本地凭据管理")
105
+ auth_sub = auth.add_subparsers(dest="auth_command", required=True)
106
+
107
+ login = auth_sub.add_parser(
108
+ "login",
109
+ help="保存 API Token 到 ~/.sciverse/credentials.json",
110
+ )
111
+ login.add_argument(
112
+ "--token",
113
+ help="直接传入 token(跳过交互式粘贴;适合脚本场景)",
114
+ )
115
+ login.add_argument(
116
+ "--endpoint",
117
+ help=f"API endpoint,默认 {DEFAULT_ENDPOINT}",
118
+ )
119
+ login.add_argument(
120
+ "--no-browser",
121
+ action="store_true",
122
+ help="不自动打开浏览器(远程 / 无桌面环境)",
123
+ )
124
+ login.set_defaults(func=_cmd_login)
125
+
126
+ logout = auth_sub.add_parser("logout", help="删除本地凭据文件")
127
+ logout.set_defaults(func=_cmd_logout)
128
+
129
+ status = auth_sub.add_parser("status", help="查看当前凭据状态")
130
+ status.set_defaults(func=_cmd_status)
131
+
132
+ return parser
133
+
134
+
135
+ def main(argv: Sequence[str] | None = None) -> int:
136
+ parser = _build_parser()
137
+ args = parser.parse_args(argv)
138
+ return args.func(args)
139
+
140
+
141
+ if __name__ == "__main__":
142
+ raise SystemExit(main())
sciverse/client.py ADDED
@@ -0,0 +1,177 @@
1
+ """SciVerse Agent Tools 异步 HTTP client。"""
2
+ from __future__ import annotations
3
+
4
+ import platform
5
+ import uuid
6
+ from types import TracebackType
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ _SKILL_NAME = "sciverse"
12
+ _CHANNEL = "python-sdk"
13
+ _PLATFORM = platform.system().lower() # "linux" | "darwin" | "windows"
14
+
15
+
16
+ _PASSTHROUGH = ("query", "page", "page_size", "fields")
17
+
18
+
19
+ def _to_backend_payload(kwargs: dict[str, Any]) -> dict[str, Any]:
20
+ """把 search_papers 高级参数映射为 platform-console MetaSearchBody 接受的
21
+ canonical 格式(query/filters/sort/fields/page/page_size)。"""
22
+ out: dict[str, Any] = {}
23
+ filters: list[dict[str, Any]] = []
24
+ sort: list[dict[str, str]] = []
25
+
26
+ for k in _PASSTHROUGH:
27
+ if k in kwargs and kwargs[k] is not None:
28
+ out[k] = kwargs[k]
29
+
30
+ def _filter(field: str, op: str, value: Any) -> None:
31
+ filters.append({"field": field, "operator": op, "value": value})
32
+
33
+ if (v := kwargs.get("title_contains")) is not None:
34
+ _filter("title", "FILTER_OP_CONTAINS", v)
35
+ if (v := kwargs.get("abstract_contains")) is not None:
36
+ _filter("abstract", "FILTER_OP_CONTAINS", v)
37
+ if (v := kwargs.get("authors")) is not None and len(v) > 0:
38
+ _filter("author", "FILTER_OP_IN", list(v))
39
+ if (v := kwargs.get("year_from")) is not None:
40
+ _filter("publication_published_year", "FILTER_OP_GTE", v)
41
+ if (v := kwargs.get("year_to")) is not None:
42
+ _filter("publication_published_year", "FILTER_OP_LTE", v)
43
+ if (v := kwargs.get("journals")) is not None and len(v) > 0:
44
+ _filter("publication_venue_name", "FILTER_OP_IN", list(v))
45
+ if (v := kwargs.get("subjects")) is not None and len(v) > 0:
46
+ _filter("subjects", "FILTER_OP_IN", list(v))
47
+ if (v := kwargs.get("filters_advanced")) is not None:
48
+ for item in v:
49
+ entry = dict(item)
50
+ entry.setdefault("operator", "FILTER_OP_EQ")
51
+ filters.append(entry)
52
+
53
+ sort_by_year = kwargs.get("sort_by_year")
54
+ if sort_by_year and sort_by_year != "none":
55
+ sort.append({
56
+ "field": "publication_published_year",
57
+ "order": "SORT_ORDER_DESC" if sort_by_year == "desc" else "SORT_ORDER_ASC",
58
+ })
59
+
60
+ if filters:
61
+ out["filters"] = filters
62
+ if sort:
63
+ out["sort"] = sort
64
+ return out
65
+
66
+
67
+ class AgentToolsClient:
68
+ """封装 SciVerse 三个对外检索接口的 Bearer-authenticated 异步 client。
69
+
70
+ 用法:
71
+ async with AgentToolsClient(base_url=..., token=...) as c:
72
+ r = await c.semantic_search(query="...")
73
+
74
+ token / base_url 都可省略 —— 省略时按以下顺序 fallback:
75
+ 1. 显式参数
76
+ 2. 环境变量 SCIVERSE_API_TOKEN / SCIVERSE_BASE_URL
77
+ 3. ~/.sciverse/credentials.json(由 `sciverse auth login` 写入)
78
+ 4. base_url 默认值 https://api.sciverse.space;token 找不到则抛 ValueError
79
+ """
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ base_url: str | None = None,
85
+ token: str | None = None,
86
+ timeout: float = 30.0,
87
+ ) -> None:
88
+ from sciverse.credentials import resolve_endpoint, resolve_token
89
+ resolved_token = resolve_token(token)
90
+ if not resolved_token:
91
+ raise ValueError(
92
+ "未找到 SciVerse API Token。请显式传 token、或设 SCIVERSE_API_TOKEN 环境变量、"
93
+ "或运行 `sciverse auth login` 保存凭据到 ~/.sciverse/credentials.json。"
94
+ )
95
+ self._base_url = resolve_endpoint(base_url).rstrip("/")
96
+ self._token = resolved_token
97
+ self._client = httpx.AsyncClient(
98
+ base_url=self._base_url,
99
+ timeout=timeout,
100
+ headers={"Authorization": f"Bearer {resolved_token}"},
101
+ event_hooks={"request": [self._inject_request_id]},
102
+ )
103
+
104
+ @staticmethod
105
+ async def _inject_request_id(request: httpx.Request) -> None:
106
+ request.headers["X-Request-Id"] = f"{_SKILL_NAME}-{_PLATFORM}-{_CHANNEL}-{uuid.uuid4()}"
107
+
108
+ async def __aenter__(self) -> "AgentToolsClient":
109
+ return self
110
+
111
+ async def __aexit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
112
+ await self.aclose()
113
+
114
+ async def aclose(self) -> None:
115
+ await self._client.aclose()
116
+
117
+ async def search_papers(self, **kwargs: Any) -> dict[str, Any]:
118
+ """对应 POST /meta-search。
119
+
120
+ 参数会被转换为后端 canonical 格式。Agent 友好参数:
121
+ - query, page, page_size, fields (透传)
122
+ - title_contains, abstract_contains → filter CONTAINS
123
+ - authors → filter author IN
124
+ - year_from, year_to → filter publication_published_year GTE/LTE
125
+ - journals → filter publication_venue_name IN
126
+ - subjects → filter subjects IN
127
+ - filters_advanced → 直接拼到 filters 列表
128
+ - sort_by_year → sort publication_published_year DESC/ASC
129
+ """
130
+ body = _to_backend_payload(kwargs)
131
+ resp = await self._client.post("/meta-search", json=body)
132
+ resp.raise_for_status()
133
+ return resp.json()
134
+
135
+ async def semantic_search(self, *, query: str, **kwargs: Any) -> dict[str, Any]:
136
+ """对应 POST /agentic-search。"""
137
+ body = {"query": query, **{k: v for k, v in kwargs.items() if v is not None}}
138
+ resp = await self._client.post("/agentic-search", json=body)
139
+ resp.raise_for_status()
140
+ return resp.json()
141
+
142
+ async def list_catalog(self, *, include_sample_values: bool = False) -> dict[str, Any]:
143
+ """对应 GET /meta-catalog。
144
+
145
+ 返回字段 catalog:每个字段的名称 / 类型 / filterable / sortable / default_returned /
146
+ 描述 / 适用 FilterOperator,外加 enum-like 字段的样本值(include_sample_values=True 时)。
147
+ Agent 第一次接触 SciVerse 或碰到字段不确定时建议先调一次再构造 search_papers。
148
+ """
149
+ params = {"include_sample_values": str(include_sample_values).lower()}
150
+ resp = await self._client.get("/meta-catalog", params=params)
151
+ resp.raise_for_status()
152
+ return resp.json()
153
+
154
+ async def read_content(self, *, doc_id: str, offset: int = 0, limit: int = 4096) -> dict[str, Any]:
155
+ """对应 GET /content。"""
156
+ params = {"doc_id": doc_id, "offset": offset, "limit": limit}
157
+ resp = await self._client.get("/content", params=params)
158
+ resp.raise_for_status()
159
+ return resp.json()
160
+
161
+ async def get_resource(self, *, file_name: str) -> tuple[bytes, str]:
162
+ """对应 GET /resource。
163
+
164
+ 取文献附属图片字节流。触发场景:read_content 返回的 Markdown 中含
165
+ `![alt](file_name)` 图片占位时,调本接口拿图片 binary。
166
+
167
+ 返回 (bytes, mime_type)。mime_type 来自响应头 content-type,如
168
+ "image/png" / "image/jpeg" / "application/octet-stream"。
169
+ """
170
+ resp = await self._client.get(
171
+ "/resource",
172
+ params={"file_name": file_name},
173
+ headers={"accept": "image/*"},
174
+ )
175
+ resp.raise_for_status()
176
+ mime = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
177
+ return resp.content, mime
@@ -0,0 +1,107 @@
1
+ """共享凭据文件读写。
2
+
3
+ 约定(与 MCP server / TS SDK 一致):
4
+ - 路径:`~/.sciverse/credentials.json`
5
+ - 文件权限:0600(仅当前用户可读写)
6
+ - 内容:
7
+ {
8
+ "token": "sv-xxx",
9
+ "endpoint": "https://api.sciverse.space",
10
+ "saved_at": "2026-05-14T15:30:00Z"
11
+ }
12
+
13
+ 读取顺序(client.py / cli.py 共用):
14
+ 1. 显式构造 client 传 token
15
+ 2. 环境变量 SCIVERSE_API_TOKEN
16
+ 3. 凭据文件
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import stat
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from typing import TypedDict
26
+
27
+
28
+ class Credentials(TypedDict, total=False):
29
+ token: str
30
+ endpoint: str
31
+ saved_at: str
32
+
33
+
34
+ DEFAULT_ENDPOINT = "https://api.sciverse.space"
35
+
36
+
37
+ def credentials_path() -> Path:
38
+ return Path.home() / ".sciverse" / "credentials.json"
39
+
40
+
41
+ def load_credentials() -> Credentials | None:
42
+ """读凭据文件。文件不存在 / 解析失败 / 不是 dict 时返回 None(不抛错)。"""
43
+ path = credentials_path()
44
+ if not path.exists():
45
+ return None
46
+ try:
47
+ data = json.loads(path.read_text(encoding="utf-8"))
48
+ except (OSError, json.JSONDecodeError):
49
+ return None
50
+ if not isinstance(data, dict):
51
+ return None
52
+ return data # type: ignore[return-value]
53
+
54
+
55
+ def save_credentials(token: str, endpoint: str = DEFAULT_ENDPOINT) -> Path:
56
+ """保存凭据到 ~/.sciverse/credentials.json,文件权限设为 0600。返回 path。"""
57
+ path = credentials_path()
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ payload: Credentials = {
60
+ "token": token,
61
+ "endpoint": endpoint,
62
+ "saved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
63
+ }
64
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
65
+ # 0600
66
+ try:
67
+ path.chmod(stat.S_IRUSR | stat.S_IWUSR)
68
+ except OSError:
69
+ # Windows / 某些 FS 不支持 chmod;保留文件,不抛错
70
+ pass
71
+ return path
72
+
73
+
74
+ def delete_credentials() -> bool:
75
+ """删凭据文件。文件不存在时返回 False,删除成功返回 True。"""
76
+ path = credentials_path()
77
+ if not path.exists():
78
+ return False
79
+ path.unlink()
80
+ return True
81
+
82
+
83
+ def resolve_token(explicit: str | None = None) -> str | None:
84
+ """按 [显式参数 → SCIVERSE_API_TOKEN → 凭据文件] 顺序返回 token,
85
+ 都没有时返回 None(不抛错;让调用方决定怎么提示用户)。"""
86
+ if explicit:
87
+ return explicit
88
+ env_token = os.environ.get("SCIVERSE_API_TOKEN")
89
+ if env_token:
90
+ return env_token
91
+ creds = load_credentials()
92
+ if creds and creds.get("token"):
93
+ return creds["token"]
94
+ return None
95
+
96
+
97
+ def resolve_endpoint(explicit: str | None = None) -> str:
98
+ """按 [显式参数 → SCIVERSE_BASE_URL → 凭据文件 → 默认值] 顺序返回 endpoint。"""
99
+ if explicit:
100
+ return explicit
101
+ env_url = os.environ.get("SCIVERSE_BASE_URL")
102
+ if env_url:
103
+ return env_url
104
+ creds = load_credentials()
105
+ if creds and creds.get("endpoint"):
106
+ return creds["endpoint"]
107
+ return DEFAULT_ENDPOINT
sciverse/py.typed ADDED
File without changes