// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Ka — agent IA de Vrai-Prix. Boucle agentique en streaming : * Claude Haiku 4.5 + panoplie d'outils serveur (base SQLite + moteur). * La réponse est un flux SSE : deltas de texte, activité d'outils, fin. * Le prompt système et les outils sont mis en cache (prompt caching). */ import Anthropic from "@anthropic-ai/sdk"; import { KA_TOOLS, runKaTool } from "@/lib/ka/tools"; export const runtime = "nodejs"; export const maxDuration = 120; const MODEL = "claude-haiku-4-5"; const MAX_TURNS = 12; const MAX_MESSAGES = 40; const MAX_CHARS = 4000; const 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 %). # Ta démarche 1. Quand on te donne une adresse (même partielle), appelle chercher_propriete immédiatement. 2. 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. 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. 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. 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. 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). # Frappe fort - 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. - Ne te contente jamais du minimum : croise les données (propriété vs marché local vs tendance provinciale) et tranche avec un avis chiffré. - Anticipe la prochaine question : offre-la avant qu'on te la pose. # Ton style - Langue de l'utilisateur (français québécois par défaut). Tutoiement chaleureux mais professionnel. - 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). - Transparence totale : c'est l'ADN de Vrai-Prix. Explique d'où viennent les chiffres quand on te le demande. - Markdown léger : gras pour la valeur clé, listes courtes. Tableaux markdown permis mais compacts (max 5 colonnes, libellés courts). # Continuité et intégrité (à respecter strictement) - 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). - 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. - 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. # Limites (à respecter strictement) - Estimations statistiques à titre indicatif : rappelle au besoin que ça ne remplace pas un évaluateur agréé (OEAQ) ni un courtier. - 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. - N'invente JAMAIS un chiffre : tout montant cité doit venir d'un outil. Si un outil échoue, dis-le simplement. - 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.`; interface ClientMsg { role: "user" | "assistant"; content: string; } function sse(controller: ReadableStreamDefaultController, data: unknown) { controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`)); } export async function POST(req: Request): Promise { let body: { messages?: ClientMsg[] }; try { body = await req.json(); } catch { return Response.json({ error: "JSON invalide" }, { status: 400 }); } const incoming = (body.messages ?? []) .filter( (m): m is ClientMsg => !!m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim().length > 0 ) .slice(-MAX_MESSAGES) .map((m) => ({ role: m.role, content: m.content.slice(0, MAX_CHARS) })); if (!incoming.length || incoming[incoming.length - 1].role !== "user") { return Response.json({ error: "Dernier message utilisateur requis" }, { status: 400 }); } if (!process.env.ANTHROPIC_API_KEY) { return Response.json({ error: "Agent non configuré" }, { status: 503 }); } // garde anti-hallucination : PDF demandé sans aucune réf en contexte → // note injectée dans le tour pour forcer le passage par les outils const hasRef = incoming.some((m) => m.content.includes("[réf:")); const lastMsg = incoming[incoming.length - 1]; if (!hasRef && /\b(pdf|rapport)/i.test(lastMsg.content)) { lastMsg.content += "\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.]"; } const client = new Anthropic(); // outils avec point de cache sur le dernier (tools + system cachés ensemble) const tools: Anthropic.ToolUnion[] = KA_TOOLS.map((t, i) => i === KA_TOOLS.length - 1 ? { ...t, cache_control: { type: "ephemeral" as const } } : t ); const messages: Anthropic.MessageParam[] = incoming.map((m) => ({ role: m.role, content: m.content, })); // réfs persistantes (id exacts évalués) — renvoyées au client pour l'historique const refs = new Map(); const collectRefs = (out: unknown) => { const scan = (o: unknown) => { if (!o || typeof o !== "object") return; const rec = o as Record; const id = rec.id; const adresse = rec.adresse; if (typeof id === "string" && id.length >= 10 && typeof adresse === "string") { refs.set(id, adresse); } for (const k of ["unite", "evaluation", "detail"]) { const v = rec[k]; if (Array.isArray(v)) v.forEach(scan); else scan(v); } }; scan(out); }; const stream = new ReadableStream({ async start(controller) { try { for (let turn = 0; turn < MAX_TURNS; turn++) { const msgStream = client.messages.stream({ model: MODEL, max_tokens: 2200, system: [ { type: "text", text: SYSTEM, cache_control: { type: "ephemeral" }, }, ], tools, messages, }); msgStream.on("text", (delta) => sse(controller, { type: "text", text: delta })); const final = await msgStream.finalMessage(); if (final.stop_reason !== "tool_use") { if (refs.size) { sse(controller, { type: "refs", refs: [...refs].slice(-4).map(([id, adresse]) => ({ id, adresse, pdf: `https://www.vrai-prix.com/api/report?id=${encodeURIComponent(id)}`, pdf_pro: `https://www.vrai-prix.com/api/report/pro?id=${encodeURIComponent(id)}`, })), }); } sse(controller, { type: "done" }); break; } // exécuter les outils demandés, renvoyer TOUS les résultats en un seul message messages.push({ role: "assistant", content: final.content }); const results: Anthropic.ToolResultBlockParam[] = []; for (const block of final.content) { if (block.type !== "tool_use") continue; sse(controller, { type: "tool", name: block.name }); let out: unknown; try { out = runKaTool(block.name, block.input as Record); } catch (e) { out = { erreur: `Échec de l'outil : ${e instanceof Error ? e.message : "inconnu"}` }; } collectRefs(out); results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out), }); } messages.push({ role: "user", content: results }); if (turn === MAX_TURNS - 1) { sse(controller, { type: "text", text: "\n\n*(J'ai atteint ma limite d'étapes pour cette réponse — repose ta question pour continuer.)*", }); sse(controller, { type: "done" }); } } } catch (e) { sse(controller, { type: "error", message: e instanceof Anthropic.APIError ? `Service momentanément indisponible (${e.status ?? "réseau"}). Réessaie dans un instant.` : "Une erreur inattendue est survenue. Réessaie.", }); } finally { controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache, no-transform", Connection: "keep-alive", "X-Accel-Buffering": "no", }, }); }