Spaces:
Paused
Paused
| """Report generation from a confirmed AnswerGrid. | |
| Builds compact markdown prompts containing only the wrong-answer blocks for | |
| each student, then calls OpenAI to produce an HTML report. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| from dataclasses import dataclass | |
| from openai import AsyncOpenAI | |
| from .answer_grid import AnswerGrid, WrongAnswer, diff_student | |
| from .config import get_settings | |
| logger = logging.getLogger(__name__) | |
| # Context window sizes per model (tokens) | |
| MODEL_CONTEXT_LIMITS: dict[str, int] = { | |
| "gpt-5.4": 128_000, | |
| "gpt-5.4-pro": 128_000, | |
| "gpt-5.3-chat-latest": 128_000, | |
| "gpt-5-mini": 128_000, | |
| "gpt-4o": 128_000, | |
| "gpt-4o-mini": 128_000, | |
| } | |
| DEFAULT_CONTEXT_LIMIT = 128_000 | |
| MIN_COMPLETION_TOKENS = 2_000 | |
| DESIRED_COMPLETION_TOKENS = 16_384 | |
| TOKEN_SAFETY_MARGIN = 256 | |
| def _estimate_tokens(text: str) -> int: | |
| return max(1, len(text) // 3) | |
| def extract_html(content: str) -> str: | |
| """Extract HTML from potential markdown code blocks.""" | |
| match = re.search(r"```html?\s*\n(.*?)```", content, re.DOTALL) | |
| if match: | |
| return match.group(1).strip() | |
| return content.strip() | |
| STUDENT_REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates a focused, personalized exam analysis report for a single student. | |
| ## Your Task | |
| 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. | |
| ## Language | |
| - Use Traditional Chinese (繁體中文) for all content | |
| - Include bilingual labels where appropriate (English + 中文) | |
| ## HTML Report Sections | |
| ### Section 1: 📋 Student Overview (學生概覽) | |
| - Student name (partial name for privacy, e.g., 李X恩) | |
| - Total questions, wrong count, score percentage (derived from counts) | |
| - A brief overall assessment | |
| ### Section 2: 📝 Wrong Answer Analysis (錯題詳解) | |
| For EACH wrong answer block provided: | |
| - Question number, full question text | |
| - ❌ Student's wrong answer vs ✅ Correct answer | |
| - Concept tags (e.g., 🎯 細節理解, 🔍 推論能力) | |
| - Why the student likely chose the wrong answer | |
| - Detailed explanation of the correct answer | |
| - Learning points and study tips for this topic | |
| ### Section 3: 📊 Weakness Summary (弱點分析) | |
| - Categorize wrong answers by topic/skill | |
| - Identify patterns in mistakes | |
| - Priority study areas | |
| ### Section 4: 💡 Study Recommendations (學習建議) | |
| - Specific, actionable study suggestions | |
| - Recommended topics to review | |
| - Study strategies for the identified weak areas | |
| ## HTML Theme | |
| Use this dark theme: | |
| ``` | |
| :root { | |
| --bg-primary: #0f1419; | |
| --bg-secondary: #1a2332; | |
| --bg-card: #212d3b; | |
| --accent-coral: #ff6b6b; | |
| --accent-teal: #4ecdc4; | |
| --accent-gold: #ffd93d; | |
| --accent-purple: #a855f7; | |
| --text-primary: #f1f5f9; | |
| --text-secondary: #94a3b8; | |
| --border-color: #334155; | |
| } | |
| ``` | |
| ## Privacy | |
| - Use partial names (e.g., 李X恩) | |
| ## Output | |
| Return ONLY the complete HTML document. No markdown code fences. Just raw HTML starting with <!DOCTYPE html>.""" | |
| class StudentPrompt: | |
| student_name: str | |
| total_questions: int | |
| wrong_count: int | |
| markdown_prompt: str | |
| def build_wrong_block(wa: WrongAnswer) -> str: | |
| """Render one wrong-question block in markdown format.""" | |
| lines = [f"### 第 {wa.question.number} 題"] | |
| if wa.question.text: | |
| lines.append(f"**題目**: {wa.question.text}") | |
| if wa.question.options: | |
| options_str = "\n".join(f"- {o}" for o in wa.question.options) | |
| lines.append(f"**選項**:\n{options_str}") | |
| lines.append(f"**學生作答**: {wa.student_answer if wa.student_answer else '(未作答)'}") | |
| lines.append(f"**正確答案**: {wa.correct_answer}") | |
| return "\n".join(lines) | |
| def build_student_markdown(grid: AnswerGrid, student_index: int) -> StudentPrompt: | |
| """Assemble the full markdown prompt for one student.""" | |
| if student_index < 0 or student_index >= len(grid.students): | |
| raise IndexError(f"student_index {student_index} out of range") | |
| student = grid.students[student_index] | |
| wrongs = diff_student(grid, student_index) | |
| if not wrongs: | |
| markdown = ( | |
| f"## 學生:{student.name}\n" | |
| f"## 總題數:{grid.total_questions},答錯:0\n\n" | |
| f"本次測驗全部答對,請為此學生生成鼓勵性的 HTML 報告," | |
| f"肯定其學習成果並建議延伸學習方向。" | |
| ) | |
| else: | |
| blocks = "\n\n".join(build_wrong_block(w) for w in wrongs) | |
| markdown = ( | |
| f"## 學生:{student.name}\n" | |
| f"## 總題數:{grid.total_questions},答錯:{len(wrongs)}\n\n" | |
| f"{blocks}\n\n" | |
| f"請為此學生生成個人化 HTML 報告,聚焦以上錯題。" | |
| ) | |
| return StudentPrompt( | |
| student_name=student.name, | |
| total_questions=grid.total_questions, | |
| wrong_count=len(wrongs), | |
| markdown_prompt=markdown, | |
| ) | |
| async def generate_student_report( | |
| grid: AnswerGrid, | |
| student_index: int, | |
| model: str = "gpt-5.4", | |
| ) -> str: | |
| """Generate an HTML report for a single student using the markdown prompt.""" | |
| settings = get_settings() | |
| client = AsyncOpenAI(api_key=settings.openai_api_key) | |
| prompt = build_student_markdown(grid, student_index) | |
| prompt_tokens = _estimate_tokens(STUDENT_REPORT_SYSTEM_PROMPT) + _estimate_tokens( | |
| prompt.markdown_prompt | |
| ) | |
| context_limit = MODEL_CONTEXT_LIMITS.get(model, DEFAULT_CONTEXT_LIMIT) | |
| available = context_limit - prompt_tokens - TOKEN_SAFETY_MARGIN | |
| logger.info( | |
| "Student report for %s — wrong=%d, ~%d prompt tokens, %d available (model: %s)", | |
| prompt.student_name, | |
| prompt.wrong_count, | |
| prompt_tokens, | |
| available, | |
| model, | |
| ) | |
| if available < MIN_COMPLETION_TOKENS: | |
| raise ValueError( | |
| f"學生 {prompt.student_name} 的資料過大(約 {prompt_tokens:,} tokens)。" | |
| ) | |
| max_completion = min(DESIRED_COMPLETION_TOKENS, available) | |
| kwargs: dict = { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": STUDENT_REPORT_SYSTEM_PROMPT}, | |
| {"role": "user", "content": prompt.markdown_prompt}, | |
| ], | |
| "max_completion_tokens": max_completion, | |
| "temperature": 0.3, | |
| } | |
| try: | |
| response = await client.chat.completions.create(**kwargs) | |
| except Exception as e: | |
| if "max_completion_tokens" in str(e): | |
| kwargs.pop("max_completion_tokens") | |
| kwargs["max_tokens"] = max_completion | |
| response = await client.chat.completions.create(**kwargs) | |
| else: | |
| raise | |
| choice = response.choices[0] | |
| html_content = choice.message.content | |
| if not html_content: | |
| reason = getattr(choice, "finish_reason", "unknown") | |
| refusal = getattr(choice.message, "refusal", None) | |
| detail = f"finish_reason={reason}" | |
| if refusal: | |
| detail += f", refusal={refusal}" | |
| raise ValueError( | |
| f"Model returned empty content for {prompt.student_name} ({detail})." | |
| ) | |
| return extract_html(html_content) | |