Skip to content

O assistente (chat)

O assistente do Futuros não é um chatbot que "sabe coisas". É uma camada de raciocínio sobre o corpus verificado: todo número que ele enuncia vem de um resultado de ferramenta, vinculado à sua citação, nunca da memória do modelo. Essa disciplina é o que o distingue de um assistente genérico — e o que o torna utilizável por um regulador ou um investidor. Está disponível em toda a plataforma (o lançador vive no layout raiz, em qualquer página); superfícies como /sala lhe passam perguntas com contexto por meio dos seus cartões de "aprofundar no chat". Ele encarna o Pilar 2 — Modelo Soberano: o fosso competitivo não são os pesos base, é o corpus com proveniência e a disciplina de ancoragem.

O padrão de raciocínio

O assistente não dispara ferramentas na primeira leitura da pergunta. A ordem é explícita: pensar a pergunta → olhar o corpus → pesquisar online se for preciso → perguntar se a pergunta é ambígua → raciocinar → então responder. A frase que motivou este desenho, na chamada de onde ele veio, foi "tem que parar um minuto e raciocinar": a falha que corrige não é responder errado, é responder rápido com o primeiro número que a recuperação devolveu.

Essa ordem também é o que faz as seções seguintes se sustentarem: a pergunta de especificação chega antes de gastar o orçamento de ferramentas, e a relevância estrita se aplica ao escolher qual número do corpus merece entrar na resposta, não ao diagramá-la.

Profundidade da resposta: completa no primeiro turno

São três níveis, e o servido por padrão é o completo:

modeO que éRodadas de ferramentasTokens de saída
fullO modo padrão. A resposta completa no primeiro turno: veredito, indicadores, marco regulatório, leitura de financiamento, precedente, quem move o indicador e o fechamento de melhoria. Antes era o que o botão Expand comprava.68.192
briefO brief executivo curto. Continua disponível na API se o cliente enviar mode:"brief"; a interface do produto já não o serve nem oferece um controle para ampliar.64.096
briefingDeep Briefing: entregável longo pronto para a diretoria, com sete seções fixas.916.384

full é o modo padrão. expand e o valor retirado normal são aceitos como alias de full. A interface não oferece um botão Expand / Ampliar: a primeira resposta já é a versão completa, nos três idiomas do produto (ES / EN / PT). Um brief explícito e substantivo ainda pode levar expandable: true no fio, para que um cliente de API reenvie o mesmo turno com mode:"full". Um brief que foi cortado nunca é oferecido como expansível: primeiro é declarado truncado.

O que muda entre brief e full não são as regras de ancoragem, idênticas nos três modos. O que muda é quantos ingredientes são exigidos. O verificador de completude (server/chat/completeness.ts) aplica ENFORCED_BY_MODE: um brief exige apenas o fechamento de melhoria, enquanto full e briefing exigem também o marco regulatório e a leitura de financiamento. A avaliação não muda com o modo — o relatório continua informando o estado real de cada ingrediente, para que a telemetria siga comparável entre modos; só a lista exigível se estreita. Um brief é mais curto, nunca menos citado: a regra interna é que um brief sem números é uma falha, e o fechamento de "como melhorar" nunca é removido, porque é o ponto. Ser mais exaustivo não autoriza inventar taxas, prazos, tickets nem contagens.

Quando o brief serve uma decisão de financiamento

O bloco brief dizia explicitamente para deixar de fora a leitura de financiamento. Para um ministro que revisa um eixo, isso está correto. Para um oficial de investimento do BID ou da CAF, o dinheiro é a pergunta, e retirá-lo produziu uma resposta de 1.357 caracteres com zero valores, zero anos e zero fontes diante de um prompt que pedia exatamente essas três coisas.

detectDfiIntent (server/chat/dfi-intent.ts) resolve isso de forma determinista: sem chamada ao modelo, sem latência, mesma pergunta e mesmo veredicto sempre. Dispara com um único sinal — uma contraparte de banca de desenvolvimento (BID/IDB/CAF), capex ou opex, ticket, tenor, bancabilidade, comitê de investimento, fechamento financeiro, ou um montante em moeda — e devolve quais sinais coincidiram, para que uma falha seja diagnosticável em vez de opaca. Os acrônimos são comparados diferenciando maiúsculas de propósito: um \bbid\b insensível ao caso dispararia com o verbo inglês bid e com bidding em qualquer pergunta de compras públicas.

No modo padrão (full) a leitura de financiamento já é um ingrediente exigido, então uma pergunta DFI a recebe no primeiro turno. Quando o detector dispara, e só em brief (full e briefing já exigem a leitura de financiamento), um bloco anexado inverte exatamente uma linha: a leitura de dinheiro fica. Todo o resto da lista de exclusões segue fora. O alvo sobe para 180-260 palavras, e o bloco insiste que uma faixa de ticket é o que o emissor pode emprestar, nunca o que foi comprometido a este piloto. Não custa uma rodada extra de recuperação: recommend_action já devolve capex, opex, instrumentos ajustados por ticket e os tomadores de decisão rankeados.

Até três perguntas de especificação

Diante de uma pergunta genuinamente ambígua ou complexa, o assistente pode devolver até três perguntas antes de responder (MAX_QUESTIONS = 3, com no máximo 4 opções cada). Nunca numa consulta simples: perguntar de volta diante de um dado direto é atrito, não rigor.

Não são só perguntas esclarecedoras, mas de especificação: servem para quem pergunta delimitar o que procura. Por isso uma pergunta pode legitimamente não trazer opções — é texto livre. Quando nenhuma das perguntas traz opções, o bloco futuros:clarify é substituído por prosa, para que um cliente antigo mostre as perguntas como texto em vez de um balão vazio. O campo questions traz sempre a lista ordenada completa; question/options espelham a primeira pergunta com opções, para que um cliente que só entende a forma antiga siga desenhando seus chips.

Regras de ancoragem

As regras são estritas e explícitas:

  • Cada número sai de um resultado de ferramenta, marcado com um token [[cite:id]]. Sem ferramenta, não há número.
  • Sem respostas de memória. O modelo não preenche números do seu treinamento; se não os obteve de uma ferramenta, diz que não os tem.
  • As contradições são mostradas. Se duas fontes divergem, o assistente aponta isso em vez de fazer uma média em silêncio.
  • O vintage é marcado. Um número antigo é apresentado com o seu ano, não como se fosse atual.
  • Resposta primeiro (BLUF). A primeira frase traz o veredito e o número citado mais importante — não um preâmbulo.
  • Pergunta antes de adivinhar. Diante de uma pergunta genuinamente ambígua em escopo, o assistente emite um bloco futuros:clarify e para, em vez de supor em silêncio (até três perguntas — ver acima).
  • Relevância estrita. Um número do corpus só entra se responde a esta pergunta. O caso que fixou a regra: perguntado sobre soberania de computação no México, "desenvolvedores ativos do GitHub" é o maior número da célula de inovação e não é proxy de nada — o relevante era a rede elétrica. A evidência é ordenada por pertinência à pergunta, não por tamanho nem pela ordem da célula.
  • As pessoas são citadas. Quando a resposta toca em quem decide algo, o corpus de pessoas-chave é citado como qualquer outra fonte, com seu persona-…-src-….
  • Responde no idioma da pergunta — qualquer um. Não só ES/EN/PT: neerlandês para o Suriname, francês ou kreyòl para o Haiti, inglês caribenho.
  • Não inventa cobertura. O produto vivo é 25 países + a linha LATAM, 10 eixos, 260 células citadas. GDB (Oxford Insights) e GIRAI não estão ingeridos; ILIA não é um índice vivo do corpus. Nunca se afirma ~9.000 fontes. O INE.Stat do Chile está vivo só para desemprego e informalidade. Uma resposta exaustiva nomeia o buraco quando a fonte não está no catálogo.

Cobertura viva versus o prometido

Ser exaustivo não autoriza ampliar o produto. Se uma sala pergunta por um índice ou um órgão que o corpus não ingeriu, a primeira frase diz isso, e depois vem o número citado mais próximo, rotulado como adjacente. Rotas vivas para as perguntas recorrentes:

  • CEPAL versus INEC / INDEC / DANE: as duas séries da célula, citadas, sem média; get_discoveries / get_regional_pulse para contradições documentadas (lista curada, não exaustiva).
  • Desnutrição Equador: cepal_desnutricion em saúde e coesão; vintage citado.
  • Donos de ES/pesquisa no Equador: pessoas + regulação + pilotos (CEDIA; SENESCYT absorvida no viceministério, Decreto 100 / 2025). O INEC é o instituto de estatística, não o dono da ES.
  • Prontidão em IA versus ILIA/GDB, e quem tem estratégia: ai-governance / national_ai_strategy.status. GDB, GIRAI e ILIA não se citam como dado Futuros.
  • Energia do corpus de treino (Ember/IRENA): séries Ember e IRENA vivas em meio ambiente + frontier-country.
  • Cobertura ODS e lacunas de espaço cívico: get_data_health (coverage_gaps); civic-pulse (GDELT, sinal direcional); civic-attention (21/25); resiliência democrática; V-Dem nas células. O espaço cívico comparável dos 25 países, quando ingerido, se cita como civicus_monitor_score (CIVICUS Monitor, ficha de país) — não se inventa um segundo índice.
  • Fiscal Argentina / BOOST: boost_spending_* nas células ARG (vintage 2022, envelhecido) + markets-screener.
  • Chile INE.Stat saúde/inovação: não ingerido; a célula de saúde ou inovação se cita pelas fontes reais (OMS, Banco Mundial, CEPAL).

A suíte de ferramentas

O assistente age por meio de ferramentas determinísticas, com um orçamento de 6 rodadas de ferramentas por resposta (9 no modo briefing). Em linguagem simples:

FerramentaO que faz
resolve_metricTraduz o que o usuário pede para um indicador concreto do corpus.
get_parameter_dataRecupera o valor de um indicador por país, com a sua citação.
search_corpus / get_datasetBusca semântica sobre o corpus e acesso direto a um dataset catalogado.
compare_countriesCompara países sobre um indicador.
get_news / get_regional_pulseNotícias citadas e pulso regional.
computeCálculo determinístico. Inclui previsões (marcadas como projeção, não como fato) e explain_change (que declara associação, não causalidade).
get_data_healthEstado de saúde e frescor do dado.
get_discoveriesAchados pré-processados (movimentos, anomalias).
find_pilots / get_pilotPilotos financiáveis por critério e ficha de piloto.
recommend_actionEncadeia piloto → instrumento de financiamento → tomador de decisão: do dado à ação. Inclui policy_screen (Bhutan/NZ/Humphrey; rúbrica em docs/plataforma/cribado-politicas.md; anulação humana para exportar).
web_search / fetch_url / perplexity_searchBusca externa, último recurso e com selo visível.

A chave do design: adicionar um dataset (server/chat/datasets.ts) o torna automaticamente catalogado, recuperável e pesquisável — sem escrever código de ferramenta novo. A recuperação combina embeddings Voyage (voyage-3-lite, 512-dim) sobre search-index.json com um fallback BM25/keyword e reranking.

Modo briefing. Com mode: "briefing" o assistente produz um entregável longo pronto para a diretoria, com sete seções fixas (resumo executivo, situação atual, tendência, comparação com pares, qualidade e vigência dos dados, contradições e riscos, "o que acompanhar") e o orçamento maior da tabela acima. As mesmas regras de ancoragem se aplicam dentro do briefing.

Blocos que a resposta pode emitir

Além da prosa citada, uma resposta pode incluir blocos que a interface renderiza como componentes. Todos compartilham a mesma regra: só mostram valores que uma ferramenta devolveu, e cada ponto conserva sua citação.

BlocoO que renderiza
futuros:vizGráfico ou matriz (5 tipos). No máximo um por resposta normal; um briefing pode usar um por seção. Num brief, prefere-se nenhum.
futuros:mapMapa de pontos por país. É a superfície onde a concorrência nos ganhava, e a que torna legível um piloto multi-país: cada dado mapeado leva seu ISO3, seu valor e sua citação, e os chips de citação são desenhados sob o mapa.
futuros:pilotsPilotos alinhados ao problema real de quem pergunta (ver relevância estrita).
futuros:personasPessoas do corpus citadas como contraparte de decisão.
futuros:newsNotícias e pulso social, marcados explicitamente como "últimas notícias, não dados 100% verificados" — nunca com a citação azul de um indicador.
futuros:lawsO snapshot de lei e artigos (ver abaixo).

Corpus verificado frente a fonte externa: azul e dourado

A distinção de proveniência é visual e tem legenda:

  • Azul — dentro do corpus Futuros: verificado, com proveniência e deep link.
  • Dourado — fora do corpus: uma fonte externa que uma busca web devolveu naquele turno. Não é verificada por nós, e isso é dito.

O dourado leva ainda o glifo junto à citação, e a paleta mantém três tons separados (--gold para texto, --gold-line para o traço, --gold-soft para o fundo) justamente porque em superfície clara o âmbar de alerta e o dourado de proveniência são vizinhos e não devem se confundir. A linguagem é suavizada ("dado não verificado"), mas a distinção nunca é removida: é ela que sustenta a promessa de proveniência.

Regulação: a lei e seus artigos, não "51 instrumentos"

Diante de uma pergunta regulatória, a resposta nomeia a lei e os artigos que regulam o tema e oferece um snapshot pop-up do trecho relevante, em vez de reportar um agregado ("51 instrumentos") e mandar o leitor a um portal oficial cru. O bloco futuros:laws é construído a partir do registro de citação do próprio instrumento — um id de citação regulatória é o id do instrumento e começa sempre pelo ISO3 do país em maiúsculas, o que torna a partição exata e não heurística.

Os datasets regulation-instruments e regulation-provisions contam como evidência regulatória, mas não como dado de núcleo: são consultas pontuais, de modo que uma resposta puramente jurídica nunca se gradua como "substantiva" só por tê-las consultado.

Quando o modelo nomeia uma lei em prosa ou numa tabela mas omite o token [[cite:id]], o renderizador sublinha o nome se coincidir com um alias de um instrumento já presente na carga de citações desse turno — acrônimos (LGPD), referências oficiais (Ley N° 26.743), títulos completos. Nunca inventa links a instrumentos que a resposta não ancorou: se a lei não está no payload de citações, o nome fica como texto simples. A regra 9 do prompt do sistema exige [[cite:id]] em cada lei nomeada; este fallback é honestidade de interface para o caso em que o modelo cumpre o grounding mas esquece o token inline.

Ticket automático de lacuna de dados

Quando uma ferramenta do corpus volta vazia, o servidor abre um ticket estruturado em vez de deixar o sinal morrer com a requisição, e o publica em /vacios via GET /api/backlog. É o mecanismo que converte um unknown unknown num known unknown, ordenado por demanda real.

Duas precisões que importam:

  • Uma falha de rede ou um erro de ferramenta nunca abre um ticket. O sinal é returnedEmpty, que registra apenas vazios reais do corpus; uma indisponibilidade não se confunde com uma lacuna. search_corpus também não conta: uma formulação ruim parece idêntica a um buraco.
  • O ticket não guarda a pergunta. Leva o país, o eixo e as ferramentas que voltaram vazias — todo vocabulário da plataforma. O endpoint é público e sem autenticação, então qualquer coisa que o ticket levasse seria algo que quem perguntou publicou sem pretender. O título de cada linha em /vacios é composto a partir do país e do eixo. (O esquema também admite missing_indicators, mas o handler nunca o passa, então o campo vem sempre ausente: documentado como o que é, não como o que promete.) A regra completa vive no cabeçalho de server/backlog/gaps.ts; o endpoint está documentado na API pública.

A lacuna chega ao modelo, não só ao backlog

Registrar o buraco não serve de nada se a resposta o encobre. O vazio chegava a três destinos — a telemetria, o backlog público e o chip do cliente — e nunca ao único consumidor que decide o que se escreve. No cabo, uma consulta sem resultados eram 68 bytes de {"dataset":"financing","total_matches":0,"records":[]}: nenhuma prosa, nenhuma proibição. Um array vazio se lê como «aqui não há nada a acrescentar», não como «você não tem com que escrever isto». Uma pergunta de financiamento registrou financing → 0 registros duas vezes, abriu seu ticket, e imprimiu igualmente uma tabela de seis linhas com prazos, taxas e graus de concessionalidade.

Agora cada resultado vazio viaja ao modelo com empty: true e um corpus_empty_note explícito, em duas variantes, porque «vazio» significa duas coisas distintas: uma ausência real (uma ferramenta do corpus perguntada por uma célula concreta que não existe) proíbe produzir qualquer tabela, linha, cifra, nome, data, taxa, prazo, grau ou montante para esse dataset; uma ausência não provada (search_corpus, resolve_metric, as ferramentas web, que podem voltar vazias pela formulação) diz exatamente isso. As duas compartilham UMA definição de vazio (isEmptyResult) e UMA lista de quais ferramentas significam ausência (NOT_A_GAP, agora em server/chat/empty-note.ts, importada por gaps.ts) — que pudessem discordar sobre o significado de «vazio» era o defeito de fundo.

E uma regra que corre contra a intuição: o modelo já não pode afirmar que a lacuna ficou registrada. Não pode observá-lo — o ticket é escrito depois que a resposta termina, e o cliente mostra o link do backlog por conta própria — então afirmá-lo era uma declaração falsa sobre o comportamento da própria plataforma. Uma resposta em produção garantiu que «Futuros registra automaticamente estas lacunas no backlog público» enquanto a telemetria mostrava zero tickets. Descrever o buraco, sim; prometer seu registro, não.

Um cargo sobrevive a uma troca de gabinete; um nome não

O ativo diferencial do corpus é saber a quem ligar, e era onde mais falhava: em dez perguntas de banca de desenvolvimento apareceram titulares errados em seis, sempre carregando peso. Um ministro que havia deixado o cargo dez dias antes seguia com «readiness 90/100, sentiment favorable» — um número de confiança que converte um registro datado numa afirmação sobre o presente.

Três mudanças, nenhuma exigindo assar de novo os 1.346 perfis. former é respeitado: 47 entradas do índice marcam um ex-titular e nenhum consumidor as lia; filtrá-las impede que 41 células país×eixo ofereçam alguém que já saiu (em primeiro lugar em 18 delas), e é grátis — de 278 células com resultados, nenhuma fica vazia nem cai abaixo de três candidatos. Cada linha leva sua data: não existe campo verified_as_of no corpus, então o selo deriva de last_updated (presente em 1.346 de 1.346 perfis, já carregado pelo ranking) como verified_as_of, verified_days_ago e currency (recent ≤30 dias, aging ≤90, unverified além). O perfil mediano tem ~66 dias, razão pela qual recent não pode significar 90. Os números de confiança são condicionados à vigência: quando currency não é recent, readiness e relationship_status voltam null com um motivo readiness_withheld — justificado pelos dados, já que readiness vale exatamente 55 em 1.281 de 1.346 linhas porque é o valor padrão do stub, não uma medição.

O prompt fecha o círculo: quando a linha não é recent, nomeia-se o cargo e a instituição e só depois, opcionalmente, o último titular registrado com sua data. Os contatos embutidos nos pilotos recebem o mesmo tratamento — só 155 de 336 trazem persona_id, e 172 dos 297 nomes distintos não existem no diretório, então um nome só é emitido se seu id resolve; caso contrário sobrevive o cargo e desaparece a pessoa.

O corpus não tem preço

O catálogo de financiamento guarda emissor, tipo, faixa de ticket, texto de elegibilidade e URL de solicitação. Não guarda taxa, spread, prazo, carência, grau de concessionalidade nem classificação de crédito — verificado campo a campo nos 64 instrumentos. Eram exatamente as cifras que uma resposta inventou: dinheiro soberano do BID a «1,5-2,5%», CAF descrita como «AAA», e um mecanismo pelo qual um wrap da MIGA comprimia 40-60 pontos-base de um spread fixado por diretoria. São as cifras com maior probabilidade de acabar num term sheet.

Duas defesas. Uma regra de prompt: nomeie o instrumento e seu emissor, dê sua faixa de ticket com citação, aponte para application_url, e diga com clareza que Futuros não leva preços nem condições; concessional-loan é uma etiqueta de catálogo, não um grau verificado de concessionalidade, e o financing[] de um piloto é um reparto de desenho, nunca cofinanciamento comprometido. E um detector determinista: detectFinancialTerms marca a coocorrência, numa mesma unidade de sentido, de vocabulário de crédito e um token de condição (porcentagem, pontos-base, benchmark + margem, duração, grau de rating). É advertência, não reescrita: baixa o chip de confiança e viaja no evento verify como fabricated_terms. As tabelas são lidas como cabeçalho de coluna + rótulo de linha + célula, porque numa tabela de condições a palavra vive no cabeçalho e o número na célula. O que não marca importa igual: a taxa de desconto (0,07-0,14 nos 67 pilotos), o horizonte em anos, a contrapartida, o desembolso, o TRL e todo o painel de acessibilidade são dados reais do corpus, e um falso positivo ali desacreditaria uma resposta correta.

Registro privado de perguntas

O ticket descreve a forma do vazio, não a pergunta — a restrição correta para uma superfície pública, mas que deixava sem registro o sinal mais útil para melhorar a recuperação: o que se pergunta de fato. Por isso existe um segundo armazenamento, privado e separado por construção:

  • Outra chave. chat:questions:v1, nunca backlog:gaps:v1. Nada que leia o backlog pode alcançá-lo, nem por acidente, e os dois são limpos separadamente.
  • O caminho público de leitura não muda. sanitizeTicket continua reconstruindo cada ticket campo a campo, então uma pergunta não pode sair por GET /api/backlog nem por /vacios. Isto acrescenta um armazenamento; não retira nenhuma garantia.
  • A leitura privada falha fechada. GET /api/questions exige Authorization: Bearer $QUESTION_LOG_TOKEN e recusa toda requisição quando o token não está configurado (a postura de api/alerts-digest.ts). Um deploy que esqueça de defini-lo não serve nada, em vez de publicar o registro.
  • O cliente continua só forma; o servidor leva o texto. O evento chat_query do PostHog (e o de funil chat_submit) leva comprimento, idioma, rota, modo e um generation_id — nunca o texto. Esse id une as duas metades ao registro privado de perguntas e aos eventos de servidor chat_generation / $ai_generation, que incluem a pergunta (e um recorte da resposta) para traços LLM. As palavras não viajam no pipeline de cliente.

Cada linha guarda pergunta, data, idioma, país, eixo, rota, modo e generation_id. A escrita é best-effort e dispara antes de abrir o stream: nunca bloqueia nem atrasa a resposta, e uma falha do armazenamento custa um dado, nunca um turno.

Anatomia de uma resposta (o protocolo SSE)

Uma resposta é um stream SSE de eventos nomeados (server/chat/handler.ts produz; src/lib/chat-client.ts faz o parse manual do formato de transmissão, porque EventSource só faz GET). O laço: transmitir um turno do modelo → se ele pediu ferramentas, executá-las contra o corpus estático, anexar os resultados, transmitir o turno seguinte → repetir até end_turn. O contexto volátil (país, parâmetro, reply_language) é anexado ao último turno de usuário, nunca ao system prompt — assim o prefixo em cache (cache_control: ephemeral) permanece byte-estável entre requests. A requisição de entrada é aparada antes de tocar o modelo: últimos 12 turnos, 2.000 caracteres por mensagem, o último turno deve ser de usuário.

Catálogo de eventos, com o que a interface faz com cada um (src/store/chatStore.ts):

EventoPayloadO que a interface faz
provider{id, sovereign, inRegion?, model}Fixa o selo de provedor / modo soberano do turno. É o primeiro a ser emitido; é reemitido a cada failover.
text{d} — delta incrementalAnexa o delta. O primeiro token após um apagamento descarta a "sombra" e inicia o texto do zero.
reset_text{}Nunca apaga para o preto: move o texto já emitido para uma camada sombra esmaecida — o usuário não vê resposta → pontinhos → resposta.
tool{name, detail, found?}Adiciona um passo à trilha de "mostrar o trabalho". found deriva do resultado real, depois de executar.
citations{items}Registra os chips novos (dedupe por id) antes do próximo turno de texto, para que [[cite:id]] resolva enquanto a prosa flui.
revising{unverified_ids}Sombra + chip "verificando…" + marca o turno como autocorrigido; limpa verify/followups anteriores.
verify{claims, traceable, sources, unverified_ids, uncited_figures, contradictions, values_confirmed, values_checked}Alimenta o selo de confiança. Só é emitido se houver algo a reportar.
followups{items}Mostra as 3–4 perguntas de acompanhamento sugeridas.
error{code, message, reason?, status?}É anexado inline sem apagar o parcial. reason (auth/credits/unavailable, derivado apenas do status HTTP, sem segredos) decide se "tentar de novo" faz sentido.
done{usage, truncated?}Fecha o turno. Piso final: se ficou vazio mas há sombra, ela é restaurada — um turno nunca termina em branco se existiu texto anterior.

O cliente adiciona uma sentinela própria: um EOF do stream sem evento terminal done/error é um stream cortado — a resposta parcial é marcada como truncada, jamais como completa, e jamais é roteada para erro (isso apagaria o parcial).

Verificação de fidelidade

Antes de entregar uma resposta, o assistente audita a si mesmo com uma verificação determinística de ancoragem — sem nenhuma chamada extra ao modelo (server/chat/faithfulness.ts):

  1. Extração de afirmações — determinística, sobre os tokens [[cite:id]], incluindo os pontos de dados dos blocos de visualização e as células de tabelas.
  2. Verificação de ancoragem — cada id citado deve ser um id que uma ferramenta realmente devolveu nesta conversa (verifyGrounding). O juiz é o conjunto returnedIds — que inclui ids embutidos nos payloads mesmo que nunca tenham sido emitidos como chips — não o registro de citações: julgar contra o registro dispararia correções sobre respostas honestas. Um id que não veio de nenhuma ferramenta é uma citação inventada, e é tratado como tal.
  3. Confirmação de valores — não basta que a citação resolva: o número que o modelo escreveu deve coincidir com o valor da fonte (confirmValues), colhido dos próprios resultados de ferramenta. A mecânica é conservadora: toma-se o número mais próximo da citação (pulando anos 1900–2099), compara-se na precisão com que foi escrito (75,9 → uma casa decimal), e os saltos de escala ×/÷100 só são aceitos se o número foi escrito como porcentagem ou se a fonte é uma fração (|fonte| < 1) — um curinga incondicional daria aval a um erro de duas ordens de magnitude. O sinal é somente-positivo: um número não parseável não confirma nem acusa.
  4. Autocorreção de uma passada — se a resposta final cita ids não ancorados, o handler emite um evento revising e gasta uma rodada extra corrigindo. Um salvamento garante que a resposta nunca fica em branco nem pior que o rascunho: a revisão é descartada como degenerada se sai vazia, se é um reconhecimento (a família "Você tem razão…", ou uma abertura de desculpa combinada com um verbo de refazer — a desculpa sozinha nunca dispara, porque também abre abstenções honestas), ou se não reduz o conjunto de ids não ancorados (o sinal semântico manda sobre o textual). Nesse caso restaura-se o rascunho anterior com os tokens de citação inválidos removidos — exatamente a reescrita que foi pedida. O salvamento roda depois do laço, não só em um end_turn limpo: o relógio pode expirar antes ou no meio da revisão, e ainda assim o rascunho completo deve ser restaurado.

Também existe um juiz LLM que classifica afirmações como SUPPORTED / PARTIAL / UNSUPPORTED, mas ele é avaliação de fidelidade offline (scripts/chat-faithfulness.ts), não um passo do runtime — as duas coisas não se confundem.

O resultado viaja até a interface em um evento verify com contagens e listas de ids — afirmações totais, quantas são rastreáveis, fontes verificadas distintas, ids não verificados, números sem citação — mais as contradições detectadas e a confirmação de valores. Só é emitido quando há algo a reportar. Daí sai o selo de confiança que o usuário vê: uma leitura honesta de quão ancorada ficou a resposta, não um enfeite.

Também há defesa contra injeção de prompt: quarantine.ts isola o conteúdo não confiável trazido pelas ferramentas para que ele não reescreva as instruções do assistente.

Visualizações ancoradas

O assistente pode emitir no máximo um bloco JSON futuros:viz por resposta normal (um briefing pode usar um por seção) — gráfico ou matriz — que a interface renderiza de forma interativa. Seus pontos de dados carregam ids de citação e passam pela mesma verificação de ancoragem que a prosa: um gráfico que cita um id não devolvido por ferramentas dispara a mesma passada de correção.

Limites honestos

A resposta roda sob orçamentos explícitos: ~100 s de relógio de parede (dentro do maxDuration de 120 s da função, com ~20 s reservados para verificação e acompanhamentos), 8.192 tokens de saída (16.384 em briefing), 12 turnos de histórico e 2.000 caracteres por mensagem. O relógio é compartilhado entre rodadas: cada turno do modelo roda com um sinal de aborto atrelado ao orçamento restante (e à desconexão do cliente — fechar a aba aborta o turno em voo, as ferramentas e os acompanhamentos, sem faturar tokens que ninguém lê). Ele governa também as ferramentas, não só o modelo: as externas carregam timeouts fixos de 12–20 s em série, então com o orçamento esgotado as restantes se degradam para um resultado is_error a partir do qual o modelo aterrissa com o que já tem — o protocolo exige um resultado por tool_use, e isso evita um kill silencioso da plataforma sem done terminal. Se o orçamento se esgota, o corte é declarado: o evento final leva done.truncated, a interface marca a resposta como "truncada" em vez de apresentar um fragmento como completo, e um guardião (stripDanglingFence) elimina qualquer bloco de visualização emitido pela metade: um número ímpar de marcadores de fence significa que o último bloco nunca fechou — corta-se dali, para que o JSON truncado nem seja renderizado quebrado nem infle a contagem de números sem citação.

A cadeia de provedores e o modo soberano

server/chat/provider.ts constrói uma cadeia de provedores com failover transparente (providerChain()). A precedência primária continua sendo soberano → OpenRouter → Anthropic — mas cada provedor configurado é anexado também como reserva: se o primário falha em transporte (timeout de conexão, 4xx de auth ou créditos, 5xx após as retentativas do próprio SDK), a rodada é retentada contra o seguinte sem consumir orçamento de recuperação (round -= 1), re-resolvendo o id de modelo pelo namespace do provedor (anthropic/claude-sonnet-4.6 no OpenRouter vs claude-sonnet-4-6 direto). O failover tem uma comporta estrita: só dispara se a rodada ainda não emitiu texto visível — com tokens já na tela, um segundo provedor retransmitindo duplicaria a resposta, então nunca se troca de host no meio de um texto. Os acompanhamentos também rodam no provedor que realmente respondeu — depois de um failover o primário está morto. O default é Sonnet para as respostas; as perguntas de acompanhamento rodam em Haiku (claude-haiku-4-5), mais barato.

A função usingSovereign() verifica se SOVEREIGN_INFERENCE_URL / _KEY estão configuradas; se estiverem, a inferência roda em um gateway na região (LiteLLM/vLLM) sobre pesos abertos, e o corpus e a consulta nunca saem do host. Essa é a costura do Pilar 2: a soberania é um toggle de provedor, não uma reescrita, e a qualidade de fronteira segue sendo o default até alcançar paridade nas avaliações.

O provedor ativo é transmitido como evento SSE provider em toda resposta — a primeira coisa a ser emitida, e reemitida a cada failover, de modo que o selo nomeia o host que realmente respondeu. O selo de "modo soberano" na interface aparece só quando a inferência foi soberana: o usuário sabe, na própria conversa, onde vive a inferência que está lendo.

Busca externa como último recurso

A web aberta só é usada quando o corpus não basta, e sempre com o selo de externa (citações web-). Um fato da web nunca é apresentado no mesmo nível que um dado do corpus verificado — a separação é visível, como explicado em Proveniência e citações.

E é opcional: com o escopo somente-corpus (corpusOnly) as ferramentas externas (web_search, fetch_url, perplexity_search) são removidas por completo, e o modelo responde unicamente a partir do corpus verificado — ou declina.

Handoffs

O assistente não é um beco sem saída. Ele pode passar o controle para superfícies analíticas: para /explorar, para consultar os dados com SQL no navegador (DuckDB-WASM), e para um quadro de embed (/embed/board), para fixar e compartilhar um conjunto de achados com a sua proveniência intacta. O mesmo corpus é exposto a agentes externos via o servidor MCP — as ferramentas de corpus do chat mais duas exclusivas do MCP (get_series, resolve_citations).

O fechamento também é ação, não só dado: toda resposta substantiva termina com uma alavanca de "como melhorar" e um bloco futuros:pilots de 1–3 cartões de pilotos validados — com regras explícitas para omiti-lo (esclarecimentos, negativas, sem dados, perguntas que já são sobre pilotos). E depois de cada resposta chegam 3–4 perguntas de acompanhamento sugeridas, geradas best-effort pelo modelo barato (Haiku) — mesmo quando a resposta ficou truncada. O envelope completo de uma resposta: prosa citada (com a sua tabela, se compara) → visualização → link para o workbench/quadro → pilotos → acompanhamentos.


Em uma frase: o assistente converte um corpus com proveniência em respostas que declaram a própria confiança, corrigem o que não sustentam e dizem onde a sua inferência roda. A disciplina de honestidade que o rege é detalhada em Metodologia e honestidade.

Cada número com sua fonte — a rastreabilidade é o contrato.