diff --git a/docs/lod-wiki/senato/sedute-commissione.md b/docs/lod-wiki/senato/sedute-commissione.md index c52bc4f..7387221 100644 --- a/docs/lod-wiki/senato/sedute-commissione.md +++ b/docs/lod-wiki/senato/sedute-commissione.md @@ -67,6 +67,8 @@ FILTER( ?date >= "2026-05-01"^^xsd:date && ?date <= "2026-05-31"^^xsd:date ) | **Doppia etichetta della commissione** | Una `Commissione` può avere **più `osr:titoloBreve`** (es. `commissione/0-2` → "Giustizia" **e** "Giustizia e autorizzazioni a procedere"). Non mettere `?comm`/`?tb` nel `SELECT` senza aggregazione, altrimenti ogni seduta si duplica. Usare `MIN(?tb)` + `GROUP BY` senza `?comm`. | | **`SAMPLE` non supportato** | Virtuoso Senato rifiuta `SAMPLE` (400). `MIN`/`MAX`/`COUNT` funzionano. | | **Niente `BIND`** | Come da [[trappole]] generali del Senato. | +| **`osr:commissione` può puntare a URI mai descritti** | Le sedute referenziano organi `commissione/2-*` che **non esistono come risorse**: zero triple come soggetto, nessuna `osr:Commissione` con quel prefisso (0 su 279). Non hanno titolo né categoria, e il nome non è ricavabile da nessuna parte del grafo — nemmeno dalle sedute, che espongono solo data/tipo/legislatura. Nella sola leg. 19 sono **16 organi**, incluso `commissione/2-1` con **647 sedute**, il secondo più attivo. Conseguenza pratica: se `osr:titoloBreve` è un pattern obbligatorio nella query, questi organi **spariscono in silenzio** e si perde metà dell'attività di commissione. Tenerlo `OPTIONAL` e accettare righe senza nome. (Verificato 2026-07-26.) | +| **Filtrare per legislatura via sedute esclude le Giunte** | Gli organi Senato non hanno una proprietà "legislatura": il filtro naturale passa da `?seduta osr:legislatura N`. Ma le **Giunte non hanno alcuna `SedutaCommissione`** (vedi *Assenti*), quindi con quel criterio scompaiono pur essendo attive. Alternativa: `osr:denominazione` è un nodo con `osr:inizio`/`osr:fine` (stesso pattern temporale dei gruppi), e l'intervallo della legislatura si ricava dal grafo stesso con `MIN`/`MAX` di `osr:dataSeduta` sulle sedute di quella legislatura. I due criteri vanno **uniti**: nessuno dei due basta. (Verificato 2026-07-26.) | # Ricerca commissione per nome @@ -183,6 +185,8 @@ Un tool "audizioni Senato" è **fattibile via scraping HTML** (non SPARQL, non J # Assenti +* **Sedute delle Giunte**: nessuna delle 4 Giunte del Senato (`commissione/0-20` Regolamento, `0-21` Elezioni e immunità parlamentari, `0-22` Biblioteca e archivio storico, `0-24` Verifica dei poteri) ha `osr:SedutaCommissione` collegate, in nessuna legislatura. L'anagrafica c'è ed è completa (`0-21`: 11 triple, `categoriaCommissione` = `Giunte`, sottotitolo "(Art. 19 del Regolamento)", tre `osr:denominazione` datate dal 1948), ma dell'attività non c'è traccia. È un'assenza rilevante: la Giunta delle elezioni e delle immunità è la sede delle autorizzazioni a procedere, cioè esattamente ciò che si cerca quando una vicenda giudiziaria tocca un parlamentare. (Verificato 2026-07-26.) + * **Commissioni bicamerali con sedute/interventi esposti**: la *Commissione parlamentare di inchiesta sul femminicidio* esiste come entità Senato (`commissione/0-141` storica leg. XVII–XVIII e `commissione/4-223` attuale XIX) con i suoi membri senatori (**24 afferenze** nella XIX), ma **non ha `SedutaCommissione`/`Intervento` collegati** nel LOD Senato (verificato 2026-07-01: 0 righe su entrambi gli URI per leg. 19). Le sedute/interventi sono pubblicati solo dalla Camera (`ocd:organo` `o19_3941`, 181 URI seduta / 157 date distinte fino a giu 2026). Vedi [[../camera/sedute-commissione]]. La composizione Senatori si ottiene con `committee-members list --committee-uri --chamber senato`. # Citations @@ -192,3 +196,5 @@ Un tool "audizioni Senato" è **fattibile via scraping HTML** (non SPARQL, non J [3] Verifica assenza attività femminicidio Senato (2026-07-01): `committee-sessions`/SPARQL su `commissione/0-141` e `4-223` → 0 sedute, 0 interventi. [4] Risposta Webmaster Senato (email 2026-07-06): conferma che lo SPARQL non espone resoconto/OdG per `SedutaCommissione`; indica le liste JSON `listasommcomm` come fonte con link ai resoconti. [5] Schema JSON verificato via agent-browser (2026-07-06): `https://www.senato.it/static/bgt/listasommcomm/0/1/t/19/2026/index.json`; `curl` → HTTP 202 vuoto (WAF). Pattern link resoconto ricavato dai `href` della pagina resoconti: `show-doc?leg=19&tipodoc=SommComm&id=&idoggetto=0`. +[6] URI di commissione orfani (2026-07-26): `SELECT (COUNT(*) AS ?n) WHERE { ?p ?o }` → `0`; `SELECT (COUNT(DISTINCT ?c) AS ?n) WHERE { ?c a osr:Commissione . FILTER(STRSTARTS(STR(?c), "http://dati.senato.it/commissione/2-")) }` → `0`, contro 279 `osr:Commissione` totali. Organi con sedute in leg. 19: 27, di cui 16 senza `osr:titoloBreve` (tutti `2-*`). +[7] Assenza sedute delle Giunte (2026-07-26): enumerazione di tutte le commissioni con `SedutaCommissione` in leg. 19 → nessuna delle 4 Giunte. Intervallo leg. 19 da `MIN`/`MAX` di `osr:dataSeduta`: `2022-10-25` → `2026-07-23`; commissioni con `osr:denominazione` valida in quel periodo: 59. diff --git a/src/tools/committees.test.ts b/src/tools/committees.test.ts new file mode 100644 index 0000000..5361c56 --- /dev/null +++ b/src/tools/committees.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { mergeSenatoCommittees } from "./committees.js"; + +const row = (o: Partial> & { uri: string }) => ({ + chamber: "senato", + uri: o.uri, + title: o.title ?? "", + short_title: o.short_title ?? "", + subtitle: o.subtitle ?? "", + category: o.category ?? "", + session_count: o.session_count ?? "", +}); + +// I due insiemi si intersecano solo in parte (#82): le Giunte hanno anagrafica +// ma non sedute, gli organi "2-*" hanno sedute ma non esistono come risorse. +describe("mergeSenatoCommittees", () => { + it("tiene gli organi con sedute e ne completa l'anagrafica", () => { + const out = mergeSenatoCommittees( + [row({ uri: "c/0-1", short_title: "Affari Costituzionali", session_count: "742" })], + [row({ uri: "c/0-1", title: "1a Commissione", short_title: "Affari Costituzionali", category: "Commissioni permanenti" })], + ); + expect(out).toHaveLength(1); + expect(out[0].session_count).toBe("742"); + expect(out[0].title).toBe("1a Commissione"); + expect(out[0].category).toBe("Commissioni permanenti"); + }); + + it("aggiunge gli organi descritti ma senza sedute, con conteggio 0 (le Giunte)", () => { + const out = mergeSenatoCommittees( + [row({ uri: "c/0-1", short_title: "Affari Costituzionali", session_count: "742" })], + [row({ uri: "c/0-21", short_title: "Giunta delle elezioni", category: "Giunte" })], + ); + expect(out.map((r) => r.uri)).toEqual(["c/0-1", "c/0-21"]); + expect(out[1].session_count).toBe("0"); + }); + + it("tiene gli organi con sedute ma privi di anagrafica, senza inventarne il nome", () => { + const out = mergeSenatoCommittees([row({ uri: "c/2-1", session_count: "647" })], []); + expect(out).toHaveLength(1); + expect(out[0].short_title).toBe(""); + expect(out[0].title).toBe(""); + expect(out[0].session_count).toBe("647"); + }); + + it("ordina per numero di sedute decrescente, poi per titolo", () => { + const out = mergeSenatoCommittees( + [ + row({ uri: "c/0-5", short_title: "Bilancio", session_count: "592" }), + row({ uri: "c/2-1", session_count: "647" }), + ], + [ + row({ uri: "c/0-22", short_title: "Biblioteca", category: "Giunte" }), + row({ uri: "c/0-21", short_title: "Giunta delle elezioni", category: "Giunte" }), + ], + ); + expect(out.map((r) => r.uri)).toEqual(["c/2-1", "c/0-5", "c/0-22", "c/0-21"]); + }); + + it("non duplica un organo presente in entrambi gli insiemi", () => { + const out = mergeSenatoCommittees( + [row({ uri: "c/0-1", session_count: "742" })], + [row({ uri: "c/0-1", short_title: "Affari Costituzionali" })], + ); + expect(out).toHaveLength(1); + expect(out[0].short_title).toBe("Affari Costituzionali"); + }); +}); diff --git a/src/tools/committees.ts b/src/tools/committees.ts index fa2a29f..a4cf5d9 100644 --- a/src/tools/committees.ts +++ b/src/tools/committees.ts @@ -18,7 +18,7 @@ const inputSchema = z.object({ .positive() .optional() .describe( - "Numero legislatura. Camera: le commissioni sono istanze per-legislatura, default 19. Senato: opzionale, senza filtro mostra tutte le commissioni storiche (nessun session_count); con filtro mostra solo quelle attive con numero di sedute.", + "Numero legislatura. Camera: le commissioni sono istanze per-legislatura, default 19. Senato: opzionale, senza filtro mostra tutte le commissioni storiche (nessun session_count); con filtro mostra gli organi attivi in quella legislatura, con il numero di sedute (0 per quelli che nel LOD non ne hanno, come le Giunte).", ), limit: z.number().int().min(1).max(1000).default(300), }); @@ -73,25 +73,122 @@ LIMIT ${limit}`; // --- Senato --------------------------------------------------------------- +// Mappatura comune alle query Senato: i nomi di variabile coincidono, cambia +// solo quali sono valorizzate (le sedute danno `n_sedute`, l'anagrafica i +// titoli). I campi assenti restano stringa vuota. +function toRows(raw: Record[]): Row[] { + return raw.map((r) => ({ + chamber: "senato", + uri: r.comm ?? "", + title: r.titolo ?? "", + short_title: r.titoloBreve ?? "", + subtitle: r.sottotitolo ?? "", + category: r.cat ?? r.categoria ?? "", + session_count: r.n_sedute ?? "", + })); +} + +// Intervallo di date coperto da una legislatura, ricavato dalle sedute di +// commissione: il grafo Senato non espone inizio/fine legislatura, ma le date +// delle sedute la delimitano con precisione sufficiente a filtrare gli organi. +async function senatoLegislatureRange( + legislature: number, +): Promise<{ from: string; to: string } | null> { + const query = `${OSR_PREFIXES} +SELECT (MIN(?d) AS ?dal) (MAX(?d) AS ?al) +WHERE { + ?s a osr:SedutaCommissione . + ?s osr:legislatura ${legislature} . + ?s osr:dataSeduta ?d +}`; + const raw = flattenBindings(await snQuery(query)); + const from = raw[0]?.dal; + const to = raw[0]?.al; + return from && to ? { from, to } : null; +} + +/** + * Unisce gli organi con sedute nella legislatura e quelli descritti come + * attivi nel suo periodo. Sono due insiemi che si intersecano solo in parte, + * e nessuno dei due basta da solo (#82): le Giunte non hanno sedute nel LOD, + * e 16 organi con sedute — incluso il secondo più attivo della leg. 19 — non + * esistono come risorse, quindi non hanno né titolo né categoria. + * I campi vuoti restano vuoti: il nome mancante è un buco della fonte, non + * qualcosa da riempire a valle. + */ +export function mergeSenatoCommittees(withSessions: Row[], described: Row[]): Row[] { + const byUri = new Map(); + for (const r of withSessions) byUri.set(r.uri, { ...r }); + for (const d of described) { + const seen = byUri.get(d.uri); + if (!seen) { + byUri.set(d.uri, { ...d, session_count: "0" }); + continue; + } + // L'anagrafica della risorsa è più ricca del solo titoloBreve aggregato + // sulle sedute: completa i campi mancanti senza toccare il conteggio. + seen.title ||= d.title; + seen.short_title ||= d.short_title; + seen.subtitle ||= d.subtitle; + seen.category ||= d.category; + } + return [...byUri.values()].sort( + (a, b) => + Number(b.session_count || 0) - Number(a.session_count || 0) || + a.short_title.localeCompare(b.short_title), + ); +} + async function querySenato( legislature: number | undefined, limit: number, ): Promise { let query: string; if (legislature) { - // Filtrate per legislatura via SedutaCommissione, con conteggio sedute. + // Le due query lavorano su insiemi piccoli e noti (nella leg. 19: 27 organi + // con sedute, 59 descritti attivi), quindi il `limit` dell'utente si applica + // DOPO il merge: troncare prima toglierebbe righe che l'ordinamento finale + // avrebbe messo in testa. Il tetto interno esiste solo come guardia. + const cap = 1000; + // Organi con sedute nella legislatura. `titoloBreve` è OPTIONAL: quando + // manca l'organo va mostrato lo stesso, altrimenti sparisce in silenzio + // proprio chi ha più sedute (#82). query = `${OSR_PREFIXES} SELECT ?comm (MIN(?titoloBreve) AS ?titoloBreve) (MIN(?cat) AS ?categoria) (COUNT(DISTINCT ?seduta) AS ?n_sedute) WHERE { ?seduta a osr:SedutaCommissione . ?seduta osr:commissione ?comm . ?seduta osr:legislatura ${legislature} . - ?comm osr:titoloBreve ?titoloBreve . + OPTIONAL { ?comm osr:titoloBreve ?titoloBreve } OPTIONAL { ?comm osr:categoriaCommissione ?cat } } GROUP BY ?comm ORDER BY DESC(?n_sedute) -LIMIT ${limit}`; +LIMIT ${cap}`; + const withSessions = toRows(flattenBindings(await snQuery(query))); + const range = await senatoLegislatureRange(legislature); + if (!range) return withSessions; + // Organi la cui denominazione è valida nel periodo della legislatura: + // stesso pattern temporale dei gruppi (osr:denominazione con inizio/fine), + // ed è l'unico modo per vedere le Giunte, che sedute nel LOD non ne hanno. + const activeQuery = `${OSR_PREFIXES} +SELECT DISTINCT ?comm ?titolo ?titoloBreve ?sottotitolo ?cat +WHERE { + ?comm a osr:Commissione . + ?comm osr:denominazione ?den . + ?den osr:inizio ?ini . + OPTIONAL { ?den osr:fine ?fine } + OPTIONAL { ?comm osr:titolo ?titolo } + OPTIONAL { ?comm osr:titoloBreve ?titoloBreve } + OPTIONAL { ?comm osr:sottotitolo ?sottotitolo } + OPTIONAL { ?comm osr:categoriaCommissione ?cat } + FILTER(str(?ini) <= "${range.to}") + FILTER(!BOUND(?fine) || str(?fine) >= "${range.from}") +} +ORDER BY ?comm +LIMIT ${cap}`; + const described = toRows(flattenBindings(await snQuery(activeQuery))); + return mergeSenatoCommittees(withSessions, described).slice(0, limit); } else { // Tutte le commissioni storiche, nessun conteggio sedute. query = `${OSR_PREFIXES} @@ -106,17 +203,7 @@ WHERE { ORDER BY ?titoloBreve LIMIT ${limit}`; } - const results = await snQuery(query); - const raw = flattenBindings(results); - return raw.map((r) => ({ - chamber: "senato", - uri: r.comm ?? "", - title: r.titolo ?? "", - short_title: r.titoloBreve ?? "", - subtitle: r.sottotitolo ?? "", - category: r.cat ?? r.categoria ?? "", - session_count: r.n_sedute ?? "", - })); + return toRows(flattenBindings(await snQuery(query))); } export const committeesTool: Tool = { @@ -124,7 +211,8 @@ export const committeesTool: Tool = { description: "[CAMERA+SENATO] Commissioni parlamentari (permanenti, speciali, d'inchiesta monocamerali e bicamerali, giunte, comitati). " + "Camera: filtrata per legislatura (default 19; le commissioni sono istanze per-legislatura), con dc:type come categoria. " + - "Senato: senza --legislature mostra tutte le commissioni storiche (nessun session_count); con --legislature filtra le attive e aggiunge il numero di sedute.", + "Senato: senza --legislature mostra tutte le commissioni storiche (nessun session_count); con --legislature unisce due insiemi — gli organi con sedute in quella legislatura (con il conteggio) e quelli la cui denominazione risulta valida nel periodo (session_count 0). Serve entrambi perché le Giunte non hanno sedute nel LOD e comparirebbero solo nel secondo. " + + "ATTENZIONE (limite della fonte, non del tool): alcuni organi del Senato con molte sedute — nella leg. 19 sedici, incluso il secondo più attivo — sono referenziati con URI 'commissione/2-*' che nel grafo non esistono come risorse: niente titolo, niente categoria. Le loro righe hanno i campi di testo VUOTI e solo uri + session_count. Non dedurre il nome dell'organo dall'URI o dal numero di sedute: non è ricavabile dal dato.", inputSchema, examples: [ "italianparliament committees list --chamber camera --legislature 19", diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index d9ff231..2e1f8da 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -633,6 +633,43 @@ describe("Senato tools", () => { expect(Number(affari!.session_count)).toBeGreaterThan(100); }, 30000); + it("committees (Senato): le Giunte compaiono anche se nel LOD non hanno sedute (#82)", async () => { + const result = await committeesTool.execute({ chamber: "senato", legislature: 19, limit: 300 }); + const giunte = result.rows.filter((r) => r.category === "Giunte"); + expect(giunte.length).toBeGreaterThanOrEqual(2); + // La Giunta elezioni e immunità è l'organo delle autorizzazioni a procedere: + // prima del fix era invisibile con --legislature, perché il filtro passava + // dalle sedute e per le Giunte il LOD non ne espone nessuna. + const immunita = giunte.find((r) => r.short_title.includes("Giunta delle elezioni")); + expect(immunita).toBeDefined(); + expect(immunita!.session_count).toBe("0"); + }, 30000); + + it("committees (Senato): gli organi privi di anagrafica nella fonte restano visibili, senza nome inventato (#82)", async () => { + const result = await committeesTool.execute({ chamber: "senato", legislature: 19, limit: 300 }); + // URI referenziati dalle sedute ma inesistenti come risorse (commissione/2-*): + // devono comparire con le sedute e i campi di testo vuoti. + const orfani = result.rows.filter((r) => r.short_title === "" && Number(r.session_count) > 0); + expect(orfani.length).toBeGreaterThan(0); + expect(Math.max(...orfani.map((r) => Number(r.session_count)))).toBeGreaterThan(100); + for (const r of orfani) { + expect(r.title).toBe(""); + expect(r.category).toBe(""); + expect(r.uri).not.toBe(""); + } + }, 30000); + + it("committees (Senato): --limit tronca dopo il merge, non dentro le query (#82)", async () => { + // Il limite deve tagliare la classifica finale — i più attivi in testa — e + // non le due query intermedie, dove taglierebbe righe che l'ordinamento + // avrebbe promosso. + const result = await committeesTool.execute({ chamber: "senato", legislature: 19, limit: 5 }); + expect(result.rows.length).toBe(5); + const counts = result.rows.map((r) => Number(r.session_count)); + expect(counts).toEqual([...counts].sort((a, b) => b - a)); + expect(counts[0]).toBeGreaterThan(500); + }, 30000); + it("committees: returns Camera committees for leg 19", async () => { const result = await committeesTool.execute({ chamber: "camera", legislature: 19, limit: 300 }); expect(result.rows.length).toBeGreaterThan(10);