import os import re import unicodedata from flask import Flask, request, jsonify from flask_cors import CORS import spacy import coref_bridge as cb import generer_texte app = Flask(__name__) CORS(app) # Modèle transformer : POS/lemmatisation plus fiables que fr_core_news_lg # (ex: "Elle marche" correctement étiqueté VERB), crucial pour la polysémie. print("Chargement de spaCy (fr_dep_news_trf)") spacy.prefer_gpu() nlp = spacy.load("fr_dep_news_trf") print("Analyse et indexation sémantique du fichier EduPicto.owx...") # ─── EXTRACTION DES DONNEES POUR L'ONTOLOGIE OWX (.owx) ──────────────── def charger_ontologie_xml(fichier_chemin): with open(fichier_chemin, "r", encoding="utf-8") as f: xml_str = f.read() # Extraction des classes class_assertions = re.findall(r'\s*\s*\s*', xml_str) ind_classes = {ind: cls for cls, ind in class_assertions} # Extraction des chemins de fichiers images locaux (plus besoin mais au cas où) chemin_assertions = re.findall(r'\s*\s*\s*]*>([^<]+)\s*', xml_str) ind_picto = {ind: picto.strip() for ind, picto in chemin_assertions} # Extraction des id ARASAAC id_assertions = re.findall(r'\s*\s*#([^<]+)\s*]*>([^<]+)\s*', xml_str) ind_id = {ind: aid.strip() for ind, aid in id_assertions} # Catégorie grammaticale (POS) : # - déduite de la classe (Action=verbe, Adjectif, Adverbe, sinon nom) # - surchargée si l'ontologie déclare explicitement #categorieGrammaticale # (utile pour la polysémie : ex. "marche" verbe vs "marché" nom) def classe_vers_pos(cls): return {"Action": "VERB", "Adjectif": "ADJ", "Adverbe": "ADV"}.get(cls, "NOUN") pos_assertions = re.findall(r'\s*\s*\s*]*>([^<]+)\s*', xml_str) ind_pos_explicite = {ind: val.strip().upper() for ind, val in pos_assertions} ind_pos = {ind: ind_pos_explicite.get(ind, classe_vers_pos(cls)) for ind, cls in ind_classes.items()} # Extraction des liaisons de contextes (Object Properties) context_assertions = re.findall(r'\s*\s*\s*\s*', xml_str) ind_contexts = {} for ind, ctx in context_assertions: if ind not in ind_contexts: ind_contexts[ind] = [] ind_contexts[ind].append(ctx) # Extraction des mots-clés de contexte (Data Properties de Protégé) mot_cle_assertions = re.findall(r'\s*\s*\s*]*>([^<]+)\s*', xml_str) ctx_mots = {} for ctx, mot in mot_cle_assertions: if ctx not in ctx_mots: ctx_mots[ctx] = [] ctx_mots[ctx].append(mot.lower().strip()) # Consolidation de tous les individus existants all_individuals = set() all_individuals.update(ind_classes.keys()) all_individuals.update(ind_picto.keys()) all_individuals.update(ind_id.keys()) all_individuals.update(ind_contexts.keys()) return { "classes": ind_classes, "pictos": ind_picto, "ids": ind_id, "contexts": ind_contexts, "keywords": ctx_mots, "pos": ind_pos, "all_individuals": list(all_individuals) } # Chargement de la structure sémantique en mémoire ONTOLOGY_DATA = charger_ontologie_xml("EduPicto.owx") # Coréférence avancée (propp_fr + LingMess). Optionnelle : si le chargement # échoue (pas de GPU, modèles absents...), le serveur garde sa logique de secours. COREF_AVANCE = False try: print("Chargement des modèles de coréférence (propp_fr)...") cb.init(with_lingmess=True) COREF_AVANCE = True print("Coréférence avancée activée.") except Exception as e: print(f"Coréférence avancée indisponible ({e}). Fallback mémoire ciblée.") print("Le serveur est opérationnel sur le port 5006.") def retirer_accents(texte): if not texte: return "" return "".join(c for c in unicodedata.normalize('NFD', texte) if unicodedata.category(c) != 'Mn').lower().strip() def obtenir_radical(mot): """ Extrait la racine d'un mot ou d'un verbe en coupant les terminaisons courantes """ m = retirer_accents(mot) if m in ["phoque", "phoques"]: return "phoque" if m in ["oeuf", "oeufs", "œuf", "œufs"]: return "oeuf" if m in ["ingredients", "ingredient"]: return "ingredient" for suff in ['er', 'ir', 'ons', 'ez', 'ent', 'es', 'e']: if m.endswith(suff) and len(m) - len(suff) >= 3: return m[:-len(suff)] return m def extraire_id_depuis_individu(ind_name): """ extrait l'identifiant numérique Arasaac ou le nom de fichier local (au cas ou) """ aid = ONTOLOGY_DATA["ids"].get(ind_name) if aid: return aid return ONTOLOGY_DATA["pictos"].get(ind_name) def chercher_individu_dans_ontologie(mot_brut, lemme, target_idx, tokens): if not mot_brut or len(mot_brut.strip()) <= 1: return None rad_mot = obtenir_radical(mot_brut) rad_lem = obtenir_radical(lemme) candidates = [] for name in ONTOLOGY_DATA["all_individuals"]: rad_ind = obtenir_radical(name) if (rad_ind == rad_mot or rad_ind == rad_lem or rad_ind.startswith(rad_mot + "_") or rad_ind.startswith(rad_lem + "_")): candidates.append(name) if not candidates: return None # DÉSAMBIGUÏSATION PAR CATÉGORIE GRAMMATICALE (POS) # Ex: "je marche jusqu'au marché" -> "marche" (VERB) = marcher, # "marché" (NOUN) = le marché. On filtre les candidats selon le POS # que spaCy donne au token courant. pos_groupe = None if 0 <= target_idx < len(tokens): pos_groupe = {"VERB": "VERB", "AUX": "VERB", "NOUN": "NOUN", "PROPN": "NOUN", "ADJ": "ADJ", "ADV": "ADV"}.get(tokens[target_idx].pos_) if pos_groupe and len(candidates) > 1: filtres = [c for c in candidates if ONTOLOGY_DATA["pos"].get(c) == pos_groupe] if filtres: # fallback : si le filtre vide tout, on garde les candidats d'origine candidates = filtres if len(candidates) == 1: return candidates[0] # PTIT ALGORITHME DE SCORING (PONDÉRÉ PAR LA DISTANCE) scores_contextes = {ctx: 0.0 for ctx in ONTOLOGY_DATA["keywords"]} for idx, t in enumerate(tokens): word = retirer_accents(t.text) distance = abs(idx - target_idx) poids = 1.0 / (1.0 + distance) for ctx, mots_cles in ONTOLOGY_DATA["keywords"].items(): if word in mots_cles: scores_contextes[ctx] += poids best_candidate = candidates[0] max_score = -1.0 for cand in candidates: score_cand = 0.0 ctx_list = ONTOLOGY_DATA["contexts"].get(cand, []) for ctx in ctx_list: score_cand += scores_contextes.get(ctx, 0.0) if score_cand > max_score: max_score = score_cand best_candidate = cand return best_candidate def detecter_contexte_automatique(doc): txt = retirer_accents(doc.text) if "salade" in txt or "tarte" in txt or "pomme" in txt or "avocat" in txt: return "contexte_cuisine" @app.route('/') def index(): # En local : sert la page de test. En conteneur (HF) : index.html absent # -> on renvoie un health check JSON (évite le 500 sur le ping de HF). try: with open("index.html", encoding="utf-8") as f: return f.read() except FileNotFoundError: return jsonify({"status": "ok", "service": "EduPicto API", "endpoints": ["/pictogramiser", "/generer"]}) def analyser_texte(texte_brut): """Cœur du pipeline : texte brut -> liste d'éléments {mot, picto, type}.""" texte_propre = texte_brut.replace("après-midi", "après_midi") doc = nlp(texte_propre) # COREF AVANCÉE : propp_fr clusterise les pronoms et leurs têtes nominales. # pronom_to_cluster : {offset_char_pronom -> id_cluster} # cluster_rep : {id_cluster -> {head_word, text, cat}} (tête nominale, si présente) if COREF_AVANCE: pronom_to_cluster, cluster_rep = cb.resoudre(texte_propre) else: pronom_to_cluster, cluster_rep = {}, {} # MEMOIRE COREF CIBLÉE (fallback hardcodé, conservé en ultime recours) memoire_coref = {"il": None, "ils": None, "elle": None, "elles": None} # MEMOIRE DYNAMIQUE : dernier individu de classe Personne rencontré. # Capte les noms propres que le NER propp_fr rate (ex: "Léo") via l'ontologie. dernier_personne = None liste_elements_flux = [] tokens = list(doc) i = 0 mots_interdits = [ "on", "tu", "je", "nous", "vous", "dans", "le", "la", "les", "un", "une", "des", "du", "de", "pour", "avec", "et", "mais" ] while i < len(tokens): token = tokens[i] if token.is_space: i += 1 continue # Interception des numéros de liste (1., 2., etc.) if token.is_digit and i + 1 < len(tokens) and tokens[i+1].text in [".", ")", "-"]: liste_elements_flux.append({"mot": "", "picto": None, "type": None}) i += 2 continue if token.is_digit and len(tokens) == 1: liste_elements_flux.append({"mot": "", "picto": None, "type": None}) i += 1 continue mot_affichage = token.text_with_ws lemme = token.lemma_.lower().strip() phrase_tokens = list(token.sent) # INTERCEPTION ET RÈGLES DE SUBSTUTITION DES PRONOMS CIBLÉS pronom_normalise = token.text.lower().strip() if pronom_normalise in ["il", "ils", "elle", "elles"]: # Détection et exclusion immédiate des tournures impersonnelles (ex: il fait froid, il y a) est_impersonnel = False if pronom_normalise == "il" and i + 1 < len(tokens): suivant = tokens[i+1].lemma_.lower().strip() if suivant in ["y", "falloir", "faut", "pleuvoir", "pleut"]: est_impersonnel = True elif suivant in ["faire", "fait"]: texte_phrase = retirer_accents(token.sent.text) if "froid" in texte_phrase or "chaud" in texte_phrase or "beau" in texte_phrase: est_impersonnel = True if est_impersonnel: # On laisse le picto vide par exemple pour la meteo liste_elements_flux.append({"mot": mot_affichage, "picto": None, "type": None}) i += 1 continue # Résolution coréférence : cluster propp_fr > mémoire dynamique > hardcodé ind_substitut = None cid = pronom_to_cluster.get(token.idx) if cid is not None: rep = cluster_rep.get(cid) if rep: rep_word = rep.get("head_word") or rep.get("text") if rep_word: ind_substitut = chercher_individu_dans_ontologie(rep_word, rep_word, i, tokens) if ind_substitut is None and pronom_normalise in ("il", "elle"): # Antécédent singulier raté par le NER (ex: Léo) → dernier Personne vu. # Volontairement PAS appliqué à ils/elles : un pronom pluriel # exigerait un picto "plusieurs personnes" que l'on n'a pas. ind_substitut = dernier_personne if ind_substitut is None: ind_substitut = memoire_coref.get(pronom_normalise) # ultime secours if ind_substitut: id_arasaac = extraire_id_depuis_individu(ind_substitut) cls_name = ONTOLOGY_DATA["classes"].get(ind_substitut, "") if cls_name == "Action": type_mot = "verbe" elif cls_name == "Personne": type_mot = "propre" else: type_mot = "commun" liste_elements_flux.append({"mot": mot_affichage, "picto": id_arasaac, "type": type_mot}) i += 1 continue # Traitement des élisions (l'œuf, l'avocat...) if (token.text.endswith("'") or token.text.endswith("’")) and i + 1 < len(tokens): next_token = tokens[i+1] mot_affichage = token.text + next_token.text_with_ws lemme_elide = next_token.lemma_.lower().strip() id_arasaac = None type_mot = None if not next_token.is_punct and lemme_elide not in mots_interdits and next_token.text.lower() != "mais": ind_trouve = chercher_individu_dans_ontologie(next_token.text, next_token.lemma_, next_token.i, tokens) if ind_trouve: id_arasaac = extraire_id_depuis_individu(ind_trouve) cls_name = ONTOLOGY_DATA["classes"].get(ind_trouve, "") if cls_name == "Action": type_mot = "verbe" elif cls_name == "Personne": type_mot = "propre" else: type_mot = "commun" # Mémoire dynamique : tout individu Personne devient l'antécédent courant if cls_name == "Personne": dernier_personne = ind_trouve # Enregistrement exclusif et ciblé dans la mémoire de coréférence nom_nettoye = retirer_accents(ind_trouve) if ind_trouve == "Léo": memoire_coref["il"] = "Léo" elif nom_nettoye in ["mala", "poline"]: memoire_coref["elles"] = ind_trouve elif nom_nettoye == "maison": memoire_coref["elle"] = "maison" elif nom_nettoye in ["legume", "légume"]: memoire_coref["ils"] = "légume" elif nom_nettoye in ["inuit", "esquimau"]: memoire_coref["ils"] = ind_trouve liste_elements_flux.append({"mot": mot_affichage, "picto": id_arasaac, "type": type_mot}) i += 2 continue if i + 1 < len(tokens) and tokens[i+1].is_punct and tokens[i+1].text in [".", ",", ";", "!", "?", ":"]: mot_affichage = mot_affichage.rstrip() + tokens[i+1].text_with_ws i += 1 id_arasaac = None type_mot = None if not token.is_punct and lemme not in mots_interdits and token.text.lower() != "mais": # token.i = vrai index du token (i a pu être incrémenté par la fusion ponctuation) ind_trouve = chercher_individu_dans_ontologie(token.text, token.lemma_, token.i, tokens) if ind_trouve: id_arasaac = extraire_id_depuis_individu(ind_trouve) cls_name = ONTOLOGY_DATA["classes"].get(ind_trouve, "") if cls_name == "Action": type_mot = "verbe" elif cls_name == "Personne": type_mot = "propre" else: type_mot = "commun" # Mémoire dynamique : tout individu Personne devient l'antécédent courant if cls_name == "Personne": dernier_personne = ind_trouve # Enregistrement exclusif et ciblé dans la mémoire de coréférence manuelle nom_nettoye = retirer_accents(ind_trouve) if ind_trouve == "Léo": memoire_coref["il"] = "Léo" elif nom_nettoye in ["mala", "poline"]: memoire_coref["elles"] = ind_trouve elif nom_nettoye == "maison": memoire_coref["elle"] = "maison" elif nom_nettoye in ["legume", "légume"]: memoire_coref["ils"] = "légume" elif nom_nettoye in ["inuit", "esquimau"]: memoire_coref["ils"] = ind_trouve liste_elements_flux.append({"mot": mot_affichage, "picto": id_arasaac, "type": type_mot}) i += 1 return liste_elements_flux @app.route('/pictogramiser', methods=['POST']) def pictogramiser_texte_integral(): texte_brut = (request.json or {}).get("texte", "") return jsonify({"flux_de_lecture": analyser_texte(texte_brut)}) @app.route('/generer', methods=['POST']) def generer_route(): """Génère un texte via Mistral (vocabulaire = ontologie) puis le pictogramise. La clé Mistral reste côté serveur (jamais exposée au client Flutter).""" theme = (request.json or {}).get("theme") try: texte = generer_texte.generer(theme) except SystemExit as e: # clé Mistral absente return jsonify({"erreur": str(e)}), 503 except Exception as e: return jsonify({"erreur": f"Génération impossible : {e}"}), 502 return jsonify({"texte": texte, "flux_de_lecture": analyser_texte(texte)}) if __name__ == '__main__': # PORT fourni par l'hébergeur (HF Spaces = 7860), 5006 en local par défaut. port = int(os.environ.get("PORT", 5006)) app.run(host="0.0.0.0", port=port, debug=False, threaded=True)