spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# textmine.py : text mining déterministe des descriptions d'annonces, exécuté5# à l'ingestion (< 5 ms par annonce, aucune dépendance lourde).6# Pipeline en quatre étapes :7# A. nettoyage typographique (MAJUSCULES, doublons, coquilles)8# B. extraction de faits (regex françaises + louka/normalize)9# C. découpage en sections (titres détectés ou thèmes)10# D. score de complétude + niveaux de confiance11# Sortie : dict JSON strict (voir `analyser`). Le seul texte12# généré est « en_bref », assemblé PAR GABARIT à partir des13# faits extraits — jamais d'invention.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from .normalize import (20 extract_details,21 parse_availability_date,22 parse_price,23 strip_accents,24)2526VERSION = 12728# Espace insécable (typographie québécoise : « 450 $ », « 50 % »)29_NBSP = " "3031# ---------------------------------------------------------------------------32# Étape A — nettoyage déterministe33# ---------------------------------------------------------------------------3435_MIN = "a-zàâäéèêëîïôöùûüÿçœæ"36_MAJ = "A-ZÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÇŒÆ"3738# Phrases collées : minuscule (x2, pour épargner « McDonald ») suivie sans39# espace d'une majuscule entamant un mot d'au moins 4 lettres.40_COLLE_RE = re.compile(rf"([{_MIN}]{{2}})([{_MAJ}][{_MIN}]{{3}})")4142# Puces variées en début de ligne -> « • »43_PUCE_RE = re.compile(r"^\s*(?:[-–—*·▪●○◦‣>]+|•+)\s+")44_PUCE_MILIEU_RE = re.compile(r"\s[·▪●○◦‣]\s")4546# Répétitions de ponctuation : « !!! » -> « ! », « ?! » -> « ? »47_PONCT_RE = re.compile(r"([!?])(?:\s*[!?])+")48_POINTS_RE = re.compile(r"\.{4,}")4950# Typographie : espace insécable avant $ et %51_DOLLAR_RE = re.compile(r"(\d)\s*([$%])")5253# Segment EN MAJUSCULES au milieu d'une ligne (≥ 2 mots, ≥ 9 caractères)54_SEG_TITRE_RE = re.compile(55 rf"(?:^|(?<=[\s.!?:•]))"56 rf"([{_MAJ}][{_MAJ}0-9'’/&\-]*(?:[ ]+[{_MAJ}0-9][{_MAJ}0-9'’/&\-]*)+)"57 rf"(?=\s|$)(?!\s*[!?.:;,])") # suivi de ponctuation = emphase, pas un titre5859_PONCT_FIN = ".,;:!?"606162def _est_ligne_criee(ligne: str) -> bool:63 """Ligne EN MAJUSCULES : > 8 caractères, > 60 % de majuscules parmi les lettres."""64 s = ligne.strip()65 if len(s) <= 8:66 return False67 lettres = [c for c in s if c.isalpha()]68 if len(lettres) < 4:69 return False70 maj = sum(1 for c in lettres if c.isupper())71 return maj / len(lettres) > 0.60727374def _est_ligne_titre(ligne: str) -> bool:75 """Ligne-titre : EN MAJUSCULES, courte, sans ponctuation de phrase.7677 « FRAIS PEINT !! » ou « NON-FUMEUR. » sont des phrases criées, pas des78 titres de section : elles seront remises en casse de phrase.79 """80 s = ligne.strip()81 return (_est_ligne_criee(s) and len(s) <= 6082 and "!" not in s and "?" not in s and not s.endswith("."))838485def _titre_capitalise(s: str) -> str:86 """« L'ESPACE DE VIE » -> « L'espace de vie » (capitalisation de titre)."""87 s = s.strip().lower()88 for i, c in enumerate(s):89 if c.isalpha():90 return s[:i] + c.upper() + s[i + 1:]91 return s929394def _dedoublonner_mots(ligne: str) -> str:95 """« Disponible disponible » -> « Disponible » (insensible casse/accents)."""96 mots = ligne.split(" ")97 resultat: list[str] = []98 precedent = None99 for mot in mots:100 cle = strip_accents(mot).lower().strip(_PONCT_FIN + "()•")101 if (cle and cle == precedent and len(cle) > 1102 and any(c.isalpha() for c in cle)):103 # doublon : on le retire, en récupérant sa ponctuation finale104 queue = mot.rstrip()105 if queue and queue[-1] in _PONCT_FIN and resultat \106 and resultat[-1] and resultat[-1][-1] not in _PONCT_FIN:107 resultat[-1] += queue[-1]108 continue109 resultat.append(mot)110 precedent = cle or None111 return " ".join(resultat)112113114def _nettoyer_ligne(ligne: str) -> str:115 """Applique les corrections de l'étape A à une ligne de texte courant."""116 s = _PUCE_RE.sub("• ", ligne)117 s = _PUCE_MILIEU_RE.sub(" • ", s)118 s = _dedoublonner_mots(s)119 s = _COLLE_RE.sub(r"\1. \2", s)120 s = _PONCT_RE.sub(r"\1", s)121 s = _POINTS_RE.sub("...", s)122 s = _DOLLAR_RE.sub(rf"\1{_NBSP}\2", s)123 s = re.sub(r"[ \t]{2,}", " ", s)124 return s.strip()125126127def _etape_a(texte: str) -> list[tuple[str, str]]:128 """Nettoyage -> liste de lignes typées : ("titre"|"texte"|"vide", contenu)."""129 texte = texte.replace("\r\n", "\n").replace("\r", "\n")130 for ch in (" ", " ", " "): # insécables -> espaces simples131 texte = texte.replace(ch, " ")132133 # découpe les segments EN MAJUSCULES enchâssés dans une ligne134 brutes: list[str] = []135 for ligne in texte.split("\n"):136 if _est_ligne_titre(ligne) or _est_ligne_criee(ligne):137 brutes.append(ligne)138 continue139 morceaux, debut, pos = [], 0, 0140 while True:141 m = _SEG_TITRE_RE.search(ligne, pos)142 if not m:143 break144 if len(m.group(1)) < 9: # segment trop court : pas un titre145 pos = m.end()146 continue147 if ligne[debut:m.start()].strip():148 morceaux.append(ligne[debut:m.start()])149 morceaux.append("\x00" + m.group(1))150 debut = pos = m.end()151 if ligne[debut:].strip() or not morceaux:152 morceaux.append(ligne[debut:])153 brutes.extend(morceaux)154155 # classification titre / texte / vide156 lignes: list[tuple[str, str]] = []157 for ligne in brutes:158 seg_titre = ligne.startswith("\x00")159 ligne = ligne.lstrip("\x00")160 if not ligne.strip():161 lignes.append(("vide", ""))162 elif seg_titre or _est_ligne_titre(ligne):163 lignes.append(("titre", _titre_capitalise(ligne)))164 elif _est_ligne_criee(ligne):165 # phrase criée (« FRAIS PEINT !! ») : casse de phrase, pas un titre166 lignes.append(("texte", _nettoyer_ligne(_titre_capitalise(ligne))))167 else:168 lignes.append(("texte", _nettoyer_ligne(ligne)))169170 # garde-fou : description entièrement en MAJUSCULES -> aucun titre,171 # les lignes criées sont simplement remises en casse de phrase172 non_vides = [l for l in lignes if l[0] != "vide"]173 titres = [l for l in lignes if l[0] == "titre"]174 if len(titres) >= 3 and non_vides and len(titres) / len(non_vides) > 0.6:175 lignes = [("texte", t) if k == "titre" else (k, t) for k, t in lignes]176177 # compresse les lignes vides successives (paragraphes préservés)178 compactees: list[tuple[str, str]] = []179 for kind, t in lignes:180 if kind == "vide" and compactees and compactees[-1][0] == "vide":181 continue182 compactees.append((kind, t))183 while compactees and compactees[0][0] == "vide":184 compactees.pop(0)185 while compactees and compactees[-1][0] == "vide":186 compactees.pop()187 return compactees188189190# ---------------------------------------------------------------------------191# Étape B — extraction de faits192# ---------------------------------------------------------------------------193194# Disponibilité : ancre contextuelle (évite de dater « Samedi 15 août » d'une195# porte ouverte) ; « indisponible » / « non disponible » sont écartés.196_DISPO_RE = re.compile(r"(?<![a-z])(?:disponib\w*|libre\b|occupation)")197_DISPO_NEG_RE = re.compile(r"(?:\bin|\bnon\s|\bplus\s|pas\s)$")198_DISPO_SUR_RE = re.compile(r"maintenant|immediat|\blibre\b|\bnow\b|vacant|\d")199200# Prix : contexte mensuel pour la confiance201_PRIX_MENSUEL_RE = re.compile(r"(?:\$|\d)\s*(?:par mois|/\s*mois\b|/m\b|mensuel|mois\b)")202203# Durée de bail minimale204_BAIL_MIN_RE = re.compile(r"(?:minimum|minimal\w*|au moins)\s*(?:de\s+)?(\d{1,2})\s*mois")205_BAIL_MIN2_RE = re.compile(r"(\d{1,2})\s*mois\s*(?:au\s+)?minimum")206_BAIL_DUREE_RE = re.compile(r"bail[^.\n]{0,25}?(\d{1,2})\s*mois")207_BAIL_ANNUEL_RE = re.compile(r"bail\s+(?:de\s+|d.)?(?:un|1)\s*an|bail\s+annuel")208_BAIL_PROMO_RE = re.compile(r"gratuit|promotion|promo\b|rabais|special")209210# Nombre d'occupants total211_OCC_TOTAL_RE = re.compile(212 r"total\s+de\s+(\d{1,2})\s+(?:chambreurs?|occupants?|personnes|locataires?|colocataires?)")213_OCC_CHAMBREURS_RE = re.compile(r"(\d{1,2})\s+chambreurs?")214_OCC_COLOC_RE = re.compile(r"colocation\s+(?:de|a)\s+(\d{1,2})")215_OCC_COLOCS_RE = re.compile(r"(\d{1,2})\s+colocataires?")216217# Salle de bain / cuisine commune ou privée218_SDB_CUISINE_COMMUNES_RE = re.compile(219 r"cuisine\s+et\s+salles?\s+de\s+bains?\s+(?:communes?|partagees?)")220_SDB_COMMUNE_RE = re.compile(221 r"salles?\s+de\s+bains?\s+(?:communes?|partagees?|a\s+partager)|sdb\s+(?:commune|partagee)")222_SDB_PRIVEE_RE = re.compile(r"salles?\s+de\s+bains?\s+privees?|sdb\s+privee")223_CUISINE_COMMUNE_RE = re.compile(r"cuisine\s+(?:commune|partagee|a\s+partager)")224_CUISINE_PRIVEE_RE = re.compile(r"cuisine\s+privee")225226# Électroménagers complémentaires (extract_details couvre le reste)227_MICRO_ONDES_RE = re.compile(r"micro[- ]?ondes?")228_POELE_RE = re.compile(r"\bpoeles?\b")229_LAVEUSE_RE = re.compile(r"\blaveuses?\b")230_SECHEUSE_RE = re.compile(r"\bsecheuses?\b")231_ENTREE_LAVEUSE_RE = re.compile(r"entrees?[^|.\n]{0,35}laveuse|sorties?\s+laveuse")232_ELECTROS_INCLUS_RE = re.compile(r"electromenagers?\s+(?:inclus|fournis)|\d\s*electromenagers")233234# Contraintes complémentaires235_ANIMAUX_NON_RE = re.compile(236 r"animaux[^.|\n]{0,40}(?:ne\s+sont\s+pas|pas|non)\s+acceptes"237 r"|animaux[^.|\n]{0,30}interdits")238_REFERENCES_RE = re.compile(239 r"enquete\s+de\s+credit|references?\s+(?:requises?|demandees?|exigees?|obligatoires?)"240 r"|verification\s+de\s+credit|credit\s+check|preuve\s+de\s+revenu")241_ETUDIANTS_RE = re.compile(r"etudiant(?:e?s)?\s+(?:seulement|uniquement)|reserve\s+aux\s+etudiant")242_OCCUPATION_SIMPLE_RE = re.compile(r"occupation\s+simple")243244# Dépôt / caution : on retourne l'extrait source, jamais une reformulation245# (matché sur le texte nettoyé AVEC accents — d'où les classes [eé], [oô])246_DEPOT_RE = re.compile(247 r"(?:aucun\s+)?(?:d[eé]p[oô]t(?:\s+de\s+garantie)?|caution)\b[^.!\n]{0,60}",248 re.I)249_DEPOT_VALIDE_RE = re.compile(250 r"\$|garantie|exige|requis|demande|obligatoire|aucun|premier\s+mois|remboursable")251252# Quartiers courants (Québec, Lévis, Grand Montréal) : motif -> (nom, ville).253# Matching sur texte sans accents en minuscules ; « st » ⇔ « saint » géré.254_QUARTIERS: list[tuple[re.Pattern, str, str]] = [255 (re.compile(p), nom, ville) for p, nom, ville in [256 # Québec257 (r"\bsillery\b", "Sillery", "Québec"),258 (r"\bsainte?[- ]foy\b|\bste[- ]foy\b", "Sainte-Foy", "Québec"),259 (r"\blimoilou\b", "Limoilou", "Québec"),260 (r"\bmontcalm\b", "Montcalm", "Québec"),261 (r"\bsaint[- ]roch\b|\bst[- ]roch\b", "Saint-Roch", "Québec"),262 (r"\bsaint[- ]sauveur\b|\bst[- ]sauveur\b", "Saint-Sauveur", "Québec"),263 (r"\bsaint[- ]jean[- ]baptiste\b|\bst[- ]jean[- ]baptiste\b",264 "Saint-Jean-Baptiste", "Québec"),265 (r"\bvieux[- ]quebec\b", "Vieux-Québec", "Québec"),266 (r"\blebourgneuf\b", "Lebourgneuf", "Québec"),267 (r"\bbeauport\b", "Beauport", "Québec"),268 (r"\bcharlesbourg\b", "Charlesbourg", "Québec"),269 (r"\bcap[- ]rouge\b", "Cap-Rouge", "Québec"),270 (r"\bvanier\b", "Vanier", "Québec"),271 (r"\bduberger\b", "Duberger", "Québec"),272 (r"\bles saules\b", "Les Saules", "Québec"),273 (r"\bloretteville\b", "Loretteville", "Québec"),274 (r"\bval[- ]belair\b", "Val-Bélair", "Québec"),275 (r"\bneufchatel\b", "Neufchâtel", "Québec"),276 # Lévis277 (r"\bsaint[- ]romuald\b|\bst[- ]romuald\b", "Saint-Romuald", "Lévis"),278 (r"\bcharny\b", "Charny", "Lévis"),279 (r"\bsaint[- ]nicolas\b|\bst[- ]nicolas\b", "Saint-Nicolas", "Lévis"),280 (r"\bvieux[- ]levis\b", "Vieux-Lévis", "Lévis"),281 (r"\bsaint[- ]jean[- ]chrysostome\b|\bst[- ]jean[- ]chrysostome\b",282 "Saint-Jean-Chrysostome", "Lévis"),283 (r"\bpintendre\b", "Pintendre", "Lévis"),284 # Grand Montréal285 (r"\bplateau([- ]mont[- ]royal)?\b", "Plateau-Mont-Royal", "Montréal"),286 (r"\brosemont\b", "Rosemont", "Montréal"),287 (r"\bhochelaga([- ]maisonneuve)?\b", "Hochelaga-Maisonneuve", "Montréal"),288 (r"\bvilleray\b", "Villeray", "Montréal"),289 (r"\bverdun\b", "Verdun", "Montréal"),290 (r"\bgriffintown\b", "Griffintown", "Montréal"),291 (r"\bahuntsic\b", "Ahuntsic", "Montréal"),292 (r"\bcote[- ]des[- ]neiges\b", "Côte-des-Neiges", "Montréal"),293 (r"\bnotre[- ]dame[- ]de[- ]grace\b|\bndg\b", "Notre-Dame-de-Grâce", "Montréal"),294 (r"\bmile[- ]end\b", "Mile End", "Montréal"),295 (r"\boutremont\b", "Outremont", "Montréal"),296 (r"\bwestmount\b", "Westmount", "Montréal"),297 (r"\bsaint[- ]leonard\b|\bst[- ]leonard\b", "Saint-Léonard", "Montréal"),298 (r"\banjou\b", "Anjou", "Montréal"),299 (r"\blasalle\b", "LaSalle", "Montréal"),300 (r"\blachine\b", "Lachine", "Montréal"),301 (r"\bsaint[- ]henri\b|\bst[- ]henri\b", "Saint-Henri", "Montréal"),302 (r"\bpointe[- ]saint[- ]charles\b|\bpointe[- ]st[- ]charles\b",303 "Pointe-Saint-Charles", "Montréal"),304 (r"\bparc[- ]extension\b", "Parc-Extension", "Montréal"),305 ]306]307# préposition juste avant le quartier = mention forte (« à Sillery »)308_QUARTIER_CTX_RE = re.compile(r"(?:\ba\s|\bau\s|\bde\s|\bdu\s|\bdans\s|secteur\s|quartier\s)$")309310# Ordres canoniques d'affichage311_ORDRE_ELECTROS = ["frigo", "cuisiniere", "laveuse", "secheuse", "lave_vaisselle", "micro_ondes"]312_ORDRE_INCLUSIONS = ["chauffage", "electricite", "eau_chaude", "wifi", "cable", "meuble"]313_ORDRE_CONTRAINTES = ["non_fumeur", "pas_d_animaux", "references_requises",314 "etudiants_seulement", "occupation_simple"]315316_MAP_INCLUSIONS = {"heating": "chauffage", "electricity": "electricite",317 "hot_water": "eau_chaude", "internet": "wifi", "cable": "cable"}318319320def _cle(texte: str) -> str:321 """Minuscules sans accents, sauts de ligne préservés (fenêtres contextuelles)."""322 return strip_accents(texte).lower()323324325# Découpage du texte nettoyé en items pour extract_details : chaque item est326# borné par « | » chez normalize, ce qui garde ses motifs négatifs précis et327# évite le backtracking quadratique sur les proses sans ponctuation.328_ITEM_SPLIT_RE = re.compile(r"(?<=[.!?:•])\s+")329_ITEM_MAX = 300 # taille maximale d'un item330_ITEM_CHEVAUCHE = 120 # chevauchement > fenêtre des motifs négatifs (~100 car.)331332# Pré-filtre : seuls les champs d'extract_details consommés ici (inclusions,333# électroménagers, animaux, fumeur, meublé) sont concernés — un item sans334# aucun de ces mots-clés ne peut produire aucun de ces indicateurs, on335# l'écarte donc d'emblée (les motifs lourds de normalize coûtent cher).336_ITEM_PERTINENT_RE = re.compile(337 r"chauff|heat|electr|hydro|eclair|eau chaude|hot water|internet|wi[- ]?fi"338 r"|cable|telus|videotron|television|bell fibe|frigo|fridge|refrig"339 r"|cuisinier|stove|\bfour\b|plaques? de cuisson|poele|lave|dishwasher"340 r"|washer|dryer|secheuse|appliance|meubl|furnish|anima|\bchat|chien"341 r"|\bpets?\b|fum|smok|inclus|included|\brien\b")342343344def _items_extraction(lignes: list[tuple[str, str]]) -> list[str]:345 items: list[str] = []346 for kind, t in lignes:347 if kind != "texte" or not t:348 continue349 for part in _ITEM_SPLIT_RE.split(t):350 part = part.strip()351 if not part or not _ITEM_PERTINENT_RE.search(_cle(part)):352 continue353 while len(part) > _ITEM_MAX: # prose sans ponctuation : on354 coupe = part.rfind(" ", _ITEM_MAX - 120, _ITEM_MAX)355 if coupe <= 0: # tronçonne avec chevauchement356 coupe = _ITEM_MAX357 items.append(part[:coupe])358 part = part[max(0, coupe - _ITEM_CHEVAUCHE):]359 items.append(part)360 return items361362363def _extraire_dispo(k: str) -> tuple[str | None, str]:364 """Date de disponibilité via normalize, ancrée sur un mot-clé du texte."""365 for m in _DISPO_RE.finditer(k):366 if _DISPO_NEG_RE.search(k[max(0, m.start() - 6):m.start()]):367 continue # « indisponible », « non disponible », « plus disponible »368 fenetre = k[m.start():m.start() + 80]369 val = parse_availability_date(fenetre)370 if val is not None:371 conf = "haute" if (val != "now" or _DISPO_SUR_RE.search(fenetre)) else "faible"372 return val, conf373 return None, "haute"374375376def _extraire_bail(k: str) -> tuple[int | None, str]:377 for rx in (_BAIL_MIN_RE, _BAIL_MIN2_RE, _BAIL_DUREE_RE):378 m = rx.search(k)379 if m:380 mois = int(m.group(1))381 if 1 <= mois <= 36:382 contexte = k[max(0, m.start() - 40):m.end() + 20]383 conf = "faible" if _BAIL_PROMO_RE.search(contexte) else "haute"384 return mois, conf385 if _BAIL_ANNUEL_RE.search(k):386 return 12, "haute"387 return None, "haute"388389390def _extraire_occupants(k: str) -> tuple[int | None, str]:391 for rx, conf in ((_OCC_TOTAL_RE, "haute"), (_OCC_CHAMBREURS_RE, "haute"),392 (_OCC_COLOC_RE, "haute"), (_OCC_COLOCS_RE, "faible")):393 m = rx.search(k)394 if m:395 n = int(m.group(1))396 if 2 <= n <= 30:397 return n, conf398 return None, "haute"399400401def _extraire_quartier(k: str) -> tuple[str | None, str, str]:402 """Premier quartier mentionné (position dans le texte) -> (nom, ville, conf)."""403 premier: tuple[int, str, str] | None = None404 for rx, nom, ville in _QUARTIERS:405 m = rx.search(k)406 if m and (premier is None or m.start() < premier[0]):407 premier = (m.start(), nom, ville)408 if premier is None:409 return None, "", "haute"410 pos, nom, ville = premier411 conf = "haute" if _QUARTIER_CTX_RE.search(k[max(0, pos - 12):pos]) else "faible"412 return nom, ville, conf413414415def _cle_quartier(s: str) -> str:416 """Normalise un nom de quartier/secteur pour la comparaison."""417 s = strip_accents(s or "").lower()418 s = re.sub(r"\bste\b", "sainte", s)419 s = re.sub(r"\bst\b", "saint", s)420 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")421422423def _fmt_montant(v: float) -> str:424 return str(int(v)) if v == int(v) else f"{v:g}"425426427def _fmt_date_fr(iso: str) -> str:428 """« 2026-07-01 » -> « le 1er juillet 2026 » ; « now » -> « dès maintenant »."""429 if iso == "now":430 return "dès maintenant"431 mois_fr = ["janvier", "février", "mars", "avril", "mai", "juin", "juillet",432 "août", "septembre", "octobre", "novembre", "décembre"]433 try:434 a, m, j = (int(x) for x in iso.split("-"))435 jour = "1er" if j == 1 else str(j)436 return f"le {jour} {mois_fr[m - 1]} {a}"437 except (ValueError, IndexError):438 return f"le {iso}"439440441# ---------------------------------------------------------------------------442# Étape C — sections443# ---------------------------------------------------------------------------444445# Ancres thématiques (texte long sans titres) — ordre d'évaluation ci-dessous446_THEME_REGLES_RE = re.compile(447 r"non[- ]fumeur|animaux|interdit|reglement|references|enquete de credit"448 r"|no smoking|sans fumee|occupation simple")449_THEME_VIE_RE = re.compile(450 r"colocation|colocataires?|chambreurs?|espaces? communs?|aires? communes?"451 r"|cuisine (?:commune|partagee)|salle de bain (?:commune|partagee)"452 r"|piscine|gym\b|salle d.entrainement|lounge|billard|terrasse")453_THEME_FRAIS_RE = re.compile(454 r"\binclus|inclusions?|\bfrais\b|depot|caution|\bbail\b|par mois|/mois|\$"455 r"|electricite|chauffage|eau chaude|stationnement|loyer")456457458def _sections_par_titres(lignes: list[tuple[str, str]]) -> list[dict]:459 sections: list[dict] = []460 titre_courant = "Description"461 tampon: list[str] = []462463 def _pousser() -> None:464 texte = "\n".join(tampon).strip("\n ")465 if texte:466 sections.append({"titre": titre_courant, "texte": texte})467468 for kind, t in lignes:469 if kind == "titre":470 _pousser()471 titre_courant, tampon = t, []472 else:473 tampon.append(t)474 _pousser()475 return sections476477478def _sections_par_themes(texte: str) -> list[dict]:479 """Texte long sans titres : découpage par thème via ancres regex.480 Chaque phrase reste du texte source nettoyé, jamais reformulé."""481 phrases = [p.strip() for p in re.split(r"(?<=[.!?])\s+|\n+", texte) if p.strip()]482 seaux: dict[str, list[str]] = {"Description": [], "L'espace de vie": [],483 "Frais et conditions": [], "Bon à savoir": []}484 for phrase in phrases:485 k = _cle(phrase)486 if _THEME_REGLES_RE.search(k):487 seaux["Bon à savoir"].append(phrase)488 elif _THEME_VIE_RE.search(k):489 seaux["L'espace de vie"].append(phrase)490 elif _THEME_FRAIS_RE.search(k):491 seaux["Frais et conditions"].append(phrase)492 else:493 seaux["Description"].append(phrase)494 return [{"titre": titre, "texte": " ".join(contenu)}495 for titre, contenu in seaux.items() if contenu]496497498# ---------------------------------------------------------------------------499# Étape B/D — assemblage des faits, en_bref, complétude500# ---------------------------------------------------------------------------501502def _en_bref(faits: dict, confiance: dict) -> str | None:503 """1 à 3 phrases assemblées par gabarit à partir des faits haute-confiance504 SEULEMENT. Aucun mot n'est inventé au-delà des libellés fixes du gabarit."""505506 def _ok(champ: str) -> bool:507 return confiance.get(champ) == "haute"508509 libelles = {"chauffage": "chauffage", "electricite": "électricité",510 "eau_chaude": "eau chaude", "wifi": "wifi", "cable": "câble"}511 phrases: list[str] = []512513 # Phrase 1 : loyer et/ou disponibilité514 prix = faits["prix_mensuel"] if _ok("prix_mensuel") else None515 dispo = faits["date_disponibilite"] if _ok("date_disponibilite") else None516 if prix is not None and dispo:517 phrases.append(f"Loyer de {_fmt_montant(prix)}{_NBSP}$ par mois, "518 f"disponible {_fmt_date_fr(dispo)}.")519 elif prix is not None:520 phrases.append(f"Loyer de {_fmt_montant(prix)}{_NBSP}$ par mois.")521 elif dispo:522 phrases.append(f"Disponible {_fmt_date_fr(dispo)}.")523524 # Phrase 2 : meublé + inclusions525 if _ok("inclusions") and faits["inclusions"]:526 noms = [libelles[i] for i in faits["inclusions"] if i in libelles]527 meuble = "meuble" in faits["inclusions"]528 morceaux = []529 if meuble:530 morceaux.append("Meublé")531 if noms:532 enum = noms[0] if len(noms) == 1 else ", ".join(noms[:-1]) + " et " + noms[-1]533 morceaux.append(f"{enum} inclus")534 if morceaux:535 phrase = " ; ".join(morceaux) + "."536 phrases.append(phrase[0].upper() + phrase[1:])537538 # Phrase 3 : vie commune et bail539 morceaux = []540 if _ok("nb_occupants_total") and faits["nb_occupants_total"]:541 morceaux.append(f"{faits['nb_occupants_total']} occupants au total")542 sdb = faits["salle_de_bain"] if _ok("salle_de_bain") else None543 cuisine = faits["cuisine"] if _ok("cuisine") else None544 if sdb and sdb == cuisine:545 suffixe = "communes" if sdb == "commune" else "privées"546 morceaux.append(f"cuisine et salle de bain {suffixe}")547 else:548 if cuisine:549 morceaux.append(f"cuisine {cuisine.replace('privee', 'privée')}")550 if sdb:551 morceaux.append(f"salle de bain {sdb.replace('privee', 'privée')}")552 if _ok("duree_bail_minimale_mois") and faits["duree_bail_minimale_mois"]:553 morceaux.append(f"bail minimum de {faits['duree_bail_minimale_mois']} mois")554 if morceaux and len(phrases) < 3:555 phrase = ", ".join(morceaux) + "."556 phrases.append(phrase[0].upper() + phrase[1:])557558 return " ".join(phrases[:3]) or None559560561def _completude(faits: dict, texte: str) -> int:562 """Pondération fixe : prix 20, dispo 15, inclusions 20, sdb/cuisine 10,563 occupants 10, contraintes 10, texte > 200 caractères 15 (total 100)."""564 score = 0565 score += 20 if faits["prix_mensuel"] is not None else 0566 score += 15 if faits["date_disponibilite"] else 0567 score += 20 if faits["inclusions"] else 0568 score += 10 if (faits["salle_de_bain"] or faits["cuisine"]) else 0569 score += 10 if faits["nb_occupants_total"] else 0570 score += 10 if faits["contraintes"] else 0571 score += 15 if len(texte) > 200 else 0572 return score573574575# ---------------------------------------------------------------------------576# Point d'entrée577# ---------------------------------------------------------------------------578579def analyser(description: str, *, price: float | None = None,580 sector: str = "", city: str = "") -> dict | None:581 """Analyse une description d'annonce -> dict JSON structuré (schéma strict).582583 None si la description est vide ou trop courte (< 40 caractères).584 `price`, `sector` et `city` (champs de l'annonce) servent uniquement aux585 contrôles de cohérence — jamais à inventer un fait absent du texte.586 """587 if not description or len(description.strip()) < 40:588 return None589590 # -- Étape A : nettoyage -------------------------------------------------591 lignes = _etape_a(description)592 texte_nettoye = "\n".join(t for _, t in lignes)593 k = _cle(texte_nettoye)594595 # -- Étape B : extraction ------------------------------------------------596 confiance: dict[str, str] = {}597 incoherences: list[str] = []598599 det = extract_details(_items_extraction(lignes), "")600601 prix = parse_price(texte_nettoye)602 if prix is not None:603 confiance["prix_mensuel"] = "haute" if _PRIX_MENSUEL_RE.search(k) else "faible"604 if price is not None and abs(prix - price) > 0.5 \605 and confiance["prix_mensuel"] == "haute":606 incoherences.append(607 f"prix dans le texte ({_fmt_montant(prix)} $) ≠ "608 f"prix de l'annonce ({_fmt_montant(price)} $)")609610 dispo, conf_dispo = _extraire_dispo(k)611 if dispo is not None:612 confiance["date_disponibilite"] = conf_dispo613614 bail, conf_bail = _extraire_bail(k)615 if bail is not None:616 confiance["duree_bail_minimale_mois"] = conf_bail617618 occupants, conf_occ = _extraire_occupants(k)619 if occupants is not None:620 confiance["nb_occupants_total"] = conf_occ621622 # salle de bain / cuisine623 sdb = cuisine = None624 if _SDB_CUISINE_COMMUNES_RE.search(k):625 sdb = cuisine = "commune"626 if sdb is None:627 sdb = "commune" if _SDB_COMMUNE_RE.search(k) else \628 "privee" if _SDB_PRIVEE_RE.search(k) else None629 if cuisine is None:630 cuisine = "commune" if _CUISINE_COMMUNE_RE.search(k) else \631 "privee" if _CUISINE_PRIVEE_RE.search(k) else None632 if sdb:633 confiance["salle_de_bain"] = "haute"634 if cuisine:635 confiance["cuisine"] = "haute"636637 # électroménagers : extract_details + compléments locaux638 app = det.get("appliances", {})639 electros: set[str] = set()640 if app.get("fridge"):641 electros.add("frigo")642 if app.get("stove") or _POELE_RE.search(k):643 electros.add("cuisiniere")644 if app.get("dishwasher"):645 electros.add("lave_vaisselle")646 if app.get("washer_dryer"):647 electros.update(("laveuse", "secheuse"))648 elif not app.get("washer_dryer_hookup") and not _ENTREE_LAVEUSE_RE.search(k):649 if _LAVEUSE_RE.search(k):650 electros.add("laveuse")651 if _SECHEUSE_RE.search(k):652 electros.add("secheuse")653 if _MICRO_ONDES_RE.search(k):654 electros.add("micro_ondes")655 if _ELECTROS_INCLUS_RE.search(k):656 electros.update(("frigo", "cuisiniere"))657 electromenagers = [e for e in _ORDRE_ELECTROS if e in electros]658 if electromenagers:659 confiance["electromenagers"] = "haute"660661 # inclusions662 incl: set[str] = {_MAP_INCLUSIONS[c] for c, actif in det.get("inclusions", {}).items()663 if actif and c in _MAP_INCLUSIONS}664 if det.get("furnished") is True:665 incl.add("meuble")666 inclusions = [i for i in _ORDRE_INCLUSIONS if i in incl]667 if inclusions:668 confiance["inclusions"] = "haute"669670 # contraintes671 contr: set[str] = set()672 if det.get("smoking") is False:673 contr.add("non_fumeur")674 if det.get("pets") == "non" or _ANIMAUX_NON_RE.search(k):675 contr.add("pas_d_animaux")676 if _REFERENCES_RE.search(k):677 contr.add("references_requises")678 if _ETUDIANTS_RE.search(k):679 contr.add("etudiants_seulement")680 if _OCCUPATION_SIMPLE_RE.search(k):681 contr.add("occupation_simple")682 contraintes = [c for c in _ORDRE_CONTRAINTES if c in contr]683 if contraintes:684 confiance["contraintes"] = "haute"685686 # dépôt : extrait source (jamais reformulé)687 depot = None688 for m in _DEPOT_RE.finditer(texte_nettoye):689 if _DEPOT_VALIDE_RE.search(_cle(m.group(0))):690 depot = m.group(0).strip(" ,;:")691 confiance["depot_mentionne"] = "haute"692 break693694 # quartier mentionné + cohérence avec le secteur/la ville de l'annonce695 quartier, ville_quartier, conf_quartier = _extraire_quartier(k)696 if quartier:697 confiance["quartier_mentionne"] = conf_quartier698 cq, cs = _cle_quartier(quartier), _cle_quartier(sector)699 if sector and cq != cs and cq not in cs and cs not in cq:700 incoherences.append(f"quartier mentionné ({quartier}) ≠ "701 f"secteur de l'annonce ({sector})")702 elif city and _cle_quartier(city) != _cle_quartier(ville_quartier):703 incoherences.append(f"quartier mentionné ({quartier}) est associé à "704 f"{ville_quartier}, l'annonce est à {city}")705706 faits = {707 "prix_mensuel": prix,708 "date_disponibilite": dispo,709 "duree_bail_minimale_mois": bail,710 "nb_occupants_total": occupants,711 "salle_de_bain": sdb,712 "cuisine": cuisine,713 "electromenagers": electromenagers,714 "inclusions": inclusions,715 "contraintes": contraintes,716 "depot_mentionne": depot,717 "quartier_mentionne": quartier,718 }719720 # -- Étape C : sections --------------------------------------------------721 if any(kind == "titre" for kind, _ in lignes):722 sections = _sections_par_titres(lignes)723 elif len(texte_nettoye) > 400:724 sections = _sections_par_themes(texte_nettoye)725 else:726 sections = [{"titre": "Description", "texte": texte_nettoye}]727728 # -- Étape D : synthèse --------------------------------------------------729 return {730 "version": VERSION,731 "texte_nettoye": texte_nettoye,732 "en_bref": _en_bref(faits, confiance),733 "sections": sections,734 "faits": faits,735 "confiance": confiance,736 "incoherences": incoherences,737 "completude": _completude(faits, texte_nettoye),738 }739