
Proyecto IA Monterroso (VI): el primer conocimiento propio (y por qué es de mentira)
Hasta ahora MonteIA sabía hablar, tenía nombre, tenía dos personalidades a elegir… y no sabía absolutamente nada del IES Monterroso. Si le preguntabas por la oferta educativa o el calendario, tenía que admitir que no tenía ni idea, porque así se lo pedíamos explícitamente en el system prompt para que no se lo inventara. Hoy le damos ese primer conocimiento real, y de paso explico una decisión que puede parecer rara a primera vista: por qué buena parte de lo que le hemos dado es falso a propósito.
De dónde sale la información
Antes de escribir nada, tocaba mirar qué información pública tiene ya el centro. La respuesta estaba delante de nuestras narices: la propia web oficial, iesmonterroso.org. De ahí sacamos, tal cual está publicado hoy:
- Oferta educativa: ESO (con sus optativas y programas especiales), las dos modalidades de Bachillerato, y las cuatro familias de Formación Profesional.
- Calendario escolar del curso 2025/26: fechas de inicio y fin de curso, y los periodos de vacaciones.
Esto es información institucional, pública, y que no identifica a ninguna persona concreta. La dejamos tal cual, porque es real y verificable en cualquier momento visitando la web.
Lo que NO hemos usado tal cual
La web también publica el equipo directivo (con nombre y apellidos de cada cargo) y los datos de contacto del centro (dirección, teléfono, correo). Aquí decidimos algo distinto: para esta entrada del blog, que es pública y sirve de muestra para que cualquiera la siga, hemos inventado esos datos. El equipo directivo, el teléfono, el correo y la dirección postal que aparecen en el código de este artículo son ficticios.
¿Por qué? Porque una cosa es documentar cómo se construye MonteIA, y otra muy distinta es publicar en abierto los nombres reales de personas concretas y su información de contacto, sin que eso aporte nada a quien solo quiere aprender a montar su propia versión del proyecto. La arquitectura y el código son exactamente los mismos con datos reales o inventados; lo único que cambia es lo que hay dentro de una variable de texto. Si vas a desplegar esto de verdad dentro de un centro, ahí sí sustituyes esos datos ficticios por los reales — y el propio código lo deja avisado con un comentario, para que no se te olvide ni se confunda nadie que herede el proyecto más adelante.
Dónde vive este conocimiento, y por qué en un bloque aparte
El nuevo conocimiento no se ha mezclado con la personalidad. Es una constante nueva, CONOCIMIENTO_CENTRO, que se concatena al system prompt de personalidad (formal o informal, la que se haya elegido) en el momento de hacer la petición al modelo.
Esto no es capricho de organización del código: es la misma arquitectura por capas de todo el proyecto, aplicada aquí dentro. La personalidad es “cómo habla” MonteIA; el conocimiento es “qué sabe”. Son cosas independientes, y separarlas tiene una ventaja muy concreta a corto plazo: el día que este bloque de texto se quede pequeño (que va a ser pronto, hay ya una carpeta con 75 documentos esperando) y haya que montar un sistema de recuperación de documentos (RAG), solo hace falta sustituir CONOCIMIENTO_CENTRO por el resultado de una búsqueda en esos documentos. La personalidad, la memoria de corto plazo, el selector, la interfaz… no se tocan.
Código completo
Archivo: ia-monterroso-api/main.py. Sustituye a la versión anterior (respaldada como mainv{N}.py, siguiendo la costumbre de la serie).
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
# 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). Úsala cuando sea relevante. Si te preguntan algo que no está en esa información, 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). Úsala cuando sea relevante. Si te preguntan algo que no está en esa información, 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
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.")
personalidad = PERSONALIDADES.get(
datos.personalidad, PERSONALIDADES[PERSONALIDAD_POR_DEFECTO]
)
system_prompt = personalidad + "\n\n" + CONOCIMIENTO_CENTRO
# 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:]
mensajes_para_el_modelo = (
[{"role": "system", "content": system_prompt}]
+ [m.model_dump() for m in historial_recortado]
)
cuerpo_peticion = {
"model": MODEL_NAME,
"messages": mensajes_para_el_modelo,
"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>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 burbujaPensando = 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');
}
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.
Cómo probarlo, paso a paso
- Abre LM Studio y comprueba que el servidor local está activo.
- Guarda una copia de tu
main.pyactual como respaldo. - Sustituye el contenido de
ia-monterroso-api/main.pypor el código de arriba. - Arranca la API:
cd ia-monterroso-api
uvicorn main:app --reload --port 8000
- Abre
http://localhost:8000/y prueba, con cualquiera de las dos personalidades:- “¿Qué modalidades de Bachillerato hay?” → debería responder bien, con datos reales.
- “¿Qué ciclos de Formación Profesional se pueden estudiar?” → debería responder bien.
- “¿Cuándo empiezan las vacaciones de Semana Santa?” → debería responder bien.
- “¿Quién es el tutor de 3º de ESO B?” o cualquier dato que sabes que no está en el bloque de conocimiento → debería admitir que no lo sabe, en vez de inventárselo.
- Si quieres comprobar que los datos de contacto son los de ejemplo (no los reales), pregúntale el teléfono o el correo del centro: tiene que salir el ficticio, tal cual está en el código.
Qué queda pendiente
Este bloque de conocimiento es intencionadamente pequeño: sirve para probar que la arquitectura de capas funciona (personalidad + conocimiento, combinables e independientes), no para dar por cerrado el conocimiento propio del centro. El siguiente paso, y el más grande hasta ahora en esta serie, es meterle a MonteIA una carpeta real con 75 documentos del IES Monterroso. Con ese volumen, un bloque de texto en el system prompt ya no sirve: hace falta un sistema de recuperación de documentos (RAG), que busque en esos documentos el fragmento relevante para cada pregunta y se lo pase al modelo solo cuando haga falta. Ese diseño, con datos reales sobre la mesa, es la próxima entrada de la serie.



