SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
53.3 KB · 969 lines typescript
Raw Blame History
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