Spaces:
Build error
Build error
Update app.py
Browse files
app.py
CHANGED
@@ -1,330 +1,840 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
|
4 |
-
import
|
5 |
-
import
|
6 |
-
|
7 |
-
from
|
8 |
-
import
|
9 |
-
import
|
10 |
-
import
|
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 |
try:
|
76 |
-
|
77 |
-
|
78 |
-
st.session_state.models_loaded = True
|
79 |
-
st.session_state.model_manager = model_manager
|
80 |
-
return True
|
81 |
-
return False
|
82 |
-
except Exception as e:
|
83 |
-
st.error(f"Model initialization failed: {str(e)}")
|
84 |
-
return False
|
85 |
-
|
86 |
-
def main():
|
87 |
-
# Title and description
|
88 |
-
st.markdown('<h1 class="main-header">🏥 Rural Diagnostic Assistant</h1>', unsafe_allow_html=True)
|
89 |
-
st.markdown("""
|
90 |
-
**AI-Powered Medical Imaging Analysis for Rural Healthcare**
|
91 |
-
*Accurate detection of TB, Pneumonia, and pregnancy risks using state-of-the-art AI models*
|
92 |
-
""")
|
93 |
-
|
94 |
-
# Sidebar optimized for Hugging Face
|
95 |
-
with st.sidebar:
|
96 |
-
st.header("⚙️ Configuration")
|
97 |
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
|
104 |
-
#
|
105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
106 |
|
107 |
-
|
108 |
-
|
109 |
-
patient_age = st.slider("Age", 0, 120, 35)
|
110 |
-
patient_gender = st.selectbox("Gender", ["Male", "Female", "Other"])
|
111 |
|
112 |
-
#
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
st.rerun()
|
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 |
-
sample_image = Image.open('./assets/sample_xray.jpg')
|
150 |
-
analyze_image(sample_image, "Chest X-Ray (TB/Pneumonia)", "Sample Patient")
|
151 |
-
with col2a:
|
152 |
-
if st.button("Sample Ultrasound", use_container_width=True):
|
153 |
-
sample_image = Image.open('./assets/sample_ultrasound.jpg')
|
154 |
-
analyze_image(sample_image, "Ultrasound (Pregnancy Risk)", "Sample Patient")
|
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 |
-
'patient_id': patient_id,
|
213 |
-
'analysis_type': analysis_type,
|
214 |
-
'results': results,
|
215 |
-
'image': image.copy()
|
216 |
-
}
|
217 |
-
st.session_state.diagnosis_history.append(diagnosis_entry)
|
218 |
-
|
219 |
-
st.success("✅ Analysis complete!")
|
220 |
-
st.rerun()
|
221 |
-
|
222 |
-
except Exception as e:
|
223 |
-
st.error(f"❌ Analysis failed: {str(e)}")
|
224 |
-
# Fallback to simulated results for demo
|
225 |
-
results = get_fallback_results(analysis_type)
|
226 |
-
diagnosis_entry = {
|
227 |
-
'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
228 |
-
'patient_id': patient_id,
|
229 |
-
'analysis_type': analysis_type,
|
230 |
-
'results': results,
|
231 |
-
'image': image.copy()
|
232 |
-
}
|
233 |
-
st.session_state.diagnosis_history.append(diagnosis_entry)
|
234 |
-
st.rerun()
|
235 |
-
|
236 |
-
def display_results(results, analysis_type):
|
237 |
-
"""Display analysis results with medical UI"""
|
238 |
-
risk_class = results['risk_level'].lower().replace(" ", "-")
|
239 |
-
|
240 |
-
st.markdown(f'<div class="diagnosis-card {risk_class}">', unsafe_allow_html=True)
|
241 |
-
|
242 |
-
# Header with condition and confidence
|
243 |
-
col1, col2 = st.columns([3, 1])
|
244 |
-
|
245 |
-
with col1:
|
246 |
-
st.subheader(results['condition'])
|
247 |
-
st.write(f"**Analysis Type:** {analysis_type}")
|
248 |
-
|
249 |
-
with col2:
|
250 |
-
# Confidence indicator
|
251 |
-
confidence_color = "#00C851" if results['confidence'] > 85 else "#ffaa00" if results['confidence'] > 70 else "#ff4444"
|
252 |
-
st.markdown(f"""
|
253 |
-
<div style="text-align: center;">
|
254 |
-
<div style="font-size: 2rem; color: {confidence_color}; font-weight: bold;">
|
255 |
-
{results['confidence']:.1f}%
|
256 |
-
</div>
|
257 |
-
<div style="font-size: 0.9rem;">Confidence</div>
|
258 |
-
</div>
|
259 |
-
""", unsafe_allow_html=True)
|
260 |
-
|
261 |
-
# Risk level badge
|
262 |
-
risk_color = {"High": "#ff4444", "Medium": "#ffaa00", "Low": "#00C851"}
|
263 |
-
st.markdown(f"""
|
264 |
-
<div style="background-color: {risk_color[results['risk_level']]}; color: white;
|
265 |
-
padding: 5px 15px; border-radius: 20px; display: inline-block;
|
266 |
-
font-weight: bold; margin-bottom: 15px;">
|
267 |
-
{results['risk_level']} Risk
|
268 |
-
</div>
|
269 |
-
""", unsafe_allow_html=True)
|
270 |
-
|
271 |
-
# Findings
|
272 |
-
st.write("**🔍 Clinical Findings:**")
|
273 |
-
st.write(results['findings'])
|
274 |
-
|
275 |
-
# Recommendations
|
276 |
-
st.write("**💡 Recommendations:**")
|
277 |
-
for i, rec in enumerate(results['recommendations'], 1):
|
278 |
-
st.write(f"{i}. {rec}")
|
279 |
-
|
280 |
-
# Confidence visualization
|
281 |
-
if 'detailed_scores' in results and results['detailed_scores']:
|
282 |
-
st.write("**📊 Detailed Scores:**")
|
283 |
-
scores = results['detailed_scores']
|
284 |
-
for condition, score in scores.items():
|
285 |
-
st.progress(score/100, text=f"{condition.replace('_', ' ').title()}: {score:.1f}%")
|
286 |
-
|
287 |
-
st.markdown('</div>', unsafe_allow_html=True)
|
288 |
-
|
289 |
-
def get_fallback_results(analysis_type):
|
290 |
-
"""Fallback results for demo purposes"""
|
291 |
-
if "Chest" in analysis_type:
|
292 |
-
return {
|
293 |
-
'condition': 'Pneumonia Detected',
|
294 |
-
'confidence': 92.5,
|
295 |
-
'risk_level': 'High',
|
296 |
-
'recommendations': [
|
297 |
-
'Consult with pulmonologist urgently',
|
298 |
-
'Start antibiotic treatment',
|
299 |
-
'Chest CT scan for confirmation',
|
300 |
-
'Monitor oxygen saturation'
|
301 |
-
],
|
302 |
-
'findings': 'Consolidation observed in right lower lobe consistent with bacterial pneumonia',
|
303 |
-
'detailed_scores': {'pneumonia': 92.5, 'tuberculosis': 15.2, 'normal': 7.5}
|
304 |
-
}
|
305 |
-
else:
|
306 |
-
return {
|
307 |
-
'condition': 'Normal Pregnancy',
|
308 |
-
'confidence': 94.2,
|
309 |
-
'risk_level': 'Low',
|
310 |
-
'recommendations': [
|
311 |
-
'Continue routine prenatal care',
|
312 |
-
'Next ultrasound in 4 weeks',
|
313 |
-
'Standard prenatal vitamin regimen',
|
314 |
-
'Monitor fetal movements'
|
315 |
-
],
|
316 |
-
'findings': 'Normal fetal development at 20 weeks gestation, appropriate biometric measurements',
|
317 |
-
'detailed_scores': {'normal': 94.2, 'ectopic_risk': 3.1, 'placental_issue': 2.7}
|
318 |
-
}
|
319 |
-
|
320 |
-
# Footer for Hugging Face
|
321 |
-
st.markdown("---")
|
322 |
-
st.markdown("""
|
323 |
-
<div style='text-align: center'>
|
324 |
-
<p>Rural Diagnostic Assistant - Deployed on Hugging Face Spaces |
|
325 |
-
<a href='https://huggingface.co/spaces' target='_blank'>Learn More</a></p>
|
326 |
-
</div>
|
327 |
-
""", unsafe_allow_html=True)
|
328 |
|
329 |
-
|
330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
|
2 |
+
|
3 |
+
# app/main.py
|
4 |
+
from fastapi import FastAPI, Depends, HTTPException, status
|
5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
6 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
7 |
+
from contextlib import asynccontextmanager
|
8 |
+
import uvicorn
|
9 |
+
from app.database import engine, Base
|
10 |
+
from app.routers import auth, sync, content, analytics, grading
|
11 |
+
from app.core.config import settings
|
12 |
+
from app.ml.model_manager import ModelManager
|
13 |
+
import logging
|
14 |
+
|
15 |
+
# Configure logging
|
16 |
+
logging.basicConfig(level=logging.INFO)
|
17 |
+
logger = logging.getLogger(__name__)
|
18 |
+
|
19 |
+
# Initialize ML models on startup
|
20 |
+
@asynccontextmanager
|
21 |
+
async def lifespan(app: FastAPI):
|
22 |
+
# Startup
|
23 |
+
logger.info("Starting AI Tutor Backend...")
|
24 |
+
|
25 |
+
# Create database tables
|
26 |
+
Base.metadata.create_all(bind=engine)
|
27 |
+
|
28 |
+
# Initialize ML models
|
29 |
+
model_manager = ModelManager()
|
30 |
+
await model_manager.load_models()
|
31 |
+
|
32 |
+
# Store model manager in app state
|
33 |
+
app.state.model_manager = model_manager
|
34 |
+
|
35 |
+
logger.info("Backend startup complete")
|
36 |
+
yield
|
37 |
+
|
38 |
+
# Shutdown
|
39 |
+
logger.info("Shutting down AI Tutor Backend...")
|
40 |
+
|
41 |
+
app = FastAPI(
|
42 |
+
title="AI Tutor Backend",
|
43 |
+
description="Adaptive Multilingual Offline-First AI Tutor API",
|
44 |
+
version="1.0.0",
|
45 |
+
lifespan=lifespan
|
46 |
)
|
47 |
|
48 |
+
# CORS middleware
|
49 |
+
app.add_middleware(
|
50 |
+
CORSMiddleware,
|
51 |
+
allow_origins=settings.ALLOWED_HOSTS,
|
52 |
+
allow_credentials=True,
|
53 |
+
allow_methods=["*"],
|
54 |
+
allow_headers=["*"],
|
55 |
+
)
|
56 |
+
|
57 |
+
# Include routers
|
58 |
+
app.include_router(auth.router, prefix="/api/v1/auth", tags=["authentication"])
|
59 |
+
app.include_router(sync.router, prefix="/api/v1/sync", tags=["synchronization"])
|
60 |
+
app.include_router(content.router, prefix="/api/v1/content", tags=["content"])
|
61 |
+
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
62 |
+
app.include_router(grading.router, prefix="/api/v1/grading", tags=["grading"])
|
63 |
+
|
64 |
+
@app.get("/")
|
65 |
+
async def root():
|
66 |
+
return {"message": "AI Tutor Backend API", "version": "1.0.0"}
|
67 |
+
|
68 |
+
@app.get("/health")
|
69 |
+
async def health_check():
|
70 |
+
return {"status": "healthy", "version": "1.0.0"}
|
71 |
+
|
72 |
+
if __name__ == "__main__":
|
73 |
+
uvicorn.run(
|
74 |
+
"app.main:app",
|
75 |
+
host=settings.HOST,
|
76 |
+
port=settings.PORT,
|
77 |
+
reload=settings.DEBUG,
|
78 |
+
log_level="info"
|
79 |
+
)
|
80 |
+
|
81 |
+
# app/core/config.py
|
82 |
+
from pydantic_settings import BaseSettings
|
83 |
+
from typing import List
|
84 |
+
|
85 |
+
class Settings(BaseSettings):
|
86 |
+
# Basic settings
|
87 |
+
APP_NAME: str = "AI Tutor Backend"
|
88 |
+
DEBUG: bool = True
|
89 |
+
HOST: str = "0.0.0.0"
|
90 |
+
PORT: int = 8000
|
91 |
+
|
92 |
+
# Database
|
93 |
+
DATABASE_URL: str = "postgresql://postgres:password@localhost/ai_tutor"
|
94 |
+
|
95 |
+
# Security
|
96 |
+
SECRET_KEY: str = "your-secret-key-change-in-production"
|
97 |
+
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 * 24 * 60 # 30 days
|
98 |
+
|
99 |
+
# CORS
|
100 |
+
ALLOWED_HOSTS: List[str] = ["*"]
|
101 |
+
|
102 |
+
# Redis (for caching and task queue)
|
103 |
+
REDIS_URL: str = "redis://localhost:6379"
|
104 |
+
|
105 |
+
# ML Models
|
106 |
+
MODEL_PATH: str = "./models"
|
107 |
+
HUGGINGFACE_CACHE: str = "./cache"
|
108 |
+
|
109 |
+
class Config:
|
110 |
+
env_file = ".env"
|
111 |
+
|
112 |
+
settings = Settings()
|
113 |
+
|
114 |
+
# app/database.py
|
115 |
+
from sqlalchemy import create_engine
|
116 |
+
from sqlalchemy.ext.declarative import declarative_base
|
117 |
+
from sqlalchemy.orm import sessionmaker
|
118 |
+
from app.core.config import settings
|
119 |
+
|
120 |
+
engine = create_engine(
|
121 |
+
settings.DATABASE_URL,
|
122 |
+
pool_pre_ping=True,
|
123 |
+
pool_recycle=300,
|
124 |
+
)
|
125 |
+
|
126 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
127 |
+
Base = declarative_base()
|
128 |
+
|
129 |
+
def get_db():
|
130 |
+
db = SessionLocal()
|
131 |
+
try:
|
132 |
+
yield db
|
133 |
+
finally:
|
134 |
+
db.close()
|
135 |
+
|
136 |
+
# app/models/database.py
|
137 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, Text, Boolean, ForeignKey, JSON
|
138 |
+
from sqlalchemy.orm import relationship
|
139 |
+
from sqlalchemy.sql import func
|
140 |
+
from app.database import Base
|
141 |
+
import uuid
|
142 |
+
|
143 |
+
class Student(Base):
|
144 |
+
__tablename__ = "students"
|
145 |
+
|
146 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
147 |
+
name = Column(String, nullable=False)
|
148 |
+
grade = Column(Integer, nullable=False)
|
149 |
+
preferred_language = Column(String, nullable=False, default="urdu")
|
150 |
+
skill_mastery = Column(JSON, default=dict)
|
151 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
152 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
153 |
+
device_id = Column(String)
|
154 |
+
|
155 |
+
# Relationships
|
156 |
+
learning_sessions = relationship("LearningSession", back_populates="student")
|
157 |
+
student_responses = relationship("StudentResponse", back_populates="student")
|
158 |
+
|
159 |
+
class Question(Base):
|
160 |
+
__tablename__ = "questions"
|
161 |
+
|
162 |
+
id = Column(String, primary_key=True)
|
163 |
+
subject = Column(String, nullable=False)
|
164 |
+
topic = Column(String, nullable=False)
|
165 |
+
grade = Column(Integer, nullable=False)
|
166 |
+
skill_tags = Column(JSON, default=list)
|
167 |
+
difficulty_estimate = Column(Float, nullable=False)
|
168 |
+
prompt = Column(JSON, nullable=False) # Multi-language prompts
|
169 |
+
options = Column(JSON, default=list)
|
170 |
+
solution_steps = Column(JSON, default=list)
|
171 |
+
hints = Column(JSON, default=dict)
|
172 |
+
answer_patterns = Column(JSON, nullable=False)
|
173 |
+
explanation = Column(JSON, default=dict)
|
174 |
+
content_version = Column(String, default="1.0")
|
175 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
176 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
177 |
+
|
178 |
+
# Relationships
|
179 |
+
student_responses = relationship("StudentResponse", back_populates="question")
|
180 |
+
|
181 |
+
class LearningSession(Base):
|
182 |
+
__tablename__ = "learning_sessions"
|
183 |
+
|
184 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
185 |
+
student_id = Column(String, ForeignKey("students.id"), nullable=False)
|
186 |
+
subject = Column(String, nullable=False)
|
187 |
+
start_time = Column(DateTime(timezone=True), nullable=False)
|
188 |
+
end_time = Column(DateTime(timezone=True))
|
189 |
+
skills_updated = Column(JSON, default=dict)
|
190 |
+
synced_at = Column(DateTime(timezone=True))
|
191 |
+
device_id = Column(String)
|
192 |
+
|
193 |
+
# Relationships
|
194 |
+
student = relationship("Student", back_populates="learning_sessions")
|
195 |
+
responses = relationship("StudentResponse", back_populates="session")
|
196 |
+
|
197 |
+
class StudentResponse(Base):
|
198 |
+
__tablename__ = "student_responses"
|
199 |
+
|
200 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
201 |
+
student_id = Column(String, ForeignKey("students.id"), nullable=False)
|
202 |
+
question_id = Column(String, ForeignKey("questions.id"), nullable=False)
|
203 |
+
session_id = Column(String, ForeignKey("learning_sessions.id"), nullable=False)
|
204 |
+
response = Column(Text, nullable=False)
|
205 |
+
response_type = Column(String, nullable=False) # 'voice' or 'text'
|
206 |
+
is_correct = Column(Boolean, nullable=False)
|
207 |
+
partial_credit = Column(Float, nullable=False, default=0.0)
|
208 |
+
confidence = Column(Float, nullable=False, default=0.0)
|
209 |
+
timestamp = Column(DateTime(timezone=True), nullable=False)
|
210 |
+
synced_at = Column(DateTime(timezone=True))
|
211 |
+
|
212 |
+
# Relationships
|
213 |
+
student = relationship("Student", back_populates="student_responses")
|
214 |
+
question = relationship("Question", back_populates="student_responses")
|
215 |
+
session = relationship("LearningSession", back_populates="responses")
|
216 |
+
|
217 |
+
class Teacher(Base):
|
218 |
+
__tablename__ = "teachers"
|
219 |
+
|
220 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
221 |
+
email = Column(String, unique=True, nullable=False)
|
222 |
+
hashed_password = Column(String, nullable=False)
|
223 |
+
name = Column(String, nullable=False)
|
224 |
+
school = Column(String)
|
225 |
+
subjects = Column(JSON, default=list)
|
226 |
+
grades = Column(JSON, default=list)
|
227 |
+
is_active = Column(Boolean, default=True)
|
228 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
229 |
+
|
230 |
+
class ContentBundle(Base):
|
231 |
+
__tablename__ = "content_bundles"
|
232 |
+
|
233 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
234 |
+
version = Column(String, nullable=False)
|
235 |
+
grade = Column(Integer, nullable=False)
|
236 |
+
subject = Column(String, nullable=False)
|
237 |
+
language = Column(String, nullable=False)
|
238 |
+
file_path = Column(String, nullable=False)
|
239 |
+
file_size = Column(Integer, nullable=False)
|
240 |
+
checksum = Column(String, nullable=False)
|
241 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
242 |
+
|
243 |
+
# app/schemas/sync.py
|
244 |
+
from pydantic import BaseModel
|
245 |
+
from typing import List, Dict, Optional, Any
|
246 |
+
from datetime import datetime
|
247 |
+
|
248 |
+
class StudentResponseSchema(BaseModel):
|
249 |
+
id: str
|
250 |
+
student_id: str
|
251 |
+
question_id: str
|
252 |
+
session_id: str
|
253 |
+
response: str
|
254 |
+
response_type: str
|
255 |
+
is_correct: bool
|
256 |
+
partial_credit: float
|
257 |
+
confidence: float
|
258 |
+
timestamp: datetime
|
259 |
+
|
260 |
+
class LearningSessionSchema(BaseModel):
|
261 |
+
id: str
|
262 |
+
student_id: str
|
263 |
+
subject: str
|
264 |
+
start_time: datetime
|
265 |
+
end_time: Optional[datetime] = None
|
266 |
+
skills_updated: Dict[str, float] = {}
|
267 |
+
|
268 |
+
class SyncPayload(BaseModel):
|
269 |
+
device_id: str
|
270 |
+
sessions: List[LearningSessionSchema]
|
271 |
+
responses: List[StudentResponseSchema]
|
272 |
+
last_sync_time: datetime
|
273 |
+
|
274 |
+
class SyncResponse(BaseModel):
|
275 |
+
success: bool
|
276 |
+
synced_sessions: int
|
277 |
+
synced_responses: int
|
278 |
+
new_content_available: bool
|
279 |
+
content_version: Optional[str] = None
|
280 |
+
|
281 |
+
# app/schemas/student.py
|
282 |
+
from pydantic import BaseModel
|
283 |
+
from typing import Dict, Optional
|
284 |
+
from datetime import datetime
|
285 |
+
|
286 |
+
class StudentCreate(BaseModel):
|
287 |
+
name: str
|
288 |
+
grade: int
|
289 |
+
preferred_language: str = "urdu"
|
290 |
+
|
291 |
+
class StudentResponse(BaseModel):
|
292 |
+
id: str
|
293 |
+
name: str
|
294 |
+
grade: int
|
295 |
+
preferred_language: str
|
296 |
+
skill_mastery: Dict[str, float]
|
297 |
+
created_at: datetime
|
298 |
+
updated_at: datetime
|
299 |
+
|
300 |
+
class Config:
|
301 |
+
from_attributes = True
|
302 |
+
|
303 |
+
# app/routers/auth.py
|
304 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
305 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
306 |
+
from sqlalchemy.orm import Session
|
307 |
+
from datetime import datetime, timedelta
|
308 |
+
from typing import Optional
|
309 |
+
import jwt
|
310 |
+
from passlib.context import CryptContext
|
311 |
+
|
312 |
+
from app.database import get_db
|
313 |
+
from app.models.database import Teacher
|
314 |
+
from app.core.config import settings
|
315 |
+
from pydantic import BaseModel
|
316 |
+
|
317 |
+
router = APIRouter()
|
318 |
+
security = HTTPBearer()
|
319 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
320 |
+
|
321 |
+
class LoginRequest(BaseModel):
|
322 |
+
email: str
|
323 |
+
password: str
|
324 |
+
|
325 |
+
class TokenResponse(BaseModel):
|
326 |
+
access_token: str
|
327 |
+
token_type: str = "bearer"
|
328 |
+
|
329 |
+
def verify_password(plain_password, hashed_password):
|
330 |
+
return pwd_context.verify(plain_password, hashed_password)
|
331 |
+
|
332 |
+
def get_password_hash(password):
|
333 |
+
return pwd_context.hash(password)
|
334 |
+
|
335 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
336 |
+
to_encode = data.copy()
|
337 |
+
if expires_delta:
|
338 |
+
expire = datetime.utcnow() + expires_delta
|
339 |
+
else:
|
340 |
+
expire = datetime.utcnow() + timedelta(minutes=15)
|
341 |
+
to_encode.update({"exp": expire})
|
342 |
+
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
343 |
+
return encoded_jwt
|
344 |
+
|
345 |
+
def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
346 |
+
try:
|
347 |
+
payload = jwt.decode(credentials.credentials, settings.SECRET_KEY, algorithms=["HS256"])
|
348 |
+
email: str = payload.get("sub")
|
349 |
+
if email is None:
|
350 |
+
raise HTTPException(
|
351 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
352 |
+
detail="Invalid authentication credentials",
|
353 |
+
headers={"WWW-Authenticate": "Bearer"},
|
354 |
+
)
|
355 |
+
return email
|
356 |
+
except jwt.PyJWTError:
|
357 |
+
raise HTTPException(
|
358 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
359 |
+
detail="Invalid authentication credentials",
|
360 |
+
headers={"WWW-Authenticate": "Bearer"},
|
361 |
+
)
|
362 |
+
|
363 |
+
@router.post("/login", response_model=TokenResponse)
|
364 |
+
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
365 |
+
teacher = db.query(Teacher).filter(Teacher.email == request.email).first()
|
366 |
+
if not teacher or not verify_password(request.password, teacher.hashed_password):
|
367 |
+
raise HTTPException(
|
368 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
369 |
+
detail="Incorrect email or password",
|
370 |
+
headers={"WWW-Authenticate": "Bearer"},
|
371 |
+
)
|
372 |
+
|
373 |
+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
374 |
+
access_token = create_access_token(
|
375 |
+
data={"sub": teacher.email}, expires_delta=access_token_expires
|
376 |
+
)
|
377 |
+
return {"access_token": access_token, "token_type": "bearer"}
|
378 |
+
|
379 |
+
@router.get("/me")
|
380 |
+
async def get_current_user(email: str = Depends(verify_token), db: Session = Depends(get_db)):
|
381 |
+
teacher = db.query(Teacher).filter(Teacher.email == email).first()
|
382 |
+
if not teacher:
|
383 |
+
raise HTTPException(status_code=404, detail="Teacher not found")
|
384 |
+
return {
|
385 |
+
"id": teacher.id,
|
386 |
+
"email": teacher.email,
|
387 |
+
"name": teacher.name,
|
388 |
+
"school": teacher.school,
|
389 |
+
"subjects": teacher.subjects,
|
390 |
+
"grades": teacher.grades
|
391 |
}
|
392 |
+
|
393 |
+
# app/routers/sync.py
|
394 |
+
from fastapi import APIRouter, Depends, HTTPException
|
395 |
+
from sqlalchemy.orm import Session
|
396 |
+
from sqlalchemy import and_
|
397 |
+
from datetime import datetime
|
398 |
+
from typing import List
|
399 |
+
|
400 |
+
from app.database import get_db
|
401 |
+
from app.models.database import Student, LearningSession, StudentResponse, Question
|
402 |
+
from app.schemas.sync import SyncPayload, SyncResponse
|
403 |
+
from app.routers.auth import verify_token
|
404 |
+
|
405 |
+
router = APIRouter()
|
406 |
+
|
407 |
+
@router.post("/push", response_model=SyncResponse)
|
408 |
+
async def sync_push(
|
409 |
+
payload: SyncPayload,
|
410 |
+
db: Session = Depends(get_db),
|
411 |
+
current_user: str = Depends(verify_token)
|
412 |
+
):
|
413 |
+
"""
|
414 |
+
Receive and process synced data from mobile devices
|
415 |
+
"""
|
416 |
try:
|
417 |
+
synced_sessions = 0
|
418 |
+
synced_responses = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
419 |
|
420 |
+
# Process learning sessions
|
421 |
+
for session_data in payload.sessions:
|
422 |
+
# Check if session already exists
|
423 |
+
existing_session = db.query(LearningSession).filter(
|
424 |
+
LearningSession.id == session_data.id
|
425 |
+
).first()
|
426 |
+
|
427 |
+
if not existing_session:
|
428 |
+
# Create new session
|
429 |
+
new_session = LearningSession(
|
430 |
+
id=session_data.id,
|
431 |
+
student_id=session_data.student_id,
|
432 |
+
subject=session_data.subject,
|
433 |
+
start_time=session_data.start_time,
|
434 |
+
end_time=session_data.end_time,
|
435 |
+
skills_updated=session_data.skills_updated,
|
436 |
+
device_id=payload.device_id,
|
437 |
+
synced_at=datetime.utcnow()
|
438 |
+
)
|
439 |
+
db.add(new_session)
|
440 |
+
synced_sessions += 1
|
441 |
|
442 |
+
# Process student responses
|
443 |
+
for response_data in payload.responses:
|
444 |
+
# Check if response already exists
|
445 |
+
existing_response = db.query(StudentResponse).filter(
|
446 |
+
StudentResponse.id == response_data.id
|
447 |
+
).first()
|
448 |
+
|
449 |
+
if not existing_response:
|
450 |
+
# Create new response
|
451 |
+
new_response = StudentResponse(
|
452 |
+
id=response_data.id,
|
453 |
+
student_id=response_data.student_id,
|
454 |
+
question_id=response_data.question_id,
|
455 |
+
session_id=response_data.session_id,
|
456 |
+
response=response_data.response,
|
457 |
+
response_type=response_data.response_type,
|
458 |
+
is_correct=response_data.is_correct,
|
459 |
+
partial_credit=response_data.partial_credit,
|
460 |
+
confidence=response_data.confidence,
|
461 |
+
timestamp=response_data.timestamp,
|
462 |
+
synced_at=datetime.utcnow()
|
463 |
+
)
|
464 |
+
db.add(new_response)
|
465 |
+
synced_responses += 1
|
466 |
|
467 |
+
# Commit all changes
|
468 |
+
db.commit()
|
|
|
|
|
469 |
|
470 |
+
# Check for new content
|
471 |
+
# For now, assume no new content available
|
472 |
+
new_content_available = False
|
473 |
+
content_version = None
|
|
|
474 |
|
475 |
+
return SyncResponse(
|
476 |
+
success=True,
|
477 |
+
synced_sessions=synced_sessions,
|
478 |
+
synced_responses=synced_responses,
|
479 |
+
new_content_available=new_content_available,
|
480 |
+
content_version=content_version
|
481 |
+
)
|
482 |
+
|
483 |
+
except Exception as e:
|
484 |
+
db.rollback()
|
485 |
+
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
|
486 |
+
|
487 |
+
@router.get("/status/{device_id}")
|
488 |
+
async def sync_status(
|
489 |
+
device_id: str,
|
490 |
+
db: Session = Depends(get_db)
|
491 |
+
):
|
492 |
+
"""
|
493 |
+
Get sync status for a device
|
494 |
+
"""
|
495 |
+
# Count unsynced items for this device
|
496 |
+
unsynced_sessions = db.query(LearningSession).filter(
|
497 |
+
and_(
|
498 |
+
LearningSession.device_id == device_id,
|
499 |
+
LearningSession.synced_at.is_(None)
|
500 |
+
)
|
501 |
+
).count()
|
502 |
+
|
503 |
+
unsynced_responses = db.query(StudentResponse).filter(
|
504 |
+
and_(
|
505 |
+
StudentResponse.synced_at.is_(None)
|
506 |
+
)
|
507 |
+
).count() # Note: StudentResponse doesn't have device_id, so checking all
|
508 |
|
509 |
+
return {
|
510 |
+
"device_id": device_id,
|
511 |
+
"unsynced_sessions": unsynced_sessions,
|
512 |
+
"unsynced_responses": unsynced_responses,
|
513 |
+
"last_sync": datetime.utcnow().isoformat()
|
514 |
+
}
|
515 |
+
|
516 |
+
# app/routers/content.py
|
517 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
518 |
+
from sqlalchemy.orm import Session
|
519 |
+
from typing import List, Optional
|
520 |
+
|
521 |
+
from app.database import get_db
|
522 |
+
from app.models.database import Question, ContentBundle
|
523 |
+
from app.routers.auth import verify_token
|
524 |
+
|
525 |
+
router = APIRouter()
|
526 |
+
|
527 |
+
@router.get("/questions")
|
528 |
+
async def get_questions(
|
529 |
+
grade: Optional[int] = Query(None, description="Grade level"),
|
530 |
+
subject: Optional[str] = Query(None, description="Subject"),
|
531 |
+
language: Optional[str] = Query("urdu", description="Language"),
|
532 |
+
limit: int = Query(50, description="Number of questions to return"),
|
533 |
+
offset: int = Query(0, description="Offset for pagination"),
|
534 |
+
db: Session = Depends(get_db)
|
535 |
+
):
|
536 |
+
"""
|
537 |
+
Get questions filtered by grade, subject, and language
|
538 |
+
"""
|
539 |
+
query = db.query(Question)
|
540 |
|
541 |
+
if grade:
|
542 |
+
query = query.filter(Question.grade == grade)
|
543 |
+
if subject:
|
544 |
+
query = query.filter(Question.subject == subject)
|
545 |
+
|
546 |
+
questions = query.offset(offset).limit(limit).all()
|
547 |
+
|
548 |
+
# Filter questions that have content in the requested language
|
549 |
+
filtered_questions = []
|
550 |
+
for question in questions:
|
551 |
+
if language in question.prompt:
|
552 |
+
question_data = {
|
553 |
+
"id": question.id,
|
554 |
+
"subject": question.subject,
|
555 |
+
"topic": question.topic,
|
556 |
+
"grade": question.grade,
|
557 |
+
"skill_tags": question.skill_tags,
|
558 |
+
"difficulty_estimate": question.difficulty_estimate,
|
559 |
+
"prompt": question.prompt.get(language, question.prompt.get('english', '')),
|
560 |
+
"options": question.options,
|
561 |
+
"solution_steps": question.solution_steps,
|
562 |
+
"hints": question.hints.get(language, question.hints.get('english', [])),
|
563 |
+
"answer_patterns": question.answer_patterns,
|
564 |
+
"explanation": question.explanation.get(language, question.explanation.get('english', ''))
|
565 |
+
}
|
566 |
+
filtered_questions.append(question_data)
|
567 |
+
|
568 |
+
return {
|
569 |
+
"questions": filtered_questions,
|
570 |
+
"total": len(filtered_questions),
|
571 |
+
"grade": grade,
|
572 |
+
"subject": subject,
|
573 |
+
"language": language
|
574 |
+
}
|
575 |
+
|
576 |
+
@router.get("/bundle")
|
577 |
+
async def get_content_bundle(
|
578 |
+
version: Optional[str] = Query("latest", description="Content version"),
|
579 |
+
grade: Optional[int] = Query(None, description="Grade level"),
|
580 |
+
subject: Optional[str] = Query(None, description="Subject"),
|
581 |
+
language: str = Query("urdu", description="Language"),
|
582 |
+
db: Session = Depends(get_db)
|
583 |
+
):
|
584 |
+
"""
|
585 |
+
Get content bundle for offline use
|
586 |
+
"""
|
587 |
+
query = db.query(ContentBundle)
|
588 |
+
|
589 |
+
if version != "latest":
|
590 |
+
query = query.filter(ContentBundle.version == version)
|
591 |
+
if grade:
|
592 |
+
query = query.filter(ContentBundle.grade == grade)
|
593 |
+
if subject:
|
594 |
+
query = query.filter(ContentBundle.subject == subject)
|
595 |
+
if language:
|
596 |
+
query = query.filter(ContentBundle.language == language)
|
597 |
+
|
598 |
+
# Get the latest version if "latest" is requested
|
599 |
+
if version == "latest":
|
600 |
+
bundle = query.order_by(ContentBundle.created_at.desc()).first()
|
601 |
+
else:
|
602 |
+
bundle = query.first()
|
603 |
+
|
604 |
+
if not bundle:
|
605 |
+
raise HTTPException(status_code=404, detail="Content bundle not found")
|
606 |
+
|
607 |
+
return {
|
608 |
+
"id": bundle.id,
|
609 |
+
"version": bundle.version,
|
610 |
+
"grade": bundle.grade,
|
611 |
+
"subject": bundle.subject,
|
612 |
+
"language": bundle.language,
|
613 |
+
"file_path": bundle.file_path,
|
614 |
+
"file_size": bundle.file_size,
|
615 |
+
"checksum": bundle.checksum,
|
616 |
+
"created_at": bundle.created_at
|
617 |
+
}
|
618 |
+
|
619 |
+
@router.post("/questions")
|
620 |
+
async def create_question(
|
621 |
+
question_data: dict,
|
622 |
+
current_user: str = Depends(verify_token),
|
623 |
+
db: Session = Depends(get_db)
|
624 |
+
):
|
625 |
+
"""
|
626 |
+
Create a new question (teacher only)
|
627 |
+
"""
|
628 |
+
try:
|
629 |
+
new_question = Question(**question_data)
|
630 |
+
db.add(new_question)
|
631 |
+
db.commit()
|
632 |
+
db.refresh(new_question)
|
633 |
|
634 |
+
return {"message": "Question created successfully", "id": new_question.id}
|
635 |
+
except Exception as e:
|
636 |
+
db.rollback()
|
637 |
+
raise HTTPException(status_code=500, detail=f"Failed to create question: {str(e)}")
|
638 |
+
|
639 |
+
# app/routers/grading.py
|
640 |
+
from fastapi import APIRouter, Depends, HTTPException
|
641 |
+
from pydantic import BaseModel
|
642 |
+
from typing import Dict, Any
|
643 |
+
from app.ml.grading_engine import GradingEngine
|
644 |
+
|
645 |
+
router = APIRouter()
|
646 |
+
|
647 |
+
class GradingRequest(BaseModel):
|
648 |
+
student_response: str
|
649 |
+
question_id: str
|
650 |
+
answer_patterns: list
|
651 |
+
response_type: str = "text"
|
652 |
+
language: str = "urdu"
|
653 |
+
|
654 |
+
class GradingResult(BaseModel):
|
655 |
+
is_correct: bool
|
656 |
+
partial_credit: float
|
657 |
+
confidence: float
|
658 |
+
feedback: str
|
659 |
+
|
660 |
+
@router.post("/grade", response_model=GradingResult)
|
661 |
+
async def grade_response(request: GradingRequest):
|
662 |
+
"""
|
663 |
+
Grade a student response using ML models
|
664 |
+
"""
|
665 |
+
try:
|
666 |
+
grading_engine = GradingEngine()
|
667 |
+
result = await grading_engine.grade_response(
|
668 |
+
response=request.student_response,
|
669 |
+
answer_patterns=request.answer_patterns,
|
670 |
+
response_type=request.response_type,
|
671 |
+
language=request.language
|
672 |
)
|
673 |
|
674 |
+
return GradingResult(
|
675 |
+
is_correct=result["is_correct"],
|
676 |
+
partial_credit=result["partial_credit"],
|
677 |
+
confidence=result["confidence"],
|
678 |
+
feedback=result.get("feedback", "")
|
679 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
680 |
|
681 |
+
except Exception as e:
|
682 |
+
raise HTTPException(status_code=500, detail=f"Grading failed: {str(e)}")
|
683 |
+
|
684 |
+
# app/routers/analytics.py
|
685 |
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
686 |
+
from sqlalchemy.orm import Session
|
687 |
+
from sqlalchemy import func, and_
|
688 |
+
from datetime import datetime, timedelta
|
689 |
+
from typing import Optional, List
|
690 |
+
|
691 |
+
from app.database import get_db
|
692 |
+
from app.models.database import Student, StudentResponse, LearningSession, Question
|
693 |
+
from app.routers.auth import verify_token
|
694 |
+
|
695 |
+
router = APIRouter()
|
696 |
+
|
697 |
+
@router.get("/student/{student_id}/progress")
|
698 |
+
async def get_student_progress(
|
699 |
+
student_id: str,
|
700 |
+
days: int = Query(30, description="Number of days to analyze"),
|
701 |
+
db: Session = Depends(get_db),
|
702 |
+
current_user: str = Depends(verify_token)
|
703 |
+
):
|
704 |
+
"""
|
705 |
+
Get detailed progress analytics for a student
|
706 |
+
"""
|
707 |
+
# Get student info
|
708 |
+
student = db.query(Student).filter(Student.id == student_id).first()
|
709 |
+
if not student:
|
710 |
+
raise HTTPException(status_code=404, detail="Student not found")
|
711 |
+
|
712 |
+
# Date range
|
713 |
+
end_date = datetime.utcnow()
|
714 |
+
start_date = end_date - timedelta(days=days)
|
715 |
+
|
716 |
+
# Get responses in date range
|
717 |
+
responses = db.query(StudentResponse).filter(
|
718 |
+
and_(
|
719 |
+
StudentResponse.student_id == student_id,
|
720 |
+
StudentResponse.timestamp >= start_date,
|
721 |
+
StudentResponse.timestamp <= end_date
|
722 |
+
)
|
723 |
+
).all()
|
724 |
+
|
725 |
+
# Calculate metrics
|
726 |
+
total_responses = len(responses)
|
727 |
+
correct_responses = sum(1 for r in responses if r.is_correct)
|
728 |
+
accuracy = correct_responses / total_responses if total_responses > 0 else 0
|
729 |
+
|
730 |
+
# Subject-wise breakdown
|
731 |
+
subject_stats = {}
|
732 |
+
for response in responses:
|
733 |
+
question = db.query(Question).filter(Question.id == response.question_id).first()
|
734 |
+
if question:
|
735 |
+
subject = question.subject
|
736 |
+
if subject not in subject_stats:
|
737 |
+
subject_stats[subject] = {"total": 0, "correct": 0, "partial_credit": 0}
|
738 |
|
739 |
+
subject_stats[subject]["total"] += 1
|
740 |
+
if response.is_correct:
|
741 |
+
subject_stats[subject]["correct"] += 1
|
742 |
+
subject_stats[subject]["partial_credit"] += response.partial_credit
|
743 |
+
|
744 |
+
# Calculate subject accuracies
|
745 |
+
for subject in subject_stats:
|
746 |
+
stats = subject_stats[subject]
|
747 |
+
stats["accuracy"] = stats["correct"] / stats["total"] if stats["total"] > 0 else 0
|
748 |
+
stats["avg_partial_credit"] = stats["partial_credit"] / stats["total"] if stats["total"] > 0 else 0
|
749 |
+
|
750 |
+
# Skill mastery trends
|
751 |
+
skill_mastery = student.skill_mastery or {}
|
752 |
+
|
753 |
+
# Recent sessions
|
754 |
+
recent_sessions = db.query(LearningSession).filter(
|
755 |
+
and_(
|
756 |
+
LearningSession.student_id == student_id,
|
757 |
+
LearningSession.start_time >= start_date
|
758 |
+
)
|
759 |
+
).order_by(LearningSession.start_time.desc()).limit(10).all()
|
760 |
+
|
761 |
+
session_data = []
|
762 |
+
for session in recent_sessions:
|
763 |
+
session_responses = [r for r in responses if r.session_id == session.id]
|
764 |
+
session_accuracy = sum(1 for r in session_responses if r.is_correct) / len(session_responses) if session_responses else 0
|
765 |
|
766 |
+
session_data.append({
|
767 |
+
"id": session.id,
|
768 |
+
"subject": session.subject,
|
769 |
+
"start_time": session.start_time,
|
770 |
+
"end_time": session.end_time,
|
771 |
+
"responses": len(session_responses),
|
772 |
+
"accuracy": session_accuracy
|
773 |
+
})
|
774 |
+
|
775 |
+
return {
|
776 |
+
"student": {
|
777 |
+
"id": student.id,
|
778 |
+
"name": student.name,
|
779 |
+
"grade": student.grade,
|
780 |
+
"preferred_language": student.preferred_language
|
781 |
+
},
|
782 |
+
"period": {
|
783 |
+
"days": days,
|
784 |
+
"start_date": start_date,
|
785 |
+
"end_date": end_date
|
786 |
+
},
|
787 |
+
"overall": {
|
788 |
+
"total_responses": total_responses,
|
789 |
+
"correct_responses": correct_responses,
|
790 |
+
"accuracy": accuracy,
|
791 |
+
"sessions": len(recent_sessions)
|
792 |
+
},
|
793 |
+
"by_subject": subject_stats,
|
794 |
+
"skill_mastery": skill_mastery,
|
795 |
+
"recent_sessions": session_data
|
796 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
797 |
|
798 |
+
@router.get("/class/overview")
|
799 |
+
async def get_class_overview(
|
800 |
+
grade: Optional[int] = Query(None, description="Grade to filter"),
|
801 |
+
days: int = Query(7, description="Number of days to analyze"),
|
802 |
+
db: Session = Depends(get_db),
|
803 |
+
current_user: str = Depends(verify_token)
|
804 |
+
):
|
805 |
+
"""
|
806 |
+
Get class-level analytics overview
|
807 |
+
"""
|
808 |
+
# Date range
|
809 |
+
end_date = datetime.utcnow()
|
810 |
+
start_date = end_date - timedelta(days=days)
|
811 |
+
|
812 |
+
# Get students
|
813 |
+
students_query = db.query(Student)
|
814 |
+
if grade:
|
815 |
+
students_query = students_query.filter(Student.grade == grade)
|
816 |
+
students = students_query.all()
|
817 |
+
|
818 |
+
# Get recent responses for these students
|
819 |
+
student_ids = [s.id for s in students]
|
820 |
+
responses = db.query(StudentResponse).filter(
|
821 |
+
and_(
|
822 |
+
StudentResponse.student_id.in_(student_ids),
|
823 |
+
StudentResponse.timestamp >= start_date
|
824 |
+
)
|
825 |
+
).all()
|
826 |
+
|
827 |
+
# Overall statistics
|
828 |
+
total_students = len(students)
|
829 |
+
active_students = len(set(r.student_id for r in responses))
|
830 |
+
total_responses = len(responses)
|
831 |
+
correct_responses = sum(1 for r in responses if r.is_correct)
|
832 |
+
overall_accuracy = correct_responses / total_responses if total_responses > 0 else 0
|
833 |
+
|
834 |
+
# Student performance breakdown
|
835 |
+
student_performance = []
|
836 |
+
for student in students:
|
837 |
+
student_responses = [r for r in responses if r.student_id == student.id]
|
838 |
+
if student_responses:
|
839 |
+
accuracy = sum(1 for r in student_responses if r.is_correct) / len(student_responses)
|
840 |
+
student_performance.appen
|