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):

View File

@@ -0,0 +1,20 @@
# Fiche 1 - Card 2 - Exemple
Cette card montre la lecture complète du nombre `345 208`.
Idée à enseigner :
- Le nombre est déjà découpé en deux groupes : `345` et `208`.
- On lit d'abord `345` comme `trois-cent-quarante-cinq`.
- On ajoute le mot `mille`.
- Puis on lit `208` comme `deux-cent-huit`.
Consigne orale courte pour Professeur TOP :
Fais lire le nombre `345 208` en deux morceaux. Dis seulement : `345` donne `trois-cent-quarante-cinq-mille`, puis `208` donne `deux-cent-huit`. Demande à l'élève de répéter la lecture.
Limites :
- Ne reparle pas de toutes les autres cards.
- Ne change pas d'exemple.
- Ne donne pas de règle longue sur les nombres en lettres.
Critère pour passer à la card suivante :
L'élève peut passer à la suite s'il répète correctement ou presque correctement la lecture de `345 208`.

View File

@@ -0,0 +1,18 @@
# Fiche 1 - Card 3 - À retenir
Cette card résume l'ordre de lecture d'un nombre découpé en deux groupes.
Idée à enseigner :
- On lit d'abord le groupe des milliers.
- Ensuite seulement, on lit le groupe des unités simples.
- Cette règle évite de mélanger les deux parties du nombre.
Consigne orale courte pour Professeur TOP :
Fais reformuler la règle : d'abord les milliers, puis les unités simples. Demande à l'élève de dire dans quel ordre il lit les deux groupes.
Limites :
- Ne donne pas un nouvel exemple compliqué.
- Ne parle pas encore des exercices.
Critère pour passer à la card suivante :
L'élève peut passer à la suite s'il dit que l'on lit les milliers avant les unités simples.

View File

@@ -0,0 +1,18 @@
# Fiche 1 - Card 4 - Exemple 1
Cette card sert à faire lire le nombre `7 425`.
Idée à enseigner :
- `7` est le groupe des milliers.
- `425` est le groupe des unités simples.
- Le nombre se lit `sept-mille-quatre-cent-vingt-cinq`.
Consigne orale courte pour Professeur TOP :
Demande à l'élève de lire `7 425` à voix haute. S'il hésite, aide-le en deux morceaux : `sept-mille`, puis `quatre-cent-vingt-cinq`.
Limites :
- Ne donne pas les deux autres exemples.
- Ne transforme pas encore en dictée.
Critère pour passer à la card suivante :
L'élève peut passer à la suite s'il lit `7 425` correctement ou avec une petite correction guidée.

View File

@@ -0,0 +1,18 @@
# Fiche 1 - Card 5 - Exemple 2
Cette card sert à faire lire le nombre `38 090`.
Idée à enseigner :
- `38` est le groupe des milliers.
- `090` se lit comme `90`, mais le zéro aide à garder la place.
- Le nombre se lit `trente-huit-mille-quatre-vingt-dix`.
Consigne orale courte pour Professeur TOP :
Demande à l'élève de lire `38 090`. S'il oublie le zéro ou dit mal la fin, rappelle que `090` se lit `quatre-vingt-dix`.
Limites :
- Ne parle pas de tous les zéros en général.
- Reste sur le nombre affiché.
Critère pour passer à la card suivante :
L'élève peut passer à la suite s'il lit `38 090` en gardant bien `trente-huit-mille`.

View File

@@ -0,0 +1,18 @@
# Fiche 1 - Card 6 - Exemple 3
Cette card sert à faire lire le nombre `506 012`.
Idée à enseigner :
- `506` est le groupe des milliers.
- `012` se lit comme `12`, mais les zéros gardent la structure du groupe.
- Le nombre se lit `cinq-cent-six-mille-douze`.
Consigne orale courte pour Professeur TOP :
Demande à l'élève de lire `506 012`. S'il hésite, aide-le à dire `cinq-cent-six-mille`, puis `douze`.
Limites :
- Ne parle pas d'autres nombres.
- Ne donne pas une leçon complète sur les zéros.
Critère pour passer à la card suivante :
L'élève a réussi cette fiche s'il lit `506 012` correctement ou s'il corrige sa lecture après aide.

View File

@@ -60,18 +60,26 @@
</g>
<g transform="translate(110 535)">
<rect x="0" y="0" width="1350" height="220" rx="24" class="panel"/>
<text x="34" y="52" class="h2blue">Exemples r&#233;solus</text>
<g transform="translate(55 88)">
<rect x="0" y="0" width="360" height="90" rx="18" fill="#f7fbf9" stroke="#edf3f0"/>
<text x="35" y="36" class="body">7 425</text>
<text x="35" y="68" class="small">sept-mille-quatre-cent-vingt-cinq</text>
<rect x="440" y="0" width="380" height="90" rx="18" fill="#f7fbf9" stroke="#edf3f0"/>
<text x="475" y="36" class="body">38 090</text>
<text x="475" y="68" class="small">trente-huit-mille-quatre-vingt-dix</text>
<rect x="860" y="0" width="380" height="90" rx="18" fill="#f7fbf9" stroke="#edf3f0"/>
<text x="895" y="36" class="body">506 012</text>
<text x="895" y="68" class="small">cinq-cent-six-mille-douze</text>
</g>
<rect x="0" y="0" width="410" height="220" rx="24" class="panel"/>
<text x="34" y="52" class="h2blue">Exemple 1</text>
<rect x="55" y="88" width="300" height="90" rx="18" fill="#f7fbf9" stroke="#edf3f0"/>
<text x="92" y="124" class="body">7 425</text>
<text x="92" y="156" class="small">sept-mille-quatre-cent-vingt-cinq</text>
</g>
<g transform="translate(595 535)">
<rect x="0" y="0" width="410" height="220" rx="24" class="panel"/>
<text x="34" y="52" class="h2blue">Exemple 2</text>
<rect x="55" y="88" width="300" height="90" rx="18" fill="#f7fbf9" stroke="#edf3f0"/>
<text x="92" y="124" class="body">38 090</text>
<text x="92" y="156" class="small">trente-huit-mille-quatre-vingt-dix</text>
</g>
<g transform="translate(1080 535)">
<rect x="0" y="0" width="410" height="220" rx="24" class="panel"/>
<text x="34" y="52" class="h2blue">Exemple 3</text>
<rect x="55" y="88" width="300" height="90" rx="18" fill="#f7fbf9" stroke="#edf3f0"/>
<text x="92" y="124" class="body">506 012</text>
<text x="92" y="156" class="small">cinq-cent-six-mille-douze</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB