diff --git a/backend/app/main.py b/backend/app/main.py index afe25d5..16b803a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,7 +16,14 @@ from .auth import ( verify_password, ) from .curriculum import QUESTIONS -from .program_content import decode_asset_token, get_content_status, get_lesson, list_lessons_for_grade, normalize_grade +from .program_content import ( + decode_asset_token, + get_content_status, + get_lesson, + list_lessons_for_grade, + normalize_grade, + render_svg_section, +) from .services import ( build_llm_reply, ensure_student_mastery, @@ -214,6 +221,22 @@ def get_program_asset( return FileResponse(path, media_type="image/svg+xml") +@app.get("/program/assets/{asset_token}/sections/{section_index}") +def get_program_asset_section( + asset_token: str, + section_index: int, + current_user: models.User = Depends(get_current_user), +): + try: + path = decode_asset_token(asset_token) + svg = render_svg_section(path, section_index) + except IndexError as exc: + raise HTTPException(status_code=404, detail="Étape introuvable") from exc + except Exception as exc: + raise HTTPException(status_code=400, detail="Ressource invalide") from exc + return Response(content=svg, media_type="image/svg+xml") + + @app.get("/students/{student_id}/program", response_model=schemas.StudentProgramResponse) def get_student_program( student_id: int, diff --git a/backend/app/program_content.py b/backend/app/program_content.py index c4b8060..ffa9dcc 100644 --- a/backend/app/program_content.py +++ b/backend/app/program_content.py @@ -1,5 +1,7 @@ import base64 +import html import os +import re from functools import lru_cache from pathlib import Path from urllib.parse import quote @@ -72,6 +74,61 @@ def build_asset_url(relative_path: str) -> str: return f"/api/program/assets/{quote(encode_asset_path(relative_path))}" +def build_section_url(relative_path: str, section_index: int) -> str: + token = quote(encode_asset_path(relative_path)) + return f"/api/program/assets/{token}/sections/{section_index}" + + +def extract_svg_sections(svg_path: Path) -> list[dict]: + text = svg_path.read_text(encoding="utf-8", errors="ignore") + sections: list[dict] = [] + pattern = re.compile( + r'[-\d.]+)\s+(?P[-\d.]+)\)">\s*' + r'[-\d.]+)"\s+height="(?P[-\d.]+)"[^>]*>' + r'(?P.*?)', + re.DOTALL, + ) + for match in pattern.finditer(text): + width = float(match.group("w")) + height = float(match.group("h")) + if width < 250 or height < 120: + continue + title_match = re.search(r']*>(?P.*?)</text>', match.group("body"), re.DOTALL) + raw_title = re.sub(r"<[^>]+>", "", title_match.group("title") if title_match else "") + title = html.unescape(raw_title).strip() or f"Étape {len(sections) + 1}" + margin = 28 + sections.append( + { + "index": len(sections), + "title": title, + "view_box": [ + max(float(match.group("x")) - margin, 0), + max(float(match.group("y")) - margin, 0), + width + margin * 2, + height + margin * 2, + ], + } + ) + return sections + + +def render_svg_section(path: Path, section_index: int) -> str: + sections = extract_svg_sections(path) + if section_index < 0 or section_index >= len(sections): + raise IndexError("Section introuvable") + x, y, width, height = sections[section_index]["view_box"] + text = path.read_text(encoding="utf-8", errors="ignore") + text = re.sub(r'\swidth="[^"]+"', f' width="{int(width)}"', text, count=1) + text = re.sub(r'\sheight="[^"]+"', f' height="{int(height)}"', text, count=1) + text = re.sub( + r'\sviewBox="[^"]+"', + f' viewBox="{x:g} {y:g} {width:g} {height:g}"', + text, + count=1, + ) + return text + + @lru_cache(maxsize=1) def list_lessons() -> list[dict]: lessons: list[dict] = [] @@ -106,15 +163,25 @@ def list_lessons() -> list[dict]: ) 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 - ] + assets = [] + for svg_path in svg_files: + relative_path = svg_path.relative_to(CONTENT_ROOT).as_posix() + sections = extract_svg_sections(svg_path) + assets.append( + { + "name": svg_path.stem, + "title": title_from_slug(svg_path.stem), + "path": relative_path, + "url": build_asset_url(relative_path), + "sections": [ + { + **section, + "url": build_section_url(relative_path, section["index"]), + } + for section in sections + ], + } + ) lessons.append( { diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bfa5dfb..260446e 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -106,6 +106,7 @@ class ProgramAsset(BaseModel): title: str path: str url: str + sections: List[dict] = [] class ProgramLesson(BaseModel): diff --git a/backend/app/services.py b/backend/app/services.py index b632129..ff4948d 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -131,6 +131,11 @@ def get_student_context(db: Session, student_id: int) -> str: lines.append( f"Leçon programme sélectionnée par le professeur: {lesson['title']} ({lesson['subject']}, {lesson['grade']})." ) + first_asset = (lesson.get("assets") or [None])[0] + if first_asset: + section_titles = [section["title"] for section in first_asset.get("sections", [])] + if section_titles: + lines.append("Étapes de la première fiche: " + ", ".join(section_titles) + ".") 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}" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 22f815c..0e99540 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -212,6 +212,7 @@ function TutorApp({ currentUser, onLogout }) { const [programStatus, setProgramStatus] = useState(null) const [studentProgram, setStudentProgram] = useState(null) const [activeAssetIndex, setActiveAssetIndex] = useState(0) + const [activeSectionIndex, setActiveSectionIndex] = useState(0) const [assetFullscreenOpen, setAssetFullscreenOpen] = useState(false) const [progressPanelOpen, setProgressPanelOpen] = useState(false) const [settingsPanelOpen, setSettingsPanelOpen] = useState(false) @@ -323,6 +324,7 @@ function TutorApp({ currentUser, onLogout }) { useEffect(() => { setActiveAssetIndex(0) + setActiveSectionIndex(0) }, [studentProgram?.id]) useEffect(() => { @@ -588,6 +590,30 @@ function TutorApp({ currentUser, onLogout }) { } } + function goToPreviousLessonStep() { + if (activeSectionIndex > 0) { + setActiveSectionIndex((index) => index - 1) + return + } + if (activeAssetIndex > 0) { + const previousAsset = lessonAssets[activeAssetIndex - 1] + setActiveAssetIndex((index) => index - 1) + setActiveSectionIndex(Math.max((previousAsset?.sections?.length || 1) - 1, 0)) + } + } + + function goToNextLessonStep() { + const sectionCount = activeSections.length || 1 + if (activeSectionIndex < sectionCount - 1) { + setActiveSectionIndex((index) => index + 1) + return + } + if (activeAssetIndex < lessonAssets.length - 1) { + setActiveAssetIndex((index) => index + 1) + setActiveSectionIndex(0) + } + } + async function finishSpeechPlayback(debugMessage) { setSpeaking(false) if (audioPlaybackUrlRef.current) { @@ -941,6 +967,21 @@ function TutorApp({ currentUser, onLogout }) { const activeLessonTitle = studentProgram?.title || lessonFocus?.label || 'Cours du jour' const lessonAssets = studentProgram?.assets || [] const activeAsset = lessonAssets[activeAssetIndex] || lessonAssets[0] || null + const activeSections = activeAsset?.sections || [] + const activeSection = activeSections[activeSectionIndex] || null + const totalLessonSteps = lessonAssets.reduce( + (total, asset) => total + Math.max(asset.sections?.length || 0, 1), + 0 + ) + const completedBeforeAsset = lessonAssets + .slice(0, activeAssetIndex) + .reduce((total, asset) => total + Math.max(asset.sections?.length || 0, 1), 0) + const activeLessonStep = totalLessonSteps + ? completedBeforeAsset + (activeSection ? activeSectionIndex + 1 : 1) + : 0 + const lessonStepPercent = totalLessonSteps + ? Math.min(100, Math.round((activeLessonStep / totalLessonSteps) * 100)) + : 0 const lessonSummary = lastAssistantMessage?.content || 'Aucune ancienne séance trouvée pour le moment. Professeur TOP commencera par une reprise courte.' const lastStudentWork = lastUserMessage?.content || 'Aucune réponse récente enregistrée.' const nextLessonAdvice = studentProgram @@ -1033,7 +1074,7 @@ function TutorApp({ currentUser, onLogout }) { <span className="stage-label">Professeur TOP</span> {activeAsset && ( <span className="asset-counter"> - Fiche {activeAssetIndex + 1}/{lessonAssets.length} + {lessonStepPercent}% · Fiche {activeAssetIndex + 1}/{lessonAssets.length} </span> )} </div> @@ -1045,21 +1086,21 @@ function TutorApp({ currentUser, onLogout }) { {activeAsset && ( <div className="lesson-asset-view" aria-label="Fiche de leçon active"> <div className="lesson-asset-toolbar"> - <strong>{activeAsset.title}</strong> + <strong>{activeSection?.title || activeAsset.title}</strong> <div className="lesson-asset-actions"> <button type="button" className="secondary-button" - onClick={() => setActiveAssetIndex((index) => Math.max(index - 1, 0))} - disabled={activeAssetIndex === 0} + onClick={goToPreviousLessonStep} + disabled={activeLessonStep <= 1} > Précédente </button> <button type="button" className="secondary-button" - onClick={() => setActiveAssetIndex((index) => Math.min(index + 1, lessonAssets.length - 1))} - disabled={activeAssetIndex >= lessonAssets.length - 1} + onClick={goToNextLessonStep} + disabled={activeLessonStep >= totalLessonSteps} > Suivante </button> @@ -1071,7 +1112,10 @@ function TutorApp({ currentUser, onLogout }) { onClick={() => setAssetFullscreenOpen(true)} aria-label="Agrandir la fiche" > - <img src={activeAsset.url} alt={activeAsset.title} /> + <img + src={activeSection?.url || activeAsset.url} + alt={activeSection?.title || activeAsset.title} + /> <span>Agrandir la fiche</span> </button> </div> @@ -1154,28 +1198,33 @@ function TutorApp({ currentUser, onLogout }) { {assetFullscreenOpen && activeAsset && ( <div className="asset-fullscreen" role="dialog" aria-label="Fiche agrandie"> <div className="asset-fullscreen-bar"> - <strong>Fiche {activeAssetIndex + 1}/{lessonAssets.length}</strong> + <strong> + Étape {activeLessonStep}/{totalLessonSteps} · {activeSection?.title || activeAsset.title} + </strong> <button type="button" className="secondary-button" onClick={() => setAssetFullscreenOpen(false)}> Fermer </button> </div> <div className="asset-fullscreen-stage"> - <img src={activeAsset.url} alt={activeAsset.title} /> + <img + src={activeSection?.url || activeAsset.url} + alt={activeSection?.title || activeAsset.title} + /> </div> <div className="asset-fullscreen-controls"> <button type="button" className="secondary-button" - onClick={() => setActiveAssetIndex((index) => Math.max(index - 1, 0))} - disabled={activeAssetIndex === 0} + onClick={goToPreviousLessonStep} + disabled={activeLessonStep <= 1} > Précédente </button> <button type="button" className="secondary-button" - onClick={() => setActiveAssetIndex((index) => Math.min(index + 1, lessonAssets.length - 1))} - disabled={activeAssetIndex >= lessonAssets.length - 1} + onClick={goToNextLessonStep} + disabled={activeLessonStep >= totalLessonSteps} > Suivante </button> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index b0b4020..01a698c 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -824,7 +824,7 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .lesson-asset-open img { - max-height: 24dvh; + max-height: 31dvh; } .asset-fullscreen { @@ -832,7 +832,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } } .asset-fullscreen-stage img { - width: max(100%, 1120px); + width: 100%; + min-height: 100%; } .asset-fullscreen-controls {