SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%

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>
simon-pierre boucher committed 2 days ago (Aug 9, 2026) parent 1e387e5

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