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 v2 — recherche approximative, dossier investisseur, tableaux, mobile

- Recherche à relaxation progressive : correspondances approximatives
  (même voie/secteur) présentées au lieu d'un simple introuvable.
- Nouvel outil dossier_investisseur : évaluation + économie par porte +
  pro forma condensé + benchmark $/porte municipal du gabarit + tendance +
  comparables + synthèse en un seul appel.
- Chat : vrais tableaux HTML, fil à défilement interne (zéro saut de page
  en streaming), gabarit mobile refait.
- Boucle renforcée : 12 tours, 2200 tokens, appels d'outils parallèles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 2 days ago (Aug 9, 2026) parent 9921faf

Showing 4 changed files with +322 and −71

modified app/src/app/api/ka/route.ts +13 −7
@@ -11,7 +11,7 @@ export const runtime = "nodejs";
11 11 export const maxDuration = 120;
12 12
13 13 const MODEL = "claude-haiku-4-5";
14 const MAX_TURNS = 8;
14 +const MAX_TURNS = 12;
15 15 const MAX_MESSAGES = 40;
16 16 const MAX_CHARS = 4000;
17 17
@@ -20,15 +20,21 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation de ValoPlex (www.valoplex.com),
20 20 # Ta démarche
21 21 1. Quand on te donne une adresse (même partielle), appelle chercher_plex immédiatement.
22 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 20212026, 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).
23 +2b. Si la recherche revient avec correspondance « approximative », ne dis JAMAIS juste « introuvable » : présente les 2-4 plex les plus proches (« je n'ai pas trouvé exactement ça, mais voici ce qui s'en rapproche ») et demande si c'est l'un d'eux. Utilise l'id EXACT retourné par l'outil — ne le retape jamais de mémoire.
24 +3. Dès qu'un plex est confirmé : appelle dossier_investisseur — il te donne TOUT en un appel (évaluation, économie par porte, pro forma condensé, benchmark $/porte municipal, tendance, comparables, synthèse). C'est ta frappe signature.
25 +4. Livre un mini-rapport d'investisseur structuré : la valeur en évidence (fourchette, confiance A-D expliquée) et LA VALEUR PAR PORTE, puis la rentabilité (loyer implicite, cashflow/porte/mois, DSCR, liquidités « tout le kit »), puis 3 insights chiffrés (écart $/porte vs la ville, croissance vs marché, marges de sécurité). Chaque réponse doit impressionner un courtier commercial.
26 +5. Pour creuser un scénario de financement précis (taux, mise, amortissement différents) : proforma_investisseur avec les paramètres demandés, et compare au scénario de base.
27 +6. Termine une évaluation réussie en offrant les rapports PDF (liens_rapports) et propose une suite concrète (autre scénario de financement, comparaison, secteur).
28 +
29 +# Frappe fort
30 +- Quand plusieurs recherches indépendantes sont nécessaires (ex. deux plex, ou stats + indice), appelle PLUSIEURS outils dans le MÊME tour — ils s'exécutent en parallèle.
31 +- Ne te contente jamais du minimum : croise les données (plex vs gabarit municipal vs tendance provinciale) et tranche avec un avis chiffré.
32 +- Anticipe la prochaine question : offre-la avant qu'on te la pose.
27 33
28 34 # Ton style
29 35 - 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 36 - 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.
37 +- Markdown léger : gras pour les valeurs clés, listes courtes. Tableaux markdown permis mais compacts (max 5 colonnes, libellés courts).
32 38
33 39 # Limites (à respecter strictement)
34 40 - Estimations statistiques indicatives : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un conseil financier ou hypothécaire.
@@ -90,7 +96,7 @@ export async function POST(req: Request): Promise<Response> {
90 96 for (let turn = 0; turn < MAX_TURNS; turn++) {
91 97 const msgStream = client.messages.stream({
92 98 model: MODEL,
93 max_tokens: 1600,
99 + max_tokens: 2200,
94 100 system: [
95 101 {
96 102 type: "text",
modified app/src/app/ka/KaHero.tsx +2 −2
@@ -9,12 +9,12 @@ export default function KaHero() {
9 9
10 10 const powers = fr
11 11 ? [
12 ["12 outils", "registre, moteur, pro forma"],
12 + ["13 outils", "registre, moteur, pro forma"],
13 13 ["393 867", "plex interrogeables"],
14 14 ["En direct", "réponse en streaming"],
15 15 ]
16 16 : [
17 ["12 tools", "registry, engine, pro forma"],
17 + ["13 tools", "registry, engine, pro forma"],
18 18 ["393,867", "queryable plexes"],
19 19 ["Live", "streaming answers"],
20 20 ];
modified app/src/components/KaChat.tsx +140 −58
@@ -2,6 +2,8 @@
2 2 /**
3 3 * Ka — interface de conversation en streaming avec l'agent IA.
4 4 * Lit le flux SSE de /api/ka : deltas de texte, activité d'outils, fin.
5 + * Le fil a son PROPRE défilement (jamais la page) et l'auto-scroll ne suit
6 + * que si l'utilisateur est déjà au bas du fil — zéro sursaut pendant le stream.
5 7 */
6 8 "use client";
7 9 import { useEffect, useRef, useState } from "react";
@@ -28,35 +30,44 @@ const TOOL_LABELS: Record<string, { fr: string; en: string }> = {
28 30 liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" },
29 31 };
30 32
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 };
33 +/* ------------------------------------------------------------ markdown */
34 +
35 +function inline(s: string, key: string): React.ReactNode[] {
36 + const parts: React.ReactNode[] = [];
37 + const rx = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)|\*\*([^*]+)\*\*|\*([^*]+)\*/g;
38 + let last = 0;
39 + let m: RegExpExecArray | null;
40 + let i = 0;
41 + while ((m = rx.exec(s))) {
42 + if (m.index > last) parts.push(s.slice(last, m.index));
43 + if (m[1] && m[2])
44 + parts.push(
45 + <a
46 + key={`${key}-${i++}`}
47 + href={m[2]}
48 + target="_blank"
49 + rel="noopener noreferrer"
50 + className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"
51 + >
52 + {m[1]}
53 + </a>
54 + );
55 + else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>);
56 + else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>);
57 + last = m.index + m[0].length;
58 + }
59 + if (last < s.length) parts.push(s.slice(last));
60 + return parts;
61 +}
55 62
63 +/** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */
64 +function renderMd(text: string): React.ReactNode[] {
56 65 const blocks: React.ReactNode[] = [];
57 66 const lines = text.split("\n");
58 67 let list: string[] = [];
68 + let table: string[][] = [];
59 69 let k = 0;
70 +
60 71 const flushList = () => {
61 72 if (!list.length) return;
62 73 blocks.push(
@@ -64,21 +75,78 @@ function renderMd(text: string): React.ReactNode[] {
64 75 {list.map((li, j) => (
65 76 <li key={j} className="flex gap-2">
66 77 <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" />
67 <span>{inline(li, `li-${k}-${j}`)}</span>
78 + <span className="min-w-0">{inline(li, `li-${k}-${j}`)}</span>
68 79 </li>
69 80 ))}
70 81 </ul>
71 82 );
72 83 list = [];
73 84 };
85 +
86 + const flushTable = () => {
87 + if (!table.length) return;
88 + const [head, ...rows] = table;
89 + blocks.push(
90 + <div
91 + key={`tw-${k++}`}
92 + className="my-2 max-w-full overflow-x-auto rounded-lg border-[1.5px] border-[var(--line)] bg-surface"
93 + >
94 + <table className="w-full border-collapse text-[12px]">
95 + <thead>
96 + <tr>
97 + {head.map((c, j) => (
98 + <th
99 + key={j}
100 + className="vp-mono whitespace-nowrap border-b-[1.5px] border-[var(--line)] bg-surface-2 px-2.5 py-1.5 text-left text-[9px] font-bold uppercase tracking-[0.08em] text-ink-3"
101 + >
102 + {inline(c, `th-${k}-${j}`)}
103 + </th>
104 + ))}
105 + </tr>
106 + </thead>
107 + <tbody>
108 + {rows.map((row, ri) => (
109 + <tr key={ri}>
110 + {row.map((c, j) => (
111 + <td
112 + key={j}
113 + className={`whitespace-nowrap px-2.5 py-1.5 align-top ${
114 + ri < rows.length - 1 ? "border-b border-[var(--line)]" : ""
115 + }`}
116 + >
117 + {inline(c, `td-${k}-${ri}-${j}`)}
118 + </td>
119 + ))}
120 + </tr>
121 + ))}
122 + </tbody>
123 + </table>
124 + </div>
125 + );
126 + table = [];
127 + };
128 +
74 129 for (const line of lines) {
75 130 const t = line.trim();
131 + // ligne de tableau |a|b| (ignore la ligne séparatrice |---|---|)
132 + if (t.startsWith("|") && t.endsWith("|") && t.length > 2) {
133 + const cells = t
134 + .slice(1, -1)
135 + .split("|")
136 + .map((c) => c.trim());
137 + if (!cells.every((c) => /^:?-{2,}:?$/.test(c))) {
138 + flushList();
139 + table.push(cells);
140 + }
141 + continue;
142 + }
143 + flushTable();
76 144 if (/^[-•]\s+/.test(t)) {
77 145 list.push(t.replace(/^[-•]\s+/, ""));
78 146 continue;
79 147 }
80 148 flushList();
81 if (t === "") continue;
149 + if (t === "" || t === "---") continue;
82 150 if (/^#{1,4}\s+/.test(t)) {
83 151 blocks.push(
84 152 <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold">
@@ -94,9 +162,12 @@ function renderMd(text: string): React.ReactNode[] {
94 162 }
95 163 }
96 164 flushList();
165 + flushTable();
97 166 return blocks;
98 167 }
99 168
169 +/* ------------------------------------------------------------ composant */
170 +
100 171 export default function KaChat() {
101 172 const { lang } = useLang();
102 173 const fr = lang === "fr";
@@ -104,13 +175,21 @@ export default function KaChat() {
104 175 const [input, setInput] = useState("");
105 176 const [busy, setBusy] = useState(false);
106 177 const [activeTool, setActiveTool] = useState<string | null>(null);
107 const endRef = useRef<HTMLDivElement>(null);
108 const abortRef = useRef<AbortController | null>(null);
178 + const feedRef = useRef<HTMLDivElement>(null);
179 + const stickBottom = useRef(true);
109 180
181 + // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas
110 182 useEffect(() => {
111 endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
183 + const el = feedRef.current;
184 + if (el && stickBottom.current) el.scrollTop = el.scrollHeight;
112 185 }, [messages, activeTool]);
113 186
187 + const onFeedScroll = () => {
188 + const el = feedRef.current;
189 + if (!el) return;
190 + stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
191 + };
192 +
114 193 const suggestions = fr
115 194 ? [
116 195 "Combien vaut le triplex au 2075 rue Grandjean à Québec ?",
@@ -131,13 +210,11 @@ export default function KaChat() {
131 210 setInput("");
132 211 setBusy(true);
133 212 setActiveTool(null);
213 + stickBottom.current = true;
134 214
135 215 const history = [...messages, { role: "user" as const, content }];
136 216 setMessages([...history, { role: "assistant", content: "", tools: [] }]);
137 217
138 const ctrl = new AbortController();
139 abortRef.current = ctrl;
140
141 218 try {
142 219 const res = await fetch("/api/ka", {
143 220 method: "POST",
@@ -145,7 +222,6 @@ export default function KaChat() {
145 222 body: JSON.stringify({
146 223 messages: history.map(({ role, content }) => ({ role, content })),
147 224 }),
148 signal: ctrl.signal,
149 225 });
150 226 if (!res.ok || !res.body) throw new Error(String(res.status));
151 227
@@ -206,7 +282,6 @@ export default function KaChat() {
206 282 } finally {
207 283 setActiveTool(null);
208 284 setBusy(false);
209 abortRef.current = null;
210 285 }
211 286 }
212 287
@@ -215,27 +290,31 @@ export default function KaChat() {
215 290
216 291 return (
217 292 <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">
293 + {/* fil de conversation — défilement interne, hauteur stable */}
294 + <div
295 + ref={feedRef}
296 + onScroll={onFeedScroll}
297 + className="vp-card flex h-[56dvh] flex-col gap-4 overflow-y-auto overscroll-contain p-3.5 sm:h-[58vh] sm:gap-5 sm:p-6"
298 + >
220 299 {messages.length === 0 && (
221 300 <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>
301 + <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border-[1.5px] border-ink bg-ink shadow-[5px_5px_0_var(--lime)] sm:h-16 sm:w-16">
302 + <span className="vp-display text-[22px] font-bold text-lime sm:text-[26px]">Ka</span>
224 303 </div>
225 <p className="vp-display text-[19px] font-bold">
304 + <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]">
226 305 {fr ? "Dis-moi quel plex t'intéresse." : "Tell me which plex you're curious about."}
227 306 </p>
228 <p className="mx-auto mt-1.5 max-w-[440px] text-[13.5px] text-ink-2">
307 + <p className="mx-auto mt-1.5 max-w-[440px] px-3 text-[13px] text-ink-2 sm:text-[13.5px]">
229 308 {fr
230 309 ? "Je fouille 393 867 plex, 91 000 ventes réelles et je calcule des pro forma complets, en direct, pendant qu'on jase."
231 310 : "I search 393,867 plexes, 91k real sales and build full pro formas, live, while we chat."}
232 311 </p>
233 <div className="mx-auto mt-5 flex max-w-[560px] flex-wrap justify-center gap-2">
312 + <div className="mx-auto mt-4 flex max-w-[560px] flex-col items-stretch gap-2 px-1 sm:mt-5 sm:flex-row sm:flex-wrap sm:justify-center">
234 313 {suggestions.map((s) => (
235 314 <button
236 315 key={s}
237 316 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)]"
317 + 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)] active:translate-y-[1px]"
239 318 >
240 319 {s}
241 320 </button>
@@ -247,21 +326,21 @@ export default function KaChat() {
247 326 {messages.map((m, i) => (
248 327 <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
249 328 {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">
329 + <div className="max-w-[88%] rounded-2xl rounded-br-md border-[1.5px] border-ink bg-ink px-3.5 py-2.5 text-[13.5px] font-medium text-paper sm:text-[14px]">
251 330 {m.content}
252 331 </div>
253 332 ) : (
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">
333 + <div className="flex w-full max-w-full gap-2 sm:max-w-[94%] sm:gap-3">
334 + <div className="mt-0.5 hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border-[1.5px] border-ink bg-ink sm:flex">
256 335 <span className="vp-display text-[12px] font-bold text-lime">Ka</span>
257 336 </div>
258 <div className="min-w-0">
337 + <div className="min-w-0 flex-1">
259 338 {m.tools && m.tools.length > 0 && (
260 339 <div className="mb-1.5 flex flex-wrap gap-1.5">
261 340 {m.tools.map((t) => (
262 341 <span
263 342 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"
343 + 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-[9.5px] uppercase tracking-[0.05em] text-ink-2"
265 344 >
266 345 <span
267 346 className={`h-[6px] w-[6px] rounded-full ${
@@ -275,7 +354,7 @@ export default function KaChat() {
275 354 ))}
276 355 </div>
277 356 )}
278 <div className="text-[14px] leading-relaxed">
357 + <div className="text-[13.5px] leading-relaxed sm:text-[14px]">
279 358 {m.content ? (
280 359 renderMd(m.content)
281 360 ) : busy && i === messages.length - 1 ? (
@@ -291,33 +370,36 @@ export default function KaChat() {
291 370 )}
292 371 </div>
293 372 ))}
294 <div ref={endRef} />
295 373 </div>
296 374
297 {/* zone de saisie */}
375 + {/* zone de saisie — toujours visible sous le fil */}
298 376 <form
299 377 onSubmit={(e) => {
300 378 e.preventDefault();
301 379 send(input);
302 380 }}
303 className="mt-4 flex gap-2.5"
381 + className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5"
304 382 >
305 383 <input
306 384 value={input}
307 385 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"
386 + placeholder={fr ? "Adresse ou question…" : "Address or question…"}
387 + className="vp-input min-w-0 flex-1"
312 388 maxLength={2000}
313 389 aria-label={fr ? "Message à Ka" : "Message to Ka"}
314 390 />
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"}
391 + <button
392 + type="submit"
393 + disabled={busy || !input.trim()}
394 + className="btn btn-primary shrink-0 px-4 disabled:opacity-50 sm:px-5"
395 + aria-label={fr ? "Envoyer" : "Send"}
396 + >
397 + <span className="hidden sm:inline">{busy ? (fr ? "Ka réfléchit…" : "Thinking…") : fr ? "Envoyer" : "Send"}</span>
398 + <span className="sm:hidden">{busy ? "…" : "↑"}</span>
317 399 </button>
318 400 </form>
319 401
320 <p className="vp-mono mt-3 text-center text-[10px] uppercase tracking-[0.08em] text-ink-3">
402 + <p className="vp-mono mt-2.5 px-2 text-center text-[9.5px] uppercase leading-relaxed tracking-[0.08em] text-ink-3 sm:mt-3 sm:text-[10px]">
321 403 {fr
322 404 ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé"
323 405 : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"}
modified app/src/lib/ka/tools.ts +167 −4
@@ -21,6 +21,60 @@ import stats from "../../data/stats.json";
21 21 const r = (n: number | null | undefined, d = 0): number | null =>
22 22 n == null || !Number.isFinite(n) ? null : Number(n.toFixed(d));
23 23
24 +/** Mots génériques d'adresse ignorés lors de la recherche approximative. */
25 +const STOP_WORDS = new Set([
26 + "rue", "avenue", "av", "boulevard", "boul", "blvd", "chemin", "ch", "route", "rte",
27 + "rang", "place", "montee", "montée", "impasse", "croissant", "terrasse", "allée", "allee",
28 + "cote", "côte", "carre", "carré", "du", "de", "des", "la", "le", "les", "l", "d",
29 + "au", "aux", "à", "a", "et", "app", "apt", "st", "ste",
30 +]);
31 +
32 +function tokenize(q: string): string[] {
33 + return q
34 + .replace(/[^\p{L}\p{N}\s'-]/gu, " ")
35 + .trim()
36 + .split(/\s+/)
37 + .filter((t) => t.length > 0);
38 +}
39 +
40 +function ftsAnd(tokens: string[], limit: number) {
41 + if (!tokens.length) return [];
42 + const match = tokens.map((t) => `"${t}"*`).join(" ");
43 + return getDb()
44 + .prepare(
45 + `SELECT u.*, bm25(units_fts) AS score
46 + FROM units_fts JOIN units u ON u.rowid = units_fts.rowid
47 + WHERE units_fts MATCH ? ORDER BY score LIMIT ?`
48 + )
49 + .all(match, limit) as ReturnType<typeof searchUnits>;
50 +}
51 +
52 +/**
53 + * Recherche à relaxation progressive : exacte, puis sans mots génériques
54 + * (rue/avenue/de…), puis sans numéro civique, puis nom de voie seul.
55 + * Retourne toujours ce qui s'en rapproche le plus plutôt que rien.
56 + */
57 +function searchFlexible(
58 + q: string,
59 + limit: number
60 +): { rows: ReturnType<typeof searchUnits>; niveau: "exacte" | "approximative" } {
61 + const strict = searchUnits(q, limit);
62 + if (strict.length) return { rows: strict, niveau: "exacte" };
63 +
64 + const tokens = tokenize(q);
65 + const sansStop = tokens.filter((t) => !STOP_WORDS.has(t.toLowerCase()));
66 + const sansNumero = sansStop.filter((t) => !/^\d+[a-z]?$/i.test(t));
67 + const nomsSeuls = sansNumero.filter((t) => t.length >= 4);
68 +
69 + for (const attempt of [sansStop, sansNumero, nomsSeuls, nomsSeuls.slice(0, 1)]) {
70 + if (!attempt.length) continue;
71 + if (attempt.length === tokens.length) continue; // déjà tenté en strict
72 + const rows = ftsAnd(attempt, limit);
73 + if (rows.length) return { rows, niveau: "approximative" };
74 + }
75 + return { rows: [], niveau: "approximative" };
76 +}
77 +
24 78 function gabarit(portes: number | null): string {
25 79 if (!portes) return "plex";
26 80 if (portes === 2) return "duplex";
@@ -103,7 +157,7 @@ export const KA_TOOLS: Anthropic.Tool[] = [
103 157 {
104 158 name: "chercher_plex",
105 159 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é ».",
160 + "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. Si aucune correspondance exacte, l'outil retombe AUTOMATIQUEMENT sur des correspondances approximatives (même rue, numéros voisins) — le champ `correspondance` te le dit. Présente alors les candidats les plus proches au lieu de répondre introuvable. Plusieurs candidats plausibles → demande de préciser AVANT d'évaluer. Copie l'`id` EXACTEMENT tel que retourné.",
107 161 input_schema: {
108 162 type: "object",
109 163 properties: {
@@ -228,6 +282,22 @@ export const KA_TOOLS: Anthropic.Tool[] = [
228 282 required: ["municipalite"],
229 283 },
230 284 },
285 + {
286 + name: "dossier_investisseur",
287 + description:
288 + "L'ARME LOURDE : dossier d'investisseur complet d'un plex en UN SEUL appel — évaluation + économie par porte + pro forma condensé (loyer implicite, RNE, cashflow/porte, DSCR, liquidités tout le kit, projection 5 ans, points morts) + marché local du gabarit (benchmark $/porte municipal) + tendance du marché des plex + top 5 comparables + synthèse chiffrée. Utilise-le SYSTÉMATIQUEMENT quand l'utilisateur demande d'évaluer ou d'analyser un plex identifié : tu obtiens tout pour livrer un mini-rapport d'investisseur d'un coup. Paramètres de financement optionnels.",
289 + input_schema: {
290 + type: "object",
291 + properties: {
292 + id: { type: "string", description: "Identifiant provincial du plex" },
293 + taux_hypo_pct: { type: "number" },
294 + mise_de_fonds_pct: { type: "number" },
295 + amortissement_ans: { type: "number" },
296 + tga_pct: { type: "number" },
297 + },
298 + required: ["id"],
299 + },
300 + },
231 301 {
232 302 name: "liens_rapports",
233 303 description:
@@ -255,14 +325,23 @@ export function runKaTool(name: string, input: J): unknown {
255 325 switch (name) {
256 326 case "chercher_plex": {
257 327 const limit = Math.min(Number(input.limite) || 6, 12);
258 const rows = searchUnits(String(input.requete ?? ""), limit);
328 + const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit);
259 329 if (!rows.length)
260 330 return {
261 331 resultats: [],
332 + correspondance: "aucune",
262 333 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.",
334 + "Aucun plex trouvé, même approximatif. Rappel : ValoPlex ne couvre que les immeubles de 2 logements et plus (unifamiliale/condo → vrai-prix.com). Essayer une autre graphie ou estimation_manuelle.",
264 335 };
265 return { resultats: rows.map(unitCard) };
336 + return {
337 + correspondance: niveau,
338 + ...(niveau === "approximative"
339 + ? {
340 + note: "Correspondance exacte introuvable — voici les plex les plus proches (même voie ou même secteur). Présente-les à l'utilisateur comme des suggestions.",
341 + }
342 + : {}),
343 + resultats: rows.map(unitCard),
344 + };
266 345 }
267 346
268 347 case "evaluer_plex": {
@@ -553,6 +632,90 @@ export function runKaTool(name: string, input: J): unknown {
553 632 };
554 633 }
555 634
635 + case "dossier_investisseur": {
636 + const id = String(input.id ?? "");
637 + const ev = estimateByUnitId(id);
638 + if (!ev || !ev.unit) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." };
639 + const u = ev.unit;
640 + const res = ev.result;
641 + const doors = u.nbLogements ?? 2;
642 +
643 + const pfFull = runKaTool("proforma_investisseur", {
644 + id,
645 + taux_hypo_pct: input.taux_hypo_pct,
646 + mise_de_fonds_pct: input.mise_de_fonds_pct,
647 + amortissement_ans: input.amortissement_ans,
648 + tga_pct: input.tga_pct,
649 + }) as Record<string, unknown>;
650 + const proformaCondense = {
651 + hypotheses: pfFull.hypotheses,
652 + loyer_implicite_par_porte_mois: (pfFull.revenus as Record<string, unknown>)?.loyer_implicite_par_porte_mois,
653 + rne_noi: (pfFull.exploitation as Record<string, unknown>)?.rne_noi,
654 + tga_implicite_pct: (pfFull.exploitation as Record<string, unknown>)?.tga_implicite_pct,
655 + cashflow_par_porte_mois: (pfFull.financement as Record<string, unknown>)?.cashflow_par_porte_mois,
656 + dscr: (pfFull.financement as Record<string, unknown>)?.dscr,
657 + liquidites_requises_totales: (pfFull.acquisition_tout_le_kit as Record<string, unknown>)?.liquidites_requises_totales,
658 + cash_on_cash_pct: (pfFull.acquisition_tout_le_kit as Record<string, unknown>)?.cash_on_cash_pct,
659 + projection_5_ans: pfFull.projection_5_ans,
660 + marges_de_securite: {
661 + loyer_point_mort_par_porte: (pfFull.marges_de_securite as Record<string, unknown>)?.loyer_point_mort_par_porte,
662 + taux_point_mort_pct: (pfFull.marges_de_securite as Record<string, unknown>)?.taux_point_mort_pct,
663 + },
664 + };
665 +
666 + // benchmark $/porte du gabarit dans la municipalité
667 + const bandCond =
668 + doors <= 3 ? "nb_logements = ?" : doors <= 5 ? "nb_logements BETWEEN 4 AND 5" : doors <= 12 ? "nb_logements BETWEEN 6 AND 12" : "nb_logements >= 13";
669 + const bandArgs: unknown[] = doors <= 3 ? [u.municipalite, doors] : [u.municipalite];
670 + const bench = u.municipalite
671 + ? (getDb()
672 + .prepare(
673 + `SELECT COUNT(*) n, AVG(est_2026 / nb_logements) prix_porte_moyen
674 + FROM units WHERE municipalite = ? COLLATE NOCASE AND est_2026 IS NOT NULL AND ${bandCond}`
675 + )
676 + .get(...bandArgs) as { n: number; prix_porte_moyen: number | null })
677 + : null;
678 +
679 + const idx = getMarketIndex("plex");
680 + const comps = res.comps.slice(0, 5).map((c) => ({
681 + adresse: [c.street, c.city].filter(Boolean).join(", "),
682 + vendu_le: c.date,
683 + prix_paye: c.amount,
684 + portes: c.doors ?? null,
685 + prix_ajuste: r(c.adjustedPrice),
686 + poids_pct: r(c.weight * 100, 1),
687 + }));
688 +
689 + const h21 = u.history.find((h) => h.year === 2021)?.value;
690 + const h26 = u.history.find((h) => h.year === 2026)?.value;
691 + const valeurParPorte = r(res.estimate / doors);
692 + const benchPorte = bench?.prix_porte_moyen ? r(bench.prix_porte_moyen) : null;
693 +
694 + return {
695 + evaluation: evalCard(ev),
696 + proforma: proformaCondense,
697 + marche_local_du_gabarit: bench
698 + ? {
699 + municipalite: u.municipalite,
700 + plex_comparables_dans_la_ville: bench.n,
701 + valeur_moyenne_par_porte: benchPorte,
702 + }
703 + : null,
704 + tendance_marche_plex: {
705 + croissance_12_mois_pct: growth(idx, 12),
706 + croissance_24_mois_pct: growth(idx, 24),
707 + },
708 + top_comparables: comps,
709 + synthese: {
710 + valeur_par_porte: valeurParPorte,
711 + ecart_par_porte_vs_ville_pct:
712 + valeurParPorte && benchPorte ? r((valeurParPorte / benchPorte - 1) * 100, 1) : null,
713 + croissance_plex_2021_2026_pct: h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null,
714 + note: "Le pro forma part du loyer implicite (supposé par la valeur au TGA de référence), pas des baux réels.",
715 + },
716 + };
717 + }
718 +
556 719 case "liens_rapports": {
557 720 const id = String(input.id ?? "");
558 721 const u = getUnit(id);
559 722