diff --git a/backend/src/main/java/br/com/fiap/vigisus/controller/AdminDashboardController.java b/backend/src/main/java/br/com/fiap/vigisus/controller/AdminDashboardController.java new file mode 100644 index 0000000..fa82d89 --- /dev/null +++ b/backend/src/main/java/br/com/fiap/vigisus/controller/AdminDashboardController.java @@ -0,0 +1,121 @@ +package br.com.fiap.vigisus.controller; + +import br.com.fiap.vigisus.application.epidemiologia.ConsultarBrasilEpidemiologicoUseCase; +import br.com.fiap.vigisus.application.epidemiologia.ConsultarRankingMunicipalUseCase; +import br.com.fiap.vigisus.dto.AdminBuscaIaDTO; +import br.com.fiap.vigisus.dto.AdminResumoDTO; +import br.com.fiap.vigisus.dto.BrasilEpidemiologicoResponse; +import br.com.fiap.vigisus.dto.EstadoDTO; +import br.com.fiap.vigisus.dto.MunicipioRiscoDTO; +import br.com.fiap.vigisus.dto.RankingMunicipioDTO; +import br.com.fiap.vigisus.dto.RankingResponse; +import br.com.fiap.vigisus.service.IaBuscaTracker; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; + +@RestController +@RequestMapping("/admin") +@Tag(name = "Admin Dashboard", description = "Endpoints do painel administrativo de monitoramento epidemiológico") +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class AdminDashboardController { + + private final ConsultarBrasilEpidemiologicoUseCase consultarBrasilUseCase; + private final ConsultarRankingMunicipalUseCase consultarRankingUseCase; + private final IaBuscaTracker iaBuscaTracker; + + @GetMapping("/resumo") + @Operation( + summary = "KPIs nacionais", + description = "Retorna os indicadores-chave nacionais: total de casos, incidência, classificação, tendência, municípios e estados afetados") + public AdminResumoDTO getResumo( + @RequestParam(defaultValue = "dengue") String doenca, + @RequestParam(required = false) Integer ano) { + int anoConsulta = ano != null ? ano : LocalDate.now().getYear(); + BrasilEpidemiologicoResponse brasil = consultarBrasilUseCase.buscar(doenca, anoConsulta); + + long municipiosAltoRisco = 0; + long municipiosEpidemia = 0; + int totalEstados = 0; + + if (brasil.getEstadosPiores() != null) { + totalEstados = brasil.getEstadosPiores().size(); + } + if (brasil.getMunicipiosPiores() != null) { + municipiosAltoRisco = brasil.getMunicipiosPiores().stream() + .filter(m -> "ALTO".equalsIgnoreCase(m.getClassificacao()) + || "MUITO_ALTO".equalsIgnoreCase(m.getClassificacao())) + .count(); + municipiosEpidemia = brasil.getMunicipiosPiores().stream() + .filter(m -> "EPIDEMIA".equalsIgnoreCase(m.getClassificacao())) + .count(); + } + + return AdminResumoDTO.builder() + .totalCasos(brasil.getTotalCasos()) + .incidenciaNacional(brasil.getIncidencia()) + .classificacaoNacional(brasil.getClassificacao()) + .tendencia(brasil.getTendencia()) + .totalMunicipiosComDados(0) + .totalEstadosAfetados(totalEstados) + .municipiosAltoRisco((int) municipiosAltoRisco) + .municipiosEpidemia((int) municipiosEpidemia) + .doenca(brasil.getDoenca()) + .ano(brasil.getAno()) + .build(); + } + + @GetMapping("/top-municipios") + @Operation( + summary = "Top N municípios por incidência", + description = "Retorna os municípios com maior incidência de dengue para uso no gráfico de ranking") + public List getTopMunicipios( + @RequestParam(defaultValue = "10") int top, + @RequestParam(defaultValue = "dengue") String doenca, + @RequestParam(required = false) Integer ano) { + RankingResponse ranking = consultarRankingUseCase.buscar(null, doenca, ano, top, "piores"); + return ranking.getRanking() != null ? ranking.getRanking() : List.of(); + } + + @GetMapping("/top-estados") + @Operation( + summary = "Top estados por total de casos", + description = "Retorna os estados com maior número de casos para uso no mapa e gráfico de barras") + public List getTopEstados( + @RequestParam(defaultValue = "dengue") String doenca, + @RequestParam(required = false) Integer ano) { + int anoConsulta = ano != null ? ano : LocalDate.now().getYear(); + BrasilEpidemiologicoResponse brasil = consultarBrasilUseCase.buscar(doenca, anoConsulta); + return brasil.getEstadosPiores() != null ? brasil.getEstadosPiores() : List.of(); + } + + @GetMapping("/municipios-risco") + @Operation( + summary = "Municípios classificados por risco", + description = "Retorna a lista de municípios críticos com classificação de risco (EPIDEMIA / ALTO / MODERADO / BAIXO)") + public List getMunicipiosRisco( + @RequestParam(defaultValue = "dengue") String doenca, + @RequestParam(required = false) Integer ano) { + int anoConsulta = ano != null ? ano : LocalDate.now().getYear(); + BrasilEpidemiologicoResponse brasil = consultarBrasilUseCase.buscar(doenca, anoConsulta); + return brasil.getMunicipiosPiores() != null ? brasil.getMunicipiosPiores() : List.of(); + } + + @GetMapping("/buscas-ia") + @Operation( + summary = "Perguntas mais frequentes feitas à IA", + description = "Retorna o ranking das perguntas mais frequentes submetidas ao endpoint /api/busca desde a última inicialização do servidor") + public List getBuscasIa( + @RequestParam(defaultValue = "20") int top) { + return iaBuscaTracker.listarMaisFrequentes(top); + } +} diff --git a/backend/src/main/java/br/com/fiap/vigisus/controller/BuscaController.java b/backend/src/main/java/br/com/fiap/vigisus/controller/BuscaController.java index dee4f2c..d0c9612 100644 --- a/backend/src/main/java/br/com/fiap/vigisus/controller/BuscaController.java +++ b/backend/src/main/java/br/com/fiap/vigisus/controller/BuscaController.java @@ -3,6 +3,7 @@ import br.com.fiap.vigisus.application.busca.BuscaCompletaUseCase; import br.com.fiap.vigisus.dto.BuscaCompletaResponse; import br.com.fiap.vigisus.dto.BuscaRequest; +import br.com.fiap.vigisus.service.IaBuscaTracker; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -24,12 +25,14 @@ public class BuscaController { private final BuscaCompletaUseCase buscaCompletaUseCase; + private final IaBuscaTracker iaBuscaTracker; @PostMapping @Operation( summary = "Busca por linguagem natural", description = "Interpreta a pergunta, consulta a base de dados e retorna resposta narrativa gerada por IA") public BuscaCompletaResponse buscar(@Valid @RequestBody BuscaRequest request) { + iaBuscaTracker.registrar(request.getPergunta()); return buscaCompletaUseCase.buscarPorPergunta(request.getPergunta()); } diff --git a/backend/src/main/java/br/com/fiap/vigisus/dto/AdminBuscaIaDTO.java b/backend/src/main/java/br/com/fiap/vigisus/dto/AdminBuscaIaDTO.java new file mode 100644 index 0000000..f264b7f --- /dev/null +++ b/backend/src/main/java/br/com/fiap/vigisus/dto/AdminBuscaIaDTO.java @@ -0,0 +1,17 @@ +package br.com.fiap.vigisus.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminBuscaIaDTO { + + private String pergunta; + private long contagem; + private String ultimaConsulta; +} diff --git a/backend/src/main/java/br/com/fiap/vigisus/dto/AdminResumoDTO.java b/backend/src/main/java/br/com/fiap/vigisus/dto/AdminResumoDTO.java new file mode 100644 index 0000000..99e9076 --- /dev/null +++ b/backend/src/main/java/br/com/fiap/vigisus/dto/AdminResumoDTO.java @@ -0,0 +1,24 @@ +package br.com.fiap.vigisus.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AdminResumoDTO { + + private long totalCasos; + private double incidenciaNacional; + private String classificacaoNacional; + private String tendencia; + private int totalMunicipiosComDados; + private int totalEstadosAfetados; + private int municipiosAltoRisco; + private int municipiosEpidemia; + private String doenca; + private int ano; +} diff --git a/backend/src/main/java/br/com/fiap/vigisus/service/IaBuscaTracker.java b/backend/src/main/java/br/com/fiap/vigisus/service/IaBuscaTracker.java new file mode 100644 index 0000000..6551084 --- /dev/null +++ b/backend/src/main/java/br/com/fiap/vigisus/service/IaBuscaTracker.java @@ -0,0 +1,56 @@ +package br.com.fiap.vigisus.service; + +import br.com.fiap.vigisus.dto.AdminBuscaIaDTO; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Service +public class IaBuscaTracker { + + private static final int MAX_QUERY_LENGTH = 120; + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm"); + + private static final class Entrada { + final AtomicLong contagem = new AtomicLong(0); + volatile String ultimaConsulta; + } + + private final ConcurrentHashMap entradas = new ConcurrentHashMap<>(); + + public void registrar(String pergunta) { + if (pergunta == null || pergunta.isBlank()) { + return; + } + String chave = normalizar(pergunta); + Entrada entrada = entradas.computeIfAbsent(chave, k -> new Entrada()); + entrada.contagem.incrementAndGet(); + entrada.ultimaConsulta = LocalDateTime.now().format(FORMATTER); + } + + public List listarMaisFrequentes(int limite) { + return entradas.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue().contagem.get(), a.getValue().contagem.get())) + .limit(limite) + .map(e -> AdminBuscaIaDTO.builder() + .pergunta(e.getKey()) + .contagem(e.getValue().contagem.get()) + .ultimaConsulta(e.getValue().ultimaConsulta != null ? e.getValue().ultimaConsulta : "-") + .build()) + .collect(Collectors.toList()); + } + + private String normalizar(String pergunta) { + String normalizada = pergunta.trim().toLowerCase(); + if (normalizada.length() > MAX_QUERY_LENGTH) { + normalizada = normalizada.substring(0, MAX_QUERY_LENGTH); + } + return normalizada; + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f040ec7..f19a72e 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -21,13 +21,18 @@ springdoc: path: /v3/api-docs server: - port: 8080 + port: 9090 + +spring: + web: + resources: + static-locations: classpath:/static/ management: endpoints: web: exposure: - include: health,info + include: health,info,metrics,caches endpoint: health: show-details: always diff --git a/backend/src/main/resources/static/admin/index.html b/backend/src/main/resources/static/admin/index.html new file mode 100644 index 0000000..d987700 --- /dev/null +++ b/backend/src/main/resources/static/admin/index.html @@ -0,0 +1,458 @@ + + + + + + VigiSUS — Painel Administrativo + + + + + +
+

VigiSUS — Painel Administrativo

+
Última atualização:  |  próxima em 30s
+
+ +
+ + +

Indicadores Nacionais (Dengue)

+
+
+ Total de Casos + + +
+
+ Incidência / 100k hab. + + +
+
+ Tendência Epidêmica + + +
+
+ Municípios em Epidemia + + +
+
+ + +

Ranking e Distribuição Geográfica

+
+
+

Top 10 Municípios — Incidência / 100k

+ +
+
+

Top Estados — Total de Casos

+ +
+
+ + +

Risco e Inteligência Artificial

+
+
+

Painel de Risco — Municípios Críticos

+
  • Carregando…
+
+
+

Perguntas Frequentes — IA

+
  • Carregando…
+
+
+ +
+ +
VigiSUS © 2025 · FIAP Tech Challenge · dados do SINAN/IBGE
+ + + + diff --git a/backend/src/test/java/br/com/fiap/vigisus/controller/AdminDashboardControllerTest.java b/backend/src/test/java/br/com/fiap/vigisus/controller/AdminDashboardControllerTest.java new file mode 100644 index 0000000..573c610 --- /dev/null +++ b/backend/src/test/java/br/com/fiap/vigisus/controller/AdminDashboardControllerTest.java @@ -0,0 +1,135 @@ +package br.com.fiap.vigisus.controller; + +import br.com.fiap.vigisus.application.epidemiologia.ConsultarBrasilEpidemiologicoUseCase; +import br.com.fiap.vigisus.application.epidemiologia.ConsultarRankingMunicipalUseCase; +import br.com.fiap.vigisus.dto.AdminBuscaIaDTO; +import br.com.fiap.vigisus.dto.AdminResumoDTO; +import br.com.fiap.vigisus.dto.BrasilEpidemiologicoResponse; +import br.com.fiap.vigisus.dto.EstadoDTO; +import br.com.fiap.vigisus.dto.MunicipioRiscoDTO; +import br.com.fiap.vigisus.dto.RankingMunicipioDTO; +import br.com.fiap.vigisus.dto.RankingResponse; +import br.com.fiap.vigisus.service.IaBuscaTracker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AdminDashboardControllerTest { + + private ConsultarBrasilEpidemiologicoUseCase brasilUseCase; + private ConsultarRankingMunicipalUseCase rankingUseCase; + private IaBuscaTracker tracker; + private AdminDashboardController controller; + + @BeforeEach + void setUp() { + brasilUseCase = mock(ConsultarBrasilEpidemiologicoUseCase.class); + rankingUseCase = mock(ConsultarRankingMunicipalUseCase.class); + tracker = mock(IaBuscaTracker.class); + controller = new AdminDashboardController(brasilUseCase, rankingUseCase, tracker); + } + + @Test + void getResumo_retornaKpisNacionais() { + EstadoDTO est1 = EstadoDTO.builder().sgUf("SP").totalCasos(100_000L).build(); + EstadoDTO est2 = EstadoDTO.builder().sgUf("MG").totalCasos(50_000L).build(); + MunicipioRiscoDTO mun1 = MunicipioRiscoDTO.builder().municipio("Campinas").classificacao("EPIDEMIA").build(); + MunicipioRiscoDTO mun2 = MunicipioRiscoDTO.builder().municipio("BH").classificacao("ALTO").build(); + + BrasilEpidemiologicoResponse resp = BrasilEpidemiologicoResponse.builder() + .totalCasos(500_000L) + .incidencia(234.5) + .classificacao("EPIDEMIA") + .tendencia("CRESCENTE") + .doenca("dengue") + .ano(2025) + .estadosPiores(List.of(est1, est2)) + .municipiosPiores(List.of(mun1, mun2)) + .build(); + + when(brasilUseCase.buscar(eq("dengue"), anyInt())).thenReturn(resp); + + AdminResumoDTO resumo = controller.getResumo("dengue", null); + + assertThat(resumo.getTotalCasos()).isEqualTo(500_000L); + assertThat(resumo.getClassificacaoNacional()).isEqualTo("EPIDEMIA"); + assertThat(resumo.getTotalEstadosAfetados()).isEqualTo(2); + assertThat(resumo.getMunicipiosEpidemia()).isEqualTo(1); + assertThat(resumo.getMunicipiosAltoRisco()).isEqualTo(1); + } + + @Test + void getTopMunicipios_retornaListaDoRanking() { + RankingMunicipioDTO mun = RankingMunicipioDTO.builder() + .posicao(1).municipio("Campinas").incidencia100k(1500.0).build(); + RankingResponse rankResp = RankingResponse.builder().ranking(List.of(mun)).build(); + + when(rankingUseCase.buscar(any(), anyString(), any(), eq(10), eq("piores"))).thenReturn(rankResp); + + List result = controller.getTopMunicipios(10, "dengue", null); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getMunicipio()).isEqualTo("Campinas"); + } + + @Test + void getTopEstados_retornaEstadosPiores() { + EstadoDTO e = EstadoDTO.builder().sgUf("SP").totalCasos(99_999L).build(); + BrasilEpidemiologicoResponse resp = BrasilEpidemiologicoResponse.builder() + .estadosPiores(List.of(e)).doenca("dengue").ano(2025).build(); + + when(brasilUseCase.buscar(anyString(), anyInt())).thenReturn(resp); + + List estados = controller.getTopEstados("dengue", null); + + assertThat(estados).hasSize(1); + assertThat(estados.get(0).getSgUf()).isEqualTo("SP"); + } + + @Test + void getMunicipiosRisco_retornaMunicipiosPiores() { + MunicipioRiscoDTO m = MunicipioRiscoDTO.builder().municipio("BH").classificacao("ALTO").build(); + BrasilEpidemiologicoResponse resp = BrasilEpidemiologicoResponse.builder() + .municipiosPiores(List.of(m)).doenca("dengue").ano(2025).build(); + + when(brasilUseCase.buscar(anyString(), anyInt())).thenReturn(resp); + + List risco = controller.getMunicipiosRisco("dengue", null); + + assertThat(risco).hasSize(1); + assertThat(risco.get(0).getClassificacao()).isEqualTo("ALTO"); + } + + @Test + void getBuscasIa_delegaParaTracker() { + AdminBuscaIaDTO q = AdminBuscaIaDTO.builder().pergunta("dengue em SP").contagem(5L).build(); + when(tracker.listarMaisFrequentes(20)).thenReturn(List.of(q)); + + List result = controller.getBuscasIa(20); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getPergunta()).isEqualTo("dengue em SP"); + } + + @Test + void getResumo_retornaZerosQuandoListasNulas() { + BrasilEpidemiologicoResponse resp = BrasilEpidemiologicoResponse.builder() + .totalCasos(0L).doenca("dengue").ano(2025).build(); + + when(brasilUseCase.buscar(anyString(), anyInt())).thenReturn(resp); + + AdminResumoDTO resumo = controller.getResumo("dengue", null); + + assertThat(resumo.getTotalEstadosAfetados()).isZero(); + assertThat(resumo.getMunicipiosEpidemia()).isZero(); + } +} diff --git a/backend/src/test/java/br/com/fiap/vigisus/controller/BuscaControllerTest.java b/backend/src/test/java/br/com/fiap/vigisus/controller/BuscaControllerTest.java index 3a7e861..0ff1057 100644 --- a/backend/src/test/java/br/com/fiap/vigisus/controller/BuscaControllerTest.java +++ b/backend/src/test/java/br/com/fiap/vigisus/controller/BuscaControllerTest.java @@ -4,6 +4,7 @@ import br.com.fiap.vigisus.dto.BuscaCompletaResponse; import br.com.fiap.vigisus.dto.BuscaRequest; import br.com.fiap.vigisus.exception.RecursoNaoEncontradoException; +import br.com.fiap.vigisus.service.IaBuscaTracker; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -15,12 +16,14 @@ class BuscaControllerTest { private BuscaCompletaUseCase buscaCompletaUseCase; + private IaBuscaTracker iaBuscaTracker; private BuscaController controller; @BeforeEach void setUp() { buscaCompletaUseCase = mock(BuscaCompletaUseCase.class); - controller = new BuscaController(buscaCompletaUseCase); + iaBuscaTracker = mock(IaBuscaTracker.class); + controller = new BuscaController(buscaCompletaUseCase, iaBuscaTracker); } @Test diff --git a/backend/src/test/java/br/com/fiap/vigisus/service/IaBuscaTrackerTest.java b/backend/src/test/java/br/com/fiap/vigisus/service/IaBuscaTrackerTest.java new file mode 100644 index 0000000..a455e01 --- /dev/null +++ b/backend/src/test/java/br/com/fiap/vigisus/service/IaBuscaTrackerTest.java @@ -0,0 +1,99 @@ +package br.com.fiap.vigisus.service; + +import br.com.fiap.vigisus.dto.AdminBuscaIaDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class IaBuscaTrackerTest { + + private IaBuscaTracker tracker; + + @BeforeEach + void setUp() { + tracker = new IaBuscaTracker(); + } + + @Test + void registrar_contabilizaConsultasUnicas() { + tracker.registrar("dengue em SP"); + tracker.registrar("dengue em SP"); + tracker.registrar("risco no RJ"); + + List resultado = tracker.listarMaisFrequentes(10); + + assertThat(resultado).hasSize(2); + assertThat(resultado.get(0).getPergunta()).isEqualTo("dengue em sp"); + assertThat(resultado.get(0).getContagem()).isEqualTo(2); + } + + @Test + void listarMaisFrequentes_retornaOrdenadoDecrescente() { + tracker.registrar("a"); + tracker.registrar("b"); + tracker.registrar("b"); + tracker.registrar("b"); + tracker.registrar("a"); + tracker.registrar("c"); + + List resultado = tracker.listarMaisFrequentes(10); + + assertThat(resultado.get(0).getContagem()).isGreaterThanOrEqualTo(resultado.get(1).getContagem()); + } + + @Test + void listarMaisFrequentes_respeitaLimite() { + for (int i = 0; i < 30; i++) { + tracker.registrar("pergunta " + i); + } + + List resultado = tracker.listarMaisFrequentes(5); + + assertThat(resultado).hasSize(5); + } + + @Test + void registrar_ignoraPerguntasNulasEVazias() { + tracker.registrar(null); + tracker.registrar(""); + tracker.registrar(" "); + + assertThat(tracker.listarMaisFrequentes(10)).isEmpty(); + } + + @Test + void registrar_normalizaParaMinusculasERemoveEspacos() { + tracker.registrar(" Dengue em SP "); + tracker.registrar("dengue em sp"); + + List resultado = tracker.listarMaisFrequentes(10); + + assertThat(resultado).hasSize(1); + assertThat(resultado.get(0).getContagem()).isEqualTo(2); + } + + @Test + void registrar_truncaPerguntasLongas() { + String longa = "a".repeat(200); + tracker.registrar(longa); + tracker.registrar(longa); + + List resultado = tracker.listarMaisFrequentes(10); + + assertThat(resultado).hasSize(1); + assertThat(resultado.get(0).getPergunta().length()).isLessThanOrEqualTo(120); + assertThat(resultado.get(0).getContagem()).isEqualTo(2); + } + + @Test + void listarMaisFrequentes_retornaUltimaConsulta() { + tracker.registrar("consulta teste"); + + List resultado = tracker.listarMaisFrequentes(10); + + assertThat(resultado.get(0).getUltimaConsulta()).isNotNull().isNotBlank(); + } +}