
Proyecto PINSAPIA: ¿Quién lo dijo, el humano o la máquina? (Línea Johnson — Modo Juego Individual)
📌 Ficha Técnica y Metadatos
- Slug:
pinsapia-linea-johnson-quien-lo-dijo-humano-o-maquina-individual-montesteam - Extract: Adaptación interactiva del Test de Turing para juego individual en el aula, dentro de los marcos MonteSTEAM y AIDARAC del IES Monterroso. Cada estudiante interactúa desde su propia pantalla evaluando textos humanos reales (cargados mediante archivo
.txto catálogo base) frente a textos redactados por un LLM local (Qwen en LM Studio). La aplicación ofrece retroalimentación inmediata respuesta a respuesta, calcula el porcentaje individual de acierto y determina al final de la partida si el alumno ha ganado al modelo o si ha sido engañado por la IA. - Palabras clave:
aidarac,montesteam,ies monterroso,proyecto pinsapia,linea johnson,katherine johnson,test de turing,juego individual,evaluacion de ia,fastapi,puerto 8085,ia en el aula,pensamiento critico,alfabetizacion de ia
1. Katherine Johnson y el reto de la verificación individual
Katherine Johnson (1918–2020) fue una de las mentes matemáticas más brillantes de la NASA. En la transición hacia el cálculo automatizado, cuando astronautas como John Glenn dudaban de las trayectorias generadas por los nuevos ordenadores IBM, fue ella quien revisó y verificó manualmente cada ecuación antes del lanzamiento espacial.
En esta modalidad del Proyecto PINSAPIA, cada estudiante asume el rol individual de Katherine Johnson desde su propia pantalla. En lugar de limitarse a una votación grupal pasiva, cada alumno se enfrenta de manera autónoma a un conjunto de textos, evalúa las evidencias estilísticas de cada afirmación y recibe respuesta inmediata sobre su capacidad de discriminación. Al finalizar el recorrido, la aplicación determina si el alumno ha superado el test con éxito.
2. Guía de Instalación y Código Fuente (app_johnson.py)
Requisitos previos e instalación de dependencias
Abre la terminal de tu sistema e instala los paquetes de Python necesarios (incluyendo python-multipart para la subida de archivos .txt):
Bash
pip install fastapi "uvicorn[standard]" httpx pydantic python-multipart
Código Completo del Servidor (app_johnson.py)
Guarda el siguiente código en un archivo llamado app_johnson.py:
Python
import uuid
import random
import time
from typing import List, Dict, Any, Optional
from fastapi import FastAPI, HTTPException, UploadFile, File, Form
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
import httpx
app = FastAPI(
title="Proyecto PINSAPIA - Línea Johnson: ¿Quién lo dijo? (Modo Individual)",
description="Desafío individual del Test de Turing en el aula con verificación e historial de aciertos."
)
LM_STUDIO_API_URL = "http://localhost:1234/v1/chat/completions"
MODEL_NAME = "local-model"
# -----------------------------------------------------------------------------
# BANCO BASE DE TEXTOS HUMANOS Y IA (RESPALDO Y POR DEFECTO)
# -----------------------------------------------------------------------------
DEFAULT_TOPIC = "Describe tu recreo ideal"
DEFAULT_HUMAN_TEXTS = [
"Mi recreo ideal tendría que durar por lo menos cuarenta y cinco minutos. Nos juntaríamos todos en las gradas de la pista a comer el bocata y a hablar de lo que vamos a hacer el fin de semana. Además, pondrían música por los altavoces del patio y los profesores no estarían llamándonos la atención por todo. Un rato de risas sin pensar en las clases de después.",
"Para mí el recreo perfecto sería en un día soleado, sentados en el césped del patio a la sombra. Cada uno traería algo de picar para compartir y jugaríamos una pachanga rápida de baloncesto. Lo más importante es que el tiempo pasara despacio, porque siempre siento que cuando mejor lo estamos pasando suena el timbre.",
"En mi recreo ideal el pabellón estaría abierto para poder jugar al voleibol sin pasar frío cuando llueve. Comeríamos un buen bocadillo con calma y habría una cafetería en el insti con batidos baratos. Sería el momento perfecto para desconectar del agobio de los exámenes de primera hora.",
"Un recreo ideal sería uno en el que no hubiera que hacer diez minutos de cola para comprar un agua o ir al baño. Tendríamos bancos cómodos en el patio para charlar tranquilamente y escuchar música entre amigos. Con media hora limpia de risas y sol con la gente de clase me basta.",
"Mi descanso ideal tendría un torneo de tenis de mesa o futbolín organizado por cursos en el vestíbulo. Se pasaría el tiempo volando entre los partidos y los ánimos de la gente. Y si además la cafetería regala palmeras de chocolate los viernes, el día ya sería perfecto."
]
FALLBACK_MACHINE_TEXTS = [
"El recreo perfecto consistiría en salir rápido al patio con la gente de mi grupo y encontrar libre la mesa de madera del centro. Comeríamos el bocata tranquilos mientras repasamos los chismes del día o planeamos qué hacer por la tarde. Lo mejor sería que durase un poco más para que no te corte la conversación a la mitad.",
"Para mí el mejor recreo es cuando no hace un calor sofocante y podemos estar en la pista jugando sin prisa. Apenas da tiempo a comer algo rápido y hablar de los juegos del fin de semana, pero al menos te despeja la cabeza antes de la siguiente clase.",
"Un descanso ideal sería poder poner música con un altavoz pequeño en la zona del césped sin que nadie ponga pegas. Estaríamos todos relajados charlando sin acordarnos de los exámenes y comprando algo fresco en la cafetería sin hacer filas eternas.",
"Mi recreo soñado sería que el timbre tardara una hora en sonar y que hubiera sitio de sobra para sentarse en la sombra. Hablaríamos de vídeos de internet o de partidos de fútbol entre risas y sin ningún agobio.",
"Lo ideal en el recreo es tener tiempo suficiente para terminarte el almuerzo sin atragantarte y poder dar una vuelta entera al patio con los amigos. Con tal de despejar la mente y reírse un rato, cualquier día se vuelve mejor."
]
# -----------------------------------------------------------------------------
# ALMACENAMIENTO GLOBAL DE POOL Y PARTIDAS INDIVIDUALES
# -----------------------------------------------------------------------------
GLOBAL_HUMAN_POOL: List[str] = list(DEFAULT_HUMAN_TEXTS)
GLOBAL_TOPIC: str = DEFAULT_TOPIC
GAMES_STORE: Dict[str, Dict[str, Any]] = {}
# -----------------------------------------------------------------------------
# GENERACIÓN DE TEXTOS IA VÍA LLM LOCAL
# -----------------------------------------------------------------------------
async def generate_llm_texts(topic: str, count: int) -> List[str]:
system_prompt = (
"Eres un estudiante adolescente de instituto español escribiendo de forma informal. "
"Escribe un texto de entre 3 y 5 frases sobre el tema que te pidan. "
"REGLAS CRÍTICAS DE ESTILO: "
"1. NO uses frases formales de cierre (CERO 'Espero que te guste', 'En conclusión' o 'Sin duda'). "
"2. Usa un lenguaje natural, directo, fresco, con giros cotidianos de joven pero sin exagerar ni forzar jerga. "
"3. Incluye pequeñas imperfecciones de redacción o estructuras sueltas propias de la oralidad escrita. "
"4. Sé breve y responde directamente sin intros largas."
)
generated = []
async with httpx.AsyncClient() as client:
for _ in range(count):
try:
payload = {
"model": MODEL_NAME,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Tema: {topic}"}
],
"temperature": 0.85,
"max_tokens": 160
}
res = await client.post(LM_STUDIO_API_URL, json=payload, timeout=4.0)
if res.status_code == 200:
txt = res.json()["choices"][0]["message"]["content"].strip()
generated.append(txt)
except Exception:
pass
if len(generated) < count:
needed = count - len(generated)
generated.extend(random.sample(FALLBACK_MACHINE_TEXTS, min(needed, len(FALLBACK_MACHINE_TEXTS))))
return generated[:count]
# -----------------------------------------------------------------------------
# MODELOS DE DATOS Y ENDPOINTS DE LA API
# -----------------------------------------------------------------------------
class GuessRequest(BaseModel):
game_id: str
guess: str # "humano" o "maquina"
class NextRequest(BaseModel):
game_id: str
@app.post("/api/upload_texts")
async def upload_texts(
topic: Optional[str] = Form(None),
raw_texts: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None)
):
global GLOBAL_HUMAN_POOL, GLOBAL_TOPIC
new_pool = []
if file:
content = await file.read()
lines = content.decode("utf-8", errors="ignore").splitlines()
new_pool = [line.strip() for line in lines if len(line.strip()) > 10]
if not new_pool and raw_texts:
lines = raw_texts.splitlines()
new_pool = [line.strip() for line in lines if len(line.strip()) > 10]
if new_pool:
GLOBAL_HUMAN_POOL = new_pool
if topic and topic.strip():
GLOBAL_TOPIC = topic.strip()
return {"status": "ok", "message": f"Cargados {len(GLOBAL_HUMAN_POOL)} textos reales exitosamente.", "topic": GLOBAL_TOPIC}
raise HTTPException(status_code=400, detail="No se han detectado textos válidos para cargar.")
@app.post("/api/start_game")
async def start_game():
game_id = str(uuid.uuid4())
# Seleccionar hasta 4 textos humanos del pool disponible
sample_size = min(4, len(GLOBAL_HUMAN_POOL))
selected_humans = random.sample(GLOBAL_HUMAN_POOL, sample_size)
# Generar o seleccionar el mismo número de textos de IA
machine_texts = await generate_llm_texts(GLOBAL_TOPIC, sample_size)
items = []
for txt in selected_humans:
items.append({"id": len(items) + 1, "text": txt, "author_type": "humano"})
for txt in machine_texts:
items.append({"id": len(items) + 1, "text": txt, "author_type": "maquina"})
random.shuffle(items)
GAMES_STORE[game_id] = {
"topic": GLOBAL_TOPIC,
"items": items,
"current_index": 0,
"score": 0,
"history": [],
"finished": False
}
first_item = items[0]
return {
"game_id": game_id,
"topic": GLOBAL_TOPIC,
"total_items": len(items),
"current_number": 1,
"item_text": first_item["text"]
}
@app.post("/api/submit_guess")
async def submit_guess(req: GuessRequest):
game = GAMES_STORE.get(req.game_id)
if not game or game["finished"]:
raise HTTPException(status_code=400, detail="Partida no encontrada o finalizada.")
idx = game["current_index"]
current_item = game["items"][idx]
is_correct = (req.guess.lower() == current_item["author_type"])
if is_correct:
game["score"] += 1
game["history"].append({
"item_number": idx + 1,
"text": current_item["text"],
"author_type": current_item["author_type"],
"user_guess": req.guess.lower(),
"is_correct": is_correct
})
return {
"is_correct": is_correct,
"real_author": current_item["author_type"],
"current_score": game["score"],
"is_last": (idx + 1) >= len(game["items"])
}
@app.post("/api/next_item")
async def next_item(req: NextRequest):
game = GAMES_STORE.get(req.game_id)
if not game or game["finished"]:
raise HTTPException(status_code=400, detail="Partida no encontrada o finalizada.")
game["current_index"] += 1
idx = game["current_index"]
if idx >= len(game["items"]):
game["finished"] = True
total = len(game["items"])
score = game["score"]
accuracy = round((score / total) * 100, 1) if total > 0 else 0
has_won = accuracy >= 60.0
return {
"finished": True,
"score": score,
"total": total,
"accuracy": accuracy,
"has_won": has_won,
"history": game["history"]
}
next_item = game["items"][idx]
return {
"finished": False,
"current_number": idx + 1,
"total_items": len(game["items"]),
"item_text": next_item["text"]
}
# -----------------------------------------------------------------------------
# INTERFAZ WEB HTML
# -----------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def home():
return f"""
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Proyecto PINSAPIA - Línea Johnson (Modo Individual)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
body {{ font-family: 'Inter', sans-serif; background-color: #f8fafc; }}
.pinsapia-topbar {{ background-color: #0f172a; }}
.btn-human {{ background-color: #0284c7; transition: all 0.2s; }}
.btn-human:hover {{ background-color: #0369a1; transform: translateY(-1px); }}
.btn-machine {{ background-color: #d97706; transition: all 0.2s; }}
.btn-machine:hover {{ background-color: #b45309; transform: translateY(-1px); }}
</style>
</head>
<body class="min-h-screen text-slate-800 flex flex-col">
<header class="pinsapia-topbar text-white px-4 py-2.5 flex flex-wrap justify-between items-center text-xs sm:text-sm shadow-md border-b border-slate-800">
<div class="flex items-center space-x-2 font-bold tracking-wide">
<span class="bg-amber-500 text-black px-2 py-0.5 rounded font-extrabold text-sm uppercase tracking-wider">PINSAPIA</span>
<span class="text-white text-sm font-semibold tracking-wider">PROYECTO JOHNSON</span>
<span class="bg-indigo-600 text-white text-[10px] px-1.5 py-0.5 rounded font-mono font-bold ml-1">PUERTO :8085</span>
</div>
<div class="flex items-center space-x-2 mt-1 sm:mt-0">
<span class="bg-slate-800 text-amber-400 border border-amber-500/30 text-[11px] px-2.5 py-0.5 rounded-full font-medium">
IES MONTERROSO — MODO INDIVIDUAL
</span>
</div>
</header>
<main class="max-w-3xl w-full mx-auto p-4 sm:p-6 space-y-6 flex-grow">
<div class="bg-gradient-to-r from-slate-900 to-indigo-950 rounded-xl p-5 text-white shadow-lg border border-slate-800 flex justify-between items-center">
<div>
<p class="text-amber-400 font-bold text-xs uppercase tracking-widest mb-1">
Desafío de Verificación Individual
</p>
<h1 class="text-xl sm:text-2xl font-extrabold tracking-tight">
🎭 ¿Quién lo dijo, el humano o la máquina?
</h1>
</div>
<div class="text-3xl sm:text-4xl text-amber-400 pl-4">
<i class="fa-solid fa-user-check"></i>
</div>
</div>
<!-- BLOQUE OPCIONAL: CARGA DE ARCHIVO TXT / CONFIGURACIÓN DE BANCO -->
<details class="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<summary class="p-4 bg-slate-50 hover:bg-slate-100 font-bold text-xs text-slate-700 uppercase tracking-wider cursor-pointer flex justify-between items-center">
<span>⚙️ Cargar nuevos textos reales (.txt o pegar textos)</span>
<i class="fa-solid fa-chevron-down text-slate-400"></i>
</summary>
<div class="p-4 border-t border-slate-200 space-y-4 text-xs">
<div class="space-y-1">
<label class="font-bold text-slate-700">Tema del experimento:</label>
<input type="text" id="cfgTopic" value="Describe tu recreo ideal" class="w-full p-2 border border-slate-300 rounded font-medium">
</div>
<div class="space-y-1">
<label class="font-bold text-slate-700">Subir archivo .txt con textos reales (un texto por línea):</label>
<input type="file" id="cfgFile" accept=".txt" class="block w-full text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100">
</div>
<div class="space-y-1">
<label class="font-bold text-slate-700">O pegar textos directamente aquí:</label>
<textarea id="cfgRaw" rows="3" class="w-full p-2 border border-slate-300 rounded font-medium" placeholder="Pega varios textos reales separados por líneas..."></textarea>
</div>
<button onclick="cargarBanco()" class="bg-slate-800 hover:bg-slate-900 text-white font-bold py-2 px-4 rounded transition">
Actualizar Banco de Textos
</button>
<span id="msgLoadStatus" class="font-bold text-emerald-600 block"></span>
</div>
</details>
<!-- PANTALLA INICIAL: BOTÓN DE INICIO DE PARTIDA -->
<section id="panelStart" class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 text-center space-y-4">
<div class="space-y-2">
<h2 class="text-lg font-bold text-slate-800">¿Preparado para poner a prueba tu ojo crítico?</h2>
<p class="text-xs text-slate-600 leading-relaxed max-w-md mx-auto">
Analizarás varios textos sobre el tema <strong id="lblCurrentTopic" class="text-indigo-700">"Describe tu recreo ideal"</strong>. Tu objetivo es adivinar cuáles han sido escritos por estudiantes y cuáles han sido creados por la IA.
</p>
</div>
<button onclick="iniciarPartida()" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-8 rounded-xl shadow-lg transition text-sm flex items-center justify-center gap-2 mx-auto">
<i class="fa-solid fa-play"></i>
<span>COMENZAR DESAFÍO INDIVIDUAL</span>
</button>
</section>
<!-- PANTALLA DE JUEGO -->
<section id="panelGame" class="bg-white rounded-xl shadow-sm border border-slate-200 p-5 space-y-5 hidden">
<div class="flex justify-between items-center border-b border-slate-100 pb-3 text-xs font-bold">
<span id="badgeStep" class="bg-indigo-100 text-indigo-800 px-3 py-1 rounded-full font-mono"></span>
<span id="badgeScore" class="bg-slate-100 text-slate-700 px-3 py-1 rounded-full font-mono">Puntos: 0</span>
</div>
<div class="bg-slate-50 border border-slate-200 rounded-xl p-6 shadow-inner text-center">
<p class="text-[11px] font-bold text-slate-400 uppercase tracking-widest mb-2">Texto a evaluar:</p>
<p id="txtToEval" class="text-base sm:text-lg font-medium text-slate-800 leading-relaxed italic">
"..."
</p>
</div>
<!-- BOTONES DE ELECCIÓN -->
<div id="boxButtons" class="grid grid-cols-2 gap-4">
<button onclick="enviarVoto('humano')" class="btn-human text-white font-bold py-4 rounded-xl shadow flex flex-col items-center justify-center space-y-1">
<span class="text-2xl">🧑🎓</span>
<span class="text-xs sm:text-sm">ES UN HUMANO</span>
</button>
<button onclick="enviarVoto('maquina')" class="btn-machine text-white font-bold py-4 rounded-xl shadow flex flex-col items-center justify-center space-y-1">
<span class="text-2xl">🤖</span>
<span class="text-xs sm:text-sm">ES UNA MÁQUINA</span>
</button>
</div>
<!-- TARJETA DE VEREDICTO / FEEDBACK INMEDIATO -->
<div id="boxFeedback" class="hidden p-4 rounded-xl border text-center space-y-3">
<div id="fbTitle" class="text-base font-extrabold flex items-center justify-center gap-2"></div>
<p id="fbDesc" class="text-xs font-medium"></p>
<button onclick="siguienteTexto()" class="bg-slate-900 hover:bg-black text-white text-xs font-bold py-2.5 px-6 rounded-lg transition">
Continuar al Siguiente Texto <i class="fa-solid fa-arrow-right ml-1"></i>
</button>
</div>
</section>
<!-- PANTALLA FINAL DE RESULTADOS INDIVIDUALES -->
<section id="panelResults" class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 space-y-6 hidden text-center">
<div id="bannerVerdict" class="p-5 rounded-xl border space-y-2">
<div id="resIcon" class="text-4xl"></div>
<h2 id="resTitle" class="text-2xl font-extrabold"></h2>
<p id="resSubtitle" class="text-xs font-semibold"></p>
</div>
<div class="grid grid-cols-2 gap-3 max-w-sm mx-auto">
<div class="bg-slate-50 border border-slate-200 p-3 rounded-lg">
<span class="text-[10px] font-bold text-slate-400 uppercase">Aciertos</span>
<p id="valScore" class="text-xl font-extrabold text-slate-800 font-mono"></p>
</div>
<div class="bg-slate-50 border border-slate-200 p-3 rounded-lg">
<span class="text-[10px] font-bold text-slate-400 uppercase">Precisión</span>
<p id="valAccuracy" class="text-xl font-extrabold text-indigo-600 font-mono"></p>
</div>
</div>
<div class="text-left space-y-3">
<h3 class="text-xs font-bold text-slate-700 uppercase tracking-wider">Historial detallado de tu partida:</h3>
<div id="listHistory" class="space-y-2"></div>
</div>
<button onclick="reiniciarPartida()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 rounded-xl shadow transition text-sm">
🔄 JUGAR OTRA PARTIDA
</button>
</section>
</main>
<script>
let currentGameId = null;
async function cargarBanco() {{
const topic = document.getElementById('cfgTopic').value;
const fileInput = document.getElementById('cfgFile');
const rawText = document.getElementById('cfgRaw').value;
const formData = new FormData();
formData.append('topic', topic);
if (fileInput.files.length > 0) {{
formData.append('file', fileInput.files[0]);
}}
if (rawText.trim()) {{
formData.append('raw_texts', rawText);
}}
try {{
const res = await fetch('/api/upload_texts', {{ method: 'POST', body: formData }});
const data = await res.json();
if (res.ok) {{
document.getElementById('msgLoadStatus').innerText = data.message;
document.getElementById('lblCurrentTopic').innerText = `"${{data.topic}}"`;
}} else {{
alert(data.detail);
}}
}} catch (e) {{
alert("Error cargando textos: " + e);
}}
}}
async function iniciarPartida() {{
document.getElementById('panelStart').classList.add('hidden');
document.getElementById('panelResults').classList.add('hidden');
document.getElementById('panelGame').classList.remove('hidden');
try {{
const res = await fetch('/api/start_game', {{ method: 'POST' }});
const data = await res.json();
currentGameId = data.game_id;
mostrarTexto(data.current_number, data.total_items, data.item_text, 0);
}} catch (e) {{
alert("Error iniciando la partida: " + e);
}}
}}
function mostrarTexto(current, total, text, score) {{
document.getElementById('badgeStep').innerText = `TEXTO ${{current}} DE ${{total}}`;
document.getElementById('badgeScore').innerText = `Aciertos: ${{score}}`;
document.getElementById('txtToEval').innerText = `"${{text}}"`;
document.getElementById('boxButtons').classList.remove('hidden');
document.getElementById('boxFeedback').classList.add('hidden');
}}
async function enviarVoto(voto) {{
if (!currentGameId) return;
try {{
const res = await fetch('/api/submit_guess', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ game_id: currentGameId, guess: voto }})
}});
const data = await res.json();
document.getElementById('boxButtons').classList.add('hidden');
const fbBox = document.getElementById('boxFeedback');
fbBox.classList.remove('hidden');
const isHuman = data.real_author === 'humano';
const authorLabel = isHuman ? 'estudiante real 🧑🎓' : 'modelo de IA 🤖';
if (data.is_correct) {{
fbBox.className = "p-4 rounded-xl border text-center space-y-2 bg-emerald-50 border-emerald-300 text-emerald-900";
document.getElementById('fbTitle').innerHTML = '<i class="fa-solid fa-circle-check text-emerald-600"></i> ¡CORRECTO!';
document.getElementById('fbDesc').innerText = `Efectivamente, este texto fue escrito por un ${{authorLabel}}.`;
}} else {{
fbBox.className = "p-4 rounded-xl border text-center space-y-2 bg-rose-50 border-rose-300 text-rose-900";
document.getElementById('fbTitle').innerHTML = '<i class="fa-solid fa-circle-xmark text-rose-600"></i> ¡INCORRECTO!';
document.getElementById('fbDesc').innerText = `Te has confundido. Este texto pertenece en realidad a un ${{authorLabel}}.`;
}}
document.getElementById('badgeScore').innerText = `Aciertos: ${{data.current_score}}`;
}} catch (e) {{
alert("Error procesando respuesta: " + e);
}}
}}
async function siguienteTexto() {{
if (!currentGameId) return;
try {{
const res = await fetch('/api/next_item', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ game_id: currentGameId }})
}});
const data = await res.json();
if (data.finished) {{
mostrarResultadosFinales(data);
}} else {{
mostrarTexto(data.current_number, data.total_items, data.item_text, document.getElementById('badgeScore').innerText.split(': ')[1]);
}}
}} catch (e) {{
alert("Error avanzando de texto: " + e);
}}
}}
function mostrarResultadosFinales(data) {{
document.getElementById('panelGame').classList.add('hidden');
document.getElementById('panelResults').classList.remove('hidden');
const banner = document.getElementById('bannerVerdict');
if (data.has_won) {{
banner.className = "p-5 rounded-xl border text-center space-y-2 bg-emerald-50 border-emerald-300 text-emerald-950";
document.getElementById('resIcon').innerText = "🏆";
document.getElementById('resTitle').innerText = "¡HAS GANADO EL DESAFÍO!";
document.getElementById('resSubtitle').innerText = "Tienes un excelente ojo crítico para detectar la huella sintáctica de la IA.";
}} else {{
banner.className = "p-5 rounded-xl border text-center space-y-2 bg-amber-50 border-amber-300 text-amber-950";
document.getElementById('resIcon').innerText = "🤖";
document.getElementById('resTitle').innerText = "¡LA MÁQUINA TE HA ENGAÑADO!";
document.getElementById('resSubtitle').innerText = "El modelo de lenguaje ha conseguido camuflar sus textos de forma convincente.";
}}
document.getElementById('valScore').innerText = `${{data.score}} / ${{data.total}}`;
document.getElementById('valAccuracy').innerText = `${{data.accuracy}}%`;
const list = document.getElementById('listHistory');
list.innerHTML = data.history.map(h => {{
const ok = h.is_correct;
const badgeReal = h.author_type === 'humano' ? '🧑🎓 Humano' : '🤖 Máquina';
const iconResult = ok ? '<span class="text-emerald-600 font-bold">✅ Acertaste</span>' : '<span class="text-rose-600 font-bold">❌ Fallaste</span>';
return `
<div class="p-3 bg-slate-50 border border-slate-200 rounded text-xs space-y-1">
<div class="flex justify-between font-bold">
<span>Texto #${{h.item_number}} (Real: ${{badgeReal}})</span>
${{iconResult}}
</div>
<p class="italic text-slate-700 font-medium">"${{h.text}}"</p>
</div>
`;
}}).join('');
}}
function reiniciarPartida() {{
document.getElementById('panelResults').classList.add('hidden');
document.getElementById('panelStart').classList.remove('hidden');
}}
</script>
</body>
</html>
"""
# -----------------------------------------------------------------------------
# EJECUCIÓN DEL SERVIDOR UVICORN (PUERTO 8085)
# -----------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run("app_johnson:app", host="0.0.0.0", port=8085, reload=True)
Cómo ejecutar la aplicación con Uvicorn en el puerto 8085
Abre la consola de comandos en la carpeta donde guardaste app_johnson.py y lanza el servidor con:
Bash
uvicorn app_johnson:app --host 0.0.0.0 --port 8085 --reload
O bien ejecutando directamente con Python:
Bash
python app_johnson.py
Accede desde tu navegador en http://localhost:8085 (o desde cada ordenador de la red del aula).
3. Detalle Técnico e Ingeniería del Prompt
La aplicación implementa una arquitectura desacoplada para partidas individuales simultáneas:
- Generación de Estado Dinámico (
game_idUUID): Cada estudiante genera una sesión independiente al pulsar “Comenzar Desafío”. - Retroalimentación Inmediata Sin Revelación Previa: Las opciones reales se almacenan exclusivamente en la memoria del servidor (
GAMES_STORE). El código cliente no recibe la autoría del texto hasta que no envía explícitamente su elección. - Procesamiento de Archivos
.txt: Permite subir un archivo plano con las respuestas redactadas previamente por el alumnado. La app extrae cada línea como un ítem independiente para alimentar el banco real.
4. Guía Didáctica para el Aula: Modo Juego Individual
[1. Cargar Archivo .txt] ──> [2. Partida Individual] ──> [3. Feedback Inmediato] ──> [4. Marcador y Veredicto]
El docente sube las Cada alumno juega en Visualización de acierto Cálculo de precisión
textos reales en la app su propio ordenador o fallo texto a texto y veredicto final
Dinámica paso a paso
- Carga de Textos Reales: El docente abre el desplegable de configuración (
http://localhost:8085) y sube el archivo.txtcon los textos redactados previamente por los alumnos (o utiliza el catálogo predeterminado de 5 textos). - Ejecución Individual: Cada estudiante accede a la URL desde su puesto de trabajo de manera autónoma sin ver las selecciones de sus compañeros.
- Desafío Texto a Texto:
- El alumno lee el fragmento y pulsa “🧑🎓 ES UN HUMANO” o “🤖 ES UNA MÁQUINA”.
- La aplicación muestra inmediatamente la resolución de esa pregunta (¡Correcto! o ¡Incorrecto!) explicando quién fue el autor real.
- Veredicto Final: Al completar el conjunto de textos, la aplicación muestra la pantalla final:
- Superado (>= 60% aciertos):“¡HAS GANADO EL DESAFÍO! Tienes un excelente ojo crítico para detectar la huella de la IA.”
- No Superado (< 60% aciertos):“¡LA MÁQUINA TE HA ENGAÑADO! El modelo ha conseguido camuflar sus textos.”


5. Preguntas de Cierre y Debate en Grupo
Al finalizar la partida individual, abre una puesta en común en el aula abordando las siguientes preguntas de reflexión:
- Pistas de detección: ¿Qué detalles específicos en los textos os permitieron acertar (falta de erratas de tecleo, sintaxis demasiado impecable, vocabulario poco habitual en adolescentes)?
- Engaños efectivos: ¿Qué textos de la IA consiguieron engañar a un mayor número de estudiantes y por qué razón?
- Métrica empírica: ¿Por qué la evaluación de la verosimilitud de la Inteligencia Artificial debe medirse mediante experimentos cuantitativos y marcadores numéricos en lugar de simples impresiones subjetivas?
❓ Pregunta de Cierre para el Debate en Grupo
“Si el porcentaje de acierto individual de un estudiante fuera exactamente del 50%, lo mismo que tirar una moneda al aire, ¿qué significaría ese resultado respecto a la capacidad del modelo para imitar la redacción humana?”



