hg-grade-exam / app.py
tbdavid2019's picture
1
e193cbc
import base64
import io
import json
import logging
import mimetypes
import os
import shutil
import tempfile
import traceback
from datetime import datetime
from pathlib import Path
from typing import List, Tuple
import gradio as gr
from dotenv import load_dotenv
from google import genai
from google.genai import types
from PIL import Image
from pypdf import PdfReader
# 設定 logging
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
load_dotenv()
# 文字理解與分析使用 gemini-2.5-flash
TEXT_MODEL = "gemini-2.5-flash"
# 圖片生成使用 gemini-3-pro-image-preview
IMAGE_MODEL = "gemini-3-pro-image-preview"
DEFAULT_API_KEY = os.getenv("GEMINI_API_KEY", "")
# 答案卷儲存路徑
ANSWERS_DIR = Path(__file__).parent / "data" / "answers"
ANSWERS_DIR.mkdir(parents=True, exist_ok=True)
# 答案卷圖片儲存路徑
ANSWER_IMAGES_DIR = Path(__file__).parent / "data" / "answer_images"
ANSWER_IMAGES_DIR.mkdir(parents=True, exist_ok=True)
def get_safe_name(name: str) -> str:
"""產生安全的檔名"""
safe_name = "".join(c for c in name if c.isalnum() or c in " _-").strip()
if not safe_name:
safe_name = datetime.now().strftime("%Y%m%d_%H%M%S")
return safe_name
def load_saved_answers() -> Tuple[dict, dict]:
"""從檔案系統載入已儲存的答案卷,返回 (文字內容, 圖片路徑列表)"""
answers = {}
answer_images = {}
try:
for json_file in ANSWERS_DIR.glob("*.json"):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
name = data.get("name", json_file.stem)
answers[name] = data.get("content", "")
# 載入對應的圖片路徑
image_paths = data.get("image_paths", [])
if image_paths:
answer_images[name] = image_paths
except Exception as e:
logger.warning(f"載入答案卷失敗 {json_file}: {e}")
except Exception as e:
logger.error(f"掃描答案卷目錄失敗: {e}")
return answers, answer_images
def save_answer_to_file(name: str, content: str, image_paths: List[str] = None) -> bool:
"""儲存答案卷到檔案,包含原始圖片"""
try:
safe_name = get_safe_name(name)
# 儲存圖片到專用目錄
saved_image_paths = []
if image_paths:
answer_img_dir = ANSWER_IMAGES_DIR / safe_name
answer_img_dir.mkdir(parents=True, exist_ok=True)
for idx, img_path in enumerate(image_paths):
src_path = Path(img_path)
if src_path.exists():
# 複製圖片到永久目錄
ext = src_path.suffix or ".png"
dest_path = answer_img_dir / f"page_{idx+1}{ext}"
# 複製檔案
shutil.copy2(src_path, dest_path)
saved_image_paths.append(str(dest_path))
logger.info(f"答案卷圖片已儲存: {dest_path}")
file_path = ANSWERS_DIR / f"{safe_name}.json"
data = {
"name": name,
"content": content,
"image_paths": saved_image_paths,
"updated_at": datetime.now().isoformat(),
}
with open(file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"答案卷已儲存: {file_path}")
return True
except Exception as e:
logger.error(f"儲存答案卷失敗: {e}")
logger.error(traceback.format_exc())
return False
def delete_answer_file(name: str) -> bool:
"""刪除答案卷檔案和圖片"""
try:
safe_name = get_safe_name(name)
# 刪除 JSON 檔案
file_path = ANSWERS_DIR / f"{safe_name}.json"
if file_path.exists():
file_path.unlink()
logger.info(f"答案卷已刪除: {file_path}")
# 刪除圖片目錄
img_dir = ANSWER_IMAGES_DIR / safe_name
if img_dir.exists():
shutil.rmtree(img_dir)
logger.info(f"答案卷圖片目錄已刪除: {img_dir}")
# 嘗試找到匹配的檔案(以 name 欄位匹配)
for json_file in ANSWERS_DIR.glob("*.json"):
try:
with open(json_file, "r", encoding="utf-8") as f:
data = json.load(f)
if data.get("name") == name:
json_file.unlink()
logger.info(f"答案卷已刪除: {json_file}")
return True
except:
pass
return True
except Exception as e:
logger.error(f"刪除答案卷失敗: {e}")
return False
def get_client(api_key: str) -> genai.Client:
"""建立 Gemini Client"""
key = api_key.strip() or DEFAULT_API_KEY
if not key:
raise ValueError("請先輸入 Gemini API Key,或在 .env 設定 GEMINI_API_KEY。")
return genai.Client(api_key=key)
def load_image_from_path(path: Path) -> Image.Image:
"""從路徑載入 PIL Image"""
return Image.open(path)
def extract_pdf_text(path: Path) -> str:
"""Lightweight PDF text extraction"""
try:
reader = PdfReader(path)
text = "\n\n".join((page.extract_text() or "") for page in reader.pages)
return text.strip()
except Exception:
return ""
def pdf_to_images(path: Path) -> List[Image.Image]:
"""將 PDF 轉換為圖片列表(使用 pdf2image 或 pypdfium2)"""
try:
import pdf2image
images = pdf2image.convert_from_path(path)
return images
except ImportError:
pass
try:
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument(path)
images = []
for page in pdf:
bitmap = page.render(scale=2)
pil_image = bitmap.to_pil()
images.append(pil_image)
return images
except ImportError:
pass
return []
def build_file_contents(paths: List[Path]) -> Tuple[List, List[str]]:
"""
建立要傳給 Gemini 的內容列表(文字與圖片)
返回 (contents, warnings)
"""
contents: List = []
warnings: List[str] = []
for path in paths:
suffix = path.suffix.lower()
if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
img = load_image_from_path(path)
contents.append(img)
elif suffix == ".pdf":
# 嘗試將 PDF 轉為圖片
pdf_images = pdf_to_images(path)
if pdf_images:
contents.extend(pdf_images)
else:
# 如果無法轉圖,至少提取文字
pdf_text = extract_pdf_text(path)
if pdf_text:
contents.append(f"[PDF {path.name} 文字]\n{pdf_text}")
else:
warnings.append(f"{path.name} 無法解析,建議轉成圖片上傳。")
else:
warnings.append(f"{path.name} 格式不支援,已略過。")
return contents, warnings
def to_gallery_items(paths: List[str]) -> List[tuple]:
"""Convert file paths to gallery tuples (path, caption)."""
items: List[tuple] = []
for idx, p in enumerate(paths, start=1):
items.append((p, f"第 {idx} 頁"))
return items
def parse_answer_key(api_key: str, files: List[str]) -> Tuple[str, str]:
"""解析答案卷,輸出 JSON 格式的標準答案"""
logger.info(f"=== parse_answer_key 開始 ===")
logger.info(f"files: {files}")
logger.info(f"api_key 長度: {len(api_key) if api_key else 0}")
if not files:
logger.warning("沒有上傳檔案")
return "", "請先上傳答案卷 (PDF / 圖片)。"
paths = [Path(f) for f in files]
logger.info(f"paths: {paths}")
file_contents, warnings = build_file_contents(paths)
logger.info(f"file_contents 數量: {len(file_contents)}")
logger.info(f"file_contents 類型: {[type(c).__name__ for c in file_contents]}")
logger.info(f"warnings: {warnings}")
if not file_contents:
logger.error("無法解析上傳的檔案")
return "", "無法解析上傳的檔案,請確認格式正確。"
prompt = """
你是一名考卷答案解析助手。請仔細辨識這份解答卷中的所有題目,包括:
- 選擇題、填充題、計算題、應用題等
- 數學公式、手寫答案
請抽取題號、標準答案與每題分數,輸出 JSON 陣列:
[
{"question_number": "一、1", "answer": "標準答案", "points": 5},
{"question_number": "二、1", "answer": "數學公式或步驟", "points": 10},
...
]
規則:
1. 保持題號原有格式與排序
2. 數學公式用 LaTeX 表示
3. 若缺少分數資訊,依題目難度合理估計
4. 只輸出 JSON,不要其他說明
"""
try:
logger.info(f"正在建立 Gemini Client...")
client = get_client(api_key)
logger.info(f"Client 建立成功,準備呼叫 API")
logger.info(f"使用模型: {TEXT_MODEL}")
logger.info(f"內容數量: {len([prompt] + file_contents)}")
response = client.models.generate_content(
model=TEXT_MODEL,
contents=[prompt] + file_contents,
config=types.GenerateContentConfig(
temperature=0.2,
)
)
logger.info(f"API 回應成功")
logger.info(f"response.parts 數量: {len(response.parts) if response.parts else 0}")
result_text = ""
for part in response.parts:
if part.text:
result_text += part.text
logger.debug(f"取得文字長度: {len(part.text)}")
logger.info(f"解析結果長度: {len(result_text)}")
warning_text = ";".join(warnings) if warnings else "已自動解析,老師可在下方微調後直接使用。"
return result_text.strip(), warning_text
except Exception as exc:
logger.error(f"解析失敗: {exc}")
logger.error(traceback.format_exc())
return "", f"解析失敗:{exc}"
def grade_exam_with_image(
api_key: str,
answer_text: str,
answer_image_paths: List[str],
student_files: List[str],
generate_teacher_feedback: bool = True,
) -> Tuple[str, List[str], str]:
"""
批改考卷並生成帶有批改標記的圖片
Args:
api_key: Gemini API Key
answer_text: 解析後的答案 JSON 文字
answer_image_paths: 原始答案卷圖片路徑列表
student_files: 學生考卷圖片路徑列表
返回 (評語文字, 批改圖片路徑列表, 狀態訊息)
"""
if not answer_text.strip() and not answer_image_paths:
return "", [], "請先選擇或建立一份答案卷,再上傳學生考卷。"
if not student_files:
return "", [], "請上傳學生考卷圖片 (建議使用清晰的 PNG/JPG)。"
# 載入學生考卷圖片
student_paths = [Path(f) for f in student_files]
student_contents, warnings = build_file_contents(student_paths)
if not student_contents:
return "", [], "無法解析上傳的學生考卷。"
# 載入答案卷圖片
answer_images = []
for img_path in answer_image_paths:
p = Path(img_path)
if p.exists():
try:
img = Image.open(p)
answer_images.append(img)
logger.info(f"載入答案卷圖片: {p}")
except Exception as e:
logger.warning(f"無法載入答案卷圖片 {p}: {e}")
logger.info(f"答案卷圖片數量: {len(answer_images)}, 學生考卷數量: {len(student_contents)}")
try:
client = get_client(api_key)
# 使用圖片生成模型,同時輸出批改圖片和文字結果
# 這樣可以確保圖片標記和文字結果一致
graded_images = []
all_feedback_parts = []
for idx, student_img in enumerate(student_contents):
if not isinstance(student_img, Image.Image):
continue
# 組合批改提示 - 要求同時輸出文字結果和批改圖片
grading_prompt = f"""你是一位經驗豐富、嚴謹的老師。請仔細批改這張學生考卷。
【重要】請嚴格按照標準答案進行批改,不要自行判斷答案!
我會提供:
1. 標準答案卷圖片(包含所有正確答案,可能有手寫內容)
2. 解析後的答案 JSON(供參考)
3. 學生考卷圖片
請完成以下兩件事:
【任務一】輸出批改結果文字:
請用以下格式輸出第 {idx+1} 頁的批改結果:
### 第 {idx+1} 頁批改結果
| 題號 | 學生答案 | 正確答案 | 得分 | 滿分 | 評語 |
|------|----------|----------|------|------|------|
| 1 | (學生寫的) | (正確答案) | X | X | 正確/錯誤原因 |
...
**本頁得分:XX / XX 分**
【任務二】生成批改後的考卷圖片:
在學生考卷圖片上標記:
- ✓ 綠色打勾:答案正確
- ✗ 紅色打叉:答案錯誤,旁邊寫正確答案
- 每題標註得分
- 底部寫總分
---
=== 以下是【標準答案卷圖片】(以此為準!)===
"""
contents = [grading_prompt]
# 加入答案卷圖片
if answer_images:
contents.extend(answer_images)
# 加入 JSON 答案作為補充
if answer_text.strip():
contents.append(f"\n=== 以下是【解析後的答案 JSON】(輔助參考)===\n{answer_text.strip()}\n")
# 加入學生考卷
contents.append("=== 以下是【學生考卷圖片】(請批改這張)===")
contents.append(student_img)
logger.info(f"批改第 {idx+1} 頁,傳送內容數量: {len(contents)}")
try:
response = client.models.generate_content(
model=IMAGE_MODEL,
contents=contents,
config=types.GenerateContentConfig(
response_modalities=['TEXT', 'IMAGE'],
image_config=types.ImageConfig(
aspect_ratio="3:4",
image_size="2K",
),
temperature=0.2,
)
)
# 從回應中提取文字和圖片
page_feedback = ""
for part in response.parts:
if hasattr(part, 'text') and part.text:
page_feedback += part.text
if hasattr(part, 'inline_data') and part.inline_data:
img = part.as_image()
temp_path = tempfile.mktemp(suffix=f"_graded_{idx+1}.png")
img.save(temp_path)
graded_images.append(temp_path)
logger.info(f"批改圖片已生成: {temp_path}")
if page_feedback:
all_feedback_parts.append(page_feedback)
logger.info(f"第 {idx+1} 頁批改文字長度: {len(page_feedback)}")
except Exception as img_exc:
logger.error(f"第 {idx+1} 頁批改失敗: {img_exc}")
warnings.append(f"第 {idx+1} 頁批改失敗:{img_exc}")
# 組合所有頁面的批改結果
combined_feedback = "\n\n".join(all_feedback_parts)
# 如果有多頁,再生成總結評語
if len(student_contents) > 0 and combined_feedback:
try:
summary_prompt = f"""根據以下批改結果,請以溫暖關懷的語氣寫一段「老師的評語」:
{combined_feedback}
請用以下格式:
---
## 老師的評語
親愛的同學:
(在這裡寫出具體的回饋,包括:
- 肯定做得好的部分
- 指出需要加強的觀念
- 給予鼓勵)
"""
summary_response = client.models.generate_content(
model=TEXT_MODEL,
contents=[summary_prompt],
config=types.GenerateContentConfig(temperature=0.4)
)
teacher_comment = ""
for part in summary_response.parts:
if part.text:
teacher_comment += part.text
if teacher_comment:
combined_feedback += "\n\n" + teacher_comment
except Exception as e:
logger.warning(f"生成老師評語失敗: {e}")
warning_text = ";".join(warnings) if warnings else "批改完成!"
return combined_feedback, graded_images, warning_text
except Exception as exc:
logger.error(f"批改失敗: {exc}")
logger.error(traceback.format_exc())
return "", [], f"批改失敗:{exc}"
def guess_exam_name(api_key: str, answer_text: str, file_names: List[str]) -> str:
"""Try to produce a friendly exam name; fallback to file stem."""
fallback = Path(file_names[0]).stem if file_names else "未命名考卷"
if not answer_text.strip():
return fallback
prompt = f"""請幫老師為這份考卷取一個簡短的中文名稱。
規則:
- 長度必須在 6-16 字之間
- 只回傳名稱本身,不要加引號、標點符號或任何說明
- 範例:「四年級數學期中考」、「五上自然第三單元」
可參考的答案內容:
{answer_text[:800]}
請直接輸出名稱(6-16字):"""
try:
client = get_client(api_key)
response = client.models.generate_content(
model=TEXT_MODEL,
contents=[prompt],
config=types.GenerateContentConfig(
temperature=0.2,
max_output_tokens=30, # 限制輸出長度
)
)
name = ""
for part in response.parts:
if part.text:
name += part.text
cleaned = name.replace("\n", "").replace("「", "").replace("」", "").replace('"', '').replace("'", "").strip()
# 強制限制長度
if len(cleaned) > 20:
cleaned = cleaned[:20]
return cleaned or fallback
except Exception:
return fallback
def parse_and_store_answer(
api_key: str,
files: List[str],
provided_name: str,
stored: dict,
stored_images: dict,
):
logger.info(f"parse_and_store_answer 開始執行,檔案數量: {len(files) if files else 0}")
stored = stored or {}
stored_images = stored_images or {}
parsed, status = parse_answer_key(api_key, files)
if not parsed:
return "", status, provided_name, gr.update(choices=list(stored.keys()), value=None, visible=True), stored, stored_images, to_gallery_items(files)
final_name = provided_name.strip() if provided_name and provided_name.strip() else guess_exam_name(
api_key, parsed, [Path(f).name for f in files]
)
stored = dict(stored)
stored[final_name] = parsed
# 處理圖片路徑(從 PDF 轉換的圖片或原始圖片)
image_paths = []
for f in files:
p = Path(f)
if p.suffix.lower() == '.pdf':
# PDF 轉成的圖片會暫存,我們要取得它們
pdf_images = pdf_to_images(p)
for idx, img in enumerate(pdf_images):
temp_path = tempfile.mktemp(suffix=f"_answer_{idx}.png")
img.save(temp_path)
image_paths.append(temp_path)
else:
image_paths.append(f)
# 自動儲存到檔案(包含圖片)
save_answer_to_file(final_name, parsed, image_paths)
# 更新 stored_images
stored_images = dict(stored_images)
# 讀取已儲存的圖片路徑
safe_name = get_safe_name(final_name)
json_path = ANSWERS_DIR / f"{safe_name}.json"
if json_path.exists():
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
stored_images[final_name] = data.get("image_paths", [])
choices = sorted(stored.keys())
msg = f"✅ 解答已確認「{final_name}」並已儲存(含原始圖片)。{status}"
logger.info(f"答案解析完成,名稱: {final_name},圖片數量: {len(stored_images.get(final_name, []))}")
return (
parsed,
msg,
final_name,
gr.update(choices=choices, value=final_name, visible=True),
stored,
stored_images,
to_gallery_items(files),
)
def load_answer_from_storage(selected: str, stored: dict):
stored = stored or {}
if not selected or selected not in stored:
return "", ""
return stored[selected], selected
def save_manual_edit(selected: str, name_input: str, edited_text: str, stored: dict):
stored = stored or {}
if not edited_text.strip():
return stored, "請先確認答案 JSON 內容再儲存。", gr.update()
if not (selected or name_input):
return stored, "請先輸入或選擇考卷名稱,再儲存。", gr.update()
final_name = (name_input or selected or "未命名考卷").strip()
stored = dict(stored)
stored[final_name] = edited_text.strip()
# 儲存到檔案
save_answer_to_file(final_name, edited_text.strip())
choices = sorted(stored.keys())
status = f"✅ 已儲存「{final_name}」的修正版本至檔案。"
return stored, status, gr.update(choices=choices, value=final_name)
def delete_answer_from_storage(selected: str, stored: dict):
"""刪除選中的答案卷"""
stored = stored or {}
if not selected:
return stored, "請先選擇要刪除的答案卷。", gr.update(), "", ""
stored = dict(stored)
if selected in stored:
del stored[selected]
# 從檔案系統刪除
delete_answer_file(selected)
choices = sorted(stored.keys())
status = f"🗑️ 已刪除「{selected}」。"
return stored, status, gr.update(choices=choices, value=None), "", ""
def grade_exam_from_storage(
api_key: str,
selected_exam: str,
stored: dict,
stored_images: dict,
files: List[str],
):
"""執行批改並返回結果,同時傳遞答案卷圖片"""
logger.info(f"grade_exam_from_storage 開始執行")
logger.info(f"選中的考卷: {selected_exam}")
logger.info(f"stored keys: {list(stored.keys()) if stored else 'None'}")
logger.info(f"學生考卷檔案數量: {len(files) if files else 0}")
stored = stored or {}
stored_images = stored_images or {}
if not selected_exam:
logger.warning("沒有選擇考卷")
return "", [], "請先選擇一份已解析的答案卷。"
answer_text = stored.get(selected_exam, "")
if not answer_text:
logger.warning(f"找不到 {selected_exam} 的內容")
return "", [], "找不到這份答案卷的內容,請重新上傳或選擇其他考卷。"
# 取得答案卷的原始圖片
answer_image_paths = stored_images.get(selected_exam, [])
logger.info(f"開始批改,答案長度: {len(answer_text)},答案卷圖片數量: {len(answer_image_paths)}")
return grade_exam_with_image(api_key, answer_text, answer_image_paths, files)
def update_gallery(files: List[str]):
return to_gallery_items(files or [])
def update_graded_gallery(images: List[str]):
"""更新批改結果的圖片展示"""
if not images:
return []
return [(img, f"批改結果 {idx+1}") for idx, img in enumerate(images)]
def clear_api_key():
"""移除 API Key"""
return "", "🗑️ API Key 已移除"
CUSTOM_CSS = """
.card {
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-radius: 16px;
border: 1px solid #e2e8f0;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
margin-bottom: 16px;
}
.primary-button {
font-weight: 700;
height: 52px;
font-size: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border: none;
border-radius: 12px;
}
.primary-button:hover {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
}
.section-title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
margin-bottom: 12px;
}
.status-ok {
color: #16a34a;
font-weight: 600;
background: #dcfce7;
padding: 8px 16px;
border-radius: 8px;
display: inline-block;
}
.status-warn {
color: #ea580c;
font-weight: 600;
background: #fff7ed;
padding: 8px 16px;
border-radius: 8px;
}
.api-key-card {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
border: 1px solid #86efac;
}
.header-title {
text-align: center;
font-size: 28px;
font-weight: 800;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 8px;
}
.header-subtitle {
text-align: center;
color: #64748b;
font-size: 16px;
margin-bottom: 24px;
}
.feedback-box {
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 12px;
padding: 16px;
font-size: 15px;
line-height: 1.8;
}
"""
def build_demo() -> gr.Blocks:
# 啟動時載入已儲存的答案卷(包含圖片路徑)
initial_answers, initial_images = load_saved_answers()
initial_choices = sorted(initial_answers.keys())
with gr.Blocks(title="老師閱卷助手") as demo:
# 注入 CSS
gr.HTML(f"<style>{CUSTOM_CSS}</style>")
stored_answers = gr.State(initial_answers)
stored_answer_images = gr.State(initial_images) # 新增:儲存答案卷圖片路徑
gr.HTML("""
<div style="text-align: center; padding: 20px 0;">
<h1 class="header-title">🎓 AI 智慧閱卷助手</h1>
<p class="header-subtitle">上傳解答與考卷,讓 AI 為您自動批改與評分</p>
</div>
""")
# Step 1: API Key
with gr.Group(elem_classes="card api-key-card"):
gr.Markdown("### 🔑 Gemini API Key", elem_classes="section-title")
with gr.Row():
api_key = gr.Textbox(
placeholder="請輸入您的 Gemini API Key",
type="password",
label="",
value=DEFAULT_API_KEY,
scale=4,
)
clear_key_btn = gr.Button("🗑️ 移除", scale=1, variant="secondary")
api_status = gr.Markdown(
"● API Key 已儲存" if DEFAULT_API_KEY else "請輸入 API Key",
elem_classes="status-ok" if DEFAULT_API_KEY else ""
)
gr.Markdown(
"💡 您的 API Key 僅會儲存在瀏覽器中,不會傳送至任何伺服器。",
elem_classes="status-warn"
)
# Step 2: 答案卷管理
with gr.Group(elem_classes="card"):
gr.Markdown("### 📝 答案卷管理", elem_classes="section-title")
# 已儲存的答案卷選擇器(顯示在最上方)
with gr.Row():
exam_picker = gr.Dropdown(
label="📂 已儲存的答案卷",
choices=initial_choices,
value=initial_choices[0] if initial_choices else None,
interactive=True,
visible=True,
scale=3,
)
delete_answer_btn = gr.Button("🗑️ 刪除", variant="secondary", scale=1)
with gr.Row():
with gr.Column(scale=2):
exam_name = gr.Textbox(
label="考卷名稱(可留空讓 AI 協助命名)",
placeholder="例:四年級下學期數學單元評量",
)
answer_upload = gr.File(
label="上傳新的答案卷 (PDF / 圖片,可多檔)",
file_count="multiple",
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp"],
type="filepath",
)
with gr.Row():
modify_answer_btn = gr.Button("📝 修改解答", variant="secondary")
save_button = gr.Button("💾 儲存修正", variant="secondary")
with gr.Column(scale=3):
answer_gallery = gr.Gallery(
label="答案卷預覽",
show_label=True,
height=200,
columns=4,
)
answer_status = gr.Markdown("")
answer_preview = gr.Textbox(
label="標準答案 (老師可在此微調)",
lines=8,
placeholder="上傳答案卷後,AI 會自動解析答案...",
visible=False,
)
# Step 3: 學生考卷上傳
with gr.Group(elem_classes="card"):
gr.Markdown("### 📷 拍攝/上傳學生考卷", elem_classes="section-title")
with gr.Row():
student_upload = gr.File(
label="上傳學生考卷 (可多頁拍攝)",
file_count="multiple",
file_types=[".png", ".jpg", ".jpeg", ".webp"],
type="filepath",
)
student_gallery = gr.Gallery(
label="考卷頁面預覽",
show_label=True,
height=280,
columns=4,
)
gr.Markdown("💡 支援多頁拍攝。請確保光線充足、字跡清晰。", elem_classes="status-warn")
# 批改設定區塊
with gr.Group(elem_classes="card"):
gr.Markdown("### ⚙️ 批改設定", elem_classes="section-title")
with gr.Row():
grading_exam_picker = gr.Dropdown(
label="🎯 選擇要使用的答案卷(批改依據)",
choices=initial_choices,
value=initial_choices[0] if initial_choices else None,
interactive=True,
scale=3,
)
refresh_answers_btn = gr.Button("🔄 重新整理", variant="secondary", scale=1)
gr.Markdown("⚠️ **請確認已選擇正確的答案卷再開始批改!**", elem_classes="status-warn")
# 批改按鈕
grade_button = gr.Button(
"🚀 開始批改考卷",
variant="primary",
elem_classes="primary-button",
size="lg",
)
# Step 4: 批改結果
with gr.Group(elem_classes="card"):
gr.Markdown("### 📊 批改結果", elem_classes="section-title")
with gr.Row():
with gr.Column(scale=1):
graded_gallery = gr.Gallery(
label="批改後的考卷",
show_label=True,
height=400,
columns=2,
)
with gr.Column(scale=1):
grade_feedback = gr.Markdown(
value="*批改結果將顯示在這裡...*",
elem_classes="feedback-box",
)
grade_status = gr.Markdown("")
# Event handlers
clear_key_btn.click(
fn=clear_api_key,
outputs=[api_key, api_status],
)
modify_answer_btn.click(
fn=lambda: gr.update(visible=True),
outputs=[answer_preview],
)
# 上傳答案卷後,同時更新兩個選擇器
def on_answer_uploaded(api_key, files, name, stored, stored_images):
result = parse_and_store_answer(api_key, files, name, stored, stored_images)
# result: (preview, status, name, picker_update, stored, stored_images, gallery)
# 需要同時更新 exam_picker 和 grading_exam_picker
preview, status, final_name, picker_update, new_stored, new_images, gallery = result
choices = sorted(new_stored.keys()) if new_stored else []
return (
preview,
status,
final_name,
gr.update(choices=choices, value=final_name), # exam_picker
gr.update(choices=choices, value=final_name), # grading_exam_picker
new_stored,
new_images,
gallery
)
answer_upload.upload(
fn=on_answer_uploaded,
inputs=[api_key, answer_upload, exam_name, stored_answers, stored_answer_images],
outputs=[answer_preview, answer_status, exam_name, exam_picker, grading_exam_picker, stored_answers, stored_answer_images, answer_gallery],
)
# 當答案卷管理的選擇器變更時,同步更新批改選擇器
def sync_pickers(selected, stored):
content, name = load_answer_from_storage(selected, stored)
return content, name, gr.update(value=selected)
exam_picker.change(
fn=sync_pickers,
inputs=[exam_picker, stored_answers],
outputs=[answer_preview, exam_name, grading_exam_picker],
)
# 當批改選擇器變更時,同步更新答案卷管理的選擇器
grading_exam_picker.change(
fn=lambda selected: gr.update(value=selected),
inputs=[grading_exam_picker],
outputs=[exam_picker],
)
# 重新整理按鈕
def refresh_answer_choices(stored):
choices = sorted(stored.keys()) if stored else []
return gr.update(choices=choices), gr.update(choices=choices)
refresh_answers_btn.click(
fn=refresh_answer_choices,
inputs=[stored_answers],
outputs=[exam_picker, grading_exam_picker],
)
save_button.click(
fn=save_manual_edit,
inputs=[exam_picker, exam_name, answer_preview, stored_answers],
outputs=[stored_answers, answer_status, exam_picker],
)
# 刪除答案卷按鈕 - 同時更新兩個選擇器
def on_delete_answer(selected, stored):
new_stored, status, picker_update, preview, name = delete_answer_from_storage(selected, stored)
choices = sorted(new_stored.keys()) if new_stored else []
return (
new_stored,
status,
gr.update(choices=choices, value=None), # exam_picker
gr.update(choices=choices, value=None), # grading_exam_picker
preview,
name
)
delete_answer_btn.click(
fn=on_delete_answer,
inputs=[exam_picker, stored_answers],
outputs=[stored_answers, answer_status, exam_picker, grading_exam_picker, answer_preview, exam_name],
)
student_upload.upload(
fn=update_gallery,
inputs=[student_upload],
outputs=[student_gallery],
)
def do_grading(api_key, grading_picker, stored, stored_images, files):
logger.info(f"do_grading 被呼叫,grading_picker={grading_picker}")
logger.info(f"stored_images keys: {list(stored_images.keys()) if stored_images else 'None'}")
if not grading_picker:
return "**❌ 請先選擇要使用的答案卷!**", [], "請在「批改設定」中選擇答案卷"
feedback, images, status = grade_exam_from_storage(api_key, grading_picker, stored, stored_images, files)
gallery_items = update_graded_gallery(images)
return feedback, gallery_items, status
grade_button.click(
fn=do_grading,
inputs=[api_key, grading_exam_picker, stored_answers, stored_answer_images, student_upload],
outputs=[grade_feedback, graded_gallery, grade_status],
)
return demo
if __name__ == "__main__":
build_demo().launch()