Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Contexte d'analyse d'une annonce : faits de l'annonce + rôle MAMH jumelé,4 * texte déterministe transmis au modèle, empreinte d'entrée (§132, 169).5 * Module PUR sauf `loadListingContext` (lecture des bases).6 */7import { createHash } from "crypto";8import { getUnit } from "../../db";9import { getListing, type ListingDetail } from "../../immoka";10import { sqftFromM2 } from "../units";11import type { BuildingType, CostInput } from "../types";12import { listingFactsFromDetails, type ListingFacts, type MamhFacts } from "./merge";13import type { AnalysisListing } from "./schema";1415export interface ListingContext {16 uid: string;17 detail: ListingDetail;18 facts: ListingFacts;19 mamh: MamhFacts | null;20 roll: CostInput["roll"];21 analysisListing: AnalysisListing;22 imageUrls: string[];23 text: string; // contexte textuel déterministe pour le modèle24 baseHashInput: string; // partie textuelle de l'empreinte (les hashes d'images s'ajoutent)25}2627function mamhType(u: { type_prop: string; lien_physique: string | null; nb_logements: number | null; aire_etages_m2: number | null }): BuildingType | null {28 switch (u.type_prop) {29 case "plex": return "plex";30 case "chalet": return "chalet";31 case "maison_mobile": return "mobile";32 case "condo_ou_multi": return (u.nb_logements ?? 1) > 1 && (u.aire_etages_m2 ?? 0) > 400 ? "plex" : "condo";33 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"; }34 default: return null;35 }36}3738export function loadListingContext(uid: string): ListingContext | null {39 const detail = getListing(uid);40 if (!detail) return null;41 const l = detail.listing;42 const facts = listingFactsFromDetails(l, detail.details);43 let mamh: MamhFacts | null = null;44 let roll: CostInput["roll"] = null;45 if (detail.eval?.unit_id) {46 const u = getUnit(detail.eval.unit_id);47 if (u) {48 const g = (u.genre_construction ?? "").toLowerCase();49 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;50 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 };51 roll = { landValue: u.valeur_terrain, buildingValue: u.valeur_batiment, totalValue: u.valeur_role, year: 2026 };52 }53 }54 const desc = (l.description ?? "").replace(/\s+/g, " ").trim();55 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");56 const features = detail.features.join("; ");57 const text = [58 "### Structured listing fields (source: listing)",59 `address: ${l.address ?? "—"} | municipality: ${l.city ?? "—"} | sector: ${l.sector ?? "—"} | region: ${l.region ?? "—"}`,60 `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"}`,61 `bedrooms: ${l.bedrooms ?? "unknown"} | bathrooms: ${l.bathrooms ?? "unknown"} | powder_rooms: ${l.powder_rooms ?? "unknown"}`,62 detailsTxt ? `### Listing details (source: listing)\n${detailsTxt}` : "",63 features ? `### Listing features (source: listing)\n${features}` : "",64 desc ? `### Broker description (source: description)\n${desc.slice(0, 6000)}` : "",65 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",66 ].filter(Boolean).join("\n\n");67 const analysisListing: AnalysisListing = {68 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,69 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,70 };71 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 });72 return { uid, detail, facts, mamh, roll, analysisListing, imageUrls: detail.images, text, baseHashInput };73}7475/** Empreinte d'entrée : URLs + hashes d'images + description + champs structurés + updated_at. */76export function inputHash(base: string, imageHashes: string[]): string {77 return createHash("sha256").update(base).update("|").update([...imageHashes].sort().join(",")).digest("hex");78}79