352 lines
12 KiB
Python
352 lines
12 KiB
Python
from contextlib import asynccontextmanager
|
|
import os
|
|
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
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 .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 eleve")
|
|
|
|
@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="Prenom 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="Eleve 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.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à."
|
|
)
|
|
previous_messages_count = db.query(models.Message).filter_by(student_id=student.id).count()
|
|
if previous_messages_count:
|
|
message = build_llm_reply(
|
|
db,
|
|
student.id,
|
|
(
|
|
"L'eleve revient pour une nouvelle seance. Fais une courte reprise: "
|
|
"explique ou on s'etait arrete la derniere fois a partir de l'historique, "
|
|
"puis annonce clairement ce qu'on va faire aujourd'hui. Termine par une "
|
|
"premiere consigne simple."
|
|
),
|
|
)
|
|
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()
|
|
|
|
reply = build_llm_reply(db, payload.student_id, payload.message)
|
|
|
|
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,
|
|
)
|