File size: 11,248 Bytes
00832a8
b54a6bc
 
a978321
7cba34a
 
0dfae1c
19c3ca0
a978321
 
 
7cba34a
f025ed5
d329e85
19c3ca0
7cba34a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b54a6bc
a978321
e341366
4d995a1
a978321
 
 
b54a6bc
a978321
 
 
 
b54a6bc
 
 
 
 
 
 
a978321
0dfae1c
a978321
 
19c3ca0
a978321
346f065
85b6c95
3778096
 
a978321
 
 
b7193be
346f065
 
a978321
 
 
 
 
 
 
3778096
a978321
 
3778096
346f065
a978321
346f065
 
3778096
a978321
 
3778096
 
a978321
346f065
3778096
 
 
 
346f065
3778096
 
 
 
346f065
 
 
3778096
a978321
 
 
0dfae1c
a978321
b7193be
 
 
 
0dfae1c
a978321
0dfae1c
 
 
 
 
 
 
 
 
3778096
a978321
0dfae1c
 
19c3ca0
0dfae1c
 
 
 
 
b7193be
 
0dfae1c
b7193be
0dfae1c
19c3ca0
0dfae1c
 
 
 
 
 
 
 
b7193be
0dfae1c
 
 
19c3ca0
0dfae1c
 
 
19c3ca0
0dfae1c
 
b54a6bc
a978321
0dfae1c
a978321
b54a6bc
a978321
0dfae1c
b7193be
0dfae1c
3778096
346f065
3778096
 
a978321
f025ed5
7cba34a
b54a6bc
0dfae1c
f025ed5
e341366
0dfae1c
f025ed5
19c3ca0
0dfae1c
f025ed5
19c3ca0
0dfae1c
a978321
 
0dfae1c
a978321
 
0dfae1c
a978321
 
 
346f065
a978321
0dfae1c
 
 
a978321
 
 
0dfae1c
a978321
0dfae1c
 
 
b7193be
0dfae1c
 
f025ed5
63918e4
0dfae1c
3778096
0dfae1c
 
 
 
 
 
 
 
 
 
a978321
 
 
0dfae1c
a978321
0dfae1c
19c3ca0
 
1e129b0
 
19c3ca0
1e129b0
0dfae1c
 
f025ed5
 
 
19c3ca0
a978321
 
 
7cba34a
 
 
b54a6bc
b527142
a978321
7cba34a
b7193be
a978321
b527142
b7193be
3778096
 
0dfae1c
b527142
7cba34a
 
f61af4a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
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)