
Proyecto IA Monterroso (XVI): la primera herramienta real de MonteIA, una calculadora segura
Hasta ahora MonteIA solo hacía una cosa: recibir una pregunta y generar una respuesta de texto, apoyándose en su conocimiento del centro y en el RAG cuando hay documentos relacionados. Hoy toca la primera “herramienta” de verdad — algo que MonteIA no genera con el modelo, sino que calcula con Python y le entrega ya hecho.
Por qué una calculadora, con lo poco vistoso que suena
Como no podemos generar imágenes (sin GPU en ROCKY, y sobre todo por la moderación de contenido sin resolver — ver el bloque 5 del estado del proyecto), la pregunta era qué otra capacidad pequeña le añadimos a MonteIA que sea realmente útil y no complique la arquitectura.
Antes de elegir, descarté dos caminos:
Dejar que el propio modelo decida cuándo usar una herramienta (function calling nativo). Es la forma “moderna” de hacerlo, y Llama 3.2 3B lo soporta en teoría. Pero antes de construir nada sobre eso, conviene comprobar si es fiable a este tamaño de modelo — y no lo es especialmente: con modelos de 3B parámetros es habitual que la llamada a la herramienta salga mal formada, o que el modelo decida no llamarla cuando debería. Construir una capa de orquestación entera sobre una decisión que el modelo toma mal una parte del tiempo no merece la pena todavía.
Una API externa (tiempo, Wikipedia). Las dos existen gratis y sin necesidad de registrarse. Pero las descarté como primer paso porque introducen algo que MonteIA no tiene hoy: una llamada de red saliente desde ROCKY hacia fuera. Quedan anotadas como candidatas para una segunda herramienta, no descartadas del todo.
Lo que sí es un problema real, documentado y fácil de comprobar: un modelo de 3B parámetros hace mal las cuentas con bastante frecuencia. Ahí hay una herramienta simple, sin red, sin coste, que arregla algo que se ve fallar en la práctica.
Qué se ha añadido
El mismo patrón que ya usa el RAG: un paso en Python, determinista, que ocurre antes de hablar con el modelo, no una decisión que se le deja al modelo. Si la pregunta contiene una expresión aritmética reconocible, se calcula con un evaluador seguro y el resultado ya verificado se añade al system prompt, para que el modelo lo use en su respuesta en vez de inventarlo.
Nada de eval(): la expresión se parsea como árbol sintáctico (ast) y solo se permiten números y un puñado de operadores. Cualquier otra cosa — nombres, llamadas a función, intentos de colar código — se descarta sin más.
def _potencia_segura(base, exponente):
if abs(exponente) > 20 or abs(base) > 10**6:
raise ValueError("Operación demasiado grande para calcularla aquí.")
return operator.pow(base, exponente)
_OPERADORES_PERMITIDOS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: _potencia_segura,
ast.Mod: operator.mod,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _evaluar_nodo(nodo):
if isinstance(nodo, ast.Constant) and isinstance(nodo.value, (int, float)):
return nodo.value
if isinstance(nodo, ast.BinOp) and type(nodo.op) in _OPERADORES_PERMITIDOS:
izquierda = _evaluar_nodo(nodo.left)
derecha = _evaluar_nodo(nodo.right)
resultado = _OPERADORES_PERMITIDOS[type(nodo.op)](izquierda, derecha)
if abs(resultado) > _LIMITE_RESULTADO:
raise ValueError("Resultado demasiado grande.")
return resultado
if isinstance(nodo, ast.UnaryOp) and type(nodo.op) in _OPERADORES_PERMITIDOS:
return _OPERADORES_PERMITIDOS[type(nodo.op)](_evaluar_nodo(nodo.operand))
raise ValueError("Expresión no permitida en la calculadora.")
Y la función que busca la expresión dentro de la pregunta y la calcula:
_CARACTERES_NUMERICOS = re.compile(r"[0-9+\-*/%^().,\s]+")
def _intentar_calculo(pregunta):
texto = _normalizar_operadores_en_palabras(pregunta)
texto = texto.replace(",", ".")
for candidato in _CARACTERES_NUMERICOS.findall(texto):
candidato = candidato.strip(" .")
if not candidato:
continue
if not re.search(r"\d", candidato) or not re.search(r"[+\-*/%^]", candidato):
continue
if candidato.count("(") != candidato.count(")"):
continue
expresion = candidato.replace("^", "**")
try:
arbol = ast.parse(expresion, mode="eval")
resultado = _evaluar_nodo(arbol.body)
except Exception:
continue
if isinstance(resultado, float) and resultado.is_integer():
resultado = int(resultado)
return (
"Cálculo verificado con Python (no lo recalcules ni lo "
f"corrijas, es exacto): {expresion.strip()} = {resultado}"
)
return None
Dos cosas a notar: se busca el tramo más largo posible de la pregunta que parezca una cuenta (no un trozo cualquiera), para no calcular “a medias” una expresión con paréntesis; y hay límites explícitos de tamaño (exponente máximo, resultado máximo) para que nadie pueda colgar la API pidiendo una potencia absurda como 99999 ** 99999.
El fallo de la primera versión: solo entendía símbolos
La primera prueba real, con José Luis preguntando “¿cuánto es 234 por 17?”, no calculó nada — el modelo lo intentó “de memoria” y se lo inventó. El motivo: el detector solo reconocía operadores en forma de símbolo (+ - * / % ^), y en español es al menos tan normal preguntar con palabras (“por”, “entre”, “más”, “menos”) como con símbolos. “234 por 17” no tiene ningún símbolo que buscar, así que la calculadora no encontraba nada que hacer y se quedaba callada — dejando al modelo solo ante la cuenta, que es justo lo que se quería evitar.
La solución: normalizar esas palabras a su símbolo antes de buscar la expresión, pero solo cuando están pegadas a un número a cada lado. Así “234 por 17” se convierte en “234 * 17” antes de llegar al resto del código, y frases como “por favor” o “quiero más información” no se tocan, porque ahí no hay un número justo antes y después de la palabra.
_NUM = r"\d+(?:[.,]\d+)?"
_PATRONES_PALABRA = [
(re.compile(rf"({_NUM})\s*(?:multiplicado\s+por|por|x)\s*({_NUM})", re.IGNORECASE), r"\1 * \2"),
(re.compile(rf"({_NUM})\s*(?:dividido\s+por|dividido\s+entre|entre)\s*({_NUM})", re.IGNORECASE), r"\1 / \2"),
(re.compile(rf"({_NUM})\s*m[aá]s\s*({_NUM})", re.IGNORECASE), r"\1 + \2"),
(re.compile(rf"({_NUM})\s*menos\s*({_NUM})", re.IGNORECASE), r"\1 - \2"),
(re.compile(rf"({_NUM})\s*elevado\s+a\s*({_NUM})", re.IGNORECASE), r"\1 ** \2"),
]
def _normalizar_operadores_en_palabras(texto):
for patron, reemplazo in _PATRONES_PALABRA:
texto = patron.sub(reemplazo, texto)
return texto
Límite conocido de esta normalización: no reconoce paréntesis dichos con palabras (“4 por (2 menos 1)” solo calcula el “4” suelto, no la expresión completa). Quien quiera usar paréntesis tiene que escribirlos con el símbolo. Es un caso raro en la práctica y no merece más complejidad por ahora.
Y el enganche, en el mismo sitio donde ya se añade el contexto del RAG:
pista_calculo = _intentar_calculo(pregunta)
if pista_calculo:
partes_system_prompt.append(pista_calculo)
Cómo se comprobó
Antes de tocar nada en ROCKY: operaciones normales (234 * 17, 2 ** 10), con paréntesis (3 + 4 * (2 - 1)), con coma decimal en vez de punto (15,5 + 4,2) — todo correcto. Intentos de colar código (import os; os.system('ls')) — ignorado, no hay ningún eval() que lo ejecute. Casos límite pensados para no romper nada: una potencia desproporcionada (99999 ** 99999) y una división por cero, las dos controladas sin fallar la petición. Y un número suelto sin operador (por ejemplo, un año dentro de la pregunta) no se confunde con una cuenta que calcular.
Tras el fallo real con “234 por 17”, se repitió la batería añadiendo los casos con palabras (“por”, “x”, “multiplicado por”, “dividido entre”, “más”, “menos”, “elevado a”) y, para no meter falsos positivos, frases que tienen esas palabras sin ser una cuenta (“quiero más información por favor”, “tengo 5 gatos menos que antes”) — estas últimas siguen sin activar la calculadora, como debe ser. Confirmado por José Luis, con la corrección instalada, que “234 por 17” ya devuelve el número correcto.
Instalación
Solo cambia main.py. Sin dependencias nuevas (ast y operator son de la librería estándar de Python). Backup del main.py actual, sustituir, reiniciar.
Cómo comprobarlo
En el chat, con cualquier tema o personalidad, preguntar algo como “¿cuánto es 234 por 17?” o “resuelve 3 más 4 por (2 menos 1)”. La respuesta debe traer el número correcto — que es justo donde antes se le veía fallar de vez en cuando a un modelo de este tamaño.
Qué queda pendiente
Si esta primera herramienta funciona bien en el uso real, la siguiente candidata natural es el tiempo (hay una API gratuita, Open-Meteo, sin necesidad de clave), pero esa sí abre una llamada de red saliente desde ROCKY que hoy no existe — una decisión a hablar antes de tocar código, no algo que se dé por hecho.




