File size: 17,238 Bytes
5693371
14e0302
50b7e58
a362398
 
21a807d
29d0842
a362398
e0ecd33
2f59c3d
e4f7e04
 
50b7e58
8ae4598
29d0842
9bff8cd
53a4ac5
50b7e58
93dbc98
2f59c3d
53a4ac5
c52e98f
2f59c3d
 
2108de0
50b7e58
 
 
 
 
 
8ae4598
 
50b7e58
 
dbadfd8
50b7e58
 
14e0302
50b7e58
 
8ae4598
50b7e58
 
 
 
14e0302
50b7e58
 
 
 
 
 
 
 
 
14e0302
 
 
50b7e58
2f59c3d
a362398
29d0842
a362398
 
14e0302
 
 
21a807d
14e0302
a362398
 
14e0302
29d0842
14e0302
 
 
 
 
 
 
 
 
 
 
 
7de5686
2f59c3d
 
dbadfd8
 
 
 
 
 
14e0302
dbadfd8
 
14e0302
dbadfd8
 
 
14e0302
dbadfd8
 
 
 
 
ea9d0eb
2f59c3d
 
 
ea9d0eb
2f59c3d
ea9d0eb
dbadfd8
3502efb
dbadfd8
ea9d0eb
 
 
dbadfd8
3502efb
ea9d0eb
dbadfd8
ea9d0eb
2f59c3d
 
3502efb
dbadfd8
2f59c3d
 
29d0842
14e0302
2f59c3d
a362398
9bff8cd
2f59c3d
14e0302
 
 
 
 
2f59c3d
bde368b
a362398
 
 
 
 
 
 
 
 
 
bde368b
29d0842
a362398
 
14e0302
01fb88d
29d0842
14e0302
93dbc98
a362398
9bff8cd
93dbc98
14e0302
 
 
 
 
 
 
 
 
 
93dbc98
 
a362398
 
 
 
 
 
93dbc98
a362398
 
14e0302
50b7e58
29607cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3502efb
93dbc98
3502efb
50b7e58
d6b8172
50b7e58
8bf2098
 
 
 
 
 
 
 
 
 
 
 
 
 
29607cf
 
984602c
 
8bf2098
 
 
 
 
 
 
 
 
 
 
29607cf
 
984602c
 
 
8bf2098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50b7e58
8bf2098
 
 
 
 
 
 
 
 
 
 
 
dbadfd8
8bf2098
 
 
 
 
 
 
 
 
dbadfd8
8bf2098
 
 
dbadfd8
8bf2098
 
dbadfd8
8bf2098
 
 
 
 
2be1203
8123a33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8bf2098
 
29607cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
# ====================    Buscador de Eventos de  Notificación SIVIGILA - Colombia 2025  =================
# JAIRO ALEXANDER ERASO MD U Nacional de Colombia.
# DIANA MILENA SOLER MARTINEZ U Juan N. Corpas
# Actualizado a google-genai V1.0

import streamlit as st
from google import genai
from google.genai import types
import os
import json
import logging
import datetime
import pandas as pd
import io
import difflib

st.set_page_config(
    page_title="Plataforma SIVIGILA Inteligente",
    page_icon="buho.png",
    layout="wide"
)

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger("sivigila_app")

@st.cache_data
def load_data():
    """Carga y procesa los archivos JSON, creando mapas optimizados para búsqueda rápida."""
    try:
        path_codigos = os.path.join('PLANTILLAS', 'DIC_NOMBRES_CODIGOS_FICHAS.json')
        path_notificacion = os.path.join('PLANTILLAS', 'DATOS_NOTIFICACION.json')
        with open(path_codigos, 'r', encoding='utf-8') as f: data_codigos = json.load(f)
        with open(path_notificacion, 'r', encoding='utf-8') as f: data_notificacion = json.load(f)
        notificacion_map = {item['FICHA']: item for item in data_notificacion}
        dx_to_info_map = {}

        for item in data_codigos:
            ficha = item.get("FICHA")
            if ficha and ficha != "No aplica": 
                evento_info = notificacion_map.get(ficha)
                if evento_info:
                    info = {"ficha": ficha, "evento": evento_info.get("Evento", "N/A"), "protocolo": item.get("NOMBRES DE PROTOCOLOS", "N/A")}
                    cie10 = item.get("CIE 10")
                    cie11 = item.get("CIE 11")
                    if cie10: dx_to_info_map[cie10] = info
                    if cie11: dx_to_info_map[cie11] = info

        nombres_eventos_limpios = sorted(list(set(item['Evento'] for item in data_notificacion)))
        logger.info("✅ Datos cargados y mapas de búsqueda optimizados creados.")
        return data_codigos, data_notificacion, notificacion_map, nombres_eventos_limpios, dx_to_info_map
    except Exception as e:
        st.error(f"❌ Error crítico al cargar o procesar los datos: {e}")
        return None, None, None, None, None

data_codigos, data_notificacion, notificacion_map, nombres_eventos_limpios, dx_to_info_map = load_data()

try:
    GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
    if not GEMINI_API_KEY:
        st.error("No se encontró la variable de entorno GEMINI_API_KEY. Por favor, configúrala en los Secrets del Space.")
        st.stop()
    

    client = genai.Client(api_key=GEMINI_API_KEY)

except Exception as e:
    st.error(f"❌ Error al configurar Gemini API: {e}")
    st.stop()

@st.cache_resource
def get_gemini_client():
    return client


def search_local_fuzzy(query, event_list, cutoff=0.6):
    """
    Busca coincidencias aproximadas usando CPU local (0 tokens).
    Retorna una lista de nombres de eventos si encuentra similitud alta.
    """
    query_lower = query.lower()
    matches = difflib.get_close_matches(query, event_list, n=3, cutoff=cutoff)
    
    if not matches:
        matches = [event for event in event_list if query_lower in event.lower()]
        
    return matches

def search_direct(query, data, notif_map):
    query_lower = query.lower().strip()
    final_results = []
    informative_results = []
    
    for item in data:
        if query_lower in item.get("NOMBRES DE PROTOCOLOS", "").lower() and item.get("FICHA") == "No aplica":
            info_notif = {
                "Evento": item.get("NOMBRES DE PROTOCOLOS").split(":")[0], 
                "FICHA": "Informativo",
                "PROTOCOLO": item.get("PROTOCOLO"),
                "is_informative": True 
            }
            informative_results.append({
                "info_notificacion": info_notif,
                "detalles_codigos": [item] 
            })

    if informative_results:
        return informative_results

    matching_items = []
    for item in data:
        if (query_lower == item.get("CIE 10", "").lower().strip() or
            query_lower == item.get("CIE 11", "").lower().strip() or
            query_lower == item.get("FICHA", "").lower().strip() or
            query_lower in item.get("NOMBRES DE PROTOCOLOS", "").lower()):
            matching_items.append(item)
    
    if not matching_items: return []
    
    grouped_by_ficha = {}
    for item in matching_items:
        ficha = item.get("FICHA")
        if ficha and ficha != "No aplica":
            if ficha not in grouped_by_ficha: grouped_by_ficha[ficha] = []
            grouped_by_ficha[ficha].append(item)
            
    for ficha, matched_details in grouped_by_ficha.items():
        info_notificacion = notif_map.get(ficha)
        if info_notificacion:
            final_results.append({"info_notificacion": info_notificacion, "detalles_codigos": matched_details})
            
    return final_results


@st.cache_data(show_spinner=False)
def search_with_gemini(query, event_list):
    if not client: return "Error: Modelo Gemini no disponible."
    
    system_prompt = f"""
    Actúa como clasificador CIE-10/CIE-11 para Colombia.
    Lista de eventos oficiales: {'; '.join(event_list)}
    Consulta: "{query}"
    Instrucción: Retorna ÚNICAMENTE el nombre exacto del evento de la lista que mejor corresponda semánticamente (ej: 'mordedura culebra' -> 'Accidente Ofídico').
    Si hay varios posibles, sepáralos con ';'. Si no hay coincidencia clara, responde "NO_ENCONTRADO".
    """
    try:
        # NUEVA SINTAXIS DE LLAMADA
        response = client.models.generate_content(
            model='gemini-2.5-flash-lite',
            contents=system_prompt
        )
        
        result_text = response.text.strip()
        if "NO_ENCONTRADO" in result_text: return []
        return [name.strip() for name in result_text.split(';') if name.strip()]
        
    except Exception as e:

        if "429" in str(e):
            return "Límite de API alcanzado."
        return f"Error API: {e}"


@st.cache_data(show_spinner=False)
def analyze_query_with_gemini(query, definition, evento_name, ficha_number):
    if not client: return "Error: Modelo Gemini no disponible."
    
    analysis_prompt = f"""
    Rol: Auditor SIVIGILA.
    Evento: "{evento_name}" (Ficha {ficha_number}).
    Definición Caso: "{definition}"
    Consulta: "{query}"
    
    Tarea: Compara la consulta con la definición.
    Responde ESTRICTAMENTE en este formato:
    **Decisión:** [NOTIFICAR | NO CUMPLE CRITERIOS]
    **Justificación:** [Explica brevísimamente qué criterios cumple o faltan]
    **Recomendación:** [Si NOTIFICAR: indicar proceder con ficha {ficha_number}. Si NO: revisar guías INS.]
    """
    try:
        # NUEVA SINTAXIS DE LLAMADA
        response = client.models.generate_content(
            model='gemini-2.5-flash-lite',
            contents=analysis_prompt
        )
        return response.text
    except Exception as e:
        if "429" in str(e):
            return "Límite de consultas para análisis alcanzado."
        return f"Error durante el análisis de IA: {e}"

# --- NUEVA FUNCIÓN CON FRAGMENTO PARA EVITAR EL SALTO DE PESTAÑA ---
@st.fragment
def seccion_analisis_ia(info, query):
    """
    Esta función maneja el botón y la visualización del análisis de forma aislada.
    Al usar @st.fragment, cuando se hace clic, solo se recarga esta parte,
    evitando que las pestañas (tabs) se reinicien.
    """
    definicion_de_caso = info.get("Definición de caso", "").strip()
    ficha = info.get("FICHA", "N/A")
    evento = info.get("Evento", "N/A")
    
    if not definicion_de_caso:
        st.warning("No hay definición de caso para analizar.")
        return

    # Usamos una clave única en session_state para guardar el resultado
    key_analisis = f"analisis_ia_result_{ficha}"

    if st.button(f"Analizar caso con IA para ficha {ficha}", key=f"btn_analisis_{ficha}"):
        with st.spinner("Analizando..."):
            resultado = analyze_query_with_gemini(query, definicion_de_caso, evento, ficha)
            st.session_state[key_analisis] = resultado

    if key_analisis in st.session_state:
        st.markdown(st.session_state[key_analisis])
# -------------------------------------------------------------------

col_img, col_text = st.columns([1, 5], gap="medium")
with col_img: st.image("buho.png", width=100)
with col_text:
    st.title("Plataforma Inteligente SIVIGILA")
    st.markdown("Herramienta experimental de apoyo para la notificación de eventos de salud pública en Colombia actualizado a CIE 10 - CIE 11. por Diana Soler, Jairo Alexander")

# COMENTAMOS LA CREACIÓN DE PESTAÑAS PRINCIPALES
# tab_individual, tab_hc, tab_db, tab_sitrep = st.tabs(["Búsqueda Individual", "Análisis por HC (pdf)", "Conexión a bases de datos", "SITREP"])

# === INICIO CONTENIDO DE BÚSQUEDA INDIVIDUAL (SIN TAB) ===
st.header("Consulta de Evento Específico")

if 'search_results' not in st.session_state:
    st.session_state.search_results = None
if 'last_query' not in st.session_state:
    st.session_state.last_query = ""

def clear_search_state():
    st.session_state.search_results = None
    st.session_state.last_query = ""
    # Limpieza profunda de análisis previos
    keys_to_delete = [k for k in st.session_state.keys() if k.startswith("analisis_ia_result_")]
    for k in keys_to_delete:
        del st.session_state[k]

with st.form(key="search_form"):
    query = st.text_input("Ingrese su búsqueda:", placeholder="Ej: Sereno, Lepra, T630, mordedura de serpiente...", help="Busque por nombre, sinónimo o código.")
    submitted = st.form_submit_button("Buscar", type="primary")

if submitted:
    if not query:
        st.warning("Por favor, ingrese un término de búsqueda.")
    elif data_codigos is None:
        st.error("Los datos no se pudieron cargar.")
    else:
        # Limpiar resultados anteriores al hacer una nueva búsqueda
        keys_to_delete = [k for k in st.session_state.keys() if k.startswith("analisis_ia_result_")]
        for k in keys_to_delete:
            del st.session_state[k]

        with st.spinner("Buscando..."):
            results = []
            # 1. Búsqueda exacta (Coste: 0 tokens)
            direct_results = search_direct(query, data_codigos, notificacion_map)

            if direct_results:
                results = direct_results
            else:
                # 2. Búsqueda Local Difusa (Fuzzy) (Coste: 0 tokens)
                event_names = search_local_fuzzy(query, nombres_eventos_limpios)
                
                # 3. Solo si Fuzzy falla, llamar a Gemini (Coste: Tokens, pero con caché)
                if not event_names:
                    gemini_response = search_with_gemini(query, nombres_eventos_limpios)
                    if isinstance(gemini_response, list):
                        event_names = gemini_response
                    elif isinstance(gemini_response, str):
                        if "Error" in gemini_response or "Límite" in gemini_response:
                            st.warning(f"IA: {gemini_response}")
                        event_names = []
                
                if event_names:
                    fichas_encontradas = {item['FICHA'] for name in event_names for item in data_notificacion if item['Evento'] == name}
                    query_keywords = [kw for kw in query.lower().split() if len(kw) > 2]
                    for ficha in fichas_encontradas:
                        info_notif = notificacion_map.get(ficha)
                        all_details_for_ficha = [d for d in data_codigos if d.get("FICHA") == ficha]
                        # Filtro visual simple
                        filtered_details = [detail for detail in all_details_for_ficha if any(keyword in detail.get("NOMBRES DE PROTOCOLOS", "").lower() for keyword in query_keywords)] if query_keywords else []
                        final_details = filtered_details if filtered_details else all_details_for_ficha
                        if info_notif:
                            results.append({"info_notificacion": info_notif, "detalles_codigos": final_details})

            st.session_state.search_results = results
            st.session_state.last_query = query

if st.session_state.search_results is not None:
    st.button("Limpiar Búsqueda", on_click=clear_search_state)
    st.markdown("---")
    results = st.session_state.search_results

    if not results:
        st.info(f"No se encontraron eventos. Intente reformular su búsqueda: '{st.session_state.last_query}'.")
    else:
        if results[0].get("info_notificacion", {}).get("is_informative"):
            st.success(f"Resultado informativo: '{st.session_state.last_query}'")
        else:
            st.success(f"Se encontraron {len(results)} evento(s): '{st.session_state.last_query}'")

        for result in results:
            info = result["info_notificacion"]
            codigos = result["detalles_codigos"]

            if info.get("is_informative"):
                with st.expander(f"**Término Informativo: {info.get('Evento', 'N/A')}**", expanded=True):
                    st.markdown("**Este término corresponde a una creencia cultural y NO es un evento de notificación obligatoria.**")
                    st.markdown("---")
                    st.markdown(f"**Explicación:** {info.get('PROTOCOLO', 'No disponible.')}")
                    st.markdown(f"**CIE-10:** `{codigos[0].get('CIE 10', 'N/A')}` | **CIE-11:** `{codigos[0].get('CIE 11', 'N/A')}`")
            else:
                with st.expander(f"**Evento: {info.get('Evento', 'Sin nombre')} (Ficha: {info.get('FICHA', 'N/A')})**", expanded=True):
                    tab_info, tab_def, tab_analysis = st.tabs(["Información General", "📖 Definición de Caso", "🤖 Análisis por IA"])
                    
                    with tab_info:
                        st.subheader("Información de Notificación")
                        notif_super = info.get("Notificación superinmedita", "NO") == "SI"
                        if notif_super: st.error(f"**¡ATENCIÓN! Requiere Notificación SUPERINMEDIATA:** {info.get('Descripción superinmedita', '')}")
                        elif info.get("Notificación Inmediata", "NO") == "SI": st.warning("**Requiere Notificación Inmediata (dentro de las 24 horas).**")
                        else: st.info("**Notificación Semanal Individual.**")
                        
                        col1, col2 = st.columns(2)
                        col1.markdown(f"**Fichas:** {info.get('Fichas a Utilizar', 'No especificado')}")
                        col2.markdown(f"**Requisito:** {info.get('REQUISITO', 'No especificado')}")
                        
                        st.markdown("---")
                        st.caption("Para ver detalles de edad, clasificación y condición final, consulte los protocolos completos del INS.")
                        
                        st.subheader("Códigos Relacionados")
                        for codigo in codigos[:5]:
                            st.markdown(f"- `{codigo.get('CIE 10', 'N/A')}` / `{codigo.get('CIE 11', 'N/A')}`: {codigo.get('NOMBRES DE PROTOCOLOS', 'N/A')}")
                        if len(codigos) > 5: st.caption(f"... y {len(codigos)-5} códigos más.")
                    
                    with tab_def:
                        texto_caso = info.get("Definición de caso", "No disponible.").strip()
                        
                        # 1. Truco para mejorar las listas: forzamos saltos de línea antes de viñetas comunes
                        # Si tus datos usan otros símbolos como '•' o '', agrégalos aquí.
                        texto_caso = texto_caso.replace("•", "\n- ").replace("", "\n- ").replace("- ", "\n- ")
                        
                        # 2. Renderizamos usando HTML para poder usar "text-align: justify"
                        # replace('\n', '<br>') asegura que los saltos de línea del JSON se respeten
                        st.markdown(
                            f"""
                            <div style="text-align: justify; font-size: 16px; line-height: 1.6; padding-right: 10px;">
                                {texto_caso.replace(chr(10), '<br>')}
                            </div>
                            """, 
                            unsafe_allow_html=True
                        )
                    
                    with tab_analysis:
                        # LLAMADA A LA FUNCIÓN FRAGMENTADA - SOLUCIÓN DEL SALTO
                        seccion_analisis_ia(info, st.session_state.last_query)

# === SECCIONES COMENTADAS ===

# with tab_hc:
#     st.header("Análisis por Historia Clínica (PDF)")
#    st.warning("🚧 Esta funcionalidad se encuentra actualmente en desarrollo.")

# with tab_db:
#     st.header("Conexión a Bases de Datos")
#     st.warning("🚧 Esta funcionalidad se encuentra actualmente en desarrollo.")

# with tab_sitrep:
#    st.header("SITREP (Situation Report)")
#     st.warning("🚧 Esta funcionalidad se encuentra actualmente en desarrollo.")