Card context
This commit is contained in:
@@ -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 qu’il 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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user