SPB Git forge

spb/toveli

Public
14commits 1branches 0releases
1.1 MBsize
maindefault branch
2 h agolast push
TypeScript 50.6% JavaScript 46.1% SQL 2.1% Shell 0.6% CSS 0.5%
11.1 KB · 74 lines typescript
Raw Blame History
1// Assistant Toveli — ancré dans la base canonique par appels d'outils ; la clé API reste côté serveur.2// Abstraction fournisseur : Anthropic par défaut ; un fournisseur local/OpenAI peut être branché via TOVELI_LLM_PROVIDER.3import Anthropic from "@anthropic-ai/sdk";4import { searchListings, getListing, marketMetrics, cityMetrics, regionMetrics, searchRoleUnits } from "./queries";5import { estimateRoleUnit } from "./estimate";6import { computeMortgage } from "./mortgage";7import { meta } from "./db";89export const ASSISTANT_MODEL = process.env.TOVELI_LLM_MODEL ?? "claude-sonnet-5-5";1011const SYSTEM = `Tu es l'assistant Toveli, plateforme immobilière québécoise (toveli.ca). Tu réponds en français du Québec, de façon concise et utile, à des particuliers qui cherchent à acheter, louer ou comprendre la valeur d'une propriété.12Règles absolues :13- Tu n'inventes JAMAIS de prix, de statistique, d'adresse ou d'annonce : tu utilises les outils et tu cites ce qu'ils renvoient (avec l'identifiant d'annonce « uid » pour que l'interface puisse l'afficher, la date de la donnée et le niveau de confiance quand il existe).14- Si un outil ne renvoie rien ou pas assez, dis-le clairement et propose une reformulation.15- Les estimations sont statistiques et indicatives : rappelle qu'elles ne remplacent ni une évaluation agréée (OEAQ) ni un conseil financier personnalisé. Ne parle jamais de « bonne affaire » ; parle d'écart entre estimation et prix demandé.16- Tu ne donnes pas de conseil juridique ou fiscal personnalisé ; tu peux expliquer des notions générales (mise de fonds, SCHL, droits de mutation, test de résistance).17- Formate les montants en dollars canadiens (ex. 589 900 $), les loyers par mois. Réponses courtes, listes à puces si plusieurs éléments. Termine par une suggestion d'action concrète sur Toveli quand c'est pertinent (ex. « Ouvrir la fiche », « Voir la carte »).18Données : annonces à vendre et à louer collectées sur des sites publics, mises à jour chaque nuit (dernière construction : ${meta("built_at") ?? "inconnue"}), rôle d'évaluation foncière (MAMH), transactions de ventes, indices de prix.`;1920const TOOLS: Anthropic.Tool[] = [21  { name: "search_listings", description: "Recherche d'annonces à vendre (market=sale) ou à louer (market=rent) avec filtres. Renvoie jusqu'à 10 annonces (uid, prix, adresse, ville, chambres, superficie, estimation, écart).", input_schema: { type: "object", properties: { market: { type: "string", enum: ["sale", "rent"] }, city: { type: "string" }, region: { type: "string" }, q: { type: "string", description: "texte libre : rue, quartier" }, category: { type: "array", items: { type: "string", enum: ["maison", "condo", "plex", "terrain", "chalet", "commercial", "logement", "chambre"] } }, priceMin: { type: "number" }, priceMax: { type: "number" }, bedroomsMin: { type: "number" }, bathroomsMin: { type: "number" }, areaMin: { type: "number" }, sort: { type: "string", enum: ["recent", "price_asc", "price_desc", "deal", "area_desc"] } }, required: ["market"] } },22  { name: "get_listing", description: "Détail complet d'une annonce par uid : description, caractéristiques, estimation, historique de prix, source.", input_schema: { type: "object", properties: { uid: { type: "string" } }, required: ["uid"] } },23  { name: "market_stats", description: "Statistiques de marché (médiane, quartiles, nb d'annonces, jours sur le marché, écart estimation/prix) pour une ville, une région ou tout le Québec, achat ou location.", input_schema: { type: "object", properties: { market: { type: "string", enum: ["sale", "rent"] }, scope: { type: "string", enum: ["province", "region", "city"] }, value: { type: "string", description: "nom de la ville/région ; 'Québec' pour la province" } }, required: ["market", "scope", "value"] } },24  { name: "top_places", description: "Classement des régions ou municipalités par prix médian et volume.", input_schema: { type: "object", properties: { market: { type: "string", enum: ["sale", "rent"] }, kind: { type: "string", enum: ["region", "city"] }, category: { type: "string" } }, required: ["market", "kind"] } },25  { name: "estimate_address", description: "Estime la valeur d'une propriété à partir d'une adresse (recherche dans le rôle d'évaluation puis estimation hybride avec fourchette, confiance, comparables).", input_schema: { type: "object", properties: { address: { type: "string" } }, required: ["address"] } },26  { name: "mortgage", description: "Calcul hypothécaire illustratif : versement, prime d'assurance, intérêts totaux, test de résistance.", input_schema: { type: "object", properties: { price: { type: "number" }, downPayment: { type: "number" }, rateAnnualPct: { type: "number" }, amortYears: { type: "number" } }, required: ["price", "downPayment", "rateAnnualPct"] } },27];2829function compact(c: ReturnType<typeof searchListings>["items"][number]) {30  return { uid: c.uid, price: c.price, address: c.address, city: c.city, region: c.region, category: c.category, type: c.typeLabel, bedrooms: c.bedrooms, bathrooms: c.bathrooms, area_sqft: c.areaSqft, estimate: c.estimate, estimate_vs_price_pct: c.ratio ? Math.round((c.ratio - 1) * 1000) / 10 : null, confidence: c.confidence, first_seen: c.firstSeen, source: c.connector };31}3233export function runTool(name: string, input: Record<string, unknown>): unknown {34  switch (name) {35    case "search_listings": { const r = searchListings({ ...(input as object), limit: 10 } as Parameters<typeof searchListings>[0]); return { total: r.total, items: r.items.map(compact) }; }36    case "get_listing": { const l = getListing(String(input.uid)); if (!l) return { error: "Annonce introuvable" }; return { uid: l.uid, market: l.market, price: l.price, address: l.address, city: l.city, region: l.region, category: l.category, type: l.type_label, bedrooms: l.bedrooms, bathrooms: l.bathrooms, area_sqft: l.area_sqft, lot_sqft: l.lot_sqft, year_built: l.year_built, description: (l.description ?? "").slice(0, 1200), features: JSON.parse(l.features ?? "[]").slice(0, 25), valuation: l.valuation ? { estimate: l.valuation.estimate, low: l.valuation.low, high: l.valuation.high, confidence: l.valuation.confidence, confidence_pct: l.valuation.confidence_pct, estimate_vs_price_pct: l.valuation.ratio ? Math.round((l.valuation.ratio - 1) * 1000) / 10 : null, evaluated_at: l.valuation.evaluated_at, model: l.valuation.model } : null, price_history: l.priceEvents.slice(-8), first_seen: l.first_seen, last_seen: l.last_seen, source: l.connector, url: l.url }; }37    case "market_stats": return { metrics: marketMetrics(String(input.scope), String(input.value), input.market as "sale" | "rent").map((m) => ({ category: m.category, n: m.n, median_price: m.median_price, p25: m.p25_price, p75: m.p75_price, median_ppsf: m.median_ppsf, median_days: m.median_days, new_30d: m.new_30d, median_estimate_vs_price_pct: m.median_ratio ? Math.round((m.median_ratio - 1) * 1000) / 10 : null, computed_at: m.computed_at })) };38    case "top_places": { const cat = (input.category as string) || "all"; const rows = input.kind === "region" ? regionMetrics(input.market as "sale" | "rent", cat) : cityMetrics(input.market as "sale" | "rent", cat, 20); return { places: rows.map((m) => ({ name: m.scope_value, n: m.n, median_price: m.median_price, median_days: m.median_days })) }; }39    case "estimate_address": { const units = searchRoleUnits(String(input.address), 5); if (!units.length) return { error: "Aucune unité du rôle ne correspond à cette adresse" }; const e = estimateRoleUnit(units[0].id); if (!e) return { error: "Unité trouvée mais estimation impossible (coordonnées manquantes)", candidates: units }; return { unit: { id: units[0].id, address: units[0].address, city: units[0].city, type: units[0].type_prop, year_built: units[0].year_built, floor_area_m2: units[0].floor_area_m2, role_value: units[0].role_value }, estimate: e.result.estimate, low: e.result.low, high: e.result.high, confidence: e.result.confidenceLevel, confidence_pct: e.result.confidencePct, n_comps: e.result.nCompsUsed, method: e.method.name, date: e.method.date, limits: e.method.limits, other_candidates: units.slice(1).map((u) => ({ id: u.id, address: u.address, city: u.city })), page: `/estimer/${encodeURIComponent(units[0].id)}` }; }40    case "mortgage": { const r = computeMortgage({ price: Number(input.price), downPayment: Number(input.downPayment), rateAnnualPct: Number(input.rateAnnualPct), amortYears: Number(input.amortYears ?? 25) }); return { payment_monthly: Math.round(r.payment), loan: Math.round(r.loan), cmhc_premium: Math.round(r.cmhcPremium), total_interest: Math.round(r.totalInterest), stress_test_payment: Math.round(r.stressTest.payment), qualifying_rate_pct: r.stressTest.qualifyingRatePct, min_down_payment: Math.round(r.minDownPayment), warnings: r.warnings, note: "Calcul illustratif, hypothèses de l'utilisateur, pas une offre." }; }41    default: return { error: `outil inconnu ${name}` };42  }43}4445export type ChatMessage = { role: "user" | "assistant"; content: string };46export interface AssistantReply { text: string; citations: string[]; toolCalls: { name: string; input: unknown }[]; model: string }4748export async function askAssistant(messages: ChatMessage[], context?: { uid?: string }): Promise<AssistantReply> {49  const key = process.env.ANTHROPIC_API_KEY;50  if (!key) throw new Error("ANTHROPIC_API_KEY manquante côté serveur");51  const client = new Anthropic({ apiKey: key });52  const sys = SYSTEM + (context?.uid ? `\nContexte : l'utilisateur consulte l'annonce uid=${context.uid} ; utilise get_listing au besoin.` : "");53  const convo: Anthropic.MessageParam[] = messages.slice(-12).map((m) => ({ role: m.role, content: m.content }));54  const toolCalls: { name: string; input: unknown }[] = []; const citations = new Set<string>();55  for (let round = 0; round < 5; round++) {56    const res = await client.messages.create({ model: ASSISTANT_MODEL, max_tokens: 1200, system: sys, tools: TOOLS, messages: convo });57    const uses = res.content.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use");58    if (res.stop_reason !== "tool_use" || !uses.length) {59      const text = res.content.filter((b): b is Anthropic.TextBlock => b.type === "text").map((b) => b.text).join("\n").trim();60      for (const m of text.matchAll(/\b((?:immo-ka|lou-ka):[a-z0-9_]+:[A-Za-z0-9_-]+)/g)) citations.add(m[1]);61      return { text, citations: [...citations], toolCalls, model: ASSISTANT_MODEL };62    }63    convo.push({ role: "assistant", content: res.content });64    const results: Anthropic.ToolResultBlockParam[] = uses.map((u) => {65      toolCalls.push({ name: u.name, input: u.input });66      let out: unknown; try { out = runTool(u.name, u.input as Record<string, unknown>); } catch (e) { out = { error: (e as Error).message }; }67      const s = JSON.stringify(out); for (const m of s.matchAll(/"uid":"((?:immo-ka|lou-ka):[^"]+)"/g)) citations.add(m[1]);68      return { type: "tool_result", tool_use_id: u.id, content: s.slice(0, 20000) };69    });70    convo.push({ role: "user", content: results });71  }72  return { text: "Je n'ai pas réussi à conclure ; pouvez-vous préciser votre demande ?", citations: [...citations], toolCalls, model: ASSISTANT_MODEL };73}74