JairoCesar's picture
Update app.py
2be1203 verified
# ==================== 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.")