From 887e9919a1cb2802e05b7dda48ae78f000b63ece Mon Sep 17 00:00:00 2001 From: root Date: Sun, 5 Apr 2026 07:35:28 +0000 Subject: [PATCH] Initial commit --- .env | 1 + .env.example | 1 + README.md | 58 ++++ backend/Dockerfile | 15 + .../__pycache__/curriculum.cpython-313.pyc | Bin 0 -> 1819 bytes .../app/__pycache__/database.cpython-313.pyc | Bin 0 -> 831 bytes backend/app/__pycache__/main.cpython-313.pyc | Bin 0 -> 9331 bytes .../app/__pycache__/models.cpython-313.pyc | Bin 0 -> 4812 bytes .../app/__pycache__/schemas.cpython-313.pyc | Bin 0 -> 3168 bytes .../app/__pycache__/services.cpython-313.pyc | Bin 0 -> 8275 bytes backend/app/curriculum.py | 53 ++++ backend/app/database.py | 17 ++ backend/app/main.py | 144 ++++++++++ backend/app/models.py | 69 +++++ backend/app/schemas.py | 60 ++++ backend/app/services.py | 138 +++++++++ backend/requirements.txt | 9 + docker-compose.yml | 47 +++ frontend/Dockerfile | 10 + frontend/index.html | 12 + frontend/package.json | 19 ++ frontend/src/App.jsx | 271 ++++++++++++++++++ frontend/src/main.jsx | 10 + frontend/src/styles.css | 141 +++++++++ frontend/vite.config.js | 10 + 25 files changed, 1085 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 README.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/__pycache__/curriculum.cpython-313.pyc create mode 100644 backend/app/__pycache__/database.cpython-313.pyc create mode 100644 backend/app/__pycache__/main.cpython-313.pyc create mode 100644 backend/app/__pycache__/models.cpython-313.pyc create mode 100644 backend/app/__pycache__/schemas.cpython-313.pyc create mode 100644 backend/app/__pycache__/services.cpython-313.pyc create mode 100644 backend/app/curriculum.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/services.py create mode 100644 backend/requirements.txt create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/vite.config.js 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 0000000000000000000000000000000000000000..773605b380ed9a70043a6184bfaaaa38da3d20a8 GIT binary patch literal 1819 zcmah}TW=dh7`0vJPSTn*anqFMOF@``vnGx)rQ8%sQA5)vWm1VZYrH#7rm=UovpXU9 z3H|~S;$UlFVKa6KG zf0)Ve!Q4J4K7K!DScYX;j$LATC(lMO9%W;8JkBQAB%8A1S1_JtGiF@Ls|>r$uCM}o zmCa($RW`@wS<#NKu?2RWE!y!7_8Pm%mh70Eyvpt^uiQ#!J3QWRaL<#m4E#nIL_+8p zD0v9H-@~eWrbVO)(8j41!K&XA)PGK%&`0F%%T`Ol=r)WPKMbUzA)_x8j3V-bP7@|1 z{8$PV{cEqYl27w);E8lB(#@yBjnk1fZ;Ez0h3&4Aq0yHXtlXG4+ahWltv8Np-KAYS z!zt9!?O29w=|Xkj(>jTmYPtedcLU$&{a<4x45nk*mJyVJg$871yS2x^=A`p2)o=s< zsovu(5h8l51Hy33oMiCxRsBben=7I?!w6E4}(bsY4Sfb5~G zZB7FUiex8j3mYSlk;nZAN(6=bibulfUb)NLu7*YReR_x1-*eJjy{hkn@H2&kmWMcf zO!cZmyP9}JO)32IME83(oSy7*>6=W)cCLLH!zV|74YDmxO~?}jH^ab>P(!_#%h;0c z2n#$vr-Z9si8lk~U8JH=%jj=snc^8YWom2WvN#G65aBi8C-e-PW=Uv=!jUI)1RJ)(`;I|78#WN3)kXW{r_o z(Y2#c%nVN!Y7Zq>3 z`maWymh@$0dnM$GR-w)5e(<>|MFeC}iB#GWMox?x$1UOoDlU}?scjRoOYPFI*pZ^V zG12P~Y?B0W5>fFGWCiySFvC+Ow`6vDf$v_}?&ydA<0!ZsdSdE(&|QWBg=?F}CNeTt zOr*^vVj2=PsB+*`W9DNnb!9G#M=QB>ls(wo+G6R%<0tpn?&kI**2|n=EtA$N9Y3yk zJm!@!a92YWv{nzLis5g?6s+*Q3J=4ItCf=IPu+3CZ`1LQI)R5~{Y1@!B#Srt4WHvn z*=+WBG*fspnSDJetS80CFD6DtMvrGQg*PlzO^Wx=pp|6yW>Tmn#V^mGLH{8(!zA^`w4XwqW=OdeNzPh literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/database.cpython-313.pyc b/backend/app/__pycache__/database.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..72d85ae359d177ad6b51dc403a17a9891d22c725 GIT binary patch literal 831 zcmah{J!lj`6n?Wmce|U7mzekyg?J_wUXfc6gBS5HK~v;HifmGOaO~~QU6$;hvwH_8 z7K@ceG$I5n1uF}|MzFNEF{Cl0V#F$q1g^2LaON&YN+0Zd-@Nzc@x5=hGdQRLGxzr= z_bmeW#SeYU9D+{Dz!sQbqB0O3g)&+Yh>+)4EQ<>gkrrek=b-}2djI-SoB&fYBGxJGqp271{$a^u+MW#1w;pJf~^Es zanNuBToCAxgKf_uR^qmBwPwYb>M@REHw=7h9TTeGo?V%pn>FuLSC<#>kH+^rH^Em8 z!zdbFXj@*s~Wnqf*Tc9Q%-BH%QD+ zvageLL7vHVm&w_){xSiK!z#333*}i{7W?(1*ze3k1J3`qrw-S}b5KWD#5yXVB_@P! z_SRnHTT$8e!Wgr~1g92rHhFr<@Pou~ti&>+(4LA&Sf6USBxz#Ls9E+p4jjXZqHYHl zvKX~#rh${HQzJuc0)O22B6ItY!Z-EAlks%!KoI4TFUN*9ls!e;R30l&wLg!99{3xN zvgU^aCG#u%@3WXhi{NEcH{!Y^UNKH2%)c_euoP`U}i5j zyxHDpza0NuxVe+NmFhp$lg}T#yS%NI(#&q|?5pMn`Q!X{uAJ(-*;6~&u~h10WHdrf Hvr+#5?}Ws@ literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..969dd210b7d6d73296268e97c6fd56c45084ed56 GIT binary patch literal 9331 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 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..66e37e41e28ba768aceb47e15c4cd03f1f61aead GIT binary patch literal 4812 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 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/services.cpython-313.pyc b/backend/app/__pycache__/services.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..175f5fba249c31c960c4b3688fc26efacb79d971 GIT binary patch literal 8275 zcmcgRYiv_lde`skw`1pp^Wee^$(TGG2+W2oLtr3*JP6>6XJN=Fy4W|)9qem!t{sv{ zT?Mt(B%-^J7DSk?MA|=_NTY6$D#89LtNiFxt5to+({Xf{X{cINnjfuP9Jq zTm7hRM33|g?i)r8BSvH-edDNU#Ei_)H?ihX%Lt8V(zlFSM{LM8Vn=p{nlSFBCa6%O zw$00SH^tHiVJ}=*>meO-vbIo*`dwZhEPfz2Yo9Q)4pw)-$U6Phr$*!OL7uUOB8 z2ifz(XU18@%#IF^jj@XB%-O;5W5bij&JItG4qn8|xs)(@g`dle>M6>0KEv=5a~371 zjwOUnCL=N_=)(ql1~D@NN@w^uGle*D^XHr(F{vaB^WqdIrX?oIGkwhFDzk4t!--P2 zEws57ivzZPTNeX(Qyjuy>15_pSS7)-GDMP)NX2=EPcxBeE)JXVnb~f2T{J0*ur3P! z{A?;NM7T7Z77(G$HHnD;t~4)oGN}yE1N0^YQ2;<5<11oF3p~I_l8IEBXQCNK6lQoX zgXxX8Z?4BT*JjAh@GZe6Q=D)a2*q7Nj0#sXo9lRcI1&RCMv`KhpG|A1#dblQ;b91P z!2tk=`4vOzjWGQTAIGPX(##alDGIQCbv(R?55mO&kAxuv!c!qGNIb%)IG5qXXc@T@ z59fyi@tsT>t_<_#Y6ccZxOjq306{80fS6PgpClvl3}+rx=W}=5>r34Ga~d0)}zL7yS$L4(J^S+N|-^V%M#3H@z zZdjb%a=2~|Ee+j%|L);GKP>y+TipZIH;%wF3*~Jr(3HvY+3@w@KOKDz8&ZlzN@t>= zBoVl)QYp#h29ZnPcDd9R!7i0ngi7*Q!thd7FuN;?c28JVK+^vLB;D}VJupobMU#t= zyQ%U(7XG8O_g>+ko-F($@f(p z3O+STiRA0Y*Y%36c(0Q}RH2EiK`&rKSZ#<3AP83|5O&Z4AFIj$(TUTX1R61?SXI@S zygaAqg(#}U>*{b-4^_ZK^a3?w95%qAiE{%UFoA^^qY^e7pd$=}cHq%^Fs$IrCN;tm z>PDy?gTcS_Q>YfGts39m#^uJJwcYQ$*O{wn&)0OwH66K{u7#1`)-)}eo`QIvS~_+6 zqkLUZt_$WIJ=@OurHiZWKi~UTdp9xavx+G%6V58Caj>Y))fC~)E8jdD@&LCgO4*7kbcV2G zRutlXGzzg8R+P73#4=k^!n#*n8Wz!tQsVa8jT5%w6-86sP!$6RH>`&nt`eG;oudlQ zP;12|@EO|m`j|#}iH3JxhhYt+**Bft$Qr}?(7Ug%cR`W@Gi_C8%rK)_%rXF2W9hZ9 zbWksv!Uiz?V-%HvTTR8nrcg&2V6n?u!*IjO7B*K)B3QedVjVEo^ZF{*33D!( z+yDBUi|Q?pFz54jOF4zQfUC2FF61OC;3qXXf>t%)ApPDVDvX0T`0vl)wQ1 z9@;-OZ-ZO}(w7-t`XGb7)+vadk_d~pyj1!f;3L`M+l3p>UnnK z9`RVlPm_X3PK6jei1CusA*=|BbQ(S=H zPO*`IZ&KtEJUBvFV~Qaz$}47w*dTBNFzzxRSDZypHAzkj=VDiN0O0`ra=-`o+ZhM2_y?wmO$;|7_}Jd?~KR zruWa>JM+a%J~%7~hx5UZ41dj`oR!_Sx|iy2oAaK%vS)AJb5QmieB_rs$8**b+g8Vt z??zq0N|_u57iG1pJKb$vZq3omw$;0|W98Gu9a(E&+gh{Kc)RUx_i}gM+aY^9^4=cV z+mrL|&slr7t*)iI#j7_OHGFo;R9o;;_S#z~^R;bqZClpYzKYh`f6=ktvDUJYczp8j zPyg!l}~sO?D|-?rtNEE`x9T&%9(F`or{*@&bQD?I}UnQS_$F7^Cmlk&fb(YHj&^&abzI1(1a&~qY|xPDIrnH-$3UMbqQoZEf;i} zuNMtv{sv;1%2LgDv2e0WrQnt|bn96ocmf01WU%^RM~!$asAs{>A2Ns#I#)|8LvKhS z!E#y0=N++By6ZsqzApTVp_rvP2ul+IJvstJq5-HBBR+ZttH$x-O9YeJT#s_GWDJ{_ zep?17RyJGFBE0g*(Qk`Qk@D{Y0fS;9??j485L217Vuy`VJmMf-=K~g^MUW51;pHT) zSm8Y*1y3%Zpk~#qC{Fewdw%frk`NJweXGPc2J2$Qbb z1pokpQN&jfExRvggm`o^9#2dnJ{6xsVOTc`|B?vR0`ePdRGy_MNhQXWqU?w(rT=+q1@YB5AKgz*ts@Y)yky{eMQlZ;{t( z(c~K>P4ku2JgMf`{|B+Q2e?sDN|>uffBI?>E{O=MJdUs-(3AYMqZT;sVb@Cv3qWvEG-J0(P}Oo|}8YDR}H!eaa` zCH)ap3si+`$kCluOm^PeDSJEf-hSEJpZ6Y-y+?B1qdDs_Vzh5GsJ7ohK_b_;)OUO6 z?%48J&b}*e?~v^sYuEC@0XaC3wRdFgNAva}**=uBpUN6fsrJsM=Cb%@2mG`sUE@)t z+;U+zOCni2X=KDU%$gVhTR-!&6ufCwAf8G${@n z%5FjrKN&gC=va5y#(EA}G+850t=O_u6ip|{{GN(Q_zYWLVz@A0ou>y3VAE>G6*s)u z^AQrD6MYWo5VnH|C%Z8f^~Iy#;gF;FRTzl%lCn9V*r(tz6~DAjCa)ByVJXffW1ru{ z<(K6!SCR0D$1!6gYX}nm>j<;&Al|26AaHRjQd$Y=Q(~K7x21{I<4jz=tqp zD1=v-;z_VaFEbgEu~cR;?_x*;L^Z@tqaUiD+$=hb@nCIGYL4OGTqOLf!?84uM$+Wf zO|c+;3Ic}dfW0i8AXH;Jr|7OKUNMOhTwIvPFU)EdrTEImG*;sXgq(z=7U(G6ItrD9 zb*q$HXis-;euj%@AcvY%S%Gj2!ej6|B(r!z*WB7G(=7|fws!2i-+Zt6FI)3Fy5t>Q zxgEi*v3|?nlx^;k{oQ&00oi{b=YK!%KP>wX=ln;q#)d5yBfEaEaPq14uy=Q2c_Qc7 zop*G~j?Rs}Sx0Bq@j>2kTy`AKIR+O7f3FXE*!Q3hrw)fTM`M8`<97~Ssh&a zLB8Xt+;Q~rKDpyWju~9Ed`-7JX<=4=m5m}xt61N#=qsOz4eLgduNIM zH&}gqtsx&cA_tB#Fras{R$>|@DxWyp2-T3I? g#Dj?!q+h7rqdR~BpuzLdJc^L!%ijOOUxXe14X-~=umAu6 literal 0 HcmV?d00001 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'] + } +})