Construyendo Lukre: donde la IA conversacional se encuentra con el software determinista
De un mensaje de WhatsApp a un registro financiero: los límites, contratos y la lógica de aplicación detrás de Lukre.

Un mensaje como «gasté 85 en el supermercado» exige poco de quien lo envía. Para la aplicación abre otras preguntas: quién lo envió, a qué hogar pertenece, qué importe quiso decir, si ese mensaje exacto ya se aplicó y qué debe decir la respuesta.
Lukre vive en ese espacio. Por fuera es una conversación de WhatsApp. Detrás hay registros financieros, un modelo de dominio y reglas que deben cumplirse tanto si el mensaje se escribió, se dictó o se fotografió. Este artículo sigue ese mensaje por el sistema y se detiene en cada límite para ver quién decide qué.
Dos sistemas, una conversación
Lukre son dos piezas de software con un contrato entre ellas.
La capa de conversación es Hermes, un entorno de agentes de código abierto utilizado sin cambios en su núcleo. Mantiene la sesión de WhatsApp mediante Baileys (cliente no oficial de WhatsApp Web, no la API Business de Meta), ejecuta el modelo que lee cada mensaje y decide qué herramienta llamar. Cada hogar tiene un perfil de agente. Un pequeño plugin mío, lukre-gate, está delante.
El servicio financiero es una aplicación Python 3.12 con FastAPI. Es la única fuente de verdad para el dinero. El agente accede por una puerta: un servidor Model Context Protocol (MCP) que publica sus operaciones como herramientas. Los mismos casos de uso están disponibles mediante REST para un panel renderizado en el servidor. Los registros viven en PostgreSQL, con SQLAlchemy y migraciones Alembic.

La separación es la principal decisión arquitectónica y su valor es sencillo: el modelo puede cambiarse, recibir otro prompt o equivocarse, y los registros siguen obedeciendo a código comprobable. El coste es igual de concreto: cada capacidad necesita herramienta, contrato y prueba, y el agente solo puede ser tan bueno como el catálogo de herramientas disponible.
En otras palabras, hay tres tipos de código en este recorrido. El modelo es probabilístico: entiende la intención, elige herramientas, lee imágenes de recibos y redacta respuestas. MCP es el contrato: publica capacidades, comprueba la forma de cada llamada y transporta entradas y resultados. La aplicación es determinista: deriva el contexto fiable, autoriza, lee importes escritos, aplica reglas, reserva cada mensaje una vez, persiste y calcula todos los totales. Las líneas no son perfectamente nítidas; las siguientes secciones muestran dónde se curvan.
Siguiendo un mensaje
La puerta de entrada
Antes de que el agente vea nada, lukre-gate se ejecuta como hook previo al despacho. Ignora grupos y difusiones, normaliza el identificador de WhatsApp del remitente a un teléfono y pregunta al servicio financiero: ¿pertenece este número a un hogar activo? Si no, se ignora el mensaje. Si algo falla, la respuesta también es «ignorar», no «dejar pasar».
El trabajo del adaptador termina ahí. No interpreta importes ni elige categorías. Decide si la conversación debe ocurrir.
El modelo elige una herramienta
El agente lee el mensaje y, para un gasto sencillo, llama a create_transaction. Este es su controlador en el servicio financiero, reducido al recorrido de texto:
@tool("create_transaction")
async def create_transaction(
ctx: Context,
*,
sender_number: Sender = "auto",
channel_message_id: str,
message: str | None = None,
) -> str:
async def run() -> Any:
async with _scope(ctx).transactions() as service:
result = await service.create_transaction(
sender_number, message, channel_message_id=channel_message_id
)
return result.as_json()
return await guard("create_transaction", ctx, run)No contiene lógica, a propósito. _scope(ctx) construye los servicios del hogar de la solicitud, el caso de uso trabaja y guard convierte rechazos tipados en códigos estables. El controlador devuelve una cadena JSON tanto en éxito como en fallo y nunca lanza una excepción, porque una excepción en este límite llegaría al miembro como silencio.
Observa el argumento message. El modelo no entrega un importe. Transmite el texto, que lee un analizador determinista del dominio con vocabularios para portugués, inglés y español. Lo que recibe la herramienta y lo que utiliza el dominio son tipos distintos:
message text → MCP tool input → application service → read_message() → CapturedEntry → checks → transaction row@dataclass(frozen=True, slots=True)
class CapturedEntry:
"""One complete financial fact read from a message."""
kind: EntryKind
amount: Decimal
currency: str
description: str
category_slug: str
transaction_date: date
@dataclass(frozen=True, slots=True)
class MessageReading:
"""The reader's verdict. ``entries`` is non-empty only when status is ``READ``."""
status: ReadingStatus
entries: tuple[CapturedEntry, ...] = ()Ambos se redujeron del módulo de captura del dominio. El modelo nunca construye un CapturedEntry; lo construye el analizador o devuelve una lectura que es una pregunta. El modelo decide qué operación se solicita. El dominio decide cuáles son los números.
Dos cosas faltan deliberadamente en la firma de la herramienta. No hay argumento de hogar: procede de la credencial del perfil de agente que hizo la llamada, resuelta antes de ejecutar cualquier controlador; este solo lo lee de la solicitud, nunca de algo que el modelo pueda rellenar. Y sender_number usa "auto" por defecto porque, en turnos de WhatsApp, el plugin lo sobrescribe con el número ya verificado antes de que la llamada llegue al servicio financiero.
MCP como límite
El agente nunca importa código financiero, no ve clases ni tablas y nunca escribe SQL. Ve un conjunto seleccionado de herramientas agrupadas en capacidades: transacciones, presupuestos, recibos y documentos, recordatorios e informes. Cada herramienta tiene nombre, descripción en el idioma del hogar y esquema de entrada derivado de la firma tipada del controlador, validado por MCP antes de la ejecución. El perfil del agente enumera las herramientas permitidas, dejando explícito el catálogo de elección del modelo.
Esto da a la capa de conversación un vocabulario de operaciones, no autoridad sobre los datos. MCP no decide el hogar de una llamada, si el remitente puede actuar, si el importe tiene sentido, si el mensaje ya se aplicó ni qué acepta la base de datos. Todo se resuelve tras ese límite, en los mismos servicios que usa el panel, dando las mismas garantías a cada entrada.
El límite compensa de formas concretas. El entorno del agente queda desacoplado de los detalles internos del servicio: clases y consultas pueden cambiar sin tocar un prompt. El contrato puede probarse sin modelo: las pruebas de integración llaman a cada herramienta como el entorno MCP y verifican que todas devuelvan cadenas JSON sin excepciones, que los errores tipados se conviertan en códigos estables, que las llamadas sin credenciales válidas no lleguen a un servicio, que el dinero cruce como cadena y vuelva como el mismo Decimal y que la lista del perfil coincida exactamente con la del servidor. Una nueva capacidad es una herramienta con contrato propio, no otro párrafo de reglas financieras en el prompt.
También hay costes. Cada capacidad necesita contrato, descripción y pruebas. Las descripciones forman parte de la interfaz porque orientan la elección del modelo. Un catálogo mayor amplía el espacio de decisión del agente y cada cambio en una herramienta debe mantenerse alineado con el perfil que la enumera y el comportamiento aprendido de la descripción anterior.
Dentro del servicio
TransactionService.create_transaction trabaja en un orden fijo dentro de una transacción de base de datos:
- Autorizar. El remitente debe ser miembro activo del hogar del token. Un rechazo queda auditado.
- Reservar el mensaje. El identificador entra en una tabla de idempotencia con restricción única de hogar, canal y mensaje. Si ya existe, no se registra nada nuevo.
- Leer. El analizador convierte el texto en un
CapturedEntryo en una pregunta. - Comprobar el importe. Se rechazan cero e importes negativos. Por encima de un límite de plausibilidad no se rechaza: se convierte en una pregunta de confirmación, porque un dígito equivocado es más probable que una compra de supermercado de seis cifras.
- Persistir y confirmar. Se guarda el registro y el dominio construye la confirmación a partir de los valores almacenados.

La respuesta que ve el miembro también empieza en el código. Una prueba unitaria fija la frase inglesa exacta para un hogar en inglés («Recorded: R$ 120,00 on groceries yesterday.») y el agente recibe instrucciones de redactar respuestas a partir de los resultados de las herramientas, sin recalcularlos.
Contratos, dominio y dinero
Que un esquema responda «sí» solo demuestra que la forma era correcta. Un registro financiero implica al menos cinco preguntas distintas, respondidas en lugares diferentes:
| Pregunta | Dónde se responde |
|---|---|
| ¿La entrada tiene la forma esperada? | Firmas de herramientas y modelos Pydantic en el límite de la API |
| ¿La información tiene sentido y está completa? | Analizador del dominio: un importe, dirección clara y fecha sin ambigüedad |
| ¿Puede esta persona hacer esto aquí? | Autorización contra el hogar vinculado al token |
| ¿Las reglas permiten el cambio? | Comprobaciones del dominio y restricciones de la base de datos, como amount > 0 |
| ¿Se persistió o sigue pendiente? | Resultado de captura: registrado, pregunta formulada o rechazado |
El dinero es Decimal en todas partes, cuantizado a dos decimales con redondeo half-up, almacenado como NUMERIC(14,2) y enviado como cadena por las herramientas. Los auxiliares de suma no aceptan float: lanzan TypeError en lugar de convertirlo silenciosamente.
Esa precisión tiene un límite que debe quedar claro. La aritmética exacta sobre un importe mal leído sigue estando mal. El analizador reduce el riesgo preguntando cuando hay dos importes o ninguna dirección clara, pero no puede saber que «85» debería ser «58».
Lukre registra dinero; no lo custodia. Un gasto registrado es lo que alguien dijo haber gastado. Un presupuesto es un límite fijado por el hogar. Ninguno es un saldo bancario y el producto no se conecta a bancos ni mueve dinero.
Quién lo pide
La identidad se divide en dos hechos que vienen de lugares distintos.
El hogar procede de la credencial del perfil del agente, verificada antes de ejecutar herramientas; las credenciales desconocidas o revocadas y los hogares suspendidos no llegan al servicio. Ninguna herramienta recibe un identificador de hogar como argumento, por lo que un prompt no puede convencer al modelo de cambiarlo.
El miembro procede del remitente. En los turnos de WhatsApp, el plugin fija sender_number al número verificado y el servicio comprueba que sea miembro activo del hogar del token. Una prueba de integración cubre el caso principal: se rechaza a un miembro real del hogar B que llama con el token del hogar A.
Bajo la aplicación hay una segunda capa. Cada tabla multiinquilino contiene household_id; el ORM filtra cada consulta y marca las filas nuevas con él. La seguridad por fila de PostgreSQL está activada y forzada en esas tablas, por transacción. Una sesión sin hogar no lee nada.
El aislamiento entre hogares no es privacidad entre miembros del mismo hogar. Los recibos y documentos extraídos son visibles para todos los miembros activos, como indica la FAQ pública. Los recordatorios son la excepción en el modelo actual: cada persona ve los que creó, los dirigidos a ella y los dirigidos a todos.
Recibos, notas de voz e incertidumbre
Las notas de voz se transcriben en la capa de conversación. La transcripción llega a la misma herramienta create_transaction, con origin="audio" y estado de transcripción, y sigue el recorrido del texto. Una transcripción vacía o fallida pide reenviar, no adivina.
En los recibos, el modelo sí lee números, por lo que el límite cambia. En los recibos electrónicos brasileños, primero se decodifica el QR mediante código. La imagen o texto del PDF pasa a un modelo multimodal, llamado con formato JSON y temperatura cero; su salida se valida contra un esquema y se trata como candidata. Dos comprobaciones deciden si puede convertirse en hecho. Esta es la aritmética, del módulo de recibos del dominio:
def reconcile(
items: Sequence[ExtractedItem],
*,
discount: Decimal = Decimal(0),
stated_total: Decimal | None,
tolerance: Decimal = RECONCILIATION_TOLERANCE,
) -> Reconciliation:
"""Check the arithmetic. Exact decimals throughout, so R$ 0,02 means R$ 0,02."""
items_total = sum_amounts([item.total_price for item in items])
computed = quantize_money(items_total - discount)
if stated_total is None:
return Reconciliation(
items_total=items_total,
discount=discount,
stated_total=None,
difference=Decimal("0.00"),
balanced=False,
)
difference = quantize_money(abs(computed - stated_total))
return Reconciliation(
items_total=items_total,
discount=discount,
stated_total=stated_total,
difference=difference,
balanced=difference <= tolerance,
)ExtractedItem es la línea de recibo propuesta por el modelo, una clase de datos sencilla. Un recibo sin total declarado nunca cuadra y la validación lo convierte en una pregunta específica antes de leer la diferencia.
Los artículos, menos descuentos, deben coincidir con el total declarado con tolerancia de dos céntimos. Total, comercio y fecha deben tener confianza de al menos 0,80; la ausencia de confianza cuenta como baja. Si falla alguna comprobación, la compra pasa a revisión y el miembro recibe una pregunta concreta. La documentación del módulo declara la regla más importante para mí: nada ajusta una cifra para que cuadre.
El recibo también es donde podría aparecer el doble cómputo, por eso el modelo de compra es estricto. Ingerir un recibo crea una compra con sus artículos y exactamente un gasto por el total. Los artículos nunca son gastos propios. Si alguien escribe «gasté 85 en el supermercado» y envía el recibo después, el servicio ofrece adjuntarlo al gasto existente en vez de crear otro. Las pruebas de integración comprueban que los totales no cambien al adjuntarlo y que dos pagos realmente idénticos sigan separados.

«La IA interpreta, el dominio decide» se acerca, pero no es exacto y la diferencia importa. Más preciso: el modelo propone operaciones; el contexto fiable y el código determinista deciden qué puede convertirse en estado. El agente llama a herramientas que lo modifican: un gasto claro se registra inmediatamente, pues preguntar «¿quieres que lo registre?» después de cada café invalidaría el producto. Lo que no puede es saltarse las reglas. Las herramientas destructivas o de configuración, como borrar una transacción o cambiar un presupuesto, devuelven primero confirmation_required. La confirmación es de un solo uso, caduca y está vinculada a la persona, la herramienta y un hash de los parámetros exactos.
Recordatorios y trabajo que ocurre después
Los recordatorios son el recorrido asíncrono más claro. No hay broker de mensajes: bucles en segundo plano se ejecutan dentro del servicio y PostgreSQL los coordina con advisory locks y FOR UPDATE SKIP LOCKED, para que dos workers nunca tomen el mismo recordatorio vencido.
Cada recordatorio tiene un destinatario que aún debe ser elegible y una programación calculada en el huso del hogar. Al vencer, el servicio guarda un mensaje en la bandeja de salida con clave determinista de deduplicación. Un worker del plugin reserva filas por un periodo breve y las envía, espaciando reintentos; tras el último, marca el fallo y alerta al operador. Un recordatorio enviado con más de cinco minutos de retraso lo indica; con una hora, se marca como perdido en vez de llegar fuera de contexto.
Cuando las cosas salen mal
Tres fallos dicen más del diseño que el camino feliz.
El mismo evento llega dos veces. La reserva de idempotencia y los registros se confirman en la misma transacción; repetir el identificador no registra nada nuevo. El límite: protege contra identificadores repetidos, no contra contar el mismo gasto dos veces con palabras distintas. Eso es una cuestión de producto, no una restricción.
Una dependencia no está disponible. Un rechazo del dominio se confirma, mientras un error transitorio revierte la transacción y libera la reserva. Si el modelo de recibos cae o agota el tiempo, el recibo se informa como ilegible y debe reenviarse, en vez de recibir una lectura inventada. El coste es un nuevo intento a cargo de la persona.
El registro se guarda, pero la respuesta no llega. El registro permanece y repetir el mensaje no crea otro. Los mensajes de la bandeja de salida, como recordatorios, se entregan al menos una vez: perder una confirmación puede duplicar el mensaje, nunca el registro. Enviar no es entregar y el sistema no promete exactly-once de extremo a extremo.
Organización, pruebas y compromisos
El paquete finance se divide por responsabilidad: domain contiene reglas puras (dinero, periodos, análisis, validación de recibos, horarios), services los casos de uso, repositories modelos y consultas SQLAlchemy, api herramientas MCP, REST y ensamblado, y extractors llamadas a modelos. El dominio no importa frameworks web ni código de base de datos. La separación no es perfecta: comparte clases sencillas con los extractores y los servicios hablan directamente con SQLAlchemy. Prefiero decirlo a dibujar un diagrama más limpio que el código.
Los proveedores de modelos quedan detrás del módulo extractor, configurados en una función de ensamblado, para que el resto del servicio nunca construya prompts ni interprete respuestas del modelo. Cambiar un proveedor es un cambio localizado. No es gratis: otro modelo puede leer los recibos de otra forma y solo las comprobaciones de confianza y aritmética separan eso de un registro.
La suite tiene pruebas unitarias del dominio, integración con PostgreSQL real y pruebas de flujos completos. Las pruebas de contrato MCP descritas están en la integración. Las llamadas a modelos son simulaciones que reproducen extracciones grabadas: demuestran cómo reacciona la aplicación a una lectura, no cómo lee el modelo. Aún no hay suite de evaluación del comportamiento del modelo. Este es el tipo de prueba que considero más útil en un dominio financiero, lo bastante pequeño para leerlo de una vez. Está adaptado de las pruebas de dinero: las reales son métodos con identificadores de requisito en sus nombres.
def test_summing_decimal_amounts_is_exact() -> None:
total = sum_amounts([Decimal("0.07")] * 200)
assert total == Decimal("14.00")
assert str(total) == "14.00"
def test_float_amounts_are_rejected() -> None:
with pytest.raises(TypeError):
sum_amounts([Decimal("0.07"), 0.07])
def test_negative_amount_is_rejected() -> None:
with pytest.raises(ValidationFailed) as raised:
check_amount(Decimal("-5"))
assert raised.value.code == "amount_not_positive"Tres decisiones y lo que cada una deja abierto:
- Un analizador determinista lee importes escritos. Ayuda: números exactos y comprobables en tres idiomas; la misma entrada siempre genera el mismo registro. No resuelve expresiones fuera del vocabulario, que se convierten en preguntas en vez de suposiciones.
- Un token por hogar y seguridad por fila en la base de datos. Ayuda: el modelo nunca elige al inquilino y un error en una consulta tampoco permite leer otro hogar. No resuelve la privacidad entre miembros del mismo hogar, que es una regla de producto, no una garantía de aislamiento.
- Herramientas de escritura sin confirmación para gastos claros. Ayuda: registrar se parece a enviar un mensaje. No resuelve una intención malinterpretada. Puede escribirse un registro erróneo y habrá que corregirlo después; solo las acciones destructivas esperan consentimiento.
De vuelta a los R$ 85
«Gasté 85 en el supermercado» termina como una fila: 85,00, en la moneda del hogar, en supermercado, atribuida al miembro que lo envió, reservada una vez y confirmada en una frase escrita por el código. La conversación siguió siendo sencilla. La ingeniería quedó detrás, donde puede probarse.
La pregunta a la que siempre vuelvo es la medición. La aplicación tiene pruebas que fijan su comportamiento; el modelo tiene instrucciones y comprobaciones. Nada de lo siguiente existe aún. El próximo paso real es un conjunto versionado de evaluaciones, ejecutado contra cada versión de prompt y modelo, que cubra:
- selección de herramientas: ¿el agente llamó a la correcta y transmitió el mensaje sin cambios?
- mensajes ambiguos: ¿preguntó en vez de adivinar?
- expresiones en los tres idiomas que lee el analizador;
- extracción de recibos, campo por campo, incluida la confianza declarada por el modelo;
- regresiones entre versiones de prompt o modelo.
Después viene observar en producción: latencia de modelos y herramientas, herramienta elegida, tokens y coste, y una traza por mensaje que conecte el turno del modelo con la llamada a herramienta y el registro. Así, cambiar un prompt o modelo se convierte en algo que puedo medir, en lugar de limitarme a esperar que funcione.
Puedes conocer Lukre y lo que hace o explorar los demás textos.
