-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexam_pdf_processing_system.py
More file actions
404 lines (337 loc) · 19.3 KB
/
Copy pathexam_pdf_processing_system.py
File metadata and controls
404 lines (337 loc) · 19.3 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
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
"""
exam_pdf_processing_system.py - 獨立考試模式 PDF 處理系統
與 Moodle 版本(complete_pdf_processing_system.py)並行,
改以 xlsx 成績表的「學號」與「姓名」欄位建立學生資料夾結構,
資料夾命名格式:學號_姓名_assignsubmission_file
其餘 PDF 辨識、YOLO 偵測、LLM 提取、模糊比對邏輯完全沿用。
"""
import os
import re
import glob
import shutil
import threading
import argparse
import pandas as pd
from typing import Optional, Dict, List
from concurrent.futures import ThreadPoolExecutor
from tqdm import tqdm
# 繼承原系統的核心辨識邏輯
from complete_pdf_processing_system import (
MultiPlatformIDExtractor,
PDFProcessingSystem,
_save_test_artifacts,
)
class ExamPDFProcessingSystem(PDFProcessingSystem):
"""
獨立考試模式:繼承 PDFProcessingSystem,
覆寫資料來源(xlsx)與學生比對邏輯。
"""
def __init__(self, test_mode: bool = False):
super().__init__(test_mode=test_mode)
self.xlsx_file_path: Optional[str] = None
self.sheet_name: str = "Summary" # 預設讀取 Summary 工作表
self.id_col: str = "學號"
self.name_col: str = "姓名"
# ──────────────────────────────────────────────────────────────────
# 1. 資料來源:xlsx → gradebook_df
# ──────────────────────────────────────────────────────────────────
def find_xlsx_file(self) -> bool:
"""自動尋找當前目錄下的 xlsx 成績表"""
# 使用列表推導式,強制過濾掉開頭為 ~$ 的暫存檔
xlsx_files = [f for f in glob.glob("*.xlsx") if not f.startswith("~$")]
if not xlsx_files:
print("❌ 找不到 xlsx 檔案,請確認成績表是否在當前目錄")
return False
# 優先選取最新的
self.xlsx_file_path = max(xlsx_files, key=os.path.getmtime)
print(f"✓ 找到成績表: {self.xlsx_file_path}")
return True
def load_gradebook_from_xlsx(self) -> bool:
"""
從 xlsx 載入學生名單。
讀取 Summary 工作表,跳過第 2 列(權重列),
取「學號」與「姓名」欄位。
"""
try:
# 使用 openpyxl 引擎,data_only=True 取值而非公式
raw_df = pd.read_excel(
self.xlsx_file_path,
sheet_name=self.sheet_name,
header=0, # 第 1 列為欄位名稱
skiprows=[1], # 跳過第 2 列(權重列,index=1)
engine="openpyxl",
)
# 確認必要欄位存在
missing = [c for c in [self.id_col, self.name_col] if c not in raw_df.columns]
if missing:
print(f"❌ 工作表缺少欄位: {missing}")
print(f" 現有欄位: {list(raw_df.columns)}")
return False
# 只保留有效的學號列(非空、數字)
df = raw_df[[self.id_col, self.name_col]].dropna(subset=[self.id_col])
df[self.id_col] = df[self.id_col].astype(str).str.strip()
df[self.name_col] = df[self.name_col].astype(str).str.strip()
df = df[df[self.id_col].str.match(r"^\d{6,8}$")] # 只保留純數字學號
self.gradebook_df = df.reset_index(drop=True)
print(f"✓ 載入 {len(self.gradebook_df)} 位學生資料")
print(f" 範例:{self.gradebook_df.head(3).to_dict('records')}")
return True
except Exception as e:
print(f"❌ 載入 xlsx 失敗: {e}")
import traceback; traceback.print_exc()
return False
# ──────────────────────────────────────────────────────────────────
# 2. 建立學生資料夾(覆寫命名規則)
# ──────────────────────────────────────────────────────────────────
def _student_folder_name(self, student_id: str, student_name: str) -> str:
"""
獨立考試版資料夾命名:學號_姓名_assignsubmission_file
(Moodle 版是 姓名_Participant編號_assignsubmission_file)
"""
return f"{student_id}_{student_name}_assignsubmission_file"
def create_student_folders(self) -> int:
"""依 gradebook_df 建立學生資料夾,回傳建立數量"""
created = 0
for _, row in self.gradebook_df.iterrows():
sid = str(row[self.id_col]).strip()
name = str(row[self.name_col]).strip()
folder = os.path.join(self.output_folder, self._student_folder_name(sid, name))
if not os.path.exists(folder):
os.makedirs(folder)
created += 1
print(f"✓ 建立 {created} 個學生資料夾(共 {len(self.gradebook_df)} 位學生)")
return created
# ──────────────────────────────────────────────────────────────────
# 3. 學號比對(覆寫以直接使用「學號」欄位)
# ──────────────────────────────────────────────────────────────────
def find_student_by_id(self, student_id: str):
"""
從 gradebook_df 的「學號」欄位比對,
回傳 (full_name, folder_name, csv_id, temp_num, match_type)
與父類介面一致,temp_num 在此回傳學號本身。
"""
# 正規化:去除 s/S/5 前綴
normalized_id = re.sub(r"^[sS5]", "", student_id)
if normalized_id != student_id:
print(f" → 前綴正規化: '{student_id}' → '{normalized_id}'")
student_id = normalized_id
candidates = []
for _, row in self.gradebook_df.iterrows():
csv_id = str(row[self.id_col]).strip()
name = str(row[self.name_col]).strip()
folder = self._student_folder_name(csv_id, name)
score, m_type = self.calculate_match_score(csv_id, student_id)
if score > 0:
candidates.append({
"full_name": name,
"folder_name": folder,
"csv_id": csv_id,
"temp_num": csv_id, # 考試版以學號充當 temp_num
"score": score,
"type": m_type,
})
if not candidates:
return None, None, None, None, "none"
candidates.sort(key=lambda x: x["score"], reverse=True)
best_score = candidates[0]["score"]
top = [c for c in candidates if c["score"] == best_score]
best = top[0]
match_type = f"multiple_candidates_{len(top)}" if len(top) > 1 else best["type"]
return best["full_name"], best["folder_name"], best["csv_id"], best["temp_num"], match_type
# ──────────────────────────────────────────────────────────────────
# 4. 覆寫 Phase 2/3 helper(讓父類的 resolve_duplicate_folders /
# resolve_unmatched_phase3 能正確讀取 xlsx 的學號欄位)
# ──────────────────────────────────────────────────────────────────
def _row_to_folder_key_and_csv_id(self, row, index: int) -> tuple:
"""
獨立考試版:直接從「學號」「姓名」欄位取值,
folder_key 格式與 _student_folder_name() 一致。
"""
sid = str(row.get(self.id_col, '')).strip()
name = str(row.get(self.name_col, '')).strip()
if not sid or not re.match(r'^\d{6,8}$', sid):
return None, None
folder_key = self._student_folder_name(sid, name)
return folder_key, sid
# ──────────────────────────────────────────────────────────────────
# 覆寫 Phase 2:處理重複分配的資料夾 (平手全保留 + 解決改名對應問題)
# ──────────────────────────────────────────────────────────────────
def resolve_duplicate_folders(self):
unmatched_dir = os.path.join(self.output_folder, "_unmatched")
os.makedirs(unmatched_dir, exist_ok=True)
for folder_name in os.listdir(self.output_folder):
folder_path = os.path.join(self.output_folder, folder_name)
if not os.path.isdir(folder_path) or folder_name == "_unmatched":
continue
pdfs = [f for f in os.listdir(folder_path) if f.lower().endswith('.pdf')]
if len(pdfs) <= 1:
continue
# 解析預期的學號
expected_id = folder_name.split('_')[0]
# 💡 [修復關鍵]:直接把系統分配給這個學號的所有紀錄抓出來
folder_records = [r for r in self.extraction_results if r.get("status") == "success" and str(r.get("csv_id")) == expected_id]
evaluated_files = []
for i, pdf_file in enumerate(pdfs):
record = None
# 嘗試尋找對應的紀錄 (不管是用舊檔名、還是系統儲存的新路徑)
for r in folder_records:
if r.get("_matched"): continue # 避免重複配對
if r.get("original_filename") == pdf_file or pdf_file in str(r.values()):
record = r
r["_matched"] = True
break
# 絕招:如果因為系統徹底改名導致無法比對,直接按順序「盲配」
# (因為都在同一個資料夾裡,肯定是這幾個紀錄造成的)
if not record and i < len(folder_records):
record = folder_records[i]
record["_matched"] = True
if not record:
continue
extracted_id = record.get("student_id", "")
score, m_type = self.calculate_match_score(expected_id, extracted_id)
evaluated_files.append({
"filename": pdf_file, # 目前的新檔名
"score": score,
"type": m_type,
"record": record
})
if not evaluated_files:
continue
# 依分數由高到低排序
evaluated_files.sort(key=lambda x: x["score"], reverse=True)
max_score = evaluated_files[0]["score"]
# 分出「最高分群(平手)」與「低分群(淘汰)」
tied_files = [f for f in evaluated_files if f["score"] == max_score]
evict_files = [f for f in evaluated_files if f["score"] < max_score]
if len(tied_files) > 1:
# 🤝 分數相同:不踢出!全部保留
for f in tied_files:
orig_name = f['record'].get('original_filename', f['filename'])
print(f" 🤝 平手保留:{orig_name} (資料夾內: {f['filename']}) (score={f['score']}, type={f['type']})")
# 強制上標籤!這樣 WorkflowManager 就一定抓得到了
f["record"]["match_type"] = "tied_conflict"
f["record"]["status"] = "success"
else:
# 🏆 只有一個最高分:正常保留
best = tied_files[0]
orig_name = best['record'].get('original_filename', best['filename'])
print(f" 🏆 保留高分:{orig_name} (score={best['score']}, type={best['type']})")
# ↩️ 處理低分檔案:強制移出至 _unmatched
for f in evict_files:
orig_name = f['record'].get('original_filename', f['filename'])
print(f" ↩️ 移出低分:{orig_name} (score={f['score']}, type={f['type']})")
src = os.path.join(folder_path, f["filename"])
dst = os.path.join(unmatched_dir, f["filename"])
try:
shutil.move(src, dst)
except Exception:
pass # 若檔案被鎖定則忽略
# 重置紀錄為未分配
f["record"]["status"] = "unmatched"
f["record"]["student_name"] = None
f["record"]["csv_id"] = None
f["record"]["match_type"] = "none"
# ──────────────────────────────────────────────────────────────────
# 5. 主流程入口(覆寫 run)
# ──────────────────────────────────────────────────────────────────
def run(self):
print("=== 獨立考試模式 PDF 處理系統 ===\n")
# Step 1:載入 xlsx 名單
if not self.find_xlsx_file():
return False
if not self.load_gradebook_from_xlsx():
return False
# Step 2:決定輸出資料夾
base_name = os.path.splitext(os.path.basename(self.xlsx_file_path))[0]
self.output_folder = base_name
os.makedirs(self.output_folder, exist_ok=True)
print(f"✓ 輸出目錄: {self.output_folder}")
# Step 3:建立學生資料夾
self.create_student_folders()
# Step 4:設定學號提取器
self.setup_extractor()
# Step 5:處理 Extract_PDF
if self.process_files(workers=2):
print("\n✓ 第一階段檔案處理完成!")
# 🌟 [新增邏輯] 在執行 Phase 2 前,先記錄哪些資料夾發生了衝突
phase2_conflict_files = set()
print("\n--- 進入第二階段:檢查重複檔案 ---")
for student_dir in os.listdir(self.output_folder):
dir_path = os.path.join(self.output_folder, student_dir)
if os.path.isdir(dir_path):
# 找出該資料夾內所有的 pdf
pdfs = [f for f in os.listdir(dir_path) if f.lower().endswith('.pdf')]
if len(pdfs) > 1:
for pdf in pdfs:
phase2_conflict_files.add(pdf)
print(f" ⚠️ 發現衝突資料夾 [{student_dir}],包含: {', '.join(pdfs)}")
if not phase2_conflict_files:
print(" ✓ 無重複分配的資料夾。")
# 執行 Phase 2 處理
self.resolve_duplicate_folders()
# --- 最終總結與報表 ---
success_count = sum(1 for r in self.extraction_results if r.get("status") == "success")
failures = [r for r in self.extraction_results if r.get("status") != "success"]
# 從原有的 warnings 中排除掉已經被記錄在 phase2_conflict_files 的檔案,避免重複印出
warnings = [r for r in self.extraction_results
if r.get("status") == "success"
and r.get("match_type", "") not in ("exact", "complement")
and r.get("original_filename") not in phase2_conflict_files]
# 🌟 [新增邏輯] 專門抓出 Phase 2 處理過的檔案結果
phase2_results = [r for r in self.extraction_results if r.get("original_filename") in phase2_conflict_files]
print(f"\n📊 總結:成功分配 {success_count} / {len(self.extraction_results)} 個檔案")
# 印出 Phase 2 處理結果
if phase2_results:
print(f"\n🔍 Phase 2 (重複衝突) 處理結果 (共 {len(phase2_results)} 個檔案):")
for p2 in phase2_results:
status_icon = "✅" if p2.get('status') == 'success' else "❌"
print(f" {status_icon} {p2.get('original_filename')} → {p2.get('student_name', '未分配')} "
f"(狀態: {p2.get('status')}, 類型: {p2.get('match_type')})")
# 印出一般的非完美匹配 (Warnings)
if warnings:
print(f"\n⚠️ 需要確認的匹配案例 (共 {len(warnings)} 個):")
for w in warnings:
print(f" 🔄 {w.get('original_filename')} → {w.get('student_name')} "
f"(AI: {w.get('student_id')}, CSV: {w.get('csv_id')}, "
f"類型: {w.get('match_type')})")
# 印出完全失敗的檔案
if failures:
# 排除掉因為 Phase 2 競爭失敗而被判定為 unmatched 的檔案(因為上面已經印過了)
pure_failures = [f for f in failures if f.get("original_filename") not in phase2_conflict_files]
if pure_failures:
print(f"\n❌ 以下檔案未能分配 (共 {len(pure_failures)} 個):")
for f in pure_failures:
print(f" • {f.get('original_filename')} → 狀態: {f.get('status')}")
return True
return False
# ──────────────────────────────────────────────────────────────────────
# CLI 入口
# ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="獨立考試模式 PDF 學號辨識處理系統")
parser.add_argument(
"--test",
action="store_true",
help="測試模式:儲存 YOLO 裁切圖片與 AI 辨識結果至各學生資料夾",
)
parser.add_argument(
"--sheet",
default="Summary",
help="xlsx 工作表名稱(預設: Summary)",
)
parser.add_argument(
"--id-col",
default="學號",
help="學號欄位名稱(預設: 學號)",
)
parser.add_argument(
"--name-col",
default="姓名",
help="姓名欄位名稱(預設: 姓名)",
)
args = parser.parse_args()
system = ExamPDFProcessingSystem(test_mode=args.test)
system.sheet_name = args.sheet
system.id_col = args.id_col
system.name_col = args.name_col
system.run()