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)