import gradio as gr import PyPDF2 import os import re import vertexai from vertexai.generative_models import GenerativeModel, Part, SafetySetting from difflib import SequenceMatcher # -------------------- # CONFIGURACIÓN GLOBAL # -------------------- generation_config = { "max_output_tokens": 8192, "temperature": 0, "top_p": 0.8, } safety_settings = [ SafetySetting( category=SafetySetting.HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold=SafetySetting.HarmBlockThreshold.OFF ), SafetySetting( category=SafetySetting.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold=SafetySetting.HarmBlockThreshold.OFF ), SafetySetting( category=SafetySetting.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold=SafetySetting.HarmBlockThreshold.OFF ), SafetySetting( category=SafetySetting.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold=SafetySetting.HarmBlockThreshold.OFF ), ] def configurar_credenciales(json_path: str): """Configura credenciales de Google Cloud a partir de un archivo JSON.""" os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = json_path # ----------- # LECTURA PDF # ----------- def extraer_texto(pdf_path: str) -> str: """ Extrae el texto de todas las páginas de un PDF con PyPDF2. Retorna un string con todo el texto concatenado. """ texto_total = "" with open(pdf_path, "rb") as f: lector = PyPDF2.PdfReader(f) for page in lector.pages: texto_total += page.extract_text() or "" return texto_total # ----------- # PARSEO DE TEXTO # ----------- def split_secciones(texto: str) -> (str, str): """ Separa el texto en dos partes: la sección 'Preguntas' y la sección 'RESPUESTAS'. Busca las palabras 'Preguntas' y 'RESPUESTAS' ignorando mayúsculas y espacios al inicio. """ match_preg = re.search(r'(?im)^\s*preguntas', texto) match_resp = re.search(r'(?im)^\s*respuestas', texto) if not match_preg or not match_resp: return (texto, "") start_preg = match_preg.end() # Fin de "Preguntas" start_resp = match_resp.start() # Inicio de "RESPUESTAS" texto_preguntas = texto[start_preg:start_resp].strip() texto_respuestas = texto[match_resp.end():].strip() return (texto_preguntas, texto_respuestas) def parsear_enumeraciones(texto: str) -> dict: """ Dado un texto que contiene enumeraciones de preguntas (por ejemplo, "1. 1- RTA1" o "2- RTA2"), separa cada número y su contenido. Retorna un dict: {"Pregunta 1": "contenido", "Pregunta 2": "contenido", ...}. Este patrón es flexible y tolera espacios al inicio y formatos creativos. Además, elimina duplicados al inicio de la respuesta (por ejemplo, "Durante Durante ..."). """ # Se utiliza un lookahead para dividir cada bloque cuando se encuentre una línea que empiece con un número, # un punto o guión y, opcionalmente, otro número con punto o guión. bloques = re.split(r'(?=^\s*\d+[\.\-]\s*(?:\d+[\.\-])?\s*)', texto, flags=re.MULTILINE) resultado = {} for bloque in bloques: bloque = bloque.strip() if not bloque: continue # Extraemos el número de la pregunta y el contenido. match = re.match(r'^\s*(\d+)[\.\-]\s*(?:\d+[\.\-])?\s*(.*)', bloque) if match: numero = match.group(1) contenido = match.group(2) # Si hay múltiples líneas, unimos las líneas adicionales. lineas = bloque.split("\n") if len(lineas) > 1: contenido_completo = " ".join([linea.strip() for linea in lineas[1:]]) if contenido_completo: contenido = contenido + " " + contenido_completo # Eliminar duplicados al inicio (por ejemplo, "Durante Durante ..." se convierte en "Durante ...") contenido = re.sub(r'^(\S+)(\s+\1)+\s+', r'\1 ', contenido) resultado[f"Pregunta {numero}"] = contenido.strip() return resultado # ------------ # COMPARACIÓN Y ANÁLISIS # ------------ def similar_textos(texto1: str, texto2: str) -> float: """Calcula la similitud entre dos textos (valor entre 0 y 1).""" return SequenceMatcher(None, texto1, texto2).ratio() def comparar_preguntas_respuestas(dict_docente: dict, dict_alumno: dict) -> (str, list): """ Compara las respuestas del docente (correctas) con las del alumno. Para cada pregunta: - Si no fue asignada se indica "No fue asignada". - Si fue asignada se calcula la similitud y se evalúa: * Correcta: ratio >= 0.85 * Incompleta: 0.5 <= ratio < 0.85 * Incorrecta: ratio < 0.5 Devuelve: - Un string con la retroalimentación por pregunta. - Una lista de diccionarios con el análisis por pregunta (solo para las asignadas). """ feedback = [] analisis = [] for pregunta, resp_correcta in dict_docente.items(): correct_clean = " ".join(resp_correcta.split()) resp_alumno_raw = dict_alumno.get(pregunta, "").strip() if not resp_alumno_raw: feedback.append( f"**{pregunta}**\n" f"Respuesta del alumno: No fue asignada.\n" f"Respuesta correcta: {correct_clean}\n" ) analisis.append({"pregunta": pregunta, "asignada": False}) else: alumno_clean = " ".join(resp_alumno_raw.split()) ratio = similar_textos(alumno_clean.lower(), correct_clean.lower()) if ratio >= 0.85: eval_text = "La respuesta es correcta." resultado = "correcta" elif ratio >= 0.5: eval_text = "La respuesta es incompleta. Se observa que faltan conceptos clave." resultado = "incompleta" else: eval_text = "La respuesta es incorrecta. No se refleja el mecanismo o concepto correcto." resultado = "incorrecta" feedback.append( f"**{pregunta}**\n" f"Respuesta del alumno: {alumno_clean}\n" f"Respuesta correcta: {correct_clean}\n" f"{eval_text}\n" ) analisis.append({"pregunta": pregunta, "asignada": True, "resultado": resultado}) return "\n".join(feedback), analisis # ----------- # FUNCIÓN PRINCIPAL # ----------- def revisar_examen(json_cred, pdf_docente, pdf_alumno): """ Función generadora que: 1. Configura credenciales. 2. Extrae y parsea el contenido de los PDFs. 3. Separa las secciones 'Preguntas' y 'RESPUESTAS'. 4. Parsea las enumeraciones de cada sección (soportando formatos creativos). 5. Compara las respuestas del alumno con las correctas. 6. Llama a un LLM para generar un resumen final con retroalimentación. """ yield "Cargando credenciales..." try: configurar_credenciales(json_cred.name) yield "Inicializando Vertex AI..." vertexai.init(project="deploygpt", location="us-central1") yield "Extrayendo texto del PDF del docente..." texto_docente = extraer_texto(pdf_docente.name) yield "Extrayendo texto del PDF del alumno..." texto_alumno = extraer_texto(pdf_alumno.name) yield "Dividiendo secciones (docente)..." preguntas_doc, respuestas_doc = split_secciones(texto_docente) yield "Dividiendo secciones (alumno)..." preguntas_alum, respuestas_alum = split_secciones(texto_alumno) yield "Parseando enumeraciones (docente)..." dict_preg_doc = parsear_enumeraciones(preguntas_doc) dict_resp_doc = parsear_enumeraciones(respuestas_doc) # Unir las respuestas correctas del docente dict_docente = {} for key in dict_preg_doc: dict_docente[key] = dict_resp_doc.get(key, "") yield "Parseando enumeraciones (alumno)..." dict_preg_alum = parsear_enumeraciones(preguntas_alum) dict_resp_alum = parsear_enumeraciones(respuestas_alum) # Unir las respuestas del alumno dict_alumno = {} for key in dict_preg_alum: dict_alumno[key] = dict_resp_alum.get(key, "") yield "Comparando preguntas y respuestas..." feedback_text, analisis = comparar_preguntas_respuestas(dict_docente, dict_alumno) if len(feedback_text.strip()) < 5: yield "No se encontraron preguntas o respuestas válidas." return # Generar resumen global utilizando el LLM (solo para preguntas asignadas) analisis_asignadas = [a for a in analisis if a.get("asignada")] resumen_prompt = f""" A continuación se presenta el análisis por pregunta de un examen sobre la regulación del colesterol, considerando solo las preguntas asignadas al alumno: {analisis_asignadas} Con base en este análisis, genera un resumen del desempeño del alumno en el examen que incluya: - Puntos fuertes: conceptos que el alumno ha comprendido correctamente. - Puntos a reforzar: preguntas en las que la respuesta fue incompleta o incorrecta, indicando qué conceptos clave faltaron o se confundieron. - Una recomendación general sobre si el alumno demuestra comprender los fundamentos o si necesita repasar el tema. No incluyas en el análisis las preguntas que no fueron asignadas. """ yield "Generando resumen final con LLM..." model = GenerativeModel( "gemini-1.5-pro-001", system_instruction=["Eres un profesor experto en bioquímica. Evalúa el desempeño del alumno basándote en los conceptos clave, sin inventar elementos adicionales."] ) summary_part = Part.from_text(resumen_prompt) summary_resp = model.generate_content( [summary_part], generation_config=generation_config, safety_settings=safety_settings, stream=False ) resumen_final = summary_resp.text.strip() final_result = f"{feedback_text}\n\n**Resumen del desempeño:**\n{resumen_final}" yield final_result except Exception as e: yield f"Error al procesar: {str(e)}" # ----------------- # INTERFAZ DE GRADIO # ----------------- interface = gr.Interface( fn=revisar_examen, inputs=[ gr.File(label="Credenciales JSON"), gr.File(label="PDF del Docente"), gr.File(label="PDF del Alumno") ], outputs="text", title="Revisión de Exámenes (Preguntas/Respuestas enumeradas)", description=( "Sube las credenciales, el PDF del docente (con las preguntas y respuestas correctas) y el PDF del alumno. " "El sistema separa las secciones 'Preguntas' y 'RESPUESTAS', parsea las enumeraciones (soportando formatos creativos) " "y luego compara las respuestas. Se evalúa si el alumno comprende los conceptos fundamentales: si la respuesta está incompleta se indica qué falta, " "si es incorrecta se comenta por qué, y se omiten las preguntas no asignadas. Finalmente, se genera un resumen con recomendaciones." ) ) interface.launch(debug=True)