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_id",
"invoice_date", "invoice_date",
"total_amount", "total_amount",
# "net_amount", "net_amount", # Podríamos considerar renombrar esto a subtotal_amount en el futuro
"receiver_name", "receiver_name",
"supplier_tax_id" "supplier_tax_id",
"total_tax_amount",
"subtotal_amount" # <-- NUEVO CAMPO
] ]
# --- CAMBIO PARA DEPURACIÓN --- # --- CAMBIO PARA DEPURACIÓN ---
@ -31,7 +33,9 @@ class Settings(BaseSettings):
"__default__": 0.82, "__default__": 0.82,
"supplier_name": 0.80, "supplier_name": 0.80,
"total_amount": 0.75, "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, "receiver_name": 0.74,
"supplier_tax_id": 0.46 "supplier_tax_id": 0.46
} }

View File

@ -1,53 +1,90 @@
# services/invoice_processor_service.py # services/invoice_processor_service.py
import logging import logging
from typing import Dict, List, Any, Optional 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 .gcp_document_ai_client import process_document_gcp
from .utils import data_cleaner from .utils import data_cleaner
from core.config import settings from core.config import settings
def _extract_specific_fields( def _extract_specific_fields(
entities: List[Any], document: Document,
# El nombre del parámetro aquí debe coincidir con el que se le pasa desde el router
default_confidence_override: Optional[float] = None default_confidence_override: Optional[float] = None
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Filtra y normaliza entidades. Si se proporciona `default_confidence_override`, Extrae datos usando una lógica de búsqueda contextual por palabra clave para
se utiliza para el umbral por defecto. Los umbrales específicos de la resolver ambigüedades en el documento.
configuración siempre tienen prioridad.
""" """
extracted_data = {field: "Not found or low confidence" for field in settings.REQUIRED_FIELDS} 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__"] 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_ entity_type = entity.type_
if entity_type not in settings.REQUIRED_FIELDS or entity_type in ['total_tax_amount', 'subtotal_amount']:
continue
if entity_type in settings.REQUIRED_FIELDS: threshold = settings.CONFIDENCE_THRESHOLDS.get(entity_type, default_threshold)
# 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.confidence >= threshold: if entity.confidence >= threshold:
value = entity.mention_text.replace('\n', ' ').strip() raw_text = entity.mention_text.strip()
if entity_type == 'invoice_date': if entity_type == 'invoice_date':
normalized_date = data_cleaner.normalize_date(value) extracted_data[entity_type] = data_cleaner.normalize_date(raw_text) or f"Unparseable Date: '{raw_text}'"
value = normalized_date if normalized_date else f"Unparseable Date: '{value}'"
extracted_data[entity_type] = value
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 return extracted_data
def process_invoice_from_bytes( def process_invoice_from_bytes(
file_bytes: bytes, file_bytes: bytes,
mime_type: str, mime_type: str,
# El nombre del parámetro aquí debe coincidir con el del router
default_confidence_override: Optional[float] = None default_confidence_override: Optional[float] = None
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """ Orquesta el proceso completo. """
Orquesta el proceso completo.
"""
try: try:
document = process_document_gcp( document = process_document_gcp(
project_id=settings.GCP_PROJECT_ID, project_id=settings.GCP_PROJECT_ID,
@ -56,13 +93,9 @@ def process_invoice_from_bytes(
file_bytes=file_bytes, file_bytes=file_bytes,
mime_type=mime_type, mime_type=mime_type,
) )
validated_data = _extract_specific_fields(document, default_confidence_override)
validated_data = _extract_specific_fields(document.entities, default_confidence_override) logging.info(f"Datos finales procesados: {validated_data}")
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}.")
return validated_data return validated_data
except Exception as e: except Exception as e:
logging.error(f"Error en el flujo de procesamiento de factura: {e}", exc_info=True) logging.error(f"Error en el flujo de procesamiento de factura: {e}", exc_info=True)
raise raise

View File

@ -1,87 +1,96 @@
# services/utils/data_cleaner.py # services/utils/data_cleaner.py
import logging import logging
import locale import locale
import re
from dateutil import parser from dateutil import parser
from typing import Optional from typing import Optional, Dict
from datetime import datetime from datetime import datetime
SPANISH_TO_ENGLISH_MONTHS = { # --- ESTA SECCIÓN NO REQUIERE CAMBIOS ---
'enero': 'january', 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'}
'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]: def _parse_with_fallback(date_string: str) -> Optional[datetime]:
""" temp_string = date_string.lower().replace(' de ', ' ').replace(' del ', ' ')
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
for spa, eng in SPANISH_TO_ENGLISH_MONTHS.items(): for spa, eng in SPANISH_TO_ENGLISH_MONTHS.items():
if spa in temp_string: if spa in temp_string: temp_string = temp_string.replace(spa, eng); break
temp_string = temp_string.replace(spa, eng) try: return parser.parse(temp_string)
break # Salimos del bucle una vez que encontramos y reemplazamos el mes except (parser.ParserError, ValueError): return None
# 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
def normalize_date(date_string: str) -> Optional[str]: def normalize_date(date_string: str) -> Optional[str]:
""" if not date_string: return None
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
original_locale = locale.getlocale(locale.LC_TIME) original_locale = locale.getlocale(locale.LC_TIME)
parsed_date = None parsed_date = None
# Estrategia 1: Intentar con el locale español
try: try:
try: try: locale.setlocale(locale.LC_TIME, 'es_ES.UTF-8')
locale.setlocale(locale.LC_TIME, 'es_ES.UTF-8') except locale.Error: locale.setlocale(locale.LC_TIME, 'Spanish')
except locale.Error:
locale.setlocale(locale.LC_TIME, 'Spanish')
parsed_date = parser.parse(date_string) parsed_date = parser.parse(date_string)
except (parser.ParserError, ValueError, locale.Error): 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) parsed_date = _parse_with_fallback(date_string)
finally: locale.setlocale(locale.LC_TIME, original_locale)
finally: return parsed_date.strftime('%d/%m/%Y') if parsed_date else None
# Siempre restauramos el locale original # --- FIN DE LA SECCIÓN SIN CAMBIOS ---
locale.setlocale(locale.LC_TIME, original_locale)
if parsed_date:
# Aquí se asegura el formato DD/MM/AAAA. def clean_numeric_value(text: str) -> str:
# '%d' -> día con cero (05), '%m' -> mes con cero (01), '%Y' -> año (2030) """Función pública para limpiar y normalizar un string numérico."""
return parsed_date.strftime('%d/%m/%Y') 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: else:
# Si ambas estrategias fallan, registramos el error final logging.warning(f"No se encontró desglose de IVA en el texto normalizado: '{normalized_text}'")
logging.error(f"Failed to parse date '{date_string}' with all available methods.")
return None 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