TypeScript 90.1%
JavaScript 3.2%
Python 3.2%
CSS 1.8%
HTML 1.8%
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2/**3 * Éva — la panoplie d'outils de l'assistante pédagogique d'UQO Éval.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";18import { prefillFromProperty, runEstimate, getEstimate, sanitizeInput, defaultInput } from "../cost/estimate";19import { buildContext, searchItems, getItem, priceHistory, allLabourRates, labourHistory, loadLocations, today as costToday } from "../cost/catalog";20import { assemblyUnitCost } from "../cost/engine";21import { deriveQuantities } from "../cost/geometry";22import { getCostDb } from "../cost/db";23import { CONDITIONS, TRADES, categoryLabel, tradeLabel } from "../cost/taxonomy";24import type { CostEstimate, CostInput, PriceKind, Quality } from "../cost/types";2526/* ---------------------------------------------------------------- helpers */2728const r = (n: number | null | undefined, d = 0): number | null =>29 n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d));3031/** Mots génériques d'adresse ignorés lors de la recherche approximative. */32const STOP_WORDS = new Set([33 "rue", "avenue", "av", "boulevard", "boul", "blvd", "chemin", "ch", "route", "rte",34 "rang", "place", "montee", "montée", "impasse", "croissant", "terrasse", "allée", "allee",35 "cote", "côte", "carre", "carré", "du", "de", "des", "la", "le", "les", "l", "d",36 "au", "aux", "à", "a", "et", "app", "apt", "st", "ste",37]);3839function tokenize(q: string): string[] {40 return q41 .replace(/[^\p{L}\p{N}\s'-]/gu, " ")42 .trim()43 .split(/\s+/)44 .filter((t) => t.length > 0);45}4647function ftsAnd(tokens: string[], limit: number) {48 if (!tokens.length) return [];49 const match = tokens.map((t) => `"${t}"*`).join(" ");50 return getDb()51 .prepare(52 `SELECT u.*, bm25(units_fts) AS score53 FROM units_fts JOIN units u ON u.rowid = units_fts.rowid54 WHERE units_fts MATCH ? ORDER BY score LIMIT ?`55 )56 .all(match, limit) as ReturnType<typeof searchUnits>;57}5859/**60 * Recherche à relaxation progressive : exacte, puis sans mots génériques61 * (rue/avenue/de…), puis sans numéro civique, puis nom de voie seul.62 * Retourne toujours ce qui s'en rapproche le plus plutôt que rien.63 */64function searchFlexible(65 q: string,66 limit: number67): { rows: ReturnType<typeof searchUnits>; niveau: "exacte" | "approximative" } {68 const strict = searchUnits(q, limit);69 if (strict.length) return { rows: strict, niveau: "exacte" };7071 const tokens = tokenize(q);72 const sansStop = tokens.filter((t) => !STOP_WORDS.has(t.toLowerCase()));73 const sansNumero = sansStop.filter((t) => !/^\d+[a-z]?$/i.test(t));74 const nomsSeuls = sansNumero.filter((t) => t.length >= 4);7576 for (const attempt of [sansStop, sansNumero, nomsSeuls, nomsSeuls.slice(0, 1)]) {77 if (!attempt.length) continue;78 if (attempt.length === tokens.length) continue; // déjà tenté en strict79 const rows = ftsAnd(attempt, limit);80 if (rows.length) return { rows, niveau: "approximative" };81 }82 return { rows: [], niveau: "approximative" };83}8485/**86 * Résout un id éventuellement mal recopié par le modèle : essai brut,87 * puis version chiffres seulement (les id provinciaux sont numériques).88 */89function resolveId(raw: unknown): string {90 const a = String(raw ?? "").trim();91 if (getUnit(a)) return a;92 const d = a.replace(/\D/g, "");93 if (d && d !== a && getUnit(d)) return d;94 return a;95}9697const ID_ERR =98 "Unité introuvable — l'id est peut-être mal recopié. Relance chercher_propriete et copie le champ `id` EXACTEMENT tel quel.";99100function liens(id: string) {101 const e = encodeURIComponent(id);102 return {103 fiche_complete: `https://www.uqo-eval.app/estimation/${e}`,104 rapport_standard_pdf: `https://www.uqo-eval.app/api/report?id=${e}`,105 rapport_professionnel_pdf: `https://www.uqo-eval.app/api/report/pro?id=${e}`,106 };107}108109function unitCard(u: ReturnType<typeof searchUnits>[number]) {110 return {111 id: u.id_provinc,112 adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "),113 municipalite: u.municipalite,114 arrondissement: u.arrond,115 type: u.type_prop,116 usage: u.cubf_libelle,117 annee_construction: u.annee_construction,118 aire_habitable_m2: r(u.aire_etages_m2, 1),119 terrain_m2: r(u.superficie_terrain_m2),120 logements: u.nb_logements,121 valeur_role_2026: u.valeur_role,122 };123}124125function evalCard(e: UnitEstimate) {126 const u = e.unit;127 const res = e.result;128 return {129 unite: u130 ? {131 id: u.id,132 adresse: u.adresse,133 municipalite: u.municipalite,134 type: u.typeProp,135 usage: u.cubfLibelle,136 annee_construction: u.anneeConstruction,137 aire_habitable_m2: r(u.aireEtagesM2, 1),138 terrain_m2: r(u.superficieTerrainM2),139 logements: u.nbLogements,140 etages: u.specs.nbEtages,141 genre_construction: u.specs.genreConstruction,142 lien_physique: u.specs.lienPhysique,143 valeur_role_2026: u.valeurRole,144 valeur_terrain_role: u.specs.valeurTerrain,145 valeur_batiment_role: u.specs.valeurBatiment,146 valeur_role_anterieure: u.specs.valeurAnterieure,147 liens: liens(u.id),148 }149 : null,150 estimation: {151 valeur: r(res.estimate),152 fourchette_basse_p10: r(res.low),153 fourchette_haute_p90: r(res.high),154 confiance_pct: res.confidencePct,155 confiance_niveau: res.confidenceLevel,156 part_modele_hedonique: r(res.modelEstimate),157 part_comparables: r(res.compsEstimate),158 poids_modele: r(res.modelWeight, 2),159 n_comparables_utilises: res.nCompsUsed,160 dispersion_comparables_pct: r(res.compsDispersionPct, 1),161 valeur_par_m2: u?.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null,162 ecart_vs_role_pct: u?.valeurRole163 ? r((res.estimate / u.valeurRole - 1) * 100, 1)164 : null,165 },166 historique_2021_2026: u?.history ?? null,167 };168}169170/* ------------------------------------------------- méthode du coût — aides */171172function natureLabel(k: PriceKind): string {173 switch (k) {174 case "observed": return "● observé";175 case "official": return "● officiel";176 case "indexed": return "○ indexé (StatCan)";177 case "derived": return "◐ calculé";178 case "reference": return "◇ référence interne (hypothèse)";179 default: return "◇ hypothèse";180 }181}182183function coutCard(est: CostEstimate) {184 const b = est.input.building;185 const d = est.depreciation;186 const c = est.confidence;187 const e = encodeURIComponent(est.id);188 return {189 estimation_id: est.id,190 date_prix: est.priceDate,191 batiment: { type: b.type, qualite: b.quality, aire_pi2: b.grossFloorAreaSqft, etages: b.stories, annee: b.yearBuilt, sous_sol: b.basement, garage: b.garage.type, revetement: b.siding, toiture: b.roof, chauffage: b.heating, salles_de_bain: b.bathrooms, logements: b.units },192 localisation: { code: est.location.code, region: est.location.nameFr, facteur_materiaux: est.location.materialFactor, facteur_main_doeuvre: est.location.labourFactor, facteur_equipement: est.location.equipmentFactor },193 cout_remplacement_neuf: est.replacementCostNew,194 fourchette_rcn: { p10: est.range.p10, p90: est.range.p90 },195 rcn_par_pi2: est.perSqft,196 rcn_par_m2: est.perM2,197 decomposition: { couts_directs: est.directCost, materiaux: est.directMaterial, main_doeuvre: est.directLabour, equipement: est.directEquipment, couts_indirects: est.indirectCost, frais_generaux: est.contractorOverhead, profit: est.contractorProfit, contingence: est.contingency },198 hypotheses: { indirects_pct_total: r(est.indirect.reduce((s, i) => s + i.pct, 0), 1), frais_generaux_pct: est.input.params.overheadPct, profit_pct: est.input.params.profitPct, contingence_pct: est.input.params.contingencyPct, vie_economique: d.economicLife, methode_depreciation: d.method },199 categories: est.categories.slice().sort((a, b2) => b2.adjusted - a.adjusted).slice(0, 6).map((k) => ({ categorie: k.labelFr, materiaux: k.material, main_doeuvre: k.labour, equipement: k.equipment, total: k.adjusted, part_pct: k.sharePct })),200 assemblages_principaux: est.lines.slice().sort((a, b2) => b2.adjusted - a.adjusted).slice(0, 6).map((l) => ({ code: l.assemblyCode, nom: l.nameFr, quantite: l.quantity, unite: l.unit, cout: l.adjusted })),201 depreciation: { methode: d.method, age_chronologique: d.chronologicalAge, age_effectif: d.effectiveAge, vie_economique: d.economicLife, physique_pct: d.physicalPct, physique: d.physical, fonctionnelle: d.functional, externe: d.external, total: d.total },202 valeur_depreciee_batiment: d.depreciatedImprovementValue,203 terrain: { valeur: est.landValue, source: est.input.land.source, methode: est.input.land.method || null },204 indication_par_le_cout: est.costApproachValue,205 confiance: { total: c.total, niveau: c.letter, fraicheur_20: c.freshness, couverture_20: c.coverage, localisation_15: c.location, main_doeuvre_15: c.labour, benchmarks_10: c.benchmarks, batiment_20: c.building, notes: c.notesFr },206 part_materiaux_prix_observes_pct: r(est.coverage.materialObservedShare * 100),207 benchmarks: est.benchmarks.length ? est.benchmarks.map((k) => ({ source: k.source, marche: k.market, plage: `${k.low}-${k.high} ${k.unit}`, uqo_eval: k.estimatePerUnit, etat: k.status })) : "donnée non disponible (aucun benchmark importé)",208 autres_lectures: est.otherReadings,209 avertissements: est.warnings,210 liens: { page_cout: `https://www.uqo-eval.app/cout?estimate=${e}`, rapport_pdf: `https://www.uqo-eval.app/api/cost/report?estimate=${e}` },211 };212}213214/* ------------------------------------------------------------ tool defs */215216export const KA_TOOLS: Anthropic.Tool[] = [217 {218 name: "chercher_propriete",219 description:220 "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é.",221 input_schema: {222 type: "object",223 properties: {224 requete: {225 type: "string",226 description: "Adresse ou fragment d'adresse, ex. « 861 route Elgin Saint-Pamphile »",227 },228 limite: { type: "number", description: "Nombre max de résultats (défaut 6, max 12)" },229 },230 required: ["requete"],231 },232 },233 {234 name: "evaluer_propriete",235 description:236 "É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.",237 input_schema: {238 type: "object",239 properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } },240 required: ["id"],241 },242 },243 {244 name: "comparables_detailles",245 description:246 "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.",247 input_schema: {248 type: "object",249 properties: {250 id: { type: "string", description: "Identifiant provincial de l'unité" },251 max: { type: "number", description: "Nombre max de comparables (défaut 8)" },252 },253 required: ["id"],254 },255 },256 {257 name: "indice_marche",258 description:259 "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 ? ».",260 input_schema: {261 type: "object",262 properties: {263 type: {264 type: "string",265 enum: ["unifamilial", "condo", "plex"],266 description: "Type de marché",267 },268 },269 required: ["type"],270 },271 },272 {273 name: "stats_municipalite",274 description:275 "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.",276 input_schema: {277 type: "object",278 properties: { municipalite: { type: "string", description: "Nom exact de la municipalité, ex. « Lévis »" } },279 required: ["municipalite"],280 },281 },282 {283 name: "stats_provinciales",284 description:285 "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.",286 input_schema: { type: "object", properties: {} },287 },288 {289 name: "evaluer_parc",290 description:291 "É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.",292 input_schema: {293 type: "object",294 properties: {295 ids: {296 type: "array",297 items: { type: "string" },298 description: "Identifiants provinciaux des unités du parc",299 },300 },301 required: ["ids"],302 },303 },304 {305 name: "comparer_proprietes",306 description:307 "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.",308 input_schema: {309 type: "object",310 properties: {311 ids: { type: "array", items: { type: "string" }, description: "2 à 4 identifiants" },312 },313 required: ["ids"],314 },315 },316 {317 name: "estimation_manuelle",318 description:319 "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.",320 input_schema: {321 type: "object",322 properties: {323 municipalite: { type: "string" },324 type: {325 type: "string",326 enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"],327 },328 aire_habitable_m2: { type: "number" },329 annee_construction: { type: "number" },330 terrain_m2: { type: "number" },331 },332 required: ["municipalite", "type"],333 },334 },335 {336 name: "chercher_proprietes_secteur",337 description:338 "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$ ».",339 input_schema: {340 type: "object",341 properties: {342 municipalite: { type: "string" },343 type: {344 type: "string",345 enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"],346 },347 valeur_max: { type: "number", description: "Budget maximal ($)" },348 valeur_min: { type: "number", description: "Valeur minimale ($)" },349 aire_min_m2: { type: "number" },350 limite: { type: "number", description: "Max résultats (défaut 8, max 15)" },351 },352 required: ["municipalite"],353 },354 },355 {356 name: "dossier_complet",357 description:358 "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.",359 input_schema: {360 type: "object",361 properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } },362 required: ["id"],363 },364 },365 {366 name: "liens_rapports",367 description:368 "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.",369 input_schema: {370 type: "object",371 properties: { id: { type: "string" } },372 required: ["id"],373 },374 },375 /* ------------------------------------------------ méthode du coût (IMM1033) */376 {377 name: "cout_estimer_propriete",378 description:379 "MÉTHODE DU COÛT (IMM1033) pour une propriété du rôle, par son id (chercher_propriete d'abord) : coût de remplacement à neuf (RCN) décomposé par catégorie (matériaux / main-d'œuvre / équipement), coûts indirects, frais généraux, profit, contingence, fourchette P10-P90, $/pi², dépréciation (physique âge-vie ou par composante, fonctionnelle, externe), valeur du terrain au rôle, INDICATION DE VALEUR PAR LE COÛT, indice de confiance A-D décomposé, part des matériaux à prix observés vs prix de référence (hypothèses), autres lectures (hédonique, comparables, hybride, rôle) et liens (page /cout + PDF). Les caractéristiques absentes du rôle (revêtement, toiture, chauffage, sous-sol, qualité…) sont des HYPOTHÈSES par défaut — dis-le. Utilise-le pour « combien coûterait cette maison à reconstruire ? ».",380 input_schema: {381 type: "object",382 properties: {383 id: { type: "string", description: "Identifiant provincial de l'unité" },384 qualite: { type: "string", enum: ["economy", "standard", "superior", "prestige"], description: "Qualité de construction (défaut standard)" },385 sous_sol: { type: "string", enum: ["none", "crawl", "unfinished", "partial", "finished", "walkout"] },386 garage: { type: "string", enum: ["none", "attached", "detached", "integrated"] },387 revetement: { type: "string", enum: ["vinyl", "brick", "fiber_cement", "wood", "stone", "steel"], description: "Revêtement principal" },388 toiture: { type: "string", enum: ["asphalt_shingle", "metal", "membrane"] },389 chauffage: { type: "string", enum: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "hydronic"] },390 salles_de_bain: { type: "number" },391 methode_depreciation: { type: "string", enum: ["age_life", "components"] },392 age_effectif: { type: "number", description: "Âge effectif en années (défaut : âge chronologique)" },393 },394 required: ["id"],395 },396 },397 {398 name: "cout_estimer_construction",399 description:400 "MÉTHODE DU COÛT pour un bâtiment HYPOTHÉTIQUE (sans adresse) : municipalité + type + aire en pi² (défauts : maison détachée standard 1 800 pi², 2 étages, sous-sol non fini, vinyle, bardeau, plinthes électriques). Retourne RCN décomposé, fourchette, $/pi², hypothèses, confiance, liens. Pas de terrain sauf si fourni. Pour « combien coûte construire une maison de 2 000 pi² à Sherbrooke ? ».",401 input_schema: {402 type: "object",403 properties: {404 municipalite: { type: "string" },405 type: { type: "string", enum: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile"] },406 qualite: { type: "string", enum: ["economy", "standard", "superior", "prestige"] },407 aire_pi2: { type: "number", description: "Aire d'étages hors sous-sol (pi²)" },408 etages: { type: "number" },409 annee_construction: { type: "number" },410 sous_sol: { type: "string", enum: ["none", "crawl", "unfinished", "partial", "finished", "walkout"] },411 garage: { type: "string", enum: ["none", "attached", "detached", "integrated"] },412 places_garage: { type: "number" },413 revetement: { type: "string", enum: ["vinyl", "brick", "fiber_cement", "wood", "stone", "steel"] },414 toiture: { type: "string", enum: ["asphalt_shingle", "metal", "membrane"] },415 chauffage: { type: "string", enum: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "hydronic"] },416 salles_de_bain: { type: "number" },417 salles_d_eau: { type: "number" },418 logements: { type: "number" },419 valeur_terrain: { type: "number", description: "Valeur du terrain à ajouter ($), si connue" },420 },421 required: [],422 },423 },424 {425 name: "cout_detail_assemblage",426 description:427 "Détail d'un assemblage de construction (ex. STR-WALL-2X6-EXT, ROOF-ASPHALT-ARCH, KIT-STANDARD, BATH-STANDARD, FND-WALL-POURED-8IN) : composition (articles, quantités par unité, pertes), prix unitaire de chaque article avec sa nature (observé / indexé / référence-hypothèse), source et date, heures de main-d'œuvre par métier et taux employeur, coût d'équipement, coût direct par unité (matériaux + main-d'œuvre + équipement), vie économique. Pour « pourquoi la toiture coûte-t-elle X ? » (prendre le code dans le résultat de cout_estimer_*).",428 input_schema: {429 type: "object",430 properties: {431 code: { type: "string", description: "Code d'assemblage" },432 localisation: { type: "string", description: "Code de localisation (défaut QC-MTL), ex. QC-GAT, QC-QUE" },433 },434 required: ["code"],435 },436 },437 {438 name: "cout_chercher_articles",439 description:440 "Recherche d'articles (coûts unitaires de matériaux) dans la base de coûts : ex. « gypse », « 2x6 », « bardeau », « fenêtre ». Retourne prix canonique courant, unité, fourchette min-max des sources, nombre de sources, date de la dernière observation et NATURE du prix (● observé chez un détaillant, ○ indexé StatCan, ◇ prix de référence interne = hypothèse). Sert aussi à trouver un code d'article pour cout_historique_materiau.",441 input_schema: {442 type: "object",443 properties: { requete: { type: "string" }, limite: { type: "number", description: "Max résultats (défaut 8, max 20)" } },444 required: ["requete"],445 },446 },447 {448 name: "cout_taux_main_doeuvre",449 description:450 "Taux de main-d'œuvre de la construction résidentielle au Québec (grilles APCHQ fondées sur les conventions collectives CCQ) : coût horaire EMPLOYEUR complet par métier (salaire de base + indemnité de congés 13 % + avantages sociaux + cotisations AE/RQAP/FSS/CCQ/CNESST…), secteur, date d'entrée en vigueur, source et historique. Sans métier : tous les métiers. Rappelle que le taux employeur n'est pas le salaire horaire.",451 input_schema: {452 type: "object",453 properties: { metier: { type: "string", description: "Code de métier (charpentier, electricien, plombier, briqueteur, couvreur, peintre, carreleur…) — optionnel" } },454 required: [],455 },456 },457 {458 name: "cout_historique_materiau",459 description:460 "Série temporelle des prix d'un article (code obtenu via cout_chercher_articles) : chaque observation datée avec sa source (détaillant), son unité d'origine, le facteur de conversion vers l'unité canonique, prix régulier ou promotion, aberrations marquées. Pour « le prix du bois a-t-il monté ? ».",461 input_schema: {462 type: "object",463 properties: { code: { type: "string" } },464 required: ["code"],465 },466 },467 {468 name: "cout_expliquer_depreciation",469 description:470 "Explique la dépréciation d'une estimation par la méthode du coût (id d'estimation renvoyé par cout_estimer_*) : âge chronologique vs âge effectif vs vie économique, méthode âge-vie ou par composante (tableau : RCN, vie, condition, âge effectif, % et $ par composante), désuétude fonctionnelle (curable / incurable) et externe, règles condition → âge effectif, et définitions pédagogiques (détérioration physique, désuétude fonctionnelle, désuétude externe, coût de reproduction vs coût de remplacement).",471 input_schema: {472 type: "object",473 properties: { estimation_id: { type: "string", description: "Identifiant (uuid) de l'estimation" } },474 required: ["estimation_id"],475 },476 },477 {478 name: "cout_comparer_localisations",479 description:480 "Compare les régions du Québec pour la méthode du coût : facteurs distincts matériaux / main-d'œuvre / équipement (la main-d'œuvre est uniforme au Québec — conventions CCQ — sauf régions éloignées) et coût direct d'une maison type de 1 800 pi² par région. Sans codes : toutes les régions.",481 input_schema: {482 type: "object",483 properties: { codes: { type: "array", items: { type: "string" }, description: "Codes de localisation (QC-MTL, QC-QUE, QC-GAT, QC-SHE, QC-SAG, QC-ABT, QC-CDN, QC-GIM, QC-NDQ…)" } },484 required: [],485 },486 },487 {488 name: "cout_sources",489 description:490 "Registre des sources de la base de coûts : nom, type (main-d'œuvre, indice, détaillant, benchmark, référence), statut de licence, active ou non, dernière synchronisation, nombre d'observations. Pour « d'où viennent les prix ? ».",491 input_schema: { type: "object", properties: {} },492 },493];494495/* ------------------------------------------------------------ execution */496497type J = Record<string, unknown>;498499function growth(idx: { month: string; idx: number }[], months: number): number | null {500 if (idx.length < months + 1) return null;501 const last = idx[idx.length - 1];502 const past = idx[idx.length - 1 - months];503 return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null;504}505506export function runKaTool(name: string, input: J): unknown {507 switch (name) {508 case "chercher_propriete": {509 const limit = Math.min(Number(input.limite) || 6, 12);510 const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit);511 if (!rows.length)512 return {513 resultats: [],514 correspondance: "aucune",515 conseil:516 "Aucun résultat, même approximatif. Essayer une graphie différente (« numéro rue municipalité »), ou proposer estimation_manuelle.",517 };518 return {519 correspondance: niveau,520 ...(niveau === "approximative"521 ? {522 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.",523 }524 : {}),525 resultats: rows.map(unitCard),526 };527 }528529 case "evaluer_propriete": {530 const e = estimateByUnitId(resolveId(input.id));531 if (!e) return { erreur: ID_ERR };532 return evalCard(e);533 }534535 case "comparables_detailles": {536 const e = estimateByUnitId(resolveId(input.id));537 if (!e) return { erreur: ID_ERR };538 const max = Math.min(Number(input.max) || 8, 15);539 return {540 n_utilises: e.result.nCompsUsed,541 comparables: e.result.comps.slice(0, max).map((c) => ({542 adresse: [c.street, c.city].filter(Boolean).join(", "),543 vendu_le: c.date,544 prix_paye: c.amount,545 distance_m: r(c.distanceM),546 il_y_a_mois: c.monthsAgo,547 ajust_marche: r(c.adjTime),548 ajust_superficie: r(c.adjArea),549 ajust_age: r(c.adjAge),550 prix_ajuste: r(c.adjustedPrice),551 poids_pct: r(c.weight * 100, 1),552 })),553 };554 }555556 case "indice_marche": {557 const idx = getMarketIndex(String(input.type ?? "unifamilial"));558 if (!idx.length) return { erreur: "Type d'indice inconnu." };559 const last12 = idx.slice(-13);560 return {561 type: input.type,562 croissance_12_mois_pct: growth(idx, 12),563 croissance_24_mois_pct: growth(idx, 24),564 serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })),565 note: "Indice $/m² lissé 3 mois, 1.0 = niveau actuel du marché.",566 };567 }568569 case "stats_municipalite": {570 const mun = String(input.municipalite ?? "");571 const g = getDb()572 .prepare(573 `SELECT COUNT(*) n, SUM(est_2026) total, SUM(valeur_role) role_total574 FROM units WHERE municipalite = ? COLLATE NOCASE`575 )576 .get(mun) as { n: number; total: number | null; role_total: number | null };577 if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` };578 const med = getDb()579 .prepare(580 `SELECT est_2026 v FROM units581 WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL582 ORDER BY est_2026 LIMIT 1583 OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2`584 )585 .get(mun, mun) as { v: number } | undefined;586 const types = getDb()587 .prepare(588 `SELECT type_prop, COUNT(*) n, SUM(est_2026) total589 FROM units WHERE municipalite = ? COLLATE NOCASE590 GROUP BY type_prop ORDER BY total DESC`591 )592 .all(mun) as { type_prop: string; n: number; total: number | null }[];593 return {594 municipalite: mun,595 unites: g.n,596 valeur_totale_estimee_2026: r(g.total),597 valeur_role_totale: r(g.role_total),598 valeur_mediane_2026: med?.v ?? null,599 par_type: types.map((t) => ({ type: t.type_prop, unites: t.n, total: r(t.total) })),600 };601 }602603 case "stats_provinciales": {604 const s = stats as J;605 const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10);606 return {607 valeur_totale_immobilier_quebec_2026: s.valeur_totale_2026,608 valeur_role_totale: s.valeur_role_totale,609 unites_evaluees: s.unites,610 logements: s.logements,611 valeur_mediane_2026: s.valeur_mediane_2026,612 croissance_2021_2026_pct: s.croissance_2021_2026_pct,613 municipalites: s.municipalites,614 par_type: s.par_type,615 top_10_villes: villes,616 };617 }618619 case "evaluer_parc": {620 const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId);621 if (ids.length < 1) return { erreur: "Fournir au moins un id." };622 const p = estimatePortfolio(ids);623 if (!p.items.length) return { erreur: "Aucune unité valide trouvée." };624 const a = p.aggregates;625 return {626 unites_evaluees: a.count,627 valeur_totale: r(a.totalEstimate),628 fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) },629 valeur_role_totale: r(a.totalRole),630 ecart_vs_role_pct: r(a.ecartRolePct, 1),631 confiance: { pct: a.confidencePct, niveau: a.confidenceLevel },632 logements_totaux: a.totalDwellings,633 aire_totale_m2: a.totalFloorArea,634 croissance_parc_2021_2026_pct: r(a.growthPct, 1),635 par_ville: a.municipalities,636 par_type: a.types,637 detail: p.items.map((i) => ({638 id: i.unit!.id,639 adresse: i.unit!.adresse,640 valeur: r(i.result.estimate),641 confiance: i.result.confidenceLevel,642 })),643 };644 }645646 case "comparer_proprietes": {647 const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId);648 if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." };649 return {650 comparaison: ids.map((id) => {651 const e = estimateByUnitId(id);652 if (!e || !e.unit) return { id, erreur: "introuvable" };653 const h21 = e.unit.history.find((h) => h.year === 2021)?.value;654 const h26 = e.unit.history.find((h) => h.year === 2026)?.value;655 return {656 id,657 adresse: e.unit.adresse,658 municipalite: e.unit.municipalite,659 valeur: r(e.result.estimate),660 valeur_par_m2: e.unit.aireEtagesM2 ? r(e.result.estimate / e.unit.aireEtagesM2) : null,661 ecart_vs_role_pct: e.unit.valeurRole662 ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1)663 : null,664 confiance: e.result.confidenceLevel,665 croissance_2021_2026_pct:666 h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,667 };668 }),669 };670 }671672 case "estimation_manuelle": {673 const e = estimateManual({674 municipality: String(input.municipalite ?? ""),675 typeProp: String(input.type ?? "unifamilial"),676 floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined,677 yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined,678 landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined,679 });680 if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." };681 return evalCard(e);682 }683684 case "chercher_proprietes_secteur": {685 const mun = String(input.municipalite ?? "");686 const limit = Math.min(Number(input.limite) || 8, 15);687 const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"];688 const args: unknown[] = [mun];689 if (input.type) {690 conds.push("type_prop = ?");691 args.push(String(input.type));692 }693 if (input.valeur_max) {694 conds.push("est_2026 <= ?");695 args.push(Number(input.valeur_max));696 }697 if (input.valeur_min) {698 conds.push("est_2026 >= ?");699 args.push(Number(input.valeur_min));700 }701 if (input.aire_min_m2) {702 conds.push("aire_etages_m2 >= ?");703 args.push(Number(input.aire_min_m2));704 }705 const rows = getDb()706 .prepare(707 `SELECT * FROM units WHERE ${conds.join(" AND ")}708 ORDER BY est_2026 DESC LIMIT ?`709 )710 .all(...args, limit) as Parameters<typeof unitCard>[0][];711 if (!rows.length) return { resultats: [], note: "Aucune propriété ne correspond aux critères." };712 return {713 resultats: rows.map((u) => ({ ...unitCard(u), valeur_estimee_2026: r(u.est_2026) })),714 };715 }716717 case "dossier_complet": {718 const id = resolveId(input.id);719 const ev = estimateByUnitId(id);720 if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." };721 const u = ev.unit;722 const res = ev.result;723724 const marcheLocal = u.municipalite725 ? (runKaTool("stats_municipalite", { municipalite: u.municipalite }) as Record<string, unknown>)726 : null;727 const idx = getMarketIndex(indexType(u.typeProp));728 const comps = res.comps.slice(0, 5).map((c) => ({729 adresse: [c.street, c.city].filter(Boolean).join(", "),730 vendu_le: c.date,731 prix_paye: c.amount,732 prix_ajuste: r(c.adjustedPrice),733 distance_m: r(c.distanceM),734 poids_pct: r(c.weight * 100, 1),735 }));736737 // synthèse pré-calculée — les chiffres qui font un avis d'expert738 const medianeMun = (marcheLocal?.valeur_mediane_2026 as number | null) ?? null;739 const h21 = u.history.find((h) => h.year === 2021)?.value;740 const h26 = u.history.find((h) => h.year === 2026)?.value;741 const croissanceProp = h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null;742 const compsM2 = res.comps743 .filter((c) => c.floorArea && c.floorArea > 20)744 .map((c) => c.adjustedPrice / c.floorArea!);745 const benchM2 = compsM2.length746 ? r(compsM2.sort((a, b) => a - b)[Math.floor(compsM2.length / 2)])747 : null;748 const propM2 = u.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null;749750 return {751 evaluation: evalCard(ev),752 marche_local: marcheLocal,753 tendance_marche_provincial: {754 type: indexType(u.typeProp),755 croissance_12_mois_pct: growth(idx, 12),756 croissance_24_mois_pct: growth(idx, 24),757 },758 top_comparables: comps,759 synthese: {760 position_vs_mediane_municipale_pct:761 medianeMun && medianeMun > 0 ? r((res.estimate / medianeMun - 1) * 100, 1) : null,762 croissance_propriete_2021_2026_pct: croissanceProp,763 valeur_m2_propriete: propM2,764 valeur_m2_mediane_comparables: benchM2,765 ecart_m2_vs_comparables_pct:766 propM2 && benchM2 ? r((propM2 / benchM2 - 1) * 100, 1) : null,767 },768 };769 }770771 case "liens_rapports": {772 const id = resolveId(input.id);773 const u = getUnit(id);774 if (!u) return { erreur: ID_ERR };775 return {776 ...liens(id),777 note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.",778 };779 }780781 /* ------------------------------------------------ méthode du coût (IMM1033) */782 case "cout_estimer_propriete": {783 const id = resolveId(input.id);784 const p = prefillFromProperty(id);785 if (!p) return { erreur: ID_ERR };786 const b = p.input.building;787 const src = p.input.attributeSources;788 if (input.qualite) { b.quality = String(input.qualite) as Quality; b.kitchenQuality = b.quality; b.bathroomQuality = b.quality; src.quality = "user"; src.kitchenQuality = "user"; }789 if (input.sous_sol) { b.basement = String(input.sous_sol) as CostInput["building"]["basement"]; b.basementFinishedPct = b.basement === "finished" || b.basement === "walkout" ? 1 : b.basement === "partial" ? 0.5 : 0; src.basement = "user"; }790 if (input.garage) { b.garage = { type: String(input.garage) as CostInput["building"]["garage"]["type"], spaces: input.garage === "none" ? 0 : 1, areaSqft: null }; src.garage = "user"; }791 if (input.revetement) { b.siding = { [String(input.revetement)]: 1 }; src.siding = "user"; }792 if (input.toiture) { b.roof = String(input.toiture) as CostInput["building"]["roof"]; src.roof = "user"; }793 if (input.chauffage) { b.heating = String(input.chauffage) as CostInput["building"]["heating"]; src.heating = "user"; }794 if (input.salles_de_bain != null) { b.bathrooms = Number(input.salles_de_bain); src.bathrooms = "user"; }795 if (input.methode_depreciation) p.input.depreciation.method = String(input.methode_depreciation) as "age_life" | "components";796 if (input.age_effectif != null) { p.input.depreciation.effectiveAge = Number(input.age_effectif); p.input.depreciation.effectiveAgeSource = "user"; }797 const est = runEstimate(sanitizeInput(p.input), { save: true });798 return {799 ...coutCard(est),800 caracteristiques_supposees: p.missing.filter((m) => !["landValue"].includes(m)),801 note: "Les caractéristiques « supposées » sont des hypothèses par défaut (le rôle MAMH ne les publie pas) : l'étudiant peut les corriger dans la page /cout. Coût de REMPLACEMENT (composantes modernes équivalentes), pas de reproduction.",802 };803 }804805 case "cout_estimer_construction": {806 const inp = defaultInput();807 const b = inp.building;808 const src = inp.attributeSources;809 inp.municipality = input.municipalite ? String(input.municipalite) : null;810 if (input.type) { b.type = String(input.type) as CostInput["building"]["type"]; src.type = "user"; }811 if (input.qualite) { b.quality = String(input.qualite) as Quality; b.kitchenQuality = b.quality; b.bathroomQuality = b.quality; src.quality = "user"; src.kitchenQuality = "user"; }812 if (input.aire_pi2) { b.grossFloorAreaSqft = Number(input.aire_pi2); src.grossFloorAreaSqft = "user"; }813 if (input.etages) { b.stories = Number(input.etages); src.stories = "user"; }814 if (input.annee_construction) { b.yearBuilt = Number(input.annee_construction); src.yearBuilt = "user"; }815 if (input.sous_sol) { b.basement = String(input.sous_sol) as CostInput["building"]["basement"]; b.basementFinishedPct = b.basement === "finished" || b.basement === "walkout" ? 1 : b.basement === "partial" ? 0.5 : 0; src.basement = "user"; }816 if (input.garage) { b.garage = { type: String(input.garage) as CostInput["building"]["garage"]["type"], spaces: input.garage === "none" ? 0 : Number(input.places_garage) || 1, areaSqft: null }; src.garage = "user"; }817 if (input.revetement) { b.siding = { [String(input.revetement)]: 1 }; src.siding = "user"; }818 if (input.toiture) { b.roof = String(input.toiture) as CostInput["building"]["roof"]; src.roof = "user"; }819 if (input.chauffage) { b.heating = String(input.chauffage) as CostInput["building"]["heating"]; src.heating = "user"; }820 if (input.salles_de_bain != null) { b.bathrooms = Number(input.salles_de_bain); src.bathrooms = "user"; }821 if (input.salles_d_eau != null) b.powderRooms = Number(input.salles_d_eau);822 if (input.logements) { b.units = Number(input.logements); b.kitchens = b.units; }823 if (input.valeur_terrain) inp.land = { value: Number(input.valeur_terrain), source: "user", rollYear: null, method: "valeur fournie par l'utilisateur" };824 const est = runEstimate(sanitizeInput(inp), { save: true });825 return { ...coutCard(est), note: "Bâtiment hypothétique : tout paramètre non fourni est une hypothèse par défaut. Sans valeur de terrain, l'indication par le coût = valeur dépréciée du bâtiment seulement." };826 }827828 case "cout_detail_assemblage": {829 const code = String(input.code ?? "").toUpperCase().trim();830 const ctx = buildContext({ locationCode: input.localisation ? String(input.localisation) : "QC-MTL" });831 const a = ctx.assemblies.get(code);832 if (!a) {833 const near = [...ctx.assemblies.keys()].filter((k) => k.includes(code.split("-")[0] ?? "")).slice(0, 12);834 return { erreur: `Assemblage « ${code} » introuvable.`, codes_proches: near };835 }836 const u = assemblyUnitCost(a, ctx);837 const f = ctx.location;838 return {839 code: a.code, nom: a.nameFr, categorie: categoryLabel(a.category, true), unite: a.unit, description: a.descriptionFr, vie_economique_ans: a.economicLife, groupe_condition: a.conditionGroup,840 localisation: { code: f.code, nom: f.nameFr, facteur_materiaux: f.materialFactor, facteur_main_doeuvre: f.labourFactor, facteur_equipement: f.equipmentFactor },841 cout_unitaire: { materiaux: u.material, main_doeuvre: u.labour, equipement: u.equipment, direct: u.direct, direct_localise: r(u.material * f.materialFactor + u.labour * f.labourFactor + u.equipment * f.equipmentFactor, 2), incertitude_pct: r(u.sigmaPct * 100, 1), part_materiaux_observes_pct: r(u.observedShare * 100) },842 composants: u.components.map((c) => ({843 article: c.itemCode || null, nom: c.nameFr, quantite_par_unite: c.itemCode ? r(c.quantity, 4) : undefined, pertes_pct: c.itemCode ? r(c.wasteFactor * 100) : undefined, unite: c.itemCode ? c.unit : undefined,844 prix_unitaire: c.itemCode ? r(c.unitPrice, 2) : undefined, cout_materiau: c.itemCode ? r(c.materialCost, 2) : undefined,845 nature_prix: c.itemCode ? natureLabel(c.provenance.kind) : undefined, source: c.itemCode ? c.provenance.source : undefined, date: c.itemCode ? c.provenance.observedAt ?? null : undefined, note: c.provenance.note ?? undefined,846 heures: c.labourHours || undefined, metier: c.trade ? tradeLabel(c.trade, true) : undefined, taux_employeur: c.hourlyRate ?? undefined, cout_main_doeuvre: c.labourCost || undefined, nature_taux: c.rateProvenance ? natureLabel(c.rateProvenance.kind) + " — " + c.rateProvenance.source : undefined,847 equipement: c.equipmentCost || undefined,848 })),849 legende: "● observé/officiel · ◐ calculé · ○ indexé (StatCan) · ◇ référence interne = hypothèse, pas une observation",850 };851 }852853 case "cout_chercher_articles": {854 const q = String(input.requete ?? "");855 const limit = Math.min(Number(input.limite) || 8, 20);856 const items = searchItems(q, limit);857 if (!items.length) return { resultats: [], note: "Aucun article — essayer un mot plus court (gypse, isolant, bardeau, fenêtre, béton…)." };858 const ctx = buildContext({ locationCode: "QC-MTL" });859 return {860 date_prix: ctx.asOf,861 resultats: items.map((i) => {862 const p = ctx.prices.get(i.code);863 return { code: i.code, nom: i.nameFr, unite: i.unit, categorie: categoryLabel(i.category, true), prix: p ? r(p.price, 2) : null, min: p ? r(p.low, 2) : null, max: p ? r(p.high, 2) : null, nature_prix: p ? natureLabel(p.kind) : "donnée non disponible", nb_sources: p?.sourceCount ?? 0, derniere_observation: p?.observedAt ?? null, sources: p?.sources.map((s) => `${s.source} ${r(s.price, 2)} $ (${s.observedAt})`).slice(0, 4), note: p?.note ?? undefined };864 }),865 legende: "● observé chez un détaillant · ○ indexé par StatCan 18-10-0289 · ◇ prix de référence interne = hypothèse documentée, non observée",866 };867 }868869 case "cout_taux_main_doeuvre": {870 const asOf = costToday();871 if (input.metier) {872 const code = String(input.metier).toLowerCase();873 const t = TRADES.find((x) => x.code === code || x.fr.toLowerCase().includes(code));874 if (!t) return { erreur: `Métier inconnu — codes : ${TRADES.map((x) => x.code).join(", ")}` };875 const hist = labourHistory(t.code);876 if (!hist.length) return { metier: t.fr, erreur: "Donnée non disponible : aucune grille synchronisée pour ce métier (le taux de repli 85 $/h — hypothèse — est alors utilisé par le moteur)." };877 return {878 metier: t.fr, code: t.code,879 grilles: hist.map((h) => ({ secteur: h.sector, classification: h.classification, en_vigueur: h.effectiveFrom, salaire_base: h.baseWage, indemnite_vacances: h.vacationCost, avantages_sociaux: h.benefitsCost, cotisations_employeur: h.employerContributions, autres: h.otherContributions, cout_horaire_employeur_total: h.totalEmployerCost, source: h.source, url: h.sourceUrl })),880 note: "Le coût employeur complet = salaire conventionné CCQ + congés (13 %) + avantages sociaux + AE, RQAP, RRQ, FSS, CNESST, prélèvement CCQ, AECQ, fonds de formation. C'est ce coût — pas le salaire nu — que le moteur multiplie par les heures.",881 };882 }883 const rates = allLabourRates(asOf).filter((x) => x.classification === "compagnon");884 if (!rates.length) return { erreur: "Donnée non disponible : aucune grille de main-d'œuvre synchronisée (le moteur utilise un taux de repli de 85 $/h, hypothèse)." };885 return {886 date: asOf,887 taux: rates.map((h) => ({ metier: h.tradeNameFr, code: h.tradeCode, secteur: h.sector, en_vigueur: h.effectiveFrom, salaire_base: h.baseWage, cout_horaire_employeur_total: h.totalEmployerCost, source: h.source })),888 note: "Taux uniformes pour tout le Québec (conventions collectives, Loi R-20) ; secteur résidentiel léger = maisons ≤ 6 logements.",889 };890 }891892 case "cout_historique_materiau": {893 const code = String(input.code ?? "").toUpperCase().trim();894 const item = getItem(code);895 if (!item) return { erreur: `Article « ${code} » introuvable — utiliser cout_chercher_articles.` };896 const hist = priceHistory(code);897 const obs = hist.filter((h) => h.kind !== "reference");898 return {899 article: { code: item.code, nom: item.nameFr, unite: item.unit, prix_reference_interne_hypothese: item.referencePrice, note_reference: item.referenceNote },900 nb_observations: obs.length,901 historique: obs.slice(-40).map((h) => ({ date: h.date, prix: r(h.price, 2), source: h.source, unite_source: h.sourceUnit, facteur_conversion: r(h.conversionFactor, 5), regulier: h.regular, nature: h.kind, aberrant: h.outlier || undefined, url: h.url })),902 variation_pct: obs.length >= 2 && obs[0].price > 0 ? r((obs[obs.length - 1].price / obs[0].price - 1) * 100, 1) : null,903 note: obs.length ? undefined : "Donnée non disponible : aucune observation détaillant pour cet article — seul le prix de référence interne (hypothèse) existe.",904 };905 }906907 case "cout_expliquer_depreciation": {908 const id = String(input.estimation_id ?? "");909 const est = getEstimate(id);910 if (!est) return { erreur: "Estimation introuvable — relancer cout_estimer_propriete / cout_estimer_construction et reprendre l'id retourné." };911 const d = est.depreciation;912 return {913 estimation_id: est.id, rcn: est.replacementCostNew,914 ages: { chronologique: d.chronologicalAge, effectif_retenu: d.effectiveAge, vie_economique: d.economicLife, source_age_effectif: est.input.depreciation.effectiveAgeSource },915 methode: d.method === "components" ? "composante par composante" : "âge-vie simple (âge effectif ÷ vie économique, plafonné à 90 %)",916 physique: { pct: d.physicalPct, montant: d.physical },917 composantes: d.components.map((c) => ({ composante: c.labelFr, rcn: c.rcn, vie: c.economicLife, condition: c.condition, age_effectif: c.effectiveAge, depreciation_pct: c.depreciationPct, depreciation: c.depreciation, restant: c.remaining, regle: c.rule })),918 fonctionnelle: { curable: d.functionalCurable, incurable: d.functionalIncurable, total: d.functional, deficiences: est.input.depreciation.functional.map((f) => ({ type: f.type, curable: f.curable, cout_correction: f.costToCure, perte_valeur: f.valueLoss })) },919 externe: { montant: d.external, note: est.input.depreciation.externalNote || "aucune preuve saisie → 0 $" },920 total: d.total, valeur_depreciee_batiment: d.depreciatedImprovementValue, terrain: est.landValue, indication_par_le_cout: est.costApproachValue,921 regles_condition_age_effectif: CONDITIONS.map((c) => `${c.fr} → ${c.effectiveAgeRatio} × vie`).join(" · "),922 definitions: {923 deterioration_physique: "Usure et vieillissement des composantes (toiture, fenêtres, finis…) ; mesurée par âge-vie (âge effectif ÷ vie économique) ou composante par composante.",924 desuetude_fonctionnelle: "Perte de valeur due à la conception ou aux caractéristiques du bâtiment lui-même (trop peu de salles de bain, configuration dépassée, suramélioration) ; curable si le coût de correction < gain de valeur, sinon incurable.",925 desuetude_externe: "Perte de valeur due à des facteurs hors de la propriété (nuisance, marché structurellement faible) ; jamais calculée automatiquement sans preuve.",926 cout_reproduction_vs_remplacement: "Reproduction = reconstruire à l'identique (mêmes matériaux et méthodes) ; remplacement = bâtiment d'utilité équivalente avec les matériaux et normes actuels. UQO Éval calcule le coût de REMPLACEMENT.",927 },928 };929 }930931 case "cout_comparer_localisations": {932 const codes = (input.codes as string[] | undefined)?.map((c) => String(c).toUpperCase());933 const locs = loadLocations().filter((l) => !codes?.length || codes.includes(l.code));934 if (!locs.length) return { erreur: "Aucune localisation — codes : " + loadLocations().map((l) => l.code).join(", ") };935 const inp = defaultInput();936 const { lines } = deriveQuantities(inp.building);937 const ctxBase = buildContext({ locationCode: "QC-MTL" });938 let baseDirect = 0;939 const perLine = lines.map((q) => { const a = ctxBase.assemblies.get(q.assemblyCode); if (!a) return null; const u = assemblyUnitCost(a, ctxBase); baseDirect += u.direct * q.quantity; return { q: q.quantity, u }; }).filter((x): x is { q: number; u: ReturnType<typeof assemblyUnitCost> } => !!x);940 return {941 maison_type: "détachée standard 1 800 pi², 2 étages, sous-sol non fini, vinyle, bardeau, plinthes électriques (prix au " + ctxBase.asOf + ")",942 regions: locs.map((l) => {943 const direct = perLine.reduce((s, x) => s + x.q * (x.u.material * l.materialFactor + x.u.labour * l.labourFactor + x.u.equipment * l.equipmentFactor), 0);944 return { code: l.code, region: l.nameFr, facteur_materiaux: l.materialFactor, facteur_main_doeuvre: l.labourFactor, facteur_equipement: l.equipmentFactor, facteur_global: l.overallFactor, confiance: l.confidence, cout_direct_maison_type: r(direct), ecart_vs_montreal_pct: baseDirect ? r((direct / baseDirect - 1) * 100, 1) : null };945 }),946 note: "Main-d'œuvre uniforme au Québec (conventions collectives CCQ) sauf régions éloignées ; facteurs matériaux/équipement = surcoût de transport (hypothèse UQO Éval, modifiable).",947 };948 }949950 case "cout_sources": {951 const rows = getCostDb().prepare(`SELECT s.name, s.source_type type, s.license_status licence, s.is_active active, s.last_successful_sync sync, s.base_url url,952 (SELECT COUNT(*) FROM cost_item_prices p WHERE p.source_id=s.id) + (SELECT COUNT(*) FROM labour_rates l WHERE l.source_id=s.id) obs953 FROM cost_sources s ORDER BY s.priority, s.name`).all() as { name: string; type: string; licence: string; active: number; sync: string | null; url: string | null; obs: number }[];954 const idx = getCostDb().prepare("SELECT COUNT(DISTINCT index_code) n, MAX(period) last FROM construction_cost_indices").get() as { n: number; last: string | null };955 return {956 sources: rows.map((x) => ({ source: x.name, type: x.type, licence: x.licence, active: x.active === 1, derniere_synchro: x.sync ?? "jamais", observations: x.obs, url: x.url })),957 indices_statcan: { series: idx.n, derniere_periode: idx.last },958 note: "Aucun prix n'est inventé : observé (détaillants), officiel (APCHQ/CCQ, StatCan) ou prix de référence interne explicitement étiqueté « hypothèse ». Firecrawl ne tourne qu'en arrière-plan ; l'interface lit la base locale.",959 };960 }961962 default:963 return { erreur: `Outil inconnu : ${name}` };964 }965}966967// réexport pratique pour la route968export { indexType };969