diff --git a/backend/app/main.py b/backend/app/main.py index f80731b..65b0c2d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -204,7 +204,7 @@ def send_completed_inspection_to_n8n(inspection, db): # No lanzamos excepción para no interrumpir el flujo normal -BACKEND_VERSION = "1.0.84" +BACKEND_VERSION = "1.0.85" app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) # S3/MinIO configuration @@ -2849,6 +2849,175 @@ Responde en formato JSON: } +@app.post("/api/ai/chat-assistant") +async def chat_with_ai_assistant( + request: dict, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) +): + """ + Chat conversacional con IA usando contexto de fotos anteriores + El asistente tiene acceso a fotos de preguntas previas para dar mejor contexto + """ + print("\n" + "="*80) + print("🤖 AI CHAT ASSISTANT") + print("="*80) + + question_id = request.get('question_id') + inspection_id = request.get('inspection_id') + user_message = request.get('user_message') + chat_history = request.get('chat_history', []) + context_photos = request.get('context_photos', []) + 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"🚗 Inspection ID: {inspection_id}") + print(f"💬 User message: {user_message}") + print(f"📸 Context photos: {len(context_photos)} fotos") + print(f"💭 Chat history: {len(chat_history)} mensajes previos") + + # Obtener configuración de IA + ai_config = db.query(models.AIConfiguration).filter( + models.AIConfiguration.is_active == True + ).first() + + if not ai_config: + return { + "success": False, + "response": "No hay configuración de IA activa. Por favor configura en Settings.", + "confidence": 0 + } + + try: + # Construir el contexto del vehículo + vehicle_context = f""" +INFORMACIÓN DEL VEHÍCULO: +- Marca: {vehicle_info.get('brand', 'N/A')} +- Modelo: {vehicle_info.get('model', 'N/A')} +- Placa: {vehicle_info.get('plate', 'N/A')} +- Kilometraje: {vehicle_info.get('km', 'N/A')} km +""" + + # Construir el contexto de las fotos anteriores + photos_context = "" + if context_photos: + photos_context = f"\n\nFOTOS ANALIZADAS PREVIAMENTE ({len(context_photos)} imágenes):\n" + for idx, photo in enumerate(context_photos[:10], 1): # Limitar a 10 fotos + ai_analysis = photo.get('aiAnalysis', []) + if ai_analysis and len(ai_analysis) > 0: + analysis_text = ai_analysis[0].get('analysis', {}) + obs = analysis_text.get('observations', 'Sin análisis') + status = analysis_text.get('status', 'unknown') + photos_context += f"\n{idx}. Pregunta ID {photo.get('questionId')}: Status={status}\n Observaciones: {obs[:200]}...\n" + + # Definir la longitud de respuesta + max_tokens_map = { + 'short': 200, + 'medium': 400, + 'long': 800 + } + max_tokens = max_tokens_map.get(response_length, 400) + + # Construir el system prompt + base_prompt = assistant_prompt or "Eres un experto mecánico automotriz que ayuda a diagnosticar problemas." + + system_prompt = f"""{base_prompt} + +{vehicle_context} + +{photos_context} + +INSTRUCCIONES ADICIONALES: +{assistant_instructions if assistant_instructions else "Sé técnico, claro y directo en tus respuestas."} + +FORMATO DE RESPUESTA: +- Sé {response_length} en tus respuestas +- Usa lenguaje técnico pero comprensible +- Si ves algo preocupante en las fotos analizadas, menciónalo +- Proporciona recomendaciones específicas cuando sea relevante +- Si no tienes suficiente información, pide más detalles +""" + + # Construir el historial de mensajes para la IA + messages = [{"role": "system", "content": system_prompt}] + + # Agregar historial previo (últimos 10 mensajes para no saturar) + for msg in chat_history[-10:]: + messages.append({ + "role": msg.get('role'), + "content": msg.get('content') + }) + + # Agregar el mensaje actual del usuario + messages.append({ + "role": "user", + "content": user_message + }) + + print(f"🔧 Enviando a {ai_config.provider} con {len(messages)} mensajes") + + # Llamar a la IA según el proveedor + if ai_config.provider == 'openai': + from openai import OpenAI + client = OpenAI(api_key=ai_config.api_key) + + response = client.chat.completions.create( + model=ai_config.model or "gpt-4", + messages=messages, + max_tokens=max_tokens, + temperature=0.7 + ) + + ai_response = response.choices[0].message.content + confidence = 0.85 # OpenAI no devuelve confidence directo + + elif ai_config.provider == 'gemini': + import google.generativeai as genai + genai.configure(api_key=ai_config.api_key) + + model = genai.GenerativeModel(ai_config.model or 'gemini-pro') + + # Gemini maneja el chat diferente + # Convertir mensajes al formato de Gemini + chat_content = "" + for msg in messages[1:]: # Skip system message + role_label = "Usuario" if msg['role'] == 'user' else "Asistente" + chat_content += f"\n{role_label}: {msg['content']}\n" + + full_prompt = f"{system_prompt}\n\nCONVERSACIÓN:\n{chat_content}\n\nAsistente:" + + response = model.generate_content(full_prompt) + ai_response = response.text + confidence = 0.80 + + else: + raise ValueError(f"Proveedor no soportado: {ai_config.provider}") + + print(f"✅ Respuesta generada: {len(ai_response)} caracteres") + + return { + "success": True, + "response": ai_response, + "confidence": confidence, + "provider": ai_config.provider, + "model": ai_config.model + } + + except Exception as e: + print(f"❌ Error en chat IA: {e}") + import traceback + traceback.print_exc() + + return { + "success": False, + "response": f"Error al comunicarse con el asistente: {str(e)}", + "confidence": 0 + } + + # ============= REPORTS ============= @app.get("/api/reports/dashboard", response_model=schemas.DashboardData) def get_dashboard_data( diff --git a/frontend/package.json b/frontend/package.json index d562a47..ab5d65b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "checklist-frontend", "private": true, - "version": "1.0.87", + "version": "1.0.88", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js index f1653f2..5c17b67 100644 --- a/frontend/public/service-worker.js +++ b/frontend/public/service-worker.js @@ -1,6 +1,6 @@ // Service Worker para PWA con detección de actualizaciones // IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión -const CACHE_NAME = 'ayutec-v1.0.87'; +const CACHE_NAME = 'ayutec-v1.0.88'; const urlsToCache = [ '/', '/index.html' diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8878e81..13fb162 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4054,6 +4054,11 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0) const [aiAnalyzing, setAiAnalyzing] = useState(false) + // AI Assistant Chat + const [showAIChat, setShowAIChat] = useState(false) + const [aiChatMessages, setAiChatMessages] = useState([]) + const [aiChatLoading, setAiChatLoading] = useState(false) + // Signature canvas const mechanicSigRef = useRef(null) @@ -5031,6 +5036,29 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl )} )} + + {/* Botón para abrir chat IA (si es tipo ai_assistant) */} + {currentQuestion.options?.type === 'ai_assistant' && ( + + )} )} @@ -5171,6 +5199,262 @@ function InspectionModal({ checklist, existingInspection, user, onClose, onCompl )} + + {/* Modal de Chat IA Asistente */} + {showAIChat && currentQuestion && ( + { + setShowAIChat(false) + // Guardar historial del chat en la respuesta + setAnswers(prev => ({ + ...prev, + [currentQuestion.id]: { + ...(prev[currentQuestion.id] || {}), + chatHistory: aiChatMessages, + value: 'chat_completed' // Marcar como respondida + } + })) + saveAnswer(currentQuestion.id) + }} + /> + )} + + ) +} + +// Componente Modal de Chat IA Asistente +function AIAssistantChatModal({ question, inspection, allAnswers, messages, setMessages, loading, setLoading, onClose }) { + const [inputMessage, setInputMessage] = useState('') + const chatEndRef = useRef(null) + const config = question.options || {} + + // Auto-scroll al final + useEffect(() => { + chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + // Enviar mensaje al asistente + const sendMessage = async () => { + if (!inputMessage.trim() || loading) return + + const userMessage = { role: 'user', content: inputMessage, timestamp: new Date().toISOString() } + setMessages(prev => [...prev, userMessage]) + setInputMessage('') + setLoading(true) + + try { + const token = localStorage.getItem('token') + const API_URL = import.meta.env.VITE_API_URL || '' + + // Recopilar fotos de preguntas anteriores según configuración + const contextPhotos = [] + const contextQuestionIds = config.context_questions + ? config.context_questions.split(',').map(id => parseInt(id.trim())) + : Object.keys(allAnswers).map(id => parseInt(id)) + + // Filtrar solo preguntas anteriores a la actual + const previousQuestionIds = contextQuestionIds.filter(id => id < question.id) + + previousQuestionIds.forEach(qId => { + const answer = allAnswers[qId] + if (answer?.photos && answer.photos.length > 0) { + answer.photos.forEach(photoUrl => { + contextPhotos.push({ + questionId: qId, + url: photoUrl, + aiAnalysis: answer.aiAnalysis + }) + }) + } + }) + + // Preparar el payload + const payload = { + 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, + model: inspection.vehicle_model, + plate: inspection.vehicle_plate, + km: inspection.vehicle_km + } + } + + console.log('📤 Enviando a chat IA:', payload) + + const response = await fetch(`${API_URL}/api/ai/chat-assistant`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + throw new Error('Error en respuesta del servidor') + } + + const data = await response.json() + console.log('📥 Respuesta de IA:', data) + + const assistantMessage = { + role: 'assistant', + content: data.response, + timestamp: new Date().toISOString(), + confidence: data.confidence + } + + setMessages(prev => [...prev, assistantMessage]) + + } catch (error) { + console.error('Error al enviar mensaje:', error) + const errorMessage = { + role: 'assistant', + content: '❌ Error al comunicarse con el asistente. Por favor intenta nuevamente.', + timestamp: new Date().toISOString(), + isError: true + } + setMessages(prev => [...prev, errorMessage]) + } finally { + setLoading(false) + } + } + + return ( +
+
+ {/* Header */} +
+
+
+
+ 💬 +

Asistente IA

+
+

+ {question.text} +

+
+ + {Object.values(allAnswers).filter(a => a.photos?.length > 0).length} preguntas con fotos + + + {messages.length} mensajes + +
+
+ +
+
+ + {/* Chat Messages */} +
+ {messages.length === 0 && ( +
+
🤖
+

+ ¡Hola! Soy tu asistente técnico. +

+

+ He analizado las fotos anteriores. ¿En qué puedo ayudarte? +

+
+ )} + + {messages.map((msg, idx) => ( +
+
+
+ {msg.content} +
+
+ {new Date(msg.timestamp).toLocaleTimeString('es-ES', { + hour: '2-digit', + minute: '2-digit' + })} + {msg.confidence && ( + • Confianza: {Math.round(msg.confidence * 100)}% + )} +
+
+
+ ))} + + {loading && ( +
+
+
+
+ El asistente está pensando... +
+
+
+ )} + +
+
+ + {/* Input */} +
+
+ setInputMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && !e.shiftKey && sendMessage()} + placeholder="Escribe tu pregunta..." + disabled={loading} + className="flex-1 px-3 sm:px-4 py-2 sm:py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 text-sm sm:text-base disabled:bg-gray-100" + /> + +
+ {config.max_messages && messages.length >= config.max_messages && ( +

+ ⚠️ Has alcanzado el límite de {config.max_messages} mensajes +

+ )} +
+
) } diff --git a/frontend/src/QuestionAnswerInput.jsx b/frontend/src/QuestionAnswerInput.jsx index 36fdce0..0b65dab 100644 --- a/frontend/src/QuestionAnswerInput.jsx +++ b/frontend/src/QuestionAnswerInput.jsx @@ -292,6 +292,25 @@ export function QuestionAnswerInput({ question, value, onChange, onSave }) { ) } + // AI_ASSISTANT (Chat con IA) + if (questionType === 'ai_assistant') { + return ( +
+
+ 💬 +

Asistente IA Disponible

+
+

+ Haz clic en el botón "💬 Consultar Asistente" debajo para abrir el chat con IA. + El asistente ha analizado las fotos anteriores y está listo para ayudarte. +

+
+ ℹ️ No requiere respuesta manual - el chat se guarda automáticamente +
+
+ ) + } + // Fallback para tipos desconocidos return (
diff --git a/frontend/src/QuestionTypeEditor.jsx b/frontend/src/QuestionTypeEditor.jsx index f9ae8a2..a4370ad 100644 --- a/frontend/src/QuestionTypeEditor.jsx +++ b/frontend/src/QuestionTypeEditor.jsx @@ -23,7 +23,8 @@ const QUESTION_TYPES = [ { value: 'number', label: '🔢 Número', icon: '#️⃣' }, { value: 'date', label: '📅 Fecha', icon: '📆' }, { value: 'time', label: '🕐 Hora', icon: '⏰' }, - { value: 'photo_only', label: '📸 Solo Fotografía', icon: '📷' } + { value: 'photo_only', label: '📸 Solo Fotografía', icon: '📷' }, + { value: 'ai_assistant', label: '🤖 Asistente IA (Chat)', icon: '💬' } ] const STATUS_OPTIONS = [ @@ -512,6 +513,121 @@ export function QuestionTypeEditor({ value, onChange, maxPoints = 1 }) {
)} + + {/* AI ASSISTANT (CHAT) */} + {config.type === 'ai_assistant' && ( +
+
+
+ 💬 +
+

Asistente IA Conversacional

+

+ El mecánico podrá chatear con IA usando fotos de preguntas anteriores como contexto. + Configura qué preguntas anteriores usar y el comportamiento del asistente. +

+
+
+
+ + {/* Prompt del asistente */} +
+ +