Ka — agent IA conversationnel spécialisé plex (Claude Haiku 4.5, streaming, 12 outils)
Page /ka + route /api/ka : boucle agentique en streaming SSE branchée sur la base des plex et le moteur en ratio (recherche FTS, évaluation porte par porte, pro forma investisseur complet paramétrable, comparables avec ajustement portes, marché des plex, stats par gabarit, parc, recherche par secteur avec portes/budget, rapports). Prompt caching sur system+outils, clé API via variable d'environnement (jamais versionnée). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 9 changed files with +1,210 and −1
modified
app/package-lock.json
+73 −1
@@ -8,6 +8,7 @@ | ||
| 8 | 8 | "name": "valoplex", |
| 9 | 9 | "version": "0.1.0", |
| 10 | 10 | "dependencies": { |
| 11 | + "@anthropic-ai/sdk": "^0.116.0", | |
| 11 | 12 | "better-sqlite3": "^13.0.3", |
| 12 | 13 | "next": "16.3.0", |
| 13 | 14 | "pdfkit": "^0.19.1", |
@@ -42,6 +43,27 @@ | ||
| 42 | 43 | "url": "https://github.com/sponsors/sindresorhus" |
| 43 | 44 | } |
| 44 | 45 | }, |
| 46 | + "node_modules/@anthropic-ai/sdk": { | |
| 47 | + "version": "0.116.0", | |
| 48 | + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.116.0.tgz", | |
| 49 | + "integrity": "sha512-4UEapYQ+epLEMsAuLZDvW8ExVSOtHD8a7zTyLzhw0H9RXJ1eilPgmqhjwgcdg22diwx13spw6fJ4rONZ+bS7Ww==", | |
| 50 | + "license": "MIT", | |
| 51 | + "dependencies": { | |
| 52 | + "json-schema-to-ts": "^3.1.1", | |
| 53 | + "standardwebhooks": "^1.0.0" | |
| 54 | + }, | |
| 55 | + "bin": { | |
| 56 | + "anthropic-ai-sdk": "bin/cli" | |
| 57 | + }, | |
| 58 | + "peerDependencies": { | |
| 59 | + "zod": "^3.25.0 || ^4.0.0" | |
| 60 | + }, | |
| 61 | + "peerDependenciesMeta": { | |
| 62 | + "zod": { | |
| 63 | + "optional": true | |
| 64 | + } | |
| 65 | + } | |
| 66 | + }, | |
| 45 | 67 | "node_modules/@babel/code-frame": { |
| 46 | 68 | "version": "7.29.7", |
| 47 | 69 | "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", |
@@ -234,6 +256,15 @@ | ||
| 234 | 256 | "node": ">=6.0.0" |
| 235 | 257 | } |
| 236 | 258 | }, |
| 259 | + "node_modules/@babel/runtime": { | |
| 260 | + "version": "7.29.7", | |
| 261 | + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", | |
| 262 | + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", | |
| 263 | + "license": "MIT", | |
| 264 | + "engines": { | |
| 265 | + "node": ">=6.9.0" | |
| 266 | + } | |
| 267 | + }, | |
| 237 | 268 | "node_modules/@babel/template": { |
| 238 | 269 | "version": "7.29.7", |
| 239 | 270 | "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", |
@@ -1687,6 +1718,12 @@ | ||
| 1687 | 1718 | "dev": true, |
| 1688 | 1719 | "license": "MIT" |
| 1689 | 1720 | }, |
| 1721 | + "node_modules/@stablelib/base64": { | |
| 1722 | + "version": "1.0.1", | |
| 1723 | + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", | |
| 1724 | + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", | |
| 1725 | + "license": "MIT" | |
| 1726 | + }, | |
| 1690 | 1727 | "node_modules/@standard-schema/spec": { |
| 1691 | 1728 | "version": "1.1.0", |
| 1692 | 1729 | "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", |
@@ -4349,6 +4386,12 @@ | ||
| 4349 | 4386 | "dev": true, |
| 4350 | 4387 | "license": "MIT" |
| 4351 | 4388 | }, |
| 4389 | + "node_modules/fast-sha256": { | |
| 4390 | + "version": "1.3.0", | |
| 4391 | + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", | |
| 4392 | + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", | |
| 4393 | + "license": "Unlicense" | |
| 4394 | + }, | |
| 4352 | 4395 | "node_modules/fastq": { |
| 4353 | 4396 | "version": "1.20.1", |
| 4354 | 4397 | "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", |
@@ -5360,6 +5403,19 @@ | ||
| 5360 | 5403 | "dev": true, |
| 5361 | 5404 | "license": "MIT" |
| 5362 | 5405 | }, |
| 5406 | + "node_modules/json-schema-to-ts": { | |
| 5407 | + "version": "3.1.1", | |
| 5408 | + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", | |
| 5409 | + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", | |
| 5410 | + "license": "MIT", | |
| 5411 | + "dependencies": { | |
| 5412 | + "@babel/runtime": "^7.18.3", | |
| 5413 | + "ts-algebra": "^2.0.0" | |
| 5414 | + }, | |
| 5415 | + "engines": { | |
| 5416 | + "node": ">=16" | |
| 5417 | + } | |
| 5418 | + }, | |
| 5363 | 5419 | "node_modules/json-schema-traverse": { |
| 5364 | 5420 | "version": "0.4.1", |
| 5365 | 5421 | "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", |
@@ -6960,6 +7016,16 @@ | ||
| 6960 | 7016 | "dev": true, |
| 6961 | 7017 | "license": "MIT" |
| 6962 | 7018 | }, |
| 7019 | + "node_modules/standardwebhooks": { | |
| 7020 | + "version": "1.0.0", | |
| 7021 | + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", | |
| 7022 | + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", | |
| 7023 | + "license": "MIT", | |
| 7024 | + "dependencies": { | |
| 7025 | + "@stablelib/base64": "^1.0.0", | |
| 7026 | + "fast-sha256": "^1.3.0" | |
| 7027 | + } | |
| 7028 | + }, | |
| 6963 | 7029 | "node_modules/std-env": { |
| 6964 | 7030 | "version": "4.2.0", |
| 6965 | 7031 | "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", |
@@ -7282,6 +7348,12 @@ | ||
| 7282 | 7348 | "node": ">=8.0" |
| 7283 | 7349 | } |
| 7284 | 7350 | }, |
| 7351 | + "node_modules/ts-algebra": { | |
| 7352 | + "version": "2.0.0", | |
| 7353 | + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", | |
| 7354 | + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", | |
| 7355 | + "license": "MIT" | |
| 7356 | + }, | |
| 7285 | 7357 | "node_modules/ts-api-utils": { |
| 7286 | 7358 | "version": "2.5.0", |
| 7287 | 7359 | "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", |
@@ -8210,7 +8282,7 @@ | ||
| 8210 | 8282 | "version": "4.4.3", |
| 8211 | 8283 | "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", |
| 8212 | 8284 | "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", |
| 8213 | − "dev": true, | |
| 8285 | + "devOptional": true, | |
| 8214 | 8286 | "license": "MIT", |
| 8215 | 8287 | "funding": { |
| 8216 | 8288 | "url": "https://github.com/sponsors/colinhacks" |
modified
app/package.json
+1 −0
@@ -10,6 +10,7 @@ | ||
| 10 | 10 | "test": "vitest run" |
| 11 | 11 | }, |
| 12 | 12 | "dependencies": { |
| 13 | + "@anthropic-ai/sdk": "^0.116.0", | |
| 13 | 14 | "better-sqlite3": "^13.0.3", |
| 14 | 15 | "next": "16.3.0", |
| 15 | 16 | "pdfkit": "^0.19.1", |
added
app/src/app/api/ka/route.ts
+163 −0
@@ -0,0 +1,163 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Ka — agent IA de ValoPlex. Boucle agentique en streaming : | |
| 4 | + * Claude Haiku 4.5 + panoplie d'outils plex (base SQLite + moteur + pro forma). | |
| 5 | + * Réponse en flux SSE ; prompt système et outils mis en cache. | |
| 6 | + */ | |
| 7 | +import Anthropic from "@anthropic-ai/sdk"; | |
| 8 | +import { KA_TOOLS, runKaTool } from "@/lib/ka/tools"; | |
| 9 | + | |
| 10 | +export const runtime = "nodejs"; | |
| 11 | +export const maxDuration = 120; | |
| 12 | + | |
| 13 | +const MODEL = "claude-haiku-4-5"; | |
| 14 | +const MAX_TURNS = 8; | |
| 15 | +const MAX_MESSAGES = 40; | |
| 16 | +const MAX_CHARS = 4000; | |
| 17 | + | |
| 18 | +const SYSTEM = `Tu es Ka, l'agent d'évaluation de ValoPlex (www.valoplex.com), créé par Simon-Pierre Boucher. Ta spécialité : les plex du Québec — tout immeuble résidentiel de 2 logements et plus, du duplex à la tour. Tu t'appuies sur 393 867 plex (1 733 744 portes), 91 060 ventes réelles (2021-2026) et un modèle hédonique en ratio calibré par gabarit (erreur médiane 13,8 %). | |
| 19 | + | |
| 20 | +# Ta démarche | |
| 21 | +1. Quand on te donne une adresse (même partielle), appelle chercher_plex immédiatement. | |
| 22 | +2. UN candidat clair → évalue directement (evaluer_plex). Plusieurs candidats plausibles → liste-les (adresse, gabarit, municipalité) et demande de choisir — ne devine jamais. | |
| 23 | +3. Présente le résultat comme un pro du multilogement : valeur estimée en évidence, fourchette P10-P90, confiance A-D (explique : A = très fiable … D = marché mince), et surtout LA VALEUR PAR PORTE — c'est comme ça qu'un investisseur pense. | |
| 24 | +4. Enchaîne avec 2-3 insights concrets : écart vs rôle, tendance 2021→2026, marché local (stats_municipalite), comparables (comparables_detailles). Choisis les outils pertinents, n'appelle pas tout systématiquement. | |
| 25 | +5. Dès qu'une question touche l'investissement, la rentabilité, le financement ou les loyers : proforma_investisseur. C'est ton arme secrète — cashflow/porte/mois, DSCR, liquidités requises « tout le kit », projection 5 ans, points morts. Mets en avant le cashflow par porte et le DSCR. | |
| 26 | +6. Termine une évaluation réussie en offrant les rapports PDF (liens_rapports). | |
| 27 | + | |
| 28 | +# Ton style | |
| 29 | +- Langue de l'utilisateur (français québécois par défaut). Tutoiement chaleureux, vocabulaire d'investisseur immobilier (portes, TGA, RNE, cap rate, cashflow). | |
| 30 | +- Concis et concret : des chiffres, pas du remplissage. Montants à la québécoise (ex. 645 000 $), arrondis intelligemment. | |
| 31 | +- Markdown léger : gras pour les valeurs clés, listes courtes. | |
| 32 | + | |
| 33 | +# Limites (à respecter strictement) | |
| 34 | +- Estimations statistiques indicatives : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un conseil financier ou hypothécaire. | |
| 35 | +- Le pro forma part d'un LOYER IMPLICITE (celui que la valeur suppose au TGA de référence), pas des baux réels — dis-le quand tu le présentes. | |
| 36 | +- Ne réponds qu'aux sujets liés aux plex, à l'immobilier québécois et à ValoPlex. Sinon, ramène poliment la conversation. | |
| 37 | +- N'invente JAMAIS un chiffre : tout montant cité doit venir d'un outil. | |
| 38 | +- Tu ne vois pas l'intérieur des immeubles (rénovations, état des baux) — mentionne-le si pertinent. Pour une unifamiliale ou un condo, dirige vers www.vrai-prix.com.`; | |
| 39 | + | |
| 40 | +interface ClientMsg { | |
| 41 | + role: "user" | "assistant"; | |
| 42 | + content: string; | |
| 43 | +} | |
| 44 | + | |
| 45 | +function sse(controller: ReadableStreamDefaultController, data: unknown) { | |
| 46 | + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)); | |
| 47 | +} | |
| 48 | + | |
| 49 | +export async function POST(req: Request): Promise<Response> { | |
| 50 | + let body: { messages?: ClientMsg[] }; | |
| 51 | + try { | |
| 52 | + body = await req.json(); | |
| 53 | + } catch { | |
| 54 | + return Response.json({ error: "JSON invalide" }, { status: 400 }); | |
| 55 | + } | |
| 56 | + | |
| 57 | + const incoming = (body.messages ?? []) | |
| 58 | + .filter( | |
| 59 | + (m): m is ClientMsg => | |
| 60 | + !!m && | |
| 61 | + (m.role === "user" || m.role === "assistant") && | |
| 62 | + typeof m.content === "string" && | |
| 63 | + m.content.trim().length > 0 | |
| 64 | + ) | |
| 65 | + .slice(-MAX_MESSAGES) | |
| 66 | + .map((m) => ({ role: m.role, content: m.content.slice(0, MAX_CHARS) })); | |
| 67 | + | |
| 68 | + if (!incoming.length || incoming[incoming.length - 1].role !== "user") { | |
| 69 | + return Response.json({ error: "Dernier message utilisateur requis" }, { status: 400 }); | |
| 70 | + } | |
| 71 | + if (!process.env.ANTHROPIC_API_KEY) { | |
| 72 | + return Response.json({ error: "Agent non configuré" }, { status: 503 }); | |
| 73 | + } | |
| 74 | + | |
| 75 | + const client = new Anthropic(); | |
| 76 | + | |
| 77 | + // outils avec point de cache sur le dernier (tools + system cachés ensemble) | |
| 78 | + const tools: Anthropic.ToolUnion[] = KA_TOOLS.map((t, i) => | |
| 79 | + i === KA_TOOLS.length - 1 ? { ...t, cache_control: { type: "ephemeral" as const } } : t | |
| 80 | + ); | |
| 81 | + | |
| 82 | + const messages: Anthropic.MessageParam[] = incoming.map((m) => ({ | |
| 83 | + role: m.role, | |
| 84 | + content: m.content, | |
| 85 | + })); | |
| 86 | + | |
| 87 | + const stream = new ReadableStream({ | |
| 88 | + async start(controller) { | |
| 89 | + try { | |
| 90 | + for (let turn = 0; turn < MAX_TURNS; turn++) { | |
| 91 | + const msgStream = client.messages.stream({ | |
| 92 | + model: MODEL, | |
| 93 | + max_tokens: 1600, | |
| 94 | + system: [ | |
| 95 | + { | |
| 96 | + type: "text", | |
| 97 | + text: SYSTEM, | |
| 98 | + cache_control: { type: "ephemeral" }, | |
| 99 | + }, | |
| 100 | + ], | |
| 101 | + tools, | |
| 102 | + messages, | |
| 103 | + }); | |
| 104 | + | |
| 105 | + msgStream.on("text", (delta) => sse(controller, { type: "text", text: delta })); | |
| 106 | + | |
| 107 | + const final = await msgStream.finalMessage(); | |
| 108 | + | |
| 109 | + if (final.stop_reason !== "tool_use") { | |
| 110 | + sse(controller, { type: "done" }); | |
| 111 | + break; | |
| 112 | + } | |
| 113 | + | |
| 114 | + messages.push({ role: "assistant", content: final.content }); | |
| 115 | + const results: Anthropic.ToolResultBlockParam[] = []; | |
| 116 | + for (const block of final.content) { | |
| 117 | + if (block.type !== "tool_use") continue; | |
| 118 | + sse(controller, { type: "tool", name: block.name }); | |
| 119 | + let out: unknown; | |
| 120 | + try { | |
| 121 | + out = runKaTool(block.name, block.input as Record<string, unknown>); | |
| 122 | + } catch (e) { | |
| 123 | + out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` }; | |
| 124 | + } | |
| 125 | + results.push({ | |
| 126 | + type: "tool_result", | |
| 127 | + tool_use_id: block.id, | |
| 128 | + content: JSON.stringify(out), | |
| 129 | + }); | |
| 130 | + } | |
| 131 | + messages.push({ role: "user", content: results }); | |
| 132 | + | |
| 133 | + if (turn === MAX_TURNS - 1) { | |
| 134 | + sse(controller, { | |
| 135 | + type: "text", | |
| 136 | + text: "\n\n*(J'ai atteint ma limite d'étapes pour cette réponse — repose ta question pour continuer.)*", | |
| 137 | + }); | |
| 138 | + sse(controller, { type: "done" }); | |
| 139 | + } | |
| 140 | + } | |
| 141 | + } catch (e) { | |
| 142 | + sse(controller, { | |
| 143 | + type: "error", | |
| 144 | + message: | |
| 145 | + e instanceof Anthropic.APIError | |
| 146 | + ? `Service momentanément indisponible (${e.status ?? "réseau"}). Réessaie dans un instant.` | |
| 147 | + : "Une erreur inattendue est survenue. Réessaie.", | |
| 148 | + }); | |
| 149 | + } finally { | |
| 150 | + controller.close(); | |
| 151 | + } | |
| 152 | + }, | |
| 153 | + }); | |
| 154 | + | |
| 155 | + return new Response(stream, { | |
| 156 | + headers: { | |
| 157 | + "Content-Type": "text/event-stream; charset=utf-8", | |
| 158 | + "Cache-Control": "no-cache, no-transform", | |
| 159 | + Connection: "keep-alive", | |
| 160 | + "X-Accel-Buffering": "no", | |
| 161 | + }, | |
| 162 | + }); | |
| 163 | +} | |
added
app/src/app/ka/KaHero.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +"use client"; | |
| 3 | +import KaChat from "@/components/KaChat"; | |
| 4 | +import { useLang } from "@/components/LangContext"; | |
| 5 | + | |
| 6 | +export default function KaHero() { | |
| 7 | + const { lang } = useLang(); | |
| 8 | + const fr = lang === "fr"; | |
| 9 | + | |
| 10 | + const powers = fr | |
| 11 | + ? [ | |
| 12 | + ["12 outils", "registre, moteur, pro forma"], | |
| 13 | + ["393 867", "plex interrogeables"], | |
| 14 | + ["En direct", "réponse en streaming"], | |
| 15 | + ] | |
| 16 | + : [ | |
| 17 | + ["12 tools", "registry, engine, pro forma"], | |
| 18 | + ["393,867", "queryable plexes"], | |
| 19 | + ["Live", "streaming answers"], | |
| 20 | + ]; | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + <main className="relative z-[1] mx-auto w-full max-w-[1080px] px-4 pb-16 pt-10 sm:px-6"> | |
| 24 | + <div className="mb-8 text-center"> | |
| 25 | + <span className="kicker justify-center"> | |
| 26 | + {fr ? "Agent IA · propulsé par Claude" : "AI agent · powered by Claude"} | |
| 27 | + </span> | |
| 28 | + <h1 className="vp-display mx-auto mt-3 max-w-[660px] text-[34px] font-bold leading-[1.05] tracking-[-0.04em] sm:text-[46px]"> | |
| 29 | + {fr ? ( | |
| 30 | + <> | |
| 31 | + Jase avec <span className="hl">Ka</span>, il pense en portes, en cashflow et en TGA. | |
| 32 | + </> | |
| 33 | + ) : ( | |
| 34 | + <> | |
| 35 | + Chat with <span className="hl">Ka</span> — it thinks in doors, cashflow and cap rates. | |
| 36 | + </> | |
| 37 | + )} | |
| 38 | + </h1> | |
| 39 | + <p className="mx-auto mt-3 max-w-[580px] text-[14.5px] text-ink-2"> | |
| 40 | + {fr | |
| 41 | + ? "Donne-lui l'adresse d'un plex : il le retrouve dans le registre, l'évalue porte par porte avec le moteur de ValoPlex, puis monte le pro forma investisseur complet — loyer implicite, hypothèque, DSCR, projection 5 ans." | |
| 42 | + : "Give it a plex address: it finds it in the registry, values it door by door with ValoPlex's engine, then builds the full investor pro forma — implied rent, mortgage, DSCR, 5-year projection."} | |
| 43 | + </p> | |
| 44 | + <div className="mt-5 flex flex-wrap justify-center gap-2.5"> | |
| 45 | + {powers.map(([b, s]) => ( | |
| 46 | + <span key={b} className="stat-chip"> | |
| 47 | + <b>{b}</b> {s} | |
| 48 | + </span> | |
| 49 | + ))} | |
| 50 | + </div> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <KaChat /> | |
| 54 | + </main> | |
| 55 | + ); | |
| 56 | +} | |
added
app/src/app/ka/page.tsx
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +import type { Metadata } from "next"; | |
| 3 | +import KaHero from "./KaHero"; | |
| 4 | + | |
| 5 | +export const metadata: Metadata = { | |
| 6 | + title: "Ka — l'agent IA de ValoPlex", | |
| 7 | + description: | |
| 8 | + "Ka évalue n'importe quel plex du Québec en conversant : recherche dans le registre, évaluation par porte, pro forma investisseur complet, comparables — en direct.", | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export default function KaPage() { | |
| 12 | + return <KaHero />; | |
| 13 | +} | |
modified
app/src/components/FooterNav.tsx
+3 −0
@@ -11,6 +11,9 @@ export default function FooterNav() { | ||
| 11 | 11 | "vp-mono text-[11px] uppercase tracking-[0.08em] text-[rgba(245,243,238,0.65)] border-b border-transparent hover:text-lime hover:border-lime transition-colors"; |
| 12 | 12 | return ( |
| 13 | 13 | <nav className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-2"> |
| 14 | + <Link href="/ka" className={cls}> | |
| 15 | + {fr ? "Ka · agent IA" : "Ka · AI agent"} | |
| 16 | + </Link> | |
| 14 | 17 | <Link href="/parc" className={cls}> |
| 15 | 18 | {fr ? "Parc immobilier" : "Portfolio"} |
| 16 | 19 | </Link> |
modified
app/src/components/HeaderNav.tsx
+1 −0
@@ -28,6 +28,7 @@ export default function HeaderNav() { | ||
| 28 | 28 | |
| 29 | 29 | const items = [ |
| 30 | 30 | { href: "/", label: fr ? "Estimer" : "Estimate" }, |
| 31 | + { href: "/ka", label: fr ? "Ka · agent IA" : "Ka · AI agent" }, | |
| 31 | 32 | { href: "/parc", label: fr ? "Parc immobilier" : "Portfolio" }, |
| 32 | 33 | { href: "/stats", label: fr ? "Statistiques" : "Statistics" }, |
| 33 | 34 | { href: "/methodologie", label: fr ? "Méthodologie" : "Methodology" }, |
added
app/src/components/KaChat.tsx
+327 −0
@@ -0,0 +1,327 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Ka — interface de conversation en streaming avec l'agent IA. | |
| 4 | + * Lit le flux SSE de /api/ka : deltas de texte, activité d'outils, fin. | |
| 5 | + */ | |
| 6 | +"use client"; | |
| 7 | +import { useEffect, useRef, useState } from "react"; | |
| 8 | +import { useLang } from "./LangContext"; | |
| 9 | + | |
| 10 | +interface Msg { | |
| 11 | + role: "user" | "assistant"; | |
| 12 | + content: string; | |
| 13 | + tools?: string[]; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const TOOL_LABELS: Record<string, { fr: string; en: string }> = { | |
| 17 | + chercher_plex: { fr: "Recherche dans le registre", en: "Searching the registry" }, | |
| 18 | + evaluer_plex: { fr: "Évaluation du plex", en: "Valuing the plex" }, | |
| 19 | + proforma_investisseur: { fr: "Calcul du pro forma", en: "Building the pro forma" }, | |
| 20 | + comparables_detailles: { fr: "Analyse des comparables", en: "Analyzing comparables" }, | |
| 21 | + indice_marche_plex: { fr: "Lecture du marché des plex", en: "Reading the plex market" }, | |
| 22 | + stats_municipalite: { fr: "Statistiques municipales", en: "Municipal statistics" }, | |
| 23 | + stats_provinciales: { fr: "Statistiques provinciales", en: "Provincial statistics" }, | |
| 24 | + evaluer_parc: { fr: "Évaluation du parc", en: "Evaluating the portfolio" }, | |
| 25 | + comparer_plex: { fr: "Comparaison des plex", en: "Comparing plexes" }, | |
| 26 | + estimation_manuelle: { fr: "Estimation par caractéristiques", en: "Estimating from specs" }, | |
| 27 | + chercher_plex_secteur: { fr: "Balayage du secteur", en: "Scanning the area" }, | |
| 28 | + liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" }, | |
| 29 | +}; | |
| 30 | + | |
| 31 | +/** Rendu markdown minimal et sûr : gras, italique, listes, liens, paragraphes. */ | |
| 32 | +function renderMd(text: string): React.ReactNode[] { | |
| 33 | + const inline = (s: string, key: string): React.ReactNode[] => { | |
| 34 | + const parts: React.ReactNode[] = []; | |
| 35 | + // liens [txt](url) puis **gras** puis *italique* | |
| 36 | + const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g; | |
| 37 | + let last = 0; | |
| 38 | + let m: RegExpExecArray | null; | |
| 39 | + let i = 0; | |
| 40 | + while ((m = rx.exec(s))) { | |
| 41 | + if (m.index > last) parts.push(s.slice(last, m.index)); | |
| 42 | + if (m[1] && m[2]) | |
| 43 | + parts.push( | |
| 44 | + <a key={`${key}-${i++}`} href={m[2]} target="_blank" rel="noopener noreferrer" className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"> | |
| 45 | + {m[1]} | |
| 46 | + </a> | |
| 47 | + ); | |
| 48 | + else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>); | |
| 49 | + else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>); | |
| 50 | + last = m.index + m[0].length; | |
| 51 | + } | |
| 52 | + if (last < s.length) parts.push(s.slice(last)); | |
| 53 | + return parts; | |
| 54 | + }; | |
| 55 | + | |
| 56 | + const blocks: React.ReactNode[] = []; | |
| 57 | + const lines = text.split("\n"); | |
| 58 | + let list: string[] = []; | |
| 59 | + let k = 0; | |
| 60 | + const flushList = () => { | |
| 61 | + if (!list.length) return; | |
| 62 | + blocks.push( | |
| 63 | + <ul key={`ul-${k++}`} className="my-1.5 space-y-1 pl-1"> | |
| 64 | + {list.map((li, j) => ( | |
| 65 | + <li key={j} className="flex gap-2"> | |
| 66 | + <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" /> | |
| 67 | + <span>{inline(li, `li-${k}-${j}`)}</span> | |
| 68 | + </li> | |
| 69 | + ))} | |
| 70 | + </ul> | |
| 71 | + ); | |
| 72 | + list = []; | |
| 73 | + }; | |
| 74 | + for (const line of lines) { | |
| 75 | + const t = line.trim(); | |
| 76 | + if (/^[-•]\s+/.test(t)) { | |
| 77 | + list.push(t.replace(/^[-•]\s+/, "")); | |
| 78 | + continue; | |
| 79 | + } | |
| 80 | + flushList(); | |
| 81 | + if (t === "") continue; | |
| 82 | + if (/^#{1,4}\s+/.test(t)) { | |
| 83 | + blocks.push( | |
| 84 | + <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold"> | |
| 85 | + {inline(t.replace(/^#{1,4}\s+/, ""), `h-${k}`)} | |
| 86 | + </p> | |
| 87 | + ); | |
| 88 | + } else { | |
| 89 | + blocks.push( | |
| 90 | + <p key={`p-${k++}`} className="my-1.5"> | |
| 91 | + {inline(t, `p-${k}`)} | |
| 92 | + </p> | |
| 93 | + ); | |
| 94 | + } | |
| 95 | + } | |
| 96 | + flushList(); | |
| 97 | + return blocks; | |
| 98 | +} | |
| 99 | + | |
| 100 | +export default function KaChat() { | |
| 101 | + const { lang } = useLang(); | |
| 102 | + const fr = lang === "fr"; | |
| 103 | + const [messages, setMessages] = useState<Msg[]>([]); | |
| 104 | + const [input, setInput] = useState(""); | |
| 105 | + const [busy, setBusy] = useState(false); | |
| 106 | + const [activeTool, setActiveTool] = useState<string | null>(null); | |
| 107 | + const endRef = useRef<HTMLDivElement>(null); | |
| 108 | + const abortRef = useRef<AbortController | null>(null); | |
| 109 | + | |
| 110 | + useEffect(() => { | |
| 111 | + endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); | |
| 112 | + }, [messages, activeTool]); | |
| 113 | + | |
| 114 | + const suggestions = fr | |
| 115 | + ? [ | |
| 116 | + "Combien vaut le triplex au 2075 rue Grandjean à Québec ?", | |
| 117 | + "Trouve-moi un triplex à Trois-Rivières sous 700 000 $", | |
| 118 | + "Fais-moi le pro forma d'un duplex à Sherbrooke", | |
| 119 | + "Que valent tous les plex du Québec ?", | |
| 120 | + ] | |
| 121 | + : [ | |
| 122 | + "How much is the triplex at 2075 rue Grandjean in Québec City worth?", | |
| 123 | + "Find me a triplex in Trois-Rivières under $700,000", | |
| 124 | + "Build the pro forma for a duplex in Sherbrooke", | |
| 125 | + "What are all Québec plexes worth?", | |
| 126 | + ]; | |
| 127 | + | |
| 128 | + async function send(text: string) { | |
| 129 | + const content = text.trim(); | |
| 130 | + if (!content || busy) return; | |
| 131 | + setInput(""); | |
| 132 | + setBusy(true); | |
| 133 | + setActiveTool(null); | |
| 134 | + | |
| 135 | + const history = [...messages, { role: "user" as const, content }]; | |
| 136 | + setMessages([...history, { role: "assistant", content: "", tools: [] }]); | |
| 137 | + | |
| 138 | + const ctrl = new AbortController(); | |
| 139 | + abortRef.current = ctrl; | |
| 140 | + | |
| 141 | + try { | |
| 142 | + const res = await fetch("/api/ka", { | |
| 143 | + method: "POST", | |
| 144 | + headers: { "Content-Type": "application/json" }, | |
| 145 | + body: JSON.stringify({ | |
| 146 | + messages: history.map(({ role, content }) => ({ role, content })), | |
| 147 | + }), | |
| 148 | + signal: ctrl.signal, | |
| 149 | + }); | |
| 150 | + if (!res.ok || !res.body) throw new Error(String(res.status)); | |
| 151 | + | |
| 152 | + const reader = res.body.getReader(); | |
| 153 | + const decoder = new TextDecoder(); | |
| 154 | + let buffer = ""; | |
| 155 | + | |
| 156 | + const patch = (fn: (m: Msg) => Msg) => | |
| 157 | + setMessages((prev) => { | |
| 158 | + const next = [...prev]; | |
| 159 | + next[next.length - 1] = fn(next[next.length - 1]); | |
| 160 | + return next; | |
| 161 | + }); | |
| 162 | + | |
| 163 | + for (;;) { | |
| 164 | + const { done, value } = await reader.read(); | |
| 165 | + if (done) break; | |
| 166 | + buffer += decoder.decode(value, { stream: true }); | |
| 167 | + const events = buffer.split("\n\n"); | |
| 168 | + buffer = events.pop() ?? ""; | |
| 169 | + for (const ev of events) { | |
| 170 | + const line = ev.split("\n").find((l) => l.startsWith("data: ")); | |
| 171 | + if (!line) continue; | |
| 172 | + let data: { type: string; text?: string; name?: string; message?: string }; | |
| 173 | + try { | |
| 174 | + data = JSON.parse(line.slice(6)); | |
| 175 | + } catch { | |
| 176 | + continue; | |
| 177 | + } | |
| 178 | + if (data.type === "text" && data.text) { | |
| 179 | + setActiveTool(null); | |
| 180 | + patch((m) => ({ ...m, content: m.content + data.text })); | |
| 181 | + } else if (data.type === "tool" && data.name) { | |
| 182 | + setActiveTool(data.name); | |
| 183 | + patch((m) => ({ | |
| 184 | + ...m, | |
| 185 | + tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!], | |
| 186 | + })); | |
| 187 | + } else if (data.type === "error") { | |
| 188 | + patch((m) => ({ | |
| 189 | + ...m, | |
| 190 | + content: m.content + (m.content ? "\n\n" : "") + `*${data.message}*`, | |
| 191 | + })); | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } | |
| 195 | + } catch { | |
| 196 | + setMessages((prev) => { | |
| 197 | + const next = [...prev]; | |
| 198 | + const last = next[next.length - 1]; | |
| 199 | + if (last?.role === "assistant" && !last.content) { | |
| 200 | + last.content = fr | |
| 201 | + ? "*Connexion interrompue — réessaie dans un instant.*" | |
| 202 | + : "*Connection lost — try again in a moment.*"; | |
| 203 | + } | |
| 204 | + return next; | |
| 205 | + }); | |
| 206 | + } finally { | |
| 207 | + setActiveTool(null); | |
| 208 | + setBusy(false); | |
| 209 | + abortRef.current = null; | |
| 210 | + } | |
| 211 | + } | |
| 212 | + | |
| 213 | + const toolLabel = (name: string) => | |
| 214 | + TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " "); | |
| 215 | + | |
| 216 | + return ( | |
| 217 | + <div className="mx-auto flex w-full max-w-[860px] flex-col"> | |
| 218 | + {/* fil de conversation */} | |
| 219 | + <div className="vp-card flex min-h-[54vh] flex-col gap-5 p-5 sm:p-7"> | |
| 220 | + {messages.length === 0 && ( | |
| 221 | + <div className="my-auto text-center"> | |
| 222 | + <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl border-[1.5px] border-ink bg-ink shadow-[5px_5px_0_var(--lime)]"> | |
| 223 | + <span className="vp-display text-[26px] font-bold text-lime">Ka</span> | |
| 224 | + </div> | |
| 225 | + <p className="vp-display text-[19px] font-bold"> | |
| 226 | + {fr ? "Dis-moi quel plex t'intéresse." : "Tell me which plex you're curious about."} | |
| 227 | + </p> | |
| 228 | + <p className="mx-auto mt-1.5 max-w-[440px] text-[13.5px] text-ink-2"> | |
| 229 | + {fr | |
| 230 | + ? "Je fouille 393 867 plex, 91 000 ventes réelles et je calcule des pro forma complets, en direct, pendant qu'on jase." | |
| 231 | + : "I search 393,867 plexes, 91k real sales and build full pro formas, live, while we chat."} | |
| 232 | + </p> | |
| 233 | + <div className="mx-auto mt-5 flex max-w-[560px] flex-wrap justify-center gap-2"> | |
| 234 | + {suggestions.map((s) => ( | |
| 235 | + <button | |
| 236 | + key={s} | |
| 237 | + onClick={() => send(s)} | |
| 238 | + className="rounded-full border-[1.5px] border-ink bg-surface px-3.5 py-2 text-[12.5px] font-semibold transition-all hover:bg-lime-soft hover:shadow-[3px_3px_0_var(--ink)]" | |
| 239 | + > | |
| 240 | + {s} | |
| 241 | + </button> | |
| 242 | + ))} | |
| 243 | + </div> | |
| 244 | + </div> | |
| 245 | + )} | |
| 246 | + | |
| 247 | + {messages.map((m, i) => ( | |
| 248 | + <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}> | |
| 249 | + {m.role === "user" ? ( | |
| 250 | + <div className="max-w-[85%] rounded-2xl rounded-br-md border-[1.5px] border-ink bg-ink px-4 py-2.5 text-[14px] font-medium text-paper"> | |
| 251 | + {m.content} | |
| 252 | + </div> | |
| 253 | + ) : ( | |
| 254 | + <div className="flex max-w-[92%] gap-3"> | |
| 255 | + <div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[1.5px] border-ink bg-ink"> | |
| 256 | + <span className="vp-display text-[12px] font-bold text-lime">Ka</span> | |
| 257 | + </div> | |
| 258 | + <div className="min-w-0"> | |
| 259 | + {m.tools && m.tools.length > 0 && ( | |
| 260 | + <div className="mb-1.5 flex flex-wrap gap-1.5"> | |
| 261 | + {m.tools.map((t) => ( | |
| 262 | + <span | |
| 263 | + key={t} | |
| 264 | + className="vp-mono inline-flex items-center gap-1.5 rounded-full border border-[var(--line)] bg-surface-2 px-2.5 py-[3px] text-[10px] uppercase tracking-[0.06em] text-ink-2" | |
| 265 | + > | |
| 266 | + <span | |
| 267 | + className={`h-[6px] w-[6px] rounded-full ${ | |
| 268 | + busy && i === messages.length - 1 && activeTool === t | |
| 269 | + ? "animate-pulse bg-[var(--lime)]" | |
| 270 | + : "bg-[var(--green)]" | |
| 271 | + }`} | |
| 272 | + /> | |
| 273 | + {toolLabel(t)} | |
| 274 | + </span> | |
| 275 | + ))} | |
| 276 | + </div> | |
| 277 | + )} | |
| 278 | + <div className="text-[14px] leading-relaxed"> | |
| 279 | + {m.content ? ( | |
| 280 | + renderMd(m.content) | |
| 281 | + ) : busy && i === messages.length - 1 ? ( | |
| 282 | + <span className="inline-flex gap-1 py-1"> | |
| 283 | + <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:0ms]" /> | |
| 284 | + <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:120ms]" /> | |
| 285 | + <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:240ms]" /> | |
| 286 | + </span> | |
| 287 | + ) : null} | |
| 288 | + </div> | |
| 289 | + </div> | |
| 290 | + </div> | |
| 291 | + )} | |
| 292 | + </div> | |
| 293 | + ))} | |
| 294 | + <div ref={endRef} /> | |
| 295 | + </div> | |
| 296 | + | |
| 297 | + {/* zone de saisie */} | |
| 298 | + <form | |
| 299 | + onSubmit={(e) => { | |
| 300 | + e.preventDefault(); | |
| 301 | + send(input); | |
| 302 | + }} | |
| 303 | + className="mt-4 flex gap-2.5" | |
| 304 | + > | |
| 305 | + <input | |
| 306 | + value={input} | |
| 307 | + onChange={(e) => setInput(e.target.value)} | |
| 308 | + placeholder={ | |
| 309 | + fr ? "Écris une adresse ou une question…" : "Type an address or a question…" | |
| 310 | + } | |
| 311 | + className="vp-input flex-1" | |
| 312 | + maxLength={2000} | |
| 313 | + aria-label={fr ? "Message à Ka" : "Message to Ka"} | |
| 314 | + /> | |
| 315 | + <button type="submit" disabled={busy || !input.trim()} className="btn btn-primary shrink-0 disabled:opacity-50"> | |
| 316 | + {busy ? (fr ? "Ka réfléchit…" : "Ka is thinking…") : fr ? "Envoyer" : "Send"} | |
| 317 | + </button> | |
| 318 | + </form> | |
| 319 | + | |
| 320 | + <p className="vp-mono mt-3 text-center text-[10px] uppercase tracking-[0.08em] text-ink-3"> | |
| 321 | + {fr | |
| 322 | + ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé" | |
| 323 | + : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"} | |
| 324 | + </p> | |
| 325 | + </div> | |
| 326 | + ); | |
| 327 | +} | |
added
app/src/lib/ka/tools.ts
+573 −0
@@ -0,0 +1,573 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Ka — la panoplie d'outils de l'agent IA de ValoPlex. | |
| 4 | + * Spécialisée plex : évaluation en ratio, économie par porte, pro forma | |
| 5 | + * investisseur « tout le kit ». Exécutée côté serveur sur la base SQLite | |
| 6 | + * (393 867 plex) et le moteur d'estimation. | |
| 7 | + */ | |
| 8 | +import Anthropic from "@anthropic-ai/sdk"; | |
| 9 | +import { getDb, getMarketIndex, getUnit, searchUnits } from "../db"; | |
| 10 | +import { | |
| 11 | + estimateByUnitId, | |
| 12 | + estimateManual, | |
| 13 | + estimatePortfolio, | |
| 14 | + type UnitEstimate, | |
| 15 | +} from "../estimator"; | |
| 16 | +import { buildProforma, DEFAULT_PARAMS, tgaReference, type ProformaParams } from "../proforma"; | |
| 17 | +import stats from "../../data/stats.json"; | |
| 18 | + | |
| 19 | +/* ---------------------------------------------------------------- helpers */ | |
| 20 | + | |
| 21 | +const r = (n: number | null | undefined, d = 0): number | null => | |
| 22 | + n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d)); | |
| 23 | + | |
| 24 | +function gabarit(portes: number | null): string { | |
| 25 | + if (!portes) return "plex"; | |
| 26 | + if (portes === 2) return "duplex"; | |
| 27 | + if (portes === 3) return "triplex"; | |
| 28 | + if (portes === 4) return "quadruplex"; | |
| 29 | + if (portes === 5) return "quintuplex"; | |
| 30 | + if (portes === 6) return "sixplex"; | |
| 31 | + return `multi ${portes} portes`; | |
| 32 | +} | |
| 33 | + | |
| 34 | +function unitCard(u: ReturnType<typeof searchUnits>[number]) { | |
| 35 | + return { | |
| 36 | + id: u.id_provinc, | |
| 37 | + adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "), | |
| 38 | + municipalite: u.municipalite, | |
| 39 | + arrondissement: u.arrond, | |
| 40 | + portes: u.nb_logements, | |
| 41 | + gabarit: gabarit(u.nb_logements), | |
| 42 | + annee_construction: u.annee_construction, | |
| 43 | + aire_habitable_m2: r(u.aire_etages_m2, 1), | |
| 44 | + terrain_m2: r(u.superficie_terrain_m2), | |
| 45 | + valeur_role_2026: u.valeur_role, | |
| 46 | + }; | |
| 47 | +} | |
| 48 | + | |
| 49 | +function evalCard(e: UnitEstimate) { | |
| 50 | + const u = e.unit; | |
| 51 | + const res = e.result; | |
| 52 | + const portes = u?.nbLogements ?? null; | |
| 53 | + return { | |
| 54 | + plex: u | |
| 55 | + ? { | |
| 56 | + id: u.id, | |
| 57 | + adresse: u.adresse, | |
| 58 | + municipalite: u.municipalite, | |
| 59 | + gabarit: gabarit(portes), | |
| 60 | + portes, | |
| 61 | + adresses_des_portes: u.specs.portesAdresses.slice(0, 20), | |
| 62 | + annee_construction: u.anneeConstruction, | |
| 63 | + aire_habitable_m2: r(u.aireEtagesM2, 1), | |
| 64 | + terrain_m2: r(u.superficieTerrainM2), | |
| 65 | + etages: u.specs.nbEtages, | |
| 66 | + genre_construction: u.specs.genreConstruction, | |
| 67 | + valeur_role_2026: u.valeurRole, | |
| 68 | + valeur_terrain_role: u.specs.valeurTerrain, | |
| 69 | + valeur_batiment_role: u.specs.valeurBatiment, | |
| 70 | + } | |
| 71 | + : null, | |
| 72 | + estimation: { | |
| 73 | + valeur: r(res.estimate), | |
| 74 | + fourchette_basse_p10: r(res.low), | |
| 75 | + fourchette_haute_p90: r(res.high), | |
| 76 | + confiance_pct: res.confidencePct, | |
| 77 | + confiance_niveau: res.confidenceLevel, | |
| 78 | + part_modele_hedonique: r(res.modelEstimate), | |
| 79 | + part_comparables: r(res.compsEstimate), | |
| 80 | + poids_modele: r(res.modelWeight, 2), | |
| 81 | + n_comparables_utilises: res.nCompsUsed, | |
| 82 | + ecart_vs_role_pct: u?.valeurRole | |
| 83 | + ? r((res.estimate / u.valeurRole - 1) * 100, 1) | |
| 84 | + : null, | |
| 85 | + }, | |
| 86 | + economie_par_porte: portes | |
| 87 | + ? { | |
| 88 | + valeur_par_porte: r(res.estimate / portes), | |
| 89 | + role_par_porte: u?.valeurRole ? r(u.valeurRole / portes) : null, | |
| 90 | + aire_par_porte_m2: u?.aireEtagesM2 ? r(u.aireEtagesM2 / portes, 1) : null, | |
| 91 | + terrain_par_porte_m2: u?.superficieTerrainM2 | |
| 92 | + ? r(u.superficieTerrainM2 / portes, 1) | |
| 93 | + : null, | |
| 94 | + } | |
| 95 | + : null, | |
| 96 | + historique_2021_2026: u?.history ?? null, | |
| 97 | + }; | |
| 98 | +} | |
| 99 | + | |
| 100 | +/* ------------------------------------------------------------ tool defs */ | |
| 101 | + | |
| 102 | +export const KA_TOOLS: Anthropic.Tool[] = [ | |
| 103 | + { | |
| 104 | + name: "chercher_plex", | |
| 105 | + description: | |
| 106 | + "Recherche plein-texte d'un plex parmi les 393 867 immeubles de 2 logements et plus du Québec. Utilise-le dès qu'un utilisateur mentionne une adresse, même partielle. S'il y a plusieurs candidats plausibles, présente-les et demande de préciser AVANT d'évaluer. Astuce : « numéro rue municipalité ».", | |
| 107 | + input_schema: { | |
| 108 | + type: "object", | |
| 109 | + properties: { | |
| 110 | + requete: { type: "string", description: "Adresse ou fragment, ex. « 2075 Grandjean Québec »" }, | |
| 111 | + limite: { type: "number", description: "Max résultats (défaut 6, max 12)" }, | |
| 112 | + }, | |
| 113 | + required: ["requete"], | |
| 114 | + }, | |
| 115 | + }, | |
| 116 | + { | |
| 117 | + name: "evaluer_plex", | |
| 118 | + description: | |
| 119 | + "Évaluation complète d'un plex par son id (obtenu via chercher_plex) : valeur estimée (modèle en ratio calibré par gabarit), fourchette P10-P90, confiance A-D, économie par porte (valeur/porte, rôle/porte, aire/porte), adresses de chaque porte, historique 2021-2026. L'outil central.", | |
| 120 | + input_schema: { | |
| 121 | + type: "object", | |
| 122 | + properties: { id: { type: "string", description: "Identifiant provincial" } }, | |
| 123 | + required: ["id"], | |
| 124 | + }, | |
| 125 | + }, | |
| 126 | + { | |
| 127 | + name: "proforma_investisseur", | |
| 128 | + description: | |
| 129 | + "Pro forma investisseur complet d'un plex (approche revenu inversée avec tous les frais) : loyer implicite par porte, état des résultats (vacance, taxes, assurances, entretien, gestion…), RNE, TGA implicite, hypothèque canadienne, cashflow/porte/mois, DSCR, cash-on-cash, droits de mutation + notaire + inspection, liquidités requises, projection 5 ans (équité, gain total, multiple), loyer et taux de point mort, sensibilité aux taux ±1 %. Paramètres tous optionnels (défauts : taux 4,75 %, mise 25 %, amort. 25 ans, TGA par gabarit). Appelle-le pour toute question d'investissement, de rentabilité ou de financement.", | |
| 130 | + input_schema: { | |
| 131 | + type: "object", | |
| 132 | + properties: { | |
| 133 | + id: { type: "string", description: "Identifiant provincial du plex" }, | |
| 134 | + taux_hypo_pct: { type: "number", description: "Taux hypothécaire annuel, ex. 4.75" }, | |
| 135 | + mise_de_fonds_pct: { type: "number", description: "Mise de fonds en %, ex. 25" }, | |
| 136 | + amortissement_ans: { type: "number", description: "Amortissement en années, ex. 25" }, | |
| 137 | + tga_pct: { type: "number", description: "TGA imposé ; sinon référence par gabarit" }, | |
| 138 | + appreciation_pct: { type: "number", description: "Appréciation annuelle supposée, ex. 2.5" }, | |
| 139 | + }, | |
| 140 | + required: ["id"], | |
| 141 | + }, | |
| 142 | + }, | |
| 143 | + { | |
| 144 | + name: "comparables_detailles", | |
| 145 | + description: | |
| 146 | + "Ventes de plex comparables utilisées dans l'évaluation : adresse, date, prix payé, nombre de portes, distance, ajustements (marché, superficie, âge, portes) et poids. Pour « pourquoi ce prix ? » ou « qu'est-ce qui s'est vendu autour ? ».", | |
| 147 | + input_schema: { | |
| 148 | + type: "object", | |
| 149 | + properties: { | |
| 150 | + id: { type: "string" }, | |
| 151 | + max: { type: "number", description: "Max comparables (défaut 8)" }, | |
| 152 | + }, | |
| 153 | + required: ["id"], | |
| 154 | + }, | |
| 155 | + }, | |
| 156 | + { | |
| 157 | + name: "indice_marche_plex", | |
| 158 | + description: | |
| 159 | + "Tendance du marché des plex au Québec : indice mensuel $/m² lissé et croissance 12/24 mois. Pour contextualiser une évaluation ou un timing d'achat.", | |
| 160 | + input_schema: { type: "object", properties: {} }, | |
| 161 | + }, | |
| 162 | + { | |
| 163 | + name: "stats_municipalite", | |
| 164 | + description: | |
| 165 | + "Statistiques plex en direct d'une municipalité : nombre de plex et de portes, valeur totale/médiane 2026, valeur médiane par porte, répartition par gabarit (duplex, triplex…). Pour comparer un plex à son marché local.", | |
| 166 | + input_schema: { | |
| 167 | + type: "object", | |
| 168 | + properties: { municipalite: { type: "string", description: "Nom exact, ex. « Trois-Rivières »" } }, | |
| 169 | + required: ["municipalite"], | |
| 170 | + }, | |
| 171 | + }, | |
| 172 | + { | |
| 173 | + name: "stats_provinciales", | |
| 174 | + description: | |
| 175 | + "Les grands chiffres des plex du Québec : valeur totale (400,6 G$), 393 867 plex, 1 733 744 portes, croissance 2021→2026, top municipalités. Pour les questions macro.", | |
| 176 | + input_schema: { type: "object", properties: {} }, | |
| 177 | + }, | |
| 178 | + { | |
| 179 | + name: "evaluer_parc", | |
| 180 | + description: | |
| 181 | + "Évalue un parc de plex (2 à 40 ids) : valeur totale, fourchette, confiance pondérée, portes totales, répartition par ville, croissance 2021→2026. Pour les investisseurs multi-immeubles.", | |
| 182 | + input_schema: { | |
| 183 | + type: "object", | |
| 184 | + properties: { ids: { type: "array", items: { type: "string" } } }, | |
| 185 | + required: ["ids"], | |
| 186 | + }, | |
| 187 | + }, | |
| 188 | + { | |
| 189 | + name: "comparer_plex", | |
| 190 | + description: | |
| 191 | + "Compare 2 à 4 plex côte à côte : valeur, valeur/porte, écart vs rôle, confiance, croissance. Pour départager des occasions d'achat.", | |
| 192 | + input_schema: { | |
| 193 | + type: "object", | |
| 194 | + properties: { ids: { type: "array", items: { type: "string" }, description: "2 à 4 ids" } }, | |
| 195 | + required: ["ids"], | |
| 196 | + }, | |
| 197 | + }, | |
| 198 | + { | |
| 199 | + name: "estimation_manuelle", | |
| 200 | + description: | |
| 201 | + "Estimation d'un plex sans adresse exacte : municipalité + nombre de portes, et si possible superficie, année. Seulement si chercher_plex ne trouve rien ou pour un scénario hypothétique.", | |
| 202 | + input_schema: { | |
| 203 | + type: "object", | |
| 204 | + properties: { | |
| 205 | + municipalite: { type: "string" }, | |
| 206 | + portes: { type: "number", description: "Nombre de logements (2+)" }, | |
| 207 | + aire_habitable_m2: { type: "number" }, | |
| 208 | + annee_construction: { type: "number" }, | |
| 209 | + terrain_m2: { type: "number" }, | |
| 210 | + }, | |
| 211 | + required: ["municipalite", "portes"], | |
| 212 | + }, | |
| 213 | + }, | |
| 214 | + { | |
| 215 | + name: "chercher_plex_secteur", | |
| 216 | + description: | |
| 217 | + "Trouve des plex dans une municipalité selon des critères d'investisseur : portes min/max, budget max (valeur estimée 2026), valeur min. Triés par valeur. Pour « trouve-moi un triplex à Trois-Rivières sous 700 k$ ».", | |
| 218 | + input_schema: { | |
| 219 | + type: "object", | |
| 220 | + properties: { | |
| 221 | + municipalite: { type: "string" }, | |
| 222 | + portes_min: { type: "number" }, | |
| 223 | + portes_max: { type: "number" }, | |
| 224 | + valeur_max: { type: "number", description: "Budget maximal ($)" }, | |
| 225 | + valeur_min: { type: "number" }, | |
| 226 | + limite: { type: "number", description: "Max résultats (défaut 8, max 15)" }, | |
| 227 | + }, | |
| 228 | + required: ["municipalite"], | |
| 229 | + }, | |
| 230 | + }, | |
| 231 | + { | |
| 232 | + name: "liens_rapports", | |
| 233 | + description: | |
| 234 | + "Liens de téléchargement des rapports PDF d'un plex (standard 3 pages avec pro forma, professionnel bancaire 6 pages) et lien de sa fiche. Offre-les à la fin d'une évaluation réussie.", | |
| 235 | + input_schema: { | |
| 236 | + type: "object", | |
| 237 | + properties: { id: { type: "string" } }, | |
| 238 | + required: ["id"], | |
| 239 | + }, | |
| 240 | + }, | |
| 241 | +]; | |
| 242 | + | |
| 243 | +/* ------------------------------------------------------------ execution */ | |
| 244 | + | |
| 245 | +type J = Record<string, unknown>; | |
| 246 | + | |
| 247 | +function growth(idx: { month: string; idx: number }[], months: number): number | null { | |
| 248 | + if (idx.length < months + 1) return null; | |
| 249 | + const last = idx[idx.length - 1]; | |
| 250 | + const past = idx[idx.length - 1 - months]; | |
| 251 | + return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null; | |
| 252 | +} | |
| 253 | + | |
| 254 | +export function runKaTool(name: string, input: J): unknown { | |
| 255 | + switch (name) { | |
| 256 | + case "chercher_plex": { | |
| 257 | + const limit = Math.min(Number(input.limite) || 6, 12); | |
| 258 | + const rows = searchUnits(String(input.requete ?? ""), limit); | |
| 259 | + if (!rows.length) | |
| 260 | + return { | |
| 261 | + resultats: [], | |
| 262 | + conseil: | |
| 263 | + "Aucun plex trouvé. Rappel : ValoPlex ne couvre que les immeubles de 2 logements et plus. Essayer « numéro rue municipalité » ou estimation_manuelle.", | |
| 264 | + }; | |
| 265 | + return { resultats: rows.map(unitCard) }; | |
| 266 | + } | |
| 267 | + | |
| 268 | + case "evaluer_plex": { | |
| 269 | + const e = estimateByUnitId(String(input.id ?? "")); | |
| 270 | + if (!e) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." }; | |
| 271 | + return evalCard(e); | |
| 272 | + } | |
| 273 | + | |
| 274 | + case "proforma_investisseur": { | |
| 275 | + const id = String(input.id ?? ""); | |
| 276 | + const e = estimateByUnitId(id); | |
| 277 | + if (!e || !e.unit) return { erreur: "Plex introuvable." }; | |
| 278 | + const doors = e.unit.nbLogements ?? 2; | |
| 279 | + const partial: Partial<ProformaParams> = {}; | |
| 280 | + if (input.taux_hypo_pct != null) partial.tauxHypoPct = Number(input.taux_hypo_pct); | |
| 281 | + if (input.mise_de_fonds_pct != null) partial.miseDeFondsPct = Number(input.mise_de_fonds_pct); | |
| 282 | + if (input.amortissement_ans != null) partial.amortAns = Number(input.amortissement_ans); | |
| 283 | + if (input.tga_pct != null) partial.tgaPct = Number(input.tga_pct); | |
| 284 | + if (input.appreciation_pct != null) partial.appreciationPct = Number(input.appreciation_pct); | |
| 285 | + const pf = buildProforma( | |
| 286 | + e.result.estimate, | |
| 287 | + doors, | |
| 288 | + e.unit.valeurRole, | |
| 289 | + e.unit.municipalite, | |
| 290 | + partial | |
| 291 | + ); | |
| 292 | + return { | |
| 293 | + plex: { adresse: e.unit.adresse, gabarit: gabarit(doors), portes: doors }, | |
| 294 | + valeur_utilisee: r(e.result.estimate), | |
| 295 | + hypotheses: { | |
| 296 | + taux_hypo_pct: pf.params.tauxHypoPct, | |
| 297 | + mise_de_fonds_pct: pf.params.miseDeFondsPct, | |
| 298 | + amortissement_ans: pf.params.amortAns, | |
| 299 | + tga_pct: pf.params.tgaPct, | |
| 300 | + tga_reference_gabarit_pct: pf.tgaRefPct, | |
| 301 | + appreciation_pct: pf.params.appreciationPct, | |
| 302 | + }, | |
| 303 | + revenus: { | |
| 304 | + loyer_implicite_par_porte_mois: r(pf.loyerMoyenMensuel), | |
| 305 | + revenus_bruts_annuels: r(pf.revenusBruts), | |
| 306 | + vacance: r(pf.vacance), | |
| 307 | + revenus_effectifs: r(pf.revenusEffectifs), | |
| 308 | + }, | |
| 309 | + depenses_annuelles: pf.depenses.map((d) => ({ poste: d.key, montant: r(d.amount) })), | |
| 310 | + exploitation: { | |
| 311 | + rne_noi: r(pf.rne), | |
| 312 | + ratio_depenses_pct: r(pf.ratioDepensesPct, 1), | |
| 313 | + tga_implicite_pct: r(pf.tgaImplicitePct, 2), | |
| 314 | + multiplicateur_revenus_bruts: r(pf.mrb, 1), | |
| 315 | + }, | |
| 316 | + financement: { | |
| 317 | + mise_de_fonds: r(pf.miseDeFonds), | |
| 318 | + hypotheque: r(pf.hypotheque), | |
| 319 | + paiement_mensuel: r(pf.paiementMensuelHypo), | |
| 320 | + service_dette_annuel: r(pf.serviceDetteAnnuel), | |
| 321 | + cashflow_annuel: r(pf.cashflowAnnuel), | |
| 322 | + cashflow_par_porte_mois: r(pf.cashflowMensuelParPorte), | |
| 323 | + dscr: r(pf.dscr, 2), | |
| 324 | + }, | |
| 325 | + acquisition_tout_le_kit: { | |
| 326 | + droits_mutation: r(pf.droitsMutation), | |
| 327 | + notaire: r(pf.fraisNotaire), | |
| 328 | + inspection: r(pf.fraisInspection), | |
| 329 | + liquidites_requises_totales: r(pf.liquiditesRequises), | |
| 330 | + cash_on_cash_pct: r(pf.cashOnCashPct, 1), | |
| 331 | + }, | |
| 332 | + projection_5_ans: { | |
| 333 | + capital_rembourse_an_1: r(pf.capitalAn1), | |
| 334 | + rendement_total_an_1_pct: r(pf.rendementTotalAn1Pct, 1), | |
| 335 | + valeur_projetee: r(pf.valeur5Ans), | |
| 336 | + equite: r(pf.equite5Ans), | |
| 337 | + gain_total: r(pf.gainTotal5Ans), | |
| 338 | + multiple_sur_liquidites: r(pf.multipleLiquidites5Ans, 2), | |
| 339 | + }, | |
| 340 | + marges_de_securite: { | |
| 341 | + loyer_point_mort_par_porte: r(pf.loyerPointMort), | |
| 342 | + marge_loyer_pct: r(pf.margeSecuriteLoyerPct, 1), | |
| 343 | + taux_point_mort_pct: r(pf.tauxPointMortPct, 2), | |
| 344 | + sensibilite_taux: pf.sensibiliteTaux.map((s) => ({ | |
| 345 | + taux_pct: s.tauxPct, | |
| 346 | + cashflow_annuel: r(s.cashflowAnnuel), | |
| 347 | + dscr: r(s.dscr, 2), | |
| 348 | + })), | |
| 349 | + }, | |
| 350 | + }; | |
| 351 | + } | |
| 352 | + | |
| 353 | + case "comparables_detailles": { | |
| 354 | + const e = estimateByUnitId(String(input.id ?? "")); | |
| 355 | + if (!e) return { erreur: "Plex introuvable." }; | |
| 356 | + const max = Math.min(Number(input.max) || 8, 15); | |
| 357 | + if (!e.result.comps.length) | |
| 358 | + return { | |
| 359 | + n_utilises: 0, | |
| 360 | + note: "Aucune vente de plex comparable dans le secteur — l'estimation repose sur le modèle hédonique.", | |
| 361 | + }; | |
| 362 | + return { | |
| 363 | + n_utilises: e.result.nCompsUsed, | |
| 364 | + comparables: e.result.comps.slice(0, max).map((c) => ({ | |
| 365 | + adresse: [c.street, c.city].filter(Boolean).join(", "), | |
| 366 | + vendu_le: c.date, | |
| 367 | + prix_paye: c.amount, | |
| 368 | + portes: c.doors ?? null, | |
| 369 | + distance_m: r(c.distanceM), | |
| 370 | + ajust_marche: r(c.adjTime), | |
| 371 | + ajust_superficie: r(c.adjArea), | |
| 372 | + ajust_age: r(c.adjAge), | |
| 373 | + ajust_portes: r(c.adjDoors), | |
| 374 | + prix_ajuste: r(c.adjustedPrice), | |
| 375 | + poids_pct: r(c.weight * 100, 1), | |
| 376 | + })), | |
| 377 | + }; | |
| 378 | + } | |
| 379 | + | |
| 380 | + case "indice_marche_plex": { | |
| 381 | + const idx = getMarketIndex("plex"); | |
| 382 | + const last12 = idx.slice(-13); | |
| 383 | + return { | |
| 384 | + croissance_12_mois_pct: growth(idx, 12), | |
| 385 | + croissance_24_mois_pct: growth(idx, 24), | |
| 386 | + serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })), | |
| 387 | + note: "Indice $/m² des ventes de plex, lissé 3 mois, 1.0 = niveau actuel.", | |
| 388 | + }; | |
| 389 | + } | |
| 390 | + | |
| 391 | + case "stats_municipalite": { | |
| 392 | + const mun = String(input.municipalite ?? ""); | |
| 393 | + const g = getDb() | |
| 394 | + .prepare( | |
| 395 | + `SELECT COUNT(*) n, SUM(nb_logements) portes, SUM(est_2026) total | |
| 396 | + FROM units WHERE municipalite = ? COLLATE NOCASE` | |
| 397 | + ) | |
| 398 | + .get(mun) as { n: number; portes: number | null; total: number | null }; | |
| 399 | + if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` }; | |
| 400 | + const med = getDb() | |
| 401 | + .prepare( | |
| 402 | + `SELECT est_2026 v, nb_logements p FROM units | |
| 403 | + WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL | |
| 404 | + ORDER BY est_2026 LIMIT 1 | |
| 405 | + OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2` | |
| 406 | + ) | |
| 407 | + .get(mun, mun) as { v: number; p: number | null } | undefined; | |
| 408 | + const gabarits = getDb() | |
| 409 | + .prepare( | |
| 410 | + `SELECT CASE WHEN nb_logements = 2 THEN 'duplex' | |
| 411 | + WHEN nb_logements = 3 THEN 'triplex' | |
| 412 | + WHEN nb_logements BETWEEN 4 AND 5 THEN '4-5 portes' | |
| 413 | + WHEN nb_logements BETWEEN 6 AND 12 THEN '6-12 portes' | |
| 414 | + ELSE '13+ portes' END AS gab, | |
| 415 | + COUNT(*) n, SUM(est_2026) total, AVG(est_2026 / nb_logements) prix_porte_moyen | |
| 416 | + FROM units WHERE municipalite = ? COLLATE NOCASE AND nb_logements >= 2 | |
| 417 | + GROUP BY gab ORDER BY n DESC` | |
| 418 | + ) | |
| 419 | + .all(mun) as { gab: string; n: number; total: number | null; prix_porte_moyen: number | null }[]; | |
| 420 | + return { | |
| 421 | + municipalite: mun, | |
| 422 | + plex: g.n, | |
| 423 | + portes: g.portes, | |
| 424 | + valeur_totale_estimee_2026: r(g.total), | |
| 425 | + valeur_mediane_2026: med?.v ?? null, | |
| 426 | + par_gabarit: gabarits.map((t) => ({ | |
| 427 | + gabarit: t.gab, | |
| 428 | + plex: t.n, | |
| 429 | + total: r(t.total), | |
| 430 | + valeur_moyenne_par_porte: r(t.prix_porte_moyen), | |
| 431 | + })), | |
| 432 | + }; | |
| 433 | + } | |
| 434 | + | |
| 435 | + case "stats_provinciales": { | |
| 436 | + const s = stats as J; | |
| 437 | + const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10); | |
| 438 | + return { | |
| 439 | + valeur_totale_plex_quebec_2026: s.valeur_totale_2026, | |
| 440 | + valeur_role_totale: s.valeur_role_totale, | |
| 441 | + plex: s.unites, | |
| 442 | + portes: s.logements, | |
| 443 | + valeur_mediane_2026: s.valeur_mediane_2026, | |
| 444 | + croissance_2021_2026_pct: s.croissance_2021_2026_pct, | |
| 445 | + municipalites: s.municipalites, | |
| 446 | + par_gabarit: s.par_type, | |
| 447 | + top_10_villes: villes, | |
| 448 | + }; | |
| 449 | + } | |
| 450 | + | |
| 451 | + case "evaluer_parc": { | |
| 452 | + const ids = (input.ids as string[] | undefined)?.slice(0, 40) ?? []; | |
| 453 | + if (ids.length < 1) return { erreur: "Fournir au moins un id." }; | |
| 454 | + const p = estimatePortfolio(ids); | |
| 455 | + if (!p.items.length) return { erreur: "Aucun plex valide trouvé." }; | |
| 456 | + const a = p.aggregates; | |
| 457 | + return { | |
| 458 | + plex_evalues: a.count, | |
| 459 | + valeur_totale: r(a.totalEstimate), | |
| 460 | + fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) }, | |
| 461 | + valeur_role_totale: r(a.totalRole), | |
| 462 | + ecart_vs_role_pct: r(a.ecartRolePct, 1), | |
| 463 | + confiance: { pct: a.confidencePct, niveau: a.confidenceLevel }, | |
| 464 | + portes_totales: a.totalDwellings, | |
| 465 | + valeur_par_porte_parc: | |
| 466 | + a.totalDwellings > 0 ? r(a.totalEstimate / a.totalDwellings) : null, | |
| 467 | + croissance_parc_2021_2026_pct: r(a.growthPct, 1), | |
| 468 | + par_ville: a.municipalities, | |
| 469 | + detail: p.items.map((i) => ({ | |
| 470 | + id: i.unit!.id, | |
| 471 | + adresse: i.unit!.adresse, | |
| 472 | + portes: i.unit!.nbLogements, | |
| 473 | + valeur: r(i.result.estimate), | |
| 474 | + confiance: i.result.confidenceLevel, | |
| 475 | + })), | |
| 476 | + }; | |
| 477 | + } | |
| 478 | + | |
| 479 | + case "comparer_plex": { | |
| 480 | + const ids = (input.ids as string[] | undefined)?.slice(0, 4) ?? []; | |
| 481 | + if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; | |
| 482 | + return { | |
| 483 | + comparaison: ids.map((id) => { | |
| 484 | + const e = estimateByUnitId(id); | |
| 485 | + if (!e || !e.unit) return { id, erreur: "introuvable" }; | |
| 486 | + const portes = e.unit.nbLogements; | |
| 487 | + const h21 = e.unit.history.find((h) => h.year === 2021)?.value; | |
| 488 | + const h26 = e.unit.history.find((h) => h.year === 2026)?.value; | |
| 489 | + return { | |
| 490 | + id, | |
| 491 | + adresse: e.unit.adresse, | |
| 492 | + municipalite: e.unit.municipalite, | |
| 493 | + gabarit: gabarit(portes), | |
| 494 | + valeur: r(e.result.estimate), | |
| 495 | + valeur_par_porte: portes ? r(e.result.estimate / portes) : null, | |
| 496 | + ecart_vs_role_pct: e.unit.valeurRole | |
| 497 | + ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1) | |
| 498 | + : null, | |
| 499 | + confiance: e.result.confidenceLevel, | |
| 500 | + croissance_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null, | |
| 501 | + }; | |
| 502 | + }), | |
| 503 | + }; | |
| 504 | + } | |
| 505 | + | |
| 506 | + case "estimation_manuelle": { | |
| 507 | + const e = estimateManual({ | |
| 508 | + municipality: String(input.municipalite ?? ""), | |
| 509 | + typeProp: "plex", | |
| 510 | + portes: input.portes ? Number(input.portes) : undefined, | |
| 511 | + floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined, | |
| 512 | + yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined, | |
| 513 | + landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined, | |
| 514 | + }); | |
| 515 | + if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." }; | |
| 516 | + return evalCard(e); | |
| 517 | + } | |
| 518 | + | |
| 519 | + case "chercher_plex_secteur": { | |
| 520 | + const mun = String(input.municipalite ?? ""); | |
| 521 | + const limit = Math.min(Number(input.limite) || 8, 15); | |
| 522 | + const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"]; | |
| 523 | + const args: unknown[] = [mun]; | |
| 524 | + if (input.portes_min) { | |
| 525 | + conds.push("nb_logements >= ?"); | |
| 526 | + args.push(Number(input.portes_min)); | |
| 527 | + } | |
| 528 | + if (input.portes_max) { | |
| 529 | + conds.push("nb_logements <= ?"); | |
| 530 | + args.push(Number(input.portes_max)); | |
| 531 | + } | |
| 532 | + if (input.valeur_max) { | |
| 533 | + conds.push("est_2026 <= ?"); | |
| 534 | + args.push(Number(input.valeur_max)); | |
| 535 | + } | |
| 536 | + if (input.valeur_min) { | |
| 537 | + conds.push("est_2026 >= ?"); | |
| 538 | + args.push(Number(input.valeur_min)); | |
| 539 | + } | |
| 540 | + const rows = getDb() | |
| 541 | + .prepare( | |
| 542 | + `SELECT * FROM units WHERE ${conds.join(" AND ")} | |
| 543 | + ORDER BY est_2026 DESC LIMIT ?` | |
| 544 | + ) | |
| 545 | + .all(...args, limit) as Parameters<typeof unitCard>[0][]; | |
| 546 | + if (!rows.length) return { resultats: [], note: "Aucun plex ne correspond aux critères." }; | |
| 547 | + return { | |
| 548 | + resultats: rows.map((u) => ({ | |
| 549 | + ...unitCard(u), | |
| 550 | + valeur_estimee_2026: r(u.est_2026), | |
| 551 | + valeur_par_porte: u.nb_logements ? r((u.est_2026 ?? 0) / u.nb_logements) : null, | |
| 552 | + })), | |
| 553 | + }; | |
| 554 | + } | |
| 555 | + | |
| 556 | + case "liens_rapports": { | |
| 557 | + const id = String(input.id ?? ""); | |
| 558 | + const u = getUnit(id); | |
| 559 | + if (!u) return { erreur: "Plex introuvable." }; | |
| 560 | + return { | |
| 561 | + fiche_complete: `https://www.valoplex.com/estimation/${encodeURIComponent(id)}`, | |
| 562 | + rapport_standard_pdf: `https://www.valoplex.com/api/report?id=${encodeURIComponent(id)}`, | |
| 563 | + rapport_professionnel_pdf: `https://www.valoplex.com/api/report/pro?id=${encodeURIComponent(id)}`, | |
| 564 | + note: "Le rapport standard inclut le pro forma ; le professionnel (6 pages) est le format bancaire.", | |
| 565 | + }; | |
| 566 | + } | |
| 567 | + | |
| 568 | + default: | |
| 569 | + return { erreur: `Outil inconnu : ${name}` }; | |
| 570 | + } | |
| 571 | +} | |
| 572 | + | |
| 573 | +export { DEFAULT_PARAMS, tgaReference }; | |
| 574 | ||