Construindo o Lukre: onde a IA conversacional encontra o software determinístico
De uma mensagem no WhatsApp a um registro financeiro: os limites, contratos e a lógica de aplicação por trás do Lukre.

Uma mensagem como “gastei 85 no supermercado” exige pouco de quem a envia. Para a aplicação, abre outras perguntas: quem enviou, a qual família pertence, qual valor quis dizer, se essa mensagem exata já foi aplicada e o que a resposta deve dizer.
O Lukre vive nesse intervalo. Por fora, é uma conversa no WhatsApp. Por trás, existem registros financeiros, um modelo de domínio e regras que precisam valer seja a mensagem digitada, falada ou fotografada. Este artigo acompanha essa mensagem pelo sistema e para em cada limite para ver quem decide o quê.
Dois sistemas, uma conversa
O Lukre são dois softwares com um contrato entre eles.
A camada de conversa é o Hermes, um ambiente de agentes de código aberto, usado sem alterações no núcleo. Mantém a sessão do WhatsApp via Baileys (cliente não oficial do WhatsApp Web, não a API Business da Meta), executa o modelo que lê cada mensagem e decide qual ferramenta chamar. Cada família tem um perfil de agente. Um pequeno plugin meu, lukre-gate, fica à frente.
O serviço financeiro é uma aplicação Python 3.12 com FastAPI. É a única fonte de verdade para dinheiro. O agente o acessa por uma porta: um servidor Model Context Protocol (MCP) que publica suas operações como ferramentas. Os mesmos casos de uso estão disponíveis via REST para um painel renderizado no servidor. Os registros ficam no PostgreSQL, com SQLAlchemy e migrações Alembic.

A separação é a principal decisão arquitetural, e seu valor é simples: o modelo pode ser trocado, receber outro prompt ou errar, e os registros continuam obedecendo a código testável. O custo é igualmente concreto: cada capacidade precisa de ferramenta, contrato e teste, e o agente só pode ser tão bom quanto o catálogo de ferramentas disponível.
Em outras palavras, existem três tipos de código nesse caminho. O modelo é probabilístico: entende intenção, escolhe ferramenta, lê imagens de recibos e redige respostas. MCP é o contrato: publica capacidades, verifica o formato de cada chamada e transporta entradas e resultados. A aplicação é determinística: deriva o contexto confiável, autoriza, lê valores digitados, aplica regras, reserva cada mensagem uma vez, persiste e calcula todos os totais. As linhas não são perfeitamente nítidas; as próximas seções mostram onde se curvam.
Acompanhando uma mensagem
A porta de entrada
Antes que o agente veja algo, lukre-gate roda como um hook pré-despacho. Ignora grupos e transmissões, normaliza o identificador WhatsApp do remetente para um telefone e pergunta ao serviço financeiro: esse número pertence a uma família ativa? Se não, a mensagem é ignorada. Se algo falhar no caminho, a resposta também é “ignorar”, não “deixar passar”.
O trabalho do adaptador termina aí. Ele não interpreta valores nem escolhe categorias. Decide se a conversa deve acontecer.
O modelo escolhe uma ferramenta
O agente lê a mensagem e, para um gasto simples, chama create_transaction. Este é seu manipulador no serviço financeiro, reduzido ao caminho 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)Não há lógica nele, de propósito. _scope(ctx) monta os serviços da família da requisição, o caso de uso trabalha e guard converte recusas tipadas em códigos estáveis. O manipulador devolve uma string JSON no sucesso e na falha e nunca lança exceção, porque uma exceção nesse limite chegaria ao membro como silêncio.
Repare no argumento message. O modelo não entrega um valor. Repassa o texto, que um analisador determinístico do domínio lê, com vocabulários para português, inglês e espanhol. O que a ferramenta recebe e o que o domínio usa são tipos diferentes:
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 foram reduzidos do módulo de captura do domínio. O modelo nunca constrói um CapturedEntry; o analisador constrói ou devolve uma leitura que é uma pergunta. O modelo decide qual operação foi solicitada. O domínio decide quais são os números.
Duas coisas faltam deliberadamente na assinatura da ferramenta. Não há argumento de família: ela vem da credencial do perfil de agente que fez a chamada, resolvida antes de qualquer manipulador executar; ele apenas a lê da requisição, nunca de algo que o modelo possa preencher. E sender_number usa "auto" por padrão porque, em turnos de WhatsApp, o plugin o sobrescreve com o número já verificado antes de a chamada chegar ao serviço financeiro.
MCP como limite
O agente nunca importa código financeiro, não vê classes ou tabelas e nunca escreve SQL. Vê um conjunto selecionado de ferramentas, agrupadas em capacidades: transações, orçamentos, recibos e documentos, lembretes e relatórios. Cada ferramenta tem nome, descrição no idioma da família e esquema de entrada derivado da assinatura tipada do manipulador, validado pelo MCP antes da execução. O perfil do agente lista as ferramentas permitidas, deixando explícito o catálogo de escolha do modelo.
Isso dá à camada de conversa um vocabulário de operações, não autoridade sobre os dados. MCP não decide a família de uma chamada, se o remetente pode agir, se o valor faz sentido, se a mensagem já foi aplicada ou o que o banco aceita. Tudo é resolvido depois desse limite, nos mesmos serviços usados pelo painel, dando as mesmas garantias a cada entrada.
O limite compensa de formas concretas. O ambiente do agente fica desacoplado das partes internas do serviço: classes e consultas podem mudar sem tocar num prompt. O contrato pode ser testado sem modelo: testes de integração chamam cada ferramenta como o ambiente MCP e verificam que todas retornam string JSON sem lançar exceções, que erros tipados viram códigos estáveis, que chamadas sem credencial válida não chegam a um serviço, que dinheiro atravessa como string e volta como o mesmo Decimal e que a lista do perfil coincide exatamente com a do servidor. Uma nova capacidade é uma ferramenta com contrato próprio, não mais um parágrafo de regras financeiras no prompt.
Também há custos. Cada capacidade precisa de contrato, descrição e testes. As descrições fazem parte da interface porque orientam a escolha do modelo. Um catálogo maior amplia o espaço de decisão do agente, e cada mudança numa ferramenta precisa acompanhar o perfil que a lista e o comportamento aprendido com a descrição anterior.
Dentro do serviço
TransactionService.create_transaction trabalha numa ordem fixa, dentro de uma transação do banco:
- Autorizar. O remetente deve ser membro ativo da família do token. Uma recusa é auditada.
- Reservar a mensagem. O identificador entra numa tabela de idempotência com restrição única de família, canal e mensagem. Se já existir, nada novo é registrado.
- Ler. O analisador converte o texto em um
CapturedEntryou em uma pergunta. - Verificar o valor. Zero e valores negativos são recusados. Acima de um limite de plausibilidade, o valor não é rejeitado: vira uma pergunta de confirmação, porque um dígito errado é mais provável que uma compra de supermercado de seis dígitos.
- Persistir e confirmar. O registro é gravado e o domínio monta a confirmação a partir dos valores armazenados.

A resposta vista pelo membro também começa no código. Um teste unitário fixa a frase inglesa exata para uma família em inglês (“Recorded: R$ 120,00 on groceries yesterday.”), e o agente é instruído a redigir respostas a partir dos resultados das ferramentas, sem recalculá-los.
Contratos, domínio e dinheiro
Um esquema responder “sim” só prova que o formato estava correto. Um registro financeiro envolve pelo menos cinco perguntas distintas, respondidas em lugares diferentes:
| Pergunta | Onde é respondida |
|---|---|
| A entrada tem o formato esperado? | Assinaturas das ferramentas e modelos Pydantic no limite da API |
| A informação faz sentido e está completa? | Analisador do domínio: um valor, direção clara e data sem ambiguidade |
| Esta pessoa pode fazer isso aqui? | Autorização contra a família vinculada ao token |
| As regras permitem a mudança? | Verificações do domínio e restrições do banco, como amount > 0 |
| Foi persistido ou continua pendente? | Resultado da captura: registrado, pergunta feita ou recusado |
Dinheiro é Decimal em todo lugar, quantizado em duas casas com arredondamento half-up, armazenado como NUMERIC(14,2) e enviado como string pelas ferramentas. Os auxiliares de soma não aceitam float: lançam TypeError em vez de converter silenciosamente.
Essa precisão tem um limite que precisa ficar claro. Aritmética exata sobre um valor lido errado continua errada. O analisador reduz o risco perguntando quando há dois valores ou nenhuma direção clara, mas não pode saber que “85” deveria ser “58”.
O Lukre registra dinheiro; não o guarda. Um gasto registrado é o que alguém disse ter gasto. Um orçamento é um limite definido pela família. Nenhum é um saldo bancário, e o produto não se conecta a bancos nem movimenta dinheiro.
Quem está pedindo
A identidade se divide em dois fatos que vêm de lugares diferentes.
A família vem da credencial do perfil do agente, verificada antes de qualquer ferramenta executar; credenciais desconhecidas ou revogadas e famílias suspensas não chegam ao serviço. Nenhuma ferramenta recebe identificador de família como argumento, então não há nada que um prompt possa convencer o modelo a trocar.
O membro vem do remetente. Nos turnos de WhatsApp, o plugin fixa sender_number no número verificado, e o serviço confere que ele é membro ativo da família do token. Um teste de integração cobre o caso principal: um membro real da família B, chamando com o token da família A, é recusado.
Abaixo da aplicação há uma segunda camada. Cada tabela de locatários contém household_id; o ORM filtra cada consulta e marca novas linhas com ele. A segurança por linha do PostgreSQL é ativada e forçada nessas tabelas, por transação. Uma sessão sem família não lê nada.
Isolamento entre famílias não é privacidade entre membros da mesma família. Recibos e documentos extraídos ficam visíveis para todos os membros ativos, como informa o FAQ público. Lembretes são a exceção no modelo atual: cada pessoa vê os que criou, os dirigidos a ela e os dirigidos a todos.
Recibos, áudios e incerteza
Áudios são transcritos na camada de conversa. A transcrição chega à mesma ferramenta create_transaction, com origin="audio" e estado de transcrição, e segue o caminho de texto. Uma transcrição vazia ou com falha pede reenvio, não um palpite.
Nos recibos, o modelo lê números, então o limite muda. Em recibos eletrônicos brasileiros, o QR code é decodificado primeiro em código. A imagem ou texto do PDF vai para um modelo multimodal, chamado com formato JSON e temperatura zero; sua saída é validada contra um esquema e tratada como candidata. Duas verificações decidem se pode virar fato. Esta é a aritmética, do módulo de recibos do domínio:
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 é a linha de recibo proposta pelo modelo, uma classe simples de dados. Um recibo sem total declarado nunca fecha, e a validação transforma isso em uma pergunta própria antes de ler a diferença.
Os itens, menos descontos, devem coincidir com o total declarado com tolerância de dois centavos. Total, estabelecimento e data precisam ter confiança de pelo menos 0,80; confiança ausente conta como baixa. Se qualquer verificação falhar, a compra vai para revisão e o membro recebe uma pergunta específica. A documentação do módulo declara a regra mais importante para mim: nada ajusta um número para fazer a conta fechar.
O recibo também é onde poderia surgir contagem dupla, por isso o modelo da compra é estrito. Ingerir um recibo cria uma compra com seus itens e exatamente um gasto pelo total. Itens nunca são gastos próprios. Se alguém digita “gastei 85 no supermercado” e envia o recibo depois, o serviço oferece vinculá-lo ao gasto existente em vez de criar outro. Testes de integração verificam que os totais não mudam após o vínculo e que dois pagamentos realmente idênticos continuam separados.

“IA interpreta, domínio decide” chega perto, mas não é exato, e a diferença importa. Mais preciso: o modelo propõe operações; contexto confiável e código determinístico decidem o que pode virar estado. O agente chama ferramentas que alteram estado: um gasto claro é registrado imediatamente, pois perguntar “quer que eu registre?” depois de cada café inviabilizaria o produto. O que não pode é pular as regras. Ferramentas destrutivas ou de configuração, como excluir uma transação ou mudar um orçamento, retornam confirmation_required primeiro. A confirmação é de uso único, expira e está vinculada à pessoa, à ferramenta e a um hash dos parâmetros exatos.
Lembretes e trabalho que acontece depois
Lembretes são o caminho assíncrono mais claro. Não há broker de mensagens: ciclos de segundo plano rodam no serviço e o PostgreSQL os coordena com advisory locks e FOR UPDATE SKIP LOCKED, para que dois workers nunca peguem o mesmo lembrete vencido.
Cada lembrete tem um destinatário que ainda deve ser elegível e uma agenda calculada no fuso da família. No vencimento, o serviço grava uma mensagem na caixa de saída com chave determinística de deduplicação. Um worker do plugin reserva linhas por um curto período e as envia, espaçando novas tentativas; após a última, marca falha e alerta o operador. Um lembrete enviado com mais de cinco minutos de atraso avisa disso; com uma hora, é marcado como perdido em vez de chegar fora de contexto.
Quando as coisas dão errado
Três falhas dizem mais sobre o projeto que o caminho feliz.
O mesmo evento chega duas vezes. A reserva de idempotência e os registros são confirmados na mesma transação; repetir o identificador não registra nada novo. O limite: protege contra identificador repetido, não contra contar o mesmo gasto duas vezes com palavras diferentes. Isso é uma questão de produto, não uma restrição.
Uma dependência fica indisponível. Uma recusa do domínio é confirmada, enquanto um erro transitório desfaz a transação e libera a reserva. Se o modelo de recibos cair ou exceder o tempo, o recibo é informado como ilegível e precisa ser reenviado, em vez de ganhar uma leitura inventada. O custo é uma nova tentativa feita pela pessoa.
O registro é salvo, mas a resposta não chega. O registro permanece e repetir a mensagem não cria outro. Mensagens da caixa de saída, como lembretes, são entregues pelo menos uma vez: perder uma confirmação pode duplicar a mensagem, nunca o registro. Enviar não é entregar, e o sistema não promete exactly-once de ponta a ponta.
Organização, testes e escolhas
O pacote finance se divide por responsabilidade: domain guarda regras puras (dinheiro, períodos, análise, validação de recibos, agendas), services os casos de uso, repositories modelos e consultas SQLAlchemy, api ferramentas MCP, REST e montagem, e extractors chamadas aos modelos. O domínio não importa framework web nem código de banco. A separação não é perfeita: compartilha classes simples com os extratores e os serviços conversam diretamente com SQLAlchemy. Prefiro dizer isso a desenhar um diagrama mais limpo que o código.
Os provedores de modelos ficam atrás do módulo extrator, configurados numa função de montagem, para que o resto do serviço nunca construa prompts nem interprete respostas de modelo. Trocar um provedor é uma mudança localizada. Não é gratuita: outro modelo pode ler recibos de outro jeito, e só as verificações de confiança e aritmética separam isso de um registro.
A suíte tem testes unitários do domínio, integração com PostgreSQL real e testes de fluxos completos. Os testes de contrato MCP descritos acima ficam na integração. As chamadas de modelo são simulações que reproduzem extrações gravadas: provam como a aplicação reage à leitura, não como o modelo lê. Ainda não há suíte de avaliação de comportamento do modelo. Este é o tipo de teste que considero mais útil num domínio financeiro, pequeno o suficiente para ler de uma vez. Foi adaptado dos testes de dinheiro: os reais são métodos com identificadores de requisito nos nomes.
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"Três decisões e o que cada uma deixa em aberto:
- Um analisador determinístico lê valores digitados. Ajuda: números exatos e testáveis em três idiomas; a mesma entrada sempre gera o mesmo registro. Não resolve expressões fora do vocabulário, que viram perguntas em vez de palpites.
- Um token por família e segurança por linha no banco. Ajuda: o modelo nunca escolhe o locatário, e um erro numa consulta ainda não lê outra família. Não resolve privacidade entre membros da mesma família, que é regra de produto, não garantia de isolamento.
- Ferramentas de escrita sem confirmação para gastos claros. Ajuda: registrar parece enviar uma mensagem. Não resolve intenção mal interpretada. Um registro errado pode ser escrito e precisa ser corrigido depois; só ações destrutivas esperam consentimento.
De volta aos R$ 85
“Gastei 85 no supermercado” termina como uma linha: 85,00, na moeda da família, em supermercado, atribuída ao membro que enviou, reservada uma vez e confirmada numa frase escrita pelo código. A conversa continuou simples. A engenharia ficou por trás, onde pode ser testada.
A pergunta à qual sempre volto é medição. A aplicação tem testes que fixam seu comportamento; o modelo tem instruções e verificações. Nada do que vem a seguir existe ainda. O próximo passo real é um conjunto versionado de avaliações, executado contra cada versão de prompt e modelo, cobrindo:
- seleção de ferramentas: o agente chamou a correta, repassando a mensagem sem alterações?
- mensagens ambíguas: perguntou em vez de adivinhar?
- expressões nos três idiomas lidos pelo analisador;
- extração de recibos, campo a campo, incluindo a confiança informada pelo modelo;
- regressões entre versões de prompt ou modelo.
Depois vem observar em produção: latência de modelos e ferramentas, ferramenta escolhida, tokens e custo, e um rastro por mensagem que conecte o turno do modelo à chamada de ferramenta e ao registro. Assim, mudar um prompt ou modelo vira algo que posso medir, em vez de apenas torcer para funcionar.
Você pode conhecer o Lukre e o que ele faz ou explorar os demais textos.
