SPB Git

spb/lou-ka Public

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

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