diff --git a/MEMORY.md b/MEMORY.md index 4584c81..5ea31aa 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -24,6 +24,9 @@ Ce projet est un POC de professeur virtuel pour enfants, avec: - `student` ne peut acceder qu'a son propre `student_id` - Le backend expose `POST /admin/student-accounts` pour creer une fiche eleve et son compte login en une seule operation. - Le frontend masque le choix/creation d'eleve pour un compte `student` et ouvre directement sa propre seance. +- Le backend expose `GET /students/{student_id}/messages` pour relire l'historique de conversation stocke en base. +- Les reponses aux mini-tests sont maintenant aussi enregistrees dans `messages` avec le role `user`. +- Le frontend recharge l'historique quand un eleve est selectionne et met la derniere instruction de Professeur TOP dans une zone centrale dediee. - Le vrai `docker-compose.yml` de production n'est pas dans ce repo. Il est situe un niveau au-dessus sur le serveur. - La conf nginx reelle route: - `/api/` vers `tutor-backend:8000` diff --git a/backend/app/main.py b/backend/app/main.py index e707182..f0daa92 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -155,6 +155,29 @@ def create_student_account( return schemas.StudentAccountResponse(student=student, user=user) +@app.get("/students/{student_id}/messages", response_model=list[schemas.MessageRead]) +def list_student_messages( + student_id: int, + limit: int = 80, + 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="Eleve introuvable") + + safe_limit = min(max(limit, 1), 200) + rows = ( + db.query(models.Message) + .filter_by(student_id=student_id) + .order_by(models.Message.created_at.desc(), models.Message.id.desc()) + .limit(safe_limit) + .all() + ) + return list(reversed(rows)) + + @app.post("/session/start", response_model=schemas.ChatResponse) def start_session( student_id: int, @@ -300,6 +323,13 @@ def answer_assessment( correct, feedback, mastery_score = evaluate_answer( db, payload.student_id, payload.skill_code, payload.answer ) + db.add( + models.Message( + student_id=student.id, + role="user", + content=f"Reponse au mini-test ({payload.skill_code}): {payload.answer}", + ) + ) db.add(models.Message(student_id=student.id, role="assistant", content=feedback)) db.commit() return schemas.AssessmentAnswerResponse( diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a6a6c24..5b9b2af 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, Field +from datetime import datetime from typing import List @@ -60,6 +61,17 @@ class ChatResponse(BaseModel): should_speak: bool = True +class MessageRead(BaseModel): + id: int + student_id: int + role: str + content: str + created_at: datetime + + class Config: + from_attributes = True + + class TTSProfile(BaseModel): id: str label: str diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2270bb6..3e65a25 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -225,6 +225,11 @@ function TutorApp({ currentUser, onLogout }) { [voiceProfiles, selectedVoiceProfileId] ) + const latestAssistantMessage = useMemo( + () => [...messages].reverse().find((message) => message.role === 'assistant') || null, + [messages] + ) + const averageScore = useMemo(() => { if (!progress.length) return 0 const total = progress.reduce((sum, item) => sum + item.mastery_score, 0) @@ -260,6 +265,9 @@ function TutorApp({ currentUser, onLogout }) { useEffect(() => { if (selectedStudentId) { loadProgress(selectedStudentId) + loadMessages(selectedStudentId) + } else { + setMessages([]) } }, [selectedStudentId]) @@ -409,7 +417,8 @@ function TutorApp({ currentUser, onLogout }) { answer: assessmentAnswer, }), }) - appendMessage('assistant', `Quiz: ${data.feedback}`) + appendMessage('user', `Reponse au mini-test: ${assessmentAnswer}`) + appendMessage('assistant', data.feedback) speak(data.feedback) setAssessment(null) setAssessmentAnswer('') @@ -432,6 +441,23 @@ function TutorApp({ currentUser, onLogout }) { setSpeaking(false) } + async function loadMessages(studentId) { + try { + setErrorMessage('') + const data = await apiFetch(`/students/${studentId}/messages?limit=80`) + setMessages( + (data || []).map((message) => ({ + id: message.id, + role: message.role, + content: message.content, + created_at: message.created_at, + })) + ) + } catch (error) { + setErrorMessage(error.message || 'Impossible de charger la conversation.') + } + } + async function finishSpeechPlayback(debugMessage) { setSpeaking(false) if (audioPlaybackUrlRef.current) { @@ -863,6 +889,17 @@ function TutorApp({ currentUser, onLogout }) { +
+ Derniere instruction + {latestAssistantMessage ? ( +

{latestAssistantMessage.content}

+ ) : ( +

+ Demarre la seance pour afficher ici la consigne de Professeur TOP. +

+ )} +
+
{messages.length === 0 &&

Le dialogue apparaƮtra ici.

} {messages.map((message) => ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1099038..893a10e 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -58,9 +58,32 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: #64748b; font-size: 0.95rem; } +.instruction-stage { + min-height: 180px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.75rem; + background: #eef6ff; + border: 1px solid #cfe4ff; + border-radius: 20px; + padding: 1.25rem; +} +.instruction-stage p { + margin: 0; + font-size: 1.25rem; + line-height: 1.5; + white-space: pre-wrap; +} +.stage-label { + color: #2563eb; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; +} .messages { flex: 1; - min-height: 360px; + min-height: 220px; overflow: auto; background: #f8fafc; border-radius: 20px;