SPB Git forge

spb/uqo-eval

Public
55commits 2branches 0releases
134.4 MBsize
maindefault branch
yesterdaylast push
TypeScript 90.1% JavaScript 3.2% Python 3.2% CSS 1.8% HTML 1.8%
10.1 KB · 173 lines typescript
Raw Blame History
1// UQO Éval — laboratoire pédagogique d'évaluation immobilière2// Tests hors réseau de l'analyse multimodale : schéma/réparation, empreinte, fusion, mapping, vecteur, images.3import { describe, expect, it } from "vitest";4import { buildModelSchema, isValid, schemaForApi, validateAndRepair, type ModelOutput } from "./schema";5import { sampleOutput } from "./fixture";6import { inputHash } from "./context";7import { applyOverrides, listingFactsFromDetails, mergeFacts, type ListingFacts, type MamhFacts } from "./merge";8import { mapToCostInput } from "./mapping";9import { EMBEDDING_DIMENSION, canonicalText, cosine, technicalVector } from "./vector";10import { readDimensions, selectImages, sizeAgnosticKey, sniffMediaType } from "./images";11import { deriveQuantities } from "../geometry";12import { ASSEMBLY_SEEDS } from "../seed/assemblies";1314const CODES = ASSEMBLY_SEEDS.map((a) => a.code);15const SCHEMA = buildModelSchema(CODES);1617const listingFacts = (over: Partial<ListingFacts> = {}): ListingFacts => ({18  yearBuilt: 1998, areaSqft: 2470, bedrooms: 4, bathrooms: 2, powderRooms: 1, propertyType: "Maison", lotSqft: 7200, buildingType: "detached", stories: null, foundation: null, siding: null, roof: null, roofGeometry: null, windows: null,19  heating: null, hasAirConditioning: null, hasAirExchanger: null, basement: null, garage: null, pool: null, units: null, waterSupply: null, wasteSystem: null, ...over,20});21const mamh: MamhFacts = { yearBuilt: 1997, grossFloorAreaSqft: 2300, stories: 2, buildingType: "detached", units: 1, landValue: 182000, buildingValue: 400000, totalValue: 582000, unitId: "123" };2223describe("schema", () => {24  it("la fixture est strictement valide et le schéma API ne contient plus de bornes", () => {25    expect(isValid(sampleOutput(), SCHEMA)).toBe(true);26    expect(JSON.stringify(schemaForApi(SCHEMA))).not.toMatch(/"minimum"|"maximum"/);27    expect(JSON.stringify(SCHEMA)).toContain('"KIT-SUPERIOR"');28  });29  it("répare de façon déterministe : chaîne numérique, énumération inconnue, champ manquant, clé inconnue, confiance bornée", () => {30    const bad = JSON.parse(JSON.stringify(sampleOutput())) as Record<string, unknown>;31    const prop = bad.property as Record<string, Record<string, unknown>>;32    prop.stories.value = "2";33    prop.building_type.value = "castle";34    delete prop.bedrooms;35    (bad.quality as Record<string, unknown>).confidence = 1.7;36    bad.extra_key = 1;37    const { value, issues } = validateAndRepair(bad, SCHEMA);38    const v = value as ModelOutput;39    expect(v.property.stories.value).toBe(2);40    expect(v.property.building_type.value).toBeNull();41    expect(v.property.bedrooms.value).toBeNull();42    expect(v.quality.confidence).toBe(1);43    expect("extra_key" in (value as object)).toBe(false);44    expect(issues.length).toBeGreaterThanOrEqual(5);45    expect(issues.every((i) => i.repaired)).toBe(true);46    expect(isValid(value, SCHEMA)).toBe(true);47    // idempotence48    expect(JSON.stringify(validateAndRepair(value, SCHEMA).value)).toBe(JSON.stringify(value));49  });50});5152describe("input hash", () => {53  it("est stable pour la même entrée et change avec les photos", () => {54    const a = inputHash("base", ["h1", "h2"]);55    expect(a).toBe(inputHash("base", ["h2", "h1"]));56    expect(a).not.toBe(inputHash("base", ["h1", "h3"]));57    expect(a).not.toBe(inputHash("base2", ["h1", "h2"]));58    expect(a).toMatch(/^[0-9a-f]{64}$/);59  });60});6162describe("merge", () => {63  it("hiérarchie : MAMH > annonce > IA, avec conflits tracés", () => {64    const ai = sampleOutput();65    const m = mergeFacts(ai, listingFacts({ roof: "metal" }), mamh);66    expect(m.grossFloorAreaSqft.value).toBe(2300);67    expect(m.grossFloorAreaSqft.source).toBe("MAMH");68    expect(m.yearBuilt.value).toBe(1997);69    expect(m.roof.value).toBe("metal"); // annonce > photo70    expect(m.roof.source).toBe("listing");71    expect(m.conflicts.some((c) => c.field === "roof" && c.valueB.includes("asphalt"))).toBe(true);72    expect(m.siding.value.brick).toBeCloseTo(0.6, 5);73    expect(m.siding.value.vinyl).toBeCloseTo(0.4, 5);74    expect(m.heating.value).toBe("heat_pump");75    expect(m.conditions.roof).toBe("average");76    expect(m.effectiveAge?.effective).toBe(17);77  });78  it("sans MAMH ni champ d'annonce, l'IA fournit la valeur (source AI)", () => {79    const m = mergeFacts(sampleOutput(), listingFacts({ yearBuilt: null, areaSqft: null, buildingType: null }), null);80    expect(m.grossFloorAreaSqft.value).toBe(2470);81    expect(m.grossFloorAreaSqft.source).toBe("listing"); // l'IA cite la source « listing »82    expect(m.foundation.source).toBe("AI");83    expect(m.basement.value).toBe("finished");84  });85  it("les overrides remplacent la valeur, gardent l'originale et passent en source user", () => {86    const m = mergeFacts(sampleOutput(), listingFacts(), null);87    const o = applyOverrides(m, [{ fieldPath: "roof", value: "metal" }, { fieldPath: "conditions.roof", value: "new" }]);88    expect(o.roof.value).toBe("metal");89    expect(o.roof.source).toBe("user");90    expect(o.roof.alternatives[0].value).toBe("asphalt_shingle");91    expect(o.conditions.roof).toBe("new");92    expect(m.roof.value).toBe("asphalt_shingle"); // l'original n'est pas muté93  });94  it("normalise les détails français d'une annonce", () => {95    const f = listingFactsFromDetails({ year_built: null, area_sqft: null, bedrooms: 3, bathrooms: 1, powder_rooms: 1, property_type: "Maison", lot_sqft: null }, { "Type de bâtiment": "Détaché (isolé)", "Revêtement": "Brique, Vinyle", "Toiture": "Bardeaux d'asphalte", "Chauffage": "Plinthes électriques", "Fondation": "Béton coulé", "Sous-sol": "6 pieds et plus, Totalement aménagé", "Stationnement (total)": "Allée (2), Garage (1)", "Piscine": "Creusée", "Année de construction": "1998" });96    expect(f.buildingType).toBe("detached");97    expect(f.roof).toBe("asphalt_shingle");98    expect(f.foundation).toBe("poured_concrete");99    expect(f.heating).toBe("electric_baseboard");100    expect(f.basement).toBe("finished");101    expect(f.garage).toEqual({ type: "attached", spaces: 1 });102    expect(f.pool).toBe("inground");103    expect(f.yearBuilt).toBe(1998);104    expect(f.siding?.brick).toBeCloseTo(0.6, 5);105  });106});107108describe("mapping", () => {109  const ctx = { listingUid: "t:1", address: "1 rue Test", municipality: "Gatineau", lat: 45.47, lng: -75.7, unitId: "123", landValue: 182000, roll: { landValue: 182000, buildingValue: 400000, totalValue: 582000, year: 2026 }, assemblyCodes: new Set(CODES) };110  it("façade hybride 60/40 → deux assemblages de revêtement pondérés ; quantités IA fiables retenues, faibles ignorées", () => {111    const ai = sampleOutput();112    const m = mergeFacts(ai, listingFacts(), mamh);113    const r = mapToCostInput(m, ai, ctx);114    expect(r.input.mode).toBe("listing");115    expect(r.input.building.siding).toEqual({ brick: 0.6, vinyl: 0.4 });116    const { lines } = deriveQuantities(r.input.building, r.input.quantityOverrides, "AI");117    const brick = lines.find((l) => l.assemblyCode === "ENV-SIDING-BRICK")!;118    const vinyl = lines.find((l) => l.assemblyCode === "ENV-SIDING-VINYL")!;119    expect(brick.quantity / (brick.quantity + vinyl.quantity)).toBeCloseTo(0.6, 2);120    expect(r.aiQuantities["KIT-SUPERIOR"].quantity).toBe(34);121    expect(lines.find((l) => l.assemblyCode === "KIT-SUPERIOR")!.source).toBe("AI");122    expect("OPN-DOOR-INT-SOLID" in r.aiQuantities).toBe(false); // confiance 0,55 < 0,6123    expect("ROOF-ASPHALT-ARCH" in r.aiQuantities).toBe(false); // géométrie dérivée par le code124    expect(r.input.depreciation.method).toBe("components");125    expect(r.input.depreciation.componentConditions.roof).toBe("average");126    expect(r.input.depreciation.effectiveAge).toBe(17);127    expect(r.input.land.value).toBe(182000);128    expect(r.input.attributeSources.grossFloorAreaSqft).toBe("MAMH");129    expect(r.input.attributeSources.foundation).toBe("AI");130  });131});132133describe("vector", () => {134  it("déterministe, de dimension fixe, similarité 1 avec lui-même et < 1 avec un bâtiment différent", () => {135    const ai = sampleOutput();136    const m = mergeFacts(ai, listingFacts(), mamh);137    const v1 = technicalVector(m);138    const v2 = technicalVector(mergeFacts(sampleOutput(), listingFacts(), mamh));139    expect(v1.length).toBe(EMBEDDING_DIMENSION);140    expect(v1).toEqual(v2);141    expect(cosine(v1, v2)).toBeCloseTo(1, 6);142    const ai2 = sampleOutput();143    ai2.quality.overall_quality = "economy";144    ai2.exterior.siding_shares = { brick_pct: 0, vinyl_pct: 100, fiber_cement_pct: 0, wood_pct: 0, stone_pct: 0, stucco_pct: 0, metal_pct: 0, other_pct: 0 };145    const v3 = technicalVector(mergeFacts(ai2, listingFacts({ buildingType: "row" }), null));146    expect(cosine(v1, v3)).toBeLessThan(0.95);147    expect(canonicalText(m)).toContain("Detached.");148    expect(canonicalText(m)).toContain("brick 60%");149  });150});151152describe("images", () => {153  it("détecte le type et lit les dimensions PNG/JPEG", () => {154    const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from("0000000d49484452", "hex"), Buffer.alloc(8)]);155    png.writeUInt32BE(1024, 16); png.writeUInt32BE(768, 20);156    expect(sniffMediaType(png)).toBe("image/png");157    expect(readDimensions(png, "image/png")).toEqual({ width: 1024, height: 768 });158    const jpg = Buffer.alloc(30, 0);159    jpg[0] = 0xff; jpg[1] = 0xd8; jpg[2] = 0xff; jpg[3] = 0xc0; jpg.writeUInt16BE(17, 4); jpg[6] = 8; jpg.writeUInt16BE(600, 7); jpg.writeUInt16BE(800, 9);160    expect(sniffMediaType(jpg)).toBe("image/jpeg");161    expect(readDimensions(jpg, "image/jpeg")).toEqual({ width: 800, height: 600 });162    expect(sniffMediaType(Buffer.from("hello"))).toBeNull();163  });164  it("regroupe les tailles d'une même photo et présélectionne en gardant les extérieurs", () => {165    expect(sizeAgnosticKey("https://x/pic/abc-lg.jpg?x=1")).toBe(sizeAgnosticKey("https://x/pic/abc-sm.jpg"));166    const imgs = Array.from({ length: 50 }, (_, i) => ({ position: i + 1, roomHint: i < 2 ? "exterior" : "unknown", qualityScore: 1, hash: `h${i}` }));167    const sel = selectImages(imgs, 40);168    expect(sel.length).toBe(40);169    expect(sel.filter((i) => i.roomHint === "exterior").length).toBe(2);170    expect(new Set(sel.map((i) => i.hash)).size).toBe(40);171  });172});173