725 lines
26 KiB
Python
725 lines
26 KiB
Python
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,
|
||
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 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("/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()}"
|
||
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 l’historique, "
|
||
"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:
|
||
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()}"
|
||
|
||
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,
|
||
)
|