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`.
- 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
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
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 .database import Base, engine, get_db
from . import models, schemas
@@ -15,6 +16,7 @@ from .auth import (
verify_password,
)
from .curriculum import QUESTIONS
from .program_content import decode_asset_token, get_lesson, list_lessons_for_grade, normalize_grade
from .services import (
build_llm_reply,
ensure_student_mastery,
@@ -59,7 +61,12 @@ def ensure_student_access(current_user: models.User, student_id: int) -> None:
return
if current_user.role == "student" and current_user.student_id == student_id:
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")
def health():
@@ -127,7 +134,7 @@ def create_student_account(
username = payload.username.strip()
first_name = payload.first_name.strip()
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()
if existing_user:
@@ -165,7 +172,7 @@ def list_student_messages(
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="Eleve introuvable")
raise HTTPException(status_code=404, detail="Élève introuvable")
safe_limit = min(max(limit, 1), 200)
rows = (
@@ -178,6 +185,72 @@ def list_student_messages(
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)
def start_session(
student_id: int,
@@ -194,18 +267,24 @@ def start_session(
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à."
)
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()
if previous_messages_count:
message = build_llm_reply(
db,
student.id,
(
"L'eleve revient pour une nouvelle seance. Fais une courte reprise: "
"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."
),
prompt = (
"Lélève revient pour une nouvelle séance. Fais une courte reprise: "
"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 "
"première 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.commit()
return schemas.ChatResponse(reply=message)

View File

@@ -68,6 +68,21 @@ class StudentSkillMastery(Base):
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):
__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]
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):
skill_code: str
skill_label: str

View File

@@ -4,6 +4,7 @@ from openai import OpenAI
from sqlalchemy.orm import Session
from . import models
from .curriculum import QUESTIONS, SKILLS
from .program_content import get_lesson
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.
- 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.
- 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.
""".strip()
@@ -119,10 +120,17 @@ def get_student_context(db: Session, student_id: int) -> str:
.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] = [
f"Élève: {student.first_name}, {student.age} ans, classe {student.grade}.",
"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:
lines.append(
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,
language="fr",
prompt=(
"Transcris uniquement en francais. "
"Transcris uniquement en français. "
"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."
),

View File

@@ -146,7 +146,7 @@ function LoginPage({ onLogin }) {
<Avatar speaking={false} />
<div className="login-copy">
<h1>Professeur TOP</h1>
<p className="muted">Connexion eleve, professeur ou maintenance.</p>
<p className="muted">Connexion élève, professeur ou maintenance.</p>
</div>
<form onSubmit={submitLogin} className="stack login-form">
@@ -208,6 +208,8 @@ function TutorApp({ currentUser, onLogout }) {
const [audioDebug, setAudioDebug] = useState([])
const [voiceProfiles, setVoiceProfiles] = useState([])
const [selectedVoiceProfileId, setSelectedVoiceProfileId] = useState('')
const [programLessons, setProgramLessons] = useState([])
const [studentProgram, setStudentProgram] = useState(null)
const [progressPanelOpen, setProgressPanelOpen] = useState(false)
const [settingsPanelOpen, setSettingsPanelOpen] = useState(false)
const mediaRecorderRef = useRef(null)
@@ -294,13 +296,20 @@ function TutorApp({ currentUser, onLogout }) {
setLastStudentTransmission('')
setCurrentFocusSkill(null)
setExerciseStatus('not-started')
setStudentProgram(null)
setProgramLessons([])
if (selectedStudentId) {
loadProgress(selectedStudentId)
loadMessages(selectedStudentId)
loadStudentProgram(selectedStudentId)
if (!isStudentUser) {
const student = students.find((item) => String(item.id) === String(selectedStudentId))
if (student) loadProgramLessons(student.grade)
}
} else {
setMessages([])
}
}, [selectedStudentId])
}, [selectedStudentId, students])
useEffect(() => {
if (selectedVoiceProfileId) {
@@ -324,7 +333,7 @@ function TutorApp({ currentUser, onLogout }) {
} else {
setStudents([])
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
}
@@ -371,7 +380,7 @@ function TutorApp({ currentUser, onLogout }) {
return
}
if (!form.username.trim() || !form.password) {
setErrorMessage('Identifiant et mot de passe eleve obligatoires.')
setErrorMessage('Identifiant et mot de passe élève obligatoires.')
return
}
@@ -391,7 +400,7 @@ function TutorApp({ currentUser, onLogout }) {
setSelectedStudentId(String(data.student.id))
setForm({ first_name: '', age: 8, grade: 'CM1', username: '', password: '' })
} 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) {
setSpeaking(false)
if (audioPlaybackUrlRef.current) {
@@ -877,11 +920,14 @@ function TutorApp({ currentUser, onLogout }) {
const lastAssistantMessage = [...messages].reverse().find((message) => message.role === 'assistant')
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 lastStudentWork = lastUserMessage?.content || 'Aucune reponse recente enregistree.'
const nextLessonAdvice = lessonFocus
? `Conseil: reprendre ${lessonFocus.label}, puis lancer un exercice court pour equilibrer la progression.`
: 'Conseil: demarrer une seance pour laisser Professeur TOP choisir la prochaine lecon.'
const activeLessonTitle = studentProgram?.title || lessonFocus?.label || 'Cours du jour'
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
? `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) {
return (
@@ -892,26 +938,26 @@ function TutorApp({ currentUser, onLogout }) {
<div>
<p className="eyebrow">Bonjour</p>
<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 className="student-home-actions">
<button onClick={toggleSession} disabled={!selectedStudentId}>
Demarrer la lecon
Démarrer la leçon
</button>
<button type="button" className="secondary-button" onClick={onLogout}>
Deconnexion
Déconnexion
</button>
</div>
</section>
<section className="student-home-grid">
<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>
</article>
<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>
</article>
@@ -920,14 +966,14 @@ function TutorApp({ currentUser, onLogout }) {
<span className="stage-label">Avancement</span>
<strong>{lessonPercent}%</strong>
</div>
<h2>{lessonFocus?.label || 'Programme a definir'}</h2>
<h2>{activeLessonTitle}</h2>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${lessonPercent}%` }} />
</div>
</article>
<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>
</article>
</section>
@@ -940,15 +986,15 @@ function TutorApp({ currentUser, onLogout }) {
<header className="lesson-topbar">
<Avatar speaking={speaking} />
<div>
<p className="eyebrow">Lecon en cours</p>
<h1>{lessonFocus?.label || 'Cours du jour'}</h1>
<p className="eyebrow">Leçon en cours</p>
<h1>{activeLessonTitle}</h1>
</div>
<div className="lesson-actions">
<button
type="button"
className="icon-button"
aria-label="Parametres"
title="Parametres"
aria-label="Paramètres"
title="Paramètres"
onClick={() => setSettingsPanelOpen(true)}
>
...
@@ -967,13 +1013,20 @@ function TutorApp({ currentUser, onLogout }) {
{currentInstruction ? (
<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 className="student-transmission lesson-bubble">
<span>Ta reponse</span>
<p>{lastStudentTransmission || 'Dicte ou ecris ta reponse quand tu es pret.'}</p>
<span>Ta réponse</span>
<p>{lastStudentTransmission || 'Dicte ou écris ta réponse quand tu es prêt.'}</p>
</section>
<form onSubmit={assessment ? submitAssessment : sendMessage} className="lesson-composer">
@@ -982,7 +1035,7 @@ function TutorApp({ currentUser, onLogout }) {
onChange={(event) =>
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}>
{isAutoListening ? 'Stop micro' : isTranscribing ? 'Transcription...' : 'Dicter'}
@@ -1014,9 +1067,9 @@ function TutorApp({ currentUser, onLogout }) {
{settingsPanelOpen && (
<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">
<h2>Parametres</h2>
<h2>Paramètres</h2>
<button type="button" className="secondary-button" onClick={() => setSettingsPanelOpen(false)}>
Fermer
</button>
@@ -1052,11 +1105,11 @@ function TutorApp({ currentUser, onLogout }) {
return (
<div className={`layout ${isStudentUser ? 'student-layout' : 'admin-layout'}`}>
<aside className="sidebar card">
<h2>{isStudentUser ? 'Ma seance' : 'Eleves'}</h2>
<h2>{isStudentUser ? 'Ma séance' : 'Élèves'}</h2>
{!isStudentUser && (
<>
<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) => (
<option key={student.id} value={student.id}>
{student.first_name} · {student.grade}
@@ -1066,7 +1119,7 @@ function TutorApp({ currentUser, onLogout }) {
<form onSubmit={createStudent} className="stack">
<input
placeholder="Prenom"
placeholder="Prénom"
value={form.first_name}
onChange={(event) => setForm({ ...form, first_name: event.target.value })}
/>
@@ -1084,171 +1137,99 @@ function TutorApp({ currentUser, onLogout }) {
<option>6e</option>
</select>
<input
placeholder="Identifiant eleve"
placeholder="Identifiant élève"
value={form.username}
onChange={(event) => setForm({ ...form, username: event.target.value })}
/>
<input
placeholder="Mot de passe eleve"
placeholder="Mot de passe élève"
type="password"
value={form.password}
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>
{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 && (
<div className="session-summary">
<strong>{selectedStudent.first_name}</strong>
<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>
<button type="button" className="secondary-button" onClick={onLogout}>
Déconnexion
</button>
</aside>
<main className="main card">
<div className="hero">
<Avatar speaking={speaking} />
<main className="admin-main card">
<header className="admin-header">
<div>
<h1>Professeur virtuel</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>
) : (
<h1>Gestion du programme</h1>
<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>
)}
</section>
</div>
<span>{currentUser.username} · {currentUser.role}</span>
</header>
<section className="messages" aria-label="Journal compact de la seance">
{messages.length === 0 && <p className="muted">Le dialogue apparaîtra ici.</p>}
{messages.map((message) => (
<div key={message.id} className={`message ${message.role}`}>
<strong>{message.role === 'assistant' ? 'Professeur TOP' : 'Élève'}</strong>
<p>{message.content}</p>
{selectedStudent ? (
<section className="admin-detail">
<div className="admin-student-card">
<span className="stage-label">Élève sélectionné</span>
<h2>{selectedStudent.first_name}</h2>
<p>{selectedStudent.grade} · {selectedStudent.age} ans</p>
</div>
))}
</section>
{assessment && (
<form className="assessment" onSubmit={submitAssessment}>
<h3>Mini-test · {assessment.skill_label}</h3>
<p>{assessment.question}</p>
<input
value={assessmentAnswer}
onChange={(event) => setAssessmentAnswer(event.target.value)}
placeholder="Ta réponse"
/>
<button type="submit">Valider</button>
</form>
<div className="admin-student-card">
<span className="stage-label">Leçon active</span>
<h2>{studentProgram?.title || 'Aucune leçon'}</h2>
<p>
{studentProgram
? `${studentProgram.subject} · ${studentProgram.asset_count} fiche(s)`
: 'Choisis une leçon dans la colonne de gauche.'}
</p>
</div>
</section>
) : (
<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">
<span>Transmis au prof</span>
<p>{lastStudentTransmission || 'Le texte reconnu par le micro ou envoye au clavier apparaitra ici.'}</p>
</section>
{studentProgram?.assets?.length > 0 && (
<section className="admin-preview">
<h2>Fiches de la leçon</h2>
<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">
<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>
)}
{errorMessage && <p className="muted voice-status visible-status">{errorMessage}</p>}
</main>
</div>
)

View File

@@ -96,6 +96,85 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
.today-card p {
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 {
margin-left: auto;
display: flex;
@@ -382,6 +461,24 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: clamp(1rem, 2.2vw, 1.35rem);
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 {
min-height: 68px;
}
@@ -471,6 +568,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
justify-content: space-between;
}
.admin-header,
.admin-detail {
grid-template-columns: 1fr;
}
.admin-header {
flex-direction: column;
}
.instruction-stage {
flex: 0 0 auto;
justify-content: flex-start;