From 59a0f56b99b108d9fee4b2d06f384838067c2288 Mon Sep 17 00:00:00 2001 From: ronalds Date: Thu, 4 Dec 2025 10:56:00 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20IMPLEMENTACI=C3=93N=20COMPLETADA=20?= =?UTF-8?q?-=20Informes=20Personalizados=20para=20Chat=20Assistant=20?= =?UTF-8?q?=F0=9F=93=8A=20Backend=20actualizado=20a=20v1.1.0=20He=20implem?= =?UTF-8?q?entado=20un=20sistema=20inteligente=20de=20generaci=C3=B3n=20de?= =?UTF-8?q?=20informes=20para=20preguntas=20con=20chat=20assistant:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔧 Cambios Implementados: 1. Nueva función generate_chat_summary() (líneas ~1450) Funcionalidad: Recibe el chat_history completo de una conversación Usa OpenAI o Gemini (según configuración activa) para analizar la conversación Genera un resumen estructurado en JSON con: problema_identificado: Descripción del problema principal hallazgos: Lista de observaciones técnicas diagnostico: Conclusión del diagnóstico recomendaciones: Pasos sugeridos Características: Temperature: 0.3 (respuestas consistentes) Max tokens: 800 Response format: JSON Manejo robusto de errores con fallback --- backend/app/main.py | 268 +++++++++++++++++++++++++++++++++++++------ frontend/src/App.jsx | 5 - 2 files changed, 232 insertions(+), 41 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index f9440f6..06615ff 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -276,7 +276,7 @@ def extract_pdf_text_smart(pdf_content: bytes, max_chars: int = None) -> dict: } -BACKEND_VERSION = "1.0.98" +BACKEND_VERSION = "1.1.0" app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) # S3/MinIO configuration @@ -1450,6 +1450,118 @@ def update_inspection( return db_inspection +async def generate_chat_summary(chat_history: list, question_text: str) -> dict: + """ + Genera un resumen estructurado de una conversación de chat con el asistente IA. + Retorna un dict con: problema_identificado, hallazgos, diagnostico, recomendaciones + """ + import asyncio + import json + import openai + import google.generativeai as genai + + if not chat_history or len(chat_history) == 0: + return { + "problema_identificado": "Sin conversación registrada", + "hallazgos": [], + "diagnostico": "N/A", + "recomendaciones": [] + } + + # Obtener configuración de IA + db = next(get_db()) + config = db.query(models.AIConfiguration).filter(models.AIConfiguration.is_active == True).first() + + if not config: + # Fallback: devolver resumen simple + return { + "problema_identificado": f"Consulta sobre: {question_text}", + "hallazgos": ["Conversación completada con el asistente"], + "diagnostico": "Ver conversación completa en el sistema", + "recomendaciones": ["Revisar historial de chat para detalles"] + } + + # Construir contexto de la conversación + conversation_text = "" + for msg in chat_history: + role = "Mecánico" if msg.get("role") == "user" else "Asistente" + content = msg.get("content", "") + conversation_text += f"{role}: {content}\n\n" + + # Prompt para generar resumen estructurado + summary_prompt = f"""Analiza la siguiente conversación entre un mecánico y un asistente de diagnóstico automotriz, y genera un resumen ejecutivo estructurado para incluir en un informe PDF. + +CONVERSACIÓN: +{conversation_text} + +INSTRUCCIONES: +Genera un resumen profesional en formato JSON con esta estructura exacta: +{{ + "problema_identificado": "Descripción breve del problema o consulta principal (máximo 2 líneas)", + "hallazgos": ["Hallazgo 1", "Hallazgo 2", "Hallazgo 3"], + "diagnostico": "Conclusión técnica del diagnóstico (máximo 3 líneas)", + "recomendaciones": ["Recomendación 1", "Recomendación 2"] +}} + +REGLAS: +- Usa lenguaje técnico pero claro +- Sé conciso y directo +- Si no hay información suficiente para algún campo, usa "N/A" o lista vacía [] +- NO incluyas información que no esté en la conversación +- El JSON debe ser válido y parseable +""" + + try: + # Usar OpenAI o Gemini según configuración + if config.provider == "openai" and config.openai_api_key: + client = openai.OpenAI(api_key=config.openai_api_key) + response = await asyncio.to_thread( + client.chat.completions.create, + model=config.openai_model or "gpt-4o", + messages=[{"role": "user", "content": summary_prompt}], + temperature=0.3, + max_tokens=800, + response_format={"type": "json_object"} + ) + summary_json = response.choices[0].message.content + + elif config.provider == "gemini" and config.gemini_api_key: + genai.configure(api_key=config.gemini_api_key) + model = genai.GenerativeModel( + model_name=config.gemini_model or "gemini-2.0-flash-exp", + generation_config={ + "temperature": 0.3, + "max_output_tokens": 800, + "response_mime_type": "application/json" + } + ) + response = await asyncio.to_thread(model.generate_content, summary_prompt) + summary_json = response.text + else: + raise Exception("No hay proveedor de IA configurado") + + # Parsear JSON + summary = json.loads(summary_json) + + # Validar estructura + required_keys = ["problema_identificado", "hallazgos", "diagnostico", "recomendaciones"] + for key in required_keys: + if key not in summary: + summary[key] = "N/A" if key in ["problema_identificado", "diagnostico"] else [] + + return summary + + except Exception as e: + print(f"❌ Error generando resumen de chat: {e}") + # Fallback + return { + "problema_identificado": f"Consulta sobre: {question_text}", + "hallazgos": ["Error al generar resumen automático"], + "diagnostico": "Ver conversación completa en el sistema", + "recomendaciones": ["Revisar historial de chat para detalles completos"] + } + + def generate_inspection_pdf(inspection_id: int, db: Session) -> str: """ Genera el PDF de una inspección y lo sube a S3. @@ -1896,6 +2008,9 @@ def generate_inspection_pdf(inspection_id: int, db: Session) -> str: elements.append(Paragraph(f"▶ {question.section or 'General'}", section_header_style)) elements.append(Spacer(1, 3*mm)) + # Detectar si es pregunta con chat assistant + is_ai_assistant = question.options and question.options.get('type') == 'ai_assistant' + # Estado visual status_colors = { 'ok': colors.HexColor('#22c55e'), @@ -1918,38 +2033,108 @@ def generate_inspection_pdf(inspection_id: int, db: Session) -> str: Paragraph(f"{status_icon} {question.text}", question_style), ]) - # Fila 2: Respuesta y estado - Convertir valor técnico a etiqueta legible - answer_text = get_readable_answer(ans.answer_value, question.options) - question_data.append([ - Table([ - [ - Paragraph(f"Respuesta: {answer_text}", answer_style), - Paragraph(f"Estado: {ans.status.upper()}", ParagraphStyle('status', parent=answer_style, textColor=status_color, fontName='Helvetica-Bold')) - ] - ], colWidths=[120*mm, 50*mm]) - ]) + # ===== LÓGICA ESPECIAL PARA AI_ASSISTANT ===== + if is_ai_assistant and ans.chat_history: + # Generar resumen estructurado del chat + import asyncio + try: + # Ejecutar función async de forma sincrónica + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + chat_summary = loop.run_until_complete( + generate_chat_summary(ans.chat_history, question.text) + ) + loop.close() + + # Renderizar informe narrativo + question_data.append([ + Paragraph(f"💬 INFORME DE DIAGNÓSTICO ASISTIDO", + ParagraphStyle('chat_title', parent=answer_style, fontSize=11, + textColor=colors.HexColor('#2563eb'), fontName='Helvetica-Bold')) + ]) + + # Problema identificado + question_data.append([ + Paragraph(f"🔍 Problema Identificado:
{chat_summary.get('problema_identificado', 'N/A')}", + comment_style) + ]) + + # Hallazgos + if chat_summary.get('hallazgos') and len(chat_summary['hallazgos']) > 0: + hallazgos_text = "
".join([f"• {h}" for h in chat_summary['hallazgos']]) + question_data.append([ + Paragraph(f"📋 Hallazgos:
{hallazgos_text}", comment_style) + ]) + + # Diagnóstico + question_data.append([ + Paragraph(f"🔧 Diagnóstico:
{chat_summary.get('diagnostico', 'N/A')}", + comment_style) + ]) + + # Recomendaciones + if chat_summary.get('recomendaciones') and len(chat_summary['recomendaciones']) > 0: + recomendaciones_text = "
".join([f"• {r}" for r in chat_summary['recomendaciones']]) + question_data.append([ + Paragraph(f"✅ Recomendaciones:
{recomendaciones_text}", + ParagraphStyle('recommendations', parent=comment_style, + textColor=colors.HexColor('#16a34a'))) + ]) + + except Exception as e: + print(f"❌ Error generando resumen de chat en PDF: {e}") + # Fallback: mostrar que hubo conversación + question_data.append([ + Table([ + [ + Paragraph(f"Respuesta: Diagnóstico asistido completado", answer_style), + Paragraph(f"Estado: {ans.status.upper()}", + ParagraphStyle('status', parent=answer_style, + textColor=status_color, fontName='Helvetica-Bold')) + ] + ], colWidths=[120*mm, 50*mm]) + ]) + question_data.append([ + Paragraph(f"ℹ️ Nota: Ver historial de conversación completo en el sistema", + comment_style) + ]) - # Fila 3: Comentario mejorado (si existe) - if ans.comment: - comment_text = ans.comment - - # Limpiar prefijo de análisis automático/IA si existe (con cualquier porcentaje) - import re - # Patrón para detectar "Análisis Automático (XX% confianza): " o "Análisis IA (XX% confianza): " - comment_text = re.sub(r'^(Análisis Automático|Análisis IA)\s*\(\d+%\s*confianza\):\s*', '', comment_text) - # También remover variantes sin emoji - comment_text = re.sub(r'^🤖\s*(Análisis Automático|Análisis IA)\s*\(\d+%\s*confianza\):\s*', '', comment_text) - - # Separar análisis y recomendaciones con salto de línea - if "Recomendaciones:" in comment_text or "Recomendación:" in comment_text: - comment_text = comment_text.replace("Recomendaciones:", "

Recomendaciones:") - comment_text = comment_text.replace("Recomendación:", "

Recomendación:") - + # ===== LÓGICA NORMAL PARA OTROS TIPOS ===== + else: + # Fila 2: Respuesta y estado - Convertir valor técnico a etiqueta legible + answer_text = get_readable_answer(ans.answer_value, question.options) question_data.append([ - Paragraph(f"Comentario: {comment_text}", comment_style) + Table([ + [ + Paragraph(f"Respuesta: {answer_text}", answer_style), + Paragraph(f"Estado: {ans.status.upper()}", + ParagraphStyle('status', parent=answer_style, + textColor=status_color, fontName='Helvetica-Bold')) + ] + ], colWidths=[120*mm, 50*mm]) ]) + + # Fila 3: Comentario mejorado (si existe) + if ans.comment: + comment_text = ans.comment + + # Limpiar prefijo de análisis automático/IA si existe (con cualquier porcentaje) + import re + # Patrón para detectar "Análisis Automático (XX% confianza): " o "Análisis IA (XX% confianza): " + comment_text = re.sub(r'^(Análisis Automático|Análisis IA)\s*\(\d+%\s*confianza\):\s*', '', comment_text) + # También remover variantes sin emoji + comment_text = re.sub(r'^🤖\s*(Análisis Automático|Análisis IA)\s*\(\d+%\s*confianza\):\s*', '', comment_text) + + # Separar análisis y recomendaciones con salto de línea + if "Recomendaciones:" in comment_text or "Recomendación:" in comment_text: + comment_text = comment_text.replace("Recomendaciones:", "

Recomendaciones:") + comment_text = comment_text.replace("Recomendación:", "

Recomendación:") + + question_data.append([ + Paragraph(f"Comentario: {comment_text}", comment_style) + ]) - # Fila 4: Imágenes (si existen) + # Fila 4: Imágenes (si existen) - COMÚN PARA TODOS LOS TIPOS if ans.media_files: media_imgs = [] for media in ans.media_files: @@ -3236,7 +3421,17 @@ INFORMACIÓN DEL VEHÍCULO: # 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} + system_prompt = f"""INSTRUCCIONES CRÍTICAS ANTI-ALUCINACIÓN (MÁXIMA PRIORIDAD): +Estas reglas SIEMPRE tienen prioridad sobre cualquier otra instrucción: + +1. PRIMERO mira la imagen/documento y DESCRIBE LITERALMENTE lo que ves +2. VERIFICA si hay texto/logos/marcas visibles (ej: "TEXA", "Bosch", "ESI[tronic]") +3. Si la imagen muestra un analizador de gases (con mediciones CO, CO₂, HC, NOₓ, O₂, Lambda), NO ES un informe de códigos DTC +4. Si la imagen muestra una pantalla con códigos tipo "P0XXX" o "1XXXX", SÍ ES un informe de diagnóstico DTC +5. NUNCA inventes información que no esté visible en la imagen +6. Si lo que ves NO coincide con lo que el usuario pregunta, DÍSELO INMEDIATAMENTE + +{base_prompt} {vehicle_context} @@ -3247,12 +3442,13 @@ INFORMACIÓN DEL VEHÍCULO: 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 +FORMATO DE RESPUESTA OBLIGATORIO: +1. [IDENTIFICACIÓN] Qué tipo de documento/imagen es esto (describe lo que VES, no lo que asumes) +2. [VERIFICACIÓN] ¿Coincide con lo que el usuario pregunta? Si NO, indícalo +3. [ANÁLISIS] Basado ÚNICAMENTE en información visible +4. [RECOMENDACIÓN] Pasos siguientes o información que necesitas + +Longitud: {response_length} """ # Construir el historial de mensajes para la IA diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 00af35d..a06b595 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5521,11 +5521,6 @@ function AIAssistantChatModal({ question, inspection, allAnswers, messages, setM formData.append('files', file) }) - // Adjuntar archivos - currentFiles.forEach((file, index) => { - formData.append('files', file) - }) - // Recopilar fotos de preguntas anteriores según configuración const contextPhotos = [] const contextQuestionIds = config.context_questions