Spaces:
Runtime error
Runtime error
Upload 24 files
Browse files- routers/__init__.py +3 -0
- routers/__pycache__/__init__.cpython-311.pyc +0 -0
- routers/__pycache__/ai_search.cpython-311.pyc +0 -0
- routers/__pycache__/embeddings.cpython-311.pyc +0 -0
- routers/__pycache__/embeddings_supabase.cpython-311.pyc +0 -0
- routers/__pycache__/matches.cpython-311.pyc +0 -0
- routers/__pycache__/reports.cpython-311.pyc +0 -0
- routers/__pycache__/reports_labels.cpython-311.pyc +0 -0
- routers/ai_search.py +578 -0
- routers/direct_matches.py +300 -0
- routers/embeddings.py +212 -0
- routers/embeddings_supabase.py +279 -0
- routers/fix_embeddings.py +237 -0
- routers/matches.py +409 -0
- routers/pets.py +578 -0
- routers/rag_search.py +385 -0
- routers/reports.py +594 -0
- routers/reports_labels.py +53 -0
- services/__init__.py +1 -0
- services/__pycache__/__init__.cpython-311.pyc +0 -0
- services/__pycache__/embeddings.cpython-311.pyc +0 -0
- services/embeddings.py +235 -0
- utils/__init__.py +9 -0
- utils/supabase_client.py +130 -0
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 |
+
|