last message

This commit is contained in:
2026-04-25 20:48:31 +02:00
parent c420d539da
commit fadd28439e
5 changed files with 107 additions and 2 deletions

View File

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

View File

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

View File

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

View File

@@ -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 }) {
</div>
</div>
<section className="instruction-stage">
<span className="stage-label">Derniere instruction</span>
{latestAssistantMessage ? (
<p>{latestAssistantMessage.content}</p>
) : (
<p className="muted">
Demarre la seance pour afficher ici la consigne de Professeur TOP.
</p>
)}
</section>
<section className="messages">
{messages.length === 0 && <p className="muted">Le dialogue apparaîtra ici.</p>}
{messages.map((message) => (

View File

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