Spaces:
Paused
Paused
File size: 7,348 Bytes
ad5d4b6 bc45e54 30ff567 bc45e54 ad5d4b6 bc45e54 ad5d4b6 bc45e54 30ff567 f002ae5 ad5d4b6 30ff567 f002ae5 bc45e54 ad5d4b6 bc45e54 f002ae5 bc45e54 f002ae5 ad5d4b6 f002ae5 bc45e54 f002ae5 ad5d4b6 f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 bc45e54 f002ae5 bc45e54 ad5d4b6 34b22f3 ad5d4b6 5722fbf bc45e54 f002ae5 ad5d4b6 bc45e54 ad5d4b6 bc45e54 30ff567 ad5d4b6 30ff567 ad5d4b6 f002ae5 30ff567 f002ae5 ad5d4b6 f002ae5 30ff567 f002ae5 30ff567 ad5d4b6 30ff567 f002ae5 bc45e54 f002ae5 ad5d4b6 bc45e54 30ff567 bc45e54 30ff567 bc45e54 ad5d4b6 bc45e54 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | """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)
|