392 lines
16 KiB
Python
Executable File
392 lines
16 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Dispatcher for the 27 AMiner Open Platform functions exposed via the
|
|
z-ai gateway's /v1/functions/invoke endpoint.
|
|
|
|
Each action maps 1:1 to an `aminer_*` function registered in the gateway.
|
|
Arguments are collected via argparse and forwarded as the `arguments` field.
|
|
Upstream response is printed as formatted JSON.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import pathlib
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any, Callable, Tuple
|
|
|
|
REQUEST_TIMEOUT = 60
|
|
CONFIG_PATHS = [
|
|
pathlib.Path.cwd() / ".z-ai-config",
|
|
pathlib.Path.home() / ".z-ai-config",
|
|
pathlib.Path("/etc/.z-ai-config"),
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Action registry
|
|
#
|
|
# Each entry defines one AMiner Open Platform API:
|
|
# function_name: matches the key in handlers/aminer_open.go's AminerOpenSpecs
|
|
# args: (flag, dest, kind, help) — kind is "str"/"int"/"float"/
|
|
# "bool"/"json" (parsed from JSON for list/dict/nested values)
|
|
#
|
|
# This list is the *single source of truth* for what params each action takes,
|
|
# so the model only has to read one structure to learn the whole surface.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_Arg = Tuple[str, str, str, str] # (flag, dest, kind, help)
|
|
|
|
ACTIONS: dict[str, dict[str, Any]] = {
|
|
# ── Paper ────────────────────────────────────────────────────────────
|
|
"paper_search": {
|
|
"function": "aminer_paper_search",
|
|
"help": "Locate a paper_id by (partial) title.",
|
|
"args": [
|
|
("--title", "title", "str", "Paper title keyword (required)"),
|
|
("--page", "page", "int", "Page number, default 1"),
|
|
("--size", "size", "int", "Page size, default 10"),
|
|
],
|
|
},
|
|
"paper_search_pro": {
|
|
"function": "aminer_paper_search_pro",
|
|
"help": "Multi-condition paper search (author/org/venue/keyword).",
|
|
"args": [
|
|
("--title", "title", "str", "Title keyword"),
|
|
("--keyword", "keyword", "str", "Subject keyword"),
|
|
("--abstract", "abstract", "str", "Abstract keyword"),
|
|
("--author", "author", "str", "Author name"),
|
|
("--org", "org", "str", "Organization name"),
|
|
("--venue", "venue", "str", "Venue name"),
|
|
("--order", "order", "str", "Sort: citation | year"),
|
|
("--page", "page", "int", "Page number, default 0"),
|
|
("--size", "size", "int", "Page size, default 10"),
|
|
],
|
|
},
|
|
"paper_qa_search": {
|
|
"function": "aminer_paper_qa_search",
|
|
"help": "AI Q&A-style search. 'query' and 'topic_*' are mutually exclusive.",
|
|
"args": [
|
|
("--query", "query", "str", "Natural language question"),
|
|
("--use-topic", "use_topic", "bool", "Use topic_* fields instead of query"),
|
|
("--topic-high", "topic_high", "str", "High-level topic"),
|
|
("--topic-middle", "topic_middle", "str", "Mid-level topic"),
|
|
("--topic-low", "topic_low", "str", "Low-level topic"),
|
|
("--title", "title", "json", "List of titles (JSON array)"),
|
|
("--doi", "doi", "str", "DOI"),
|
|
("--year", "year", "json", "Year filter, e.g. [2020,2024]"),
|
|
("--sci-flag", "sci_flag", "bool", "Restrict to SCI venues"),
|
|
("--n-citation-flag", "n_citation_flag", "bool", "Return citation count"),
|
|
("--force-citation-sort", "force_citation_sort", "bool", "Sort by citations"),
|
|
("--force-year-sort", "force_year_sort", "bool", "Sort by year"),
|
|
("--author-terms", "author_terms", "json", "Author name list"),
|
|
("--org-terms", "org_terms", "json", "Org name list"),
|
|
("--author-id", "author_id", "json", "Author ID list"),
|
|
("--org-id", "org_id", "json", "Org ID list"),
|
|
("--venue-ids", "venue_ids", "json", "Venue ID list"),
|
|
("--size", "size", "int", "Page size, default 10"),
|
|
("--offset", "offset", "int", "Offset, default 0"),
|
|
],
|
|
},
|
|
"paper_info": {
|
|
"function": "aminer_paper_info",
|
|
"help": "Batch retrieve papers by ID list.",
|
|
"args": [
|
|
("--ids", "ids", "json", "JSON array of paper_id strings (required)"),
|
|
],
|
|
},
|
|
"paper_detail": {
|
|
"function": "aminer_paper_detail",
|
|
"help": "Full paper info for a single paper_id.",
|
|
"args": [
|
|
("--id", "id", "str", "paper_id (required)"),
|
|
],
|
|
},
|
|
"paper_relation": {
|
|
"function": "aminer_paper_relation",
|
|
"help": "Citation chain (cited papers) for a paper_id.",
|
|
"args": [
|
|
("--id", "id", "str", "paper_id (required)"),
|
|
],
|
|
},
|
|
"paper_list_by_keywords": {
|
|
"function": "aminer_paper_list_by_keywords",
|
|
"help": "Batch keyword retrieval returning abstracts + metadata.",
|
|
"args": [
|
|
("--keywords", "keywords", "json", "JSON array of keyword strings (required)"),
|
|
("--page", "page", "int", "Page number, default 0"),
|
|
("--size", "size", "int", "Page size, default 10"),
|
|
],
|
|
},
|
|
"paper_detail_by_condition": {
|
|
"function": "aminer_paper_detail_by_condition",
|
|
"help": "Year + venue dimension lookup. Year + venue_id both required.",
|
|
"args": [
|
|
("--year", "year", "int", "Year (required)"),
|
|
("--venue-id", "venue_id", "str", "Venue ID (required)"),
|
|
],
|
|
},
|
|
# ── Scholar ──────────────────────────────────────────────────────────
|
|
"person_search": {
|
|
"function": "aminer_person_search",
|
|
"help": "Search scholars by name and/or org.",
|
|
"args": [
|
|
("--name", "name", "str", "Scholar name"),
|
|
("--org", "org", "str", "Organization name"),
|
|
("--org-id", "org_id", "json", "List of org IDs"),
|
|
("--offset", "offset", "int", "Offset, default 0"),
|
|
("--size", "size", "int", "Page size, default 5"),
|
|
],
|
|
},
|
|
"person_detail": {
|
|
"function": "aminer_person_detail",
|
|
"help": "Full scholar profile (bio/education/honors).",
|
|
"args": [("--id", "id", "str", "person_id (required)")],
|
|
},
|
|
"person_figure": {
|
|
"function": "aminer_person_figure",
|
|
"help": "Scholar portrait (interests, work history).",
|
|
"args": [("--id", "id", "str", "person_id (required)")],
|
|
},
|
|
"person_paper_relation": {
|
|
"function": "aminer_person_paper_relation",
|
|
"help": "List of papers by this scholar.",
|
|
"args": [("--id", "id", "str", "person_id (required)")],
|
|
},
|
|
"person_patent_relation": {
|
|
"function": "aminer_person_patent_relation",
|
|
"help": "List of patents by this scholar.",
|
|
"args": [("--id", "id", "str", "person_id (required)")],
|
|
},
|
|
"person_project": {
|
|
"function": "aminer_person_project",
|
|
"help": "Research projects (funding, dates, source).",
|
|
"args": [("--id", "id", "str", "person_id (required)")],
|
|
},
|
|
# ── Organization ─────────────────────────────────────────────────────
|
|
"org_search": {
|
|
"function": "aminer_org_search",
|
|
"help": "Search institutions by name keyword.",
|
|
"args": [("--orgs", "orgs", "json", "JSON array of org name strings (required)")],
|
|
},
|
|
"org_detail": {
|
|
"function": "aminer_org_detail",
|
|
"help": "Org details by ID list.",
|
|
"args": [("--ids", "ids", "json", "JSON array of org_id strings (required)")],
|
|
},
|
|
"org_person_relation": {
|
|
"function": "aminer_org_person_relation",
|
|
"help": "Affiliated scholars (10 per call).",
|
|
"args": [
|
|
("--org-id", "org_id", "str", "org_id (required)"),
|
|
("--offset", "offset", "int", "Offset, default 0"),
|
|
],
|
|
},
|
|
"org_paper_relation": {
|
|
"function": "aminer_org_paper_relation",
|
|
"help": "Papers authored by org members (10 per call).",
|
|
"args": [
|
|
("--org-id", "org_id", "str", "org_id (required)"),
|
|
("--offset", "offset", "int", "Offset, default 0"),
|
|
],
|
|
},
|
|
"org_patent_relation": {
|
|
"function": "aminer_org_patent_relation",
|
|
"help": "Org patent list (max page_size 10,000).",
|
|
"args": [
|
|
("--id", "id", "str", "org_id (required)"),
|
|
("--page", "page", "int", "Page number, default 1"),
|
|
("--page-size", "page_size", "int", "Page size, default 100"),
|
|
],
|
|
},
|
|
"org_disambiguate": {
|
|
"function": "aminer_org_disambiguate",
|
|
"help": "Normalize raw org string.",
|
|
"args": [("--org", "org", "str", "Raw org string (required)")],
|
|
},
|
|
"org_disambiguate_pro": {
|
|
"function": "aminer_org_disambiguate_pro",
|
|
"help": "Extract primary/secondary org IDs.",
|
|
"args": [("--org", "org", "str", "Raw org string (required)")],
|
|
},
|
|
# ── Venue ────────────────────────────────────────────────────────────
|
|
"venue_search": {
|
|
"function": "aminer_venue_search",
|
|
"help": "Search journals/conferences by name.",
|
|
"args": [("--name", "name", "str", "Venue name (required)")],
|
|
},
|
|
"venue_detail": {
|
|
"function": "aminer_venue_detail",
|
|
"help": "Venue details (ISSN, abbreviation, type).",
|
|
"args": [("--id", "id", "str", "venue_id (required)")],
|
|
},
|
|
"venue_paper_relation": {
|
|
"function": "aminer_venue_paper_relation",
|
|
"help": "Papers published in a venue, optionally filtered by year.",
|
|
"args": [
|
|
("--id", "id", "str", "venue_id (required)"),
|
|
("--offset", "offset", "int", "Offset, default 0"),
|
|
("--limit", "limit", "int", "Limit, default 20"),
|
|
("--year", "year", "int", "Publication year"),
|
|
],
|
|
},
|
|
# ── Patent ───────────────────────────────────────────────────────────
|
|
"patent_search": {
|
|
"function": "aminer_patent_search",
|
|
"help": "Search patents by name/keyword.",
|
|
"args": [
|
|
("--query", "query", "str", "Search query (required)"),
|
|
("--page", "page", "int", "Page number, default 0"),
|
|
("--size", "size", "int", "Page size, default 10"),
|
|
],
|
|
},
|
|
"patent_info": {
|
|
"function": "aminer_patent_info",
|
|
"help": "Basic patent info.",
|
|
"args": [("--id", "id", "str", "patent_id (required)")],
|
|
},
|
|
"patent_detail": {
|
|
"function": "aminer_patent_detail",
|
|
"help": "Full patent details (abstract, IPC, claims).",
|
|
"args": [("--id", "id", "str", "patent_id (required)")],
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config loading and HTTP
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def load_config() -> dict[str, Any]:
|
|
for path in CONFIG_PATHS:
|
|
try:
|
|
cfg = json.loads(path.read_text(encoding="utf-8"))
|
|
except FileNotFoundError:
|
|
continue
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise SystemExit(f"failed to read {path}: {exc}") from exc
|
|
if cfg.get("baseUrl") and cfg.get("apiKey"):
|
|
if not cfg.get("token"):
|
|
print(
|
|
f"warning: {path} has no 'token' field; the gateway requires "
|
|
"X-Token for auth and will return 401.",
|
|
file=sys.stderr,
|
|
)
|
|
return cfg
|
|
raise SystemExit(
|
|
"z-ai config not found. Create .z-ai-config in cwd, $HOME, or /etc "
|
|
"with {\"baseUrl\": ..., \"apiKey\": ..., \"token\": ...}."
|
|
)
|
|
|
|
|
|
def invoke(config: dict[str, Any], function_name: str, arguments: dict[str, Any]) -> Any:
|
|
base_url = str(config["baseUrl"]).rstrip("/")
|
|
url = f"{base_url}/functions/invoke"
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "aminer-academic-search-skill/1.0",
|
|
"Authorization": f"Bearer {config['apiKey']}",
|
|
"X-Z-AI-From": "Z",
|
|
}
|
|
if config.get("token"):
|
|
headers["X-Token"] = str(config["token"])
|
|
if config.get("chatId"):
|
|
headers["X-Chat-Id"] = str(config["chatId"])
|
|
if config.get("userId"):
|
|
headers["X-User-Id"] = str(config["userId"])
|
|
|
|
body = json.dumps(
|
|
{"function_name": function_name, "arguments": arguments},
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as resp: # nosec B310
|
|
raw = resp.read().decode("utf-8")
|
|
except urllib.error.HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
|
hint = ""
|
|
if exc.code == 401 and "X-Token" in detail:
|
|
hint = " (hint: add a valid 'token' field to .z-ai-config)"
|
|
elif exc.code == 403:
|
|
hint = " (hint: request rejected by auth — check 'apiKey' / X-Z-AI-From)"
|
|
raise SystemExit(f"http {exc.code}: {detail or exc.reason}{hint}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise SystemExit(f"gateway unreachable: {exc.reason}") from exc
|
|
|
|
try:
|
|
parsed = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise SystemExit(f"invalid json response: {exc}") from exc
|
|
|
|
if isinstance(parsed.get("error"), str) and parsed["error"]:
|
|
raise SystemExit(f"gateway error: {parsed['error']}")
|
|
return parsed.get("result")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Argparse wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _cast(kind: str) -> Callable[[str], Any]:
|
|
if kind == "int":
|
|
return int
|
|
if kind == "float":
|
|
return float
|
|
if kind == "json":
|
|
return lambda s: json.loads(s)
|
|
return str
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Call one of 27 AMiner Open Platform APIs via the z-ai gateway.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
sub = parser.add_subparsers(dest="action", required=True, metavar="ACTION")
|
|
for name, meta in ACTIONS.items():
|
|
p = sub.add_parser(
|
|
name,
|
|
help=meta["help"],
|
|
description=f"{meta['help']} function_name: {meta['function']}",
|
|
)
|
|
for flag, dest, kind, help_text in meta["args"]:
|
|
if kind == "bool":
|
|
p.add_argument(flag, dest=dest, action="store_true", help=help_text)
|
|
else:
|
|
p.add_argument(flag, dest=dest, type=_cast(kind), help=help_text)
|
|
return parser
|
|
|
|
|
|
def collect_arguments(ns: argparse.Namespace, spec: list[_Arg]) -> dict[str, Any]:
|
|
args: dict[str, Any] = {}
|
|
for _flag, dest, kind, _help in spec:
|
|
value = getattr(ns, dest, None)
|
|
if value is None:
|
|
continue
|
|
if kind == "bool" and value is False:
|
|
continue
|
|
args[dest] = value
|
|
return args
|
|
|
|
|
|
def main() -> int:
|
|
parser = build_parser()
|
|
ns = parser.parse_args()
|
|
|
|
meta = ACTIONS[ns.action]
|
|
arguments = collect_arguments(ns, meta["args"])
|
|
|
|
config = load_config()
|
|
result = invoke(config, meta["function"], arguments)
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|