Spaces:
Paused
Paused
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 filesStep 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
|
| 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
|
| 232 |
-
|
| 233 |
-
prompt_content: Optional[str] = None
|
| 234 |
model: str = "gpt-5.4"
|
| 235 |
|
| 236 |
|
| 237 |
-
@app.
|
| 238 |
-
async def
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
|
| 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
|
| 272 |
-
report_id = await save_report(session_id,
|
| 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
|
| 43 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
## Your Task
|
| 58 |
-
Generate a complete, self-contained HTML report
|
| 59 |
|
| 60 |
## Language
|
| 61 |
- Use Traditional Chinese (繁體中文) for all content
|
| 62 |
- Include bilingual labels where appropriate (English + 中文)
|
| 63 |
|
| 64 |
-
## HTML Report
|
| 65 |
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
### Section
|
| 69 |
-
For
|
| 70 |
-
- Question number, text,
|
| 71 |
-
-
|
| 72 |
- Concept tags (e.g., 🎯 細節理解, 🔍 推論能力)
|
| 73 |
-
-
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
### Section
|
| 76 |
-
-
|
| 77 |
-
-
|
| 78 |
-
-
|
| 79 |
-
- Student details table with score badges (color-coded)
|
| 80 |
|
| 81 |
-
### Section
|
| 82 |
-
-
|
| 83 |
-
-
|
| 84 |
-
-
|
| 85 |
|
| 86 |
-
## HTML
|
| 87 |
|
| 88 |
-
Use this dark theme
|
| 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恩)
|
| 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 |
-
|
| 116 |
|
| 117 |
-
##
|
| 118 |
-
{questions}
|
| 119 |
|
| 120 |
-
##
|
| 121 |
-
{
|
| 122 |
|
| 123 |
-
##
|
| 124 |
-
{
|
| 125 |
|
| 126 |
-
|
| 127 |
-
|
| 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 |
-
|
| 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
|
| 148 |
-
"""Extract
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
return
|
| 158 |
|
| 159 |
|
| 160 |
-
async def
|
|
|
|
| 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 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
len(current_student_data.get("students", [])),
|
| 190 |
-
)
|
| 191 |
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
|
| 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"
|
| 207 |
-
f"請減少題目數量或簡化提示。"
|
| 208 |
)
|
| 209 |
|
| 210 |
-
max_completion = min(DESIRED_COMPLETION_TOKENS,
|
| 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":
|
| 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}).
|
| 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 {
|
| 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 [
|
| 28 |
-
const [
|
| 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 |
-
|
| 67 |
-
|
| 68 |
setCurrentStep(1);
|
| 69 |
};
|
| 70 |
|
|
@@ -76,51 +95,88 @@ export default function App() {
|
|
| 76 |
setCurrentStep(2);
|
| 77 |
}, []);
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
setIsGenerating(true);
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
);
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
const
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
const
|
| 118 |
-
|
| 119 |
-
const
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
| 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 |
-
{
|
| 197 |
-
<
|
| 198 |
-
|
| 199 |
-
|
| 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 |
-
|
|
|
|
| 219 |
onBack={() => setCurrentStep(2)}
|
| 220 |
-
|
| 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 |
-
|
|
|
|
| 5 |
onBack: () => void;
|
| 6 |
-
|
| 7 |
-
onExportPng: () => void;
|
| 8 |
}
|
| 9 |
|
| 10 |
-
export function ReportViewer({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
const iframeRef = useRef<HTMLIFrameElement>(null);
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const url = URL.createObjectURL(blob);
|
| 16 |
const a = document.createElement("a");
|
| 17 |
a.href = url;
|
| 18 |
-
a.download =
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
| 40 |
</div>
|
| 41 |
|
| 42 |
-
{/*
|
| 43 |
-
<div className="
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 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 |
);
|