sih-br-mcp 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/cache.d.ts +108 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +270 -0
- package/dist/cache.js.map +1 -0
- package/dist/data/brazil-regions.json +68 -0
- package/dist/data/cid-chapters.json +163 -0
- package/dist/data/csap-groups-cid9.json +4281 -0
- package/dist/data/csap-groups.json +262 -0
- package/dist/db/duckdb.d.ts +263 -0
- package/dist/db/duckdb.d.ts.map +1 -0
- package/dist/db/duckdb.js +837 -0
- package/dist/db/duckdb.js.map +1 -0
- package/dist/freshness.d.ts +101 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +303 -0
- package/dist/freshness.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1504 -0
- package/dist/index.js.map +1 -0
- package/dist/provenance.d.ts +217 -0
- package/dist/provenance.d.ts.map +1 -0
- package/dist/provenance.js +534 -0
- package/dist/provenance.js.map +1 -0
- package/dist/utils/age-groups.d.ts +164 -0
- package/dist/utils/age-groups.d.ts.map +1 -0
- package/dist/utils/age-groups.js +112 -0
- package/dist/utils/age-groups.js.map +1 -0
- package/dist/utils/population.d.ts +39 -0
- package/dist/utils/population.d.ts.map +1 -0
- package/dist/utils/population.js +114 -0
- package/dist/utils/population.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1504 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SIH-BR-MCP: MCP Server para análise de dados do SIH-SUS
|
|
4
|
+
* Foco em Internações por Condições Sensíveis à Atenção Primária (ICSAP)
|
|
5
|
+
*/
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { getAvailableYears, getDataDirectory, getPopulationYearRange, getPopulationCoverage, aggregatedAgeGroupsFor, populationSourceFor, closeDatabase, queryCausas, queryIcsap, querySeries, rankCsapGroups, calculateIcsapIndicators, hasPopulationData, configuredDataDirectory, getPopulationDir, resetPopulationCoverageCache, POPULATION_MISSING_MESSAGE, getPopulation, } from "./db/duckdb.js";
|
|
10
|
+
// Importa dados de referência
|
|
11
|
+
import csapGroups from "./data/csap-groups.json" with { type: "json" };
|
|
12
|
+
import cidChapters from "./data/cid-chapters.json" with { type: "json" };
|
|
13
|
+
import { SERVER_VERSION, eraNotes, loadSidecars, provenanceFor, raceNotes, withProvenance, yearsCid9, yearsUfArquivo, yearsWithoutRace, } from "./provenance.js";
|
|
14
|
+
import { getFreshness, startFreshnessCheck } from "./freshness.js";
|
|
15
|
+
import { CUBES_BASE_URL, CUBES_CACHE_ENABLED, cubesCacheDir, ensurePopulation, ensureYears, loadCubesManifest, populationPresent, publishedYears, yearsFromArgs } from "./cache.js";
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// DEFINIÇÃO DAS FERRAMENTAS
|
|
18
|
+
// =============================================================================
|
|
19
|
+
const tools = [
|
|
20
|
+
// --- Metadados ---
|
|
21
|
+
{
|
|
22
|
+
name: "list_csap_groups",
|
|
23
|
+
description: "Lista os 19 grupos de Condições Sensíveis à Atenção Primária (CSAP) " +
|
|
24
|
+
"conforme Portaria MS/SAS 221/2008. Retorna código, nome e códigos CID-10 de cada grupo.",
|
|
25
|
+
inputSchema: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
group_code: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Código do grupo específico (ex: 'g01'). Se omitido, retorna todos.",
|
|
31
|
+
},
|
|
32
|
+
include_cid_codes: {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
description: "Se true, inclui lista de códigos CID-10 (default: false)",
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "list_cid_chapters",
|
|
41
|
+
description: "Lista os 22 capítulos da CID-10 com seus códigos e faixas de diagnóstico. " +
|
|
42
|
+
"Os cubos de 1992–1997 (diagnóstico em CID-9) trazem `cid_chapter` como o capítulo CID-10 equivalente (mapa por categoria em src/data/cid9-chapters.json).",
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: "object",
|
|
45
|
+
properties: {},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "get_available_years",
|
|
50
|
+
description: "Retorna os anos disponíveis nos dados do SIH-SUS carregados e o frescor dos cubos em relação ao " +
|
|
51
|
+
"espelho healthbr-data (`freshness.status`: current, stale, unknown, pending ou disabled; " +
|
|
52
|
+
"quando stale, lista por ano as partições reeditadas pelo MS, regeneradas, retiradas ou novas na janela). " +
|
|
53
|
+
"Por ano, o que muda entre as eras do SIH: `race_available` (raça/cor só de 2008), `cid_revision` (9 = CID-9 de 6 dígitos em 1992–1997, 10 = CID-10; 1997 tem as duas), " +
|
|
54
|
+
"`icsap_list_revision` (cid9-derivada, não oficial, em 1992–1997), `uf_basis` (arquivo em 1992–1997, residencia de 1998), `municipality_available`, `currency` e `records_date_imputed`.",
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
// --- Internações Gerais ---
|
|
61
|
+
{
|
|
62
|
+
name: "get_hospitalizations",
|
|
63
|
+
description: "Consulta dados de internações hospitalares do SUS com filtros flexíveis. " +
|
|
64
|
+
"Permite agregar por múltiplas dimensões (UF, CID, sexo, idade, raça, ano/mês). " +
|
|
65
|
+
"Raça/cor só existe de 2008 em diante: em 1998–2007 `race` é nulo (ver get_available_years.race_available). " +
|
|
66
|
+
"Série desde 1992: em 1992–1997 o diagnóstico é CID-9 decodificado por tabela (`cid_group` = categoria de 3 dígitos, `cid_chapter` = capítulo CID-10 equivalente; agrupar por `cid_revision` separa 9 e 10 — 1997 tem os dois), " +
|
|
67
|
+
"`uf` é a UF do ARQUIVO (estabelecimento), não de residência, e `value` é nominal na moeda da época — ver get_available_years (uf_basis, currency) e as `notes` da resposta. " +
|
|
68
|
+
"`exclusion` (agrupável) marca as internações fora do universo do % ICSAP do csapAIH (procedimento_obstetrico, parto, longa_permanencia; nula = dentro).",
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: "object",
|
|
71
|
+
properties: {
|
|
72
|
+
year: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: { type: "integer" },
|
|
75
|
+
description: "Anos para consultar (ex: [2023, 2024]); série de 1992 em diante",
|
|
76
|
+
},
|
|
77
|
+
month: {
|
|
78
|
+
type: "array",
|
|
79
|
+
items: { type: "integer" },
|
|
80
|
+
description: "Meses (1-12). Se omitido, todos.",
|
|
81
|
+
},
|
|
82
|
+
uf: {
|
|
83
|
+
type: "array",
|
|
84
|
+
items: { type: "string" },
|
|
85
|
+
description: "Lista de UFs (ex: ['SP', 'RJ']). Se omitido, todas.",
|
|
86
|
+
},
|
|
87
|
+
cid_chapter: {
|
|
88
|
+
type: "array",
|
|
89
|
+
items: { type: "integer" },
|
|
90
|
+
description: "Capítulos CID-10 (1-22). Se omitido, todos.",
|
|
91
|
+
},
|
|
92
|
+
sex: {
|
|
93
|
+
type: "string",
|
|
94
|
+
enum: ["M", "F"],
|
|
95
|
+
description: "Filtrar por sexo",
|
|
96
|
+
},
|
|
97
|
+
age_min: {
|
|
98
|
+
type: "integer",
|
|
99
|
+
description: "Idade mínima em anos",
|
|
100
|
+
},
|
|
101
|
+
age_max: {
|
|
102
|
+
type: "integer",
|
|
103
|
+
description: "Idade máxima em anos",
|
|
104
|
+
},
|
|
105
|
+
race: {
|
|
106
|
+
type: "array",
|
|
107
|
+
items: { type: "string" },
|
|
108
|
+
description: "Raça/cor (branca, preta, parda, amarela, indigena, ignorado). Só existe de 2008 em diante: em 1998–2007 race é nulo e o filtro não alcança esses anos.",
|
|
109
|
+
},
|
|
110
|
+
is_csap: {
|
|
111
|
+
type: "boolean",
|
|
112
|
+
description: "Filtrar apenas CSAP (true) ou não-CSAP (false)",
|
|
113
|
+
},
|
|
114
|
+
group_by: {
|
|
115
|
+
type: "array",
|
|
116
|
+
items: {
|
|
117
|
+
type: "string",
|
|
118
|
+
enum: ["year", "month", "uf", "cid_chapter", "cid_revision", "cid_group", "sex", "age", "race", "exclusion", "is_csap", "csap_group"],
|
|
119
|
+
},
|
|
120
|
+
description: "Dimensões para agrupamento",
|
|
121
|
+
},
|
|
122
|
+
limit: {
|
|
123
|
+
type: "integer",
|
|
124
|
+
description: "Limitar número de resultados",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "get_hospitalization_trends",
|
|
131
|
+
description: "Retorna séries temporais de internações (mensal ou anual). " +
|
|
132
|
+
"Útil para análise de tendências e sazonalidade. Série desde 1992; em 1992–1997 `uf` é a UF do arquivo (estabelecimento) " +
|
|
133
|
+
"e as internações sem data na fonte (1992-01..04 e 1993-01) entram no mês de faturamento — ver get_available_years e as `notes`.",
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: "object",
|
|
136
|
+
properties: {
|
|
137
|
+
year_start: {
|
|
138
|
+
type: "integer",
|
|
139
|
+
description: "Ano inicial",
|
|
140
|
+
},
|
|
141
|
+
year_end: {
|
|
142
|
+
type: "integer",
|
|
143
|
+
description: "Ano final",
|
|
144
|
+
},
|
|
145
|
+
uf: {
|
|
146
|
+
type: "array",
|
|
147
|
+
items: { type: "string" },
|
|
148
|
+
description: "UFs para filtrar",
|
|
149
|
+
},
|
|
150
|
+
cid_chapter: {
|
|
151
|
+
type: "integer",
|
|
152
|
+
description: "Capítulo CID-10 específico",
|
|
153
|
+
},
|
|
154
|
+
granularity: {
|
|
155
|
+
type: "string",
|
|
156
|
+
enum: ["monthly", "yearly"],
|
|
157
|
+
description: "Granularidade temporal (default: yearly)",
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
required: ["year_start", "year_end"],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "compare_regions",
|
|
165
|
+
description: "Compara internações entre UFs ou regiões do Brasil. " +
|
|
166
|
+
"Gera rankings e identifica variações regionais. Em 1992–1997 `uf` é a UF do arquivo (estabelecimento), não de residência — ver get_available_years.uf_basis e as `notes`.",
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: "object",
|
|
169
|
+
properties: {
|
|
170
|
+
year: {
|
|
171
|
+
type: "array",
|
|
172
|
+
items: { type: "integer" },
|
|
173
|
+
description: "Anos para consultar",
|
|
174
|
+
},
|
|
175
|
+
compare_by: {
|
|
176
|
+
type: "string",
|
|
177
|
+
enum: ["uf", "region"],
|
|
178
|
+
description: "Comparar por UF ou região (default: uf)",
|
|
179
|
+
},
|
|
180
|
+
cid_chapter: {
|
|
181
|
+
type: "integer",
|
|
182
|
+
description: "Capítulo CID-10 específico",
|
|
183
|
+
},
|
|
184
|
+
is_csap: {
|
|
185
|
+
type: "boolean",
|
|
186
|
+
description: "Filtrar apenas CSAP",
|
|
187
|
+
},
|
|
188
|
+
metric: {
|
|
189
|
+
type: "string",
|
|
190
|
+
enum: ["n", "deaths"],
|
|
191
|
+
description: "Métrica para ranking (default: n)",
|
|
192
|
+
},
|
|
193
|
+
limit: {
|
|
194
|
+
type: "integer",
|
|
195
|
+
description: "Número de resultados (default: 10)",
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
// --- ICSAP ---
|
|
201
|
+
{
|
|
202
|
+
name: "get_icsap",
|
|
203
|
+
description: "Consulta internações por Condições Sensíveis à Atenção Primária (ICSAP). " +
|
|
204
|
+
"Permite filtros por grupo CSAP, UF, município, sexo, idade e raça. " +
|
|
205
|
+
"Raça/cor só existe de 2008 em diante: em 1998–2007 `race` é nulo (ver get_available_years.race_available). " +
|
|
206
|
+
"Série desde 1992: em 1992–1997 a ICSAP vem de lista CID-9 DERIVADA e não oficial (g03 e g05 não comparáveis com 1998+), " +
|
|
207
|
+
"`uf` é a UF do arquivo e `municipality_code` é nulo — ver get_available_years (icsap_list_revision, uf_basis) e as `notes`. " +
|
|
208
|
+
"Percentual no universo do pacote R csapAIH por padrão (`universe`): fora do numerador e do denominador as internações por procedimento obstétrico, parto e longa permanência.",
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
year: {
|
|
213
|
+
type: "array",
|
|
214
|
+
items: { type: "integer" },
|
|
215
|
+
description: "Anos para consultar",
|
|
216
|
+
},
|
|
217
|
+
uf: {
|
|
218
|
+
type: "array",
|
|
219
|
+
items: { type: "string" },
|
|
220
|
+
description: "UFs para filtrar",
|
|
221
|
+
},
|
|
222
|
+
municipality_code: {
|
|
223
|
+
type: "string",
|
|
224
|
+
description: "Código IBGE do município (6 dígitos)",
|
|
225
|
+
},
|
|
226
|
+
csap_group: {
|
|
227
|
+
type: "array",
|
|
228
|
+
items: { type: "string" },
|
|
229
|
+
description: "Grupos CSAP (ex: ['g01', 'g05'])",
|
|
230
|
+
},
|
|
231
|
+
sex: {
|
|
232
|
+
type: "string",
|
|
233
|
+
enum: ["M", "F"],
|
|
234
|
+
description: "Filtrar por sexo",
|
|
235
|
+
},
|
|
236
|
+
age_min: {
|
|
237
|
+
type: "integer",
|
|
238
|
+
description: "Idade mínima",
|
|
239
|
+
},
|
|
240
|
+
age_max: {
|
|
241
|
+
type: "integer",
|
|
242
|
+
description: "Idade máxima",
|
|
243
|
+
},
|
|
244
|
+
race: {
|
|
245
|
+
type: "array",
|
|
246
|
+
items: { type: "string" },
|
|
247
|
+
description: "Raça/cor (branca, preta, parda, amarela, indigena, ignorado). Só existe de 2008 em diante: em 1998–2007 race é nulo e o filtro não alcança esses anos.",
|
|
248
|
+
},
|
|
249
|
+
group_by: {
|
|
250
|
+
type: "array",
|
|
251
|
+
items: {
|
|
252
|
+
type: "string",
|
|
253
|
+
enum: ["year", "uf", "municipality_code", "cid_revision", "csap_group", "sex", "age", "race"],
|
|
254
|
+
},
|
|
255
|
+
description: "Dimensões para agrupamento",
|
|
256
|
+
},
|
|
257
|
+
universe: {
|
|
258
|
+
type: "string",
|
|
259
|
+
enum: ["csapaih", "all"],
|
|
260
|
+
description: "Universo do % ICSAP: 'csapaih' (padrão) tira do numerador e do denominador as internações por procedimento obstétrico, com diagnóstico de parto (O80-O84) e as AIH de longa permanência, como o pacote R csapAIH (Nedel); 'all' conta todas as internações.",
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "get_icsap_indicators",
|
|
267
|
+
description: "Calcula indicadores de ICSAP: percentual (ICSAP/Total×100). " +
|
|
268
|
+
"Métricas-chave para avaliar a Atenção Primária. " +
|
|
269
|
+
"Agrupar por raça só faz sentido de 2008 em diante: em 1998–2007 `race` é nulo (ver get_available_years.race_available). " +
|
|
270
|
+
"Em 1992–1997 a ICSAP vem de lista CID-9 DERIVADA e não oficial (g03 e g05 não comparáveis com 1998+) e `uf` é a UF do arquivo — ver as `notes`. " +
|
|
271
|
+
"Percentual no universo do pacote R csapAIH por padrão (`universe`): fora do numerador e do denominador as internações por procedimento obstétrico, parto e longa permanência.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
type: "object",
|
|
274
|
+
properties: {
|
|
275
|
+
year: {
|
|
276
|
+
type: "array",
|
|
277
|
+
items: { type: "integer" },
|
|
278
|
+
description: "Anos para calcular",
|
|
279
|
+
},
|
|
280
|
+
uf: {
|
|
281
|
+
type: "array",
|
|
282
|
+
items: { type: "string" },
|
|
283
|
+
description: "UFs para calcular",
|
|
284
|
+
},
|
|
285
|
+
municipality_code: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "Código IBGE do município",
|
|
288
|
+
},
|
|
289
|
+
sex: {
|
|
290
|
+
type: "string",
|
|
291
|
+
enum: ["M", "F"],
|
|
292
|
+
description: "Filtrar por sexo",
|
|
293
|
+
},
|
|
294
|
+
age_min: {
|
|
295
|
+
type: "integer",
|
|
296
|
+
description: "Idade mínima",
|
|
297
|
+
},
|
|
298
|
+
age_max: {
|
|
299
|
+
type: "integer",
|
|
300
|
+
description: "Idade máxima",
|
|
301
|
+
},
|
|
302
|
+
group_by: {
|
|
303
|
+
type: "array",
|
|
304
|
+
items: {
|
|
305
|
+
type: "string",
|
|
306
|
+
enum: ["year", "uf", "cid_revision", "sex", "race"],
|
|
307
|
+
},
|
|
308
|
+
description: "Dimensões para agrupamento",
|
|
309
|
+
},
|
|
310
|
+
universe: {
|
|
311
|
+
type: "string",
|
|
312
|
+
enum: ["csapaih", "all"],
|
|
313
|
+
description: "Universo do % ICSAP: 'csapaih' (padrão) tira do numerador e do denominador as internações por procedimento obstétrico, com diagnóstico de parto (O80-O84) e as AIH de longa permanência, como o pacote R csapAIH (Nedel); 'all' conta todas as internações.",
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
name: "rank_csap_groups",
|
|
320
|
+
description: "Gera ranking dos 19 grupos CSAP por número de internações, " +
|
|
321
|
+
"dias de internação ou valor. Identifica principais causas evitáveis. " +
|
|
322
|
+
"Em 1992–1997 a ICSAP vem de lista CID-9 DERIVADA e não oficial (g03 e g05 não comparáveis com 1998+) e `value` é nominal na moeda da época — ver as `notes`. " +
|
|
323
|
+
"Universo do pacote R csapAIH por padrão (`universe`): fora as internações por procedimento obstétrico, parto e longa permanência.",
|
|
324
|
+
inputSchema: {
|
|
325
|
+
type: "object",
|
|
326
|
+
properties: {
|
|
327
|
+
year: {
|
|
328
|
+
type: "array",
|
|
329
|
+
items: { type: "integer" },
|
|
330
|
+
description: "Anos para consultar",
|
|
331
|
+
},
|
|
332
|
+
uf: {
|
|
333
|
+
type: "array",
|
|
334
|
+
items: { type: "string" },
|
|
335
|
+
description: "UFs para filtrar",
|
|
336
|
+
},
|
|
337
|
+
sex: {
|
|
338
|
+
type: "string",
|
|
339
|
+
enum: ["M", "F"],
|
|
340
|
+
description: "Filtrar por sexo",
|
|
341
|
+
},
|
|
342
|
+
age_min: {
|
|
343
|
+
type: "integer",
|
|
344
|
+
description: "Idade mínima",
|
|
345
|
+
},
|
|
346
|
+
age_max: {
|
|
347
|
+
type: "integer",
|
|
348
|
+
description: "Idade máxima",
|
|
349
|
+
},
|
|
350
|
+
metric: {
|
|
351
|
+
type: "string",
|
|
352
|
+
enum: ["n", "days", "value", "deaths"],
|
|
353
|
+
description: "Métrica para ranking (default: n)",
|
|
354
|
+
},
|
|
355
|
+
universe: {
|
|
356
|
+
type: "string",
|
|
357
|
+
enum: ["csapaih", "all"],
|
|
358
|
+
description: "Universo do % ICSAP: 'csapaih' (padrão) tira do numerador e do denominador as internações por procedimento obstétrico, com diagnóstico de parto (O80-O84) e as AIH de longa permanência, como o pacote R csapAIH (Nedel); 'all' conta todas as internações.",
|
|
359
|
+
},
|
|
360
|
+
limit: {
|
|
361
|
+
type: "integer",
|
|
362
|
+
description: "Número de grupos no ranking (default: 19)",
|
|
363
|
+
minimum: 1,
|
|
364
|
+
maximum: 19,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: "classify_as_csap",
|
|
371
|
+
description: "Classifica um ou mais códigos CID-10 como CSAP ou não. " +
|
|
372
|
+
"Retorna o grupo CSAP correspondente se aplicável. Só CID-10: os códigos CID-9 de 6 dígitos do SIH de 1992–1997 " +
|
|
373
|
+
"são classificados no build pela lista derivada (src/data/csap-groups-cid9.json), não por esta ferramenta.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {
|
|
377
|
+
cid_codes: {
|
|
378
|
+
type: "array",
|
|
379
|
+
items: { type: "string" },
|
|
380
|
+
description: "Códigos CID-10 para classificar (ex: ['J18', 'A09', 'K35'])",
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
required: ["cid_codes"],
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
// --- Ferramentas que dependem de dados populacionais ---
|
|
387
|
+
{
|
|
388
|
+
name: "get_hospitalization_rates",
|
|
389
|
+
description: "Calcula taxas de internação por população (por 100.000 habitantes, configurável). " +
|
|
390
|
+
"Denominador lido de dois arquivos, informados em get_available_years.population_years: projeções do IBGE por idade simples de 2000 em diante (pop_uf.parquet) e, de 1991 a 1999, população por faixa etária quinquenal somada dos municípios (pop_uf_agregado.parquet) — " +
|
|
391
|
+
"antes de 2000 o recorte por idade só vale nos limites das faixas (age_min múltiplo de 5, age_max terminado em 4 ou 9, ou 80+). A resposta diz qual arquivo serviu a cada ano (population_source) e avisa quando mistura os dois.",
|
|
392
|
+
inputSchema: {
|
|
393
|
+
type: "object",
|
|
394
|
+
properties: {
|
|
395
|
+
year: {
|
|
396
|
+
type: "array",
|
|
397
|
+
items: { type: "integer" },
|
|
398
|
+
description: "Anos para calcular",
|
|
399
|
+
},
|
|
400
|
+
rate_type: {
|
|
401
|
+
type: "string",
|
|
402
|
+
enum: ["crude", "specific"],
|
|
403
|
+
description: "Tipo de taxa: crude (bruta) ou specific (específica por filtro)",
|
|
404
|
+
},
|
|
405
|
+
rate_per: {
|
|
406
|
+
type: "integer",
|
|
407
|
+
enum: [1000, 10000, 100000],
|
|
408
|
+
description: "Taxa por X habitantes (default: 100000)",
|
|
409
|
+
},
|
|
410
|
+
uf: {
|
|
411
|
+
type: "array",
|
|
412
|
+
items: { type: "string" },
|
|
413
|
+
description: "UFs para filtrar",
|
|
414
|
+
},
|
|
415
|
+
cid_chapter: {
|
|
416
|
+
type: "array",
|
|
417
|
+
items: { type: "integer" },
|
|
418
|
+
description: "Capítulos CID-10 (1-22)",
|
|
419
|
+
},
|
|
420
|
+
sex: {
|
|
421
|
+
type: "string",
|
|
422
|
+
enum: ["M", "F"],
|
|
423
|
+
description: "Filtrar por sexo",
|
|
424
|
+
},
|
|
425
|
+
age_min: {
|
|
426
|
+
type: "integer",
|
|
427
|
+
description: "Idade mínima",
|
|
428
|
+
},
|
|
429
|
+
age_max: {
|
|
430
|
+
type: "integer",
|
|
431
|
+
description: "Idade máxima",
|
|
432
|
+
},
|
|
433
|
+
is_csap: {
|
|
434
|
+
type: "boolean",
|
|
435
|
+
description: "Filtrar apenas CSAP",
|
|
436
|
+
},
|
|
437
|
+
group_by: {
|
|
438
|
+
type: "array",
|
|
439
|
+
items: {
|
|
440
|
+
type: "string",
|
|
441
|
+
enum: ["year", "uf", "sex"],
|
|
442
|
+
},
|
|
443
|
+
description: "Dimensões para agrupamento",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
name: "compare_icsap_trends",
|
|
450
|
+
description: "Análise temporal comparativa de ICSAP entre UFs ou grupos CSAP. " +
|
|
451
|
+
"Calcula tendências, variação anual e identifica melhores/piores desempenhos. Para `percentage` e `count` valem todos os anos do SIH (desde 1992); " +
|
|
452
|
+
"`rate_per_10k` exige população e aceita só os anos de get_available_years.population_years. " +
|
|
453
|
+
"Em 1992–1997 a ICSAP vem de lista CID-9 DERIVADA e não oficial (g03 e g05 não comparáveis com 1998+) e `uf` é a UF do arquivo — ver as `notes`. " +
|
|
454
|
+
"Percentual no universo do pacote R csapAIH por padrão (`universe`): fora do numerador e do denominador as internações por procedimento obstétrico, parto e longa permanência.",
|
|
455
|
+
inputSchema: {
|
|
456
|
+
type: "object",
|
|
457
|
+
properties: {
|
|
458
|
+
start_year: {
|
|
459
|
+
type: "integer",
|
|
460
|
+
description: "Ano inicial",
|
|
461
|
+
},
|
|
462
|
+
end_year: {
|
|
463
|
+
type: "integer",
|
|
464
|
+
description: "Ano final",
|
|
465
|
+
},
|
|
466
|
+
compare_by: {
|
|
467
|
+
type: "string",
|
|
468
|
+
enum: ["uf", "csap_group"],
|
|
469
|
+
description: "Comparar por UF ou grupo CSAP",
|
|
470
|
+
},
|
|
471
|
+
compare_values: {
|
|
472
|
+
type: "array",
|
|
473
|
+
items: { type: "string" },
|
|
474
|
+
description: "Valores específicos para comparar (UFs ou grupos CSAP)",
|
|
475
|
+
},
|
|
476
|
+
indicator: {
|
|
477
|
+
type: "string",
|
|
478
|
+
enum: ["percentage", "count", "rate_per_10k"],
|
|
479
|
+
description: "Indicador: percentage (% ICSAP), count (número), rate_per_10k (taxa)",
|
|
480
|
+
},
|
|
481
|
+
include_trend_line: {
|
|
482
|
+
type: "boolean",
|
|
483
|
+
description: "Incluir análise de tendência linear (default: true)",
|
|
484
|
+
},
|
|
485
|
+
universe: {
|
|
486
|
+
type: "string",
|
|
487
|
+
enum: ["csapaih", "all"],
|
|
488
|
+
description: "Universo do % ICSAP: 'csapaih' (padrão) tira do numerador e do denominador as internações por procedimento obstétrico, com diagnóstico de parto (O80-O84) e as AIH de longa permanência, como o pacote R csapAIH (Nedel); 'all' conta todas as internações.",
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
required: ["start_year", "end_year"],
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
];
|
|
495
|
+
// =============================================================================
|
|
496
|
+
// HANDLERS DAS FERRAMENTAS
|
|
497
|
+
// =============================================================================
|
|
498
|
+
/**
|
|
499
|
+
* Campo `notes` da era antiga (1992–1997) já no formato de spread: vazio
|
|
500
|
+
* quando a consulta não alcança nenhum ano da era (a fixture 2023 do golden
|
|
501
|
+
* nunca o produz). `extra` são notas de outra origem (raça/cor).
|
|
502
|
+
*/
|
|
503
|
+
function notesField(years, aspects = {}, extra = []) {
|
|
504
|
+
const notes = [...extra, ...eraNotes(years, getAvailableYears(), aspects)];
|
|
505
|
+
return notes.length > 0 ? { notes } : {};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Nota do universo do % ICSAP (0.10.0): qual universo a resposta usou. Sempre
|
|
509
|
+
* presente nas quatro ferramentas ICSAP, para o leitor saber que o número é o
|
|
510
|
+
* da literatura (csapAIH) e como obter o outro.
|
|
511
|
+
*/
|
|
512
|
+
/**
|
|
513
|
+
* Notas do denominador populacional (sih:taxas-1992-1999): qual arquivo serviu
|
|
514
|
+
* a cada ano e o aviso quando a consulta mistura os dois (idade simples de
|
|
515
|
+
* 2000 em diante; faixa etária quinquenal, somada dos municípios, antes).
|
|
516
|
+
*/
|
|
517
|
+
function populationNotes(sources) {
|
|
518
|
+
const years = Object.keys(sources).map(Number).sort((a, b) => a - b);
|
|
519
|
+
const agg = years.filter((y) => sources[y] === "aggregated");
|
|
520
|
+
const det = years.filter((y) => sources[y] === "detailed");
|
|
521
|
+
const notes = [];
|
|
522
|
+
if (agg.length > 0) {
|
|
523
|
+
notes.push(`Denominador de ${agg.join(", ")}: pop_uf_agregado.parquet — população por UF, sexo e FAIXA ETÁRIA quinquenal (0-4 … 75-79, 80 e +), somada dos municípios (IBGE via DATASUS/POPBR; a idade ignorada da fonte entra no total e fica fora de qualquer recorte etário). Recorte por idade só nos limites das faixas.`);
|
|
524
|
+
}
|
|
525
|
+
if (agg.length > 0 && det.length > 0) {
|
|
526
|
+
notes.push(`A consulta mistura dois denominadores: faixa etária somada dos municípios até 1999 (${agg.join(", ")}) e projeções do IBGE por idade simples de 2000 em diante (${det.join(", ")}). Taxas brutas são comparáveis; taxas por idade só se o recorte cair nas faixas quinquenais.`);
|
|
527
|
+
}
|
|
528
|
+
return notes;
|
|
529
|
+
}
|
|
530
|
+
/** Arquivo de população por ano, para populationNotes(). */
|
|
531
|
+
async function popSourcesFor(years) {
|
|
532
|
+
const out = {};
|
|
533
|
+
for (const y of years)
|
|
534
|
+
out[y] = await populationSourceFor(y);
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
function universeNote(universe) {
|
|
538
|
+
return (universe ?? "csapaih") === "csapaih"
|
|
539
|
+
? "Universo do % ICSAP como o pacote R csapAIH (Nedel): numerador e denominador SEM as internações por procedimento obstétrico, com diagnóstico de parto (O80-O84; CID-9 650 e 669.5-669.7) e as AIH de longa permanência (IDENT = 5) — coluna `exclusion` dos cubos. A lista de diagnósticos CSAP é a da Portaria 221/2008 sem alteração. Para contar todas as internações, `universe: \"all\"`."
|
|
540
|
+
: "Universo `all`: todas as internações no numerador e no denominador, inclusive partos, procedimentos obstétricos e AIH de longa permanência. O percentual da literatura brasileira (csapAIH) é o de `universe: \"csapaih\"` (padrão).";
|
|
541
|
+
}
|
|
542
|
+
// --- Metadados ---
|
|
543
|
+
async function handleListCsapGroups(args) {
|
|
544
|
+
const { group_code, include_cid_codes } = args;
|
|
545
|
+
if (group_code) {
|
|
546
|
+
const group = csapGroups.groups.find((g) => g.code.toLowerCase() === group_code.toLowerCase());
|
|
547
|
+
if (!group) {
|
|
548
|
+
return { error: `Grupo CSAP '${group_code}' não encontrado` };
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
group: {
|
|
552
|
+
id: group.id,
|
|
553
|
+
code: group.code,
|
|
554
|
+
name_pt: group.name_pt,
|
|
555
|
+
name_en: group.name_en,
|
|
556
|
+
diagnoses: group.diagnoses,
|
|
557
|
+
cid_codes: include_cid_codes ? group.cid10_all : undefined,
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
total_groups: csapGroups.groups.length,
|
|
563
|
+
source: csapGroups.metadata.source,
|
|
564
|
+
groups: csapGroups.groups.map((g) => ({
|
|
565
|
+
code: g.code,
|
|
566
|
+
name: g.name_pt,
|
|
567
|
+
cid_count: g.cid10_all.length,
|
|
568
|
+
cid_codes: include_cid_codes ? g.cid10_all : undefined,
|
|
569
|
+
})),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
async function handleListCidChapters() {
|
|
573
|
+
return {
|
|
574
|
+
total_chapters: cidChapters.chapters.length,
|
|
575
|
+
chapters: cidChapters.chapters,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
async function describeCubesChannel() {
|
|
579
|
+
if (!CUBES_CACHE_ENABLED) {
|
|
580
|
+
return { enabled: false, base_url: CUBES_BASE_URL, published_years: [], cache_dir: null, manifest_source: "disabled" };
|
|
581
|
+
}
|
|
582
|
+
const { manifest, source } = await loadCubesManifest();
|
|
583
|
+
return {
|
|
584
|
+
enabled: true,
|
|
585
|
+
base_url: CUBES_BASE_URL,
|
|
586
|
+
published_years: publishedYears(manifest),
|
|
587
|
+
manifest_generated_at: manifest?.generated_at ?? null,
|
|
588
|
+
manifest_source: source,
|
|
589
|
+
cache_dir: cubesCacheDir(),
|
|
590
|
+
note: "Ferramentas que leem cubo baixam sob demanda, do canal para o cache, os anos que a chamada pede (SHA-256 conferido); sem ano na chamada, a série publicada inteira.",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async function handleGetAvailableYears() {
|
|
594
|
+
try {
|
|
595
|
+
const years = getAvailableYears();
|
|
596
|
+
const noRace = yearsWithoutRace();
|
|
597
|
+
// Sidecars dos anos carregados (builder >= 2.5.0 traz os campos da era
|
|
598
|
+
// antiga; um sidecar anterior cai nos valores de 1998+).
|
|
599
|
+
const sidecars = loadSidecars().filter((s) => years.includes(s.cube_year));
|
|
600
|
+
const byYear = (f) => Object.fromEntries(sidecars.map((s) => [String(s.cube_year), f(s)]));
|
|
601
|
+
return {
|
|
602
|
+
years,
|
|
603
|
+
data_range: {
|
|
604
|
+
first_year: years[0],
|
|
605
|
+
last_year: years[years.length - 1],
|
|
606
|
+
total_years: years.length,
|
|
607
|
+
},
|
|
608
|
+
note: "Anos com dados Parquet disponíveis",
|
|
609
|
+
// Raça/cor por ano (sidecar `columns_missing`, builder >= 2.4.0): RACA_COR
|
|
610
|
+
// só entra no leiaute da AIH em 2008; em 1998–2007 `race` é nulo.
|
|
611
|
+
race_available: Object.fromEntries(years.map((y) => [String(y), !noRace.includes(y)])),
|
|
612
|
+
years_without_race: noRace,
|
|
613
|
+
// Era antiga 1992–1997 (sidecar do builder >= 2.5.0; docs/analise-002 e
|
|
614
|
+
// analise-003): revisão da CID por ano (internações por revisão; 1997 tem
|
|
615
|
+
// as duas), lista ICSAP por revisão, base do eixo `uf`, município,
|
|
616
|
+
// moeda de `value` e internações sem data na fonte.
|
|
617
|
+
cid_revision: byYear((s) => s.cid_revision ?? { "10": s.totals?.records_in_cube ?? null }),
|
|
618
|
+
years_cid9: yearsCid9(),
|
|
619
|
+
icsap_available: byYear(() => true),
|
|
620
|
+
icsap_list_revision: byYear((s) => s.icsap_list_revision ?? { "10": "portaria-221-2008" }),
|
|
621
|
+
uf_basis: byYear((s) => s.uf_basis ?? "residencia"),
|
|
622
|
+
years_uf_arquivo: yearsUfArquivo(),
|
|
623
|
+
municipality_available: byYear((s) => s.municipality_available ?? true),
|
|
624
|
+
currency: byYear((s) => s.currency ?? null),
|
|
625
|
+
records_date_imputed: byYear((s) => s.records_date_imputed ?? 0),
|
|
626
|
+
// Universo do % ICSAP (builder >= 2.6.0, csapAIH): internações dentro do
|
|
627
|
+
// universo e fora dele por motivo; null em cubo anterior a 2.6.0.
|
|
628
|
+
csap_universe: byYear((s) => s.csap_universe
|
|
629
|
+
? { method: s.csap_universe.method, records_in_universe: s.csap_universe.records_in_universe, excluded: s.csap_universe.excluded }
|
|
630
|
+
: null),
|
|
631
|
+
// Cobertura dos arquivos de população, lida deles: é o que as ferramentas
|
|
632
|
+
// de taxa aceitam — `detailed` (idade simples, 2000+) e `aggregated`
|
|
633
|
+
// (faixa etária quinquenal, 1991–1999; sih:taxas-1992-1999).
|
|
634
|
+
population_years: await getPopulationCoverage(),
|
|
635
|
+
// Canal público dos cubos (src/cache.ts): o que existe para baixar e onde
|
|
636
|
+
// o cache local vive. `published_years` vem do manifest.json do canal
|
|
637
|
+
// (timeout curto; cópia em disco quando a rede falha); `years` acima são
|
|
638
|
+
// os cubos já presentes localmente.
|
|
639
|
+
cubes_channel: await describeCubesChannel(),
|
|
640
|
+
// Frescor dos cubos frente ao espelho healthbr-data (src/freshness.ts):
|
|
641
|
+
// checado em segundo plano na inicialização, sem bloquear.
|
|
642
|
+
freshness: getFreshness(),
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
return {
|
|
647
|
+
error: error instanceof Error ? error.message : "Erro ao listar anos",
|
|
648
|
+
years: [],
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
async function handleClassifyAsCsap(args) {
|
|
653
|
+
const { cid_codes } = args;
|
|
654
|
+
const results = [];
|
|
655
|
+
for (const cid of cid_codes) {
|
|
656
|
+
const cidUpper = cid.toUpperCase().trim();
|
|
657
|
+
const cid3 = cidUpper.substring(0, 3);
|
|
658
|
+
const cid4 = cidUpper.substring(0, 4);
|
|
659
|
+
let found = false;
|
|
660
|
+
for (const group of csapGroups.groups) {
|
|
661
|
+
for (const groupCid of group.cid10_all) {
|
|
662
|
+
// Remove ponto do CID da lista para comparar
|
|
663
|
+
const groupCidClean = groupCid.replace(".", "");
|
|
664
|
+
// Verifica match
|
|
665
|
+
if (cidUpper === groupCidClean ||
|
|
666
|
+
cid3 === groupCidClean ||
|
|
667
|
+
cid4 === groupCidClean ||
|
|
668
|
+
cidUpper.startsWith(groupCidClean)) {
|
|
669
|
+
results.push({
|
|
670
|
+
cid: cid,
|
|
671
|
+
is_csap: true,
|
|
672
|
+
csap_group: group.code,
|
|
673
|
+
csap_name: group.name_pt,
|
|
674
|
+
});
|
|
675
|
+
found = true;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (found)
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
if (!found) {
|
|
683
|
+
results.push({
|
|
684
|
+
cid: cid,
|
|
685
|
+
is_csap: false,
|
|
686
|
+
csap_group: null,
|
|
687
|
+
csap_name: null,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const csapCount = results.filter((r) => r.is_csap).length;
|
|
692
|
+
return {
|
|
693
|
+
classifications: results,
|
|
694
|
+
summary: {
|
|
695
|
+
total: results.length,
|
|
696
|
+
csap: csapCount,
|
|
697
|
+
non_csap: results.length - csapCount,
|
|
698
|
+
},
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
async function handleGetHospitalizations(args) {
|
|
702
|
+
const filters = {
|
|
703
|
+
years: args.year,
|
|
704
|
+
months: args.month,
|
|
705
|
+
ufs: args.uf,
|
|
706
|
+
cidChapters: args.cid_chapter,
|
|
707
|
+
sex: args.sex,
|
|
708
|
+
ageMin: args.age_min,
|
|
709
|
+
ageMax: args.age_max,
|
|
710
|
+
races: args.race,
|
|
711
|
+
isCsap: args.is_csap,
|
|
712
|
+
};
|
|
713
|
+
try {
|
|
714
|
+
const data = await queryCausas({
|
|
715
|
+
filters,
|
|
716
|
+
groupBy: args.group_by,
|
|
717
|
+
metrics: ["n", "days", "value", "deaths"],
|
|
718
|
+
orderBy: args.group_by?.includes("year")
|
|
719
|
+
? "year"
|
|
720
|
+
: args.group_by?.[0] || "n_hospitalizations DESC",
|
|
721
|
+
limit: args.limit,
|
|
722
|
+
});
|
|
723
|
+
const totals = data.reduce((acc, row) => ({
|
|
724
|
+
n: acc.n + (Number(row.n_hospitalizations) || 0),
|
|
725
|
+
days: acc.days + (Number(row.total_days) || 0),
|
|
726
|
+
value: acc.value + (Number(row.total_value) || 0),
|
|
727
|
+
deaths: acc.deaths + (Number(row.deaths) || 0),
|
|
728
|
+
}), { n: 0, days: 0, value: 0, deaths: 0 });
|
|
729
|
+
const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
|
|
730
|
+
return {
|
|
731
|
+
data,
|
|
732
|
+
...notesField(args.year, { value: true, month: !!args.month?.length || !!args.group_by?.includes("month") }, raceN),
|
|
733
|
+
summary: {
|
|
734
|
+
total_hospitalizations: totals.n,
|
|
735
|
+
total_days: totals.days,
|
|
736
|
+
total_value: Math.round(totals.value * 100) / 100,
|
|
737
|
+
deaths: totals.deaths,
|
|
738
|
+
hospital_mortality_rate: totals.n > 0 ? Math.round((totals.deaths / totals.n) * 10000) / 100 : 0,
|
|
739
|
+
records_returned: data.length,
|
|
740
|
+
},
|
|
741
|
+
filters_applied: args,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
return {
|
|
746
|
+
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
747
|
+
data: [],
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async function handleGetHospitalizationTrends(args) {
|
|
752
|
+
const { year_start, year_end, uf, cid_chapter, granularity = "yearly" } = args;
|
|
753
|
+
try {
|
|
754
|
+
if (granularity === "monthly") {
|
|
755
|
+
// Usa cubo de séries temporais
|
|
756
|
+
const data = await querySeries({
|
|
757
|
+
filters: {
|
|
758
|
+
yearMonthStart: `${year_start}-01`,
|
|
759
|
+
yearMonthEnd: `${year_end}-12`,
|
|
760
|
+
ufs: uf,
|
|
761
|
+
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
762
|
+
},
|
|
763
|
+
groupBy: ["year_month"],
|
|
764
|
+
orderBy: "year_month",
|
|
765
|
+
});
|
|
766
|
+
return {
|
|
767
|
+
granularity: "monthly",
|
|
768
|
+
series: data,
|
|
769
|
+
period: { start: `${year_start}-01`, end: `${year_end}-12` },
|
|
770
|
+
...notesField(yearsFromArgs(args) ?? undefined, { month: true }),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
// Agregação anual do cubo de causas
|
|
775
|
+
const years = [];
|
|
776
|
+
for (let y = year_start; y <= year_end; y++) {
|
|
777
|
+
years.push(y);
|
|
778
|
+
}
|
|
779
|
+
const data = await queryCausas({
|
|
780
|
+
filters: {
|
|
781
|
+
years,
|
|
782
|
+
ufs: uf,
|
|
783
|
+
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
784
|
+
},
|
|
785
|
+
groupBy: ["year"],
|
|
786
|
+
metrics: ["n", "deaths"],
|
|
787
|
+
orderBy: "year",
|
|
788
|
+
});
|
|
789
|
+
return {
|
|
790
|
+
granularity: "yearly",
|
|
791
|
+
series: data,
|
|
792
|
+
period: { start: year_start, end: year_end },
|
|
793
|
+
...notesField(yearsFromArgs(args) ?? undefined, { month: args.granularity === "monthly" }),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
return {
|
|
799
|
+
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
800
|
+
series: [],
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
async function handleCompareRegions(args) {
|
|
805
|
+
const { year, compare_by = "uf", cid_chapter, is_csap, metric = "n", limit = 10 } = args;
|
|
806
|
+
try {
|
|
807
|
+
const groupByField = compare_by === "region" ? "uf" : "uf"; // TODO: agregar por região
|
|
808
|
+
const data = await queryCausas({
|
|
809
|
+
filters: {
|
|
810
|
+
years: year,
|
|
811
|
+
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
812
|
+
isCsap: is_csap,
|
|
813
|
+
},
|
|
814
|
+
groupBy: [groupByField],
|
|
815
|
+
metrics: ["n", "deaths"],
|
|
816
|
+
orderBy: metric === "n" ? "n_hospitalizations DESC" : "deaths DESC",
|
|
817
|
+
limit,
|
|
818
|
+
});
|
|
819
|
+
// Adiciona ranking
|
|
820
|
+
const ranking = data.map((row, index) => ({
|
|
821
|
+
rank: index + 1,
|
|
822
|
+
uf: row.uf,
|
|
823
|
+
n_hospitalizations: row.n_hospitalizations,
|
|
824
|
+
deaths: row.deaths,
|
|
825
|
+
mortality_rate: Number(row.n_hospitalizations) > 0
|
|
826
|
+
? Math.round((Number(row.deaths) / Number(row.n_hospitalizations)) * 10000) / 100
|
|
827
|
+
: 0,
|
|
828
|
+
}));
|
|
829
|
+
return {
|
|
830
|
+
compare_by,
|
|
831
|
+
metric,
|
|
832
|
+
ranking,
|
|
833
|
+
total_locations: data.length,
|
|
834
|
+
...notesField(args.year, {}),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
return {
|
|
839
|
+
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
840
|
+
ranking: [],
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
async function handleGetIcsap(args) {
|
|
845
|
+
const filters = {
|
|
846
|
+
years: args.year,
|
|
847
|
+
ufs: args.uf,
|
|
848
|
+
municipalityCodes: args.municipality_code ? [args.municipality_code] : undefined,
|
|
849
|
+
csapGroups: args.csap_group,
|
|
850
|
+
sex: args.sex,
|
|
851
|
+
ageMin: args.age_min,
|
|
852
|
+
ageMax: args.age_max,
|
|
853
|
+
races: args.race,
|
|
854
|
+
};
|
|
855
|
+
try {
|
|
856
|
+
const data = await queryIcsap({
|
|
857
|
+
filters,
|
|
858
|
+
groupBy: args.group_by,
|
|
859
|
+
metrics: ["n", "n_total", "days", "value", "deaths"],
|
|
860
|
+
universe: args.universe,
|
|
861
|
+
orderBy: args.group_by?.includes("year")
|
|
862
|
+
? "year"
|
|
863
|
+
: args.group_by?.[0] || "n_icsap DESC",
|
|
864
|
+
});
|
|
865
|
+
// Totais do filtro inteiro, numa consulta sem agrupamento (0.9.0): somar
|
|
866
|
+
// as linhas de `data` repetiria n_total sempre que o agrupamento divide o
|
|
867
|
+
// estrato (por grupo CSAP) — o denominador vem dos estratos distintos.
|
|
868
|
+
const [whole] = await calculateIcsapIndicators({ filters, groupBy: [], universe: args.universe });
|
|
869
|
+
const totals = {
|
|
870
|
+
icsap: Number(whole?.n_icsap) || 0,
|
|
871
|
+
total: Number(whole?.n_total) || 0,
|
|
872
|
+
days: Number(whole?.total_days) || 0,
|
|
873
|
+
value: Number(whole?.total_value) || 0,
|
|
874
|
+
deaths: Number(whole?.deaths) || 0,
|
|
875
|
+
};
|
|
876
|
+
const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
|
|
877
|
+
return {
|
|
878
|
+
data,
|
|
879
|
+
...notesField(args.year, { icsap: true, value: true, municipality: !!args.municipality_code || !!args.group_by?.includes("municipality_code") }, [...raceN, universeNote(args.universe)]),
|
|
880
|
+
summary: {
|
|
881
|
+
total_icsap: totals.icsap,
|
|
882
|
+
total_hospitalizations: totals.total,
|
|
883
|
+
icsap_percentage: totals.total > 0
|
|
884
|
+
? Math.round((totals.icsap / totals.total) * 10000) / 100
|
|
885
|
+
: 0,
|
|
886
|
+
total_days: totals.days,
|
|
887
|
+
total_value: Math.round(totals.value * 100) / 100,
|
|
888
|
+
deaths: totals.deaths,
|
|
889
|
+
records_returned: data.length,
|
|
890
|
+
},
|
|
891
|
+
filters_applied: args,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
return {
|
|
896
|
+
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
897
|
+
data: [],
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
async function handleGetIcsapIndicators(args) {
|
|
902
|
+
const filters = {
|
|
903
|
+
years: args.year,
|
|
904
|
+
ufs: args.uf,
|
|
905
|
+
municipalityCodes: args.municipality_code ? [args.municipality_code] : undefined,
|
|
906
|
+
sex: args.sex,
|
|
907
|
+
ageMin: args.age_min,
|
|
908
|
+
ageMax: args.age_max,
|
|
909
|
+
};
|
|
910
|
+
try {
|
|
911
|
+
const data = await calculateIcsapIndicators({
|
|
912
|
+
filters,
|
|
913
|
+
groupBy: args.group_by,
|
|
914
|
+
universe: args.universe,
|
|
915
|
+
});
|
|
916
|
+
const raceN = args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
|
|
917
|
+
return {
|
|
918
|
+
data,
|
|
919
|
+
...notesField(args.year, { icsap: true, value: true }, [...raceN, universeNote(args.universe)]),
|
|
920
|
+
indicators_calculated: ["icsap_percentage"],
|
|
921
|
+
note: "icsap_percentage = (n_icsap / n_total) * 100",
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
return {
|
|
926
|
+
error: error instanceof Error ? error.message : "Erro no cálculo",
|
|
927
|
+
data: [],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
async function handleRankCsapGroups(args) {
|
|
932
|
+
const filters = {
|
|
933
|
+
years: args.year,
|
|
934
|
+
ufs: args.uf,
|
|
935
|
+
sex: args.sex,
|
|
936
|
+
ageMin: args.age_min,
|
|
937
|
+
ageMax: args.age_max,
|
|
938
|
+
};
|
|
939
|
+
try {
|
|
940
|
+
const data = await rankCsapGroups({
|
|
941
|
+
filters,
|
|
942
|
+
metric: args.metric || "n",
|
|
943
|
+
limit: args.limit || 19,
|
|
944
|
+
universe: args.universe,
|
|
945
|
+
});
|
|
946
|
+
// Adiciona nomes dos grupos e calcula percentuais
|
|
947
|
+
const totalMetric = data.reduce((acc, row) => acc + (Number(row.metric_value) || 0), 0);
|
|
948
|
+
const ranking = data.map((row, index) => {
|
|
949
|
+
const group = csapGroups.groups.find((g) => g.code === row.csap_group);
|
|
950
|
+
return {
|
|
951
|
+
rank: index + 1,
|
|
952
|
+
csap_group: row.csap_group,
|
|
953
|
+
csap_name: group?.name_pt || "Desconhecido",
|
|
954
|
+
metric_value: row.metric_value,
|
|
955
|
+
pct_of_total: totalMetric > 0
|
|
956
|
+
? Math.round((Number(row.metric_value) / totalMetric) * 10000) / 100
|
|
957
|
+
: 0,
|
|
958
|
+
n_hospitalizations: row.n_hospitalizations,
|
|
959
|
+
total_days: row.total_days,
|
|
960
|
+
total_value: row.total_value,
|
|
961
|
+
deaths: row.deaths,
|
|
962
|
+
};
|
|
963
|
+
});
|
|
964
|
+
// Concentração nos top 3 e top 5
|
|
965
|
+
const top3Pct = ranking.slice(0, 3).reduce((acc, r) => acc + r.pct_of_total, 0);
|
|
966
|
+
const top5Pct = ranking.slice(0, 5).reduce((acc, r) => acc + r.pct_of_total, 0);
|
|
967
|
+
return {
|
|
968
|
+
metric: args.metric || "n",
|
|
969
|
+
ranking,
|
|
970
|
+
...notesField(args.year, { icsap: true, value: true }, [universeNote(args.universe)]),
|
|
971
|
+
concentration: {
|
|
972
|
+
top_3_percentage: Math.round(top3Pct * 100) / 100,
|
|
973
|
+
top_5_percentage: Math.round(top5Pct * 100) / 100,
|
|
974
|
+
},
|
|
975
|
+
total_groups: ranking.length,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
catch (error) {
|
|
979
|
+
return {
|
|
980
|
+
error: error instanceof Error ? error.message : "Erro no ranking",
|
|
981
|
+
ranking: [],
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
async function handleGetHospitalizationRates(args) {
|
|
986
|
+
// Verifica se dados populacionais estão disponíveis (pasta de dados ou cache
|
|
987
|
+
// local, enchido do canal por ensurePopulation() antes deste handler)
|
|
988
|
+
if (!hasPopulationData()) {
|
|
989
|
+
return {
|
|
990
|
+
error: POPULATION_MISSING_MESSAGE,
|
|
991
|
+
data: [],
|
|
992
|
+
note: `Esta ferramenta requer pop_uf.parquet (ou pop_uf_agregado/pop_municipios) em ${getPopulationDir()}; o canal ${CUBES_BASE_URL} publica os três no bloco population do manifest.json.`,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
// Valida anos pela cobertura REAL dos arquivos de população (lida deles, não
|
|
996
|
+
// fixada aqui): idade simples de 2000 em diante (pop_uf) e faixa etária
|
|
997
|
+
// quinquenal antes (pop_uf_agregado, 1991–1999 — sih:taxas-1992-1999).
|
|
998
|
+
const coverage = await getPopulationCoverage();
|
|
999
|
+
if (coverage && args.year && args.year.some(y => y < coverage.first_year || y > coverage.last_year)) {
|
|
1000
|
+
return {
|
|
1001
|
+
error: `Anos devem estar entre ${coverage.first_year} e ${coverage.last_year} (cobertura dos arquivos de população por UF: ${[coverage.aggregated, coverage.detailed].filter(Boolean).map((c) => `${c.source} ${c.first_year}–${c.last_year}, ${c.age}`).join("; ")}).`,
|
|
1002
|
+
data: [],
|
|
1003
|
+
population_years: coverage,
|
|
1004
|
+
available_sih_years: getAvailableYears(),
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
// Antes de 2000 o recorte etário só existe nas faixas quinquenais do arquivo
|
|
1008
|
+
if (coverage?.aggregated && args.year && (args.age_min !== undefined || args.age_max !== undefined)) {
|
|
1009
|
+
const agg = coverage.aggregated;
|
|
1010
|
+
const usesAggregated = args.year.some((y) => y >= agg.first_year && y <= agg.last_year && !(coverage.detailed && y >= coverage.detailed.first_year));
|
|
1011
|
+
if (usesAggregated && aggregatedAgeGroupsFor(args.age_min, args.age_max) === null) {
|
|
1012
|
+
return {
|
|
1013
|
+
error: `Antes de 2000 a população por UF só existe em faixas etárias quinquenais (${agg.age_groups.join(", ")}): o intervalo ${args.age_min ?? 0}–${args.age_max ?? "+"} não cai nos limites das faixas. Use age_min múltiplo de 5 e age_max terminado em 4 ou 9 (ou só age_min = 80).`,
|
|
1014
|
+
data: [],
|
|
1015
|
+
population_years: coverage,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// Normaliza UFs para uppercase
|
|
1020
|
+
const normalizedUfs = args.uf?.map(u => u.toUpperCase());
|
|
1021
|
+
// Verifica se os anos solicitados existem nos dados SIH
|
|
1022
|
+
const availableYears = getAvailableYears();
|
|
1023
|
+
const requestedYears = args.year || availableYears;
|
|
1024
|
+
const validYears = requestedYears.filter(y => availableYears.includes(y));
|
|
1025
|
+
if (validYears.length === 0) {
|
|
1026
|
+
return {
|
|
1027
|
+
error: `Nenhum dos anos solicitados (${requestedYears.join(", ")}) tem dados SIH disponíveis.`,
|
|
1028
|
+
data: [],
|
|
1029
|
+
available_sih_years: availableYears,
|
|
1030
|
+
note: "Use get_available_years para ver anos com dados de internação.",
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const ratePer = args.rate_per || 100000;
|
|
1034
|
+
const filters = {
|
|
1035
|
+
years: validYears,
|
|
1036
|
+
ufs: normalizedUfs,
|
|
1037
|
+
cidChapters: args.cid_chapter,
|
|
1038
|
+
sex: args.sex,
|
|
1039
|
+
ageMin: args.age_min,
|
|
1040
|
+
ageMax: args.age_max,
|
|
1041
|
+
isCsap: args.is_csap,
|
|
1042
|
+
};
|
|
1043
|
+
// Qual arquivo de população serve a cada ano consultado (sih:taxas-1992-1999)
|
|
1044
|
+
const popSources = {};
|
|
1045
|
+
for (const y of validYears)
|
|
1046
|
+
popSources[y] = await populationSourceFor(y);
|
|
1047
|
+
try {
|
|
1048
|
+
// Define agrupamento: se uf ou year foram passados sem group_by, agrupa automaticamente
|
|
1049
|
+
const groupBy = args.group_by ? [...args.group_by] : [];
|
|
1050
|
+
if (normalizedUfs && normalizedUfs.length > 1 && !groupBy.includes("uf")) {
|
|
1051
|
+
groupBy.push("uf");
|
|
1052
|
+
}
|
|
1053
|
+
if (validYears.length > 1 && !groupBy.includes("year")) {
|
|
1054
|
+
groupBy.push("year");
|
|
1055
|
+
}
|
|
1056
|
+
const hasUfGrouping = groupBy.includes("uf");
|
|
1057
|
+
const hasYearGrouping = groupBy.includes("year");
|
|
1058
|
+
// Busca internações
|
|
1059
|
+
const hospData = await queryCausas({
|
|
1060
|
+
filters,
|
|
1061
|
+
groupBy,
|
|
1062
|
+
metrics: ["n", "deaths"],
|
|
1063
|
+
});
|
|
1064
|
+
// Filtra resultados nulos (quando GROUP BY vazio e sem dados, retorna {null, null})
|
|
1065
|
+
const validHospData = hospData.filter(row => row.n_hospitalizations !== null && row.n_hospitalizations !== undefined);
|
|
1066
|
+
if (validHospData.length === 0) {
|
|
1067
|
+
// Sem internações, mas podemos ainda fornecer a população
|
|
1068
|
+
const popYear = validYears[0];
|
|
1069
|
+
let population = 0;
|
|
1070
|
+
try {
|
|
1071
|
+
population = await getPopulation({ year: popYear, uf: normalizedUfs, sex: args.sex, ageMin: args.age_min, ageMax: args.age_max });
|
|
1072
|
+
}
|
|
1073
|
+
catch { /* ignore */ }
|
|
1074
|
+
return {
|
|
1075
|
+
data: [],
|
|
1076
|
+
summary: {
|
|
1077
|
+
total_hospitalizations: 0,
|
|
1078
|
+
total_population: population,
|
|
1079
|
+
overall_rate: 0,
|
|
1080
|
+
rate_per: ratePer,
|
|
1081
|
+
rate_type: args.rate_type || "crude",
|
|
1082
|
+
},
|
|
1083
|
+
metadata: {
|
|
1084
|
+
population_source: popSources,
|
|
1085
|
+
filters_applied: { ...args, uf: normalizedUfs, year: validYears },
|
|
1086
|
+
note: "Nenhuma internação encontrada para os filtros aplicados.",
|
|
1087
|
+
available_sih_years: availableYears,
|
|
1088
|
+
},
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
// Busca população correspondente e calcula taxas
|
|
1092
|
+
const results = [];
|
|
1093
|
+
for (const row of validHospData) {
|
|
1094
|
+
const hospYear = hasYearGrouping ? Number(row.year) : validYears[0];
|
|
1095
|
+
const hospUf = hasUfGrouping ? String(row.uf) : undefined;
|
|
1096
|
+
// Busca população para o estrato
|
|
1097
|
+
let population = 0;
|
|
1098
|
+
try {
|
|
1099
|
+
population = await getPopulation({
|
|
1100
|
+
year: hospYear,
|
|
1101
|
+
uf: hospUf ? [hospUf] : normalizedUfs,
|
|
1102
|
+
sex: args.sex,
|
|
1103
|
+
ageMin: args.age_min,
|
|
1104
|
+
ageMax: args.age_max,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
catch (popError) {
|
|
1108
|
+
console.error(`[get_hospitalization_rates] Erro população (year=${hospYear}, uf=${hospUf}): ${popError}`);
|
|
1109
|
+
}
|
|
1110
|
+
const nHosp = Number(row.n_hospitalizations) || 0;
|
|
1111
|
+
const deaths = Number(row.deaths) || 0;
|
|
1112
|
+
const rate = population > 0 ? (nHosp / population) * ratePer : 0;
|
|
1113
|
+
results.push({
|
|
1114
|
+
...(hasYearGrouping ? { year: hospYear } : {}),
|
|
1115
|
+
...(hasUfGrouping ? { uf: hospUf } : {}),
|
|
1116
|
+
n_hospitalizations: nHosp,
|
|
1117
|
+
deaths,
|
|
1118
|
+
population,
|
|
1119
|
+
rate_per_100k: Math.round(rate * 100) / 100,
|
|
1120
|
+
rate_per: ratePer,
|
|
1121
|
+
population_source: popSources[hospYear] ?? null,
|
|
1122
|
+
mortality_rate: nHosp > 0 ? Math.round((deaths / nHosp) * 10000) / 100 : 0,
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
// Calcula totais
|
|
1126
|
+
const totalHosp = results.reduce((acc, r) => acc + r.n_hospitalizations, 0);
|
|
1127
|
+
const totalPop = results.reduce((acc, r) => acc + r.population, 0);
|
|
1128
|
+
const overallRate = totalPop > 0 ? (totalHosp / totalPop) * ratePer : 0;
|
|
1129
|
+
return {
|
|
1130
|
+
data: results,
|
|
1131
|
+
summary: {
|
|
1132
|
+
total_hospitalizations: totalHosp,
|
|
1133
|
+
total_population: totalPop,
|
|
1134
|
+
overall_rate: Math.round(overallRate * 100) / 100,
|
|
1135
|
+
rate_per: ratePer,
|
|
1136
|
+
rate_type: args.rate_type || "crude",
|
|
1137
|
+
},
|
|
1138
|
+
metadata: {
|
|
1139
|
+
population_source: popSources,
|
|
1140
|
+
population_notes: populationNotes(popSources),
|
|
1141
|
+
filters_applied: { ...args, uf: normalizedUfs, year: validYears },
|
|
1142
|
+
},
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
return {
|
|
1147
|
+
error: error instanceof Error ? error.message : "Erro ao calcular taxas",
|
|
1148
|
+
data: [],
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
async function handleCompareIcsapTrends(args) {
|
|
1153
|
+
const { start_year, end_year, compare_by, indicator, include_trend_line } = args;
|
|
1154
|
+
const indicatorType = indicator || "percentage";
|
|
1155
|
+
const includeTrend = include_trend_line !== false;
|
|
1156
|
+
// Validação de anos pelo intervalo REAL de pop_uf.parquet (lido do arquivo,
|
|
1157
|
+
// não fixado aqui) — só quando o indicador usa denominador populacional:
|
|
1158
|
+
// `percentage` e `count` valem para qualquer ano do SIH (0.9.0; antes a
|
|
1159
|
+
// checagem barrava 1998–1999 sem motivo).
|
|
1160
|
+
const popRange = await getPopulationYearRange();
|
|
1161
|
+
if (indicatorType === "rate_per_10k" && popRange && (start_year < popRange.first_year || end_year > popRange.last_year)) {
|
|
1162
|
+
return {
|
|
1163
|
+
error: `Para rate_per_10k os anos devem estar entre ${popRange.first_year} e ${popRange.last_year} (cobertura dos arquivos de população: idade simples de 2000 em diante, faixa etária quinquenal antes — ver get_available_years.population_years); percentage e count aceitam qualquer ano do SIH.`,
|
|
1164
|
+
series: [],
|
|
1165
|
+
population_years: popRange,
|
|
1166
|
+
available_sih_years: getAvailableYears(),
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
// Se usa taxa, verifica população
|
|
1170
|
+
if (indicatorType === "rate_per_10k" && !hasPopulationData()) {
|
|
1171
|
+
return {
|
|
1172
|
+
error: `Taxa por população requer dados populacionais. ${POPULATION_MISSING_MESSAGE}`,
|
|
1173
|
+
series: [],
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
// Normaliza compare_values (UFs para uppercase)
|
|
1177
|
+
const compare_values = args.compare_values?.map(v => compare_by === "uf" ? v.toUpperCase() : v);
|
|
1178
|
+
// Filtra anos pelos disponíveis no SIH
|
|
1179
|
+
const allYears = Array.from({ length: end_year - start_year + 1 }, (_, i) => start_year + i);
|
|
1180
|
+
const availableYears = getAvailableYears();
|
|
1181
|
+
const years = allYears.filter(y => availableYears.includes(y));
|
|
1182
|
+
if (years.length === 0) {
|
|
1183
|
+
return {
|
|
1184
|
+
error: `Nenhum dos anos no intervalo ${start_year}-${end_year} tem dados SIH disponíveis.`,
|
|
1185
|
+
series: [],
|
|
1186
|
+
available_sih_years: availableYears,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
try {
|
|
1190
|
+
const filters = {
|
|
1191
|
+
years,
|
|
1192
|
+
csapGroups: compare_by === "csap_group" ? compare_values : undefined,
|
|
1193
|
+
ufs: compare_by === "uf" ? compare_values : undefined,
|
|
1194
|
+
};
|
|
1195
|
+
// Determina groupBy baseado em compare_by
|
|
1196
|
+
const groupBy = ["year"];
|
|
1197
|
+
if (compare_by) {
|
|
1198
|
+
groupBy.push(compare_by);
|
|
1199
|
+
}
|
|
1200
|
+
// Busca dados ICSAP
|
|
1201
|
+
const data = await calculateIcsapIndicators({
|
|
1202
|
+
filters,
|
|
1203
|
+
groupBy,
|
|
1204
|
+
universe: args.universe,
|
|
1205
|
+
});
|
|
1206
|
+
// Organiza série temporal
|
|
1207
|
+
const seriesMap = {};
|
|
1208
|
+
for (const row of data) {
|
|
1209
|
+
const year = Number(row.year);
|
|
1210
|
+
const compareKey = compare_by ? String(row[compare_by]) : "total";
|
|
1211
|
+
const icsap = Number(row.n_icsap) || 0;
|
|
1212
|
+
const total = Number(row.n_total) || 0;
|
|
1213
|
+
if (!seriesMap[compareKey]) {
|
|
1214
|
+
seriesMap[compareKey] = {};
|
|
1215
|
+
}
|
|
1216
|
+
seriesMap[compareKey][year] = { icsap, total };
|
|
1217
|
+
}
|
|
1218
|
+
// Se precisa de taxa, busca população por ano
|
|
1219
|
+
if (indicatorType === "rate_per_10k") {
|
|
1220
|
+
for (const compareKey of Object.keys(seriesMap)) {
|
|
1221
|
+
for (const year of years) {
|
|
1222
|
+
if (seriesMap[compareKey][year]) {
|
|
1223
|
+
try {
|
|
1224
|
+
const ufFilter = compare_by === "uf" ? [compareKey] : undefined;
|
|
1225
|
+
const pop = await getPopulation({ year, uf: ufFilter });
|
|
1226
|
+
seriesMap[compareKey][year].population = pop;
|
|
1227
|
+
}
|
|
1228
|
+
catch {
|
|
1229
|
+
seriesMap[compareKey][year].population = 0;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// Constrói séries e calcula indicadores
|
|
1236
|
+
const series = [];
|
|
1237
|
+
const trendData = {};
|
|
1238
|
+
for (const year of years) {
|
|
1239
|
+
const row = { year };
|
|
1240
|
+
for (const compareKey of Object.keys(seriesMap)) {
|
|
1241
|
+
const entry = seriesMap[compareKey][year];
|
|
1242
|
+
if (entry) {
|
|
1243
|
+
let value;
|
|
1244
|
+
switch (indicatorType) {
|
|
1245
|
+
case "count":
|
|
1246
|
+
value = entry.icsap;
|
|
1247
|
+
break;
|
|
1248
|
+
case "rate_per_10k":
|
|
1249
|
+
value = entry.population && entry.population > 0
|
|
1250
|
+
? (entry.icsap / entry.population) * 10000
|
|
1251
|
+
: 0;
|
|
1252
|
+
break;
|
|
1253
|
+
case "percentage":
|
|
1254
|
+
default:
|
|
1255
|
+
value = entry.total > 0 ? (entry.icsap / entry.total) * 100 : 0;
|
|
1256
|
+
}
|
|
1257
|
+
row[compareKey] = Math.round(value * 100) / 100;
|
|
1258
|
+
// Acumula para cálculo de tendência
|
|
1259
|
+
if (!trendData[compareKey]) {
|
|
1260
|
+
trendData[compareKey] = { values: [], years: [] };
|
|
1261
|
+
}
|
|
1262
|
+
trendData[compareKey].values.push(value);
|
|
1263
|
+
trendData[compareKey].years.push(year);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
series.push(row);
|
|
1267
|
+
}
|
|
1268
|
+
// Calcula tendências (regressão linear simples)
|
|
1269
|
+
const trends = {};
|
|
1270
|
+
if (includeTrend) {
|
|
1271
|
+
for (const [compareKey, td] of Object.entries(trendData)) {
|
|
1272
|
+
const n = td.values.length;
|
|
1273
|
+
if (n < 2)
|
|
1274
|
+
continue;
|
|
1275
|
+
// Regressão linear simples
|
|
1276
|
+
const sumX = td.years.reduce((a, b) => a + b, 0);
|
|
1277
|
+
const sumY = td.values.reduce((a, b) => a + b, 0);
|
|
1278
|
+
const sumXY = td.years.reduce((acc, x, i) => acc + x * td.values[i], 0);
|
|
1279
|
+
const sumX2 = td.years.reduce((acc, x) => acc + x * x, 0);
|
|
1280
|
+
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
|
1281
|
+
const startValue = td.values[0];
|
|
1282
|
+
const endValue = td.values[n - 1];
|
|
1283
|
+
const changePct = startValue > 0 ? ((endValue - startValue) / startValue) * 100 : 0;
|
|
1284
|
+
trends[compareKey] = {
|
|
1285
|
+
slope: Math.round(slope * 1000) / 1000,
|
|
1286
|
+
direction: slope > 0.1 ? "increasing" : slope < -0.1 ? "decreasing" : "stable",
|
|
1287
|
+
avg_annual_change: Math.round(slope * 100) / 100,
|
|
1288
|
+
start_value: Math.round(startValue * 100) / 100,
|
|
1289
|
+
end_value: Math.round(endValue * 100) / 100,
|
|
1290
|
+
change_pct: Math.round(changePct * 100) / 100,
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
// Identifica melhor/pior desempenho (para percentage, menor é melhor)
|
|
1295
|
+
let bestPerformer;
|
|
1296
|
+
let worstPerformer;
|
|
1297
|
+
if (Object.keys(trends).length > 1) {
|
|
1298
|
+
const sortedByChange = Object.entries(trends).sort((a, b) => a[1].change_pct - b[1].change_pct);
|
|
1299
|
+
if (indicatorType === "percentage") {
|
|
1300
|
+
// Para porcentagem ICSAP, queda é melhor
|
|
1301
|
+
bestPerformer = sortedByChange[0][0];
|
|
1302
|
+
worstPerformer = sortedByChange[sortedByChange.length - 1][0];
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
// Para contagem e taxa, menor variação positiva ou maior queda é melhor
|
|
1306
|
+
bestPerformer = sortedByChange[0][0];
|
|
1307
|
+
worstPerformer = sortedByChange[sortedByChange.length - 1][0];
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
return {
|
|
1311
|
+
indicator: indicatorType,
|
|
1312
|
+
period: { start: start_year, end: end_year },
|
|
1313
|
+
compare_by: compare_by || "total",
|
|
1314
|
+
series,
|
|
1315
|
+
...notesField(years, { icsap: true }, [
|
|
1316
|
+
universeNote(args.universe),
|
|
1317
|
+
...(indicatorType === "rate_per_10k" ? populationNotes(await popSourcesFor(years)) : []),
|
|
1318
|
+
]),
|
|
1319
|
+
trends: includeTrend ? trends : undefined,
|
|
1320
|
+
summary: {
|
|
1321
|
+
best_performer: bestPerformer,
|
|
1322
|
+
worst_performer: worstPerformer,
|
|
1323
|
+
note: indicatorType === "percentage"
|
|
1324
|
+
? "Para % ICSAP, queda indica melhoria na Atenção Primária"
|
|
1325
|
+
: undefined,
|
|
1326
|
+
},
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
catch (error) {
|
|
1330
|
+
return {
|
|
1331
|
+
error: error instanceof Error ? error.message : "Erro na análise de tendências",
|
|
1332
|
+
series: [],
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
// =============================================================================
|
|
1337
|
+
// SERVIDOR MCP
|
|
1338
|
+
// =============================================================================
|
|
1339
|
+
const server = new Server({
|
|
1340
|
+
name: "sih-br-mcp",
|
|
1341
|
+
version: SERVER_VERSION,
|
|
1342
|
+
}, {
|
|
1343
|
+
capabilities: {
|
|
1344
|
+
tools: {},
|
|
1345
|
+
},
|
|
1346
|
+
});
|
|
1347
|
+
// Handler: Lista ferramentas disponíveis
|
|
1348
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1349
|
+
return { tools };
|
|
1350
|
+
});
|
|
1351
|
+
// Ferramentas que não leem cubo (só tabelas de referência, metadados ou
|
|
1352
|
+
// população): não disparam download do cache.
|
|
1353
|
+
const TOOLS_WITHOUT_CUBES = new Set(["list_csap_groups", "list_cid_chapters", "get_available_years", "classify_as_csap"]);
|
|
1354
|
+
// Ferramentas que precisam do denominador populacional (0.12.0): garantem os
|
|
1355
|
+
// arquivos de população no cache local antes de rodar — chamada própria, fora
|
|
1356
|
+
// do `if` dos cubos, porque get_available_years está em TOOLS_WITHOUT_CUBES e
|
|
1357
|
+
// mesmo assim relata population_years.
|
|
1358
|
+
const TOOLS_WITH_POPULATION = new Set(["get_hospitalization_rates", "compare_icsap_trends", "get_available_years"]);
|
|
1359
|
+
/**
|
|
1360
|
+
* População: só age quando a pasta de dados configurada não tem os três
|
|
1361
|
+
* arquivos (instalação pelo npm) e o cache está ligado. Idempotente. Falha de
|
|
1362
|
+
* rede não derruba a chamada — o handler responde "sem denominador" com a
|
|
1363
|
+
* mensagem única (POPULATION_MISSING_MESSAGE); o motivo vai para o stderr.
|
|
1364
|
+
*/
|
|
1365
|
+
async function ensurePopulationForTool(name) {
|
|
1366
|
+
if (!CUBES_CACHE_ENABLED || !TOOLS_WITH_POPULATION.has(name))
|
|
1367
|
+
return;
|
|
1368
|
+
if (populationPresent(configuredDataDirectory()))
|
|
1369
|
+
return;
|
|
1370
|
+
try {
|
|
1371
|
+
const { downloaded, available } = await ensurePopulation(cubesCacheDir(), (m) => console.error(`[cache] ${m}`));
|
|
1372
|
+
if (downloaded.length) {
|
|
1373
|
+
console.error(`[cache] população (${downloaded.join(", ")}) baixada para ${cubesCacheDir()}`);
|
|
1374
|
+
resetPopulationCoverageCache();
|
|
1375
|
+
}
|
|
1376
|
+
if (!available)
|
|
1377
|
+
console.error(`[cache] manifesto de ${CUBES_BASE_URL} sem bloco population — sem denominador até o produtor publicar`);
|
|
1378
|
+
}
|
|
1379
|
+
catch (e) {
|
|
1380
|
+
console.error(`[cache] população não baixada: ${e instanceof Error ? e.message : String(e)}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// Handler: Executa ferramenta
|
|
1384
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1385
|
+
const { name, arguments: args } = request.params;
|
|
1386
|
+
try {
|
|
1387
|
+
let result;
|
|
1388
|
+
// Denominadores populacionais do canal (0.12.0): antes do switch e fora do
|
|
1389
|
+
// `if` dos cubos — get_available_years não lê cubo mas relata population_years.
|
|
1390
|
+
await ensurePopulationForTool(name);
|
|
1391
|
+
// Cache local dos cubos (src/cache.ts): ferramentas que leem cubo garantem
|
|
1392
|
+
// antes os anos pedidos — ou a série publicada inteira, quando a chamada
|
|
1393
|
+
// não diz ano. Só entra em ação quando a pasta de dados não tem cubos
|
|
1394
|
+
// (instalação pelo npm); com data/ cheio, ensureYears() não baixa nada.
|
|
1395
|
+
if (CUBES_CACHE_ENABLED && !TOOLS_WITHOUT_CUBES.has(name)) {
|
|
1396
|
+
const dir = getDataDirectory();
|
|
1397
|
+
const { downloaded, unavailable } = await ensureYears(dir, yearsFromArgs(args), (m) => console.error(`[cache] ${m}`));
|
|
1398
|
+
if (downloaded.length)
|
|
1399
|
+
console.error(`[cache] ano(s) ${downloaded.join(", ")} baixado(s) para ${dir}`);
|
|
1400
|
+
if (unavailable.length && downloaded.length === 0 && getAvailableYears().length === 0) {
|
|
1401
|
+
return {
|
|
1402
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1403
|
+
error: `Ano(s) ${unavailable.join(", ")} não publicado(s) no canal de cubos (${CUBES_BASE_URL}) e nenhum cubo local.`,
|
|
1404
|
+
published_years: publishedYears((await loadCubesManifest()).manifest),
|
|
1405
|
+
}, null, 2) }],
|
|
1406
|
+
isError: true,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
switch (name) {
|
|
1411
|
+
// Metadados
|
|
1412
|
+
case "list_csap_groups":
|
|
1413
|
+
result = await handleListCsapGroups(args);
|
|
1414
|
+
break;
|
|
1415
|
+
case "list_cid_chapters":
|
|
1416
|
+
result = await handleListCidChapters();
|
|
1417
|
+
break;
|
|
1418
|
+
case "get_available_years":
|
|
1419
|
+
result = await handleGetAvailableYears();
|
|
1420
|
+
break;
|
|
1421
|
+
case "classify_as_csap":
|
|
1422
|
+
result = await handleClassifyAsCsap(args);
|
|
1423
|
+
break;
|
|
1424
|
+
// Internações Gerais
|
|
1425
|
+
case "get_hospitalizations":
|
|
1426
|
+
result = await handleGetHospitalizations(args);
|
|
1427
|
+
break;
|
|
1428
|
+
case "get_hospitalization_trends":
|
|
1429
|
+
result = await handleGetHospitalizationTrends(args);
|
|
1430
|
+
break;
|
|
1431
|
+
case "compare_regions":
|
|
1432
|
+
result = await handleCompareRegions(args);
|
|
1433
|
+
break;
|
|
1434
|
+
// ICSAP
|
|
1435
|
+
case "get_icsap":
|
|
1436
|
+
result = await handleGetIcsap(args);
|
|
1437
|
+
break;
|
|
1438
|
+
case "get_icsap_indicators":
|
|
1439
|
+
result = await handleGetIcsapIndicators(args);
|
|
1440
|
+
break;
|
|
1441
|
+
case "rank_csap_groups":
|
|
1442
|
+
result = await handleRankCsapGroups(args);
|
|
1443
|
+
break;
|
|
1444
|
+
// Ferramentas com dados populacionais
|
|
1445
|
+
case "get_hospitalization_rates":
|
|
1446
|
+
result = await handleGetHospitalizationRates(args);
|
|
1447
|
+
break;
|
|
1448
|
+
case "compare_icsap_trends":
|
|
1449
|
+
result = await handleCompareIcsapTrends(args);
|
|
1450
|
+
break;
|
|
1451
|
+
default:
|
|
1452
|
+
return {
|
|
1453
|
+
content: [
|
|
1454
|
+
{
|
|
1455
|
+
type: "text",
|
|
1456
|
+
text: `Ferramenta desconhecida: ${name}`,
|
|
1457
|
+
},
|
|
1458
|
+
],
|
|
1459
|
+
isError: true,
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
// Toda resposta sai com o bloco de proveniência do contrato (concise):
|
|
1463
|
+
// fonte, URL, safra, retrieved_at, citação e licença — ver src/provenance.ts.
|
|
1464
|
+
return withProvenance(result, provenanceFor(name, args));
|
|
1465
|
+
}
|
|
1466
|
+
catch (error) {
|
|
1467
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1468
|
+
return {
|
|
1469
|
+
content: [
|
|
1470
|
+
{
|
|
1471
|
+
type: "text",
|
|
1472
|
+
text: `Erro ao executar ${name}: ${errorMessage}`,
|
|
1473
|
+
},
|
|
1474
|
+
],
|
|
1475
|
+
isError: true,
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
// =============================================================================
|
|
1480
|
+
// INICIALIZAÇÃO
|
|
1481
|
+
// =============================================================================
|
|
1482
|
+
async function main() {
|
|
1483
|
+
const transport = new StdioServerTransport();
|
|
1484
|
+
await server.connect(transport);
|
|
1485
|
+
console.error("SIH-BR-MCP Server iniciado");
|
|
1486
|
+
// Frescor dos cubos: sonda o manifesto do espelho em segundo plano (512
|
|
1487
|
+
// bytes; só baixa os 10 MB se o manifesto mudou), com timeout curto. Não
|
|
1488
|
+
// espera — a primeira ferramenta chamada antes do veredito vê `pending`.
|
|
1489
|
+
void startFreshnessCheck();
|
|
1490
|
+
}
|
|
1491
|
+
main().catch((error) => {
|
|
1492
|
+
console.error("Erro fatal:", error);
|
|
1493
|
+
process.exit(1);
|
|
1494
|
+
});
|
|
1495
|
+
// Cleanup ao encerrar
|
|
1496
|
+
process.on("SIGINT", async () => {
|
|
1497
|
+
await closeDatabase();
|
|
1498
|
+
process.exit(0);
|
|
1499
|
+
});
|
|
1500
|
+
process.on("SIGTERM", async () => {
|
|
1501
|
+
await closeDatabase();
|
|
1502
|
+
process.exit(0);
|
|
1503
|
+
});
|
|
1504
|
+
//# sourceMappingURL=index.js.map
|