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.2%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Ka — agent IA de Vrai-Prix. Boucle agentique en streaming :4 * Claude Haiku 4.5 + panoplie d'outils serveur (base SQLite + moteur).5 * La réponse est un flux SSE : deltas de texte, activité d'outils, fin.6 * Le prompt système et les outils sont mis en cache (prompt caching).7 */8import Anthropic from "@anthropic-ai/sdk";9import { KA_TOOLS, runKaTool } from "@/lib/ka/tools";1011export const runtime = "nodejs";12export const maxDuration = 120;1314const MODEL = "claude-haiku-4-5";15const MAX_TURNS = 12;16const MAX_MESSAGES = 40;17const MAX_CHARS = 4000;1819const SYSTEM = `Tu es Ka, l'agent d'évaluation immobilière de Vrai-Prix (www.vrai-prix.com), créé par Simon-Pierre Boucher. Tu aides les gens à connaître la vraie valeur de leur propriété au Québec — unifamiliale, condo, plex, chalet, terrain — en t'appuyant sur 3 747 008 unités d'évaluation, 745 119 ventes réelles (2021-2026) et un modèle hédonique validé (erreur médiane 11 %).2021# Ta démarche221. Quand on te donne une adresse (même partielle), appelle chercher_propriete immédiatement.232. S'il y a UN candidat clair, évalue-le directement (evaluer_propriete). S'il y en a plusieurs plausibles, liste-les brièvement (adresse, municipalité, type) et demande à l'utilisateur de choisir — ne devine jamais.242b. 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.253. 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.264. 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.275. 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).2829# Frappe fort30- 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.31- Ne te contente jamais du minimum : croise les données (propriété vs marché local vs tendance provinciale) et tranche avec un avis chiffré.32- Anticipe la prochaine question : offre-la avant qu'on te la pose.3334# Ton style35- Langue de l'utilisateur (français québécois par défaut). Tutoiement chaleureux mais professionnel.36- Concis et concret : des chiffres, pas du remplissage. Formate les montants à la québécoise (ex. 445 000 $) et arrondis intelligemment (au millier près).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).3940# 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.4445# Limites (à respecter strictement)46- Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier.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.48- N'invente JAMAIS un chiffre : tout montant cité doit venir d'un outil. Si un outil échoue, dis-le simplement.49- Tu ne peux pas voir l'intérieur des propriétés : les rénovations récentes ne sont pas captées, mentionne-le si pertinent.`;5051interface ClientMsg {52 role: "user" | "assistant";53 content: string;54}5556function sse(controller: ReadableStreamDefaultController, data: unknown) {57 controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));58}5960export async function POST(req: Request): Promise<Response> {61 let body: { messages?: ClientMsg[] };62 try {63 body = await req.json();64 } catch {65 return Response.json({ error: "JSON invalide" }, { status: 400 });66 }6768 const incoming = (body.messages ?? [])69 .filter(70 (m): m is ClientMsg =>71 !!m &&72 (m.role === "user" || m.role === "assistant") &&73 typeof m.content === "string" &&74 m.content.trim().length > 075 )76 .slice(-MAX_MESSAGES)77 .map((m) => ({ role: m.role, content: m.content.slice(0, MAX_CHARS) }));7879 if (!incoming.length || incoming[incoming.length - 1].role !== "user") {80 return Response.json({ error: "Dernier message utilisateur requis" }, { status: 400 });81 }82 if (!process.env.ANTHROPIC_API_KEY) {83 return Response.json({ error: "Agent non configuré" }, { status: 503 });84 }8586 // garde anti-hallucination : PDF demandé sans aucune réf en contexte →87 // note injectée dans le tour pour forcer le passage par les outils88 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 }9495 const client = new Anthropic();9697 // outils avec point de cache sur le dernier (tools + system cachés ensemble)98 const tools: Anthropic.ToolUnion[] = KA_TOOLS.map((t, i) =>99 i === KA_TOOLS.length - 1 ? { ...t, cache_control: { type: "ephemeral" as const } } : t100 );101102 const messages: Anthropic.MessageParam[] = incoming.map((m) => ({103 role: m.role,104 content: m.content,105 }));106107 // réfs persistantes (id exacts évalués) — renvoyées au client pour l'historique108 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 };126127 const stream = new ReadableStream({128 async start(controller) {129 try {130 for (let turn = 0; turn < MAX_TURNS; turn++) {131 const msgStream = client.messages.stream({132 model: MODEL,133 max_tokens: 2200,134 system: [135 {136 type: "text",137 text: SYSTEM,138 cache_control: { type: "ephemeral" },139 },140 ],141 tools,142 messages,143 });144145 msgStream.on("text", (delta) => sse(controller, { type: "text", text: delta }));146147 const final = await msgStream.finalMessage();148149 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 }161 sse(controller, { type: "done" });162 break;163 }164165 // exécuter les outils demandés, renvoyer TOUS les résultats en un seul message166 messages.push({ role: "assistant", content: final.content });167 const results: Anthropic.ToolResultBlockParam[] = [];168 for (const block of final.content) {169 if (block.type !== "tool_use") continue;170 sse(controller, { type: "tool", name: block.name });171 let out: unknown;172 try {173 out = runKaTool(block.name, block.input as Record<string, unknown>);174 } catch (e) {175 out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` };176 }177 collectRefs(out);178 results.push({179 type: "tool_result",180 tool_use_id: block.id,181 content: JSON.stringify(out),182 });183 }184 messages.push({ role: "user", content: results });185186 if (turn === MAX_TURNS - 1) {187 sse(controller, {188 type: "text",189 text: "\n\n*(J'ai atteint ma limite d'étapes pour cette réponse — repose ta question pour continuer.)*",190 });191 sse(controller, { type: "done" });192 }193 }194 } catch (e) {195 sse(controller, {196 type: "error",197 message:198 e instanceof Anthropic.APIError199 ? `Service momentanément indisponible (${e.status ?? "réseau"}). Réessaie dans un instant.`200 : "Une erreur inattendue est survenue. Réessaie.",201 });202 } finally {203 controller.close();204 }205 },206 });207208 return new Response(stream, {209 headers: {210 "Content-Type": "text/event-stream; charset=utf-8",211 "Cache-Control": "no-cache, no-transform",212 Connection: "keep-alive",213 "X-Accel-Buffering": "no",214 },215 });216}217