SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%
31.0 KB · 760 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Ka — la panoplie d'outils de l'agent IA de ValoPlex.4 * Spécialisée plex : évaluation en ratio, économie par porte, pro forma5 * investisseur « tout le kit ». Exécutée côté serveur sur la base SQLite6 * (393 867 plex) et le moteur d'estimation.7 */8import Anthropic from "@anthropic-ai/sdk";9import { getDb, getMarketIndex, getUnit, searchUnits } from "../db";10import {11  estimateByUnitId,12  estimateManual,13  estimatePortfolio,14  type UnitEstimate,15} from "../estimator";16import { buildProforma, DEFAULT_PARAMS, tgaReference, type ProformaParams } from "../proforma";17import stats from "../../data/stats.json";1819/* ---------------------------------------------------------------- helpers */2021const r = (n: number | null | undefined, d = 0): number | null =>22  n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d));2324/** Mots génériques d'adresse ignorés lors de la recherche approximative. */25const STOP_WORDS = new Set([26  "rue", "avenue", "av", "boulevard", "boul", "blvd", "chemin", "ch", "route", "rte",27  "rang", "place", "montee", "montée", "impasse", "croissant", "terrasse", "allée", "allee",28  "cote", "côte", "carre", "carré", "du", "de", "des", "la", "le", "les", "l", "d",29  "au", "aux", "à", "a", "et", "app", "apt", "st", "ste",30]);3132function tokenize(q: string): string[] {33  return q34    .replace(/[^\p{L}\p{N}\s'-]/gu, " ")35    .trim()36    .split(/\s+/)37    .filter((t) => t.length > 0);38}3940function ftsAnd(tokens: string[], limit: number) {41  if (!tokens.length) return [];42  const match = tokens.map((t) => `"${t}"*`).join(" ");43  return getDb()44    .prepare(45      `SELECT u.*, bm25(units_fts) AS score46       FROM units_fts JOIN units u ON u.rowid = units_fts.rowid47       WHERE units_fts MATCH ? ORDER BY score LIMIT ?`48    )49    .all(match, limit) as ReturnType<typeof searchUnits>;50}5152/**53 * Recherche à relaxation progressive : exacte, puis sans mots génériques54 * (rue/avenue/de…), puis sans numéro civique, puis nom de voie seul.55 * Retourne toujours ce qui s'en rapproche le plus plutôt que rien.56 */57function searchFlexible(58  q: string,59  limit: number60): { rows: ReturnType<typeof searchUnits>; niveau: "exacte" | "approximative" } {61  const strict = searchUnits(q, limit);62  if (strict.length) return { rows: strict, niveau: "exacte" };6364  const tokens = tokenize(q);65  const sansStop = tokens.filter((t) => !STOP_WORDS.has(t.toLowerCase()));66  const sansNumero = sansStop.filter((t) => !/^\d+[a-z]?$/i.test(t));67  const nomsSeuls = sansNumero.filter((t) => t.length >= 4);6869  for (const attempt of [sansStop, sansNumero, nomsSeuls, nomsSeuls.slice(0, 1)]) {70    if (!attempt.length) continue;71    if (attempt.length === tokens.length) continue; // déjà tenté en strict72    const rows = ftsAnd(attempt, limit);73    if (rows.length) return { rows, niveau: "approximative" };74  }75  return { rows: [], niveau: "approximative" };76}7778/**79 * Résout un id éventuellement mal recopié par le modèle : essai brut,80 * puis version chiffres seulement (les id provinciaux sont numériques).81 */82function resolveId(raw: unknown): string {83  const a = String(raw ?? "").trim();84  if (getUnit(a)) return a;85  const d = a.replace(/\D/g, "");86  if (d && d !== a && getUnit(d)) return d;87  return a;88}8990const ID_ERR =91  "Plex introuvable — l'id est peut-être mal recopié. Relance chercher_plex et copie le champ `id` EXACTEMENT tel quel.";9293function liens(id: string) {94  const e = encodeURIComponent(id);95  return {96    fiche_complete: `https://www.valoplex.com/estimation/${e}`,97    rapport_standard_pdf: `https://www.valoplex.com/api/report?id=${e}`,98    rapport_professionnel_pdf: `https://www.valoplex.com/api/report/pro?id=${e}`,99  };100}101102function gabarit(portes: number | null): string {103  if (!portes) return "plex";104  if (portes === 2) return "duplex";105  if (portes === 3) return "triplex";106  if (portes === 4) return "quadruplex";107  if (portes === 5) return "quintuplex";108  if (portes === 6) return "sixplex";109  return `multi ${portes} portes`;110}111112function unitCard(u: ReturnType<typeof searchUnits>[number]) {113  return {114    id: u.id_provinc,115    adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "),116    municipalite: u.municipalite,117    arrondissement: u.arrond,118    portes: u.nb_logements,119    gabarit: gabarit(u.nb_logements),120    annee_construction: u.annee_construction,121    aire_habitable_m2: r(u.aire_etages_m2, 1),122    terrain_m2: r(u.superficie_terrain_m2),123    valeur_role_2026: u.valeur_role,124  };125}126127function evalCard(e: UnitEstimate) {128  const u = e.unit;129  const res = e.result;130  const portes = u?.nbLogements ?? null;131  return {132    plex: u133      ? {134          id: u.id,135          adresse: u.adresse,136          municipalite: u.municipalite,137          gabarit: gabarit(portes),138          portes,139          adresses_des_portes: u.specs.portesAdresses.slice(0, 20),140          annee_construction: u.anneeConstruction,141          aire_habitable_m2: r(u.aireEtagesM2, 1),142          terrain_m2: r(u.superficieTerrainM2),143          etages: u.specs.nbEtages,144          genre_construction: u.specs.genreConstruction,145          valeur_role_2026: u.valeurRole,146          valeur_terrain_role: u.specs.valeurTerrain,147          valeur_batiment_role: u.specs.valeurBatiment,148          liens: liens(u.id),149        }150      : null,151    estimation: {152      valeur: r(res.estimate),153      fourchette_basse_p10: r(res.low),154      fourchette_haute_p90: r(res.high),155      confiance_pct: res.confidencePct,156      confiance_niveau: res.confidenceLevel,157      part_modele_hedonique: r(res.modelEstimate),158      part_comparables: r(res.compsEstimate),159      poids_modele: r(res.modelWeight, 2),160      n_comparables_utilises: res.nCompsUsed,161      ecart_vs_role_pct: u?.valeurRole162        ? r((res.estimate / u.valeurRole - 1) * 100, 1)163        : null,164    },165    economie_par_porte: portes166      ? {167          valeur_par_porte: r(res.estimate / portes),168          role_par_porte: u?.valeurRole ? r(u.valeurRole / portes) : null,169          aire_par_porte_m2: u?.aireEtagesM2 ? r(u.aireEtagesM2 / portes, 1) : null,170          terrain_par_porte_m2: u?.superficieTerrainM2171            ? r(u.superficieTerrainM2 / portes, 1)172            : null,173        }174      : null,175    historique_2021_2026: u?.history ?? null,176  };177}178179/* ------------------------------------------------------------ tool defs */180181export const KA_TOOLS: Anthropic.Tool[] = [182  {183    name: "chercher_plex",184    description:185      "Recherche plein-texte d'un plex parmi les 393 867 immeubles de 2 logements et plus du Québec. Utilise-le dès qu'un utilisateur mentionne une adresse, même partielle. Si aucune correspondance exacte, l'outil retombe AUTOMATIQUEMENT sur des correspondances approximatives (même rue, numéros voisins) — le champ `correspondance` te le dit. Présente alors les candidats les plus proches au lieu de répondre introuvable. Plusieurs candidats plausibles → demande de préciser AVANT d'évaluer. Copie l'`id` EXACTEMENT tel que retourné.",186    input_schema: {187      type: "object",188      properties: {189        requete: { type: "string", description: "Adresse ou fragment, ex. « 2075 Grandjean Québec »" },190        limite: { type: "number", description: "Max résultats (défaut 6, max 12)" },191      },192      required: ["requete"],193    },194  },195  {196    name: "evaluer_plex",197    description:198      "Évaluation complète d'un plex par son id (obtenu via chercher_plex) : valeur estimée (modèle en ratio calibré par gabarit), fourchette P10-P90, confiance A-D, économie par porte (valeur/porte, rôle/porte, aire/porte), adresses de chaque porte, historique 2021-2026. L'outil central.",199    input_schema: {200      type: "object",201      properties: { id: { type: "string", description: "Identifiant provincial" } },202      required: ["id"],203    },204  },205  {206    name: "proforma_investisseur",207    description:208      "Pro forma investisseur complet d'un plex (approche revenu inversée avec tous les frais) : loyer implicite par porte, état des résultats (vacance, taxes, assurances, entretien, gestion…), RNE, TGA implicite, hypothèque canadienne, cashflow/porte/mois, DSCR, cash-on-cash, droits de mutation + notaire + inspection, liquidités requises, projection 5 ans (équité, gain total, multiple), loyer et taux de point mort, sensibilité aux taux ±1 %. Paramètres tous optionnels (défauts : taux 4,75 %, mise 25 %, amort. 25 ans, TGA par gabarit). Appelle-le pour toute question d'investissement, de rentabilité ou de financement.",209    input_schema: {210      type: "object",211      properties: {212        id: { type: "string", description: "Identifiant provincial du plex" },213        taux_hypo_pct: { type: "number", description: "Taux hypothécaire annuel, ex. 4.75" },214        mise_de_fonds_pct: { type: "number", description: "Mise de fonds en %, ex. 25" },215        amortissement_ans: { type: "number", description: "Amortissement en années, ex. 25" },216        tga_pct: { type: "number", description: "TGA imposé ; sinon référence par gabarit" },217        appreciation_pct: { type: "number", description: "Appréciation annuelle supposée, ex. 2.5" },218      },219      required: ["id"],220    },221  },222  {223    name: "comparables_detailles",224    description:225      "Ventes de plex comparables utilisées dans l'évaluation : adresse, date, prix payé, nombre de portes, distance, ajustements (marché, superficie, âge, portes) et poids. Pour « pourquoi ce prix ? » ou « qu'est-ce qui s'est vendu autour ? ».",226    input_schema: {227      type: "object",228      properties: {229        id: { type: "string" },230        max: { type: "number", description: "Max comparables (défaut 8)" },231      },232      required: ["id"],233    },234  },235  {236    name: "indice_marche_plex",237    description:238      "Tendance du marché des plex au Québec : indice mensuel $/m² lissé et croissance 12/24 mois. Pour contextualiser une évaluation ou un timing d'achat.",239    input_schema: { type: "object", properties: {} },240  },241  {242    name: "stats_municipalite",243    description:244      "Statistiques plex en direct d'une municipalité : nombre de plex et de portes, valeur totale/médiane 2026, valeur médiane par porte, répartition par gabarit (duplex, triplex…). Pour comparer un plex à son marché local.",245    input_schema: {246      type: "object",247      properties: { municipalite: { type: "string", description: "Nom exact, ex. « Trois-Rivières »" } },248      required: ["municipalite"],249    },250  },251  {252    name: "stats_provinciales",253    description:254      "Les grands chiffres des plex du Québec : valeur totale (400,6 G$), 393 867 plex, 1 733 744 portes, croissance 2021→2026, top municipalités. Pour les questions macro.",255    input_schema: { type: "object", properties: {} },256  },257  {258    name: "evaluer_parc",259    description:260      "Évalue un parc de plex (2 à 40 ids) : valeur totale, fourchette, confiance pondérée, portes totales, répartition par ville, croissance 2021→2026. Pour les investisseurs multi-immeubles.",261    input_schema: {262      type: "object",263      properties: { ids: { type: "array", items: { type: "string" } } },264      required: ["ids"],265    },266  },267  {268    name: "comparer_plex",269    description:270      "Compare 2 à 4 plex côte à côte : valeur, valeur/porte, écart vs rôle, confiance, croissance. Pour départager des occasions d'achat.",271    input_schema: {272      type: "object",273      properties: { ids: { type: "array", items: { type: "string" }, description: "2 à 4 ids" } },274      required: ["ids"],275    },276  },277  {278    name: "estimation_manuelle",279    description:280      "Estimation d'un plex sans adresse exacte : municipalité + nombre de portes, et si possible superficie, année. Seulement si chercher_plex ne trouve rien ou pour un scénario hypothétique.",281    input_schema: {282      type: "object",283      properties: {284        municipalite: { type: "string" },285        portes: { type: "number", description: "Nombre de logements (2+)" },286        aire_habitable_m2: { type: "number" },287        annee_construction: { type: "number" },288        terrain_m2: { type: "number" },289      },290      required: ["municipalite", "portes"],291    },292  },293  {294    name: "chercher_plex_secteur",295    description:296      "Trouve des plex dans une municipalité selon des critères d'investisseur : portes min/max, budget max (valeur estimée 2026), valeur min. Triés par valeur. Pour « trouve-moi un triplex à Trois-Rivières sous 700 k$ ».",297    input_schema: {298      type: "object",299      properties: {300        municipalite: { type: "string" },301        portes_min: { type: "number" },302        portes_max: { type: "number" },303        valeur_max: { type: "number", description: "Budget maximal ($)" },304        valeur_min: { type: "number" },305        limite: { type: "number", description: "Max résultats (défaut 8, max 15)" },306      },307      required: ["municipalite"],308    },309  },310  {311    name: "dossier_investisseur",312    description:313      "L'ARME LOURDE : dossier d'investisseur complet d'un plex en UN SEUL appel — évaluation + économie par porte + pro forma condensé (loyer implicite, RNE, cashflow/porte, DSCR, liquidités tout le kit, projection 5 ans, points morts) + marché local du gabarit (benchmark $/porte municipal) + tendance du marché des plex + top 5 comparables + synthèse chiffrée. Utilise-le SYSTÉMATIQUEMENT quand l'utilisateur demande d'évaluer ou d'analyser un plex identifié : tu obtiens tout pour livrer un mini-rapport d'investisseur d'un coup. Paramètres de financement optionnels.",314    input_schema: {315      type: "object",316      properties: {317        id: { type: "string", description: "Identifiant provincial du plex" },318        taux_hypo_pct: { type: "number" },319        mise_de_fonds_pct: { type: "number" },320        amortissement_ans: { type: "number" },321        tga_pct: { type: "number" },322      },323      required: ["id"],324    },325  },326  {327    name: "liens_rapports",328    description:329      "Liens de téléchargement des rapports PDF d'un plex (standard 3 pages avec pro forma, professionnel bancaire 6 pages) et lien de sa fiche. Offre-les à la fin d'une évaluation réussie.",330    input_schema: {331      type: "object",332      properties: { id: { type: "string" } },333      required: ["id"],334    },335  },336];337338/* ------------------------------------------------------------ execution */339340type J = Record<string, unknown>;341342function growth(idx: { month: string; idx: number }[], months: number): number | null {343  if (idx.length < months + 1) return null;344  const last = idx[idx.length - 1];345  const past = idx[idx.length - 1 - months];346  return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null;347}348349export function runKaTool(name: string, input: J): unknown {350  switch (name) {351    case "chercher_plex": {352      const limit = Math.min(Number(input.limite) || 6, 12);353      const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit);354      if (!rows.length)355        return {356          resultats: [],357          correspondance: "aucune",358          conseil:359            "Aucun plex trouvé, même approximatif. Rappel : ValoPlex ne couvre que les immeubles de 2 logements et plus (unifamiliale/condo → vrai-prix.com). Essayer une autre graphie ou estimation_manuelle.",360        };361      return {362        correspondance: niveau,363        ...(niveau === "approximative"364          ? {365              note: "Correspondance exacte introuvable — voici les plex les plus proches (même voie ou même secteur). Présente-les à l'utilisateur comme des suggestions.",366            }367          : {}),368        resultats: rows.map(unitCard),369      };370    }371372    case "evaluer_plex": {373      const e = estimateByUnitId(resolveId(input.id));374      if (!e) return { erreur: ID_ERR };375      return evalCard(e);376    }377378    case "proforma_investisseur": {379      const id = resolveId(input.id);380      const e = estimateByUnitId(id);381      if (!e || !e.unit) return { erreur: ID_ERR };382      const doors = e.unit.nbLogements ?? 2;383      const partial: Partial<ProformaParams> = {};384      if (input.taux_hypo_pct != null) partial.tauxHypoPct = Number(input.taux_hypo_pct);385      if (input.mise_de_fonds_pct != null) partial.miseDeFondsPct = Number(input.mise_de_fonds_pct);386      if (input.amortissement_ans != null) partial.amortAns = Number(input.amortissement_ans);387      if (input.tga_pct != null) partial.tgaPct = Number(input.tga_pct);388      if (input.appreciation_pct != null) partial.appreciationPct = Number(input.appreciation_pct);389      const pf = buildProforma(390        e.result.estimate,391        doors,392        e.unit.valeurRole,393        e.unit.municipalite,394        partial395      );396      return {397        plex: { adresse: e.unit.adresse, gabarit: gabarit(doors), portes: doors },398        valeur_utilisee: r(e.result.estimate),399        hypotheses: {400          taux_hypo_pct: pf.params.tauxHypoPct,401          mise_de_fonds_pct: pf.params.miseDeFondsPct,402          amortissement_ans: pf.params.amortAns,403          tga_pct: pf.params.tgaPct,404          tga_reference_gabarit_pct: pf.tgaRefPct,405          appreciation_pct: pf.params.appreciationPct,406        },407        revenus: {408          loyer_implicite_par_porte_mois: r(pf.loyerMoyenMensuel),409          revenus_bruts_annuels: r(pf.revenusBruts),410          vacance: r(pf.vacance),411          revenus_effectifs: r(pf.revenusEffectifs),412        },413        depenses_annuelles: pf.depenses.map((d) => ({ poste: d.key, montant: r(d.amount) })),414        exploitation: {415          rne_noi: r(pf.rne),416          ratio_depenses_pct: r(pf.ratioDepensesPct, 1),417          tga_implicite_pct: r(pf.tgaImplicitePct, 2),418          multiplicateur_revenus_bruts: r(pf.mrb, 1),419        },420        financement: {421          mise_de_fonds: r(pf.miseDeFonds),422          hypotheque: r(pf.hypotheque),423          paiement_mensuel: r(pf.paiementMensuelHypo),424          service_dette_annuel: r(pf.serviceDetteAnnuel),425          cashflow_annuel: r(pf.cashflowAnnuel),426          cashflow_par_porte_mois: r(pf.cashflowMensuelParPorte),427          dscr: r(pf.dscr, 2),428        },429        acquisition_tout_le_kit: {430          droits_mutation: r(pf.droitsMutation),431          notaire: r(pf.fraisNotaire),432          inspection: r(pf.fraisInspection),433          liquidites_requises_totales: r(pf.liquiditesRequises),434          cash_on_cash_pct: r(pf.cashOnCashPct, 1),435        },436        projection_5_ans: {437          capital_rembourse_an_1: r(pf.capitalAn1),438          rendement_total_an_1_pct: r(pf.rendementTotalAn1Pct, 1),439          valeur_projetee: r(pf.valeur5Ans),440          equite: r(pf.equite5Ans),441          gain_total: r(pf.gainTotal5Ans),442          multiple_sur_liquidites: r(pf.multipleLiquidites5Ans, 2),443        },444        marges_de_securite: {445          loyer_point_mort_par_porte: r(pf.loyerPointMort),446          marge_loyer_pct: r(pf.margeSecuriteLoyerPct, 1),447          taux_point_mort_pct: r(pf.tauxPointMortPct, 2),448          sensibilite_taux: pf.sensibiliteTaux.map((s) => ({449            taux_pct: s.tauxPct,450            cashflow_annuel: r(s.cashflowAnnuel),451            dscr: r(s.dscr, 2),452          })),453        },454      };455    }456457    case "comparables_detailles": {458      const e = estimateByUnitId(resolveId(input.id));459      if (!e) return { erreur: ID_ERR };460      const max = Math.min(Number(input.max) || 8, 15);461      if (!e.result.comps.length)462        return {463          n_utilises: 0,464          note: "Aucune vente de plex comparable dans le secteur — l'estimation repose sur le modèle hédonique.",465        };466      return {467        n_utilises: e.result.nCompsUsed,468        comparables: e.result.comps.slice(0, max).map((c) => ({469          adresse: [c.street, c.city].filter(Boolean).join(", "),470          vendu_le: c.date,471          prix_paye: c.amount,472          portes: c.doors ?? null,473          distance_m: r(c.distanceM),474          ajust_marche: r(c.adjTime),475          ajust_superficie: r(c.adjArea),476          ajust_age: r(c.adjAge),477          ajust_portes: r(c.adjDoors),478          prix_ajuste: r(c.adjustedPrice),479          poids_pct: r(c.weight * 100, 1),480        })),481      };482    }483484    case "indice_marche_plex": {485      const idx = getMarketIndex("plex");486      const last12 = idx.slice(-13);487      return {488        croissance_12_mois_pct: growth(idx, 12),489        croissance_24_mois_pct: growth(idx, 24),490        serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })),491        note: "Indice $/m² des ventes de plex, lissé 3 mois, 1.0 = niveau actuel.",492      };493    }494495    case "stats_municipalite": {496      const mun = String(input.municipalite ?? "");497      const g = getDb()498        .prepare(499          `SELECT COUNT(*) n, SUM(nb_logements) portes, SUM(est_2026) total500           FROM units WHERE municipalite = ? COLLATE NOCASE`501        )502        .get(mun) as { n: number; portes: number | null; total: number | null };503      if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` };504      const med = getDb()505        .prepare(506          `SELECT est_2026 v, nb_logements p FROM units507           WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL508           ORDER BY est_2026 LIMIT 1509           OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2`510        )511        .get(mun, mun) as { v: number; p: number | null } | undefined;512      const gabarits = getDb()513        .prepare(514          `SELECT CASE WHEN nb_logements = 2 THEN 'duplex'515                       WHEN nb_logements = 3 THEN 'triplex'516                       WHEN nb_logements BETWEEN 4 AND 5 THEN '4-5 portes'517                       WHEN nb_logements BETWEEN 6 AND 12 THEN '6-12 portes'518                       ELSE '13+ portes' END AS gab,519                  COUNT(*) n, SUM(est_2026) total, AVG(est_2026 / nb_logements) prix_porte_moyen520           FROM units WHERE municipalite = ? COLLATE NOCASE AND nb_logements >= 2521           GROUP BY gab ORDER BY n DESC`522        )523        .all(mun) as { gab: string; n: number; total: number | null; prix_porte_moyen: number | null }[];524      return {525        municipalite: mun,526        plex: g.n,527        portes: g.portes,528        valeur_totale_estimee_2026: r(g.total),529        valeur_mediane_2026: med?.v ?? null,530        par_gabarit: gabarits.map((t) => ({531          gabarit: t.gab,532          plex: t.n,533          total: r(t.total),534          valeur_moyenne_par_porte: r(t.prix_porte_moyen),535        })),536      };537    }538539    case "stats_provinciales": {540      const s = stats as J;541      const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10);542      return {543        valeur_totale_plex_quebec_2026: s.valeur_totale_2026,544        valeur_role_totale: s.valeur_role_totale,545        plex: s.unites,546        portes: s.logements,547        valeur_mediane_2026: s.valeur_mediane_2026,548        croissance_2021_2026_pct: s.croissance_2021_2026_pct,549        municipalites: s.municipalites,550        par_gabarit: s.par_type,551        top_10_villes: villes,552      };553    }554555    case "evaluer_parc": {556      const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId);557      if (ids.length < 1) return { erreur: "Fournir au moins un id." };558      const p = estimatePortfolio(ids);559      if (!p.items.length) return { erreur: "Aucun plex valide trouvé." };560      const a = p.aggregates;561      return {562        plex_evalues: a.count,563        valeur_totale: r(a.totalEstimate),564        fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) },565        valeur_role_totale: r(a.totalRole),566        ecart_vs_role_pct: r(a.ecartRolePct, 1),567        confiance: { pct: a.confidencePct, niveau: a.confidenceLevel },568        portes_totales: a.totalDwellings,569        valeur_par_porte_parc:570          a.totalDwellings > 0 ? r(a.totalEstimate / a.totalDwellings) : null,571        croissance_parc_2021_2026_pct: r(a.growthPct, 1),572        par_ville: a.municipalities,573        detail: p.items.map((i) => ({574          id: i.unit!.id,575          adresse: i.unit!.adresse,576          portes: i.unit!.nbLogements,577          valeur: r(i.result.estimate),578          confiance: i.result.confidenceLevel,579        })),580      };581    }582583    case "comparer_plex": {584      const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId);585      if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." };586      return {587        comparaison: ids.map((id) => {588          const e = estimateByUnitId(id);589          if (!e || !e.unit) return { id, erreur: "introuvable" };590          const portes = e.unit.nbLogements;591          const h21 = e.unit.history.find((h) => h.year === 2021)?.value;592          const h26 = e.unit.history.find((h) => h.year === 2026)?.value;593          return {594            id,595            adresse: e.unit.adresse,596            municipalite: e.unit.municipalite,597            gabarit: gabarit(portes),598            valeur: r(e.result.estimate),599            valeur_par_porte: portes ? r(e.result.estimate / portes) : null,600            ecart_vs_role_pct: e.unit.valeurRole601              ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1)602              : null,603            confiance: e.result.confidenceLevel,604            croissance_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,605          };606        }),607      };608    }609610    case "estimation_manuelle": {611      const e = estimateManual({612        municipality: String(input.municipalite ?? ""),613        typeProp: "plex",614        portes: input.portes ? Number(input.portes) : undefined,615        floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined,616        yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined,617        landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined,618      });619      if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." };620      return evalCard(e);621    }622623    case "chercher_plex_secteur": {624      const mun = String(input.municipalite ?? "");625      const limit = Math.min(Number(input.limite) || 8, 15);626      const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"];627      const args: unknown[] = [mun];628      if (input.portes_min) {629        conds.push("nb_logements >= ?");630        args.push(Number(input.portes_min));631      }632      if (input.portes_max) {633        conds.push("nb_logements <= ?");634        args.push(Number(input.portes_max));635      }636      if (input.valeur_max) {637        conds.push("est_2026 <= ?");638        args.push(Number(input.valeur_max));639      }640      if (input.valeur_min) {641        conds.push("est_2026 >= ?");642        args.push(Number(input.valeur_min));643      }644      const rows = getDb()645        .prepare(646          `SELECT * FROM units WHERE ${conds.join(" AND ")}647           ORDER BY est_2026 DESC LIMIT ?`648        )649        .all(...args, limit) as Parameters<typeof unitCard>[0][];650      if (!rows.length) return { resultats: [], note: "Aucun plex ne correspond aux critères." };651      return {652        resultats: rows.map((u) => ({653          ...unitCard(u),654          valeur_estimee_2026: r(u.est_2026),655          valeur_par_porte: u.nb_logements ? r((u.est_2026 ?? 0) / u.nb_logements) : null,656        })),657      };658    }659660    case "dossier_investisseur": {661      const id = resolveId(input.id);662      const ev = estimateByUnitId(id);663      if (!ev || !ev.unit) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." };664      const u = ev.unit;665      const res = ev.result;666      const doors = u.nbLogements ?? 2;667668      const pfFull = runKaTool("proforma_investisseur", {669        id,670        taux_hypo_pct: input.taux_hypo_pct,671        mise_de_fonds_pct: input.mise_de_fonds_pct,672        amortissement_ans: input.amortissement_ans,673        tga_pct: input.tga_pct,674      }) as Record<string, unknown>;675      const proformaCondense = {676        hypotheses: pfFull.hypotheses,677        loyer_implicite_par_porte_mois: (pfFull.revenus as Record<string, unknown>)?.loyer_implicite_par_porte_mois,678        rne_noi: (pfFull.exploitation as Record<string, unknown>)?.rne_noi,679        tga_implicite_pct: (pfFull.exploitation as Record<string, unknown>)?.tga_implicite_pct,680        cashflow_par_porte_mois: (pfFull.financement as Record<string, unknown>)?.cashflow_par_porte_mois,681        dscr: (pfFull.financement as Record<string, unknown>)?.dscr,682        liquidites_requises_totales: (pfFull.acquisition_tout_le_kit as Record<string, unknown>)?.liquidites_requises_totales,683        cash_on_cash_pct: (pfFull.acquisition_tout_le_kit as Record<string, unknown>)?.cash_on_cash_pct,684        projection_5_ans: pfFull.projection_5_ans,685        marges_de_securite: {686          loyer_point_mort_par_porte: (pfFull.marges_de_securite as Record<string, unknown>)?.loyer_point_mort_par_porte,687          taux_point_mort_pct: (pfFull.marges_de_securite as Record<string, unknown>)?.taux_point_mort_pct,688        },689      };690691      // benchmark $/porte du gabarit dans la municipalité692      const bandCond =693        doors <= 3 ? "nb_logements = ?" : doors <= 5 ? "nb_logements BETWEEN 4 AND 5" : doors <= 12 ? "nb_logements BETWEEN 6 AND 12" : "nb_logements >= 13";694      const bandArgs: unknown[] = doors <= 3 ? [u.municipalite, doors] : [u.municipalite];695      const bench = u.municipalite696        ? (getDb()697            .prepare(698              `SELECT COUNT(*) n, AVG(est_2026 / nb_logements) prix_porte_moyen699               FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL AND ${bandCond}`700            )701            .get(...bandArgs) as { n: number; prix_porte_moyen: number | null })702        : null;703704      const idx = getMarketIndex("plex");705      const comps = res.comps.slice(0, 5).map((c) => ({706        adresse: [c.street, c.city].filter(Boolean).join(", "),707        vendu_le: c.date,708        prix_paye: c.amount,709        portes: c.doors ?? null,710        prix_ajuste: r(c.adjustedPrice),711        poids_pct: r(c.weight * 100, 1),712      }));713714      const h21 = u.history.find((h) => h.year === 2021)?.value;715      const h26 = u.history.find((h) => h.year === 2026)?.value;716      const valeurParPorte = r(res.estimate / doors);717      const benchPorte = bench?.prix_porte_moyen ? r(bench.prix_porte_moyen) : null;718719      return {720        evaluation: evalCard(ev),721        proforma: proformaCondense,722        marche_local_du_gabarit: bench723          ? {724              municipalite: u.municipalite,725              plex_comparables_dans_la_ville: bench.n,726              valeur_moyenne_par_porte: benchPorte,727            }728          : null,729        tendance_marche_plex: {730          croissance_12_mois_pct: growth(idx, 12),731          croissance_24_mois_pct: growth(idx, 24),732        },733        top_comparables: comps,734        synthese: {735          valeur_par_porte: valeurParPorte,736          ecart_par_porte_vs_ville_pct:737            valeurParPorte && benchPorte ? r((valeurParPorte / benchPorte - 1) * 100, 1) : null,738          croissance_plex_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,739          note: "Le pro forma part du loyer implicite (supposé par la valeur au TGA de référence), pas des baux réels.",740        },741      };742    }743744    case "liens_rapports": {745      const id = resolveId(input.id);746      const u = getUnit(id);747      if (!u) return { erreur: ID_ERR };748      return {749        ...liens(id),750        note: "Le rapport standard inclut le pro forma ; le professionnel (6 pages) est le format bancaire.",751      };752    }753754    default:755      return { erreur: `Outil inconnu : ${name}` };756  }757}758759export { DEFAULT_PARAMS, tgaReference };760