Soporte para PDFs agregado al sistema de análisis con IA

📋 Cambios Implementados
Frontend:

 Input acepta image/*,application/pdf
 Label actualizado: "Fotografías / Documentos PDF *"
 Preview diferenciado: PDFs muestran icono 📝 rojo en lugar de imagen
 Nombre de archivo PDF visible en el preview
 Contador genérico: "archivo(s) cargado(s)"
Backend:

 Agregado pypdf==4.0.1 a requirements.txt
 Detección automática de PDFs por content_type o extensión
 Extracción de texto de PDFs usando pypdf.PdfReader
 Validación de PDFs vacíos (sin texto extraíble)
 Prompts adaptados automáticamente para PDFs
 Soporte en OpenAI y Gemini (análisis de texto en lugar de vision)
 Límite de 4000 caracteres del PDF para análisis
🎯 Funcionamiento
Usuario sube PDF → Sistema detecta tipo de archivo
Extrae texto → PyPDF lee todas las páginas
Análisis IA → Envía texto al modelo (no usa Vision API)
Respuesta → Misma estructura JSON que con imágenes
⚠️ Limitaciones
PDFs escaneados sin OCR no funcionarán (requieren texto seleccionable)
Máximo 4000 caracteres del PDF enviados al AI
📦 Versiones
Frontend: 1.0.91 → 1.0.92
Backend: 1.0.89 → 1.0.90
This commit is contained in:
2025-12-02 09:40:44 -03:00
parent d51d912962
commit bf30b1a2bf
6 changed files with 100 additions and 24 deletions

View File

@@ -207,7 +207,7 @@ def send_completed_inspection_to_n8n(inspection, db):
# No lanzamos excepción para no interrumpir el flujo normal # No lanzamos excepción para no interrumpir el flujo normal
BACKEND_VERSION = "1.0.89" BACKEND_VERSION = "1.0.90"
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
# S3/MinIO configuration # S3/MinIO configuration
@@ -2558,11 +2558,52 @@ async def analyze_image(
"message": "No hay configuración de IA activa. Configure en Settings." "message": "No hay configuración de IA activa. Configure en Settings."
} }
# Guardar imagen temporalmente # Guardar archivo temporalmente y procesar según tipo
import base64 import base64
from pypdf import PdfReader
contents = await file.read() contents = await file.read()
file_type = file.content_type
print(f"📄 Tipo de archivo: {file_type}")
# Detectar si es PDF
is_pdf = file_type == 'application/pdf' or file.filename.lower().endswith('.pdf')
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()
print(f"✅ Texto extraído: {len(pdf_text)} caracteres")
if not pdf_text.strip():
return {
"status": "error",
"message": "No se pudo extraer texto del PDF. Puede ser un PDF escaneado sin OCR."
}
# 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') image_b64 = base64.b64encode(contents).decode('utf-8')
pdf_text = None
# Obtener contexto de la pregunta si se proporciona # Obtener contexto de la pregunta si se proporciona
question_obj = None question_obj = None
@@ -2715,6 +2756,13 @@ NOTA:
- "context_match" debe ser true si la imagen muestra un componente vehicular relevante, false si no corresponde.""" - "context_match" debe ser true si la imagen muestra un componente vehicular relevante, false si no corresponde."""
user_message = "Analiza este componente del vehículo para la inspección general." user_message = "Analiza este componente del vehículo para la inspección general."
# Ajustar prompt si es PDF en lugar de imagen
if is_pdf:
system_prompt = system_prompt.replace("Analiza la imagen", "Analiza el documento PDF")
system_prompt = system_prompt.replace("la imagen", "el documento")
system_prompt = system_prompt.replace("context_match", "document_relevance")
user_message = user_message.replace("imagen", "documento PDF")
print(f"\n🤖 PROMPT ENVIADO AL AI:") print(f"\n🤖 PROMPT ENVIADO AL AI:")
print(f"Provider: {ai_config.provider}") print(f"Provider: {ai_config.provider}")
print(f"Model: {ai_config.model_name}") print(f"Model: {ai_config.model_name}")
@@ -2726,9 +2774,19 @@ NOTA:
import openai import openai
openai.api_key = ai_config.api_key openai.api_key = ai_config.api_key
response = openai.ChatCompletion.create( # Construir mensaje según si es PDF o imagen
model=ai_config.model_name, if is_pdf:
messages=[ # Para PDF, solo texto
messages_content = [
{"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
}
]
else:
# Para imagen, usar vision
messages_content = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{ {
"role": "user", "role": "user",
@@ -2743,7 +2801,11 @@ NOTA:
} }
] ]
} }
], ]
response = openai.ChatCompletion.create(
model=ai_config.model_name,
messages=messages_content,
max_tokens=500 max_tokens=500
) )
@@ -2757,11 +2819,17 @@ NOTA:
genai.configure(api_key=ai_config.api_key) genai.configure(api_key=ai_config.api_key)
model = genai.GenerativeModel(ai_config.model_name) model = genai.GenerativeModel(ai_config.model_name)
# Convertir base64 a imagen PIL
image = Image.open(BytesIO(contents))
prompt = f"{system_prompt}\n\n{user_message}" 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]}"
response = model.generate_content(prompt_with_content)
else:
# Para imagen, incluir imagen
image = Image.open(BytesIO(contents))
response = model.generate_content([prompt, image]) response = model.generate_content([prompt, image])
ai_response = response.text ai_response = response.text
else: else:

View File

@@ -14,6 +14,7 @@ openai==1.10.0
google-generativeai==0.3.2 google-generativeai==0.3.2
Pillow==10.2.0 Pillow==10.2.0
reportlab==4.0.9 reportlab==4.0.9
pypdf==4.0.1
python-dotenv==1.0.0 python-dotenv==1.0.0
boto3==1.34.89 boto3==1.34.89
requests==2.31.0 requests==2.31.0

View File

@@ -1,7 +1,7 @@
{ {
"name": "checklist-frontend", "name": "checklist-frontend",
"private": true, "private": true,
"version": "1.0.91", "version": "1.0.92",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
// Service Worker para PWA con detección de actualizaciones // Service Worker para PWA con detección de actualizaciones
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión // IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
const CACHE_NAME = 'ayutec-v1.0.91'; const CACHE_NAME = 'ayutec-v1.0.92';
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/index.html' '/index.html'

View File

@@ -5007,10 +5007,10 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
{currentQuestion.allow_photos && ( {currentQuestion.allow_photos && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Fotografías * Fotografías / Documentos *
{currentQuestion.max_photos && ( {currentQuestion.max_photos && (
<span className="ml-2 text-xs text-gray-600"> <span className="ml-2 text-xs text-gray-600">
(máximo {currentQuestion.max_photos} foto{currentQuestion.max_photos > 1 ? 's' : ''}) (máximo {currentQuestion.max_photos} archivo{currentQuestion.max_photos > 1 ? 's' : ''})
</span> </span>
)} )}
{(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && ( {(checklist.ai_mode === 'assisted' || checklist.ai_mode === 'full') && (
@@ -5023,7 +5023,7 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
<input <input
key={`photo-input-${currentQuestion.id}`} key={`photo-input-${currentQuestion.id}`}
type="file" type="file"
accept="image/*" accept="image/*,application/pdf"
multiple={currentQuestion.max_photos > 1} multiple={currentQuestion.max_photos > 1}
onChange={(e) => { onChange={(e) => {
handlePhotoChange(currentQuestion.id, e.target.files) handlePhotoChange(currentQuestion.id, e.target.files)
@@ -5038,16 +5038,23 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
{answers[currentQuestion.id]?.photos?.length > 0 && ( {answers[currentQuestion.id]?.photos?.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<div className="text-sm font-medium text-gray-700"> <div className="text-sm font-medium text-gray-700">
{answers[currentQuestion.id].photos.length} foto(s) cargada(s): {answers[currentQuestion.id].photos.length} archivo(s) cargado(s):
</div> </div>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{answers[currentQuestion.id].photos.map((photo, index) => ( {answers[currentQuestion.id].photos.map((photo, index) => (
<div key={index} className="relative group"> <div key={index} className="relative group">
{photo.type === 'application/pdf' ? (
<div className="w-full h-24 flex flex-col items-center justify-center bg-red-50 border-2 border-red-300 rounded-lg">
<span className="text-3xl">📝</span>
<span className="text-xs text-red-700 mt-1">PDF</span>
</div>
) : (
<img <img
src={URL.createObjectURL(photo)} src={URL.createObjectURL(photo)}
alt={`Foto ${index + 1}`} alt={`Foto ${index + 1}`}
className="w-full h-24 object-cover rounded-lg border border-gray-300" className="w-full h-24 object-cover rounded-lg border border-gray-300"
/> />
)}
<button <button
type="button" type="button"
onClick={() => handleRemovePhoto(currentQuestion.id, index)} onClick={() => handleRemovePhoto(currentQuestion.id, index)}
@@ -5057,7 +5064,7 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
</button> </button>
<div className="text-xs text-center text-gray-600 mt-1"> <div className="text-xs text-center text-gray-600 mt-1">
Foto {index + 1} {photo.type === 'application/pdf' ? photo.name : `Foto ${index + 1}`}
</div> </div>
</div> </div>
))} ))}

View File

@@ -140,7 +140,7 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
{sidebarOpen && ( {sidebarOpen && (
<div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30"> <div className="mb-3 px-2 py-1.5 bg-indigo-900/30 rounded-lg border border-indigo-700/30">
<p className="text-xs text-indigo-300 text-center font-medium"> <p className="text-xs text-indigo-300 text-center font-medium">
Ayutec v1.0.91 Ayutec v1.0.92
</p> </p>
</div> </div>
)} )}