✅ 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:
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user