"""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)}"