From 7dccc51ce1fc4aa388d250d6e90f308f4b7a7edb Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Sat, 2 May 2026 18:33:38 +0200 Subject: [PATCH] Card context --- backend/app/main.py | 26 ++++++--- backend/app/program_content.py | 22 +++++++- backend/app/schemas.py | 3 + backend/app/services.py | 56 +++++++++++++++++++ .../Fiche1Card1.md | 21 +++++++ frontend/src/App.jsx | 5 ++ 6 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card1.md diff --git a/backend/app/main.py b/backend/app/main.py index d3bcceb..612af8d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,7 @@ from .program_content import ( ) from .services import ( build_llm_reply, + build_lesson_reply, ensure_student_mastery, evaluate_answer, list_tts_profiles, @@ -347,20 +348,27 @@ def chat( lesson_context = "" if payload.lesson_title or payload.section_title or payload.asset_title: lesson_context = ( - "\n\nContexte de l’écran actuellement montré à l’élève: " - f"leçon={payload.lesson_title or 'non précisée'}, " - f"fiche={payload.asset_title or 'non précisée'}, " - f"carte affichée={payload.section_title or 'non précisée'}, " - f"avancement={payload.step_percent if payload.step_percent is not None else 'non précisé'}%. " - "Réponds uniquement à propos de cette carte. Vérifie que l’élève comprend ce qui est affiché. " - "Si l’élève dit qu’il a compris, invite-le à passer à l’étape suivante." + 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" + f"Card courante: {payload.section_title or 'non précisée'}\n" + f"Clé de contexte: {payload.section_context_key or 'non précisée'}\n" + f"Avancement affiché: {payload.step_percent if payload.step_percent is not None else 'non précisé'}%\n" + "Règle: réponds uniquement à propos de cette card et vérifie la compréhension de l'élève.\n" ) + if payload.section_context: + lesson_context += f"\nTexte pédagogique de la card courante:\n{payload.section_context.strip()}" - reply = build_llm_reply(db, payload.student_id, f"{payload.message}{lesson_context}") + if lesson_context: + lesson_reply = build_lesson_reply(db, payload.student_id, payload.message, lesson_context) + reply = lesson_reply["reply"] + advance_lesson_step = lesson_reply["advance_lesson_step"] + else: + reply = build_llm_reply(db, payload.student_id, payload.message) + advance_lesson_step = False db.add(models.Message(student_id=student.id, role="assistant", content=reply)) db.commit() - return schemas.ChatResponse(reply=reply) + return schemas.ChatResponse(reply=reply, advance_lesson_step=advance_lesson_step) @app.post("/transcribe") diff --git a/backend/app/program_content.py b/backend/app/program_content.py index ffa9dcc..3190621 100644 --- a/backend/app/program_content.py +++ b/backend/app/program_content.py @@ -79,7 +79,17 @@ def build_section_url(relative_path: str, section_index: int) -> str: return f"/api/program/assets/{token}/sections/{section_index}" -def extract_svg_sections(svg_path: Path) -> list[dict]: +def find_section_context_path(svg_path: Path, asset_number: int, section_number: int) -> Path | None: + context_dir = svg_path.parent.parent / "card_contexts" / svg_path.stem + candidates = [ + context_dir / f"Fiche{asset_number}Card{section_number}.md", + context_dir / f"fiche_{asset_number:02d}_card_{section_number:02d}.md", + context_dir / f"card_{section_number:02d}.md", + ] + return next((candidate for candidate in candidates if candidate.exists()), None) + + +def extract_svg_sections(svg_path: Path, asset_number: int | None = None) -> list[dict]: text = svg_path.read_text(encoding="utf-8", errors="ignore") sections: list[dict] = [] pattern = re.compile( @@ -97,10 +107,16 @@ def extract_svg_sections(svg_path: Path) -> list[dict]: 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 + section_number = len(sections) + 1 + context_path = find_section_context_path(svg_path, asset_number or 1, section_number) sections.append( { "index": len(sections), "title": title, + "context_key": f"Fiche{asset_number or 1}Card{section_number}", + "context": context_path.read_text(encoding="utf-8", errors="ignore").strip() + if context_path + else "", "view_box": [ max(float(match.group("x")) - margin, 0), max(float(match.group("y")) - margin, 0), @@ -164,9 +180,9 @@ def list_lessons() -> list[dict]: title = first_heading or title assets = [] - for svg_path in svg_files: + for asset_index, svg_path in enumerate(svg_files, start=1): relative_path = svg_path.relative_to(CONTENT_ROOT).as_posix() - sections = extract_svg_sections(svg_path) + sections = extract_svg_sections(svg_path, asset_index) assets.append( { "name": svg_path.stem, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 561ccd8..fef0643 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -57,12 +57,15 @@ class ChatRequest(BaseModel): lesson_title: str | None = None asset_title: str | None = None section_title: str | None = None + section_context_key: str | None = None + section_context: str | None = None step_percent: int | None = None class ChatResponse(BaseModel): reply: str should_speak: bool = True + advance_lesson_step: bool = False class MessageRead(BaseModel): diff --git a/backend/app/services.py b/backend/app/services.py index ff4948d..dc814d3 100644 --- a/backend/app/services.py +++ b/backend/app/services.py @@ -1,4 +1,6 @@ +import json import os +import re from typing import List from openai import OpenAI from sqlalchemy.orm import Session @@ -163,6 +165,60 @@ def build_llm_reply(db: Session, student_id: int, user_message: str) -> str: return response.output_text.strip() +def build_lesson_reply(db: Session, student_id: int, user_message: str, lesson_context: str) -> dict: + context = get_student_context(db, student_id) + response = client.responses.create( + model="gpt-4.1-mini", + input=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "system", "content": TURN_TAKING_PROMPT}, + { + "role": "system", + "content": ( + "Tu reçois un contexte courant de leçon lié à la card affichée. " + "Tu dois parler uniquement de cette card, sauf si l'élève pose une question de clarification immédiate. " + "Si la réponse de l'élève montre clairement qu'il a compris la card courante, mets " + "`advance_lesson_step` à true. Sinon, mets-le à false. " + "Réponds strictement en JSON valide avec les clés `reply` et `advance_lesson_step`. " + "`reply` contient uniquement ce que Professeur TOP dit à l'élève, sans JSON visible." + ), + }, + { + "role": "user", + "content": ( + f"Contexte pédagogique général:\n{context}\n\n" + f"Contexte courant de la card affichée:\n{lesson_context}\n\n" + f"Message de l'élève:\n{user_message}" + ), + }, + ], + temperature=0.4, + ) + raw_text = response.output_text.strip() + json_text = raw_text + if json_text.startswith("```"): + json_text = re.sub(r"^```(?:json)?\s*", "", json_text) + json_text = re.sub(r"\s*```$", "", json_text) + if "{" in json_text and "}" in json_text: + json_text = json_text[json_text.find("{") : json_text.rfind("}") + 1] + try: + parsed = json.loads(json_text) + except json.JSONDecodeError: + return {"reply": raw_text, "advance_lesson_step": False} + + reply = str(parsed.get("reply") or "").strip() + advance_value = parsed.get("advance_lesson_step") + advance_lesson_step = ( + advance_value + if isinstance(advance_value, bool) + else str(advance_value).strip().lower() in {"true", "oui", "yes", "1"} + ) + return { + "reply": reply or raw_text, + "advance_lesson_step": advance_lesson_step, + } + + def list_tts_profiles() -> list[dict]: return [ { diff --git a/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card1.md b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card1.md new file mode 100644 index 0000000..9bc5269 --- /dev/null +++ b/backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01A_nombres_entiers/card_contexts/01_lecture_ecriture_nombres_entiers/Fiche1Card1.md @@ -0,0 +1,21 @@ +# Fiche 1 - Card 1 - La méthode + +Cette card présente la méthode pour lire un nombre entier de 6 chiffres. + +Idée à enseigner : +- On découpe le nombre en groupes de 3 chiffres en partant de la droite. +- Dans l'exemple `345 208`, le groupe `345` est le groupe des milliers. +- Le groupe `208` est le groupe des unités simples. +- Ce découpage aide à lire le nombre sans se perdre. + +Consigne orale courte pour Professeur TOP : +Explique que pour lire un grand nombre, on le sépare en deux paquets de trois chiffres : d'abord les milliers, puis les unités simples. Prends uniquement l'exemple affiché `345 208`. Demande ensuite à l'élève s'il comprend pourquoi on coupe entre `345` et `208`. + +Limites : +- Ne parle pas encore de toute la fiche. +- Ne donne pas les autres exemples de la fiche. +- Ne passe pas à la lecture complète du nombre sauf si l'élève demande ou semble prêt. +- Reste sur le thème : découper un nombre de 6 chiffres en deux groupes. + +Critère pour passer à la card suivante : +L'élève peut passer à la suite s'il dit clairement qu'il a compris, ou s'il explique avec ses mots que `345` représente les milliers et `208` les unités simples. diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index be702b5..c028ec8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -617,6 +617,8 @@ function TutorApp({ currentUser, onLogout }) { lesson_title: activeLessonTitle, asset_title: activeAsset?.title || null, section_title: activeSection?.title || activeAsset?.title || null, + section_context_key: activeSection?.context_key || null, + section_context: activeSection?.context || null, step_percent: lessonStepPercent, } } @@ -740,6 +742,9 @@ function TutorApp({ currentUser, onLogout }) { }) appendMessage('assistant', data.reply) setCurrentInstruction(data.reply) + if (data.advance_lesson_step) { + goToNextLessonStep() + } speak(data.reply) }