senado-br-mcp 3.20.1 → 3.21.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.
@@ -218,11 +218,20 @@ const ideiasSchema = {
218
218
  {
219
219
  name: "plConvertido",
220
220
  type: "string",
221
- description: "Proposição gerada pela ideia (ex.: \"SUG 12/2024\"), quando convertida.",
221
+ description: "Sugestão gerada pela ideia na CDH (ex.: \"SUG 12/2024\"). Apesar do nome histórico, NÃO é o projeto de lei: os projetos estão em `proposicoes`.",
222
222
  sourceEndpoint: EP_IDEIAS_DETALHE,
223
- sourceField: "página de detalhe: identificação da proposição (SUG/PL nº); desde a 3.20.0, o acontecimento \"Essa ideia gerou a Sugestão nº N, de AAAA\" da linha do tempo",
224
- operationalization: "Até a 3.19: regex de identificação de proposição no detalhe. Desde a 3.20.0 (página redesenhada): a Sugestão gerada, \"SUG N/AAAA\"; as proposições em que a ideia virou (pela CDH ou adotada por parlamentar) saem só no detalhe ao vivo (senado_ecidadania_obter_ideia). Leitura sem Sugestão não apaga valor já gravado.",
225
- caveat: "Detail-only (v2): null nas ideias ainda não enriquecidas ou não convertidas.",
223
+ sourceField: "página de detalhe: o acontecimento \"Essa ideia gerou a Sugestão nº N, de AAAA\" da linha do tempo (desde a 3.20.0)",
224
+ operationalization: "Desde a 3.20.0 (página redesenhada): a Sugestão gerada, \"SUG N/AAAA\". Até a 3.19 um regex de identificação de proposição deixou valores de outro tipo (PL/PEC, alguns com ano impossível); na 3.21.0 esses 11 foram relidos da página e retirados do campo (os projetos foram para `proposicoes`). Leitura sem Sugestão não apaga valor já gravado.",
225
+ caveat: "Detail-only (v2): null nas ideias sem Sugestão ou ainda não lidas pelo leitor da 3.20.0.",
226
+ },
227
+ {
228
+ name: "proposicoes",
229
+ type: "object",
230
+ description: "Proposições em que a ideia foi transformada, pela CDH ou adotada por parlamentar (lista; uma ideia pode ter várias).",
231
+ sourceEndpoint: EP_IDEIAS_DETALHE,
232
+ sourceField: "página de detalhe: acontecimentos de transformação da linha do tempo (\"Ideia transformada em …\", adoção por Senador(a)/Deputado(a)) e o link de cada proposição",
233
+ operationalization: "Copiadas da linha do tempo, na ordem dela; cada item: ordem, data, transformadaPor (Comissão/Senador/Deputado, como publicado), sexo (gênero gramatical do cargo publicado), parlamentar e parlamentarUf (agentes públicos), tipoSigla, tipoNome, numero, ano, complemento, url. [] = página lida sem proposição. Lista guardada com itens não é apagada por leitura vazia.",
234
+ caveat: "Detail-only (3.21.0): null nas ideias ainda não lidas pelo leitor da 3.21.0 (a cobertura cresce com o backfill diário de detalhe).",
226
235
  },
227
236
  {
228
237
  name: "url",
@@ -348,6 +348,8 @@ export function buildIdeiaResumo(fields) {
348
348
  autorUf: fields.autorUf ?? null,
349
349
  descricao: fields.descricao ?? null,
350
350
  plConvertido: fields.plConvertido ?? null,
351
+ // Só entra quando lida: a chave ausente deixa o payload das linhas antigas byte-idêntico.
352
+ ...(fields.proposicoes !== undefined ? { proposicoes: fields.proposicoes } : {}),
351
353
  status: fields.status || "aberta",
352
354
  url: fields.url || `${ECIDADANIA_BASE}/visualizacaoideia?id=${fields.id}`,
353
355
  };
@@ -327,6 +327,7 @@ export function detalheParaCorpus(d) {
327
327
  autorUf: d.autorUf,
328
328
  descricao: d.descricao ? d.descricao.substring(0, DESCRICAO_CORPUS_MAX) : null,
329
329
  plConvertido: d.sugestao ? `SUG ${d.sugestao.numero}/${d.sugestao.ano}` : null,
330
+ proposicoes: d.proposicoes,
330
331
  };
331
332
  }
332
333
  /**
@@ -57,6 +57,22 @@ export function mesclarCampo(lido, guardado) {
57
57
  return guardado;
58
58
  return null;
59
59
  }
60
+ /**
61
+ * Mesclagem de um campo LISTA em que a lista vazia lida é informação ("a página foi lida e não traz
62
+ * nenhum"), distinta do campo ausente ("ainda não lida"). Lista com itens lida substitui; senão fica a
63
+ * guardada com itens; senão `[]` se alguém leu (agora ou antes); senão `undefined` (o campo fica de fora
64
+ * do payload, e as linhas nunca lidas mantêm o hash de antes). Lista guardada com itens nunca vira `[]`.
65
+ * `null` lido = não leu (fetch falhou, ou escritor que não lê a página).
66
+ */
67
+ export function mesclarLista(lido, guardado) {
68
+ if (Array.isArray(lido) && lido.length > 0)
69
+ return lido;
70
+ if (Array.isArray(guardado) && guardado.length > 0)
71
+ return guardado;
72
+ if (Array.isArray(lido) || Array.isArray(guardado))
73
+ return [];
74
+ return undefined;
75
+ }
60
76
  /**
61
77
  * Número no formato brasileiro ("1.234.567") ou null quando não há número para ler. Par do
62
78
  * `parseBrNum` (que devolve 0 para ausente/lixo e por isso não serve a escritor do corpus: ausência
@@ -169,7 +169,7 @@ export const ideiasHighlightCfg = {
169
169
  metrica: (i) => i.apoios,
170
170
  comissao: () => null,
171
171
  // Preserves the corpus-authoritative detail fields (dataPublicacao/autorUf/descricao/plConvertido,
172
- // v2) so the 2h apoios splice never wipes them; apoios only when the REST item carried it.
172
+ // v2; proposicoes, 3.21) so the 2h apoios splice never wipes them; apoios only when the REST item carried it.
173
173
  splice: (corpus, fresh) => {
174
174
  if (!corpus) {
175
175
  return fresh.apoios === null ? null : buildIdeiaResumo({ id: fresh.id, titulo: fresh.titulo, apoios: fresh.apoios });
@@ -182,6 +182,7 @@ export const ideiasHighlightCfg = {
182
182
  autorUf: corpus.autorUf,
183
183
  descricao: corpus.descricao,
184
184
  plConvertido: corpus.plConvertido,
185
+ proposicoes: corpus.proposicoes,
185
186
  status: corpus.status,
186
187
  url: corpus.url,
187
188
  });
@@ -297,7 +297,7 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
297
297
  }
298
298
  });
299
299
  // G5. senado_ecidadania_listar_ideias
300
- server.tool("senado_ecidadania_listar_ideias", "Lista ideias legislativas propostas por cidadãos no e-Cidadania — **conjunto completo** (corpus persistido em D1; ideias em andamento atualizadas diariamente; ~118 mil ideias, incluindo encerradas e convertidas em proposição). Retorna `{ count, ideias, avisoDatas }`, cada ideia com `id`, `titulo`, `apoios`, `status` (a categoria da listagem do portal em que a ideia aparece: `aberta` = \"Aberta\", \"Na comissão\" ou \"Aguardando envio à CDH\"; `encerrada` = \"Encerrada – sem apoio suficiente\" ou \"Não acatada\" pela CDH; `convertida` = \"Transformada em proposição\"; `removida` = retirada do portal, que não a publica mais — o corpus guarda o último estado conhecido), `url` e os campos lidos do detalhe quando o backfill já passou por ela (`dataPublicacao`, `autorUf`, `descricao`, `plConvertido`; `null` até lá; `autorUf` traz a sigla da UF ou \"Exterior\", como o portal publica, e `null` quando o portal não publica a localidade). **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o corpus guarda; o nome do campo é histórico — e por isso cai no futuro em ideia aberta; `dataCriacaoEstimada` (= data limite − 120 dias, a regra dos 4 meses do portal) é calculada pelo servidor e é estimativa, não dado da fonte; a data REAL da proposição sai em `senado_ecidadania_obter_ideia` (`dataProposta`); `avisoDatas` repete isso na resposta. Aceita filtro por `status` e `limite` (padrão 20). Para um ranking das mais apoiadas, ordene por apoios (`ordenarPor: \"apoios\"`, `ordem: \"desc\"`). Para o detalhe completo de uma ideia (texto, autor, linha do tempo, Sugestão, decisão da CDH, proposições em que virou) chame `senado_ecidadania_obter_ideia` com o `id`.", {
300
+ server.tool("senado_ecidadania_listar_ideias", "Lista ideias legislativas propostas por cidadãos no e-Cidadania — **conjunto completo** (corpus persistido em D1; ideias em andamento atualizadas diariamente; ~118 mil ideias, incluindo encerradas e convertidas em proposição). Retorna `{ count, ideias, avisoDatas }`, cada ideia com `id`, `titulo`, `apoios`, `status` (a categoria da listagem do portal em que a ideia aparece: `aberta` = \"Aberta\", \"Na comissão\" ou \"Aguardando envio à CDH\"; `encerrada` = \"Encerrada – sem apoio suficiente\" ou \"Não acatada\" pela CDH; `convertida` = \"Transformada em proposição\"; `removida` = retirada do portal, que não a publica mais — o corpus guarda o último estado conhecido), `url` e os campos lidos do detalhe quando o backfill já passou por ela (`dataPublicacao`, `autorUf`, `descricao`, `plConvertido`; `null` até lá; `autorUf` traz a sigla da UF ou \"Exterior\", como o portal publica, e `null` quando o portal não publica a localidade; `plConvertido`, apesar do nome, é a Sugestão gerada na CDH, \"SUG N/AAAA\", não o projeto de lei). `proposicoes` (desde a 3.21.0, ausente até o backfill ler a ideia; `[]` = lida sem proposição) lista os projetos em que a ideia foi transformada, pela CDH ou adotada por parlamentar: tipo, número, ano, quem transformou, data e link. **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o corpus guarda; o nome do campo é histórico — e por isso cai no futuro em ideia aberta; `dataCriacaoEstimada` (= data limite − 120 dias, a regra dos 4 meses do portal) é calculada pelo servidor e é estimativa, não dado da fonte; a data REAL da proposição sai em `senado_ecidadania_obter_ideia` (`dataProposta`); `avisoDatas` repete isso na resposta. Aceita filtro por `status` e `limite` (padrão 20). Para um ranking das mais apoiadas, ordene por apoios (`ordenarPor: \"apoios\"`, `ordem: \"desc\"`). Para o detalhe completo de uma ideia (texto, autor, linha do tempo, Sugestão, decisão da CDH, proposições em que virou) chame `senado_ecidadania_obter_ideia` com o `id`.", {
301
301
  status: z.enum(["aberta", "encerrada", "convertida", "removida", "todas"]).optional().describe("Filtrar por status (`removida` = retirada do portal)"),
302
302
  ordenarPor: z.enum(["apoios", "data", "comentarios"]).optional().describe("Campo para ordenação (apoios é o disponível no corpus; data/comentarios só no detalhe)"),
303
303
  ordem: z.enum(["asc", "desc"]).optional().describe("Ordem de ordenação"),
@@ -97,6 +97,23 @@ const OFFICIAL_ARRAY_FIELDS = {
97
97
  */
98
98
  const CITIZEN_ARRAY_FIELDS = { ideias: ["avisos"] };
99
99
  const CITIZEN_OBJECT_ARRAY_FIELDS = { ideias: { linhaDoTempo: ["texto"] } };
100
+ /** Texto oficial dentro das proposições da ideia (nome do parlamentar e do tipo, copiados da página). */
101
+ const OFFICIAL_OBJECT_ARRAY_FIELDS = {
102
+ ideias: { proposicoes: ["parlamentar", "tipoNome", "complemento"] },
103
+ };
104
+ function applyObjectArray(arr, campos, fn) {
105
+ if (!Array.isArray(arr))
106
+ return arr;
107
+ return arr.map((el) => {
108
+ if (!el || typeof el !== "object")
109
+ return el;
110
+ const copia = { ...el };
111
+ for (const c of campos)
112
+ if (c in copia)
113
+ copia[c] = applyField(copia[c], fn);
114
+ return copia;
115
+ });
116
+ }
100
117
  /**
101
118
  * Retorna uma CÓPIA rasa de `item` com os campos de texto tratados para a entidade dada: campos de
102
119
  * cidadão demarcados, campos oficiais sanitizados. Não muta a entrada (o item persistido em
@@ -126,18 +143,12 @@ export function tagUntrustedFields(entidade, item) {
126
143
  out[f] = applyStringArray(out[f], neutralizeUntrustedText);
127
144
  }
128
145
  for (const [f, campos] of Object.entries(CITIZEN_OBJECT_ARRAY_FIELDS[entidade] ?? {})) {
129
- const arr = out[f];
130
- if (!Array.isArray(arr))
131
- continue;
132
- out[f] = arr.map((el) => {
133
- if (!el || typeof el !== "object")
134
- return el;
135
- const copia = { ...el };
136
- for (const c of campos)
137
- if (c in copia)
138
- copia[c] = applyField(copia[c], neutralizeUntrustedText);
139
- return copia;
140
- });
146
+ if (f in out)
147
+ out[f] = applyObjectArray(out[f], campos, neutralizeUntrustedText);
148
+ }
149
+ for (const [f, campos] of Object.entries(OFFICIAL_OBJECT_ARRAY_FIELDS[entidade] ?? {})) {
150
+ if (f in out)
151
+ out[f] = applyObjectArray(out[f], campos, sanitizeScrapedText);
141
152
  }
142
153
  return out;
143
154
  }
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Single source of truth for the server version (kept in sync with package.json). */
2
- export const VERSION = "3.20.1";
2
+ export const VERSION = "3.21.0";
3
3
  export const USER_AGENT = `senado-br-mcp/${VERSION} (+https://senado.sidneybissoli.com; sbissoli76@gmail.com)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "senado-br-mcp",
3
- "version": "3.20.1",
3
+ "version": "3.21.0",
4
4
  "description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
5
5
  "keywords": [
6
6
  "mcp",