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>
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 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). | |
| 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 | |