SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
31.4 KB · 751 lines python
Raw Blame History
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