Spaces:
Sleeping
Sleeping
Commit
·
e193cbc
1
Parent(s):
402a461
- README.md +176 -0
- app.py +1017 -0
- env.example +2 -0
- 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 |
+

|
| 23 |
+

|
| 24 |
+

|
| 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
|