from contextlib import asynccontextmanager import os from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from sqlalchemy.orm import Session from .database import Base, engine, get_db from . import models, schemas from .auth import ( AUTH_COOKIE_NAME, create_access_token, get_current_user, hash_password, require_roles, seed_admin_user, verify_password, ) from .curriculum import QUESTIONS from .program_content import ( decode_asset_token, get_content_status, get_lesson, list_lessons_for_grade, normalize_grade, render_svg_section, ) from .services import ( build_llm_reply, ensure_student_mastery, evaluate_answer, list_tts_profiles, pick_next_skill, seed_skills, synthesize_speech, transcribe_audio, ) @asynccontextmanager async def lifespan(app: FastAPI): Base.metadata.create_all(bind=engine) db = next(get_db()) try: seed_skills(db) seed_admin_user(db) finally: db.close() yield app = FastAPI(title="Professeur Virtuel API", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=[ "https://prof.open-squared.tech", "http://localhost:3000", "http://localhost:3001", ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) def ensure_student_access(current_user: models.User, student_id: int) -> None: if current_user.role in {"teacher", "maintenance"}: return if current_user.role == "student" and current_user.student_id == student_id: return raise HTTPException(status_code=403, detail="Droits insuffisants pour cet élève") def build_student_program_response(student: models.Student, assignment: models.StudentProgramAssignment | None): lesson = get_lesson(assignment.lesson_id) if assignment else None return schemas.StudentProgramResponse(student=student, lesson=lesson) @app.get("/health") def health(): return {"status": "ok"} @app.post("/auth/login", response_model=schemas.LoginResponse) def login(payload: schemas.LoginRequest, response: Response, db: Session = Depends(get_db)): user = db.query(models.User).filter_by(username=payload.username).first() if not user or not verify_password(payload.password, user.password_hash): raise HTTPException(status_code=401, detail="Identifiants invalides") token = create_access_token(user) response.set_cookie( key=AUTH_COOKIE_NAME, value=token, httponly=True, secure=os.getenv("AUTH_COOKIE_SECURE", "false").lower() == "true", samesite=os.getenv("AUTH_COOKIE_SAMESITE", "lax"), max_age=int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "720")) * 60, path="/", ) return schemas.LoginResponse(access_token=token, user=user) @app.get("/auth/me", response_model=schemas.UserRead) def auth_me(current_user: models.User = Depends(get_current_user)): return current_user @app.post("/auth/logout") def logout(response: Response): response.delete_cookie(key=AUTH_COOKIE_NAME, path="/") return {"status": "ok"} @app.get("/students", response_model=list[schemas.StudentRead]) def list_students( db: Session = Depends(get_db), current_user: models.User = Depends(require_roles("teacher", "maintenance")), ): return db.query(models.Student).order_by(models.Student.id.asc()).all() @app.post("/students", response_model=schemas.StudentRead) def create_student( payload: schemas.StudentCreate, db: Session = Depends(get_db), current_user: models.User = Depends(require_roles("teacher", "maintenance")), ): student = models.Student(**payload.model_dump()) db.add(student) db.commit() db.refresh(student) ensure_student_mastery(db, student) return student @app.post("/admin/student-accounts", response_model=schemas.StudentAccountResponse) def create_student_account( payload: schemas.StudentAccountCreate, db: Session = Depends(get_db), current_user: models.User = Depends(require_roles("teacher", "maintenance")), ): username = payload.username.strip() first_name = payload.first_name.strip() if not username or not first_name: raise HTTPException(status_code=400, detail="Prénom et identifiant obligatoires") existing_user = db.query(models.User).filter_by(username=username).first() if existing_user: raise HTTPException(status_code=409, detail="Identifiant deja utilise") student = models.Student( first_name=first_name, age=payload.age, grade=payload.grade, ) db.add(student) db.commit() db.refresh(student) ensure_student_mastery(db, student) user = models.User( username=username, password_hash=hash_password(payload.password), role="student", student_id=student.id, ) db.add(user) db.commit() db.refresh(user) return schemas.StudentAccountResponse(student=student, user=user) @app.get("/students/{student_id}/messages", response_model=list[schemas.MessageRead]) def list_student_messages( student_id: int, limit: int = 80, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, student_id) student = db.query(models.Student).filter_by(id=student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") safe_limit = min(max(limit, 1), 200) rows = ( db.query(models.Message) .filter_by(student_id=student_id) .order_by(models.Message.created_at.desc(), models.Message.id.desc()) .limit(safe_limit) .all() ) return list(reversed(rows)) @app.get("/program/lessons", response_model=schemas.ProgramLessonListResponse) def list_program_lessons( grade: str, current_user: models.User = Depends(require_roles("teacher", "maintenance")), ): return schemas.ProgramLessonListResponse(lessons=list_lessons_for_grade(grade)) @app.get("/program/status") def get_program_status( current_user: models.User = Depends(require_roles("teacher", "maintenance")), ): return get_content_status() @app.get("/program/assets/{asset_token}") def get_program_asset( asset_token: str, current_user: models.User = Depends(get_current_user), ): try: path = decode_asset_token(asset_token) except Exception as exc: raise HTTPException(status_code=400, detail="Ressource invalide") from exc if not path.exists() or path.suffix.lower() != ".svg": raise HTTPException(status_code=404, detail="Fiche introuvable") return FileResponse(path, media_type="image/svg+xml") @app.get("/program/assets/{asset_token}/sections/{section_index}") def get_program_asset_section( asset_token: str, section_index: int, current_user: models.User = Depends(get_current_user), ): try: path = decode_asset_token(asset_token) svg = render_svg_section(path, section_index) except IndexError as exc: raise HTTPException(status_code=404, detail="Étape introuvable") from exc except Exception as exc: raise HTTPException(status_code=400, detail="Ressource invalide") from exc return Response(content=svg, media_type="image/svg+xml") @app.get("/students/{student_id}/program", response_model=schemas.StudentProgramResponse) def get_student_program( student_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, student_id) student = db.query(models.Student).filter_by(id=student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first() return build_student_program_response(student, assignment) @app.put("/admin/students/{student_id}/program", response_model=schemas.StudentProgramResponse) def assign_student_program( student_id: int, payload: schemas.StudentProgramAssignRequest, db: Session = Depends(get_db), current_user: models.User = Depends(require_roles("teacher", "maintenance")), ): student = db.query(models.Student).filter_by(id=student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") lesson = get_lesson(payload.lesson_id) if not lesson: raise HTTPException(status_code=404, detail="Leçon introuvable") if lesson["grade"] != normalize_grade(student.grade): raise HTTPException(status_code=400, detail="Cette leçon ne correspond pas au niveau de l’élève") assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first() if not assignment: assignment = models.StudentProgramAssignment(student_id=student_id) assignment.lesson_id = lesson["id"] assignment.title = lesson["title"] assignment.subject = lesson["subject"] assignment.grade = lesson["grade"] db.add(assignment) db.commit() db.refresh(assignment) return build_student_program_response(student, assignment) @app.post("/session/start", response_model=schemas.ChatResponse) def start_session( student_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, student_id) student = db.query(models.Student).filter_by(id=student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") ensure_student_mastery(db, student) message = ( f"Bonjour {student.first_name} ! Je suis Professeur TOP, ton professeur virtuel. " "Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà." ) assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student.id).first() assigned_lesson = get_lesson(assignment.lesson_id) if assignment else None if assigned_lesson: first_asset = (assigned_lesson.get("assets") or [None])[0] first_section = (first_asset.get("sections") or [None])[0] if first_asset else None section_label = first_section["title"] if first_section else first_asset["title"] if first_asset else assigned_lesson["title"] message = ( f"Bonjour {student.first_name} ! Aujourd’hui, on travaille la leçon " f"{assigned_lesson['title']}. Regarde la carte « {section_label} ». " "Dis-moi si tu comprends cette partie, ou ce qui te gêne." ) previous_messages_count = db.query(models.Message).filter_by(student_id=student.id).count() if previous_messages_count: prompt = ( "L’élève revient pour une nouvelle séance. Fais une courte reprise: " "explique où on s’était arrêté la dernière fois à partir de l’historique, " "puis annonce clairement ce qu'on va faire aujourd'hui. Termine par une " "première consigne simple." ) if assigned_lesson: first_asset = (assigned_lesson.get("assets") or [None])[0] first_section = (first_asset.get("sections") or [None])[0] if first_asset else None section_label = first_section["title"] if first_section else first_asset["title"] if first_asset else assigned_lesson["title"] prompt += ( f" La leçon active choisie par le professeur est: {assigned_lesson['title']}. " f"La carte affichée est: {section_label}. Parle de cette carte, pas d'une ancienne leçon." ) message = build_llm_reply(db, student.id, prompt) db.add(models.Message(student_id=student.id, role="assistant", content=message)) db.commit() return schemas.ChatResponse(reply=message) @app.post("/chat", response_model=schemas.ChatResponse) def chat( payload: schemas.ChatRequest, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, payload.student_id) student = db.query(models.Student).filter_by(id=payload.student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") db.add(models.Message(student_id=student.id, role="user", content=payload.message)) db.commit() lesson_context = "" if payload.lesson_title or payload.section_title or payload.asset_title: lesson_context = ( "\n\nContexte de l’écran actuellement montré à l’élève: " f"leçon={payload.lesson_title or 'non précisée'}, " f"fiche={payload.asset_title or 'non précisée'}, " f"carte affichée={payload.section_title or 'non précisée'}, " f"avancement={payload.step_percent if payload.step_percent is not None else 'non précisé'}%. " "Réponds uniquement à propos de cette carte. Vérifie que l’élève comprend ce qui est affiché. " "Si l’élève dit qu’il a compris, invite-le à passer à l’étape suivante." ) reply = build_llm_reply(db, payload.student_id, f"{payload.message}{lesson_context}") db.add(models.Message(student_id=student.id, role="assistant", content=reply)) db.commit() return schemas.ChatResponse(reply=reply) @app.post("/transcribe") async def transcribe( file: UploadFile = File(...), current_user: models.User = Depends(get_current_user), ): if not file.filename: raise HTTPException(status_code=400, detail="Fichier audio manquant") audio_bytes = await file.read() if not audio_bytes: raise HTTPException(status_code=400, detail="Fichier audio vide") try: text = transcribe_audio(file.filename, audio_bytes, file.content_type) except Exception as exc: raise HTTPException(status_code=502, detail=f"Erreur de transcription: {exc}") from exc return {"text": text} @app.get("/tts/profiles", response_model=schemas.TTSProfilesResponse) def get_tts_profiles(): return schemas.TTSProfilesResponse(profiles=list_tts_profiles()) @app.post("/tts") def text_to_speech( payload: schemas.TTSRequest, current_user: models.User = Depends(get_current_user), ): try: audio_bytes = synthesize_speech(payload.text, payload.profile_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"Erreur de synthèse vocale: {exc}") from exc return Response(content=audio_bytes, media_type="audio/mpeg") @app.get("/progress/{student_id}", response_model=schemas.ProgressResponse) def get_progress( student_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, student_id) student = db.query(models.Student).filter_by(id=student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") rows = ( db.query(models.StudentSkillMastery, models.Skill) .join(models.Skill, models.Skill.id == models.StudentSkillMastery.skill_id) .filter(models.StudentSkillMastery.student_id == student_id) .order_by(models.Skill.subject.asc(), models.Skill.label.asc()) .all() ) progress = [ schemas.SkillProgress( code=skill.code, subject=skill.subject, label=skill.label, mastery_score=mastery.mastery_score, confidence=mastery.confidence, evidence_count=mastery.evidence_count, ) for mastery, skill in rows ] return schemas.ProgressResponse(student=student, progress=progress) @app.get("/assessment/next/{student_id}", response_model=schemas.AssessmentQuestionResponse) def next_assessment( student_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, student_id) student = db.query(models.Student).filter_by(id=student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") skill = pick_next_skill(db, student_id) question = QUESTIONS[skill.code]["question"] return schemas.AssessmentQuestionResponse(skill_code=skill.code, skill_label=skill.label, question=question) @app.post("/assessment/answer", response_model=schemas.AssessmentAnswerResponse) def answer_assessment( payload: schemas.AssessmentAnswerRequest, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user), ): ensure_student_access(current_user, payload.student_id) student = db.query(models.Student).filter_by(id=payload.student_id).first() if not student: raise HTTPException(status_code=404, detail="Élève introuvable") if payload.skill_code not in QUESTIONS: raise HTTPException(status_code=400, detail="Compétence inconnue") correct, feedback, mastery_score = evaluate_answer( db, payload.student_id, payload.skill_code, payload.answer ) db.add( models.Message( student_id=student.id, role="user", content=f"Reponse au mini-test ({payload.skill_code}): {payload.answer}", ) ) db.add(models.Message(student_id=student.id, role="assistant", content=feedback)) db.commit() return schemas.AssessmentAnswerResponse( correct=correct, feedback=feedback, mastery_score=mastery_score, )