
Proyecto IA Monterroso (VIII): respuesta en directo, palabra por palabra
Todo lo que hemos ido montando hasta ahora funcionaba, pero tenía un defecto de “sensación”: preguntabas algo, la interfaz decía “Pensando…” durante varios segundos, y de golpe aparecía la respuesta entera. Funcional, sí. Impresionante, no mucho. Hoy MonteIA aprende a escribir en directo, palabra por palabra, como cualquier chat de IA que se precie — y de paso cuento un error que se coló al hacerlo y cómo se arregló, porque forma parte del proceso tanto como el resultado final.
Qué vamos a hacer
Cambiar cómo se pide y se entrega la respuesta, sin tocar nada de lo que ya funcionaba: ni la memoria de corto plazo, ni las dos personalidades, ni el conocimiento del centro, ni el RAG. Todo eso sigue igual por dentro; lo único que cambia es cómo viaja la respuesta desde el modelo hasta la pantalla.
Cómo funciona el streaming
Hasta ahora, la API le pedía a LM Studio la respuesta completa y esperaba a tenerla entera antes de devolver nada. Ahora se le pide en modo streaming ("stream": true en la petición), y LM Studio va mandando la respuesta en trocitos, normalmente palabra a palabra o incluso más pequeños, a medida que el modelo los genera. La API reenvía cada trocito tal cual le llega, sin esperar a que la respuesta esté completa, y la página web los va pintando en la misma burbuja en cuanto los recibe.
El resultado: en vez de una espera silenciosa seguida de un bloque de texto entero, se ve a MonteIA “escribiendo” en tiempo real. Es exactamente la misma información, generada exactamente igual por dentro; lo único que cambia es cómo se entrega. Pero la diferencia de sensación, sobre todo delante de alumnado, es enorme.
Por el camino, esto también hace que la respuesta empiece a verse antes: no hay que esperar a que el modelo termine de generar todo el texto, basta con que genere la primera palabra.
El error que se coló (y cómo se encontró)
Nada más probarlo, José Luis se encontró con esto al preguntar por el lince ibérico:
“El lince ibérico (Lynx pardinus) es una especie de lince que se encuentra en la PenÃnsula Ibérica, especÃficamente en España y Portugal…”
Tildes y eñes convertidas en una sopa de símbolos. Es un fallo clásico de codificación de texto, y merece la pena explicarlo porque es de los que se repiten en cualquier proyecto que mueva texto entre programas: cuando se lee una respuesta en modo streaming con la librería requests de Python pidiéndole que decodifique el texto por nosotros (decode_unicode=True), esa librería tiene que adivinar en qué codificación está el texto si el servidor no lo dice explícitamente en la cabecera de la respuesta. LM Studio no lo dice. requests adivinó mal (asumió una codificación antigua, Latin-1, en vez de UTF-8), y cada carácter especial en UTF-8 —que ocupa dos bytes, como la “é”— se interpretó como si fueran dos caracteres Latin-1 distintos. De ahí la “é”.
La solución: dejar de pedirle a requests que adivine, y decodificar nosotros mismos el texto como UTF-8 de forma explícita, línea a línea. Un cambio de pocas líneas, pero que hay que saber diagnosticar — y por eso queda documentado aquí, no solo arreglado en silencio.
Código completo
Solo cambia ia-monterroso-api/main.py. Ni rag.py ni requirements.txt se tocan esta vez: no hace falta instalar nada nuevo, solo reiniciar la API.
import json
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from pydantic import BaseModel
import requests
from rag import buscar_contexto
# --- 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
# Personalidades de MonteIA. Viven aquí, en la capa de la API, y no en el
# cliente: así cualquier aplicación futura que hable con esta API (la web,
# una app de alumnado, lo que sea) hereda las mismas personalidades sin
# tener que repetirlas en cada sitio. Si cambia el modelo de IA, este texto
# no cambia.
SYSTEM_PROMPT_FORMAL = """Eres MonteIA, el asistente de inteligencia artificial del IES Monterroso, un instituto público de Estepona (Andalucía, España).
Cómo debes comportarte:
- Habla en español de España, de forma cercana, clara y respetuosa.
- Tu público es alumnado y profesorado de un instituto: sé apropiado para un entorno educativo.
- Más abajo tienes información real sobre el centro (oferta educativa, calendario, contacto) y, cuando venga al caso, fragmentos reales de documentos internos del IES Monterroso relacionados con la pregunta. Básate en ellos cuando estén disponibles. Si te preguntan algo que no está en esa información ni en esos fragmentos, dilo claramente en vez de inventarte una respuesta que suene plausible: no sabes nada del centro que no se te haya dado explícitamente.
- Si no sabes algo con certeza, dilo abiertamente en vez de inventarte datos. Eres un modelo pequeño ejecutado en un ordenador sin GPU, así que puedes equivocarte, y es mejor admitirlo que dar una respuesta inventada con seguridad.
- Sé conciso salvo que te pidan una explicación larga.
"""
SYSTEM_PROMPT_INFORMAL = """Eres MonteIA, el asistente de inteligencia artificial del IES Monterroso, un instituto público de Estepona (Andalucía, España). Aquí tienes una personalidad cercana e informal, como la de un profesor muy activo que siempre tiene mil cosas entre manos.
Cómo debes comportarte:
- Eres animado, dinámico y con sentido del humor. Puedes hacer bromas de vez en cuando, pero SOLO sobre ti mismo (por ejemplo, sobre lo liado que andas o lo que tardas en pensar la respuesta). Nunca bromees sobre otras personas, sobre el alumnado, el profesorado, ni sobre temas sensibles.
- Sigues estando en un centro educativo público: por debajo del tono gracioso, mantén siempre el respeto y un contenido apropiado para cualquier edad del instituto.
- De vez en cuando, sin repetirlo en cada respuesta, recuerda con simpatía que estás para ayudar a entender y aprender, no para que te copien un examen o una tarea tal cual.
- Más abajo tienes información real sobre el centro (oferta educativa, calendario, contacto) y, cuando venga al caso, fragmentos reales de documentos internos del IES Monterroso relacionados con la pregunta. Básate en ellos cuando estén disponibles. Si te preguntan algo que no está en esa información ni en esos fragmentos, dilo claramente (con humor si quieres, pero sin inventar datos): no sabes nada del centro que no se te haya dado explícitamente.
- Si no sabes algo con certeza, dilo abiertamente en vez de inventarte datos, con la misma naturalidad con la que bromeas. Eres un modelo pequeño ejecutado en un ordenador sin GPU, así que puedes equivocarte.
- Habla en español de España. Sé conciso salvo que te pidan una explicación larga.
"""
PERSONALIDADES = {
"formal": SYSTEM_PROMPT_FORMAL,
"informal": SYSTEM_PROMPT_INFORMAL,
}
PERSONALIDAD_POR_DEFECTO = "formal"
# Conocimiento propio del centro. Es deliberadamente un bloque de texto
# aparte de la personalidad (capa de "conocimiento" separada de la capa de
# "orquestación/personalidad", como marca la arquitectura del proyecto), así
# que el día que esto crezca y haya que pasar a un sistema de recuperación
# de documentos (RAG), solo se sustituye este bloque por una búsqueda en los
# documentos reales, sin tocar la personalidad.
#
# IMPORTANTE: el equipo directivo, el teléfono, el correo y la dirección
# postal de aquí abajo son DATOS FICTICIOS, inventados a propósito para esta
# versión pública/de blog. La oferta educativa y el calendario escolar sí
# son reales, tomados de la web oficial del centro. En un despliegue real
# dentro del IES Monterroso, esos datos ficticios se sustituyen por los
# verdaderos.
CONOCIMIENTO_CENTRO = """Información del IES Monterroso (Estepona, Málaga, España):
Contacto (datos de ejemplo, ficticios):
- Dirección postal: Avda. de las Palmeras, 12, 29680 Estepona (Málaga).
- Teléfono: 951 00 11 22.
- Correo: contacto@ejemplo-instituto.es.
Equipo directivo (nombres de ejemplo, ficticios):
- Dirección: Elena Cabrera Ruiz.
- Vicedirección: Antonio Reyes Molina.
- Jefatura de Estudios: Marta Domínguez Vega.
- Secretaría: Carlos Medina Ortiz.
Oferta educativa (real):
- ESO: 4 cursos (12-16 años). En 1º-3º hay optativas como Ajedrez, Robótica y Oratoria, y segundas lenguas (alemán, francés). En 4º se elige entre opciones como Economía, Latín, Tecnología u otra segunda lengua. Existen Programas de Diversificación Curricular y Ciclos Formativos de Grado Básico desde los 15 años. La evaluación usa la escala IN/SU/BI/NT/SB.
- Bachillerato: dos modalidades, Ciencias y Tecnología, y Humanidades y Ciencias Sociales.
- Formación Profesional: Informática y Comunicaciones; Atención a Personas en Situación de Dependencia; Guía, Información y Asistencias Turísticas; Acondicionamiento Físico.
Calendario escolar 2025/26 (real):
- Curso lectivo: del 15 de septiembre de 2025 al 14 de junio de 2026.
- Vacaciones de Navidad: del 22 de diciembre de 2025 al 6 de enero de 2026.
- Semana Blanca: del 23 al 27 de febrero de 2026.
- Semana Santa: del 30 de marzo al 3 de abril de 2026.
- No hay información pública de horarios de secretaría ni de fechas exactas de evaluaciones: si preguntan por eso, dilo así, no lo inventes.
"""
app = FastAPI(title="MonteIA - API")
class Mensaje(BaseModel):
role: str # "user" o "assistant"
content: str
class PeticionChat(BaseModel):
mensajes: list[Mensaje]
personalidad: str = PERSONALIDAD_POR_DEFECTO
@app.post("/preguntar")
def preguntar(datos: PeticionChat):
if not datos.mensajes:
raise HTTPException(status_code=400, detail="No se ha enviado ningún mensaje.")
personalidad = PERSONALIDADES.get(
datos.personalidad, PERSONALIDADES[PERSONALIDAD_POR_DEFECTO]
)
# Recortamos el historial de turnos ANTES de añadir el system prompt,
# para que el recorte nunca se lleve por delante la personalidad.
historial_recortado = datos.mensajes[-MAX_MENSAJES_CONTEXTO:]
# RAG: buscamos en los documentos internos indexados (ver rag.py) los
# fragmentos más relacionados con la ÚLTIMA pregunta (no con toda la
# conversación, para no arrastrar temas antiguos). Solo se añaden al
# system prompt si hay alguno realmente relacionado; si no, no se
# menciona ningún documento y el modelo sigue las reglas normales de
# admitir lo que no sabe.
ultima_pregunta = historial_recortado[-1].content if historial_recortado else ""
contexto_documentos = buscar_contexto(ultima_pregunta)
partes_system_prompt = [personalidad, CONOCIMIENTO_CENTRO]
if contexto_documentos:
partes_system_prompt.append(
"Fragmentos de documentos internos del IES Monterroso relacionados "
"con la última pregunta (puede que no todos sean útiles: usa solo lo "
"que responda de verdad a la pregunta, y si te basas en uno, puedes "
"citar el documento entre paréntesis):\n\n" + contexto_documentos
)
system_prompt = "\n\n".join(partes_system_prompt)
mensajes_para_el_modelo = (
[{"role": "system", "content": system_prompt}]
+ [m.model_dump() for m in historial_recortado]
)
# Pedimos la respuesta en modo streaming ("stream": True): LM Studio la
# va mandando trozo a trozo, en formato de eventos tipo
# "data: {...json...}", a medida que el modelo genera cada palabra, en
# vez de esperar a tenerla completa. Así la interfaz puede ir pintando
# la respuesta en directo, como en un chat de verdad, en lugar de dejar
# al usuario esperando y que aparezca todo de golpe al final.
cuerpo_peticion = {
"model": MODEL_NAME,
"messages": mensajes_para_el_modelo,
"temperature": 0.7,
"stream": True,
}
try:
respuesta_lm_studio = requests.post(
LM_STUDIO_URL, json=cuerpo_peticion, timeout=TIMEOUT_SEGUNDOS, stream=True
)
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.")
# Comprobamos aquí, ANTES de empezar a devolver la respuesta, que
# LM Studio no ha respondido con un error (por ejemplo, si el nombre
# del modelo no coincide). raise_for_status() ya se ha llamado arriba
# dentro del try, así que si llegamos aquí la conexión ha ido bien.
def trozos_de_texto():
"""Generador que va leyendo los eventos de LM Studio y devolviendo
solo el texto nuevo de cada uno, según se van generando.
Importante: leemos las líneas en bytes (SIN decode_unicode=True) y
las decodificamos nosotros mismos como UTF-8. LM Studio no indica
la codificación en la cabecera de la respuesta, y `requests` la
adivina cuando decode_unicode=True; a veces adivina mal (Latin-1),
y las tildes/eñes salen corruptas ("é" en vez de "é"). Decodificar
siempre como UTF-8 explícitamente evita ese problema.
"""
try:
for linea_bytes in respuesta_lm_studio.iter_lines():
if not linea_bytes:
continue
linea = linea_bytes.decode("utf-8", errors="replace")
if not linea.startswith("data: "):
continue
contenido = linea[len("data: ") :]
if contenido.strip() == "[DONE]":
break
try:
trozo_json = json.loads(contenido)
texto_nuevo = trozo_json["choices"][0]["delta"].get("content", "")
except (json.JSONDecodeError, KeyError, IndexError):
continue
if texto_nuevo:
yield texto_nuevo
finally:
respuesta_lm_studio.close()
return StreamingResponse(trozos_de_texto(), media_type="text/plain; charset=utf-8")
@app.get("/salud")
def salud():
return {"estado": "ok"}
PAGINA_HTML = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>MonteIA</title>
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body { font-family: system-ui, sans-serif; background: #ffffff; color: #1f1f1f;
display: flex; flex-direction: column; }
.cabecera { background: #1e7d3c; color: white; padding: 14px 24px; }
.cabecera h1 { margin: 0; font-size: 1.35rem; letter-spacing: 0.2px; }
.cabecera p { margin: 2px 0 0; font-size: 0.8rem; color: #dff3e4; }
.franja { display: flex; height: 8px; }
.franja div { flex: 1; }
.franja .verde { background: #1e7d3c; }
.franja .blanca { background: #ffffff; }
.contenido { flex: 1; display: flex; flex-direction: column; width: 100%;
max-width: 860px; margin: 0 auto; padding: 16px 20px 20px;
min-height: 0; }
.aviso { font-size: 0.8rem; color: #6b6b6b; margin: 0 0 10px; }
#chat { flex: 1; overflow-y: auto; padding: 10px 4px; display: flex;
flex-direction: column; gap: 12px; }
.burbuja { max-width: 78%; padding: 11px 14px; border-radius: 14px;
line-height: 1.5; white-space: pre-wrap; font-size: 0.97rem; }
.usuario { align-self: flex-end; background: #1e7d3c; color: white;
border-bottom-right-radius: 4px; }
.asistente { align-self: flex-start; background: #eaf7ee; color: #1f1f1f;
border: 1px solid #cfead7; border-bottom-left-radius: 4px; }
.asistente.pensando { color: #6b6b6b; font-style: italic; }
.error { align-self: flex-start; background: #fde8e8; color: #9b1c1c;
border: 1px solid #f6c6c6; }
.zona-entrada { display: flex; gap: 8px; margin-top: 12px; align-items: flex-end;
border-top: 1px solid #e6e6e6; padding-top: 12px; }
textarea { flex: 1; font-size: 1rem; padding: 10px 12px; border-radius: 10px;
border: 1px solid #cfd4cf; resize: none; max-height: 120px; font-family: inherit; }
textarea:focus { outline: none; border-color: #1e7d3c; }
button { padding: 10px 18px; font-size: 1rem; border: none; border-radius: 10px;
background: #1e7d3c; color: white; cursor: pointer; white-space: nowrap; }
button:disabled { background: #a8c6b0; cursor: not-allowed; }
#boton_nueva { background: none; color: #1e7d3c; text-decoration: underline;
font-size: 0.8rem; padding: 0; margin: 0 0 10px; align-self: flex-start; }
.barra_superior { display: flex; justify-content: space-between; align-items: center;
flex-wrap: wrap; gap: 8px; margin-bottom: 4px; }
.selector_personalidad { font-size: 0.8rem; color: #444; display: flex;
align-items: center; gap: 6px; }
.selector_personalidad select { font-size: 0.8rem; padding: 3px 6px; border-radius: 6px;
border: 1px solid #cfd4cf; background: white; }
</style>
</head>
<body>
<div class="cabecera">
<h1>MonteIA</h1>
<p>Asistente del IES Monterroso</p>
</div>
<div class="franja"><div class="verde"></div><div class="blanca"></div><div class="verde"></div></div>
<div class="contenido">
<p class="aviso">Puede tardar unos segundos en pensar la respuesta y alguna vez
equivocarse en datos concretos. La conversación no se guarda: si recargas
la página, empieza de cero.</p>
<div class="barra_superior">
<button id="boton_nueva" onclick="nuevaConversacion()">Empezar conversación nueva</button>
<label class="selector_personalidad">
Personalidad:
<select id="personalidad" onchange="nuevaConversacion()">
<option value="formal">Formal</option>
<option value="informal">Cercana (informal)</option>
</select>
</label>
</div>
<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');
const selectorPersonalidad = document.getElementById('personalidad');
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 burbujaRespuesta = pintarBurbuja('assistant', 'Pensando...', 'pensando');
try {
const res = await fetch('/preguntar', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mensajes: historial, personalidad: selectorPersonalidad.value })
});
if (!res.ok) {
const detalle = await res.json().catch(() => null);
throw new Error(detalle && detalle.detail ? detalle.detail : 'Error del servidor');
}
// La respuesta llega en streaming (texto plano, trozo a trozo), no
// como un JSON completo de golpe. La vamos pintando en directo en la
// misma burbuja a medida que va llegando.
const lector = res.body.getReader();
const decodificador = new TextDecoder('utf-8');
let textoCompleto = '';
let esPrimerTrozo = true;
while (true) {
const { done, value } = await lector.read();
if (done) break;
const trozo = decodificador.decode(value, { stream: true });
if (!trozo) continue;
if (esPrimerTrozo) {
burbujaRespuesta.classList.remove('pensando');
burbujaRespuesta.textContent = '';
esPrimerTrozo = false;
}
textoCompleto += trozo;
burbujaRespuesta.textContent = textoCompleto;
chatDiv.scrollTop = chatDiv.scrollHeight;
}
if (!textoCompleto) {
throw new Error('El modelo no ha devuelto ninguna respuesta.');
}
historial.push({ role: 'assistant', content: textoCompleto });
} catch (e) {
burbujaRespuesta.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
Cómo probarlo
- Guarda una copia de tu
main.pyactual como respaldo. - Sustitúyelo por el código de arriba. No hace falta tocar
rag.pynirequirements.txt, ni instalar nada nuevo. - Reinicia la API (Ctrl+C si estaba en marcha, y de nuevo
uvicorn main:app --reload --port 8000). - Abre
http://localhost:8000/y haz cualquier pregunta: la respuesta debería ir apareciendo palabra por palabra, no de golpe al final. - Prueba algo con tildes y eñes —por ejemplo, “háblame del lince ibérico”— y comprueba que se ven bien, sin símbolos raros.
Qué queda pendiente
El streaming es, sobre todo, una mejora de sensación: la misma IA, con una manera de presentarse mucho más viva. La lista de tareas de fondo del proyecto no cambia por esto: sigue pendiente revisar los 70 documentos restantes para ampliar el RAG, y todo lo demás sigue en local, sin autenticación ni acceso desde fuera de ROCKY. Como mejora futura en la misma línea de “impresionar sin gastar un euro”, queda sobre la mesa que MonteIA lea sus respuestas en voz alta con la síntesis de voz del propio navegador — gratis, sin servicios externos, y sin tocar nada de lo que hay hoy.


