328 lines
13 KiB
Python
328 lines
13 KiB
Python
import json
|
||
import os
|
||
import re
|
||
from typing import List
|
||
from openai import OpenAI
|
||
from sqlalchemy.orm import Session
|
||
from . import models
|
||
from .curriculum import QUESTIONS, SKILLS
|
||
from .program_content import get_lesson
|
||
|
||
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||
|
||
|
||
SYSTEM_PROMPT = """
|
||
Tu es Professeur TOP, un professeur virtuel français pour enfants de 8 à 12 ans.
|
||
Règles :
|
||
- Tu parles toujours en français simple et chaleureux.
|
||
- Tu donnes des explications très courtes, puis un mini exemple.
|
||
- Tu tiens compte du niveau de l'élève et de ses faiblesses indiquées dans le contexte.
|
||
- Tu n'inventes pas la progression : elle est fournie dans le contexte.
|
||
- Tu encourages sans infantiliser.
|
||
- Quand l'élève se trompe, tu expliques calmement puis proposes une question très simple.
|
||
- Tu enseignes principalement le programme national français niveau primaire/cycle 3.
|
||
""".strip()
|
||
|
||
TURN_TAKING_PROMPT = """
|
||
Consignes de rythme oral:
|
||
- Fais des tours de parole courts: 1 a 3 phrases maximum, sauf demande explicite.
|
||
- Termine souvent par une question ou une consigne courte pour laisser l'enfant parler.
|
||
- Ne fais pas de longs monologues: une idee a la fois.
|
||
- Si l’élève t’interrompt, arrête-toi, écoute son message, puis reprends seulement si c’est utile.
|
||
- Si l'interruption semble accidentelle ou hors sujet, reponds tres brievement puis continue.
|
||
""".strip()
|
||
|
||
TTS_PROFILES = [
|
||
{
|
||
"id": "rigolote",
|
||
"label": "Rigolote",
|
||
"description": "La plus joueuse et cartoon.",
|
||
"voice": "coral",
|
||
"speed": 1.08,
|
||
"instructions": (
|
||
"Parle en français avec une énergie joyeuse, malicieuse et très expressive. "
|
||
"Tu es Professeur TOP, un professeur amusant, chaleureux et un peu théâtral. "
|
||
"Le ton doit rester clair pour un enfant, avec des intonations souriantes, "
|
||
"des fins de phrases dynamiques et une diction très vivante."
|
||
),
|
||
},
|
||
{
|
||
"id": "petillante",
|
||
"label": "Pétillante",
|
||
"description": "Enjouée, dynamique et encourageante.",
|
||
"voice": "shimmer",
|
||
"speed": 1.03,
|
||
"instructions": (
|
||
"Parle en français avec une voix lumineuse, motivante et positive. "
|
||
"Tu es Professeur TOP: enthousiaste, rassurant et très engageant, "
|
||
"sans caricature excessive. Garde une diction nette et chaleureuse."
|
||
),
|
||
},
|
||
{
|
||
"id": "douce",
|
||
"label": "Douce",
|
||
"description": "Calme, rassurante et patiente.",
|
||
"voice": "sage",
|
||
"speed": 0.98,
|
||
"instructions": (
|
||
"Parle en français avec une voix douce, calme et très rassurante. "
|
||
"Tu es Professeur TOP dans une version posée, bienveillante et patiente. "
|
||
"Le rythme est fluide, jamais pressé, avec une intonation apaisante."
|
||
),
|
||
},
|
||
{
|
||
"id": "sobre",
|
||
"label": "Sobre",
|
||
"description": "La plus neutre et sérieuse.",
|
||
"voice": "alloy",
|
||
"speed": 1.0,
|
||
"instructions": (
|
||
"Parle en français avec une voix claire, naturelle et sobre. "
|
||
"Tu es Professeur TOP dans une version très lisible, professionnelle et mesurée. "
|
||
"Reste chaleureux, mais sans effet théâtral."
|
||
),
|
||
},
|
||
]
|
||
|
||
|
||
def seed_skills(db: Session) -> None:
|
||
for skill in SKILLS:
|
||
existing = db.query(models.Skill).filter(models.Skill.code == skill["code"]).first()
|
||
if not existing:
|
||
db.add(models.Skill(**skill))
|
||
db.commit()
|
||
|
||
|
||
def ensure_student_mastery(db: Session, student: models.Student) -> None:
|
||
all_skills = db.query(models.Skill).all()
|
||
for skill in all_skills:
|
||
found = (
|
||
db.query(models.StudentSkillMastery)
|
||
.filter_by(student_id=student.id, skill_id=skill.id)
|
||
.first()
|
||
)
|
||
if not found:
|
||
db.add(models.StudentSkillMastery(student_id=student.id, skill_id=skill.id))
|
||
db.commit()
|
||
|
||
|
||
def get_student_context(db: Session, student_id: int, include_history: bool = True) -> str:
|
||
student = db.query(models.Student).filter_by(id=student_id).first()
|
||
mastery = (
|
||
db.query(models.StudentSkillMastery, models.Skill)
|
||
.join(models.Skill, models.Skill.id == models.StudentSkillMastery.skill_id)
|
||
.filter(models.StudentSkillMastery.student_id == student_id)
|
||
.all()
|
||
)
|
||
recent_messages = []
|
||
if include_history:
|
||
recent_messages = (
|
||
db.query(models.Message)
|
||
.filter_by(student_id=student_id)
|
||
.order_by(models.Message.created_at.desc())
|
||
.limit(6)
|
||
.all()
|
||
)
|
||
|
||
assignment = db.query(models.StudentProgramAssignment).filter_by(student_id=student_id).first()
|
||
lesson = get_lesson(assignment.lesson_id) if assignment else None
|
||
|
||
lines: List[str] = [
|
||
f"Élève: {student.first_name}, {student.age} ans, classe {student.grade}.",
|
||
"Progression par compétence:",
|
||
]
|
||
if lesson:
|
||
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}"
|
||
)
|
||
if include_history:
|
||
lines.append("Historique récent:")
|
||
for message in reversed(recent_messages):
|
||
lines.append(f"- {message.role}: {message.content}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def build_llm_reply(db: Session, student_id: int, user_message: str) -> str:
|
||
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": "user",
|
||
"content": f"Contexte pédagogique:\n{context}\n\nMessage de l'élève:\n{user_message}",
|
||
},
|
||
],
|
||
temperature=0.7,
|
||
)
|
||
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, include_history=False)
|
||
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. "
|
||
"N'utilise que les nombres, mots de vocabulaire et actions explicitement autorisés dans le contexte de la card. "
|
||
"Si un nombre n'est pas affiché ou autorisé dans ce contexte, tu n'en parles pas. "
|
||
"Ne propose jamais 'un autre nombre' si le contexte ne donne pas cet autre nombre. "
|
||
"Si l'élève veut continuer mais que la card courante est terminée, mets `advance_lesson_step` à true. "
|
||
"Quand `advance_lesson_step` vaut true, ta réponse doit seulement dire: "
|
||
"`Très bien, on passe à la suite.` Ne répète pas le nombre de la card terminée. "
|
||
"N'utilise pas l'historique des séances précédentes pour choisir le thème de ta réponse. "
|
||
"Ta réponse visible doit être très courte: maximum 24 mots, 1 phrase d'explication "
|
||
"puis éventuellement 1 question courte. Évite les phrases longues avec plusieurs propositions. "
|
||
"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 [
|
||
{
|
||
"id": profile["id"],
|
||
"label": profile["label"],
|
||
"description": profile["description"],
|
||
}
|
||
for profile in TTS_PROFILES
|
||
]
|
||
|
||
|
||
def synthesize_speech(text: str, profile_id: str) -> bytes:
|
||
clean_text = text.strip()
|
||
if not clean_text:
|
||
raise ValueError("Texte à synthétiser manquant")
|
||
|
||
profile = next((item for item in TTS_PROFILES if item["id"] == profile_id), None)
|
||
if not profile:
|
||
raise ValueError("Profil de voix inconnu")
|
||
|
||
response = client.audio.speech.create(
|
||
model="gpt-4o-mini-tts",
|
||
voice=profile["voice"],
|
||
input=clean_text[:4096],
|
||
instructions=profile["instructions"],
|
||
response_format="mp3",
|
||
speed=profile["speed"],
|
||
)
|
||
return response.content
|
||
|
||
|
||
def transcribe_audio(filename: str, audio_bytes: bytes, content_type: str | None = None) -> str:
|
||
file_payload = (filename, audio_bytes, content_type or "application/octet-stream")
|
||
transcript = client.audio.transcriptions.create(
|
||
model="gpt-4o-mini-transcribe",
|
||
file=file_payload,
|
||
language="fr",
|
||
prompt=(
|
||
"Transcris uniquement en français. "
|
||
"Le locuteur est un enfant qui repond a un exercice scolaire. "
|
||
"Les phrases peuvent etre tres courtes, par exemple: nous chantons, je lis, 8 plus 5."
|
||
),
|
||
)
|
||
return (getattr(transcript, "text", "") or "").strip()
|
||
|
||
|
||
def pick_next_skill(db: Session, student_id: int) -> models.Skill:
|
||
weakest = (
|
||
db.query(models.StudentSkillMastery)
|
||
.filter_by(student_id=student_id)
|
||
.order_by(models.StudentSkillMastery.mastery_score.asc())
|
||
.first()
|
||
)
|
||
return db.query(models.Skill).filter_by(id=weakest.skill_id).first()
|
||
|
||
|
||
def evaluate_answer(db: Session, student_id: int, skill_code: str, answer: str):
|
||
q = QUESTIONS[skill_code]
|
||
normalized_student = answer.strip().lower()
|
||
normalized_expected = q["expected_answer"].strip().lower()
|
||
correct = normalized_student == normalized_expected
|
||
|
||
skill = db.query(models.Skill).filter_by(code=skill_code).first()
|
||
mastery = (
|
||
db.query(models.StudentSkillMastery)
|
||
.filter_by(student_id=student_id, skill_id=skill.id)
|
||
.first()
|
||
)
|
||
|
||
if correct:
|
||
mastery.mastery_score = min(100.0, mastery.mastery_score + 8)
|
||
mastery.confidence = min(1.0, mastery.confidence + 0.15)
|
||
feedback = q["feedback_ok"]
|
||
else:
|
||
mastery.mastery_score = max(0.0, mastery.mastery_score - 6)
|
||
mastery.confidence = min(1.0, mastery.confidence + 0.1)
|
||
feedback = q["feedback_ko"]
|
||
|
||
mastery.evidence_count += 1
|
||
|
||
db.add(
|
||
models.AssessmentAttempt(
|
||
student_id=student_id,
|
||
skill_code=skill_code,
|
||
question=q["question"],
|
||
expected_answer=q["expected_answer"],
|
||
student_answer=answer,
|
||
is_correct=1 if correct else 0,
|
||
feedback=feedback,
|
||
)
|
||
)
|
||
db.commit()
|
||
db.refresh(mastery)
|
||
return correct, feedback, mastery.mastery_score
|