Mejoras Implementadas en Extracción de PDFs con IA

He mejorado significativamente el sistema de extracción de texto de PDFs para el análisis con IA. Aquí están los cambios principales:

🎯 Problemas Resueltos
Límites muy pequeños → Aumentados significativamente según capacidad del modelo
No extraía todo el documento → Ahora procesa hasta 100k caracteres
Duplicación de contenido → Detecta y elimina páginas repetidas
Sin información de estado → Reporta páginas procesadas, truncado, etc.
This commit is contained in:
2025-12-03 00:55:11 -03:00
parent 582114a55a
commit 50909e4499
4 changed files with 279 additions and 52 deletions

View File

@@ -209,7 +209,74 @@ def send_completed_inspection_to_n8n(inspection, db):
# No lanzamos excepción para no interrumpir el flujo normal
BACKEND_VERSION = "1.0.94"
# ============================================================================
# UTILIDADES PARA PROCESAMIENTO DE PDFs
# ============================================================================
def extract_pdf_text_smart(pdf_content: bytes, max_chars: int = None) -> dict:
"""
Extrae texto de un PDF de forma inteligente, evitando duplicaciones
y manejando PDFs grandes.
Args:
pdf_content: Contenido del PDF en bytes
max_chars: Límite máximo de caracteres (None = sin límite)
Returns:
dict con 'text', 'pages', 'total_chars', 'truncated'
"""
from pypdf import PdfReader
from io import BytesIO
try:
pdf_file = BytesIO(pdf_content)
pdf_reader = PdfReader(pdf_file)
full_text = ""
pages_processed = 0
total_pages = len(pdf_reader.pages)
for page_num, page in enumerate(pdf_reader.pages, 1):
page_text = page.extract_text()
# Limpiar y validar texto de la página
if page_text and page_text.strip():
# Evitar duplicación: verificar si el texto ya existe
# (algunos PDFs pueden tener páginas repetidas)
if page_text.strip() not in full_text:
full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n"
pages_processed += 1
# Si hay límite y lo alcanzamos, detener
if max_chars and len(full_text) >= max_chars:
break
total_chars = len(full_text)
truncated = False
# Aplicar límite si se especificó
if max_chars and total_chars > max_chars:
full_text = full_text[:max_chars]
truncated = True
return {
'text': full_text,
'pages': total_pages,
'pages_processed': pages_processed,
'total_chars': total_chars,
'truncated': truncated,
'success': True
}
except Exception as e:
return {
'text': '',
'error': str(e),
'success': False
}
BACKEND_VERSION = "1.0.95"
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
# S3/MinIO configuration
@@ -2598,7 +2665,6 @@ async def analyze_image(
# Guardar archivo temporalmente y procesar según tipo
import base64
from pypdf import PdfReader
contents = await file.read()
file_type = file.content_type
@@ -2610,18 +2676,21 @@ async def analyze_image(
if is_pdf:
print("📕 Detectado PDF - extrayendo texto...")
try:
from io import BytesIO
pdf_file = BytesIO(contents)
pdf_reader = PdfReader(pdf_file)
# Extraer texto de todas las páginas
pdf_text = ""
for page_num, page in enumerate(pdf_reader.pages):
pdf_text += f"\n--- Página {page_num + 1} ---\n"
pdf_text += page.extract_text()
# Usar función inteligente de extracción
# Para análisis de imagen usamos hasta 100k caracteres (Gemini soporta mucho más)
pdf_result = extract_pdf_text_smart(contents, max_chars=100000)
print(f"✅ Texto extraído: {len(pdf_text)} caracteres")
if not pdf_result['success']:
return {
"status": "error",
"message": f"Error al procesar PDF: {pdf_result.get('error', 'Unknown')}"
}
pdf_text = pdf_result['text']
print(f"✅ Texto extraído: {pdf_result['total_chars']} caracteres de {pdf_result['pages_processed']}/{pdf_result['pages']} páginas")
if pdf_result['truncated']:
print(f"⚠️ PDF truncado a 100k caracteres")
if not pdf_text.strip():
return {
@@ -2631,13 +2700,6 @@ async def analyze_image(
# Para PDFs usamos análisis de texto, no de imagen
image_b64 = None
except Exception as e:
print(f"❌ Error al procesar PDF: {str(e)}")
return {
"status": "error",
"message": f"Error al procesar PDF: {str(e)}"
}
else:
# Es una imagen
image_b64 = base64.b64encode(contents).decode('utf-8')
@@ -2819,7 +2881,7 @@ NOTA:
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": f"{user_message}\n\n--- CONTENIDO DEL DOCUMENTO PDF ---\n{pdf_text[:4000]}" # Limitar a 4000 chars
"content": f"{user_message}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:30000]}" # 30k chars para GPT-4
}
]
else:
@@ -2860,8 +2922,8 @@ NOTA:
prompt = f"{system_prompt}\n\n{user_message}"
if is_pdf:
# Para PDF, solo texto
prompt_with_content = f"{prompt}\n\n--- CONTENIDO DEL DOCUMENTO PDF ---\n{pdf_text[:4000]}"
# Para PDF, solo texto - Gemini puede manejar contextos muy largos (2M tokens)
prompt_with_content = f"{prompt}\n\n--- CONTENIDO DEL DOCUMENTO PDF ({len(pdf_text)} caracteres) ---\n{pdf_text[:100000]}"
response = model.generate_content(prompt_with_content)
else:
# Para imagen, incluir imagen
@@ -3036,8 +3098,6 @@ async def chat_with_ai_assistant(
attached_files_data = []
if files:
import base64
from pypdf import PdfReader
from io import BytesIO
for file in files:
file_content = await file.read()
@@ -3051,18 +3111,22 @@ async def chat_with_ai_assistant(
# Si es PDF, extraer texto
if file_type == 'application/pdf' or file.filename.lower().endswith('.pdf'):
try:
pdf_file = BytesIO(file_content)
pdf_reader = PdfReader(pdf_file)
pdf_text = ""
for page in pdf_reader.pages:
pdf_text += page.extract_text()
# Usar función inteligente - límite de 50k para chat (balance entre contexto y tokens)
pdf_result = extract_pdf_text_smart(file_content, max_chars=50000)
if pdf_result['success']:
file_info['content_type'] = 'pdf'
file_info['text'] = pdf_text[:2000] # Limitar texto
print(f"📄 PDF procesado: {file.filename} - {len(pdf_text)} caracteres")
except Exception as e:
print(f"❌ Error procesando PDF {file.filename}: {str(e)}")
file_info['error'] = str(e)
file_info['text'] = pdf_result['text']
file_info['total_chars'] = pdf_result['total_chars']
file_info['pages'] = pdf_result['pages']
file_info['pages_processed'] = pdf_result['pages_processed']
file_info['truncated'] = pdf_result['truncated']
truncated_msg = " (TRUNCADO)" if pdf_result['truncated'] else ""
print(f"📄 PDF procesado: {file.filename} - {pdf_result['total_chars']} caracteres, {pdf_result['pages_processed']}/{pdf_result['pages']} páginas{truncated_msg}")
else:
print(f"❌ Error procesando PDF {file.filename}: {pdf_result.get('error', 'Unknown')}")
file_info['error'] = pdf_result.get('error', 'Error desconocido')
# Si es imagen, convertir a base64
elif file_type.startswith('image/'):
@@ -3120,9 +3184,12 @@ INFORMACIÓN DEL VEHÍCULO:
attached_context = f"\n\nARCHIVOS ADJUNTOS EN ESTE MENSAJE ({len(attached_files_data)} archivos):\n"
for idx, file_info in enumerate(attached_files_data, 1):
if file_info.get('content_type') == 'pdf':
attached_context += f"\n{idx}. PDF: {file_info['filename']}\n"
truncated_indicator = "TRUNCADO" if file_info.get('truncated') else ""
pages_info = f" ({file_info.get('pages_processed', '?')}/{file_info.get('pages', '?')} páginas, {file_info.get('total_chars', '?')} caracteres{truncated_indicator})" if 'pages' in file_info else ""
attached_context += f"\n{idx}. PDF: {file_info['filename']}{pages_info}\n"
if 'text' in file_info:
attached_context += f" Contenido: {file_info['text'][:500]}...\n"
# Mostrar más contexto del PDF (primeros 2000 caracteres como preview)
attached_context += f" Contenido: {file_info['text'][:2000]}...\n"
elif file_info.get('content_type') == 'image':
attached_context += f"\n{idx}. Imagen: {file_info['filename']}\n"

View File

@@ -0,0 +1,155 @@
# Mejoras en la Extracción de PDFs con IA
## Versión Backend: 1.0.95
## Problema Original
El sistema tenía limitaciones al procesar PDFs con IA:
1. **Límites muy pequeños**: Solo extraía 2,000-4,000 caracteres
2. **Sin manejo de duplicaciones**: Páginas repetidas se procesaban múltiples veces
3. **No aprovechaba contextos largos**: Los modelos modernos soportan millones de tokens
4. **Falta de información**: No reportaba páginas procesadas o si el contenido fue truncado
## Solución Implementada
### 1. Función Centralizada de Extracción
Nueva función `extract_pdf_text_smart()` que:
- ✅ Extrae texto de forma inteligente
- ✅ Detecta y evita páginas duplicadas
- ✅ Maneja límites configurables
- ✅ Reporta estadísticas completas (páginas, caracteres, truncado)
- ✅ Manejo robusto de errores
```python
pdf_result = extract_pdf_text_smart(pdf_content, max_chars=50000)
# Retorna:
# {
# 'text': '...',
# 'pages': 10,
# 'pages_processed': 9, # Si una página estaba duplicada
# 'total_chars': 45000,
# 'truncated': False,
# 'success': True
# }
```
### 2. Límites Optimizados por Caso de Uso
| Endpoint | Límite Anterior | Límite Nuevo | Modelo Objetivo |
|----------|----------------|--------------|-----------------|
| `/api/analyze-image` (OpenAI) | 4,000 chars | 30,000 chars | GPT-4 (128k tokens) |
| `/api/analyze-image` (Gemini) | 4,000 chars | 100,000 chars | Gemini 1.5/2.0 (2M tokens) |
| `/api/ai/chat-assistant` | 2,000 chars | 50,000 chars | Equilibrado para contexto |
### 3. Detección de Duplicaciones
El sistema ahora verifica si el contenido de una página ya existe antes de agregarlo:
```python
if page_text.strip() not in full_text:
full_text += f"\n--- Página {page_num}/{total_pages} ---\n{page_text.strip()}\n"
```
Esto previene:
- PDFs con páginas idénticas repetidas
- Documentos mal generados con contenido duplicado
- Uso innecesario de tokens en el análisis IA
### 4. Información Mejorada
El sistema ahora reporta:
- **Páginas totales**: Total de páginas en el PDF
- **Páginas procesadas**: Páginas únicas con contenido
- **Caracteres totales**: Tamaño real del texto extraído
- **Indicador de truncado**: Si el PDF fue limitado
Ejemplo de output:
```
📄 PDF procesado: manual-vehiculo.pdf - 87450 caracteres, 8/10 páginas (TRUNCADO)
```
## Capacidades de Contexto por Modelo
### OpenAI GPT-4
- **Contexto**: ~128,000 tokens (~500,000 caracteres)
- **Límite aplicado**: 30,000 caracteres
- **Razón**: Balance entre contexto útil y costo
### Gemini 1.5/2.0 Pro
- **Contexto**: 2,000,000 tokens (~8,000,000 caracteres)
- **Límite aplicado**: 100,000 caracteres
- **Razón**: Aprovechar contexto masivo sin sobrecargar
### Chat Assistant
- **Límite**: 50,000 caracteres
- **Razón**: Incluye historial + contexto de fotos + PDF
## Casos de Uso Soportados
### ✅ PDFs Pequeños (1-5 páginas)
Extracción completa sin truncado
### ✅ PDFs Medianos (5-20 páginas)
Extracción completa o parcial según contenido
### ✅ PDFs Grandes (20+ páginas)
Extracción inteligente con truncado después de límite
### ✅ PDFs con Páginas Duplicadas
Detección automática y eliminación
### ✅ Múltiples PDFs en Chat
Cada uno procesado independientemente con su límite
## Indicadores de Estado
### En Logs del Servidor
```
📄 PDF procesado: documento.pdf - 25000 caracteres, 10/10 páginas
📄 PDF procesado: manual.pdf - 50000 caracteres, 15/20 páginas (TRUNCADO)
```
### En Respuesta al Cliente
```json
{
"attached_files": [
{
"filename": "manual.pdf",
"type": "application/pdf",
"pages": 20,
"pages_processed": 15,
"total_chars": 75000,
"truncated": true
}
]
}
```
## Próximas Mejoras Potenciales
1. **Chunking Inteligente**: Para PDFs muy grandes, dividir en chunks semánticos
2. **OCR Integrado**: Detectar PDFs escaneados y aplicar OCR automático
3. **Resumen Automático**: Para PDFs grandes, generar resumen antes de análisis
4. **Cache de Extracciones**: Guardar texto extraído en DB para reutilización
## Migración
No requiere migración de base de datos. Los cambios son retrocompatibles.
## Testing
Para probar las mejoras:
1. **PDF pequeño** (< 10 páginas): Debe procesarse completo
2. **PDF grande** (> 50 páginas): Debe truncarse y reportar info
3. **PDF con duplicados**: Debe eliminar páginas repetidas
4. **Múltiples PDFs**: Cada uno procesado independientemente
## Notas Técnicas
- La función `extract_pdf_text_smart()` está en `main.py` línea ~210
- Usa `pypdf.PdfReader` para extracción
- Maneja encoding UTF-8 automáticamente
- Thread-safe (usa BytesIO)

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -20,9 +20,9 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
{sidebarOpen && (
<div className="flex items-center gap-2">
<img
src="/logo192.png"
src="/ayutec_logo.png"
alt="Ayutec"
className="w-8 h-8 object-contain"
className="w-8 h-8 object-contain bg-white rounded-lg p-1"
/>
<h2 className="text-xl font-bold bg-gradient-to-r from-indigo-400 to-purple-400 bg-clip-text text-transparent">Ayutec</h2>
</div>
@@ -141,16 +141,21 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
{/* Versión */}
{sidebarOpen && (
<div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30">
<div className="flex items-center justify-center gap-2">
<a
href="https://ayutec.es"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 hover:opacity-80 transition-opacity"
>
<img
src="/logo192.png"
src="/ayutec_logo.webp"
alt="Ayutec"
className="w-4 h-4 object-contain"
className="w-10 h-10 object-contain bg-white rounded p-1"
/>
<p className="text-xs text-indigo-300 font-medium">
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
Ayutec v1.1.0
</p>
</div>
</a>
</div>
)}