"""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 .""" @dataclass(frozen=True) 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)