commit 887e9919a1cb2802e05b7dda48ae78f000b63ece Author: root Date: Sun Apr 5 07:35:28 2026 +0000 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..9ebb048 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +OPENAI_API_KEY=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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ed6ed73 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=sk-... diff --git a/README.md b/README.md new file mode 100644 index 0000000..d541f69 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# POC professeur virtuel (8-12 ans) + +Ce POC propose : +- une page web avec avatar animé +- un chat pédagogique en français +- une mémoire élève persistée dans PostgreSQL +- un mini diagnostic adaptatif sur quelques compétences du programme français +- une API FastAPI branchée sur OpenAI via la Responses API +- des conteneurs Docker pour le frontend, le backend, PostgreSQL et Redis + +## Lancer le projet + +1. Copier le fichier d'environnement : + +```bash +cp .env.example .env +``` + +2. Ajouter votre clé OpenAI dans `.env`. + +3. Lancer : + +```bash +docker compose up --build +``` + +4. Ouvrir : +- Frontend : http://localhost:3000 +- API : http://localhost:8000/docs + +## Parcours de démo + +- Choisir un élève ou en créer un nouveau +- Cliquer sur "Démarrer la séance" +- Poser une question ou répondre au quiz +- Regarder la progression se mettre à jour +- L'avatar lit les réponses via la synthèse vocale du navigateur + +## Limites du POC + +- l'avatar est volontairement simple (SVG/CSS) pour rester 100 % web +- la voix entrante utilise le navigateur via Web Speech API quand disponible +- la progression couvre seulement quelques micro-compétences pour la démo +- pas encore de dashboard parent ni de conformité RGPD/CNIL complète + +## Architecture + +- `frontend/` : React + Vite +- `backend/` : FastAPI + SQLAlchemy + OpenAI SDK +- `postgres` : mémoire structurée élève +- `redis` : réservé au cache / file d'événements dans la suite + +## Extensions conseillées + +- remplacer l'avatar SVG par un avatar VRM +- passer en audio temps réel avec Realtime API + WebRTC +- enrichir le référentiel avec tout le programme national français +- ajouter un moteur de révision espacée diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..03e61ab --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/app/__pycache__/curriculum.cpython-313.pyc b/backend/app/__pycache__/curriculum.cpython-313.pyc new file mode 100644 index 0000000..773605b Binary files /dev/null and b/backend/app/__pycache__/curriculum.cpython-313.pyc differ diff --git a/backend/app/__pycache__/database.cpython-313.pyc b/backend/app/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000..72d85ae Binary files /dev/null and b/backend/app/__pycache__/database.cpython-313.pyc differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..969dd21 Binary files /dev/null and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..66e37e4 Binary files /dev/null and b/backend/app/__pycache__/models.cpython-313.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-313.pyc b/backend/app/__pycache__/schemas.cpython-313.pyc new file mode 100644 index 0000000..5a728ad Binary files /dev/null and b/backend/app/__pycache__/schemas.cpython-313.pyc differ diff --git a/backend/app/__pycache__/services.cpython-313.pyc b/backend/app/__pycache__/services.cpython-313.pyc new file mode 100644 index 0000000..175f5fb Binary files /dev/null and b/backend/app/__pycache__/services.cpython-313.pyc differ diff --git a/backend/app/curriculum.py b/backend/app/curriculum.py new file mode 100644 index 0000000..7b247b4 --- /dev/null +++ b/backend/app/curriculum.py @@ -0,0 +1,53 @@ +SKILLS = [ + { + "code": "math_addition_posee", + "subject": "Mathématiques", + "label": "Addition posée à deux chiffres", + "description": "Savoir additionner des nombres entiers à deux chiffres.", + }, + { + "code": "math_tables_x3_x4", + "subject": "Mathématiques", + "label": "Tables de multiplication 3 et 4", + "description": "Connaître et utiliser les tables de 3 et de 4.", + }, + { + "code": "fr_conjugaison_present", + "subject": "Français", + "label": "Présent des verbes du 1er groupe", + "description": "Conjuguer un verbe du premier groupe au présent.", + }, + { + "code": "fr_nature_mots", + "subject": "Français", + "label": "Identifier nom, verbe et adjectif", + "description": "Reconnaître la nature simple de mots dans une phrase.", + }, +] + +QUESTIONS = { + "math_addition_posee": { + "question": "Calcule 27 + 35.", + "expected_answer": "62", + "feedback_ok": "Bravo, 27 + 35 = 62. Tu as bien additionné les dizaines et les unités.", + "feedback_ko": "La bonne réponse était 62. Pense à additionner d'abord les unités puis les dizaines.", + }, + "math_tables_x3_x4": { + "question": "Combien font 4 × 6 ?", + "expected_answer": "24", + "feedback_ok": "Oui, 4 fois 6 font 24. Très bien.", + "feedback_ko": "La bonne réponse était 24. Tu peux réciter la table de 4 : 4, 8, 12, 16, 20, 24.", + }, + "fr_conjugaison_present": { + "question": "Conjugue le verbe 'chanter' avec 'nous' au présent.", + "expected_answer": "nous chantons", + "feedback_ok": "Très bien, on dit bien 'nous chantons'.", + "feedback_ko": "La bonne réponse était 'nous chantons'. Avec 'nous', beaucoup de verbes du 1er groupe finissent par -ons.", + }, + "fr_nature_mots": { + "question": "Dans la phrase 'Le chat noir dort', quel est l'adjectif ?", + "expected_answer": "noir", + "feedback_ok": "Oui, 'noir' décrit le chat, c'est donc l'adjectif.", + "feedback_ko": "La bonne réponse était 'noir'. Un adjectif donne une précision sur le nom.", + }, +} diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..765cf84 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,17 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import declarative_base, sessionmaker + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./local.db") + +engine = create_engine(DATABASE_URL, pool_pre_ping=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..f10e1a5 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,144 @@ +from contextlib import asynccontextmanager +from fastapi import Depends, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from .database import Base, engine, get_db +from . import models, schemas +from .curriculum import QUESTIONS +from .services import build_llm_reply, ensure_student_mastery, evaluate_answer, pick_next_skill, seed_skills + + +@asynccontextmanager +async def lifespan(app: FastAPI): + Base.metadata.create_all(bind=engine) + db = next(get_db()) + try: + seed_skills(db) + finally: + db.close() + yield + + +app = FastAPI(title="Professeur Virtuel API", version="0.1.0", lifespan=lifespan) + +from fastapi.middleware.cors import CORSMiddleware + +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://prof.open-squared.tech", + "http://localhost:3000", + "http://localhost:3001", + ], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.get("/students", response_model=list[schemas.StudentRead]) +def list_students(db: Session = Depends(get_db)): + return db.query(models.Student).order_by(models.Student.id.asc()).all() + + +@app.post("/students", response_model=schemas.StudentRead) +def create_student(payload: schemas.StudentCreate, db: Session = Depends(get_db)): + student = models.Student(**payload.model_dump()) + db.add(student) + db.commit() + db.refresh(student) + ensure_student_mastery(db, student) + return student + + +@app.post("/session/start", response_model=schemas.ChatResponse) +def start_session(student_id: int, db: Session = Depends(get_db)): + student = db.query(models.Student).filter_by(id=student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + + ensure_student_mastery(db, student) + message = ( + f"Bonjour {student.first_name} ! Je suis ton professeur virtuel. " + "Aujourd'hui, on va apprendre pas à pas et faire un petit test pour voir ce que tu maîtrises déjà." + ) + db.add(models.Message(student_id=student.id, role="assistant", content=message)) + db.commit() + return schemas.ChatResponse(reply=message) + + +@app.post("/chat", response_model=schemas.ChatResponse) +def chat(payload: schemas.ChatRequest, db: Session = Depends(get_db)): + student = db.query(models.Student).filter_by(id=payload.student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + + db.add(models.Message(student_id=student.id, role="user", content=payload.message)) + db.commit() + + reply = build_llm_reply(db, payload.student_id, payload.message) + + db.add(models.Message(student_id=student.id, role="assistant", content=reply)) + db.commit() + return schemas.ChatResponse(reply=reply) + + +@app.get("/progress/{student_id}", response_model=schemas.ProgressResponse) +def get_progress(student_id: int, db: Session = Depends(get_db)): + student = db.query(models.Student).filter_by(id=student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + + rows = ( + db.query(models.StudentSkillMastery, models.Skill) + .join(models.Skill, models.Skill.id == models.StudentSkillMastery.skill_id) + .filter(models.StudentSkillMastery.student_id == student_id) + .order_by(models.Skill.subject.asc(), models.Skill.label.asc()) + .all() + ) + progress = [ + schemas.SkillProgress( + code=skill.code, + subject=skill.subject, + label=skill.label, + mastery_score=mastery.mastery_score, + confidence=mastery.confidence, + evidence_count=mastery.evidence_count, + ) + for mastery, skill in rows + ] + return schemas.ProgressResponse(student=student, progress=progress) + + +@app.get("/assessment/next/{student_id}", response_model=schemas.AssessmentQuestionResponse) +def next_assessment(student_id: int, db: Session = Depends(get_db)): + student = db.query(models.Student).filter_by(id=student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + skill = pick_next_skill(db, student_id) + question = QUESTIONS[skill.code]["question"] + return schemas.AssessmentQuestionResponse(skill_code=skill.code, skill_label=skill.label, question=question) + + +@app.post("/assessment/answer", response_model=schemas.AssessmentAnswerResponse) +def answer_assessment(payload: schemas.AssessmentAnswerRequest, db: Session = Depends(get_db)): + student = db.query(models.Student).filter_by(id=payload.student_id).first() + if not student: + raise HTTPException(status_code=404, detail="Élève introuvable") + if payload.skill_code not in QUESTIONS: + raise HTTPException(status_code=400, detail="Compétence inconnue") + + correct, feedback, mastery_score = evaluate_answer( + db, payload.student_id, payload.skill_code, payload.answer + ) + db.add(models.Message(student_id=student.id, role="assistant", content=feedback)) + db.commit() + return schemas.AssessmentAnswerResponse( + correct=correct, + feedback=feedback, + mastery_score=mastery_score, + ) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..34ab23d --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,69 @@ +from datetime import datetime +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship +from .database import Base + + +class Student(Base): + __tablename__ = "students" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + first_name: Mapped[str] = mapped_column(String(100)) + age: Mapped[int] = mapped_column(Integer) + grade: Mapped[str] = mapped_column(String(50)) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + messages = relationship("Message", back_populates="student", cascade="all, delete-orphan") + mastery = relationship("StudentSkillMastery", back_populates="student", cascade="all, delete-orphan") + + +class Message(Base): + __tablename__ = "messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True) + role: Mapped[str] = mapped_column(String(20)) + content: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + student = relationship("Student", back_populates="messages") + + +class Skill(Base): + __tablename__ = "skills" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + code: Mapped[str] = mapped_column(String(100), unique=True) + subject: Mapped[str] = mapped_column(String(50)) + label: Mapped[str] = mapped_column(String(255)) + description: Mapped[str] = mapped_column(Text) + + +class StudentSkillMastery(Base): + __tablename__ = "student_skill_mastery" + __table_args__ = (UniqueConstraint("student_id", "skill_id", name="uq_student_skill"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True) + skill_id: Mapped[int] = mapped_column(ForeignKey("skills.id"), index=True) + mastery_score: Mapped[float] = mapped_column(Float, default=50.0) + confidence: Mapped[float] = mapped_column(Float, default=0.2) + evidence_count: Mapped[int] = mapped_column(Integer, default=0) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + student = relationship("Student", back_populates="mastery") + skill = relationship("Skill") + + +class AssessmentAttempt(Base): + __tablename__ = "assessment_attempts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), index=True) + skill_code: Mapped[str] = mapped_column(String(100), index=True) + question: Mapped[str] = mapped_column(Text) + expected_answer: Mapped[str] = mapped_column(Text) + student_answer: Mapped[str] = mapped_column(Text) + is_correct: Mapped[int] = mapped_column(Integer) + feedback: Mapped[str] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..b3283c0 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,60 @@ +from pydantic import BaseModel, Field +from typing import List + + +class StudentCreate(BaseModel): + first_name: str = Field(..., min_length=1) + age: int = Field(..., ge=8, le=12) + grade: str + + +class StudentRead(BaseModel): + id: int + first_name: str + age: int + grade: str + + class Config: + from_attributes = True + + +class ChatRequest(BaseModel): + student_id: int + message: str + + +class ChatResponse(BaseModel): + reply: str + should_speak: bool = True + + +class SkillProgress(BaseModel): + code: str + subject: str + label: str + mastery_score: float + confidence: float + evidence_count: int + + +class ProgressResponse(BaseModel): + student: StudentRead + progress: List[SkillProgress] + + +class AssessmentQuestionResponse(BaseModel): + skill_code: str + skill_label: str + question: str + + +class AssessmentAnswerRequest(BaseModel): + student_id: int + skill_code: str + answer: str + + +class AssessmentAnswerResponse(BaseModel): + correct: bool + feedback: str + mastery_score: float diff --git a/backend/app/services.py b/backend/app/services.py new file mode 100644 index 0000000..2ef0cc2 --- /dev/null +++ b/backend/app/services.py @@ -0,0 +1,138 @@ +import os +from typing import List +from openai import OpenAI +from sqlalchemy.orm import Session +from . import models +from .curriculum import QUESTIONS, SKILLS + +client = OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + + +SYSTEM_PROMPT = """ +Tu es ProfAmi, un professeur virtuel français pour enfants de 8 à 12 ans. +Règles : +- Tu parles toujours en français simple et chaleureux. +- Tu donnes des explications très courtes, puis un mini exemple. +- Tu tiens compte du niveau de l'élève et de ses faiblesses indiquées dans le contexte. +- Tu n'inventes pas la progression : elle est fournie dans le contexte. +- Tu encourages sans infantiliser. +- Quand l'élève se trompe, tu expliques calmement puis proposes une question très simple. +- Tu enseignes principalement le programme national français niveau primaire/cycle 3. +""".strip() + + +def seed_skills(db: Session) -> None: + for skill in SKILLS: + existing = db.query(models.Skill).filter(models.Skill.code == skill["code"]).first() + if not existing: + db.add(models.Skill(**skill)) + db.commit() + + +def ensure_student_mastery(db: Session, student: models.Student) -> None: + all_skills = db.query(models.Skill).all() + for skill in all_skills: + found = ( + db.query(models.StudentSkillMastery) + .filter_by(student_id=student.id, skill_id=skill.id) + .first() + ) + if not found: + db.add(models.StudentSkillMastery(student_id=student.id, skill_id=skill.id)) + db.commit() + + +def get_student_context(db: Session, student_id: int) -> str: + student = db.query(models.Student).filter_by(id=student_id).first() + mastery = ( + db.query(models.StudentSkillMastery, models.Skill) + .join(models.Skill, models.Skill.id == models.StudentSkillMastery.skill_id) + .filter(models.StudentSkillMastery.student_id == student_id) + .all() + ) + recent_messages = ( + db.query(models.Message) + .filter_by(student_id=student_id) + .order_by(models.Message.created_at.desc()) + .limit(6) + .all() + ) + + lines: List[str] = [ + f"Élève: {student.first_name}, {student.age} ans, classe {student.grade}.", + "Progression par compétence:", + ] + 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}" + ) + lines.append("Historique récent:") + for message in reversed(recent_messages): + lines.append(f"- {message.role}: {message.content}") + return "\n".join(lines) + + +def build_llm_reply(db: Session, student_id: int, user_message: str) -> str: + context = get_student_context(db, student_id) + response = client.responses.create( + model="gpt-4.1-mini", + input=[ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": f"Contexte pédagogique:\n{context}\n\nMessage de l'élève:\n{user_message}", + }, + ], + temperature=0.7, + ) + return response.output_text.strip() + + +def pick_next_skill(db: Session, student_id: int) -> models.Skill: + weakest = ( + db.query(models.StudentSkillMastery) + .filter_by(student_id=student_id) + .order_by(models.StudentSkillMastery.mastery_score.asc()) + .first() + ) + return db.query(models.Skill).filter_by(id=weakest.skill_id).first() + + +def evaluate_answer(db: Session, student_id: int, skill_code: str, answer: str): + q = QUESTIONS[skill_code] + normalized_student = answer.strip().lower() + normalized_expected = q["expected_answer"].strip().lower() + correct = normalized_student == normalized_expected + + skill = db.query(models.Skill).filter_by(code=skill_code).first() + mastery = ( + db.query(models.StudentSkillMastery) + .filter_by(student_id=student_id, skill_id=skill.id) + .first() + ) + + if correct: + mastery.mastery_score = min(100.0, mastery.mastery_score + 8) + mastery.confidence = min(1.0, mastery.confidence + 0.15) + feedback = q["feedback_ok"] + else: + mastery.mastery_score = max(0.0, mastery.mastery_score - 6) + mastery.confidence = min(1.0, mastery.confidence + 0.1) + feedback = q["feedback_ko"] + + mastery.evidence_count += 1 + + db.add( + models.AssessmentAttempt( + student_id=student_id, + skill_code=skill_code, + question=q["question"], + expected_answer=q["expected_answer"], + student_answer=answer, + is_correct=1 if correct else 0, + feedback=feedback, + ) + ) + db.commit() + db.refresh(mastery) + return correct, feedback, mastery.mastery_score diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..829452c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.0 +sqlalchemy==2.0.40 +psycopg2-binary==2.9.10 +pydantic==2.10.6 +python-dotenv==1.0.1 +openai==1.72.0 +redis==5.2.1 +alembic==1.15.2 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..590aa0c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: tutor + POSTGRES_USER: tutor + POSTGRES_PASSWORD: tutor + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + backend: + build: ./backend + environment: + OPENAI_API_KEY: ${OPENAI_API_KEY} + DATABASE_URL: postgresql+psycopg2://tutor:tutor@postgres:5432/tutor + REDIS_URL: redis://redis:6379/0 + APP_ENV: development + FRONTEND_ORIGIN: http://localhost:3000 + ports: + - "8001:8000" + depends_on: + - postgres + - redis + volumes: + - ./backend:/app + + frontend: + build: ./frontend + environment: + VITE_API_BASE_URL: http://localhost:8000 + ports: + - "3001:3000" + depends_on: + - backend + volumes: + - ./frontend:/app + - /app/node_modules + +volumes: + postgres_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..ac0834c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..663e4dd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Professeur virtuel + + + +
+ + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8bce529 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "prof-virtuel-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^5.4.14" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..ab5fe57 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,271 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' + +const API_BASE = '/api' + +function Avatar({ speaking }) { + return ( +
+
+
+
+ + +
+
+
+
+
ProfAmi
+
+ ) +} + +function ProgressCard({ item }) { + const pct = Math.round(item.mastery_score) + return ( +
+
+ {item.label} + {pct}% +
+
+
+
+ {item.subject} · preuves: {item.evidence_count} +
+ ) +} + +export default function App() { + const [students, setStudents] = useState([]) + const [selectedStudentId, setSelectedStudentId] = useState('') + const [form, setForm] = useState({ first_name: '', age: 8, grade: 'CM1' }) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [progress, setProgress] = useState([]) + const [assessment, setAssessment] = useState(null) + const [assessmentAnswer, setAssessmentAnswer] = useState('') + const [speaking, setSpeaking] = useState(false) + const recognitionRef = useRef(null) + + const selectedStudent = useMemo( + () => students.find((s) => String(s.id) === String(selectedStudentId)), + [students, selectedStudentId] + ) + + useEffect(() => { + loadStudents() + }, []) + + useEffect(() => { + if (selectedStudentId) { + loadProgress(selectedStudentId) + } + }, [selectedStudentId]) + + async function loadStudents() { + const res = await fetch(`${API_BASE}/students`) + const data = await res.json() + setStudents(data) + if (data.length && !selectedStudentId) { + setSelectedStudentId(String(data[0].id)) + } + } + + async function createStudent(e) { + e.preventDefault() + const res = await fetch(`${API_BASE}/students`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...form, age: Number(form.age) }), + }) + const data = await res.json() + await loadStudents() + setSelectedStudentId(String(data.id)) + } + + async function startSession() { + if (!selectedStudentId) return + const res = await fetch(`${API_BASE}/session/start?student_id=${selectedStudentId}`, { method: 'POST' }) + const data = await res.json() + appendMessage('assistant', data.reply) + speak(data.reply) + await loadProgress(selectedStudentId) + } + + function appendMessage(role, content) { + setMessages((prev) => [...prev, { role, content, id: crypto.randomUUID() }]) + } + + async function sendMessage(e) { + e.preventDefault() + if (!selectedStudentId || !input.trim()) return + const text = input.trim() + appendMessage('user', text) + setInput('') + const res = await fetch(`${API_BASE}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ student_id: Number(selectedStudentId), message: text }), + }) + const data = await res.json() + appendMessage('assistant', data.reply) + speak(data.reply) + } + + async function loadProgress(studentId) { + const res = await fetch(`${API_BASE}/progress/${studentId}`) + const data = await res.json() + setProgress(data.progress || []) + } + + async function fetchAssessment() { + if (!selectedStudentId) return + const res = await fetch(`${API_BASE}/assessment/next/${selectedStudentId}`) + const data = await res.json() + setAssessment(data) + setAssessmentAnswer('') + } + + async function submitAssessment(e) { + e.preventDefault() + if (!assessment || !assessmentAnswer.trim()) return + const res = await fetch(`${API_BASE}/assessment/answer`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + student_id: Number(selectedStudentId), + skill_code: assessment.skill_code, + answer: assessmentAnswer, + }), + }) + const data = await res.json() + appendMessage('assistant', `Quiz: ${data.feedback}`) + speak(data.feedback) + setAssessment(null) + setAssessmentAnswer('') + await loadProgress(selectedStudentId) + } + + function speak(text) { + if (!('speechSynthesis' in window)) return + window.speechSynthesis.cancel() + const utterance = new SpeechSynthesisUtterance(text) + utterance.lang = 'fr-FR' + utterance.onstart = () => setSpeaking(true) + utterance.onend = () => setSpeaking(false) + utterance.onerror = () => setSpeaking(false) + window.speechSynthesis.speak(utterance) + } + + function startVoiceInput() { + const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition + if (!Recognition) { + alert('La reconnaissance vocale du navigateur n\'est pas disponible ici.') + return + } + const recognition = new Recognition() + recognition.lang = 'fr-FR' + recognition.interimResults = false + recognition.maxAlternatives = 1 + recognition.onresult = (event) => { + const transcript = event.results[0][0].transcript + setInput(transcript) + } + recognition.onerror = () => {} + recognitionRef.current = recognition + recognition.start() + } + + return ( +
+ + +
+
+ +
+

Professeur virtuel

+

+ {selectedStudent + ? `Séance active pour ${selectedStudent.first_name}, ${selectedStudent.grade}` + : 'Choisis ou crée un élève pour commencer.'} +

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

Le dialogue apparaîtra ici.

} + {messages.map((message) => ( +
+ {message.role === 'assistant' ? 'ProfAmi' : 'Élève'} +

{message.content}

+
+ ))} +
+ + {assessment && ( +
+

Mini-test · {assessment.skill_label}

+

{assessment.question}

+ setAssessmentAnswer(e.target.value)} + placeholder="Ta réponse" + /> + +
+ )} + +
+ setInput(e.target.value)} + placeholder="Pose une question ou demande une explication..." + /> + + +
+
+
+ ) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..84181a8 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..966e72b --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,141 @@ +:root { + font-family: Inter, system-ui, sans-serif; + color: #14213d; + background: #f5f7fb; +} + +* { box-sizing: border-box; } +body { margin: 0; } +button, input, select { + font: inherit; + border-radius: 12px; + border: 1px solid #cfd7e6; + padding: 0.8rem 1rem; +} +button { + background: #2563eb; + color: white; + border: none; + cursor: pointer; +} +button:disabled { opacity: 0.5; cursor: not-allowed; } + +.layout { + display: grid; + grid-template-columns: 360px 1fr; + min-height: 100vh; + gap: 1rem; + padding: 1rem; +} +.card { + background: white; + border-radius: 24px; + box-shadow: 0 10px 30px rgba(20,33,61,0.08); + padding: 1rem; +} +.sidebar, .main { display: flex; flex-direction: column; gap: 1rem; } +.stack { display: flex; flex-direction: column; gap: 0.75rem; } +.small-gap { gap: 0.5rem; } +.hero { display: flex; align-items: center; gap: 1rem; } +.messages { + flex: 1; + min-height: 360px; + overflow: auto; + background: #f8fafc; + border-radius: 20px; + padding: 1rem; +} +.message { + max-width: 80%; + margin-bottom: 0.75rem; + padding: 0.9rem 1rem; + border-radius: 18px; +} +.message.user { + margin-left: auto; + background: #dbeafe; +} +.message.assistant { + background: #eaf7e7; +} +.message p { margin: 0.35rem 0 0; white-space: pre-wrap; } +.composer { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 0.75rem; +} +.assessment { + background: #fff7ed; + padding: 1rem; + border-radius: 18px; +} +.progress-card { + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 0.75rem; +} +.progress-head { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.4rem; +} +.progress-bar { + height: 10px; + background: #e5e7eb; + border-radius: 999px; + overflow: hidden; + margin-bottom: 0.35rem; +} +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #60a5fa, #2563eb); +} +.muted { color: #64748b; } +.avatar-shell { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; } +.avatar { + width: 144px; + height: 144px; + border-radius: 50%; + background: radial-gradient(circle at 30% 30%, #fde68a, #f59e0b); + display: grid; + place-items: center; + box-shadow: 0 10px 20px rgba(245, 158, 11, 0.28); +} +.avatar.speaking { animation: pulse 0.7s infinite alternate; } +.face { width: 90px; } +.eyes { + display: flex; + justify-content: space-between; + margin-bottom: 1.2rem; +} +.eyes span { + width: 14px; + height: 14px; + background: #1f2937; + border-radius: 50%; +} +.mouth { + width: 44px; + height: 10px; + background: #7c2d12; + border-radius: 999px; + margin: 0 auto; + transition: all 0.15s ease; +} +.mouth-speaking { + width: 34px; + height: 24px; + border-radius: 0 0 999px 999px; +} +.avatar-caption { font-weight: 700; } + +@keyframes pulse { + from { transform: scale(1); } + to { transform: scale(1.05); } +} + +@media (max-width: 920px) { + .layout { grid-template-columns: 1fr; } + .composer { grid-template-columns: 1fr; } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..08dba1b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + allowedHosts: ['prof.open-squared.tech'] + } +})