Módulo 3: Comprensión de Documentos
8. Proyecto: Document Extractor
Descripción
Este proyecto cierra el Módulo 3 construyendo un Document Extractor completo: un sistema que recibe un PDF o imagen de documento, detecta su tipo, extrae texto con la técnica apropiada (PyMuPDF, Tesseract, Vision), convierte la información en datos estructurados con Pydantic, y maneja documentos largos con chunking. Integra todas las cápsulas del módulo en un pipeline funcional.
No es un script aislado. Es el patrón que usan sistemas de procesamiento documental en producción: un router que decide la ruta de extracción según el tipo de documento, y un parser que convierte texto libre en datos tipados y validados.
Por qué importa: En producción, los documentos llegan en cualquier formato — PDFs con texto, PDFs escaneados, fotografías de facturas. Sin un sistema que detecte el tipo y aplique la técnica correcta, terminas con pipelines frágiles que fallan con el primer documento inesperado.
Conexión con el módulo: Cada componente viene de una cápsula: PDFs (02), OCR/Vision (03), imágenes de documentos (04), Pydantic (05), chunking (06), errores (07).
Conexión con la guía: Este extractor es la base del Document Analyzer del Módulo 8. Allí le agregarás RAG para Q&A sobre el contenido extraído, y opcionalmente TTS para resúmenes hablados.
Especificaciones Técnicas
Input
El extractor acepta dos argumentos:
| Parámetro | Tipo | Descripción |
|---|---|---|
file_path | str | Ruta a PDF o imagen (JPG, PNG, WEBP) |
schema_type | str | Tipo de schema: "invoice", "receipt", "contract", "auto" |
Output
@dataclass
class ExtractionResult:
success: bool # Si la extracción fue exitosa
data: dict | None # Datos estructurados validados por Pydantic
raw_text: str # Texto crudo extraído del documento
pages_processed: int # Número de páginas procesadas
method: str # "pymupdf" | "tesseract" | "vision"
document_type: str # "pdf_text" | "pdf_scanned" | "image"
schema_used: str # "invoice" | "receipt" | "contract"
cost_usd: float # Costo estimado de la extracción
confidence: float # 0.0-1.0 confianza en la extracción
errors: list[str] # Errores encontrados durante el proceso
latency_seconds: float # Tiempo total de ejecución
Requisitos funcionales
- Detectar tipo de documento: PDF con texto, PDF escaneado, imagen
- Extraer texto con la técnica óptima según el tipo detectado
- Definir schemas Pydantic para facturas, recibos y contratos
- Extraer datos estructurados enviando texto/imagen al LLM con el schema
- Manejar documentos largos con chunking por páginas y merge de resultados
- Reportar costo, confianza y método usado en cada extracción
Paso 1: Configuración y Estructuras de Datos
Definimos los enums, dataclasses y configuración base. Separar constantes de lógica permite cambiar precios o métodos sin tocar el pipeline.
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any, Optional
import base64
import json
import time
import fitz
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError
class DocumentType(Enum):
PDF_TEXT = "pdf_text"
PDF_SCANNED = "pdf_scanned"
IMAGE = "image"
UNKNOWN = "unknown"
class ExtractionMethod(Enum):
PYMUPDF = "pymupdf"
TESSERACT = "tesseract"
VISION = "vision"
SUPPORTED_EXTENSIONS = {
".pdf": "pdf",
".jpg": "image", ".jpeg": "image",
".png": "image", ".webp": "image",
}
METHOD_COSTS_PER_PAGE = {
ExtractionMethod.PYMUPDF: 0.0,
ExtractionMethod.TESSERACT: 0.0,
ExtractionMethod.VISION: 0.003,
}
MIN_TEXT_CHARS_FOR_DIGITAL = 50
@dataclass
class ExtractionResult:
success: bool = False
data: dict | None = None
raw_text: str = ""
pages_processed: int = 0
method: str = ""
document_type: str = ""
schema_used: str = ""
cost_usd: float = 0.0
confidence: float = 0.0
errors: list[str] = field(default_factory=list)
latency_seconds: float = 0.0
client = OpenAI()
DocumentType codifica los tres casos que el sistema debe manejar. ExtractionMethod determina qué técnica usar. Los costos por página son aproximados — Vision API cobra por tokens de imagen, pero para estimación rápida usamos un promedio por página.
Paso 2: Detectar Tipo de Documento
La detección sigue tres reglas: si es imagen, es imagen. Si es PDF, extraemos texto con PyMuPDF — si tiene más de 50 caracteres por página en promedio, es PDF digital; si no, es PDF escaneado.
def detect_document_type(file_path: str) -> DocumentType:
"""Clasifica el documento como PDF digital, PDF escaneado, o imagen."""
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"Archivo no encontrado: {file_path}")
suffix = path.suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise ValueError(f"Formato no soportado: {suffix}")
if SUPPORTED_EXTENSIONS[suffix] == "image":
return DocumentType.IMAGE
doc = fitz.open(file_path)
total_text = ""
for page_num in range(len(doc)):
total_text += doc[page_num].get_text()
doc.close()
avg_chars_per_page = len(total_text.strip()) / max(len(doc), 1)
if avg_chars_per_page > MIN_TEXT_CHARS_FOR_DIGITAL:
return DocumentType.PDF_TEXT
return DocumentType.PDF_SCANNED
Paso 3: Extraer Texto
Tres métodos de extracción. PyMuPDF para PDFs digitales (gratis, rápido). Tesseract para OCR local cuando Vision no es opción. Vision API para máxima calidad en documentos escaneados o imágenes.
def extract_text_pymupdf(file_path: str) -> tuple[str, int]:
"""Extrae texto de PDF digital con PyMuPDF. Retorna (texto, páginas)."""
doc = fitz.open(file_path)
pages = []
for page_num in range(len(doc)):
page_text = doc[page_num].get_text()
if page_text.strip():
pages.append(page_text)
doc.close()
return "\n\n".join(pages), len(pages)
def extract_text_tesseract(file_path: str) -> tuple[str, int]:
"""Extrae texto con Tesseract OCR. Convierte PDF a imágenes primero."""
import pytesseract
from pdf2image import convert_from_path
from PIL import Image
path = Path(file_path)
if path.suffix.lower() == ".pdf":
images = convert_from_path(file_path, dpi=200)
else:
images = [Image.open(file_path)]
texts = []
for img in images:
text = pytesseract.image_to_string(img, lang="spa+eng")
texts.append(text)
return "\n\n".join(texts), len(images)
def extract_text_vision(file_path: str) -> tuple[str, int]:
"""Extrae texto enviando imágenes a GPT-4 Vision."""
path = Path(file_path)
images_b64 = []
if path.suffix.lower() == ".pdf":
doc = fitz.open(file_path)
for page_num in range(min(len(doc), 10)):
page = doc[page_num]
mat = fitz.Matrix(150 / 72, 150 / 72)
pix = page.get_pixmap(matrix=mat, alpha=False)
b64 = base64.b64encode(pix.tobytes("png")).decode()
images_b64.append(b64)
doc.close()
else:
with open(file_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
images_b64.append(b64)
prompt = "Extrae TODO el texto visible en este documento. Mantén la estructura original (títulos, listas, tablas). Responde solo con el texto extraído."
content = [{"type": "text", "text": prompt}]
for b64 in images_b64:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"}
})
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": content}],
temperature=0,
max_tokens=4096,
)
return response.choices[0].message.content, len(images_b64)
def select_and_extract(
file_path: str, doc_type: DocumentType
) -> tuple[str, int, ExtractionMethod]:
"""Selecciona método de extracción según tipo de documento."""
if doc_type == DocumentType.PDF_TEXT:
text, pages = extract_text_pymupdf(file_path)
return text, pages, ExtractionMethod.PYMUPDF
if doc_type == DocumentType.PDF_SCANNED:
text, pages = extract_text_vision(file_path)
return text, pages, ExtractionMethod.VISION
if doc_type == DocumentType.IMAGE:
text, pages = extract_text_vision(file_path)
return text, pages, ExtractionMethod.VISION
raise ValueError(f"Tipo de documento no reconocido: {doc_type}")
select_and_extract es el router central. PDFs digitales van a PyMuPDF (costo cero). Escaneados e imágenes van a Vision. Si quisieras un fallback a Tesseract por costo, cambias una línea aquí.
Paso 4: Schemas Pydantic
Tres schemas para los tipos de documento más comunes. El schema registry mapea nombres a clases Pydantic y permite auto-detección por keywords.
class InvoiceItem(BaseModel):
descripcion: str = Field(default="", description="Descripción del producto/servicio")
cantidad: float = Field(default=1.0, description="Cantidad")
precio_unitario: float = Field(default=0.0, description="Precio por unidad")
monto: float = Field(default=0.0, description="Monto total del item")
class InvoiceSchema(BaseModel):
fecha: Optional[str] = Field(default=None, description="Fecha en formato YYYY-MM-DD")
numero_factura: Optional[str] = Field(default=None, description="Número de factura")
proveedor: Optional[str] = Field(default=None, description="Nombre del proveedor/emisor")
cliente: Optional[str] = Field(default=None, description="Nombre del cliente/receptor")
subtotal: Optional[float] = Field(default=None, description="Subtotal antes de impuestos")
impuestos: Optional[float] = Field(default=None, description="Monto de impuestos")
total: Optional[float] = Field(default=None, description="Total a pagar")
moneda: Optional[str] = Field(default=None, description="Moneda (USD, EUR, MXN)")
items: list[InvoiceItem] = Field(default_factory=list, description="Lista de items")
class ReceiptItem(BaseModel):
descripcion: str = Field(default="", description="Nombre del producto")
cantidad: float = Field(default=1.0)
precio: float = Field(default=0.0)
class ReceiptSchema(BaseModel):
fecha: Optional[str] = Field(default=None, description="Fecha YYYY-MM-DD")
comercio: Optional[str] = Field(default=None, description="Nombre del comercio")
direccion: Optional[str] = Field(default=None, description="Dirección del comercio")
items: list[ReceiptItem] = Field(default_factory=list)
subtotal: Optional[float] = None
impuesto: Optional[float] = None
total: Optional[float] = None
metodo_pago: Optional[str] = Field(default=None, description="Efectivo, tarjeta, etc.")
class ContractSchema(BaseModel):
titulo: Optional[str] = Field(default=None, description="Título del contrato")
fecha: Optional[str] = Field(default=None, description="Fecha de firma YYYY-MM-DD")
partes: list[str] = Field(default_factory=list, description="Partes involucradas")
objeto: Optional[str] = Field(default=None, description="Objeto del contrato")
vigencia: Optional[str] = Field(default=None, description="Período de vigencia")
monto: Optional[float] = Field(default=None, description="Monto del contrato")
clausulas_clave: list[str] = Field(
default_factory=list, description="Cláusulas principales resumidas"
)
SCHEMA_REGISTRY: dict[str, type[BaseModel]] = {
"invoice": InvoiceSchema,
"receipt": ReceiptSchema,
"contract": ContractSchema,
}
SCHEMA_KEYWORDS: dict[str, list[str]] = {
"invoice": ["factura", "invoice", "nº factura", "subtotal", "iva", "proveedor"],
"receipt": ["ticket", "recibo", "receipt", "comercio", "cajero", "cambio"],
"contract": ["contrato", "contract", "cláusula", "vigencia", "partes", "firmante"],
}
def detect_schema_type(text: str) -> str:
"""Auto-detecta el tipo de schema analizando palabras clave en el texto."""
text_lower = text.lower()
scores = {}
for schema_name, keywords in SCHEMA_KEYWORDS.items():
score = sum(1 for kw in keywords if kw in text_lower)
scores[schema_name] = score
best = max(scores, key=scores.get)
if scores[best] == 0:
return "invoice"
return best
Cada schema usa Field(default=None) — un documento puede no tener todos los campos, y extraemos lo que hay sin fallar. La auto-detección por keywords es simple pero efectiva; en producción podrías reemplazarla con un clasificador LLM.
Paso 5: Extracción Estructurada con LLM
Enviamos el texto extraído al LLM junto con el schema JSON. El LLM devuelve JSON que parseamos y validamos con Pydantic.
def extract_structured_data(
text: str,
schema_type: str,
images_b64: list[str] | None = None,
) -> tuple[dict, float]:
"""Extrae datos estructurados usando LLM + schema Pydantic.
Returns:
(datos_validados, confianza)
"""
schema_class = SCHEMA_REGISTRY.get(schema_type)
if not schema_class:
raise ValueError(f"Schema no registrado: {schema_type}")
schema_json = schema_class.model_json_schema()
schema_str = json.dumps(schema_json, indent=2, ensure_ascii=False)
prompt = f"""Extrae los datos de este documento según el schema proporcionado.
SCHEMA (JSON Schema):
{schema_str}
REGLAS:
- Responde ÚNICAMENTE con JSON válido que cumpla el schema
- Si un campo no está presente en el documento, usa null
- Fechas en formato YYYY-MM-DD
- Montos como números (sin símbolos de moneda)
- Si hay items/líneas, extrae todos los que encuentres
DOCUMENTO:
{text[:6000]}"""
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
if images_b64:
for b64 in images_b64[:5]:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"}
})
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": content}],
response_format={"type": "json_object"},
temperature=0,
)
raw_json = json.loads(response.choices[0].message.content)
try:
validated = schema_class(**raw_json)
data = validated.model_dump()
confidence = _calculate_field_confidence(data)
return data, confidence
except ValidationError as e:
data = _safe_partial_parse(raw_json, schema_class)
confidence = _calculate_field_confidence(data) * 0.7
return data, confidence
def _safe_partial_parse(raw: dict, schema_class: type[BaseModel]) -> dict:
"""Intenta parsear parcialmente cuando la validación completa falla."""
clean = {}
for field_name, field_info in schema_class.model_fields.items():
if field_name in raw:
try:
clean[field_name] = raw[field_name]
except (TypeError, ValueError):
clean[field_name] = None
else:
clean[field_name] = None
return clean
def _calculate_field_confidence(data: dict) -> float:
"""Calcula confianza basada en proporción de campos no-nulos."""
if not data:
return 0.0
total = len(data)
filled = sum(1 for v in data.values() if v is not None and v != "" and v != [])
return round(filled / max(total, 1), 2)
Paso 6: Manejo de Documentos Largos
Para PDFs de más de 5 páginas, procesamos por chunks y mergeamos los resultados. _safe_partial_parse actúa como fallback: si el LLM devuelve JSON que no cumple el schema, extraemos lo posible en lugar de perder la extracción.
CHUNK_SIZE_PAGES = 5
def extract_long_document(
file_path: str,
schema_type: str,
) -> tuple[str, list[dict], int]:
"""Extrae texto de documento largo en chunks por páginas.
Returns:
(texto_completo, chunks_data, total_pages)
"""
doc = fitz.open(file_path)
total_pages = len(doc)
all_text_parts = []
chunks_data = []
for start in range(0, total_pages, CHUNK_SIZE_PAGES):
end = min(start + CHUNK_SIZE_PAGES, total_pages)
chunk_text = ""
for page_num in range(start, end):
chunk_text += doc[page_num].get_text() + "\n\n"
all_text_parts.append(chunk_text)
if chunk_text.strip():
data, conf = extract_structured_data(chunk_text, schema_type)
chunks_data.append({"pages": f"{start+1}-{end}", "data": data, "confidence": conf})
doc.close()
full_text = "\n\n".join(all_text_parts)
return full_text, chunks_data, total_pages
def merge_chunk_results(chunks_data: list[dict], schema_type: str) -> tuple[dict, float]:
"""Combina resultados de múltiples chunks en un solo resultado.
Estrategia: para campos escalares toma el primer valor no-nulo.
Para listas (items, cláusulas) concatena todos.
"""
if not chunks_data:
return {}, 0.0
if len(chunks_data) == 1:
return chunks_data[0]["data"], chunks_data[0]["confidence"]
schema_class = SCHEMA_REGISTRY[schema_type]
merged = {}
list_fields = set()
for field_name, field_info in schema_class.model_fields.items():
if hasattr(field_info.annotation, "__origin__") and field_info.annotation.__origin__ is list:
list_fields.add(field_name)
merged[field_name] = []
else:
merged[field_name] = None
for chunk in chunks_data:
data = chunk["data"]
for key, value in data.items():
if key in list_fields and isinstance(value, list):
merged[key].extend(value)
elif merged.get(key) is None and value is not None:
merged[key] = value
avg_confidence = sum(c["confidence"] for c in chunks_data) / len(chunks_data)
return merged, round(avg_confidence, 2)
El merge prioriza el primer valor no-nulo para campos escalares (fecha, total). Para listas como items o cláusulas, concatena todos los chunks.
Paso 7: Función Principal
extract_document() integra todos los pasos en un pipeline limpio. Es el único punto de entrada que necesita el usuario.
def extract_document(
file_path: str,
schema_type: str = "auto",
) -> ExtractionResult:
"""Pipeline completo de extracción de documentos.
Args:
file_path: Ruta a PDF o imagen
schema_type: "invoice", "receipt", "contract", o "auto" para detectar
Returns:
ExtractionResult con datos, metadata y métricas
"""
start = time.time()
result = ExtractionResult()
try:
doc_type = detect_document_type(file_path)
result.document_type = doc_type.value
text, pages, method = select_and_extract(file_path, doc_type)
result.raw_text = text
result.pages_processed = pages
result.method = method.value
result.cost_usd = METHOD_COSTS_PER_PAGE[method] * pages
if schema_type == "auto":
schema_type = detect_schema_type(text)
result.schema_used = schema_type
if pages > CHUNK_SIZE_PAGES and doc_type == DocumentType.PDF_TEXT:
full_text, chunks_data, total_pages = extract_long_document(
file_path, schema_type
)
result.raw_text = full_text
result.pages_processed = total_pages
data, confidence = merge_chunk_results(chunks_data, schema_type)
else:
data, confidence = extract_structured_data(text, schema_type)
result.data = data
result.confidence = confidence
result.success = True
except FileNotFoundError as e:
result.errors.append(f"Archivo no encontrado: {e}")
except ValueError as e:
result.errors.append(f"Error de formato: {e}")
except Exception as e:
result.errors.append(f"Error inesperado: {type(e).__name__}: {e}")
result.latency_seconds = round(time.time() - start, 2)
return result
Demo: Uso Completo
def print_result(result: ExtractionResult):
"""Imprime resultado de extracción de forma legible."""
status = "ÉXITO" if result.success else "ERROR"
print(f"\n{'='*60}")
print(f" Estado: {status}")
print(f" Tipo doc: {result.document_type}")
print(f" Método: {result.method}")
print(f" Schema: {result.schema_used}")
print(f" Páginas: {result.pages_processed}")
print(f" Confianza: {result.confidence:.0%}")
print(f" Costo est.: ${result.cost_usd:.4f}")
print(f" Latencia: {result.latency_seconds}s")
if result.data:
print(f" Datos extraídos:")
for key, value in result.data.items():
if isinstance(value, list) and len(value) > 2:
print(f" {key}: [{len(value)} items]")
else:
print(f" {key}: {value}")
if result.errors:
print(f" Errores:")
for err in result.errors:
print(f" - {err}")
print(f"{'='*60}")
# --- Ejemplo 1: Factura en PDF digital ---
result = extract_document("factura_digital.pdf", schema_type="invoice")
print_result(result)
# --- Ejemplo 2: Recibo escaneado (imagen) ---
result = extract_document("recibo_foto.jpg", schema_type="receipt")
print_result(result)
# --- Ejemplo 3: Contrato largo con auto-detección ---
result = extract_document("contrato_20paginas.pdf", schema_type="auto")
print_result(result)
# --- Ejemplo 4: Acceso directo al JSON ---
result = extract_document("factura.pdf")
if result.success:
print(json.dumps(result.data, indent=2, ensure_ascii=False))
Salida esperada (factura digital):
============================================================
Estado: ÉXITO
Tipo doc: pdf_text
Método: pymupdf
Schema: invoice
Páginas: 1
Confianza: 89%
Costo est.: $0.0030
Latencia: 1.84s
Datos extraídos:
fecha: 2024-03-15
numero_factura: INV-2024-0847
proveedor: Acme Technologies S.A.
total: 5220.0
items: [3 items]
============================================================
Extensión 1: Batch Processing
Procesa múltiples documentos con tracking de progreso, costos acumulados, y resumen estadístico.
@dataclass
class BatchResult:
total: int = 0
successful: int = 0
failed: int = 0
total_cost_usd: float = 0.0
total_pages: int = 0
avg_confidence: float = 0.0
by_type: dict = field(default_factory=dict)
by_method: dict = field(default_factory=dict)
results: list[ExtractionResult] = field(default_factory=list)
def extract_batch(
file_paths: list[str],
schema_type: str = "auto",
budget_usd: float | None = None,
) -> BatchResult:
"""Procesa múltiples documentos con tracking de progreso."""
batch = BatchResult()
confidences = []
for i, path in enumerate(file_paths):
print(f" [{i+1}/{len(file_paths)}] Procesando: {Path(path).name}...")
if budget_usd and batch.total_cost_usd >= budget_usd:
fail = ExtractionResult()
fail.errors.append(f"Presupuesto agotado: ${batch.total_cost_usd:.4f}/{budget_usd}")
batch.results.append(fail)
batch.failed += 1
batch.total += 1
continue
result = extract_document(path, schema_type=schema_type)
batch.results.append(result)
batch.total += 1
if result.success:
batch.successful += 1
batch.total_cost_usd += result.cost_usd
batch.total_pages += result.pages_processed
confidences.append(result.confidence)
batch.by_type[result.document_type] = batch.by_type.get(result.document_type, 0) + 1
batch.by_method[result.method] = batch.by_method.get(result.method, 0) + 1
else:
batch.failed += 1
batch.avg_confidence = round(sum(confidences) / len(confidences), 2) if confidences else 0.0
return batch
def print_batch_report(batch: BatchResult):
"""Imprime reporte de batch processing."""
print(f"\n{'='*60}")
print(f" REPORTE DE BATCH")
print(f"{'='*60}")
print(f" Total documentos: {batch.total}")
print(f" Exitosos: {batch.successful}")
print(f" Fallidos: {batch.failed}")
print(f" Total páginas: {batch.total_pages}")
print(f" Costo total: ${batch.total_cost_usd:.4f}")
print(f" Confianza prom.: {batch.avg_confidence:.0%}")
if batch.by_type:
print(f" Por tipo:")
for doc_type, count in batch.by_type.items():
print(f" {doc_type}: {count}")
if batch.by_method:
print(f" Por método:")
for method, count in batch.by_method.items():
print(f" {method}: {count}")
print(f"{'='*60}")
Extensión 2: Confidence Scoring Detallado
Un scoring más granular que analiza la calidad de cada campo extraído, no solo la proporción de campos llenos.
@dataclass
class FieldScore:
field_name: str
present: bool
plausible: bool
score: float
def score_extraction(data: dict, schema_type: str) -> tuple[float, list[FieldScore]]:
"""Evalúa la calidad de la extracción campo por campo.
Checks:
- Presencia: el campo tiene valor no-nulo
- Plausibilidad: el valor tiene formato esperado
"""
field_scores = []
plausibility_checks = {
"fecha": lambda v: bool(v and len(str(v)) == 10 and "-" in str(v)),
"total": lambda v: isinstance(v, (int, float)) and v > 0,
"subtotal": lambda v: isinstance(v, (int, float)) and v > 0,
"impuestos": lambda v: isinstance(v, (int, float)) and v >= 0,
"impuesto": lambda v: isinstance(v, (int, float)) and v >= 0,
"monto": lambda v: isinstance(v, (int, float)) and v > 0,
"moneda": lambda v: bool(v and len(str(v)) == 3),
"items": lambda v: isinstance(v, list) and len(v) > 0,
"partes": lambda v: isinstance(v, list) and len(v) >= 2,
"clausulas_clave": lambda v: isinstance(v, list) and len(v) > 0,
}
for field_name, value in data.items():
present = value is not None and value != "" and value != []
check = plausibility_checks.get(field_name)
plausible = check(value) if check and present else present
score = 0.0
if present:
score = 1.0 if plausible else 0.5
field_scores.append(FieldScore(
field_name=field_name,
present=present,
plausible=plausible,
score=score,
))
total_score = sum(fs.score for fs in field_scores) / max(len(field_scores), 1)
return round(total_score, 2), field_scores
def print_confidence_report(data: dict, schema_type: str):
"""Imprime reporte detallado de confianza por campo."""
total_score, field_scores = score_extraction(data, schema_type)
print(f"\n Confianza detallada: {total_score:.0%}")
for fs in field_scores:
status = "OK" if fs.plausible else ("parcial" if fs.present else "falta")
print(f" {fs.field_name:<20} {status:<10} {fs.score:.1f}")
Troubleshooting del Proyecto
Problema 1: PDF escaneado se detecta como digital
Síntoma: Un PDF escaneado tiene texto OCR invisible embebido (OCR layer) y PyMuPDF lo extrae, pero el texto es basura.
Solución: Agrega un quality check: calcula alpha_ratio = sum(c.isalpha() for c in text) / len(text). Si es menor a 0.5, re-clasifica como escaneado y usa Vision. Integra este check en select_and_extract como post-validación del texto de PyMuPDF.
Problema 2: Campos de factura con formatos inconsistentes
Síntoma: El LLM devuelve "total": "$1,234.56" en lugar de "total": 1234.56.
Solución: Agrega un pre-procesador entre el JSON del LLM y Pydantic que limpie valores monetarios: value.replace("$", "").replace(",", "") y luego float(). Aplícalo a campos numéricos antes de pasar a schema_class(**raw_json).
Problema 3: Documento largo excede timeout
Síntoma: Un contrato de 50 páginas toma más de 60 segundos y falla por timeout del cliente OpenAI.
Solución: Ajusta el timeout del cliente (OpenAI(timeout=120.0)) y reduce CHUNK_SIZE_PAGES = 3 para documentos muy largos.
Problema 4: Vision API falla con imágenes de baja resolución
Síntoma: Fotografías de documentos tomadas con poca luz devuelven texto parcial o incorrecto.
Solución: Pre-procesa con PIL antes de enviar: ImageEnhance.Contrast(img).enhance(1.5) y ImageEnhance.Sharpness(img).enhance(2.0). Guarda como JPEG quality=95 antes de codificar a Base64.
Problema 5: Schema auto-detectado incorrectamente
Síntoma: Un recibo se clasifica como factura porque contiene la palabra "factura simplificada".
Solución: Usa pesos diferenciados por keyword en lugar de conteo simple:
SCHEMA_WEIGHTS: dict[str, dict[str, float]] = {
"invoice": {"factura": 2.0, "proveedor": 1.5, "iva": 1.0, "nº factura": 2.0},
"receipt": {"ticket": 2.0, "recibo": 2.0, "cajero": 1.5, "cambio": 1.0},
"contract": {"contrato": 2.0, "cláusula": 2.0, "vigencia": 1.5, "firmante": 1.5},
}
Checklist de Completitud
Pipeline core:
- Acepta PDF e imágenes (JPG, PNG, WEBP)
- Detecta PDF digital vs escaneado vs imagen
- Extrae texto con PyMuPDF (digital), Vision (escaneado/imagen)
- Tesseract disponible como método alternativo
- Schemas Pydantic: InvoiceSchema, ReceiptSchema, ContractSchema
- Schema registry con auto-detección por keywords
- Extracción estructurada con LLM + validación Pydantic
- Fallback parcial cuando la validación falla
Documentos largos:
- Chunking por páginas (CHUNK_SIZE_PAGES configurable)
- Procesamiento independiente por chunk
- Merge de resultados (escalares: primer no-nulo, listas: concatenar)
Calidad y métricas:
- ExtractionResult con todos los campos especificados
- Costo estimado por extracción
- Confianza basada en campos extraídos
- Latencia medida
- Errores capturados sin crashes
Extensiones:
- Batch processing con tracking de progreso y presupuesto
- Confidence scoring detallado por campo
Ejercicios
Ejercicio 1: Agregar Schema de Recibo Médico (Fácil)
Crea un MedicalReceiptSchema con campos: paciente, doctor, hospital, fecha, diagnostico, medicamentos (lista con nombre, dosis, cantidad), total. Regístralo en SCHEMA_REGISTRY con keywords apropiadas.
Ver solución
class Medicamento(BaseModel):
nombre: str = Field(default="", description="Nombre del medicamento")
dosis: Optional[str] = Field(default=None, description="Dosis indicada")
cantidad: int = Field(default=1, description="Cantidad recetada")
precio: float = Field(default=0.0)
class MedicalReceiptSchema(BaseModel):
paciente: Optional[str] = Field(default=None, description="Nombre del paciente")
doctor: Optional[str] = Field(default=None, description="Nombre del médico")
hospital: Optional[str] = Field(default=None, description="Centro médico")
fecha: Optional[str] = Field(default=None, description="Fecha YYYY-MM-DD")
diagnostico: Optional[str] = Field(default=None, description="Diagnóstico")
medicamentos: list[Medicamento] = Field(default_factory=list)
total: Optional[float] = Field(default=None)
SCHEMA_REGISTRY["medical_receipt"] = MedicalReceiptSchema
SCHEMA_KEYWORDS["medical_receipt"] = [
"paciente", "doctor", "médico", "receta", "diagnóstico",
"medicamento", "dosis", "hospital", "clínica",
]
result = extract_document("receta_medica.pdf", schema_type="medical_receipt")
print(json.dumps(result.data, indent=2, ensure_ascii=False))
Ejercicio 2: Capa de Validación Post-Extracción (Medio)
Crea una función validate_extraction(data, schema_type) que aplique reglas de negocio sobre los datos extraídos: total debe ser >= subtotal, fecha no puede ser futura, items deben sumar aprox. el subtotal (±10%). Retorna lista de warnings y un booleano is_valid.
Ver solución
from datetime import date
def validate_extraction(data: dict, schema_type: str) -> tuple[bool, list[str]]:
"""Valida datos extraídos contra reglas de negocio."""
warnings = []
if schema_type in ("invoice", "receipt"):
total = data.get("total")
subtotal = data.get("subtotal")
if total is not None and subtotal is not None:
if total < subtotal:
warnings.append(
f"Total ({total}) menor que subtotal ({subtotal})"
)
fecha_str = data.get("fecha")
if fecha_str:
try:
doc_date = date.fromisoformat(fecha_str)
if doc_date > date.today():
warnings.append(f"Fecha futura detectada: {fecha_str}")
except ValueError:
warnings.append(f"Fecha con formato inválido: {fecha_str}")
items = data.get("items", [])
if items and subtotal is not None:
items_sum = sum(
item.get("monto", 0) or item.get("precio", 0) * item.get("cantidad", 1)
for item in items
)
if items_sum > 0 and abs(items_sum - subtotal) / subtotal > 0.10:
warnings.append(
f"Items suman {items_sum:.2f}, subtotal es {subtotal:.2f} (diferencia >10%)"
)
if schema_type == "contract":
partes = data.get("partes", [])
if len(partes) < 2:
warnings.append("Contrato con menos de 2 partes identificadas")
is_valid = len(warnings) == 0
return is_valid, warnings
result = extract_document("factura.pdf", schema_type="invoice")
if result.success:
is_valid, validation_warnings = validate_extraction(result.data, result.schema_used)
print(f"Validación: {'PASS' if is_valid else 'WARN'}")
for w in validation_warnings:
print(f" - {w}")
Resumen
En este proyecto construiste un Document Extractor completo que:
- Detecta tipo de documento (PDF digital, PDF escaneado, imagen) usando PyMuPDF para análisis de texto embebido
- Extrae texto con el método óptimo: PyMuPDF para digitales (gratis), Tesseract para OCR local, Vision API para máxima calidad
- Define schemas Pydantic para facturas, recibos y contratos con auto-detección por keywords
- Extrae datos estructurados con LLM + validación Pydantic, con fallback parcial cuando la validación falla
- Maneja documentos largos con chunking por páginas y merge inteligente de resultados
- Reporta métricas de costo, confianza, método usado y latencia en cada extracción
Este extractor es la base del Document Analyzer del Módulo 8, donde integrarás RAG para Q&A sobre contenido extraído y TTS para resúmenes hablados.
Próximo módulo: Módulo 4 — Generación de Imágenes. Del análisis pasas a la creación: DALL-E, Stable Diffusion, y pipelines de generación controlada.
Recursos Adicionales
- PyMuPDF Documentation — Extracción de texto y renderizado de PDF
- Tesseract OCR — Motor OCR open-source
- Pydantic V2 Docs — Modelos, validación y JSON Schema
- OpenAI Vision Guide — Procesamiento de imágenes con GPT-4
- OpenAI Structured Outputs — JSON mode y response format
- pdf2image — Conversión de PDF a imágenes para OCR