squid-main-space / marine_mcp.py
mengxaingshuo's picture
fix: sync v4 runtime and fast inventory
dcea8ab
Raw
History Blame Contribute Delete
31.7 kB
"""Marine MCP bridge to the school Marine Data FastAPI."""
from __future__ import annotations
import os, re, time
from datetime import date as calendar_date, timedelta
from typing import Any
import httpx
from mcp.server.mcpserver import MCPServer
from fisheries_hf import (
analyze_and_export,
download_dataset_file,
local_dataset_tree,
merge_local_with_remote_tree,
)
API_URL = os.environ.get("MARINE_API_URL", "").strip().rstrip("/")
if not API_URL:
raise RuntimeError("MARINE_API_URL is not configured")
# Browser-facing download URLs must not expose the private Marine API address.
# On the school deployment this is set to the fixed UI proxy prefix, e.g.
# https://subway-unbiased-barcode.ngrok-free.dev/marine. If it is unset we
# retain the old local URL for private/operator-only deployments.
PUBLIC_BASE_URL = (
os.environ.get("MARINE_PUBLIC_BASE_URL")
or os.environ.get("PUBLIC_BASE_URL")
or ""
).strip().rstrip("/")
def _download_url(path: str) -> str:
path = str(path or "").strip()
if not path.startswith("/download/"):
return ""
return f"{PUBLIC_BASE_URL or API_URL}{path}"
mcp = MCPServer(
"Marine Data",
instructions=(
"Gateway to the user's school Marine Data Server. "
"Use health/domains/status for live state. "
"Use marine_query and marine_subset for real data retrieval. "
"School-server Ocean data and Hugging Face fisheries data are separate data planes. "
"Search both configured fisheries repositories and preserve repository provenance. "
"Use fisheries_analyze_export with both repository and path for actual CSV/TSV/ZIP content, filtering, statistics and CSV export. "
"Never invent files or values."
),
)
def _get(path: str) -> dict[str, Any]:
with httpx.Client(timeout=60.0, follow_redirects=True) as client:
r = client.get(f"{API_URL}{path}")
r.raise_for_status()
return r.json()
def _post(path: str, payload: dict[str, Any]) -> dict[str, Any]:
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
response = client.post(
f"{API_URL}{path}",
json=payload,
)
if response.is_error:
try:
body = response.json()
detail = (
body.get("detail")
if isinstance(body, dict)
else None
)
except Exception:
detail = None
if not detail:
detail = (
response.text.strip()
or response.reason_phrase
)
return {
"status": "error",
"http_status": response.status_code,
"detail": detail,
}
return response.json()
def _domain(value: str) -> str:
value = value.strip().lower()
if value not in {"ocean", "tuna", "squid"}:
raise ValueError("domain must be one of: ocean, tuna, squid")
return value
def _norm_domain(value: str) -> str:
value = value.strip().lower()
if value not in {"ocean", "tuna", "squid"}:
raise ValueError("domain must be one of: ocean, tuna, squid")
return value
@mcp.tool()
def marine_health() -> dict[str, Any]:
"""Check whether the school Marine Data Server is reachable."""
return _get("/health")
@mcp.tool()
def marine_domains() -> dict[str, Any]:
"""Return live overview for ocean, tuna and squid."""
return _get("/domains")
@mcp.tool()
def marine_status(domain: str = "ocean") -> dict[str, Any]:
"""Return detailed live status for one data center."""
d = _domain(domain)
return _get("/status" if d == "ocean" else f"/status/{d}")
@mcp.tool()
def marine_catalog() -> dict[str, Any]:
'Return the live Ocean data catalog from the school server.'
return _get("/catalog")
@mcp.tool()
def marine_query(
date: str,
variable: str,
source: str,
domain: str = "ocean",
) -> dict[str, Any]:
'Check whether a source/variable/date exists on the school Ocean server.'
return _post(
"/data/query",
{
"domain": _norm_domain(domain),
"source": source.strip().lower(),
"date": date.strip(),
"variable": variable.strip().lower(),
},
)
@mcp.tool()
def marine_subset(
date: str,
lon_min: float,
lon_max: float,
lat_min: float,
lat_max: float,
variable: str,
source: str,
domain: str = "ocean",
depth: float | None = None,
) -> dict[str, Any]:
'Create a NetCDF subset from any supported Ocean source.'
payload = {
"domain": _norm_domain(domain),
"source": source.strip().lower(),
"date": date.strip(),
"variable": variable.strip().lower(),
"lon_min": float(lon_min),
"lon_max": float(lon_max),
"lat_min": float(lat_min),
"lat_max": float(lat_max),
"format": "netcdf",
}
if depth is not None:
payload["depth"] = float(depth)
result = _post("/data/export", payload)
path = result.get("download_path")
if isinstance(path, str) and path.startswith("/download/"):
result["download_url"] = _download_url(path)
return result
@mcp.tool()
def marine_download(token: str) -> dict[str, Any]:
"""Convert an export token into a browser HTTPS download URL."""
token = token.strip()
if not re.fullmatch(r"[A-Za-z0-9_-]{20,160}", token):
raise ValueError("invalid download token")
return {"download_url": _download_url(f"/download/{token}")}
@mcp.tool()
def marine_fisheries_catalog() -> dict:
"""Compatibility alias for the live Hugging Face squid catalog."""
return fisheries_catalog("squid")
@mcp.tool()
def marine_export(
date: str,
lon_min: float,
lon_max: float,
lat_min: float,
lat_max: float,
variable: str,
source: str,
format: str = "netcdf",
domain: str = "ocean",
depth: float | None = None,
) -> dict[str, Any]:
'Export Ocean data as netcdf/csv/xlsx/json/geotiff/png.'
payload = {
"domain": _norm_domain(domain),
"source": source.strip().lower(),
"date": date.strip(),
"variable": variable.strip().lower(),
"lon_min": float(lon_min),
"lon_max": float(lon_max),
"lat_min": float(lat_min),
"lat_max": float(lat_max),
"format": format.strip().lower(),
}
if depth is not None:
payload["depth"] = float(depth)
result = _post("/data/export", payload)
path = result.get("download_path")
if isinstance(path, str) and path.startswith("/download/"):
result["download_url"] = _download_url(path)
return result
def _range_export_limit() -> int:
"""Return a bounded, operator-configurable batch size.
This deliberately never accepts a user/model supplied limit: a malformed
request must not fan out into thousands of school-server export jobs.
"""
try:
configured = int(os.environ.get("MARINE_EXPORT_RANGE_MAX_DAYS", "31"))
except ValueError:
configured = 31
return max(1, min(configured, 31))
@mcp.tool()
def marine_export_range(
start_date: str,
end_date: str,
lon_min: float,
lon_max: float,
lat_min: float,
lat_max: float,
variable: str,
source: str,
format: str = "netcdf",
domain: str = "ocean",
depth: float | None = None,
) -> dict[str, Any]:
"""Export one Ocean request for every day in an inclusive date range.
The school Ocean API is single-day by design. This safe batch endpoint
makes multi-day downloads explicit and returns one real download URL per
successful day. Ranges are capped at 31 days to protect the server; a
longer period must be requested as calendar-month batches.
"""
try:
start = calendar_date.fromisoformat(str(start_date).strip())
end = calendar_date.fromisoformat(str(end_date).strip())
except ValueError as exc:
raise ValueError("start_date and end_date must be YYYY-MM-DD") from exc
if end < start:
raise ValueError("end_date must not be earlier than start_date")
total_days = (end - start).days + 1
max_days = _range_export_limit()
if total_days > max_days:
return {
"status": "error",
"detail": (
f"日期范围共 {total_days} 天,超过单次安全上限 {max_days} 天。"
"请按不超过 31 天的自然月批次提交。"
),
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"max_days": max_days,
}
files: list[dict[str, Any]] = []
failures: list[dict[str, Any]] = []
current = start
while current <= end:
day = current.isoformat()
result = marine_export(
date=day,
lon_min=lon_min,
lon_max=lon_max,
lat_min=lat_min,
lat_max=lat_max,
variable=variable,
source=source,
format=format,
domain=domain,
depth=depth,
)
if str(result.get("status") or "").lower() == "error":
failures.append({"date": day, "detail": str(result.get("detail") or "export failed")})
else:
files.append({"date": day, **result})
current += timedelta(days=1)
status = "ok" if not failures else ("partial" if files else "error")
return {
"status": status,
"start_date": start.isoformat(),
"end_date": end.isoformat(),
"requested_days": total_days,
"files": files,
"failures": failures,
}
# ============================================================================
# Hugging Face fisheries data bridge
# ============================================================================
HF_SQUID_DATASET_REPO = (
os.environ.get("HF_SQUID_DATASET_REPO")
or os.environ.get("HF_DATASET_REPO")
or "globalsquiddatabase/squid_dataset"
).strip()
HF_TUNA_DATASET_REPO = (
os.environ.get("HF_TUNA_DATASET_REPO")
or "globalsquiddatabase/Tuna-Fisheries-Dataset"
).strip()
HF_DATASET_REPOS = {
"squid": HF_SQUID_DATASET_REPO,
"tuna": HF_TUNA_DATASET_REPO,
}
HF_DATASET_REPO = HF_SQUID_DATASET_REPO
_HF_TREE_CACHE: dict[str, dict[str, Any]] = {}
_SQUID_CATALOG = [
{
"source": "FAO FishStatJ",
"resource": "全球柔鱼科捕捞量",
"variables": ["catch", "species", "country_or_area", "year"],
"time_range": "1998-2024(正式资源清单口径;实际标准层以live inventory为准)",
"spatial_scale": "全球;无经纬度网格",
"temporal_scale": "年",
"science_uses": ["长期捕捞量变化", "国家/地区贡献结构", "物种捕捞组成变化"],
"caveats": ["不能用于精细渔场位置分析", "没有努力量时不能直接得到CPUE"],
},
{
"source": "Sea Around Us",
"resource": "全球柔鱼科重建捕捞量",
"variables": ["reconstructed_catch", "species", "area", "year"],
"time_range": "1950-2019",
"spatial_scale": "0.5°×0.5°",
"temporal_scale": "年",
"science_uses": ["历史空间捕捞格局", "渔场重心变化", "区域热点年代际变化"],
"caveats": ["属于重建数据", "使用时必须说明重建口径"],
},
{
"source": "SPRFMO",
"resource": "南太平洋捕捞量与努力量",
"variables": ["catch", "effort", "year", "grid"],
"time_range": "2007-2021(后续补充以live inventory为准)",
"spatial_scale": "5°×5°",
"temporal_scale": "年/仓库后续标准层可能含月",
"science_uses": ["区域作业格局", "捕捞强度变化", "重算CPUE后做相对丰度分析"],
"caveats": ["CPUE必须用总catch÷总effort重算", "不同努力量单位不可直接相加"],
},
{
"source": "WCPFC",
"resource": "中西太平洋月度捕捞数据",
"variables": ["catch", "year", "month", "grid", "coverage"],
"time_range": "1967-2024",
"spatial_scale": "1°×1°",
"temporal_scale": "月",
"science_uses": ["月尺度捕捞热点", "渔场季节迁移", "与SST/锋面/ENSO做时空匹配"],
"caveats": ["需结合coverage解释缺测", "缺测不能直接解释为零捕捞"],
},
{
"source": "RAM Legacy",
"resource": "茎柔鱼资源评估数据",
"variables": ["catch", "biomass", "recruitment", "CPUE"],
"time_range": "1950-2024(不同种群覆盖不同)",
"spatial_scale": "评估种群/stock",
"temporal_scale": "年",
"science_uses": ["资源量长期变化", "补充量变化", "资源状态与捕捞压力分析"],
"caveats": ["不同评估模型单位/标准化口径不同", "跨种群比较前需统一数据字典"],
},
{
"source": "Global Fishing Watch",
"resource": "全球AIS表观渔船作业努力量",
"variables": ["apparent_fishing_hours", "vessel_presence", "flag", "gear_type"],
"time_range": "2012-2024",
"spatial_scale": "0.1°×0.1°",
"temporal_scale": "月",
"science_uses": ["渔船活动强度", "作业努力热点迁移", "与渔获/CPUE联合分析捕捞压力"],
"caveats": ["AIS+模型推断的表观努力量", "不能等同于捕捞量、日志努力量或资源丰度"],
},
{
"source": "VIIRS VBD",
"resource": "夜光船探测三变量",
"variables": ["n_detect", "avg_rade9", "pct_detect"],
"time_range": "2017-2024",
"spatial_scale": "15 arcsec 原始;仓库可能含1°标准层",
"temporal_scale": "月",
"science_uses": ["夜光作业船热点", "灯光强度与探测稳定性", "补充AIS不足区的活动证据"],
"caveats": ["夜光探测不是捕捞量", "必须结合cvg评估观测机会"],
},
{
"source": "VIIRS CVG",
"resource": "卫星覆盖次数/观测机会",
"variables": ["cvg"],
"time_range": "2017-2024",
"spatial_scale": "15 arcsec",
"temporal_scale": "月",
"science_uses": ["夜光质量控制", "覆盖偏差校正", "区域/月际可比性评估"],
"caveats": ["cvg不是渔船活动量", "不能当作捕捞努力量"],
},
]
_TUNA_SOURCE_TERMS = {
"WCPFC": ["wcpfc"],
"IATTC": ["iattc"],
"ICCAT": ["iccat"],
"IOTC": ["iotc"],
"CCSBT": ["ccsbt"],
"FAO": ["fao"],
"GFW": ["global fishing watch", "gfw"],
}
_DOMAIN_TERMS = {
"squid": [
"柔鱼", "鱿鱼", "squid", "ommastre", "dosidicus", "illex", "todarodes",
"sprfmo", "npfc", "ram legacy", "viirs", "vbd", "sea around", "sea_around", "gfw",
],
"tuna": [
"金枪鱼", "tuna", "wcpfc", "iattc", "iccat", "iotc", "ccsbt",
"yellowfin", "bigeye", "skipjack", "albacore", "bluefin", "yft", "bet", "skj",
],
}
def _hf_headers() -> dict[str, str]:
token = os.environ.get("HF_TOKEN", "").strip()
return {"Authorization": f"Bearer {token}"} if token else {}
def _hf_tree(repo: str, force: bool = False) -> list[dict[str, Any]]:
repo = repo.strip()
local_items = local_dataset_tree(repo)
now = time.time()
cache = _HF_TREE_CACHE.get(repo) or {}
if (
not force
and now - float(cache.get("ts") or 0) < 300
and cache.get("items")
):
return merge_local_with_remote_tree(cache["items"], repo)
next_url = f"https://huggingface.co/api/datasets/{repo}/tree/main"
params: dict[str, Any] | None = {
"recursive": "true",
"expand": "false",
"limit": 1000,
}
items: list[dict[str, Any]] = []
pages = 0
try:
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
while next_url and pages < 50:
r = client.get(next_url, params=params, headers=_hf_headers())
params = None
pages += 1
if r.status_code in {401, 403}:
raise RuntimeError(
f"无法读取 Hugging Face Dataset {repo}。请确认 Space Secret 中存在具有 Dataset 读取权限的 HF_TOKEN,"
"且运行时配置已将 HF_TOKEN 传给 marine MCP 子进程。"
)
if r.status_code >= 400:
raise RuntimeError(
f"Hugging Face Dataset tree request failed: HTTP {r.status_code} ({repo}): {r.text[:300]}"
)
data = r.json()
if not isinstance(data, list):
raise RuntimeError(f"Hugging Face Dataset tree returned an unexpected response: {repo}")
items.extend(x for x in data if isinstance(x, dict))
next_url = (r.links.get("next") or {}).get("url")
except Exception:
# A network outage must not hide the school mirror, but only the
# successfully fetched HF tree is authoritative when it is reachable.
if local_items:
return merge_local_with_remote_tree([], repo)
raise
if next_url:
raise RuntimeError("Hugging Face Dataset 文件树超过在线分页安全上限。")
_HF_TREE_CACHE[repo] = {"ts": now, "items": items}
return merge_local_with_remote_tree(items, repo)
def _repos_for_domain(domain: str) -> list[tuple[str, str]]:
d = (domain or "all").strip().lower()
if d == "squid":
return [("squid", HF_SQUID_DATASET_REPO)]
if d == "tuna":
return [("tuna", HF_TUNA_DATASET_REPO)]
if d in {"all", "fisheries", "fishery"}:
return list(HF_DATASET_REPOS.items())
raise ValueError("domain must be one of: squid, tuna, all")
def _hf_files(
domain: str = "all",
force: bool = False,
) -> tuple[list[dict[str, Any]], dict[str, str]]:
files: list[dict[str, Any]] = []
errors: dict[str, str] = {}
for repo_domain, repo in _repos_for_domain(domain):
try:
items = _files_only(_hf_tree(repo, force=force))
except Exception as exc:
errors[repo] = str(exc)[:500]
continue
for item in items:
row = dict(item)
row["repository"] = repo
row["repository_domain"] = repo_domain
files.append(row)
return files, errors
def _files_only(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
x for x in items
if str(x.get("type") or "").lower() in {"file", "blob"}
or (
not str(x.get("type") or "").strip()
and "path" in x
and "size" in x
)
]
def _human_bytes(value: Any) -> str:
try:
n = float(value or 0)
except Exception:
n = 0.0
units = ["B", "KB", "MB", "GB", "TB"]
i = 0
while n >= 1024 and i < len(units) - 1:
n /= 1024.0
i += 1
return f"{n:.2f} {units[i]}"
def _domain_match(path: str, domain: str) -> bool:
d = (domain or "all").strip().lower()
if d in {"all", "fisheries", "fishery"}:
return True
terms = _DOMAIN_TERMS.get(d)
if not terms:
raise ValueError("domain must be one of: squid, tuna, all")
p = path.lower()
return any(term in p for term in terms)
def _query_terms(query: str) -> list[str]:
q = (query or "").strip().lower()
aliases = {
"柔鱼": ["柔鱼", "鱿鱼", "squid"],
"鱿鱼": ["柔鱼", "鱿鱼", "squid"],
"金枪鱼": ["金枪鱼", "tuna"],
"捕捞量": ["捕捞", "catch"],
"努力量": ["努力", "effort", "fishing_hours", "fishing hours"],
"cpue": ["cpue"],
"渔船": ["gfw", "vessel", "ais", "viirs", "vbd"],
"夜光": ["viirs", "vbd", "cvg", "n_detect", "rade"],
"资源评估": ["ram", "assessment", "biomass", "recruitment"],
}
terms = [q] if q else []
for key, vals in aliases.items():
if key in q:
terms.extend(vals)
for token in re.split(r"[\s,,/、;;]+", q):
if len(token) >= 2:
terms.append(token)
out = []
for t in terms:
if t and t not in out:
out.append(t)
return out
@mcp.tool()
def fisheries_catalog(domain: str = "squid") -> dict[str, Any]:
"""Return fisheries resources and the scientific questions they can support."""
d = (domain or "squid").strip().lower()
if d not in {"squid", "tuna", "all"}:
raise ValueError("domain must be one of: squid, tuna, all")
result: dict[str, Any] = {
"status": "ok",
"repositories": [repo for _, repo in _repos_for_domain(d)],
"data_plane": "Hugging Face Dataset",
"important_distinction": (
"HF fisheries Dataset is separate from the school-server tuna_data/squid_data task databases. "
"Empty school-server task databases do not mean the HF fisheries Dataset is empty."
),
"aggregation_rules": {
"catch": "SUM over time/space; preserve units",
"effort": "SUM only within compatible units",
"CPUE": "recompute aggregated total catch / aggregated total effort; never average monthly CPUE",
"GFW": "AIS/model-derived apparent fishing effort; not catch or stock abundance",
"VIIRS": "night-light vessel activity evidence; use CVG for observation-opportunity QC",
},
}
if d in {"squid", "all"}:
result["squid_semantic_catalog"] = _SQUID_CATALOG
try:
items, repo_errors = _hf_files(d)
live = []
for x in items:
path = str(x.get("path") or "")
live.append({
"path": path,
"repository": x.get("repository", ""),
"repository_domain": x.get("repository_domain", ""),
"size_bytes": int(x.get("size") or 0),
"size": _human_bytes(x.get("size") or 0),
})
result["repository_errors"] = repo_errors
total_bytes = sum(x["size_bytes"] for x in live)
result["live_inventory"] = {
"matched_file_count": len(live),
"matched_size_bytes": total_bytes,
"matched_size": _human_bytes(total_bytes),
"path_preview": live[:40],
"preview_truncated": len(live) > 40,
}
if d in {"tuna", "all"}:
groups = {}
for source, terms in _TUNA_SOURCE_TERMS.items():
matched = [x for x in live if any(t in x["path"].lower() for t in terms)]
if matched:
groups[source] = {
"file_count": len(matched),
"size": _human_bytes(sum(x["size_bytes"] for x in matched)),
"examples": [x["path"] for x in matched[:6]],
}
result["tuna_live_groups"] = groups
result["tuna_note"] = (
"Tuna availability is derived from the live HF repository tree. "
"Do not use a planned download list as proof that a tuna dataset is already present."
)
except Exception as exc:
result["live_inventory"] = {"status": "error", "detail": str(exc)}
return result
@mcp.tool()
def fisheries_inventory(
domain: str = "all",
keyword: str | None = None,
max_results: int = 80,
refresh: bool = False,
query: str | None = None,
source: str | None = None,
) -> dict[str, Any]:
"""Inspect both live fisheries trees.
Preferred arguments are ``domain`` (squid/tuna/all) and ``keyword``.
``query`` and ``source`` are accepted as compatibility aliases because
some chat runtimes emit those names for inventory searches.
"""
if query and not keyword:
keyword = str(query).strip()
if source:
source_text = str(source).strip()
source_lower = source_text.lower()
if source_lower in {"squid", "tuna", "all", "fisheries", "fishery"}:
domain = source_lower
elif source_text == HF_SQUID_DATASET_REPO:
domain = "squid"
elif source_text == HF_TUNA_DATASET_REPO:
domain = "tuna"
elif not keyword:
keyword = source_text
try:
items, repo_errors = _hf_files(domain, force=bool(refresh))
except Exception as exc:
return {"status": "error", "repositories": HF_DATASET_REPOS, "detail": str(exc)}
d = (domain or "all").strip().lower()
limit = max(1, min(int(max_results or 80), 200))
qterms = _query_terms(keyword or "")
matches = []
for x in items:
path = str(x.get("path") or "")
if d not in {"all", "fisheries", "fishery"} and x.get("repository_domain") != d:
continue
plow = path.lower()
if qterms and not any(t in plow for t in qterms):
continue
matches.append({
"path": path,
"repository": x.get("repository", ""),
"repository_domain": x.get("repository_domain", ""),
"size_bytes": int(x.get("size") or 0),
"size": _human_bytes(x.get("size") or 0),
})
total_bytes = sum(x["size_bytes"] for x in matches)
return {
"status": "ok",
"repositories": [repo for _, repo in _repos_for_domain(d)],
"repository_errors": repo_errors,
"branch": "main",
"domain": d,
"keyword": keyword,
"matched_file_count": len(matches),
"matched_size_bytes": total_bytes,
"matched_size": _human_bytes(total_bytes),
"results": matches[:limit],
"results_truncated": len(matches) > limit,
"cache_seconds": 300,
}
@mcp.tool()
def fisheries_search(query: str, max_results: int = 40) -> dict[str, Any]:
"""Search real HF fisheries files by source/species/metric/path keywords."""
q = (query or "").strip()
if not q:
raise ValueError("query is required")
try:
items, repo_errors = _hf_files("all")
except Exception as exc:
return {"status": "error", "repositories": HF_DATASET_REPOS, "detail": str(exc)}
terms = _query_terms(q)
scored = []
for x in items:
path = str(x.get("path") or "")
plow = path.lower()
score = sum(1 for t in terms if t in plow)
if score:
scored.append((
score,
{
"path": path,
"repository": x.get("repository", ""),
"repository_domain": x.get("repository_domain", ""),
"size_bytes": int(x.get("size") or 0),
"size": _human_bytes(x.get("size") or 0),
},
))
scored.sort(key=lambda z: (-z[0], z[1]["path"]))
limit = max(1, min(int(max_results or 40), 100))
return {
"status": "ok",
"repositories": list(HF_DATASET_REPOS.values()),
"repository_errors": repo_errors,
"query": q,
"matched_file_count": len(scored),
"results": [x for _, x in scored[:limit]],
"results_truncated": len(scored) > limit,
}
def _live_file(path: str, repository: str | None = None) -> dict[str, Any]:
clean = str(path or "").strip().lstrip("/")
if not clean:
raise ValueError("path is required")
selector = str(repository or "all").strip()
lowered = selector.lower()
if lowered in HF_DATASET_REPOS:
domain = lowered
elif selector in HF_DATASET_REPOS.values():
domain = next(k for k, v in HF_DATASET_REPOS.items() if v == selector)
elif lowered in {"", "all"}:
domain = "all"
else:
raise ValueError("repository must be squid, tuna, all, or an exact configured repository id")
items, repo_errors = _hf_files(domain)
exact = [item for item in items if str(item.get("path") or "") == clean]
if not exact:
raise ValueError(
"请求的文件不在所选 Hugging Face main 实时文件树中;"
"请先使用 fisheries_search 或 fisheries_inventory 确认精确路径。"
)
if len(exact) > 1:
repos = ", ".join(str(item.get("repository") or "") for item in exact)
raise ValueError(f"同一路径存在于多个仓库({repos}),请显式指定 repository。")
item = exact[0]
return {
"path": clean,
"size_bytes": int(item.get("size") or 0),
"repository": str(item.get("repository") or ""),
"repository_domain": str(item.get("repository_domain") or ""),
"repository_errors": repo_errors,
}
@mcp.tool()
def fisheries_analyze_export(
path: str,
repository: str | None = None,
year: int | None = None,
lon_min: float | None = None,
lon_max: float | None = None,
lat_min: float | None = None,
lat_max: float | None = None,
metric_columns: str | None = None,
max_rows: int = 2_000_000,
) -> dict[str, Any]:
"""Read a validated HF fisheries CSV/TSV/ZIP, analyze/filter it, and export a real CSV.
The path must exactly match one configured Dataset live tree. Repository
may be squid, tuna, or an exact configured repository id. The tool
accepts optional year and bounding-box filters, reports actual columns,
scanned/matched rows, missing values, exact duplicates, monthly counts and
annual metric sums, then returns a tokenized HTTPS download URL. It never
accepts arbitrary URLs, repositories, shell commands, or local paths.
"""
try:
item = _live_file(path, repository=repository)
local_path, revision = download_dataset_file(
item["path"],
item["size_bytes"],
repository=item["repository"],
)
return analyze_and_export(
local_path,
dataset_path=item["path"],
revision=revision,
repository=item["repository"],
year=year,
lon_min=lon_min,
lon_max=lon_max,
lat_min=lat_min,
lat_max=lat_max,
metric_columns=metric_columns,
max_rows=max_rows,
)
except Exception as exc:
return {
"status": "error",
"repository": str(repository or "all"),
"path": str(path or ""),
"detail": str(exc),
}
@mcp.tool()
def fisheries_data_rules() -> dict[str, Any]:
"""Return fisheries aggregation and interpretation rules."""
return {
"catch": {
"aggregation": "sum",
"rule": "时间/空间聚合采用求和,并保留原始单位。",
},
"effort": {
"aggregation": "sum",
"rule": "时间/空间聚合采用求和;fishing hours 与 vessel-days 等不同单位不可直接相加。",
},
"CPUE": {
"aggregation": "recompute",
"rule": "CPUE = 聚合后的总catch / 聚合后的总effort;禁止直接平均月度或格点CPUE。",
},
"GFW": {
"rule": "apparent fishing hours 是AIS+模型推断的表观作业努力量,不等同于真实捕捞量或资源丰度。",
},
"VIIRS": {
"rule": "n_detect/avg_rade9/pct_detect是夜光船活动指标;cvg是观测机会/覆盖质量控制变量。",
},
"missing_time": {
"rule": "不得把月度/年度数据伪装成逐日数据;缺失月份必须显式报告。",
},
}
if __name__ == "__main__":
mcp.run()