expert_ifab / app.py
Foccuus's picture
Update app.py
0467675 verified
Raw
History Blame Contribute Delete
32.6 kB
import os
import re
import numpy as np
import together
import gradio as gr
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
# ─── Constantes ───────────────────────────────────────────────────────────────
TOGETHER_API_KEY = os.environ.get("KEY_SECRET")
DOCUMENT_PATH = "Lois du Jeu 2025-26-pages simples.txt"
EMBEDDING_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
CHUNK_SIZE = 600
CHUNK_OVERLAP = 100
MIN_CHUNK = 150
TOP_K = 8
BM25_CANDIDATES = 25
BM25_SEUIL = 2.0
MAX_HISTORY = 3
together_client = together.Together(api_key=TOGETHER_API_KEY)
# ─── Informations méta ────────────────────────────────────────────────────────
META_INFO = """
Informations sur ce chatbot :
- Nom : Expert Arbitrage IFAB
- Créé par : Foccuus (ce chatbot a été entièrement conçu et développé par Foccuus)
- Modèle de langage utilisé (LLM) : Llama 3.3 70B Instruct Turbo, un modèle open-source créé par Meta,
utilisé comme moteur de génération de texte, hébergé via la plateforme Together AI.
Attention : Meta a créé le modèle de langage, mais Meta n'a PAS créé ce chatbot. Ce chatbot a été créé par Foccuus.
- Moteur de recherche : Hybride BM25 (Okapi) + Sentence-Transformers (paraphrase-multilingual-MiniLM-L12-v2)
- Document de référence : Lois du Jeu de l'IFAB 2025/26 (texte intégral en français)
- Interface : Gradio, déployé sur Hugging Face Spaces
- Fonctionnement : la question est reformulée en mots-clés techniques, une recherche hybride identifie
les extraits les plus pertinents, puis le LLM génère une réponse basée uniquement sur ces extraits.
""".strip()
MOTS_META = [
"modèle", "model", "llm", "llama", "intelligence artificielle",
"together", "bm25", "embedding", "sentence", "transformer", "rag",
"chatbot", "bot", "comment tu fonctionnes", "comment fonctionne",
"qui t'a créé", "qui t'a fait", "qui a créé", "qui a fait",
"développé", "construit", "technologie", "architecture",
"hugging face", "gradio", "api", "quel outil", "quel logiciel",
"qui es-tu", "qui es tu", "tu es quoi", "t'a fait", "t as fait",
"fait avec", "basé sur", "base sur", "fonctionne comment",
"comment es-tu", "comment es tu", "tu fonctionnes", "tu marches",
"ia utilisée", "ia utilisee", "ia utilisé", "ai utilisé",
"quel ia", "quelle ia", "quel ai", "quelle ai", "comment as-tu"
]
MOTS_FOOTBALL = [
"joueur", "ballon", "but", "arbitre", "faute", "penalty", "carton",
"rouge", "jaune", "hors-jeu", "offside", "touche", "corner", "coup franc",
"gardien", "surface", "réparation", "équipe", "match", "terrain",
"loi", "ifab", "remplaçant", "capitaine", "tir au but", "prolongation",
"mi-temps", "avertissement", "exclusion", "main", "dégagement",
"engagement", "football", "foot", "soccer", "expulsion", "infraction",
"sanction", "but refusé", "var", "goal", "passe", "tacle",
"simulation", "obstruction", "règle", "reglement"
]
# Marqueurs à ignorer dans la mémoire (messages de chargement)
MARQUEURS_CHARGEMENT = [
"🔍", "📚", "⚙️", "analyse de votre question",
"reformulation", "recherche dans les lois"
]
def est_question_meta(question):
q = question.lower().replace("'", "'").replace("`", "'")
return any(mot in q for mot in MOTS_META)
def est_hors_sujet(question, meilleur_bm25):
q = question.lower()
contient_mot_football = any(mot in q for mot in MOTS_FOOTBALL)
return meilleur_bm25 < BM25_SEUIL and not contient_mot_football
def est_message_chargement(contenu):
"""Détecte si un message est un indicateur de chargement (à exclure de la mémoire)."""
c = contenu.lower()
return any(m in c for m in MARQUEURS_CHARGEMENT)
# ─── Chargement et découpage ──────────────────────────────────────────────────
def charger_et_decouper(chemin):
texte = None
for encodage in ["utf-8", "latin-1", "cp1252"]:
try:
with open(chemin, "r", encoding=encodage) as f:
texte = f.read()
print(f"Fichier lu avec encodage : {encodage}")
break
except UnicodeDecodeError:
continue
if texte is None:
raise ValueError(f"Impossible de lire : {chemin}")
texte = re.sub(r'\n{3,}', '\n\n', texte).strip()
chunks_list, debut = [], 0
while debut < len(texte):
fin = min(debut + CHUNK_SIZE, len(texte))
if fin < len(texte):
coupe = texte.rfind('.', debut + MIN_CHUNK, fin)
if coupe > debut:
fin = coupe + 1
chunk = texte[debut:fin].strip()
if len(chunk) >= MIN_CHUNK:
chunks_list.append(chunk)
if fin >= len(texte):
break
nouveau_debut = fin - CHUNK_OVERLAP
if nouveau_debut <= debut:
nouveau_debut = debut + 1
debut = nouveau_debut
print(f"Nombre de chunks : {len(chunks_list)}")
return chunks_list
def construire_index_bm25(chunks_list):
tokenized = [re.findall(r'\b\w+\b', c.lower()) for c in chunks_list]
return BM25Okapi(tokenized)
# ─── Recherche hybride ────────────────────────────────────────────────────────
def reformuler_question(question, memoire):
"""Reformule la question en mots-clés techniques IFAB."""
ctx = f"Contexte de la conversation :\n{memoire}\n\n" if memoire else ""
prompt = (
f"{ctx}Question : {question}\n\n"
"Extrais 6 à 10 mots-clés techniques du football et des Lois du Jeu IFAB. "
"Inclus les synonymes importants (ex: carton rouge = expulsion = infractions passibles). "
"Réponds UNIQUEMENT avec les mots-clés séparés par des espaces, sans ponctuation."
)
try:
rep = together_client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
max_tokens=60
)
mots = rep.choices[0].message.content.strip()
return question + " " + mots
except Exception as e:
print(f"[reformuler_question] Erreur : {e}")
return question
def rechercher_hybride(question_enrichie, k=TOP_K):
"""BM25 filtre les candidats, sentence-transformers re-classe par sens."""
tokens = re.findall(r'\b\w+\b', question_enrichie.lower())
scores_bm25 = bm25_index.get_scores(tokens)
top_idx = sorted(
range(len(scores_bm25)),
key=lambda i: scores_bm25[i],
reverse=True
)[:BM25_CANDIDATES]
# BUG FIX : éviter la division par zéro si tous les scores sont nuls
scores_positifs = [scores_bm25[i] for i in top_idx if scores_bm25[i] > 0]
if not scores_positifs:
return []
max_bm25 = max(scores_positifs)
q_emb = modele_embedding.encode(question_enrichie, normalize_embeddings=True)
resultats = []
for i in top_idx:
if scores_bm25[i] <= 0:
continue
# BUG FIX : vérifier que l'index est valide avant d'accéder à embeddings_chunks
if i >= len(embeddings_chunks):
continue
score_st = float(np.dot(q_emb, embeddings_chunks[i]))
score_bm25_n = float(scores_bm25[i]) / max_bm25
score_hybride = round(0.4 * score_bm25_n + 0.6 * score_st, 3)
resultats.append({
"texte": all_chunks[i],
"score": score_hybride,
"bm25": round(float(scores_bm25[i]), 2),
})
resultats.sort(key=lambda x: x["score"], reverse=True)
return resultats[:k]
# ─── Extraction texte brut (Gradio 6.x stocke parfois du JSON) ──────────────
def extraire_texte(content):
"""
Extrait le texte brut du contenu, peu importe le format :
- str simple
- list Gradio : [{"type": "text", "text": "..."}]
- str sérialisée d'une list (bug d'imbrication)
"""
if content is None:
return ""
if isinstance(content, list):
parties = []
for bloc in content:
if isinstance(bloc, dict):
parties.append(extraire_texte(bloc.get("text", bloc.get("content", ""))))
return " ".join(parties).strip()
if isinstance(content, str):
# Détecter une liste sérialisée imbriquée et l'aplatir
s = content.strip()
if s.startswith("[{") and ("'text'" in s or '"text"' in s):
try:
import ast
parsed = ast.literal_eval(s)
if isinstance(parsed, list):
return extraire_texte(parsed)
except Exception:
pass
return content
return str(content)
# ─── Mémoire conversationnelle ────────────────────────────────────────────────
def construire_memoire(historique):
"""Extrait les derniers échanges réels, hors messages de chargement."""
precedents = historique[:-2] if len(historique) >= 2 else []
messages_reels = []
for m in precedents:
texte = extraire_texte(m.get("content", ""))
if texte.strip() and not est_message_chargement(texte):
messages_reels.append({"role": m["role"], "content": texte})
if not messages_reels:
return ""
recent = messages_reels[-(MAX_HISTORY * 2):]
lignes = ["Contexte de la conversation :"]
for msg in recent:
role = "Question" if msg["role"] == "user" else "Réponse"
# Nettoyer le badge de fin avant d'inclure dans la mémoire
texte_propre = msg["content"].split("\n\n---\n")[0]
lignes.append(f"{role} : {texte_propre[:300]}")
return "\n".join(lignes)
# ─── Sources formatées ────────────────────────────────────────────────────────
def extraire_lois(texte):
lois = re.findall(r'\bLoi\s+(\d+)\b', texte, re.IGNORECASE)
return sorted(set(lois), key=lambda x: int(x))
def formater_sources(extraits):
if not extraits:
return "Aucun extrait trouvé."
lignes = []
for i, e in enumerate(extraits):
lois = extraire_lois(e["texte"])
label = "📖 " + " | ".join(f"Loi {l}" for l in lois) if lois else "📄 Section générale"
lignes.append(
f"[Extrait {i+1}] {label}\n"
f"Score : {e['score']:.3f} | BM25 : {e['bm25']}\n"
+ "─" * 40 + "\n"
+ e["texte"][:380] + "..."
)
return "\n\n".join(lignes)
# ─── Évaluation de la réponse ─────────────────────────────────────────────────
def evaluer_reponse(reponse, extraits):
"""Vérifie via le LLM si la réponse est cohérente avec les extraits."""
if not reponse.strip() or len(reponse.strip()) < 60:
return "\n\n---\n⚠️ *Réponse trop courte — essayez de reformuler votre question.*"
if "ne figure pas dans les extraits" in reponse.lower():
return "\n\n---\n⚠️ *Information introuvable dans les extraits — essayez de reformuler.*"
contexte_court = "\n".join(e["texte"][:200] for e in extraits[:3])
prompt_eval = (
f"Extraits de référence :\n{contexte_court}\n\n"
f"Réponse générée :\n{reponse[:400]}\n\n"
"Cette réponse est-elle fidèle aux extraits sans informations inventées ? "
"Réponds uniquement par OUI ou NON."
)
try:
rep = together_client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages=[{"role": "user", "content": prompt_eval}],
temperature=0.0,
max_tokens=5
)
verdict = rep.choices[0].message.content.strip().upper()
if "NON" in verdict:
return "\n\n---\n⚠️ *Réponse à vérifier — consultez les extraits sources.*"
except Exception as e:
print(f"[evaluer_reponse] Erreur : {e}")
return "\n\n---\n✅ *Réponse vérifiée — basée sur les Lois du Jeu de l'IFAB.*"
# ─── Prompts ──────────────────────────────────────────────────────────────────
SYSTEME_IFAB = (
"Tu es un expert en arbitrage international spécialisé dans les Lois du Jeu de l'IFAB. "
"Réponds UNIQUEMENT à partir des extraits fournis, sans ajouter d'informations extérieures. "
"Synthétise les extraits pertinents en une réponse fluide, complète et bien organisée. "
"Si la réponse n'est pas dans les extraits, dis uniquement : "
"'Cette information ne figure pas dans les extraits fournis.' "
"Cite le numéro de Loi concerné quand il apparaît dans les extraits. "
"Rédige en prose avec des paragraphes clairs, sans numérotation rigide. "
"Tiens compte du contexte de conversation s'il est fourni. "
"Si on te demande de ré-expliquer, utilise des mots simples, des exemples concrets "
"et décompose les règles étape par étape. "
"Réponds en français avec un ton professionnel et pédagogique."
)
SYSTEME_META = (
"Tu es un assistant expert en arbitrage de football créé par Foccuus. "
"Réponds aux questions sur toi-même de façon claire, naturelle et concise, "
"en utilisant uniquement les informations techniques fournies. "
"IMPORTANT : tu as été créé par Foccuus. "
"Le modèle de langage que tu utilises (Llama 3.3, créé par Meta) est un outil, "
"comme un moteur dans une voiture — Meta a fait le moteur, mais Foccuus a construit la voiture. "
"Ne confonds jamais le créateur du modèle LLM (Meta) avec le créateur de ce chatbot (Foccuus). "
"Ne dis jamais que l'information ne figure pas dans les extraits. "
"Réponds en français avec un ton professionnel et chaleureux."
)
# ─── Streaming ────────────────────────────────────────────────────────────────
def lire_stream(stream):
"""Lit un stream Together en gérant les cas limites (None, choices vide)."""
for event in stream:
try:
if not event.choices:
continue
token = event.choices[0].delta.content
if token is not None:
yield token
except Exception as e:
print(f"[lire_stream] Erreur token : {e}")
continue
def chat_stream(message, historique):
# ── Sécurité : convertir en liste Python propre avec texte brut ──────────
try:
hist = [
{"role": m["role"], "content": extraire_texte(m.get("content", ""))}
for m in (historique or [])
]
except Exception as e:
print(f"[chat_stream] Erreur init historique : {e}")
hist = []
if not message or not message.strip():
yield hist, ""
return
print(f"\n[chat_stream] Q{len(hist)//2 + 1} : {message[:60]}")
# ── Ajout de la question + placeholder assistant ───────────────────────
hist = hist + [
{"role": "user", "content": message},
{"role": "assistant", "content": "🔍 Analyse de votre question..."}
]
yield hist, ""
# ── Cas 1 : Question méta ──────────────────────────────────────────────
if est_question_meta(message):
hist[-1]["content"] = "⚙️ Récupération des informations techniques..."
yield hist, "ℹ️ Question sur le chatbot — aucun extrait consulté."
try:
stream = together_client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages=[
{"role": "system", "content": SYSTEME_META},
{"role": "user", "content": (
f"Informations sur ce chatbot :\n---\n{META_INFO}\n---\n\n"
f"Question : {message}"
)}
],
temperature=0.2,
max_tokens=600,
stream=True
)
reponse = ""
for token in lire_stream(stream):
reponse += token
hist[-1]["content"] = reponse
yield hist, "ℹ️ Question sur le chatbot — aucun extrait consulté."
print(f"[chat_stream] Méta OK ({len(reponse)} chars)")
except Exception as e:
print(f"[chat_stream] Erreur méta : {e}")
hist[-1]["content"] = f"❌ Erreur : {e}"
yield hist, ""
return
# ── Cas 2 : Question vague (ex: "je ne comprends pas") ────────────────
QUESTIONS_VAGUES = [
"je ne comprends pas", "peux tu expliquer", "explique",
"c'est quoi", "c est quoi", "dis m'en plus", "plus de details",
"développe", "developpe", "que veux tu dire", "clarifier",
"je comprends pas", "j'ai pas compris", "j ai pas compris",
"c'est pas clair", "c est pas clair", "reformule", "précise",
"tu peux détailler", "tu peux expliquer"
]
msg_lower = message.lower().strip()
est_vague = (
len(message.strip()) < 15
or any(msg_lower == v or msg_lower.startswith(v) for v in QUESTIONS_VAGUES)
)
if est_vague:
# Récupérer la dernière question ET la dernière réponse
messages_prec = [m for m in hist[:-2]]
questions_prec = [m["content"] for m in messages_prec if m["role"] == "user"]
reponses_prec = [
m["content"].split("\n\n---\n")[0] # enlever le badge
for m in messages_prec if m["role"] == "assistant"
and not est_message_chargement(m.get("content", ""))
]
if questions_prec and reponses_prec:
# On demande au LLM de ré-expliquer la réponse précédente plus simplement
message_enrichi = (
f"L'utilisateur n'a pas compris la réponse précédente. "
f"Question initiale : {questions_prec[-1]}\n"
f"Réponse précédente : {reponses_prec[-1][:400]}\n"
f"Reformule et explique de façon plus simple et pédagogique."
)
print(f"[chat_stream] Question vague → ré-explication demandée")
elif questions_prec:
message_enrichi = questions_prec[-1] + " explique simplement"
else:
hist[-1]["content"] = (
"Pourriez-vous préciser votre question ?\n\n"
"Voici quelques exemples :\n"
"• *Dans quel cas un joueur est-il hors-jeu ?*\n"
"• *Quelles sont les fautes passibles d'un carton rouge ?*\n"
"• *Comment se déroule l'exécution d'un penalty ?*"
)
yield hist, ""
return
else:
message_enrichi = message
# ── Cas 3 : Mémoire conversationnelle ─────────────────────────────────
try:
memoire = construire_memoire(hist)
print(f"[chat_stream] Mémoire : {len(memoire)} chars")
except Exception as e:
print(f"[chat_stream] Erreur mémoire : {e}")
memoire = ""
# ── Cas 3 : Mémoire conversationnelle ─────────────────────────────────
try:
memoire = construire_memoire(hist)
print(f"[chat_stream] Mémoire : {len(memoire)} chars")
except Exception as e:
print(f"[chat_stream] Erreur mémoire : {e}")
memoire = ""
# ── Cas 4 : Reformulation ─────────────────────────────────────────────
hist[-1]["content"] = "🔍 Reformulation de la question..."
yield hist, ""
try:
question_enrichie = reformuler_question(message_enrichi, memoire)
print(f"[chat_stream] Question enrichie : {question_enrichie[:80]}")
except Exception as e:
print(f"[chat_stream] Erreur reformulation : {e}")
question_enrichie = message_enrichi
# ── Cas 4 : Recherche hybride ──────────────────────────────────────────
hist[-1]["content"] = "📚 Recherche dans les Lois du Jeu..."
yield hist, ""
try:
extraits = rechercher_hybride(question_enrichie)
print(f"[chat_stream] {len(extraits)} extraits trouvés")
except Exception as e:
print(f"[chat_stream] Erreur recherche : {e}")
extraits = []
meilleur_bm25 = extraits[0]["bm25"] if extraits else 0
# ── Cas 5 : Hors sujet ────────────────────────────────────────────────
if est_hors_sujet(message, meilleur_bm25):
hist[-1]["content"] = (
"Je suis uniquement spécialisé dans les **Lois du Jeu de football de l'IFAB**.\n\n"
"Pourriez-vous me poser une question en lien avec les règles du football ?\n\n"
"Voici quelques exemples :\n"
"• *Dans quel cas un joueur est-il hors-jeu ?*\n"
"• *Quelles sont les fautes passibles d'un carton rouge ?*\n"
"• *Comment se déroule l'exécution d'un penalty ?*"
)
yield hist, "⚠️ Question hors sujet — aucun extrait consulté."
return
# ── Cas 6 : Construction du prompt ────────────────────────────────────
try:
sources = formater_sources(extraits)
contexte = "\n\n".join(
f"[Extrait {i+1}]\n{e['texte']}" for i, e in enumerate(extraits)
)
# Limiter la mémoire à 500 chars pour éviter un prompt trop long
memoire_section = f"{memoire[:500]}\n\n" if memoire else ""
prompt = (
f"{memoire_section}"
f"Extraits des Lois du Jeu de l'IFAB :\n---\n{contexte}\n---\n\n"
f"Question : {message}"
)
except Exception as e:
print(f"[chat_stream] Erreur construction prompt : {e}")
hist[-1]["content"] = f"❌ Erreur interne : {e}"
yield hist, ""
return
# ── Cas 7 : Streaming ─────────────────────────────────────────────────
hist[-1]["content"] = ""
reponse = ""
try:
stream = together_client.chat.completions.create(
model="meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages=[
{"role": "system", "content": SYSTEME_IFAB},
{"role": "user", "content": prompt}
],
temperature=0.1,
max_tokens=1200,
stream=True
)
for token in lire_stream(stream):
reponse += token
hist[-1]["content"] = reponse
yield hist, sources
print(f"[chat_stream] Réponse OK ({len(reponse)} chars)")
# ── Badge de fin ──────────────────────────────────────────────────
if reponse.strip() and "ne figure pas dans les extraits" not in reponse.lower():
badge = "\n\n---\n✅ *Réponse basée sur les Lois du Jeu de l'IFAB.*"
else:
badge = "\n\n---\n⚠️ *Information introuvable — essayez de reformuler.*"
hist[-1]["content"] = reponse + badge
yield hist, sources
except Exception as e:
print(f"[chat_stream] Erreur streaming : {e}")
msg_err = reponse if reponse else f"❌ Erreur : {e}"
hist[-1]["content"] = msg_err
yield hist, sources
# ─── Initialisation ───────────────────────────────────────────────────────────
print("Chargement du document...")
all_chunks = charger_et_decouper(DOCUMENT_PATH)
print("Construction de l'index BM25...")
bm25_index = construire_index_bm25(all_chunks)
print("Chargement du modèle d'embedding...")
modele_embedding = SentenceTransformer(EMBEDDING_MODEL)
print("Vectorisation des chunks...")
embeddings_chunks = modele_embedding.encode(
all_chunks,
batch_size=32,
normalize_embeddings=True,
show_progress_bar=True
)
print(f"✓ Index hybride prêt — {len(all_chunks)} chunks | shape: {embeddings_chunks.shape}")
EXEMPLES = [
"Dans quel cas un joueur est-il hors-jeu ?",
"Quelles sont les fautes passibles d'un carton rouge ?",
"Comment doit se derouler l'execution d'un penalty ?",
"Quelles sont les règles pour un coup franc direct ?",
"Quand l'arbitre peut-il accorder un avantage ?",
"Quelles sont les dimensions réglementaires du terrain ?",
"Dans quels cas un but peut-il être refusé ?",
]
# ─── Thème & CSS ──────────────────────────────────────────────────────────────
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* ── Fond général ── */
body, .gradio-container {
background: #0f1923 !important;
font-family: 'Inter', 'Segoe UI', sans-serif !important;
min-height: 100vh;
}
/* ── En-tête ── */
.header-md {
background: linear-gradient(135deg, #0f2a5e 0%, #1a3a7a 50%, #0f2a5e 100%);
border: 1px solid #2a5298;
border-bottom: 3px solid #4a7fd4;
border-radius: 14px;
padding: 20px 32px;
margin-bottom: 16px;
text-align: center;
}
.header-md h2 {
color: #ffffff !important;
font-size: 1.5rem !important;
font-weight: 700 !important;
margin: 0 0 4px 0 !important;
}
.header-md p {
color: #a8c4f0 !important;
font-size: 0.9rem !important;
margin: 0 !important;
}
/* ── Labels ── */
label span {
color: #a8c4f0 !important;
font-weight: 600 !important;
}
/* ── Conteneur chatbot ── */
.chatbot-wrap {
background-color: #141f2e !important;
border: 1px solid #1e3a5e !important;
border-radius: 12px !important;
}
/* ── Texte dans les bulles — ciblage large pour Gradio 6.x ── */
.chatbot-wrap * {
color: #e8eef8 !important;
}
/* ── Bulle utilisateur ── */
.chatbot-wrap [data-testid="user"] {
background: #1a3a7a !important;
border: 1px solid #2a5298 !important;
border-radius: 16px 16px 4px 16px !important;
}
/* ── Bulle assistant ── */
.chatbot-wrap [data-testid="bot"] {
background: #1a2540 !important;
border: 1px solid #1e3a5e !important;
border-radius: 16px 16px 16px 4px !important;
}
/* ── Textbox question ── */
.question-input textarea {
background-color: #141f2e !important;
color: #e8eef8 !important;
border: 1px solid #1e3a5e !important;
border-radius: 10px !important;
font-size: 0.95rem !important;
}
.question-input textarea:focus {
border-color: #4a7fd4 !important;
box-shadow: 0 0 0 3px rgba(74, 127, 212, 0.15) !important;
}
.question-input textarea::placeholder {
color: #5a7aaa !important;
}
/* ── Bouton principal ── */
.btn-send {
background: linear-gradient(135deg, #1a3a7a, #2952a3) !important;
color: #ffffff !important;
border: 1px solid #2a5298 !important;
border-radius: 10px !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
}
.btn-send:hover {
background: linear-gradient(135deg, #2952a3, #3563c4) !important;
transform: translateY(-1px) !important;
}
/* ── Bouton reset ── */
.btn-reset {
background-color: #141f2e !important;
color: #6a9fd8 !important;
border: 1px solid #1e3a5e !important;
border-radius: 10px !important;
transition: all 0.2s !important;
}
.btn-reset:hover {
background-color: #1a2a40 !important;
color: #a8c4f0 !important;
}
/* ── Sources ── */
.sources-box textarea {
background-color: #0d1622 !important;
color: #90bae8 !important;
border: 1px solid #1a3060 !important;
border-radius: 10px !important;
font-size: 11.5px !important;
font-family: 'Consolas', 'Courier New', monospace !important;
line-height: 1.6 !important;
}
/* ── Pied de page ── */
.footer-md p {
color: #4a6a9a !important;
font-size: 11px !important;
text-align: center !important;
margin-top: 8px !important;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: #0f1923; }
::-webkit-scrollbar-thumb { background: #1e3a6e; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #2a5298; }
"""
# ─── Interface Gradio ─────────────────────────────────────────────────────────
with gr.Blocks(title="Expert Arbitrage IFAB") as app:
gr.Markdown(
"## 🏟️ Assistant Expert — Lois du Jeu de l'IFAB 2025/26\n"
"*Posez vos questions sur les règles officielles du football*",
elem_classes=["header-md"]
)
with gr.Row():
# ── Colonne principale (chat) ──────────────────────────────────────
with gr.Column(scale=3):
chatbot = gr.Chatbot(
label="Consultation Arbitre",
height=450,
elem_classes=["chatbot-wrap"]
)
question = gr.Textbox(
label="Votre question",
placeholder="Ex : Un gardien peut-il marquer à la main ?",
lines=2,
elem_classes=["question-input"]
)
with gr.Row():
envoyer = gr.Button(
"🏁 Consulter l'expert",
variant="primary",
elem_classes=["btn-send"]
)
effacer = gr.Button(
"🔄 Nouvelle conversation",
elem_classes=["btn-reset"]
)
gr.Examples(
examples=EXEMPLES,
inputs=question,
label="Questions fréquentes",
)
# ── Colonne sources ────────────────────────────────────────────────
with gr.Column(scale=2):
sources_box = gr.Textbox(
label="📋 Extraits officiels consultés",
lines=28,
interactive=False,
elem_classes=["sources-box"]
)
envoyer.click(
chat_stream,
inputs=[question, chatbot],
outputs=[chatbot, sources_box]
).then(lambda: "", outputs=question)
question.submit(
chat_stream,
inputs=[question, chatbot],
outputs=[chatbot, sources_box]
).then(lambda: "", outputs=question)
effacer.click(lambda: ([], ""), outputs=[chatbot, sources_box])
gr.Markdown(
"🛡️ *Outil pédagogique basé sur les textes officiels de l'IFAB "
"— Ne remplace pas l'avis d'un arbitre officiel certifié.*",
elem_classes=["footer-md"]
)
app.launch(server_name="0.0.0.0", server_port=7860, css=CSS)