Ka — agent IA conversationnel (Claude Haiku 4.5, streaming, 11 outils)
Page /ka + route /api/ka : boucle agentique en streaming SSE branchée sur la base SQLite et le moteur d'estimation (recherche FTS, évaluation, comparables, indice de marché, stats municipales/provinciales, parc, comparaison, recherche par secteur, 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 10 changed files with +1,108 and −1
modified
.gitignore
+3 −0
@@ -12,3 +12,6 @@ data/ | ||
| 12 | 12 | *.log |
| 13 | 13 | test-results/ |
| 14 | 14 | playwright-report/ |
| 15 | + | |
| 16 | +# secrets — jamais versionnés | |
| 17 | +.env* | |
modified
package-lock.json
+73 −1
@@ -8,6 +8,7 @@ | ||
| 8 | 8 | "name": "vrai-prix", |
| 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", |
@@ -4348,6 +4385,12 @@ | ||
| 4348 | 4385 | "dev": true, |
| 4349 | 4386 | "license": "MIT" |
| 4350 | 4387 | }, |
| 4388 | + "node_modules/fast-sha256": { | |
| 4389 | + "version": "1.3.0", | |
| 4390 | + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", | |
| 4391 | + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", | |
| 4392 | + "license": "Unlicense" | |
| 4393 | + }, | |
| 4351 | 4394 | "node_modules/fastq": { |
| 4352 | 4395 | "version": "1.20.1", |
| 4353 | 4396 | "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", |
@@ -5359,6 +5402,19 @@ | ||
| 5359 | 5402 | "dev": true, |
| 5360 | 5403 | "license": "MIT" |
| 5361 | 5404 | }, |
| 5405 | + "node_modules/json-schema-to-ts": { | |
| 5406 | + "version": "3.1.1", | |
| 5407 | + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", | |
| 5408 | + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", | |
| 5409 | + "license": "MIT", | |
| 5410 | + "dependencies": { | |
| 5411 | + "@babel/runtime": "^7.18.3", | |
| 5412 | + "ts-algebra": "^2.0.0" | |
| 5413 | + }, | |
| 5414 | + "engines": { | |
| 5415 | + "node": ">=16" | |
| 5416 | + } | |
| 5417 | + }, | |
| 5362 | 5418 | "node_modules/json-schema-traverse": { |
| 5363 | 5419 | "version": "0.4.1", |
| 5364 | 5420 | "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", |
@@ -6959,6 +7015,16 @@ | ||
| 6959 | 7015 | "dev": true, |
| 6960 | 7016 | "license": "MIT" |
| 6961 | 7017 | }, |
| 7018 | + "node_modules/standardwebhooks": { | |
| 7019 | + "version": "1.0.0", | |
| 7020 | + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", | |
| 7021 | + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", | |
| 7022 | + "license": "MIT", | |
| 7023 | + "dependencies": { | |
| 7024 | + "@stablelib/base64": "^1.0.0", | |
| 7025 | + "fast-sha256": "^1.3.0" | |
| 7026 | + } | |
| 7027 | + }, | |
| 6962 | 7028 | "node_modules/std-env": { |
| 6963 | 7029 | "version": "4.2.0", |
| 6964 | 7030 | "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", |
@@ -7281,6 +7347,12 @@ | ||
| 7281 | 7347 | "node": ">=8.0" |
| 7282 | 7348 | } |
| 7283 | 7349 | }, |
| 7350 | + "node_modules/ts-algebra": { | |
| 7351 | + "version": "2.0.0", | |
| 7352 | + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", | |
| 7353 | + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", | |
| 7354 | + "license": "MIT" | |
| 7355 | + }, | |
| 7284 | 7356 | "node_modules/ts-api-utils": { |
| 7285 | 7357 | "version": "2.5.0", |
| 7286 | 7358 | "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", |
@@ -8209,7 +8281,7 @@ | ||
| 8209 | 8281 | "version": "4.4.3", |
| 8210 | 8282 | "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", |
| 8211 | 8283 | "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", |
| 8212 | − "dev": true, | |
| 8284 | + "devOptional": true, | |
| 8213 | 8285 | "license": "MIT", |
| 8214 | 8286 | "funding": { |
| 8215 | 8287 | "url": "https://github.com/sponsors/colinhacks" |
modified
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
src/app/api/ka/route.ts
+164 −0
@@ -0,0 +1,164 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Ka — agent IA de Vrai-Prix. Boucle agentique en streaming : | |
| 4 | + * Claude Haiku 4.5 + panoplie d'outils serveur (base SQLite + moteur). | |
| 5 | + * La réponse est un flux SSE : deltas de texte, activité d'outils, fin. | |
| 6 | + * Le prompt système et les outils sont mis en cache (prompt caching). | |
| 7 | + */ | |
| 8 | +import Anthropic from "@anthropic-ai/sdk"; | |
| 9 | +import { KA_TOOLS, runKaTool } from "@/lib/ka/tools"; | |
| 10 | + | |
| 11 | +export const runtime = "nodejs"; | |
| 12 | +export const maxDuration = 120; | |
| 13 | + | |
| 14 | +const MODEL = "claude-haiku-4-5"; | |
| 15 | +const MAX_TURNS = 8; | |
| 16 | +const MAX_MESSAGES = 40; | |
| 17 | +const MAX_CHARS = 4000; | |
| 18 | + | |
| 19 | +const SYSTEM = `Tu es Ka, l'agent d'évaluation immobilière de Vrai-Prix (www.vrai-prix.com), créé par Simon-Pierre Boucher. Tu aides les gens à connaître la vraie valeur de leur propriété au Québec — unifamiliale, condo, plex, chalet, terrain — en t'appuyant sur 3 747 008 unités d'évaluation, 745 119 ventes réelles (2021-2026) et un modèle hédonique validé (erreur médiane 11 %). | |
| 20 | + | |
| 21 | +# Ta démarche | |
| 22 | +1. Quand on te donne une adresse (même partielle), appelle chercher_propriete immédiatement. | |
| 23 | +2. S'il y a UN candidat clair, évalue-le directement (evaluer_propriete). S'il y en a plusieurs plausibles, liste-les brièvement (adresse, municipalité, type) et demande à l'utilisateur de choisir — ne devine jamais. | |
| 24 | +3. Présente le résultat comme un pro : valeur estimée en évidence, fourchette P10-P90, indice de confiance (explique-le : A = très fiable … D = marché mince), écart vs l'évaluation municipale, valeur au m². | |
| 25 | +4. Donne ensuite 2-3 insights concrets tirés des données : tendance 2021→2026 de la propriété, comparaison au marché local (stats_municipalite), tendance du marché (indice_marche), comparables marquants (comparables_detailles). Choisis les outils pertinents — n'appelle pas tout systématiquement. | |
| 26 | +5. 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 mais professionnel. | |
| 30 | +- Concis et concret : des chiffres, pas du remplissage. Formate les montants à la québécoise (ex. 445 000 $) et arrondis intelligemment (au millier près). | |
| 31 | +- Transparence totale : c'est l'ADN de Vrai-Prix. Explique d'où viennent les chiffres quand on te le demande. | |
| 32 | +- Markdown léger : gras pour la valeur clé, listes courtes. Pas de tableaux géants. | |
| 33 | + | |
| 34 | +# Limites (à respecter strictement) | |
| 35 | +- Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier. | |
| 36 | +- Ne réponds qu'aux sujets liés à l'immobilier québécois et à Vrai-Prix. Pour tout autre sujet, ramène poliment la conversation. | |
| 37 | +- N'invente JAMAIS un chiffre : tout montant cité doit venir d'un outil. Si un outil échoue, dis-le simplement. | |
| 38 | +- Tu ne peux pas voir l'intérieur des propriétés : les rénovations récentes ne sont pas captées, mentionne-le si pertinent.`; | |
| 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 | + // exécuter les outils demandés, renvoyer TOUS les résultats en un seul message | |
| 115 | + messages.push({ role: "assistant", content: final.content }); | |
| 116 | + const results: Anthropic.ToolResultBlockParam[] = []; | |
| 117 | + for (const block of final.content) { | |
| 118 | + if (block.type !== "tool_use") continue; | |
| 119 | + sse(controller, { type: "tool", name: block.name }); | |
| 120 | + let out: unknown; | |
| 121 | + try { | |
| 122 | + out = runKaTool(block.name, block.input as Record<string, unknown>); | |
| 123 | + } catch (e) { | |
| 124 | + out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` }; | |
| 125 | + } | |
| 126 | + results.push({ | |
| 127 | + type: "tool_result", | |
| 128 | + tool_use_id: block.id, | |
| 129 | + content: JSON.stringify(out), | |
| 130 | + }); | |
| 131 | + } | |
| 132 | + messages.push({ role: "user", content: results }); | |
| 133 | + | |
| 134 | + if (turn === MAX_TURNS - 1) { | |
| 135 | + sse(controller, { | |
| 136 | + type: "text", | |
| 137 | + text: "\n\n*(J'ai atteint ma limite d'étapes pour cette réponse — repose ta question pour continuer.)*", | |
| 138 | + }); | |
| 139 | + sse(controller, { type: "done" }); | |
| 140 | + } | |
| 141 | + } | |
| 142 | + } catch (e) { | |
| 143 | + sse(controller, { | |
| 144 | + type: "error", | |
| 145 | + message: | |
| 146 | + e instanceof Anthropic.APIError | |
| 147 | + ? `Service momentanément indisponible (${e.status ?? "réseau"}). Réessaie dans un instant.` | |
| 148 | + : "Une erreur inattendue est survenue. Réessaie.", | |
| 149 | + }); | |
| 150 | + } finally { | |
| 151 | + controller.close(); | |
| 152 | + } | |
| 153 | + }, | |
| 154 | + }); | |
| 155 | + | |
| 156 | + return new Response(stream, { | |
| 157 | + headers: { | |
| 158 | + "Content-Type": "text/event-stream; charset=utf-8", | |
| 159 | + "Cache-Control": "no-cache, no-transform", | |
| 160 | + Connection: "keep-alive", | |
| 161 | + "X-Accel-Buffering": "no", | |
| 162 | + }, | |
| 163 | + }); | |
| 164 | +} | |
added
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 | + ["11 outils", "registre, moteur, marché"], | |
| 13 | + ["3,7 M", "unités interrogeables"], | |
| 14 | + ["En direct", "réponse en streaming"], | |
| 15 | + ] | |
| 16 | + : [ | |
| 17 | + ["11 tools", "registry, engine, market"], | |
| 18 | + ["3.7M", "queryable units"], | |
| 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-[620px] 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 connaît chaque adresse du Québec. | |
| 32 | + </> | |
| 33 | + ) : ( | |
| 34 | + <> | |
| 35 | + Chat with <span className="hl">Ka</span> — it knows every Québec address. | |
| 36 | + </> | |
| 37 | + )} | |
| 38 | + </h1> | |
| 39 | + <p className="mx-auto mt-3 max-w-[560px] text-[14.5px] text-ink-2"> | |
| 40 | + {fr | |
| 41 | + ? "Donne-lui une adresse : il la retrouve dans le registre, l'évalue avec le même moteur transparent que Vrai-Prix, puis t'explique le prix — comparables, marché local, tendance." | |
| 42 | + : "Give it an address: it finds it in the registry, values it with Vrai-Prix's transparent engine, then explains the price — comparables, local market, trend."} | |
| 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
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 Vrai-Prix", | |
| 7 | + description: | |
| 8 | + "Ka évalue n'importe quelle propriété du Québec en conversant : recherche dans le registre, évaluation transparente, comparables, insights de marché — en direct.", | |
| 9 | +}; | |
| 10 | + | |
| 11 | +export default function KaPage() { | |
| 12 | + return <KaHero />; | |
| 13 | +} | |
modified
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
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
src/components/KaChat.tsx
+326 −0
@@ -0,0 +1,326 @@ | ||
| 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_propriete: { fr: "Recherche dans le registre", en: "Searching the registry" }, | |
| 18 | + evaluer_propriete: { fr: "Évaluation en cours", en: "Running the valuation" }, | |
| 19 | + comparables_detailles: { fr: "Analyse des comparables", en: "Analyzing comparables" }, | |
| 20 | + indice_marche: { fr: "Lecture de l'indice de marché", en: "Reading the market index" }, | |
| 21 | + stats_municipalite: { fr: "Statistiques municipales", en: "Municipal statistics" }, | |
| 22 | + stats_provinciales: { fr: "Statistiques provinciales", en: "Provincial statistics" }, | |
| 23 | + evaluer_parc: { fr: "Évaluation du parc", en: "Evaluating the portfolio" }, | |
| 24 | + comparer_proprietes: { fr: "Comparaison des propriétés", en: "Comparing properties" }, | |
| 25 | + estimation_manuelle: { fr: "Estimation par caractéristiques", en: "Estimating from specs" }, | |
| 26 | + chercher_proprietes_secteur: { fr: "Balayage du secteur", en: "Scanning the area" }, | |
| 27 | + liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" }, | |
| 28 | +}; | |
| 29 | + | |
| 30 | +/** Rendu markdown minimal et sûr : gras, italique, listes, liens, paragraphes. */ | |
| 31 | +function renderMd(text: string): React.ReactNode[] { | |
| 32 | + const inline = (s: string, key: string): React.ReactNode[] => { | |
| 33 | + const parts: React.ReactNode[] = []; | |
| 34 | + // liens [txt](url) puis **gras** puis *italique* | |
| 35 | + const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g; | |
| 36 | + let last = 0; | |
| 37 | + let m: RegExpExecArray | null; | |
| 38 | + let i = 0; | |
| 39 | + while ((m = rx.exec(s))) { | |
| 40 | + if (m.index > last) parts.push(s.slice(last, m.index)); | |
| 41 | + if (m[1] && m[2]) | |
| 42 | + parts.push( | |
| 43 | + <a key={`${key}-${i++}`} href={m[2]} target="_blank" rel="noopener noreferrer" className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"> | |
| 44 | + {m[1]} | |
| 45 | + </a> | |
| 46 | + ); | |
| 47 | + else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>); | |
| 48 | + else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>); | |
| 49 | + last = m.index + m[0].length; | |
| 50 | + } | |
| 51 | + if (last < s.length) parts.push(s.slice(last)); | |
| 52 | + return parts; | |
| 53 | + }; | |
| 54 | + | |
| 55 | + const blocks: React.ReactNode[] = []; | |
| 56 | + const lines = text.split("\n"); | |
| 57 | + let list: string[] = []; | |
| 58 | + let k = 0; | |
| 59 | + const flushList = () => { | |
| 60 | + if (!list.length) return; | |
| 61 | + blocks.push( | |
| 62 | + <ul key={`ul-${k++}`} className="my-1.5 space-y-1 pl-1"> | |
| 63 | + {list.map((li, j) => ( | |
| 64 | + <li key={j} className="flex gap-2"> | |
| 65 | + <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" /> | |
| 66 | + <span>{inline(li, `li-${k}-${j}`)}</span> | |
| 67 | + </li> | |
| 68 | + ))} | |
| 69 | + </ul> | |
| 70 | + ); | |
| 71 | + list = []; | |
| 72 | + }; | |
| 73 | + for (const line of lines) { | |
| 74 | + const t = line.trim(); | |
| 75 | + if (/^[-•]\s+/.test(t)) { | |
| 76 | + list.push(t.replace(/^[-•]\s+/, "")); | |
| 77 | + continue; | |
| 78 | + } | |
| 79 | + flushList(); | |
| 80 | + if (t === "") continue; | |
| 81 | + if (/^#{1,4}\s+/.test(t)) { | |
| 82 | + blocks.push( | |
| 83 | + <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold"> | |
| 84 | + {inline(t.replace(/^#{1,4}\s+/, ""), `h-${k}`)} | |
| 85 | + </p> | |
| 86 | + ); | |
| 87 | + } else { | |
| 88 | + blocks.push( | |
| 89 | + <p key={`p-${k++}`} className="my-1.5"> | |
| 90 | + {inline(t, `p-${k}`)} | |
| 91 | + </p> | |
| 92 | + ); | |
| 93 | + } | |
| 94 | + } | |
| 95 | + flushList(); | |
| 96 | + return blocks; | |
| 97 | +} | |
| 98 | + | |
| 99 | +export default function KaChat() { | |
| 100 | + const { lang } = useLang(); | |
| 101 | + const fr = lang === "fr"; | |
| 102 | + const [messages, setMessages] = useState<Msg[]>([]); | |
| 103 | + const [input, setInput] = useState(""); | |
| 104 | + const [busy, setBusy] = useState(false); | |
| 105 | + const [activeTool, setActiveTool] = useState<string | null>(null); | |
| 106 | + const endRef = useRef<HTMLDivElement>(null); | |
| 107 | + const abortRef = useRef<AbortController | null>(null); | |
| 108 | + | |
| 109 | + useEffect(() => { | |
| 110 | + endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); | |
| 111 | + }, [messages, activeTool]); | |
| 112 | + | |
| 113 | + const suggestions = fr | |
| 114 | + ? [ | |
| 115 | + "Combien vaut le 861 route Elgin à Saint-Pamphile ?", | |
| 116 | + "Le marché des condos monte-t-il au Québec ?", | |
| 117 | + "Trouve-moi une maison à Lévis sous 450 000 $", | |
| 118 | + "Que vaut tout l'immobilier du Québec ?", | |
| 119 | + ] | |
| 120 | + : [ | |
| 121 | + "How much is 861 route Elgin in Saint-Pamphile worth?", | |
| 122 | + "Is the Québec condo market going up?", | |
| 123 | + "Find me a house in Lévis under $450,000", | |
| 124 | + "What is all Québec real estate worth?", | |
| 125 | + ]; | |
| 126 | + | |
| 127 | + async function send(text: string) { | |
| 128 | + const content = text.trim(); | |
| 129 | + if (!content || busy) return; | |
| 130 | + setInput(""); | |
| 131 | + setBusy(true); | |
| 132 | + setActiveTool(null); | |
| 133 | + | |
| 134 | + const history = [...messages, { role: "user" as const, content }]; | |
| 135 | + setMessages([...history, { role: "assistant", content: "", tools: [] }]); | |
| 136 | + | |
| 137 | + const ctrl = new AbortController(); | |
| 138 | + abortRef.current = ctrl; | |
| 139 | + | |
| 140 | + try { | |
| 141 | + const res = await fetch("/api/ka", { | |
| 142 | + method: "POST", | |
| 143 | + headers: { "Content-Type": "application/json" }, | |
| 144 | + body: JSON.stringify({ | |
| 145 | + messages: history.map(({ role, content }) => ({ role, content })), | |
| 146 | + }), | |
| 147 | + signal: ctrl.signal, | |
| 148 | + }); | |
| 149 | + if (!res.ok || !res.body) throw new Error(String(res.status)); | |
| 150 | + | |
| 151 | + const reader = res.body.getReader(); | |
| 152 | + const decoder = new TextDecoder(); | |
| 153 | + let buffer = ""; | |
| 154 | + | |
| 155 | + const patch = (fn: (m: Msg) => Msg) => | |
| 156 | + setMessages((prev) => { | |
| 157 | + const next = [...prev]; | |
| 158 | + next[next.length - 1] = fn(next[next.length - 1]); | |
| 159 | + return next; | |
| 160 | + }); | |
| 161 | + | |
| 162 | + for (;;) { | |
| 163 | + const { done, value } = await reader.read(); | |
| 164 | + if (done) break; | |
| 165 | + buffer += decoder.decode(value, { stream: true }); | |
| 166 | + const events = buffer.split("\n\n"); | |
| 167 | + buffer = events.pop() ?? ""; | |
| 168 | + for (const ev of events) { | |
| 169 | + const line = ev.split("\n").find((l) => l.startsWith("data: ")); | |
| 170 | + if (!line) continue; | |
| 171 | + let data: { type: string; text?: string; name?: string; message?: string }; | |
| 172 | + try { | |
| 173 | + data = JSON.parse(line.slice(6)); | |
| 174 | + } catch { | |
| 175 | + continue; | |
| 176 | + } | |
| 177 | + if (data.type === "text" && data.text) { | |
| 178 | + setActiveTool(null); | |
| 179 | + patch((m) => ({ ...m, content: m.content + data.text })); | |
| 180 | + } else if (data.type === "tool" && data.name) { | |
| 181 | + setActiveTool(data.name); | |
| 182 | + patch((m) => ({ | |
| 183 | + ...m, | |
| 184 | + tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!], | |
| 185 | + })); | |
| 186 | + } else if (data.type === "error") { | |
| 187 | + patch((m) => ({ | |
| 188 | + ...m, | |
| 189 | + content: m.content + (m.content ? "\n\n" : "") + `*${data.message}*`, | |
| 190 | + })); | |
| 191 | + } | |
| 192 | + } | |
| 193 | + } | |
| 194 | + } catch { | |
| 195 | + setMessages((prev) => { | |
| 196 | + const next = [...prev]; | |
| 197 | + const last = next[next.length - 1]; | |
| 198 | + if (last?.role === "assistant" && !last.content) { | |
| 199 | + last.content = fr | |
| 200 | + ? "*Connexion interrompue — réessaie dans un instant.*" | |
| 201 | + : "*Connection lost — try again in a moment.*"; | |
| 202 | + } | |
| 203 | + return next; | |
| 204 | + }); | |
| 205 | + } finally { | |
| 206 | + setActiveTool(null); | |
| 207 | + setBusy(false); | |
| 208 | + abortRef.current = null; | |
| 209 | + } | |
| 210 | + } | |
| 211 | + | |
| 212 | + const toolLabel = (name: string) => | |
| 213 | + TOOL_LABELS[name]?.[fr ? "fr" : "en"] ?? name.replace(/_/g, " "); | |
| 214 | + | |
| 215 | + return ( | |
| 216 | + <div className="mx-auto flex w-full max-w-[860px] flex-col"> | |
| 217 | + {/* fil de conversation */} | |
| 218 | + <div className="vp-card flex min-h-[54vh] flex-col gap-5 p-5 sm:p-7"> | |
| 219 | + {messages.length === 0 && ( | |
| 220 | + <div className="my-auto text-center"> | |
| 221 | + <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)]"> | |
| 222 | + <span className="vp-display text-[26px] font-bold text-lime">Ka</span> | |
| 223 | + </div> | |
| 224 | + <p className="vp-display text-[19px] font-bold"> | |
| 225 | + {fr ? "Dis-moi quelle propriété t'intéresse." : "Tell me which property you're curious about."} | |
| 226 | + </p> | |
| 227 | + <p className="mx-auto mt-1.5 max-w-[440px] text-[13.5px] text-ink-2"> | |
| 228 | + {fr | |
| 229 | + ? "Je fouille 3,7 millions d'unités d'évaluation et 745 000 ventes réelles, en direct, pendant qu'on jase." | |
| 230 | + : "I search 3.7M assessment units and 745k real sales, live, while we chat."} | |
| 231 | + </p> | |
| 232 | + <div className="mx-auto mt-5 flex max-w-[560px] flex-wrap justify-center gap-2"> | |
| 233 | + {suggestions.map((s) => ( | |
| 234 | + <button | |
| 235 | + key={s} | |
| 236 | + onClick={() => send(s)} | |
| 237 | + 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)]" | |
| 238 | + > | |
| 239 | + {s} | |
| 240 | + </button> | |
| 241 | + ))} | |
| 242 | + </div> | |
| 243 | + </div> | |
| 244 | + )} | |
| 245 | + | |
| 246 | + {messages.map((m, i) => ( | |
| 247 | + <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}> | |
| 248 | + {m.role === "user" ? ( | |
| 249 | + <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"> | |
| 250 | + {m.content} | |
| 251 | + </div> | |
| 252 | + ) : ( | |
| 253 | + <div className="flex max-w-[92%] gap-3"> | |
| 254 | + <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"> | |
| 255 | + <span className="vp-display text-[12px] font-bold text-lime">Ka</span> | |
| 256 | + </div> | |
| 257 | + <div className="min-w-0"> | |
| 258 | + {m.tools && m.tools.length > 0 && ( | |
| 259 | + <div className="mb-1.5 flex flex-wrap gap-1.5"> | |
| 260 | + {m.tools.map((t) => ( | |
| 261 | + <span | |
| 262 | + key={t} | |
| 263 | + 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" | |
| 264 | + > | |
| 265 | + <span | |
| 266 | + className={`h-[6px] w-[6px] rounded-full ${ | |
| 267 | + busy && i === messages.length - 1 && activeTool === t | |
| 268 | + ? "animate-pulse bg-[var(--lime)]" | |
| 269 | + : "bg-[var(--green)]" | |
| 270 | + }`} | |
| 271 | + /> | |
| 272 | + {toolLabel(t)} | |
| 273 | + </span> | |
| 274 | + ))} | |
| 275 | + </div> | |
| 276 | + )} | |
| 277 | + <div className="text-[14px] leading-relaxed"> | |
| 278 | + {m.content ? ( | |
| 279 | + renderMd(m.content) | |
| 280 | + ) : busy && i === messages.length - 1 ? ( | |
| 281 | + <span className="inline-flex gap-1 py-1"> | |
| 282 | + <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:0ms]" /> | |
| 283 | + <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:120ms]" /> | |
| 284 | + <span className="h-2 w-2 animate-bounce rounded-full bg-ink-3 [animation-delay:240ms]" /> | |
| 285 | + </span> | |
| 286 | + ) : null} | |
| 287 | + </div> | |
| 288 | + </div> | |
| 289 | + </div> | |
| 290 | + )} | |
| 291 | + </div> | |
| 292 | + ))} | |
| 293 | + <div ref={endRef} /> | |
| 294 | + </div> | |
| 295 | + | |
| 296 | + {/* zone de saisie */} | |
| 297 | + <form | |
| 298 | + onSubmit={(e) => { | |
| 299 | + e.preventDefault(); | |
| 300 | + send(input); | |
| 301 | + }} | |
| 302 | + className="mt-4 flex gap-2.5" | |
| 303 | + > | |
| 304 | + <input | |
| 305 | + value={input} | |
| 306 | + onChange={(e) => setInput(e.target.value)} | |
| 307 | + placeholder={ | |
| 308 | + fr ? "Écris une adresse ou une question…" : "Type an address or a question…" | |
| 309 | + } | |
| 310 | + className="vp-input flex-1" | |
| 311 | + maxLength={2000} | |
| 312 | + aria-label={fr ? "Message à Ka" : "Message to Ka"} | |
| 313 | + /> | |
| 314 | + <button type="submit" disabled={busy || !input.trim()} className="btn btn-primary shrink-0 disabled:opacity-50"> | |
| 315 | + {busy ? (fr ? "Ka réfléchit…" : "Ka is thinking…") : fr ? "Envoyer" : "Send"} | |
| 316 | + </button> | |
| 317 | + </form> | |
| 318 | + | |
| 319 | + <p className="vp-mono mt-3 text-center text-[10px] uppercase tracking-[0.08em] text-ink-3"> | |
| 320 | + {fr | |
| 321 | + ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé" | |
| 322 | + : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"} | |
| 323 | + </p> | |
| 324 | + </div> | |
| 325 | + ); | |
| 326 | +} | |
added
src/lib/ka/tools.ts
+468 −0
@@ -0,0 +1,468 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +/** | |
| 3 | + * Ka — la panoplie d'outils de l'agent IA de Vrai-Prix. | |
| 4 | + * Chaque outil est exécuté côté serveur, directement sur la base SQLite | |
| 5 | + * (3,75 M unités) et le moteur d'estimation. Les sorties sont des JSON | |
| 6 | + * compacts : Ka lit vite, coûte peu, et cite des chiffres exacts. | |
| 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 | + indexType, | |
| 15 | + type UnitEstimate, | |
| 16 | +} from "../estimator"; | |
| 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 unitCard(u: ReturnType<typeof searchUnits>[number]) { | |
| 25 | + return { | |
| 26 | + id: u.id_provinc, | |
| 27 | + adresse: [u.adresse, u.apt ? `app. ${u.apt}` : null].filter(Boolean).join(", "), | |
| 28 | + municipalite: u.municipalite, | |
| 29 | + arrondissement: u.arrond, | |
| 30 | + type: u.type_prop, | |
| 31 | + usage: u.cubf_libelle, | |
| 32 | + annee_construction: u.annee_construction, | |
| 33 | + aire_habitable_m2: r(u.aire_etages_m2, 1), | |
| 34 | + terrain_m2: r(u.superficie_terrain_m2), | |
| 35 | + logements: u.nb_logements, | |
| 36 | + valeur_role_2026: u.valeur_role, | |
| 37 | + }; | |
| 38 | +} | |
| 39 | + | |
| 40 | +function evalCard(e: UnitEstimate) { | |
| 41 | + const u = e.unit; | |
| 42 | + const res = e.result; | |
| 43 | + return { | |
| 44 | + unite: u | |
| 45 | + ? { | |
| 46 | + id: u.id, | |
| 47 | + adresse: u.adresse, | |
| 48 | + municipalite: u.municipalite, | |
| 49 | + type: u.typeProp, | |
| 50 | + usage: u.cubfLibelle, | |
| 51 | + annee_construction: u.anneeConstruction, | |
| 52 | + aire_habitable_m2: r(u.aireEtagesM2, 1), | |
| 53 | + terrain_m2: r(u.superficieTerrainM2), | |
| 54 | + logements: u.nbLogements, | |
| 55 | + etages: u.specs.nbEtages, | |
| 56 | + genre_construction: u.specs.genreConstruction, | |
| 57 | + lien_physique: u.specs.lienPhysique, | |
| 58 | + valeur_role_2026: u.valeurRole, | |
| 59 | + valeur_terrain_role: u.specs.valeurTerrain, | |
| 60 | + valeur_batiment_role: u.specs.valeurBatiment, | |
| 61 | + valeur_role_anterieure: u.specs.valeurAnterieure, | |
| 62 | + } | |
| 63 | + : null, | |
| 64 | + estimation: { | |
| 65 | + valeur: r(res.estimate), | |
| 66 | + fourchette_basse_p10: r(res.low), | |
| 67 | + fourchette_haute_p90: r(res.high), | |
| 68 | + confiance_pct: res.confidencePct, | |
| 69 | + confiance_niveau: res.confidenceLevel, | |
| 70 | + part_modele_hedonique: r(res.modelEstimate), | |
| 71 | + part_comparables: r(res.compsEstimate), | |
| 72 | + poids_modele: r(res.modelWeight, 2), | |
| 73 | + n_comparables_utilises: res.nCompsUsed, | |
| 74 | + dispersion_comparables_pct: r(res.compsDispersionPct, 1), | |
| 75 | + valeur_par_m2: u?.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null, | |
| 76 | + ecart_vs_role_pct: u?.valeurRole | |
| 77 | + ? r((res.estimate / u.valeurRole - 1) * 100, 1) | |
| 78 | + : null, | |
| 79 | + }, | |
| 80 | + historique_2021_2026: u?.history ?? null, | |
| 81 | + }; | |
| 82 | +} | |
| 83 | + | |
| 84 | +/* ------------------------------------------------------------ tool defs */ | |
| 85 | + | |
| 86 | +export const KA_TOOLS: Anthropic.Tool[] = [ | |
| 87 | + { | |
| 88 | + name: "chercher_propriete", | |
| 89 | + description: | |
| 90 | + "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. Retourne jusqu'à `limite` candidats avec leur id. S'il y a plusieurs candidats plausibles, présente-les à l'utilisateur et demande-lui de préciser AVANT d'évaluer. Astuce : « numéro rue municipalité » fonctionne le mieux.", | |
| 91 | + input_schema: { | |
| 92 | + type: "object", | |
| 93 | + properties: { | |
| 94 | + requete: { | |
| 95 | + type: "string", | |
| 96 | + description: "Adresse ou fragment d'adresse, ex. « 861 route Elgin Saint-Pamphile »", | |
| 97 | + }, | |
| 98 | + limite: { type: "number", description: "Nombre max de résultats (défaut 6, max 12)" }, | |
| 99 | + }, | |
| 100 | + required: ["requete"], | |
| 101 | + }, | |
| 102 | + }, | |
| 103 | + { | |
| 104 | + name: "evaluer_propriete", | |
| 105 | + description: | |
| 106 | + "É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.", | |
| 107 | + input_schema: { | |
| 108 | + type: "object", | |
| 109 | + properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } }, | |
| 110 | + required: ["id"], | |
| 111 | + }, | |
| 112 | + }, | |
| 113 | + { | |
| 114 | + name: "comparables_detailles", | |
| 115 | + description: | |
| 116 | + "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.", | |
| 117 | + input_schema: { | |
| 118 | + type: "object", | |
| 119 | + properties: { | |
| 120 | + id: { type: "string", description: "Identifiant provincial de l'unité" }, | |
| 121 | + max: { type: "number", description: "Nombre max de comparables (défaut 8)" }, | |
| 122 | + }, | |
| 123 | + required: ["id"], | |
| 124 | + }, | |
| 125 | + }, | |
| 126 | + { | |
| 127 | + name: "indice_marche", | |
| 128 | + description: | |
| 129 | + "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 ? ».", | |
| 130 | + input_schema: { | |
| 131 | + type: "object", | |
| 132 | + properties: { | |
| 133 | + type: { | |
| 134 | + type: "string", | |
| 135 | + enum: ["unifamilial", "condo", "plex"], | |
| 136 | + description: "Type de marché", | |
| 137 | + }, | |
| 138 | + }, | |
| 139 | + required: ["type"], | |
| 140 | + }, | |
| 141 | + }, | |
| 142 | + { | |
| 143 | + name: "stats_municipalite", | |
| 144 | + description: | |
| 145 | + "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.", | |
| 146 | + input_schema: { | |
| 147 | + type: "object", | |
| 148 | + properties: { municipalite: { type: "string", description: "Nom exact de la municipalité, ex. « Lévis »" } }, | |
| 149 | + required: ["municipalite"], | |
| 150 | + }, | |
| 151 | + }, | |
| 152 | + { | |
| 153 | + name: "stats_provinciales", | |
| 154 | + description: | |
| 155 | + "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.", | |
| 156 | + input_schema: { type: "object", properties: {} }, | |
| 157 | + }, | |
| 158 | + { | |
| 159 | + name: "evaluer_parc", | |
| 160 | + description: | |
| 161 | + "É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.", | |
| 162 | + input_schema: { | |
| 163 | + type: "object", | |
| 164 | + properties: { | |
| 165 | + ids: { | |
| 166 | + type: "array", | |
| 167 | + items: { type: "string" }, | |
| 168 | + description: "Identifiants provinciaux des unités du parc", | |
| 169 | + }, | |
| 170 | + }, | |
| 171 | + required: ["ids"], | |
| 172 | + }, | |
| 173 | + }, | |
| 174 | + { | |
| 175 | + name: "comparer_proprietes", | |
| 176 | + description: | |
| 177 | + "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.", | |
| 178 | + input_schema: { | |
| 179 | + type: "object", | |
| 180 | + properties: { | |
| 181 | + ids: { type: "array", items: { type: "string" }, description: "2 à 4 identifiants" }, | |
| 182 | + }, | |
| 183 | + required: ["ids"], | |
| 184 | + }, | |
| 185 | + }, | |
| 186 | + { | |
| 187 | + name: "estimation_manuelle", | |
| 188 | + description: | |
| 189 | + "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.", | |
| 190 | + input_schema: { | |
| 191 | + type: "object", | |
| 192 | + properties: { | |
| 193 | + municipalite: { type: "string" }, | |
| 194 | + type: { | |
| 195 | + type: "string", | |
| 196 | + enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"], | |
| 197 | + }, | |
| 198 | + aire_habitable_m2: { type: "number" }, | |
| 199 | + annee_construction: { type: "number" }, | |
| 200 | + terrain_m2: { type: "number" }, | |
| 201 | + }, | |
| 202 | + required: ["municipalite", "type"], | |
| 203 | + }, | |
| 204 | + }, | |
| 205 | + { | |
| 206 | + name: "chercher_proprietes_secteur", | |
| 207 | + description: | |
| 208 | + "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$ ».", | |
| 209 | + input_schema: { | |
| 210 | + type: "object", | |
| 211 | + properties: { | |
| 212 | + municipalite: { type: "string" }, | |
| 213 | + type: { | |
| 214 | + type: "string", | |
| 215 | + enum: ["unifamilial", "plex", "condo_ou_multi", "chalet", "maison_mobile", "terrain"], | |
| 216 | + }, | |
| 217 | + valeur_max: { type: "number", description: "Budget maximal ($)" }, | |
| 218 | + valeur_min: { type: "number", description: "Valeur minimale ($)" }, | |
| 219 | + aire_min_m2: { type: "number" }, | |
| 220 | + limite: { type: "number", description: "Max résultats (défaut 8, max 15)" }, | |
| 221 | + }, | |
| 222 | + required: ["municipalite"], | |
| 223 | + }, | |
| 224 | + }, | |
| 225 | + { | |
| 226 | + name: "liens_rapports", | |
| 227 | + description: | |
| 228 | + "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.", | |
| 229 | + input_schema: { | |
| 230 | + type: "object", | |
| 231 | + properties: { id: { type: "string" } }, | |
| 232 | + required: ["id"], | |
| 233 | + }, | |
| 234 | + }, | |
| 235 | +]; | |
| 236 | + | |
| 237 | +/* ------------------------------------------------------------ execution */ | |
| 238 | + | |
| 239 | +type J = Record<string, unknown>; | |
| 240 | + | |
| 241 | +function growth(idx: { month: string; idx: number }[], months: number): number | null { | |
| 242 | + if (idx.length < months + 1) return null; | |
| 243 | + const last = idx[idx.length - 1]; | |
| 244 | + const past = idx[idx.length - 1 - months]; | |
| 245 | + return past.idx > 0 ? r((last.idx / past.idx - 1) * 100, 1) : null; | |
| 246 | +} | |
| 247 | + | |
| 248 | +export function runKaTool(name: string, input: J): unknown { | |
| 249 | + switch (name) { | |
| 250 | + case "chercher_propriete": { | |
| 251 | + const limit = Math.min(Number(input.limite) || 6, 12); | |
| 252 | + const rows = searchUnits(String(input.requete ?? ""), limit); | |
| 253 | + if (!rows.length) | |
| 254 | + return { | |
| 255 | + resultats: [], | |
| 256 | + conseil: | |
| 257 | + "Aucun résultat. Essayer une graphie différente (« numéro rue municipalité »), sans abréviations, ou proposer estimation_manuelle.", | |
| 258 | + }; | |
| 259 | + return { resultats: rows.map(unitCard) }; | |
| 260 | + } | |
| 261 | + | |
| 262 | + case "evaluer_propriete": { | |
| 263 | + const e = estimateByUnitId(String(input.id ?? "")); | |
| 264 | + if (!e) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; | |
| 265 | + return evalCard(e); | |
| 266 | + } | |
| 267 | + | |
| 268 | + case "comparables_detailles": { | |
| 269 | + const e = estimateByUnitId(String(input.id ?? "")); | |
| 270 | + if (!e) return { erreur: "Unité introuvable." }; | |
| 271 | + const max = Math.min(Number(input.max) || 8, 15); | |
| 272 | + return { | |
| 273 | + n_utilises: e.result.nCompsUsed, | |
| 274 | + comparables: e.result.comps.slice(0, max).map((c) => ({ | |
| 275 | + adresse: [c.street, c.city].filter(Boolean).join(", "), | |
| 276 | + vendu_le: c.date, | |
| 277 | + prix_paye: c.amount, | |
| 278 | + distance_m: r(c.distanceM), | |
| 279 | + il_y_a_mois: c.monthsAgo, | |
| 280 | + ajust_marche: r(c.adjTime), | |
| 281 | + ajust_superficie: r(c.adjArea), | |
| 282 | + ajust_age: r(c.adjAge), | |
| 283 | + prix_ajuste: r(c.adjustedPrice), | |
| 284 | + poids_pct: r(c.weight * 100, 1), | |
| 285 | + })), | |
| 286 | + }; | |
| 287 | + } | |
| 288 | + | |
| 289 | + case "indice_marche": { | |
| 290 | + const idx = getMarketIndex(String(input.type ?? "unifamilial")); | |
| 291 | + if (!idx.length) return { erreur: "Type d'indice inconnu." }; | |
| 292 | + const last12 = idx.slice(-13); | |
| 293 | + return { | |
| 294 | + type: input.type, | |
| 295 | + croissance_12_mois_pct: growth(idx, 12), | |
| 296 | + croissance_24_mois_pct: growth(idx, 24), | |
| 297 | + serie_12_derniers_mois: last12.map((p) => ({ mois: p.month, indice: r(p.idx, 3) })), | |
| 298 | + note: "Indice $/m² lissé 3 mois, 1.0 = niveau actuel du marché.", | |
| 299 | + }; | |
| 300 | + } | |
| 301 | + | |
| 302 | + case "stats_municipalite": { | |
| 303 | + const mun = String(input.municipalite ?? ""); | |
| 304 | + const g = getDb() | |
| 305 | + .prepare( | |
| 306 | + `SELECT COUNT(*) n, SUM(est_2026) total, SUM(valeur_role) role_total | |
| 307 | + FROM units WHERE municipalite = ? COLLATE NOCASE` | |
| 308 | + ) | |
| 309 | + .get(mun) as { n: number; total: number | null; role_total: number | null }; | |
| 310 | + if (!g?.n) return { erreur: `Municipalité « ${mun} » introuvable (nom exact requis).` }; | |
| 311 | + const med = getDb() | |
| 312 | + .prepare( | |
| 313 | + `SELECT est_2026 v FROM units | |
| 314 | + WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL | |
| 315 | + ORDER BY est_2026 LIMIT 1 | |
| 316 | + OFFSET (SELECT COUNT(*) FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL) / 2` | |
| 317 | + ) | |
| 318 | + .get(mun, mun) as { v: number } | undefined; | |
| 319 | + const types = getDb() | |
| 320 | + .prepare( | |
| 321 | + `SELECT type_prop, COUNT(*) n, SUM(est_2026) total | |
| 322 | + FROM units WHERE municipalite = ? COLLATE NOCASE | |
| 323 | + GROUP BY type_prop ORDER BY total DESC` | |
| 324 | + ) | |
| 325 | + .all(mun) as { type_prop: string; n: number; total: number | null }[]; | |
| 326 | + return { | |
| 327 | + municipalite: mun, | |
| 328 | + unites: g.n, | |
| 329 | + valeur_totale_estimee_2026: r(g.total), | |
| 330 | + valeur_role_totale: r(g.role_total), | |
| 331 | + valeur_mediane_2026: med?.v ?? null, | |
| 332 | + par_type: types.map((t) => ({ type: t.type_prop, unites: t.n, total: r(t.total) })), | |
| 333 | + }; | |
| 334 | + } | |
| 335 | + | |
| 336 | + case "stats_provinciales": { | |
| 337 | + const s = stats as J; | |
| 338 | + const villes = (s.par_ville as { ville: string; total: number; n: number }[] | undefined)?.slice(0, 10); | |
| 339 | + return { | |
| 340 | + valeur_totale_immobilier_quebec_2026: s.valeur_totale_2026, | |
| 341 | + valeur_role_totale: s.valeur_role_totale, | |
| 342 | + unites_evaluees: s.unites, | |
| 343 | + logements: s.logements, | |
| 344 | + valeur_mediane_2026: s.valeur_mediane_2026, | |
| 345 | + croissance_2021_2026_pct: s.croissance_2021_2026_pct, | |
| 346 | + municipalites: s.municipalites, | |
| 347 | + par_type: s.par_type, | |
| 348 | + top_10_villes: villes, | |
| 349 | + }; | |
| 350 | + } | |
| 351 | + | |
| 352 | + case "evaluer_parc": { | |
| 353 | + const ids = (input.ids as string[] | undefined)?.slice(0, 40) ?? []; | |
| 354 | + if (ids.length < 1) return { erreur: "Fournir au moins un id." }; | |
| 355 | + const p = estimatePortfolio(ids); | |
| 356 | + if (!p.items.length) return { erreur: "Aucune unité valide trouvée." }; | |
| 357 | + const a = p.aggregates; | |
| 358 | + return { | |
| 359 | + unites_evaluees: a.count, | |
| 360 | + valeur_totale: r(a.totalEstimate), | |
| 361 | + fourchette: { basse: r(a.totalLow), haute: r(a.totalHigh) }, | |
| 362 | + valeur_role_totale: r(a.totalRole), | |
| 363 | + ecart_vs_role_pct: r(a.ecartRolePct, 1), | |
| 364 | + confiance: { pct: a.confidencePct, niveau: a.confidenceLevel }, | |
| 365 | + logements_totaux: a.totalDwellings, | |
| 366 | + aire_totale_m2: a.totalFloorArea, | |
| 367 | + croissance_parc_2021_2026_pct: r(a.growthPct, 1), | |
| 368 | + par_ville: a.municipalities, | |
| 369 | + par_type: a.types, | |
| 370 | + detail: p.items.map((i) => ({ | |
| 371 | + id: i.unit!.id, | |
| 372 | + adresse: i.unit!.adresse, | |
| 373 | + valeur: r(i.result.estimate), | |
| 374 | + confiance: i.result.confidenceLevel, | |
| 375 | + })), | |
| 376 | + }; | |
| 377 | + } | |
| 378 | + | |
| 379 | + case "comparer_proprietes": { | |
| 380 | + const ids = (input.ids as string[] | undefined)?.slice(0, 4) ?? []; | |
| 381 | + if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; | |
| 382 | + return { | |
| 383 | + comparaison: ids.map((id) => { | |
| 384 | + const e = estimateByUnitId(id); | |
| 385 | + if (!e || !e.unit) return { id, erreur: "introuvable" }; | |
| 386 | + const h21 = e.unit.history.find((h) => h.year === 2021)?.value; | |
| 387 | + const h26 = e.unit.history.find((h) => h.year === 2026)?.value; | |
| 388 | + return { | |
| 389 | + id, | |
| 390 | + adresse: e.unit.adresse, | |
| 391 | + municipalite: e.unit.municipalite, | |
| 392 | + valeur: r(e.result.estimate), | |
| 393 | + valeur_par_m2: e.unit.aireEtagesM2 ? r(e.result.estimate / e.unit.aireEtagesM2) : null, | |
| 394 | + ecart_vs_role_pct: e.unit.valeurRole | |
| 395 | + ? r((e.result.estimate / e.unit.valeurRole - 1) * 100, 1) | |
| 396 | + : null, | |
| 397 | + confiance: e.result.confidenceLevel, | |
| 398 | + croissance_2021_2026_pct: | |
| 399 | + h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null, | |
| 400 | + }; | |
| 401 | + }), | |
| 402 | + }; | |
| 403 | + } | |
| 404 | + | |
| 405 | + case "estimation_manuelle": { | |
| 406 | + const e = estimateManual({ | |
| 407 | + municipality: String(input.municipalite ?? ""), | |
| 408 | + typeProp: String(input.type ?? "unifamilial"), | |
| 409 | + floorArea: input.aire_habitable_m2 ? Number(input.aire_habitable_m2) : undefined, | |
| 410 | + yearBuilt: input.annee_construction ? Number(input.annee_construction) : undefined, | |
| 411 | + landArea: input.terrain_m2 ? Number(input.terrain_m2) : undefined, | |
| 412 | + }); | |
| 413 | + if (!e) return { erreur: "Municipalité inconnue ou marché trop mince." }; | |
| 414 | + return evalCard(e); | |
| 415 | + } | |
| 416 | + | |
| 417 | + case "chercher_proprietes_secteur": { | |
| 418 | + const mun = String(input.municipalite ?? ""); | |
| 419 | + const limit = Math.min(Number(input.limite) || 8, 15); | |
| 420 | + const conds: string[] = ["municipalite = ? COLLATE NOCASE", "est_2026 IS NOT NULL"]; | |
| 421 | + const args: unknown[] = [mun]; | |
| 422 | + if (input.type) { | |
| 423 | + conds.push("type_prop = ?"); | |
| 424 | + args.push(String(input.type)); | |
| 425 | + } | |
| 426 | + if (input.valeur_max) { | |
| 427 | + conds.push("est_2026 <= ?"); | |
| 428 | + args.push(Number(input.valeur_max)); | |
| 429 | + } | |
| 430 | + if (input.valeur_min) { | |
| 431 | + conds.push("est_2026 >= ?"); | |
| 432 | + args.push(Number(input.valeur_min)); | |
| 433 | + } | |
| 434 | + if (input.aire_min_m2) { | |
| 435 | + conds.push("aire_etages_m2 >= ?"); | |
| 436 | + args.push(Number(input.aire_min_m2)); | |
| 437 | + } | |
| 438 | + const rows = getDb() | |
| 439 | + .prepare( | |
| 440 | + `SELECT * FROM units WHERE ${conds.join(" AND ")} | |
| 441 | + ORDER BY est_2026 DESC LIMIT ?` | |
| 442 | + ) | |
| 443 | + .all(...args, limit) as Parameters<typeof unitCard>[0][]; | |
| 444 | + if (!rows.length) return { resultats: [], note: "Aucune propriété ne correspond aux critères." }; | |
| 445 | + return { | |
| 446 | + resultats: rows.map((u) => ({ ...unitCard(u), valeur_estimee_2026: r(u.est_2026) })), | |
| 447 | + }; | |
| 448 | + } | |
| 449 | + | |
| 450 | + case "liens_rapports": { | |
| 451 | + const id = String(input.id ?? ""); | |
| 452 | + const u = getUnit(id); | |
| 453 | + if (!u) return { erreur: "Unité introuvable." }; | |
| 454 | + return { | |
| 455 | + fiche_complete: `https://www.vrai-prix.com/estimation/${encodeURIComponent(id)}`, | |
| 456 | + rapport_standard_pdf: `https://www.vrai-prix.com/api/report?id=${encodeURIComponent(id)}`, | |
| 457 | + rapport_professionnel_pdf: `https://www.vrai-prix.com/api/report/pro?id=${encodeURIComponent(id)}`, | |
| 458 | + note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.", | |
| 459 | + }; | |
| 460 | + } | |
| 461 | + | |
| 462 | + default: | |
| 463 | + return { erreur: `Outil inconnu : ${name}` }; | |
| 464 | + } | |
| 465 | +} | |
| 466 | + | |
| 467 | +// réexport pratique pour la route | |
| 468 | +export { indexType }; | |
| 469 | ||