Spaces:
Running
Running
Update main.py
Browse files
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
|
| 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
|
| 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 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return json.loads(json_str)
|
| 101 |
|
| 102 |
|
| 103 |
-
def gemini_json(contents, system_instruction: str
|
| 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
|
| 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
|
| 134 |
-
#
|
| 135 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
system_instruction = (
|
| 149 |
-
f"{
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
| 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(
|
| 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 '
|
| 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
|
| 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
|
| 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(
|
| 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 |
-
"
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 852 |
-
subject
|
| 853 |
-
grade
|
| 854 |
-
student_id
|
| 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 = (
|