Files
open-school/backend/app/main.py
2026-05-06 19:55:37 +02:00

811 lines
30 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 json
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 sqlalchemy import inspect, text
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 (
build_program_tree,
decode_asset_token,
format_adaptive_kit_context,
get_content_status,
get_lesson,
list_lessons_for_grade,
normalize_grade,
render_svg_section,
update_program_review,
)
from .services import (
build_llm_reply,
build_lesson_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)
ensure_lightweight_migrations()
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_lightweight_migrations() -> None:
inspector = inspect(engine)
if "student_program_assignments" in inspector.get_table_names():
columns = {column["name"] for column in inspector.get_columns("student_program_assignments")}
with engine.begin() as connection:
if "start_asset_index" not in columns:
connection.execute(
text("ALTER TABLE student_program_assignments ADD COLUMN start_asset_index INTEGER DEFAULT 0")
)
if "start_section_index" not in columns:
connection.execute(
text("ALTER TABLE student_program_assignments ADD COLUMN start_section_index INTEGER DEFAULT 0")
)
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,
start_asset_index=assignment.start_asset_index if assignment else 0,
start_section_index=assignment.start_section_index if assignment else 0,
)
def format_student_adaptive_profile_context(profile: models.StudentAdaptiveProfile | None) -> str:
if not profile:
return ""
return (
"Profil adaptatif recommandé pour cet élève:\n"
f"- Parcours: {profile.profile_title}\n"
f"- Source: {profile.source}\n"
f"- Notes professeur: {profile.notes or 'aucune'}\n"
"Règle: utilise ce parcours comme aide prioritaire si l'élève hésite ou se trompe sur cette leçon.\n"
)
def clamp_lesson_position(lesson: dict, asset_index: int, section_index: int) -> tuple[int, int]:
assets = lesson.get("assets") or []
if not assets:
return 0, 0
safe_asset_index = min(max(asset_index, 0), len(assets) - 1)
sections = assets[safe_asset_index].get("sections") or []
safe_section_index = min(max(section_index, 0), max(len(sections) - 1, 0))
return safe_asset_index, safe_section_index
def upsert_card_progress(db: Session, student_id: int, state: schemas.LessonCardState) -> models.LessonCardProgress:
row = (
db.query(models.LessonCardProgress)
.filter_by(
student_id=student_id,
lesson_id=state.lesson_id,
asset_path=state.asset_path,
section_key=state.section_key,
)
.first()
)
if not row:
row = models.LessonCardProgress(
student_id=student_id,
lesson_id=state.lesson_id,
asset_path=state.asset_path,
asset_title=state.asset_title,
section_key=state.section_key,
section_title=state.section_title,
)
row.asset_title = state.asset_title
row.section_title = state.section_title
row.comprehension_score = max(row.comprehension_score or 0, state.comprehension_score)
row.attempts = max(row.attempts or 0, state.attempts)
if state.validated and row.validated_at is None:
from datetime import datetime
row.validated_at = datetime.utcnow()
db.add(row)
return row
def update_next_program_start_from_progress(db: Session, student_id: int, lesson_id: str) -> None:
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
if not assignment or assignment.lesson_id != lesson_id:
return
lesson = get_lesson(lesson_id)
if not lesson:
return
validated_keys = {
row.section_key
for row in db.query(models.LessonCardProgress)
.filter_by(student_id=student_id, lesson_id=lesson_id)
.all()
if row.validated_at is not None
}
assets = lesson.get("assets") or []
for asset_index, asset in enumerate(assets):
sections = asset.get("sections") or []
if not sections:
continue
for section_index, section in enumerate(sections):
section_key = section.get("context_key") or f"{asset.get('path', 'asset')}:{section.get('index', 0)}"
if section_key not in validated_keys:
assignment.start_asset_index = asset_index
assignment.start_section_index = section_index
db.add(assignment)
return
last_asset_index = max(len(assets) - 1, 0)
last_sections = assets[last_asset_index].get("sections") if assets else []
assignment.start_asset_index = last_asset_index
assignment.start_section_index = max(len(last_sections or []) - 1, 0)
db.add(assignment)
@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("/admin/program/tree")
def get_admin_program_tree(
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return build_program_tree()
@app.put("/admin/program/review")
def put_admin_program_review(
payload: schemas.ProgramReviewRequest,
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return update_program_review(
payload.node_key,
payload.status,
payload.comment,
current_user.username,
)
@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.put("/admin/students/{student_id}/program/start", response_model=schemas.StudentProgramResponse)
def update_student_program_start(
student_id: int,
payload: schemas.StudentProgramStartRequest,
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")
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
if not assignment:
raise HTTPException(status_code=404, detail="Aucune leçon active")
lesson = get_lesson(assignment.lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Leçon introuvable")
asset_index, section_index = clamp_lesson_position(lesson, payload.asset_index, payload.section_index)
assignment.start_asset_index = asset_index
assignment.start_section_index = section_index
db.add(assignment)
db.commit()
db.refresh(assignment)
return build_student_program_response(student, assignment)
@app.get("/admin/students/{student_id}/adaptive-profile", response_model=schemas.StudentAdaptiveProfileRead | None)
def get_student_adaptive_profile(
student_id: int,
lesson_id: str,
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")
return (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student_id, lesson_id=lesson_id)
.first()
)
@app.put("/admin/students/{student_id}/adaptive-profile", response_model=schemas.StudentAdaptiveProfileRead)
def upsert_student_adaptive_profile(
student_id: int,
payload: schemas.StudentAdaptiveProfileRequest,
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")
row = (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student_id, lesson_id=payload.lesson_id)
.first()
)
if not row:
row = models.StudentAdaptiveProfile(student_id=student_id, lesson_id=payload.lesson_id)
row.diagnostic_id = payload.diagnostic_id
row.profile_id = payload.profile_id
row.profile_title = payload.profile_title
row.notes = payload.notes
row.source = payload.source
db.add(row)
db.commit()
db.refresh(row)
return row
@app.get("/students/{student_id}/card-progress", response_model=list[schemas.LessonCardProgressRead])
def list_card_progress(
student_id: int,
lesson_id: str | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
query = db.query(models.LessonCardProgress).filter_by(student_id=student_id)
if lesson_id:
query = query.filter_by(lesson_id=lesson_id)
return query.order_by(models.LessonCardProgress.updated_at.desc()).all()
@app.post("/session/end", response_model=schemas.LessonSessionLogRead)
def end_lesson_session(
payload: schemas.LessonSessionEndRequest,
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")
for state in payload.card_states:
upsert_card_progress(db, student.id, state)
db.flush()
update_next_program_start_from_progress(db, student.id, payload.lesson_id)
conversation_json = json.dumps(
payload.conversation,
ensure_ascii=False,
)
card_results_json = json.dumps(
[state.model_dump(mode="json") for state in payload.card_states],
ensure_ascii=False,
)
log = models.LessonSessionLog(
student_id=student.id,
lesson_id=payload.lesson_id,
lesson_title=payload.lesson_title,
conversation=conversation_json,
card_results=card_results_json,
)
db.add(log)
db.commit()
db.refresh(log)
return log
@app.get("/admin/students/{student_id}/session-logs", response_model=list[schemas.LessonSessionLogRead])
def list_session_logs(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return (
db.query(models.LessonSessionLog)
.filter_by(student_id=student_id)
.order_by(models.LessonSessionLog.ended_at.desc(), models.LessonSessionLog.id.desc())
.all()
)
@app.delete("/admin/session-logs/{log_id}")
def delete_session_log(
log_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
log = db.query(models.LessonSessionLog).filter_by(id=log_id).first()
if not log:
raise HTTPException(status_code=404, detail="Log introuvable")
db.delete(log)
db.commit()
return {"status": "ok"}
@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:
asset_index, section_index = clamp_lesson_position(
assigned_lesson,
assignment.start_asset_index if assignment else 0,
assignment.start_section_index if assignment else 0,
)
first_asset = (assigned_lesson.get("assets") or [None])[asset_index]
first_sections = first_asset.get("sections") or [] if first_asset else []
first_section = first_sections[section_index] if first_sections else None
section_label = first_section["title"] if first_section else first_asset["title"] if first_asset else assigned_lesson["title"]
lesson_context = (
f"Leçon courante: {assigned_lesson['title']}\n"
f"Fiche courante: {first_asset['title'] if first_asset else 'non précisée'}\n"
f"Card courante: {section_label}\n"
f"Clé de contexte: {first_section.get('context_key') if first_section else 'non précisée'}\n"
"Avancement affiché: début de leçon\n"
"Règle: commence directement sur cette card. Ne fais pas de reprise de la dernière séance.\n"
)
if first_section and first_section.get("context"):
lesson_context += f"\nTexte pédagogique de la card courante:\n{first_section['context'].strip()}"
adaptive_context = format_adaptive_kit_context(assigned_lesson)
if adaptive_context:
lesson_context += f"\n\n{adaptive_context}"
adaptive_profile = (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student.id, lesson_id=assigned_lesson["id"])
.first()
)
profile_context = format_student_adaptive_profile_context(adaptive_profile)
if profile_context:
lesson_context += f"\n\n{profile_context}"
lesson_reply = build_lesson_reply(
db,
student.id,
"La séance démarre. Présente la card courante et pose une question de compréhension.",
lesson_context,
)
message = lesson_reply["reply"]
previous_messages_count = db.query(models.Message).filter_by(student_id=student.id).count()
if previous_messages_count and not assigned_lesson:
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."
)
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:
current_lesson = get_lesson(payload.lesson_id) if payload.lesson_id else None
lesson_context = (
f"Leçon courante: {payload.lesson_title or 'non précisée'}\n"
f"Fiche courante: {payload.asset_title or 'non précisée'}\n"
f"Card courante: {payload.section_title or 'non précisée'}\n"
f"Clé de contexte: {payload.section_context_key or 'non précisée'}\n"
f"Avancement affiché: {payload.step_percent if payload.step_percent is not None else 'non précisé'}%\n"
"Règle: réponds uniquement à propos de cette card et vérifie la compréhension de l'élève.\n"
)
if payload.section_context:
lesson_context += f"\nTexte pédagogique de la card courante:\n{payload.section_context.strip()}"
adaptive_context = format_adaptive_kit_context(current_lesson)
if adaptive_context:
lesson_context += f"\n\n{adaptive_context}"
if current_lesson:
adaptive_profile = (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student.id, lesson_id=current_lesson["id"])
.first()
)
profile_context = format_student_adaptive_profile_context(adaptive_profile)
if profile_context:
lesson_context += f"\n\n{profile_context}"
if lesson_context:
lesson_reply = build_lesson_reply(db, payload.student_id, payload.message, lesson_context)
reply = lesson_reply["reply"]
advance_lesson_step = lesson_reply["advance_lesson_step"]
else:
reply = build_llm_reply(db, payload.student_id, payload.message)
advance_lesson_step = False
db.add(models.Message(student_id=student.id, role="assistant", content=reply))
db.commit()
return schemas.ChatResponse(reply=reply, advance_lesson_step=advance_lesson_step)
@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,
)