zcbot/tools/paper_server.py

363 lines
13 KiB
Python

"""Host-side paper_server tools.
PAPER_SERVER_API_KEY stays in the host control plane. The model receives only
typed business arguments and trimmed JSON / task-local file paths.
"""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urljoin, urlparse
import httpx
from .base import Tool
_DEFAULT_BASE_URL = "http://paper.xxhhcty.xyz:8080"
_TIMEOUT = 30.0
_DOWNLOAD_TIMEOUT = 60.0
_MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024
_TYPE_RE = re.compile(r"^[A-Za-z0-9_-]{1,64}$")
_AUTH_ERR_CODES = {"not_authenticated", "authentication_failed", "permission_denied"}
_LIST_FIELDS = (
"id",
"doi",
"title",
"first_author",
"first_author_institution",
"publication_year",
"publication_date",
"publication_name",
"has_fulltext_pdf",
"has_fulltext_xml",
"has_abstract",
"is_oa",
"type",
"abstract",
"pdf_url",
"xml_url",
)
def _config() -> tuple[str, str, str]:
base_url = os.environ.get("PAPER_SERVER_URL", _DEFAULT_BASE_URL).strip().rstrip("/")
api_key = os.environ.get("PAPER_SERVER_API_KEY", "").strip()
if not api_key:
raise RuntimeError("PAPER_SERVER_API_KEY env 未设置,无法查询 paper_server")
return base_url, f"{base_url}/api/resm/paper", api_key
def _params(api_key: str, values: Optional[dict[str, Any]] = None) -> dict[str, Any]:
result = dict(values or {})
result["api_key"] = api_key
return result
def _raise_response_error(response: httpx.Response) -> None:
err_code = ""
if response.status_code in (400, 401, 403):
try:
response.read()
body = response.json()
if isinstance(body, dict):
err_code = str(body.get("err_code") or "")
except Exception:
pass
if response.status_code in (401, 403) or err_code in _AUTH_ERR_CODES:
raise RuntimeError(
f"paper_server auth failed (HTTP {response.status_code}, "
f"{err_code or 'no err_code'}):请管理员检查平台 PAPER_SERVER_API_KEY"
)
if response.status_code >= 400:
# 不调用 raise_for_status():HTTPStatusError 会携带含 api_key 的完整请求 URL。
raise RuntimeError(f"paper_server request failed (HTTP {response.status_code})")
def _get_json(
url: str, *, api_key: str, params: Optional[dict[str, Any]] = None
) -> Any:
try:
response = httpx.get(
url,
params=_params(api_key, params),
timeout=_TIMEOUT,
)
except httpx.RequestError as exc:
raise RuntimeError(
f"paper_server connection failed: {type(exc).__name__}"
) from None
_raise_response_error(response)
try:
return response.json()
except ValueError:
raise RuntimeError("paper_server returned invalid JSON") from None
def _results(data: Any) -> list[dict[str, Any]]:
values = (
data.get("results") if isinstance(data, dict) and "results" in data else data
)
if not isinstance(values, list):
raise RuntimeError("paper_server returned an unexpected result shape")
return [item for item in values if isinstance(item, dict)]
def _is_doi(value: str) -> bool:
return "/" in value and value.lstrip().startswith("10.")
def _resolve_to_id(id_or_doi: str, api_url: str, api_key: str) -> str:
value = str(id_or_doi or "").strip()
if not value:
raise ValueError("id_or_doi 不能为空")
if not _is_doi(value):
return value
matches = _results(_get_json(api_url + "/", api_key=api_key, params={"doi": value}))
if not matches:
raise ValueError(f"doi 未命中:{value}")
if len(matches) > 1:
raise ValueError(f"doi 命中多条({len(matches)}):{value}")
paper_id = str(matches[0].get("id") or "").strip()
if not paper_id:
raise RuntimeError("paper_server DOI 查询结果缺少 id")
return paper_id
def _get_paper(id_or_doi: str) -> dict[str, Any]:
_base_url, api_url, api_key = _config()
paper_id = _resolve_to_id(id_or_doi, api_url, api_key)
data = _get_json(f"{api_url}/{paper_id}/", api_key=api_key)
if not isinstance(data, dict):
raise RuntimeError("paper_server returned an unexpected paper shape")
return data
def _safe_stem(value: str) -> str:
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", value.strip()).strip("._")
return stem[:180] or "paper"
def _media_url(raw_url: str, base_url: str) -> str:
url = urljoin(base_url + "/", raw_url)
parsed = urlparse(url)
expected = urlparse(base_url)
if parsed.scheme not in ("http", "https") or (
parsed.scheme.lower(),
parsed.netloc.lower(),
) != (expected.scheme.lower(), expected.netloc.lower()):
raise RuntimeError("paper_server returned an out-of-origin media URL")
return url
class PaperSearchTool(Tool):
name = "paper_search"
description = (
"Search the platform paper_server metadata collection. "
"Use publication_type for server-side source-type filtering, e.g. 'book', "
"'book-chapter' or 'article'. publication_type uses paper_server/OpenAlex raw "
"values; normalized literature type book_chapter is represented here as raw "
"'book-chapter'. Prefer English keywords."
)
parameters = {
"type": "object",
"properties": {
"keyword": {
"type": "string",
"description": "Fuzzy title/author/institution query.",
},
"publication_type": {
"type": "string",
"description": "Exact raw source type, e.g. book or book-chapter.",
},
"year": {"type": "integer", "description": "Exact publication year."},
"year_gte": {"type": "integer", "description": "Minimum publication year."},
"year_lte": {"type": "integer", "description": "Maximum publication year."},
"doi": {"type": "string", "description": "Exact DOI."},
"first_author": {
"type": "string",
"description": "Exact first-author name.",
},
"publication_name": {
"type": "string",
"description": "Exact journal/container name.",
},
"has_pdf": {
"type": "boolean",
"description": "Filter by PDF availability.",
},
"is_oa": {
"type": "boolean",
"description": "Filter by open-access status.",
},
"limit": {
"type": "integer",
"default": 10,
"description": "Maximum records, 1-50.",
},
},
}
def execute(
self,
keyword: str = "",
publication_type: str = "",
year: Optional[int] = None,
year_gte: Optional[int] = None,
year_lte: Optional[int] = None,
doi: str = "",
first_author: str = "",
publication_name: str = "",
has_pdf: Optional[bool] = None,
is_oa: Optional[bool] = None,
limit: int = 10,
) -> str:
raw_type = str(publication_type or "").strip()
if raw_type and not _TYPE_RE.fullmatch(raw_type):
return (
"[Error] publication_type 只能包含字母、数字、下划线或连字符,"
"最长 64 字符"
)
limit = min(max(int(limit), 1), 50)
params: dict[str, Any] = {"page_size": limit}
optional = {
"search": str(keyword or "").strip(),
"type": raw_type,
"doi": str(doi or "").strip(),
"first_author": str(first_author or "").strip(),
"publication_name": str(publication_name or "").strip(),
}
params.update({key: value for key, value in optional.items() if value})
if year is not None:
params["publication_year"] = int(year)
if year_gte is not None:
params["publication_year_gte"] = int(year_gte)
if year_lte is not None:
params["publication_year_lte"] = int(year_lte)
if has_pdf is not None:
params["has_fulltext_pdf"] = "true" if has_pdf else "false"
if is_oa is not None:
params["is_oa"] = "true" if is_oa else "false"
try:
_base_url, api_url, api_key = _config()
papers = _results(_get_json(api_url + "/", api_key=api_key, params=params))
except Exception as exc:
return f"[Error] paper_search failed:{type(exc).__name__}:{exc}"
trimmed = [
{key: paper.get(key) for key in _LIST_FIELDS} for paper in papers[:limit]
]
return json.dumps(trimmed, ensure_ascii=False, indent=2)
class PaperGetTool(Tool):
name = "paper_get"
description = (
"Get one complete paper_server metadata record by internal id or exact DOI."
)
parameters = {
"type": "object",
"properties": {
"id_or_doi": {"type": "string", "description": "paper_server id or DOI."},
},
"required": ["id_or_doi"],
}
def execute(self, id_or_doi: str) -> str:
try:
paper = _get_paper(id_or_doi)
except Exception as exc:
return f"[Error] paper_get failed:{type(exc).__name__}:{exc}"
return json.dumps(paper, ensure_ascii=False, indent=2)
class PaperFetchTool(Tool):
name = "paper_fetch"
description = (
"Download an available PDF or XML from paper_server into the current task's "
"papers/ directory. Use the format actually reported by paper_search/paper_get."
)
parameters = {
"type": "object",
"properties": {
"id_or_doi": {"type": "string", "description": "paper_server id or DOI."},
"format": {
"type": "string",
"enum": ["pdf", "xml"],
"description": "File format to download.",
},
},
"required": ["id_or_doi", "format"],
}
def __init__(
self,
*,
working_dir: Path,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir=base_dir, user_root=user_root)
self.working_dir = Path(working_dir)
def execute(self, id_or_doi: str, format: str) -> str: # noqa: A002 - JSON tool contract
file_format = str(format or "").lower().strip()
if file_format not in {"pdf", "xml"}:
return "[Error] format 必须是 pdf 或 xml"
try:
paper = _get_paper(id_or_doi)
available_key = f"has_fulltext_{file_format}"
if not paper.get(available_key):
reason = (
paper.get("fail_reason") or f"no {file_format.upper()} on server"
)
raise RuntimeError(f"paper has no {file_format.upper()}:{reason}")
raw_url = str(paper.get(f"{file_format}_url") or "").strip()
if not raw_url:
raise RuntimeError(f"paper {file_format}_url unavailable")
base_url, _api_url, api_key = _config()
media_url = _media_url(raw_url, base_url)
identity = str(paper.get("doi") or paper.get("id") or id_or_doi)
destination = (
self.working_dir / "papers" / f"{_safe_stem(identity)}.{file_format}"
)
if destination.exists() and destination.stat().st_size > 0:
return f"saved:{self._display(destination)} (existing)"
destination.parent.mkdir(parents=True, exist_ok=True)
partial = destination.with_suffix(destination.suffix + ".part")
total = 0
try:
with httpx.stream(
"GET",
media_url,
params=_params(api_key),
timeout=_DOWNLOAD_TIMEOUT,
) as response:
_raise_response_error(response)
with partial.open("wb") as handle:
for chunk in response.iter_bytes(chunk_size=64 * 1024):
total += len(chunk)
if total > _MAX_DOWNLOAD_BYTES:
message = (
"paper_server file exceeds "
f"{_MAX_DOWNLOAD_BYTES} bytes"
)
raise RuntimeError(message)
handle.write(chunk)
partial.replace(destination)
except httpx.RequestError as exc:
raise RuntimeError(
f"paper_server download connection failed:{type(exc).__name__}"
) from None
finally:
if partial.exists():
try:
partial.unlink()
except OSError:
pass
except Exception as exc:
return f"[Error] paper_fetch failed:{type(exc).__name__}:{exc}"
return f"saved:{self._display(destination)}"