// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Fusion des sources (§150-153) : rôle MAMH > champs structurés de l'annonce > * description (lue par l'IA) > observation visuelle > géométrie dérivée > * hypothèse typique. Aucun écrasement automatique : un désaccord entre sources * devient un CONFLIT affiché ; la source la plus forte gagne, l'autre est * conservée dans `alternatives`. Module PUR. */ import type { AttributeSource, Basement, BuildingType, Condition, Foundation, GarageType, Heating, Quality, RoofGeometry, RoofType, SidingMix, WindowType } from "../types"; import type { ModelOutput, EvidenceSource } from "./schema"; export interface Fact { value: T; source: AttributeSource; confidence: number; evidence: string[]; alternatives: { source: AttributeSource; value: T; confidence: number }[] } export interface Conflict { field: string; sourceA: AttributeSource; valueA: string; sourceB: AttributeSource; valueB: string; severity: "low" | "medium" | "high" } /** Faits structurés d'une annonce (colonnes + `details` JSON normalisé). */ export interface ListingFacts { yearBuilt: number | null; areaSqft: number | null; bedrooms: number | null; bathrooms: number | null; powderRooms: number | null; propertyType: string | null; lotSqft: number | null; buildingType: BuildingType | null; stories: number | null; foundation: Foundation | null; siding: SidingMix | null; roof: RoofType | null; roofGeometry: RoofGeometry | null; windows: WindowType | null; heating: Heating | null; hasAirConditioning: boolean | null; hasAirExchanger: boolean | null; basement: Basement | null; garage: { type: GarageType; spaces: number } | null; pool: "none" | "above_ground" | "inground" | null; units: number | null; waterSupply: "municipal" | "well" | null; wasteSystem: "municipal" | "septic" | null; } export 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 } export interface MergedFacts { buildingType: Fact; yearBuilt: Fact; stories: Fact; grossFloorAreaSqft: Fact; footprintSqft: Fact; foundation: Fact; structure: Fact<"wood_frame" | "steel" | "concrete" | "log" | "masonry">; siding: Fact; roof: Fact; roofGeometry: Fact; roofPitch: Fact; windows: Fact; windowCount: Fact; heating: Fact; hasAirConditioning: Fact; hasAirExchanger: Fact; basement: Fact; basementFinishedPct: Fact; garage: Fact<{ type: GarageType; spaces: number; areaSqft: number | null }>; kitchens: Fact; kitchenQuality: Fact; bathrooms: Fact; powderRooms: Fact; bathroomQuality: Fact; bedrooms: Fact; quality: Fact; flooring: Fact>; deckSqft: Fact; drivewaySqft: Fact; driveway: Fact<"asphalt" | "pavers" | "gravel" | "concrete" | "none">; fenceLinFt: Fact; pool: Fact<"none" | "above_ground" | "inground">; units: Fact; kitchenLinearFt: Fact; countertopSqft: Fact; backsplashSqft: Fact; bathroomTileSqft: Fact; interiorDoorCount: Fact; conditions: Partial>; effectiveAge: { chronological: number | null; effective: number | null; economicLife: number | null; confidence: number; reasoning: string[] } | null; conflicts: Conflict[]; } const fold = (s: string) => s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); /* ------------------------------------------- normalisation des détails */ /** Convertit le JSON `details` d'une annonce (clés françaises libres) en faits typés. */ export 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): ListingFacts { const entries = Object.entries(details).map(([k, v]) => [fold(k), typeof v === "string" ? fold(v) : String(v ?? "")] as [string, string]); 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; }; 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; }; const pt = fold(listing.property_type ?? ""); const typeTxt = `${pt} ${get("type de batiment", "type de propriete", "genre de propriete") ?? ""}`; let buildingType: BuildingType | null = null; if (/plex|duplex|triplex|quadruplex|quintuplex|multifamil|revenu/.test(typeTxt)) buildingType = "plex"; else if (/condo|copropri|appartement|loft/.test(typeTxt)) buildingType = "condo"; else if (/chalet|villegiature|cottage/.test(typeTxt)) buildingType = "chalet"; else if (/mobile|prefabriqu|usinee/.test(typeTxt)) buildingType = "mobile"; else if (/jumel|semi-detach|semi detach/.test(typeTxt)) buildingType = "semi_detached"; else if (/rangee|en rangee|townhouse|maison de ville/.test(typeTxt)) buildingType = "row"; else if (/detach|isole|maison|bungalow|plain-pied|cottage|paliers|unifamil/.test(typeTxt)) buildingType = "detached"; 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); const yr = listing.year_built && listing.year_built > 1600 ? listing.year_built : num(get("annee de construction")) ; const fTxt = get("fondation"); 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; const sTxt = get("revetement", "revetement exterieur", "parement"); let siding: SidingMix | null = null; if (sTxt) { const parts: (keyof SidingMix)[] = []; 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"); 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"); if (parts.length) { siding = {}; parts.forEach((p, i) => { siding![p] = i === 0 ? (parts.length === 1 ? 1 : 0.6) : 0.4 / (parts.length - 1); }); } } const rTxt = get("toiture", "toit", "couverture"); 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; 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; const wTxt = get("fenestration", "fenetre"); const windows: WindowType | null = !wTxt ? null : /hybride/.test(wTxt) ? "hybrid" : /pvc/.test(wTxt) ? "pvc" : /aluminium/.test(wTxt) ? "aluminum" : /bois/.test(wTxt) ? "wood" : null; const hTxt = `${get("chauffage", "systeme de chauffage", "mode de chauffage") ?? ""} ${get("energie pour le chauffage", "energie") ?? ""}`.trim(); let heating: Heating | null = null; if (hTxt) { if (/thermopompe|pompe a chaleur/.test(hTxt) && !/mural/.test(hTxt)) heating = "heat_pump"; else if (/geotherm/.test(hTxt)) heating = "geothermal"; else if (/plinthe|convecteur|electrique/.test(hTxt) && !/air soufflee|air chaud|fournaise/.test(hTxt)) heating = "electric_baseboard"; else if (/air soufflee|air chaud|fournaise/.test(hTxt)) heating = /gaz/.test(hTxt) ? "furnace_gas" : /mazout|huile/.test(hTxt) ? "furnace_oil" : "furnace_electric"; else if (/eau chaude|radiateur|radiant|hydron/.test(hTxt)) heating = "hydronic"; else if (/bois|granule/.test(hTxt)) heating = "wood"; } const eqTxt = `${get("equipement", "equipement disponible", "climatisation", "systeme de ventilation") ?? ""} ${entries.map(([, v]) => v).join(" ")}`; const hasAirConditioning = /climatis|thermopompe/.test(eqTxt) ? true : null; const hasAirExchanger = /echangeur d'air|echangeur d air|vrc|ventilateur recuperateur/.test(eqTxt) ? true : null; const bTxt = get("sous-sol", "sous sol"); 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; const gTxt = get("garage", "stationnement"); let garage: { type: GarageType; spaces: number } | null = null; if (gTxt) { if (/garage/.test(gTxt) || entries.some(([k]) => k === "garage")) { 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"; const m = gTxt.match(/garage\s*\((\d+)\)/); const spaces = m ? Number(m[1]) : /double/.test(gTxt) ? 2 : /triple/.test(gTxt) ? 3 : 1; garage = { type, spaces }; } else if (/allee|exterieur/.test(gTxt)) garage = { type: "none", spaces: 0 }; } const pTxt = get("piscine"); const pool = !pTxt ? null : /creus/.test(pTxt) ? "inground" : /hors|hors-terre|hors terre/.test(pTxt) ? "above_ground" : /aucun|non/.test(pTxt) ? "none" : null; 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); const waterTxt = get("approvisionnement en eau", "eau"); const wasteTxt = get("systeme d'egout", "systeme d egout", "egout"); return { 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, buildingType, stories: storiesTxt ? num(storiesTxt) : null, foundation, siding, roof, roofGeometry, windows, heating, hasAirConditioning, hasAirExchanger, basement, garage, pool, units, 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, }; } /* ------------------------------------------------------------- fusion */ const TIER: Record = { cadastral: 0, MAMH: 0, listing: 1, user: -1, AI: 3, derived: 4, assumed: 5 }; const aiSourceToAttr = (s: EvidenceSource): AttributeSource => (s === "MAMH" ? "MAMH" : s === "listing" ? "listing" : s === "derived" ? "derived" : s === "assumed" || s === "unknown" ? "assumed" : "AI"); interface Cand { value: T | null | undefined; source: AttributeSource; confidence: number; evidence?: string[] } function pick(field: string, cands: Cand[], fallback: T, conflicts: Conflict[], severity: Conflict["severity"] = "medium", eq: (a: T, b: T) => boolean = (a, b) => JSON.stringify(a) === JSON.stringify(b)): Fact { const valid = cands.filter((c): c is Cand & { value: T } => c.value != null && c.value !== ("" as unknown)); if (!valid.length) return { value: fallback, source: "assumed", confidence: 0.3, evidence: [], alternatives: [] }; // l'IA qui déclare une source « listing »/« MAMH » reste derrière les champs structurés réels valid.sort((a, b) => TIER[a.source] - TIER[b.source] || b.confidence - a.confidence); const best = valid[0]; const alternatives = valid.slice(1).map((c) => ({ source: c.source, value: c.value, confidence: c.confidence })); for (const alt of valid.slice(1)) { if (!eq(best.value, alt.value) && alt.confidence >= 0.5) { conflicts.push({ field, sourceA: best.source, valueA: JSON.stringify(best.value), sourceB: alt.source, valueB: JSON.stringify(alt.value), severity }); } } return { value: best.value, source: best.source, confidence: best.confidence, evidence: best.evidence ?? [], alternatives }; } const q = (v: string | null | undefined): Quality | null => (v === "custom_luxury" ? "prestige" : v === "economy" || v === "standard" || v === "superior" || v === "prestige" ? v : null); const numClose = (tol: number) => (a: number, b: number) => Math.abs(a - b) <= tol * Math.max(Math.abs(a), Math.abs(b), 1); export function mergeFacts(ai: ModelOutput, listing: ListingFacts, mamh: MamhFacts | null): MergedFacts { const conflicts: Conflict[] = []; const A = (a: { value: T | null; confidence: number; source: EvidenceSource; evidence: string[] } | undefined): Cand => ({ value: a?.value ?? null, source: aiSourceToAttr(a?.source ?? "unknown"), confidence: a?.confidence ?? 0, evidence: a?.evidence ?? [] }); const L = (v: T | null | undefined, conf = 0.9): Cand => ({ value: v, source: "listing", confidence: conf }); const M = (v: T | null | undefined): Cand => ({ value: v, source: "MAMH", confidence: 0.97 }); const buildingType = pick("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"); const yearBuilt = pick("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)); const stories = pick("stories", [M(mamh?.stories), L(listing.stories), A(ai.property.stories)], 1, conflicts, "high", (a, b) => Math.round(a) === Math.round(b)); // MAMH : aire d'étages hors sous-sol (référence) ; annonce : superficie habitable (peut inclure le sous-sol) const grossFloorAreaSqft = pick("grossFloorAreaSqft", [M(mamh?.grossFloorAreaSqft), L(listing.areaSqft, 0.8), A(ai.property.gross_floor_area_sqft)], 1500, conflicts, "high", numClose(0.25)); const footprintSqft = pick("footprintSqft", [A(ai.geometry.footprint_sqft)], null, conflicts, "low"); const foundation = pick("foundation", [L(listing.foundation), A(ai.construction.foundation as Cand["value"] extends never ? never : { value: Foundation | null; confidence: number; source: EvidenceSource; evidence: string[] })], "poured_concrete", conflicts, "medium"); const structure = pick("structure", [A(ai.construction.structural_system as { value: MergedFacts["structure"]["value"] | null; confidence: number; source: EvidenceSource; evidence: string[] })], "wood_frame", conflicts, "low"); const aiSiding = sidingFromAi(ai); const siding = pick("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); const roof = pick("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"); const roofGeometry = pick("roofGeometry", [L(listing.roofGeometry), A(ai.construction.roof_geometry as { value: RoofGeometry | null; confidence: number; source: EvidenceSource; evidence: string[] })], "gable", conflicts, "low"); const roofPitch = pick("roofPitch", [A(ai.geometry.roof_pitch_rise_per_12)], roofGeometry.value === "flat" ? 0 : 6, conflicts, "low"); const windows = pick("windows", [L(listing.windows), A(ai.exterior.windows_type as { value: WindowType | null; confidence: number; source: EvidenceSource; evidence: string[] })], "pvc", conflicts, "low"); const wc = ai.estimated_quantities.window_count.value ?? ai.exterior.window_count_estimate.value; const windowCount = pick("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"); const heating = pick("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"); const hasAirConditioning = pick("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"); const hasAirExchanger = pick("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"); const aiBasement = basementFromAi(ai.basement.state.value); const basement = pick("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"); const pctAi = ai.estimated_quantities.finished_basement_pct.value ?? ai.basement.estimated_finished_pct.value; const basementFinishedPct = pick("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"); 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; const garage = pick("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); const units = pick("units", [M(mamh?.units), L(listing.units), A(ai.property.dwelling_units)], buildingType.value === "plex" ? 2 : 1, conflicts, "medium"); const kitchens = pick("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"); const bathrooms = pick("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"); const powderRooms = pick("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"); const bedrooms = pick("bedrooms", [L(listing.bedrooms), A(ai.property.bedrooms)], null, conflicts, "low"); const kq = q(ai.quality.kitchen_quality) ?? q(ai.kitchens[0]?.quality ?? null); const kitchenQuality = pick("kitchenQuality", [{ value: kq, source: "AI", confidence: ai.quality.confidence, evidence: ai.kitchens.flatMap((k) => k.evidence) }], "standard", conflicts, "low"); const bq = q(ai.quality.bathroom_quality) ?? q(ai.bathrooms[0]?.quality ?? null); const bathroomQuality = pick("bathroomQuality", [{ value: bq, source: "AI", confidence: ai.quality.confidence, evidence: ai.bathrooms.flatMap((b) => b.evidence) }], "standard", conflicts, "low"); const quality = pick("quality", [{ value: q(ai.quality.overall_quality), source: "AI", confidence: ai.quality.confidence, evidence: [] }], "standard", conflicts, "low"); const flooring = pick>("flooring", [{ value: flooringFromAi(ai), source: "AI", confidence: ai.confidence.interior, evidence: ai.interior.flooring_types.flatMap((f) => f.evidence) }], {}, conflicts, "low"); const deckSqft = pick("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"); const drivewayType = pick("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"); const drivewaySqft = pick("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"); const fenceLinFt = pick("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"); const pool = pick("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"); const aiQty = (qv: { value: number | null; confidence: number; evidence: string[] }) => pick("qty", [{ value: qv.value, source: "AI", confidence: qv.confidence, evidence: qv.evidence }], null, [], "low"); const conditions: Partial> = {}; const cmap: Record = { roof: "roof", windows: "windows", exterior: "exterior", interior: "interior", kitchen: "kitchen", bathroom: "bathrooms", mechanical: "mechanical", electrical: "electrical", structure: "structure", site: "site" }; for (const [g, k] of Object.entries(cmap)) { const v = ai.condition[k]; if (v && typeof v === "string") conditions[g] = v as Condition; } const ea = ai.estimated_effective_age; return { buildingType, yearBuilt, stories, grossFloorAreaSqft, footprintSqft, foundation, structure, siding, roof, roofGeometry, roofPitch, windows, windowCount, heating, hasAirConditioning, hasAirExchanger, basement, basementFinishedPct, garage, kitchens, kitchenQuality, bathrooms, powderRooms, bathroomQuality, bedrooms, quality, flooring, deckSqft, drivewaySqft, driveway: drivewayType, fenceLinFt, pool, units, 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), 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, conflicts, }; } /* ------------------------------------------------------------ helpers IA */ function sidingFromAi(ai: ModelOutput): { mix: SidingMix | null; confidence: number } { const s = ai.exterior.siding_shares; const raw: Record = { 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 }; const total = Object.values(raw).reduce((a, b) => a + (b > 0 ? b : 0), 0); if (total > 0) { const mix: SidingMix = {}; for (const [k, v] of Object.entries(raw) as [keyof SidingMix, number][]) if (v > 0) mix[k] = v / total; return { mix, confidence: Math.max(ai.exterior.primary_siding.confidence, 0.5) }; } const p = ai.exterior.primary_siding.value; if (p && p !== "unknown") { const mix: SidingMix = {}; mix[p as keyof SidingMix] = 1; return { mix, confidence: ai.exterior.primary_siding.confidence }; } return { mix: null, confidence: 0 }; } const 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); }; function heatingFromAi(ai: ModelOutput): Heating | null { const src = ai.mechanical.heat_source.value, dist = ai.mechanical.heat_distribution.value, hp = ai.mechanical.heat_pump.value; if (hp === "central") return "heat_pump"; if (src === "geothermal") return "geothermal"; if (src === "wood") return "wood"; if (dist === "forced_air") return src === "natural_gas" || src === "propane" ? "furnace_gas" : src === "oil" ? "furnace_oil" : "furnace_electric"; if (dist === "radiant_floor" || dist === "hydronic_radiators") return "hydronic"; if (dist === "baseboards" || dist === "convectors" || dist === "heat_pump_wall") return "electric_baseboard"; if (src === "natural_gas" || src === "propane") return "furnace_gas"; if (src === "oil") return "furnace_oil"; if (src === "electricity") return "electric_baseboard"; return null; } const aiSourceFromMech = (s: string): AttributeSource => (s === "listing" ? "listing" : s === "observed" ? "AI" : s === "inferred" ? "AI" : "assumed"); function basementFromAi(v: string | null): Basement | null { 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; } } function flooringFromAi(ai: ModelOutput): Record | null { const map: Record = { hardwood: "hardwood", engineered_wood: "engineered", vinyl_plank: "vinyl_plank", laminate_floor: "laminate", ceramic_tile: "ceramic", porcelain_tile: "ceramic", carpet: "carpet" }; const out: Record = {}; 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; } const tot = Object.values(out).reduce((a, b) => a + b, 0); if (!tot) return null; for (const k of Object.keys(out)) out[k] = out[k] / tot; return out; } /** Applique des corrections manuelles (field_path → valeur) sur les faits fusionnés ; source = user. */ export function applyOverrides(m: MergedFacts, overrides: { fieldPath: string; value: unknown }[]): MergedFacts { const out: MergedFacts = JSON.parse(JSON.stringify(m)); for (const o of overrides) { const [root, sub] = o.fieldPath.split("."); if (root === "conditions" && sub) { out.conditions[sub] = o.value as Condition; continue; } 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; } const f = (out as unknown as Record | undefined>)[root]; if (!f || typeof f !== "object" || !("value" in f)) continue; const original = JSON.parse(JSON.stringify(f.value ?? null)) as unknown; if (sub && f.value && typeof f.value === "object") (f.value as Record)[sub] = o.value; else f.value = o.value; f.alternatives = [{ source: f.source, value: original, confidence: f.confidence }, ...f.alternatives]; f.source = "user"; f.confidence = 1; f.evidence = ["user_override"]; } return out; }