chih.yikuan Claude Opus 4.6 (1M context) commited on
Commit
30ff567
·
1 Parent(s): b17eeed

Fix: Add prompt size check and auto-truncate students to prevent empty LLM responses

Browse files

When exam data is too large for the model's context window, the report
generator now automatically truncates student data (50% → 25%) instead
of failing. Also switches to compact JSON serialization to save ~20-30%
tokens, and dynamically adjusts max_completion_tokens based on available
space.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

chatkit/backend/app/report_generator.py CHANGED
@@ -3,12 +3,54 @@
3
  from __future__ import annotations
4
 
5
  import json
 
6
  import re
7
 
8
  from openai import AsyncOpenAI
9
 
10
  from .config import get_settings
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports.
14
 
@@ -96,9 +138,9 @@ def get_default_prompt() -> str:
96
  def fill_prompt(template: str, questions: dict, student_answers: dict, teacher_answers: dict) -> str:
97
  """Fill the prompt template with actual data."""
98
  return template.format(
99
- questions=json.dumps(questions, ensure_ascii=False, indent=2),
100
- student_answers=json.dumps(student_answers, ensure_ascii=False, indent=2),
101
- teacher_answers=json.dumps(teacher_answers, ensure_ascii=False, indent=2),
102
  )
103
 
104
 
@@ -129,8 +171,52 @@ async def generate_report(
129
  """
130
  settings = get_settings()
131
  client = AsyncOpenAI(api_key=settings.openai_api_key)
132
-
133
- filled_prompt = fill_prompt(prompt_template, questions_data, student_answers_data, teacher_answers_data)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
  kwargs: dict = {
136
  "model": model,
@@ -138,7 +224,7 @@ async def generate_report(
138
  {"role": "system", "content": REPORT_SYSTEM_PROMPT},
139
  {"role": "user", "content": filled_prompt},
140
  ],
141
- "max_completion_tokens": 16384,
142
  "temperature": 0.3,
143
  }
144
 
@@ -148,7 +234,7 @@ async def generate_report(
148
  # Some models may not support max_completion_tokens; fallback
149
  if "max_completion_tokens" in str(e):
150
  kwargs.pop("max_completion_tokens")
151
- kwargs["max_tokens"] = 16384
152
  response = await client.chat.completions.create(**kwargs)
153
  else:
154
  raise
 
3
  from __future__ import annotations
4
 
5
  import json
6
+ import logging
7
  import re
8
 
9
  from openai import AsyncOpenAI
10
 
11
  from .config import get_settings
12
 
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Context window sizes per model (tokens)
16
+ MODEL_CONTEXT_LIMITS: dict[str, int] = {
17
+ "gpt-5.4": 128_000,
18
+ "gpt-5.4-pro": 128_000,
19
+ "gpt-5.3-chat-latest": 128_000,
20
+ "gpt-5-mini": 128_000,
21
+ "gpt-4o": 128_000,
22
+ "gpt-4o-mini": 128_000,
23
+ }
24
+
25
+ DEFAULT_CONTEXT_LIMIT = 128_000
26
+ # Minimum tokens we need for a useful HTML report
27
+ MIN_COMPLETION_TOKENS = 2_000
28
+ DESIRED_COMPLETION_TOKENS = 16_384
29
+ TOKEN_SAFETY_MARGIN = 256
30
+
31
+
32
+ def _estimate_tokens(text: str) -> int:
33
+ """Estimate token count using char-count heuristic.
34
+
35
+ CJK text averages ~1.5-2 tokens/char; English ~4 chars/token.
36
+ Using len//3 is a conservative middle-ground that slightly overestimates,
37
+ which is the safe direction for a pre-flight check.
38
+ """
39
+ return max(1, len(text) // 3)
40
+
41
+
42
+ def _truncate_students(student_answers: dict, max_students: int) -> tuple[dict, int, int]:
43
+ """Truncate student list to fit within token budget.
44
+
45
+ Returns (truncated_data, original_count, kept_count).
46
+ """
47
+ students = student_answers.get("students", [])
48
+ original_count = len(students)
49
+ if original_count <= max_students:
50
+ return student_answers, original_count, original_count
51
+ truncated = {**student_answers, "students": students[:max_students]}
52
+ return truncated, original_count, max_students
53
+
54
 
55
  REPORT_SYSTEM_PROMPT = """You are ClassLens, an AI teaching assistant that creates beautiful, comprehensive exam analysis reports.
56
 
 
138
  def fill_prompt(template: str, questions: dict, student_answers: dict, teacher_answers: dict) -> str:
139
  """Fill the prompt template with actual data."""
140
  return template.format(
141
+ questions=json.dumps(questions, ensure_ascii=False, separators=(",", ":")),
142
+ student_answers=json.dumps(student_answers, ensure_ascii=False, separators=(",", ":")),
143
+ teacher_answers=json.dumps(teacher_answers, ensure_ascii=False, separators=(",", ":")),
144
  )
145
 
146
 
 
171
  """
172
  settings = get_settings()
173
  client = AsyncOpenAI(api_key=settings.openai_api_key)
174
+ context_limit = MODEL_CONTEXT_LIMITS.get(model, DEFAULT_CONTEXT_LIMIT)
175
+
176
+ # --- Try full data first, auto-truncate students if too large ---
177
+ current_student_data = student_answers_data
178
+ total_students = len(student_answers_data.get("students", []))
179
+ truncated = False
180
+
181
+ for attempt in range(3): # full → 50% → 25% of students
182
+ filled_prompt = fill_prompt(prompt_template, questions_data, current_student_data, teacher_answers_data)
183
+ prompt_tokens = _estimate_tokens(REPORT_SYSTEM_PROMPT) + _estimate_tokens(filled_prompt)
184
+ available_for_completion = context_limit - prompt_tokens - TOKEN_SAFETY_MARGIN
185
+
186
+ logger.info(
187
+ "Attempt %d — prompt ~%d tokens, available for completion: %d (model: %s, students: %d)",
188
+ attempt + 1, prompt_tokens, available_for_completion, model,
189
+ len(current_student_data.get("students", [])),
190
+ )
191
+
192
+ if available_for_completion >= MIN_COMPLETION_TOKENS:
193
+ break
194
+
195
+ # Reduce student count: 50% → 25% of original
196
+ fraction = 0.5 if attempt == 0 else 0.25
197
+ max_students = max(1, int(total_students * fraction))
198
+ current_student_data, _, kept = _truncate_students(student_answers_data, max_students)
199
+ truncated = True
200
+ logger.warning(
201
+ "Prompt too large, truncating students: %d → %d", total_students, kept,
202
+ )
203
+ else:
204
+ # Still too large after all truncation attempts
205
+ raise ValueError(
206
+ f"提示內容過大(約 {prompt_tokens:,} tokens),即使只保留 25% 的學生資料仍超出模型限制。"
207
+ f"請減少題目數量或簡化提示。"
208
+ )
209
+
210
+ max_completion = min(DESIRED_COMPLETION_TOKENS, available_for_completion)
211
+
212
+ # Add a note to the prompt if data was truncated
213
+ if truncated:
214
+ kept_count = len(current_student_data.get("students", []))
215
+ truncation_note = (
216
+ f"\n\n⚠️ 注意:因資料量過大,僅包含 {kept_count}/{total_students} 位學生的答案。"
217
+ f"請在報告中註明此為部分學生的抽樣分析。"
218
+ )
219
+ filled_prompt += truncation_note
220
 
221
  kwargs: dict = {
222
  "model": model,
 
224
  {"role": "system", "content": REPORT_SYSTEM_PROMPT},
225
  {"role": "user", "content": filled_prompt},
226
  ],
227
+ "max_completion_tokens": max_completion,
228
  "temperature": 0.3,
229
  }
230
 
 
234
  # Some models may not support max_completion_tokens; fallback
235
  if "max_completion_tokens" in str(e):
236
  kwargs.pop("max_completion_tokens")
237
+ kwargs["max_tokens"] = max_completion
238
  response = await client.chat.completions.create(**kwargs)
239
  else:
240
  raise