mirror of
https://github.com/jihe520/MathModelAgent.git
synced 2026-09-19 08:09:48 +08:00
ac57ad0c18
LLM 层重构: - 将 LLM 调用层重构为 Provider 模式,支持多 API 类型 - 新增 Anthropic、OpenAI Chat、OpenAI Responses 三种 Provider - 新增 LLM 类型定义和工厂类 WebSocket 重连: - 添加指数退避自动重连机制(初始 1s,最大 30s,最多 10 次) - 区分手动关闭与意外断开,仅意外断开触发重连 - 在任务页面顶部添加连接状态指示灯(绿/黄/红) 图表字体跨平台兼容: - 在 backend/fonts/ 存放 SimHei 字体文件 - create_work_dir() 自动复制字体到任务工作目录 - 两个解释器从工作目录动态加载字体 - E2B 移除 apt-get install 依赖,改用上传字体文件 - 统一三处字体优先级配置 其他: - 重构注释规范、修复类型错误、添加 lint hook - 更新 CLAUDE.md 项目文档
234 lines
6.6 KiB
Python
234 lines
6.6 KiB
Python
"""通用工具函数模块,提供任务 ID 生成、文件操作和文档转换等功能。"""
|
||
|
||
import os
|
||
import shutil
|
||
import datetime
|
||
import hashlib
|
||
import tomllib
|
||
from app.schemas.enums import CompTemplate
|
||
from app.utils.log_util import logger
|
||
import re
|
||
import pypandoc # type: ignore[import-unresolved]
|
||
from app.config.setting import settings
|
||
|
||
TASK_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||
|
||
|
||
def create_task_id() -> str:
|
||
"""生成基于时间戳和随机哈希的唯一任务 ID。"""
|
||
# 生成时间戳和随机hash
|
||
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||
random_hash = hashlib.md5(str(datetime.datetime.now()).encode()).hexdigest()[:8]
|
||
return f"{timestamp}-{random_hash}"
|
||
|
||
|
||
def ensure_safe_task_id(task_id: str) -> str:
|
||
"""验证任务 ID 的合法性,防止路径遍历攻击。
|
||
|
||
Args:
|
||
task_id: 待验证的任务 ID。
|
||
|
||
Returns:
|
||
验证通过的任务 ID。
|
||
|
||
Raises:
|
||
ValueError: 任务 ID 不合法时抛出。
|
||
"""
|
||
normalized = (task_id or "").strip()
|
||
if not normalized or not TASK_ID_PATTERN.fullmatch(normalized):
|
||
raise ValueError("非法 task_id")
|
||
return normalized
|
||
|
||
|
||
def create_work_dir(task_id: str) -> str:
|
||
"""为指定任务创建工作目录,并复制字体文件到工作目录。
|
||
|
||
Args:
|
||
task_id: 任务 ID。
|
||
|
||
Returns:
|
||
工作目录路径。
|
||
"""
|
||
# 设置主工作目录和子目录
|
||
work_dir = os.path.join("project", "work_dir", task_id)
|
||
|
||
try:
|
||
# 创建目录,如果目录已存在也不会报错
|
||
os.makedirs(work_dir, exist_ok=True)
|
||
# 复制字体文件到工作目录,确保图表中文正常显示
|
||
_copy_fonts_to_work_dir(work_dir)
|
||
return work_dir
|
||
except Exception as e:
|
||
# 捕获并记录创建目录时的异常
|
||
logger.error(f"创建工作目录失败: {str(e)}")
|
||
raise
|
||
|
||
|
||
# 字体源目录(backend/fonts/)
|
||
_FONTS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "fonts")
|
||
|
||
|
||
def _copy_fonts_to_work_dir(work_dir: str) -> None:
|
||
"""将后端字体目录中的字体文件复制到工作目录。
|
||
|
||
Args:
|
||
work_dir: 目标工作目录路径。
|
||
"""
|
||
fonts_dir = os.path.normpath(_FONTS_DIR)
|
||
if not os.path.isdir(fonts_dir):
|
||
logger.warning(f"字体目录不存在: {fonts_dir}")
|
||
return
|
||
|
||
for filename in os.listdir(fonts_dir):
|
||
if not filename.lower().endswith((".ttf", ".otf", ".ttc")):
|
||
continue
|
||
src = os.path.join(fonts_dir, filename)
|
||
dst = os.path.join(work_dir, filename)
|
||
try:
|
||
shutil.copy2(src, dst)
|
||
logger.debug(f"复制字体: {filename} -> {work_dir}")
|
||
except Exception as e:
|
||
logger.warning(f"复制字体 {filename} 失败: {e}")
|
||
|
||
|
||
def get_work_dir(task_id: str) -> str:
|
||
"""获取指定任务的工作目录路径。
|
||
|
||
Args:
|
||
task_id: 任务 ID。
|
||
|
||
Returns:
|
||
工作目录路径。
|
||
|
||
Raises:
|
||
FileNotFoundError: 工作目录不存在时抛出。
|
||
"""
|
||
work_dir = os.path.join("project", "work_dir", task_id)
|
||
if os.path.exists(work_dir):
|
||
return work_dir
|
||
else:
|
||
logger.error(f"工作目录不存在: {work_dir}")
|
||
raise FileNotFoundError(f"工作目录不存在: {work_dir}")
|
||
|
||
|
||
# TODO: 是不是应该将 Prompt 写成一个 class
|
||
def get_config_template(comp_template: CompTemplate = CompTemplate.CHINA) -> dict:
|
||
"""获取论文模板配置。
|
||
|
||
Args:
|
||
comp_template: 竞赛模板类型。
|
||
|
||
Returns:
|
||
模板配置字典。
|
||
"""
|
||
if comp_template == CompTemplate.CHINA:
|
||
return load_toml(os.path.join("app", "config", "md_template.toml"))
|
||
return {}
|
||
|
||
|
||
def load_toml(path: str) -> dict:
|
||
"""加载 TOML 配置文件。
|
||
|
||
Args:
|
||
path: TOML 文件路径。
|
||
"""
|
||
with open(path, "rb") as f:
|
||
return tomllib.load(f)
|
||
|
||
|
||
def load_markdown(path: str) -> str:
|
||
"""加载 Markdown 文件内容。
|
||
|
||
Args:
|
||
path: Markdown 文件路径。
|
||
"""
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return f.read()
|
||
|
||
|
||
def get_current_files(folder_path: str, type: str = "all") -> list[str]:
|
||
"""获取指定目录下的文件列表。
|
||
|
||
Args:
|
||
folder_path: 目录路径。
|
||
type: 文件类型过滤(all/md/ipynb/data/image)。
|
||
"""
|
||
files = os.listdir(folder_path)
|
||
if type == "all":
|
||
return files
|
||
elif type == "md":
|
||
return [file for file in files if file.endswith(".md")]
|
||
elif type == "ipynb":
|
||
return [file for file in files if file.endswith(".ipynb")]
|
||
elif type == "data":
|
||
return [
|
||
file for file in files if file.endswith(".xlsx") or file.endswith(".csv")
|
||
]
|
||
elif type == "image":
|
||
return [
|
||
file for file in files if file.endswith(".png") or file.endswith(".jpg")
|
||
]
|
||
return []
|
||
|
||
|
||
def transform_link(task_id: str, content: str):
|
||
"""将 Markdown 中的图片链接转换为静态资源 URL。
|
||
|
||
Args:
|
||
task_id: 任务 ID,用于构建 URL 路径。
|
||
content: 包含图片链接的 Markdown 文本。
|
||
"""
|
||
content = re.sub(
|
||
r"!\[(.*?)\]\((.*?\.(?:png|jpg|jpeg|gif|bmp|webp))\)",
|
||
lambda match: f"})",
|
||
content,
|
||
)
|
||
return content
|
||
|
||
|
||
def md_2_docx(task_id: str):
|
||
"""将 Markdown 论文转换为 DOCX 格式。
|
||
|
||
Args:
|
||
task_id: 任务 ID。
|
||
"""
|
||
work_dir = get_work_dir(task_id)
|
||
md_path = os.path.join(work_dir, "res.md")
|
||
docx_path = os.path.join(work_dir, "res.docx")
|
||
|
||
extra_args = [
|
||
"--resource-path",
|
||
str(work_dir),
|
||
"--mathml", # MathML 格式公式
|
||
"--standalone",
|
||
]
|
||
|
||
pypandoc.convert_file(
|
||
source_file=md_path,
|
||
to="docx",
|
||
outputfile=docx_path,
|
||
format="markdown+tex_math_dollars",
|
||
extra_args=extra_args,
|
||
)
|
||
print(f"转换完成: {docx_path}")
|
||
logger.info(f"转换完成: {docx_path}")
|
||
|
||
|
||
def split_footnotes(text: str) -> tuple[str, list[tuple[str, str]]]:
|
||
"""从文本中分离正文和脚注。
|
||
|
||
Args:
|
||
text: 包含脚注的完整文本。
|
||
|
||
Returns:
|
||
(正文, 脚注列表) 的元组,脚注格式为 (编号, 内容)。
|
||
"""
|
||
main_text = re.sub(
|
||
r"\n\[\^\d+\]:.*?(?=\n\[\^|\n\n|\Z)", "", text, flags=re.DOTALL
|
||
).strip()
|
||
|
||
# 匹配脚注定义
|
||
footnotes = re.findall(r"\[\^(\d+)\]:\s*(.+?)(?=\n\[\^|\n\n|\Z)", text, re.DOTALL)
|
||
logger.info(f"main_text:{main_text} \n footnotes:{footnotes}")
|
||
return main_text, footnotes
|