-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapi_services.py
More file actions
365 lines (319 loc) · 17.5 KB
/
api_services.py
File metadata and controls
365 lines (319 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
import base64
import json
import logging
import mimetypes
import os
import re
import time
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
{topic}
# INSTRUCTIONS FOR AI (Process in English)
## 1. ROLE & GOAL
You are a highly experienced senior high school English teacher specializing in the Chinese National College Entrance Examination (Gaokao). Your goal is to provide a detailed, constructive, and encouraging evaluation of a student's essay, correctly identifying the essay type and applying the appropriate scoring standard.
## 2. INPUT DATA
You will receive three pieces of data:
- `<topic>`: For "Application Writing", this is the essay prompt. For "Read and Continue Writing", this is the initial story provided to the student.
- `<wscore>`: A quantitative handwriting quality score from 0.0 to 1.0.
- `<text>`: The full text of the student's handwritten essay.
## 3. STEP 1: IDENTIFY ESSAY TYPE
First, you MUST determine which of the two following Gaokao essay types this is. This decision will change the total score.
* **TYPE A: Application Writing (应用文)**
* **Clues:** The total word count of the student's `<text>` is shorter, typically around 80-100 words. The `<topic>` is a straightforward instruction (e.g., "Write a letter to...").
* **Total Score:** 15 points.
* **TYPE B: Read and Continue Writing (读后续写)**
* **Clues:** The total word count of the student's `<text>` is longer, typically around 150 words. The `<topic>` contains a substantial story. The student's `<text>` will consist of two distinct paragraphs, and the beginning of each paragraph will match the starting sentences provided in the original exam prompt.
* **Total Score:** 25 points.
## 4. STEP 2: APPLY SCORING LOGIC
Based on the identified essay type, apply the corresponding grading logic. The Handwriting score calculation is the same for both.
* **Handwriting & Presentation Score (通用卷面分计算):**
* This sub-score is always out of **3 points**.
* **Calculation:** Get a raw score (`Raw Score = wscore * 3`). Then, round the `Raw Score` **up** to the nearest half-point (0.5).
* **Rounding Example:** A raw score of 2.49 becomes 2.5. A raw score of 2.51 becomes 3.0. A score of 2.50 remains 2.5.
* **GRADING FOR TYPE A: Application Writing (Total 15)**
* **Content & Language (12 points):** Evaluate grammar, vocabulary, sentence structure, and relevance to the topic.
* **Handwriting & Presentation (3 points):** Use the calculation described above.
* **Final Score:** (Content & Language Score) + (Handwriting Score) out of 15.
* **GRADING FOR TYPE B: Read and Continue Writing (Total 25)**
* **Content & Language (22 points):** Evaluate the quality of the continuation. Key criteria include: coherence with the original story, logical plot development, character consistency, richness of detail, and advanced use of grammar, vocabulary, and sentence structures.
* **Handwriting & Presentation (3 points):** Use the calculation described above.
* **Final Score:** (Content & Language Score) + (Handwriting Score) out of 25.
## 5. FINAL TASK
Analyze the text, identify the essay type, calculate the scores, and present your complete feedback in **Simplified Chinese** using the precise Markdown format specified in the "OUTPUT SPECIFICATION" section. Ensure the final score correctly reflects the total points possible (15 or 25).
#--- End of English Instructions ---
# OUTPUT SPECIFICATION (MUST BE IN SIMPLIFIED CHINESE)
#你应该综合考量书写和内容的评分,内容是主要的,字体是次要的,例如对于写的内容和作文毫无关系但是字很好看的,不应该给3分而是直接给0分
# 请使用以下Markdown格式,并用简体中文填充所有内容,优点找不到不要硬找,问题建议要把全部问题找出来并且解析,都要遵循类似格式。对于分数的总分则必须由你选择是15分还是25分(不一定是下面的15分)。
###【作文内容】
* **作文文本:** [在此处粘贴完整的作文文本。]
### 【综合评价】
(在此处用一两句鼓励性的话,对本次作文进行总体概述。如果写的太烂了也可以骂人)
### 【亮点与优点】
* **(优点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:
"""封装了与外部API(VLM和LLM)交互的所有逻辑。"""
def __init__(self, config_manager: ConfigManager, ui_queue: Optional[Any] = None):
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))
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 _chat_endpoint(self, base_url: Optional[str]) -> str:
if not base_url:
raise ValueError("服务地址未配置,请先在设置中填写 API Base URL")
return base_url.rstrip("/")
def _usage_from_response(self, response_json: Dict[str, Any]) -> Dict[str, int]:
usage = response_json.get("usage") or {}
return {
"prompt_tokens": int(usage.get("prompt_tokens", 0) or 0),
"completion_tokens": int(usage.get("completion_tokens", 0) or 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:
client_kwargs["api_key"] = api_key
return OpenAI(**client_kwargs)
def _invoke_chat_completion(
self,
label: str,
base_url: Optional[str],
api_key: Optional[str],
payload: Dict[str, Any],
max_retries: int,
retry_delay: int,
timeout: float,
) -> Dict[str, Any]:
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:
model = payload.get("model")
self._log(f"{label} 请求: endpoint={endpoint}, model={model}")
response = client.chat.completions.create(**payload)
response_json = response.model_dump()
trimmed = json.dumps(response_json, ensure_ascii=False)
if len(trimmed) > 800:
trimmed = trimmed[:797] + "..."
self._log(f"{label} 响应: {trimmed}")
return response_json
except OpenAIError as exc:
last_error = exc
error_message = str(exc)
except Exception as exc: # pylint: disable=broad-except
last_error = exc
error_message = str(exc)
if attempt == max_retries - 1:
raise 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} 调用失败:未知错误")
def process_essay_image(self, file_path: str, topic: str) -> Tuple[str, Dict[str, int], Dict[str, int]]:
"""
执行完整的两步式作文批改流程:
1. VLM调用:分析作文图片,提取手写文本和书写质量分数。
2. LLM调用:基于VLM的输出和作文题目,生成详细的批改报告。
返回: (批改报告, VLM token使用情况, LLM token使用情况)
"""
# --- 步骤 1: 调用VLM进行图像分析 ---
try:
max_retries = int(self.config.get("MaxRetries", 3))
retry_delay = int(self.config.get("RetryDelay", 5))
except (ValueError, TypeError):
max_retries = 3
retry_delay = 5
try:
request_timeout = float(self.config.get("RequestTimeout", 120))
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
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", "Qwen/Qwen3-VL-235B-A22B-Instruct")
vlm_payload = {
"model": vlm_model,
"messages": vlm_messages,
"max_tokens": 4096,
"temperature": vlm_temperature,
}
vlm_response_json = self._invoke_chat_completion(
"VLM",
self.config.get("VlmUrl"),
self.config.get("VlmApiKey"),
vlm_payload,
max_retries,
retry_delay,
request_timeout,
)
choices = vlm_response_json.get("choices") or []
if not choices:
raise ValueError(f"VLM 未返回 choices,响应:{vlm_response_json}")
vlm_output = choices[0].get("message", {}).get("content") or ""
vlm_usage = self._usage_from_response(vlm_response_json)
# 解析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生成批改报告 ---
# 从配置加载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}]
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": llm_temperature,
"max_tokens": 4096,
}
final_report: str
try:
llm_response_json = self._invoke_chat_completion(
"LLM",
self.config.get("LlmUrl"),
self.config.get("LlmApiKey"),
llm_payload,
max_retries,
retry_delay,
request_timeout,
)
llm_choices = llm_response_json.get("choices") or []
if not llm_choices:
raise ValueError(f"LLM 未返回 choices,响应:{llm_response_json}")
final_report = llm_choices[0].get("message", {}).get("content") or "错误:AI未能生成报告。"
except Exception as exc:
self._log(f"LLM 调用失败:{exc}")
final_report = f"错误:AI生成报告失败({exc})"
llm_response_json = {}
llm_usage = self._usage_from_response(llm_response_json)
# 渲染Markdown为HTML(如果配置开启)
html_path = None
if self.markdown_renderer:
# 定义HTML报告的文件名
report_base_name = os.path.splitext(file_path)[0]
html_output_path = f"{report_base_name}_report.html"
html_path = self.markdown_renderer.render_markdown_to_html_file(final_report, html_output_path)
if html_path:
self._log(f"已生成HTML报告: {os.path.basename(html_path)}")
return final_report, vlm_usage, llm_usage, html_path
def check_for_updates(current_version_str: str) -> Optional[str]:
"""
Checks for new releases on GitHub.
Returns the new version tag if an update is available, otherwise None.
"""
try:
url = "https://api.github.com/repos/Eric-Terminal/Pro_llm_correct/releases/latest"
# Add a user-agent to avoid being blocked
req = urllib.request.Request(url, headers={'User-Agent': 'Pro_llm_correct-Update-Checker'})
with urllib.request.urlopen(req) as response:
if response.status == 200:
data = json.loads(response.read().decode())
# 根据您的调试反馈,我们现在读取 'name' 字段来获取版本号
latest_version_name = data.get("name", "v0.0.0")
# Use packaging.version for robust comparison
if version.parse(latest_version_name) > version.parse(current_version_str):
return latest_version_name
except Exception as e:
logging.error(f"Failed to check for updates: {e}")
return None