
Proyecto IA Monterroso (V): le damos nombre, colores, personalidad… y dos personalidades
En la entrada anterior conseguimos que la interfaz dejara de ser una pregunta suelta y se comportara como un chat de verdad, con memoria de corto plazo. Hoy tocan varias cosas que van juntas de manera natural: darle una identidad al asistente, darle personalidad, y —porque no hay solo una manera de sonar cercano— dejar elegir entre dos. Nada de esto necesita nada nuevo instalado; se hace en el mismo main.py de siempre.
Qué vamos a hacer
- Identidad visual: el asistente pasa a llamarse MonteIA, la interfaz deja de verse como una tarjeta pequeña centrada y ocupa toda la pantalla como una aplicación de chat normal, y adopta una paleta verde y blanco, en un guiño discreto a la bandera de Andalucía.
- Personalidad: hasta ahora, cada pregunta se mandaba “en seco” al modelo, sin ningún contexto sobre quién es o cómo debe comportarse. Añadimos un system prompt: unas instrucciones fijas que se le mandan al modelo antes de cada conversación.
- Dos personalidades, no una: en vez de una sola forma de hablar, definimos dos —formal y cercana/informal— y añadimos un selector en la propia página para elegir con cuál se quiere hablar.
Dónde vive la personalidad, y por qué importa
La personalidad no se ha puesto en la página web, sino en la API, en dos constantes: SYSTEM_PROMPT_FORMAL y SYSTEM_PROMPT_INFORMAL. La diferencia no es cosmética: si la personalidad viviera en el HTML/JavaScript del navegador, cualquier otra aplicación que en el futuro hable con esta misma API (una app de alumnado, una integración con Moodle, lo que sea) tendría que repetirla por su cuenta. Puesta en la API, todo el que hable con MonteIA a través de /preguntar hereda automáticamente las mismas personalidades, sin hacer nada especial. Es el mismo principio de modularidad que ya aplicamos al montar la API por delante de LM Studio en vez de conectar todo directamente.
El cliente (la página web, o cualquier otra app que se conecte en el futuro) solo tiene que decir cuál de las dos quiere usando un campo personalidad en la petición, con "formal" o "informal". Si no se especifica, o se manda un valor que no existe, la API cae por defecto en la formal.
Las dos personalidades
Formal — la de siempre, pensada para un uso más institucional:
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.
- 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.
- No tienes todavía información propia y verificada sobre el IES Monterroso (horarios, profesorado, normativa, etc.). Si te preguntan algo concreto de ese tipo, dilo claramente en vez de improvisar una respuesta que suene plausible.
- Sé conciso salvo que te pidan una explicación larga.
Informal (cercana) — con más humor y desparpajo, inspirada en el estilo de un profesor muy activo, con cuidado explícito de no bromear sobre nadie más que sobre sí mismo, y recordando de tanto en tanto que la IA es para aprender, no para copiar:
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.
- 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.
- No tienes todavía información propia y verificada sobre el IES Monterroso (horarios, profesorado, normativa, etc.). Si te preguntan algo concreto de ese tipo, dilo claramente, con humor si quieres, pero sin inventar datos.
- Habla en español de España. Sé conciso salvo que te pidan una explicación larga.
Las dos comparten dos reglas que no son negociables, con independencia del tono: admitir cuando no se sabe algo (documentamos en una entrega anterior que el modelo, si no sabe algo, se lo inventa con total seguridad —nos describió al lince con rasgos de perro—) y no inventar datos concretos del IES Monterroso, porque todavía no los tiene.
El selector en la página
Se ha añadido un desplegable “Personalidad” arriba del chat, con las opciones Formal y Cercana (informal). Al cambiarlo se empieza una conversación nueva automáticamente: mezclar mensajes de una conversación con dos personalidades distintas quedaría raro, así que se evita sin más.
Código completo
Archivo: ia-monterroso-api/main.py. Sustituye por completo a la versión anterior (que queda guardada, siguiendo la costumbre de la serie, en un mainv{N}.py de respaldo).
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.
- 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.
- No tienes todavía información propia y verificada sobre el IES Monterroso (horarios, profesorado, normativa, etc.). Si te preguntan algo concreto de ese tipo, dilo claramente en vez de improvisar una respuesta que suene plausible.
- 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.
- 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.
- No tienes todavía información propia y verificada sobre el IES Monterroso (horarios, profesorado, normativa, etc.). Si te preguntan algo concreto de ese tipo, dilo claramente, con humor si quieres, pero sin inventar datos.
- 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"
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.")
system_prompt = 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:]
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: seguimos con FastAPI, Uvicorn y Requests, nada más.
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 antes de sustituirlo, por si algo no sale bien. - Sustituye el contenido de
ia-monterroso-api/main.pypor el código de arriba. - En una terminal, dentro de la carpeta del proyecto:
cd ia-monterroso-api
uvicorn main:app --reload --port 8000
- Abre
http://localhost:8000/y comprueba el aspecto: cabecera verde con “MonteIA”, franja verde-blanco-verde, chat a pantalla completa, y el desplegable “Personalidad” arriba del chat. - Con “Formal” seleccionado, pregúntale “¿Quién eres?” y comprueba el tono.
- Cambia el selector a “Cercana (informal)” —verás que la conversación se reinicia sola— y hazle la misma pregunta. Comprueba que el tono cambia de verdad y que, si sigues charlando un rato, en algún momento menciona con humor que está para ayudar a aprender, no para copiar.
- En cualquiera de las dos, pregúntale algo muy concreto del centro que sabes que no puede conocer (un horario, un nombre real de profesorado) y comprueba que admite que no lo sabe, en vez de inventárselo.
- Confirma que la memoria de corto plazo sigue funcionando dentro de una misma personalidad (pregúntale tu nombre y si lo recuerda dos mensajes después).
Qué queda pendiente
Las personalidades ya están puestas, pero MonteIA sigue sin saber nada real del IES Monterroso: ni horarios, ni oferta educativa, ni normativa, nada. Ese es el siguiente paso de la serie: darle conocimiento propio del centro. Antes de escribir una sola línea de código para eso, hay que decidir con qué información real se va a alimentar, y según cuánta sea, veremos si basta con incluirla directamente en las instrucciones del asistente o si compensa montar ya un sistema de recuperación de documentos (RAG). Más complejidad, solo si hace falta.



