Files
open-school/backend/app/main.py
2026-05-01 21:18:48 +02:00

483 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} ! Aujourdhui, 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 lhistorique, "
"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 quil 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,
)