| | |
| | |
| | |
| | |
| |
|
| | 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: |
| | |
| | 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: |
| | |
| | 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}" |
| |
|
| | |
| | @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 |
| |
|
| | |
| | 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") |
| |
|
| | |
| | |
| |
|
| | |
| | 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 = "" |
| | |
| | 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: |
| | |
| | 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 = [] |
| | |
| | direct_results = search_direct(query, data_codigos, notificacion_map) |
| |
|
| | if direct_results: |
| | results = direct_results |
| | else: |
| | |
| | event_names = search_local_fuzzy(query, nombres_eventos_limpios) |
| | |
| | |
| | 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] |
| | |
| | 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() |
| | |
| | |
| | |
| | texto_caso = texto_caso.replace("•", "\n- ").replace("", "\n- ").replace("- ", "\n- ") |
| | |
| | |
| | |
| | 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: |
| | |
| | seccion_analisis_ia(info, st.session_state.last_query) |
| |
|
| | |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |
| |
|
| | |
| | |
| | |