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 — 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>
Simon-Pierre Boucher committed yesterday (Aug 9, 2026) parent 3c54493

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