admin mode
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
138
backend/app/program_content.py
Normal file
138
backend/app/program_content.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user