Spaces:
Running
Running
| # ============================================================================== | |
| # 0. PARCHE DE SISTEMA (Requerido para HF Spaces / ChromaDB) | |
| # ============================================================================== | |
| import sys | |
| try: | |
| __import__('pysqlite3') | |
| sys.modules['sqlite3'] = sys.modules.pop('pysqlite3') | |
| except ImportError: | |
| pass | |
| # ============================================================================== | |
| # 1. LIBRERÍAS | |
| # ============================================================================== | |
| import streamlit as st | |
| import os | |
| import time | |
| import csv | |
| import math | |
| import datetime | |
| import tempfile | |
| import torch | |
| import torch.nn as nn | |
| import torch.nn.functional as F | |
| from torchvision import transforms | |
| from torchvision.models import efficientnet_b4 | |
| from PIL import Image | |
| import numpy as np | |
| import matplotlib.pyplot as plt | |
| import cv2 | |
| from crewai import Agent, Task, Crew, Process, LLM | |
| from RAG_tool import BuscadorGuiasClinicas | |
| from fpdf import FPDF | |
| # LIBRERÍAS RAGAS (Usando Clases Base) | |
| from datasets import Dataset | |
| from ragas import evaluate | |
| from ragas.metrics import Faithfulness, AnswerRelevancy | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| from langchain_openai import ChatOpenAI | |
| # ============================================================================== | |
| # 2. CONFIGURACIÓN VISUAL Y DE PRIVACIDAD | |
| # ============================================================================== | |
| st.set_page_config( | |
| page_title="DermaRAG - Diagnóstico", | |
| page_icon="🏥", | |
| layout="wide", | |
| initial_sidebar_state="collapsed" | |
| ) | |
| # Inicialización de variables de estado para privacidad | |
| if "privacy_ack" not in st.session_state: | |
| st.session_state["privacy_ack"] = False | |
| if "show_privacy_dialog" not in st.session_state: | |
| st.session_state["show_privacy_dialog"] = True | |
| if "consent_data_health" not in st.session_state: | |
| st.session_state["consent_data_health"] = False | |
| if "consent_ai_support" not in st.session_state: | |
| st.session_state["consent_ai_support"] = False | |
| if "consent_images" not in st.session_state: | |
| st.session_state["consent_images"] = False | |
| # ============================================================================== | |
| # 3. INYECCIÓN DE CSS | |
| # ============================================================================== | |
| st.markdown(""" | |
| <style> | |
| .block-container { padding-top: 3rem; padding-bottom: 5rem; padding-left: 5rem; padding-right: 5rem; max-width: 80% !important; } | |
| .stApp { background-color: #f4f6f9; color: #333333; } | |
| .header-container { background: linear-gradient(135deg, #003366 0%, #004080 100%); padding: 30px; border-radius: 12px; color: white; text-align: center; margin-bottom: 30px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); } | |
| .stApp .header-container h1, .stApp .header-container p, .stMarkdown .header-container p, .stMarkdown .header-container h1 { color: white !important; border-bottom: none !important; } | |
| div[data-testid="stVerticalBlockBorderWrapper"] { background-color: #ffffff !important; border-radius: 12px !important; padding: 20px !important; border: 1px solid #e0e0e0 !important; box-shadow: 0 4px 10px rgba(0,0,0,0.05) !important; } | |
| h1, h2, h3, h4, h5 { color: #003366 !important; } | |
| h2 { border-bottom: 2px solid #667eea; padding-bottom: 8px; margin-bottom: 20px !important; } | |
| .stTextInput input, .stTextArea textarea, .stSelectbox div[data-baseweb="select"], .stNumberInput div[data-baseweb="input"] { background-color: #ffffff !important; color: #333333 !important; border: 1px solid #cccccc !important; } | |
| .stNumberInput input, [data-testid="stFileUploaderDropzone"] section, [data-testid="stFileUploaderDropzone"] div, [data-testid="stFileUploaderDropzone"] span { color: #333333 !important; } | |
| .stNumberInput button { background-color: #f0f2f6 !important; color: #333333 !important; } | |
| [data-testid="stFileUploaderDropzone"] { background-color: #f8f9fa !important; border: 2px dashed #667eea !important; } | |
| [data-testid="stFileUploaderDropzone"] button { background-color: #ffffff !important; color: #003366 !important; border: 1px solid #003366 !important; } | |
| .stCheckbox label p, .stCheckbox label span, label p, label span, .stMarkdown p:not(.header-container p) { color: #333333 !important; font-weight: 500 !important; } | |
| div.stButton > button { background: linear-gradient(135deg, #28a745 0%, #20c997 100%) !important; color: white !important; border: none !important; padding: 15px 30px !important; font-size: 18px !important; font-weight: bold !important; border-radius: 8px !important; width: 100% !important; box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3) !important; } | |
| div.stButton > button:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4) !important; } | |
| div.stButton > button p { color: white !important; } | |
| .privacy-banner { background: linear-gradient(135deg, #fff7e6 0%, #fff3cd 100%); border: 1px solid #f0c36d; border-left: 8px solid #d97706; border-radius: 12px; padding: 18px 22px; margin-bottom: 20px; color: #5b3b00; } | |
| .privacy-banner h3, .privacy-banner p, .privacy-banner li { color: #5b3b00 !important; } | |
| .privacy-note-box { background: #f8fbff; border: 1px solid #cfe2ff; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; } | |
| .privacy-note-box strong, .privacy-note-box p, .privacy-note-box li { color: #0b3a66 !important; } | |
| .medical-warning { background: #fff1f2; border: 1px solid #fecdd3; border-left: 6px solid #e11d48; border-radius: 10px; padding: 14px 16px; margin-top: 10px; color: #881337; font-size: 14px; } | |
| .medical-warning strong, .medical-warning p { color: #881337 !important; } | |
| /* RESPONSIVE MÓVIL */ | |
| @media (max-width: 768px) { | |
| .block-container { | |
| padding-left: 1rem !important; | |
| padding-right: 1rem !important; | |
| max-width: 100% !important; | |
| } | |
| .header-container h1 { | |
| font-size: 1.3rem !important; | |
| line-height: 1.4 !important; | |
| } | |
| .header-container p { | |
| font-size: 0.85rem !important; | |
| } | |
| } | |
| /* FORZADO ANTI-FLICKERING (ESTABILIZADOR DE IMAGEN) */ | |
| [data-testid="stImage"], [data-testid="stImage"] > img { | |
| zoom: 1 !important; | |
| transform: translateZ(0) !important; | |
| backface-visibility: hidden !important; | |
| -webkit-transform: translate3d(0,0,0) !important; | |
| perspective: 1000px !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ============================================================================== | |
| # 3.1 FUNCIONES DE PRIVACIDAD | |
| # ============================================================================== | |
| def render_main_privacy_messages(): | |
| st.markdown(""" | |
| <div class="privacy-banner"> | |
| <h3>🔐 Aviso importante sobre privacidad y uso responsable</h3> | |
| <p>Esta plataforma tiene <strong>fines académicos</strong> y utiliza <strong>inteligencia artificial como apoyo</strong> para el análisis dermatológico. | |
| <strong>No sustituye</strong> el criterio médico, el diagnóstico profesional ni la atención clínica presencial.</p> | |
| <ul> | |
| <li>Ingrese únicamente la <strong>información mínima necesaria</strong> para el análisis.</li> | |
| <li>Evite nombres completos, números de identidad, direcciones u otros <strong>identificadores directos</strong>.</li> | |
| </ul> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| st.markdown(""" | |
| <div class="privacy-note-box"> | |
| <strong>📌 Tratamiento de datos y minimización</strong> | |
| <p>Los datos personales y de salud son sensibles. Por ello, solo deben cargarse los datos estrictamente necesarios para fines académicos.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with c2: | |
| st.markdown(""" | |
| <div class="privacy-note-box"> | |
| <strong>🖼️ Uso de imágenes clínicas</strong> | |
| <p>No cargue imágenes con elementos innecesarios que permitan identificar directamente al paciente, salvo autorización.</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def open_consent_dialog(force=False): | |
| dialog_callable = getattr(st, "dialog", None) | |
| if dialog_callable is None: | |
| st.warning("Este entorno no soporta ventanas modales. Consentimiento en línea:") | |
| with st.container(border=True): | |
| consent_data = st.checkbox("Comprendo que trato datos sensibles.", key="inline_data") | |
| consent_ai = st.checkbox("Comprendo que es IA de apoyo.", key="inline_ai") | |
| consent_img = st.checkbox("Confirmo anonimización.", key="inline_img") | |
| if st.button("Aceptar y continuar", key="inline_accept"): | |
| if consent_data and consent_ai and consent_img: | |
| st.session_state.update({"privacy_ack": True, "show_privacy_dialog": False}) | |
| st.rerun() | |
| else: | |
| st.error("Debe aceptar todos los puntos.") | |
| return | |
| def _dialog(): | |
| st.markdown(""" | |
| Antes de utilizar la plataforma, confirme lo siguiente: | |
| - Esta herramienta tiene **fines académicos**. | |
| - Usa **IA como apoyo** y **no sustituye** evaluación médica profesional. | |
| - Solo ingresará datos **autorizados, anonimizados o seudonimizados**. | |
| """) | |
| consent_data = st.checkbox("Comprendo el tratamiento de datos.", key="modal_data") | |
| consent_ai = st.checkbox("Comprendo que la IA es de apoyo.", key="modal_ai") | |
| consent_img = st.checkbox("Cuento con autorización para imágenes.", key="modal_img") | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| if st.button("Aceptar y continuar", use_container_width=True): | |
| if consent_data and consent_ai and consent_img: | |
| st.session_state.update({"privacy_ack": True, "show_privacy_dialog": False}) | |
| st.rerun() | |
| else: | |
| st.error("Debe aceptar todos los puntos.") | |
| with c2: | |
| if st.button("Cerrar", use_container_width=True): | |
| st.session_state["show_privacy_dialog"] = False | |
| if force: | |
| st.warning("Debe aceptar para continuar.") | |
| st.rerun() | |
| _dialog() | |
| # ============================================================================== | |
| # 4. CLASES DE VISIÓN (GRAD-CAM) | |
| # ============================================================================== | |
| class FeatureExtractor: | |
| def __init__(self, model, target_layers): | |
| self.activations = {} | |
| for name, layer in target_layers.items(): | |
| layer.register_forward_hook(self.get_hook(name)) | |
| def get_hook(self, name): | |
| def hook(model, input, output): | |
| self.activations[name] = output.detach() | |
| return hook | |
| class GradCAM: | |
| def __init__(self, model, target_layer): | |
| self.model = model | |
| self.activations = None | |
| self.gradients = None | |
| target_layer.register_forward_hook(self.save_activation) | |
| target_layer.register_full_backward_hook(self.save_gradient) | |
| def save_activation(self, module, input, output): | |
| self.activations = output | |
| def save_gradient(self, module, grad_input, grad_output): | |
| self.gradients = grad_output[0] | |
| def __call__(self, x): | |
| self.model.zero_grad() | |
| output = self.model(x) | |
| idx = torch.argmax(output, dim=1) | |
| output[0, idx].backward() | |
| grads = self.gradients.cpu().data.numpy()[0] | |
| fmaps = self.activations.cpu().data.numpy()[0] | |
| weights = np.mean(grads, axis=(1, 2)) | |
| cam = np.zeros(fmaps.shape[1:], dtype=np.float32) | |
| for i, w in enumerate(weights): | |
| cam += w * fmaps[i] | |
| cam = np.maximum(cam, 0) | |
| cam = cv2.resize(cam, (380, 380)) | |
| cam = (cam - np.min(cam)) / (np.max(cam) + 1e-8) | |
| return cam, output, idx | |
| def plot_feature_maps(activations, layer_name, title, output_file): | |
| act = activations[layer_name].squeeze().cpu().numpy() | |
| mean_act = np.mean(act, axis=(1, 2)) | |
| top_indices = np.argsort(mean_act)[::-1][:16] | |
| fig, axes = plt.subplots(4, 4, figsize=(10, 10)) | |
| fig.suptitle(title, fontsize=16) | |
| for idx, ax in enumerate(axes.flat): | |
| if idx < len(top_indices): | |
| fmap_idx = top_indices[idx] | |
| fmap = act[fmap_idx] | |
| fmap = (fmap - np.min(fmap)) / (np.max(fmap) + 1e-8) | |
| ax.imshow(fmap, cmap='viridis') | |
| ax.set_title(f"Filtro {fmap_idx}", fontsize=8) | |
| ax.axis('off') | |
| plt.tight_layout() | |
| plt.savefig(output_file) | |
| plt.close() | |
| return output_file | |
| def cargar_tu_modelo_especifico(ruta_pth): | |
| model = efficientnet_b4(weights=None) | |
| num_ftrs = model.classifier[1].in_features | |
| model.classifier = nn.Sequential( | |
| nn.Dropout(p=0.45), | |
| nn.Linear(num_ftrs, 3) | |
| ) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| try: | |
| state_dict = torch.load(ruta_pth, map_location=device) | |
| model.load_state_dict(state_dict) | |
| except Exception as e: | |
| st.error(f"❌ Error cargando pesos: {e}") | |
| return None | |
| model.to(device) | |
| model.eval() | |
| return model | |
| transformacion_validacion = transforms.Compose([ | |
| transforms.Resize((380, 380)), | |
| transforms.ToTensor(), | |
| transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) | |
| ]) | |
| def ejecutar_pipeline_gradcam(modelo, ruta_img, temp_dir): | |
| feature_extractor = FeatureExtractor( | |
| modelo, | |
| {'capa_inicial': modelo.features[0], 'capa_final': modelo.features[-1]} | |
| ) | |
| grad_cam = GradCAM(modelo, modelo.features[-1]) | |
| pil_img = Image.open(ruta_img).convert('RGB') | |
| device = next(modelo.parameters()).device | |
| img_tensor = transformacion_validacion(pil_img).unsqueeze(0).to(device) | |
| cam_map, logits, pred_idx = grad_cam(img_tensor) | |
| probs = F.softmax(logits, dim=1).cpu().data.numpy()[0] | |
| CLASES_NOMBRES = ['Benigno', 'Melanoma', 'Carcinoma'] | |
| img_cv = cv2.imread(ruta_img) | |
| img_cv = cv2.resize(img_cv, (380, 380)) | |
| heatmap = cv2.applyColorMap(np.uint8(255 * cam_map), cv2.COLORMAP_JET) | |
| superimposed = cv2.addWeighted(img_cv, 0.6, heatmap, 0.4, 0) | |
| plt.figure(figsize=(12, 5)) | |
| plt.subplot(1, 3, 1) | |
| plt.imshow(cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)) | |
| plt.title("Original") | |
| plt.axis('off') | |
| plt.subplot(1, 3, 2) | |
| plt.imshow(cv2.cvtColor(superimposed, cv2.COLOR_BGR2RGB)) | |
| plt.title(f"Atención IA\n({CLASES_NOMBRES[pred_idx]})") | |
| plt.axis('off') | |
| plt.subplot(1, 3, 3) | |
| bars = plt.bar(CLASES_NOMBRES, probs, color=['green', 'red', 'orange']) | |
| plt.title("Probabilidades") | |
| plt.ylim(0, 1.15) | |
| for bar in bars: | |
| plt.text( | |
| bar.get_x() + bar.get_width() / 2.0, | |
| bar.get_height() + 0.02, | |
| f'{bar.get_height()*100:.1f}%', | |
| ha='center', | |
| va='bottom', | |
| fontsize=10, | |
| fontweight='bold' | |
| ) | |
| path_diag = os.path.join(temp_dir, "1_diagnostico_clinico.png") | |
| plt.savefig(path_diag) | |
| plt.close() | |
| path_bordes = os.path.join(temp_dir, "2_analisis_bordes.png") | |
| plot_feature_maps( | |
| feature_extractor.activations, | |
| 'capa_inicial', | |
| "BORDES Y FORMAS", | |
| path_bordes | |
| ) | |
| path_patrones = os.path.join(temp_dir, "3_analisis_patrones.png") | |
| plot_feature_maps( | |
| feature_extractor.activations, | |
| 'capa_final', | |
| "TEXTURA", | |
| path_patrones | |
| ) | |
| return path_diag, path_bordes, path_patrones, CLASES_NOMBRES[pred_idx], probs | |
| def analizar_imagen_medica(ruta_imagen, modelo): | |
| if modelo is None: | |
| return "Error: Modelo no cargado." | |
| CLASES = ['Benigno', 'Melanoma', 'Carcinoma'] | |
| try: | |
| image = transformacion_validacion(Image.open(ruta_imagen).convert('RGB')).unsqueeze(0).to(next(modelo.parameters()).device) | |
| with torch.no_grad(): | |
| probs = torch.nn.functional.softmax(modelo(image), dim=1) | |
| clase_idx = torch.argmax(probs, 1).item() | |
| return f"ANÁLISIS DE IA:\n- Predicción: {CLASES[clase_idx].upper()}\n- Confianza: {probs[0][clase_idx].item()*100:.2f}%\n * Benigno: {probs[0][0].item()*100:.2f}%\n * Melanoma: {probs[0][1].item()*100:.2f}%\n * Carcinoma: {probs[0][2].item()*100:.2f}%" | |
| except Exception as e: | |
| return f"Error: {str(e)}" | |
| # ============================================================================== | |
| # 5. GENERADOR PDF | |
| # ============================================================================== | |
| class PDFReport(FPDF): | |
| def __init__(self, paciente_info): | |
| super().__init__() | |
| self.paciente_info = paciente_info | |
| def header(self): | |
| self.set_font('Arial', 'B', 15) | |
| self.cell(0, 10, 'DermaRAG - Informe Diagnóstico', 0, 1, 'C') | |
| self.line(10, 20, 200, 20) | |
| self.ln(5) | |
| def footer(self): | |
| self.set_y(-20) | |
| # Disclaimer medico-legal (en cada pagina) | |
| self.set_font('Arial', 'I', 7) | |
| self.set_text_color(150, 30, 30) | |
| disclaimer = ("AVISO MEDICO-LEGAL: Esta herramienta tiene fines academicos, " | |
| "usa IA como apoyo y no sustituye evaluacion medica profesional.") | |
| self.multi_cell(0, 3, disclaimer, 0, 'C') | |
| # Datos del paciente y numero de pagina | |
| self.set_text_color(0, 0, 0) | |
| self.set_font('Arial', 'I', 8) | |
| self.cell(0, 5, f"ID Paciente: {self.paciente_info['id']} | Pag {self.page_no()}", 0, 0, 'C') | |
| def chapter_title(self, label): | |
| self.set_font('Arial', 'B', 12) | |
| self.set_fill_color(200, 220, 255) | |
| self.cell(0, 6, label, 0, 1, 'L', 1) | |
| self.ln(4) | |
| def chapter_body(self, text): | |
| self.set_font('Arial', '', 11) | |
| self.multi_cell(0, 5, text) | |
| self.ln() | |
| # ============================================================================== | |
| # 6. INTERFAZ DE USUARIO STREAMLIT | |
| # ============================================================================== | |
| st.markdown(""" | |
| <div class="header-container"> | |
| <h1 style="color: white !important; font-size: clamp(1.1rem, 5vw, 2rem); line-height: 1.3; word-wrap: break-word; overflow-wrap: break-word;">🏥 DermaRAG - Sistema Multiagente de Diagnóstico Dermatológico</h1> | |
| <p style="color: white !important;">Prototipo Funcional| IA Explicable con Retrieval-Augmented Generation | Guías AAD/BAD/NCCN</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Validación de Token GROQ | |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY") | |
| if not GROQ_API_KEY: | |
| st.error("⚠️ Falta el token de Groq. Añade `GROQ_API_KEY` a tus Secrets.") | |
| st.stop() | |
| # Funciones de privacidad en la vista principal | |
| render_main_privacy_messages() | |
| if not st.session_state.get("privacy_ack", False) and st.session_state.get("show_privacy_dialog", True): | |
| open_consent_dialog(force=False) | |
| RUTA_MODELO = 'mejor_modelo_v5.pth' | |
| if os.path.exists(RUTA_MODELO): | |
| modelo_cnn = cargar_tu_modelo_especifico(RUTA_MODELO) | |
| else: | |
| st.error(f"⚠️ Falta '{RUTA_MODELO}'") | |
| modelo_cnn = None | |
| col_izq, col_der = st.columns([1, 1], gap="large") | |
| with col_izq: | |
| with st.container(border=True): | |
| st.markdown("## 📋 Datos del Paciente") | |
| c1, c2 = st.columns(2) | |
| nombre = c1.text_input("Nombre del paciente *", placeholder="Ej. Gerardo García") | |
| edad = c1.number_input("Edad *", value=0, min_value=0, max_value=120, step=1) | |
| fototipo = c1.selectbox( | |
| "Fototipo Fitzpatrick *", | |
| ["Tipo I - Piel muy clara", "Tipo II - Piel clara", "Tipo III - Piel intermedia", | |
| "Tipo IV - Piel morena clara", "Tipo V - Piel morena", "Tipo VI - Piel negra"], | |
| index=None, | |
| placeholder="Selecciona una opción..." | |
| ) | |
| id_paciente = c2.text_input("ID Paciente *", placeholder="Ej. PAC-2025-001") | |
| sexo = c2.selectbox("Sexo *", ["Masculino", "Femenino", "Otro"], index=None, placeholder="Seleccionar...") | |
| with st.container(border=True): | |
| st.markdown("## 🔬 Datos Clínicos de la Lesión") | |
| localizacion = st.selectbox( | |
| "Localización Anatómica *", | |
| ["Tronco (pecho/espalda)", "Cabeza y Cuello", "Extremidades Superiores", | |
| "Extremidades Inferiores", "Manos/Pies (Acral)", "Mucosas"], | |
| index=None, | |
| placeholder="Seleccionar ubicación..." | |
| ) | |
| cc1, cc2 = st.columns(2) | |
| tamano = cc1.number_input("Tamaño (mm) *", value=0, min_value=0, step=1) | |
| evolucion = cc2.number_input("Evolución (meses)", value=0, min_value=0, step=1) | |
| sintomas = st.text_area("Síntomas Asociados", placeholder="Ej. Prurito, sangrado, asimetría...", height=80) | |
| historia = st.text_area("Antecedentes Relevantes", placeholder="Ej. Historia familiar de melanoma...", height=80) | |
| with st.container(border=True): | |
| st.markdown("## 🔎 Criterios ABCDE (Dermoscopia Visual)") | |
| col_checks = st.columns(5) | |
| check_a = col_checks[0].checkbox("A", value=False, help="Asimetría") | |
| check_b = col_checks[1].checkbox("B", value=False, help="Bordes Irregulares") | |
| check_c = col_checks[2].checkbox("C", value=False, help="Color (Policromía)") | |
| check_d = col_checks[3].checkbox("D", value=False, help="Diámetro > 6mm") | |
| check_e = col_checks[4].checkbox("E", value=False, help="Evolución") | |
| with col_der: | |
| with st.container(border=True): | |
| st.markdown("## 📸 Imagen de la Lesión Cutánea") | |
| uploaded_file = st.file_uploader("Sube imagen (JPG/PNG)", type=["jpg", "png", "jpeg"]) | |
| if uploaded_file: | |
| img_temp_pil = Image.open(uploaded_file) | |
| w, h = img_temp_pil.size | |
| size_kb = uploaded_file.size / 1024 | |
| st.success(f"✅ Archivo: {uploaded_file.name} | {size_kb:.2f} KB | {w}x{h} px") | |
| st.image(img_temp_pil, caption="Vista Previa", width=375) | |
| st.markdown(""" | |
| <div class="medical-warning"> | |
| <strong>Antes de analizar:</strong> Confirme consentimiento de privacidad. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| analyze_btn = st.button("🔍 Analizar con IA Multiagente + GradCAM", use_container_width=True) | |
| # ============================================================================== | |
| # 7. EJECUCIÓN DEL SISTEMA | |
| # ============================================================================== | |
| if analyze_btn: | |
| if not st.session_state.get("privacy_ack", False): | |
| st.session_state["show_privacy_dialog"] = True | |
| open_consent_dialog(force=True) | |
| st.stop() | |
| if uploaded_file and modelo_cnn: | |
| if not nombre or localizacion is None: | |
| st.error("⚠️ Completa al menos: Nombre y Localización.") | |
| else: | |
| with st.status("🔄 Ejecutando Sistema...", expanded=True) as status: | |
| temp_dir = tempfile.mkdtemp() | |
| ruta_input = os.path.join(temp_dir, "input.jpg") | |
| with open(ruta_input, "wb") as f: | |
| f.write(uploaded_file.getvalue()) | |
| st.write("🧠 Percepción Visual...") | |
| t0 = time.time() | |
| path_diag, path_bordes, path_patrones, pred_clase, probs = ejecutar_pipeline_gradcam( | |
| modelo_cnn, | |
| ruta_input, | |
| temp_dir | |
| ) | |
| resultado_vision = analizar_imagen_medica(ruta_input, modelo_cnn) | |
| latencia_vision = time.time() - t0 | |
| st.write("⚕️ Razonamiento Clínico Groq...") | |
| llm_agentes = LLM( | |
| model="groq/llama-3.3-70b-versatile", | |
| api_key=GROQ_API_KEY, | |
| temperature=0.5 | |
| ) | |
| llm_especialista = LLM( | |
| model="groq/openai/gpt-oss-120b", | |
| api_key=GROQ_API_KEY, | |
| temperature=0.1, | |
| max_tokens=8000 | |
| ) | |
| hallazgos_lista = [] | |
| if check_a: hallazgos_lista.append("Asimetría") | |
| if check_b: hallazgos_lista.append("Bordes") | |
| if check_c: hallazgos_lista.append("Policromía") | |
| if check_d: hallazgos_lista.append(f"Diámetro > 6mm ({tamano}mm)") | |
| if check_e: hallazgos_lista.append("Evolución") | |
| hallazgos_txt = ", ".join(hallazgos_lista) if hallazgos_lista else "Ninguno" | |
| task_med = ( | |
| f"DATOS: {edad} años, {sexo}, Fototipo: {fototipo}\n" | |
| f"CLÍNICA: {localizacion}, {tamano}mm, {evolucion} meses\n" | |
| f"SÍNTOMAS: {sintomas}\n" | |
| f"ANTECEDENTES: {historia}\n" | |
| f"ABCDE: {hallazgos_txt}\n" | |
| f"VISION IA: [{resultado_vision}]" | |
| ) | |
| medico_atencion_primaria = Agent( | |
| role='Auditor Clínico', | |
| goal=f'Validar coherencia Grad-CAM vs clínica. Contexto: {task_med}', | |
| backstory='Especialista en Triaje. Tu filosofía: "La IA es herramienta". IDIOMA OBLIGATORIO: EXCLUSIVAMENTE ESPAÑOL.', | |
| verbose=True, | |
| allow_delegation=False, | |
| llm=llm_agentes | |
| ) | |
| herramienta_rag = BuscadorGuiasClinicas() | |
| especialista_piel = Agent( | |
| role='Oncólogo Dermatólogo Basado en Evidencia', | |
| goal=( | |
| 'Generar un plan oncológico respaldado EXCLUSIVAMENTE por las guías ' | |
| 'clínicas indexadas (NCCN, AAD, BAD, oncosur). NUNCA respondes de ' | |
| 'memoria. Tu primer acto SIEMPRE es consultar la herramienta de búsqueda.' | |
| ), | |
| backstory=( | |
| 'Eres un oncólogo dermatólogo certificado que SOLO confía en evidencia ' | |
| 'documentada. Tu protocolo personal es: "Sin guía, no hay respuesta". ' | |
| 'Antes de emitir cualquier opinión, SIEMPRE consultas las guías clínicas ' | |
| 'mediante la herramienta disponible. OBLIGACIÓN ABSOLUTA: REDACTAR EN ' | |
| 'ESPAÑOL PERFECTO. Tus respuestas siempre incluyen citas textuales con ' | |
| 'la fuente exacta.' | |
| ), | |
| verbose=True, | |
| allow_delegation=False, | |
| tools=[herramienta_rag], | |
| max_iter=12, | |
| llm=llm_especialista | |
| ) | |
| task_atencion_primaria = Task( | |
| description=f"Analiza: {task_med}. REGLA: 100% ESPAÑOL. Fidelidad a IA. Traducción Semiológica.", | |
| agent=medico_atencion_primaria, | |
| expected_output="1. Validación Visión\n2. Resumen Semiológico\n3. Solicitud Interconsulta" | |
| ) | |
| task_especialista = Task( | |
| description=( | |
| f"Eres Oncólogo Dermatólogo. El paciente presenta:\n" | |
| f"- Predicción IA (CNN): {pred_clase}\n" | |
| f"- Localización: {localizacion}\n" | |
| f"- Tamaño: {tamano} mm\n" | |
| f"- Evolución: {evolucion} meses\n" | |
| f"- Fototipo: {fototipo}\n" | |
| f"- Hallazgos ABCDE: {hallazgos_txt}\n" | |
| f"- Síntomas: {sintomas}\n" | |
| f"- Antecedentes: {historia}\n\n" | |
| "═══════════════════════════════════════════════\n" | |
| "PASO 1 OBLIGATORIO — ANTES de redactar UNA sola palabra del informe, " | |
| "DEBES llamar la herramienta 'buscador_guias_clinicas' AL MENOS 5 VECES " | |
| "con estas queries EXACTAS, una por una:\n\n" | |
| f" Query 1: 'protocolo tratamiento {pred_clase.lower()}'\n" | |
| f" Query 2: 'márgenes quirúrgicos {pred_clase.lower()}'\n" | |
| f" Query 3: 'cirugía Mohs {pred_clase.lower()}'\n" | |
| f" Query 4: 'estadificación {pred_clase.lower()} factores riesgo'\n" | |
| f" Query 5: 'seguimiento {pred_clase.lower()} recurrencia'\n\n" | |
| "Si no llamas la herramienta 5 veces, tu respuesta será RECHAZADA.\n" | |
| "═══════════════════════════════════════════════\n\n" | |
| "PASO 2 — REGLA DE FIDELIDAD ABSOLUTA (CRÍTICO):\n" | |
| "1. CADA AFIRMACIÓN clínica del informe debe estar respaldada por una cita " | |
| "TEXTUAL Y LITERAL (copy-paste exacto, palabra por palabra) de un fragmento " | |
| "recuperado por la herramienta. PROHIBIDO parafrasear, resumir o reformular.\n" | |
| "2. Antes de escribir cada oración, identifica primero el fragmento que la " | |
| "respalda. Si no encuentras un fragmento que diga LITERALMENTE eso, NO LO " | |
| "ESCRIBAS.\n" | |
| "3. Las citas en la sección Referencias deben ser COPIA EXACTA de los " | |
| "fragmentos del RAG. No invento, no embellezco, no acorto.\n\n" | |
| "═══════════════════════════════════════════════\n" | |
| "PASO 3 — REGLA CRÍTICA DE CANTIDAD vs CALIDAD:\n" | |
| "Mínimo 3 referencias, máximo 6. PROHIBIDO rellenar con citas inventadas " | |
| "para llegar a un número objetivo. Es 1000 veces preferible 3 referencias " | |
| "100% reales que 8 referencias mezcladas con invenciones.\n\n" | |
| "ANTES de escribir cada referencia, pregúntate: ¿esta cita aparece " | |
| "TEXTUALMENTE en alguno de los fragmentos que me devolvió la herramienta? " | |
| "Si la respuesta es 'no estoy seguro', NO LA INCLUYAS.\n\n" | |
| "Las citas que mencionan al paciente concreto (su edad, tamaño de lesión, " | |
| "síntomas específicos) son SIEMPRE inventadas — las guías clínicas hablan " | |
| "de poblaciones, no de pacientes individuales. Si una de tus 'citas' " | |
| "menciona '50mm' o 'cabeza y cuello del paciente', es INVENTADA. " | |
| "Bórrala.\n\n" | |
| "PASO 4 — FUENTES VÁLIDAS (LISTA BLANCA):\n" | |
| "Las únicas fuentes válidas son los archivos .pdf que aparezcan en los " | |
| "fragmentos recuperados por la herramienta (ej: 'COL_D1_GUIA COMPLETA " | |
| "carcinoma basocelular.pdf', 'jnccn-article-p1181.pdf', 'cutaneous_melanoma.pdf', " | |
| "'guia-oncosur-de-melanoma.pdf', 'basoespino.pdf', etc.).\n\n" | |
| "PROHIBIDO ABSOLUTAMENTE citar como fuente:\n" | |
| " ❌ 'Validación Visión'\n" | |
| " ❌ 'Resumen Semiológico'\n" | |
| " ❌ 'Análisis Clínico'\n" | |
| " ❌ Cualquier nombre que NO termine en .pdf\n" | |
| " ❌ Cualquier output del agente anterior (auditor clínico)\n\n" | |
| "Si no tienes 3 fragmentos del RAG con archivos .pdf reales, usa solo los " | |
| "que sí tengas (mínimo 3) y NO inventes los demás.\n" | |
| "═══════════════════════════════════════════════\n\n" | |
| "PASO 5 — Redacta el informe en ESPAÑOL siguiendo el expected_output. " | |
| "Cada sección debe tener AL MENOS 4 oraciones sustantivas, todas con (ver Ref. N).\n\n" | |
| "Si una query devuelve 'No se encontró información relevante', intenta " | |
| f"con queries más cortas (ej: '{pred_clase.lower()}', 'biopsia piel')." | |
| ), | |
| agent=especialista_piel, | |
| context=[task_atencion_primaria], | |
| expected_output=( | |
| "### 1. Diagnóstico Presuntivo\n" | |
| "[4+ oraciones integrando IA, ABCDE, contexto. Cada afirmación con (ver Ref. N).]\n\n" | |
| "### 2. Protocolo de Tratamiento\n" | |
| "[4+ oraciones: técnica, márgenes, alternativas. Cada afirmación con (ver Ref. N).]\n\n" | |
| "### 3. Seguimiento\n" | |
| "[4+ oraciones: frecuencia, signos de alarma, autoexamen. Cada afirmación con (ver Ref. N).]\n\n" | |
| "### Referencias\n" | |
| "(SOLO citas LITERALES copy-paste de fragmentos del RAG. Solo fuentes .pdf reales. " | |
| "Mínimo 3, máximo 6. NUNCA mencionar al paciente individual en una cita.)\n\n" | |
| "**Ref. 1:** \"[copy-paste LITERAL del fragmento, sin modificar nada]\"\n" | |
| "_Fuente: nombre_archivo.pdf, página X_\n\n" | |
| "**Ref. 2:** \"[copy-paste LITERAL del fragmento]\"\n" | |
| "_Fuente: nombre_archivo.pdf, página Y_\n\n" | |
| "**Ref. 3:** \"[copy-paste LITERAL del fragmento]\"\n" | |
| "_Fuente: nombre_archivo.pdf, página Z_\n\n" | |
| "(Agrega Ref. 4-6 SOLO si tienes fragmentos reales adicionales del RAG.)" | |
| ) | |
| ) | |
| # Aquí limpiamos el archivo de memoria RAG antes de cada corrida | |
| # para no mezclar contextos de pacientes anteriores. La herramienta | |
| # BuscadorGuiasClinicas escribe ahí cada fragmento que recupera de ChromaDB. | |
| if os.path.exists("memoria_rag.txt"): | |
| os.remove("memoria_rag.txt") | |
| crew = Crew( | |
| agents=[medico_atencion_primaria, especialista_piel], | |
| tasks=[task_atencion_primaria, task_especialista], | |
| verbose=True, | |
| process=Process.sequential, | |
| language='es' | |
| ) | |
| st.session_state['resultado_final'] = crew.kickoff() | |
| # Guardamos en session_state para mostrar FUERA del st.status . | |
| if os.path.exists("memoria_rag.txt"): | |
| with open("memoria_rag.txt", "r", encoding="utf-8") as f: | |
| n_frags = len([x for x in f.read().split("\n\n") if x.strip()]) | |
| st.session_state['rag_n_frags'] = n_frags | |
| else: | |
| st.session_state['rag_n_frags'] = 0 | |
| # DETECTOR DE REFERENCIAS FALSAS | |
| resultado_str = str(st.session_state.get('resultado_final', '')) | |
| fuentes_falsas = [ | |
| "Validación Visión", "Resumen Semiológico", "Análisis Clínico", | |
| "Auditor Clínico", "Solicitud Interconsulta", "Validación de Visión", | |
| "Resumen Semiologico", "Validacion Vision" | |
| ] | |
| st.session_state['refs_falsas'] = [f for f in fuentes_falsas if f in resultado_str] | |
| # VERIFICADOR DE CITAS: compara cada Ref. del informe contra los | |
| # fragmentos reales del RAG. Si una cita no aparece literalmente (o casi), | |
| # la marca como sospechosa de invención. | |
| import re | |
| def normalizar(texto): | |
| """Quita puntuación, espacios extras, pasa a minúsculas para comparar.""" | |
| texto = re.sub(r'[^\w\s]', ' ', texto.lower()) | |
| texto = re.sub(r'\s+', ' ', texto).strip() | |
| return texto | |
| def verificar_cita(cita, fragmentos_normalizados): | |
| """ | |
| Devuelve True si la cita aparece (al menos parcialmente) en algún fragmento. | |
| Usa matching de ventanas de 8 palabras consecutivas — basta que UNA ventana | |
| coincida para considerar la cita como respaldada. | |
| """ | |
| cita_norm = normalizar(cita) | |
| palabras = cita_norm.split() | |
| if len(palabras) < 5: | |
| return False | |
| # Generar ventanas deslizantes de 8 palabras | |
| ventana_size = min(8, len(palabras)) | |
| for i in range(len(palabras) - ventana_size + 1): | |
| ventana = ' '.join(palabras[i:i + ventana_size]) | |
| for frag_norm in fragmentos_normalizados: | |
| if ventana in frag_norm: | |
| return True | |
| return False | |
| # Cargar fragmentos del RAG y normalizarlos | |
| citas_verificadas = [] | |
| if os.path.exists("memoria_rag.txt"): | |
| with open("memoria_rag.txt", "r", encoding="utf-8") as f: | |
| fragmentos = [x.strip() for x in f.read().split("\n\n") if x.strip()] | |
| fragmentos_norm = [normalizar(frag) for frag in fragmentos] | |
| # Extraer todas las citas del formato: Ref. N: "..." o **Ref. N:** "..." | |
| patron_cita = r'\*?\*?Ref\.?\s*(\d+):?\*?\*?\s*[""]([^""]+)[""]' | |
| matches = re.findall(patron_cita, resultado_str) | |
| for num_ref, texto_cita in matches: | |
| es_real = verificar_cita(texto_cita, fragmentos_norm) | |
| citas_verificadas.append({ | |
| 'num': num_ref, | |
| 'texto': texto_cita[:100] + ('...' if len(texto_cita) > 100 else ''), | |
| 'real': es_real | |
| }) | |
| st.session_state['citas_verificadas'] = citas_verificadas | |
| st.session_state.update({ | |
| 'diagnostico_generado': True, | |
| 'pred_clase': pred_clase, | |
| 'probs': probs, | |
| 'path_diag': path_diag, | |
| 'path_bordes': path_bordes, | |
| 'path_patrones': path_patrones, | |
| 'temp_dir': temp_dir, | |
| 'ragas_scores': None, | |
| 'pdf_bytes': None, | |
| 'pdf_para_id': None | |
| }) | |
| latencia_total = time.time() - t0 | |
| status.update(label=f"✅ Diagnóstico en {latencia_total:.2f}s", state="complete") | |
| archivo_logs = "logs_latencia.csv" | |
| if not os.path.exists(archivo_logs): | |
| with open(archivo_logs, mode='w', newline='') as file: | |
| writer = csv.writer(file) | |
| writer.writerow(["Fecha", "ID_Paciente", "Latencia_Vision_seg", "Latencia_Total_seg"]) | |
| with open(archivo_logs, mode='a', newline='') as file: | |
| writer = csv.writer(file) | |
| fecha_actual = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") | |
| writer.writerow([fecha_actual, id_paciente, round(latencia_vision, 2), round(latencia_total, 2)]) | |
| else: | |
| st.warning("⚠️ Por favor sube una imagen para proceder.") | |
| # ============================================================================== | |
| # 8. RENDERIZADO FUERA DEL BOTÓN Y RAGAS MANUAL | |
| # ============================================================================== | |
| if st.session_state.get('diagnostico_generado', False): | |
| st.markdown("---") | |
| # BANNERS DE VERIFICACIÓN RAG (visibles fuera del st.status colapsado) | |
| n_frags = st.session_state.get('rag_n_frags', 0) | |
| if n_frags > 0: | |
| st.info(f"🔍 RAG recuperó **{n_frags} fragmentos** de las guías clínicas durante el análisis.") | |
| else: | |
| st.warning("⚠️ El agente NO invocó la herramienta RAG en esta corrida. Las métricas RAGas serán 0.") | |
| refs_falsas = st.session_state.get('refs_falsas', []) | |
| if refs_falsas: | |
| st.warning( | |
| f"⚠️ **Referencias inventadas detectadas:** {', '.join(refs_falsas)}. " | |
| f"El agente citó el output del agente anterior en vez de fragmentos reales del RAG. " | |
| f"Esto bajará la Fidelidad RAGas. Considera regenerar el diagnóstico." | |
| ) | |
| # VERIFICADOR DE CITAS: muestra el desglose de cuántas refs son reales vs inventadas | |
| citas_verif = st.session_state.get('citas_verificadas', []) | |
| if citas_verif: | |
| n_total = len(citas_verif) | |
| n_reales = sum(1 for c in citas_verif if c['real']) | |
| n_inventadas = n_total - n_reales | |
| ratio = n_reales / n_total if n_total > 0 else 0 | |
| if ratio >= 0.8: | |
| st.success( | |
| f"✅ **Verificación de citas:** {n_reales}/{n_total} referencias respaldadas " | |
| f"por fragmentos reales del RAG ({ratio*100:.0f}%)." | |
| ) | |
| elif ratio >= 0.5: | |
| st.warning( | |
| f"⚠️ **Verificación de citas:** solo {n_reales}/{n_total} referencias están " | |
| f"respaldadas por el RAG ({ratio*100:.0f}%). Las demás parecen inventadas." | |
| ) | |
| else: | |
| st.error( | |
| f"❌ **Verificación de citas:** solo {n_reales}/{n_total} referencias son reales " | |
| f"({ratio*100:.0f}%). El modelo está alucinando la mayoría de las citas." | |
| ) | |
| # Desglose detallado en expander | |
| with st.expander(f"🔎 Ver desglose de las {n_total} citas"): | |
| for c in citas_verif: | |
| icono = "✅" if c['real'] else "❌" | |
| st.markdown(f"{icono} **Ref. {c['num']}:** _{c['texto']}_") | |
| st.subheader("👁️ Análisis Explicable y Auditoría") | |
| t1, t2, t3, t4 = st.tabs(["Diagnóstico IA", "Bordes (Capa Baja)", "Patrones (Capa Alta)", "📊 Auditoría RAGas"]) | |
| with t1: | |
| st.image(st.session_state['path_diag'], use_container_width=True) | |
| with t2: | |
| st.image(st.session_state['path_bordes'], use_container_width=True) | |
| with t3: | |
| st.image(st.session_state['path_patrones'], use_container_width=True) | |
| with t4: | |
| st.markdown("### Auditoría Clínica RAGas (Ejecución Manual)") | |
| if st.button("🚀 Ejecutar Auditoría", use_container_width=True): | |
| with st.spinner("Auditando con IA Juez Groq..."): | |
| try: | |
| # Juez RAGas = GPT-OSS 120B | |
| # ChatOpenAI usa el endpoint OpenAI-compatible de Groq directamente. | |
| llm_juez = ChatOpenAI( | |
| api_key=GROQ_API_KEY, | |
| base_url="https://api.groq.com/openai/v1", | |
| model="openai/gpt-oss-120b", | |
| temperature=0, | |
| max_tokens=16000 | |
| ) | |
| embeddings_juez = HuggingFaceEmbeddings( | |
| model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" | |
| ) | |
| # Leer los fragmentos reales que la herramienta | |
| # BuscadorGuiasClinicas escribió en memoria_rag.txt durante el kickoff. | |
| ctx = [] | |
| if os.path.exists("memoria_rag.txt"): | |
| with open("memoria_rag.txt", "r", encoding="utf-8") as f: | |
| contenido = f.read().strip() | |
| ctx = [frag.strip() for frag in contenido.split("\n\n") if frag.strip()] | |
| if not ctx: | |
| st.error("⚠️ No hay contextos RAG para auditar. El agente especialista no usó la herramienta de guías clínicas en esta corrida. Vuelve a generar el diagnóstico.") | |
| st.stop() | |
| res_txt = str(st.session_state['resultado_final']) | |
| # El informe cubre 3 secciones (diagnóstico, protocolo, | |
| # seguimiento). Si la pregunta solo menciona "protocolo", la Relevancia baja | |
| # porque las preguntas hipotéticas que RAGas genera no coinciden. | |
| pregunta = ( | |
| f"¿Cuál es el diagnóstico presuntivo, el protocolo de tratamiento " | |
| f"basado en guías clínicas, y el plan de seguimiento recomendado para " | |
| f"un paciente con sospecha de {st.session_state['pred_clase']} " | |
| f"de {tamano}mm localizado en {localizacion}, considerando los hallazgos " | |
| f"ABCDE y la evidencia de las guías oncológicas?" | |
| ) | |
| dataset = Dataset.from_dict({ | |
| "question": [pregunta], | |
| "contexts": [ctx], | |
| "answer": [res_txt] | |
| }) | |
| res = evaluate( | |
| dataset=dataset, | |
| metrics=[Faithfulness(), AnswerRelevancy(strictness=1)], | |
| llm=llm_juez, | |
| embeddings=embeddings_juez, | |
| raise_exceptions=True | |
| ) | |
| def s_score(c): | |
| for col in res.to_pandas().columns: | |
| if c.lower() in col.lower(): | |
| return 0.0 if math.isnan(res.to_pandas()[col][0]) else res.to_pandas()[col][0] | |
| return 0.0 | |
| st.session_state['ragas_scores'] = { | |
| 'f': s_score('faithfulness'), | |
| 'r': s_score('relevancy') | |
| } | |
| except Exception as e: | |
| st.error(f"Error RAGas: {e}") | |
| if st.session_state.get('ragas_scores'): | |
| c_r1, c_r2 = st.columns(2) | |
| def fmt(s): | |
| color = 'green' if s > 0.8 else 'orange' if s > 0.6 else 'red' | |
| return f"<span style='color: {color}; font-size:24px; font-weight:bold;'>{s:.2f}</span>" | |
| with c_r1: | |
| st.markdown(f"**Fidelidad:**<br>{fmt(st.session_state['ragas_scores']['f'])}", unsafe_allow_html=True) | |
| with c_r2: | |
| st.markdown(f"**Relevancia:**<br>{fmt(st.session_state['ragas_scores']['r'])}", unsafe_allow_html=True) | |
| st.markdown("### 📊 Informe Final") | |
| with st.container(border=True): | |
| st.markdown(st.session_state['resultado_final']) | |
| st.markdown(""" | |
| <div style="background: #fff1f2; border: 1px solid #fecdd3; border-left: 6px solid #e11d48; | |
| border-radius: 8px; padding: 12px 16px; margin-top: 12px; margin-bottom: 12px; | |
| font-size: 13px; color: #881337; text-align: center;"> | |
| ⚠️ <strong>AVISO MÉDICO-LEGAL:</strong> Esta herramienta tiene fines académicos, | |
| usa IA como apoyo y no sustituye evaluación médica profesional. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # El PDF se genera UNA sola vez y se cachea en session_state. | |
| # Antes se regeneraba en cada rerun causando reescritura del archivo + I/O continuo | |
| # + remontaje del download_button → flickering visible. | |
| if not st.session_state.get('pdf_bytes') or st.session_state.get('pdf_para_id') != id_paciente: | |
| pdf = PDFReport({'id': id_paciente, 'edad': edad}) | |
| pdf.add_page() | |
| pdf.chapter_title("1. Análisis") | |
| pdf.image(st.session_state['path_diag'], w=190) | |
| pdf.ln(5) | |
| pdf.chapter_title("2. Informe") | |
| # Limpieza AGRESIVA de caracteres Unicode con unicodedata, | |
| # Porque GPT-OSS genera muchos caracteres no-latin1 (especialmente \u00a0 non-breaking space) | |
| import unicodedata | |
| texto_informe = str(st.session_state['resultado_final']).replace('**', '') | |
| reemplazos = { | |
| '\u00a0': ' ', '\u2013': '-', '\u2014': '-', | |
| '\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"', | |
| '\u2026': '...', '\u2265': '>=', '\u2264': '<=', '\u00b1': '+/-', | |
| '\u2192': '->', '\u2190': '<-', '\u00b7': '*', '\u2022': '*', | |
| '\u00bf': '?', '\u00a1': '!', '\u2212': '-', '\u00d7': 'x', | |
| '\t': ' ', | |
| } | |
| for orig, repl in reemplazos.items(): | |
| texto_informe = texto_informe.replace(orig, repl) | |
| texto_normalizado = "" | |
| for char in texto_informe: | |
| try: | |
| char.encode('latin-1') | |
| texto_normalizado += char | |
| except UnicodeEncodeError: | |
| decomp = unicodedata.normalize('NFKD', char) | |
| for c in decomp: | |
| try: | |
| c.encode('latin-1') | |
| texto_normalizado += c | |
| except UnicodeEncodeError: | |
| pass | |
| pdf.chapter_body(texto_normalizado) | |
| # Generar bytes del PDF en memoria (sin escribir a disco repetidamente) | |
| out_pdf = os.path.join(st.session_state['temp_dir'], "reporte.pdf") | |
| pdf.output(out_pdf) | |
| with open(out_pdf, "rb") as f: | |
| st.session_state['pdf_bytes'] = f.read() | |
| st.session_state['pdf_para_id'] = id_paciente | |
| # Botón de descarga usa los bytes cacheados (sin I/O en cada rerun) | |
| st.download_button( | |
| "📄 Descargar PDF", | |
| data=st.session_state['pdf_bytes'], | |
| file_name=f"Reporte_{id_paciente}.pdf", | |
| mime="application/pdf", | |
| key="download_pdf_btn" | |
| ) | |
| # --- SIDEBAR ("Estado Consentimiento") --- | |
| with st.sidebar: | |
| st.markdown("### 🔐 Estado Privacidad") | |
| if st.session_state.get("privacy_ack", False): | |
| st.success("Consentimiento aceptado.") | |
| else: | |
| st.warning("Pendiente.") | |
| if st.button("Ver consentimiento"): | |
| st.session_state["show_privacy_dialog"]=True | |
| open_consent_dialog() | |
| st.markdown("---") | |
| st.markdown("### 📊 Panel de Administración") | |
| archivo_logs = "logs_latencia.csv" | |
| if os.path.exists(archivo_logs): | |
| st.write("Descarga los registros de tiempo para calcular el Percentil 95.") | |
| with open(archivo_logs, "rb") as f: | |
| st.download_button( | |
| label="📥 Descargar Logs (CSV)", | |
| data=f, | |
| file_name="historial_latencia_dermarag.csv", | |
| mime="text/csv", | |
| use_container_width=True | |
| ) | |
| else: | |
| st.info("Aún no hay logs generados. Analiza una imagen primero.") | |
| # ========================================================================== | |
| # 🔬 DIAGNÓSTICO CHROMADB | |
| # ========================================================================== | |
| st.markdown("---") | |
| st.markdown("### 🔬 Diagnóstico ChromaDB") | |
| if st.button("Verificar base RAG", use_container_width=True): | |
| try: | |
| # ¿Existe la carpeta? | |
| if not os.path.exists("./chroma_db"): | |
| st.error("❌ La carpeta ./chroma_db NO existe en el Space.") | |
| st.info("Verifica que la carpeta esté subida al repo del Space (puede requerir git lfs).") | |
| else: | |
| archivos = os.listdir("./chroma_db") | |
| st.write(f"📁 Archivos en chroma_db: **{len(archivos)}**") | |
| with st.expander("Ver archivos"): | |
| st.code("\n".join(archivos[:20])) | |
| # ¿Carga y tiene documentos? | |
| from langchain_huggingface import HuggingFaceEmbeddings as _HFE | |
| from langchain_community.vectorstores import Chroma as _Chroma | |
| emb = _HFE( | |
| model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" | |
| ) | |
| db = _Chroma(persist_directory="./chroma_db", embedding_function=emb) | |
| total = db._collection.count() | |
| st.metric("Total de chunks indexados", total) | |
| if total == 0: | |
| st.error("❌ La base existe pero está VACÍA. Hay que reindexar los PDFs.") | |
| else: | |
| # Prueba de búsqueda real | |
| resultados = db.similarity_search("margen melanoma", k=3) | |
| st.success(f"✅ Búsqueda funcional. {len(resultados)} resultados para 'margen melanoma':") | |
| for i, r in enumerate(resultados, 1): | |
| fuente = r.metadata.get('source', '?') | |
| pagina = r.metadata.get('page', '?') | |
| with st.expander(f"📄 Resultado {i}: {os.path.basename(fuente)} (pág {pagina})"): | |
| st.write(r.page_content[:500] + "...") | |
| except Exception as e: | |
| st.error(f"Error al verificar: {e}") | |
| import traceback | |
| with st.expander("Traceback completo"): | |
| st.code(traceback.format_exc()) | |
| st.markdown(""" | |
| <div style='text-align: center; color: #666666; padding: 20px;'> | |
| DermaRAG MVP v2.0 | EfficientNet-B4 · Llama 3.3 70B + GPT-OSS 120B (Groq) | |
| </div> | |
| """, unsafe_allow_html=True) |