SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.2%
24.1 KB · 619 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Ka — la panoplie d'outils de l'agent IA de Vrai-Prix.4 * Chaque outil est exécuté côté serveur, directement sur la base SQLite5 * (3,75 M unités) et le moteur d'estimation. Les sorties sont des JSON6 * compacts : Ka lit vite, coûte peu, et cite des chiffres exacts.7 */8import Anthropic from "@anthropic-ai/sdk";9import { getDb, getMarketIndex, getUnit, searchUnits } from "../db";10import {11  estimateByUnitId,12  estimateManual,13  estimatePortfolio,14  indexType,15  type UnitEstimate,16} from "../estimator";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  "Unité introuvable — l'id est peut-être mal recopié. Relance chercher_propriete et copie le champ `id` EXACTEMENT tel quel.";9293function liens(id: string) {94  const e = encodeURIComponent(id);95  return {96    fiche_complete: `https://www.vrai-prix.com/estimation/${e}`,97    rapport_standard_pdf: `https://www.vrai-prix.com/api/report?id=${e}`,98    rapport_professionnel_pdf: `https://www.vrai-prix.com/api/report/pro?id=${e}`,99  };100}101102function unitCard(u: ReturnType<typeof searchUnits>[number]) {103  return {104    id: u.id_provinc,105    adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "),106    municipalite: u.municipalite,107    arrondissement: u.arrond,108    type: u.type_prop,109    usage: u.cubf_libelle,110    annee_construction: u.annee_construction,111    aire_habitable_m2: r(u.aire_etages_m2, 1),112    terrain_m2: r(u.superficie_terrain_m2),113    logements: u.nb_logements,114    valeur_role_2026: u.valeur_role,115  };116}117118function evalCard(e: UnitEstimate) {119  const u = e.unit;120  const res = e.result;121  return {122    unite: u123      ? {124          id: u.id,125          adresse: u.adresse,126          municipalite: u.municipalite,127          type: u.typeProp,128          usage: u.cubfLibelle,129          annee_construction: u.anneeConstruction,130          aire_habitable_m2: r(u.aireEtagesM2, 1),131          terrain_m2: r(u.superficieTerrainM2),132          logements: u.nbLogements,133          etages: u.specs.nbEtages,134          genre_construction: u.specs.genreConstruction,135          lien_physique: u.specs.lienPhysique,136          valeur_role_2026: u.valeurRole,137          valeur_terrain_role: u.specs.valeurTerrain,138          valeur_batiment_role: u.specs.valeurBatiment,139          valeur_role_anterieure: u.specs.valeurAnterieure,140          liens: liens(u.id),141        }142      : null,143    estimation: {144      valeur: r(res.estimate),145      fourchette_basse_p10: r(res.low),146      fourchette_haute_p90: r(res.high),147      confiance_pct: res.confidencePct,148      confiance_niveau: res.confidenceLevel,149      part_modele_hedonique: r(res.modelEstimate),150      part_comparables: r(res.compsEstimate),151      poids_modele: r(res.modelWeight, 2),152      n_comparables_utilises: res.nCompsUsed,153      dispersion_comparables_pct: r(res.compsDispersionPct, 1),154      valeur_par_m2: u?.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null,155      ecart_vs_role_pct: u?.valeurRole156        ? r((res.estimate / u.valeurRole - 1) * 100, 1)157        : null,158    },159    historique_2021_2026: u?.history ?? null,160  };161}162163/* ------------------------------------------------------------ tool defs */164165export const KA_TOOLS: Anthropic.Tool[] = [166  {167    name: "chercher_propriete",168    description:169      "Recherche plein-texte d'une propriété parmi les 3 747 008 unités d'évaluation 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 (« voici ce qui s'en rapproche ») au lieu de répondre introuvable. Plusieurs candidats plausibles → demande de préciser AVANT d'évaluer. Copie l'`id` EXACTEMENT tel que retourné.",170    input_schema: {171      type: "object",172      properties: {173        requete: {174          type: "string",175          description: "Adresse ou fragment d'adresse, ex. « 861 route Elgin Saint-Pamphile »",176        },177        limite: { type: "number", description: "Nombre max de résultats (défaut 6, max 12)" },178      },179      required: ["requete"],180    },181  },182  {183    name: "evaluer_propriete",184    description:185      "Évaluation complète d'une propriété par son id (obtenu via chercher_propriete) : valeur estimée, fourchette P10-P90, indice de confiance A-D, décomposition modèle hédonique vs comparables, valeur au rôle, valeur/m², historique 2021-2026. C'est l'outil central — appelle-le une fois l'unité confirmée.",186    input_schema: {187      type: "object",188      properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } },189      required: ["id"],190    },191  },192  {193    name: "comparables_detailles",194    description:195      "Liste détaillée des ventes comparables utilisées dans l'évaluation d'une unité : adresse, date, prix payé, distance, ajustements (marché, superficie, âge) en dollars, prix ajusté et poids. Utile quand l'utilisateur demande « pourquoi ce prix ? » ou veut voir les ventes du voisinage.",196    input_schema: {197      type: "object",198      properties: {199        id: { type: "string", description: "Identifiant provincial de l'unité" },200        max: { type: "number", description: "Nombre max de comparables (défaut 8)" },201      },202      required: ["id"],203    },204  },205  {206    name: "indice_marche",207    description:208      "Tendance du marché québécois par type de propriété (unifamilial, condo, plex) : indice mensuel $/m² et croissance sur 12 et 24 mois. Utile pour contextualiser une évaluation ou répondre à « le marché monte-t-il ? ».",209    input_schema: {210      type: "object",211      properties: {212        type: {213          type: "string",214          enum: ["unifamilial", "condo", "plex"],215          description: "Type de marché",216        },217      },218      required: ["type"],219    },220  },221  {222    name: "stats_municipalite",223    description:224      "Statistiques en direct d'une municipalité : nombre d'unités, valeur totale et médiane estimée 2026, valeur au rôle, répartition par type. Calculé en direct sur la base. Utile pour comparer une propriété à son marché local.",225    input_schema: {226      type: "object",227      properties: { municipalite: { type: "string", description: "Nom exact de la municipalité, ex. « Lévis »" } },228      required: ["municipalite"],229    },230  },231  {232    name: "stats_provinciales",233    description:234      "Les grands chiffres du Québec : valeur totale de tout l'immobilier de la province (2,01 billions $), unités, logements, croissance 2021→2026, top municipalités, répartition par type. Pour les questions macro.",235    input_schema: { type: "object", properties: {} },236  },237  {238    name: "evaluer_parc",239    description:240      "Évalue un parc immobilier complet (2 à 40 unités par leurs ids) : valeur totale agrégée, fourchette, confiance pondérée, répartition par ville et par type, croissance du parc 2021→2026. Pour les investisseurs multi-propriétés.",241    input_schema: {242      type: "object",243      properties: {244        ids: {245          type: "array",246          items: { type: "string" },247          description: "Identifiants provinciaux des unités du parc",248        },249      },250      required: ["ids"],251    },252  },253  {254    name: "comparer_proprietes",255    description:256      "Compare 2 à 4 propriétés côte à côte : valeur estimée, $/m², écart vs rôle, confiance, croissance 2021→2026. Pour aider un choix d'achat.",257    input_schema: {258      type: "object",259      properties: {260        ids: { type: "array", items: { type: "string" }, description: "2 à 4 identifiants" },261      },262      required: ["ids"],263    },264  },265  {266    name: "estimation_manuelle",267    description:268      "Estimation sans adresse exacte, à partir de caractéristiques : municipalité + type, et si possible superficie habitable, année, terrain. À utiliser seulement si chercher_propriete ne trouve rien ou pour un scénario hypothétique.",269    input_schema: {270      type: "object",271      properties: {272        municipalite: { type: "string" },273        type: {274          type: "string",275          enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"],276        },277        aire_habitable_m2: { type: "number" },278        annee_construction: { type: "number" },279        terrain_m2: { type: "number" },280      },281      required: ["municipalite", "type"],282    },283  },284  {285    name: "chercher_proprietes_secteur",286    description:287      "Trouve des propriétés dans une municipalité selon des critères : type, budget max (valeur estimée 2026), superficie min. Retourne les meilleures correspondances triées par valeur. Pour « trouve-moi une maison à Lévis sous 500 k$ ».",288    input_schema: {289      type: "object",290      properties: {291        municipalite: { type: "string" },292        type: {293          type: "string",294          enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"],295        },296        valeur_max: { type: "number", description: "Budget maximal ($)" },297        valeur_min: { type: "number", description: "Valeur minimale ($)" },298        aire_min_m2: { type: "number" },299        limite: { type: "number", description: "Max résultats (défaut 8, max 15)" },300      },301      required: ["municipalite"],302    },303  },304  {305    name: "dossier_complet",306    description:307      "L'ARME LOURDE : dossier d'expert complet d'une propriété en UN SEUL appel — évaluation complète + statistiques du marché local + tendance du marché provincial + top 5 des comparables + synthèse chiffrée pré-calculée (position vs médiane municipale, $/m² vs comparables, croissance propriété vs marché). Utilise-le SYSTÉMATIQUEMENT quand l'utilisateur demande d'évaluer une propriété identifiée : tu obtiens tout pour livrer un mini-rapport d'expert d'un coup.",308    input_schema: {309      type: "object",310      properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } },311      required: ["id"],312    },313  },314  {315    name: "liens_rapports",316    description:317      "Donne les liens de téléchargement des rapports PDF d'une unité (rapport standard 3 pages et rapport professionnel bancaire 6 pages) et le lien de sa fiche complète. Offre-les à la fin d'une évaluation réussie.",318    input_schema: {319      type: "object",320      properties: { id: { type: "string" } },321      required: ["id"],322    },323  },324];325326/* ------------------------------------------------------------ execution */327328type J = Record<string, unknown>;329330function growth(idx: { month: string; idx: number }[], months: number): number | null {331  if (idx.length < months + 1) return null;332  const last = idx[idx.length - 1];333  const past = idx[idx.length - 1 - months];334  return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null;335}336337export function runKaTool(name: string, input: J): unknown {338  switch (name) {339    case "chercher_propriete": {340      const limit = Math.min(Number(input.limite) || 6, 12);341      const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit);342      if (!rows.length)343        return {344          resultats: [],345          correspondance: "aucune",346          conseil:347            "Aucun résultat, même approximatif. Essayer une graphie différente (« numéro rue municipalité »), ou proposer estimation_manuelle.",348        };349      return {350        correspondance: niveau,351        ...(niveau === "approximative"352          ? {353              note: "Correspondance exacte introuvable — voici les adresses les plus proches (même voie ou même secteur). Présente-les à l'utilisateur comme des suggestions.",354            }355          : {}),356        resultats: rows.map(unitCard),357      };358    }359360    case "evaluer_propriete": {361      const e = estimateByUnitId(resolveId(input.id));362      if (!e) return { erreur: ID_ERR };363      return evalCard(e);364    }365366    case "comparables_detailles": {367      const e = estimateByUnitId(resolveId(input.id));368      if (!e) return { erreur: ID_ERR };369      const max = Math.min(Number(input.max) || 8, 15);370      return {371        n_utilises: e.result.nCompsUsed,372        comparables: e.result.comps.slice(0, max).map((c) => ({373          adresse: [c.street, c.city].filter(Boolean).join(", "),374          vendu_le: c.date,375          prix_paye: c.amount,376          distance_m: r(c.distanceM),377          il_y_a_mois: c.monthsAgo,378          ajust_marche: r(c.adjTime),379          ajust_superficie: r(c.adjArea),380          ajust_age: r(c.adjAge),381          prix_ajuste: r(c.adjustedPrice),382          poids_pct: r(c.weight * 100, 1),383        })),384      };385    }386387    case "indice_marche": {388      const idx = getMarketIndex(String(input.type ?? "unifamilial"));389      if (!idx.length) return { erreur: "Type d'indice inconnu." };390      const last12 = idx.slice(-13);391      return {392        type: input.type,393        croissance_12_mois_pct: growth(idx, 12),394        croissance_24_mois_pct: growth(idx, 24),395        serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })),396        note: "Indice $/m² lissé 3 mois, 1.0 = niveau actuel du marché.",397      };398    }399400    case "stats_municipalite": {401      const mun = String(input.municipalite ?? "");402      const g = getDb()403        .prepare(404          `SELECT COUNT(*) n, SUM(est_2026) total, SUM(valeur_role) role_total405           FROM units WHERE municipalite = ? COLLATE NOCASE`406        )407        .get(mun) as { n: number; total: number | null; role_total: number | null };408      if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` };409      const med = getDb()410        .prepare(411          `SELECT est_2026 v FROM units412           WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL413           ORDER BY est_2026 LIMIT 1414           OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2`415        )416        .get(mun, mun) as { v: number } | undefined;417      const types = getDb()418        .prepare(419          `SELECT type_prop, COUNT(*) n, SUM(est_2026) total420           FROM units WHERE municipalite = ? COLLATE NOCASE421           GROUP BY type_prop ORDER BY total DESC`422        )423        .all(mun) as { type_prop: string; n: number; total: number | null }[];424      return {425        municipalite: mun,426        unites: g.n,427        valeur_totale_estimee_2026: r(g.total),428        valeur_role_totale: r(g.role_total),429        valeur_mediane_2026: med?.v ?? null,430        par_type: types.map((t) => ({ type: t.type_prop, unites: t.n, total: r(t.total) })),431      };432    }433434    case "stats_provinciales": {435      const s = stats as J;436      const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10);437      return {438        valeur_totale_immobilier_quebec_2026: s.valeur_totale_2026,439        valeur_role_totale: s.valeur_role_totale,440        unites_evaluees: s.unites,441        logements: s.logements,442        valeur_mediane_2026: s.valeur_mediane_2026,443        croissance_2021_2026_pct: s.croissance_2021_2026_pct,444        municipalites: s.municipalites,445        par_type: s.par_type,446        top_10_villes: villes,447      };448    }449450    case "evaluer_parc": {451      const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId);452      if (ids.length < 1) return { erreur: "Fournir au moins un id." };453      const p = estimatePortfolio(ids);454      if (!p.items.length) return { erreur: "Aucune unité valide trouvée." };455      const a = p.aggregates;456      return {457        unites_evaluees: a.count,458        valeur_totale: r(a.totalEstimate),459        fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) },460        valeur_role_totale: r(a.totalRole),461        ecart_vs_role_pct: r(a.ecartRolePct, 1),462        confiance: { pct: a.confidencePct, niveau: a.confidenceLevel },463        logements_totaux: a.totalDwellings,464        aire_totale_m2: a.totalFloorArea,465        croissance_parc_2021_2026_pct: r(a.growthPct, 1),466        par_ville: a.municipalities,467        par_type: a.types,468        detail: p.items.map((i) => ({469          id: i.unit!.id,470          adresse: i.unit!.adresse,471          valeur: r(i.result.estimate),472          confiance: i.result.confidenceLevel,473        })),474      };475    }476477    case "comparer_proprietes": {478      const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId);479      if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." };480      return {481        comparaison: ids.map((id) => {482          const e = estimateByUnitId(id);483          if (!e || !e.unit) return { id, erreur: "introuvable" };484          const h21 = e.unit.history.find((h) => h.year === 2021)?.value;485          const h26 = e.unit.history.find((h) => h.year === 2026)?.value;486          return {487            id,488            adresse: e.unit.adresse,489            municipalite: e.unit.municipalite,490            valeur: r(e.result.estimate),491            valeur_par_m2: e.unit.aireEtagesM2 ? r(e.result.estimate / e.unit.aireEtagesM2) : null,492            ecart_vs_role_pct: e.unit.valeurRole493              ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1)494              : null,495            confiance: e.result.confidenceLevel,496            croissance_2021_2026_pct:497              h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,498          };499        }),500      };501    }502503    case "estimation_manuelle": {504      const e = estimateManual({505        municipality: String(input.municipalite ?? ""),506        typeProp: String(input.type ?? "unifamilial"),507        floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined,508        yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined,509        landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined,510      });511      if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." };512      return evalCard(e);513    }514515    case "chercher_proprietes_secteur": {516      const mun = String(input.municipalite ?? "");517      const limit = Math.min(Number(input.limite) || 8, 15);518      const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"];519      const args: unknown[] = [mun];520      if (input.type) {521        conds.push("type_prop = ?");522        args.push(String(input.type));523      }524      if (input.valeur_max) {525        conds.push("est_2026 <= ?");526        args.push(Number(input.valeur_max));527      }528      if (input.valeur_min) {529        conds.push("est_2026 >= ?");530        args.push(Number(input.valeur_min));531      }532      if (input.aire_min_m2) {533        conds.push("aire_etages_m2 >= ?");534        args.push(Number(input.aire_min_m2));535      }536      const rows = getDb()537        .prepare(538          `SELECT * FROM units WHERE ${conds.join(" AND ")}539           ORDER BY est_2026 DESC LIMIT ?`540        )541        .all(...args, limit) as Parameters<typeof unitCard>[0][];542      if (!rows.length) return { resultats: [], note: "Aucune propriété ne correspond aux critères." };543      return {544        resultats: rows.map((u) => ({ ...unitCard(u), valeur_estimee_2026: r(u.est_2026) })),545      };546    }547548    case "dossier_complet": {549      const id = resolveId(input.id);550      const ev = estimateByUnitId(id);551      if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." };552      const u = ev.unit;553      const res = ev.result;554555      const marcheLocal = u.municipalite556        ? (runKaTool("stats_municipalite", { municipalite: u.municipalite }) as Record<string, unknown>)557        : null;558      const idx = getMarketIndex(indexType(u.typeProp));559      const comps = res.comps.slice(0, 5).map((c) => ({560        adresse: [c.street, c.city].filter(Boolean).join(", "),561        vendu_le: c.date,562        prix_paye: c.amount,563        prix_ajuste: r(c.adjustedPrice),564        distance_m: r(c.distanceM),565        poids_pct: r(c.weight * 100, 1),566      }));567568      // synthèse pré-calculée — les chiffres qui font un avis d'expert569      const medianeMun = (marcheLocal?.valeur_mediane_2026 as number | null) ?? null;570      const h21 = u.history.find((h) => h.year === 2021)?.value;571      const h26 = u.history.find((h) => h.year === 2026)?.value;572      const croissanceProp = h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null;573      const compsM2 = res.comps574        .filter((c) => c.floorArea && c.floorArea > 20)575        .map((c) => c.adjustedPrice / c.floorArea!);576      const benchM2 = compsM2.length577        ? r(compsM2.sort((a, b) => a - b)[Math.floor(compsM2.length / 2)])578        : null;579      const propM2 = u.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null;580581      return {582        evaluation: evalCard(ev),583        marche_local: marcheLocal,584        tendance_marche_provincial: {585          type: indexType(u.typeProp),586          croissance_12_mois_pct: growth(idx, 12),587          croissance_24_mois_pct: growth(idx, 24),588        },589        top_comparables: comps,590        synthese: {591          position_vs_mediane_municipale_pct:592            medianeMun && medianeMun > 0 ? r((res.estimate / medianeMun - 1) * 100, 1) : null,593          croissance_propriete_2021_2026_pct: croissanceProp,594          valeur_m2_propriete: propM2,595          valeur_m2_mediane_comparables: benchM2,596          ecart_m2_vs_comparables_pct:597            propM2 && benchM2 ? r((propM2 / benchM2 - 1) * 100, 1) : null,598        },599      };600    }601602    case "liens_rapports": {603      const id = resolveId(input.id);604      const u = getUnit(id);605      if (!u) return { erreur: ID_ERR };606      return {607        ...liens(id),608        note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.",609      };610    }611612    default:613      return { erreur: `Outil inconnu : ${name}` };614  }615}616617// réexport pratique pour la route618export { indexType };619