Cards flow
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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é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 |
@@ -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 }) {
|
||||
<article className="student-info-card card">
|
||||
<div className="today-card-head">
|
||||
<span className="stage-label">Avancement</span>
|
||||
<strong>{lessonPercent}%</strong>
|
||||
<strong>{displayedLessonPercent}%</strong>
|
||||
</div>
|
||||
<h2>{activeLessonTitle}</h2>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${lessonPercent}%` }} />
|
||||
<div className="progress-fill" style={{ width: `${displayedLessonPercent}%` }} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1328,6 +1508,36 @@ function TutorApp({ currentUser, onLogout }) {
|
||||
? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
|
||||
: 'Choisis une leçon dans la colonne de gauche.'}
|
||||
</p>
|
||||
{studentProgram?.assets?.length > 0 && (
|
||||
<div className="admin-start-controls">
|
||||
<label>
|
||||
<span>Reprendre à la fiche</span>
|
||||
<select
|
||||
value={programStart.asset_index}
|
||||
onChange={(event) => updateProgramStart(event.target.value, 0)}
|
||||
>
|
||||
{studentProgram.assets.map((asset, index) => (
|
||||
<option key={asset.path} value={index}>
|
||||
{index + 1}. {asset.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Card de départ</span>
|
||||
<select
|
||||
value={programStart.section_index}
|
||||
onChange={(event) => updateProgramStart(programStart.asset_index, event.target.value)}
|
||||
>
|
||||
{(studentProgram.assets[programStart.asset_index]?.sections || []).map((section, index) => (
|
||||
<option key={section.context_key || index} value={index}>
|
||||
{index + 1}. {section.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
@@ -1351,6 +1561,47 @@ function TutorApp({ currentUser, onLogout }) {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedStudent && (
|
||||
<section className="admin-preview">
|
||||
<h2>Compréhension des cards</h2>
|
||||
{cardProgress.length ? (
|
||||
<div className="admin-log-list">
|
||||
{cardProgress.map((item) => (
|
||||
<article key={item.id} className="admin-log-card">
|
||||
<strong>{item.section_title}</strong>
|
||||
<span>{Math.round(item.comprehension_score)}% · essais: {item.attempts}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">Aucune card validée pour le moment.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{selectedStudent && (
|
||||
<section className="admin-preview">
|
||||
<h2>Logs de séance</h2>
|
||||
{sessionLogs.length ? (
|
||||
<div className="admin-log-list">
|
||||
{sessionLogs.map((log) => (
|
||||
<article key={log.id} className="admin-log-card">
|
||||
<div>
|
||||
<strong>{log.lesson_title}</strong>
|
||||
<span>{new Date(log.ended_at).toLocaleString('fr-FR')}</span>
|
||||
</div>
|
||||
<button type="button" className="secondary-button" onClick={() => deleteSessionLog(log.id)}>
|
||||
Effacer
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">Aucun log enregistré.</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user