// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Ka — la panoplie d'outils de l'agent IA de Vrai-Prix. * Chaque outil est exécuté côté serveur, directement sur la base SQLite * (3,75 M unités) et le moteur d'estimation. Les sorties sont des JSON * compacts : Ka lit vite, coûte peu, et cite des chiffres exacts. */ import Anthropic from "@anthropic-ai/sdk"; import { getDb, getMarketIndex, getUnit, searchUnits } from "../db"; import { estimateByUnitId, estimateManual, estimatePortfolio, indexType, type UnitEstimate, } from "../estimator"; import stats from "../../data/stats.json"; /* ---------------------------------------------------------------- helpers */ const r = (n: number | null | undefined, d = 0): number | null => n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d)); /** Mots génériques d'adresse ignorés lors de la recherche approximative. */ const STOP_WORDS = new Set([ "rue", "avenue", "av", "boulevard", "boul", "blvd", "chemin", "ch", "route", "rte", "rang", "place", "montee", "montée", "impasse", "croissant", "terrasse", "allée", "allee", "cote", "côte", "carre", "carré", "du", "de", "des", "la", "le", "les", "l", "d", "au", "aux", "à", "a", "et", "app", "apt", "st", "ste", ]); function tokenize(q: string): string[] { return q .replace(/[^\p{L}\p{N}\s'-]/gu, " ") .trim() .split(/\s+/) .filter((t) => t.length > 0); } function ftsAnd(tokens: string[], limit: number) { if (!tokens.length) return []; const match = tokens.map((t) => `"${t}"*`).join(" "); return getDb() .prepare( `SELECT u.*, bm25(units_fts) AS score FROM units_fts JOIN units u ON u.rowid = units_fts.rowid WHERE units_fts MATCH ? ORDER BY score LIMIT ?` ) .all(match, limit) as ReturnType; } /** * Recherche à relaxation progressive : exacte, puis sans mots génériques * (rue/avenue/de…), puis sans numéro civique, puis nom de voie seul. * Retourne toujours ce qui s'en rapproche le plus plutôt que rien. */ function searchFlexible( q: string, limit: number ): { rows: ReturnType; niveau: "exacte" | "approximative" } { const strict = searchUnits(q, limit); if (strict.length) return { rows: strict, niveau: "exacte" }; const tokens = tokenize(q); const sansStop = tokens.filter((t) => !STOP_WORDS.has(t.toLowerCase())); const sansNumero = sansStop.filter((t) => !/^\d+[a-z]?$/i.test(t)); const nomsSeuls = sansNumero.filter((t) => t.length >= 4); for (const attempt of [sansStop, sansNumero, nomsSeuls, nomsSeuls.slice(0, 1)]) { if (!attempt.length) continue; if (attempt.length === tokens.length) continue; // déjà tenté en strict const rows = ftsAnd(attempt, limit); if (rows.length) return { rows, niveau: "approximative" }; } return { rows: [], niveau: "approximative" }; } /** * Résout un id éventuellement mal recopié par le modèle : essai brut, * puis version chiffres seulement (les id provinciaux sont numériques). */ function resolveId(raw: unknown): string { const a = String(raw ?? "").trim(); if (getUnit(a)) return a; const d = a.replace(/\D/g, ""); if (d && d !== a && getUnit(d)) return d; return a; } const ID_ERR = "Unité introuvable — l'id est peut-être mal recopié. Relance chercher_propriete et copie le champ `id` EXACTEMENT tel quel."; function liens(id: string) { const e = encodeURIComponent(id); return { fiche_complete: `https://www.vrai-prix.com/estimation/${e}`, rapport_standard_pdf: `https://www.vrai-prix.com/api/report?id=${e}`, rapport_professionnel_pdf: `https://www.vrai-prix.com/api/report/pro?id=${e}`, }; } function unitCard(u: ReturnType[number]) { return { id: u.id_provinc, adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "), municipalite: u.municipalite, arrondissement: u.arrond, type: u.type_prop, usage: u.cubf_libelle, annee_construction: u.annee_construction, aire_habitable_m2: r(u.aire_etages_m2, 1), terrain_m2: r(u.superficie_terrain_m2), logements: u.nb_logements, valeur_role_2026: u.valeur_role, }; } function evalCard(e: UnitEstimate) { const u = e.unit; const res = e.result; return { unite: u ? { id: u.id, adresse: u.adresse, municipalite: u.municipalite, type: u.typeProp, usage: u.cubfLibelle, annee_construction: u.anneeConstruction, aire_habitable_m2: r(u.aireEtagesM2, 1), terrain_m2: r(u.superficieTerrainM2), logements: u.nbLogements, etages: u.specs.nbEtages, genre_construction: u.specs.genreConstruction, lien_physique: u.specs.lienPhysique, valeur_role_2026: u.valeurRole, valeur_terrain_role: u.specs.valeurTerrain, valeur_batiment_role: u.specs.valeurBatiment, valeur_role_anterieure: u.specs.valeurAnterieure, liens: liens(u.id), } : null, estimation: { valeur: r(res.estimate), fourchette_basse_p10: r(res.low), fourchette_haute_p90: r(res.high), confiance_pct: res.confidencePct, confiance_niveau: res.confidenceLevel, part_modele_hedonique: r(res.modelEstimate), part_comparables: r(res.compsEstimate), poids_modele: r(res.modelWeight, 2), n_comparables_utilises: res.nCompsUsed, dispersion_comparables_pct: r(res.compsDispersionPct, 1), valeur_par_m2: u?.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null, ecart_vs_role_pct: u?.valeurRole ? r((res.estimate / u.valeurRole - 1) * 100, 1) : null, }, historique_2021_2026: u?.history ?? null, }; } /* ------------------------------------------------------------ tool defs */ export const KA_TOOLS: Anthropic.Tool[] = [ { name: "chercher_propriete", description: "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é.", input_schema: { type: "object", properties: { requete: { type: "string", description: "Adresse ou fragment d'adresse, ex. « 861 route Elgin Saint-Pamphile »", }, limite: { type: "number", description: "Nombre max de résultats (défaut 6, max 12)" }, }, required: ["requete"], }, }, { name: "evaluer_propriete", description: "É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.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } }, required: ["id"], }, }, { name: "comparables_detailles", description: "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.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" }, max: { type: "number", description: "Nombre max de comparables (défaut 8)" }, }, required: ["id"], }, }, { name: "indice_marche", description: "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 ? ».", input_schema: { type: "object", properties: { type: { type: "string", enum: ["unifamilial", "condo", "plex"], description: "Type de marché", }, }, required: ["type"], }, }, { name: "stats_municipalite", description: "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.", input_schema: { type: "object", properties: { municipalite: { type: "string", description: "Nom exact de la municipalité, ex. « Lévis »" } }, required: ["municipalite"], }, }, { name: "stats_provinciales", description: "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.", input_schema: { type: "object", properties: {} }, }, { name: "evaluer_parc", description: "É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.", input_schema: { type: "object", properties: { ids: { type: "array", items: { type: "string" }, description: "Identifiants provinciaux des unités du parc", }, }, required: ["ids"], }, }, { name: "comparer_proprietes", description: "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.", input_schema: { type: "object", properties: { ids: { type: "array", items: { type: "string" }, description: "2 à 4 identifiants" }, }, required: ["ids"], }, }, { name: "estimation_manuelle", description: "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.", input_schema: { type: "object", properties: { municipalite: { type: "string" }, type: { type: "string", enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"], }, aire_habitable_m2: { type: "number" }, annee_construction: { type: "number" }, terrain_m2: { type: "number" }, }, required: ["municipalite", "type"], }, }, { name: "chercher_proprietes_secteur", description: "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$ ».", input_schema: { type: "object", properties: { municipalite: { type: "string" }, type: { type: "string", enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"], }, valeur_max: { type: "number", description: "Budget maximal ($)" }, valeur_min: { type: "number", description: "Valeur minimale ($)" }, aire_min_m2: { type: "number" }, limite: { type: "number", description: "Max résultats (défaut 8, max 15)" }, }, required: ["municipalite"], }, }, { name: "dossier_complet", description: "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.", input_schema: { type: "object", properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } }, required: ["id"], }, }, { name: "liens_rapports", description: "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.", input_schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], }, }, ]; /* ------------------------------------------------------------ execution */ type J = Record; function growth(idx: { month: string; idx: number }[], months: number): number | null { if (idx.length < months + 1) return null; const last = idx[idx.length - 1]; const past = idx[idx.length - 1 - months]; return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null; } export function runKaTool(name: string, input: J): unknown { switch (name) { case "chercher_propriete": { const limit = Math.min(Number(input.limite) || 6, 12); const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit); if (!rows.length) return { resultats: [], correspondance: "aucune", conseil: "Aucun résultat, même approximatif. Essayer une graphie différente (« numéro rue municipalité »), ou proposer estimation_manuelle.", }; return { correspondance: niveau, ...(niveau === "approximative" ? { 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.", } : {}), resultats: rows.map(unitCard), }; } case "evaluer_propriete": { const e = estimateByUnitId(resolveId(input.id)); if (!e) return { erreur: ID_ERR }; return evalCard(e); } case "comparables_detailles": { const e = estimateByUnitId(resolveId(input.id)); if (!e) return { erreur: ID_ERR }; const max = Math.min(Number(input.max) || 8, 15); return { n_utilises: e.result.nCompsUsed, comparables: e.result.comps.slice(0, max).map((c) => ({ adresse: [c.street, c.city].filter(Boolean).join(", "), vendu_le: c.date, prix_paye: c.amount, distance_m: r(c.distanceM), il_y_a_mois: c.monthsAgo, ajust_marche: r(c.adjTime), ajust_superficie: r(c.adjArea), ajust_age: r(c.adjAge), prix_ajuste: r(c.adjustedPrice), poids_pct: r(c.weight * 100, 1), })), }; } case "indice_marche": { const idx = getMarketIndex(String(input.type ?? "unifamilial")); if (!idx.length) return { erreur: "Type d'indice inconnu." }; const last12 = idx.slice(-13); return { type: input.type, croissance_12_mois_pct: growth(idx, 12), croissance_24_mois_pct: growth(idx, 24), serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })), note: "Indice $/m² lissé 3 mois, 1.0 = niveau actuel du marché.", }; } case "stats_municipalite": { const mun = String(input.municipalite ?? ""); const g = getDb() .prepare( `SELECT COUNT(*) n, SUM(est_2026) total, SUM(valeur_role) role_total FROM units WHERE municipalite = ? COLLATE NOCASE` ) .get(mun) as { n: number; total: number | null; role_total: number | null }; if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` }; const med = getDb() .prepare( `SELECT est_2026 v FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL ORDER BY est_2026 LIMIT 1 OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2` ) .get(mun, mun) as { v: number } | undefined; const types = getDb() .prepare( `SELECT type_prop, COUNT(*) n, SUM(est_2026) total FROM units WHERE municipalite = ? COLLATE NOCASE GROUP BY type_prop ORDER BY total DESC` ) .all(mun) as { type_prop: string; n: number; total: number | null }[]; return { municipalite: mun, unites: g.n, valeur_totale_estimee_2026: r(g.total), valeur_role_totale: r(g.role_total), valeur_mediane_2026: med?.v ?? null, par_type: types.map((t) => ({ type: t.type_prop, unites: t.n, total: r(t.total) })), }; } case "stats_provinciales": { const s = stats as J; const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10); return { valeur_totale_immobilier_quebec_2026: s.valeur_totale_2026, valeur_role_totale: s.valeur_role_totale, unites_evaluees: s.unites, logements: s.logements, valeur_mediane_2026: s.valeur_mediane_2026, croissance_2021_2026_pct: s.croissance_2021_2026_pct, municipalites: s.municipalites, par_type: s.par_type, top_10_villes: villes, }; } case "evaluer_parc": { const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId); if (ids.length < 1) return { erreur: "Fournir au moins un id." }; const p = estimatePortfolio(ids); if (!p.items.length) return { erreur: "Aucune unité valide trouvée." }; const a = p.aggregates; return { unites_evaluees: a.count, valeur_totale: r(a.totalEstimate), fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) }, valeur_role_totale: r(a.totalRole), ecart_vs_role_pct: r(a.ecartRolePct, 1), confiance: { pct: a.confidencePct, niveau: a.confidenceLevel }, logements_totaux: a.totalDwellings, aire_totale_m2: a.totalFloorArea, croissance_parc_2021_2026_pct: r(a.growthPct, 1), par_ville: a.municipalities, par_type: a.types, detail: p.items.map((i) => ({ id: i.unit!.id, adresse: i.unit!.adresse, valeur: r(i.result.estimate), confiance: i.result.confidenceLevel, })), }; } case "comparer_proprietes": { const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId); if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; return { comparaison: ids.map((id) => { const e = estimateByUnitId(id); if (!e || !e.unit) return { id, erreur: "introuvable" }; const h21 = e.unit.history.find((h) => h.year === 2021)?.value; const h26 = e.unit.history.find((h) => h.year === 2026)?.value; return { id, adresse: e.unit.adresse, municipalite: e.unit.municipalite, valeur: r(e.result.estimate), valeur_par_m2: e.unit.aireEtagesM2 ? r(e.result.estimate / e.unit.aireEtagesM2) : null, ecart_vs_role_pct: e.unit.valeurRole ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1) : null, confiance: e.result.confidenceLevel, croissance_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null, }; }), }; } case "estimation_manuelle": { const e = estimateManual({ municipality: String(input.municipalite ?? ""), typeProp: String(input.type ?? "unifamilial"), floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined, yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined, landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined, }); if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." }; return evalCard(e); } case "chercher_proprietes_secteur": { const mun = String(input.municipalite ?? ""); const limit = Math.min(Number(input.limite) || 8, 15); const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"]; const args: unknown[] = [mun]; if (input.type) { conds.push("type_prop = ?"); args.push(String(input.type)); } if (input.valeur_max) { conds.push("est_2026 <= ?"); args.push(Number(input.valeur_max)); } if (input.valeur_min) { conds.push("est_2026 >= ?"); args.push(Number(input.valeur_min)); } if (input.aire_min_m2) { conds.push("aire_etages_m2 >= ?"); args.push(Number(input.aire_min_m2)); } const rows = getDb() .prepare( `SELECT * FROM units WHERE ${conds.join(" AND ")} ORDER BY est_2026 DESC LIMIT ?` ) .all(...args, limit) as Parameters[0][]; if (!rows.length) return { resultats: [], note: "Aucune propriété ne correspond aux critères." }; return { resultats: rows.map((u) => ({ ...unitCard(u), valeur_estimee_2026: r(u.est_2026) })), }; } case "dossier_complet": { const id = resolveId(input.id); const ev = estimateByUnitId(id); if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; const u = ev.unit; const res = ev.result; const marcheLocal = u.municipalite ? (runKaTool("stats_municipalite", { municipalite: u.municipalite }) as Record) : null; const idx = getMarketIndex(indexType(u.typeProp)); const comps = res.comps.slice(0, 5).map((c) => ({ adresse: [c.street, c.city].filter(Boolean).join(", "), vendu_le: c.date, prix_paye: c.amount, prix_ajuste: r(c.adjustedPrice), distance_m: r(c.distanceM), poids_pct: r(c.weight * 100, 1), })); // synthèse pré-calculée — les chiffres qui font un avis d'expert const medianeMun = (marcheLocal?.valeur_mediane_2026 as number | null) ?? null; const h21 = u.history.find((h) => h.year === 2021)?.value; const h26 = u.history.find((h) => h.year === 2026)?.value; const croissanceProp = h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null; const compsM2 = res.comps .filter((c) => c.floorArea && c.floorArea > 20) .map((c) => c.adjustedPrice / c.floorArea!); const benchM2 = compsM2.length ? r(compsM2.sort((a, b) => a - b)[Math.floor(compsM2.length / 2)]) : null; const propM2 = u.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null; return { evaluation: evalCard(ev), marche_local: marcheLocal, tendance_marche_provincial: { type: indexType(u.typeProp), croissance_12_mois_pct: growth(idx, 12), croissance_24_mois_pct: growth(idx, 24), }, top_comparables: comps, synthese: { position_vs_mediane_municipale_pct: medianeMun && medianeMun > 0 ? r((res.estimate / medianeMun - 1) * 100, 1) : null, croissance_propriete_2021_2026_pct: croissanceProp, valeur_m2_propriete: propM2, valeur_m2_mediane_comparables: benchM2, ecart_m2_vs_comparables_pct: propM2 && benchM2 ? r((propM2 / benchM2 - 1) * 100, 1) : null, }, }; } case "liens_rapports": { const id = resolveId(input.id); const u = getUnit(id); if (!u) return { erreur: ID_ERR }; return { ...liens(id), note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.", }; } default: return { erreur: `Outil inconnu : ${name}` }; } } // réexport pratique pour la route export { indexType };