DemoProfeIA / app.py
cesar's picture
Update app.py
346f065 verified
raw
history blame
11.2 kB
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)