Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# textmine.py : text mining déterministe des descriptions d'annonces, exécuté5# à l'ingestion (< 5 ms par annonce, aucune dépendance lourde).6# Pipeline en quatre étapes :7# A. nettoyage typographique (MAJUSCULES, doublons, coquilles)8# B. extraction de faits (regex françaises + rentka/normalize)9# C. découpage en sections (titres détectés ou thèmes)10# D. score de complétude + niveaux de confiance11# Sortie : dict JSON strict (voir `analyser`). Le seul texte12# généré est « en_bref », assemblé PAR GABARIT à partir des13# faits extraits — jamais d'invention.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import re1819from .normalize import (20 extract_details,21 parse_availability_date,22 parse_price,23 strip_accents,24)2526VERSION = 12728# Espace insécable (typographie québécoise : « 450 $ », « 50 % »)29_NBSP = " "3031# ---------------------------------------------------------------------------32# Étape A — nettoyage déterministe33# ---------------------------------------------------------------------------3435_MIN = "a-zàâäéèêëîïôöùûüÿçœæ"36_MAJ = "A-ZÀÂÄÉÈÊËÎÏÔÖÙÛÜŸÇŒÆ"3738# Phrases collées : minuscule (x2, pour épargner « McDonald ») suivie sans39# espace d'une majuscule entamant un mot d'au moins 4 lettres.40_COLLE_RE = re.compile(rf"([{_MIN}]{{2}})([{_MAJ}][{_MIN}]{{3}})")4142# Puces variées en début de ligne -> « • »43_PUCE_RE = re.compile(r"^\s*(?:[-–—*·▪●○◦‣>]+|•+)\s+")44_PUCE_MILIEU_RE = re.compile(r"\s[·▪●○◦‣]\s")4546# Répétitions de ponctuation : « !!! » -> « ! », « ?! » -> « ? »47_PONCT_RE = re.compile(r"([!?])(?:\s*[!?])+")48_POINTS_RE = re.compile(r"\.{4,}")4950# Typographie : espace insécable avant $ et %51_DOLLAR_RE = re.compile(r"(\d)\s*([$%])")5253# Segment EN MAJUSCULES au milieu d'une ligne (≥ 2 mots, ≥ 9 caractères)54_SEG_TITRE_RE = re.compile(55 rf"(?:^|(?<=[\s.!?:•]))"56 rf"([{_MAJ}][{_MAJ}0-9'’/&\-]*(?:[ ]+[{_MAJ}0-9][{_MAJ}0-9'’/&\-]*)+)"57 rf"(?=\s|$)(?!\s*[!?.:;,])") # suivi de ponctuation = emphase, pas un titre5859_PONCT_FIN = ".,;:!?"606162def _est_ligne_criee(ligne: str) -> bool:63 """Ligne EN MAJUSCULES : > 8 caractères, > 60 % de majuscules parmi les lettres."""64 s = ligne.strip()65 if len(s) <= 8:66 return False67 lettres = [c for c in s if c.isalpha()]68 if len(lettres) < 4:69 return False70 maj = sum(1 for c in lettres if c.isupper())71 return maj / len(lettres) > 0.60727374def _est_ligne_titre(ligne: str) -> bool:75 """Ligne-titre : EN MAJUSCULES, courte, sans ponctuation de phrase.7677 « FRAIS PEINT !! » ou « NON-FUMEUR. » sont des phrases criées, pas des78 titres de section : elles seront remises en casse de phrase.79 """80 s = ligne.strip()81 return (_est_ligne_criee(s) and len(s) <= 6082 and "!" not in s and "?" not in s and not s.endswith("."))838485def _titre_capitalise(s: str) -> str:86 """« L'ESPACE DE VIE » -> « L'espace de vie » (capitalisation de titre)."""87 s = s.strip().lower()88 for i, c in enumerate(s):89 if c.isalpha():90 return s[:i] + c.upper() + s[i + 1:]91 return s929394def _dedoublonner_mots(ligne: str) -> str:95 """« Disponible disponible » -> « Disponible » (insensible casse/accents)."""96 mots = ligne.split(" ")97 resultat: list[str] = []98 precedent = None99 for mot in mots:100 cle = strip_accents(mot).lower().strip(_PONCT_FIN + "()•")101 if (cle and cle == precedent and len(cle) > 1102 and any(c.isalpha() for c in cle)):103 # doublon : on le retire, en récupérant sa ponctuation finale104 queue = mot.rstrip()105 if queue and queue[-1] in _PONCT_FIN and resultat \106 and resultat[-1] and resultat[-1][-1] not in _PONCT_FIN:107 resultat[-1] += queue[-1]108 continue109 resultat.append(mot)110 precedent = cle or None111 return " ".join(resultat)112113114def _nettoyer_ligne(ligne: str) -> str:115 """Applique les corrections de l'étape A à une ligne de texte courant."""116 s = _PUCE_RE.sub("• ", ligne)117 s = _PUCE_MILIEU_RE.sub(" • ", s)118 s = _dedoublonner_mots(s)119 s = _COLLE_RE.sub(r"\1. \2", s)120 s = _PONCT_RE.sub(r"\1", s)121 s = _POINTS_RE.sub("...", s)122 s = _DOLLAR_RE.sub(rf"\1{_NBSP}\2", s)123 s = re.sub(r"[ \t]{2,}", " ", s)124 return s.strip()125126127def _etape_a(texte: str) -> list[tuple[str, str]]:128 """Nettoyage -> liste de lignes typées : ("titre"|"texte"|"vide", contenu)."""129 texte = texte.replace("\r\n", "\n").replace("\r", "\n")130 for ch in (" ", " ", " "): # insécables -> espaces simples131 texte = texte.replace(ch, " ")132133 # découpe les segments EN MAJUSCULES enchâssés dans une ligne134 brutes: list[str] = []135 for ligne in texte.split("\n"):136 if _est_ligne_titre(ligne) or _est_ligne_criee(ligne):137 brutes.append(ligne)138 continue139 morceaux, debut, pos = [], 0, 0140 while True:141 m = _SEG_TITRE_RE.search(ligne, pos)142 if not m:143 break144 if len(m.group(1)) < 9: # segment trop court : pas un titre145 pos = m.end()146 continue147 if ligne[debut:m.start()].strip():148 morceaux.append(ligne[debut:m.start()])149 morceaux.append("\x00" + m.group(1))150 debut = pos = m.end()151 if ligne[debut:].strip() or not morceaux:152 morceaux.append(ligne[debut:])153 brutes.extend(morceaux)154155 # classification titre / texte / vide156 lignes: list[tuple[str, str]] = []157 for ligne in brutes:158 seg_titre = ligne.startswith("\x00")159 ligne = ligne.lstrip("\x00")160 if not ligne.strip():161 lignes.append(("vide", ""))162 elif seg_titre or _est_ligne_titre(ligne):163 lignes.append(("titre", _titre_capitalise(ligne)))164 elif _est_ligne_criee(ligne):165 # phrase criée (« FRAIS PEINT !! ») : casse de phrase, pas un titre166 lignes.append(("texte", _nettoyer_ligne(_titre_capitalise(ligne))))167 else:168 lignes.append(("texte", _nettoyer_ligne(ligne)))169170 # garde-fou : description entièrement en MAJUSCULES -> aucun titre,171 # les lignes criées sont simplement remises en casse de phrase172 non_vides = [l for l in lignes if l[0] != "vide"]173 titres = [l for l in lignes if l[0] == "titre"]174 if len(titres) >= 3 and non_vides and len(titres) / len(non_vides) > 0.6:175 lignes = [("texte", t) if k == "titre" else (k, t) for k, t in lignes]176177 # compresse les lignes vides successives (paragraphes préservés)178 compactees: list[tuple[str, str]] = []179 for kind, t in lignes:180 if kind == "vide" and compactees and compactees[-1][0] == "vide":181 continue182 compactees.append((kind, t))183 while compactees and compactees[0][0] == "vide":184 compactees.pop(0)185 while compactees and compactees[-1][0] == "vide":186 compactees.pop()187 return compactees188189190# ---------------------------------------------------------------------------191# Étape B — extraction de faits192# ---------------------------------------------------------------------------193194# Disponibilité : ancre contextuelle (évite de dater « Samedi 15 août » d'une195# porte ouverte) ; « indisponible » / « non disponible » sont écartés.196_DISPO_RE = re.compile(r"(?<![a-z])(?:disponib\w*|libre\b|occupation)")197_DISPO_NEG_RE = re.compile(r"(?:\bin|\bnon\s|\bplus\s|pas\s)$")198_DISPO_SUR_RE = re.compile(r"maintenant|immediat|\blibre\b|\bnow\b|vacant|\d")199200# Prix : contexte mensuel pour la confiance201_PRIX_MENSUEL_RE = re.compile(r"(?:\$|\d)\s*(?:par mois|/\s*mois\b|/m\b|mensuel|mois\b|per month|/\\s*month\\b|/mo\\b|monthly|a month\\b)")202203# Durée de bail minimale204_BAIL_MIN_RE = re.compile(r"(?:minimum|minimal\w*|au moins|at least)\s*(?:de\s+|of\s+)?(\d{1,2})\s*(?:mois|months?)")205_BAIL_MIN2_RE = re.compile(r"(\d{1,2})\s*(?:mois|months?)\s*(?:au\s+)?minimum")206_BAIL_DUREE_RE = re.compile(r"(?:bail|lease)[^.\n]{0,25}?(\d{1,2})\s*(?:mois|months?)")207_BAIL_ANNUEL_RE = re.compile(r"bail\s+(?:de\s+|d.)?(?:un|1)\s*an|bail\s+annuel|(?:one|1)[- ]year\\s+lease|annual\\s+lease|12[- ]month\\s+lease")208_BAIL_PROMO_RE = re.compile(r"gratuit|promotion|promo\b|rabais|special")209210# Nombre d'occupants total211_OCC_TOTAL_RE = re.compile(212 r"total\s+de\s+(\d{1,2})\s+(?:chambreurs?|occupants?|personnes|locataires?|colocataires?)"213 r"|total\s+of\s+(\d{1,2})\s+(?:occupants?|people|tenants?|roommates?)")214_OCC_CHAMBREURS_RE = re.compile(r"(\d{1,2})\s+chambreurs?")215_OCC_COLOC_RE = re.compile(r"colocation\s+(?:de|a)\s+(\d{1,2})")216_OCC_COLOCS_RE = re.compile(r"(\d{1,2})\s+colocataires?")217218# Salle de bain / cuisine commune ou privée219_SDB_CUISINE_COMMUNES_RE = re.compile(220 r"cuisine\s+et\s+salles?\s+de\s+bains?\s+(?:communes?|partagees?)")221_SDB_COMMUNE_RE = re.compile(222 r"salles?\s+de\s+bains?\s+(?:communes?|partagees?|a\s+partager)|sdb\s+(?:commune|partagee)")223_SDB_PRIVEE_RE = re.compile(r"salles?\s+de\s+bains?\s+privees?|sdb\s+privee")224_CUISINE_COMMUNE_RE = re.compile(r"cuisine\s+(?:commune|partagee|a\s+partager)")225_CUISINE_PRIVEE_RE = re.compile(r"cuisine\s+privee")226227# Électroménagers complémentaires (extract_details couvre le reste)228_MICRO_ONDES_RE = re.compile(r"micro[- ]?ondes?")229_POELE_RE = re.compile(r"\bpoeles?\b")230_LAVEUSE_RE = re.compile(r"\blaveuses?\b")231_SECHEUSE_RE = re.compile(r"\bsecheuses?\b")232_ENTREE_LAVEUSE_RE = re.compile(r"entrees?[^|.\n]{0,35}laveuse|sorties?\s+laveuse")233_ELECTROS_INCLUS_RE = re.compile(r"electromenagers?\s+(?:inclus|fournis)|\d\s*electromenagers")234235# Contraintes complémentaires236_ANIMAUX_NON_RE = re.compile(237 r"animaux[^.|\n]{0,40}(?:ne\s+sont\s+pas|pas|non)\s+acceptes"238 r"|animaux[^.|\n]{0,30}interdits|no\s+pets|pets?\s+not\s+allowed")239_REFERENCES_RE = re.compile(240 r"enquete\s+de\s+credit|references?\s+(?:requises?|demandees?|exigees?|obligatoires?)"241 r"|verification\s+de\s+credit|credit\s+check|preuve\s+de\s+revenu")242_ETUDIANTS_RE = re.compile(r"etudiant(?:e?s)?\s+(?:seulement|uniquement)|reserve\s+aux\s+etudiant|students?\\s+only")243_OCCUPATION_SIMPLE_RE = re.compile(r"occupation\s+simple")244245# Dépôt / caution : on retourne l'extrait source, jamais une reformulation246# (matché sur le texte nettoyé AVEC accents — d'où les classes [eé], [oô])247_DEPOT_RE = re.compile(248 r"(?:aucun\s+)?(?:d[eé]p[oô]t(?:\s+de\s+garantie)?|caution)\b[^.!\n]{0,60}",249 re.I)250_DEPOT_VALIDE_RE = re.compile(251 r"\$|garantie|exige|requis|demande|obligatoire|aucun|premier\s+mois|remboursable")252253# Common neighbourhoods (major Canadian markets outside Québec):254# pattern -> (name, city). Matched on lowercase accent-stripped text.255_QUARTIERS: list[tuple[re.Pattern, str, str]] = [256 (re.compile(p), nom, ville) for p, nom, ville in [257 # Toronto258 (r"\bthe annex\b|\bannex\b", "The Annex", "Toronto"),259 (r"\bliberty village\b", "Liberty Village", "Toronto"),260 (r"\bleslieville\b", "Leslieville", "Toronto"),261 (r"\bthe beaches\b|\bbeaches\b", "The Beaches", "Toronto"),262 (r"\byorkville\b", "Yorkville", "Toronto"),263 (r"\bdistillery\b", "Distillery District", "Toronto"),264 (r"\bking west\b", "King West", "Toronto"),265 (r"\bqueen west\b", "Queen West", "Toronto"),266 (r"\bcabbagetown\b", "Cabbagetown", "Toronto"),267 (r"\briverdale\b", "Riverdale", "Toronto"),268 (r"\bthe junction\b", "The Junction", "Toronto"),269 (r"\bparkdale\b", "Parkdale", "Toronto"),270 (r"\broncesvalles\b", "Roncesvalles", "Toronto"),271 (r"\bdanforth\b", "The Danforth", "Toronto"),272 (r"\bmidtown\b", "Midtown", "Toronto"),273 (r"\bnorth york\b", "North York", "Toronto"),274 (r"\betobicoke\b", "Etobicoke", "Toronto"),275 (r"\bscarborough\b", "Scarborough", "Toronto"),276 # Ottawa277 (r"\bcentretown\b", "Centretown", "Ottawa"),278 (r"\bthe glebe\b|\bglebe\b", "The Glebe", "Ottawa"),279 (r"\bwestboro\b", "Westboro", "Ottawa"),280 (r"\bsandy hill\b", "Sandy Hill", "Ottawa"),281 (r"\bbyward\b", "ByWard Market", "Ottawa"),282 (r"\bkanata\b", "Kanata", "Ottawa"),283 (r"\bnepean\b", "Nepean", "Ottawa"),284 (r"\borleans\b", "Orléans", "Ottawa"),285 (r"\bhintonburg\b", "Hintonburg", "Ottawa"),286 # Hamilton / KW / London287 (r"\bwestdale\b", "Westdale", "Hamilton"),288 (r"\bstoney creek\b", "Stoney Creek", "Hamilton"),289 (r"\buptown waterloo\b", "Uptown", "Waterloo"),290 (r"\bold north\b", "Old North", "London"),291 (r"\bwortley\b", "Wortley Village", "London"),292 # Vancouver293 (r"\byaletown\b", "Yaletown", "Vancouver"),294 (r"\bkitsilano\b|\bkits\b", "Kitsilano", "Vancouver"),295 (r"\bgastown\b", "Gastown", "Vancouver"),296 (r"\bmount pleasant\b", "Mount Pleasant", "Vancouver"),297 (r"\bwest end\b", "West End", "Vancouver"),298 (r"\bcommercial drive\b", "Commercial Drive", "Vancouver"),299 # Calgary / Edmonton300 (r"\bbeltline\b", "Beltline", "Calgary"),301 (r"\bkensington\b", "Kensington", "Calgary"),302 (r"\bmission\b", "Mission", "Calgary"),303 (r"\bbridgeland\b", "Bridgeland", "Calgary"),304 (r"\bold strathcona\b|\bstrathcona\b", "Strathcona", "Edmonton"),305 (r"\boliver\b", "Oliver", "Edmonton"),306 (r"\bgarneau\b", "Garneau", "Edmonton"),307 # Winnipeg / Halifax308 (r"\bosborne village\b", "Osborne Village", "Winnipeg"),309 (r"\bthe forks\b", "The Forks", "Winnipeg"),310 (r"\bnorth end halifax\b", "North End", "Halifax"),311 (r"\bsouth end halifax\b", "South End", "Halifax"),312 (r"\bdartmouth\b", "Dartmouth", "Halifax"),313 ]314]315# préposition juste avant le quartier = mention forte (« à Sillery »)316_QUARTIER_CTX_RE = re.compile(r"(?:\ba\s|\bau\s|\bde\s|\bdu\s|\bdans\s|secteur\s|quartier\s)$")317318# Ordres canoniques d'affichage319_ORDRE_ELECTROS = ["frigo", "cuisiniere", "laveuse", "secheuse", "lave_vaisselle", "micro_ondes"]320_ORDRE_INCLUSIONS = ["chauffage", "electricite", "eau_chaude", "wifi", "cable", "meuble"]321_ORDRE_CONTRAINTES = ["non_fumeur", "pas_d_animaux", "references_requises",322 "etudiants_seulement", "occupation_simple"]323324_MAP_INCLUSIONS = {"heating": "chauffage", "electricity": "electricite",325 "hot_water": "eau_chaude", "internet": "wifi", "cable": "cable"}326327328def _cle(texte: str) -> str:329 """Minuscules sans accents, sauts de ligne préservés (fenêtres contextuelles)."""330 return strip_accents(texte).lower()331332333# Découpage du texte nettoyé en items pour extract_details : chaque item est334# borné par « | » chez normalize, ce qui garde ses motifs négatifs précis et335# évite le backtracking quadratique sur les proses sans ponctuation.336_ITEM_SPLIT_RE = re.compile(r"(?<=[.!?:•])\s+")337_ITEM_MAX = 300 # taille maximale d'un item338_ITEM_CHEVAUCHE = 120 # chevauchement > fenêtre des motifs négatifs (~100 car.)339340# Pré-filtre : seuls les champs d'extract_details consommés ici (inclusions,341# électroménagers, animaux, fumeur, meublé) sont concernés — un item sans342# aucun de ces mots-clés ne peut produire aucun de ces indicateurs, on343# l'écarte donc d'emblée (les motifs lourds de normalize coûtent cher).344_ITEM_PERTINENT_RE = re.compile(345 r"chauff|heat|electr|hydro|eclair|eau chaude|hot water|internet|wi[- ]?fi"346 r"|cable|telus|videotron|television|bell fibe|frigo|fridge|refrig"347 r"|cuisinier|stove|\bfour\b|plaques? de cuisson|poele|lave|dishwasher"348 r"|washer|dryer|secheuse|appliance|meubl|furnish|anima|\bchat|chien"349 r"|\bpets?\b|fum|smok|inclus|included|\brien\b")350351352def _items_extraction(lignes: list[tuple[str, str]]) -> list[str]:353 items: list[str] = []354 for kind, t in lignes:355 if kind != "texte" or not t:356 continue357 for part in _ITEM_SPLIT_RE.split(t):358 part = part.strip()359 if not part or not _ITEM_PERTINENT_RE.search(_cle(part)):360 continue361 while len(part) > _ITEM_MAX: # prose sans ponctuation : on362 coupe = part.rfind(" ", _ITEM_MAX - 120, _ITEM_MAX)363 if coupe <= 0: # tronçonne avec chevauchement364 coupe = _ITEM_MAX365 items.append(part[:coupe])366 part = part[max(0, coupe - _ITEM_CHEVAUCHE):]367 items.append(part)368 return items369370371def _extraire_dispo(k: str) -> tuple[str | None, str]:372 """Date de disponibilité via normalize, ancrée sur un mot-clé du texte."""373 for m in _DISPO_RE.finditer(k):374 if _DISPO_NEG_RE.search(k[max(0, m.start() - 6):m.start()]):375 continue # « indisponible », « non disponible », « plus disponible »376 fenetre = k[m.start():m.start() + 80]377 val = parse_availability_date(fenetre)378 if val is not None:379 conf = "haute" if (val != "now" or _DISPO_SUR_RE.search(fenetre)) else "faible"380 return val, conf381 return None, "haute"382383384def _extraire_bail(k: str) -> tuple[int | None, str]:385 for rx in (_BAIL_MIN_RE, _BAIL_MIN2_RE, _BAIL_DUREE_RE):386 m = rx.search(k)387 if m:388 mois = int(m.group(1))389 if 1 <= mois <= 36:390 contexte = k[max(0, m.start() - 40):m.end() + 20]391 conf = "faible" if _BAIL_PROMO_RE.search(contexte) else "haute"392 return mois, conf393 if _BAIL_ANNUEL_RE.search(k):394 return 12, "haute"395 return None, "haute"396397398def _extraire_occupants(k: str) -> tuple[int | None, str]:399 for rx, conf in ((_OCC_TOTAL_RE, "haute"), (_OCC_CHAMBREURS_RE, "haute"),400 (_OCC_COLOC_RE, "haute"), (_OCC_COLOCS_RE, "faible")):401 m = rx.search(k)402 if m:403 g = next((x for x in m.groups() if x), None)404 if g is None:405 continue406 n = int(g)407 if 2 <= n <= 30:408 return n, conf409 return None, "haute"410411412def _extraire_quartier(k: str) -> tuple[str | None, str, str]:413 """Premier quartier mentionné (position dans le texte) -> (nom, ville, conf)."""414 premier: tuple[int, str, str] | None = None415 for rx, nom, ville in _QUARTIERS:416 m = rx.search(k)417 if m and (premier is None or m.start() < premier[0]):418 premier = (m.start(), nom, ville)419 if premier is None:420 return None, "", "haute"421 pos, nom, ville = premier422 conf = "haute" if _QUARTIER_CTX_RE.search(k[max(0, pos - 12):pos]) else "faible"423 return nom, ville, conf424425426def _cle_quartier(s: str) -> str:427 """Normalise un nom de quartier/secteur pour la comparaison."""428 s = strip_accents(s or "").lower()429 s = re.sub(r"\bste\b", "sainte", s)430 s = re.sub(r"\bst\b", "saint", s)431 return re.sub(r"[^a-z0-9]+", "-", s).strip("-")432433434def _fmt_montant(v: float) -> str:435 return str(int(v)) if v == int(v) else f"{v:g}"436437438def _fmt_date_fr(iso: str) -> str:439 """«2026-07-01» -> «July 1, 2026»; «now» -> «right away»."""440 if iso == "now":441 return "right away"442 months = ["January", "February", "March", "April", "May", "June", "July",443 "August", "September", "October", "November", "December"]444 try:445 a, m, j = (int(x) for x in iso.split("-"))446 return f"{months[m - 1]} {j}, {a}"447 except (ValueError, IndexError):448 return iso449450451# ---------------------------------------------------------------------------452# Étape C — sections453# ---------------------------------------------------------------------------454455# Ancres thématiques (texte long sans titres) — ordre d'évaluation ci-dessous456_THEME_REGLES_RE = re.compile(457 r"non[- ]fumeur|animaux|interdit|reglement|references|enquete de credit"458 r"|no smoking|sans fumee|occupation simple")459_THEME_VIE_RE = re.compile(460 r"colocation|colocataires?|chambreurs?|espaces? communs?|aires? communes?"461 r"|cuisine (?:commune|partagee)|salle de bain (?:commune|partagee)"462 r"|piscine|gym\b|salle d.entrainement|lounge|billard|terrasse")463_THEME_FRAIS_RE = re.compile(464 r"\binclus|inclusions?|\bfrais\b|depot|caution|\bbail\b|par mois|/mois|\$"465 r"|electricite|chauffage|eau chaude|stationnement|loyer")466467468def _sections_par_titres(lignes: list[tuple[str, str]]) -> list[dict]:469 sections: list[dict] = []470 titre_courant = "Description"471 tampon: list[str] = []472473 def _pousser() -> None:474 texte = "\n".join(tampon).strip("\n ")475 if texte:476 sections.append({"titre": titre_courant, "texte": texte})477478 for kind, t in lignes:479 if kind == "titre":480 _pousser()481 titre_courant, tampon = t, []482 else:483 tampon.append(t)484 _pousser()485 return sections486487488def _sections_par_themes(texte: str) -> list[dict]:489 """Texte long sans titres : découpage par thème via ancres regex.490 Chaque phrase reste du texte source nettoyé, jamais reformulé."""491 phrases = [p.strip() for p in re.split(r"(?<=[.!?])\s+|\n+", texte) if p.strip()]492 seaux: dict[str, list[str]] = {"Description": [], "Living space": [],493 "Fees and conditions": [], "Good to know": []}494 for phrase in phrases:495 k = _cle(phrase)496 if _THEME_REGLES_RE.search(k):497 seaux["Good to know"].append(phrase)498 elif _THEME_VIE_RE.search(k):499 seaux["Living space"].append(phrase)500 elif _THEME_FRAIS_RE.search(k):501 seaux["Fees and conditions"].append(phrase)502 else:503 seaux["Description"].append(phrase)504 return [{"titre": titre, "texte": " ".join(contenu)}505 for titre, contenu in seaux.items() if contenu]506507508# ---------------------------------------------------------------------------509# Étape B/D — assemblage des faits, en_bref, complétude510# ---------------------------------------------------------------------------511512def _en_bref(faits: dict, confiance: dict) -> str | None:513 """1 à 3 phrases assemblées par gabarit à partir des faits haute-confiance514 SEULEMENT. Aucun mot n'est inventé au-delà des libellés fixes du gabarit."""515516 def _ok(champ: str) -> bool:517 return confiance.get(champ) == "haute"518519 libelles = {"chauffage": "heat", "electricite": "electricity",520 "eau_chaude": "hot water", "wifi": "wifi", "cable": "cable"}521 phrases: list[str] = []522523 # Phrase 1 : loyer et/ou disponibilité524 prix = faits["prix_mensuel"] if _ok("prix_mensuel") else None525 dispo = faits["date_disponibilite"] if _ok("date_disponibilite") else None526 if prix is not None and dispo:527 phrases.append(f"Rent of ${_fmt_montant(prix)} per month, "528 f"available {_fmt_date_fr(dispo)}.")529 elif prix is not None:530 phrases.append(f"Rent of ${_fmt_montant(prix)} per month.")531 elif dispo:532 phrases.append(f"Available {_fmt_date_fr(dispo)}.")533534 # Phrase 2 : meublé + inclusions535 if _ok("inclusions") and faits["inclusions"]:536 noms = [libelles[i] for i in faits["inclusions"] if i in libelles]537 meuble = "meuble" in faits["inclusions"]538 morceaux = []539 if meuble:540 morceaux.append("Furnished")541 if noms:542 enum = noms[0] if len(noms) == 1 else ", ".join(noms[:-1]) + " and " + noms[-1]543 morceaux.append(f"{enum} included")544 if morceaux:545 phrase = " ; ".join(morceaux) + "."546 phrases.append(phrase[0].upper() + phrase[1:])547548 # Phrase 3 : vie commune et bail549 morceaux = []550 if _ok("nb_occupants_total") and faits["nb_occupants_total"]:551 morceaux.append(f"{faits['nb_occupants_total']} occupants in total")552 sdb = faits["salle_de_bain"] if _ok("salle_de_bain") else None553 cuisine = faits["cuisine"] if _ok("cuisine") else None554 if sdb and sdb == cuisine:555 suffixe = "shared" if sdb == "commune" else "private"556 morceaux.append(f"{suffixe} kitchen and bathroom")557 else:558 if cuisine:559 morceaux.append("shared kitchen" if cuisine == "commune"560 else "private kitchen")561 if sdb:562 morceaux.append("shared bathroom" if sdb == "commune"563 else "private bathroom")564 if _ok("duree_bail_minimale_mois") and faits["duree_bail_minimale_mois"]:565 morceaux.append(f"minimum lease of {faits['duree_bail_minimale_mois']} months")566 if morceaux and len(phrases) < 3:567 phrase = ", ".join(morceaux) + "."568 phrases.append(phrase[0].upper() + phrase[1:])569570 return " ".join(phrases[:3]) or None571572573def _completude(faits: dict, texte: str) -> int:574 """Pondération fixe : prix 20, dispo 15, inclusions 20, sdb/cuisine 10,575 occupants 10, contraintes 10, texte > 200 caractères 15 (total 100)."""576 score = 0577 score += 20 if faits["prix_mensuel"] is not None else 0578 score += 15 if faits["date_disponibilite"] else 0579 score += 20 if faits["inclusions"] else 0580 score += 10 if (faits["salle_de_bain"] or faits["cuisine"]) else 0581 score += 10 if faits["nb_occupants_total"] else 0582 score += 10 if faits["contraintes"] else 0583 score += 15 if len(texte) > 200 else 0584 return score585586587# ---------------------------------------------------------------------------588# Point d'entrée589# ---------------------------------------------------------------------------590591def analyser(description: str, *, price: float | None = None,592 sector: str = "", city: str = "") -> dict | None:593 """Analyse une description d'annonce -> dict JSON structuré (schéma strict).594595 None si la description est vide ou trop courte (< 40 caractères).596 `price`, `sector` et `city` (champs de l'annonce) servent uniquement aux597 contrôles de cohérence — jamais à inventer un fait absent du texte.598 """599 if not description or len(description.strip()) < 40:600 return None601602 # -- Étape A : nettoyage -------------------------------------------------603 lignes = _etape_a(description)604 texte_nettoye = "\n".join(t for _, t in lignes)605 k = _cle(texte_nettoye)606607 # -- Étape B : extraction ------------------------------------------------608 confiance: dict[str, str] = {}609 incoherences: list[str] = []610611 det = extract_details(_items_extraction(lignes), "")612613 prix = parse_price(texte_nettoye)614 if prix is not None:615 confiance["prix_mensuel"] = "haute" if _PRIX_MENSUEL_RE.search(k) else "faible"616 if price is not None and abs(prix - price) > 0.5 \617 and confiance["prix_mensuel"] == "haute":618 incoherences.append(619 f"prix dans le texte ({_fmt_montant(prix)} $) ≠ "620 f"prix de l'annonce ({_fmt_montant(price)} $)")621622 dispo, conf_dispo = _extraire_dispo(k)623 if dispo is not None:624 confiance["date_disponibilite"] = conf_dispo625626 bail, conf_bail = _extraire_bail(k)627 if bail is not None:628 confiance["duree_bail_minimale_mois"] = conf_bail629630 occupants, conf_occ = _extraire_occupants(k)631 if occupants is not None:632 confiance["nb_occupants_total"] = conf_occ633634 # salle de bain / cuisine635 sdb = cuisine = None636 if _SDB_CUISINE_COMMUNES_RE.search(k):637 sdb = cuisine = "commune"638 if sdb is None:639 sdb = "commune" if _SDB_COMMUNE_RE.search(k) else \640 "privee" if _SDB_PRIVEE_RE.search(k) else None641 if cuisine is None:642 cuisine = "commune" if _CUISINE_COMMUNE_RE.search(k) else \643 "privee" if _CUISINE_PRIVEE_RE.search(k) else None644 if sdb:645 confiance["salle_de_bain"] = "haute"646 if cuisine:647 confiance["cuisine"] = "haute"648649 # électroménagers : extract_details + compléments locaux650 app = det.get("appliances", {})651 electros: set[str] = set()652 if app.get("fridge"):653 electros.add("frigo")654 if app.get("stove") or _POELE_RE.search(k):655 electros.add("cuisiniere")656 if app.get("dishwasher"):657 electros.add("lave_vaisselle")658 if app.get("washer_dryer"):659 electros.update(("laveuse", "secheuse"))660 elif not app.get("washer_dryer_hookup") and not _ENTREE_LAVEUSE_RE.search(k):661 if _LAVEUSE_RE.search(k):662 electros.add("laveuse")663 if _SECHEUSE_RE.search(k):664 electros.add("secheuse")665 if _MICRO_ONDES_RE.search(k):666 electros.add("micro_ondes")667 if _ELECTROS_INCLUS_RE.search(k):668 electros.update(("frigo", "cuisiniere"))669 electromenagers = [e for e in _ORDRE_ELECTROS if e in electros]670 if electromenagers:671 confiance["electromenagers"] = "haute"672673 # inclusions674 incl: set[str] = {_MAP_INCLUSIONS[c] for c, actif in det.get("inclusions", {}).items()675 if actif and c in _MAP_INCLUSIONS}676 if det.get("furnished") is True:677 incl.add("meuble")678 inclusions = [i for i in _ORDRE_INCLUSIONS if i in incl]679 if inclusions:680 confiance["inclusions"] = "haute"681682 # contraintes683 contr: set[str] = set()684 if det.get("smoking") is False:685 contr.add("non_fumeur")686 if det.get("pets") == "non" or _ANIMAUX_NON_RE.search(k):687 contr.add("pas_d_animaux")688 if _REFERENCES_RE.search(k):689 contr.add("references_requises")690 if _ETUDIANTS_RE.search(k):691 contr.add("etudiants_seulement")692 if _OCCUPATION_SIMPLE_RE.search(k):693 contr.add("occupation_simple")694 contraintes = [c for c in _ORDRE_CONTRAINTES if c in contr]695 if contraintes:696 confiance["contraintes"] = "haute"697698 # dépôt : extrait source (jamais reformulé)699 depot = None700 for m in _DEPOT_RE.finditer(texte_nettoye):701 if _DEPOT_VALIDE_RE.search(_cle(m.group(0))):702 depot = m.group(0).strip(" ,;:")703 confiance["depot_mentionne"] = "haute"704 break705706 # quartier mentionné + cohérence avec le secteur/la ville de l'annonce707 quartier, ville_quartier, conf_quartier = _extraire_quartier(k)708 if quartier:709 confiance["quartier_mentionne"] = conf_quartier710 cq, cs = _cle_quartier(quartier), _cle_quartier(sector)711 if sector and cq != cs and cq not in cs and cs not in cq:712 incoherences.append(f"quartier mentionné ({quartier}) ≠ "713 f"secteur de l'annonce ({sector})")714 elif city and _cle_quartier(city) != _cle_quartier(ville_quartier):715 incoherences.append(f"quartier mentionné ({quartier}) est associé à "716 f"{ville_quartier}, l'annonce est à {city}")717718 faits = {719 "prix_mensuel": prix,720 "date_disponibilite": dispo,721 "duree_bail_minimale_mois": bail,722 "nb_occupants_total": occupants,723 "salle_de_bain": sdb,724 "cuisine": cuisine,725 "electromenagers": electromenagers,726 "inclusions": inclusions,727 "contraintes": contraintes,728 "depot_mentionne": depot,729 "quartier_mentionne": quartier,730 }731732 # -- Étape C : sections --------------------------------------------------733 if any(kind == "titre" for kind, _ in lignes):734 sections = _sections_par_titres(lignes)735 elif len(texte_nettoye) > 400:736 sections = _sections_par_themes(texte_nettoye)737 else:738 sections = [{"titre": "Description", "texte": texte_nettoye}]739740 # -- Étape D : synthèse --------------------------------------------------741 return {742 "version": VERSION,743 "texte_nettoye": texte_nettoye,744 "en_bref": _en_bref(faits, confiance),745 "sections": sections,746 "faits": faits,747 "confiance": confiance,748 "incoherences": incoherences,749 "completude": _completude(faits, texte_nettoye),750 }751