SPB Git forge

spb/vrai-prix

Public

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

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%

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 1 mo ago (Aug 9, 2026) parent 3c54493

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