SPB Git

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%
10.4 KB · 217 lines typescript
Raw Blame History
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