Files
MathModelAgent/backend/app/tools/e2b_interpreter.py
Guobin ZhangandGrey e9de237ea7 fix: matplotlib 中文方块——font_manager 旧缓存导致 SimHei 注册失效 (#74)
* fix: 透传 reasoning_content,修复 DeepSeek/MiMo thinking 模式下的 400 错误

API 开启 thinking 模式时,返回的 reasoning_content 必须在下一轮请求中原样
带回。当前代码丢弃了该字段,导致后续调用 100% 返回 400 错误,重试机制陷入
无限循环。修复内容:
- StandardResponse 新增 reasoning_content 字段
- OpenAIChatProvider 从响应中提取该字段
- 各 Agent 构建 assistant 消息时携带 reasoning_content
- agent.py 基类同时补上遗漏的 tool_calls 写入

* fix: matplotlib 中文方块问题——初始化脚本清除字体缓存 + prompt 增加 addfont 注册

- local/e2b interpreter: 注册字体前清除 matplotlib 字体缓存并重建 font_manager,
  回退链加入 macOS 系统中文字体 (Heiti SC, STHeiti, PingFang SC)
- coder prompt: sns.set_theme() 之前增加 font_manager.addfont() 步骤,
  确保 LLM 生成的每段代码主动注册字体,不依赖初始化脚本

* refactor: 精简字体修复——prompt 移除冗余 addfont,补充工程约束意识

- coder prompt 撤回 addfont 注册步骤(内核初始化已处理),避免重复 token 消耗
- modeler/coder prompt 新增:优化类问题的工程约束铁律(变量物理上下界、Q4 绳长限制)
- modeler/coder prompt 新增:EDA 按题型区分——机理题不做描述性统计

---------

Co-authored-by: Grey <grey@Greys-MacBook-Air.local>
2026-05-16 19:14:36 +08:00

394 lines
16 KiB
Python

"""E2B 云端代码解释器模块,通过 E2B 沙箱执行 Python 代码。"""
import os
from e2b_code_interpreter import AsyncSandbox # type: ignore[import-unresolved]
from app.schemas.response import (
ErrorModel,
OutputItem,
ResultModel,
StdErrModel,
StdOutModel,
SystemMessage,
)
from app.services.redis_manager import redis_manager
from app.tools.notebook_serializer import NotebookSerializer
from app.utils.log_util import logger
from app.config.setting import settings
import json
from app.tools.base_interpreter import BaseCodeInterpreter
class E2BCodeInterpreter(BaseCodeInterpreter):
"""基于 E2B 沙箱的云端代码解释器。"""
def __init__(
self,
task_id: str,
work_dir: str,
notebook_serializer: NotebookSerializer,
):
super().__init__(task_id, work_dir, notebook_serializer)
self.sbx = None
@classmethod
async def create(
cls,
task_id: str,
work_dir: str,
notebook_serializer: NotebookSerializer,
) -> "E2BCodeInterpreter":
"""创建并初始化 E2BCodeInterpreter 实例"""
instance = cls(task_id, work_dir, notebook_serializer)
return instance
async def initialize(self, timeout: int = 3000):
"""异步初始化沙箱环境"""
try:
self.sbx = await AsyncSandbox.create(
api_key=settings.E2B_API_KEY, timeout=timeout
)
logger.info("沙箱环境初始化成功")
await self._pre_execute_code()
await self._upload_all_files()
except Exception as e:
logger.error(f"初始化沙箱环境失败: {str(e)}")
raise
async def _upload_all_files(self):
"""上传工作目录中的数据文件和字体文件到沙箱"""
try:
logger.info(f"开始上传文件,工作目录: {self.work_dir}")
if not os.path.exists(self.work_dir):
logger.error(f"工作目录不存在: {self.work_dir}")
raise FileNotFoundError(f"工作目录不存在: {self.work_dir}")
files = [
f for f in os.listdir(self.work_dir)
if f.endswith((".csv", ".xlsx", ".ttf", ".otf", ".ttc"))
]
logger.info(f"工作目录中的文件列表: {files}")
for file in files:
file_path = os.path.join(self.work_dir, file)
if os.path.isfile(file_path):
try:
with open(file_path, "rb") as f:
content = f.read()
await self.sbx.files.write(f"/home/user/{file}", content)
logger.info(f"成功上传文件到沙箱: {file}")
except Exception as e:
logger.error(f"上传文件 {file} 失败: {str(e)}")
raise
except Exception as e:
logger.error(f"文件上传过程失败: {str(e)}")
raise
async def _pre_execute_code(self):
init_code = (
# 加载中文字体,确保图表中文正常显示(跨平台兼容)
"import os\n"
"import matplotlib\n"
"import matplotlib.pyplot as plt\n"
"from matplotlib import font_manager\n"
"import pathlib as _pl, glob as _glob\n"
"_cache_dir = _pl.Path(matplotlib.get_cachedir())\n"
"for _cache_file in _glob.glob(str(_cache_dir / 'fontlist*.json')):\n"
" _pl.Path(_cache_file).unlink(missing_ok=True)\n"
"font_manager.fontManager.__init__()\n"
"_font_dir = '/home/user'\n"
"_loaded = False\n"
"for _f in os.listdir(_font_dir):\n"
" if _f.lower().endswith(('.ttf', '.otf', '.ttc')):\n"
" _fp = os.path.join(_font_dir, _f)\n"
" font_manager.fontManager.addfont(_fp)\n"
" _loaded = True\n"
"if _loaded:\n"
" print(f'中文字体已加载,可用字体数: {len(font_manager.fontManager.ttflist)}')\n"
"plt.rcParams['font.sans-serif'] = ['SimHei', 'Heiti SC', 'STHeiti', 'PingFang SC', 'Noto Sans CJK SC', 'Noto Sans SC', 'WenQuanYi Micro Hei', 'Microsoft YaHei', 'sans-serif']\n"
"plt.rcParams['axes.unicode_minus'] = False\n"
"plt.rcParams['font.family'] = 'sans-serif'\n"
)
await self.execute_code(init_code)
async def execute_code(self, code: str) -> tuple[str, bool, str]:
"""执行代码并返回结果"""
if not self.sbx:
raise RuntimeError("沙箱环境未初始化")
logger.info(f"执行代码: {code}")
self.notebook_serializer.add_code_cell_to_notebook(code)
text_to_gpt: list[str] = []
content_to_display: list[OutputItem] | None = []
error_occurred: bool = False
error_message: str = ""
await redis_manager.publish_message(
self.task_id,
SystemMessage(content="开始执行代码"),
)
# 执行 Python 代码
logger.info("开始在沙箱中执行代码...")
execution = await self.sbx.run_code(code) # 返回 Execution 对象
logger.info("代码执行完成,开始处理结果...")
await redis_manager.publish_message(
self.task_id,
SystemMessage(content="代码执行完成"),
)
# 处理执行错误
if execution.error:
error_occurred = True
error_message = f"Error: {execution.error.name}: {execution.error.value}\n{execution.error.traceback}"
error_message = self._truncate_text(error_message)
logger.error(f"执行错误: {error_message}")
text_to_gpt.append(self.delete_color_control_char(error_message))
content_to_display.append(
ErrorModel(
name=execution.error.name,
value=execution.error.value,
traceback=execution.error.traceback,
)
)
# 处理标准输出和标准错误
if execution.logs:
if execution.logs.stdout:
stdout_str = "\n".join(execution.logs.stdout)
stdout_str = self._truncate_text(stdout_str)
logger.info(f"标准输出: {stdout_str}")
text_to_gpt.append(stdout_str)
content_to_display.append(
StdOutModel(msg="\n".join(execution.logs.stdout))
)
self.notebook_serializer.add_code_cell_output_to_notebook(stdout_str)
if execution.logs.stderr:
stderr_str = "\n".join(execution.logs.stderr)
stderr_str = self._truncate_text(stderr_str)
logger.warning(f"标准错误: {stderr_str}")
text_to_gpt.append(stderr_str)
content_to_display.append(
StdErrModel(msg="\n".join(execution.logs.stderr))
)
# 处理执行结果
if execution.results:
for result in execution.results:
# 1. 文本格式
if str(result):
content_to_display.append(
ResultModel(res_type="result", format="text", msg=str(result))
)
# 2. HTML格式
if result._repr_html_():
content_to_display.append(
ResultModel(
res_type="result", format="html", msg=result._repr_html_()
)
)
# 3. Markdown格式
if result._repr_markdown_():
content_to_display.append(
ResultModel(
res_type="result",
format="markdown",
msg=result._repr_markdown_(),
)
)
# 4. PNG图片(base64字符串,前端可直接渲染)
if result._repr_png_():
content_to_display.append(
ResultModel(
res_type="result", format="png", msg=result._repr_png_()
)
)
# 5. JPEG图片
if result._repr_jpeg_():
content_to_display.append(
ResultModel(
res_type="result", format="jpeg", msg=result._repr_jpeg_()
)
)
# 6. SVG
if result._repr_svg_():
content_to_display.append(
ResultModel(
res_type="result", format="svg", msg=result._repr_svg_()
)
)
# 7. PDF
if result._repr_pdf_():
content_to_display.append(
ResultModel(
res_type="result", format="pdf", msg=result._repr_pdf_()
)
)
# 8. LaTeX
if result._repr_latex_():
content_to_display.append(
ResultModel(
res_type="result", format="latex", msg=result._repr_latex_()
)
)
# 9. JSON
if result._repr_json_():
content_to_display.append(
ResultModel(
res_type="result",
format="json",
msg=json.dumps(result._repr_json_()),
)
)
# 10. JavaScript
if result._repr_javascript_():
content_to_display.append(
ResultModel(
res_type="result",
format="javascript",
msg=result._repr_javascript_(),
)
)
# 处理主要结果
# if result.is_main_result and result.text:
# result_text = self._truncate_text(result.text)
# logger.info(f"主要结果: {result_text}")
# text_to_gpt.append(result_text)
# self.notebook_serializer.add_code_cell_output_to_notebook(
# result_text
# )
# 限制返回的文本总长度
for item in content_to_display:
if isinstance(item, dict):
if item.get("type") in ["stdout", "stderr", "error"]:
text_to_gpt.append(
self._truncate_text(
item.get("content") or item.get("value") or ""
)
)
elif isinstance(item, ResultModel):
if item.format in ["text", "html", "markdown", "json"]:
text_to_gpt.append(
self._truncate_text(f"[{item.format}]\n{item.msg}")
)
elif item.format in ["png", "jpeg", "svg", "pdf"]:
text_to_gpt.append(
f"[{item.format} 图片已生成,内容为 base64,未展示]"
)
logger.info(f"text_to_gpt: {text_to_gpt}")
combined_text = "\n".join(text_to_gpt)
# 在代码执行完成后,立即同步文件
try:
await self.download_all_files_from_sandbox()
logger.info("文件同步完成")
except Exception as e:
logger.error(f"文件同步失败: {str(e)}")
# 保存到分段内容
## TODO: Base64 等图像需要优化
await self._push_to_websocket(content_to_display)
return (
combined_text,
error_occurred,
error_message,
)
async def get_created_images(self, section: str) -> list[str]:
"""获取当前 section 创建的图片列表"""
if not self.sbx:
logger.warning("沙箱环境未初始化")
return []
try:
files = await self.sbx.files.list("./")
for file in files:
if file.path.endswith(".png") or file.path.endswith(".jpg"):
self.add_section(section)
self.section_output[section]["images"].append(file.name)
self.created_images = list(
set(self.section_output[section]["images"]) - set(self.created_images)
)
logger.info(f"{section}-获取创建的图片列表: {self.created_images}")
return self.created_images
except Exception as e:
logger.error(f"获取创建的图片列表失败: {str(e)}")
return []
async def cleanup(self):
"""清理资源并关闭沙箱"""
try:
if self.sbx:
if await self.sbx.is_running():
try:
await self.download_all_files_from_sandbox()
except Exception as e:
logger.error(f"下载文件失败: {str(e)}")
finally:
await self.sbx.kill()
logger.info("成功关闭沙箱环境")
else:
logger.warning("沙箱已经关闭,跳过清理步骤")
except Exception as e:
logger.error(f"清理沙箱环境失败: {str(e)}")
# 这里可以选择不抛出异常,因为这是清理步骤
async def download_all_files_from_sandbox(self) -> None:
"""从沙箱中下载所有文件并与本地同步"""
try:
assert self.sbx is not None
# 获取沙箱中的文件列表
sandbox_files = await self.sbx.files.list("/home/user")
sandbox_files_dict = {f.name: f for f in sandbox_files} # noqa: F841
# 获取本地文件列表
local_files = set()
if os.path.exists(self.work_dir):
local_files = set(os.listdir(self.work_dir))
# 下载新文件或更新已修改的文件
for file in sandbox_files:
try:
# 排除 .bash_logout、.bashrc 和 .profile 文件
if file.name in [".bash_logout", ".bashrc", ".profile"]:
continue
local_path = os.path.join(self.work_dir, file.name)
should_download = True
# 检查文件是否需要更新
if file.name in local_files:
# 这里可以添加文件修改时间或内容哈希的比较
# 暂时简单处理,有同名文件就更新
pass
if should_download:
# 使用 bytes 格式读取文件内容,确保正确处理二进制数据
content = await self.sbx.files.read(file.path, format="bytes")
# 确保目标目录存在
os.makedirs(self.work_dir, exist_ok=True)
# 写入文件
with open(local_path, "wb") as f:
f.write(content)
logger.info(f"同步文件: {file.name}")
except Exception as e:
logger.error(f"同步文件 {file.name} 失败: {str(e)}")
continue
logger.info("文件同步完成")
except Exception as e:
logger.error(f"文件同步失败: {str(e)}")