
PROYECTO PEREC — Historias con Reglas
Línea THEMIS — Ética, Alfabetización Mediática y Pensamiento Crítico con IA Local
IES Monterroso | Puerto:
:8071| Archivo:app_historiasreglas.py
📌 1. Ficha Técnica
| Parámetro | Detalle |
| Nombre del Proyecto | PROYECTO PEREC (Historias con Reglas / Verificación Determinista) |
| Epónimo | Georges Perec (1936–1982), escritor francés miembro del colectivo OuLiPo (Ouvroir de littérature potentielle), célebre por escribir la novela La Disparition (300 páginas) sin usar ni una sola vez la letra “e”. |
| Puerto de Servicio | http://localhost:8071 / [http://127.0.0.1:8071](http://127.0.0.1:8071) |
| 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) en LM Studio ([http://127.0.0.1:1234](http://127.0.0.1:1234)). |
| Módulo Determinista | Algoritmo Python puro (expresiones regulares y parsing sintáctico) para la auditoría formal del texto. |
| Destinatarios | Alumnado de 1º a 4º de ESO y Bachillerato (Lengua Castellana, Literatura y Tecnología/IA). |
🏛️ 2. ¿Por qué Georges Perec y OuLiPo?
El grupo OuLiPo demostró en el siglo XX que imponer restricciones formales extremas al lenguaje (lipogramas, palíndromos, acrósticos) no ahoga la creatividad, sino que obliga al cerebro humano a buscar caminos insospechados.
En el contexto de la Inteligencia Artificial, el Proyecto PEREC utiliza estas mismas restricciones como una lupa de auditoría. Poner a un LLM a escribir sin una letra concreta o con un número exacto de palabras por frase no es un mero juego literario: es un experimento científico de aula para revelar cómo funcionan los modelos de lenguaje por dentro.
🎯 3. Propósito Pedagógico y Filosofía THEMIS
El pilar ético y técnico fundamental de este proyecto se resume en la regla de oro de la línea THEMIS / Proyecto Hamilton:
“Separar siempre lo que genera la IA de forma estocástica (probabilística) de lo que debe verificar Python de forma determinista.”
Principios clave para el aula:
- Por qué la IA “hace trampa” (Límites de los LLMs): Los modelos de lenguaje no cuentan letras ni miden palabras mientras escriben; predicen la siguiente subpalabra (token) por asociación estadística. Por eso fallan estrepitosamente en restricciones formales estrictas (como un lipograma sin “e”).
- Nunca le preguntes a la IA si se ha equivocado: Si le preguntas al propio LLM “¿has usado la letra ‘e’?”, el modelo responderá habitualmente “No, he cumplido la regla perfectamente”, alucinando una falsa confirmación.
- Auditoría Externa con Python: La verificación la realiza un algoritmo determinista en Python, incapaz de alucinar. Muestra con precisión quirúrgica dónde, cuándo y cómo ha fallado el modelo.
⚙️ 4. Arquitectura de Datos y Flujo Técnico
Plaintext
[ Usuario impone Reglas ] ──► (Lipograma sin 'E' + Nº de palabras por frase)
│
▼
[ FastAPI (app_historiasreglas.py) ]
│
├──► [ LM Studio (:1234) / Qwen ] ──► Genera la historia (Intento)
│ │
▼ ▼
[ MÓDULO DETERMINISTA PYTHON ] ◄────────────────────────────┘
├── 1. Cuenta caracteres/variantes de la letra prohibida (Línea, palabra, posición)
├── 2. Segmenta frases y cuenta palabras exactas por oración
└── 3. Genera Semáforo Verde/Rojo + Desglose detallado de fallos
│
▼
[ Interfaz Web PEREC (:8071) ] ──► Muestra Cuento + Semáforo + Fe de Errores
📂 5. Estructura de Archivos
Un único archivo autosuficiente (Single-File Application):
Plaintext
proyecto_perec/
└── app_historiasreglas.py # Servidor FastAPI + Verificador Determinista + Frontend HTML/CSS/JS
🚀 6. Guía de Ejecución
- Asegurar LM Studio:
- Cargar el modelo
qwen2-vl-2b-instruct(o cualquier variante de Qwen). - Confirmar que el servidor local está activo en
[http://127.0.0.1:1234](http://127.0.0.1:1234).
- Cargar el modelo
- Lanzar el Servidor PEREC:
PowerShelluvicorn app_historiasreglas:app --host 0.0.0.0 --port 8071 --reload - Abrir en el Navegador:Navegar a
http://localhost:8071o mediante la IP local del aula ([http://192.168.](http://192.168.)X.X:8071).

🏫 7. Dinámica Sugerida para una Sesión de Clase (50 min)
- Fase 1: El Desafío Humano vs. IA (10 min): Los alumnos intentan escribir a mano un micro-cuento de 3 frases sin usar la letra “e”. Experimentan la dificultad cognitiva del lipograma.
- Fase 2: Generación con Qwen (10 min): Usan la app proponiendo un tema y solicitando al modelo local que cumpla el lipograma y/o la restricción de palabras por frase.
- Fase 3: Auditoría Determinista (15 min): Pulsan el botón de verificación. Analizan el semáforo rojo y la lista de errores detectados por el script de Python.
- Fase 4: Reflexión Técnica (15 min): Debatimos por qué el modelo falló: ¿Por qué la IA juraba en su texto haber cumplido la regla cuando Python ha encontrado 8 letras ‘e’? Se introduce el concepto de Tokenización y Atención Probabilística.
🐍 Código Completo de app_historiasreglas.py (Puerto :8071)
Python
import json
import re
import httpx
from typing import List, Dict, Any
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
app = FastAPI(title="Proyecto PEREC - Historias con Reglas (THEMIS)")
LM_STUDIO_URL = "http://127.0.0.1:1234/v1/chat/completions"
class GenerationRequest(BaseModel):
prompt: str
forbidden_char: str = "e"
target_words_per_sentence: int = 10
# --- MÓDULO DETERMINISTA PYTHON (SIN IA) ---
def verify_rules_deterministically(text: str, forbidden_char: str, target_words: int) -> List[Dict[str, Any]]:
results = []
# 1. VERIFICACIÓN DE LIPOGRAMA (Letra Prohibida)
forbidden_clean = forbidden_char.lower().strip()
# Mapeo de variantes acentuadas
accents_map = {
'e': ['e', 'é', 'ê', 'ë', 'E', 'É', 'Ê', 'Ë'],
'a': ['a', 'á', 'à', 'â', 'ä', 'A', 'Á', 'À', 'Â', 'Ä'],
'o': ['o', 'ó', 'ò', 'ô', 'ö', 'O', 'Ó', 'Ò', 'Ô', 'Ö'],
'i': ['i', 'í', 'ì', 'î', 'ï', 'I', 'Í', 'Ì', 'Î', 'Ï'],
'u': ['u', 'ú', 'ù', 'û', 'ü', 'U', 'Ú', 'Ù', 'Û', 'Ü']
}
target_variants = set(accents_map.get(forbidden_clean, [forbidden_clean, forbidden_clean.upper()]))
lines = text.split('\n')
errors_lipogram = []
total_words = 0
clean_words_count = 0
for line_idx, line in enumerate(lines, 1):
words = line.split()
for word in words:
total_words += 1
clean_word = re.sub(r'[^\w]', '', word)
found_chars = [ch for ch in clean_word if ch in target_variants]
if found_chars:
errors_lipogram.append(f"Línea {line_idx}: Palabra '{word}' contiene la letra prohibida ('{found_chars[0]}')")
else:
clean_words_count += 1
passed_lipogram = len(errors_lipogram) == 0 and total_words > 0
results.append({
"rule_id": "lipogram",
"title": f"Lipograma estricto (Sin la letra '{forbidden_clean.upper()}')",
"passed": passed_lipogram,
"score_text": f"{clean_words_count} de {total_words} palabras válidas",
"errors": errors_lipogram[:15] # Limitar a los 15 primeros fallos
})
# 2. VERIFICACIÓN DE PALABRAS POR FRASE
raw_sentences = re.split(r'[.!?]+', text)
sentences = [s.strip() for s in raw_sentences if s.strip()]
errors_sentences = []
valid_sentences_count = 0
for s_idx, sentence in enumerate(sentences, 1):
words_in_sentence = sentence.split()
count = len(words_in_sentence)
if count == target_words:
valid_sentences_count += 1
else:
short_sentence = sentence[:35] + "..." if len(sentence) > 35 else sentence
errors_sentences.append(f"Frase {s_idx} ('{short_sentence}'): tiene {count} palabras (se exigían {target_words})")
passed_sentences = len(errors_sentences) == 0 and len(sentences) > 0
results.append({
"rule_id": "words_per_sentence",
"title": f"Longitud exacta por frase ({target_words} palabras)",
"passed": passed_sentences,
"score_text": f"{valid_sentences_count} de {len(sentences)} frases con recuento exacto",
"errors": errors_sentences[:15]
})
return results
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 PEREC - Historias con Reglas</title>
<style>
:root {
--bg-page: #f8fafc;
--nav-bg: #0f172a;
--primary: #8b5cf6;
--primary-hover: #7c3aed;
--success-green: #059669;
--success-bg: #f0fdf4;
--error-red: #dc2626;
--error-bg: #fef2f2;
--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: #c084fc; 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-purple { background: var(--primary); color: white; }
.pill-amber { background: #d97706; color: white; }
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
.banner {
background: linear-gradient(135deg, #0f172a, #1e1b4b);
color: white;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 6px solid #c084fc;
}
.banner-text h2 { font-size: 12px; color: #c084fc; text-transform: uppercase; font-weight: 800; letter-spacing: 0.5px; }
.banner-text p { font-size: 18px; font-weight: 700; margin-top: 2px; }
.grid-main { display: grid; grid-template-columns: 380px 1fr; gap: 24px; }
.card { background: white; border-radius: 12px; border: 1px solid var(--border-color); padding: 20px; }
.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; }
input[type="text"], input[type="number"], textarea, select { 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: 90px; 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: var(--primary-hover); }
.btn-primary:disabled { background: #94a3b8; cursor: not-allowed; }
/* RESULTADOS Y SEMÁFORO */
.story-display { background: #fafafa; border: 1px solid var(--border-color); border-radius: 8px; padding: 16px; font-family: Georgia, serif; font-size: 16px; line-height: 1.8; color: #1e293b; white-space: pre-wrap; margin-bottom: 20px; }
.semaphore-card { border-radius: 10px; padding: 16px; margin-bottom: 16px; border: 2px solid var(--border-color); }
.semaphore-card.pass { background: var(--success-bg); border-color: #86efac; }
.semaphore-card.fail { background: var(--error-bg); border-color: #fca5a5; }
.semaphore-header { display: flex; align-items: center; justify-content: space-between; font-weight: 800; font-size: 14px; text-transform: uppercase; }
.semaphore-card.pass .semaphore-header { color: var(--success-green); }
.semaphore-card.fail .semaphore-header { color: var(--error-red); }
.badge-status { padding: 4px 10px; border-radius: 20px; font-size: 12px; font-weight: 800; }
.badge-pass { background: #10b981; color: white; }
.badge-fail { background: #ef4444; color: white; }
.error-list { margin-top: 10px; padding-left: 20px; font-size: 12.5px; color: #991b1b; font-family: monospace; }
.error-list li { margin-bottom: 4px; }
.pedagogical-box { background: #f0f9ff; border: 1px solid #bae6fd; border-left: 4px solid #0284c7; padding: 14px; border-radius: 8px; margin-top: 20px; font-size: 13px; color: #0369a1; }
.loader { display: none; text-align: center; padding: 30px; color: var(--primary); font-weight: 700; }
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="brand-logo">PEREC</div>
<div class="header-titles">
<h1>PROYECTO PEREC <span class="pill pill-amber">PUERTO :8071</span></h1>
</div>
</div>
<span class="pill pill-purple">IES MONTERROSO — LÍNEA THEMIS</span>
</header>
<div class="container">
<div class="banner">
<div class="banner-text">
<h2>HISTORIAS CON REGLAS Y VERIFICACIÓN DETERMINISTA</h2>
<p>Demostrando los límites del "Instruction Following" en Modelos de Lenguaje</p>
</div>
<div style="font-size:36px;">✍️</div>
</div>
<div class="grid-main">
<!-- PANEL IZQUIERDO: CONFIGURACIÓN DE REGLAS -->
<div class="card">
<div class="card-title">⚙️ 1. Configura el Desafío</div>
<label for="promptInput">Tema de la Historia:</label>
<textarea id="promptInput" placeholder="Ejemplo: Escribe un cuento sobre un robot de cocina que quiere ser poeta..."></textarea>
<label for="charInput">Letra Prohibida (Lipograma):</label>
<input type="text" id="charInput" value="e" maxlength="1" style="text-align:center; font-weight:800; font-size:18px; color:var(--error-red);">
<label for="wordsInput">Palabras exactas por frase:</label>
<input type="number" id="wordsInput" value="10" min="3" max="30">
<button class="btn-primary" id="generateBtn" onclick="generateAndVerify()">
<span>🚀</span> Generar y Auditar con Python
</button>
</div>
<!-- PANEL DERECHO: TEXTO + AUDITORÍA DETERMINISTA -->
<div class="card">
<div class="card-title">📄 2. Resultado del Modelo + Auditoría Python</div>
<div id="loader" class="loader">
🤖 Qwen intentando escribir la historia bajo restricciones...<br>
<small style="color:#64748b;">(El script en Python auditará el resultado letra a letra al terminar)</small>
</div>
<div id="resultsArea" style="display:none;">
<label>Texto Generado por el LLM:</label>
<div id="storyText" class="story-display"></div>
<div class="card-title" style="margin-top:20px;">🚥 Auditoría Determinista de Python</div>
<div id="semaphoresContainer"></div>
<div class="pedagogical-box">
🧐 <strong>LECCIÓN DE INGENIERÍA Y AUDITORÍA DE IA:</strong><br>
Observa cómo el modelo promete cumplir la regla, pero falla en el recuento real.
<strong>¿Por qué ocurre esto?</strong> Los modelos de lenguaje predicen <em>tokens</em> (subpalabras) probabilísticamente, no poseen un contador interno de caracteres mientras generan.
<em>Por eso la verificación siempre debe hacerla un script determinista en Python.</em>
</div>
</div>
</div>
</div>
</div>
<script>
async function generateAndVerify() {
const prompt = document.getElementById('promptInput').value.trim();
const forbidden_char = document.getElementById('charInput').value.trim() || "e";
const target_words = parseInt(document.getElementById('wordsInput').value) || 10;
if (!prompt) {
alert('Escribe un tema para la historia.');
return;
}
const btn = document.getElementById('generateBtn');
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/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
forbidden_char: forbidden_char,
target_words_per_sentence: target_words
})
});
if (!response.ok) throw new Error('Error en el servidor');
const data = await response.json();
document.getElementById('storyText').innerText = data.text;
const semaphoresContainer = document.getElementById('semaphoresContainer');
semaphoresContainer.innerHTML = '';
data.verification.forEach(rule => {
const isPass = rule.passed;
const cardClass = isPass ? 'pass' : 'fail';
const badgeClass = isPass ? 'badge-pass' : 'badge-fail';
const badgeText = isPass ? 'CUMPLIDA' : 'FALLADA';
let errorsHTML = '';
if (rule.errors && rule.errors.length > 0) {
errorsHTML = '<ul class="error-list">' + rule.errors.map(e => `<li>❌ ${e}</li>`).join('') + '</ul>';
}
semaphoresContainer.innerHTML += `
<div class="semaphore-card ${cardClass}">
<div class="semaphore-header">
<span>${isPass ? '✅' : '❌'} ${rule.title}</span>
<span class="badge-status ${badgeClass}">${badgeText}</span>
</div>
<div style="font-size:12px; margin-top:4px; color:#475569;">📊 Puntuación: <strong>${rule.score_text}</strong></div>
${errorsHTML}
</div>
`;
});
resultsArea.style.display = 'block';
} catch (err) {
alert('Error al conectar con el servicio. Revisa que LM Studio esté activo 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/generate")
async def generate_story(req: GenerationRequest):
if not req.prompt.strip():
raise HTTPException(status_code=400, detail="El prompt no puede estar vacío.")
system_prompt = (
f"Eres un escritor experimental que sigue reglas formales muy estrictas.\n"
f"REGLA 1 OBLIGATORIA: Escribe un cuento corto cumpliendo un LIPOGRAMA ESTRICTO. Está ABSOLUTAMENTE PROHIBIDO usar la letra '{req.forbidden_char.upper()}' (ni con acento ni en mayúscula) en ninguna palabra.\n"
f"REGLA 2 OBLIGATORIA: Cada frase del cuento debe tener EXACTAMENTE {req.target_words_per_sentence} palabras.\n\n"
f"Escribe solo la historia directamente, sin introducir explicaciones ni comentarios sobre las reglas."
)
try:
async with httpx.AsyncClient(timeout=35.0) as client:
payload = {
"model": "qwen2-vl-2b-instruct",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"Escribe una historia sobre: {req.prompt}"}
],
"temperature": 0.4,
"max_tokens": 400
}
res = await client.post(LM_STUDIO_URL, json=payload)
if res.status_code != 200:
raise HTTPException(status_code=500, detail="Error en LM Studio")
generated_text = res.json()["choices"][0]["message"]["content"].strip()
# EJECUCIÓN DE LA AUDITORÍA DETERMINISTA EN PYTHON (NUNCA PREGUNTAR AL MODELO)
verification = verify_rules_deterministically(
text=generated_text,
forbidden_char=req.forbidden_char,
target_words=req.target_words_per_sentence
)
return JSONResponse({
"text": generated_text,
"verification": verification
})
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error al conectar con LM Studio: {str(e)}")



