diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index f0129bd..6ed4641 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.11' # 你可以根据需要更改Python版本 + python-version: '3.13.3' # 你可以根据需要更改Python版本 # 第3步:安装依赖包 - name: Install dependencies diff --git a/.gitignore b/.gitignore index 6c40309..b335fc5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Python virtual environment venv/ .venv/ +venv_3.13/ env/ ENV/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c20ac9 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13.3 diff --git a/api_services.py b/api_services.py index e2b1c51..44385fb 100644 --- a/api_services.py +++ b/api_services.py @@ -1,10 +1,13 @@ import base64 -from typing import Dict, Any, Tuple +from typing import Dict, Any, Tuple, Optional from config_manager import ConfigManager +from markdown_renderer import create_markdown_renderer import os import mimetypes import re from openai import OpenAI +import time +import logging # 定义默认的LLM Prompt模板。使用`.format()`方法进行后续的动态填充。 DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC @@ -50,13 +53,13 @@ Based on the identified essay type, apply the corresponding grading logic. The H Analyze the text, identify the essay type, calculate the scores, and present your complete feedback in **Simplified Chinese** using the precise Markdown format specified in the "OUTPUT SPECIFICATION" section. Ensure the final score correctly reflects the total points possible (15 or 25). #--- End of English Instructions --- # OUTPUT SPECIFICATION (MUST BE IN SIMPLIFIED CHINESE) -# 请严格使用以下Markdown格式,并用简体中文填充所有内容,优点找不到不要硬找,问题建议要把全部问题找出来并且解析,都要遵循类似格式。 +# 请使用以下Markdown格式,并用简体中文填充所有内容,优点找不到不要硬找,问题建议要把全部问题找出来并且解析,都要遵循类似格式。对于分数的总分则必须由你选择是15分还是25分(不一定是下面的15分)。 ###【作文内容】 * **作文文本:** [在此处粘贴完整的作文文本。] ### 【综合评价】 -(在此处用一两句鼓励性的话,对本次作文进行总体概述。) +(在此处用一两句鼓励性的话,对本次作文进行总体概述。如果写的太烂了也可以骂人) ### 【亮点与优点】 * **(优点1):** [具体描述作文内容或语言上的一个亮点。] * **(优点2):** [具体描述另一个优点。] @@ -89,8 +92,15 @@ Analyze the text, identify the essay type, calculate the scores, and present you class ApiService: """封装了与外部API(VLM和LLM)交互的所有逻辑。""" - def __init__(self, config_manager: ConfigManager): + def __init__(self, config_manager: ConfigManager, ui_queue: Optional[Any] = None): self.config = config_manager + self.ui_queue = ui_queue + self.markdown_renderer = create_markdown_renderer(config_manager) + + def _log(self, message: str): + """将日志消息放入UI队列。""" + if self.ui_queue: + self.ui_queue.put(("log", message)) def _encode_image_to_base64_url(self, image_path: str) -> str: """将本地图片文件编码为Base64数据URL。""" @@ -111,10 +121,25 @@ class ApiService: 返回: (批改报告, VLM token使用情况, LLM token使用情况) """ # --- 步骤 1: 调用VLM进行图像分析 --- - vlm_client = OpenAI( - api_key=self.config.get("VlmApiKey"), - base_url=self.config.get("VlmUrl") - ) + try: + max_retries = int(self.config.get("MaxRetries", 3)) + retry_delay = int(self.config.get("RetryDelay", 5)) + except (ValueError, TypeError): + max_retries = 3 + retry_delay = 5 + + for attempt in range(max_retries): + try: + vlm_client = OpenAI( + api_key=self.config.get("VlmApiKey"), + base_url=self.config.get("VlmUrl") + ) + break + except Exception as e: + if attempt == max_retries - 1: + raise + self._log(f"VLM客户端创建失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries})") + time.sleep(retry_delay) base64_image_url = self._encode_image_to_base64_url(file_path) vlm_prompt = """# ROLE @@ -144,8 +169,16 @@ Strictly adhere to the following format. Do not output anything else. vlm_messages = [{"role": "user", "content": [{"type": "text", "text": vlm_prompt}, {"type": "image_url", "image_url": {"url": base64_image_url}}]}] vlm_model = self.config.get("VlmModel", "Pro/THUDM/GLM-4.1V-9B-Thinking") - vlm_response = vlm_client.chat.completions.create(model=vlm_model, messages=vlm_messages, max_tokens=4096, temperature=1) - vlm_output = vlm_response.choices[0].message.content or "" + for attempt in range(max_retries): + try: + vlm_response = vlm_client.chat.completions.create(model=vlm_model, messages=vlm_messages, max_tokens=4096, temperature=1) + vlm_output = vlm_response.choices[0].message.content or "" + break + except Exception as e: + if attempt == max_retries - 1: + raise + self._log(f"VLM调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries})") + time.sleep(retry_delay) vlm_usage = { "prompt_tokens": vlm_response.usage.prompt_tokens if vlm_response.usage else 0, @@ -170,10 +203,18 @@ Strictly adhere to the following format. Do not output anything else. raise ValueError(f"VLM未能按预期格式返回,无法解析文本。模型返回:\n{vlm_output}") # --- 步骤 2: 调用LLM生成批改报告 --- - llm_client = OpenAI( - api_key=self.config.get("LlmApiKey"), - base_url=self.config.get("LlmUrl") - ) + for attempt in range(max_retries): + try: + llm_client = OpenAI( + api_key=self.config.get("LlmApiKey"), + base_url=self.config.get("LlmUrl") + ) + break + except Exception as e: + if attempt == max_retries - 1: + raise + self._log(f"LLM客户端创建失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries})") + time.sleep(retry_delay) # 从配置加载Prompt模板,若用户未定义则使用默认模板 prompt_template = self.config.get("LlmPromptTemplate") @@ -190,12 +231,32 @@ Strictly adhere to the following format. Do not output anything else. llm_messages = [{"role": "user", "content": final_llm_prompt}] llm_model = self.config.get("LlmModel", "moonshotai/Kimi-K2-Instruct") - llm_response = llm_client.chat.completions.create(model=llm_model, messages=llm_messages, temperature=1, max_tokens=16384) - final_report = llm_response.choices[0].message.content or "错误:AI未能生成报告。" + for attempt in range(max_retries): + try: + llm_response = llm_client.chat.completions.create(model=llm_model, messages=llm_messages, temperature=1, max_tokens=16384) + final_report = llm_response.choices[0].message.content or "错误:AI未能生成报告。" + break + except Exception as e: + if attempt == max_retries - 1: + final_report = f"错误:AI生成报告失败(达到最大重试次数 {max_retries} 次)" + else: + self._log(f"LLM调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries})") + time.sleep(retry_delay) llm_usage = { "prompt_tokens": llm_response.usage.prompt_tokens if llm_response.usage else 0, "completion_tokens": llm_response.usage.completion_tokens if llm_response.usage else 0, } - return final_report, vlm_usage, llm_usage \ No newline at end of file + # 渲染Markdown为HTML(如果配置开启) + html_path = None + if self.markdown_renderer: + # 定义HTML报告的文件名 + report_base_name = os.path.splitext(file_path)[0] + html_output_path = f"{report_base_name}_report.html" + + html_path = self.markdown_renderer.render_markdown_to_html_file(final_report, html_output_path) + if html_path: + self._log(f"已生成HTML报告: {os.path.basename(html_path)}") + + return final_report, vlm_usage, llm_usage, html_path \ No newline at end of file diff --git a/app_ui.py b/app_ui.py index ad42ce6..0fc4d7c 100644 --- a/app_ui.py +++ b/app_ui.py @@ -5,9 +5,13 @@ import queue import os from typing import List import concurrent.futures +import logging from config_manager import ConfigManager from api_services import ApiService, DEFAULT_LLM_PROMPT_TEMPLATE +# 配置日志记录器 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') + class AboutDialog(tk.Toplevel): """“关于”对话框,展示应用信息,支持滚动查看。""" def __init__(self, parent, config_manager: ConfigManager): @@ -62,8 +66,8 @@ class AboutDialog(tk.Toplevel): 5. **获取报告:** 任务完成后,每一张图片对应的Markdown格式详细批改报告,都会自动生成在原图片所在的目录下。 作者: Eric_Terminal -https://github.com/Eric-Terminal -版本: 2.5 +https://github.com/Eric-Terminal/Pro_llm_correct +版本: 3.0 --- 历史Token使用量 (仅供参考): @@ -105,6 +109,9 @@ class SettingsDialog(tk.Toplevel): self.llm_model = tk.StringVar(value=current_config.get("LlmModel", "moonshotai/Kimi-K2-Instruct")) self.sensitivity_factor = tk.StringVar(value=current_config.get("SensitivityFactor", "1.5")) self.max_workers = tk.StringVar(value=current_config.get("MaxWorkers", "4")) + self.max_retries = tk.StringVar(value=current_config.get("MaxRetries", "3")) + self.retry_delay = tk.StringVar(value=current_config.get("RetryDelay", "5")) + self.render_markdown = tk.BooleanVar(value=current_config.get("RenderMarkdown", True)) # 智能加载Prompt模板:优先使用用户自定义模板,否则使用默认模板 user_template = current_config.get("LlmPromptTemplate") @@ -145,6 +152,12 @@ class SettingsDialog(tk.Toplevel): ttk.Entry(other_frame, textvariable=self.sensitivity_factor, width=40).grid(column=1, row=0, sticky=(tk.W, tk.E)) ttk.Label(other_frame, text="最大并发数:").grid(column=0, row=1, sticky=tk.W, pady=2) ttk.Entry(other_frame, textvariable=self.max_workers, width=40).grid(column=1, row=1, sticky=(tk.W, tk.E)) + ttk.Label(other_frame, text="最大重试次数:").grid(column=0, row=2, sticky=tk.W, pady=2) + ttk.Entry(other_frame, textvariable=self.max_retries, width=40).grid(column=1, row=2, sticky=(tk.W, tk.E)) + ttk.Label(other_frame, text="重试延迟(秒):").grid(column=0, row=3, sticky=tk.W, pady=2) + ttk.Entry(other_frame, textvariable=self.retry_delay, width=40).grid(column=1, row=3, sticky=(tk.W, tk.E)) + ttk.Label(other_frame, text="渲染Markdown报告:").grid(column=0, row=4, sticky=tk.W, pady=2) + ttk.Checkbutton(other_frame, variable=self.render_markdown).grid(column=1, row=4, sticky=tk.W) # LLM Prompt模板编辑区域 prompt_frame = ttk.LabelFrame(frame, text="LLM Prompt 模板 (可在此修改,请勿修改{}占位符内容导致程序参数无法正常传递,通常情况下修改总分即可)", padding="10") @@ -178,6 +191,7 @@ class SettingsDialog(tk.Toplevel): "LlmModel": self.llm_model.get(), "SensitivityFactor": self.sensitivity_factor.get(), "MaxWorkers": self.max_workers.get(), + "RenderMarkdown": self.render_markdown.get(), "LlmPromptTemplate": self.llm_prompt_text.get("1.0", "end-1c") } @@ -194,18 +208,18 @@ class SettingsDialog(tk.Toplevel): class MainApp: """应用主窗口类,负责构建UI界面、处理用户交互和协调后台服务。""" - def __init__(self, root: tk.Tk, config_manager: ConfigManager, api_service: ApiService): + def __init__(self, root: tk.Tk, config_manager: ConfigManager): self.root = root self.config_manager = config_manager - self.api_service = api_service + self.ui_queue = queue.Queue() + self.api_service = ApiService(config_manager, self.ui_queue) self.file_paths: List[str] = [] self.is_file_selected = False - self.ui_queue = queue.Queue() self.topic_input = None self.processed_count = 0 self.lock = threading.Lock() - + self._setup_ui() self._initialize_config() self.root.after(100, self._process_ui_queue) @@ -380,7 +394,8 @@ class MainApp: def _concurrent_worker_manager(self, file_paths: List[str], topic: str): """使用线程池并发处理所有选定的文件。""" try: - max_workers = int(self.config_manager.get("MaxWorkers", "4")) + # 强制将从配置中读取的值转换为整数,提供默认值以防万一 + max_workers = int(self.config_manager.get("MaxWorkers", 4)) except (ValueError, TypeError): max_workers = 4 @@ -396,10 +411,11 @@ class MainApp: base_name = os.path.basename(file_path) self.ui_queue.put(("log", f"开始处理: {base_name}")) try: - final_report, vlm_usage, llm_usage = self.api_service.process_essay_image(file_path, topic) + final_report, vlm_usage, llm_usage, html_path = self.api_service.process_essay_image(file_path, topic) - report_filename = os.path.splitext(file_path)[0] + "_report.md" - with open(report_filename, 'w', encoding='utf-8') as f: + # 保存Markdown源文件 + report_filename_md = os.path.splitext(file_path)[0] + "_report.md" + with open(report_filename_md, 'w', encoding='utf-8') as f: f.write(final_report) vlm_in = vlm_usage.get("prompt_tokens", 0) @@ -408,7 +424,14 @@ class MainApp: llm_out = llm_usage.get("completion_tokens", 0) usage_log = f"Token用量: VLM(in:{vlm_in}, out:{vlm_out}), LLM(in:{llm_in}, out:{llm_out})" - self.ui_queue.put(("log", f"完成批改: {base_name} -> {os.path.basename(report_filename)}")) + + # 记录所有生成的文件 + output_files = [os.path.basename(report_filename_md)] + if html_path and os.path.exists(html_path): + output_files.append(os.path.basename(html_path)) + self.ui_queue.put(("log", f"已生成HTML报告: {os.path.basename(html_path)}")) + + self.ui_queue.put(("log", f"完成批改: {base_name} -> {', '.join(output_files)}")) self.ui_queue.put(("log", usage_log)) # 加锁以保证线程安全地更新和保存配置 diff --git a/config_manager.py b/config_manager.py index 03126a9..982866e 100644 --- a/config_manager.py +++ b/config_manager.py @@ -21,6 +21,8 @@ class ConfigManager: self._fernet: Optional[Fernet] = None self._initialize_encryption() self.load() + # 确保默认渲染设置存在 + self._ensure_default_render_settings() def _initialize_encryption(self): """使用预设的密码和盐生成加密密钥,并初始化Fernet加密/解密实例。""" @@ -34,6 +36,17 @@ class ConfigManager: return "" return self._fernet.encrypt(value.encode('utf-8')).decode('utf-8') + def _ensure_default_render_settings(self): + """确保渲染相关的默认设置存在""" + if self.get("RenderMarkdownToImage") is None: + self.set("RenderMarkdownToImage", True) # 默认开启渲染功能 + if self.get("RenderImageFormat") is None: + self.set("RenderImageFormat", "png") # 默认PNG格式 + if self.get("RenderImageWidth") is None: + self.set("RenderImageWidth", 800) # 默认图片宽度 + if self.get("RenderImageQuality") is None: + self.set("RenderImageQuality", 90) # 默认图片质量 + def _decrypt(self, encrypted_value: str) -> str: """ 使用初始化的Fernet实例解密字符串。 @@ -106,6 +119,8 @@ class ConfigManager: "LlmUrl": "LLM服务地址", "LlmApiKey": "LLM服务密钥", "LlmModel": "LLM模型名称", + "MaxRetries": "最大重试次数", + "RetryDelay": "重试延迟时间(秒)", } for key, name in required_settings.items(): # 使用self.get()来确保我们检查的是解密后的值 diff --git a/main.py b/main.py index 232719f..eaf41e9 100644 --- a/main.py +++ b/main.py @@ -25,13 +25,12 @@ if __name__ == "__main__": # 1. 初始化核心服务 # 使用 resource_path 确保在打包后也能正确找到配置文件 config_manager = ConfigManager(resource_path("config.json")) - api_service = ApiService(config_manager) # 2. 创建Tkinter主窗口 root = tk.Tk() - # 3. 实例化主应用,将服务注入应用 - app = MainApp(root, config_manager, api_service) + # 3. 实例化主应用,服务将在MainApp内部创建 + app = MainApp(root, config_manager) # 4. 启动Tkinter事件循环 root.mainloop() \ No newline at end of file diff --git a/markdown_renderer.py b/markdown_renderer.py new file mode 100644 index 0000000..8acb7c9 --- /dev/null +++ b/markdown_renderer.py @@ -0,0 +1,142 @@ +import os +import markdown +from config_manager import ConfigManager +from typing import Optional + +class MarkdownRenderer: + """Markdown渲染器,支持将Markdown文本渲染为带样式的HTML文件""" + + def __init__(self, config_manager: ConfigManager): + self.config_manager = config_manager + + def render_markdown_to_html_file(self, markdown_text: str, output_path: str) -> Optional[str]: + """ + 将Markdown文本渲染为带样式的HTML文件。 + + Args: + markdown_text: Markdown格式的文本。 + output_path: 输出HTML文件的路径。 + + Returns: + 如果成功,返回生成的HTML文件路径;否则返回None。 + """ + # 检查配置中的RenderMarkdown设置,默认开启 + render_enabled = self.config_manager.get("RenderMarkdown") + if render_enabled is None: + # 如果配置中没有设置,使用默认值True + render_enabled = True + + if not render_enabled: + return None + + try: + # 将Markdown转换为HTML + html_body = markdown.markdown(markdown_text, extensions=['extra', 'tables']) + + # 组装完整的HTML文档,并嵌入CSS样式 + full_html = self._wrap_with_style(html_body) + + # 将HTML内容写入文件 + with open(output_path, 'w', encoding='utf-8') as f: + f.write(full_html) + + return output_path + except Exception as e: + print(f"渲染Markdown到HTML文件时出错: {e}") + return None + + def _wrap_with_style(self, html_body: str) -> str: + """将HTML内容包裹在带有预设CSS样式的完整HTML结构中""" + css_style = """ + + + """ + return f""" + + +
+ + +