feat: translate comments, docstrings, and log messages to Spanish.

This commit is contained in:
Marco Gallegos
2025-12-18 12:33:39 -06:00
parent 519a5ad705
commit 3b9346fe0f
12 changed files with 205 additions and 205 deletions

104
README.md
View File

@@ -1,81 +1,81 @@
# Telegram Expenses Bot # Bot de Gastos de Telegram
A modular, AI-powered bot to track and manage expenses via Telegram. It uses LLMs to extract structured data from text, images, and audio, and persists them for easy reporting. Un bot modular impulsado por IA para rastrear y gestionar gastos a través de Telegram. Utiliza LLMs para extraer datos estructurados de texto, imágenes y audio, y los persiste para facilitar la generación de informes.
## Key Features ## Características Clave
- 🤖 **AI Extraction**: Automatically parses amount, currency, description, and date from natural language. - 🤖 **Extracción por IA**: Analiza automáticamente el monto, la moneda, la descripción y la fecha a partir del lenguaje natural.
- 🖼️ **Multimodal**: Supports text, images (receipts), and audio (voice notes) - *in progress*. - 🖼️ **Multimodal**: Soporta texto, imágenes (recibos) y audio (notas de voz) - *en progreso*.
- 📊 **Structured Storage**: Saves data to a database with support for exporting to CSV/Google Sheets. - 📊 **Almacenamiento Estructurado**: Guarda los datos en una base de datos con soporte para exportar a CSV/Google Sheets.
- 🛡️ **Audit Trail**: Keeps track of raw inputs and AI confidence scores for reliability. - 🛡️ **Pista de Auditoría**: Realiza un seguimiento de las entradas sin procesar y las puntuaciones de confianza de la IA para mayor fiabilidad.
- 🐳 **Dockerized**: Easy deployment using Docker and Docker Compose. - 🐳 **Dockerizado**: Despliegue sencillo utilizando Docker y Docker Compose.
## Project Structure ## Estructura del Proyecto
The project has transitioned to a more robust, service-oriented architecture located in the `/app` directory. El proyecto ha transicionado a una arquitectura más robusta y orientada a servicios ubicada en el directorio `/app`.
- **/app**: Core application logic. - **/app**: Lógica central de la aplicación.
- **/ai**: LLM integration, prompts, and extraction logic. - **/ai**: Integración con LLM, prompts y lógica de extracción.
- **/audit**: Logging and raw data storage for traceability. - **/audit**: Registro y almacenamiento de datos sin procesar para trazabilidad.
- **/ingestion**: Handlers for different input types (text, image, audio, document). - **/ingestion**: Manejadores para diferentes tipos de entrada (texto, imagen, audio, documento).
- **/integrations**: External services (e.g., exporters, webhook clients). - **/integrations**: Servicios externos (ej. exportadores, clientes de webhook).
- **/modules**: Telegram bot command handlers (`/start`, `/status`, etc.). - **/modules**: Manejadores de comandos del bot de Telegram (`/start`, `/status`, etc.).
- **/persistence**: Database models and repositories (SQLAlchemy). - **/persistence**: Modelos de base de datos y repositorios (SQLAlchemy).
- **/preprocessing**: Data cleaning, validation, and language detection. - **/preprocessing**: Limpieza de datos, validación y detección de idioma.
- **/schema**: Pydantic models for data validation and API documentation. - **/schema**: Modelos Pydantic para validación de datos y documentación de la API.
- **main.py**: FastAPI entry point and webhook handlers. - **main.py**: Punto de entrada de FastAPI y manejadores de webhooks.
- **router.py**: Orchestrates the processing pipeline. - **router.py**: Orquesta el pipeline de procesamiento.
- **/config**: Static configuration files (keywords, providers). - **/config**: Archivos de configuración estática (palabras clave, proveedores).
- **/src**: Legacy/Initial implementation (Phase 1 & 2). - **/src**: Implementación heredada/inicial (Fases 1 y 2).
- **tasks.md**: Detailed project roadmap and progress tracker. - **tasks.md**: Hoja de ruta detallada del proyecto y seguidor de progreso.
## How It Works (Workflow) ## Cómo Funciona (Flujo de Trabajo)
1. **Input**: The user sends a message to the Telegram bot (text, image, or voice). 1. **Entrada**: El usuario envía un mensaje al bot de Telegram (texto, imagen o voz).
2. **Ingestion**: The bot receives the update and passes it to the `/app/ingestion` layer to extract raw text. 2. **Ingestión**: El bot recibe la actualización y la pasa a la capa `/app/ingestion` para extraer el texto sin procesar.
3. **Routing**: `router.py` takes the raw text and coordinates the next steps. 3. **Enrutamiento**: `router.py` toma el texto sin procesar y coordina los siguientes pasos.
4. **Extraction**: The `/app/ai/extractor.py` uses OpenAI's GPT models to parse the text into a structured `ExtractedExpense`. 4. **Extracción**: `/app/ai/extractor.py` utiliza los modelos GPT de OpenAI para analizar el texto en un `ExtractedExpense` estructurado.
5. **Audit & Classify**: The `/app/ai/classifier.py` assigns categories and a confidence score. 5. **Auditoría y Clasificación**: `/app/ai/classifier.py` asigna categorías y una puntuación de confianza.
6. **Persistence**: If confidence is high, the expense is automatically saved via `/app/persistence/repositories.py`. If low, it awaits manual confirmation. 6. **Persistencia**: Si la confianza es alta, el gasto se guarda automáticamente a través de `/app/persistence/repositories.py`. Si es baja, espera confirmación manual.
## Project Status ## Estado del Proyecto
Current Phase: **Phase 3/4 - Intelligence & Processing** Fase Actual: **Fase 3/4 - Inteligencia y Procesamiento**
- [x] **Phase 1: Infrastructure**: FastAPI, Docker, and basic input handling. - [x] **Fase 1: Infraestructura**: FastAPI, Docker y manejo básico de entradas.
- [x] **Phase 2: Data Models**: Explicit expense states and Pydantic schemas. - [x] **Fase 2: Modelos de Datos**: Estados de gastos explícitos y esquemas Pydantic.
- [/] **Phase 3: Logic**: Configuration loaders and provider matching (In Progress). - [x] **Fase 3: Lógica**: Cargadores de configuración y coincidencia de proveedores (Completado).
- [/] **Phase 4: AI Analyst**: Multimodal extraction and confidence scoring (In Progress). - [/] **Fase 4: Analista de IA**: Extracción multimodal y puntuación de confianza (En Progreso).
## Setup & Development ## Configuración y Desarrollo
### 1. Environment Variables ### 1. Variables de Entorno
Copy `.env.example` to `.env` and fill in your credentials: Copia `.env.example` a `.env` y completa tus credenciales:
```bash ```bash
TELEGRAM_TOKEN=your_bot_token TELEGRAM_TOKEN=tu_token_de_bot
OPENAI_API_KEY=your_openai_key OPENAI_API_KEY=tu_clave_de_openai
DATABASE_URL=mysql+pymysql://user:password@db:3306/expenses DATABASE_URL=mysql+pymysql://usuario:contraseña@db:3306/expenses
# MySQL specific (for Docker) # Específico de MySQL (para Docker)
MYSQL_ROOT_PASSWORD=root_password MYSQL_ROOT_PASSWORD=contraseña_root
MYSQL_DATABASE=expenses MYSQL_DATABASE=expenses
MYSQL_USER=user MYSQL_USER=usuario
MYSQL_PASSWORD=password MYSQL_PASSWORD=contraseña
``` ```
### 2. Run with Docker ### 2. Ejecutar con Docker
```bash ```bash
docker-compose up --build docker-compose up --build
``` ```
### 3. Local Development (FastAPI) ### 3. Desarrollo Local (FastAPI)
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt
uvicorn app.main:app --reload uvicorn app.main:app --reload
``` ```
### 4. Running the Bot (Polling) ### 4. Ejecutar el Bot (Polling)
For local testing without webhooks, you can run a polling script that uses the handlers in `app/modules`. Para pruebas locales sin webhooks, puedes ejecutar un script de polling que utilice los manejadores en `app/modules`.
--- ---
*Maintained by Marco Gallegos* *Mantenido por Marco Gallegos*

View File

@@ -1,5 +1,5 @@
""" """
AI-powered classification and confidence scoring. Clasificación y puntuación de confianza impulsada por IA.
""" """
import openai import openai
import json import json
@@ -17,25 +17,25 @@ logger = logging.getLogger(__name__)
def classify_and_audit(expense: ProvisionalExpense) -> ProvisionalExpense: def classify_and_audit(expense: ProvisionalExpense) -> ProvisionalExpense:
""" """
Uses an AI model to audit an extracted expense, providing a confidence Utiliza un modelo de IA para auditar un gasto extraído, proporcionando una puntuación
score and notes. This is a placeholder for a more complex classification de confianza y notas. Este es un marcador de posición para una lógica de clasificación
and validation logic. y validación más compleja.
Args: Args:
expense: A ProvisionalExpense object with extracted data. expense: Un objeto ProvisionalExpense con datos extraídos.
Returns: Returns:
The same ProvisionalExpense object, updated with the audit findings. El mismo objeto ProvisionalExpense, actualizado con los hallazgos de la auditoría.
""" """
logger.info(f"Starting AI audit for expense: {expense.extracted_data.description}") logger.info(f"Iniciando auditoría por IA para el gasto: {expense.extracted_data.description}")
# For now, this is a placeholder. A real implementation would # Por ahora, esto es un marcador de posición. Una implementación real
# call an AI model like in the extractor. # llamaría a un modelo de IA como en el extractor.
# For demonstration, we'll just assign a high confidence score. # Para la demostración, simplemente asignaremos una puntuación de confianza alta.
expense.confidence_score = 0.95 expense.confidence_score = 0.95
expense.validation_notes.append("AI audit placeholder: auto-approved.") expense.validation_notes.append("Marcador de posición de auditoría por IA: aprobado automáticamente.")
expense.processing_method = "ai_inference" # Assume AI was used expense.processing_method = "ai_inference" # Asumir que se usó IA
logger.info("AI audit placeholder complete.") logger.info("AI audit placeholder complete.")

View File

@@ -1,5 +1,5 @@
""" """
AI-powered data extraction from raw text. Extracción de datos impulsada por IA a partir de texto sin procesar.
""" """
import openai import openai
import json import json
@@ -17,15 +17,15 @@ logger = logging.getLogger(__name__)
def extract_expense_data(text: str) -> ExtractedExpense: def extract_expense_data(text: str) -> ExtractedExpense:
""" """
Uses an AI model to extract structured expense data from a raw text string. Utiliza un modelo de IA para extraer datos de gastos estructurados de una cadena de texto sin procesar.
Args: Args:
text: The raw text from user input, OCR, or transcription. text: El texto sin procesar de la entrada del usuario, OCR o transcripción.
Returns: Returns:
An ExtractedExpense object with the data found by the AI. Un objeto ExtractedExpense con los datos encontrados por la IA.
""" """
logger.info(f"Starting AI extraction for text: '{text[:100]}...'") logger.info(f"Iniciando extracción por IA para el texto: '{text[:100]}...'")
try: try:
response = openai.ChatCompletion.create( response = openai.ChatCompletion.create(
@@ -38,23 +38,23 @@ def extract_expense_data(text: str) -> ExtractedExpense:
response_format={"type": "json_object"} response_format={"type": "json_object"}
) )
# The response from OpenAI should be a JSON string in the message content # La respuesta de OpenAI debería ser una cadena JSON en el contenido del mensaje
json_response = response.choices[0].message['content'] json_response = response.choices[0].message['content']
extracted_data = json.loads(json_response) extracted_data = json.loads(json_response)
logger.info(f"AI extraction successful. Raw JSON: {extracted_data}") logger.info(f"Extracción por IA exitosa. JSON sin procesar: {extracted_data}")
# Add the original text to the model for audit purposes # Añadir el texto original al modelo para fines de auditoría
extracted_data['raw_text'] = text extracted_data['raw_text'] = text
return ExtractedExpense(**extracted_data) return ExtractedExpense(**extracted_data)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON from AI response: {e}") logger.error(f"Error al decodificar JSON de la respuesta de la IA: {e}")
# Return a model with only the raw text for manual review # Devolver un modelo con solo el texto sin procesar para revisión manual
return ExtractedExpense(raw_text=text) return ExtractedExpense(raw_text=text)
except Exception as e: except Exception as e:
logger.error(f"An unexpected error occurred during AI extraction: {e}") logger.error(f"Ocurrió un error inesperado durante la extracción por IA: {e}")
# Return a model with only the raw text # Devolver un modelo con solo el texto sin procesar
return ExtractedExpense(raw_text=text) return ExtractedExpense(raw_text=text)

View File

@@ -1,36 +1,36 @@
""" """
Configuration loader. Cargador de configuración.
Loads environment variables from a .env file and makes them available as a Config object. Carga las variables de entorno desde un archivo .env y las pone a disposición como un objeto Config.
""" """
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
# Load environment variables from .env file in the project root # Cargar variables de entorno desde el archivo .env en la raíz del proyecto
# Note: The path is relative to the file's location in the final `app` directory # Nota: La ruta es relativa a la ubicación del archivo en el directorio final `app`
dotenv_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env') dotenv_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '.env')
if os.path.exists(dotenv_path): if os.path.exists(dotenv_path):
load_dotenv(dotenv_path) load_dotenv(dotenv_path)
class Config: class Config:
""" """
Holds the application's configuration. Contiene la configuración de la aplicación.
""" """
# Telegram Bot Token # Token del Bot de Telegram
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN") TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
# OpenAI API Key # OpenAI API Key
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
# Supergroup ID for the bot # ID del Supergrupo para el bot
SUPERGROUP_ID = os.getenv("SUPERGROUP_ID") SUPERGROUP_ID = os.getenv("SUPERGROUP_ID")
# Database URL (e.g., "sqlite:///expenses.db") # URL de la Base de Datos (ej., "sqlite:///expenses.db")
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///../database.db") DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///../database.db")
# Log level # Nivel de registro
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
# Create a single instance of the configuration # Crear una única instancia de la configuración
config = Config() config = Config()

View File

@@ -1,17 +1,17 @@
""" """
Application entry point. Punto de entrada de la aplicación.
Initializes the FastAPI application, sets up logging, database, Inicializa la aplicación FastAPI, configura el registro, la base de datos
and defines the main API endpoints. y define los principales endpoints de la API.
""" """
import logging import logging
from fastapi import FastAPI, Depends, HTTPException from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
# It's crucial to set up the config before other imports # Es crucial configurar la configuración antes de otras importaciones
from app.config import config from app.config import config
# Now, set up logging based on the config # Ahora, configurar el registro basado en la configuración
logging.basicConfig(level=config.LOG_LEVEL.upper()) logging.basicConfig(level=config.LOG_LEVEL.upper())
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,46 +20,46 @@ from app.schema.base import RawInput
from app.router import process_expense_input from app.router import process_expense_input
from app.persistence import repositories, db from app.persistence import repositories, db
# Create database tables on startup # Crear tablas de base de datos al inicio
# This is simple, but for production, you'd use migrations (e.g., Alembic) # Esto es simple, pero para producción, usarías migraciones (ej. Alembic)
repositories.create_tables() repositories.create_tables()
# Initialize the FastAPI app # Inicializar la aplicación FastAPI
app = FastAPI( app = FastAPI(
title="Telegram Expenses Bot API", title="API del Bot de Gastos de Telegram",
description="Processes and manages expense data from various sources.", description="Procesa y gestiona datos de gastos de diversas fuentes.",
version="1.0.0" version="1.0.0"
) )
@app.on_event("startup") @app.on_event("startup")
async def startup_event(): async def startup_event():
logger.info("Application startup complete.") logger.info("Inicio de la aplicación completado.")
logger.info(f"Log level is set to: {config.LOG_LEVEL.upper()}") logger.info(f"El nivel de registro está establecido en: {config.LOG_LEVEL.upper()}")
@app.get("/", tags=["Status"]) @app.get("/", tags=["Estado"])
async def root(): async def root():
"""Health check endpoint.""" """Endpoint de verificación de salud."""
return {"message": "Telegram Expenses Bot API is running."} return {"message": "La API del Bot de Gastos de Telegram está en ejecución."}
@app.post("/webhook/telegram", tags=["Webhooks"]) @app.post("/webhook/telegram", tags=["Webhooks"])
async def process_telegram_update(request: dict): async def process_telegram_update(request: dict):
""" """
This endpoint would receive updates directly from a Telegram webhook. Este endpoint recibiría actualizaciones directamente de un webhook de Telegram.
It needs to be implemented to parse the Telegram Update object and Necesita ser implementado para analizar el objeto Update de Telegram y
convert it into our internal RawInput model. convertirlo en nuestro modelo RawInput interno.
""" """
logger.info(f"Received Telegram update: {request}") logger.info(f"Actualización de Telegram recibida: {request}")
# TODO: Implement a parser for the Telegram Update object. # TODO: Implementar un analizador para el objeto Update de Telegram.
# For now, this is a placeholder. # Por ahora, esto es un marcador de posición.
return {"status": "received", "message": "Telegram webhook handler not fully implemented."} return {"status": "received", "message": "El manejador de webhook de Telegram no está completamente implementado."}
@app.post("/process-expense", tags=["Processing"]) @app.post("/process-expense", tags=["Procesamiento"])
async def process_expense(raw_input: RawInput, db_session: Session = Depends(db.get_db)): async def process_expense(raw_input: RawInput, db_session: Session = Depends(db.get_db)):
""" """
Receives raw expense data, processes it through the full pipeline, Recibe datos de gastos sin procesar, los procesa a través del pipeline completo
and returns the result. y devuelve el resultado.
""" """
logger.info(f"Received raw input for processing: {raw_input.dict()}") logger.info(f"Entrada sin procesar recibida para procesamiento: {raw_input.dict()}")
try: try:
result = process_expense_input(db=db_session, raw_input=raw_input) result = process_expense_input(db=db_session, raw_input=raw_input)
@@ -67,18 +67,18 @@ async def process_expense(raw_input: RawInput, db_session: Session = Depends(db.
if result: if result:
return {"status": "success", "expense_id": result.id} return {"status": "success", "expense_id": result.id}
else: else:
# This could happen if confidence is low or an error occurred # Esto podría suceder si la confianza es baja o ocurrió un error
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Failed to process expense. It may require manual review or had invalid data." detail="Error al procesar el gasto. Puede requerir revisión manual o tenía datos inválidos."
) )
except ValueError as e: except ValueError as e:
logger.error(f"Validation error: {e}") logger.error(f"Error de validación: {e}")
raise HTTPException(status_code=422, detail=str(e)) raise HTTPException(status_code=422, detail=str(e))
except Exception as e: except Exception as e:
logger.critical(f"An unexpected error occurred in the processing pipeline: {e}", exc_info=True) logger.critical(f"Ocurrió un error inesperado en el pipeline de procesamiento: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="An internal server error occurred.") raise HTTPException(status_code=500, detail="Ocurrió un error interno del servidor.")
# To run this app: # Para ejecutar esta aplicación:
# uvicorn app.main:app --reload # uvicorn app.main:app --reload

View File

@@ -1,14 +1,14 @@
""" """
Handler for the /start command. Manejador para el comando /start.
""" """
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Sends a welcome message when the /start command is issued.""" """Envía un mensaje de bienvenida cuando se emite el comando /start."""
user = update.effective_user user = update.effective_user
await update.message.reply_html( await update.message.reply_html(
rf"Hi {user.mention_html()}! Welcome to the Expense Bot. " rf"¡Hola {user.mention_html()}! Bienvenido al Bot de Gastos. "
"Send me a message with an expense (e.g., 'lunch 25 eur') " "Envíame un mensaje con un gasto (ej., 'comida 25 eur') "
"or forward a voice message or receipt image.", "o reenvía un mensaje de voz o una imagen de un recibo.",
) )

View File

@@ -1,13 +1,13 @@
""" """
Handler for receiving and processing user messages (text, audio, images). Manejador para recibir y procesar mensajes de usuario (texto, audio, imágenes).
""" """
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
import logging import logging
from app.schema.base import RawInput from app.schema.base import RawInput
# This is a simplified integration. In a real app, you would likely # Esta es una integración simplificada. En una aplicación real, probablemente
# have a queue or a more robust way to trigger the processing pipeline. # tendrías una cola o una forma más robusta de activar el pipeline de procesamiento.
from app.router import process_expense_input from app.router import process_expense_input
from app.persistence.db import get_db from app.persistence.db import get_db
@@ -15,12 +15,12 @@ logger = logging.getLogger(__name__)
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
""" """
Handles regular messages and triggers the expense processing pipeline. Maneja mensajes regulares y activa el pipeline de procesamiento de gastos.
""" """
user_id = str(update.effective_user.id) user_id = str(update.effective_user.id)
# This is a very simplified example. # Este es un ejemplo muy simplificado.
# A real implementation needs to handle files, voice, etc. # Una implementación real necesita manejar archivos, voz, etc.
if update.message.text: if update.message.text:
raw_input = RawInput( raw_input = RawInput(
user_id=user_id, user_id=user_id,
@@ -29,20 +29,20 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
) )
try: try:
# Get a DB session # Obtener una sesión de BD
db_session = next(get_db()) db_session = next(get_db())
# Run the processing pipeline # Ejecutar el pipeline de procesamiento
result = process_expense_input(db=db_session, raw_input=raw_input) result = process_expense_input(db=db_session, raw_input=raw_input)
if result: if result:
await update.message.reply_text(f"Expense saved successfully! ID: {result.id}") await update.message.reply_text(f"¡Gasto guardado con éxito! ID: {result.id}")
else: else:
await update.message.reply_text("I couldn't fully process that. It might need manual review.") await update.message.reply_text("No pude procesar eso completamente. Podría necesitar revisión manual.")
except Exception as e: except Exception as e:
logger.error(f"Error handling message: {e}", exc_info=True) logger.error(f"Error al manejar el mensaje: {e}", exc_info=True)
await update.message.reply_text("Sorry, an error occurred while processing your request.") await update.message.reply_text("Lo siento, ocurrió un error al procesar tu solicitud.")
else: else:
await update.message.reply_text("I can currently only process text messages.") await update.message.reply_text("Actualmente solo puedo procesar mensajes de texto.")

View File

@@ -1,5 +1,5 @@
""" """
Database connection and session management. Conexión a la base de datos y gestión de sesiones.
""" """
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
@@ -11,7 +11,7 @@ from app.config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try: try:
# The 'check_same_thread' argument is specific to SQLite. # El argumento 'check_same_thread' es específico de SQLite.
engine_args = {"check_same_thread": False} if config.DATABASE_URL.startswith("sqlite") else {} engine_args = {"check_same_thread": False} if config.DATABASE_URL.startswith("sqlite") else {}
engine = create_engine( engine = create_engine(
@@ -23,18 +23,18 @@ try:
Base = declarative_base() Base = declarative_base()
logger.info("Database engine created successfully.") logger.info("Motor de base de datos creado con éxito.")
except Exception as e: except Exception as e:
logger.critical(f"Failed to connect to the database: {e}") logger.critical(f"Error al conectar con la base de datos: {e}")
# Exit or handle the critical error appropriately # Salir o manejar el error crítico apropiadamente
engine = None engine = None
SessionLocal = None SessionLocal = None
Base = None Base = None
def get_db(): def get_db():
""" """
Dependency for FastAPI routes to get a DB session. Dependencia para que las rutas de FastAPI obtengan una sesión de BD.
""" """
if SessionLocal is None: if SessionLocal is None:
raise Exception("Database is not configured. Cannot create session.") raise Exception("Database is not configured. Cannot create session.")

View File

@@ -1,6 +1,6 @@
""" """
Data access layer for persistence. Capa de acceso a datos para la persistencia.
Contains functions to interact with the database. Contiene funciones para interactuar con la base de datos.
""" """
from sqlalchemy import Column, Integer, String, Float, Date, DateTime, Text from sqlalchemy import Column, Integer, String, Float, Date, DateTime, Text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,7 +11,7 @@ from app.schema.base import FinalExpense
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --- Database ORM Model --- # --- Modelo ORM de Base de Datos ---
class ExpenseDB(Base): class ExpenseDB(Base):
__tablename__ = "expenses" __tablename__ = "expenses"
@@ -33,28 +33,28 @@ class ExpenseDB(Base):
def create_tables(): def create_tables():
""" """
Creates all database tables defined by models inheriting from Base. Crea todas las tablas de la base de datos definidas por los modelos que heredan de Base.
""" """
if engine: if engine:
logger.info("Creating database tables if they don't exist...") logger.info("Creando tablas de base de datos si no existen...")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
logger.info("Tables created successfully.") logger.info("Tablas creadas con éxito.")
else: else:
logger.error("Cannot create tables, database engine is not available.") logger.error("No se pueden crear las tablas, el motor de base de datos no está disponible.")
# --- Repository Functions --- # --- Funciones del Repositorio ---
def save_final_expense(db: Session, expense: FinalExpense) -> ExpenseDB: def save_final_expense(db: Session, expense: FinalExpense) -> ExpenseDB:
""" """
Saves a user-confirmed expense to the database. Guarda un gasto confirmado por el usuario en la base de datos.
Args: Args:
db: The database session. db: La sesión de la base de datos.
expense: The FinalExpense object to save. expense: El objeto FinalExpense a guardar.
Returns: Returns:
The created ExpenseDB object. El objeto ExpenseDB creado.
""" """
logger.info(f"Saving final expense for user {expense.user_id} to the database.") logger.info(f"Guardando gasto final para el usuario {expense.user_id} en la base de datos.")
db_expense = ExpenseDB(**expense.dict()) db_expense = ExpenseDB(**expense.dict())
@@ -62,5 +62,5 @@ def save_final_expense(db: Session, expense: FinalExpense) -> ExpenseDB:
db.commit() db.commit()
db.refresh(db_expense) db.refresh(db_expense)
logger.info(f"Successfully saved expense with ID {db_expense.id}.") logger.info(f"Gasto guardado con éxito con ID {db_expense.id}.")
return db_expense return db_expense

View File

@@ -1,5 +1,5 @@
""" """
Configuration loader for providers and keywords. Cargador de configuración para proveedores y palabras clave.
""" """
import csv import csv
import os import os
@@ -8,43 +8,43 @@ from typing import List, Dict, Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Paths to configuration files # Rutas a los archivos de configuración
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROVIDERS_PATH = os.path.join(BASE_DIR, 'config', 'providers.csv') PROVIDERS_PATH = os.path.join(BASE_DIR, 'config', 'providers.csv')
KEYWORDS_PATH = os.path.join(BASE_DIR, 'config', 'keywords.csv') KEYWORDS_PATH = os.path.join(BASE_DIR, 'config', 'keywords.csv')
def load_providers() -> List[Dict[str, Any]]: def load_providers() -> List[Dict[str, Any]]:
""" """
Loads the providers configuration from CSV. Carga la configuración de proveedores desde el archivo CSV.
""" """
providers = [] providers = []
if not os.path.exists(PROVIDERS_PATH): if not os.path.exists(PROVIDERS_PATH):
logger.warning(f"Providers file not found at {PROVIDERS_PATH}") logger.warning(f"Archivo de proveedores no encontrado en {PROVIDERS_PATH}")
return providers return providers
try: try:
with open(PROVIDERS_PATH, mode='r', encoding='utf-8') as f: with open(PROVIDERS_PATH, mode='r', encoding='utf-8') as f:
reader = csv.DictReader(f) reader = csv.DictReader(f)
for row in reader: for row in reader:
# Process aliases into a list # Procesar alias en una lista
if 'aliases' in row and row['aliases']: if 'aliases' in row and row['aliases']:
row['aliases'] = [a.strip().lower() for a in row['aliases'].split(',')] row['aliases'] = [a.strip().lower() for a in row['aliases'].split(',')]
else: else:
row['aliases'] = [] row['aliases'] = []
providers.append(row) providers.append(row)
logger.info(f"Loaded {len(providers)} providers from {PROVIDERS_PATH}") logger.info(f"Se cargaron {len(providers)} proveedores desde {PROVIDERS_PATH}")
except Exception as e: except Exception as e:
logger.error(f"Error loading providers: {e}") logger.error(f"Error al cargar proveedores: {e}")
return providers return providers
def load_keywords() -> List[Dict[str, Any]]: def load_keywords() -> List[Dict[str, Any]]:
""" """
Loads the keywords configuration from CSV. Carga la configuración de palabras clave desde el archivo CSV.
""" """
keywords = [] keywords = []
if not os.path.exists(KEYWORDS_PATH): if not os.path.exists(KEYWORDS_PATH):
logger.warning(f"Keywords file not found at {KEYWORDS_PATH}") logger.warning(f"Archivo de palabras clave no encontrado en {KEYWORDS_PATH}")
return keywords return keywords
try: try:
@@ -54,8 +54,8 @@ def load_keywords() -> List[Dict[str, Any]]:
if 'keyword' in row: if 'keyword' in row:
row['keyword'] = row['keyword'].strip().lower() row['keyword'] = row['keyword'].strip().lower()
keywords.append(row) keywords.append(row)
logger.info(f"Loaded {len(keywords)} keywords from {KEYWORDS_PATH}") logger.info(f"Se cargaron {len(keywords)} palabras clave desde {KEYWORDS_PATH}")
except Exception as e: except Exception as e:
logger.error(f"Error loading keywords: {e}") logger.error(f"Error al cargar palabras clave: {e}")
return keywords return keywords

View File

@@ -1,5 +1,5 @@
""" """
Matching logic for providers and keywords. Lógica de coincidencia para proveedores y palabras clave.
""" """
import logging import logging
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
@@ -7,13 +7,13 @@ from app.preprocessing.config_loader import load_providers, load_keywords
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Global cache for configuration # Caché global para la configuración
_PROVIDERS = None _PROVIDERS = None
_KEYWORDS = None _KEYWORDS = None
def get_config(): def get_config():
""" """
Returns the loaded configuration, using cache if available. Devuelve la configuración cargada, utilizando la caché si está disponible.
""" """
global _PROVIDERS, _KEYWORDS global _PROVIDERS, _KEYWORDS
if _PROVIDERS is None: if _PROVIDERS is None:
@@ -24,7 +24,7 @@ def get_config():
def match_provider(description: str) -> Optional[Dict[str, Any]]: def match_provider(description: str) -> Optional[Dict[str, Any]]:
""" """
Searches for a provider name or alias in the description. Busca un nombre de proveedor o alias en la descripción.
""" """
providers, _ = get_config() providers, _ = get_config()
desc_lower = description.lower() desc_lower = description.lower()
@@ -33,11 +33,11 @@ def match_provider(description: str) -> Optional[Dict[str, Any]]:
name = p.get('provider_name', '').lower() name = p.get('provider_name', '').lower()
aliases = p.get('aliases', []) aliases = p.get('aliases', [])
# Check name # Verificar nombre
if name and name in desc_lower: if name and name in desc_lower:
return p return p
# Check aliases # Verificar alias
for alias in aliases: for alias in aliases:
if alias and alias in desc_lower: if alias and alias in desc_lower:
return p return p
@@ -46,7 +46,7 @@ def match_provider(description: str) -> Optional[Dict[str, Any]]:
def match_keywords(description: str) -> Optional[Dict[str, Any]]: def match_keywords(description: str) -> Optional[Dict[str, Any]]:
""" """
Searches for keywords in the description. Busca palabras clave en la descripción.
""" """
_, keywords = get_config() _, keywords = get_config()
desc_lower = description.lower() desc_lower = description.lower()
@@ -60,13 +60,13 @@ def match_keywords(description: str) -> Optional[Dict[str, Any]]:
def get_metadata_from_match(description: str) -> Dict[str, Any]: def get_metadata_from_match(description: str) -> Dict[str, Any]:
""" """
Attempts to find metadata (category, subcategory, etc.) for a description. Intenta encontrar metadatos (categoría, subcategoría, etc.) para una descripción.
Priority: Provider Match > Keyword Match. Prioridad: Coincidencia de Proveedor > Coincidencia de Palabra Clave.
""" """
# 1. Try Provider Match # 1. Intentar coincidencia de proveedor
provider = match_provider(description) provider = match_provider(description)
if provider: if provider:
logger.info(f"Matched provider: {provider['provider_name']}") logger.info(f"Proveedor coincidente: {provider['provider_name']}")
return { return {
"category": provider.get('categoria_principal'), "category": provider.get('categoria_principal'),
"subcategory": provider.get('subcategoria'), "subcategory": provider.get('subcategoria'),
@@ -75,10 +75,10 @@ def get_metadata_from_match(description: str) -> Dict[str, Any]:
"matched_name": provider['provider_name'] "matched_name": provider['provider_name']
} }
# 2. Try Keyword Match # 2. Intentar coincidencia de palabra clave
keyword = match_keywords(description) keyword = match_keywords(description)
if keyword: if keyword:
logger.info(f"Matched keyword: {keyword['keyword']}") logger.info(f"Palabra clave coincidente: {keyword['keyword']}")
return { return {
"category": keyword.get('categoria_principal'), "category": keyword.get('categoria_principal'),
"subcategory": keyword.get('subcategoria'), "subcategory": keyword.get('subcategoria'),

View File

@@ -1,7 +1,7 @@
""" """
Main application router. Enrutador principal de la aplicación.
Orchestrates the entire expense processing workflow, from input to persistence. Orquesta todo el flujo de trabajo de procesamiento de gastos, desde la entrada hasta la persistencia.
""" """
import logging import logging
@@ -16,55 +16,55 @@ logger = logging.getLogger(__name__)
def process_expense_input(db: Session, raw_input: RawInput) -> FinalExpense: def process_expense_input(db: Session, raw_input: RawInput) -> FinalExpense:
""" """
Full pipeline for processing a raw input. Pipeline completo para procesar una entrada sin procesar.
1. Ingestion: Convert input (text, image, etc.) to raw text. 1. Ingestión: Convertir la entrada (texto, imagen, etc.) en texto sin procesar.
2. AI Extraction: Parse the raw text into structured data. 2. Extracción por IA: Analizar el texto sin procesar en datos estructurados.
3. AI Classification/Audit: Validate and categorize the expense. 3. Clasificación/Auditoría por IA: Validar y categorizar el gasto.
4. Persistence: Save the final, confirmed expense to the database. 4. Persistencia: Guardar el gasto final confirmado en la base de datos.
""" """
logger.info(f"Router processing input for user {raw_input.user_id} of type {raw_input.input_type}") logger.info(f"El enrutador está procesando la entrada para el usuario {raw_input.user_id} de tipo {raw_input.input_type}")
# 1. Ingestion # 1. Ingestión
raw_text = "" raw_text = ""
if raw_input.input_type == "text": if raw_input.input_type == "text":
raw_text = text.process_text_input(raw_input.data) raw_text = text.process_text_input(raw_input.data)
elif raw_input.input_type == "image": elif raw_input.input_type == "image":
# In a real app, data would be bytes, not a string path # En una aplicación real, los datos serían bytes, no una ruta de cadena
raw_text = image.process_image_input(raw_input.data.encode()) raw_text = image.process_image_input(raw_input.data.encode())
elif raw_input.input_type == "audio": elif raw_input.input_type == "audio":
raw_text = audio.process_audio_input(raw_input.data.encode()) raw_text = audio.process_audio_input(raw_input.data.encode())
elif raw_input.input_type == "document": elif raw_input.input_type == "document":
raw_text = document.process_document_input(raw_input.data.encode()) raw_text = document.process_document_input(raw_input.data.encode())
else: else:
raise ValueError(f"Unsupported input type: {raw_input.input_type}") raise ValueError(f"Tipo de entrada no soportado: {raw_input.input_type}")
if not raw_text: if not raw_text:
logger.error("Ingestion phase resulted in empty text. Aborting.") logger.error("La fase de ingestión resultó en un texto vacío. Abortando.")
# We might want to return a specific status here # Podríamos querer devolver un estado específico aquí
return None return None
# 2. AI Extraction # 2. Extracción por IA
extracted_data = extractor.extract_expense_data(raw_text) extracted_data = extractor.extract_expense_data(raw_text)
if not extracted_data.amount or not extracted_data.description: if not extracted_data.amount or not extracted_data.description:
logger.error("AI extraction failed to find key details. Aborting.") logger.error("La extracción por IA no pudo encontrar detalles clave. Abortando.")
return None return None
# 3. AI Classification & Confirmation (simplified) # 3. Clasificación y Confirmación por IA (simplificado)
# In a real bot, you would present this to the user for confirmation. # En un bot real, presentarías esto al usuario para su confirmación.
provisional_expense = ProvisionalExpense( provisional_expense = ProvisionalExpense(
user_id=raw_input.user_id, user_id=raw_input.user_id,
extracted_data=extracted_data, extracted_data=extracted_data,
confidence_score=0.0 # Will be set by classifier confidence_score=0.0 # Será establecido por el clasificador
) )
audited_expense = classifier.classify_and_audit(provisional_expense) audited_expense = classifier.classify_and_audit(provisional_expense)
# 3.5 Deterministic Matching (Phase 3) # 3.5 Coincidencia Determinística (Fase 3)
# Enrich data with categories from providers/keywords if available # Enriquecer los datos con categorías de proveedores/palabras clave si están disponibles
match_metadata = matcher.get_metadata_from_match(extracted_data.description) match_metadata = matcher.get_metadata_from_match(extracted_data.description)
# For now, we auto-confirm if confidence is high. # Por ahora, auto-confirmamos si la confianza es alta.
if audited_expense.confidence_score > 0.7: if audited_expense.confidence_score > 0.7:
final_expense = FinalExpense( final_expense = FinalExpense(
user_id=audited_expense.user_id, user_id=audited_expense.user_id,
@@ -79,12 +79,12 @@ def process_expense_input(db: Session, raw_input: RawInput) -> FinalExpense:
confirmed_by="auto-confirm" confirmed_by="auto-confirm"
) )
# 4. Persistence # 4. Persistencia
db_record = repositories.save_final_expense(db, final_expense) db_record = repositories.save_final_expense(db, final_expense)
logger.info(f"Successfully processed and saved expense ID {db_record.id}") logger.info(f"Gasto procesado y guardado con éxito ID {db_record.id}")
return db_record return db_record
else: else:
logger.warning(f"Expense for user {raw_input.user_id} has low confidence. Awaiting manual confirmation.") logger.warning(f"El gasto para el usuario {raw_input.user_id} tiene baja confianza. Esperando confirmación manual.")
# Here you would store the provisional expense and notify the user # Aquí guardarías el gasto provisional y notificarías al usuario
return None return None