Cards flow

This commit is contained in:
2026-05-02 19:38:30 +02:00
parent 11e63f7758
commit 6ddb120c94
11 changed files with 681 additions and 25 deletions

View File

@@ -1,9 +1,11 @@
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 (
@@ -40,6 +42,7 @@ from .services import (
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
ensure_lightweight_migrations()
db = next(get_db())
try:
seed_skills(db)
@@ -64,6 +67,21 @@ app.add_middleware(
)
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
@@ -74,7 +92,54 @@ def ensure_student_access(current_user: models.User, student_id: int) -> None:
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)
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
@app.get("/health")
def health():
@@ -282,6 +347,108 @@ def assign_student_program(
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)
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,
@@ -301,8 +468,14 @@ def start_session(
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
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"

View File

@@ -78,6 +78,44 @@ class StudentProgramAssignment(Base):
title: Mapped[str] = mapped_column(String(255))
subject: Mapped[str] = mapped_column(String(100))
grade: Mapped[str] = mapped_column(String(50), index=True)
start_asset_index: Mapped[int] = mapped_column(Integer, default=0)
start_section_index: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
student = relationship("Student")
class LessonSessionLog(Base):
__tablename__ = "lesson_session_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True)
lesson_id: Mapped[str] = mapped_column(String(500), index=True)
lesson_title: Mapped[str] = mapped_column(String(255))
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
ended_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
conversation: Mapped[str] = mapped_column(Text)
card_results: Mapped[str] = mapped_column(Text)
student = relationship("Student")
class LessonCardProgress(Base):
__tablename__ = "lesson_card_progress"
__table_args__ = (
UniqueConstraint("student_id", "lesson_id", "asset_path", "section_key", name="uq_student_lesson_card"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True)
lesson_id: Mapped[str] = mapped_column(String(500), index=True)
asset_path: Mapped[str] = mapped_column(String(500), index=True)
asset_title: Mapped[str] = mapped_column(String(255))
section_key: Mapped[str] = mapped_column(String(120), index=True)
section_title: Mapped[str] = mapped_column(String(255))
comprehension_score: Mapped[float] = mapped_column(Float, default=0.0)
attempts: Mapped[int] = mapped_column(Integer, default=0)
validated_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
student = relationship("Student")

View File

@@ -1,6 +1,6 @@
from pydantic import BaseModel, Field
from datetime import datetime
from typing import List
from typing import Any, List
class StudentCreate(BaseModel):
@@ -137,6 +137,63 @@ class StudentProgramAssignRequest(BaseModel):
class StudentProgramResponse(BaseModel):
student: StudentRead
lesson: ProgramLesson | None = None
start_asset_index: int = 0
start_section_index: int = 0
class StudentProgramStartRequest(BaseModel):
asset_index: int = Field(..., ge=0)
section_index: int = Field(..., ge=0)
class LessonCardState(BaseModel):
lesson_id: str
asset_path: str
asset_title: str
section_key: str
section_title: str
comprehension_score: float = Field(..., ge=0, le=100)
attempts: int = Field(0, ge=0)
validated: bool = False
class LessonSessionEndRequest(BaseModel):
student_id: int
lesson_id: str
lesson_title: str
conversation: List[dict[str, Any]]
card_states: List[LessonCardState] = []
class LessonSessionLogRead(BaseModel):
id: int
student_id: int
lesson_id: str
lesson_title: str
started_at: datetime
ended_at: datetime
conversation: str
card_results: str
class Config:
from_attributes = True
class LessonCardProgressRead(BaseModel):
id: int
student_id: int
lesson_id: str
asset_path: str
asset_title: str
section_key: str
section_title: str
comprehension_score: float
attempts: int
validated_at: datetime | None = None
updated_at: datetime
class Config:
from_attributes = True
class AssessmentQuestionResponse(BaseModel):