Initial commit

This commit is contained in:
Eric-Terminal
2025-08-04 01:29:50 +08:00
parent 283e1ded88
commit 4a021d5eca
7 changed files with 783 additions and 0 deletions

58
.github/workflows/build-windows.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
# 工作流的名称
name: Build Windows Executable
# 触发条件:
# 1. 当代码被推送到 main 分支时
# 2. 当你创建一个新的 "Tag" (用于发布新版本)
# 3. 允许你在GitHub页面手动触发
on:
push:
branches: [ "main" ]
release:
types: [ created ]
workflow_dispatch:
jobs:
build:
# 指定运行环境为最新的Windows服务器
runs-on: windows-latest
steps:
# 第1步检出你的代码
- name: Checkout repository
uses: actions/checkout@v4
# 第2步设置Python环境
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11' # 你可以根据需要更改Python版本
# 第3步安装依赖包
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pyinstaller
# 第4步使用PyInstaller打包
# --noconsole: 这是一个GUI应用不显示控制台窗口
# --onefile: 打包成单个.exe文件
# --add-data: 将 config.json 文件包含到exe中
# --name: 指定生成的可执行文件名
- name: Build with PyInstaller
run: pyinstaller --noconsole --onefile --name "AI-Essay-Corrector" --add-data "config.json;." main.py
# 第5步将打包好的.exe上传以便在工作流页面下载 (适合测试)
- name: Upload artifact for testing
uses: actions/upload-artifact@v4
with:
name: AI-Essay-Corrector-Windows-exe
path: dist/AI-Essay-Corrector.exe
# 第6步 (专业级开源发布): 当你创建Tag时自动创建Release并附上.exe
- name: Create Release and Upload Asset
if: startsWith(github.ref, 'refs/tags/') # 仅在创建Tag时运行此步骤
uses: softprops/action-gh-release@v2
with:
files: dist/AI-Essay-Corrector.exe

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Python virtual environment
venv/
.venv/
env/
ENV/
# Python cache files
__pycache__/
*.pyc
*.pyo
*.pyd
# PyInstaller build artifacts
dist/
build/
*.spec
# IDE-specific files
.vscode/
.idea/
# OS-specific files
.DS_Store
Thumbs.db
# Local configuration file
# This file is generated by the application and contains user-specific secrets.
# It should not be committed to version control.
config.json

163
api_services.py Normal file
View File

@@ -0,0 +1,163 @@
import base64
from typing import Dict, Any
from config_manager import ConfigManager
import os
import mimetypes
import re
from openai import OpenAI
# 定义默认的LLM Prompt模板。使用`.format()`方法进行后续的动态填充。
DEFAULT_LLM_PROMPT_TEMPLATE = """# ESSAY TOPIC
{topic}
# INSTRUCTIONS FOR AI (Process in English)
## 1. ROLE & GOAL
You are a highly experienced senior high school English teacher. Your task is to provide a detailed, constructive, and encouraging evaluation of a student's essay.
## 2. INPUT DATA
You will receive a quantitative `<wscore>` and the full `<text>` of the essay. The essay is based on the topic provided above.
## 3. GRADING LOGIC (Total Score: 15 points)
- **Content & Language (12 points):** Evaluate this based on grammar, vocabulary, sentence structure, etc., in relation to the essay topic.
- **Handwriting & Presentation (3 points):** Calculate the score by first getting a raw score (`Raw Score = wscore * 3`), and then rounding the `Raw Score` **up** to the nearest half-point (0.5).
- *Rounding Logic Example:* A raw score of 2.49 becomes 2.5. A raw score of 2.51 becomes 3.0. A raw score of 2.50 remains 2.5. A score of 0 remains 0.
## 4. FINAL TASK
Analyze the text, calculate scores, and present your feedback in **Simplified Chinese** using the precise Markdown format specified below.
#--- End of English Instructions ---
# OUTPUT SPECIFICATION (MUST BE IN SIMPLIFIED CHINESE)
# 请严格使用以下Markdown格式并用简体中文填充所有内容优点可以两个到三个问题建议要把全部问题找出来并且解析都要遵循类似格式。
###【作文内容】
* **作文文本:** [在此处粘贴完整的作文文本。]
### 【综合评价】
(在此处用一两句鼓励性的话,对本次作文进行总体概述。)
### 【亮点与优点】
* **(优点1):** [具体描述作文内容或语言上的一个亮点。]
* **(优点2):** [具体描述另一个优点。]
* **(优点3):以此类推不限制数量但建议控制在3个以内。
### 【问题与修改建议】
* **[问题1 - 语法/拼写错误]:**
* **原文句子:** "[引用出现错误的原文句子]"
* **问题分析:** [简要说明错误类型。]
* **修改建议:** "[写出修改后的正确句子]"
* **[问题2 - 表达/逻辑]:**
* **原文句子:** "[引用表达欠佳的原文句子]"
* **问题分析:** [说明问题所在。]
* **修改建议:** "[提供一个更好的表达方式。]
* **[问题3 - 以此类推不限制数量但建议控制在3个以内。]:**
* **原文句子:** "[说明问题所在。]"
* **问题分析:** [说明问题所在。]
### 【分数评估】
* **内容与语言分 (Content & Language):** [分数] / 12
* **卷面与书写分 (Handwriting & Presentation):** [分数] / 3
* ---
* **最终得分 (Final Score):** **[总分] / 15**
# INPUT DATA FOR THIS TASK
<wscore>{wscore}</wscore>
<text>
{essay_text}
</text>
"""
class ApiService:
"""封装了与外部APIVLM和LLM交互的所有逻辑。"""
def __init__(self, config_manager: ConfigManager):
self.config = config_manager
def _encode_image_to_base64_url(self, image_path: str) -> str:
"""将本地图片文件编码为Base64数据URL。"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"Image file not found at: {image_path}")
mime_type, _ = mimetypes.guess_type(image_path)
if not mime_type or not mime_type.startswith('image'):
raise ValueError(f"File is not a recognizable image type: {mime_type}")
with open(image_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
return f"data:{mime_type};base64,{encoded_string}"
def process_essay_image(self, file_path: str, topic: str) -> str:
"""
执行完整的两步式作文批改流程:
1. VLM调用分析作文图片提取手写文本和书写质量分数。
2. LLM调用基于VLM的输出和作文题目生成详细的批改报告。
"""
# --- 步骤 1: 调用VLM进行图像分析 ---
vlm_client = OpenAI(
api_key=self.config.get("VlmApiKey"),
base_url=self.config.get("VlmUrl")
)
base64_image_url = self._encode_image_to_base64_url(file_path)
vlm_prompt = """# ROLE
You are a high-precision OCR (Optical Character Recognition) and handwriting analysis engine. Your only job is to analyze the provided image and output structured data. Do not add any conversational text or explanations.
# TASK
Analyze the handwriting quality and extract all text from the image.
## 1. Handwriting Quality Analysis:
- Critically evaluate the handwriting on a continuous scale from 0.0 to 1.0.
- The scoring must be stringent. A score of 1.0 is reserved for flawless, machine-printed-like perfection, which is virtually unattainable.
- **Score Tiers:**
- **0.90-0.99:** Near-perfect, professional calligrapher level. Extremely rare.
- **0.80-0.89:** Excellent, clear, consistent, and aesthetically pleasing. The best a top student can achieve.
- **0.70-0.79:** Good and very legible, but with minor inconsistencies in size or spacing.
- **0.60-0.69:** Clear and legible, but with noticeable inconsistencies.
- **Below 0.60:** Legibility is impacted.
- Output this score enclosed in a single <wscore> XML tag.
## 2. Full Text Extraction:
- Perform a high-accuracy OCR on the entire image.
- Preserve the original line breaks and paragraph structure as best as possible.
- Output the full extracted text enclosed in a single <text> XML tag.
# OUTPUT FORMAT
Strictly adhere to the following format. Do not output anything else.
<wscore>[Your calculated score, e.g., 0.85]</wscore>
<text>
[The full extracted text from the image goes here.]
</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", "gemini-2.5-pro")
vlm_response = vlm_client.chat.completions.create(model=vlm_model, messages=vlm_messages, max_tokens=4096, temperature=1)
vlm_output = vlm_response.choices[0].message.content or ""
# 解析VLM返回的XML格式输出提取分数和文本
wscore_match = re.search(r'<wscore>(.*?)</wscore>', vlm_output, re.DOTALL)
text_match = re.search(r'<text>(.*?)</text>', vlm_output, re.DOTALL)
original_wscore = float(wscore_match.group(1).strip()) if wscore_match else 0.0
essay_text = text_match.group(1).strip() if text_match else "错误:无法从图片中提取文本。"
try:
sensitivity_factor = float(self.config.get("SensitivityFactor", "1.0"))
except (ValueError, TypeError):
# 如果配置的敏感度因子无效则使用默认值1.0
sensitivity_factor = 1.0
wscore = original_wscore ** sensitivity_factor
if not text_match:
raise ValueError(f"VLM未能按预期格式返回无法解析文本。模型返回\n{vlm_output}")
# --- 步骤 2: 调用LLM生成批改报告 ---
llm_client = OpenAI(
api_key=self.config.get("LlmApiKey"),
base_url=self.config.get("LlmUrl")
)
# 从配置加载Prompt模板若用户未定义则使用默认模板
prompt_template = self.config.get("LlmPromptTemplate")
if not prompt_template:
prompt_template = DEFAULT_LLM_PROMPT_TEMPLATE
# 使用作文题目、书写分数和识别出的文本填充Prompt模板
final_llm_prompt = prompt_template.format(
topic=topic,
wscore=wscore,
essay_text=essay_text
)
llm_messages = [{"role": "user", "content": final_llm_prompt}]
llm_model = self.config.get("LlmModel", "gemini-2.5-pro")
llm_response = llm_client.chat.completions.create(model=llm_model, messages=llm_messages, temperature=1, max_tokens=16384)
final_report = llm_response.choices[0].message.content or "错误AI未能生成报告。"
return final_report

387
app_ui.py Normal file
View File

@@ -0,0 +1,387 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import threading
import queue
import os
from typing import List
import concurrent.futures
from config_manager import ConfigManager
from api_services import ApiService, DEFAULT_LLM_PROMPT_TEMPLATE
class AboutDialog(tk.Toplevel):
"""“关于”对话框,展示应用信息,支持滚动查看。"""
def __init__(self, parent):
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)
about_text = """
欢迎使用 AI 作文批改助手!这是一款专为教育者和学生设计的智能工具,旨在利用前沿的人工智能技术,提供高效、精准、个性化的英文作文批改体验。
核心亮点:
- **双核AI引擎:** 采用先进的两步式处理流程。首先由专用的视觉语言模型(VLM)对作文图片进行高精度手写识别(OCR)与专业的书写质量评估;随后,强大的大语言模型(LLM)会结合识别出的文本、作文题目以及书写评分,进行深度、全面的分析与批改。
- **极致的灵活性与可配置性:**
* **服务分离:** 您可以为VLM和LLM设置完全独立的API服务地址、密钥和模型名称轻松适配不同的AI提供商需要兼容OpenAI格式或自建服务。
* **逻辑定制:** 书写评分的“敏感度因子”可在设置中调整,以适应不同年级或要求的评分标准。
* **模板开放:** 核心的LLM批改指令模板Prompt完全开放给用户。您可以在设置中自由修改调整评分维度、总分、反馈风格等实现高度个性化的批改要求。
- **闪电般的并发处理:** 内置高效的多线程并发引擎,无论您选择一张还是上百张图片,程序都能同时处理,大幅缩短批量批改的等待时间。最大并发任务数亦可在设置中自由调整。
- **企业级的安全保障:** 我们深知API密钥的敏感性。所有密钥信息在保存到本地配置文件时均经过强大的加密算法处理有效防止明文泄露保障您的账户安全。
- **人性化的评分策略:** 卷面书写分采用更符合教学直觉的“向上取整至0.5分”规则,确保评分结果既精确又公平。
使用说明:
1. **初次配置:** 点击“设置”分别填入您的VLM和LLM服务提供商的URL、API密钥和模型名称。
2. **输入题目:** 在主界面上方的文本框中,输入本次批改的“作文题目”。
3. **选择文件:** 点击“选择图片”,一次性选择所有需要批改的学生作文图片。
4. **开始批改:** 点击“开始批改”,程序将自动在后台进行并发处理,您可以在日志区看到实时进度。
5. **获取报告:** 任务完成后每一张图片对应的Markdown格式详细批改报告都会自动生成在原图片所在的目录下。
作者: Eric_Terminal
https://github.com/Eric-Terminal
版本: 2.4
"""
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, current_config: dict):
super().__init__(parent)
self.transient(parent)
self.title("设置")
self.result = None
# 为VLM和LLM服务分别创建Tkinter字符串变量
self.vlm_url = tk.StringVar(value=current_config.get("VlmUrl", ""))
self.vlm_api_key = tk.StringVar(value=current_config.get("VlmApiKey", ""))
self.vlm_model = tk.StringVar(value=current_config.get("VlmModel", "gemini-2.5-pro"))
self.llm_url = tk.StringVar(value=current_config.get("LlmUrl", ""))
self.llm_api_key = tk.StringVar(value=current_config.get("LlmApiKey", ""))
self.llm_model = tk.StringVar(value=current_config.get("LlmModel", "gemini-2.5-pro"))
self.sensitivity_factor = tk.StringVar(value=current_config.get("SensitivityFactor", "1.5"))
self.max_workers = tk.StringVar(value=current_config.get("MaxWorkers", "4"))
# 智能加载Prompt模板优先使用用户自定义模板否则使用默认模板
user_template = current_config.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).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).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))
# 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(),
"LlmPromptTemplate": self.llm_prompt_text.get("1.0", "end-1c") # 从Text控件获取用户修改后的Prompt模板
}
self.destroy()
def on_close(self):
self.result = None
self.destroy()
class MainApp:
"""应用主窗口类负责构建UI界面、处理用户交互和协调后台服务。"""
def __init__(self, root: tk.Tk, config_manager: ConfigManager, api_service: ApiService):
self.root = root
self.config_manager = config_manager
self.api_service = api_service
self.file_paths: List[str] = []
self.is_file_selected = False
self.ui_queue = queue.Queue()
self.topic_input = None
self.processed_count = 0
self.lock = threading.Lock()
self._setup_ui()
self._initialize_config()
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
if messagebox.askretrycancel("配置未完成", f"请配置: {missing_item}") == "cancel":
self.root.quit()
return
def _open_settings_dialog(self):
"""打开设置对话框,并根据返回结果更新和保存配置。"""
dialog = SettingsDialog(self.root, self.config_manager.config)
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():
self.config_manager.set(key, value)
self.config_manager.save()
def _open_about_dialog(self):
"""创建并显示“关于”对话框。"""
AboutDialog(self.root)
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
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 = self.api_service.process_essay_image(file_path, topic)
report_filename = os.path.splitext(file_path)[0] + "_report.md"
with open(report_filename, 'w', encoding='utf-8') as f:
f.write(final_report)
self.ui_queue.put(("log", f"完成批改: {base_name} -> {os.path.basename(report_filename)}"))
except Exception as e:
self.ui_queue.put(("log", f"文件: {base_name} 失败: {e}"))
# 无论成功或失败,都更新进度
self.ui_queue.put(("progress", 1))

107
config_manager.py Normal file
View File

@@ -0,0 +1,107 @@
import json
import os
import base64
import hashlib
from typing import Dict, Optional, Tuple
from cryptography.fernet import Fernet, InvalidToken
class ConfigManager:
"""
管理应用的配置(`config.json`),包括加载、保存以及对敏感信息的自动加解密。
"""
# 定义需要进行加密处理的配置项
SENSITIVE_KEYS = ["VlmApiKey", "LlmApiKey"]
# 用于生成加密密钥的密码和盐。注意:修改这些值将导致旧的配置文件无法解密。
_ENCRYPTION_PASSWORD = b"a-strong-but-not-public-password-for-this-app"
_SALT = b'salt_for_llm_app_config'
def __init__(self, file_path: str = "config.json"):
self.file_path = file_path
self.config: Dict[str, str] = {}
self._fernet: Optional[Fernet] = None
self._initialize_encryption()
self.load()
def _initialize_encryption(self):
"""使用预设的密码和盐生成加密密钥并初始化Fernet加密/解密实例。"""
kdf = hashlib.pbkdf2_hmac('sha256', self._ENCRYPTION_PASSWORD, self._SALT, 100000)
key = base64.urlsafe_b64encode(kdf)
self._fernet = Fernet(key)
def _encrypt(self, value: str) -> str:
"""使用初始化的Fernet实例加密字符串。"""
if not value or not self._fernet:
return ""
return self._fernet.encrypt(value.encode('utf-8')).decode('utf-8')
def _decrypt(self, encrypted_value: str) -> str:
"""
使用初始化的Fernet实例解密字符串。
如果解密失败(例如,值是旧的明文或已损坏),则返回空字符串以避免程序崩溃。
"""
if not encrypted_value or not self._fernet:
return ""
try:
return self._fernet.decrypt(encrypted_value.encode('utf-8')).decode('utf-8')
except InvalidToken:
# 如果解密失败,返回空字符串
return ""
def load(self) -> bool:
"""从JSON文件加载配置。如果文件不存在则创建一个空的配置文件。"""
if not os.path.exists(self.file_path):
# 如果配置文件不存在,则创建一个空的配置字典并保存
self.config = {}
self.save()
return True
try:
with open(self.file_path, 'r', encoding='utf-8') as f:
self.config = json.load(f)
return True
except (json.JSONDecodeError, IOError):
self.config = {}
return False
def save(self):
"""将当前配置保存到JSON文件。敏感信息的加密在`set`方法中处理。"""
try:
with open(self.file_path, 'w', encoding='utf-8') as f:
json.dump(self.config, f, indent=4)
except IOError as e:
print(f"保存配置失败: {e}")
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
"""获取指定键的配置值。如果键属于敏感信息,则自动解密后返回。"""
value = self.config.get(key)
if value is None:
return default
if key in self.SENSITIVE_KEYS:
return self._decrypt(value)
return value
def set(self, key: str, value: str):
"""设置指定键的配置值。如果键属于敏感信息,则自动加密后存储。"""
if key in self.SENSITIVE_KEYS:
self.config[key] = self._encrypt(value)
else:
self.config[key] = value
def check_settings(self) -> Tuple[bool, Optional[str]]:
"""
检查所有必需的配置项是否都已设置。
返回一个元组,包含检查结果(布尔值)和第一个缺失的配置项名称(字符串)。
"""
required_settings = {
"VlmUrl": "VLM服务地址",
"VlmApiKey": "VLM服务密钥",
"VlmModel": "VLM模型名称",
"LlmUrl": "LLM服务地址",
"LlmApiKey": "LLM服务密钥",
"LlmModel": "LLM模型名称",
}
for key, name in required_settings.items():
# 使用self.get()来确保我们检查的是解密后的值
if not self.get(key):
return False, name
return True, None

37
main.py Normal file
View File

@@ -0,0 +1,37 @@
import tkinter as tk
from app_ui import MainApp
from config_manager import ConfigManager
from api_services import ApiService
import sys
import os
def resource_path(relative_path):
"""
获取资源的绝对路径以支持PyInstaller打包后的单文件应用。
在开发环境中,返回基于当前工作目录的相对路径。
在PyInstaller打包的应用中返回临时文件夹`_MEIPASS`中的路径。
"""
try:
# 尝试获取PyInstaller在运行时创建的临时路径
base_path = sys._MEIPASS
except Exception:
# 如果`_MEIPASS`属性不存在,说明是在开发环境,使用当前目录
base_path = os.path.abspath(".")
return os.path.join(base_path, relative_path)
if __name__ == "__main__":
# 1. 初始化核心服务
# 使用 resource_path 确保在打包后也能正确找到配置文件
config_manager = ConfigManager(resource_path("config.json"))
api_service = ApiService(config_manager)
# 2. 创建Tkinter主窗口
root = tk.Tk()
# 3. 实例化主应用,将服务注入应用
app = MainApp(root, config_manager, api_service)
# 4. 启动Tkinter事件循环
root.mainloop()

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
cryptography
openai