From 6994a5b7d975368e4f1f4b4cf062c2c06cb6f29e Mon Sep 17 00:00:00 2001 From: nitai Date: Mon, 6 Jul 2026 13:06:23 -0300 Subject: [PATCH] =?UTF-8?q?blog:=20s=C3=A9rie=20"A=20evolu=C3=A7=C3=A3o=20?= =?UTF-8?q?da=20plataforma=20DGB=20(jun=E2=80=93jul=202026)"=20=E2=80=94?= =?UTF-8?q?=204=20posts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cobre o período de 10 jun a 3 jul de 2026 (desde o último post, MLflow): - Entidades canônicas + lente semântica (NER v2) - Grafo em Neo4j + backfill com governador de cota - Gobus MCP — agente que investiga o gov.br - Entidades em alta, políticas públicas e o fechamento do ciclo Voz impessoal/plataforma, storytelling. Autor: nitai. Deck DGB × SECOM embutido no post 4. Validado via mkdocs build + Playwright. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...-11-entidades-canonicas-lente-semantica.md | 176 +++++++++++++ ...26-06-18-grafo-entidades-backfill-neo4j.md | 232 ++++++++++++++++++ .../2026-06-30-gobus-mcp-agente-govbr.md | 178 ++++++++++++++ ...-03-entidades-alta-politicas-fechamento.md | 178 ++++++++++++++ 4 files changed, 764 insertions(+) create mode 100644 docs/blog/posts/2026-06-11-entidades-canonicas-lente-semantica.md create mode 100644 docs/blog/posts/2026-06-18-grafo-entidades-backfill-neo4j.md create mode 100644 docs/blog/posts/2026-06-30-gobus-mcp-agente-govbr.md create mode 100644 docs/blog/posts/2026-07-03-entidades-alta-politicas-fechamento.md diff --git a/docs/blog/posts/2026-06-11-entidades-canonicas-lente-semantica.md b/docs/blog/posts/2026-06-11-entidades-canonicas-lente-semantica.md new file mode 100644 index 0000000..2f938b0 --- /dev/null +++ b/docs/blog/posts/2026-06-11-entidades-canonicas-lente-semantica.md @@ -0,0 +1,176 @@ +--- +date: 2026-06-11 +authors: + - nitai +categories: + - NLP + - Entidades + - Data Science + - Portal +title: "Entidades que viram conhecimento: NER canônico, Wikidata e a lente semântica" +hide: + - toc +--- + +# Entidades que viram conhecimento: NER canônico, Wikidata e a lente semântica + +Até esta rodada, uma entidade no DGB era apenas texto: "Min. da Saúde", "Ministério da Saúde" e "MS" eram três coisas diferentes para a plataforma, strings soltas presas a uma notícia e a mais nada. Em dois dias (10 e 11 de junho de 2026), a plataforma trocou esse NER "bruto" por entidades **canônicas** — cada menção passou a resolver para um nó de conhecimento com identidade estável, tipo (organização, pessoa, lugar, e agora também **Eventos** e **Políticas públicas**) e, quando possível, um **QID da Wikidata** como chave de deduplicação linked-data. No caminho, a busca de "relacionadas" deixou de ser por código de tema e virou **similaridade semântica** (com um ganho medido de ~4,3s para ~180ms), a tela da notícia ganhou chips agrupados por tipo e uma **lente semântica** que marca entidades no corpo do texto. + + + +--- + +## De strings soltas a nós de conhecimento + +O ponto de partida era um NER que devolvia texto e um depósito de lixo. Entidades vinham como cadeias de caracteres, variações da mesma coisa nunca se encontravam, e tudo que o modelo não sabia classificar caía num balde `MISC` que não servia para nada. + +``` +ANTES (NER bruto) DEPOIS (entidade canônica) +----------------- -------------------------- +"Min. da Saúde" --+ +-- entity_registry +"Ministério da | strings | id: canônico estável + Saúde" | soltas, | tipo: ORG +"MS" --+ sem ligação --> | label: Ministério da Saúde + | wikidata: Q1519799 + +-- (alias --> o mesmo nó) +``` + +A evolução aconteceu em duas frentes que fecharam juntas: o **extrator + a pipeline de canonicalização** na camada de ciência de dados, e a **fundação canônica** (banco, índice, API e portal) que transforma esses nós em navegação real para quem lê o portal. + +--- + +## O extrator NER evoluído e a pipeline de canonicalização + +A primeira metade da história está em [data-science#26](https://github.com/destaquesgovbr/data-science/pull/26) e [data-platform#179](https://github.com/destaquesgovbr/data-platform/pull/179). + +O NER saiu da chamada LLM combinada e ganhou **chamada Bedrock própria**, com modelo configurável via `NER_MODEL_ID`. Junto veio uma nova taxonomia em português que acrescenta **Eventos** e **Políticas públicas** aos tipos clássicos (organização, pessoa, lugar) — e, decisivo, um bloco explícito de "**NÃO é entidade**" no prompt, que drena o depósito `MISC` na origem em vez de deixar ruído escorrer para o índice. Cada entidade passou a sair com `forma_canonica` e `salience`, e a resposta crua do modelo passou a ser gravada em `news_llm_raw` (objeto JSONB) — o que torna o corpus **reprocessável sem re-chamar o Bedrock**. + +A canonicalização propriamente dita (`canonicalization_job.py`, `canonicalization.py`, `wikidata_client.py`) trabalha por forma distinta, numa cascata com desligamentos progressivos: + +``` +menção (forma distinta) + | + v + gazetteer (159 órgãos) -- hit --> canonical_id + | miss + v + Opus (CANON_MODEL_ID) -- forma canônica + tipo + | + v + Wikidata QID (dedup) -- gates de confiança + | + escalada contextual (PER / homônimos) + v + entity_registry <-- backfill de canonical_id nas menções +``` + +O QID da Wikidata não é enfeite: é a **chave de deduplicação**. Duas formas diferentes que resolvem para o mesmo QID são, por definição, a mesma entidade. Para pessoas e homônimos, a pipeline aplica **gates de confiança** e uma **escalada contextual** — o guard citado no PR distingue, por exemplo, a Saúde brasileira de uma homônima estrangeira antes de fundir os nós. Tudo isso é resumível (pode parar e retomar) e faz backfill do `canonical_id` nas menções já existentes. + +A fundação de dados que segura essa canonicalização veio nas **migrações 015-020** ([data-platform#179](https://github.com/destaquesgovbr/data-platform/pull/179)): + +- `entity_registry` — a tabela canônica, já modelada **Neo4j-ready** (guarde esse detalhe para o próximo post); +- `entity_alias` — o resolver **determinístico** de aliases, semeado com **159 órgãos** derivados da tabela `agencies`; +- índice GIN sobre a coluna canônica, `news_llm_raw` (o cru reprocessável) e `entity_registry_seen` (o estado da canonicalização). + +!!! note "O que a revisão adversarial pegou" + A rodada de revisão adversarial não foi decorativa: em [data-platform#179](https://github.com/destaquesgovbr/data-platform/pull/179) ela flagrou e corrigiu uma **colisão de alias** e um **bug crítico de offsets em NFD** (a normalização Unicode que faz a lente apontar para o caractere certo). Em [data-science#26](https://github.com/destaquesgovbr/data-science/pull/26), 136 testes verdes com Bedrock e Wikidata mockados — sem chamadas reais, sem reprocessamento acidental. Em [data-platform#179](https://github.com/destaquesgovbr/data-platform/pull/179), 586 testes unitários verdes. + +--- + +## A fundação canônica no Typesense e a lente semântica + +Com o registro canônico pronto no Postgres, faltava expor isso ao portal — e foi onde o índice de busca precisou aprender os tipos novos. + +### O indexer que perdia entidade + +O Typesense ganhou os campos `entity_event`, `entity_policy` e `entity_canonical`, e o indexer passou a rotear EVENT/POLICY e a emitir o `canonical_id` ([data-platform#179](https://github.com/destaquesgovbr/data-platform/pull/179)). No caminho, um bug silencioso foi corrigido: tudo que não fosse ORG/PER/LOC — justamente os novos Eventos e Políticas — caía num `entity_misc` e **era perdido** na indexação. A lente semântica, do lado do data-platform, ganhou o feature-worker `compute_content_annotations`, que calcula **offsets determinísticos** com um mapa `folded -> original` robusto a NFD e casefold, para nunca destacar a palavra errada no corpo. + +### Relacionadas por significado, não por código de tema + +Na API, [graphql-api#17](https://github.com/destaquesgovbr/graphql-api/pull/17) trouxe as features calculadas para dentro do `Article` e reescreveu as "notícias relacionadas". Antes, "relacionadas" era um `OR` entre códigos de tema; agora é **similaridade semântica** via `pgvector`. O detalhe de engenharia importa: a query `get_similar_articles` foi reescrita para usar o índice **HNSW** (`ORDER BY content_embedding <=> $1::vector`, com o embedding do artigo base como literal), eliminando um `Seq Scan` que varria **~333 mil linhas**. Medido localmente, o salto foi de **~4,3s para ~180ms**. + +Para não onerar listas e busca, `Article.features` é um campo **lazy**: um DataLoader por `unique_id` que só toca o `news_features` (JSONB) quando o campo é efetivamente selecionado. A assinatura GraphQL de `relatedArticles` ficou **inalterada** — a troca de mecanismo por baixo passou pelo gate anti-drift sem quebrar o portal. A mesma PR adicionou filtros por entidade e sentimento, o argumento `sort` (`RELEVANCE`/`DATE`/`TRENDING`/`VIEWS`) e o resolver público `entitySuggestions` para typeahead. **446 testes** verdes. + +A camada canônica em si veio logo em seguida ([graphql-api#18](https://github.com/destaquesgovbr/graphql-api/pull/18)): `EntityType.canonicalId` + `salience`, um resolver `entity(id): EntityNode` que lê o `entity_registry`, o modo canônico do `entitySuggestions` (faceta `entity_canonical`), o filtro de busca `entityCanonical` e o `ArticleFeatures.contentAnnotations` — a lente. Todas as mudanças de SDL foram **puramente aditivas/nullable**, o que mantém o drift gate seguro. **470 testes** verdes. + +!!! warning "A ordem de deploy não é opcional" + Como o portal compila contra um snapshot do SDL, o **graphql-api tem de ser deployado antes** do portal. Os dois PRs de API deixam isso explícito, e o filtro/typeahead de entidade só retorna dados **depois** do reindex do Typesense em produção — até lá, degrada para vazio em vez de quebrar. + +--- + +## Grupos Eventos/Políticas e a lente na tela da notícia + +Do lado do portal, o trabalho veio em duas PRs. [portal#264](https://github.com/destaquesgovbr/portal/pull/264) montou a **tela da notícia enriquecida** (o componente `ArticleFeatures`, composto de `ArticleFichaBar` + `ArticleEntities`), os filtros por sentimento e entidade com ordenação na `/busca`, o `EntityMultiSelect` com typeahead alimentado por `entitySuggestions` e as primeiras páginas `/entidades/[slug]`. **484 testes** verdes. + +[portal#265](https://github.com/destaquesgovbr/portal/pull/265) fechou a camada canônica na interface: + +- **chips agrupados** de Eventos e Políticas Públicas, com `lib/entity-types.ts` como fonte única de cor e ícone por tipo; +- páginas `/entidades/[id-canonico]` com header vindo de `entity(id)` — nome, tipo, descrição e link para a **Wikidata** — e a lista de notícias via filtro `entityCanonical`, com fallback gracioso para os slugs de texto legados; +- a **`SemanticLens`**: a lente que marca entidades inline no corpo da notícia. + +A lente merece uma nota de projeto. Ela usa uma estratégia **validate-then-split**: antes de destacar, valida que o trecho no offset corresponde de fato à entidade, e só então divide o texto — a revisão adversarial confirmou que a lente é **incapaz de destacar a palavra errada**. Ela vem **desligada por padrão**, com um toggle discreto ("ghost") e a preferência persistida em `localStorage`, para não poluir a leitura de quem não a quer. **525 testes** verdes. + +| | Antes | Depois | +|---|---|---| +| Entidade | string solta na notícia | nó canônico com `id` estável | +| Variações ("MS", "Min. Saúde") | três entidades distintas | um mesmo nó (via `entity_alias`) | +| Tipos reconhecidos | ORG / PER / LOC (+ balde `MISC`) | + Eventos + Políticas; sem `MISC` | +| Identidade externa | nenhuma | QID da Wikidata (linked data) | +| Relacionadas | `OR` de códigos de tema | similaridade semântica (HNSW) | +| Entidade no corpo do texto | texto puro | lente semântica (marcação inline) | + +--- + +## Ligando os modelos: as env vars do Bedrock + +Nada disso liga sozinho. Os modelos que o NER e a canonicalização usam são configuráveis, e [infra#198](https://github.com/destaquesgovbr/infra/pull/198) fez essa fiação via Terraform: variáveis `ner_model_id` e `canon_model_id`, com `NER_MODEL_ID` indo para o `enrichment-worker` e `CANON_MODEL_ID` para o Composer (onde a futura DAG de canonicalização vai rodar, já com boto3). + +A decisão de segurança aqui foi deixar os **defaults no modelo atual** (`anthropic.claude-3-haiku-20240307-v1:0`), de forma que o `terraform plan` fosse um **no-op seguro** — apenas adiciona as env vars, sem mudar comportamento. Ativar o upgrade (Sonnet 4.6 para o NER, Opus 4.8 para a canonicalização) é um passo separado e deliberado. Os IDs exatos dos inference-profiles **não foram inventados**: sem AWS CLI no ambiente, o PR documenta o padrão esperado (`us.anthropic.claude-sonnet-4-6--v1:0` etc.) e pede confirmação da string real habilitada na conta. + +!!! tip "Falha graciosa por design" + O enrichment-worker é resiliente: se o `NER_MODEL_ID` estiver inválido, o NER **degrada** (entidades vazias) sem derrubar tema, resumo e sentimento — porque a chamada combinada desses três usa outro modelo. Um ID errado custa entidades naquele lote, não a pipeline inteira. + +--- + +## O 403 da Wikidata (e o driver de re-NER) + +O primeiro reprocessamento real, sobre uma fatia de um mês, quase entregou uma canonicalização sem a parte que a torna linked data. A Wikidata começou a devolver **HTTP 403 em todas as requisições** — nenhum QID era linkado, e a deduplicação por Wikidata simplesmente não acontecia. A causa: a Wikimedia bloqueia requisições sem um **User-Agent descritivo**. + +O fix foi cirúrgico ([data-science#28](https://github.com/destaquesgovbr/data-science/pull/28)): o `wikidata_client.py` passou a enviar um `WIKIDATA_USER_AGENT` no `httpx.Client`. A verificação foi feita ao vivo — sem UA, 403; com UA, 200 — e até os primeiros QIDs saíram corretos: + +``` +Renan Calheiros -> Q3623584 +Ministério da Saúde -> Q1519799 +Bolsa Família -> Q575545 +``` + +A mesma PR trouxe `scripts/renew_ner_window.py`, um driver de re-NER de janela **resumível e capado** (newest-first) que reusa exatamente o caminho do worker — a ferramenta que conduziu o reprocessamento do mês e o backfill gradual seguinte. + +--- + +## Números + +| Métrica | Valor | +|---|---| +| Migrações de banco | 015-020 (`entity_registry`, `entity_alias`, ...) | +| Órgãos semeados no resolver de alias | 159 (a partir de `agencies`) | +| Ganho da busca de relacionadas (HNSW) | ~4,3s -> ~180ms (Seq Scan em ~333k linhas eliminado) | +| Testes (data-science#26 / data-platform#179) | 136 / 586 verdes | +| Testes (graphql-api #17 / #18) | 446 / 470 verdes | +| Testes (portal #264 / #265) | 484 / 525 verdes | + +--- + +## Lições + +1. **A chave de deduplicação tem de vir de fora.** Enquanto entidade era só texto, "MS" e "Ministério da Saúde" jamais se encontrariam. O QID da Wikidata dá um ancoradouro externo e estável — duas formas que resolvem para o mesmo QID são a mesma coisa, sem heurística frágil. +2. **Elimine o balde de lixo na origem.** Um bloco explícito de "NÃO é entidade" no prompt drena o `MISC` antes de ele virar ruído no índice — muito mais barato que filtrar depois. E o `entity_misc` que perdia Eventos e Políticas mostra que um balde também engole o que você passou a querer. +3. **Guarde o cru, reprocesse à vontade.** Gravar a resposta do modelo em `news_llm_raw` (JSONB) desacoplou o reprocessamento das chamadas ao Bedrock — a taxonomia pode evoluir sem re-pagar inferência. +4. **Integrações externas quebram em silêncio.** O 403 da Wikidata não derrubava nada: só parava de linkar QIDs. Um User-Agent descritivo resolveu, mas só foi visto porque o reprocessamento real exercitou o caminho de ponta a ponta. +5. **Falhe gracioso, deploye em ordem.** Env vars com default no modelo atual tornam o `plan` um no-op; NER inválido degrada sem derrubar tema/resumo/sentimento; e SDL aditivo/nullable com "API antes do portal" mantém o drift gate honesto. + +Com o `entity_registry` no ar, cada entidade agora é um nó com identidade, tipo e, quando dá, um QID. Mas um nó sozinho é só metade da promessa: o valor real está nas **arestas** — quem aparece com quem, quais órgãos e políticas se cruzam, como esses vínculos evoluem no tempo. Não por acaso a tabela nasceu **Neo4j-ready**. O próximo post da série conta como esses nós viraram um grafo de verdade — e o backfill retroativo que trouxe o passado do gov.br para dentro dele. + +--- + +*Série **A evolução da plataforma DGB (jun–jul 2026)** · post 1 de 4.* +*Próximo post: [O grafo do gov.br: entidades em Neo4j e o backfill retroativo](2026-06-18-grafo-entidades-backfill-neo4j.md).* diff --git a/docs/blog/posts/2026-06-18-grafo-entidades-backfill-neo4j.md b/docs/blog/posts/2026-06-18-grafo-entidades-backfill-neo4j.md new file mode 100644 index 0000000..9a6d9ba --- /dev/null +++ b/docs/blog/posts/2026-06-18-grafo-entidades-backfill-neo4j.md @@ -0,0 +1,232 @@ +--- +date: 2026-06-18 +authors: + - nitai +categories: + - Grafo + - Infraestrutura + - Data Engineering + - Entidades +title: "O grafo do gov.br: entidades em Neo4j e o backfill retroativo com governador de cota" +hide: + - toc +--- + +# O grafo do gov.br: entidades em Neo4j e o backfill retroativo com governador de cota + +Com as entidades canônicas em mãos — cada órgão, lei, política e pessoa reduzido a um `canonical_id` estável, muitos deles já ancorados num QID da Wikidata — faltava fazer duas coisas com elas. A primeira: projetá-las num **grafo**, para que "quem aparece com quem" deixasse de ser uma consulta ad-hoc e virasse uma estrutura navegável, no Postgres e num **Neo4j** dedicado, servida ao portal como entidades relacionadas e uma rede ego-centrada. A segunda, bem mais dura: rodar um **backfill retroativo** sobre toda a base histórica — os ~314 mil artigos que nunca passaram pelo NER canônico — sem estourar o teto diário de tokens da AWS Bedrock. Em cerca de três dias intensos, a plataforma ganhou o grafo (Fases 6a–6d) e transformou o reprocessamento numa operação de guerra controlada por um **governador de cota**, com direito a dois tropeços de permissão, uma quota que se descobriu fictícia, um limite real da AWS confirmado na marra e uma sequência de falsos-positivos de sigla que só apareceram rodando em escala de produção. + + + +## Onde paramos + +O post anterior fechou a canonicalização de entidades: a plataforma passou a colapsar variantes de nome numa entidade única e a ancorá-la, quando possível, na Wikidata. Isso resolve o "o quê" (a identidade de cada entidade), mas não o "com quem" — a relação entre entidades continuava implícita, espalhada linha a linha dentro de `news_features`. As duas frentes deste ciclo atacam exatamente esse vazio: uma **projeta as relações num grafo**; a outra **aplica a canonicalização a todo o passado**, porque um grafo construído só com as notícias novas seria um grafo torto, cego para quase toda a história do gov.br. + +--- + +## Salto 1: projetar as entidades num grafo (Fase 6) + +A projeção foi quebrada em quatro fases coordenadas — dados, infra, API e UI — para que cada peça pudesse subir com um deploy pequeno e verificável. + +``` + news_features (menções por artigo) + │ DAG project_entity_graph (0 */6) + ▼ + ┌──────────────┐ rebuild set-based, idempotente, 1 transação + │ news_entities│ (só menções com canonical_id não-nulo) + │ entity_edges │ co_mention (weight≥2, src id interno `dgb_`) — em **33 dos 35** merges, isso apagaria o QID e manteria o `dgb_`, perdendo a âncora linked-data. Com a seleção Wikidata-wins, os 33 pares passaram a preservar o QID; os 2 restantes eram duplicatas exatas `dgb_×dgb_` (Casa Civil, TransfereGov). Suíte em 253. + +### Edições por ano + +A última salvaguarda veio de uma decisão de produto: eventos e programas recorrentes são rastreados **por edição** — "Enem 2025" e "Enem 2026" são entidades distintas ([data-science#35](https://github.com/destaquesgovbr/data-science/pull/35)). Sem isso, o detector proporia fusões erradas por Jaccard alto (ex.: "Encontro Nacional de Gestão de Pessoas (ENGP)" ↔ "...(ENGP) 2026", Jaccard 0,857). O guard determinístico `differs_by_year` bloqueia a fusão de nomes cujos conjuntos de tokens-de-ano (4 dígitos, 1900–2099) diferem, tanto no dedup quanto no mint-time. "Copa do Mundo FIFA de 2026" vs "... 2026" (mesmo ano) **ainda funde** — ali o ano não distingue nada. O efeito medido: de 2 propostas de EVENT para 1 (só a Copa; a ENGP foi bloqueada). Suíte em 262. + +### E o grafo precisa esquecer + +Todo esse merge contínuo revelou um vazamento no elo final ([data-platform#186](https://github.com/destaquesgovbr/data-platform/pull/186)). O `sync_graph_to_neo4j` era **MERGE-only**: fazia upsert dos nós correntes, mas **nunca removia** do Neo4j os nós que tinham saído do Postgres. Como entidades se fundem o tempo todo — o `add_alias` Wikidata-wins promove `dgb_`→QID a cada canonicalização, além dos `--dedup` retroativos — o Neo4j acumulava nós stale com arestas mortas. Durante a retroatividade do dedup ORG, após 36 merges, o Neo4j ficou com 36 nós órfãos (**1143 vs 1107** no Postgres), exigindo cleanup manual via túnel SSH. A correção adicionou um passo de limpeza na mesma sessão do sync: + +```cypher +MATCH (e:Entity) WHERE NOT e.entity_id IN $valid_ids +DETACH DELETE e RETURN count(*) AS deleted +``` + +Com um **guard de segurança** essencial: o DELETE só roda se o `fetch_nodes` retornou nós — um fetch vazio por erro transitório **nunca** zera o grafo. A partir daí, o Neo4j se autolimpa a cada 6h, sem mais cleanup manual. + +--- + +## Antes e depois + +| | Antes | Depois | +|---|---|---| +| Relação entre entidades | implícita, linha a linha em `news_features` | grafo explícito (`entity_edges`) no Postgres + Neo4j | +| Navegação de relações no portal | inexistente | "Entidades relacionadas" + rede ego-centrada | +| Cobertura da canonicalização | só notícias novas | backfill sobre ~314k artigos históricos | +| Controle de gasto com LLM | nenhum (risco de estourar a conta) | governador de cota com ledger em Postgres | +| Teto de tokens/dia | suposto (800k, depois "ilimitado") | 6,0M — limite real da AWS confirmado | +| Dedup de ORG | Jaccard 0,62 (falsos-positivos) | 0,85 + detector de sigla homonym-safe | +| Nós stale no Neo4j | acumulavam (cleanup manual via SSH) | autolimpeza a cada sync | + +## Números + +| Métrica | Valor | +|--------|-------| +| Fases do grafo | 6a · 6b · 6c · 6d | +| Artigos históricos alvo do backfill | ~314.000 sem NER | +| Teto diário de tokens (final) | **6,0M** (limite AWS) · custo ~US$ 33/dia | +| Split de custo | canon ~US$ 26/dia · NER ~US$ 7/dia | +| Paralelismo | `--workers 10` · Jobs 4×/dia | +| Tokens poupados (PER short-circuit) | ~18M (~13k servidores sem Wikidata) | +| Threshold de dedup ORG | 0,62 → **0,85** (+ detector de sigla) | +| Falsos-positivos ORG (dry-run) | 8,8MB → 35 propostas corretas | +| Merges legítimos ORG | 35 (4.606 menções · 2.696 linhas do grafo) | +| QIDs preservados no dedup | 33 de 35 pares | +| Nós stale limpos do Neo4j | 36 (1143 → 1107) | +| Neo4j | 5 Community · `e2-standard-4` · ~US$ 80–120/mês | +| Testes (data-science, ao fim) | 262 · 0 falhas | +| Migrações | 021 · 022 · 023 (ledger de cota) | + +## Lições + +1. **Cota de LLM se descobre errando, não lendo a documentação.** O teto passou por 800k → 9,05M → 6,0M em horas. A única fonte de verdade sobre o limite diário do Bedrock foi o próprio `ThrottlingException` em produção — nenhum número documentado bateu. Governar custo pressupõe medir o consumo real (o ledger `llm_daily_usage`) e aceitar recalibrar. +2. **O governador precisa proteger o presente, não só o passado.** Reservar ≥20% da cota para o worker ao vivo e fazer o backfill parar graciosamente entre lotes evitou que o reprocessamento histórico afogasse o enriquecimento das notícias novas. +3. **Threshold não separa semântica.** Fusões legítimas ("Ministério da Educação (MEC)") e falsos-positivos ("Banco Central" vs "Banco do Brasil") podem ter o mesmo Jaccard. Foi preciso um detector determinístico de sigla com gate de subsequência — e ainda assim o path (b) explodiu em 8,8MB de lixo até ser removido. Em dados reais, o caminho mais esperto é o mais perigoso. +4. **Valide o dedup contra produção antes de aplicar.** Rodar `--dry-run` contra o banco real revelou os self-matches, as colisões de sigla e as 4.606 menções que ficariam órfãs — tudo antes de qualquer escrita. A retroatividade só é segura depois que o dry-run está limpo. +5. **Identidade tem hierarquia: o QID sempre vence.** A seleção de target por contagem de aliases teria apagado o QID em 33 de 35 merges. Ancorar a regra Wikidata-wins em cada ponto de fusão (dedup e `add_alias`) preserva a âncora linked-data que dá sentido ao grafo. +6. **Um grafo derivado precisa saber esquecer.** Sincronização MERGE-only acumula nós stale quando a origem funde entidades continuamente. O cleanup de nós fora do conjunto corrente — com guard contra fetch vazio — é o que mantém o Neo4j fiel ao Postgres sem intervenção manual. + +Ao fim deste ciclo, o gov.br deixou de ser uma lista de notícias e entidades para virar uma **rede navegável**, alimentada por um corpus histórico que finalmente fala a mesma língua canônica do presente. Um grafo consistente e uma base reprocessada não são um fim em si — são o substrato sobre o qual um agente pode raciocinar. É exatamente para lá que o próximo passo do arco aponta: colocar um investigador automático a percorrer essa rede. + +--- + +*Série **A evolução da plataforma DGB (jun–jul 2026)** · post 2 de 4.* +*Post anterior: [Entidades que viram conhecimento](2026-06-11-entidades-canonicas-lente-semantica.md) · Próximo post: [Gobus MCP: um agente que investiga o gov.br](2026-06-30-gobus-mcp-agente-govbr.md).* diff --git a/docs/blog/posts/2026-06-30-gobus-mcp-agente-govbr.md b/docs/blog/posts/2026-06-30-gobus-mcp-agente-govbr.md new file mode 100644 index 0000000..3ac8cf9 --- /dev/null +++ b/docs/blog/posts/2026-06-30-gobus-mcp-agente-govbr.md @@ -0,0 +1,178 @@ +--- +date: 2026-06-30 +authors: + - nitai +categories: + - MCP + - IA + - Infraestrutura + - Analytics +title: "Gobus MCP: um agente que investiga o gov.br — do Cloud Run ao forecast" +hide: + - toc +--- + +# Gobus MCP: um agente que investiga o gov.br — do Cloud Run ao forecast + +Em pouco menos de duas semanas — de 19 de junho a 1 de julho de 2026 — a plataforma DGB ganhou uma nova porta: o **Gobus MCP**, um servidor que abre todo o acervo enriquecido de notícias do governo a agentes de IA como o Claude. Não é mais um endpoint humano; é uma interface para uma máquina que raciocina. Com ele, um agente passou a poder perguntar "o que o MEC comunicou esta semana?", "essa política está em qual fase?", "há algum pico anômalo de cobertura?" e "quais temas devem crescer nos próximos dias?" — e responder consultando dados reais, não a própria memória. Este post conta o nascimento do Gobus: as quatro queries GraphQL que o alimentam, o provisionamento no Cloud Run, o site de documentação, as duas fases de capacidades analíticas, e — o fio mais honesto da história — a saga do transporte, um bug de sessão que só apareceu quando subagentes começaram a usar o servidor de verdade. + + + +## O que é MCP, e por que o gov.br precisa dele + +O **Model Context Protocol (MCP)** é um protocolo aberto que deixa um agente de IA usar ferramentas e consultar dados externos de forma padronizada e segura — em vez de cada integração ser costurada à mão, o agente "descobre" o que pode fazer e chama funções bem definidas. Na prática: o Claude conectado ao Gobus enxerga um catálogo de _tools_ (ações), _resources_ (dados) e _prompts_ (fluxos guiados), e escolhe o que usar para responder à pergunta do usuário. + +O desenho do Gobus tem uma regra de ouro: **o MCP consulta exclusivamente o GraphQL**. Nada de acesso direto a Postgres, Typesense ou Neo4j. O servidor MCP é uma camada fina de tradução — a inteligência de dados continua vivendo atrás do `graphql-api`, o caminho único consolidado no [post anterior](2026-06-18-grafo-entidades-backfill-neo4j.md). + +``` + Claude / agente de IA + │ MCP (tools · resources · prompts) + ▼ + ┌──────────────────┐ + │ Gobus MCP │ Cloud Run · southamerica-east1 + │ (FastMCP) │ SA dedicada: run.invoker (mínimo) + └────────┬─────────┘ + │ GraphQL (ÚNICO caminho de leitura) + ▼ + ┌──────────────────┐ + │ graphql-api │ + └────────┬─────────┘ + │ + ┌─────┼───────────┬────────────┐ + ▼ ▼ ▼ ▼ + Postgres Typesense Neo4j embeddings +``` + +## As queries que alimentam o Gobus + +Antes do servidor existir, o GraphQL precisou aprender a falar a língua da análise. Foram adicionadas **4 novas queries** ([graphql-api#20](https://github.com/destaquesgovbr/graphql-api/pull/20)), cada uma sustentando um tipo de investigação que o agente faria: + +- **`agencyAnalytics`** — métricas de publicação por agência e período (via `DATE_TRUNC GROUP BY` no Postgres): volume, sentimento e legibilidade, na granularidade DAY / WEEK / MONTH. +- **`trendingThemes`** — temas em crescimento, comparando duas janelas do Typesense (recente vs. baseline), com `growth_score = window_daily / baseline_daily`. +- **`entityCoverage`** — série temporal de menções de uma entidade canônica por agência (`news_entities JOIN news`). +- **`entitySearch`** — resolução _fuzzy_ de nomes de entidade: match exato em `entity_alias` em UNION com `pg_trgm` sobre o `entity_registry`, ordenado por confiança. + +Essas quatro queries são o pré-requisito de tudo o que veio depois — o Gobus só é tão capaz quanto o schema que consulta. A entrega chegou com **13 testes** passando localmente cobrindo os novos resolvers de analytics e de entidades. + +## Do Terraform ao ar: o Cloud Run e os quatro fixes de infra + +O servidor foi provisionado inteiramente por Terraform ([infra#206](https://github.com/destaquesgovbr/infra/pull/206)): um serviço Cloud Run `destaquesgovbr-gobus-mcp` em `southamerica-east1`, com uma **service account dedicada** de permissão mínima — apenas `roles/run.invoker` na `graphql-api`, nada além. Um Artifact Registry próprio (com imagem placeholder, para o CI/CD do repo `gobus-mcp` substituir), a URL da `graphql-api` injetada por referência (sem hardcode), acesso público via HTTPS (a autenticação fica a cargo do cliente MCP) e **sem `min_instances`** — escala a zero quando ninguém está investigando. + +O primeiro obstáculo apareceu no CI, não na aplicação. O deploy falhava com `Permission 'iam.serviceAccounts.getAccessToken' denied`: o repositório `gobus-mcp` simplesmente não estava registrado no **Workload Identity Federation** como caller autorizado. A correção seguiu o mesmo padrão de todos os outros repos e liberou o primeiro deploy real ([infra#207](https://github.com/destaquesgovbr/infra/pull/207)). + +Depois vieram três fixes que só a operação revelou — o tipo de detalhe que nenhum tutorial antecipa: + +!!! warning "Cada `terraform apply` desfazia o deploy" + A imagem do serviço voltava sozinha para o placeholder `hello:latest` a cada `apply`, apagando o que o CI/CD havia publicado — a revisão em produção rodava o placeholder apesar de existir imagem real no Artifact Registry. A causa: o Terraform gerencia a **configuração** do serviço, mas o CI/CD gerencia a **imagem**. A solução é o padrão canônico para esse conflito — `lifecycle { ignore_changes = [template[0].containers[0].image] }` ([infra#209](https://github.com/destaquesgovbr/infra/pull/209)). O mesmo PR removeu a env var `MCP_TRANSPORT=sse`, já ignorada pelo `server.py` desde o fix de uma race condition — o transporte passou a ser determinado pelo `PORT` que o Cloud Run injeta. + +Os outros dois fixes de infra são inseparáveis da história do transporte — e é para ela que vamos agora. + +## A saga do transporte: um 404 que só os subagentes viam + +Este é o fio mais instrutivo da entrega. O Gobus nasceu com transporte **SSE** (Server-Sent Events), e SSE é _stateful_: cada cliente abre uma sessão cujo ID vive na **memória de uma instância**. Isso tem uma consequência imediata em Cloud Run — se houver mais de uma instância, o `POST /messages/` pode cair numa instância diferente da que abriu o `GET /sse`, e o protocolo estoura com `-32602`. A defesa foi limitar o serviço a **`max_instance_count = 1`** ([infra#210](https://github.com/destaquesgovbr/infra/pull/210)): uma instância só, sessão sempre em casa. + +Funcionou — até os subagentes entrarem em cena. + +!!! warning "HTTP 404 — Could not find session" + Subagentes que faziam **múltiplas chamadas MCP sequenciais** falhavam na segunda com `404 Could not find session` ([gobus-mcp#4](https://github.com/destaquesgovbr/gobus-mcp/issues/4)). O padrão era cruel: a primeira chamada estabelecia a sessão e funcionava; entre uma chamada e outra o stream SSE de um subagente efêmero fechava; a segunda chamada não achava mais a sessão. Na sessão principal do Claude Code tudo ia bem, porque o cliente mantém o stream aberto de forma persistente. Além disso, todo deploy invalidava todas as sessões ativas de uma vez. + +A primeira reação foi um paliativo de infra: o timeout default do Cloud Run é de **300s (5 min)**, e ele encerrava a stream SSE justamente durante sessões de subagente com chamadas espaçadas. O timeout foi elevado para **3600s**, o máximo suportado pela plataforma ([infra#211](https://github.com/destaquesgovbr/infra/pull/211)). Ajudou, mas não curou a doença — o problema não era o tempo, era o **estado em memória**. + +O plano de cura óbvio era migrar para **streamable-http**, o transporte _stateless_ do FastMCP 3.x, onde cada request é independente. Mas a primeira tentativa (commit `8588d02`) bateu num muro de compatibilidade: o FastMCP 3.x fala a spec MCP **2025-03-26** (baseada em POST), enquanto o cliente do Claude Code daquele momento falava a spec **2024-11-05** (GET + SSE). Incompatibilidade total — o servidor novo não conversava com o cliente existente. + +A saída foi engenhosa: **servir os dois protocolos ao mesmo tempo** ([gobus-mcp#5](https://github.com/destaquesgovbr/gobus-mcp/pull/5)). Com o salto de FastMCP 2.9 → 3.x e a flag `stateless_http=True`, o `/mcp` passou a processar cada POST de forma independente — sem session ID, sem expiração. E o `/sse` legado foi mantido para compatibilidade, implementado manualmente com um `SseServerTransport` registrado como `custom_route` (o FastMCP 3.x não cria o `/sse` sozinho): + +| Endpoint | Spec | Comportamento | +|---|---|---| +| `/mcp` | 2025-03-26 | Stateless HTTP: cada POST independente, sem sessão | +| `/sse` | 2024-11-05 | SSE stream + POST `/messages` (backward-compat) | + +O `.mcp.json` passou a apontar para `/mcp`, com o `/sse` como rede de segurança caso o cliente ainda não suportasse a spec nova. A entrega chegou com **48 testes** unitários verdes. + +E o desfecho — o mais honesto de todos. Depois de toda essa engenharia de transporte HTTP, a resposta prática para o Claude Code acabou sendo **rodar o MCP em modo `stdio` local**, porque os endpoints HTTP ainda tinham bugs no CLI. A issue [gobus-mcp#4](https://github.com/destaquesgovbr/gobus-mcp/issues/4) foi fechada como _wontfix_, com o `stdio` local documentado como transporte recomendado ([gobus-mcp#6](https://github.com/destaquesgovbr/gobus-mcp/pull/6)). O trabalho no dual transport não foi perdido — deixou o servidor pronto para o dia em que o HTTP stateless for a via padrão, e liberou conceitualmente o `max_instance_count=1`. Mas a lição ficou: **a arquitetura mais elegante nem sempre é a que o cliente do dia consegue usar.** + +!!! tip "Para conectar no Claude Code hoje" + O caminho recomendado é `stdio` local — sem sessão que expira, sem stream que cai entre chamadas de subagente. Os endpoints HTTP (`/mcp` e `/sse`) continuam disponíveis para clientes remotos. + +## Fase 1 e Fase 2: de leitor a analista + +Com o transporte domado, o Gobus deixou de ser só um leitor de notícias e virou um analista. Duas fases, ambas construídas com **TDD obrigatório** (testes antes da implementação) e ancoradas nos achados do EXPERIMENTO_V3. + +**Fase 1** ([gobus-mcp#6](https://github.com/destaquesgovbr/gobus-mcp/pull/6)) trouxe capacidades de legibilidade e de políticas públicas: + +- `gobus_get_readability_recommendations(agency_key, days, limit)` — diagnóstico de legibilidade por agência, com o gap até a meta Flesch e recomendações de estilo. +- `gobus_get_policy_lifecycle(policy_name, date_from)` — o ciclo de vida comunicacional de uma política pública: fases, âncoras narrativas e em que fase ela está agora. +- Um MCP App novo, `ui://readability-dashboard` — um dashboard HTML/SVG autocontido, sem CDN externo. + +**Fase 2** ([gobus-mcp#7](https://github.com/destaquesgovbr/gobus-mcp/pull/7)) fechou o arco do título — do Cloud Run ao **forecast** — com 3 tools e 2 resources: + +- `gobus_detect_anomalies(sensitivity)` — detecta **picos sustentados** (temas que crescem em ambas as janelas 3d/21d e 7d/28d) e **cobertura concentrada** (entidades com alto `volumeRatio` e poucas agências cobrindo), com limiares por sensibilidade high / medium / low. +- `gobus_forecast_trends(horizon_days, limit)` — um score composto ponderado sobre 3 janelas (3d / 7d / 21d), com **momentum** (acelerando / desacelerando / estável) e **confiança** (nº de janelas que concordam). Traz até uma nota sobre o viés de borda de fim de semana na janela de 3 dias. +- `gobus_score_article(unique_id)` — uma nota editorial de 0 a 10, combinando **legibilidade (50%) + concisão (30%) + densidade de entidades (20%)**, comparada ao benchmark de 90 dias da própria agência. +- `gobus://readability-report` — JSON de legibilidade por agência, ordenado pelo gap até a meta (Flesch 50). +- `gobus://health/pipelines` — health-check dos pipelines frágeis (trendingScore, sentimento, legibilidade): `OK | DEGRADED | DEAD`. + +A Fase 2 rendeu **15 testes novos**, chegando a **90 no total** (red→green). E deixou uma armadilha documentada: o blueprint pedia `trendingThemes { label baselineCount }`, mas a introspecção do schema real mostrou `themeLabel` e `baselineDailyAvg`. As queries foram escritas com os nomes reais — porque um blueprint desatualizado quebra em runtime, não em tempo de build. + +## O agente precisa aprender a usar as ferramentas + +Ter tools não basta. Uma investigação sobre o estado da arte de agentes que consomem MCP (jun/2026) apontou um problema surpreendente: **o MCP entrega o _quê_, mas não o _quando_ e o _como_** ([gobus-mcp#2](https://github.com/destaquesgovbr/gobus-mcp/issues/2)). Um agente "limpo", sem system prompt, conectado ao Gobus tem acesso a todas as tools — mas não sabe encadeá-las na ordem certa. + +O dado que mais chama atenção veio de um paper (arxiv 2602.14878): **97,1% das descrições de tools MCP têm problemas**, e **56% não descrevem sequer o propósito com clareza**. Como a string de `description` é o principal sinal que o modelo usa para escolher a tool e os argumentos, descrições vagas levam direto a chamadas erradas. A issue estabeleceu quatro alavancas de melhoria: + +1. **Auditar as descrições** das tools contra 6 componentes — propósito, parâmetros, retorno, efeitos colaterais, exemplos e restrições — com atenção especial a pré-condições (ex.: `resolve_entity` é pré-requisito de qualquer operação de entidade). +2. **Uma skill host-side** (`.claude/skills/gobus.md`) que ensina o agente _quando_ acionar cada fluxo — o mapa UC → tool principal → tools auxiliares — sem depender de o usuário conhecer os nomes dos prompts. +3. **Controlar o tamanho do contexto** em respostas grandes — um `max_nodes` (default 20) em `get_entity_network` e um `summary_only` em `get_entity_profile`. +4. **Instruir paralelismo explícito** nos prompts — chamadas independentes (como `search_news` + `get_agency_analytics`) podem rodar em paralelo, e o prompt deve dizer isso. + +É a diferença entre publicar uma API e projetar uma **experiência de agente**. + +## A casa da documentação + +Um servidor para IA precisa de documentação para humanos. O repo `gobus-mcp` ganhou um site MkDocs Material co-localizado, no mesmo padrão da `graphql-api` — **20 páginas** cobrindo as 7 tools, 3 resources e 4 prompts, além de casos de uso, arquitetura e deploy, com build limpo sob `mkdocs build --strict` ([gobus-mcp#1](https://github.com/destaquesgovbr/gobus-mcp/pull/1)). E a documentação central ganhou um módulo dedicado, com diagrama Mermaid do fluxo Claude ↔ Gobus MCP ↔ graphql-api, tabela de capacidades e snippets de conexão (HTTP em produção e `stdio` local) ([docs#53](https://github.com/destaquesgovbr/docs/pull/53)). + +!!! note "As contagens crescem por fase" + O site (7 tools · 3 resources · 4 prompts) retrata o Gobus no seu lançamento. As Fases 1 e 2 acrescentaram 5 tools e 2 resources a essa superfície — a documentação profunda acompanha em [destaquesgovbr.github.io/gobus-mcp](https://destaquesgovbr.github.io/gobus-mcp/). + +## Antes e depois + +| | Antes | Depois | +|---|---|---| +| Acesso de IA ao acervo gov.br | inexistente | servidor MCP (tools · resources · prompts) | +| Caminho de dados do MCP | — | só GraphQL (sem Postgres/Typesense/Neo4j direto) | +| Transporte | SSE stateful, sessão em memória | `/mcp` stateless + `/sse` legado; `stdio` local para o CLI | +| Subagentes com N chamadas | 404 na 2ª chamada | funciona (stdio / stateless) | +| Timeout Cloud Run | 300s (matava a stream) | 3600s (máximo) | +| Deploy vs. Terraform | `apply` resetava a imagem | `ignore_changes` na imagem | +| Capacidades | leitura de notícias | + legibilidade, políticas, anomalias, forecast, score | + +## Números + +| Métrica | Valor | +|---|---| +| Novas queries GraphQL | **4** ([graphql-api#20](https://github.com/destaquesgovbr/graphql-api/pull/20)) | +| Tools MCP | 7 iniciais · +2 na Fase 1 · +3 na Fase 2 | +| Resources · Prompts | 3 (+2 na Fase 2) · 4 | +| Testes | 13 (graphql-api) · 48 (dual transport) · **90** no total (gobus-mcp) | +| Specs MCP suportadas | `2025-03-26` (/mcp) + `2024-11-05` (/sse) | +| Timeout Cloud Run | 300s → **3600s** | +| Instâncias Cloud Run | `max_instance_count = 1` (SSE stateful) | +| Páginas de documentação | **20** ([gobus-mcp#1](https://github.com/destaquesgovbr/gobus-mcp/pull/1)) | +| Qualidade de descrições de tools (paper) | 97,1% com problemas · 56% sem propósito claro | +| Região · Runtime | `southamerica-east1` · Cloud Run · FastMCP 3.x | + +## Lições + +1. **A regra "só GraphQL" pagou dividendos.** Manter o MCP sem acesso direto aos bancos fez do servidor uma camada fina e substituível — toda a lógica de dados continua num único ponto auditável, e o Gobus herda de graça tudo o que o `graphql-api` já sabe. +2. **O bug de sessão só existia com o usuário real.** SSE stateful funcionava na sessão principal e desmoronava em subagentes efêmeros. Nenhum teste unitário pegaria isso — foi preciso um agente de verdade, fazendo chamadas espaçadas, para o `404 Could not find session` aparecer. +3. **A arquitetura elegante nem sempre é a viável hoje.** O dual transport foi a solução tecnicamente correta, mas o que destravou o Claude Code foi o pragmático `stdio` local. Vale construir para o futuro _e_ desbloquear o presente. +4. **Terraform gerencia config; CI/CD gerencia imagem.** Sem `ignore_changes` na imagem, cada `apply` desfazia silenciosamente o deploy. É o padrão canônico quando duas automações tocam o mesmo recurso. +5. **Um blueprint desatualizado quebra em runtime.** Confiar no `themeLabel`/`baselineDailyAvg` reais em vez do `label`/`baselineCount` do documento evitou uma falha que só apareceria com o agente já em produção — a introspecção do schema é a fonte da verdade. +6. **Dar tools ao agente é metade do trabalho.** As descrições, a skill host-side e os hints de paralelismo são o que transforma um catálogo de funções numa experiência de agente que de fato investiga o gov.br. + +O Gobus saiu do zero a um analista com forecast em menos de duas semanas. O próximo passo do arco é colocar essas capacidades a serviço de perguntas reais — entidades em alta, o ciclo de vida das políticas públicas e o fechamento do trabalho iniciado aqui. + +--- + +*Série **A evolução da plataforma DGB (jun–jul 2026)** · post 3 de 4.* +*Post anterior: [O grafo do gov.br](2026-06-18-grafo-entidades-backfill-neo4j.md) · Próximo post: [Entidades em alta, políticas públicas e o fechamento do ciclo](2026-07-03-entidades-alta-politicas-fechamento.md).* diff --git a/docs/blog/posts/2026-07-03-entidades-alta-politicas-fechamento.md b/docs/blog/posts/2026-07-03-entidades-alta-politicas-fechamento.md new file mode 100644 index 0000000..c2be3b9 --- /dev/null +++ b/docs/blog/posts/2026-07-03-entidades-alta-politicas-fechamento.md @@ -0,0 +1,178 @@ +--- +date: 2026-07-03 +authors: + - nitai +categories: + - Analytics + - Portal + - Dados +title: "Entidades em alta, políticas públicas e o fechamento do ciclo" +hide: + - toc +--- + +# Entidades em alta, políticas públicas e o fechamento do ciclo + +Em cerca de três semanas (10 de junho a 3 de julho), a plataforma fechou o ciclo transformando pesquisa em produto. Um scorer de detecção de tendências subiu de NDCG@10 0.727 para 1.000 e virou uma seção "Entidades em Alta" na home; as políticas públicas deixaram de ser texto solto e passaram a objetos de primeira classe, com ontologia, gazetteer e queries próprias; e uma release consolidada de entidades (NER v2) foi para produção. Este é o quarto e último post da série — o momento em que a canonicalização, o grafo e o agente Gobus viram aplicação no ar. + + + +Os posts anteriores contaram a construção das fundações: a **canonicalização semântica** (entidades NER viradas conceitos canônicos, com Wikidata QID), o **grafo de entidades relacionadas** e a rede navegável, e o **agente Gobus** que investiga o gov.br via MCP. Este post é sobre o que se faz com tudo isso quando desce para o usuário final — aplicar essas fundações em funcionalidades concretas na tela de quem usa a plataforma. + +--- + +## 1. Entidades em Alta: da pesquisa ao card na home + +O arco desta funcionalidade é o melhor exemplo do ciclo inteiro: começa num loop de pesquisa autônoma, atravessa uma produtização com duas pegadinhas de deploy, e termina como um grid de cards na página `/noticias`. + +### O scorer que aprendeu sozinho a chegar em 1.0 + +A detecção de "entidades em alta" nasceu de um experimento no padrão **autoresearch do Karpathy** ([data-platform#188](https://github.com/destaquesgovbr/data-platform/pull/188)): um agente de IA edita **apenas** o arquivo `scorer.py`, um harness fixo (`evaluate.py`) mede NDCG@10 sobre **20 janelas históricas**, e o loop guarda só as mudanças que melhoram a métrica. O caminho até a nota perfeita não foi reto: + +``` + commit NDCG@10 decisão ideia + d20c654 0.7276 keep baseline: 0.6·vr + 0.4·ag + 94e8320 0.7208 discard multiplicativo: vr × ag + 0abb318 0.7253 discard log-transform: log1p(vr) × ag + d767722 0.9500 keep skip LOC + niche 1/(1+ba) + 3e971a8 0.9967 keep hard filter agency_stagnant + semantic_novelty + b46fb0b 0.9967 discard +new_edge_count — sem efeito + fea9acd 1.0000 keep hard filters volume_ratio>1.5 + baseline_agencies≤20 +``` + +O maior salto isolado (+22 pontos percentuais) veio de **filtrar entidades do tipo LOC** — localizações inflavam o ranking sem nunca serem positivos do oracle. A convergência final para 1.0 veio ao transformar os limites do próprio oracle (`volume_ratio > 1.5`, `baseline_agencies ≤ 20`) em filtros hard dentro do scorer. O score contínuo, usado só para ordenação interna, ficou: + +```python +niche = 1 / (1 + baseline_agencies) +score = 0.40·volume_ratio + 0.25·agency_growth + 0.20·niche·volume_ratio + 0.15·semantic_novelty +``` + +Um detalhe de infraestrutura foi decisivo para o loop ser tolerável: dois índices novos (`024_add_trend_detection_indexes.sql`) derrubaram o carregamento de cada snapshot de **~640s para ~40s por janela**, e o experimento `trend-detection-autoresearch` (ID 4) no MLflow — com `min_instances=1` para matar cold start — registrou cada corrida com parâmetros, métricas e o próprio `scorer.py` como artefato. + +### Do notebook para produção: DAG, job e as duas pegadinhas de deploy + +Um scorer perfeito num experimento não serve a ninguém. A produtização ([data-platform#190](https://github.com/destaquesgovbr/data-platform/pull/190)) criou a tabela `entity_trending_scores` (migração 025, já com a coluna `volume_ratio` que o portal precisaria), empacotou o job em `jobs/trend_detection/` e agendou um DAG `compute_entity_trending` rodando **4x ao dia** (`0 */6 * * *`), com testes TDD cobrindo scorer (8 casos), persistência (4) e a estrutura do DAG (5 casos via AST). + +Foi ao chegar no Composer que a realidade cobrou seu preço — duas vezes: + +- **O módulo que não foi junto** ([data-platform#191](https://github.com/destaquesgovbr/data-platform/pull/191)): o step `deploy-plugins` listava submódulos explicitamente e esquecera do `trend_detection`, gerando `ModuleNotFoundError` no DAG. Foi preciso adicionar o módulo à lista (e copiá-lo manualmente para o bucket para desbloquear na hora). +- **O embedding que dava timeout** ([data-platform#192](https://github.com/destaquesgovbr/data-platform/pull/192)): em produção, as queries de embedding no `load_snapshot()` puxavam centenas de MB de vetores 768-dim pelo Cloud SQL Auth Proxy, batendo timeout consistente de **~327s**. A saída foi um parâmetro `compute_embeddings=False` (agora o default): as queries de embedding são puladas e `semantic_novelty = 0.0`. Como esse sinal responde por apenas 15% do score, os outros 85% (volume, crescimento de agências, nicho) — os discriminadores primários — seguem intactos. + +### O resolver e o card + +Com a tabela populada, o [graphql-api#21](https://github.com/destaquesgovbr/graphql-api/pull/21) expôs o tipo `TrendingEntityResult` (7 campos) e o resolver `trendingEntities(limit)`, lendo `entity_trending_scores` via asyncpg com clamp defensivo a 50 — e retornando `[]` graciosamente enquanto a migração 025 não estiver aplicada. No portal, o [portal#267](https://github.com/destaquesgovbr/portal/pull/267) montou o componente `TrendingEntitiesSection`: um grid 2×3 de cards com badge de crescimento (↑N×), ícone por tipo de entidade e link para `/entidades/[id]`, inserido logo após o hero da home `/noticias`. Todo o caminho tem fallback gracioso para `[]`, e o teste E2E se auto-ignora enquanto a tabela estiver vazia durante o rollout. + +``` + DAG compute_entity_trending (4x/dia) + │ scorer NDCG@10 = 1.0 + ▼ + entity_trending_scores (Postgres) + │ resolver trendingEntities → [] se vazio + ▼ + graphql-api ──► portal /noticias ──► grid 2×3 "Entidades em Alta" (↑N×) +``` + +### E as páginas de entidade que mostravam zero artigos + +Paralelo a isso, um bug embaraçoso: clicar em **qualquer** entidade canônica (`Q...` ou `dgb_...`) em `/entidades/[id]` mostrava zero artigos. A causa era sutil — o caminho antigo filtrava por `entityCanonical` no Typesense, mas o campo `entity_canonical` dos documentos ainda não fora populado (o reprocessamento segue pendente, bloqueado em infra#198). Em vez de esperar o reprocessamento, a plataforma abriu um caminho independente: o resolver `entityArticles(entityId, page, limit)` ([graphql-api#22](https://github.com/destaquesgovbr/graphql-api/pull/22)) faz JOIN de `news_entities → news` direto no Postgres, dedup por `unique_id` e ordenação por `published_at DESC`, sem tocar no Typesense. Em staging, a entidade `Q5933752` passou a retornar **7 artigos** (antes: 0). O portal ([portal#268](https://github.com/destaquesgovbr/portal/pull/268)) passou a usar esse caminho quando há `canonicalId` presente, deixando o legado fuzzy-texto intacto para os demais casos. + +## 2. Políticas públicas viram objetos de primeira classe + +A segunda frente foi conceitual: tratar **políticas públicas** não como palavras que aparecem em notícias, mas como entidades canônicas com metadados próprios. + +### A ontologia e o gazetteer + +O [data-platform#195](https://github.com/destaquesgovbr/data-platform/pull/195) trouxe o `policy_gazetteer.csv` com **mais de 40 políticas prioritárias**, cada uma classificada por domínio (SOCIAL / ECONOMIC / HEALTH / EDUCATION / SECURITY / ENVIRONMENT / GOVERNANCE) e por fase do ciclo de vida (ANNOUNCED / REGULATION / IMPLEMENTATION / EVALUATION / ROUTINE). A migração `026_policy_ontology_seed.sql` popula esses metadados no campo JSONB `extra` do `entity_registry` e insere políticas ausentes — idempotente para as existentes (UPDATE), inserindo só as que faltam (INSERT ... WHERE NOT EXISTS), com rollback seguro. + +!!! note "O caso `dgb_taxa-selic`" + A migração resolve o bug UC-09: a taxa Selic, um dos conceitos econômicos mais centrais, tinha **0 artigos NER** porque nunca fora extraída como entidade. O gazetteer a insere com `confidence=0.9` e `provenance=gazetteer`, abrindo caminho para backfill manual — um lembrete de que o NER automático tem pontos cegos que só uma curadoria explícita cobre. + +### As queries + +Sobre essa base, duas queries. O [graphql-api#25](https://github.com/destaquesgovbr/graphql-api/pull/25) adicionou `policyDetails(entityId)` com o tipo `PolicyDetails`, expondo os metadados de ontologia de uma política (Fase 1). O [graphql-api#27](https://github.com/destaquesgovbr/graphql-api/pull/27) adicionou `policies(domain, lifecyclePhase, limit, offset)` — o tipo `PolicyListItem` inclui a contagem de artigos via LEFT JOIN com `news_entities` — para alimentar a futura página `/politicas`. No momento do merge, a migração 026 já estava em produção com **45 políticas** com domínio e fase preenchidos, e a query traz 7 testes com mocks (sem dependência de banco). + +### Dois campos editoriais de baixo custo + +Aproveitando a rodada, o [graphql-api#26](https://github.com/destaquesgovbr/graphql-api/pull/26) adicionou dois campos derivados de alto valor para análise editorial (UC-07), sem custo de query extra: + +- `Article.publicationHour` (`Int`): a hora 0–23, calculada em Python a partir de `publishedAt`. +- `Agency.isRepublisher` (`Boolean!`): derivado do `code`, marcando agências republicadoras (`agencia_brasil`, `tvbrasil`, `ebc`, `radioagencia_nacional`) — como single source of truth, sem duplicar a lógica nos dois resolvers que constroem `Agency`. + +A suíte completa terminou com **522 testes passando**. + +## 3. Polimento e a release + +O ciclo fechou com uma série de correções que separam "funciona" de "funciona bem", culminando numa release. + +### Analytics sem buracos + +O `agencyAnalytics` com `granularity=DAY` simplesmente **omitia** os dias sem publicações, criando buracos nos gráficos. O [graphql-api#23](https://github.com/destaquesgovbr/graphql-api/pull/23) introduziu `generate_series` no SQL diário, garantindo que todo dia do intervalo apareça com `article_count=0` via `COALESCE` (MONTH e WEEK seguem com `DATE_TRUNC`). O [graphql-api#24](https://github.com/destaquesgovbr/graphql-api/pull/24) consolidou esse gap-fill junto com `entityArticles`, `trendingEntities` e um fix de `topArticles` em `trendingThemes` — que era sempre retornado vazio e passou a popular até 5 artigos representativos por tema, com fallback silencioso. + +### `/busca` vazia listava um erro + +A tela `/busca` **sem termo e sem filtro** exibia "Ocorreu um erro ao carregar os resultados". A causa raiz, confirmada contra o backend real, era que o resolver `search` **rejeita query vazia** (`"Query must not be empty"`). O [portal#271](https://github.com/destaquesgovbr/portal/pull/271) passou a navegar cronologicamente via `listArticles` (a query `articles`, Postgres, ordenada por `published_at desc` com dedup) — o mesmo caminho do feed `/noticias` — e trocou o cabeçalho para "Explorar notícias" quando não há query. Validado com 122k+ artigos, 17/17 testes unitários e um E2E de regressão; o [portal#272](https://github.com/destaquesgovbr/portal/pull/272) promoveu a correção de `development` para `main`. + +### O 500 do clipping + +Acessar `/minha-conta/clipping` em produção devolvia "Application error" (digest `1082197660`). O [portal#269](https://github.com/destaquesgovbr/portal/pull/269) revelou duas camadas: + +- **Sintoma:** no `Promise.all` da página, `listFollowedListings()` era a **única** das 5 chamadas sem `try/catch` — as demais degradavam graciosamente, mas essa propagava `UNAUTHENTICATED` e derrubava o SSR. +- **Causa raiz:** o usuário tinha sessão NextAuth válida, mas o access token Keycloak enviado ao graphql-api estava expirado. Quando `refreshGovBrToken` falha, devolve o token velho com `error: 'RefreshAccessTokenError'` — porém esse erro nunca era exposto na sessão. Resultado: sessão viva com token morto. + +A correção atacou as duas: `getFollows()` ganhou `try/catch → []`, e a sessão passou a expor `session.error`, com o layout de rotas logadas redirecionando para re-login quando o refresh falha. + +### A release e os slides + +O [portal#270](https://github.com/destaquesgovbr/portal/pull/270) consolidou tudo numa release de `development` para `main` — **9 commits entre 11 e 24 de junho** — reunindo o arco inteiro de entidades (tela da notícia enriquecida, entidades canônicas, entidades relacionadas com visualização de rede, e "Entidades em Alta" na home) mais as correções de clipping e `/entidades`. Por fim, o [docs#54](https://github.com/destaquesgovbr/docs/pull/54) publicou um [deck de 6 slides](https://destaquesgovbr.github.io/docs/apresentacoes/secom-jun2026/) (16:9, HTML inline, ~100 KB, zero dependências externas) para a apresentação **DGB × SECOM de junho/2026**, cobrindo acervo, enriquecimento com IA, ferramentas para ASCOMs, a stack API+MCP+agente e o roadmap. + +!!! tip "Os slides desta apresentação" + O deck do **DGB × SECOM de junho/2026** está embutido abaixo e também + publicado em + ****. + +
+ +
+ +

⛶ Abrir os slides em tela cheia →

+ +## Antes e depois + +| | Antes | Depois | +|---|---|---| +| Entidades em alta | scorer de pesquisa (NDCG@10 0.727) | DAG 4x/dia, NDCG@10 1.0, card na home | +| Páginas `/entidades` | 0 artigos (dependia de reprocessamento) | artigos via Postgres, independente do Typesense | +| Políticas públicas | palavras soltas nas notícias | 45 entidades canônicas com domínio e ciclo de vida | +| `/busca` vazia | "Ocorreu um erro" | lista cronológica ("Explorar notícias") | +| `/minha-conta/clipping` | 500 com token expirado | degrada + re-login automático | + +## Números + +| Métrica | Valor | +|--------|-------| +| Scorer de tendências | NDCG@10 **0.727 → 1.000** · 20 janelas | +| Ganho isolado (filtrar LOC) | **+22 pp** | +| Load de snapshot (índices 024) | ~640s → **~40s** por janela | +| DAG `compute_entity_trending` | **4x/dia** (`0 */6 * * *`) | +| Timeout de embedding evitado | ~327s → skip (`compute_embeddings=False`) | +| Políticas na ontologia | gazetteer 40+ · **45** em produção | +| Testes (graphql-api) | **522** passando | +| Release do portal | 9 commits (11–24 jun) | + +## Lições + +1. **Filtro certo bate feature nova.** No scorer, o maior salto (+22 pp) não veio de um sinal novo, mas de **remover** entidades LOC do ranking; e a nota perfeita veio de copiar os limites do oracle como filtros hard. Modelar o domínio venceu adicionar complexidade. +2. **A pegadinha de produção mora no deploy, não no algoritmo.** Um scorer NDCG@10=1.0 quase não rodou por um `cp` esquecido e por um timeout de embedding de 327s. Produtizar é onde a pesquisa encontra o Cloud SQL Auth Proxy — reserve tempo para isso. +3. **Não espere o reprocessamento; abra um caminho independente.** As páginas `/entidades` foram destravadas lendo o Postgres direto, sem depender do campo do Typesense que segue pendente. Um caminho de dados alternativo entrega valor hoje sem bloquear a migração de amanhã. +4. **A curadoria cobre o ponto cego do NER.** A Selic tinha 0 artigos por nunca ser extraída; um gazetteer com `provenance` explícito conserta o que o modelo automático não vê. + +O ciclo que começou com entidades NER canônicas e um grafo de relações fecha aqui, no ar e nas mãos de quem usa: tendências detectadas automaticamente e exibidas na home, políticas públicas com identidade própria e o arco de entidades reunido numa release do portal. Os follow-ups conhecidos seguem abertos — o reprocessamento do `entity_canonical` no Typesense (infra#198) e a página `/politicas` que consome a query `policies()`. Da fundação semântica à aplicação consolidada, o arco desta série mostra o mesmo padrão repetido em cada frente: pesquisar com honestidade e produtizar com paciência — da canonicalização ao grafo, do agente à aplicação. + +--- + +*Série **A evolução da plataforma DGB (jun–jul 2026)** · post 4 de 4.* +*Post anterior: [Gobus MCP: um agente que investiga o gov.br](2026-06-30-gobus-mcp-agente-govbr.md).* +