Card context

This commit is contained in:
2026-05-02 18:33:38 +02:00
parent 23e6125fb3
commit 7dccc51ce1
6 changed files with 121 additions and 12 deletions

View File

@@ -26,6 +26,7 @@ from .program_content import (
) )
from .services import ( from .services import (
build_llm_reply, build_llm_reply,
build_lesson_reply,
ensure_student_mastery, ensure_student_mastery,
evaluate_answer, evaluate_answer,
list_tts_profiles, list_tts_profiles,
@@ -347,20 +348,27 @@ def chat(
lesson_context = "" lesson_context = ""
if payload.lesson_title or payload.section_title or payload.asset_title: if payload.lesson_title or payload.section_title or payload.asset_title:
lesson_context = ( lesson_context = (
"\n\nContexte de lécran actuellement montré à lélève: " f"Leçon courante: {payload.lesson_title or 'non précisée'}\n"
f"leçon={payload.lesson_title or 'non précisée'}, " f"Fiche courante: {payload.asset_title or 'non précisée'}\n"
f"fiche={payload.asset_title or 'non précisée'}, " f"Card courante: {payload.section_title or 'non précisée'}\n"
f"carte affichée={payload.section_title or 'non précisée'}, " f"Clé de contexte: {payload.section_context_key or 'non précisée'}\n"
f"avancement={payload.step_percent if payload.step_percent is not None else 'non précisé'}%. " f"Avancement affiché: {payload.step_percent if payload.step_percent is not None else 'non précisé'}%\n"
"Réponds uniquement à propos de cette carte. Vérifie que lélève comprend ce qui est affiché. " "Règle: réponds uniquement à propos de cette card et vérifie la compréhension de l'élève.\n"
"Si lélève dit quil a compris, invite-le à passer à létape suivante."
) )
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.add(models.Message(student_id=student.id, role="assistant", content=reply))
db.commit() db.commit()
return schemas.ChatResponse(reply=reply) return schemas.ChatResponse(reply=reply, advance_lesson_step=advance_lesson_step)
@app.post("/transcribe") @app.post("/transcribe")

View File

@@ -79,7 +79,17 @@ def build_section_url(relative_path: str, section_index: int) -> str:
return f"/api/program/assets/{token}/sections/{section_index}" 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") text = svg_path.read_text(encoding="utf-8", errors="ignore")
sections: list[dict] = [] sections: list[dict] = []
pattern = re.compile( 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 "") 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}" title = html.unescape(raw_title).strip() or f"Étape {len(sections) + 1}"
margin = 28 margin = 28
section_number = len(sections) + 1
context_path = find_section_context_path(svg_path, asset_number or 1, section_number)
sections.append( sections.append(
{ {
"index": len(sections), "index": len(sections),
"title": title, "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": [ "view_box": [
max(float(match.group("x")) - margin, 0), max(float(match.group("x")) - margin, 0),
max(float(match.group("y")) - margin, 0), max(float(match.group("y")) - margin, 0),
@@ -164,9 +180,9 @@ def list_lessons() -> list[dict]:
title = first_heading or title title = first_heading or title
assets = [] 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() 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( assets.append(
{ {
"name": svg_path.stem, "name": svg_path.stem,

View File

@@ -57,12 +57,15 @@ class ChatRequest(BaseModel):
lesson_title: str | None = None lesson_title: str | None = None
asset_title: str | None = None asset_title: str | None = None
section_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 step_percent: int | None = None
class ChatResponse(BaseModel): class ChatResponse(BaseModel):
reply: str reply: str
should_speak: bool = True should_speak: bool = True
advance_lesson_step: bool = False
class MessageRead(BaseModel): class MessageRead(BaseModel):

View File

@@ -1,4 +1,6 @@
import json
import os import os
import re
from typing import List from typing import List
from openai import OpenAI from openai import OpenAI
from sqlalchemy.orm import Session 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() 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]: def list_tts_profiles() -> list[dict]:
return [ return [
{ {

View File

@@ -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.

View File

@@ -617,6 +617,8 @@ function TutorApp({ currentUser, onLogout }) {
lesson_title: activeLessonTitle, lesson_title: activeLessonTitle,
asset_title: activeAsset?.title || null, asset_title: activeAsset?.title || null,
section_title: activeSection?.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, step_percent: lessonStepPercent,
} }
} }
@@ -740,6 +742,9 @@ function TutorApp({ currentUser, onLogout }) {
}) })
appendMessage('assistant', data.reply) appendMessage('assistant', data.reply)
setCurrentInstruction(data.reply) setCurrentInstruction(data.reply)
if (data.advance_lesson_step) {
goToNextLessonStep()
}
speak(data.reply) speak(data.reply)
} }