diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3f683f..b58204b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,11 +42,10 @@ jobs: pip install pyinstaller # 第4步:使用PyInstaller打包 - # --noconsole: 这是一个GUI应用,不显示控制台窗口 - # --onefile: 打包成单个.exe文件 + # --onefile: 打包成单个可执行文件 # --name: 指定生成的可执行文件名 - name: Build with PyInstaller - run: pyinstaller --noconsole --onefile --name "AI-Essay-Corrector" main.py + run: pyinstaller --onefile --name "AI-Essay-Corrector" main.py # 第5步:将打包好的上传,以便在工作流页面下载 (适合测试) - name: Upload artifact for testing @@ -60,4 +59,4 @@ jobs: if: startsWith(github.ref, 'refs/tags/') # 仅在创建Tag时运行此步骤 uses: softprops/action-gh-release@v2 with: - files: dist/AI-Essay-Corrector* \ No newline at end of file + files: dist/AI-Essay-Corrector* diff --git a/README.md b/README.md index 3388882..8ceb985 100644 --- a/README.md +++ b/README.md @@ -1,137 +1,106 @@ # AI 作文批改助手 ✨ -(^▽^)ノ゙ 欢迎使用 AI 作文批改助手!这是一款专为教育工作者和学生设计的本地 Web 应用,能够像经验丰富的英语老师一样,自动批改手写英文作文图片,并生成专业详细的批改报告。 +> 上传手写英文作文 → 自动识别文本 → 按高考标准打分 → 输出详尽反馈报告,全流程在本地浏览器完成。 -## ✨ 核心特色功能 +![Web UI 批改页面](photo/1.png) +![Web UI 设置页面](photo/2.png) +![Web UI 关于页面](photo/3.png) -### 🤖 双AI引擎智能处理 -- **视觉语言模型(VLM)**: 专业的手写文字识别(OCR)和书写质量评估,给出精准的卷面分数 -- **大语言模型(LLM)**: 深度内容分析,提供专业的语法纠错和写作建议 -- **智能作文类型识别**: 自动识别应用文(15分制)和读后续写(25分制)两种高考作文类型 +## 全面重构亮点 +- **现代化 Web UI**:基于 Flask 构建的单页应用,所有功能集中在浏览器端完成,配置与状态实时同步。 +- **任务可追溯**:每次批改都会创建独立 run id,原图、Markdown、HTML 报告集中存放,便于回看和分享。 +- **并发调度升级**:多线程线程池 + 独立任务状态机,批量图片互不阻塞,失败文件单独记录。 +- **Prompt / 评分可插拔**:默认内置高考英语评分模板,可在 UI 动态替换;书写敏感度、模型温度均可调整。 +- **安全与透明**:API Key 以设备指纹派生的密钥加密存储,支持一键清除;Token 用量实时累积并在 UI 展示。 +- **自动更新提示**:后台检查 GitHub Releases 获取最新版本信息,可一键触发或关闭。 -### ⚙️ 极致灵活配置 -- **服务独立配置**: VLM和LLM支持完全独立的API服务、密钥和模型配置 -- **评分标准可调**: 书写质量"敏感度因子"自由调节,适应不同评分要求 -- **Prompt模板开放**: 核心批改指令完全可自定义,打造个性化批改风格 +## 工作原理概览 +1. **图片接入**:兼容摄像头拍照、扫描件或批量上传,自动清洗文件名防止覆盖。 +2. **VLM 解析**:将图片转为 base64,通过兼容 OpenAI 的视觉模型 OCR + 计算书写分。 +3. **LLM 批改**:根据作文题目、识别文本、书写分构建 Prompt,生成结构化中文反馈。 +4. **报告生成**:按配置保存 Markdown,并可渲染为带主题的 HTML 文件输出。 +5. **状态同步**:Web UI 实时播报进度、日志、Token 消耗。 -### 🚀 高效并发处理 -- 多线程并发引擎,支持批量处理任意数量的作文图片 -- 智能任务调度,大幅提升批改效率,节省宝贵时间 -- 实时进度显示和详细日志输出,随时掌握处理状态 +## 快速开始 -### 🔒 企业级安全保障 -- 军事级加密算法保护API密钥,防止敏感信息泄露 -- 本地配置文件加密存储,确保账户安全无忧 -- 透明的Token使用统计,方便成本控制 +### 环境准备 +- Python 3.9 及以上 +- macOS / Windows / Linux 均可 +- 任意兼容 OpenAI API 协议的 VLM/LLM 服务(OpenAI、Azure OpenAI、通义、DeepSeek 等) -### 📊 专业输出格式 -- **Markdown源文件**: 完整的批改报告,支持进一步编辑和定制 -- **HTML可视化报告**: 美观易读的网页格式,方便分享和查看 -- **详细错误分析**: 语法错误、表达问题、修改建议一应俱全 -- **精准分数评估**: 专业的评分体系,符合高考评分标准 - ---- - -## 使用指南 - -### 快速开始 -1. **下载程序**: 前往 [Releases页面](https://github.com/Eric-Terminal/Pro_llm_correct/releases) 下载最新版本 -2. **启动 Web UI**: - - 在终端运行 `python3 main.py` - - 程序会从 4567 端口起寻找可用端口,并自动打开浏览器访问 Web 界面 -3. **配置服务**: - - 通过顶部导航切换到“服务设置”页,填写 VLM/LLM 的 URL、API Key、模型名称等参数 - - 可自定义 Prompt 模板、并发数量、重试策略与输出目录 - - 密钥字段不会回显;若提示“已保存”,留空即可沿用原值,输入新值即可覆盖 - - 点击“保存设置”即可持久化到本地 `config.json`(密钥自动加密) -4. **上传批改**: - - Web 首页默认停留在“批改作文”页,在表单中输入作文题目或场景说明 - - 上传需要批改的作文照片(支持多选) - - 点击“开始批改”,浏览器会实时显示每个文件的处理状态与日志 -5. **查看报告**: - - 所有输出默认保存在 `output_reports/<时间戳>/` 目录 - - 结果卡片中提供 Markdown/HTML 链接,可直接在浏览器查看或下载 - -### 输出文件说明 -- 默认保存在 `output_reports/<时间戳>/` 目录 -- `原文件名_report.md`: Markdown 格式详细批改报告 -- `原文件名_report.html`: HTML 可视化批改报告 -- 包含: 作文内容、综合评价、亮点优点、问题建议、分数评估 - ---- - -## 🛠️ 开发者指南 - -### 环境搭建 +### 安装依赖 ```bash -# 1. 克隆仓库 git clone https://github.com/Eric-Terminal/Pro_llm_correct.git cd Pro_llm_correct - -# 2. 创建虚拟环境(推荐) python3 -m venv venv -source venv/bin/activate # Linux/Mac -# venv\Scripts\activate # Windows - -# 3. 安装依赖 +source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install -r requirements.txt -# 需要确保系统已安装 curl(macOS/Linux 默认自带,Windows 可安装 Git Bash 或使用 WSL) +``` -# 4. 运行程序 +### 启动 Web 版 +```bash python3 main.py ``` +- 应用将尝试从 4567–4667 中选择空闲端口,并自动打开默认浏览器。 +- 首次运行会生成 `config.json`、`output_reports/` 等目录。 -### 项目打包 -```bash -# 打包为独立可执行文件 -pyinstaller --noconsole --onefile main.py +## 使用流程 +1. 在「批改作文」页填入题目或场景说明。 +2. 上传一张或多张作文图片并提交。 +3. 查看实时处理状态:成功会显示 Markdown / HTML 下载链接,失败会给出详细错误。 +4. 结果保存在 `output_reports//` 中,run id 由时间戳生成,保证唯一。 -# 打包好的程序在 dist/ 目录 ``` +output_reports/ +└── / # 例如 20240101-120000 + ├── essay-1.png # 原始上传文件 + ├── essay-1_report.md # Markdown 报告(若启用 SaveMarkdown) + └── essay-1_report.html # HTML 报告(若启用 RenderMarkdown) +``` +- `OutputDirectory` 可改为绝对路径以迁移到 NAS / 外部硬盘。 +- 若只启用 HTML,程序会在渲染完成后自动删除对应 Markdown 文件。 -### 技术架构 -- **前端**: Flask Web 服务 + 原生 HTML/CSS(玻璃拟态苹果风界面) -- **核心**: 双AI引擎架构 (VLM + LLM) -- **安全**: cryptography 加密存储配置 -- **并发**: threading + concurrent.futures.ThreadPoolExecutor -- **输出**: Markdown/HTML 报告(内置样式渲染器) +## 关键配置参考 +| 分类 | 键名 | 说明 | +| --- | --- | --- | +| 服务连接 | `VlmUrl` / `VlmModel` / `VlmApiKey`
`LlmUrl` / `LlmModel` / `LlmApiKey` | 与 OpenAI SDK 参数保持一致;密钥输入后即被本地加密,输入框留空表示沿用已有值。 | +| 性能与容错 | `MaxWorkers` / `MaxRetries` / `RetryDelay` / `RequestTimeout` | 控制并发线程数、失败重试次数与间隔、单次请求超时(秒)。 | +| 评分策略 | `SensitivityFactor` | 对 VLM 输出的书写分进行幂次强化/弱化(默认 1.0)。 | +| | `VlmTemperature` / `LlmTemperature` | 约束模型随机性,范围 0–2。 | +| Prompt 定制 | `LlmPromptTemplate` | 使用 Python `str.format` 语法,支持 `{topic}`、`{wscore}`、`{essay_text}` 占位符,留空回退到内置模板。 | +| 输出控制 | `OutputDirectory` / `SaveMarkdown` / `RenderMarkdown` | 自定义输出目录及报告格式,布尔选项可在 UI 勾选。 | +| 版本与统计 | `AutoUpdateCheck` / `UsageVlmInput` 等 | 自动更新开关及历史 Token 统计,展示于 UI「关于」面板。 | ---- +配置文件位于仓库根目录 `config.json`,敏感字段均以设备指纹派生密钥加密存储,迁移到新设备后需重新输入 API Key。 -## 📝 配置说明 +## Web API(用于自动化集成) +- `GET /api/config`:读取当前配置、版本信息、Token 统计。 +- `POST /api/config`:提交 JSON 更新配置;支持 `ClearVlmApiKey` / `ClearLlmApiKey` 清除敏感字段。 +- `POST /api/process`:multipart/form-data,包含 `topic` 与 `files[]`,返回 run id。 +- `GET /api/run-status/`:轮询任务状态、日志、Token 用量以及生成的文件路径。 +- `GET /outputs/`:访问生成的原图或批改报告。 -### 必需配置项 -- `VlmUrl`: VLM服务地址 -- `VlmApiKey`: VLM服务密钥(自动加密) -- `VlmModel`: VLM模型名称 -- `LlmUrl`: LLM服务地址 -- `LlmApiKey`: LLM服务密钥(自动加密) -- `LlmModel`: LLM模型名称 +## 日志与故障排查 +- 控制台会输出端口探测、API 请求摘要和异常信息。 +- Web UI:结果卡片实时显示每个文件的日志及错误信息。 +- 常见问题排查: + - **配置缺失**:缺少必填项时,后端会在任务开始前返回具体提示。 + - **网络或权限错误**:请确认模型名称、Key 是否正确,服务是否支持图像输入,并适当调整 `RequestTimeout` / `RetryDelay`。 -### 可选配置项 -- `SensitivityFactor`: 书写评分敏感度因子(默认1.5) -- `MaxWorkers`: 最大并发数(默认4) -- `MaxRetries`: 最大重试次数(默认3) -- `RetryDelay`: 重试延迟秒数(默认5) -- `RequestTimeout`: 单次 API 请求超时时长(秒,默认120) -- `SaveMarkdown`: 是否保存Markdown文件(默认True) -- `RenderMarkdown`: 是否渲染HTML报告(默认True) +## 开发者指南 +- 核心依赖:Flask(Web 服务)、cryptography(配置加密)、openai SDK(兼容多家服务)、markdown(报告渲染)。 +- 调试技巧: + ```bash + python3 web_app.py # 直接运行 Flask 应用 + python3 main.py # 启动正式入口,包含日志与端口选择 + ``` +- 如需打包为单文件可执行程序: + ```bash + pyinstaller --noconsole --onefile main.py + ``` + 生成的可执行文件位于 `dist/`。 ---- - -## 📄 开源协议 - -本项目采用 [MIT License](LICENSE) 开源协议。您可以自由地使用、修改和分发本软件,只需保留原始的版权声明即可。 - ---- - -## 🤝 贡献与支持 - -如果您在使用过程中遇到问题或有改进建议,欢迎: -- 提交 [Issue](https://github.com/Eric-Terminal/Pro_llm_correct/issues) -- 发起 [Pull Request](https://github.com/Eric-Terminal/Pro_llm_correct/pulls) -- 给项目点个 ⭐ Star 支持一下! - ---- - -*由 Eric-Terminal 精心开发。希望这个工具能够帮助更多的教育工作者和学生!(。・ω・。)ノ♡* +## 贡献与许可 +- 欢迎通过 Issue / Pull Request 分享想法与改进。 +- 如果这个项目对你有帮助,别忘了点个 ⭐️。 +- 本项目遵循 [MIT License](LICENSE)。 diff --git a/api_services.py b/api_services.py index 8db803c..8d6f700 100644 --- a/api_services.py +++ b/api_services.py @@ -1,16 +1,18 @@ import base64 -from typing import Dict, Any, Tuple, Optional -import urllib.request import json -from packaging import version -from config_manager import ConfigManager -from markdown_renderer import create_markdown_renderer -import os +import logging import mimetypes +import os import re import time -import logging -import subprocess +import urllib.request +from typing import Any, Dict, Optional, Tuple + +from openai import OpenAI, OpenAIError +from packaging import version + +from config_manager import ConfigManager +from markdown_renderer import create_markdown_renderer # 定义默认的LLM Prompt模板。使用`.format()`方法进行后续的动态填充。 DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC @@ -100,13 +102,13 @@ class ApiService: self.config = config_manager self.ui_queue = ui_queue self.markdown_renderer = create_markdown_renderer(config_manager) + self.logger = logging.getLogger("essay_corrector.api") def _log(self, message: str): """将日志消息放入UI队列。""" + self.logger.info(message) 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。""" @@ -122,7 +124,7 @@ class ApiService: def _chat_endpoint(self, base_url: Optional[str]) -> str: if not base_url: raise ValueError("服务地址未配置,请先在设置中填写 API Base URL") - return base_url.rstrip('/') + "/chat/completions" + return base_url.rstrip("/") def _usage_from_response(self, response_json: Dict[str, Any]) -> Dict[str, int]: usage = response_json.get("usage") or {} @@ -131,51 +133,15 @@ class ApiService: "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)), - ] + def _create_openai_client(self, base_url: str, api_key: Optional[str], timeout: float) -> OpenAI: + client_kwargs: Dict[str, Any] = { + "base_url": base_url.rstrip("/"), + "timeout": max(timeout, 1.0), + "max_retries": 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 + client_kwargs["api_key"] = api_key + return OpenAI(**client_kwargs) def _invoke_chat_completion( self, @@ -187,17 +153,31 @@ class ApiService: retry_delay: int, timeout: float, ) -> Dict[str, Any]: - endpoint = self._chat_endpoint(base_url) + normalized_base_url = self._chat_endpoint(base_url) + endpoint = f"{normalized_base_url}/chat/completions" + client = self._create_openai_client(normalized_base_url, api_key, timeout) last_error: Optional[Exception] = None for attempt in range(max_retries): try: - return self._post_json_with_curl(endpoint, api_key, payload, timeout) + model = payload.get("model") + self._log(f"{label} 请求: endpoint={endpoint}, model={model}") + response = client.chat.completions.create(**payload) + response_json = response.model_dump() + trimmed = json.dumps(response_json, ensure_ascii=False) + if len(trimmed) > 800: + trimmed = trimmed[:797] + "..." + self._log(f"{label} 响应: {trimmed}") + return response_json + except OpenAIError as exc: + last_error = exc + error_message = str(exc) 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) + error_message = str(exc) + if attempt == max_retries - 1: + raise last_error + self._log(f"{label} 调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries}),错误: {error_message}") + time.sleep(retry_delay) if last_error: raise last_error raise RuntimeError(f"{label} 调用失败:未知错误") @@ -222,6 +202,12 @@ class ApiService: except (ValueError, TypeError): request_timeout = 120.0 + try: + vlm_temperature = float(self.config.get("VlmTemperature", 0.0)) + except (ValueError, TypeError): + vlm_temperature = 0.0 + vlm_temperature = min(max(vlm_temperature, 0.0), 2.0) + base64_image_url = self._encode_image_to_base64_url(file_path) vlm_prompt = """# ROLE @@ -250,12 +236,12 @@ 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_model = self.config.get("VlmModel", "Qwen/Qwen3-VL-235B-A22B-Instruct") vlm_payload = { "model": vlm_model, "messages": vlm_messages, "max_tokens": 4096, - "temperature": 1, + "temperature": vlm_temperature, } vlm_response_json = self._invoke_chat_completion( "VLM", @@ -306,12 +292,18 @@ 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") + try: + llm_temperature = float(self.config.get("LlmTemperature", 0.0)) + except (ValueError, TypeError): + llm_temperature = 0.0 + llm_temperature = min(max(llm_temperature, 0.0), 2.0) + + llm_model = self.config.get("LlmModel", "Qwen/Qwen3-VL-235B-A22B-Instruct") llm_payload = { "model": llm_model, "messages": llm_messages, - "temperature": 1, - "max_tokens": 16384, + "temperature": llm_temperature, + "max_tokens": 4096, } final_report: str diff --git a/app_ui.py b/app_ui.py deleted file mode 100644 index ddf7e6b..0000000 --- a/app_ui.py +++ /dev/null @@ -1,516 +0,0 @@ -import tkinter as tk -from tkinter import ttk, filedialog, messagebox -import threading -import queue -import os -from typing import List -import concurrent.futures -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') - -class AboutDialog(tk.Toplevel): - """“关于”对话框,展示应用信息,支持滚动查看。""" - def __init__(self, parent, config_manager: ConfigManager): - super().__init__(parent) - self.transient(parent) - self.title("关于 AI 作文批改助手") - # 设置一个适合滚动的默认窗口尺寸 - self.geometry("450x400") - - # 主框架,用于容纳文本和滚动条 - main_frame = ttk.Frame(self) - main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) - main_frame.grid_rowconfigure(0, weight=1) - main_frame.grid_columnconfigure(0, weight=1) - - # 使用Text控件以支持长文本和滚动条 - text_widget = tk.Text(main_frame, wrap="word", relief="flat", spacing1=5, spacing3=5) - text_widget.grid(row=0, column=0, sticky="nsew") - - # 创建并关联垂直滚动条 - scrollbar = ttk.Scrollbar(main_frame, orient="vertical", command=text_widget.yview) - scrollbar.grid(row=0, column=1, sticky="ns") - text_widget.config(yscrollcommand=scrollbar.set) - - vlm_in = config_manager.get('UsageVlmInput', 0) - vlm_out = config_manager.get('UsageVlmOutput', 0) - llm_in = config_manager.get('UsageLlmInput', 0) - llm_out = config_manager.get('UsageLlmOutput', 0) - - about_text = f""" -欢迎使用 AI 作文批改助手!这是一款专为教育工作者和学生设计的智能工具,利用前沿的人工智能技术,提供高效、精准、个性化的英文作文批改体验。 - -✨ 核心特色: - -- **双AI引擎架构:** 采用创新的两步式处理流程。首先由专业的视觉语言模型(VLM)进行高精度手写文字识别(OCR)和专业的书写质量评估;然后由强大的大语言模型(LLM)结合识别文本、作文题目和书写评分,进行深度内容分析和专业批改。 - -- **极致灵活性:** - * **服务独立配置:** VLM和LLM支持完全独立的API服务地址、密钥和模型名称,轻松适配各种AI服务提供商(兼容OpenAI格式) - * **智能评分调节:** 书写质量"敏感度因子"可自由调整,适应不同年级和评分标准要求 - * **Prompt完全开放:** 核心批改指令模板完全可自定义,支持调整评分标准、总分设置和反馈风格 - -- **高效并发处理:** 内置多线程并发引擎,支持批量处理任意数量的图片,大幅提升批改效率,最大并发数可配置 - -- **企业级安全保障:** 所有API密钥均采用军事级加密算法存储,确保您的账户信息安全 - -- **专业评分体系:** 针对高考英语作文场景设计,支持应用文(15分制)和读后续写(25分制)两种评分标准 - -📋 使用指南: -1. **首次设置:** 点击"设置",配置VLM和LLM服务的URL、API密钥和模型 -2. **输入题目:** 在主界面文本框中输入本次批改的作文题目 -3. **选择图片:** 点击"选择图片",可多选需要批改的作文图片 -4. **开始批改:** 点击"开始批改",程序自动进行并发处理 -5. **查看报告:** 处理完成后,Markdown和HTML格式的详细批改报告将保存在原图片目录 - -🎯 输出格式: -- Markdown源文件(可编辑) -- HTML可视化报告(美观易读) -- 详细的语法错误分析 -- 专业的写作建议 -- 精准的分数评估 - -作者: Eric_Terminal -项目地址: https://github.com/Eric-Terminal/Pro_llm_correct -版本: {CURRENT_VERSION} - ---- -历史Token使用统计: -- VLM 输入Token: {vlm_in:,} -- VLM 输出Token: {vlm_out:,} -- LLM 输入Token: {llm_in:,} -- LLM 输出Token: {llm_out:,} -""" - - text_widget.insert("1.0", about_text) - # 将文本设置为只读,防止用户修改 - text_widget.config(state="disabled") - - # 放置“关闭”按钮的框架 - btn_frame = ttk.Frame(main_frame) - btn_frame.grid(row=1, column=0, columnspan=2, sticky="e", pady=(10, 0)) - close_button = ttk.Button(btn_frame, text="关闭", command=self.destroy) - close_button.pack() - - self.protocol("WM_DELETE_WINDOW", self.destroy) - self.grab_set() - self.wait_window(self) - - -class SettingsDialog(tk.Toplevel): - """"设置"对话框,允许用户配置VLM、LLM服务及其他应用参数。""" - def __init__(self, parent, config_manager: ConfigManager): - super().__init__(parent) - self.transient(parent) - self.title("设置") - self.result = None - self.config_manager = config_manager - - # 为VLM和LLM服务分别创建Tkinter字符串变量 - # 使用config_manager.get()方法获取解密后的值用于显示 - self.vlm_url = tk.StringVar(value=config_manager.get("VlmUrl", "https://api.siliconflow.cn/v1")) - self.vlm_api_key = tk.StringVar(value=config_manager.get("VlmApiKey", "")) - self.vlm_model = tk.StringVar(value=config_manager.get("VlmModel", "Pro/THUDM/GLM-4.1V-9B-Thinking")) - self.llm_url = tk.StringVar(value=config_manager.get("LlmUrl", "https://api.siliconflow.cn/v1")) - self.llm_api_key = tk.StringVar(value=config_manager.get("LlmApiKey", "")) - self.llm_model = tk.StringVar(value=config_manager.get("LlmModel", "Qwen/Qwen3-235B-A22B-Instruct-2507")) - self.sensitivity_factor = tk.StringVar(value=config_manager.get("SensitivityFactor", "1.5")) - self.max_workers = tk.StringVar(value=config_manager.get("MaxWorkers", "4")) - self.max_retries = tk.StringVar(value=config_manager.get("MaxRetries", "3")) - self.retry_delay = tk.StringVar(value=config_manager.get("RetryDelay", "5")) - self.save_markdown = tk.BooleanVar(value=config_manager.get("SaveMarkdown", True)) - self.render_markdown = tk.BooleanVar(value=config_manager.get("RenderMarkdown", True)) - self.auto_update_check = tk.BooleanVar(value=config_manager.get("AutoUpdateCheck", True)) - - # 智能加载Prompt模板:优先使用用户自定义模板,否则使用默认模板 - user_template = config_manager.get("LlmPromptTemplate") - self.llm_prompt_template_str = user_template if user_template else DEFAULT_LLM_PROMPT_TEMPLATE - - - frame = ttk.Frame(self, padding="10") - frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - frame.grid_columnconfigure(0, weight=1) - - # VLM服务设置区域 - vlm_frame = ttk.LabelFrame(frame, text="VLM (视觉模型) 设置", padding="10") - vlm_frame.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5) - vlm_frame.grid_columnconfigure(1, weight=1) - ttk.Label(vlm_frame, text="VLM URL:").grid(column=0, row=0, sticky=tk.W, pady=2) - ttk.Entry(vlm_frame, textvariable=self.vlm_url, width=40).grid(column=1, row=0, sticky=(tk.W, tk.E)) - ttk.Label(vlm_frame, text="VLM API Key:").grid(column=0, row=1, sticky=tk.W, pady=2) - ttk.Entry(vlm_frame, textvariable=self.vlm_api_key, width=40, show='*').grid(column=1, row=1, sticky=(tk.W, tk.E)) - ttk.Label(vlm_frame, text="VLM 模型:").grid(column=0, row=2, sticky=tk.W, pady=2) - ttk.Entry(vlm_frame, textvariable=self.vlm_model, width=40).grid(column=1, row=2, sticky=(tk.W, tk.E)) - - # LLM服务设置区域 - llm_frame = ttk.LabelFrame(frame, text="LLM (语言模型) 设置", padding="10") - llm_frame.grid(row=1, column=0, sticky=(tk.W, tk.E), pady=5) - llm_frame.grid_columnconfigure(1, weight=1) - ttk.Label(llm_frame, text="LLM URL:").grid(column=0, row=0, sticky=tk.W, pady=2) - ttk.Entry(llm_frame, textvariable=self.llm_url, width=40).grid(column=1, row=0, sticky=(tk.W, tk.E)) - ttk.Label(llm_frame, text="LLM API Key:").grid(column=0, row=1, sticky=tk.W, pady=2) - ttk.Entry(llm_frame, textvariable=self.llm_api_key, width=40, show='*').grid(column=1, row=1, sticky=(tk.W, tk.E)) - ttk.Label(llm_frame, text="LLM 模型:").grid(column=0, row=2, sticky=tk.W, pady=2) - ttk.Entry(llm_frame, textvariable=self.llm_model, width=40).grid(column=1, row=2, sticky=(tk.W, tk.E)) - - # 其他应用参数设置区域 - other_frame = ttk.LabelFrame(frame, text="其他设置", padding="10") - other_frame.grid(row=2, column=0, sticky=(tk.W, tk.E), pady=5) - other_frame.grid_columnconfigure(1, weight=1) - ttk.Label(other_frame, text="手写打分敏感度:").grid(column=0, row=0, sticky=tk.W, pady=2) - 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.save_markdown).grid(column=1, row=4, sticky=tk.W) - ttk.Label(other_frame, text="渲染HTML报告:").grid(column=0, row=5, sticky=tk.W, pady=2) - ttk.Checkbutton(other_frame, variable=self.render_markdown).grid(column=1, row=5, sticky=tk.W) - ttk.Label(other_frame, text="启动时检查更新:").grid(column=0, row=6, sticky=tk.W, pady=2) - ttk.Checkbutton(other_frame, variable=self.auto_update_check).grid(column=1, row=6, sticky=tk.W) - - # LLM Prompt模板编辑区域 - prompt_frame = ttk.LabelFrame(frame, text="LLM Prompt 模板 (可在此修改,请勿修改{}占位符内容导致程序参数无法正常传递,通常情况下修改总分即可)", padding="10") - prompt_frame.grid(row=3, column=0, sticky=(tk.W, tk.E), pady=5) - prompt_frame.grid_columnconfigure(0, weight=1) - prompt_frame.grid_rowconfigure(0, weight=1) - - self.llm_prompt_text = tk.Text(prompt_frame, height=10, wrap="word") - self.llm_prompt_text.grid(row=0, column=0, sticky="nsew") - prompt_scrollbar = ttk.Scrollbar(prompt_frame, orient="vertical", command=self.llm_prompt_text.yview) - prompt_scrollbar.grid(row=0, column=1, sticky="ns") - self.llm_prompt_text.config(yscrollcommand=prompt_scrollbar.set) - self.llm_prompt_text.insert("1.0", self.llm_prompt_template_str) - - btn_frame = ttk.Frame(frame) - btn_frame.grid(row=4, column=0, sticky=tk.E, pady=10) - ttk.Button(btn_frame, text="确定", command=self.on_ok).pack(side=tk.LEFT, padx=5) - ttk.Button(btn_frame, text="关闭", command=self.on_close).pack(side=tk.LEFT) - - self.protocol("WM_DELETE_WINDOW", self.on_close) - self.grab_set() - self.wait_window(self) - - def on_ok(self): - self.result = { - "VlmUrl": self.vlm_url.get(), - "VlmApiKey": self.vlm_api_key.get(), - "VlmModel": self.vlm_model.get(), - "LlmUrl": self.llm_url.get(), - "LlmApiKey": self.llm_api_key.get(), - "LlmModel": self.llm_model.get(), - "SensitivityFactor": self.sensitivity_factor.get(), - "MaxWorkers": self.max_workers.get(), - "MaxRetries": self.max_retries.get(), - "RetryDelay": self.retry_delay.get(), - "SaveMarkdown": self.save_markdown.get(), - "RenderMarkdown": self.render_markdown.get(), - "AutoUpdateCheck": self.auto_update_check.get(), - "LlmPromptTemplate": self.llm_prompt_text.get("1.0", "end-1c") - } - - # 如果用户修改后的模板与默认模板内容一致,则不写入配置文件,以使用默认值 - if self.result["LlmPromptTemplate"].strip() == DEFAULT_LLM_PROMPT_TEMPLATE.strip(): - self.result["LlmPromptTemplate"] = None # 使用 None 作为信号,表示应移除此配置项 - - self.destroy() - - def on_close(self): - self.result = None - self.destroy() - - -class MainApp: - """应用主窗口类,负责构建UI界面、处理用户交互和协调后台服务。""" - def __init__(self, root: tk.Tk, config_manager: ConfigManager): - self.root = root - self.config_manager = config_manager - 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.topic_input = None - self.processed_count = 0 - self.lock = threading.Lock() - - self._setup_ui() - self._initialize_config() - self._check_for_updates_on_startup() - self.root.after(100, self._process_ui_queue) - - def _setup_ui(self): - """初始化和布局主窗口的所有UI组件。""" - self.root.title("AI 作文批改助手") - self.root.geometry("550x450") - main_frame = ttk.Frame(self.root, padding="10") - main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) - self.root.grid_columnconfigure(0, weight=1) - self.root.grid_rowconfigure(0, weight=1) - - # 顶部进度条 - top_frame = ttk.Frame(main_frame) - top_frame.grid(row=0, column=0, columnspan=2, sticky=(tk.W, tk.E), pady=5) - top_frame.grid_columnconfigure(0, weight=1) - self.progress_bar = ttk.Progressbar(top_frame, orient="horizontal", mode="determinate") - self.progress_bar.grid(row=0, column=0, sticky=(tk.W, tk.E)) - - # 左侧控制按钮 - left_frame = ttk.Frame(main_frame) - left_frame.grid(row=1, column=0, sticky=(tk.N, tk.W), padx=(0, 10)) - ttk.Button(left_frame, text="选择图片", command=self._open_file_dialog).pack(fill=tk.X, pady=5) - ttk.Button(left_frame, text="开始批改", command=self._start_processing).pack(fill=tk.X, pady=5) - ttk.Button(left_frame, text="设置", command=self._open_settings_dialog).pack(fill=tk.X, pady=5) - ttk.Button(left_frame, text="关于", command=self._open_about_dialog).pack(fill=tk.X, pady=5) - - # 右侧输入和日志区域 - right_frame = ttk.Frame(main_frame) - right_frame.grid(row=1, column=1, sticky=(tk.W, tk.E, tk.N, tk.S)) - main_frame.grid_columnconfigure(1, weight=1) - main_frame.grid_rowconfigure(1, weight=1) - - # 配置右侧框架的网格权重,使组件能自适应缩放 - right_frame.grid_rowconfigure(0, weight=7) # 作文题目框占7份 - right_frame.grid_rowconfigure(1, weight=3) # 日志框占3份 - right_frame.grid_columnconfigure(0, weight=1) - - # 作文题目输入框 - self.topic_input = tk.Text(right_frame, wrap="word") - self.topic_input.grid(row=0, column=0, sticky="nsew", pady=(0, 5)) - - self.topic_input.insert("1.0", "(在此输入作文题目)") - self.topic_input.config(fg="grey") - - # 实现输入框的占位符(placeholder)效果 - def on_focus_in(event): - if self.topic_input.get("1.0", "end-1c").strip() == "(在此输入作文题目)": - self.topic_input.delete("1.0", tk.END) - self.topic_input.config(fg="black") - - def on_focus_out(event): - if not self.topic_input.get("1.0", "end-1c").strip(): - self.topic_input.insert("1.0", "(在此输入作文题目)") - self.topic_input.config(fg="grey") - - self.topic_input.bind("", on_focus_in) - self.topic_input.bind("", on_focus_out) - - # 日志输出框(带滚动条) - listbox_frame = ttk.Frame(right_frame) - listbox_frame.grid(row=1, column=0, sticky="nsew") - listbox_frame.grid_rowconfigure(0, weight=1) - listbox_frame.grid_columnconfigure(0, weight=1) - - self.listbox = tk.Listbox(listbox_frame) - self.listbox.grid(row=0, column=0, sticky="nsew") - - scrollbar = ttk.Scrollbar(listbox_frame, orient="vertical", command=self.listbox.yview) - scrollbar.grid(row=0, column=1, sticky="ns") - - self.listbox.config(yscrollcommand=scrollbar.set) - - def _log(self, message: str): - """将消息添加到日志列表框并滚动到底部。""" - self.listbox.insert(tk.END, message) - self.listbox.see(tk.END) - - def _initialize_config(self): - """应用启动时检查配置完整性,如果配置不完整则强制用户设置。""" - is_ok, _ = self.config_manager.check_settings() - if not is_ok: - self._show_config_dialog_until_valid() - - def _show_config_dialog_until_valid(self): - """循环显示设置对话框,直到所有必需配置项都已填写。""" - while True: - self._open_settings_dialog() - is_ok, missing_item = self.config_manager.check_settings() - if is_ok: - return - # 用户可以选择取消配置,此时直接退出程序 - result = messagebox.askretrycancel("配置未完成", - f"请配置: {missing_item}\n\n点击'重试'继续配置,点击'取消'退出程序") - if result is None or result == "cancel" or not result: - # 用户点击取消或关闭对话框,直接退出程序 - self.root.quit() - return - # 用户点击重试,继续循环 - - def _open_settings_dialog(self): - """打开设置对话框,并根据返回结果更新和保存配置。""" - dialog = SettingsDialog(self.root, self.config_manager) - if dialog.result: - # 清理旧的、统一的AI配置和OCR配置,以兼容新版分离的配置 - self.config_manager.config.pop("AiUrl", None) - self.config_manager.config.pop("AiApiKey", None) - self.config_manager.config.pop("OcrApiKey", None) - self.config_manager.config.pop("OcrSecretKey", None) - for key, value in dialog.result.items(): - if key == "LlmPromptTemplate": - if value is None: - # 如果值为None,表示用户希望恢复默认模板,因此从配置中移除该键 - self.config_manager.config.pop(key, None) - else: - self.config_manager.set(key, value) - else: - self.config_manager.set(key, value) - self.config_manager.save() - - def _open_about_dialog(self): - """创建并显示“关于”对话框。""" - AboutDialog(self.root, self.config_manager) - - def _open_file_dialog(self): - """打开文件选择对话框,让用户选择一个或多个图片文件。""" - paths = filedialog.askopenfilenames(title="选择作文图片", filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp")]) - if paths: - self.file_paths = paths - self.is_file_selected = True - self._log(f"已选择 {len(paths)} 个文件") - else: - self._log("取消选择") - - def _start_processing(self): - """启动作文批改流程。""" - if not self.is_file_selected: - messagebox.showerror("操作错误", "请先选择文件") - return - - topic = self.topic_input.get("1.0", tk.END).strip() - if not topic or topic == "(在此输入作文题目)": - messagebox.showerror("操作错误", "请输入作文题目") - return - - # 重置进度条和计数器 - self.progress_bar['value'] = 0 - self.progress_bar['maximum'] = len(self.file_paths) - self.processed_count = 0 - - # 在后台线程中启动并发处理 - thread = threading.Thread(target=self._concurrent_worker_manager, args=(self.file_paths, topic), daemon=True) - thread.start() - - def _process_ui_queue(self): - """定期检查UI更新队列,并执行相应的UI操作(如记日志、更新进度条)。""" - try: - while True: - task, data = self.ui_queue.get_nowait() - if task == "log": - self._log(data) - elif task == "progress": - with self.lock: - self.processed_count += 1 - self.progress_bar['value'] = self.processed_count - elif task == "finish": - messagebox.showinfo("完成", "所有文件处理完成") - self.progress_bar['value'] = 0 - self.is_file_selected = False - elif task == "update_found": - self._show_update_dialog(data) - except queue.Empty: - pass - finally: - # 持续轮询队列 - self.root.after(100, self._process_ui_queue) - - def _concurrent_worker_manager(self, file_paths: List[str], topic: str): - """使用线程池并发处理所有选定的文件。""" - try: - # 强制将从配置中读取的值转换为整数,提供默认值以防万一 - max_workers = int(self.config_manager.get("MaxWorkers", 4)) - except (ValueError, TypeError): - max_workers = 4 - - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - for file_path in file_paths: - executor.submit(self._process_single_file, file_path, topic) - - # 所有任务完成后,向UI队列发送完成信号 - self.ui_queue.put(("finish", None)) - - def _process_single_file(self, file_path: str, topic: str): - """处理单个图片文件的完整流程:调用API、保存报告、更新UI队列。""" - base_name = os.path.basename(file_path) - self.ui_queue.put(("log", f"开始处理: {base_name}")) - try: - final_report, vlm_usage, llm_usage, html_path = self.api_service.process_essay_image(file_path, topic) - - # 检查是否保存Markdown文件 - save_markdown = self.config_manager.get("SaveMarkdown", True) - report_filename_md = os.path.splitext(file_path)[0] + "_report.md" - - # 保存Markdown源文件(如果配置开启) - if save_markdown: - with open(report_filename_md, 'w', encoding='utf-8') as f: - f.write(final_report) - - vlm_in = vlm_usage.get("prompt_tokens", 0) - vlm_out = vlm_usage.get("completion_tokens", 0) - llm_in = llm_usage.get("prompt_tokens", 0) - 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})" - - # 记录所有生成的文件 - output_files = [] - if save_markdown: - output_files.append(os.path.basename(report_filename_md)) - - # 检查是否只勾选了HTML,如果是则删除Markdown文件 - render_html = self.config_manager.get("RenderMarkdown", True) - 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)}")) - - # 如果只勾选HTML,不勾选Markdown,则删除Markdown文件 - if not save_markdown and render_html and os.path.exists(report_filename_md): - os.remove(report_filename_md) - self.ui_queue.put(("log", f"已删除Markdown文件(仅保留HTML)")) - - self.ui_queue.put(("log", f"完成批改: {base_name} -> {', '.join(output_files)}")) - self.ui_queue.put(("log", usage_log)) - - # 加锁以保证线程安全地更新和保存配置 - with self.lock: - self.config_manager.update_token_usage(vlm_in, vlm_out, llm_in, llm_out) - self.config_manager.save() - - except Exception as e: - self.ui_queue.put(("log", f"文件: {base_name} 失败: {e}")) - - # 无论成功或失败,都更新进度 - self.ui_queue.put(("progress", 1)) - - def _check_for_updates_on_startup(self): - """如果启用了自动更新检查,则在后台线程中启动检查。""" - if self.config_manager.get("AutoUpdateCheck", True): - thread = threading.Thread(target=self._perform_update_check, daemon=True) - thread.start() - - def _perform_update_check(self): - """执行实际的更新检查并向UI队列发送结果。""" - logging.info("正在检查更新...") - new_version = check_for_updates(CURRENT_VERSION) - if new_version: - logging.info(f"发现新版本: {new_version}") - self.ui_queue.put(("update_found", new_version)) - else: - logging.info("当前已是最新版本。") - - def _show_update_dialog(self, new_version: str): - """显示更新可用对话框,并根据用户选择打开下载页面。""" - title = "发现新版本" - message = f"发现新版本 {new_version}!\n您当前的版本是 {CURRENT_VERSION}。\n\n是否前往下载页面?" - if messagebox.askyesno(title, message): - try: - webbrowser.open("https://github.com/Eric-Terminal/Pro_llm_correct/releases/latest") - except Exception as e: - messagebox.showerror("打开失败", f"无法打开浏览器:{e}") diff --git a/main.py b/main.py index bb213cb..cef8ac0 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,11 @@ +import logging import os import socket import sys import threading import time import webbrowser +from pathlib import Path from config_manager import ConfigManager from web_app import create_app @@ -50,14 +52,43 @@ def open_browser_later(url: str, delay: float = 1.0) -> None: threading.Thread(target=_opener, daemon=True).start() +def configure_logging(base_path: Path) -> None: + """Configure application logging.""" + handlers = [] + formatter = logging.Formatter( + "[%(asctime)s] %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + handlers.append(console_handler) + + root = logging.getLogger() + root.handlers.clear() + root.setLevel(logging.INFO) + for handler in handlers: + root.addHandler(handler) + + werkzeug_logger = logging.getLogger("werkzeug") + werkzeug_logger.handlers.clear() + werkzeug_logger.setLevel(logging.WARNING) + werkzeug_logger.propagate = False + + if __name__ == "__main__": config_path = get_config_path() + runtime_dir = Path(config_path).resolve().parent + configure_logging(runtime_dir) + logger = logging.getLogger("essay_corrector.main") + logger.info("使用配置文件: %s", config_path) + config_manager = ConfigManager(config_path) app = create_app(config_manager) port = find_available_port() url = f"http://127.0.0.1:{port}/" - print(f"🚀 Web UI 已启动,访问: {url}") + logger.info("🚀 Web UI 已启动,访问: %s", url) open_browser_later(url) app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False) diff --git a/photo/1.png b/photo/1.png index d4dd57f..4e7b39f 100644 Binary files a/photo/1.png and b/photo/1.png differ diff --git a/photo/2.png b/photo/2.png index c2e7563..9843369 100644 Binary files a/photo/2.png and b/photo/2.png differ diff --git a/photo/3.png b/photo/3.png index 267dd1b..dba7b30 100644 Binary files a/photo/3.png and b/photo/3.png differ diff --git a/photo/4.png b/photo/4.png deleted file mode 100644 index 178d344..0000000 Binary files a/photo/4.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index b3fbe53..aba0d6a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ cryptography flask markdown packaging +openai>=1.0.0 diff --git a/version.py b/version.py index 8785b50..f0db6e5 100644 --- a/version.py +++ b/version.py @@ -1 +1 @@ -CURRENT_VERSION = "4.1.0" +CURRENT_VERSION = "4.2.0" diff --git a/web_app.py b/web_app.py index f2f2721..170dcc9 100644 --- a/web_app.py +++ b/web_app.py @@ -51,6 +51,9 @@ def create_app(config_manager: ConfigManager) -> Flask: app = Flask(__name__) app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 MB payload ceiling + app.logger.handlers.clear() + app.logger.propagate = True + logger = logging.getLogger("essay_corrector.web") api_service = ApiService(config_manager) config_lock = threading.Lock() @@ -156,7 +159,7 @@ def create_app(config_manager: ConfigManager) -> Flask: config_manager.save() except Exception as exc: # pylint: disable=broad-except - logging.exception("文件处理失败: %s", saved_path) + logger.exception("文件处理失败: %s", saved_path) error = str(exc) logs.append(f"处理失败: {error}") @@ -253,14 +256,17 @@ def create_app(config_manager: ConfigManager) -> Flask: "VlmApiKey": "", "HasVlmApiKey": has_vlm_key, "VlmModel": config_manager.get("VlmModel", ""), + "VlmTemperature": config_manager.get("VlmTemperature", 0.0), "LlmUrl": config_manager.get("LlmUrl", ""), "LlmApiKey": "", "HasLlmApiKey": has_llm_key, "LlmModel": config_manager.get("LlmModel", ""), + "LlmTemperature": config_manager.get("LlmTemperature", 0.0), "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), + "RequestTimeout": config_manager.get("RequestTimeout", 120), "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), @@ -289,6 +295,11 @@ def create_app(config_manager: ConfigManager) -> Flask: "LlmApiKey", ] int_fields = ["MaxWorkers", "MaxRetries", "RetryDelay"] + float_fields = { + "RequestTimeout": (1.0, None), + "VlmTemperature": (0.0, 2.0), + "LlmTemperature": (0.0, 2.0), + } bool_fields = ["SaveMarkdown", "RenderMarkdown", "AutoUpdateCheck"] updates: Dict[str, Any] = {} @@ -318,6 +329,19 @@ def create_app(config_manager: ConfigManager) -> Flask: except (TypeError, ValueError): return jsonify({"error": f"{key} 需要是整数"}), 400 + for key, bounds in float_fields.items(): + if key in payload and payload[key] not in (None, ""): + try: + value = float(payload[key]) + except (TypeError, ValueError): + return jsonify({"error": f"{key} 需要是数字"}), 400 + min_val, max_val = bounds + if min_val is not None and value < min_val: + return jsonify({"error": f"{key} 不能小于 {min_val}"}), 400 + if max_val is not None and value > max_val: + return jsonify({"error": f"{key} 不能大于 {max_val}"}), 400 + updates[key] = value + if "SensitivityFactor" in payload and payload["SensitivityFactor"] not in (None, ""): try: updates["SensitivityFactor"] = float(payload["SensitivityFactor"]) @@ -854,6 +878,9 @@ def create_app(config_manager: ConfigManager) -> Flask: + @@ -863,6 +890,9 @@ def create_app(config_manager: ConfigManager) -> Flask: + @@ -875,6 +905,9 @@ def create_app(config_manager: ConfigManager) -> Flask: + @@ -898,6 +931,17 @@ def create_app(config_manager: ConfigManager) -> Flask:

关于与更新

+
+

AI 作文批改助手

+

一款专注于英语作文批改的 Web 应用,整合视觉语言模型(VLM)与大语言模型(LLM),帮助教师与学生高效获得结构化反馈。

+
    +
  • 两阶段流水线:先识别手写文本与书写分,再生成全中文批改报告。
  • +
  • 任务分离:所有图片自动归档到独立 run id,方便回溯、分享与比对。
  • +
  • Prompt 可编辑:浏览器内直接替换评分模板,快速适配不同考试场景。
  • +
  • 安全可控:API Key 本地加密保存,Token 用量实时累计并在界面呈现。
  • +
+

作者:Eric_Terminal · 项目主页:GitHub

+
当前版本:{{ current_version }}
正在获取最新版本信息...
@@ -987,13 +1031,16 @@ def create_app(config_manager: ConfigManager) -> Flask: settingsForm.vlm_url.value = data.VlmUrl || ''; settingsForm.vlm_api_key.value = ''; settingsForm.vlm_model.value = data.VlmModel || ''; + settingsForm.vlm_temperature.value = data.VlmTemperature ?? 0; settingsForm.llm_url.value = data.LlmUrl || ''; settingsForm.llm_api_key.value = ''; settingsForm.llm_model.value = data.LlmModel || ''; + settingsForm.llm_temperature.value = data.LlmTemperature ?? 0; settingsForm.sensitivity_factor.value = data.SensitivityFactor || ''; settingsForm.max_workers.value = data.MaxWorkers || 4; settingsForm.max_retries.value = data.MaxRetries || 3; settingsForm.retry_delay.value = data.RetryDelay || 5; + settingsForm.request_timeout.value = data.RequestTimeout ?? 120; settingsForm.output_directory.value = data.OutputDirectory || '{{ default_output_dir }}'; settingsForm.save_markdown.checked = !!data.SaveMarkdown; settingsForm.render_markdown.checked = !!data.RenderMarkdown; @@ -1051,13 +1098,16 @@ def create_app(config_manager: ConfigManager) -> Flask: VlmUrl: settingsForm.vlm_url.value.trim(), VlmApiKey: settingsForm.vlm_api_key.value.trim(), VlmModel: settingsForm.vlm_model.value.trim(), + VlmTemperature: settingsForm.vlm_temperature.value, LlmUrl: settingsForm.llm_url.value.trim(), LlmApiKey: settingsForm.llm_api_key.value.trim(), LlmModel: settingsForm.llm_model.value.trim(), + LlmTemperature: settingsForm.llm_temperature.value, SensitivityFactor: settingsForm.sensitivity_factor.value.trim(), MaxWorkers: settingsForm.max_workers.value, MaxRetries: settingsForm.max_retries.value, RetryDelay: settingsForm.retry_delay.value, + RequestTimeout: settingsForm.request_timeout.value, OutputDirectory: settingsForm.output_directory.value.trim(), SaveMarkdown: settingsForm.save_markdown.checked, RenderMarkdown: settingsForm.render_markdown.checked,