From 6ddb120c94b2b0624e4f720db241bdc22a20f294 Mon Sep 17 00:00:00 2001
From: laurentbarontini
Date: Sat, 2 May 2026 19:38:30 +0200
Subject: [PATCH] Cards flow
---
backend/app/main.py | 179 +++++++++++-
backend/app/models.py | 38 +++
backend/app/schemas.py | 59 +++-
.../Fiche1Card2.md | 20 ++
.../Fiche1Card3.md | 18 ++
.../Fiche1Card4.md | 18 ++
.../Fiche1Card5.md | 18 ++
.../Fiche1Card6.md | 18 ++
.../01_lecture_ecriture_nombres_entiers.svg | 34 ++-
frontend/src/App.jsx | 267 +++++++++++++++++-
frontend/src/styles.css | 37 +++
11 files changed, 681 insertions(+), 25 deletions(-)
create mode 100644 backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card2.md
create mode 100644 backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card3.md
create mode 100644 backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card4.md
create mode 100644 backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card5.md
create mode 100644 backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card6.md
diff --git a/backend/app/main.py b/backend/app/main.py
index ae4d843..2e19eaa 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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"
diff --git a/backend/app/models.py b/backend/app/models.py
index b2443bd..52af711 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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")
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index fef0643..e5b08ba 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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):
diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card2.md b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card2.md
new file mode 100644
index 0000000..2125b1d
--- /dev/null
+++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card2.md
@@ -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`.
diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card3.md b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card3.md
new file mode 100644
index 0000000..e163e1e
--- /dev/null
+++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card3.md
@@ -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.
diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card4.md b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card4.md
new file mode 100644
index 0000000..77059f4
--- /dev/null
+++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card4.md
@@ -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.
diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card5.md b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card5.md
new file mode 100644
index 0000000..8aa9ad7
--- /dev/null
+++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card5.md
@@ -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`.
diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card6.md b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card6.md
new file mode 100644
index 0000000..5b484c9
--- /dev/null
+++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card6.md
@@ -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.
diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/svg/01_lecture_ecriture_nombres_entiers.svg b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/svg/01_lecture_ecriture_nombres_entiers.svg
index 40bf4b1..971dfc6 100644
--- a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/svg/01_lecture_ecriture_nombres_entiers.svg
+++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/svg/01_lecture_ecriture_nombres_entiers.svg
@@ -60,18 +60,26 @@
-
- Exemples résolus
-
-
- 7 425
- sept-mille-quatre-cent-vingt-cinq
-
- 38 090
- trente-huit-mille-quatre-vingt-dix
-
- 506 012
- cinq-cent-six-mille-douze
-
+
+ Exemple 1
+
+ 7 425
+ sept-mille-quatre-cent-vingt-cinq
+
+
+
+
+ Exemple 2
+
+ 38 090
+ trente-huit-mille-quatre-vingt-dix
+
+
+
+
+ Exemple 3
+
+ 506 012
+ cinq-cent-six-mille-douze
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 741e06d..b8c64db 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -213,6 +213,10 @@ function TutorApp({ currentUser, onLogout }) {
const [studentProgram, setStudentProgram] = useState(null)
const [activeAssetIndex, setActiveAssetIndex] = useState(0)
const [activeSectionIndex, setActiveSectionIndex] = useState(0)
+ const [programStart, setProgramStart] = useState({ asset_index: 0, section_index: 0 })
+ const [cardProgress, setCardProgress] = useState([])
+ const [sessionCardStates, setSessionCardStates] = useState({})
+ const [sessionLogs, setSessionLogs] = useState([])
const [progressPanelOpen, setProgressPanelOpen] = useState(false)
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false)
const mediaRecorderRef = useRef(null)
@@ -301,12 +305,18 @@ function TutorApp({ currentUser, onLogout }) {
setExerciseStatus('not-started')
setStudentProgram(null)
setProgramLessons([])
+ setProgramStart({ asset_index: 0, section_index: 0 })
+ setCardProgress([])
+ setSessionCardStates({})
+ setSessionLogs([])
if (selectedStudentId) {
loadProgress(selectedStudentId)
loadMessages(selectedStudentId)
loadStudentProgram(selectedStudentId)
+ loadCardProgress(selectedStudentId)
if (!isStudentUser) {
loadProgramStatus()
+ loadSessionLogs(selectedStudentId)
const student = students.find((item) => String(item.id) === String(selectedStudentId))
if (student) loadProgramLessons(student.grade)
}
@@ -321,11 +331,6 @@ function TutorApp({ currentUser, onLogout }) {
}
}, [selectedVoiceProfileId])
- useEffect(() => {
- setActiveAssetIndex(0)
- setActiveSectionIndex(0)
- }, [studentProgram?.id])
-
useEffect(() => {
loadVoiceProfiles()
return () => {
@@ -334,6 +339,12 @@ function TutorApp({ currentUser, onLogout }) {
}
}, [])
+ useEffect(() => {
+ if (selectedStudentId && studentProgram?.id) {
+ loadCardProgress(selectedStudentId, studentProgram.id)
+ }
+ }, [selectedStudentId, studentProgram?.id])
+
async function loadStudents() {
if (isStudentUser) {
if (currentUser.student) {
@@ -507,7 +518,42 @@ function TutorApp({ currentUser, onLogout }) {
setSpeaking(false)
}
- function endSession() {
+ async function saveLessonSession() {
+ if (!selectedStudentId || !studentProgram) return
+ const conversation = messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ content: message.content,
+ created_at: message.created_at || null,
+ }))
+ if (currentInstruction) {
+ conversation.push({
+ id: 'current-instruction',
+ role: 'assistant',
+ content: currentInstruction,
+ created_at: null,
+ })
+ }
+ await apiFetch('/session/end', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ student_id: Number(selectedStudentId),
+ lesson_id: studentProgram.id,
+ lesson_title: studentProgram.title,
+ conversation,
+ card_states: getMergedCardStates(),
+ }),
+ })
+ await loadCardProgress(selectedStudentId, studentProgram.id)
+ }
+
+ async function endSession() {
+ try {
+ await saveLessonSession()
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible d’enregistrer la séance.')
+ }
stopSpeechPlayback()
deactivateAutoListening(false)
setSessionActive(false)
@@ -518,6 +564,7 @@ function TutorApp({ currentUser, onLogout }) {
setAssessmentAnswer('')
setInput('')
setLastStudentTransmission('')
+ setSessionCardStates({})
}
async function toggleSession() {
@@ -568,11 +615,39 @@ function TutorApp({ currentUser, onLogout }) {
try {
const data = await apiFetch(`/students/${studentId}/program`)
setStudentProgram(data.lesson || null)
+ const nextStart = {
+ asset_index: data.start_asset_index || 0,
+ section_index: data.start_section_index || 0,
+ }
+ setProgramStart(nextStart)
+ setActiveAssetIndex(nextStart.asset_index)
+ setActiveSectionIndex(nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la leçon de l’élève.')
}
}
+ async function loadCardProgress(studentId, lessonId = studentProgram?.id) {
+ if (!studentId) return
+ try {
+ const suffix = lessonId ? `?lesson_id=${encodeURIComponent(lessonId)}` : ''
+ const data = await apiFetch(`/students/${studentId}/card-progress${suffix}`)
+ setCardProgress(data || [])
+ } catch {
+ setCardProgress([])
+ }
+ }
+
+ async function loadSessionLogs(studentId) {
+ if (!studentId || isStudentUser) return
+ try {
+ const data = await apiFetch(`/admin/students/${studentId}/session-logs`)
+ setSessionLogs(data || [])
+ } catch {
+ setSessionLogs([])
+ }
+ }
+
async function assignProgramLesson(lessonId) {
if (!selectedStudentId || !lessonId) return
try {
@@ -583,11 +658,51 @@ function TutorApp({ currentUser, onLogout }) {
body: JSON.stringify({ lesson_id: lessonId }),
})
setStudentProgram(data.lesson || null)
+ const nextStart = {
+ asset_index: data.start_asset_index || 0,
+ section_index: data.start_section_index || 0,
+ }
+ setProgramStart(nextStart)
+ setActiveAssetIndex(nextStart.asset_index)
+ setActiveSectionIndex(nextStart.section_index)
} catch (error) {
setErrorMessage(error.message || 'Impossible d’affecter cette leçon.')
}
}
+ async function updateProgramStart(assetIndex, sectionIndex) {
+ if (!selectedStudentId || !studentProgram) return
+ try {
+ const data = await apiFetch(`/admin/students/${selectedStudentId}/program/start`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ asset_index: Number(assetIndex),
+ section_index: Number(sectionIndex),
+ }),
+ })
+ setStudentProgram(data.lesson || null)
+ const nextStart = {
+ asset_index: data.start_asset_index || 0,
+ section_index: data.start_section_index || 0,
+ }
+ setProgramStart(nextStart)
+ setActiveAssetIndex(nextStart.asset_index)
+ setActiveSectionIndex(nextStart.section_index)
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible de programmer la reprise.')
+ }
+ }
+
+ async function deleteSessionLog(logId) {
+ try {
+ await apiFetch(`/admin/session-logs/${logId}`, { method: 'DELETE' })
+ await loadSessionLogs(selectedStudentId)
+ } catch (error) {
+ setErrorMessage(error.message || 'Impossible d’effacer ce log.')
+ }
+ }
+
function goToPreviousLessonStep() {
if (activeSectionIndex > 0) {
setActiveSectionIndex((index) => index - 1)
@@ -612,6 +727,62 @@ function TutorApp({ currentUser, onLogout }) {
}
}
+ function getSectionKey(asset, section) {
+ return section?.context_key || `${asset?.path || 'asset'}:${section?.index ?? 0}`
+ }
+
+ function getActiveCardState(score = 0, validated = false) {
+ if (!studentProgram || !activeAsset) return null
+ const sectionTitle = activeSection?.title || activeAsset.title
+ const sectionKey = getSectionKey(activeAsset, activeSection)
+ return {
+ lesson_id: studentProgram.id,
+ asset_path: activeAsset.path,
+ asset_title: activeAsset.title,
+ section_key: sectionKey,
+ section_title: sectionTitle,
+ comprehension_score: score,
+ attempts: 1,
+ validated,
+ }
+ }
+
+ function rememberActiveCard({ score = 100, validated = true } = {}) {
+ const state = getActiveCardState(score, validated)
+ if (!state) return
+ setSessionCardStates((current) => {
+ const previous = current[state.section_key] || {}
+ return {
+ ...current,
+ [state.section_key]: {
+ ...state,
+ comprehension_score: Math.max(previous.comprehension_score || 0, state.comprehension_score),
+ attempts: Math.max(previous.attempts || 0, state.attempts),
+ validated: Boolean(previous.validated || state.validated),
+ },
+ }
+ })
+ }
+
+ function getMergedCardStates() {
+ const fromProgress = cardProgress
+ .filter((item) => item.lesson_id === studentProgram?.id)
+ .map((item) => ({
+ lesson_id: item.lesson_id,
+ asset_path: item.asset_path,
+ asset_title: item.asset_title,
+ section_key: item.section_key,
+ section_title: item.section_title,
+ comprehension_score: item.comprehension_score,
+ attempts: item.attempts,
+ validated: Boolean(item.validated_at),
+ }))
+ const merged = {}
+ for (const item of fromProgress) merged[item.section_key] = item
+ for (const item of Object.values(sessionCardStates)) merged[item.section_key] = item
+ return Object.values(merged)
+ }
+
function getLessonContextPayload() {
return {
lesson_title: activeLessonTitle,
@@ -743,7 +914,10 @@ function TutorApp({ currentUser, onLogout }) {
appendMessage('assistant', data.reply)
setCurrentInstruction(data.reply)
if (data.advance_lesson_step) {
+ rememberActiveCard({ score: 100, validated: true })
goToNextLessonStep()
+ } else {
+ rememberActiveCard({ score: 40, validated: false })
}
speak(data.reply)
}
@@ -1005,6 +1179,12 @@ function TutorApp({ currentUser, onLogout }) {
const lessonStepPercent = totalLessonSteps
? Math.min(100, Math.round((activeLessonStep / totalLessonSteps) * 100))
: 0
+ const mergedCardStates = getMergedCardStates()
+ const validatedLessonCards = mergedCardStates.filter((item) => item.validated).length
+ const lessonComprehensionPercent = totalLessonSteps
+ ? Math.min(100, Math.round((validatedLessonCards / totalLessonSteps) * 100))
+ : lessonPercent
+ const displayedLessonPercent = studentProgram ? lessonComprehensionPercent : lessonPercent
const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne séance trouvée pour le moment. Professeur TOP commencera par une reprise courte.'
const lastStudentWork = lastUserMessage?.content || 'Aucune réponse récente enregistrée.'
const nextLessonAdvice = studentProgram
@@ -1048,11 +1228,11 @@ function TutorApp({ currentUser, onLogout }) {
Avancement
- {lessonPercent}%
+ {displayedLessonPercent}%
{activeLessonTitle}
@@ -1328,6 +1508,36 @@ function TutorApp({ currentUser, onLogout }) {
? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
: 'Choisis une leçon dans la colonne de gauche.'}
+ {studentProgram?.assets?.length > 0 && (
+
+
+ Reprendre à la fiche
+ updateProgramStart(event.target.value, 0)}
+ >
+ {studentProgram.assets.map((asset, index) => (
+
+ {index + 1}. {asset.title}
+
+ ))}
+
+
+
+ Card de départ
+ updateProgramStart(programStart.asset_index, event.target.value)}
+ >
+ {(studentProgram.assets[programStart.asset_index]?.sections || []).map((section, index) => (
+
+ {index + 1}. {section.title}
+
+ ))}
+
+
+
+ )}
) : (
@@ -1351,6 +1561,47 @@ function TutorApp({ currentUser, onLogout }) {
)}
+ {selectedStudent && (
+
+ Compréhension des cards
+ {cardProgress.length ? (
+
+ {cardProgress.map((item) => (
+
+ {item.section_title}
+ {Math.round(item.comprehension_score)}% · essais: {item.attempts}
+
+ ))}
+
+ ) : (
+ Aucune card validée pour le moment.
+ )}
+
+ )}
+
+ {selectedStudent && (
+
+ Logs de séance
+ {sessionLogs.length ? (
+
+ {sessionLogs.map((log) => (
+
+
+ {log.lesson_title}
+ {new Date(log.ended_at).toLocaleString('fr-FR')}
+
+ deleteSessionLog(log.id)}>
+ Effacer
+
+
+ ))}
+
+ ) : (
+ Aucun log enregistré.
+ )}
+
+ )}
+
{errorMessage && {errorMessage}
}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 129b00a..dee726e 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -154,6 +154,19 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
background: #f8fafc;
padding: 1rem;
}
+.admin-start-controls {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.75rem;
+ margin-top: 0.9rem;
+}
+.admin-start-controls label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ font-size: 0.9rem;
+ font-weight: 700;
+}
.admin-preview {
display: flex;
flex-direction: column;
@@ -180,6 +193,30 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
border-radius: 12px;
background: white;
}
+.admin-log-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+.admin-log-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 12px;
+ background: #f8fafc;
+ padding: 0.75rem;
+}
+.admin-log-card div {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+.admin-log-card span {
+ color: #64748b;
+ font-size: 0.9rem;
+}
.session-user {
margin-left: auto;
display: flex;