tbdavid2019 commited on
Commit
e193cbc
·
1 Parent(s): 402a461
Files changed (4) hide show
  1. README.md +176 -0
  2. app.py +1017 -0
  3. env.example +2 -0
  4. requirements.txt +8 -0
README.md CHANGED
@@ -11,3 +11,179 @@ short_description: 上傳解答卷和拍照學生考卷-自動批改和給出評
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
14
+
15
+
16
+ ---
17
+
18
+ # 🎓 AI 智慧閱卷助手
19
+
20
+ > 上傳解答與考卷,讓 Gemini 3 Pro 為您自動批改、評分,並生成帶有批改標記的考卷圖片與溫暖的老師評語。
21
+
22
+ ![Gemini 3 Pro](https://img.shields.io/badge/Model-Gemini%203%20Pro%20Image-4285F4?style=flat-square&logo=google)
23
+ ![Python](https://img.shields.io/badge/Python-3.10+-3776AB?style=flat-square&logo=python)
24
+ ![Gradio](https://img.shields.io/badge/UI-Gradio-FF6F00?style=flat-square)
25
+
26
+ ---
27
+
28
+ ## ✨ 功能特色
29
+
30
+ ### 🔑 填寫個人 API Key
31
+ - 使用 Google AI Studio 發放的 Gemini API Key
32
+ - API Key **僅儲存在瀏覽器記憶體**,不會傳送至任何伺服器
33
+ - 支援一鍵移除 API Key
34
+
35
+ ### 📝 上傳答案卷(自動解析)
36
+ - 支援 **PDF** 與 **圖片**(PNG、JPG、WEBP)格式
37
+ - 使用 Gemini 3 Pro 自動辨識並解析標準答案
38
+ - 支援手寫答案、數學公式的辨識
39
+ - 老師可在解析後**手動修正答案**
40
+
41
+ ### 📷 上傳學生考卷
42
+ - 支援多頁拍攝上傳
43
+ - 自動辨識手寫字跡與計算過程
44
+ - 清晰的考卷預覽功能
45
+
46
+ ### 🚀 智慧批改
47
+ - **逐題評分**:對照標準答案自動批改
48
+ - **生成批改圖片**:在原考卷上標記 ✓ ✗、扣分原因、總分
49
+ - **老師評語**:生成溫暖且具體的回饋,包含:
50
+ - 肯定學生做得好的部分
51
+ - 指出需要改進的地方
52
+ - 給予鼓勵與改進建議
53
+
54
+ ---
55
+
56
+ ## 📦 快速開始
57
+
58
+ ### 1. 安裝套件
59
+
60
+ ```bash
61
+ # 建立虛擬環境(建議)
62
+ python -m venv .venv
63
+ source .venv/bin/activate # macOS/Linux
64
+ # .venv\Scripts\activate # Windows
65
+
66
+ # 安裝依賴
67
+ pip install -r requirements.txt
68
+ ```
69
+
70
+ ### 2. 設定環境變數(可選)
71
+
72
+ 建立 `.env` 檔案:
73
+
74
+ ```bash
75
+ GEMINI_API_KEY=your_api_key_here
76
+ ```
77
+
78
+ > 💡 若不想寫入檔案,也可直接在 UI 中輸入 API Key。
79
+
80
+ ### 3. 執行應用程式
81
+
82
+ ```bash
83
+ python app.py
84
+ ```
85
+
86
+ ### 4. 開始使用
87
+
88
+ 1. 瀏覽器開啟 `http://localhost:7860`
89
+ 2. 輸入您的 Gemini API Key
90
+ 3. 上傳標準答案卷(PDF 或圖片)
91
+ 4. 確認解析結果,必要時手動修正
92
+ 5. 上傳學生考卷圖片
93
+ 6. 點擊「開始批改考卷」
94
+ 7. 查看批改結果、批改後的圖片與老師評語
95
+
96
+ ---
97
+
98
+ ## 🛠️ 技術架構
99
+
100
+ | 項目 | 說明 |
101
+ |------|------|
102
+ | **AI 模型** | `gemini-3-pro-image-preview` (Gemini 3 Pro Image Preview) |
103
+ | **SDK** | Google Gen AI SDK (`google-genai`) |
104
+ | **UI 框架** | Gradio |
105
+ | **PDF 處理** | pypdf (文字提取) + pypdfium2 (轉圖片) |
106
+
107
+ ### 核心功能
108
+
109
+ - **圖像生成**:使用 `response_modalities=['TEXT', 'IMAGE']` 讓 Gemini 直接輸出批改後的考卷圖片
110
+ - **多輪對話**:支援反覆修正與調整
111
+ - **高解析度**:批改圖片支援 2K 輸出
112
+
113
+ ---
114
+
115
+ ## 📁 專案結構
116
+
117
+ ```
118
+ hg-grade-test/
119
+ ├── app.py # 主程式
120
+ ├── requirements.txt # Python 依賴
121
+ ├── .env.example # 環境變數範例
122
+ ├── README.md # 說明文件
123
+ └── .venv/ # 虛擬環境(不納入版控)
124
+ ```
125
+
126
+ ---
127
+
128
+ ## ⚙️ 環境變數
129
+
130
+ | 變數名稱 | 說明 |
131
+ |----------|------|
132
+ | `GEMINI_API_KEY` | Google AI Studio 發放的 Gemini API Key |
133
+
134
+ ---
135
+
136
+ ## 📋 依賴套件
137
+
138
+ ```
139
+ gradio>=4.44.0
140
+ google-genai>=1.0.0
141
+ pypdf>=5.0.0
142
+ Pillow>=10.4.0
143
+ python-dotenv>=1.0.1
144
+ pypdfium2>=4.0.0
145
+ ```
146
+
147
+ ---
148
+
149
+ ## ⚠️ 注意事項
150
+
151
+ 1. **隱私安全**
152
+ - API Key 僅存在瀏覽器記憶體,不會被寫入檔案或傳送至伺服器
153
+ - 考卷庫為記憶體暫存,重啟後需重新上傳
154
+
155
+ 2. **圖片品質**
156
+ - 建議上傳清晰的 JPG/PNG 圖片
157
+ - 確保光線充足、字跡清晰
158
+ - PDF 若版面複雜,建議先轉成圖片
159
+
160
+ 3. **模型限制**
161
+ - `gemini-3-pro-image-preview` 為預覽版模型
162
+ - 圖片生成可能需要 1-3 分鐘
163
+ - 若圖片生成失敗,仍會返回文字批改結果
164
+
165
+ 4. **最佳實踐**
166
+ - 每次上傳後確認解析結果是否正確
167
+ - 必要時手動修正答案後再進行批改
168
+ - 分批上傳大量考卷可提高穩定性
169
+
170
+ ---
171
+
172
+ ## 🔗 相關資源
173
+
174
+ - [Gemini API 文件](https://ai.google.dev/gemini-api/docs)
175
+ - [Gemini 圖像生成指南](https://ai.google.dev/gemini-api/docs/image-generation)
176
+ - [Google AI Studio](https://aistudio.google.com/) - 取得 API Key
177
+ - [Gradio 官方文件](https://www.gradio.app/docs)
178
+
179
+ ---
180
+
181
+ ## 📄 授權
182
+
183
+ MIT License
184
+
185
+ ---
186
+
187
+ ## 🙏 致謝
188
+
189
+ 感謝 Google 提供強大的 Gemini 3 Pro 模型,讓 AI 閱卷成為可能!
app.py ADDED
@@ -0,0 +1,1017 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import io
3
+ import json
4
+ import logging
5
+ import mimetypes
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+ import traceback
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import List, Tuple
13
+
14
+ import gradio as gr
15
+ from dotenv import load_dotenv
16
+ from google import genai
17
+ from google.genai import types
18
+ from PIL import Image
19
+ from pypdf import PdfReader
20
+
21
+ # 設定 logging
22
+ logging.basicConfig(
23
+ level=logging.DEBUG,
24
+ format='%(asctime)s - %(levelname)s - %(message)s'
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ load_dotenv()
29
+
30
+ # 文字理解與分析使用 gemini-2.5-flash
31
+ TEXT_MODEL = "gemini-2.5-flash"
32
+ # 圖片生成使用 gemini-3-pro-image-preview
33
+ IMAGE_MODEL = "gemini-3-pro-image-preview"
34
+ DEFAULT_API_KEY = os.getenv("GEMINI_API_KEY", "")
35
+
36
+ # 答案卷儲存路徑
37
+ ANSWERS_DIR = Path(__file__).parent / "data" / "answers"
38
+ ANSWERS_DIR.mkdir(parents=True, exist_ok=True)
39
+ # 答案卷圖片儲存路徑
40
+ ANSWER_IMAGES_DIR = Path(__file__).parent / "data" / "answer_images"
41
+ ANSWER_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
42
+
43
+
44
+ def get_safe_name(name: str) -> str:
45
+ """產生安全的檔名"""
46
+ safe_name = "".join(c for c in name if c.isalnum() or c in " _-").strip()
47
+ if not safe_name:
48
+ safe_name = datetime.now().strftime("%Y%m%d_%H%M%S")
49
+ return safe_name
50
+
51
+
52
+ def load_saved_answers() -> Tuple[dict, dict]:
53
+ """從檔案系統載入已儲存的答案卷,返回 (文字內容, 圖片路徑列表)"""
54
+ answers = {}
55
+ answer_images = {}
56
+ try:
57
+ for json_file in ANSWERS_DIR.glob("*.json"):
58
+ try:
59
+ with open(json_file, "r", encoding="utf-8") as f:
60
+ data = json.load(f)
61
+ name = data.get("name", json_file.stem)
62
+ answers[name] = data.get("content", "")
63
+ # 載入對應的圖片路徑
64
+ image_paths = data.get("image_paths", [])
65
+ if image_paths:
66
+ answer_images[name] = image_paths
67
+ except Exception as e:
68
+ logger.warning(f"載入答案卷失敗 {json_file}: {e}")
69
+ except Exception as e:
70
+ logger.error(f"掃描答案卷目錄失敗: {e}")
71
+ return answers, answer_images
72
+
73
+
74
+ def save_answer_to_file(name: str, content: str, image_paths: List[str] = None) -> bool:
75
+ """儲存答案卷到檔案,包含原始圖片"""
76
+ try:
77
+ safe_name = get_safe_name(name)
78
+
79
+ # 儲存圖片到專用目錄
80
+ saved_image_paths = []
81
+ if image_paths:
82
+ answer_img_dir = ANSWER_IMAGES_DIR / safe_name
83
+ answer_img_dir.mkdir(parents=True, exist_ok=True)
84
+
85
+ for idx, img_path in enumerate(image_paths):
86
+ src_path = Path(img_path)
87
+ if src_path.exists():
88
+ # 複製圖片到永久目錄
89
+ ext = src_path.suffix or ".png"
90
+ dest_path = answer_img_dir / f"page_{idx+1}{ext}"
91
+
92
+ # 複製檔案
93
+ shutil.copy2(src_path, dest_path)
94
+ saved_image_paths.append(str(dest_path))
95
+ logger.info(f"答案卷圖片已儲存: {dest_path}")
96
+
97
+ file_path = ANSWERS_DIR / f"{safe_name}.json"
98
+ data = {
99
+ "name": name,
100
+ "content": content,
101
+ "image_paths": saved_image_paths,
102
+ "updated_at": datetime.now().isoformat(),
103
+ }
104
+ with open(file_path, "w", encoding="utf-8") as f:
105
+ json.dump(data, f, ensure_ascii=False, indent=2)
106
+ logger.info(f"答案卷已儲存: {file_path}")
107
+ return True
108
+ except Exception as e:
109
+ logger.error(f"儲存答案卷失敗: {e}")
110
+ logger.error(traceback.format_exc())
111
+ return False
112
+
113
+
114
+ def delete_answer_file(name: str) -> bool:
115
+ """刪除答案卷檔案和圖片"""
116
+ try:
117
+ safe_name = get_safe_name(name)
118
+
119
+ # 刪除 JSON 檔案
120
+ file_path = ANSWERS_DIR / f"{safe_name}.json"
121
+ if file_path.exists():
122
+ file_path.unlink()
123
+ logger.info(f"答案卷已刪除: {file_path}")
124
+
125
+ # 刪除圖片目錄
126
+ img_dir = ANSWER_IMAGES_DIR / safe_name
127
+ if img_dir.exists():
128
+ shutil.rmtree(img_dir)
129
+ logger.info(f"答案卷圖片目錄已刪除: {img_dir}")
130
+
131
+ # 嘗試找到匹配的檔案(以 name 欄位匹配)
132
+ for json_file in ANSWERS_DIR.glob("*.json"):
133
+ try:
134
+ with open(json_file, "r", encoding="utf-8") as f:
135
+ data = json.load(f)
136
+ if data.get("name") == name:
137
+ json_file.unlink()
138
+ logger.info(f"答案卷已刪除: {json_file}")
139
+ return True
140
+ except:
141
+ pass
142
+ return True
143
+ except Exception as e:
144
+ logger.error(f"刪除答案卷失敗: {e}")
145
+ return False
146
+
147
+
148
+ def get_client(api_key: str) -> genai.Client:
149
+ """建立 Gemini Client"""
150
+ key = api_key.strip() or DEFAULT_API_KEY
151
+ if not key:
152
+ raise ValueError("請先輸入 Gemini API Key,或在 .env 設定 GEMINI_API_KEY。")
153
+ return genai.Client(api_key=key)
154
+
155
+
156
+ def load_image_from_path(path: Path) -> Image.Image:
157
+ """從路徑載入 PIL Image"""
158
+ return Image.open(path)
159
+
160
+
161
+ def extract_pdf_text(path: Path) -> str:
162
+ """Lightweight PDF text extraction"""
163
+ try:
164
+ reader = PdfReader(path)
165
+ text = "\n\n".join((page.extract_text() or "") for page in reader.pages)
166
+ return text.strip()
167
+ except Exception:
168
+ return ""
169
+
170
+
171
+ def pdf_to_images(path: Path) -> List[Image.Image]:
172
+ """將 PDF 轉換為圖片列表(使用 pdf2image 或 pypdfium2)"""
173
+ try:
174
+ import pdf2image
175
+ images = pdf2image.convert_from_path(path)
176
+ return images
177
+ except ImportError:
178
+ pass
179
+
180
+ try:
181
+ import pypdfium2 as pdfium
182
+ pdf = pdfium.PdfDocument(path)
183
+ images = []
184
+ for page in pdf:
185
+ bitmap = page.render(scale=2)
186
+ pil_image = bitmap.to_pil()
187
+ images.append(pil_image)
188
+ return images
189
+ except ImportError:
190
+ pass
191
+
192
+ return []
193
+
194
+
195
+ def build_file_contents(paths: List[Path]) -> Tuple[List, List[str]]:
196
+ """
197
+ 建立要傳給 Gemini 的內容列表(文字與圖片)
198
+ 返回 (contents, warnings)
199
+ """
200
+ contents: List = []
201
+ warnings: List[str] = []
202
+
203
+ for path in paths:
204
+ suffix = path.suffix.lower()
205
+ if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
206
+ img = load_image_from_path(path)
207
+ contents.append(img)
208
+ elif suffix == ".pdf":
209
+ # 嘗試將 PDF 轉為圖片
210
+ pdf_images = pdf_to_images(path)
211
+ if pdf_images:
212
+ contents.extend(pdf_images)
213
+ else:
214
+ # 如果無法轉圖,至少提取文字
215
+ pdf_text = extract_pdf_text(path)
216
+ if pdf_text:
217
+ contents.append(f"[PDF {path.name} 文字]\n{pdf_text}")
218
+ else:
219
+ warnings.append(f"{path.name} 無法解析,建議轉成圖片上傳。")
220
+ else:
221
+ warnings.append(f"{path.name} 格式不支援,已略過。")
222
+
223
+ return contents, warnings
224
+
225
+
226
+ def to_gallery_items(paths: List[str]) -> List[tuple]:
227
+ """Convert file paths to gallery tuples (path, caption)."""
228
+ items: List[tuple] = []
229
+ for idx, p in enumerate(paths, start=1):
230
+ items.append((p, f"第 {idx} 頁"))
231
+ return items
232
+
233
+
234
+ def parse_answer_key(api_key: str, files: List[str]) -> Tuple[str, str]:
235
+ """解析答案卷,輸出 JSON 格式的標準答案"""
236
+ logger.info(f"=== parse_answer_key 開始 ===")
237
+ logger.info(f"files: {files}")
238
+ logger.info(f"api_key 長度: {len(api_key) if api_key else 0}")
239
+
240
+ if not files:
241
+ logger.warning("沒有上傳檔案")
242
+ return "", "請先上傳答案卷 (PDF / 圖片)。"
243
+
244
+ paths = [Path(f) for f in files]
245
+ logger.info(f"paths: {paths}")
246
+
247
+ file_contents, warnings = build_file_contents(paths)
248
+ logger.info(f"file_contents 數量: {len(file_contents)}")
249
+ logger.info(f"file_contents 類型: {[type(c).__name__ for c in file_contents]}")
250
+ logger.info(f"warnings: {warnings}")
251
+
252
+ if not file_contents:
253
+ logger.error("無法解析上傳的檔案")
254
+ return "", "無法解析上傳的檔案,請確認格式正確。"
255
+
256
+ prompt = """
257
+ 你是一名考卷答案解析助手。請仔細辨識這份解答卷中的所有題目,包括:
258
+ - 選擇題、填充題、計算題、應用題等
259
+ - 數學公式、手寫答案
260
+
261
+ 請抽取題號、標準答案與每題分數,輸出 JSON 陣列:
262
+ [
263
+ {"question_number": "一、1", "answer": "標準答案", "points": 5},
264
+ {"question_number": "二、1", "answer": "數學公式或步驟", "points": 10},
265
+ ...
266
+ ]
267
+
268
+ 規則:
269
+ 1. 保持題號原有格式與排序
270
+ 2. 數學公式用 LaTeX 表示
271
+ 3. 若缺少分數資訊,依題目難度合理估計
272
+ 4. 只輸出 JSON,不要其他說明
273
+ """
274
+
275
+ try:
276
+ logger.info(f"正在建立 Gemini Client...")
277
+ client = get_client(api_key)
278
+ logger.info(f"Client 建立成功,準備呼叫 API")
279
+ logger.info(f"使用模型: {TEXT_MODEL}")
280
+ logger.info(f"內容數量: {len([prompt] + file_contents)}")
281
+
282
+ response = client.models.generate_content(
283
+ model=TEXT_MODEL,
284
+ contents=[prompt] + file_contents,
285
+ config=types.GenerateContentConfig(
286
+ temperature=0.2,
287
+ )
288
+ )
289
+
290
+ logger.info(f"API 回應成功")
291
+ logger.info(f"response.parts 數量: {len(response.parts) if response.parts else 0}")
292
+
293
+ result_text = ""
294
+ for part in response.parts:
295
+ if part.text:
296
+ result_text += part.text
297
+ logger.debug(f"取得文字長度: {len(part.text)}")
298
+
299
+ logger.info(f"解析結果長度: {len(result_text)}")
300
+ warning_text = ";".join(warnings) if warnings else "已自動解析,老師可在下方微調後直接使用。"
301
+ return result_text.strip(), warning_text
302
+ except Exception as exc:
303
+ logger.error(f"解析失敗: {exc}")
304
+ logger.error(traceback.format_exc())
305
+ return "", f"解析失敗:{exc}"
306
+
307
+
308
+ def grade_exam_with_image(
309
+ api_key: str,
310
+ answer_text: str,
311
+ answer_image_paths: List[str],
312
+ student_files: List[str],
313
+ generate_teacher_feedback: bool = True,
314
+ ) -> Tuple[str, List[str], str]:
315
+ """
316
+ 批改考卷並生成帶有批改標記的圖片
317
+
318
+ Args:
319
+ api_key: Gemini API Key
320
+ answer_text: 解析後的答案 JSON 文字
321
+ answer_image_paths: 原始答案卷圖片路徑列表
322
+ student_files: 學生考卷圖片路徑列表
323
+
324
+ 返回 (評語文字, 批改圖片路徑列表, 狀態訊息)
325
+ """
326
+ if not answer_text.strip() and not answer_image_paths:
327
+ return "", [], "請先選擇或建立一份答案卷,再上傳學生考卷。"
328
+ if not student_files:
329
+ return "", [], "請上傳學生考卷圖片 (建議使用清晰的 PNG/JPG)。"
330
+
331
+ # 載入學生考卷圖片
332
+ student_paths = [Path(f) for f in student_files]
333
+ student_contents, warnings = build_file_contents(student_paths)
334
+
335
+ if not student_contents:
336
+ return "", [], "無法解析上傳的學生考卷。"
337
+
338
+ # 載入答案卷圖片
339
+ answer_images = []
340
+ for img_path in answer_image_paths:
341
+ p = Path(img_path)
342
+ if p.exists():
343
+ try:
344
+ img = Image.open(p)
345
+ answer_images.append(img)
346
+ logger.info(f"載入答案卷圖片: {p}")
347
+ except Exception as e:
348
+ logger.warning(f"無法載入答案卷圖片 {p}: {e}")
349
+
350
+ logger.info(f"答案卷圖片數量: {len(answer_images)}, 學生考卷數量: {len(student_contents)}")
351
+
352
+ try:
353
+ client = get_client(api_key)
354
+
355
+ # 使用圖片生成模型,同時輸出批改圖片和文字結果
356
+ # 這樣可以確保圖片標記和文字結果一致
357
+ graded_images = []
358
+ all_feedback_parts = []
359
+
360
+ for idx, student_img in enumerate(student_contents):
361
+ if not isinstance(student_img, Image.Image):
362
+ continue
363
+
364
+ # 組合批改提示 - 要求同時輸出文字結果和批改圖片
365
+ grading_prompt = f"""你是一位經驗豐富、嚴謹的老師。請仔細批改這張學生考卷。
366
+
367
+ 【重要】請嚴格按照標準答案進行批改,不要自行判斷答案!
368
+
369
+ 我會提供:
370
+ 1. 標準答案卷圖片(包含所有正確答案,可能有手寫內容)
371
+ 2. 解析後的答案 JSON(供參考)
372
+ 3. 學生考卷圖片
373
+
374
+ 請完成以下兩件事:
375
+
376
+ 【任務一】輸出批改結果文字:
377
+ 請用以下格式輸出第 {idx+1} 頁的批改結果:
378
+
379
+ ### 第 {idx+1} 頁批改結果
380
+ | 題號 | 學生答案 | 正確答案 | 得分 | 滿分 | 評語 |
381
+ |------|----------|----------|------|------|------|
382
+ | 1 | (學生寫的) | (正確答案) | X | X | 正確/錯誤原因 |
383
+ ...
384
+
385
+ **本頁得分:XX / XX 分**
386
+
387
+ 【任務二】生成批改後的考卷圖片:
388
+ 在學生考卷圖片上標記:
389
+ - ✓ 綠色打勾:答案正確
390
+ - ✗ 紅色打叉:答案錯誤,旁邊寫正確答案
391
+ - 每題標註得分
392
+ - 底部寫總分
393
+
394
+ ---
395
+ === 以下是【標準答案卷圖片】(以此為準!)===
396
+ """
397
+
398
+ contents = [grading_prompt]
399
+
400
+ # 加入答案卷圖片
401
+ if answer_images:
402
+ contents.extend(answer_images)
403
+
404
+ # 加入 JSON 答案作為補充
405
+ if answer_text.strip():
406
+ contents.append(f"\n=== 以下是【解析後的答案 JSON】(輔助參考)===\n{answer_text.strip()}\n")
407
+
408
+ # 加入學生考卷
409
+ contents.append("=== 以下是【學生考卷圖片】(請批改這張)===")
410
+ contents.append(student_img)
411
+
412
+ logger.info(f"批改第 {idx+1} 頁,傳送內容數量: {len(contents)}")
413
+
414
+ try:
415
+ response = client.models.generate_content(
416
+ model=IMAGE_MODEL,
417
+ contents=contents,
418
+ config=types.GenerateContentConfig(
419
+ response_modalities=['TEXT', 'IMAGE'],
420
+ image_config=types.ImageConfig(
421
+ aspect_ratio="3:4",
422
+ image_size="2K",
423
+ ),
424
+ temperature=0.2,
425
+ )
426
+ )
427
+
428
+ # 從回應中提取文字和圖片
429
+ page_feedback = ""
430
+ for part in response.parts:
431
+ if hasattr(part, 'text') and part.text:
432
+ page_feedback += part.text
433
+ if hasattr(part, 'inline_data') and part.inline_data:
434
+ img = part.as_image()
435
+ temp_path = tempfile.mktemp(suffix=f"_graded_{idx+1}.png")
436
+ img.save(temp_path)
437
+ graded_images.append(temp_path)
438
+ logger.info(f"批改圖片已生成: {temp_path}")
439
+
440
+ if page_feedback:
441
+ all_feedback_parts.append(page_feedback)
442
+ logger.info(f"第 {idx+1} 頁批改文字長度: {len(page_feedback)}")
443
+
444
+ except Exception as img_exc:
445
+ logger.error(f"第 {idx+1} 頁批改失敗: {img_exc}")
446
+ warnings.append(f"第 {idx+1} 頁批改失敗:{img_exc}")
447
+
448
+ # 組合所有頁面的批改結果
449
+ combined_feedback = "\n\n".join(all_feedback_parts)
450
+
451
+ # 如果有多頁,再生成總結評語
452
+ if len(student_contents) > 0 and combined_feedback:
453
+ try:
454
+ summary_prompt = f"""根據以下批改結果,請以溫暖關懷的語氣寫一段「老師的評語」:
455
+
456
+ {combined_feedback}
457
+
458
+ 請用以下格式:
459
+
460
+ ---
461
+
462
+ ## 老師的評語
463
+
464
+ 親愛的同學:
465
+ (在這裡寫出具體的回饋,包括:
466
+ - 肯定做得好的部分
467
+ - 指出需要加強的觀念
468
+ - 給予鼓勵)
469
+ """
470
+ summary_response = client.models.generate_content(
471
+ model=TEXT_MODEL,
472
+ contents=[summary_prompt],
473
+ config=types.GenerateContentConfig(temperature=0.4)
474
+ )
475
+
476
+ teacher_comment = ""
477
+ for part in summary_response.parts:
478
+ if part.text:
479
+ teacher_comment += part.text
480
+
481
+ if teacher_comment:
482
+ combined_feedback += "\n\n" + teacher_comment
483
+
484
+ except Exception as e:
485
+ logger.warning(f"生成老師評語失敗: {e}")
486
+
487
+ warning_text = ";".join(warnings) if warnings else "批改完成!"
488
+ return combined_feedback, graded_images, warning_text
489
+
490
+ except Exception as exc:
491
+ logger.error(f"批改失敗: {exc}")
492
+ logger.error(traceback.format_exc())
493
+ return "", [], f"批改失敗:{exc}"
494
+
495
+
496
+ def guess_exam_name(api_key: str, answer_text: str, file_names: List[str]) -> str:
497
+ """Try to produce a friendly exam name; fallback to file stem."""
498
+ fallback = Path(file_names[0]).stem if file_names else "未命名考卷"
499
+ if not answer_text.strip():
500
+ return fallback
501
+ prompt = f"""請幫老師為這份考卷取一個簡短的中文名稱。
502
+
503
+ 規則:
504
+ - 長度必須在 6-16 字之間
505
+ - 只回傳名稱本身,不要加引號、標點符號或任何說明
506
+ - 範例:「四年級數學期中考」、「五上自然第三單元」
507
+
508
+ 可參考的答案內容:
509
+ {answer_text[:800]}
510
+
511
+ 請直接輸出名稱(6-16字):"""
512
+ try:
513
+ client = get_client(api_key)
514
+ response = client.models.generate_content(
515
+ model=TEXT_MODEL,
516
+ contents=[prompt],
517
+ config=types.GenerateContentConfig(
518
+ temperature=0.2,
519
+ max_output_tokens=30, # 限制輸出長度
520
+ )
521
+ )
522
+ name = ""
523
+ for part in response.parts:
524
+ if part.text:
525
+ name += part.text
526
+ cleaned = name.replace("\n", "").replace("「", "").replace("」", "").replace('"', '').replace("'", "").strip()
527
+ # 強制限制長度
528
+ if len(cleaned) > 20:
529
+ cleaned = cleaned[:20]
530
+ return cleaned or fallback
531
+ except Exception:
532
+ return fallback
533
+
534
+
535
+ def parse_and_store_answer(
536
+ api_key: str,
537
+ files: List[str],
538
+ provided_name: str,
539
+ stored: dict,
540
+ stored_images: dict,
541
+ ):
542
+ logger.info(f"parse_and_store_answer 開始執行,檔案數量: {len(files) if files else 0}")
543
+ stored = stored or {}
544
+ stored_images = stored_images or {}
545
+ parsed, status = parse_answer_key(api_key, files)
546
+ if not parsed:
547
+ return "", status, provided_name, gr.update(choices=list(stored.keys()), value=None, visible=True), stored, stored_images, to_gallery_items(files)
548
+
549
+ final_name = provided_name.strip() if provided_name and provided_name.strip() else guess_exam_name(
550
+ api_key, parsed, [Path(f).name for f in files]
551
+ )
552
+ stored = dict(stored)
553
+ stored[final_name] = parsed
554
+
555
+ # 處理圖片路徑(從 PDF 轉換的圖片或原始圖片)
556
+ image_paths = []
557
+ for f in files:
558
+ p = Path(f)
559
+ if p.suffix.lower() == '.pdf':
560
+ # PDF 轉成的圖片會暫存,我們要取得它們
561
+ pdf_images = pdf_to_images(p)
562
+ for idx, img in enumerate(pdf_images):
563
+ temp_path = tempfile.mktemp(suffix=f"_answer_{idx}.png")
564
+ img.save(temp_path)
565
+ image_paths.append(temp_path)
566
+ else:
567
+ image_paths.append(f)
568
+
569
+ # 自動儲存到檔案(包含圖片)
570
+ save_answer_to_file(final_name, parsed, image_paths)
571
+
572
+ # 更新 stored_images
573
+ stored_images = dict(stored_images)
574
+ # 讀取已儲存的圖片路徑
575
+ safe_name = get_safe_name(final_name)
576
+ json_path = ANSWERS_DIR / f"{safe_name}.json"
577
+ if json_path.exists():
578
+ with open(json_path, "r", encoding="utf-8") as f:
579
+ data = json.load(f)
580
+ stored_images[final_name] = data.get("image_paths", [])
581
+
582
+ choices = sorted(stored.keys())
583
+ msg = f"✅ 解答已確認「{final_name}」並已儲存(含原始圖片)。{status}"
584
+ logger.info(f"答案解析完成,名稱: {final_name},圖片數量: {len(stored_images.get(final_name, []))}")
585
+ return (
586
+ parsed,
587
+ msg,
588
+ final_name,
589
+ gr.update(choices=choices, value=final_name, visible=True),
590
+ stored,
591
+ stored_images,
592
+ to_gallery_items(files),
593
+ )
594
+
595
+
596
+ def load_answer_from_storage(selected: str, stored: dict):
597
+ stored = stored or {}
598
+ if not selected or selected not in stored:
599
+ return "", ""
600
+ return stored[selected], selected
601
+
602
+
603
+ def save_manual_edit(selected: str, name_input: str, edited_text: str, stored: dict):
604
+ stored = stored or {}
605
+ if not edited_text.strip():
606
+ return stored, "請先確認答案 JSON 內容再儲存。", gr.update()
607
+ if not (selected or name_input):
608
+ return stored, "請先輸入或選擇考卷名稱,再儲存。", gr.update()
609
+
610
+ final_name = (name_input or selected or "未命名考卷").strip()
611
+ stored = dict(stored)
612
+ stored[final_name] = edited_text.strip()
613
+
614
+ # 儲存到檔案
615
+ save_answer_to_file(final_name, edited_text.strip())
616
+
617
+ choices = sorted(stored.keys())
618
+ status = f"✅ 已儲存「{final_name}」的修正版本至檔案。"
619
+ return stored, status, gr.update(choices=choices, value=final_name)
620
+
621
+
622
+ def delete_answer_from_storage(selected: str, stored: dict):
623
+ """刪除選中的答案卷"""
624
+ stored = stored or {}
625
+ if not selected:
626
+ return stored, "請先選擇要刪除的答案卷。", gr.update(), "", ""
627
+
628
+ stored = dict(stored)
629
+ if selected in stored:
630
+ del stored[selected]
631
+
632
+ # 從檔案系統刪除
633
+ delete_answer_file(selected)
634
+
635
+ choices = sorted(stored.keys())
636
+ status = f"🗑️ 已刪除「{selected}」。"
637
+ return stored, status, gr.update(choices=choices, value=None), "", ""
638
+
639
+
640
+ def grade_exam_from_storage(
641
+ api_key: str,
642
+ selected_exam: str,
643
+ stored: dict,
644
+ stored_images: dict,
645
+ files: List[str],
646
+ ):
647
+ """執行批改並返回結果,同時傳遞答案卷圖片"""
648
+ logger.info(f"grade_exam_from_storage 開始執行")
649
+ logger.info(f"選中的考卷: {selected_exam}")
650
+ logger.info(f"stored keys: {list(stored.keys()) if stored else 'None'}")
651
+ logger.info(f"學生考卷檔案數量: {len(files) if files else 0}")
652
+
653
+ stored = stored or {}
654
+ stored_images = stored_images or {}
655
+ if not selected_exam:
656
+ logger.warning("沒有選擇考卷")
657
+ return "", [], "請先選擇一份已解析的答案卷。"
658
+ answer_text = stored.get(selected_exam, "")
659
+ if not answer_text:
660
+ logger.warning(f"找不到 {selected_exam} 的內容")
661
+ return "", [], "找不到這份答案卷的內容,請重新上傳或選擇其他考卷。"
662
+
663
+ # 取得答案卷的原始圖片
664
+ answer_image_paths = stored_images.get(selected_exam, [])
665
+ logger.info(f"開始批改,答案長度: {len(answer_text)},答案卷圖片數量: {len(answer_image_paths)}")
666
+
667
+ return grade_exam_with_image(api_key, answer_text, answer_image_paths, files)
668
+
669
+
670
+ def update_gallery(files: List[str]):
671
+ return to_gallery_items(files or [])
672
+
673
+
674
+ def update_graded_gallery(images: List[str]):
675
+ """更新批改結果的圖片展示"""
676
+ if not images:
677
+ return []
678
+ return [(img, f"批改結果 {idx+1}") for idx, img in enumerate(images)]
679
+
680
+
681
+ def clear_api_key():
682
+ """移除 API Key"""
683
+ return "", "🗑️ API Key 已移除"
684
+
685
+
686
+ CUSTOM_CSS = """
687
+ .card {
688
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
689
+ border-radius: 16px;
690
+ border: 1px solid #e2e8f0;
691
+ padding: 20px;
692
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
693
+ margin-bottom: 16px;
694
+ }
695
+ .primary-button {
696
+ font-weight: 700;
697
+ height: 52px;
698
+ font-size: 16px;
699
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
700
+ border: none;
701
+ border-radius: 12px;
702
+ }
703
+ .primary-button:hover {
704
+ background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
705
+ }
706
+ .section-title {
707
+ font-size: 18px;
708
+ font-weight: 700;
709
+ color: #1e293b;
710
+ margin-bottom: 12px;
711
+ }
712
+ .status-ok {
713
+ color: #16a34a;
714
+ font-weight: 600;
715
+ background: #dcfce7;
716
+ padding: 8px 16px;
717
+ border-radius: 8px;
718
+ display: inline-block;
719
+ }
720
+ .status-warn {
721
+ color: #ea580c;
722
+ font-weight: 600;
723
+ background: #fff7ed;
724
+ padding: 8px 16px;
725
+ border-radius: 8px;
726
+ }
727
+ .api-key-card {
728
+ background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
729
+ border: 1px solid #86efac;
730
+ }
731
+ .header-title {
732
+ text-align: center;
733
+ font-size: 28px;
734
+ font-weight: 800;
735
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
736
+ -webkit-background-clip: text;
737
+ -webkit-text-fill-color: transparent;
738
+ margin-bottom: 8px;
739
+ }
740
+ .header-subtitle {
741
+ text-align: center;
742
+ color: #64748b;
743
+ font-size: 16px;
744
+ margin-bottom: 24px;
745
+ }
746
+ .feedback-box {
747
+ background: #fffbeb;
748
+ border: 1px solid #fcd34d;
749
+ border-radius: 12px;
750
+ padding: 16px;
751
+ font-size: 15px;
752
+ line-height: 1.8;
753
+ }
754
+ """
755
+
756
+
757
+ def build_demo() -> gr.Blocks:
758
+ # 啟動時載入已儲存的答案卷(包含圖片路徑)
759
+ initial_answers, initial_images = load_saved_answers()
760
+ initial_choices = sorted(initial_answers.keys())
761
+
762
+ with gr.Blocks(title="老師閱卷助手") as demo:
763
+ # 注入 CSS
764
+ gr.HTML(f"<style>{CUSTOM_CSS}</style>")
765
+ stored_answers = gr.State(initial_answers)
766
+ stored_answer_images = gr.State(initial_images) # 新增:儲存答案卷圖片路徑
767
+
768
+ gr.HTML("""
769
+ <div style="text-align: center; padding: 20px 0;">
770
+ <h1 class="header-title">🎓 AI 智慧閱卷助手</h1>
771
+ <p class="header-subtitle">上傳解答與考卷,讓 AI 為您自動批改與評分</p>
772
+ </div>
773
+ """)
774
+
775
+ # Step 1: API Key
776
+ with gr.Group(elem_classes="card api-key-card"):
777
+ gr.Markdown("### 🔑 Gemini API Key", elem_classes="section-title")
778
+ with gr.Row():
779
+ api_key = gr.Textbox(
780
+ placeholder="請輸入您的 Gemini API Key",
781
+ type="password",
782
+ label="",
783
+ value=DEFAULT_API_KEY,
784
+ scale=4,
785
+ )
786
+ clear_key_btn = gr.Button("🗑️ 移除", scale=1, variant="secondary")
787
+ api_status = gr.Markdown(
788
+ "● API Key 已儲存" if DEFAULT_API_KEY else "請輸入 API Key",
789
+ elem_classes="status-ok" if DEFAULT_API_KEY else ""
790
+ )
791
+ gr.Markdown(
792
+ "💡 您的 API Key 僅會儲存在瀏覽器中,不會傳送至任何伺服器。",
793
+ elem_classes="status-warn"
794
+ )
795
+
796
+ # Step 2: 答案卷管理
797
+ with gr.Group(elem_classes="card"):
798
+ gr.Markdown("### 📝 答案卷管理", elem_classes="section-title")
799
+
800
+ # 已儲存的答案卷選擇器(顯示在最上方)
801
+ with gr.Row():
802
+ exam_picker = gr.Dropdown(
803
+ label="📂 已儲存的答案卷",
804
+ choices=initial_choices,
805
+ value=initial_choices[0] if initial_choices else None,
806
+ interactive=True,
807
+ visible=True,
808
+ scale=3,
809
+ )
810
+ delete_answer_btn = gr.Button("🗑️ 刪除", variant="secondary", scale=1)
811
+
812
+ with gr.Row():
813
+ with gr.Column(scale=2):
814
+ exam_name = gr.Textbox(
815
+ label="考卷名稱(可留空讓 AI 協助命名)",
816
+ placeholder="例:四年級下學期數學單元評量",
817
+ )
818
+ answer_upload = gr.File(
819
+ label="上傳新的答案卷 (PDF / 圖片,可多檔)",
820
+ file_count="multiple",
821
+ file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp"],
822
+ type="filepath",
823
+ )
824
+ with gr.Row():
825
+ modify_answer_btn = gr.Button("📝 修改解答", variant="secondary")
826
+ save_button = gr.Button("💾 儲存修正", variant="secondary")
827
+ with gr.Column(scale=3):
828
+ answer_gallery = gr.Gallery(
829
+ label="答案卷預覽",
830
+ show_label=True,
831
+ height=200,
832
+ columns=4,
833
+ )
834
+ answer_status = gr.Markdown("")
835
+ answer_preview = gr.Textbox(
836
+ label="標準答案 (老師可在此微調)",
837
+ lines=8,
838
+ placeholder="上傳答案卷後,AI 會自動解析答案...",
839
+ visible=False,
840
+ )
841
+
842
+ # Step 3: 學生考卷上傳
843
+ with gr.Group(elem_classes="card"):
844
+ gr.Markdown("### 📷 拍攝/上傳學生考卷", elem_classes="section-title")
845
+ with gr.Row():
846
+ student_upload = gr.File(
847
+ label="上傳學生考卷 (可多頁拍攝)",
848
+ file_count="multiple",
849
+ file_types=[".png", ".jpg", ".jpeg", ".webp"],
850
+ type="filepath",
851
+ )
852
+ student_gallery = gr.Gallery(
853
+ label="考卷頁面預覽",
854
+ show_label=True,
855
+ height=280,
856
+ columns=4,
857
+ )
858
+ gr.Markdown("💡 支援多頁拍攝。請確保光線充足、字跡清晰。", elem_classes="status-warn")
859
+
860
+ # 批改設定區塊
861
+ with gr.Group(elem_classes="card"):
862
+ gr.Markdown("### ⚙️ 批改設定", elem_classes="section-title")
863
+ with gr.Row():
864
+ grading_exam_picker = gr.Dropdown(
865
+ label="🎯 選擇要使用的答案卷(批改依據)",
866
+ choices=initial_choices,
867
+ value=initial_choices[0] if initial_choices else None,
868
+ interactive=True,
869
+ scale=3,
870
+ )
871
+ refresh_answers_btn = gr.Button("🔄 重新整理", variant="secondary", scale=1)
872
+
873
+ gr.Markdown("⚠️ **請確認已選擇正確的答案卷再開始批改!**", elem_classes="status-warn")
874
+
875
+ # 批改按鈕
876
+ grade_button = gr.Button(
877
+ "🚀 開始批改考卷",
878
+ variant="primary",
879
+ elem_classes="primary-button",
880
+ size="lg",
881
+ )
882
+
883
+ # Step 4: 批改結果
884
+ with gr.Group(elem_classes="card"):
885
+ gr.Markdown("### 📊 批改結果", elem_classes="section-title")
886
+
887
+ with gr.Row():
888
+ with gr.Column(scale=1):
889
+ graded_gallery = gr.Gallery(
890
+ label="批改後的考卷",
891
+ show_label=True,
892
+ height=400,
893
+ columns=2,
894
+ )
895
+ with gr.Column(scale=1):
896
+ grade_feedback = gr.Markdown(
897
+ value="*批改結果將顯示在這裡...*",
898
+ elem_classes="feedback-box",
899
+ )
900
+
901
+ grade_status = gr.Markdown("")
902
+
903
+ # Event handlers
904
+ clear_key_btn.click(
905
+ fn=clear_api_key,
906
+ outputs=[api_key, api_status],
907
+ )
908
+
909
+ modify_answer_btn.click(
910
+ fn=lambda: gr.update(visible=True),
911
+ outputs=[answer_preview],
912
+ )
913
+
914
+ # 上傳答案卷後,同時更新兩個選擇器
915
+ def on_answer_uploaded(api_key, files, name, stored, stored_images):
916
+ result = parse_and_store_answer(api_key, files, name, stored, stored_images)
917
+ # result: (preview, status, name, picker_update, stored, stored_images, gallery)
918
+ # 需要同時更新 exam_picker 和 grading_exam_picker
919
+ preview, status, final_name, picker_update, new_stored, new_images, gallery = result
920
+ choices = sorted(new_stored.keys()) if new_stored else []
921
+ return (
922
+ preview,
923
+ status,
924
+ final_name,
925
+ gr.update(choices=choices, value=final_name), # exam_picker
926
+ gr.update(choices=choices, value=final_name), # grading_exam_picker
927
+ new_stored,
928
+ new_images,
929
+ gallery
930
+ )
931
+
932
+ answer_upload.upload(
933
+ fn=on_answer_uploaded,
934
+ inputs=[api_key, answer_upload, exam_name, stored_answers, stored_answer_images],
935
+ outputs=[answer_preview, answer_status, exam_name, exam_picker, grading_exam_picker, stored_answers, stored_answer_images, answer_gallery],
936
+ )
937
+
938
+ # 當答案卷管理的選擇器變更時,同步更新批改選擇器
939
+ def sync_pickers(selected, stored):
940
+ content, name = load_answer_from_storage(selected, stored)
941
+ return content, name, gr.update(value=selected)
942
+
943
+ exam_picker.change(
944
+ fn=sync_pickers,
945
+ inputs=[exam_picker, stored_answers],
946
+ outputs=[answer_preview, exam_name, grading_exam_picker],
947
+ )
948
+
949
+ # 當批改選擇器變更時,同步更新答案卷管理的選擇器
950
+ grading_exam_picker.change(
951
+ fn=lambda selected: gr.update(value=selected),
952
+ inputs=[grading_exam_picker],
953
+ outputs=[exam_picker],
954
+ )
955
+
956
+ # 重新整理按鈕
957
+ def refresh_answer_choices(stored):
958
+ choices = sorted(stored.keys()) if stored else []
959
+ return gr.update(choices=choices), gr.update(choices=choices)
960
+
961
+ refresh_answers_btn.click(
962
+ fn=refresh_answer_choices,
963
+ inputs=[stored_answers],
964
+ outputs=[exam_picker, grading_exam_picker],
965
+ )
966
+
967
+ save_button.click(
968
+ fn=save_manual_edit,
969
+ inputs=[exam_picker, exam_name, answer_preview, stored_answers],
970
+ outputs=[stored_answers, answer_status, exam_picker],
971
+ )
972
+
973
+ # 刪除答案卷按鈕 - 同時更新兩個選擇器
974
+ def on_delete_answer(selected, stored):
975
+ new_stored, status, picker_update, preview, name = delete_answer_from_storage(selected, stored)
976
+ choices = sorted(new_stored.keys()) if new_stored else []
977
+ return (
978
+ new_stored,
979
+ status,
980
+ gr.update(choices=choices, value=None), # exam_picker
981
+ gr.update(choices=choices, value=None), # grading_exam_picker
982
+ preview,
983
+ name
984
+ )
985
+
986
+ delete_answer_btn.click(
987
+ fn=on_delete_answer,
988
+ inputs=[exam_picker, stored_answers],
989
+ outputs=[stored_answers, answer_status, exam_picker, grading_exam_picker, answer_preview, exam_name],
990
+ )
991
+
992
+ student_upload.upload(
993
+ fn=update_gallery,
994
+ inputs=[student_upload],
995
+ outputs=[student_gallery],
996
+ )
997
+
998
+ def do_grading(api_key, grading_picker, stored, stored_images, files):
999
+ logger.info(f"do_grading 被呼叫,grading_picker={grading_picker}")
1000
+ logger.info(f"stored_images keys: {list(stored_images.keys()) if stored_images else 'None'}")
1001
+ if not grading_picker:
1002
+ return "**❌ 請先選擇要使用的答案卷!**", [], "請在「批改設定」中選擇答案卷"
1003
+ feedback, images, status = grade_exam_from_storage(api_key, grading_picker, stored, stored_images, files)
1004
+ gallery_items = update_graded_gallery(images)
1005
+ return feedback, gallery_items, status
1006
+
1007
+ grade_button.click(
1008
+ fn=do_grading,
1009
+ inputs=[api_key, grading_exam_picker, stored_answers, stored_answer_images, student_upload],
1010
+ outputs=[grade_feedback, graded_gallery, grade_status],
1011
+ )
1012
+
1013
+ return demo
1014
+
1015
+
1016
+ if __name__ == "__main__":
1017
+ build_demo().launch()
env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # AISTUDIO Gemini API Key
2
+ GEMINI_API_KEY=your_api_key_here
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ google-genai>=1.0.0
3
+ pypdf>=5.0.0
4
+ Pillow>=10.4.0
5
+ python-dotenv>=1.0.1
6
+ # 選用:PDF 轉圖片(二選一即可)
7
+ # pdf2image>=1.16.0 # 需要安裝 poppler
8
+ pypdfium2>=4.0.0