Este documento explica o agente de IA: como uma mensagem vira uma resposta transmitida, como o modelo ganha poder real sobre os dados do usuário sem ganhar os de mais ninguém, como LLMs de camada gratuita são encadeados em um modelo confiável e o que acontece em cada ponto de falha.
O agente é um chat que age. Suas ferramentas chamam os mesmos services de domínio da API REST, então tudo que o modelo faz passa pelas mesmas checagens de posse e pela mesma validação de um clique de botão. O cliente fala com ele por Server-Sent Events, então tokens e atividade de ferramenta aparecem ao vivo.
sequenceDiagram
participant C as Cliente (web / mobile)
participant BE as AiAgentService
participant M as Advisor de memória
participant LLM as Cadeia de fallback
participant T as Ferramentas
C->>BE: POST /ai/agent/chats/{id}/stream
BE->>M: carrega janela (20 mensagens)
BE->>LLM: prompt + ferramentas + contexto
LLM-->>C: eventos de token, em stream
LLM->>T: chamada de ferramenta (validada, com checagem de posse)
T-->>C: eventos de ferramenta iniciada / concluída
BE->>BE: persiste o turno primeiro
BE-->>C: done (segmentos oficiais)
Dois detalhes desse desenho carregam a maior parte do peso de confiabilidade. Todo evento passa por uma única função de envio que registra o turno antes de escrever no socket, então um cliente morto nunca custa o transcript. E o turno é persistido antes de o evento done ser emitido; se a persistência falha, o cliente é avisado (TRANSCRIPT_PERSIST_FAILED) em vez de receber um final limpo que nunca foi salvo.
O Beyou roda em LLMs de camada gratuita, e camadas gratuitas falham: rate limits, resets de cota, soluços de provedor. A resposta é uma cadeia que se comporta como um único modelo:
| # | Provedor | Modelo padrão |
|---|---|---|
| 1 | Mistral | mistral-small-latest |
| 2 | Gemini | gemini-2.5-flash |
| 3 | GLM | glm-4.7-flash |
| 4 | NVIDIA | meta/llama-3.3-70b-instruct |
| 5 | DeepSeek | deepseek-v4-flash |
A tabela mostra os padrões de fábrica, e a escalação é configuração, não código. A produção roda dois elos, mistral,gemini, pela razão jurídica descrita abaixo. A NVIDIA tinha saído antes por um motivo comum: se mostrou lenta demais no uso real, e deixou a cadeia por uma variável de ambiente.
As regras da cadeia, cada uma com sua razão:
ai.llm-chain.blocked é uma segunda lista que vence a ordem, checada antes de qualquer outra coisa. Uma ordem é uma variável de ambiente que qualquer um pode alargar, então é aqui que deixar um provedor de fora fica registrado como decisão. Se as duas listas não deixarem nada, o FallbackChatModel se recusa a ser construído e a aplicação falha no boot em vez de servir um assistente morto.Todo outro subsistema daqui mantém os dados do usuário no banco do próprio Beyou. Responder uma mensagem do agente significa mandar ela para uma empresa que não é o Beyou, junto com o que o modelo lê no caminho até a resposta, o que faz da escalação de provedores uma decisão de proteção de dados tanto quanto de confiabilidade.
Um turno leva a mensagem, as mensagens anteriores daquela conversa, as duas notas de memória (global e por chat), o nome de exibição do usuário, e os nomes e descrições do que as ferramentas consultaram: hábitos, tarefas, metas, rotinas, categorias. Quando o modelo lê o quadro de Ideias, o texto das notas vai junto. Não leva o email, o hash da senha, nem nada que pertença a outro usuário.
O controlador do Beyou está estabelecido em Portugal, então uma requisição que chega a um provedor fora do EEE é uma transferência e precisa de uma base legal:
| Provedor | Estabelecido em | Base |
|---|---|---|
| Mistral AI | França | Dentro do EEE |
| Google Gemini | Estados Unidos | EU-US Data Privacy Framework |
| NVIDIA | Estados Unidos | EU-US Data Privacy Framework |
| Z.ai (GLM) | China | Sem decisão de adequação |
| DeepSeek | China | Sem decisão de adequação |
Por isso a produção roda order: mistral,gemini com blocked: glm,deepseek, os dois fixados no application-prod.yaml. GLM e DeepSeek seguem configurados e utilizáveis em desenvolvimento, onde os dados são inventados, e não conseguem entrar na cadeia em produção mesmo que alguém alargue a ordem. A política de privacidade publicada conta isso ao usuário, e essa é a segunda razão da blocklist existir: uma promessa impressa lá não deveria depender de alguém lembrar por que a ordem estava estreita.
Duas chamadas de saída do caderno de estudos ficam fora dessa cadeia, e portanto fora da blocklist: os embeddings (notebook.embedding.*, qualquer endpoint compatível com OpenAI, Mistral por padrão) e a busca de fontes (Tavily, ou Gemini com Google Search). Cada uma pega o host das próprias configurações, e blocked não manda em nenhuma das duas. A política de privacidade cita a Mistral para os embeddings e o Tavily ou o Gemini para a busca, então apontar qualquer uma para outro lugar é uma mudança de política antes de ser uma mudança de configuração. O texto do resumo do dia e as ferramentas de estudo do caderno passam, sim, pela cadeia.
O assistente é opcional de ponta a ponta. Nada chega a provedor nenhum para quem nunca abre ele, e o histórico de conversas e as duas notas de memória podem ser apagados dentro do app e saem na exportação de dados.
Setenta e oito ferramentas agrupadas por domínio: CRUD completo de hábitos, categorias, tarefas e metas (mais completar, aumentar, diminuir, arquivar e restaurar meta, e uma ferramenta de mover-para-debaixo que muda o pai de uma meta na árvore pelo nome, sem reenviar todos os campos), construção de rotina (criar, edições dirigidas, edição de substituição total, adicionar e remover item), rotinas em lista (criar e substituir por inteiro, as duas recebendo um array plano de itens e nenhum horário), schedules, a rotina de hoje com check e skip, as micro-tarefas do Modo Foco (listar, adicionar, marcar, fixar, apagar, reordenar) e a visão do dia, leitura e ajuste da configuração do usuário, humor diário (um escritor e um leitor), o quadro de Ideias (um escritor e um leitor), o caderno de estudos (listStudyTopics, getStudyPlanForToday e getStudyBoard o leem; addStudyNode, editStudyNode, setStudyNodeStatus, connectStudyNodes, disconnectStudyNodes, removeStudyNode, reorderStudyBoard e addStudyNotes mudam um quadro ou acrescentam anotações a uma página, que é nomeada pelo id da página ou pelo título exato, e um nome que bate com duas coisas é recusado em vez de adivinhado; generateStudyCards rascunha cartões numa página pela IA de estudo e gasta a cota de IA do caderno; showDiagram desenha um diagrama no chat sem salvar nada, e addStudyDiagram acrescenta um diagrama a uma página como bloco), os ciclos de estudo (getStudyToday, listStudyCycles, getStudyCycle e listStudySessions os leem; createStudyCycle, editStudyCycle, editStudyBlock, archiveStudyCycle, deleteStudyCycle, logStudySession e deleteStudySession escrevem), dois escritores de memória e envio de feedback. Fontes não são graváveis pelo chat; o prompt manda a pessoa ao caderno para isso. O tópico do caderno de estudos explica as ferramentas de quadro e os ciclos.
O quadro de Ideias tem um escritor e um leitor. createQuickNote cola uma nota no quadro com as palavras do próprio usuário, com uma cor opcional em qualquer caixa, e o servidor a coloca na primeira célula livre da grade, a mesma que uma nota digitada na página ocuparia. Um quadro cheio recusa, e a descrição da ferramenta pede ao modelo que sugira arquivar algumas. getQuickNotes devolve o id, o texto e a cor de cada nota do quadro, de trás para a frente, e deixa as arquivadas de fora. O prompt traça a linha que a pessoa traçaria: um pensamento cru para guardar é nota, algo que ela pretende fazer é tarefa (createUserTask). Mover, mudar a cor, arquivar, apagar e transformar uma nota em tarefa acontecem no próprio quadro, e o modelo manda o link do quadro em vez de tentar. createQuickNote avisa o domínio quickNotes, então um quadro aberto ao lado do chat mostra a nota nova, e o registro de ferramentas compartilhado desenha getQuickNotes como chip e liga o card do escritor a /ideas.
As onze ferramentas de ciclo passam pelo StudyCycleAssistant, que traduz os nomes que a pessoa usa nas requisições que as telas mandam e depois chama o StudyCycleService, então o planejador, a regra de assuntos que não se sobrepõem e o caminho do crédito são os mesmos das telas. Um assunto é qualquer página do caderno, nomeada pelo título exato ou pelo id. Um título que não bate com nada recebe de volta a lista de tópicos, e um que duas páginas dividem é recusado com uma pergunta que lista cada candidata com o tópico dela, como fazem as ferramentas de quadro. O getStudyToday responde com a fatia de hoje de cada ciclo e o listStudyCycles com a volta de cada ciclo ativo. O createStudyCycle recebe um nome, os minutos de cada dia da semana com horários de início opcionais (um dia que ficou de fora é descanso, os dias da semana vêm em qualquer caixa ou em português, e os minutos se ajustam aos passos de 5 da semana), os tópicos com importância e domínio, e opcionalmente o maior bloco e o modo padrão, e roda o mesmo planejador do editor; a descrição da ferramenta pede ao modelo que confirme os tópicos e a semana antes. O logStudySession lança estudo que a pessoa diz ter feito longe do timer, com a data de hoje a menos que ela cite um dia dos últimos 30, e os minutos entram na hora no ciclo do tópico. Essa sessão é o mesmo registro que o diálogo "Lançar sessão" do caderno grava, o relato da própria pessoa sobre estudo feito em outro lugar, e o histórico do ciclo a mostra como lançada à mão; ela nunca grava um ciclo de timer, que continua sem escrita (abaixo). O createStudyCycle e o logStudySession avisam o domínio studyCycles. O getStudyToday fica fora desse mapa mesmo fixando o conceito do primeiro bloco aberto de cada tópico, porque nada que um cliente mostra muda quando isso acontece. O getUserConfiguration lista os widgets do dashboard a partir de uma cópia dos ids do frontend, e essa cópia agora traz o studyToday, mais o constanceHeatmap e o moodWeek, que tinham ficado de fora, e o calendarToday do calendário. O calendário em si ainda não tem ferramenta no assistente.
As outras sete vieram depois das primeiras semanas de uso, quando as telas faziam coisas que o chat não fazia. Um ciclo é citado pelo nome, em qualquer caixa, ou pelo id, e só os ciclos ativos da pessoa respondem: o id do ciclo de outra pessoa ou de um ciclo arquivado recebe a mesma recusa de um nome desconhecido, com a lista dos ciclos ativos. O getStudyCycle lê um ciclo inteiro (a semana, os assuntos com os pesos, cada bloco com o número dele), e as descrições das ferramentas de edição mandam o modelo passar por ali antes. O editStudyCycle muda só o que recebe e completa o resto com o ciclo como está, porque o service recebe a configuração inteira. Os dias enviados substituem só aqueles dias, um dia enviado sem minutos ou sem horário de início mantém o que tinha, e "none" tira o horário. Os assuntos, quando enviados, são a lista nova inteira, e um assunto que fica mantém os pesos e os minutos definidos à mão, a não ser que venham pesos novos com ele. O editStudyBlock cita um bloco pelo número na fila e manda um PATCH só com os campos que o modelo passou. Um conceito transforma o bloco em GOAL, seja uma página dentro do assunto, seja NEXT para o que o roteiro disser, e TIME com conceito é recusado como contradição. O archiveStudyCycle e o deleteStudyCycle chamam as rotas das telas, e a descrição do delete pede ao modelo que confirme o nome e ofereça arquivar quando a pessoa puder querer o histórico. O listStudySessions lista as sessões lançadas à mão nos últimos 1 a 31 dias (7 por padrão), com os ids. O logStudySession agora responde com o id da sessão nova, e o deleteStudySession apaga uma sessão da própria pessoa, com os minutos saindo da volta quando essa volta ainda está correndo. Uma sessão errada se corrige apagando e lançando de novo. Pomodoros não são listados nem apagáveis pelo chat. As cinco escritas novas avisam o domínio studyCycles. O getStudyToday, o listStudyCycles, o getStudyCycle e o listStudySessions são leituras, e o registro de ferramentas compartilhado em @beyou/state mostra as quatro como chips. As duas primeiras apareciam como cards de escrita com link para o caderno.
Três ausências nessa lista são deliberadas. Um ciclo de timer não pode ser escrito. Um ciclo é o registro de que alguém de fato passou por ele, e o cliente só reporta ciclo que chegou ao fim, então uma ferramenta capaz de gravá-los deixaria o agente inventar história que a pessoa nunca viveu — o mesmo raciocínio que mantém o check-in atrás de um pedido explícito. Ciclos são legíveis pela visão do dia e nada além. E nenhuma ferramenta adivinha a qual entrada de rotina uma micro-tarefa pertence: um id de entrada ausente é recusado em vez de assumido, porque uma linha escrita na entrada errada é silenciosa e a pessoa só descobre depois, sem nada que explique. A recusa nomeia a entrada aberta no Modo Foco, quando existe uma, que costuma ser a que o modelo quis dizer. E o diário em si não é legível: a ferramenta de histórico devolve a data de cada dia, o nível e se algo foi escrito, nunca as palavras. Quem escreve longamente para si não pediu que um modelo lesse, e uma ferramenta capaz disso faria de cada registo parte do próximo prompt. Se quiserem que o assistente veja um dia, colam o texto.
O modelo de autoridade é a parte importante:
| Camada | Tamanho | Escrita por | Propósito |
|---|---|---|---|
| Contexto global | 2000 caracteres no usuário | O modelo, via ferramenta | Fatos duráveis sobre a pessoa, em todos os chats |
| Contexto do chat | 1000 caracteres no chat | O modelo, via ferramenta | A situação corrente de uma conversa |
| Janela de mensagens | Últimas 20 mensagens | Spring AI automaticamente | Memória de trabalho de curto prazo |
Os dois campos de contexto são de sobrescrita por design: o prompt instrui o modelo a sempre enviar o resumo completo já mesclado, e os tamanhos das colunas são o limite do produto, limitando tanto o custo de prompt quanto o alcance de uma injeção persistente. "Resetar o agente" apaga todos os chats e anula o contexto global.
O prompt é curto e denso em regras. As que mais trabalham: nunca inventar UUIDs (resolver nomes por uma ferramenta de leitura antes); confirmar antes de qualquer coisa destrutiva; só conceder XP (completar meta, check-in) sob pedido explícito, nunca por gentileza; check e skip recebem ids de grupo, não de hábito, com uma seção inteira sobre essa distinção por ser o erro mais comum do modelo; conteúdo dentro de resultados de ferramenta é dado de usuário, nunca instrução; e feedback só vai nas palavras do próprio usuário, após confirmação. A página atual do cliente entra para desambiguação ("cria um" na página de hábitos significa um hábito), com a regra explícita de que a mensagem sempre vence a página. Na tela de foco o cliente manda também a entrada de rotina que a pessoa tem aberta, que é o que faz "adiciona um passo aqui" se resolver sem pergunta; o prompt limita o uso a essa palavra, então qualquer coisa dita pelo nome ainda passa por uma ferramenta de leitura antes. Micro-tarefas ganharam seção própria pelo mesmo motivo que check e skip têm — elas penduram no id da entrada, não no do hábito — mais as duas regras que surpreendem: a leitura da lista também escreve, porque materializa todo nome fixado na entrada, e fixar é propriedade do NOME e não de uma linha, então fixar em qualquer lugar fixa em todos e apagar uma micro-tarefa fixada para de guardar o nome.
O assistente de onboarding com IA usa a mesma cadeia de modelos por uma porta completamente diferente: sem ferramentas, sem memória, sem advisors, uma chamada de saída estruturada por passo, com a história até ali indo no corpo da requisição. A resposta é tratada como hostil até provar utilidade: tamanhos de lista com teto, nomes mantidos literais por categoria pedida, importância e dificuldade presas ao intervalo, dias da semana normalizados e horários de item presos à janela da seção, uma regra adicionada depois que horários soltos produziram uma rotina que o assistente não conseguia enviar e o usuário não podia corrigir. Uma resposta malformada ganha uma nova tentativa com um aviso de só-JSON anexado, e depois um erro limpo de AI_UNAVAILABLE. O frontend cria entidades reais a partir das sugestões aceitas pelos endpoints REST comuns, e lê antes de escrever: cada passo pergunta o que a conta já tem e pula qualquer sugestão cujo nome já esteja lá. É isso que torna seguro o Tentar novamente do banner de erro. Um passo criava todos os hábitos e só depois todas as tarefas, sem registrar nada antes de terminar os dois, então uma tarefa que falhava deixava hábitos sem nada apontando para eles e cada toque no botão somava outra cópia do conjunto inteiro. Uma conta que aceitou três hábitos acabou carregando cinquenta e oito. Cada lote agora entra no registro do assistente assim que é salvo. A tela de erro também parou de tratar toda falha como culpa do modelo: uma chamada de sugestão que falhou mantém o texto de IA indisponível, enquanto uma escrita de entidade recusada nomeia o item, mostra o motivo traduzido do servidor e lista o que já está salvo. A tela que culpava a IA por um POST /habit recusado tinha mandado o usuário e o primeiro diagnóstico daquele incidente na direção errada.
O texto do resumo passa pela mesma cadeia e pela mesma espécie de porta que o assistente de onboarding: sem ferramentas, sem memória, sem advisors, uma chamada de saída estruturada. Duas coisas nele são diferentes de propósito.
O modelo nunca calcula nada que o usuário leia. Percentagens, dias que faltam, onde está a constância, quanto vale ainda um check atrasado: tudo isso é derivado em Java a partir das mesmas linhas que o resto da app lê, e o modelo recebe números já fechados com a instrução de não inventar nenhum. Peça a um modelo de camada gratuita para descobrir que metas estão em risco e ele erra a aritmética, inventa metas que não existem e responde de forma diferente a cada manhã para dados idênticos. Dividir assim mantém certos os números no ecrã, e faz com que uma manhã com a cadeia toda em arrefecimento continue a produzir um diálogo que funciona em vez de um vazio. O prompt é também onde as regras do próprio produto ficam ditas sem rodeios, porque são elas que um modelo erra quando o deixam adivinhar: que pular é uma escolha do usuário e nunca uma falha, e que um dia em que nada estava marcado não pode quebrar a constância.
O modelo fala do que o ecrã não consegue mostrar. A primeira versão entregava-lhe as mesmas contagens que o painel mostra e pedia que as escrevesse por extenso, e ele fazia isso todas as manhãs: "você tem 25 itens hoje e uma sequência de 93 dias" logo abaixo da linha que já dizia isso. Essas contagens também ficavam velhas em menos de um minuto, porque o usuário vai marcando os itens de ontem ao lado do texto. Por isso o bloco de fatos está dividido em dois. O que já está no ecrã vai marcado como contexto para não repetir, e os itens abertos de ontem ficam de fora, nem nome nem contagem. O resto são duas semanas de sinais calculados no DailyBriefingFactsBuilder a partir das linhas de snapshot que ele já lia: a conclusão dos últimos sete dias contra os sete anteriores, hábitos deixados abertos na maior parte dos dias em que apareceram, hábitos marcados todos os dias, os níveis de humor e a tendência deles, e o ritmo de cada meta contra uma linha reta do início ao fim. O modelo escolhe os dois ou três que importam e diz o que eles significam para hoje. O humor chega só como nível. O texto do diário nunca entra no resumo: os clientes leem a entrada de ontem direto da API de humor, a mesma regra que a ferramenta getUserMoodHistory do agente segue.
Uma chamada falhada é comunicada na resposta em vez de ser lançada. O assistente de onboarding não tem nada para mostrar sem as sugestões, por isso levanta AI_UNAVAILABLE. Um resumo sem texto continua a ser um resumo, por isso o GET /daily-briefing responde 200 aconteça o que acontecer à cadeia e os clientes mostram a própria cópia traduzida no lugar das linhas. Também não há repetição: quem chama já está a segurar um pedido contra um prazo e a alternativa é texto que ninguém vai dar pela falta, portanto repetir compraria um diálogo mais lento para evitar uma perda cosmética.
O pedido não espera que a chamada termine. Uma camada gratuita pode levar meio minuto, e o diálogo abre com dados que o painel já tem, por isso só a área do texto espera: cerca de oito segundos, findos os quais o pedido devolve PENDING e a chamada fica a correr para se guardar sozinha. O diálogo então consulta GET /daily-briefing/narrative, uma leitura por chave primária que nunca inicia uma chamada, em cerca de setenta segundos e dez pedidos, e cai na linha alternativa quando o tempo acaba. Essa consulta chegou tarde. O primeiro desenho contava com a abertura seguinte do mesmo dia para encontrar o texto, mas o diálogo abre uma vez por dia, e durante semanas todo texto em produção ficou READY na linha enquanto o ecrã mostrava um esqueleto. Chamadas concorrentes juntam-se a um único future em curso, com chave na linha do resumo, para que um usuário com a app aberta em dois aparelhos não compre o mesmo parágrafo duas vezes.
A geração acontece a pedido e nunca na passagem noturna. A passagem de fecho do dia percorre todas as contas que existem; uma chamada ao modelo lá dentro gastaria a quota de quem abre a app com quem não abre, e escreveria a metade de "hoje" às 02:00, antes de ter acontecido seja o que for hoje.
A IA do caderno de estudos (o chat e as saídas da sala de estudo, o rascunho de roadmap, as sugestões de nós, o "explicar" e os flashcards da IA) é uma terceira porta para a mesma cadeia, construída como a do onboarding: o NotebookLlm faz uma chamada estruturada via ChatClient.entity(), tenta de novo uma vez pedindo JSON válido e depois levanta AI_UNAVAILABLE. Sem ferramentas, sem streaming, sem memória. Quem chama monta o contexto e manda tudo a cada vez. O prompt de sistema é prompts/notebookTutor.st, e cada mensagem começa com um modo. TUTOR, o chat da sala de estudo, responde primeiro a partir de trechos numerados (as anotações da pessoa, depois os trechos buscados nas fontes dela), cita-os como [n] e segue com o próprio conhecimento onde eles param, avisando. GROUNDED, as saídas do estúdio e os flashcards da IA, responde só a partir dos trechos. SUPPORTED explica com o conhecimento do próprio modelo e cita onde os trechos concordam. PLANNING não tem trechos. O StudyContextBuilder é dono da numeração e descarta qualquer citação que o modelo invente. Uma página sem nada para ler responde NOTEBOOK_NOTHING_TO_STUDY antes de chamar o modelo. Essas chamadas gastam um balde próprio, notebook-ai, de 60 por hora, para que estudar nunca coma os 30 do assistente. O tópico do caderno de estudos cobre a busca de trechos e o resto.
SSE não anda sobre o cliente axios (XHR bufferiza), então um helper de stream dedicado embrulha o fetch com configuração própria: a URL base e o header de auth vivo emprestados do app, a mesma função compartilhada de refresh de token (para um 401 no stream não correr contra um segundo refresh) e, no mobile, o fetch do Expo, porque o fetch global do React Native bufferiza corpos inteiros. O parser bufferiza entre chunks, tolera heartbeats, valida a forma de cada evento na fronteira e decodifica UTF-8 de forma segura para stream, então um caractere multi-byte dividido entre chunks não corrompe.
O widget web monta uma vez dentro do shell protegido, carrega o painel de forma lazy no primeiro clique e fica escondido até o tutorial terminar. Um envio novo aborta o stream anterior, desmontar aborta tudo (o que faz o logout cancelar a chamada de LLM também), e respostas de um chat do qual você saiu são descartadas no cliente, já que o servidor as persistiu de qualquer forma.
| Falha | Comportamento |
|---|---|
| Todos os elos da cadeia falham | Métrica incrementada, a última exceção vira um evento de erro; o cliente desfaz a bolha otimista e devolve o texto digitado ao compositor |
| Terceiro stream simultâneo | Um emissor de vida curta responde TOO_MANY_STREAMS sem abrir chamada de LLM (teto: 2 por usuário) |
| Persistência do transcript falha | O cliente recebe TRANSCRIPT_PERSIST_FAILED em vez de um done falso |
| Rate limit | 30 chamadas ao modelo por hora por usuário, um balde para todo POST em um chat (o onboarding tem seus próprios 30, separados, e o caderno de estudos seus próprios 60) |
| Cliente morto em pausa silenciosa | O heartbeat de 15 segundos é o detector; sua falha derruba o stream e libera a vaga |