SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%

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

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