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%
25.6 KB · 303 lines typescript
Raw Blame History
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