// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Property Technical JSON (§95-118) : types TypeScript, JSON Schema STRICT * (outil Claude, additionalProperties:false, required complet) et validateur / * réparateur déterministe maison (aucune dépendance). Module PUR. * * Le modèle ne produit que la partie TECHNIQUE (`ModelOutput`) ; le code * ajoute `metadata` et `listing` (données connues), la géométrie dérivée et * les quantités calculables par formule (§193). */ import { MATERIALS } from "../taxonomy"; export const SCHEMA_VERSION = "1.0"; export const PROMPT_VERSION = "v1"; /* ------------------------------------------------------------------ types */ export type EvidenceSource = "listing" | "MAMH" | "photo" | "derived" | "ai_estimated" | "assumed" | "unknown"; export type MechStatus = "observed" | "listing" | "inferred" | "unknown"; export type QualityLevel = "economy" | "standard" | "superior" | "prestige" | "custom_luxury"; export type ConditionLevel = "poor" | "below_average" | "average" | "good" | "very_good" | "excellent" | "renovated" | "new"; export interface StrAttr { value: string | null; confidence: number; source: EvidenceSource; evidence: string[] } export interface NumAttr { value: number | null; confidence: number; source: EvidenceSource; evidence: string[] } export interface BoolAttr { value: boolean | null; confidence: number; source: EvidenceSource; evidence: string[] } export interface Qty { value: number | null; unit: string; confidence: number; source: EvidenceSource; method: string | null; evidence: string[] } export interface MechAttr { value: string | null; status: MechStatus; confidence: number; evidence: string[] } export interface KitchenAnalysis { location: string | null; quality: QualityLevel | null; cabinet_type: string | null; countertop: string | null; island: boolean | null; estimated_cabinet_linear_ft: number | null; estimated_countertop_sqft: number | null; estimated_backsplash_sqft: number | null; condition: ConditionLevel | null; confidence: number; evidence: string[] } export interface BathroomAnalysis { type: "full" | "powder" | "ensuite" | "unknown"; quality: QualityLevel | null; tub: string | null; shower: string | null; double_vanity: boolean | null; tile_extent: string | null; fixtures_quality: QualityLevel | null; estimated_area_sqft: number | null; estimated_tile_sqft: number | null; condition: ConditionLevel | null; confidence: number; evidence: string[] } export interface RenovationHint { component: string; renovation_likelihood: number; estimated_renovation_age_range: number[]; evidence: string[] } export interface AssemblyHint { assembly_code: string; quantity: number | null; unit: string; confidence: number; evidence: string[] } export interface ModelOutput { property: { building_type: StrAttr; year_built: NumAttr; stories: NumAttr; gross_floor_area_sqft: NumAttr; bedrooms: NumAttr; bathrooms: NumAttr; powder_rooms: NumAttr; dwelling_units: NumAttr; }; geometry: { footprint_sqft: NumAttr; roof_area_sqft: NumAttr; exterior_wall_area_sqft: NumAttr; perimeter_ft: NumAttr; roof_pitch_rise_per_12: NumAttr }; construction: { foundation: StrAttr; structural_system: StrAttr; floor_system: StrAttr; wall_system: StrAttr; roof_system_type: StrAttr; roof_geometry: StrAttr; insulation_level: StrAttr; building_envelope: StrAttr }; exterior: { primary_siding: StrAttr; secondary_siding: StrAttr; siding_shares: { brick_pct: number; vinyl_pct: number; fiber_cement_pct: number; wood_pct: number; stone_pct: number; stucco_pct: number; metal_pct: number; other_pct: number }; windows_type: StrAttr; window_count_estimate: Qty; exterior_doors_count: Qty; roof_covering: StrAttr; gutters: BoolAttr; balconies_count: Qty; decks_count: Qty; exterior_stairs: StrAttr; }; interior: { flooring_types: { material: string; share_pct: number; evidence: string[] }[]; wall_finish: StrAttr; ceiling_finish: StrAttr; trim_quality: StrAttr; door_quality: StrAttr; stair_quality: StrAttr; fireplace: StrAttr; built_ins: BoolAttr; ceiling_height_ft: NumAttr }; kitchens: KitchenAnalysis[]; bathrooms: BathroomAnalysis[]; mechanical: { heat_source: MechAttr; heat_distribution: MechAttr; heat_pump: MechAttr; air_conditioning: MechAttr; air_exchanger: MechAttr; water_heater: MechAttr; fireplace: MechAttr; boiler: MechAttr; furnace: MechAttr }; electrical: { panel_type: MechAttr; panel_capacity_amps: NumAttr; service_type: MechAttr; visible_age: MechAttr; ev_charger: MechAttr; generator: MechAttr; lighting_quality: MechAttr; smart_home: MechAttr }; plumbing: { water_supply: MechAttr; waste_system: MechAttr; visible_pipe_type: MechAttr; fixtures_quality: MechAttr; laundry: MechAttr }; basement: { state: StrAttr; estimated_finished_pct: NumAttr; quality: StrAttr; ceiling_type: StrAttr; flooring: StrAttr; bathroom: BoolAttr; bedrooms: NumAttr }; garage: { type: StrAttr; spaces: NumAttr; area_estimate_sqft: NumAttr; finished: BoolAttr; heated: BoolAttr; door_count: NumAttr }; exterior_improvements: { deck_sqft: Qty; patio_sqft: Qty; pool: StrAttr; spa: BoolAttr; shed: BoolAttr; fence_linear_ft: Qty; driveway_type: StrAttr; driveway_sqft: Qty; landscaping_quality: StrAttr; retaining_wall: BoolAttr; outdoor_kitchen: BoolAttr }; quality: { exterior_quality: QualityLevel | null; interior_quality: QualityLevel | null; kitchen_quality: QualityLevel | null; bathroom_quality: QualityLevel | null; mechanical_quality: QualityLevel | null; overall_quality: QualityLevel | null; confidence: number }; condition: { roof: ConditionLevel | null; windows: ConditionLevel | null; exterior: ConditionLevel | null; interior: ConditionLevel | null; kitchen: ConditionLevel | null; bathrooms: ConditionLevel | null; basement: ConditionLevel | null; mechanical: ConditionLevel | null; electrical: ConditionLevel | null; structure: ConditionLevel | null; site: ConditionLevel | null; confidence: number }; estimated_effective_age: { chronological_age: number | null; effective_age: number | null; economic_life: number | null; confidence: number; reasoning_summary: string[] }; renovations: RenovationHint[]; estimated_quantities: { window_count: Qty; kitchen_cabinets_linear_ft: Qty; countertop_sqft: Qty; backsplash_sqft: Qty; bathroom_tile_sqft: Qty; deck_sqft: Qty; driveway_sqft: Qty; fence_linear_ft: Qty; garage_area_sqft: Qty; finished_basement_pct: Qty; interior_door_count: Qty }; assemblies: AssemblyHint[]; uncertainties: string[]; privacy: { people_visible: boolean; documents_visible: boolean; licence_plates_visible: boolean }; confidence: { overall: number; construction: number; exterior: number; interior: number; mechanical: number; quantities: number }; } export interface AnalysisMetadata { analysis_id: string; listing_id: string; listing_source: string; model: string; prompt_version: string; schema_version: string; analysis_timestamp: string; image_count: number; input_hash: string; input_token_estimate: number | null; output_tokens: number | null; latency_ms: number | null } export interface AnalysisListing { address: string | null; municipality: string | null; region: string | null; asking_price: number; listing_date: string | null; description_excerpt: string | null; bedrooms: number | null; bathrooms: number | null; powder_rooms: number | null; lot_area_sqft: number | null; living_area_sqft: number | null; year_built: number | null; property_type: string | null; url: string | null; unit_id: string | null } export interface ImageManifestEntry { id: string; position: number; room_hint: string; source_url: string; hash: string; width: number | null; height: number | null } export interface PropertyTechnicalAnalysis extends ModelOutput { metadata: AnalysisMetadata; listing: AnalysisListing; images: ImageManifestEntry[]; /** géométrie recalculée par le code (source derived) — remplace les valeurs IA quand une formule existe */ derived_geometry: { footprint_sqft: number; perimeter_ft: number; exposed_perimeter_ft: number; gross_wall_sqft: number; net_wall_sqft: number; roof_sqft: number; window_count: number; basement_floor_sqft: number; basement_finished_sqft: number; garage_sqft: number; formulas: Record }; } /* ----------------------------------------------------------- JSON Schema */ type JS = Record; const NUM_NULL: JS = { type: ["number", "null"] }; const STR_NULL: JS = { type: ["string", "null"] }; const BOOL_NULL: JS = { type: ["boolean", "null"] }; const CONF: JS = { type: "number", minimum: 0, maximum: 1, description: "0.0 to 1.0" }; const EVID: JS = { type: "array", items: { type: "string" } }; const SRC: JS = { type: "string", enum: ["listing", "MAMH", "photo", "derived", "ai_estimated", "assumed", "unknown"] }; const MECH_STATUS: JS = { type: "string", enum: ["observed", "listing", "inferred", "unknown"] }; export const QUALITY_ENUM = ["economy", "standard", "superior", "prestige", "custom_luxury"] as const; export const CONDITION_ENUM = ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"] as const; const QUAL_NULL: JS = nullableEnum(QUALITY_ENUM); const COND_NULL: JS = nullableEnum(CONDITION_ENUM); /** Énumération nullable en mode strict : `anyOf` (un `enum` mixte string/null est refusé par l'API). */ function nullableEnum(values: readonly string[], desc?: string): JS { return { anyOf: [{ type: "string", enum: [...values] }, { type: "null" }], ...(desc ? { description: desc } : {}) }; } function obj(props: Record): JS { return { type: "object", properties: props, required: Object.keys(props), additionalProperties: false }; } const strAttr = (values?: readonly string[], desc?: string): JS => obj({ value: values ? nullableEnum(values, desc) : { ...STR_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID }); const numAttr = (desc?: string): JS => obj({ value: { ...NUM_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID }); const boolAttr = (desc?: string): JS => obj({ value: { ...BOOL_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID }); const qty = (unit: string, desc: string): JS => obj({ value: { ...NUM_NULL, description: desc }, unit: { type: "string", enum: [unit] }, confidence: CONF, source: SRC, method: STR_NULL, evidence: EVID }); const mech = (values?: readonly string[]): JS => obj({ value: values ? nullableEnum(values) : STR_NULL, status: MECH_STATUS, confidence: CONF, evidence: EVID }); export const BUILDING_TYPE_ENUM = ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"] as const; export const FOUNDATION_ENUM = ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone", "unknown"] as const; export const STRUCTURE_ENUM = ["wood_frame", "steel", "concrete", "log", "masonry", "unknown"] as const; export const ROOF_TYPE_ENUM = ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile", "unknown"] as const; export const ROOF_GEOM_ENUM = ["gable", "hip", "flat", "mansard", "complex", "unknown"] as const; export const SIDING_ENUM = ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel", "unknown"] as const; export const WINDOW_ENUM = ["pvc", "hybrid", "aluminum", "wood", "unknown"] as const; export const BASEMENT_ENUM = ["none", "crawl", "unfinished", "partially_finished", "finished", "walkout", "unknown"] as const; export const GARAGE_ENUM = ["none", "attached", "detached", "integrated", "carport", "unknown"] as const; export const HEAT_SOURCE_ENUM = ["electricity", "natural_gas", "propane", "oil", "wood", "geothermal", "dual_energy", "unknown"] as const; export const HEAT_DIST_ENUM = ["baseboards", "forced_air", "radiant_floor", "hydronic_radiators", "heat_pump_wall", "convectors", "unknown"] as const; export const POOL_ENUM = ["none", "above_ground", "inground", "unknown"] as const; export const DRIVEWAY_ENUM = ["asphalt", "pavers", "gravel", "concrete", "none", "unknown"] as const; const FLOOR_MATERIALS = ["hardwood", "engineered_wood", "vinyl_plank", "laminate_floor", "ceramic_tile", "porcelain_tile", "carpet", "concrete_floor", "unknown"] as const; export function buildModelSchema(assemblyCodes: string[]): JS { return obj({ property: obj({ building_type: strAttr(BUILDING_TYPE_ENUM, "Type de bâtiment"), year_built: numAttr("Année de construction"), stories: numAttr("Nombre d'étages hors sous-sol"), gross_floor_area_sqft: numAttr("Aire d'étages hors sous-sol (pi²)"), bedrooms: numAttr(), bathrooms: numAttr("Salles de bain complètes"), powder_rooms: numAttr("Salles d'eau"), dwelling_units: numAttr("Nombre de logements"), }), geometry: obj({ footprint_sqft: numAttr("Empreinte au sol (pi²) — seulement si observable"), roof_area_sqft: numAttr(), exterior_wall_area_sqft: numAttr(), perimeter_ft: numAttr(), roof_pitch_rise_per_12: numAttr("Pente du toit (élévation sur 12)") }), construction: obj({ foundation: strAttr(FOUNDATION_ENUM), structural_system: strAttr(STRUCTURE_ENUM), floor_system: strAttr(), wall_system: strAttr(), roof_system_type: strAttr(ROOF_TYPE_ENUM, "Type de couverture"), roof_geometry: strAttr(ROOF_GEOM_ENUM), insulation_level: strAttr(), building_envelope: strAttr(), }), exterior: obj({ primary_siding: strAttr(SIDING_ENUM), secondary_siding: strAttr(SIDING_ENUM), siding_shares: obj({ brick_pct: { type: "number" }, vinyl_pct: { type: "number" }, fiber_cement_pct: { type: "number" }, wood_pct: { type: "number" }, stone_pct: { type: "number" }, stucco_pct: { type: "number" }, metal_pct: { type: "number" }, other_pct: { type: "number" } }), windows_type: strAttr(WINDOW_ENUM), window_count_estimate: qty("unit", "Nombre total de fenêtres estimé"), exterior_doors_count: qty("unit", "Portes extérieures (hors porte-patio et garage)"), roof_covering: strAttr(ROOF_TYPE_ENUM), gutters: boolAttr(), balconies_count: qty("unit", "Balcons"), decks_count: qty("unit", "Terrasses"), exterior_stairs: strAttr(), }), interior: obj({ flooring_types: { type: "array", items: obj({ material: { type: "string", enum: [...FLOOR_MATERIALS] }, share_pct: { type: "number" }, evidence: EVID }) }, wall_finish: strAttr(), ceiling_finish: strAttr(), trim_quality: strAttr(), door_quality: strAttr(), stair_quality: strAttr(), fireplace: strAttr(), built_ins: boolAttr(), ceiling_height_ft: numAttr(), }), kitchens: { type: "array", items: obj({ location: STR_NULL, quality: QUAL_NULL, cabinet_type: nullableEnum(["melamine", "thermofoil", "painted_mdf", "wood_veneer", "solid_wood", "laminate", "unknown"]), countertop: nullableEnum(["quartz", "granite", "laminate", "butcher_block", "solid_surface", "marble", "unknown"]), island: BOOL_NULL, estimated_cabinet_linear_ft: NUM_NULL, estimated_countertop_sqft: NUM_NULL, estimated_backsplash_sqft: NUM_NULL, condition: COND_NULL, confidence: CONF, evidence: EVID }) }, bathrooms: { type: "array", items: obj({ type: { type: "string", enum: ["full", "powder", "ensuite", "unknown"] }, quality: QUAL_NULL, tub: STR_NULL, shower: STR_NULL, double_vanity: BOOL_NULL, tile_extent: STR_NULL, fixtures_quality: QUAL_NULL, estimated_area_sqft: NUM_NULL, estimated_tile_sqft: NUM_NULL, condition: COND_NULL, confidence: CONF, evidence: EVID }) }, mechanical: obj({ heat_source: mech(HEAT_SOURCE_ENUM), heat_distribution: mech(HEAT_DIST_ENUM), heat_pump: mech(["central", "wall_mounted", "none", "unknown"]), air_conditioning: mech(["central", "wall_unit", "none", "unknown"]), air_exchanger: mech(["present", "absent", "unknown"]), water_heater: mech(), fireplace: mech(), boiler: mech(), furnace: mech() }), electrical: obj({ panel_type: mech(), panel_capacity_amps: numAttr(), service_type: mech(), visible_age: mech(), ev_charger: mech(["present", "absent", "unknown"]), generator: mech(["present", "absent", "unknown"]), lighting_quality: mech(), smart_home: mech() }), plumbing: obj({ water_supply: mech(["municipal", "well", "unknown"]), waste_system: mech(["municipal", "septic", "unknown"]), visible_pipe_type: mech(), fixtures_quality: mech(), laundry: mech() }), basement: obj({ state: strAttr(BASEMENT_ENUM), estimated_finished_pct: numAttr("0-100"), quality: strAttr(QUALITY_ENUM), ceiling_type: strAttr(), flooring: strAttr(), bathroom: boolAttr(), bedrooms: numAttr() }), garage: obj({ type: strAttr(GARAGE_ENUM), spaces: numAttr(), area_estimate_sqft: numAttr(), finished: boolAttr(), heated: boolAttr(), door_count: numAttr() }), exterior_improvements: obj({ deck_sqft: qty("pi2", "Terrasse(s), surface totale"), patio_sqft: qty("pi2", "Patio/dalle"), pool: strAttr(POOL_ENUM), spa: boolAttr(), shed: boolAttr(), fence_linear_ft: qty("pi_lin", "Clôture"), driveway_type: strAttr(DRIVEWAY_ENUM), driveway_sqft: qty("pi2", "Entrée"), landscaping_quality: strAttr(QUALITY_ENUM), retaining_wall: boolAttr(), outdoor_kitchen: boolAttr() }), quality: obj({ exterior_quality: QUAL_NULL, interior_quality: QUAL_NULL, kitchen_quality: QUAL_NULL, bathroom_quality: QUAL_NULL, mechanical_quality: QUAL_NULL, overall_quality: QUAL_NULL, confidence: CONF }), condition: obj({ roof: COND_NULL, windows: COND_NULL, exterior: COND_NULL, interior: COND_NULL, kitchen: COND_NULL, bathrooms: COND_NULL, basement: COND_NULL, mechanical: COND_NULL, electrical: COND_NULL, structure: COND_NULL, site: COND_NULL, confidence: CONF }), estimated_effective_age: obj({ chronological_age: NUM_NULL, effective_age: NUM_NULL, economic_life: NUM_NULL, confidence: CONF, reasoning_summary: { type: "array", items: { type: "string" }, description: "Faits observés, factuels et courts (pas de raisonnement interne)" } }), renovations: { type: "array", items: obj({ component: { type: "string" }, renovation_likelihood: CONF, estimated_renovation_age_range: { type: "array", items: { type: "number" } }, evidence: EVID }) }, estimated_quantities: obj({ window_count: qty("unit", "Fenêtres"), kitchen_cabinets_linear_ft: qty("pi_lin", "Armoires de cuisine (base + haut)"), countertop_sqft: qty("pi2", "Comptoirs"), backsplash_sqft: qty("pi2", "Dosseret"), bathroom_tile_sqft: qty("pi2", "Carrelage des salles de bain"), deck_sqft: qty("pi2", "Terrasse"), driveway_sqft: qty("pi2", "Entrée"), fence_linear_ft: qty("pi_lin", "Clôture"), garage_area_sqft: qty("pi2", "Garage"), finished_basement_pct: qty("pct", "Part du sous-sol finie"), interior_door_count: qty("unit", "Portes intérieures"), }), assemblies: { type: "array", items: obj({ assembly_code: { type: "string", enum: assemblyCodes }, quantity: NUM_NULL, unit: { type: "string" }, confidence: CONF, evidence: EVID }) }, uncertainties: { type: "array", items: { type: "string" } }, privacy: obj({ people_visible: { type: "boolean" }, documents_visible: { type: "boolean" }, licence_plates_visible: { type: "boolean" } }), confidence: obj({ overall: CONF, construction: CONF, exterior: CONF, interior: CONF, mechanical: CONF, quantities: CONF }), }); } /* ------------------------------------------------- validation / réparation */ export interface ValidationIssue { path: string; message: string; repaired: boolean } /** * Valide `data` contre un schéma (sous-ensemble JSON Schema : type, enum, * properties/required/additionalProperties, items, minimum/maximum) et * répare de façon déterministe : champ manquant → null / défaut, chaîne * numérique → nombre, énumération inconnue → null, confiance hors [0,1] → * bornée, clé inconnue → supprimée. Retourne l'objet réparé et les anomalies. */ export function validateAndRepair(data: unknown, schema: JS, path = "$"): { value: unknown; issues: ValidationIssue[] } { const issues: ValidationIssue[] = []; const value = walk(data, schema, path, issues); return { value, issues }; } /** * Sections de premier niveau absentes de la sortie du modèle (`$.property`, * `$.exterior`…). Le réparateur les remplit de nulls, mais une sortie où * plusieurs sections manquent n'est PAS une analyse : c'est typiquement un appel * d'outil « placeholder » (`{"property_analysis":{"placeholder":true}}`, observé * avec Sonnet 5 en tool_choice forcé, 2026-09-08). À traiter comme un échec * dur → relance, jamais comme une analyse complète. */ export function missingTopLevelSections(issues: ValidationIssue[]): string[] { return issues.filter((i) => i.repaired && i.message === "champ manquant" && /^\$\.[a-z_]+$/.test(i.path)).map((i) => i.path.slice(2)); } /** Seuil au-delà duquel une sortie est jugée vide (placeholder) : ≥ 3 sections manquantes. */ export const MAX_MISSING_SECTIONS = 2; function typeList(schema: JS): string[] { const t = schema.type; return Array.isArray(t) ? (t as string[]) : typeof t === "string" ? [t] : []; } function defaultFor(schema: JS): unknown { const types = typeList(schema); if (types.includes("null")) return null; if (types.includes("object")) return walk({}, schema, "$", []); if (types.includes("array")) return []; if (types.includes("number") || types.includes("integer")) return typeof schema.minimum === "number" ? schema.minimum : 0; if (types.includes("boolean")) return false; if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0]; return ""; } function walk(v: unknown, schema: JS, path: string, issues: ValidationIssue[]): unknown { if (Array.isArray(schema.anyOf)) { const branches = schema.anyOf as JS[]; const nullBranch = branches.find((b) => typeList(b).includes("null")); const main = branches.find((b) => !typeList(b).includes("null")) ?? branches[0]; if (v == null) { if (nullBranch) return null; issues.push({ path, message: "champ manquant", repaired: true }); return defaultFor(main); } const before = issues.length; const out = walk(v, main, path, issues); if (issues.length > before && nullBranch && Array.isArray(main.enum) && !main.enum.includes(v)) return null; return out; } const types = typeList(schema); const nullable = types.includes("null"); if (v === undefined) { issues.push({ path, message: "champ manquant", repaired: true }); return defaultFor(schema); } if (v === null) { if (nullable) return null; issues.push({ path, message: "null non permis", repaired: true }); return defaultFor(schema); } if (types.includes("object")) { if (typeof v !== "object" || Array.isArray(v)) { issues.push({ path, message: "objet attendu", repaired: true }); return walk({}, schema, path, issues); } const props = (schema.properties ?? {}) as Record; const out: Record = {}; const src = v as Record; for (const [k, ps] of Object.entries(props)) out[k] = walk(src[k], ps, `${path}.${k}`, issues); for (const k of Object.keys(src)) if (!(k in props)) issues.push({ path: `${path}.${k}`, message: "clé inconnue supprimée", repaired: true }); return out; } if (types.includes("array")) { if (!Array.isArray(v)) { issues.push({ path, message: "tableau attendu", repaired: true }); return []; } const it = schema.items as JS | undefined; return it ? v.map((x, i) => walk(x, it, `${path}[${i}]`, issues)).filter((x) => x !== undefined) : v; } if (types.includes("number") || types.includes("integer")) { let n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v.replace(",", "."))) ? Number(v.replace(",", ".")) : NaN; if (typeof v === "string" && Number.isFinite(n)) issues.push({ path, message: `nombre en texte « ${v} » converti`, repaired: true }); if (!Number.isFinite(n)) { if (nullable) { issues.push({ path, message: `nombre attendu (${JSON.stringify(v)})`, repaired: true }); return null; } issues.push({ path, message: `nombre attendu (${JSON.stringify(v)}) → 0`, repaired: true }); n = 0; } if (typeof schema.minimum === "number" && n < schema.minimum) { issues.push({ path, message: `borne min ${schema.minimum}`, repaired: true }); n = schema.minimum; } if (typeof schema.maximum === "number" && n > schema.maximum) { issues.push({ path, message: `borne max ${schema.maximum}`, repaired: true }); n = schema.maximum; } if (types.includes("integer")) n = Math.round(n); return n; } if (types.includes("boolean")) { if (typeof v === "boolean") return v; if (v === "true" || v === "false") { issues.push({ path, message: "booléen texte", repaired: true }); return v === "true"; } issues.push({ path, message: "booléen attendu", repaired: true }); return nullable ? null : false; } if (types.includes("string")) { const s = typeof v === "string" ? v : String(v); if (typeof v !== "string") issues.push({ path, message: "chaîne attendue (converti)", repaired: true }); if (Array.isArray(schema.enum) && !schema.enum.includes(s)) { issues.push({ path, message: `valeur hors énumération « ${s} »`, repaired: true }); return nullable ? null : (schema.enum.find((e) => e === "unknown") ?? schema.enum[0]); } return s; } return v; } /** * Version du schéma acceptée par l'API en mode strict : `minimum`/`maximum` * ne sont pas supportés pour les nombres → retirés (le validateur local, lui, * les applique pour borner les confiances). */ export function schemaForApi(schema: JS): JS { const strip = (x: unknown): unknown => { if (Array.isArray(x)) return x.map(strip); if (x && typeof x === "object") { const out: Record = {}; for (const [k, v] of Object.entries(x as Record)) { if (k === "minimum" || k === "maximum") continue; out[k] = strip(v); } return out; } return x; }; return strip(schema) as JS; } /** Vrai si `data` respecte strictement le schéma (aucune réparation nécessaire). */ export function isValid(data: unknown, schema: JS): boolean { return validateAndRepair(data, schema).issues.length === 0; } export const KNOWN_MATERIALS: readonly string[] = MATERIALS;