ClassLens-dev / chatkit /backend /app /report_generator.py
Yu Chen
Refactor to add answer grid & assoicate llm parsing
ad5d4b6
Raw
History Blame Contribute Delete
7.35 kB
"""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>."""
@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)