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%
9.2 KB · 124 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Faits fusionnés → entrée du moteur de coût (§116-118, 124-125, 143-149, 164).4 * Le mapping est déterministe : même JSON technique + même instantané de prix5 * = même résultat. L'IA n'intervient pas ici. Module PUR.6 */7import { defaultInput } from "../estimate-defaults";8import { deriveQuantities } from "../geometry";9import { BUILDING_TYPES, CONDITION_GROUPS } from "../taxonomy";10import type { AttributeSource, CostInput, Quality } from "../types";11import type { MergedFacts } from "./merge";12import type { ModelOutput } from "./schema";1314export const AI_QTY_MIN_CONFIDENCE = 0.6;1516export interface MappingResult {17  input: CostInput;18  /** quantités d'assemblage retenues depuis l'IA (code → {qty, confidence}) */19  aiQuantities: Record<string, { quantity: number; confidence: number; evidence: string[] }>;20  notes: string[];21}2223function kitchenAssembly(q: Quality): string {24  return q === "economy" ? "KIT-ECONOMY" : q === "superior" ? "KIT-SUPERIOR" : q === "prestige" ? "KIT-PRESTIGE" : "KIT-STANDARD";25}2627export 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<string> }): MappingResult {28  const input = defaultInput();29  const notes: string[] = [];30  input.mode = "listing";31  input.listingUid = ctx.listingUid;32  input.propertyId = ctx.unitId;33  input.address = ctx.address;34  input.municipality = ctx.municipality;35  input.lat = ctx.lat; input.lng = ctx.lng;36  const b = input.building;37  const src: Partial<Record<string, AttributeSource>> = {};38  const set = <K extends keyof typeof b>(k: K, v: (typeof b)[K], s: AttributeSource) => { b[k] = v; src[k as string] = s; };39  set("type", m.buildingType.value, m.buildingType.source);40  set("quality", m.quality.value, m.quality.source);41  set("grossFloorAreaSqft", Math.round(m.grossFloorAreaSqft.value), m.grossFloorAreaSqft.source);42  set("footprintSqft", m.footprintSqft.value, m.footprintSqft.source);43  set("stories", Math.max(1, Math.round(m.stories.value)), m.stories.source);44  set("yearBuilt", m.yearBuilt.value, m.yearBuilt.source);45  set("basement", m.basement.value, m.basement.source);46  set("basementFinishedPct", m.basementFinishedPct.value, m.basementFinishedPct.source);47  set("garage", { type: m.garage.value.type, spaces: m.garage.value.spaces, areaSqft: m.garage.value.areaSqft }, m.garage.source);48  set("structure", m.structure.value, m.structure.source);49  set("foundation", m.foundation.value, m.foundation.source);50  set("siding", m.siding.value, m.siding.source);51  set("roof", m.roof.value, m.roof.source);52  set("roofGeometry", m.roofGeometry.value, m.roofGeometry.source);53  set("roofPitch", m.roofGeometry.value === "flat" ? 0 : Math.max(2, Math.min(14, m.roofPitch.value || 6)), m.roofPitch.source);54  set("windows", m.windows.value, m.windows.source);55  set("windowCount", m.windowCount.value != null && m.windowCount.confidence >= AI_QTY_MIN_CONFIDENCE ? Math.round(m.windowCount.value) : null, m.windowCount.source);56  set("heating", m.heating.value, m.heating.source);57  set("hasAirConditioning", m.hasAirConditioning.value, m.hasAirConditioning.source);58  set("hasAirExchanger", m.hasAirExchanger.value, m.hasAirExchanger.source);59  set("kitchens", Math.max(1, Math.round(m.kitchens.value)), m.kitchens.source);60  set("kitchenQuality", m.kitchenQuality.value, m.kitchenQuality.source);61  set("bathrooms", Math.max(0, Math.round(m.bathrooms.value)), m.bathrooms.source);62  set("powderRooms", Math.max(0, Math.round(m.powderRooms.value)), m.powderRooms.source);63  set("bathroomQuality", m.bathroomQuality.value, m.bathroomQuality.source);64  set("bedrooms", m.bedrooms.value, m.bedrooms.source);65  set("flooring", m.flooring.value ?? {}, m.flooring.source);66  set("deckSqft", Math.round(m.deckSqft.value), m.deckSqft.source);67  set("driveway", m.driveway.value, m.driveway.source);68  set("drivewaySqft", m.driveway.value === "none" ? 0 : Math.round(m.drivewaySqft.value), m.drivewaySqft.source);69  set("fenceLinFt", Math.round(m.fenceLinFt.value), m.fenceLinFt.source);70  set("pool", m.pool.value, m.pool.source);71  set("units", Math.max(1, Math.round(m.units.value)), m.units.source);72  input.attributeSources = src;7374  // quantités IA fiables → surcharges d'assemblages (l'IA propose, le moteur calcule le reste)75  const aiQuantities: MappingResult["aiQuantities"] = {};76  const takeQty = (code: string, f: { value: number | null; confidence: number; evidence: string[] }, scale = 1) => {77    if (f.value != null && f.value > 0 && f.confidence >= AI_QTY_MIN_CONFIDENCE && ctx.assemblyCodes.has(code)) {78      aiQuantities[code] = { quantity: Math.round(f.value * scale * 100) / 100, confidence: f.confidence, evidence: f.evidence };79      input.quantityOverrides[code] = aiQuantities[code].quantity;80    }81  };82  takeQty(kitchenAssembly(m.kitchenQuality.value), m.kitchenLinearFt);83  takeQty("OPN-DOOR-INT-" + (m.quality.value === "superior" || m.quality.value === "prestige" ? "SOLID" : "HOLLOW"), m.interiorDoorCount);84  // assemblages suggérés par l'IA avec quantité soutenue par une preuve : acceptés seulement s'ils85  // font partie des assemblages que le moteur retient pour CE bâtiment (variantes de qualité/type déjà86  // choisies : cuisine, salles de bain, fenêtres, portes, chauffage…) ou d'une famille additive.87  const engineCodes = new Set(deriveQuantities(b, {}, "AI", input.excludedAssemblies).lines.map((l) => l.assemblyCode));88  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)/;89  for (const a of ai.assemblies) {90    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;91    // ne jamais laisser l'IA surcharger la géométrie que le code dérive (murs, toit, fondations, gypse, planchers, cloisons)92    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; }93    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; }94    aiQuantities[a.assembly_code] = { quantity: a.quantity, confidence: a.confidence, evidence: a.evidence };95    input.quantityOverrides[a.assembly_code] = a.quantity;96  }9798  // dépréciation par composante : conditions IA → règles métier ; âge effectif = suggestion IA99  input.depreciation.method = "components";100  input.depreciation.componentConditions = {};101  for (const g of CONDITION_GROUPS) { const c = m.conditions[g.key]; if (c) input.depreciation.componentConditions[g.key] = c; }102  input.depreciation.economicLife = BUILDING_TYPES.find((t) => t.key === b.type)?.economicLife ?? 60;103  if (m.effectiveAge?.effective != null) { input.depreciation.effectiveAge = Math.round(m.effectiveAge.effective); input.depreciation.effectiveAgeSource = "AI"; }104  if (m.effectiveAge?.economicLife && m.effectiveAge.economicLife >= 30) input.depreciation.economicLife = Math.round(m.effectiveAge.economicLife);105106  // terrain : valeur du rôle si l'annonce est jumelée, sinon inconnue (à saisir)107  input.roll = ctx.roll;108  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: "" };109  if (!input.land.value) notes.push("terrain non jumelé : valeur du terrain à saisir");110  if (b.type === "condo") notes.push("condo : quote-part des parties communes non incluse");111  return { input, aiQuantities, notes };112}113114/** Score combiné (§172-173) : analyse bâtiment · quantités · mapping · données de coût · localisation → globale /100. */115export 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 } {116  const building = Math.round(100 * ai.confidence.overall);117  const quantities = Math.round(100 * (0.5 * ai.confidence.quantities + 0.5 * Math.min(1, (m.grossFloorAreaSqft.confidence + m.stories.confidence) / 2)));118  const mapping = Math.round(100 * mappedShare);119  const costData = Math.round(100 * (0.7 * est.coverage.materialObservedShare + 0.3 * est.coverage.pricingCoverage));120  const location = Math.round((100 * est.confidence.location) / 15);121  const overall = Math.round(0.3 * building + 0.2 * quantities + 0.15 * mapping + 0.25 * costData + 0.1 * location);122  return { building, quantities, mapping, costData, location, overall };123}124