Files
open-school/backend/app/services.py
2026-05-02 19:52:48 +02:00

326 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 tinterrompt, arrête-toi, écoute son message, puis reprends seulement si cest 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. "
"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