Ka — liens PDF fiables (réfs persistantes, garde anti-hallucination)
Même triple verrou que Vrai-Prix : réfs persistantes avec URLs exactes dans l'historique client, garde serveur sur les demandes de PDF sans réf, endpoints /api/report tolérants, resolveId partout, liens intégrés au dossier investisseur. Vérifié en prod : « oui pdf » sur le 165 portes Grandjean donne les vrais liens (200). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 5 changed files with +115 and −20
modified
app/src/app/api/ka/route.ts
+47 −1
@@ -24,7 +24,7 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation de ValoPlex (www.valoplex.com), | ||
| 24 | 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 | 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 | 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). | |
| 27 | +6. 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 (autre scénario de financement, comparaison, secteur). | |
| 28 | 28 | |
| 29 | 29 | # Frappe fort |
| 30 | 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. |
@@ -36,6 +36,11 @@ const SYSTEM = `Tu es Ka, l'agent d'évaluation de ValoPlex (www.valoplex.com), | ||
| 36 | 36 | - Concis et concret : des chiffres, pas du remplissage. Montants à la québécoise (ex. 645 000 $), arrondis intelligemment. |
| 37 | 37 | - Markdown léger : gras pour les valeurs clés, listes courtes. Tableaux markdown permis mais compacts (max 5 colonnes, libellés courts). |
| 38 | 38 | |
| 39 | +# Continuité et intégrité (à respecter strictement) | |
| 40 | +- 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). | |
| 41 | +- 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. | |
| 42 | +- 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. | |
| 43 | + | |
| 39 | 44 | # Limites (à respecter strictement) |
| 40 | 45 | - Estimations statistiques indicatives : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un conseil financier ou hypothécaire. |
| 41 | 46 | - Le pro forma part d'un LOYER IMPLICITE (celui que la valeur suppose au TGA de référence), pas des baux réels — dis-le quand tu le présentes. |
@@ -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_plex 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 ["plex", "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.valoplex.com/api/report?id=${encodeURIComponent(id)}`, | |
| 157 | + pdf_pro: `https://www.valoplex.com/api/report/pro?id=${encodeURIComponent(id)}`, | |
| 158 | + })), | |
| 159 | + }); | |
| 160 | + } | |
| 116 | 161 | sse(controller, { type: "done" }); |
| 117 | 162 | break; |
| 118 | 163 | } |
@@ -128,6 +173,7 @@ export async function POST(req: Request): Promise<Response> { | ||
| 128 | 173 | } catch (e) { |
| 129 | 174 | out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` }; |
| 130 | 175 | } |
| 176 | + collectRefs(out); | |
| 131 | 177 | results.push({ |
| 132 | 178 | type: "tool_result", |
| 133 | 179 | tool_use_id: block.id, |
modified
app/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
app/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
app/src/components/KaChat.tsx
+20 −1
@@ -147,6 +147,7 @@ function renderMd(text: string): React.ReactNode[] { | ||
| 147 | 147 | } |
| 148 | 148 | flushList(); |
| 149 | 149 | if (t === "" || t === "---") continue; |
| 150 | + if (t.startsWith("[réf:")) continue; // réf machine pour la continuité — jamais affichée | |
| 150 | 151 | if (/^#{1,4}\s+/.test(t)) { |
| 151 | 152 | blocks.push( |
| 152 | 153 | <p key={`h-${k++}`} className="vp-display mt-2 text-[15px] font-bold"> |
@@ -245,7 +246,13 @@ export default function KaChat() { | ||
| 245 | 246 | for (const ev of events) { |
| 246 | 247 | const line = ev.split("\n").find((l) => l.startsWith("data: ")); |
| 247 | 248 | if (!line) continue; |
| 248 | − let data: { type: string; text?: string; name?: string; message?: string }; | |
| 249 | + let data: { | |
| 250 | + type: string; | |
| 251 | + text?: string; | |
| 252 | + name?: string; | |
| 253 | + message?: string; | |
| 254 | + refs?: { id: string; adresse: string; pdf?: string; pdf_pro?: string }[]; | |
| 255 | + }; | |
| 249 | 256 | try { |
| 250 | 257 | data = JSON.parse(line.slice(6)); |
| 251 | 258 | } catch { |
@@ -260,6 +267,18 @@ export default function KaChat() { | ||
| 260 | 267 | ...m, |
| 261 | 268 | tools: m.tools?.includes(data.name!) ? m.tools : [...(m.tools ?? []), data.name!], |
| 262 | 269 | })); |
| 270 | + } else if (data.type === "refs" && Array.isArray(data.refs)) { | |
| 271 | + const lines = ( | |
| 272 | + data.refs as { id: string; adresse: string; pdf?: string; pdf_pro?: string }[] | |
| 273 | + ) | |
| 274 | + .map( | |
| 275 | + (r) => | |
| 276 | + `[réf: ${r.adresse} → id ${r.id}` + | |
| 277 | + (r.pdf ? ` | rapport standard: ${r.pdf} | rapport pro: ${r.pdf_pro}` : "") + | |
| 278 | + "]" | |
| 279 | + ) | |
| 280 | + .join("\n"); | |
| 281 | + patch((m) => ({ ...m, content: m.content + "\n\n" + lines })); | |
| 263 | 282 | } else if (data.type === "error") { |
| 264 | 283 | patch((m) => ({ |
| 265 | 284 | ...m, |
modified
app/src/lib/ka/tools.ts
+37 −14
@@ -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 | + "Plex introuvable — l'id est peut-être mal recopié. Relance chercher_plex 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.valoplex.com/estimation/${e}`, | |
| 97 | + rapport_standard_pdf: `https://www.valoplex.com/api/report?id=${e}`, | |
| 98 | + rapport_professionnel_pdf: `https://www.valoplex.com/api/report/pro?id=${e}`, | |
| 99 | + }; | |
| 100 | +} | |
| 101 | + | |
| 78 | 102 | function gabarit(portes: number | null): string { |
| 79 | 103 | if (!portes) return "plex"; |
| 80 | 104 | if (portes === 2) return "duplex"; |
@@ -121,6 +145,7 @@ function evalCard(e: UnitEstimate) { | ||
| 121 | 145 | valeur_role_2026: u.valeurRole, |
| 122 | 146 | valeur_terrain_role: u.specs.valeurTerrain, |
| 123 | 147 | valeur_batiment_role: u.specs.valeurBatiment, |
| 148 | + liens: liens(u.id), | |
| 124 | 149 | } |
| 125 | 150 | : null, |
| 126 | 151 | estimation: { |
@@ -345,15 +370,15 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 345 | 370 | } |
| 346 | 371 | |
| 347 | 372 | case "evaluer_plex": { |
| 348 | − const e = estimateByUnitId(String(input.id ?? "")); | |
| 349 | − if (!e) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." }; | |
| 373 | + const e = estimateByUnitId(resolveId(input.id)); | |
| 374 | + if (!e) return { erreur: ID_ERR }; | |
| 350 | 375 | return evalCard(e); |
| 351 | 376 | } |
| 352 | 377 | |
| 353 | 378 | case "proforma_investisseur": { |
| 354 | − const id = String(input.id ?? ""); | |
| 379 | + const id = resolveId(input.id); | |
| 355 | 380 | const e = estimateByUnitId(id); |
| 356 | − if (!e || !e.unit) return { erreur: "Plex introuvable." }; | |
| 381 | + if (!e || !e.unit) return { erreur: ID_ERR }; | |
| 357 | 382 | const doors = e.unit.nbLogements ?? 2; |
| 358 | 383 | const partial: Partial<ProformaParams> = {}; |
| 359 | 384 | if (input.taux_hypo_pct != null) partial.tauxHypoPct = Number(input.taux_hypo_pct); |
@@ -430,8 +455,8 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 430 | 455 | } |
| 431 | 456 | |
| 432 | 457 | case "comparables_detailles": { |
| 433 | − const e = estimateByUnitId(String(input.id ?? "")); | |
| 434 | − if (!e) return { erreur: "Plex introuvable." }; | |
| 458 | + const e = estimateByUnitId(resolveId(input.id)); | |
| 459 | + if (!e) return { erreur: ID_ERR }; | |
| 435 | 460 | const max = Math.min(Number(input.max) || 8, 15); |
| 436 | 461 | if (!e.result.comps.length) |
| 437 | 462 | return { |
@@ -528,7 +553,7 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 528 | 553 | } |
| 529 | 554 | |
| 530 | 555 | case "evaluer_parc": { |
| 531 | − const ids = (input.ids as string[] | undefined)?.slice(0, 40) ?? []; | |
| 556 | + const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 40).map(resolveId); | |
| 532 | 557 | if (ids.length < 1) return { erreur: "Fournir au moins un id." }; |
| 533 | 558 | const p = estimatePortfolio(ids); |
| 534 | 559 | if (!p.items.length) return { erreur: "Aucun plex valide trouvé." }; |
@@ -556,7 +581,7 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 556 | 581 | } |
| 557 | 582 | |
| 558 | 583 | case "comparer_plex": { |
| 559 | − const ids = (input.ids as string[] | undefined)?.slice(0, 4) ?? []; | |
| 584 | + const ids = ((input.ids as string[] | undefined) ?? []).slice(0, 4).map(resolveId); | |
| 560 | 585 | if (ids.length < 2) return { erreur: "Fournir 2 à 4 ids." }; |
| 561 | 586 | return { |
| 562 | 587 | comparaison: ids.map((id) => { |
@@ -633,7 +658,7 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 633 | 658 | } |
| 634 | 659 | |
| 635 | 660 | case "dossier_investisseur": { |
| 636 | − const id = String(input.id ?? ""); | |
| 661 | + const id = resolveId(input.id); | |
| 637 | 662 | const ev = estimateByUnitId(id); |
| 638 | 663 | if (!ev || !ev.unit) return { erreur: "Plex introuvable — vérifier l'id avec chercher_plex." }; |
| 639 | 664 | const u = ev.unit; |
@@ -717,13 +742,11 @@ export function runKaTool(name: string, input: J): unknown { | ||
| 717 | 742 | } |
| 718 | 743 | |
| 719 | 744 | case "liens_rapports": { |
| 720 | − const id = String(input.id ?? ""); | |
| 745 | + const id = resolveId(input.id); | |
| 721 | 746 | const u = getUnit(id); |
| 722 | − if (!u) return { erreur: "Plex introuvable." }; | |
| 747 | + if (!u) return { erreur: ID_ERR }; | |
| 723 | 748 | return { |
| 724 | − fiche_complete: `https://www.valoplex.com/estimation/${encodeURIComponent(id)}`, | |
| 725 | − rapport_standard_pdf: `https://www.valoplex.com/api/report?id=${encodeURIComponent(id)}`, | |
| 726 | − rapport_professionnel_pdf: `https://www.valoplex.com/api/report/pro?id=${encodeURIComponent(id)}`, | |
| 749 | + ...liens(id), | |
| 727 | 750 | note: "Le rapport standard inclut le pro forma ; le professionnel (6 pages) est le format bancaire.", |
| 728 | 751 | }; |
| 729 | 752 | } |
| 730 | 753 | |