// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Faits fusionnés → entrée du moteur de coût (§116-118, 124-125, 143-149, 164). * Le mapping est déterministe : même JSON technique + même instantané de prix * = même résultat. L'IA n'intervient pas ici. Module PUR. */ import { defaultInput } from "../estimate-defaults"; import { deriveQuantities } from "../geometry"; import { BUILDING_TYPES, CONDITION_GROUPS } from "../taxonomy"; import type { AttributeSource, CostInput, Quality } from "../types"; import type { MergedFacts } from "./merge"; import type { ModelOutput } from "./schema"; export const AI_QTY_MIN_CONFIDENCE = 0.6; export interface MappingResult { input: CostInput; /** quantités d'assemblage retenues depuis l'IA (code → {qty, confidence}) */ aiQuantities: Record; notes: string[]; } function kitchenAssembly(q: Quality): string { return q === "economy" ? "KIT-ECONOMY" : q === "superior" ? "KIT-SUPERIOR" : q === "prestige" ? "KIT-PRESTIGE" : "KIT-STANDARD"; } export function mapToCostInput(m: MergedFacts, ai: ModelOutput, ctx: { listingUid: string; address: string | null; municipality: string | null; lat: number | null; lng: number | null; unitId: string | null; landValue: number | null; roll: CostInput["roll"]; assemblyCodes: Set }): MappingResult { const input = defaultInput(); const notes: string[] = []; input.mode = "listing"; input.listingUid = ctx.listingUid; input.propertyId = ctx.unitId; input.address = ctx.address; input.municipality = ctx.municipality; input.lat = ctx.lat; input.lng = ctx.lng; const b = input.building; const src: Partial> = {}; const set = (k: K, v: (typeof b)[K], s: AttributeSource) => { b[k] = v; src[k as string] = s; }; set("type", m.buildingType.value, m.buildingType.source); set("quality", m.quality.value, m.quality.source); set("grossFloorAreaSqft", Math.round(m.grossFloorAreaSqft.value), m.grossFloorAreaSqft.source); set("footprintSqft", m.footprintSqft.value, m.footprintSqft.source); set("stories", Math.max(1, Math.round(m.stories.value)), m.stories.source); set("yearBuilt", m.yearBuilt.value, m.yearBuilt.source); set("basement", m.basement.value, m.basement.source); set("basementFinishedPct", m.basementFinishedPct.value, m.basementFinishedPct.source); set("garage", { type: m.garage.value.type, spaces: m.garage.value.spaces, areaSqft: m.garage.value.areaSqft }, m.garage.source); set("structure", m.structure.value, m.structure.source); set("foundation", m.foundation.value, m.foundation.source); set("siding", m.siding.value, m.siding.source); set("roof", m.roof.value, m.roof.source); set("roofGeometry", m.roofGeometry.value, m.roofGeometry.source); set("roofPitch", m.roofGeometry.value === "flat" ? 0 : Math.max(2, Math.min(14, m.roofPitch.value || 6)), m.roofPitch.source); set("windows", m.windows.value, m.windows.source); set("windowCount", m.windowCount.value != null && m.windowCount.confidence >= AI_QTY_MIN_CONFIDENCE ? Math.round(m.windowCount.value) : null, m.windowCount.source); set("heating", m.heating.value, m.heating.source); set("hasAirConditioning", m.hasAirConditioning.value, m.hasAirConditioning.source); set("hasAirExchanger", m.hasAirExchanger.value, m.hasAirExchanger.source); set("kitchens", Math.max(1, Math.round(m.kitchens.value)), m.kitchens.source); set("kitchenQuality", m.kitchenQuality.value, m.kitchenQuality.source); set("bathrooms", Math.max(0, Math.round(m.bathrooms.value)), m.bathrooms.source); set("powderRooms", Math.max(0, Math.round(m.powderRooms.value)), m.powderRooms.source); set("bathroomQuality", m.bathroomQuality.value, m.bathroomQuality.source); set("bedrooms", m.bedrooms.value, m.bedrooms.source); set("flooring", m.flooring.value ?? {}, m.flooring.source); set("deckSqft", Math.round(m.deckSqft.value), m.deckSqft.source); set("driveway", m.driveway.value, m.driveway.source); set("drivewaySqft", m.driveway.value === "none" ? 0 : Math.round(m.drivewaySqft.value), m.drivewaySqft.source); set("fenceLinFt", Math.round(m.fenceLinFt.value), m.fenceLinFt.source); set("pool", m.pool.value, m.pool.source); set("units", Math.max(1, Math.round(m.units.value)), m.units.source); input.attributeSources = src; // quantités IA fiables → surcharges d'assemblages (l'IA propose, le moteur calcule le reste) const aiQuantities: MappingResult["aiQuantities"] = {}; const takeQty = (code: string, f: { value: number | null; confidence: number; evidence: string[] }, scale = 1) => { if (f.value != null && f.value > 0 && f.confidence >= AI_QTY_MIN_CONFIDENCE && ctx.assemblyCodes.has(code)) { aiQuantities[code] = { quantity: Math.round(f.value * scale * 100) / 100, confidence: f.confidence, evidence: f.evidence }; input.quantityOverrides[code] = aiQuantities[code].quantity; } }; takeQty(kitchenAssembly(m.kitchenQuality.value), m.kitchenLinearFt); takeQty("OPN-DOOR-INT-" + (m.quality.value === "superior" || m.quality.value === "prestige" ? "SOLID" : "HOLLOW"), m.interiorDoorCount); // assemblages suggérés par l'IA avec quantité soutenue par une preuve : acceptés seulement s'ils // font partie des assemblages que le moteur retient pour CE bâtiment (variantes de qualité/type déjà // choisies : cuisine, salles de bain, fenêtres, portes, chauffage…) ou d'une famille additive. const engineCodes = new Set(deriveQuantities(b, {}, "AI", input.excludedAssemblies).lines.map((l) => l.assemblyCode)); const ADDITIVE = /^(EXT-DECK|EXT-FENCE|EXT-DRIVEWAY|EXT-POOL|EXT-STEPS|EXT-GUTTERS|OPN-SKYLIGHT|OPN-DOOR-GARAGE|ELE-EV-CHARGER|ELE-LIGHTING-EXT|MEC-HRV|MEC-HEATPUMP-WALL|MEC-CENTRAL-AC|STR-STAIRS|INT-CLOSET)/; for (const a of ai.assemblies) { if (a.quantity == null || a.quantity <= 0 || a.confidence < AI_QTY_MIN_CONFIDENCE || !ctx.assemblyCodes.has(a.assembly_code) || a.assembly_code in aiQuantities) continue; // ne jamais laisser l'IA surcharger la géométrie que le code dérive (murs, toit, fondations, gypse, planchers, cloisons) if (/^(STR-WALL|STR-FLOOR|STR-ROOF|STR-BEAM|STR-COLUMN|FND-|ROOF-|INT-DRYWALL|INT-PAINT|INT-FLOOR|INT-TRIM|ENV-INSUL|ENV-SIDING|ENV-SOFFIT|SITE-|BSM-|ELE-ROUGH|ELE-SERVICE|MEC-PLUMBING|MEC-DUCTWORK|MEC-DRAINAGE)/.test(a.assembly_code)) { notes.push(`quantité IA ignorée pour ${a.assembly_code} (dérivée par le code)`); continue; } if (!engineCodes.has(a.assembly_code) && !ADDITIVE.test(a.assembly_code)) { notes.push(`assemblage IA ${a.assembly_code} ignoré (variante non retenue pour ce bâtiment — évite le double compte)`); continue; } aiQuantities[a.assembly_code] = { quantity: a.quantity, confidence: a.confidence, evidence: a.evidence }; input.quantityOverrides[a.assembly_code] = a.quantity; } // dépréciation par composante : conditions IA → règles métier ; âge effectif = suggestion IA input.depreciation.method = "components"; input.depreciation.componentConditions = {}; for (const g of CONDITION_GROUPS) { const c = m.conditions[g.key]; if (c) input.depreciation.componentConditions[g.key] = c; } input.depreciation.economicLife = BUILDING_TYPES.find((t) => t.key === b.type)?.economicLife ?? 60; if (m.effectiveAge?.effective != null) { input.depreciation.effectiveAge = Math.round(m.effectiveAge.effective); input.depreciation.effectiveAgeSource = "AI"; } if (m.effectiveAge?.economicLife && m.effectiveAge.economicLife >= 30) input.depreciation.economicLife = Math.round(m.effectiveAge.economicLife); // terrain : valeur du rôle si l'annonce est jumelée, sinon inconnue (à saisir) input.roll = ctx.roll; input.land = ctx.landValue && ctx.landValue > 0 ? { value: ctx.landValue, source: "role", rollYear: 2026, method: "Valeur du terrain au rôle d'évaluation foncière 2026 (MAMH), via le jumelage de l'annonce" } : { value: null, source: "none", rollYear: null, method: "" }; if (!input.land.value) notes.push("terrain non jumelé : valeur du terrain à saisir"); if (b.type === "condo") notes.push("condo : quote-part des parties communes non incluse"); return { input, aiQuantities, notes }; } /** Score combiné (§172-173) : analyse bâtiment · quantités · mapping · données de coût · localisation → globale /100. */ export function combinedConfidence(ai: ModelOutput, m: MergedFacts, est: { coverage: { materialObservedShare: number; pricingCoverage: number }; confidence: { location: number } }, mappedShare: number): { building: number; quantities: number; mapping: number; costData: number; location: number; overall: number } { const building = Math.round(100 * ai.confidence.overall); const quantities = Math.round(100 * (0.5 * ai.confidence.quantities + 0.5 * Math.min(1, (m.grossFloorAreaSqft.confidence + m.stories.confidence) / 2))); const mapping = Math.round(100 * mappedShare); const costData = Math.round(100 * (0.7 * est.coverage.materialObservedShare + 0.3 * est.coverage.pricingCoverage)); const location = Math.round((100 * est.confidence.location) / 15); const overall = Math.round(0.3 * building + 0.2 * quantities + 0.15 * mapping + 0.25 * costData + 0.1 * location); return { building, quantities, mapping, costData, location, overall }; }