rairo commited on
Commit
adb5280
·
verified ·
1 Parent(s): 2f314df

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +148 -50
main.py CHANGED
@@ -3,10 +3,10 @@ import json
3
  import logging
4
  import re
5
  import base64
6
- import tempfile
7
  import time
8
  from datetime import datetime
9
  from io import BytesIO
 
10
 
11
  # Third-party imports
12
  from flask import Flask, request, jsonify, Response, stream_with_context
@@ -42,7 +42,7 @@ MODEL_ID = 'gemini-2.5-flash'
42
  # GENERIC GEMINI HELPERS
43
  # ---------------------------------------------------------------------------
44
 
45
- def call_gemini(contents, system_instruction: str | None = None, retries: int = 2):
46
  """
47
  Send a non-streaming request to Gemini and return the response text.
48
  `contents` can be a string, a list of Parts, or a list of Content objects.
@@ -63,6 +63,9 @@ def call_gemini(contents, system_instruction: str | None = None, retries: int =
63
  return response.text
64
  except Exception as e:
65
  if "429" in str(e) or "ResourceExhausted" in str(e):
 
 
 
66
  time.sleep(2 * (attempt + 1))
67
  continue
68
  logging.error(f"Gemini error: {e}")
@@ -71,7 +74,7 @@ def call_gemini(contents, system_instruction: str | None = None, retries: int =
71
  return ""
72
 
73
 
74
- def call_gemini_stream(contents, system_instruction: str | None = None):
75
  """
76
  Yield text chunks from a streaming Gemini request.
77
  Used for the chat endpoint.
@@ -95,12 +98,30 @@ def parse_json_response(text: str) -> dict:
95
  """Strip markdown fences and parse JSON from a Gemini response."""
96
  cleaned = re.sub(r'```json\s*', '', text)
97
  cleaned = re.sub(r'```\s*', '', cleaned)
98
- match = re.search(r'(\{.*\}|\[.*\])', cleaned, re.DOTALL)
99
- json_str = match.group(1) if match else cleaned
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  return json.loads(json_str)
101
 
102
 
103
- def gemini_json(contents, system_instruction: str | None = None, retries: int = 2) -> dict:
104
  """Call Gemini and parse the response as JSON. Returns {} on failure."""
105
  text = call_gemini(contents, system_instruction=system_instruction, retries=retries)
106
  try:
@@ -119,7 +140,7 @@ def gemini_json(contents, system_instruction: str | None = None, retries: int =
119
  # SHARED VALIDATION HELPER
120
  # ---------------------------------------------------------------------------
121
 
122
- def require_fields(data: dict, fields: list) -> str | None:
123
  """Return an error message string if any required field is missing, else None."""
124
  missing = [f for f in fields if not data.get(f)]
125
  if missing:
@@ -130,9 +151,11 @@ def require_fields(data: dict, fields: list) -> str | None:
130
  # 1. CHAT COMPLETION – POST /functions/v1/ai-chat
131
  #
132
  # The client sends:
133
- # messages – array of {role, content} (full history incl. system turns if any)
134
- # systemContext – string (optional) the system prompt / role context from the client
135
- # locale – string (optional, default "en")
 
 
136
  # ---------------------------------------------------------------------------
137
 
138
  @app.route('/functions/v1/ai-chat', methods=['POST'])
@@ -142,13 +165,30 @@ def ai_chat():
142
  if not messages or not isinstance(messages, list):
143
  return jsonify({'error': 'messages array is required'}), 400
144
 
145
- system_context = data.get('systemContext', '') # client-supplied context
146
- locale = data.get('locale', 'en')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
148
  system_instruction = (
149
- f"{system_context}\n\nAlways respond in the language/locale: {locale}."
150
- if system_context
151
- else f"You are a helpful educational assistant. Respond in locale: {locale}."
 
 
152
  )
153
 
154
  # Build a list of Content objects from the messages array
@@ -162,9 +202,13 @@ def ai_chat():
162
  types.Content(role=gemini_role, parts=[types.Part(text=content)])
163
  )
164
 
 
 
 
 
165
  def sse_generator():
166
  try:
167
- for chunk in call_gemini_stream(contents, system_instruction=system_instruction):
168
  payload = json.dumps({
169
  "choices": [{"delta": {"content": chunk}, "finish_reason": None}]
170
  })
@@ -207,35 +251,53 @@ def ai_student_insights():
207
 
208
  system_instruction = (
209
  "You are an educational AI analyst. Analyse student performance data and return "
210
- "a structured JSON report. Be empathetic, data-driven and actionable."
 
 
 
 
 
 
211
  )
212
 
213
  prompt = f"""Generate a student performance insights report as JSON.
214
 
215
  Student ID: {student_id}
216
  School ID: {school_id}
217
- Subjects filter: {subjects if subjects else 'all subjects'}
218
  Term: {term_id}
219
  Include recommendations: {include_recs}
220
 
 
 
 
 
 
 
221
  Return ONLY valid JSON matching this exact structure:
222
  {{
223
  "studentId": "{student_id}",
224
- "summary": "<2-3 sentence overview>",
225
  "subjectBreakdown": [
226
  {{
227
- "subject": "<subject>",
228
- "trend": "<improving|stable|declining>",
229
- "averageScore": <number 0-100>,
230
- "insight": "<specific insight>",
231
- "riskLevel": "<low|medium|high>"
232
  }}
233
  ],
234
  "recommendations": ["<action 1>", "<action 2>"],
235
- "overallRiskLevel": "<low|medium|high>"
236
  }}
237
  """
238
  result = gemini_json(prompt, system_instruction=system_instruction)
 
 
 
 
 
 
239
  return jsonify(result)
240
 
241
  except Exception as e:
@@ -370,7 +432,7 @@ def read_uploaded_pdf(file_field_name: str = 'file'):
370
 
371
  @app.route('/functions/v1/ai-scheme-to-schedule', methods=['POST'])
372
  def ai_scheme_to_schedule():
373
- temp_path = None
374
  try:
375
  system_instruction = (
376
  "You are an expert school timetable planner. Read the scheme of work carefully, "
@@ -494,7 +556,7 @@ Return ONLY valid JSON matching this exact structure:
494
  else:
495
  # Scanned / image-only PDF – send the raw PDF bytes to Gemini Vision
496
  logging.info("PDF: no text layer, using Gemini native PDF vision.")
497
- pdf_part = pdf_bytes_to_inline_part(pdf_bytes)
498
  prompt_part = types.Part(text=schedule_prompt)
499
 
500
  client = get_client()
@@ -522,9 +584,6 @@ Return ONLY valid JSON matching this exact structure:
522
  except Exception as e:
523
  logging.error(f"/ai-scheme-to-schedule error: {e}")
524
  return jsonify({'error': str(e)}), 500
525
- finally:
526
- if temp_path and os.path.exists(temp_path):
527
- os.remove(temp_path)
528
 
529
  # ---------------------------------------------------------------------------
530
  # 5. IMAGE DATA IMPORT (OCR) – POST /functions/v1/ai-image-import
@@ -537,7 +596,6 @@ def ai_image_import():
537
  - JSON body with `imageBase64` (base64-encoded JPEG/PNG string)
538
  - multipart/form-data with a `file` field
539
  """
540
- temp_path = None
541
  try:
542
  import_type = None
543
  school_id = None
@@ -574,11 +632,21 @@ def ai_image_import():
574
  "Extract structured data from the image with high accuracy. Return only JSON."
575
  )
576
 
 
 
 
 
 
 
 
 
 
 
577
  prompt = f"""Extract data from this school document image.
578
 
579
  Import Type: {import_type}
580
  School ID: {school_id}
581
- Additional context: {json.dumps({k: v for k, v in (request.get_json(silent=True) or {}).items() if k not in ['imageBase64', 'importType', 'schoolId']})}
582
 
583
  For importType="{import_type}", return a JSON object matching the documented output schema.
584
  Include confidence scores, warnings for unclear fields, and a summary object.
@@ -610,9 +678,6 @@ Return ONLY valid JSON.
610
  except Exception as e:
611
  logging.error(f"/ai-image-import error: {e}")
612
  return jsonify({'error': str(e)}), 500
613
- finally:
614
- if temp_path and os.path.exists(temp_path):
615
- os.remove(temp_path)
616
 
617
  # ---------------------------------------------------------------------------
618
  # 6. REPORT CARD COMMENT GENERATOR – POST /functions/v1/ai-report-comment
@@ -685,7 +750,9 @@ def ai_parent_message():
685
  system_instruction = (
686
  "You are a school communication specialist. Translate academic jargon into "
687
  "simple, parent-friendly language and optionally translate to the requested language. "
688
- "Return only JSON."
 
 
689
  )
690
 
691
  prompt = f"""Simplify and translate a school message for a parent.
@@ -695,16 +762,28 @@ Child's name: {child_name}
695
  Context: {context} (report_card | attendance | behaviour | general)
696
  Target language: {target_language}
697
 
 
 
 
 
 
 
698
  Return ONLY valid JSON:
699
  {{
700
  "originalMessage": "{message}",
701
- "simplifiedMessage": "<parent-friendly version in English>",
702
  "translatedMessage": "<translated version if target_language != 'en', else same as simplifiedMessage>",
703
  "language": "{target_language}",
704
- "suggestedActions": ["<actionable tip for parent>"]
705
  }}
706
  """
707
  result = gemini_json(prompt, system_instruction=system_instruction)
 
 
 
 
 
 
708
  return jsonify(result)
709
 
710
  except Exception as e:
@@ -733,7 +812,14 @@ def ai_attendance_analysis():
733
 
734
  system_instruction = (
735
  "You are an educational data analyst specialising in attendance patterns. "
736
- "Identify at-risk students and actionable patterns. Return only JSON."
 
 
 
 
 
 
 
737
  )
738
 
739
  prompt = f"""Analyse student attendance data and detect patterns.
@@ -743,33 +829,45 @@ Scope: {scope} (school | class | student)
743
  Scope ID: {scope_id or 'N/A'}
744
  Date Range: {json.dumps(date_range) if date_range else 'current term'}
745
 
 
 
 
 
 
 
746
  Return ONLY valid JSON:
747
  {{
748
  "scope": "{scope}",
749
  "scopeId": "{scope_id or ''}",
750
  "period": {json.dumps(date_range) if date_range else '{{"from": "term start", "to": "term end"}}'},
751
- "overallRate": <percentage>,
752
  "patterns": [
753
  {{
754
  "type": "<day_of_week|chronic_absence|weather_related|etc>",
755
- "detail": "<description>",
756
  "severity": "<low|medium|high>",
757
- "studentIds": ["<id>"]
758
  }}
759
  ],
760
  "atRiskStudents": [
761
  {{
762
- "studentId": "<id>",
763
- "studentName": "<name>",
764
- "absenceRate": <percentage>,
765
  "trend": "<improving|stable|worsening>",
766
  "recommendation": "<action>"
767
  }}
768
  ],
769
- "summary": "<overall summary sentence>"
770
  }}
771
  """
772
  result = gemini_json(prompt, system_instruction=system_instruction)
 
 
 
 
 
 
773
  return jsonify(result)
774
 
775
  except Exception as e:
@@ -848,10 +946,10 @@ def ai_homework_help():
848
  if err:
849
  return jsonify({'error': err}), 400
850
 
851
- question = data['question']
852
- subject = data['subject']
853
- grade = data['grade']
854
- student_id = data['studentId']
855
  show_working = data.get('showWorking', True)
856
 
857
  system_instruction = (
 
3
  import logging
4
  import re
5
  import base64
 
6
  import time
7
  from datetime import datetime
8
  from io import BytesIO
9
+ from typing import Optional, Union
10
 
11
  # Third-party imports
12
  from flask import Flask, request, jsonify, Response, stream_with_context
 
42
  # GENERIC GEMINI HELPERS
43
  # ---------------------------------------------------------------------------
44
 
45
+ def call_gemini(contents, system_instruction: Optional[str] = None, retries: int = 2):
46
  """
47
  Send a non-streaming request to Gemini and return the response text.
48
  `contents` can be a string, a list of Parts, or a list of Content objects.
 
63
  return response.text
64
  except Exception as e:
65
  if "429" in str(e) or "ResourceExhausted" in str(e):
66
+ if attempt == retries:
67
+ logging.error(f"Gemini rate limit exceeded after {retries + 1} attempts.")
68
+ raise
69
  time.sleep(2 * (attempt + 1))
70
  continue
71
  logging.error(f"Gemini error: {e}")
 
74
  return ""
75
 
76
 
77
+ def call_gemini_stream(contents, system_instruction: Optional[str] = None):
78
  """
79
  Yield text chunks from a streaming Gemini request.
80
  Used for the chat endpoint.
 
98
  """Strip markdown fences and parse JSON from a Gemini response."""
99
  cleaned = re.sub(r'```json\s*', '', text)
100
  cleaned = re.sub(r'```\s*', '', cleaned)
101
+ # Use non-greedy match and DOTALL to handle nested JSON correctly
102
+ match = re.search(r'(\{.*?\}|\[.*?\])', cleaned, re.DOTALL)
103
+ # If non-greedy misses complex nested structures, fall back to full cleaned string
104
+ json_str = cleaned.strip()
105
+ if match:
106
+ # Try to find the outermost balanced structure
107
+ for i, ch in enumerate(cleaned):
108
+ if ch in ('{', '['):
109
+ opener = ch
110
+ closer = '}' if ch == '{' else ']'
111
+ depth = 0
112
+ for j, c in enumerate(cleaned[i:], i):
113
+ if c == opener:
114
+ depth += 1
115
+ elif c == closer:
116
+ depth -= 1
117
+ if depth == 0:
118
+ json_str = cleaned[i:j + 1]
119
+ break
120
+ break
121
  return json.loads(json_str)
122
 
123
 
124
+ def gemini_json(contents, system_instruction: Optional[str] = None, retries: int = 2) -> dict:
125
  """Call Gemini and parse the response as JSON. Returns {} on failure."""
126
  text = call_gemini(contents, system_instruction=system_instruction, retries=retries)
127
  try:
 
140
  # SHARED VALIDATION HELPER
141
  # ---------------------------------------------------------------------------
142
 
143
+ def require_fields(data: dict, fields: list) -> Optional[str]:
144
  """Return an error message string if any required field is missing, else None."""
145
  missing = [f for f in fields if not data.get(f)]
146
  if missing:
 
151
  # 1. CHAT COMPLETION – POST /functions/v1/ai-chat
152
  #
153
  # The client sends:
154
+ # messages – array of {role, content} (full conversation history)
155
+ # userRole – string Required per spec. One of: admin, teacher, student, parent
156
+ # schoolId – string Required per spec. School context identifier
157
+ # locale – string Optional. Language preference (default: "en")
158
+ # systemContext – string Deprecated legacy field. Ignored when userRole is present.
159
  # ---------------------------------------------------------------------------
160
 
161
  @app.route('/functions/v1/ai-chat', methods=['POST'])
 
165
  if not messages or not isinstance(messages, list):
166
  return jsonify({'error': 'messages array is required'}), 400
167
 
168
+ # Per spec, userRole and schoolId are required fields
169
+ err = require_fields(data, ['userRole', 'schoolId'])
170
+ if err:
171
+ return jsonify({'error': err}), 400
172
+
173
+ user_role = data['userRole']
174
+ school_id = data['schoolId']
175
+ locale = data.get('locale', 'en')
176
+
177
+ # Role-aware system prompts injected server-side per spec.
178
+ # Never invent student/school data not explicitly provided in the conversation.
179
+ role_context = {
180
+ 'admin': "You are a helpful school administration assistant. You help with school management, reports, and oversight tasks.",
181
+ 'teacher': "You are a helpful teaching assistant. You help with lesson planning, student support, and classroom management.",
182
+ 'student': "You are a friendly and encouraging student tutor. Guide students to understand concepts without giving direct answers.",
183
+ 'parent': "You are a helpful parent liaison. Explain school information clearly and in plain language.",
184
+ }.get(user_role, "You are a helpful educational assistant.")
185
 
186
  system_instruction = (
187
+ f"{role_context} "
188
+ f"You are operating in the context of school ID: {school_id}. "
189
+ f"IMPORTANT: Never invent, hallucinate, or fabricate student names, IDs, scores, or any school data not explicitly provided in the conversation. "
190
+ f"If you do not have the data needed to answer, say so clearly. "
191
+ f"Always respond in the language/locale: {locale}."
192
  )
193
 
194
  # Build a list of Content objects from the messages array
 
202
  types.Content(role=gemini_role, parts=[types.Part(text=content)])
203
  )
204
 
205
+ # Capture for closure
206
+ _contents = contents
207
+ _system_instruction = system_instruction
208
+
209
  def sse_generator():
210
  try:
211
+ for chunk in call_gemini_stream(_contents, system_instruction=_system_instruction):
212
  payload = json.dumps({
213
  "choices": [{"delta": {"content": chunk}, "finish_reason": None}]
214
  })
 
251
 
252
  system_instruction = (
253
  "You are an educational AI analyst. Analyse student performance data and return "
254
+ "a structured JSON report. Be empathetic, data-driven and actionable. "
255
+ "CRITICAL: You do not have access to a database. Never invent, fabricate, or "
256
+ "hallucinate student names, database IDs, scores, or subject data. "
257
+ "Use only data explicitly provided in this prompt. "
258
+ "In the summary field, refer to the student by their studentId only — never invent a name. "
259
+ "In subjectBreakdown, only include subjects explicitly listed in the subjects filter; "
260
+ "if no filter is given, state that subject-level data was not provided."
261
  )
262
 
263
  prompt = f"""Generate a student performance insights report as JSON.
264
 
265
  Student ID: {student_id}
266
  School ID: {school_id}
267
+ Subjects filter: {subjects if subjects else 'not specified — do not invent subject data'}
268
  Term: {term_id}
269
  Include recommendations: {include_recs}
270
 
271
+ IMPORTANT RULES:
272
+ - Use the studentId "{student_id}" exactly as provided. Do not invent a student name.
273
+ - Only include subjectBreakdown entries for subjects explicitly listed above.
274
+ - If no real performance data is available, set averageScore to null and explain in the insight field.
275
+ - Do not fabricate trends, scores, or risk levels.
276
+
277
  Return ONLY valid JSON matching this exact structure:
278
  {{
279
  "studentId": "{student_id}",
280
+ "summary": "<2-3 sentence overview referring to student by ID, not an invented name>",
281
  "subjectBreakdown": [
282
  {{
283
+ "subject": "<subject from the provided filter only>",
284
+ "trend": "<improving|stable|declining|unknown>",
285
+ "averageScore": <number 0-100 or null if not available>,
286
+ "insight": "<specific insight based only on provided data>",
287
+ "riskLevel": "<low|medium|high|unknown>"
288
  }}
289
  ],
290
  "recommendations": ["<action 1>", "<action 2>"],
291
+ "overallRiskLevel": "<low|medium|high|unknown>"
292
  }}
293
  """
294
  result = gemini_json(prompt, system_instruction=system_instruction)
295
+
296
+ # Sanitise: ensure studentId in output matches what was requested,
297
+ # not a hallucinated value
298
+ if result:
299
+ result['studentId'] = student_id
300
+
301
  return jsonify(result)
302
 
303
  except Exception as e:
 
432
 
433
  @app.route('/functions/v1/ai-scheme-to-schedule', methods=['POST'])
434
  def ai_scheme_to_schedule():
435
+ # FIX: temp_path was referenced in finally but never initialised — set to None
436
  try:
437
  system_instruction = (
438
  "You are an expert school timetable planner. Read the scheme of work carefully, "
 
556
  else:
557
  # Scanned / image-only PDF – send the raw PDF bytes to Gemini Vision
558
  logging.info("PDF: no text layer, using Gemini native PDF vision.")
559
+ pdf_part = pdf_bytes_to_inline_part(pdf_bytes)
560
  prompt_part = types.Part(text=schedule_prompt)
561
 
562
  client = get_client()
 
584
  except Exception as e:
585
  logging.error(f"/ai-scheme-to-schedule error: {e}")
586
  return jsonify({'error': str(e)}), 500
 
 
 
587
 
588
  # ---------------------------------------------------------------------------
589
  # 5. IMAGE DATA IMPORT (OCR) – POST /functions/v1/ai-image-import
 
596
  - JSON body with `imageBase64` (base64-encoded JPEG/PNG string)
597
  - multipart/form-data with a `file` field
598
  """
 
599
  try:
600
  import_type = None
601
  school_id = None
 
632
  "Extract structured data from the image with high accuracy. Return only JSON."
633
  )
634
 
635
+ # FIX: the original code called request.get_json() inside the multipart branch,
636
+ # which always returns None/empty for multipart requests. Use request.form instead
637
+ # to safely build extra_context without risking None dereference.
638
+ if request.content_type and 'multipart/form-data' in request.content_type:
639
+ extra_context = {k: v for k, v in request.form.items()
640
+ if k not in ['importType', 'schoolId']}
641
+ else:
642
+ extra_context = {k: v for k, v in (request.get_json(silent=True) or {}).items()
643
+ if k not in ['imageBase64', 'importType', 'schoolId']}
644
+
645
  prompt = f"""Extract data from this school document image.
646
 
647
  Import Type: {import_type}
648
  School ID: {school_id}
649
+ Additional context: {json.dumps(extra_context)}
650
 
651
  For importType="{import_type}", return a JSON object matching the documented output schema.
652
  Include confidence scores, warnings for unclear fields, and a summary object.
 
678
  except Exception as e:
679
  logging.error(f"/ai-image-import error: {e}")
680
  return jsonify({'error': str(e)}), 500
 
 
 
681
 
682
  # ---------------------------------------------------------------------------
683
  # 6. REPORT CARD COMMENT GENERATOR – POST /functions/v1/ai-report-comment
 
750
  system_instruction = (
751
  "You are a school communication specialist. Translate academic jargon into "
752
  "simple, parent-friendly language and optionally translate to the requested language. "
753
+ "CRITICAL: Only use information explicitly present in the provided message. "
754
+ "Never invent subject names, scores, database IDs, or any data not in the message. "
755
+ "Refer to the child only by the name provided. Return only JSON."
756
  )
757
 
758
  prompt = f"""Simplify and translate a school message for a parent.
 
762
  Context: {context} (report_card | attendance | behaviour | general)
763
  Target language: {target_language}
764
 
765
+ IMPORTANT RULES:
766
+ - Only reference subjects, scores, and details that appear verbatim in the original message above.
767
+ - Do not invent or infer subject names or data not present in the message.
768
+ - Refer to the child only as "{child_name}" — never substitute a different name.
769
+ - Never include database IDs in any field.
770
+
771
  Return ONLY valid JSON:
772
  {{
773
  "originalMessage": "{message}",
774
+ "simplifiedMessage": "<parent-friendly version in English using only data from the original message>",
775
  "translatedMessage": "<translated version if target_language != 'en', else same as simplifiedMessage>",
776
  "language": "{target_language}",
777
+ "suggestedActions": ["<actionable tip for parent based only on the message content>"]
778
  }}
779
  """
780
  result = gemini_json(prompt, system_instruction=system_instruction)
781
+
782
+ # Sanitise: pin fields that must match the request inputs exactly
783
+ if result:
784
+ result['originalMessage'] = message
785
+ result['language'] = target_language
786
+
787
  return jsonify(result)
788
 
789
  except Exception as e:
 
812
 
813
  system_instruction = (
814
  "You are an educational data analyst specialising in attendance patterns. "
815
+ "Identify at-risk students and actionable patterns. "
816
+ "CRITICAL: You do not have access to a database. Never invent, fabricate, or "
817
+ "hallucinate student names, student IDs, percentages, or attendance records. "
818
+ "Only use data explicitly provided in this prompt. "
819
+ "If real attendance data is not provided, set overallRate to null, return empty "
820
+ "arrays for patterns and atRiskStudents, and explain in the summary that no data was supplied. "
821
+ "Never expose raw database document IDs in summary, detail, or recommendation text fields. "
822
+ "Return only JSON."
823
  )
824
 
825
  prompt = f"""Analyse student attendance data and detect patterns.
 
829
  Scope ID: {scope_id or 'N/A'}
830
  Date Range: {json.dumps(date_range) if date_range else 'current term'}
831
 
832
+ IMPORTANT RULES:
833
+ - Do not invent student names or IDs. Only use names/IDs explicitly provided above.
834
+ - Do not fabricate attendance percentages or absence counts.
835
+ - If attendance records are not included in this prompt, return empty patterns and atRiskStudents arrays.
836
+ - Never expose raw database document IDs in any text field (summary, detail, recommendation).
837
+
838
  Return ONLY valid JSON:
839
  {{
840
  "scope": "{scope}",
841
  "scopeId": "{scope_id or ''}",
842
  "period": {json.dumps(date_range) if date_range else '{{"from": "term start", "to": "term end"}}'},
843
+ "overallRate": <percentage or null if no data provided>,
844
  "patterns": [
845
  {{
846
  "type": "<day_of_week|chronic_absence|weather_related|etc>",
847
+ "detail": "<description based only on provided data>",
848
  "severity": "<low|medium|high>",
849
+ "studentIds": ["<id — only if explicitly provided>"]
850
  }}
851
  ],
852
  "atRiskStudents": [
853
  {{
854
+ "studentId": "<id — only if explicitly provided>",
855
+ "studentName": "<name — only if explicitly provided, never invented>",
856
+ "absenceRate": <percentage — only if provided>,
857
  "trend": "<improving|stable|worsening>",
858
  "recommendation": "<action>"
859
  }}
860
  ],
861
+ "summary": "<overall summary based only on provided data, no invented names or IDs>"
862
  }}
863
  """
864
  result = gemini_json(prompt, system_instruction=system_instruction)
865
+
866
+ # Sanitise: pin scope fields to request values so they cannot be hallucinated
867
+ if result:
868
+ result['scope'] = scope
869
+ result['scopeId'] = scope_id or ''
870
+
871
  return jsonify(result)
872
 
873
  except Exception as e:
 
946
  if err:
947
  return jsonify({'error': err}), 400
948
 
949
+ question = data['question']
950
+ subject = data['subject']
951
+ grade = data['grade']
952
+ student_id = data['studentId']
953
  show_working = data.get('showWorking', True)
954
 
955
  system_instruction = (