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 && ( +
+ + +
+ )} ) : ( @@ -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')} +
+ +
+ ))} +
+ ) : ( +

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;