This commit is contained in:
2026-05-06 19:55:37 +02:00
parent 7a7994e8b6
commit b41993c455
43 changed files with 1563 additions and 14 deletions

View File

@@ -21,6 +21,7 @@ from .curriculum import QUESTIONS
from .program_content import (
build_program_tree,
decode_asset_token,
format_adaptive_kit_context,
get_content_status,
get_lesson,
list_lessons_for_grade,
@@ -102,6 +103,18 @@ def build_student_program_response(student: models.Student, assignment: models.S
)
def format_student_adaptive_profile_context(profile: models.StudentAdaptiveProfile | None) -> str:
if not profile:
return ""
return (
"Profil adaptatif recommandé pour cet élève:\n"
f"- Parcours: {profile.profile_title}\n"
f"- Source: {profile.source}\n"
f"- Notes professeur: {profile.notes or 'aucune'}\n"
"Règle: utilise ce parcours comme aide prioritaire si l'élève hésite ou se trompe sur cette leçon.\n"
)
def clamp_lesson_position(lesson: dict, asset_index: int, section_index: int) -> tuple[int, int]:
assets = lesson.get("assets") or []
if not assets:
@@ -427,6 +440,55 @@ def update_student_program_start(
return build_student_program_response(student, assignment)
@app.get("/admin/students/{student_id}/adaptive-profile", response_model=schemas.StudentAdaptiveProfileRead | None)
def get_student_adaptive_profile(
student_id: int,
lesson_id: str,
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")
return (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student_id, lesson_id=lesson_id)
.first()
)
@app.put("/admin/students/{student_id}/adaptive-profile", response_model=schemas.StudentAdaptiveProfileRead)
def upsert_student_adaptive_profile(
student_id: int,
payload: schemas.StudentAdaptiveProfileRequest,
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")
row = (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student_id, lesson_id=payload.lesson_id)
.first()
)
if not row:
row = models.StudentAdaptiveProfile(student_id=student_id, lesson_id=payload.lesson_id)
row.diagnostic_id = payload.diagnostic_id
row.profile_id = payload.profile_id
row.profile_title = payload.profile_title
row.notes = payload.notes
row.source = payload.source
db.add(row)
db.commit()
db.refresh(row)
return row
@app.get("/students/{student_id}/card-progress", response_model=list[schemas.LessonCardProgressRead])
def list_card_progress(
student_id: int,
@@ -544,6 +606,17 @@ def start_session(
)
if first_section and first_section.get("context"):
lesson_context += f"\nTexte pédagogique de la card courante:\n{first_section['context'].strip()}"
adaptive_context = format_adaptive_kit_context(assigned_lesson)
if adaptive_context:
lesson_context += f"\n\n{adaptive_context}"
adaptive_profile = (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student.id, lesson_id=assigned_lesson["id"])
.first()
)
profile_context = format_student_adaptive_profile_context(adaptive_profile)
if profile_context:
lesson_context += f"\n\n{profile_context}"
lesson_reply = build_lesson_reply(
db,
student.id,
@@ -581,6 +654,7 @@ def chat(
lesson_context = ""
if payload.lesson_title or payload.section_title or payload.asset_title:
current_lesson = get_lesson(payload.lesson_id) if payload.lesson_id else None
lesson_context = (
f"Leçon courante: {payload.lesson_title or 'non précisée'}\n"
f"Fiche courante: {payload.asset_title or 'non précisée'}\n"
@@ -591,6 +665,18 @@ def chat(
)
if payload.section_context:
lesson_context += f"\nTexte pédagogique de la card courante:\n{payload.section_context.strip()}"
adaptive_context = format_adaptive_kit_context(current_lesson)
if adaptive_context:
lesson_context += f"\n\n{adaptive_context}"
if current_lesson:
adaptive_profile = (
db.query(models.StudentAdaptiveProfile)
.filter_by(student_id=student.id, lesson_id=current_lesson["id"])
.first()
)
profile_context = format_student_adaptive_profile_context(adaptive_profile)
if profile_context:
lesson_context += f"\n\n{profile_context}"
if lesson_context:
lesson_reply = build_lesson_reply(db, payload.student_id, payload.message, lesson_context)

View File

@@ -121,6 +121,25 @@ class LessonCardProgress(Base):
student = relationship("Student")
class StudentAdaptiveProfile(Base):
__tablename__ = "student_adaptive_profiles"
__table_args__ = (
UniqueConstraint("student_id", "lesson_id", name="uq_student_adaptive_lesson"),
)
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)
diagnostic_id: Mapped[str | None] = mapped_column(String(500), nullable=True)
profile_id: Mapped[str] = mapped_column(String(500))
profile_title: Mapped[str] = mapped_column(String(255))
notes: Mapped[str] = mapped_column(Text, default="")
source: Mapped[str] = mapped_column(String(50), default="teacher")
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
student = relationship("Student")
class AssessmentAttempt(Base):
__tablename__ = "assessment_attempts"

View File

@@ -250,6 +250,7 @@ def list_lessons() -> list[dict]:
"cycle": cycle,
"asset_count": len(assets),
"assets": assets,
"adaptive_kit": collect_adaptive_kit(lesson_dir),
}
)
@@ -297,6 +298,7 @@ def save_review_state(state: dict) -> None:
def collect_lesson_status(lesson: dict) -> dict:
lesson_dir = CONTENT_ROOT / lesson["id"]
kit_dir = lesson_dir / "kit_adaptatif"
return {
"micro_fiches": len([path for path in lesson_dir.glob("*.md") if path.name.startswith(tuple("0123456789"))]),
"lesson_svg": len(lesson.get("assets") or []),
@@ -304,6 +306,9 @@ def collect_lesson_status(lesson: dict) -> dict:
"contexts": len(list((lesson_dir / "card_contexts").rglob("Fiche*Card*.md"))),
"text_exercises": len(list((lesson_dir / "exercices").rglob("exercices.md"))),
"exercise_svg": len(list((lesson_dir / "exercices_svg").glob("*.svg"))),
"adaptive_tests": len([path for path in (kit_dir / "test_diagnostic").glob("*.md") if path.name != "README.md"]) if kit_dir.exists() else 0,
"adaptive_profiles": len([path for path in (kit_dir / "parcours_profils").glob("*.md") if path.name != "README.md"]) if kit_dir.exists() else 0,
"adaptive_svg": len(list((kit_dir / "svg").glob("*.svg"))) if kit_dir.exists() else 0,
"validations": sorted(path.stem for path in lesson_dir.glob("VALIDATION_PHASE_*.md")),
}
@@ -318,6 +323,69 @@ def append_tree_child(parent: dict, key: str, label: str, node_type: str, path:
return child
def read_review_text(path: Path, max_chars: int = 6000) -> str:
text = path.read_text(encoding="utf-8", errors="ignore").strip()
return text[:max_chars]
def collect_adaptive_kit(lesson_dir: Path) -> dict | None:
kit_dir = lesson_dir / "kit_adaptatif"
if not kit_dir.exists():
return None
def markdown_items(folder: str) -> list[dict]:
base = kit_dir / folder
if not base.exists():
return []
return [
{
"id": path.relative_to(CONTENT_ROOT).as_posix(),
"title": title_from_slug(path.stem),
"content": read_review_text(path),
}
for path in sorted(base.glob("*.md"))
if path.name != "README.md"
]
supports = []
svg_dir = kit_dir / "svg"
if svg_dir.exists():
for path in sorted(svg_dir.glob("*.svg")):
relative = path.relative_to(CONTENT_ROOT).as_posix()
supports.append(
{
"id": relative,
"title": title_from_slug(path.stem),
"url": build_asset_url(relative),
}
)
return {
"id": kit_dir.relative_to(CONTENT_ROOT).as_posix(),
"title": "Kit adaptatif",
"diagnostics": markdown_items("test_diagnostic"),
"profiles": markdown_items("parcours_profils"),
"supports": supports,
}
def format_adaptive_kit_context(lesson: dict | None) -> str:
kit = (lesson or {}).get("adaptive_kit")
if not kit:
return ""
diagnostics = ", ".join(item["title"] for item in kit.get("diagnostics") or [])
profiles = ", ".join(item["title"] for item in kit.get("profiles") or [])
return (
"Kit adaptatif disponible pour cette leçon.\n"
f"Tests diagnostics: {diagnostics or 'aucun'}.\n"
f"Parcours de reprise: {profiles or 'aucun'}.\n"
"Règle adaptative: si l'élève se trompe nettement, semble bloqué, inverse les notions ou demande de l'aide, "
"ne change pas de card tout de suite. Propose une micro-question de diagnostic liée à la difficulté observée, "
"puis oriente vers le parcours le plus probable en une phrase courte.\n"
)
def build_program_tree() -> dict:
reviews = load_review_state()
root = {
@@ -365,6 +433,37 @@ def build_program_tree() -> dict:
)
section_node["url"] = section.get("url")
section_node["review"] = reviews.get(section_key, {})
lesson_dir = CONTENT_ROOT / lesson["id"]
kit_dir = lesson_dir / "kit_adaptatif"
if kit_dir.exists():
kit_key = f"{lesson['id']}/kit_adaptatif"
kit_node = append_tree_child(lesson_node, kit_key, "Kit adaptatif", "kit", kit_key)
kit_node["review"] = reviews.get(kit_key, {})
tests_node = append_tree_child(kit_node, f"{kit_key}/test_diagnostic", "Tests diagnostics", "group", f"{kit_key}/test_diagnostic")
for test_path in sorted((kit_dir / "test_diagnostic").glob("*.md")):
if test_path.name == "README.md":
continue
relative = test_path.relative_to(CONTENT_ROOT).as_posix()
test_node = append_tree_child(tests_node, relative, title_from_slug(test_path.stem), "diagnostic", relative)
test_node["content"] = read_review_text(test_path)
test_node["review"] = reviews.get(relative, {})
profiles_node = append_tree_child(kit_node, f"{kit_key}/parcours_profils", "Parcours profils", "group", f"{kit_key}/parcours_profils")
for profile_path in sorted((kit_dir / "parcours_profils").glob("*.md")):
if profile_path.name == "README.md":
continue
relative = profile_path.relative_to(CONTENT_ROOT).as_posix()
profile_node = append_tree_child(profiles_node, relative, title_from_slug(profile_path.stem), "parcours", relative)
profile_node["content"] = read_review_text(profile_path)
profile_node["review"] = reviews.get(relative, {})
svg_node = append_tree_child(kit_node, f"{kit_key}/svg", "Supports visuels", "group", f"{kit_key}/svg")
for svg_path in sorted((kit_dir / "svg").glob("*.svg")):
relative = svg_path.relative_to(CONTENT_ROOT).as_posix()
support_node = append_tree_child(svg_node, relative, title_from_slug(svg_path.stem), "support", relative)
support_node["url"] = build_asset_url(relative)
support_node["review"] = reviews.get(relative, {})
return root

View File

@@ -54,6 +54,7 @@ class StudentAccountResponse(BaseModel):
class ChatRequest(BaseModel):
student_id: int
message: str
lesson_id: str | None = None
lesson_title: str | None = None
asset_title: str | None = None
section_title: str | None = None
@@ -124,6 +125,7 @@ class ProgramLesson(BaseModel):
cycle: str
asset_count: int
assets: List[ProgramAsset] = []
adaptive_kit: dict[str, Any] | None = None
class ProgramLessonListResponse(BaseModel):
@@ -152,6 +154,30 @@ class StudentProgramStartRequest(BaseModel):
section_index: int = Field(..., ge=0)
class StudentAdaptiveProfileRequest(BaseModel):
lesson_id: str = Field(..., min_length=1)
diagnostic_id: str | None = None
profile_id: str = Field(..., min_length=1)
profile_title: str = Field(..., min_length=1)
notes: str = ""
source: str = "teacher"
class StudentAdaptiveProfileRead(BaseModel):
id: int
student_id: int
lesson_id: str
diagnostic_id: str | None = None
profile_id: str
profile_title: str
notes: str
source: str
updated_at: datetime
class Config:
from_attributes = True
class LessonCardState(BaseModel):
lesson_id: str
asset_path: str