Spaces:
Sleeping
Sleeping
update
Browse files- desktop.ini +6 -0
- firebase_progress.py +67 -0
- layout/renderer/dt_callbacks.py +36 -6
- layout/renderer/dt_quiz.py +4 -0
- layout/shell/header.py +14 -0
- pages/firebase_progress.py +175 -0
- pages/teacher.py +210 -6
desktop.ini
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[.ShellClassInfo]
|
| 2 |
+
IconResource=D:\other_thing\2�N���лP��Ƨ�\�N������Ƨ�\���A�Y�N�ת��B��\��.ico,0
|
| 3 |
+
[ViewState]
|
| 4 |
+
Mode=
|
| 5 |
+
Vid=
|
| 6 |
+
FolderType=Generic
|
firebase_progress.py
CHANGED
|
@@ -81,6 +81,73 @@ def log_solution_viewed(student_id: str, unit: str, solution_id: str) -> None:
|
|
| 81 |
.set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def log_hint_used(student_id: str, unit: str, qid: str) -> None:
|
| 85 |
"""記錄學生使用了 AI 提示。"""
|
| 86 |
log_event(student_id, "hint_used", unit=unit, qid=qid)
|
|
|
|
| 81 |
.set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
|
| 82 |
|
| 83 |
|
| 84 |
+
def log_choice_option(unit: str, qid: str, option_key: str) -> None:
|
| 85 |
+
"""記錄某題某選項被選擇的次數(全班統計,存在 question_stats 集合)。"""
|
| 86 |
+
db = get_db()
|
| 87 |
+
ref = (
|
| 88 |
+
db.collection("question_stats")
|
| 89 |
+
.document(f"{unit}__{qid}")
|
| 90 |
+
)
|
| 91 |
+
ref.set({
|
| 92 |
+
"unit": unit,
|
| 93 |
+
"qid": qid,
|
| 94 |
+
f"option_{option_key}": _increment(1),
|
| 95 |
+
}, merge=True)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def log_quiz_question_stat(unit: str, qid: str, correct: bool) -> None:
|
| 99 |
+
"""記錄某題全班答對/答錯次數(存在 question_stats 集合)。"""
|
| 100 |
+
db = get_db()
|
| 101 |
+
ref = db.collection("question_stats").document(f"{unit}__{qid}")
|
| 102 |
+
field = "correct_count" if correct else "wrong_count"
|
| 103 |
+
ref.set({"unit": unit, "qid": qid, field: _increment(1)}, merge=True)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_all_question_stats() -> list[dict]:
|
| 107 |
+
"""讀取所有題目的答題統計(老師後台用)。"""
|
| 108 |
+
db = get_db()
|
| 109 |
+
docs = db.collection("question_stats").stream()
|
| 110 |
+
return [d.to_dict() for d in docs]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def register_question(
|
| 114 |
+
unit: str,
|
| 115 |
+
qid: str,
|
| 116 |
+
qtype: str,
|
| 117 |
+
prompt_runs: list | None = None,
|
| 118 |
+
prompt_md: str = "",
|
| 119 |
+
options: list | None = None,
|
| 120 |
+
correct_answer: str | list | None = None,
|
| 121 |
+
) -> None:
|
| 122 |
+
"""
|
| 123 |
+
在學生作答時,記錄題目原始內容到 question_stats。
|
| 124 |
+
每次都更新(允許覆蓋),確保每道題目各自正確記錄。
|
| 125 |
+
prompt_runs: 題目的 run 清單(含文字與 LaTeX)
|
| 126 |
+
prompt_md: 題目的 markdown 純文字(備援)
|
| 127 |
+
options: 選項清單 [{"key": "a", "text": "..."}, ...]
|
| 128 |
+
correct_answer: 正確答案 key(單選為 str,多選為 list)
|
| 129 |
+
"""
|
| 130 |
+
db = get_db()
|
| 131 |
+
ref = db.collection("question_stats").document(f"{unit}__{qid}")
|
| 132 |
+
update: dict = {"unit": unit, "qid": qid, "qtype": qtype}
|
| 133 |
+
if prompt_runs is not None:
|
| 134 |
+
# 只存 text/latex 欄位,避免多餘 key 佔空間
|
| 135 |
+
update["prompt_runs"] = [
|
| 136 |
+
{k: v for k, v in r.items() if k in ("text", "latex", "kind", "style", "newline")}
|
| 137 |
+
for r in (prompt_runs or [])
|
| 138 |
+
]
|
| 139 |
+
if prompt_md:
|
| 140 |
+
update["prompt_md"] = prompt_md[:300]
|
| 141 |
+
if options:
|
| 142 |
+
update["options"] = [{"key": o.get("key", ""), "text": o.get("text", "")} for o in options]
|
| 143 |
+
if correct_answer is not None:
|
| 144 |
+
if isinstance(correct_answer, list):
|
| 145 |
+
update["correct_answers"] = [str(k).lower() for k in correct_answer]
|
| 146 |
+
else:
|
| 147 |
+
update["correct_answers"] = [str(correct_answer).lower()]
|
| 148 |
+
ref.set(update, merge=True)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
def log_hint_used(student_id: str, unit: str, qid: str) -> None:
|
| 152 |
"""記錄學生使用了 AI 提示。"""
|
| 153 |
log_event(student_id, "hint_used", unit=unit, qid=qid)
|
layout/renderer/dt_callbacks.py
CHANGED
|
@@ -94,8 +94,14 @@ def _cb_check_fill(n_check, n_hint, user_input, meta, prev_pass, student_id, pat
|
|
| 94 |
qid = trig.get("qid", "")
|
| 95 |
unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
|
| 96 |
try:
|
| 97 |
-
from firebase_progress import log_quiz_result
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
except Exception as e:
|
| 100 |
print(f"[Firebase] fill correct 失敗:{e}")
|
| 101 |
return _feedback_child(right_msg), "green", "light", {}, True
|
|
@@ -103,8 +109,14 @@ def _cb_check_fill(n_check, n_hint, user_input, meta, prev_pass, student_id, pat
|
|
| 103 |
qid = trig.get("qid", "")
|
| 104 |
unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
|
| 105 |
try:
|
| 106 |
-
from firebase_progress import log_quiz_result
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
except Exception as e:
|
| 109 |
print(f"[Firebase] fill wrong 失敗:{e}")
|
| 110 |
return _feedback_child(wrong_msg), "red", "light", {}, False
|
|
@@ -246,8 +258,26 @@ def _cb_check_choice(n_submit, n_hint, val, meta, student_id, pathname):
|
|
| 246 |
qid = trig.get("qid", "") if isinstance(trig, dict) else ""
|
| 247 |
unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
|
| 248 |
try:
|
| 249 |
-
from firebase_progress import log_quiz_result
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
except Exception as e:
|
| 252 |
print(f"[Firebase] choice 失敗:{e}")
|
| 253 |
|
|
|
|
| 94 |
qid = trig.get("qid", "")
|
| 95 |
unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
|
| 96 |
try:
|
| 97 |
+
from firebase_progress import log_quiz_result, log_quiz_question_stat, register_question
|
| 98 |
+
_meta = meta or {}
|
| 99 |
+
if qid:
|
| 100 |
+
register_question(unit, qid, "fill",
|
| 101 |
+
prompt_runs=_meta.get("prompt_runs"),
|
| 102 |
+
prompt_md=_meta.get("prompt_md", ""))
|
| 103 |
+
log_quiz_result(student_id, unit, qid, True, answer=user_input or "")
|
| 104 |
+
log_quiz_question_stat(unit, qid, True)
|
| 105 |
except Exception as e:
|
| 106 |
print(f"[Firebase] fill correct 失敗:{e}")
|
| 107 |
return _feedback_child(right_msg), "green", "light", {}, True
|
|
|
|
| 109 |
qid = trig.get("qid", "")
|
| 110 |
unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
|
| 111 |
try:
|
| 112 |
+
from firebase_progress import log_quiz_result, log_quiz_question_stat, register_question
|
| 113 |
+
_meta = meta or {}
|
| 114 |
+
if qid:
|
| 115 |
+
register_question(unit, qid, "fill",
|
| 116 |
+
prompt_runs=_meta.get("prompt_runs"),
|
| 117 |
+
prompt_md=_meta.get("prompt_md", ""))
|
| 118 |
+
log_quiz_result(student_id, unit, qid, False, answer=user_input or "")
|
| 119 |
+
log_quiz_question_stat(unit, qid, False)
|
| 120 |
except Exception as e:
|
| 121 |
print(f"[Firebase] fill wrong 失敗:{e}")
|
| 122 |
return _feedback_child(wrong_msg), "red", "light", {}, False
|
|
|
|
| 258 |
qid = trig.get("qid", "") if isinstance(trig, dict) else ""
|
| 259 |
unit = (pathname or "").strip("/").replace("/", "_") or "unknown"
|
| 260 |
try:
|
| 261 |
+
from firebase_progress import log_quiz_result, log_quiz_question_stat, log_choice_option, register_question
|
| 262 |
+
_meta = meta or {}
|
| 263 |
+
options = _meta.get("options", [])
|
| 264 |
+
if qid:
|
| 265 |
+
_correct = _meta.get("answers") if qtype == "multi" else _meta.get("answer")
|
| 266 |
+
register_question(unit, qid, qtype,
|
| 267 |
+
prompt_runs=_meta.get("prompt_runs"),
|
| 268 |
+
prompt_md=_meta.get("prompt_md", ""),
|
| 269 |
+
options=options or None,
|
| 270 |
+
correct_answer=_correct or None)
|
| 271 |
+
# 記錄答題結果(含答案)
|
| 272 |
+
selected_answer = ",".join(sorted(selected)) if qtype == "multi" else (val or "")
|
| 273 |
+
log_quiz_result(student_id, unit, qid, ok, answer=selected_answer)
|
| 274 |
+
log_quiz_question_stat(unit, qid, ok)
|
| 275 |
+
# 記錄各選項被選次數(選擇題)
|
| 276 |
+
if qtype == "choice" and val:
|
| 277 |
+
log_choice_option(unit, qid, str(val))
|
| 278 |
+
elif qtype == "multi":
|
| 279 |
+
for sel_key in selected:
|
| 280 |
+
log_choice_option(unit, qid, str(sel_key))
|
| 281 |
except Exception as e:
|
| 282 |
print(f"[Firebase] choice 失敗:{e}")
|
| 283 |
|
layout/renderer/dt_quiz.py
CHANGED
|
@@ -160,6 +160,8 @@ def render_quiz(node: dict):
|
|
| 160 |
|
| 161 |
meta = {
|
| 162 |
"qtype": qtype, "prompt_text": full_prompt_text,
|
|
|
|
|
|
|
| 163 |
"options": opts, "answer": q.get("answer", ""),
|
| 164 |
"answers": q.get("answers", []), "explain": q.get("explain", ""),
|
| 165 |
"msg_right": q.get("msg_right", "✔ 正確!"),
|
|
@@ -222,6 +224,8 @@ def render_quiz(node: dict):
|
|
| 222 |
"normalize": q.get("normalize", []),
|
| 223 |
"msg_right": node.get("right_msg", ""), "msg_wrong": node.get("wrong_msg", ""),
|
| 224 |
"prompt_text": full_prompt_text,
|
|
|
|
|
|
|
| 225 |
"ai": {
|
| 226 |
"enabled": str(node.get("ai", "1")).lower() not in ("0", "false", "no", "off"),
|
| 227 |
"provider": node.get("ai_provider") or "gemini",
|
|
|
|
| 160 |
|
| 161 |
meta = {
|
| 162 |
"qtype": qtype, "prompt_text": full_prompt_text,
|
| 163 |
+
"prompt_runs": prompt_runs,
|
| 164 |
+
"prompt_md": prompt_md,
|
| 165 |
"options": opts, "answer": q.get("answer", ""),
|
| 166 |
"answers": q.get("answers", []), "explain": q.get("explain", ""),
|
| 167 |
"msg_right": q.get("msg_right", "✔ 正確!"),
|
|
|
|
| 224 |
"normalize": q.get("normalize", []),
|
| 225 |
"msg_right": node.get("right_msg", ""), "msg_wrong": node.get("wrong_msg", ""),
|
| 226 |
"prompt_text": full_prompt_text,
|
| 227 |
+
"prompt_runs": prompt_runs,
|
| 228 |
+
"prompt_md": prompt_md,
|
| 229 |
"ai": {
|
| 230 |
"enabled": str(node.get("ai", "1")).lower() not in ("0", "false", "no", "off"),
|
| 231 |
"provider": node.get("ai_provider") or "gemini",
|
layout/shell/header.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
# header.py (或你專案中產生 header 的檔案)
|
| 2 |
from dash import html
|
| 3 |
import dash_mantine_components as dmc
|
|
@@ -39,6 +40,19 @@ def generate_header():
|
|
| 39 |
align="center",
|
| 40 |
children=[
|
| 41 |
create_student_badge(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
dmc.Switch(
|
| 43 |
id="color-scheme-switch", size="lg", color="gray",
|
| 44 |
onLabel="🌙", offLabel="☀️", persistence=True
|
|
|
|
| 1 |
+
from dash_iconify import DashIconify
|
| 2 |
# header.py (或你專案中產生 header 的檔案)
|
| 3 |
from dash import html
|
| 4 |
import dash_mantine_components as dmc
|
|
|
|
| 40 |
align="center",
|
| 41 |
children=[
|
| 42 |
create_student_badge(),
|
| 43 |
+
dmc.Anchor(
|
| 44 |
+
href="/teacher",
|
| 45 |
+
children=dmc.Tooltip(
|
| 46 |
+
label="老師後台",
|
| 47 |
+
children=dmc.ActionIcon(
|
| 48 |
+
DashIconify(icon="tabler:layout-dashboard", height=18),
|
| 49 |
+
size="md",
|
| 50 |
+
variant="subtle",
|
| 51 |
+
color="gray",
|
| 52 |
+
),
|
| 53 |
+
),
|
| 54 |
+
style={"lineHeight": 0},
|
| 55 |
+
),
|
| 56 |
dmc.Switch(
|
| 57 |
id="color-scheme-switch", size="lg", color="gray",
|
| 58 |
onLabel="🌙", offLabel="☀️", persistence=True
|
pages/firebase_progress.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
firebase_progress.py
|
| 3 |
+
--------------------
|
| 4 |
+
學生學習進度的讀寫邏輯。
|
| 5 |
+
放在專案根目錄,由 dt_callbacks.py 和頁面呼叫。
|
| 6 |
+
|
| 7 |
+
Firestore 資料結構:
|
| 8 |
+
students/{student_id}/
|
| 9 |
+
profile: { name, student_id, last_seen }
|
| 10 |
+
progress: { quiz_correct, solutions_viewed, ai_hints_used, ... }
|
| 11 |
+
events: [ { type, unit, qid, ts, ... }, ... ] ← 子集合
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
import datetime
|
| 15 |
+
from firebase_init import get_db
|
| 16 |
+
|
| 17 |
+
# ─── 時間戳 ────────────────────────────────────────────────────────────────
|
| 18 |
+
def _now() -> str:
|
| 19 |
+
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ─── 學生個人資料 ──────────────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
def upsert_student(student_id: str, name: str = "") -> None:
|
| 25 |
+
"""建立或更新學生個人資料。"""
|
| 26 |
+
db = get_db()
|
| 27 |
+
ref = db.collection("students").document(student_id)
|
| 28 |
+
ref.set({
|
| 29 |
+
"student_id": student_id,
|
| 30 |
+
"name": name or student_id,
|
| 31 |
+
"last_seen": _now(),
|
| 32 |
+
}, merge=True)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ─── 記錄事件 ─────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
def log_event(student_id: str, event_type: str, **kwargs) -> None:
|
| 38 |
+
"""
|
| 39 |
+
記錄一筆學習事件到 students/{student_id}/events 子集合。
|
| 40 |
+
|
| 41 |
+
event_type 範例:
|
| 42 |
+
"quiz_correct" - 填空/選擇題答對
|
| 43 |
+
"quiz_wrong" - 答錯
|
| 44 |
+
"solution_viewed" - 展開詳細解法
|
| 45 |
+
"hint_used" - 按下 AI 提示
|
| 46 |
+
"quiz_ai_done" - AI 批改完成
|
| 47 |
+
"""
|
| 48 |
+
db = get_db()
|
| 49 |
+
payload = {
|
| 50 |
+
"type": event_type,
|
| 51 |
+
"ts": _now(),
|
| 52 |
+
**kwargs, # unit, qid, answer, ...
|
| 53 |
+
}
|
| 54 |
+
db.collection("students").document(student_id)\
|
| 55 |
+
.collection("events").add(payload)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def log_quiz_result(
|
| 59 |
+
student_id: str,
|
| 60 |
+
unit: str,
|
| 61 |
+
qid: str,
|
| 62 |
+
correct: bool,
|
| 63 |
+
answer: str = "",
|
| 64 |
+
) -> None:
|
| 65 |
+
"""記錄一題答題結果,同時更新 progress 計數。"""
|
| 66 |
+
event_type = "quiz_correct" if correct else "quiz_wrong"
|
| 67 |
+
log_event(student_id, event_type, unit=unit, qid=qid, answer=answer)
|
| 68 |
+
|
| 69 |
+
# 更新統計計數
|
| 70 |
+
db = get_db()
|
| 71 |
+
ref = db.collection("students").document(student_id)
|
| 72 |
+
field = "quiz_correct_count" if correct else "quiz_wrong_count"
|
| 73 |
+
ref.set({field: _increment(1), "last_seen": _now()}, merge=True)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def log_solution_viewed(student_id: str, unit: str, solution_id: str) -> None:
|
| 77 |
+
"""記錄學生展開了某個解法區塊。"""
|
| 78 |
+
log_event(student_id, "solution_viewed", unit=unit, solution_id=solution_id)
|
| 79 |
+
db = get_db()
|
| 80 |
+
db.collection("students").document(student_id)\
|
| 81 |
+
.set({"solutions_viewed_count": _increment(1), "last_seen": _now()}, merge=True)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def log_choice_option(unit: str, qid: str, option_key: str) -> None:
|
| 85 |
+
"""記錄某題某選項被選擇的次數(全班統計,存在 question_stats 集合)。"""
|
| 86 |
+
db = get_db()
|
| 87 |
+
ref = (
|
| 88 |
+
db.collection("question_stats")
|
| 89 |
+
.document(f"{unit}__{qid}")
|
| 90 |
+
)
|
| 91 |
+
ref.set({
|
| 92 |
+
"unit": unit,
|
| 93 |
+
"qid": qid,
|
| 94 |
+
f"option_{option_key}": _increment(1),
|
| 95 |
+
}, merge=True)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def log_quiz_question_stat(unit: str, qid: str, correct: bool) -> None:
|
| 99 |
+
"""記錄某題全班答對/答錯次數(存在 question_stats 集合)。"""
|
| 100 |
+
db = get_db()
|
| 101 |
+
ref = db.collection("question_stats").document(f"{unit}__{qid}")
|
| 102 |
+
field = "correct_count" if correct else "wrong_count"
|
| 103 |
+
ref.set({"unit": unit, "qid": qid, field: _increment(1)}, merge=True)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_all_question_stats() -> list[dict]:
|
| 107 |
+
"""讀取所有題目的答題統計(老師後台用)。"""
|
| 108 |
+
db = get_db()
|
| 109 |
+
docs = db.collection("question_stats").stream()
|
| 110 |
+
return [d.to_dict() for d in docs]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def register_question(unit: str, qid: str, prompt_text: str, qtype: str, options: list | None = None) -> None:
|
| 114 |
+
"""
|
| 115 |
+
在第一次有學生作答時,記錄題目內容到 question_stats(若尚未存在)。
|
| 116 |
+
prompt_text: 題目文字(純文字,不含 LaTeX 標記也可)
|
| 117 |
+
options: [{"key": "a", "text": "..."}, ...] 或 None
|
| 118 |
+
"""
|
| 119 |
+
db = get_db()
|
| 120 |
+
ref = db.collection("question_stats").document(f"{unit}__{qid}")
|
| 121 |
+
doc = ref.get()
|
| 122 |
+
if doc.exists and doc.to_dict().get("prompt_text"):
|
| 123 |
+
return # 已有紀錄,不重複寫
|
| 124 |
+
update = {"unit": unit, "qid": qid, "qtype": qtype}
|
| 125 |
+
if prompt_text:
|
| 126 |
+
update["prompt_text"] = prompt_text[:300] # 截斷避免過長
|
| 127 |
+
if options:
|
| 128 |
+
update["options"] = [{"key": o.get("key", ""), "text": o.get("text", "")} for o in options]
|
| 129 |
+
ref.set(update, merge=True)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def log_hint_used(student_id: str, unit: str, qid: str) -> None:
|
| 133 |
+
"""記錄學生使用了 AI 提示。"""
|
| 134 |
+
log_event(student_id, "hint_used", unit=unit, qid=qid)
|
| 135 |
+
db = get_db()
|
| 136 |
+
db.collection("students").document(student_id)\
|
| 137 |
+
.set({"ai_hints_used_count": _increment(1), "last_seen": _now()}, merge=True)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ─── 讀取 ────────────────────────────────────────────────────────────────
|
| 141 |
+
|
| 142 |
+
def get_student_progress(student_id: str) -> dict:
|
| 143 |
+
"""讀取單一學生的 progress 計數(老師後台用)。"""
|
| 144 |
+
db = get_db()
|
| 145 |
+
doc = db.collection("students").document(student_id).get()
|
| 146 |
+
if doc.exists:
|
| 147 |
+
return doc.to_dict()
|
| 148 |
+
return {}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def get_all_students() -> list[dict]:
|
| 152 |
+
"""讀取所有學生的 profile(老師後台列表用)。"""
|
| 153 |
+
db = get_db()
|
| 154 |
+
docs = db.collection("students").stream()
|
| 155 |
+
return [d.to_dict() for d in docs]
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def get_student_events(student_id: str, limit: int = 100) -> list[dict]:
|
| 159 |
+
"""讀取某學生最近的事件記錄。"""
|
| 160 |
+
db = get_db()
|
| 161 |
+
docs = (
|
| 162 |
+
db.collection("students").document(student_id)
|
| 163 |
+
.collection("events")
|
| 164 |
+
.order_by("ts", direction="DESCENDING")
|
| 165 |
+
.limit(limit)
|
| 166 |
+
.stream()
|
| 167 |
+
)
|
| 168 |
+
return [d.to_dict() for d in docs]
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ─── Firestore 遞增輔助 ───────────────────────────────────────────────────
|
| 172 |
+
|
| 173 |
+
def _increment(n: int):
|
| 174 |
+
from google.cloud.firestore import Increment
|
| 175 |
+
return Increment(n)
|
pages/teacher.py
CHANGED
|
@@ -63,9 +63,32 @@ def layout():
|
|
| 63 |
dmc.Text("📚 單元答題統計", fw=700, size="lg", mb="sm"),
|
| 64 |
html.Div(id="teacher-unit-stats"),
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
# 個別學生事件
|
| 67 |
dmc.Divider(my="lg"),
|
| 68 |
html.Div(id="teacher-events-section"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
]),
|
| 70 |
],
|
| 71 |
)
|
|
@@ -134,7 +157,11 @@ def clear_all_data(n):
|
|
| 134 |
"ai_hints_used_count": 0,
|
| 135 |
"solutions_viewed_count": 0,
|
| 136 |
}, merge=True)
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
except Exception as e:
|
| 139 |
print(f"[Teacher] 清除失敗:{e}")
|
| 140 |
return 1 # 觸發重新整理
|
|
@@ -146,24 +173,25 @@ def clear_all_data(n):
|
|
| 146 |
Output("teacher-stats-cards", "children"),
|
| 147 |
Output("teacher-student-table", "children"),
|
| 148 |
Output("teacher-unit-stats", "children"),
|
|
|
|
| 149 |
Input("teacher-auth-store", "data"),
|
| 150 |
Input("teacher-refresh-btn", "n_clicks"),
|
| 151 |
prevent_initial_call=False,
|
| 152 |
)
|
| 153 |
def load_dashboard(auth, _):
|
| 154 |
if not auth:
|
| 155 |
-
return no_update, no_update, no_update
|
| 156 |
|
| 157 |
try:
|
| 158 |
from firebase_progress import get_all_students, get_student_events
|
| 159 |
students = get_all_students()
|
| 160 |
except Exception as e:
|
| 161 |
err = dmc.Alert(f"讀取失敗:{e}", color="red")
|
| 162 |
-
return err, err, err
|
| 163 |
|
| 164 |
if not students:
|
| 165 |
empty = dmc.Text("目前沒有學生資料。", c="dimmed")
|
| 166 |
-
return empty, empty, empty
|
| 167 |
|
| 168 |
# ── 統計卡片 ──
|
| 169 |
total = len(students)
|
|
@@ -247,7 +275,73 @@ def load_dashboard(auth, _):
|
|
| 247 |
dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
|
| 248 |
])
|
| 249 |
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
|
| 253 |
# ─── 個別學生事件 ─────────────────────────────────────────────────────────
|
|
@@ -276,12 +370,15 @@ def show_events(n_clicks_list):
|
|
| 276 |
for ev in events:
|
| 277 |
etype = ev.get("type", "")
|
| 278 |
_, color = _event_style(etype)
|
|
|
|
|
|
|
| 279 |
rows.append(dmc.TableTr([
|
| 280 |
dmc.TableTd(dmc.Badge(etype, color=color, variant="light", size="sm")),
|
| 281 |
dmc.TableTd(ev.get("unit", "—").replace("_", "/"),
|
| 282 |
style={"fontSize": "12px"}),
|
| 283 |
dmc.TableTd(ev.get("qid") or ev.get("solution_id", "—"),
|
| 284 |
style={"fontSize": "11px", "color": "#888"}),
|
|
|
|
| 285 |
dmc.TableTd((ev.get("ts", "")[:16] or "—").replace("T", " "),
|
| 286 |
style={"fontSize": "12px", "color": "#888"}),
|
| 287 |
]))
|
|
@@ -291,13 +388,120 @@ def show_events(n_clicks_list):
|
|
| 291 |
dmc.Table(striped=True, withTableBorder=True,
|
| 292 |
children=[
|
| 293 |
dmc.TableThead(dmc.TableTr([dmc.TableTh(h) for h in
|
| 294 |
-
["事件", "單元", "題目", "時間"]])),
|
| 295 |
dmc.TableTbody(rows) if rows else
|
| 296 |
dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
|
| 297 |
]),
|
| 298 |
])
|
| 299 |
|
| 300 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
# ─── 輔助 ────────────────────────────────────────────────────────────────
|
| 302 |
|
| 303 |
def _stat_card(label, value, icon, color):
|
|
|
|
| 63 |
dmc.Text("📚 單元答題統計", fw=700, size="lg", mb="sm"),
|
| 64 |
html.Div(id="teacher-unit-stats"),
|
| 65 |
|
| 66 |
+
# 每題答題統計
|
| 67 |
+
dmc.Divider(my="lg"),
|
| 68 |
+
dmc.Text("📊 每題答題統計", fw=700, size="lg", mb="sm"),
|
| 69 |
+
dmc.Text("(包含各選項被選次數)", size="sm", c="dimmed", mb="sm"),
|
| 70 |
+
html.Div(id="teacher-question-stats"),
|
| 71 |
+
|
| 72 |
# 個別學生事件
|
| 73 |
dmc.Divider(my="lg"),
|
| 74 |
html.Div(id="teacher-events-section"),
|
| 75 |
+
|
| 76 |
+
# 題目 doc_ids Store(由 load_dashboard 寫入)
|
| 77 |
+
dcc.Store(id="teacher-question-doc-ids", data=[]),
|
| 78 |
+
|
| 79 |
+
# 題目預覽 Modal
|
| 80 |
+
dmc.Modal(
|
| 81 |
+
id="teacher-question-modal",
|
| 82 |
+
opened=False,
|
| 83 |
+
centered=True,
|
| 84 |
+
size="lg",
|
| 85 |
+
title=dmc.Group([
|
| 86 |
+
DashIconify(icon="tabler:file-text", height=20),
|
| 87 |
+
dmc.Text("題目內容", fw=700),
|
| 88 |
+
], gap="xs"),
|
| 89 |
+
children=html.Div(id="teacher-question-modal-body"),
|
| 90 |
+
),
|
| 91 |
+
dcc.Store(id="teacher-question-modal-store", data=None),
|
| 92 |
]),
|
| 93 |
],
|
| 94 |
)
|
|
|
|
| 157 |
"ai_hints_used_count": 0,
|
| 158 |
"solutions_viewed_count": 0,
|
| 159 |
}, merge=True)
|
| 160 |
+
# 清除每題統計
|
| 161 |
+
q_stats = db.collection("question_stats").stream()
|
| 162 |
+
for qs in q_stats:
|
| 163 |
+
qs.reference.delete()
|
| 164 |
+
print("[Teacher] 已清除所有答題記錄(含每題統計)")
|
| 165 |
except Exception as e:
|
| 166 |
print(f"[Teacher] 清除失敗:{e}")
|
| 167 |
return 1 # 觸發重新整理
|
|
|
|
| 173 |
Output("teacher-stats-cards", "children"),
|
| 174 |
Output("teacher-student-table", "children"),
|
| 175 |
Output("teacher-unit-stats", "children"),
|
| 176 |
+
Output("teacher-question-stats", "children"),
|
| 177 |
Input("teacher-auth-store", "data"),
|
| 178 |
Input("teacher-refresh-btn", "n_clicks"),
|
| 179 |
prevent_initial_call=False,
|
| 180 |
)
|
| 181 |
def load_dashboard(auth, _):
|
| 182 |
if not auth:
|
| 183 |
+
return no_update, no_update, no_update, no_update
|
| 184 |
|
| 185 |
try:
|
| 186 |
from firebase_progress import get_all_students, get_student_events
|
| 187 |
students = get_all_students()
|
| 188 |
except Exception as e:
|
| 189 |
err = dmc.Alert(f"讀取失敗:{e}", color="red")
|
| 190 |
+
return err, err, err, err
|
| 191 |
|
| 192 |
if not students:
|
| 193 |
empty = dmc.Text("目前沒有學生資料。", c="dimmed")
|
| 194 |
+
return empty, empty, empty, empty
|
| 195 |
|
| 196 |
# ── 統計卡片 ──
|
| 197 |
total = len(students)
|
|
|
|
| 275 |
dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
|
| 276 |
])
|
| 277 |
|
| 278 |
+
# ── 每題答題統計 ──
|
| 279 |
+
try:
|
| 280 |
+
from firebase_progress import get_all_question_stats
|
| 281 |
+
q_stats_raw = get_all_question_stats()
|
| 282 |
+
except Exception:
|
| 283 |
+
q_stats_raw = []
|
| 284 |
+
|
| 285 |
+
question_rows = []
|
| 286 |
+
question_doc_ids = [] # 與 question_rows 同索引,對應 Firestore doc id
|
| 287 |
+
for qs in sorted(q_stats_raw, key=lambda x: (x.get("unit", ""), x.get("qid", ""))):
|
| 288 |
+
unit_raw = qs.get("unit", "") # 原始格式,如 "Ch14_6"
|
| 289 |
+
qid_raw = qs.get("qid", "") # 原始格式,如 "flow-15::step-1::q1"
|
| 290 |
+
unit_label = unit_raw.replace("_", "/") # 顯示用
|
| 291 |
+
qid_label = qid_raw
|
| 292 |
+
c = qs.get("correct_count", 0)
|
| 293 |
+
w = qs.get("wrong_count", 0)
|
| 294 |
+
t = c + w
|
| 295 |
+
rate = f"{c/t*100:.0f}%" if t > 0 else "—"
|
| 296 |
+
|
| 297 |
+
# 選項統計
|
| 298 |
+
option_keys = sorted([k for k in qs if k.startswith("option_")])
|
| 299 |
+
correct_answers = qs.get("correct_answers", []) # list of correct key strings
|
| 300 |
+
if option_keys:
|
| 301 |
+
badges = []
|
| 302 |
+
for k in option_keys:
|
| 303 |
+
opt_key = k.replace("option_", "").lower()
|
| 304 |
+
is_correct = opt_key in [str(a).lower() for a in correct_answers]
|
| 305 |
+
badges.append(dmc.Badge(
|
| 306 |
+
f"{opt_key.upper()}:{qs[k]}",
|
| 307 |
+
color="green" if is_correct else "red",
|
| 308 |
+
variant="light",
|
| 309 |
+
size="sm",
|
| 310 |
+
))
|
| 311 |
+
option_badges = dmc.Group(gap=4, children=badges)
|
| 312 |
+
else:
|
| 313 |
+
option_badges = dmc.Text("(填空題)", size="xs", c="dimmed")
|
| 314 |
+
|
| 315 |
+
question_rows.append(dmc.TableTr([
|
| 316 |
+
dmc.TableTd(unit_label, style={"fontSize": "12px"}),
|
| 317 |
+
dmc.TableTd(qid_label, style={"fontSize": "11px", "color": "#888"}),
|
| 318 |
+
dmc.TableTd(str(c), style={"color": "green", "fontWeight": 600}),
|
| 319 |
+
dmc.TableTd(str(w), style={"color": "red", "fontWeight": 600}),
|
| 320 |
+
dmc.TableTd(rate),
|
| 321 |
+
dmc.TableTd(option_badges),
|
| 322 |
+
dmc.TableTd(dmc.Button(
|
| 323 |
+
"查看題目",
|
| 324 |
+
id={"type": "teacher-view-question", "idx": len(question_rows)},
|
| 325 |
+
size="xs", variant="subtle",
|
| 326 |
+
leftSection=DashIconify(icon="tabler:eye", height=12),
|
| 327 |
+
)),
|
| 328 |
+
]))
|
| 329 |
+
question_doc_ids.append(f"{unit_raw}__{qid_raw}")
|
| 330 |
+
|
| 331 |
+
question_table = html.Div([
|
| 332 |
+
dcc.Store(id="teacher-question-doc-ids", data=question_doc_ids),
|
| 333 |
+
dmc.Table(
|
| 334 |
+
striped=True, withTableBorder=True,
|
| 335 |
+
children=[
|
| 336 |
+
dmc.TableThead(dmc.TableTr([dmc.TableTh(h) for h in
|
| 337 |
+
["單元", "題目 ID", "答對次數", "答錯次數", "正確率", "各選項次數", ""]])),
|
| 338 |
+
dmc.TableTbody(question_rows) if question_rows else
|
| 339 |
+
dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
|
| 340 |
+
]
|
| 341 |
+
),
|
| 342 |
+
])
|
| 343 |
+
|
| 344 |
+
return stats_cards, table, unit_table, question_table
|
| 345 |
|
| 346 |
|
| 347 |
# ─── 個別學生事件 ─────────────────────────────────────────────────────────
|
|
|
|
| 370 |
for ev in events:
|
| 371 |
etype = ev.get("type", "")
|
| 372 |
_, color = _event_style(etype)
|
| 373 |
+
answer_val = ev.get("answer", "")
|
| 374 |
+
answer_cell = dmc.Text(str(answer_val)[:40], size="xs", c="dimmed") if answer_val else dmc.Text("—", size="xs", c="dimmed")
|
| 375 |
rows.append(dmc.TableTr([
|
| 376 |
dmc.TableTd(dmc.Badge(etype, color=color, variant="light", size="sm")),
|
| 377 |
dmc.TableTd(ev.get("unit", "—").replace("_", "/"),
|
| 378 |
style={"fontSize": "12px"}),
|
| 379 |
dmc.TableTd(ev.get("qid") or ev.get("solution_id", "—"),
|
| 380 |
style={"fontSize": "11px", "color": "#888"}),
|
| 381 |
+
dmc.TableTd(answer_cell),
|
| 382 |
dmc.TableTd((ev.get("ts", "")[:16] or "—").replace("T", " "),
|
| 383 |
style={"fontSize": "12px", "color": "#888"}),
|
| 384 |
]))
|
|
|
|
| 388 |
dmc.Table(striped=True, withTableBorder=True,
|
| 389 |
children=[
|
| 390 |
dmc.TableThead(dmc.TableTr([dmc.TableTh(h) for h in
|
| 391 |
+
["事件", "單元", "題目", "學生答案", "時間"]])),
|
| 392 |
dmc.TableTbody(rows) if rows else
|
| 393 |
dmc.TableTbody(dmc.TableTr(dmc.TableTd("沒有記錄"))),
|
| 394 |
]),
|
| 395 |
])
|
| 396 |
|
| 397 |
|
| 398 |
+
# ─── 查看題目 Modal ──────────────────────────────────────────────────────
|
| 399 |
+
|
| 400 |
+
@callback(
|
| 401 |
+
Output("teacher-question-modal", "opened"),
|
| 402 |
+
Output("teacher-question-modal-body", "children"),
|
| 403 |
+
Input({"type": "teacher-view-question", "idx": ALL}, "n_clicks"),
|
| 404 |
+
State("teacher-question-doc-ids", "data"),
|
| 405 |
+
prevent_initial_call=True,
|
| 406 |
+
)
|
| 407 |
+
def open_question_modal(n_clicks_list, doc_ids):
|
| 408 |
+
trigger = dash.callback_context.triggered_id
|
| 409 |
+
if not trigger or not isinstance(trigger, dict):
|
| 410 |
+
return no_update, no_update
|
| 411 |
+
|
| 412 |
+
# 確認是真正的點擊(n_clicks > 0),避免重整時觸發
|
| 413 |
+
idx = trigger.get("idx")
|
| 414 |
+
if idx is None:
|
| 415 |
+
return no_update, no_update
|
| 416 |
+
triggered_clicks = n_clicks_list[idx] if idx < len(n_clicks_list) else 0
|
| 417 |
+
if not triggered_clicks or triggered_clicks < 1:
|
| 418 |
+
return no_update, no_update
|
| 419 |
+
|
| 420 |
+
doc_ids = doc_ids or []
|
| 421 |
+
if idx >= len(doc_ids):
|
| 422 |
+
return no_update, no_update
|
| 423 |
+
doc_id = doc_ids[idx]
|
| 424 |
+
|
| 425 |
+
if not doc_id:
|
| 426 |
+
return no_update, no_update
|
| 427 |
+
|
| 428 |
+
try:
|
| 429 |
+
from firebase_init import get_db
|
| 430 |
+
db = get_db()
|
| 431 |
+
doc = db.collection("question_stats").document(doc_id).get()
|
| 432 |
+
if not doc.exists:
|
| 433 |
+
return True, dmc.Alert("找不到題目資料(學生尚未作答此題)。", color="yellow")
|
| 434 |
+
data = doc.to_dict()
|
| 435 |
+
except Exception as e:
|
| 436 |
+
return True, dmc.Alert(f"讀取失敗:{e}", color="red")
|
| 437 |
+
|
| 438 |
+
unit = data.get("unit", "—").replace("_", "/")
|
| 439 |
+
qid = data.get("qid", "—")
|
| 440 |
+
qtype = data.get("qtype", "—")
|
| 441 |
+
options = data.get("options", [])
|
| 442 |
+
|
| 443 |
+
qtype_label = {"fill": "填空題", "choice": "單選題", "multi": "多選題", "order": "排序題"}.get(qtype, qtype)
|
| 444 |
+
|
| 445 |
+
# 把 prompt_runs 轉成 Markdown 字串(含 $...$ LaTeX)
|
| 446 |
+
prompt_runs = data.get("prompt_runs") or []
|
| 447 |
+
prompt_md = data.get("prompt_md", "")
|
| 448 |
+
|
| 449 |
+
def runs_to_md(runs):
|
| 450 |
+
parts = []
|
| 451 |
+
for r in runs:
|
| 452 |
+
if "latex" in r:
|
| 453 |
+
latex = r["latex"].strip()
|
| 454 |
+
if r.get("kind") in ("math_block", "block_math", "display_math"):
|
| 455 |
+
parts.append("\n\n$$\n" + latex + "\n$$\n\n")
|
| 456 |
+
else:
|
| 457 |
+
parts.append(f"${latex}$")
|
| 458 |
+
elif "text" in r:
|
| 459 |
+
parts.append(r["text"])
|
| 460 |
+
if str(r.get("newline", "false")).lower() == "true":
|
| 461 |
+
parts.append("\n\n")
|
| 462 |
+
return "".join(parts).strip()
|
| 463 |
+
|
| 464 |
+
prompt_display = runs_to_md(prompt_runs) if prompt_runs else prompt_md
|
| 465 |
+
|
| 466 |
+
if not prompt_display:
|
| 467 |
+
prompt_component = dmc.Text(
|
| 468 |
+
"(此題題目文字嵌在上下文卡片中,請參考下方選項識別題目。)",
|
| 469 |
+
size="sm", c="dimmed", style={"fontStyle": "italic"},
|
| 470 |
+
)
|
| 471 |
+
else:
|
| 472 |
+
prompt_component = dcc.Markdown(
|
| 473 |
+
prompt_display,
|
| 474 |
+
mathjax=True,
|
| 475 |
+
style={"fontSize": "0.95rem", "lineHeight": "1.7"},
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
body_children = [
|
| 479 |
+
dmc.Group([
|
| 480 |
+
dmc.Badge(qtype_label, color="violet", variant="light"),
|
| 481 |
+
dmc.Text(f"單元:{unit}", size="sm", c="dimmed"),
|
| 482 |
+
dmc.Text(f"題目 ID:{qid}", size="sm", c="dimmed"),
|
| 483 |
+
], gap="xs", mb="md"),
|
| 484 |
+
dmc.Divider(mb="md"),
|
| 485 |
+
dmc.Text("📝 題目:", fw=700, size="sm", mb=4),
|
| 486 |
+
dmc.Paper(prompt_component, p="sm", radius="sm", withBorder=True, mb="md"),
|
| 487 |
+
]
|
| 488 |
+
|
| 489 |
+
if options:
|
| 490 |
+
body_children.append(dmc.Text("🔘 選項:", fw=700, size="sm", mb=4))
|
| 491 |
+
for op in options:
|
| 492 |
+
key = op.get("key", "?")
|
| 493 |
+
text = op.get("text", "")
|
| 494 |
+
body_children.append(
|
| 495 |
+
dmc.Group([
|
| 496 |
+
dmc.Badge(key.upper(), variant="outline", size="sm", color="blue"),
|
| 497 |
+
dcc.Markdown(text, mathjax=True,
|
| 498 |
+
style={"display": "inline", "fontSize": "0.9rem", "margin": 0}),
|
| 499 |
+
], gap="xs", mb=4, align="center")
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
return True, html.Div(body_children)
|
| 503 |
+
|
| 504 |
+
|
| 505 |
# ─── 輔助 ────────────────────────────────────────────────────────────────
|
| 506 |
|
| 507 |
def _stat_card(label, value, icon, color):
|