chih.yikuan Claude Opus 4.6 (1M context) commited on
Commit
f002ae5
·
1 Parent(s): e1ed51d

Redesign Step 2: per-student report generation with wrong-answer filtering

Browse files

Step 2 now shows a student checklist instead of prompt editor. Users select
which students to generate reports for. Backend filters each student's wrong
answers and only sends the relevant questions + wrong answers to the LLM,
dramatically reducing prompt size. Reports generate sequentially with live
progress in Step 3's tabbed viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chatkit/backend/app/main.py CHANGED
@@ -31,7 +31,7 @@ from .database import (
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_report, get_default_prompt
35
 
36
  STATIC_DIR = Path(__file__).parent.parent / "static"
37
 
@@ -228,21 +228,37 @@ async def api_delete_prompt(prompt_id: int, user=Depends(get_current_user)):
228
  # Report Generation Endpoints
229
  # =============================================================================
230
 
231
- class GenerateReportRequest(BaseModel):
232
- prompt_id: Optional[int] = None
233
- prompt_content: Optional[str] = None
234
  model: str = "gpt-5.4"
235
 
236
 
237
- @app.post("/api/sessions/{session_id}/generate-report")
238
- async def api_generate_report(
239
- session_id: int, body: GenerateReportRequest, user=Depends(get_current_user)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  ):
 
241
  session = await get_session(session_id)
242
  if not session or session["user_id"] != user["id"]:
243
  raise HTTPException(status_code=404, detail="Session not found")
244
 
245
- # Load parsed data
246
  data = await get_parsed_data(session_id)
247
  questions = {}
248
  student_answers = {}
@@ -256,21 +272,18 @@ async def api_generate_report(
256
  teacher_answers = item["structured_data"]
257
 
258
  if not questions:
259
- raise HTTPException(status_code=400, detail="No questions data found. Upload and process files first.")
 
 
 
 
260
 
261
- # Get prompt content
262
- prompt_content = body.prompt_content
263
- if not prompt_content and body.prompt_id:
264
- prompt = await get_prompt(body.prompt_id)
265
- if prompt:
266
- prompt_content = prompt["content"]
267
- if not prompt_content:
268
- prompt_content = get_default_prompt()
269
 
270
  try:
271
- html = await generate_report(questions, student_answers, teacher_answers, prompt_content, body.model)
272
- report_id = await save_report(session_id, body.prompt_id, html)
273
- return {"report_id": report_id, "html_content": html}
274
  except Exception as e:
275
  raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
276
 
 
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
35
 
36
  STATIC_DIR = Path(__file__).parent.parent / "static"
37
 
 
228
  # Report Generation Endpoints
229
  # =============================================================================
230
 
231
+ class GenerateStudentReportRequest(BaseModel):
232
+ student_index: int
 
233
  model: str = "gpt-5.4"
234
 
235
 
236
+ @app.get("/api/sessions/{session_id}/students")
237
+ async def api_get_students(session_id: int, user=Depends(get_current_user)):
238
+ """Return the list of student names from parsed student_answers data."""
239
+ session = await get_session(session_id)
240
+ if not session or session["user_id"] != user["id"]:
241
+ raise HTTPException(status_code=404, detail="Session not found")
242
+
243
+ data = await get_parsed_data(session_id)
244
+ student_answers = {}
245
+ for item in data:
246
+ if item["data_type"] == "student_answers":
247
+ student_answers = item["structured_data"]
248
+ break
249
+
250
+ return {"students": get_student_names(student_answers)}
251
+
252
+
253
+ @app.post("/api/sessions/{session_id}/generate-student-report")
254
+ async def api_generate_student_report(
255
+ session_id: int, body: GenerateStudentReportRequest, user=Depends(get_current_user)
256
  ):
257
+ """Generate a report for a single student, focused on their wrong answers."""
258
  session = await get_session(session_id)
259
  if not session or session["user_id"] != user["id"]:
260
  raise HTTPException(status_code=404, detail="Session not found")
261
 
 
262
  data = await get_parsed_data(session_id)
263
  questions = {}
264
  student_answers = {}
 
272
  teacher_answers = item["structured_data"]
273
 
274
  if not questions:
275
+ raise HTTPException(status_code=400, detail="No questions data found.")
276
+
277
+ students = student_answers.get("students", [])
278
+ if body.student_index < 0 or body.student_index >= len(students):
279
+ raise HTTPException(status_code=400, detail=f"Invalid student index: {body.student_index}")
280
 
281
+ student = students[body.student_index]
 
 
 
 
 
 
 
282
 
283
  try:
284
+ html = await generate_student_report(student, questions, teacher_answers, body.model)
285
+ report_id = await save_report(session_id, None, html)
286
+ return {"report_id": report_id, "html_content": html, "student_name": student.get("name", "")}
287
  except Exception as e:
288
  raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
289
 
chatkit/backend/app/report_generator.py CHANGED
@@ -23,69 +23,115 @@ MODEL_CONTEXT_LIMITS: dict[str, int] = {
23
  }
24
 
25
  DEFAULT_CONTEXT_LIMIT = 128_000
26
- # Minimum tokens we need for a useful HTML report
27
  MIN_COMPLETION_TOKENS = 2_000
28
  DESIRED_COMPLETION_TOKENS = 16_384
29
  TOKEN_SAFETY_MARGIN = 256
30
 
31
 
32
  def _estimate_tokens(text: str) -> int:
33
- """Estimate token count using char-count heuristic.
34
-
35
- CJK text averages ~1.5-2 tokens/char; English ~4 chars/token.
36
- Using len//3 is a conservative middle-ground that slightly overestimates,
37
- which is the safe direction for a pre-flight check.
38
- """
39
  return max(1, len(text) // 3)
40
 
41
 
42
- def _truncate_students(student_answers: dict, max_students: int) -> tuple[dict, int, int]:
43
- """Truncate student list to fit within token budget.
 
 
 
 
 
 
 
 
44
 
45
- Returns (truncated_data, original_count, kept_count).
46
- """
47
- students = student_answers.get("students", [])
48
- original_count = len(students)
49
- if original_count <= max_students:
50
- return student_answers, original_count, original_count
51
- truncated = {**student_answers, "students": students[:max_students]}
52
- return truncated, original_count, max_students
53
 
 
 
 
 
 
 
54
 
55
- REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  ## Your Task
58
- Generate a complete, self-contained HTML report based on the exam data and prompt provided.
59
 
60
  ## Language
61
  - Use Traditional Chinese (繁體中文) for all content
62
  - Include bilingual labels where appropriate (English + 中文)
63
 
64
- ## HTML Report Requirements
65
 
66
- Generate a complete HTML file with these sections:
 
 
 
67
 
68
- ### Section 1: 📝 Q&A Analysis (題目詳解)
69
- For each question:
70
- - Question number, text, and type
71
- - Correct answer highlighted
72
  - Concept tags (e.g., 🎯 細節理解, 🔍 推論能力)
73
- - Detailed explanation with: what it tests, solving strategy, common mistakes, learning points
 
 
74
 
75
- ### Section 2: 📊 Statistics (成績統計)
76
- - Stats grid: total students, average, highest, lowest
77
- - Score distribution bar chart (Chart.js)
78
- - Question accuracy doughnut chart (Chart.js)
79
- - Student details table with score badges (color-coded)
80
 
81
- ### Section 3: 👩‍🏫 Teacher Insights (教師分析與建議)
82
- - Overall performance analysis
83
- - Teaching recommendations
84
- - Individual student support suggestions (🔴 needs attention, 🟡 needs reinforcement, 🟢 high performers)
85
 
86
- ## HTML Template Structure
87
 
88
- Use this dark theme with the following CSS variables:
89
  ```
90
  :root {
91
  --bg-primary: #0f1419;
@@ -101,127 +147,104 @@ Use this dark theme with the following CSS variables:
101
  }
102
  ```
103
 
104
- Include Chart.js via CDN: `<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>`
105
-
106
  ## Privacy
107
- - Use partial names (e.g., 李X恩) in reports
108
- - Never expose full student identifiers
109
- - Group low performers sensitively
110
 
111
  ## Output
112
  Return ONLY the complete HTML document. No markdown code fences. Just raw HTML starting with <!DOCTYPE html>."""
113
 
114
 
115
- DEFAULT_ANALYSIS_PROMPT = """請根據以下考試數據生成一份完整的分析報告。
116
 
117
- ## 考試題目:
118
- {questions}
119
 
120
- ## 學生答案:
121
- {student_answers}
122
 
123
- ## 標準答案:
124
- {teacher_answers}
125
 
126
- 請生成包含以下內容的 HTML 報告:
127
- 1. 題目詳解 (Q&A Analysis) - 每題的詳細分析、解題策略、常見錯誤
128
- 2. 成績統計 (Statistics) - 包含 Chart.js 圖表的統計分析
129
- 3. 教師分析與建議 (Teacher Insights) - 教學改進建議和個別輔導建議
130
 
131
- 請自動計算每位學生的成績,識別常見錯誤模式,並提供具體的教學建議。"""
 
132
 
 
133
 
134
- def get_default_prompt() -> str:
135
- return DEFAULT_ANALYSIS_PROMPT
136
 
137
-
138
- def fill_prompt(template: str, questions: dict, student_answers: dict, teacher_answers: dict) -> str:
139
- """Fill the prompt template with actual data."""
140
- return template.format(
141
- questions=json.dumps(questions, ensure_ascii=False, separators=(",", ":")),
142
- student_answers=json.dumps(student_answers, ensure_ascii=False, separators=(",", ":")),
143
- teacher_answers=json.dumps(teacher_answers, ensure_ascii=False, separators=(",", ":")),
144
- )
145
 
146
 
147
- def extract_html(content: str) -> str:
148
- """Extract HTML from potential markdown code blocks."""
149
- # Try to find HTML in code blocks first
150
- match = re.search(r"```html?\s*\n(.*?)```", content, re.DOTALL)
151
- if match:
152
- return match.group(1).strip()
153
- # If content starts with <!DOCTYPE or <html, it's already raw HTML
154
- stripped = content.strip()
155
- if stripped.startswith("<!DOCTYPE") or stripped.startswith("<html"):
156
- return stripped
157
- return stripped
158
 
159
 
160
- async def generate_report(
 
161
  questions_data: dict,
162
- student_answers_data: dict,
163
  teacher_answers_data: dict,
164
- prompt_template: str,
165
  model: str = "gpt-5.4",
166
  ) -> str:
167
- """
168
- Generate HTML report by sending parsed exam data + prompt to OpenAI.
169
- Uses the same OPENAI_API_KEY as file parsing.
170
- Returns complete HTML string.
171
- """
172
  settings = get_settings()
173
  client = AsyncOpenAI(api_key=settings.openai_api_key)
174
- context_limit = MODEL_CONTEXT_LIMITS.get(model, DEFAULT_CONTEXT_LIMIT)
175
 
176
- # --- Try full data first, auto-truncate students if too large ---
177
- current_student_data = student_answers_data
178
- total_students = len(student_answers_data.get("students", []))
179
- truncated = False
180
 
181
- for attempt in range(3): # full → 50% → 25% of students
182
- filled_prompt = fill_prompt(prompt_template, questions_data, current_student_data, teacher_answers_data)
183
- prompt_tokens = _estimate_tokens(REPORT_SYSTEM_PROMPT) + _estimate_tokens(filled_prompt)
184
- available_for_completion = context_limit - prompt_tokens - TOKEN_SAFETY_MARGIN
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
 
186
- logger.info(
187
- "Attempt %d — prompt ~%d tokens, available for completion: %d (model: %s, students: %d)",
188
- attempt + 1, prompt_tokens, available_for_completion, model,
189
- len(current_student_data.get("students", [])),
190
- )
191
 
192
- if available_for_completion >= MIN_COMPLETION_TOKENS:
193
- break
 
 
194
 
195
- # Reduce student count: 50% → 25% of original
196
- fraction = 0.5 if attempt == 0 else 0.25
197
- max_students = max(1, int(total_students * fraction))
198
- current_student_data, _, kept = _truncate_students(student_answers_data, max_students)
199
- truncated = True
200
- logger.warning(
201
- "Prompt too large, truncating students: %d → %d", total_students, kept,
202
- )
203
- else:
204
- # Still too large after all truncation attempts
205
  raise ValueError(
206
- f"提示內容過大(約 {prompt_tokens:,} tokens),即使只保留 25% 的學生資料仍超出模型限制。"
207
- f"請減少題目數量或簡化提示。"
208
  )
209
 
210
- max_completion = min(DESIRED_COMPLETION_TOKENS, available_for_completion)
211
-
212
- # Add a note to the prompt if data was truncated
213
- if truncated:
214
- kept_count = len(current_student_data.get("students", []))
215
- truncation_note = (
216
- f"\n\n⚠️ 注意:因資料量過大,僅包含 {kept_count}/{total_students} 位學生的答案。"
217
- f"請在報告中註明此為部分學生的抽樣分析。"
218
- )
219
- filled_prompt += truncation_note
220
 
221
  kwargs: dict = {
222
  "model": model,
223
  "messages": [
224
- {"role": "system", "content": REPORT_SYSTEM_PROMPT},
225
  {"role": "user", "content": filled_prompt},
226
  ],
227
  "max_completion_tokens": max_completion,
@@ -231,7 +254,6 @@ async def generate_report(
231
  try:
232
  response = await client.chat.completions.create(**kwargs)
233
  except Exception as e:
234
- # Some models may not support max_completion_tokens; fallback
235
  if "max_completion_tokens" in str(e):
236
  kwargs.pop("max_completion_tokens")
237
  kwargs["max_tokens"] = max_completion
@@ -247,5 +269,5 @@ async def generate_report(
247
  detail = f"finish_reason={reason}"
248
  if refusal:
249
  detail += f", refusal={refusal}"
250
- raise ValueError(f"Model returned empty content ({detail}). Try a different model or simplify your prompt.")
251
  return extract_html(html_content)
 
23
  }
24
 
25
  DEFAULT_CONTEXT_LIMIT = 128_000
 
26
  MIN_COMPLETION_TOKENS = 2_000
27
  DESIRED_COMPLETION_TOKENS = 16_384
28
  TOKEN_SAFETY_MARGIN = 256
29
 
30
 
31
  def _estimate_tokens(text: str) -> int:
 
 
 
 
 
 
32
  return max(1, len(text) // 3)
33
 
34
 
35
+ def extract_html(content: str) -> str:
36
+ """Extract HTML from potential markdown code blocks."""
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 (new flow)
48
+ # ---------------------------------------------------------------------------
 
 
 
 
 
49
 
50
+ def filter_student_wrong_answers(
51
+ student: dict,
52
+ questions_data: dict,
53
+ teacher_answers_data: dict,
54
+ ) -> tuple[list[dict], list[dict], list[dict]]:
55
+ """Compare a student's answers with teacher answers, return wrong items.
56
 
57
+ Returns (wrong_questions, wrong_student_answers, relevant_teacher_answers).
58
+ """
59
+ teacher_map: dict[int, dict] = {}
60
+ for ta in teacher_answers_data.get("answers", []):
61
+ qnum = ta.get("question_number")
62
+ if qnum is not None:
63
+ teacher_map[int(qnum)] = ta
64
+
65
+ question_map: dict[int, dict] = {}
66
+ for q in questions_data.get("questions", []):
67
+ num = q.get("number")
68
+ if num is not None:
69
+ question_map[int(num)] = q
70
+
71
+ wrong_questions: list[dict] = []
72
+ wrong_answers: list[dict] = []
73
+ relevant_teacher: list[dict] = []
74
+
75
+ for sa in student.get("answers", []):
76
+ qnum = sa.get("question_number")
77
+ if qnum is None:
78
+ continue
79
+ qnum = int(qnum)
80
+ ta = teacher_map.get(qnum)
81
+ if ta is None:
82
+ continue
83
+
84
+ student_ans = str(sa.get("answer", "")).strip()
85
+ correct_ans = str(ta.get("correct_answer", "")).strip()
86
+
87
+ if student_ans.lower() != correct_ans.lower():
88
+ wrong_answers.append(sa)
89
+ relevant_teacher.append(ta)
90
+ q = question_map.get(qnum)
91
+ if q:
92
+ wrong_questions.append(q)
93
+
94
+ return wrong_questions, wrong_answers, relevant_teacher
95
+
96
+
97
+ STUDENT_REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates a focused, personalized exam analysis report for a single student.
98
 
99
  ## Your Task
100
+ Generate a complete, self-contained HTML report analyzing this student's wrong answers only.
101
 
102
  ## Language
103
  - Use Traditional Chinese (繁體中文) for all content
104
  - Include bilingual labels where appropriate (English + 中文)
105
 
106
+ ## HTML Report Sections
107
 
108
+ ### Section 1: 📋 Student Overview (學生概覽)
109
+ - Student name (use partial name for privacy, e.g., 李X恩)
110
+ - Total questions, correct count, wrong count, score percentage
111
+ - A brief overall assessment
112
 
113
+ ### Section 2: 📝 Wrong Answer Analysis (錯題詳解)
114
+ For EACH wrong answer:
115
+ - Question number, full question text, type
116
+ - ❌ Student's wrong answer vs ✅ Correct answer
117
  - Concept tags (e.g., 🎯 細節理解, 🔍 推論能力)
118
+ - Why the student likely chose the wrong answer
119
+ - Detailed explanation of the correct answer
120
+ - Learning points and study tips for this topic
121
 
122
+ ### Section 3: 📊 Weakness Summary (弱點分析)
123
+ - Categorize wrong answers by topic/skill
124
+ - Identify patterns in mistakes
125
+ - Priority study areas
 
126
 
127
+ ### Section 4: 💡 Study Recommendations (學習建議)
128
+ - Specific, actionable study suggestions
129
+ - Recommended topics to review
130
+ - Study strategies for the identified weak areas
131
 
132
+ ## HTML Theme
133
 
134
+ Use this dark theme:
135
  ```
136
  :root {
137
  --bg-primary: #0f1419;
 
147
  }
148
  ```
149
 
 
 
150
  ## Privacy
151
+ - Use partial names (e.g., 李X恩)
 
 
152
 
153
  ## Output
154
  Return ONLY the complete HTML document. No markdown code fences. Just raw HTML starting with <!DOCTYPE html>."""
155
 
156
 
157
+ STUDENT_REPORT_PROMPT = """請為以下學生生成個人化的錯題分析報告。
158
 
159
+ ## 學生姓名:{student_name}
 
160
 
161
+ ## 考試總題數:{total_questions}
162
+ ## 答錯題數:{wrong_count}
163
 
164
+ ## 答錯的題目:
165
+ {wrong_questions}
166
 
167
+ ## 該學生的錯誤答案:
168
+ {wrong_answers}
 
 
169
 
170
+ ## 對應的標準答案:
171
+ {teacher_answers}
172
 
173
+ 請針對每一道錯題提供詳細分析,找出錯誤模式,並給出具體的學習建議。"""
174
 
 
 
175
 
176
+ def get_default_prompt() -> str:
177
+ return STUDENT_REPORT_PROMPT
 
 
 
 
 
 
178
 
179
 
180
+ def get_student_names(student_answers_data: dict) -> list[dict]:
181
+ """Extract student names/ids from parsed student answers data."""
182
+ students = student_answers_data.get("students", [])
183
+ result = []
184
+ for i, s in enumerate(students):
185
+ result.append({
186
+ "index": i,
187
+ "name": s.get("name", f"Student {i + 1}"),
188
+ "id": s.get("id", ""),
189
+ })
190
+ return result
191
 
192
 
193
+ async def generate_student_report(
194
+ student: dict,
195
  questions_data: dict,
 
196
  teacher_answers_data: dict,
 
197
  model: str = "gpt-5.4",
198
  ) -> str:
199
+ """Generate HTML report for a single student, focused on their wrong answers."""
 
 
 
 
200
  settings = get_settings()
201
  client = AsyncOpenAI(api_key=settings.openai_api_key)
 
202
 
203
+ total_questions = len(questions_data.get("questions", []))
204
+ wrong_questions, wrong_answers, relevant_teacher = filter_student_wrong_answers(
205
+ student, questions_data, teacher_answers_data,
206
+ )
207
 
208
+ student_name = student.get("name", "Unknown")
209
+
210
+ if not wrong_answers:
211
+ # All correct — return a simple congratulations page
212
+ return f"""<!DOCTYPE html>
213
+ <html lang="zh-TW"><head><meta charset="UTF-8"><title>{student_name} - 分析報告</title>
214
+ <style>body{{background:#0f1419;color:#f1f5f9;font-family:system-ui;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}}
215
+ .card{{background:#212d3b;border-radius:16px;padding:3rem;text-align:center;max-width:500px}}
216
+ h1{{color:#4ecdc4;margin-bottom:1rem}}p{{color:#94a3b8;font-size:1.1rem}}</style></head>
217
+ <body><div class="card"><h1>🎉 全部答對!</h1><p><strong>{student_name}</strong> 在本次考試中答對了全部 {total_questions} 題,表現優異!</p></div></body></html>"""
218
+
219
+ filled_prompt = STUDENT_REPORT_PROMPT.format(
220
+ student_name=student_name,
221
+ total_questions=total_questions,
222
+ wrong_count=len(wrong_answers),
223
+ wrong_questions=json.dumps({"questions": wrong_questions}, ensure_ascii=False, indent=2),
224
+ wrong_answers=json.dumps(wrong_answers, ensure_ascii=False, indent=2),
225
+ teacher_answers=json.dumps(relevant_teacher, ensure_ascii=False, indent=2),
226
+ )
227
 
228
+ prompt_tokens = _estimate_tokens(STUDENT_REPORT_SYSTEM_PROMPT) + _estimate_tokens(filled_prompt)
229
+ context_limit = MODEL_CONTEXT_LIMITS.get(model, DEFAULT_CONTEXT_LIMIT)
230
+ available = context_limit - prompt_tokens - TOKEN_SAFETY_MARGIN
 
 
231
 
232
+ logger.info(
233
+ "Student report for %s — %d wrong answers, ~%d prompt tokens, %d available (model: %s)",
234
+ student_name, len(wrong_answers), prompt_tokens, available, model,
235
+ )
236
 
237
+ if available < MIN_COMPLETION_TOKENS:
 
 
 
 
 
 
 
 
 
238
  raise ValueError(
239
+ f"學生 {student_name} 的錯題資料過大(約 {prompt_tokens:,} tokens),請減少題目數量。"
 
240
  )
241
 
242
+ max_completion = min(DESIRED_COMPLETION_TOKENS, available)
 
 
 
 
 
 
 
 
 
243
 
244
  kwargs: dict = {
245
  "model": model,
246
  "messages": [
247
+ {"role": "system", "content": STUDENT_REPORT_SYSTEM_PROMPT},
248
  {"role": "user", "content": filled_prompt},
249
  ],
250
  "max_completion_tokens": max_completion,
 
254
  try:
255
  response = await client.chat.completions.create(**kwargs)
256
  except Exception as e:
 
257
  if "max_completion_tokens" in str(e):
258
  kwargs.pop("max_completion_tokens")
259
  kwargs["max_tokens"] = max_completion
 
269
  detail = f"finish_reason={reason}"
270
  if refusal:
271
  detail += f", refusal={refusal}"
272
+ raise ValueError(f"Model returned empty content for {student_name} ({detail}).")
273
  return extract_html(html_content)
chatkit/frontend/src/App.tsx CHANGED
@@ -5,7 +5,7 @@ import { LoginForm } from "./components/LoginForm";
5
  import { StepIndicator } from "./components/StepIndicator";
6
  import { FileUploadPanel } from "./components/step1/FileUploadPanel";
7
  import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
8
- import { PromptEditor } from "./components/step2/PromptEditor";
9
  import { ReportViewer } from "./components/step3/ReportViewer";
10
  import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
11
 
@@ -17,6 +17,20 @@ interface User {
17
  display_name: string;
18
  }
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  export default function App() {
21
  const [invited, setInvited] = useState(() => localStorage.getItem("invite_code") === INVITE_CODE);
22
  const [user, setUser] = useState<User | null>(null);
@@ -24,8 +38,8 @@ export default function App() {
24
  const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
25
  const [sessionId, setSessionId] = useState<number | null>(null);
26
  const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
27
- const [promptContent, setPromptContent] = useState("");
28
- const [reportHtml, setReportHtml] = useState("");
29
  const [isGenerating, setIsGenerating] = useState(false);
30
 
31
  // Check for saved JWT on mount
@@ -41,15 +55,6 @@ export default function App() {
41
  }
42
  }, []);
43
 
44
- // Load default prompt
45
- useEffect(() => {
46
- if (user && !promptContent) {
47
- apiGet<{ content: string }>("/api/prompts/default")
48
- .then((res) => setPromptContent(res.content))
49
- .catch(() => {});
50
- }
51
- }, [user]);
52
-
53
  // Create session when user is authenticated and no session exists
54
  useEffect(() => {
55
  if (user && !sessionId) {
@@ -59,12 +64,26 @@ export default function App() {
59
  }
60
  }, [user]);
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  const handleLogout = () => {
63
  setUser(null);
64
  setSessionId(null);
65
  setParsedData({});
66
- setPromptContent("");
67
- setReportHtml("");
68
  setCurrentStep(1);
69
  };
70
 
@@ -76,51 +95,88 @@ export default function App() {
76
  setCurrentStep(2);
77
  }, []);
78
 
79
- const handleRunSummary = useCallback(async (model: string) => {
80
- if (!sessionId || !promptContent.trim()) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  setIsGenerating(true);
82
- try {
83
- const res = await apiPost<{ report_id: number; html_content: string }>(
84
- `/api/sessions/${sessionId}/generate-report`,
85
- { prompt_content: promptContent, model }
 
 
 
 
 
86
  );
87
- setReportHtml(res.html_content);
88
- setCurrentStep(3);
89
- } catch (err) {
90
- alert(err instanceof Error ? err.message : "Report generation failed");
91
- } finally {
92
- setIsGenerating(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  }
94
- }, [sessionId, promptContent]);
95
-
96
- const handleExportPdf = useCallback(async () => {
97
- // Dynamic import to avoid loading html2pdf.js unless needed
98
- const html2pdf = (await import("html2pdf.js")).default;
99
- const container = document.createElement("div");
100
- container.innerHTML = reportHtml;
101
- document.body.appendChild(container);
102
- await html2pdf()
103
- .set({ margin: 10, filename: "classlens-report.pdf", html2canvas: { scale: 2 } })
104
- .from(container)
105
- .save();
106
- document.body.removeChild(container);
107
- }, [reportHtml]);
108
-
109
- const handleExportPng = useCallback(async () => {
110
- const html2canvas = (await import("html2canvas")).default;
111
- const container = document.createElement("div");
112
- container.innerHTML = reportHtml;
113
- container.style.width = "1200px";
114
- container.style.position = "absolute";
115
- container.style.left = "-9999px";
116
- document.body.appendChild(container);
117
- const canvas = await html2canvas(container, { scale: 2 });
118
- document.body.removeChild(container);
119
- const link = document.createElement("a");
120
- link.download = "classlens-report.png";
121
- link.href = canvas.toDataURL("image/png");
122
- link.click();
123
- }, [reportHtml]);
 
124
 
125
  const handleStepClick = (step: 1 | 2 | 3) => {
126
  if (step <= currentStep) setCurrentStep(step);
@@ -186,23 +242,25 @@ export default function App() {
186
  <div className="space-y-6 animate-fade-in-up">
187
  <div className="text-center mb-4">
188
  <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
189
- 編輯分析提示詞
190
  </h2>
191
  <p className="text-[var(--color-text-muted)] mt-2">
192
- 編輯提示詞來自訂分析報告的內容與格式
193
  </p>
194
  </div>
195
  <ParsedDataSummary parsedData={parsedData} />
196
- {sessionId && (
197
- <PromptEditor
198
- promptContent={promptContent}
199
- onPromptChange={setPromptContent}
200
- sessionId={sessionId}
201
- onRunSummary={handleRunSummary}
202
  isGenerating={isGenerating}
203
- parsedData={parsedData}
204
- onParsedDataUpdate={handleParsedDataUpdate}
205
  />
 
 
 
 
 
 
206
  )}
207
  </div>
208
  )}
@@ -211,14 +269,14 @@ export default function App() {
211
  <div className="animate-fade-in-up">
212
  <div className="text-center mb-4">
213
  <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
214
- 分析報告
215
  </h2>
216
  </div>
217
  <ReportViewer
218
- reportHtml={reportHtml}
 
219
  onBack={() => setCurrentStep(2)}
220
- onExportPdf={handleExportPdf}
221
- onExportPng={handleExportPng}
222
  />
223
  </div>
224
  )}
 
5
  import { StepIndicator } from "./components/StepIndicator";
6
  import { FileUploadPanel } from "./components/step1/FileUploadPanel";
7
  import { ParsedDataSummary } from "./components/step2/ParsedDataSummary";
8
+ import { StudentSelector } from "./components/step2/StudentSelector";
9
  import { ReportViewer } from "./components/step3/ReportViewer";
10
  import { apiGet, apiPost, getToken, clearToken } from "./lib/api";
11
 
 
17
  display_name: string;
18
  }
19
 
20
+ interface StudentInfo {
21
+ index: number;
22
+ name: string;
23
+ id: string;
24
+ }
25
+
26
+ export interface StudentReport {
27
+ studentName: string;
28
+ studentIndex: number;
29
+ html: string;
30
+ status: "pending" | "generating" | "done" | "error";
31
+ error?: string;
32
+ }
33
+
34
  export default function App() {
35
  const [invited, setInvited] = useState(() => localStorage.getItem("invite_code") === INVITE_CODE);
36
  const [user, setUser] = useState<User | null>(null);
 
38
  const [currentStep, setCurrentStep] = useState<1 | 2 | 3>(1);
39
  const [sessionId, setSessionId] = useState<number | null>(null);
40
  const [parsedData, setParsedData] = useState<Record<string, unknown>>({});
41
+ const [students, setStudents] = useState<StudentInfo[]>([]);
42
+ const [reports, setReports] = useState<StudentReport[]>([]);
43
  const [isGenerating, setIsGenerating] = useState(false);
44
 
45
  // Check for saved JWT on mount
 
55
  }
56
  }, []);
57
 
 
 
 
 
 
 
 
 
 
58
  // Create session when user is authenticated and no session exists
59
  useEffect(() => {
60
  if (user && !sessionId) {
 
64
  }
65
  }, [user]);
66
 
67
+ // Extract student list from parsed data
68
+ useEffect(() => {
69
+ const sa = parsedData.student_answers as { students?: { name?: string; id?: string }[] } | undefined;
70
+ if (sa?.students) {
71
+ setStudents(
72
+ sa.students.map((s, i) => ({
73
+ index: i,
74
+ name: s.name || `Student ${i + 1}`,
75
+ id: s.id || "",
76
+ }))
77
+ );
78
+ }
79
+ }, [parsedData]);
80
+
81
  const handleLogout = () => {
82
  setUser(null);
83
  setSessionId(null);
84
  setParsedData({});
85
+ setStudents([]);
86
+ setReports([]);
87
  setCurrentStep(1);
88
  };
89
 
 
95
  setCurrentStep(2);
96
  }, []);
97
 
98
+ // Sequential per-student report generation
99
+ const handleGenerateReports = useCallback(async (selectedIndices: number[], model: string) => {
100
+ if (!sessionId) return;
101
+
102
+ // Initialize report slots
103
+ const initial: StudentReport[] = selectedIndices.map((idx) => {
104
+ const s = students.find((s) => s.index === idx);
105
+ return {
106
+ studentName: s?.name || `Student ${idx + 1}`,
107
+ studentIndex: idx,
108
+ html: "",
109
+ status: "pending",
110
+ };
111
+ });
112
+ setReports(initial);
113
  setIsGenerating(true);
114
+ setCurrentStep(3);
115
+
116
+ // Generate sequentially
117
+ for (let i = 0; i < selectedIndices.length; i++) {
118
+ const idx = selectedIndices[i];
119
+
120
+ // Mark current as generating
121
+ setReports((prev) =>
122
+ prev.map((r, j) => (j === i ? { ...r, status: "generating" } : r))
123
  );
124
+
125
+ try {
126
+ const res = await apiPost<{
127
+ report_id: number;
128
+ html_content: string;
129
+ student_name: string;
130
+ }>(`/api/sessions/${sessionId}/generate-student-report`, {
131
+ student_index: idx,
132
+ model,
133
+ });
134
+
135
+ setReports((prev) =>
136
+ prev.map((r, j) =>
137
+ j === i ? { ...r, html: res.html_content, status: "done" } : r
138
+ )
139
+ );
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : "Generation failed";
142
+ setReports((prev) =>
143
+ prev.map((r, j) =>
144
+ j === i ? { ...r, status: "error", error: msg } : r
145
+ )
146
+ );
147
+ }
148
  }
149
+
150
+ setIsGenerating(false);
151
+ }, [sessionId, students]);
152
+
153
+ const handleExportAllHtml = useCallback(() => {
154
+ const doneReports = reports.filter((r) => r.status === "done");
155
+ if (doneReports.length === 0) return;
156
+
157
+ // Combine all reports into one HTML file with page breaks
158
+ const combined = `<!DOCTYPE html>
159
+ <html lang="zh-TW"><head><meta charset="UTF-8"><title>ClassLens - Student Reports</title>
160
+ <style>
161
+ .page-break { page-break-after: always; break-after: page; }
162
+ .report-container { margin-bottom: 2rem; }
163
+ </style></head><body>
164
+ ${doneReports
165
+ .map(
166
+ (r, i) =>
167
+ `<div class="report-container${i < doneReports.length - 1 ? " page-break" : ""}">${r.html}</div>`
168
+ )
169
+ .join("\n")}
170
+ </body></html>`;
171
+
172
+ const blob = new Blob([combined], { type: "text/html" });
173
+ const url = URL.createObjectURL(blob);
174
+ const a = document.createElement("a");
175
+ a.href = url;
176
+ a.download = "classlens-all-reports.html";
177
+ a.click();
178
+ URL.revokeObjectURL(url);
179
+ }, [reports]);
180
 
181
  const handleStepClick = (step: 1 | 2 | 3) => {
182
  if (step <= currentStep) setCurrentStep(step);
 
242
  <div className="space-y-6 animate-fade-in-up">
243
  <div className="text-center mb-4">
244
  <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
245
+ 選擇學生
246
  </h2>
247
  <p className="text-[var(--color-text-muted)] mt-2">
248
+ 勾選要生成個人錯題分析報告的學生,系統會逐一分析每位學生的答錯題目
249
  </p>
250
  </div>
251
  <ParsedDataSummary parsedData={parsedData} />
252
+ {students.length > 0 ? (
253
+ <StudentSelector
254
+ students={students}
255
+ onGenerate={handleGenerateReports}
 
 
256
  isGenerating={isGenerating}
 
 
257
  />
258
+ ) : (
259
+ <div className="card p-8 text-center">
260
+ <p className="text-[var(--color-text-muted)]">
261
+ 尚未上傳學生答案資料,請先回到上一步上傳檔案
262
+ </p>
263
+ </div>
264
  )}
265
  </div>
266
  )}
 
269
  <div className="animate-fade-in-up">
270
  <div className="text-center mb-4">
271
  <h2 className="font-display text-2xl font-bold text-[var(--color-text)]">
272
+ 學生分析報告
273
  </h2>
274
  </div>
275
  <ReportViewer
276
+ reports={reports}
277
+ isGenerating={isGenerating}
278
  onBack={() => setCurrentStep(2)}
279
+ onExportAllHtml={handleExportAllHtml}
 
280
  />
281
  </div>
282
  )}
chatkit/frontend/src/components/step2/StudentSelector.tsx ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { ModelSelector } from "../ModelSelector";
3
+
4
+ interface Student {
5
+ index: number;
6
+ name: string;
7
+ id: string;
8
+ }
9
+
10
+ interface StudentSelectorProps {
11
+ students: Student[];
12
+ onGenerate: (selectedIndices: number[], model: string) => void;
13
+ isGenerating: boolean;
14
+ }
15
+
16
+ export function StudentSelector({
17
+ students,
18
+ onGenerate,
19
+ isGenerating,
20
+ }: StudentSelectorProps) {
21
+ const [selected, setSelected] = useState<Set<number>>(
22
+ () => new Set(students.map((s) => s.index))
23
+ );
24
+ const [model, setModel] = useState("gpt-5.4");
25
+
26
+ const toggleAll = () => {
27
+ if (selected.size === students.length) {
28
+ setSelected(new Set());
29
+ } else {
30
+ setSelected(new Set(students.map((s) => s.index)));
31
+ }
32
+ };
33
+
34
+ const toggle = (index: number) => {
35
+ setSelected((prev) => {
36
+ const next = new Set(prev);
37
+ if (next.has(index)) {
38
+ next.delete(index);
39
+ } else {
40
+ next.add(index);
41
+ }
42
+ return next;
43
+ });
44
+ };
45
+
46
+ const allSelected = selected.size === students.length;
47
+
48
+ return (
49
+ <div className="space-y-4">
50
+ {/* Header bar */}
51
+ <div className="flex flex-wrap items-center justify-between gap-3">
52
+ <div className="flex items-center gap-3">
53
+ <button
54
+ onClick={toggleAll}
55
+ className="btn btn-outline text-sm !py-2"
56
+ >
57
+ {allSelected ? "取消全選" : "全選"}
58
+ </button>
59
+ <span className="text-sm text-[var(--color-text-muted)]">
60
+ 已選 {selected.size} / {students.length} 位學生
61
+ </span>
62
+ </div>
63
+ <ModelSelector value={model} onChange={setModel} label="報告模型:" />
64
+ </div>
65
+
66
+ {/* Student list */}
67
+ <div className="card p-4 max-h-[50vh] overflow-y-auto">
68
+ <div className="grid gap-2">
69
+ {students.map((s) => (
70
+ <label
71
+ key={s.index}
72
+ className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-colors ${
73
+ selected.has(s.index)
74
+ ? "bg-[var(--color-primary)]/10 border border-[var(--color-primary)]/30"
75
+ : "hover:bg-[var(--color-surface)] border border-transparent"
76
+ }`}
77
+ >
78
+ <input
79
+ type="checkbox"
80
+ checked={selected.has(s.index)}
81
+ onChange={() => toggle(s.index)}
82
+ className="w-5 h-5 rounded accent-[var(--color-primary)]"
83
+ />
84
+ <span className="font-semibold text-[var(--color-text)]">
85
+ {s.name}
86
+ </span>
87
+ {s.id && (
88
+ <span className="text-xs text-[var(--color-text-muted)]">
89
+ ({s.id})
90
+ </span>
91
+ )}
92
+ </label>
93
+ ))}
94
+ </div>
95
+ </div>
96
+
97
+ {/* Generate button */}
98
+ <div className="text-center">
99
+ <button
100
+ onClick={() => onGenerate(Array.from(selected).sort((a, b) => a - b), model)}
101
+ disabled={isGenerating || selected.size === 0}
102
+ className="btn btn-accent text-lg px-8 py-4 disabled:opacity-50 disabled:cursor-not-allowed"
103
+ >
104
+ {isGenerating ? (
105
+ <span className="flex items-center gap-2">
106
+ <span className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
107
+ 生成報告中...
108
+ </span>
109
+ ) : (
110
+ `為 ${selected.size} 位學生生成報告 →`
111
+ )}
112
+ </button>
113
+ </div>
114
+ </div>
115
+ );
116
+ }
chatkit/frontend/src/components/step3/ReportViewer.tsx CHANGED
@@ -1,53 +1,157 @@
1
- import { useRef } from "react";
 
2
 
3
  interface ReportViewerProps {
4
- reportHtml: string;
 
5
  onBack: () => void;
6
- onExportPdf: () => void;
7
- onExportPng: () => void;
8
  }
9
 
10
- export function ReportViewer({ reportHtml, onBack, onExportPdf, onExportPng }: ReportViewerProps) {
 
 
 
 
 
 
11
  const iframeRef = useRef<HTMLIFrameElement>(null);
12
 
13
- const handleDownloadHtml = () => {
14
- const blob = new Blob([reportHtml], { type: "text/html" });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  const url = URL.createObjectURL(blob);
16
  const a = document.createElement("a");
17
  a.href = url;
18
- a.download = "classlens-report.html";
19
  a.click();
20
  URL.revokeObjectURL(url);
21
  };
22
 
23
  return (
24
  <div className="space-y-4">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  {/* Action bar */}
26
  <div className="flex flex-wrap items-center gap-3">
27
  <button onClick={onBack} className="btn btn-outline text-sm">
28
- ← 返回修改提示詞
29
  </button>
30
  <div className="flex-1" />
31
- <button onClick={handleDownloadHtml} className="btn btn-outline text-sm">
32
- 下載 HTML
33
- </button>
34
- <button onClick={onExportPdf} className="btn btn-outline text-sm">
35
- 匯出 PDF
36
- </button>
37
- <button onClick={onExportPng} className="btn btn-outline text-sm">
38
- 匯出 PNG
39
- </button>
 
40
  </div>
41
 
42
- {/* Report iframe */}
43
- <div className="card overflow-hidden" style={{ height: "75vh" }}>
44
- <iframe
45
- ref={iframeRef}
46
- srcDoc={reportHtml}
47
- title="Report"
48
- className="w-full h-full border-0"
49
- sandbox="allow-scripts allow-same-origin"
50
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  </div>
52
  </div>
53
  );
 
1
+ import { useState, useRef, useEffect } from "react";
2
+ import type { StudentReport } from "../../App";
3
 
4
  interface ReportViewerProps {
5
+ reports: StudentReport[];
6
+ isGenerating: boolean;
7
  onBack: () => void;
8
+ onExportAllHtml: () => void;
 
9
  }
10
 
11
+ export function ReportViewer({
12
+ reports,
13
+ isGenerating,
14
+ onBack,
15
+ onExportAllHtml,
16
+ }: ReportViewerProps) {
17
+ const [activeIndex, setActiveIndex] = useState(0);
18
  const iframeRef = useRef<HTMLIFrameElement>(null);
19
 
20
+ // Auto-switch to the report currently being generated
21
+ useEffect(() => {
22
+ const generatingIdx = reports.findIndex((r) => r.status === "generating");
23
+ if (generatingIdx >= 0) {
24
+ setActiveIndex(generatingIdx);
25
+ } else {
26
+ // When generation finishes, show the last completed one
27
+ let lastDone = -1;
28
+ for (let j = reports.length - 1; j >= 0; j--) {
29
+ if (reports[j].status === "done") { lastDone = j; break; }
30
+ }
31
+ if (lastDone >= 0) setActiveIndex(lastDone);
32
+ }
33
+ }, [reports]);
34
+
35
+ const activeReport = reports[activeIndex];
36
+ const doneCount = reports.filter((r) => r.status === "done").length;
37
+ const errorCount = reports.filter((r) => r.status === "error").length;
38
+
39
+ const handleDownloadSingle = () => {
40
+ if (!activeReport?.html) return;
41
+ const blob = new Blob([activeReport.html], { type: "text/html" });
42
  const url = URL.createObjectURL(blob);
43
  const a = document.createElement("a");
44
  a.href = url;
45
+ a.download = `classlens-${activeReport.studentName}.html`;
46
  a.click();
47
  URL.revokeObjectURL(url);
48
  };
49
 
50
  return (
51
  <div className="space-y-4">
52
+ {/* Progress bar */}
53
+ {isGenerating && (
54
+ <div className="card p-4">
55
+ <div className="flex items-center justify-between mb-2">
56
+ <span className="text-sm font-semibold text-[var(--color-text)]">
57
+ 生成進度
58
+ </span>
59
+ <span className="text-sm text-[var(--color-text-muted)]">
60
+ {doneCount + errorCount} / {reports.length}
61
+ </span>
62
+ </div>
63
+ <div className="w-full h-2 bg-[var(--color-border)] rounded-full overflow-hidden">
64
+ <div
65
+ className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-success)] transition-all duration-500"
66
+ style={{
67
+ width: `${((doneCount + errorCount) / reports.length) * 100}%`,
68
+ }}
69
+ />
70
+ </div>
71
+ </div>
72
+ )}
73
+
74
  {/* Action bar */}
75
  <div className="flex flex-wrap items-center gap-3">
76
  <button onClick={onBack} className="btn btn-outline text-sm">
77
+ ← 返回選擇學生
78
  </button>
79
  <div className="flex-1" />
80
+ {activeReport?.status === "done" && (
81
+ <button onClick={handleDownloadSingle} className="btn btn-outline text-sm">
82
+ 下載此報告
83
+ </button>
84
+ )}
85
+ {doneCount > 1 && (
86
+ <button onClick={onExportAllHtml} className="btn btn-primary text-sm">
87
+ 下載全部報告
88
+ </button>
89
+ )}
90
  </div>
91
 
92
+ {/* Student tabs */}
93
+ <div className="flex gap-2 overflow-x-auto pb-2">
94
+ {reports.map((r, i) => (
95
+ <button
96
+ key={r.studentIndex}
97
+ onClick={() => setActiveIndex(i)}
98
+ className={`shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all ${
99
+ i === activeIndex
100
+ ? "bg-[var(--color-primary)] text-white shadow-md"
101
+ : "bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] border border-[var(--color-border)]"
102
+ }`}
103
+ >
104
+ <span className="mr-1.5">
105
+ {r.status === "done" && "✅"}
106
+ {r.status === "generating" && "⏳"}
107
+ {r.status === "pending" && "⬜"}
108
+ {r.status === "error" && "❌"}
109
+ </span>
110
+ {r.studentName}
111
+ </button>
112
+ ))}
113
+ </div>
114
+
115
+ {/* Report content */}
116
+ <div className="card overflow-hidden" style={{ height: "70vh" }}>
117
+ {activeReport?.status === "done" && (
118
+ <iframe
119
+ ref={iframeRef}
120
+ srcDoc={activeReport.html}
121
+ title={`Report - ${activeReport.studentName}`}
122
+ className="w-full h-full border-0"
123
+ sandbox="allow-scripts allow-same-origin"
124
+ />
125
+ )}
126
+
127
+ {activeReport?.status === "generating" && (
128
+ <div className="w-full h-full flex flex-col items-center justify-center gap-4">
129
+ <div className="w-10 h-10 border-3 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
130
+ <p className="text-[var(--color-text-muted)] text-lg">
131
+ 正在為 <strong className="text-[var(--color-text)]">{activeReport.studentName}</strong> 生成報告...
132
+ </p>
133
+ </div>
134
+ )}
135
+
136
+ {activeReport?.status === "pending" && (
137
+ <div className="w-full h-full flex items-center justify-center">
138
+ <p className="text-[var(--color-text-muted)]">
139
+ 等待生成...
140
+ </p>
141
+ </div>
142
+ )}
143
+
144
+ {activeReport?.status === "error" && (
145
+ <div className="w-full h-full flex flex-col items-center justify-center gap-3">
146
+ <span className="text-4xl">❌</span>
147
+ <p className="text-[var(--color-text)] font-semibold">
148
+ {activeReport.studentName} 報告生成失敗
149
+ </p>
150
+ <p className="text-sm text-[var(--color-text-muted)] max-w-md text-center">
151
+ {activeReport.error}
152
+ </p>
153
+ </div>
154
+ )}
155
  </div>
156
  </div>
157
  );