Skip to content

Commit 88553b0

Browse files
authored
Merge pull request #54 from ondata/feat/speeches-date-filter
feat(speeches): filtro --date-from/--date-to + colonna date (#37)
2 parents 86ad73a + 8a3f442 commit 88553b0

11 files changed

Lines changed: 278 additions & 32 deletions

File tree

LOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## 2026-07-11
44

5+
- **feat `speeches` — filtro `--date-from`/`--date-to` + colonna `date`** (#37, branch `feat/speeches-date-filter`). Prima gli interventi non erano filtrabili né datati nell'output. **Camera**: la data reale non è sull'intervento (`ods:modified` è il timestamp del record) ma sulla `ocd:discussione` che lo raggruppa (`dc:date` plain `AAAAMMGG`); verificato che copre sia Aula (stenografico, `sezione=assemblea`) sia commissione (bollettino), cardinalità 1 per intervento. Il join+`FILTER` sulle date va dentro la subquery-first (il filtro precede il `LIMIT`); performante solo col range filter sul soggetto → il filtro data richiede `legislature` (0.5s con, degrada senza). **Senato**: `osr:dataSeduta` (xsd:date ISO) già disponibile, filtro con `STR(?ds)` (regge sia xsd:date sia xsd:string). Colonna `date` (YYYY-MM-DD) ora su entrambe le camere — rinominata da `session_date` per parità (nessun consumer esterno). CLI (`--date-from/--date-to`), MCP, skill (tools.md + commands.md) e wiki (`camera/interventi-data.md` nuova) allineati. +2 test (Camera e Senato), 139/139 verdi, tsc pulito.
6+
57
- **v0.24.2** — release patch. Raccoglie i fix e i miglioramenti mergiati dopo la 0.24.1 (137/137 test verdi, tsc pulito, **43 tool** invariati): (1) **fix CLI emptyHint** (#49) — l'`emptyHint` statico dei tool ora arriva anche via CLI (prima cablato solo in `formatResult`/MCP), così i vuoti "legittimi" (es. gap COVID leg.18) hanno la spiegazione anche a riga di comando; via OpenSpec + `withEmptyHint`. (2) **anti-refuso `bill_number` blindato** (#52) — test di regressione sul Piano Casa (label "DDL n. 1994" → `bill_number` 1944, dal backfill `osr:fase`) e wiki `fiducia-fallback` allineata. (3) **`senato-vote-detail` — dettaglio per tipo di voto** (#53) — verificato che il dettaglio nominativo c'è per tutte le modalità; la scelta espressa (Favorevole/Contrario/Astenuto) manca solo su `segreta` e `verifica numero legale` (solo presenze). Description del tool, wiki `votazione-tipo-semantico` e skill aggiornate; segnale `roll_call_available` derivabile da `type`, niente campo nuovo. (4) **ricetta sede question time `aic`** — la sede (Aula/commissione) è già filtrabile via `--type "immediata in assemblea"/"in commissione"` (match sul label regolare), documentata in description/esempio + skill. (5) **fix agente** news-driven-cli-gap-analyzer che si auto-delegava (#50). Nessuna nuova capacità utente → patch.
68

79
## 2026-07-10

docs/lod-wiki/camera/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Endpoint SPARQL: `https://dati.camera.it/sparql`. Ontologia OCD (namespace `http
66

77
* [Sedute e attività delle commissioni](sedute-commissione.md) - `ocd:seduta` per organo e per data; proprietà reali (`dc:date` stringa `AAAAMMGG`, `ocd:rif_organo`, `ocd:rif_leg`) e filtro legislatura obbligatorio.
88
* [Composizione delle commissioni](composizione-commissione.md) - chi ne fa parte con ruolo e date; due path RDF da unire (`ocd:membro` + `ocd:ufficioParlamentare`), trappola bicamerale sui presidenti senatori (`ocd:rif_senatore`).
9+
* [Data di un intervento in aula](interventi-data.md) - l'intervento non porta la data (`ods:modified` è il timestamp del record); il giorno reale è su `dc:date` della `ocd:discussione` che lo raggruppa (`AAAAMMGG`), verificato per Aula e commissione. Filtro data performante solo con range filter sul soggetto (legislatura obbligatoria).
910
* [Date degli atti di sindacato ispettivo (aic)](aic-date.md) - `dc:date` è presentazione (a volte composta `pres-modifica`), `ocd:endDate` è conclusione/trattazione; la seduta NON è un link strutturato (numero solo in `dc:description`). Trappola question time.
1011
* [Iter di un atto — timeline degli stati](iter-statoIter.md) - la cronologia dell'iter è una timeline via `ocd:rif_statoIter` (uno stato per fase, `dc:date`+`dc:title`); copertura alla pari tra legislature (18 = 19). Il ramo Senato dà per design solo lo stato corrente (timeline nel feed RSS), asimmetria di ramo non di legislatura.
1112
* [Firmatari di decreti-legge e atti governativi](firmatari-atti-governativi.md) - sugli atti del Governo `ocd:primo_firmatario` punta a un blank node "membro di governo", non a un deputato: il nome del ministro è via `ocd:rif_persona`, il dicastero in `ocd:ruolo`. Senza seguirli il nome torna vuoto.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Data di un intervento in aula (Camera)
2+
3+
L'intervento (`ocd:intervento`) **non porta la propria data**: l'unica proprietà temporale sull'istanza è `ods:modified`, che è il timestamp di modifica del record, non il giorno in cui l'intervento è stato pronunciato. La data reale vive sulla `ocd:discussione` che raggruppa l'intervento: la discussione lo referenzia con `ocd:rif_intervento` e porta `dc:date`.
4+
5+
```sparql
6+
PREFIX ocd: <http://dati.camera.it/ocd/>
7+
PREFIX dc: <http://purl.org/dc/elements/1.1/>
8+
SELECT ?date WHERE {
9+
?disc ocd:rif_intervento <http://dati.camera.it/ocd/intervento.rdf/in19_824755> ; dc:date ?date .
10+
}
11+
```
12+
13+
`dc:date` è una stringa `AAAAMMGG` (es. `20260617`), confrontabile lessicograficamente per un filtro a intervallo. Come per gli altri campi data della Camera (`votes`, `sessions`, `aic`), il confronto va forzato con `STR(?date)`: su Virtuoso Camera i range su `dc:date` senza `STR()` rischiano un confronto numerico spurio e vuoti muti. La copertura è verificata su **entrambe** le sedi: interventi d'Aula (documento stenografico, `dc:relation` con `sezione=assemblea`) e interventi di commissione (bollettino, `sezione=bollettini`) hanno tutti la discussione con `dc:date`, con cardinalità 1 per intervento.
14+
15+
## Filtrare per data in modo performante
16+
17+
Gli interventi Camera non hanno `ocd:rif_leg`: la legislatura è solo nel pattern URI (`in<leg>_<id>`). Un filtro data va perciò abbinato al range filter sul soggetto (`FILTER(?s >= <…in19_> && ?s < <…in19_z>)`), che àncora l'indice Virtuoso; il join sulla discussione con il `FILTER` sulle date va **dentro** la subquery che seleziona/ordina/limita i soli `?s`, così il filtro precede il `LIMIT`. Senza il vincolo di legislatura la query perde l'àncora ed è molto più lenta. Questo è esattamente ciò che fa il tool `speeches` con `--date-from`/`--date-to`.

skills/italian-parliament-cli/references/commands.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,13 @@ italianparliament vote-detail show --vote-uri <vote-uri> --format csv
112112
```
113113

114114
### `speeches list`
115-
Interventi in aula (disponibili da leg. 17).
115+
Interventi in aula, Camera e Senato (`--chamber`; dati Camera da leg. 17). Colonna `date` (YYYY-MM-DD) in output.
116116
```bash
117117
italianparliament speeches list --legislature 19
118+
italianparliament speeches list --legislature 19 --date-from 2026-06-17 --date-to 2026-06-17
119+
italianparliament speeches list --chamber senato --legislature 19 --date-from 2025-03-01 --date-to 2025-03-31
118120
```
121+
`--date-from`/`--date-to` filtrano per la **data della seduta**. Camera: la data non è sull'intervento (`ods:modified` è il timestamp del record) ma sulla `ocd:discussione` che lo raggruppa — il tool la ricava. Per la Camera il filtro data richiede `--legislature` (àncora l'indice; senza è molto più lento).
119122

120123
### `attendance show`
121124
Conteggio aggregato dei voti di un deputato su tutte le votazioni della sua legislatura (favorevole/contrario/astensione/non ha votato/ha votato in scrutinio segreto) — misura di attivismo/assenteismo.

skills/italian-parliament-mcp/references/tools.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,12 @@ Come ha votato ogni deputato in una votazione.
8484
- `voteType`: Favorevole | Contrario | Astenuto | Non ha votato
8585

8686
### `speeches`
87-
Interventi in aula Camera.
88-
- `legislature`: numero legislatura (nota: dati disponibili da leg. 17)
89-
- `deputyUri`: filtra per deputato
87+
Interventi in aula, Camera **e** Senato (`chamber`).
88+
- `chamber`: `camera` (default) o `senato`
89+
- `legislature`: numero legislatura (nota: dati Camera disponibili da leg. 17)
90+
- `deputyUri`: filtra per parlamentare
91+
- `dateFrom` / `dateTo` (YYYY-MM-DD): filtra per **data della seduta** dell'intervento. Camera: la data non è sull'intervento (`ods:modified` è il timestamp del record) ma sulla `ocd:discussione` che lo raggruppa — il tool la ricava per te. Per la Camera il filtro data richiede `legislature` (àncora l'indice; senza è molto più lento).
92+
- Colonna `date` (YYYY-MM-DD) in output per entrambe le camere.
9093

9194
### `attendance`
9295
Conteggio aggregato dei voti di un deputato su tutte le votazioni della sua legislatura (misura di attivismo/assenteismo). L'URI del deputato è già specifico di una legislatura (`.../deputato.rdf/d<ID>_<LEG>`), quindi il conteggio è già delimitato senza filtro separato.

src/cli.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { CAPABILITIES, capabilityScore } from "./core/capabilities.js";
4747
import { formatRows, type Format } from "./core/format.js";
4848
import { SparqlError } from "./core/client.js";
4949
import { ZodError } from "zod";
50+
import { formatZodError } from "./core/zod-error.js";
5051
import type { ToolResult } from "./tools/types.js";
5152
import { withEmptyHint } from "./core/empty-hint.js";
5253
import { createRequire } from "module";
@@ -84,13 +85,7 @@ async function runTool(tool: { inputSchema: { parse(i: unknown): any }; execute(
8485
parsed = tool.inputSchema.parse(input);
8586
} catch (e) {
8687
if (e instanceof ZodError) {
87-
const msgs = e.issues.map((i) => {
88-
const field = i.path.join(".") || "input";
89-
return i.code === "invalid_enum_value"
90-
? `--${field}: valore non valido "${(i as { received?: string }).received ?? ""}". Ammessi: ${i.options.join(" | ")}.`
91-
: `--${field}: ${i.message}`;
92-
});
93-
throw new Error(msgs.join("\n"));
88+
throw new Error(formatZodError(e, true));
9489
}
9590
throw e;
9691
}
@@ -644,6 +639,8 @@ const speechesList = defineCommand({
644639
type: "string",
645640
description: "Full URI of a deputy/senator",
646641
},
642+
"date-from": { type: "string", description: "Start date YYYY-MM-DD (session date)" },
643+
"date-to": { type: "string", description: "End date YYYY-MM-DD (session date)" },
647644
"count-only": {
648645
type: "boolean",
649646
description: "Return only the total count",
@@ -658,6 +655,8 @@ const speechesList = defineCommand({
658655
chamber,
659656
legislature: parseIntFlag(args.legislature as string, "legislature"),
660657
deputyUri: (args["deputy-uri"] as string) || undefined,
658+
dateFrom: (args["date-from"] as string) || undefined,
659+
dateTo: (args["date-to"] as string) || undefined,
661660
countOnly: Boolean(args["count-only"]),
662661
limit: parseIntFlag(args.limit as string, "limit") ?? 100,
663662
offset: Number(args.offset ?? 0),
@@ -1861,14 +1860,7 @@ if (argv[0] === "sparql" && argv[1] !== "query" && argv[1] !== "--help" && argv[
18611860
runMain(main).catch((err: unknown) => {
18621861
if (err instanceof ZodError) {
18631862
// Errori di validazione input: per gli enum, elenca i valori validi.
1864-
const msgs = err.issues.map((i) => {
1865-
const field = i.path.join(".") || "input";
1866-
if (i.code === "invalid_enum_value") {
1867-
return `--${field}: valore non valido. Ammessi: ${i.options.join(" | ")}.`;
1868-
}
1869-
return `--${field}: ${i.message}`;
1870-
});
1871-
process.stderr.write(`Error: ${msgs.join("\n")}\n`);
1863+
process.stderr.write(`Error: ${formatZodError(err, true)}\n`);
18721864
} else if (err instanceof SparqlError) {
18731865
process.stderr.write(
18741866
`Error: ${err.message}\nEndpoint: ${err.endpoint}\n${err.status ? `Status: ${err.status}\n` : ""}`,

src/core/zod-error.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, it, expect } from "vitest";
2+
import { z } from "zod";
3+
import { ZodError } from "zod";
4+
import { formatZodError } from "./zod-error.js";
5+
6+
function zodErrorFrom(schema: z.ZodTypeAny, input: unknown): ZodError {
7+
const r = schema.safeParse(input);
8+
if (r.success) throw new Error("atteso fallimento di parsing");
9+
return r.error;
10+
}
11+
12+
describe("formatZodError", () => {
13+
it("enum: elenca i valori ammessi e il valore ricevuto", () => {
14+
const schema = z.object({ voteType: z.enum(["finale", "emendamento"]) });
15+
const msg = formatZodError(zodErrorFrom(schema, { voteType: "x" }));
16+
expect(msg).toContain("voteType:");
17+
expect(msg).toContain('"finale" | "emendamento"');
18+
expect(msg).toContain('"x"');
19+
});
20+
21+
it("flagStyle rende il flag CLI reale (-- + kebab-case), MCP lascia il nome schema nudo", () => {
22+
const schema = z.object({ dateFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) });
23+
const err = zodErrorFrom(schema, { dateFrom: "pippo" });
24+
expect(formatZodError(err, true)).toMatch(/^--date-from:/);
25+
expect(formatZodError(err)).toMatch(/^dateFrom:/);
26+
});
27+
28+
it("una riga per issue", () => {
29+
const schema = z.object({ a: z.string(), b: z.number() });
30+
const msg = formatZodError(zodErrorFrom(schema, { a: 1, b: "x" }));
31+
expect(msg.split("\n").length).toBe(2);
32+
});
33+
});

src/core/zod-error.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { ZodError } from "zod";
2+
3+
// Formatta un ZodError in un messaggio compatto, una riga per campo, con i
4+
// valori ammessi per gli enum. Condiviso tra CLI e path MCP (server.ts) così
5+
// un input non valido produce lo stesso errore leggibile invece della stringa
6+
// JSON lunga di default di Zod. flagStyle=true antepone "--" al nome del campo
7+
// (stile flag CLI); false lascia il nome del parametro nudo (MCP).
8+
export function formatZodError(e: ZodError, flagStyle = false): string {
9+
// flagStyle: rende il campo come il flag CLI reale — "--" + kebab-case
10+
// (dateFrom -> --date-from, voteType -> --vote-type), così l'errore non
11+
// suggerisce un flag camelCase inesistente. MCP usa il nome nudo dello schema.
12+
const label = (field: string) =>
13+
flagStyle ? `--${field.replace(/([A-Z])/g, "-$1").toLowerCase()}` : field;
14+
const safeJson = (value: unknown) => {
15+
try {
16+
return JSON.stringify(value) ?? "undefined";
17+
} catch {
18+
return String(value);
19+
}
20+
};
21+
return e.issues
22+
.map((i) => {
23+
const field = i.path.join(".") || "input";
24+
if (i.code === "invalid_enum_value") {
25+
const received = safeJson(i.received);
26+
const allowed = i.options.map((v) => safeJson(v)).join(" | ");
27+
return `${label(field)}: valore non valido ${received}. Ammessi: ${allowed}.`;
28+
}
29+
return `${label(field)}: ${i.message}`;
30+
})
31+
.join("\n");
32+
}

src/server.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ import { senatoAttendanceTool } from "./tools/senato-attendance.js";
4545
import type { Tool, ToolResult } from "./tools/types.js";
4646
import { toJsonl } from "./core/format.js";
4747
import { SparqlError } from "./core/client.js";
48+
import { ZodError } from "zod";
49+
import { formatZodError } from "./core/zod-error.js";
4850

4951
function describe(tool: Tool): string {
5052
return `${tool.description}\n\nExamples:\n${tool.examples
@@ -63,20 +65,27 @@ function formatResult(result: ToolResult, emptyHint?: string): string {
6365
function makeHandler(tool: Tool) {
6466
return async (input: unknown) => {
6567
try {
66-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
67-
const result = await tool.execute(input as any);
68+
// Valida l'input con lo schema Zod prima di eseguire, come fa la CLI
69+
// (cli.ts). Le regex/limiti dello schema (es. il formato data YYYY-MM-DD
70+
// di speeches) non devono dipendere dal solo dispatch dell'SDK: parseare
71+
// qui è il chokepoint unico che protegge tutti i tool da input non
72+
// conformi che finirebbero interpolati nelle query SPARQL.
73+
const parsed = tool.inputSchema.parse(input);
74+
const result = await tool.execute(parsed);
6875
return {
6976
content: [
7077
{ type: "text" as const, text: formatResult(result, tool.emptyHint) },
7178
],
7279
};
7380
} catch (err) {
7481
const message =
75-
err instanceof SparqlError
76-
? `SPARQL error on ${err.endpoint}${err.status ? ` (HTTP ${err.status})` : ""}: ${err.message}`
77-
: err instanceof Error
78-
? err.message
79-
: String(err);
82+
err instanceof ZodError
83+
? formatZodError(err)
84+
: err instanceof SparqlError
85+
? `SPARQL error on ${err.endpoint}${err.status ? ` (HTTP ${err.status})` : ""}: ${err.message}`
86+
: err instanceof Error
87+
? err.message
88+
: String(err);
8089
return {
8190
content: [{ type: "text" as const, text: `Error: ${message}` }],
8291
isError: true,

0 commit comments

Comments
 (0)