// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Contexte d'analyse d'une annonce : faits de l'annonce + rôle MAMH jumelé, * texte déterministe transmis au modèle, empreinte d'entrée (§132, 169). * Module PUR sauf `loadListingContext` (lecture des bases). */ import { createHash } from "crypto"; import { getUnit } from "../../db"; import { getListing, type ListingDetail } from "../../immoka"; import { sqftFromM2 } from "../units"; import type { BuildingType, CostInput } from "../types"; import { listingFactsFromDetails, type ListingFacts, type MamhFacts } from "./merge"; import type { AnalysisListing } from "./schema"; export interface ListingContext { uid: string; detail: ListingDetail; facts: ListingFacts; mamh: MamhFacts | null; roll: CostInput["roll"]; analysisListing: AnalysisListing; imageUrls: string[]; text: string; // contexte textuel déterministe pour le modèle baseHashInput: string; // partie textuelle de l'empreinte (les hashes d'images s'ajoutent) } function mamhType(u: { type_prop: string; lien_physique: string | null; nb_logements: number | null; aire_etages_m2: number | null }): BuildingType | null { switch (u.type_prop) { case "plex": return "plex"; case "chalet": return "chalet"; case "maison_mobile": return "mobile"; case "condo_ou_multi": return (u.nb_logements ?? 1) > 1 && (u.aire_etages_m2 ?? 0) > 400 ? "plex" : "condo"; case "unifamilial": { const l = (u.lien_physique ?? "").toLowerCase(); return l.includes("jumel") ? "semi_detached" : l.includes("rang") || l.includes("intégré") || l.includes("integre") ? "row" : "detached"; } default: return null; } } export function loadListingContext(uid: string): ListingContext | null { const detail = getListing(uid); if (!detail) return null; const l = detail.listing; const facts = listingFactsFromDetails(l, detail.details); let mamh: MamhFacts | null = null; let roll: CostInput["roll"] = null; if (detail.eval?.unit_id) { const u = getUnit(detail.eval.unit_id); if (u) { const g = (u.genre_construction ?? "").toLowerCase(); const stories = u.nb_etages && u.nb_etages > 0 ? Math.max(1, Math.round(u.nb_etages)) : g ? (g.includes("plain-pied") || g.includes("unimodulaire") ? 1 : g.includes("étages entiers") ? 2 : g.includes("mansard") || g.includes("décal") ? 2 : 1) : null; mamh = { yearBuilt: u.annee_construction && u.annee_construction > 1600 ? u.annee_construction : null, grossFloorAreaSqft: u.aire_etages_m2 && u.aire_etages_m2 > 15 ? Math.round(sqftFromM2(u.aire_etages_m2)) : null, stories, buildingType: mamhType(u), units: u.nb_logements, landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, unitId: u.id_provinc }; roll = { landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, year: 2026 }; } } const desc = (l.description ?? "").replace(/\s+/g, " ").trim(); const detailsTxt = Object.entries(detail.details).filter(([k]) => !["img_audited", "transaction"].includes(k)).map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`).join("\n"); const features = detail.features.join("; "); const text = [ "### Structured listing fields (source: listing)", `address: ${l.address ?? "—"} | municipality: ${l.city ?? "—"} | sector: ${l.sector ?? "—"} | region: ${l.region ?? "—"}`, `property_type: ${l.property_type ?? "—"} | asking_price_cad: ${l.price} | year_built: ${l.year_built ?? "unknown"} | living_area_sqft: ${l.area_sqft ?? "unknown"} | lot_area_sqft: ${l.lot_sqft ?? "unknown"}`, `bedrooms: ${l.bedrooms ?? "unknown"} | bathrooms: ${l.bathrooms ?? "unknown"} | powder_rooms: ${l.powder_rooms ?? "unknown"}`, detailsTxt ? `### Listing details (source: listing)\n${detailsTxt}` : "", features ? `### Listing features (source: listing)\n${features}` : "", desc ? `### Broker description (source: description)\n${desc.slice(0, 6000)}` : "", mamh ? `### Assessment roll — MAMH (source: MAMH, authoritative)\nyear_built: ${mamh.yearBuilt ?? "unknown"} | gross_floor_area_sqft_excluding_basement: ${mamh.grossFloorAreaSqft ?? "unknown"} | stories: ${mamh.stories ?? "unknown"} | building_type: ${mamh.buildingType ?? "unknown"} | dwelling_units: ${mamh.units ?? "unknown"} | land_value_cad: ${mamh.landValue ?? "unknown"} | building_value_cad: ${mamh.buildingValue ?? "unknown"}` : "### Assessment roll — MAMH\nnot matched", ].filter(Boolean).join("\n\n"); const analysisListing: AnalysisListing = { address: l.address, municipality: l.city, region: l.region, asking_price: l.price, listing_date: l.first_seen ? new Date(l.first_seen * 1000).toISOString().slice(0, 10) : null, description_excerpt: desc ? desc.slice(0, 600) : null, bedrooms: l.bedrooms, bathrooms: l.bathrooms, powder_rooms: l.powder_rooms, lot_area_sqft: l.lot_sqft, living_area_sqft: l.area_sqft, year_built: l.year_built, property_type: l.property_type, url: l.url, unit_id: mamh?.unitId ?? null, }; const baseHashInput = JSON.stringify({ urls: detail.images, description: l.description, specs: { pt: l.property_type, yb: l.year_built, a: l.area_sqft, lot: l.lot_sqft, bd: l.bedrooms, ba: l.bathrooms, pr: l.powder_rooms, details: detail.details, features: detail.features }, updated: l.updated_at, mamh }); return { uid, detail, facts, mamh, roll, analysisListing, imageUrls: detail.images, text, baseHashInput }; } /** Empreinte d'entrée : URLs + hashes d'images + description + champs structurés + updated_at. */ export function inputHash(base: string, imageHashes: string[]): string { return createHash("sha256").update(base).update("|").update([...imageHashes].sort().join(",")).digest("hex"); }