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/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