From 94f5d37f7ae702364232ee5c943a06a130108ad6 Mon Sep 17 00:00:00 2001 From: Vitrixxl Date: Tue, 14 Oct 2025 17:44:54 +0200 Subject: [PATCH] fully fonctionnal --- backend/app.py | 20 ++++- .../schema-builder/useSchemaValidation.ts | 36 +++++++- .../src/features/profiles/ProfilesTab.tsx | 85 ++++++++++++++++++- frontend/src/schemas/llm-json-guide.md | 1 + frontend/src/schemas/profile-schema.ts | 65 ++++++++++++++ frontend/src/schemas/utils.ts | 31 ++++++- 6 files changed, 230 insertions(+), 8 deletions(-) diff --git a/backend/app.py b/backend/app.py index 7877656..21007d2 100644 --- a/backend/app.py +++ b/backend/app.py @@ -10,6 +10,7 @@ from uuid import UUID, uuid4 import psycopg from psycopg import sql from psycopg.rows import dict_row +from psycopg.types.json import Json from dotenv import load_dotenv from flask import Flask, abort, jsonify, request, g from flask_cors import CORS @@ -156,7 +157,11 @@ def _isoformat(dt: datetime | None) -> str | None: def _ensure_json_compatible(value: Any, field_name: str) -> Any: - if isinstance(value, (dict, list, str, int, float, bool)) or value is None: + if value is None: + return None + if isinstance(value, (dict, list)): + return Json(value) + if isinstance(value, (str, int, float, bool)): return value abort(400, description=f"Field '{field_name}' must be valid JSON data") @@ -202,6 +207,8 @@ def _serialize_row( for field in datetime_fields or (): result[field] = _isoformat(result.get(field)) for field, value in list(result.items()): + if isinstance(value, Json): + result[field] = value.data if isinstance(value, UUID): result[field] = str(value) return result @@ -244,6 +251,12 @@ def _insert_row( ) -> Mapping[str, Any]: if not data: raise ValueError("Cannot insert without data") + adapted_data: Dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, (dict, list)): + adapted_data[key] = Json(value) + else: + adapted_data[key] = value query = sql.SQL( "INSERT INTO {table} ({columns}) VALUES ({values}) RETURNING {returning}" ).format( @@ -252,7 +265,7 @@ def _insert_row( values=_placeholders(data.keys()), returning=_columns_sql(returning), ) - row = _fetch_one(query, data) + row = _fetch_one(query, adapted_data) if row is None: raise RuntimeError("Insert statement did not return a row") return row @@ -286,6 +299,9 @@ def _update_row( ) params: Dict[str, Any] = dict(data) params["identifier"] = identifier_value + for key, value in list(params.items()): + if isinstance(value, (dict, list)): + params[key] = Json(value) return _fetch_one(query, params) diff --git a/frontend/src/components/schema-builder/useSchemaValidation.ts b/frontend/src/components/schema-builder/useSchemaValidation.ts index f718423..d32f145 100644 --- a/frontend/src/components/schema-builder/useSchemaValidation.ts +++ b/frontend/src/components/schema-builder/useSchemaValidation.ts @@ -55,7 +55,17 @@ function validateNode(schema: SchemaNode, value: unknown, path = ""): SchemaVali } function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: string) { - if (value === undefined || value === null || value === "") { + if (value === undefined || value === null) { + return schema.required === false + ? [] + : [createError(path, "Valeur requise.")]; + } + + if (value === "") { + if (schema.required === false && schema.type === "string") { + return []; + } + return schema.required === false ? [] : [createError(path, "Valeur requise.")]; @@ -84,11 +94,19 @@ function validatePrimitive(schema: SchemaPrimitiveNode, value: unknown, path: st } function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) { + if (value === undefined || value === null) { + return schema.required === false ? [] : [createError(path, "Valeur requise.")]; + } + if (schema.multiple) { if (!Array.isArray(value)) { return [createError(path, "Selection multiple attendue.")]; } + if (value.length === 0) { + return schema.required === false ? [] : [createError(path, "Valeur requise.")]; + } + const invalid = value.filter((item) => !schema.options.includes(item as string)); if (invalid.length > 0) { @@ -110,6 +128,10 @@ function validateEnum(schema: SchemaEnumNode, value: unknown, path: string) { } function validateRange(schema: SchemaRangeNode, value: unknown, path: string) { + if (value === undefined || value === null) { + return schema.required === false ? [] : [createError(path, "Objet {min,max} attendu.")]; + } + if (!value || typeof value !== "object") { return [createError(path, "Objet {min,max} attendu.")]; } @@ -142,14 +164,26 @@ function validateRange(schema: SchemaRangeNode, value: unknown, path: string) { } function validateArray(schema: SchemaArrayNode, value: unknown, path: string) { + if (value === undefined || value === null) { + return schema.required === false ? [] : [createError(path, "Tableau attendu.")]; + } + if (!Array.isArray(value)) { return [createError(path, "Tableau attendu.")]; } + if (value.length === 0) { + return schema.required === false ? [] : [createError(path, "Valeur requise.")]; + } + return value.flatMap((item, index) => validateNode(schema.element, item, makeIndexPath(path, index))); } function validateObject(schema: SchemaObjectNode, value: unknown, path: string) { + if (value === undefined || value === null) { + return schema.required === false ? [] : [createError(path, "Objet attendu.")]; + } + if (!value || typeof value !== "object") { return [createError(path, "Objet attendu.")]; } diff --git a/frontend/src/features/profiles/ProfilesTab.tsx b/frontend/src/features/profiles/ProfilesTab.tsx index 0f41a17..c6de598 100644 --- a/frontend/src/features/profiles/ProfilesTab.tsx +++ b/frontend/src/features/profiles/ProfilesTab.tsx @@ -74,7 +74,7 @@ function getErrorMessage(error: unknown) { function transformPayload(values: ProfileFormValues): CreateProfilePayload { const payload: CreateProfilePayload = { name: values.name.trim(), - criteria: values.criteria, + criteria: cleanupCriteriaForSubmit(values.criteria), is_active: values.is_active, }; @@ -118,6 +118,85 @@ function createDefaultValues(): ProfileFormValues { }; } +function normalizeProfileCriteriaForForm(criteria: JsonObject): JsonObject { + const clone = JSON.parse(JSON.stringify(criteria ?? {})) as JsonObject; + + const vision = clone.vision_requirements; + + if (vision && typeof vision === "object") { + normalizeVisionSection(vision as Record, "kitchen"); + normalizeVisionSection(vision as Record, "bathroom"); + } + + return clone; +} + +function normalizeVisionSection(parent: Record, key: string) { + const section = parent[key]; + + if (!section || typeof section !== "object") { + return; + } + + const record = section as Record; + const appliancesValue = record["appliances"]; + + if (Array.isArray(appliancesValue)) { + record["appliances"] = appliancesValue.join(", "); + return; + } + + if (appliancesValue === null || appliancesValue === undefined || appliancesValue === "") { + delete record["appliances"]; + return; + } + + if (typeof appliancesValue !== "string") { + record["appliances"] = String(appliancesValue); + } +} + +function cleanupCriteriaForSubmit(criteria: JsonObject): JsonObject { + const cleaned = cleanupValue(criteria); + + if (!cleaned || typeof cleaned !== "object") { + return {}; + } + + return cleaned as JsonObject; +} + +function cleanupValue(value: unknown): unknown { + if (Array.isArray(value)) { + const cleanedArray = value + .map((item) => cleanupValue(item)) + .filter((item) => item !== undefined); + + return cleanedArray.length > 0 ? cleanedArray : undefined; + } + + if (value && typeof value === "object") { + const result: Record = {}; + + Object.entries(value as Record).forEach(([key, entryValue]) => { + const cleanedEntry = cleanupValue(entryValue); + + if (cleanedEntry !== undefined) { + result[key] = cleanedEntry; + } + }); + + return Object.keys(result).length > 0 ? result : undefined; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + + return value; +} + export function ProfilesTab() { const [criteriaValid, setCriteriaValid] = useState(true); const [editingProfileId, setEditingProfileId] = useState(null); @@ -425,7 +504,9 @@ export function ProfilesTab() { profile_id: profile.profile_id, name: profile.name, description: profile.description ?? "", - criteria: profile.criteria, + criteria: normalizeProfileCriteriaForForm( + profile.criteria + ), created_at: createdAtInput, is_active: profile.is_active, }); diff --git a/frontend/src/schemas/llm-json-guide.md b/frontend/src/schemas/llm-json-guide.md index 94f854d..4b5eadc 100644 --- a/frontend/src/schemas/llm-json-guide.md +++ b/frontend/src/schemas/llm-json-guide.md @@ -228,6 +228,7 @@ Profile criteria are expressed as an object that follows the schema below. As wi | profile.min_bathrooms | number | | Minimum required bathroom count. | | profile.property_type | array | | List of desired property categories (apartment, house, etc.). | | profile.dpe_classes | enum[] | A, B, C, D, E, F, G, NC | Accepted energy classes. | +| profile.ges_classes | enum[] | A, B, C, D, E, F, G, NC | Accepted greenhouse gas emission classes. | | profile.city | string | | Single target city name. | | profile.cities | array | | List of acceptable cities. | | profile.postal_code | string | | Target postal code. | diff --git a/frontend/src/schemas/profile-schema.ts b/frontend/src/schemas/profile-schema.ts index 7aa451c..210d69d 100644 --- a/frontend/src/schemas/profile-schema.ts +++ b/frontend/src/schemas/profile-schema.ts @@ -36,6 +36,7 @@ const characteristicsSchema: SchemaObjectNode = { fields: [ { key: "type", + required: false, schema: { kind: "enum", options: ["any", "all", "none"], @@ -46,6 +47,7 @@ const characteristicsSchema: SchemaObjectNode = { }, { key: "description", + required: false, schema: { kind: "primitive", type: "string", @@ -55,6 +57,7 @@ const characteristicsSchema: SchemaObjectNode = { }, { key: "items", + required: false, schema: { kind: "array", required: false, @@ -76,6 +79,7 @@ const poiConstraintSchema: SchemaObjectNode = { fields: [ { key: "poi_category", + required: false, schema: { kind: "primitive", type: "string", @@ -85,34 +89,42 @@ const poiConstraintSchema: SchemaObjectNode = { }, { key: "max_walk_time_minutes", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_car_time_minutes", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_transport_time_minutes", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_bike_time_minutes", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_distance_meters", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "lift_type", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "domain_min_altitude", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "min_rating", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, ], @@ -121,6 +133,7 @@ const poiConstraintSchema: SchemaObjectNode = { const visionWeightFields: SchemaObjectNode["fields"] = [ { key: "weight", + required: false, schema: { kind: "primitive", type: "number", @@ -129,6 +142,7 @@ const visionWeightFields: SchemaObjectNode["fields"] = [ }, { key: "missing_penalty", + required: false, schema: { kind: "primitive", type: "number", @@ -143,6 +157,7 @@ export const profileSchema: SchemaDefinition = { fields: [ { key: "transaction_type", + required: false, schema: { kind: "enum", options: transactionOptions, @@ -153,34 +168,42 @@ export const profileSchema: SchemaDefinition = { }, { key: "min_price", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_price", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "min_size", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_size", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "min_bedrooms", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "max_bedrooms", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "min_bathrooms", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "property_type", + required: false, schema: { kind: "array", required: false, @@ -190,6 +213,7 @@ export const profileSchema: SchemaDefinition = { }, { key: "dpe_classes", + required: false, schema: { kind: "enum", options: dpeClassOptions, @@ -198,12 +222,25 @@ export const profileSchema: SchemaDefinition = { description: "Accepted energy classes.", }, }, + { + key: "ges_classes", + required: false, + schema: { + kind: "enum", + options: dpeClassOptions, + multiple: true, + required: false, + description: "Accepted greenhouse emission classes.", + }, + }, { key: "city", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "cities", + required: false, schema: { kind: "array", required: false, @@ -212,22 +249,27 @@ export const profileSchema: SchemaDefinition = { }, { key: "postal_code", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "code_insee", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "target_yield", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, { key: "require_address", + required: false, schema: { kind: "primitive", type: "boolean", required: false }, }, { key: "characteristics", + required: false, schema: { kind: "array", required: false, @@ -237,6 +279,7 @@ export const profileSchema: SchemaDefinition = { }, { key: "poi_proximity", + required: false, schema: { kind: "array", required: false, @@ -246,34 +289,41 @@ export const profileSchema: SchemaDefinition = { }, { key: "vision_requirements", + required: false, schema: { kind: "object", required: false, fields: [ { key: "description", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "kitchen", + required: false, schema: { kind: "object", required: false, fields: [ { key: "description", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "size", + required: false, schema: { kind: "enum", options: visionSizeOptions, required: false }, }, { key: "open", + required: false, schema: { kind: "primitive", type: "boolean", required: false }, }, { key: "appliances", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, ...visionWeightFields, @@ -282,20 +332,24 @@ export const profileSchema: SchemaDefinition = { }, { key: "bathroom", + required: false, schema: { kind: "object", required: false, fields: [ { key: "description", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "size", + required: false, schema: { kind: "enum", options: visionSizeOptions, required: false }, }, { key: "appliances", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, ...visionWeightFields, @@ -304,16 +358,19 @@ export const profileSchema: SchemaDefinition = { }, { key: "living_room", + required: false, schema: { kind: "object", required: false, fields: [ { key: "description", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "size", + required: false, schema: { kind: "enum", options: visionSizeOptions, required: false }, }, ...visionWeightFields, @@ -322,16 +379,19 @@ export const profileSchema: SchemaDefinition = { }, { key: "bedrooms", + required: false, schema: { kind: "object", required: false, fields: [ { key: "description", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "min_count", + required: false, schema: { kind: "primitive", type: "number", required: false }, }, ...visionWeightFields, @@ -340,20 +400,24 @@ export const profileSchema: SchemaDefinition = { }, { key: "outdoor", + required: false, schema: { kind: "object", required: false, fields: [ { key: "description", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, { key: "required", + required: false, schema: { kind: "primitive", type: "boolean", required: false }, }, { key: "types", + required: false, schema: { kind: "array", required: false, @@ -366,6 +430,7 @@ export const profileSchema: SchemaDefinition = { }, { key: "note", + required: false, schema: { kind: "primitive", type: "string", required: false }, }, ], diff --git a/frontend/src/schemas/utils.ts b/frontend/src/schemas/utils.ts index 45d600f..622fcee 100644 --- a/frontend/src/schemas/utils.ts +++ b/frontend/src/schemas/utils.ts @@ -65,7 +65,16 @@ function buildArrayDefault(schema: SchemaArrayNode) { function buildObjectDefault(schema: SchemaObjectNode) { return schema.fields.reduce>((accumulator, field) => { - accumulator[field.key] = buildDefaultValue(field.schema); + if (field.required === false) { + return accumulator; + } + + const defaultValue = buildDefaultValue(field.schema); + + if (defaultValue !== undefined) { + accumulator[field.key] = defaultValue; + } + return accumulator; }, {}); } @@ -129,8 +138,24 @@ function mergeObjectDefaults(schema: SchemaObjectNode, value: unknown) { const record = value as Record; return schema.fields.reduce>((accumulator, field) => { - accumulator[field.key] = mergeDefaults(field.schema, record[field.key]); + if (record[field.key] !== undefined) { + const merged = mergeDefaults(field.schema, record[field.key]); + + if (merged !== undefined) { + accumulator[field.key] = merged; + } + + return accumulator; + } + + if (field.required !== false) { + const defaultValue = buildDefaultValue(field.schema); + + if (defaultValue !== undefined) { + accumulator[field.key] = defaultValue; + } + } + return accumulator; }, {}); } -