admin mode

This commit is contained in:
2026-05-01 20:37:15 +02:00
parent 72a9f9d9e2
commit bfd367f3d8
8 changed files with 563 additions and 186 deletions

View File

@@ -183,6 +183,26 @@ Approche progressive recommandee:
- En cas de `502` sur `/api/*`, verifier d'abord `docker logs tutor-backend`. - En cas de `502` sur `/api/*`, verifier d'abord `docker logs tutor-backend`.
- Pour tester l'auth locale actuelle: `.env` contient `ADMIN_USERNAME=admin` et `ADMIN_PASSWORD=admin`; a changer avant tout usage partage. - Pour tester l'auth locale actuelle: `.env` contient `ADMIN_USERNAME=admin` et `ADMIN_PASSWORD=admin`; a changer avant tout usage partage.
## Note Programme Pedagogique par Eleve
- Le mode admin doit devenir une gestion du programme par eleve.
- Pour chaque eleve configure, le professeur/admin doit pouvoir choisir une lecon dans le contenu disponible localement.
- La source prioritaire du contenu est:
- `C:\DataS\OpenSquared\OpenSchool\Programme\contenus_pedagogiques`
- Le contenu est classe par cycle, niveau et matiere:
- cycle 3: CM1, CM2, 6e
- cycle 4: 5e, 4e, 3e
- Pour un eleve de CM1, l'admin doit pouvoir choisir par exemple:
- mathematiques > nombres entiers
- Les fiches `.svg` associees a la lecon choisie doivent s'afficher dans la zone centrale de Professeur TOP cote eleve.
- Premier essai implemente: une affectation active par eleve, selectionnee en admin, puis visible dans l'ecran eleve.
- A prevoir ensuite:
- plusieurs cours possibles par eleve
- progression equilibree entre lecons et matieres
- instructions personnalisees par eleve
- suivi fin des fiches vues / exercices faits
- Vigilance langue française: les libellés visibles doivent utiliser les accents et formulations françaises correctes, par exemple `Démarrer la leçon`.
## Plan de Dev Acces Eleve / Admin ## Plan de Dev Acces Eleve / Admin
1. Socle comptes et roles: table `users`, hash password, seed admin depuis `.env`, login, session courante. 1. Socle comptes et roles: table `users`, hash password, seed admin depuis `.env`, login, session courante.

View File

@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
import os import os
from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile from fastapi import Depends, FastAPI, File, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .database import Base, engine, get_db from .database import Base, engine, get_db
from . import models, schemas from . import models, schemas
@@ -15,6 +16,7 @@ from .auth import (
verify_password, verify_password,
) )
from .curriculum import QUESTIONS from .curriculum import QUESTIONS
from .program_content import decode_asset_token, get_lesson, list_lessons_for_grade, normalize_grade
from .services import ( from .services import (
build_llm_reply, build_llm_reply,
ensure_student_mastery, ensure_student_mastery,
@@ -59,7 +61,12 @@ def ensure_student_access(current_user: models.User, student_id: int) -> None:
return return
if current_user.role == "student" and current_user.student_id == student_id: if current_user.role == "student" and current_user.student_id == student_id:
return return
raise HTTPException(status_code=403, detail="Droits insuffisants pour cet eleve") raise HTTPException(status_code=403, detail="Droits insuffisants pour cet élève")
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)
@app.get("/health") @app.get("/health")
def health(): def health():
@@ -127,7 +134,7 @@ def create_student_account(
username = payload.username.strip() username = payload.username.strip()
first_name = payload.first_name.strip() first_name = payload.first_name.strip()
if not username or not first_name: if not username or not first_name:
raise HTTPException(status_code=400, detail="Prenom et identifiant obligatoires") raise HTTPException(status_code=400, detail="Prénom et identifiant obligatoires")
existing_user = db.query(models.User).filter_by(username=username).first() existing_user = db.query(models.User).filter_by(username=username).first()
if existing_user: if existing_user:
@@ -165,7 +172,7 @@ def list_student_messages(
ensure_student_access(current_user, student_id) ensure_student_access(current_user, student_id)
student = db.query(models.Student).filter_by(id=student_id).first() student = db.query(models.Student).filter_by(id=student_id).first()
if not student: if not student:
raise HTTPException(status_code=404, detail="Eleve introuvable") raise HTTPException(status_code=404, detail="Élève introuvable")
safe_limit = min(max(limit, 1), 200) safe_limit = min(max(limit, 1), 200)
rows = ( rows = (
@@ -178,6 +185,72 @@ def list_student_messages(
return list(reversed(rows)) return list(reversed(rows))
@app.get("/program/lessons", response_model=schemas.ProgramLessonListResponse)
def list_program_lessons(
grade: str,
current_user: models.User = Depends(require_roles("teacher", "maintenance")),
):
return schemas.ProgramLessonListResponse(lessons=list_lessons_for_grade(grade))
@app.get("/program/assets/{asset_token}")
def get_program_asset(
asset_token: str,
current_user: models.User = Depends(get_current_user),
):
try:
path = decode_asset_token(asset_token)
except Exception as exc:
raise HTTPException(status_code=400, detail="Ressource invalide") from exc
if not path.exists() or path.suffix.lower() != ".svg":
raise HTTPException(status_code=404, detail="Fiche introuvable")
return FileResponse(path, media_type="image/svg+xml")
@app.get("/students/{student_id}/program", response_model=schemas.StudentProgramResponse)
def get_student_program(
student_id: int,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user),
):
ensure_student_access(current_user, student_id)
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()
return build_student_program_response(student, assignment)
@app.put("/admin/students/{student_id}/program", response_model=schemas.StudentProgramResponse)
def assign_student_program(
student_id: int,
payload: schemas.StudentProgramAssignRequest,
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")
lesson = get_lesson(payload.lesson_id)
if not lesson:
raise HTTPException(status_code=404, detail="Leçon introuvable")
if lesson["grade"] != normalize_grade(student.grade):
raise HTTPException(status_code=400, detail="Cette leçon ne correspond pas au niveau de lélève")
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
if not assignment:
assignment = models.StudentProgramAssignment(student_id=student_id)
assignment.lesson_id = lesson["id"]
assignment.title = lesson["title"]
assignment.subject = lesson["subject"]
assignment.grade = lesson["grade"]
db.add(assignment)
db.commit()
db.refresh(assignment)
return build_student_program_response(student, assignment)
@app.post("/session/start", response_model=schemas.ChatResponse) @app.post("/session/start", response_model=schemas.ChatResponse)
def start_session( def start_session(
student_id: int, student_id: int,
@@ -194,18 +267,24 @@ def start_session(
f"Bonjour {student.first_name} ! Je suis Professeur TOP, ton professeur virtuel. " f"Bonjour {student.first_name} ! Je suis Professeur TOP, ton professeur virtuel. "
"Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà." "Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà."
) )
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:
message = (
f"Bonjour {student.first_name} ! Aujourdhui, on travaille la leçon "
f"{assigned_lesson['title']}. Regarde la première fiche, puis dis-moi ce que tu remarques."
)
previous_messages_count = db.query(models.Message).filter_by(student_id=student.id).count() previous_messages_count = db.query(models.Message).filter_by(student_id=student.id).count()
if previous_messages_count: if previous_messages_count:
message = build_llm_reply( prompt = (
db, "Lélève revient pour une nouvelle séance. Fais une courte reprise: "
student.id, "explique où on sétait arrêté la dernière fois à partir de lhistorique, "
( "puis annonce clairement ce qu'on va faire aujourd'hui. Termine par une "
"L'eleve revient pour une nouvelle seance. Fais une courte reprise: " "première consigne simple."
"explique ou on s'etait arrete la derniere fois a partir de l'historique, "
"puis annonce clairement ce qu'on va faire aujourd'hui. Termine par une "
"premiere consigne simple."
),
) )
if assigned_lesson:
prompt += f" La leçon active choisie par le professeur est: {assigned_lesson['title']}."
message = build_llm_reply(db, student.id, prompt)
db.add(models.Message(student_id=student.id, role="assistant", content=message)) db.add(models.Message(student_id=student.id, role="assistant", content=message))
db.commit() db.commit()
return schemas.ChatResponse(reply=message) return schemas.ChatResponse(reply=message)

View File

@@ -68,6 +68,21 @@ class StudentSkillMastery(Base):
skill = relationship("Skill") skill = relationship("Skill")
class StudentProgramAssignment(Base):
__tablename__ = "student_program_assignments"
__table_args__ = (UniqueConstraint("student_id", name="uq_student_program_assignment"),)
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)
title: Mapped[str] = mapped_column(String(255))
subject: Mapped[str] = mapped_column(String(100))
grade: Mapped[str] = mapped_column(String(50), index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
student = relationship("Student")
class AssessmentAttempt(Base): class AssessmentAttempt(Base):
__tablename__ = "assessment_attempts" __tablename__ = "assessment_attempts"

View File

@@ -0,0 +1,138 @@
import base64
import os
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
def default_content_root() -> Path:
current = Path(__file__).resolve()
candidates = [
parent / "Programme" / "contenus_pedagogiques"
for parent in current.parents
]
candidates.extend(
[
Path("/programme/contenus_pedagogiques"),
Path("/app/Programme/contenus_pedagogiques"),
]
)
return next((candidate for candidate in candidates if candidate.exists()), candidates[0])
CONTENT_ROOT = Path(os.getenv("PROGRAM_CONTENT_ROOT", default_content_root())).resolve()
GRADE_ALIASES = {
"CM1": "cm1",
"CM2": "cm2",
"6e": "6e",
"6eme": "6e",
"6ieme": "6e",
"5e": "5e",
"5eme": "5e",
"5ieme": "5e",
"4e": "4e",
"4eme": "4e",
"4ieme": "4e",
"3e": "3e",
"3eme": "3e",
"3ieme": "3e",
}
def normalize_grade(grade: str) -> str:
compact = (grade or "").strip().replace("è", "e").replace("é", "e")
return GRADE_ALIASES.get(compact, compact.lower())
def title_from_slug(slug: str) -> str:
parts = slug.split("_")
if parts and parts[0][:2].isdigit():
parts = parts[1:]
return " ".join(part.capitalize() for part in parts if part)
def encode_asset_path(relative_path: str) -> str:
payload = relative_path.encode("utf-8")
return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
def decode_asset_token(token: str) -> Path:
padding = "=" * (-len(token) % 4)
relative = base64.urlsafe_b64decode(f"{token}{padding}").decode("utf-8")
path = (CONTENT_ROOT / relative).resolve()
if CONTENT_ROOT not in path.parents:
raise ValueError("Chemin de ressource invalide")
return path
def build_asset_url(relative_path: str) -> str:
return f"/api/program/assets/{quote(encode_asset_path(relative_path))}"
@lru_cache(maxsize=1)
def list_lessons() -> list[dict]:
lessons: list[dict] = []
if not CONTENT_ROOT.exists():
return lessons
for svg_dir in sorted(CONTENT_ROOT.glob("cycle_*/*/*/**/svg")):
if not svg_dir.is_dir():
continue
svg_files = sorted(svg_dir.glob("*.svg"))
if not svg_files:
continue
lesson_dir = svg_dir.parent
relative_lesson = lesson_dir.relative_to(CONTENT_ROOT).as_posix()
parts = relative_lesson.split("/")
if len(parts) < 4:
continue
cycle, subject, grade = parts[0], parts[1], parts[2]
title = title_from_slug(lesson_dir.name)
readme = lesson_dir / "README.md"
if readme.exists():
first_heading = next(
(
line.lstrip("#").strip()
for line in readme.read_text(encoding="utf-8", errors="ignore").splitlines()
if line.startswith("#")
),
"",
)
title = first_heading or title
assets = [
{
"name": svg_path.stem,
"title": title_from_slug(svg_path.stem),
"path": svg_path.relative_to(CONTENT_ROOT).as_posix(),
"url": build_asset_url(svg_path.relative_to(CONTENT_ROOT).as_posix()),
}
for svg_path in svg_files
]
lessons.append(
{
"id": relative_lesson,
"title": title,
"subject": title_from_slug(subject),
"grade": grade,
"cycle": cycle,
"asset_count": len(assets),
"assets": assets,
}
)
return lessons
def list_lessons_for_grade(grade: str) -> list[dict]:
normalized = normalize_grade(grade)
return [lesson for lesson in list_lessons() if lesson["grade"] == normalized]
def get_lesson(lesson_id: str) -> dict | None:
return next((lesson for lesson in list_lessons() if lesson["id"] == lesson_id), None)

View File

@@ -101,6 +101,36 @@ class ProgressResponse(BaseModel):
progress: List[SkillProgress] progress: List[SkillProgress]
class ProgramAsset(BaseModel):
name: str
title: str
path: str
url: str
class ProgramLesson(BaseModel):
id: str
title: str
subject: str
grade: str
cycle: str
asset_count: int
assets: List[ProgramAsset] = []
class ProgramLessonListResponse(BaseModel):
lessons: List[ProgramLesson]
class StudentProgramAssignRequest(BaseModel):
lesson_id: str = Field(..., min_length=1)
class StudentProgramResponse(BaseModel):
student: StudentRead
lesson: ProgramLesson | None = None
class AssessmentQuestionResponse(BaseModel): class AssessmentQuestionResponse(BaseModel):
skill_code: str skill_code: str
skill_label: str skill_label: str

View File

@@ -4,6 +4,7 @@ from openai import OpenAI
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from . import models from . import models
from .curriculum import QUESTIONS, SKILLS from .curriculum import QUESTIONS, SKILLS
from .program_content import get_lesson
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
@@ -25,7 +26,7 @@ Consignes de rythme oral:
- Fais des tours de parole courts: 1 a 3 phrases maximum, sauf demande explicite. - Fais des tours de parole courts: 1 a 3 phrases maximum, sauf demande explicite.
- Termine souvent par une question ou une consigne courte pour laisser l'enfant parler. - Termine souvent par une question ou une consigne courte pour laisser l'enfant parler.
- Ne fais pas de longs monologues: une idee a la fois. - Ne fais pas de longs monologues: une idee a la fois.
- Si l'eleve t'interrompt, arrete-toi, ecoute son message, puis reprends seulement si c'est utile. - Si lélève tinterrompt, arrête-toi, écoute son message, puis reprends seulement si cest utile.
- Si l'interruption semble accidentelle ou hors sujet, reponds tres brievement puis continue. - Si l'interruption semble accidentelle ou hors sujet, reponds tres brievement puis continue.
""".strip() """.strip()
@@ -119,10 +120,17 @@ def get_student_context(db: Session, student_id: int) -> str:
.all() .all()
) )
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
lesson = get_lesson(assignment.lesson_id) if assignment else None
lines: List[str] = [ lines: List[str] = [
f"Élève: {student.first_name}, {student.age} ans, classe {student.grade}.", f"Élève: {student.first_name}, {student.age} ans, classe {student.grade}.",
"Progression par compétence:", "Progression par compétence:",
] ]
if lesson:
lines.append(
f"Leçon programme sélectionnée par le professeur: {lesson['title']} ({lesson['subject']}, {lesson['grade']})."
)
for mastery_row, skill in mastery: for mastery_row, skill in mastery:
lines.append( lines.append(
f"- {skill.label}: score={mastery_row.mastery_score:.1f}, confiance={mastery_row.confidence:.2f}, preuves={mastery_row.evidence_count}" f"- {skill.label}: score={mastery_row.mastery_score:.1f}, confiance={mastery_row.confidence:.2f}, preuves={mastery_row.evidence_count}"
@@ -188,7 +196,7 @@ def transcribe_audio(filename: str, audio_bytes: bytes, content_type: str | None
file=file_payload, file=file_payload,
language="fr", language="fr",
prompt=( prompt=(
"Transcris uniquement en francais. " "Transcris uniquement en français. "
"Le locuteur est un enfant qui repond a un exercice scolaire. " "Le locuteur est un enfant qui repond a un exercice scolaire. "
"Les phrases peuvent etre tres courtes, par exemple: nous chantons, je lis, 8 plus 5." "Les phrases peuvent etre tres courtes, par exemple: nous chantons, je lis, 8 plus 5."
), ),

View File

@@ -146,7 +146,7 @@ function LoginPage({ onLogin }) {
<Avatar speaking={false} /> <Avatar speaking={false} />
<div className="login-copy"> <div className="login-copy">
<h1>Professeur TOP</h1> <h1>Professeur TOP</h1>
<p className="muted">Connexion eleve, professeur ou maintenance.</p> <p className="muted">Connexion élève, professeur ou maintenance.</p>
</div> </div>
<form onSubmit={submitLogin} className="stack login-form"> <form onSubmit={submitLogin} className="stack login-form">
@@ -208,6 +208,8 @@ function TutorApp({ currentUser, onLogout }) {
const [audioDebug, setAudioDebug] = useState([]) const [audioDebug, setAudioDebug] = useState([])
const [voiceProfiles, setVoiceProfiles] = useState([]) const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('') const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('')
const [programLessons, setProgramLessons] = useState([])
const [studentProgram, setStudentProgram] = useState(null)
const [progressPanelOpen, setProgressPanelOpen] = useState(false) const [progressPanelOpen, setProgressPanelOpen] = useState(false)
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) const [settingsPanelOpen, setSettingsPanelOpen] = useState(false)
const mediaRecorderRef = useRef(null) const mediaRecorderRef = useRef(null)
@@ -294,13 +296,20 @@ function TutorApp({ currentUser, onLogout }) {
setLastStudentTransmission('') setLastStudentTransmission('')
setCurrentFocusSkill(null) setCurrentFocusSkill(null)
setExerciseStatus('not-started') setExerciseStatus('not-started')
setStudentProgram(null)
setProgramLessons([])
if (selectedStudentId) { if (selectedStudentId) {
loadProgress(selectedStudentId) loadProgress(selectedStudentId)
loadMessages(selectedStudentId) loadMessages(selectedStudentId)
loadStudentProgram(selectedStudentId)
if (!isStudentUser) {
const student = students.find((item) => String(item.id) === String(selectedStudentId))
if (student) loadProgramLessons(student.grade)
}
} else { } else {
setMessages([]) setMessages([])
} }
}, [selectedStudentId]) }, [selectedStudentId, students])
useEffect(() => { useEffect(() => {
if (selectedVoiceProfileId) { if (selectedVoiceProfileId) {
@@ -324,7 +333,7 @@ function TutorApp({ currentUser, onLogout }) {
} else { } else {
setStudents([]) setStudents([])
setSelectedStudentId('') setSelectedStudentId('')
setErrorMessage('Ce compte eleve n est pas encore rattache a une fiche eleve.') setErrorMessage('Ce compte élève nest pas encore rattaché à une fiche élève.')
} }
return return
} }
@@ -371,7 +380,7 @@ function TutorApp({ currentUser, onLogout }) {
return return
} }
if (!form.username.trim() || !form.password) { if (!form.username.trim() || !form.password) {
setErrorMessage('Identifiant et mot de passe eleve obligatoires.') setErrorMessage('Identifiant et mot de passe élève obligatoires.')
return return
} }
@@ -391,7 +400,7 @@ function TutorApp({ currentUser, onLogout }) {
setSelectedStudentId(String(data.student.id)) setSelectedStudentId(String(data.student.id))
setForm({ first_name: '', age: 8, grade: 'CM1', username: '', password: '' }) setForm({ first_name: '', age: 8, grade: 'CM1', username: '', password: '' })
} catch (error) { } catch (error) {
setErrorMessage(error.message || 'Impossible de creer le compte eleve.') setErrorMessage(error.message || 'Impossible de créer le compte élève.')
} }
} }
@@ -527,6 +536,40 @@ function TutorApp({ currentUser, onLogout }) {
} }
} }
async function loadProgramLessons(grade) {
if (!grade) return
try {
const data = await apiFetch(`/program/lessons?grade=${encodeURIComponent(grade)}`)
setProgramLessons(data.lessons || [])
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger le programme.')
}
}
async function loadStudentProgram(studentId) {
try {
const data = await apiFetch(`/students/${studentId}/program`)
setStudentProgram(data.lesson || null)
} catch (error) {
setErrorMessage(error.message || 'Impossible de charger la leçon de lélève.')
}
}
async function assignProgramLesson(lessonId) {
if (!selectedStudentId || !lessonId) return
try {
setErrorMessage('')
const data = await apiFetch(`/admin/students/${selectedStudentId}/program`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lesson_id: lessonId }),
})
setStudentProgram(data.lesson || null)
} catch (error) {
setErrorMessage(error.message || 'Impossible daffecter cette leçon.')
}
}
async function finishSpeechPlayback(debugMessage) { async function finishSpeechPlayback(debugMessage) {
setSpeaking(false) setSpeaking(false)
if (audioPlaybackUrlRef.current) { if (audioPlaybackUrlRef.current) {
@@ -877,11 +920,14 @@ function TutorApp({ currentUser, onLogout }) {
const lastAssistantMessage = [...messages].reverse().find((message) => message.role === 'assistant') const lastAssistantMessage = [...messages].reverse().find((message) => message.role === 'assistant')
const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user') const lastUserMessage = [...messages].reverse().find((message) => message.role === 'user')
const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne seance trouvee pour le moment. Professeur TOP commencera par une reprise courte.' const activeLessonTitle = studentProgram?.title || lessonFocus?.label || 'Cours du jour'
const lastStudentWork = lastUserMessage?.content || 'Aucune reponse recente enregistree.' const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne séance trouvée pour le moment. Professeur TOP commencera par une reprise courte.'
const nextLessonAdvice = lessonFocus const lastStudentWork = lastUserMessage?.content || 'Aucune réponse récente enregistrée.'
? `Conseil: reprendre ${lessonFocus.label}, puis lancer un exercice court pour equilibrer la progression.` const nextLessonAdvice = studentProgram
: 'Conseil: demarrer une seance pour laisser Professeur TOP choisir la prochaine lecon.' ? `Conseil: démarrer la leçon ${studentProgram.title} avec les fiches choisies par le professeur.`
: lessonFocus
? `Conseil: reprendre ${lessonFocus.label}, puis lancer un exercice court pour équilibrer la progression.`
: 'Conseil: démarrer une séance pour laisser Professeur TOP choisir la prochaine leçon.'
if (isStudentUser) { if (isStudentUser) {
return ( return (
@@ -892,26 +938,26 @@ function TutorApp({ currentUser, onLogout }) {
<div> <div>
<p className="eyebrow">Bonjour</p> <p className="eyebrow">Bonjour</p>
<h1>{selectedStudent?.first_name || currentUser.username}</h1> <h1>{selectedStudent?.first_name || currentUser.username}</h1>
<p className="muted">{selectedStudent?.grade || 'Classe non renseignee'}</p> <p className="muted">{selectedStudent?.grade || 'Classe non renseignée'}</p>
</div> </div>
<div className="student-home-actions"> <div className="student-home-actions">
<button onClick={toggleSession} disabled={!selectedStudentId}> <button onClick={toggleSession} disabled={!selectedStudentId}>
Demarrer la lecon Démarrer la leçon
</button> </button>
<button type="button" className="secondary-button" onClick={onLogout}> <button type="button" className="secondary-button" onClick={onLogout}>
Deconnexion Déconnexion
</button> </button>
</div> </div>
</section> </section>
<section className="student-home-grid"> <section className="student-home-grid">
<article className="student-info-card card"> <article className="student-info-card card">
<span className="stage-label">Derniere seance</span> <span className="stage-label">Dernière séance</span>
<p>{lessonSummary}</p> <p>{lessonSummary}</p>
</article> </article>
<article className="student-info-card card"> <article className="student-info-card card">
<span className="stage-label">Derniere reponse</span> <span className="stage-label">Dernière réponse</span>
<p>{lastStudentWork}</p> <p>{lastStudentWork}</p>
</article> </article>
@@ -920,14 +966,14 @@ function TutorApp({ currentUser, onLogout }) {
<span className="stage-label">Avancement</span> <span className="stage-label">Avancement</span>
<strong>{lessonPercent}%</strong> <strong>{lessonPercent}%</strong>
</div> </div>
<h2>{lessonFocus?.label || 'Programme a definir'}</h2> <h2>{activeLessonTitle}</h2>
<div className="progress-bar"> <div className="progress-bar">
<div className="progress-fill" style={{ width: `${lessonPercent}%` }} /> <div className="progress-fill" style={{ width: `${lessonPercent}%` }} />
</div> </div>
</article> </article>
<article className="student-info-card card advice-card"> <article className="student-info-card card advice-card">
<span className="stage-label">Prochaine lecon conseillee</span> <span className="stage-label">Prochaine leçon conseillée</span>
<p>{nextLessonAdvice}</p> <p>{nextLessonAdvice}</p>
</article> </article>
</section> </section>
@@ -940,15 +986,15 @@ function TutorApp({ currentUser, onLogout }) {
<header className="lesson-topbar"> <header className="lesson-topbar">
<Avatar speaking={speaking} /> <Avatar speaking={speaking} />
<div> <div>
<p className="eyebrow">Lecon en cours</p> <p className="eyebrow">Leçon en cours</p>
<h1>{lessonFocus?.label || 'Cours du jour'}</h1> <h1>{activeLessonTitle}</h1>
</div> </div>
<div className="lesson-actions"> <div className="lesson-actions">
<button <button
type="button" type="button"
className="icon-button" className="icon-button"
aria-label="Parametres" aria-label="Paramètres"
title="Parametres" title="Paramètres"
onClick={() => setSettingsPanelOpen(true)} onClick={() => setSettingsPanelOpen(true)}
> >
... ...
@@ -967,13 +1013,20 @@ function TutorApp({ currentUser, onLogout }) {
{currentInstruction ? ( {currentInstruction ? (
<p>{currentInstruction}</p> <p>{currentInstruction}</p>
) : ( ) : (
<p className="muted">Professeur TOP prepare la suite de la lecon.</p> <p className="muted">Professeur TOP prépare la suite de la leçon.</p>
)}
{studentProgram?.assets?.length > 0 && (
<div className="lesson-assets" aria-label="Fiches de leçon">
{studentProgram.assets.map((asset) => (
<img key={asset.path} src={asset.url} alt={asset.title} />
))}
</div>
)} )}
</section> </section>
<section className="student-transmission lesson-bubble"> <section className="student-transmission lesson-bubble">
<span>Ta reponse</span> <span>Ta réponse</span>
<p>{lastStudentTransmission || 'Dicte ou ecris ta reponse quand tu es pret.'}</p> <p>{lastStudentTransmission || 'Dicte ou écris ta réponse quand tu es prêt.'}</p>
</section> </section>
<form onSubmit={assessment ? submitAssessment : sendMessage} className="lesson-composer"> <form onSubmit={assessment ? submitAssessment : sendMessage} className="lesson-composer">
@@ -982,7 +1035,7 @@ function TutorApp({ currentUser, onLogout }) {
onChange={(event) => onChange={(event) =>
assessment ? setAssessmentAnswer(event.target.value) : setInput(event.target.value) assessment ? setAssessmentAnswer(event.target.value) : setInput(event.target.value)
} }
placeholder={assessment ? 'Ta reponse au mini-test...' : 'Ecris ta reponse...'} placeholder={assessment ? 'Ta réponse au mini-test...' : 'Écris ta réponse...'}
/> />
<button type="button" onClick={startVoiceInput} disabled={isTranscribing}> <button type="button" onClick={startVoiceInput} disabled={isTranscribing}>
{isAutoListening ? 'Stop micro' : isTranscribing ? 'Transcription...' : 'Dicter'} {isAutoListening ? 'Stop micro' : isTranscribing ? 'Transcription...' : 'Dicter'}
@@ -1014,9 +1067,9 @@ function TutorApp({ currentUser, onLogout }) {
{settingsPanelOpen && ( {settingsPanelOpen && (
<div className="progress-panel-backdrop" role="presentation" onClick={() => setSettingsPanelOpen(false)}> <div className="progress-panel-backdrop" role="presentation" onClick={() => setSettingsPanelOpen(false)}>
<aside className="progress-panel card" role="dialog" aria-label="Parametres" onClick={(event) => event.stopPropagation()}> <aside className="progress-panel card" role="dialog" aria-label="Paramètres" onClick={(event) => event.stopPropagation()}>
<div className="progress-panel-head"> <div className="progress-panel-head">
<h2>Parametres</h2> <h2>Paramètres</h2>
<button type="button" className="secondary-button" onClick={() => setSettingsPanelOpen(false)}> <button type="button" className="secondary-button" onClick={() => setSettingsPanelOpen(false)}>
Fermer Fermer
</button> </button>
@@ -1052,11 +1105,11 @@ function TutorApp({ currentUser, onLogout }) {
return ( return (
<div className={`layout ${isStudentUser ? 'student-layout' : 'admin-layout'}`}> <div className={`layout ${isStudentUser ? 'student-layout' : 'admin-layout'}`}>
<aside className="sidebar card"> <aside className="sidebar card">
<h2>{isStudentUser ? 'Ma seance' : 'Eleves'}</h2> <h2>{isStudentUser ? 'Ma séance' : 'Élèves'}</h2>
{!isStudentUser && ( {!isStudentUser && (
<> <>
<select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}> <select value={selectedStudentId} onChange={(event) => setSelectedStudentId(event.target.value)}>
<option value="">Choisir un eleve</option> <option value="">Choisir un élève</option>
{students.map((student) => ( {students.map((student) => (
<option key={student.id} value={student.id}> <option key={student.id} value={student.id}>
{student.first_name} · {student.grade} {student.first_name} · {student.grade}
@@ -1066,7 +1119,7 @@ function TutorApp({ currentUser, onLogout }) {
<form onSubmit={createStudent} className="stack"> <form onSubmit={createStudent} className="stack">
<input <input
placeholder="Prenom" placeholder="Prénom"
value={form.first_name} value={form.first_name}
onChange={(event) => setForm({ ...form, first_name: event.target.value })} onChange={(event) => setForm({ ...form, first_name: event.target.value })}
/> />
@@ -1084,171 +1137,99 @@ function TutorApp({ currentUser, onLogout }) {
<option>6e</option> <option>6e</option>
</select> </select>
<input <input
placeholder="Identifiant eleve" placeholder="Identifiant élève"
value={form.username} value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })} onChange={(event) => setForm({ ...form, username: event.target.value })}
/> />
<input <input
placeholder="Mot de passe eleve" placeholder="Mot de passe élève"
type="password" type="password"
value={form.password} value={form.password}
onChange={(event) => setForm({ ...form, password: event.target.value })} onChange={(event) => setForm({ ...form, password: event.target.value })}
/> />
<button type="submit">Creer le compte eleve</button> <button type="submit">Créer le compte élève</button>
</form> </form>
{selectedStudent && (
<section className="admin-program-panel">
<h3>Programme de lélève</h3>
<p className="muted">
{studentProgram
? `Leçon active: ${studentProgram.title}`
: 'Aucune leçon active pour cet élève.'}
</p>
<select
value={studentProgram?.id || ''}
onChange={(event) => assignProgramLesson(event.target.value)}
>
<option value="">Choisir une leçon pour {selectedStudent.grade}</option>
{programLessons.map((lesson) => (
<option key={lesson.id} value={lesson.id}>
{lesson.subject} · {lesson.title} · {lesson.asset_count} fiche(s)
</option>
))}
</select>
</section>
)}
</> </>
)} )}
{isStudentUser && selectedStudent && ( <button type="button" className="secondary-button" onClick={onLogout}>
<div className="session-summary"> Déconnexion
<strong>{selectedStudent.first_name}</strong> </button>
<span>{selectedStudent.grade}</span>
<span>{sessionActive ? 'Seance en cours' : 'Pret pour la seance'}</span>
</div>
)}
{isStudentUser && (
<div className="today-panel">
<button onClick={toggleSession} disabled={!selectedStudentId}>
{sessionActive ? 'Fin de la seance' : 'Demarrer la seance'}
</button>
<h3>Themes du jour</h3>
<div className="today-card lesson-card">
<div className="today-card-head">
<strong>Lecon</strong>
<span>{lessonPercent}% acquis</span>
</div>
<p>{lessonFocus?.label || 'Le sujet sera choisi au demarrage.'}</p>
</div>
<div className="today-card exercise-card">
<div className="today-card-head">
<strong>Exercice</strong>
<span>{exerciseLabel}</span>
</div>
<p>{assessment?.skill_label || lessonFocus?.label || 'Aucun exercice lance.'}</p>
</div>
</div>
)}
<button onClick={startSession} disabled={!selectedStudentId}>Démarrer la séance</button>
<button onClick={fetchAssessment} disabled={!selectedStudentId}>Lancer un mini-test</button>
<h3>Progression</h3>
<div className="stack small-gap">
{progress.map((item) => <ProgressCard key={item.code} item={item} />)}
</div>
</aside> </aside>
<main className="main card"> <main className="admin-main card">
<div className="hero"> <header className="admin-header">
<Avatar speaking={speaking} />
<div> <div>
<h1>Professeur virtuel</h1> <h1>Gestion du programme</h1>
<p>
{selectedStudent
? `Séance active pour ${selectedStudent.first_name}, ${selectedStudent.grade}`
: 'Choisis ou crée un élève pour commencer.'}
</p>
</div>
<div className="session-user">
<span>{currentUser.username} · {currentUser.role}</span>
<button type="button" className="secondary-button" onClick={onLogout}>
Deconnexion
</button>
</div>
</div>
<section className="instruction-stage">
<span className="stage-label">Cours en cours</span>
{currentInstruction ? (
<p>{currentInstruction}</p>
) : (
<p className="muted"> <p className="muted">
Demarre la seance pour que Professeur TOP reprenne ou tu t'etais arrete et lance le cours du jour. Configure les élèves, leur niveau et la leçon active visible dans le mode élève.
</p> </p>
)} </div>
</section> <span>{currentUser.username} · {currentUser.role}</span>
</header>
<section className="messages" aria-label="Journal compact de la seance"> {selectedStudent ? (
{messages.length === 0 && <p className="muted">Le dialogue apparaîtra ici.</p>} <section className="admin-detail">
{messages.map((message) => ( <div className="admin-student-card">
<div key={message.id} className={`message ${message.role}`}> <span className="stage-label">Élève sélectionné</span>
<strong>{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}</strong> <h2>{selectedStudent.first_name}</h2>
<p>{message.content}</p> <p>{selectedStudent.grade} · {selectedStudent.age} ans</p>
</div> </div>
))}
</section>
{assessment && ( <div className="admin-student-card">
<form className="assessment" onSubmit={submitAssessment}> <span className="stage-label">Leçon active</span>
<h3>Mini-test · {assessment.skill_label}</h3> <h2>{studentProgram?.title || 'Aucune leçon'}</h2>
<p>{assessment.question}</p> <p>
<input {studentProgram
value={assessmentAnswer} ? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
onChange={(event) => setAssessmentAnswer(event.target.value)} : 'Choisis une leçon dans la colonne de gauche.'}
placeholder="Ta réponse" </p>
/> </div>
<button type="submit">Valider</button> </section>
</form> ) : (
<section className="admin-empty">
<h2>Aucun élève sélectionné</h2>
<p className="muted">Choisis un élève ou crée un compte pour configurer son programme.</p>
</section>
)} )}
<section className="student-transmission"> {studentProgram?.assets?.length > 0 && (
<span>Transmis au prof</span> <section className="admin-preview">
<p>{lastStudentTransmission || 'Le texte reconnu par le micro ou envoye au clavier apparaitra ici.'}</p> <h2>Fiches de la leçon</h2>
</section> <div className="admin-asset-grid">
{studentProgram.assets.map((asset) => (
<article key={asset.path} className="admin-asset-card">
<img src={asset.url} alt={asset.title} />
<strong>{asset.title}</strong>
</article>
))}
</div>
</section>
)}
<form onSubmit={assessment ? submitAssessment : sendMessage} className="composer"> {errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
<input
value={assessment ? assessmentAnswer : input}
onChange={(event) =>
assessment ? setAssessmentAnswer(event.target.value) : setInput(event.target.value)
}
placeholder={assessment ? 'Ta réponse au mini-test...' : 'Pose une question ou demande une explication...'}
/>
<select
value={selectedVoiceProfileId}
onChange={(event) => setSelectedVoiceProfileId(event.target.value)}
title="Choisir la personnalité vocale de Professeur TOP"
>
<option value="">Voix de Professeur TOP</option>
{voiceProfiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{formatVoiceLabel(profile)}
</option>
))}
</select>
{selectedVoiceProfile && (
<span
className="voice-tooltip"
title={`Voix IA non humaine: ${formatVoiceLabel(selectedVoiceProfile)} - ${selectedVoiceProfile.description}`}
aria-label={`Voix IA non humaine: ${formatVoiceLabel(selectedVoiceProfile)} - ${selectedVoiceProfile.description}`}
>
i
</span>
)}
<button type="button" onClick={startVoiceInput} disabled={isTranscribing}>
{isAutoListening ? 'Couper le micro' : isTranscribing ? 'Transcription...' : 'Micro auto'}
</button>
<button type="submit">Envoyer</button>
</form>
{isAutoListening && (
<div className="voice-status">
Niveau micro: {Math.round(Math.min(micLevel * 1200, 100))}%
</div>
)}
{selectedVoiceProfile && (
<p className="muted voice-note">
Voix IA non humaine: {selectedVoiceProfile.label} · {selectedVoiceProfile.description}
</p>
)}
{voiceStatus && <p className="muted voice-status">{voiceStatus}</p>}
{errorMessage && <p className="muted voice-status">{errorMessage}</p>}
{DEBUG_AUDIO && (
<div className="voice-status">
{audioDebug.length === 0 ? 'Debug audio: aucune trace' : `Debug audio: ${audioDebug.join(' | ')}`}
</div>
)}
</main> </main>
</div> </div>
) )

View File

@@ -96,6 +96,85 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.today-card p { .today-card p {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
.admin-program-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
border: 1px solid #dbeafe;
border-radius: 16px;
background: #f8fbff;
padding: 0.85rem;
}
.admin-program-panel h3,
.admin-program-panel p {
margin: 0;
}
.admin-main {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 0;
overflow-y: auto;
}
.admin-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
.admin-header h1,
.admin-header p,
.admin-detail h2,
.admin-detail p,
.admin-empty h2,
.admin-empty p,
.admin-preview h2 {
margin: 0;
}
.admin-header span {
color: #64748b;
white-space: nowrap;
}
.admin-detail {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.admin-student-card,
.admin-empty {
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #f8fafc;
padding: 1rem;
}
.admin-preview {
display: flex;
flex-direction: column;
gap: 1rem;
}
.admin-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.admin-asset-card {
display: flex;
flex-direction: column;
gap: 0.6rem;
border: 1px solid #e5e7eb;
border-radius: 16px;
background: #f8fafc;
padding: 0.75rem;
}
.admin-asset-card img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
border-radius: 12px;
background: white;
}
.session-user { .session-user {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -382,6 +461,24 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: clamp(1rem, 2.2vw, 1.35rem); font-size: clamp(1rem, 2.2vw, 1.35rem);
line-height: 1.5; line-height: 1.5;
} }
.lesson-assets {
min-height: 0;
flex: 1 1 auto;
display: flex;
gap: 0.75rem;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.25rem;
}
.lesson-assets img {
width: min(100%, 720px);
max-height: 100%;
flex: 0 0 min(100%, 720px);
object-fit: contain;
border: 1px solid #dbeafe;
border-radius: 14px;
background: white;
}
.lesson-bubble { .lesson-bubble {
min-height: 68px; min-height: 68px;
} }
@@ -471,6 +568,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
justify-content: space-between; justify-content: space-between;
} }
.admin-header,
.admin-detail {
grid-template-columns: 1fr;
}
.admin-header {
flex-direction: column;
}
.instruction-stage { .instruction-stage {
flex: 0 0 auto; flex: 0 0 auto;
justify-content: flex-start; justify-content: flex-start;