import re import os import json import random import uuid import pandas as pd from datetime import datetime from pathlib import Path from gtts import gTTS import gradio as gr from transformers import pipeline import gspread from oauth2client.service_account import ServiceAccountCredentials from gemini import responder_con_gemini # ===================================================== # CONFIGURACIÓN GENERAL # ===================================================== SPREADSHEET_ID = "1YizPDOaelHY-vTTrBSWy6Iv8P0SvNOTPNrAEgEiWIIY" NOMBRE_HOJA = "Hoja 1" # Cambia esto si tu pestaña tiene otro nombre exacto VERSION_CONSENTIMIENTO = "1.0" NOMBRE_HOJA_CONSENTIMIENTOS = "Consentimientos" ENCABEZADOS_CONSENTIMIENTO = [ "fecha_hora", "matricula", "nombre", "grupo", "consentimiento", "version_consentimiento" ] ENCABEZADOS = [ "fecha_hora", "folio_atencion", "matricula", "nombre", "grupo", "idioma", "mensaje", "clasificacion", "intencion", "tema", "label", "score", "nivel_riesgo", "semaforo", "coincidencias_riesgo", "probabilidades", "tipo_riesgo", "ideacion_suicida", "desesperanza", "aislamiento", "alerta_urgente", "respuesta_agente" ] # ===================================================== # CARGAR MODELO DE SENTIMIENTO # ===================================================== print("Cargando modelo de sentimiento...") modelo_sentimiento = pipeline( "text-classification", model="pysentimiento/robertuito-sentiment-analysis", top_k=None ) print("Modelo cargado correctamente") # ===================================================== # CONEXIÓN A GOOGLE SHEETS # ===================================================== def conectar_sheet(): """ Conecta con Google Sheets usando el Secret GOOGLE_CREDENTIALS. En Hugging Face Spaces, guarda el JSON completo de la cuenta de servicio en Settings > Secrets con el nombre exacto: GOOGLE_CREDENTIALS """ print("=" * 60) print("INICIANDO CONEXIÓN A GOOGLE SHEETS") scope = [ "https://spreadsheets.google.com/feeds", "https://www.googleapis.com/auth/drive" ] creds_json = os.getenv("GOOGLE_CREDENTIALS") print("Verificando GOOGLE_CREDENTIALS...") if creds_json: print("✅ GOOGLE_CREDENTIALS encontrado") print(f"📏 Longitud del Secret: {len(creds_json)} caracteres") else: print("❌ GOOGLE_CREDENTIALS NO encontrado") if not creds_json: raise ValueError( "No se encontró el Secret GOOGLE_CREDENTIALS. " "Verifica en Hugging Face: Settings > Secrets." ) try: creds_dict = json.loads(creds_json) print("✅ JSON leído correctamente") except Exception as e: raise ValueError( f"El Secret GOOGLE_CREDENTIALS no tiene formato JSON válido: {repr(e)}" ) from e try: creds = ServiceAccountCredentials.from_json_keyfile_dict( creds_dict, scope ) print("✅ Credenciales creadas") except Exception as e: raise ValueError( f"No se pudieron crear las credenciales de Google: {repr(e)}" ) from e try: client = gspread.authorize(creds) print("✅ Cliente autorizado") except Exception as e: raise ValueError( f"No se pudo autorizar el cliente de Google Sheets: {repr(e)}" ) from e try: archivo = client.open_by_key(SPREADSHEET_ID) print("✅ Spreadsheet abierto") except Exception as e: raise ValueError( "No se pudo abrir el Spreadsheet. " f"Revisa SPREADSHEET_ID y permisos: {repr(e)}" ) from e try: sheet = archivo.worksheet(NOMBRE_HOJA) print(f"✅ Hoja encontrada: {NOMBRE_HOJA}") except Exception as e: print( f"⚠️ No se encontró la hoja '{NOMBRE_HOJA}'. " f"Se usará la primera hoja: {repr(e)}" ) sheet = archivo.sheet1 print("✅ Primera hoja abierta correctamente") print("CONEXIÓN A GOOGLE SHEETS FINALIZADA") print("=" * 60) return sheet def asegurar_encabezados(sheet): """ Coloca los encabezados cuando la primera fila de la hoja está vacía. """ try: primera_fila = sheet.row_values(1) if not primera_fila: sheet.append_row( ENCABEZADOS, value_input_option="USER_ENTERED" ) print("✅ Encabezados agregados correctamente") else: print("✅ La hoja ya contiene encabezados") except Exception as e: print( "❌ No se pudieron verificar o agregar los encabezados: " f"{repr(e)}" ) # ===================================================== # CONSENTIMIENTO INFORMADO Y REGISTRO # ===================================================== def guardar_consentimiento(acepta, matricula, nombre, grupo): """ Verifica la aceptación, intenta guardar el consentimiento y permite avanzar a la interfaz de SOFIA. """ if not acepta: return ( gr.update(visible=True), # Mantiene el aviso visible gr.update(visible=False), # Mantiene SOFIA oculta matricula or "", nombre or "", grupo or "", "⚠️ Debes marcar la casilla de aceptación para continuar." ) matricula_final = str(matricula or "").strip() or "ANONIMO" nombre_final = str(nombre or "").strip() or "ANONIMO" grupo_final = str(grupo or "").strip() mensaje_estado = "✅ Consentimiento aceptado correctamente." try: sheet = conectar_sheet_consentimientos() sheet.append_row( [ datetime.now().strftime("%Y-%m-%d %H:%M:%S"), matricula_final, nombre_final, grupo_final, "SI", VERSION_CONSENTIMIENTO ], value_input_option="USER_ENTERED", insert_data_option="INSERT_ROWS" ) print("✅ Consentimiento guardado en Google Sheets") except Exception as e: import traceback print("=" * 80) print("ERROR AL GUARDAR EL CONSENTIMIENTO") print(traceback.format_exc()) print("=" * 80) mensaje_estado = ( "⚠️ Consentimiento aceptado. " "No fue posible guardar temporalmente el registro en Google Sheets." ) # Siempre permite avanzar cuando la casilla fue aceptada return ( gr.update(visible=False), # Oculta el aviso gr.update(visible=True), # Muestra la interfaz de SOFIA "" if matricula_final == "ANONIMO" else matricula_final, "" if nombre_final == "ANONIMO" else nombre_final, grupo_final, mensaje_estado ) # ===================================================== # FUNCIONES DE RESPALDO FOSO II # ===================================================== def detectar_pregunta_foso2(mensaje): return False def responder_foso2(mensaje): return ( "Gracias por tu pregunta. En este momento no se detectó una consulta pedagógica " "específica de FOSO II, pero puedes escribir con más detalle tu duda." ) # ===================================================== # GENERAR FOLIO DE ATENCIÓN # ===================================================== def generar_folio_atencion(): return "ATN-" + str(uuid.uuid4())[:8].upper() # ===================================================== # PATRONES DE RIESGO # ===================================================== PATRONES_RIESGO_ALTO = [ r"\bme quiero morir\b", r"\bquiero morir\b", r"\bno quiero vivir\b", r"\bquiero terminar con todo\b", r"\bquiero acabar con todo\b", r"\bquiero matarme\b", r"\bme voy a suicidar\b", r"\bsuicidarme\b", r"\bquitarme la vida\b", r"\bya no quiero vivir\b", r"\bsería mejor morir\b", r"\bseria mejor morir\b", r"\bestoy pensando en matarme\b", ] PATRONES_RIESGO_MODERADO = [ r"\bnada tiene sentido\b", r"\bno veo salida\b", r"\bya no puedo\b", r"\bquiero desaparecer\b", r"\bme siento mal\b", r"\bestoy muy triste\b", r"\bestoy deprimid[oa]\b", r"\bme siento solo\b", r"\bme siento sola\b", r"\bnadie me entiende\b", r"\btengo miedo\b", r"\bestoy muy estresad[oa]\b", r"\bme siento estresad[oa]\b", r"\btengo muchas tareas\b", r"\bestoy cansad[oa]\b", r"\bme siento abrumad[oa]\b", r"\bno puedo más\b", r"\bno puedo mas\b", r"\bya no aguanto\b", r"\btodo sería mejor sin mí\b", r"\btodo seria mejor sin mi\b", ] PATRONES_DESESPERANZA = [ r"\bnada tiene sentido\b", r"\bno veo salida\b", r"\bya no puedo\b", r"\bno puedo más\b", r"\bno puedo mas\b", r"\bya no aguanto\b", r"\bquisiera desaparecer\b", r"\btodo sería mejor sin mí\b", r"\btodo seria mejor sin mi\b", r"\bmi vida no vale\b", r"\bsoy una carga\b", ] PATRONES_AISLAMIENTO = [ r"\bme siento solo\b", r"\bme siento sola\b", r"\bnadie me entiende\b", r"\bestoy solo\b", r"\bestoy sola\b", r"\bno tengo a nadie\b", r"\bquiero aislarme\b", r"\bno quiero hablar con nadie\b", ] PATRONES_IDEACION_SUICIDA = [ r"\bme quiero morir\b", r"\bquiero morir\b", r"\bno quiero vivir\b", r"\bquiero matarme\b", r"\bme voy a suicidar\b", r"\bsuicidarme\b", r"\bquitarme la vida\b", r"\bestoy pensando en matarme\b", r"\bquisiera dormirme y no despertar\b", ] # ===================================================== # FUNCIÓN PARA CALCULAR NIVEL DE RIESGO # ===================================================== def calcular_nivel_riesgo(texto, label, score): t = str(texto or "").lower().strip() coincidencias_altas = [p for p in PATRONES_RIESGO_ALTO if re.search(p, t)] coincidencias_moderadas = [p for p in PATRONES_RIESGO_MODERADO if re.search(p, t)] if coincidencias_altas: return "alto", "🔴", coincidencias_altas + coincidencias_moderadas if coincidencias_moderadas: return "moderado", "🟠", coincidencias_moderadas if label == "NEG" and score >= 0.90: return "moderado", "🟠", ["modelo_NEG_alto"] return "bajo", "🟢", [] # ===================================================== # DETECTAR TEMA PRINCIPAL DEL MENSAJE # ===================================================== def detectar_tema(texto): t = str(texto or "").lower().strip() if any(p in t for p in ["no puedo dormir", "insomnio", "duermo mal", "no duermo"]): return "sueno" if any(p in t for p in ["triste", "tristeza", "deprimido", "deprimida", "llorar", "vacío", "vacio"]): return "tristeza" if any(p in t for p in ["ansiedad", "ansioso", "ansiosa", "estres", "estrés", "abrumado", "abrumada", "presión", "presion"]): return "ansiedad" if any(p in t for p in ["tarea", "tareas", "examen", "materia", "profe", "profesor", "calificación", "calificacion", "estudiar", "actividad"]): return "academico" if any(p in t for p in ["familia", "mamá", "mama", "papá", "papa", "casa", "hermano", "hermana", "padres"]): return "familia" if any(p in t for p in ["novio", "novia", "pareja", "terminé", "termine", "terminamos", "relación", "relacion"]): return "pareja" if any(p in t for p in ["solo", "sola", "nadie me entiende", "sin amigos", "me siento aislado", "me siento aislada"]): return "soledad" if any(p in t for p in ["horario", "documento", "trámite", "tramite", "grupo", "boleta", "inscripción", "inscripcion"]): return "administrativo" return "general" # ===================================================== # DETECCIÓN DE INDICADORES DE RIESGO SUICIDA # ===================================================== def detectar_indicadores_suicidio(texto): t = str(texto or "").lower().strip() ideacion = any(re.search(p, t) for p in PATRONES_IDEACION_SUICIDA) desesperanza = any(re.search(p, t) for p in PATRONES_DESESPERANZA) aislamiento = any(re.search(p, t) for p in PATRONES_AISLAMIENTO) if ideacion: tipo_riesgo = "riesgo_suicida_alto" alerta_urgente = "si" elif desesperanza or aislamiento: tipo_riesgo = "riesgo_socioemocional_moderado" alerta_urgente = "no" else: tipo_riesgo = "malestar_general_o_sin_indicadores" alerta_urgente = "no" return { "ideacion_suicida": "si" if ideacion else "no", "desesperanza": "si" if desesperanza else "no", "aislamiento": "si" if aislamiento else "no", "tipo_riesgo": tipo_riesgo, "alerta_urgente": alerta_urgente } # ===================================================== # ANALIZAR MENSAJE # ===================================================== def analizar_frase(texto): texto = str(texto or "").strip() if not texto: return None resultado_modelo = modelo_sentimiento(texto)[0] mejor = max(resultado_modelo, key=lambda x: x["score"]) label = mejor["label"] score = float(mejor["score"]) probabilidades = { x["label"]: round(float(x["score"]), 3) for x in resultado_modelo } texto_lower = texto.lower() nivel_riesgo, semaforo, coincidencias_riesgo = calcular_nivel_riesgo( texto_lower, label, score ) if nivel_riesgo == "alto": clasificacion = "alto_riesgo" elif nivel_riesgo == "moderado": clasificacion = "riesgo_moderado" else: clasificacion = "normal" if any(p in texto_lower for p in [ "tarea", "tareas", "profe", "profesor", "materia", "ejercicio", "actividad", "tema", "examen", "calificación", "calificacion", "muchas tareas", "no entiendo", "estudiar" ]): intencion = "academico_emocional" elif any(p in texto_lower for p in [ "horario", "documento", "trámite", "tramite", "grupo", "boleta", "inscripción", "inscripcion" ]): intencion = "administrativo" else: intencion = "emocional" tema = detectar_tema(texto) indicadores = detectar_indicadores_suicidio(texto) return { "clasificacion": clasificacion, "intencion": intencion, "label": label, "score": score, "probabilidades": probabilidades, "nivel_riesgo": nivel_riesgo, "semaforo": semaforo, "tema": tema, "coincidencias_riesgo": coincidencias_riesgo, "tipo_riesgo": indicadores["tipo_riesgo"], "ideacion_suicida": indicadores["ideacion_suicida"], "desesperanza": indicadores["desesperanza"], "aislamiento": indicadores["aislamiento"], "alerta_urgente": indicadores["alerta_urgente"] } # ===================================================== # BLOQUES DE TEXTO VARIABLES # ===================================================== def elegir(lista): return random.choice(lista) def construir_sugerencias_por_tema(tema, intencion): sugerencias_generales = [ "tratar de poner en palabras lo que estás sintiendo para entenderlo mejor", "buscar a una persona de confianza con quien puedas hablar", "hacer una pausa breve, respirar despacio y bajar un poco la tensión del momento", "evitar cargar con todo tú solo o tú sola", ] mapa = { "sueno": [ "evitar usar el celular unos minutos antes de dormir", "hacer respiraciones lentas y profundas antes de acostarte", "anotar las preocupaciones que te están quitando el sueño", "pedir apoyo si el insomnio se está volviendo frecuente" ], "tristeza": [ "expresar lo que sientes con alguien de confianza", "no aislarte por completo en este momento", "realizar una actividad sencilla que te ayude a salir un poco de la carga emocional", "buscar acompañamiento del área de orientación o psicología" ], "ansiedad": [ "hacer pausas cortas durante el día para disminuir la saturación mental", "dividir el problema en partes pequeñas en lugar de querer resolver todo al mismo tiempo", "practicar respiración profunda por unos minutos", "identificar qué situación te está generando más presión" ], "academico": [ "organizar tus tareas por prioridad y fecha de entrega", "empezar por una sola actividad en vez de pensar en todo al mismo tiempo", "pedir que te expliquen nuevamente el tema que no entiendes", "buscar apoyo con tu docente, tutor o un compañero de confianza" ], "familia": [ "hablar con calma con una persona de tu familia que te genere confianza", "buscar apoyo adulto si en casa te sientes sobrepasado o sobrepasada", "tratar de identificar qué situación familiar te está afectando más", "pedir acompañamiento institucional si el conflicto continúa" ], "pareja": [ "tomarte un momento para entender qué fue lo que más te lastimó", "evitar tomar decisiones impulsivas cuando la emoción está muy intensa", "hablar con alguien de confianza para no quedarte solo o sola con esto", "recordar que una situación afectiva difícil también necesita acompañamiento" ], "soledad": [ "acercarte a una persona con quien te sientas mínimamente en confianza", "evitar encerrarte completamente con lo que sientes", "buscar apoyo en orientación o tutoría", "dar un primer paso pequeño para pedir ayuda" ], "administrativo": [ "explicar con más detalle cuál es el trámite o la duda que necesitas resolver", "revisar si tu duda corresponde a horario, grupo, boleta o documento", "acercarte al área del plantel que lleve ese proceso", "tener a la mano tus datos escolares para recibir una orientación más clara" ], "general": sugerencias_generales } sugerencias = mapa.get(tema, sugerencias_generales) if intencion == "administrativo": sugerencias = mapa["administrativo"] elif intencion == "academico_emocional" and tema == "general": sugerencias = mapa["academico"] random.shuffle(sugerencias) return sugerencias[:3] # ===================================================== # RESPUESTA DE CRISIS # ===================================================== def construir_respuesta_crisis(nombre="Estudiante"): return ( f"Hola {nombre}. Gracias por decir lo que estás sintiendo.\n\n" "Lo que acabas de expresar requiere atención inmediata. No estás solo o sola.\n\n" "Por favor, haz esto ahora mismo:\n" "1. Busca a un adulto, familiar, docente, tutor, orientador u otra persona de confianza y dile que necesitas acompañamiento inmediato.\n" "2. No te quedes solo o sola en este momento.\n" "3. Acércate de inmediato al área de orientación, tutoría o psicología de tu plantel.\n" "4. Si sientes que podrías lastimarte o existe peligro inmediato, llama al 911 o comunícate con la Línea de la Vida: 800 911 2000.\n\n" "Tu bienestar importa y pedir ayuda ahora es muy importante.\n\n" "Respóndeme con una sola palabra:\n" "- 'solo' si estás sin compañía\n" "- 'acompañado' si hay alguien contigo" ) # ===================================================== # RESPUESTA AMPLIA DEL AGENTE # ===================================================== def construir_respuesta_amplia(resultado, nombre="Estudiante"): if not resultado: return ( f"Hola {nombre}. Gracias por escribir.\n\n" "Quiero leerte con atención. Cuéntame un poco más sobre lo que estás viviendo " "para poder orientarte mejor." ) clasificacion = resultado.get("clasificacion", "normal") intencion = resultado.get("intencion", "emocional") nivel_riesgo = resultado.get("nivel_riesgo", "bajo") tema = resultado.get("tema", "general") aperturas = [ f"Hola {nombre}. Gracias por confiar en este espacio para expresar lo que estás viviendo.", f"Hola {nombre}. Gracias por compartir lo que sientes; hacerlo ya es un paso importante.", f"Hola {nombre}. Valoro que hayas escrito sobre esto, porque lo que te pasa sí importa." ] empatia_moderada = [ "Lo que estás sintiendo merece atención, acompañamiento y un espacio para hablarlo con calma.", "A veces la presión emocional, personal o escolar puede sentirse muy pesada, y no tienes por qué cargar con todo sin apoyo.", "Cuando varias cosas se juntan al mismo tiempo, es normal sentirse cansado, abrumado o sin mucha claridad." ] preguntas_finales = [ "Si quieres, puedo ayudarte a identificar qué te está afectando más en este momento.", "Si te parece, podemos ir paso a paso para ubicar qué situación te está pesando más.", "Cuéntame un poco más y te ayudo a ordenar lo que estás sintiendo." ] if clasificacion == "alto_riesgo" or nivel_riesgo == "alto": return construir_respuesta_crisis(nombre) if clasificacion == "riesgo_moderado" or nivel_riesgo == "moderado": sugerencias = construir_sugerencias_por_tema(tema, intencion) texto_tema = { "sueno": "El problema para dormir muchas veces aparece cuando hay preocupaciones, tensión acumulada o malestar emocional.", "tristeza": "La tristeza sostenida puede ser una señal de que algo importante necesita atención y apoyo.", "ansiedad": "La ansiedad o el estrés pueden afectar tanto tus pensamientos como tu cuerpo, tu concentración y tu descanso.", "academico": "La carga escolar puede generar mucha presión cuando se acumulan tareas, exámenes o temas difíciles.", "familia": "Los problemas familiares también pueden afectar mucho el ánimo, la concentración y la sensación de estabilidad.", "pareja": "Las situaciones afectivas pueden doler profundamente y alterar el equilibrio emocional.", "soledad": "Sentirse solo o sola puede volver más pesada cualquier situación difícil.", "administrativo": "Cuando una duda escolar o administrativa no se resuelve, también puede generar estrés y frustración.", "general": "A veces cuesta poner en orden lo que sentimos, sobre todo cuando hay varias preocupaciones al mismo tiempo." } return ( f"{elegir(aperturas)}\n\n" f"{elegir(empatia_moderada)} {texto_tema.get(tema, texto_tema['general'])}\n\n" "Además de reconocer lo que te está pasando, también es importante pensar en pequeños pasos que sí puedes dar desde ahora.\n\n" "Estas opciones podrían ayudarte:\n" f"- {sugerencias[0]}\n" f"- {sugerencias[1]}\n" f"- {sugerencias[2]}\n\n" "También es recomendable que busques acompañamiento en tu plantel para no enfrentar esto en soledad. " "Puedes acercarte a tutoría, orientación educativa o psicología.\n\n" f"{elegir(preguntas_finales)} " "Puedes decirme si esto se relaciona más con la escuela, con tus emociones, con tu familia o con algún problema personal." ) if intencion == "academico_emocional": sugerencias = construir_sugerencias_por_tema("academico", intencion) return ( f"{elegir(aperturas)}\n\n" "Entiendo que la carga académica puede llegar a sentirse muy pesada, especialmente cuando se juntan tareas, exámenes, temas difíciles o presión por las calificaciones.\n\n" "Eso no significa que no puedas avanzar; muchas veces significa que necesitas apoyo, orden y empezar por una sola parte del problema.\n\n" "Podrías intentar lo siguiente:\n" f"- {sugerencias[0]}\n" f"- {sugerencias[1]}\n" f"- {sugerencias[2]}\n\n" "También puedo ayudarte a revisar contigo qué te está costando más: una materia, una tarea, una instrucción, un tema específico o simplemente la acumulación de trabajo.\n\n" "Cuéntame qué materia o actividad te está preocupando más y avanzamos paso a paso." ) if intencion == "administrativo": sugerencias = construir_sugerencias_por_tema("administrativo", intencion) return ( f"{elegir(aperturas)}\n\n" "Puedo orientarte con dudas escolares o administrativas relacionadas con horario, grupo, documentos, boletas, trámites o indicaciones generales del plantel.\n\n" "Para darte una respuesta más útil, necesito que me escribas con un poco más de detalle qué es exactamente lo que necesitas resolver.\n\n" "Por ejemplo, puedes especificar si tu duda se relaciona con:\n" f"- {sugerencias[0]}\n" f"- {sugerencias[1]}\n" f"- {sugerencias[2]}\n\n" "Entre más claro me expliques tu situación, más precisa podrá ser mi orientación." ) sugerencias = construir_sugerencias_por_tema(tema, intencion) return ( f"{elegir(aperturas)}\n\n" "A veces poner en palabras lo que nos pasa ayuda a entender mejor la situación y a encontrar un primer paso para afrontarla. " "No necesitas resolver todo de una sola vez.\n\n" "Algunas opciones que podrían ayudarte en este momento son:\n" f"- {sugerencias[0]}\n" f"- {sugerencias[1]}\n" f"- {sugerencias[2]}\n\n" "Si quieres, puedo seguir acompañándote para identificar qué es lo que más te preocupa y cómo podrías empezar a manejarlo." ) # ===================================================== # ANÁLISIS TEXTUAL # ===================================================== def construir_analisis_textual(frase, resultado): if not resultado: return "Sin análisis." probs = resultado.get("probabilidades", {}) texto = [] texto.append("ANÁLISIS EMOCIONAL") texto.append(f"Frase: {frase}") texto.append(f"Clasificación: {resultado.get('clasificacion', '')}") texto.append(f"Intención detectada: {resultado.get('intencion', '')}") texto.append(f"Tema detectado: {resultado.get('tema', '')}") texto.append(f"Etiqueta principal: {resultado.get('label', '')}") texto.append(f"Confianza: {float(resultado.get('score', 0.0)):.3f}") texto.append(f"Nivel de riesgo: {resultado.get('nivel_riesgo', '')}") texto.append(f"Semáforo: {resultado.get('semaforo', '')}") texto.append(f"Coincidencias de riesgo: {resultado.get('coincidencias_riesgo', '')}") texto.append(f"Tipo de riesgo: {resultado.get('tipo_riesgo', '')}") texto.append(f"Ideación suicida: {resultado.get('ideacion_suicida', '')}") texto.append(f"Desesperanza: {resultado.get('desesperanza', '')}") texto.append(f"Aislamiento: {resultado.get('aislamiento', '')}") texto.append(f"Alerta urgente: {resultado.get('alerta_urgente', '')}") texto.append("") texto.append("Probabilidades:") texto.append(f"- NEG: {float(probs.get('NEG', 0.0)):.3f}") texto.append(f"- NEU: {float(probs.get('NEU', 0.0)):.3f}") texto.append(f"- POS: {float(probs.get('POS', 0.0)):.3f}") return "\n".join(texto) # ===================================================== # GUARDAR EN GOOGLE SHEETS # ===================================================== def guardar_interaccion(folio_atencion, matricula, nombre, grupo, idioma, mensaje, resultado, respuesta): try: print("ENTRANDO A guardar_interaccion...") sheet = conectar_sheet() print("Conexión con Google Sheets correcta") if resultado is None: resultado = {} fila = [ datetime.now().strftime("%Y-%m-%d %H:%M:%S"), str(folio_atencion or ""), str(matricula or ""), str(nombre or ""), str(grupo or ""), str(idioma or "Español"), str(mensaje or ""), str(resultado.get("clasificacion", "")), str(resultado.get("intencion", "")), str(resultado.get("tema", "")), str(resultado.get("label", "")), str(resultado.get("score", "")), str(resultado.get("nivel_riesgo", "")), str(resultado.get("semaforo", "")), str(resultado.get("coincidencias_riesgo", "")), str(resultado.get("probabilidades", "")), str(resultado.get("tipo_riesgo", "")), str(resultado.get("ideacion_suicida", "")), str(resultado.get("desesperanza", "")), str(resultado.get("aislamiento", "")), str(resultado.get("alerta_urgente", "")), str(respuesta or "") ] # Guarda SIEMPRE al final, sin borrar registros anteriores sheet.append_row( fila, value_input_option="USER_ENTERED", insert_data_option="INSERT_ROWS" ) print("✅ Datos guardados correctamente en Google Sheets") return True, "Guardado correcto" except Exception as e: error = f"❌ Error al guardar en Google Sheets: {repr(e)}" print(error) return False, error # ===================================================== # RESPONDER CHAT # ===================================================== def responder_chat(matricula, nombre, grupo, idioma, mensaje, historial): mensaje = str(mensaje or "").strip() historial = str(historial or "").strip() if not mensaje: return historial, "", "Escribe algo.", "Sin análisis" try: nombre_mostrar = nombre.strip() if str(nombre or "").strip() else "Estudiante" matricula_final = matricula.strip() if str(matricula or "").strip() else "ANONIMO" grupo_final = grupo.strip() if str(grupo or "").strip() else "" idioma_final = str(idioma or "Español").strip() folio_atencion = generar_folio_atencion() # Consulta FOSO II. También se guarda registro. if detectar_pregunta_foso2(mensaje): respuesta_base = responder_foso2(mensaje) respuesta = f"📌 Folio de atención: {folio_atencion}\n\n{respuesta_base}" analisis = "Consulta pedagógica detectada: FOSO II" resultado_foso = { "clasificacion": "consulta_pedagogica", "intencion": "foso_ii", "tema": "foso_ii", "label": "", "score": "", "nivel_riesgo": "bajo", "semaforo": "🟢", "coincidencias_riesgo": "", "probabilidades": "", "tipo_riesgo": "consulta_pedagogica", "ideacion_suicida": "no", "desesperanza": "no", "aislamiento": "no", "alerta_urgente": "no" } guardado, mensaje_guardado = guardar_interaccion( folio_atencion, matricula_final, nombre_mostrar, grupo_final, idioma_final, mensaje, resultado_foso, respuesta ) print(mensaje_guardado) bloque_usuario = f"Tú: {mensaje}" bloque_agente = f"Agente: {respuesta}" nuevo_historial = historial + "\n\n" + bloque_usuario + "\n" + bloque_agente if historial else bloque_usuario + "\n" + bloque_agente return nuevo_historial, "", respuesta, analisis resultado = analizar_frase(mensaje) # Gemini redacta la respuesta cuando el riesgo no es alto. # Si Gemini falla, el agente usa la respuesta original como respaldo. if resultado and resultado.get("nivel_riesgo") == "alto": respuesta_base = construir_respuesta_crisis(nombre_mostrar) else: try: respuesta_base = responder_con_gemini( mensaje=mensaje, nombre=nombre_mostrar, resultado=resultado, idioma=idioma_final ) if not respuesta_base or str(respuesta_base).startswith("Error de Gemini"): respuesta_base = construir_respuesta_amplia(resultado, nombre_mostrar) except Exception as e: print(f"Error al responder con Gemini: {repr(e)}") respuesta_base = construir_respuesta_amplia(resultado, nombre_mostrar) respuesta = f"📌 Folio de atención: {folio_atencion}\n\n{respuesta_base}" analisis = construir_analisis_textual(mensaje, resultado) if resultado is not None: guardado, mensaje_guardado = guardar_interaccion( folio_atencion, matricula_final, nombre_mostrar, grupo_final, idioma_final, mensaje, resultado, respuesta ) print(mensaje_guardado) if not guardado: analisis += f"\n\nERROR DE GUARDADO:\n{mensaje_guardado}" bloque_usuario = f"Tú: {mensaje}" bloque_agente = f"Agente: {respuesta}" nuevo_historial = historial + "\n\n" + bloque_usuario + "\n" + bloque_agente if historial else bloque_usuario + "\n" + bloque_agente return nuevo_historial, "", respuesta, analisis except Exception as e: print(f"Error interno: {repr(e)}") return historial, "", f"Error interno: {repr(e)}", "Sin análisis" # ===================================================== # MICRÓFONO, TRANSCRIPCIÓN Y VOZ DE SOFIA # ===================================================== modelo_transcripcion = None def obtener_modelo_transcripcion(): """Carga Whisper solamente cuando se utiliza el micrófono por primera vez.""" global modelo_transcripcion if modelo_transcripcion is None: print("Cargando modelo de transcripción Whisper...") modelo_transcripcion = pipeline( "automatic-speech-recognition", model="openai/whisper-tiny" ) print("Modelo de transcripción cargado correctamente") return modelo_transcripcion def transcribir_audio(ruta_audio): """Convierte el audio grabado por el estudiante en texto.""" if not ruta_audio: return "" try: transcriptor = obtener_modelo_transcripcion() resultado = transcriptor( str(ruta_audio), generate_kwargs={ "language": "spanish", "task": "transcribe" } ) if isinstance(resultado, dict): texto = resultado.get("text", "") else: texto = str(resultado or "") return texto.strip() except Exception as e: print(f"Error al transcribir audio: {repr(e)}") return "" def limpiar_texto_para_voz(texto): """ Elimina Markdown, emojis, folios y símbolos visuales antes de convertir el texto en voz. """ texto = str(texto or "") if not texto: return "" # Eliminar folio de atención texto = re.sub( r"📌\s*Folio de atención:\s*[A-Z0-9-]+", "", texto, flags=re.IGNORECASE ) # Eliminar enlaces Markdown conservando solo el texto texto = re.sub( r"\[([^\]]+)\]\([^)]+\)", r"\1", texto ) # Eliminar negritas, cursivas y código Markdown texto = re.sub(r"\*\*(.*?)\*\*", r"\1", texto) texto = re.sub(r"__(.*?)__", r"\1", texto) texto = re.sub(r"\*(.*?)\*", r"\1", texto) texto = re.sub(r"_(.*?)_", r"\1", texto) texto = re.sub(r"`(.*?)`", r"\1", texto) # Eliminar encabezados Markdown texto = re.sub( r"^\s*#{1,6}\s*", "", texto, flags=re.MULTILINE ) # Eliminar cualquier asterisco restante texto = texto.replace("*", "") # Convertir listas en pausas naturales texto = re.sub( r"^\s*[-•▪►]\s*", "", texto, flags=re.MULTILINE ) texto = re.sub( r"^\s*(\d+)[.)]\s*", r"\1. ", texto, flags=re.MULTILINE ) # Eliminar emojis y símbolos visuales simbolos = [ "🔴", "🟠", "🟢", "🚨", "📞", "☎️", "📌", "✅", "⚠️", "❗", "👉", "➡️", "✔️", "•", "▪", "►" ] for simbolo in simbolos: texto = texto.replace(simbolo, "") # Mejorar pronunciación texto = texto.replace( "800 911 2000", "ochocientos, nueve once, dos mil" ) texto = texto.replace( "911", "nueve once" ) # Eliminar comillas y símbolos innecesarios texto = texto.replace('"', "") texto = texto.replace("'", "") texto = texto.replace("~", "") texto = texto.replace("_", " ") # Crear pausas naturales texto = re.sub(r"\n+", ". ", texto) texto = re.sub(r"\s+", " ", texto) texto = re.sub(r"\.{2,}", ".", texto) return texto.strip() def generar_voz_sofia(texto): """Convierte la respuesta de SOFIA en un archivo MP3 en español.""" texto_limpio = limpiar_texto_para_voz(texto) if not texto_limpio: return None try: carpeta_audio = Path("/tmp/sofia_audio") carpeta_audio.mkdir( parents=True, exist_ok=True ) ruta_audio = ( carpeta_audio / f"sofia_{uuid.uuid4().hex}.mp3" ) gTTS( text=texto_limpio, lang="es", slow=False ).save(str(ruta_audio)) return str(ruta_audio) except Exception as e: print( f"Error al generar la voz de SOFIA: {repr(e)}" ) return None def responder_chat_con_voz( matricula, nombre, grupo, idioma, mensaje, historial ): """Conserva responder_chat y añade la salida de voz.""" nuevo_historial, mensaje_limpio, respuesta, analisis = responder_chat( matricula, nombre, grupo, idioma, mensaje, historial ) audio_respuesta = generar_voz_sofia(respuesta) return ( nuevo_historial, mensaje_limpio, respuesta, analisis, audio_respuesta ) def responder_desde_microfono( matricula, nombre, grupo, idioma, audio_entrada, historial ): """ Transcribe el micrófono y envía el texto a la función original responder_chat. """ texto_transcrito = transcribir_audio(audio_entrada) if not texto_transcrito: return ( historial, "", ( "No pude reconocer el audio. " "Intenta hablar nuevamente cerca del micrófono." ), "Sin análisis", None, None ) nuevo_historial, _, respuesta, analisis = responder_chat( matricula, nombre, grupo, idioma, texto_transcrito, historial ) audio_respuesta = generar_voz_sofia(respuesta) return ( nuevo_historial, texto_transcrito, respuesta, analisis, audio_respuesta, None ) def activar_ayuda_urgente_con_voz(nombre): """Conserva la función de crisis y añade la lectura de la respuesta.""" mensaje_limpio, respuesta, analisis = activar_ayuda_urgente(nombre) audio_respuesta = generar_voz_sofia(respuesta) return ( mensaje_limpio, respuesta, analisis, audio_respuesta ) def limpiar_chat_con_voz(): """Limpia conversación, mensaje, análisis y componentes de audio.""" return "", "", "", "", None, None # ===================================================== # LIMPIAR CHAT # ===================================================== def limpiar_chat(): return "", "", "", "" # ===================================================== # RESPUESTA RÁPIDA DE CRISIS DESDE BOTÓN # ===================================================== def activar_ayuda_urgente(nombre): nombre_mostrar = str(nombre or "").strip() or "Estudiante" folio_atencion = generar_folio_atencion() respuesta_base = construir_respuesta_crisis(nombre_mostrar) respuesta = f"📌 Folio de atención: {folio_atencion}\n\n{respuesta_base}" resultado = { "clasificacion": "alto_riesgo", "intencion": "emocional", "tema": "crisis", "label": "NEG", "score": 1.0, "nivel_riesgo": "alto", "semaforo": "🔴", "coincidencias_riesgo": "boton_ayuda_urgente", "probabilidades": "", "tipo_riesgo": "riesgo_suicida_alto", "ideacion_suicida": "si", "desesperanza": "no", "aislamiento": "no", "alerta_urgente": "si" } guardado, mensaje_guardado = guardar_interaccion( folio_atencion, "BOTON_URGENCIA", nombre_mostrar, "", "Español", "El estudiante presionó el botón de ayuda urgente", resultado, respuesta ) print(mensaje_guardado) analisis = "ALERTA MANUAL: solicitud de ayuda urgente" if not guardado: analisis += f"\n\nERROR DE GUARDADO:\n{mensaje_guardado}" return "", respuesta, analisis # ===================================================== # INTERFAZ VISUAL MEJORADA Y RESPONSIVE # ===================================================== css = """ body { background: linear-gradient(135deg, #eef7f4 0%, #e8f0ff 50%, #f4edff 100%); background-attachment: fixed; font-family: Arial, sans-serif; color: #111827; } .gradio-container { max-width: 1450px !important; padding-left: 12px !important; padding-right: 12px !important; } p, li, span, div, label { color: #111827 !important; } h1, h2, h3, h4 { color: #0f172a !important; font-weight: 800 !important; } .bloque { background: #ffffff; border-radius: 18px; padding: 18px; box-shadow: 0 2px 10px rgba(0,0,0,0.10); margin-bottom: 12px; } .header-box { background: linear-gradient(90deg, #0b6b43, #0f8a57); color: white !important; border-radius: 22px; padding: 24px; margin-bottom: 15px; box-shadow: 0 4px 14px rgba(0,0,0,0.12); } .header-box, .header-box div, .header-box span, .header-box b, .header-box .titulo, .header-box .subtitulo { color: white !important; } .titulo { font-size: 34px; font-weight: 900; margin-bottom: 6px; line-height: 1.15; } .subtitulo { font-size: 17px; font-weight: 500; opacity: 1; line-height: 1.4; } .seguro { background: #f8fafc; border-radius: 16px; padding: 18px; font-size: 17px; line-height: 1.65; margin-bottom: 12px; color: #111827 !important; border: 1px solid #e5e7eb; } .estado { background: #ecfdf3; border-radius: 12px; padding: 12px; font-weight: 800; color: #0b6b43 !important; text-align: center; margin-top: 10px; border: 1px solid #bbf7d0; } .footer-note { font-size: 13px; color: #374151 !important; margin-top: 8px; } textarea, input { font-size: 16px !important; color: #111827 !important; font-weight: 500 !important; } ::placeholder { color: #6b7280 !important; opacity: 1 !important; } label { font-size: 14px !important; font-weight: 700 !important; color: #111827 !important; } button { min-height: 44px !important; font-size: 15px !important; font-weight: 700 !important; border-radius: 12px !important; } textarea[aria-label="Conversación"] { min-height: 260px !important; color: #111827 !important; } .header-box img { max-width: 70px; max-height: 70px; } @media (max-width: 768px) { .gradio-container { padding-left: 8px !important; padding-right: 8px !important; } .header-box { border-radius: 18px; padding: 18px; margin-bottom: 12px; flex-direction: row; align-items: center; gap: 10px; } .header-box img { width: 50px !important; height: 50px !important; } .titulo { font-size: 23px !important; line-height: 1.2; font-weight: 900 !important; } .subtitulo { font-size: 15px !important; line-height: 1.35; font-weight: 500 !important; } .bloque { padding: 14px; border-radius: 14px; margin-bottom: 10px; } .bloque h2, .bloque h3 { font-size: 22px !important; font-weight: 900 !important; color: #111827 !important; } .bloque p, .bloque li, .seguro { font-size: 17px !important; line-height: 1.5 !important; color: #111827 !important; font-weight: 500 !important; } .seguro { padding: 14px; border-radius: 14px; } .estado { padding: 10px; font-size: 15px !important; } label { font-size: 15px !important; font-weight: 800 !important; } button { width: 100% !important; margin-bottom: 8px !important; font-size: 15px !important; } textarea[aria-label="Conversación"] { min-height: 180px !important; } textarea[aria-label="Análisis emocional"] { min-height: 160px !important; } } textarea, input { background: #ffffff !important; color: #111827 !important; -webkit-text-fill-color: #111827 !important; } textarea[aria-label="Conversación"] { background: #ffffff !important; color: #111827 !important; } textarea[aria-label="Respuesta del agente"] { background: #ffffff !important; color: #111827 !important; } textarea[aria-label="Análisis emocional"] { background: #ffffff !important; color: #111827 !important; } html { color-scheme: light !important; } ::placeholder { color: #6b7280 !important; opacity: 1 !important; } """ tema_visual = gr.themes.Soft(primary_hue="green") with gr.Blocks(theme=tema_visual, css=css, title="Sistema de Orientación para el Fortalecimiento Integral del Alumno UNEVT") as demo: gr.HTML("""
Logo CONALEP
🤖 Sistema de Orientación para el Fortalecimiento Integral del Alumno UNEVT
Apoyo socioemocional, Pedagogico y detección de riesgos

Desarrollado por:
Doctorante. Jorge Noé Gámez Mora
Mtro. Mauricio Pueblas Cornejo Evelyn González Anzaldo · Herandy Pamela Díaz Catarino . Misael Fernando Vallejo Romero
""") with gr.Group(visible=True) as pantalla_consentimiento: gr.HTML("""

🔒 Consentimiento Informado, Aviso de Privacidad y Confidencialidad

Bienvenido(a) a SOFIA

SOFIA es un agente conversacional basado en Inteligencia Artificial, diseñado para brindar orientación inicial, apoyo socioemocional y acompañamiento pedagógico a estudiantes.

🤖 Uso de Inteligencia Artificial

Las respuestas de SOFIA tienen fines informativos, preventivos y de orientación inicial. Pueden ser generadas mediante modelos de Inteligencia Artificial.

🔐 Aviso de Privacidad

Los datos que proporciones voluntariamente, como nombre, matrícula, grupo y mensajes, serán utilizados para brindar orientación inicial, detectar indicadores de riesgo socioemocional, mejorar el sistema, generar información estadística y realizar seguimiento institucional cuando sea necesario.

Los datos serán tratados conforme al Aviso de Privacidad institucional de la Universidad Estatal del Valle de Toluca y a la normativa aplicable en materia de protección de datos personales.

🔒 Confidencialidad

La información compartida será tratada con confidencialidad. Cuando se detecten indicadores de riesgo grave para la integridad del estudiante o de terceras personas, el sistema podrá recomendar la canalización inmediata con personal institucional, servicios de salud o emergencias.

⚠️ Limitaciones

SOFIA no sustituye la atención psicológica, médica o psiquiátrica; no realiza diagnósticos clínicos, no prescribe medicamentos y no reemplaza la valoración de un profesional.

🚨 Emergencias

Si existe peligro inmediato, llama al 911, comunícate con la Línea de la Vida: 800 911 2000 y busca el acompañamiento de una persona adulta, familiar, tutor u orientador.

El uso de SOFIA es voluntario. Puedes abandonar la conversación en cualquier momento.

""") with gr.Row(): consentimiento_matricula = gr.Textbox( label="Matrícula (opcional)", placeholder="Ej. 22012345" ) consentimiento_nombre = gr.Textbox( label="Nombre (opcional)", placeholder="Escribe tu nombre" ) consentimiento_grupo = gr.Textbox( label="Grupo (opcional)", placeholder="Ej. 210" ) acepta_consentimiento = gr.Checkbox( label=( "He leído y acepto el Aviso de Privacidad, el Consentimiento " "Informado y las condiciones de uso de SOFIA." ), value=False, interactive=True ) aceptar_consentimiento_btn = gr.Button( "✅ Aceptar e iniciar conversación", variant="primary" ) estado_consentimiento = gr.Markdown("") with gr.Group(visible=False) as pantalla_agente: # ===================================================== # SELECTOR DE IDIOMA / LENGUA # ===================================================== with gr.Row(): idioma = gr.Dropdown( choices=[ "Español", "Mazahua (Jñatrjo)" ], value="Español", label="🌎 Idioma / Lengua", info=( "Selecciona el idioma de interacción. " "La modalidad Mazahua (Jñatrjo) se encuentra " "en fase demostrativa y de validación lingüística." ), interactive=True ) with gr.Row(): with gr.Column(scale=1): gr.HTML("""

¿Qué puedo hacer por ti?

✅ Brindarte acompañamiento inicial

✅ Detectar señales de riesgo socioemocional

✅ Orientarte en temas emocionales y escolares

✅ Canalizarte si necesitas ayuda urgente

""") gr.HTML("""

🔒 Espacio confidencial

Este agente no sustituye atención psicológica, médica o de emergencia.

Si existe peligro inmediato, llama al 911 o a la Línea de la Vida: 800 911 2000.

""") matricula = gr.Textbox( label="Matrícula (opcional)", placeholder="Ej. 22012345 o deja vacío si prefieres anonimato" ) nombre = gr.Textbox(label="Nombre", placeholder="Escribe tu nombre") grupo = gr.Textbox(label="Grupo", placeholder="Ej. 210") with gr.Column(scale=2): gr.HTML("""

🔒 Hola, este es un espacio seguro para ti.

Puedes hablar conmigo con confianza.
Todo lo que compartas será tratado con seriedad, respeto y confidencialidad, conforme al aviso de privacidad institucional.

Estoy aquí para apoyarte. Si te sientes en riesgo o necesitas ayuda urgente, busca a tu tutor, orientador del bienestar o a un adulto de confianza.

No estás solo.

""") historial = gr.Textbox( label="Conversación", lines=16, interactive=False, placeholder="Aquí aparecerá la conversación..." ) mensaje = gr.Textbox( label="Mensaje del estudiante", placeholder="Escribe aquí cómo te sientes o qué necesitas..." ) audio_entrada = gr.Audio( sources=["microphone"], type="filepath", label="🎤 Habla con SOFIA" ) audio_salida = gr.Audio( label="🔊 Voz de SOFIA", autoplay=True, interactive=False ) with gr.Row(): enviar = gr.Button("Enviar", variant="primary") limpiar = gr.Button("Limpiar") respuesta_box = gr.Textbox(label="Respuesta del agente", lines=10, visible=False) with gr.Column(scale=1): gr.HTML("""

Acciones rápidas

📋 Evaluación rápida

📚 Recursos de apoyo

📞 Contactar tutor u orientador

🧠 Acompañamiento emocional inicial

""") gr.HTML("""
Estado del agente: En línea 🟢
""") crisis_btn = gr.Button("🚨 ¡Necesito ayuda urgente!", variant="stop") gr.HTML(""" """) analisis_box = gr.Textbox( label="Análisis emocional", lines=18, visible=False ) aceptar_consentimiento_btn.click( guardar_consentimiento, inputs=[ acepta_consentimiento, consentimiento_matricula, consentimiento_nombre, consentimiento_grupo ], outputs=[ pantalla_consentimiento, pantalla_agente, matricula, nombre, grupo, estado_consentimiento ] ) enviar.click( responder_chat_con_voz, inputs=[matricula, nombre, grupo, idioma, mensaje, historial], outputs=[historial, mensaje, respuesta_box, analisis_box, audio_salida] ) mensaje.submit( responder_chat_con_voz, inputs=[matricula, nombre, grupo, idioma, mensaje, historial], outputs=[historial, mensaje, respuesta_box, analisis_box, audio_salida] ) limpiar.click( limpiar_chat_con_voz, inputs=[], outputs=[historial, mensaje, respuesta_box, analisis_box, audio_entrada, audio_salida] ) crisis_btn.click( activar_ayuda_urgente_con_voz, inputs=[nombre], outputs=[mensaje, respuesta_box, analisis_box, audio_salida] ) audio_entrada.change( responder_desde_microfono, inputs=[matricula, nombre, grupo, idioma, audio_entrada, historial], outputs=[historial, mensaje, respuesta_box, analisis_box, audio_salida, audio_entrada] ) if __name__ == "__main__": print("Iniciando app tesis...") demo.launch()