mirror of
https://github.com/marcogll/telegram_expenses_controller.git
synced 2026-01-13 13:25:15 +00:00
feat: translate comments, docstrings, and log messages to Spanish.
This commit is contained in:
104
README.md
104
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.
|
- 🤖 **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*
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
64
app/main.py
64
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,
|
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
|
||||||
|
|||||||
@@ -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.",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user