Spaces:
Sleeping
Sleeping
Yu Chen commited on
Commit ·
ad5d4b6
1
Parent(s): bb875d5
Refactor to add answer grid & assoicate llm parsing
Browse files- chatkit/backend/app/answer_grid.py +229 -0
- chatkit/backend/app/database.py +32 -0
- chatkit/backend/app/main.py +107 -73
- chatkit/backend/app/report_generator.py +80 -100
- chatkit/backend/pyproject.toml +11 -0
- chatkit/backend/tests/__init__.py +0 -0
- chatkit/backend/tests/test_answer_grid.py +319 -0
- chatkit/backend/tests/test_report_generator.py +126 -0
- chatkit/frontend/src/App.tsx +0 -16
- chatkit/frontend/src/components/step2/AnswerGridEditor.tsx +283 -0
- chatkit/frontend/src/components/step2/DataPreviewModal.tsx +77 -251
- chatkit/frontend/src/components/step2/PromptEditor.tsx +18 -157
chatkit/backend/app/answer_grid.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canonical answer-grid data model.
|
| 2 |
+
|
| 3 |
+
Single source of truth for per-student report generation:
|
| 4 |
+
- total_questions (N)
|
| 5 |
+
- official_answers: 1xN vector of letters
|
| 6 |
+
- students: m rows of N letters (or None for blank)
|
| 7 |
+
- questions: N question metadata entries for rendering wrong blocks
|
| 8 |
+
|
| 9 |
+
Single-choice only. Letters A-Z. All comparisons are case-insensitive trim.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
_BLANK_MARKERS = {"", "-", "–", "—", "n/a", "none", "null"}
|
| 19 |
+
_LETTER_RE = re.compile(r"^[A-Z]$")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class QuestionMeta:
|
| 24 |
+
number: int
|
| 25 |
+
text: str
|
| 26 |
+
options: tuple[str, ...]
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class StudentRow:
|
| 31 |
+
name: str
|
| 32 |
+
answers: tuple[Optional[str], ...]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class AnswerGrid:
|
| 37 |
+
total_questions: int
|
| 38 |
+
official_answers: tuple[Optional[str], ...]
|
| 39 |
+
students: tuple[StudentRow, ...]
|
| 40 |
+
questions: tuple[QuestionMeta, ...]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class WrongAnswer:
|
| 45 |
+
question: QuestionMeta
|
| 46 |
+
student_answer: Optional[str]
|
| 47 |
+
correct_answer: str
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def normalize_letter(raw: Optional[str]) -> Optional[str]:
|
| 51 |
+
"""Normalize a raw cell to a single uppercase letter or None."""
|
| 52 |
+
if raw is None:
|
| 53 |
+
return None
|
| 54 |
+
s = str(raw).strip()
|
| 55 |
+
if s.casefold() in _BLANK_MARKERS:
|
| 56 |
+
return None
|
| 57 |
+
s = s.upper()
|
| 58 |
+
# Tolerate "A)" or "A) foo" -> "A"
|
| 59 |
+
if len(s) > 1 and s[0].isalpha() and s[1] in (")", ".", "、", " "):
|
| 60 |
+
s = s[0]
|
| 61 |
+
return s if _LETTER_RE.match(s) else None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _normalize_row(
|
| 65 |
+
answers: list, n: int
|
| 66 |
+
) -> tuple[Optional[str], ...]:
|
| 67 |
+
"""Normalize an answer row: pad/truncate to length n."""
|
| 68 |
+
out: list[Optional[str]] = []
|
| 69 |
+
for i in range(n):
|
| 70 |
+
raw = answers[i] if i < len(answers) else None
|
| 71 |
+
out.append(normalize_letter(raw))
|
| 72 |
+
return tuple(out)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def from_dict(payload: dict) -> AnswerGrid:
|
| 76 |
+
"""Parse + validate a client-submitted answer grid."""
|
| 77 |
+
if not isinstance(payload, dict):
|
| 78 |
+
raise ValueError("payload must be a dict")
|
| 79 |
+
|
| 80 |
+
n_raw = payload.get("total_questions")
|
| 81 |
+
if not isinstance(n_raw, int) or n_raw < 1:
|
| 82 |
+
raise ValueError("total_questions must be a positive integer")
|
| 83 |
+
n = n_raw
|
| 84 |
+
|
| 85 |
+
official = payload.get("official_answers") or []
|
| 86 |
+
if not isinstance(official, list):
|
| 87 |
+
raise ValueError("official_answers must be a list")
|
| 88 |
+
official_tuple = _normalize_row(official, n)
|
| 89 |
+
|
| 90 |
+
students_raw = payload.get("students") or []
|
| 91 |
+
if not isinstance(students_raw, list):
|
| 92 |
+
raise ValueError("students must be a list")
|
| 93 |
+
students: list[StudentRow] = []
|
| 94 |
+
for i, s in enumerate(students_raw):
|
| 95 |
+
if not isinstance(s, dict):
|
| 96 |
+
raise ValueError(f"students[{i}] must be a dict")
|
| 97 |
+
name = str(s.get("name") or f"Student {i + 1}")
|
| 98 |
+
answers = s.get("answers") or []
|
| 99 |
+
if not isinstance(answers, list):
|
| 100 |
+
raise ValueError(f"students[{i}].answers must be a list")
|
| 101 |
+
students.append(StudentRow(name=name, answers=_normalize_row(answers, n)))
|
| 102 |
+
|
| 103 |
+
questions_raw = payload.get("questions") or []
|
| 104 |
+
if not isinstance(questions_raw, list):
|
| 105 |
+
raise ValueError("questions must be a list")
|
| 106 |
+
questions: list[QuestionMeta] = []
|
| 107 |
+
for i in range(n):
|
| 108 |
+
q = questions_raw[i] if i < len(questions_raw) else {}
|
| 109 |
+
if not isinstance(q, dict):
|
| 110 |
+
q = {}
|
| 111 |
+
number = q.get("number") if isinstance(q.get("number"), int) else i + 1
|
| 112 |
+
text = str(q.get("text") or "")
|
| 113 |
+
opts_raw = q.get("options") or []
|
| 114 |
+
opts = tuple(str(o) for o in opts_raw) if isinstance(opts_raw, list) else ()
|
| 115 |
+
questions.append(QuestionMeta(number=number, text=text, options=opts))
|
| 116 |
+
|
| 117 |
+
return AnswerGrid(
|
| 118 |
+
total_questions=n,
|
| 119 |
+
official_answers=official_tuple,
|
| 120 |
+
students=tuple(students),
|
| 121 |
+
questions=tuple(questions),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def to_dict(grid: AnswerGrid) -> dict:
|
| 126 |
+
"""Serialize an AnswerGrid to JSON-friendly dict."""
|
| 127 |
+
return {
|
| 128 |
+
"total_questions": grid.total_questions,
|
| 129 |
+
"official_answers": list(grid.official_answers),
|
| 130 |
+
"students": [
|
| 131 |
+
{"name": s.name, "answers": list(s.answers)} for s in grid.students
|
| 132 |
+
],
|
| 133 |
+
"questions": [
|
| 134 |
+
{"number": q.number, "text": q.text, "options": list(q.options)}
|
| 135 |
+
for q in grid.questions
|
| 136 |
+
],
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def seed_from_parsed(
|
| 141 |
+
questions_data: dict,
|
| 142 |
+
student_answers_data: dict,
|
| 143 |
+
teacher_answers_data: dict,
|
| 144 |
+
) -> AnswerGrid:
|
| 145 |
+
"""Synthesize an initial grid from existing parsed JSON.
|
| 146 |
+
|
| 147 |
+
- N is derived from questions_data if present, else from teacher_answers length,
|
| 148 |
+
else from the longest student row.
|
| 149 |
+
- Student rows are padded/truncated to N.
|
| 150 |
+
- Cells are normalized to letters or None.
|
| 151 |
+
"""
|
| 152 |
+
q_list = (questions_data or {}).get("questions") or []
|
| 153 |
+
t_list = (teacher_answers_data or {}).get("answers") or []
|
| 154 |
+
s_list = (student_answers_data or {}).get("students") or []
|
| 155 |
+
|
| 156 |
+
if q_list:
|
| 157 |
+
n = len(q_list)
|
| 158 |
+
elif t_list:
|
| 159 |
+
n = max((a.get("question_number", 0) for a in t_list), default=0)
|
| 160 |
+
else:
|
| 161 |
+
longest = 0
|
| 162 |
+
for s in s_list:
|
| 163 |
+
ans = s.get("answers") or []
|
| 164 |
+
nums = [a.get("question_number", 0) for a in ans]
|
| 165 |
+
if nums:
|
| 166 |
+
longest = max(longest, max(nums))
|
| 167 |
+
n = longest
|
| 168 |
+
|
| 169 |
+
n = max(n, 1)
|
| 170 |
+
|
| 171 |
+
# Build official answers by question_number index
|
| 172 |
+
official: list[Optional[str]] = [None] * n
|
| 173 |
+
for a in t_list:
|
| 174 |
+
qn = a.get("question_number")
|
| 175 |
+
if isinstance(qn, int) and 1 <= qn <= n:
|
| 176 |
+
official[qn - 1] = normalize_letter(a.get("correct_answer"))
|
| 177 |
+
|
| 178 |
+
# Build student rows by question_number index
|
| 179 |
+
students: list[StudentRow] = []
|
| 180 |
+
for i, s in enumerate(s_list):
|
| 181 |
+
row: list[Optional[str]] = [None] * n
|
| 182 |
+
for a in s.get("answers") or []:
|
| 183 |
+
qn = a.get("question_number")
|
| 184 |
+
if isinstance(qn, int) and 1 <= qn <= n:
|
| 185 |
+
row[qn - 1] = normalize_letter(a.get("answer"))
|
| 186 |
+
name = str(s.get("name") or f"Student {i + 1}")
|
| 187 |
+
students.append(StudentRow(name=name, answers=tuple(row)))
|
| 188 |
+
|
| 189 |
+
# Build question metadata
|
| 190 |
+
questions: list[QuestionMeta] = []
|
| 191 |
+
for i in range(n):
|
| 192 |
+
q = q_list[i] if i < len(q_list) else {}
|
| 193 |
+
number = q.get("number") if isinstance(q.get("number"), int) else i + 1
|
| 194 |
+
text = str(q.get("text") or "")
|
| 195 |
+
opts_raw = q.get("options") or []
|
| 196 |
+
opts = tuple(str(o) for o in opts_raw) if isinstance(opts_raw, list) else ()
|
| 197 |
+
questions.append(QuestionMeta(number=number, text=text, options=opts))
|
| 198 |
+
|
| 199 |
+
return AnswerGrid(
|
| 200 |
+
total_questions=n,
|
| 201 |
+
official_answers=tuple(official),
|
| 202 |
+
students=tuple(students),
|
| 203 |
+
questions=tuple(questions),
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def diff_student(grid: AnswerGrid, student_index: int) -> list[WrongAnswer]:
|
| 208 |
+
"""Return the student's wrong answers ordered by question number.
|
| 209 |
+
|
| 210 |
+
Skips questions where the official key is blank (None).
|
| 211 |
+
"""
|
| 212 |
+
if student_index < 0 or student_index >= len(grid.students):
|
| 213 |
+
raise IndexError(f"student_index {student_index} out of range")
|
| 214 |
+
row = grid.students[student_index].answers
|
| 215 |
+
wrongs: list[WrongAnswer] = []
|
| 216 |
+
for i, correct in enumerate(grid.official_answers):
|
| 217 |
+
if correct is None:
|
| 218 |
+
continue
|
| 219 |
+
student_ans = row[i] if i < len(row) else None
|
| 220 |
+
# Blank student answer is always wrong when a key exists
|
| 221 |
+
if student_ans != correct:
|
| 222 |
+
wrongs.append(
|
| 223 |
+
WrongAnswer(
|
| 224 |
+
question=grid.questions[i],
|
| 225 |
+
student_answer=student_ans,
|
| 226 |
+
correct_answer=correct,
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
return wrongs
|
chatkit/backend/app/database.py
CHANGED
|
@@ -219,6 +219,38 @@ async def delete_parsed_data(session_id: int, data_type: Optional[str] = None):
|
|
| 219 |
await db.commit()
|
| 220 |
|
| 221 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
# ---- Prompt CRUD ----
|
| 223 |
|
| 224 |
async def save_prompt(user_id: int, name: str, content: str, is_default: bool = False) -> int:
|
|
|
|
| 219 |
await db.commit()
|
| 220 |
|
| 221 |
|
| 222 |
+
# ---- Answer Grid CRUD ----
|
| 223 |
+
|
| 224 |
+
ANSWER_GRID_DATA_TYPE = "answer_grid"
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
async def save_answer_grid(session_id: int, grid_dict: dict) -> int:
|
| 228 |
+
"""Save confirmed answer grid, replacing any previous one for the session."""
|
| 229 |
+
await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE)
|
| 230 |
+
return await save_parsed_data(
|
| 231 |
+
session_id=session_id,
|
| 232 |
+
data_type=ANSWER_GRID_DATA_TYPE,
|
| 233 |
+
file_name="",
|
| 234 |
+
raw_text="",
|
| 235 |
+
structured_data=grid_dict,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
async def get_answer_grid(session_id: int) -> Optional[dict]:
|
| 240 |
+
"""Return the saved answer grid dict or None if not confirmed."""
|
| 241 |
+
db_path = _get_db_path()
|
| 242 |
+
async with aiosqlite.connect(db_path) as db:
|
| 243 |
+
db.row_factory = aiosqlite.Row
|
| 244 |
+
async with db.execute(
|
| 245 |
+
"SELECT structured_data FROM parsed_data WHERE session_id = ? AND data_type = ? ORDER BY created_at DESC LIMIT 1",
|
| 246 |
+
(session_id, ANSWER_GRID_DATA_TYPE),
|
| 247 |
+
) as cursor:
|
| 248 |
+
row = await cursor.fetchone()
|
| 249 |
+
if not row:
|
| 250 |
+
return None
|
| 251 |
+
return json.loads(row["structured_data"])
|
| 252 |
+
|
| 253 |
+
|
| 254 |
# ---- Prompt CRUD ----
|
| 255 |
|
| 256 |
async def save_prompt(user_id: int, name: str, content: str, is_default: bool = False) -> int:
|
chatkit/backend/app/main.py
CHANGED
|
@@ -28,10 +28,15 @@ from .database import (
|
|
| 28 |
save_report,
|
| 29 |
get_reports,
|
| 30 |
get_report,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
)
|
| 32 |
from .auth import get_current_user, register_user, login_user
|
| 33 |
from .file_processor import process_uploaded_files
|
| 34 |
-
from .
|
|
|
|
| 35 |
|
| 36 |
STATIC_DIR = Path(__file__).parent.parent / "static"
|
| 37 |
|
|
@@ -140,6 +145,8 @@ async def api_upload_files(
|
|
| 140 |
try:
|
| 141 |
structured = await process_uploaded_files(files, data_type, session_id, description=description, model=model)
|
| 142 |
await update_session_status(session_id, "processed")
|
|
|
|
|
|
|
| 143 |
return {"status": "ok", "data_type": data_type, "data": structured}
|
| 144 |
except ValueError as e:
|
| 145 |
raise HTTPException(status_code=400, detail=str(e))
|
|
@@ -185,12 +192,6 @@ async def api_list_all_prompts(user=Depends(get_current_user)):
|
|
| 185 |
return {"prompts": prompts}
|
| 186 |
|
| 187 |
|
| 188 |
-
@app.get("/api/prompts/default")
|
| 189 |
-
async def api_get_default_prompt():
|
| 190 |
-
"""Get the default analysis prompt template."""
|
| 191 |
-
return {"content": get_default_prompt()}
|
| 192 |
-
|
| 193 |
-
|
| 194 |
@app.post("/api/prompts")
|
| 195 |
async def api_save_prompt(body: PromptRequest, user=Depends(get_current_user)):
|
| 196 |
prompt_id = await save_prompt(user["id"], body.name, body.content)
|
|
@@ -225,101 +226,134 @@ async def api_delete_prompt(prompt_id: int, user=Depends(get_current_user)):
|
|
| 225 |
|
| 226 |
|
| 227 |
# =============================================================================
|
| 228 |
-
#
|
| 229 |
# =============================================================================
|
| 230 |
|
| 231 |
-
class
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
class GenerateStudentReportRequest(BaseModel):
|
| 237 |
-
student_index: int
|
| 238 |
-
prompt_template: Optional[str] = None
|
| 239 |
-
model: str = "gpt-5.4"
|
| 240 |
|
| 241 |
|
| 242 |
-
|
| 243 |
-
async def api_preview_student_prompt(
|
| 244 |
-
session_id: int, body: PreviewStudentPromptRequest, user=Depends(get_current_user)
|
| 245 |
-
):
|
| 246 |
-
"""Preview the filled prompt for a single student (raw data substituted into template)."""
|
| 247 |
session = await get_session(session_id)
|
| 248 |
if not session or session["user_id"] != user["id"]:
|
| 249 |
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
|
|
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
data = await get_parsed_data(session_id)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
for item in data:
|
| 256 |
if item["data_type"] == "questions":
|
| 257 |
-
|
| 258 |
elif item["data_type"] == "student_answers":
|
| 259 |
-
|
| 260 |
elif item["data_type"] == "teacher_answers":
|
| 261 |
-
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
raise HTTPException(status_code=400, detail=f"Invalid student index: {body.student_index}")
|
| 266 |
|
| 267 |
-
student = students[body.student_index]
|
| 268 |
-
result = build_student_prompt(student, questions, teacher_answers, body.prompt_template)
|
| 269 |
-
return result
|
| 270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
if not session or session["user_id"] != user["id"]:
|
| 277 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
if item["data_type"] == "student_answers":
|
| 283 |
-
student_answers = item["structured_data"]
|
| 284 |
-
break
|
| 285 |
|
| 286 |
-
return {"students": get_student_names(student_answers)}
|
| 287 |
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
):
|
| 293 |
-
"""
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
if item["data_type"] == "questions":
|
| 304 |
-
questions = item["structured_data"]
|
| 305 |
-
elif item["data_type"] == "student_answers":
|
| 306 |
-
student_answers = item["structured_data"]
|
| 307 |
-
elif item["data_type"] == "teacher_answers":
|
| 308 |
-
teacher_answers = item["structured_data"]
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
-
students = student_answers.get("students", [])
|
| 314 |
-
if body.student_index < 0 or body.student_index >= len(students):
|
| 315 |
-
raise HTTPException(status_code=400, detail=f"Invalid student index: {body.student_index}")
|
| 316 |
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
|
| 319 |
try:
|
| 320 |
-
html = await generate_student_report(
|
| 321 |
report_id = await save_report(session_id, None, html)
|
| 322 |
-
|
|
|
|
| 323 |
except Exception as e:
|
| 324 |
raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
|
| 325 |
|
|
|
|
| 28 |
save_report,
|
| 29 |
get_reports,
|
| 30 |
get_report,
|
| 31 |
+
save_answer_grid,
|
| 32 |
+
get_answer_grid,
|
| 33 |
+
delete_parsed_data,
|
| 34 |
+
ANSWER_GRID_DATA_TYPE,
|
| 35 |
)
|
| 36 |
from .auth import get_current_user, register_user, login_user
|
| 37 |
from .file_processor import process_uploaded_files
|
| 38 |
+
from .answer_grid import from_dict as grid_from_dict, seed_from_parsed, to_dict as grid_to_dict
|
| 39 |
+
from .report_generator import generate_student_report, build_student_markdown
|
| 40 |
|
| 41 |
STATIC_DIR = Path(__file__).parent.parent / "static"
|
| 42 |
|
|
|
|
| 145 |
try:
|
| 146 |
structured = await process_uploaded_files(files, data_type, session_id, description=description, model=model)
|
| 147 |
await update_session_status(session_id, "processed")
|
| 148 |
+
# Invalidate any previously-confirmed answer grid since inputs changed
|
| 149 |
+
await delete_parsed_data(session_id, ANSWER_GRID_DATA_TYPE)
|
| 150 |
return {"status": "ok", "data_type": data_type, "data": structured}
|
| 151 |
except ValueError as e:
|
| 152 |
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
| 192 |
return {"prompts": prompts}
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
@app.post("/api/prompts")
|
| 196 |
async def api_save_prompt(body: PromptRequest, user=Depends(get_current_user)):
|
| 197 |
prompt_id = await save_prompt(user["id"], body.name, body.content)
|
|
|
|
| 226 |
|
| 227 |
|
| 228 |
# =============================================================================
|
| 229 |
+
# Answer Grid Endpoints (canonical data for report generation)
|
| 230 |
# =============================================================================
|
| 231 |
|
| 232 |
+
class AnswerGridPayload(BaseModel):
|
| 233 |
+
total_questions: int
|
| 234 |
+
official_answers: list[Optional[str]]
|
| 235 |
+
students: list[dict]
|
| 236 |
+
questions: list[dict]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
|
| 239 |
+
async def _session_or_404(session_id: int, user: dict) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
session = await get_session(session_id)
|
| 241 |
if not session or session["user_id"] != user["id"]:
|
| 242 |
raise HTTPException(status_code=404, detail="Session not found")
|
| 243 |
+
return session
|
| 244 |
+
|
| 245 |
|
| 246 |
+
@app.get("/api/sessions/{session_id}/answer-grid")
|
| 247 |
+
async def api_get_answer_grid(session_id: int, user=Depends(get_current_user)):
|
| 248 |
+
"""Return the saved grid, or a seeded grid synthesized from parsed data."""
|
| 249 |
+
await _session_or_404(session_id, user)
|
| 250 |
+
|
| 251 |
+
saved = await get_answer_grid(session_id)
|
| 252 |
+
if saved:
|
| 253 |
+
return {"grid": saved, "is_confirmed": True}
|
| 254 |
+
|
| 255 |
+
# Seed from parsed data
|
| 256 |
data = await get_parsed_data(session_id)
|
| 257 |
+
questions_data: dict = {}
|
| 258 |
+
student_answers_data: dict = {}
|
| 259 |
+
teacher_answers_data: dict = {}
|
| 260 |
for item in data:
|
| 261 |
if item["data_type"] == "questions":
|
| 262 |
+
questions_data = item["structured_data"]
|
| 263 |
elif item["data_type"] == "student_answers":
|
| 264 |
+
student_answers_data = item["structured_data"]
|
| 265 |
elif item["data_type"] == "teacher_answers":
|
| 266 |
+
teacher_answers_data = item["structured_data"]
|
| 267 |
|
| 268 |
+
grid = seed_from_parsed(questions_data, student_answers_data, teacher_answers_data)
|
| 269 |
+
return {"grid": grid_to_dict(grid), "is_confirmed": False}
|
|
|
|
| 270 |
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
+
@app.post("/api/sessions/{session_id}/answer-grid")
|
| 273 |
+
async def api_save_answer_grid(
|
| 274 |
+
session_id: int, body: AnswerGridPayload, user=Depends(get_current_user)
|
| 275 |
+
):
|
| 276 |
+
"""Persist a user-confirmed answer grid."""
|
| 277 |
+
await _session_or_404(session_id, user)
|
| 278 |
|
| 279 |
+
try:
|
| 280 |
+
grid = grid_from_dict(body.model_dump())
|
| 281 |
+
except ValueError as e:
|
| 282 |
+
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
|
| 283 |
|
| 284 |
+
grid_dict = grid_to_dict(grid)
|
| 285 |
+
await save_answer_grid(session_id, grid_dict)
|
| 286 |
+
return {"grid": grid_dict, "is_confirmed": True}
|
|
|
|
|
|
|
|
|
|
| 287 |
|
|
|
|
| 288 |
|
| 289 |
+
# =============================================================================
|
| 290 |
+
# Report Generation Endpoints
|
| 291 |
+
# =============================================================================
|
| 292 |
|
| 293 |
+
class PreviewStudentPromptRequest(BaseModel):
|
| 294 |
+
student_index: int
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class GenerateStudentReportRequest(BaseModel):
|
| 298 |
+
student_index: int
|
| 299 |
+
model: str = "gpt-5.4"
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
async def _require_confirmed_grid(session_id: int):
|
| 303 |
+
saved = await get_answer_grid(session_id)
|
| 304 |
+
if not saved:
|
| 305 |
+
raise HTTPException(
|
| 306 |
+
status_code=400,
|
| 307 |
+
detail="Answer grid not confirmed. Please confirm it in step 2 first.",
|
| 308 |
+
)
|
| 309 |
+
try:
|
| 310 |
+
return grid_from_dict(saved)
|
| 311 |
+
except ValueError as e:
|
| 312 |
+
raise HTTPException(status_code=400, detail=f"Invalid saved grid: {e}")
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@app.post("/api/sessions/{session_id}/preview-student-prompt")
|
| 316 |
+
async def api_preview_student_prompt(
|
| 317 |
+
session_id: int, body: PreviewStudentPromptRequest, user=Depends(get_current_user)
|
| 318 |
):
|
| 319 |
+
"""Preview the markdown prompt that will be sent to the LLM."""
|
| 320 |
+
await _session_or_404(session_id, user)
|
| 321 |
+
grid = await _require_confirmed_grid(session_id)
|
|
|
|
| 322 |
|
| 323 |
+
if body.student_index < 0 or body.student_index >= len(grid.students):
|
| 324 |
+
raise HTTPException(
|
| 325 |
+
status_code=400,
|
| 326 |
+
detail=f"Invalid student index: {body.student_index}",
|
| 327 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
+
prompt = build_student_markdown(grid, body.student_index)
|
| 330 |
+
return {
|
| 331 |
+
"student_name": prompt.student_name,
|
| 332 |
+
"total_questions": prompt.total_questions,
|
| 333 |
+
"wrong_count": prompt.wrong_count,
|
| 334 |
+
"markdown_prompt": prompt.markdown_prompt,
|
| 335 |
+
}
|
| 336 |
|
|
|
|
|
|
|
|
|
|
| 337 |
|
| 338 |
+
@app.post("/api/sessions/{session_id}/generate-student-report")
|
| 339 |
+
async def api_generate_student_report(
|
| 340 |
+
session_id: int, body: GenerateStudentReportRequest, user=Depends(get_current_user)
|
| 341 |
+
):
|
| 342 |
+
"""Generate an HTML report for one student from the confirmed grid."""
|
| 343 |
+
await _session_or_404(session_id, user)
|
| 344 |
+
grid = await _require_confirmed_grid(session_id)
|
| 345 |
+
|
| 346 |
+
if body.student_index < 0 or body.student_index >= len(grid.students):
|
| 347 |
+
raise HTTPException(
|
| 348 |
+
status_code=400,
|
| 349 |
+
detail=f"Invalid student index: {body.student_index}",
|
| 350 |
+
)
|
| 351 |
|
| 352 |
try:
|
| 353 |
+
html = await generate_student_report(grid, body.student_index, body.model)
|
| 354 |
report_id = await save_report(session_id, None, html)
|
| 355 |
+
student_name = grid.students[body.student_index].name
|
| 356 |
+
return {"report_id": report_id, "html_content": html, "student_name": student_name}
|
| 357 |
except Exception as e:
|
| 358 |
raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
|
| 359 |
|
chatkit/backend/app/report_generator.py
CHANGED
|
@@ -1,13 +1,18 @@
|
|
| 1 |
-
"""Report generation
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
import json
|
| 6 |
import logging
|
| 7 |
import re
|
|
|
|
| 8 |
|
| 9 |
from openai import AsyncOpenAI
|
| 10 |
|
|
|
|
| 11 |
from .config import get_settings
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
|
@@ -37,20 +42,13 @@ def extract_html(content: str) -> str:
|
|
| 37 |
match = re.search(r"```html?\s*\n(.*?)```", content, re.DOTALL)
|
| 38 |
if match:
|
| 39 |
return match.group(1).strip()
|
| 40 |
-
|
| 41 |
-
if stripped.startswith("<!DOCTYPE") or stripped.startswith("<html"):
|
| 42 |
-
return stripped
|
| 43 |
-
return stripped
|
| 44 |
-
|
| 45 |
|
| 46 |
-
# ---------------------------------------------------------------------------
|
| 47 |
-
# Per-student report generation
|
| 48 |
-
# ---------------------------------------------------------------------------
|
| 49 |
|
| 50 |
STUDENT_REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates a focused, personalized exam analysis report for a single student.
|
| 51 |
|
| 52 |
## Your Task
|
| 53 |
-
Generate a complete, self-contained HTML report analyzing this student's exam performance.
|
| 54 |
|
| 55 |
## Language
|
| 56 |
- Use Traditional Chinese (繁體中文) for all content
|
|
@@ -59,13 +57,13 @@ Generate a complete, self-contained HTML report analyzing this student's exam pe
|
|
| 59 |
## HTML Report Sections
|
| 60 |
|
| 61 |
### Section 1: 📋 Student Overview (學生概覽)
|
| 62 |
-
- Student name (
|
| 63 |
-
- Total questions,
|
| 64 |
- A brief overall assessment
|
| 65 |
|
| 66 |
### Section 2: 📝 Wrong Answer Analysis (錯題詳解)
|
| 67 |
-
For EACH wrong answer:
|
| 68 |
-
- Question number, full question text
|
| 69 |
- ❌ Student's wrong answer vs ✅ Correct answer
|
| 70 |
- Concept tags (e.g., 🎯 細節理解, 🔍 推論能力)
|
| 71 |
- Why the student likely chose the wrong answer
|
|
@@ -107,108 +105,88 @@ Use this dark theme:
|
|
| 107 |
Return ONLY the complete HTML document. No markdown code fences. Just raw HTML starting with <!DOCTYPE html>."""
|
| 108 |
|
| 109 |
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
##
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
{
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
{
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
| 153 |
|
| 154 |
-
|
| 155 |
-
student_name=
|
| 156 |
-
total_questions=total_questions,
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
teacher_answers=json.dumps(teacher_answers, ensure_ascii=False, indent=2) if teacher_answers else "(未提供)",
|
| 160 |
)
|
| 161 |
|
| 162 |
-
return {
|
| 163 |
-
"filled_prompt": filled,
|
| 164 |
-
"student_name": student_name,
|
| 165 |
-
"total_questions": total_questions,
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def get_student_names(student_answers_data: dict) -> list[dict]:
|
| 170 |
-
"""Extract student names/ids from parsed student answers data."""
|
| 171 |
-
students = student_answers_data.get("students", [])
|
| 172 |
-
result = []
|
| 173 |
-
for i, s in enumerate(students):
|
| 174 |
-
result.append({
|
| 175 |
-
"index": i,
|
| 176 |
-
"name": s.get("name", f"Student {i + 1}"),
|
| 177 |
-
"id": s.get("id", ""),
|
| 178 |
-
})
|
| 179 |
-
return result
|
| 180 |
-
|
| 181 |
|
| 182 |
async def generate_student_report(
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
teacher_answers_data: dict,
|
| 186 |
-
prompt_template: str | None = None,
|
| 187 |
model: str = "gpt-5.4",
|
| 188 |
) -> str:
|
| 189 |
-
"""Generate HTML report for a single student.
|
| 190 |
-
|
| 191 |
-
Sends raw data to the LLM — the prompt_template controls how data is interpreted.
|
| 192 |
-
"""
|
| 193 |
settings = get_settings()
|
| 194 |
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 195 |
|
| 196 |
-
|
| 197 |
-
filled_prompt = result["filled_prompt"]
|
| 198 |
-
student_name = result["student_name"]
|
| 199 |
|
| 200 |
-
prompt_tokens = _estimate_tokens(STUDENT_REPORT_SYSTEM_PROMPT) + _estimate_tokens(
|
|
|
|
|
|
|
| 201 |
context_limit = MODEL_CONTEXT_LIMITS.get(model, DEFAULT_CONTEXT_LIMIT)
|
| 202 |
available = context_limit - prompt_tokens - TOKEN_SAFETY_MARGIN
|
| 203 |
|
| 204 |
logger.info(
|
| 205 |
-
"Student report for %s — ~%d prompt tokens, %d available (model: %s)",
|
| 206 |
-
student_name,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
)
|
| 208 |
|
| 209 |
if available < MIN_COMPLETION_TOKENS:
|
| 210 |
raise ValueError(
|
| 211 |
-
f"學生 {student_name} 的資料過大(約 {prompt_tokens:,} tokens)
|
| 212 |
)
|
| 213 |
|
| 214 |
max_completion = min(DESIRED_COMPLETION_TOKENS, available)
|
|
@@ -217,7 +195,7 @@ async def generate_student_report(
|
|
| 217 |
"model": model,
|
| 218 |
"messages": [
|
| 219 |
{"role": "system", "content": STUDENT_REPORT_SYSTEM_PROMPT},
|
| 220 |
-
{"role": "user", "content":
|
| 221 |
],
|
| 222 |
"max_completion_tokens": max_completion,
|
| 223 |
"temperature": 0.3,
|
|
@@ -241,5 +219,7 @@ async def generate_student_report(
|
|
| 241 |
detail = f"finish_reason={reason}"
|
| 242 |
if refusal:
|
| 243 |
detail += f", refusal={refusal}"
|
| 244 |
-
raise ValueError(
|
|
|
|
|
|
|
| 245 |
return extract_html(html_content)
|
|
|
|
| 1 |
+
"""Report generation from a confirmed AnswerGrid.
|
| 2 |
+
|
| 3 |
+
Builds compact markdown prompts containing only the wrong-answer blocks for
|
| 4 |
+
each student, then calls OpenAI to produce an HTML report.
|
| 5 |
+
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
| 9 |
import logging
|
| 10 |
import re
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
|
| 13 |
from openai import AsyncOpenAI
|
| 14 |
|
| 15 |
+
from .answer_grid import AnswerGrid, WrongAnswer, diff_student
|
| 16 |
from .config import get_settings
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
|
|
|
| 42 |
match = re.search(r"```html?\s*\n(.*?)```", content, re.DOTALL)
|
| 43 |
if match:
|
| 44 |
return match.group(1).strip()
|
| 45 |
+
return content.strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
STUDENT_REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates a focused, personalized exam analysis report for a single student.
|
| 49 |
|
| 50 |
## Your Task
|
| 51 |
+
Generate a complete, self-contained HTML report analyzing this student's exam performance based ONLY on the wrong-answer blocks provided. The system has already pre-filtered to the questions this student got wrong — do not guess about questions not included.
|
| 52 |
|
| 53 |
## Language
|
| 54 |
- Use Traditional Chinese (繁體中文) for all content
|
|
|
|
| 57 |
## HTML Report Sections
|
| 58 |
|
| 59 |
### Section 1: 📋 Student Overview (學生概覽)
|
| 60 |
+
- Student name (partial name for privacy, e.g., 李X恩)
|
| 61 |
+
- Total questions, wrong count, score percentage (derived from counts)
|
| 62 |
- A brief overall assessment
|
| 63 |
|
| 64 |
### Section 2: 📝 Wrong Answer Analysis (錯題詳解)
|
| 65 |
+
For EACH wrong answer block provided:
|
| 66 |
+
- Question number, full question text
|
| 67 |
- ❌ Student's wrong answer vs ✅ Correct answer
|
| 68 |
- Concept tags (e.g., 🎯 細節理解, 🔍 推論能力)
|
| 69 |
- Why the student likely chose the wrong answer
|
|
|
|
| 105 |
Return ONLY the complete HTML document. No markdown code fences. Just raw HTML starting with <!DOCTYPE html>."""
|
| 106 |
|
| 107 |
|
| 108 |
+
@dataclass(frozen=True)
|
| 109 |
+
class StudentPrompt:
|
| 110 |
+
student_name: str
|
| 111 |
+
total_questions: int
|
| 112 |
+
wrong_count: int
|
| 113 |
+
markdown_prompt: str
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def build_wrong_block(wa: WrongAnswer) -> str:
|
| 117 |
+
"""Render one wrong-question block in markdown format."""
|
| 118 |
+
lines = [f"### 第 {wa.question.number} 題"]
|
| 119 |
+
if wa.question.text:
|
| 120 |
+
lines.append(f"**題目**: {wa.question.text}")
|
| 121 |
+
if wa.question.options:
|
| 122 |
+
options_str = "\n".join(f"- {o}" for o in wa.question.options)
|
| 123 |
+
lines.append(f"**選項**:\n{options_str}")
|
| 124 |
+
lines.append(f"**學生作答**: {wa.student_answer if wa.student_answer else '(未作答)'}")
|
| 125 |
+
lines.append(f"**正確答案**: {wa.correct_answer}")
|
| 126 |
+
return "\n".join(lines)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def build_student_markdown(grid: AnswerGrid, student_index: int) -> StudentPrompt:
|
| 130 |
+
"""Assemble the full markdown prompt for one student."""
|
| 131 |
+
if student_index < 0 or student_index >= len(grid.students):
|
| 132 |
+
raise IndexError(f"student_index {student_index} out of range")
|
| 133 |
+
|
| 134 |
+
student = grid.students[student_index]
|
| 135 |
+
wrongs = diff_student(grid, student_index)
|
| 136 |
+
|
| 137 |
+
if not wrongs:
|
| 138 |
+
markdown = (
|
| 139 |
+
f"## 學生:{student.name}\n"
|
| 140 |
+
f"## 總題數:{grid.total_questions},答錯:0\n\n"
|
| 141 |
+
f"本次測驗全部答對,請為此學生生成鼓勵性的 HTML 報告,"
|
| 142 |
+
f"肯定其學習成果並建議延伸學習方向。"
|
| 143 |
+
)
|
| 144 |
+
else:
|
| 145 |
+
blocks = "\n\n".join(build_wrong_block(w) for w in wrongs)
|
| 146 |
+
markdown = (
|
| 147 |
+
f"## 學生:{student.name}\n"
|
| 148 |
+
f"## 總題數:{grid.total_questions},答錯:{len(wrongs)}\n\n"
|
| 149 |
+
f"{blocks}\n\n"
|
| 150 |
+
f"請為此學生生成個人化 HTML 報告,聚焦以上錯題。"
|
| 151 |
+
)
|
| 152 |
|
| 153 |
+
return StudentPrompt(
|
| 154 |
+
student_name=student.name,
|
| 155 |
+
total_questions=grid.total_questions,
|
| 156 |
+
wrong_count=len(wrongs),
|
| 157 |
+
markdown_prompt=markdown,
|
|
|
|
| 158 |
)
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
async def generate_student_report(
|
| 162 |
+
grid: AnswerGrid,
|
| 163 |
+
student_index: int,
|
|
|
|
|
|
|
| 164 |
model: str = "gpt-5.4",
|
| 165 |
) -> str:
|
| 166 |
+
"""Generate an HTML report for a single student using the markdown prompt."""
|
|
|
|
|
|
|
|
|
|
| 167 |
settings = get_settings()
|
| 168 |
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
| 169 |
|
| 170 |
+
prompt = build_student_markdown(grid, student_index)
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
prompt_tokens = _estimate_tokens(STUDENT_REPORT_SYSTEM_PROMPT) + _estimate_tokens(
|
| 173 |
+
prompt.markdown_prompt
|
| 174 |
+
)
|
| 175 |
context_limit = MODEL_CONTEXT_LIMITS.get(model, DEFAULT_CONTEXT_LIMIT)
|
| 176 |
available = context_limit - prompt_tokens - TOKEN_SAFETY_MARGIN
|
| 177 |
|
| 178 |
logger.info(
|
| 179 |
+
"Student report for %s — wrong=%d, ~%d prompt tokens, %d available (model: %s)",
|
| 180 |
+
prompt.student_name,
|
| 181 |
+
prompt.wrong_count,
|
| 182 |
+
prompt_tokens,
|
| 183 |
+
available,
|
| 184 |
+
model,
|
| 185 |
)
|
| 186 |
|
| 187 |
if available < MIN_COMPLETION_TOKENS:
|
| 188 |
raise ValueError(
|
| 189 |
+
f"學生 {prompt.student_name} 的資料過大(約 {prompt_tokens:,} tokens)。"
|
| 190 |
)
|
| 191 |
|
| 192 |
max_completion = min(DESIRED_COMPLETION_TOKENS, available)
|
|
|
|
| 195 |
"model": model,
|
| 196 |
"messages": [
|
| 197 |
{"role": "system", "content": STUDENT_REPORT_SYSTEM_PROMPT},
|
| 198 |
+
{"role": "user", "content": prompt.markdown_prompt},
|
| 199 |
],
|
| 200 |
"max_completion_tokens": max_completion,
|
| 201 |
"temperature": 0.3,
|
|
|
|
| 219 |
detail = f"finish_reason={reason}"
|
| 220 |
if refusal:
|
| 221 |
detail += f", refusal={refusal}"
|
| 222 |
+
raise ValueError(
|
| 223 |
+
f"Model returned empty content for {prompt.student_name} ({detail})."
|
| 224 |
+
)
|
| 225 |
return extract_html(html_content)
|
chatkit/backend/pyproject.toml
CHANGED
|
@@ -28,3 +28,14 @@ dev = [
|
|
| 28 |
[build-system]
|
| 29 |
requires = ["setuptools>=68.0", "wheel"]
|
| 30 |
build-backend = "setuptools.build_meta"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
[build-system]
|
| 29 |
requires = ["setuptools>=68.0", "wheel"]
|
| 30 |
build-backend = "setuptools.build_meta"
|
| 31 |
+
|
| 32 |
+
[tool.setuptools.packages.find]
|
| 33 |
+
include = ["app*"]
|
| 34 |
+
exclude = ["static*", "tests*"]
|
| 35 |
+
|
| 36 |
+
[tool.pytest.ini_options]
|
| 37 |
+
markers = [
|
| 38 |
+
"unit: marks tests as unit tests",
|
| 39 |
+
"integration: marks tests as integration tests",
|
| 40 |
+
]
|
| 41 |
+
asyncio_mode = "auto"
|
chatkit/backend/tests/__init__.py
ADDED
|
File without changes
|
chatkit/backend/tests/test_answer_grid.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for answer_grid module."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app.answer_grid import (
|
| 8 |
+
AnswerGrid,
|
| 9 |
+
QuestionMeta,
|
| 10 |
+
StudentRow,
|
| 11 |
+
diff_student,
|
| 12 |
+
from_dict,
|
| 13 |
+
normalize_letter,
|
| 14 |
+
seed_from_parsed,
|
| 15 |
+
to_dict,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# normalize_letter
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.mark.unit
|
| 25 |
+
@pytest.mark.parametrize(
|
| 26 |
+
"raw, expected",
|
| 27 |
+
[
|
| 28 |
+
("A", "A"),
|
| 29 |
+
("a", "A"),
|
| 30 |
+
(" b ", "B"),
|
| 31 |
+
("C)", "C"),
|
| 32 |
+
("D) foo", "D"),
|
| 33 |
+
("", None),
|
| 34 |
+
("-", None),
|
| 35 |
+
("–", None),
|
| 36 |
+
("—", None),
|
| 37 |
+
(None, None),
|
| 38 |
+
(" ", None),
|
| 39 |
+
("N/A", None),
|
| 40 |
+
("invalid", None),
|
| 41 |
+
("AB", None),
|
| 42 |
+
],
|
| 43 |
+
)
|
| 44 |
+
def test_normalize_letter(raw, expected):
|
| 45 |
+
assert normalize_letter(raw) == expected
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# from_dict validation
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@pytest.mark.unit
|
| 54 |
+
def test_from_dict_minimal_valid():
|
| 55 |
+
payload = {
|
| 56 |
+
"total_questions": 2,
|
| 57 |
+
"official_answers": ["A", "B"],
|
| 58 |
+
"students": [{"name": "Alice", "answers": ["A", "C"]}],
|
| 59 |
+
"questions": [
|
| 60 |
+
{"number": 1, "text": "Q1", "options": ["A) x", "B) y"]},
|
| 61 |
+
{"number": 2, "text": "Q2", "options": ["A) x", "B) y"]},
|
| 62 |
+
],
|
| 63 |
+
}
|
| 64 |
+
grid = from_dict(payload)
|
| 65 |
+
assert grid.total_questions == 2
|
| 66 |
+
assert grid.official_answers == ("A", "B")
|
| 67 |
+
assert grid.students[0].name == "Alice"
|
| 68 |
+
assert grid.students[0].answers == ("A", "C")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@pytest.mark.unit
|
| 72 |
+
def test_from_dict_pads_short_student_row():
|
| 73 |
+
payload = {
|
| 74 |
+
"total_questions": 3,
|
| 75 |
+
"official_answers": ["A", "B", "C"],
|
| 76 |
+
"students": [{"name": "Alice", "answers": ["A"]}],
|
| 77 |
+
"questions": [{"text": "Q"}, {"text": "Q"}, {"text": "Q"}],
|
| 78 |
+
}
|
| 79 |
+
grid = from_dict(payload)
|
| 80 |
+
assert grid.students[0].answers == ("A", None, None)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.mark.unit
|
| 84 |
+
def test_from_dict_truncates_long_student_row():
|
| 85 |
+
payload = {
|
| 86 |
+
"total_questions": 2,
|
| 87 |
+
"official_answers": ["A", "B"],
|
| 88 |
+
"students": [{"name": "Alice", "answers": ["A", "B", "C", "D"]}],
|
| 89 |
+
"questions": [],
|
| 90 |
+
}
|
| 91 |
+
grid = from_dict(payload)
|
| 92 |
+
assert grid.students[0].answers == ("A", "B")
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@pytest.mark.unit
|
| 96 |
+
@pytest.mark.parametrize(
|
| 97 |
+
"bad_field",
|
| 98 |
+
[
|
| 99 |
+
{"total_questions": 0},
|
| 100 |
+
{"total_questions": -1},
|
| 101 |
+
{"total_questions": "three"},
|
| 102 |
+
],
|
| 103 |
+
)
|
| 104 |
+
def test_from_dict_rejects_bad_n(bad_field):
|
| 105 |
+
payload = {
|
| 106 |
+
"total_questions": 2,
|
| 107 |
+
"official_answers": ["A", "B"],
|
| 108 |
+
"students": [],
|
| 109 |
+
"questions": [],
|
| 110 |
+
}
|
| 111 |
+
payload.update(bad_field)
|
| 112 |
+
with pytest.raises(ValueError):
|
| 113 |
+
from_dict(payload)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@pytest.mark.unit
|
| 117 |
+
def test_from_dict_default_student_name():
|
| 118 |
+
payload = {
|
| 119 |
+
"total_questions": 1,
|
| 120 |
+
"official_answers": ["A"],
|
| 121 |
+
"students": [{"answers": ["A"]}],
|
| 122 |
+
"questions": [],
|
| 123 |
+
}
|
| 124 |
+
grid = from_dict(payload)
|
| 125 |
+
assert grid.students[0].name == "Student 1"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
# to_dict roundtrip
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
@pytest.mark.unit
|
| 134 |
+
def test_to_dict_roundtrip():
|
| 135 |
+
payload = {
|
| 136 |
+
"total_questions": 2,
|
| 137 |
+
"official_answers": ["A", "B"],
|
| 138 |
+
"students": [{"name": "Alice", "answers": ["A", "C"]}],
|
| 139 |
+
"questions": [
|
| 140 |
+
{"number": 1, "text": "Q1", "options": ["A) x", "B) y"]},
|
| 141 |
+
{"number": 2, "text": "Q2", "options": ["A) x", "B) y"]},
|
| 142 |
+
],
|
| 143 |
+
}
|
| 144 |
+
grid = from_dict(payload)
|
| 145 |
+
again = from_dict(to_dict(grid))
|
| 146 |
+
assert grid == again
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
# seed_from_parsed
|
| 151 |
+
# ---------------------------------------------------------------------------
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
@pytest.mark.unit
|
| 155 |
+
def test_seed_from_parsed_basic():
|
| 156 |
+
questions = {
|
| 157 |
+
"questions": [
|
| 158 |
+
{"number": 1, "text": "Q1", "options": ["A) a", "B) b"]},
|
| 159 |
+
{"number": 2, "text": "Q2", "options": ["A) a", "B) b"]},
|
| 160 |
+
]
|
| 161 |
+
}
|
| 162 |
+
students = {
|
| 163 |
+
"students": [
|
| 164 |
+
{
|
| 165 |
+
"name": "Alice",
|
| 166 |
+
"answers": [
|
| 167 |
+
{"question_number": 1, "answer": "A"},
|
| 168 |
+
{"question_number": 2, "answer": "b"},
|
| 169 |
+
],
|
| 170 |
+
}
|
| 171 |
+
]
|
| 172 |
+
}
|
| 173 |
+
teachers = {
|
| 174 |
+
"answers": [
|
| 175 |
+
{"question_number": 1, "correct_answer": "A"},
|
| 176 |
+
{"question_number": 2, "correct_answer": "B"},
|
| 177 |
+
]
|
| 178 |
+
}
|
| 179 |
+
grid = seed_from_parsed(questions, students, teachers)
|
| 180 |
+
assert grid.total_questions == 2
|
| 181 |
+
assert grid.official_answers == ("A", "B")
|
| 182 |
+
assert grid.students[0].answers == ("A", "B")
|
| 183 |
+
assert grid.questions[0].text == "Q1"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@pytest.mark.unit
|
| 187 |
+
def test_seed_from_parsed_handles_dash_as_blank():
|
| 188 |
+
questions = {"questions": [{"number": 1, "text": "Q"}]}
|
| 189 |
+
students = {
|
| 190 |
+
"students": [
|
| 191 |
+
{"name": "Alice", "answers": [{"question_number": 1, "answer": "-"}]}
|
| 192 |
+
]
|
| 193 |
+
}
|
| 194 |
+
teachers = {"answers": [{"question_number": 1, "correct_answer": "A"}]}
|
| 195 |
+
grid = seed_from_parsed(questions, students, teachers)
|
| 196 |
+
# Dash normalizes to None — diff treats None != "A" as wrong
|
| 197 |
+
assert grid.students[0].answers == (None,)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@pytest.mark.unit
|
| 201 |
+
def test_seed_from_parsed_no_questions_derives_n_from_teacher():
|
| 202 |
+
questions = {}
|
| 203 |
+
students = {
|
| 204 |
+
"students": [{"name": "A", "answers": [{"question_number": 1, "answer": "A"}]}]
|
| 205 |
+
}
|
| 206 |
+
teachers = {
|
| 207 |
+
"answers": [
|
| 208 |
+
{"question_number": 1, "correct_answer": "A"},
|
| 209 |
+
{"question_number": 2, "correct_answer": "B"},
|
| 210 |
+
{"question_number": 3, "correct_answer": "C"},
|
| 211 |
+
]
|
| 212 |
+
}
|
| 213 |
+
grid = seed_from_parsed(questions, students, teachers)
|
| 214 |
+
assert grid.total_questions == 3
|
| 215 |
+
assert grid.official_answers == ("A", "B", "C")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@pytest.mark.unit
|
| 219 |
+
def test_seed_from_parsed_all_empty_gives_n_one():
|
| 220 |
+
grid = seed_from_parsed({}, {}, {})
|
| 221 |
+
assert grid.total_questions == 1
|
| 222 |
+
assert grid.students == ()
|
| 223 |
+
assert grid.official_answers == (None,)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# diff_student
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
@pytest.mark.unit
|
| 232 |
+
def test_diff_student_returns_only_wrong():
|
| 233 |
+
grid = AnswerGrid(
|
| 234 |
+
total_questions=3,
|
| 235 |
+
official_answers=("A", "B", "C"),
|
| 236 |
+
students=(StudentRow(name="Alice", answers=("A", "D", "C")),),
|
| 237 |
+
questions=(
|
| 238 |
+
QuestionMeta(number=1, text="Q1", options=()),
|
| 239 |
+
QuestionMeta(number=2, text="Q2", options=()),
|
| 240 |
+
QuestionMeta(number=3, text="Q3", options=()),
|
| 241 |
+
),
|
| 242 |
+
)
|
| 243 |
+
wrongs = diff_student(grid, 0)
|
| 244 |
+
assert len(wrongs) == 1
|
| 245 |
+
assert wrongs[0].question.number == 2
|
| 246 |
+
assert wrongs[0].student_answer == "D"
|
| 247 |
+
assert wrongs[0].correct_answer == "B"
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@pytest.mark.unit
|
| 251 |
+
def test_diff_student_blank_student_answer_is_wrong():
|
| 252 |
+
grid = AnswerGrid(
|
| 253 |
+
total_questions=2,
|
| 254 |
+
official_answers=("A", "B"),
|
| 255 |
+
students=(StudentRow(name="Alice", answers=("A", None)),),
|
| 256 |
+
questions=(
|
| 257 |
+
QuestionMeta(number=1, text="Q1", options=()),
|
| 258 |
+
QuestionMeta(number=2, text="Q2", options=()),
|
| 259 |
+
),
|
| 260 |
+
)
|
| 261 |
+
wrongs = diff_student(grid, 0)
|
| 262 |
+
assert len(wrongs) == 1
|
| 263 |
+
assert wrongs[0].student_answer is None
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
@pytest.mark.unit
|
| 267 |
+
def test_diff_student_skips_blank_official_key():
|
| 268 |
+
grid = AnswerGrid(
|
| 269 |
+
total_questions=2,
|
| 270 |
+
official_answers=("A", None), # Q2 key missing
|
| 271 |
+
students=(StudentRow(name="Alice", answers=("B", "C")),),
|
| 272 |
+
questions=(
|
| 273 |
+
QuestionMeta(number=1, text="Q1", options=()),
|
| 274 |
+
QuestionMeta(number=2, text="Q2", options=()),
|
| 275 |
+
),
|
| 276 |
+
)
|
| 277 |
+
wrongs = diff_student(grid, 0)
|
| 278 |
+
assert len(wrongs) == 1
|
| 279 |
+
assert wrongs[0].question.number == 1
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
@pytest.mark.unit
|
| 283 |
+
def test_diff_student_all_correct():
|
| 284 |
+
grid = AnswerGrid(
|
| 285 |
+
total_questions=2,
|
| 286 |
+
official_answers=("A", "B"),
|
| 287 |
+
students=(StudentRow(name="Alice", answers=("A", "B")),),
|
| 288 |
+
questions=(
|
| 289 |
+
QuestionMeta(number=1, text="Q1", options=()),
|
| 290 |
+
QuestionMeta(number=2, text="Q2", options=()),
|
| 291 |
+
),
|
| 292 |
+
)
|
| 293 |
+
assert diff_student(grid, 0) == []
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@pytest.mark.unit
|
| 297 |
+
def test_diff_student_out_of_range():
|
| 298 |
+
grid = AnswerGrid(
|
| 299 |
+
total_questions=1,
|
| 300 |
+
official_answers=("A",),
|
| 301 |
+
students=(),
|
| 302 |
+
questions=(QuestionMeta(number=1, text="Q", options=()),),
|
| 303 |
+
)
|
| 304 |
+
with pytest.raises(IndexError):
|
| 305 |
+
diff_student(grid, 0)
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
@pytest.mark.unit
|
| 309 |
+
def test_diff_student_preserves_question_order():
|
| 310 |
+
grid = AnswerGrid(
|
| 311 |
+
total_questions=4,
|
| 312 |
+
official_answers=("A", "B", "C", "D"),
|
| 313 |
+
students=(StudentRow(name="Alice", answers=("X", "B", "Y", "Z")),),
|
| 314 |
+
questions=tuple(
|
| 315 |
+
QuestionMeta(number=i + 1, text=f"Q{i+1}", options=()) for i in range(4)
|
| 316 |
+
),
|
| 317 |
+
)
|
| 318 |
+
wrongs = diff_student(grid, 0)
|
| 319 |
+
assert [w.question.number for w in wrongs] == [1, 3, 4]
|
chatkit/backend/tests/test_report_generator.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for report_generator prompt assembly."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from app.answer_grid import AnswerGrid, QuestionMeta, StudentRow, WrongAnswer
|
| 8 |
+
from app.report_generator import (
|
| 9 |
+
build_student_markdown,
|
| 10 |
+
build_wrong_block,
|
| 11 |
+
extract_html,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _make_grid(official, student_answers) -> AnswerGrid:
|
| 16 |
+
n = len(official)
|
| 17 |
+
return AnswerGrid(
|
| 18 |
+
total_questions=n,
|
| 19 |
+
official_answers=tuple(official),
|
| 20 |
+
students=(StudentRow(name="Alice", answers=tuple(student_answers)),),
|
| 21 |
+
questions=tuple(
|
| 22 |
+
QuestionMeta(number=i + 1, text=f"Q{i+1}", options=("A) a", "B) b", "C) c"))
|
| 23 |
+
for i in range(n)
|
| 24 |
+
),
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@pytest.mark.unit
|
| 29 |
+
def test_build_wrong_block_full():
|
| 30 |
+
wa = WrongAnswer(
|
| 31 |
+
question=QuestionMeta(
|
| 32 |
+
number=3,
|
| 33 |
+
text="What is 2+2?",
|
| 34 |
+
options=("A) 3", "B) 4", "C) 5"),
|
| 35 |
+
),
|
| 36 |
+
student_answer="A",
|
| 37 |
+
correct_answer="B",
|
| 38 |
+
)
|
| 39 |
+
block = build_wrong_block(wa)
|
| 40 |
+
assert "### 第 3 題" in block
|
| 41 |
+
assert "What is 2+2?" in block
|
| 42 |
+
assert "A) 3" in block
|
| 43 |
+
assert "**學生作答**: A" in block
|
| 44 |
+
assert "**正確答案**: B" in block
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@pytest.mark.unit
|
| 48 |
+
def test_build_wrong_block_without_options():
|
| 49 |
+
wa = WrongAnswer(
|
| 50 |
+
question=QuestionMeta(number=1, text="Q1", options=()),
|
| 51 |
+
student_answer="A",
|
| 52 |
+
correct_answer="B",
|
| 53 |
+
)
|
| 54 |
+
block = build_wrong_block(wa)
|
| 55 |
+
assert "**選項**" not in block
|
| 56 |
+
assert "**學生作答**: A" in block
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@pytest.mark.unit
|
| 60 |
+
def test_build_wrong_block_blank_student_answer():
|
| 61 |
+
wa = WrongAnswer(
|
| 62 |
+
question=QuestionMeta(number=1, text="Q", options=()),
|
| 63 |
+
student_answer=None,
|
| 64 |
+
correct_answer="A",
|
| 65 |
+
)
|
| 66 |
+
block = build_wrong_block(wa)
|
| 67 |
+
assert "(未作答)" in block
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@pytest.mark.unit
|
| 71 |
+
def test_build_student_markdown_filters_to_wrong_only():
|
| 72 |
+
grid = _make_grid(["A", "B", "C"], ["A", "X", "C"])
|
| 73 |
+
result = build_student_markdown(grid, 0)
|
| 74 |
+
assert result.wrong_count == 1
|
| 75 |
+
assert result.total_questions == 3
|
| 76 |
+
assert "### 第 2 題" in result.markdown_prompt
|
| 77 |
+
assert "### 第 1 題" not in result.markdown_prompt
|
| 78 |
+
assert "### 第 3 題" not in result.markdown_prompt
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.mark.unit
|
| 82 |
+
def test_build_student_markdown_header_contains_counts():
|
| 83 |
+
grid = _make_grid(["A", "B", "C"], ["X", "Y", "Z"])
|
| 84 |
+
result = build_student_markdown(grid, 0)
|
| 85 |
+
assert "總題數:3,答錯:3" in result.markdown_prompt
|
| 86 |
+
assert "學生:Alice" in result.markdown_prompt
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@pytest.mark.unit
|
| 90 |
+
def test_build_student_markdown_all_correct_path():
|
| 91 |
+
grid = _make_grid(["A", "B"], ["A", "B"])
|
| 92 |
+
result = build_student_markdown(grid, 0)
|
| 93 |
+
assert result.wrong_count == 0
|
| 94 |
+
assert "答錯:0" in result.markdown_prompt
|
| 95 |
+
assert "全部答對" in result.markdown_prompt
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@pytest.mark.unit
|
| 99 |
+
def test_build_student_markdown_preserves_question_ordering():
|
| 100 |
+
grid = _make_grid(["A", "B", "C", "D"], ["X", "B", "Y", "Z"])
|
| 101 |
+
result = build_student_markdown(grid, 0)
|
| 102 |
+
# Blocks must appear in Q1, Q3, Q4 order
|
| 103 |
+
idx1 = result.markdown_prompt.index("### 第 1 題")
|
| 104 |
+
idx3 = result.markdown_prompt.index("### 第 3 題")
|
| 105 |
+
idx4 = result.markdown_prompt.index("### 第 4 題")
|
| 106 |
+
assert idx1 < idx3 < idx4
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@pytest.mark.unit
|
| 110 |
+
def test_build_student_markdown_out_of_range():
|
| 111 |
+
grid = _make_grid(["A"], ["A"])
|
| 112 |
+
with pytest.raises(IndexError):
|
| 113 |
+
build_student_markdown(grid, 5)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@pytest.mark.unit
|
| 117 |
+
@pytest.mark.parametrize(
|
| 118 |
+
"content, expected",
|
| 119 |
+
[
|
| 120 |
+
("<!DOCTYPE html><html></html>", "<!DOCTYPE html><html></html>"),
|
| 121 |
+
("```html\n<!DOCTYPE html><html></html>\n```", "<!DOCTYPE html><html></html>"),
|
| 122 |
+
(" <html></html> ", "<html></html>"),
|
| 123 |
+
],
|
| 124 |
+
)
|
| 125 |
+
def test_extract_html(content, expected):
|
| 126 |
+
assert extract_html(content) == expected
|
chatkit/frontend/src/App.tsx
CHANGED
|
@@ -39,7 +39,6 @@ export default function App() {
|
|
| 39 |
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
| 40 |
const [sessionId, setSessionId] = useState<number | null>(null);
|
| 41 |
const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
|
| 42 |
-
const [promptContent, setPromptContent] = useState("");
|
| 43 |
const [students, setStudents] = useState<StudentInfo[]>([]);
|
| 44 |
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
| 45 |
const [reports, setReports] = useState<StudentReport[]>([]);
|
|
@@ -58,15 +57,6 @@ export default function App() {
|
|
| 58 |
}
|
| 59 |
}, []);
|
| 60 |
|
| 61 |
-
// Load default prompt
|
| 62 |
-
useEffect(() => {
|
| 63 |
-
if (user && !promptContent) {
|
| 64 |
-
apiGet<{ content: string }>("/api/prompts/default")
|
| 65 |
-
.then((res) => setPromptContent(res.content))
|
| 66 |
-
.catch(() => {});
|
| 67 |
-
}
|
| 68 |
-
}, [user]);
|
| 69 |
-
|
| 70 |
// Create session when user is authenticated and no session exists
|
| 71 |
useEffect(() => {
|
| 72 |
if (user && !sessionId) {
|
|
@@ -94,7 +84,6 @@ export default function App() {
|
|
| 94 |
setUser(null);
|
| 95 |
setSessionId(null);
|
| 96 |
setParsedData({});
|
| 97 |
-
setPromptContent("");
|
| 98 |
setStudents([]);
|
| 99 |
setSelectedIndices([]);
|
| 100 |
setReports([]);
|
|
@@ -140,7 +129,6 @@ export default function App() {
|
|
| 140 |
student_name: string;
|
| 141 |
}>(`/api/sessions/${sessionId}/generate-student-report`, {
|
| 142 |
student_index: idx,
|
| 143 |
-
prompt_template: promptContent || undefined,
|
| 144 |
model,
|
| 145 |
});
|
| 146 |
|
|
@@ -267,15 +255,11 @@ ${doneReports
|
|
| 267 |
/>
|
| 268 |
{sessionId && (
|
| 269 |
<PromptEditor
|
| 270 |
-
promptContent={promptContent}
|
| 271 |
-
onPromptChange={setPromptContent}
|
| 272 |
sessionId={sessionId}
|
| 273 |
onGenerate={handleGenerateReports}
|
| 274 |
isGenerating={isGenerating}
|
| 275 |
selectedCount={selectedIndices.length}
|
| 276 |
selectedIndices={selectedIndices}
|
| 277 |
-
parsedData={parsedData}
|
| 278 |
-
onParsedDataUpdate={handleParsedDataUpdate}
|
| 279 |
/>
|
| 280 |
)}
|
| 281 |
</>
|
|
|
|
| 39 |
const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
|
| 40 |
const [sessionId, setSessionId] = useState<number | null>(null);
|
| 41 |
const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
|
|
|
|
| 42 |
const [students, setStudents] = useState<StudentInfo[]>([]);
|
| 43 |
const [selectedIndices, setSelectedIndices] = useState<number[]>([]);
|
| 44 |
const [reports, setReports] = useState<StudentReport[]>([]);
|
|
|
|
| 57 |
}
|
| 58 |
}, []);
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
// Create session when user is authenticated and no session exists
|
| 61 |
useEffect(() => {
|
| 62 |
if (user && !sessionId) {
|
|
|
|
| 84 |
setUser(null);
|
| 85 |
setSessionId(null);
|
| 86 |
setParsedData({});
|
|
|
|
| 87 |
setStudents([]);
|
| 88 |
setSelectedIndices([]);
|
| 89 |
setReports([]);
|
|
|
|
| 129 |
student_name: string;
|
| 130 |
}>(`/api/sessions/${sessionId}/generate-student-report`, {
|
| 131 |
student_index: idx,
|
|
|
|
| 132 |
model,
|
| 133 |
});
|
| 134 |
|
|
|
|
| 255 |
/>
|
| 256 |
{sessionId && (
|
| 257 |
<PromptEditor
|
|
|
|
|
|
|
| 258 |
sessionId={sessionId}
|
| 259 |
onGenerate={handleGenerateReports}
|
| 260 |
isGenerating={isGenerating}
|
| 261 |
selectedCount={selectedIndices.length}
|
| 262 |
selectedIndices={selectedIndices}
|
|
|
|
|
|
|
| 263 |
/>
|
| 264 |
)}
|
| 265 |
</>
|
chatkit/frontend/src/components/step2/AnswerGridEditor.tsx
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 2 |
+
import { apiGet, apiPost } from "../../lib/api";
|
| 3 |
+
|
| 4 |
+
export interface QuestionMeta {
|
| 5 |
+
number: number;
|
| 6 |
+
text: string;
|
| 7 |
+
options: string[];
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export interface StudentRow {
|
| 11 |
+
name: string;
|
| 12 |
+
answers: (string | null)[];
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export interface AnswerGrid {
|
| 16 |
+
total_questions: number;
|
| 17 |
+
official_answers: (string | null)[];
|
| 18 |
+
students: StudentRow[];
|
| 19 |
+
questions: QuestionMeta[];
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
interface AnswerGridResponse {
|
| 23 |
+
grid: AnswerGrid;
|
| 24 |
+
is_confirmed: boolean;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
interface AnswerGridEditorProps {
|
| 28 |
+
sessionId: number;
|
| 29 |
+
onConfirmedChange: (grid: AnswerGrid | null) => void;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function normalizeCell(raw: string): string | null {
|
| 33 |
+
const trimmed = raw.trim().toUpperCase();
|
| 34 |
+
if (!trimmed || trimmed === "-") return null;
|
| 35 |
+
// Take first letter only, allow A-Z
|
| 36 |
+
const first = trimmed.charAt(0);
|
| 37 |
+
return /^[A-Z]$/.test(first) ? first : null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function resizeRow(row: (string | null)[], n: number): (string | null)[] {
|
| 41 |
+
if (row.length === n) return row;
|
| 42 |
+
if (row.length > n) return row.slice(0, n);
|
| 43 |
+
return [...row, ...Array<string | null>(n - row.length).fill(null)];
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function resizeQuestions(qs: QuestionMeta[], n: number): QuestionMeta[] {
|
| 47 |
+
if (qs.length === n) return qs;
|
| 48 |
+
if (qs.length > n) return qs.slice(0, n);
|
| 49 |
+
const pad: QuestionMeta[] = [];
|
| 50 |
+
for (let i = qs.length; i < n; i++) {
|
| 51 |
+
pad.push({ number: i + 1, text: "", options: [] });
|
| 52 |
+
}
|
| 53 |
+
return [...qs, ...pad];
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export function AnswerGridEditor({
|
| 57 |
+
sessionId,
|
| 58 |
+
onConfirmedChange,
|
| 59 |
+
}: AnswerGridEditorProps) {
|
| 60 |
+
const [grid, setGrid] = useState<AnswerGrid | null>(null);
|
| 61 |
+
const [loading, setLoading] = useState(true);
|
| 62 |
+
const [saving, setSaving] = useState(false);
|
| 63 |
+
const [saved, setSaved] = useState(false);
|
| 64 |
+
const [error, setError] = useState("");
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
setLoading(true);
|
| 68 |
+
apiGet<AnswerGridResponse>(`/api/sessions/${sessionId}/answer-grid`)
|
| 69 |
+
.then((res) => {
|
| 70 |
+
setGrid(res.grid);
|
| 71 |
+
setSaved(res.is_confirmed);
|
| 72 |
+
if (res.is_confirmed) {
|
| 73 |
+
onConfirmedChange(res.grid);
|
| 74 |
+
}
|
| 75 |
+
})
|
| 76 |
+
.catch((e) => setError(e instanceof Error ? e.message : "Failed to load grid"))
|
| 77 |
+
.finally(() => setLoading(false));
|
| 78 |
+
}, [sessionId]);
|
| 79 |
+
|
| 80 |
+
const qNums = useMemo(() => {
|
| 81 |
+
if (!grid) return [];
|
| 82 |
+
return Array.from({ length: grid.total_questions }, (_, i) => i + 1);
|
| 83 |
+
}, [grid]);
|
| 84 |
+
|
| 85 |
+
const markDirty = useCallback(() => {
|
| 86 |
+
setSaved(false);
|
| 87 |
+
onConfirmedChange(null);
|
| 88 |
+
}, [onConfirmedChange]);
|
| 89 |
+
|
| 90 |
+
const updateN = useCallback(
|
| 91 |
+
(n: number) => {
|
| 92 |
+
if (!grid || n < 1) return;
|
| 93 |
+
setGrid({
|
| 94 |
+
...grid,
|
| 95 |
+
total_questions: n,
|
| 96 |
+
official_answers: resizeRow(grid.official_answers, n),
|
| 97 |
+
students: grid.students.map((s) => ({
|
| 98 |
+
...s,
|
| 99 |
+
answers: resizeRow(s.answers, n),
|
| 100 |
+
})),
|
| 101 |
+
questions: resizeQuestions(grid.questions, n),
|
| 102 |
+
});
|
| 103 |
+
markDirty();
|
| 104 |
+
},
|
| 105 |
+
[grid, markDirty]
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
const updateStudentCell = useCallback(
|
| 109 |
+
(studentIdx: number, qIdx: number, raw: string) => {
|
| 110 |
+
if (!grid) return;
|
| 111 |
+
const value = normalizeCell(raw);
|
| 112 |
+
const nextStudents = grid.students.map((s, i) => {
|
| 113 |
+
if (i !== studentIdx) return s;
|
| 114 |
+
const nextAnswers = [...s.answers];
|
| 115 |
+
nextAnswers[qIdx] = value;
|
| 116 |
+
return { ...s, answers: nextAnswers };
|
| 117 |
+
});
|
| 118 |
+
setGrid({ ...grid, students: nextStudents });
|
| 119 |
+
markDirty();
|
| 120 |
+
},
|
| 121 |
+
[grid, markDirty]
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
const updateOfficialCell = useCallback(
|
| 125 |
+
(qIdx: number, raw: string) => {
|
| 126 |
+
if (!grid) return;
|
| 127 |
+
const value = normalizeCell(raw);
|
| 128 |
+
const next = [...grid.official_answers];
|
| 129 |
+
next[qIdx] = value;
|
| 130 |
+
setGrid({ ...grid, official_answers: next });
|
| 131 |
+
markDirty();
|
| 132 |
+
},
|
| 133 |
+
[grid, markDirty]
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
const handleSave = useCallback(async () => {
|
| 137 |
+
if (!grid) return;
|
| 138 |
+
setSaving(true);
|
| 139 |
+
setError("");
|
| 140 |
+
try {
|
| 141 |
+
const res = await apiPost<AnswerGridResponse>(
|
| 142 |
+
`/api/sessions/${sessionId}/answer-grid`,
|
| 143 |
+
grid
|
| 144 |
+
);
|
| 145 |
+
setGrid(res.grid);
|
| 146 |
+
setSaved(true);
|
| 147 |
+
onConfirmedChange(res.grid);
|
| 148 |
+
} catch (e) {
|
| 149 |
+
setError(e instanceof Error ? e.message : "Failed to save grid");
|
| 150 |
+
} finally {
|
| 151 |
+
setSaving(false);
|
| 152 |
+
}
|
| 153 |
+
}, [grid, sessionId, onConfirmedChange]);
|
| 154 |
+
|
| 155 |
+
if (loading) {
|
| 156 |
+
return (
|
| 157 |
+
<div className="card p-8 text-center">
|
| 158 |
+
<div className="inline-block w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
if (!grid) {
|
| 164 |
+
return (
|
| 165 |
+
<div className="card p-8 text-center">
|
| 166 |
+
<p className="text-[var(--color-text-muted)]">
|
| 167 |
+
{error || "尚未上傳任何資料,請回到上一步先解析檔案"}
|
| 168 |
+
</p>
|
| 169 |
+
</div>
|
| 170 |
+
);
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
return (
|
| 174 |
+
<div className="card p-5 space-y-4">
|
| 175 |
+
<div className="flex items-center justify-between flex-wrap gap-3">
|
| 176 |
+
<div>
|
| 177 |
+
<h3 className="font-display text-lg font-semibold text-[var(--color-text)]">
|
| 178 |
+
📋 確認答案表 (Answer Grid)
|
| 179 |
+
</h3>
|
| 180 |
+
<p className="text-xs text-[var(--color-text-muted)] mt-1">
|
| 181 |
+
請核對學生作答與標準答案,確認後才能生成報告。支援單選題(A-Z)。
|
| 182 |
+
</p>
|
| 183 |
+
</div>
|
| 184 |
+
<div className="flex items-center gap-2">
|
| 185 |
+
<label className="text-sm text-[var(--color-text-muted)]">題數:</label>
|
| 186 |
+
<input
|
| 187 |
+
type="number"
|
| 188 |
+
min={1}
|
| 189 |
+
max={200}
|
| 190 |
+
value={grid.total_questions}
|
| 191 |
+
onChange={(e) => updateN(Number(e.target.value))}
|
| 192 |
+
className="input !w-20 !py-1 text-sm"
|
| 193 |
+
/>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<div className="overflow-x-auto max-h-[60vh] overflow-y-auto">
|
| 198 |
+
<table className="w-full text-sm border-separate border-spacing-0">
|
| 199 |
+
<thead className="sticky top-0 bg-[var(--color-surface)] z-10">
|
| 200 |
+
<tr>
|
| 201 |
+
<th className="text-left py-2 px-3 border-b border-[var(--color-border)] sticky left-0 bg-[var(--color-surface)] z-20 min-w-[120px]">
|
| 202 |
+
學生
|
| 203 |
+
</th>
|
| 204 |
+
{qNums.map((n) => (
|
| 205 |
+
<th
|
| 206 |
+
key={n}
|
| 207 |
+
className="text-center py-2 px-2 border-b border-[var(--color-border)] text-[var(--color-text-muted)] min-w-[48px]"
|
| 208 |
+
>
|
| 209 |
+
Q{n}
|
| 210 |
+
</th>
|
| 211 |
+
))}
|
| 212 |
+
</tr>
|
| 213 |
+
</thead>
|
| 214 |
+
<tbody>
|
| 215 |
+
{grid.students.map((s, si) => (
|
| 216 |
+
<tr key={si}>
|
| 217 |
+
<td className="py-1 px-3 border-b border-[var(--color-border)]/50 sticky left-0 bg-[var(--color-background)] z-10 font-medium text-[var(--color-text)]">
|
| 218 |
+
{s.name}
|
| 219 |
+
</td>
|
| 220 |
+
{qNums.map((n, qi) => (
|
| 221 |
+
<td
|
| 222 |
+
key={n}
|
| 223 |
+
className="border-b border-[var(--color-border)]/50 p-0.5"
|
| 224 |
+
>
|
| 225 |
+
<input
|
| 226 |
+
type="text"
|
| 227 |
+
maxLength={2}
|
| 228 |
+
defaultValue={s.answers[qi] ?? ""}
|
| 229 |
+
onBlur={(e) => updateStudentCell(si, qi, e.target.value)}
|
| 230 |
+
className="w-full text-center py-1 bg-transparent text-[var(--color-text)] border border-transparent hover:border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none rounded"
|
| 231 |
+
/>
|
| 232 |
+
</td>
|
| 233 |
+
))}
|
| 234 |
+
</tr>
|
| 235 |
+
))}
|
| 236 |
+
|
| 237 |
+
{/* Answer key row */}
|
| 238 |
+
<tr className="bg-[var(--color-primary)]/5">
|
| 239 |
+
<td className="py-2 px-3 border-t-2 border-[var(--color-primary)] sticky left-0 bg-[var(--color-surface)] z-10 font-bold text-[var(--color-primary)]">
|
| 240 |
+
✅ 標準答案
|
| 241 |
+
</td>
|
| 242 |
+
{qNums.map((n, qi) => (
|
| 243 |
+
<td
|
| 244 |
+
key={n}
|
| 245 |
+
className="border-t-2 border-[var(--color-primary)] p-0.5"
|
| 246 |
+
>
|
| 247 |
+
<input
|
| 248 |
+
type="text"
|
| 249 |
+
maxLength={2}
|
| 250 |
+
defaultValue={grid.official_answers[qi] ?? ""}
|
| 251 |
+
onBlur={(e) => updateOfficialCell(qi, e.target.value)}
|
| 252 |
+
className="w-full text-center py-1 bg-transparent text-[var(--color-success)] font-semibold border border-transparent hover:border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none rounded"
|
| 253 |
+
/>
|
| 254 |
+
</td>
|
| 255 |
+
))}
|
| 256 |
+
</tr>
|
| 257 |
+
</tbody>
|
| 258 |
+
</table>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
{error && (
|
| 262 |
+
<p className="text-sm text-red-400 text-center">{error}</p>
|
| 263 |
+
)}
|
| 264 |
+
|
| 265 |
+
<div className="flex items-center justify-between flex-wrap gap-3">
|
| 266 |
+
<div className="text-xs text-[var(--color-text-muted)]">
|
| 267 |
+
{saved ? (
|
| 268 |
+
<span className="text-[var(--color-success)]">✓ 已確認(可生成報告)</span>
|
| 269 |
+
) : (
|
| 270 |
+
<span className="text-amber-400">⚠ 尚未確認,編輯後請按下「確認儲存」</span>
|
| 271 |
+
)}
|
| 272 |
+
</div>
|
| 273 |
+
<button
|
| 274 |
+
onClick={handleSave}
|
| 275 |
+
disabled={saving}
|
| 276 |
+
className="btn btn-primary text-sm disabled:opacity-50"
|
| 277 |
+
>
|
| 278 |
+
{saving ? "儲存中..." : saved ? "重新儲存" : "確認儲存"}
|
| 279 |
+
</button>
|
| 280 |
+
</div>
|
| 281 |
+
</div>
|
| 282 |
+
);
|
| 283 |
+
}
|
chatkit/frontend/src/components/step2/DataPreviewModal.tsx
CHANGED
|
@@ -1,176 +1,61 @@
|
|
| 1 |
-
import {
|
| 2 |
import { apiPost } from "../../lib/api";
|
| 3 |
|
| 4 |
interface DataPreviewModalProps {
|
| 5 |
-
parsedData: Record<string, unknown>;
|
| 6 |
-
onParsedDataUpdate: (dataType: string, data: unknown) => void;
|
| 7 |
onGenerate: () => void;
|
| 8 |
onClose: () => void;
|
| 9 |
isGenerating: boolean;
|
| 10 |
sessionId: number;
|
| 11 |
selectedIndices: number[];
|
| 12 |
-
promptTemplate: string;
|
| 13 |
}
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
{ key: "teacher_answers", label: "標準答案", icon: "✅" },
|
| 21 |
-
];
|
| 22 |
-
|
| 23 |
-
interface StudentInfo {
|
| 24 |
-
index: number;
|
| 25 |
-
name: string;
|
| 26 |
}
|
| 27 |
|
| 28 |
export function DataPreviewModal({
|
| 29 |
-
parsedData,
|
| 30 |
-
onParsedDataUpdate,
|
| 31 |
onGenerate,
|
| 32 |
onClose,
|
| 33 |
isGenerating,
|
| 34 |
sessionId,
|
| 35 |
selectedIndices,
|
| 36 |
-
promptTemplate,
|
| 37 |
}: DataPreviewModalProps) {
|
| 38 |
-
const [
|
| 39 |
-
const [editTexts, setEditTexts] = useState<Record<string, string>>({
|
| 40 |
-
questions: "",
|
| 41 |
-
student_answers: "",
|
| 42 |
-
teacher_answers: "",
|
| 43 |
-
});
|
| 44 |
-
const [parseErrors, setParseErrors] = useState<Record<string, string>>({});
|
| 45 |
-
|
| 46 |
-
// Final prompt preview state
|
| 47 |
-
const [previewStudentIdx, setPreviewStudentIdx] = useState<number>(
|
| 48 |
selectedIndices.length > 0 ? selectedIndices[0] : 0
|
| 49 |
);
|
| 50 |
-
const [
|
| 51 |
-
const [
|
| 52 |
-
const [
|
| 53 |
-
student_name: string;
|
| 54 |
-
total_questions: number;
|
| 55 |
-
} | null>(null);
|
| 56 |
|
| 57 |
-
// Build students list from parsedData
|
| 58 |
-
const students: StudentInfo[] = (() => {
|
| 59 |
-
const sa = parsedData.student_answers as { students?: { name?: string }[] } | undefined;
|
| 60 |
-
return (sa?.students || []).map((s, i) => ({
|
| 61 |
-
index: i,
|
| 62 |
-
name: s.name || `Student ${i + 1}`,
|
| 63 |
-
}));
|
| 64 |
-
})();
|
| 65 |
-
|
| 66 |
-
const selectedStudents = students.filter((s) => selectedIndices.includes(s.index));
|
| 67 |
-
|
| 68 |
-
// Initialize edit texts from parsedData
|
| 69 |
useEffect(() => {
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
}, [
|
| 83 |
-
|
| 84 |
-
const loadFilledPrompt = async (studentIndex: number) => {
|
| 85 |
-
setPromptLoading(true);
|
| 86 |
-
setFilledPrompt("");
|
| 87 |
-
setPromptMeta(null);
|
| 88 |
-
try {
|
| 89 |
-
const res = await apiPost<{
|
| 90 |
-
filled_prompt: string;
|
| 91 |
-
student_name: string;
|
| 92 |
-
total_questions: number;
|
| 93 |
-
}>(`/api/sessions/${sessionId}/preview-student-prompt`, {
|
| 94 |
-
student_index: studentIndex,
|
| 95 |
-
prompt_template: promptTemplate || undefined,
|
| 96 |
-
});
|
| 97 |
-
setFilledPrompt(res.filled_prompt);
|
| 98 |
-
setPromptMeta({
|
| 99 |
-
student_name: res.student_name,
|
| 100 |
-
total_questions: res.total_questions,
|
| 101 |
-
});
|
| 102 |
-
} catch (err) {
|
| 103 |
-
setFilledPrompt(
|
| 104 |
-
`Error: ${err instanceof Error ? err.message : "Failed to load preview"}`
|
| 105 |
-
);
|
| 106 |
-
}
|
| 107 |
-
setPromptLoading(false);
|
| 108 |
-
};
|
| 109 |
-
|
| 110 |
-
const handleTextChange = (key: string, value: string) => {
|
| 111 |
-
setEditTexts((prev) => ({ ...prev, [key]: value }));
|
| 112 |
-
setParseErrors((prev) => {
|
| 113 |
-
const next = { ...prev };
|
| 114 |
-
delete next[key];
|
| 115 |
-
return next;
|
| 116 |
-
});
|
| 117 |
-
};
|
| 118 |
-
|
| 119 |
-
const handleSave = (key: string) => {
|
| 120 |
-
try {
|
| 121 |
-
const parsed = JSON.parse(editTexts[key]);
|
| 122 |
-
onParsedDataUpdate(key, parsed);
|
| 123 |
-
setParseErrors((prev) => {
|
| 124 |
-
const next = { ...prev };
|
| 125 |
-
delete next[key];
|
| 126 |
-
return next;
|
| 127 |
-
});
|
| 128 |
-
} catch {
|
| 129 |
-
setParseErrors((prev) => ({ ...prev, [key]: "JSON 格式錯誤,請檢查語法" }));
|
| 130 |
-
}
|
| 131 |
-
};
|
| 132 |
-
|
| 133 |
-
const handleGenerate = () => {
|
| 134 |
-
let hasError = false;
|
| 135 |
-
for (const tab of DATA_TABS) {
|
| 136 |
-
if (!editTexts[tab.key].trim()) continue;
|
| 137 |
-
try {
|
| 138 |
-
const parsed = JSON.parse(editTexts[tab.key]);
|
| 139 |
-
onParsedDataUpdate(tab.key, parsed);
|
| 140 |
-
} catch {
|
| 141 |
-
setParseErrors((prev) => ({ ...prev, [tab.key]: "JSON 格式錯誤,請檢查語法" }));
|
| 142 |
-
setActiveTab(tab.key);
|
| 143 |
-
hasError = true;
|
| 144 |
-
break;
|
| 145 |
-
}
|
| 146 |
-
}
|
| 147 |
-
if (!hasError) onGenerate();
|
| 148 |
-
};
|
| 149 |
-
|
| 150 |
-
const getItemCount = (key: string): number => {
|
| 151 |
-
try {
|
| 152 |
-
const data = JSON.parse(editTexts[key]);
|
| 153 |
-
if (key === "questions") return data?.questions?.length ?? 0;
|
| 154 |
-
if (key === "student_answers") return data?.students?.length ?? 0;
|
| 155 |
-
if (key === "teacher_answers") return data?.answers?.length ?? 0;
|
| 156 |
-
} catch {}
|
| 157 |
-
return 0;
|
| 158 |
-
};
|
| 159 |
|
| 160 |
return (
|
| 161 |
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
| 162 |
-
{/* Backdrop */}
|
| 163 |
<div
|
| 164 |
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
| 165 |
onClick={onClose}
|
| 166 |
/>
|
| 167 |
|
| 168 |
-
|
| 169 |
-
<div className="relative w-full max-w-5xl max-h-[90vh] flex flex-col card overflow-hidden">
|
| 170 |
-
{/* Header */}
|
| 171 |
<div className="flex items-center justify-between p-5 border-b border-[var(--color-border)]">
|
| 172 |
<h3 className="font-display text-xl font-bold text-[var(--color-text)]">
|
| 173 |
-
預覽
|
| 174 |
</h3>
|
| 175 |
<button
|
| 176 |
onClick={onClose}
|
|
@@ -180,128 +65,69 @@ export function DataPreviewModal({
|
|
| 180 |
</button>
|
| 181 |
</div>
|
| 182 |
|
| 183 |
-
{/* Tabs */}
|
| 184 |
-
<div className="flex border-b border-[var(--color-border)]">
|
| 185 |
-
{/* Final prompt tab first */}
|
| 186 |
-
<button
|
| 187 |
-
onClick={() => setActiveTab("final_prompt")}
|
| 188 |
-
className={`px-4 py-3 text-sm font-semibold transition-colors relative ${
|
| 189 |
-
activeTab === "final_prompt"
|
| 190 |
-
? "text-[var(--color-accent)] bg-[var(--color-accent)]/5"
|
| 191 |
-
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
| 192 |
-
}`}
|
| 193 |
-
>
|
| 194 |
-
<span>🔍 最終提示詞</span>
|
| 195 |
-
{activeTab === "final_prompt" && (
|
| 196 |
-
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-accent)]" />
|
| 197 |
-
)}
|
| 198 |
-
</button>
|
| 199 |
-
{DATA_TABS.map((tab) => {
|
| 200 |
-
const count = getItemCount(tab.key);
|
| 201 |
-
const hasError = !!parseErrors[tab.key];
|
| 202 |
-
return (
|
| 203 |
-
<button
|
| 204 |
-
key={tab.key}
|
| 205 |
-
onClick={() => setActiveTab(tab.key)}
|
| 206 |
-
className={`flex-1 px-4 py-3 text-sm font-semibold transition-colors relative ${
|
| 207 |
-
activeTab === tab.key
|
| 208 |
-
? "text-[var(--color-primary)] bg-[var(--color-primary)]/5"
|
| 209 |
-
: "text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:bg-[var(--color-surface)]"
|
| 210 |
-
} ${hasError ? "!text-red-500" : ""}`}
|
| 211 |
-
>
|
| 212 |
-
<span>{tab.icon} {tab.label}</span>
|
| 213 |
-
{count > 0 && (
|
| 214 |
-
<span className="ml-2 text-xs badge badge-success">{count}</span>
|
| 215 |
-
)}
|
| 216 |
-
{activeTab === tab.key && (
|
| 217 |
-
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--color-primary)]" />
|
| 218 |
-
)}
|
| 219 |
-
</button>
|
| 220 |
-
);
|
| 221 |
-
})}
|
| 222 |
-
</div>
|
| 223 |
-
|
| 224 |
-
{/* Content */}
|
| 225 |
<div className="flex-1 overflow-hidden flex flex-col p-5 min-h-0">
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
>
|
| 238 |
-
{
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
<
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
</div>
|
| 257 |
-
) : (
|
| 258 |
-
<textarea
|
| 259 |
-
value={filledPrompt}
|
| 260 |
-
readOnly
|
| 261 |
-
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y flex-1"
|
| 262 |
-
spellCheck={false}
|
| 263 |
-
/>
|
| 264 |
-
)}
|
| 265 |
-
</>
|
| 266 |
) : (
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
<button
|
| 274 |
-
onClick={() => handleSave(activeTab)}
|
| 275 |
-
className="btn btn-outline text-sm !py-1.5 !px-3"
|
| 276 |
-
>
|
| 277 |
-
套用修改
|
| 278 |
-
</button>
|
| 279 |
-
</div>
|
| 280 |
-
|
| 281 |
-
{parseErrors[activeTab] && (
|
| 282 |
-
<div className="mb-3 px-3 py-2 rounded-lg bg-red-500/10 text-red-500 text-sm">
|
| 283 |
-
{parseErrors[activeTab]}
|
| 284 |
-
</div>
|
| 285 |
-
)}
|
| 286 |
-
|
| 287 |
-
<textarea
|
| 288 |
-
value={editTexts[activeTab] || ""}
|
| 289 |
-
onChange={(e) => handleTextChange(activeTab, e.target.value)}
|
| 290 |
-
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y flex-1"
|
| 291 |
-
placeholder={`尚未上傳${DATA_TABS.find((t) => t.key === activeTab)?.label ?? ""}資料`}
|
| 292 |
-
spellCheck={false}
|
| 293 |
-
/>
|
| 294 |
-
</>
|
| 295 |
)}
|
| 296 |
</div>
|
| 297 |
|
| 298 |
-
{/* Footer */}
|
| 299 |
<div className="flex items-center justify-end gap-3 p-5 border-t border-[var(--color-border)]">
|
| 300 |
<button onClick={onClose} className="btn btn-outline">
|
| 301 |
返回編輯
|
| 302 |
</button>
|
| 303 |
<button
|
| 304 |
-
onClick={
|
| 305 |
disabled={isGenerating}
|
| 306 |
className="btn btn-accent text-lg px-8 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 307 |
>
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
import { apiPost } from "../../lib/api";
|
| 3 |
|
| 4 |
interface DataPreviewModalProps {
|
|
|
|
|
|
|
| 5 |
onGenerate: () => void;
|
| 6 |
onClose: () => void;
|
| 7 |
isGenerating: boolean;
|
| 8 |
sessionId: number;
|
| 9 |
selectedIndices: number[];
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
+
interface PreviewResponse {
|
| 13 |
+
student_name: string;
|
| 14 |
+
total_questions: number;
|
| 15 |
+
wrong_count: number;
|
| 16 |
+
markdown_prompt: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
export function DataPreviewModal({
|
|
|
|
|
|
|
| 20 |
onGenerate,
|
| 21 |
onClose,
|
| 22 |
isGenerating,
|
| 23 |
sessionId,
|
| 24 |
selectedIndices,
|
|
|
|
| 25 |
}: DataPreviewModalProps) {
|
| 26 |
+
const [previewIdx, setPreviewIdx] = useState<number>(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
selectedIndices.length > 0 ? selectedIndices[0] : 0
|
| 28 |
);
|
| 29 |
+
const [loading, setLoading] = useState(false);
|
| 30 |
+
const [preview, setPreview] = useState<PreviewResponse | null>(null);
|
| 31 |
+
const [error, setError] = useState("");
|
|
|
|
|
|
|
|
|
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
useEffect(() => {
|
| 34 |
+
setLoading(true);
|
| 35 |
+
setError("");
|
| 36 |
+
setPreview(null);
|
| 37 |
+
apiPost<PreviewResponse>(
|
| 38 |
+
`/api/sessions/${sessionId}/preview-student-prompt`,
|
| 39 |
+
{ student_index: previewIdx }
|
| 40 |
+
)
|
| 41 |
+
.then(setPreview)
|
| 42 |
+
.catch((e) =>
|
| 43 |
+
setError(e instanceof Error ? e.message : "Failed to load preview")
|
| 44 |
+
)
|
| 45 |
+
.finally(() => setLoading(false));
|
| 46 |
+
}, [previewIdx, sessionId]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
return (
|
| 49 |
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
|
|
| 50 |
<div
|
| 51 |
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
| 52 |
onClick={onClose}
|
| 53 |
/>
|
| 54 |
|
| 55 |
+
<div className="relative w-full max-w-4xl max-h-[90vh] flex flex-col card overflow-hidden">
|
|
|
|
|
|
|
| 56 |
<div className="flex items-center justify-between p-5 border-b border-[var(--color-border)]">
|
| 57 |
<h3 className="font-display text-xl font-bold text-[var(--color-text)]">
|
| 58 |
+
🔍 最終提示詞預覽
|
| 59 |
</h3>
|
| 60 |
<button
|
| 61 |
onClick={onClose}
|
|
|
|
| 65 |
</button>
|
| 66 |
</div>
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
<div className="flex-1 overflow-hidden flex flex-col p-5 min-h-0">
|
| 69 |
+
<div className="flex items-center gap-3 mb-3 flex-wrap">
|
| 70 |
+
<label className="text-sm font-semibold text-[var(--color-text)]">
|
| 71 |
+
預覽學生:
|
| 72 |
+
</label>
|
| 73 |
+
<select
|
| 74 |
+
value={previewIdx}
|
| 75 |
+
onChange={(e) => setPreviewIdx(Number(e.target.value))}
|
| 76 |
+
className="input !w-auto !py-1.5 text-sm"
|
| 77 |
+
>
|
| 78 |
+
{selectedIndices.map((idx) => (
|
| 79 |
+
<option key={idx} value={idx}>
|
| 80 |
+
Student {idx + 1}
|
| 81 |
+
</option>
|
| 82 |
+
))}
|
| 83 |
+
</select>
|
| 84 |
+
{preview && (
|
| 85 |
+
<>
|
| 86 |
+
<span className="badge badge-success text-xs">
|
| 87 |
+
{preview.student_name}
|
| 88 |
+
</span>
|
| 89 |
+
<span className="badge badge-success text-xs">
|
| 90 |
+
總題數 {preview.total_questions}
|
| 91 |
+
</span>
|
| 92 |
+
<span
|
| 93 |
+
className={`badge text-xs ${
|
| 94 |
+
preview.wrong_count === 0 ? "badge-success" : "bg-amber-500/20 text-amber-400"
|
| 95 |
+
}`}
|
| 96 |
>
|
| 97 |
+
錯 {preview.wrong_count}
|
| 98 |
+
</span>
|
| 99 |
+
</>
|
| 100 |
+
)}
|
| 101 |
+
</div>
|
| 102 |
+
|
| 103 |
+
<p className="text-xs text-[var(--color-text-muted)] mb-3">
|
| 104 |
+
以下是實際送給 LLM 的完整提示詞(僅含該學生答錯的題目)
|
| 105 |
+
</p>
|
| 106 |
+
|
| 107 |
+
{loading ? (
|
| 108 |
+
<div className="flex-1 flex items-center justify-center">
|
| 109 |
+
<div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
|
| 110 |
+
</div>
|
| 111 |
+
) : error ? (
|
| 112 |
+
<div className="flex-1 px-3 py-2 rounded-lg bg-red-500/10 text-red-400 text-sm">
|
| 113 |
+
{error}
|
| 114 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
) : (
|
| 116 |
+
<textarea
|
| 117 |
+
value={preview?.markdown_prompt ?? ""}
|
| 118 |
+
readOnly
|
| 119 |
+
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y flex-1"
|
| 120 |
+
spellCheck={false}
|
| 121 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
)}
|
| 123 |
</div>
|
| 124 |
|
|
|
|
| 125 |
<div className="flex items-center justify-end gap-3 p-5 border-t border-[var(--color-border)]">
|
| 126 |
<button onClick={onClose} className="btn btn-outline">
|
| 127 |
返回編輯
|
| 128 |
</button>
|
| 129 |
<button
|
| 130 |
+
onClick={onGenerate}
|
| 131 |
disabled={isGenerating}
|
| 132 |
className="btn btn-accent text-lg px-8 py-3 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 133 |
>
|
chatkit/frontend/src/components/step2/PromptEditor.tsx
CHANGED
|
@@ -1,197 +1,59 @@
|
|
| 1 |
-
import { useState
|
| 2 |
-
import { apiGet, apiPost } from "../../lib/api";
|
| 3 |
import { ModelSelector } from "../ModelSelector";
|
| 4 |
import { DataPreviewModal } from "./DataPreviewModal";
|
| 5 |
-
|
| 6 |
-
interface Prompt {
|
| 7 |
-
id: number;
|
| 8 |
-
name: string;
|
| 9 |
-
content: string;
|
| 10 |
-
user_id: number;
|
| 11 |
-
author_email?: string;
|
| 12 |
-
}
|
| 13 |
|
| 14 |
interface PromptEditorProps {
|
| 15 |
-
promptContent: string;
|
| 16 |
-
onPromptChange: (content: string) => void;
|
| 17 |
sessionId: number;
|
| 18 |
onGenerate: (model: string) => void;
|
| 19 |
isGenerating: boolean;
|
| 20 |
selectedCount: number;
|
| 21 |
selectedIndices: number[];
|
| 22 |
-
parsedData: Record<string, unknown>;
|
| 23 |
-
onParsedDataUpdate: (dataType: string, data: unknown) => void;
|
| 24 |
}
|
| 25 |
|
| 26 |
export function PromptEditor({
|
| 27 |
-
promptContent,
|
| 28 |
-
onPromptChange,
|
| 29 |
sessionId,
|
| 30 |
onGenerate,
|
| 31 |
isGenerating,
|
| 32 |
selectedCount,
|
| 33 |
selectedIndices,
|
| 34 |
-
parsedData,
|
| 35 |
-
onParsedDataUpdate,
|
| 36 |
}: PromptEditorProps) {
|
| 37 |
-
const [prompts, setPrompts] = useState<Prompt[]>([]);
|
| 38 |
-
const [allPrompts, setAllPrompts] = useState<Prompt[]>([]);
|
| 39 |
-
const [saveName, setSaveName] = useState("");
|
| 40 |
-
const [showSave, setShowSave] = useState(false);
|
| 41 |
-
const [showOthers, setShowOthers] = useState(false);
|
| 42 |
-
const [saving, setSaving] = useState(false);
|
| 43 |
const [model, setModel] = useState("gpt-5.4");
|
| 44 |
const [showPreview, setShowPreview] = useState(false);
|
|
|
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
}, []);
|
| 49 |
-
|
| 50 |
-
const loadPrompts = async () => {
|
| 51 |
-
try {
|
| 52 |
-
const [mine, all] = await Promise.all([
|
| 53 |
-
apiGet<{ prompts: Prompt[] }>("/api/prompts"),
|
| 54 |
-
apiGet<{ prompts: Prompt[] }>("/api/prompts/all"),
|
| 55 |
-
]);
|
| 56 |
-
setPrompts(mine.prompts);
|
| 57 |
-
setAllPrompts(all.prompts);
|
| 58 |
-
} catch {}
|
| 59 |
-
};
|
| 60 |
|
| 61 |
-
const
|
| 62 |
-
if (
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
setSaveName("");
|
| 67 |
-
setShowSave(false);
|
| 68 |
-
await loadPrompts();
|
| 69 |
-
} catch {}
|
| 70 |
-
setSaving(false);
|
| 71 |
-
};
|
| 72 |
-
|
| 73 |
-
const handleLoadPrompt = (prompt: Prompt) => {
|
| 74 |
-
onPromptChange(prompt.content);
|
| 75 |
-
setShowOthers(false);
|
| 76 |
-
};
|
| 77 |
|
| 78 |
return (
|
| 79 |
<div className="space-y-4">
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
{
|
| 83 |
-
|
| 84 |
-
<select
|
| 85 |
-
onChange={(e) => {
|
| 86 |
-
const p = prompts.find((p) => p.id === Number(e.target.value));
|
| 87 |
-
if (p) onPromptChange(p.content);
|
| 88 |
-
}}
|
| 89 |
-
defaultValue=""
|
| 90 |
-
className="input !w-auto !py-2 text-sm"
|
| 91 |
-
>
|
| 92 |
-
<option value="" disabled>我的提示詞...</option>
|
| 93 |
-
{prompts.map((p) => (
|
| 94 |
-
<option key={p.id} value={p.id}>{p.name}</option>
|
| 95 |
-
))}
|
| 96 |
-
</select>
|
| 97 |
-
)}
|
| 98 |
-
|
| 99 |
-
{/* Load others' prompts */}
|
| 100 |
-
<button
|
| 101 |
-
onClick={() => setShowOthers(!showOthers)}
|
| 102 |
-
className="btn btn-outline text-sm !py-2"
|
| 103 |
-
>
|
| 104 |
-
瀏覽其他人的提示詞
|
| 105 |
-
</button>
|
| 106 |
-
|
| 107 |
-
{/* Save button */}
|
| 108 |
-
<button
|
| 109 |
-
onClick={() => setShowSave(!showSave)}
|
| 110 |
-
className="btn btn-outline text-sm !py-2"
|
| 111 |
-
>
|
| 112 |
-
儲存提示詞
|
| 113 |
-
</button>
|
| 114 |
|
| 115 |
-
|
| 116 |
-
<
|
| 117 |
-
<ModelSelector value={model} onChange={setModel} label="報告模型:" />
|
| 118 |
-
</div>
|
| 119 |
</div>
|
| 120 |
|
| 121 |
-
{/* Others' prompts dropdown */}
|
| 122 |
-
{showOthers && (
|
| 123 |
-
<div className="card p-4 max-h-48 overflow-y-auto">
|
| 124 |
-
<h4 className="text-sm font-semibold text-[var(--color-text-muted)] mb-2">所有提示詞</h4>
|
| 125 |
-
{allPrompts.length === 0 ? (
|
| 126 |
-
<p className="text-sm text-[var(--color-text-muted)]">目前沒有其他人的提示詞</p>
|
| 127 |
-
) : (
|
| 128 |
-
<div className="space-y-2">
|
| 129 |
-
{allPrompts.map((p) => (
|
| 130 |
-
<button
|
| 131 |
-
key={p.id}
|
| 132 |
-
onClick={() => handleLoadPrompt(p)}
|
| 133 |
-
className="w-full text-left p-2 rounded-lg hover:bg-[var(--color-primary)]/10 transition-colors"
|
| 134 |
-
>
|
| 135 |
-
<span className="text-sm text-[var(--color-text)]">{p.name}</span>
|
| 136 |
-
{p.author_email && (
|
| 137 |
-
<span className="text-xs text-[var(--color-text-muted)] ml-2">
|
| 138 |
-
by {p.author_email}
|
| 139 |
-
</span>
|
| 140 |
-
)}
|
| 141 |
-
</button>
|
| 142 |
-
))}
|
| 143 |
-
</div>
|
| 144 |
-
)}
|
| 145 |
-
</div>
|
| 146 |
-
)}
|
| 147 |
-
|
| 148 |
-
{/* Save prompt form */}
|
| 149 |
-
{showSave && (
|
| 150 |
-
<div className="card p-4 flex items-center gap-3">
|
| 151 |
-
<input
|
| 152 |
-
type="text"
|
| 153 |
-
value={saveName}
|
| 154 |
-
onChange={(e) => setSaveName(e.target.value)}
|
| 155 |
-
placeholder="提示詞名稱..."
|
| 156 |
-
className="input !py-2 text-sm flex-1"
|
| 157 |
-
/>
|
| 158 |
-
<button
|
| 159 |
-
onClick={handleSave}
|
| 160 |
-
disabled={saving || !saveName.trim()}
|
| 161 |
-
className="btn btn-primary text-sm !py-2 disabled:opacity-50"
|
| 162 |
-
>
|
| 163 |
-
{saving ? "..." : "儲存"}
|
| 164 |
-
</button>
|
| 165 |
-
</div>
|
| 166 |
-
)}
|
| 167 |
-
|
| 168 |
-
{/* Prompt textarea */}
|
| 169 |
-
<textarea
|
| 170 |
-
value={promptContent}
|
| 171 |
-
onChange={(e) => onPromptChange(e.target.value)}
|
| 172 |
-
rows={10}
|
| 173 |
-
className="input !rounded-xl font-mono text-sm leading-relaxed resize-y"
|
| 174 |
-
placeholder="輸入分析提示詞..."
|
| 175 |
-
/>
|
| 176 |
-
|
| 177 |
-
{/* Preview button */}
|
| 178 |
<div className="text-center">
|
| 179 |
<button
|
| 180 |
onClick={() => setShowPreview(true)}
|
| 181 |
-
disabled={
|
| 182 |
className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 183 |
>
|
| 184 |
-
{
|
| 185 |
-
? "請先選擇學生"
|
| 186 |
-
: `預覽分析資料(${selectedCount} 位學生)→`}
|
| 187 |
</button>
|
| 188 |
</div>
|
| 189 |
|
| 190 |
-
{/* Data preview modal */}
|
| 191 |
{showPreview && (
|
| 192 |
<DataPreviewModal
|
| 193 |
-
parsedData={parsedData}
|
| 194 |
-
onParsedDataUpdate={onParsedDataUpdate}
|
| 195 |
onGenerate={() => {
|
| 196 |
setShowPreview(false);
|
| 197 |
onGenerate(model);
|
|
@@ -200,7 +62,6 @@ export function PromptEditor({
|
|
| 200 |
isGenerating={isGenerating}
|
| 201 |
sessionId={sessionId}
|
| 202 |
selectedIndices={selectedIndices}
|
| 203 |
-
promptTemplate={promptContent}
|
| 204 |
/>
|
| 205 |
)}
|
| 206 |
</div>
|
|
|
|
| 1 |
+
import { useState } from "react";
|
|
|
|
| 2 |
import { ModelSelector } from "../ModelSelector";
|
| 3 |
import { DataPreviewModal } from "./DataPreviewModal";
|
| 4 |
+
import { AnswerGridEditor, type AnswerGrid } from "./AnswerGridEditor";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
interface PromptEditorProps {
|
|
|
|
|
|
|
| 7 |
sessionId: number;
|
| 8 |
onGenerate: (model: string) => void;
|
| 9 |
isGenerating: boolean;
|
| 10 |
selectedCount: number;
|
| 11 |
selectedIndices: number[];
|
|
|
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
export function PromptEditor({
|
|
|
|
|
|
|
| 15 |
sessionId,
|
| 16 |
onGenerate,
|
| 17 |
isGenerating,
|
| 18 |
selectedCount,
|
| 19 |
selectedIndices,
|
|
|
|
|
|
|
| 20 |
}: PromptEditorProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
const [model, setModel] = useState("gpt-5.4");
|
| 22 |
const [showPreview, setShowPreview] = useState(false);
|
| 23 |
+
const [confirmedGrid, setConfirmedGrid] = useState<AnswerGrid | null>(null);
|
| 24 |
|
| 25 |
+
const canGenerate =
|
| 26 |
+
!isGenerating && selectedCount > 0 && confirmedGrid !== null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
const buttonLabel = (() => {
|
| 29 |
+
if (selectedCount === 0) return "請先選擇學生";
|
| 30 |
+
if (!confirmedGrid) return "請先確認答案表";
|
| 31 |
+
return `預覽並生成報告(${selectedCount} 位學生)→`;
|
| 32 |
+
})();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
return (
|
| 35 |
<div className="space-y-4">
|
| 36 |
+
<AnswerGridEditor
|
| 37 |
+
sessionId={sessionId}
|
| 38 |
+
onConfirmedChange={setConfirmedGrid}
|
| 39 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
+
<div className="flex items-center justify-end">
|
| 42 |
+
<ModelSelector value={model} onChange={setModel} label="報告模型:" />
|
|
|
|
|
|
|
| 43 |
</div>
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
<div className="text-center">
|
| 46 |
<button
|
| 47 |
onClick={() => setShowPreview(true)}
|
| 48 |
+
disabled={!canGenerate}
|
| 49 |
className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
| 50 |
>
|
| 51 |
+
{buttonLabel}
|
|
|
|
|
|
|
| 52 |
</button>
|
| 53 |
</div>
|
| 54 |
|
|
|
|
| 55 |
{showPreview && (
|
| 56 |
<DataPreviewModal
|
|
|
|
|
|
|
| 57 |
onGenerate={() => {
|
| 58 |
setShowPreview(false);
|
| 59 |
onGenerate(model);
|
|
|
|
| 62 |
isGenerating={isGenerating}
|
| 63 |
sessionId={sessionId}
|
| 64 |
selectedIndices={selectedIndices}
|
|
|
|
| 65 |
/>
|
| 66 |
)}
|
| 67 |
</div>
|