Files
MathModelAgent/backend/app/tools/local_interpreter.py
EastonJiangandCursor 11f38624cd fix+perf: 绘图中文字体修复、进度自动跟踪、未验证配置刷爆日志 (#102)
* fix: 加固 Docker matplotlib 中文字体配置

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: 提前读取路径

* perf: 进度时间线与代码执行区自动跟踪最新消息

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: 未验证模型配置直接启动任务时无限重试刷爆日志

- 前端保存配置时始终推送到后端,不再依赖验证通过
- 后端 workflow 入口预校验四个 Agent 的 model/apiKey
- _validate_config 改抛 LLMConfigError 避免被 JSON 重试捕获
- coordinator/modeler 的 JSON 解析重试加上限 (MAX_JSON_RETRIES=3)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 10:31:33 +08:00

255 lines
11 KiB
Python

"""本地代码解释器模块,通过本地 Jupyter 内核执行 Python 代码。"""
from app.tools.base_interpreter import BaseCodeInterpreter
from app.tools.matplotlib_setup import build_matplotlib_init_code
from app.tools.notebook_serializer import NotebookSerializer
import jupyter_client
from app.utils.log_util import logger
import os
from app.services.redis_manager import redis_manager
from app.schemas.response import (
OutputItem,
ResultModel,
StdErrModel,
SystemMessage,
)
class LocalCodeInterpreter(BaseCodeInterpreter):
"""基于本地 Jupyter 内核的代码解释器。"""
def __init__(
self,
task_id: str,
work_dir: str,
notebook_serializer: NotebookSerializer,
):
super().__init__(task_id, work_dir, notebook_serializer)
self.km, self.kc = None, None
self.interrupt_signal = False
async def initialize(self):
# 本地内核一般不需异步上传文件,直接切换目录即可
# 初始化 Jupyter 内核管理器和客户端
logger.info("初始化本地内核")
# 设置 UTF-8 编码环境,避免 Windows 中文环境下 GBK 编码导致的乱码问题
kernel_env = os.environ.copy()
kernel_env["PYTHONIOENCODING"] = "utf-8"
kernel_env["PYTHONUTF8"] = "1"
self.km, self.kc = jupyter_client.manager.start_new_kernel(
kernel_name="python3", env=kernel_env
)
font_msg, font_type = self._pre_execute_code()
if font_msg:
await redis_manager.publish_message(
self.task_id,
SystemMessage(content=font_msg, type=font_type),
)
def _pre_execute_code(self) -> tuple[str | None, str]:
"""执行 matplotlib 初始化,并解析字体加载结果供前端展示。
Returns:
(消息文案, SystemMessage.type);无可用信息时文案为 None。
"""
init_code = build_matplotlib_init_code(self.work_dir)
execution = self.execute_code_(init_code)
stdout = "\n".join(text for mark, text in execution if mark == "stdout")
for line in stdout.splitlines():
line = line.strip()
if "中文字体已加载" in line:
# 去掉日志前缀,前端只展示关键结论
content = line.removeprefix("[matplotlib_setup] ").strip()
return content, "success"
if "未找到中文字体" in line:
content = line.removeprefix("[matplotlib_setup] ").strip()
return content, "warning"
return None, "info"
async def execute_code(self, code: str) -> tuple[str, bool, str]:
logger.info(f"执行代码: {code}")
# 添加代码到notebook
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 = self.execute_code_(code)
logger.info("代码执行完成,开始处理结果...")
await redis_manager.publish_message(
self.task_id,
SystemMessage(content="代码执行完成"),
)
for mark, out_str in execution:
if mark in ("stdout", "execute_result_text", "display_text"):
text_to_gpt.append(self._truncate_text(f"[{mark}]\n{out_str}"))
# 添加text到notebook
content_to_display.append(
ResultModel(res_type="result", format="text", msg=out_str)
)
self.notebook_serializer.add_code_cell_output_to_notebook(out_str)
elif mark in (
"execute_result_png",
"execute_result_jpeg",
"display_png",
"display_jpeg",
):
# TODO: 视觉模型解释图像
text_to_gpt.append(f"[{mark} 图片已生成,内容为 base64,未展示]")
# 添加image到notebook
if "png" in mark:
self.notebook_serializer.add_image_to_notebook(out_str, "image/png")
content_to_display.append(
ResultModel(res_type="result", format="png", msg=out_str)
)
else:
self.notebook_serializer.add_image_to_notebook(
out_str, "image/jpeg"
)
content_to_display.append(
ResultModel(res_type="result", format="jpeg", msg=out_str)
)
elif mark == "error":
error_occurred = True
error_message = self.delete_color_control_char(out_str)
error_message = self._truncate_text(error_message)
logger.error(f"执行错误: {error_message}")
text_to_gpt.append(error_message)
# 添加error到notebook
self.notebook_serializer.add_code_cell_error_to_notebook(out_str)
content_to_display.append(StdErrModel(msg=out_str))
logger.info(f"text_to_gpt: {text_to_gpt}")
combined_text = "\n".join(text_to_gpt)
await self._push_to_websocket(content_to_display)
return (
combined_text,
error_occurred,
error_message,
)
def execute_code_(self, code) -> list[tuple[str, str]]:
assert self.kc is not None
assert self.km is not None
self.kc.execute(code)
logger.info(f"执行代码: {code}")
# Get the output of the code
msg_list = []
while True:
try:
iopub_msg = self.kc.get_iopub_msg(timeout=1)
msg_list.append(iopub_msg)
if (
iopub_msg["msg_type"] == "status"
and iopub_msg["content"].get("execution_state") == "idle"
):
break
except Exception:
if self.interrupt_signal:
self.km.interrupt_kernel()
self.interrupt_signal = False
continue
all_output: list[tuple[str, str]] = []
for iopub_msg in msg_list:
if iopub_msg["msg_type"] == "stream":
if iopub_msg["content"].get("name") == "stdout":
output = iopub_msg["content"]["text"]
all_output.append(("stdout", output))
elif iopub_msg["msg_type"] == "execute_result":
if "data" in iopub_msg["content"]:
if "text/plain" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["text/plain"]
all_output.append(("execute_result_text", output))
if "text/html" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["text/html"]
all_output.append(("execute_result_html", output))
if "image/png" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["image/png"]
all_output.append(("execute_result_png", output))
if "image/jpeg" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["image/jpeg"]
all_output.append(("execute_result_jpeg", output))
elif iopub_msg["msg_type"] == "display_data":
if "data" in iopub_msg["content"]:
if "text/plain" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["text/plain"]
all_output.append(("display_text", output))
if "text/html" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["text/html"]
all_output.append(("display_html", output))
if "image/png" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["image/png"]
all_output.append(("display_png", output))
if "image/jpeg" in iopub_msg["content"]["data"]:
output = iopub_msg["content"]["data"]["image/jpeg"]
all_output.append(("display_jpeg", output))
elif iopub_msg["msg_type"] == "error":
# TODO: 正确返回格式
if "traceback" in iopub_msg["content"]:
output = "\n".join(iopub_msg["content"]["traceback"])
cleaned_output = self.delete_color_control_char(output)
all_output.append(("error", cleaned_output))
return all_output
async def get_created_images(self, section: str) -> list[str]:
"""获取新创建的图片列表"""
current_images = set()
files = os.listdir(self.work_dir)
for file in files:
if file.endswith((".png", ".jpg", ".jpeg")):
current_images.add(file)
# 计算新增的图片
new_images = current_images - self.last_created_images
# 更新last_created_images为当前的图片集合
self.last_created_images = current_images
logger.info(f"新创建的图片列表: {new_images}")
return list(new_images) # 最后转换为list返回
async def cleanup(self):
# 关闭内核
assert self.kc is not None
assert self.km is not None
self.kc.shutdown()
logger.info("关闭内核")
self.km.shutdown_kernel()
def send_interrupt_signal(self):
self.interrupt_signal = True
def restart_jupyter_kernel(self):
"""Restart the Jupyter kernel and recreate the work directory."""
assert self.kc is not None
self.kc.shutdown()
# 设置 UTF-8 编码环境,避免 Windows 中文环境下 GBK 编码导致的乱码问题
kernel_env = os.environ.copy()
kernel_env["PYTHONIOENCODING"] = "utf-8"
kernel_env["PYTHONUTF8"] = "1"
self.km, self.kc = jupyter_client.manager.start_new_kernel(
kernel_name="python3", env=kernel_env
)
self.interrupt_signal = False
self._create_work_dir()
self._pre_execute_code()
def _create_work_dir(self):
"""Ensure the working directory exists after a restart."""
os.makedirs(self.work_dir, exist_ok=True)