Skip to content

Commit ea600c1

Browse files
aborrusoclaude
andcommitted
fix(vote-detail): risolvi --group-acronym contro le sigle reali del voto (#77)
Le sigle di ocd:siglaGruppo non coincidono con l'acronym di `groups list` (AZ-PER-RE → APERRE, IV-CR → IVICRE, Misto disaggregato): il FILTER su una sigla inesistente restituiva zero righe senza dire perché. La sigla richiesta viene ora risolta contro quelle effettivamente presenti nella votazione (query leggera), con confronto tollerante a maiuscole e punteggiatura. Se non esiste: risultato vuoto + hint con le sigle reali e la più simile. Nessuna mappatura hardcoded: il disallineamento è della fonte e va risolto lì, il codice si limita a renderlo visibile. Closes #77 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ab8ad8a commit ea600c1

2 files changed

Lines changed: 168 additions & 2 deletions

File tree

src/tools/vote-detail.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, it, expect } from "vitest";
2+
import { resolveGroupAcronym, buildGroupAcronymHint } from "./vote-detail.js";
3+
4+
// Sigle realmente presenti in una votazione di leg. 19 (vs19_456_018).
5+
const AVAILABLE = [
6+
"FDI",
7+
"M5S",
8+
"LEGA",
9+
"M-ALT",
10+
"M-MIN",
11+
"PD-IDP",
12+
"FI-PPE",
13+
"AVS",
14+
"M-+EUR",
15+
"APERRE",
16+
"IVICRE",
17+
"NM-M-C",
18+
];
19+
20+
describe("resolveGroupAcronym", () => {
21+
it("match esatto", () => {
22+
expect(resolveGroupAcronym("FDI", AVAILABLE)).toBe("FDI");
23+
expect(resolveGroupAcronym("FI-PPE", AVAILABLE)).toBe("FI-PPE");
24+
});
25+
26+
it("ignora maiuscole/minuscole", () => {
27+
expect(resolveGroupAcronym("fdi", AVAILABLE)).toBe("FDI");
28+
expect(resolveGroupAcronym("Pd-Idp", AVAILABLE)).toBe("PD-IDP");
29+
});
30+
31+
it("ignora la punteggiatura", () => {
32+
expect(resolveGroupAcronym("PD IDP", AVAILABLE)).toBe("PD-IDP");
33+
expect(resolveGroupAcronym("nmmc", AVAILABLE)).toBe("NM-M-C");
34+
});
35+
36+
it("null sulle sigle di groups list non derivabili da una regola", () => {
37+
// Sono i casi che motivano l'hint: nessuna normalizzazione le riconcilia.
38+
expect(resolveGroupAcronym("AZ-PER-RE", AVAILABLE)).toBeNull();
39+
expect(resolveGroupAcronym("IV-CR", AVAILABLE)).toBeNull();
40+
expect(resolveGroupAcronym("MISTO", AVAILABLE)).toBeNull();
41+
});
42+
43+
it("null su sigla inesistente", () => {
44+
expect(resolveGroupAcronym("XYZ", AVAILABLE)).toBeNull();
45+
});
46+
});
47+
48+
describe("buildGroupAcronymHint", () => {
49+
it("suggerisce la sigla più simile e elenca quelle presenti", () => {
50+
const h = buildGroupAcronymHint("AZ-PER-RE", AVAILABLE);
51+
expect(h).toContain('Nessun gruppo con sigla "AZ-PER-RE"');
52+
expect(h).toContain('Forse cercavi "APERRE"');
53+
expect(h).toContain("IVICRE");
54+
});
55+
56+
it("suggerisce IVICRE per IV-CR", () => {
57+
expect(buildGroupAcronymHint("IV-CR", AVAILABLE)).toContain('Forse cercavi "IVICRE"');
58+
});
59+
60+
it("spiega che il Misto è disaggregato", () => {
61+
const h = buildGroupAcronymHint("MISTO", AVAILABLE);
62+
expect(h).toContain("Misto");
63+
expect(h).toContain("M-ALT");
64+
// Nessuna componente somiglia a "MISTO": meglio nessun suggerimento che uno a caso.
65+
expect(h).not.toContain("Forse cercavi");
66+
});
67+
68+
it("non suggerisce nulla su una sigla senza affinità", () => {
69+
expect(buildGroupAcronymHint("XYZ", AVAILABLE)).not.toContain("Forse cercavi");
70+
});
71+
72+
it("votazione senza sigle: dice che il filtro non è applicabile", () => {
73+
expect(buildGroupAcronymHint("FDI", [])).toContain("non è applicabile");
74+
});
75+
});

src/tools/vote-detail.ts

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ const inputSchema = z.object({
1313
groupAcronym: z
1414
.string()
1515
.optional()
16-
.describe("Filtra per sigla gruppo (es. 'FDI', 'PD-IDP', 'M5S'). Case-sensitive."),
16+
.describe(
17+
"Filtra per sigla gruppo (es. 'FDI', 'M5S', 'APERRE'). Le sigle sono quelle del dataset votazioni, che NON sempre coincidono con l'acronym di `groups list` (es. AZ-PER-RE → APERRE, IV-CR → IVICRE) e in cui il gruppo Misto è disaggregato nelle sue componenti (M-ALT, M-MIN, …). Maiuscole/minuscole e punteggiatura sono irrilevanti; se la sigla non esiste in quella votazione il risultato è vuoto con l'elenco delle sigle presenti.",
18+
),
1719
voteType: z
1820
.enum(["Favorevole", "Contrario", "Astensione", "Non ha votato"])
1921
.optional()
@@ -29,6 +31,84 @@ function stripLegLabel(label: string): string {
2931

3032
const columns = ["deputy_uri", "deputy_name", "vote", "group_uri", "group_acronym", "html_url"];
3133

34+
// Le sigle di `ocd:siglaGruppo` (dataset votazioni) non coincidono con
35+
// l'acronym di `groups list`: AZ-PER-RE → APERRE, IV-CR → IVICRE, e il Misto è
36+
// disaggregato per componente. Il confronto ignora quindi maiuscole e
37+
// punteggiatura, ma le differenze restanti non sono derivabili da una regola:
38+
// per quelle si mostra l'elenco delle sigle realmente presenti nel voto.
39+
function normalizeAcronym(s: string): string {
40+
return s.toUpperCase().replace(/[^A-Z0-9]/g, "");
41+
}
42+
43+
/** Sigla del dataset corrispondente a quella richiesta, o null se nessuna. */
44+
export function resolveGroupAcronym(requested: string, available: string[]): string | null {
45+
const exact = available.find((a) => a === requested);
46+
if (exact) return exact;
47+
const ci = available.find((a) => a.toUpperCase() === requested.toUpperCase());
48+
if (ci) return ci;
49+
const norm = normalizeAcronym(requested);
50+
return available.find((a) => normalizeAcronym(a) === norm) ?? null;
51+
}
52+
53+
/** Somiglianza di Dice sui bigrammi: ordina i suggerimenti, non decide il match. */
54+
function similarity(a: string, b: string): number {
55+
const bigrams = (s: string) => {
56+
const out: string[] = [];
57+
for (let i = 0; i < s.length - 1; i++) out.push(s.slice(i, i + 2));
58+
return out;
59+
};
60+
const A = bigrams(a);
61+
const B = bigrams(b);
62+
if (A.length === 0 || B.length === 0) return a === b ? 1 : 0;
63+
const pool = [...B];
64+
let hits = 0;
65+
for (const g of A) {
66+
const i = pool.indexOf(g);
67+
if (i >= 0) {
68+
hits++;
69+
pool.splice(i, 1);
70+
}
71+
}
72+
return (2 * hits) / (A.length + B.length);
73+
}
74+
75+
/** Nota mostrata quando la sigla richiesta non esiste in quella votazione. */
76+
export function buildGroupAcronymHint(requested: string, available: string[]): string {
77+
if (available.length === 0) {
78+
return `Nessuna sigla di gruppo registrata in questa votazione: il filtro --group-acronym non è applicabile.`;
79+
}
80+
const norm = normalizeAcronym(requested);
81+
const ranked = [...available].sort(
82+
(x, y) => similarity(norm, normalizeAcronym(y)) - similarity(norm, normalizeAcronym(x)),
83+
);
84+
const best = ranked[0];
85+
const parts = [
86+
`Nessun gruppo con sigla "${requested}" in questa votazione.`,
87+
`Le sigle del dataset votazioni non coincidono sempre con l'acronym di 'groups list' (es. AZ-PER-RE → APERRE, IV-CR → IVICRE).`,
88+
];
89+
if (similarity(norm, normalizeAcronym(best)) >= 0.4) {
90+
parts.push(`Forse cercavi "${best}".`);
91+
}
92+
parts.push(`Sigle presenti in questa votazione: ${ranked.join(", ")}.`);
93+
if (available.some((a) => /^M-/.test(a))) {
94+
parts.push(`Il gruppo Misto è disaggregato nelle sue componenti (le sigle "M-…"): non esiste una riga "MISTO".`);
95+
}
96+
return parts.join(" ");
97+
}
98+
99+
/** Sigle di gruppo effettivamente presenti nella votazione (query leggera). */
100+
async function fetchVoteAcronyms(voteUri: string): Promise<string[]> {
101+
const query = `${OCD_PREFIXES}
102+
SELECT DISTINCT ?siglaGruppo
103+
WHERE {
104+
?v a ocd:voto .
105+
?v ocd:rif_votazione <${voteUri}> .
106+
?v ocd:siglaGruppo ?siglaGruppo .
107+
}`;
108+
const raw = flattenBindings(await cdQuery(query));
109+
return raw.map((r) => r.siglaGruppo ?? "").filter((s) => s !== "");
110+
}
111+
32112
export const voteDetailTool: Tool<typeof inputSchema> = {
33113
name: "vote-detail",
34114
description:
@@ -39,10 +119,21 @@ export const voteDetailTool: Tool<typeof inputSchema> = {
39119
"italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs18_100_005 --format jsonl",
40120
"italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs19_010_003 --limit 50",
41121
"italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs19_641_046 --group-acronym FDI --vote-type Contrario",
122+
"italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs19_456_018 --group-acronym APERRE",
42123
],
43124
async execute(input) {
44125
const filters: string[] = [];
45-
if (input.groupAcronym) filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = "${input.groupAcronym}")`);
126+
if (input.groupAcronym) {
127+
// La sigla richiesta va risolta contro quelle realmente presenti nel voto,
128+
// altrimenti un FILTER su una sigla inesistente restituirebbe zero righe
129+
// senza dire perché (issue #77).
130+
const available = await fetchVoteAcronyms(input.voteUri);
131+
const resolved = resolveGroupAcronym(input.groupAcronym, available);
132+
if (!resolved) {
133+
return { rows: [], columns, hint: buildGroupAcronymHint(input.groupAcronym, available) };
134+
}
135+
filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = "${resolved}")`);
136+
}
46137
if (input.voteType) filters.push(`FILTER(?type = "${input.voteType}")`);
47138

48139
const query = `${OCD_PREFIXES}

0 commit comments

Comments
 (0)