
PROYECTO WELLS — Termómetro del Bulo
Línea THEMIS — Ética, Alfabetización Mediática y Pensamiento Crítico con IA Local
IES Monterroso | Puerto:
:8070| Archivo:app_bulo.py
📌 1. Ficha Técnica
| Parámetro | Detalle |
| Nombre del Proyecto | PROYECTO WELLS (Termómetro del Bulo) |
| Epónimo | Ida B. Wells (1862–1931), pionera del periodismo de investigación y la verificación rigurosa de datos frente a la propaganda y la desinformación. |
| Puerto de Servicio | http://localhost:8070 / [http://127.0.0.1:8070](http://127.0.0.1:8070) |
| Stack Técnico | Python 3.10+, FastAPI, Uvicorn, HTML5/JS ES6 (Single-File Architecture). |
| Motor de IA (LLM) | Qwen (qwen2-vl-2b-instruct o qwen2.5-instruct) ejecutándose localmente en LM Studio ([http://127.0.0.1:1234](http://127.0.0.1:1234)). |
| Tipo de Arquitectura | Ingeniería de Prompts pura (Zero-shot JSON estructurado), sin RAG, sin embeddings, sin bases de datos externas. |
| Destinatarios | Alumnado de 1º a 4º de ESO y Bachillerato. |
🏛️ 2. ¿Por qué Ida B. Wells?
Ida B. Wells fue una de las primeras periodistas en el siglo XIX en comprender que la información falsa no se combate solo con opinión, sino desmontándola dato a dato: cruzando cifras oficiales, fechas, registros públicos y testimonios contrastados.
Este proyecto rinde homenaje a su metodología: separar tajantemente lo que es un dato factualmente verificable de lo que es un juicio de valor o sesgo interpretativo.
🎯 3. Propósito Pedagógico y Filosofía THEMIS
A diferencia de las herramientas comerciales de fact-checking que pretenden dar un “veredicto automático de verdad” (diciendo si una noticia es Verdadera o Falsa), el Proyecto WELLS se apoya en una premisa ética fundamental:
La IA NUNCA debe ser el juez final de la verdad.
Principios clave para el aula:
- La IA como Asistente Sintáctico, no como Juez: El modelo únicamente ayuda al alumnado a desempaquetar y estructurar el texto en dos columnas para facilitar su lectura crítica.
- El Humano en el Bucle (Human-in-the-Loop): El trabajo real empieza después de la respuesta de la IA. Para cada frase marcada como “Verificable”, el estudiante debe especificar qué fuente primaria oficial utilizaría para comprobarla (ej. AEMET, INE, BOJA, un paper científico).
- El Error del Modelo como Recurso Didáctico: Si Qwen clasifica un juicio de valor como si fuera un “hecho” (o viceversa), ese fallo no es un defecto de la app, es la lección de la clase. Permite demostrar al alumnado que los modelos de lenguaje no “entienden” el mundo, sino que procesan patrones probabilísticos del lenguaje.

⚙️ 4. Arquitectura de Datos y Flujo Técnico
Plaintext
[ Noticia / Tuit / Titular ]
│
▼
[ Interfaz Web WELLS (:8070) ]
│ (Petición POST HTTP)
▼
[ FastAPI (app_bulo.py) ] ── (System Prompt JSON Estricto) ──► [ LM Studio (:1234) / Qwen ]
│ │
▼ ▼
[ Renderizado en 2 Columnas ] ◄─────────────────────────────── [ Respuesta JSON ]
├── 🟩 Columna 1: Hechos Verificables ──► [ Input: ¿Cómo lo comprobarías? ]
└── 🟧 Columna 2: Opiniones / Valoraciones
📂 5. Estructura de Archivos
Un único archivo autosuficiente (Single-File Application):
Plaintext
proyecto_wells/
└── app_bulo.py # Servidor FastAPI + Frontend HTML/CSS/JS + Cliente HTTPx
🚀 6. Guía de Ejecución
- Asegurar LM Studio:
- Cargar el modelo
qwen2-vl-2b-instruct(o cualquier variante de Qwen). - Encender el servidor local en
[http://127.0.0.1:1234](http://127.0.0.1:1234).
- Cargar el modelo
- Lanzar el Servidor WELLS:PowerShell
uvicorn app_bulo:app --host 0.0.0.0 --port 8070 --reload - Abrir en el Navegador:Navegar a
http://localhost:8070o acceder desde los ordenadores del aula mediante la IP local de la red del instituto ([http://192.168.](http://192.168.)X.X:8070).
import json
import re
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
app = FastAPI(title="Proyecto WELLS - Termómetro del Bulo (THEMIS)")
# IP local explícita IPv4 para evitar problemas de resolución de localhost en Windows
LM_STUDIO_URL = "http://127.0.0.1:1234/v1/chat/completions"
class TextAnalysisRequest(BaseModel):
text: str
HTML_CONTENT = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PROYECTO WELLS - Termómetro del Bulo</title>
<style>
:root {
--bg-page: #f8fafc;
--nav-bg: #0f172a;
--primary: #0284c7;
--fact-green: #059669;
--fact-bg: #f0fdf4;
--fact-border: #86efac;
--opinion-amber: #d97706;
--opinion-bg: #fffbeb;
--opinion-border: #fde68a;
--border-color: #cbd5e1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-page); color: #0f172a; line-height: 1.5; }
header {
background: var(--nav-bg);
color: white;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.brand-logo { background: #38bdf8; color: #0f172a; font-weight: 900; padding: 6px 10px; border-radius: 6px; font-size: 18px; }
.header-titles h1 { font-size: 18px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.pill { padding: 2px 8px; border-radius: 4px; font-weight: 600; font-size: 11px; text-transform: uppercase; }
.pill-blue { background: var(--primary); color: white; }
.pill-amber { background: var(--opinion-amber); color: white; }
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
.banner {
background: linear-gradient(135deg, #0f172a, #1e293b);
color: white;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 6px solid #38bdf8;
}
.banner-text h2 { font-size: 12px; color: #38bdf8; text-transform: uppercase; font-weight: 800; letter-spacing: 0.5px; }
.banner-text p { font-size: 18px; font-weight: 700; margin-top: 2px; }
.card { background: white; border-radius: 12px; border: 1px solid var(--border-color); padding: 20px; margin-bottom: 24px; }
.card-title { font-size: 14px; font-weight: 800; text-transform: uppercase; color: #475569; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
label { font-size: 13px; font-weight: 700; color: #334155; display: block; margin-bottom: 6px; }
select, textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 14px; margin-bottom: 14px; font-family: inherit; }
textarea { height: 100px; resize: vertical; }
.btn-primary { width: 100%; padding: 12px; background: var(--primary); color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: background 0.2s; }
.btn-primary:hover { background: #0369a1; }
.btn-primary:disabled { background: #94a3b8; cursor: not-allowed; }
/* DOS COLUMNAS DE RESULTADOS */
.columns-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 16px; }
.col-box { border-radius: 10px; padding: 16px; border: 2px solid var(--border-color); }
.col-box.facts { background: var(--fact-bg); border-color: var(--fact-border); }
.col-box.opinions { background: var(--opinion-bg); border-color: var(--opinion-border); }
.col-header { font-size: 15px; font-weight: 800; text-transform: uppercase; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
.col-box.facts .col-header { color: #166534; }
.col-box.opinions .col-header { color: #92400e; }
.item-card { background: white; border-radius: 8px; padding: 12px; margin-bottom: 12px; border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 1px 3px rgba(0,0,0,0.03); }
.item-phrase { font-weight: 700; font-size: 13.5px; color: #0f172a; margin-bottom: 6px; }
.item-reason { font-size: 12px; color: #64748b; font-style: italic; margin-bottom: 8px; }
.student-input { width: 100%; padding: 8px 10px; border: 1px dashed var(--border-color); border-radius: 6px; font-size: 12px; background: #fafafa; margin-top: 6px; color: #334155; }
.student-input:focus { border-style: solid; border-color: var(--primary); outline: none; background: #fff; }
.ai-eval-box { background: #eff6ff; border: 1px solid #bfdbfe; border-left: 4px solid var(--primary); padding: 14px; border-radius: 8px; margin-top: 20px; font-size: 13px; color: #1e40af; }
.warning-box { background: #fef2f2; border: 1px solid #fecaca; border-left: 4px solid #ef4444; padding: 14px; border-radius: 8px; margin-top: 16px; font-size: 13px; color: #991b1b; font-weight: 600; }
.loader { display: none; text-align: center; padding: 20px; color: var(--primary); font-weight: 700; }
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="brand-logo">WELLS</div>
<div class="header-titles">
<h1>PROYECTO WELLS <span class="pill pill-amber">PUERTO :8070</span></h1>
</div>
</div>
<span class="pill pill-blue">IES MONTERROSO — LÍNEA THEMIS</span>
</header>
<div class="container">
<div class="banner">
<div class="banner-text">
<h2>TERMÓMETRO DEL BULO Y ANÁLISIS CRÍTICO DE MEDIOS</h2>
<p>Separación de Hechos Verificables vs. Opiniones e Interpretaciones</p>
</div>
<div style="font-size:36px;">🌡️</div>
</div>
<div class="card">
<div class="card-title">📝 1. Introduce la Noticia o Titular a Analizar</div>
<label for="presetSelect">Ejemplos preconfigurados para clase:</label>
<select id="presetSelect">
<option value="">-- Selecciona un texto de prueba --</option>
<option value="Ayer la temperatura en el centro de la ciudad alcanzó un récord histórico de 44.5 °C según la AEMET. Esto demuestra claramente la absoluta incompetencia de los políticos locales, que están arruinando el planeta sin tomar medidas urgentes.">Ejemplo 1: Noticia Climática y Política</option>
<option value="Un estudio de la Universidad de Oxford con 1.500 adolescentes reveló que el 65% usa TikTok más de 3 horas al día. Está claro que las redes sociales son la peor epidemia del siglo XXI y están destruyendo por completo a la juventud.">Ejemplo 2: Redes Sociales y Jóvenes</option>
<option value="El IES Monterroso inauguró el pasado 12 de febrero un nuevo aula STEM equipada con 25 ordenadores. Es sin duda el mejor instituto de toda la provincia y sus alumnos aprenden el doble que en cualquier otro centro.">Ejemplo 3: Noticia Escolar / Local</option>
</select>
<label for="newsInput">Texto para el Termómetro:</label>
<textarea id="newsInput" placeholder="Pega aquí el texto de una red social, titular o noticia..."></textarea>
<button class="btn-primary" id="analyzeBtn" onclick="analyzeText()">
<span>🔥</span> Clasificar Frases con Qwen Local
</button>
</div>
<!-- RESULTADOS -->
<div id="loader" class="loader">
🤖 Qwen está analizando la sintaxis del texto...
</div>
<div id="resultsArea" style="display:none;">
<div class="columns-grid">
<!-- COLUMNA HECHOS -->
<div class="col-box facts">
<div class="col-header">
<span>🟩 1. Hechos Verificables</span>
<span id="factsCount" class="pill pill-blue" style="background:#166534;">0</span>
</div>
<div id="factsList"></div>
</div>
<!-- COLUMNA OPINIONES -->
<div class="col-box opinions">
<div class="col-header">
<span>🟧 2. Opiniones / Valoraciones</span>
<span id="opinionsCount" class="pill pill-amber">0</span>
</div>
<div id="opinionsList"></div>
</div>
</div>
<div class="ai-eval-box" id="aiSummaryBox">
<strong>💡 Resumen Sintáctico de la IA:</strong> <span id="aiSummaryText"></span>
</div>
<!-- ADVERTENCIA PEDAGÓGICA SOBRE ERRORES DE LA IA -->
<div class="warning-box">
🧐 <strong>ACTIVIDAD DE CLASE (Pensamiento Crítico THEMIS):</strong><br>
Revisa el trabajo del modelo. ¿Ha clasificado la IA alguna opinión como un "hecho" o viceversa? <br>
<em>Recuerda: La IA es solo un asistente para estructurar el texto, NUNCA el juez final de la verdad.</em>
</div>
</div>
</div>
<script>
document.getElementById('presetSelect').addEventListener('change', (e) => {
if (e.target.value) {
document.getElementById('newsInput').value = e.target.value;
}
});
async function analyzeText() {
const text = document.getElementById('newsInput').value.trim();
if (!text) {
alert('Escribe o pega un texto para analizar.');
return;
}
const btn = document.getElementById('analyzeBtn');
const loader = document.getElementById('loader');
const resultsArea = document.getElementById('resultsArea');
btn.disabled = true;
loader.style.display = 'block';
resultsArea.style.display = 'none';
try {
const response = await fetch('/v1/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text })
});
if (!response.ok) throw new Error('Error en la respuesta del servidor');
const data = await response.json();
// Renderizar Hechos
const factsList = document.getElementById('factsList');
factsList.innerHTML = '';
document.getElementById('factsCount').innerText = data.verificables.length;
if (data.verificables.length === 0) {
factsList.innerHTML = '<div style="font-size:12px; color:#64748b; font-style:italic;">No se detectaron afirmaciones factuales claras.</div>';
} else {
data.verificables.forEach((item, idx) => {
factsList.innerHTML += `
<div class="item-card">
<div class="item-phrase">"${item.frase}"</div>
<div class="item-reason">📌 Motivo: ${item.motivo}</div>
<input type="text" class="student-input" placeholder="🔍 Alumno: ¿Cómo comprobarías este dato en la vida real?">
</div>
`;
});
}
// Renderizar Opiniones
const opinionsList = document.getElementById('opinionsList');
opinionsList.innerHTML = '';
document.getElementById('opinionsCount').innerText = data.opiniones.length;
if (data.opiniones.length === 0) {
opinionsList.innerHTML = '<div style="font-size:12px; color:#64748b; font-style:italic;">No se detectaron adjetivos ni juicios de valor.</div>';
} else {
data.opiniones.forEach((item) => {
opinionsList.innerHTML += `
<div class="item-card">
<div class="item-phrase">"${item.frase}"</div>
<div class="item-reason">💬 Motivo: ${item.motivo}</div>
</div>
`;
});
}
document.getElementById('aiSummaryText').innerText = data.analisis_critico || 'Texto analizado correctamente.';
resultsArea.style.display = 'block';
} catch (err) {
alert('No se pudo conectar con Qwen en LM Studio. Revisa que esté ejecutándose en http://127.0.0.1:1234');
} finally {
btn.disabled = false;
loader.style.display = 'none';
}
}
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def serve_index():
return HTML_CONTENT
@app.post("/v1/analyze")
async def analyze_news(req: TextAnalysisRequest):
if not req.text.strip():
raise HTTPException(status_code=400, detail="El texto no puede estar vacío.")
system_prompt = (
"Eres un asistente educativo del Proyecto WELLS (Línea THEMIS). "
"Tu tarea es analizar el texto introducido y clasificar sus frases en dos categorías strictly separadas:\n\n"
"1. 'verificables': Cifras, fechas, nombres, datos medibles, lugares o hechos objetivos comprobables.\n"
"2. 'opiniones': Juicios de valor, adjetivos cargados emocionalmente, especulaciones, predicciones o valoraciones subjetivas.\n\n"
"DEBES responder EXCLUSIVAMENTE en formato JSON válido con la siguiente estructura exactísima (sin texto explicativo fuera del JSON):\n"
"{\n"
' "verificables": [{"frase": "...", "motivo": "..."}],\n'
' "opiniones": [{"frase": "...", "motivo": "..."}],\n'
' "analisis_critico": "Breve frase (2 líneas) sobre la mezcla de hechos y opinión en el texto."\n'
"}"
)
try:
# Timeout a 30.0 segundos para convivir con la carga paralela de la GPU
async with httpx.AsyncClient(timeout=30.0) as client:
payload = {
"model": "qwen2-vl-2b-instruct",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Texto a analizar:\n'{req.text}'"}
],
"temperature": 0.2, # Baja temperatura para JSON estricto
"max_tokens": 500
}
res = await client.post(LM_STUDIO_URL, json=payload)
if res.status_code != 200:
raise HTTPException(status_code=500, detail="Error de respuesta en LM Studio")
raw_content = res.json()["choices"][0]["message"]["content"].strip()
# Limpieza de bloques de código markdown si el modelo los genera
json_match = re.search(r'\{.*\}', raw_content, re.DOTALL)
if json_match:
clean_json_str = json_match.group(0)
data = json.loads(clean_json_str)
else:
data = json.loads(raw_content)
return JSONResponse(data)
except json.JSONDecodeError:
return JSONResponse({
"verificables": [{"frase": req.text[:60] + "...", "motivo": "No se pudo formatear el JSON correctamente."}],
"opiniones": [],
"analisis_critico": "El modelo no generó un formato JSON perfecto, pero se puede revisar la frase manualmente."
})
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al conectar con LM Studio: {str(e)}")
🏫 7. Dinámica Sugerida para una Sesión de Clase (50 min)
- Fase 1: Captura de Datos (10 min): Los alumnos buscan en redes sociales o periódicos digitales un titular polémico o llamativo.
- Fase 2: Triaje con Qwen (10 min): Pegan el texto en el Termómetro del Bulo y ejecutan el análisis en dos columnas.
- Fase 3: Auditoría del Modelo (15 min): Revisan la clasificación realizada por la IA.
- ¿Ha colado la IA adjetivos cargados dentro de la columna de Hechos?
- ¿Ha marcado como Opinión un dato numérico claro?
- Fase 4: Investigación y Fuentes Primarias (15 min): Para los hechos listados, los estudiantes rellenan las casillas interactivas indicando el método exacto de contraste (nombre de la institución, organismo público o registro oficial).



