Spaces:
Runtime error
Runtime error
Upload 9 files
Browse files- app_whatsapp.py +33 -0
- config.py +31 -0
- core_pipeline.py +89 -0
- db_supabase.py +95 -0
- dockerfile +41 -0
- nlp_category.py +71 -0
- nlp_intent.py +62 -0
- nlp_ner.py +100 -0
- requirements.txt +17 -0
app_whatsapp.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app_whatsapp.py
|
| 2 |
+
from fastapi import FastAPI, Form
|
| 3 |
+
from fastapi.responses import PlainTextResponse
|
| 4 |
+
from core_pipeline import procesar_mensaje
|
| 5 |
+
from config import logger
|
| 6 |
+
|
| 7 |
+
app = FastAPI(title="Asistente Financiero WhatsApp")
|
| 8 |
+
|
| 9 |
+
# Twilio manda POST x-www-form-urlencoded a este endpoint
|
| 10 |
+
# Configura tu webhook en Twilio: https://TU-SERVIDOR/ngrok/etc/whatsapp
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@app.post("/whatsapp", response_class=PlainTextResponse)
|
| 14 |
+
async def whatsapp_webhook(
|
| 15 |
+
Body: str = Form(...),
|
| 16 |
+
From: str = Form(None),
|
| 17 |
+
WaId: str = Form(None),
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Webhook de Twilio WhatsApp.
|
| 21 |
+
Body = mensaje de texto
|
| 22 |
+
From = número del usuario (whatsapp:+51...)
|
| 23 |
+
WaId = ID de WhatsApp del usuario
|
| 24 |
+
"""
|
| 25 |
+
logger.info("===== WhatsApp WEBHOOK =====")
|
| 26 |
+
logger.info("From: %s | WaId: %s | Body: %s", From, WaId, Body)
|
| 27 |
+
|
| 28 |
+
resultado = procesar_mensaje(Body)
|
| 29 |
+
respuesta_texto = resultado["respuesta"]
|
| 30 |
+
|
| 31 |
+
# Respondemos en texto plano (Twilio lo acepta),
|
| 32 |
+
# si quieres TwiML puedes devolver XML.
|
| 33 |
+
return respuesta_texto
|
config.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# config.py
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
import logging
|
| 5 |
+
|
| 6 |
+
# Carga variables de entorno desde .env
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
# ========== LOGGING ==========
|
| 10 |
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
| 11 |
+
logging.basicConfig(
|
| 12 |
+
level=LOG_LEVEL,
|
| 13 |
+
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("finanzas_app")
|
| 17 |
+
|
| 18 |
+
# ========== TWILIO ==========
|
| 19 |
+
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN") # para validar firma si quieres
|
| 20 |
+
TWILIO_WHATSAPP_NUMBER = os.getenv("TWILIO_WHATSAPP_NUMBER", "whatsapp:+14155238886")
|
| 21 |
+
|
| 22 |
+
# ========== SUPABASE ==========
|
| 23 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 24 |
+
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY")
|
| 25 |
+
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", SUPABASE_ANON_KEY)
|
| 26 |
+
|
| 27 |
+
# Usuario por defecto (tu UUID de la tabla usuarios)
|
| 28 |
+
DEFAULT_USER_ID = os.getenv(
|
| 29 |
+
"DEFAULT_USER_ID",
|
| 30 |
+
"c6f4a4b6-1234-45ab-b0a2-88ac4ed4d111"
|
| 31 |
+
)
|
core_pipeline.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# core_pipeline.py
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from config import logger
|
| 4 |
+
from nlp_intent import predecir_intencion
|
| 5 |
+
from nlp_ner import extraer_entidades
|
| 6 |
+
from nlp_category import predecir_categoria
|
| 7 |
+
from db_supabase import insertar_gasto, insertar_ingreso
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _parse_monto_str(monto_str: str) -> float:
|
| 12 |
+
# Extrae número de algo como "50", "50.00", "S/ 50.90"
|
| 13 |
+
if not monto_str:
|
| 14 |
+
return 0.0
|
| 15 |
+
numeros = re.findall(r"\d+[.,]?\d*", monto_str)
|
| 16 |
+
if not numeros:
|
| 17 |
+
return 0.0
|
| 18 |
+
valor = numeros[0].replace(",", ".")
|
| 19 |
+
try:
|
| 20 |
+
return float(valor)
|
| 21 |
+
except ValueError:
|
| 22 |
+
return 0.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def procesar_mensaje(texto: str) -> Dict[str, Any]:
|
| 26 |
+
"""
|
| 27 |
+
Pipeline completo:
|
| 28 |
+
1. Predice intención
|
| 29 |
+
2. Extrae entidades (monto, fecha)
|
| 30 |
+
3. Predice categoría usando SetFit con el TEXTO COMPLETO
|
| 31 |
+
4. Aplica acción en Supabase según intención
|
| 32 |
+
5. Devuelve dict con info + mensaje para usuario
|
| 33 |
+
"""
|
| 34 |
+
logger.info("==== Procesando mensaje ====")
|
| 35 |
+
logger.info("Texto: %s", texto)
|
| 36 |
+
|
| 37 |
+
# 1. INTENCIÓN
|
| 38 |
+
intencion = predecir_intencion(texto)
|
| 39 |
+
|
| 40 |
+
# 2. ENTIDADES
|
| 41 |
+
ents = extraer_entidades(texto)
|
| 42 |
+
monto = _parse_monto_str(ents.get("monto"))
|
| 43 |
+
fecha = ents.get("fecha")
|
| 44 |
+
|
| 45 |
+
# 3. CATEGORÍA (texto completo, no la categoria_texto de NER)
|
| 46 |
+
categoria_final = predecir_categoria(texto)
|
| 47 |
+
|
| 48 |
+
# 4. LÓGICA DE NEGOCIO
|
| 49 |
+
respuesta = ""
|
| 50 |
+
|
| 51 |
+
if intencion == "agregar_gasto":
|
| 52 |
+
insertar_gasto(
|
| 53 |
+
monto=monto,
|
| 54 |
+
categoria_str=categoria_final,
|
| 55 |
+
fecha_str=fecha,
|
| 56 |
+
descripcion=texto
|
| 57 |
+
)
|
| 58 |
+
respuesta = f"Anoté un gasto de S/ {monto:.2f} en la categoría '{categoria_final}'."
|
| 59 |
+
|
| 60 |
+
elif intencion == "agregar_ingreso":
|
| 61 |
+
insertar_ingreso(
|
| 62 |
+
monto=monto,
|
| 63 |
+
categoria_ingreso=categoria_final,
|
| 64 |
+
fecha_str=fecha,
|
| 65 |
+
descripcion=texto
|
| 66 |
+
)
|
| 67 |
+
respuesta = f"Registré un ingreso de S/ {monto:.2f} como '{categoria_final}'."
|
| 68 |
+
|
| 69 |
+
elif intencion == "agregar_aporte":
|
| 70 |
+
# TODO: integrar con metas_ahorro
|
| 71 |
+
respuesta = "Detecté que quieres registrar un aporte a una meta de ahorro. Aún no he sido conectado a metas_ahorro 😅."
|
| 72 |
+
|
| 73 |
+
elif intencion == "puedo_gastar":
|
| 74 |
+
# TODO: leer presupuestos_mensuales y responder según límites
|
| 75 |
+
respuesta = "Según tu presupuesto, todavía no tengo conectada la lógica para validar si puedes gastar eso 😅, pero la intención está detectada."
|
| 76 |
+
|
| 77 |
+
else:
|
| 78 |
+
respuesta = f"Detecté intención '{intencion}' con categoría '{categoria_final}', pero aún no tengo lógica asociada."
|
| 79 |
+
|
| 80 |
+
logger.info("[PIPELINE] Resultado: intencion=%s, monto=%s, categoria=%s, fecha=%s",
|
| 81 |
+
intencion, monto, categoria_final, fecha)
|
| 82 |
+
|
| 83 |
+
return {
|
| 84 |
+
"intencion": intencion,
|
| 85 |
+
"monto": monto,
|
| 86 |
+
"categoria_final": categoria_final,
|
| 87 |
+
"fecha": fecha,
|
| 88 |
+
"respuesta": respuesta,
|
| 89 |
+
}
|
db_supabase.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# db_supabase.py
|
| 2 |
+
from supabase import create_client, Client
|
| 3 |
+
from datetime import date
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from config import SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, DEFAULT_USER_ID, logger
|
| 6 |
+
|
| 7 |
+
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_or_create_categoria(nombre: str, tipo: str = "gasto") -> Optional[int]:
|
| 11 |
+
"""
|
| 12 |
+
Busca una categoría por nombre (case insensitive).
|
| 13 |
+
Si no existe, la crea.
|
| 14 |
+
Devuelve id_categoria o None si algo falla.
|
| 15 |
+
"""
|
| 16 |
+
logger.info("[DB] get_or_create_categoria: %s (%s)", nombre, tipo)
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
res = (
|
| 20 |
+
supabase.table("categorias")
|
| 21 |
+
.select("id_categoria")
|
| 22 |
+
.eq("id_usuario", DEFAULT_USER_ID)
|
| 23 |
+
.ilike("nombre_categoria", nombre)
|
| 24 |
+
.execute()
|
| 25 |
+
)
|
| 26 |
+
data = res.data or []
|
| 27 |
+
if data:
|
| 28 |
+
return data[0]["id_categoria"]
|
| 29 |
+
|
| 30 |
+
# Crear nueva categoría
|
| 31 |
+
insert_res = (
|
| 32 |
+
supabase.table("categorias")
|
| 33 |
+
.insert(
|
| 34 |
+
{
|
| 35 |
+
"id_usuario": DEFAULT_USER_ID,
|
| 36 |
+
"nombre_categoria": nombre,
|
| 37 |
+
"tipo_categoria": "ingreso" if tipo.startswith("ingreso") else "gasto",
|
| 38 |
+
"descripcion": f"Creada automáticamente para {nombre}",
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
.execute()
|
| 42 |
+
)
|
| 43 |
+
return insert_res.data[0]["id_categoria"]
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error("[DB] Error get_or_create_categoria: %s", e, exc_info=True)
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def insertar_gasto(monto: float, categoria_str: str, fecha_str: Optional[str], descripcion: str):
|
| 50 |
+
logger.info("[DB] Insertar gasto: monto=%s, cat=%s, fecha=%s", monto, categoria_str, fecha_str)
|
| 51 |
+
|
| 52 |
+
id_categoria = get_or_create_categoria(categoria_str, tipo="gasto")
|
| 53 |
+
|
| 54 |
+
payload = {
|
| 55 |
+
"id_usuario": DEFAULT_USER_ID,
|
| 56 |
+
"id_categoria": id_categoria,
|
| 57 |
+
"id_tipo": 2, # por ahora, por defecto "Variable"
|
| 58 |
+
"monto": monto,
|
| 59 |
+
"descripcion": descripcion,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if fecha_str:
|
| 63 |
+
payload["fecha"] = fecha_str
|
| 64 |
+
else:
|
| 65 |
+
payload["fecha"] = str(date.today())
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
supabase.table("gastos").insert(payload).execute()
|
| 69 |
+
logger.info("[DB] Gasto insertado correctamente.")
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error("[DB] Error al insertar gasto: %s", e, exc_info=True)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def insertar_ingreso(monto: float, categoria_ingreso: str, fecha_str: Optional[str], descripcion: str):
|
| 75 |
+
logger.info("[DB] Insertar ingreso: monto=%s, cat=%s, fecha=%s", monto, categoria_ingreso, fecha_str)
|
| 76 |
+
|
| 77 |
+
id_categoria = get_or_create_categoria(categoria_ingreso, tipo="ingreso")
|
| 78 |
+
|
| 79 |
+
payload = {
|
| 80 |
+
"id_usuario": DEFAULT_USER_ID,
|
| 81 |
+
"id_categoria": id_categoria,
|
| 82 |
+
"monto": monto,
|
| 83 |
+
"descripcion": descripcion,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if fecha_str:
|
| 87 |
+
payload["fecha"] = fecha_str
|
| 88 |
+
else:
|
| 89 |
+
payload["fecha"] = str(date.today())
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
supabase.table("ingresos").insert(payload).execute()
|
| 93 |
+
logger.info("[DB] Ingreso insertado correctamente.")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error("[DB] Error al insertar ingreso: %s", e, exc_info=True)
|
dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---------------------------------------------------
|
| 2 |
+
# Base image: Python optimizado para ML + CPU
|
| 3 |
+
# ---------------------------------------------------
|
| 4 |
+
FROM python:3.10-slim
|
| 5 |
+
|
| 6 |
+
# ---------------------------------------------------
|
| 7 |
+
# Instalar dependencias del sistema
|
| 8 |
+
# ---------------------------------------------------
|
| 9 |
+
RUN apt-get update && apt-get install -y \
|
| 10 |
+
git \
|
| 11 |
+
libsndfile1 \
|
| 12 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------
|
| 15 |
+
# Crear directorio de la app
|
| 16 |
+
# ---------------------------------------------------
|
| 17 |
+
WORKDIR /app
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------
|
| 20 |
+
# Copiar requirements.txt e instalar dependencias
|
| 21 |
+
# ---------------------------------------------------
|
| 22 |
+
COPY requirements.txt /app/requirements.txt
|
| 23 |
+
|
| 24 |
+
RUN pip install --upgrade pip
|
| 25 |
+
RUN pip install -r /app/requirements.txt
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------
|
| 28 |
+
# Copiar TODO el proyecto dentro del contenedor
|
| 29 |
+
# ---------------------------------------------------
|
| 30 |
+
COPY . /app
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------
|
| 33 |
+
# Puerto para HuggingFace Spaces
|
| 34 |
+
# ---------------------------------------------------
|
| 35 |
+
EXPOSE 7860
|
| 36 |
+
|
| 37 |
+
# ---------------------------------------------------
|
| 38 |
+
# Command para iniciar FastAPI en HF Spaces
|
| 39 |
+
# HuggingFace espera que corras en el puerto 7860
|
| 40 |
+
# ---------------------------------------------------
|
| 41 |
+
CMD ["uvicorn", "app_whatsapp:app", "--host", "0.0.0.0", "--port", "7860"]
|
nlp_category.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# nlp_category.py
|
| 2 |
+
import os
|
| 3 |
+
import logging
|
| 4 |
+
import unicodedata
|
| 5 |
+
from setfit import SetFitModel
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
# ======================
|
| 10 |
+
# 1. Cargar modelo SetFit
|
| 11 |
+
# ======================
|
| 12 |
+
|
| 13 |
+
SETFIT_MODEL_PATH = "Alecit1234/modelo_finanzas_peru_v1"
|
| 14 |
+
|
| 15 |
+
logger.info("Cargando modelo SetFit desde HuggingFace...")
|
| 16 |
+
|
| 17 |
+
model_setfit = SetFitModel.from_pretrained(
|
| 18 |
+
SETFIT_MODEL_PATH,
|
| 19 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
logger.info("Modelo SetFit cargado correctamente.")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ===========================
|
| 26 |
+
# 2. Categorías (Label Map)
|
| 27 |
+
# ===========================
|
| 28 |
+
|
| 29 |
+
CATEGORY_LABEL_MAP = {
|
| 30 |
+
0:"comida", 1:"supermercado", 2:"transporte", 3:"taxi", 4:"entretenimiento",
|
| 31 |
+
5:"educacion", 6:"tecnologia", 7:"servicios", 8:"fitness", 9:"imprevistos",
|
| 32 |
+
10:"delivery", 11:"mascotas", 12:"familia", 13:"salud",
|
| 33 |
+
14:"ingreso_beca", 15:"ingreso_trabajo", 16:"ingreso_familia",
|
| 34 |
+
17:"ingreso_venta", 18:"ingreso_freelance", 19:"ingreso_extra"
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ======================
|
| 39 |
+
# 3. Normalizar texto
|
| 40 |
+
# ======================
|
| 41 |
+
|
| 42 |
+
def _normalizar(texto: str) -> str:
|
| 43 |
+
texto = texto.lower()
|
| 44 |
+
texto = unicodedata.normalize("NFD", texto)
|
| 45 |
+
return "".join(c for c in texto if unicodedata.category(c) != "Mn")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ======================
|
| 49 |
+
# 4. Predicción categoría
|
| 50 |
+
# ======================
|
| 51 |
+
|
| 52 |
+
def predecir_categoria(texto: str) -> str:
|
| 53 |
+
"""
|
| 54 |
+
Predice categoría usando SOLO el texto completo.
|
| 55 |
+
NER aporta monto y fecha, pero NO categoría.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
if not texto or texto.strip() == "":
|
| 59 |
+
logger.warning("Texto vacío recibido en predecir_categoria()")
|
| 60 |
+
return "otros"
|
| 61 |
+
|
| 62 |
+
texto_norm = _normalizar(texto)
|
| 63 |
+
|
| 64 |
+
logger.debug(f"[SETFIT] Texto normalizado: {texto_norm}")
|
| 65 |
+
|
| 66 |
+
pred_id = model_setfit.predict([texto_norm])[0].item()
|
| 67 |
+
categoria = CATEGORY_LABEL_MAP[pred_id]
|
| 68 |
+
|
| 69 |
+
logger.info(f"[SETFIT] Categoría predicha: {categoria} (id={pred_id})")
|
| 70 |
+
|
| 71 |
+
return categoria
|
nlp_intent.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# nlp_intent.py
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import torch
|
| 5 |
+
import logging
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForSequenceClassification
|
| 7 |
+
from huggingface_hub import hf_hub_download
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# ========== RUTA DEL MODELO ==========
|
| 12 |
+
INTENT_MODEL_PATH = "Alecit1234/modelo_intenciones"
|
| 13 |
+
|
| 14 |
+
logger.info("Cargando modelo de intenciones desde HuggingFace Hub: %s", INTENT_MODEL_PATH)
|
| 15 |
+
|
| 16 |
+
# ========== CARGA DEL MODELO ==========
|
| 17 |
+
tokenizer_int = AutoTokenizer.from_pretrained(
|
| 18 |
+
INTENT_MODEL_PATH,
|
| 19 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
model_int = AutoModelForSequenceClassification.from_pretrained(
|
| 23 |
+
INTENT_MODEL_PATH,
|
| 24 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# ========== LABEL MAP ==========
|
| 28 |
+
label_map_path = hf_hub_download(
|
| 29 |
+
repo_id=INTENT_MODEL_PATH,
|
| 30 |
+
filename="label_map.json",
|
| 31 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
with open(label_map_path, "r", encoding="utf-8") as f:
|
| 35 |
+
INTENT_LABEL_MAP = json.load(f)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ========== FUNCIÓN PRINCIPAL ==========
|
| 39 |
+
def predecir_intencion(texto: str) -> str:
|
| 40 |
+
"""Predice intención de un texto usando modelo de clasificación."""
|
| 41 |
+
if not texto or texto.strip() == "":
|
| 42 |
+
logger.warning("[INTENT] Texto vacío recibido. Asignando intención 'otros'.")
|
| 43 |
+
return "otros"
|
| 44 |
+
|
| 45 |
+
logger.debug("[INTENT] Texto de entrada: %s", texto)
|
| 46 |
+
|
| 47 |
+
inputs = tokenizer_int(
|
| 48 |
+
texto,
|
| 49 |
+
return_tensors="pt",
|
| 50 |
+
truncation=True,
|
| 51 |
+
max_length=64,
|
| 52 |
+
padding="max_length"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
with torch.no_grad():
|
| 56 |
+
logits = model_int(**inputs).logits
|
| 57 |
+
pred_id = torch.argmax(logits, dim=1).item()
|
| 58 |
+
|
| 59 |
+
intent = INTENT_LABEL_MAP[str(pred_id)]
|
| 60 |
+
|
| 61 |
+
logger.info("[INTENT] Predicción: %s (id=%s)", intent, pred_id)
|
| 62 |
+
return intent
|
nlp_ner.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# nlp_ner.py
|
| 2 |
+
import os
|
| 3 |
+
import json
|
| 4 |
+
import logging
|
| 5 |
+
import torch
|
| 6 |
+
from transformers import AutoTokenizer, AutoModelForTokenClassification
|
| 7 |
+
from huggingface_hub import hf_hub_download
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
# =====================================
|
| 12 |
+
# 1. NOMBRE DEL MODELO EN HUGGINGFACE
|
| 13 |
+
# =====================================
|
| 14 |
+
NER_MODEL_PATH = "Alecit1234/modelo_ner"
|
| 15 |
+
|
| 16 |
+
logger.info("Cargando modelo NER desde HuggingFace Hub: %s", NER_MODEL_PATH)
|
| 17 |
+
|
| 18 |
+
# =====================================
|
| 19 |
+
# 2. CARGAR TOKENIZER & MODEL
|
| 20 |
+
# =====================================
|
| 21 |
+
tokenizer_ner = AutoTokenizer.from_pretrained(
|
| 22 |
+
NER_MODEL_PATH,
|
| 23 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
model_ner = AutoModelForTokenClassification.from_pretrained(
|
| 27 |
+
NER_MODEL_PATH,
|
| 28 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
# =====================================
|
| 32 |
+
# 3. CARGAR LABEL_MAP DESDE HF
|
| 33 |
+
# =====================================
|
| 34 |
+
label_map_path = hf_hub_download(
|
| 35 |
+
repo_id=NER_MODEL_PATH,
|
| 36 |
+
filename="label_map.json",
|
| 37 |
+
use_auth_token=os.getenv("HUGGINGFACE_AUTH_TOKEN")
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
with open(label_map_path, "r", encoding="utf-8") as f:
|
| 41 |
+
NER_LABELS = json.load(f)
|
| 42 |
+
|
| 43 |
+
logger.info("Etiquetas NER cargadas: %s", NER_LABELS)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# =====================================
|
| 47 |
+
# 4. FUNCIÓN: EXTRAER ENTIDADES
|
| 48 |
+
# =====================================
|
| 49 |
+
def extraer_entidades(texto: str) -> dict:
|
| 50 |
+
"""
|
| 51 |
+
Extrae entidades: monto, fecha, categoria_texto (solo referencia)
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
logger.debug("[NER] Procesando texto: %s", texto)
|
| 55 |
+
|
| 56 |
+
inputs = tokenizer_ner(
|
| 57 |
+
texto,
|
| 58 |
+
return_tensors="pt",
|
| 59 |
+
truncation=True,
|
| 60 |
+
max_length=64
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
with torch.no_grad():
|
| 64 |
+
outputs = model_ner(**inputs)
|
| 65 |
+
|
| 66 |
+
preds = outputs.logits.argmax(dim=-1)[0].tolist()
|
| 67 |
+
tokens = tokenizer_ner.convert_ids_to_tokens(inputs["input_ids"][0])
|
| 68 |
+
|
| 69 |
+
entidades = {"monto": None, "categoria_texto": None, "fecha": None}
|
| 70 |
+
|
| 71 |
+
palabra = ""
|
| 72 |
+
tipo_actual = None
|
| 73 |
+
|
| 74 |
+
for tok, pred_id in zip(tokens, preds):
|
| 75 |
+
label = NER_LABELS[str(pred_id)]
|
| 76 |
+
|
| 77 |
+
# Cuando cambia la etiqueta
|
| 78 |
+
if label == "O":
|
| 79 |
+
if tipo_actual and palabra:
|
| 80 |
+
entidades[tipo_actual] = palabra
|
| 81 |
+
palabra = ""
|
| 82 |
+
tipo_actual = None
|
| 83 |
+
continue
|
| 84 |
+
|
| 85 |
+
# Asignación del tipo
|
| 86 |
+
if label == "MONEY":
|
| 87 |
+
tipo_actual = "monto"
|
| 88 |
+
elif label == "CATEGORY":
|
| 89 |
+
tipo_actual = "categoria_texto"
|
| 90 |
+
elif label == "DATE":
|
| 91 |
+
tipo_actual = "fecha"
|
| 92 |
+
|
| 93 |
+
palabra += tok.replace("▁", "")
|
| 94 |
+
|
| 95 |
+
# Último token acumulado
|
| 96 |
+
if tipo_actual and palabra:
|
| 97 |
+
entidades[tipo_actual] = palabra
|
| 98 |
+
|
| 99 |
+
logger.info("[NER] Resultado entidades: %s", entidades)
|
| 100 |
+
return entidades
|
requirements.txt
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.110.0
|
| 2 |
+
uvicorn[standard]==0.29.0
|
| 3 |
+
python-dotenv==1.0.1
|
| 4 |
+
|
| 5 |
+
supabase==2.4.3
|
| 6 |
+
|
| 7 |
+
transformers==4.38.2
|
| 8 |
+
huggingface-hub==0.20.3
|
| 9 |
+
sentence-transformers==2.2.2
|
| 10 |
+
setfit==1.0.3
|
| 11 |
+
|
| 12 |
+
torch==2.0.1
|
| 13 |
+
numpy
|
| 14 |
+
pydantic>=1.10
|
| 15 |
+
requests
|
| 16 |
+
python-multipart
|
| 17 |
+
accelerate
|