From 3b9346fe0f691bf3f7aa8e653bd5456d1b71ccfe Mon Sep 17 00:00:00 2001 From: Marco Gallegos Date: Thu, 18 Dec 2025 12:33:39 -0600 Subject: [PATCH] feat: translate comments, docstrings, and log messages to Spanish. --- README.md | 104 ++++++++++++++--------------- app/ai/classifier.py | 24 +++---- app/ai/extractor.py | 24 +++---- app/config.py | 20 +++--- app/main.py | 64 +++++++++--------- app/modules/start.py | 10 +-- app/modules/upload.py | 26 ++++---- app/persistence/db.py | 12 ++-- app/persistence/repositories.py | 28 ++++---- app/preprocessing/config_loader.py | 22 +++--- app/preprocessing/matcher.py | 26 ++++---- app/router.py | 50 +++++++------- 12 files changed, 205 insertions(+), 205 deletions(-) diff --git a/README.md b/README.md index dd28102..691c58c 100644 --- a/README.md +++ b/README.md @@ -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. -- 🖼️ **Multimodal**: Supports text, images (receipts), and audio (voice notes) - *in progress*. -- 📊 **Structured Storage**: Saves data to a database with support for exporting to CSV/Google Sheets. -- 🛡️ **Audit Trail**: Keeps track of raw inputs and AI confidence scores for reliability. -- 🐳 **Dockerized**: Easy deployment using Docker and Docker Compose. +- 🤖 **Extracción por IA**: Analiza automáticamente el monto, la moneda, la descripción y la fecha a partir del lenguaje natural. +- 🖼️ **Multimodal**: Soporta texto, imágenes (recibos) y audio (notas de voz) - *en progreso*. +- 📊 **Almacenamiento Estructurado**: Guarda los datos en una base de datos con soporte para exportar a CSV/Google Sheets. +- 🛡️ **Pista de Auditoría**: Realiza un seguimiento de las entradas sin procesar y las puntuaciones de confianza de la IA para mayor fiabilidad. +- 🐳 **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. - - **/ai**: LLM integration, prompts, and extraction logic. - - **/audit**: Logging and raw data storage for traceability. - - **/ingestion**: Handlers for different input types (text, image, audio, document). - - **/integrations**: External services (e.g., exporters, webhook clients). - - **/modules**: Telegram bot command handlers (`/start`, `/status`, etc.). - - **/persistence**: Database models and repositories (SQLAlchemy). - - **/preprocessing**: Data cleaning, validation, and language detection. - - **/schema**: Pydantic models for data validation and API documentation. - - **main.py**: FastAPI entry point and webhook handlers. - - **router.py**: Orchestrates the processing pipeline. -- **/config**: Static configuration files (keywords, providers). -- **/src**: Legacy/Initial implementation (Phase 1 & 2). -- **tasks.md**: Detailed project roadmap and progress tracker. +- **/app**: Lógica central de la aplicación. + - **/ai**: Integración con LLM, prompts y lógica de extracción. + - **/audit**: Registro y almacenamiento de datos sin procesar para trazabilidad. + - **/ingestion**: Manejadores para diferentes tipos de entrada (texto, imagen, audio, documento). + - **/integrations**: Servicios externos (ej. exportadores, clientes de webhook). + - **/modules**: Manejadores de comandos del bot de Telegram (`/start`, `/status`, etc.). + - **/persistence**: Modelos de base de datos y repositorios (SQLAlchemy). + - **/preprocessing**: Limpieza de datos, validación y detección de idioma. + - **/schema**: Modelos Pydantic para validación de datos y documentación de la API. + - **main.py**: Punto de entrada de FastAPI y manejadores de webhooks. + - **router.py**: Orquesta el pipeline de procesamiento. +- **/config**: Archivos de configuración estática (palabras clave, proveedores). +- **/src**: Implementación heredada/inicial (Fases 1 y 2). +- **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). -2. **Ingestion**: The bot receives the update and passes it to the `/app/ingestion` layer to extract raw text. -3. **Routing**: `router.py` takes the raw text and coordinates the next steps. -4. **Extraction**: The `/app/ai/extractor.py` uses OpenAI's GPT models to parse the text into a structured `ExtractedExpense`. -5. **Audit & Classify**: The `/app/ai/classifier.py` assigns categories and a confidence score. -6. **Persistence**: If confidence is high, the expense is automatically saved via `/app/persistence/repositories.py`. If low, it awaits manual confirmation. +1. **Entrada**: El usuario envía un mensaje al bot de Telegram (texto, imagen o voz). +2. **Ingestión**: El bot recibe la actualización y la pasa a la capa `/app/ingestion` para extraer el texto sin procesar. +3. **Enrutamiento**: `router.py` toma el texto sin procesar y coordina los siguientes pasos. +4. **Extracción**: `/app/ai/extractor.py` utiliza los modelos GPT de OpenAI para analizar el texto en un `ExtractedExpense` estructurado. +5. **Auditoría y Clasificación**: `/app/ai/classifier.py` asigna categorías y una puntuación de confianza. +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] **Phase 2: Data Models**: Explicit expense states and Pydantic schemas. -- [/] **Phase 3: Logic**: Configuration loaders and provider matching (In Progress). -- [/] **Phase 4: AI Analyst**: Multimodal extraction and confidence scoring (In Progress). +- [x] **Fase 1: Infraestructura**: FastAPI, Docker y manejo básico de entradas. +- [x] **Fase 2: Modelos de Datos**: Estados de gastos explícitos y esquemas Pydantic. +- [x] **Fase 3: Lógica**: Cargadores de configuración y coincidencia de proveedores (Completado). +- [/] **Fase 4: Analista de IA**: Extracción multimodal y puntuación de confianza (En Progreso). -## Setup & Development +## Configuración y Desarrollo -### 1. Environment Variables -Copy `.env.example` to `.env` and fill in your credentials: +### 1. Variables de Entorno +Copia `.env.example` a `.env` y completa tus credenciales: ```bash -TELEGRAM_TOKEN=your_bot_token -OPENAI_API_KEY=your_openai_key -DATABASE_URL=mysql+pymysql://user:password@db:3306/expenses +TELEGRAM_TOKEN=tu_token_de_bot +OPENAI_API_KEY=tu_clave_de_openai +DATABASE_URL=mysql+pymysql://usuario:contraseña@db:3306/expenses -# MySQL specific (for Docker) -MYSQL_ROOT_PASSWORD=root_password +# Específico de MySQL (para Docker) +MYSQL_ROOT_PASSWORD=contraseña_root MYSQL_DATABASE=expenses -MYSQL_USER=user -MYSQL_PASSWORD=password +MYSQL_USER=usuario +MYSQL_PASSWORD=contraseña ``` -### 2. Run with Docker +### 2. Ejecutar con Docker ```bash docker-compose up --build ``` -### 3. Local Development (FastAPI) +### 3. Desarrollo Local (FastAPI) ```bash pip install -r requirements.txt uvicorn app.main:app --reload ``` -### 4. Running the Bot (Polling) -For local testing without webhooks, you can run a polling script that uses the handlers in `app/modules`. +### 4. Ejecutar el Bot (Polling) +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* diff --git a/app/ai/classifier.py b/app/ai/classifier.py index 27646a5..84dbc2e 100644 --- a/app/ai/classifier.py +++ b/app/ai/classifier.py @@ -1,5 +1,5 @@ """ -AI-powered classification and confidence scoring. +Clasificación y puntuación de confianza impulsada por IA. """ import openai import json @@ -17,25 +17,25 @@ logger = logging.getLogger(__name__) def classify_and_audit(expense: ProvisionalExpense) -> ProvisionalExpense: """ - Uses an AI model to audit an extracted expense, providing a confidence - score and notes. This is a placeholder for a more complex classification - and validation logic. + Utiliza un modelo de IA para auditar un gasto extraído, proporcionando una puntuación + de confianza y notas. Este es un marcador de posición para una lógica de clasificación + y validación más compleja. Args: - expense: A ProvisionalExpense object with extracted data. + expense: Un objeto ProvisionalExpense con datos extraídos. 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 - # call an AI model like in the extractor. - # For demonstration, we'll just assign a high confidence score. + # Por ahora, esto es un marcador de posición. Una implementación real + # llamaría a un modelo de IA como en el extractor. + # Para la demostración, simplemente asignaremos una puntuación de confianza alta. expense.confidence_score = 0.95 - expense.validation_notes.append("AI audit placeholder: auto-approved.") - expense.processing_method = "ai_inference" # Assume AI was used + expense.validation_notes.append("Marcador de posición de auditoría por IA: aprobado automáticamente.") + expense.processing_method = "ai_inference" # Asumir que se usó IA logger.info("AI audit placeholder complete.") diff --git a/app/ai/extractor.py b/app/ai/extractor.py index 57863be..101fe2b 100644 --- a/app/ai/extractor.py +++ b/app/ai/extractor.py @@ -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 json @@ -17,15 +17,15 @@ logger = logging.getLogger(__name__) 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: - 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: - 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: response = openai.ChatCompletion.create( @@ -38,23 +38,23 @@ def extract_expense_data(text: str) -> ExtractedExpense: 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'] 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 return ExtractedExpense(**extracted_data) except json.JSONDecodeError as e: - logger.error(f"Failed to decode JSON from AI response: {e}") - # Return a model with only the raw text for manual review + logger.error(f"Error al decodificar JSON de la respuesta de la IA: {e}") + # Devolver un modelo con solo el texto sin procesar para revisión manual return ExtractedExpense(raw_text=text) except Exception as e: - logger.error(f"An unexpected error occurred during AI extraction: {e}") - # Return a model with only the raw text + logger.error(f"Ocurrió un error inesperado durante la extracción por IA: {e}") + # Devolver un modelo con solo el texto sin procesar return ExtractedExpense(raw_text=text) diff --git a/app/config.py b/app/config.py index 51ffb00..a883b11 100644 --- a/app/config.py +++ b/app/config.py @@ -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 from dotenv import load_dotenv -# Load environment variables from .env file in the project root -# Note: The path is relative to the file's location in the final `app` directory +# Cargar variables de entorno desde el archivo .env en la raíz del proyecto +# 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') if os.path.exists(dotenv_path): load_dotenv(dotenv_path) 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") # 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") - # 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") - # Log level + # Nivel de registro LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") -# Create a single instance of the configuration +# Crear una única instancia de la configuración config = Config() diff --git a/app/main.py b/app/main.py index 9348f7b..a505995 100644 --- a/app/main.py +++ b/app/main.py @@ -1,17 +1,17 @@ """ -Application entry point. +Punto de entrada de la aplicación. -Initializes the FastAPI application, sets up logging, database, -and defines the main API endpoints. +Inicializa la aplicación FastAPI, configura el registro, la base de datos +y define los principales endpoints de la API. """ import logging from fastapi import FastAPI, Depends, HTTPException 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 -# Now, set up logging based on the config +# Ahora, configurar el registro basado en la configuración logging.basicConfig(level=config.LOG_LEVEL.upper()) logger = logging.getLogger(__name__) @@ -20,46 +20,46 @@ from app.schema.base import RawInput from app.router import process_expense_input from app.persistence import repositories, db -# Create database tables on startup -# This is simple, but for production, you'd use migrations (e.g., Alembic) +# Crear tablas de base de datos al inicio +# Esto es simple, pero para producción, usarías migraciones (ej. Alembic) repositories.create_tables() -# Initialize the FastAPI app +# Inicializar la aplicación FastAPI app = FastAPI( - title="Telegram Expenses Bot API", - description="Processes and manages expense data from various sources.", + title="API del Bot de Gastos de Telegram", + description="Procesa y gestiona datos de gastos de diversas fuentes.", version="1.0.0" ) @app.on_event("startup") async def startup_event(): - logger.info("Application startup complete.") - logger.info(f"Log level is set to: {config.LOG_LEVEL.upper()}") + logger.info("Inicio de la aplicación completado.") + 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(): - """Health check endpoint.""" - return {"message": "Telegram Expenses Bot API is running."} + """Endpoint de verificación de salud.""" + return {"message": "La API del Bot de Gastos de Telegram está en ejecución."} @app.post("/webhook/telegram", tags=["Webhooks"]) async def process_telegram_update(request: dict): """ - This endpoint would receive updates directly from a Telegram webhook. - It needs to be implemented to parse the Telegram Update object and - convert it into our internal RawInput model. + Este endpoint recibiría actualizaciones directamente de un webhook de Telegram. + Necesita ser implementado para analizar el objeto Update de Telegram y + convertirlo en nuestro modelo RawInput interno. """ - logger.info(f"Received Telegram update: {request}") - # TODO: Implement a parser for the Telegram Update object. - # For now, this is a placeholder. - return {"status": "received", "message": "Telegram webhook handler not fully implemented."} + logger.info(f"Actualización de Telegram recibida: {request}") + # TODO: Implementar un analizador para el objeto Update de Telegram. + # Por ahora, esto es un marcador de posición. + 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)): """ - Receives raw expense data, processes it through the full pipeline, - and returns the result. + Recibe datos de gastos sin procesar, los procesa a través del pipeline completo + 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: 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: return {"status": "success", "expense_id": result.id} 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( 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: - logger.error(f"Validation error: {e}") + logger.error(f"Error de validación: {e}") raise HTTPException(status_code=422, detail=str(e)) except Exception as e: - logger.critical(f"An unexpected error occurred in the processing pipeline: {e}", exc_info=True) - raise HTTPException(status_code=500, detail="An internal server error occurred.") + logger.critical(f"Ocurrió un error inesperado en el pipeline de procesamiento: {e}", exc_info=True) + 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 diff --git a/app/modules/start.py b/app/modules/start.py index c6dee5b..91d32a0 100644 --- a/app/modules/start.py +++ b/app/modules/start.py @@ -1,14 +1,14 @@ """ -Handler for the /start command. +Manejador para el comando /start. """ from telegram import Update from telegram.ext import ContextTypes 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 await update.message.reply_html( - rf"Hi {user.mention_html()}! Welcome to the Expense Bot. " - "Send me a message with an expense (e.g., 'lunch 25 eur') " - "or forward a voice message or receipt image.", + rf"¡Hola {user.mention_html()}! Bienvenido al Bot de Gastos. " + "Envíame un mensaje con un gasto (ej., 'comida 25 eur') " + "o reenvía un mensaje de voz o una imagen de un recibo.", ) diff --git a/app/modules/upload.py b/app/modules/upload.py index 17c660b..09d1f76 100644 --- a/app/modules/upload.py +++ b/app/modules/upload.py @@ -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.ext import ContextTypes import logging from app.schema.base import RawInput -# This is a simplified integration. In a real app, you would likely -# have a queue or a more robust way to trigger the processing pipeline. +# Esta es una integración simplificada. En una aplicación real, probablemente +# 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.persistence.db import get_db @@ -15,12 +15,12 @@ logger = logging.getLogger(__name__) 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) - # This is a very simplified example. - # A real implementation needs to handle files, voice, etc. + # Este es un ejemplo muy simplificado. + # Una implementación real necesita manejar archivos, voz, etc. if update.message.text: raw_input = RawInput( user_id=user_id, @@ -29,20 +29,20 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) try: - # Get a DB session + # Obtener una sesión de BD 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) 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: - 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: - logger.error(f"Error handling message: {e}", exc_info=True) - await update.message.reply_text("Sorry, an error occurred while processing your request.") + logger.error(f"Error al manejar el mensaje: {e}", exc_info=True) + await update.message.reply_text("Lo siento, ocurrió un error al procesar tu solicitud.") 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.") diff --git a/app/persistence/db.py b/app/persistence/db.py index 14e66cd..9e63ace 100644 --- a/app/persistence/db.py +++ b/app/persistence/db.py @@ -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.ext.declarative import declarative_base @@ -11,7 +11,7 @@ from app.config import config logger = logging.getLogger(__name__) 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 = create_engine( @@ -23,18 +23,18 @@ try: Base = declarative_base() - logger.info("Database engine created successfully.") + logger.info("Motor de base de datos creado con éxito.") except Exception as e: - logger.critical(f"Failed to connect to the database: {e}") - # Exit or handle the critical error appropriately + logger.critical(f"Error al conectar con la base de datos: {e}") + # Salir o manejar el error crítico apropiadamente engine = None SessionLocal = None Base = None 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: raise Exception("Database is not configured. Cannot create session.") diff --git a/app/persistence/repositories.py b/app/persistence/repositories.py index be54822..815c488 100644 --- a/app/persistence/repositories.py +++ b/app/persistence/repositories.py @@ -1,6 +1,6 @@ """ -Data access layer for persistence. -Contains functions to interact with the database. +Capa de acceso a datos para la persistencia. +Contiene funciones para interactuar con la base de datos. """ from sqlalchemy import Column, Integer, String, Float, Date, DateTime, Text from sqlalchemy.orm import Session @@ -11,7 +11,7 @@ from app.schema.base import FinalExpense logger = logging.getLogger(__name__) -# --- Database ORM Model --- +# --- Modelo ORM de Base de Datos --- class ExpenseDB(Base): __tablename__ = "expenses" @@ -33,28 +33,28 @@ class ExpenseDB(Base): 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: - 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) - logger.info("Tables created successfully.") + logger.info("Tablas creadas con éxito.") 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: """ - Saves a user-confirmed expense to the database. + Guarda un gasto confirmado por el usuario en la base de datos. Args: - db: The database session. - expense: The FinalExpense object to save. + db: La sesión de la base de datos. + expense: El objeto FinalExpense a guardar. 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()) @@ -62,5 +62,5 @@ def save_final_expense(db: Session, expense: FinalExpense) -> ExpenseDB: db.commit() 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 diff --git a/app/preprocessing/config_loader.py b/app/preprocessing/config_loader.py index e3a937c..9922a35 100644 --- a/app/preprocessing/config_loader.py +++ b/app/preprocessing/config_loader.py @@ -1,5 +1,5 @@ """ -Configuration loader for providers and keywords. +Cargador de configuración para proveedores y palabras clave. """ import csv import os @@ -8,43 +8,43 @@ from typing import List, Dict, Any 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__)))) PROVIDERS_PATH = os.path.join(BASE_DIR, 'config', 'providers.csv') KEYWORDS_PATH = os.path.join(BASE_DIR, 'config', 'keywords.csv') def load_providers() -> List[Dict[str, Any]]: """ - Loads the providers configuration from CSV. + Carga la configuración de proveedores desde el archivo CSV. """ providers = [] 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 try: with open(PROVIDERS_PATH, mode='r', encoding='utf-8') as f: reader = csv.DictReader(f) for row in reader: - # Process aliases into a list + # Procesar alias en una lista if 'aliases' in row and row['aliases']: row['aliases'] = [a.strip().lower() for a in row['aliases'].split(',')] else: row['aliases'] = [] 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: - logger.error(f"Error loading providers: {e}") + logger.error(f"Error al cargar proveedores: {e}") return providers 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 = [] 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 try: @@ -54,8 +54,8 @@ def load_keywords() -> List[Dict[str, Any]]: if 'keyword' in row: row['keyword'] = row['keyword'].strip().lower() 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: - logger.error(f"Error loading keywords: {e}") + logger.error(f"Error al cargar palabras clave: {e}") return keywords diff --git a/app/preprocessing/matcher.py b/app/preprocessing/matcher.py index dec677f..6a5a6f7 100644 --- a/app/preprocessing/matcher.py +++ b/app/preprocessing/matcher.py @@ -1,5 +1,5 @@ """ -Matching logic for providers and keywords. +Lógica de coincidencia para proveedores y palabras clave. """ import logging from typing import Optional, Dict, Any @@ -7,13 +7,13 @@ from app.preprocessing.config_loader import load_providers, load_keywords logger = logging.getLogger(__name__) -# Global cache for configuration +# Caché global para la configuración _PROVIDERS = None _KEYWORDS = None 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 if _PROVIDERS is None: @@ -24,7 +24,7 @@ def get_config(): 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() desc_lower = description.lower() @@ -33,11 +33,11 @@ def match_provider(description: str) -> Optional[Dict[str, Any]]: name = p.get('provider_name', '').lower() aliases = p.get('aliases', []) - # Check name + # Verificar nombre if name and name in desc_lower: return p - # Check aliases + # Verificar alias for alias in aliases: if alias and alias in desc_lower: return p @@ -46,7 +46,7 @@ def match_provider(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() 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]: """ - Attempts to find metadata (category, subcategory, etc.) for a description. - Priority: Provider Match > Keyword Match. + Intenta encontrar metadatos (categoría, subcategoría, etc.) para una descripción. + Prioridad: Coincidencia de Proveedor > Coincidencia de Palabra Clave. """ - # 1. Try Provider Match + # 1. Intentar coincidencia de proveedor provider = match_provider(description) if provider: - logger.info(f"Matched provider: {provider['provider_name']}") + logger.info(f"Proveedor coincidente: {provider['provider_name']}") return { "category": provider.get('categoria_principal'), "subcategory": provider.get('subcategoria'), @@ -75,10 +75,10 @@ def get_metadata_from_match(description: str) -> Dict[str, Any]: "matched_name": provider['provider_name'] } - # 2. Try Keyword Match + # 2. Intentar coincidencia de palabra clave keyword = match_keywords(description) if keyword: - logger.info(f"Matched keyword: {keyword['keyword']}") + logger.info(f"Palabra clave coincidente: {keyword['keyword']}") return { "category": keyword.get('categoria_principal'), "subcategory": keyword.get('subcategoria'), diff --git a/app/router.py b/app/router.py index 4efdf0e..a3b6679 100644 --- a/app/router.py +++ b/app/router.py @@ -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 @@ -16,55 +16,55 @@ logger = logging.getLogger(__name__) 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. - 2. AI Extraction: Parse the raw text into structured data. - 3. AI Classification/Audit: Validate and categorize the expense. - 4. Persistence: Save the final, confirmed expense to the database. + 1. Ingestión: Convertir la entrada (texto, imagen, etc.) en texto sin procesar. + 2. Extracción por IA: Analizar el texto sin procesar en datos estructurados. + 3. Clasificación/Auditoría por IA: Validar y categorizar el gasto. + 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 = "" if raw_input.input_type == "text": raw_text = text.process_text_input(raw_input.data) 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()) elif raw_input.input_type == "audio": raw_text = audio.process_audio_input(raw_input.data.encode()) elif raw_input.input_type == "document": raw_text = document.process_document_input(raw_input.data.encode()) 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: - logger.error("Ingestion phase resulted in empty text. Aborting.") - # We might want to return a specific status here + logger.error("La fase de ingestión resultó en un texto vacío. Abortando.") + # Podríamos querer devolver un estado específico aquí return None - # 2. AI Extraction + # 2. Extracción por IA extracted_data = extractor.extract_expense_data(raw_text) 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 - # 3. AI Classification & Confirmation (simplified) - # In a real bot, you would present this to the user for confirmation. + # 3. Clasificación y Confirmación por IA (simplificado) + # En un bot real, presentarías esto al usuario para su confirmación. provisional_expense = ProvisionalExpense( user_id=raw_input.user_id, 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) - # 3.5 Deterministic Matching (Phase 3) - # Enrich data with categories from providers/keywords if available + # 3.5 Coincidencia Determinística (Fase 3) + # Enriquecer los datos con categorías de proveedores/palabras clave si están disponibles 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: final_expense = FinalExpense( user_id=audited_expense.user_id, @@ -79,12 +79,12 @@ def process_expense_input(db: Session, raw_input: RawInput) -> FinalExpense: confirmed_by="auto-confirm" ) - # 4. Persistence + # 4. Persistencia 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 else: - logger.warning(f"Expense for user {raw_input.user_id} has low confidence. Awaiting manual confirmation.") - # Here you would store the provisional expense and notify the user + logger.warning(f"El gasto para el usuario {raw_input.user_id} tiene baja confianza. Esperando confirmación manual.") + # Aquí guardarías el gasto provisional y notificarías al usuario return None