From b17beda976aaad05185789c715818f53ed6a8231 Mon Sep 17 00:00:00 2001 From: Eric-Terminal <121368508+Eric-Terminal@users.noreply.github.com> Date: Thu, 4 Sep 2025 02:33:21 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0Python=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=EF=BC=9A=E6=B7=BB=E5=8A=A0markdown=E6=B8=B2=E6=9F=93=E5=99=A8?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96API=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=EF=BC=8C=E5=AE=8C=E5=96=84Windows=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=EF=BC=8C=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-windows.yml | 2 +- .gitignore | 1 + .python-version | 1 + api_services.py | 95 ++++++++++++++--- app_ui.py | 45 ++++++-- config_manager.py | 15 +++ main.py | 5 +- markdown_renderer.py | 142 +++++++++++++++++++++++++ project_analysis_report.md | 123 ---------------------- project_analysis_report_dev.md | 157 ---------------------------- requirements.txt | 3 +- 11 files changed, 276 insertions(+), 313 deletions(-) create mode 100644 .python-version create mode 100644 markdown_renderer.py delete mode 100644 project_analysis_report.md delete mode 100644 project_analysis_report_dev.md 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""" + + + + + + 作文批改报告 + {css_style} + + + {html_body} + + + """ + +# 工具函数 +def create_markdown_renderer(config_manager: ConfigManager) -> MarkdownRenderer: + """创建Markdown渲染器实例""" + return MarkdownRenderer(config_manager) \ No newline at end of file diff --git a/project_analysis_report.md b/project_analysis_report.md deleted file mode 100644 index 81bcd1d..0000000 --- a/project_analysis_report.md +++ /dev/null @@ -1,123 +0,0 @@ -# AI 作文批改助手 - 深度代码分析与架构报告 - -## 1. 项目概述与设计哲学 - -本项目是一个基于 Python 和 Tkinter 的桌面应用程序,名为 “AI 作文批改助手”。其核心目标是利用先进的 AI 模型,为教育者和学生提供一个高效、精准且高度可定制的英文作文批改解决方案。 - -**设计哲学**: -该应用的核心设计哲学是 **“专业分工”** 与 **“极致灵活”**。 -- **专业分工**: 它不依赖单一的通用 AI 模型,而是采用“视觉语言模型(VLM)” + “大语言模型(LLM)”的两步式处理流程。VLM 专注于其擅长的图像识别和质量评估,而 LLM 则负责深度、富有逻辑的文本分析与批改。这种分工确保了流程的每个环节都由最合适的工具来完成,从而最大化了准确性和效率。 -- **极致灵活**: 开发者深刻理解不同用户(或不同教学场景)对批改的要求千差万别。因此,应用将所有核心参数,从 API 服务地址、模型名称,到最关键的 LLM 指令模板(Prompt),都开放给用户配置。这使得应用不仅仅是一个工具,更是一个可以被用户“训练”和“塑造”的个性化批改平台。 - ---- - -## 2. 核心功能与工作流程(深度解析) - -应用程序的工作流程经过精心设计,确保了健壮性、效率和优秀的用户体验。 - -1. **启动与配置校验**: - - 应用启动时,`ConfigManager` 会立刻加载 `config.json`。如果文件不存在,它会自动创建一个空的,避免了首次运行因缺少文件而崩溃。 - - 在主界面初始化时,会调用 `config_manager.check_settings()` 检查所有必需的 API 配置。如果任何一项缺失,程序会进入一个**强制配置循环** (`_show_config_dialog_until_valid`),不断弹出设置窗口,直到用户填妥所有关键信息为止,确保了后续 API 调用的先决条件。 - -2. **用户交互**: - - 用户在主界面的 `tk.Text` 组件中输入作文题目。这里有一个非常贴心的 UI 细节:通过绑定 `` 和 `` 事件,实现了输入框的占位符(Placeholder)效果,引导用户输入。 - - 用户点击“选择图片”,可以一次性选择多个文件,程序将文件路径存储在 `self.file_paths` 列表中。 - -3. **并发处理启动**: - - 点击“开始批改”后,程序首先验证文件和题目是否都已提供。 - - 然后,它会创建一个新的后台守护线程 (`threading.Thread`),目标是 `_concurrent_worker_manager` 方法。这样做是为了将耗时的任务完全与主 UI 线程分离,防止界面冻结。 - - `_concurrent_worker_manager` 内部使用 `concurrent.futures.ThreadPoolExecutor` 创建一个线程池,其最大工作线程数 (`max_workers`) 由用户在设置中定义。它会遍历所有图片路径,为每一张图片向线程池提交一个 `_process_single_file` 的处理任务。 - -4. **两步式 AI 引擎 (核心细节)**: - - **步骤一:VLM 视觉分析 (`api_services.py`)** - - 工作线程首先调用 `api_service.process_essay_image`。 - - 该方法的第一步是调用 VLM。其 Prompt 极为关键,它严格指示 VLM 扮演一个“高精度 OCR 和手写分析引擎”,并用 `` 和 `` XML 标签包裹其输出。这种结构化输出的要求,使得后续用正则表达式 (`re.search`) 解析结果变得非常可靠。 - - 应用还引入了“手写打分敏感度” (`SensitivityFactor`) 的概念。它通过 `wscore = original_wscore ** sensitivity_factor` 这行代码,对 VLM 的原始评分进行指数调整。当因子 > 1.0 时,会拉大高分和低分区间的差距,使得评分更严格;反之则更宽松。 - - **步骤二:LLM 深度批改 (`api_services.py`)** - - 拿到 VLM 的输出后,程序会加载 LLM 的 Prompt 模板。如果用户在设置中自定义了模板,则使用用户的版本;否则,使用 `DEFAULT_LLM_PROMPT_TEMPLATE`。 - - 这个默认模板是一个极其详尽的“元程序”,它为 LLM 设定了清晰的角色(高三英语老师)、目标、输入数据格式,并提供了详细的评分逻辑。它甚至教会了 LLM 如何根据作文字数和题目类型线索来区分“应用文”和“读后续写”这两种题型,并应用不同的总分(15分 vs 25分)。 - - 最后,程序将作文题目、调整后的书写分数和识别出的文本填入模板,向 LLM 发起最终的批改请求。 - -5. **结果生成与线程安全UI更新**: - - `_process_single_file` 线程在收到 AI 的最终报告后,会将其写入一个新的 `.md` 文件。 - - **关键的 UI 更新机制**: - - 工作线程**从不直接操作**任何 Tkinter UI 组件。 - - 取而代之,它将所有需要反馈给用户的信息(如“开始处理...”、“处理完成...”、“Token用量...”、“失败...”,以及进度更新信号)都通过 `self.ui_queue.put()` 方法放入一个线程安全的 `queue.Queue` 实例中。 - - 与此同时,在主 UI 线程中,一个由 `self.root.after(100, self._process_ui_queue)` 启动的**定时轮询器**每 100 毫秒被唤醒一次。 - - 这个轮询器会尝试从队列中 `get_nowait()` 消息。一旦取到消息,它就可以**安全地**在主线程中更新日志列表框和进度条等 UI 元素。 - - **线程安全的数据更新**: 当需要更新累计的 Token 使用量时,工作线程会使用 `with self.lock:` 来获取一个线程锁 (`threading.Lock`),确保在任何时刻只有一个线程能修改和保存 `config.json`,避免了数据竞争和文件损坏。 - ---- - -## 3. 技术架构与设计亮点(深度分析) - -- **依赖注入 (Dependency Injection)**: 在 `main.py` 中,`ConfigManager` 和 `ApiService` 的实例被创建后,作为参数“注入”到 `MainApp` 的构造函数中。这是一种优秀的设计模式,它降低了 `MainApp` 与具体服务实现之间的耦合。如果未来需要替换 `ApiService` 的实现(例如,换成一个本地模型的服务),只需在 `main.py` 中修改一行代码,而无需改动 `MainApp` 内部。 - -- **企业级的配置安全 (`config_manager.py`)**: - - 应用没有简单地将 API 密钥明文存储,而是实现了一套强大的对称加密机制。 - - 它使用 `hashlib.pbkdf2_hmac`,这是一个基于密码的密钥派生函数。它将一个固定的内部密码 (`_ENCRYPTION_PASSWORD`) 和一个“盐” (`_SALT`) 通过大量哈希迭代(100,000次),生成一个几乎不可能被逆向破解的、安全的加密密钥。 - - 然后,它使用这个密钥初始化 `cryptography.fernet.Fernet` 实例。Fernet 保证了加密信息是经过认证的(无法被篡改),并且包含了时间戳以防止重放攻击,提供了远超普通 Base64 编码的安全性。 - -- **健壮的并发模型与线程安全 (`app_ui.py`)**: - - 这是本项目技术上最出色的部分。它完美地解决了桌面 GUI 应用中最常见的难题:如何在执行长耗时任务的同时保持界面响应。 - - 它综合运用了 `ThreadPoolExecutor` (管理工作线程)、`queue.Queue` (作为线程间通信的缓冲管道) 和 `root.after` (在主线程中创建非阻塞的轮询器),构成了一个经典且高效的生产者-消费者模型。工作线程是消息的“生产者”,主线程的轮询器是“消费者”。 - - 同时,通过 `threading.Lock` 确保了对共享资源(配置文件)的互斥访问。这套组合拳充分展示了开发者对并发编程的深刻理解。 - ---- - -## 4. 代码中的亮点与最佳实践 - -- **优雅的占位符实现**: 在 `app_ui.py` 中,通过绑定 `FocusIn` 和 `FocusOut` 事件来动态改变 `Text` 控件的内容和前景颜色,是一种轻量级且有效的实现输入框占位符的方法。 -- **对打包部署的考量**: `main.py` 中的 `resource_path` 函数是一个重要的细节。它通过检查 `sys._MEIPASS` 属性是否存在,来判断程序是在开发环境中运行还是被 PyInstaller 打包后运行,从而动态地计算配置文件的正确路径。这确保了应用在被打包成单文件可执行程序后依然能正常工作。 -- **智能的 Prompt 模板管理**: 在 `SettingsDialog` 的 `on_ok` 方法中,如果用户修改后的 Prompt 与默认模板一致,程序会将其设置为 `None`,并在保存时从配置文件中移除该键。这避免了将冗长的默认模板存入配置文件,保持了 `config.json` 的整洁。 - ---- - -## 5. Mermaid 流程图 - - - -```mermaid -graph TD - %% 主方向改为 Top-Down,使整体更接近正方形; - %% 子图内部按需使用 LR 来平衡横向长度。 - subgraph UI["UI (主线程)"] - style UI fill:#E6F3FF,stroke:#007BFF - A["用户操作
(选择图片, 输入题目)"] --> B{"点击'开始批改'"} - B --> C["MainApp: _start_processing()"] - C --> D["启动后台线程池"] - - subgraph UIQ["UI 更新循环"] - direction LR - V["_process_ui_queue()
(定时轮询)"] -- "get() 消息" --> U_Queue[(UI 队列)] - V --> W["/更新日志和进度条/"] - end - end - - subgraph BACK["后台 (工作线程池)"] - style BACK fill:#FFF2E6,stroke:#FF8C00 - D -- "为每个文件提交任务" --> E["_process_single_file()"] - E --> F_Call["调用 ApiService"] - F_Call --> P_Return{"获取
(报告, Token用量)"} - P_Return --> S["写入 .md 报告"] - P_Return --> R["更新 Token (加锁)"] - S & R -- "put() 消息" --> U_Queue - end - - subgraph SRV["服务和数据"] - style SRV fill:#E6FFEB,stroke:#28A745 - F_Call --> F["ApiService: process_essay_image()"] - F --> G["VLM 调用"] - G --> K["LLM 调用"] - K --> P_Return - Q[(Config Manager)] -- "提供密钥" --> G & K - R --> Q - end - - %% 用显式的纵向连接平衡整体宽度 - UI --> BACK - BACK --> SRV - - style U_Queue fill:#D1C4E9,stroke:#673AB7,stroke-width:2px - style Q fill:#FFCDD2,stroke:#D32F2F,stroke-width:2px -``` \ No newline at end of file diff --git a/project_analysis_report_dev.md b/project_analysis_report_dev.md deleted file mode 100644 index 181368d..0000000 --- a/project_analysis_report_dev.md +++ /dev/null @@ -1,157 +0,0 @@ -# AI 作文批改助手 - 开发者技术架构文档 - -## 1. 系统概述 - -**AI 作文批改助手** 是一款基于 Python/Tkinter 的桌面应用,旨在为教育场景提供一个高度可定制的自动化英文作文批改解决方案。其架构核心是围绕 **服务解耦**、**安全配置** 和 **健壮的并发处理** 构建的,确保了系统的灵活性、安全性与高性能。 - -本文档旨在为参与本项目的开发者提供清晰的架构解析、模块说明和核心工作流程,以促进高效的协同开发。 - ---- - -## 2. 模块化架构解析 - -系统采用分层架构,将UI、业务逻辑和配置管理严格分离。核心模块如下: - -| 文件名 | 模块职责 | 关键技术/模式 | -| :--- | :--- | :--- | -| `main.py` | **应用入口 (Entry Point)** | 依赖注入 (DI) | -| `app_ui.py` | **UI与主控层 (View/Controller)** | Tkinter, 生产者-消费者模型, 并发控制 | -| `api_services.py` | **外部服务适配层 (Service Adapter)** | OpenAI API, 模板方法模式 | -| `config_manager.py` | **配置与安全层 (Configuration/Security)** | Fernet 对称加密, 单例模式思想 | - -### 2.1. `main.py`: 依赖注入与启动器 - -- **职责**: 作为应用的启动器,负责初始化所有核心服务 (`ConfigManager`, `ApiService`),并将这些服务的实例 **注入** 到主应用 `MainApp` 中。 -- **设计优势**: 这种依赖注入的方式,极大地降低了 `MainApp` 与具体服务实现之间的耦合度。例如,若未来需要将 `ApiService` 切换为调用本地模型,只需在 `main.py` 中替换 `ApiService` 的实现类,而 `MainApp` 的代码无需任何改动。 -- **打包考量**: `resource_path` 函数通过检查 `sys._MEIPASS` 属性,实现了对 PyInstaller 打包环境的兼容,确保了配置文件路径在开发和部署环境中的一致性。 - -### 2.2. `config_manager.py`: 企业级安全配置 - -- **职责**: 负责 `config.json` 的全生命周期管理,包括加载、保存、读取和写入。其核心特性是 **对敏感信息的自动加解密**。 -- **加密机制**: - 1. **密钥派生**: 使用 `hashlib.pbkdf2_hmac` 基于固定的内部密码和盐,通过 100,000 次迭代生成一个高强度的加密密钥。这可以有效抵御彩虹表和暴力破解攻击。 - 2. **认证加密**: 利用 `cryptography.fernet.Fernet` 实现 **认证加密 (AEAD)**。这意味着加密后的数据不仅保密,还能防止篡改(完整性保护),并内置 -时间戳以防止重放攻击。 -- **解密容错**: `_decrypt` 方法中包含 `try...except InvalidToken` 块,确保了即使配置文件中的某个值是未加密的旧数据或已损坏,程序也不会崩溃,而是安全地返回空字符串。 - -### 2.3. `api_services.py`: AI 服务适配器 - -- **职责**: 封装了与外部 AI 服务(VLM 和 LLM)的所有交互细节,为上层应用提供统一、简洁的调用接口 (`process_essay_image`)。 -- **两步式 AI 流程 (Two-Step AI Pipeline)**: - 1. **VLM 阶段**: - - **输入**: 图片路径。 - - **处理**: 将图片编码为 Base64,调用 VLM API。Prompt 设计得极为严格,要求 VLM 返回 **结构化数据**(`` 和 `` XML 标签),这使得后续解析非常可靠。 - - **输出**: 书写分数 (`wscore`) 和识别出的文本 (`essay_text`)。 - 2. **LLM 阶段**: - - **输入**: 作文题目、VLM 阶段的书写分数和文本。 - - **处理**: 加载 Prompt 模板(优先使用用户自定义版本),将输入数据填入模板,调用 LLM API。 - - **输出**: 最终的 Markdown 格式批改报告。 -- **设计优势**: 将所有 API Key、URL 和模型名称的管理完全委托给 `ConfigManager`,自身不存储任何敏感信息。同时,通过 `DEFAULT_LLM_PROMPT_TEMPLATE` 提供了开箱即用的默认行为,也支持用户通过 -`config.json` 进行深度定制。 - -### -2.4. `app_ui.py`: 并发模型与线程安全UI - -- **职责**: 作为应用的核心控制器,负责UI渲染、事件响应,以及 **协调UI主线程与后台工作线程的交互**。 -- **并发模型 (生产者-消费者)**: 这是本应用技术实现上最关键的部分。 - 1. **生产者 (Producer)**: 后台工作线程 (`_process_single_file`)。当用户点击“开始批改”后,`_concurrent_worker_manager` 会创建一个 `ThreadPoolExecutor` 线程池。池中的每个线程在完成一项任务(调用API、保存文件)后,会将结果或状态更新(如日志消息、进度信号)封装成一个元组 `(task_type, data)`,通过 `self.ui_queue.put()` 方法放入一个线程安全的 `queue.Queue` 实例中。 - 2. **消费者 (Consumer)**: UI主线程中的 `_process_ui_queue` 方法。该方法由 `self.root.after(100, ...)` 启动,作为一个 **非阻塞的定时轮询器**,每100毫秒被唤醒一次。它会尝试从 `ui_queue` 中 `get_nowait()` 消息。一旦获取到消息,它就可以 **安全地在UI主线程中** 执行 -UI更新操作(如更新日志列表、增加进度条)。 -- **线程安全**: - - **UI操作**: 遵循了GUI编程的黄金法则—— **任何UI组件的修改都必须在主线程中进行**。通过 `queue.Queue` 实现了线程间通信,避免了后台线程直接操作UI组件。 - - **数据共享**: 对于需要被多个线程修改的共享资源(累计的Token使用量),代码在 `_process_single_file` 中使用了 `with self.lock:` (`threading.Lock`) 来确保对 `config.json` 的读写操作是 **原子性** 的,从而防止了数据竞争和文件损坏。 - ---- - -## 3. 核心工作流程 (端到端) - -以下是用户从点击“开始批改”到任务完成的完整数据流和控制流: - -1. **UI线程**: `_start_processing` 被触发。 - - **校验**: 检查文件列表和作文题目是否为空。 - - **初始化**: 重置进度条和计数器。 - - **启动后台任务**: 创建一个新的守护线程 (`threading.Thread`),其目标是 `_concurrent_worker_manager` 方法。这确保了耗时的任务管理器本身不会阻塞UI。 - -2. **后台管理线程**: `_concurrent_worker_manager` 开始执行。 - - **读取配置**: 从 `ConfigManager` 获取 `MaxWorkers` 参数。 - - **创建线程池**: 实例化一个 `concurrent.futures.ThreadPoolExecutor`。 - - **任务分发**: 遍历所有文件路径,为每个文件向线程池提交一个 `_process_single_file` 任务。 - -3. **后台工作线程 (并发执行)**: `_process_single_file` 开始执行。 - - **状态通知 (生产消息)**: 向 `ui_queue` 推入一条 `("log", "开始处理...")` 消息。 - - **调用服务层**: 调用 `api_service.process_essay_image`,传入文件路径和题目。 - - `ApiService` 内部依次完成VLM和LLM的API调用。 - - **处理结果**: - - **成功**: 将返回的 `final_report` 写入 `.md` 文件。 - - **成功**: 使用 `threading.Lock` 安全地更新 `ConfigManager` 中的Token用量并保存。 - - **成功/失败**: 向 `ui_queue` 推入日志消息、Token用量消息。 - - **进度通知 (生产消息)**: 无论成功与否,都向 `ui_queue` 推入一条 `("progress", 1)` 消息。 - -4. **UI线程 (轮询)**: `_process_ui_queue` 定期执行。 - - **消费消息**: 从 `ui_queue` 中取出消息。 - - **解析与执行**: 根据消息类型 (`"log"`, `"progress"`, `"finish"`),安全地更新UI组件(`Listbox`, `Progressbar`)或弹出完成对话框。 - -5. **任务结束**: - - 当线程池中的所有任务都完成后,`_concurrent_worker_manager` 方法执行完毕。 - - 它向 `ui_queue` 推入一条 `("finish", None)` 消息,触发UI线程显示最终的完成提示。 - ---- - -## 4. 系统交互流程图 (Mermaid) - -```mermaid -graph TD - A["用户: 选择图片文件"] --> B["用户: 输入作文题目"]; - B --> C{"用户: 点击'开始'按钮"}; - C --> D["UI线程: _start_processing()"]; - D --> E["UI线程: 校验输入"]; - E -- 输入有效 --> F["UI线程: 重置进度条"]; - F --> G["UI线程: 创建并启动
后台管理线程"]; - - G --> H["管理线程: _concurrent_worker_manager() 启动"]; - H --> I["管理线程: 从配置读取MaxWorkers"]; - I --> J["管理线程: 创建ThreadPoolExecutor"]; - J --> K["管理线程: 遍历所有文件路径"]; - K --> L["管理线程: 为每个文件提交
_process_single_file 任务"]; - - L -- 提交任务 --> M["工作线程: _process_single_file() 启动"]; - M --> M_LOG_START["工作线程: 推送('log', '开始处理...')"]; - M_LOG_START --> Y[(UI队列)]; - - M_LOG_START --> N["工作线程: 调用 api_service.process_essay_image()"]; - N --> N1["ApiService: _encode_image_to_base64_url()"]; - N1 --> N2["ApiService: 构建VLM Prompt"]; - N2 --> N3["ApiService: 调用VLM API"]; - N3 --> N4["ApiService: 解析VLM响应 (wscore, text)"]; - N4 --> N5["ApiService: 应用敏感度因子"]; - N5 --> N6["ApiService: 加载LLM Prompt模板"]; - N6 --> N7["ApiService: 格式化最终LLM Prompt"]; - N7 --> N8["ApiService: 调用LLM API"]; - N8 --> N9["ApiService: 返回批改报告和Token用量"]; - - N9 --> O{"工作线程: API调用是否成功?"}; - O -- 是 --> P["工作线程: 将报告写入.md文件"]; - P --> Q["工作线程: 获取线程锁 (threading.Lock)"]; - Q --> R["工作线程: 更新Token用量到配置"]; - R --> S["工作线程: 保存config.json"]; - S --> T["工作线程: 释放线程锁"]; - T --> U["工作线程: 推送('log', '处理成功...')"]; - U --> Y; - - O -- 否 --> V["工作线程: 获取异常详情"]; - V --> W["工作线程: 推送('log', '处理失败...')"]; - W --> Y; - - U --> X["工作线程: 推送('progress', 1)"]; - W --> X; - X --> Y; - - L -- 所有任务完成 --> AA["管理线程: 推送('finish', None)"]; - AA --> Y; - - Y -- get_nowait() --> Z["UI线程: _process_ui_queue() (每100ms)"]; - Z --> ZA{"UI线程: 检查消息类型"}; - ZA -- log --> ZB["UI线程: 更新日志Listbox"]; - ZA -- progress --> ZC["UI线程: 更新进度条"]; - ZA -- finish --> ZD["UI线程: 显示'全部完成'对话框"]; -``` \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ff8425a..7c51eed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ cryptography -openai \ No newline at end of file +openai +markdown \ No newline at end of file