phase 8
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user