# app.py import sqlite3 import numpy as np import unicodedata import re import json import os import gradio as gr import spacy from sentence_transformers import SentenceTransformer from sklearn.metrics.pairwise import cosine_similarity import random # ✅ Cargar modelo de embeddings modelo = SentenceTransformer("hiiamsid/sentence_similarity_spanish_es") # ✅ Cargar modelo NLP de spaCy try: nlp = spacy.load("es_core_news_sm") except: from spacy.cli import download download("es_core_news_sm") nlp = spacy.load("es_core_news_sm") # ✅ Cargar base SQLite (debe estar en la raíz del repo) conn = sqlite3.connect("chatbot_fcefn.db", check_same_thread=False) cursor = conn.cursor() # ✅ Cargar archivos locales with open("sinonimos.json", "r", encoding="utf-8") as f: sinonimos = json.load(f) with open("stopwords.json", "r", encoding="utf-8") as f: stopwords = set(json.load(f)) with open("preguntas_repaso.json", "r", encoding="utf-8") as f: modelos_por_categoria = json.load(f) # ------------------------------------ 🔧 Utilidades ------------------------------------ def normalizar(texto): texto = texto.lower() texto = unicodedata.normalize("NFKD", texto).encode("ASCII", "ignore").decode("utf-8") texto = re.sub(r"[^\w\s]", "", texto) for original, estandar in sinonimos.items(): texto = re.sub(r'\b' + re.escape(original) + r'\b', estandar, texto) texto = re.sub(r"\s+", " ", texto).strip() return texto def extraer_palabras_clave(texto): doc = nlp(texto) return [ token.lemma_.lower() for token in doc if token.is_alpha and token.lemma_.lower() not in stopwords and token.pos_ in ["NOUN", "PROPN"] and len(token.lemma_) > 2 ] # ------------------------------------ ✅ Generar y guardar embeddings si no están ------------------------------ def generar_y_guardar_embeddings(): # Obtener IDs ya con embedding cursor.execute("SELECT pregunta_id FROM embeddings") existentes = set(row[0] for row in cursor.fetchall()) # Obtener preguntas con id, question y category (ojo: category, no categoria) cursor.execute("SELECT id, question, category FROM faq") preguntas = cursor.fetchall() n_nuevos = 0 for pregunta_id, texto, categoria in preguntas: if pregunta_id in existentes: continue # Ya tiene embedding emb = modelo.encode(texto) emb_bytes = emb.astype(np.float32).tobytes() # Insertar en embeddings (categoria) cursor.execute( "INSERT INTO embeddings (pregunta_id, embedding, categoria) VALUES (?, ?, ?)", (pregunta_id, emb_bytes, categoria) ) n_nuevos += 1 conn.commit() print(f"Embeddings generados y guardados para {n_nuevos} preguntas nuevas.") generar_y_guardar_embeddings() # ------------------------------------ ✅ Cargar datos ------------------------------------ cursor.execute(""" SELECT f.question, f.answer, e.embedding, f.category FROM faq f JOIN embeddings e ON f.id = e.pregunta_id """) rows = cursor.fetchall() preguntas = [row[0] for row in rows] respuestas = [row[1] for row in rows] categorias = [row[3] for row in rows] embeddings_preguntas = [np.frombuffer(row[2], dtype=np.float32) for row in rows] # Agrupar por categoría respuestas_por_categoria = {} embeddings_por_categoria = {} categorias_unicas = sorted(set(categorias)) for pregunta, respuesta, emb, cat in zip(preguntas, respuestas, embeddings_preguntas, categorias): cat = cat.lower() respuestas_por_categoria.setdefault(cat, []).append(respuesta) embeddings_por_categoria.setdefault(cat, []).append(emb) # ================= Precomputados para matching robusto ================= # Preguntas normalizadas (en el mismo orden que `preguntas`) preguntas_norm = [normalizar(p) for p in preguntas] # Índices de preguntas por categoría (índices referidos a las listas globales preguntas/respuestas) indices_por_categoria = {} for i, cat in enumerate(categorias): cat_l = cat.lower() indices_por_categoria.setdefault(cat_l, []).append(i) # ------------------------------------ ✅ Chatbot sin voz ------------------------------------ import difflib def responder(pregunta_usuario, categoria_usuario, fuente_alternativa): if not pregunta_usuario.strip(): return "

Por favor, escribí una palabra clave.

" if not categoria_usuario: return "

Seleccioná una categoría.

" categoria = categoria_usuario.lower() if categoria not in embeddings_por_categoria: return "

Categoría no encontrada.

" # Normalizado completo del input texto_normalizado = normalizar(pregunta_usuario) # Extraer palabras clave (seguimos usándolas pero no dependemos exclusivamente de ellas) palabras_clave = extraer_palabras_clave(pregunta_usuario) # Obtener índices de preguntas de la categoría seleccionada idxs_cat = indices_por_categoria.get(categoria, []) if not idxs_cat: return "

No hay preguntas en esa categoría.

" # 1) MATCH EXACTO: si la pregunta normalizada coincide exactamente con alguna pregunta en la BD -> devolverla for idx in idxs_cat: if preguntas_norm[idx] == texto_normalizado: return ( f"

🧠 Coincidencia exacta encontrada:

" f"

📘 Pregunta: {preguntas[idx]}
" f"Respuesta:
{respuestas[idx]}

" ) # 2) MATCH POR SUBSTRING / PALABRA con regex en preguntas normalizadas for idx in idxs_cat: q_norm = preguntas_norm[idx] # Si la pregunta del usuario contiene completamente la pregunta de la BD o viceversa if texto_normalizado in q_norm or q_norm in texto_normalizado: return ( f"

🧠 Coincidencia por frase encontrada:

" f"

📘 Pregunta: {preguntas[idx]}
" f"Respuesta:
{respuestas[idx]}

" ) # Buscar palabras clave (regex límite de palabra) for palabra in palabras_clave: if re.search(r'\b' + re.escape(normalizar(palabra)) + r'\b', q_norm): return ( f"

🧠 Coincidencia literal para '{palabra}':

" f"

📘 Pregunta: {preguntas[idx]}
" f"Respuesta:
{respuestas[idx]}

" ) # 3) MATCH FUZZY (opcional, para capturar pequeñas variantes) # Si hay una pregunta muy similar (> 0.94) la devolvemos best_ratio = 0.0 best_idx = None for idx in idxs_cat: ratio = difflib.SequenceMatcher(None, texto_normalizado, preguntas_norm[idx]).ratio() if ratio > best_ratio: best_ratio = ratio best_idx = idx if best_ratio >= 0.94: return ( f"

🧠 Coincidencia aproximada (ratio {best_ratio:.2f}):

" f"

📘 Pregunta: {preguntas[best_idx]}
" f"Respuesta:
{respuestas[best_idx]}

" ) # 4) Si no encontramos coincidencia textual, vamos por embeddings: # Usar el texto normalizado completo (mejor que solo palabras clave) pregunta_proc = texto_normalizado if texto_normalizado else normalizar(" ".join(palabras_clave)) emb_usuario = modelo.encode(pregunta_proc).reshape(1, -1) emb_cat = np.vstack(embeddings_por_categoria[categoria]) sims = cosine_similarity(emb_usuario, emb_cat)[0] mejor_idx_en_cat = np.argmax(sims) mejor_sim = sims[mejor_idx_en_cat] # mapear mejor_idx_en_cat (índice relativo en embeddings_por_categoria[categoria]) a índice global # Primero reconstruimos la lista de índices para esa categoría en el mismo orden que embeddings_por_categoria # (cuando creaste embeddings_por_categoria lo añadiste en el mismo orden que rows; asumimos coincidencia) # Una forma robusta: obtener lista de índices globales para la categoría (idxs_cat) y usar mejor_idx_en_cat como posición en esa lista if mejor_idx_en_cat < len(idxs_cat): idx_global = idxs_cat[mejor_idx_en_cat] else: # fallback (no debería ocurrir) idx_global = idxs_cat[0] if mejor_sim < 0.5: busqueda = re.sub(r"\s+", "+", pregunta_usuario.strip()) link = f"https://www.google.com/search?q={busqueda}" if fuente_alternativa == "Google" else f"https://www.youtube.com/results?search_query={busqueda}" return f"No encontré una respuesta clara.
Consultá en {fuente_alternativa}" return ( f"

🧠 Palabras clave detectadas: {' '.join(palabras_clave) if palabras_clave else '(ninguna)'}

" f"

📘 Respuesta (sim {mejor_sim:.3f}):
{respuestas[idx_global]}

" ) # ------------------------------------ ✅ Repaso de conceptos ------------------------------------ def mostrar_pregunta_por_categoria(categoria): if categoria not in modelos_por_categoria: return "Categoría no encontrada", [], "", "" pregunta = random.choice(modelos_por_categoria[categoria]) return pregunta["pregunta"], pregunta["opciones"], pregunta["respuesta"], categoria def verificar_respuesta_cat(pregunta_usuario, opciones, respuesta_correcta, seleccion_usuario, categoria_actual): if not seleccion_usuario: return "

Seleccioná una opción para continuar.

" if seleccion_usuario == respuesta_correcta: return "

✅ ¡Correcto!

" else: return f"

❌ Incorrecto. La respuesta correcta es: {respuesta_correcta}

" # ------------------------------------ ✅ Interfaces Gradio ------------------------------------ chatbot = gr.Interface( fn=responder, inputs=[ gr.Textbox(lines=2, label="Tu pregunta"), gr.Dropdown(choices=categorias_unicas, label="Seleccioná la categoría"), gr.Dropdown(choices=["Google", "YouTube"], label="Buscar en otra fuente si no se encuentra") ], outputs=gr.HTML(label="Respuesta del chatbot"), title="CHATBOT DE FCEFN", description="🧠 Ingresá una pregunta o palabra clave teórica de las asignaturas. Seleccioná categoría. Si no se encuentra una respuesta clara, se sugiere una fuente externa.", theme="soft" ) def interfaz_parciales_cat(): categorias_disponibles = list(modelos_por_categoria.keys()) with gr.Blocks() as demo: gr.Markdown("### 🎯 Preguntas Multiple Choice para repaso") with gr.Row(): selector_categoria = gr.Dropdown(choices=categorias_disponibles, label="Elegí una categoría", value=categorias_disponibles[0]) boton_nueva = gr.Button("Siguiente pregunta") pregunta = gr.Textbox(label="Pregunta", interactive=False) opciones_radio = gr.Radio(choices=[""], label="Elegí una opción") resultado = gr.HTML() estado_respuesta = gr.State() estado_categoria = gr.State() def cargar_pregunta(categoria): p, opciones, r, c = mostrar_pregunta_por_categoria(categoria) return p, gr.update(choices=opciones, value=None), r, c, "", None def manejar_verificacion(p, o, r, s, c): return verificar_respuesta_cat(p, o, r, s, c) boton_nueva.click( fn=cargar_pregunta, inputs=[selector_categoria], outputs=[pregunta, opciones_radio, estado_respuesta, estado_categoria, resultado, opciones_radio] ) opciones_radio.change( fn=manejar_verificacion, inputs=[pregunta, opciones_radio, estado_respuesta, opciones_radio, estado_categoria], outputs=resultado ) return demo # ✅ Interfaz final con pestañas app = gr.TabbedInterface( [chatbot, interfaz_parciales_cat()], tab_names=["Consultar Conceptos", "Repasar Conceptos"] ) # ✅ Lanzamiento en Hugging Face if __name__ == "__main__": app.launch()