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%
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