Skip to content

Commit 3d2ff2e

Browse files
aborrusoclaude
andauthored
fix(senato-votes): elimina i 403 da query troppo lunga (chunking + query compatta) (#76)
* fix(senato-votes): spezza le OR-chain in query da 25 termini (#72) L'endpoint Senato respinge con 403 le richieste con query string oltre ~2 KB, e il POST è sempre rifiutato: l'unica strada è tenere corta la GET. Con 53 fiducie in leg. 19 la OR-chain su osr:fase superava la soglia e --keyword falliva sistematicamente. Due i punti interessati: il supplemento fiducie e il Fallback 1. Il merge resta first-wins, quindi l'esito non dipende dai confini dei blocchi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(senato-votes): compatta la query principale sotto i 2047 byte (#72) Il chunking non bastava: la query principale sfora da sola con keyword multi-parola (2059 byte con "corte dei conti", limite 2047), e lì non c'è nessuna lista da spezzare. Indentazione rimossa e header prefissi ridotto ai due usati: 2081 -> 1926 byte di URL. La keyword entra tre volte nella query, quindi il margine non è infinito: assertQueryFits blocca in partenza le query troppo lunghe con un messaggio che dice cosa fare, invece del 403 opaco. Soglia misurata al byte per bisezione (2047 passa, 2048 no), con query di controllo a 200 prima e dopo: non è il blocco per frequenza. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(skill-cli): distingui i due 403 del Senato (dimensione vs frequenza) Stesso codice e stessa pagina di errore per due cause opposte: nel primo caso ritentare è inutile (va accorciata la query), nel secondo è dannoso. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(review): riferimento corretto alla soglia e test fedele al client - il commento sulla query compatta rimandava a SENATO_MAX_OR_TERMS, che riguarda il chunking: il vincolo qui è SENATO_MAX_REQUEST_URI - l'asserzione sulla dimensione usava encodeURIComponent su un frammento; ora sta accanto agli helper, misura con senatoRequestUriLength (stesso encoding del client) su una query intera con i suoi prefissi Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: rimanda a SENATO_MAX_REQUEST_URI invece del valore approssimato Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1b9d560 commit 3d2ff2e

6 files changed

Lines changed: 204 additions & 40 deletions

File tree

skills/italian-parliament-cli/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Regole d'oro:
115115
- **Prova più sinonimi e radici di parola** prima di concludere. Preferisci la **radice** breve che copre più forme: `elett` → elettorale/elettori, `elez` → elezione/elezioni (occhio: `elezioni` al plurale non matcha un titolo con `elezione` al singolare — la radice sì); `ambient` → ambiente/ambientale. Se il tool matcha a confini di parola (es. `aic --keyword`), usa più keyword separate.
116116
- **Vuoto ≠ assente.** Se non trovi nulla, riformula con un sinonimo o una radice prima di dire all'utente che il dato non c'è. Solo dopo 2-3 varianti fallite l'assenza è credibile.
117117
- **Sindacato ispettivo (Senato) non è ricercabile per argomento**: `sindacato-ispettivo --keyword` non filtra sull'oggetto perché il LOD Senato non espone l'oggetto/testo dell'atto (solo tipo, numero, data, firmatari). Il testo vive solo nella pagina HTML esterna. Non promettere ricerche tematiche su questo tool.
118+
- **Sul Senato la keyword corta conviene due volte.** Oltre a matchare più forme, evita di sbattere contro il limite di dimensione delle richieste (vedi Tips): sui tool Senato la keyword entra tre volte nella query, quindi una frase lunga la fa crescere in fretta.
118119

119120
## Grounding (non inventare)
120121

@@ -133,3 +134,7 @@ Quando ricostruisci iter, voti o schede, il rischio è la **confabulazione su sc
133134
- **Senato confidence votes have empty `ddl_uri` at the source**, but `senato-votes list --ddl-uri <uri>` **now returns the *fiducia* anyway** (it resolves the DDL's seduta dates and re-links the confidence vote of that day). Still check `ddl_uri` on any "final" vote found by date: it may belong to a different act (unified text). `--ddl-uri` **derives the legislature from the DDL**, so a historical DDL (e.g. leg.18 `http://dati.senato.it/ddl/52988`) works without passing `--legislature`.
134135
- **Senato — quando c'è il voto individuale**: `senato-vote-detail` dà il sì/no del singolo senatore solo per i voti di **merito** (`type` = `elettronica`, `nominale con appello`, `controprova`). Per `segreta` (voto segreto) e `verifica numero legale` (conteggio del quorum) restituisce solo le presenze (`Presente non votante`, `In congedo/missione`), **mai** una scelta espressa (né `Favorevole`, né `Contrario`, né `Astenuto`): la scelta non è nel dato. Non dedurre il voto individuale su queste due modalità.
135136
- **Question time Camera per sede**: le interrogazioni a risposta immediata hanno la sede nel label in modo regolare, e `aic --type` matcha anche sul label. Quindi la sede è già filtrabile senza campo dedicato: `aic list --type "immediata in assemblea"` = question time in Aula, `aic list --type "immediata in commissione"` = in commissione (combinabile con `--keyword <tema>`).
137+
- **Senato: due errori diversi si presentano allo stesso modo.** L'endpoint del Senato accetta richieste fino a **2047 byte** di URL e rifiuta le più lunghe, ma risponde `403` sia in quel caso sia quando le richieste sono troppo ravvicinate — e la pagina di errore è identica. Regola pratica:
138+
- **"Query troppo lunga per l'endpoint del Senato"** (errore della CLI, prima di uscire in rete): accorcia la keyword — una radice breve basta, il match è per sottostringa — oppure restringi l'intervallo di date. Ritentare identico non serve a nulla.
139+
- **`403` restituito dall'endpoint**: sono troppe richieste ravvicinate. Aspetta qualche minuto, non ritentare a raffica. La CLI già distanzia di 2 secondi le proprie chiamate al Senato: il blocco arriva quando si sommano più comandi o test in sequenza.
140+
- Nota: il limite riguarda **solo il Senato**. La Camera accetta richieste molto più grandi.

src/core/chunk.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, it, expect } from "vitest";
2+
import { chunk } from "./chunk.js";
3+
4+
describe("chunk", () => {
5+
it("spezza in blocchi della dimensione richiesta", () => {
6+
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
7+
});
8+
9+
it("preserva l'ordine originale", () => {
10+
const nums = Array.from({ length: 53 }, (_, i) => i);
11+
expect(chunk(nums, 25).flat()).toEqual(nums);
12+
});
13+
14+
it("restituisce un solo blocco se la lista è più corta della dimensione", () => {
15+
expect(chunk(["a", "b"], 25)).toEqual([["a", "b"]]);
16+
});
17+
18+
it("restituisce lista vuota su input vuoto (nessuna query a vuoto)", () => {
19+
expect(chunk([], 25)).toEqual([]);
20+
});
21+
22+
it("rifiuta una dimensione non valida", () => {
23+
expect(() => chunk([1, 2], 0)).toThrow();
24+
});
25+
});

src/core/chunk.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Spezza una lista in blocchi di al più `size` elementi, preservando l'ordine.
3+
*
4+
* Serve a tenere corte le query SPARQL costruite come OR-chain su una lista di
5+
* lunghezza variabile (`FILTER(STR(?x) = "a" || STR(?x) = "b" || ...)`):
6+
* l'endpoint del Senato respinge con 403 le richieste oltre
7+
* `SENATO_MAX_REQUEST_URI` byte, e il POST non è un'alternativa (è sempre
8+
* rifiutato). Con una lista che cresce — es. le fiducie di un'intera
9+
* legislatura — la query supera la soglia e il tool smette di funzionare.
10+
*/
11+
export function chunk<T>(items: readonly T[], size: number): T[][] {
12+
if (size < 1) throw new Error("chunk: size deve essere >= 1");
13+
const out: T[][] = [];
14+
for (let i = 0; i < items.length; i += size) {
15+
out.push(items.slice(i, i + size));
16+
}
17+
return out;
18+
}

src/core/senato-query-size.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect } from "vitest";
2+
import {
3+
SENATO_MAX_OR_TERMS,
4+
SENATO_MAX_REQUEST_URI,
5+
senatoRequestUriLength,
6+
assertQueryFits,
7+
} from "./senato-query-size.js";
8+
import { chunk } from "./chunk.js";
9+
import { OSR_PREFIXES } from "./prefixes.js";
10+
11+
describe("senato-query-size", () => {
12+
it("misura la request-URI con l'encoding usato dal client", () => {
13+
// "/sparql?" + "query=" + "a" + "&format=application%2Fjson"
14+
// (URLSearchParams encoda anche lo slash: 26 byte, non 24)
15+
expect(senatoRequestUriLength("a")).toBe(8 + 6 + 1 + 26);
16+
});
17+
18+
it("lascia passare una query sotto la soglia", () => {
19+
expect(() => assertQueryFits("SELECT ?s WHERE { ?s ?p ?o }")).not.toThrow();
20+
});
21+
22+
it("blocca la query oltre la soglia con un messaggio che dice cosa fare", () => {
23+
const q = `SELECT ?s WHERE { ?s ?p "${"x".repeat(SENATO_MAX_REQUEST_URI)}" }`;
24+
expect(() => assertQueryFits(q, "corte dei conti")).toThrow(
25+
/Query troppo lunga.*corte dei conti/s,
26+
);
27+
});
28+
29+
it("la OR-chain delle fiducie sfora intera, a blocchi di SENATO_MAX_OR_TERMS no", () => {
30+
// 53 fiducie in leg. 19: è il caso reale che mandava --keyword in 403.
31+
const nums = Array.from({ length: 53 }, (_, i) => String(1000 + i));
32+
const query = (batch: string[]) => `${OSR_PREFIXES}
33+
SELECT ?ddl ?f WHERE {
34+
?ddl a osr:Ddl ; osr:legislatura 19 ; osr:fase ?f .
35+
FILTER(${batch.map((n) => `STR(?f) = "S.${n}"`).join(" || ")})
36+
}`;
37+
expect(senatoRequestUriLength(query(nums))).toBeGreaterThan(
38+
SENATO_MAX_REQUEST_URI,
39+
);
40+
for (const batch of chunk(nums, SENATO_MAX_OR_TERMS)) {
41+
expect(() => assertQueryFits(query(batch))).not.toThrow();
42+
}
43+
});
44+
45+
it("il confine è esattamente 2047 accettata / 2048 rifiutata", () => {
46+
// padding calibrato sulla lunghezza reale, non stimato
47+
const fill = (target: number) => {
48+
let q = "SELECT ?s WHERE { ?s ?p ?o }";
49+
while (senatoRequestUriLength(q) < target) q += "x";
50+
return q;
51+
};
52+
const alLimite = fill(SENATO_MAX_REQUEST_URI);
53+
expect(senatoRequestUriLength(alLimite)).toBe(2047);
54+
expect(() => assertQueryFits(alLimite)).not.toThrow();
55+
expect(() => assertQueryFits(alLimite + "x")).toThrow();
56+
});
57+
});

src/core/senato-query-size.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Vincoli di dimensione delle richieste all'endpoint SPARQL del Senato.
3+
*
4+
* Il front-end di `dati.senato.it` accetta una request-URI (`/sparql?query=…`)
5+
* fino a **2047 byte** e respinge da 2048 in su con una pagina 403 HTML, non
6+
* con un errore SPARQL né con un 414. Misurato per bisezione il 25/7/2026, con
7+
* query di controllo a 200 subito prima e subito dopo: non è il blocco per
8+
* frequenza, che invece respinge anche le richieste minime. Il POST è sempre
9+
* rifiutato, quindi non c'è modo di aggirare il limite spostando la query nel
10+
* corpo: l'unica strada è tenere corta la GET.
11+
*/
12+
export const SENATO_MAX_REQUEST_URI = 2047;
13+
14+
/**
15+
* Massimo numero di termini in una OR-chain (`FILTER(STR(?x) = "a" || …)`) per
16+
* singola query. 45 termini sfiorano la soglia, 50 la superano: 25 lascia
17+
* margine anche quando il resto della query cresce, al costo di una query in
18+
* più ogni 25 elementi.
19+
*/
20+
export const SENATO_MAX_OR_TERMS = 25;
21+
22+
/**
23+
* Lunghezza della request-URI che il client produrrebbe per questa query,
24+
* calcolata con lo stesso encoding di `URLSearchParams` usato in `client.ts`.
25+
*/
26+
export function senatoRequestUriLength(query: string): number {
27+
const params = new URLSearchParams({ query, format: "application/json" });
28+
return "/sparql?".length + params.toString().length;
29+
}
30+
31+
/**
32+
* Blocca in partenza le query troppo lunghe con un messaggio che dice cosa
33+
* fare, invece di lasciare arrivare un 403 opaco — indistinguibile, lato
34+
* client, dal blocco per frequenza.
35+
*/
36+
export function assertQueryFits(query: string, keyword?: string): void {
37+
const len = senatoRequestUriLength(query);
38+
if (len <= SENATO_MAX_REQUEST_URI) return;
39+
const perche = keyword
40+
? ` La parola chiave "${keyword}" pesa circa tre volte la sua lunghezza: accorciala (una radice più corta basta, il match è per sottostringa)`
41+
: " Restringi i filtri";
42+
throw new Error(
43+
`Query troppo lunga per l'endpoint del Senato: ${len} byte di request-URI, il massimo accettato è ${SENATO_MAX_REQUEST_URI}.${perche} oppure aggiungi un intervallo di date più stretto.`,
44+
);
45+
}

src/tools/senato-votes.ts

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ import { OSR_PREFIXES } from "../core/prefixes.js";
44
import { flattenBindings } from "../core/flatten.js";
55
import { actHtmlUrl, ddlRssUrl } from "../core/html-url.js";
66
import { extractBillNumber } from "../core/bill-number.js";
7+
import { chunk } from "../core/chunk.js";
8+
import {
9+
SENATO_MAX_OR_TERMS,
10+
assertQueryFits,
11+
} from "../core/senato-query-size.js";
712
import type { Tool } from "./types.js";
813

914
/**
@@ -347,22 +352,24 @@ WHERE {
347352
.filter((v) => v.bill_number);
348353
if (cand.length === 0) return [];
349354
const nums = [...new Set(cand.map((v) => v.bill_number))];
350-
const filter = nums.map((n) => `STR(?f) = "S.${n}"`).join(" || ");
351-
const fq = `${OSR_PREFIXES}
355+
const byNum = new Map<string, { ddl: string; titolo: string }>();
356+
for (const batch of chunk(nums, SENATO_MAX_OR_TERMS)) {
357+
const filter = batch.map((n) => `STR(?f) = "S.${n}"`).join(" || ");
358+
const fq = `${OSR_PREFIXES}
352359
SELECT ?ddl ?f ?titolo ?titoloBreve WHERE {
353360
?ddl a osr:Ddl ; osr:legislatura ${effectiveLeg} ; osr:fase ?f .
354361
OPTIONAL { ?ddl osr:titolo ?titolo }
355362
OPTIONAL { ?ddl osr:titoloBreve ?titoloBreve }
356363
FILTER(${filter})
357364
}`;
358-
const byNum = new Map<string, { ddl: string; titolo: string }>();
359-
for (const r of flattenBindings(await snQuery(fq))) {
360-
const num = (r.f ?? "").replace(/^S\./, "");
361-
if (num && r.ddl && !byNum.has(num))
362-
byNum.set(num, {
363-
ddl: r.ddl,
364-
titolo: `${r.titolo ?? ""} ${r.titoloBreve ?? ""}`,
365-
});
365+
for (const r of flattenBindings(await snQuery(fq))) {
366+
const num = (r.f ?? "").replace(/^S\./, "");
367+
if (num && r.ddl && !byNum.has(num))
368+
byNum.set(num, {
369+
ddl: r.ddl,
370+
titolo: `${r.titolo ?? ""} ${r.titoloBreve ?? ""}`,
371+
});
372+
}
366373
}
367374
const kw = input.keyword!.toLowerCase();
368375
return cand.filter((v) => {
@@ -457,30 +464,34 @@ SELECT DISTINCT ?date ?label WHERE {
457464
// set è piccolo: niente LIMIT/OFFSET server, paginiamo in TS dopo il
458465
// post-filtro (altrimenti il LIMIT taglierebbe prima del filtro).
459466
const paginate = input.ddlUri ? "" : `LIMIT ${input.limit}\nOFFSET ${input.offset}`;
460-
const coreSelect = `SELECT DISTINCT ?v ?date ?numero ?tipo ?label ?esito
461-
?favorevoli ?contrari ?astenuti ?presenti ?votanti ?maggioranza
462-
?ddl ?oggetto
467+
// Query scritta compatta di proposito: indentazione e a capo finiscono
468+
// nella request-URI, che oltre SENATO_MAX_REQUEST_URI byte viene respinta
469+
// con 403 (di qui assertQueryFits, sotto). La keyword entra tre volte — label, titolo e
470+
// titoloBreve del DDL — quindi la lunghezza cresce di ~3 volte la keyword:
471+
// il margine recuperato qui è ciò che permette le keyword multi-parola.
472+
const coreSelect = `SELECT DISTINCT ?v ?date ?numero ?tipo ?label ?esito ?favorevoli ?contrari ?astenuti ?presenti ?votanti ?maggioranza ?ddl ?oggetto
463473
WHERE {
464-
?v a osr:Votazione ; osr:legislatura ${effectiveLeg} ; osr:seduta ?s .
465-
OPTIONAL { ?s osr:dataSeduta ?date }
466-
${ddlTopicPattern}
467-
${needsLabel ? `?v rdfs:label ?label . ${labelFilter}` : "OPTIONAL { ?v rdfs:label ?label }"}
468-
OPTIONAL { ?v osr:numero ?numero }
469-
OPTIONAL { ?v osr:tipoVotazione ?tipo }
470-
OPTIONAL { ?v osr:esito ?esito }
471-
OPTIONAL { ?v osr:favorevoli ?favorevoli }
472-
OPTIONAL { ?v osr:contrari ?contrari }
473-
OPTIONAL { ?v osr:astenuti ?astenuti }
474-
OPTIONAL { ?v osr:presenti ?presenti }
475-
OPTIONAL { ?v osr:votanti ?votanti }
476-
OPTIONAL { ?v osr:maggioranza ?maggioranza }
477-
OPTIONAL { ?v osr:oggetto ?oggetto . OPTIONAL { ?oggetto osr:relativoA ?ddl } }
478-
${ddlDateFilter}
479-
${dateFromFilter}
480-
${dateToFilter}
474+
?v a osr:Votazione ; osr:legislatura ${effectiveLeg} ; osr:seduta ?s .
475+
OPTIONAL { ?s osr:dataSeduta ?date }
476+
${ddlTopicPattern}
477+
${needsLabel ? `?v rdfs:label ?label . ${labelFilter}` : "OPTIONAL { ?v rdfs:label ?label }"}
478+
OPTIONAL { ?v osr:numero ?numero } OPTIONAL { ?v osr:tipoVotazione ?tipo } OPTIONAL { ?v osr:esito ?esito }
479+
OPTIONAL { ?v osr:favorevoli ?favorevoli } OPTIONAL { ?v osr:contrari ?contrari } OPTIONAL { ?v osr:astenuti ?astenuti }
480+
OPTIONAL { ?v osr:presenti ?presenti } OPTIONAL { ?v osr:votanti ?votanti } OPTIONAL { ?v osr:maggioranza ?maggioranza }
481+
OPTIONAL { ?v osr:oggetto ?oggetto . OPTIONAL { ?oggetto osr:relativoA ?ddl } }
482+
${ddlDateFilter}
483+
${dateFromFilter}
484+
${dateToFilter}
481485
}`;
482486

483-
const query = `${OSR_PREFIXES}\n${coreSelect}\nORDER BY DESC(?date) DESC(?numero)\n${paginate}`;
487+
const body = `${coreSelect}\nORDER BY DESC(?date) DESC(?numero)\n${paginate}`;
488+
// Header prefissi minimo invece di OSR_PREFIXES (che ne dichiara quattro,
489+
// ~180 byte): qui servono osr: e rdfs:, e xsd: solo quando un filtro data
490+
// tipizza il letterale.
491+
const query = `PREFIX osr: <http://dati.senato.it/osr/>
492+
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>${body.includes("xsd:") ? '\nPREFIX xsd: <http://www.w3.org/2001/XMLSchema#>' : ""}
493+
${body}`;
494+
assertQueryFits(query, input.keyword);
484495

485496
const results = await snQuery(query);
486497
const raw = flattenBindings(results);
@@ -526,22 +537,25 @@ WHERE {
526537
}
527538
// Fallback 1: alcuni voti (tipicamente le fiducie) non hanno osr:oggetto e
528539
// quindi nessun ddl_uri via grafo, ma citano il DDL nel label. Risolviamo il
529-
// numero → URI in un'unica query via osr:fase="S.<num>" (univoco intra-leg;
530-
// niente VALUES batch su Virtuoso → OR-chain). Solo per i label che citano
531-
// davvero un DDL (bill_number non vuoto).
540+
// numero → URI via osr:fase="S.<num>" (univoco intra-leg; niente VALUES
541+
// batch su Virtuoso → OR-chain, spezzata in query da SENATO_MAX_OR_TERMS
542+
// per non superare la soglia dei 2 KB). Solo per i label che citano davvero
543+
// un DDL (bill_number non vuoto).
532544
let needing = [...byUri.values()].filter((v) => !v.ddl_uri && v.bill_number);
533545
const nums = [...new Set(needing.map((v) => v.bill_number))];
534546
if (nums.length) {
535-
const filter = nums.map((n) => `STR(?f) = "S.${n}"`).join(" || ");
536-
const fbQuery = `${OSR_PREFIXES}
547+
const byFase = new Map<string, string>();
548+
for (const batch of chunk(nums, SENATO_MAX_OR_TERMS)) {
549+
const filter = batch.map((n) => `STR(?f) = "S.${n}"`).join(" || ");
550+
const fbQuery = `${OSR_PREFIXES}
537551
SELECT ?ddl ?f WHERE {
538552
?ddl a osr:Ddl ; osr:legislatura ${effectiveLeg} ; osr:fase ?f .
539553
FILTER(${filter})
540554
}`;
541-
const byFase = new Map<string, string>();
542-
for (const r of flattenBindings(await snQuery(fbQuery))) {
543-
const num = (r.f ?? "").replace(/^S\./, "");
544-
if (num && r.ddl && !byFase.has(num)) byFase.set(num, r.ddl);
555+
for (const r of flattenBindings(await snQuery(fbQuery))) {
556+
const num = (r.f ?? "").replace(/^S\./, "");
557+
if (num && r.ddl && !byFase.has(num)) byFase.set(num, r.ddl);
558+
}
545559
}
546560
for (const v of needing) {
547561
const ddl = byFase.get(v.bill_number);

0 commit comments

Comments
 (0)