Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Property Technical JSON (§95-118) : types TypeScript, JSON Schema STRICT4 * (outil Claude, additionalProperties:false, required complet) et validateur /5 * réparateur déterministe maison (aucune dépendance). Module PUR.6 *7 * Le modèle ne produit que la partie TECHNIQUE (`ModelOutput`) ; le code8 * ajoute `metadata` et `listing` (données connues), la géométrie dérivée et9 * les quantités calculables par formule (§193).10 */11import { MATERIALS } from "../taxonomy";1213export const SCHEMA_VERSION = "1.0";14export const PROMPT_VERSION = "v1";1516/* ------------------------------------------------------------------ types */1718export type EvidenceSource = "listing" | "MAMH" | "photo" | "derived" | "ai_estimated" | "assumed" | "unknown";19export type MechStatus = "observed" | "listing" | "inferred" | "unknown";20export type QualityLevel = "economy" | "standard" | "superior" | "prestige" | "custom_luxury";21export type ConditionLevel = "poor" | "below_average" | "average" | "good" | "very_good" | "excellent" | "renovated" | "new";2223export interface StrAttr { value: string | null; confidence: number; source: EvidenceSource; evidence: string[] }24export interface NumAttr { value: number | null; confidence: number; source: EvidenceSource; evidence: string[] }25export interface BoolAttr { value: boolean | null; confidence: number; source: EvidenceSource; evidence: string[] }26export interface Qty { value: number | null; unit: string; confidence: number; source: EvidenceSource; method: string | null; evidence: string[] }27export interface MechAttr { value: string | null; status: MechStatus; confidence: number; evidence: string[] }2829export 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[] }30export 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[] }31export interface RenovationHint { component: string; renovation_likelihood: number; estimated_renovation_age_range: number[]; evidence: string[] }32export interface AssemblyHint { assembly_code: string; quantity: number | null; unit: string; confidence: number; evidence: string[] }3334export interface ModelOutput {35 property: {36 building_type: StrAttr; year_built: NumAttr; stories: NumAttr; gross_floor_area_sqft: NumAttr; bedrooms: NumAttr; bathrooms: NumAttr; powder_rooms: NumAttr; dwelling_units: NumAttr;37 };38 geometry: { footprint_sqft: NumAttr; roof_area_sqft: NumAttr; exterior_wall_area_sqft: NumAttr; perimeter_ft: NumAttr; roof_pitch_rise_per_12: NumAttr };39 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 };40 exterior: {41 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 };42 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;43 };44 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 };45 kitchens: KitchenAnalysis[];46 bathrooms: BathroomAnalysis[];47 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 };48 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 };49 plumbing: { water_supply: MechAttr; waste_system: MechAttr; visible_pipe_type: MechAttr; fixtures_quality: MechAttr; laundry: MechAttr };50 basement: { state: StrAttr; estimated_finished_pct: NumAttr; quality: StrAttr; ceiling_type: StrAttr; flooring: StrAttr; bathroom: BoolAttr; bedrooms: NumAttr };51 garage: { type: StrAttr; spaces: NumAttr; area_estimate_sqft: NumAttr; finished: BoolAttr; heated: BoolAttr; door_count: NumAttr };52 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 };53 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 };54 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 };55 estimated_effective_age: { chronological_age: number | null; effective_age: number | null; economic_life: number | null; confidence: number; reasoning_summary: string[] };56 renovations: RenovationHint[];57 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 };58 assemblies: AssemblyHint[];59 uncertainties: string[];60 privacy: { people_visible: boolean; documents_visible: boolean; licence_plates_visible: boolean };61 confidence: { overall: number; construction: number; exterior: number; interior: number; mechanical: number; quantities: number };62}6364export 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 }65export 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 }66export interface ImageManifestEntry { id: string; position: number; room_hint: string; source_url: string; hash: string; width: number | null; height: number | null }6768export interface PropertyTechnicalAnalysis extends ModelOutput {69 metadata: AnalysisMetadata;70 listing: AnalysisListing;71 images: ImageManifestEntry[];72 /** géométrie recalculée par le code (source derived) — remplace les valeurs IA quand une formule existe */73 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<string, string> };74}7576/* ----------------------------------------------------------- JSON Schema */7778type JS = Record<string, unknown>;79const NUM_NULL: JS = { type: ["number", "null"] };80const STR_NULL: JS = { type: ["string", "null"] };81const BOOL_NULL: JS = { type: ["boolean", "null"] };82const CONF: JS = { type: "number", minimum: 0, maximum: 1, description: "0.0 to 1.0" };83const EVID: JS = { type: "array", items: { type: "string" } };84const SRC: JS = { type: "string", enum: ["listing", "MAMH", "photo", "derived", "ai_estimated", "assumed", "unknown"] };85const MECH_STATUS: JS = { type: "string", enum: ["observed", "listing", "inferred", "unknown"] };86export const QUALITY_ENUM = ["economy", "standard", "superior", "prestige", "custom_luxury"] as const;87export const CONDITION_ENUM = ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"] as const;88const QUAL_NULL: JS = nullableEnum(QUALITY_ENUM);89const COND_NULL: JS = nullableEnum(CONDITION_ENUM);9091/** Énumération nullable en mode strict : `anyOf` (un `enum` mixte string/null est refusé par l'API). */92function nullableEnum(values: readonly string[], desc?: string): JS {93 return { anyOf: [{ type: "string", enum: [...values] }, { type: "null" }], ...(desc ? { description: desc } : {}) };94}95function obj(props: Record<string, JS>): JS {96 return { type: "object", properties: props, required: Object.keys(props), additionalProperties: false };97}98const strAttr = (values?: readonly string[], desc?: string): JS => obj({ value: values ? nullableEnum(values, desc) : { ...STR_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID });99const numAttr = (desc?: string): JS => obj({ value: { ...NUM_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID });100const boolAttr = (desc?: string): JS => obj({ value: { ...BOOL_NULL, ...(desc ? { description: desc } : {}) }, confidence: CONF, source: SRC, evidence: EVID });101const 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 });102const mech = (values?: readonly string[]): JS => obj({ value: values ? nullableEnum(values) : STR_NULL, status: MECH_STATUS, confidence: CONF, evidence: EVID });103104export const BUILDING_TYPE_ENUM = ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"] as const;105export const FOUNDATION_ENUM = ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone", "unknown"] as const;106export const STRUCTURE_ENUM = ["wood_frame", "steel", "concrete", "log", "masonry", "unknown"] as const;107export const ROOF_TYPE_ENUM = ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile", "unknown"] as const;108export const ROOF_GEOM_ENUM = ["gable", "hip", "flat", "mansard", "complex", "unknown"] as const;109export const SIDING_ENUM = ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel", "unknown"] as const;110export const WINDOW_ENUM = ["pvc", "hybrid", "aluminum", "wood", "unknown"] as const;111export const BASEMENT_ENUM = ["none", "crawl", "unfinished", "partially_finished", "finished", "walkout", "unknown"] as const;112export const GARAGE_ENUM = ["none", "attached", "detached", "integrated", "carport", "unknown"] as const;113export const HEAT_SOURCE_ENUM = ["electricity", "natural_gas", "propane", "oil", "wood", "geothermal", "dual_energy", "unknown"] as const;114export const HEAT_DIST_ENUM = ["baseboards", "forced_air", "radiant_floor", "hydronic_radiators", "heat_pump_wall", "convectors", "unknown"] as const;115export const POOL_ENUM = ["none", "above_ground", "inground", "unknown"] as const;116export const DRIVEWAY_ENUM = ["asphalt", "pavers", "gravel", "concrete", "none", "unknown"] as const;117const FLOOR_MATERIALS = ["hardwood", "engineered_wood", "vinyl_plank", "laminate_floor", "ceramic_tile", "porcelain_tile", "carpet", "concrete_floor", "unknown"] as const;118119export function buildModelSchema(assemblyCodes: string[]): JS {120 return obj({121 property: obj({122 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²)"),123 bedrooms: numAttr(), bathrooms: numAttr("Salles de bain complètes"), powder_rooms: numAttr("Salles d'eau"), dwelling_units: numAttr("Nombre de logements"),124 }),125 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)") }),126 construction: obj({127 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(),128 }),129 exterior: obj({130 primary_siding: strAttr(SIDING_ENUM), secondary_siding: strAttr(SIDING_ENUM),131 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" } }),132 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(),133 }),134 interior: obj({135 flooring_types: { type: "array", items: obj({ material: { type: "string", enum: [...FLOOR_MATERIALS] }, share_pct: { type: "number" }, evidence: EVID }) },136 wall_finish: strAttr(), ceiling_finish: strAttr(), trim_quality: strAttr(), door_quality: strAttr(), stair_quality: strAttr(), fireplace: strAttr(), built_ins: boolAttr(), ceiling_height_ft: numAttr(),137 }),138 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 }) },139 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 }) },140 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() }),141 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() }),142 plumbing: obj({ water_supply: mech(["municipal", "well", "unknown"]), waste_system: mech(["municipal", "septic", "unknown"]), visible_pipe_type: mech(), fixtures_quality: mech(), laundry: mech() }),143 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() }),144 garage: obj({ type: strAttr(GARAGE_ENUM), spaces: numAttr(), area_estimate_sqft: numAttr(), finished: boolAttr(), heated: boolAttr(), door_count: numAttr() }),145 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() }),146 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 }),147 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 }),148 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)" } }),149 renovations: { type: "array", items: obj({ component: { type: "string" }, renovation_likelihood: CONF, estimated_renovation_age_range: { type: "array", items: { type: "number" } }, evidence: EVID }) },150 estimated_quantities: obj({151 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"),152 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"),153 }),154 assemblies: { type: "array", items: obj({ assembly_code: { type: "string", enum: assemblyCodes }, quantity: NUM_NULL, unit: { type: "string" }, confidence: CONF, evidence: EVID }) },155 uncertainties: { type: "array", items: { type: "string" } },156 privacy: obj({ people_visible: { type: "boolean" }, documents_visible: { type: "boolean" }, licence_plates_visible: { type: "boolean" } }),157 confidence: obj({ overall: CONF, construction: CONF, exterior: CONF, interior: CONF, mechanical: CONF, quantities: CONF }),158 });159}160161/* ------------------------------------------------- validation / réparation */162163export interface ValidationIssue { path: string; message: string; repaired: boolean }164165/**166 * Valide `data` contre un schéma (sous-ensemble JSON Schema : type, enum,167 * properties/required/additionalProperties, items, minimum/maximum) et168 * répare de façon déterministe : champ manquant → null / défaut, chaîne169 * numérique → nombre, énumération inconnue → null, confiance hors [0,1] →170 * bornée, clé inconnue → supprimée. Retourne l'objet réparé et les anomalies.171 */172export function validateAndRepair(data: unknown, schema: JS, path = "$"): { value: unknown; issues: ValidationIssue[] } {173 const issues: ValidationIssue[] = [];174 const value = walk(data, schema, path, issues);175 return { value, issues };176}177178/**179 * Sections de premier niveau absentes de la sortie du modèle (`$.property`,180 * `$.exterior`…). Le réparateur les remplit de nulls, mais une sortie où181 * plusieurs sections manquent n'est PAS une analyse : c'est typiquement un appel182 * d'outil « placeholder » (`{"property_analysis":{"placeholder":true}}`, observé183 * avec Sonnet 5 en tool_choice forcé, 2026-09-08). À traiter comme un échec184 * dur → relance, jamais comme une analyse complète.185 */186export function missingTopLevelSections(issues: ValidationIssue[]): string[] {187 return issues.filter((i) => i.repaired && i.message === "champ manquant" && /^\$\.[a-z_]+$/.test(i.path)).map((i) => i.path.slice(2));188}189190/** Seuil au-delà duquel une sortie est jugée vide (placeholder) : ≥ 3 sections manquantes. */191export const MAX_MISSING_SECTIONS = 2;192193function typeList(schema: JS): string[] {194 const t = schema.type;195 return Array.isArray(t) ? (t as string[]) : typeof t === "string" ? [t] : [];196}197198function defaultFor(schema: JS): unknown {199 const types = typeList(schema);200 if (types.includes("null")) return null;201 if (types.includes("object")) return walk({}, schema, "$", []);202 if (types.includes("array")) return [];203 if (types.includes("number") || types.includes("integer")) return typeof schema.minimum === "number" ? schema.minimum : 0;204 if (types.includes("boolean")) return false;205 if (Array.isArray(schema.enum) && schema.enum.length) return schema.enum[0];206 return "";207}208209function walk(v: unknown, schema: JS, path: string, issues: ValidationIssue[]): unknown {210 if (Array.isArray(schema.anyOf)) {211 const branches = schema.anyOf as JS[];212 const nullBranch = branches.find((b) => typeList(b).includes("null"));213 const main = branches.find((b) => !typeList(b).includes("null")) ?? branches[0];214 if (v == null) { if (nullBranch) return null; issues.push({ path, message: "champ manquant", repaired: true }); return defaultFor(main); }215 const before = issues.length;216 const out = walk(v, main, path, issues);217 if (issues.length > before && nullBranch && Array.isArray(main.enum) && !main.enum.includes(v)) return null;218 return out;219 }220 const types = typeList(schema);221 const nullable = types.includes("null");222 if (v === undefined) {223 issues.push({ path, message: "champ manquant", repaired: true });224 return defaultFor(schema);225 }226 if (v === null) {227 if (nullable) return null;228 issues.push({ path, message: "null non permis", repaired: true });229 return defaultFor(schema);230 }231 if (types.includes("object")) {232 if (typeof v !== "object" || Array.isArray(v)) { issues.push({ path, message: "objet attendu", repaired: true }); return walk({}, schema, path, issues); }233 const props = (schema.properties ?? {}) as Record<string, JS>;234 const out: Record<string, unknown> = {};235 const src = v as Record<string, unknown>;236 for (const [k, ps] of Object.entries(props)) out[k] = walk(src[k], ps, `${path}.${k}`, issues);237 for (const k of Object.keys(src)) if (!(k in props)) issues.push({ path: `${path}.${k}`, message: "clé inconnue supprimée", repaired: true });238 return out;239 }240 if (types.includes("array")) {241 if (!Array.isArray(v)) { issues.push({ path, message: "tableau attendu", repaired: true }); return []; }242 const it = schema.items as JS | undefined;243 return it ? v.map((x, i) => walk(x, it, `${path}[${i}]`, issues)).filter((x) => x !== undefined) : v;244 }245 if (types.includes("number") || types.includes("integer")) {246 let n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v.replace(",", "."))) ? Number(v.replace(",", ".")) : NaN;247 if (typeof v === "string" && Number.isFinite(n)) issues.push({ path, message: `nombre en texte « ${v} » converti`, repaired: true });248 if (!Number.isFinite(n)) {249 if (nullable) { issues.push({ path, message: `nombre attendu (${JSON.stringify(v)})`, repaired: true }); return null; }250 issues.push({ path, message: `nombre attendu (${JSON.stringify(v)}) → 0`, repaired: true });251 n = 0;252 }253 if (typeof schema.minimum === "number" && n < schema.minimum) { issues.push({ path, message: `borne min ${schema.minimum}`, repaired: true }); n = schema.minimum; }254 if (typeof schema.maximum === "number" && n > schema.maximum) { issues.push({ path, message: `borne max ${schema.maximum}`, repaired: true }); n = schema.maximum; }255 if (types.includes("integer")) n = Math.round(n);256 return n;257 }258 if (types.includes("boolean")) {259 if (typeof v === "boolean") return v;260 if (v === "true" || v === "false") { issues.push({ path, message: "booléen texte", repaired: true }); return v === "true"; }261 issues.push({ path, message: "booléen attendu", repaired: true });262 return nullable ? null : false;263 }264 if (types.includes("string")) {265 const s = typeof v === "string" ? v : String(v);266 if (typeof v !== "string") issues.push({ path, message: "chaîne attendue (converti)", repaired: true });267 if (Array.isArray(schema.enum) && !schema.enum.includes(s)) {268 issues.push({ path, message: `valeur hors énumération « ${s} »`, repaired: true });269 return nullable ? null : (schema.enum.find((e) => e === "unknown") ?? schema.enum[0]);270 }271 return s;272 }273 return v;274}275276/**277 * Version du schéma acceptée par l'API en mode strict : `minimum`/`maximum`278 * ne sont pas supportés pour les nombres → retirés (le validateur local, lui,279 * les applique pour borner les confiances).280 */281export function schemaForApi(schema: JS): JS {282 const strip = (x: unknown): unknown => {283 if (Array.isArray(x)) return x.map(strip);284 if (x && typeof x === "object") {285 const out: Record<string, unknown> = {};286 for (const [k, v] of Object.entries(x as Record<string, unknown>)) {287 if (k === "minimum" || k === "maximum") continue;288 out[k] = strip(v);289 }290 return out;291 }292 return x;293 };294 return strip(schema) as JS;295}296297/** Vrai si `data` respecte strictement le schéma (aucune réparation nécessaire). */298export function isValid(data: unknown, schema: JS): boolean {299 return validateAndRepair(data, schema).issues.length === 0;300}301302export const KNOWN_MATERIALS: readonly string[] = MATERIALS;303