Chat AI Assistant con Archivos Adjuntos Implementado

🎯 Nueva Funcionalidad Completa
Se ha implementado un sistema de chat conversacional con IA que permite adjuntar archivos (imágenes y PDFs), similar a ChatGPT, con prompt personalizable y envío completo al webhook.

📋 Características Implementadas
1. Adjuntar Archivos en el Chat
 Botón 📎 para adjuntar archivos
 Soporte para imágenes (JPG, PNG, etc.) y PDFs
 Preview de archivos adjuntos antes de enviar
 Eliminación individual de archivos adjuntos
 Múltiples archivos por mensaje
 Validación de tipos de archivo
2. Procesamiento Backend de Archivos
 Endpoint modificado para recibir FormData con archivos
 PDFs: Extracción automática de texto con pypdf
 Imágenes: Conversión a base64 para Vision AI
 Análisis combinado de texto + imágenes
 Límite de 2000 caracteres por PDF para optimizar
3. Integración con IA
 OpenAI Vision: Soporte multimodal (texto + imágenes)
 Gemini: Soporte de imágenes y texto
 Contexto enriquecido con archivos adjuntos
 Prompts adaptados según tipo de archivo
4. Custom Prompt por Checklist
 Campo assistant_prompt configurable por pregunta
 Campo assistant_instructions para instrucciones adicionales
 Control de longitud de respuesta (short/medium/long)
 Contexto automático del vehículo en cada mensaje
5. Persistencia del Chat
 Nuevo campo chat_history en modelo Answer
 Migración SQL: add_chat_history_to_answers.sql
 Guardado automático del historial completo
 Restauración del chat al reabrir
6. Envío al Webhook (n8n)
 Todos los chats incluidos en send_completed_inspection_to_n8n()
 Campo chat_history en cada respuesta del webhook
 Incluye metadata de archivos adjuntos
 Tipo de pregunta identificado en webhook
 Datos completos para análisis posterior
This commit is contained in:
2025-12-02 11:22:21 -03:00
parent bf30b1a2bf
commit c374909fa8
8 changed files with 240 additions and 57 deletions

View File

@@ -131,7 +131,8 @@ def send_completed_inspection_to_n8n(inspection, db):
"id": answer.question.id, "id": answer.question.id,
"texto": answer.question.text, "texto": answer.question.text,
"seccion": answer.question.section, "seccion": answer.question.section,
"orden": answer.question.order "orden": answer.question.order,
"tipo": answer.question.type
}, },
"respuesta": answer.answer_value, "respuesta": answer.answer_value,
"estado": answer.status, "estado": answer.status,
@@ -139,7 +140,8 @@ def send_completed_inspection_to_n8n(inspection, db):
"puntos_obtenidos": answer.points_earned, "puntos_obtenidos": answer.points_earned,
"es_critico": answer.is_flagged, "es_critico": answer.is_flagged,
"imagenes": imagenes, "imagenes": imagenes,
"ai_analysis": answer.ai_analysis "ai_analysis": answer.ai_analysis,
"chat_history": answer.chat_history # Incluir historial de chat si existe
}) })
# Preparar datos completos de la inspección # Preparar datos completos de la inspección
@@ -207,7 +209,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.90" BACKEND_VERSION = "1.0.91"
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
# S3/MinIO configuration # S3/MinIO configuration
@@ -2959,33 +2961,80 @@ Responde en formato JSON:
@app.post("/api/ai/chat-assistant") @app.post("/api/ai/chat-assistant")
async def chat_with_ai_assistant( async def chat_with_ai_assistant(
request: dict, question_id: int = Form(...),
inspection_id: int = Form(...),
user_message: str = Form(""),
chat_history: str = Form("[]"),
context_photos: str = Form("[]"),
vehicle_info: str = Form("{}"),
assistant_prompt: str = Form(""),
assistant_instructions: str = Form(""),
response_length: str = Form("medium"),
files: List[UploadFile] = File(default=[]),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user) current_user: models.User = Depends(get_current_user)
): ):
""" """
Chat conversacional con IA usando contexto de fotos anteriores Chat conversacional con IA usando contexto de fotos anteriores
El asistente tiene acceso a fotos de preguntas previas para dar mejor contexto El asistente tiene acceso a fotos de preguntas previas para dar mejor contexto
Ahora soporta archivos adjuntos (imágenes y PDFs)
""" """
print("\n" + "="*80) print("\n" + "="*80)
print("🤖 AI CHAT ASSISTANT") print("🤖 AI CHAT ASSISTANT")
print("="*80) print("="*80)
question_id = request.get('question_id') # Parsear JSON strings
inspection_id = request.get('inspection_id') import json
user_message = request.get('user_message') chat_history_list = json.loads(chat_history)
chat_history = request.get('chat_history', []) context_photos_list = json.loads(context_photos)
context_photos = request.get('context_photos', []) vehicle_info_dict = json.loads(vehicle_info)
assistant_prompt = request.get('assistant_prompt', '')
assistant_instructions = request.get('assistant_instructions', '')
response_length = request.get('response_length', 'medium')
vehicle_info = request.get('vehicle_info', {})
print(f"📋 Question ID: {question_id}") print(f"📋 Question ID: {question_id}")
print(f"🚗 Inspection ID: {inspection_id}") print(f"🚗 Inspection ID: {inspection_id}")
print(f"💬 User message: {user_message}") print(f"💬 User message: {user_message}")
print(f"📸 Context photos: {len(context_photos)} fotos") print(f"📎 Attached files: {len(files)}")
print(f"💭 Chat history: {len(chat_history)} mensajes previos") print(f"📸 Context photos: {len(context_photos_list)} fotos")
print(f"💭 Chat history: {len(chat_history_list)} mensajes previos")
# Procesar archivos adjuntos
attached_files_data = []
if files:
import base64
from pypdf import PdfReader
from io import BytesIO
for file in files:
file_content = await file.read()
file_type = file.content_type
file_info = {
'filename': file.filename,
'type': file_type,
'size': len(file_content)
}
# 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()
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)
# Si es imagen, convertir a base64
elif file_type.startswith('image/'):
file_info['content_type'] = 'image'
file_info['base64'] = base64.b64encode(file_content).decode('utf-8')
print(f"🖼️ Imagen procesada: {file.filename}")
attached_files_data.append(file_info)
# Obtener configuración de IA # Obtener configuración de IA
ai_config = db.query(models.AIConfiguration).filter( ai_config = db.query(models.AIConfiguration).filter(
@@ -3003,17 +3052,17 @@ async def chat_with_ai_assistant(
# Construir el contexto del vehículo # Construir el contexto del vehículo
vehicle_context = f""" vehicle_context = f"""
INFORMACIÓN DEL VEHÍCULO: INFORMACIÓN DEL VEHÍCULO:
- Marca: {vehicle_info.get('brand', 'N/A')} - Marca: {vehicle_info_dict.get('brand', 'N/A')}
- Modelo: {vehicle_info.get('model', 'N/A')} - Modelo: {vehicle_info_dict.get('model', 'N/A')}
- Placa: {vehicle_info.get('plate', 'N/A')} - Placa: {vehicle_info_dict.get('plate', 'N/A')}
- Kilometraje: {vehicle_info.get('km', 'N/A')} km - Kilometraje: {vehicle_info_dict.get('km', 'N/A')} km
""" """
# Construir el contexto de las fotos anteriores # Construir el contexto de las fotos anteriores
photos_context = "" photos_context = ""
if context_photos: if context_photos_list:
photos_context = f"\n\nFOTOS ANALIZADAS PREVIAMENTE ({len(context_photos)} imágenes):\n" photos_context = f"\n\nFOTOS ANALIZADAS PREVIAMENTE ({len(context_photos_list)} imágenes):\n"
for idx, photo in enumerate(context_photos[:10], 1): # Limitar a 10 fotos for idx, photo in enumerate(context_photos_list[:10], 1): # Limitar a 10 fotos
ai_analysis = photo.get('aiAnalysis', []) ai_analysis = photo.get('aiAnalysis', [])
if ai_analysis and len(ai_analysis) > 0: if ai_analysis and len(ai_analysis) > 0:
analysis_text = ai_analysis[0].get('analysis', {}) analysis_text = ai_analysis[0].get('analysis', {})
@@ -3029,6 +3078,18 @@ INFORMACIÓN DEL VEHÍCULO:
} }
max_tokens = max_tokens_map.get(response_length, 400) max_tokens = max_tokens_map.get(response_length, 400)
# Construir contexto de archivos adjuntos
attached_context = ""
if attached_files_data:
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"
if 'text' in file_info:
attached_context += f" Contenido: {file_info['text'][:500]}...\n"
elif file_info.get('content_type') == 'image':
attached_context += f"\n{idx}. Imagen: {file_info['filename']}\n"
# Construir el system prompt # Construir el system prompt
base_prompt = assistant_prompt or "Eres un experto mecánico automotriz que ayuda a diagnosticar problemas." base_prompt = assistant_prompt or "Eres un experto mecánico automotriz que ayuda a diagnosticar problemas."
@@ -3038,6 +3099,8 @@ INFORMACIÓN DEL VEHÍCULO:
{photos_context} {photos_context}
{attached_context}
INSTRUCCIONES ADICIONALES: INSTRUCCIONES ADICIONALES:
{assistant_instructions if assistant_instructions else "Sé técnico, claro y directo en tus respuestas."} {assistant_instructions if assistant_instructions else "Sé técnico, claro y directo en tus respuestas."}
@@ -3053,13 +3116,35 @@ FORMATO DE RESPUESTA:
messages = [{"role": "system", "content": system_prompt}] messages = [{"role": "system", "content": system_prompt}]
# Agregar historial previo (últimos 10 mensajes para no saturar) # Agregar historial previo (últimos 10 mensajes para no saturar)
for msg in chat_history[-10:]: for msg in chat_history_list[-10:]:
messages.append({ messages.append({
"role": msg.get('role'), "role": msg.get('role'),
"content": msg.get('content') "content": msg.get('content')
}) })
# Agregar el mensaje actual del usuario # Agregar el mensaje actual del usuario con imágenes si hay
has_images = any(f.get('content_type') == 'image' for f in attached_files_data)
if has_images:
# Formato multimodal para OpenAI/Gemini
user_content = []
if user_message:
user_content.append({"type": "text", "text": user_message})
# Agregar imágenes
for file_info in attached_files_data:
if file_info.get('content_type') == 'image':
user_content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{file_info['base64']}"}
})
messages.append({
"role": "user",
"content": user_content
})
else:
# Solo texto
messages.append({ messages.append({
"role": "user", "role": "user",
"content": user_message "content": user_message
@@ -3111,7 +3196,8 @@ FORMATO DE RESPUESTA:
"response": ai_response, "response": ai_response,
"confidence": confidence, "confidence": confidence,
"provider": ai_config.provider, "provider": ai_config.provider,
"model": ai_config.model_name "model": ai_config.model_name,
"attached_files": [{'filename': f['filename'], 'type': f['type']} for f in attached_files_data]
} }
except Exception as e: except Exception as e:

View File

@@ -157,6 +157,7 @@ class Answer(Base):
comment = Column(Text) # Comentarios adicionales comment = Column(Text) # Comentarios adicionales
ai_analysis = Column(JSON) # Análisis de IA si aplica ai_analysis = Column(JSON) # Análisis de IA si aplica
chat_history = Column(JSON) # Historial de chat con AI Assistant (para tipo ai_assistant)
is_flagged = Column(Boolean, default=False) # Si requiere atención is_flagged = Column(Boolean, default=False) # Si requiere atención
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -215,6 +215,7 @@ class AnswerCreate(AnswerBase):
inspection_id: int inspection_id: int
question_id: int question_id: int
ai_analysis: Optional[list] = None # Lista de análisis de IA (soporta múltiples imágenes) ai_analysis: Optional[list] = None # Lista de análisis de IA (soporta múltiples imágenes)
chat_history: Optional[list] = None # Historial de chat con AI Assistant
class AnswerUpdate(AnswerBase): class AnswerUpdate(AnswerBase):
pass pass
@@ -225,6 +226,7 @@ class Answer(AnswerBase):
question_id: int question_id: int
points_earned: int points_earned: int
ai_analysis: Optional[list] = None # Lista de análisis de IA ai_analysis: Optional[list] = None # Lista de análisis de IA
chat_history: Optional[list] = None # Historial de chat con AI Assistant
created_at: datetime created_at: datetime
class Config: class Config:

View File

@@ -1,7 +1,7 @@
{ {
"name": "checklist-frontend", "name": "checklist-frontend",
"private": true, "private": true,
"version": "1.0.92", "version": "1.0.93",
"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.92'; const CACHE_NAME = 'ayutec-v1.0.93';
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/index.html' '/index.html'

View File

@@ -4270,7 +4270,8 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
status: status, status: status,
comment: answer.observations || null, comment: answer.observations || null,
ai_analysis: answer.aiAnalysis || null, ai_analysis: answer.aiAnalysis || null,
is_flagged: status === 'critical' is_flagged: status === 'critical',
chat_history: answer.chatHistory || null // Agregar historial de chat
} }
const response = await fetch(`${API_URL}/api/answers`, { const response = await fetch(`${API_URL}/api/answers`, {
@@ -5265,7 +5266,9 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl
// Componente Modal de Chat IA Asistente // Componente Modal de Chat IA Asistente
function AIAssistantChatModal({ question, inspection, allAnswers, messages, setMessages, loading, setLoading, onClose }) { function AIAssistantChatModal({ question, inspection, allAnswers, messages, setMessages, loading, setLoading, onClose }) {
const [inputMessage, setInputMessage] = useState('') const [inputMessage, setInputMessage] = useState('')
const [attachedFiles, setAttachedFiles] = useState([])
const chatEndRef = useRef(null) const chatEndRef = useRef(null)
const fileInputRef = useRef(null)
const config = question.options || {} const config = question.options || {}
// Auto-scroll al final // Auto-scroll al final
@@ -5273,19 +5276,66 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages]) }, [messages])
// Manejar adjuntos de archivos
const handleFileAttach = (e) => {
const files = Array.from(e.target.files)
const validFiles = files.filter(file => {
const isImage = file.type.startsWith('image/')
const isPDF = file.type === 'application/pdf'
const isValid = isImage || isPDF
if (!isValid) {
alert(`⚠️ ${file.name}: Solo se permiten imágenes y PDFs`)
}
return isValid
})
setAttachedFiles(prev => [...prev, ...validFiles])
}
const removeAttachedFile = (index) => {
setAttachedFiles(prev => prev.filter((_, i) => i !== index))
}
// Enviar mensaje al asistente // Enviar mensaje al asistente
const sendMessage = async () => { const sendMessage = async () => {
if (!inputMessage.trim() || loading) return if ((!inputMessage.trim() && attachedFiles.length === 0) || loading) return
const userMessage = { role: 'user', content: inputMessage, timestamp: new Date().toISOString() } const userMessage = {
role: 'user',
content: inputMessage || '📎 Archivos adjuntos',
timestamp: new Date().toISOString(),
files: attachedFiles.map(f => ({ name: f.name, type: f.type, size: f.size }))
}
setMessages(prev => [...prev, userMessage]) setMessages(prev => [...prev, userMessage])
const currentFiles = attachedFiles
setInputMessage('') setInputMessage('')
setAttachedFiles([])
setLoading(true) setLoading(true)
try { try {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
const API_URL = import.meta.env.VITE_API_URL || '' const API_URL = import.meta.env.VITE_API_URL || ''
// Preparar FormData para enviar archivos
const formData = new FormData()
formData.append('question_id', question.id)
formData.append('inspection_id', inspection.id)
formData.append('user_message', inputMessage)
formData.append('chat_history', JSON.stringify(messages))
formData.append('assistant_prompt', config.assistant_prompt || '')
formData.append('assistant_instructions', config.assistant_instructions || '')
formData.append('response_length', config.response_length || 'medium')
// Adjuntar archivos
currentFiles.forEach((file, index) => {
formData.append('files', file)
})
// Adjuntar archivos
currentFiles.forEach((file, index) => {
formData.append('files', file)
})
// Recopilar fotos de preguntas anteriores según configuración // Recopilar fotos de preguntas anteriores según configuración
const contextPhotos = [] const contextPhotos = []
const contextQuestionIds = config.context_questions const contextQuestionIds = config.context_questions
@@ -5308,33 +5358,23 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
} }
}) })
// Preparar el payload formData.append('context_photos', JSON.stringify(contextPhotos))
const payload = { formData.append('vehicle_info', JSON.stringify({
question_id: question.id,
inspection_id: inspection.id,
user_message: inputMessage,
chat_history: messages,
context_photos: contextPhotos,
assistant_prompt: config.assistant_prompt || '',
assistant_instructions: config.assistant_instructions || '',
response_length: config.response_length || 'medium',
vehicle_info: {
brand: inspection.vehicle_brand, brand: inspection.vehicle_brand,
model: inspection.vehicle_model, model: inspection.vehicle_model,
plate: inspection.vehicle_plate, plate: inspection.vehicle_plate,
km: inspection.vehicle_km km: inspection.vehicle_km
} }))
}
console.log('📤 Enviando a chat IA:', payload) console.log('📤 Enviando a chat IA con archivos:', currentFiles.length)
const response = await fetch(`${API_URL}/api/ai/chat-assistant`, { const response = await fetch(`${API_URL}/api/ai/chat-assistant`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`
'Content-Type': 'application/json' // No incluir Content-Type, fetch lo establece automáticamente con FormData
}, },
body: JSON.stringify(payload) body: formData
}) })
if (!response.ok) { if (!response.ok) {
@@ -5430,6 +5470,18 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
<div className="text-sm sm:text-base whitespace-pre-wrap break-words"> <div className="text-sm sm:text-base whitespace-pre-wrap break-words">
{msg.content} {msg.content}
</div> </div>
{/* Mostrar archivos adjuntos si existen */}
{msg.files && msg.files.length > 0 && (
<div className="mt-2 space-y-1">
{msg.files.map((file, fIdx) => (
<div key={fIdx} className={`text-xs flex items-center gap-1 ${msg.role === 'user' ? 'text-blue-100' : 'text-gray-600'}`}>
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<span className="truncate">{file.name}</span>
<span>({(file.size / 1024).toFixed(1)} KB)</span>
</div>
))}
</div>
)}
<div <div
className={`text-xs mt-2 ${ className={`text-xs mt-2 ${
msg.role === 'user' ? 'text-blue-100' : 'text-gray-400' msg.role === 'user' ? 'text-blue-100' : 'text-gray-400'
@@ -5463,7 +5515,43 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
{/* Input */} {/* Input */}
<div className="border-t p-3 sm:p-4 bg-white rounded-b-xl"> <div className="border-t p-3 sm:p-4 bg-white rounded-b-xl">
{/* Preview de archivos adjuntos */}
{attachedFiles.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{attachedFiles.map((file, idx) => (
<div key={idx} className="flex items-center gap-2 bg-gray-100 px-3 py-2 rounded-lg text-sm">
<span>{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<span className="max-w-[150px] truncate">{file.name}</span>
<button
onClick={() => removeAttachedFile(idx)}
className="text-red-600 hover:text-red-800 font-bold"
type="button"
>
</button>
</div>
))}
</div>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<input
type="file"
ref={fileInputRef}
onChange={handleFileAttach}
accept="image/*,application/pdf"
multiple
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={loading}
className="px-3 py-2 sm:py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed"
title="Adjuntar archivos (imágenes o PDFs)"
type="button"
>
📎
</button>
<input <input
type="text" type="text"
value={inputMessage} value={inputMessage}
@@ -5475,7 +5563,7 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM
/> />
<button <button
onClick={sendMessage} onClick={sendMessage}
disabled={!inputMessage.trim() || loading} disabled={(!inputMessage.trim() && attachedFiles.length === 0) || loading}
className="px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition text-sm sm:text-base font-semibold" className="px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white rounded-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition text-sm sm:text-base font-semibold"
> >
Enviar Enviar

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.92 Ayutec v1.0.93
</p> </p>
</div> </div>
)} )}

View File

@@ -0,0 +1,6 @@
-- Agregar campo chat_history a la tabla answers
-- Fecha: 2025-12-02
ALTER TABLE answers ADD COLUMN IF NOT EXISTS chat_history JSON;
COMMENT ON COLUMN answers.chat_history IS 'Historial de conversación con AI Assistant para preguntas tipo ai_assistant';