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 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 .report_generator import generate_student_report, get_default_prompt, get_student_names, build_student_prompt
 
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
- # Report Generation Endpoints
229
  # =============================================================================
230
 
231
- class PreviewStudentPromptRequest(BaseModel):
232
- student_index: int
233
- prompt_template: Optional[str] = None
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
- @app.post("/api/sessions/{session_id}/preview-student-prompt")
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
- questions = {}
253
- student_answers = {}
254
- teacher_answers = {}
255
  for item in data:
256
  if item["data_type"] == "questions":
257
- questions = item["structured_data"]
258
  elif item["data_type"] == "student_answers":
259
- student_answers = item["structured_data"]
260
  elif item["data_type"] == "teacher_answers":
261
- teacher_answers = item["structured_data"]
262
 
263
- students = student_answers.get("students", [])
264
- if body.student_index < 0 or body.student_index >= len(students):
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
- @app.get("/api/sessions/{session_id}/students")
273
- async def api_get_students(session_id: int, user=Depends(get_current_user)):
274
- """Return the list of student names from parsed student_answers data."""
275
- session = await get_session(session_id)
276
- if not session or session["user_id"] != user["id"]:
277
- raise HTTPException(status_code=404, detail="Session not found")
278
 
279
- data = await get_parsed_data(session_id)
280
- student_answers = {}
281
- for item in data:
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
- @app.post("/api/sessions/{session_id}/generate-student-report")
290
- async def api_generate_student_report(
291
- session_id: int, body: GenerateStudentReportRequest, user=Depends(get_current_user)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  ):
293
- """Generate a report for a single student, focused on their wrong answers."""
294
- session = await get_session(session_id)
295
- if not session or session["user_id"] != user["id"]:
296
- raise HTTPException(status_code=404, detail="Session not found")
297
 
298
- data = await get_parsed_data(session_id)
299
- questions = {}
300
- student_answers = {}
301
- teacher_answers = {}
302
- for item in data:
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
- if not questions:
311
- raise HTTPException(status_code=400, detail="No questions data found.")
 
 
 
 
 
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
- student = students[body.student_index]
 
 
 
 
 
 
 
 
 
 
 
 
318
 
319
  try:
320
- html = await generate_student_report(student, questions, teacher_answers, body.prompt_template, body.model)
321
  report_id = await save_report(session_id, None, html)
322
- return {"report_id": report_id, "html_content": html, "student_name": student.get("name", "")}
 
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 using OpenAI GPT directly (no ChatKit)."""
 
 
 
 
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
- stripped = content.strip()
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 (use partial name for privacy, e.g., 李X恩)
63
- - Total questions, correct count, wrong count, score percentage
64
  - A brief overall assessment
65
 
66
  ### Section 2: 📝 Wrong Answer Analysis (錯題詳解)
67
- For EACH wrong answer:
68
- - Question number, full question text, type
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
- DEFAULT_STUDENT_PROMPT = """請為以下學生生成個人化的考試分析報告。
111
-
112
- ## 資料格式說明
113
- - 在學生答案中,「-」表示該題答對
114
- - 其他文字為學生的實際作答內容(學生錯誤答案)
115
-
116
- ## 學生姓名:{student_name}
117
-
118
- ## 考試總題數:{total_questions}
119
-
120
- ## 考試目:
121
- {questions}
122
-
123
- ## 該學生的答案:
124
- {student_answers}
125
-
126
- ## 標準案:
127
- {teacher_answers}
128
-
129
- 請判斷哪些題目答錯,針對每一道錯題提供詳細分析,找出錯誤模式,並給出具體的學習建議。
130
- 如果未提供標準答案,請根據題目內容推斷正確答案。"""
131
-
132
-
133
- def get_default_prompt() -> str:
134
- return DEFAULT_STUDENT_PROMPT
135
-
136
-
137
- def build_student_prompt(
138
- student: dict,
139
- questions_data: dict,
140
- teacher_answers_data: dict,
141
- prompt_template: str | None = None,
142
- ) -> dict:
143
- """Build the filled prompt for a student (for preview).
144
-
145
- Passes raw data — no filtering. The prompt instructs the LLM how to read it.
146
- """
147
- template = prompt_template or DEFAULT_STUDENT_PROMPT
148
- total_questions = len(questions_data.get("questions", []))
149
- student_name = student.get("name", "Unknown")
150
- student_answers = student.get("answers", [])
151
-
152
- teacher_answers = teacher_answers_data.get("answers", [])
 
153
 
154
- filled = template.format(
155
- student_name=student_name,
156
- total_questions=total_questions,
157
- questions=json.dumps(questions_data, ensure_ascii=False, indent=2),
158
- student_answers=json.dumps(student_answers, ensure_ascii=False, indent=2),
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
- student: dict,
184
- questions_data: dict,
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
- result = build_student_prompt(student, questions_data, teacher_answers_data, prompt_template)
197
- filled_prompt = result["filled_prompt"]
198
- student_name = result["student_name"]
199
 
200
- prompt_tokens = _estimate_tokens(STUDENT_REPORT_SYSTEM_PROMPT) + _estimate_tokens(filled_prompt)
 
 
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, prompt_tokens, available, model,
 
 
 
 
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": filled_prompt},
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(f"Model returned empty content for {student_name} ({detail}).")
 
 
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 { useState, useEffect } from "react";
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
- type TabKey = "questions" | "student_answers" | "teacher_answers" | "final_prompt";
16
-
17
- const DATA_TABS: { key: Exclude<TabKey, "final_prompt">; label: string; icon: string }[] = [
18
- { key: "questions", label: "考試題目", icon: "📝" },
19
- { key: "student_answers", label: "學生答案", icon: "👨‍🎓" },
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 [activeTab, setActiveTab] = useState<TabKey>("final_prompt");
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 [filledPrompt, setFilledPrompt] = useState("");
51
- const [promptLoading, setPromptLoading] = useState(false);
52
- const [promptMeta, setPromptMeta] = useState<{
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
- const texts: Record<string, string> = {};
71
- for (const tab of DATA_TABS) {
72
- const data = parsedData[tab.key];
73
- texts[tab.key] = data ? JSON.stringify(data, null, 2) : "";
74
- }
75
- setEditTexts(texts);
76
- }, []);
77
-
78
- // Load filled prompt when tab is active or student changes
79
- useEffect(() => {
80
- if (activeTab !== "final_prompt") return;
81
- loadFilledPrompt(previewStudentIdx);
82
- }, [activeTab, previewStudentIdx]);
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
- {/* Modal */}
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
- {activeTab === "final_prompt" ? (
227
- /* Final prompt preview */
228
- <>
229
- <div className="flex items-center gap-3 mb-3 flex-wrap">
230
- <label className="text-sm font-semibold text-[var(--color-text)]">
231
- 預覽學生:
232
- </label>
233
- <select
234
- value={previewStudentIdx}
235
- onChange={(e) => setPreviewStudentIdx(Number(e.target.value))}
236
- className="input !w-auto !py-1.5 text-sm"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  >
238
- {selectedStudents.map((s) => (
239
- <option key={s.index} value={s.index}>
240
- {s.name}
241
- </option>
242
- ))}
243
- </select>
244
- {promptMeta && (
245
- <span className="badge badge-success text-xs">
246
- 總題數 {promptMeta.total_questions}
247
- </span>
248
- )}
249
- </div>
250
- <p className="text-xs text-[var(--color-text-muted)] mb-3">
251
- 以下是實際送給 LLM 的完整提示詞,變數已替換為該學生的原始資料
252
- </p>
253
- {promptLoading ? (
254
- <div className="flex-1 flex items-center justify-center">
255
- <div className="w-6 h-6 border-2 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
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
- /* Data editing tabs */
268
- <>
269
- <div className="flex items-center justify-between mb-3">
270
- <p className="text-sm text-[var(--color-text-muted)]">
271
- 可直接編輯下方 JSON 資料,修改後點擊「套用修改」儲存
272
- </p>
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={handleGenerate}
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, useEffect } from "react";
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
- useEffect(() => {
47
- loadPrompts();
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 handleSave = async () => {
62
- if (!saveName.trim()) return;
63
- setSaving(true);
64
- try {
65
- await apiPost("/api/prompts", { name: saveName, content: promptContent });
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
- {/* Toolbar */}
81
- <div className="flex flex-wrap items-center gap-3">
82
- {/* Load own prompts */}
83
- {prompts.length > 0 && (
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
- {/* Model selector - right aligned */}
116
- <div className="ml-auto">
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={isGenerating || selectedCount === 0}
182
  className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
183
  >
184
- {selectedCount === 0
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>