Text mining des descriptions : nettoyage, extraction, sections, En bref
louka/textmine.py (déterministe, ~1 ms/annonce, aucune dépendance) : - A nettoyage : TITRES EN MAJUSCULES -> sections, doublons (« Disponible disponible »), phrases collées, typographie québécoise (insécables $/%) - B extraction : bail minimum, occupants (« 11 chambreurs »), sdb/cuisine commune-privée, électros, dépôt, ~40 quartiers (réutilise normalize.py) - C sections jamais reformulées ; « En bref » par gabarit strict à partir des faits haute-confiance seulement (aucune invention) - D confiance par fait, incohérences (quartier mentionné ≠ secteur), complétude 0-100 — 25 tests dont un test d'or sur le 2435 boul. Laurier Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 3 changed files with +1,424 and −0
added
louka/textmine.py
+738 −0
@@ -0,0 +1,738 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — Agrégateur de logements à louer (province de Québec) | |
| 3 | +# Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 4 | +# 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 confiance | |
| 11 | +# Sortie : dict JSON strict (voir `analyser`). Le seul texte | |
| 12 | +# généré est « en_bref », assemblé PAR GABARIT à partir des | |
| 13 | +# faits extraits — jamais d'invention. | |
| 14 | +# ----------------------------------------------------------------------------- | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import re | |
| 18 | + | |
| 19 | +from .normalize import ( | |
| 20 | + extract_details, | |
| 21 | + parse_availability_date, | |
| 22 | + parse_price, | |
| 23 | + strip_accents, | |
| 24 | +) | |
| 25 | + | |
| 26 | +VERSION = 1 | |
| 27 | + | |
| 28 | +# Espace insécable (typographie québécoise : « 450 $ », « 50 % ») | |
| 29 | +_NBSP = " " | |
| 30 | + | |
| 31 | +# --------------------------------------------------------------------------- | |
| 32 | +# Étape A — nettoyage déterministe | |
| 33 | +# --------------------------------------------------------------------------- | |
| 34 | + | |
| 35 | +_MIN = "a-zàâäéèêëîïôöùûüÿçœæ" | |
| 36 | +_MAJ = "A-ZÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÇŒÆ" | |
| 37 | + | |
| 38 | +# Phrases collées : minuscule (x2, pour épargner « McDonald ») suivie sans | |
| 39 | +# espace d'une majuscule entamant un mot d'au moins 4 lettres. | |
| 40 | +_COLLE_RE = re.compile(rf"([{_MIN}]{{2}})([{_MAJ}][{_MIN}]{{3}})") | |
| 41 | + | |
| 42 | +# 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") | |
| 45 | + | |
| 46 | +# Répétitions de ponctuation : « !!! » -> « ! », « ?! » -> « ? » | |
| 47 | +_PONCT_RE = re.compile(r"([!?])(?:\s*[!?])+") | |
| 48 | +_POINTS_RE = re.compile(r"\.{4,}") | |
| 49 | + | |
| 50 | +# Typographie : espace insécable avant $ et % | |
| 51 | +_DOLLAR_RE = re.compile(r"(\d)\s*([$%])") | |
| 52 | + | |
| 53 | +# 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 titre | |
| 58 | + | |
| 59 | +_PONCT_FIN = ".,;:!?" | |
| 60 | + | |
| 61 | + | |
| 62 | +def _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 False | |
| 67 | + lettres = [c for c in s if c.isalpha()] | |
| 68 | + if len(lettres) < 4: | |
| 69 | + return False | |
| 70 | + maj = sum(1 for c in lettres if c.isupper()) | |
| 71 | + return maj / len(lettres) > 0.60 | |
| 72 | + | |
| 73 | + | |
| 74 | +def _est_ligne_titre(ligne: str) -> bool: | |
| 75 | + """Ligne-titre : EN MAJUSCULES, courte, sans ponctuation de phrase. | |
| 76 | + | |
| 77 | + « FRAIS PEINT !! » ou « NON-FUMEUR. » sont des phrases criées, pas des | |
| 78 | + titres de section : elles seront remises en casse de phrase. | |
| 79 | + """ | |
| 80 | + s = ligne.strip() | |
| 81 | + return (_est_ligne_criee(s) and len(s) <= 60 | |
| 82 | + and "!" not in s and "?" not in s and not s.endswith(".")) | |
| 83 | + | |
| 84 | + | |
| 85 | +def _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 s | |
| 92 | + | |
| 93 | + | |
| 94 | +def _dedoublonner_mots(ligne: str) -> str: | |
| 95 | + """« Disponible disponible » -> « Disponible » (insensible casse/accents).""" | |
| 96 | + mots = ligne.split(" ") | |
| 97 | + resultat: list[str] = [] | |
| 98 | + precedent = None | |
| 99 | + for mot in mots: | |
| 100 | + cle = strip_accents(mot).lower().strip(_PONCT_FIN + "()•") | |
| 101 | + if (cle and cle == precedent and len(cle) > 1 | |
| 102 | + and any(c.isalpha() for c in cle)): | |
| 103 | + # doublon : on le retire, en récupérant sa ponctuation finale | |
| 104 | + 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 | + continue | |
| 109 | + resultat.append(mot) | |
| 110 | + precedent = cle or None | |
| 111 | + return " ".join(resultat) | |
| 112 | + | |
| 113 | + | |
| 114 | +def _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() | |
| 125 | + | |
| 126 | + | |
| 127 | +def _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 simples | |
| 131 | + texte = texte.replace(ch, " ") | |
| 132 | + | |
| 133 | + # découpe les segments EN MAJUSCULES enchâssés dans une ligne | |
| 134 | + 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 | + continue | |
| 139 | + morceaux, debut, pos = [], 0, 0 | |
| 140 | + while True: | |
| 141 | + m = _SEG_TITRE_RE.search(ligne, pos) | |
| 142 | + if not m: | |
| 143 | + break | |
| 144 | + if len(m.group(1)) < 9: # segment trop court : pas un titre | |
| 145 | + pos = m.end() | |
| 146 | + continue | |
| 147 | + 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) | |
| 154 | + | |
| 155 | + # classification titre / texte / vide | |
| 156 | + 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 titre | |
| 166 | + lignes.append(("texte", _nettoyer_ligne(_titre_capitalise(ligne)))) | |
| 167 | + else: | |
| 168 | + lignes.append(("texte", _nettoyer_ligne(ligne))) | |
| 169 | + | |
| 170 | + # garde-fou : description entièrement en MAJUSCULES -> aucun titre, | |
| 171 | + # les lignes criées sont simplement remises en casse de phrase | |
| 172 | + 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] | |
| 176 | + | |
| 177 | + # 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 | + continue | |
| 182 | + 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 compactees | |
| 188 | + | |
| 189 | + | |
| 190 | +# --------------------------------------------------------------------------- | |
| 191 | +# Étape B — extraction de faits | |
| 192 | +# --------------------------------------------------------------------------- | |
| 193 | + | |
| 194 | +# Disponibilité : ancre contextuelle (évite de dater « Samedi 15 août » d'une | |
| 195 | +# 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") | |
| 199 | + | |
| 200 | +# Prix : contexte mensuel pour la confiance | |
| 201 | +_PRIX_MENSUEL_RE = re.compile(r"(?:\$|\d)\s*(?:par mois|/\s*mois\b|/m\b|mensuel|mois\b)") | |
| 202 | + | |
| 203 | +# Durée de bail minimale | |
| 204 | +_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") | |
| 209 | + | |
| 210 | +# Nombre d'occupants total | |
| 211 | +_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?") | |
| 216 | + | |
| 217 | +# Salle de bain / cuisine commune ou privée | |
| 218 | +_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") | |
| 225 | + | |
| 226 | +# É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") | |
| 233 | + | |
| 234 | +# Contraintes complémentaires | |
| 235 | +_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") | |
| 243 | + | |
| 244 | +# Dépôt / caution : on retourne l'extrait source, jamais une reformulation | |
| 245 | +# (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") | |
| 251 | + | |
| 252 | +# 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ébec | |
| 257 | + (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évis | |
| 277 | + (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éal | |
| 285 | + (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)$") | |
| 309 | + | |
| 310 | +# Ordres canoniques d'affichage | |
| 311 | +_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"] | |
| 315 | + | |
| 316 | +_MAP_INCLUSIONS = {"heating": "chauffage", "electricity": "electricite", | |
| 317 | + "hot_water": "eau_chaude", "internet": "wifi", "cable": "cable"} | |
| 318 | + | |
| 319 | + | |
| 320 | +def _cle(texte: str) -> str: | |
| 321 | + """Minuscules sans accents, sauts de ligne préservés (fenêtres contextuelles).""" | |
| 322 | + return strip_accents(texte).lower() | |
| 323 | + | |
| 324 | + | |
| 325 | +# Découpage du texte nettoyé en items pour extract_details : chaque item est | |
| 326 | +# borné par « | » chez normalize, ce qui garde ses motifs négatifs précis et | |
| 327 | +# é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 item | |
| 330 | +_ITEM_CHEVAUCHE = 120 # chevauchement > fenêtre des motifs négatifs (~100 car.) | |
| 331 | + | |
| 332 | +# Pré-filtre : seuls les champs d'extract_details consommés ici (inclusions, | |
| 333 | +# électroménagers, animaux, fumeur, meublé) sont concernés — un item sans | |
| 334 | +# aucun de ces mots-clés ne peut produire aucun de ces indicateurs, on | |
| 335 | +# 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") | |
| 342 | + | |
| 343 | + | |
| 344 | +def _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 | + continue | |
| 349 | + 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 | + continue | |
| 353 | + while len(part) > _ITEM_MAX: # prose sans ponctuation : on | |
| 354 | + coupe = part.rfind(" ", _ITEM_MAX - 120, _ITEM_MAX) | |
| 355 | + if coupe <= 0: # tronçonne avec chevauchement | |
| 356 | + coupe = _ITEM_MAX | |
| 357 | + items.append(part[:coupe]) | |
| 358 | + part = part[max(0, coupe - _ITEM_CHEVAUCHE):] | |
| 359 | + items.append(part) | |
| 360 | + return items | |
| 361 | + | |
| 362 | + | |
| 363 | +def _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, conf | |
| 373 | + return None, "haute" | |
| 374 | + | |
| 375 | + | |
| 376 | +def _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, conf | |
| 385 | + if _BAIL_ANNUEL_RE.search(k): | |
| 386 | + return 12, "haute" | |
| 387 | + return None, "haute" | |
| 388 | + | |
| 389 | + | |
| 390 | +def _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, conf | |
| 398 | + return None, "haute" | |
| 399 | + | |
| 400 | + | |
| 401 | +def _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 = None | |
| 404 | + 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 = premier | |
| 411 | + conf = "haute" if _QUARTIER_CTX_RE.search(k[max(0, pos - 12):pos]) else "faible" | |
| 412 | + return nom, ville, conf | |
| 413 | + | |
| 414 | + | |
| 415 | +def _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("-") | |
| 421 | + | |
| 422 | + | |
| 423 | +def _fmt_montant(v: float) -> str: | |
| 424 | + return str(int(v)) if v == int(v) else f"{v:g}" | |
| 425 | + | |
| 426 | + | |
| 427 | +def _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}" | |
| 439 | + | |
| 440 | + | |
| 441 | +# --------------------------------------------------------------------------- | |
| 442 | +# Étape C — sections | |
| 443 | +# --------------------------------------------------------------------------- | |
| 444 | + | |
| 445 | +# Ancres thématiques (texte long sans titres) — ordre d'évaluation ci-dessous | |
| 446 | +_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") | |
| 456 | + | |
| 457 | + | |
| 458 | +def _sections_par_titres(lignes: list[tuple[str, str]]) -> list[dict]: | |
| 459 | + sections: list[dict] = [] | |
| 460 | + titre_courant = "Description" | |
| 461 | + tampon: list[str] = [] | |
| 462 | + | |
| 463 | + def _pousser() -> None: | |
| 464 | + texte = "\n".join(tampon).strip("\n ") | |
| 465 | + if texte: | |
| 466 | + sections.append({"titre": titre_courant, "texte": texte}) | |
| 467 | + | |
| 468 | + 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 sections | |
| 476 | + | |
| 477 | + | |
| 478 | +def _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] | |
| 496 | + | |
| 497 | + | |
| 498 | +# --------------------------------------------------------------------------- | |
| 499 | +# Étape B/D — assemblage des faits, en_bref, complétude | |
| 500 | +# --------------------------------------------------------------------------- | |
| 501 | + | |
| 502 | +def _en_bref(faits: dict, confiance: dict) -> str | None: | |
| 503 | + """1 à 3 phrases assemblées par gabarit à partir des faits haute-confiance | |
| 504 | + SEULEMENT. Aucun mot n'est inventé au-delà des libellés fixes du gabarit.""" | |
| 505 | + | |
| 506 | + def _ok(champ: str) -> bool: | |
| 507 | + return confiance.get(champ) == "haute" | |
| 508 | + | |
| 509 | + libelles = {"chauffage": "chauffage", "electricite": "électricité", | |
| 510 | + "eau_chaude": "eau chaude", "wifi": "wifi", "cable": "câble"} | |
| 511 | + phrases: list[str] = [] | |
| 512 | + | |
| 513 | + # Phrase 1 : loyer et/ou disponibilité | |
| 514 | + prix = faits["prix_mensuel"] if _ok("prix_mensuel") else None | |
| 515 | + dispo = faits["date_disponibilite"] if _ok("date_disponibilite") else None | |
| 516 | + 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)}.") | |
| 523 | + | |
| 524 | + # Phrase 2 : meublé + inclusions | |
| 525 | + 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:]) | |
| 537 | + | |
| 538 | + # Phrase 3 : vie commune et bail | |
| 539 | + 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 None | |
| 543 | + cuisine = faits["cuisine"] if _ok("cuisine") else None | |
| 544 | + 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:]) | |
| 557 | + | |
| 558 | + return " ".join(phrases[:3]) or None | |
| 559 | + | |
| 560 | + | |
| 561 | +def _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 = 0 | |
| 565 | + score += 20 if faits["prix_mensuel"] is not None else 0 | |
| 566 | + score += 15 if faits["date_disponibilite"] else 0 | |
| 567 | + score += 20 if faits["inclusions"] else 0 | |
| 568 | + score += 10 if (faits["salle_de_bain"] or faits["cuisine"]) else 0 | |
| 569 | + score += 10 if faits["nb_occupants_total"] else 0 | |
| 570 | + score += 10 if faits["contraintes"] else 0 | |
| 571 | + score += 15 if len(texte) > 200 else 0 | |
| 572 | + return score | |
| 573 | + | |
| 574 | + | |
| 575 | +# --------------------------------------------------------------------------- | |
| 576 | +# Point d'entrée | |
| 577 | +# --------------------------------------------------------------------------- | |
| 578 | + | |
| 579 | +def 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). | |
| 582 | + | |
| 583 | + None si la description est vide ou trop courte (< 40 caractères). | |
| 584 | + `price`, `sector` et `city` (champs de l'annonce) servent uniquement aux | |
| 585 | + contrôles de cohérence — jamais à inventer un fait absent du texte. | |
| 586 | + """ | |
| 587 | + if not description or len(description.strip()) < 40: | |
| 588 | + return None | |
| 589 | + | |
| 590 | + # -- Étape A : nettoyage ------------------------------------------------- | |
| 591 | + lignes = _etape_a(description) | |
| 592 | + texte_nettoye = "\n".join(t for _, t in lignes) | |
| 593 | + k = _cle(texte_nettoye) | |
| 594 | + | |
| 595 | + # -- Étape B : extraction ------------------------------------------------ | |
| 596 | + confiance: dict[str, str] = {} | |
| 597 | + incoherences: list[str] = [] | |
| 598 | + | |
| 599 | + det = extract_details(_items_extraction(lignes), "") | |
| 600 | + | |
| 601 | + 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)} $)") | |
| 609 | + | |
| 610 | + dispo, conf_dispo = _extraire_dispo(k) | |
| 611 | + if dispo is not None: | |
| 612 | + confiance["date_disponibilite"] = conf_dispo | |
| 613 | + | |
| 614 | + bail, conf_bail = _extraire_bail(k) | |
| 615 | + if bail is not None: | |
| 616 | + confiance["duree_bail_minimale_mois"] = conf_bail | |
| 617 | + | |
| 618 | + occupants, conf_occ = _extraire_occupants(k) | |
| 619 | + if occupants is not None: | |
| 620 | + confiance["nb_occupants_total"] = conf_occ | |
| 621 | + | |
| 622 | + # salle de bain / cuisine | |
| 623 | + sdb = cuisine = None | |
| 624 | + 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 None | |
| 629 | + if cuisine is None: | |
| 630 | + cuisine = "commune" if _CUISINE_COMMUNE_RE.search(k) else \ | |
| 631 | + "privee" if _CUISINE_PRIVEE_RE.search(k) else None | |
| 632 | + if sdb: | |
| 633 | + confiance["salle_de_bain"] = "haute" | |
| 634 | + if cuisine: | |
| 635 | + confiance["cuisine"] = "haute" | |
| 636 | + | |
| 637 | + # électroménagers : extract_details + compléments locaux | |
| 638 | + 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" | |
| 660 | + | |
| 661 | + # inclusions | |
| 662 | + 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" | |
| 669 | + | |
| 670 | + # contraintes | |
| 671 | + 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" | |
| 685 | + | |
| 686 | + # dépôt : extrait source (jamais reformulé) | |
| 687 | + depot = None | |
| 688 | + 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 | + break | |
| 693 | + | |
| 694 | + # quartier mentionné + cohérence avec le secteur/la ville de l'annonce | |
| 695 | + quartier, ville_quartier, conf_quartier = _extraire_quartier(k) | |
| 696 | + if quartier: | |
| 697 | + confiance["quartier_mentionne"] = conf_quartier | |
| 698 | + 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}") | |
| 705 | + | |
| 706 | + 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 | + } | |
| 719 | + | |
| 720 | + # -- É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}] | |
| 727 | + | |
| 728 | + # -- É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 | + } | |
added
reports/textmine.md
+329 −0
@@ -0,0 +1,329 @@ | ||
| 1 | +# Text mining des descriptions — `louka/textmine.py` | |
| 2 | + | |
| 3 | +Analyse déterministe des descriptions d'annonces, exécutée à l'ingestion | |
| 4 | +(~1 ms par annonce en moyenne, p95 ≈ 4 ms sur le corpus complet de 3 763 | |
| 5 | +descriptions). Aucune dépendance lourde : ni spaCy, ni LLM — uniquement des | |
| 6 | +règles et des expressions régulières françaises, plus la réutilisation des | |
| 7 | +extracteurs de `louka/normalize.py` (`parse_price`, `parse_availability_date`, | |
| 8 | +`extract_details`, `strip_accents`). | |
| 9 | + | |
| 10 | +## API | |
| 11 | + | |
| 12 | +```python | |
| 13 | +from louka.textmine import analyser | |
| 14 | + | |
| 15 | +resultat = analyser(description, price=..., sector=..., city=...) | |
| 16 | +# None si description vide ou < 40 caractères, sinon un dict JSON strict | |
| 17 | +``` | |
| 18 | + | |
| 19 | +Les arguments `price`, `sector` et `city` (champs de l'annonce) ne servent | |
| 20 | +qu'aux contrôles de cohérence — jamais à inventer un fait absent du texte. | |
| 21 | + | |
| 22 | +## Pipeline | |
| 23 | + | |
| 24 | +### Étape A — nettoyage (déterministe) | |
| 25 | + | |
| 26 | +- **Lignes/segments EN MAJUSCULES** (> 60 % de majuscules, > 8 caractères, | |
| 27 | + ≤ 60, sans `!`/`?` ni point final) → titres de section, remis en | |
| 28 | + capitalisation de titre (« L'ESPACE DE VIE » → « L'espace de vie »). | |
| 29 | + Les phrases criées (« FRAIS PEINT !! ») sont remises en casse de phrase | |
| 30 | + sans devenir des titres. Garde-fou : une description entièrement en | |
| 31 | + majuscules ne produit aucun titre. | |
| 32 | +- **Mots consécutifs dupliqués** (« Disponible disponible » → « Disponible »), | |
| 33 | + insensible à la casse et aux accents, avec récupération de la ponctuation. | |
| 34 | +- **Phrases collées** : « lumineuxVenez » → « lumineux. Venez » (deux | |
| 35 | + minuscules + majuscule + trois minuscules ; « McDonald » est épargné). | |
| 36 | +- **Typographie québécoise** : espace insécable avant `$` et `%` | |
| 37 | + (« 450$ » → « 450 $ »), espaces multiples réduits, puces variées → « • ». | |
| 38 | +- **Ponctuation répétée** neutralisée (« !!! » → « ! »). | |
| 39 | +- Les paragraphes (sauts de ligne) sont préservés dans `texte_nettoye`. | |
| 40 | + | |
| 41 | +### Étape B — extraction de faits | |
| 42 | + | |
| 43 | +- `prix_mensuel` et `date_disponibilite` via `normalize.parse_price` / | |
| 44 | + `parse_availability_date` ; la disponibilité est ancrée sur un mot-clé | |
| 45 | + (« disponible », « libre », « occupation ») pour ne pas dater une porte | |
| 46 | + ouverte, et « indisponible » est écarté. | |
| 47 | +- `extract_details` de normalize fournit la base (inclusions, | |
| 48 | + électroménagers, animaux, fumeur, meublé) ; le texte nettoyé lui est passé | |
| 49 | + ligne par ligne (pré-filtré et tronçonné) pour garder ses motifs négatifs | |
| 50 | + précis et l'exécution rapide. | |
| 51 | +- Compléments propres au module : durée de bail minimale (« minimum | |
| 52 | + 3 mois », « bail d'un an »), nombre d'occupants (« Total de | |
| 53 | + 11 chambreurs », « colocation de 5 »), salle de bain / cuisine commune ou | |
| 54 | + privée, micro-ondes/poêle, dépôt mentionné (extrait source, jamais | |
| 55 | + reformulé), quartier mentionné (≈ 40 quartiers courants de Québec, Lévis | |
| 56 | + et du Grand Montréal). | |
| 57 | + | |
| 58 | +### Étape C — sections | |
| 59 | + | |
| 60 | +- Texte découpé selon les titres détectés à l'étape A (le préambule devient | |
| 61 | + « Description »). | |
| 62 | +- Sans titres et texte > 400 caractères : découpage par thème via ancres | |
| 63 | + regex — règles/interdits → « Bon à savoir » ; colocation/espaces communs → | |
| 64 | + « L'espace de vie » ; frais/inclusions → « Frais et conditions » ; reste → | |
| 65 | + « Description ». | |
| 66 | +- Chaque section est du texte source nettoyé, **jamais reformulé**. | |
| 67 | + | |
| 68 | +### Étape D — synthèse | |
| 69 | + | |
| 70 | +- `en_bref` : 1 à 3 phrases assemblées **par gabarit** à partir des seuls | |
| 71 | + faits en confiance « haute » (aucune invention) : loyer/disponibilité, | |
| 72 | + meublé/inclusions, vie commune/bail. | |
| 73 | +- `confiance` : « haute » ou « faible » par fait extrait (prix sans contexte | |
| 74 | + mensuel, durée de bail dans une promotion, quartier sans préposition… → | |
| 75 | + « faible », non affiché en badge). | |
| 76 | +- `incoherences` : quartier mentionné ≠ secteur de l'annonce, quartier d'une | |
| 77 | + autre ville, prix du texte ≠ prix de l'annonce. | |
| 78 | +- `completude` (0-100) : prix 20, disponibilité 15, inclusions 20, | |
| 79 | + sdb/cuisine 10, occupants 10, contraintes 10, texte > 200 caractères 15. | |
| 80 | + | |
| 81 | +## Complétude sur le corpus | |
| 82 | + | |
| 83 | +- 20 annonces aléatoires (graine 42) : moyenne **26,5 / 100** | |
| 84 | + (valeurs : 0, 80, 35, 15, 0, 55, 15, 50, 35, 45, 0, 20, 15, 15, 0, 20, 60, 35, 35, 0). | |
| 85 | +- Corpus complet (3 763 descriptions ≥ 40 caractères) : moyenne **18,9 / 100** | |
| 86 | + — la plupart des descriptions de gestionnaires sont du texte de marque | |
| 87 | + sans prix ni disponibilité, ce que le score reflète. | |
| 88 | + | |
| 89 | +## Exemples réels (JSON produit) | |
| 90 | + | |
| 91 | +### `gestipro:24813` — 2435 boul. Laurier (Gestipro) — chambre, cas de référence | |
| 92 | +```json | |
| 93 | +{ | |
| 94 | + "version": 1, | |
| 95 | + "texte_nettoye": "Chambre meublé de base en location située au 2435 boul. Laurier à Sillery. Disponible\ndès maintenant\nà 450 $ par mois, chauffage, électricité, eau chaude et wifi inclus. Location pour minimum 3 mois.\nL’espace de vie\nTotal de 11 chambreurs dans la maison\nCuisine et salle de bain commune aux autres occupants de l’appartement\nRéfrigérateur, cuisinière, laveuse et sécheuse inclus dans les pièces communes\nChambre meublée de base comme sur les photos, pour occupation simple seulement\n\nFrais et charges\nChauffage, eau chaude et électricité inclus\nWifi inclus\nÀ proximité / secteur\nSecteur très prisé de la ville\nÀ proximité des ponts, de l’autoroute Robert-Bourassa et de l’autoroute Henri IV\nPrès de tous les services de Sillery et Ste-Foy dont Laurier Québec, Place de la Cité et Place Ste-Foy.\nUniversité Laval à distance de marche\nPrendre note qu’afin d’offrir à nos locataires une tranquillité, les animaux ne sont pas acceptés. Enquête de crédit obligatoire pour tous nos nouveaux locataires.\nPour visiter, communiquez avec Gestipro au 418-522-2525 poste 1\nPour consulter tous nos appartements à louer, visitez le www.gestipro.info", | |
| 96 | + "en_bref": "Loyer de 450 $ par mois, disponible dès maintenant. Meublé ; chauffage, électricité, eau chaude et wifi inclus. 11 occupants au total, cuisine et salle de bain communes, bail minimum de 3 mois.", | |
| 97 | + "sections": [ | |
| 98 | + { | |
| 99 | + "titre": "Description", | |
| 100 | + "texte": "Chambre meublé de base en location située au 2435 boul. Laurier à Sillery. Disponible\ndès maintenant\nà 450 $ par mois, chauffage, électricité, eau chaude et wifi inclus. Location pour minimum 3 mois." | |
| 101 | + }, | |
| 102 | + { | |
| 103 | + "titre": "L’espace de vie", | |
| 104 | + "texte": "Total de 11 chambreurs dans la maison\nCuisine et salle de bain commune aux autres occupants de l’appartement\nRéfrigérateur, cuisinière, laveuse et sécheuse inclus dans les pièces communes\nChambre meublée de base comme sur les photos, pour occupation simple seulement" | |
| 105 | + }, | |
| 106 | + { | |
| 107 | + "titre": "Frais et charges", | |
| 108 | + "texte": "Chauffage, eau chaude et électricité inclus\nWifi inclus" | |
| 109 | + }, | |
| 110 | + { | |
| 111 | + "titre": "À proximité / secteur", | |
| 112 | + "texte": "Secteur très prisé de la ville\nÀ proximité des ponts, de l’autoroute Robert-Bourassa et de l’autoroute Henri IV\nPrès de tous les services de Sillery et Ste-Foy dont Laurier Québec, Place de la Cité et Place Ste-Foy.\nUniversité Laval à distance de marche\nPrendre note qu’afin d’offrir à nos locataires une tranquillité, les animaux ne sont pas acceptés. Enquête de crédit obligatoire pour tous nos nouveaux locataires.\nPour visiter, communiquez avec Gestipro au 418-522-2525 poste 1\nPour consulter tous nos appartements à louer, visitez le www.gestipro.info" | |
| 113 | + } | |
| 114 | + ], | |
| 115 | + "faits": { | |
| 116 | + "prix_mensuel": 450.0, | |
| 117 | + "date_disponibilite": "now", | |
| 118 | + "duree_bail_minimale_mois": 3, | |
| 119 | + "nb_occupants_total": 11, | |
| 120 | + "salle_de_bain": "commune", | |
| 121 | + "cuisine": "commune", | |
| 122 | + "electromenagers": [ | |
| 123 | + "frigo", | |
| 124 | + "cuisiniere", | |
| 125 | + "laveuse", | |
| 126 | + "secheuse" | |
| 127 | + ], | |
| 128 | + "inclusions": [ | |
| 129 | + "chauffage", | |
| 130 | + "electricite", | |
| 131 | + "eau_chaude", | |
| 132 | + "wifi", | |
| 133 | + "meuble" | |
| 134 | + ], | |
| 135 | + "contraintes": [ | |
| 136 | + "pas_d_animaux", | |
| 137 | + "references_requises", | |
| 138 | + "occupation_simple" | |
| 139 | + ], | |
| 140 | + "depot_mentionne": null, | |
| 141 | + "quartier_mentionne": "Sillery" | |
| 142 | + }, | |
| 143 | + "confiance": { | |
| 144 | + "prix_mensuel": "haute", | |
| 145 | + "date_disponibilite": "haute", | |
| 146 | + "duree_bail_minimale_mois": "haute", | |
| 147 | + "nb_occupants_total": "haute", | |
| 148 | + "salle_de_bain": "haute", | |
| 149 | + "cuisine": "haute", | |
| 150 | + "electromenagers": "haute", | |
| 151 | + "inclusions": "haute", | |
| 152 | + "contraintes": "haute", | |
| 153 | + "quartier_mentionne": "haute" | |
| 154 | + }, | |
| 155 | + "incoherences": [ | |
| 156 | + "quartier mentionné (Sillery) ≠ secteur de l'annonce (Sainte-Foy)" | |
| 157 | + ], | |
| 158 | + "completude": 100 | |
| 159 | +} | |
| 160 | +``` | |
| 161 | + | |
| 162 | +### `gimcote:10143` — 606 rue Napoléon (GIM Côté) — 3½ Saint-Jean-Baptiste | |
| 163 | +```json | |
| 164 | +{ | |
| 165 | + "version": 1, | |
| 166 | + "texte_nettoye": "418-655-8281\nÀ voir au 606 rue Napoléon, superbe grand 3 1/2, entièrement refait à neuf ! Situé dans un immeuble tranquille, situé au 2e et dernier étage. Galerie arrière.\nFrais peint !\nTrès belle cuisine avec magnifiques armoires. Logement bien éclairé!\nPossibilité d’avoir laveuse-sécheuse et frigo inclus moyennant un supplément.\nPlancher de bois et céramique.\nBelle salle de bain, entrée laveuse-sécheuse.\nInstallation lave-vaisselle.\nNon-fumeur.\nChat opéré accepté, pas de chien.\nSituation idéal, en plein coeur du Centre-Ville, accès rapide à la Haute-Ville et aux commerces de la Cité Électronique. Tout les services à proximité tel que épicerie, banques, pharmacie, restaurant etc.. Transport en commun très proche !\n850 $ par mois non-chauffé, non-éclairé\nLibre maintenant\nPour plus d’information ou pour visité, téléphonez à Michel au 418-655-8281", | |
| 167 | + "en_bref": "Loyer de 850 $ par mois, disponible dès maintenant.", | |
| 168 | + "sections": [ | |
| 169 | + { | |
| 170 | + "titre": "Description", | |
| 171 | + "texte": "418-655-8281 À voir au 606 rue Napoléon, superbe grand 3 1/2, entièrement refait à neuf ! Situé dans un immeuble tranquille, situé au 2e et dernier étage. Galerie arrière. Très belle cuisine avec magnifiques armoires. Logement bien éclairé! Plancher de bois et céramique. Belle salle de bain, entrée laveuse-sécheuse. Installation lave-vaisselle. Chat opéré accepté, pas de chien. Situation idéal, en plein coeur du Centre-Ville, accès rapide à la Haute-Ville et aux commerces de la Cité Électronique. Tout les services à proximité tel que épicerie, banques, pharmacie, restaurant etc.. Transport en commun très proche ! Libre maintenant Pour plus d’information ou pour visité, téléphonez à Michel au 418-655-8281" | |
| 172 | + }, | |
| 173 | + { | |
| 174 | + "titre": "Frais et conditions", | |
| 175 | + "texte": "Frais peint ! Possibilité d’avoir laveuse-sécheuse et frigo inclus moyennant un supplément. 850 $ par mois non-chauffé, non-éclairé" | |
| 176 | + }, | |
| 177 | + { | |
| 178 | + "titre": "Bon à savoir", | |
| 179 | + "texte": "Non-fumeur." | |
| 180 | + } | |
| 181 | + ], | |
| 182 | + "faits": { | |
| 183 | + "prix_mensuel": 850.0, | |
| 184 | + "date_disponibilite": "now", | |
| 185 | + "duree_bail_minimale_mois": null, | |
| 186 | + "nb_occupants_total": null, | |
| 187 | + "salle_de_bain": null, | |
| 188 | + "cuisine": null, | |
| 189 | + "electromenagers": [ | |
| 190 | + "frigo", | |
| 191 | + "lave_vaisselle" | |
| 192 | + ], | |
| 193 | + "inclusions": [], | |
| 194 | + "contraintes": [ | |
| 195 | + "non_fumeur" | |
| 196 | + ], | |
| 197 | + "depot_mentionne": null, | |
| 198 | + "quartier_mentionne": null | |
| 199 | + }, | |
| 200 | + "confiance": { | |
| 201 | + "prix_mensuel": "haute", | |
| 202 | + "date_disponibilite": "haute", | |
| 203 | + "electromenagers": "haute", | |
| 204 | + "contraintes": "haute" | |
| 205 | + }, | |
| 206 | + "incoherences": [], | |
| 207 | + "completude": 60 | |
| 208 | +} | |
| 209 | +``` | |
| 210 | + | |
| 211 | +### `immomarketing:10150-rue-lajeunesse-montreal-3-et-demi` — 10150 rue Lajeunesse (Immo Marketing) — 3½ Ahuntsic | |
| 212 | +```json | |
| 213 | +{ | |
| 214 | + "version": 1, | |
| 215 | + "texte_nettoye": "Promotion 2 mois gratuit sur certaines\nUNITÉS ! À louer – Charmant 3 ½ au cœur de Ahuntsic-Cartierville Découvrez ce 3 ½ lumineux et bien entretenu , situé dans un secteur recherché de Cartierville, à deux pas de la station de métro Sauvé (ligne orange). Parfait pour une personne seule ou un couple à la recherche d’un environnement urbain pratique, avec un accès facile aux services essentiels. Ce qui est inclus : Cuisinière et réfrigérateur Chauffage et eau chaude Service de buanderie dans l’immeuble Un emplacement de choix : À moins de 5 minutes à pied du métro Sauvé Épiceries, écoles et pharmacies à proximité Entouré de nombreux parcs et espaces verts , parfaits pour vos moments de détente ou vos activités de plein air Informations supplémentaires Non-fumeur Animaux non acceptés Enquête de crédit exigée Photos à titre indicatif Gestionnaire de l’immeuble Pour toute demande de visite ou information concernant l’immeuble, veuillez communiquer directement avec le gestionnaire : Guy : 438-373-7153 Sadrack : 514-998-8421 Il se fera un plaisir de vous répondre. N’attendez plus pour planifier votre visite! Ce logement allie confort, accessibilité et tranquillité dans un quartier en pleine vie. — 1 chambre(s), 1 salle(s) de bain.", | |
| 216 | + "en_bref": "Chauffage et eau chaude inclus.", | |
| 217 | + "sections": [ | |
| 218 | + { | |
| 219 | + "titre": "Promotion 2 mois gratuit sur certaines", | |
| 220 | + "texte": "UNITÉS ! À louer – Charmant 3 ½ au cœur de Ahuntsic-Cartierville Découvrez ce 3 ½ lumineux et bien entretenu , situé dans un secteur recherché de Cartierville, à deux pas de la station de métro Sauvé (ligne orange). Parfait pour une personne seule ou un couple à la recherche d’un environnement urbain pratique, avec un accès facile aux services essentiels. Ce qui est inclus : Cuisinière et réfrigérateur Chauffage et eau chaude Service de buanderie dans l’immeuble Un emplacement de choix : À moins de 5 minutes à pied du métro Sauvé Épiceries, écoles et pharmacies à proximité Entouré de nombreux parcs et espaces verts , parfaits pour vos moments de détente ou vos activités de plein air Informations supplémentaires Non-fumeur Animaux non acceptés Enquête de crédit exigée Photos à titre indicatif Gestionnaire de l’immeuble Pour toute demande de visite ou information concernant l’immeuble, veuillez communiquer directement avec le gestionnaire : Guy : 438-373-7153 Sadrack : 514-998-8421 Il se fera un plaisir de vous répondre. N’attendez plus pour planifier votre visite! Ce logement allie confort, accessibilité et tranquillité dans un quartier en pleine vie. — 1 chambre(s), 1 salle(s) de bain." | |
| 221 | + } | |
| 222 | + ], | |
| 223 | + "faits": { | |
| 224 | + "prix_mensuel": null, | |
| 225 | + "date_disponibilite": null, | |
| 226 | + "duree_bail_minimale_mois": null, | |
| 227 | + "nb_occupants_total": null, | |
| 228 | + "salle_de_bain": null, | |
| 229 | + "cuisine": null, | |
| 230 | + "electromenagers": [ | |
| 231 | + "frigo", | |
| 232 | + "cuisiniere" | |
| 233 | + ], | |
| 234 | + "inclusions": [ | |
| 235 | + "chauffage", | |
| 236 | + "eau_chaude" | |
| 237 | + ], | |
| 238 | + "contraintes": [ | |
| 239 | + "non_fumeur", | |
| 240 | + "pas_d_animaux", | |
| 241 | + "references_requises" | |
| 242 | + ], | |
| 243 | + "depot_mentionne": null, | |
| 244 | + "quartier_mentionne": "Ahuntsic" | |
| 245 | + }, | |
| 246 | + "confiance": { | |
| 247 | + "electromenagers": "haute", | |
| 248 | + "inclusions": "haute", | |
| 249 | + "contraintes": "haute", | |
| 250 | + "quartier_mentionne": "haute" | |
| 251 | + }, | |
| 252 | + "incoherences": [], | |
| 253 | + "completude": 45 | |
| 254 | +} | |
| 255 | +``` | |
| 256 | + | |
| 257 | +### `progim:332233` — Progim 332233 — texte générique de gestionnaire | |
| 258 | +```json | |
| 259 | +{ | |
| 260 | + "version": 1, | |
| 261 | + "texte_nettoye": "Situé dans le secteur paisible et recherché de Dorval, cet appartement bénéficie d’un emplacement idéal à proximité du bord de l’eau, de plusieurs parcs, commerces et services essentiels, tout en offrant un accès rapide aux autoroutes et au transport en commun. Il offre : • • • L’immeuble se trouve dans un environnement calme et résidentiel, parfait pour les familles, couples et professionnels recherchant une qualité de vie agréable à proximité du centre-ville et de l’aéroport. Points forts du secteur : • À proximité du bord de l’eau et du chemin du Bord-du-Lac-Lakeshore • Près des parcs Winds", | |
| 262 | + "en_bref": null, | |
| 263 | + "sections": [ | |
| 264 | + { | |
| 265 | + "titre": "Description", | |
| 266 | + "texte": "Situé dans le secteur paisible et recherché de Dorval, cet appartement bénéficie d’un emplacement idéal à proximité du bord de l’eau, de plusieurs parcs, commerces et services essentiels, tout en offrant un accès rapide aux autoroutes et au transport en commun. Il offre : • • • L’immeuble se trouve dans un environnement calme et résidentiel, parfait pour les familles, couples et professionnels recherchant une qualité de vie agréable à proximité du centre-ville et de l’aéroport. Points forts du secteur : • À proximité du bord de l’eau et du chemin du Bord-du-Lac-Lakeshore • Près des parcs Winds" | |
| 267 | + } | |
| 268 | + ], | |
| 269 | + "faits": { | |
| 270 | + "prix_mensuel": null, | |
| 271 | + "date_disponibilite": null, | |
| 272 | + "duree_bail_minimale_mois": null, | |
| 273 | + "nb_occupants_total": null, | |
| 274 | + "salle_de_bain": null, | |
| 275 | + "cuisine": null, | |
| 276 | + "electromenagers": [], | |
| 277 | + "inclusions": [], | |
| 278 | + "contraintes": [], | |
| 279 | + "depot_mentionne": null, | |
| 280 | + "quartier_mentionne": null | |
| 281 | + }, | |
| 282 | + "confiance": {}, | |
| 283 | + "incoherences": [], | |
| 284 | + "completude": 15 | |
| 285 | +} | |
| 286 | +``` | |
| 287 | + | |
| 288 | +### `cogir:1522-3` — Cogir 1522-3 — texte de marque d'un projet locatif | |
| 289 | +```json | |
| 290 | +{ | |
| 291 | + "version": 1, | |
| 292 | + "texte_nettoye": "Les condos Domo à louer dans le Vieux-Montréal proposent des unités élégantes avec électroménagers et services inclus. Profitez d'une boulangerie et du marché Aisle 24 directement dans votre immeuble. Les espaces communs somptueux comprennent une piscine sur le toit, une terrasse, un salon social ainsi qu'une salle de sport entièrement équipée. L'entrée sans clé et le stationnement souterrain, ainsi que les espaces de rangement et les supports à vélos, ajoutent un niveau supplémentaire de commodité.", | |
| 293 | + "en_bref": null, | |
| 294 | + "sections": [ | |
| 295 | + { | |
| 296 | + "titre": "Description", | |
| 297 | + "texte": "Profitez d'une boulangerie et du marché Aisle 24 directement dans votre immeuble." | |
| 298 | + }, | |
| 299 | + { | |
| 300 | + "titre": "L'espace de vie", | |
| 301 | + "texte": "Les espaces communs somptueux comprennent une piscine sur le toit, une terrasse, un salon social ainsi qu'une salle de sport entièrement équipée." | |
| 302 | + }, | |
| 303 | + { | |
| 304 | + "titre": "Frais et conditions", | |
| 305 | + "texte": "Les condos Domo à louer dans le Vieux-Montréal proposent des unités élégantes avec électroménagers et services inclus. L'entrée sans clé et le stationnement souterrain, ainsi que les espaces de rangement et les supports à vélos, ajoutent un niveau supplémentaire de commodité." | |
| 306 | + } | |
| 307 | + ], | |
| 308 | + "faits": { | |
| 309 | + "prix_mensuel": null, | |
| 310 | + "date_disponibilite": null, | |
| 311 | + "duree_bail_minimale_mois": null, | |
| 312 | + "nb_occupants_total": null, | |
| 313 | + "salle_de_bain": null, | |
| 314 | + "cuisine": null, | |
| 315 | + "electromenagers": [ | |
| 316 | + "frigo" | |
| 317 | + ], | |
| 318 | + "inclusions": [], | |
| 319 | + "contraintes": [], | |
| 320 | + "depot_mentionne": null, | |
| 321 | + "quartier_mentionne": null | |
| 322 | + }, | |
| 323 | + "confiance": { | |
| 324 | + "electromenagers": "haute" | |
| 325 | + }, | |
| 326 | + "incoherences": [], | |
| 327 | + "completude": 15 | |
| 328 | +} | |
| 329 | +``` | |
added
tests/test_textmine.py
+357 −0
@@ -0,0 +1,357 @@ | ||
| 1 | +# ----------------------------------------------------------------------------- | |
| 2 | +# Lou-Ka — tests du text mining des descriptions (louka/textmine.py) | |
| 3 | +# ----------------------------------------------------------------------------- | |
| 4 | +import json | |
| 5 | + | |
| 6 | +from louka.textmine import analyser | |
| 7 | + | |
| 8 | +NBSP = " " # espace insécable (typographie québécoise : « 450 $ ») | |
| 9 | + | |
| 10 | +# Cas de référence : VRAIE description de la chambre Gestipro du | |
| 11 | +# 2435 boul. Laurier (source gestipro:24813, copiée telle quelle de la base). | |
| 12 | +DESC_2435_LAURIER = ( | |
| 13 | + "Chambre meublé de base en location située au 2435 boul. Laurier à " | |
| 14 | + "Sillery. Disponible disponible\n" | |
| 15 | + "dès maintenant\n" | |
| 16 | + "à 450$ par mois, chauffage, électricité, eau chaude et wifi inclus. " | |
| 17 | + "Location pour minimum 3 mois.\n" | |
| 18 | + "L’ESPACE DE VIE\n" | |
| 19 | + "Total de 11 chambreurs dans la maison\n" | |
| 20 | + "Cuisine et salle de bain commune aux autres occupants de l’appartement\n" | |
| 21 | + "Réfrigérateur, cuisinière, laveuse et sécheuse inclus dans les pièces communes\n" | |
| 22 | + "Chambre meublée de base comme sur les photos, pour occupation simple seulement\n" | |
| 23 | + " \n" | |
| 24 | + "FRAIS ET CHARGES\n" | |
| 25 | + "Chauffage, eau chaude et électricité inclus\n" | |
| 26 | + "Wifi inclus\n" | |
| 27 | + "À PROXIMITÉ / SECTEUR\n" | |
| 28 | + "Secteur très prisé de la ville\n" | |
| 29 | + "À proximité des ponts, de l’autoroute Robert-Bourassa et de l’autoroute Henri IV\n" | |
| 30 | + "Près de tous les services de Sillery et Ste-Foy dont Laurier Québec, " | |
| 31 | + "Place de la Cité et Place Ste-Foy.\n" | |
| 32 | + "Université Laval à distance de marche\n" | |
| 33 | + "Prendre note qu’afin d’offrir à nos locataires une tranquillité, les " | |
| 34 | + "animaux ne sont pas acceptés. Enquête de crédit obligatoire pour tous " | |
| 35 | + "nos nouveaux locataires.\n" | |
| 36 | + "Pour visiter, communiquez avec Gestipro au 418-522-2525 poste 1\n" | |
| 37 | + "Pour consulter tous nos appartements à louer, visitez le www.gestipro.info" | |
| 38 | +) | |
| 39 | + | |
| 40 | + | |
| 41 | +# -- garde d'entrée ------------------------------------------------------------- | |
| 42 | + | |
| 43 | +def test_description_vide_ou_trop_courte(): | |
| 44 | + assert analyser("") is None | |
| 45 | + assert analyser(" ") is None | |
| 46 | + assert analyser("Beau logement à louer.") is None # < 40 caractères | |
| 47 | + assert analyser("x" * 39) is None | |
| 48 | + assert analyser("Grand appartement lumineux à louer au centre-ville.") is not None | |
| 49 | + | |
| 50 | + | |
| 51 | +# -- étape A : nettoyage --------------------------------------------------------- | |
| 52 | + | |
| 53 | +def test_titres_en_majuscules_deviennent_sections(): | |
| 54 | + res = analyser("Magnifique appartement au bord de l'eau du fleuve.\n" | |
| 55 | + "L'ESPACE DE VIE\n" | |
| 56 | + "Grande pièce ouverte avec beaucoup de rangement pratique.") | |
| 57 | + assert "L'espace de vie" in res["texte_nettoye"] | |
| 58 | + assert "L'ESPACE DE VIE" not in res["texte_nettoye"] | |
| 59 | + assert [s["titre"] for s in res["sections"]] == ["Description", "L'espace de vie"] | |
| 60 | + assert res["sections"][1]["texte"] == \ | |
| 61 | + "Grande pièce ouverte avec beaucoup de rangement pratique." | |
| 62 | + | |
| 63 | + | |
| 64 | +def test_segment_majuscules_dans_une_ligne(): | |
| 65 | + # titre EN MAJUSCULES enchâssé au milieu d'une ligne (≥ 2 mots, ≥ 9 car.) | |
| 66 | + res = analyser("Belle chambre au centre-ville. FRAIS ET CONDITIONS " | |
| 67 | + "Le loyer inclut le chauffage et l'électricité.") | |
| 68 | + assert "Frais et conditions" in [s["titre"] for s in res["sections"]] | |
| 69 | + | |
| 70 | + | |
| 71 | +def test_doublons_de_mots_consecutifs(): | |
| 72 | + # insensible à la casse et aux accents | |
| 73 | + res = analyser("Logement rénové RÉNOVÉ récemment. Disponible disponible " | |
| 74 | + "dès maintenant pour les visites de groupe.") | |
| 75 | + assert "rénové RÉNOVÉ" not in res["texte_nettoye"] | |
| 76 | + assert "Disponible disponible" not in res["texte_nettoye"] | |
| 77 | + assert "Disponible dès maintenant" in res["texte_nettoye"] | |
| 78 | + | |
| 79 | + | |
| 80 | +def test_phrases_collees(): | |
| 81 | + res = analyser("Appartement très lumineuxVenez le visiter rapidement, " | |
| 82 | + "près du McDonald et du parc du quartier.") | |
| 83 | + assert "lumineux. Venez" in res["texte_nettoye"] | |
| 84 | + assert "McDonald" in res["texte_nettoye"] # nom propre intact | |
| 85 | + | |
| 86 | + | |
| 87 | +def test_typographie_quebecoise(): | |
| 88 | + res = analyser("Grand 4 1/2 à 875$ par mois avec rabais de 5% !!! " | |
| 89 | + "Venez visiter l'appartement dès aujourd'hui.") | |
| 90 | + assert f"875{NBSP}$ par mois" in res["texte_nettoye"] # insécable avant $ | |
| 91 | + assert f"5{NBSP}%" in res["texte_nettoye"] # insécable avant % | |
| 92 | + assert "!!!" not in res["texte_nettoye"] # « !!! » -> « ! » | |
| 93 | + assert " " not in res["texte_nettoye"] # espaces multiples | |
| 94 | + | |
| 95 | + | |
| 96 | +def test_puces_normalisees(): | |
| 97 | + res = analyser("Inclusions du logement :\n- Frigo inclus\n* Cuisinière " | |
| 98 | + "incluse\n· Balcon avant avec vue") | |
| 99 | + lignes = res["texte_nettoye"].split("\n") | |
| 100 | + assert lignes[1:] == ["• Frigo inclus", "• Cuisinière incluse", | |
| 101 | + "• Balcon avant avec vue"] | |
| 102 | + | |
| 103 | + | |
| 104 | +# -- étape B : extraction -------------------------------------------------------- | |
| 105 | + | |
| 106 | +def test_prix_mensuel_et_confiance(): | |
| 107 | + res = analyser("Beau 4 1/2 chauffé et éclairé à 995$ par mois, dans un " | |
| 108 | + "immeuble tranquille près des services.") | |
| 109 | + assert res["faits"]["prix_mensuel"] == 995 | |
| 110 | + assert res["confiance"]["prix_mensuel"] == "haute" | |
| 111 | + # montant sans contexte mensuel -> confiance faible, exclu de « en_bref » | |
| 112 | + res2 = analyser("Grand studio lumineux au centre-ville, rabais de 500 $ " | |
| 113 | + "à la signature avant la fin du mois de septembre.") | |
| 114 | + assert res2["faits"]["prix_mensuel"] == 500 | |
| 115 | + assert res2["confiance"]["prix_mensuel"] == "faible" | |
| 116 | + assert res2["en_bref"] is None or "Loyer" not in res2["en_bref"] | |
| 117 | + | |
| 118 | + | |
| 119 | +def test_incoherence_prix_annonce(): | |
| 120 | + res = analyser("Chambre à louer à 450$ par mois dans une grande maison " | |
| 121 | + "près de l'université et des autobus.", price=500.0) | |
| 122 | + assert any("450" in i and "500" in i for i in res["incoherences"]) | |
| 123 | + | |
| 124 | + | |
| 125 | +def test_date_disponibilite_ancree(): | |
| 126 | + res = analyser("Grande chambre à louer, disponible dès maintenant, dans " | |
| 127 | + "un secteur paisible près des autobus.") | |
| 128 | + assert res["faits"]["date_disponibilite"] == "now" | |
| 129 | + # une date évènementielle sans ancre de disponibilité n'est PAS une dispo | |
| 130 | + res2 = analyser("Grande porte ouverte samedi 15 août pour venir voir le " | |
| 131 | + "logement au centre-ville avec nos agents.") | |
| 132 | + assert res2["faits"]["date_disponibilite"] is None | |
| 133 | + | |
| 134 | + | |
| 135 | +def test_duree_bail_minimale(): | |
| 136 | + txt = "Chambre dans une maison partagée du centre-ville. Location pour " | |
| 137 | + assert analyser(txt + "minimum 3 mois.")["faits"]["duree_bail_minimale_mois"] == 3 | |
| 138 | + assert analyser(txt + "un bail de 12 mois.")["faits"]["duree_bail_minimale_mois"] == 12 | |
| 139 | + assert analyser(txt + "un bail d'un an.")["faits"]["duree_bail_minimale_mois"] == 12 | |
| 140 | + # durée liée à une promotion -> confiance faible | |
| 141 | + res = analyser("Un mois gratuit sur un bail de 13 mois pour toute " | |
| 142 | + "signature avant la fin du mois prochain.") | |
| 143 | + assert res["faits"]["duree_bail_minimale_mois"] == 13 | |
| 144 | + assert res["confiance"]["duree_bail_minimale_mois"] == "faible" | |
| 145 | + | |
| 146 | + | |
| 147 | +def test_nb_occupants_total(): | |
| 148 | + res = analyser("Total de 11 chambreurs dans la maison, cuisine équipée " | |
| 149 | + "et salon partagés entre les occupants.") | |
| 150 | + assert res["faits"]["nb_occupants_total"] == 11 | |
| 151 | + res2 = analyser("Belle grande chambre dans une colocation de 5 située " | |
| 152 | + "tout près du cégep et des commerces.") | |
| 153 | + assert res2["faits"]["nb_occupants_total"] == 5 | |
| 154 | + | |
| 155 | + | |
| 156 | +def test_salle_de_bain_et_cuisine(): | |
| 157 | + res = analyser("Chambre avec salle de bain commune et cuisine partagée " | |
| 158 | + "avec les autres locataires de l'étage.") | |
| 159 | + assert res["faits"]["salle_de_bain"] == "commune" | |
| 160 | + assert res["faits"]["cuisine"] == "commune" | |
| 161 | + res2 = analyser("Grande suite avec salle de bain privée attenante, dans " | |
| 162 | + "un immeuble récent proche du terminus.") | |
| 163 | + assert res2["faits"]["salle_de_bain"] == "privee" | |
| 164 | + # tournure combinée « cuisine et salle de bain commune » | |
| 165 | + res3 = analyser("Cuisine et salle de bain commune aux autres occupants " | |
| 166 | + "de l'appartement, au deuxième étage.") | |
| 167 | + assert res3["faits"]["salle_de_bain"] == "commune" | |
| 168 | + assert res3["faits"]["cuisine"] == "commune" | |
| 169 | + | |
| 170 | + | |
| 171 | +def test_electromenagers(): | |
| 172 | + res = analyser("Réfrigérateur, cuisinière, laveuse et sécheuse inclus, " | |
| 173 | + "ainsi qu'un lave-vaisselle et un micro-ondes neufs.") | |
| 174 | + assert res["faits"]["electromenagers"] == \ | |
| 175 | + ["frigo", "cuisiniere", "laveuse", "secheuse", "lave_vaisselle", "micro_ondes"] | |
| 176 | + # « entrées laveuse-sécheuse » = branchement, pas un appareil fourni | |
| 177 | + res2 = analyser("Logement avec entrées laveuse-sécheuse et grande cuisine " | |
| 178 | + "éclairée donnant sur la cour arrière.") | |
| 179 | + assert "laveuse" not in res2["faits"]["electromenagers"] | |
| 180 | + | |
| 181 | + | |
| 182 | +def test_inclusions(): | |
| 183 | + res = analyser("Chambre meublée à louer, chauffage, électricité, eau " | |
| 184 | + "chaude et wifi inclus pour tous les locataires.") | |
| 185 | + assert res["faits"]["inclusions"] == \ | |
| 186 | + ["chauffage", "electricite", "eau_chaude", "wifi", "meuble"] | |
| 187 | + res2 = analyser("Grand logement au dernier étage, électricité à la " | |
| 188 | + "charge du locataire et chauffage non inclus.") | |
| 189 | + assert "electricite" not in res2["faits"]["inclusions"] | |
| 190 | + assert "chauffage" not in res2["faits"]["inclusions"] | |
| 191 | + | |
| 192 | + | |
| 193 | +def test_contraintes(): | |
| 194 | + res = analyser("Logement non-fumeur. Les animaux ne sont pas acceptés. " | |
| 195 | + "Enquête de crédit obligatoire. Réservé aux étudiants seulement.") | |
| 196 | + assert res["faits"]["contraintes"] == \ | |
| 197 | + ["non_fumeur", "pas_d_animaux", "references_requises", "etudiants_seulement"] | |
| 198 | + | |
| 199 | + | |
| 200 | +def test_depot_mentionne(): | |
| 201 | + res = analyser("Un dépôt de garantie de 300 $ est demandé à la remise " | |
| 202 | + "des clés du logement, remboursable au départ.") | |
| 203 | + assert res["faits"]["depot_mentionne"] is not None | |
| 204 | + assert "300" in res["faits"]["depot_mentionne"] | |
| 205 | + res2 = analyser("Grand logement lumineux à louer dans un secteur calme " | |
| 206 | + "près des parcs et des écoles du quartier.") | |
| 207 | + assert res2["faits"]["depot_mentionne"] is None | |
| 208 | + | |
| 209 | + | |
| 210 | +def test_quartier_et_incoherence_secteur(): | |
| 211 | + txt = ("Chambre située au 2435 boul. Laurier à Sillery, tout près des " | |
| 212 | + "services et de l'université.") | |
| 213 | + res = analyser(txt, sector="Sainte-Foy") | |
| 214 | + assert res["faits"]["quartier_mentionne"] == "Sillery" | |
| 215 | + assert res["incoherences"] == \ | |
| 216 | + ["quartier mentionné (Sillery) ≠ secteur de l'annonce (Sainte-Foy)"] | |
| 217 | + # secteur cohérent -> aucune incohérence | |
| 218 | + res2 = analyser(txt, sector="Sillery") | |
| 219 | + assert res2["incoherences"] == [] | |
| 220 | + # quartier d'une autre ville que celle de l'annonce | |
| 221 | + res3 = analyser("Grande chambre à louer dans Verdun, près du métro et " | |
| 222 | + "du canal, disponible immédiatement.", city="Québec") | |
| 223 | + assert res3["faits"]["quartier_mentionne"] == "Verdun" | |
| 224 | + assert any("Montréal" in i for i in res3["incoherences"]) | |
| 225 | + | |
| 226 | + | |
| 227 | +def test_quartier_confiance_faible_sans_preposition(): | |
| 228 | + res = analyser("Sillery offre de nombreux services et une belle qualité " | |
| 229 | + "de vie à distance de marche du fleuve.") | |
| 230 | + assert res["faits"]["quartier_mentionne"] == "Sillery" | |
| 231 | + assert res["confiance"]["quartier_mentionne"] == "faible" | |
| 232 | + | |
| 233 | + | |
| 234 | +# -- étape C : sections thématiques ---------------------------------------------- | |
| 235 | + | |
| 236 | +def test_sections_thematiques_sans_titres(): | |
| 237 | + texte = ("Ce grand logement lumineux se trouve au coeur d'un secteur " | |
| 238 | + "paisible et verdoyant, à quelques minutes des principaux axes " | |
| 239 | + "routiers de la ville. " | |
| 240 | + "La colocation compte quatre personnes et les espaces communs " | |
| 241 | + "sont spacieux et bien entretenus. " | |
| 242 | + "Le loyer est de 600 $ par mois et le chauffage est inclus dans " | |
| 243 | + "le prix demandé chaque mois. " | |
| 244 | + "Les animaux ne sont pas acceptés et il est interdit de fumer " | |
| 245 | + "partout dans l'immeuble en tout temps. " | |
| 246 | + "Une visite guidée peut être organisée sur rendez-vous durant " | |
| 247 | + "la semaine ou la fin de semaine.") | |
| 248 | + assert len(texte) > 400 | |
| 249 | + res = analyser(texte) | |
| 250 | + titres = [s["titre"] for s in res["sections"]] | |
| 251 | + assert titres == ["Description", "L'espace de vie", | |
| 252 | + "Frais et conditions", "Bon à savoir"] | |
| 253 | + par_titre = {s["titre"]: s["texte"] for s in res["sections"]} | |
| 254 | + assert "colocation compte quatre personnes" in par_titre["L'espace de vie"] | |
| 255 | + assert "600" in par_titre["Frais et conditions"] | |
| 256 | + assert "animaux" in par_titre["Bon à savoir"] | |
| 257 | + # chaque section est du texte source nettoyé, jamais reformulé | |
| 258 | + for s in res["sections"]: | |
| 259 | + for phrase in s["texte"].split(". "): | |
| 260 | + assert phrase.rstrip(".") in res["texte_nettoye"] | |
| 261 | + | |
| 262 | + | |
| 263 | +def test_texte_court_sans_titres_section_unique(): | |
| 264 | + res = analyser("Joli studio à louer au centre-ville, tout près des " | |
| 265 | + "cafés, des parcs et des transports en commun.") | |
| 266 | + assert res["sections"] == [{"titre": "Description", | |
| 267 | + "texte": res["texte_nettoye"]}] | |
| 268 | + | |
| 269 | + | |
| 270 | +# -- en_bref et complétude ------------------------------------------------------- | |
| 271 | + | |
| 272 | +def test_en_bref_sans_invention(): | |
| 273 | + # aucun fait extrait -> aucun « en bref » généré | |
| 274 | + res = analyser("Venez découvrir ce charmant logement situé tout près du " | |
| 275 | + "fleuve et des grands parcs de la ville.") | |
| 276 | + assert res["en_bref"] is None | |
| 277 | + # seuls les faits présents apparaissent (pas de prix -> pas de « Loyer ») | |
| 278 | + res2 = analyser("Grande chambre disponible dès maintenant avec salle de " | |
| 279 | + "bain commune, dans une maison chaleureuse.") | |
| 280 | + assert "Loyer" not in res2["en_bref"] | |
| 281 | + assert "maintenant" in res2["en_bref"] | |
| 282 | + | |
| 283 | + | |
| 284 | +def test_completude(): | |
| 285 | + # prix (20) + texte > 200 caractères (15) = 35 | |
| 286 | + res = analyser("Le loyer est de 700$ par mois pour ce logement. Situé " | |
| 287 | + "sur une rue paisible bordée d'arbres matures, à distance " | |
| 288 | + "de marche des commerces, des cafés et des parcs du " | |
| 289 | + "secteur, ce logement saura vous charmer par sa grande " | |
| 290 | + "luminosité naturelle.") | |
| 291 | + assert res["completude"] == 35 | |
| 292 | + # aucun fait, texte court -> 0 | |
| 293 | + res2 = analyser("Venez découvrir ce charmant logement situé tout près " | |
| 294 | + "du fleuve et des grands parcs.") | |
| 295 | + assert res2["completude"] == 0 | |
| 296 | + | |
| 297 | + | |
| 298 | +def test_schema_et_json_serialisable(): | |
| 299 | + res = analyser(DESC_2435_LAURIER) | |
| 300 | + assert set(res) == {"version", "texte_nettoye", "en_bref", "sections", | |
| 301 | + "faits", "confiance", "incoherences", "completude"} | |
| 302 | + assert set(res["faits"]) == { | |
| 303 | + "prix_mensuel", "date_disponibilite", "duree_bail_minimale_mois", | |
| 304 | + "nb_occupants_total", "salle_de_bain", "cuisine", "electromenagers", | |
| 305 | + "inclusions", "contraintes", "depot_mentionne", "quartier_mentionne"} | |
| 306 | + assert res["version"] == 1 | |
| 307 | + assert all(v in ("haute", "faible") for v in res["confiance"].values()) | |
| 308 | + json.dumps(res, ensure_ascii=False) # sérialisable tel quel | |
| 309 | + | |
| 310 | + | |
| 311 | +# -- test d'or : 2435 boul. Laurier (Gestipro) ----------------------------------- | |
| 312 | + | |
| 313 | +def test_or_2435_laurier(): | |
| 314 | + """Faits attendus vérifiés à la main sur la vraie description Gestipro.""" | |
| 315 | + res = analyser(DESC_2435_LAURIER, price=450.0, | |
| 316 | + sector="Sainte-Foy", city="Québec") | |
| 317 | + | |
| 318 | + # étape A : doublon retiré, typographie, titres capitalisés | |
| 319 | + assert "Disponible disponible" not in res["texte_nettoye"] | |
| 320 | + assert "Sillery. Disponible\ndès maintenant" in res["texte_nettoye"] | |
| 321 | + assert f"450{NBSP}$ par mois" in res["texte_nettoye"] | |
| 322 | + assert "L’ESPACE DE VIE" not in res["texte_nettoye"] | |
| 323 | + | |
| 324 | + # étape B : les faits, vérifiés à la main | |
| 325 | + assert res["faits"] == { | |
| 326 | + "prix_mensuel": 450.0, | |
| 327 | + "date_disponibilite": "now", | |
| 328 | + "duree_bail_minimale_mois": 3, | |
| 329 | + "nb_occupants_total": 11, | |
| 330 | + "salle_de_bain": "commune", | |
| 331 | + "cuisine": "commune", | |
| 332 | + "electromenagers": ["frigo", "cuisiniere", "laveuse", "secheuse"], | |
| 333 | + "inclusions": ["chauffage", "electricite", "eau_chaude", "wifi", "meuble"], | |
| 334 | + "contraintes": ["pas_d_animaux", "references_requises", "occupation_simple"], | |
| 335 | + "depot_mentionne": None, | |
| 336 | + "quartier_mentionne": "Sillery", | |
| 337 | + } | |
| 338 | + assert all(v == "haute" for v in res["confiance"].values()) | |
| 339 | + | |
| 340 | + # étape C : sections découpées selon les titres du texte source | |
| 341 | + assert [s["titre"] for s in res["sections"]] == \ | |
| 342 | + ["Description", "L’espace de vie", "Frais et charges", | |
| 343 | + "À proximité / secteur"] | |
| 344 | + assert "Total de 11 chambreurs" in res["sections"][1]["texte"] | |
| 345 | + assert "Wifi inclus" in res["sections"][2]["texte"] | |
| 346 | + | |
| 347 | + # en_bref : gabarit strict, uniquement des faits extraits | |
| 348 | + assert res["en_bref"] == ( | |
| 349 | + f"Loyer de 450{NBSP}$ par mois, disponible dès maintenant. " | |
| 350 | + "Meublé ; chauffage, électricité, eau chaude et wifi inclus. " | |
| 351 | + "11 occupants au total, cuisine et salle de bain communes, " | |
| 352 | + "bail minimum de 3 mois.") | |
| 353 | + | |
| 354 | + # étape D : incohérence de quartier + complétude maximale | |
| 355 | + assert res["incoherences"] == \ | |
| 356 | + ["quartier mentionné (Sillery) ≠ secteur de l'annonce (Sainte-Foy)"] | |
| 357 | + assert res["completude"] == 100 | |
| 358 | ||