SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
5.6 KB · 79 lines typescript
Raw Blame History
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