status beta 000.002

This commit is contained in:
Daniel Oscar Zamo 2025-08-27 22:21:11 +02:00
parent e00f881537
commit d31765a72c
3 changed files with 148 additions and 102 deletions

View File

@ -20,9 +20,11 @@ class Settings(BaseSettings):
"invoice_id",
"invoice_date",
"total_amount",
# "net_amount",
"net_amount", # Podríamos considerar renombrar esto a subtotal_amount en el futuro
"receiver_name",
"supplier_tax_id"
"supplier_tax_id",
"total_tax_amount",
"subtotal_amount" # <-- NUEVO CAMPO
]
# --- CAMBIO PARA DEPURACIÓN ---
@ -31,7 +33,9 @@ class Settings(BaseSettings):
"__default__": 0.82,
"supplier_name": 0.80,
"total_amount": 0.75,
# "net_amount": 0.92,
"subtotal_amount": 0.75, # Un umbral razonable
"net_amount": 0.92,
"total_tax_amount": 0.0, # Ponemos 0.0 porque no viene de DocumentAI, lo calculamos nosotros
"receiver_name": 0.74,
"supplier_tax_id": 0.46
}

View File

@ -1,53 +1,90 @@
# services/invoice_processor_service.py
import logging
from typing import Dict, List, Any, Optional
from google.cloud.documentai_v1.types import Document
from .gcp_document_ai_client import process_document_gcp
from .utils import data_cleaner
from core.config import settings
def _extract_specific_fields(
entities: List[Any],
# El nombre del parámetro aquí debe coincidir con el que se le pasa desde el router
document: Document,
default_confidence_override: Optional[float] = None
) -> Dict[str, str]:
"""
Filtra y normaliza entidades. Si se proporciona `default_confidence_override`,
se utiliza para el umbral por defecto. Los umbrales específicos de la
configuración siempre tienen prioridad.
Extrae datos usando una lógica de búsqueda contextual por palabra clave para
resolver ambigüedades en el documento.
"""
extracted_data = {field: "Not found or low confidence" for field in settings.REQUIRED_FIELDS}
# Determina el umbral por defecto para esta ejecución
default_threshold = default_confidence_override if default_confidence_override is not None else settings.CONFIDENCE_THRESHOLDS["__default__"]
for entity in entities:
full_text_lines = document.text.split('\n')
for entity in document.entities:
entity_type = entity.type_
if entity_type in settings.REQUIRED_FIELDS:
# Lógica corregida: Prioriza el umbral específico del campo, si no, usa el por defecto.
threshold = settings.CONFIDENCE_THRESHOLDS.get(entity_type, default_threshold)
if entity_type not in settings.REQUIRED_FIELDS or entity_type in ['total_tax_amount', 'subtotal_amount']:
continue
if entity.confidence >= threshold:
value = entity.mention_text.replace('\n', ' ').strip()
threshold = settings.CONFIDENCE_THRESHOLDS.get(entity_type, default_threshold)
if entity_type == 'invoice_date':
normalized_date = data_cleaner.normalize_date(value)
value = normalized_date if normalized_date else f"Unparseable Date: '{value}'"
if entity.confidence >= threshold:
raw_text = entity.mention_text.strip()
extracted_data[entity_type] = value
if entity_type == 'invoice_date':
extracted_data[entity_type] = data_cleaner.normalize_date(raw_text) or f"Unparseable Date: '{raw_text}'"
elif entity_type == 'total_amount':
# --- LÓGICA DE BÚSQUEDA CONTEXTUAL POR PALABRA CLAVE ---
contextual_line = None
logging.info(f"Buscando contexto para '{raw_text}' con la palabra clave 'Total'")
for line in full_text_lines:
# La línea debe contener el texto del importe Y la palabra "total"
if raw_text in line and "total" in line.lower():
contextual_line = line
logging.info(f"Contexto definitivo para total_amount encontrado: '{contextual_line}'")
break
# Si no encontramos una línea contextual, usamos el texto original como fallback
text_to_parse = contextual_line if contextual_line else raw_text
parsed_amounts = data_cleaner.parse_total_and_tax(text_to_parse)
total_str = parsed_amounts.get('total_amount')
tax_str = parsed_amounts.get('total_tax_amount')
if total_str:
extracted_data['total_amount'] = total_str
if tax_str:
extracted_data['total_tax_amount'] = tax_str
try:
subtotal = float(total_str) - float(tax_str)
subtotal_str = f"{subtotal:.2f}"
extracted_data['subtotal_amount'] = subtotal_str
extracted_data['net_amount'] = subtotal_str
except (ValueError, TypeError):
logging.error("Error de conversión para cálculo de subtotal.")
else:
extracted_data['total_tax_amount'] = '0.00'
extracted_data['subtotal_amount'] = total_str
if extracted_data.get('net_amount') == "Not found or low confidence":
extracted_data['net_amount'] = total_str
elif entity_type in ['net_amount', 'subtotal_amount']:
# Evitamos procesar estos campos directamente si ya los hemos calculado
if extracted_data.get(entity_type) == "Not found or low confidence":
extracted_data[entity_type] = data_cleaner.clean_numeric_value(raw_text)
else:
extracted_data[entity_type] = raw_text.replace('\n', ' ').strip()
return extracted_data
def process_invoice_from_bytes(
file_bytes: bytes,
mime_type: str,
# El nombre del parámetro aquí debe coincidir con el del router
default_confidence_override: Optional[float] = None
) -> Dict[str, str]:
"""
Orquesta el proceso completo.
"""
""" Orquesta el proceso completo. """
try:
document = process_document_gcp(
project_id=settings.GCP_PROJECT_ID,
@ -56,13 +93,9 @@ def process_invoice_from_bytes(
file_bytes=file_bytes,
mime_type=mime_type,
)
validated_data = _extract_specific_fields(document.entities, default_confidence_override)
log_threshold = default_confidence_override if default_confidence_override is not None else "config default"
logging.info(f"Documento procesado. Umbral por defecto usado: {log_threshold}.")
validated_data = _extract_specific_fields(document, default_confidence_override)
logging.info(f"Datos finales procesados: {validated_data}")
return validated_data
except Exception as e:
logging.error(f"Error en el flujo de procesamiento de factura: {e}", exc_info=True)
raise

View File

@ -1,87 +1,96 @@
# services/utils/data_cleaner.py
import logging
import locale
import re
from dateutil import parser
from typing import Optional
from typing import Optional, Dict
from datetime import datetime
SPANISH_TO_ENGLISH_MONTHS = {
'enero': 'january',
'febrero': 'february',
'marzo': 'march',
'abril': 'april',
'mayo': 'may',
'junio': 'june',
'julio': 'july',
'agosto': 'august',
'septiembre': 'september',
'octubre': 'october',
'noviembre': 'november',
'diciembre': 'december'
}
# --- ESTA SECCIÓN NO REQUIERE CAMBIOS ---
SPANISH_TO_ENGLISH_MONTHS = { 'enero': 'january', 'febrero': 'february', 'marzo': 'march', 'abril': 'april', 'mayo': 'may', 'junio': 'june', 'julio': 'july', 'agosto': 'august', 'septiembre': 'september', 'octubre': 'october', 'noviembre': 'november', 'diciembre': 'december'}
def _parse_with_fallback(date_string: str) -> Optional[datetime]:
"""
Intenta parsear la fecha usando un fallback manual que primero limpia
preposiciones comunes en español ("de", "del") y luego traduce los meses.
"""
# 1. Normalizar a minúsculas para trabajar de forma consistente
temp_string = date_string.lower()
# 2. Traducir el mes de español a inglés
temp_string = date_string.lower().replace(' de ', ' ').replace(' del ', ' ')
for spa, eng in SPANISH_TO_ENGLISH_MONTHS.items():
if spa in temp_string:
temp_string = temp_string.replace(spa, eng)
break # Salimos del bucle una vez que encontramos y reemplazamos el mes
# 3. Eliminar preposiciones comunes, cuidando los espacios para evitar unir palabras
temp_string = temp_string.replace(' de ', ' ')
temp_string = temp_string.replace(' del ', ' ')
# Después de la limpieza, la cadena debería ser algo como '5 january 2030', que es parseable.
try:
logging.info(f"Attempting to parse cleaned date string: '{temp_string}'")
return parser.parse(temp_string)
except (parser.ParserError, ValueError):
# Si incluso después de la limpieza falla, no podemos hacer más.
logging.warning(f"Fallback parsing failed even for cleaned string: '{temp_string}'")
return None
if spa in temp_string: temp_string = temp_string.replace(spa, eng); break
try: return parser.parse(temp_string)
except (parser.ParserError, ValueError): return None
def normalize_date(date_string: str) -> Optional[str]:
"""
Parses a date string from various formats and normalizes it to DD/MM/YYYY.
It first tries using Spanish locale, and if it fails, it uses a manual
cleaning and translation fallback.
"""
if not date_string:
return None
if not date_string: return None
original_locale = locale.getlocale(locale.LC_TIME)
parsed_date = None
# Estrategia 1: Intentar con el locale español
try:
try:
locale.setlocale(locale.LC_TIME, 'es_ES.UTF-8')
except locale.Error:
locale.setlocale(locale.LC_TIME, 'Spanish')
try: locale.setlocale(locale.LC_TIME, 'es_ES.UTF-8')
except locale.Error: locale.setlocale(locale.LC_TIME, 'Spanish')
parsed_date = parser.parse(date_string)
except (parser.ParserError, ValueError, locale.Error):
logging.warning(f"Could not parse date '{date_string}' using Spanish locale. Attempting robust fallback.")
# Estrategia 2: Si el locale falla, usar el fallback robusto
parsed_date = _parse_with_fallback(date_string)
finally: locale.setlocale(locale.LC_TIME, original_locale)
return parsed_date.strftime('%d/%m/%Y') if parsed_date else None
# --- FIN DE LA SECCIÓN SIN CAMBIOS ---
finally:
# Siempre restauramos el locale original
locale.setlocale(locale.LC_TIME, original_locale)
if parsed_date:
# Aquí se asegura el formato DD/MM/AAAA.
# '%d' -> día con cero (05), '%m' -> mes con cero (01), '%Y' -> año (2030)
return parsed_date.strftime('%d/%m/%Y')
def clean_numeric_value(text: str) -> str:
"""Función pública para limpiar y normalizar un string numérico."""
if not text: return "0.00"
cleaned = text.strip().replace('.', '').replace(',', '.')
try: return f"{float(cleaned):.2f}"
except (ValueError, TypeError):
logging.warning(f"Could not convert '{text}' to a numeric value. Defaulting to 0.00.")
return "0.00"
def parse_total_and_tax(text: str) -> Dict[str, Optional[str]]:
"""
Versión final y robusta. Parsea un string que contiene el total y el IVA.
"""
logging.info(f"Texto original recibido para parsing: '{text}'")
normalized_text = " ".join(text.split())
logging.info(f"Texto normalizado para la regex: '{normalized_text}'")
result = {'total_amount': None, 'total_tax_amount': None}
total_match = re.search(r'([\d.,]+)', normalized_text)
if total_match:
result['total_amount'] = clean_numeric_value(total_match.group(1))
# Regex Definitiva: más tolerante con el texto que rodea al número del IVA
tax_match = re.search(r'\(.*?(?:incluye|incluido|iva)\s+([\d.,]+).*?\)', normalized_text, re.IGNORECASE)
if tax_match:
result['total_tax_amount'] = clean_numeric_value(tax_match.group(1))
logging.info(f"¡ÉXITO! Importe de IVA encontrado y limpiado: {result['total_tax_amount']}")
else:
# Si ambas estrategias fallan, registramos el error final
logging.error(f"Failed to parse date '{date_string}' with all available methods.")
return None
logging.warning(f"No se encontró desglose de IVA en el texto normalizado: '{normalized_text}'")
return result
def parse_total_and_tax_LEGACY(text: str) -> Dict[str, Optional[str]]:
"""
Versión final y robusta. Parsea un string que contiene el total y el IVA.
Primero normaliza los espacios en blanco y luego aplica una regex de alta precisión.
"""
logging.info(f"Texto original recibido para parsing: '{text}'")
# --- PASO 1: PRE-PROCESAMIENTO Y NORMALIZACIÓN DEL TEXTO ---
# Esto convierte saltos de línea, tabs y espacios múltiples en un único espacio.
# Ej: "398,00€\n (incluye..." -> "398,00€ (incluye..."
normalized_text = " ".join(text.split())
logging.info(f"Texto normalizado para la regex: '{normalized_text}'")
result = {'total_amount': None, 'total_tax_amount': None}
# 2. Extraer el importe total (el primer número que encuentre del texto normalizado)
total_match = re.search(r'([\d.,]+)', normalized_text)
if total_match:
result['total_amount'] = clean_numeric_value(total_match.group(1))
logging.info(f"Importe total encontrado y limpiado: {result['total_amount']}")
# 3. Regex de alta precisión aplicada sobre el texto normalizado.
tax_match = re.search(r'\(.*?(?:incluye|incluido)\s+([\d.,]+)€?\s*IVA.*?\)', normalized_text, re.IGNORECASE)
if tax_match:
result['total_tax_amount'] = clean_numeric_value(tax_match.group(1))
logging.info(f"¡ÉXITO! Importe de IVA encontrado y limpiado: {result['total_tax_amount']}")
else:
logging.warning(f"No se encontró desglose de IVA en el texto normalizado: '{normalized_text}'")
return result