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. O país selecionado no atlas viaja com o visitante: quem escolhe BRA e depois abre o chat em /, /governance/* ou /mercados continua perguntando sobre o Brasil, salvo se a rota ou ?c= nomear outro país, ou a Sala pedir a região inteira (?c=LATAM). No servidor, o viés de país se retira sozinho quando o assunto real é outro: se a pergunta (ou o próprio texto de busca que o modelo gerou) nomeia outro país — por nome, código ISO3 ou gentílico: «la banca peruana», «o agro brasileiro» — ou se a pergunta é explicitamente regional («América Latina», «Latinoamérica», «LATAM», «América do Sul»). Retirar-se degrada a busca sem filtro, nunca a um filtro errado: forçar BRA sobre uma pergunta pelo Uruguai descartaria o registro certo e a resposta afirmaria uma ausência falsa. Abrir o compositor dispara chat_composer_open (lançador, atalho, sala, link compartilhado). Cada envio dispara chat_submit e chat_query com o mesmo generation_id que o servidor usa em chat_generation. Uma falha visível dispara chat_fail com essa chave; Parar não conta como falha. O modo registrado por padrão é full, nunca o alias retirado normal. 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.

São perguntas esclarecedoras e 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.

O JSON desse bloco é escrito à mão pelo modelo, e o bloco costuma ser a resposta inteira do turno: uma recusa estrita não apagava um adorno, apagava o turno inteiro e o usuário lia «Não consegui completar a resposta». Por isso o parsing é tolerante, em três passos: JSON.parse estrito; se falhar, um reparo mecânico (vírgula sobrando antes de }/], prosa envolvendo o objeto) que respeita as vírgulas dentro de cadeias; e se ainda assim falhar, as cadeias question/text são resgatadas e mostradas como prosa numerada. As opções nunca são resgatadas — uma lista de opções lida pela metade é pior que prosa — e um corpo do qual nenhuma pergunta se recupera continua sendo descartado inteiro.

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.
  • Cada citação é auditável no seu primeiro uso. Na primeira vez que uma resposta usa um [[cite:id]], o token vai acompanhado — na mesma frase ou célula — de um rótulo legível da fonte com o seu ano, tirado do próprio registro da citação; os usos seguintes do mesmo id podem ir sozinhos. Para um id externo web- o emparelhamento é obrigatório em cada uso: um [[cite:web-…]] sozinho nunca é o único ponteiro de uma afirmação. O servidor aplica isso de forma determinista (server/chat/cite-label-assist.ts), tirando o rótulo apenas do campo source do registro ou do host da sua URL — nunca do title, que num resultado externo é texto de página não confiável. Um id cujo registro não sustenta um rótulo honesto fica para a maquinaria de [?]/resgate, em vez de ser vestido com um nome inventado.
  • 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.
  • Tecnologias de fronteira (97 indicadores × 25 países, dez domínios): frontier-ranking (cobertura observada, não capacidade; muitos valores são zeros reais da fonte), frontier-country (com filtro domain= para nuclear / biotech / quantum / space / patents), frontier-domain (o que cada domínio mede e o que não mede), frontier-gaps (as ausências medidas: FLOPs instalados, Tbps de cabo, regiões cloud, densidade robótica IFR, registros públicos de implantação de IA, VC deep-tech, slots orbitais ITU) e frontier-sources. Um sourced-zero se enuncia como achado; um no_source não se lê como zero.
  • 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, no idioma do usuário: compara por igual as etiquetas es/en/pt do registro governado, então «taxa de desemprego» ou «unemployment rate» resolvem para a mesma métrica que «tasa de desempleo» em vez de produzir uma ausência falsa.
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. Os filtros opcionais de ambos (país, pilar) são normalizados contra o catálogo antes da busca — «Brasil» ou «educação» filtram igual a BRA / educacion, em vez de esconder o corpus etiquetado atrás de uma ausência falsa — e a busca por texto do catálogo legal ignora maiúsculas e acentos («cambio climatico» encontra a Ley General de Cambio Climático).
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). Os cálculos multi-país (ranking, brecha à fronteira, correlação) também declaram quais países pedidos ficaram de fora por falta de dado no ano solicitado (countries_without_data, com o último ano disponível da série quando ela existe): um ranking parcial nunca se apresenta como completo.
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. A busca e o ajuste ao problema comparam por igual o texto do piloto em es/en/pt (nome e pitch) — uma pergunta em português encontra o piloto cujo próprio name_pt a descreve — e o nome e o pitch chegam no idioma da pergunta.
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). O emparelhamento por ticket também declara quando a lista mostrada é um recorte (buckets_truncated, com o que foi mostrado e o que de fato coube): são devolvidas até 6 janelas que cabem e até 3 de cada borda, e no portfólio real 56 dos 68 pilotos recortam algum grupo — um chega a 17 instrumentos que cabem para 6 lugares —, então uma pré-seleção nunca se apresenta como o conjunto completo de instrumentos que podem financiar o piloto. candidates_considered não basta: conta os três grupos ao mesmo tempo.
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.

Desde o histórico por conta, este registro só recebe os turnos sem sessão ou cuja gravação no Postgres falhou; com DATABASE_URL, GET /api/questions lê do Postgres e marca cada linha com user_hash. O mesmo vale para GET /api/chat-turns (source: "postgres").

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.
title{conversationId, title}Só na primeira resposta de uma conversa: o título gerado pelo Haiku substitui o provisório (primeiros 60 caracteres da pergunta). A barra lateral de /chat o mostra.
error{code, message, reason?, status?}É anexado inline sem apagar o parcial. reason (auth/credits/unavailable, derivado do status HTTP mais uma pista depurada do corpo do provedor — um 403 cujo corpo diz "Key limit exceeded" é credits, não auth — sem segredos) decide se "tentar de novo" faz sentido. message nunca é mostrado: é traduzido para uma causa limitada de telemetria (ver abaixo).

Um mesmo code não é uma mesma falha. internal é emitido por cinco condições sem relação entre si: um fetch que falhou no navegador antes de sair (network_error), um stream cortado no meio da resposta (stream_error), um turno que não produziu texto algum (empty — um vazio honesto do corpus, não uma queda), uma exceção não tratada dentro do turno e uma falha ao abrir o stream (ambas internal). Por isso o evento chat_fail leva uma propriedade cause, derivada do message do fio contra uma lista branca fechada (network · stream · empty · server · http · declined, e other para o resto), de modo que um chat_fail code=internal em produção possa ser diagnosticado em vez de agrupar cinco falhas distintas sob um único código. A lista branca é o que impede que um message de texto livre (uma dica de limite de taxa, uma mensagem de validação) entre como propriedade de analítica. | done | {usage, truncated?, conversationId?} | Fecha o turno. Piso final: se ficou vazio mas há sombra, ela é restaurada — um turno nunca termina em branco se existiu texto anterior. conversationId chega quando o turno foi registrado na conta: o cliente o adota para os turnos seguintes. | | persisted | {conversationId, seq} | Chega depois de done: a linha do turno já está no Postgres. O cliente esvazia o cache de sessão e não tenta de novo. Se não chegar, o cliente tenta a persistência uma única vez (ver Histórico de conversas). |

O cliente adiciona uma sentinela própria para o stream cortado, nas suas duas formas: um EOF sem evento terminal done/error, e uma leitura que falha no meio da resposta. As duas são o mesmo fato — a conexão morreu enquanto a resposta era transmitida — e qual delas ocorre é um detalhe do transporte que o leitor não consegue perceber, então ambas recebem a mesma revelação: o parcial é marcado como truncado, jamais como completo, e nunca é apagado. A metade que falha também reporta stream_error, a causa que o operador precisa em chat_fail; a revelação é o que se deve ao leitor e o reporte é o que se deve ao operador, e não se substituem. Se um evento terminal já chegou, o corte posterior é um encerramento de conexão e não é reportado de novo: o turno já declarou o seu resultado.

Um turno saudável é sempre 200 + SSE: o handler codifica até as próprias recusas (rate_limited, bad_request, not_configured) como evento error sobre um 200. Por isso uma resposta HTTP não-200 nunca chegou ao modelo, e o cliente a classifica pelo que de fato significa em vez de chamá-la de upstream: 401/403/404/405 → not_configured (o deploy não serve chat, p. ex. CHAT_ENABLED sem definir), 429 → rate_limited, 413 e demais 4xx → bad_request, 408 e 5xx → upstream com reason: unavailable (o único caso em que tentar de novo é conselho honesto). Todas carregam o status, então um único chat_fail basta para diagnosticar.

Histórico de conversas (conta) ​

Desde a chegada das contas, cada conversa com o assistente fica guardada na conta de quem pergunta (tabelas conversation e message no Postgres, Neon, região São Paulo) e é retomada em /chat. Três regras governam o desenho:

  1. A conversa é criada preguiçosamente. O cliente envia POST /api/chat com conversationId: null na primeira vez; o handler cria a linha, guarda o turno do usuário e transmite a resposta exatamente como antes. O evento terminal done leva conversationId, e o cliente o adota para os turnos seguintes. Um conversationId alheio, apagado ou inválido nunca anexa a essa conversa: o servidor abre uma nova, sem interromper a resposta.
  2. A geração continua sem estado. O cliente continua enviando os últimos 12 turnos no corpo; a montagem do prompt, a fidelidade e a completude não mudam. A persistência é um efeito colateral (server/chat/persist.ts) com prazo próprio (2,5 s): se o banco não responde, a resposta sai do mesmo jeito e o turno cai nos registros Redis de sempre.
  3. Uma linha por turno, idempotente. O turno do assistente é guardado no finally do handler, depois de done (texto, citações completas, acompanhamentos, generation_id, modelo, truncated, verificação, provedor, traço de ferramentas), e o stream emite persisted. Se esse evento não chega, o cliente tenta uma única vez com POST /api/conversations/:id/messages (ou POST /api/conversations com o turno, se nunca recebeu um id); o generation_id deduplica, então uma retentativa jamais produz duas linhas. Um turno recusado antes de responder (cota, chat não configurado) não cria nada.
O que se guardaOndeNotas
Turno do usuáriomessage(role=user)Texto, idioma, modo e contexto de página (em meta).
Turno do assistentemessage(role=assistant)Texto com [[cite:id]], as citações completas (para que o fio re-renderize com procedência), acompanhamentos, modelo, truncated, verificação, provedor, ferramentas, ticket de lacuna de dados e estado do turno.
Títuloconversation.titlePrimeiros 60 caracteres da primeira pergunta; após a primeira resposta, um título gerado pelo Haiku (uma única chamada, em paralelo com os acompanhamentos; nunca regenerado). Renomeável em /chat.
Visibilidadeconversation.visibilityprivate por padrão; org compartilha com a organização ativa (leitura para seus membros; renomear, excluir e continuar escrevendo seguem com o autor). Parar de compartilhar devolve a linha ao âmbito pessoal.

O que não muda. O link público compartilhado (/atlas#ask=…) continua funcionando para um fio solto e nunca aponta para uma conversa guardada. O sessionStorage (futuros_chat_session) passa a ser só um cache de recuperação em caso de queda: é esvaziado assim que o turno fica guardado e, ao recarregar, o cliente reidrata a conversa a partir de GET /api/conversations/:id.

A interface. /chat lista as conversas agrupadas por dia (hoje, ontem, últimos 7 dias, últimos 30 dias, anteriores), com busca por título sem distinguir acentos, filtro de âmbito (minhas conversas / todas, incluindo as compartilhadas com a organização), renomear, excluir e o alternador de compartilhamento. Os painéis de chat de /atlas e /sala ligam para /chat e conservam seu painel inline.

Os registros anônimos no Redis deixam de ser escritos para um turno que tem linha no Postgres: chat:questions:v1 e chat:turns:v1 só recebem os turnos sem sessão ou cuja gravação falhou. GET /api/questions e GET /api/chat-turns (mesmo QUESTION_LOG_TOKEN, mesma forma de resposta, mais source e user_hash) leem primeiro o Postgres quando DATABASE_URL existe, com o id de usuário hasheado (SHA-256 com sal, 16 caracteres), e só sem banco caem para o Redis. Sem DATABASE_URL tudo se comporta como antes desta versão, e os endpoints /api/conversations* respondem 503 history_not_configured.

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.

Resposta vazia: uma retentativa limitada antes de desistir ​

Um turno pode chegar à sua rodada terminal sem nada para entregar, e nenhuma das três formas tem a ver com o relógio: o provedor devolve uma conclusão degenerada (end_turn sem conteúdo, o que o OpenRouter faz quando seu upstream está degradado); o turno terminal vem inteiro dentro do rascunho privado ```futuros:reasoning, que é removido no servidor e deve continuar sendo; ou seu bloco ```futuros:clarify não sobrevive à normalização.

Antes, o handler desistia na primeira: emitia error: internal/empty e o usuário lia «Não consegui completar a resposta. Tente novamente» enquanto sobravam rodadas e relógio. Agora gasta exatamente uma retentativa, com o problema nomeado explicitamente na instrução, e prefere o próximo provedor da cadeia quando existe: uma conclusão vazia é um sinal de saúde do provedor, não um problema de prompt. É seguro por construção — uma resposta vazia nunca chegou a enc("text"), e uma normalização que esvaziou um rascunho já mandou reset_text —, então a tela está em branco e a retentativa não pode duplicar nada. A retentativa exige ≥ 15 s de relógio (uma rodada de modelo mais margem para o done terminal) e uma rodada livre no orçamento; sem isso, desiste de imediato.

Se a retentativa também voltar vazia, o mesmo error honesto de sempre é emitido — um balão em branco nunca é aceitável — mas a linha de log agora carrega o estado que a torna atribuível sem reconstruir tempos de requisição: rodadas usadas, ferramentas executadas, milissegundos decorridos, truncated (que separa o caminho do relógio dos caminhos do modelo), shaping_emptied (o modelo escreveu algo e a normalização apagou) e o provedor ativo.

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.

Os artefatos exportáveis herdam o mesmo contrato. Os botões da resposta (copiar, CSV, XLSX, compartilhar) resolvem as citações sem distinguir maiúsculas — o modelo às vezes escreve [[cite:icr-BRA]] para o registro icr-bra e a verificação do servidor aceita, então o export não pode perder essa fonte — e um token de citação cujo id não resolve para nenhum registro nunca é apagado em silêncio: fica como marcador visível [?] e é listado como «referência não verificada» na lista de fontes. Apagá-lo transformaria um número não verificado em prosa limpa sem citação no exato artefato que um terceiro repassa — precisamente a lavagem que a verificação existe para impedir (um id não ancorado pode chegar ao cliente: um rascunho truncado pula a correção). As planilhas exportadas — o CSV de fontes e a pasta XLSX — também neutralizam células que começam com um caractere de fórmula (=, +, -, @, inclusive após espaços ou tabulações iniciais): o título de uma citação web vem de fora do corpus e, aberto no Excel ou no Sheets, seria executável — fica como texto inerte, atrás de um apóstrofo inicial que a planilha não exibe. Um único sanitizeSpreadsheetCell serve aos dois caminhos, porque um artefato encaminhado a um ministério ou a uma IFI não pode depender de qual dos dois botões foi apertado. As células numéricas (a coluna de ano) passam intactas nos dois, e o CSV também coloca entre aspas o retorno de carro: as linhas são unidas com \r\n, então um CR sem aspas dentro de um título partiria o registro em dois. E o link de compartilhar decodifica com a mesma fidelidade com que codifica: os limites do lado receptor são pelo menos tão generosos quanto o teto de 24 000 bytes do emissor, então um briefing longo legítimo nunca perde sua cauda — nem suas citações — em silêncio, e um token fabricado maior que esse teto é rejeitado inteiro em vez de truncado. Os blocos de máquina também nunca vazam como JSON cru: o briefing e o XLSX renderizam o bloco futuros:viz como linhas de números com suas referências numeradas — assim o XLSX lista as mesmas fontes que o CSV da mesma resposta, incluindo as que só o gráfico citava — e os blocos futuros:pilots / futuros:clarify são convertidos em texto legível (pilotos e perguntas, não JSON) em copiar, briefing e XLSX.

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.

Cotas de uso e disponibilidade ​

Duas superfícies, duas posturas. O chat no site (mesma origem, SPA) é gratuito e não pede chave. O POST /api/chat programático — scripts, agentes ou outras origens — exige uma chave Futuros pré-paga (Authorization: Bearer ft_live_…) comprada em /acceso: 5 créditos por turno, 15 por um Deep Briefing. Sem chave → api_key_required; sem créditos → insufficient_credits. O servidor MCP cobra igual (ver MCP).

O gasto do chat no site continua limitado por cotas explícitas cobradas antes de tocar o modelo (api/chat.ts, server/http/rate-limit.ts):

CotaTetoO que limita
Por IP20 turnos por minutoO uso individual. É cobrada sobre o IP confiável (x-real-ip, ou o extremo direito de x-forwarded-for), nunca sobre o cabeçalho falsificável.
Global por minuto300 turnos por minuto somando todos os usuáriosA rajada, dimensionada bem acima do tráfego real.
Global por hora300 turnos por hora somando todos os usuáriosO gasto sustentado. É o teto que limita uma campanha distribuída que rotaciona IPs, contra a qual o limite por IP não faz nada.

Um turno de Deep Briefing custa três unidades em vez de uma nas três cotas (uma ao ser admitido, duas de sobretaxa depois de lido o modo), porque consome até 9 rodadas de ferramentas, 16.384 tokens de saída e busca web paga. Na prática: cerca de 6 briefings por minuto e por IP, e cerca de 100 briefings por hora em toda a plataforma. A sobretaxa não bloqueia o turno que a dispara; é cobrada sobre a janela em curso.

Ao ultrapassar uma cota, a resposta é um evento SSE error com código rate_limited e cabeçalho Retry-After: "too many requests; slow down" para a cota por IP, "service is busy; retry shortly" para as globais.

Teto degradado em vez de serviço sem medição. Em um deploy gerenciado (Vercel), se o armazenamento compartilhado de cotas está configurado mas não pode ser consultado, o chat segue respondendo sob um teto bem mais estreito, contado na memória de cada instância: 6 turnos por minuto por IP (em vez de 20), 30 por minuto e 120 por hora em toda a plataforma (em vez de 300 e 300). Degradar para os tetos completos multiplicaria cada um pelo número de instâncias vivas, ou seja, tiraria o único limite ao gasto justamente quando o armazenamento falha; já recusar tudo transforma uma queda do Redis em uma queda do produto. O teto degradado é o meio-termo: ninguém que use o chat de verdade percebe (um turno leva dezenas de segundos), e um atacante obtém um múltiplo pequeno de um número pequeno.

Esses quatro números são por instância, não totais da plataforma: sem armazenamento compartilhado não há como contar entre instâncias, então a exposição real no pior caso é cada teto multiplicado pelo número de instâncias vivas. É justamente por isso que são tão estreitos — com os tetos completos o mesmo multiplicador se aplicaria a 20, 300 e 300. É uma degradação medida, não um teto duro; o teto duro volta quando o armazenamento volta.

Ao ultrapassar esse teto de emergência a resposta continua honesta e distinta da de cota ultrapassada: "rate limiting is temporarily unavailable; retry shortly". Não é que você esteja acima da sua cota publicada; é que a cota não pode ser consultada. GET /api/chat expõe isso em limiter: store_configured, store_reachable e degraded — o sinal que um operador deve vigiar, porque configuração sozinha não é saúde. Essa sondagem chega mesmo ao armazenamento (não cobra cota alguma, com tempo limite de 2 s) e é cacheada por 10 segundos por instância, para que um monitor com sondagem periódica não vire ele próprio carga sobre o armazenamento que já pode estar falhando; o veredicto pode então ficar até 10 s atrasado. O mesmo endpoint informa a credencial de inferência em provider_key: probed, ok, o status upstream e, quando o OpenRouter é o primário, a postura do limite de gasto da chave (exhausted, limit_remaining). Uma chave válida cujo teto por chave já foi gasto continua falhando em todos os turnos com 403 "Key limit exceeded" (2026-09-15: todos os turnos em produção, com a credencial em si aceita), então ok vira false com o teto esgotado exatamente como com uma chave rejeitada — a correção é recarregar ou subir o teto, não rotacionar.

Um deploy que nunca configurou um armazenamento (desenvolvimento local, self-hosting) segue com os tetos completos por instância e um aviso nos logs. Um operador que prefira recusar a servir sem medição define RATE_LIMIT_REQUIRE_STORE=1: isso restitui a recusa dura, tanto para um armazenamento ausente quanto para um quebrado.

A cadeia de provedores e o modo soberano ​

server/chat/provider.ts constrói uma cadeia de provedores com failover transparente (providerChain() / providerChainForRequest(model)). 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. Antes de gastar um failover, um 400 pré-resposta retenta a mesma rodada uma vez sem cache_control (OpenRouter e alguns shims Messages rejeitam o breakpoint de cache quando nenhum endpoint roteado suporta prompt caching da Anthropic). O error SSE de uma cadeia esgotada leva reason (auth / credits / unavailable / upstream) e status HTTP. O default é Sonnet para as respostas; as perguntas de acompanhamento rodam em Haiku (claude-haiku-4-5), mais barato. O campo model do compositor aplica-se via providerChainForRequest(parsed.model) após o parse — o gate de configuração continua usando providerChain().

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.

Pacote de decisão para processos (GuIA e AIE) ​

Toda resposta do assistente pode ser baixada como pacote de decisão (ação "Pacote de decisão" sob a resposta): um artefato Markdown feito para ser anexado a um processo de compra pública ou de política — um gabinete ministerial, um regulador ou uma DFI cola no Word sem retrabalho. Cinco seções fixas (src/lib/guia-pack.ts, funções puras com testes; a exportação é gerada inteiramente no navegador):

  1. Consulta e resposta citada — a pergunta, a resposta com referências numeradas [1] e uma linha de propósito / uso previsto que a pessoa revisora preenche (a plataforma nunca a adivinha).
  2. Anexo de fontes citadas — apenas as fontes que a resposta realmente cita, com URL e ano quando existem; cada linha declara sua origem (corpus ou web (externa): a separação corpus/web também viaja no export), e as citadas somente dentro de um gráfico aparecem marcadas como gráfico.
  3. O que NÃO está no corpus — lacunas declaradas a partir de sinais que o turno já carrega: a verificação determinística (citações que nenhuma ferramenta retornou, números sem fonte, números citados que não coincidem com nenhum valor registrado de sua fonte, termos de financiamento que o corpus não pode sustentar, contradições entre fontes), o tíquete de lacuna de dados, os cortes (resposta truncada ou interrompida) e os marcadores de citação sem registro na sessão, tanto os que foram retirados da prosa quanto os que apareciam apenas dentro de um gráfico. Se a verificação não chegou a ser executada (transmissão interrompida, conversa restaurada), o pacote o declara em vez de fingir uma checagem limpa; sem sinais registrados, a seção o diz explicitamente — nenhuma lacuna ou risco é sintetizado.
  4. Aprovação humana (obrigatória) — bloco em branco por construção: nome e cargo da pessoa revisora, data, decisão (aprovar / revisar / rejeitar), plano de reversão (rollback) e plano de monitoramento. O Futuros não aprova nada automaticamente; o pacote só tem valor com essa assinatura.
  5. Mapa de etapas GuIA / IA pública — tabela que ancora cada seção do pacote aos pontos de controle de dois guias públicos de 2026: o GuIA do Brasil (Guia Unificado de IA para o Setor Público, NIA/SGD — 7 etapas, Prospecção → Desativação, com a AIE nas etapas 1–2, o gate de rollback na 4 e o monitoramento na 6) e o guia de implementação da CEPAL (LC/TS.2026/33). É uma leitura temática de apoio: o Futuros não é certificado nem endossado por nenhum dos dois guias, e o pacote não substitui a avaliação de impacto algorítmico exigida pela regulação aplicável.

Anexo de citações na tela ​

Quando um turno termina, ChatMessage monta CiteAppendix (src/components/chat/CiteAppendix.tsx) sob a resposta, recolhido num <details> com a mesma linguagem visual do rastro de consultas ao corpus. A linha visível sem abrir é § ANEXO DE CITAS · {n}, onde {n} é a quantidade de linhas resolvidas que a exportação numera. Se há linhas web- com registro, a mesma linha soma {n} fora do corpus. Uma linha [?] (id citado sem registro na sessão) conta à parte como {n} sem registro e fica de fora da contagem fora do corpus: não há registro a classificar como externo. Uma linha que a exportação não numera (um ponto de gráfico com cite e sem valor, que fencesToText não reemite) aparece como {n} sem numeração.

Cada id citado na prosa ou numa cerca futuros:viz é uma linha, na ordem da primeira citação, com deduplicação sem distinguir maiúsculas. O [n] de uma linha resolvida é o mesmo que toBriefing() e answerToWorkbook() imprimem em src/lib/chat-export.ts: ambos percorrem o texto já passado por fencesToText (as citações do gráfico que têm valor voltam a sair como [[cite:id]]) e resolvem o id sem distinguir maiúsculas. A linha sem registro aparece como [?] e abre a gaveta de procedência.

O link da linha segue linkGradeFor (src/lib/cite-link-grade.ts). Grau record (a URL do próprio registro) e grau deep (uma url_template com parâmetros) aparecem como link. Grau portal (só dá para reconstruir uma página de entrada) não mostra URL: o texto diz que o editor não publica um link por registro, e o controle abre a gaveta, onde essa página aparece etiquetada como portal.

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.