SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%

Ka — agent IA conversationnel spécialisé plex (Claude Haiku 4.5, streaming, 12 outils)

Page /ka + route /api/ka : boucle agentique en streaming SSE branchée
sur la base des plex et le moteur en ratio (recherche FTS, évaluation
porte par porte, pro forma investisseur complet paramétrable, comparables
avec ajustement portes, marché des plex, stats par gabarit, parc,
recherche par secteur avec portes/budget, rapports). Prompt caching sur
system+outils, clé API via variable d'environnement (jamais versionnée).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 9, 2026) parent bc8e3c2

Showing 9 changed files with +1,210 and −1

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