修复已知bug和优化使用体验
5
.github/workflows/build.yml
vendored
@@ -42,11 +42,10 @@ jobs:
|
|||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
|
|
||||||
# 第4步:使用PyInstaller打包
|
# 第4步:使用PyInstaller打包
|
||||||
# --noconsole: 这是一个GUI应用,不显示控制台窗口
|
# --onefile: 打包成单个可执行文件
|
||||||
# --onefile: 打包成单个.exe文件
|
|
||||||
# --name: 指定生成的可执行文件名
|
# --name: 指定生成的可执行文件名
|
||||||
- name: Build with PyInstaller
|
- name: Build with PyInstaller
|
||||||
run: pyinstaller --noconsole --onefile --name "AI-Essay-Corrector" main.py
|
run: pyinstaller --onefile --name "AI-Essay-Corrector" main.py
|
||||||
|
|
||||||
# 第5步:将打包好的上传,以便在工作流页面下载 (适合测试)
|
# 第5步:将打包好的上传,以便在工作流页面下载 (适合测试)
|
||||||
- name: Upload artifact for testing
|
- name: Upload artifact for testing
|
||||||
|
|||||||
193
README.md
@@ -1,137 +1,106 @@
|
|||||||
# AI 作文批改助手 ✨
|
# AI 作文批改助手 ✨
|
||||||
|
|
||||||
(^▽^)ノ゙ 欢迎使用 AI 作文批改助手!这是一款专为教育工作者和学生设计的本地 Web 应用,能够像经验丰富的英语老师一样,自动批改手写英文作文图片,并生成专业详细的批改报告。
|
> 上传手写英文作文 → 自动识别文本 → 按高考标准打分 → 输出详尽反馈报告,全流程在本地浏览器完成。
|
||||||
|
|
||||||
## ✨ 核心特色功能
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
### 🤖 双AI引擎智能处理
|
## 全面重构亮点
|
||||||
- **视觉语言模型(VLM)**: 专业的手写文字识别(OCR)和书写质量评估,给出精准的卷面分数
|
- **现代化 Web UI**:基于 Flask 构建的单页应用,所有功能集中在浏览器端完成,配置与状态实时同步。
|
||||||
- **大语言模型(LLM)**: 深度内容分析,提供专业的语法纠错和写作建议
|
- **任务可追溯**:每次批改都会创建独立 run id,原图、Markdown、HTML 报告集中存放,便于回看和分享。
|
||||||
- **智能作文类型识别**: 自动识别应用文(15分制)和读后续写(25分制)两种高考作文类型
|
- **并发调度升级**:多线程线程池 + 独立任务状态机,批量图片互不阻塞,失败文件单独记录。
|
||||||
|
- **Prompt / 评分可插拔**:默认内置高考英语评分模板,可在 UI 动态替换;书写敏感度、模型温度均可调整。
|
||||||
|
- **安全与透明**:API Key 以设备指纹派生的密钥加密存储,支持一键清除;Token 用量实时累积并在 UI 展示。
|
||||||
|
- **自动更新提示**:后台检查 GitHub Releases 获取最新版本信息,可一键触发或关闭。
|
||||||
|
|
||||||
### ⚙️ 极致灵活配置
|
## 工作原理概览
|
||||||
- **服务独立配置**: VLM和LLM支持完全独立的API服务、密钥和模型配置
|
1. **图片接入**:兼容摄像头拍照、扫描件或批量上传,自动清洗文件名防止覆盖。
|
||||||
- **评分标准可调**: 书写质量"敏感度因子"自由调节,适应不同评分要求
|
2. **VLM 解析**:将图片转为 base64,通过兼容 OpenAI 的视觉模型 OCR + 计算书写分。
|
||||||
- **Prompt模板开放**: 核心批改指令完全可自定义,打造个性化批改风格
|
3. **LLM 批改**:根据作文题目、识别文本、书写分构建 Prompt,生成结构化中文反馈。
|
||||||
|
4. **报告生成**:按配置保存 Markdown,并可渲染为带主题的 HTML 文件输出。
|
||||||
|
5. **状态同步**:Web UI 实时播报进度、日志、Token 消耗。
|
||||||
|
|
||||||
### 🚀 高效并发处理
|
## 快速开始
|
||||||
- 多线程并发引擎,支持批量处理任意数量的作文图片
|
|
||||||
- 智能任务调度,大幅提升批改效率,节省宝贵时间
|
|
||||||
- 实时进度显示和详细日志输出,随时掌握处理状态
|
|
||||||
|
|
||||||
### 🔒 企业级安全保障
|
### 环境准备
|
||||||
- 军事级加密算法保护API密钥,防止敏感信息泄露
|
- Python 3.9 及以上
|
||||||
- 本地配置文件加密存储,确保账户安全无忧
|
- macOS / Windows / Linux 均可
|
||||||
- 透明的Token使用统计,方便成本控制
|
- 任意兼容 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
|
```bash
|
||||||
# 1. 克隆仓库
|
|
||||||
git clone https://github.com/Eric-Terminal/Pro_llm_correct.git
|
git clone https://github.com/Eric-Terminal/Pro_llm_correct.git
|
||||||
cd Pro_llm_correct
|
cd Pro_llm_correct
|
||||||
|
|
||||||
# 2. 创建虚拟环境(推荐)
|
|
||||||
python3 -m venv venv
|
python3 -m venv venv
|
||||||
source venv/bin/activate # Linux/Mac
|
source venv/bin/activate # Windows 使用 venv\Scripts\activate
|
||||||
# venv\Scripts\activate # Windows
|
|
||||||
|
|
||||||
# 3. 安装依赖
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
# 需要确保系统已安装 curl(macOS/Linux 默认自带,Windows 可安装 Git Bash 或使用 WSL)
|
```
|
||||||
|
|
||||||
# 4. 运行程序
|
### 启动 Web 版
|
||||||
|
```bash
|
||||||
python3 main.py
|
python3 main.py
|
||||||
```
|
```
|
||||||
|
- 应用将尝试从 4567–4667 中选择空闲端口,并自动打开默认浏览器。
|
||||||
|
- 首次运行会生成 `config.json`、`output_reports/` 等目录。
|
||||||
|
|
||||||
### 项目打包
|
## 使用流程
|
||||||
```bash
|
1. 在「批改作文」页填入题目或场景说明。
|
||||||
# 打包为独立可执行文件
|
2. 上传一张或多张作文图片并提交。
|
||||||
pyinstaller --noconsole --onefile main.py
|
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 加密存储配置
|
| 服务连接 | `VlmUrl` / `VlmModel` / `VlmApiKey`<br>`LlmUrl` / `LlmModel` / `LlmApiKey` | 与 OpenAI SDK 参数保持一致;密钥输入后即被本地加密,输入框留空表示沿用已有值。 |
|
||||||
- **并发**: threading + concurrent.futures.ThreadPoolExecutor
|
| 性能与容错 | `MaxWorkers` / `MaxRetries` / `RetryDelay` / `RequestTimeout` | 控制并发线程数、失败重试次数与间隔、单次请求超时(秒)。 |
|
||||||
- **输出**: Markdown/HTML 报告(内置样式渲染器)
|
| 评分策略 | `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服务地址
|
- 控制台会输出端口探测、API 请求摘要和异常信息。
|
||||||
- `VlmApiKey`: VLM服务密钥(自动加密)
|
- Web UI:结果卡片实时显示每个文件的日志及错误信息。
|
||||||
- `VlmModel`: VLM模型名称
|
- 常见问题排查:
|
||||||
- `LlmUrl`: LLM服务地址
|
- **配置缺失**:缺少必填项时,后端会在任务开始前返回具体提示。
|
||||||
- `LlmApiKey`: LLM服务密钥(自动加密)
|
- **网络或权限错误**:请确认模型名称、Key 是否正确,服务是否支持图像输入,并适当调整 `RequestTimeout` / `RetryDelay`。
|
||||||
- `LlmModel`: LLM模型名称
|
|
||||||
|
|
||||||
### 可选配置项
|
## 开发者指南
|
||||||
- `SensitivityFactor`: 书写评分敏感度因子(默认1.5)
|
- 核心依赖:Flask(Web 服务)、cryptography(配置加密)、openai SDK(兼容多家服务)、markdown(报告渲染)。
|
||||||
- `MaxWorkers`: 最大并发数(默认4)
|
- 调试技巧:
|
||||||
- `MaxRetries`: 最大重试次数(默认3)
|
```bash
|
||||||
- `RetryDelay`: 重试延迟秒数(默认5)
|
python3 web_app.py # 直接运行 Flask 应用
|
||||||
- `RequestTimeout`: 单次 API 请求超时时长(秒,默认120)
|
python3 main.py # 启动正式入口,包含日志与端口选择
|
||||||
- `SaveMarkdown`: 是否保存Markdown文件(默认True)
|
```
|
||||||
- `RenderMarkdown`: 是否渲染HTML报告(默认True)
|
- 如需打包为单文件可执行程序:
|
||||||
|
```bash
|
||||||
|
pyinstaller --noconsole --onefile main.py
|
||||||
|
```
|
||||||
|
生成的可执行文件位于 `dist/`。
|
||||||
|
|
||||||
---
|
## 贡献与许可
|
||||||
|
- 欢迎通过 Issue / Pull Request 分享想法与改进。
|
||||||
## 📄 开源协议
|
- 如果这个项目对你有帮助,别忘了点个 ⭐️。
|
||||||
|
- 本项目遵循 [MIT License](LICENSE)。
|
||||||
本项目采用 [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 精心开发。希望这个工具能够帮助更多的教育工作者和学生!(。・ω・。)ノ♡*
|
|
||||||
|
|||||||
120
api_services.py
@@ -1,16 +1,18 @@
|
|||||||
import base64
|
import base64
|
||||||
from typing import Dict, Any, Tuple, Optional
|
|
||||||
import urllib.request
|
|
||||||
import json
|
import json
|
||||||
from packaging import version
|
import logging
|
||||||
from config_manager import ConfigManager
|
|
||||||
from markdown_renderer import create_markdown_renderer
|
|
||||||
import os
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import logging
|
import urllib.request
|
||||||
import subprocess
|
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()`方法进行后续的动态填充。
|
# 定义默认的LLM Prompt模板。使用`.format()`方法进行后续的动态填充。
|
||||||
DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC
|
DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC
|
||||||
@@ -100,13 +102,13 @@ class ApiService:
|
|||||||
self.config = config_manager
|
self.config = config_manager
|
||||||
self.ui_queue = ui_queue
|
self.ui_queue = ui_queue
|
||||||
self.markdown_renderer = create_markdown_renderer(config_manager)
|
self.markdown_renderer = create_markdown_renderer(config_manager)
|
||||||
|
self.logger = logging.getLogger("essay_corrector.api")
|
||||||
|
|
||||||
def _log(self, message: str):
|
def _log(self, message: str):
|
||||||
"""将日志消息放入UI队列。"""
|
"""将日志消息放入UI队列。"""
|
||||||
|
self.logger.info(message)
|
||||||
if self.ui_queue:
|
if self.ui_queue:
|
||||||
self.ui_queue.put(("log", message))
|
self.ui_queue.put(("log", message))
|
||||||
else:
|
|
||||||
logging.info(message)
|
|
||||||
|
|
||||||
def _encode_image_to_base64_url(self, image_path: str) -> str:
|
def _encode_image_to_base64_url(self, image_path: str) -> str:
|
||||||
"""将本地图片文件编码为Base64数据URL。"""
|
"""将本地图片文件编码为Base64数据URL。"""
|
||||||
@@ -122,7 +124,7 @@ class ApiService:
|
|||||||
def _chat_endpoint(self, base_url: Optional[str]) -> str:
|
def _chat_endpoint(self, base_url: Optional[str]) -> str:
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise ValueError("服务地址未配置,请先在设置中填写 API 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]:
|
def _usage_from_response(self, response_json: Dict[str, Any]) -> Dict[str, int]:
|
||||||
usage = response_json.get("usage") or {}
|
usage = response_json.get("usage") or {}
|
||||||
@@ -131,51 +133,15 @@ class ApiService:
|
|||||||
"completion_tokens": int(usage.get("completion_tokens", 0) or 0),
|
"completion_tokens": int(usage.get("completion_tokens", 0) or 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _post_json_with_curl(self, endpoint: str, api_key: Optional[str], payload: Dict[str, Any], timeout: float) -> Dict[str, Any]:
|
def _create_openai_client(self, base_url: str, api_key: Optional[str], timeout: float) -> OpenAI:
|
||||||
data_str = json.dumps(payload, ensure_ascii=False)
|
client_kwargs: Dict[str, Any] = {
|
||||||
command = [
|
"base_url": base_url.rstrip("/"),
|
||||||
"curl",
|
"timeout": max(timeout, 1.0),
|
||||||
"-sS",
|
"max_retries": 0,
|
||||||
"-X",
|
}
|
||||||
"POST",
|
|
||||||
endpoint,
|
|
||||||
"-H",
|
|
||||||
"Content-Type: application/json",
|
|
||||||
"--data-binary",
|
|
||||||
"@-",
|
|
||||||
"-w",
|
|
||||||
"\nHTTP_STATUS:%{http_code}",
|
|
||||||
"--max-time",
|
|
||||||
str(max(timeout, 1.0)),
|
|
||||||
]
|
|
||||||
if api_key:
|
if api_key:
|
||||||
command.extend(["-H", f"Authorization: Bearer {api_key}"])
|
client_kwargs["api_key"] = api_key
|
||||||
|
return OpenAI(**client_kwargs)
|
||||||
completed = subprocess.run(command, capture_output=True, text=True, input=data_str)
|
|
||||||
stdout = completed.stdout or ""
|
|
||||||
stderr = completed.stderr.strip()
|
|
||||||
|
|
||||||
status_code = None
|
|
||||||
if "HTTP_STATUS:" in stdout:
|
|
||||||
stdout, status_part = stdout.rsplit("HTTP_STATUS:", 1)
|
|
||||||
try:
|
|
||||||
status_code = int(status_part.strip())
|
|
||||||
except ValueError:
|
|
||||||
status_code = None
|
|
||||||
|
|
||||||
response_text = stdout.strip()
|
|
||||||
|
|
||||||
if completed.returncode != 0 or (status_code and status_code >= 400):
|
|
||||||
error_message = response_text or stderr or f"curl exited with code {completed.returncode}"
|
|
||||||
raise RuntimeError(f"调用失败 (HTTP {status_code}): {error_message}")
|
|
||||||
|
|
||||||
if not response_text:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
return json.loads(response_text)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ValueError(f"无法解析 API 返回的 JSON: {response_text[:500]}") from exc
|
|
||||||
|
|
||||||
def _invoke_chat_completion(
|
def _invoke_chat_completion(
|
||||||
self,
|
self,
|
||||||
@@ -187,16 +153,30 @@ class ApiService:
|
|||||||
retry_delay: int,
|
retry_delay: int,
|
||||||
timeout: float,
|
timeout: float,
|
||||||
) -> Dict[str, Any]:
|
) -> 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
|
last_error: Optional[Exception] = None
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
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
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
last_error = exc
|
last_error = exc
|
||||||
|
error_message = str(exc)
|
||||||
if attempt == max_retries - 1:
|
if attempt == max_retries - 1:
|
||||||
raise
|
raise last_error
|
||||||
self._log(f"{label} 调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries}),错误: {exc}")
|
self._log(f"{label} 调用失败,{retry_delay}秒后重试... (尝试 {attempt + 1}/{max_retries}),错误: {error_message}")
|
||||||
time.sleep(retry_delay)
|
time.sleep(retry_delay)
|
||||||
if last_error:
|
if last_error:
|
||||||
raise last_error
|
raise last_error
|
||||||
@@ -222,6 +202,12 @@ class ApiService:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
request_timeout = 120.0
|
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)
|
base64_image_url = self._encode_image_to_base64_url(file_path)
|
||||||
|
|
||||||
vlm_prompt = """# ROLE
|
vlm_prompt = """# ROLE
|
||||||
@@ -250,12 +236,12 @@ Strictly adhere to the following format. Do not output anything else.
|
|||||||
</text>"""
|
</text>"""
|
||||||
vlm_messages = [{"role": "user", "content": [{"type": "text", "text": vlm_prompt}, {"type": "image_url", "image_url": {"url": base64_image_url}}]}]
|
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 = {
|
vlm_payload = {
|
||||||
"model": vlm_model,
|
"model": vlm_model,
|
||||||
"messages": vlm_messages,
|
"messages": vlm_messages,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"temperature": 1,
|
"temperature": vlm_temperature,
|
||||||
}
|
}
|
||||||
vlm_response_json = self._invoke_chat_completion(
|
vlm_response_json = self._invoke_chat_completion(
|
||||||
"VLM",
|
"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_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 = {
|
llm_payload = {
|
||||||
"model": llm_model,
|
"model": llm_model,
|
||||||
"messages": llm_messages,
|
"messages": llm_messages,
|
||||||
"temperature": 1,
|
"temperature": llm_temperature,
|
||||||
"max_tokens": 16384,
|
"max_tokens": 4096,
|
||||||
}
|
}
|
||||||
|
|
||||||
final_report: str
|
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 os
|
||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from config_manager import ConfigManager
|
from config_manager import ConfigManager
|
||||||
from web_app import create_app
|
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()
|
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__":
|
if __name__ == "__main__":
|
||||||
config_path = get_config_path()
|
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)
|
config_manager = ConfigManager(config_path)
|
||||||
app = create_app(config_manager)
|
app = create_app(config_manager)
|
||||||
|
|
||||||
port = find_available_port()
|
port = find_available_port()
|
||||||
url = f"http://127.0.0.1:{port}/"
|
url = f"http://127.0.0.1:{port}/"
|
||||||
print(f"🚀 Web UI 已启动,访问: {url}")
|
logger.info("🚀 Web UI 已启动,访问: %s", url)
|
||||||
open_browser_later(url)
|
open_browser_later(url)
|
||||||
|
|
||||||
app.run(host="127.0.0.1", port=port, debug=False, use_reloader=False)
|
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
|
flask
|
||||||
markdown
|
markdown
|
||||||
packaging
|
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 = Flask(__name__)
|
||||||
app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100 MB payload ceiling
|
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)
|
api_service = ApiService(config_manager)
|
||||||
config_lock = threading.Lock()
|
config_lock = threading.Lock()
|
||||||
@@ -156,7 +159,7 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
config_manager.save()
|
config_manager.save()
|
||||||
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
logging.exception("文件处理失败: %s", saved_path)
|
logger.exception("文件处理失败: %s", saved_path)
|
||||||
error = str(exc)
|
error = str(exc)
|
||||||
logs.append(f"处理失败: {error}")
|
logs.append(f"处理失败: {error}")
|
||||||
|
|
||||||
@@ -253,14 +256,17 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
"VlmApiKey": "",
|
"VlmApiKey": "",
|
||||||
"HasVlmApiKey": has_vlm_key,
|
"HasVlmApiKey": has_vlm_key,
|
||||||
"VlmModel": config_manager.get("VlmModel", ""),
|
"VlmModel": config_manager.get("VlmModel", ""),
|
||||||
|
"VlmTemperature": config_manager.get("VlmTemperature", 0.0),
|
||||||
"LlmUrl": config_manager.get("LlmUrl", ""),
|
"LlmUrl": config_manager.get("LlmUrl", ""),
|
||||||
"LlmApiKey": "",
|
"LlmApiKey": "",
|
||||||
"HasLlmApiKey": has_llm_key,
|
"HasLlmApiKey": has_llm_key,
|
||||||
"LlmModel": config_manager.get("LlmModel", ""),
|
"LlmModel": config_manager.get("LlmModel", ""),
|
||||||
|
"LlmTemperature": config_manager.get("LlmTemperature", 0.0),
|
||||||
"SensitivityFactor": config_manager.get("SensitivityFactor", "1.0"),
|
"SensitivityFactor": config_manager.get("SensitivityFactor", "1.0"),
|
||||||
"MaxWorkers": config_manager.get("MaxWorkers", 4),
|
"MaxWorkers": config_manager.get("MaxWorkers", 4),
|
||||||
"MaxRetries": config_manager.get("MaxRetries", 3),
|
"MaxRetries": config_manager.get("MaxRetries", 3),
|
||||||
"RetryDelay": config_manager.get("RetryDelay", 5),
|
"RetryDelay": config_manager.get("RetryDelay", 5),
|
||||||
|
"RequestTimeout": config_manager.get("RequestTimeout", 120),
|
||||||
"SaveMarkdown": _as_bool(config_manager.get("SaveMarkdown", True), True),
|
"SaveMarkdown": _as_bool(config_manager.get("SaveMarkdown", True), True),
|
||||||
"RenderMarkdown": _as_bool(config_manager.get("RenderMarkdown", True), True),
|
"RenderMarkdown": _as_bool(config_manager.get("RenderMarkdown", True), True),
|
||||||
"AutoUpdateCheck": _as_bool(config_manager.get("AutoUpdateCheck", True), True),
|
"AutoUpdateCheck": _as_bool(config_manager.get("AutoUpdateCheck", True), True),
|
||||||
@@ -289,6 +295,11 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
"LlmApiKey",
|
"LlmApiKey",
|
||||||
]
|
]
|
||||||
int_fields = ["MaxWorkers", "MaxRetries", "RetryDelay"]
|
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"]
|
bool_fields = ["SaveMarkdown", "RenderMarkdown", "AutoUpdateCheck"]
|
||||||
|
|
||||||
updates: Dict[str, Any] = {}
|
updates: Dict[str, Any] = {}
|
||||||
@@ -318,6 +329,19 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return jsonify({"error": f"{key} 需要是整数"}), 400
|
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, ""):
|
if "SensitivityFactor" in payload and payload["SensitivityFactor"] not in (None, ""):
|
||||||
try:
|
try:
|
||||||
updates["SensitivityFactor"] = float(payload["SensitivityFactor"])
|
updates["SensitivityFactor"] = float(payload["SensitivityFactor"])
|
||||||
@@ -854,6 +878,9 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
<label>VLM 模型
|
<label>VLM 模型
|
||||||
<input type="text" name="vlm_model" autocomplete="off" />
|
<input type="text" name="vlm_model" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>VLM 温度 (0-2)
|
||||||
|
<input type="number" name="vlm_temperature" min="0" max="2" step="0.1" />
|
||||||
|
</label>
|
||||||
<label>LLM URL
|
<label>LLM URL
|
||||||
<input type="text" name="llm_url" autocomplete="off" />
|
<input type="text" name="llm_url" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
@@ -863,6 +890,9 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
<label>LLM 模型
|
<label>LLM 模型
|
||||||
<input type="text" name="llm_model" autocomplete="off" />
|
<input type="text" name="llm_model" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>LLM 温度 (0-2)
|
||||||
|
<input type="number" name="llm_temperature" min="0" max="2" step="0.1" />
|
||||||
|
</label>
|
||||||
<label>手写敏感度 (建议 1.0)
|
<label>手写敏感度 (建议 1.0)
|
||||||
<input type="text" name="sensitivity_factor" autocomplete="off" />
|
<input type="text" name="sensitivity_factor" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
@@ -875,6 +905,9 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
<label>重试延迟 (秒)
|
<label>重试延迟 (秒)
|
||||||
<input type="number" name="retry_delay" min="1" />
|
<input type="number" name="retry_delay" min="1" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>请求超时时间 (秒)
|
||||||
|
<input type="number" name="request_timeout" min="1" step="1" />
|
||||||
|
</label>
|
||||||
<label>输出目录
|
<label>输出目录
|
||||||
<input type="text" name="output_directory" autocomplete="off" />
|
<input type="text" name="output_directory" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
@@ -898,6 +931,17 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
<div class="view" data-view-section="about">
|
<div class="view" data-view-section="about">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>关于与更新</h2>
|
<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 class="about-card">
|
||||||
<div><strong>当前版本:</strong><span id="about-current">{{ current_version }}</span></div>
|
<div><strong>当前版本:</strong><span id="about-current">{{ current_version }}</span></div>
|
||||||
<div id="about-latest">正在获取最新版本信息...</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_url.value = data.VlmUrl || '';
|
||||||
settingsForm.vlm_api_key.value = '';
|
settingsForm.vlm_api_key.value = '';
|
||||||
settingsForm.vlm_model.value = data.VlmModel || '';
|
settingsForm.vlm_model.value = data.VlmModel || '';
|
||||||
|
settingsForm.vlm_temperature.value = data.VlmTemperature ?? 0;
|
||||||
settingsForm.llm_url.value = data.LlmUrl || '';
|
settingsForm.llm_url.value = data.LlmUrl || '';
|
||||||
settingsForm.llm_api_key.value = '';
|
settingsForm.llm_api_key.value = '';
|
||||||
settingsForm.llm_model.value = data.LlmModel || '';
|
settingsForm.llm_model.value = data.LlmModel || '';
|
||||||
|
settingsForm.llm_temperature.value = data.LlmTemperature ?? 0;
|
||||||
settingsForm.sensitivity_factor.value = data.SensitivityFactor || '';
|
settingsForm.sensitivity_factor.value = data.SensitivityFactor || '';
|
||||||
settingsForm.max_workers.value = data.MaxWorkers || 4;
|
settingsForm.max_workers.value = data.MaxWorkers || 4;
|
||||||
settingsForm.max_retries.value = data.MaxRetries || 3;
|
settingsForm.max_retries.value = data.MaxRetries || 3;
|
||||||
settingsForm.retry_delay.value = data.RetryDelay || 5;
|
settingsForm.retry_delay.value = data.RetryDelay || 5;
|
||||||
|
settingsForm.request_timeout.value = data.RequestTimeout ?? 120;
|
||||||
settingsForm.output_directory.value = data.OutputDirectory || '{{ default_output_dir }}';
|
settingsForm.output_directory.value = data.OutputDirectory || '{{ default_output_dir }}';
|
||||||
settingsForm.save_markdown.checked = !!data.SaveMarkdown;
|
settingsForm.save_markdown.checked = !!data.SaveMarkdown;
|
||||||
settingsForm.render_markdown.checked = !!data.RenderMarkdown;
|
settingsForm.render_markdown.checked = !!data.RenderMarkdown;
|
||||||
@@ -1051,13 +1098,16 @@ def create_app(config_manager: ConfigManager) -> Flask:
|
|||||||
VlmUrl: settingsForm.vlm_url.value.trim(),
|
VlmUrl: settingsForm.vlm_url.value.trim(),
|
||||||
VlmApiKey: settingsForm.vlm_api_key.value.trim(),
|
VlmApiKey: settingsForm.vlm_api_key.value.trim(),
|
||||||
VlmModel: settingsForm.vlm_model.value.trim(),
|
VlmModel: settingsForm.vlm_model.value.trim(),
|
||||||
|
VlmTemperature: settingsForm.vlm_temperature.value,
|
||||||
LlmUrl: settingsForm.llm_url.value.trim(),
|
LlmUrl: settingsForm.llm_url.value.trim(),
|
||||||
LlmApiKey: settingsForm.llm_api_key.value.trim(),
|
LlmApiKey: settingsForm.llm_api_key.value.trim(),
|
||||||
LlmModel: settingsForm.llm_model.value.trim(),
|
LlmModel: settingsForm.llm_model.value.trim(),
|
||||||
|
LlmTemperature: settingsForm.llm_temperature.value,
|
||||||
SensitivityFactor: settingsForm.sensitivity_factor.value.trim(),
|
SensitivityFactor: settingsForm.sensitivity_factor.value.trim(),
|
||||||
MaxWorkers: settingsForm.max_workers.value,
|
MaxWorkers: settingsForm.max_workers.value,
|
||||||
MaxRetries: settingsForm.max_retries.value,
|
MaxRetries: settingsForm.max_retries.value,
|
||||||
RetryDelay: settingsForm.retry_delay.value,
|
RetryDelay: settingsForm.retry_delay.value,
|
||||||
|
RequestTimeout: settingsForm.request_timeout.value,
|
||||||
OutputDirectory: settingsForm.output_directory.value.trim(),
|
OutputDirectory: settingsForm.output_directory.value.trim(),
|
||||||
SaveMarkdown: settingsForm.save_markdown.checked,
|
SaveMarkdown: settingsForm.save_markdown.checked,
|
||||||
RenderMarkdown: settingsForm.render_markdown.checked,
|
RenderMarkdown: settingsForm.render_markdown.checked,
|
||||||
|
|||||||