
Proyecto IA Monterroso (IV): de pregunta-respuesta a chat de verdad
En las entregas anteriores dejamos IA Monterroso funcionando en local: LM Studio ejecutando Llama 3.2 3B Instruct en ROCKY, una API propia en FastAPI por delante para no depender directamente del motor de inferencia, y una página web mínima con una caja de texto, un botón “Preguntar” y una respuesta debajo. Funcionaba, que ya es mucho, pero tenía una limitación evidente: cada pregunta era una isla. No había manera de decir “me llamo José” y que dos frases después la IA se acordara.
Toca resolver eso.
Qué vamos a hacer
Dos cosas, que no son lo mismo aunque a primera vista lo parezcan:
- Que la interfaz se vea como un chat: burbujas de pregunta y respuesta una debajo de otra, con scroll, en vez de un único cuadro que se sobrescribe cada vez.
- Que la IA recuerde lo que se ha dicho antes en la misma conversación, al menos durante un rato.
Lo primero es un cambio de interfaz, sin más. Lo segundo obliga a tocar la API, porque hasta ahora cada petición a /preguntar mandaba una sola pregunta suelta a LM Studio, sin nada de contexto alrededor.
El límite que no queremos ignorar
ROCKY no tiene GPU. Todo el modelo corre sobre CPU, y ya vimos en la entrada anterior que las respuestas tardan varios segundos. Si para “recordar” la conversación mandáramos siempre el historial completo desde el primer mensaje, cada respuesta sería un poco más lenta que la anterior, y en algún momento nos comeríamos la ventana de contexto del modelo (que en un 3B no es precisamente enorme).
Así que la memoria que añadimos hoy es memoria a corto plazo, no memoria de verdad: el backend solo reenvía al modelo los últimos 12 mensajes de la conversación (6 turnos de ida y vuelta), aunque en la pantalla se sigue viendo todo el historial completo. Es una solución deliberadamente sencilla y con un límite claro, pensada para no quedarnos sin velocidad de respuesta a las diez preguntas. Memoria de verdad, con documentos y contexto persistente, es cosa de una entrega futura (la del RAG).
Otra cosa que no hace todavía esta versión: guardar la conversación en ningún sitio. El historial vive en una variable dentro del navegador. Si recargas la página, se pierde. Es intencionado: no hay base de datos ni backend con sesiones, y así seguimos evitando complejidad que hoy no necesitamos.
Solución recomendada
Todo el cambio cabe en el mismo archivo de siempre, main.py, sin dependencias nuevas:
- Frontend: la página HTML pasa de tener una caja de respuesta única a tener un contenedor de chat con burbujas (usuario a la derecha, IA a la izquierda), un botón para empezar conversación nueva, y Enter para enviar el mensaje (Mayús+Enter para salto de línea, como en cualquier chat que conozcas).
- Backend: el endpoint
/preguntardeja de recibir unapreguntasuelta y pasa a recibir una lista demensajes(con suroley sucontent), que reenvía a LM Studio tal cual, recortada a los últimos 12.
Código completo
Archivo: ia-monterroso-api/main.py (sustituye por completo a la versión anterior, que queda guardada como mainv2.py por si hay que volver atrás).
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
import requests
# --- Configuración ---
LM_STUDIO_URL = "http://localhost:1234/v1/chat/completions"
MODEL_NAME = "llama-3.2-3b-instruct"
TIMEOUT_SEGUNDOS = 120
# Cuántos mensajes recientes del historial se envían al modelo en cada
# petición. No enviamos la conversación entera: en un modelo pequeño (3B)
# ejecutado en CPU sin GPU, cuanto más historial se manda, más lenta es la
# respuesta y antes se llena la ventana de contexto del modelo. Con esto la
# IA "recuerda" lo último dicho sin que la conversación se vuelva eterna
# de procesar.
MAX_MENSAJES_CONTEXTO = 12
app = FastAPI(title="IA Monterroso - API")
class Mensaje(BaseModel):
role: str # "user" o "assistant"
content: str
class PeticionChat(BaseModel):
mensajes: list[Mensaje]
class Respuesta(BaseModel):
respuesta: str
@app.post("/preguntar", response_model=Respuesta)
def preguntar(datos: PeticionChat):
if not datos.mensajes:
raise HTTPException(status_code=400, detail="No se ha enviado ningún mensaje.")
historial_recortado = datos.mensajes[-MAX_MENSAJES_CONTEXTO:]
cuerpo_peticion = {
"model": MODEL_NAME,
"messages": [m.model_dump() for m in historial_recortado],
"temperature": 0.7,
}
try:
respuesta_lm_studio = requests.post(
LM_STUDIO_URL, json=cuerpo_peticion, timeout=TIMEOUT_SEGUNDOS
)
respuesta_lm_studio.raise_for_status()
except requests.exceptions.ConnectionError:
raise HTTPException(
status_code=503,
detail="No se puede conectar con LM Studio. ¿Está abierto y con el servidor activado?",
)
except requests.exceptions.Timeout:
raise HTTPException(status_code=504, detail="El modelo ha tardado demasiado en responder.")
datos_respuesta = respuesta_lm_studio.json()
texto_generado = datos_respuesta["choices"][0]["message"]["content"]
return Respuesta(respuesta=texto_generado)
@app.get("/salud")
def salud():
return {"estado": "ok"}
PAGINA_HTML = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>IA Monterroso</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #f4f4f5; margin: 0;
display: flex; justify-content: center; padding: 24px 16px; }
.tarjeta { background: white; border-radius: 12px; padding: 20px 20px 16px;
max-width: 640px; width: 100%; box-shadow: 0 1px 4px rgba(0,0,0,0.1);
display: flex; flex-direction: column; height: calc(100vh - 48px); max-height: 780px; }
h1 { font-size: 1.2rem; margin: 0 0 2px; }
.aviso { font-size: 0.8rem; color: #666; margin: 0 0 12px; }
#chat { flex: 1; overflow-y: auto; padding: 6px 4px; display: flex;
flex-direction: column; gap: 10px; border-top: 1px solid #eee;
border-bottom: 1px solid #eee; }
.burbuja { max-width: 82%; padding: 10px 13px; border-radius: 14px;
line-height: 1.45; white-space: pre-wrap; font-size: 0.95rem; }
.usuario { align-self: flex-end; background: #4f46e5; color: white;
border-bottom-right-radius: 4px; }
.asistente { align-self: flex-start; background: #eef0f4; color: #1f1f1f;
border-bottom-left-radius: 4px; }
.asistente.pensando { color: #888; font-style: italic; }
.error { align-self: flex-start; background: #fde8e8; color: #9b1c1c; }
.zona-entrada { display: flex; gap: 8px; margin-top: 12px; align-items: flex-end; }
textarea { flex: 1; font-size: 1rem; padding: 10px; border-radius: 8px;
border: 1px solid #ccc; resize: none; max-height: 120px; font-family: inherit; }
button { padding: 10px 16px; font-size: 1rem; border: none; border-radius: 8px;
background: #4f46e5; color: white; cursor: pointer; white-space: nowrap; }
button:disabled { background: #a5a5a5; cursor: not-allowed; }
#boton_nueva { background: none; color: #666; text-decoration: underline;
font-size: 0.8rem; padding: 0; margin: 0 0 8px; align-self: flex-start; }
</style>
</head>
<body>
<div class="tarjeta">
<h1>IA Monterroso (prototipo)</h1>
<p class="aviso">Versión de pruebas, modelo pequeño ejecutándose en local. Puede
tardar varios segundos y equivocarse en datos concretos. El historial se
pierde al recargar la página: no se guarda en ningún servidor.</p>
<button id="boton_nueva" onclick="nuevaConversacion()">Empezar conversación nueva</button>
<div id="chat"></div>
<div class="zona-entrada">
<textarea id="pregunta" placeholder="Escribe tu pregunta... (Enter para enviar, Mayús+Enter para salto de línea)" rows="1"></textarea>
<button id="boton" onclick="enviar()">Enviar</button>
</div>
</div>
<script>
let historial = [];
const chatDiv = document.getElementById('chat');
const textarea = document.getElementById('pregunta');
const boton = document.getElementById('boton');
function pintarBurbuja(role, texto, extraClase) {
const div = document.createElement('div');
div.className = 'burbuja ' + (role === 'user' ? 'usuario' : 'asistente') + (extraClase ? ' ' + extraClase : '');
div.textContent = texto;
chatDiv.appendChild(div);
chatDiv.scrollTop = chatDiv.scrollHeight;
return div;
}
function nuevaConversacion() {
historial = [];
chatDiv.innerHTML = '';
}
async function enviar() {
const texto = textarea.value.trim();
if (!texto) return;
historial.push({ role: 'user', content: texto });
pintarBurbuja('user', texto);
textarea.value = '';
textarea.style.height = 'auto';
boton.disabled = true;
boton.textContent = '...';
const burbujaPensando = pintarBurbuja('assistant', 'Pensando...', 'pensando');
try {
const res = await fetch('/preguntar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mensajes: historial })
});
if (!res.ok) {
const detalle = await res.json().catch(() => null);
throw new Error(detalle && detalle.detail ? detalle.detail : 'Error del servidor');
}
const datos = await res.json();
burbujaPensando.remove();
historial.push({ role: 'assistant', content: datos.respuesta });
pintarBurbuja('assistant', datos.respuesta);
} catch (e) {
burbujaPensando.remove();
pintarBurbuja('assistant', e.message || 'Ha ocurrido un error.', 'error');
historial.pop(); // no dejamos en el historial la pregunta si no hubo respuesta
} finally {
boton.disabled = false;
boton.textContent = 'Enviar';
textarea.focus();
}
}
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
enviar();
}
});
textarea.addEventListener('input', () => {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
});
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
def interfaz():
return PAGINA_HTML
No hace falta tocar requirements.txt: seguimos con FastAPI, Uvicorn y Requests, nada más.
Cómo probarlo
- Abre LM Studio y comprueba que el servidor local está activo (el mismo paso de siempre).
- En una terminal, dentro de la carpeta del proyecto:
cd ia-monterroso-api
uvicorn main:app --reload --port 8000
- Abre
http://localhost:8000/en el navegador. - Prueba a mantener una conversación de varios turnos. Por ejemplo:
- “Me llamo José.”
- “¿Cómo me llamo?”
- Pulsa “Empezar conversación nueva” y comprueba que el chat se vacía y que, si vuelves a preguntar “¿cómo me llamo?”, la IA ya no lo sabe.
- Recarga la página (F5) y confirma que el historial desaparece, tal y como se avisa en la propia interfaz.
Qué queda pendiente
Esto sigue siendo un prototipo de un solo equipo, sin acceso desde fuera de ROCKY, sin autenticación y sin conocimiento propio del centro. Las próximas entradas de la serie irán, probablemente, hacia dar a la IA documentos e información propia del IES Monterroso, y hacia pensar cómo dar acceso a esto sin abrir el ordenador del instituto a Internet de cualquier manera. De momento, con que el chat se comporte como un chat de verdad, ya hemos ganado bastante usabilidad para el mismo coste de siempre: cero.



