version 001.002

This commit is contained in:
Daniel Oscar Zamo 2025-08-27 02:08:01 +02:00
parent db8680baa2
commit 1327c04bb1
6 changed files with 69 additions and 69 deletions

View File

@ -3,7 +3,8 @@ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status,
from typing import Dict, Optional from typing import Dict, Optional
from api.dependencies import get_current_active_user from api.dependencies import get_current_active_user
from services import invoice_processor_service # Importamos la función específica para que el código sea más claro
from services.invoice_processor_service import process_invoice_from_bytes
from db.models import User from db.models import User
router = APIRouter() router = APIRouter()
@ -14,11 +15,14 @@ ALLOWED_CONTENT_TYPES = ["application/pdf", "image/jpeg", "image/png", "image/ti
async def upload_invoice( async def upload_invoice(
current_user: User = Depends(get_current_active_user), current_user: User = Depends(get_current_active_user),
file: UploadFile = File(...), file: UploadFile = File(...),
# Nuevo parámetro: viene del formulario, es opcional y debe estar entre 0.0 y 1.0 # --- PUNTO CRÍTICO ---
confidence_threshold: Optional[float] = Form(None, ge=0.0, le=1.0) # El nombre del parámetro aquí, `default_confidence_override`, DEBE coincidir
# con el nombre usado en el formData.append() del JavaScript.
default_confidence_override: Optional[float] = Form(None, ge=0.0, le=1.0)
): ):
""" """
Endpoint para subir una factura. Ahora acepta un umbral de confianza opcional. Endpoint para subir una factura. Acepta un umbral para sobrescribir
la confianza por defecto.
""" """
if file.content_type not in ALLOWED_CONTENT_TYPES: if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException( raise HTTPException(
@ -29,11 +33,11 @@ async def upload_invoice(
try: try:
file_bytes = await file.read() file_bytes = await file.read()
# Pasamos el umbral recibido al servicio # Pasamos el parámetro con el nombre correcto al servicio
extracted_data = invoice_processor_service.process_invoice_from_bytes( extracted_data = process_invoice_from_bytes(
file_bytes=file_bytes, file_bytes=file_bytes,
mime_type=file.content_type, mime_type=file.content_type,
override_threshold=confidence_threshold default_confidence_override=default_confidence_override
) )
return extracted_data return extracted_data
except Exception as e: except Exception as e:

View File

@ -15,20 +15,25 @@ class Settings(BaseSettings):
GCP_LOCATION: str GCP_LOCATION: str
DOCAI_PROCESSOR_ID: str DOCAI_PROCESSOR_ID: str
# --- Lógica de Negocio (extraída del antiguo config.py) --- # --- Lógica de Negocio (pedido por Jaime, datos a extraer) ---
REQUIRED_FIELDS: List[str] = [ REQUIRED_FIELDS: List[str] = [
"supplier_name",
"invoice_id", "invoice_id",
"invoice_date", "invoice_date",
"total_amount" "total_amount",
"net_amount",
"receiver_name",
"supplier_tax_id"
] ]
# Umbrales de confianza por campo. Un valor por defecto y anulaciones específicas. # --- CAMBIO PARA DEPURACIÓN ---
# Bajamos los umbrales para asegurarnos de que los datos se extraen.
CONFIDENCE_THRESHOLDS: Dict[str, float] = { CONFIDENCE_THRESHOLDS: Dict[str, float] = {
"__default__": 0.85, "__default__": 0.82, # <-- Bajamos el por defecto al 10%
"supplier_name": 0.90, "supplier_name": 0.80,
"total_amount": 0.95 "total_amount": 0.75, # <-- Bajamos todos a un nivel muy bajo
"net_amount": 0.92,
"receiver_name": 0.74,
"supplier_tax_id": 0.46 # <-- Especialmente este que estaba en 0.46
} }
# Creamos una única instancia global de la configuración # Creamos una única instancia global de la configuración
settings = Settings() settings = Settings()

View File

@ -3,55 +3,50 @@ import logging
from typing import Dict, List, Any, Optional from typing import Dict, List, Any, Optional
from .gcp_document_ai_client import process_document_gcp from .gcp_document_ai_client import process_document_gcp
# Importamos nuestro limpiador de datos para usarlo
from .utils import data_cleaner from .utils import data_cleaner
from core.config import settings from core.config import settings
# --- Lógica de negocio refactorizada ---
def _extract_specific_fields( def _extract_specific_fields(
entities: List[Any], entities: List[Any],
override_threshold: Optional[float] = None # El nombre del parámetro aquí debe coincidir con el que se le pasa desde el router
default_confidence_override: Optional[float] = None
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Filtra y normaliza entidades. Si se proporciona `override_threshold`, Filtra y normaliza entidades. Si se proporciona `default_confidence_override`,
se utiliza ese valor para todos los campos. De lo contrario, utiliza se utiliza para el umbral por defecto. Los umbrales específicos de la
los umbrales definidos en la configuración. 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__"]
for entity in entities: for entity in entities:
entity_type = entity.type_ entity_type = entity.type_
# Lógica de decisión del umbral if entity_type in settings.REQUIRED_FIELDS:
if override_threshold is not None: # Lógica corregida: Prioriza el umbral específico del campo, si no, usa el por defecto.
threshold = override_threshold threshold = settings.CONFIDENCE_THRESHOLDS.get(entity_type, default_threshold)
else:
# Comportamiento original: usar la configuración por campo
threshold = settings.CONFIDENCE_THRESHOLDS.get(entity_type, settings.CONFIDENCE_THRESHOLDS["__default__"])
if entity_type in settings.REQUIRED_FIELDS and entity.confidence >= threshold: if entity.confidence >= threshold:
value = entity.mention_text.replace('\n', ' ').strip() value = entity.mention_text.replace('\n', ' ').strip()
# Reactivamos la limpieza de fechas if entity_type == 'invoice_date':
if entity_type == 'invoice_date': normalized_date = data_cleaner.normalize_date(value)
normalized_date = data_cleaner.normalize_date(value) value = normalized_date if normalized_date else f"Unparseable Date: '{value}'"
value = normalized_date if normalized_date else f"Unparseable Date: '{value}'"
extracted_data[entity_type] = value extracted_data[entity_type] = value
return extracted_data return extracted_data
# --- Función principal del servicio actualizada ---
def process_invoice_from_bytes( def process_invoice_from_bytes(
file_bytes: bytes, file_bytes: bytes,
mime_type: str, mime_type: str,
override_threshold: Optional[float] = None # Nuevo parámetro opcional # El nombre del parámetro aquí debe coincidir con el del router
default_confidence_override: Optional[float] = None
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Orquesta el proceso completo. Ahora pasa el umbral de confianza Orquesta el proceso completo.
opcional a la capa de lógica de negocio.
""" """
try: try:
document = process_document_gcp( document = process_document_gcp(
@ -62,10 +57,10 @@ def process_invoice_from_bytes(
mime_type=mime_type, mime_type=mime_type,
) )
# Pasamos el umbral opcional a la función de extracción validated_data = _extract_specific_fields(document.entities, default_confidence_override)
validated_data = _extract_specific_fields(document.entities, override_threshold)
logging.info(f"Documento procesado con éxito con un umbral de {override_threshold or 'default'}.") 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:

View File

@ -1,4 +1,4 @@
# src/cli_invoice_processor/data_cleaner.py # services/utils/data_cleaner.py
import logging import logging
import locale import locale
from dateutil import parser from dateutil import parser

View File

@ -20,13 +20,11 @@
.upload-area { margin-top: 1rem; border: 2px dashed #bdc3c7; padding: 2rem; border-radius: 8px; text-align: center; background-color: #ecf0f1; } .upload-area { margin-top: 1rem; border: 2px dashed #bdc3c7; padding: 2rem; border-radius: 8px; text-align: center; background-color: #ecf0f1; }
.upload-form button { background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 5px; cursor: pointer; font-size: 16px; font-weight: bold; margin-top: 1rem; } .upload-form button { background-color: #3498db; color: white; border: none; padding: 12px 24px; border-radius: 5px; cursor: pointer; font-size: 16px; font-weight: bold; margin-top: 1rem; }
.upload-form button:disabled { background-color: #95a5a6; cursor: not-allowed; } .upload-form button:disabled { background-color: #95a5a6; cursor: not-allowed; }
/* --- Nuevo: Estilos para la configuración avanzada --- */
.settings { margin-top: 2rem; padding: 1.5rem; background-color: #f8f9fa; border-radius: 8px; } .settings { margin-top: 2rem; padding: 1.5rem; background-color: #f8f9fa; border-radius: 8px; }
.slider-container { display: flex; align-items: center; gap: 15px; } .slider-container { display: flex; align-items: center; gap: 15px; }
.slider-container label { font-weight: bold; } .slider-container label { font-weight: bold; }
.slider-container input[type="range"] { flex-grow: 1; } .slider-container input[type="range"] { flex-grow: 1; }
.slider-container #confidence-value { font-weight: bold; color: #3498db; min-width: 45px; } .slider-container #confidence-value { font-weight: bold; color: #3498db; min-width: 45px; }
/* --- Fin de nuevos estilos --- */
.results { margin-top: 2rem; background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 1.5rem; border-radius: 8px; display: none; } .results { margin-top: 2rem; background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 1.5rem; border-radius: 8px; display: none; }
.results-table { width: 100%; border-collapse: collapse; } .results-table { width: 100%; border-collapse: collapse; }
.results-table td { padding: 12px; border-bottom: 1px solid #dee2e6; } .results-table td { padding: 12px; border-bottom: 1px solid #dee2e6; }
@ -49,10 +47,13 @@
<div class="settings"> <div class="settings">
<h2>Configuración Avanzada</h2> <h2>Configuración Avanzada</h2>
<div class="slider-container"> <div class="slider-container">
<label for="confidence-slider">Confianza Mínima:</label> <label for="confidence-slider">Confianza Mínima por Defecto:</label>
<input type="range" id="confidence-slider" min="0" max="100" value="85"> <input type="range" id="confidence-slider" min="0" max="100" value="85">
<span id="confidence-value">85%</span> <span id="confidence-value">85%</span>
</div> </div>
<p style="font-size: 12px; color: #6c757d; margin-top: 5px; text-align: left;">
Este valor se usará para campos que no tienen un umbral específico en la configuración. Los umbrales para campos como 'Importe Total' o 'CIF' mantendrán su valor predefinido en el sistema.
</p>
</div> </div>
<div class="upload-area"> <div class="upload-area">
<input type="file" id="invoice-file" name="invoice" required accept="application/pdf,image/jpeg,image/png,image/tiff"> <input type="file" id="invoice-file" name="invoice" required accept="application/pdf,image/jpeg,image/png,image/tiff">
@ -76,7 +77,6 @@
const confidenceSlider = document.getElementById('confidence-slider'); const confidenceSlider = document.getElementById('confidence-slider');
const confidenceValue = document.getElementById('confidence-value'); const confidenceValue = document.getElementById('confidence-value');
// --- Lógica para actualizar el valor del slider en la UI ---
confidenceSlider.addEventListener('input', () => { confidenceSlider.addEventListener('input', () => {
confidenceValue.textContent = `${confidenceSlider.value}%`; confidenceValue.textContent = `${confidenceSlider.value}%`;
}); });
@ -97,19 +97,18 @@
const file = fileInput.files[0]; const file = fileInput.files[0];
const token = localStorage.getItem('accessToken'); const token = localStorage.getItem('accessToken');
// --- Nuevo: Leer el valor del slider --- if (!file) {
showMessage('Por favor, selecciona un archivo.', 'error');
submitButton.disabled = false;
return;
}
const thresholdValue = confidenceSlider.value; const thresholdValue = confidenceSlider.value;
const thresholdFloat = parseFloat(thresholdValue) / 100.0; const thresholdFloat = parseFloat(thresholdValue) / 100.0;
// ------------------------------------
if (!file) { /* ... (código sin cambios) ... */ }
if (!token) { /* ... (código sin cambios) ... */ }
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
// --- Nuevo: Añadir el umbral al FormData --- formData.append('default_confidence_override', thresholdFloat);
formData.append('confidence_threshold', thresholdFloat);
// ----------------------------------------
try { try {
const response = await fetch('/api/invoices/upload', { const response = await fetch('/api/invoices/upload', {
@ -117,6 +116,7 @@
headers: { 'Authorization': `Bearer ${token}` }, headers: { 'Authorization': `Bearer ${token}` },
body: formData, body: formData,
}); });
const data = await response.json(); const data = await response.json();
if (response.ok) { if (response.ok) {
@ -126,15 +126,14 @@
showMessage(data.detail || 'Ocurrió un error.', 'error'); showMessage(data.detail || 'Ocurrió un error.', 'error');
} }
} catch (error) { } catch (error) {
showMessage('Error de conexión.', 'error'); console.error('Ha ocurrido un error en el bloque try/catch:', error);
showMessage('Error de conexión o al procesar la respuesta.', 'error');
} finally { } finally {
submitButton.disabled = false; submitButton.disabled = false;
fileInput.value = ''; fileInput.value = '';
} }
}); });
// --- Funciones de Ayuda para la UI ---
function showMessage(text, type = 'info') { function showMessage(text, type = 'info') {
messageContainer.textContent = text; messageContainer.textContent = text;
messageContainer.className = `message ${type}`; messageContainer.className = `message ${type}`;
@ -142,16 +141,13 @@
} }
function displayResults(data) { function displayResults(data) {
// Limpiar tabla anterior
resultsTable.innerHTML = ''; resultsTable.innerHTML = '';
// Crear una fila por cada dato extraído
for (const key in data) { for (const key in data) {
const row = resultsTable.insertRow(); const row = resultsTable.insertRow();
const keyCell = row.insertCell(0); const keyCell = row.insertCell(0);
const valueCell = row.insertCell(1); const valueCell = row.insertCell(1);
// Formatear la clave para que sea legible (ej. 'supplier_name' -> 'Supplier Name')
keyCell.textContent = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); keyCell.textContent = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
valueCell.textContent = data[key]; valueCell.textContent = data[key];
} }