# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # textmine.py : text mining déterministe des descriptions d'annonces, exécuté # à l'ingestion (< 5 ms par annonce, aucune dépendance lourde). # Pipeline en quatre étapes : # A. nettoyage typographique (MAJUSCULES, doublons, coquilles) # B. extraction de faits (regex françaises + louka/normalize) # C. découpage en sections (titres détectés ou thèmes) # D. score de complétude + niveaux de confiance # Sortie : dict JSON strict (voir `analyser`). Le seul texte # généré est « en_bref », assemblé PAR GABARIT à partir des # faits extraits — jamais d'invention. # ----------------------------------------------------------------------------- from __future__ import annotations import re from .normalize import ( extract_details, parse_availability_date, parse_price, strip_accents, ) VERSION = 1 # Espace insécable (typographie québécoise : « 450 $ », « 50 % ») _NBSP = " " # --------------------------------------------------------------------------- # Étape A — nettoyage déterministe # --------------------------------------------------------------------------- _MIN = "a-zàâäéèêëîïôöùûüÿçœæ" _MAJ = "A-ZÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÇŒÆ" # Phrases collées : minuscule (x2, pour épargner « McDonald ») suivie sans # espace d'une majuscule entamant un mot d'au moins 4 lettres. _COLLE_RE = re.compile(rf"([{_MIN}]{{2}})([{_MAJ}][{_MIN}]{{3}})") # Puces variées en début de ligne -> « • » _PUCE_RE = re.compile(r"^\s*(?:[-–—*·▪●○◦‣>]+|•+)\s+") _PUCE_MILIEU_RE = re.compile(r"\s[·▪●○◦‣]\s") # Répétitions de ponctuation : « !!! » -> « ! », « ?! » -> « ? » _PONCT_RE = re.compile(r"([!?])(?:\s*[!?])+") _POINTS_RE = re.compile(r"\.{4,}") # Typographie : espace insécable avant $ et % _DOLLAR_RE = re.compile(r"(\d)\s*([$%])") # Segment EN MAJUSCULES au milieu d'une ligne (≥ 2 mots, ≥ 9 caractères) _SEG_TITRE_RE = re.compile( rf"(?:^|(?<=[\s.!?:•]))" rf"([{_MAJ}][{_MAJ}0-9'’/&\-]*(?:[ ]+[{_MAJ}0-9][{_MAJ}0-9'’/&\-]*)+)" rf"(?=\s|$)(?!\s*[!?.:;,])") # suivi de ponctuation = emphase, pas un titre _PONCT_FIN = ".,;:!?" def _est_ligne_criee(ligne: str) -> bool: """Ligne EN MAJUSCULES : > 8 caractères, > 60 % de majuscules parmi les lettres.""" s = ligne.strip() if len(s) <= 8: return False lettres = [c for c in s if c.isalpha()] if len(lettres) < 4: return False maj = sum(1 for c in lettres if c.isupper()) return maj / len(lettres) > 0.60 def _est_ligne_titre(ligne: str) -> bool: """Ligne-titre : EN MAJUSCULES, courte, sans ponctuation de phrase. « FRAIS PEINT !! » ou « NON-FUMEUR. » sont des phrases criées, pas des titres de section : elles seront remises en casse de phrase. """ s = ligne.strip() return (_est_ligne_criee(s) and len(s) <= 60 and "!" not in s and "?" not in s and not s.endswith(".")) def _titre_capitalise(s: str) -> str: """« L'ESPACE DE VIE » -> « L'espace de vie » (capitalisation de titre).""" s = s.strip().lower() for i, c in enumerate(s): if c.isalpha(): return s[:i] + c.upper() + s[i + 1:] return s def _dedoublonner_mots(ligne: str) -> str: """« Disponible disponible » -> « Disponible » (insensible casse/accents).""" mots = ligne.split(" ") resultat: list[str] = [] precedent = None for mot in mots: cle = strip_accents(mot).lower().strip(_PONCT_FIN + "()•") if (cle and cle == precedent and len(cle) > 1 and any(c.isalpha() for c in cle)): # doublon : on le retire, en récupérant sa ponctuation finale queue = mot.rstrip() if queue and queue[-1] in _PONCT_FIN and resultat \ and resultat[-1] and resultat[-1][-1] not in _PONCT_FIN: resultat[-1] += queue[-1] continue resultat.append(mot) precedent = cle or None return " ".join(resultat) def _nettoyer_ligne(ligne: str) -> str: """Applique les corrections de l'étape A à une ligne de texte courant.""" s = _PUCE_RE.sub("• ", ligne) s = _PUCE_MILIEU_RE.sub(" • ", s) s = _dedoublonner_mots(s) s = _COLLE_RE.sub(r"\1. \2", s) s = _PONCT_RE.sub(r"\1", s) s = _POINTS_RE.sub("...", s) s = _DOLLAR_RE.sub(rf"\1{_NBSP}\2", s) s = re.sub(r"[ \t]{2,}", " ", s) return s.strip() def _etape_a(texte: str) -> list[tuple[str, str]]: """Nettoyage -> liste de lignes typées : ("titre"|"texte"|"vide", contenu).""" texte = texte.replace("\r\n", "\n").replace("\r", "\n") for ch in (" ", " ", " "): # insécables -> espaces simples texte = texte.replace(ch, " ") # découpe les segments EN MAJUSCULES enchâssés dans une ligne brutes: list[str] = [] for ligne in texte.split("\n"): if _est_ligne_titre(ligne) or _est_ligne_criee(ligne): brutes.append(ligne) continue morceaux, debut, pos = [], 0, 0 while True: m = _SEG_TITRE_RE.search(ligne, pos) if not m: break if len(m.group(1)) < 9: # segment trop court : pas un titre pos = m.end() continue if ligne[debut:m.start()].strip(): morceaux.append(ligne[debut:m.start()]) morceaux.append("\x00" + m.group(1)) debut = pos = m.end() if ligne[debut:].strip() or not morceaux: morceaux.append(ligne[debut:]) brutes.extend(morceaux) # classification titre / texte / vide lignes: list[tuple[str, str]] = [] for ligne in brutes: seg_titre = ligne.startswith("\x00") ligne = ligne.lstrip("\x00") if not ligne.strip(): lignes.append(("vide", "")) elif seg_titre or _est_ligne_titre(ligne): lignes.append(("titre", _titre_capitalise(ligne))) elif _est_ligne_criee(ligne): # phrase criée (« FRAIS PEINT !! ») : casse de phrase, pas un titre lignes.append(("texte", _nettoyer_ligne(_titre_capitalise(ligne)))) else: lignes.append(("texte", _nettoyer_ligne(ligne))) # garde-fou : description entièrement en MAJUSCULES -> aucun titre, # les lignes criées sont simplement remises en casse de phrase non_vides = [l for l in lignes if l[0] != "vide"] titres = [l for l in lignes if l[0] == "titre"] if len(titres) >= 3 and non_vides and len(titres) / len(non_vides) > 0.6: lignes = [("texte", t) if k == "titre" else (k, t) for k, t in lignes] # compresse les lignes vides successives (paragraphes préservés) compactees: list[tuple[str, str]] = [] for kind, t in lignes: if kind == "vide" and compactees and compactees[-1][0] == "vide": continue compactees.append((kind, t)) while compactees and compactees[0][0] == "vide": compactees.pop(0) while compactees and compactees[-1][0] == "vide": compactees.pop() return compactees # --------------------------------------------------------------------------- # Étape B — extraction de faits # --------------------------------------------------------------------------- # Disponibilité : ancre contextuelle (évite de dater « Samedi 15 août » d'une # porte ouverte) ; « indisponible » / « non disponible » sont écartés. _DISPO_RE = re.compile(r"(? (nom, ville). # Matching sur texte sans accents en minuscules ; « st » ⇔ « saint » géré. _QUARTIERS: list[tuple[re.Pattern, str, str]] = [ (re.compile(p), nom, ville) for p, nom, ville in [ # Québec (r"\bsillery\b", "Sillery", "Québec"), (r"\bsainte?[- ]foy\b|\bste[- ]foy\b", "Sainte-Foy", "Québec"), (r"\blimoilou\b", "Limoilou", "Québec"), (r"\bmontcalm\b", "Montcalm", "Québec"), (r"\bsaint[- ]roch\b|\bst[- ]roch\b", "Saint-Roch", "Québec"), (r"\bsaint[- ]sauveur\b|\bst[- ]sauveur\b", "Saint-Sauveur", "Québec"), (r"\bsaint[- ]jean[- ]baptiste\b|\bst[- ]jean[- ]baptiste\b", "Saint-Jean-Baptiste", "Québec"), (r"\bvieux[- ]quebec\b", "Vieux-Québec", "Québec"), (r"\blebourgneuf\b", "Lebourgneuf", "Québec"), (r"\bbeauport\b", "Beauport", "Québec"), (r"\bcharlesbourg\b", "Charlesbourg", "Québec"), (r"\bcap[- ]rouge\b", "Cap-Rouge", "Québec"), (r"\bvanier\b", "Vanier", "Québec"), (r"\bduberger\b", "Duberger", "Québec"), (r"\bles saules\b", "Les Saules", "Québec"), (r"\bloretteville\b", "Loretteville", "Québec"), (r"\bval[- ]belair\b", "Val-Bélair", "Québec"), (r"\bneufchatel\b", "Neufchâtel", "Québec"), # Lévis (r"\bsaint[- ]romuald\b|\bst[- ]romuald\b", "Saint-Romuald", "Lévis"), (r"\bcharny\b", "Charny", "Lévis"), (r"\bsaint[- ]nicolas\b|\bst[- ]nicolas\b", "Saint-Nicolas", "Lévis"), (r"\bvieux[- ]levis\b", "Vieux-Lévis", "Lévis"), (r"\bsaint[- ]jean[- ]chrysostome\b|\bst[- ]jean[- ]chrysostome\b", "Saint-Jean-Chrysostome", "Lévis"), (r"\bpintendre\b", "Pintendre", "Lévis"), # Grand Montréal (r"\bplateau([- ]mont[- ]royal)?\b", "Plateau-Mont-Royal", "Montréal"), (r"\brosemont\b", "Rosemont", "Montréal"), (r"\bhochelaga([- ]maisonneuve)?\b", "Hochelaga-Maisonneuve", "Montréal"), (r"\bvilleray\b", "Villeray", "Montréal"), (r"\bverdun\b", "Verdun", "Montréal"), (r"\bgriffintown\b", "Griffintown", "Montréal"), (r"\bahuntsic\b", "Ahuntsic", "Montréal"), (r"\bcote[- ]des[- ]neiges\b", "Côte-des-Neiges", "Montréal"), (r"\bnotre[- ]dame[- ]de[- ]grace\b|\bndg\b", "Notre-Dame-de-Grâce", "Montréal"), (r"\bmile[- ]end\b", "Mile End", "Montréal"), (r"\boutremont\b", "Outremont", "Montréal"), (r"\bwestmount\b", "Westmount", "Montréal"), (r"\bsaint[- ]leonard\b|\bst[- ]leonard\b", "Saint-Léonard", "Montréal"), (r"\banjou\b", "Anjou", "Montréal"), (r"\blasalle\b", "LaSalle", "Montréal"), (r"\blachine\b", "Lachine", "Montréal"), (r"\bsaint[- ]henri\b|\bst[- ]henri\b", "Saint-Henri", "Montréal"), (r"\bpointe[- ]saint[- ]charles\b|\bpointe[- ]st[- ]charles\b", "Pointe-Saint-Charles", "Montréal"), (r"\bparc[- ]extension\b", "Parc-Extension", "Montréal"), ] ] # préposition juste avant le quartier = mention forte (« à Sillery ») _QUARTIER_CTX_RE = re.compile(r"(?:\ba\s|\bau\s|\bde\s|\bdu\s|\bdans\s|secteur\s|quartier\s)$") # Ordres canoniques d'affichage _ORDRE_ELECTROS = ["frigo", "cuisiniere", "laveuse", "secheuse", "lave_vaisselle", "micro_ondes"] _ORDRE_INCLUSIONS = ["chauffage", "electricite", "eau_chaude", "wifi", "cable", "meuble"] _ORDRE_CONTRAINTES = ["non_fumeur", "pas_d_animaux", "references_requises", "etudiants_seulement", "occupation_simple"] _MAP_INCLUSIONS = {"heating": "chauffage", "electricity": "electricite", "hot_water": "eau_chaude", "internet": "wifi", "cable": "cable"} def _cle(texte: str) -> str: """Minuscules sans accents, sauts de ligne préservés (fenêtres contextuelles).""" return strip_accents(texte).lower() # Découpage du texte nettoyé en items pour extract_details : chaque item est # borné par « | » chez normalize, ce qui garde ses motifs négatifs précis et # évite le backtracking quadratique sur les proses sans ponctuation. _ITEM_SPLIT_RE = re.compile(r"(?<=[.!?:•])\s+") _ITEM_MAX = 300 # taille maximale d'un item _ITEM_CHEVAUCHE = 120 # chevauchement > fenêtre des motifs négatifs (~100 car.) # Pré-filtre : seuls les champs d'extract_details consommés ici (inclusions, # électroménagers, animaux, fumeur, meublé) sont concernés — un item sans # aucun de ces mots-clés ne peut produire aucun de ces indicateurs, on # l'écarte donc d'emblée (les motifs lourds de normalize coûtent cher). _ITEM_PERTINENT_RE = re.compile( r"chauff|heat|electr|hydro|eclair|eau chaude|hot water|internet|wi[- ]?fi" r"|cable|telus|videotron|television|bell fibe|frigo|fridge|refrig" r"|cuisinier|stove|\bfour\b|plaques? de cuisson|poele|lave|dishwasher" r"|washer|dryer|secheuse|appliance|meubl|furnish|anima|\bchat|chien" r"|\bpets?\b|fum|smok|inclus|included|\brien\b") def _items_extraction(lignes: list[tuple[str, str]]) -> list[str]: items: list[str] = [] for kind, t in lignes: if kind != "texte" or not t: continue for part in _ITEM_SPLIT_RE.split(t): part = part.strip() if not part or not _ITEM_PERTINENT_RE.search(_cle(part)): continue while len(part) > _ITEM_MAX: # prose sans ponctuation : on coupe = part.rfind(" ", _ITEM_MAX - 120, _ITEM_MAX) if coupe <= 0: # tronçonne avec chevauchement coupe = _ITEM_MAX items.append(part[:coupe]) part = part[max(0, coupe - _ITEM_CHEVAUCHE):] items.append(part) return items def _extraire_dispo(k: str) -> tuple[str | None, str]: """Date de disponibilité via normalize, ancrée sur un mot-clé du texte.""" for m in _DISPO_RE.finditer(k): if _DISPO_NEG_RE.search(k[max(0, m.start() - 6):m.start()]): continue # « indisponible », « non disponible », « plus disponible » fenetre = k[m.start():m.start() + 80] val = parse_availability_date(fenetre) if val is not None: conf = "haute" if (val != "now" or _DISPO_SUR_RE.search(fenetre)) else "faible" return val, conf return None, "haute" def _extraire_bail(k: str) -> tuple[int | None, str]: for rx in (_BAIL_MIN_RE, _BAIL_MIN2_RE, _BAIL_DUREE_RE): m = rx.search(k) if m: mois = int(m.group(1)) if 1 <= mois <= 36: contexte = k[max(0, m.start() - 40):m.end() + 20] conf = "faible" if _BAIL_PROMO_RE.search(contexte) else "haute" return mois, conf if _BAIL_ANNUEL_RE.search(k): return 12, "haute" return None, "haute" def _extraire_occupants(k: str) -> tuple[int | None, str]: for rx, conf in ((_OCC_TOTAL_RE, "haute"), (_OCC_CHAMBREURS_RE, "haute"), (_OCC_COLOC_RE, "haute"), (_OCC_COLOCS_RE, "faible")): m = rx.search(k) if m: n = int(m.group(1)) if 2 <= n <= 30: return n, conf return None, "haute" def _extraire_quartier(k: str) -> tuple[str | None, str, str]: """Premier quartier mentionné (position dans le texte) -> (nom, ville, conf).""" premier: tuple[int, str, str] | None = None for rx, nom, ville in _QUARTIERS: m = rx.search(k) if m and (premier is None or m.start() < premier[0]): premier = (m.start(), nom, ville) if premier is None: return None, "", "haute" pos, nom, ville = premier conf = "haute" if _QUARTIER_CTX_RE.search(k[max(0, pos - 12):pos]) else "faible" return nom, ville, conf def _cle_quartier(s: str) -> str: """Normalise un nom de quartier/secteur pour la comparaison.""" s = strip_accents(s or "").lower() s = re.sub(r"\bste\b", "sainte", s) s = re.sub(r"\bst\b", "saint", s) return re.sub(r"[^a-z0-9]+", "-", s).strip("-") def _fmt_montant(v: float) -> str: return str(int(v)) if v == int(v) else f"{v:g}" def _fmt_date_fr(iso: str) -> str: """« 2026-07-01 » -> « le 1er juillet 2026 » ; « now » -> « dès maintenant ».""" if iso == "now": return "dès maintenant" mois_fr = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"] try: a, m, j = (int(x) for x in iso.split("-")) jour = "1er" if j == 1 else str(j) return f"le {jour} {mois_fr[m - 1]} {a}" except (ValueError, IndexError): return f"le {iso}" # --------------------------------------------------------------------------- # Étape C — sections # --------------------------------------------------------------------------- # Ancres thématiques (texte long sans titres) — ordre d'évaluation ci-dessous _THEME_REGLES_RE = re.compile( r"non[- ]fumeur|animaux|interdit|reglement|references|enquete de credit" r"|no smoking|sans fumee|occupation simple") _THEME_VIE_RE = re.compile( r"colocation|colocataires?|chambreurs?|espaces? communs?|aires? communes?" r"|cuisine (?:commune|partagee)|salle de bain (?:commune|partagee)" r"|piscine|gym\b|salle d.entrainement|lounge|billard|terrasse") _THEME_FRAIS_RE = re.compile( r"\binclus|inclusions?|\bfrais\b|depot|caution|\bbail\b|par mois|/mois|\$" r"|electricite|chauffage|eau chaude|stationnement|loyer") def _sections_par_titres(lignes: list[tuple[str, str]]) -> list[dict]: sections: list[dict] = [] titre_courant = "Description" tampon: list[str] = [] def _pousser() -> None: texte = "\n".join(tampon).strip("\n ") if texte: sections.append({"titre": titre_courant, "texte": texte}) for kind, t in lignes: if kind == "titre": _pousser() titre_courant, tampon = t, [] else: tampon.append(t) _pousser() return sections def _sections_par_themes(texte: str) -> list[dict]: """Texte long sans titres : découpage par thème via ancres regex. Chaque phrase reste du texte source nettoyé, jamais reformulé.""" phrases = [p.strip() for p in re.split(r"(?<=[.!?])\s+|\n+", texte) if p.strip()] seaux: dict[str, list[str]] = {"Description": [], "L'espace de vie": [], "Frais et conditions": [], "Bon à savoir": []} for phrase in phrases: k = _cle(phrase) if _THEME_REGLES_RE.search(k): seaux["Bon à savoir"].append(phrase) elif _THEME_VIE_RE.search(k): seaux["L'espace de vie"].append(phrase) elif _THEME_FRAIS_RE.search(k): seaux["Frais et conditions"].append(phrase) else: seaux["Description"].append(phrase) return [{"titre": titre, "texte": " ".join(contenu)} for titre, contenu in seaux.items() if contenu] # --------------------------------------------------------------------------- # Étape B/D — assemblage des faits, en_bref, complétude # --------------------------------------------------------------------------- def _en_bref(faits: dict, confiance: dict) -> str | None: """1 à 3 phrases assemblées par gabarit à partir des faits haute-confiance SEULEMENT. Aucun mot n'est inventé au-delà des libellés fixes du gabarit.""" def _ok(champ: str) -> bool: return confiance.get(champ) == "haute" libelles = {"chauffage": "chauffage", "electricite": "électricité", "eau_chaude": "eau chaude", "wifi": "wifi", "cable": "câble"} phrases: list[str] = [] # Phrase 1 : loyer et/ou disponibilité prix = faits["prix_mensuel"] if _ok("prix_mensuel") else None dispo = faits["date_disponibilite"] if _ok("date_disponibilite") else None if prix is not None and dispo: phrases.append(f"Loyer de {_fmt_montant(prix)}{_NBSP}$ par mois, " f"disponible {_fmt_date_fr(dispo)}.") elif prix is not None: phrases.append(f"Loyer de {_fmt_montant(prix)}{_NBSP}$ par mois.") elif dispo: phrases.append(f"Disponible {_fmt_date_fr(dispo)}.") # Phrase 2 : meublé + inclusions if _ok("inclusions") and faits["inclusions"]: noms = [libelles[i] for i in faits["inclusions"] if i in libelles] meuble = "meuble" in faits["inclusions"] morceaux = [] if meuble: morceaux.append("Meublé") if noms: enum = noms[0] if len(noms) == 1 else ", ".join(noms[:-1]) + " et " + noms[-1] morceaux.append(f"{enum} inclus") if morceaux: phrase = " ; ".join(morceaux) + "." phrases.append(phrase[0].upper() + phrase[1:]) # Phrase 3 : vie commune et bail morceaux = [] if _ok("nb_occupants_total") and faits["nb_occupants_total"]: morceaux.append(f"{faits['nb_occupants_total']} occupants au total") sdb = faits["salle_de_bain"] if _ok("salle_de_bain") else None cuisine = faits["cuisine"] if _ok("cuisine") else None if sdb and sdb == cuisine: suffixe = "communes" if sdb == "commune" else "privées" morceaux.append(f"cuisine et salle de bain {suffixe}") else: if cuisine: morceaux.append(f"cuisine {cuisine.replace('privee', 'privée')}") if sdb: morceaux.append(f"salle de bain {sdb.replace('privee', 'privée')}") if _ok("duree_bail_minimale_mois") and faits["duree_bail_minimale_mois"]: morceaux.append(f"bail minimum de {faits['duree_bail_minimale_mois']} mois") if morceaux and len(phrases) < 3: phrase = ", ".join(morceaux) + "." phrases.append(phrase[0].upper() + phrase[1:]) return " ".join(phrases[:3]) or None def _completude(faits: dict, texte: str) -> int: """Pondération fixe : prix 20, dispo 15, inclusions 20, sdb/cuisine 10, occupants 10, contraintes 10, texte > 200 caractères 15 (total 100).""" score = 0 score += 20 if faits["prix_mensuel"] is not None else 0 score += 15 if faits["date_disponibilite"] else 0 score += 20 if faits["inclusions"] else 0 score += 10 if (faits["salle_de_bain"] or faits["cuisine"]) else 0 score += 10 if faits["nb_occupants_total"] else 0 score += 10 if faits["contraintes"] else 0 score += 15 if len(texte) > 200 else 0 return score # --------------------------------------------------------------------------- # Point d'entrée # --------------------------------------------------------------------------- def analyser(description: str, *, price: float | None = None, sector: str = "", city: str = "") -> dict | None: """Analyse une description d'annonce -> dict JSON structuré (schéma strict). None si la description est vide ou trop courte (< 40 caractères). `price`, `sector` et `city` (champs de l'annonce) servent uniquement aux contrôles de cohérence — jamais à inventer un fait absent du texte. """ if not description or len(description.strip()) < 40: return None # -- Étape A : nettoyage ------------------------------------------------- lignes = _etape_a(description) texte_nettoye = "\n".join(t for _, t in lignes) k = _cle(texte_nettoye) # -- Étape B : extraction ------------------------------------------------ confiance: dict[str, str] = {} incoherences: list[str] = [] det = extract_details(_items_extraction(lignes), "") prix = parse_price(texte_nettoye) if prix is not None: confiance["prix_mensuel"] = "haute" if _PRIX_MENSUEL_RE.search(k) else "faible" if price is not None and abs(prix - price) > 0.5 \ and confiance["prix_mensuel"] == "haute": incoherences.append( f"prix dans le texte ({_fmt_montant(prix)} $) ≠ " f"prix de l'annonce ({_fmt_montant(price)} $)") dispo, conf_dispo = _extraire_dispo(k) if dispo is not None: confiance["date_disponibilite"] = conf_dispo bail, conf_bail = _extraire_bail(k) if bail is not None: confiance["duree_bail_minimale_mois"] = conf_bail occupants, conf_occ = _extraire_occupants(k) if occupants is not None: confiance["nb_occupants_total"] = conf_occ # salle de bain / cuisine sdb = cuisine = None if _SDB_CUISINE_COMMUNES_RE.search(k): sdb = cuisine = "commune" if sdb is None: sdb = "commune" if _SDB_COMMUNE_RE.search(k) else \ "privee" if _SDB_PRIVEE_RE.search(k) else None if cuisine is None: cuisine = "commune" if _CUISINE_COMMUNE_RE.search(k) else \ "privee" if _CUISINE_PRIVEE_RE.search(k) else None if sdb: confiance["salle_de_bain"] = "haute" if cuisine: confiance["cuisine"] = "haute" # électroménagers : extract_details + compléments locaux app = det.get("appliances", {}) electros: set[str] = set() if app.get("fridge"): electros.add("frigo") if app.get("stove") or _POELE_RE.search(k): electros.add("cuisiniere") if app.get("dishwasher"): electros.add("lave_vaisselle") if app.get("washer_dryer"): electros.update(("laveuse", "secheuse")) elif not app.get("washer_dryer_hookup") and not _ENTREE_LAVEUSE_RE.search(k): if _LAVEUSE_RE.search(k): electros.add("laveuse") if _SECHEUSE_RE.search(k): electros.add("secheuse") if _MICRO_ONDES_RE.search(k): electros.add("micro_ondes") if _ELECTROS_INCLUS_RE.search(k): electros.update(("frigo", "cuisiniere")) electromenagers = [e for e in _ORDRE_ELECTROS if e in electros] if electromenagers: confiance["electromenagers"] = "haute" # inclusions incl: set[str] = {_MAP_INCLUSIONS[c] for c, actif in det.get("inclusions", {}).items() if actif and c in _MAP_INCLUSIONS} if det.get("furnished") is True: incl.add("meuble") inclusions = [i for i in _ORDRE_INCLUSIONS if i in incl] if inclusions: confiance["inclusions"] = "haute" # contraintes contr: set[str] = set() if det.get("smoking") is False: contr.add("non_fumeur") if det.get("pets") == "non" or _ANIMAUX_NON_RE.search(k): contr.add("pas_d_animaux") if _REFERENCES_RE.search(k): contr.add("references_requises") if _ETUDIANTS_RE.search(k): contr.add("etudiants_seulement") if _OCCUPATION_SIMPLE_RE.search(k): contr.add("occupation_simple") contraintes = [c for c in _ORDRE_CONTRAINTES if c in contr] if contraintes: confiance["contraintes"] = "haute" # dépôt : extrait source (jamais reformulé) depot = None for m in _DEPOT_RE.finditer(texte_nettoye): if _DEPOT_VALIDE_RE.search(_cle(m.group(0))): depot = m.group(0).strip(" ,;:") confiance["depot_mentionne"] = "haute" break # quartier mentionné + cohérence avec le secteur/la ville de l'annonce quartier, ville_quartier, conf_quartier = _extraire_quartier(k) if quartier: confiance["quartier_mentionne"] = conf_quartier cq, cs = _cle_quartier(quartier), _cle_quartier(sector) if sector and cq != cs and cq not in cs and cs not in cq: incoherences.append(f"quartier mentionné ({quartier}) ≠ " f"secteur de l'annonce ({sector})") elif city and _cle_quartier(city) != _cle_quartier(ville_quartier): incoherences.append(f"quartier mentionné ({quartier}) est associé à " f"{ville_quartier}, l'annonce est à {city}") faits = { "prix_mensuel": prix, "date_disponibilite": dispo, "duree_bail_minimale_mois": bail, "nb_occupants_total": occupants, "salle_de_bain": sdb, "cuisine": cuisine, "electromenagers": electromenagers, "inclusions": inclusions, "contraintes": contraintes, "depot_mentionne": depot, "quartier_mentionne": quartier, } # -- Étape C : sections -------------------------------------------------- if any(kind == "titre" for kind, _ in lignes): sections = _sections_par_titres(lignes) elif len(texte_nettoye) > 400: sections = _sections_par_themes(texte_nettoye) else: sections = [{"titre": "Description", "texte": texte_nettoye}] # -- Étape D : synthèse -------------------------------------------------- return { "version": VERSION, "texte_nettoye": texte_nettoye, "en_bref": _en_bref(faits, confiance), "sections": sections, "faits": faits, "confiance": confiance, "incoherences": incoherences, "completude": _completude(faits, texte_nettoye), }