# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: 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 + rentka/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"(? (name, city). Matched on lowercase accent-stripped text. _QUARTIERS: list[tuple[re.Pattern, str, str]] = [ (re.compile(p), nom, ville) for p, nom, ville in [ # Toronto (r"\bthe annex\b|\bannex\b", "The Annex", "Toronto"), (r"\bliberty village\b", "Liberty Village", "Toronto"), (r"\bleslieville\b", "Leslieville", "Toronto"), (r"\bthe beaches\b|\bbeaches\b", "The Beaches", "Toronto"), (r"\byorkville\b", "Yorkville", "Toronto"), (r"\bdistillery\b", "Distillery District", "Toronto"), (r"\bking west\b", "King West", "Toronto"), (r"\bqueen west\b", "Queen West", "Toronto"), (r"\bcabbagetown\b", "Cabbagetown", "Toronto"), (r"\briverdale\b", "Riverdale", "Toronto"), (r"\bthe junction\b", "The Junction", "Toronto"), (r"\bparkdale\b", "Parkdale", "Toronto"), (r"\broncesvalles\b", "Roncesvalles", "Toronto"), (r"\bdanforth\b", "The Danforth", "Toronto"), (r"\bmidtown\b", "Midtown", "Toronto"), (r"\bnorth york\b", "North York", "Toronto"), (r"\betobicoke\b", "Etobicoke", "Toronto"), (r"\bscarborough\b", "Scarborough", "Toronto"), # Ottawa (r"\bcentretown\b", "Centretown", "Ottawa"), (r"\bthe glebe\b|\bglebe\b", "The Glebe", "Ottawa"), (r"\bwestboro\b", "Westboro", "Ottawa"), (r"\bsandy hill\b", "Sandy Hill", "Ottawa"), (r"\bbyward\b", "ByWard Market", "Ottawa"), (r"\bkanata\b", "Kanata", "Ottawa"), (r"\bnepean\b", "Nepean", "Ottawa"), (r"\borleans\b", "Orléans", "Ottawa"), (r"\bhintonburg\b", "Hintonburg", "Ottawa"), # Hamilton / KW / London (r"\bwestdale\b", "Westdale", "Hamilton"), (r"\bstoney creek\b", "Stoney Creek", "Hamilton"), (r"\buptown waterloo\b", "Uptown", "Waterloo"), (r"\bold north\b", "Old North", "London"), (r"\bwortley\b", "Wortley Village", "London"), # Vancouver (r"\byaletown\b", "Yaletown", "Vancouver"), (r"\bkitsilano\b|\bkits\b", "Kitsilano", "Vancouver"), (r"\bgastown\b", "Gastown", "Vancouver"), (r"\bmount pleasant\b", "Mount Pleasant", "Vancouver"), (r"\bwest end\b", "West End", "Vancouver"), (r"\bcommercial drive\b", "Commercial Drive", "Vancouver"), # Calgary / Edmonton (r"\bbeltline\b", "Beltline", "Calgary"), (r"\bkensington\b", "Kensington", "Calgary"), (r"\bmission\b", "Mission", "Calgary"), (r"\bbridgeland\b", "Bridgeland", "Calgary"), (r"\bold strathcona\b|\bstrathcona\b", "Strathcona", "Edmonton"), (r"\boliver\b", "Oliver", "Edmonton"), (r"\bgarneau\b", "Garneau", "Edmonton"), # Winnipeg / Halifax (r"\bosborne village\b", "Osborne Village", "Winnipeg"), (r"\bthe forks\b", "The Forks", "Winnipeg"), (r"\bnorth end halifax\b", "North End", "Halifax"), (r"\bsouth end halifax\b", "South End", "Halifax"), (r"\bdartmouth\b", "Dartmouth", "Halifax"), ] ] # 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: g = next((x for x in m.groups() if x), None) if g is None: continue n = int(g) 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» -> «July 1, 2026»; «now» -> «right away».""" if iso == "now": return "right away" months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] try: a, m, j = (int(x) for x in iso.split("-")) return f"{months[m - 1]} {j}, {a}" except (ValueError, IndexError): return 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": [], "Living space": [], "Fees and conditions": [], "Good to know": []} for phrase in phrases: k = _cle(phrase) if _THEME_REGLES_RE.search(k): seaux["Good to know"].append(phrase) elif _THEME_VIE_RE.search(k): seaux["Living space"].append(phrase) elif _THEME_FRAIS_RE.search(k): seaux["Fees and 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": "heat", "electricite": "electricity", "eau_chaude": "hot water", "wifi": "wifi", "cable": "cable"} 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"Rent of ${_fmt_montant(prix)} per month, " f"available {_fmt_date_fr(dispo)}.") elif prix is not None: phrases.append(f"Rent of ${_fmt_montant(prix)} per month.") elif dispo: phrases.append(f"Available {_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("Furnished") if noms: enum = noms[0] if len(noms) == 1 else ", ".join(noms[:-1]) + " and " + noms[-1] morceaux.append(f"{enum} included") 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 in 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 = "shared" if sdb == "commune" else "private" morceaux.append(f"{suffixe} kitchen and bathroom") else: if cuisine: morceaux.append("shared kitchen" if cuisine == "commune" else "private kitchen") if sdb: morceaux.append("shared bathroom" if sdb == "commune" else "private bathroom") if _ok("duree_bail_minimale_mois") and faits["duree_bail_minimale_mois"]: morceaux.append(f"minimum lease of {faits['duree_bail_minimale_mois']} months") 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), }