SPB Git

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

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 (Aug 9, 2026) parent 3c54493 Browse files

5 changed files +113 −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 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. 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. 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. 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 # Frappe fort 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. 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 - Transparence totale : c'est l'ADN de Vrai-Prix. Explique d'où viennent les chiffres quand on te le demande. 37 - Transparence totale : c'est l'ADN de Vrai-Prix. Explique d'où viennent les chiffres quand on te le demande.
38 - Markdown léger : gras pour la valeur clé, listes courtes. Tableaux markdown permis mais compacts (max 5 colonnes, libellés courts). 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 # Limites (à respecter strictement) 45 # Limites (à respecter strictement)
41 - Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier. 46 - Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier.
42 - 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. 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 return Response.json({ error: "Agent non configuré" }, { status: 503 }); 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 const client = new Anthropic(); 95 const client = new Anthropic();
82 96
83 // outils avec point de cache sur le dernier (tools + system cachés ensemble) 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 content: m.content, 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 const stream = new ReadableStream({ 127 const stream = new ReadableStream({
94 async start(controller) { 128 async start(controller) {
95 try { 129 try {
@@ -113,6 +147,17 @@ export async function POST(req: Request): Promise<Response> {
113 const final = await msgStream.finalMessage(); 147 const final = await msgStream.finalMessage();
114 148
115 if (final.stop_reason !== "tool_use") { 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 sse(controller, { type: "done" }); 161 sse(controller, { type: "done" });
117 break; 162 break;
118 } 163 }
@@ -129,6 +174,7 @@ export async function POST(req: Request): Promise<Response> {
129 } catch (e) { 174 } catch (e) {
130 out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` }; 175 out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` };
131 } 176 }
177+ collectRefs(out);
132 results.push({ 178 results.push({
133 type: "tool_result", 179 type: "tool_result",
134 tool_use_id: block.id, 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 import { buildProReport } from "@/lib/report-pro"; 5 import { buildProReport } from "@/lib/report-pro";
6 6
7 export async function GET(req: NextRequest) { 7 export async function GET(req: NextRequest) {
8− const id = req.nextUrl.searchParams.get("id"); 8+ const rawId = req.nextUrl.searchParams.get("id");
9− if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 }); 9+ if (!rawId) return NextResponse.json({ error: "id requis" }, { status: 400 });
10+ const id = rawId.replace(/\D/g, "") || rawId;
10 const data = estimateByUnitId(id); 11 const data = estimateByUnitId(id);
11 if (!data || !data.unit) 12 if (!data || !data.unit)
12 return NextResponse.json({ error: "unité introuvable" }, { status: 404 }); 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 import { buildReport } from "@/lib/report"; 4 import { buildReport } from "@/lib/report";
5 5
6 export async function GET(req: NextRequest) { 6 export async function GET(req: NextRequest) {
7− const id = req.nextUrl.searchParams.get("id"); 7+ const rawId = req.nextUrl.searchParams.get("id");
8− if (!id) return NextResponse.json({ error: "id requis" }, { status: 400 }); 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 const data = estimateByUnitId(id); 15 const data = estimateByUnitId(id);
10 if (!data || !data.unit) 16 if (!data || !data.unit)
11 return NextResponse.json({ error: "unité introuvable" }, { status: 404 }); 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 flushList(); 147 flushList();
148 if (t === "" || t === "---") continue; 148 if (t === "" || t === "---") continue;
149+ if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée
149 if (/^#{1,4}\s+/.test(t)) { 150 if (/^#{1,4}\s+/.test(t)) {
150 blocks.push( 151 blocks.push(
151 <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold"> 152 <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold">
@@ -244,7 +245,13 @@ export default function KaChat() {
244 for (const ev of events) { 245 for (const ev of events) {
245 const line = ev.split("\n").find((l) => l.startsWith("data: ")); 246 const line = ev.split("\n").find((l) => l.startsWith("data: "));
246 if (!line) continue; 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 try { 255 try {
249 data = JSON.parse(line.slice(6)); 256 data = JSON.parse(line.slice(6));
250 } catch { 257 } catch {
@@ -259,6 +266,18 @@ export default function KaChat() {
259 ...m, 266 ...m,
260 tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!], 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 } else if (data.type === "error") { 281 } else if (data.type === "error") {
263 patch((m) => ({ 282 patch((m) => ({
264 ...m, 283 ...m,
modified src/lib/ka/tools.ts +35 −12
@@ -75,6 +75,30 @@ function searchFlexible(
75 return { rows: [], niveau: "approximative" }; 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 function unitCard(u: ReturnType<typeof searchUnits>[number]) { 102 function unitCard(u: ReturnType<typeof searchUnits>[number]) {
79 return { 103 return {
80 id: u.id_provinc, 104 id: u.id_provinc,
@@ -113,6 +137,7 @@ function evalCard(e: UnitEstimate) {
113 valeur_terrain_role: u.specs.valeurTerrain, 137 valeur_terrain_role: u.specs.valeurTerrain,
114 valeur_batiment_role: u.specs.valeurBatiment, 138 valeur_batiment_role: u.specs.valeurBatiment,
115 valeur_role_anterieure: u.specs.valeurAnterieure, 139 valeur_role_anterieure: u.specs.valeurAnterieure,
140+ liens: liens(u.id),
116 } 141 }
117 : null, 142 : null,
118 estimation: { 143 estimation: {
@@ -333,14 +358,14 @@ export function runKaTool(name: string, input: J): unknown {
333 } 358 }
334 359
335 case "evaluer_propriete": { 360 case "evaluer_propriete": {
336− const e = estimateByUnitId(String(input.id ?? "")); 361+ const e = estimateByUnitId(resolveId(input.id));
337− if (!e) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; 362+ if (!e) return { erreur: ID_ERR };
338 return evalCard(e); 363 return evalCard(e);
339 } 364 }
340 365
341 case "comparables_detailles": { 366 case "comparables_detailles": {
342− const e = estimateByUnitId(String(input.id ?? "")); 367+ const e = estimateByUnitId(resolveId(input.id));
343− if (!e) return { erreur: "Unité introuvable." }; 368+ if (!e) return { erreur: ID_ERR };
344 const max = Math.min(Number(input.max) || 8, 15); 369 const max = Math.min(Number(input.max) || 8, 15);
345 return { 370 return {
346 n_utilises: e.result.nCompsUsed, 371 n_utilises: e.result.nCompsUsed,
@@ -423,7 +448,7 @@ export function runKaTool(name: string, input: J): unknown {
423 } 448 }
424 449
425 case "evaluer_parc": { 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 if (ids.length < 1) return { erreur: "Fournir au moins un id." }; 452 if (ids.length < 1) return { erreur: "Fournir au moins un id." };
428 const p = estimatePortfolio(ids); 453 const p = estimatePortfolio(ids);
429 if (!p.items.length) return { erreur: "Aucune unité valide trouvée." }; 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 case "comparer_proprietes": { 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 if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; 479 if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." };
455 return { 480 return {
456 comparaison: ids.map((id) => { 481 comparaison: ids.map((id) => {
@@ -521,7 +546,7 @@ export function runKaTool(name: string, input: J): unknown {
521 } 546 }
522 547
523 case "dossier_complet": { 548 case "dossier_complet": {
524− const id = String(input.id ?? ""); 549+ const id = resolveId(input.id);
525 const ev = estimateByUnitId(id); 550 const ev = estimateByUnitId(id);
526 if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." }; 551 if (!ev || !ev.unit) return { erreur: "Unité introuvable — vérifier l'id avec chercher_propriete." };
527 const u = ev.unit; 552 const u = ev.unit;
@@ -575,13 +600,11 @@ export function runKaTool(name: string, input: J): unknown {
575 } 600 }
576 601
577 case "liens_rapports": { 602 case "liens_rapports": {
578− const id = String(input.id ?? ""); 603+ const id = resolveId(input.id);
579 const u = getUnit(id); 604 const u = getUnit(id);
580− if (!u) return { erreur: "Unité introuvable." }; 605+ if (!u) return { erreur: ID_ERR };
581 return { 606 return {
582− fiche_complete: `https://www.vrai-prix.com/estimation/${encodeURIComponent(id)}`, 607+ ...liens(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)}`,
585 note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.", 608 note: "Le rapport professionnel (6 pages) est le format à présenter à une banque.",
586 }; 609 };
587 } 610 }
588 611
Menu

Theme