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>
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 2021→2026 de la propriété, comparaison au marché local (stats_municipalite), tendance du marché (indice_marche), comparables marquants (comparables_detailles). Choisis les outils pertinents — n'appelle pas tout systématiquement. | |
| 26 | −5. Termine une évaluation réussie en offrant les rapports PDF (liens_rapports). | |
| 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 | |