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.*?)', 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 }) {
Professeur TOP
{activeAsset && (
- Fiche {activeAssetIndex + 1}/{lessonAssets.length}
+ {lessonStepPercent}% · Fiche {activeAssetIndex + 1}/{lessonAssets.length}
)}
@@ -1045,21 +1086,21 @@ function TutorApp({ currentUser, onLogout }) {
{activeAsset && (
-
{activeAsset.title}
+
{activeSection?.title || activeAsset.title}
@@ -1071,7 +1112,10 @@ function TutorApp({ currentUser, onLogout }) {
onClick={() => setAssetFullscreenOpen(true)}
aria-label="Agrandir la fiche"
>
-

+
Agrandir la fiche
@@ -1154,28 +1198,33 @@ function TutorApp({ currentUser, onLogout }) {
{assetFullscreenOpen && activeAsset && (
- Fiche {activeAssetIndex + 1}/{lessonAssets.length}
+
+ Étape {activeLessonStep}/{totalLessonSteps} · {activeSection?.title || activeAsset.title}
+
-

+
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 {