Skip to content

Instantly share code, notes, and snippets.

@CortesAguilar
Created November 24, 2025 06:00
Show Gist options
  • Select an option

  • Save CortesAguilar/9025f5b893ce963550f1d987eeb90696 to your computer and use it in GitHub Desktop.

Select an option

Save CortesAguilar/9025f5b893ce963550f1d987eeb90696 to your computer and use it in GitHub Desktop.
AutoPractica con API de OpenAI Chatgtp via MicroPython

Integración de ChatGPT vía API en MicroPython con Raspberry Pi Pico W (Mini-RAG + Web UI)

Auto-Práctica – Lenguajes de Interfaz

Alumno: Javier Ulises Cortes Aguilar Matrícula: 22211541 Microcontrolador: Raspberry Pi Pico W (RP2040) Lenguaje: MicroPython


1. Introducción

Esta práctica implementa una integración completa entre MicroPython, ChatGPT y un sistema web dinámico. El objetivo es crear una mini aplicación de trivia interactiva sobre ajedrez, donde la Raspberry Pi Pico W solicita preguntas generadas por ChatGPT y las presenta en un servidor web local accesible desde cualquier navegador.

La práctica original pedía mostrar las respuestas en un display OLED; sin embargo, tuve problemas técnicos con mi hardware (detallados más adelante), por lo que opté por una interfaz web completamente funcional, apoyada en HTML generado con Stitch by Google y una arquitectura segura para consumir la API de OpenAI sin exponer claves.


2. Problemática inicial y cambios necesarios

2.1 Problema con el hotspot del teléfono

No pude avanzar la práctica en clase debido a que mi punto de acceso móvil bloqueaba la comunicación entre dispositivos por medio de AP Isolation, lo cual impide que una Raspberry Pi Pico W se conecte al servidor desde el mismo hotspot. Según ChatGPT, el modelo CUBOT P90 no permite desactivar AP isolation.

image

Solución: Utilizar una red WiFi doméstica.


2.2 Problemas con el Display OLED

Intenté integrar un display OLED modelo ARD 384, siguiendo estas conexiones:

VCC → 3V3 OUT
GND → GND
SDA → GP4
SCL → GP5

Probé también GP0 y GP1.

El resultado fue que el OLED solo mostraba basura, por lo que sospecho incompatibilidad o fallo físico. image

Por este motivo, el OLED fue omitido. En su lugar, se usa la consola de Thonny y una interfaz web completa.


3. Arquitectura general del sistema

Para poder conectar la Rasbperry a la API de OpenAI, se implementaron tres capas, debido a que MicroPython solo soporta HTTP/1.0 y no HTTPS:

3.1 Capa 1 – Raspberry Pi Pico W (MicroPython)

  • Corre un servidor web local (main.py)
  • Se conecta por WiFi
  • Genera UI dinámica con dos pantallas HTML:
image
  • pregunta.html
  • pantalla trivia.html
  • Solicita a un servidor externo un JSON con preguntas de trivia
  • Implementa un Mini-RAG local (selección de contexto local relevante)
  • Muestra detalles de depuración en consola (sin OLED)

3.2 Capa 2 – Proxy en FastAPI (proxy.py)

La Pico W NO puede mandar HTTPS, y Google Apps Script solo acepta HTTPS. Además, Apps Script requiere HTTP/1.1 y JSON estándar.

Por eso se creó este proxy: image

  • Recibe las peticiones HTTP/1.0 de la Pico
  • Parsea manualmente el JSON
  • Reenvía la petición a Apps Script mediante HTTPS
  • Devuelve la respuesta a la Pico

3.3 Capa 3 – Google Apps Script (doPost)

Es la capa que realmente llama a OpenAI. image

Funciones:

  • Recibe {pregunta, contexto}
  • Añade un prompt con Mini-RAG incorporado
  • Llama a la API de ChatGPT
  • image
  • Devuelve solo el texto resultante
  • Mantiene la clave privada segura

4. Flujo completo del sistema (Pico → Proxy → Apps Script → OpenAI → Pico)

  1. La Pico solicita “genera 5 preguntas tipo trivia”.

  2. Se selecciona automáticamente un contexto relevante (Mini-RAG).

  3. Se envía un JSON al proxy FastAPI.

  4. El proxy reenvía la petición a Google Apps Script.

  5. Apps Script llama a OpenAI.

  6. OpenAI devuelve un JSON con 5 preguntas, opciones, índice correcto y explicación.

  7. La Pico construye dinámicamente las pantallas web.

  8. El usuario responde las preguntas.

  9. Se muestra la pantalla final con:

    • puntaje
    • respuestas elegidas
    • respuestas correctas
    • explicaciones
  10. La consola de Thonny registra todo el proceso como diagnóstico.


5. Ejemplo real del log de Thonny

MPY: soft reboot
API_URL usado por la Pico:
http://192.168.100.16:8000/pico
Conectado a WiFi con IP: 192.168.100.25
Plantillas HTML cargadas correctamente.
Solicitando preguntas de trivia al LLM...

Contexto local seleccionado (Mini-RAG):
  [1] Las reglas actuales del ajedrez...
  [2] El ajedrez moderno se originó...
  [3] Los campeonatos mundiales oficiales...

Enviando solicitud al proxy...
Respuesta cruda recibida del proxy:

Preguntas recibidas: 5
Servidor web disponible en: http://192.168.100.25
Cliente conectado desde: ('192.168.100.16', 60509)
Petición: GET / {}
Pregunta 1 de 5
Opciones: ['Chaturanga', 'Go', 'Backgammon', 'Damas']
Cliente conectado desde: ('192.168.100.16', 56923)
Petición: GET /respuesta {'opcion': '0'}
Pregunta 1 contestada.
...
Test finalizado. Resultado final: 2/5

La consola funciona como pantalla alternativa ante la falta de OLED.


6. Mini-RAG: Recuperación de contexto local

El sistema incluye 3–5 frases locales.

Ejemplo:

"Las reglas actuales del ajedrez, incluyendo el movimiento de la dama..."
"El ajedrez moderno se originó del chaturanga..."
"Wilhelm Steinitz fue el primer campeón mundial oficial..."

Se compara la consulta con estas frases.

Esto mejora notablemente la calidad de las preguntas generadas y demuestra integración con técnicas básicas de RAG.


7. Interfaz web generada

Se usaron dos archivos generados con Stitch by Google: image

  • pregunta.html – UI de cada pregunta
  • pantalla trivia.html – resultados finales

Ambas pantallas son totalmente responsivas y se sirven desde la Raspberry Pico W.


8. Archivos incluidos en el proyecto

Archivo Función
main.py Servidor MicroPython + Mini-RAG + HTML dinámico
pregunta.html Pantalla de pregunta
pantalla trivia.html Pantalla de resultados
proxy.py Proxy HTTP/HTTPS para apps script
Code.gs Llamada segura a OpenAI vía Google Apps Script
README.md Documentación del proyecto

9. Conclusiones

  • A pesar de problemas con el hotspot y el display OLED, se logró una implementación completa funcional.
  • Se integró una arquitectura robusta con 3 capas que permite consumir la API de OpenAI desde MicroPython de manera segura.
  • Se logró implementar un sistema de Mini-RAG efectivo.
  • Se mostró un caso real de interfaz web embebida en un microcontrolador.
  • El proyecto supera la práctica original y demuestra manejo avanzado de APIs, servidores, y diseño frontend.

// ============================================================================
// Google Apps Script - Proxy seguro para llamadas a OpenAI desde la Pico W
// Materia: Lenguajes de Interfaz
// Alumno: Javier Ulises Cortes Aguilar - 22211541
//
// Este script recibe solicitudes POST desde la Raspberry Pi Pico,
// extrae "pregunta" y "contexto", genera un prompt combinado (Mini-RAG),
// llama a OpenAI GPT-4.1-mini de manera segura y devuelve SOLO la respuesta.
// ============================================================================
// Clave privada de OpenAI (nunca debe almacenarse en la Pico)
const OPENAI_API_KEY = "sk-proj********QA";
// ============================================================================
// doPost(e)
// Punto de entrada para solicitudes HTTP POST
// e.postData.contents contiene el JSON enviado por la Raspberry Pi Pico.
//
// Flujo:
// 1. Validar entrada
// 2. Leer pregunta y contexto
// 3. Construir prompt contextualizado
// 4. Llamar a OpenAI
// 5. Devolver respuesta limpia (solo texto)
// ============================================================================
function doPost(e) {
Logger.log("LLEGO POST");
Logger.log(JSON.stringify(e));
try {
Logger.log("Solicitud recibida");
// ----------------------------------------------------------------------
// 1. Validación de datos POST
// ----------------------------------------------------------------------
if (!e || !e.postData || !e.postData.contents) {
Logger.log("No hay datos POST");
return ContentService.createTextOutput("Error: No se enviaron datos")
.setMimeType(ContentService.MimeType.TEXT);
}
// Convertimos el contenido JSON recibido a objeto JS
const input = JSON.parse(e.postData.contents);
Logger.log("Contenido recibido: " + JSON.stringify(input));
// ----------------------------------------------------------------------
// 2. Extraer parámetros enviados por la Pico
// ----------------------------------------------------------------------
const pregunta = input.pregunta || "¿Qué es el ajedrez?";
const contexto = (input.contexto || []).join("\n"); // concatenamos el mini-RAG
// ----------------------------------------------------------------------
// 3. Construcción del prompt final
// El LLM recibe el contexto + la pregunta
// ----------------------------------------------------------------------
const prompt = `
Contexto:
${contexto}
Pregunta: ${pregunta}
Respuesta:
`;
// ----------------------------------------------------------------------
// 4. Payload para OpenAI (modelo, mensajes, temperatura)
// ----------------------------------------------------------------------
const payload = {
model: "gpt-4.1-mini",
messages: [
{
role: "system",
content: "Eres un experto en ajedrez que responde de forma breve y precisa."
},
{
role: "user",
content: prompt
}
],
temperature: 0.7
};
// ----------------------------------------------------------------------
// 5. Configuración de la solicitud HTTP hacia OpenAI
// ----------------------------------------------------------------------
const options = {
method: "POST",
contentType: "application/json",
headers: {
Authorization: "Bearer " + OPENAI_API_KEY // seguridad: clave oculta
},
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
Logger.log("Llamando a OpenAI...");
// ----------------------------------------------------------------------
// 6. Llamada real a OpenAI
// ----------------------------------------------------------------------
const res = UrlFetchApp.fetch(
"https://api.openai.com/v1/chat/completions",
options
);
const resText = res.getContentText();
Logger.log("Respuesta cruda: " + resText);
const data = JSON.parse(resText);
// ----------------------------------------------------------------------
// 7. Manejo de errores de OpenAI
// ----------------------------------------------------------------------
if (data.error) {
Logger.log("Error desde OpenAI: " + data.error.message);
return ContentService
.createTextOutput("Error desde OpenAI: " + data.error.message)
.setMimeType(ContentService.MimeType.TEXT);
}
// ----------------------------------------------------------------------
// 8. Extraer respuesta del modelo (solo texto)
// ----------------------------------------------------------------------
const texto = data.choices[0].message.content.trim();
// ----------------------------------------------------------------------
// 9. Respuesta final hacia la Pico
// ----------------------------------------------------------------------
return ContentService
.createTextOutput(texto)
.setMimeType(ContentService.MimeType.TEXT);
} catch (err) {
// ----------------------------------------------------------------------
// 10. Manejo global de excepciones
// ----------------------------------------------------------------------
Logger.log("Error capturado: " + err);
return ContentService
.createTextOutput("Error interno: " + err)
.setMimeType(ContentService.MimeType.TEXT);
}
}
# ╔══════════════════════════════════════════════════════════════╗
# ║ 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.")
<!DOCTYPE html>
<html class="light" lang="es">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Mini Trivia de Ajedrez – Resultados finales</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap"
rel="stylesheet"
/>
<style>
body {
font-family: "Inter", sans-serif;
}
.bg-board {
background-image:
radial-gradient(circle at 25px 25px, rgba(13, 27, 20, 0.04) 2%, transparent 0%),
radial-gradient(circle at 75px 75px, rgba(13, 27, 20, 0.04) 2%, transparent 0%);
background-size: 100px 100px;
}
</style>
</head>
<body class="font-display bg-[#f6f8f7] text-slate-800">
<div class="min-h-screen flex flex-col bg-board">
<!-- Top bar -->
<header class="w-full py-6 px-4">
<div class="max-w-4xl mx-auto flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-900 text-white"
>
<svg
viewBox="0 0 48 48"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
>
<path
d="M44 4H30.6666V17.3334H17.3334V30.6666H4V44H44V4Z"
></path>
</svg>
</div>
<div class="flex flex-col">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">
Mini Trivia de Ajedrez
</p>
<h1 class="text-sm font-semibold text-slate-900">
Resultados finales
</h1>
</div>
</div>
</header>
<!-- Main -->
<main class="flex-1 flex justify-center px-4 pb-12">
<div
class="w-full max-w-3xl mx-auto rounded-3xl bg-white shadow-lg border border-slate-100 overflow-visible"
>
<div class="px-6 sm:px-10 pt-10 pb-8 text-center">
<h2
class="text-3xl sm:text-4xl font-extrabold tracking-tight text-slate-900"
>
¡Trivia completada!
</h2>
<p class="mt-4 text-sm font-medium text-slate-500">
Puntaje final
</p>
<p class="mt-1 text-5xl font-black text-slate-900">
[[PUNTAJE]]
<span class="text-3xl font-bold text-slate-400">
/ [[TOTAL_PREGUNTAS]]
</span>
</p>
<p class="mt-2 text-xs sm:text-sm text-slate-500">
Cada respuesta correcta suma 1 punto.
</p>
</div>
<!-- Lista de preguntas -->
<div class="px-6 sm:px-10 pb-10">
<h3
class="text-left text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 mb-3"
>
Detalle de tus respuestas
</h3>
<div class="flex flex-col gap-3">
[[LISTA_RESULTADOS]]
</div>
</div>
</div>
</main>
</div>
</body>
</html>
<!DOCTYPE html>
<html class="light" lang="es">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Mini Trivia de Ajedrez</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossorigin
/>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL,GRAD@100..700,0..1,0..200"
/>
<style>
body {
font-family: "Inter", sans-serif;
}
.material-symbols-outlined {
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0,
"opsz" 24;
}
.bg-board {
background-image:
radial-gradient(circle at 25px 25px, rgba(13, 27, 20, 0.05) 2%, transparent 0%),
radial-gradient(circle at 75px 75px, rgba(13, 27, 20, 0.05) 2%, transparent 0%);
background-size: 100px 100px;
}
</style>
</head>
<body class="font-display bg-background-light text-slate-800">
<div class="min-h-screen flex flex-col bg-board">
<!-- Header -->
<header class="w-full bg-[#102219] shadow-md">
<div class="mx-auto max-w-[960px] px-4 py-6">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-[#13ec80]/10 text-[#13ec80]"
>
<svg
viewBox="0 0 48 48"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
>
<path
d="M44 4H30.6666V17.3334H17.3334V30.6666H4V44H44V4Z"
></path>
</svg>
</div>
<div class="flex flex-col">
<h1 class="text-lg font-bold tracking-tight text-white">
Mini trivia de ajedrez
</h1>
<p class="text-sm text-emerald-200/80">
Historia, reglas y curiosidades del tablero.
</p>
</div>
</div>
</div>
</header>
<!-- Main -->
<main class="flex-1 flex justify-center py-10 sm:py-16 px-4">
<div class="w-full max-w-2xl">
<!-- Progreso -->
<div class="mb-6">
<p class="text-base font-medium text-slate-800">
Pregunta [[NUMERO_PREGUNTA]] de [[TOTAL_PREGUNTAS]]
</p>
<div class="mt-2 h-2 w-full rounded-full bg-[#bbf7d0]">
<div
class="h-2 rounded-full bg-[#13ec80]"
style="width: [[PORCENTAJE_BARRA]]%;"
></div>
</div>
</div>
<!-- Card -->
<div
class="rounded-2xl bg-white shadow-lg border border-black/5 overflow-hidden"
>
<!-- Texto de la pregunta -->
<div class="p-6 sm:p-8">
<div class="flex items-center gap-3 mb-4">
<span
class="material-symbols-outlined text-[#13ec80] text-2xl"
>
quiz
</span>
<p
class="text-xs font-semibold tracking-[0.12em] text-[#13ec80]"
>
[[CATEGORIA]]
</p>
</div>
<p class="text-2xl font-bold leading-tight text-slate-900">
[[TEXTO_PREGUNTA]]
</p>
</div>
<!-- Opciones -->
<div
class="bg-[#f9fafb] px-6 sm:px-8 py-6"
style="display:flex;flex-direction:column;gap:12px;"
>
[[OPCIONES]]
</div>
</div>
<p class="mt-4 text-center text-sm text-slate-500">
Haz clic en una opción para continuar con la siguiente
pregunta.
</p>
</div>
</main>
</div>
</body>
</html>
# =============================================================================
# proxy.py — Servidor FastAPI como intermediario entre Pico W y Apps Script
#
# Materia : Lenguajes de Interfaz
# Alumno : Javier Ulises Cortes Aguilar - 22211541
# Proyecto : Mini-RAG + Trivia de Ajedrez con backend seguro
#
# Este servidor recibe peticiones POST desde la Raspberry Pi Pico (MicroPython),
# parsea el JSON manualmente — porque MicroPython envía HTTP/1.0 — y reenvía
# esos datos al Google Apps Script (Stitch), que se encarga de llamar a OpenAI.
#
# Ventajas:
# • No expones claves API en la Pico.
# • Puedes inspeccionar tráfico y agregar seguridad.
# • Maneja compatibilidad entre HTTP/1.0 ↔ HTTP/1.1.
# =============================================================================
from fastapi import FastAPI, Request
import requests
import uvicorn
import json
# -----------------------------------------------------------------------------
# URL del Apps Script (Stitch)
# Se encuentra desplegado y recibe JSON con: {pregunta, contexto}
# -----------------------------------------------------------------------------
STITCH_URL = (
"https://script.google.com/macros/s/AKfycbyu26mgPD1O36nRnF42MIx5p4K484rG3KarfpgmCcE35lJW-vMFvXmP9tw2Y1GKgFSugw/exec"
)
# Crear instancia FastAPI
app = FastAPI()
# -----------------------------------------------------------------------------
# Ruta principal: POST /pico
#
# La Pico W envía un JSON como string por HTTP/1.0, por lo que NO se puede usar
# request.json() (FastAPI espera HTTP/1.1). Se debe leer el body crudo.
# -----------------------------------------------------------------------------
@app.post("/pico")
async def proxy_pico(request: Request):
# -------------------------------------------------------------------------
# 1. Leer body crudo (bytes) tal como llega desde MicroPython
# -------------------------------------------------------------------------
raw_body = await request.body()
try:
# Intentamos decodificar como texto
decoded = raw_body.decode()
# Intentamos convertir ese texto a JSON
data = json.loads(decoded)
except Exception as e:
# Si algo falla, devolvemos información diagnóstica
return {
"error": f"Error parseando JSON: {str(e)}",
"raw": decoded if "decoded" in locals() else raw_body,
}
# -------------------------------------------------------------------------
# 2. Reenviar los datos al Apps Script (Stitch)
# -------------------------------------------------------------------------
try:
# Hacemos POST al Apps Script — aquí va la llamada a OpenAI
res = requests.post(STITCH_URL, json=data, timeout=10)
# Devolvemos un JSON limpio para la Pico
return {"respuesta": res.text}
except Exception as e:
return {"error": f"Error al contactar Stitch: {str(e)}"}
# -----------------------------------------------------------------------------
# Lanzar servidor con Uvicorn
#
# Notas:
# • host debe ser tu IP LAN (para que la Pico pueda conectarse)
# • MicroPython maneja mejor IPv4 explícito
# -----------------------------------------------------------------------------
if __name__ == "__main__":
uvicorn.run(app, host="192.168.100.16", port=8000)
@CortesAguilar
Copy link
Author

Screen-Recording_compressed.mp4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment