diff --git a/MEMORY.md b/MEMORY.md
index 67f4ebd..757ab0d 100644
--- a/MEMORY.md
+++ b/MEMORY.md
@@ -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.
diff --git a/backend/app/main.py b/backend/app/main.py
index 8ac5d0d..9843506 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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} ! Aujourd’hui, 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 l’historique, "
+ "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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 4f3f906..b2443bd 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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"
diff --git a/backend/app/program_content.py b/backend/app/program_content.py
new file mode 100644
index 0000000..a054e41
--- /dev/null
+++ b/backend/app/program_content.py
@@ -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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 5b9b2af..bfa5dfb 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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
diff --git a/backend/app/services.py b/backend/app/services.py
index e765995..b632129 100644
--- a/backend/app/services.py
+++ b/backend/app/services.py
@@ -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 t’interrompt, arrête-toi, écoute son message, puis reprends seulement si c’est 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."
),
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 5e3fab6..90ff71d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -146,7 +146,7 @@ function LoginPage({ onLogin }) {
Connexion eleve, professeur ou maintenance.
+Connexion élève, professeur ou maintenance.