MGC1991MF commited on
Commit
05e12f0
·
verified ·
1 Parent(s): 614e67f

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1109 -0
app.py ADDED
@@ -0,0 +1,1109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==============================================================================
2
+ # 0. PARCHE DE SISTEMA (Requerido para HF Spaces / ChromaDB)
3
+ # ==============================================================================
4
+ import sys
5
+
6
+ try:
7
+ __import__('pysqlite3')
8
+ sys.modules['sqlite3'] = sys.modules.pop('pysqlite3')
9
+ except ImportError:
10
+ pass
11
+
12
+ # ==============================================================================
13
+ # 1. LIBRERÍAS
14
+ # ==============================================================================
15
+ import streamlit as st
16
+ import os
17
+ import time
18
+ import csv
19
+ import math
20
+ import datetime
21
+ import tempfile
22
+ import torch
23
+ import torch.nn as nn
24
+ import torch.nn.functional as F
25
+ from torchvision import transforms
26
+ from torchvision.models import efficientnet_b4
27
+ from PIL import Image
28
+ import numpy as np
29
+ import matplotlib.pyplot as plt
30
+ import cv2
31
+ from crewai import Agent, Task, Crew, Process, LLM
32
+ from RAG_tool import BuscadorGuiasClinicas
33
+ from fpdf import FPDF
34
+
35
+ # LIBRERÍAS RAGAS (Usando Clases Base)
36
+ from datasets import Dataset
37
+ from ragas import evaluate
38
+ from ragas.metrics import Faithfulness, AnswerRelevancy
39
+ from langchain_huggingface import HuggingFaceEmbeddings
40
+ from langchain_openai import ChatOpenAI
41
+
42
+ # ==============================================================================
43
+ # 2. CONFIGURACIÓN VISUAL Y DE PRIVACIDAD
44
+ # ==============================================================================
45
+ st.set_page_config(
46
+ page_title="DermaRAG - Diagnóstico",
47
+ page_icon="🏥",
48
+ layout="wide",
49
+ initial_sidebar_state="collapsed"
50
+ )
51
+
52
+ # Inicialización de variables de estado para privacidad
53
+ if "privacy_ack" not in st.session_state:
54
+ st.session_state["privacy_ack"] = False
55
+ if "show_privacy_dialog" not in st.session_state:
56
+ st.session_state["show_privacy_dialog"] = True
57
+ if "consent_data_health" not in st.session_state:
58
+ st.session_state["consent_data_health"] = False
59
+ if "consent_ai_support" not in st.session_state:
60
+ st.session_state["consent_ai_support"] = False
61
+ if "consent_images" not in st.session_state:
62
+ st.session_state["consent_images"] = False
63
+
64
+ # ==============================================================================
65
+ # 3. INYECCIÓN DE CSS
66
+ # ==============================================================================
67
+ st.markdown("""
68
+ <style>
69
+ .block-container { padding-top: 3rem; padding-bottom: 5rem; padding-left: 5rem; padding-right: 5rem; max-width: 80% !important; }
70
+ .stApp { background-color: #f4f6f9; color: #333333; }
71
+ .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); }
72
+ .stApp .header-container h1, .stApp .header-container p, .stMarkdown .header-container p, .stMarkdown .header-container h1 { color: white !important; border-bottom: none !important; }
73
+ 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; }
74
+ h1, h2, h3, h4, h5 { color: #003366 !important; }
75
+ h2 { border-bottom: 2px solid #667eea; padding-bottom: 8px; margin-bottom: 20px !important; }
76
+ .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; }
77
+ .stNumberInput input, [data-testid="stFileUploaderDropzone"] section, [data-testid="stFileUploaderDropzone"] div, [data-testid="stFileUploaderDropzone"] span { color: #333333 !important; }
78
+ .stNumberInput button { background-color: #f0f2f6 !important; color: #333333 !important; }
79
+ [data-testid="stFileUploaderDropzone"] { background-color: #f8f9fa !important; border: 2px dashed #667eea !important; }
80
+ [data-testid="stFileUploaderDropzone"] button { background-color: #ffffff !important; color: #003366 !important; border: 1px solid #003366 !important; }
81
+ .stCheckbox label p, .stCheckbox label span, label p, label span, .stMarkdown p:not(.header-container p) { color: #333333 !important; font-weight: 500 !important; }
82
+ 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; }
83
+ div.stButton > button:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4) !important; }
84
+ div.stButton > button p { color: white !important; }
85
+ .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; }
86
+ .privacy-banner h3, .privacy-banner p, .privacy-banner li { color: #5b3b00 !important; }
87
+ .privacy-note-box { background: #f8fbff; border: 1px solid #cfe2ff; border-radius: 10px; padding: 14px 16px; margin-bottom: 12px; }
88
+ .privacy-note-box strong, .privacy-note-box p, .privacy-note-box li { color: #0b3a66 !important; }
89
+ .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; }
90
+ .medical-warning strong, .medical-warning p { color: #881337 !important; }
91
+ /* RESPONSIVE MÓVIL */
92
+ @media (max-width: 768px) {
93
+ .block-container {
94
+ padding-left: 1rem !important;
95
+ padding-right: 1rem !important;
96
+ max-width: 100% !important;
97
+ }
98
+ .header-container h1 {
99
+ font-size: 1.3rem !important;
100
+ line-height: 1.4 !important;
101
+ }
102
+ .header-container p {
103
+ font-size: 0.85rem !important;
104
+ }
105
+ }
106
+
107
+ /* FORZADO ANTI-FLICKERING (ESTABILIZADOR DE IMAGEN) */
108
+ [data-testid="stImage"], [data-testid="stImage"] > img {
109
+ zoom: 1 !important;
110
+ transform: translateZ(0) !important;
111
+ backface-visibility: hidden !important;
112
+ -webkit-transform: translate3d(0,0,0) !important;
113
+ perspective: 1000px !important;
114
+ }
115
+ </style>
116
+ """, unsafe_allow_html=True)
117
+
118
+ # ==============================================================================
119
+ # 3.1 FUNCIONES DE PRIVACIDAD
120
+ # ==============================================================================
121
+ def render_main_privacy_messages():
122
+ st.markdown("""
123
+ <div class="privacy-banner">
124
+ <h3>🔐 Aviso importante sobre privacidad y uso responsable</h3>
125
+ <p>Esta plataforma tiene <strong>fines académicos</strong> y utiliza <strong>inteligencia artificial como apoyo</strong> para el análisis dermatológico.
126
+ <strong>No sustituye</strong> el criterio médico, el diagnóstico profesional ni la atención clínica presencial.</p>
127
+ <ul>
128
+ <li>Ingrese únicamente la <strong>información mínima necesaria</strong> para el análisis.</li>
129
+ <li>Evite nombres completos, números de identidad, direcciones u otros <strong>identificadores directos</strong>.</li>
130
+ </ul>
131
+ </div>
132
+ """, unsafe_allow_html=True)
133
+
134
+ c1, c2 = st.columns(2)
135
+ with c1:
136
+ st.markdown("""
137
+ <div class="privacy-note-box">
138
+ <strong>📌 Tratamiento de datos y minimización</strong>
139
+ <p>Los datos personales y de salud son sensibles. Por ello, solo deben cargarse los datos estrictamente necesarios para fines académicos.</p>
140
+ </div>
141
+ """, unsafe_allow_html=True)
142
+ with c2:
143
+ st.markdown("""
144
+ <div class="privacy-note-box">
145
+ <strong>🖼️ Uso de imágenes clínicas</strong>
146
+ <p>No cargue imágenes con elementos innecesarios que permitan identificar directamente al paciente, salvo autorización.</p>
147
+ </div>
148
+ """, unsafe_allow_html=True)
149
+
150
+ def open_consent_dialog(force=False):
151
+ dialog_callable = getattr(st, "dialog", None)
152
+
153
+ if dialog_callable is None:
154
+ st.warning("Este entorno no soporta ventanas modales. Consentimiento en línea:")
155
+ with st.container(border=True):
156
+ consent_data = st.checkbox("Comprendo que trato datos sensibles.", key="inline_data")
157
+ consent_ai = st.checkbox("Comprendo que es IA de apoyo.", key="inline_ai")
158
+ consent_img = st.checkbox("Confirmo anonimización.", key="inline_img")
159
+
160
+ if st.button("Aceptar y continuar", key="inline_accept"):
161
+ if consent_data and consent_ai and consent_img:
162
+ st.session_state.update({"privacy_ack": True, "show_privacy_dialog": False})
163
+ st.rerun()
164
+ else:
165
+ st.error("Debe aceptar todos los puntos.")
166
+ return
167
+
168
+ @dialog_callable("Consentimiento informado y privacidad")
169
+ def _dialog():
170
+ st.markdown("""
171
+ Antes de utilizar la plataforma, confirme lo siguiente:
172
+ - Esta herramienta tiene **fines académicos**.
173
+ - Usa **IA como apoyo** y **no sustituye** evaluación médica profesional.
174
+ - Solo ingresará datos **autorizados, anonimizados o seudonimizados**.
175
+ """)
176
+ consent_data = st.checkbox("Comprendo el tratamiento de datos.", key="modal_data")
177
+ consent_ai = st.checkbox("Comprendo que la IA es de apoyo.", key="modal_ai")
178
+ consent_img = st.checkbox("Cuento con autorización para imágenes.", key="modal_img")
179
+
180
+ c1, c2 = st.columns(2)
181
+ with c1:
182
+ if st.button("Aceptar y continuar", use_container_width=True):
183
+ if consent_data and consent_ai and consent_img:
184
+ st.session_state.update({"privacy_ack": True, "show_privacy_dialog": False})
185
+ st.rerun()
186
+ else:
187
+ st.error("Debe aceptar todos los puntos.")
188
+ with c2:
189
+ if st.button("Cerrar", use_container_width=True):
190
+ st.session_state["show_privacy_dialog"] = False
191
+ if force:
192
+ st.warning("Debe aceptar para continuar.")
193
+ st.rerun()
194
+
195
+ _dialog()
196
+
197
+ # ==============================================================================
198
+ # 4. CLASES DE VISIÓN (GRAD-CAM)
199
+ # ==============================================================================
200
+ class FeatureExtractor:
201
+ def __init__(self, model, target_layers):
202
+ self.activations = {}
203
+ for name, layer in target_layers.items():
204
+ layer.register_forward_hook(self.get_hook(name))
205
+
206
+ def get_hook(self, name):
207
+ def hook(model, input, output):
208
+ self.activations[name] = output.detach()
209
+ return hook
210
+
211
+ class GradCAM:
212
+ def __init__(self, model, target_layer):
213
+ self.model = model
214
+ self.activations = None
215
+ self.gradients = None
216
+ target_layer.register_forward_hook(self.save_activation)
217
+ target_layer.register_full_backward_hook(self.save_gradient)
218
+
219
+ def save_activation(self, module, input, output):
220
+ self.activations = output
221
+
222
+ def save_gradient(self, module, grad_input, grad_output):
223
+ self.gradients = grad_output[0]
224
+
225
+ def __call__(self, x):
226
+ self.model.zero_grad()
227
+ output = self.model(x)
228
+ idx = torch.argmax(output, dim=1)
229
+ output[0, idx].backward()
230
+
231
+ grads = self.gradients.cpu().data.numpy()[0]
232
+ fmaps = self.activations.cpu().data.numpy()[0]
233
+ weights = np.mean(grads, axis=(1, 2))
234
+ cam = np.zeros(fmaps.shape[1:], dtype=np.float32)
235
+
236
+ for i, w in enumerate(weights):
237
+ cam += w * fmaps[i]
238
+
239
+ cam = np.maximum(cam, 0)
240
+ cam = cv2.resize(cam, (380, 380))
241
+ cam = (cam - np.min(cam)) / (np.max(cam) + 1e-8)
242
+
243
+ return cam, output, idx
244
+
245
+ def plot_feature_maps(activations, layer_name, title, output_file):
246
+ act = activations[layer_name].squeeze().cpu().numpy()
247
+ mean_act = np.mean(act, axis=(1, 2))
248
+ top_indices = np.argsort(mean_act)[::-1][:16]
249
+
250
+ fig, axes = plt.subplots(4, 4, figsize=(10, 10))
251
+ fig.suptitle(title, fontsize=16)
252
+
253
+ for idx, ax in enumerate(axes.flat):
254
+ if idx < len(top_indices):
255
+ fmap_idx = top_indices[idx]
256
+ fmap = act[fmap_idx]
257
+ fmap = (fmap - np.min(fmap)) / (np.max(fmap) + 1e-8)
258
+ ax.imshow(fmap, cmap='viridis')
259
+ ax.set_title(f"Filtro {fmap_idx}", fontsize=8)
260
+ ax.axis('off')
261
+
262
+ plt.tight_layout()
263
+ plt.savefig(output_file)
264
+ plt.close()
265
+
266
+ return output_file
267
+
268
+ @st.cache_resource
269
+ def cargar_tu_modelo_especifico(ruta_pth):
270
+ model = efficientnet_b4(weights=None)
271
+ num_ftrs = model.classifier[1].in_features
272
+ model.classifier = nn.Sequential(
273
+ nn.Dropout(p=0.45),
274
+ nn.Linear(num_ftrs, 3)
275
+ )
276
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
277
+
278
+ try:
279
+ state_dict = torch.load(ruta_pth, map_location=device)
280
+ model.load_state_dict(state_dict)
281
+ except Exception as e:
282
+ st.error(f"❌ Error cargando pesos: {e}")
283
+ return None
284
+
285
+ model.to(device)
286
+ model.eval()
287
+
288
+ return model
289
+
290
+ transformacion_validacion = transforms.Compose([
291
+ transforms.Resize((380, 380)),
292
+ transforms.ToTensor(),
293
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
294
+ ])
295
+
296
+ def ejecutar_pipeline_gradcam(modelo, ruta_img, temp_dir):
297
+ feature_extractor = FeatureExtractor(
298
+ modelo,
299
+ {'capa_inicial': modelo.features[0], 'capa_final': modelo.features[-1]}
300
+ )
301
+ grad_cam = GradCAM(modelo, modelo.features[-1])
302
+
303
+ pil_img = Image.open(ruta_img).convert('RGB')
304
+ device = next(modelo.parameters()).device
305
+ img_tensor = transformacion_validacion(pil_img).unsqueeze(0).to(device)
306
+
307
+ cam_map, logits, pred_idx = grad_cam(img_tensor)
308
+ probs = F.softmax(logits, dim=1).cpu().data.numpy()[0]
309
+ CLASES_NOMBRES = ['Benigno', 'Melanoma', 'Carcinoma']
310
+
311
+ img_cv = cv2.imread(ruta_img)
312
+ img_cv = cv2.resize(img_cv, (380, 380))
313
+ heatmap = cv2.applyColorMap(np.uint8(255 * cam_map), cv2.COLORMAP_JET)
314
+ superimposed = cv2.addWeighted(img_cv, 0.6, heatmap, 0.4, 0)
315
+
316
+ plt.figure(figsize=(12, 5))
317
+
318
+ plt.subplot(1, 3, 1)
319
+ plt.imshow(cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB))
320
+ plt.title("Original")
321
+ plt.axis('off')
322
+
323
+ plt.subplot(1, 3, 2)
324
+ plt.imshow(cv2.cvtColor(superimposed, cv2.COLOR_BGR2RGB))
325
+ plt.title(f"Atención IA\n({CLASES_NOMBRES[pred_idx]})")
326
+ plt.axis('off')
327
+
328
+ plt.subplot(1, 3, 3)
329
+ bars = plt.bar(CLASES_NOMBRES, probs, color=['green', 'red', 'orange'])
330
+ plt.title("Probabilidades")
331
+ plt.ylim(0, 1.15)
332
+
333
+ for bar in bars:
334
+ plt.text(
335
+ bar.get_x() + bar.get_width() / 2.0,
336
+ bar.get_height() + 0.02,
337
+ f'{bar.get_height()*100:.1f}%',
338
+ ha='center',
339
+ va='bottom',
340
+ fontsize=10,
341
+ fontweight='bold'
342
+ )
343
+
344
+ path_diag = os.path.join(temp_dir, "1_diagnostico_clinico.png")
345
+ plt.savefig(path_diag)
346
+ plt.close()
347
+
348
+ path_bordes = os.path.join(temp_dir, "2_analisis_bordes.png")
349
+ plot_feature_maps(
350
+ feature_extractor.activations,
351
+ 'capa_inicial',
352
+ "BORDES Y FORMAS",
353
+ path_bordes
354
+ )
355
+
356
+ path_patrones = os.path.join(temp_dir, "3_analisis_patrones.png")
357
+ plot_feature_maps(
358
+ feature_extractor.activations,
359
+ 'capa_final',
360
+ "TEXTURA",
361
+ path_patrones
362
+ )
363
+
364
+ return path_diag, path_bordes, path_patrones, CLASES_NOMBRES[pred_idx], probs
365
+
366
+ def analizar_imagen_medica(ruta_imagen, modelo):
367
+ if modelo is None:
368
+ return "Error: Modelo no cargado."
369
+
370
+ CLASES = ['Benigno', 'Melanoma', 'Carcinoma']
371
+
372
+ try:
373
+ image = transformacion_validacion(Image.open(ruta_imagen).convert('RGB')).unsqueeze(0).to(next(modelo.parameters()).device)
374
+ with torch.no_grad():
375
+ probs = torch.nn.functional.softmax(modelo(image), dim=1)
376
+ clase_idx = torch.argmax(probs, 1).item()
377
+
378
+ 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}%"
379
+ except Exception as e:
380
+ return f"Error: {str(e)}"
381
+
382
+ # ==============================================================================
383
+ # 5. GENERADOR PDF
384
+ # ==============================================================================
385
+ class PDFReport(FPDF):
386
+ def __init__(self, paciente_info):
387
+ super().__init__()
388
+ self.paciente_info = paciente_info
389
+
390
+ def header(self):
391
+ self.set_font('Arial', 'B', 15)
392
+ self.cell(0, 10, 'DermaRAG - Informe Diagnóstico', 0, 1, 'C')
393
+ self.line(10, 20, 200, 20)
394
+ self.ln(5)
395
+
396
+ def footer(self):
397
+ self.set_y(-20)
398
+ # Disclaimer medico-legal (en cada pagina)
399
+ self.set_font('Arial', 'I', 7)
400
+ self.set_text_color(150, 30, 30)
401
+ disclaimer = ("AVISO MEDICO-LEGAL: Esta herramienta tiene fines academicos, "
402
+ "usa IA como apoyo y no sustituye evaluacion medica profesional.")
403
+ self.multi_cell(0, 3, disclaimer, 0, 'C')
404
+ # Datos del paciente y numero de pagina
405
+ self.set_text_color(0, 0, 0)
406
+ self.set_font('Arial', 'I', 8)
407
+ self.cell(0, 5, f"ID Paciente: {self.paciente_info['id']} | Pag {self.page_no()}", 0, 0, 'C')
408
+
409
+ def chapter_title(self, label):
410
+ self.set_font('Arial', 'B', 12)
411
+ self.set_fill_color(200, 220, 255)
412
+ self.cell(0, 6, label, 0, 1, 'L', 1)
413
+ self.ln(4)
414
+
415
+ def chapter_body(self, text):
416
+ self.set_font('Arial', '', 11)
417
+ self.multi_cell(0, 5, text)
418
+ self.ln()
419
+
420
+ # ==============================================================================
421
+ # 6. INTERFAZ DE USUARIO STREAMLIT
422
+ # ==============================================================================
423
+ st.markdown("""
424
+ <div class="header-container">
425
+ <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>
426
+ <p style="color: white !important;">IA Explicable | Guías AAD/BAD/NCCN</p>
427
+ </div>
428
+ """, unsafe_allow_html=True)
429
+
430
+ # Validación de Token GROQ
431
+ GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
432
+ if not GROQ_API_KEY:
433
+ st.error("⚠️ Falta el token de Groq. Añade `GROQ_API_KEY` a tus Secrets.")
434
+ st.stop()
435
+
436
+ # Funciones de privacidad en la vista principal
437
+ render_main_privacy_messages()
438
+
439
+ if not st.session_state.get("privacy_ack", False) and st.session_state.get("show_privacy_dialog", True):
440
+ open_consent_dialog(force=False)
441
+
442
+ RUTA_MODELO = 'mejor_modelo_v5.pth'
443
+
444
+ if os.path.exists(RUTA_MODELO):
445
+ modelo_cnn = cargar_tu_modelo_especifico(RUTA_MODELO)
446
+ else:
447
+ st.error(f"⚠️ Falta '{RUTA_MODELO}'")
448
+ modelo_cnn = None
449
+
450
+ col_izq, col_der = st.columns([1, 1], gap="large")
451
+
452
+ with col_izq:
453
+ with st.container(border=True):
454
+ st.markdown("## 📋 Datos del Paciente")
455
+ c1, c2 = st.columns(2)
456
+ nombre = c1.text_input("Nombre del paciente *", placeholder="Ej. Gerardo García")
457
+ edad = c1.number_input("Edad *", value=0, min_value=0, max_value=120, step=1)
458
+ fototipo = c1.selectbox(
459
+ "Fototipo Fitzpatrick *",
460
+ ["Tipo I - Piel muy clara", "Tipo II - Piel clara", "Tipo III - Piel intermedia",
461
+ "Tipo IV - Piel morena clara", "Tipo V - Piel morena", "Tipo VI - Piel negra"],
462
+ index=None,
463
+ placeholder="Selecciona una opción..."
464
+ )
465
+ id_paciente = c2.text_input("ID Paciente *", placeholder="Ej. PAC-2025-001")
466
+ sexo = c2.selectbox("Sexo *", ["Masculino", "Femenino", "Otro"], index=None, placeholder="Seleccionar...")
467
+
468
+ with st.container(border=True):
469
+ st.markdown("## 🔬 Datos Clínicos de la Lesión")
470
+ localizacion = st.selectbox(
471
+ "Localización Anatómica *",
472
+ ["Tronco (pecho/espalda)", "Cabeza y Cuello", "Extremidades Superiores",
473
+ "Extremidades Inferiores", "Manos/Pies (Acral)", "Mucosas"],
474
+ index=None,
475
+ placeholder="Seleccionar ubicación..."
476
+ )
477
+ cc1, cc2 = st.columns(2)
478
+ tamano = cc1.number_input("Tamaño (mm) *", value=0, min_value=0, step=1)
479
+ evolucion = cc2.number_input("Evolución (meses)", value=0, min_value=0, step=1)
480
+ sintomas = st.text_area("Síntomas Asociados", placeholder="Ej. Prurito, sangrado, asimetría...", height=80)
481
+ historia = st.text_area("Antecedentes Relevantes", placeholder="Ej. Historia familiar de melanoma...", height=80)
482
+
483
+ with st.container(border=True):
484
+ st.markdown("## 🔎 Criterios ABCDE (Dermoscopia Visual)")
485
+ col_checks = st.columns(5)
486
+ check_a = col_checks[0].checkbox("A", value=False, help="Asimetría")
487
+ check_b = col_checks[1].checkbox("B", value=False, help="Bordes Irregulares")
488
+ check_c = col_checks[2].checkbox("C", value=False, help="Color (Policromía)")
489
+ check_d = col_checks[3].checkbox("D", value=False, help="Diámetro > 6mm")
490
+ check_e = col_checks[4].checkbox("E", value=False, help="Evolución")
491
+
492
+ with col_der:
493
+ with st.container(border=True):
494
+ st.markdown("## 📸 Imagen de la Lesión Cutánea")
495
+ uploaded_file = st.file_uploader("Sube imagen (JPG/PNG)", type=["jpg", "png", "jpeg"])
496
+
497
+ if uploaded_file:
498
+ img_temp_pil = Image.open(uploaded_file)
499
+ w, h = img_temp_pil.size
500
+ size_kb = uploaded_file.size / 1024
501
+
502
+ st.success(f"✅ Archivo: {uploaded_file.name} | {size_kb:.2f} KB | {w}x{h} px")
503
+ st.image(img_temp_pil, caption="Vista Previa", width=375)
504
+
505
+ st.markdown("""
506
+ <div class="medical-warning">
507
+ <strong>Antes de analizar:</strong> Confirme consentimiento de privacidad.
508
+ </div>
509
+ """, unsafe_allow_html=True)
510
+
511
+ analyze_btn = st.button("🔍 Analizar con IA Multiagente + GradCAM", use_container_width=True)
512
+
513
+ # ==============================================================================
514
+ # 7. EJECUCIÓN DEL SISTEMA
515
+ # ==============================================================================
516
+ if analyze_btn:
517
+ if not st.session_state.get("privacy_ack", False):
518
+ st.session_state["show_privacy_dialog"] = True
519
+ open_consent_dialog(force=True)
520
+ st.stop()
521
+
522
+ if uploaded_file and modelo_cnn:
523
+ if not nombre or localizacion is None:
524
+ st.error("⚠️ Completa al menos: Nombre y Localización.")
525
+ else:
526
+ with st.status("🔄 Ejecutando Sistema...", expanded=True) as status:
527
+ temp_dir = tempfile.mkdtemp()
528
+ ruta_input = os.path.join(temp_dir, "input.jpg")
529
+
530
+ with open(ruta_input, "wb") as f:
531
+ f.write(uploaded_file.getvalue())
532
+
533
+ st.write("🧠 Percepción Visual...")
534
+ t0 = time.time()
535
+
536
+ path_diag, path_bordes, path_patrones, pred_clase, probs = ejecutar_pipeline_gradcam(
537
+ modelo_cnn,
538
+ ruta_input,
539
+ temp_dir
540
+ )
541
+
542
+ resultado_vision = analizar_imagen_medica(ruta_input, modelo_cnn)
543
+ latencia_vision = time.time() - t0
544
+
545
+ st.write("⚕️ Razonamiento Clínico Groq...")
546
+
547
+ # --- AQUÍ ESTÁ LA SOLUCIÓN DEFINITIVA AL ERROR PYDANTIC ---
548
+ # Usamos el LLM nativo de CrewAI (que funciona bajo el ecosistema Pydantic v2)
549
+ llm_agentes = LLM(
550
+ model="groq/llama-3.3-70b-versatile",
551
+ api_key=GROQ_API_KEY,
552
+ temperature=0.5
553
+ )
554
+
555
+ # 🔧 LLM DEDICADO PARA EL ESPECIALISTA: temperatura baja = más obediencia,
556
+ # menos creatividad, citas más literales (clave para subir Fidelidad RAGas).
557
+ # Modelo: GPT-OSS 120B (modelo razonador top de Groq, excelente siguiendo
558
+ # instrucciones estrictas y formatos largos)
559
+ # max_tokens alto porque es razonador y consume tokens pensando antes de escribir
560
+ llm_especialista = LLM(
561
+ model="groq/openai/gpt-oss-120b",
562
+ api_key=GROQ_API_KEY,
563
+ temperature=0.1,
564
+ max_tokens=8000
565
+ )
566
+
567
+ hallazgos_lista = []
568
+ if check_a: hallazgos_lista.append("Asimetría")
569
+ if check_b: hallazgos_lista.append("Bordes")
570
+ if check_c: hallazgos_lista.append("Policromía")
571
+ if check_d: hallazgos_lista.append(f"Diámetro > 6mm ({tamano}mm)")
572
+ if check_e: hallazgos_lista.append("Evolución")
573
+
574
+ hallazgos_txt = ", ".join(hallazgos_lista) if hallazgos_lista else "Ninguno"
575
+
576
+ task_med = (
577
+ f"DATOS: {edad} años, {sexo}, Fototipo: {fototipo}\n"
578
+ f"CLÍNICA: {localizacion}, {tamano}mm, {evolucion} meses\n"
579
+ f"SÍNTOMAS: {sintomas}\n"
580
+ f"ANTECEDENTES: {historia}\n"
581
+ f"ABCDE: {hallazgos_txt}\n"
582
+ f"VISION IA: [{resultado_vision}]"
583
+ )
584
+
585
+ medico_atencion_primaria = Agent(
586
+ role='Auditor Clínico',
587
+ goal=f'Validar coherencia Grad-CAM vs clínica. Contexto: {task_med}',
588
+ backstory='Especialista en Triaje. Tu filosofía: "La IA es herramienta". IDIOMA OBLIGATORIO: EXCLUSIVAMENTE ESPAÑOL.',
589
+ verbose=True,
590
+ allow_delegation=False,
591
+ llm=llm_agentes
592
+ )
593
+
594
+ herramienta_rag = BuscadorGuiasClinicas()
595
+
596
+ especialista_piel = Agent(
597
+ role='Oncólogo Dermatólogo Basado en Evidencia',
598
+ goal=(
599
+ 'Generar un plan oncológico respaldado EXCLUSIVAMENTE por las guías '
600
+ 'clínicas indexadas (NCCN, AAD, BAD, oncosur). NUNCA respondes de '
601
+ 'memoria. Tu primer acto SIEMPRE es consultar la herramienta de búsqueda.'
602
+ ),
603
+ backstory=(
604
+ 'Eres un oncólogo dermatólogo certificado que SOLO confía en evidencia '
605
+ 'documentada. Tu protocolo personal es: "Sin guía, no hay respuesta". '
606
+ 'Antes de emitir cualquier opinión, SIEMPRE consultas las guías clínicas '
607
+ 'mediante la herramienta disponible. OBLIGACIÓN ABSOLUTA: REDACTAR EN '
608
+ 'ESPAÑOL PERFECTO. Tus respuestas siempre incluyen citas textuales con '
609
+ 'la fuente exacta.'
610
+ ),
611
+ verbose=True,
612
+ allow_delegation=False,
613
+ tools=[herramienta_rag],
614
+ max_iter=12,
615
+ llm=llm_especialista
616
+ )
617
+
618
+ task_atencion_primaria = Task(
619
+ description=f"Analiza: {task_med}. REGLA: 100% ESPAÑOL. Fidelidad a IA. Traducción Semiológica.",
620
+ agent=medico_atencion_primaria,
621
+ expected_output="1. Validación Visión\n2. Resumen Semiológico\n3. Solicitud Interconsulta"
622
+ )
623
+
624
+ task_especialista = Task(
625
+ description=(
626
+ f"Eres Oncólogo Dermatólogo. El paciente presenta:\n"
627
+ f"- Predicción IA (CNN): {pred_clase}\n"
628
+ f"- Localización: {localizacion}\n"
629
+ f"- Tamaño: {tamano} mm\n"
630
+ f"- Evolución: {evolucion} meses\n"
631
+ f"- Fototipo: {fototipo}\n"
632
+ f"- Hallazgos ABCDE: {hallazgos_txt}\n"
633
+ f"- Síntomas: {sintomas}\n"
634
+ f"- Antecedentes: {historia}\n\n"
635
+ "═══════════════════════════════════════════════\n"
636
+ "PASO 1 OBLIGATORIO — ANTES de redactar UNA sola palabra del informe, "
637
+ "DEBES llamar la herramienta 'buscador_guias_clinicas' AL MENOS 5 VECES "
638
+ "con estas queries EXACTAS, una por una:\n\n"
639
+ f" Query 1: 'protocolo tratamiento {pred_clase.lower()}'\n"
640
+ f" Query 2: 'márgenes quirúrgicos {pred_clase.lower()}'\n"
641
+ f" Query 3: 'cirugía Mohs {pred_clase.lower()}'\n"
642
+ f" Query 4: 'estadificación {pred_clase.lower()} factores riesgo'\n"
643
+ f" Query 5: 'seguimiento {pred_clase.lower()} recurrencia'\n\n"
644
+ "Si no llamas la herramienta 5 veces, tu respuesta será RECHAZADA.\n"
645
+ "═══════════════════════════��═══════════════════\n\n"
646
+ "PASO 2 — REGLA DE FIDELIDAD ABSOLUTA (CRÍTICO):\n"
647
+ "1. CADA AFIRMACIÓN clínica del informe debe estar respaldada por una cita "
648
+ "TEXTUAL Y LITERAL (copy-paste exacto, palabra por palabra) de un fragmento "
649
+ "recuperado por la herramienta. PROHIBIDO parafrasear, resumir o reformular.\n"
650
+ "2. Antes de escribir cada oración, identifica primero el fragmento que la "
651
+ "respalda. Si no encuentras un fragmento que diga LITERALMENTE eso, NO LO "
652
+ "ESCRIBAS.\n"
653
+ "3. Las citas en la sección Referencias deben ser COPIA EXACTA de los "
654
+ "fragmentos del RAG. No invento, no embellezco, no acorto.\n\n"
655
+ "═══════════════════════════════════════════════\n"
656
+ "PASO 3 — REGLA CRÍTICA DE CANTIDAD vs CALIDAD:\n"
657
+ "Mínimo 3 referencias, máximo 6. PROHIBIDO rellenar con citas inventadas "
658
+ "para llegar a un número objetivo. Es 1000 veces preferible 3 referencias "
659
+ "100% reales que 8 referencias mezcladas con invenciones.\n\n"
660
+ "ANTES de escribir cada referencia, pregúntate: ¿esta cita aparece "
661
+ "TEXTUALMENTE en alguno de los fragmentos que me devolvió la herramienta? "
662
+ "Si la respuesta es 'no estoy seguro', NO LA INCLUYAS.\n\n"
663
+ "Las citas que mencionan al paciente concreto (su edad, tamaño de lesión, "
664
+ "síntomas específicos) son SIEMPRE inventadas — las guías clínicas hablan "
665
+ "de poblaciones, no de pacientes individuales. Si una de tus 'citas' "
666
+ "menciona '50mm' o 'cabeza y cuello del paciente', es INVENTADA. "
667
+ "Bórrala.\n\n"
668
+ "PASO 4 — FUENTES VÁLIDAS (LISTA BLANCA):\n"
669
+ "Las únicas fuentes válidas son los archivos .pdf que aparezcan en los "
670
+ "fragmentos recuperados por la herramienta (ej: 'COL_D1_GUIA COMPLETA "
671
+ "carcinoma basocelular.pdf', 'jnccn-article-p1181.pdf', 'cutaneous_melanoma.pdf', "
672
+ "'guia-oncosur-de-melanoma.pdf', 'basoespino.pdf', etc.).\n\n"
673
+ "PROHIBIDO ABSOLUTAMENTE citar como fuente:\n"
674
+ " ❌ 'Validación Visión'\n"
675
+ " ❌ 'Resumen Semiológico'\n"
676
+ " ❌ 'Análisis Clínico'\n"
677
+ " ❌ Cualquier nombre que NO termine en .pdf\n"
678
+ " ❌ Cualquier output del agente anterior (auditor clínico)\n\n"
679
+ "Si no tienes 3 fragmentos del RAG con archivos .pdf reales, usa solo los "
680
+ "que sí tengas (mínimo 3) y NO inventes los demás.\n"
681
+ "═══════════════════════════════════════════════\n\n"
682
+ "PASO 5 — Redacta el informe en ESPAÑOL siguiendo el expected_output. "
683
+ "Cada sección debe tener AL MENOS 4 oraciones sustantivas, todas con (ver Ref. N).\n\n"
684
+ "Si una query devuelve 'No se encontró información relevante', intenta "
685
+ f"con queries más cortas (ej: '{pred_clase.lower()}', 'biopsia piel')."
686
+ ),
687
+ agent=especialista_piel,
688
+ context=[task_atencion_primaria],
689
+ expected_output=(
690
+ "### 1. Diagnóstico Presuntivo\n"
691
+ "[4+ oraciones integrando IA, ABCDE, contexto. Cada afirmación con (ver Ref. N).]\n\n"
692
+ "### 2. Protocolo de Tratamiento\n"
693
+ "[4+ oraciones: técnica, márgenes, alternativas. Cada afirmación con (ver Ref. N).]\n\n"
694
+ "### 3. Seguimiento\n"
695
+ "[4+ oraciones: frecuencia, signos de alarma, autoexamen. Cada afirmación con (ver Ref. N).]\n\n"
696
+ "### Referencias\n"
697
+ "(SOLO citas LITERALES copy-paste de fragmentos del RAG. Solo fuentes .pdf reales. "
698
+ "Mínimo 3, máximo 6. NUNCA mencionar al paciente individual en una cita.)\n\n"
699
+ "**Ref. 1:** \"[copy-paste LITERAL del fragmento, sin modificar nada]\"\n"
700
+ "_Fuente: nombre_archivo.pdf, página X_\n\n"
701
+ "**Ref. 2:** \"[copy-paste LITERAL del fragmento]\"\n"
702
+ "_Fuente: nombre_archivo.pdf, página Y_\n\n"
703
+ "**Ref. 3:** \"[copy-paste LITERAL del fragmento]\"\n"
704
+ "_Fuente: nombre_archivo.pdf, página Z_\n\n"
705
+ "(Agrega Ref. 4-6 SOLO si tienes fragmentos reales adicionales del RAG.)"
706
+ )
707
+ )
708
+
709
+ # 🔧 FIX RAGAS: Limpiamos el archivo de memoria RAG antes de cada corrida
710
+ # para no mezclar contextos de pacientes anteriores. La herramienta
711
+ # BuscadorGuiasClinicas escribe ahí cada fragmento que recupera de ChromaDB.
712
+ if os.path.exists("memoria_rag.txt"):
713
+ os.remove("memoria_rag.txt")
714
+
715
+ crew = Crew(
716
+ agents=[medico_atencion_primaria, especialista_piel],
717
+ tasks=[task_atencion_primaria, task_especialista],
718
+ verbose=True,
719
+ process=Process.sequential,
720
+ language='es'
721
+ )
722
+
723
+ st.session_state['resultado_final'] = crew.kickoff()
724
+
725
+ # 🔧 FIX RAGAS: Verificación de que el agente realmente usó la herramienta RAG.
726
+ # Guardamos en session_state para mostrar FUERA del st.status (que se colapsa).
727
+ if os.path.exists("memoria_rag.txt"):
728
+ with open("memoria_rag.txt", "r", encoding="utf-8") as f:
729
+ n_frags = len([x for x in f.read().split("\n\n") if x.strip()])
730
+ st.session_state['rag_n_frags'] = n_frags
731
+ else:
732
+ st.session_state['rag_n_frags'] = 0
733
+
734
+ # 🔧 DETECTOR DE REFERENCIAS FALSAS
735
+ resultado_str = str(st.session_state.get('resultado_final', ''))
736
+ fuentes_falsas = [
737
+ "Validación Visión", "Resumen Semiológico", "Análisis Clínico",
738
+ "Auditor Clínico", "Solicitud Interconsulta", "Validación de Visión",
739
+ "Resumen Semiologico", "Validacion Vision"
740
+ ]
741
+ st.session_state['refs_falsas'] = [f for f in fuentes_falsas if f in resultado_str]
742
+
743
+ # 🔧 VERIFICADOR DE CITAS: compara cada Ref. del informe contra los
744
+ # fragmentos reales del RAG. Si una cita no aparece literalmente (o casi),
745
+ # la marca como sospechosa de invención.
746
+ import re
747
+
748
+ def normalizar(texto):
749
+ """Quita puntuación, espacios extras, pasa a minúsculas para comparar."""
750
+ texto = re.sub(r'[^\w\s]', ' ', texto.lower())
751
+ texto = re.sub(r'\s+', ' ', texto).strip()
752
+ return texto
753
+
754
+ def verificar_cita(cita, fragmentos_normalizados):
755
+ """
756
+ Devuelve True si la cita aparece (al menos parcialmente) en algún fragmento.
757
+ Usa matching de ventanas de 8 palabras consecutivas — basta que UNA ventana
758
+ coincida para considerar la cita como respaldada.
759
+ """
760
+ cita_norm = normalizar(cita)
761
+ palabras = cita_norm.split()
762
+ if len(palabras) < 5:
763
+ return False
764
+ # Generar ventanas deslizantes de 8 palabras
765
+ ventana_size = min(8, len(palabras))
766
+ for i in range(len(palabras) - ventana_size + 1):
767
+ ventana = ' '.join(palabras[i:i + ventana_size])
768
+ for frag_norm in fragmentos_normalizados:
769
+ if ventana in frag_norm:
770
+ return True
771
+ return False
772
+
773
+ # Cargar fragmentos del RAG y normalizarlos
774
+ citas_verificadas = []
775
+ if os.path.exists("memoria_rag.txt"):
776
+ with open("memoria_rag.txt", "r", encoding="utf-8") as f:
777
+ fragmentos = [x.strip() for x in f.read().split("\n\n") if x.strip()]
778
+ fragmentos_norm = [normalizar(frag) for frag in fragmentos]
779
+
780
+ # Extraer todas las citas del formato: Ref. N: "..." o **Ref. N:** "..."
781
+ patron_cita = r'\*?\*?Ref\.?\s*(\d+):?\*?\*?\s*[""]([^""]+)[""]'
782
+ matches = re.findall(patron_cita, resultado_str)
783
+
784
+ for num_ref, texto_cita in matches:
785
+ es_real = verificar_cita(texto_cita, fragmentos_norm)
786
+ citas_verificadas.append({
787
+ 'num': num_ref,
788
+ 'texto': texto_cita[:100] + ('...' if len(texto_cita) > 100 else ''),
789
+ 'real': es_real
790
+ })
791
+
792
+ st.session_state['citas_verificadas'] = citas_verificadas
793
+
794
+ st.session_state.update({
795
+ 'diagnostico_generado': True,
796
+ 'pred_clase': pred_clase,
797
+ 'probs': probs,
798
+ 'path_diag': path_diag,
799
+ 'path_bordes': path_bordes,
800
+ 'path_patrones': path_patrones,
801
+ 'temp_dir': temp_dir,
802
+ 'ragas_scores': None,
803
+ 'pdf_bytes': None, # 🔧 Invalidar caché del PDF para forzar regeneración
804
+ 'pdf_para_id': None
805
+ })
806
+
807
+ latencia_total = time.time() - t0
808
+ status.update(label=f"✅ Diagnóstico en {latencia_total:.2f}s", state="complete")
809
+
810
+ archivo_logs = "logs_latencia.csv"
811
+ if not os.path.exists(archivo_logs):
812
+ with open(archivo_logs, mode='w', newline='') as file:
813
+ writer = csv.writer(file)
814
+ writer.writerow(["Fecha", "ID_Paciente", "Latencia_Vision_seg", "Latencia_Total_seg"])
815
+
816
+ with open(archivo_logs, mode='a', newline='') as file:
817
+ writer = csv.writer(file)
818
+ fecha_actual = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
819
+ writer.writerow([fecha_actual, id_paciente, round(latencia_vision, 2), round(latencia_total, 2)])
820
+ else:
821
+ st.warning("⚠️ Por favor sube una imagen para proceder.")
822
+
823
+ # ==============================================================================
824
+ # 8. RENDERIZADO FUERA DEL BOTÓN Y RAGAS MANUAL
825
+ # ==============================================================================
826
+ if st.session_state.get('diagnostico_generado', False):
827
+ st.markdown("---")
828
+
829
+ # 🔧 BANNERS DE VERIFICACIÓN RAG (visibles fuera del st.status colapsado)
830
+ n_frags = st.session_state.get('rag_n_frags', 0)
831
+ if n_frags > 0:
832
+ st.info(f"🔍 RAG recuperó **{n_frags} fragmentos** de las guías clínicas durante el análisis.")
833
+ else:
834
+ st.warning("⚠️ El agente NO invocó la herramienta RAG en esta corrida. Las métricas RAGas serán 0.")
835
+
836
+ refs_falsas = st.session_state.get('refs_falsas', [])
837
+ if refs_falsas:
838
+ st.warning(
839
+ f"⚠️ **Referencias inventadas detectadas:** {', '.join(refs_falsas)}. "
840
+ f"El agente citó el output del agente anterior en vez de fragmentos reales del RAG. "
841
+ f"Esto bajará la Fidelidad RAGas. Considera regenerar el diagnóstico."
842
+ )
843
+
844
+ # 🔧 VERIFICADOR DE CITAS: muestra el desglose de cuántas refs son reales vs inventadas
845
+ citas_verif = st.session_state.get('citas_verificadas', [])
846
+ if citas_verif:
847
+ n_total = len(citas_verif)
848
+ n_reales = sum(1 for c in citas_verif if c['real'])
849
+ n_inventadas = n_total - n_reales
850
+ ratio = n_reales / n_total if n_total > 0 else 0
851
+
852
+ if ratio >= 0.8:
853
+ st.success(
854
+ f"✅ **Verificación de citas:** {n_reales}/{n_total} referencias respaldadas "
855
+ f"por fragmentos reales del RAG ({ratio*100:.0f}%)."
856
+ )
857
+ elif ratio >= 0.5:
858
+ st.warning(
859
+ f"⚠️ **Verificación de citas:** solo {n_reales}/{n_total} referencias están "
860
+ f"respaldadas por el RAG ({ratio*100:.0f}%). Las demás parecen inventadas."
861
+ )
862
+ else:
863
+ st.error(
864
+ f"❌ **Verificación de citas:** solo {n_reales}/{n_total} referencias son reales "
865
+ f"({ratio*100:.0f}%). El modelo está alucinando la mayoría de las citas."
866
+ )
867
+
868
+ # Desglose detallado en expander
869
+ with st.expander(f"🔎 Ver desglose de las {n_total} citas"):
870
+ for c in citas_verif:
871
+ icono = "✅" if c['real'] else "❌"
872
+ st.markdown(f"{icono} **Ref. {c['num']}:** _{c['texto']}_")
873
+
874
+ st.subheader("👁️ Análisis Explicable y Auditoría")
875
+
876
+ t1, t2, t3, t4 = st.tabs(["Diagnóstico IA", "Bordes (Capa Baja)", "Patrones (Capa Alta)", "📊 Auditoría RAGas"])
877
+
878
+ with t1:
879
+ st.image(st.session_state['path_diag'], use_container_width=True)
880
+ with t2:
881
+ st.image(st.session_state['path_bordes'], use_container_width=True)
882
+ with t3:
883
+ st.image(st.session_state['path_patrones'], use_container_width=True)
884
+
885
+ with t4:
886
+ st.markdown("### Auditoría Clínica RAGas (Ejecución Manual)")
887
+
888
+ if st.button("🚀 Ejecutar Auditoría", use_container_width=True):
889
+ with st.spinner("Auditando con IA Juez Groq..."):
890
+ try:
891
+ # 🔧 MEJORA C: Juez RAGas upgradeado a GPT-OSS 120B (modelo razonador)
892
+ # para evaluación más rigurosa y consistente que Llama 3.3 70B.
893
+ # ChatOpenAI usa el endpoint OpenAI-compatible de Groq directamente.
894
+ llm_juez = ChatOpenAI(
895
+ api_key=GROQ_API_KEY,
896
+ base_url="https://api.groq.com/openai/v1",
897
+ model="openai/gpt-oss-120b",
898
+ temperature=0,
899
+ max_tokens=16000
900
+ )
901
+ embeddings_juez = HuggingFaceEmbeddings(
902
+ model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
903
+ )
904
+
905
+ # 🔧 FIX RAGAS: Leer los fragmentos reales que la herramienta
906
+ # BuscadorGuiasClinicas escribió en memoria_rag.txt durante el kickoff.
907
+ ctx = []
908
+ if os.path.exists("memoria_rag.txt"):
909
+ with open("memoria_rag.txt", "r", encoding="utf-8") as f:
910
+ contenido = f.read().strip()
911
+ ctx = [frag.strip() for frag in contenido.split("\n\n") if frag.strip()]
912
+
913
+ if not ctx:
914
+ 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.")
915
+ st.stop()
916
+
917
+ res_txt = str(st.session_state['resultado_final'])
918
+ # 🔧 PREGUNTA AMPLIADA: el informe cubre 3 secciones (diagnóstico, protocolo,
919
+ # seguimiento). Si la pregunta solo menciona "protocolo", la Relevancia baja
920
+ # porque las preguntas hipotéticas que RAGas genera no coinciden.
921
+ pregunta = (
922
+ f"¿Cuál es el diagnóstico presuntivo, el protocolo de tratamiento "
923
+ f"basado en guías clínicas, y el plan de seguimiento recomendado para "
924
+ f"un paciente con sospecha de {st.session_state['pred_clase']} "
925
+ f"de {tamano}mm localizado en {localizacion}, considerando los hallazgos "
926
+ f"ABCDE y la evidencia de las guías oncológicas?"
927
+ )
928
+
929
+ dataset = Dataset.from_dict({
930
+ "question": [pregunta],
931
+ "contexts": [ctx],
932
+ "answer": [res_txt]
933
+ })
934
+
935
+ res = evaluate(
936
+ dataset=dataset,
937
+ metrics=[Faithfulness(), AnswerRelevancy(strictness=1)],
938
+ llm=llm_juez,
939
+ embeddings=embeddings_juez,
940
+ raise_exceptions=True
941
+ )
942
+
943
+ def s_score(c):
944
+ for col in res.to_pandas().columns:
945
+ if c.lower() in col.lower():
946
+ return 0.0 if math.isnan(res.to_pandas()[col][0]) else res.to_pandas()[col][0]
947
+ return 0.0
948
+
949
+ st.session_state['ragas_scores'] = {
950
+ 'f': s_score('faithfulness'),
951
+ 'r': s_score('relevancy')
952
+ }
953
+ except Exception as e:
954
+ st.error(f"Error RAGas: {e}")
955
+
956
+ if st.session_state.get('ragas_scores'):
957
+ c_r1, c_r2 = st.columns(2)
958
+
959
+ def fmt(s):
960
+ color = 'green' if s > 0.8 else 'orange' if s > 0.6 else 'red'
961
+ return f"<span style='color: {color}; font-size:24px; font-weight:bold;'>{s:.2f}</span>"
962
+
963
+ with c_r1:
964
+ st.markdown(f"**Fidelidad:**<br>{fmt(st.session_state['ragas_scores']['f'])}", unsafe_allow_html=True)
965
+ with c_r2:
966
+ st.markdown(f"**Relevancia:**<br>{fmt(st.session_state['ragas_scores']['r'])}", unsafe_allow_html=True)
967
+
968
+ st.markdown("### 📊 Informe Final")
969
+ with st.container(border=True):
970
+ st.markdown(st.session_state['resultado_final'])
971
+
972
+ # Disclaimer medico-legal debajo del informe (interfaz Streamlit)
973
+ st.markdown("""
974
+ <div style="background: #fff1f2; border: 1px solid #fecdd3; border-left: 6px solid #e11d48;
975
+ border-radius: 8px; padding: 12px 16px; margin-top: 12px; margin-bottom: 12px;
976
+ font-size: 13px; color: #881337; text-align: center;">
977
+ ⚠️ <strong>AVISO MÉDICO-LEGAL:</strong> Esta herramienta tiene fines académicos,
978
+ usa IA como apoyo y no sustituye evaluación médica profesional.
979
+ </div>
980
+ """, unsafe_allow_html=True)
981
+
982
+ # 🔧 ANTI-FLICKERING: El PDF se genera UNA sola vez y se cachea en session_state.
983
+ # Antes se regeneraba en cada rerun causando reescritura del archivo + I/O continuo
984
+ # + remontaje del download_button → flickering visible.
985
+ if not st.session_state.get('pdf_bytes') or st.session_state.get('pdf_para_id') != id_paciente:
986
+ pdf = PDFReport({'id': id_paciente, 'edad': edad})
987
+ pdf.add_page()
988
+ pdf.chapter_title("1. Análisis")
989
+ pdf.image(st.session_state['path_diag'], w=190)
990
+ pdf.ln(5)
991
+ pdf.chapter_title("2. Informe")
992
+ # Limpieza AGRESIVA de caracteres Unicode con unicodedata
993
+ # GPT-OSS genera muchos caracteres no-latin1 (especialmente \u00a0 non-breaking space)
994
+ import unicodedata
995
+ texto_informe = str(st.session_state['resultado_final']).replace('**', '')
996
+ reemplazos = {
997
+ '\u00a0': ' ', '\u2013': '-', '\u2014': '-',
998
+ '\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"',
999
+ '\u2026': '...', '\u2265': '>=', '\u2264': '<=', '\u00b1': '+/-',
1000
+ '\u2192': '->', '\u2190': '<-', '\u00b7': '*', '\u2022': '*',
1001
+ '\u00bf': '?', '\u00a1': '!', '\u2212': '-', '\u00d7': 'x',
1002
+ '\t': ' ',
1003
+ }
1004
+ for orig, repl in reemplazos.items():
1005
+ texto_informe = texto_informe.replace(orig, repl)
1006
+ texto_normalizado = ""
1007
+ for char in texto_informe:
1008
+ try:
1009
+ char.encode('latin-1')
1010
+ texto_normalizado += char
1011
+ except UnicodeEncodeError:
1012
+ decomp = unicodedata.normalize('NFKD', char)
1013
+ for c in decomp:
1014
+ try:
1015
+ c.encode('latin-1')
1016
+ texto_normalizado += c
1017
+ except UnicodeEncodeError:
1018
+ pass
1019
+ pdf.chapter_body(texto_normalizado)
1020
+
1021
+ # Generar bytes del PDF en memoria (sin escribir a disco repetidamente)
1022
+ out_pdf = os.path.join(st.session_state['temp_dir'], "reporte.pdf")
1023
+ pdf.output(out_pdf)
1024
+ with open(out_pdf, "rb") as f:
1025
+ st.session_state['pdf_bytes'] = f.read()
1026
+ st.session_state['pdf_para_id'] = id_paciente
1027
+
1028
+ # Botón de descarga usa los bytes cacheados (sin I/O en cada rerun)
1029
+ st.download_button(
1030
+ "📄 Descargar PDF",
1031
+ data=st.session_state['pdf_bytes'],
1032
+ file_name=f"Reporte_{id_paciente}.pdf",
1033
+ mime="application/pdf",
1034
+ key="download_pdf_btn"
1035
+ )
1036
+
1037
+ # --- SIDEBAR (Estado Consentimiento) ---
1038
+ with st.sidebar:
1039
+ st.markdown("### 🔐 Estado Privacidad")
1040
+ if st.session_state.get("privacy_ack", False):
1041
+ st.success("Consentimiento aceptado.")
1042
+ else:
1043
+ st.warning("Pendiente.")
1044
+ if st.button("Ver consentimiento"):
1045
+ st.session_state["show_privacy_dialog"]=True
1046
+ open_consent_dialog()
1047
+
1048
+ st.markdown("---")
1049
+ st.markdown("### 📊 Panel de Administración")
1050
+ archivo_logs = "logs_latencia.csv"
1051
+ if os.path.exists(archivo_logs):
1052
+ st.write("Descarga los registros de tiempo para calcular el Percentil 95.")
1053
+ with open(archivo_logs, "rb") as f:
1054
+ st.download_button(
1055
+ label="📥 Descargar Logs (CSV)",
1056
+ data=f,
1057
+ file_name="historial_latencia_dermarag.csv",
1058
+ mime="text/csv",
1059
+ use_container_width=True
1060
+ )
1061
+ else:
1062
+ st.info("Aún no hay logs generados. Analiza una imagen primero.")
1063
+
1064
+ # ==========================================================================
1065
+ # 🔬 DIAGNÓSTICO CHROMADB (temporal — para verificar la base RAG)
1066
+ # ==========================================================================
1067
+ st.markdown("---")
1068
+ st.markdown("### 🔬 Diagnóstico ChromaDB")
1069
+ if st.button("Verificar base RAG", use_container_width=True):
1070
+ try:
1071
+ # ¿Existe la carpeta?
1072
+ if not os.path.exists("./chroma_db"):
1073
+ st.error("❌ La carpeta ./chroma_db NO existe en el Space.")
1074
+ st.info("Verifica que la carpeta esté subida al repo del Space (puede requerir git lfs).")
1075
+ else:
1076
+ archivos = os.listdir("./chroma_db")
1077
+ st.write(f"📁 Archivos en chroma_db: **{len(archivos)}**")
1078
+ with st.expander("Ver archivos"):
1079
+ st.code("\n".join(archivos[:20]))
1080
+
1081
+ # ¿Carga y tiene documentos?
1082
+ from langchain_huggingface import HuggingFaceEmbeddings as _HFE
1083
+ from langchain_community.vectorstores import Chroma as _Chroma
1084
+
1085
+ emb = _HFE(
1086
+ model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
1087
+ )
1088
+ db = _Chroma(persist_directory="./chroma_db", embedding_function=emb)
1089
+ total = db._collection.count()
1090
+ st.metric("Total de chunks indexados", total)
1091
+
1092
+ if total == 0:
1093
+ st.error("❌ La base existe pero está VACÍA. Hay que reindexar los PDFs.")
1094
+ else:
1095
+ # Prueba de búsqueda real
1096
+ resultados = db.similarity_search("margen melanoma", k=3)
1097
+ st.success(f"✅ Búsqueda funcional. {len(resultados)} resultados para 'margen melanoma':")
1098
+ for i, r in enumerate(resultados, 1):
1099
+ fuente = r.metadata.get('source', '?')
1100
+ pagina = r.metadata.get('page', '?')
1101
+ with st.expander(f"📄 Resultado {i}: {os.path.basename(fuente)} (pág {pagina})"):
1102
+ st.write(r.page_content[:500] + "...")
1103
+ except Exception as e:
1104
+ st.error(f"Error al verificar: {e}")
1105
+ import traceback
1106
+ with st.expander("Traceback completo"):
1107
+ st.code(traceback.format_exc())
1108
+
1109
+ st.markdown("<div style='text-align: center; color: #666666; padding: 20px;'>DermaRAG MVP v1.5 | Desarrollado con Mistral + EfficientNet-B4</div>", unsafe_allow_html=True)