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): 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:
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) 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): o step
deploy-pluginslistava submódulos explicitamente e esquecera dotrend_detection, gerandoModuleNotFoundErrorno 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): 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âmetrocompute_embeddings=False(agora o default): as queries de embedding são puladas esemantic_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 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 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) 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) 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 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.
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 adicionou policyDetails(entityId) com o tipo PolicyDetails, expondo os metadados de ontologia de uma política (Fase 1). O graphql-api#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 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 depublishedAt.Agency.isRepublisher(Boolean!): derivado docode, marcando agências republicadoras (agencia_brasil,tvbrasil,ebc,radioagencia_nacional) — como single source of truth, sem duplicar a lógica nos dois resolvers que constroemAgency.
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 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 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 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 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 revelou duas camadas:
- Sintoma: no
Promise.allda página,listFollowedListings()era a única das 5 chamadas semtry/catch— as demais degradavam graciosamente, mas essa propagavaUNAUTHENTICATEDe 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
refreshGovBrTokenfalha, devolve o token velho comerror: '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 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 publicou um deck de 6 slides (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.
Os slides desta apresentação
O deck do DGB × SECOM de junho/2026 está embutido abaixo e também publicado em https://destaquesgovbr.github.io/docs/apresentacoes/secom-jun2026/.
⛶ 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¶
- 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.
- A pegadinha de produção mora no deploy, não no algoritmo. Um scorer NDCG@10=1.0 quase não rodou por um
cpesquecido e por um timeout de embedding de 327s. Produtizar é onde a pesquisa encontra o Cloud SQL Auth Proxy — reserve tempo para isso. - Não espere o reprocessamento; abra um caminho independente. As páginas
/entidadesforam 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ã. - A curadoria cobre o ponto cego do NER. A Selic tinha 0 artigos por nunca ser extraída; um gazetteer com
provenanceexplí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.