Cards flow

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

View File

@@ -1,9 +1,11 @@
from contextlib import asynccontextmanager
import json
import os
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from sqlalchemy import inspect, text
from .database import Base, engine, get_db
from . import models, schemas
from .auth import (
@@ -40,6 +42,7 @@ from .services import (
@asynccontextmanager
async def lifespan(app: FastAPI):
Base.metadata.create_all(bind=engine)
ensure_lightweight_migrations()
db = next(get_db())
try:
seed_skills(db)
@@ -64,6 +67,21 @@ app.add_middleware(
)
def ensure_lightweight_migrations() -> None:
inspector = inspect(engine)
if "student_program_assignments" in inspector.get_table_names():
columns = {column["name"] for column in inspector.get_columns("student_program_assignments")}
with engine.begin() as connection:
if "start_asset_index" not in columns:
connection.execute(
text("ALTER TABLE student_program_assignments ADD COLUMN start_asset_index INTEGER DEFAULT 0")
)
if "start_section_index" not in columns:
connection.execute(
text("ALTER TABLE student_program_assignments ADD COLUMN start_section_index INTEGER DEFAULT 0")
)
def ensure_student_access(current_user: models.User, student_id: int) -> None:
if current_user.role in {"teacher", "maintenance"}:
return
@@ -74,7 +92,54 @@ def ensure_student_access(current_user: models.User, student_id: int) -> None:
def build_student_program_response(student: models.Student, assignment: models.StudentProgramAssignment | None):
lesson = get_lesson(assignment.lesson_id) if assignment else None
return schemas.StudentProgramResponse(student=student, lesson=lesson)
return schemas.StudentProgramResponse(
student=student,
lesson=lesson,
start_asset_index=assignment.start_asset_index if assignment else 0,
start_section_index=assignment.start_section_index if assignment else 0,
)
def clamp_lesson_position(lesson: dict, asset_index: int, section_index: int) -> tuple[int, int]:
assets = lesson.get("assets") or []
if not assets:
return 0, 0
safe_asset_index = min(max(asset_index, 0), len(assets) - 1)
sections = assets[safe_asset_index].get("sections") or []
safe_section_index = min(max(section_index, 0), max(len(sections) - 1, 0))
return safe_asset_index, safe_section_index
def upsert_card_progress(db: Session, student_id: int, state: schemas.LessonCardState) -> models.LessonCardProgress:
row = (
db.query(models.LessonCardProgress)
.filter_by(
student_id=student_id,
lesson_id=state.lesson_id,
asset_path=state.asset_path,
section_key=state.section_key,
)
.first()
)
if not row:
row = models.LessonCardProgress(
student_id=student_id,
lesson_id=state.lesson_id,
asset_path=state.asset_path,
asset_title=state.asset_title,
section_key=state.section_key,
section_title=state.section_title,
)
row.asset_title = state.asset_title
row.section_title = state.section_title
row.comprehension_score = max(row.comprehension_score or 0, state.comprehension_score)
row.attempts = max(row.attempts or 0, state.attempts)
if state.validated and row.validated_at is None:
from datetime import datetime
row.validated_at = datetime.utcnow()
db.add(row)
return row
@app.get("/health")
def health():
@@ -282,6 +347,108 @@ def assign_student_program(
return build_student_program_response(student, assignment)
@app.put("/admin/students/{student_id}/program/start", response_model=schemas.StudentProgramResponse)
def update_student_program_start(
student_id: int,
payload: schemas.StudentProgramStartRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
student = db.query(models.Student).filter_by(id=student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
if not assignment:
raise HTTPException(status_code=404, detail="Aucune leçon active")
lesson = get_lesson(assignment.lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Leçon introuvable")
asset_index, section_index = clamp_lesson_position(lesson, payload.asset_index, payload.section_index)
assignment.start_asset_index = asset_index
assignment.start_section_index = section_index
db.add(assignment)
db.commit()
db.refresh(assignment)
return build_student_program_response(student, assignment)
@app.get("/students/{student_id}/card-progress", response_model=list[schemas.LessonCardProgressRead])
def list_card_progress(
student_id: int,
lesson_id: str | None = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
query = db.query(models.LessonCardProgress).filter_by(student_id=student_id)
if lesson_id:
query = query.filter_by(lesson_id=lesson_id)
return query.order_by(models.LessonCardProgress.updated_at.desc()).all()
@app.post("/session/end", response_model=schemas.LessonSessionLogRead)
def end_lesson_session(
payload: schemas.LessonSessionEndRequest,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, payload.student_id)
student = db.query(models.Student).filter_by(id=payload.student_id).first()
if not student:
raise HTTPException(status_code=404, detail="Élève introuvable")
for state in payload.card_states:
upsert_card_progress(db, student.id, state)
conversation_json = json.dumps(
payload.conversation,
ensure_ascii=False,
)
card_results_json = json.dumps(
[state.model_dump(mode="json") for state in payload.card_states],
ensure_ascii=False,
)
log = models.LessonSessionLog(
student_id=student.id,
lesson_id=payload.lesson_id,
lesson_title=payload.lesson_title,
conversation=conversation_json,
card_results=card_results_json,
)
db.add(log)
db.commit()
db.refresh(log)
return log
@app.get("/admin/students/{student_id}/session-logs", response_model=list[schemas.LessonSessionLogRead])
def list_session_logs(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return (
db.query(models.LessonSessionLog)
.filter_by(student_id=student_id)
.order_by(models.LessonSessionLog.ended_at.desc(), models.LessonSessionLog.id.desc())
.all()
)
@app.delete("/admin/session-logs/{log_id}")
def delete_session_log(
log_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
log = db.query(models.LessonSessionLog).filter_by(id=log_id).first()
if not log:
raise HTTPException(status_code=404, detail="Log introuvable")
db.delete(log)
db.commit()
return {"status": "ok"}
@app.post("/session/start", response_model=schemas.ChatResponse)
def start_session(
student_id: int,
@@ -301,8 +468,14 @@ def start_session(
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student.id).first()
assigned_lesson = get_lesson(assignment.lesson_id) if assignment else None
if assigned_lesson:
first_asset = (assigned_lesson.get("assets") or [None])[0]
first_section = (first_asset.get("sections") or [None])[0] if first_asset else None
asset_index, section_index = clamp_lesson_position(
assigned_lesson,
assignment.start_asset_index if assignment else 0,
assignment.start_section_index if assignment else 0,
)
first_asset = (assigned_lesson.get("assets") or [None])[asset_index]
first_sections = first_asset.get("sections") or [] if first_asset else []
first_section = first_sections[section_index] if first_sections else None
section_label = first_section["title"] if first_section else first_asset["title"] if first_asset else assigned_lesson["title"]
lesson_context = (
f"Leçon courante: {assigned_lesson['title']}\n"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -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 denregistrer 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 daffecter 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 deffacer 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>

View File

@@ -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;