Ka — liens PDF fiables (réfs persistantes, garde anti-hallucination)
Le modèle inventait des URLs de rapport quand les résultats d'outils n'étaient plus en contexte. Triple verrou : - chaque évaluation émet une réf persistante (id + URLs exactes des PDF) que le client conserve dans l'historique, masquée à l'affichage ; - garde serveur : demande de PDF sans réf en contexte → note injectée forçant chercher + liens_rapports avant tout lien ; - endpoints /api/report tolérants (id décoré nettoyé, format=pro* redirigé vers le rapport professionnel) ; liens intégrés au dossier/évaluation ; resolveId sur tous les outils à identifiant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 5 changed files with +113 and −18
modified
src/app/api/ka/route.ts
+47 −1
@@ -24,7 +24,7 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation immobilière de Vrai-Prix (www.v | ||
| 24 | 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 | 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 | 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). | |
| 27 | +5. Les liens des rapports PDF sont DÉJÀ dans chaque évaluation/dossier (champ « liens ») : quand l'utilisateur veut le PDF, donne ces liens directement en markdown ([Rapport standard](url) / [Rapport professionnel](url)) — n'appelle liens_rapports que si tu ne les as plus sous la main. Termine en proposant une suite concrète (comparer, parc, secteur). | |
| 28 | 28 | |
| 29 | 29 | # Frappe fort |
| 30 | 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. |
@@ -37,6 +37,11 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation immobilière de Vrai-Prix (www.v | ||
| 37 | 37 | - Transparence totale : c'est l'ADN de Vrai-Prix. Explique d'où viennent les chiffres quand on te le demande. |
| 38 | 38 | - Markdown léger : gras pour la valeur clé, listes courtes. Tableaux markdown permis mais compacts (max 5 colonnes, libellés courts). |
| 39 | 39 | |
| 40 | +# Continuité et intégrité (à respecter strictement) | |
| 41 | +- Les messages précédents peuvent contenir des lignes « [réf: adresse → id NNN] » : c'est l'id EXACT d'une unité déjà évaluée — réutilise-le directement (pas besoin de rechercher). | |
| 42 | +- Les DEUX seuls formats d'URL de rapport qui existent : …/api/report?id=ID (standard) et …/api/report/pro?id=ID (professionnel). Il n'existe AUCUN paramètre « format », aucun chemin /rapports/. N'invente JAMAIS une URL : copie-la telle quelle depuis une réf ou un outil. | |
| 43 | +- Pas de réf ni de liens d'outil sous la main ? Tu DOIS appeler chercher puis liens_rapports AVANT de donner un lien — jamais de lien de mémoire. | |
| 44 | + | |
| 40 | 45 | # Limites (à respecter strictement) |
| 41 | 46 | - Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier. |
| 42 | 47 | - Ne réponds qu'aux sujets liés à l'immobilier québécois et à Vrai-Prix. Pour tout autre sujet, ramène poliment la conversation. |
@@ -78,6 +83,15 @@ export async function POST(req: Request): Promise<Response> { | ||
| 78 | 83 | return Response.json({ error: "Agent non configuré" }, { status: 503 }); |
| 79 | 84 | } |
| 80 | 85 | |
| 86 | + // garde anti-hallucination : PDF demandé sans aucune réf en contexte → | |
| 87 | + // note injectée dans le tour pour forcer le passage par les outils | |
| 88 | + const hasRef = incoming.some((m) => m.content.includes("[réf:")); | |
| 89 | + const lastMsg = incoming[incoming.length - 1]; | |
| 90 | + if (!hasRef && /\b(pdf|rapport)/i.test(lastMsg.content)) { | |
| 91 | + lastMsg.content += | |
| 92 | + "\n\n[note du système : aucun lien de rapport n'est disponible en contexte — appelle d'abord chercher_propriete puis liens_rapports pour obtenir les VRAIS liens ; n'écris aucune URL de mémoire.]"; | |
| 93 | + } | |
| 94 | + | |
| 81 | 95 | const client = new Anthropic(); |
| 82 | 96 | |
| 83 | 97 | // outils avec point de cache sur le dernier (tools + system cachés ensemble) |
@@ -90,6 +104,26 @@ export async function POST(req: Request): Promise<Response> { | ||
| 90 | 104 | content: m.content, |
| 91 | 105 | })); |
| 92 | 106 | |
| 107 | + // réfs persistantes (id exacts évalués) — renvoyées au client pour l'historique | |
| 108 | + const refs = new Map<string, string>(); | |
| 109 | + const collectRefs = (out: unknown) => { | |
| 110 | + const scan = (o: unknown) => { | |
| 111 | + if (!o || typeof o !== "object") return; | |
| 112 | + const rec = o as Record<string, unknown>; | |
| 113 | + const id = rec.id; | |
| 114 | + const adresse = rec.adresse; | |
| 115 | + if (typeof id === "string" && id.length >= 10 && typeof adresse === "string") { | |
| 116 | + refs.set(id, adresse); | |
| 117 | + } | |
| 118 | + for (const k of ["unite", "evaluation", "detail"]) { | |
| 119 | + const v = rec[k]; | |
| 120 | + if (Array.isArray(v)) v.forEach(scan); | |
| 121 | + else scan(v); | |
| 122 | + } | |
| 123 | + }; | |
| 124 | + scan(out); | |
| 125 | + }; | |
| 126 | + | |
| 93 | 127 | const stream = new ReadableStream({ |
| 94 | 128 | async start(controller) { |
| 95 | 129 | try { |
@@ -113,6 +147,17 @@ export async function POST(req: Request): Promise<Response> { | ||
| 113 | 147 | const final = await msgStream.finalMessage(); |
| 114 | 148 | |
| 115 | 149 | if (final.stop_reason !== "tool_use") { |
| 150 | + if (refs.size) { | |
| 151 | + sse(controller, { | |
| 152 | + type: "refs", | |
| 153 | + refs: [...refs].slice(-4).map(([id, adresse]) => ({ | |
| 154 | + id, | |
| 155 | + adresse, | |
| 156 | + pdf: `https://www.vrai-prix.com/api/report?id=${encodeURIComponent(id)}`, | |
| 157 | + pdf_pro: `https://www.vrai-prix.com/api/report/pro?id=${encodeURIComponent(id)}`, | |
| 158 | + })), | |
| 159 | + }); | |
| 160 | + } | |
| 116 | 161 | sse(controller, { type: "done" }); |
| 117 | 162 | break; |
| 118 | 163 | } |
@@ -129,6 +174,7 @@ export async function POST(req: Request): Promise<Response> { | ||
| 129 | 174 | } catch (e) { |
| 130 | 175 | out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` }; |
| 131 | 176 | } |
| 177 | + collectRefs(out); | |
| 132 | 178 | results.push({ |
| 133 | 179 | type: "tool_result", |
| 134 | 180 | tool_use_id: block.id, |
modified
src/app/api/report/pro/route.ts
+3 −2
@@ -5,8 +5,9 @@ import { getMarketIndex } from "@/lib/db"; | ||
| 5 | 5 | import { buildProReport } from "@/lib/report-pro"; |
| 6 | 6 | |
| 7 | 7 | export async function GET(req: NextRequest) { |
| 8 | − const id = req.nextUrl.searchParams.get("id"); | |
| 9 | − if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 }); | |
| 8 | + const rawId = req.nextUrl.searchParams.get("id"); | |
| 9 | + if (!rawId) return NextResponse.json({ error: "id requis" }, { status: 400 }); | |
| 10 | + const id = rawId.replace(/\D/g, "") || rawId; | |
| 10 | 11 | const data = estimateByUnitId(id); |
| 11 | 12 | if (!data || !data.unit) |
| 12 | 13 | return NextResponse.json({ error: "unité introuvable" }, { status: 404 }); |
modified
src/app/api/report/route.ts
+8 −2
@@ -4,8 +4,14 @@ import { estimateByUnitId } from "@/lib/estimator"; | ||
| 4 | 4 | import { buildReport } from "@/lib/report"; |
| 5 | 5 | |
| 6 | 6 | export async function GET(req: NextRequest) { |
| 7 | − const id = req.nextUrl.searchParams.get("id"); | |
| 8 | − if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 }); | |
| 7 | + const rawId = req.nextUrl.searchParams.get("id"); | |
| 8 | + if (!rawId) return NextResponse.json({ error: "id requis" }, { status: 400 }); | |
| 9 | + const id = rawId.replace(/\D/g, "") || rawId; // tolère un id décoré (espaces, tirets) | |
| 10 | + // tolère les liens du type ?format=professionnel générés par des agents | |
| 11 | + const format = (req.nextUrl.searchParams.get("format") ?? "").toLowerCase(); | |
| 12 | + if (format.startsWith("pro")) { | |
| 13 | + return NextResponse.redirect(new URL(`/api/report/pro?id=${encodeURIComponent(id)}`, req.url)); | |
| 14 | + } | |
| 9 | 15 | const data = estimateByUnitId(id); |
| 10 | 16 | if (!data || !data.unit) |
| 11 | 17 | return NextResponse.json({ error: "unité introuvable" }, { status: 404 }); |
modified
src/components/KaChat.tsx
+20 −1
@@ -146,6 +146,7 @@ function renderMd(text: string): React.ReactNode[] { | ||
| 146 | 146 | } |
| 147 | 147 | flushList(); |
| 148 | 148 | if (t === "" || t === "---") continue; |
| 149 | + if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée | |
| 149 | 150 | if (/^#{1,4}\s+/.test(t)) { |
| 150 | 151 | blocks.push( |
| 151 | 152 | <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold"> |
@@ -244,7 +245,13 @@ export default function KaChat() { | ||
| 244 | 245 | for (const ev of events) { |
| 245 | 246 | const line = ev.split("\n").find((l) => l.startsWith("data: ")); |
| 246 | 247 | if (!line) continue; |
| 247 | − let data: { type: string; text?: string; name?: string; message?: string }; | |
| 248 | + let data: { | |
| 249 | + type: string; | |
| 250 | + text?: string; | |
| 251 | + name?: string; | |
| 252 | + message?: string; | |
| 253 | + refs?: { id: string; adresse: string; pdf?: string; pdf_pro?: string }[]; | |
| 254 | + }; | |
| 248 | 255 | try { |
| 249 | 256 | data = JSON.parse(line.slice(6)); |
| 250 | 257 | } catch { |
@@ -259,6 +266,18 @@ export default function KaChat() { | ||
| 259 | 266 | ...m, |
| 260 | 267 | tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!], |
| 261 | 268 | })); |
| 269 | + } else if (data.type === "refs" && Array.isArray(data.refs)) { | |
| 270 | + const lines = ( | |
| 271 | + data.refs as { id: string; adresse: string; pdf?: string; pdf_pro?: string }[] | |
| 272 | + ) | |
| 273 | + .map( | |
| 274 | + (r) => | |
| 275 | + `[réf: ${r.adresse} → id ${r.id}` + | |
| 276 | + (r.pdf ? ` | rapport standard: ${r.pdf} | rapport pro: ${r.pdf_pro}` : "") + | |
| 277 | + "]" | |
| 278 | + ) | |
| 279 | + .join("\n"); | |
| 280 | + patch((m) => ({ ...m, content: m.content + "\n\n" + lines })); | |
| 262 | 281 | } else if (data.type === "error") { |
| 263 | 282 | patch((m) => ({ |
| 264 | 283 | ...m, |
modified
src/lib/ka/tools.ts
+35 −12
@@ -75,6 +75,30 @@ function searchFlexible( | ||
| 75 | 75 | return { rows: [], niveau: "approximative" }; |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | +/** | |
| 79 | + * Résout un id éventuellement mal recopié par le modèle : essai brut, | |
| 80 | + * puis version chiffres seulement (les id provinciaux sont numériques). | |
| 81 | + */ | |
| 82 | +function resolveId(raw: unknown): string { | |
| 83 | + const a = String(raw ?? "").trim(); | |
| 84 | + if (getUnit(a)) return a; | |
| 85 | + const d = a.replace(/\D/g, ""); | |
| 86 | + if (d && d !== a && getUnit(d)) return d; | |
| 87 | + return a; | |
| 88 | +} | |
| 89 | + | |
| 90 | +const ID_ERR = | |
| 91 | + "Unité introuvable — l'id est peut-être mal recopié. Relance chercher_propriete et copie le champ `id` EXACTEMENT tel quel."; | |
| 92 | + | |
| 93 | +function liens(id: string) { | |
| 94 | + const e = encodeURIComponent(id); | |
| 95 | + return { | |
| 96 | + fiche_complete: `https://www.vrai-prix.com/estimation/${e}`, | |
| 97 | + rapport_standard_pdf: `https://www.vrai-prix.com/api/report?id=${e}`, | |
| 98 | + rapport_professionnel_pdf: `https://www.vrai-prix.com/api/report/pro?id=${e}`, | |
| 99 | + }; | |
| 100 | +} | |
| 101 | + | |
| 78 | 102 | function unitCard(u: ReturnType<typeof searchUnits>[number]) { |
| 79 | 103 | return { |
| 80 | 104 | id: u.id_provinc, |
@@ -113,6 +137,7 @@ function evalCard(e: UnitEstimate) { | ||
| 113 | 137 | valeur_terrain_role: u.specs.valeurTerrain, |
| 114 | 138 | valeur_batiment_role: u.specs.valeurBatiment, |
| 115 | 139 | valeur_role_anterieure: u.specs.valeurAnterieure, |
| 140 | + liens: liens(u.id), | |
| 116 | 141 | } |
| 117 | 142 | : null, |
| 118 | 143 | estimation: { |
@@ -333,14 +358,14 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 333 | 358 | } |
| 334 | 359 | |
| 335 | 360 | case "evaluer_propriete": { |
| 336 | − const e = estimateByUnitId(String(input.id ?? "")); | |
| 337 | − if (!e) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; | |
| 361 | + const e = estimateByUnitId(resolveId(input.id)); | |
| 362 | + if (!e) return { erreur: ID_ERR }; | |
| 338 | 363 | return evalCard(e); |
| 339 | 364 | } |
| 340 | 365 | |
| 341 | 366 | case "comparables_detailles": { |
| 342 | − const e = estimateByUnitId(String(input.id ?? "")); | |
| 343 | − if (!e) return { erreur: "Unité introuvable." }; | |
| 367 | + const e = estimateByUnitId(resolveId(input.id)); | |
| 368 | + if (!e) return { erreur: ID_ERR }; | |
| 344 | 369 | const max = Math.min(Number(input.max) || 8, 15); |
| 345 | 370 | return { |
| 346 | 371 | n_utilises: e.result.nCompsUsed, |
@@ -423,7 +448,7 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 423 | 448 | } |
| 424 | 449 | |
| 425 | 450 | case "evaluer_parc": { |
| 426 | − const ids = (input.ids as string[] | undefined)?.slice(0, 40) ?? []; | |
| 451 | + const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId); | |
| 427 | 452 | if (ids.length < 1) return { erreur: "Fournir au moins un id." }; |
| 428 | 453 | const p = estimatePortfolio(ids); |
| 429 | 454 | if (!p.items.length) return { erreur: "Aucune unité valide trouvée." }; |
@@ -450,7 +475,7 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 450 | 475 | } |
| 451 | 476 | |
| 452 | 477 | case "comparer_proprietes": { |
| 453 | − const ids = (input.ids as string[] | undefined)?.slice(0, 4) ?? []; | |
| 478 | + const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId); | |
| 454 | 479 | if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; |
| 455 | 480 | return { |
| 456 | 481 | comparaison: ids.map((id) => { |
@@ -521,7 +546,7 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 521 | 546 | } |
| 522 | 547 | |
| 523 | 548 | case "dossier_complet": { |
| 524 | − const id = String(input.id ?? ""); | |
| 549 | + const id = resolveId(input.id); | |
| 525 | 550 | const ev = estimateByUnitId(id); |
| 526 | 551 | if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; |
| 527 | 552 | const u = ev.unit; |
@@ -575,13 +600,11 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 575 | 600 | } |
| 576 | 601 | |
| 577 | 602 | case "liens_rapports": { |
| 578 | − const id = String(input.id ?? ""); | |
| 603 | + const id = resolveId(input.id); | |
| 579 | 604 | const u = getUnit(id); |
| 580 | − if (!u) return { erreur: "Unité introuvable." }; | |
| 605 | + if (!u) return { erreur: ID_ERR }; | |
| 581 | 606 | return { |
| 582 | − fiche_complete: `https://www.vrai-prix.com/estimation/${encodeURIComponent(id)}`, | |
| 583 | − rapport_standard_pdf: `https://www.vrai-prix.com/api/report?id=${encodeURIComponent(id)}`, | |
| 584 | − rapport_professionnel_pdf: `https://www.vrai-prix.com/api/report/pro?id=${encodeURIComponent(id)}`, | |
| 607 | + ...liens(id), | |
| 585 | 608 | note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.", |
| 586 | 609 | }; |
| 587 | 610 | } |
| 588 | 611 | |