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 * 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