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%
29.8 KB · 255 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Fusion des sources (§150-153) : rôle MAMH > champs structurés de l'annonce >4 * description (lue par l'IA) > observation visuelle > géométrie dérivée >5 * hypothèse typique. Aucun écrasement automatique : un désaccord entre sources6 * devient un CONFLIT affiché ; la source la plus forte gagne, l'autre est7 * conservée dans `alternatives`. Module PUR.8 */9import type { AttributeSource, Basement, BuildingType, Condition, Foundation, GarageType, Heating, Quality, RoofGeometry, RoofType, SidingMix, WindowType } from "../types";10import type { ModelOutput, EvidenceSource } from "./schema";1112export interface Fact<T> { value: T; source: AttributeSource; confidence: number; evidence: string[]; alternatives: { source: AttributeSource; value: T; confidence: number }[] }13export interface Conflict { field: string; sourceA: AttributeSource; valueA: string; sourceB: AttributeSource; valueB: string; severity: "low" | "medium" | "high" }1415/** Faits structurés d'une annonce (colonnes + `details` JSON normalisé). */16export interface ListingFacts {17  yearBuilt: number | null; areaSqft: number | null; bedrooms: number | null; bathrooms: number | null; powderRooms: number | null; propertyType: string | null; lotSqft: number | null;18  buildingType: BuildingType | null; stories: number | null; foundation: Foundation | null; siding: SidingMix | null; roof: RoofType | null; roofGeometry: RoofGeometry | null; windows: WindowType | null;19  heating: Heating | null; hasAirConditioning: boolean | null; hasAirExchanger: boolean | null; basement: Basement | null; garage: { type: GarageType; spaces: number } | null; pool: "none" | "above_ground" | "inground" | null;20  units: number | null; waterSupply: "municipal" | "well" | null; wasteSystem: "municipal" | "septic" | null;21}2223export interface MamhFacts { yearBuilt: number | null; grossFloorAreaSqft: number | null; stories: number | null; buildingType: BuildingType | null; units: number | null; landValue: number | null; buildingValue: number | null; totalValue: number | null; unitId: string }2425export interface MergedFacts {26  buildingType: Fact<BuildingType>; yearBuilt: Fact<number | null>; stories: Fact<number>; grossFloorAreaSqft: Fact<number>; footprintSqft: Fact<number | null>;27  foundation: Fact<Foundation>; structure: Fact<"wood_frame" | "steel" | "concrete" | "log" | "masonry">; siding: Fact<SidingMix>; roof: Fact<RoofType>; roofGeometry: Fact<RoofGeometry>; roofPitch: Fact<number>; windows: Fact<WindowType>; windowCount: Fact<number | null>;28  heating: Fact<Heating>; hasAirConditioning: Fact<boolean>; hasAirExchanger: Fact<boolean>; basement: Fact<Basement>; basementFinishedPct: Fact<number>;29  garage: Fact<{ type: GarageType; spaces: number; areaSqft: number | null }>; kitchens: Fact<number>; kitchenQuality: Fact<Quality>; bathrooms: Fact<number>; powderRooms: Fact<number>; bathroomQuality: Fact<Quality>; bedrooms: Fact<number | null>;30  quality: Fact<Quality>; flooring: Fact<Record<string, number>>; deckSqft: Fact<number>; drivewaySqft: Fact<number>; driveway: Fact<"asphalt" | "pavers" | "gravel" | "concrete" | "none">; fenceLinFt: Fact<number>; pool: Fact<"none" | "above_ground" | "inground">; units: Fact<number>;31  kitchenLinearFt: Fact<number | null>; countertopSqft: Fact<number | null>; backsplashSqft: Fact<number | null>; bathroomTileSqft: Fact<number | null>; interiorDoorCount: Fact<number | null>;32  conditions: Partial<Record<string, Condition>>; effectiveAge: { chronological: number | null; effective: number | null; economicLife: number | null; confidence: number; reasoning: string[] } | null;33  conflicts: Conflict[];34}3536const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();3738/* ------------------------------------------- normalisation des détails */3940/** Convertit le JSON `details` d'une annonce (clés françaises libres) en faits typés. */41export function listingFactsFromDetails(listing: { year_built: number | null; area_sqft: number | null; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null; property_type: string | null; lot_sqft: number | null }, details: Record<string, unknown>): ListingFacts {42  const entries = Object.entries(details).map(([k, v]) => [fold(k), typeof v === "string" ? fold(v) : String(v ?? "")] as [string, string]);43  const get = (...keys: string[]): string | null => { const e = entries.find(([k]) => keys.some((kk) => k.includes(kk))); return e && e[1] && e[1] !== "inconnue" && e[1] !== "inconnu" ? e[1] : null; };44  const num = (s: string | null): number | null => { if (!s) return null; const m = s.replace(/\s/g, "").match(/(\d+(?:[.,]\d+)?)/); return m ? Number(m[1].replace(",", ".")) : null; };45  const pt = fold(listing.property_type ?? "");46  const typeTxt = `${pt} ${get("type de batiment", "type de propriete", "genre de propriete") ?? ""}`;47  let buildingType: BuildingType | null = null;48  if (/plex|duplex|triplex|quadruplex|quintuplex|multifamil|revenu/.test(typeTxt)) buildingType = "plex";49  else if (/condo|copropri|appartement|loft/.test(typeTxt)) buildingType = "condo";50  else if (/chalet|villegiature|cottage/.test(typeTxt)) buildingType = "chalet";51  else if (/mobile|prefabriqu|usinee/.test(typeTxt)) buildingType = "mobile";52  else if (/jumel|semi-detach|semi detach/.test(typeTxt)) buildingType = "semi_detached";53  else if (/rangee|en rangee|townhouse|maison de ville/.test(typeTxt)) buildingType = "row";54  else if (/detach|isole|maison|bungalow|plain-pied|cottage|paliers|unifamil/.test(typeTxt)) buildingType = "detached";55  const storiesTxt = get("nombre d'etages", "nombre d etages", "etages") ?? (/plain-pied|bungalow/.test(typeTxt) ? "1" : /2 etages|cottage|a etages/.test(typeTxt) ? "2" : null);56  const yr = listing.year_built && listing.year_built > 1600 ? listing.year_built : num(get("annee de construction")) ;57  const fTxt = get("fondation");58  const foundation: Foundation | null = !fTxt ? null : /coul|beton/.test(fTxt) && !/bloc/.test(fTxt) ? "poured_concrete" : /bloc/.test(fTxt) ? "concrete_block" : /dalle/.test(fTxt) ? "slab_on_grade" : /pieu|pilot/.test(fTxt) ? "piers" : /pierre/.test(fTxt) ? "stone" : null;59  const sTxt = get("revetement", "revetement exterieur", "parement");60  let siding: SidingMix | null = null;61  if (sTxt) {62    const parts: (keyof SidingMix)[] = [];63    if (/brique/.test(sTxt)) parts.push("brick"); if (/vinyle/.test(sTxt)) parts.push("vinyl"); if (/canexel|fibro|fibre de ciment|fibrociment/.test(sTxt)) parts.push("fiber_cement"); if (/bois|cedre/.test(sTxt)) parts.push("wood");64    if (/pierre/.test(sTxt)) parts.push("stone"); if (/stuc|crepi|acrylique/.test(sTxt)) parts.push("stucco"); if (/aluminium/.test(sTxt)) parts.push("aluminum"); if (/acier|tole/.test(sTxt)) parts.push("steel");65    if (parts.length) { siding = {}; parts.forEach((p, i) => { siding![p] = i === 0 ? (parts.length === 1 ? 1 : 0.6) : 0.4 / (parts.length - 1); }); }66  }67  const rTxt = get("toiture", "toit", "couverture");68  const roof: RoofType | null = !rTxt ? null : /bardeau|asphalte/.test(rTxt) ? "asphalt_shingle" : /tole|metal|acier/.test(rTxt) ? "metal" : /membrane|elastom|epdm|tpo|goudron|gravier|asphalte et gravier/.test(rTxt) ? "membrane" : /cedre/.test(rTxt) ? "cedar" : /ardoise|tuile/.test(rTxt) ? "slate_tile" : null;69  const roofGeometry: RoofGeometry | null = !rTxt ? null : /plat/.test(rTxt) ? "flat" : /mansard/.test(rTxt) ? "mansard" : /quatre versants|4 versants|croupe/.test(rTxt) ? "hip" : /pignon|deux versants|2 versants/.test(rTxt) ? "gable" : null;70  const wTxt = get("fenestration", "fenetre");71  const windows: WindowType | null = !wTxt ? null : /hybride/.test(wTxt) ? "hybrid" : /pvc/.test(wTxt) ? "pvc" : /aluminium/.test(wTxt) ? "aluminum" : /bois/.test(wTxt) ? "wood" : null;72  const hTxt = `${get("chauffage", "systeme de chauffage", "mode de chauffage") ?? ""} ${get("energie pour le chauffage", "energie") ?? ""}`.trim();73  let heating: Heating | null = null;74  if (hTxt) {75    if (/thermopompe|pompe a chaleur/.test(hTxt) && !/mural/.test(hTxt)) heating = "heat_pump";76    else if (/geotherm/.test(hTxt)) heating = "geothermal";77    else if (/plinthe|convecteur|electrique/.test(hTxt) && !/air soufflee|air chaud|fournaise/.test(hTxt)) heating = "electric_baseboard";78    else if (/air soufflee|air chaud|fournaise/.test(hTxt)) heating = /gaz/.test(hTxt) ? "furnace_gas" : /mazout|huile/.test(hTxt) ? "furnace_oil" : "furnace_electric";79    else if (/eau chaude|radiateur|radiant|hydron/.test(hTxt)) heating = "hydronic";80    else if (/bois|granule/.test(hTxt)) heating = "wood";81  }82  const eqTxt = `${get("equipement", "equipement disponible", "climatisation", "systeme de ventilation") ?? ""} ${entries.map(([, v]) => v).join(" ")}`;83  const hasAirConditioning = /climatis|thermopompe/.test(eqTxt) ? true : null;84  const hasAirExchanger = /echangeur d'air|echangeur d air|vrc|ventilateur recuperateur/.test(eqTxt) ? true : null;85  const bTxt = get("sous-sol", "sous sol");86  const basement: Basement | null = !bTxt ? null : /aucun|sans sous-sol|vide sanitaire/.test(bTxt) ? (/vide/.test(bTxt) ? "crawl" : "none") : /totalement amenage|entierement amenage|fini|amenage/.test(bTxt) && !/partiellement|non/.test(bTxt) ? "finished" : /partiellement/.test(bTxt) ? "partial" : /non amenage|brut/.test(bTxt) ? "unfinished" : /rez-de-jardin|walk/.test(bTxt) ? "walkout" : /6 pieds|pieds et plus/.test(bTxt) ? "unfinished" : null;87  const gTxt = get("garage", "stationnement");88  let garage: { type: GarageType; spaces: number } | null = null;89  if (gTxt) {90    if (/garage/.test(gTxt) || entries.some(([k]) => k === "garage")) {91      const type: GarageType = /detach/.test(gTxt) ? "detached" : /integre|sous|au sous-sol/.test(gTxt) ? "integrated" : /attach|attenant/.test(gTxt) ? "attached" : /abri/.test(gTxt) ? "carport" : "attached";92      const m = gTxt.match(/garage\s*\((\d+)\)/); const spaces = m ? Number(m[1]) : /double/.test(gTxt) ? 2 : /triple/.test(gTxt) ? 3 : 1;93      garage = { type, spaces };94    } else if (/allee|exterieur/.test(gTxt)) garage = { type: "none", spaces: 0 };95  }96  const pTxt = get("piscine");97  const pool = !pTxt ? null : /creus/.test(pTxt) ? "inground" : /hors|hors-terre|hors terre/.test(pTxt) ? "above_ground" : /aucun|non/.test(pTxt) ? "none" : null;98  const units = num(get("nombre d'unites", "nombre de logements", "logements")) ?? (buildingType === "plex" ? (/duplex/.test(typeTxt) ? 2 : /triplex/.test(typeTxt) ? 3 : /quadruplex/.test(typeTxt) ? 4 : /quintuplex/.test(typeTxt) ? 5 : null) : null);99  const waterTxt = get("approvisionnement en eau", "eau");100  const wasteTxt = get("systeme d'egout", "systeme d egout", "egout");101  return {102    yearBuilt: yr, areaSqft: listing.area_sqft && listing.area_sqft > 50 ? listing.area_sqft : num(get("superficie habitable", "superficie du batiment", "superficie batiment")), bedrooms: listing.bedrooms, bathrooms: listing.bathrooms, powderRooms: listing.powder_rooms, propertyType: listing.property_type, lotSqft: listing.lot_sqft,103    buildingType, stories: storiesTxt ? num(storiesTxt) : null, foundation, siding, roof, roofGeometry, windows, heating, hasAirConditioning, hasAirExchanger, basement, garage, pool, units,104    waterSupply: !waterTxt ? null : /municip/.test(waterTxt) ? "municipal" : /puits/.test(waterTxt) ? "well" : null, wasteSystem: !wasteTxt ? null : /municip/.test(wasteTxt) ? "municipal" : /septi|fosse/.test(wasteTxt) ? "septic" : null,105  };106}107108/* ------------------------------------------------------------- fusion */109110const TIER: Record<AttributeSource, number> = { cadastral: 0, MAMH: 0, listing: 1, user: -1, AI: 3, derived: 4, assumed: 5 };111const aiSourceToAttr = (s: EvidenceSource): AttributeSource => (s === "MAMH" ? "MAMH" : s === "listing" ? "listing" : s === "derived" ? "derived" : s === "assumed" || s === "unknown" ? "assumed" : "AI");112113interface Cand<T> { value: T | null | undefined; source: AttributeSource; confidence: number; evidence?: string[] }114115function pick<T>(field: string, cands: Cand<T>[], fallback: T, conflicts: Conflict[], severity: Conflict["severity"] = "medium", eq: (a: T, b: T) => boolean = (a, b) => JSON.stringify(a) === JSON.stringify(b)): Fact<T> {116  const valid = cands.filter((c): c is Cand<T> & { value: T } => c.value != null && c.value !== ("" as unknown));117  if (!valid.length) return { value: fallback, source: "assumed", confidence: 0.3, evidence: [], alternatives: [] };118  // l'IA qui déclare une source « listing »/« MAMH » reste derrière les champs structurés réels119  valid.sort((a, b) => TIER[a.source] - TIER[b.source] || b.confidence - a.confidence);120  const best = valid[0];121  const alternatives = valid.slice(1).map((c) => ({ source: c.source, value: c.value, confidence: c.confidence }));122  for (const alt of valid.slice(1)) {123    if (!eq(best.value, alt.value) && alt.confidence >= 0.5) {124      conflicts.push({ field, sourceA: best.source, valueA: JSON.stringify(best.value), sourceB: alt.source, valueB: JSON.stringify(alt.value), severity });125    }126  }127  return { value: best.value, source: best.source, confidence: best.confidence, evidence: best.evidence ?? [], alternatives };128}129130const q = (v: string | null | undefined): Quality | null => (v === "custom_luxury" ? "prestige" : v === "economy" || v === "standard" || v === "superior" || v === "prestige" ? v : null);131const numClose = (tol: number) => (a: number, b: number) => Math.abs(a - b) <= tol * Math.max(Math.abs(a), Math.abs(b), 1);132133export function mergeFacts(ai: ModelOutput, listing: ListingFacts, mamh: MamhFacts | null): MergedFacts {134  const conflicts: Conflict[] = [];135  const A = <T,>(a: { value: T | null; confidence: number; source: EvidenceSource; evidence: string[] } | undefined): Cand<T> => ({ value: a?.value ?? null, source: aiSourceToAttr(a?.source ?? "unknown"), confidence: a?.confidence ?? 0, evidence: a?.evidence ?? [] });136  const L = <T,>(v: T | null | undefined, conf = 0.9): Cand<T> => ({ value: v, source: "listing", confidence: conf });137  const M = <T,>(v: T | null | undefined): Cand<T> => ({ value: v, source: "MAMH", confidence: 0.97 });138139  const buildingType = pick<BuildingType>("buildingType", [M(mamh?.buildingType), L(listing.buildingType), A(ai.property.building_type as { value: BuildingType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "detached", conflicts, "high");140  const yearBuilt = pick<number | null>("yearBuilt", [M(mamh?.yearBuilt), L(listing.yearBuilt), A(ai.property.year_built)], null, conflicts, "medium", (a, b) => a == null || b == null ? a === b : numClose(0.02)(a, b));141  const stories = pick<number>("stories", [M(mamh?.stories), L(listing.stories), A(ai.property.stories)], 1, conflicts, "high", (a, b) => Math.round(a) === Math.round(b));142  // MAMH : aire d'étages hors sous-sol (référence) ; annonce : superficie habitable (peut inclure le sous-sol)143  const grossFloorAreaSqft = pick<number>("grossFloorAreaSqft", [M(mamh?.grossFloorAreaSqft), L(listing.areaSqft, 0.8), A(ai.property.gross_floor_area_sqft)], 1500, conflicts, "high", numClose(0.25));144  const footprintSqft = pick<number | null>("footprintSqft", [A(ai.geometry.footprint_sqft)], null, conflicts, "low");145  const foundation = pick<Foundation>("foundation", [L(listing.foundation), A(ai.construction.foundation as Cand<Foundation>["value"] extends never ? never : { value: Foundation | null; confidence: number; source: EvidenceSource; evidence: string[] })], "poured_concrete", conflicts, "medium");146  const structure = pick<MergedFacts["structure"]["value"]>("structure", [A(ai.construction.structural_system as { value: MergedFacts["structure"]["value"] | null; confidence: number; source: EvidenceSource; evidence: string[] })], "wood_frame", conflicts, "low");147  const aiSiding = sidingFromAi(ai);148  const siding = pick<SidingMix>("siding", [L(listing.siding, 0.75), { value: aiSiding.mix, source: "AI", confidence: aiSiding.confidence, evidence: ai.exterior.primary_siding.evidence }], { vinyl: 1 }, conflicts, "medium", sameDominant);149  const roof = pick<RoofType>("roof", [L(listing.roof), A(ai.exterior.roof_covering as { value: RoofType | null; confidence: number; source: EvidenceSource; evidence: string[] }), A(ai.construction.roof_system_type as { value: RoofType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "asphalt_shingle", conflicts, "high");150  const roofGeometry = pick<RoofGeometry>("roofGeometry", [L(listing.roofGeometry), A(ai.construction.roof_geometry as { value: RoofGeometry | null; confidence: number; source: EvidenceSource; evidence: string[] })], "gable", conflicts, "low");151  const roofPitch = pick<number>("roofPitch", [A(ai.geometry.roof_pitch_rise_per_12)], roofGeometry.value === "flat" ? 0 : 6, conflicts, "low");152  const windows = pick<WindowType>("windows", [L(listing.windows), A(ai.exterior.windows_type as { value: WindowType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "pvc", conflicts, "low");153  const wc = ai.estimated_quantities.window_count.value ?? ai.exterior.window_count_estimate.value;154  const windowCount = pick<number | null>("windowCount", [{ value: wc, source: "AI", confidence: Math.max(ai.estimated_quantities.window_count.confidence, ai.exterior.window_count_estimate.confidence), evidence: ai.estimated_quantities.window_count.evidence }], null, conflicts, "low");155  const heating = pick<Heating>("heating", [L(listing.heating), { value: heatingFromAi(ai), source: aiSourceFromMech(ai.mechanical.heat_source.status), confidence: Math.min(ai.mechanical.heat_source.confidence, ai.mechanical.heat_distribution.confidence || 0.5), evidence: [...ai.mechanical.heat_source.evidence, ...ai.mechanical.heat_distribution.evidence] }], "electric_baseboard", conflicts, "medium");156  const hasAirConditioning = pick<boolean>("hasAirConditioning", [L(listing.hasAirConditioning), { value: ai.mechanical.air_conditioning.value == null || ai.mechanical.air_conditioning.value === "unknown" ? null : ai.mechanical.air_conditioning.value !== "none", source: aiSourceFromMech(ai.mechanical.air_conditioning.status), confidence: ai.mechanical.air_conditioning.confidence, evidence: ai.mechanical.air_conditioning.evidence }], heating.value === "heat_pump", conflicts, "low");157  const hasAirExchanger = pick<boolean>("hasAirExchanger", [L(listing.hasAirExchanger), { value: ai.mechanical.air_exchanger.value == null || ai.mechanical.air_exchanger.value === "unknown" ? null : ai.mechanical.air_exchanger.value === "present", source: aiSourceFromMech(ai.mechanical.air_exchanger.status), confidence: ai.mechanical.air_exchanger.confidence, evidence: ai.mechanical.air_exchanger.evidence }], (yearBuilt.value ?? 1990) >= 2000, conflicts, "low");158  const aiBasement = basementFromAi(ai.basement.state.value);159  const basement = pick<Basement>("basement", [L(listing.basement), { value: aiBasement, source: aiSourceToAttr(ai.basement.state.source), confidence: ai.basement.state.confidence, evidence: ai.basement.state.evidence }], buildingType.value === "condo" || buildingType.value === "mobile" ? "none" : "unfinished", conflicts, "medium");160  const pctAi = ai.estimated_quantities.finished_basement_pct.value ?? ai.basement.estimated_finished_pct.value;161  const basementFinishedPct = pick<number>("basementFinishedPct", [{ value: pctAi != null ? Math.min(1, pctAi > 1 ? pctAi / 100 : pctAi) : null, source: "AI", confidence: ai.basement.estimated_finished_pct.confidence, evidence: ai.basement.estimated_finished_pct.evidence }], basement.value === "finished" || basement.value === "walkout" ? 0.9 : basement.value === "partial" ? 0.5 : 0, conflicts, "low");162  const gAi = ai.garage.type.value && ai.garage.type.value !== "unknown" ? { type: ai.garage.type.value as GarageType, spaces: Math.max(ai.garage.type.value === "none" ? 0 : 1, Math.round(ai.garage.spaces.value ?? 1)), areaSqft: ai.estimated_quantities.garage_area_sqft.value ?? ai.garage.area_estimate_sqft.value } : null;163  const garage = pick<MergedFacts["garage"]["value"]>("garage", [L(listing.garage ? { ...listing.garage, areaSqft: gAi?.areaSqft ?? null } : null), { value: gAi, source: aiSourceToAttr(ai.garage.type.source), confidence: ai.garage.type.confidence, evidence: ai.garage.type.evidence }], { type: "none", spaces: 0, areaSqft: null }, conflicts, "medium", (a, b) => a.type === b.type);164  const units = pick<number>("units", [M(mamh?.units), L(listing.units), A(ai.property.dwelling_units)], buildingType.value === "plex" ? 2 : 1, conflicts, "medium");165  const kitchens = pick<number>("kitchens", [{ value: ai.kitchens.length || null, source: "AI", confidence: 0.7, evidence: ai.kitchens.flatMap((k) => k.evidence) }], Math.max(1, units.value), conflicts, "low");166  const bathrooms = pick<number>("bathrooms", [L(listing.bathrooms), A(ai.property.bathrooms), { value: ai.bathrooms.filter((b) => b.type !== "powder").length || null, source: "AI", confidence: 0.6 }], 1, conflicts, "low");167  const powderRooms = pick<number>("powderRooms", [L(listing.powderRooms), A(ai.property.powder_rooms), { value: ai.bathrooms.filter((b) => b.type === "powder").length || null, source: "AI", confidence: 0.6 }], 0, conflicts, "low");168  const bedrooms = pick<number | null>("bedrooms", [L(listing.bedrooms), A(ai.property.bedrooms)], null, conflicts, "low");169  const kq = q(ai.quality.kitchen_quality) ?? q(ai.kitchens[0]?.quality ?? null);170  const kitchenQuality = pick<Quality>("kitchenQuality", [{ value: kq, source: "AI", confidence: ai.quality.confidence, evidence: ai.kitchens.flatMap((k) => k.evidence) }], "standard", conflicts, "low");171  const bq = q(ai.quality.bathroom_quality) ?? q(ai.bathrooms[0]?.quality ?? null);172  const bathroomQuality = pick<Quality>("bathroomQuality", [{ value: bq, source: "AI", confidence: ai.quality.confidence, evidence: ai.bathrooms.flatMap((b) => b.evidence) }], "standard", conflicts, "low");173  const quality = pick<Quality>("quality", [{ value: q(ai.quality.overall_quality), source: "AI", confidence: ai.quality.confidence, evidence: [] }], "standard", conflicts, "low");174  const flooring = pick<Record<string, number>>("flooring", [{ value: flooringFromAi(ai), source: "AI", confidence: ai.confidence.interior, evidence: ai.interior.flooring_types.flatMap((f) => f.evidence) }], {}, conflicts, "low");175  const deckSqft = pick<number>("deckSqft", [{ value: ai.estimated_quantities.deck_sqft.value ?? ai.exterior_improvements.deck_sqft.value, source: "AI", confidence: ai.estimated_quantities.deck_sqft.confidence, evidence: ai.estimated_quantities.deck_sqft.evidence }], 0, conflicts, "low");176  const drivewayType = pick<MergedFacts["driveway"]["value"]>("driveway", [{ value: ai.exterior_improvements.driveway_type.value && ai.exterior_improvements.driveway_type.value !== "unknown" ? (ai.exterior_improvements.driveway_type.value as MergedFacts["driveway"]["value"]) : null, source: aiSourceToAttr(ai.exterior_improvements.driveway_type.source), confidence: ai.exterior_improvements.driveway_type.confidence, evidence: ai.exterior_improvements.driveway_type.evidence }], buildingType.value === "condo" ? "none" : "asphalt", conflicts, "low");177  const drivewaySqft = pick<number>("drivewaySqft", [{ value: ai.estimated_quantities.driveway_sqft.value ?? ai.exterior_improvements.driveway_sqft.value, source: "AI", confidence: ai.estimated_quantities.driveway_sqft.confidence, evidence: ai.estimated_quantities.driveway_sqft.evidence }], drivewayType.value === "none" ? 0 : 500, conflicts, "low");178  const fenceLinFt = pick<number>("fenceLinFt", [{ value: ai.estimated_quantities.fence_linear_ft.value ?? ai.exterior_improvements.fence_linear_ft.value, source: "AI", confidence: ai.estimated_quantities.fence_linear_ft.confidence, evidence: ai.estimated_quantities.fence_linear_ft.evidence }], 0, conflicts, "low");179  const pool = pick<MergedFacts["pool"]["value"]>("pool", [L(listing.pool), { value: ai.exterior_improvements.pool.value && ai.exterior_improvements.pool.value !== "unknown" ? (ai.exterior_improvements.pool.value as MergedFacts["pool"]["value"]) : null, source: aiSourceToAttr(ai.exterior_improvements.pool.source), confidence: ai.exterior_improvements.pool.confidence, evidence: ai.exterior_improvements.pool.evidence }], "none", conflicts, "low");180  const aiQty = (qv: { value: number | null; confidence: number; evidence: string[] }) => pick<number | null>("qty", [{ value: qv.value, source: "AI", confidence: qv.confidence, evidence: qv.evidence }], null, [], "low");181  const conditions: Partial<Record<string, Condition>> = {};182  const cmap: Record<string, keyof ModelOutput["condition"]> = { roof: "roof", windows: "windows", exterior: "exterior", interior: "interior", kitchen: "kitchen", bathroom: "bathrooms", mechanical: "mechanical", electrical: "electrical", structure: "structure", site: "site" };183  for (const [g, k] of Object.entries(cmap)) { const v = ai.condition[k]; if (v && typeof v === "string") conditions[g] = v as Condition; }184  const ea = ai.estimated_effective_age;185  return {186    buildingType, yearBuilt, stories, grossFloorAreaSqft, footprintSqft, foundation, structure, siding, roof, roofGeometry, roofPitch, windows, windowCount, heating, hasAirConditioning, hasAirExchanger, basement, basementFinishedPct, garage,187    kitchens, kitchenQuality, bathrooms, powderRooms, bathroomQuality, bedrooms, quality, flooring, deckSqft, drivewaySqft, driveway: drivewayType, fenceLinFt, pool, units,188    kitchenLinearFt: aiQty(ai.estimated_quantities.kitchen_cabinets_linear_ft), countertopSqft: aiQty(ai.estimated_quantities.countertop_sqft), backsplashSqft: aiQty(ai.estimated_quantities.backsplash_sqft), bathroomTileSqft: aiQty(ai.estimated_quantities.bathroom_tile_sqft), interiorDoorCount: aiQty(ai.estimated_quantities.interior_door_count),189    conditions, effectiveAge: ea.effective_age != null || ea.chronological_age != null ? { chronological: ea.chronological_age, effective: ea.effective_age, economicLife: ea.economic_life, confidence: ea.confidence, reasoning: ea.reasoning_summary } : null,190    conflicts,191  };192}193194/* ------------------------------------------------------------ helpers IA */195196function sidingFromAi(ai: ModelOutput): { mix: SidingMix | null; confidence: number } {197  const s = ai.exterior.siding_shares;198  const raw: Record<keyof SidingMix, number> = { brick: s.brick_pct, vinyl: s.vinyl_pct, fiber_cement: s.fiber_cement_pct, wood: s.wood_pct, stone: s.stone_pct, stucco: s.stucco_pct, steel: s.metal_pct, aluminum: 0 };199  const total = Object.values(raw).reduce((a, b) => a + (b > 0 ? b : 0), 0);200  if (total > 0) {201    const mix: SidingMix = {};202    for (const [k, v] of Object.entries(raw) as [keyof SidingMix, number][]) if (v > 0) mix[k] = v / total;203    return { mix, confidence: Math.max(ai.exterior.primary_siding.confidence, 0.5) };204  }205  const p = ai.exterior.primary_siding.value;206  if (p && p !== "unknown") { const mix: SidingMix = {}; mix[p as keyof SidingMix] = 1; return { mix, confidence: ai.exterior.primary_siding.confidence }; }207  return { mix: null, confidence: 0 };208}209const sameDominant = (a: SidingMix, b: SidingMix) => { const d = (m: SidingMix) => Object.entries(m).sort((x, y) => (y[1] ?? 0) - (x[1] ?? 0))[0]?.[0]; return d(a) === d(b); };210211function heatingFromAi(ai: ModelOutput): Heating | null {212  const src = ai.mechanical.heat_source.value, dist = ai.mechanical.heat_distribution.value, hp = ai.mechanical.heat_pump.value;213  if (hp === "central") return "heat_pump";214  if (src === "geothermal") return "geothermal";215  if (src === "wood") return "wood";216  if (dist === "forced_air") return src === "natural_gas" || src === "propane" ? "furnace_gas" : src === "oil" ? "furnace_oil" : "furnace_electric";217  if (dist === "radiant_floor" || dist === "hydronic_radiators") return "hydronic";218  if (dist === "baseboards" || dist === "convectors" || dist === "heat_pump_wall") return "electric_baseboard";219  if (src === "natural_gas" || src === "propane") return "furnace_gas";220  if (src === "oil") return "furnace_oil";221  if (src === "electricity") return "electric_baseboard";222  return null;223}224const aiSourceFromMech = (s: string): AttributeSource => (s === "listing" ? "listing" : s === "observed" ? "AI" : s === "inferred" ? "AI" : "assumed");225function basementFromAi(v: string | null): Basement | null {226  switch (v) { case "none": return "none"; case "crawl": return "crawl"; case "unfinished": return "unfinished"; case "partially_finished": return "partial"; case "finished": return "finished"; case "walkout": return "walkout"; default: return null; }227}228function flooringFromAi(ai: ModelOutput): Record<string, number> | null {229  const map: Record<string, string> = { hardwood: "hardwood", engineered_wood: "engineered", vinyl_plank: "vinyl_plank", laminate_floor: "laminate", ceramic_tile: "ceramic", porcelain_tile: "ceramic", carpet: "carpet" };230  const out: Record<string, number> = {};231  for (const f of ai.interior.flooring_types) { const k = map[f.material]; if (k && f.share_pct > 0) out[k] = (out[k] ?? 0) + f.share_pct; }232  const tot = Object.values(out).reduce((a, b) => a + b, 0);233  if (!tot) return null;234  for (const k of Object.keys(out)) out[k] = out[k] / tot;235  return out;236}237238/** Applique des corrections manuelles (field_path → valeur) sur les faits fusionnés ; source = user. */239export function applyOverrides(m: MergedFacts, overrides: { fieldPath: string; value: unknown }[]): MergedFacts {240  const out: MergedFacts = JSON.parse(JSON.stringify(m));241  for (const o of overrides) {242    const [root, sub] = o.fieldPath.split(".");243    if (root === "conditions" && sub) { out.conditions[sub] = o.value as Condition; continue; }244    if (root === "effectiveAge") { out.effectiveAge = { ...(out.effectiveAge ?? { chronological: null, effective: null, economicLife: null, confidence: 1, reasoning: [] }), effective: Number(o.value), confidence: 1, reasoning: ["corrigé par l'utilisateur"] }; continue; }245    const f = (out as unknown as Record<string, Fact<unknown> | undefined>)[root];246    if (!f || typeof f !== "object" || !("value" in f)) continue;247    const original = JSON.parse(JSON.stringify(f.value ?? null)) as unknown;248    if (sub && f.value && typeof f.value === "object") (f.value as Record<string, unknown>)[sub] = o.value;249    else f.value = o.value;250    f.alternatives = [{ source: f.source, value: original, confidence: f.confidence }, ...f.alternatives];251    f.source = "user"; f.confidence = 1; f.evidence = ["user_override"];252  }253  return out;254}255