修复已知bug和优化使用体验
5
.github/workflows/build.yml
vendored
@@ -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
@@ -1,137 +1,106 @@
|
||||
# AI 作文批改助手 ✨
|
||||
|
||||
(^▽^)ノ゙ 欢迎使用 AI 作文批改助手!这是一款专为教育工作者和学生设计的本地 Web 应用,能够像经验丰富的英语老师一样,自动批改手写英文作文图片,并生成专业详细的批改报告。
|
||||
> 上传手写英文作文 → 自动识别文本 → 按高考标准打分 → 输出详尽反馈报告,全流程在本地浏览器完成。
|
||||
|
||||
## ✨ 核心特色功能
|
||||

|
||||

|
||||

|
||||
|
||||
### 🤖 双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>/` 中,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` | 约束模型随机性,范围 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/<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)
|
||||
## 开发者指南
|
||||
- 核心依赖: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)。
|
||||
|
||||
124
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.
|
||||
</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
@@ -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
@@ -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)
|
||||
|
||||
BIN
photo/1.png
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 1.1 MiB |
BIN
photo/2.png
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 1.4 MiB |
BIN
photo/3.png
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 1.5 MiB |
BIN
photo/4.png
|
Before Width: | Height: | Size: 190 KiB |
@@ -2,3 +2,4 @@ cryptography
|
||||
flask
|
||||
markdown
|
||||
packaging
|
||||
openai>=1.0.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
CURRENT_VERSION = "4.1.0"
|
||||
CURRENT_VERSION = "4.2.0"
|
||||
|
||||
52
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:
|
||||
<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,
|
||||
|
||||