milagrosdelpiano1 commited on
Commit
d33a817
·
verified ·
1 Parent(s): b5bb237

Upload 24 files

Browse files
routers/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Paquete de routers para PetAlert API
2
+
3
+
routers/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (184 Bytes). View file
 
routers/__pycache__/ai_search.cpython-311.pyc ADDED
Binary file (15.6 kB). View file
 
routers/__pycache__/embeddings.cpython-311.pyc ADDED
Binary file (6.9 kB). View file
 
routers/__pycache__/embeddings_supabase.cpython-311.pyc ADDED
Binary file (8.21 kB). View file
 
routers/__pycache__/matches.cpython-311.pyc ADDED
Binary file (15.2 kB). View file
 
routers/__pycache__/reports.cpython-311.pyc ADDED
Binary file (26.5 kB). View file
 
routers/__pycache__/reports_labels.cpython-311.pyc ADDED
Binary file (2.5 kB). View file
 
routers/ai_search.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Búsqueda con IA
3
+ ==========================
4
+
5
+ Este router maneja búsquedas inteligentes de mascotas usando inteligencia artificial.
6
+ Utiliza MegaDescriptor para generar embeddings de imágenes y busca coincidencias
7
+ visuales entre reportes de mascotas perdidas y encontradas.
8
+
9
+ Funcionalidades principales:
10
+ - Búsqueda por imagen usando embeddings de MegaDescriptor
11
+ - Cálculo de similitud visual entre imágenes
12
+ - Filtrado por ubicación geográfica (Haversine)
13
+ - Cálculo de scores combinados (visual, color, ubicación, tiempo)
14
+ - Búsqueda de matches automáticos entre reportes
15
+
16
+ Flujo de búsqueda:
17
+ 1. Usuario sube una imagen de una mascota
18
+ 2. Se genera embedding usando MegaDescriptor
19
+ 3. Se buscan reportes similares usando búsqueda vectorial (pgvector)
20
+ 4. Se calculan scores de similitud (visual, color, ubicación, tiempo)
21
+ 5. Se ordenan y filtran los resultados
22
+ 6. Se retornan los mejores matches
23
+
24
+ El sistema usa embeddings de 1536 dimensiones generados por MegaDescriptor-L-384.
25
+ """
26
+
27
+ # =========================
28
+ # Imports de FastAPI
29
+ # =========================
30
+ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
31
+
32
+ # =========================
33
+ # Imports de Python estándar
34
+ # =========================
35
+ import math # Para cálculos matemáticos (Haversine)
36
+ import os # Para variables de entorno
37
+ import sys # Para manipular el path de Python
38
+ import traceback # Para debugging
39
+
40
+ # =========================
41
+ # Imports de pathlib
42
+ # =========================
43
+ from pathlib import Path # Para trabajar con rutas de forma multiplataforma
44
+
45
+ # =========================
46
+ # Imports de tipos
47
+ # =========================
48
+ from typing import Any, Dict, List, Optional # Para type hints
49
+
50
+ # =========================
51
+ # Imports de Supabase
52
+ # =========================
53
+ from supabase import Client # Cliente de Supabase
54
+
55
+ # Agregar la carpeta parent al path para poder importar utils
56
+ sys.path.insert(0, str(Path(__file__).parent.parent))
57
+ from utils.supabase_client import get_supabase_client
58
+
59
+ # =========================
60
+ # Imports de servicios
61
+ # =========================
62
+ from services.embeddings import image_bytes_to_vec # Generar embeddings con MegaDescriptor
63
+
64
+ # Crear el router con prefijo /ai-search
65
+ router = APIRouter(prefix="/ai-search", tags=["ai-search"])
66
+
67
+ def _sb() -> Client:
68
+ """Crea un cliente de Supabase con configuración optimizada de timeouts"""
69
+ try:
70
+ return get_supabase_client()
71
+ except Exception as e:
72
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
73
+
74
+ def _coords(loc: Optional[dict]) -> Optional[tuple]:
75
+ """
76
+ Extrae coordenadas de un objeto de ubicación en formato GeoJSON.
77
+
78
+ GeoJSON usa el formato: {"type": "Point", "coordinates": [longitud, latitud]}
79
+ Esta función extrae las coordenadas y las retorna como tupla (lat, lon).
80
+
81
+ Args:
82
+ loc: Objeto de ubicación GeoJSON o None
83
+
84
+ Returns:
85
+ Tupla (latitud, longitud) o None si no se pueden extraer
86
+
87
+ Ejemplo:
88
+ >>> _coords({"type": "Point", "coordinates": [-58.3816, -34.6037]})
89
+ (-34.6037, -58.3816) # (lat, lon)
90
+ """
91
+ # Verificar que sea un diccionario con la clave "coordinates"
92
+ if isinstance(loc, dict) and "coordinates" in loc:
93
+ # GeoJSON usa [lon, lat], pero retornamos (lat, lon) para consistencia
94
+ lon, lat = loc["coordinates"]
95
+ return (lat, lon) # Retornar como (latitud, longitud)
96
+ return None # Si no es un formato válido, retornar None
97
+
98
+ def haversine_km(lat1, lon1, lat2, lon2):
99
+ """
100
+ Calcula la distancia en kilómetros entre dos puntos geográficos usando la fórmula de Haversine.
101
+
102
+ La fórmula de Haversine calcula la distancia del círculo máximo (great circle distance)
103
+ entre dos puntos en una esfera, considerando que la Tierra es una esfera perfecta.
104
+ Es más precisa que calcular distancia euclidiana simple para coordenadas geográficas.
105
+
106
+ Args:
107
+ lat1, lon1: Latitud y longitud del primer punto (en grados)
108
+ lat2, lon2: Latitud y longitud del segundo punto (en grados)
109
+
110
+ Returns:
111
+ Distancia en kilómetros entre los dos puntos
112
+
113
+ Ejemplo:
114
+ >>> # Distancia entre Buenos Aires y Córdoba (Argentina)
115
+ >>> haversine_km(-34.6037, -58.3816, -31.4201, -64.1888)
116
+ ~700 km
117
+ """
118
+ R = 6371.0 # Radio de la Tierra en kilómetros
119
+ # Convertir diferencias de latitud y longitud a radianes
120
+ dlat = math.radians(lat2 - lat1) # Diferencia de latitud en radianes
121
+ dlon = math.radians(lon2 - lon1) # Diferencia de longitud en radianes
122
+ # Aplicar la fórmula de Haversine
123
+ # a = sin²(Δlat/2) + cos(lat1) * cos(lat2) * sin²(Δlon/2)
124
+ a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
125
+ # Distancia = 2 * R * arcsin(√a)
126
+ return 2 * R * math.asin(math.sqrt(a))
127
+
128
+ def label_set(labels_json) -> set:
129
+ """
130
+ Convierte etiquetas JSON a un conjunto de strings normalizados.
131
+
132
+ Las etiquetas pueden venir en diferentes formatos JSON.
133
+ Esta función normaliza y extrae las etiquetas como un set de strings en minúsculas.
134
+
135
+ Args:
136
+ labels_json: Objeto JSON con etiquetas (puede ser dict con "labels" o lista)
137
+
138
+ Returns:
139
+ Set de strings en minúsculas con las etiquetas
140
+
141
+ Ejemplo:
142
+ >>> label_set({"labels": [{"label": "Perro"}, {"description": "Golden Retriever"}]})
143
+ {"perro", "golden retriever"}
144
+ """
145
+ # Si no hay etiquetas, retornar set vacío
146
+ if not labels_json:
147
+ return set()
148
+
149
+ # Intentar obtener la lista de etiquetas del objeto JSON
150
+ # Puede venir como {"labels": [...]} o directamente como lista
151
+ items = labels_json.get("labels") if isinstance(labels_json, dict) else None
152
+
153
+ # Si no es una lista válida, retornar set vacío
154
+ if not isinstance(items, list):
155
+ return set()
156
+
157
+ # Extraer etiquetas: buscar "label" o "description" en cada item
158
+ # Convertir a minúsculas para comparaciones case-insensitive
159
+ # Filtrar items vacíos o None
160
+ return {(it.get("label") or it.get("description") or "").lower() for it in items if it}
161
+
162
+ def color_set(colors_json) -> set:
163
+ """Convierte colores JSON a un conjunto de strings."""
164
+ if not colors_json:
165
+ return set()
166
+ if isinstance(colors_json, list):
167
+ return set(color.lower() for color in colors_json if color)
168
+ return set()
169
+
170
+ def calculate_visual_similarity(analysis_labels, candidate_labels):
171
+ """
172
+ Calcula similitud visual entre dos conjuntos de etiquetas usando el coeficiente de Jaccard.
173
+
174
+ El coeficiente de Jaccard mide la similitud entre dos conjuntos:
175
+ J(A,B) = |A ∩ B| / |A ∪ B|
176
+
177
+ Esto da un valor entre 0 (sin similitud) y 1 (idénticos).
178
+ Se multiplica por 100 para obtener un porcentaje.
179
+
180
+ Args:
181
+ analysis_labels: Etiquetas de la imagen de búsqueda (JSON)
182
+ candidate_labels: Etiquetas del reporte candidato (JSON)
183
+
184
+ Returns:
185
+ Porcentaje de similitud (0-100)
186
+
187
+ Ejemplo:
188
+ >>> calculate_visual_similarity(
189
+ ... {"labels": [{"label": "Perro"}, {"label": "Dorado"}]},
190
+ ... {"labels": [{"label": "Perro"}, {"label": "Labrador"}]}
191
+ ... )
192
+ 33.33 # 1 etiqueta en común de 3 totales (perro, dorado, labrador)
193
+ """
194
+ # Si alguno de los conjuntos está vacío, no hay similitud
195
+ if not analysis_labels or not candidate_labels:
196
+ return 0
197
+
198
+ # Convertir a sets de strings normalizados
199
+ set1 = label_set(analysis_labels) # Etiquetas de la imagen de búsqueda
200
+ set2 = label_set(candidate_labels) # Etiquetas del candidato
201
+
202
+ # Si alguno de los sets está vacío después de la conversión, no hay similitud
203
+ if not set1 or not set2:
204
+ return 0
205
+
206
+ # Calcular intersección (etiquetas en común) y unión (todas las etiquetas)
207
+ intersection = len(set1 & set2) # Etiquetas que aparecen en ambos
208
+ union = len(set1 | set2) # Todas las etiquetas únicas (sin duplicados)
209
+
210
+ # Evitar división por cero
211
+ if union == 0:
212
+ return 0
213
+
214
+ # Coeficiente de Jaccard multiplicado por 100 para obtener porcentaje
215
+ return (intersection / union) * 100
216
+
217
+ def calculate_color_similarity(analysis_colors, candidate_colors):
218
+ """Calcula similitud de colores entre dos conjuntos."""
219
+ if not analysis_colors or not candidate_colors:
220
+ return 0
221
+
222
+ set1 = color_set(analysis_colors)
223
+ set2 = color_set(candidate_colors)
224
+
225
+ if not set1 or not set2:
226
+ return 0
227
+
228
+ intersection = len(set1 & set2)
229
+ union = len(set1 | set2)
230
+
231
+ if union == 0:
232
+ return 0
233
+
234
+ return (intersection / union) * 100
235
+
236
+ def calculate_location_score(distance_km, max_distance_km=50):
237
+ """Calcula puntuación de ubicación basada en distancia."""
238
+ if distance_km <= 0:
239
+ return 100
240
+ if distance_km >= max_distance_km:
241
+ return 0
242
+
243
+ # Puntuación decrece linealmente con la distancia
244
+ return max(0, 100 - (distance_km / max_distance_km) * 100)
245
+
246
+ def calculate_time_score(created_at):
247
+ """Calcula puntuación basada en la antigüedad del reporte."""
248
+ from datetime import datetime, timezone
249
+
250
+ try:
251
+ if isinstance(created_at, str):
252
+ report_date = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
253
+ else:
254
+ report_date = created_at
255
+
256
+ now = datetime.now(timezone.utc)
257
+ days_old = (now - report_date).days
258
+
259
+ # Reportes más recientes tienen mayor puntuación
260
+ if days_old <= 1:
261
+ return 100
262
+ elif days_old <= 7:
263
+ return 80
264
+ elif days_old <= 30:
265
+ return 60
266
+ else:
267
+ return 40
268
+ except:
269
+ return 50 # Puntuación neutral si hay error
270
+
271
+ @router.post("/")
272
+ async def ai_search(
273
+ file: UploadFile = File(...), # Archivo de imagen subido por el usuario
274
+ user_lat: float = Query(...), # Latitud del usuario (requerida)
275
+ user_lng: float = Query(...), # Longitud del usuario (requerida)
276
+ radius_km: float = Query(10.0), # Radio de búsqueda en kilómetros (default: 10km)
277
+ search_type: str = Query("both") # Tipo de búsqueda: 'lost', 'found', o 'both' (default: 'both')
278
+ ):
279
+ """
280
+ Endpoint principal de búsqueda con IA.
281
+
282
+ Este endpoint permite buscar mascotas similares subiendo una imagen.
283
+ El proceso es:
284
+ 1. Recibe una imagen del usuario
285
+ 2. Genera un embedding usando MegaDescriptor
286
+ 3. Busca reportes similares usando búsqueda vectorial en Supabase
287
+ 4. Calcula scores de similitud (visual, ubicación, tiempo)
288
+ 5. Filtra por radio geográfico
289
+ 6. Retorna los mejores matches ordenados por score total
290
+
291
+ Args:
292
+ file: Imagen de la mascota a buscar (JPEG, PNG, etc.)
293
+ user_lat: Latitud del usuario (para filtrar por distancia)
294
+ user_lng: Longitud del usuario (para filtrar por distancia)
295
+ radius_km: Radio máximo de búsqueda en kilómetros (default: 10km)
296
+ search_type: Tipo de reportes a buscar:
297
+ - 'lost': Solo reportes de mascotas perdidas
298
+ - 'found': Solo reportes de mascotas encontradas
299
+ - 'both': Ambos tipos (default)
300
+
301
+ Returns:
302
+ JSON con:
303
+ - analysis: Datos del análisis de la imagen
304
+ - matches: Lista de reportes similares ordenados por score
305
+ - total_matches: Número total de matches encontrados
306
+
307
+ Raises:
308
+ HTTPException 400: Si los parámetros son inválidos
309
+ HTTPException 500: Si hay error procesando la imagen o buscando
310
+ """
311
+ try:
312
+ # =========================
313
+ # Validación de parámetros
314
+ # =========================
315
+ # Validar que el tipo de búsqueda sea uno de los valores permitidos
316
+ if search_type not in ['lost', 'found', 'both']:
317
+ raise HTTPException(400, "search_type debe ser 'lost', 'found' o 'both'")
318
+
319
+ # =========================
320
+ # Procesar la imagen
321
+ # =========================
322
+ # Leer el contenido del archivo subido
323
+ # UploadFile.read() retorna los bytes de la imagen
324
+ content = await file.read()
325
+
326
+ # Validar que el archivo no esté vacío
327
+ if not content:
328
+ raise HTTPException(400, "Archivo vacío o no leído")
329
+
330
+ # =========================
331
+ # Generar embedding con MegaDescriptor
332
+ # =========================
333
+ # Importar la función para generar embeddings
334
+ # MegaDescriptor convierte la imagen en un vector numérico de 1536 dimensiones
335
+ from services.embeddings import image_bytes_to_vec
336
+
337
+ # Generar el embedding de la imagen de búsqueda
338
+ # Esto puede tardar 1-3 segundos dependiendo del hardware
339
+ query_embedding = image_bytes_to_vec(content)
340
+
341
+ # =========================
342
+ # Buscar reportes similares
343
+ # =========================
344
+ # Obtener cliente de Supabase
345
+ sb = _sb()
346
+
347
+ # Convertir el embedding de numpy array a lista de Python
348
+ # Supabase RPC requiere una lista, no un numpy array
349
+ query_vector = query_embedding.tolist()
350
+
351
+ # Buscar reportes similares usando la función RPC de Supabase
352
+ # search_similar_reports es una función PostgreSQL que usa pgvector
353
+ # para buscar vectores similares usando distancia coseno
354
+ try:
355
+ similar_reports = sb.rpc(
356
+ 'search_similar_reports', # Nombre de la función RPC en Supabase
357
+ {
358
+ 'query_embedding': query_vector, # Embedding de la imagen de búsqueda
359
+ 'match_threshold': 0.6, # Umbral mínimo de similitud (60%)
360
+ 'match_count': 50 # Máximo de candidatos a retornar
361
+ }
362
+ ).execute()
363
+
364
+ # Extraer los datos de la respuesta
365
+ # Si no hay datos, usar lista vacía
366
+ candidates = similar_reports.data if similar_reports.data else []
367
+ except Exception as e:
368
+ # Si falla la búsqueda por embedding (error en RPC, base de datos, etc.)
369
+ # Hacer una búsqueda simple sin embeddings como fallback
370
+ # Esto asegura que siempre haya resultados, aunque sean menos precisos
371
+ candidates = []
372
+
373
+ # =========================
374
+ # Preparar datos de análisis
375
+ # =========================
376
+ # Crear objeto con información del análisis
377
+ # Nota: Ya no usamos Google Vision, así que labels y colors están vacíos
378
+ analysis_data = {
379
+ "labels": [], # Etiquetas detectadas (vacío, ya no usamos Google Vision)
380
+ "colors": [], # Colores detectados (vacío, ya no usamos Google Vision)
381
+ "species": "other", # Especie detectada (se actualizará con los candidatos)
382
+ "file_name": file.filename, # Nombre del archivo subido
383
+ "file_size": len(content), # Tamaño del archivo en bytes
384
+ "method": "embedding_similarity" # Método usado: búsqueda por embeddings
385
+ }
386
+
387
+ # =========================
388
+ # Filtrar candidatos
389
+ # =========================
390
+ # Filtrar candidatos por tipo de búsqueda si no es "both"
391
+ # Si el usuario busca solo "lost", excluir reportes "found" y viceversa
392
+ if search_type != "both":
393
+ candidates = [c for c in candidates if c.get("type") == search_type]
394
+
395
+ # =========================
396
+ # Fallback: búsqueda simple
397
+ # =========================
398
+ # Si no hay candidatos por embedding (falló la búsqueda vectorial o no hay matches)
399
+ # Hacer una búsqueda simple por tipo de reporte
400
+ # Esto asegura que siempre haya resultados para mostrar
401
+ if not candidates:
402
+ # Construir query para obtener reportes activos
403
+ query = sb.table("reports").select("*").eq("status", "active")
404
+
405
+ # Si el tipo de búsqueda no es "both", filtrar por tipo
406
+ if search_type != "both":
407
+ query = query.eq("type", search_type)
408
+
409
+ # Ejecutar la query y obtener los resultados
410
+ candidates = query.execute().data
411
+
412
+ # =========================
413
+ # Detectar especie
414
+ # =========================
415
+ # Intentar detectar la especie del primer candidato más similar
416
+ # Esto ayuda a mejorar los filtros posteriores
417
+ detected_species = None
418
+ if candidates:
419
+ # Obtener la especie del primer candidato (el más similar)
420
+ # Si no tiene especie, usar "other" por defecto
421
+ detected_species = candidates[0].get("species", "other")
422
+
423
+ # Actualizar análisis con la especie detectada
424
+ analysis_data["species"] = detected_species or "other"
425
+
426
+ # Filtrar por distancia y calcular puntuaciones
427
+ results = []
428
+ for candidate in candidates:
429
+ candidate_coords = _coords(candidate.get("location"))
430
+ if not candidate_coords:
431
+ continue
432
+
433
+ cand_lat, cand_lng = candidate_coords
434
+ distance_km = haversine_km(user_lat, user_lng, cand_lat, cand_lng)
435
+
436
+ if distance_km > radius_km:
437
+ continue
438
+
439
+ # Calcular puntuaciones basadas en similitud de embedding
440
+ # Si el candidato viene de la búsqueda por embedding, ya tiene similitud
441
+ if "similarity" in candidate:
442
+ visual_score = candidate["similarity"] * 100
443
+ else:
444
+ # Calcular similitud de embedding si está disponible
445
+ visual_score = 50 # Puntuación neutral si no hay embedding
446
+
447
+ # Similitud de colores (usar colores guardados en el reporte si existen)
448
+ candidate_colors = candidate.get("colors", [])
449
+ color_score = calculate_color_similarity(
450
+ [], # No tenemos colores de la imagen de búsqueda
451
+ candidate_colors
452
+ ) if candidate_colors else 0
453
+
454
+ location_score = calculate_location_score(distance_km, radius_km)
455
+ time_score = calculate_time_score(candidate.get("created_at"))
456
+
457
+ # Puntuación total ponderada
458
+ total_score = (
459
+ visual_score * 0.4 + # 40% similitud visual
460
+ color_score * 0.3 + # 30% similitud de colores
461
+ location_score * 0.2 + # 20% proximidad geográfica
462
+ time_score * 0.1 # 10% relevancia temporal
463
+ )
464
+
465
+ # Solo incluir resultados con puntuación mínima
466
+ if total_score >= 30: # Umbral mínimo de relevancia
467
+ results.append({
468
+ "candidate": {
469
+ "id": candidate["id"],
470
+ "pet_name": candidate.get("pet_name"),
471
+ "species": candidate.get("species"),
472
+ "breed": candidate.get("breed"),
473
+ "color": candidate.get("color"),
474
+ "size": candidate.get("size"),
475
+ "description": candidate.get("description"),
476
+ "location": candidate.get("location"),
477
+ "photos": candidate.get("photos", []),
478
+ "labels": candidate.get("labels"),
479
+ "reporter_id": candidate.get("reporter_id"),
480
+ "created_at": candidate.get("created_at"),
481
+ },
482
+ "distance_km": round(distance_km, 2),
483
+ "visual_similarity": round(visual_score, 1),
484
+ "color_similarity": round(color_score, 1),
485
+ "location_score": round(location_score, 1),
486
+ "time_score": round(time_score, 1),
487
+ "total_score": round(total_score, 1),
488
+ "match_confidence": "Alta" if total_score >= 70 else "Media" if total_score >= 50 else "Baja"
489
+ })
490
+
491
+ # Ordenar por puntuación total
492
+ results.sort(key=lambda x: x["total_score"], reverse=True)
493
+
494
+ # Limitar resultados a los mejores 20
495
+ top_results = results[:20]
496
+
497
+ return {
498
+ "analysis": analysis_data,
499
+ "matches": top_results,
500
+ "search_metadata": {
501
+ "total_candidates": len(candidates),
502
+ "filtered_results": len(results),
503
+ "returned_results": len(top_results),
504
+ "search_type": search_type,
505
+ "radius_km": radius_km,
506
+ "user_location": {"lat": user_lat, "lng": user_lng},
507
+ "detected_species": detected_species,
508
+ "analysis_confidence": "Alta" if len(candidates) > 0 else "Baja"
509
+ }
510
+ }
511
+
512
+ except HTTPException:
513
+ raise
514
+ except Exception as e:
515
+ traceback.print_exc()
516
+ raise HTTPException(500, f"Error en búsqueda IA: {str(e)}")
517
+
518
+ @router.get("/health")
519
+ async def ai_search_health():
520
+ """Verifica el estado del servicio de búsqueda IA."""
521
+ try:
522
+ # Verificar conexión a Supabase
523
+ sb = _sb()
524
+ test_query = sb.table("reports").select("id").limit(1).execute()
525
+
526
+ return {
527
+ "status": "ok",
528
+ "message": "Servicio de búsqueda IA funcionando",
529
+ "supabase": "conectado" if test_query.data is not None else "error",
530
+ "method": "embedding_similarity",
531
+ "endpoints": {
532
+ "ai_search": "/ai-search/",
533
+ "health": "/ai-search/health"
534
+ }
535
+ }
536
+ except Exception as e:
537
+ return {
538
+ "status": "error",
539
+ "message": f"Error en servicio IA: {str(e)}",
540
+ "supabase": "error"
541
+ }
542
+
543
+ @router.post("/similarity")
544
+ async def calculate_similarity(
545
+ labels1: List[Dict[str, Any]] = None,
546
+ labels2: List[Dict[str, Any]] = None,
547
+ colors1: List[str] = None,
548
+ colors2: List[str] = None
549
+ ):
550
+ """
551
+ Calcula similitud entre dos conjuntos de etiquetas y colores.
552
+ Útil para testing y debugging.
553
+ """
554
+ try:
555
+ visual_similarity = calculate_visual_similarity(
556
+ {"labels": labels1 or []},
557
+ {"labels": labels2 or []}
558
+ )
559
+
560
+ color_similarity = calculate_color_similarity(
561
+ colors1 or [],
562
+ colors2 or []
563
+ )
564
+
565
+ return {
566
+ "visual_similarity": round(visual_similarity, 1),
567
+ "color_similarity": round(color_similarity, 1),
568
+ "combined_score": round((visual_similarity + color_similarity) / 2, 1),
569
+ "inputs": {
570
+ "labels1": labels1,
571
+ "labels2": labels2,
572
+ "colors1": colors1,
573
+ "colors2": colors2
574
+ }
575
+ }
576
+ except Exception as e:
577
+ raise HTTPException(500, f"Error calculando similitud: {str(e)}")
578
+
routers/direct_matches.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router para Búsqueda Directa de Coincidencias usando Embeddings
3
+ ================================================================
4
+
5
+ Este router permite buscar coincidencias directamente usando los embeddings
6
+ ya almacenados en Supabase, sin necesidad de generar nuevos embeddings.
7
+
8
+ A diferencia de /ai-search que genera embeddings de una imagen nueva,
9
+ este router usa embeddings pre-existentes para buscar matches más rápido.
10
+
11
+ Funcionalidades:
12
+ - Buscar matches para un reporte usando su embedding existente
13
+ - Búsqueda vectorial usando distancia coseno
14
+ - Filtrado por tipo (lost/found) y especie
15
+ - Configuración de umbral de similitud y número de resultados
16
+ """
17
+
18
+ from fastapi import APIRouter, HTTPException, Query
19
+ from typing import Optional, List, Dict, Any
20
+ import os
21
+ from supabase import Client
22
+ import numpy as np # Para operaciones con vectores
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ # Agregar la carpeta parent al path para poder importar utils
27
+ sys.path.insert(0, str(Path(__file__).parent.parent))
28
+ from utils.supabase_client import get_supabase_client
29
+
30
+ # Crear el router con prefijo /direct-matches
31
+ router = APIRouter(prefix="/direct-matches", tags=["direct-matches"])
32
+
33
+ def _sb() -> Client:
34
+ """
35
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
36
+
37
+ Returns:
38
+ Client: Cliente de Supabase configurado
39
+
40
+ Raises:
41
+ HTTPException: Si no se puede conectar a Supabase
42
+ """
43
+ try:
44
+ return get_supabase_client()
45
+ except Exception as e:
46
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
47
+
48
+ @router.post("/find/{report_id}")
49
+ async def find_matches_for_report(
50
+ report_id: str, # ID del reporte para buscar matches
51
+ match_threshold: float = Query(0.1, ge=0.0, le=1.0, description="Umbral mínimo de similitud (0.0-1.0)"),
52
+ top_k: int = Query(10, ge=1, le=50, description="Número máximo de resultados a retornar")
53
+ ):
54
+ """
55
+ Busca coincidencias para un reporte específico usando su embedding existente.
56
+
57
+ Este endpoint es más rápido que /ai-search porque usa embeddings ya generados
58
+ en lugar de generar uno nuevo desde una imagen.
59
+
60
+ El proceso es:
61
+ 1. Obtiene el embedding del reporte base
62
+ 2. Busca reportes del tipo opuesto (lost/found) con embeddings
63
+ 3. Calcula similitud usando distancia coseno
64
+ 4. Filtra por umbral de similitud y especie
65
+ 5. Retorna los mejores matches ordenados por similitud
66
+
67
+ Args:
68
+ report_id: ID del reporte para buscar matches
69
+ match_threshold: Umbral mínimo de similitud (0.0 = cualquier similitud, 1.0 = idéntico)
70
+ top_k: Número máximo de matches a retornar
71
+
72
+ Returns:
73
+ JSON con:
74
+ - report_id: ID del reporte base
75
+ - matches: Lista de matches encontrados ordenados por similitud
76
+ - total_candidates: Número total de candidatos evaluados
77
+ - message: Mensaje informativo
78
+ """
79
+ try:
80
+ sb = _sb()
81
+
82
+ # =========================
83
+ # Obtener reporte base
84
+ # =========================
85
+ # Obtener el reporte base con su embedding y datos relevantes
86
+ base_result = sb.table("reports")\
87
+ .select("id, embedding, type, species, photos, pet_name, description, color")\
88
+ .eq("id", report_id)\
89
+ .single()\
90
+ .execute()
91
+
92
+ # Verificar que el reporte existe
93
+ if not base_result.data:
94
+ raise HTTPException(404, f"Reporte {report_id} no encontrado")
95
+
96
+ base_report = base_result.data
97
+ base_embedding = base_report.get("embedding")
98
+
99
+ # Verificar que el reporte tiene embedding
100
+ if not base_embedding:
101
+ raise HTTPException(400, f"El reporte {report_id} no tiene embedding generado")
102
+
103
+ # =========================
104
+ # Parsear embedding
105
+ # =========================
106
+ # Postgrest devuelve vectores como strings JSON, necesitamos parsearlos
107
+ # Esto es necesario porque pgvector almacena los vectores como JSON en Supabase
108
+ if isinstance(base_embedding, str):
109
+ import json
110
+ try:
111
+ base_embedding = json.loads(base_embedding)
112
+ except:
113
+ raise HTTPException(400, f"El reporte {report_id} tiene un embedding inválido")
114
+
115
+ # Extraer datos del reporte base
116
+ base_type = base_report.get("type") # "lost" o "found"
117
+ base_species = base_report.get("species") # "dog", "cat", etc.
118
+
119
+ # Determinar el tipo opuesto para buscar
120
+ # Si el reporte base es "lost", buscar "found" y viceversa
121
+ target_type = "found" if base_type == "lost" else "lost"
122
+
123
+ # Log para debugging
124
+ print(f"🔍 [direct-match] Buscando coincidencias para reporte {report_id}")
125
+ print(f" Tipo base: {base_type}, buscando: {target_type}")
126
+ print(f" Especie: {base_species}")
127
+ print(f" Dimensiones embedding: {len(base_embedding)}")
128
+
129
+ # =========================
130
+ # Buscar candidatos
131
+ # =========================
132
+ # Obtener todos los reportes del tipo opuesto que tengan embeddings
133
+ candidates_result = sb.table("reports")\
134
+ .select("id, embedding, species, type, photos, pet_name, description, color, created_at")\
135
+ .eq("type", target_type)\
136
+ .eq("status", "active")\
137
+ .not_.is_("embedding", "null")\
138
+ .execute()
139
+
140
+ # Si no hay candidatos, retornar respuesta vacía
141
+ if not candidates_result.data:
142
+ return {
143
+ "report_id": report_id,
144
+ "matches": [],
145
+ "total_candidates": 0,
146
+ "message": f"No hay reportes de tipo '{target_type}' con embeddings"
147
+ }
148
+
149
+ candidates = candidates_result.data
150
+ print(f" Candidatos encontrados: {len(candidates)}")
151
+
152
+ # =========================
153
+ # Filtrar por especie
154
+ # =========================
155
+ # Filtrar candidatos por especie si el reporte base tiene especie
156
+ # Esto mejora la precisión de los matches
157
+ if base_species:
158
+ candidates = [c for c in candidates if c.get("species") == base_species]
159
+ print(f" Candidatos después de filtrar por especie: {len(candidates)}")
160
+
161
+ # =========================
162
+ # Calcular similitudes
163
+ # =========================
164
+ # Convertir embedding base a numpy array para cálculos vectoriales
165
+ base_vec = np.array(base_embedding, dtype=np.float32)
166
+ base_norm = np.linalg.norm(base_vec) # Norma del vector (para normalización)
167
+
168
+ # Calcular similitud con cada candidato
169
+ matches = []
170
+ for candidate in candidates:
171
+ # Evitar comparar consigo mismo
172
+ if candidate["id"] == report_id:
173
+ continue
174
+
175
+ candidate_embedding = candidate.get("embedding")
176
+ if not candidate_embedding:
177
+ continue
178
+
179
+ # Parsear si es string JSON
180
+ if isinstance(candidate_embedding, str):
181
+ import json
182
+ try:
183
+ candidate_embedding = json.loads(candidate_embedding)
184
+ except:
185
+ continue
186
+
187
+ try:
188
+ # Convertir embedding del candidato a numpy
189
+ candidate_vec = np.array(candidate_embedding, dtype=np.float32)
190
+
191
+ # Verificar que las dimensiones coincidan
192
+ if len(candidate_vec) != len(base_vec):
193
+ print(f" ⚠️ Dimensiones no coinciden: {len(candidate_vec)} vs {len(base_vec)}")
194
+ continue
195
+
196
+ # Calcular similitud coseno
197
+ candidate_norm = np.linalg.norm(candidate_vec)
198
+ similarity = float(np.dot(base_vec, candidate_vec) / (base_norm * candidate_norm))
199
+
200
+ # Filtrar por umbral
201
+ if similarity >= match_threshold:
202
+ matches.append({
203
+ "report_id": candidate["id"],
204
+ "similarity_score": round(similarity, 4),
205
+ "pet_name": candidate.get("pet_name"),
206
+ "species": candidate.get("species"),
207
+ "color": candidate.get("color"),
208
+ "type": candidate.get("type"),
209
+ "photo": (candidate.get("photos") or [None])[0] if isinstance(candidate.get("photos"), list) else None,
210
+ "description": candidate.get("description"),
211
+ "created_at": candidate.get("created_at")
212
+ })
213
+ except Exception as e:
214
+ print(f" ⚠️ Error calculando similitud con {candidate['id']}: {str(e)}")
215
+ continue
216
+
217
+ # Ordenar por similitud descendente
218
+ matches.sort(key=lambda x: x["similarity_score"], reverse=True)
219
+
220
+ # Limitar resultados
221
+ top_matches = matches[:top_k]
222
+
223
+ print(f" ✅ Encontradas {len(top_matches)} coincidencias (de {len(matches)} sobre umbral)")
224
+
225
+ # Guardar matches en la tabla matches
226
+ if top_matches:
227
+ await _save_matches_to_db(sb, report_id, base_type, top_matches)
228
+
229
+ return {
230
+ "report_id": report_id,
231
+ "matches": top_matches,
232
+ "total_candidates": len(candidates),
233
+ "total_above_threshold": len(matches),
234
+ "returned": len(top_matches),
235
+ "search_params": {
236
+ "match_threshold": match_threshold,
237
+ "top_k": top_k,
238
+ "base_type": base_type,
239
+ "target_type": target_type,
240
+ "species_filter": base_species
241
+ }
242
+ }
243
+
244
+ except HTTPException:
245
+ raise
246
+ except Exception as e:
247
+ print(f"❌ [direct-match] Error: {str(e)}")
248
+ import traceback
249
+ traceback.print_exc()
250
+ raise HTTPException(500, f"Error buscando coincidencias: {str(e)}")
251
+
252
+ async def _save_matches_to_db(sb: Client, report_id: str, report_type: str, matches: List[Dict[str, Any]]):
253
+ """Guarda los matches en la tabla matches"""
254
+ try:
255
+ saved_count = 0
256
+ for match in matches:
257
+ match_report_id = match["report_id"]
258
+ similarity_score = match["similarity_score"]
259
+
260
+ # Determinar qué es lost y qué es found
261
+ if report_type == "lost":
262
+ lost_report_id = report_id
263
+ found_report_id = match_report_id
264
+ else:
265
+ lost_report_id = match_report_id
266
+ found_report_id = report_id
267
+
268
+ # Verificar si ya existe este match
269
+ existing = sb.table("matches")\
270
+ .select("id, similarity_score")\
271
+ .eq("lost_report_id", lost_report_id)\
272
+ .eq("found_report_id", found_report_id)\
273
+ .execute()
274
+
275
+ if existing.data:
276
+ # Actualizar si la similitud es mayor
277
+ existing_match = existing.data[0]
278
+ if similarity_score > existing_match.get("similarity_score", 0):
279
+ sb.table("matches").update({
280
+ "similarity_score": similarity_score,
281
+ "matched_by": "ai_visual",
282
+ "status": "pending"
283
+ }).eq("id", existing_match["id"]).execute()
284
+ saved_count += 1
285
+ else:
286
+ # Crear nuevo match
287
+ sb.table("matches").insert({
288
+ "lost_report_id": lost_report_id,
289
+ "found_report_id": found_report_id,
290
+ "similarity_score": similarity_score,
291
+ "matched_by": "ai_visual",
292
+ "status": "pending"
293
+ }).execute()
294
+ saved_count += 1
295
+
296
+ print(f" 💾 Guardados {saved_count} matches en la base de datos")
297
+ except Exception as e:
298
+ print(f" ⚠️ Error guardando matches: {str(e)}")
299
+ # No lanzar excepción, solo loguear
300
+
routers/embeddings.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Embeddings (Legacy)
3
+ =============================
4
+
5
+ Este router maneja operaciones de embeddings usando conexión directa a PostgreSQL.
6
+ NOTA: Este router es legacy y puede estar en desuso. La funcionalidad principal
7
+ de embeddings está en embeddings_supabase.py que usa Supabase.
8
+
9
+ Funcionalidades:
10
+ - Indexar embeddings de reportes directamente en PostgreSQL
11
+ - Buscar imágenes similares usando búsqueda vectorial con pgvector
12
+ - Filtrar por ubicación geográfica (opcional)
13
+ - Crear matches automáticamente cuando se encuentra una coincidencia
14
+
15
+ Este router usa psycopg para conexión directa a PostgreSQL en lugar de Supabase.
16
+ """
17
+
18
+ # =========================
19
+ # Imports de FastAPI
20
+ # =========================
21
+ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
22
+
23
+ # =========================
24
+ # Imports de Python estándar
25
+ # =========================
26
+ import os # Para variables de entorno
27
+ import psycopg # Cliente PostgreSQL directo
28
+
29
+ # =========================
30
+ # Imports de tipos
31
+ # =========================
32
+ from typing import Optional # Para tipos opcionales
33
+
34
+ # =========================
35
+ # Imports de servicios
36
+ # =========================
37
+ from services.embeddings import image_bytes_to_vec # Generar embeddings de imágenes
38
+ from supabase import Client, create_client # Cliente de Supabase (no usado en este router)
39
+
40
+ # Crear el router con prefijo /embeddings
41
+ router = APIRouter(prefix="/embeddings", tags=["embeddings"])
42
+
43
+ def get_conn():
44
+ """
45
+ Obtiene una conexión directa a PostgreSQL usando psycopg.
46
+
47
+ Returns:
48
+ psycopg.Connection: Conexión a PostgreSQL con autocommit habilitado
49
+
50
+ Raises:
51
+ RuntimeError: Si DATABASE_URL no está configurada
52
+
53
+ Esta función usa conexión directa a PostgreSQL en lugar de Supabase.
54
+ Útil para operaciones que requieren acceso directo a pgvector.
55
+ """
56
+ dsn = os.getenv("DATABASE_URL")
57
+ if not dsn:
58
+ raise RuntimeError("DATABASE_URL no configurada")
59
+ return psycopg.connect(dsn, autocommit=True)
60
+
61
+ def get_supabase():
62
+ """
63
+ Obtiene un cliente de Supabase (no usado en este router, pero disponible).
64
+
65
+ Returns:
66
+ Client: Cliente de Supabase configurado
67
+
68
+ Raises:
69
+ RuntimeError: Si SUPABASE_URL o SUPABASE_SERVICE_KEY no están configuradas
70
+ """
71
+ url = os.getenv("SUPABASE_URL")
72
+ key = os.getenv("SUPABASE_SERVICE_KEY")
73
+ if not url or not key:
74
+ raise RuntimeError("SUPABASE_URL o SUPABASE_SERVICE_KEY no configuradas")
75
+ return create_client(url, key)
76
+
77
+ @router.post("/index/{report_id}")
78
+ async def index_report_embedding(report_id: str, file: UploadFile = File(...)):
79
+ """
80
+ Indexa un embedding para un reporte específico.
81
+
82
+ Este endpoint:
83
+ 1. Recibe una imagen del reporte
84
+ 2. Genera el embedding usando MegaDescriptor
85
+ 3. Actualiza el campo embedding en la tabla reports
86
+
87
+ Args:
88
+ report_id: ID del reporte (UUID)
89
+ file: Archivo de imagen a procesar
90
+
91
+ Returns:
92
+ dict: Estado de la operación con report_id y dimensiones del embedding
93
+
94
+ Raises:
95
+ HTTPException 400: Si no se puede procesar la imagen
96
+ HTTPException 404: Si el report_id no existe
97
+ """
98
+ try:
99
+ # Generar embedding de la imagen usando MegaDescriptor
100
+ vec = image_bytes_to_vec(await file.read())
101
+ except Exception as e:
102
+ raise HTTPException(400, f"No se pudo procesar la imagen: {e}")
103
+
104
+ # Actualizar el embedding en PostgreSQL directamente
105
+ with get_conn() as conn, conn.cursor() as cur:
106
+ cur.execute("""
107
+ update public.reports
108
+ set embedding = %s
109
+ where id = %s::uuid
110
+ """, (vec, report_id))
111
+ if cur.rowcount == 0:
112
+ raise HTTPException(404, "report_id no encontrado")
113
+ return {"status": "ok", "report_id": report_id, "dims": len(vec)}
114
+
115
+ @router.post("/search_image")
116
+ async def search_image(
117
+ file: UploadFile = File(...),
118
+ top_k: int = Query(10, ge=1, le=50),
119
+ lost_id: Optional[str] = Query(None),
120
+ lat: Optional[float] = Query(None),
121
+ lng: Optional[float] = Query(None),
122
+ max_km: Optional[float] = Query(None, description="Radio máximo en km")
123
+ ):
124
+ """
125
+ Busca reportes similares usando búsqueda vectorial por imagen.
126
+
127
+ Este endpoint:
128
+ 1. Genera un embedding de la imagen de búsqueda
129
+ 2. Busca reportes similares usando pgvector (operador <#> para cosine distance)
130
+ 3. Filtra por ubicación geográfica si se proporciona (opcional)
131
+ 4. Crea un match automático si se encuentra una coincidencia y se proporciona lost_id
132
+
133
+ Args:
134
+ file: Imagen de búsqueda
135
+ top_k: Número máximo de resultados (1-50, por defecto 10)
136
+ lost_id: ID del reporte perdido (opcional, para crear match automático)
137
+ lat: Latitud para filtrar por ubicación (opcional)
138
+ lng: Longitud para filtrar por ubicación (opcional)
139
+ max_km: Radio máximo en kilómetros para filtrar (opcional)
140
+
141
+ Returns:
142
+ dict: Lista de resultados con report_id, similarity_score, species, color, photo, labels
143
+
144
+ Raises:
145
+ HTTPException 400: Si no se puede procesar la imagen
146
+ """
147
+ try:
148
+ # Generar embedding de la imagen de búsqueda
149
+ qvec = image_bytes_to_vec(await file.read())
150
+ except Exception as e:
151
+ raise HTTPException(400, f"No se pudo procesar la imagen: {e}")
152
+
153
+ # Construir consulta SQL base para búsqueda vectorial
154
+ # Usa el operador <#> de pgvector para cosine distance
155
+ # (1 - cosine_distance) = similarity_score
156
+ base_sql = """
157
+ select r.id,
158
+ (1 - (r.embedding <#> %(qvec)s)) as similarity_score,
159
+ r.species, r.color, r.photos, r.labels
160
+ from public.reports r
161
+ """
162
+ where = ["r.embedding is not null"] # Solo reportes con embeddings
163
+ params = {"qvec": qvec, "qvec2": qvec, "top_k": top_k}
164
+
165
+ # Si se proporciona ubicación, agregar filtro geográfico usando Haversine
166
+ if lat is not None and lng is not None and max_km and max_km > 0:
167
+ base_sql += """
168
+ join public.reports_with_coords rc on rc.id = r.id
169
+ """
170
+ # Fórmula de Haversine en SQL para calcular distancia en kilómetros
171
+ # 6371 es el radio de la Tierra en km
172
+ where.append("""
173
+ ( 6371 * acos(
174
+ cos(radians(%(lat)s)) * cos(radians(rc.latitude))
175
+ * cos(radians(rc.longitude) - radians(%(lng)s))
176
+ + sin(radians(%(lat)s)) * sin(radians(rc.latitude))
177
+ ) ) <= %(max_km)s
178
+ """)
179
+ params.update({"lat": lat, "lng": lng, "max_km": max_km})
180
+
181
+ # Construir SQL final con ordenamiento por similitud
182
+ sql = f"""
183
+ {base_sql}
184
+ {" where " + " and ".join(where) if where else ""}
185
+ order by r.embedding <#> %(qvec2)s -- Ordenar por cosine distance (menor = más similar)
186
+ limit %(top_k)s
187
+ """
188
+
189
+ # Ejecutar búsqueda y procesar resultados
190
+ results = []
191
+ with get_conn() as conn, conn.cursor() as cur:
192
+ cur.execute(sql, params)
193
+ for rid, sim, species, color, photos, labels in cur.fetchall():
194
+ results.append({
195
+ "report_id": rid,
196
+ "similarity_score": float(sim) if sim is not None else None,
197
+ "species": species,
198
+ "color": color,
199
+ "photo": (photos or [None])[0] if isinstance(photos, list) else None, # Primera foto
200
+ "labels": labels
201
+ })
202
+
203
+ # Si hay resultados y se proporcionó lost_id, crear match automático
204
+ if results:
205
+ top1 = results[0]
206
+ cur.execute("""
207
+ insert into public.matches
208
+ (lost_report_id, found_report_id, similarity_score, matched_by, status)
209
+ values (%s::uuid, %s::uuid, %s, 'ai_visual', 'pending')
210
+ """, (lost_id if lost_id else None, top1["report_id"],
211
+ round(top1["similarity_score"], 4) if top1["similarity_score"] is not None else None))
212
+ return {"results": results}
routers/embeddings_supabase.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Embeddings para Supabase
3
+ ===================================
4
+
5
+ Este router maneja la generación y gestión de embeddings de imágenes
6
+ usando MegaDescriptor y almacenándolos en Supabase.
7
+
8
+ Funcionalidades:
9
+ - Generar embeddings de imágenes
10
+ - Indexar embeddings en reportes (guardar en base de datos)
11
+ - Buscar imágenes similares usando embeddings
12
+ - Filtrar por ubicación y otros criterios
13
+
14
+ Los embeddings se generan usando MegaDescriptor y se almacenan
15
+ en Supabase usando pgvector para búsquedas vectoriales eficientes.
16
+ """
17
+
18
+ # =========================
19
+ # Imports de FastAPI
20
+ # =========================
21
+ from fastapi import APIRouter, File, HTTPException, Query, UploadFile
22
+
23
+ # =========================
24
+ # Imports de Python estándar
25
+ # =========================
26
+ import os # Para variables de entorno
27
+
28
+ # =========================
29
+ # Imports de tipos
30
+ # =========================
31
+ from typing import Optional # Para tipos opcionales
32
+
33
+ # =========================
34
+ # Imports de servicios
35
+ # =========================
36
+ from services.embeddings import image_bytes_to_vec # Generar embeddings con MegaDescriptor
37
+
38
+ # =========================
39
+ # Imports de Supabase
40
+ # =========================
41
+ from supabase import Client, create_client # Cliente de Supabase
42
+
43
+ # Crear el router con prefijo /embeddings
44
+ router = APIRouter(prefix="/embeddings", tags=["embeddings"])
45
+
46
+ def get_supabase():
47
+ """
48
+ Crea un cliente de Supabase básico.
49
+
50
+ Returns:
51
+ Client: Cliente de Supabase
52
+
53
+ Raises:
54
+ RuntimeError: Si no se encuentran las credenciales en variables de entorno
55
+ """
56
+ url = os.getenv("SUPABASE_URL")
57
+ key = os.getenv("SUPABASE_SERVICE_KEY")
58
+ if not url or not key:
59
+ raise RuntimeError("SUPABASE_URL o SUPABASE_SERVICE_KEY no configuradas")
60
+ return create_client(url, key)
61
+
62
+ @router.post("/generate")
63
+ async def generate_embedding(file: UploadFile = File(...)):
64
+ """
65
+ Genera un embedding para una imagen usando MegaDescriptor.
66
+
67
+ Este endpoint:
68
+ 1. Recibe una imagen
69
+ 2. Genera el embedding usando MegaDescriptor-L-384
70
+ 3. Retorna el embedding como lista de números
71
+
72
+ Args:
73
+ file: Archivo de imagen a procesar
74
+
75
+ Returns:
76
+ dict: Embedding, dimensiones, modelo usado, nombre y tamaño del archivo
77
+
78
+ Raises:
79
+ HTTPException 400: Si el archivo está vacío
80
+ HTTPException 500: Si hay error generando el embedding
81
+ """
82
+ try:
83
+ image_bytes = await file.read()
84
+ if not image_bytes:
85
+ raise HTTPException(400, "Archivo vacío o no leído")
86
+
87
+ vec = image_bytes_to_vec(image_bytes)
88
+
89
+ return {
90
+ "embedding": vec.tolist(),
91
+ "dimensions": len(vec),
92
+ "model": "MegaDescriptor-L-384",
93
+ "file_name": file.filename,
94
+ "file_size": len(image_bytes)
95
+ }
96
+ except Exception as e:
97
+ raise HTTPException(500, f"Error generando embedding: {str(e)}")
98
+
99
+ @router.post("/index/{report_id}")
100
+ async def index_report_embedding(report_id: str, file: UploadFile = File(...)):
101
+ """
102
+ Indexa un embedding para un reporte específico en Supabase.
103
+
104
+ Este endpoint:
105
+ 1. Genera el embedding de la imagen usando MegaDescriptor
106
+ 2. Guarda el embedding en la tabla reports usando pgvector
107
+ 3. Actualiza el campo embedding del reporte
108
+
109
+ Args:
110
+ report_id: ID del reporte (UUID)
111
+ file: Archivo de imagen a procesar
112
+
113
+ Returns:
114
+ dict: Estado de la operación con report_id y dimensiones
115
+
116
+ Raises:
117
+ HTTPException 400: Si no se puede procesar la imagen
118
+ HTTPException 404: Si el report_id no existe
119
+ HTTPException 500: Si hay error actualizando el embedding
120
+ """
121
+ try:
122
+ # Generar embedding de la imagen usando MegaDescriptor
123
+ vec = image_bytes_to_vec(await file.read())
124
+ except Exception as e:
125
+ raise HTTPException(400, f"No se pudo procesar la imagen: {e}")
126
+
127
+ sb = get_supabase()
128
+ try:
129
+ # Convertir vector numpy a lista para Supabase
130
+ # Supabase necesita una lista de Python, no un numpy array
131
+ vec_list = vec.tolist()
132
+
133
+ # Usar función RPC de Supabase para actualizar el embedding
134
+ # Esta función SQL maneja la conversión correcta a tipo vector de pgvector
135
+ result = sb.rpc('update_report_embedding', {
136
+ 'report_id': report_id,
137
+ 'embedding_vector': vec_list
138
+ }).execute()
139
+
140
+ if not result.data:
141
+ raise HTTPException(404, "report_id no encontrado")
142
+
143
+ return {"status": "ok", "report_id": report_id, "dims": len(vec)}
144
+ except Exception as e:
145
+ raise HTTPException(500, f"Error actualizando embedding: {e}")
146
+
147
+ @router.post("/search_image")
148
+ async def search_image(
149
+ file: UploadFile = File(...),
150
+ top_k: int = Query(10, ge=1, le=50),
151
+ lost_id: Optional[str] = Query(None),
152
+ lat: Optional[float] = Query(None),
153
+ lng: Optional[float] = Query(None),
154
+ max_km: Optional[float] = Query(None, description="Radio máximo en km")
155
+ ):
156
+ """
157
+ Busca reportes similares usando búsqueda vectorial por imagen.
158
+
159
+ Este endpoint:
160
+ 1. Genera un embedding de la imagen de búsqueda usando MegaDescriptor
161
+ 2. Busca reportes con embeddings similares en Supabase
162
+ 3. Calcula similitud coseno entre embeddings
163
+ 4. Filtra por ubicación geográfica si se proporciona (opcional)
164
+ 5. Crea un match automático si se encuentra una coincidencia y se proporciona lost_id
165
+
166
+ Args:
167
+ file: Imagen de búsqueda
168
+ top_k: Número máximo de resultados (1-50, por defecto 10)
169
+ lost_id: ID del reporte perdido (opcional, para crear match automático)
170
+ lat: Latitud para filtrar por ubicación (opcional)
171
+ lng: Longitud para filtrar por ubicación (opcional)
172
+ max_km: Radio máximo en kilómetros para filtrar (opcional)
173
+
174
+ Returns:
175
+ dict: Lista de resultados con report_id, similarity_score, species, color, photo, labels
176
+
177
+ Raises:
178
+ HTTPException 400: Si no se puede procesar la imagen
179
+ HTTPException 500: Si hay error en la búsqueda
180
+ """
181
+ try:
182
+ # Generar embedding de la imagen de búsqueda usando MegaDescriptor
183
+ qvec = image_bytes_to_vec(await file.read())
184
+ except Exception as e:
185
+ raise HTTPException(400, f"No se pudo procesar la imagen: {e}")
186
+
187
+ sb = get_supabase()
188
+
189
+ # =========================
190
+ # Construir query base
191
+ # =========================
192
+ # Obtener todos los reportes que tengan embedding
193
+ query = sb.table("reports").select("id, species, color, photos, labels, embedding")
194
+
195
+ # Filtrar por embedding no nulo (solo reportes con embeddings)
196
+ query = query.not_.is_("embedding", "null")
197
+
198
+ # Aplicar filtro geográfico si se proporciona
199
+ # NOTA: El filtro geográfico completo requeriría una función RPC en Supabase
200
+ # Por ahora, obtenemos todos y podríamos filtrar después si es necesario
201
+ if lat is not None and lng is not None and max_km and max_km > 0:
202
+ # Para Supabase, necesitaríamos usar una función RPC o filtrar después
203
+ # Por ahora, obtenemos todos y filtramos después
204
+ pass
205
+
206
+ try:
207
+ result = query.execute()
208
+ reports = result.data
209
+
210
+ # =========================
211
+ # Calcular similitud para cada reporte
212
+ # =========================
213
+ results = []
214
+ import numpy as np # Para operaciones con vectores
215
+
216
+ for report in reports:
217
+ if not report.get("embedding"):
218
+ continue
219
+
220
+ # Convertir embedding de Supabase a numpy array
221
+ # Los embeddings vienen como listas desde Supabase/pgvector
222
+ report_vec = report["embedding"]
223
+ if isinstance(report_vec, list):
224
+ # Asegurar que todos los elementos son números válidos
225
+ try:
226
+ report_vec = np.array([float(x) for x in report_vec], dtype=np.float32)
227
+ except (ValueError, TypeError):
228
+ continue # Si hay error, saltar este reporte
229
+ else:
230
+ continue # Si no es una lista, saltar
231
+
232
+ # Calcular similitud coseno entre embeddings
233
+ # Similitud coseno = (A · B) / (||A|| * ||B||)
234
+ # Valores cercanos a 1 = muy similares, valores cercanos a 0 = diferentes
235
+ try:
236
+ similarity = np.dot(qvec, report_vec) / (np.linalg.norm(qvec) * np.linalg.norm(report_vec))
237
+ except:
238
+ continue # Si hay error en el cálculo, saltar este reporte
239
+
240
+ # Aplicar filtro geográfico si es necesario
241
+ # NOTA: Para filtrar por ubicación, necesitaríamos las coordenadas del reporte
242
+ # Por ahora, este filtro no está implementado completamente
243
+ if lat is not None and lng is not None and max_km and max_km > 0:
244
+ # Aquí necesitarías las coordenadas del reporte
245
+ # Por ahora, saltamos el filtro geográfico
246
+ pass
247
+
248
+ results.append({
249
+ "report_id": report["id"],
250
+ "similarity_score": float(similarity), # Convertir a float nativo de Python
251
+ "species": report.get("species"),
252
+ "color": report.get("color"),
253
+ "photo": (report.get("photos") or [None])[0] if isinstance(report.get("photos"), list) else None, # Primera foto
254
+ "labels": report.get("labels")
255
+ })
256
+
257
+ # Ordenar por similitud (mayor a menor) y tomar top_k
258
+ results.sort(key=lambda x: x["similarity_score"], reverse=True)
259
+ results = results[:top_k]
260
+
261
+ # Guardar top-1 en matches si hay resultados y se proporcionó lost_id
262
+ # Esto crea un match automático entre el reporte perdido y el encontrado más similar
263
+ if results and lost_id:
264
+ top1 = results[0]
265
+ try:
266
+ sb.table("matches").insert({
267
+ "lost_report_id": lost_id,
268
+ "found_report_id": top1["report_id"],
269
+ "similarity_score": round(top1["similarity_score"], 4), # Redondear a 4 decimales
270
+ "matched_by": "ai_visual", # Indica que fue encontrado usando embeddings de imágenes (MegaDescriptor)
271
+ "status": "pending" # Match pendiente de confirmación
272
+ }).execute()
273
+ except Exception as e:
274
+ print(f"Error guardando match: {e}") # Log del error pero no fallar la búsqueda
275
+
276
+ return {"results": results}
277
+
278
+ except Exception as e:
279
+ raise HTTPException(500, f"Error en búsqueda: {e}")
routers/fix_embeddings.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router para Regenerar Embeddings
3
+ =================================
4
+
5
+ Este router proporciona herramientas administrativas para regenerar embeddings
6
+ de reportes que no los tienen o que tienen embeddings incorrectos.
7
+
8
+ Funcionalidades:
9
+ - Regenerar embedding para un reporte específico
10
+ - Regenerar embeddings para múltiples reportes
11
+ - Regenerar embeddings para todos los reportes sin embedding
12
+ - Verificar estado de embeddings
13
+
14
+ Útil para:
15
+ - Migración de reportes antiguos que no tienen embeddings
16
+ - Corrección de embeddings corruptos
17
+ - Regeneración masiva después de actualizar el modelo
18
+ """
19
+
20
+ from fastapi import APIRouter, HTTPException
21
+ from typing import List, Dict
22
+ import os, sys
23
+ from pathlib import Path
24
+ from supabase import Client
25
+ import httpx # Para descargar imágenes desde URLs
26
+ from services.embeddings import image_bytes_to_vec # Función para generar embeddings
27
+
28
+ # Agregar la carpeta parent al path para poder importar utils
29
+ sys.path.insert(0, str(Path(__file__).parent.parent))
30
+ from utils.supabase_client import get_supabase_client
31
+
32
+ # Crear el router con prefijo /fix-embeddings
33
+ router = APIRouter(prefix="/fix-embeddings", tags=["fix-embeddings"])
34
+
35
+ def _sb() -> Client:
36
+ """
37
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
38
+
39
+ Returns:
40
+ Client: Cliente de Supabase configurado
41
+
42
+ Raises:
43
+ HTTPException: Si no se puede conectar a Supabase
44
+ """
45
+ try:
46
+ return get_supabase_client()
47
+ except Exception as e:
48
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
49
+
50
+ @router.post("/regenerate/{report_id}")
51
+ async def regenerate_embedding_for_report(report_id: str):
52
+ """
53
+ Regenera el embedding para un reporte específico que tiene foto pero no embedding.
54
+ """
55
+ try:
56
+ sb = _sb()
57
+
58
+ # Obtener el reporte
59
+ result = sb.table("reports")\
60
+ .select("id, photos, embedding")\
61
+ .eq("id", report_id)\
62
+ .single()\
63
+ .execute()
64
+
65
+ if not result.data:
66
+ raise HTTPException(404, f"Reporte {report_id} no encontrado")
67
+
68
+ report = result.data
69
+ photos = report.get("photos", [])
70
+
71
+ if not photos or len(photos) == 0:
72
+ raise HTTPException(400, f"El reporte {report_id} no tiene fotos")
73
+
74
+ first_photo = photos[0]
75
+
76
+ print(f"🔄 Generando embedding para reporte {report_id}")
77
+ print(f" Foto: {first_photo}")
78
+
79
+ # Descargar la imagen
80
+ async with httpx.AsyncClient(timeout=30.0) as client:
81
+ response = await client.get(first_photo)
82
+ response.raise_for_status()
83
+ image_bytes = response.content
84
+
85
+ # Generar embedding
86
+ vec = image_bytes_to_vec(image_bytes)
87
+ vec_list = vec.tolist()
88
+
89
+ print(f" Dimensiones del embedding: {len(vec_list)}")
90
+
91
+ # Guardar en Supabase
92
+ update_result = sb.table('reports').update({
93
+ 'embedding': vec_list
94
+ }).eq('id', report_id).execute()
95
+
96
+ if update_result.data:
97
+ print(f"✅ Embedding regenerado exitosamente para reporte {report_id}")
98
+ return {
99
+ "success": True,
100
+ "report_id": report_id,
101
+ "message": "Embedding regenerado exitosamente",
102
+ "embedding_dimensions": len(vec_list)
103
+ }
104
+ else:
105
+ raise HTTPException(500, "No se pudo guardar el embedding")
106
+
107
+ except HTTPException:
108
+ raise
109
+ except Exception as e:
110
+ print(f"❌ Error regenerando embedding: {str(e)}")
111
+ import traceback
112
+ traceback.print_exc()
113
+ raise HTTPException(500, f"Error regenerando embedding: {str(e)}")
114
+
115
+ @router.post("/regenerate-all")
116
+ async def regenerate_all_missing_embeddings():
117
+ """
118
+ Regenera embeddings para todos los reportes que tienen fotos pero no tienen embedding.
119
+ """
120
+ try:
121
+ sb = _sb()
122
+
123
+ # Obtener reportes sin embedding pero con fotos
124
+ result = sb.table("reports")\
125
+ .select("id, photos")\
126
+ .is_("embedding", "null")\
127
+ .execute()
128
+
129
+ if not result.data:
130
+ return {
131
+ "success": True,
132
+ "message": "No hay reportes sin embeddings",
133
+ "processed": 0
134
+ }
135
+
136
+ reports = result.data
137
+ reports_with_photos = [r for r in reports if r.get("photos") and len(r.get("photos", [])) > 0]
138
+
139
+ if not reports_with_photos:
140
+ return {
141
+ "success": True,
142
+ "message": "No hay reportes con fotos sin embeddings",
143
+ "processed": 0
144
+ }
145
+
146
+ print(f"🔄 Regenerando embeddings para {len(reports_with_photos)} reportes...")
147
+
148
+ success_count = 0
149
+ errors: List[Dict] = []
150
+
151
+ for report in reports_with_photos:
152
+ report_id = report["id"]
153
+ photos = report["photos"]
154
+ first_photo = photos[0]
155
+
156
+ try:
157
+ # Descargar la imagen
158
+ async with httpx.AsyncClient(timeout=30.0) as client:
159
+ response = await client.get(first_photo)
160
+ response.raise_for_status()
161
+ image_bytes = response.content
162
+
163
+ # Generar embedding
164
+ vec = image_bytes_to_vec(image_bytes)
165
+ vec_list = vec.tolist()
166
+
167
+ # Guardar en Supabase
168
+ sb.table('reports').update({
169
+ 'embedding': vec_list
170
+ }).eq('id', report_id).execute()
171
+
172
+ success_count += 1
173
+ print(f" ✅ {success_count}/{len(reports_with_photos)}: {report_id}")
174
+
175
+ except Exception as e:
176
+ error_msg = str(e)
177
+ errors.append({"report_id": report_id, "error": error_msg})
178
+ print(f" ❌ Error con {report_id}: {error_msg}")
179
+
180
+ return {
181
+ "success": True,
182
+ "message": f"Procesados {success_count} reportes exitosamente",
183
+ "total_processed": success_count,
184
+ "total_errors": len(errors),
185
+ "errors": errors[:10] # Mostrar solo los primeros 10 errores
186
+ }
187
+
188
+ except Exception as e:
189
+ print(f"❌ Error en regeneración masiva: {str(e)}")
190
+ import traceback
191
+ traceback.print_exc()
192
+ raise HTTPException(500, f"Error en regeneración masiva: {str(e)}")
193
+
194
+ @router.get("/check-missing")
195
+ async def check_missing_embeddings():
196
+ """
197
+ Verifica cuántos reportes no tienen embeddings.
198
+ """
199
+ try:
200
+ sb = _sb()
201
+
202
+ # Total de reportes
203
+ all_result = sb.table("reports")\
204
+ .select("id", count="exact")\
205
+ .execute()
206
+ total_reports = all_result.count or 0
207
+
208
+ # Reportes sin embedding
209
+ no_embedding_result = sb.table("reports")\
210
+ .select("id", count="exact")\
211
+ .is_("embedding", "null")\
212
+ .execute()
213
+ no_embedding_count = no_embedding_result.count or 0
214
+
215
+ # Reportes sin embedding pero con fotos
216
+ with_photos_result = sb.table("reports")\
217
+ .select("id, photos")\
218
+ .is_("embedding", "null")\
219
+ .execute()
220
+
221
+ reports_with_photos = 0
222
+ if with_photos_result.data:
223
+ reports_with_photos = len([
224
+ r for r in with_photos_result.data
225
+ if r.get("photos") and len(r.get("photos", [])) > 0
226
+ ])
227
+
228
+ return {
229
+ "total_reports": total_reports,
230
+ "reports_without_embedding": no_embedding_count,
231
+ "reports_with_photos_without_embedding": reports_with_photos,
232
+ "percentage_missing": round((no_embedding_count / total_reports * 100), 2) if total_reports > 0 else 0
233
+ }
234
+
235
+ except Exception as e:
236
+ raise HTTPException(500, f"Error verificando embeddings: {str(e)}")
237
+
routers/matches.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Matches (Coincidencias)
3
+ ==================================
4
+
5
+ Este router maneja todas las operaciones relacionadas con matches (coincidencias)
6
+ entre reportes de mascotas perdidas y encontradas.
7
+
8
+ Un match es una posible coincidencia entre:
9
+ - Un reporte de mascota perdida y un reporte de mascota encontrada, o viceversa
10
+ - Se calculan usando similitud visual (embeddings), ubicación geográfica, y etiquetas
11
+
12
+ Funcionalidades principales:
13
+ - Búsqueda automática de matches para un reporte
14
+ - Obtener matches pendientes de un usuario o reporte
15
+ - Aceptar o rechazar matches
16
+ - Calcular scores de similitud entre reportes
17
+ """
18
+
19
+ from fastapi import APIRouter, HTTPException, Query
20
+ from typing import List, Dict, Any, Optional
21
+ import os, math, sys
22
+ from pathlib import Path
23
+ from supabase import Client
24
+
25
+ # Agregar la carpeta parent al path para poder importar utils
26
+ sys.path.insert(0, str(Path(__file__).parent.parent))
27
+ from utils.supabase_client import get_supabase_client
28
+
29
+ # Crear el router con prefijo /matches
30
+ router = APIRouter(prefix="/matches", tags=["matches"])
31
+
32
+ def _sb() -> Client:
33
+ """
34
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
35
+
36
+ Returns:
37
+ Client: Cliente de Supabase configurado
38
+
39
+ Raises:
40
+ HTTPException: Si no se puede conectar a Supabase
41
+ """
42
+ try:
43
+ return get_supabase_client()
44
+ except Exception as e:
45
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
46
+
47
+ def _coords(loc: Optional[dict]) -> Optional[tuple]:
48
+ """
49
+ Extrae coordenadas de un objeto de ubicación en formato GeoJSON.
50
+
51
+ Args:
52
+ loc: Objeto GeoJSON con formato {"type":"Point","coordinates":[lon,lat]}
53
+
54
+ Returns:
55
+ Tupla (latitud, longitud) o None si no se pueden extraer
56
+ """
57
+ # GeoJSON usa formato {"type":"Point","coordinates":[lon,lat]}
58
+ if isinstance(loc, dict) and "coordinates" in loc:
59
+ lon, lat = loc["coordinates"]
60
+ return (lat, lon) # Retornar como (lat, lon) para consistencia
61
+ return None
62
+
63
+ def haversine_km(lat1, lon1, lat2, lon2):
64
+ """
65
+ Calcula la distancia en kilómetros entre dos puntos geográficos usando la fórmula de Haversine.
66
+
67
+ Args:
68
+ lat1, lon1: Latitud y longitud del primer punto (en grados)
69
+ lat2, lon2: Latitud y longitud del segundo punto (en grados)
70
+
71
+ Returns:
72
+ Distancia en kilómetros entre los dos puntos
73
+ """
74
+ R = 6371.0 # Radio de la Tierra en kilómetros
75
+ # Convertir diferencias a radianes
76
+ dlat = math.radians(lat2 - lat1)
77
+ dlon = math.radians(lon2 - lon1)
78
+ # Aplicar fórmula de Haversine
79
+ a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
80
+ return 2 * R * math.asin(math.sqrt(a))
81
+
82
+ def label_set(labels_json) -> set:
83
+ """
84
+ Convierte etiquetas JSON a un conjunto de strings normalizados.
85
+
86
+ Args:
87
+ labels_json: Objeto JSON con etiquetas (puede ser dict con "labels" o lista)
88
+
89
+ Returns:
90
+ Set de strings en minúsculas con las etiquetas
91
+ """
92
+ # Si no hay etiquetas, retornar set vacío
93
+ if not labels_json:
94
+ return set()
95
+
96
+ # Intentar obtener la lista de etiquetas
97
+ items = labels_json.get("labels") if isinstance(labels_json, dict) else None
98
+
99
+ # Si no es una lista válida, retornar set vacío
100
+ if not isinstance(items, list):
101
+ return set()
102
+
103
+ # Extraer etiquetas y normalizar a minúsculas
104
+ return {(it.get("label") or it.get("description") or "").lower() for it in items if it}
105
+
106
+ @router.get("/auto-match")
107
+ def auto_match(
108
+ report_id: str = Query(..., description="ID del reporte base para buscar matches"),
109
+ radius_km: float = Query(10.0, description="Radio de búsqueda en kilómetros"),
110
+ top_k: int = Query(5, description="Número máximo de matches a retornar")
111
+ ):
112
+ """
113
+ Busca automáticamente matches (coincidencias) para un reporte.
114
+
115
+ Este endpoint busca reportes del tipo opuesto (si es "lost" busca "found" y viceversa)
116
+ que estén dentro del radio especificado y tengan la misma especie.
117
+
118
+ El algoritmo de scoring considera:
119
+ - Overlap de etiquetas (más etiquetas en común = mayor score)
120
+ - Distancia geográfica (más cerca = mayor score)
121
+
122
+ Args:
123
+ report_id: ID del reporte base (debe existir en la base de datos)
124
+ radius_km: Radio máximo de búsqueda en kilómetros (default: 10km)
125
+ top_k: Número máximo de matches a retornar (default: 5)
126
+
127
+ Returns:
128
+ JSON con:
129
+ - report_id: ID del reporte base
130
+ - radius_km: Radio usado
131
+ - total_candidates: Total de candidatos encontrados
132
+ - top_k: Lista de los mejores matches ordenados por score
133
+ """
134
+ sb = _sb()
135
+
136
+ # =========================
137
+ # Obtener reporte base
138
+ # =========================
139
+ # Buscar el reporte base en la base de datos
140
+ base = sb.table("reports").select("*").eq("id", report_id).single().execute().data
141
+
142
+ # Verificar que el reporte existe
143
+ if not base:
144
+ raise HTTPException(404, "Reporte base no encontrado")
145
+
146
+ # =========================
147
+ # Extraer datos del reporte base
148
+ # =========================
149
+ # Extraer coordenadas de la ubicación
150
+ base_pt = _coords(base.get("location"))
151
+ if not base_pt:
152
+ raise HTTPException(400, "El reporte base no tiene location válido (GeoJSON Point)")
153
+
154
+ base_lat, base_lon = base_pt # Desempaquetar coordenadas
155
+ base_labels = label_set(base.get("labels")) # Extraer etiquetas como set
156
+
157
+ # Determinar el tipo opuesto de reporte a buscar
158
+ # Si el reporte base es "lost", buscar "found" y viceversa
159
+ target_type = "found" if base.get("type") == "lost" else "lost"
160
+
161
+ # =========================
162
+ # Buscar candidatos
163
+ # =========================
164
+ # Buscar reportes que:
165
+ # - Sean del tipo opuesto (lost/found)
166
+ # - Estén activos (status = "active")
167
+ # - Tengan la misma especie
168
+ candidates = sb.table("reports").select("*") \
169
+ .eq("type", target_type) \
170
+ .eq("status", "active") \
171
+ .eq("species", base.get("species")) \
172
+ .execute().data
173
+
174
+ # =========================
175
+ # Filtrar y calcular scores
176
+ # =========================
177
+ results: List[Dict[str, Any]] = []
178
+
179
+ # Aproximación rápida: 1 grado ≈ 111 km
180
+ # Usar esto para un filtro rápido antes de calcular la distancia exacta
181
+ lat_pad = radius_km / 111.0 # Padding en latitud
182
+ lon_pad = radius_km / 111.0 # Padding en longitud
183
+
184
+ # Iterar sobre cada candidato
185
+ for c in candidates:
186
+ # Extraer coordenadas del candidato
187
+ pt = _coords(c.get("location"))
188
+ if not pt:
189
+ continue # Saltar si no tiene coordenadas válidas
190
+
191
+ lat, lon = pt
192
+
193
+ # Filtro rápido: verificar si está dentro del rectángulo aproximado
194
+ # Esto evita calcular la distancia exacta para candidatos muy lejanos
195
+ if not (base_lat - lat_pad <= lat <= base_lat + lat_pad and
196
+ base_lon - lon_pad <= lon <= base_lon + lon_pad):
197
+ continue # Está fuera del área aproximada, saltar
198
+
199
+ # Calcular distancia exacta usando Haversine
200
+ d = haversine_km(base_lat, base_lon, lat, lon)
201
+
202
+ # Filtrar por radio exacto
203
+ if d > radius_km:
204
+ continue # Está fuera del radio, saltar
205
+
206
+ # =========================
207
+ # Calcular score
208
+ # =========================
209
+ # Calcular overlap de etiquetas (intersección de sets)
210
+ # Más etiquetas en común = mayor score
211
+ overlap = len(base_labels & label_set(c.get("labels")))
212
+
213
+ # Score = (overlap * 10) - (distancia * 0.2)
214
+ # - Cada etiqueta en común suma 10 puntos
215
+ # - Cada kilómetro resta 0.2 puntos
216
+ # Esto prioriza matches cercanos con muchas etiquetas en común
217
+ score = overlap * 10 - d * 0.2
218
+
219
+ # Agregar a resultados
220
+ results.append({
221
+ "candidate": {
222
+ "id": c["id"], # ID del candidato
223
+ "pet_name": c.get("pet_name"), # Nombre de la mascota
224
+ "species": c.get("species"), # Especie
225
+ "color": c.get("color"), # Color
226
+ "location": c.get("location"), # Ubicación
227
+ # Primera foto del array, o None si no hay fotos
228
+ "photo": (c.get("photos") or [None])[0] if isinstance(c.get("photos"), list) else None,
229
+ "labels": c.get("labels"), # Etiquetas
230
+ },
231
+ "distance_km": round(d, 2), # Distancia redondeada a 2 decimales
232
+ "label_overlap": overlap, # Número de etiquetas en común
233
+ "score": round(score, 3) # Score redondeado a 3 decimales
234
+ })
235
+
236
+ # =========================
237
+ # Ordenar y retornar
238
+ # =========================
239
+ # Ordenar por score descendente (mejores matches primero)
240
+ results.sort(key=lambda x: x["score"], reverse=True)
241
+
242
+ # Retornar solo los top_k mejores matches
243
+ return {
244
+ "report_id": report_id,
245
+ "radius_km": radius_km,
246
+ "total_candidates": len(results),
247
+ "top_k": results[:top_k] # Retornar solo los mejores
248
+ }
249
+
250
+
251
+ @router.get("/pending")
252
+ async def get_pending_matches(
253
+ user_id: Optional[str] = Query(None, description="ID del usuario para filtrar matches de sus reportes"),
254
+ report_id: Optional[str] = Query(None, description="ID del reporte específico para obtener sus matches"),
255
+ status: str = Query("pending", description="Estado de los matches (pending, accepted, rejected)")
256
+ ):
257
+ """
258
+ Obtiene matches pendientes (o con otro estado) para un usuario o un reporte específico.
259
+
260
+ Este endpoint permite obtener matches de dos formas:
261
+ 1. Por usuario: Retorna todos los matches de todos los reportes del usuario
262
+ 2. Por reporte: Retorna todos los matches de un reporte específico
263
+
264
+ Un match puede estar en tres estados:
265
+ - pending: Match encontrado pero aún no aceptado/rechazado
266
+ - accepted: Match aceptado por el usuario
267
+ - rejected: Match rechazado por el usuario
268
+
269
+ Args:
270
+ user_id: ID del usuario (opcional, filtra por reportes del usuario)
271
+ report_id: ID del reporte (opcional, filtra por matches de ese reporte)
272
+ status: Estado de los matches a buscar (default: "pending")
273
+
274
+ Returns:
275
+ Lista de matches con información completa de ambos reportes
276
+ """
277
+ try:
278
+ sb = _sb()
279
+
280
+ if report_id:
281
+ # =========================
282
+ # Obtener matches de un reporte específico
283
+ # =========================
284
+ # Un match puede tener el reporte como lost_report_id o found_report_id
285
+ # Necesitamos buscar en ambas columnas
286
+ matches_lost = sb.table("matches").select("*").eq("lost_report_id", report_id).eq("status", status).execute()
287
+ matches_found = sb.table("matches").select("*").eq("found_report_id", report_id).eq("status", status).execute()
288
+
289
+ all_matches = (matches_lost.data or []) + (matches_found.data or [])
290
+
291
+ # Enriquecer con información de los reportes relacionados
292
+ enriched_matches = []
293
+ for match in all_matches:
294
+ lost_id = match.get("lost_report_id")
295
+ found_id = match.get("found_report_id")
296
+
297
+ # Obtener reporte perdido
298
+ lost_report = None
299
+ if lost_id:
300
+ lost_result = sb.table("reports").select("id, type, pet_name, species, photos, description, location, created_at").eq("id", lost_id).single().execute()
301
+ lost_report = lost_result.data if lost_result.data else None
302
+
303
+ # Obtener reporte encontrado
304
+ found_report = None
305
+ if found_id:
306
+ found_result = sb.table("reports").select("id, type, pet_name, species, photos, description, location, created_at").eq("id", found_id).single().execute()
307
+ found_report = found_result.data if found_result.data else None
308
+
309
+ enriched_matches.append({
310
+ "match_id": match.get("id"),
311
+ "similarity_score": match.get("similarity_score"),
312
+ "matched_by": match.get("matched_by"),
313
+ "status": match.get("status"),
314
+ "created_at": match.get("created_at"),
315
+ "lost_report": lost_report,
316
+ "found_report": found_report
317
+ })
318
+
319
+ return {
320
+ "matches": enriched_matches,
321
+ "count": len(enriched_matches),
322
+ "report_id": report_id
323
+ }
324
+
325
+ elif user_id:
326
+ # Obtener matches de todos los reportes del usuario
327
+ # Primero obtener todos los reportes del usuario
328
+ user_reports = sb.table("reports").select("id").eq("reporter_id", user_id).execute()
329
+ report_ids = [r["id"] for r in (user_reports.data or [])]
330
+
331
+ if not report_ids:
332
+ return {"matches": [], "count": 0, "user_id": user_id}
333
+
334
+ # Obtener matches donde los reportes del usuario están involucrados
335
+ matches_lost = sb.table("matches").select("*").in_("lost_report_id", report_ids).eq("status", status).execute()
336
+ matches_found = sb.table("matches").select("*").in_("found_report_id", report_ids).eq("status", status).execute()
337
+
338
+ all_matches = (matches_lost.data or []) + (matches_found.data or [])
339
+
340
+ # Enriquecer con información de los reportes (similar al caso anterior)
341
+ enriched_matches = []
342
+ for match in all_matches:
343
+ lost_id = match.get("lost_report_id")
344
+ found_id = match.get("found_report_id")
345
+
346
+ lost_report = None
347
+ if lost_id:
348
+ lost_result = sb.table("reports").select("id, type, pet_name, species, photos, description, location, created_at").eq("id", lost_id).single().execute()
349
+ lost_report = lost_result.data if lost_result.data else None
350
+
351
+ found_report = None
352
+ if found_id:
353
+ found_result = sb.table("reports").select("id, type, pet_name, species, photos, description, location, created_at").eq("id", found_id).single().execute()
354
+ found_report = found_result.data if found_result.data else None
355
+
356
+ enriched_matches.append({
357
+ "match_id": match.get("id"),
358
+ "similarity_score": match.get("similarity_score"),
359
+ "matched_by": match.get("matched_by"),
360
+ "status": match.get("status"),
361
+ "created_at": match.get("created_at"),
362
+ "lost_report": lost_report,
363
+ "found_report": found_report
364
+ })
365
+
366
+ return {
367
+ "matches": enriched_matches,
368
+ "count": len(enriched_matches),
369
+ "user_id": user_id
370
+ }
371
+ else:
372
+ raise HTTPException(400, "Debe proporcionar user_id o report_id")
373
+
374
+ except HTTPException:
375
+ raise
376
+ except Exception as e:
377
+ raise HTTPException(500, f"Error obteniendo matches pendientes: {str(e)}")
378
+
379
+
380
+ @router.put("/{match_id}/status")
381
+ async def update_match_status(
382
+ match_id: str,
383
+ status: str = Query(..., description="Nuevo estado del match (accepted, rejected)")
384
+ ):
385
+ """
386
+ Actualiza el estado de un match (aceptado o rechazado).
387
+ """
388
+ try:
389
+ if status not in ["accepted", "rejected"]:
390
+ raise HTTPException(400, "El estado debe ser 'accepted' o 'rejected'")
391
+
392
+ sb = _sb()
393
+
394
+ result = sb.table("matches").update({"status": status}).eq("id", match_id).execute()
395
+
396
+ if not result.data or len(result.data) == 0:
397
+ raise HTTPException(404, f"Match {match_id} no encontrado")
398
+
399
+ return {
400
+ "success": True,
401
+ "match_id": match_id,
402
+ "status": status,
403
+ "message": f"Match {status} exitosamente"
404
+ }
405
+
406
+ except HTTPException:
407
+ raise
408
+ except Exception as e:
409
+ raise HTTPException(500, f"Error actualizando estado del match: {str(e)}")
routers/pets.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Mascotas
3
+ ==================
4
+
5
+ Este router maneja todas las operaciones relacionadas con las mascotas de los usuarios.
6
+ Permite gestionar el perfil de las mascotas, incluyendo información de salud,
7
+ vacunaciones, medicamentos, recordatorios y eventos de salud.
8
+
9
+ Funcionalidades principales:
10
+ - CRUD de mascotas (crear, leer, actualizar, eliminar)
11
+ - Gestión de salud (vacunaciones, medicamentos, recordatorios)
12
+ - Eventos de salud (wellness, health events)
13
+ - Resumen de salud de la mascota
14
+ """
15
+
16
+ from fastapi import APIRouter, HTTPException, Query, Body
17
+ from typing import List, Dict, Any, Optional
18
+ from datetime import date, datetime
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ # Agregar la carpeta parent al path para poder importar utils
23
+ sys.path.insert(0, str(Path(__file__).parent.parent))
24
+ from utils.supabase_client import get_supabase_client
25
+
26
+ # Crear el router con prefijo /pets
27
+ router = APIRouter(prefix="/pets", tags=["pets"])
28
+
29
+ def _sb():
30
+ """
31
+ Crea un cliente de Supabase con configuración optimizada.
32
+
33
+ Returns:
34
+ Client: Cliente de Supabase configurado
35
+
36
+ Raises:
37
+ HTTPException: Si no se puede conectar a Supabase
38
+ """
39
+ try:
40
+ return get_supabase_client()
41
+ except Exception as e:
42
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
43
+
44
+ # ==============================================
45
+ # ENDPOINTS BÁSICOS DE MASCOTAS
46
+ # ==============================================
47
+
48
+ @router.get("/")
49
+ async def get_user_pets(owner_id: str = Query(..., description="ID del dueño")):
50
+ """
51
+ Obtiene todas las mascotas de un usuario.
52
+
53
+ Args:
54
+ owner_id: ID del usuario dueño de las mascotas
55
+
56
+ Returns:
57
+ JSON con:
58
+ - pets: Lista de mascotas del usuario
59
+ - count: Número total de mascotas
60
+
61
+ Las mascotas se ordenan por fecha de creación (más recientes primero).
62
+ """
63
+ try:
64
+ sb = _sb()
65
+ # Consultar todas las mascotas del usuario
66
+ result = sb.table("pets")\
67
+ .select("*")\
68
+ .eq("owner_id", owner_id)\
69
+ .order("created_at", desc=True)\
70
+ .execute()
71
+ return {"pets": result.data, "count": len(result.data)}
72
+ except Exception as e:
73
+ raise HTTPException(500, f"Error obteniendo mascotas: {str(e)}")
74
+
75
+ @router.get("/{pet_id}")
76
+ async def get_pet_by_id(pet_id: str):
77
+ """Obtiene una mascota por ID con su resumen de salud"""
78
+ try:
79
+ sb = _sb()
80
+
81
+ # Obtener la mascota
82
+ pet_result = sb.table("pets").select("*").eq("id", pet_id).execute()
83
+ if not pet_result.data:
84
+ raise HTTPException(404, "Mascota no encontrada")
85
+
86
+ pet = pet_result.data[0]
87
+
88
+ # Obtener resumen de salud usando la función SQL
89
+ try:
90
+ health_summary = sb.rpc("obtener_resumen_salud_mascota", {"pet_id": pet_id}).execute()
91
+ pet["health_summary"] = health_summary.data[0] if health_summary.data else {}
92
+ except:
93
+ pet["health_summary"] = {}
94
+
95
+ return {"pet": pet}
96
+ except HTTPException:
97
+ raise
98
+ except Exception as e:
99
+ raise HTTPException(500, f"Error obteniendo mascota: {str(e)}")
100
+
101
+ @router.post("/")
102
+ async def create_pet(pet_data: Dict[str, Any] = Body(...)):
103
+ """Crea una nueva mascota"""
104
+ try:
105
+ sb = _sb()
106
+
107
+ required_fields = ["owner_id", "name", "species"]
108
+ for field in required_fields:
109
+ if field not in pet_data:
110
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
111
+
112
+ result = sb.table("pets").insert(pet_data).execute()
113
+
114
+ if not result.data:
115
+ raise HTTPException(500, "Error creando mascota")
116
+
117
+ return {"pet": result.data[0], "message": "Mascota creada exitosamente"}
118
+ except HTTPException:
119
+ raise
120
+ except Exception as e:
121
+ raise HTTPException(500, f"Error creando mascota: {str(e)}")
122
+
123
+ @router.put("/{pet_id}")
124
+ async def update_pet(pet_id: str, updates: Dict[str, Any] = Body(...)):
125
+ """Actualiza una mascota existente"""
126
+ try:
127
+ sb = _sb()
128
+ result = sb.table("pets").update(updates).eq("id", pet_id).execute()
129
+
130
+ if not result.data:
131
+ raise HTTPException(404, "Mascota no encontrada")
132
+
133
+ return {"pet": result.data[0], "message": "Mascota actualizada exitosamente"}
134
+ except HTTPException:
135
+ raise
136
+ except Exception as e:
137
+ raise HTTPException(500, f"Error actualizando mascota: {str(e)}")
138
+
139
+ @router.delete("/{pet_id}")
140
+ async def delete_pet(pet_id: str):
141
+ """Elimina una mascota (y todos sus datos relacionados por CASCADE)"""
142
+ try:
143
+ sb = _sb()
144
+ result = sb.table("pets").delete().eq("id", pet_id).execute()
145
+ return {"message": "Mascota eliminada exitosamente"}
146
+ except Exception as e:
147
+ raise HTTPException(500, f"Error eliminando mascota: {str(e)}")
148
+
149
+ # ==============================================
150
+ # ENDPOINTS DE HISTORIAL DE SALUD
151
+ # ==============================================
152
+
153
+ @router.get("/{pet_id}/health-history")
154
+ async def get_health_history(
155
+ pet_id: str,
156
+ limit: int = Query(50, description="Límite de resultados"),
157
+ offset: int = Query(0, description="Offset para paginación")
158
+ ):
159
+ """Obtiene el historial de salud de una mascota"""
160
+ try:
161
+ sb = _sb()
162
+ result = sb.table("historial_salud")\
163
+ .select("*")\
164
+ .eq("id_mascota", pet_id)\
165
+ .order("fecha", desc=True)\
166
+ .limit(limit)\
167
+ .offset(offset)\
168
+ .execute()
169
+ return {"history": result.data, "count": len(result.data)}
170
+ except Exception as e:
171
+ raise HTTPException(500, f"Error obteniendo historial: {str(e)}")
172
+
173
+ @router.post("/{pet_id}/health-history")
174
+ async def add_health_event(
175
+ pet_id: str,
176
+ event_data: Dict[str, Any] = Body(...)
177
+ ):
178
+ """Agrega un evento al historial de salud"""
179
+ try:
180
+ sb = _sb()
181
+ event_data["id_mascota"] = pet_id
182
+
183
+ required_fields = ["tipo_evento", "descripcion"]
184
+ for field in required_fields:
185
+ if field not in event_data:
186
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
187
+
188
+ result = sb.table("historial_salud").insert(event_data).execute()
189
+
190
+ if not result.data:
191
+ raise HTTPException(500, "Error creando evento de salud")
192
+
193
+ return {"event": result.data[0], "message": "Evento agregado exitosamente"}
194
+ except HTTPException:
195
+ raise
196
+ except Exception as e:
197
+ raise HTTPException(500, f"Error agregando evento: {str(e)}")
198
+
199
+ # ==============================================
200
+ # ENDPOINTS DE VACUNACIONES Y TRATAMIENTOS
201
+ # ==============================================
202
+
203
+ @router.get("/{pet_id}/vaccinations")
204
+ async def get_vaccinations(pet_id: str):
205
+ """Obtiene todas las vacunaciones y tratamientos de una mascota"""
206
+ try:
207
+ sb = _sb()
208
+ result = sb.table("vacunacion_tratamiento")\
209
+ .select("*")\
210
+ .eq("id_mascota", pet_id)\
211
+ .order("fecha_inicio", desc=True)\
212
+ .execute()
213
+ return {"vaccinations": result.data, "count": len(result.data)}
214
+ except Exception as e:
215
+ raise HTTPException(500, f"Error obteniendo vacunaciones: {str(e)}")
216
+
217
+ @router.post("/{pet_id}/vaccinations")
218
+ async def add_vaccination(
219
+ pet_id: str,
220
+ vaccination_data: Dict[str, Any] = Body(...)
221
+ ):
222
+ """Agrega una vacunación o tratamiento"""
223
+ try:
224
+ sb = _sb()
225
+ vaccination_data["id_mascota"] = pet_id
226
+
227
+ required_fields = ["tipo", "nombre", "fecha_inicio"]
228
+ for field in required_fields:
229
+ if field not in vaccination_data:
230
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
231
+
232
+ result = sb.table("vacunacion_tratamiento").insert(vaccination_data).execute()
233
+
234
+ if not result.data:
235
+ raise HTTPException(500, "Error creando vacunación")
236
+
237
+ return {"vaccination": result.data[0], "message": "Vacunación agregada exitosamente"}
238
+ except HTTPException:
239
+ raise
240
+ except Exception as e:
241
+ raise HTTPException(500, f"Error agregando vacunación: {str(e)}")
242
+
243
+ @router.put("/vaccinations/{vaccination_id}")
244
+ async def update_vaccination(
245
+ vaccination_id: str,
246
+ updates: Dict[str, Any] = Body(...)
247
+ ):
248
+ """Actualiza una vacunación existente"""
249
+ try:
250
+ sb = _sb()
251
+ result = sb.table("vacunacion_tratamiento")\
252
+ .update(updates)\
253
+ .eq("id", vaccination_id)\
254
+ .execute()
255
+
256
+ if not result.data:
257
+ raise HTTPException(404, "Vacunación no encontrada")
258
+
259
+ return {"vaccination": result.data[0], "message": "Vacunación actualizada exitosamente"}
260
+ except HTTPException:
261
+ raise
262
+ except Exception as e:
263
+ raise HTTPException(500, f"Error actualizando vacunación: {str(e)}")
264
+
265
+ # ==============================================
266
+ # ENDPOINTS DE MEDICAMENTOS
267
+ # ==============================================
268
+
269
+ @router.get("/{pet_id}/medications")
270
+ async def get_medications(pet_id: str, active_only: bool = Query(False)):
271
+ """Obtiene los medicamentos de una mascota"""
272
+ try:
273
+ sb = _sb()
274
+ query = sb.table("medicamentos_activos")\
275
+ .select("*")\
276
+ .eq("id_mascota", pet_id)
277
+
278
+ if active_only:
279
+ query = query.eq("activo", True)
280
+
281
+ result = query.order("fecha_inicio", desc=True).execute()
282
+ return {"medications": result.data, "count": len(result.data)}
283
+ except Exception as e:
284
+ raise HTTPException(500, f"Error obteniendo medicamentos: {str(e)}")
285
+
286
+ @router.post("/{pet_id}/medications")
287
+ async def add_medication(
288
+ pet_id: str,
289
+ medication_data: Dict[str, Any] = Body(...)
290
+ ):
291
+ """Agrega un medicamento activo"""
292
+ try:
293
+ sb = _sb()
294
+ medication_data["id_mascota"] = pet_id
295
+
296
+ required_fields = ["nombre", "dosis", "frecuencia", "fecha_inicio"]
297
+ for field in required_fields:
298
+ if field not in medication_data:
299
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
300
+
301
+ result = sb.table("medicamentos_activos").insert(medication_data).execute()
302
+
303
+ if not result.data:
304
+ raise HTTPException(500, "Error creando medicamento")
305
+
306
+ return {"medication": result.data[0], "message": "Medicamento agregado exitosamente"}
307
+ except HTTPException:
308
+ raise
309
+ except Exception as e:
310
+ raise HTTPException(500, f"Error agregando medicamento: {str(e)}")
311
+
312
+ @router.put("/medications/{medication_id}")
313
+ async def update_medication(
314
+ medication_id: str,
315
+ updates: Dict[str, Any] = Body(...)
316
+ ):
317
+ """Actualiza un medicamento (puede desactivarlo)"""
318
+ try:
319
+ sb = _sb()
320
+ result = sb.table("medicamentos_activos")\
321
+ .update(updates)\
322
+ .eq("id", medication_id)\
323
+ .execute()
324
+
325
+ if not result.data:
326
+ raise HTTPException(404, "Medicamento no encontrado")
327
+
328
+ return {"medication": result.data[0], "message": "Medicamento actualizado exitosamente"}
329
+ except HTTPException:
330
+ raise
331
+ except Exception as e:
332
+ raise HTTPException(500, f"Error actualizando medicamento: {str(e)}")
333
+
334
+ # ==============================================
335
+ # ENDPOINTS DE INDICADORES DE BIENESTAR
336
+ # ==============================================
337
+
338
+ @router.get("/{pet_id}/wellness")
339
+ async def get_wellness_indicators(
340
+ pet_id: str,
341
+ limit: int = Query(30, description="Límite de resultados (últimos N registros)")
342
+ ):
343
+ """Obtiene los indicadores de bienestar de una mascota"""
344
+ try:
345
+ sb = _sb()
346
+ result = sb.table("indicador_bienestar")\
347
+ .select("*")\
348
+ .eq("id_mascota", pet_id)\
349
+ .order("fecha", desc=True)\
350
+ .limit(limit)\
351
+ .execute()
352
+ return {"indicators": result.data, "count": len(result.data)}
353
+ except Exception as e:
354
+ raise HTTPException(500, f"Error obteniendo indicadores: {str(e)}")
355
+
356
+ @router.post("/{pet_id}/wellness")
357
+ async def add_wellness_indicator(
358
+ pet_id: str,
359
+ indicator_data: Dict[str, Any] = Body(...)
360
+ ):
361
+ """Agrega un indicador de bienestar (peso, actividad, etc.)"""
362
+ try:
363
+ sb = _sb()
364
+ indicator_data["id_mascota"] = pet_id
365
+
366
+ result = sb.table("indicador_bienestar").insert(indicator_data).execute()
367
+
368
+ if not result.data:
369
+ raise HTTPException(500, "Error creando indicador")
370
+
371
+ return {"indicator": result.data[0], "message": "Indicador agregado exitosamente"}
372
+ except Exception as e:
373
+ raise HTTPException(500, f"Error agregando indicador: {str(e)}")
374
+
375
+ # ==============================================
376
+ # ENDPOINTS DE RECORDATORIOS
377
+ # ==============================================
378
+
379
+ @router.get("/{pet_id}/reminders")
380
+ async def get_reminders(
381
+ pet_id: str,
382
+ active_only: bool = Query(True),
383
+ upcoming_only: bool = Query(False)
384
+ ):
385
+ """Obtiene los recordatorios de una mascota"""
386
+ try:
387
+ sb = _sb()
388
+ query = sb.table("recordatorio")\
389
+ .select("*")\
390
+ .eq("id_mascota", pet_id)
391
+
392
+ if active_only:
393
+ query = query.eq("activo", True)
394
+
395
+ if upcoming_only:
396
+ query = query.gte("fecha_programada", date.today().isoformat())
397
+
398
+ result = query.order("fecha_programada", desc=False).execute()
399
+ return {"reminders": result.data, "count": len(result.data)}
400
+ except Exception as e:
401
+ raise HTTPException(500, f"Error obteniendo recordatorios: {str(e)}")
402
+
403
+ @router.post("/{pet_id}/reminders")
404
+ async def create_reminder(
405
+ pet_id: str,
406
+ reminder_data: Dict[str, Any] = Body(...)
407
+ ):
408
+ """Crea un nuevo recordatorio"""
409
+ try:
410
+ sb = _sb()
411
+ reminder_data["id_mascota"] = pet_id
412
+
413
+ required_fields = ["tipo", "titulo", "fecha_programada"]
414
+ for field in required_fields:
415
+ if field not in reminder_data:
416
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
417
+
418
+ result = sb.table("recordatorio").insert(reminder_data).execute()
419
+
420
+ if not result.data:
421
+ raise HTTPException(500, "Error creando recordatorio")
422
+
423
+ return {"reminder": result.data[0], "message": "Recordatorio creado exitosamente"}
424
+ except HTTPException:
425
+ raise
426
+ except Exception as e:
427
+ raise HTTPException(500, f"Error creando recordatorio: {str(e)}")
428
+
429
+ @router.put("/reminders/{reminder_id}/complete")
430
+ async def complete_reminder(reminder_id: str):
431
+ """Marca un recordatorio como cumplido"""
432
+ try:
433
+ sb = _sb()
434
+ result = sb.table("recordatorio")\
435
+ .update({
436
+ "cumplido": True,
437
+ "fecha_cumplido": datetime.now().isoformat()
438
+ })\
439
+ .eq("id", reminder_id)\
440
+ .execute()
441
+
442
+ if not result.data:
443
+ raise HTTPException(404, "Recordatorio no encontrado")
444
+
445
+ return {"reminder": result.data[0], "message": "Recordatorio marcado como cumplido"}
446
+ except HTTPException:
447
+ raise
448
+ except Exception as e:
449
+ raise HTTPException(500, f"Error actualizando recordatorio: {str(e)}")
450
+
451
+ # ==============================================
452
+ # ENDPOINTS DE DOCUMENTOS MÉDICOS
453
+ # ==============================================
454
+
455
+ @router.get("/{pet_id}/documents")
456
+ async def get_medical_documents(pet_id: str):
457
+ """Obtiene todos los documentos médicos de una mascota"""
458
+ try:
459
+ sb = _sb()
460
+ result = sb.table("documento_medico")\
461
+ .select("*")\
462
+ .eq("id_mascota", pet_id)\
463
+ .order("fecha_documento", desc=True)\
464
+ .execute()
465
+ return {"documents": result.data, "count": len(result.data)}
466
+ except Exception as e:
467
+ raise HTTPException(500, f"Error obteniendo documentos: {str(e)}")
468
+
469
+ @router.post("/{pet_id}/documents")
470
+ async def add_medical_document(
471
+ pet_id: str,
472
+ document_data: Dict[str, Any] = Body(...)
473
+ ):
474
+ """Agrega un documento médico"""
475
+ try:
476
+ sb = _sb()
477
+ document_data["id_mascota"] = pet_id
478
+
479
+ required_fields = ["tipo_documento", "nombre", "archivo_url"]
480
+ for field in required_fields:
481
+ if field not in document_data:
482
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
483
+
484
+ result = sb.table("documento_medico").insert(document_data).execute()
485
+
486
+ if not result.data:
487
+ raise HTTPException(500, "Error creando documento")
488
+
489
+ return {"document": result.data[0], "message": "Documento agregado exitosamente"}
490
+ except HTTPException:
491
+ raise
492
+ except Exception as e:
493
+ raise HTTPException(500, f"Error agregando documento: {str(e)}")
494
+
495
+ # ==============================================
496
+ # ENDPOINTS DE PLANES DE CUIDADO
497
+ # ==============================================
498
+
499
+ @router.get("/{pet_id}/care-plans")
500
+ async def get_care_plans(pet_id: str, active_only: bool = Query(False)):
501
+ """Obtiene los planes de cuidado de una mascota"""
502
+ try:
503
+ sb = _sb()
504
+ query = sb.table("plan_cuidado")\
505
+ .select("*, checklist_cuidado(*)")\
506
+ .eq("id_mascota", pet_id)
507
+
508
+ if active_only:
509
+ query = query.eq("activo", True)
510
+
511
+ result = query.order("fecha_inicio", desc=True).execute()
512
+ return {"care_plans": result.data, "count": len(result.data)}
513
+ except Exception as e:
514
+ raise HTTPException(500, f"Error obteniendo planes de cuidado: {str(e)}")
515
+
516
+ @router.post("/{pet_id}/care-plans")
517
+ async def create_care_plan(
518
+ pet_id: str,
519
+ plan_data: Dict[str, Any] = Body(...)
520
+ ):
521
+ """Crea un nuevo plan de cuidado"""
522
+ try:
523
+ sb = _sb()
524
+ plan_data["id_mascota"] = pet_id
525
+
526
+ required_fields = ["nombre", "fecha_inicio"]
527
+ for field in required_fields:
528
+ if field not in plan_data:
529
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
530
+
531
+ # Extraer checklist si existe
532
+ checklist_items = plan_data.pop("checklist", [])
533
+
534
+ result = sb.table("plan_cuidado").insert(plan_data).execute()
535
+
536
+ if not result.data:
537
+ raise HTTPException(500, "Error creando plan de cuidado")
538
+
539
+ plan_id = result.data[0]["id"]
540
+
541
+ # Agregar items del checklist si existen
542
+ if checklist_items:
543
+ checklist_data = [
544
+ {**item, "id_plan": plan_id} for item in checklist_items
545
+ ]
546
+ sb.table("checklist_cuidado").insert(checklist_data).execute()
547
+
548
+ return {"care_plan": result.data[0], "message": "Plan de cuidado creado exitosamente"}
549
+ except HTTPException:
550
+ raise
551
+ except Exception as e:
552
+ raise HTTPException(500, f"Error creando plan de cuidado: {str(e)}")
553
+
554
+ @router.put("/care-plans/{plan_id}/checklist/{item_id}")
555
+ async def update_checklist_item(
556
+ plan_id: str,
557
+ item_id: str,
558
+ updates: Dict[str, Any] = Body(...)
559
+ ):
560
+ """Actualiza un item del checklist (marcar como completado, etc.)"""
561
+ try:
562
+ sb = _sb()
563
+ result = sb.table("checklist_cuidado")\
564
+ .update(updates)\
565
+ .eq("id", item_id)\
566
+ .eq("id_plan", plan_id)\
567
+ .execute()
568
+
569
+ if not result.data:
570
+ raise HTTPException(404, "Item del checklist no encontrado")
571
+
572
+ return {"item": result.data[0], "message": "Item actualizado exitosamente"}
573
+ except HTTPException:
574
+ raise
575
+ except Exception as e:
576
+ raise HTTPException(500, f"Error actualizando item: {str(e)}")
577
+
578
+
routers/rag_search.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Búsqueda RAG (Retrieval Augmented Generation)
3
+ =======================================================
4
+
5
+ Este router implementa búsqueda semántica usando RAG (Retrieval Augmented Generation).
6
+ RAG combina búsqueda vectorial con embeddings para encontrar información relevante.
7
+
8
+ Funcionalidades:
9
+ - Búsqueda de reportes similares usando embeddings
10
+ - Búsqueda con filtros de ubicación
11
+ - Guardar y recuperar embeddings de reportes
12
+ - Estadísticas de embeddings
13
+
14
+ Los embeddings se almacenan en Supabase usando pgvector para búsquedas eficientes.
15
+ """
16
+
17
+ # =========================
18
+ # Imports de FastAPI
19
+ # =========================
20
+ from fastapi import APIRouter, Body, HTTPException, Query
21
+
22
+ # =========================
23
+ # Imports de Python estándar
24
+ # =========================
25
+ import os # Para variables de entorno
26
+ import sys # Para manipular el path de Python
27
+
28
+ # =========================
29
+ # Imports de pathlib
30
+ # =========================
31
+ from pathlib import Path # Para trabajar con rutas de forma multiplataforma
32
+
33
+ # =========================
34
+ # Imports de tipos
35
+ # =========================
36
+ from typing import Any, Dict, List, Optional # Para type hints
37
+
38
+ # =========================
39
+ # Imports de NumPy
40
+ # =========================
41
+ import numpy as np # Para operaciones con vectores
42
+
43
+ # =========================
44
+ # Imports de Supabase
45
+ # =========================
46
+ from supabase import Client # Cliente de Supabase
47
+
48
+ # Agregar la carpeta parent al path para poder importar utils
49
+ sys.path.insert(0, str(Path(__file__).parent.parent))
50
+ from utils.supabase_client import get_supabase_client
51
+
52
+ # Crear el router con prefijo /rag
53
+ router = APIRouter(prefix="/rag", tags=["rag-search"])
54
+
55
+ def _sb() -> Client:
56
+ """
57
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
58
+
59
+ Returns:
60
+ Client: Cliente de Supabase configurado
61
+
62
+ Raises:
63
+ HTTPException: Si no se puede conectar a Supabase
64
+ """
65
+ try:
66
+ return get_supabase_client()
67
+ except Exception as e:
68
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
69
+
70
+ @router.post("/search")
71
+ async def rag_search(
72
+ embedding: List[float] = Body(..., description="Vector de embedding (512 dimensiones)"),
73
+ match_threshold: float = Query(0.7, ge=0.0, le=1.0, description="Umbral mínimo de similitud"),
74
+ match_count: int = Query(10, ge=1, le=50, description="Número máximo de resultados"),
75
+ filter_species: Optional[str] = Query(None, description="Filtrar por especie"),
76
+ filter_type: Optional[str] = Query(None, description="Filtrar por tipo (lost/found)")
77
+ ):
78
+ """
79
+ Búsqueda RAG usando embeddings almacenados en Supabase.
80
+
81
+ Este endpoint busca reportes similares basándose en similitud de embeddings.
82
+ Usa la función SQL search_similar_reports que utiliza pgvector para búsqueda
83
+ vectorial eficiente.
84
+
85
+ Args:
86
+ embedding: Vector de embedding de 512 dimensiones (debe ser exactamente 512)
87
+ match_threshold: Umbral mínimo de similitud (0.0-1.0, default 0.7)
88
+ match_count: Número máximo de resultados (1-50, default 10)
89
+ filter_species: Filtrar por especie (opcional, ej: 'dog', 'cat')
90
+ filter_type: Filtrar por tipo de reporte (opcional, 'lost' o 'found')
91
+
92
+ Returns:
93
+ dict: Resultados con lista de reportes similares, conteo y parámetros de búsqueda
94
+
95
+ Raises:
96
+ HTTPException 400: Si el embedding no tiene 512 dimensiones
97
+ HTTPException 500: Si hay error en la búsqueda
98
+ """
99
+ try:
100
+ # Validar que el embedding tenga 512 dimensiones
101
+ if len(embedding) != 512:
102
+ raise HTTPException(400, f"El embedding debe tener 512 dimensiones, se recibieron {len(embedding)}")
103
+
104
+ sb = _sb()
105
+
106
+ # Llamar a la función SQL de búsqueda
107
+ result = sb.rpc('search_similar_reports', {
108
+ 'query_embedding': embedding,
109
+ 'match_threshold': match_threshold,
110
+ 'match_count': match_count,
111
+ 'filter_species': filter_species,
112
+ 'filter_type': filter_type
113
+ }).execute()
114
+
115
+ if not result.data:
116
+ return {
117
+ "results": [],
118
+ "count": 0,
119
+ "message": "No se encontraron reportes similares"
120
+ }
121
+
122
+ return {
123
+ "results": result.data,
124
+ "count": len(result.data),
125
+ "search_params": {
126
+ "match_threshold": match_threshold,
127
+ "match_count": match_count,
128
+ "filter_species": filter_species,
129
+ "filter_type": filter_type
130
+ }
131
+ }
132
+
133
+ except HTTPException:
134
+ raise
135
+ except Exception as e:
136
+ raise HTTPException(500, f"Error en búsqueda RAG: {str(e)}")
137
+
138
+ @router.post("/search-with-location")
139
+ async def rag_search_with_location(
140
+ embedding: List[float] = Body(..., description="Vector de embedding (512 dimensiones)"),
141
+ user_lat: float = Query(..., description="Latitud del usuario"),
142
+ user_lng: float = Query(..., description="Longitud del usuario"),
143
+ max_distance_km: float = Query(10.0, ge=0.1, le=100.0, description="Distancia máxima en km"),
144
+ match_threshold: float = Query(0.7, ge=0.0, le=1.0, description="Umbral mínimo de similitud"),
145
+ match_count: int = Query(10, ge=1, le=50, description="Número máximo de resultados"),
146
+ filter_species: Optional[str] = Query(None, description="Filtrar por especie"),
147
+ filter_type: Optional[str] = Query(None, description="Filtrar por tipo (lost/found)")
148
+ ):
149
+ """
150
+ Búsqueda RAG con filtro geográfico.
151
+
152
+ Este endpoint combina similitud de embeddings con proximidad geográfica.
153
+ Busca reportes que sean visualmente similares Y estén dentro del radio especificado.
154
+
155
+ Args:
156
+ embedding: Vector de embedding de 512 dimensiones
157
+ user_lat: Latitud del usuario (requerida)
158
+ user_lng: Longitud del usuario (requerida)
159
+ max_distance_km: Distancia máxima en kilómetros (0.1-100, default 10.0)
160
+ match_threshold: Umbral mínimo de similitud (0.0-1.0, default 0.7)
161
+ match_count: Número máximo de resultados (1-50, default 10)
162
+ filter_species: Filtrar por especie (opcional)
163
+ filter_type: Filtrar por tipo de reporte (opcional)
164
+
165
+ Returns:
166
+ dict: Resultados con lista de reportes similares dentro del radio, conteo y parámetros
167
+
168
+ Raises:
169
+ HTTPException 400: Si el embedding no tiene 512 dimensiones
170
+ HTTPException 500: Si hay error en la búsqueda
171
+ """
172
+ try:
173
+ # Validar que el embedding tenga 512 dimensiones
174
+ if len(embedding) != 512:
175
+ raise HTTPException(400, f"El embedding debe tener 512 dimensiones, se recibieron {len(embedding)}")
176
+
177
+ sb = _sb()
178
+
179
+ # Llamar a la función SQL de búsqueda con ubicación
180
+ result = sb.rpc('search_similar_reports_with_location', {
181
+ 'query_embedding': embedding,
182
+ 'user_lat': user_lat,
183
+ 'user_lng': user_lng,
184
+ 'max_distance_km': max_distance_km,
185
+ 'match_threshold': match_threshold,
186
+ 'match_count': match_count,
187
+ 'filter_species': filter_species,
188
+ 'filter_type': filter_type
189
+ }).execute()
190
+
191
+ if not result.data:
192
+ return {
193
+ "results": [],
194
+ "count": 0,
195
+ "message": "No se encontraron reportes similares en el área especificada"
196
+ }
197
+
198
+ return {
199
+ "results": result.data,
200
+ "count": len(result.data),
201
+ "search_params": {
202
+ "user_location": {"lat": user_lat, "lng": user_lng},
203
+ "max_distance_km": max_distance_km,
204
+ "match_threshold": match_threshold,
205
+ "match_count": match_count,
206
+ "filter_species": filter_species,
207
+ "filter_type": filter_type
208
+ }
209
+ }
210
+
211
+ except HTTPException:
212
+ raise
213
+ except Exception as e:
214
+ raise HTTPException(500, f"Error en búsqueda RAG con ubicación: {str(e)}")
215
+
216
+ @router.post("/save-embedding/{report_id}")
217
+ async def save_embedding(
218
+ report_id: str,
219
+ embedding: List[float] = Body(..., description="Vector de embedding (512 dimensiones)")
220
+ ):
221
+ """
222
+ Guarda un embedding en Supabase para un reporte específico.
223
+
224
+ Este endpoint actualiza el campo embedding de un reporte en la base de datos.
225
+ El embedding se almacena usando pgvector para permitir búsquedas vectoriales eficientes.
226
+
227
+ Args:
228
+ report_id: ID del reporte (UUID)
229
+ embedding: Vector de embedding de 512 dimensiones
230
+
231
+ Returns:
232
+ dict: Confirmación de éxito con report_id y dimensiones
233
+
234
+ Raises:
235
+ HTTPException 400: Si el embedding no tiene 512 dimensiones
236
+ HTTPException 404: Si el reporte no existe
237
+ HTTPException 500: Si hay error guardando el embedding
238
+ """
239
+ try:
240
+ # Validar que el embedding tenga 512 dimensiones
241
+ if len(embedding) != 512:
242
+ raise HTTPException(400, f"El embedding debe tener 512 dimensiones, se recibieron {len(embedding)}")
243
+
244
+ sb = _sb()
245
+
246
+ # Usar la función RPC para actualizar el embedding
247
+ result = sb.rpc('update_report_embedding', {
248
+ 'report_id': report_id,
249
+ 'embedding_vector': embedding
250
+ }).execute()
251
+
252
+ if not result.data:
253
+ raise HTTPException(404, f"Reporte {report_id} no encontrado")
254
+
255
+ return {
256
+ "success": True,
257
+ "report_id": report_id,
258
+ "message": "Embedding guardado exitosamente",
259
+ "embedding_dimensions": len(embedding)
260
+ }
261
+
262
+ except HTTPException:
263
+ raise
264
+ except Exception as e:
265
+ raise HTTPException(500, f"Error guardando embedding: {str(e)}")
266
+
267
+ @router.get("/embedding/{report_id}")
268
+ async def get_embedding(report_id: str):
269
+ """
270
+ Obtiene el embedding de un reporte específico.
271
+
272
+ Este endpoint recupera el embedding almacenado de un reporte desde Supabase.
273
+ Útil para verificar si un reporte tiene embedding o para análisis.
274
+
275
+ Args:
276
+ report_id: ID del reporte (UUID)
277
+
278
+ Returns:
279
+ dict: Embedding del reporte con dimensiones
280
+
281
+ Raises:
282
+ HTTPException 404: Si el reporte no tiene embedding o no existe
283
+ HTTPException 500: Si hay error obteniendo el embedding
284
+ """
285
+ try:
286
+ sb = _sb()
287
+
288
+ result = sb.rpc('get_report_embedding', {
289
+ 'report_id': report_id
290
+ }).execute()
291
+
292
+ if not result.data or result.data is None:
293
+ raise HTTPException(404, f"Reporte {report_id} no tiene embedding o no existe")
294
+
295
+ # El embedding viene como vector de pgvector
296
+ embedding = result.data
297
+
298
+ return {
299
+ "report_id": report_id,
300
+ "embedding": embedding if isinstance(embedding, list) else embedding.tolist() if hasattr(embedding, 'tolist') else list(embedding),
301
+ "dimensions": len(embedding) if isinstance(embedding, (list, np.ndarray)) else 512
302
+ }
303
+
304
+ except HTTPException:
305
+ raise
306
+ except Exception as e:
307
+ raise HTTPException(500, f"Error obteniendo embedding: {str(e)}")
308
+
309
+ @router.get("/has-embedding/{report_id}")
310
+ async def check_has_embedding(report_id: str):
311
+ """
312
+ Verifica si un reporte tiene embedding.
313
+
314
+ Este endpoint verifica rápidamente si un reporte tiene un embedding almacenado.
315
+ Útil para determinar si se necesita generar un embedding para un reporte.
316
+
317
+ Args:
318
+ report_id: ID del reporte (UUID)
319
+
320
+ Returns:
321
+ dict: report_id y has_embedding (boolean)
322
+
323
+ Raises:
324
+ HTTPException 500: Si hay error verificando el embedding
325
+ """
326
+ try:
327
+ sb = _sb()
328
+
329
+ result = sb.rpc('has_embedding', {
330
+ 'report_id': report_id
331
+ }).execute()
332
+
333
+ return {
334
+ "report_id": report_id,
335
+ "has_embedding": result.data if isinstance(result.data, bool) else bool(result.data)
336
+ }
337
+
338
+ except Exception as e:
339
+ raise HTTPException(500, f"Error verificando embedding: {str(e)}")
340
+
341
+ @router.get("/stats")
342
+ async def get_rag_stats():
343
+ """
344
+ Obtiene estadísticas sobre los embeddings en la base de datos.
345
+
346
+ Este endpoint proporciona información sobre:
347
+ - Total de reportes
348
+ - Reportes con embedding
349
+ - Reportes activos con embedding
350
+ - Porcentaje de cobertura (reportes con embedding / total)
351
+
352
+ Returns:
353
+ dict: Estadísticas de embeddings con conteos y porcentajes
354
+
355
+ Raises:
356
+ HTTPException 500: Si hay error obteniendo las estadísticas
357
+ """
358
+ try:
359
+ sb = _sb()
360
+
361
+ # Contar total de reportes
362
+ total_result = sb.table("reports").select("id", count="exact").execute()
363
+ total_count = total_result.count if hasattr(total_result, 'count') else len(total_result.data)
364
+
365
+ # Contar reportes con embedding
366
+ with_embedding_result = sb.table("reports").select("id", count="exact").not_.is_("embedding", "null").execute()
367
+ with_embedding_count = with_embedding_result.count if hasattr(with_embedding_result, 'count') else len(with_embedding_result.data)
368
+
369
+ # Contar reportes activos con embedding
370
+ active_with_embedding_result = sb.table("reports").select("id", count="exact").eq("status", "active").not_.is_("embedding", "null").execute()
371
+ active_with_embedding_count = active_with_embedding_result.count if hasattr(active_with_embedding_result, 'count') else len(active_with_embedding_result.data)
372
+
373
+ return {
374
+ "total_reports": total_count,
375
+ "reports_with_embedding": with_embedding_count,
376
+ "active_reports_with_embedding": active_with_embedding_count,
377
+ "coverage_percentage": round((with_embedding_count / total_count * 100) if total_count > 0 else 0, 2),
378
+ "embedding_dimensions": 512
379
+ }
380
+
381
+ except Exception as e:
382
+ raise HTTPException(500, f"Error obteniendo estadísticas: {str(e)}")
383
+
384
+
385
+
routers/reports.py ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Reportes
3
+ ==================
4
+
5
+ Este router maneja todas las operaciones CRUD (Create, Read, Update, Delete)
6
+ relacionadas con reportes de mascotas perdidas y encontradas.
7
+
8
+ Funcionalidades principales:
9
+ - Crear reportes nuevos (con generación automática de embeddings)
10
+ - Obtener reportes (todos, por usuario, por ID, cercanos)
11
+ - Actualizar reportes existentes
12
+ - Eliminar/cancelar reportes
13
+ - Subir fotos de mascotas
14
+ - Generar embeddings de imágenes en segundo plano
15
+
16
+ Los embeddings se generan automáticamente cuando se crea un reporte con fotos,
17
+ permitiendo búsquedas visuales inteligentes más adelante.
18
+ """
19
+
20
+ from fastapi import APIRouter, HTTPException, Query, Body, BackgroundTasks
21
+ from typing import List, Dict, Any, Optional
22
+ import os, math, sys
23
+ from pathlib import Path
24
+ from supabase import Client
25
+ import httpx # Para hacer peticiones HTTP (descargar imágenes)
26
+ import asyncio # Para operaciones asíncronas
27
+ from services.embeddings import image_bytes_to_vec # Función para generar embeddings
28
+
29
+ # Agregar la carpeta parent al path para poder importar utils
30
+ # Esto permite usar imports como "from utils.supabase_client import ..."
31
+ sys.path.insert(0, str(Path(__file__).parent.parent))
32
+ from utils.supabase_client import get_supabase_client
33
+
34
+ # =========================
35
+ # Configuración
36
+ # =========================
37
+ # Verificar si la generación local de embeddings está habilitada
38
+ # Si está habilitada, los embeddings se generan en el servidor usando MegaDescriptor
39
+ # Si no, se espera que se generen externamente
40
+ GENERATE_EMBEDDINGS_LOCALLY = (
41
+ os.getenv("GENERATE_EMBEDDINGS_LOCALLY", "false").lower() in ("1", "true", "yes")
42
+ )
43
+
44
+ # Crear el router con prefijo /reports
45
+ # Todas las rutas de este archivo empezarán con /reports
46
+ router = APIRouter(prefix="/reports", tags=["reports"])
47
+
48
+ def _sb() -> Client:
49
+ """
50
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
51
+
52
+ Esta función es un helper que crea y retorna un cliente de Supabase.
53
+ Si falla la conexión, lanza una excepción HTTP 500.
54
+
55
+ Returns:
56
+ Client: Cliente de Supabase configurado
57
+
58
+ Raises:
59
+ HTTPException: Si no se puede conectar a Supabase
60
+ """
61
+ try:
62
+ return get_supabase_client()
63
+ except Exception as e:
64
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
65
+
66
+ def _extract_coords(location_data) -> Optional[tuple]:
67
+ """
68
+ Extrae coordenadas de diferentes formatos de ubicación.
69
+
70
+ Esta función maneja múltiples formatos de datos geográficos:
71
+ - PostGIS: String con formato "SRID=4326;POINT(lon lat)"
72
+ - GeoJSON: Objeto con formato {"type":"Point","coordinates":[lon,lat]}
73
+
74
+ Args:
75
+ location_data: Datos de ubicación en cualquier formato soportado
76
+
77
+ Returns:
78
+ Tupla (latitud, longitud) o None si no se pueden extraer
79
+
80
+ Ejemplo:
81
+ >>> _extract_coords("SRID=4326;POINT(-58.3816 -34.6037)")
82
+ (-34.6037, -58.3816)
83
+ >>> _extract_coords({"type":"Point","coordinates":[-58.3816, -34.6037]})
84
+ (-34.6037, -58.3816)
85
+ """
86
+ # Si no hay datos, retornar None
87
+ if not location_data:
88
+ return None
89
+
90
+ # =========================
91
+ # Formato PostGIS
92
+ # =========================
93
+ # PostGIS es una extensión de PostgreSQL para datos geográficos
94
+ # Formato: "SRID=4326;POINT(lon lat)"
95
+ # SRID 4326 es el sistema de coordenadas WGS84 (lat/lon estándar)
96
+ if isinstance(location_data, str) and "POINT(" in location_data:
97
+ try:
98
+ # Extraer la parte del string que contiene las coordenadas
99
+ # Ejemplo: "SRID=4326;POINT(-58.3816 -34.6037)" -> "-58.3816 -34.6037"
100
+ coords_str = location_data.split("POINT(")[1].split(")")[0]
101
+ # Convertir a float y extraer lon y lat
102
+ lon, lat = map(float, coords_str.split())
103
+ # Retornar como (lat, lon) para consistencia
104
+ return (lat, lon)
105
+ except:
106
+ # Si falla el parsing, retornar None
107
+ return None
108
+
109
+ # =========================
110
+ # Formato GeoJSON
111
+ # =========================
112
+ # GeoJSON es un formato estándar para datos geográficos
113
+ # Formato: {"type":"Point","coordinates":[lon,lat]}
114
+ # Nota: GeoJSON usa [lon, lat] pero retornamos (lat, lon) para consistencia
115
+ if isinstance(location_data, dict) and "coordinates" in location_data:
116
+ try:
117
+ # Extraer coordenadas del array
118
+ lon, lat = location_data["coordinates"]
119
+ # Retornar como (lat, lon) para consistencia
120
+ return (lat, lon)
121
+ except:
122
+ # Si falla el parsing, retornar None
123
+ return None
124
+
125
+ # Si no coincide con ningún formato conocido, retornar None
126
+ return None
127
+
128
+ def haversine_km(lat1, lon1, lat2, lon2):
129
+ """
130
+ Calcula la distancia en kilómetros entre dos puntos geográficos usando la fórmula de Haversine.
131
+
132
+ La fórmula de Haversine calcula la distancia del círculo máximo entre dos puntos
133
+ en una esfera (la Tierra), considerando que es una esfera perfecta.
134
+
135
+ Args:
136
+ lat1, lon1: Latitud y longitud del primer punto (en grados)
137
+ lat2, lon2: Latitud y longitud del segundo punto (en grados)
138
+
139
+ Returns:
140
+ Distancia en kilómetros entre los dos puntos
141
+ """
142
+ R = 6371.0 # Radio de la Tierra en kilómetros
143
+ # Convertir diferencias de latitud y longitud a radianes
144
+ dlat = math.radians(lat2 - lat1)
145
+ dlon = math.radians(lon2 - lon1)
146
+ # Aplicar la fórmula de Haversine
147
+ a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
148
+ return 2 * R * math.asin(math.sqrt(a))
149
+
150
+ async def generate_and_save_embedding(report_id: str, photo_url: str):
151
+ """
152
+ Genera y guarda el embedding de una imagen para un reporte.
153
+
154
+ Esta función se ejecuta en segundo plano (background task) para no bloquear
155
+ la respuesta HTTP al crear un reporte. El proceso completo es:
156
+ 1. Descarga la imagen desde Supabase Storage
157
+ 2. Genera un embedding usando MegaDescriptor (vector numérico de 1536 dimensiones)
158
+ 3. Guarda el embedding en la columna 'embedding' de la tabla 'reports'
159
+ 4. Opcionalmente busca automáticamente matches (coincidencias) con otros reportes
160
+
161
+ Los embeddings permiten búsquedas visuales inteligentes: cuando un usuario
162
+ sube una foto de una mascota, el sistema puede encontrar reportes con fotos
163
+ visualmente similares usando búsqueda vectorial.
164
+
165
+ Args:
166
+ report_id: ID del reporte en la base de datos (UUID)
167
+ photo_url: URL completa de la imagen en Supabase Storage
168
+ Ejemplo: https://xxxxx.supabase.co/storage/v1/object/public/photos/xxx.jpg
169
+
170
+ Nota:
171
+ - Si falla, no lanza excepción para no afectar la creación del reporte
172
+ - Usa reintentos automáticos (hasta 3 intentos) para manejar errores temporales
173
+ - Si la generación local de embeddings está desactivada, esta función no hace nada
174
+ """
175
+ max_retries = 3 # Número máximo de intentos si falla (para manejar errores temporales de red)
176
+
177
+ # Intentar hasta max_retries veces
178
+ for attempt in range(max_retries):
179
+ try:
180
+ # Log del intento (diferente mensaje si es reintento)
181
+ if attempt > 0:
182
+ print(f"🔄 [embedding] Reintento {attempt + 1}/{max_retries} para reporte {report_id}")
183
+ else:
184
+ print(f"🔄 [embedding] Generando embedding para reporte {report_id} desde {photo_url}")
185
+
186
+ # =========================
187
+ # Descargar la imagen
188
+ # =========================
189
+ # Descargar la imagen desde Supabase Storage usando httpx
190
+ # Timeout aumentado para Windows que puede tener problemas de red
191
+ timeout = httpx.Timeout(60.0, connect=60.0) # 60 segundos total y para conectar
192
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
193
+ response = await client.get(photo_url)
194
+ response.raise_for_status() # Lanza error si el status code no es 2xx
195
+ image_bytes = response.content # Obtener los bytes de la imagen
196
+
197
+ print(f"🔍 Imagen descargada: {len(image_bytes)} bytes")
198
+
199
+ # Generar embedding usando MegaDescriptor
200
+ # image_bytes_to_vec convierte la imagen en un vector numérico
201
+ vec = image_bytes_to_vec(image_bytes)
202
+ vec_list = vec.tolist() # Convertir numpy array a lista de Python
203
+
204
+ print(f"🔍 Embedding generado: {len(vec_list)} dimensiones")
205
+
206
+ # Guardar el embedding en la columna 'embedding' del reporte
207
+ # pgvector (extensión de PostgreSQL) acepta arrays de Python directamente
208
+ sb = _sb()
209
+ result = sb.table('reports').update({
210
+ 'embedding': vec_list
211
+ }).eq('id', report_id).execute()
212
+
213
+ if result.data:
214
+ print(f"✅ [embedding] Embedding guardado exitosamente para reporte {report_id}")
215
+
216
+ # Buscar matches automáticamente después de generar el embedding
217
+ # Esto permite encontrar coincidencias inmediatamente sin esperar
218
+ try:
219
+ await find_and_save_matches(report_id)
220
+ except Exception as match_error:
221
+ # Si falla la búsqueda de matches, no es crítico, solo logueamos
222
+ print(f"⚠️ [matches] Error buscando matches (no crítico): {str(match_error)}")
223
+
224
+ return # Éxito, salir de la función
225
+ else:
226
+ print(f"⚠️ [embedding] No se pudo guardar embedding para reporte {report_id}")
227
+
228
+ except httpx.TimeoutException as e:
229
+ # Si hay timeout (la descarga tarda mucho), reintentamos
230
+ print(f"⏱️ [embedding] Timeout al procesar imagen (intento {attempt + 1}/{max_retries})")
231
+ if attempt == max_retries - 1:
232
+ print(f"❌ [embedding] Error después de {max_retries} intentos: Timeout")
233
+ else:
234
+ # Backoff exponencial: esperamos 1s, 2s, 4s entre intentos
235
+ await asyncio.sleep(2 ** attempt)
236
+
237
+ except Exception as e:
238
+ # Cualquier otro error, logueamos y reintentamos
239
+ print(f"❌ [embedding] Error generando embedding para reporte {report_id}: {str(e)}")
240
+ if attempt < max_retries - 1:
241
+ await asyncio.sleep(2 ** attempt) # Backoff exponencial
242
+ # No lanzar excepción para no afectar la creación del reporte
243
+
244
+
245
+ async def find_and_save_matches(report_id: str, threshold: float = 0.1, max_matches: int = 10):
246
+ """
247
+ Busca matches (coincidencias) similares para un reporte y los guarda automáticamente.
248
+
249
+ Esta función:
250
+ 1. Obtiene el embedding del reporte base
251
+ 2. Busca reportes del tipo opuesto (lost ↔ found) con embeddings
252
+ 3. Calcula la similitud coseno entre embeddings
253
+ 4. Filtra por umbral de similitud (threshold)
254
+ 5. Guarda los matches en la tabla 'matches' de la base de datos
255
+
256
+ Args:
257
+ report_id: ID del reporte para el cual buscar matches
258
+ threshold: Umbral mínimo de similitud (0.0 a 1.0). Default: 0.1 (10% de similitud)
259
+ max_matches: Número máximo de matches a guardar. Default: 10
260
+
261
+ Nota: Si el reporte no tiene embedding, la función retorna sin hacer nada.
262
+ """
263
+ try:
264
+ print(f"🔍 [matches] Buscando coincidencias para reporte {report_id}...")
265
+
266
+ sb = _sb()
267
+
268
+ # Obtener el reporte con su embedding
269
+ report_result = sb.table("reports")\
270
+ .select("id, embedding, type, species")\
271
+ .eq("id", report_id)\
272
+ .execute()
273
+
274
+ if not report_result.data:
275
+ print(f"⚠️ [matches] Reporte {report_id} no encontrado")
276
+ return
277
+
278
+ report = report_result.data[0]
279
+ report_embedding = report.get("embedding")
280
+ report_type = report.get("type")
281
+
282
+ if not report_embedding:
283
+ print(f"⚠️ [matches] Reporte {report_id} no tiene embedding")
284
+ return
285
+
286
+ # Postgrest devuelve vectores como strings JSON, parsearlos
287
+ if isinstance(report_embedding, str):
288
+ import json
289
+ try:
290
+ report_embedding = json.loads(report_embedding)
291
+ except Exception as e:
292
+ print(f"⚠️ [matches] Error parseando embedding: {e}")
293
+ return
294
+
295
+ # Determinar tipo opuesto para buscar matches
296
+ opposite_type = "found" if report_type == "lost" else "lost"
297
+
298
+ # Buscar reportes del tipo opuesto con embeddings
299
+ candidates = sb.table("reports")\
300
+ .select("id, embedding, species")\
301
+ .eq("type", opposite_type)\
302
+ .eq("status", "active")\
303
+ .not_.is_("embedding", "null")\
304
+ .execute()
305
+
306
+ if not candidates.data:
307
+ print(f"ℹ️ [matches] No hay reportes de tipo '{opposite_type}' para comparar")
308
+ return
309
+
310
+ # Calcular similitud con todos los candidatos
311
+ import numpy as np
312
+ base_vec = np.array(report_embedding, dtype=np.float32)
313
+
314
+ matches_found = []
315
+ for candidate in candidates.data:
316
+ try:
317
+ candidate_embedding = candidate.get("embedding")
318
+ if not candidate_embedding:
319
+ continue
320
+
321
+ # Parsear si es string JSON
322
+ if isinstance(candidate_embedding, str):
323
+ try:
324
+ candidate_embedding = json.loads(candidate_embedding)
325
+ except:
326
+ continue
327
+
328
+ candidate_vec = np.array(candidate_embedding, dtype=np.float32)
329
+
330
+ # Similitud coseno (embeddings ya están normalizados)
331
+ similarity = float(np.dot(base_vec, candidate_vec))
332
+
333
+ if similarity >= threshold:
334
+ matches_found.append({
335
+ "candidate_id": candidate["id"],
336
+ "similarity": similarity,
337
+ "species": candidate.get("species")
338
+ })
339
+ except Exception as e:
340
+ print(f"⚠️ [matches] Error procesando candidato: {e}")
341
+ continue
342
+
343
+ # Ordenar por similitud descendente y tomar los mejores
344
+ matches_found.sort(key=lambda x: x["similarity"], reverse=True)
345
+ matches_found = matches_found[:max_matches]
346
+
347
+ if not matches_found:
348
+ print(f"ℹ️ [matches] No se encontraron coincidencias con similitud >= {threshold}")
349
+ return
350
+
351
+ # Guardar matches en la base de datos
352
+ matches_saved = 0
353
+ for match in matches_found:
354
+ try:
355
+ match_data = {
356
+ "similarity_score": round(match["similarity"], 4),
357
+ "matched_by": "ai_visual",
358
+ "status": "pending"
359
+ }
360
+
361
+ # Configurar IDs según el tipo de reporte
362
+ if report_type == "lost":
363
+ match_data["lost_report_id"] = report_id
364
+ match_data["found_report_id"] = match["candidate_id"]
365
+ else:
366
+ match_data["lost_report_id"] = match["candidate_id"]
367
+ match_data["found_report_id"] = report_id
368
+
369
+ # Verificar si ya existe el match
370
+ if report_type == "lost":
371
+ existing = sb.table("matches")\
372
+ .select("id, similarity_score")\
373
+ .eq("lost_report_id", report_id)\
374
+ .eq("found_report_id", match["candidate_id"])\
375
+ .execute()
376
+ else:
377
+ existing = sb.table("matches")\
378
+ .select("id, similarity_score")\
379
+ .eq("lost_report_id", match["candidate_id"])\
380
+ .eq("found_report_id", report_id)\
381
+ .execute()
382
+
383
+ if existing.data:
384
+ # Actualizar si la nueva similitud es mejor
385
+ existing_match = existing.data[0]
386
+ if match["similarity"] > (existing_match.get("similarity_score") or 0):
387
+ sb.table("matches")\
388
+ .update(match_data)\
389
+ .eq("id", existing_match["id"])\
390
+ .execute()
391
+ matches_saved += 1
392
+ print(f" ✅ [matches] Match actualizado: {match['candidate_id']} (similitud: {match['similarity']:.3f})")
393
+ else:
394
+ # Crear nuevo match
395
+ sb.table("matches").insert(match_data).execute()
396
+ matches_saved += 1
397
+ print(f" ✅ [matches] Match creado: {match['candidate_id']} (similitud: {match['similarity']:.3f})")
398
+
399
+ except Exception as e:
400
+ print(f"⚠️ [matches] Error guardando match: {e}")
401
+ continue
402
+
403
+ print(f"✅ [matches] {matches_saved} coincidencias guardadas para reporte {report_id}")
404
+
405
+ except Exception as e:
406
+ print(f"❌ [matches] Error en búsqueda de matches: {str(e)}")
407
+ # No lanzar excepción, solo loguear el error
408
+
409
+ @router.get("/")
410
+ async def get_all_reports():
411
+ """Obtiene todos los reportes activos"""
412
+ try:
413
+ sb = _sb()
414
+ result = sb.table("reports").select("*").eq("status", "active").order("created_at", desc=True).execute()
415
+ return {"reports": result.data, "count": len(result.data)}
416
+ except Exception as e:
417
+ raise HTTPException(500, f"Error obteniendo reportes: {str(e)}")
418
+
419
+ @router.get("/nearby")
420
+ async def get_nearby_reports(
421
+ lat: float = Query(..., description="Latitud"),
422
+ lng: float = Query(..., description="Longitud"),
423
+ radius_km: float = Query(10.0, description="Radio en kilómetros")
424
+ ):
425
+ """Obtiene reportes cercanos a una ubicación"""
426
+ try:
427
+ sb = _sb()
428
+ result = sb.table("reports").select("*").eq("status", "active").execute()
429
+
430
+ nearby_reports = []
431
+ for report in result.data:
432
+ coords = _extract_coords(report.get("location"))
433
+ if coords:
434
+ report_lat, report_lon = coords
435
+ distance = haversine_km(lat, lng, report_lat, report_lon)
436
+ if distance <= radius_km:
437
+ report["distance_km"] = round(distance, 2)
438
+ nearby_reports.append(report)
439
+
440
+ # Ordenar por distancia
441
+ nearby_reports.sort(key=lambda x: x["distance_km"])
442
+
443
+ return {"reports": nearby_reports, "count": len(nearby_reports)}
444
+ except Exception as e:
445
+ raise HTTPException(500, f"Error obteniendo reportes cercanos: {str(e)}")
446
+
447
+ @router.get("/{report_id}")
448
+ async def get_report_by_id(report_id: str):
449
+ """Obtiene un reporte por ID"""
450
+ try:
451
+ sb = _sb()
452
+ result = sb.table("reports").select("*").eq("id", report_id).execute()
453
+ report_data = result.data[0] if result.data else None
454
+
455
+ if not report_data:
456
+ raise HTTPException(404, "Reporte no encontrado")
457
+
458
+ return {"report": report_data}
459
+ except Exception as e:
460
+ if "404" in str(e):
461
+ raise e
462
+ raise HTTPException(500, f"Error obteniendo reporte: {str(e)}")
463
+
464
+ @router.post("/")
465
+ async def create_report(
466
+ report_data: Dict[str, Any] = Body(...),
467
+ background_tasks: BackgroundTasks = None
468
+ ):
469
+ """Crea un nuevo reporte y genera embeddings automáticamente si hay fotos"""
470
+ try:
471
+ sb = _sb()
472
+
473
+ # Validar datos requeridos
474
+ required_fields = ["type", "reporter_id", "species", "description", "location"]
475
+ for field in required_fields:
476
+ if field not in report_data:
477
+ raise HTTPException(400, f"Campo requerido faltante: {field}")
478
+
479
+ # Crear reporte
480
+ result = sb.table("reports").insert(report_data).execute()
481
+
482
+ if not result.data:
483
+ raise HTTPException(500, "Error creando reporte")
484
+
485
+ created_report = result.data[0]
486
+ report_id = created_report.get("id")
487
+
488
+ # Generar embedding automáticamente si hay fotos (de forma síncrona para asegurar que se guarde)
489
+ photos = created_report.get("photos") or report_data.get("photos", [])
490
+ if GENERATE_EMBEDDINGS_LOCALLY and photos and isinstance(photos, list) and len(photos) > 0:
491
+ first_photo = photos[0]
492
+ if first_photo:
493
+ print(f"📸 [embedding] Reporte creado con fotos. Generando embedding para reporte {report_id}...")
494
+ # Generar embedding de forma síncrona para asegurar que se guarde antes de retornar
495
+ try:
496
+ await generate_and_save_embedding(report_id, first_photo)
497
+ print(f"✅ [embedding] Embedding generado y guardado para reporte {report_id}")
498
+ except Exception as e:
499
+ print(f"⚠️ [embedding] Error generando embedding (no crítico): {str(e)}")
500
+ # No fallar la creación del reporte si falla el embedding
501
+ elif photos and isinstance(photos, list) and len(photos) > 0:
502
+ print("ℹ️ [embedding] Generación local desactivada. La IA externa se encargará del embedding.")
503
+
504
+ return {"report": created_report, "message": "Reporte creado exitosamente"}
505
+ except Exception as e:
506
+ if "400" in str(e) or "500" in str(e):
507
+ raise e
508
+ raise HTTPException(500, f"Error creando reporte: {str(e)}")
509
+
510
+ @router.put("/{report_id}")
511
+ async def update_report(
512
+ report_id: str,
513
+ updates: Dict[str, Any] = Body(...),
514
+ background_tasks: BackgroundTasks = None
515
+ ):
516
+ """Actualiza un reporte existente y genera embeddings si hay nuevas fotos"""
517
+ try:
518
+ sb = _sb()
519
+
520
+ # Obtener el reporte actual para verificar si tiene embedding
521
+ current_result = sb.table("reports").select("id, photos, embedding").eq("id", report_id).execute()
522
+ current_report = current_result.data[0] if current_result.data else None
523
+
524
+ if not current_report:
525
+ raise HTTPException(404, "Reporte no encontrado")
526
+
527
+ # Actualizar reporte
528
+ result = sb.table("reports").update(updates).eq("id", report_id).execute()
529
+
530
+ if not result.data:
531
+ raise HTTPException(404, "Reporte no encontrado")
532
+
533
+ updated_report = result.data[0]
534
+
535
+ # Generar embedding si:
536
+ # 1. Hay fotos nuevas o actualizadas
537
+ # 2. El reporte no tiene embedding aún
538
+ photos = updated_report.get("photos") or updates.get("photos", [])
539
+ has_embedding = current_report.get("embedding") is not None
540
+
541
+ if GENERATE_EMBEDDINGS_LOCALLY and photos and isinstance(photos, list) and len(photos) > 0:
542
+ first_photo = photos[0]
543
+ if first_photo and (not has_embedding or "photos" in updates):
544
+ print(f"📸 [embedding] Reporte actualizado con fotos. Generando embedding para reporte {report_id}...")
545
+ # Generar embedding de forma síncrona para asegurar que se guarde
546
+ try:
547
+ await generate_and_save_embedding(report_id, first_photo)
548
+ print(f"✅ [embedding] Embedding generado y guardado para reporte {report_id}")
549
+ except Exception as e:
550
+ print(f"⚠️ [embedding] Error generando embedding (no crítico): {str(e)}")
551
+ # No fallar la actualización del reporte si falla el embedding
552
+ elif photos and isinstance(photos, list) and len(photos) > 0 and (not has_embedding or "photos" in updates):
553
+ print("ℹ️ [embedding] Generación local desactivada. La IA externa actualizará el embedding si corresponde.")
554
+
555
+ return {"report": updated_report, "message": "Reporte actualizado exitosamente"}
556
+ except Exception as e:
557
+ if "404" in str(e):
558
+ raise e
559
+ raise HTTPException(500, f"Error actualizando reporte: {str(e)}")
560
+
561
+ @router.delete("/{report_id}")
562
+ async def delete_report(report_id: str):
563
+ """Elimina un reporte (soft delete cambiando status a cancelled)"""
564
+ try:
565
+ sb = _sb()
566
+ result = sb.table("reports").update({"status": "cancelled"}).eq("id", report_id).execute()
567
+
568
+ if not result.data:
569
+ raise HTTPException(404, "Reporte no encontrado")
570
+
571
+ return {"message": "Reporte eliminado exitosamente"}
572
+ except Exception as e:
573
+ if "404" in str(e):
574
+ raise e
575
+ raise HTTPException(500, f"Error eliminando reporte: {str(e)}")
576
+
577
+ @router.post("/{report_id}/resolve")
578
+ async def resolve_report(report_id: str):
579
+ """Marca un reporte como resuelto"""
580
+ try:
581
+ sb = _sb()
582
+ result = sb.table("reports").update({
583
+ "status": "resolved",
584
+ "resolved_at": "now()"
585
+ }).eq("id", report_id).execute()
586
+
587
+ if not result.data:
588
+ raise HTTPException(404, "Reporte no encontrado")
589
+
590
+ return {"report": result.data[0], "message": "Reporte marcado como resuelto"}
591
+ except Exception as e:
592
+ if "404" in str(e):
593
+ raise e
594
+ raise HTTPException(500, f"Error resolviendo reporte: {str(e)}")
routers/reports_labels.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Router de Etiquetas de Reportes
3
+ ================================
4
+
5
+ Este router maneja la gestión de etiquetas (labels) de los reportes.
6
+ Las etiquetas son metadatos adicionales que se pueden asociar a un reporte
7
+ para mejorar la búsqueda y categorización.
8
+
9
+ Funcionalidades:
10
+ - Guardar etiquetas para un reporte
11
+ - Actualizar etiquetas existentes
12
+
13
+ Las etiquetas se almacenan como JSON en la columna 'labels' de la tabla 'reports'.
14
+ """
15
+
16
+ from fastapi import APIRouter, HTTPException, Path, Body
17
+ from typing import Any, Dict
18
+ import os, sys
19
+ from pathlib import Path as PathLib
20
+ from supabase import Client
21
+
22
+ # Agregar la carpeta parent al path para poder importar utils
23
+ sys.path.insert(0, str(PathLib(__file__).parent.parent))
24
+ from utils.supabase_client import get_supabase_client
25
+
26
+ # Crear el router con prefijo /reports (comparte el mismo prefijo que reports.py)
27
+ router = APIRouter(prefix="/reports", tags=["reports"])
28
+
29
+ def _sb() -> Client:
30
+ """
31
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
32
+
33
+ Returns:
34
+ Client: Cliente de Supabase configurado
35
+
36
+ Raises:
37
+ HTTPException: Si no se puede conectar a Supabase
38
+ """
39
+ try:
40
+ return get_supabase_client()
41
+ except Exception as e:
42
+ raise HTTPException(500, f"Error conectando a Supabase: {str(e)}")
43
+
44
+ @router.post("/{report_id}/labels")
45
+ def save_labels(report_id: str = Path(...), payload: Dict[str, Any] = Body(...)):
46
+ if "labels" not in payload or not isinstance(payload["labels"], list):
47
+ raise HTTPException(400, "Se espera {'labels': [...]}")
48
+
49
+ sb = _sb()
50
+ res = sb.table("reports").update({"labels": payload}).eq("id", report_id).execute()
51
+ if not res.data:
52
+ raise HTTPException(404, "Reporte no encontrado")
53
+ return {"ok": True, "updated": res.data[0]["id"]}
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # services/__init__.py
services/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (185 Bytes). View file
 
services/__pycache__/embeddings.cpython-311.pyc ADDED
Binary file (5.58 kB). View file
 
services/embeddings.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Servicio de Generación de Embeddings con MegaDescriptor
3
+ =======================================================
4
+
5
+ Este módulo se encarga de generar embeddings (vectores numéricos) de imágenes
6
+ usando el modelo MegaDescriptor, especializado en reconocimiento de animales.
7
+
8
+ MegaDescriptor es un modelo de deep learning que convierte imágenes en vectores
9
+ de alta dimensión (1536 dimensiones) que capturan características visuales.
10
+ Estos vectores se usan para buscar coincidencias visuales entre reportes de
11
+ mascotas perdidas y encontradas.
12
+
13
+ Flujo:
14
+ 1. Recibe bytes de una imagen
15
+ 2. Preprocesa la imagen (redimensiona a 384x384, normaliza)
16
+ 3. Pasa la imagen por el modelo MegaDescriptor
17
+ 4. Obtiene el embedding (vector de características)
18
+ 5. Normaliza el vector (L2 normalization)
19
+ 6. Retorna el vector como numpy array
20
+
21
+ El modelo se carga una sola vez y se reutiliza para todas las peticiones.
22
+ """
23
+
24
+ # backend/services/embeddings.py
25
+ import io # Para trabajar con bytes en memoria
26
+ import asyncio # Para operaciones asíncronas
27
+ from typing import Optional # Para type hints
28
+ import numpy as np # Para arrays numéricos
29
+ from PIL import Image # Para procesar imágenes
30
+ import torch # PyTorch para deep learning
31
+ import torchvision.transforms as T # Transformaciones de imágenes
32
+ import timm # Biblioteca para cargar modelos pre-entrenados
33
+
34
+ # =========================
35
+ # Configuración del Modelo MegaDescriptor
36
+ # =========================
37
+ # Detectar si hay GPU disponible (CUDA), si no usa CPU
38
+ # GPU es mucho más rápido pero CPU también funciona
39
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
40
+
41
+ # Nombre del modelo en Hugging Face Hub
42
+ # MegaDescriptor-L-384 es la versión Large que procesa imágenes de 384x384 píxeles
43
+ MODEL_NAME = "hf-hub:BVRA/MegaDescriptor-L-384"
44
+
45
+ # La dimensión del embedding se detectará automáticamente al cargar el modelo
46
+ # MegaDescriptor-L-384 genera embeddings de 1536 dimensiones
47
+ EMBEDDING_DIM = None
48
+
49
+ # Variables globales para cachear el modelo cargado
50
+ # Esto evita cargar el modelo múltiples veces (es costoso en tiempo y memoria)
51
+ _model = None # El modelo de PyTorch
52
+ _transforms = None # Las transformaciones de preprocesamiento
53
+ _actual_dim = None # La dimensión real del embedding (se detecta al cargar)
54
+
55
+ # Semáforo para limitar concurrencia
56
+ # Permite máximo 2 inferencias simultáneas para evitar saturar la GPU/memoria
57
+ # Si hay más peticiones, esperan su turno
58
+ _inference_semaphore = asyncio.Semaphore(2)
59
+
60
+ def _load_model():
61
+ """
62
+ Carga el modelo MegaDescriptor y sus transformaciones de preprocesamiento.
63
+
64
+ Esta función usa el patrón "lazy loading" - solo carga el modelo la primera vez
65
+ que se llama. Las siguientes llamadas retornan el modelo ya cargado (cacheado).
66
+
67
+ El modelo se descarga automáticamente desde Hugging Face Hub la primera vez.
68
+ Puede tardar varios minutos dependiendo de la conexión a internet.
69
+
70
+ Returns:
71
+ Tupla con (modelo, transformaciones, dimensión_del_embedding)
72
+ """
73
+ global _model, _transforms, _actual_dim
74
+
75
+ # Solo cargar si no está cargado ya (singleton pattern)
76
+ if _model is None:
77
+ print(f"🔄 Cargando MegaDescriptor en {DEVICE}...")
78
+
79
+ # Cargar modelo desde Hugging Face Hub usando timm
80
+ # num_classes=0 significa que queremos solo las features (sin capa de clasificación)
81
+ # Esto nos da el embedding directamente sin pasar por una capa de salida
82
+ _model = timm.create_model(MODEL_NAME, pretrained=True, num_classes=0)
83
+
84
+ # Mover el modelo a la GPU si está disponible, si no queda en CPU
85
+ _model = _model.to(DEVICE)
86
+
87
+ # Poner el modelo en modo evaluación (desactiva dropout, batch norm, etc.)
88
+ # Esto es importante para obtener resultados consistentes
89
+ _model.eval()
90
+
91
+ # Verificar la dimensión real del embedding haciendo una inferencia de prueba
92
+ # Creamos una imagen dummy (aleatoria) y vemos qué tamaño de vector produce
93
+ with torch.no_grad(): # No calcular gradientes (ahorra memoria)
94
+ # Crear imagen dummy: 1 batch, 3 canales RGB, 384x384 píxeles
95
+ dummy_input = torch.randn(1, 3, 384, 384).to(DEVICE)
96
+ dummy_output = _model(dummy_input) # Pasar por el modelo
97
+ _actual_dim = dummy_output.shape[-1] # Obtener la última dimensión (tamaño del vector)
98
+ print(f"📊 Dimensión del modelo: {_actual_dim}")
99
+
100
+ # Configurar transformaciones de preprocesamiento para las imágenes
101
+ # Estas transformaciones deben coincidir EXACTAMENTE con las que usó el modelo durante el entrenamiento
102
+ # MegaDescriptor-L-384 usa normalización [0.5, 0.5, 0.5] que convierte [0, 1] a [-1, 1]
103
+ _transforms = T.Compose([
104
+ T.Resize(size=(384, 384)), # Redimensionar a 384x384 (tamaño que espera MegaDescriptor)
105
+ T.ToTensor(), # Convertir PIL Image a tensor de PyTorch (0-255 -> 0-1)
106
+ # Normalizar: mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]
107
+ # Esto convierte el rango de [0, 1] a [-1, 1] (estándar para MegaDescriptor)
108
+ T.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5])
109
+ ])
110
+ print(f"✅ MegaDescriptor cargado exitosamente")
111
+
112
+ # Retornar el modelo, transformaciones y dimensión (ya cargados o recién cargados)
113
+ return _model, _transforms, _actual_dim
114
+
115
+ async def image_bytes_to_vec_async(image_bytes: bytes) -> np.ndarray:
116
+ """
117
+ Genera embedding de forma asíncrona con control de concurrencia.
118
+
119
+ Esta es la versión asíncrona recomendada para usar en endpoints FastAPI.
120
+ Usa un semáforo para limitar cuántas inferencias se ejecutan simultáneamente,
121
+ evitando saturar la GPU o la memoria del sistema.
122
+
123
+ Args:
124
+ image_bytes: Bytes de la imagen (formato JPEG, PNG, etc.)
125
+
126
+ Returns:
127
+ numpy array float32 normalizado con el embedding (vector de características)
128
+
129
+ Nota: La inferencia se ejecuta en un thread pool para no bloquear el event loop
130
+ de asyncio, permitiendo que el servidor siga procesando otras peticiones.
131
+ """
132
+ # Adquirir el semáforo (espera si ya hay 2 inferencias en curso)
133
+ async with _inference_semaphore:
134
+ # Ejecutar la generación del embedding en un thread separado
135
+ # Esto evita bloquear el event loop de asyncio mientras se procesa la imagen
136
+ return await asyncio.to_thread(_generate_embedding, image_bytes)
137
+
138
+ def _generate_embedding(image_bytes: bytes) -> np.ndarray:
139
+ """
140
+ Genera el embedding de una imagen (función interna, no usar directamente).
141
+
142
+ Esta función hace el trabajo real de generar el embedding:
143
+ 1. Carga el modelo si no está cargado
144
+ 2. Convierte los bytes de la imagen a un objeto PIL Image
145
+ 3. Aplica las transformaciones de preprocesamiento
146
+ 4. Pasa la imagen por el modelo MegaDescriptor
147
+ 5. Normaliza el vector resultante (L2 normalization)
148
+ 6. Convierte a numpy array y limpia la memoria
149
+
150
+ Args:
151
+ image_bytes: Bytes de la imagen en cualquier formato (JPEG, PNG, etc.)
152
+
153
+ Returns:
154
+ numpy array float32 normalizado con el embedding
155
+
156
+ Nota: Esta función es síncrona. Para uso asíncrono, usar image_bytes_to_vec_async.
157
+ """
158
+ # Cargar el modelo (o obtener el ya cargado)
159
+ model, transforms, actual_dim = _load_model()
160
+
161
+ # Convertir bytes a imagen PIL
162
+ # io.BytesIO crea un objeto file-like en memoria desde los bytes
163
+ # Image.open() lee la imagen desde ese objeto
164
+ # .convert("RGB") asegura que la imagen tenga 3 canales (rojo, verde, azul)
165
+ # incluso si originalmente era escala de grises o tenía transparencia
166
+ img = Image.open(io.BytesIO(image_bytes)).convert("RGB")
167
+
168
+ # Aplicar transformaciones y generar embedding
169
+ # torch.inference_mode() es más eficiente que torch.no_grad() para inferencia
170
+ # Desactiva el cálculo de gradientes completamente (ahorra memoria y tiempo)
171
+ with torch.inference_mode():
172
+ # Aplicar transformaciones: resize, to tensor, normalize
173
+ # unsqueeze(0) agrega una dimensión de batch al inicio: [3, 384, 384] -> [1, 3, 384, 384]
174
+ # .to(DEVICE) mueve el tensor a GPU si está disponible
175
+ img_tensor = transforms(img).unsqueeze(0).to(DEVICE)
176
+
177
+ # Pasar la imagen por el modelo para obtener el embedding
178
+ # El modelo procesa la imagen y extrae características visuales
179
+ feats = model(img_tensor)
180
+
181
+ # Normalización L2: divide cada elemento por la norma del vector
182
+ # Esto hace que todos los embeddings tengan la misma "magnitud"
183
+ # Es importante para comparar embeddings usando producto punto o coseno
184
+ # feats.norm(dim=-1, keepdim=True) calcula la norma de cada vector en el batch
185
+ feats = feats / feats.norm(dim=-1, keepdim=True)
186
+
187
+ # Convertir de tensor de PyTorch a numpy array
188
+ # squeeze(0) elimina la dimensión de batch: [1, 1536] -> [1536]
189
+ # detach() desvincula del grafo computacional
190
+ # cpu() mueve a CPU (necesario antes de convertir a numpy)
191
+ # numpy() convierte a numpy array
192
+ # astype("float32") asegura que sea float32 (más eficiente que float64)
193
+ vec = feats.squeeze(0).detach().cpu().numpy().astype("float32")
194
+
195
+ # Limpiar memoria explícitamente para liberar RAM/VRAM
196
+ # Esto es importante cuando se procesan muchas imágenes
197
+ del img, img_tensor, feats
198
+
199
+ # Si estamos usando GPU, limpiar la caché de CUDA
200
+ # Esto libera memoria de GPU que PyTorch puede haber reservado
201
+ if DEVICE == "cuda":
202
+ torch.cuda.empty_cache()
203
+
204
+ print(f"🔍 Embedding generado: {vec.shape[-1]} dimensiones")
205
+
206
+ return vec
207
+
208
+ def image_bytes_to_vec(image_bytes: bytes) -> np.ndarray:
209
+ """
210
+ Genera embedding L2-normalizado usando MegaDescriptor (versión síncrona).
211
+
212
+ Esta es la función principal para generar embeddings de forma síncrona.
213
+ Es un wrapper simple de _generate_embedding() para mantener compatibilidad
214
+ con código existente que no usa asyncio.
215
+
216
+ Para nuevos código, preferir image_bytes_to_vec_async() que es más eficiente
217
+ en servidores asíncronos como FastAPI.
218
+
219
+ Args:
220
+ image_bytes: Bytes de la imagen en cualquier formato (JPEG, PNG, etc.)
221
+
222
+ Returns:
223
+ numpy array float32 normalizado con el embedding
224
+ La dimensión depende del modelo (MegaDescriptor-L-384 genera 1536 dimensiones)
225
+
226
+ Ejemplo:
227
+ >>> with open("foto_perro.jpg", "rb") as f:
228
+ ... image_bytes = f.read()
229
+ >>> embedding = image_bytes_to_vec(image_bytes)
230
+ >>> print(embedding.shape) # (1536,)
231
+ >>> print(embedding.dtype) # float32
232
+ """
233
+ # Versión síncrona simple para mantener compatibilidad con código existente
234
+ # Llama directamente a la función interna que hace el trabajo
235
+ return _generate_embedding(image_bytes)
utils/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Utilidades para el backend de PetFind"""
2
+ from .supabase_client import create_supabase_client, get_supabase_client
3
+
4
+ __all__ = ['create_supabase_client', 'get_supabase_client']
5
+
6
+
7
+
8
+
9
+
utils/supabase_client.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility para Crear Clientes de Supabase con Configuración Optimizada
3
+ =====================================================================
4
+
5
+ Este módulo proporciona funciones para crear clientes de Supabase con
6
+ configuración optimizada de timeouts y manejo de conexiones.
7
+
8
+ Problemas que resuelve:
9
+ - WinError 10060 en Windows (timeout de conexión)
10
+ - Conexiones lentas o con firewall
11
+ - Timeouts prematuros en operaciones largas
12
+ - Manejo de reintentos automáticos
13
+
14
+ La configuración incluye:
15
+ - Timeouts aumentados para conexiones lentas
16
+ - Límites de conexiones concurrentes
17
+ - Reintentos automáticos
18
+ - Keep-alive para conexiones persistentes
19
+ """
20
+
21
+ import os
22
+ from supabase import create_client, Client, ClientOptions
23
+ import httpx # Cliente HTTP con mejor control de timeouts
24
+ from typing import Optional
25
+
26
+ def create_supabase_client(
27
+ url: Optional[str] = None,
28
+ key: Optional[str] = None,
29
+ timeout: float = 30.0,
30
+ max_retries: int = 3
31
+ ) -> Client:
32
+ """
33
+ Crea un cliente de Supabase con configuración optimizada de timeouts.
34
+
35
+ Args:
36
+ url: URL de Supabase (por defecto: SUPABASE_URL del .env)
37
+ key: Service key (por defecto: SUPABASE_SERVICE_KEY del .env)
38
+ timeout: Timeout en segundos para las peticiones (default: 30s)
39
+ max_retries: Número máximo de reintentos (default: 3)
40
+
41
+ Returns:
42
+ Cliente de Supabase configurado
43
+
44
+ Raises:
45
+ ValueError: Si no se encuentran las credenciales
46
+ """
47
+ # Obtener credenciales del entorno si no se proporcionan
48
+ supabase_url = url or os.getenv("SUPABASE_URL")
49
+ supabase_key = key or os.getenv("SUPABASE_SERVICE_KEY")
50
+
51
+ if not supabase_url or not supabase_key:
52
+ raise ValueError("SUPABASE_URL y SUPABASE_SERVICE_KEY son requeridos")
53
+
54
+ # Configurar httpx con timeouts apropiados y retry logic
55
+ # Timeouts aumentados para conexiones lentas/firewalls de Windows
56
+ timeout_config = httpx.Timeout(
57
+ connect=60.0, # Tiempo para establecer la conexión (aumentado para Windows)
58
+ read=timeout, # Tiempo para leer la respuesta
59
+ write=timeout, # Tiempo para escribir la petición
60
+ pool=10.0 # Tiempo para obtener una conexión del pool
61
+ )
62
+
63
+ # Configurar límites de conexión
64
+ limits = httpx.Limits(
65
+ max_connections=100,
66
+ max_keepalive_connections=20,
67
+ keepalive_expiry=30.0
68
+ )
69
+
70
+ # Crear transporte con retry logic
71
+ transport = httpx.HTTPTransport(
72
+ retries=max_retries,
73
+ limits=limits
74
+ )
75
+
76
+ # Crear httpx client personalizado
77
+ http_client = httpx.Client(
78
+ timeout=timeout_config,
79
+ transport=transport,
80
+ follow_redirects=True
81
+ )
82
+
83
+ # Crear cliente de Supabase con httpx client personalizado
84
+ try:
85
+ # Crear opciones del cliente correctamente
86
+ options = ClientOptions(
87
+ schema="public",
88
+ headers={},
89
+ auto_refresh_token=True,
90
+ persist_session=False
91
+ )
92
+
93
+ client = create_client(
94
+ supabase_url,
95
+ supabase_key,
96
+ options=options
97
+ )
98
+
99
+ # Reemplazar el cliente HTTP interno con nuestra configuración
100
+ # El cliente de supabase-py usa httpx internamente
101
+ if hasattr(client, '_postgrest_client'):
102
+ if hasattr(client._postgrest_client, 'session'):
103
+ # Actualizar la sesión con nuestro cliente configurado
104
+ client._postgrest_client.session = http_client
105
+
106
+ # También actualizar el cliente de storage si existe
107
+ if hasattr(client, '_storage_client'):
108
+ if hasattr(client._storage_client, 'session'):
109
+ client._storage_client.session = http_client
110
+
111
+ return client
112
+
113
+ except Exception as e:
114
+ http_client.close()
115
+ raise RuntimeError(f"Error creando cliente de Supabase: {str(e)}") from e
116
+
117
+
118
+ def get_supabase_client() -> Client:
119
+ """
120
+ Obtiene un cliente de Supabase con configuración por defecto.
121
+ Útil para uso rápido sin configuración personalizada.
122
+
123
+ Returns:
124
+ Cliente de Supabase configurado
125
+
126
+ Raises:
127
+ ValueError: Si no se encuentran las credenciales
128
+ """
129
+ return create_supabase_client()
130
+