diff --git a/README.md b/README.md index d8b2bb6..3388882 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,6 @@ # AI 作文批改助手 ✨ -(^▽^)ノ゙ 欢迎使用 AI 作文批改助手!这是一款专为教育工作者和学生设计的智能桌面工具,能够像经验丰富的英语老师一样,自动批改手写英文作文图片,并生成专业详细的批改报告。 - -## 🎯 应用界面预览 - - - - - ---- +(^▽^)ノ゙ 欢迎使用 AI 作文批改助手!这是一款专为教育工作者和学生设计的本地 Web 应用,能够像经验丰富的英语老师一样,自动批改手写英文作文图片,并生成专业详细的批改报告。 ## ✨ 核心特色功能 @@ -44,19 +36,26 @@ ### 快速开始 1. **下载程序**: 前往 [Releases页面](https://github.com/Eric-Terminal/Pro_llm_correct/releases) 下载最新版本 -2. **首次配置**: - - 运行程序,自动弹出设置窗口 - - 配置VLM和LLM服务的URL、API密钥和模型名称 - - 点击确定保存,密钥自动加密存储 -3. **开始批改**: - - 在主界面输入作文题目 - - 点击"选择图片",多选需要批改的作文图片 - - 点击"开始批改",程序自动进行并发处理 -4. **查看报告**: 处理完成后,Markdown和HTML格式报告自动保存在原图片目录 +2. **启动 Web UI**: + - 在终端运行 `python3 main.py` + - 程序会从 4567 端口起寻找可用端口,并自动打开浏览器访问 Web 界面 +3. **配置服务**: + - 通过顶部导航切换到“服务设置”页,填写 VLM/LLM 的 URL、API Key、模型名称等参数 + - 可自定义 Prompt 模板、并发数量、重试策略与输出目录 + - 密钥字段不会回显;若提示“已保存”,留空即可沿用原值,输入新值即可覆盖 + - 点击“保存设置”即可持久化到本地 `config.json`(密钥自动加密) +4. **上传批改**: + - Web 首页默认停留在“批改作文”页,在表单中输入作文题目或场景说明 + - 上传需要批改的作文照片(支持多选) + - 点击“开始批改”,浏览器会实时显示每个文件的处理状态与日志 +5. **查看报告**: + - 所有输出默认保存在 `output_reports/<时间戳>/` 目录 + - 结果卡片中提供 Markdown/HTML 链接,可直接在浏览器查看或下载 ### 输出文件说明 -- `原文件名_report.md`: Markdown格式详细批改报告 -- `原文件名_report.html`: HTML可视化批改报告 +- 默认保存在 `output_reports/<时间戳>/` 目录 +- `原文件名_report.md`: Markdown 格式详细批改报告 +- `原文件名_report.html`: HTML 可视化批改报告 - 包含: 作文内容、综合评价、亮点优点、问题建议、分数评估 --- @@ -76,9 +75,10 @@ source venv/bin/activate # Linux/Mac # 3. 安装依赖 pip install -r requirements.txt +# 需要确保系统已安装 curl(macOS/Linux 默认自带,Windows 可安装 Git Bash 或使用 WSL) # 4. 运行程序 -python main.py +python3 main.py ``` ### 项目打包 @@ -90,11 +90,11 @@ pyinstaller --noconsole --onefile main.py ``` ### 技术架构 -- **前端**: Tkinter GUI界面 +- **前端**: Flask Web 服务 + 原生 HTML/CSS(玻璃拟态苹果风界面) - **核心**: 双AI引擎架构 (VLM + LLM) -- **安全**: cryptography加密库 -- **并发**: threading + concurrent.futures -- **输出**: Markdown + HTML渲染 +- **安全**: cryptography 加密存储配置 +- **并发**: threading + concurrent.futures.ThreadPoolExecutor +- **输出**: Markdown/HTML 报告(内置样式渲染器) --- @@ -113,6 +113,7 @@ pyinstaller --noconsole --onefile main.py - `MaxWorkers`: 最大并发数(默认4) - `MaxRetries`: 最大重试次数(默认3) - `RetryDelay`: 重试延迟秒数(默认5) +- `RequestTimeout`: 单次 API 请求超时时长(秒,默认120) - `SaveMarkdown`: 是否保存Markdown文件(默认True) - `RenderMarkdown`: 是否渲染HTML报告(默认True) diff --git a/api_services.py b/api_services.py index 119af06..8db803c 100644 --- a/api_services.py +++ b/api_services.py @@ -8,9 +8,9 @@ from markdown_renderer import create_markdown_renderer import os import mimetypes import re -from openai import OpenAI import time import logging +import subprocess # 定义默认的LLM Prompt模板。使用`.format()`方法进行后续的动态填充。 DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC @@ -105,6 +105,8 @@ class ApiService: """将日志消息放入UI队列。""" if self.ui_queue: self.ui_queue.put(("log", message)) + else: + logging.info(message) def _encode_image_to_base64_url(self, image_path: str) -> str: """将本地图片文件编码为Base64数据URL。""" @@ -117,6 +119,89 @@ class ApiService: encoded_string = base64.b64encode(image_file.read()).decode('utf-8') return f"data:{mime_type};base64,{encoded_string}" + def _chat_endpoint(self, base_url: Optional[str]) -> str: + if not base_url: + raise ValueError("服务地址未配置,请先在设置中填写 API Base URL") + return base_url.rstrip('/') + "/chat/completions" + + def _usage_from_response(self, response_json: Dict[str, Any]) -> Dict[str, int]: + usage = response_json.get("usage") or {} + return { + "prompt_tokens": int(usage.get("prompt_tokens", 0) or 0), + "completion_tokens": int(usage.get("completion_tokens", 0) or 0), + } + + def _post_json_with_curl(self, endpoint: str, api_key: Optional[str], payload: Dict[str, Any], timeout: float) -> Dict[str, Any]: + data_str = json.dumps(payload, ensure_ascii=False) + command = [ + "curl", + "-sS", + "-X", + "POST", + endpoint, + "-H", + "Content-Type: application/json", + "--data-binary", + "@-", + "-w", + "\nHTTP_STATUS:%{http_code}", + "--max-time", + str(max(timeout, 1.0)), + ] + if api_key: + command.extend(["-H", f"Authorization: Bearer {api_key}"]) + + completed = subprocess.run(command, capture_output=True, text=True, input=data_str) + stdout = completed.stdout or "" + stderr = completed.stderr.strip() + + status_code = None + if "HTTP_STATUS:" in stdout: + stdout, status_part = stdout.rsplit("HTTP_STATUS:", 1) + try: + status_code = int(status_part.strip()) + except ValueError: + status_code = None + + response_text = stdout.strip() + + if completed.returncode != 0 or (status_code and status_code >= 400): + error_message = response_text or stderr or f"curl exited with code {completed.returncode}" + raise RuntimeError(f"调用失败 (HTTP {status_code}): {error_message}") + + if not response_text: + return {} + + try: + return json.loads(response_text) + except json.JSONDecodeError as exc: + raise ValueError(f"无法解析 API 返回的 JSON: {response_text[:500]}") from exc + + def _invoke_chat_completion( + self, + label: str, + base_url: Optional[str], + api_key: Optional[str], + payload: Dict[str, Any], + max_retries: int, + retry_delay: int, + timeout: float, + ) -> Dict[str, Any]: + endpoint = self._chat_endpoint(base_url) + last_error: Optional[Exception] = None + for attempt in range(max_retries): + try: + return self._post_json_with_curl(endpoint, api_key, payload, timeout) + except Exception as exc: # pylint: disable=broad-except + last_error = exc + if attempt == max_retries - 1: + raise + self._log(f"{label} 调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries}),错误: {exc}") + time.sleep(retry_delay) + if last_error: + raise last_error + raise RuntimeError(f"{label} 调用失败:未知错误") + def process_essay_image(self, file_path: str, topic: str) -> Tuple[str, Dict[str, int], Dict[str, int]]: """ 执行完整的两步式作文批改流程: @@ -131,19 +216,12 @@ class ApiService: 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) + + try: + request_timeout = float(self.config.get("RequestTimeout", 120)) + except (ValueError, TypeError): + request_timeout = 120.0 + base64_image_url = self._encode_image_to_base64_url(file_path) vlm_prompt = """# ROLE @@ -173,21 +251,28 @@ 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") - 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, - "completion_tokens": vlm_response.usage.completion_tokens if vlm_response.usage else 0, + vlm_payload = { + "model": vlm_model, + "messages": vlm_messages, + "max_tokens": 4096, + "temperature": 1, } + vlm_response_json = self._invoke_chat_completion( + "VLM", + self.config.get("VlmUrl"), + self.config.get("VlmApiKey"), + vlm_payload, + max_retries, + retry_delay, + request_timeout, + ) + + choices = vlm_response_json.get("choices") or [] + if not choices: + raise ValueError(f"VLM 未返回 choices,响应:{vlm_response_json}") + vlm_output = choices[0].get("message", {}).get("content") or "" + + vlm_usage = self._usage_from_response(vlm_response_json) # 解析VLM返回的XML格式输出,提取分数和文本 wscore_match = re.search(r'(.*?)', vlm_output, re.DOTALL) @@ -207,19 +292,6 @@ Strictly adhere to the following format. Do not output anything else. raise ValueError(f"VLM未能按预期格式返回,无法解析文本。模型返回:\n{vlm_output}") # --- 步骤 2: 调用LLM生成批改报告 --- - 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") if not prompt_template: @@ -235,23 +307,35 @@ 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") - 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, + llm_payload = { + "model": llm_model, + "messages": llm_messages, + "temperature": 1, + "max_tokens": 16384, } + final_report: str + try: + llm_response_json = self._invoke_chat_completion( + "LLM", + self.config.get("LlmUrl"), + self.config.get("LlmApiKey"), + llm_payload, + max_retries, + retry_delay, + request_timeout, + ) + llm_choices = llm_response_json.get("choices") or [] + if not llm_choices: + raise ValueError(f"LLM 未返回 choices,响应:{llm_response_json}") + final_report = llm_choices[0].get("message", {}).get("content") or "错误:AI未能生成报告。" + except Exception as exc: + self._log(f"LLM 调用失败:{exc}") + final_report = f"错误:AI生成报告失败({exc})" + llm_response_json = {} + + llm_usage = self._usage_from_response(llm_response_json) + # 渲染Markdown为HTML(如果配置开启) html_path = None if self.markdown_renderer: @@ -286,4 +370,4 @@ def check_for_updates(current_version_str: str) -> Optional[str]: return latest_version_name except Exception as e: logging.error(f"Failed to check for updates: {e}") - return None \ No newline at end of file + return None diff --git a/app_ui.py b/app_ui.py index 712c3d1..ddf7e6b 100644 --- a/app_ui.py +++ b/app_ui.py @@ -9,12 +9,11 @@ import logging import webbrowser from config_manager import ConfigManager from api_services import ApiService, DEFAULT_LLM_PROMPT_TEMPLATE, check_for_updates +from version import CURRENT_VERSION # 配置日志记录器 logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -CURRENT_VERSION = "3.2.0" - class AboutDialog(tk.Toplevel): """“关于”对话框,展示应用信息,支持滚动查看。""" def __init__(self, parent, config_manager: ConfigManager): diff --git a/config.json b/config.json index 9e26dfe..1f49ecd 100644 --- a/config.json +++ b/config.json @@ -1 +1,23 @@ -{} \ No newline at end of file +{ + "__device_salt__": "c2FsdF9mb3JfbGxtX2FwcF9jb25maWdDMTdGUkFDUlE2TDQ=", + "__device_fingerprint__": "d9bddd980989cdac1e26fdb26d21fa098ea9d5f2dc2da062b069d268f8c9a1ff", + "__device_fingerprint_source__": "hardware", + "SaveMarkdown": true, + "RenderMarkdown": true, + "VlmUrl": "https://api.ericterminal.com/v1", + "VlmApiKey": "gAAAAABo-F2EZa6kKSdQNolAPQcvN5RMdyCcgJpQ2VFg0szZWFWfa7MQQsMWT7R6jKpSBgVxSCYuzEC2A-xcZqIdkY_rqdFSYzT0Uou84UKP0aqkTCNTJwd3wwetbYQTpcWtJpRuw6KrVnFtypcON4K7gXdV0biDEw==", + "VlmModel": "gemini-2.5-pro", + "LlmUrl": "https://api.ericterminal.com/v1", + "LlmApiKey": "gAAAAABo-F2E-b6xQWGj5DhJfTNlZkekVeidkYI9ZH7-bhPayqcbAkNVsc9NpgSFo5b1dfifT9cVirJeGjoxobSS6ewWJvDN0JgbZa2e1pZeo5Gsqvei3O2s4xOEk9kAsWej6WjynjFYHkBEz10sK53cCPHZI1RqAA==", + "LlmModel": "gemini-2.5-pro", + "OutputDirectory": "output_reports", + "MaxWorkers": 4, + "MaxRetries": 3, + "RetryDelay": 5, + "SensitivityFactor": 1.0, + "AutoUpdateCheck": true, + "UsageVlmInput": 645, + "UsageVlmOutput": 19, + "UsageLlmInput": 1385, + "UsageLlmOutput": 296 +} \ No newline at end of file diff --git a/main.py b/main.py index d8c8475..bb213cb 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,12 @@ -import tkinter as tk -from app_ui import MainApp -from config_manager import ConfigManager -from api_services import ApiService -import sys import os +import socket +import sys +import threading +import time +import webbrowser + +from config_manager import ConfigManager +from web_app import create_app def get_config_path(): @@ -21,17 +24,40 @@ def get_config_path(): # 开发环境,使用当前目录 return "config.json" +def find_available_port(start: int = 4567, limit: int = 4667) -> int: + """Return the first free TCP port within the inclusive range.""" + for port in range(start, limit + 1): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + return port + except OSError: + continue + raise RuntimeError(f"无法在 {start}-{limit} 范围内找到可用端口") + + +def open_browser_later(url: str, delay: float = 1.0) -> None: + """Open the default browser after a small delay.""" + + def _opener(): + time.sleep(delay) + try: + webbrowser.open_new(url) + except Exception: + pass + + threading.Thread(target=_opener, daemon=True).start() + + if __name__ == "__main__": - # 1. 初始化核心服务 - # 使用合适的配置文件路径 config_path = get_config_path() config_manager = ConfigManager(config_path) + app = create_app(config_manager) - # 2. 创建Tkinter主窗口 - root = tk.Tk() - - # 3. 实例化主应用,服务将在MainApp内部创建 - app = MainApp(root, config_manager) + port = find_available_port() + url = f"http://127.0.0.1:{port}/" + print(f"🚀 Web UI 已启动,访问: {url}") + open_browser_later(url) - # 4. 启动Tkinter事件循环 - root.mainloop() \ No newline at end of file + app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False) diff --git a/output_reports/20251022-123002/00aca795371b915ef531d44da63834eb.png b/output_reports/20251022-123002/00aca795371b915ef531d44da63834eb.png new file mode 100644 index 0000000..cf068e6 Binary files /dev/null and b/output_reports/20251022-123002/00aca795371b915ef531d44da63834eb.png differ diff --git a/output_reports/20251022-123002/131910080_p0_master1200.jpg b/output_reports/20251022-123002/131910080_p0_master1200.jpg new file mode 100644 index 0000000..73cf88c Binary files /dev/null and b/output_reports/20251022-123002/131910080_p0_master1200.jpg differ diff --git a/output_reports/20251022-123002/131910080_p1_master1200.jpg b/output_reports/20251022-123002/131910080_p1_master1200.jpg new file mode 100644 index 0000000..6d1c389 Binary files /dev/null and b/output_reports/20251022-123002/131910080_p1_master1200.jpg differ diff --git a/output_reports/20251022-123002/b4626d020aa79403bb2bff0dc52849f5.png b/output_reports/20251022-123002/b4626d020aa79403bb2bff0dc52849f5.png new file mode 100644 index 0000000..802c650 Binary files /dev/null and b/output_reports/20251022-123002/b4626d020aa79403bb2bff0dc52849f5.png differ diff --git a/output_reports/20251022-123350/4K.png b/output_reports/20251022-123350/4K.png new file mode 100644 index 0000000..0a90ecd Binary files /dev/null and b/output_reports/20251022-123350/4K.png differ diff --git a/output_reports/20251022-124130/4K.png b/output_reports/20251022-124130/4K.png new file mode 100644 index 0000000..0a90ecd Binary files /dev/null and b/output_reports/20251022-124130/4K.png differ diff --git a/output_reports/20251022-124454/4K.png b/output_reports/20251022-124454/4K.png new file mode 100644 index 0000000..0a90ecd Binary files /dev/null and b/output_reports/20251022-124454/4K.png differ diff --git a/output_reports/20251022-124454/4K_report.md b/output_reports/20251022-124454/4K_report.md new file mode 100644 index 0000000..2c8151a --- /dev/null +++ b/output_reports/20251022-124454/4K_report.md @@ -0,0 +1,16 @@ +###【作文内容】 +* **作文文本:** +### 【综合评价】 +同学,你提交的是一张白卷,这在考试中是绝对不允许的。一分不得的情况下,你将与其他考生拉开巨大的差距。希望这只是一个系统测试,在真正的考试中,请务必认真对待,写出你的想法。 +### 【亮点与优点】 +* 本次作文为空白卷,无任何优点。 +### 【问题与修改建议】 +* **[问题1 - 未作答]:** + * **原文句子:** "全文为空。" + * **问题分析:** 考生没有书写任何内容,无法进行评分。在任何考试中,交白卷都意味着该题得分为0,这将对总成绩产生灾难性的影响。 + * **修改建议:** 务必审题并按照要求完成写作任务。即使语言表达不够完美,也要尽力尝试,争取拿到基本的分数。请记住,写了就有可能得分,不写一定是0分。 +### 【分数评估】 +* **内容与语言分 (Content & Language):** 0 / 12 +* **卷面与书写分 (Handwriting & Presentation):** 0 / 3 +* --- +* **最终得分 (Final Score):** **0 / 15** \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 846df44..b3fbe53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ cryptography -openai +flask markdown -packaging \ No newline at end of file +packaging diff --git a/version.py b/version.py new file mode 100644 index 0000000..8507634 --- /dev/null +++ b/version.py @@ -0,0 +1 @@ +CURRENT_VERSION = "4" diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..f2f2721 --- /dev/null +++ b/web_app.py @@ -0,0 +1,1317 @@ +import logging +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from flask import ( + Flask, + abort, + jsonify, + render_template_string, + request, + send_from_directory, +) +from werkzeug.utils import secure_filename + +from api_services import ApiService, DEFAULT_LLM_PROMPT_TEMPLATE, check_for_updates +from config_manager import ConfigManager +from version import CURRENT_VERSION + + +DEFAULT_OUTPUT_DIR_NAME = "output_reports" + + +def _ensure_directory(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _usage_snapshot(raw: Optional[Dict[str, Any]]) -> Dict[str, int]: + raw = raw or {} + return { + "prompt_tokens": int(raw.get("prompt_tokens", 0) or 0), + "completion_tokens": int(raw.get("completion_tokens", 0) or 0), + } + + +def create_app(config_manager: ConfigManager) -> Flask: + """Create and configure the Flask web application.""" + + app = Flask(__name__) + app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 MB payload ceiling + + api_service = ApiService(config_manager) + config_lock = threading.Lock() + update_state: Dict[str, Optional[str]] = {"latest": None, "checked": None} + update_lock = threading.Lock() + run_states: Dict[str, Dict[str, Any]] = {} + run_states_lock = threading.Lock() + + def get_output_root() -> Path: + configured = config_manager.get("OutputDirectory") + base_path = Path(configured) if configured else Path(DEFAULT_OUTPUT_DIR_NAME) + if not base_path.is_absolute(): + base_path = Path.cwd() / base_path + return _ensure_directory(base_path) + + def relative_to_output(path: Path) -> str: + root = get_output_root().resolve() + resolved = path.resolve() + try: + relative = resolved.relative_to(root) + except ValueError as exc: # pragma: no cover - safety guard + raise ValueError("Requested path is outside of the output directory") from exc + return relative.as_posix() + + def start_update_check(force: bool = False) -> None: + if not _as_bool(config_manager.get("AutoUpdateCheck", True), True): + return + + with update_lock: + already_checked = update_state["checked"] + + if already_checked and not force: + return + + def _worker() -> None: + latest = check_for_updates(CURRENT_VERSION) + timestamp = datetime.now().isoformat(timespec="seconds") + with update_lock: + update_state["latest"] = latest + update_state["checked"] = timestamp + + threading.Thread(target=_worker, daemon=True).start() + + def _execute_run( + run_id: str, + saved_files: List[Dict[str, Any]], + topic: str, + run_dir: Path, + max_workers: int, + save_markdown: bool, + ) -> None: + aggregate = {"vlm_in": 0, "vlm_out": 0, "llm_in": 0, "llm_out": 0} + failures = 0 + + with run_states_lock: + state = run_states.get(run_id) + if state: + state["status"] = "running" + + def process_single(file_info: Dict[str, Any]) -> Dict[str, Any]: + saved_path: Path = file_info["path"] + logs: List[str] = [f"开始处理: {file_info['original']}"] + markdown_path: Optional[Path] = None + html_path: Optional[Path] = None + vlm_usage = {"prompt_tokens": 0, "completion_tokens": 0} + llm_usage = {"prompt_tokens": 0, "completion_tokens": 0} + error: Optional[str] = None + rendered_html_path: Optional[str] = None + + report_markdown_path = saved_path.parent / f"{saved_path.stem}_report.md" + + try: + final_report, raw_vlm_usage, raw_llm_usage, rendered_html_path = api_service.process_essay_image( + str(saved_path), + topic, + ) + + vlm_usage = _usage_snapshot(raw_vlm_usage) + llm_usage = _usage_snapshot(raw_llm_usage) + + if save_markdown: + markdown_path = report_markdown_path + markdown_path.write_text(final_report, encoding="utf-8") + logs.append(f"已生成 Markdown: {markdown_path.name}") + + render_html = _as_bool(config_manager.get("RenderMarkdown", True), True) + if rendered_html_path: + html_path = Path(rendered_html_path) + logs.append(f"已生成 HTML: {html_path.name}") + if not save_markdown and render_html and report_markdown_path.exists(): + report_markdown_path.unlink(missing_ok=True) + logs.append("已删除 Markdown(仅保留 HTML)") + elif not save_markdown and report_markdown_path.exists(): + report_markdown_path.unlink(missing_ok=True) + + with config_lock: + config_manager.update_token_usage( + vlm_usage["prompt_tokens"], + vlm_usage["completion_tokens"], + llm_usage["prompt_tokens"], + llm_usage["completion_tokens"], + ) + config_manager.save() + + except Exception as exc: # pylint: disable=broad-except + logging.exception("文件处理失败: %s", saved_path) + error = str(exc) + logs.append(f"处理失败: {error}") + + saved_rel = relative_to_output(saved_path) + markdown_rel = relative_to_output(markdown_path) if markdown_path else None + if rendered_html_path: + html_rel = relative_to_output(Path(rendered_html_path)) + else: + html_rel = relative_to_output(html_path) if html_path else None + + return { + "index": file_info["index"], + "original": file_info["original"], + "saved": saved_rel, + "markdown": markdown_rel, + "html": html_rel, + "vlm_usage": vlm_usage, + "llm_usage": llm_usage, + "logs": logs, + "error": error, + } + + try: + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [executor.submit(process_single, info) for info in saved_files] + for future in as_completed(futures): + result = future.result() + if not result["error"]: + aggregate["vlm_in"] += result["vlm_usage"]["prompt_tokens"] + aggregate["vlm_out"] += result["vlm_usage"]["completion_tokens"] + aggregate["llm_in"] += result["llm_usage"]["prompt_tokens"] + aggregate["llm_out"] += result["llm_usage"]["completion_tokens"] + else: + failures += 1 + + with run_states_lock: + state = run_states.get(run_id) + if not state: + continue + state["completed"] = state.get("completed", 0) + 1 + state.setdefault("results", {})[result["index"]] = result + state["aggregate"] = aggregate.copy() + if result["error"]: + state.setdefault("errors", []).append( + {"index": result["index"], "message": result["error"]} + ) + except Exception as exc: # pylint: disable=broad-except + logging.exception("批处理任务失败: %s", run_id) + with run_states_lock: + state = run_states.get(run_id) + if state: + state["status"] = "failed" + state["error"] = str(exc) + state["aggregate"] = aggregate + state["finished_at"] = datetime.now().isoformat(timespec="seconds") + return + + total = len(saved_files) + if total == 0: + status = "empty" + elif failures == 0: + status = "ok" + elif failures == total: + status = "failed" + else: + status = "partial" + + with run_states_lock: + state = run_states.get(run_id) + if state: + state["status"] = status + state["aggregate"] = aggregate + state["completed"] = total + state["finished_at"] = datetime.now().isoformat(timespec="seconds") + + @app.get("/api/config") + def read_config(): + usage = { + "vlm_input": int(config_manager.get("UsageVlmInput", 0) or 0), + "vlm_output": int(config_manager.get("UsageVlmOutput", 0) or 0), + "llm_input": int(config_manager.get("UsageLlmInput", 0) or 0), + "llm_output": int(config_manager.get("UsageLlmOutput", 0) or 0), + } + + with update_lock: + latest_version = update_state["latest"] + checked_at = update_state["checked"] + + has_vlm_key = bool(config_manager.get("VlmApiKey")) + has_llm_key = bool(config_manager.get("LlmApiKey")) + + data = { + "VlmUrl": config_manager.get("VlmUrl", ""), + "VlmApiKey": "", + "HasVlmApiKey": has_vlm_key, + "VlmModel": config_manager.get("VlmModel", ""), + "LlmUrl": config_manager.get("LlmUrl", ""), + "LlmApiKey": "", + "HasLlmApiKey": has_llm_key, + "LlmModel": config_manager.get("LlmModel", ""), + "SensitivityFactor": config_manager.get("SensitivityFactor", "1.0"), + "MaxWorkers": config_manager.get("MaxWorkers", 4), + "MaxRetries": config_manager.get("MaxRetries", 3), + "RetryDelay": config_manager.get("RetryDelay", 5), + "SaveMarkdown": _as_bool(config_manager.get("SaveMarkdown", True), True), + "RenderMarkdown": _as_bool(config_manager.get("RenderMarkdown", True), True), + "AutoUpdateCheck": _as_bool(config_manager.get("AutoUpdateCheck", True), True), + "LlmPromptTemplate": config_manager.get("LlmPromptTemplate") or DEFAULT_LLM_PROMPT_TEMPLATE, + "OutputDirectory": str(config_manager.get("OutputDirectory", DEFAULT_OUTPUT_DIR_NAME)), + "Usage": usage, + "CurrentVersion": CURRENT_VERSION, + "LatestVersion": latest_version, + "CheckedAt": checked_at, + } + return jsonify(data) + + @app.post("/api/config") + def update_config(): + payload = request.get_json(silent=True) or {} + + string_fields = [ + "VlmUrl", + "VlmModel", + "LlmUrl", + "LlmModel", + "OutputDirectory", + ] + sensitive_fields = [ + "VlmApiKey", + "LlmApiKey", + ] + int_fields = ["MaxWorkers", "MaxRetries", "RetryDelay"] + bool_fields = ["SaveMarkdown", "RenderMarkdown", "AutoUpdateCheck"] + + updates: Dict[str, Any] = {} + + for key in string_fields: + if key in payload: + value = (payload.get(key) or "").strip() + if key == "OutputDirectory" and not value: + return jsonify({"error": "输出目录不能为空"}), 400 + updates[key] = value + + for key in sensitive_fields: + if key in payload: + value = (payload.get(key) or "").strip() + if value: + updates[key] = value + + if payload.get("ClearVlmApiKey"): + updates["VlmApiKey"] = "" + if payload.get("ClearLlmApiKey"): + updates["LlmApiKey"] = "" + + for key in int_fields: + if key in payload and payload[key] not in (None, ""): + try: + updates[key] = int(payload[key]) + except (TypeError, ValueError): + return jsonify({"error": f"{key} 需要是整数"}), 400 + + if "SensitivityFactor" in payload and payload["SensitivityFactor"] not in (None, ""): + try: + updates["SensitivityFactor"] = float(payload["SensitivityFactor"]) + except (TypeError, ValueError): + return jsonify({"error": "SensitivityFactor 需要是数字"}), 400 + + for key in bool_fields: + if key in payload: + updates[key] = bool(payload[key]) + + prompt_template = payload.get("LlmPromptTemplate") + if prompt_template is not None: + normalized = str(prompt_template).strip() + if not normalized: + updates["LlmPromptTemplate"] = None + elif normalized == DEFAULT_LLM_PROMPT_TEMPLATE.strip(): + updates["LlmPromptTemplate"] = None + else: + updates["LlmPromptTemplate"] = normalized + + with config_lock: + for key, value in updates.items(): + if key == "LlmPromptTemplate" and value is None: + config_manager.config.pop(key, None) + elif key in ("VlmApiKey", "LlmApiKey") and value == "": + config_manager.config.pop(key, None) + else: + config_manager.set(key, value) + config_manager.save() + + if "OutputDirectory" in updates and updates["OutputDirectory"]: + get_output_root() + + start_update_check(force=True) + return jsonify({"status": "ok"}) + + @app.post("/api/process") + def process_files(): + topic = (request.form.get("topic") or "").strip() + if not topic: + return jsonify({"error": "请输入作文题目"}), 400 + + uploads = request.files.getlist("files") + if not uploads: + return jsonify({"error": "请至少选择一张图片"}), 400 + + run_id = datetime.now().strftime("%Y%m%d-%H%M%S") + output_root = get_output_root() + run_dir = _ensure_directory(output_root / run_id) + + saved_files: List[Dict[str, Any]] = [] + used_names = set() + for index, upload in enumerate(uploads): + original_name = upload.filename or f"upload_{index + 1}.png" + safe_name = secure_filename(original_name) or f"upload_{index + 1}.png" + if safe_name in used_names: + stem = Path(safe_name).stem + suffix = Path(safe_name).suffix or ".png" + counter = 1 + candidate = f"{stem}_{counter}{suffix}" + while candidate in used_names: + counter += 1 + candidate = f"{stem}_{counter}{suffix}" + safe_name = candidate + used_names.add(safe_name) + + saved_path = run_dir / safe_name + upload.save(saved_path) + saved_files.append( + { + "index": index, + "original": original_name, + "name": safe_name, + "path": saved_path, + } + ) + + try: + max_workers = int(config_manager.get("MaxWorkers", 4)) or 1 + except (TypeError, ValueError): + max_workers = 4 + + save_markdown = _as_bool(config_manager.get("SaveMarkdown", True), True) + run_path = relative_to_output(run_dir) + + run_state = { + "run_id": run_id, + "status": "queued", + "total": len(saved_files), + "completed": 0, + "aggregate": {"vlm_in": 0, "vlm_out": 0, "llm_in": 0, "llm_out": 0}, + "results": {}, + "errors": [], + "run_path": run_path, + "created_at": datetime.now().isoformat(timespec="seconds"), + } + + with run_states_lock: + run_states[run_id] = run_state + + worker = threading.Thread( + target=_execute_run, + args=(run_id, saved_files, topic, run_dir, max_workers, save_markdown), + daemon=True, + ) + worker.start() + + return jsonify( + { + "status": "queued", + "run_id": run_id, + "total": len(saved_files), + "run_path": run_path, + } + ) + + @app.get("/api/run-status/") + def run_status(run_id: str): + with run_states_lock: + state = run_states.get(run_id) + if not state: + abort(404) + + results_dict = state.get("results", {}) + results = [results_dict[index] for index in sorted(results_dict.keys())] + aggregate = dict(state.get("aggregate", {"vlm_in": 0, "vlm_out": 0, "llm_in": 0, "llm_out": 0})) + + response = { + "run_id": run_id, + "status": state.get("status", "unknown"), + "total": state.get("total", 0), + "completed": state.get("completed", 0), + "aggregate": aggregate, + "results": results, + "run_path": state.get("run_path"), + "error": state.get("error"), + "errors": state.get("errors", []), + } + + return jsonify(response) + + @app.get("/outputs/") + def serve_outputs(requested_path: str): + output_root = get_output_root().resolve() + target_path = (output_root / requested_path).resolve() + try: + target_path.relative_to(output_root) + except ValueError: + abort(404) + if not target_path.exists() or target_path.is_dir(): + abort(404) + relative = target_path.relative_to(output_root).as_posix() + return send_from_directory(str(output_root), relative) + + @app.get("/api/update-status") + def update_status(): + with update_lock: + return jsonify( + { + "current": CURRENT_VERSION, + "latest": update_state["latest"], + "checked": update_state["checked"], + } + ) + + @app.post("/api/update-check") + def trigger_update_check(): + start_update_check(force=True) + return jsonify({"status": "checking"}) + + @app.get("/") + def index(): + html = ''' + + + + + + AI 作文批改助手 · Web + + + + + + + AI 作文批改助手 · Web + 版本 {{ current_version }} + + 加载用量中... + + + + 批改作文 + 服务设置 + 关于 + + + + + 批改任务 + + 作文题目 / 场景说明 + + + 上传作文照片 (支持多选) + + + + 开始批改 + + + + + + + 批改结果 + + 暂时没有任务,上传图片后将显示处理结果。 + + + + + + + 服务设置 + + + VLM URL + + + VLM API Key + + + VLM 模型 + + + LLM URL + + + LLM API Key + + + LLM 模型 + + + 手写敏感度 (建议 1.0) + + + 最大并发数 + + + 最大重试次数 + + + 重试延迟 (秒) + + + 输出目录 + + + + + 保存 Markdown + 渲染 HTML 报告 + 启动时检查更新 + + LLM Prompt 模板 + + + + 保存设置 + 恢复默认模板 + + + + + + + + 关于与更新 + + 当前版本:{{ current_version }} + 正在获取最新版本信息... + + + 检查更新 + + + + 使用提示 + + 默认使用 output_reports/时间戳 保存批改文件,可在设置中修改。 + 可单独保存 Markdown 或 HTML,也可保留二者。 + Prompt 模板支持完全自定义,请保留参数占位符以确保正常传值。 + + + + + + + + + + ''' + return render_template_string( + html, + current_version=CURRENT_VERSION, + default_output_dir=DEFAULT_OUTPUT_DIR_NAME, + ) + + start_update_check() + return app
版本 {{ current_version }}
output_reports/时间戳