se agrega archivos para la instalación en Docker
This commit is contained in:
1 parent
533105e7c3
commit
cf2c1ee147
28 files changed
+1430
-501
No files matched your search
+114
-28
@@ -1,8 +1,9 @@
|
||||
import pdfplumber
|
||||
import re
|
||||
import os
|
||||
import llm_processor
|
||||
|
||||
########################################################
|
||||
# ========================================================================
|
||||
def extraer_texto_pdf(ruta_pdf):
|
||||
"""Extrae todo el texto de un PDF usando pdfplumber."""
|
||||
texto_completo = ""
|
||||
@@ -15,7 +16,7 @@ def extraer_texto_pdf(ruta_pdf):
|
||||
print(f" ⚠️ Error leyendo PDF {ruta_pdf}: {e}")
|
||||
return ""
|
||||
|
||||
########################################################
|
||||
# ========================================================================
|
||||
def extraer_ruc(texto):
|
||||
"""Busca un RUC (11 dígitos, empieza con 10 o 20) en el texto."""
|
||||
# Intentamos buscar patrones de RUC: RUC 2060... o R.U.C. 2060...
|
||||
@@ -30,54 +31,139 @@ def extraer_ruc(texto):
|
||||
return match.group(1)
|
||||
return "N/A"
|
||||
|
||||
########################################################
|
||||
# ========================================================================
|
||||
def _flexible(palabra):
|
||||
"""Convierte una palabra en un patrón regex que tolera espacios entre caracteres.
|
||||
Ej: FACTURA -> F\\s*A\\s*C\\s*T\\s*U\\s*R\\s*A
|
||||
"""
|
||||
return '\\s*'.join(palabra)
|
||||
|
||||
|
||||
def extraer_tipo_documento(texto):
|
||||
"""Identifica el tipo de documento SUNAT basándose en el texto usando regex multilínea."""
|
||||
"""Identifica el tipo de documento SUNAT basándose en el texto usando regex multilínea.
|
||||
Soporta texto con espacios entre caracteres (ej: 'F A C T U R A')."""
|
||||
t = texto.upper()
|
||||
|
||||
|
||||
patrones = {
|
||||
"01": [r'FACTURA\s+(ELECTR[OÓ]NICA|DE\s+VENTA)', r'FACTURA'],
|
||||
"03": [r'BOLETA\s+DE\s+VENTA', r'BOLETA\s+ELECTR[OÓ]NICA'],
|
||||
"07": [r'NOTA\s+DE\s+CR[EÉ]DITO'],
|
||||
"08": [r'NOTA\s+DE\s+D[EÉ]BITO'],
|
||||
"14": [r'RECIBO\s+POR\s+HONORARIOS'],
|
||||
"09": [r'GU[IÍ]A\s+DE\s+REMISI[OÓ]N']
|
||||
"01": [
|
||||
rf'{_flexible("FACTURA")}\s+({_flexible("ELECTRÓNICA")}|{_flexible("ELECTRONICA")}|DE\s+VENTA)',
|
||||
rf'{_flexible("FACTURA")}',
|
||||
],
|
||||
"03": [
|
||||
rf'{_flexible("BOLETA")}\s+DE\s+{_flexible("VENTA")}',
|
||||
rf'{_flexible("BOLETA")}\s+{_flexible("ELECTRÓNICA")}',
|
||||
rf'{_flexible("BOLETA")}\s+{_flexible("ELECTRONICA")}',
|
||||
],
|
||||
"07": [
|
||||
rf'{_flexible("NOTA")}\s+DE\s+{_flexible("CRÉDITO")}',
|
||||
rf'{_flexible("NOTA")}\s+DE\s+{_flexible("CREDITO")}',
|
||||
],
|
||||
"08": [
|
||||
rf'{_flexible("NOTA")}\s+DE\s+{_flexible("DÉBITO")}',
|
||||
rf'{_flexible("NOTA")}\s+DE\s+{_flexible("DEBITO")}',
|
||||
],
|
||||
"14": [
|
||||
rf'{_flexible("RECIBO")}\s+POR\s+{_flexible("HONORARIOS")}',
|
||||
],
|
||||
"09": [
|
||||
rf'{_flexible("GUÍA")}\s+DE\s+{_flexible("REMISIÓN")}',
|
||||
rf'{_flexible("GUIA")}\s+DE\s+{_flexible("REMISION")}',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
for tipo, regex_list in patrones.items():
|
||||
for p in regex_list:
|
||||
if re.search(p, t, re.IGNORECASE | re.MULTILINE):
|
||||
return tipo
|
||||
|
||||
|
||||
return "99"
|
||||
|
||||
########################################################
|
||||
# ========================================================================
|
||||
def extraer_serie_numero(texto):
|
||||
"""Busca la serie y número (ej. F001-123 o F001-0000123)."""
|
||||
# Patrón común: [FBE][A-Z0-9]{3}-\d+
|
||||
patron = re.search(r'\b([FBE][A-Z0-9]{3})\s*-\s*(\d+)\b', texto)
|
||||
if patron:
|
||||
return f"{patron.group(1)}-{patron.group(2)}"
|
||||
"""Busca la serie y número (ej. F001-123, E001-0000123, EG07-2367)."""
|
||||
serie_patrones = [
|
||||
# Formato con guión: letras+dígitos - número
|
||||
r'\b([A-Z]{1,4}\d{2,4})\s*-\s*(\d+)\b',
|
||||
# Fallback sin guión
|
||||
r'([A-Z]{1,4}\d{2,4})\s+(\d{1,10})',
|
||||
# Serie numérica corta sin letras
|
||||
r'\b(\d{3})\s*-\s*(\d+)\b'
|
||||
]
|
||||
|
||||
# Fallback para casos donde no hay guión o hay espacios raros
|
||||
patron_alt = re.search(r'([FBE][A-Z0-9]{3})\s+(\d{1,10})', texto)
|
||||
if patron_alt:
|
||||
return f"{patron_alt.group(1)}-{patron_alt.group(2)}"
|
||||
for patron in serie_patrones:
|
||||
match = re.search(patron, texto)
|
||||
if match:
|
||||
return f"{match.group(1)}-{match.group(2)}"
|
||||
|
||||
return "No encontrado"
|
||||
|
||||
########################################################
|
||||
# ========================================================================
|
||||
def _necesita_llm(resultado):
|
||||
"""True si algún campo clave no se encontró con regex."""
|
||||
return (
|
||||
resultado['ruc'] in ("N/A", "Desconocido") or
|
||||
resultado['tipo_doc'] == "99" or
|
||||
resultado['id'] in ("No encontrado", "N/A")
|
||||
)
|
||||
|
||||
|
||||
def _campos_rellenados(regex_original, regex_result):
|
||||
"""Cuenta cuántos campos cambió el LLM respecto al regex original."""
|
||||
count = 0
|
||||
if regex_original['ruc'] in ("N/A", "Desconocido") and regex_result['ruc'] not in ("N/A", "Desconocido"):
|
||||
count += 1
|
||||
if regex_original['tipo_doc'] == "99" and regex_result['tipo_doc'] != "99":
|
||||
count += 1
|
||||
if regex_original['id'] in ("No encontrado", "N/A") and regex_result['id'] not in ("No encontrado", "N/A"):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _merge_llm(regex_result, llm_data):
|
||||
"""Rellena campos vacíos del regex con datos del LLM sin pisar lo ya encontrado."""
|
||||
if regex_result['ruc'] in ("N/A", "Desconocido"):
|
||||
ruc = llm_data.get('ruc', '')
|
||||
if re.match(r'^(10|20)\d{9}$', str(ruc)):
|
||||
regex_result['ruc'] = ruc
|
||||
|
||||
if regex_result['tipo_doc'] == "99":
|
||||
tipo = str(llm_data.get('tipo', '99'))
|
||||
tipo_codes = {"01", "02", "03", "07", "08", "09", "14"}
|
||||
if tipo in tipo_codes:
|
||||
regex_result['tipo_doc'] = tipo
|
||||
|
||||
if regex_result['id'] in ("No encontrado", "N/A"):
|
||||
serie = str(llm_data.get('serie', ''))
|
||||
numero = str(llm_data.get('numero', ''))
|
||||
if serie and numero:
|
||||
regex_result['id'] = f"{serie}-{numero}"
|
||||
|
||||
return regex_result
|
||||
|
||||
|
||||
def obtener_datos_pdf(ruta_pdf):
|
||||
"""Orquestador para extraer metadata de un PDF."""
|
||||
"""Orquestador para extraer metadata de un PDF.
|
||||
Primero intenta con regex; si faltan campos, usa LLM como fallback."""
|
||||
texto = extraer_texto_pdf(ruta_pdf)
|
||||
if not texto:
|
||||
return {"valido": False, "error": "No se pudo extraer texto"}
|
||||
|
||||
return {
|
||||
resultado = {
|
||||
"valido": True,
|
||||
"tipo_doc": extraer_tipo_documento(texto),
|
||||
"ruc": extraer_ruc(texto),
|
||||
"id": extraer_serie_numero(texto)
|
||||
"id": extraer_serie_numero(texto),
|
||||
"llm_usado": False,
|
||||
"llm_respuesta": None
|
||||
}
|
||||
|
||||
########################################################
|
||||
if _necesita_llm(resultado):
|
||||
resultado["llm_usado"] = True
|
||||
llm_data = llm_processor.extraer_con_llm(texto)
|
||||
resultado["llm_respuesta"] = dict(llm_data) if llm_data else None
|
||||
if llm_data:
|
||||
antes = dict(resultado)
|
||||
resultado = _merge_llm(resultado, llm_data)
|
||||
|
||||
return resultado
|
||||
# ========================================================================
|
||||
Reference in new issue
Block a user