Spaces:
Sleeping
Sleeping
| 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) |