SPB Git

spb/vrai-prix Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

TypeScript 96.7% CSS 3.1%

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>
Simon-Pierre Boucher committed yesterday (Aug 9, 2026) parent e3a46d2

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