admin mode

This commit is contained in:
2026-05-01 20:37:15 +02:00
parent 72a9f9d9e2
commit bfd367f3d8
8 changed files with 563 additions and 186 deletions

View File

@@ -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} ! Aujourdhui, 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 lhistorique, "
"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)

View File

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

View File

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

View File

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

View File

@@ -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 tinterrompt, arrête-toi, écoute son message, puis reprends seulement si cest 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."
),