|
# ╔══════════════════════════════════════════════════════════════╗ |
|
# ║ Mini Trivia de Ajedrez – Raspberry Pi Pico W ║ |
|
# ║ ║ |
|
# ║ Materia : Lenguajes de Interfaz ║ |
|
# ║ Alumno : Javier Ulises Cortes Aguilar ║ |
|
# ║ Matrícula : 22211541 ║ |
|
# ║ Proyecto : Web UI + ChatGPT (Mini-RAG) para trivia ║ |
|
# ║ ║ |
|
# ╚══════════════════════════════════════════════════════════════╝ |
|
|
|
import network |
|
import socket |
|
import time |
|
import ujson |
|
import urequests |
|
|
|
# --- CONFIGURACIÓN WiFi --- |
|
SSID = "SrCrowhite" |
|
PASSWORD = "TotalPrueba01!" |
|
|
|
# --- URL del PROXY FastAPI --- |
|
API_URL = "http://192.168.100.16:8000/pico" |
|
|
|
print("API_URL usado por la Pico:") |
|
print(API_URL) |
|
|
|
# ------------------------------- |
|
# ESTADO GLOBAL DE LA TRIVIA |
|
# ------------------------------- |
|
preguntas = [] # lista de dicts con: categoria, texto, opciones, correcta, explicacion |
|
respuestas_usuario = [] # índice elegido por el usuario por pregunta |
|
indice_actual = 0 # índice de la pregunta que se está mostrando |
|
|
|
PREGUNTA_TEMPLATE = "" |
|
RESULTADOS_TEMPLATE = "" |
|
|
|
# ------------------------------- |
|
# CONTEXTO LOCAL (Mini-RAG) |
|
# ------------------------------- |
|
# 3–5 frases cortas que representan "conocimiento local" del sistema. |
|
# Se usarán para enriquecer el prompt que va al LLM. |
|
CONTEXTO_LOCAL = [ |
|
"El ajedrez moderno se originó a partir del juego indio chaturanga y evolucionó en Europa durante la Edad Media.", |
|
"Las reglas actuales del ajedrez, incluyendo el movimiento de la dama y el enroque, se consolidaron entre los siglos XV y XIX.", |
|
"Los campeonatos mundiales oficiales de ajedrez comenzaron en el siglo XIX con Wilhelm Steinitz como primer campeón.", |
|
"Conceptos clave del ajedrez incluyen desarrollo de piezas, control del centro, seguridad del rey y estructura de peones.", |
|
"Jugadores famosos como Bobby Fischer, Garry Kasparov y Magnus Carlsen marcaron distintas épocas del ajedrez competitivo." |
|
] |
|
|
|
|
|
def obtener_contexto_relevante(consulta, max_items=3): |
|
""" |
|
Mini-RAG muy simple: |
|
- Divide la consulta y las frases en palabras. |
|
- Cuenta coincidencias. |
|
- Devuelve las 'max_items' frases con mayor puntaje. |
|
""" |
|
consulta_palabras = set( |
|
consulta.lower() |
|
.replace("¿", "") |
|
.replace("?", "") |
|
.replace(",", " ") |
|
.replace(".", " ") |
|
.split() |
|
) |
|
|
|
puntuados = [] |
|
for frase in CONTEXTO_LOCAL: |
|
palabras = set( |
|
frase.lower() |
|
.replace(",", " ") |
|
.replace(".", " ") |
|
.split() |
|
) |
|
score = len(consulta_palabras & palabras) |
|
puntuados.append((score, frase)) |
|
|
|
# Ordenar por score descendente |
|
puntuados.sort(key=lambda x: x[0], reverse=True) |
|
|
|
relevantes = [f for score, f in puntuados if score > 0][:max_items] |
|
if not relevantes: |
|
relevantes = CONTEXTO_LOCAL[:max_items] |
|
|
|
print("Contexto local seleccionado (Mini-RAG):") |
|
for idx, frase in enumerate(relevantes): |
|
print(" [{}] {}".format(idx + 1, frase)) |
|
|
|
return relevantes |
|
|
|
|
|
# --- Conexión WiFi --- |
|
def conectar_wifi(): |
|
wlan = network.WLAN(network.STA_IF) |
|
wlan.active(True) |
|
|
|
if not wlan.isconnected(): |
|
print("Conectando a WiFi...") |
|
wlan.connect(SSID, PASSWORD) |
|
for _ in range(12): |
|
if wlan.isconnected(): |
|
break |
|
time.sleep(1) |
|
|
|
if wlan.isconnected(): |
|
ip = wlan.ifconfig()[0] |
|
print("Conectado a WiFi con IP:", ip) |
|
return True |
|
else: |
|
print("No se pudo conectar a WiFi.") |
|
return False |
|
|
|
|
|
# --- Consultar LLM vía proxy --- |
|
def consultar_llm(pregunta, contexto): |
|
""" |
|
Envía 'pregunta' y 'contexto' al Apps Script mediante el proxy FastAPI. |
|
El proxy responde un JSON con la forma: |
|
{"respuesta": "texto que devolvió ChatGPT"} |
|
Esta función devuelve SOLO el contenido del campo "respuesta". |
|
""" |
|
payload = { |
|
"pregunta": pregunta, |
|
"contexto": contexto |
|
} |
|
|
|
try: |
|
print("Enviando solicitud al proxy...") |
|
|
|
body = ujson.dumps(payload).encode("utf-8") |
|
|
|
response = urequests.post( |
|
API_URL, |
|
data=body, |
|
headers={ |
|
"Content-Type": "application/json; charset=utf-8", |
|
"Content-Length": str(len(body)) |
|
} |
|
) |
|
|
|
raw_text = response.text.strip() |
|
print("Respuesta cruda recibida del proxy:") |
|
print(raw_text) |
|
response.close() |
|
|
|
# Parseamos el JSON externo del proxy |
|
try: |
|
data = ujson.loads(raw_text) |
|
if isinstance(data, dict) and "respuesta" in data: |
|
return data["respuesta"] |
|
except Exception as e: |
|
print("No se pudo parsear JSON externo, devolviendo texto crudo. Error:", e) |
|
|
|
# En caso de duda devolvemos todo el texto |
|
return raw_text |
|
|
|
except Exception as e: |
|
print("ERROR en la solicitud:", e) |
|
return "ERROR:" + str(e) |
|
|
|
|
|
# ----------------------------------- |
|
# UTILIDADES PARA LA TRIVIA |
|
# ----------------------------------- |
|
def cargar_templates(): |
|
global PREGUNTA_TEMPLATE, RESULTADOS_TEMPLATE |
|
try: |
|
with open("pregunta.html", "r") as f: |
|
PREGUNTA_TEMPLATE = f.read() |
|
with open("pantalla trivia.html", "r") as f: |
|
RESULTADOS_TEMPLATE = f.read() |
|
print("Plantillas HTML cargadas correctamente.") |
|
except Exception as e: |
|
print("Error cargando plantillas HTML:", e) |
|
|
|
|
|
def limpiar_respuesta_llm(texto): |
|
""" |
|
Limpia posibles bloques ```json ... ``` y deja solo el JSON interno. |
|
""" |
|
if not texto: |
|
return "" |
|
texto = texto.strip() |
|
if texto.startswith("```"): |
|
lineas = texto.splitlines() |
|
filtradas = [] |
|
for linea in lineas: |
|
l = linea.strip() |
|
if l.startswith("```"): |
|
continue |
|
filtradas.append(linea) |
|
texto = "\n".join(filtradas).strip() |
|
return texto |
|
|
|
|
|
def obtener_trivia_desde_llm(n_preguntas=5): |
|
""" |
|
Pide al LLM que genere un JSON con preguntas de trivia sobre ajedrez. |
|
Incluye 'explicacion' (15-20 palabras) para cada pregunta. |
|
Usa contexto local (Mini-RAG) para enriquecer la respuesta. |
|
""" |
|
prompt = ( |
|
"Genera un JSON con exactamente " + str(n_preguntas) + " preguntas de trivia sobre ajedrez en español. " |
|
"Responde SOLO con JSON válido, sin nada más. " |
|
'El formato debe ser: { "preguntas": [ { ' |
|
'"categoria": "Historia del ajedrez", ' |
|
'"texto": "¿Pregunta?", ' |
|
'"opciones": ["opcion1","opcion2","opcion3","opcion4"], ' |
|
'"correcta": 0, ' |
|
'"explicacion": "Breve explicación de por qué la respuesta correcta lo es, entre 15 y 20 palabras." ' |
|
"}, ... ] }. " |
|
"\"correcta\" es el índice (0-3) de la opción correcta. " |
|
"Incluye preguntas de historia, reglas, curiosidades y jugadores famosos. " |
|
"No expliques nada fuera del JSON, no agregues texto adicional." |
|
) |
|
|
|
print("Solicitando preguntas de trivia al LLM...") |
|
|
|
# Mini-RAG: seleccionar contexto relevante según el prompt |
|
contexto_relevante = obtener_contexto_relevante(prompt, max_items=3) |
|
|
|
bruto = consultar_llm(prompt, contexto_relevante) |
|
|
|
if bruto.startswith("ERROR:"): |
|
print("Fallo al obtener trivia:", bruto) |
|
return [] |
|
|
|
limpio = limpiar_respuesta_llm(bruto) |
|
|
|
try: |
|
data_llm = ujson.loads(limpio) |
|
except Exception as e: |
|
print("Error parseando JSON desde el LLM:", e) |
|
print("Texto recibido (limpio):") |
|
print(limpio) |
|
return [] |
|
|
|
lista = data_llm.get("preguntas", []) |
|
print("Preguntas recibidas:", len(lista)) |
|
return lista |
|
|
|
|
|
def parsear_query(path): |
|
""" |
|
Divide '/respuesta?opcion=1' en ('/respuesta', {'opcion': '1'}) |
|
""" |
|
if "?" not in path: |
|
return path, {} |
|
base, qs = path.split("?", 1) |
|
params = {} |
|
for par in qs.split("&"): |
|
if "=" in par: |
|
k, v = par.split("=", 1) |
|
params[k] = v |
|
return base, params |
|
|
|
|
|
def render_pregunta_html(): |
|
""" |
|
Rellena la plantilla de pregunta con los datos de la pregunta actual. |
|
Usa los placeholders: |
|
[[NUMERO_PREGUNTA]], [[TOTAL_PREGUNTAS]], [[PORCENTAJE_BARRA]], |
|
[[CATEGORIA]], [[TEXTO_PREGUNTA]], [[OPCIONES]] |
|
""" |
|
global indice_actual, preguntas |
|
|
|
p = preguntas[indice_actual] |
|
num = indice_actual + 1 |
|
total = len(preguntas) |
|
porcentaje = int(num * 100 / total) |
|
|
|
print("Pregunta", num, "de", total) |
|
print("Opciones:", p["opciones"]) |
|
|
|
# Construimos las opciones con HTML simple, usando A), B), C), D) |
|
opciones_html = "" |
|
for idx, texto in enumerate(p["opciones"]): |
|
letra = chr(ord('A') + idx) |
|
opciones_html += ( |
|
'<div style="margin-top:8px;">' |
|
'<a href="/respuesta?opcion=' + str(idx) + '" ' |
|
'style="display:block;padding:12px 16px;border-radius:999px;' |
|
'border:1px solid #e5e7eb;background:#ffffff;' |
|
'font-family:system-ui, -apple-system, BlinkMacSystemFont, \'Inter\', sans-serif;' |
|
'font-size:16px;color:#111827;text-decoration:none;box-shadow:0 1px 2px rgba(15,23,42,0.04);">' |
|
+ letra + ") " + texto + |
|
"</a>" |
|
"</div>\n" |
|
) |
|
|
|
html = PREGUNTA_TEMPLATE |
|
html = html.replace("[[NUMERO_PREGUNTA]]", str(num)) |
|
html = html.replace("[[TOTAL_PREGUNTAS]]", str(total)) |
|
html = html.replace("[[PORCENTAJE_BARRA]]", str(porcentaje)) |
|
html = html.replace("[[CATEGORIA]]", p.get("categoria", "Trivia de ajedrez")) |
|
html = html.replace("[[TEXTO_PREGUNTA]]", p["texto"]) |
|
html = html.replace("[[OPCIONES]]", opciones_html) |
|
|
|
return html |
|
|
|
|
|
def render_resultados_html(): |
|
""" |
|
Rellena la plantilla de resultados con: |
|
- puntaje, |
|
- lista de preguntas, |
|
- tu respuesta, |
|
- respuesta correcta (si fallaste), |
|
- explicación corta. |
|
""" |
|
global preguntas, respuestas_usuario |
|
|
|
total = len(preguntas) |
|
correctas = 0 |
|
items_html = "" |
|
|
|
for i, p in enumerate(preguntas): |
|
elegida = respuestas_usuario[i] |
|
correcta = p.get("correcta", 0) |
|
opciones = p.get("opciones", []) |
|
texto_p = p.get("texto", "Pregunta") |
|
|
|
# Textos de respuesta |
|
if elegida is not None and 0 <= elegida < len(opciones): |
|
texto_elegida = opciones[elegida] |
|
else: |
|
texto_elegida = "Sin respuesta" |
|
|
|
if 0 <= correcta < len(opciones): |
|
texto_correcta = opciones[correcta] |
|
else: |
|
texto_correcta = "N/D" |
|
|
|
explicacion = p.get("explicacion", "") |
|
|
|
es_ok = (elegida == correcta) |
|
if es_ok: |
|
correctas += 1 |
|
|
|
icono = "✓" if es_ok else "✕" |
|
color_bg = "#dcfce7" if es_ok else "#fee2e2" |
|
color_text = "#166534" if es_ok else "#b91c1c" |
|
|
|
# Card de resultado por pregunta |
|
items_html += ( |
|
'<div style="margin-top:10px;border-radius:999px;padding:10px 16px;' |
|
'background:' + color_bg + ';' |
|
'font-family:system-ui, -apple-system, BlinkMacSystemFont, \'Inter\', sans-serif;' |
|
'font-size:14px;line-height:1.4;">' |
|
'<div style="font-weight:600;color:' + color_text + ';margin-bottom:4px;">' |
|
+ icono + " " + str(i + 1) + ". " + texto_p + |
|
"</div>" |
|
'<div style="color:#374151;">Tu respuesta: ' + texto_elegida + "</div>" |
|
) |
|
|
|
if not es_ok: |
|
items_html += ( |
|
'<div style="color:#111827;">Respuesta correcta: ' + |
|
texto_correcta + |
|
"</div>" |
|
) |
|
|
|
if explicacion: |
|
items_html += ( |
|
'<div style="color:#6b7280;font-size:12px;margin-top:4px;">' |
|
+ explicacion + |
|
"</div>" |
|
) |
|
|
|
items_html += "</div>\n" |
|
|
|
# Log bonito al finalizar |
|
print("Test finalizado. Resultado final: {}/{}".format(correctas, total)) |
|
|
|
html = RESULTADOS_TEMPLATE |
|
html = html.replace("[[PUNTAJE]]", str(correctas)) |
|
html = html.replace("[[TOTAL_PREGUNTAS]]", str(total)) |
|
html = html.replace("[[LISTA_RESULTADOS]]", items_html) |
|
|
|
return html |
|
|
|
|
|
# --- Servidor web local Pico W --- |
|
def iniciar_webserver(): |
|
global indice_actual, preguntas, respuestas_usuario |
|
|
|
addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1] |
|
s = socket.socket() |
|
s.bind(addr) |
|
s.listen(1) |
|
|
|
ip = network.WLAN(network.STA_IF).ifconfig()[0] |
|
print("Servidor web disponible en: http://" + ip) |
|
|
|
while True: |
|
try: |
|
cl, addr = s.accept() |
|
print("Cliente conectado desde:", addr) |
|
req = cl.recv(1024) |
|
if not req: |
|
cl.close() |
|
continue |
|
|
|
# Primera línea de la petición: "GET /ruta HTTP/1.1" |
|
req_line = req.decode().split("\r\n")[0] |
|
partes = req_line.split(" ") |
|
if len(partes) < 2: |
|
cl.close() |
|
continue |
|
|
|
metodo = partes[0] |
|
full_path = partes[1] |
|
path, params = parsear_query(full_path) |
|
|
|
print("Petición:", metodo, path, params) |
|
|
|
if path == "/" or path == "/pregunta": |
|
body = render_pregunta_html() |
|
|
|
elif path == "/respuesta": |
|
opcion_str = params.get("opcion", "0") |
|
try: |
|
opcion = int(opcion_str) |
|
except: |
|
opcion = 0 |
|
|
|
# Número humano de pregunta antes de avanzar |
|
num_pregunta = indice_actual + 1 |
|
print("Pregunta {} contestada.".format(num_pregunta)) |
|
|
|
# Guardar la respuesta del usuario |
|
respuestas_usuario[indice_actual] = opcion |
|
indice_actual += 1 |
|
|
|
if indice_actual < len(preguntas): |
|
body = render_pregunta_html() |
|
else: |
|
# La siguiente vista será resultados; |
|
# el print del resultado final se hace dentro de render_resultados_html |
|
body = render_resultados_html() |
|
|
|
else: |
|
body = "<h1>404 - Recurso no encontrado</h1>" |
|
|
|
respuesta = ( |
|
"HTTP/1.1 200 OK\r\n" |
|
"Content-Type: text/html; charset=utf-8\r\n" |
|
"Connection: close\r\n" |
|
"\r\n" + body |
|
) |
|
cl.send(respuesta) |
|
|
|
except Exception as e: |
|
print("Error en webserver:", e) |
|
finally: |
|
try: |
|
cl.close() |
|
except: |
|
pass |
|
|
|
|
|
# --- Inicio del flujo --- |
|
if conectar_wifi(): |
|
# Cargar HTML desde archivos |
|
cargar_templates() |
|
|
|
# Pedir preguntas al LLM usando Mini-RAG |
|
preguntas = obtener_trivia_desde_llm(5) |
|
|
|
if not preguntas: |
|
print("No se pudieron obtener preguntas desde el LLM. Revisa conexión, proxy o formato de respuesta.") |
|
else: |
|
# Inicializar estado de respuestas |
|
respuestas_usuario = [None] * len(preguntas) |
|
indice_actual = 0 |
|
|
|
# Levantar servidor HTTP |
|
iniciar_webserver() |
|
else: |
|
print("Sin red, no se puede iniciar el servidor.") |
|
|
Screen-Recording_compressed.mp4