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

- Recherche à relaxation progressive : si l'adresse exacte est introuvable,
  Ka présente les correspondances les plus proches (même voie/secteur) au
  lieu de répondre introuvable.
- Nouvel outil dossier_complet : évaluation + marché local + tendance +
  comparables + synthèse chiffrée en un seul appel — réponses d'expert.
- Chat : vrais tableaux HTML pour le markdown, fil avec défilement interne
  (plus aucun saut de page pendant le streaming), gabarit mobile refait
  (hauteur dvh, composer toujours visible, chips adaptées).
- 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 yesterday (Aug 9, 2026) parent 9da18b4

Showing 4 changed files with +285 and −70

modified src/app/api/ka/route.ts +12 −6
@@ -12,7 +12,7 @@ export const runtime = "nodejs";
12 12 export const maxDuration = 120;
13 13
14 14 const MODEL = "claude-haiku-4-5";
15 const MAX_TURNS = 8;
15 +const MAX_TURNS = 12;
16 16 const MAX_MESSAGES = 40;
17 17 const MAX_CHARS = 4000;
18 18
@@ -21,15 +21,21 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation immobilière de Vrai-Prix (www.v
21 21 # Ta démarche
22 22 1. Quand on te donne une adresse (même partielle), appelle chercher_propriete immédiatement.
23 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 20212026 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).
24 +2b. Si la recherche revient avec correspondance « approximative », ne dis JAMAIS juste « introuvable » : présente les 2-4 adresses les plus proches (« je n'ai pas trouvé exactement ça, mais voici ce qui s'en rapproche ») et demande si c'est l'une d'elles. Utilise l'id EXACT retourné par l'outil — ne le retape jamais de mémoire.
25 +3. Dès qu'une unité est confirmée : appelle dossier_complet — il te donne TOUT en un appel (évaluation, marché local, tendance, comparables, synthèse chiffrée). C'est ta frappe signature.
26 +4. Livre un mini-rapport d'expert structuré : la valeur en évidence (fourchette P10-P90, confiance A-D expliquée), puis le contexte (écart vs rôle, position vs médiane municipale, $/m² vs comparables), puis 3 insights chiffrés que l'utilisateur ne savait pas (croissance vs marché, comparables marquants, momentum). Chaque réponse doit impressionner un courtier.
27 +5. Termine une évaluation réussie en offrant les rapports PDF (liens_rapports) et propose une suite concrète (comparer, parc, secteur).
28 +
29 +# Frappe fort
30 +- Quand plusieurs recherches indépendantes sont nécessaires (ex. deux adresses, 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 (propriété vs marché local 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 mais professionnel.
30 36 - 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 37 - 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.
38 +- Markdown léger : gras pour la valeur clé, listes courtes. Tableaux markdown permis mais compacts (max 5 colonnes, libellés courts).
33 39
34 40 # Limites (à respecter strictement)
35 41 - Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier.
@@ -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 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 ["11 outils", "registre, moteur, marché"],
12 + ["12 outils", "registre, moteur, marché"],
13 13 ["3,7 M", "unités interrogeables"],
14 14 ["En direct", "réponse en streaming"],
15 15 ]
16 16 : [
17 ["11 tools", "registry, engine, market"],
17 + ["12 tools", "registry, engine, market"],
18 18 ["3.7M", "queryable units"],
19 19 ["Live", "streaming answers"],
20 20 ];
modified 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";
@@ -27,35 +29,44 @@ const TOOL_LABELS: Record<string, { fr: string; en: string }> = {
27 29 liens_rapports: { fr: "Préparation des rapports", en: "Preparing reports" },
28 30 };
29 31
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 };
32 +/* ------------------------------------------------------------ markdown */
33 +
34 +function inline(s: string, key: string): React.ReactNode[] {
35 + const parts: React.ReactNode[] = [];
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
45 + key={`${key}-${i++}`}
46 + href={m[2]}
47 + target="_blank"
48 + rel="noopener noreferrer"
49 + className="font-bold underline decoration-[var(--lime)] decoration-2 underline-offset-2"
50 + >
51 + {m[1]}
52 + </a>
53 + );
54 + else if (m[3]) parts.push(<strong key={`${key}-${i++}`}>{m[3]}</strong>);
55 + else if (m[4]) parts.push(<em key={`${key}-${i++}`}>{m[4]}</em>);
56 + last = m.index + m[0].length;
57 + }
58 + if (last < s.length) parts.push(s.slice(last));
59 + return parts;
60 +}
54 61
62 +/** Rendu markdown minimal et sûr : gras, italique, listes, liens, TABLEAUX, titres. */
63 +function renderMd(text: string): React.ReactNode[] {
55 64 const blocks: React.ReactNode[] = [];
56 65 const lines = text.split("\n");
57 66 let list: string[] = [];
67 + let table: string[][] = [];
58 68 let k = 0;
69 +
59 70 const flushList = () => {
60 71 if (!list.length) return;
61 72 blocks.push(
@@ -63,21 +74,78 @@ function renderMd(text: string): React.ReactNode[] {
63 74 {list.map((li, j) => (
64 75 <li key={j} className="flex gap-2">
65 76 <span className="mt-[7px] h-[6px] w-[6px] shrink-0 rounded-full bg-[var(--lime)]" />
66 <span>{inline(li, `li-${k}-${j}`)}</span>
77 + <span className="min-w-0">{inline(li, `li-${k}-${j}`)}</span>
67 78 </li>
68 79 ))}
69 80 </ul>
70 81 );
71 82 list = [];
72 83 };
84 +
85 + const flushTable = () => {
86 + if (!table.length) return;
87 + const [head, ...rows] = table;
88 + blocks.push(
89 + <div
90 + key={`tw-${k++}`}
91 + className="my-2 max-w-full overflow-x-auto rounded-lg border-[1.5px] border-[var(--line)] bg-surface"
92 + >
93 + <table className="w-full border-collapse text-[12px]">
94 + <thead>
95 + <tr>
96 + {head.map((c, j) => (
97 + <th
98 + key={j}
99 + 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"
100 + >
101 + {inline(c, `th-${k}-${j}`)}
102 + </th>
103 + ))}
104 + </tr>
105 + </thead>
106 + <tbody>
107 + {rows.map((row, ri) => (
108 + <tr key={ri}>
109 + {row.map((c, j) => (
110 + <td
111 + key={j}
112 + className={`whitespace-nowrap px-2.5 py-1.5 align-top ${
113 + ri < rows.length - 1 ? "border-b border-[var(--line)]" : ""
114 + }`}
115 + >
116 + {inline(c, `td-${k}-${ri}-${j}`)}
117 + </td>
118 + ))}
119 + </tr>
120 + ))}
121 + </tbody>
122 + </table>
123 + </div>
124 + );
125 + table = [];
126 + };
127 +
73 128 for (const line of lines) {
74 129 const t = line.trim();
130 + // ligne de tableau |a|b| (ignore la ligne séparatrice |---|---|)
131 + if (t.startsWith("|") && t.endsWith("|") && t.length > 2) {
132 + const cells = t
133 + .slice(1, -1)
134 + .split("|")
135 + .map((c) => c.trim());
136 + if (!cells.every((c) => /^:?-{2,}:?$/.test(c))) {
137 + flushList();
138 + table.push(cells);
139 + }
140 + continue;
141 + }
142 + flushTable();
75 143 if (/^[-•]\s+/.test(t)) {
76 144 list.push(t.replace(/^[-•]\s+/, ""));
77 145 continue;
78 146 }
79 147 flushList();
80 if (t === "") continue;
148 + if (t === "" || t === "---") continue;
81 149 if (/^#{1,4}\s+/.test(t)) {
82 150 blocks.push(
83 151 <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold">
@@ -93,9 +161,12 @@ function renderMd(text: string): React.ReactNode[] {
93 161 }
94 162 }
95 163 flushList();
164 + flushTable();
96 165 return blocks;
97 166 }
98 167
168 +/* ------------------------------------------------------------ composant */
169 +
99 170 export default function KaChat() {
100 171 const { lang } = useLang();
101 172 const fr = lang === "fr";
@@ -103,13 +174,21 @@ export default function KaChat() {
103 174 const [input, setInput] = useState("");
104 175 const [busy, setBusy] = useState(false);
105 176 const [activeTool, setActiveTool] = useState<string | null>(null);
106 const endRef = useRef<HTMLDivElement>(null);
107 const abortRef = useRef<AbortController | null>(null);
177 + const feedRef = useRef<HTMLDivElement>(null);
178 + const stickBottom = useRef(true);
108 179
180 + // auto-scroll DU FIL seulement (jamais la page), et seulement si déjà en bas
109 181 useEffect(() => {
110 endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
182 + const el = feedRef.current;
183 + if (el && stickBottom.current) el.scrollTop = el.scrollHeight;
111 184 }, [messages, activeTool]);
112 185
186 + const onFeedScroll = () => {
187 + const el = feedRef.current;
188 + if (!el) return;
189 + stickBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 140;
190 + };
191 +
113 192 const suggestions = fr
114 193 ? [
115 194 "Combien vaut le 861 route Elgin à Saint-Pamphile ?",
@@ -130,13 +209,11 @@ export default function KaChat() {
130 209 setInput("");
131 210 setBusy(true);
132 211 setActiveTool(null);
212 + stickBottom.current = true;
133 213
134 214 const history = [...messages, { role: "user" as const, content }];
135 215 setMessages([...history, { role: "assistant", content: "", tools: [] }]);
136 216
137 const ctrl = new AbortController();
138 abortRef.current = ctrl;
139
140 217 try {
141 218 const res = await fetch("/api/ka", {
142 219 method: "POST",
@@ -144,7 +221,6 @@ export default function KaChat() {
144 221 body: JSON.stringify({
145 222 messages: history.map(({ role, content }) => ({ role, content })),
146 223 }),
147 signal: ctrl.signal,
148 224 });
149 225 if (!res.ok || !res.body) throw new Error(String(res.status));
150 226
@@ -205,7 +281,6 @@ export default function KaChat() {
205 281 } finally {
206 282 setActiveTool(null);
207 283 setBusy(false);
208 abortRef.current = null;
209 284 }
210 285 }
211 286
@@ -214,27 +289,31 @@ export default function KaChat() {
214 289
215 290 return (
216 291 <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">
292 + {/* fil de conversation — défilement interne, hauteur stable */}
293 + <div
294 + ref={feedRef}
295 + onScroll={onFeedScroll}
296 + 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"
297 + >
219 298 {messages.length === 0 && (
220 299 <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>
300 + <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">
301 + <span className="vp-display text-[22px] font-bold text-lime sm:text-[26px]">Ka</span>
223 302 </div>
224 <p className="vp-display text-[19px] font-bold">
303 + <p className="vp-display px-2 text-[17px] font-bold sm:text-[19px]">
225 304 {fr ? "Dis-moi quelle propriété t'intéresse." : "Tell me which property you're curious about."}
226 305 </p>
227 <p className="mx-auto mt-1.5 max-w-[440px] text-[13.5px] text-ink-2">
306 + <p className="mx-auto mt-1.5 max-w-[440px] px-3 text-[13px] text-ink-2 sm:text-[13.5px]">
228 307 {fr
229 308 ? "Je fouille 3,7 millions d'unités d'évaluation et 745 000 ventes réelles, en direct, pendant qu'on jase."
230 309 : "I search 3.7M assessment units and 745k real sales, live, while we chat."}
231 310 </p>
232 <div className="mx-auto mt-5 flex max-w-[560px] flex-wrap justify-center gap-2">
311 + <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">
233 312 {suggestions.map((s) => (
234 313 <button
235 314 key={s}
236 315 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)]"
316 + 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]"
238 317 >
239 318 {s}
240 319 </button>
@@ -246,21 +325,21 @@ export default function KaChat() {
246 325 {messages.map((m, i) => (
247 326 <div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
248 327 {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">
328 + <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]">
250 329 {m.content}
251 330 </div>
252 331 ) : (
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">
332 + <div className="flex w-full max-w-full gap-2 sm:max-w-[94%] sm:gap-3">
333 + <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">
255 334 <span className="vp-display text-[12px] font-bold text-lime">Ka</span>
256 335 </div>
257 <div className="min-w-0">
336 + <div className="min-w-0 flex-1">
258 337 {m.tools && m.tools.length > 0 && (
259 338 <div className="mb-1.5 flex flex-wrap gap-1.5">
260 339 {m.tools.map((t) => (
261 340 <span
262 341 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"
342 + 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"
264 343 >
265 344 <span
266 345 className={`h-[6px] w-[6px] rounded-full ${
@@ -274,7 +353,7 @@ export default function KaChat() {
274 353 ))}
275 354 </div>
276 355 )}
277 <div className="text-[14px] leading-relaxed">
356 + <div className="text-[13.5px] leading-relaxed sm:text-[14px]">
278 357 {m.content ? (
279 358 renderMd(m.content)
280 359 ) : busy && i === messages.length - 1 ? (
@@ -290,33 +369,36 @@ export default function KaChat() {
290 369 )}
291 370 </div>
292 371 ))}
293 <div ref={endRef} />
294 372 </div>
295 373
296 {/* zone de saisie */}
374 + {/* zone de saisie — toujours visible sous le fil */}
297 375 <form
298 376 onSubmit={(e) => {
299 377 e.preventDefault();
300 378 send(input);
301 379 }}
302 className="mt-4 flex gap-2.5"
380 + className="mt-3 flex gap-2 sm:mt-4 sm:gap-2.5"
303 381 >
304 382 <input
305 383 value={input}
306 384 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"
385 + placeholder={fr ? "Adresse ou question…" : "Address or question…"}
386 + className="vp-input min-w-0 flex-1"
311 387 maxLength={2000}
312 388 aria-label={fr ? "Message à Ka" : "Message to Ka"}
313 389 />
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"}
390 + <button
391 + type="submit"
392 + disabled={busy || !input.trim()}
393 + className="btn btn-primary shrink-0 px-4 disabled:opacity-50 sm:px-5"
394 + aria-label={fr ? "Envoyer" : "Send"}
395 + >
396 + <span className="hidden sm:inline">{busy ? (fr ? "Ka réfléchit…" : "Thinking…") : fr ? "Envoyer" : "Send"}</span>
397 + <span className="sm:hidden">{busy ? "…" : "↑"}</span>
316 398 </button>
317 399 </form>
318 400
319 <p className="vp-mono mt-3 text-center text-[10px] uppercase tracking-[0.08em] text-ink-3">
401 + <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]">
320 402 {fr
321 403 ? "Ka est une IA (Claude d'Anthropic) — estimations indicatives, pas un évaluateur agréé"
322 404 : "Ka is an AI (Anthropic's Claude) — indicative estimates, not a chartered appraiser"}
modified src/lib/ka/tools.ts +131 −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 unitCard(u: ReturnType<typeof searchUnits>[number]) {
25 79 return {
26 80 id: u.id_provinc,
@@ -87,7 +141,7 @@ export const KA_TOOLS: Anthropic.Tool[] = [
87 141 {
88 142 name: "chercher_propriete",
89 143 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.",
144 + "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. 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 (« voici ce qui s'en rapproche ») au lieu de répondre introuvable. Plusieurs candidats plausibles → demande de préciser AVANT d'évaluer. Copie l'`id` EXACTEMENT tel que retourné.",
91 145 input_schema: {
92 146 type: "object",
93 147 properties: {
@@ -222,6 +276,16 @@ export const KA_TOOLS: Anthropic.Tool[] = [
222 276 required: ["municipalite"],
223 277 },
224 278 },
279 + {
280 + name: "dossier_complet",
281 + description:
282 + "L'ARME LOURDE : dossier d'expert complet d'une propriété en UN SEUL appel — évaluation complète + statistiques du marché local + tendance du marché provincial + top 5 des comparables + synthèse chiffrée pré-calculée (position vs médiane municipale, $/m² vs comparables, croissance propriété vs marché). Utilise-le SYSTÉMATIQUEMENT quand l'utilisateur demande d'évaluer une propriété identifiée : tu obtiens tout pour livrer un mini-rapport d'expert d'un coup.",
283 + input_schema: {
284 + type: "object",
285 + properties: { id: { type: "string", description: "Identifiant provincial de l'unité" } },
286 + required: ["id"],
287 + },
288 + },
225 289 {
226 290 name: "liens_rapports",
227 291 description:
@@ -249,14 +313,23 @@ export function runKaTool(name: string, input: J): unknown {
249 313 switch (name) {
250 314 case "chercher_propriete": {
251 315 const limit = Math.min(Number(input.limite) || 6, 12);
252 const rows = searchUnits(String(input.requete ?? ""), limit);
316 + const { rows, niveau } = searchFlexible(String(input.requete ?? ""), limit);
253 317 if (!rows.length)
254 318 return {
255 319 resultats: [],
320 + correspondance: "aucune",
256 321 conseil:
257 "Aucun résultat. Essayer une graphie différente (« numéro rue municipalité »), sans abréviations, ou proposer estimation_manuelle.",
322 + "Aucun résultat, même approximatif. Essayer une graphie différente (« numéro rue municipalité »), ou proposer estimation_manuelle.",
258 323 };
259 return { resultats: rows.map(unitCard) };
324 + return {
325 + correspondance: niveau,
326 + ...(niveau === "approximative"
327 + ? {
328 + note: "Correspondance exacte introuvable — voici les adresses les plus proches (même voie ou même secteur). Présente-les à l'utilisateur comme des suggestions.",
329 + }
330 + : {}),
331 + resultats: rows.map(unitCard),
332 + };
260 333 }
261 334
262 335 case "evaluer_propriete": {
@@ -447,6 +520,60 @@ export function runKaTool(name: string, input: J): unknown {
447 520 };
448 521 }
449 522
523 + case "dossier_complet": {
524 + const id = String(input.id ?? "");
525 + const ev = estimateByUnitId(id);
526 + if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." };
527 + const u = ev.unit;
528 + const res = ev.result;
529 +
530 + const marcheLocal = u.municipalite
531 + ? (runKaTool("stats_municipalite", { municipalite: u.municipalite }) as Record<string, unknown>)
532 + : null;
533 + const idx = getMarketIndex(indexType(u.typeProp));
534 + const comps = res.comps.slice(0, 5).map((c) => ({
535 + adresse: [c.street, c.city].filter(Boolean).join(", "),
536 + vendu_le: c.date,
537 + prix_paye: c.amount,
538 + prix_ajuste: r(c.adjustedPrice),
539 + distance_m: r(c.distanceM),
540 + poids_pct: r(c.weight * 100, 1),
541 + }));
542 +
543 + // synthèse pré-calculée — les chiffres qui font un avis d'expert
544 + const medianeMun = (marcheLocal?.valeur_mediane_2026 as number | null) ?? null;
545 + const h21 = u.history.find((h) => h.year === 2021)?.value;
546 + const h26 = u.history.find((h) => h.year === 2026)?.value;
547 + const croissanceProp = h21 && h26 ? r((h26 / h21 - 1) * 100, 1) : null;
548 + const compsM2 = res.comps
549 + .filter((c) => c.floorArea && c.floorArea > 20)
550 + .map((c) => c.adjustedPrice / c.floorArea!);
551 + const benchM2 = compsM2.length
552 + ? r(compsM2.sort((a, b) => a - b)[Math.floor(compsM2.length / 2)])
553 + : null;
554 + const propM2 = u.aireEtagesM2 ? r(res.estimate / u.aireEtagesM2) : null;
555 +
556 + return {
557 + evaluation: evalCard(ev),
558 + marche_local: marcheLocal,
559 + tendance_marche_provincial: {
560 + type: indexType(u.typeProp),
561 + croissance_12_mois_pct: growth(idx, 12),
562 + croissance_24_mois_pct: growth(idx, 24),
563 + },
564 + top_comparables: comps,
565 + synthese: {
566 + position_vs_mediane_municipale_pct:
567 + medianeMun && medianeMun > 0 ? r((res.estimate / medianeMun - 1) * 100, 1) : null,
568 + croissance_propriete_2021_2026_pct: croissanceProp,
569 + valeur_m2_propriete: propM2,
570 + valeur_m2_mediane_comparables: benchM2,
571 + ecart_m2_vs_comparables_pct:
572 + propM2 && benchM2 ? r((propM2 / benchM2 - 1) * 100, 1) : null,
573 + },
574 + };
575 + }
576 +
450 577 case "liens_rapports": {
451 578 const id = String(input.id ?? "");
452 579 const u = getUnit(id);
453 580