修复已知bug和优化使用体验

This commit is contained in:
Eric-Terminal
2025-10-25 01:38:17 +08:00
parent 0535795ccd
commit e8045dcf5f
12 changed files with 227 additions and 701 deletions

View File

@@ -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

193
README.md
View File

@@ -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
# 需要确保系统已安装 curlmacOS/Linux 默认自带Windows 可安装 Git Bash 或使用 WSL
```
# 4. 运行程序
### 启动 Web 版
```bash
python3 main.py
```
- 应用将尝试从 45674667 中选择空闲端口,并自动打开默认浏览器。
- 首次运行会生成 `config.json``output_reports/` 等目录。
### 项目打包
```bash
# 打包为独立可执行文件
pyinstaller --noconsole --onefile main.py
## 使用流程
1. 在「批改作文」页填入题目或场景说明。
2. 上传一张或多张作文图片并提交。
3. 查看实时处理状态:成功会显示 Markdown / HTML 下载链接,失败会给出详细错误。
4. 结果保存在 `output_reports/<run_id>/`run id 由时间戳生成,保证唯一。
# 打包好的程序在 dist/ 目录
```
output_reports/
└── <run_id>/ # 例如 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`<br>`LlmUrl` / `LlmModel` / `LlmApiKey` | 与 OpenAI SDK 参数保持一致;密钥输入后即被本地加密,输入框留空表示沿用已有值。 |
| 性能与容错 | `MaxWorkers` / `MaxRetries` / `RetryDelay` / `RequestTimeout` | 控制并发线程数、失败重试次数与间隔、单次请求超时(秒)。 |
| 评分策略 | `SensitivityFactor` | 对 VLM 输出的书写分进行幂次强化/弱化(默认 1.0)。 |
| | `VlmTemperature` / `LlmTemperature` | 约束模型随机性,范围 02。 |
| 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/<run_id>`轮询任务状态、日志、Token 用量以及生成的文件路径。
- `GET /outputs/<path>`:访问生成的原图或批改报告。
### 必需配置项
- `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
## 开发者指南
- 核心依赖FlaskWeb 服务、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)。

View File

@@ -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,16 +153,30 @@ 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
error_message = str(exc)
if attempt == max_retries - 1:
raise
self._log(f"{label} 调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries}),错误: {exc}")
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
@@ -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.
</text>"""
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

516
app_ui.py
View File

@@ -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("<FocusIn>", on_focus_in)
self.topic_input.bind("<FocusOut>", 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}")

33
main.py
View File

@@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

View File

@@ -2,3 +2,4 @@ cryptography
flask
markdown
packaging
openai>=1.0.0

View File

@@ -1 +1 @@
CURRENT_VERSION = "4.1.0"
CURRENT_VERSION = "4.2.0"

View File

@@ -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:
<label>VLM 模型
<input type="text" name="vlm_model" autocomplete="off" />
</label>
<label>VLM 温度 (0-2)
<input type="number" name="vlm_temperature" min="0" max="2" step="0.1" />
</label>
<label>LLM URL
<input type="text" name="llm_url" autocomplete="off" />
</label>
@@ -863,6 +890,9 @@ def create_app(config_manager: ConfigManager) -> Flask:
<label>LLM 模型
<input type="text" name="llm_model" autocomplete="off" />
</label>
<label>LLM 温度 (0-2)
<input type="number" name="llm_temperature" min="0" max="2" step="0.1" />
</label>
<label>手写敏感度 (建议 1.0)
<input type="text" name="sensitivity_factor" autocomplete="off" />
</label>
@@ -875,6 +905,9 @@ def create_app(config_manager: ConfigManager) -> Flask:
<label>重试延迟 (秒)
<input type="number" name="retry_delay" min="1" />
</label>
<label>请求超时时间 (秒)
<input type="number" name="request_timeout" min="1" step="1" />
</label>
<label>输出目录
<input type="text" name="output_directory" autocomplete="off" />
</label>
@@ -898,6 +931,17 @@ def create_app(config_manager: ConfigManager) -> Flask:
<div class="view" data-view-section="about">
<div class="section">
<h2>关于与更新</h2>
<div class="about-card">
<h3>AI 作文批改助手</h3>
<p>一款专注于英语作文批改的 Web 应用整合视觉语言模型VLM与大语言模型LLM帮助教师与学生高效获得结构化反馈。</p>
<ul>
<li>两阶段流水线:先识别手写文本与书写分,再生成全中文批改报告。</li>
<li>任务分离:所有图片自动归档到独立 run id方便回溯、分享与比对。</li>
<li>Prompt 可编辑:浏览器内直接替换评分模板,快速适配不同考试场景。</li>
<li>安全可控API Key 本地加密保存Token 用量实时累计并在界面呈现。</li>
</ul>
<p class="muted">作者Eric_Terminal · 项目主页:<a href="https://github.com/Eric-Terminal/Pro_llm_correct" target="_blank" rel="noopener">GitHub</a></p>
</div>
<div class="about-card">
<div><strong>当前版本:</strong><span id="about-current">{{ current_version }}</span></div>
<div id="about-latest">正在获取最新版本信息...</div>
@@ -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,