diff --git a/.gitignore b/.gitignore index 5a44a8d..777dd93 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ Thumbs.db # Obsidian — vault local é camada de pensamento, não versionada (ver OBSIDIAN.md) .obsidian/ + +# Obsidian Bases (lixo local) +*.base diff --git a/.gitleaks.toml b/.gitleaks.toml index 83ec59f..b1958ed 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -18,14 +18,19 @@ keywords = ["portal_transparencia_api_key"] [[rules]] id = "generic-env-secret" -description = "Atribuição de segredo (api key, token, senha) com valor real" -regex = '''(?i)\b(api[_-]?key|apikey|secret|token|password|passwd|senha|client[_-]?secret)\b\s*[:=]\s*['"]?[a-z0-9_\-./+=]{12,}['"]?''' +description = "Atribuição de segredo (api key, token, senha) com valor literal entre aspas" +# Exige ASPAS no valor: segredos hardcoded são strings literais ("abc123..."). +# Referências de código como `= process.env.OPENAI_API_KEY` (sem aspas) não casam. +regex = '''(?i)\b(api[_-]?key|apikey|secret|token|password|passwd|senha|client[_-]?secret)\b\s*[:=]\s*['"][a-z0-9_\-./+=]{12,}['"]''' keywords = ["api_key","apikey","secret","token","password","passwd","senha"] # ── Allowlist: o que NÃO é vazamento (placeholders, exemplos, docs) ─────────── [allowlist] -description = "Placeholders e arquivos de exemplo — não são segredos reais" +description = "Placeholders, exemplos e referências de código — não são segredos reais" +# Testa a LINHA inteira (não só o trecho casado): assim `process.env.X` e +# placeholders em qualquer posição da linha são corretamente ignorados. +regexTarget = "line" paths = [ '''\.env\.example$''', '''\.gitleaks\.toml$''', @@ -34,5 +39,6 @@ paths = [ ] regexes = [ '''(?i)(your[_-]?|sua[_-]?|seu[_-]?|change[_-]?me|example|placeholder|dummy|fake|test|sample|xxx+|<[^>]+>|chave[_-]?aqui|cole[_-]?aqui|\.\.\.)''', - '''(?i)process\.env\.''', + '''process\.env\.''', + '''import\.meta\.env\.''', ] diff --git a/.planning/DESIGN_SYSTEM.md b/.planning/DESIGN_SYSTEM.md index 62e601f..ec85554 100644 --- a/.planning/DESIGN_SYSTEM.md +++ b/.planning/DESIGN_SYSTEM.md @@ -36,6 +36,8 @@ Agentes devem consultar esses arquivos antes de alterar UI, componentes, navega | stories | atualizações recentes | | explorar | navegar por entidades | +Reações estruturadas permitidas para objetos públicos: `apoio`, `parcial`, `contra`, `faltou informação`. Elas representam opinião do usuário e nunca podem aparecer como fato, fonte, evidência ou sinal de atenção. + ## Estrutura de pastas recomendada ```text @@ -185,6 +187,7 @@ Criar em `packages/ui/src/components/deolho`: - `ContextualActionDrawer` no mobile; - painel lateral de evidências no desktop, aberto quando o usuário seleciona evidência; - `sonner` para feedback curto, sem tom sensacionalista. +- ações sociais permitidas apenas em objetos públicos: acompanhar, reagir de forma estruturada e compartilhar; contribuição contextual curta exige moderação antes de publicação. O shell não deve depender de dados reais. Usar fixtures sintéticas claramente marcadas até a ingestão existir. @@ -292,6 +295,7 @@ Dado incompleto: bloco de limitação no card - Limitações devem usar `BlocoLimitacaoDado`. - Sinais de atenção devem usar `SinalAtencaoCard` e o aviso obrigatório. - Não criar comentários livres, likes, ranking moral ou interface de denúncia sensacionalista. +- Não gamificar reputação pública de usuário; hábitos e pontos devem ser privados e focados em leitura, evidência aberta e contribuição contextual útil. - Todo componente cívico deve ter Storybook com estados: padrão, carregando, vazio, erro, dado incompleto e fonte atrasada quando fizer sentido. ## O que deve ir em `packages/ui` diff --git a/.planning/HANDOFF.md b/.planning/HANDOFF.md index 6a15182..5eab7ad 100644 --- a/.planning/HANDOFF.md +++ b/.planning/HANDOFF.md @@ -5,8 +5,8 @@ Ultima atualizacao: 2026-05-30 ## Estado atual - Repositorio local: `D:\DeOlho` -- Branch atual: `codex/fundacao-civica-eventos` -- Observacao Git: `git pull --ff-only` nao executa nesta branch porque ela ainda nao tem upstream configurado. +- Branch atual: `codex/m0-fundacao-social-grafo` +- Observacao Git: `git pull --ff-only` foi executado em `main` antes da branch e estava atualizado; a branch local ainda nao tem upstream configurado. - Territorio da entrega: Americana/SP primeiro. - Escopo trabalhado: fundacao civica com eventos, evidencias, vinculos documentados, fluxos financeiros e cobertura de fontes. @@ -141,3 +141,201 @@ Fazer a fatia de produto "linha do tempo civica": 2. Tela de evento com fonte, trecho, data de publicacao/coleta, link original e limitacoes. 3. Filtros por rua/bairro/orgao/fornecedor/tipo de gasto. 4. Mapeadores dedicados da Camara para indicacoes/requerimentos de zeladoria. + +## Atualizacao M0: Fundacao Social-Civica + ADR do Grafo + +### Issue trabalhada + +M0: Fundacao Social-Civica + ADR do Grafo. + +### Entregue + +- Home reposicionada como radar social-civico com `EventoCivicoCard`, badges de tipo/fonte/confianca, limitacoes, evidencias, conexoes e contexto territorial Wikipedia com fallback. +- Compositor renomeado para `evento-compositor`, com contrato `EventoComposto`, suporte a `fornecedorDocumento` e `credorDocumento`, avisos obrigatorios em sancoes e degradacao visivel quando falta fonte/evidencia. +- Politica de Wikipedia isolada: enriquecimento permitido apenas para cidade, territorio, orgao, unidade de orgao e lugar publico. +- Reacoes civicas atualizadas para enum fechado: `apoio`, `parcial`, `contra`, `faltou_informacao`; continuam opiniao de usuario, sem persistencia nesta entrega. +- ADR criada em `.planning/research/GRAPH.md`: decisao recomendada e evolutiva, com PostgreSQL como fonte canonica/read model antes de qualquer Neo4j em producao. +- `.planning` e `docs/` atualizados para guardrails de rede social civica, contribuicoes contextuais moderadas, habitos privados e mapa de conexoes documentadas. + +### Verificacoes executadas + +- `pnpm --filter web test:compositor` -> OK. +- `pnpm -r typecheck` -> OK. +- `pnpm -r lint` -> OK. +- `pnpm -r build` -> OK. +- `git diff --check` -> OK; apenas avisos CRLF do Windows. +- Validacao visual com Playwright em `http://127.0.0.1:3000/` desktop e mobile: HTTP 200, 30 cards, fonte/confianca visiveis, sem linguagem publica de post/like/comentario livre, sem overflow horizontal raiz. + +### Pendencias conhecidas + +- `/explorar` ainda e a experiencia de descoberta atual; a substituicao por Mapa de conexoes fica para a proxima issue. +- A pasta/import path `components/feed` permanece como legado tecnico; a UI e os comentarios de produto foram ajustados para radar/evento civico. +- Sem Neo4j, comentarios persistentes, perfis, pontos ou gamificacao publica nesta entrega. + +### Proxima issue recomendada + +Criar `/explorar` como Mapa de conexoes com read model PostgreSQL: + +1. `getGraphSnapshot({ territory, rootEntityId, depth })`. +2. Lista acessivel + grafo compacto com painel de evidencia. +3. Limites iniciais: depth 1, maximo 150 nos e 300 arestas. +4. Toda aresta publica precisa de fonte, confianca e evidencia; sinais neutros mostram aviso de que nao indicam irregularidade. + +## Atualizacao: Agente leve de apresentacao e entrada em eventos + +### Entregue + +- Criado agente leve em `packages/collectors/src/agents/civic-post-agent.ts`. + - Usa OpenAI Responses API com `gpt-4.1-nano` e JSON schema quando ha cota. + - Consome input compacto e `max_output_tokens` baixo. + - Nunca cria fato, acusacao, relacao ou valor fora dos campos recebidos. + - Se a API falhar ou faltar cota, cai em fallback deterministico. +- Criado script `pnpm --filter @deolho/collectors format:posts -- --limit=N`. +- Criado script `pnpm --filter @deolho/collectors sync:civic` para mapear PNCP, TCE, Diario e formatar eventos recentes no final. +- Home agora ordena por `mais recentes` por padrao, com alternativas `em alta na semana` e `mais conectados`. +- `/explorar` foi unificado com o Inicio: redireciona para `/?ordem=semana`. +- Cards do radar agora levam para `/evento/[id]` via `ler tudo`. +- Criada pagina `/evento/[id]` com cabecalho visual, imagem contextual, evidencia principal, conexoes documentadas quando existirem e relacionados com fallback por categoria/fonte. + +### Verificacoes adicionais + +- `pnpm --filter @deolho/collectors typecheck` -> OK. +- `pnpm --filter @deolho/collectors format:posts -- --limit=5 --force` -> OK; atualizou 5 eventos recentes via fallback porque a API retornou `insufficient_quota`. +- Playwright em mobile e desktop: + - Home HTTP 200; + - filtros de ordenacao presentes; + - primeiro card abre `/evento/[id]`; + - detalhe HTTP 200 com evidencia e relacionados; + - `/explorar` redireciona para `/?ordem=semana`; + - sem overflow horizontal raiz e sem erros de console. + +### Pendencia operacional + +- A chave OpenAI foi criada e salva em `.env.local`, mas a conta/projeto retornou `insufficient_quota`. O agente esta pronto; para usar a camada OpenAI, e necessario habilitar credito/cota no projeto da Platform. + +## Atualizacao: IA visivel, leitura completa e recorte 2026+ + +### Entregue + +- O agente leve agora recebe trechos de `evidence` e produz `leituraCompleta`, alem de titulo, resumo, linha fina, etiquetas, conexoes textuais e aviso. +- `format:posts` passou a processar por padrao apenas eventos desde `2026-01-01`; aceita `--since=YYYY-MM-DD` para mudar o recorte. +- O metadata salvo em `entidades.apresentacao` agora registra `leituraCompleta`, `evidenciasLidas`, `desde`, modo/modelo, titulo/resumo original e data de geracao. +- O compositor preserva titulo/resumo ja organizados pelo agente e separa conexoes textuais do agente como `tipo: "ia"`, com tooltip proprio. +- Cards mostram selo visual quando o titulo/resumo foram organizados por IA/agente. +- A pagina `/evento/[id]` usa `BlocoExplicacaoIA` para exibir claramente a leitura organizada, metadados do agente, fonte usada e limitacao de que a camada apenas organiza texto. +- A pagina de detalhe ganhou bloco explicito de contexto visual Wikipedia com aviso de que nao e fonte do evento. + +### Verificacoes adicionais + +- `pnpm --filter @deolho/collectors typecheck` -> OK. +- `pnpm --filter web test:compositor` -> OK. +- `pnpm -r lint` -> OK. +- `pnpm -r build` -> OK. +- Browser local em `http://127.0.0.1:3000/` -> Home abre com ordenacao e contexto Wikipedia; como o Postgres local estava fora, validou estado vazio/fallback. + +### Pendencias operacionais + +- `pnpm --filter @deolho/collectors format:posts -- --limit=8 --force` nao conseguiu executar porque o Postgres local recusou conexao (`ECONNREFUSED`) e o Docker Desktop nao estava ativo. +- Quando o banco voltar, rodar `pnpm --filter @deolho/collectors format:posts -- --limit=80 --force` para preencher a nova leitura completa nos eventos recentes. + +## Atualizacao: Wikipedia estrategica, agente local e datas do Diario + +### Entregue + +- O radar "mais recentes" agora ordena por `coalesce(data_evento, published_at::date)` e nao usa mais `criado_em` como data publica. Eventos sem data publica caem no fim e nao entram no filtro "em alta na semana". +- O compositor mostra limitacao visivel quando evento nao tem data publica confiavel. +- O mapper do Diario Oficial adiciona limitacao `data publica` quando o atomo nao tem `edicaoDate`; ao remapear, esses eventos deixam claro que nao devem ser tratados como recentes. +- O agente de apresentacao ganhou provedores: + - `DEOLHO_POST_AGENT_PROVIDER=local` (padrao): deterministicamente local, zero tokens externos; + - `DEOLHO_POST_AGENT_PROVIDER=ollama`: LLM local via Ollama; + - `DEOLHO_POST_AGENT_PROVIDER=openai`: Responses API quando houver cota; + - `DEOLHO_POST_AGENT_PROVIDER=auto`: tenta Ollama e cai para local, sem usar token externo. +- A leitura completa aumentou para ate 3600 caracteres e usa ate 5 evidencias/trechos para explicar o documento em secoes. +- `/evento/[id]` agora sempre mostra uma leitura completa organizada: usa a apresentacao salva quando existir, ou gera leitura local ao vivo a partir das evidencias da pagina. +- Wikipedia agora e contextual por evento: tenta orgaos, empresas, pessoas publicas, leis e conceitos publicos citados, alem do territorio. +- Home passa a destacar contexto enciclopedico dos eventos recentes quando houver eventos, em vez de depender apenas de Americana. +- A UI explicita que Wikipedia contextualiza, mas nao e fonte do evento; a prova continua nas evidencias oficiais. + +### Configuracao local recomendada + +Para zero tokens externos: + +```bash +DEOLHO_POST_AGENT_PROVIDER=local +``` + +Para usar IA local com Ollama: + +```bash +ollama pull llama3.2:3b +DEOLHO_POST_AGENT_PROVIDER=ollama +DEOLHO_POST_AGENT_LOCAL_MODEL=llama3.2:3b +``` + +## Atualizacao: ficha publica da evidencia e Wikipedia restrita ao evento + +### Entregue + +- Home deixou de renderizar bloco Wikipedia generico. O enriquecimento enciclopedico fica dentro da pagina do evento, onde ha contexto e evidencias. +- Wikipedia por evento deixou de usar fallback automatico de Americana/SP e agora tenta apenas entidades, leis e conceitos diretamente citados no evento ou nas evidencias. +- A pagina `/evento/[id]` ganhou ficha publica da evidencia antes da leitura organizada: + - o que aconteceu; + - por que/objeto; + - quem esta envolvido; + - quando aconteceu/publicou/coletou; + - onde aconteceu; + - o que foi publicamente divulgado; + - lacunas e limitacoes. +- O read model de detalhe agora carrega territorio, datas da evidencia e metadados das relacoes documentadas para alimentar a ficha sem inferencia. +- Conexoes documentadas mostram fonte, confianca e aviso neutro quando nao forem fato oficial. + +### Guardrail registrado + +Wikipedia contextualiza apenas o que foi citado no evento. Nao prova fatos, nao cria vinculos e nao deve aparecer como contexto aleatorio na Home. + +### Verificacoes adicionais + +- `pnpm --filter web test:compositor` -> OK. +- `pnpm -r typecheck` -> OK. +- `pnpm -r lint` -> OK. +- `pnpm -r build` -> OK. +- `git diff --check` -> OK; apenas avisos CRLF do Windows. +- Browser local em `http://127.0.0.1:3000/`: + - Home com 129.736 eventos e sem bloco Wikipedia generico; + - primeiro evento abre `/evento/[id]`; + - detalhe mostra ficha publica da evidencia, leitura organizada, Wikipedia contextual do evento, trecho original e relacionados/conexoes; + - mobile 390x844 e desktop 1280x900 sem overflow horizontal e sem erros de console. + +## Atualizacao: resumo mensal do radar e cards mais explicativos + +### Entregue + +- `mais recentes` agora abre com uma leitura local do mês antes da lista normal: + - período, total de eventos, valor citado, fontes e categorias mais presentes; + - pontos úteis para entender o mês; + - recomendações de acontecimentos do próprio mês. +- Criado read model `getResumoMensalRadar`, com fallback para o mês mais recente com dados quando o mês corrente não tiver eventos. +- Criado componente `ResumoMensalRadarCard`. +- Cards de evento ficaram mais densos e legíveis: + - mídia visual temática local por tipo de evento; + - título maior; + - descrição mais contextual; + - mini-ficha com `Para que`, `Envolvidos`, `Onde` e `Quando`. +- O agente local de apresentação foi melhorado para contratos recentes: + - preserva fornecedor no título; + - corta campos colados por rótulos estruturados; + - usa evidência para objeto/finalidade; + - continua sem tokens externos por padrão. +- Reprocessados 120 eventos recentes com `pnpm --filter @deolho/collectors format:posts -- --limit=120 --force`. + +### Verificacoes adicionais + +- `pnpm --filter web test:compositor` -> OK. +- `pnpm --filter @deolho/collectors typecheck` -> OK. +- `pnpm -r lint` -> OK. +- `pnpm -r build` -> OK. +- Browser local: + - resumo mensal visível em `mais recentes`; + - recomendações do mês visíveis; + - cards com mini-ficha e imagens locais; + - mobile 390x844 e desktop 1280x900 sem overflow horizontal e sem erros de console. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md index 84f6861..3d6d829 100644 --- a/.planning/PROJECT.md +++ b/.planning/PROJECT.md @@ -51,9 +51,15 @@ Transformar um contrato público difícil de entender em uma página clara, rast - [ ] **OSS-02**: Pipeline de CI/CD aberto e reproduzível - [ ] **OSS-03**: Vault Obsidian público (via Obsidian Publish) com decisões, specs e contexto do projeto +**Fundação Social-Cívica — v0** +- [ ] **SOC-01**: Reações cívicas usam enum fechado (`apoio`, `parcial`, `contra`, `faltou informação`) e são sempre opinião de usuário, nunca fato oficial +- [ ] **SOC-02**: Contribuições contextuais curtas entram com moderação antes de publicação +- [ ] **SOC-03**: Acompanhar entidades e hábitos privados podem existir sem ranking público ou competição moral +- [ ] **GRAPH-01**: Mapa de conexões usa apenas nós e arestas documentadas, cada aresta com fonte, evidência e confiança + ### Out of Scope -- **Feed social / comentários abertos** — risco de virar arena política antes de ter moderação adequada; entra em v2 com regras estruturadas +- **Feed social / comentários abertos sem moderação** — risco de virar arena política; a direção permitida é social-cívica com reações estruturadas, contribuições contextuais moderadas e evidência obrigatória - **Chat com IA como feature principal** — a IA serve para explicar e resumir, não para ser o produto em si - **Dados em "tempo real"** — fontes públicas não atualizam instantaneamente; prometer isso é desonesto - **Dados municipais genéricos (v1)** — Americana-SP é o piloto municipal; outras prefeituras entram depois de provar a tese com Americana @@ -125,6 +131,8 @@ Transformar um contrato público difícil de entender em uma página clara, rast | Público-alvo amplo desde v1 (jornalistas + cidadãos + devs) | Não sacrificar profundidade por simplicidade nem vice-versa — três perfis têm necessidades complementares | — Pending | | Sem comentários abertos em v1 | Risco de desinformação e perseguição antes de moderação madura — comunidade estruturada vem depois | — Pending | | Separar pessoa pública × cidadão comum desde a arquitetura | Obrigação jurídica e ética — não é feature opcional | — Pending | +| Grafo de conexões começa como read model PostgreSQL | O banco já possui `entities` e `entity_relationships`; Neo4j só entra se consultas profundas ou analytics justificarem nova infraestrutura | — Proposed 2026-05-30 | +| Social-cívico não é rede social aberta | Reações são estruturadas, comentários são contribuições moderadas e gamificação é privada para não incentivar acusação ou competição moral | — Proposed 2026-05-30 | ## Evolution diff --git a/.planning/research/GRAPH.md b/.planning/research/GRAPH.md new file mode 100644 index 0000000..d4b1730 --- /dev/null +++ b/.planning/research/GRAPH.md @@ -0,0 +1,146 @@ +# ADR: Grafo de Conexões Cívicas + +Data: 2026-05-30 + +## Status + +Proposta para M0. Decisão recomendada: **manter PostgreSQL como fonte canônica e criar um read model de grafo antes de avaliar Neo4j em produção**. + +## Contexto + +O DeOlho já possui os blocos centrais de grafo: + +- nós canônicos em `entities`; +- arestas documentadas em `entity_relationships`; +- evidências em `evidence`; +- eventos públicos em `civic_events`. + +O objetivo de produto é visualizar Estado, órgãos, empresas, agentes públicos, contratos, sanções e vínculos documentados como rede de conexões. O risco central é transformar proximidade visual em acusação. Toda aresta pública precisa carregar fonte, evidência, confiança e limitação. + +## Opções Avaliadas + +### Opção A: PostgreSQL + read model de grafo + +Usar o PostgreSQL atual como fonte de verdade e expor um contrato `GraphNode`/`GraphEdge` para a UI. Consultas de vizinhança usam joins e, quando necessário, CTEs recursivas. A visualização pode ser implementada no front com biblioteca dedicada depois da M0. + +Vantagens: + +- sem nova infraestrutura; +- preserva a proveniência já modelada; +- menor risco de divergência entre banco relacional e grafo; +- suficiente para vizinhança de 1-2 saltos e exploração territorial inicial. + +Custos: + +- consultas profundas e algoritmos de grafo ficam menos naturais; +- precisa cuidar de limites para não renderizar redes grandes demais. + +### Opção B: Neo4j paralelo + +Adicionar Neo4j como índice de grafo sincronizado a partir do PostgreSQL. Neo4j usa modelo de grafo de propriedades e Cypher, que é uma linguagem própria para padrões de nós e relacionamentos. + +Vantagens: + +- consultas de caminho e padrões complexos ficam mais diretas; +- bom encaixe futuro para investigação de múltiplos saltos; +- pode viabilizar analytics de rede mais sofisticado. + +Custos: + +- nova infraestrutura, backup e observabilidade; +- risco de divergência com a fonte canônica; +- precisa de pipeline de sincronização idempotente e auditável; +- aumenta a superfície operacional antes de validar a experiência. + +### Opção C: Híbrido evolutivo + +Começar com read model em PostgreSQL e só promover Neo4j quando houver necessidade comprovada: latência ruim, consultas de caminhos complexos, analytics de rede recorrente ou volume que torne o read model insuficiente. + +Vantagens: + +- entrega UX de grafo rapidamente sem travar a arquitetura; +- preserva a opção Neo4j com contrato de dados estável; +- permite medir antes de adicionar custo. + +Custos: + +- exige disciplina para manter o contrato `GraphNode`/`GraphEdge` independente do backend; +- pode haver retrabalho no índice se o produto exigir Neo4j depois. + +## Decisão + +Adotar a **Opção C**. + +M0 não adiciona Neo4j. A próxima entrega deve criar um endpoint/read model em PostgreSQL que produza: + +```ts +type GraphNode = { + id: string; + tipo: "empresa" | "orgao" | "unidade_orgao" | "pessoa_publica" | "contrato" | "territorio" | "tema"; + label: string; + href: string; + fonte?: { id: string; estado?: string }; + confianca?: string; + media?: { imagemUrl?: string; alt?: string; credito?: string }; +}; + +type GraphEdge = { + id: string; + from: string; + to: string; + tipo: string; + label: string; + fonte: { id: string; url?: string }; + confianca: string; + evidenciaHref?: string; + pesoVisual?: number; + aviso?: "Sinal de atenção não indica irregularidade"; +}; +``` + +## Regras de Produto + +- Aresta sem evidência não entra no grafo público. +- Vínculo por sobrenome, proximidade visual ou inferência heurística não vira aresta pública. +- Termos como nepotismo ou conflito de interesse só aparecem após regra objetiva documentada ou revisão manual. Antes disso, usar “vínculo documentado” ou “sinal de atenção”. +- O grafo deve começar com 1 salto e expansão controlada. Não renderizar rede completa por padrão. +- Cada edge precisa permitir voltar à fonte ou ao evento que a sustenta. + +## Wire Textual para `/explorar` + +```text +[Header] +Mapa de conexões documentadas +Busca: empresa, órgão, agente público, contrato ou tema +Aviso: conexões mostram vínculos documentados; não indicam irregularidade. + +[Filtros] +Território | Tipo de nó | Tipo de vínculo | Fonte | Confiança + +[Visualização] +Grafo compacto com nó raiz e vizinhos de 1 salto +Cada aresta tem label curto e abre PainelEvidencia + +[Lista acessível] +Órgão A -- fornecedor documentado --> Empresa B +Fonte: PNCP | Confiança: Fonte oficial | Ver evidência + +[Estados] +Sem relações: "Nenhum vínculo documentado encontrado para estes filtros." +Fonte parcial: mostrar limitação antes do grafo. +``` + +## Próxima Issue Recomendada + +Criar `/explorar` como “Mapa de conexões” usando PostgreSQL: + +1. Read model `getGraphSnapshot({ territory, rootEntityId, depth })`. +2. UI com lista + grafo compacto: nós, arestas, filtros por tipo e painel de evidência. +3. Limites: depth inicial 1, máximo 150 nós e 300 arestas. +4. Estado vazio explicando ausência de relações sem sugerir irregularidade. + +## Referências + +- Neo4j Cypher docs: https://neo4j.com/docs/cypher/ +- PostgreSQL CTE/recursive queries: https://www.postgresql.org/docs/17/queries-with.html +- Cytoscape.js docs: https://js.cytoscape.org/ diff --git a/2026-05-30.md b/2026-05-30.md new file mode 100644 index 0000000..e69de29 diff --git "a/Sem t\303\255tulo.canvas" "b/Sem t\303\255tulo.canvas" new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ "b/Sem t\303\255tulo.canvas" @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index ee775b8..8672cf4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,8 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test:compositor": "tsx src/lib/evento-compositor.test.ts" }, "dependencies": { "@deolho/db": "workspace:*", @@ -30,6 +31,7 @@ "eslint": "^9", "eslint-config-next": "16.2.6", "tailwindcss": "^4", + "tsx": "^4.22.3", "typescript": "^5" } } diff --git a/apps/web/public/media/eventos/ato-normativo.png b/apps/web/public/media/eventos/ato-normativo.png new file mode 100644 index 0000000..1c14d22 Binary files /dev/null and b/apps/web/public/media/eventos/ato-normativo.png differ diff --git a/apps/web/public/media/eventos/audiencia.png b/apps/web/public/media/eventos/audiencia.png new file mode 100644 index 0000000..e2c19bf Binary files /dev/null and b/apps/web/public/media/eventos/audiencia.png differ diff --git a/apps/web/public/media/eventos/cargo-publico.png b/apps/web/public/media/eventos/cargo-publico.png new file mode 100644 index 0000000..bc9952f Binary files /dev/null and b/apps/web/public/media/eventos/cargo-publico.png differ diff --git a/apps/web/public/media/eventos/contratacao.png b/apps/web/public/media/eventos/contratacao.png new file mode 100644 index 0000000..f0a49e5 Binary files /dev/null and b/apps/web/public/media/eventos/contratacao.png differ diff --git a/apps/web/public/media/eventos/obra.png b/apps/web/public/media/eventos/obra.png new file mode 100644 index 0000000..f2030f7 Binary files /dev/null and b/apps/web/public/media/eventos/obra.png differ diff --git a/apps/web/public/media/eventos/pagamento.png b/apps/web/public/media/eventos/pagamento.png new file mode 100644 index 0000000..34b6157 Binary files /dev/null and b/apps/web/public/media/eventos/pagamento.png differ diff --git a/apps/web/public/media/eventos/receita.png b/apps/web/public/media/eventos/receita.png new file mode 100644 index 0000000..ffc7010 Binary files /dev/null and b/apps/web/public/media/eventos/receita.png differ diff --git a/apps/web/public/media/eventos/sancao.png b/apps/web/public/media/eventos/sancao.png new file mode 100644 index 0000000..a3f9203 Binary files /dev/null and b/apps/web/public/media/eventos/sancao.png differ diff --git a/apps/web/src/app/evento/[id]/page.tsx b/apps/web/src/app/evento/[id]/page.tsx new file mode 100644 index 0000000..35c93e5 --- /dev/null +++ b/apps/web/src/app/evento/[id]/page.tsx @@ -0,0 +1,624 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + ArrowLeft, + BookOpen, + CalendarDays, + ClipboardList, + Database, + ExternalLink, + FileText, + HelpCircle, + Link2, + MapPin, + SearchCheck, + Users, +} from "lucide-react"; +import { AppShell } from "@/components/app-shell/app-shell"; +import { TipoInformacaoBadge, FonteBadge, ConfiancaBadge } from "@/components/deolho/badges"; +import { BlocoExplicacaoIA, BlocoLimitacaoDado } from "@/components/deolho/blocos"; +import { EventoCivicoCard } from "@/components/feed/evento-civico-card"; +import { ReacaoCivica } from "@/components/feed/reacao-civica"; +import { SaveShare } from "@/components/feed/save-share"; +import { comporEvento, leituraLocalInterpretativa, type Conexao, type EventoComposto } from "@/lib/evento-compositor"; +import { dataExtensa, fonteLabel, getEventoDetalhe, type EventoDetalhe } from "@/lib/eventos"; +import { contextoWikipediaEvento } from "@/lib/wikipedia"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export default async function EventoPage({ params }: PageProps) { + const { id } = await params; + const detalhe = await getEventoDetalhe(id); + if (!detalhe) notFound(); + + const composto = comporEvento(detalhe.evento); + const evidenciaPrincipal = detalhe.evidencias[0]; + const apresentacao = composto.apresentacaoIA; + const leituraTexto = apresentacao?.leituraCompleta + ?? leituraLocalInterpretativa(detalhe.evento); + const leituraModo = apresentacao?.modo ?? "local"; + const contextosWiki = await contextoWikipediaEvento({ + evento: detalhe.evento, + evidencias: detalhe.evidencias, + limite: 5, + }); + const contextoHero = contextosWiki.find((item) => item.info.foto) ?? contextosWiki[0]; + const dataLabel = dataExtensa( + detalhe.evento.dataEvento ?? detalhe.evento.publishedAt?.toISOString().slice(0, 10) ?? null, + ); + const fichaPublica = montarFichaPublica(detalhe, composto); + + return ( + + + + voltar ao radar + + +
+
+
+ + + +
+
+

{composto.selo.emoji} {composto.selo.label}

+

{composto.titulo}

+ {composto.resumo && ( +

{composto.resumo}

+ )} + {composto.valorLabel && ( +

+ {composto.valorLabel} +

+ )} +
+
+ +
+ {composto.avisos.map((aviso) => ( +

+ {aviso} +

+ ))} + + + + {leituraTexto && ( + Boolean(item))} + limitacoes={[ + apresentacao + ? "Organiza texto e leitura; fatos, valores e vínculos continuam vindo da evidência oficial." + : "Leitura local ao vivo, sem tokens externos; fatos, valores e vínculos continuam vindo da evidência oficial.", + ]} + /> + )} + +
+
+

+ + Contexto enciclopédico · Wikipedia +

+

+ Personalizado pelas entidades, leis e conceitos citados. Não é fonte do evento; a prova continua nas evidências oficiais. +

+
+ {contextosWiki.length > 0 ? ( +
+ {contextosWiki.map((ctx) => ( +
+
+ {!ctx.info.foto && labelTipoWiki(ctx.tipo)} +
+
+

+ {labelTipoWiki(ctx.tipo)} · {ctx.label} +

+

{ctx.info.titulo}

+

+ {ctx.info.resumo ?? ctx.motivo} +

+
+ {ctx.info.url && ( + + abrir Wikipedia + + )} + {ctx.motivo} +
+
+
+ ))} +
+ ) : ( +

+ Nenhum contexto enciclopédico seguro encontrado para este evento. +

+ )} +
+ + {(composto.conexoes.length > 0 || detalhe.relacoes.length > 0) && ( +
+

Conexões

+ {composto.conexoes.length > 0 && ( +
+ {composto.conexoes.map((c, i) => )} +
+ )} + {detalhe.relacoes.length > 0 && ( +
+ {detalhe.relacoes.map((relacao) => ( +
+
+ + + {relacao.tipo} + + +
+ {relacao.descricao &&

{relacao.descricao}

} +
+ Fonte: {fonteLabel(relacao.sourceId)} + Confiança: {formatarConfiancaRelacao(relacao.confianca)} + {relacao.trustType !== "fato_oficial" && ( + + sinal neutro; não indica irregularidade + + )} +
+ {relacao.evidenceHref && ( + + ver evidência + + )} +
+ ))} +
+ )} +
+ )} + + {(evidenciaPrincipal || detalhe.evidencias.length > 1) && ( +
+

Provas e documentos

+ {evidenciaPrincipal && ( +
+
+

+ {evidenciaPrincipal.titulo} · {evidenciaPrincipal.metodoExtracao} + {evidenciaPrincipal.publishedAt ? ` · publicado em ${dataHoraCurta(evidenciaPrincipal.publishedAt)}` : ""} + {evidenciaPrincipal.fetchedAt ? ` · coletado em ${dataHoraCurta(evidenciaPrincipal.fetchedAt)}` : ""} +

+ {evidenciaPrincipal.sourceUrl && ( + + abrir fonte + + )} +
+ {evidenciaPrincipal.trecho && ( +

+ {evidenciaPrincipal.trecho} +

+ )} +
+ )} + {detalhe.evidencias.length > 1 && ( + + )} +
+ )} + + {composto.limitacoes.length > 0 && } +
+ + {composto.permiteAcoesCivicas && ( +
+ + +
+ )} +
+ + {detalhe.relacionados.length > 0 && ( +
+
+
+

Relacionados

+

Eventos conectados por entidade quando houver; fallback por mesma fonte/categoria.

+
+
+
+ {detalhe.relacionados.map((evento) => )} +
+
+ )} + +

+ {composto.fonte.nome} · {dataLabel} +

+
+ ); +} + +type FichaItemId = "aconteceu" | "porque" | "envolvidos" | "quando" | "onde" | "divulgado" | "valor"; + +interface FichaPublicaItem { + id: FichaItemId; + titulo: string; + valor: string; + detalhe?: string; +} + +interface FichaPublica { + itens: FichaPublicaItem[]; +} + +function FichaPublicaEvidencia({ ficha }: { ficha: FichaPublica }) { + return ( +
+
+

+ + Ficha pública da evidência +

+

+ Campos extraídos dos dados coletados, evidências e vínculos documentados. A ficha responde o que, por que, quem, quando e onde sem transformar conexão em acusação. +

+
+ +
+ {ficha.itens.map((item) => ( +
+
+ + {item.titulo} +
+

{item.valor}

+ {item.detalhe &&

{item.detalhe}

} +
+ ))} +
+ +
+ ); +} + +function FichaIcon({ id }: { id: FichaItemId }) { + const className = "h-3.5 w-3.5"; + if (id === "aconteceu") return ; + if (id === "porque") return ; + if (id === "envolvidos") return ; + if (id === "quando") return ; + if (id === "onde") return ; + if (id === "divulgado") return ; + return ; +} + +function montarFichaPublica(detalhe: EventoDetalhe, composto: EventoComposto): FichaPublica { + const evento = detalhe.evento; + const textosEvidencia = detalhe.evidencias + .map((ev) => ev.trecho?.replace(/\s+/g, " ").trim()) + .filter((trecho): trecho is string => Boolean(trecho)); + const textoBase = [evento.titulo, evento.resumo, ...textosEvidencia].filter(Boolean).join("\n"); + const envolvidos = listarEnvolvidos(detalhe, textoBase); + const quando = descreverQuando(detalhe); + const onde = descreverTerritorio(evento.territorio); + const porque = extrairMotivoOuObjeto(textoBase) ?? composto.resumo ?? "A fonte pública não separou justificativa ou objeto em campo próprio."; + const divulgado = detalhe.evidencias.length > 0 + ? `${detalhe.evidencias.length} evidência(s) coletada(s) de ${fonteLabel(evento.sourceId)}.` + : "A fonte principal foi registrada, mas ainda não há evidência granular ligada ao evento."; + + const itens: FichaPublicaItem[] = [ + { + id: "aconteceu", + titulo: "O que aconteceu", + valor: composto.titulo, + detalhe: resumoCurto(composto.resumo ?? evento.resumo ?? textosEvidencia[0] ?? "Evento público registrado no banco canônico.", 280), + }, + { + id: "porque", + titulo: "Por que / objeto", + valor: resumoCurto(porque, 220), + detalhe: "Campo organizado a partir do objeto, finalidade, ação orçamentária ou texto do documento quando a fonte não traz campo dedicado.", + }, + { + id: "envolvidos", + titulo: "Quem está envolvido", + valor: envolvidos.length > 0 ? envolvidos.slice(0, 5).join(" · ") : "Envolvidos não destacados em campo estruturado.", + detalhe: envolvidos.length > 5 ? `Mais ${envolvidos.length - 5} menção(ões) aparecem nas evidências abaixo.` : undefined, + }, + { + id: "quando", + titulo: "Quando aconteceu", + valor: quando.valor, + detalhe: quando.detalhe, + }, + { + id: "onde", + titulo: "Onde aconteceu", + valor: onde.valor, + detalhe: onde.detalhe, + }, + { + id: "divulgado", + titulo: "Base pública", + valor: divulgado, + detalhe: `Fonte principal: ${fonteLabel(evento.sourceId)}. Confiança: ${composto.confianca.replace(/_/g, " ")}.`, + }, + ]; + + if (composto.valorLabel) { + itens.splice(1, 0, { + id: "valor", + titulo: "Valor citado", + valor: composto.valorLabel, + detalhe: "Valor publicado pela fonte; não é juízo de mérito nem sinal automático.", + }); + } + + return { itens }; +} + +function listarEnvolvidos(detalhe: EventoDetalhe, textoBase: string): string[] { + const nomes = new Set(); + const entidades = (detalhe.evento.entidades ?? {}) as { + orgaoNome?: string | null; + orgaos?: Array<{ nome?: string | null; sigla?: string | null }>; + pessoas?: Array<{ nome?: string | null; papel?: string | null; cargo?: string | null }>; + }; + const push = (valor: string | null | undefined) => { + const limpo = limparValorExtraido(valor); + if (limpo && limpo.length >= 3) nomes.add(limpo); + }; + + push(entidades.orgaoNome); + for (const orgao of entidades.orgaos ?? []) push(orgao.sigla ? `${orgao.nome} (${orgao.sigla})` : orgao.nome); + for (const pessoa of entidades.pessoas ?? []) { + if (!pessoa.nome) continue; + push([pessoa.nome, pessoa.cargo ?? pessoa.papel].filter(Boolean).join(" — ")); + } + for (const relacao of detalhe.relacoes) { + push(relacao.fromLabel); + push(relacao.toLabel); + } + for (const label of ["Fornecedor", "Credor", "Contratado", "Contratada", "Empresa", "Órg[ãa]o", "Secretaria", "Quem"]) { + push(extrairCampo(textoBase, label)); + } + + return [...nomes].slice(0, 12); +} + +function descreverQuando(detalhe: EventoDetalhe): { valor: string; detalhe?: string } { + const evento = detalhe.evento; + const partes = [ + evento.dataEvento ? `data do evento: ${dataExtensa(evento.dataEvento)}` : null, + evento.publishedAt ? `publicado em ${dataHoraCurta(evento.publishedAt)}` : null, + evento.fetchedAt ? `coletado em ${dataHoraCurta(evento.fetchedAt)}` : null, + ].filter((item): item is string => Boolean(item)); + const evidenciasComData = detalhe.evidencias + .flatMap((ev) => [ev.publishedAt, ev.fetchedAt]) + .filter((data): data is Date => Boolean(data)); + const primeiraDataEvidencia = evidenciasComData[0]; + if (partes.length === 0 && primeiraDataEvidencia) { + return { + valor: `evidência datada em ${dataHoraCurta(primeiraDataEvidencia)}`, + detalhe: "O evento não trouxe data própria; a data veio da evidência coletada.", + }; + } + if (partes.length === 0) { + return { valor: "Sem data pública confiável.", detalhe: "Este evento não deve ser tratado como recente enquanto a fonte não trouxer data." }; + } + return { + valor: partes[0] ?? "Data pública registrada.", + detalhe: partes.slice(1).join(" · ") || undefined, + }; +} + +function descreverTerritorio(raw: unknown): { valor: string; detalhe?: string } { + if (!raw || typeof raw !== "object") return { valor: "Americana/SP", detalhe: "Território padrão da coleta local." }; + const territorio = raw as { + municipio?: string; + uf?: string; + bairro?: string; + endereco?: string; + mencoes?: Array<{ tipo?: string; nome?: string }>; + }; + const cidade = [territorio.municipio ?? "Americana", territorio.uf ?? "SP"].filter(Boolean).join("/"); + const mencoes = (territorio.mencoes ?? []) + .map((m) => [m.tipo, m.nome].filter(Boolean).join(": ")) + .filter(Boolean) + .slice(0, 4); + const detalhes = [territorio.endereco, territorio.bairro, ...mencoes].filter(Boolean); + return { + valor: cidade || "Americana/SP", + detalhe: detalhes.length > 0 ? detalhes.join(" · ") : "A fonte não trouxe rua, bairro ou equipamento público separado.", + }; +} + +function extrairMotivoOuObjeto(texto: string): string | null { + for (const label of ["Objeto", "Finalidade", "Justificativa", "Ação orçament[áa]ria", "Programa", "Descrição"]) { + const valor = extrairCampo(texto, label); + if (valor) return valor; + } + const primeiraFrase = texto.match(/[^.!?]+[.!?]?/)?.[0]?.trim(); + return primeiraFrase && primeiraFrase.length > 20 ? primeiraFrase : null; +} + +function extrairCampo(texto: string | null, label: string): string | null { + if (!texto) return null; + const re = new RegExp(`${label}\\s*:?\\s*([^\\n.]+?)(?:\\.\\s|\\.$|\\n|$)`, "i"); + return limparValorExtraido(re.exec(texto)?.[1]); +} + +function limparValorExtraido(valor: string | null | undefined): string | null { + if (!valor) return null; + const limpo = valor + .replace(/\s+/g, " ") + .replace(/\s+-\s+$/, "") + .trim(); + if (!limpo || /^não informado$/i.test(limpo)) return null; + return resumoCurto(limpo, 120); +} + +function resumoCurto(texto: string, max: number): string { + const limpo = texto.replace(/\s+/g, " ").trim(); + return limpo.length > max ? `${limpo.slice(0, max - 1).trimEnd()}…` : limpo; +} + +function formatarConfiancaRelacao(valor: string): string { + const numero = Number(valor); + if (!Number.isFinite(numero)) return valor || "não informada"; + return `${Math.round(numero * 100)}% documentada`; +} + +function EntidadeLink({ href, label }: { href: string | null; label: string }) { + if (!href) return {label}; + return ( + + {label} + + ); +} + +function dataHoraCurta(input: string | Date): string { + const data = input instanceof Date ? input : new Date(input); + if (Number.isNaN(data.getTime())) return String(input); + return data.toLocaleString("pt-BR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function tituloLeituraAgente(modo?: string): string { + if (modo === "openai") return "Leitura completa organizada por IA"; + if (modo === "ollama") return "Leitura completa organizada por IA local"; + return "Leitura completa organizada pelo agente local"; +} + +function metaModoAgente(modo?: string, modelo?: string | null): string { + if (modo === "openai") return modelo ?? "OpenAI"; + if (modo === "ollama") return modelo ? `Ollama · ${modelo}` : "Ollama local"; + if (modo === "local") return "modo local sem tokens"; + return "fallback local"; +} + +function ConexaoChip({ conexao }: { conexao: Conexao }) { + const emoji = iconeConexao(conexao.tipo); + const cls = "inline-flex items-center gap-1 rounded-full border border-foreground/15 bg-background/50 px-2.5 py-1 text-[11px] font-medium text-foreground/80 transition-colors hover:border-[var(--political)] hover:text-[var(--political)]"; + if (!conexao.href) { + return ( + + {emoji} {conexao.label} + + ); + } + if (/^https?:\/\//i.test(conexao.href)) { + return ( + + {emoji} {conexao.label} + + ); + } + return ( + + {emoji} {conexao.label} + + ); +} + +function iconeConexao(tipo: Conexao["tipo"]): string { + switch (tipo) { + case "empresa": return "🏢"; + case "pessoa_publica": return "👤"; + case "orgao": return "🏛️"; + case "termo": return "📖"; + case "evidencia": return "📄"; + case "ia": return "✨"; + default: return "•"; + } +} + +function labelTipoWiki(tipo: string): string { + const labels: Record = { + cidade: "cidade", + territorio: "território", + orgao: "instituição", + unidade_orgao: "órgão", + lugar_publico: "lugar", + empresa: "empresa", + pessoa_publica: "pessoa pública", + lei: "lei", + conceito_publico: "conceito", + }; + return labels[tipo] ?? "contexto"; +} diff --git a/apps/web/src/app/eventos/page.tsx b/apps/web/src/app/eventos/page.tsx index 7dae23f..695ea06 100644 --- a/apps/web/src/app/eventos/page.tsx +++ b/apps/web/src/app/eventos/page.tsx @@ -1,6 +1,6 @@ /** - * /eventos foi UNIFICADO no Início (/). Esta rota agora só redireciona, pra não - * ter duas superfícies de feed (feedback do Pedro: "tudo isso ser 1"). + * /eventos foi unificado no Início (/). Esta rota só redireciona para evitar + * duas superfícies públicas concorrentes. */ import { redirect } from "next/navigation"; diff --git a/apps/web/src/app/explorar/page.tsx b/apps/web/src/app/explorar/page.tsx index e395f9a..ccca7fb 100644 --- a/apps/web/src/app/explorar/page.tsx +++ b/apps/web/src/app/explorar/page.tsx @@ -1,236 +1,13 @@ -/** - * /explorar — reformulado depois do feedback do Pedro. - * - * Saiu o grid genérico "tipos de entidade". Entra DESCOBERTA: - * 1. Empresas em alta (CNPJs com mais menções no diário) - * 2. Atos em destaque (átomos top-score da semana) - * 3. Bairros de Americana (placeholder até dado real entrar) - * 4. Vereadores e Escolas (placeholders honestos — exigem novas fontes) - * - * Mantém a vibe Instagram-explore mas focado em civic, não social. - */ -import type { Metadata } from "next"; -import Link from "next/link"; -import { ArrowLeft, Building2, MapPin, GraduationCap, Users, Landmark } from "lucide-react"; -import { AppShell } from "@/components/app-shell/app-shell"; -import { AtomCard } from "@/components/feed/atom-card"; -import { EmptyState } from "@/components/feed/empty-state"; -import { GeoBreadcrumb } from "@/components/feed/geo-breadcrumb"; -import { getAtomsRanqueados, getTopCnpjs } from "@/lib/atoms"; -import { getPessoas, getOrgaos } from "@/lib/entidades"; -import { BAIRROS_AMERICANA } from "@/lib/civic-data"; +import { redirect } from "next/navigation"; -export const revalidate = 600; -export const metadata: Metadata = { - title: "Explorar — DeOlho", - description: "Descobrir empresas em alta, atos em destaque, bairros e mais.", -}; - -function formatarCnpj(d: string): string { - if (d.length !== 14) return d; - return `${d.slice(0, 2)}.${d.slice(2, 5)}.${d.slice(5, 8)}/${d.slice(8, 12)}-${d.slice(12)}`; +interface PageProps { + searchParams: Promise<{ cat?: string; ordem?: string }>; } -export default async function ExplorarPage() { - const [topCnpjs, destaques, topPessoas, topOrgaos] = await Promise.all([ - getTopCnpjs(8), - getAtomsRanqueados({ categoria: "tudo", limit: 3 }), - getPessoas(6), - getOrgaos(8), - ]); - - return ( - - - - voltar - - -
-

Explorar

-

- Descubra empresas, atos em destaque, lugares e pessoas públicas. -

-
- - - - {/* Atos em destaque (top 3 ranqueados) */} -
-

🔥 em destaque agora

-
- {destaques.map((a) => ( - - ))} -
- - ver tudo no radar - -
- - {/* Empresas em alta (CNPJs com mais menções no diário) */} -
-

- - empresas em alta -

- {topCnpjs.length === 0 ? ( - - ) : ( -
- {topCnpjs.map((e) => ( - - - {e.cnpj.slice(0, 2)} - -
-

- CNPJ -

-

{formatarCnpj(e.cnpj)}

-
-
-

- {e.mencoes} -

-

- {e.mencoes === 1 ? "menção" : "menções"} -

-
- - ))} -
- )} -
- - {/* Bairros de Americana */} -
-

- - bairros de Americana -

-
- {BAIRROS_AMERICANA.slice(0, 6).map((b) => ( - - {b} - - ))} -
-

- Filtro real por bairro entra quando os atos do diário forem geo-tageados. -

-
- - {/* Órgãos públicos — REAL (Secretarias, DAE, Guarda Municipal…) */} -
-

- - órgãos públicos -

- {topOrgaos.length === 0 ? ( - - ) : ( -
- {topOrgaos.map((o) => ( - -

{o.nome}

-

- {o.sigla ? `${o.sigla} · ` : ""}{o.mencoes} {o.mencoes === 1 ? "ato" : "atos"} -

- - ))} -
- )} -
- - {/* Agentes públicos citados — REAL */} -
-

- - agentes públicos citados -

- {topPessoas.length === 0 ? ( - - ) : ( - <> -
- {topPessoas.map((p) => ( - - - {p.nome.split(" ").map((w) => w[0]).slice(0, 2).join("")} - -
-

{p.nome}

-

{p.cargos[0] ?? p.papeis[0]}

-
-
-

{p.mencoes}

-

{p.mencoes === 1 ? "ato" : "atos"}

-
- - ))} -
- - sobrenomes em atos oficiais → - - - )} -
- - {/* Escolas — placeholder honesto */} -
-

- - escolas públicas -

- -
-
- ); +export default async function ExplorarPage({ searchParams }: PageProps) { + const sp = await searchParams; + const params = new URLSearchParams(); + params.set("ordem", sp.ordem === "recentes" ? "recentes" : "semana"); + if (sp.cat) params.set("cat", sp.cat); + redirect(`/?${params.toString()}`); } diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 3a46bd9..bcfc1bd 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,21 +1,24 @@ /** - * Início — o FEED CÍVICO de Americana, unificado. + * Início — radar social-cívico de Americana. * - * Uma rede social viva: cada acontecimento público (contrato, pagamento, ato, - * sanção) vira um POST legível e navegável, vindo do banco canônico (civic_events, - * ~130 mil eventos das fontes oficiais). O algoritmo de relevância (getEventosRanqueados) - * dá vida ao feed — dinheiro e recência sobem, os 124k pagamentos não afogam. - * - * Substitui as antigas superfícies separadas (início de átomos + /eventos): agora - * é UM lugar só, alimentado automaticamente pelo que os coletores trazem. + * Cada acontecimento publico vem do banco canonico, com fonte, confianca, + * limitacoes e conexoes navegaveis. A camada social fica limitada a reacoes + * estruturadas, acompanhar e compartilhar objetos publicos. */ import { Eye } from "lucide-react"; import { AppShell } from "@/components/app-shell/app-shell"; -import { PostCard } from "@/components/feed/post-card"; +import { EventoCivicoCard } from "@/components/feed/evento-civico-card"; +import { ResumoMensalRadarCard } from "@/components/feed/resumo-mensal-radar"; import { StoriesRow, type StoryItem } from "@/components/feed/stories-row"; import { GeoBreadcrumb } from "@/components/feed/geo-breadcrumb"; import { EmptyState } from "@/components/feed/empty-state"; -import { getEventosRanqueados, getEventosStats, type CategoriaEvento } from "@/lib/eventos"; +import { + getEventosRanqueados, + getEventosStats, + getResumoMensalRadar, + type CategoriaEvento, + type OrdemEventos, +} from "@/lib/eventos"; export const dynamic = "force-dynamic"; @@ -37,7 +40,7 @@ const CATS_VALIDAS: CategoriaEvento[] = [ ]; interface PageProps { - searchParams: Promise<{ cat?: string }>; + searchParams: Promise<{ cat?: string; ordem?: string }>; } export default async function HomePage({ searchParams }: PageProps) { @@ -45,12 +48,20 @@ export default async function HomePage({ searchParams }: PageProps) { const cat = (CATS_VALIDAS as readonly string[]).includes(sp.cat ?? "") ? (sp.cat as CategoriaEvento) : undefined; + const ordem: OrdemEventos = sp.ordem === "semana" || sp.ordem === "relevantes" + ? sp.ordem + : "recentes"; - const [eventos, stats] = await Promise.all([ - getEventosRanqueados({ categoria: cat, limit: 30 }), + const [eventos, stats, resumoMensal] = await Promise.all([ + getEventosRanqueados({ categoria: cat, limit: 30, ordem }), getEventosStats(), + ordem === "recentes" ? getResumoMensalRadar({ categoria: cat }) : Promise.resolve(null), ]); + // Não repetir na lista os eventos já destacados em "Comece por estes" do resumo. + const idsNoResumo = new Set((resumoMensal?.recomendados ?? []).map((e) => e.id)); + const eventosLista = eventos.filter((e) => !idsNoResumo.has(e.id)); + const stories: StoryItem[] = [ { id: "tudo", label: "tudo", href: "/", iniciais: "✨", ativo: !cat, bg: "bg-foreground/8", fg: "text-foreground", novo: !!cat }, { id: "contratacao", label: "contratos", href: "/?cat=contratacao", iniciais: "📋", ativo: cat === "contratacao" }, @@ -59,6 +70,12 @@ export default async function HomePage({ searchParams }: PageProps) { { id: "sancao", label: "sanções", href: "/?cat=sancao", iniciais: "⚠️", ativo: cat === "sancao" }, { id: "nomeacao_exoneracao", label: "cargos", href: "/?cat=nomeacao_exoneracao", iniciais: "👤", ativo: cat === "nomeacao_exoneracao" }, ]; + const ordemHref = (o: OrdemEventos) => `/${cat ? `?cat=${cat}&ordem=${o}` : o === "recentes" ? "" : `?ordem=${o}`}`; + const ordenacoes: Array<{ id: OrdemEventos; label: string }> = [ + { id: "recentes", label: "mais recentes" }, + { id: "semana", label: "em alta na semana" }, + { id: "relevantes", label: "mais conectados" }, + ]; return ( @@ -70,8 +87,8 @@ export default async function HomePage({ searchParams }: PageProps) {

{saudacao()} 👋

{eventos.length > 0 - ? `${stats.total.toLocaleString("pt-BR")} acontecimentos públicos. ${cat ? "Filtrando." : "Veja o que rolou na cidade."}` - : "Conecte o banco (DATABASE_URL) para o feed ganhar vida."} + ? `${stats.total.toLocaleString("pt-BR")} acontecimentos públicos. ${cat ? "Filtrando." : "Veja o que mudou na cidade."}` + : "Conecte o banco (DATABASE_URL) para o radar mostrar acontecimentos públicos."}

@@ -89,15 +106,38 @@ export default async function HomePage({ searchParams }: PageProps) { + + + {ordem === "recentes" && resumoMensal && eventos.length > 0 && ( +
+ +
+ )} +
- {eventos.length === 0 ? ( + {eventosLista.length === 0 ? ( ) : ( - eventos.map((e) => ) + eventosLista.map((e) => ) )}
diff --git a/apps/web/src/app/radar/page.tsx b/apps/web/src/app/radar/page.tsx index 5649879..9c6ae7e 100644 --- a/apps/web/src/app/radar/page.tsx +++ b/apps/web/src/app/radar/page.tsx @@ -1,7 +1,7 @@ /** - * /radar — feed completo de átomos (sem limite), ordenado por relevância. + * /radar — lista completa de átomos (sem limite), ordenada por relevância. * - * Stories são o único filtro — mesma estrutura que a Home. + * A trilha horizontal é o único filtro — mesma estrutura que a Home. */ import type { Metadata } from "next"; import Link from "next/link"; diff --git a/apps/web/src/app/ref/[slug]/page.tsx b/apps/web/src/app/ref/[slug]/page.tsx index b944da1..33f71fb 100644 --- a/apps/web/src/app/ref/[slug]/page.tsx +++ b/apps/web/src/app/ref/[slug]/page.tsx @@ -1,7 +1,7 @@ /** * /ref/[slug] — "tudo que se correlaciona com esta referência". * - * Quando um post no feed cita "Lei nº 6.274/2019", o usuário clica e + * Quando um evento público cita "Lei nº 6.274/2019", o usuário clica e * cai aqui: a lista de todos os átomos (no diário inteiro) que tocam essa * lei. Resposta direta ao pedido do Pedro: "clicar e ver tudo que * correlaciona com essa informação, com resumo breve". diff --git a/apps/web/src/components/app-shell/mobile-bottom-nav.tsx b/apps/web/src/components/app-shell/mobile-bottom-nav.tsx index 1c0b5bb..73fbfd1 100644 --- a/apps/web/src/components/app-shell/mobile-bottom-nav.tsx +++ b/apps/web/src/components/app-shell/mobile-bottom-nav.tsx @@ -4,9 +4,8 @@ * MobileBottomNav — navegação inferior do mobile. * * Itens: Início · Radar · (+) · Explorar · Mapa - * O botão "+" abre o ContextualActionDrawer (não é "criar post" — é ações - * permitidas por rota: acompanhar, reportar erro, contribuir contexto, - * exportar, citar página). + * Quando o botão "+" voltar, ele deve abrir ações permitidas por rota: + * acompanhar, reportar erro, contribuir contexto, exportar, citar página. */ import Link from "next/link"; import { usePathname } from "next/navigation"; diff --git a/apps/web/src/components/app-shell/top-search-bar.tsx b/apps/web/src/components/app-shell/top-search-bar.tsx index 7d67137..97bfce6 100644 --- a/apps/web/src/components/app-shell/top-search-bar.tsx +++ b/apps/web/src/components/app-shell/top-search-bar.tsx @@ -70,7 +70,7 @@ export function TopSearchBar() { - ))} +
+ {REACOES.map((r) => { + const Icon = r.icon; + return ( + + ); + })}
); } diff --git a/apps/web/src/components/feed/resumo-mensal-radar.tsx b/apps/web/src/components/feed/resumo-mensal-radar.tsx new file mode 100644 index 0000000..ff8a7eb --- /dev/null +++ b/apps/web/src/components/feed/resumo-mensal-radar.tsx @@ -0,0 +1,139 @@ +import Link from "next/link"; +import { ArrowRight, CalendarDays, Database, ListChecks, Sparkles } from "lucide-react"; +import { TipoInformacaoBadge, FonteBadge } from "@/components/deolho/badges"; +import { comporEvento } from "@/lib/evento-compositor"; +import { categoriaMeta, dataExtensa, fonteLabel, valorBRL, type ResumoMensalRadar } from "@/lib/eventos"; +import type { FonteId } from "@/lib/civic-types"; + +export function ResumoMensalRadarCard({ resumo }: { resumo: ResumoMensalRadar }) { + return ( +
+
+
+
+
+ + + leitura local do mês + + + + {resumo.periodoLabel} + +
+

{resumo.leitura.titulo}

+

{resumo.leitura.texto}

+
+
+ +
+
+ + + + +
+ + {resumo.leitura.pontos.length > 0 && ( +
+
+ + Para entender o mês +
+
    + {resumo.leitura.pontos.map((ponto) =>
  • {ponto}
  • )} +
+
+ )} + +
+
+ + O que mais aparece +
+
+ {resumo.porCategoria.map((item) => ( + + {categoriaMeta(item.categoria).emoji} {categoriaMeta(item.categoria).label}: {item.total.toLocaleString("pt-BR")} + {item.valor ? ` · ${valorBRL(item.valor)}` : ""} + + ))} +
+ {resumo.fontes.length > 0 && ( +
+ Fontes do mês: + {resumo.fontes.map((fonte) => ( + + ))} +
+ )} +
+ + {resumo.recomendados.length > 0 && ( +
+

Comece por estes acontecimentos do mês

+
+ {resumo.recomendados.slice(0, 5).map((evento) => { + const composto = comporEvento(evento); + return ( + +
+ {composto.titulo} + +
+
+ + {fonteLabel(evento.sourceId)} + {dataExtensa(evento.dataEvento ?? evento.publishedAt?.toISOString().slice(0, 10) ?? null)} + {composto.valorLabel && {composto.valorLabel}} +
+ + ); + })} +
+
+ )} + +

+ {resumo.leitura.limitacoes.join(" ")} Depois deste resumo, o radar continua com os acontecimentos mais recentes. +

+
+
+ ); +} + +function ResumoMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function dataCurta(iso: string): string { + return new Date(`${iso}T12:00:00`).toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit" }); +} + +function fonteIdSeguro(sourceId: string): FonteId { + const ids = new Set([ + "pncp", + "tce-sp", + "cgu-transparencia", + "querido-diario", + "diario-americana", + "transparencia-americana", + "receita-cnpj", + "camara-americana", + "tse", + "sinteticos", + ]); + return ids.has(sourceId as FonteId) ? sourceId as FonteId : "sinteticos"; +} diff --git a/apps/web/src/components/feed/save-share.tsx b/apps/web/src/components/feed/save-share.tsx index 09769cc..cf5df68 100644 --- a/apps/web/src/components/feed/save-share.tsx +++ b/apps/web/src/components/feed/save-share.tsx @@ -1,11 +1,10 @@ "use client"; /** - * Save + Share — Instagram-style ações secundárias do card. + * Save + Share — acoes secundarias para objetos publicos. * - * Save (acompanhar) = bookmark local agora; quando houver pseudônimo verificado - * vira persistente. Share = Web Share API quando disponível, copia URL no - * clipboard como fallback. Tudo client-side e otimista. + * Acompanhar = bookmark local agora; quando houver pseudonimo verificado vira + * persistente. Compartilhar usa Web Share API ou copia URL como fallback. */ import { useState } from "react"; import { Bookmark, Share2 } from "lucide-react"; diff --git a/apps/web/src/components/feed/stories-row.tsx b/apps/web/src/components/feed/stories-row.tsx index 6620802..b9cff32 100644 --- a/apps/web/src/components/feed/stories-row.tsx +++ b/apps/web/src/components/feed/stories-row.tsx @@ -1,6 +1,8 @@ /** - * StoriesRow — fila horizontal de círculos no topo, ao estilo das stories do - * Instagram, mas para CIDADES, BAIRROS e TEMAS (não pessoas). + * StoriesRow — trilha horizontal de filtros cívicos. + * + * O nome do componente é legado; na interface ele representa categorias, + * territórios e temas públicos, não stories sociais ou pessoas. * * Visual: avatar redondo com anel suave (ou gradiente quente quando "novo * conteúdo"). Label embaixo. Scroll horizontal nativo mobile com snap. @@ -16,9 +18,9 @@ export interface StoryItem { href: string; /** Inicial / sigla pra desenhar dentro do círculo quando não há ícone. */ iniciais?: string; - /** Quando true, mostra anel gradiente quente — indicando conteúdo novo. */ + /** Quando true, mostra anel gradiente quente — indicando atualização recente. */ novo?: boolean; - /** Quando true, marca como categoria ativa do feed (ring escuro). */ + /** Quando true, marca como categoria ativa do radar (ring escuro). */ ativo?: boolean; /** Override de paleta determinística. */ bg?: string; diff --git a/apps/web/src/components/feed/trust-hint.tsx b/apps/web/src/components/feed/trust-hint.tsx index 2b30631..6b30b11 100644 --- a/apps/web/src/components/feed/trust-hint.tsx +++ b/apps/web/src/components/feed/trust-hint.tsx @@ -1,8 +1,8 @@ /** * TrustHint — confiança da fonte como ÍCONE sutil, não chip berrante. * - * Substitui o TipoInformacaoBadge no FEED Instagram-style. A intenção é deixar - * a confiança presente e clicável sem dominar a hierarquia visual do card. + * Alternativa compacta ao TipoInformacaoBadge em superfícies densas. A intenção + * é deixar a confiança presente e clicável sem dominar a hierarquia visual. * * Quando precisar de label completo, usar TipoInformacaoBadge (em páginas de * detalhe técnicas). diff --git a/apps/web/src/lib/atoms.ts b/apps/web/src/lib/atoms.ts index 629ce36..d017a1d 100644 --- a/apps/web/src/lib/atoms.ts +++ b/apps/web/src/lib/atoms.ts @@ -1,7 +1,7 @@ /** * Loader server-side dos ÁTOMOS extraídos do Diário de Americana. * - * Cada átomo é uma unidade de conteúdo do feed cívico (lei, decreto, portaria, + * Cada átomo é uma unidade de conteúdo do radar cívico (lei, decreto, portaria, * contrato, pregão, etc.) com posição no texto, número, resumo e CNPJs/valores * detectados na vizinhança. O extrator (packages/collectors/src/extract/atoms.ts) * gera atoms.json a partir dos 60 PDFs reais. @@ -165,7 +165,7 @@ async function load(): Promise { } catch (e) { // Só toleramos o caso "arquivo ainda não gerado" (fresh checkout / primeiro // deploy antes do coletor rodar). JSON malformado, permissão negada, schema - // diferente, etc. são regressão real — devem quebrar pra não publicar feed + // diferente, etc. são regressão real — devem quebrar pra não publicar radar // silenciosamente vazio em produção. if ((e as NodeJS.ErrnoException)?.code !== "ENOENT") throw e; _cache = VAZIO; @@ -233,7 +233,7 @@ export async function getAtomsPorCnpj(cnpj: string): Promise { return data.atomos.filter((a) => a.cnpjsMencionados.includes(doc)); } -/** Átomos por lista de ids, preservando a ordem cronológica do feed. */ +/** Átomos por lista de ids, preservando a ordem cronológica do radar. */ export async function getAtomsPorIds(ids: string[]): Promise { const data = await load(); const set = new Set(ids); @@ -302,7 +302,7 @@ export function tipoLabel(t: TipoAto): string { } // ── Algoritmo de relevância ────────────────────────────────────────────────── -// O feed não é só "cronológico cego". Atos de dinheiro pesam mais, com CNPJ +// O radar não é só "cronológico cego". Atos de dinheiro pesam mais, com CNPJ // pesa mais, recente pesa mais. Resultado: o usuário vê o que importa primeiro. const PESO_TIPO: Record = { contrato: 30, @@ -358,7 +358,7 @@ function scoreAtomo(a: Atom, hojeMs = Date.now()): number { /** * Versão ranqueada com DIVERSIDADE: depois de ordenar por score, aplica uma * penalidade leve em átomos do mesmo tipo/empresa consecutivos. Resultado: - * o feed mistura conteúdos em vez de mostrar 5 contratos seguidos da mesma + * o radar mistura conteúdos em vez de mostrar 5 contratos seguidos da mesma * empresa. */ export async function getAtomsRanqueados( diff --git a/apps/web/src/lib/evento-compositor.test.ts b/apps/web/src/lib/evento-compositor.test.ts new file mode 100644 index 0000000..116debe --- /dev/null +++ b/apps/web/src/lib/evento-compositor.test.ts @@ -0,0 +1,128 @@ +import assert from "node:assert/strict"; +import { comporEvento, leituraLocalInterpretativa } from "./evento-compositor"; +import { podeEnriquecerComWikipedia } from "./wiki-policy"; + +function assertEmpresaHref(evento: ReturnType, href: string) { + assert.equal( + evento.conexoes.some((c) => c.tipo === "empresa" && c.href === href), + true, + `esperava conexão de empresa para ${href}`, + ); +} + +const pagamento = comporEvento({ + categoria: "pagamento", + tipo: "pagamento_registrado", + titulo: "Despesa registrada para ACME SERVICOS LTDA", + resumo: "Órgão: Prefeitura. Credor: ACME SERVICOS LTDA. Ação orçamentária: Manutenção de escolas.", + valor: "12500.00", + sourceId: "tce-sp", + sourceUrl: "https://exemplo.tce.sp.gov.br/despesa", + trustType: "fato_oficial", + entidades: { credorDocumento: "12.345.678/0001-90" }, +}); +assertEmpresaHref(pagamento, "/empresa/12345678000190"); + +const contratacao = comporEvento({ + categoria: "contratacao", + tipo: "contrato_publicado", + titulo: "Contrato publicado", + resumo: "Fornecedor: BETA COMERCIO LTDA. Objeto: compra de materiais.", + valor: "900000", + sourceId: "pncp", + sourceUrl: "https://pncp.gov.br/app/contratos/1", + trustType: "fato_oficial", + entidades: { fornecedorDocumento: "98.765.432/0001-10" }, +}); +assertEmpresaHref(contratacao, "/empresa/98765432000110"); + +const sancao = comporEvento({ + categoria: "sancao", + tipo: "sancao_registrada", + titulo: "Sanção registrada", + resumo: "GAMA LTDA Tipo: impedimento temporário.", + valor: null, + sourceId: "cgu-transparencia", + sourceUrl: "https://portaldatransparencia.gov.br/sancoes", + trustType: "fato_oficial", + entidades: { fornecedorDocumento: "11.111.111/0001-11" }, +}); +assert.equal( + sancao.avisos.some((aviso) => aviso.includes("não indica irregularidade")), + true, +); + +assert.equal(podeEnriquecerComWikipedia("pessoa_comum"), false); +assert.equal(podeEnriquecerComWikipedia("orgao"), true); +assert.equal(podeEnriquecerComWikipedia("pessoa_publica"), true); +assert.equal(podeEnriquecerComWikipedia("empresa"), true); +assert.equal(podeEnriquecerComWikipedia("lei"), true); + +const semFonte = comporEvento({ + categoria: "ato_normativo", + titulo: "Ato sem fonte", + resumo: "Texto pendente de proveniência.", + valor: null, + sourceId: null, + sourceUrl: null, + trustType: null, +}); +assert.equal(semFonte.tipoInformacao, "dado_incompleto"); +assert.equal(semFonte.confianca, "incompleto"); +assert.equal(semFonte.limitacoes.length >= 2, true); + +const formatadoPorAgente = comporEvento({ + categoria: "contratacao", + tipo: "contrato_publicado", + titulo: "Título organizado pelo agente", + resumo: "Fornecedor: DELTA LTDA. Objeto: serviço público.", + valor: "56000", + sourceId: "pncp", + sourceUrl: "https://pncp.gov.br/app/contratos/2", + trustType: "fato_oficial", + entidades: { + fornecedorDocumento: "22.222.222/0001-22", + apresentacao: { + modo: "openai", + modelo: "gpt-4.1-nano", + linhaFina: "Contrato · R$ 56 mil · PNCP", + leituraCompleta: "Leitura completa organizada somente a partir da evidência oficial.", + conexoesTexto: ["DELTA LTDA"], + evidenciasLidas: 1, + }, + }, +}); +assert.equal(formatadoPorAgente.titulo, "Título organizado pelo agente"); +assert.equal(formatadoPorAgente.apresentacaoIA?.leituraCompleta?.includes("evidência oficial"), true); +assert.equal( + formatadoPorAgente.conexoes.some((c) => c.tipo === "ia" && c.label === "DELTA LTDA"), + true, +); + +// Leitura interpretativa: explica o significado SEM recitar o trecho bruto. +const leituraContrato = leituraLocalInterpretativa({ + categoria: "contratacao", + titulo: "Prefeitura contratou Beta Comercio", + resumo: "Fornecedor: BETA COMERCIO LTDA. Objeto: compra de materiais escolares.", + valor: "900000", + sourceId: "pncp", + sourceUrl: "https://pncp.gov.br/app/contratos/1", + trustType: "fato_oficial", +}); +assert.match(leituraContrato, /A Prefeitura contratou/); +assert.match(leituraContrato, /Vale acompanhar/); +assert.match(leituraContrato, /ficha e nas provas/); +assert.equal(leituraContrato.includes("Fornecedor: BETA"), false, "não deve recitar o campo bruto"); + +const leituraSancao = leituraLocalInterpretativa({ + categoria: "sancao", + titulo: "Sanção registrada", + resumo: "GAMA LTDA Tipo: impedimento temporário.", + valor: null, + sourceId: "cgu-transparencia", + sourceUrl: "https://portaldatransparencia.gov.br/sancoes", + trustType: "fato_oficial", +}); +assert.match(leituraSancao, /não significa, por si só, culpa/); + +console.log("[evento-compositor] testes OK"); diff --git a/apps/web/src/lib/evento-compositor.ts b/apps/web/src/lib/evento-compositor.ts new file mode 100644 index 0000000..8b7bb10 --- /dev/null +++ b/apps/web/src/lib/evento-compositor.ts @@ -0,0 +1,570 @@ +/** + * Compositor de eventos publicos. + * + * Transforma um registro de `civic_events` em uma unidade de UI verificavel: + * titulo legivel, resumo curto, valor, fonte, confianca, limitacoes, evidencias + * e conexoes navegaveis. O modulo e puro para permanecer testavel sem banco. + */ +import type { + Fonte, + FonteId, + Limitacao, + NivelConfianca, + TipoInformacao, +} from "@/lib/civic-types"; + +export interface ApresentacaoIA { + linhaFina?: string; + leituraCompleta?: string; + etiquetas?: string[]; + conexoesTexto?: string[]; + aviso?: string | null; + modo?: string; + modelo?: string | null; + geradoEm?: string; + tituloOriginal?: string; + resumoOriginal?: string | null; + evidenciasLidas?: number; + desde?: string; +} + +export interface EventoParaComposicao { + id?: string; + categoria: string; + tipo?: string; + titulo: string; + resumo: string | null; + valor: string | null; + sourceId?: string | null; + sourceUrl?: string | null; + dataEvento?: string | null; + publishedAt?: string | Date | null; + fetchedAt?: string | Date | null; + limitacoes?: unknown; + trustType?: string | null; + /** Referências do evento (jsonb civic_events.entidades), quando houver. */ + entidades?: { + fornecedorDocumento?: string | null; + credorDocumento?: string | null; + /** Nome do órgão já resolvido pelo read model (join em entities), opcional. */ + orgaoNome?: string | null; + apresentacao?: ApresentacaoIA; + } | null; +} + +/** Uma conexao navegavel a partir do evento publico. */ +export interface Conexao { + tipo: "empresa" | "orgao" | "pessoa_publica" | "termo" | "evidencia" | "ia"; + label: string; + /** Destino do clique. Ausente em termo puro, que usa tooltip. */ + href?: string; + /** Explicacao curta para termos civicos. */ + tooltip?: string; +} + +export interface EventoMedia { + tipo: "tematica" | "wikipedia"; + gradiente: string; + emoji: string; + alt: string; + imagemUrl?: string | null; + credito?: string | null; + href?: string | null; +} + +export interface EvidenciaComposta { + titulo: string; + href: string; + fonte: FonteId; +} + +export interface EventoComposto { + titulo: string; + resumo: string | null; + valorLabel: string | null; + tipoInformacao: TipoInformacao; + fonte: Fonte; + confianca: NivelConfianca; + limitacoes: Limitacao[]; + evidencias: EvidenciaComposta[]; + conexoes: Conexao[]; + media: EventoMedia; + selo: { emoji: string; label: string }; + avisos: string[]; + apresentacaoIA?: ApresentacaoIA; + permiteAcoesCivicas: boolean; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/** "R$ 12,6 milhões" · "R$ 56 mil" · "R$ 1.234,00" — legível pro cidadão. */ +export function valorHumano(valor: string | number | null): string | null { + if (valor === null) return null; + const n = typeof valor === "number" ? valor : Number(valor); + if (!Number.isFinite(n) || n === 0) return null; + if (n >= 1_000_000_000) return `R$ ${(n / 1_000_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} bilhões`; + if (n >= 1_000_000) return `R$ ${(n / 1_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} milhões`; + if (n >= 10_000) return `R$ ${Math.round(n / 1000).toLocaleString("pt-BR")} mil`; + return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); +} + +/** Extrai "Label: valor" do resumo semi-estruturado (até o próximo ". " ou fim). */ +function extrair(resumo: string | null, label: string): string | null { + if (!resumo) return null; + const labels = "Envolvido|Órg[ãa]o citado|Fornecedor|Credor|Contratado|Contratada|Empresa|Órg[ãa]o|Objeto\\/finalidade|Objeto|Finalidade|Ação orçament[áa]ria|Valor|Fonte|Tipo"; + const re = new RegExp(`${label}\\s*:\\s*([^\\n.]+?)(?=\\s+(?:${labels})\\s*:|\\.\\s|\\.$|$)`, "i"); + const m = resumo.match(re); + return m?.[1]?.trim() || null; +} + +const CONECTIVOS = new Set([ + "de", "da", "do", "das", "dos", "e", "a", "o", "as", "os", + "em", "para", "por", "com", "no", "na", "ao", "pela", "pelo", "pelas", "pelos", +]); + +/** Title Case em pt-BR: conectivos minúsculos, siglas preservadas só em texto MISTO. */ +function titleCasePt(s: string): string { + // Texto todo-maiúsculo (PNCP/PDF) → não dá pra distinguir sigla de palavra; faz + // title case puro. Só preserva siglas quando há minúsculas (token all-caps = sigla). + const textoMisto = /[a-zà-ÿ]/.test(s); + return s + .split(/\s+/) + .map((w, i) => { + const lw = w.toLocaleLowerCase("pt-BR"); + if (i > 0 && CONECTIVOS.has(lw)) return lw; // conectivo minúsculo (antes da sigla) + if (textoMisto && w.length >= 2 && w.length <= 5 && /[A-ZÀ-Ú]/.test(w) && w === w.toLocaleUpperCase("pt-BR")) { + return w; // sigla preservada (ex.: "Empresa XPTO Ltda") + } + return lw.charAt(0).toLocaleUpperCase("pt-BR") + lw.slice(1); + }) + .join(" "); +} + +/** Capitaliza e encurta um nome próprio/empresa pra caber no título. */ +function nomeCurto(s: string | null, max = 48): string | null { + if (!s) return null; + const tc = titleCasePt(s.replace(/\s+/g, " ").trim()); + return tc.length > max ? tc.slice(0, max - 1).trimEnd() + "…" : tc; +} + +/** Proporção de letras maiúsculas (ignora dígitos/pontuação). */ +function fracaoMaiusculas(s: string): number { + const letras = s.replace(/[^A-Za-zÀ-ÿ]/g, ""); + if (!letras) return 0; + const maius = letras.replace(/[^A-ZÀ-Þ]/g, "").length; + return maius / letras.length; +} + +/** Objeto/contexto em caixa de leitura: corta metadados, normaliza CAIXA ALTA do PDF. */ +function objetoLegivel(resumo: string | null, max = 150): string | null { + if (!resumo) return null; + // Tira os campos estruturados que viram chips (Fornecedor/Credor/Valor/Órgão…). + let texto = resumo + .split(/\b(?:Fornecedor|Credor|Valor registrado|Valor arrecadado|Maior fase|Tipo|Fonte|Órg[ãa]o|Ação or[çc])/i)[0] + .replace(/,?\s*conforme.*$/i, "") + .replace(/\s+/g, " ") + .trim(); + if (!texto) return null; + // PDFs vêm majoritariamente em CAIXA ALTA → sentence case quando >60% maiúsculas. + if (fracaoMaiusculas(texto) > 0.6 && texto.length > 12) { + texto = texto.toLocaleLowerCase("pt-BR"); + texto = texto.charAt(0).toLocaleUpperCase("pt-BR") + texto.slice(1); + } + return texto.length > max ? texto.slice(0, max - 1).trimEnd() + "…" : texto; +} + +const MEDIA: Record = { + contratacao: { tipo: "tematica", gradiente: "from-emerald-500/15 to-teal-500/10", emoji: "📋", alt: "Imagem temática de contrato público", imagemUrl: "/media/eventos/contratacao.png" }, + pagamento: { tipo: "tematica", gradiente: "from-sky-500/15 to-indigo-500/10", emoji: "💸", alt: "Imagem temática de pagamento público", imagemUrl: "/media/eventos/pagamento.png" }, + receita: { tipo: "tematica", gradiente: "from-amber-500/15 to-yellow-500/10", emoji: "🪙", alt: "Imagem temática de receita pública", imagemUrl: "/media/eventos/receita.png" }, + ato_normativo: { tipo: "tematica", gradiente: "from-violet-500/15 to-fuchsia-500/10", emoji: "📜", alt: "Imagem temática de ato oficial", imagemUrl: "/media/eventos/ato-normativo.png" }, + nomeacao_exoneracao: { tipo: "tematica", gradiente: "from-rose-500/15 to-orange-500/10", emoji: "👤", alt: "Imagem temática de mudança de cargo público", imagemUrl: "/media/eventos/cargo-publico.png" }, + sancao: { tipo: "tematica", gradiente: "from-amber-500/20 to-orange-500/10", emoji: "⚠️", alt: "Imagem temática de sanção oficial", imagemUrl: "/media/eventos/sancao.png" }, + audiencia_conselho: { tipo: "tematica", gradiente: "from-blue-500/15 to-cyan-500/10", emoji: "🏛️", alt: "Imagem temática de audiência ou conselho", imagemUrl: "/media/eventos/audiencia.png" }, + obra_zeladoria: { tipo: "tematica", gradiente: "from-orange-500/15 to-amber-500/10", emoji: "🚧", alt: "Imagem temática de obra ou zeladoria", imagemUrl: "/media/eventos/obra.png" }, + relacionamento: { tipo: "tematica", gradiente: "from-slate-500/15 to-cyan-500/10", emoji: "🔗", alt: "Vinculo documentado" }, + limitacao_fonte: { tipo: "tematica", gradiente: "from-amber-500/15 to-stone-500/10", emoji: "ℹ️", alt: "Limitacao de fonte" }, +}; + +const SELO: Record = { + contratacao: "Contrato", + pagamento: "Pagamento", + receita: "Arrecadação", + ato_normativo: "Ato oficial", + nomeacao_exoneracao: "Cargo público", + sancao: "Sanção", + audiencia_conselho: "Audiência", + obra_zeladoria: "Obra", +}; + +// ── Compositor ───────────────────────────────────────────────────────────── + +/** slug estável em pt-BR (sem acento, minúsculo, hifenizado) para /orgao, /pessoa. */ +function slugify(s: string): string { + return s + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +/** Termos cívicos explicados em 1 linha — o glossário que vira tooltip clicável. */ +const GLOSSARIO: Array<{ re: RegExp; label: string; tooltip: string }> = [ + { re: /pregão eletrônico/i, label: "Pregão eletrônico", tooltip: "Disputa de preços online; vence quem oferece o menor valor cumprindo as regras." }, + { re: /pregão/i, label: "Pregão", tooltip: "Modalidade de licitação para compras comuns; vence o menor preço." }, + { re: /dispensa de licitação|dispensa/i, label: "Dispensa de licitação", tooltip: "Contratação direta, sem licitação, permitida por lei em casos específicos." }, + { re: /inexigibilidade/i, label: "Inexigibilidade", tooltip: "Contratação sem licitação quando só há um fornecedor possível." }, + { re: /concorrência/i, label: "Concorrência", tooltip: "Modalidade de licitação para contratos de maior valor." }, + { re: /termo aditivo|aditivo/i, label: "Termo aditivo", tooltip: "Alteração de um contrato já existente (prazo, valor ou objeto)." }, + { re: /empenhad|empenho/i, label: "Empenho", tooltip: "Primeira fase do gasto: o governo reserva o dinheiro para a despesa." }, + { re: /liquidad|liquidação/i, label: "Liquidação", tooltip: "O governo confirma que o serviço/produto foi entregue." }, + { re: /\bpago\b|pagamento/i, label: "Pagamento", tooltip: "Fase final: o dinheiro efetivamente sai dos cofres públicos." }, +]; + +/** Detecta termos cívicos no texto e os transforma em conexões com tooltip. */ +function detectarTermos(texto: string, limite = 3): Conexao[] { + const out: Conexao[] = []; + const vistos = new Set(); + for (const g of GLOSSARIO) { + if (out.length >= limite) break; + if (g.re.test(texto) && !vistos.has(g.label)) { + vistos.add(g.label); + out.push({ tipo: "termo", label: g.label, tooltip: g.tooltip }); + } + } + return out; +} + +const FONTE_LABEL: Record = { + pncp: "Portal Nacional de Contratações (PNCP)", + "tce-sp": "Tribunal de Contas do Estado (TCE-SP)", + "cgu-transparencia": "Portal da Transparência (CGU)", + "querido-diario": "Querido Diário", + "diario-americana": "Diário Oficial de Americana", + "transparencia-americana": "Transparência Americana", + "receita-cnpj": "Receita Federal (CNPJ)", + "camara-americana": "Câmara Municipal de Americana", + tse: "Justiça Eleitoral (TSE)", + sinteticos: "Dados sintéticos", +}; + +const FONTES_VALIDAS = new Set(Object.keys(FONTE_LABEL) as FonteId[]); + +function fonteId(sourceId: string | null | undefined): FonteId { + if (sourceId && FONTES_VALIDAS.has(sourceId as FonteId)) return sourceId as FonteId; + return "sinteticos"; +} + +function dataIsoCurta(data: string | Date | null | undefined): string | undefined { + if (!data) return undefined; + if (data instanceof Date) return data.toISOString(); + return data; +} + +function normalizarLimitacoes(raw: unknown): Limitacao[] { + if (!Array.isArray(raw)) return []; + return raw + .map((item): Limitacao | null => { + if (!item || typeof item !== "object") return null; + const registro = item as { campo?: unknown; mensagem?: unknown; tipo?: unknown; fonte?: unknown }; + const mensagem = typeof registro.mensagem === "string" ? registro.mensagem : null; + if (!mensagem) return null; + return { + tipo: typeof registro.tipo === "string" && registro.tipo in LIMITE_TIPO + ? LIMITE_TIPO[registro.tipo] + : "dado_ausente", + mensagem, + campoAfetado: typeof registro.campo === "string" ? registro.campo : undefined, + fonte: typeof registro.fonte === "string" && FONTES_VALIDAS.has(registro.fonte as FonteId) + ? registro.fonte as FonteId + : undefined, + }; + }) + .filter((item): item is Limitacao => Boolean(item)); +} + +const LIMITE_TIPO: Record = { + dado_ausente: "dado_ausente", + fonte_atrasada: "fonte_atrasada", + fonte_indisponivel: "fonte_indisponivel", + campo_contraditorio: "campo_contraditorio", +}; + +function tipoInformacao(e: EventoParaComposicao, semFonte: boolean, semEvidencia: boolean): TipoInformacao { + if (semFonte || semEvidencia) return "dado_incompleto"; + switch (e.trustType) { + case "explicacao": + return "explicacao_ia"; + case "sinal_atencao": + return "sinal_atencao"; + case "noticia": + return "noticia"; + case "opiniao": + return "opiniao"; + default: + return "fato_oficial"; + } +} + +function confiancaFonte(e: EventoParaComposicao, limitacoes: Limitacao[], semFonte: boolean, semEvidencia: boolean): NivelConfianca { + if (semFonte || semEvidencia || limitacoes.length > 0) return "incompleto"; + if (e.trustType === "sinal_atencao" || e.trustType === "explicacao") return "verificacao_pendente"; + return "fonte_oficial"; +} + +function documentoEmpresa(e: EventoParaComposicao): string | null { + const doc = e.categoria === "pagamento" + ? e.entidades?.credorDocumento ?? e.entidades?.fornecedorDocumento + : e.entidades?.fornecedorDocumento ?? e.entidades?.credorDocumento; + return doc?.replace(/\D/g, "") ?? null; +} + +export function comporEvento(e: EventoParaComposicao): EventoComposto { + const media = MEDIA[e.categoria] ?? { + tipo: "tematica", + gradiente: "from-foreground/10 to-foreground/5", + emoji: "•", + alt: "Evento publico", + }; + const selo = { emoji: media.emoji, label: SELO[e.categoria] ?? e.categoria }; + const valorLabel = valorHumano(e.valor); + const apresentacaoIA = e.entidades?.apresentacao; + const usarTextoFormatado = Boolean(apresentacaoIA); + let titulo = e.titulo; + let resumo = apresentacaoIA?.linhaFina || objetoLegivel(e.resumo); + const avisos: string[] = []; + const conexoes: Conexao[] = []; + + const sourceId = e.sourceId?.trim() || null; + const fonteDesconhecida = Boolean(sourceId && !FONTES_VALIDAS.has(sourceId as FonteId)); + const fid = fonteId(sourceId); + const semFonte = !sourceId || fonteDesconhecida; + const semEvidencia = !e.sourceUrl; + const limitacoes = normalizarLimitacoes(e.limitacoes); + if (!sourceId) { + limitacoes.push({ + tipo: "dado_ausente", + campoAfetado: "fonte", + mensagem: "Este evento ainda não informa a base pública de origem; trate como dado incompleto.", + }); + } else if (fonteDesconhecida) { + limitacoes.push({ + tipo: "dado_ausente", + campoAfetado: "fonte", + mensagem: `A base pública "${sourceId}" ainda não está catalogada no DeOlho; trate como dado incompleto.`, + }); + } + if (semEvidencia) { + limitacoes.push({ + tipo: "dado_ausente", + campoAfetado: "evidência", + fonte: fid === "sinteticos" ? undefined : fid, + mensagem: "A fonte deste evento não disponibilizou um link de evidência direta neste registro.", + }); + } + if (!e.dataEvento && !e.publishedAt) { + limitacoes.push({ + tipo: "dado_ausente", + campoAfetado: "data pública", + fonte: fid === "sinteticos" ? undefined : fid, + mensagem: "A fonte não informou uma data pública confiável para ordenar este evento; ele não deve aparecer como recente.", + }); + } + + if (apresentacaoIA?.aviso) { + avisos.push(apresentacaoIA.aviso); + } + + const fonte: Fonte = { + id: fid, + nome: FONTE_LABEL[fid], + url: e.sourceUrl ?? undefined, + dataPublicacao: dataIsoCurta(e.publishedAt), + dataColeta: dataIsoCurta(e.fetchedAt), + estado: semFonte || semEvidencia ? "partial" : "fresh", + isSynthetic: fid === "sinteticos", + }; + + const evidencias: EvidenciaComposta[] = e.sourceUrl + ? [{ titulo: `Abrir fonte ${fonte.nome}`, href: e.sourceUrl, fonte: fid }] + : []; + + // Empresa: fornecedor ou credor com CNPJ completo leva para a pagina da empresa. + const cnpj = documentoEmpresa(e); + + switch (e.categoria) { + case "contratacao": { + const fornecedor = nomeCurto(extrair(e.resumo, "Fornecedor")); + if (!usarTextoFormatado) { + titulo = fornecedor + ? `Prefeitura contratou ${fornecedor}${valorLabel ? ` por ${valorLabel}` : ""}` + : `Prefeitura fez uma contratação${valorLabel ? ` de ${valorLabel}` : ""}`; + } + if (cnpj?.length === 14) conexoes.push({ tipo: "empresa", label: fornecedor ?? "Empresa contratada", href: `/empresa/${cnpj}` }); + break; + } + case "pagamento": { + const credor = nomeCurto(extrair(e.resumo, "Credor")); + if (!usarTextoFormatado) { + titulo = credor + ? `Prefeitura pagou ${valorLabel ?? "uma despesa"} a ${credor}` + : `Prefeitura registrou pagamento${valorLabel ? ` de ${valorLabel}` : ""}`; + resumo = nomeCurto(extrair(e.resumo, "Ação orçament[áa]ria"), 120) ?? resumo; + } + if (cnpj?.length === 14 && credor) conexoes.push({ tipo: "empresa", label: credor, href: `/empresa/${cnpj}` }); + break; + } + case "receita": { + const tipo = nomeCurto(e.titulo.replace(/^Receita arrecadada:\s*\d*\s*-?\s*/i, ""), 60); + if (!usarTextoFormatado) { + titulo = `Prefeitura arrecadou ${valorLabel ?? "receita"}${tipo ? ` — ${tipo}` : ""}`; + } + break; + } + case "sancao": { + const empresa = nomeCurto(e.resumo?.split(/\bTipo\b/i)[0] ?? null); + const tipo = nomeCurto(extrair(e.resumo, "Tipo"), 70); + if (!usarTextoFormatado) { + titulo = empresa ? `${empresa} consta em lista de sanção` : "Sanção registrada"; + resumo = tipo; + } + avisos.push("Sinal oficial de sanção não indica irregularidade por si só. Consulte a fonte e o contexto."); + if (cnpj?.length === 14 && empresa) conexoes.push({ tipo: "empresa", label: empresa, href: `/empresa/${cnpj}` }); + break; + } + case "nomeacao_exoneracao": { + const partes = (e.resumo ?? "").split("·").map((s) => s.trim()); + const nome = nomeCurto(partes[0] ?? null, 40); + const cargo = partes[1] ?? null; + if (!usarTextoFormatado) { + titulo = nome ? `${e.titulo}: ${nome}` : e.titulo; + resumo = cargo ?? resumo; + } + if (nome) conexoes.push({ tipo: "pessoa_publica", label: nome, href: `/pessoa/${slugify(nome)}` }); + break; + } + // ato_normativo / audiencia_conselho / obra_zeladoria: titulo do banco + resumo. + } + + // Orgao: nome resolvido pelo read model (join), ou "Orgao: X" no resumo. + const orgao = e.entidades?.orgaoNome ?? extrair(e.resumo, "Órg[ãa]o citado") ?? extrair(e.resumo, "Órg[ãa]o"); + const orgaoNome = nomeCurto(orgao, 50); + if (orgaoNome && !/prefeitura municipal de americana/i.test(orgaoNome)) { + conexoes.push({ tipo: "orgao", label: orgaoNome, href: `/orgao/${slugify(orgaoNome)}` }); + } + + if (e.sourceUrl) { + conexoes.push({ tipo: "evidencia", label: "Ver evidência", href: e.sourceUrl }); + } + + // Termos civicos a partir de titulo + resumo. + conexoes.push(...detectarTermos(`${e.titulo} ${e.resumo ?? ""}`)); + for (const label of apresentacaoIA?.conexoesTexto ?? []) { + conexoes.push({ + tipo: "ia", + label, + tooltip: "Conexão textual organizada pelo agente a partir dos campos e evidências deste evento.", + }); + } + + const possuiPessoaComoAlvo = conexoes.some((c) => c.tipo === "pessoa_publica"); + + return { + titulo, + resumo, + valorLabel, + tipoInformacao: tipoInformacao(e, semFonte, semEvidencia), + fonte, + confianca: confiancaFonte(e, limitacoes, semFonte, semEvidencia), + limitacoes, + evidencias, + conexoes, + media, + selo, + avisos, + apresentacaoIA, + permiteAcoesCivicas: !possuiPessoaComoAlvo, + }; +} + +/** Deixa a primeira letra minúscula (para encaixar objeto no meio de uma frase). */ +function minuscInicial(s: string): string { + return s ? s.charAt(0).toLocaleLowerCase("pt-BR") + s.slice(1) : s; +} + +function objetoDoResumo(resumo: string | null): string | null { + return nomeCurto( + extrair(resumo, "Objeto\\/finalidade") + ?? extrair(resumo, "Objeto") + ?? extrair(resumo, "Finalidade") + ?? extrair(resumo, "Ação orçament[áa]ria"), + 160, + ); +} + +/** + * Leitura INTERPRETATIVA local (sem IA, sem recitar o trecho bruto). Diferente da + * Ficha (que lista fatos) e das Provas (que mostram o documento), esta explica o + * SIGNIFICADO em linguagem simples e orienta o que acompanhar — a partir dos + * campos já publicados pela fonte. Pura e testável. + */ +export function leituraLocalInterpretativa(e: EventoParaComposicao): string { + const valor = valorHumano(e.valor); + const objeto = objetoDoResumo(e.resumo); + const orgao = nomeCurto(e.entidades?.orgaoNome ?? extrair(e.resumo, "Órg[ãa]o citado") ?? extrair(e.resumo, "Órg[ãa]o"), 60); + const partes: string[] = []; + const acompanhar: string[] = []; + + switch (e.categoria) { + case "contratacao": { + const fornecedor = nomeCurto(extrair(e.resumo, "Fornecedor")) ?? "uma empresa"; + partes.push(`A Prefeitura contratou ${fornecedor}${objeto ? ` para ${minuscInicial(objeto)}` : ""}${valor ? `, ao valor registrado de ${valor}` : ""}.`); + if (orgao) partes.push(`O órgão responsável citado é ${orgao}.`); + partes.push("Um contrato é um compromisso de gasto público: o dinheiro só deve sair conforme o serviço ou produto é entregue."); + acompanhar.push("a vigência e eventuais aditivos que aumentem prazo ou valor", "se a entrega corresponde ao que foi contratado"); + break; + } + case "pagamento": { + const credor = nomeCurto(extrair(e.resumo, "Credor")) ?? "um credor"; + partes.push(`A Prefeitura registrou um pagamento${valor ? ` de ${valor}` : ""} a ${credor}.`); + if (objeto) partes.push(`Finalidade informada: ${minuscInicial(objeto)}.`); + partes.push("Todo gasto passa por empenho (reserva do dinheiro), liquidação (confirmação da entrega) e pagamento (saída efetiva)."); + acompanhar.push("se este pagamento corresponde a um contrato já publicado", "a fase informada — empenhado, liquidado ou pago"); + break; + } + case "receita": { + partes.push(`Entrou ${valor ?? "uma receita"} nos cofres da Prefeitura.`); + partes.push("Receita é o dinheiro que o município arrecada — impostos, transferências e outras fontes — e que financia os serviços públicos."); + acompanhar.push("como essa arrecadação evolui ao longo do ano"); + break; + } + case "sancao": { + const empresa = nomeCurto(e.resumo?.split(/\bTipo\b/i)[0] ?? null) ?? "Uma empresa"; + const tipo = nomeCurto(extrair(e.resumo, "Tipo"), 80); + partes.push(`${empresa} aparece em uma lista pública de sanção${tipo ? ` (${minuscInicial(tipo)})` : ""}.`); + partes.push("Constar em lista de sanção é um registro administrativo e não significa, por si só, culpa ou irregularidade neste evento."); + acompanhar.push("o órgão que aplicou a sanção, o prazo e o motivo na fonte oficial"); + break; + } + case "nomeacao_exoneracao": { + const partesNome = (e.resumo ?? "").split("·").map((s) => s.trim()); + const nome = nomeCurto(partesNome[0] ?? null, 40); + const cargo = partesNome[1] ?? null; + partes.push(`${e.titulo}${nome ? `: ${nome}` : ""}${cargo ? ` (${minuscInicial(cargo)})` : ""}.`); + partes.push("Nomeações e exonerações de cargos em comissão são decisões do Executivo publicadas no diário oficial."); + acompanhar.push("quem ocupa funções de confiança e por quanto tempo"); + break; + } + default: { + partes.push(objeto ? `${e.titulo}: ${minuscInicial(objeto)}.` : `${e.titulo}.`); + partes.push("Atos publicados no diário oficial valem como decisão pública a partir da publicação."); + acompanhar.push("o que o ato muda na prática e quem é afetado"); + } + } + + const blocos = [partes.join(" ")]; + if (acompanhar.length > 0) blocos.push(`Vale acompanhar: ${acompanhar.join("; ")}.`); + blocos.push("Esta leitura organiza, em linguagem simples, os campos já publicados pela fonte. Os fatos, valores e vínculos estão na ficha e nas provas desta página."); + return blocos.join("\n\n"); +} diff --git a/apps/web/src/lib/eventos.ts b/apps/web/src/lib/eventos.ts index 4a1cba9..debd699 100644 --- a/apps/web/src/lib/eventos.ts +++ b/apps/web/src/lib/eventos.ts @@ -31,12 +31,14 @@ export interface EventoCivico { resumo: string | null; dataEvento: string | null; valor: string | null; + territorio: unknown; sourceId: string; sourceUrl: string | null; publishedAt: Date | null; fetchedAt: Date | null; limitacoes: unknown; trustType: string; + entidades: unknown; } let _db: DB | null | undefined; @@ -53,9 +55,12 @@ export interface FiltroEventos { categoria?: CategoriaEvento; limit?: number; offset?: number; + ordem?: OrdemEventos; } -/** Eventos mais recentes primeiro (sem data caem no fim). */ +export type OrdemEventos = "recentes" | "semana" | "relevantes"; + +/** Eventos mais recentes primeiro por data publica; sem data caem no fim. */ export async function getEventos(filtro: FiltroEventos = {}): Promise { const db = getDb(); if (!db) return []; @@ -70,16 +75,18 @@ export async function getEventos(filtro: FiltroEventos = {}): Promise; } +export interface ResumoMensalRadar { + periodoLabel: string; + inicio: string; + fim: string; + totalEventos: number; + totalValor: string | null; + porCategoria: Array<{ categoria: string; total: number; valor: string | null }>; + fontes: Array<{ sourceId: string; total: number }>; + recomendados: EventoRanqueado[]; + leitura: { + titulo: string; + texto: string; + pontos: string[]; + limitacoes: string[]; + }; +} + /** Contagem total + por categoria, para o cabeçalho e os filtros. */ export async function getEventosStats(): Promise { const db = getDb(); @@ -112,15 +136,39 @@ export async function getEventosStats(): Promise { } } -// ── Algoritmo do feed — dá "vida" à rede social ────────────────────────────── +// ── Algoritmo do radar social-cívico ───────────────────────────────────────── /** - * Ranking de relevância: sem ele o feed seria 124k pagamentos por data. Pesa + * Ranking de relevância: sem ele o radar seria 124k pagamentos por data. Pesa * tipo (contrato/sanção no topo, pagamento no fim), valor (log) e recência (leve, * porque os dados se concentram nos últimos meses). Depois aplica diversidade - * pra não repetir a mesma categoria em sequência — feed que "respira". + * para não repetir a mesma categoria em sequência. */ export interface EventoRanqueado extends EventoCivico { - entidades: { fornecedorDocumento?: string | null; orgaoNome?: string | null } | null; + entidades: { + fornecedorDocumento?: string | null; + credorDocumento?: string | null; + orgaoNome?: string | null; + orgaoEntityId?: string | null; + fornecedorEntityId?: string | null; + credorEntityId?: string | null; + cnpjs?: string[]; + pessoas?: Array<{ nome?: string | null; papel?: string | null; cargo?: string | null }>; + orgaos?: Array<{ nome?: string | null; sigla?: string | null }>; + apresentacao?: { + linhaFina?: string; + leituraCompleta?: string; + etiquetas?: string[]; + conexoesTexto?: string[]; + aviso?: string | null; + modo?: string; + modelo?: string | null; + geradoEm?: string; + tituloOriginal?: string; + resumoOriginal?: string | null; + evidenciasLidas?: number; + desde?: string; + }; + } | null; } // Score de relevância (texto SQL reutilizado no select e no row_number). @@ -131,16 +179,151 @@ const SCORE_SQL = `( when 'obra_zeladoria' then 14 when 'audiencia_conselho' then 10 when 'receita' then 8 when 'pagamento' then 2 else 5 end + least(40, coalesce(ln(greatest(valor, 1)) * 3, 0)) - + case when data_evento is null then 0 - when current_date - data_evento < 30 then 20 - when current_date - data_evento < 90 then 12 - when current_date - data_evento < 365 then 5 else 0 end + + case when coalesce(data_evento, published_at::date) is null then 0 + when current_date - coalesce(data_evento, published_at::date) < 30 then 20 + when current_date - coalesce(data_evento, published_at::date) < 90 then 12 + when current_date - coalesce(data_evento, published_at::date) < 365 then 5 else 0 end )`; +interface PeriodoMensal { + inicio: string; + fimExclusivo: string; + fimInclusivo: string; +} + +function periodoDoMes(data: Date): PeriodoMensal { + const inicio = new Date(data.getFullYear(), data.getMonth(), 1); + const fim = new Date(data.getFullYear(), data.getMonth() + 1, 1); + const fimInclusivo = new Date(fim); + fimInclusivo.setDate(fimInclusivo.getDate() - 1); + return { + inicio: dataIso(inicio), + fimExclusivo: dataIso(fim), + fimInclusivo: dataIso(fimInclusivo), + }; +} + +function dataIso(data: Date): string { + const y = data.getFullYear(); + const m = String(data.getMonth() + 1).padStart(2, "0"); + const d = String(data.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +function labelMes(inicio: string): string { + const data = new Date(`${inicio}T12:00:00`); + return data.toLocaleDateString("pt-BR", { month: "long", year: "numeric" }); +} + +async function consultarBaseMensal(db: DB, periodo: PeriodoMensal, categoria?: CategoriaEvento): Promise<{ + totalEventos: number; + totalValor: string | null; +}> { + const row = rowsFrom<{ totalEventos: number; totalValor: string | null }>(await db.execute(sql` + select count(*)::int as "totalEventos", + nullif(coalesce(sum(valor), 0), 0)::text as "totalValor" + from civic_events + where coalesce(data_evento, published_at::date) >= ${periodo.inicio}::date + and coalesce(data_evento, published_at::date) < ${periodo.fimExclusivo}::date + ${categoria ? sql`and categoria = ${categoria}` : sql``} + `))[0]; + return { + totalEventos: Number(row?.totalEventos ?? 0), + totalValor: row?.totalValor ?? null, + }; +} + +async function consultarCategoriasMensais(db: DB, periodo: PeriodoMensal, categoria?: CategoriaEvento): Promise> { + return rowsFrom<{ categoria: string; total: number; valor: string | null }>(await db.execute(sql` + select categoria, + count(*)::int as total, + nullif(coalesce(sum(valor), 0), 0)::text as valor + from civic_events + where coalesce(data_evento, published_at::date) >= ${periodo.inicio}::date + and coalesce(data_evento, published_at::date) < ${periodo.fimExclusivo}::date + ${categoria ? sql`and categoria = ${categoria}` : sql``} + group by categoria + order by count(*) desc + limit 6 + `)); +} + +async function consultarFontesMensais(db: DB, periodo: PeriodoMensal, categoria?: CategoriaEvento): Promise> { + return rowsFrom<{ sourceId: string; total: number }>(await db.execute(sql` + select source_id as "sourceId", count(*)::int as total + from civic_events + where coalesce(data_evento, published_at::date) >= ${periodo.inicio}::date + and coalesce(data_evento, published_at::date) < ${periodo.fimExclusivo}::date + ${categoria ? sql`and categoria = ${categoria}` : sql``} + group by source_id + order by count(*) desc + limit 4 + `)); +} + +async function consultarRecomendadosMensais(db: DB, periodo: PeriodoMensal, categoria?: CategoriaEvento): Promise { + const rows = rowsFrom(await db.execute(sql` + select id, categoria, tipo, titulo, resumo, valor::text as valor, + territorio, + source_id as "sourceId", source_url as "sourceUrl", + data_evento as "dataEvento", entidades, + published_at as "publishedAt", fetched_at as "fetchedAt", + limitacoes, trust_type as "trustType" + from civic_events + where source_url is not null + and coalesce(data_evento, published_at::date) >= ${periodo.inicio}::date + and coalesce(data_evento, published_at::date) < ${periodo.fimExclusivo}::date + ${categoria ? sql`and categoria = ${categoria}` : sql``} + order by ${sql.raw(SCORE_SQL)} desc, coalesce(data_evento, published_at::date) desc nulls last, criado_em desc + limit 18 + `)); + return diversificar(rows).slice(0, 6); +} + +function montarLeituraMensal(input: { + periodoLabel: string; + totalEventos: number; + totalValor: string | null; + porCategoria: Array<{ categoria: string; total: number; valor: string | null }>; + fontes: Array<{ sourceId: string; total: number }>; + recomendados: EventoRanqueado[]; + categoria?: CategoriaEvento; +}): ResumoMensalRadar["leitura"] { + const principal = input.porCategoria[0]; + const segunda = input.porCategoria[1]; + const fontePrincipal = input.fontes[0]; + const categoriaLabel = principal ? categoriaMeta(principal.categoria).label.toLocaleLowerCase("pt-BR") : "eventos públicos"; + const escopo = input.categoria ? ` em ${categoriaMeta(input.categoria).label.toLocaleLowerCase("pt-BR")}` : ""; + const titulo = `${capitalizar(input.periodoLabel)} em Americana`; + const texto = input.totalEventos > 0 + ? `O mês tem ${input.totalEventos.toLocaleString("pt-BR")} acontecimentos públicos${escopo}. A maior concentração aparece em ${categoriaLabel}${segunda ? `, seguida por ${categoriaMeta(segunda.categoria).label.toLocaleLowerCase("pt-BR")}` : ""}.${input.totalValor ? ` Os valores citados pelas fontes somam ${input.totalValor}.` : ""}` + : `Ainda não há acontecimentos públicos com data neste mês para este recorte.`; + const pontos = [ + principal ? `${categoriaMeta(principal.categoria).label}: ${principal.total.toLocaleString("pt-BR")} registro(s) no mês.` : null, + fontePrincipal ? `Fonte mais presente: ${fonteLabel(fontePrincipal.sourceId)} (${fontePrincipal.total.toLocaleString("pt-BR")} registro(s)).` : null, + input.recomendados.length > 0 ? `${input.recomendados.length} evento(s) recomendados para começar a leitura do mês.` : null, + ].filter((item): item is string => Boolean(item)); + const limitacoes = [ + "Leitura local organizada por algoritmo determinístico; não cria fatos novos.", + "Valores são somas dos campos publicados pelas fontes e podem não representar execução financeira completa.", + ]; + return { titulo, texto, pontos, limitacoes }; +} + +function capitalizar(texto: string): string { + return texto.charAt(0).toLocaleUpperCase("pt-BR") + texto.slice(1); +} + /** - * Round-robin por categoria: pega 1 de cada categoria por rodada (cada grupo já - * vem ordenado por score). Garante que sanções/atos/nomeações apareçam no topo do - * feed em vez de serem afogados por contratos — feed variado, que "respira". + * Round-robin por categoria: pega 1 de cada categoria por rodada, com cada grupo + * ordenado por score. Evita que uma única categoria esconda as demais. */ function diversificar(rows: T[]): T[] { const grupos = new Map(); @@ -166,33 +349,52 @@ function diversificar(rows: T[]): T[] { } /** - * Feed vivo: usa COTAS por categoria (row_number) pra garantir variedade — sem - * isso, contratos (score alto) afogariam sanções, atos e nomeações. Depois ordena + * Radar vivo: usa cotas por categoria (row_number) para garantir variedade. Depois ordena * por score e diversifica. Quando há filtro de categoria, traz só ela (sem cota). */ export async function getEventosRanqueados(filtro: FiltroEventos = {}): Promise { const db = getDb(); if (!db) return []; - const { categoria, limit = 30 } = filtro; + const { categoria, limit = 30, ordem = "recentes" } = filtro; const cotaPorCategoria = categoria ? 120 : 14; // sem filtro: até 14 de cada categoria try { + if (ordem === "recentes") { + const result = await db.execute(sql` + select id, categoria, tipo, titulo, resumo, valor::text as valor, + territorio, + source_id as "sourceId", source_url as "sourceUrl", + data_evento as "dataEvento", entidades, + published_at as "publishedAt", fetched_at as "fetchedAt", + limitacoes, trust_type as "trustType" + from civic_events + where true + ${categoria ? sql`and categoria = ${categoria}` : sql``} + order by coalesce(data_evento, published_at::date) desc nulls last, criado_em desc + limit ${Math.min(limit, 200)} + `); + return rowsFrom(result); + } + const result = await db.execute(sql` select id, categoria, tipo, titulo, resumo, valor::text as valor, + territorio, source_id as "sourceId", source_url as "sourceUrl", data_evento as "dataEvento", entidades, published_at as "publishedAt", fetched_at as "fetchedAt", limitacoes, trust_type as "trustType" from ( select *, ${sql.raw(SCORE_SQL)} as _score, - row_number() over (partition by categoria order by ${sql.raw(SCORE_SQL)} desc, data_evento desc nulls last) as _rn + row_number() over (partition by categoria order by ${sql.raw(SCORE_SQL)} desc, coalesce(data_evento, published_at::date) desc nulls last) as _rn from civic_events - ${categoria ? sql`where categoria = ${categoria}` : sql``} + where true + ${categoria ? sql`and categoria = ${categoria}` : sql``} + ${ordem === "semana" ? sql`and coalesce(data_evento, published_at::date) >= current_date - interval '7 days'` : sql``} ) t where _rn <= ${cotaPorCategoria} order by _score desc limit 120 `); - const rows = (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows ?? []) as EventoRanqueado[]; + const rows = rowsFrom(result); return diversificar(rows).slice(0, limit); } catch (e) { console.warn(`[eventos] ranking falhou (${e instanceof Error ? e.message : e})`); @@ -200,6 +402,245 @@ export async function getEventosRanqueados(filtro: FiltroEventos = {}): Promise< } } +export async function getResumoMensalRadar(filtro: { categoria?: CategoriaEvento } = {}): Promise { + const db = getDb(); + if (!db) return null; + try { + let periodo = periodoDoMes(new Date()); + let base = await consultarBaseMensal(db, periodo, filtro.categoria); + + if (base.totalEventos === 0) { + const ultimo = rowsFrom<{ data: string }>(await db.execute(sql` + select coalesce(data_evento, published_at::date)::text as data + from civic_events + where coalesce(data_evento, published_at::date) is not null + ${filtro.categoria ? sql`and categoria = ${filtro.categoria}` : sql``} + order by coalesce(data_evento, published_at::date) desc + limit 1 + `))[0]?.data; + if (!ultimo) return null; + periodo = periodoDoMes(new Date(`${ultimo}T12:00:00`)); + base = await consultarBaseMensal(db, periodo, filtro.categoria); + } + + const [porCategoria, fontes, recomendados] = await Promise.all([ + consultarCategoriasMensais(db, periodo, filtro.categoria), + consultarFontesMensais(db, periodo, filtro.categoria), + consultarRecomendadosMensais(db, periodo, filtro.categoria), + ]); + + return { + periodoLabel: labelMes(periodo.inicio), + inicio: periodo.inicio, + fim: periodo.fimInclusivo, + totalEventos: base.totalEventos, + totalValor: valorBRL(base.totalValor), + porCategoria, + fontes, + recomendados, + leitura: montarLeituraMensal({ + periodoLabel: labelMes(periodo.inicio), + totalEventos: base.totalEventos, + totalValor: valorBRL(base.totalValor), + porCategoria, + fontes, + recomendados, + categoria: filtro.categoria, + }), + }; + } catch (e) { + console.warn(`[eventos] resumo mensal falhou (${e instanceof Error ? e.message : e})`); + return null; + } +} + +function rowsFrom(result: unknown): T[] { + return (Array.isArray(result) ? result : (result as { rows?: unknown[] }).rows ?? []) as T[]; +} + +export interface EvidenciaEvento { + id: string; + titulo: string; + sourceId: string; + sourceUrl: string | null; + trecho: string | null; + pagina: number | null; + metodoExtracao: string; + fieldPath: string | null; + publishedAt: Date | null; + fetchedAt: Date | null; + trustType: string; +} + +export interface RelacaoEvento { + id: string; + tipo: string; + descricao: string | null; + fromLabel: string; + fromKind: string; + fromHref: string | null; + toLabel: string; + toKind: string; + toHref: string | null; + evidenceHref: string | null; + sourceId: string; + confianca: string; + trustType: string; +} + +export interface EventoDetalhe { + evento: EventoRanqueado; + evidencias: EvidenciaEvento[]; + relacoes: RelacaoEvento[]; + relacionados: EventoRanqueado[]; +} + +function hrefEntidade(kind: string, label: string, documento: string | null): string | null { + if (kind === "empresa" && documento) return `/empresa/${documento.replace(/\D/g, "")}`; + if (kind === "orgao") return `/orgao/${slugify(label)}`; + if (kind === "pessoa_publica") return `/pessoa/${slugify(label)}`; + return null; +} + +function slugify(s: string): string { + return s + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +export async function getEventoDetalhe(id: string): Promise { + const db = getDb(); + if (!db) return null; + try { + const eventos = await db + .select({ + id: civicEvents.id, + categoria: civicEvents.categoria, + tipo: civicEvents.tipo, + titulo: civicEvents.titulo, + resumo: civicEvents.resumo, + dataEvento: civicEvents.dataEvento, + valor: civicEvents.valor, + territorio: civicEvents.territorio, + sourceId: civicEvents.sourceId, + sourceUrl: civicEvents.sourceUrl, + publishedAt: civicEvents.publishedAt, + fetchedAt: civicEvents.fetchedAt, + limitacoes: civicEvents.limitacoes, + trustType: civicEvents.trustType, + entidades: civicEvents.entidades, + }) + .from(civicEvents) + .where(eq(civicEvents.id, id)) + .limit(1); + const evento = eventos[0] as EventoRanqueado | undefined; + if (!evento) return null; + + const evidencias = rowsFrom(await db.execute(sql` + select id, titulo, source_id as "sourceId", source_url as "sourceUrl", trecho, pagina, + metodo_extracao as "metodoExtracao", field_path as "fieldPath", + published_at as "publishedAt", fetched_at as "fetchedAt", trust_type as "trustType" + from evidence + where civic_event_id = ${id} + order by criado_em asc + `)); + + const relacoesRaw = rowsFrom<{ + id: string; + tipo: string; + descricao: string | null; + fromLabel: string; + fromKind: string; + fromDocumento: string | null; + toLabel: string; + toKind: string; + toDocumento: string | null; + evidenceHref: string | null; + sourceId: string; + confianca: string; + trustType: string; + }>(await db.execute(sql` + select r.id, r.tipo, r.descricao, + ef.nome as "fromLabel", ef.kind as "fromKind", ef.documento as "fromDocumento", + et.nome as "toLabel", et.kind as "toKind", et.documento as "toDocumento", + ev.source_url as "evidenceHref", + r.source_id as "sourceId", r.confianca::text as "confianca", r.trust_type as "trustType" + from entity_relationships r + join entities ef on ef.id = r.from_entity_id + join entities et on et.id = r.to_entity_id + left join evidence ev on ev.id = r.evidence_id + where r.civic_event_id = ${id} + order by r.criado_em asc + limit 24 + `)); + const relacoes = relacoesRaw.map((r) => ({ + id: r.id, + tipo: r.tipo, + descricao: r.descricao, + fromLabel: r.fromLabel, + fromKind: r.fromKind, + fromHref: hrefEntidade(r.fromKind, r.fromLabel, r.fromDocumento), + toLabel: r.toLabel, + toKind: r.toKind, + toHref: hrefEntidade(r.toKind, r.toLabel, r.toDocumento), + evidenceHref: r.evidenceHref, + sourceId: r.sourceId, + confianca: r.confianca, + trustType: r.trustType, + })); + + const entidades = (evento.entidades ?? {}) as { + fornecedorDocumento?: string | null; + credorDocumento?: string | null; + orgaoEntityId?: string | null; + fornecedorEntityId?: string | null; + credorEntityId?: string | null; + }; + const documento = entidades.fornecedorDocumento ?? entidades.credorDocumento ?? null; + const orgaoEntityId = entidades.orgaoEntityId ?? null; + let relacionados = rowsFrom(await db.execute(sql` + select id, categoria, tipo, titulo, resumo, valor::text as valor, + territorio, + source_id as "sourceId", source_url as "sourceUrl", + data_evento as "dataEvento", entidades, + published_at as "publishedAt", fetched_at as "fetchedAt", + limitacoes, trust_type as "trustType" + from civic_events + where id <> ${id} + and ( + ${documento ? sql`entidades->>'fornecedorDocumento' = ${documento} or entidades->>'credorDocumento' = ${documento}` : sql`false`} + ${orgaoEntityId ? sql`or entidades->>'orgaoEntityId' = ${orgaoEntityId}` : sql``} + ) + order by coalesce(data_evento, published_at::date) desc nulls last, criado_em desc + limit 8 + `)); + if (relacionados.length === 0) { + relacionados = rowsFrom(await db.execute(sql` + select id, categoria, tipo, titulo, resumo, valor::text as valor, + territorio, + source_id as "sourceId", source_url as "sourceUrl", + data_evento as "dataEvento", entidades, + published_at as "publishedAt", fetched_at as "fetchedAt", + limitacoes, trust_type as "trustType" + from civic_events + where id <> ${id} + and categoria = ${evento.categoria} + and source_id = ${evento.sourceId} + order by coalesce(data_evento, published_at::date) desc nulls last, criado_em desc + limit 8 + `)); + } + + return { evento, evidencias, relacoes, relacionados }; + } catch (e) { + console.warn(`[eventos] detalhe falhou (${e instanceof Error ? e.message : e})`); + return null; + } +} + // ── Apresentação legível (linguagem pro cidadão comum) ─────────────────────── export const CATEGORIA_META: Record< diff --git a/apps/web/src/lib/post-compositor.ts b/apps/web/src/lib/post-compositor.ts deleted file mode 100644 index 7bde501..0000000 --- a/apps/web/src/lib/post-compositor.ts +++ /dev/null @@ -1,268 +0,0 @@ -/** - * Compositor de POSTS — o coração do feed cívico. - * - * Transforma um evento "seco" do banco (civic_events) num POST excelente de - * leitura, estilo Instagram: título humano, uma linha fina clara, valor legível, - * capa temática (gradiente + ícone) e disclaimers fiéis à doutrina. - * - * Módulo PURO (sem banco, sem server-only) — testável isoladamente e reaproveitável - * tanto no Início quanto em páginas de detalhe. - * - * Doutrina respeitada: - * - Sanção mostra aviso "não indica culpa". - * - Nada de acusação; linguagem neutra ("contratou", "pagou", "recebeu"). - * - Fonte sempre presente (quem chama o post mostra a fonte/evidência). - */ - -export interface EventoParaPost { - categoria: string; - titulo: string; - resumo: string | null; - valor: string | null; - sourceId: string; - /** Referências do evento (jsonb civic_events.entidades), quando houver. */ - entidades?: { - fornecedorDocumento?: string | null; - /** Nome do órgão já resolvido pelo read model (join em entities), opcional. */ - orgaoNome?: string | null; - } | null; -} - -/** Uma conexão navegável a partir do post (o "clicar no nome e ir fundo"). */ -export interface Conexao { - tipo: "empresa" | "orgao" | "pessoa" | "termo"; - label: string; - /** Destino do clique (drill-down). Ausente em "termo" puro (só tooltip). */ - href?: string; - /** Explicação curta para termos cívicos (glossário inline). */ - tooltip?: string; -} - -export interface PostCapa { - /** Classe Tailwind de gradiente para a capa (a "imagem" base do post). */ - gradiente: string; - emoji: string; -} - -export interface Post { - /** Título humano, pronto pra ler — substitui o título seco do banco. */ - titulo: string; - /** Uma linha de contexto em linguagem simples (objeto, finalidade…). */ - linhaFina: string | null; - /** Valor legível e abreviado: "R$ 12,6 milhões". null quando não há. */ - valorLabel: string | null; - selo: { emoji: string; label: string }; - capa: PostCapa; - /** Aviso obrigatório quando o tipo exige (ex.: sanção). */ - disclaimer: string | null; - /** Conexões clicáveis — empresa, órgão, pessoa, termos. O coração do "conectar tudo". */ - conexoes: Conexao[]; -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -/** "R$ 12,6 milhões" · "R$ 56 mil" · "R$ 1.234,00" — legível pro cidadão. */ -export function valorHumano(valor: string | number | null): string | null { - if (valor === null) return null; - const n = typeof valor === "number" ? valor : Number(valor); - if (!Number.isFinite(n) || n === 0) return null; - if (n >= 1_000_000_000) return `R$ ${(n / 1_000_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} bilhões`; - if (n >= 1_000_000) return `R$ ${(n / 1_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} milhões`; - if (n >= 10_000) return `R$ ${Math.round(n / 1000).toLocaleString("pt-BR")} mil`; - return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); -} - -/** Extrai "Label: valor" do resumo semi-estruturado (até o próximo ". " ou fim). */ -function extrair(resumo: string | null, label: string): string | null { - if (!resumo) return null; - const re = new RegExp(`${label}\\s*:?\\s*([^.]+?)(?:\\.\\s|\\.$|$)`, "i"); - const m = resumo.match(re); - return m?.[1]?.trim() || null; -} - -const CONECTIVOS = new Set([ - "de", "da", "do", "das", "dos", "e", "a", "o", "as", "os", - "em", "para", "por", "com", "no", "na", "ao", "pela", "pelo", "pelas", "pelos", -]); - -/** Title Case em pt-BR: conectivos minúsculos, siglas preservadas só em texto MISTO. */ -function titleCasePt(s: string): string { - // Texto todo-maiúsculo (PNCP/PDF) → não dá pra distinguir sigla de palavra; faz - // title case puro. Só preserva siglas quando há minúsculas (token all-caps = sigla). - const textoMisto = /[a-zà-ÿ]/.test(s); - return s - .split(/\s+/) - .map((w, i) => { - const lw = w.toLocaleLowerCase("pt-BR"); - if (i > 0 && CONECTIVOS.has(lw)) return lw; // conectivo minúsculo (antes da sigla) - if (textoMisto && w.length >= 2 && w.length <= 5 && /[A-ZÀ-Ú]/.test(w) && w === w.toLocaleUpperCase("pt-BR")) { - return w; // sigla preservada (ex.: "Empresa XPTO Ltda") - } - return lw.charAt(0).toLocaleUpperCase("pt-BR") + lw.slice(1); - }) - .join(" "); -} - -/** Capitaliza e encurta um nome próprio/empresa pra caber no título. */ -function nomeCurto(s: string | null, max = 48): string | null { - if (!s) return null; - const tc = titleCasePt(s.replace(/\s+/g, " ").trim()); - return tc.length > max ? tc.slice(0, max - 1).trimEnd() + "…" : tc; -} - -/** Proporção de letras maiúsculas (ignora dígitos/pontuação). */ -function fracaoMaiusculas(s: string): number { - const letras = s.replace(/[^A-Za-zÀ-ÿ]/g, ""); - if (!letras) return 0; - const maius = letras.replace(/[^A-ZÀ-Þ]/g, "").length; - return maius / letras.length; -} - -/** Objeto/contexto em caixa de leitura: corta metadados, normaliza CAIXA ALTA do PDF. */ -function objetoLegivel(resumo: string | null, max = 150): string | null { - if (!resumo) return null; - // Tira os campos estruturados que viram chips (Fornecedor/Credor/Valor/Órgão…). - let texto = resumo - .split(/\b(?:Fornecedor|Credor|Valor registrado|Valor arrecadado|Maior fase|Tipo|Fonte|Órg[ãa]o|Ação or[çc])/i)[0] - .replace(/,?\s*conforme.*$/i, "") - .replace(/\s+/g, " ") - .trim(); - if (!texto) return null; - // PDFs vêm majoritariamente em CAIXA ALTA → sentence case quando >60% maiúsculas. - if (fracaoMaiusculas(texto) > 0.6 && texto.length > 12) { - texto = texto.toLocaleLowerCase("pt-BR"); - texto = texto.charAt(0).toLocaleUpperCase("pt-BR") + texto.slice(1); - } - return texto.length > max ? texto.slice(0, max - 1).trimEnd() + "…" : texto; -} - -const CAPA: Record = { - contratacao: { gradiente: "from-emerald-500/15 to-teal-500/10", emoji: "📋" }, - pagamento: { gradiente: "from-sky-500/15 to-indigo-500/10", emoji: "💸" }, - receita: { gradiente: "from-amber-500/15 to-yellow-500/10", emoji: "🪙" }, - ato_normativo: { gradiente: "from-violet-500/15 to-fuchsia-500/10", emoji: "📜" }, - nomeacao_exoneracao: { gradiente: "from-rose-500/15 to-orange-500/10", emoji: "👤" }, - sancao: { gradiente: "from-red-500/20 to-orange-500/10", emoji: "⚠️" }, - audiencia_conselho: { gradiente: "from-blue-500/15 to-cyan-500/10", emoji: "🏛️" }, - obra_zeladoria: { gradiente: "from-orange-500/15 to-amber-500/10", emoji: "🚧" }, -}; - -const SELO: Record = { - contratacao: "Contrato", - pagamento: "Pagamento", - receita: "Arrecadação", - ato_normativo: "Ato oficial", - nomeacao_exoneracao: "Cargo público", - sancao: "Sanção", - audiencia_conselho: "Audiência", - obra_zeladoria: "Obra", -}; - -// ── Compositor ───────────────────────────────────────────────────────────── - -/** slug estável em pt-BR (sem acento, minúsculo, hifenizado) para /orgao, /pessoa. */ -function slugify(s: string): string { - return s - .normalize("NFD") - .replace(/[̀-ͯ]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - -/** Termos cívicos explicados em 1 linha — o glossário que vira tooltip clicável. */ -const GLOSSARIO: Array<{ re: RegExp; label: string; tooltip: string }> = [ - { re: /pregão eletrônico/i, label: "Pregão eletrônico", tooltip: "Disputa de preços online; vence quem oferece o menor valor cumprindo as regras." }, - { re: /pregão/i, label: "Pregão", tooltip: "Modalidade de licitação para compras comuns; vence o menor preço." }, - { re: /dispensa de licitação|dispensa/i, label: "Dispensa de licitação", tooltip: "Contratação direta, sem licitação, permitida por lei em casos específicos." }, - { re: /inexigibilidade/i, label: "Inexigibilidade", tooltip: "Contratação sem licitação quando só há um fornecedor possível." }, - { re: /concorrência/i, label: "Concorrência", tooltip: "Modalidade de licitação para contratos de maior valor." }, - { re: /termo aditivo|aditivo/i, label: "Termo aditivo", tooltip: "Alteração de um contrato já existente (prazo, valor ou objeto)." }, - { re: /empenhad|empenho/i, label: "Empenho", tooltip: "Primeira fase do gasto: o governo reserva o dinheiro para a despesa." }, - { re: /liquidad|liquidação/i, label: "Liquidação", tooltip: "O governo confirma que o serviço/produto foi entregue." }, - { re: /\bpago\b|pagamento/i, label: "Pagamento", tooltip: "Fase final: o dinheiro efetivamente sai dos cofres públicos." }, -]; - -/** Detecta termos cívicos no texto e os transforma em conexões com tooltip. */ -function detectarTermos(texto: string, limite = 3): Conexao[] { - const out: Conexao[] = []; - const vistos = new Set(); - for (const g of GLOSSARIO) { - if (out.length >= limite) break; - if (g.re.test(texto) && !vistos.has(g.label)) { - vistos.add(g.label); - out.push({ tipo: "termo", label: g.label, tooltip: g.tooltip }); - } - } - return out; -} - -export function comporPost(e: EventoParaPost): Post { - const capa = CAPA[e.categoria] ?? { gradiente: "from-foreground/10 to-foreground/5", emoji: "•" }; - const selo = { emoji: capa.emoji, label: SELO[e.categoria] ?? e.categoria }; - const valorLabel = valorHumano(e.valor); - let titulo = e.titulo; - let linhaFina = objetoLegivel(e.resumo); - let disclaimer: string | null = null; - const conexoes: Conexao[] = []; - - // Empresa: CNPJ do fornecedor → página da empresa (drill-down financeiro). - const cnpj = e.entidades?.fornecedorDocumento?.replace(/\D/g, "") ?? null; - - switch (e.categoria) { - case "contratacao": { - const fornecedor = nomeCurto(extrair(e.resumo, "Fornecedor")); - titulo = fornecedor - ? `Prefeitura contratou ${fornecedor}${valorLabel ? ` por ${valorLabel}` : ""}` - : `Prefeitura fez uma contratação${valorLabel ? ` de ${valorLabel}` : ""}`; - if (cnpj?.length === 14) conexoes.push({ tipo: "empresa", label: fornecedor ?? "Empresa contratada", href: `/empresa/${cnpj}` }); - break; - } - case "pagamento": { - const credor = nomeCurto(extrair(e.resumo, "Credor")); - titulo = credor - ? `Prefeitura pagou ${valorLabel ?? "uma despesa"} a ${credor}` - : `Prefeitura registrou pagamento${valorLabel ? ` de ${valorLabel}` : ""}`; - linhaFina = nomeCurto(extrair(e.resumo, "Ação orçament[áa]ria"), 120) ?? linhaFina; - if (cnpj?.length === 14 && credor) conexoes.push({ tipo: "empresa", label: credor, href: `/empresa/${cnpj}` }); - break; - } - case "receita": { - const tipo = nomeCurto(e.titulo.replace(/^Receita arrecadada:\s*\d*\s*-?\s*/i, ""), 60); - titulo = `Prefeitura arrecadou ${valorLabel ?? "receita"}${tipo ? ` — ${tipo}` : ""}`; - break; - } - case "sancao": { - const empresa = nomeCurto(e.resumo?.split(/\bTipo\b/i)[0] ?? null); - const tipo = nomeCurto(extrair(e.resumo, "Tipo"), 70); - titulo = empresa ? `${empresa} consta em lista de sanção` : "Sanção registrada"; - linhaFina = tipo; - disclaimer = "Sinal oficial de sanção — não indica culpa nem irregularidade; veja a fonte."; - if (cnpj?.length === 14 && empresa) conexoes.push({ tipo: "empresa", label: empresa, href: `/empresa/${cnpj}` }); - break; - } - case "nomeacao_exoneracao": { - const partes = (e.resumo ?? "").split("·").map((s) => s.trim()); - const nome = nomeCurto(partes[0] ?? null, 40); - const cargo = partes[1] ?? null; - titulo = nome ? `${e.titulo}: ${nome}` : e.titulo; - linhaFina = cargo ?? linhaFina; - // Agente público nomeado/exonerado é informação pública → drill-down na pessoa. - if (nome) conexoes.push({ tipo: "pessoa", label: nome, href: `/pessoa/${slugify(nome)}` }); - break; - } - // ato_normativo / audiencia_conselho / obra_zeladoria: título do banco + linha fina. - } - - // Órgão: nome resolvido pelo read model (join), ou "Órgão: X" no resumo. - const orgao = e.entidades?.orgaoNome ?? extrair(e.resumo, "Órg[ãa]o"); - const orgaoNome = nomeCurto(orgao, 50); - if (orgaoNome && !/prefeitura municipal de americana/i.test(orgaoNome)) { - conexoes.push({ tipo: "orgao", label: orgaoNome, href: `/orgao/${slugify(orgaoNome)}` }); - } - - // Termos cívicos (glossário inline) a partir de título + resumo. - conexoes.push(...detectarTermos(`${e.titulo} ${e.resumo ?? ""}`)); - - return { titulo, linhaFina, valorLabel, selo, capa, disclaimer, conexoes }; -} diff --git a/apps/web/src/lib/wiki-policy.ts b/apps/web/src/lib/wiki-policy.ts new file mode 100644 index 0000000..7c2301e --- /dev/null +++ b/apps/web/src/lib/wiki-policy.ts @@ -0,0 +1,27 @@ +export type WikiEntityKind = + | "cidade" + | "territorio" + | "orgao" + | "unidade_orgao" + | "lugar_publico" + | "empresa" + | "pessoa_publica" + | "pessoa_comum" + | "lei" + | "conceito_publico"; + +const TIPOS_PERMITIDOS = new Set([ + "cidade", + "territorio", + "orgao", + "unidade_orgao", + "lugar_publico", + "empresa", + "pessoa_publica", + "lei", + "conceito_publico", +]); + +export function podeEnriquecerComWikipedia(tipo: WikiEntityKind): boolean { + return TIPOS_PERMITIDOS.has(tipo); +} diff --git a/apps/web/src/lib/wikipedia.ts b/apps/web/src/lib/wikipedia.ts index 57ced73..e943437 100644 --- a/apps/web/src/lib/wikipedia.ts +++ b/apps/web/src/lib/wikipedia.ts @@ -1,21 +1,22 @@ /** - * Enriquecimento via Wikipedia (REST API em português) — fotos, resumo e fatos - * para dar contexto humano aos posts: a cidade, os órgãos públicos, lugares. + * Enriquecimento via Wikipedia (REST API em português) para contexto + * enciclopedico dentro da pagina do evento. * * Decisão de stack: usa a API REST pública da Wikipedia direto via `fetch` * (sem dependência Python/lib externa). Cache de 7 dias via Next. * - * DOUTRINA: enriquecer apenas ENTIDADES PÚBLICAS (cidade, órgãos, lugares). - * NUNCA buscar pessoas físicas comuns — só cabe a agente público notório, e ainda - * assim com cautela. Por isso o enriquecimento é opt-in por tipo, nunca automático - * sobre nomes de pessoas. + * DOUTRINA: enriquecer apenas entidades, leis e conceitos citados no evento. + * Nunca buscar pessoas fisicas comuns, nunca usar fallback generico de cidade e + * nunca usar Wikipedia como fonte dos eventos oficiais. */ import "server-only"; +import type { EventoRanqueado, EvidenciaEvento } from "./eventos"; +import { podeEnriquecerComWikipedia, type WikiEntityKind } from "./wiki-policy"; export interface WikiInfo { titulo: string; resumo: string | null; - /** URL da imagem (thumbnail) — a "foto" do post. */ + /** URL da imagem (thumbnail). */ foto: string | null; /** Link para a página completa na Wikipedia. */ url: string | null; @@ -30,7 +31,7 @@ const _cache = new Map(); /** * Busca resumo + foto de um título na Wikipedia PT. Retorna null se não existir, - * for desambiguação, ou em qualquer falha (degrada sem quebrar o post). + * for desambiguação, ou em qualquer falha. */ export async function buscarWikipedia(titulo: string): Promise { const chave = titulo.trim(); @@ -75,7 +76,192 @@ export async function buscarWikipedia(titulo: string): Promise } } -/** Contexto da cidade-piloto (foto + resumo de Americana) para o cabeçalho do feed. */ +export interface WikiContextoEvento { + id: string; + tipo: WikiEntityKind; + label: string; + motivo: string; + query: string; + info: WikiInfo; +} + +interface WikiCandidato { + id: string; + tipo: WikiEntityKind; + label: string; + motivo: string; + query: string; + prioridade: number; +} + +type WikiCandidatoInput = Omit; + +export function buscarWikipediaSegura(input: { + titulo: string; + tipo: WikiEntityKind; +}): Promise { + if (!podeEnriquecerComWikipedia(input.tipo)) return Promise.resolve(null); + return buscarWikipedia(input.titulo); +} + +/** Utilitário explicito para telas territoriais futuras; não usar como fallback de evento. */ export function contextoCidade(): Promise { - return buscarWikipedia("Americana (São Paulo)"); + return buscarWikipediaSegura({ titulo: "Americana (São Paulo)", tipo: "cidade" }); +} + +export async function contextoWikipediaEvento(input: { + evento: EventoRanqueado; + evidencias: EvidenciaEvento[]; + limite?: number; +}): Promise { + const candidatos = candidatosWikipediaEvento(input.evento, input.evidencias) + .sort((a, b) => b.prioridade - a.prioridade) + .slice(0, 8); + + const encontrados = await Promise.all(candidatos.map(async (candidato): Promise => { + const info = await buscarWikipediaSegura({ titulo: candidato.query, tipo: candidato.tipo }); + return info + ? { + id: candidato.id, + tipo: candidato.tipo, + label: candidato.label, + motivo: candidato.motivo, + query: candidato.query, + info, + } + : null; + })); + + return encontrados + .filter((item): item is WikiContextoEvento => Boolean(item)) + .slice(0, input.limite ?? 5); +} + +function candidatosWikipediaEvento(evento: EventoRanqueado, evidencias: EvidenciaEvento[]): WikiCandidato[] { + const candidatos: WikiCandidato[] = []; + const vistos = new Set(); + const texto = [ + evento.titulo, + evento.resumo, + ...evidencias.map((e) => e.trecho ?? ""), + ].join("\n"); + + const push = (candidato: WikiCandidatoInput) => { + const key = normalizar(`${candidato.tipo}:${candidato.query}`); + if (!key || vistos.has(key)) return; + vistos.add(key); + candidatos.push({ ...candidato, id: key }); + }; + + const orgaos = [ + evento.entidades?.orgaoNome, + ...((evento.entidades?.orgaos ?? []).map((o) => o.nome)), + extrairCampo(evento.resumo, "Órg[ãa]o"), + ].filter((nome): nome is string => Boolean(nome && nome.trim().length > 4)); + for (const orgao of orgaos) { + push({ + tipo: "orgao", + label: orgao, + query: limparNome(orgao), + motivo: "Órgão ou instituição citada no evento.", + prioridade: 90, + }); + } + + const empresa = extrairCampo(evento.resumo, evento.categoria === "pagamento" ? "Credor" : "Fornecedor") + ?? extrairCampo(evento.resumo, "Contratado") + ?? extrairCampo(evento.resumo, "Empresa"); + if (empresa) { + push({ + tipo: "empresa", + label: empresa, + query: limparNome(empresa), + motivo: "Empresa citada pela fonte oficial; Wikipedia é apenas contexto institucional.", + prioridade: 76, + }); + } + + for (const pessoa of evento.entidades?.pessoas ?? []) { + if (!pessoa.nome || pessoa.nome.trim().length < 6) continue; + push({ + tipo: "pessoa_publica", + label: pessoa.nome, + query: limparNome(pessoa.nome), + motivo: pessoa.cargo ? `Pessoa citada em função pública: ${pessoa.cargo}.` : "Pessoa citada em função pública.", + prioridade: 72, + }); + } + + for (const lei of detectarLeis(texto)) push(lei); + for (const conceito of detectarConceitosPublicos(texto)) push(conceito); + + return candidatos; +} + +function detectarLeis(texto: string): WikiCandidatoInput[] { + const out: WikiCandidatoInput[] = []; + const push = (label: string, query: string, prioridade = 70) => { + out.push({ + tipo: "lei", + label, + query, + motivo: "Lei citada ou necessária para contextualizar o ato.", + prioridade, + }); + }; + if (/\b(?:lei\s*(?:federal\s*)?(?:n[ºo]\s*)?14\.?133(?:\/(?:21|2021))?|art\.?\s*75\b)/i.test(texto)) { + push("Lei 14.133/2021", "Lei de Licitações e Contratos Administrativos", 82); + } + if (/\b(?:lei\s*(?:n[ºo]\s*)?8\.?666(?:\/93|\/1993)?)\b/i.test(texto)) { + push("Lei 8.666/1993", "Lei de Licitações", 75); + } + if (/\b(?:lei\s*complementar\s*(?:n[ºo]\s*)?101(?:\/2000)?|LRF|responsabilidade fiscal)\b/i.test(texto)) { + push("Lei de Responsabilidade Fiscal", "Lei de Responsabilidade Fiscal", 75); + } + if (/\bconstitui[cç][aã]o\s+federal\b/i.test(texto)) { + push("Constituição Federal", "Constituição brasileira de 1988", 70); + } + return out; +} + +function detectarConceitosPublicos(texto: string): WikiCandidatoInput[] { + const regras: Array<{ re: RegExp; label: string; query: string; prioridade: number }> = [ + { re: /\bpreg[aã]o\b/i, label: "Pregão", query: "Pregão (licitação)", prioridade: 64 }, + { re: /\bdispensa de licita[cç][aã]o\b/i, label: "Dispensa de licitação", query: "Licitação", prioridade: 62 }, + { re: /\btermo aditivo\b/i, label: "Termo aditivo", query: "Contrato administrativo", prioridade: 60 }, + { re: /\bdi[aá]rio oficial\b/i, label: "Diário oficial", query: "Diário oficial", prioridade: 54 }, + { re: /\bSUS\b/i, label: "Sistema Único de Saúde", query: "Sistema Único de Saúde", prioridade: 52 }, + { re: /\bguarda municipal\b/i, label: "Guarda municipal", query: "Guarda municipal", prioridade: 52 }, + ]; + return regras + .filter((regra) => regra.re.test(texto)) + .map((regra) => ({ + tipo: "conceito_publico" as const, + label: regra.label, + query: regra.query, + motivo: "Conceito público ajuda a entender o documento, mas não prova o evento.", + prioridade: regra.prioridade, + })); +} + +function extrairCampo(resumo: string | null, label: string): string | null { + if (!resumo) return null; + const re = new RegExp(`${label}\\s*:?\\s*([^.]+?)(?:\\.\\s|\\.$|$)`, "i"); + return re.exec(resumo)?.[1]?.trim() ?? null; +} + +function limparNome(nome: string): string { + return nome + .replace(/\b(?:CNPJ|CPF)\b.*$/i, "") + .replace(/\s+/g, " ") + .trim(); +} + +function normalizar(value: string): string { + return value + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); } diff --git a/docs/agents/checklist-pr-ui.md b/docs/agents/checklist-pr-ui.md index d10fdd8..ebcbd6c 100644 --- a/docs/agents/checklist-pr-ui.md +++ b/docs/agents/checklist-pr-ui.md @@ -17,6 +17,7 @@ Use este checklist em todo PR que altera UI, componentes, docs de design system - [ ] Explicação por IA está visualmente separada de fato oficial. - [ ] Sinal de atenção contém o aviso `Sinal de atenção não indica irregularidade`. - [ ] Nenhuma UI cria score de corrupção, ranking moral ou acusação automática. +- [ ] Reações e contribuições de usuário aparecem como opinião/contribuição, nunca como fato ou evidência. ## Estados @@ -52,6 +53,7 @@ Use este checklist em todo PR que altera UI, componentes, docs de design system - [ ] Sem tom partidário. - [ ] Sem termos `post`, `like`, `comentário livre` ou `seguir` na UI. - [ ] Limitações dizem o que falta e por quê. +- [ ] Mapa ou grafo de conexões não sugere culpa por proximidade visual. ## Handoff diff --git a/docs/components/civicos.md b/docs/components/civicos.md index 3eb3763..b58a387 100644 --- a/docs/components/civicos.md +++ b/docs/components/civicos.md @@ -174,7 +174,7 @@ Props de domínio persistente não devem ser acopladas diretamente ao banco. Use | O que é | Bloco visual para explicação gerada por IA a partir de fontes conhecidas. | | Quando usar | Resumos em português simples, explicação de termos e perguntas cívicas. | | Quando não usar | Para fato oficial, acusação, inferência sem fonte ou decisão pública. | -| Props | `texto`, `fontesUsadas`, `modelo`, `geradoEm`, `limites`, `disclaimer`. | +| Props | `texto`, `titulo`, `fontesUsadas`, `geradoEm`, `meta`, `limitacoes`, `className`. | | Estados | Padrão, carregando, sem fontes suficientes, erro, revisão pendente. | | Exemplo | `Explicação gerada a partir dos campos oficiais disponíveis.` | | Acessibilidade | Label `Explicação por IA` deve aparecer antes do texto. | @@ -272,6 +272,36 @@ Props de domínio persistente não devem ser acopladas diretamente ao banco. Use | Regras para agentes | Ações sensíveis precisam explicar consequência antes de continuar. | | Storybook | `/?path=/docs/deolho-contextualactiondrawer--docs` | +## ReacaoCivica + +| Campo | Definição | +|-------|-----------| +| O que é | Controle de reação estruturada para objetos públicos verificáveis. | +| Quando usar | Evento público, contrato, ato, documento ou página de entidade que admita opinião de usuário sem confundir com fato. | +| Quando não usar | Em pessoa física comum, pessoa pública como alvo individual, sinal de culpa, ranking ou julgamento moral. | +| Props | `contagem`, `className`; enum fechado: `apoio`, `parcial`, `contra`, `faltou_informacao`. | +| Estados | Sem reação, reação selecionada, contagem local, persistência pendente. | +| Exemplo | Usuário marca `faltou_informacao` em um contrato com evidência incompleta. | +| Acessibilidade | Botões com `aria-pressed`, label textual e ícone; não depender só de cor. | +| Erros comuns | Tratar reação como evidência; permitir reações em pessoas; usar como ranking de suspeita. | +| Regras para agentes | Reações são opinião de usuário e devem ser visualmente separadas de fonte/confiança. | +| Storybook | Futuro `/?path=/docs/deolho-reacaocivica--docs` | + +## ContribuicaoContextual + +| Campo | Definição | +|-------|-----------| +| O que é | Comentário curto e moderado, vinculado a evento, entidade ou evidência. | +| Quando usar | Para pergunta, contexto local ou correção com fonte indicada. | +| Quando não usar | Debate livre, acusação, denúncia sem fonte ou exposição de dado pessoal sensível. | +| Props | `contexto`, `texto`, `fonteOpcional`, `status`, `moderacao`. | +| Estados | Rascunho, pendente, aprovado, rejeitado, removido por moderação. | +| Exemplo | `A fonte municipal não informa a escola atendida; alguém encontrou documento complementar?` | +| Acessibilidade | Campo com limite claro de caracteres, erro legível e confirmação de envio. | +| Erros comuns | Publicar automaticamente; confundir contribuição com fato oficial. | +| Regras para agentes | Estado inicial público persistente deve ser `pending`; nada vira visível sem moderação. | +| Storybook | Futuro `/?path=/docs/deolho-contribuicaocontextual--docs` | + ## UniversalCommandSearch | Campo | Definição | diff --git a/docs/patterns/conteudo-linguagem.md b/docs/patterns/conteudo-linguagem.md index a2f9602..9dc663e 100644 --- a/docs/patterns/conteudo-linguagem.md +++ b/docs/patterns/conteudo-linguagem.md @@ -17,6 +17,10 @@ - página de entidade; - atualização de entidade; - evento público. +- reação estruturada; +- mapa de conexões; +- vínculo documentado; +- contribuição contextual moderada. ## Termos proibidos ou restritos @@ -32,6 +36,21 @@ - IA descobriu; - irregularidade comprovada sem fonte oficial. +## Reações cívicas + +Use apenas o enum fechado: + +- `apoio`; +- `parcial`; +- `contra`; +- `faltou informação`. + +Essas reações são opinião de usuário, não fato oficial. Não usar como ranking, sinal de culpa, priorização de suspeita ou evidência. + +## Contribuições contextuais + +Comentários públicos entram como contribuição contextual curta, vinculada a evento, entidade ou evidência. Estado inicial obrigatório: `pending`. Nada deve publicar automaticamente acusação, dado pessoal sensível ou afirmação sem fonte. + ## Como escrever sinais de atenção Use estrutura neutra: @@ -57,6 +76,44 @@ Limitações: [limitações]. IA nunca é fonte primária. +## Como escrever resumo mensal + +O resumo mensal do radar é uma leitura local a partir dos eventos públicos daquele período. Ele deve: + +- citar mês, território, volume de eventos, fontes e valores como dados publicados; +- recomendar acontecimentos do próprio mês sem sugerir culpa ou suspeita; +- declarar que valores são campos citados pelas fontes, não execução financeira completa; +- aparecer antes da lista de eventos em `mais recentes`, e a lista normal continua abaixo. + +## Como usar Wikipedia + +Wikipedia é contexto enciclopédico, não evidência do evento. + +Use para: + +- somente dentro da página de evento, nunca como bloco genérico da Home; +- órgão, instituição, lei, conceito público, empresa citada e pessoa pública em função pública quando aparecerem no próprio evento ou nas evidências; +- imagem contextual e resumo enciclopédico; +- ajudar o usuário a entender termos como licitação, pregão, lei citada ou órgão público. + +Não use para: + +- preencher contexto aleatório ou fallback automático de cidade; +- provar o contrato, pagamento, sanção, ato ou vínculo; +- substituir link oficial, trecho do documento ou fonte pública; +- enriquecer pessoa física comum; +- inferir relação entre entidades. + +Microcopy obrigatória: + +```text +Wikipedia contextualiza, mas não é fonte do evento. A prova continua nas evidências oficiais. +``` + +## Agente local + +Quando `DEOLHO_POST_AGENT_PROVIDER=local` ou `ollama`, a leitura organizada não consome tokens externos. Mesmo assim, ela continua sendo explicação, não fato oficial. A UI deve mostrar `modo local`, `Ollama` ou `OpenAI` de forma visível. + ## Como escrever limitação de fonte Use estrutura objetiva: diff --git a/docs/patterns/navegacao.md b/docs/patterns/navegacao.md index 43e8e32..7d6762d 100644 --- a/docs/patterns/navegacao.md +++ b/docs/patterns/navegacao.md @@ -37,6 +37,7 @@ Ações permitidas: - acompanhar entidade; - ver evidência; +- reagir de forma estruturada em objeto público; - reportar erro estruturado; - contribuir contexto; - exportar CSV/JSON; @@ -50,6 +51,8 @@ Ações proibidas: - acusar; - denunciar sem evidência. +Reações estruturadas e contribuições contextuais não substituem evidência. A interface deve deixar claro quando algo é opinião do usuário. + ## Drawer de ações `ContextualActionDrawer` detalha ações do botão `+`. O drawer precisa: @@ -74,3 +77,7 @@ Links para entidade relacionada devem deixar claro: - confiança ou limitação. Não criar relação implícita apenas por proximidade visual. + +## Mapa de conexões + +`/explorar` pode evoluir para mapa de conexões, mas a navegação precisa manter alternativa em lista. Cada aresta visível deve exibir tipo de vínculo, fonte, confiança e caminho para evidência. Expansão de rede deve ser controlada por profundidade e filtros para evitar que proximidade visual pareça acusação. diff --git a/docs/patterns/telas.md b/docs/patterns/telas.md index 8ace1e0..e4f0bde 100644 --- a/docs/patterns/telas.md +++ b/docs/patterns/telas.md @@ -13,7 +13,7 @@ Cada padrão de tela deve ser implementado primeiro com dados sintéticos marcad | Componentes usados | `TerritorialCarousel`, `EventoPublicoCard`, `EntidadeCard`, `UniversalCommandSearch`, `MobileBottomNav`, `FloatingActionButton`. | | Estados obrigatórios | Carregando, vazio, erro, fonte atrasada, dado incompleto. | | O que não fazer | Hero marketing, feed de posts, curtidas, comentários livres, ranking moral. | -| Checklist de PR | Mostra dados sintéticos; cada card tem tipo/fonte/confiança; mobile não depende de hover; estados estão implementados. | +| Checklist de PR | Mostra dados sintéticos quando aplicável; em `mais recentes`, abre com resumo local do mês antes da lista; cada card tem tipo/fonte/confiança, mini-ficha de contexto, evidência e mídia visual neutra; mobile não depende de hover; estados estão implementados. | ## Radar de mudanças públicas @@ -52,7 +52,7 @@ Cada padrão de tela deve ser implementado primeiro com dados sintéticos marcad | Componentes usados | `ContratoCard`, `EvidenciaLink`, `PainelEvidencia`, `BlocoExplicacaoIA`, `BlocoLimitacaoDado`, `SinalAtencaoCard`. | | Estados obrigatórios | Carregando, contrato não encontrado, payload parcial, fonte atrasada, campo contraditório. | | O que não fazer | Inferir irregularidade por valor, fornecedor, prazo ou órgão. | -| Checklist de PR | Campos têm fonte; resumo IA cita base usada; export preserva metadados; sinais têm método e aviso. | +| Checklist de PR | Campos têm fonte; leitura IA/local cita base usada; Wikipedia é contexto e não evidência; export preserva metadados; sinais têm método e aviso. | ## Página de empresa @@ -106,6 +106,19 @@ Cada padrão de tela deve ser implementado primeiro com dados sintéticos marcad | O que não fazer | Chat principal, busca que ranqueia suspeita, resultado sem tipo/fonte. | | Checklist de PR | Resultado tem tipo; dados públicos têm fonte; teclado funciona; vazio é útil. | +## Mapa de conexões + +| Campo | Diretriz | +|-------|----------| +| Objetivo | Visualizar entidades públicas e vínculos documentados sem transformar conexão em acusação. | +| Usuário principal | Jornalista, pesquisador, cidadão engajado ou contribuidor investigando relações verificáveis. | +| Layout desktop | Grafo compacto à esquerda/centro, lista de relações e painel de evidência à direita. | +| Layout mobile | Lista de relações primeiro; grafo como visualização complementar com expansão controlada. | +| Componentes usados | `EntidadeCard`, `FonteBadge`, `ConfiancaBadge`, `EvidenciaLink`, `BlocoLimitacaoDado`, futuro `GraphCanvas`. | +| Estados obrigatórios | Carregando, sem relações, fonte parcial, aresta sem evidência ocultada, erro de expansão. | +| O que não fazer | Renderizar rede completa por padrão; sugerir nepotismo ou conflito sem regra objetiva; usar espessura/cor como acusação. | +| Checklist de PR | Toda aresta tem fonte/evidência; profundidade tem limite; há alternativa textual acessível. | + ## Painel de fonte | Campo | Diretriz | @@ -117,7 +130,7 @@ Cada padrão de tela deve ser implementado primeiro com dados sintéticos marcad | Componentes usados | `PainelEvidencia`, `EvidenciaLink`, `BlocoLimitacaoDado`, `Table`. | | Estados obrigatórios | Sem evidência, fonte indisponível, atrasada, parcial, erro. | | O que não fazer | Esconder link oficial; depender de tooltip; misturar IA como fonte. | -| Checklist de PR | Datas estão visíveis; links têm labels; limitações explicam impacto. | +| Checklist de PR | Datas estão visíveis; links têm labels; limitações explicam impacto; a ficha pública responde o que aconteceu, por que/objeto, quem está envolvido, quando, onde e o que foi divulgado oficialmente. | ## Página de erro diff --git a/packages/collectors/.env.example b/packages/collectors/.env.example index e4723f9..911471d 100644 --- a/packages/collectors/.env.example +++ b/packages/collectors/.env.example @@ -6,3 +6,16 @@ DATABASE_URL=postgres://deolho:deolho@localhost:5432/deolho # Chave gratuita da API da CGU (CEIS/CNEP — sanções a empresas). # Cadastrar e-mail em: https://portaldatransparencia.gov.br/api-de-dados/cadastrar-email PORTAL_TRANSPARENCIA_API_KEY= + +# Agente leve de apresentação dos eventos. +# local = zero chamadas externas, determinístico e barato (padrão) +# ollama = LLM local via http://127.0.0.1:11434/api/generate +# openai = OpenAI Responses API +# auto = tenta Ollama e cai para local sem consumir tokens externos +DEOLHO_POST_AGENT_PROVIDER=local +DEOLHO_POST_AGENT_LOCAL_MODEL=llama3.2:3b +OLLAMA_HOST=http://127.0.0.1:11434 + +# Só necessário quando DEOLHO_POST_AGENT_PROVIDER=openai. +OPENAI_API_KEY= +DEOLHO_POST_AGENT_MODEL=gpt-4.1-nano diff --git a/packages/collectors/package.json b/packages/collectors/package.json index 501bdc6..fc0a63e 100644 --- a/packages/collectors/package.json +++ b/packages/collectors/package.json @@ -16,6 +16,8 @@ "map:tce": "tsx src/mappers/tce-sp.ts", "map:diario": "tsx src/mappers/diario-mentions.ts", "map:diario-atoms": "tsx src/mappers/diario-atoms.ts", + "format:posts": "tsx src/agents/format-posts.ts", + "sync:civic": "pnpm run map:pncp && pnpm run map:tce && pnpm run map:diario-atoms && pnpm run format:posts -- --limit=80", "enrich:socios": "tsx src/enrich/cnpj-socios.ts", "collect:transparencia": "tsx src/adapters/transparencia-americana.ts", "reconcile": "tsx src/reconcile/entities.ts", diff --git a/packages/collectors/src/agents/civic-post-agent.ts b/packages/collectors/src/agents/civic-post-agent.ts new file mode 100644 index 0000000..4277b5f --- /dev/null +++ b/packages/collectors/src/agents/civic-post-agent.ts @@ -0,0 +1,524 @@ +import path from "node:path"; +import dotenv from "dotenv"; +import { z } from "zod"; + +dotenv.config({ path: path.resolve(process.cwd(), "../../.env.local"), quiet: true }); +dotenv.config({ quiet: true }); + +export const CivicPostAgentOutputSchema = z.object({ + titulo: z.string().min(8).max(130), + resumo: z.string().min(16).max(460), + linhaFina: z.string().max(180), + leituraCompleta: z.string().min(24).max(3600), + etiquetas: z.array(z.string().min(2).max(32)).max(6), + conexoesTexto: z.array(z.string().min(4).max(90)).max(6), + aviso: z.string().max(180).nullable(), +}); + +export type CivicPostAgentOutput = z.infer; + +export interface CivicPostAgentInput { + id?: string; + categoria: string; + tipo: string; + titulo: string; + resumo: string | null; + valor: string | null; + sourceId: string; + sourceUrl: string | null; + dataEvento: string | null; + entidades: unknown; + limitacoes: unknown; + evidencias?: Array<{ + titulo: string | null; + sourceUrl: string | null; + trecho: string | null; + metodoExtracao?: string | null; + }>; +} + +type AgentProvider = "auto" | "openai" | "ollama" | "local"; +type AgentMode = "openai" | "ollama" | "local" | "fallback"; + +const PROVIDER = provider(); +const MODEL = process.env.DEOLHO_POST_AGENT_MODEL || "gpt-4.1-nano"; +const OLLAMA_MODEL = process.env.DEOLHO_POST_AGENT_LOCAL_MODEL || "llama3.2:3b"; +const RESPONSES_URL = "https://api.openai.com/v1/responses"; +let apiDisabledReason: string | null = null; + +function provider(): AgentProvider { + const raw = (process.env.DEOLHO_POST_AGENT_PROVIDER ?? "local").toLowerCase(); + if (raw === "auto" || raw === "openai" || raw === "ollama" || raw === "local") return raw; + return "local"; +} + +const OUTPUT_JSON_SCHEMA = { + name: "civic_post_agent_output", + strict: true, + schema: { + type: "object", + additionalProperties: false, + required: ["titulo", "resumo", "linhaFina", "leituraCompleta", "etiquetas", "conexoesTexto", "aviso"], + properties: { + titulo: { type: "string", minLength: 8, maxLength: 130 }, + resumo: { type: "string", minLength: 16, maxLength: 460 }, + linhaFina: { type: "string", maxLength: 180 }, + leituraCompleta: { type: "string", minLength: 24, maxLength: 3600 }, + etiquetas: { + type: "array", + maxItems: 6, + items: { type: "string", minLength: 2, maxLength: 32 }, + }, + conexoesTexto: { + type: "array", + maxItems: 6, + items: { type: "string", minLength: 4, maxLength: 90 }, + }, + aviso: { type: ["string", "null"], maxLength: 180 }, + }, + }, +}; + +const INSTRUCTIONS = [ + "Voce e o agente leve de apresentacao civica do DeOlho.", + "Sua unica funcao e formatar texto para um evento civico claro e verificavel.", + "Use somente os campos recebidos. Nao invente nomes, valores, datas, leis, vinculos, irregularidades ou relacoes.", + "Leia os trechos de evidencia quando existirem e use-os para escrever leituraCompleta com secoes curtas.", + "leituraCompleta deve explicar o documento inteiro em linguagem simples, preservando nomes, valores e datas exatamente como vieram no input.", + "Use secoes como: O que aconteceu, O que o documento diz, Pontos para acompanhar e Limitacoes.", + "Nao acuse pessoas ou empresas. Nao crie score, ranking de suspeita ou conclusao moral.", + "Se houver sancao ou sinal de atencao, escreva aviso neutro dizendo que nao indica irregularidade por si so.", + "Prefira portugues do Brasil simples, direto e sem sensacionalismo.", + "Otimize para custo: responda apenas JSON no schema pedido.", +].join("\n"); + +function crop(value: unknown, max: number): unknown { + if (typeof value === "string") return value.slice(0, max); + if (Array.isArray(value)) return value.slice(0, 12).map((item) => crop(item, max)); + if (value && typeof value === "object") { + const out: Record = {}; + for (const [key, val] of Object.entries(value).slice(0, 20)) out[key] = crop(val, max); + return out; + } + return value; +} + +function inputCompacto(input: CivicPostAgentInput): string { + return JSON.stringify({ + categoria: input.categoria, + tipo: input.tipo, + titulo: crop(input.titulo, 220), + resumo: crop(input.resumo, 900), + valor: input.valor, + fonte: input.sourceId, + temLinkFonte: Boolean(input.sourceUrl), + dataEvento: input.dataEvento, + entidades: crop(input.entidades, 700), + limitacoes: crop(input.limitacoes, 420), + evidencias: input.evidencias?.slice(0, 5).map((e) => ({ + titulo: crop(e.titulo, 120), + temLinkFonte: Boolean(e.sourceUrl), + metodoExtracao: e.metodoExtracao, + trecho: crop(e.trecho, Number(process.env.DEOLHO_POST_AGENT_EVIDENCE_MAX_CHARS ?? 3000)), + })) ?? [], + }); +} + +function extractOutputText(data: unknown): string { + const obj = data as { output_text?: unknown; output?: unknown }; + if (typeof obj.output_text === "string") return obj.output_text; + if (!Array.isArray(obj.output)) return ""; + for (const item of obj.output) { + const content = (item as { content?: unknown }).content; + if (!Array.isArray(content)) continue; + for (const part of content) { + const text = (part as { text?: unknown }).text; + if (typeof text === "string") return text; + } + } + return ""; +} + +function parseJsonOutput(text: string): CivicPostAgentOutput { + try { + return CivicPostAgentOutputSchema.parse(JSON.parse(text)); + } catch { + const inicio = text.indexOf("{"); + const fim = text.lastIndexOf("}"); + if (inicio >= 0 && fim > inicio) { + return CivicPostAgentOutputSchema.parse(JSON.parse(text.slice(inicio, fim + 1))); + } + throw new Error("saida do agente nao e JSON valido"); + } +} + +function valorCurto(valor: string | null): string | null { + if (!valor) return null; + const n = Number(valor); + if (!Number.isFinite(n) || n === 0) return null; + if (n >= 1_000_000) return `R$ ${(n / 1_000_000).toLocaleString("pt-BR", { maximumFractionDigits: 1 })} mi`; + if (n >= 10_000) return `R$ ${Math.round(n / 1000).toLocaleString("pt-BR")} mil`; + return n.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }); +} + +function fallback(input: CivicPostAgentInput): CivicPostAgentOutput { + const valor = valorCurto(input.valor); + const base = input.titulo.replace(/\s+/g, " ").trim(); + const historico = extrairHistoricoApresentacao(input.entidades); + const evidenciasTexto = (input.evidencias?.map((e) => e.trecho ?? "").join("\n") ?? "") + .replace(/\s+/g, " ") + .trim(); + const texto = [ + historico.tituloOriginal, + historico.resumoOriginal, + input.titulo, + input.resumo, + evidenciasTexto, + ].join("\n").replace(/\s+/g, " ").trim(); + const fornecedor = extrairCampo(texto, input.categoria === "pagamento" ? "Credor" : "Fornecedor") + ?? extrairCampo(texto, "Contratad[oa]") + ?? extrairCampo(texto, "Empresa"); + const fornecedorDoTitulo = fornecedor ?? extrairFornecedorDeTitulo(texto, input.categoria); + const orgao = extrairCampo(texto, "Órgão") ?? extrairCampo(texto, "Secretaria") ?? extrairSecretaria(texto); + const objeto = extrairCampo(evidenciasTexto, "Objeto") + ?? extrairObjetoSolto(evidenciasTexto) + ?? primeiraFrase(evidenciasTexto) + ?? extrairCampo(texto, "Objeto") + ?? extrairCampo(texto, "Finalidade") + ?? extrairCampo(texto, "Ação orçamentária") + ?? primeiraFrase(input.resumo ?? texto); + const label = labelCategoria(input.categoria); + const fonte = labelFonte(input.sourceId); + const titulo = tituloInteligente({ input, fornecedor: fornecedorDoTitulo, valor, base }); + const resumo = resumoInteligente({ input, fornecedor: fornecedorDoTitulo, orgao, objeto, valor, fonte, base: input.resumo ?? base }); + const aviso = input.categoria === "sancao" || input.tipo.includes("sinal") + ? "Sinal oficial não indica irregularidade por si só; consulte a fonte e o contexto." + : null; + + return { + titulo: titulo.length >= 8 ? titulo : `Atualização pública: ${label}`, + resumo: resumo.slice(0, 460) || "Atualização pública registrada com fonte documentada.", + linhaFina: [label, valor, fonte].filter(Boolean).join(" · ").slice(0, 180), + leituraCompleta: leituraCompletaFallback(input), + etiquetas: [label, fonte].filter(Boolean).slice(0, 6), + conexoesTexto: conexoesFallback(input), + aviso, + }; +} + +function extrairHistoricoApresentacao(entidades: unknown): { + tituloOriginal?: string; + resumoOriginal?: string; +} { + if (!entidades || typeof entidades !== "object" || Array.isArray(entidades)) return {}; + const apresentacao = (entidades as { apresentacao?: unknown }).apresentacao; + if (!apresentacao || typeof apresentacao !== "object" || Array.isArray(apresentacao)) return {}; + const item = apresentacao as { tituloOriginal?: unknown; resumoOriginal?: unknown }; + return { + tituloOriginal: typeof item.tituloOriginal === "string" ? item.tituloOriginal : undefined, + resumoOriginal: typeof item.resumoOriginal === "string" ? item.resumoOriginal : undefined, + }; +} + +function extrairFornecedorDeTitulo(texto: string, categoria: string): string | null { + const re = categoria === "pagamento" + ? /Prefeitura\s+paga\s+(?:R\$\s*[\d.,]+(?:\s*(?:mil|mi|milhões))?\s+)?a\s+(.+?)(?:\s+por\s+R\$|\.|$)/i + : /Prefeitura\s+contrata\s+(.+?)(?:\s+por\s+R\$|\.|$)/i; + return normalizarCampo(texto.match(re)?.[1]); +} + +function tituloInteligente(input: { + input: CivicPostAgentInput; + fornecedor: string | null; + valor: string | null; + base: string; +}): string { + const fornecedor = input.fornecedor ? nomeCurto(input.fornecedor, 54) : null; + if (input.input.categoria === "contratacao") { + return fornecedor + ? `Prefeitura contrata ${fornecedor}${input.valor ? ` por ${input.valor}` : ""}`.slice(0, 130) + : `Prefeitura registra contratação${input.valor ? ` de ${input.valor}` : ""}`.slice(0, 130); + } + if (input.input.categoria === "pagamento") { + return fornecedor + ? `Prefeitura paga ${input.valor ?? "despesa pública"} a ${fornecedor}`.slice(0, 130) + : `Prefeitura registra pagamento${input.valor ? ` de ${input.valor}` : ""}`.slice(0, 130); + } + if (input.input.categoria === "receita") return `Prefeitura registra receita${input.valor ? ` de ${input.valor}` : ""}`.slice(0, 130); + if (input.input.categoria === "sancao") { + return fornecedor ? `${fornecedor} aparece em cadastro oficial de sanção` : "Cadastro oficial registra sanção"; + } + const precisaTituloGenerico = input.base.length < 24 || /^[a-zà-ÿ]/.test(input.base); + const baseSeguro = precisaTituloGenerico ? tituloGenerico(input.input.categoria, input.input.tipo) : input.base; + return input.valor && !baseSeguro.includes(input.valor) + ? `${baseSeguro.slice(0, 90)} · ${input.valor}`.slice(0, 130) + : baseSeguro.slice(0, 130); +} + +function resumoInteligente(input: { + input: CivicPostAgentInput; + fornecedor: string | null; + orgao: string | null; + objeto: string | null; + valor: string | null; + fonte: string; + base: string; +}): string { + const partes: string[] = []; + const acao = input.input.categoria === "pagamento" + ? "registrou um pagamento" + : input.input.categoria === "receita" + ? "registrou uma receita" + : input.input.categoria === "sancao" + ? "registrou uma sanção em base pública" + : "registrou uma contratação"; + partes.push(`A fonte ${input.fonte} ${acao}${input.valor ? ` com valor citado de ${input.valor}` : ""}.`); + if (input.fornecedor) partes.push(`Envolvido: ${nomeCurto(input.fornecedor, 90)}.`); + if (input.orgao) partes.push(`Órgão citado: ${nomeCurto(input.orgao, 90)}.`); + if (input.objeto) partes.push(`Objeto/finalidade: ${nomeCurto(input.objeto, 210)}.`); + if (partes.length <= 1 && input.base) partes.push(input.base); + return partes.join(" "); +} + +function nomeCurto(value: string, max: number): string { + const texto = value + .replace(/\b(?:CNPJ|CPF)\b.*$/i, "") + .replace(/\s+/g, " ") + .trim(); + return texto.length > max ? `${texto.slice(0, max - 1).trimEnd()}…` : texto; +} + +function primeiraFrase(texto: string): string | null { + const frase = texto.match(/[^.!?]+[.!?]?/)?.[0]?.replace(/\s+/g, " ").trim(); + return frase && frase.length > 18 ? frase : null; +} + +function leituraCompletaFallback(input: CivicPostAgentInput): string { + const evidencias = input.evidencias?.filter((e) => e.trecho?.trim()).slice(0, 5) ?? []; + const textoBase = (evidencias.map((e) => e.trecho).join("\n\n") || input.resumo || input.titulo) + .replace(/\s+/g, " ") + .trim(); + const linhas = [ + `O que aconteceu\n${labelCategoria(input.categoria)} registrado em ${labelFonte(input.sourceId)}.${input.valor ? ` Valor citado: ${valorCurto(input.valor) ?? input.valor}.` : ""}`, + `O que o documento diz\n${quebrarEmParagrafos(textoBase, 480).join("\n\n")}`, + `Pontos para acompanhar\nConfira a evidência oficial, os nomes citados, valores, prazos e órgãos envolvidos antes de tirar qualquer conclusão.`, + `Limitações\nEsta leitura local apenas organiza o texto disponível. Quando a fonte não informa data, objeto completo ou vínculo documentado, o DeOlho deve mostrar essa limitação.`, + ].filter((linha): linha is string => Boolean(linha)); + return linhas.join("\n\n").slice(0, 3600); +} + +function quebrarEmParagrafos(texto: string, tamanho: number): string[] { + const frases = texto.match(/[^.!?]+[.!?]?/g)?.map((s) => s.trim()).filter(Boolean) ?? [texto]; + const out: string[] = []; + let atual = ""; + for (const frase of frases) { + if ((atual + " " + frase).trim().length > tamanho && atual) { + out.push(atual); + atual = frase; + } else { + atual = `${atual} ${frase}`.trim(); + } + if (out.length >= 3) break; + } + if (atual && out.length < 4) out.push(atual); + return out; +} + +function conexoesFallback(input: CivicPostAgentInput): string[] { + const texto = `${input.titulo}. ${input.resumo ?? ""}`; + return [ + extrairCampo(texto, "Fornecedor"), + extrairCampo(texto, "Credor"), + extrairCampo(texto, "Órgão"), + extrairCampo(texto, "Modalidade"), + extrairCampo(texto, "Tipo"), + ].filter((item, index, arr): item is string => Boolean(item) && arr.indexOf(item) === index).slice(0, 6); +} + +function extrairCampo(texto: string, label: string): string | null { + const labels = "Fornecedor|Credor|Contratad[oa]|Empresa|Órgão|Orgao|Secretaria|Objeto|Finalidade|Ação orçamentária|Acao orcamentaria|Valor|Modalidade|Fonte|Tipo"; + const re = new RegExp(`${label}\\s*:\\s*([^.;\\n]+?)(?=\\s+(?:${labels})\\s*:|[.;\\n]|$)`, "i"); + const valor = normalizarCampo(texto.match(re)?.[1]); + if (!valor || valor.length < 4) return null; + return valor.slice(0, 90); +} + +function extrairObjetoSolto(texto: string): string | null { + const re = /\bObjeto\s+(.+?)(?=\s+(?:Fornecedor|Credor|Contratad[oa]|Empresa|Órgão|Valor|Modalidade|Fonte|Tipo)\s*:|[.;\n]|$)/i; + const valor = normalizarCampo(texto.match(re)?.[1]); + if (!valor || valor.length < 12) return null; + return nomeCurto(valor, 210); +} + +function normalizarCampo(value: string | undefined): string | null { + if (!value) return null; + const limpo = value + .replace(/\s+/g, " ") + .replace(/\b(?:Fornecedor|Credor|Contratad[oa]|Empresa|Órgão|Orgao|Objeto|Finalidade|Valor|Modalidade|Fonte|Tipo)\s*:.*$/i, "") + .replace(/^[,:;\-\s]+|[,:;\-\s]+$/g, "") + .trim(); + if (!limpo || /^não informado$/i.test(limpo)) return null; + return limpo; +} + +function extrairSecretaria(texto: string): string | null { + const m = texto.match(/\bSECRETARIA\s+(?:MUNICIPAL\s+)?DE\s+([A-ZÀ-Ú\s]{3,40})/i); + if (!m?.[1]) return null; + return `Secretaria de ${titleCasePt(m[1].replace(/\s+/g, " ").trim())}`; +} + +function titleCasePt(texto: string): string { + return texto + .toLocaleLowerCase("pt-BR") + .split(/\s+/) + .map((palavra) => palavra.charAt(0).toLocaleUpperCase("pt-BR") + palavra.slice(1)) + .join(" "); +} + +function labelCategoria(categoria: string): string { + const map: Record = { + contratacao: "Contratação", + pagamento: "Pagamento", + receita: "Receita", + ato_normativo: "Ato oficial", + nomeacao_exoneracao: "Cargo público", + sancao: "Sanção", + audiencia_conselho: "Audiência ou conselho", + obra_zeladoria: "Obra ou zeladoria", + limitacao_fonte: "Fonte parcial", + }; + return map[categoria] ?? categoria.replace(/_/g, " "); +} + +function labelFonte(sourceId: string): string { + const map: Record = { + pncp: "PNCP", + "tce-sp": "TCE-SP", + "diario-americana": "Diário de Americana", + "cgu-transparencia": "CGU Transparência", + "camara-americana": "Câmara de Americana", + }; + return map[sourceId] ?? sourceId; +} + +function tituloGenerico(categoria: string, tipo: string): string { + if (categoria === "contratacao") return tipo.includes("licitacao") ? "Licitação publicada em Americana" : "Contrato público registrado em Americana"; + if (categoria === "pagamento") return "Pagamento público registrado em Americana"; + if (categoria === "receita") return "Receita pública registrada em Americana"; + if (categoria === "sancao") return "Sanção oficial registrada em base pública"; + if (categoria === "nomeacao_exoneracao") return "Mudança de cargo público registrada"; + if (categoria === "obra_zeladoria") return "Atualização sobre obra ou zeladoria"; + return "Ato oficial publicado em Americana"; +} + +export async function formatarComAgenteLeve(input: CivicPostAgentInput): Promise<{ + output: CivicPostAgentOutput; + modo: AgentMode; + modelo: string | null; +}> { + if (PROVIDER === "local") return { output: fallback(input), modo: "local", modelo: "deterministico-local" }; + if (PROVIDER === "ollama") return formatarComOllama(input); + if (PROVIDER === "auto") { + const local = await tentarOllama(input); + if (local) return local; + return formatarComOpenAI(input); + } + return formatarComOpenAI(input); +} + +async function formatarComOpenAI(input: CivicPostAgentInput): Promise<{ + output: CivicPostAgentOutput; + modo: AgentMode; + modelo: string | null; +}> { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) return { output: fallback(input), modo: "fallback", modelo: null }; + if (apiDisabledReason) return { output: fallback(input), modo: "fallback", modelo: null }; + + try { + const res = await fetch(RESPONSES_URL, { + method: "POST", + signal: AbortSignal.timeout(20_000), + headers: { + "content-type": "application/json", + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MODEL, + instructions: INSTRUCTIONS, + input: inputCompacto(input), + max_output_tokens: Number(process.env.DEOLHO_POST_AGENT_MAX_OUTPUT_TOKENS ?? 1200), + text: { + format: { + type: "json_schema", + ...OUTPUT_JSON_SCHEMA, + }, + }, + }), + }); + + if (!res.ok) { + const body = await res.text(); + if (res.status === 429 && body.includes("insufficient_quota")) { + apiDisabledReason = "insufficient_quota"; + console.warn("[post-agent] OpenAI sem cota; usando fallback deterministico nesta execução."); + } + throw new Error(`OpenAI ${res.status}: ${body}`); + } + const data = await res.json() as unknown; + const text = extractOutputText(data); + const parsed = parseJsonOutput(text); + return { output: parsed, modo: "openai", modelo: MODEL }; + } catch (e) { + if (!apiDisabledReason) console.warn(`[post-agent] fallback deterministico (${e instanceof Error ? e.message : e})`); + return { output: fallback(input), modo: "fallback", modelo: null }; + } +} + +async function tentarOllama(input: CivicPostAgentInput): Promise<{ + output: CivicPostAgentOutput; + modo: AgentMode; + modelo: string | null; +} | null> { + try { + return await formatarComOllama(input); + } catch { + return null; + } +} + +async function formatarComOllama(input: CivicPostAgentInput): Promise<{ + output: CivicPostAgentOutput; + modo: AgentMode; + modelo: string | null; +}> { + try { + const host = (process.env.OLLAMA_HOST || "http://127.0.0.1:11434").replace(/\/+$/, ""); + const res = await fetch(`${host}/api/generate`, { + method: "POST", + signal: AbortSignal.timeout(Number(process.env.DEOLHO_POST_AGENT_LOCAL_TIMEOUT_MS ?? 45_000)), + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: OLLAMA_MODEL, + stream: false, + format: "json", + prompt: [ + INSTRUCTIONS, + "Responda somente JSON valido, sem markdown, no schema: titulo, resumo, linhaFina, leituraCompleta, etiquetas, conexoesTexto, aviso.", + inputCompacto(input), + ].join("\n\n"), + options: { + temperature: 0.1, + num_predict: Number(process.env.DEOLHO_POST_AGENT_LOCAL_NUM_PREDICT ?? 1400), + }, + }), + }); + if (!res.ok) throw new Error(`Ollama ${res.status}: ${await res.text()}`); + const data = await res.json() as { response?: unknown }; + const parsed = parseJsonOutput(typeof data.response === "string" ? data.response : ""); + return { output: parsed, modo: "ollama", modelo: OLLAMA_MODEL }; + } catch (e) { + console.warn(`[post-agent] Ollama indisponivel; usando modo local (${e instanceof Error ? e.message : e})`); + return { output: fallback(input), modo: "local", modelo: "deterministico-local" }; + } +} diff --git a/packages/collectors/src/agents/format-posts.ts b/packages/collectors/src/agents/format-posts.ts new file mode 100644 index 0000000..457b902 --- /dev/null +++ b/packages/collectors/src/agents/format-posts.ts @@ -0,0 +1,125 @@ +import "dotenv/config"; +import { eq, schema, sql } from "@deolho/db"; +import { closeDb, getDb } from "../utils/ingest.js"; +import { formatarComAgenteLeve, type CivicPostAgentInput } from "./civic-post-agent.js"; + +const { civicEvents } = schema; + +interface Args { + limit: number; + force: boolean; + dryRun: boolean; + since: string; +} + +function args(): Args { + const argv = process.argv.slice(2); + const value = (name: string): string | null => { + const i = argv.indexOf(name); + const inline = argv.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + return i >= 0 ? argv[i + 1] ?? null : null; + }; + return { + limit: Math.min(Number(value("--limit") ?? 50), 250), + force: argv.includes("--force"), + dryRun: argv.includes("--dry-run"), + since: normalizarData(value("--since") ?? "2026-01-01"), + }; +} + +function normalizarData(value: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { + throw new Error("--since deve usar o formato YYYY-MM-DD"); + } + return value; +} + +function rowsFrom(result: unknown): T[] { + if (Array.isArray(result)) return result as T[]; + return ((result as { rows?: unknown[] }).rows ?? []) as T[]; +} + +function mergeEntidades(entidades: unknown, meta: unknown): Record { + return { + ...(entidades && typeof entidades === "object" && !Array.isArray(entidades) ? entidades as Record : {}), + apresentacao: meta, + }; +} + +export async function formatarEventosRecentes(options: Args = args()): Promise { + const db = getDb(); + if (!db) { + console.error("[format:posts] DATABASE_URL não definida."); + return; + } + + const result = await db.execute(sql` + select id, categoria, tipo, titulo, resumo, valor::text as valor, + source_id as "sourceId", source_url as "sourceUrl", + data_evento as "dataEvento", entidades, limitacoes + from civic_events + where source_url is not null + and coalesce(data_evento, published_at::date) >= ${options.since}::date + ${options.force ? sql`` : sql`and (entidades is null or entidades->'apresentacao' is null)`} + order by coalesce(data_evento, published_at::date) desc nulls last, criado_em desc + limit ${options.limit} + `); + const rows = rowsFrom(result); + console.log(`[format:posts] ${rows.length} eventos desde ${options.since} para formatar`); + + let n = 0; + for (const row of rows) { + const evidencias = rowsFrom[number]>(await db.execute(sql` + select titulo, source_url as "sourceUrl", trecho, metodo_extracao as "metodoExtracao" + from evidence + where civic_event_id = ${row.id} + order by criado_em asc + limit 3 + `)); + const input: CivicPostAgentInput = { ...row, evidencias }; + const { output, modo, modelo } = await formatarComAgenteLeve(input); + const apresentacao = { + v: 1, + modo, + modelo, + linhaFina: output.linhaFina, + leituraCompleta: output.leituraCompleta, + etiquetas: output.etiquetas, + conexoesTexto: output.conexoesTexto, + aviso: output.aviso, + tituloOriginal: row.titulo, + resumoOriginal: row.resumo, + evidenciasLidas: evidencias.length, + desde: options.since, + geradoEm: new Date().toISOString(), + }; + + if (options.dryRun) { + console.log(`[format:posts] ${row.id}: ${output.titulo}`); + continue; + } + + await db + .update(civicEvents) + .set({ + titulo: output.titulo, + resumo: output.resumo, + entidades: mergeEntidades(row.entidades, apresentacao), + atualizadoEm: new Date(), + }) + .where(eq(civicEvents.id, row.id!)); + n++; + } + + console.log(`[format:posts] ${n} eventos atualizados`); +} + +if (process.argv[1]?.endsWith("format-posts.ts") || process.argv[1]?.endsWith("format-posts.js")) { + formatarEventosRecentes() + .catch((e) => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => closeDb()); +} diff --git a/packages/collectors/src/mappers/diario-atoms.ts b/packages/collectors/src/mappers/diario-atoms.ts index 5689f88..96c1f19 100644 --- a/packages/collectors/src/mappers/diario-atoms.ts +++ b/packages/collectors/src/mappers/diario-atoms.ts @@ -111,6 +111,27 @@ function resumo(atom: Atom): string { return atom.subtitulo ?? atom.textoDocumento ?? atom.resumoLimpo ?? atom.resumo; } +function limitacoes(atom: Atom): Array<{ campo: string; mensagem: string; tipo?: string; fonte?: string }> | null { + const out: Array<{ campo: string; mensagem: string; tipo?: string; fonte?: string }> = []; + if (atom.tipo === "indefinido") { + out.push({ + campo: "tipo", + mensagem: "A publicação foi segmentada, mas ainda não foi classificada com segurança.", + tipo: "dado_ausente", + fonte: "diario-americana", + }); + } + if (!atom.edicaoDate) { + out.push({ + campo: "data pública", + mensagem: "O Diário Oficial de Americana não informou data para esta publicação; ela não deve ser tratada como recente.", + tipo: "dado_ausente", + fonte: "diario-americana", + }); + } + return out.length > 0 ? out : null; +} + export async function mapearAtomosDiario(): Promise { const db = getDb(); if (!db) { @@ -152,9 +173,7 @@ export async function mapearAtomosDiario(): Promise { pessoas: atom.pessoas ?? [], orgaos: atom.orgaos ?? [], }, - limitacoes: atom.tipo === "indefinido" - ? [{ campo: "tipo", mensagem: "A publicação foi segmentada, mas ainda não foi classificada com segurança." }] - : null, + limitacoes: limitacoes(atom), sourceUrl: url, publishedAt: atom.edicaoDate ? new Date(atom.edicaoDate) : null, fetchedAt: null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 165652d..c47ef5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: tailwindcss: specifier: ^4 version: 4.3.0 + tsx: + specifier: ^4.22.3 + version: 4.22.3 typescript: specifier: ^5 version: 5.9.3