senado-br-mcp 3.16.0 → 3.18.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/README.md +2 -2
- package/dist/dataset/schema.js +4 -4
- package/dist/scraper/ecidadania.js +38 -0
- package/dist/tools/ecidadania.js +21 -6
- package/dist/tools/estrutura.js +1 -0
- package/dist/tools/materias.js +47 -19
- package/dist/tools/servidores.js +31 -9
- package/dist/utils/provenance.js +74 -19
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -532,7 +532,7 @@ This caching happens at the **tool level** (inside each tool's callback), not at
|
|
|
532
532
|
|
|
533
533
|
## Provenance
|
|
534
534
|
|
|
535
|
-
**Every tool** attaches a **provenance envelope** so a result is traceable back to its official source — provenance is treated as a first-class part of the answer, not an optional extra (the audience is journalists and political-science researchers, for whom an un-sourced figure is unusable).
|
|
535
|
+
**Every tool** attaches a **provenance envelope** so a result is traceable back to its official source — provenance is treated as a first-class part of the answer, not an optional extra (the audience is journalists and political-science researchers, for whom an un-sourced figure is unusable). The envelope implements the portfolio-wide **provenance contract** ([`@sbissoli/mcp-provenance`](https://www.npmjs.com/package/@sbissoli/mcp-provenance)): the server builds and validates a full canonical model per response, and emits its **`concise` projection** — a fixed-order block with explicit `null` for unknown fields; responses that merge several upstream reads also list each part with its own extraction instant (`field_sources`), and the block's `retrieved_at` is then the oldest of them. The block lives in `structuredContent.provenance` (parseable by clients; note the advertised per-tool output schema is permissive, so contract validation happens server-side at build time, in the package) and is mirrored as a compact source footer in the text content for clients that only render text — the data JSON itself is **not** duplicated with the envelope, to keep the per-response token cost low.
|
|
536
536
|
|
|
537
537
|
Coverage spans all four upstream sources, each with its own `source`/`citation`/`license` (in `src/utils/provenance.ts`):
|
|
538
538
|
|
|
@@ -581,7 +581,7 @@ Each release ships one NDJSON resource per entity (one `HarmonizedRecord` per li
|
|
|
581
581
|
| Resource (`*.ndjson`) | Grain | Key variables | Source(s) |
|
|
582
582
|
|---|---|---|---|
|
|
583
583
|
| `consultas` | 1 public consultation (matéria) | `materia`, `ementa`, `votosSim`/`votosNao`/`totalVotos`, `percentual*`, **`autoria`ⁿ**, **`relator`ⁿ**, `status`, `url`, `firstSeenAt` | `pesquisamateria` listing + **detail** (`visualizacaomateria`)ⁿ + `/processo?tramitando=S` for status |
|
|
584
|
-
| `ideias` | 1 legislative idea (~113.7k) | `titulo`, `apoios`, `status`, **`dataPublicacao
|
|
584
|
+
| `ideias` | 1 legislative idea (~113.7k) | `titulo`, `apoios`, `status`, **`dataPublicacao`ⁿ** (the *support deadline* — the only date the portal publishes; creation ≈ deadline − 120 days, see the dictionary), **`autorUf`ⁿ**, **`descricao`ⁿ**, **`plConvertido`ⁿ**, `url`, `firstSeenAt` | `pesquisaideia` listing + **detail** (`visualizacaoideia`) via a resumable backfillⁿ |
|
|
585
585
|
| `eventos` | 1 interactive event (audiência) | `titulo`, **`data`ᶜ**, **`hora`ᶜ**, `comissao`, **`comissaoNomeCompleto`ⁿ**, **`local`ⁿ**, **`descricao`ⁿ**, **`pauta`ⁿ**, **`convidados`ⁿ**, **`videoUrl`ⁿ**, **`comentarios`ᶜ**, `status`, `url`, `firstSeenAt` | `principalaudiencia` listing + **detail** (`visualizacaoaudiencia`)ⁿ + **AJAX comment fragment**ᶜ |
|
|
586
586
|
| **`eventos_comentarios`ⁿ** | 1 comment (comment-level) | `eventoId`, `comentarioId`, `uf`, `texto`, `data`, `hora`, `momentoVideoUrl`, `convidadoAssociado` | AJAX fragment `ajaxcolecaocomentarioaudiencia?audienciaId=` |
|
|
587
587
|
| `consultas_votos` | 1 matéria (historical acervo) | `materia`, `ementa`, `autoria`, `votosSim`/`votosNao`/`totalVotos`, `votosPorUf`, `status`, `url`, `referencePeriod` | Arquimedes CSV `Proposições-com-votos.csv` (re-ingested weekly) |
|
package/dist/dataset/schema.js
CHANGED
|
@@ -191,11 +191,11 @@ const ideiasSchema = {
|
|
|
191
191
|
{
|
|
192
192
|
name: "dataPublicacao",
|
|
193
193
|
type: "date",
|
|
194
|
-
description: "Data limite para atingir os 20.000 apoios (única âncora temporal upstream da ideia).",
|
|
194
|
+
description: "Data limite para atingir os 20.000 apoios (única âncora temporal upstream da ideia). NÃO é a data de criação: o nome é histórico, mantido pela compatibilidade do esquema.",
|
|
195
195
|
sourceEndpoint: EP_IDEIAS_DETALHE,
|
|
196
|
-
sourceField: "página de detalhe: bloco \"Data limite\" (DD/MM/AAAA)",
|
|
197
|
-
operationalization: "extractDate sobre a data-limite exibida no detalhe (visualizacaoideia) → ISO YYYY-MM-DD; lido no crawl de detalhe. Reaberto na v2 (era null por design na v1).",
|
|
198
|
-
caveat: "Detail-only (v2): null nas ideias ainda não enriquecidas pelo backfill de detalhe (censura à esquerda residual nesses campos até o backfill completar).",
|
|
196
|
+
sourceField: "página de detalhe: bloco \"Data limite para receber 20.000 apoios\" (DD/MM/AAAA)",
|
|
197
|
+
operationalization: "extractDate sobre a data-limite exibida no detalhe (visualizacaoideia) → ISO YYYY-MM-DD; lido no crawl de detalhe. Reaberto na v2 (era null por design na v1). Data provável de criação = esta data − 120 dias (regra dos 4 meses do portal; medida exata em 4 ideias convertidas de 2017-2020 contra a base de Pereira, UFPE 2024) — derivação do leitor, não variável do dataset.",
|
|
198
|
+
caveat: "Em ideia aberta esta data está no futuro (é o fim do prazo de apoios). Detail-only (v2): null nas ideias ainda não enriquecidas pelo backfill de detalhe (censura à esquerda residual nesses campos até o backfill completar).",
|
|
199
199
|
},
|
|
200
200
|
{
|
|
201
201
|
name: "autorUf",
|
|
@@ -253,6 +253,44 @@ export async function obterConsultaInternal(id) {
|
|
|
253
253
|
linkMateria: null,
|
|
254
254
|
};
|
|
255
255
|
}
|
|
256
|
+
// ── Ideias ─────────────────────────────────────────────────────────────
|
|
257
|
+
/**
|
|
258
|
+
* Prazo de apoios de uma ideia, em dias. O portal diz que "as ideias ficam abertas por 4 meses"
|
|
259
|
+
* (`/comofuncionaideia`) e publica UMA data só, a "Data limite para receber 20.000 apoios" — é ela
|
|
260
|
+
* que o campo `dataPublicacao` guarda (nome histórico; ver `AVISO_DATAS_IDEIA`). Medido em
|
|
261
|
+
* 09/10/2026 contra a data de proposição de 4 ideias convertidas (base de Pereira, UFPE 2024,
|
|
262
|
+
* github.com/theus0307/dissertacao): a data limite é SEMPRE a proposição + 120 dias exatos —
|
|
263
|
+
* SUG 49/2017 12/09/2017 → 10/01/2018; SUG 43/2019 14/10/2019 → 11/02/2020; SUG 49/2019
|
|
264
|
+
* 31/07/2019 → 28/11/2019; SUG 21/2020 02/06/2020 → 30/09/2020. "4 meses" de calendário erraria
|
|
265
|
+
* em 2 a 3 dias em todas.
|
|
266
|
+
*/
|
|
267
|
+
export const IDEIA_PRAZO_APOIOS_DIAS = 120;
|
|
268
|
+
/**
|
|
269
|
+
* Aviso que acompanha as datas de ideia na saída das tools. Vai JUNTO do dado, não só na descrição
|
|
270
|
+
* da tool: a descrição é lida uma vez, o dado é lido a cada resposta, e a data limite de uma ideia
|
|
271
|
+
* aberta cai no futuro — sem o aviso o leitor conclui que o servidor errou.
|
|
272
|
+
*/
|
|
273
|
+
export const AVISO_DATAS_IDEIA = "dataPublicacao é a data limite para receber os 20.000 apoios, a única data que o portal publica (o nome do campo é histórico); " +
|
|
274
|
+
"por isso cai no futuro em ideia aberta. dataCriacaoEstimada = data limite menos 120 dias, calculada pelo servidor " +
|
|
275
|
+
"(regra dos 4 meses do portal, medida em 4 ideias de 2017-2020): é estimativa, não dado da fonte.";
|
|
276
|
+
/**
|
|
277
|
+
* Data provável de criação da ideia: data limite (ISO) − `IDEIA_PRAZO_APOIOS_DIAS`. Aritmética em UTC
|
|
278
|
+
* sobre o dia civil (sem fuso, sem horário de verão). Data ausente ou inválida → null, nunca uma data
|
|
279
|
+
* inventada (o `Date` "corrige" 31/02 para 03/03; aqui isso é rejeitado).
|
|
280
|
+
*/
|
|
281
|
+
export function estimarCriacaoIdeia(dataLimite) {
|
|
282
|
+
if (!dataLimite)
|
|
283
|
+
return null;
|
|
284
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dataLimite);
|
|
285
|
+
if (!m)
|
|
286
|
+
return null;
|
|
287
|
+
const [ano, mes, dia] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
288
|
+
const t = Date.UTC(ano, mes - 1, dia);
|
|
289
|
+
const d = new Date(t);
|
|
290
|
+
if (Number.isNaN(t) || d.getUTCFullYear() !== ano || d.getUTCMonth() !== mes - 1 || d.getUTCDate() !== dia)
|
|
291
|
+
return null;
|
|
292
|
+
return new Date(t - IDEIA_PRAZO_APOIOS_DIAS * 86_400_000).toISOString().slice(0, 10);
|
|
293
|
+
}
|
|
256
294
|
/**
|
|
257
295
|
* Canonical `IdeiaResumo` builder — single source of the object's field order, so the JSON payload
|
|
258
296
|
* (and `contentHash`) is byte-identical across the writers into `ecidadania_current`: the 2h highlight
|
package/dist/tools/ecidadania.js
CHANGED
|
@@ -16,9 +16,9 @@ import { provenanceEcidadania, provenanceArquimedesVotos, resultWithProvenance }
|
|
|
16
16
|
import { tagUntrustedFields, tagUntrustedList, neutralizeUntrustedText, sanitizeScrapedText } from "../utils/untrusted.js";
|
|
17
17
|
import { CACHE_ON_DEMAND } from "../types.js";
|
|
18
18
|
import { resolveList, writeDetalheThrough } from "../scraper/store.js";
|
|
19
|
-
import { ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, } from "../scraper/ecidadania.js";
|
|
19
|
+
import { ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, estimarCriacaoIdeia, AVISO_DATAS_IDEIA, IDEIA_PRAZO_APOIOS_DIAS, } from "../scraper/ecidadania.js";
|
|
20
20
|
// Re-export the scraper's pure/IO helpers so existing unit tests keep importing them from here.
|
|
21
|
-
export { ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, };
|
|
21
|
+
export { estimarCriacaoIdeia, AVISO_DATAS_IDEIA, IDEIA_PRAZO_APOIOS_DIAS, ECIDADANIA_BASE, parseBrNum, extractId, normalizeEcidadaniaUrl, stripHtml, extractDate, extractTime, buildConsultaResumo, listarConsultasInternal, obterConsultaInternal, listarIdeiasInternal, obterIdeiaInternal, listarEventosInternal, obterEventoInternal, };
|
|
22
22
|
/** Format an integer with pt-BR thousands separators ("253804" → "253.804").
|
|
23
23
|
* Avoids toLocaleString, whose Intl data is not guaranteed on Workers (OBS-12). */
|
|
24
24
|
export function formatIntBR(n) {
|
|
@@ -163,7 +163,7 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
|
|
|
163
163
|
}
|
|
164
164
|
});
|
|
165
165
|
// G5. senado_ecidadania_listar_ideias
|
|
166
|
-
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 }`, cada ideia com `id`, `titulo`, `apoios`, `status` (`aberta`/`encerrada`/`convertida`/`removida` — `removida` = retirada do portal, que não a publica mais; o corpus guarda o último estado conhecido)
|
|
166
|
+
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` (`aberta`/`encerrada`/`convertida`/`removida` — `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á). **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o portal publica; 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; `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, se virou projeto de lei) chame `senado_ecidadania_obter_ideia` com o `id`.", {
|
|
167
167
|
status: z.enum(["aberta", "encerrada", "convertida", "removida", "todas"]).optional().describe("Filtrar por status (`removida` = retirada do portal)"),
|
|
168
168
|
ordenarPor: z.enum(["apoios", "data", "comentarios"]).optional().describe("Campo para ordenação (apoios é o disponível no corpus; data/comentarios só no detalhe)"),
|
|
169
169
|
ordem: z.enum(["asc", "desc"]).optional().describe("Ordem de ordenação"),
|
|
@@ -181,19 +181,34 @@ export function registerECidadaniaTools(server, _baseUrl, env, ctx) {
|
|
|
181
181
|
const limite = params.limite ?? 20;
|
|
182
182
|
const offset = ((params.pagina ?? 1) - 1) * limite;
|
|
183
183
|
arr = arr.slice(offset, offset + limite);
|
|
184
|
-
|
|
184
|
+
// Derivado na SAÍDA, nunca no corpus: `IdeiaResumo` é o payload persistido (hash estável).
|
|
185
|
+
const ideias = arr.map((i) => ({ ...i, dataCriacaoEstimada: estimarCriacaoIdeia(i.dataPublicacao) }));
|
|
186
|
+
return resultWithProvenance({
|
|
187
|
+
count: ideias.length,
|
|
188
|
+
ideias: tagUntrustedList("ideias", ideias),
|
|
189
|
+
avisoDatas: AVISO_DATAS_IDEIA,
|
|
190
|
+
meta,
|
|
191
|
+
}, provLista("/principalideia", "ideias", meta));
|
|
185
192
|
}
|
|
186
193
|
catch (e) {
|
|
187
194
|
return ecidadaniaError(e);
|
|
188
195
|
}
|
|
189
196
|
});
|
|
190
197
|
// G6. senado_ecidadania_obter_ideia
|
|
191
|
-
server.tool("senado_ecidadania_obter_ideia", "Obtém o detalhe de uma ideia legislativa do e-Cidadania. Retorna um objeto com `id`, `titulo`, `descricao` (texto completo, truncado em ~2000 caracteres), `apoios`, `dataPublicacao`, `status`, `autor`, `url` e `plConvertido` (sigla/número quando virou projeto de lei). O campo `comentarios` vem `null`: a página de ideia não possui recurso de comentários. Obtenha o `id` antes via `senado_ecidadania_listar_ideias`.", { id: z.number().int().positive().describe("ID da ideia legislativa") }, async (params) => {
|
|
198
|
+
server.tool("senado_ecidadania_obter_ideia", "Obtém o detalhe de uma ideia legislativa do e-Cidadania. Retorna um objeto com `id`, `titulo`, `descricao` (texto completo, truncado em ~2000 caracteres), `apoios`, `dataPublicacao`, `dataCriacaoEstimada`, `avisoDatas`, `status`, `autor`, `url` e `plConvertido` (sigla/número quando virou projeto de lei). **Datas:** `dataPublicacao` é a DATA LIMITE para receber os 20.000 apoios — a única data que o portal publica; 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. O campo `comentarios` vem `null`: a página de ideia não possui recurso de comentários. Obtenha o `id` antes via `senado_ecidadania_listar_ideias`.", { id: z.number().int().positive().describe("ID da ideia legislativa") }, async (params) => {
|
|
192
199
|
try {
|
|
193
200
|
const { value: r, fetchedAt } = await cachedFetchWithMeta("ecidadania_ideia", { id: params.id }, CACHE_ON_DEMAND, () => obterIdeiaInternal(params.id));
|
|
194
201
|
writeDetalheThrough(db, ctx, "ideias", params.id, r);
|
|
195
202
|
// OBS-9: idea pages have no comments resource — serve null instead of a spurious 0.
|
|
196
|
-
|
|
203
|
+
// Datas: `dataCriacaoEstimada` e `avisoDatas` entram SÓ na resposta (não no write-through acima,
|
|
204
|
+
// que guarda o que a página diz).
|
|
205
|
+
const ideia = r;
|
|
206
|
+
const detalhe = {
|
|
207
|
+
...r,
|
|
208
|
+
dataCriacaoEstimada: estimarCriacaoIdeia(ideia.dataPublicacao),
|
|
209
|
+
comentarios: null,
|
|
210
|
+
avisoDatas: AVISO_DATAS_IDEIA,
|
|
211
|
+
};
|
|
197
212
|
return resultWithProvenance(tagUntrustedFields("ideias", detalhe), provDetalhe(r, `/visualizacaoideia?id=${params.id}`, `ideia=${params.id}`, fetchedAt));
|
|
198
213
|
}
|
|
199
214
|
catch (e) {
|
package/dist/tools/estrutura.js
CHANGED
|
@@ -31,6 +31,7 @@ export function provenanceEstrutura() {
|
|
|
31
31
|
dataset_id: "estrutura-organizacional",
|
|
32
32
|
reference_period: ESTRUTURA_VINTAGE.slice(0, 10),
|
|
33
33
|
retrieved_at: ESTRUTURA_VINTAGE,
|
|
34
|
+
revision: SOURCES.SENADO_INSTITUCIONAL.revision,
|
|
34
35
|
});
|
|
35
36
|
}
|
|
36
37
|
/** Forma pública de um órgão (nunca expõe o código interno). */
|
package/dist/tools/materias.js
CHANGED
|
@@ -125,6 +125,44 @@ export function parseDocumentoProcesso(d) {
|
|
|
125
125
|
* Resolve codigoMateria → processo summary item via /processo?codigoMateria=.
|
|
126
126
|
* Devolve também o `fetchedAt` do cache p/ a granularidade por-campo (o resumo dá a `ementa`).
|
|
127
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* Proveniência de `senado_obter_materia` secao=detalhe, que funde 3 endpoints: o detalhe
|
|
130
|
+
* (`/processo/{id}`, fonte de topo), a `ementa` do resumo (`/processo`) e o `relator` da
|
|
131
|
+
* relatoria (`/processo/relatoria`, só quando há relator), cada um com o seu instante real.
|
|
132
|
+
*
|
|
133
|
+
* Os três são lidos em momentos (e de entradas de cache) distintos, então o detalhe
|
|
134
|
+
* também entra como sub-fonte: o `retrieved_at` do topo é derivado pelo adaptador como o
|
|
135
|
+
* MAIS ANTIGO das três (contrato §3) e, sem este item, o instante do detalhe sumiria do
|
|
136
|
+
* bloco quando o resumo fosse mais velho. Antes da 3.17.0 o topo era o fetch do detalhe,
|
|
137
|
+
* feito DEPOIS das sub-fontes — com a v1.2 ligada, a lib lançaria e a tool cairia.
|
|
138
|
+
*/
|
|
139
|
+
export function provenanceMateriaDetalhe(baseUrl, a) {
|
|
140
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
141
|
+
const dataset_id = `codigoMateria=${a.codigoMateria}`;
|
|
142
|
+
const fieldSources = [
|
|
143
|
+
{
|
|
144
|
+
fields: Object.keys(a.detalhe),
|
|
145
|
+
source_url: `${base}/processo/${a.idProcesso}`,
|
|
146
|
+
dataset_id,
|
|
147
|
+
retrieved_at: a.detalheFetchedAt,
|
|
148
|
+
},
|
|
149
|
+
{ fields: ["ementa"], source_url: `${base}/processo`, dataset_id, retrieved_at: a.resumoFetchedAt },
|
|
150
|
+
];
|
|
151
|
+
if (a.relatoriaFetchedAt !== undefined) {
|
|
152
|
+
fieldSources.push({
|
|
153
|
+
fields: ["relator"],
|
|
154
|
+
source_url: `${base}/processo/relatoria`,
|
|
155
|
+
dataset_id,
|
|
156
|
+
retrieved_at: a.relatoriaFetchedAt,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return provenanceFor("SENADO_LEGIS", baseUrl, `/processo/${a.idProcesso}`, {
|
|
160
|
+
dataset_id,
|
|
161
|
+
reference_period: a.detalhe.dataApresentacao || (a.detalhe.ano ? String(a.detalhe.ano) : undefined),
|
|
162
|
+
retrieved_at: a.detalheFetchedAt,
|
|
163
|
+
field_sources: fieldSources,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
128
166
|
async function resolveProcesso(codigoMateria, baseUrl) {
|
|
129
167
|
const { value: response, fetchedAt } = await cachedFetchWithMeta("_processo_por_materia", { codigoMateria }, CACHE_ON_DEMAND, () => upstreamFetch("/processo", { codigoMateria: String(codigoMateria) }, baseUrl));
|
|
130
168
|
const item = ensureArray(response)[0];
|
|
@@ -267,7 +305,8 @@ export function registerMateriasTools(server, baseUrl) {
|
|
|
267
305
|
// secao === "detalhe" (padrão) — funde 3 endpoints numa só resposta, então a
|
|
268
306
|
// proveniência ganha granularidade por-campo (field_sources): a fonte de topo é o
|
|
269
307
|
// detalhe (/processo/{id}); a `ementa` vem do resumo (/processo) e o `relator` da
|
|
270
|
-
// relatoria (/processo/relatoria), cada um com o seu retrieved_at real
|
|
308
|
+
// relatoria (/processo/relatoria), cada um com o seu retrieved_at real; o do topo é
|
|
309
|
+
// o mais antigo dos três (ver `provenanceMateriaDetalhe`).
|
|
271
310
|
const [resumoRes, relatoriasRes] = await Promise.all([
|
|
272
311
|
resolveProcesso(params.codigoMateria, baseUrl),
|
|
273
312
|
cachedFetchWithMeta("_processo_relatoria", { codigoMateria: params.codigoMateria }, CACHE_ON_DEMAND, () => upstreamFetch("/processo/relatoria", { codigoMateria: String(params.codigoMateria) }, baseUrl)).catch(() => null),
|
|
@@ -276,24 +315,13 @@ export function registerMateriasTools(server, baseUrl) {
|
|
|
276
315
|
const { value: detalheRes, fetchedAt } = await cachedFetchWithMeta("senado_obter_materia", { idProcesso: resumo.id }, CACHE_ON_DEMAND, () => upstreamFetch(`/processo/${resumo.id}`, {}, baseUrl));
|
|
277
316
|
const detalhe = parseProcessoDetalhe(detalheRes);
|
|
278
317
|
const relator = pickRelatorAtual(ensureArray(relatoriasRes?.value));
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
fields: ["relator"],
|
|
287
|
-
source_url: `${base}/processo/relatoria`,
|
|
288
|
-
dataset_id,
|
|
289
|
-
retrieved_at: relatoriasRes?.fetchedAt,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
const prov = provenanceFor("SENADO_LEGIS", baseUrl, `/processo/${resumo.id}`, {
|
|
293
|
-
dataset_id,
|
|
294
|
-
reference_period: detalhe.dataApresentacao || (detalhe.ano ? String(detalhe.ano) : undefined),
|
|
295
|
-
retrieved_at: fetchedAt,
|
|
296
|
-
field_sources: fieldSources,
|
|
318
|
+
const prov = provenanceMateriaDetalhe(baseUrl, {
|
|
319
|
+
codigoMateria: params.codigoMateria,
|
|
320
|
+
idProcesso: resumo.id,
|
|
321
|
+
detalhe,
|
|
322
|
+
detalheFetchedAt: fetchedAt,
|
|
323
|
+
resumoFetchedAt: resumoRes.fetchedAt,
|
|
324
|
+
relatoriaFetchedAt: relator ? relatoriasRes?.fetchedAt : undefined,
|
|
297
325
|
});
|
|
298
326
|
return resultWithProvenance({
|
|
299
327
|
...detalhe,
|
package/dist/tools/servidores.js
CHANGED
|
@@ -331,6 +331,36 @@ export function particionarPorUnidade(lista, indice, codUnidade) {
|
|
|
331
331
|
afastadosOuEmTransito,
|
|
332
332
|
};
|
|
333
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Proveniência de `senado_servidores` com `subordinadasA`: a classificação cruza a folha
|
|
336
|
+
* (fonte administrativa, buscada/cacheada nesta chamada — `prov`) com o organograma (fonte
|
|
337
|
+
* institucional, snapshot extraído do portal em `ESTRUTURA_VINTAGE` e embutido no bundle).
|
|
338
|
+
*
|
|
339
|
+
* O `retrieved_at` do snapshot é o instante REAL da extração no portal (o crawler o grava
|
|
340
|
+
* em `extraidoEm`), não uma data de build — então fica: zerá-lo para `null` diria "não sei
|
|
341
|
+
* quando foi extraído", o que é falso, e trocá-lo pelo instante da folha diria que o
|
|
342
|
+
* organograma foi lido agora, o que também é. Com ele, o topo passa a ser o mais antigo
|
|
343
|
+
* das duas (derivado pelo adaptador, contrato §3), e a folha entra como sub-fonte própria
|
|
344
|
+
* para que o seu instante não suma do bloco. Antes da 3.17.0 o topo era o da folha, mais
|
|
345
|
+
* novo que o snapshot — com a v1.2 ligada, a lib lançaria e a tool cairia.
|
|
346
|
+
*/
|
|
347
|
+
export function provenanceServidoresSubordinadas(prov, situacao) {
|
|
348
|
+
return withFieldSources(prov, [
|
|
349
|
+
{
|
|
350
|
+
fields: ["servidores", "total", "count", "naoClassificados", "afastadosOuEmTransito"],
|
|
351
|
+
source_url: prov.source_url,
|
|
352
|
+
dataset_id: `servidores; situacao=${situacao}`,
|
|
353
|
+
retrieved_at: prov.retrieved_at,
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
fields: ["subordinadasA", "total", "naoClassificados"],
|
|
357
|
+
source_url: provenanceEstrutura().source_url,
|
|
358
|
+
dataset_id: "estrutura-organizacional",
|
|
359
|
+
reference_period: ESTRUTURA_VINTAGE.slice(0, 10),
|
|
360
|
+
retrieved_at: ESTRUTURA_VINTAGE,
|
|
361
|
+
},
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
334
364
|
export function registerServidoresTools(server, admBaseUrl) {
|
|
335
365
|
// P1. senado_servidores
|
|
336
366
|
server.tool("senado_servidores", "Lista servidores do Senado por `situacao` (ativos, efetivos, comissionados ou inativos), com filtros opcionais por `nome`, `lotacao` e `cargo`. Retorna `{ situacao, count, total, servidores[] }`, cada item com `nome`, `vinculo`, `situacao`, `cargo`, `funcao`, `lotacao`, `anoAdmissao` etc. Aplica `limite` (padrão 50, máx 500) e inclui `aviso` quando há truncamento — refine os filtros. Também conta e lista TODOS os servidores de TODA a estrutura subordinada a uma diretoria/secretaria (não só a lotação direta), cruzando a lotação de cada servidor com o organograma até o nível de serviço: informe a unidade (sigla ou nome, ex.: 'DGER') em `subordinadasA` — use isso para 'quantas pessoas estão sob a Diretoria-Geral', pois filtrar `lotacao` pela sigla-mãe retorna 0 (os servidores ficam em serviços/núcleos subordinados). Nesse modo o retorno traz a unidade resolvida, o total (um piso), os servidores, as unidades não reconhecidas no organograma (`naoClassificados`, com nota explicativa) e os afastados/em trânsito (`afastadosOuEmTransito`); ao responder, verbalize esses conceitos em português corrente — não cite nomes de parâmetros ou campos. Para o organograma em si use `senado_estrutura_organizacional`; para remuneração use `senado_remuneracoes_servidores`.", {
|
|
@@ -368,15 +398,7 @@ export function registerServidoresTools(server, admBaseUrl) {
|
|
|
368
398
|
return toolError(`Unidade '${params.subordinadasA}' não encontrada na estrutura organizacional.${dica}`, "nao_encontrado");
|
|
369
399
|
}
|
|
370
400
|
const { sob, naoClassificados, afastadosOuEmTransito } = particionarPorUnidade(lista, indice, alvo.cod);
|
|
371
|
-
|
|
372
|
-
const provComEstrutura = withFieldSources(prov, [
|
|
373
|
-
{
|
|
374
|
-
fields: ["subordinadasA", "total", "naoClassificados"],
|
|
375
|
-
source_url: provenanceEstrutura().source_url,
|
|
376
|
-
dataset_id: "estrutura-organizacional",
|
|
377
|
-
retrieved_at: ESTRUTURA_VINTAGE,
|
|
378
|
-
},
|
|
379
|
-
]);
|
|
401
|
+
const provComEstrutura = provenanceServidoresSubordinadas(prov, situacao);
|
|
380
402
|
return resultWithProvenance({
|
|
381
403
|
situacao,
|
|
382
404
|
subordinadasA: { sigla: alvo.sigla, nome: alvo.nome },
|
package/dist/utils/provenance.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Vetor A — Proveniência no payload, contrato do portfólio (pacote
|
|
3
|
-
* `@sbissoli/mcp-provenance
|
|
3
|
+
* `@sbissoli/mcp-provenance`; a versão EMITIDA é `provenanceContext.contractVersion`).
|
|
4
4
|
*
|
|
5
5
|
* pt-BR adapter over `@sbissoli/mcp-provenance`: o modelo canônico, os modos
|
|
6
6
|
* (`concise`/`detailed`), o determinismo de serialização, o fuso e o rodapé vivem no
|
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
* - v1.0 (release 3.5.0): `structuredContent.provenance` e o espelho em `_meta`
|
|
15
15
|
* passam a ser a projeção **concise** do contrato, com `null` explícito quando
|
|
16
16
|
* desconhecido (antes: campos opcionais omitidos).
|
|
17
|
-
* - v1.1 (release 3.11.0
|
|
18
|
-
* pacote): o concise tem exatamente 7 chaves — `source`, `source_url`,
|
|
17
|
+
* - v1.1 (release 3.11.0): o concise tem 7 chaves fixas — `source`, `source_url`,
|
|
19
18
|
* `data_vintage` (ex-`reference_period`), `retrieved_at`, `citation`, `license` e
|
|
20
19
|
* `retrieval` (diagnóstico medido da ida à origem, ou `null`).
|
|
21
|
-
* - v1.2 (
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* - v1.2 (EMITIDA desde a release 3.17.0): acrescenta ao concise a chave opcional
|
|
21
|
+
* `field_sources` (sub-fontes por campo), só nas respostas que fundem recortes. Com
|
|
22
|
+
* ela a lib COBRA que o `retrieved_at` do topo seja o mais antigo entre as sub-fontes
|
|
23
|
+
* (lança `ProvenanceContractError`, o que derrubaria a tool) — por isso o adaptador
|
|
24
|
+
* o deriva por construção (`oldestRetrievedAt`), nunca a tool à mão.
|
|
25
|
+
* - v1.3 (declarada pelo `outputSchema` desde a 3.17.0, ainda NÃO emitida): `notices`,
|
|
26
|
+
* `derived`/`derivation_note` e `revision`. A `revision` já é informada aqui, por
|
|
27
|
+
* fonte (`SOURCES`), e a lib a descarta enquanto o servidor emitir 1.2.
|
|
25
28
|
* - O rodapé de texto segue a redação fixada do contrato: linha de fonte
|
|
26
29
|
* ("Fonte: … · url · dados de … · extraído em …"), linha de licença e o aviso ao
|
|
27
30
|
* leitor de que a referência completa pode ser solicitada na própria conversa.
|
|
@@ -54,6 +57,7 @@ export const provenanceContext = createProvenanceContext({
|
|
|
54
57
|
locale: "pt-BR",
|
|
55
58
|
timezone: { offset: "-03:00", label: "horário de Brasília" },
|
|
56
59
|
defaultMode: "concise",
|
|
60
|
+
contractVersion: "1.2",
|
|
57
61
|
});
|
|
58
62
|
const BRASILIA = provenanceContext.timezone;
|
|
59
63
|
/**
|
|
@@ -75,16 +79,57 @@ const toCanonicalFieldSource = (fs) => ({
|
|
|
75
79
|
data_vintage: fs.reference_period ?? null,
|
|
76
80
|
retrieved_at: fs.retrieved_at ?? null,
|
|
77
81
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
/**
|
|
83
|
+
* `retrieved_at` do topo de uma resposta que funde sub-fontes: o MAIS ANTIGO entre o
|
|
84
|
+
* topo informado e as sub-fontes (contrato §3, "nada aqui é mais velho que isto"). A
|
|
85
|
+
* partir da v1.2 a lib lança `ProvenanceContractError` se o topo for mais novo que
|
|
86
|
+
* alguma sub-fonte — e o erro derruba a tool. Derivar aqui, no único ponto por onde
|
|
87
|
+
* passam todas as `field_sources`, torna a regra verdadeira por construção: nenhuma
|
|
88
|
+
* tool escolhe o instante do topo à mão. Sub-fonte sem instante (`undefined`/`null`)
|
|
89
|
+
* não conta; sem topo informado, o mais antigo das sub-fontes substitui o "agora" que
|
|
90
|
+
* o builder usaria (que seria mais novo que elas).
|
|
91
|
+
*/
|
|
92
|
+
export function oldestRetrievedAt(top, fieldSources) {
|
|
93
|
+
let oldest = top;
|
|
94
|
+
let oldestMs = top !== undefined ? Date.parse(top) : Number.NaN;
|
|
95
|
+
for (const fs of fieldSources ?? []) {
|
|
96
|
+
if (fs.retrieved_at == null)
|
|
97
|
+
continue;
|
|
98
|
+
const ms = Date.parse(fs.retrieved_at);
|
|
99
|
+
if (Number.isNaN(ms))
|
|
100
|
+
continue;
|
|
101
|
+
if (oldest === undefined || Number.isNaN(oldestMs) || ms < oldestMs) {
|
|
102
|
+
oldest = fs.retrieved_at;
|
|
103
|
+
oldestMs = ms;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return oldest;
|
|
107
|
+
}
|
|
108
|
+
const extraToCanonical = (extra = {}) => {
|
|
109
|
+
const retrieved_at = oldestRetrievedAt(extra.retrieved_at, extra.field_sources);
|
|
110
|
+
return {
|
|
111
|
+
...(extra.dataset_id !== undefined ? { dataset: extra.dataset_id } : {}),
|
|
112
|
+
...(extra.reference_period !== undefined ? { data_vintage: extra.reference_period } : {}),
|
|
113
|
+
...(retrieved_at !== undefined ? { retrieved_at } : {}),
|
|
114
|
+
...(extra.api_version !== undefined ? { api_version: extra.api_version } : {}),
|
|
115
|
+
...(extra.field_sources !== undefined
|
|
116
|
+
? { field_sources: extra.field_sources.map(toCanonicalFieldSource) }
|
|
117
|
+
: {}),
|
|
118
|
+
retrieval: extra.retrieval !== undefined ? extra.retrieval : currentRetrieval(),
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Situação de revisão por fonte (contrato v1.3, `revision`; decisão do dono de 08/10/2026:
|
|
123
|
+
* `current` em todas as fontes do senado, `final` em nenhuma — só com prova da fonte).
|
|
124
|
+
* A `note` só repete o que o servidor JÁ diz nas instructions (`src/app-surface.ts`) sobre
|
|
125
|
+
* os dados administrativos; nas demais fontes não há texto publicado, então `null`.
|
|
126
|
+
* Enquanto o servidor emitir a v1.2 a lib descarta o campo do fio — fica no canônico.
|
|
127
|
+
*/
|
|
128
|
+
const REVISAO_VIGENTE = { status: "current", note: null };
|
|
129
|
+
const REVISAO_ADMINISTRATIVA = {
|
|
130
|
+
status: "current",
|
|
131
|
+
note: "Dados administrativos são os que o Senado publica no instante da extração e podem ser corrigidos depois.",
|
|
132
|
+
};
|
|
88
133
|
/** Metadados estáticos por fonte upstream. source_url é montado por chamada (endpoint real). */
|
|
89
134
|
export const SOURCES = {
|
|
90
135
|
/** API legislativa — legis.senado.leg.br/dadosabertos (votações, processos, matérias…). */
|
|
@@ -92,36 +137,42 @@ export const SOURCES = {
|
|
|
92
137
|
source: "Senado Federal — Dados Abertos (Legislativo)",
|
|
93
138
|
citation: "Fonte: Senado Federal, Portal de Dados Abertos (Legislativo) — legis.senado.leg.br/dadosabertos.",
|
|
94
139
|
license: "Dados Abertos do Senado Federal — uso livre com atribuição da fonte.",
|
|
140
|
+
revision: REVISAO_VIGENTE,
|
|
95
141
|
},
|
|
96
142
|
/** API administrativa — adm.senado.gov.br/adm-dadosabertos (CEAPS, folha, contratos…). */
|
|
97
143
|
SENADO_ADM: {
|
|
98
144
|
source: "Senado Federal — Dados Abertos (Administrativo)",
|
|
99
145
|
citation: "Fonte: Senado Federal, Portal de Dados Abertos (Administrativo) — adm.senado.gov.br/adm-dadosabertos.",
|
|
100
146
|
license: "Dados Abertos do Senado Federal — uso livre com atribuição da fonte.",
|
|
147
|
+
revision: REVISAO_ADMINISTRATIVA,
|
|
101
148
|
},
|
|
102
149
|
/** Portal institucional — estrutura organizacional (www12.senado.leg.br/institucional/estrutura). */
|
|
103
150
|
SENADO_INSTITUCIONAL: {
|
|
104
151
|
source: "Senado Federal — Portal Institucional (Estrutura Organizacional)",
|
|
105
152
|
citation: "Fonte: Senado Federal, Portal Institucional — Estrutura Organizacional (www12.senado.leg.br/institucional/estrutura).",
|
|
106
153
|
license: "Dados Abertos do Senado Federal — uso livre com atribuição da fonte.",
|
|
154
|
+
revision: REVISAO_VIGENTE,
|
|
107
155
|
},
|
|
108
156
|
/** Portal e-Cidadania — www12.senado.leg.br/ecidadania. */
|
|
109
157
|
ECIDADANIA: {
|
|
110
158
|
source: "Senado Federal — Portal e-Cidadania",
|
|
111
159
|
citation: "Fonte: Senado Federal, Portal e-Cidadania — www12.senado.leg.br/ecidadania.",
|
|
112
160
|
license: "Dados Abertos do Senado Federal — uso livre com atribuição da fonte.",
|
|
161
|
+
revision: REVISAO_VIGENTE,
|
|
113
162
|
},
|
|
114
163
|
/** Feed de execução orçamentária/financeira — www.senado.gov.br/bi-arqs/Arquimedes/Financeiro. */
|
|
115
164
|
SENADO_ORCAMENTO_EXEC: {
|
|
116
165
|
source: "Senado Federal — Execução Orçamentária e Financeira",
|
|
117
166
|
citation: "Fonte: Senado Federal — Dados Abertos Orçamentários (Arquimedes/Financeiro) — senado.gov.br.",
|
|
118
167
|
license: "Dados Abertos do Senado Federal — uso livre com atribuição da fonte.",
|
|
168
|
+
revision: REVISAO_ADMINISTRATIVA,
|
|
119
169
|
},
|
|
120
170
|
/** Acervo histórico de votos das consultas e-Cidadania — CSV Arquimedes (bi-arqs/.../ecidadania). */
|
|
121
171
|
ECIDADANIA_ARQUIMEDES: {
|
|
122
172
|
source: "Senado Federal — e-Cidadania (acervo histórico de votos, Arquimedes)",
|
|
123
173
|
citation: "Fonte: Senado Federal — e-Cidadania, acervo de votos por matéria/UF (Arquimedes/DadosAbertos) — senado.gov.br.",
|
|
124
174
|
license: "Dados Abertos do Senado Federal — uso livre com atribuição da fonte.",
|
|
175
|
+
revision: REVISAO_VIGENTE,
|
|
125
176
|
},
|
|
126
177
|
};
|
|
127
178
|
/** URL canônica do CSV Arquimedes de votos das consultas e-Cidadania (source_url da proveniência). */
|
|
@@ -133,12 +184,13 @@ export const ECIDADANIA_ARQUIMEDES_CSV_URL = "https://www.senado.gov.br/bi-arqs/
|
|
|
133
184
|
* camada de cache.
|
|
134
185
|
*/
|
|
135
186
|
export function buildProvenance(input) {
|
|
136
|
-
const { source, source_url, citation, license, ...extra } = input;
|
|
187
|
+
const { source, source_url, citation, license, revision, ...extra } = input;
|
|
137
188
|
return provenanceContext.build({
|
|
138
189
|
source,
|
|
139
190
|
source_url,
|
|
140
191
|
citation,
|
|
141
192
|
license,
|
|
193
|
+
...(revision !== undefined ? { revision } : {}),
|
|
142
194
|
...extraToCanonical(extra),
|
|
143
195
|
});
|
|
144
196
|
}
|
|
@@ -189,12 +241,15 @@ export function provenanceArquimedesVotos(extra) {
|
|
|
189
241
|
export function withFieldSources(prov, fieldSources) {
|
|
190
242
|
if (fieldSources.length === 0)
|
|
191
243
|
return prov;
|
|
244
|
+
// O topo passa a ser o mais antigo entre ele e as sub-fontes (ver `oldestRetrievedAt`).
|
|
245
|
+
const retrieved_at = oldestRetrievedAt(prov.retrieved_at, fieldSources) ?? prov.retrieved_at;
|
|
192
246
|
return provenanceContext.build({
|
|
193
247
|
...prov,
|
|
248
|
+
retrieved_at,
|
|
194
249
|
field_sources: fieldSources.map(toCanonicalFieldSource),
|
|
195
250
|
});
|
|
196
251
|
}
|
|
197
|
-
/** Rodapé de texto
|
|
252
|
+
/** Rodapé de texto do contrato: fonte · url · vintage · extração, licença e aviso ao leitor. */
|
|
198
253
|
export const provenanceFooter = (p) => provenanceContext.footer(p);
|
|
199
254
|
/**
|
|
200
255
|
* Chaves namespaced do espelho em `_meta` (nível de resultado). O prefixo reverse-DNS
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "senado-br-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"description": "MCP server for Brazilian Senate open data — runs hosted on Cloudflare Workers or locally via npx (stdio)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -51,10 +51,10 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
54
|
-
"@sbissoli/mcp-provenance": "^0.
|
|
54
|
+
"@sbissoli/mcp-provenance": "^0.4.0",
|
|
55
55
|
"@sbissoli/mcp-search": "^0.9.0",
|
|
56
56
|
"@sbissoli/mcp-stats": "^0.3.0",
|
|
57
|
-
"@sbissoli/mcp-upstream": "^0.4.
|
|
57
|
+
"@sbissoli/mcp-upstream": "^0.4.2",
|
|
58
58
|
"zod": "^4.5.4"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|