huoyan-enterprise/mcp-server-demo/excel_analyzer.py

275 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Excel 表格分析下载、schema 探测、pandas 沙箱执行。
设计原则:
- MCP 容器内执行,与 backend 进程隔离(建议 Docker 部署)
- 仅允许对已加载的 ``df`` 做 pandas 运算,禁止 import / 文件 / 网络
- 代码必须将最终答案赋给 ``result``
"""
from __future__ import annotations
import ast
import json
import tempfile
from pathlib import Path
from typing import Any
import httpx
import pandas as pd
MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024
DOWNLOAD_TIMEOUT = 120.0
EXEC_TIMEOUT_SEC = 15
MAX_RESULT_ROWS = 200
FORBIDDEN_CALL_NAMES = frozenset(
{
"open",
"exec",
"eval",
"compile",
"__import__",
"getattr",
"setattr",
"delattr",
"globals",
"locals",
"vars",
"dir",
"help",
"input",
"breakpoint",
"exit",
"quit",
}
)
FORBIDDEN_ATTR_NAMES = frozenset({"__import__", "__subclasses__", "__globals__", "__code__"})
SAFE_BUILTINS = {
"len": len,
"str": str,
"int": int,
"float": float,
"list": list,
"dict": dict,
"tuple": tuple,
"set": set,
"sum": sum,
"min": min,
"max": max,
"range": range,
"round": round,
"abs": abs,
"sorted": sorted,
"enumerate": enumerate,
"zip": zip,
"map": map,
"filter": filter,
"any": any,
"all": all,
"bool": bool,
"True": True,
"False": False,
"None": None,
}
def _validate_pandas_code(code: str) -> None:
if not code or not code.strip():
raise ValueError("pandas_code 不能为空")
if len(code) > 8000:
raise ValueError("pandas_code 过长(上限 8000 字符)")
tree = ast.parse(code, mode="exec")
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
raise ValueError("禁止 import请仅使用已提供的 pd 与 df")
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in FORBIDDEN_CALL_NAMES:
raise ValueError(f"禁止调用: {func.id}")
if isinstance(func, ast.Attribute) and func.attr in FORBIDDEN_ATTR_NAMES:
raise ValueError(f"禁止访问: {func.attr}")
if isinstance(node, ast.Attribute) and node.attr in FORBIDDEN_ATTR_NAMES:
raise ValueError(f"禁止访问属性: {node.attr}")
def _download_excel(file_url: str) -> Path:
url = (file_url or "").strip()
if not url.startswith(("http://", "https://")):
raise ValueError("file_url 必须是 http(s) 可下载地址")
suffix = ".xlsx"
lower = url.split("?", 1)[0].lower()
if lower.endswith(".xls"):
suffix = ".xls"
elif lower.endswith(".csv"):
suffix = ".csv"
with httpx.Client(timeout=DOWNLOAD_TIMEOUT, follow_redirects=True) as client:
with client.stream("GET", url) as resp:
resp.raise_for_status()
cl = resp.headers.get("content-length")
if cl and int(cl) > MAX_DOWNLOAD_BYTES:
raise ValueError(f"文件过大(>{MAX_DOWNLOAD_BYTES // (1024 * 1024)}MB")
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
try:
total = 0
for chunk in resp.iter_bytes():
total += len(chunk)
if total > MAX_DOWNLOAD_BYTES:
raise ValueError(f"文件过大(>{MAX_DOWNLOAD_BYTES // (1024 * 1024)}MB")
tmp.write(chunk)
tmp.flush()
return Path(tmp.name)
except Exception:
tmp.close()
Path(tmp.name).unlink(missing_ok=True)
raise
finally:
tmp.close()
def _resolve_sheet_name(sheet_name: str, excel_path: Path) -> str | int:
raw = (sheet_name or "").strip()
xl = pd.ExcelFile(excel_path)
if not raw or raw.lower() in ("0", "first", "default"):
return xl.sheet_names[0] if xl.sheet_names else 0
if raw.isdigit():
idx = int(raw)
if idx < 0 or idx >= len(xl.sheet_names):
raise ValueError(f"sheet 索引越界: {idx},共 {len(xl.sheet_names)} 个 sheet")
return xl.sheet_names[idx]
if raw not in xl.sheet_names:
raise ValueError(f"sheet 不存在: {raw},可选: {xl.sheet_names}")
return raw
def _load_dataframe(excel_path: Path, sheet_name: str) -> pd.DataFrame:
if excel_path.suffix.lower() == ".csv":
return pd.read_csv(excel_path)
resolved = _resolve_sheet_name(sheet_name, excel_path)
return pd.read_excel(excel_path, sheet_name=resolved)
def _format_result(result: Any) -> str:
if isinstance(result, pd.DataFrame):
df = result.head(MAX_RESULT_ROWS)
payload = {
"type": "dataframe",
"rows": len(result),
"truncated": len(result) > MAX_RESULT_ROWS,
"data": json.loads(df.to_json(orient="records", force_ascii=False)),
}
return json.dumps(payload, ensure_ascii=False)
if isinstance(result, pd.Series):
s = result.head(MAX_RESULT_ROWS)
payload = {
"type": "series",
"length": len(result),
"truncated": len(result) > MAX_RESULT_ROWS,
"data": json.loads(s.to_json(force_ascii=False)),
}
return json.dumps(payload, ensure_ascii=False)
if isinstance(result, (int, float, bool)) or result is None:
return json.dumps({"type": "scalar", "value": result}, ensure_ascii=False)
if isinstance(result, str):
return json.dumps({"type": "text", "value": result[:5000]}, ensure_ascii=False)
return json.dumps({"type": "other", "value": str(result)[:5000]}, ensure_ascii=False)
def _sandbox_execute(code: str, df: pd.DataFrame) -> Any:
_validate_pandas_code(code)
namespace: dict[str, Any] = {"pd": pd, "df": df, "result": None}
compiled = compile(code, "<pandas_sandbox>", "exec")
def _run() -> None:
exec(compiled, {"__builtins__": SAFE_BUILTINS}, namespace) # noqa: S102
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
fut = pool.submit(_run)
try:
fut.result(timeout=EXEC_TIMEOUT_SEC)
except concurrent.futures.TimeoutError as e:
raise TimeoutError(f"执行超时(>{EXEC_TIMEOUT_SEC}s") from e
if namespace.get("result") is None:
raise ValueError("请在 pandas_code 末尾将答案赋给变量 result例如: result = df[''].sum()")
return namespace["result"]
def get_excel_schema(file_url: str, sheet_name: str = "") -> str:
"""
下载 Excel/CSV 并返回 sheet 列表、列名、类型与前几行预览JSON 字符串)。
"""
path = _download_excel(file_url)
try:
if path.suffix.lower() == ".csv":
df = pd.read_csv(path, nrows=5)
preview_rows = json.loads(
df.head(5).to_json(orient="records", force_ascii=False)
)
payload = {
"format": "csv",
"sheets": ["default"],
"default_sheet": "default",
"schemas": {
"default": {
"columns": [str(c) for c in df.columns],
"dtypes": {str(c): str(df[c].dtype) for c in df.columns},
"preview_rows": preview_rows,
}
},
}
return json.dumps(payload, ensure_ascii=False)
xl = pd.ExcelFile(path)
schemas: dict[str, Any] = {}
for name in xl.sheet_names:
df = pd.read_excel(path, sheet_name=name, nrows=5)
schemas[name] = {
"columns": [str(c) for c in df.columns],
"dtypes": {str(c): str(df[c].dtype) for c in df.columns},
"preview_rows": json.loads(
df.head(5).to_json(orient="records", force_ascii=False)
),
}
payload = {
"format": "excel",
"sheets": xl.sheet_names,
"default_sheet": xl.sheet_names[0] if xl.sheet_names else "",
"schemas": schemas,
"hint": "分析时先选 sheet_name再调用 run_pandas_on_excel代码中只用 df 与 pd结果赋给 result",
}
if sheet_name.strip():
resolved = _resolve_sheet_name(sheet_name, path)
key = resolved if isinstance(resolved, str) else xl.sheet_names[int(resolved)]
payload["selected_sheet"] = key
return json.dumps(payload, ensure_ascii=False)
finally:
path.unlink(missing_ok=True)
def run_pandas_on_excel(file_url: str, pandas_code: str, sheet_name: str = "0") -> str:
"""
在沙箱中对 Excel/CSV 执行 pandas 代码。
pandas_code 示例::
result = df[df['区域'] == '华东']['销售额'].sum()
result = df.groupby('部门')['金额'].mean().sort_values(ascending=False).head(10)
"""
path = _download_excel(file_url)
try:
df = _load_dataframe(path, sheet_name)
if len(df) > 100_000:
raise ValueError("表格行数过多(>10万请让用户拆分文件或缩小范围")
result = _sandbox_execute(pandas_code, df)
return _format_result(result)
finally:
path.unlink(missing_ok=True)