Spaces:
Sleeping
Sleeping
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) | |