From ab8ad8a064013245db3ac070d21c785a8ebd3ca5 Mon Sep 17 00:00:00 2001 From: aborruso Date: Sun, 26 Jul 2026 08:48:09 +0200 Subject: [PATCH 1/4] docs(news-agent): spec catalogo + allineamento agent Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il catalogo esisteva solo nella skill Pi: l'agent Claude Code non lo leggeva né lo aggiornava. Sei run consecutive (17-25 lug) senza sezione nel catalogo e con le stesse vicende riproposte ogni volta. Porta nell'agent la Fase 0 (lettura + regola di equivalenza) e la Fase 4 (append), con la deroga esplicita alla regola di analisi vergine. Nella spec: naming sezione e formato riga canonico, divieto di annotazioni di copertura, regola di compattazione, verifica eseguibile. Il catalogo è in .gitignore: backfill delle sei sezioni fatto in locale. Co-Authored-By: Claude Opus 5 (1M context) --- .../agents/news-driven-cli-gap-analyzer.md | 33 +++++++- .../specs/2026-07-15-news-catalog-design.md | 76 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-15-news-catalog-design.md diff --git a/.claude/agents/news-driven-cli-gap-analyzer.md b/.claude/agents/news-driven-cli-gap-analyzer.md index 96d4a10..2215393 100644 --- a/.claude/agents/news-driven-cli-gap-analyzer.md +++ b/.claude/agents/news-driven-cli-gap-analyzer.md @@ -12,7 +12,14 @@ You are a Parliamentary Data Coverage Analyst specialized in bridging real journ - Simplicity above all: minimal, targeted CLI invocations that map directly to each news item. - Fix root causes, never symptoms: when a gap emerges, describe the underlying capability missing, not a workaround. - Be concise and high-signal. Brevity over grammar. -- Every run is a fresh, virgin analysis: do NOT read, reference, or compare against previous notes in `docs/news-agent/` or any other prior report. Do not carry over conclusions from past runs — test everything from scratch on the current CLI state. +- Every run is a fresh, virgin analysis **with respect to CLI findings**: do NOT read, reference, or compare against previous notes in `docs/news-agent/` or any other prior report. The **sole** historical memory allowed is `docs/news-agent/catalog.md`, and only to avoid re-selecting parliamentary stories already tested. Never carry over strengths, weaknesses, commands, or conclusions from past runs — test the selected stories from scratch on the current CLI state. + +## Phase 0 — Previously Tested Story Catalog +1. Read `docs/news-agent/catalog.md` before finalizing the news selection. Read the entire file, continuing with an offset if the tool truncates it. Do NOT open the reports the catalog is named after. +2. Deduplicate by **parliamentary story**, not URL. Two articles are the same story when they concern the same act or parliamentary topic, the same concrete stage or event (for example confidence vote, final vote, question time, committee hearing), and the same substantive date or sitting. A different publisher, headline, or URL does not make the story new. +3. Treat a genuinely different stage of the same bill as a new story: presentation, committee approval, confidence vote, and final approval can be distinct stories when they happened as distinct parliamentary events. +4. Use the catalog **only as an exclusion list** during discovery. It is not evidence about current CLI coverage and must not influence the outcome of the tests. +5. If the catalog is missing, create it with a short explanation and continue; do not reconstruct it by reading prior reports during a normal analysis run. ## Phase 1 — News Discovery (Exa MCP) 1. Use the Exa MCP tools to search for high-interest news about activities of Camera and Senato. Prefer queries in Italian (e.g. "Camera dei Deputati votazione", "Senato disegno di legge", "question time parlamento", "emendamenti aula", specific hot DDL names). Bias toward stories that plausibly touch structured parliamentary data: votes, bills/DDL, speeches, question time, committee work, sponsors/firmatari, parliamentarian profiles. @@ -20,10 +27,12 @@ You are a Parliamentary Data Coverage Analyst specialized in bridging real journ - **the 2 best current news** (this week/month); - **the 2 best news items from 2025** (e.g. DDL, votes, or question time from that year); - **the 2 best news items from 2020** (e.g. COVID-era decrees, votes, or acts). - "Best" = highest journalistic interest AND strongest structured-data hook (a concrete vote, DDL, speech, firmatario, or profile a journalist would want to verify). Search more candidates than needed in each bucket, then pick the top 2. + "Best" = highest journalistic interest AND strongest structured-data hook (a concrete vote, DDL, speech, firmatario, or profile a journalist would want to verify). Search more candidates than needed in each bucket, **compare them semantically with the catalog, discard the already-tested stories**, then pick the top 2 unseen stories. + If a bucket has fewer than 2 viable unseen stories, perform at least **two additional, distinct Exa query refinements** for that bucket (different topic, chamber, event type, or normative vocabulary). Only after those refinements may you reuse a catalogued story. Prefer a story tested least recently or one that directly exercises a CLI capability changed since its last test, and record the retest reason explicitly. This is a soft fallback, not permission to default to familiar stories. The historical items are deliberate: 2020 falls in **legislature 18** and 2025 in **legislature 19**, so they stress-test whether the CLI reaches back across legislatures (older parliamentarians, past DDL numbering, historical votes/speeches) instead of only serving the current moment. For dated searches use Exa date filters and pin the exact date in the note. When testing these items, remember to pass the correct `--legislature` (18 for 2020, 19 for 2025/current) and check whether tools that default to the current legislature still surface the historical data. -3. For each of the 6 selected items, capture: a one-line summary, the source URL **with the news date (YYYY-MM-DD)**, and the underlying parliamentary data question(s) a journalist would ask to verify/deepen it. +3. For each of the 6 selected items, capture: a one-line summary, the source URL **with the news date (YYYY-MM-DD)**, the catalog status (`nuova` or `ritest`), and the underlying parliamentary data question(s) a journalist would ask to verify/deepen it. 4. Discard purely political-opinion pieces with no verifiable data hook. +5. **Always skip Piano Casa / Decreto Casa items.** Do not select news about the 2026 Piano Casa / DL Casa / decreto-legge 7 maggio 2026, n. 66, even if Exa ranks them highly: the Senato source label contains a known wrong DDL number (`1994` instead of `1944`), so it is not a good signal for general CLI coverage. If such items appear among the strongest current candidates, explicitly replace them with the next-best Exa result that has a concrete structured-data hook. ## Phase 2 — CLI Capability Mapping & Testing 1. **Load the CLI skill first.** Before invoking the CLI, load the `italian-parliament-cli` skill (via the Skill tool) and read it: it documents commands, patterns, and known traps (keyword search must use the formal/normative term, chamber asymmetries, empty-label pitfalls). Use it to shape correct invocations and to avoid reporting false "missing data" gaps caused by wrong search terms. @@ -54,7 +63,7 @@ Write the result to `./docs/news-agent/YYYY-MM-DD_HH-MM.md` (create the `docs/ne Structure the file exactly as: - Title (do NOT start the title with a number) -- `## Notizie analizzate` — bullet list: summary + **date (YYYY-MM-DD) and legislature** + URL + journalist data-question, per item; keep the current / 2025 / 2020 items clearly distinguishable +- `## Notizie analizzate` — bullet list: summary + **date (YYYY-MM-DD) and legislature** + URL + journalist data-question + catalog status (`nuova` or `ritest`), per item; keep the current / 2025 / 2020 items clearly distinguishable. For every `ritest`, include the reason it was unavoidable or specifically useful. - `## Punti di forza` — where the CLI covered the news well, with the specific command(s) that worked; note explicitly whether **historical coverage (2025 leg.19, 2020 leg.18)** held up - `## Punti di debolezza` — coverage gaps, bugs, missing filters, chamber asymmetries, **and any degradation on the historical items** (e.g. tools that only work for the current legislature, missing older data), with evidence. **Escludi le assenze source-side già note (sezione "Limiti noti della fonte", buckets A/B/C): non elencarle qui.** Se una notizia dipende davvero da una di esse, liquidala in una riga ("limite noto della fonte, vedi wiki — non un gap CLI") e passa oltre. - `## Suggerimenti implementativi` — concrete, root-cause implementation proposals (new tool, new filter, fixed field), prioritized, mapped to the news items they unlock. Non proporre di "coprire" ciò che rientra nei buckets A/B/C (per il bucket B indica il tool già esistente). @@ -62,9 +71,25 @@ Structure the file exactly as: Formatting rules: every triple-backtick code block must be preceded by a blank line. Do not reference Claude or any assistant in the document. Keep bullets short and high-signal. +## Phase 4 — Catalog Update +1. After the report has been written successfully, append **one** section to `docs/news-agent/catalog.md` named exactly like the report file, for example `## 2026-07-26_09-30.md`. +2. Add one bullet per selected story, in this exact canonical form, one line each: + +``` +- **nuova|ritest** — YYYY-MM-DD — leg. NN — Camera|Senato|Camera+Senato — concrete stage and event — ref: act number and/or stable URI — URL +``` + + Use the news/event date, not the run date. `ref` holds only **identity** data (act number such as `C.2822` / `S.1951` / `DL 18/2020`, bill URI, vote URI, AIC code) discovered during testing; write `ref: —` when none is available. +3. **Never write coverage annotations in the catalog.** Phrases like "vote missing from LOD", "tool X returned nothing", or any strength/weakness belong to the report only. The catalog carries story identity, nothing else — otherwise the next run inherits conclusions the virgin-analysis rule forbids. +4. Keep the catalog append-only, retests included, so a later run can see when a story was last exercised. +5. Before appending, verify that a section with the same report filename does not already exist. If it does, do not duplicate it. + ## Quality Control - Ground every strength/weakness in an actual command you ran and its observed output. No speculation presented as fact. - If Exa returns weak results, refine queries (synonyms, specific DDL numbers, chamber-specific terms) before proceeding. +- Before testing, verify that every story marked `nuova` is absent from the catalog **at story level**, not merely absent as an exact URL. +- If a story is marked `ritest`, verify that the report states which two search refinements failed to produce a stronger unseen alternative, or which changed CLI capability justifies the regression test. +- The run is not complete until Phase 4 has appended the section to the catalog: a report without its catalog section makes the next run repeat the same stories. - If the CLI cannot be located or built, report that clearly at the top of the note and still deliver the news-driven data-question mapping. - After writing the file, confirm its path back to the user. diff --git a/docs/superpowers/specs/2026-07-15-news-catalog-design.md b/docs/superpowers/specs/2026-07-15-news-catalog-design.md new file mode 100644 index 0000000..e349ddb --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-news-catalog-design.md @@ -0,0 +1,76 @@ +# Catalogo delle notizie già testate + +## Obiettivo + +Evitare che il news-driven CLI gap analyzer selezioni ripetutamente la stessa vicenda parlamentare solo perché Exa propone URL o fonti differenti. + +## Disegno + +- Conservare un catalogo append-only in `docs/news-agent/catalog.md`. +- Il catalogo contiene solo l'identità delle notizie testate e il report di provenienza; non contiene conclusioni sui punti di forza o debolezza della CLI. +- Considerare duplicati due articoli che riguardano lo stesso atto o tema, la stessa fase parlamentare e la stessa data o seduta sostanziale. +- Considerare nuova una fase differente dello stesso atto. +- Preferire sempre vicende non ancora testate. Prima di riusarne una, effettuare almeno due raffinamenti di ricerca nel relativo bucket. +- Consentire il riuso quando non esistono alternative sufficienti o quando è necessario un test di regressione mirato; motivarlo nel report. +- Dopo ogni run, aggiungere al catalogo le sei notizie selezionate. + +## Formato + +Ogni sezione si chiama esattamente come il file del report che l'ha prodotta, estensione compresa: `## 2026-07-26_09-30.md`. Nessun'altra forma è ammessa: una sezione con un nome diverso rende impossibile verificare che ogni report abbia lasciato traccia. + +Ogni vicenda occupa una riga sola: + +``` +- **nuova|ritest** — YYYY-MM-DD — leg. NN — Camera|Senato|Camera+Senato — fase concreta ed evento — ref: numero atto e/o URI stabile — URL +``` + +La data è quella dell'evento parlamentare, non quella della run. + +Il campo `ref` accoglie solo dati di identità: numero d'atto (`C.2822`, `S.1951`, `DL 18/2020`), URI del ddl, URI della votazione, codice AIC. Quando non se ne è trovato nessuno si scrive `ref: —`. + +Lo status `backfill` marca le sezioni ricostruite a posteriori, dove lo status originale `nuova`/`ritest` non è più ricostruibile. + +## Confine con la regola di analisi vergine + +I riferimenti stabili sono identità e vanno registrati: servono proprio a riconoscere la stessa vicenda sotto titoli diversi. + +Le annotazioni di copertura no. Frasi come "voto assente dal LOD Camera" o "il tool non ha restituito nulla" sono esiti di test: appartengono al report e nel catalogo diventerebbero esattamente la contaminazione che la regola di analisi vergine vieta. Il divieto è esplicito nella fase di aggiornamento del catalogo, non implicito. + +## Crescita + +Il catalogo viene letto per intero a ogni run, quindi il suo costo cresce linearmente con il numero di run. Sei vicende al giorno diventano oltre duemila righe in un anno. + +Regola: superate le 400 righe, le sezioni più vecchie di 90 giorni confluiscono in un'unica sezione `## Archivio fino a YYYY-MM-DD`, con una riga per vicenda **distinta** — le ripetizioni della stessa vicenda su più run collassano in una riga sola, che conserva la data dell'ultimo test. Si perde l'associazione vicenda→report, che serve solo a scegliere quale ritestare per prima; si conserva l'identità, che è ciò su cui si fa dedup. + +## Flusso + +1. Leggere il catalogo, senza leggere i report precedenti. +2. Cercare e classificare più candidati per ciascun bucket temporale. +3. Confrontare semanticamente i candidati con il catalogo. +4. Selezionare due vicende nuove per bucket quando possibile. +5. Eseguire i test e scrivere il report. +6. Appendere al catalogo una sezione riferita al nuovo report. + +Il passo 6 è parte della run, non un'appendice facoltativa: un report senza la sua sezione fa ripetere le stesse vicende alla run successiva. + +## Implementazioni + +Il flusso vive in due copie che vanno tenute allineate: + +- `.agents/skills/news-driven-cli-gap-analyzer/SKILL.md` (skill Pi); +- `.claude/agents/news-driven-cli-gap-analyzer.md` (agent Claude Code). + +Una modifica applicata a una sola delle due produce run che ignorano il catalogo pur avendo il catalogo sul disco: è successo tra il 17 e il 25 luglio 2026, con sei run consecutive senza sezione e le stesse vicende riproposte ogni volta. + +## Verifica + +- Ogni file di report in `docs/news-agent/` ha una sezione omonima nel catalogo: + `for f in docs/news-agent/2*.md; do grep -q "^## $(basename $f)$" docs/news-agent/catalog.md || echo "MANCA: $f"; done` +- Le due implementazioni contengono entrambe la fase di lettura e quella di aggiornamento del catalogo. +- La skill distingue un URL nuovo da una vicenda nuova. +- La regola di analisi vergine continua a vietare la lettura delle conclusioni pregresse e il catalogo non contiene annotazioni di copertura. +- Un riuso è esplicitamente riconoscibile nel report, con la motivazione. + +## Aggiornamenti + +- 2026-07-26: normalizzati naming delle sezioni e formato riga; esplicitato il divieto di annotazioni di copertura; aggiunta la regola di crescita; allineato l'agent Claude Code alla skill Pi; ricostruite le sei sezioni mancanti (17, 19, 20, 21, 23, 25 luglio). From ea600c1f6d49cd633f493c44cbda1ee225583557 Mon Sep 17 00:00:00 2001 From: aborruso Date: Sun, 26 Jul 2026 09:26:03 +0200 Subject: [PATCH 2/4] fix(vote-detail): risolvi --group-acronym contro le sigle reali del voto (#77) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/tools/vote-detail.test.ts | 75 +++++++++++++++++++++++++++ src/tools/vote-detail.ts | 95 ++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/tools/vote-detail.test.ts diff --git a/src/tools/vote-detail.test.ts b/src/tools/vote-detail.test.ts new file mode 100644 index 0000000..fa59f6b --- /dev/null +++ b/src/tools/vote-detail.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { resolveGroupAcronym, buildGroupAcronymHint } from "./vote-detail.js"; + +// Sigle realmente presenti in una votazione di leg. 19 (vs19_456_018). +const AVAILABLE = [ + "FDI", + "M5S", + "LEGA", + "M-ALT", + "M-MIN", + "PD-IDP", + "FI-PPE", + "AVS", + "M-+EUR", + "APERRE", + "IVICRE", + "NM-M-C", +]; + +describe("resolveGroupAcronym", () => { + it("match esatto", () => { + expect(resolveGroupAcronym("FDI", AVAILABLE)).toBe("FDI"); + expect(resolveGroupAcronym("FI-PPE", AVAILABLE)).toBe("FI-PPE"); + }); + + it("ignora maiuscole/minuscole", () => { + expect(resolveGroupAcronym("fdi", AVAILABLE)).toBe("FDI"); + expect(resolveGroupAcronym("Pd-Idp", AVAILABLE)).toBe("PD-IDP"); + }); + + it("ignora la punteggiatura", () => { + expect(resolveGroupAcronym("PD IDP", AVAILABLE)).toBe("PD-IDP"); + expect(resolveGroupAcronym("nmmc", AVAILABLE)).toBe("NM-M-C"); + }); + + it("null sulle sigle di groups list non derivabili da una regola", () => { + // Sono i casi che motivano l'hint: nessuna normalizzazione le riconcilia. + expect(resolveGroupAcronym("AZ-PER-RE", AVAILABLE)).toBeNull(); + expect(resolveGroupAcronym("IV-CR", AVAILABLE)).toBeNull(); + expect(resolveGroupAcronym("MISTO", AVAILABLE)).toBeNull(); + }); + + it("null su sigla inesistente", () => { + expect(resolveGroupAcronym("XYZ", AVAILABLE)).toBeNull(); + }); +}); + +describe("buildGroupAcronymHint", () => { + it("suggerisce la sigla più simile e elenca quelle presenti", () => { + const h = buildGroupAcronymHint("AZ-PER-RE", AVAILABLE); + expect(h).toContain('Nessun gruppo con sigla "AZ-PER-RE"'); + expect(h).toContain('Forse cercavi "APERRE"'); + expect(h).toContain("IVICRE"); + }); + + it("suggerisce IVICRE per IV-CR", () => { + expect(buildGroupAcronymHint("IV-CR", AVAILABLE)).toContain('Forse cercavi "IVICRE"'); + }); + + it("spiega che il Misto è disaggregato", () => { + const h = buildGroupAcronymHint("MISTO", AVAILABLE); + expect(h).toContain("Misto"); + expect(h).toContain("M-ALT"); + // Nessuna componente somiglia a "MISTO": meglio nessun suggerimento che uno a caso. + expect(h).not.toContain("Forse cercavi"); + }); + + it("non suggerisce nulla su una sigla senza affinità", () => { + expect(buildGroupAcronymHint("XYZ", AVAILABLE)).not.toContain("Forse cercavi"); + }); + + it("votazione senza sigle: dice che il filtro non è applicabile", () => { + expect(buildGroupAcronymHint("FDI", [])).toContain("non è applicabile"); + }); +}); diff --git a/src/tools/vote-detail.ts b/src/tools/vote-detail.ts index 135ac6e..03f774a 100644 --- a/src/tools/vote-detail.ts +++ b/src/tools/vote-detail.ts @@ -13,7 +13,9 @@ const inputSchema = z.object({ groupAcronym: z .string() .optional() - .describe("Filtra per sigla gruppo (es. 'FDI', 'PD-IDP', 'M5S'). Case-sensitive."), + .describe( + "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.", + ), voteType: z .enum(["Favorevole", "Contrario", "Astensione", "Non ha votato"]) .optional() @@ -29,6 +31,84 @@ function stripLegLabel(label: string): string { const columns = ["deputy_uri", "deputy_name", "vote", "group_uri", "group_acronym", "html_url"]; +// Le sigle di `ocd:siglaGruppo` (dataset votazioni) non coincidono con +// l'acronym di `groups list`: AZ-PER-RE → APERRE, IV-CR → IVICRE, e il Misto è +// disaggregato per componente. Il confronto ignora quindi maiuscole e +// punteggiatura, ma le differenze restanti non sono derivabili da una regola: +// per quelle si mostra l'elenco delle sigle realmente presenti nel voto. +function normalizeAcronym(s: string): string { + return s.toUpperCase().replace(/[^A-Z0-9]/g, ""); +} + +/** Sigla del dataset corrispondente a quella richiesta, o null se nessuna. */ +export function resolveGroupAcronym(requested: string, available: string[]): string | null { + const exact = available.find((a) => a === requested); + if (exact) return exact; + const ci = available.find((a) => a.toUpperCase() === requested.toUpperCase()); + if (ci) return ci; + const norm = normalizeAcronym(requested); + return available.find((a) => normalizeAcronym(a) === norm) ?? null; +} + +/** Somiglianza di Dice sui bigrammi: ordina i suggerimenti, non decide il match. */ +function similarity(a: string, b: string): number { + const bigrams = (s: string) => { + const out: string[] = []; + for (let i = 0; i < s.length - 1; i++) out.push(s.slice(i, i + 2)); + return out; + }; + const A = bigrams(a); + const B = bigrams(b); + if (A.length === 0 || B.length === 0) return a === b ? 1 : 0; + const pool = [...B]; + let hits = 0; + for (const g of A) { + const i = pool.indexOf(g); + if (i >= 0) { + hits++; + pool.splice(i, 1); + } + } + return (2 * hits) / (A.length + B.length); +} + +/** Nota mostrata quando la sigla richiesta non esiste in quella votazione. */ +export function buildGroupAcronymHint(requested: string, available: string[]): string { + if (available.length === 0) { + return `Nessuna sigla di gruppo registrata in questa votazione: il filtro --group-acronym non è applicabile.`; + } + const norm = normalizeAcronym(requested); + const ranked = [...available].sort( + (x, y) => similarity(norm, normalizeAcronym(y)) - similarity(norm, normalizeAcronym(x)), + ); + const best = ranked[0]; + const parts = [ + `Nessun gruppo con sigla "${requested}" in questa votazione.`, + `Le sigle del dataset votazioni non coincidono sempre con l'acronym di 'groups list' (es. AZ-PER-RE → APERRE, IV-CR → IVICRE).`, + ]; + if (similarity(norm, normalizeAcronym(best)) >= 0.4) { + parts.push(`Forse cercavi "${best}".`); + } + parts.push(`Sigle presenti in questa votazione: ${ranked.join(", ")}.`); + if (available.some((a) => /^M-/.test(a))) { + parts.push(`Il gruppo Misto è disaggregato nelle sue componenti (le sigle "M-…"): non esiste una riga "MISTO".`); + } + return parts.join(" "); +} + +/** Sigle di gruppo effettivamente presenti nella votazione (query leggera). */ +async function fetchVoteAcronyms(voteUri: string): Promise { + const query = `${OCD_PREFIXES} +SELECT DISTINCT ?siglaGruppo +WHERE { + ?v a ocd:voto . + ?v ocd:rif_votazione <${voteUri}> . + ?v ocd:siglaGruppo ?siglaGruppo . +}`; + const raw = flattenBindings(await cdQuery(query)); + return raw.map((r) => r.siglaGruppo ?? "").filter((s) => s !== ""); +} + export const voteDetailTool: Tool = { name: "vote-detail", description: @@ -39,10 +119,21 @@ export const voteDetailTool: Tool = { "italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs18_100_005 --format jsonl", "italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs19_010_003 --limit 50", "italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs19_641_046 --group-acronym FDI --vote-type Contrario", + "italianparliament vote-detail show --vote-uri http://dati.camera.it/ocd/votazione.rdf/vs19_456_018 --group-acronym APERRE", ], async execute(input) { const filters: string[] = []; - if (input.groupAcronym) filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = "${input.groupAcronym}")`); + if (input.groupAcronym) { + // La sigla richiesta va risolta contro quelle realmente presenti nel voto, + // altrimenti un FILTER su una sigla inesistente restituirebbe zero righe + // senza dire perché (issue #77). + const available = await fetchVoteAcronyms(input.voteUri); + const resolved = resolveGroupAcronym(input.groupAcronym, available); + if (!resolved) { + return { rows: [], columns, hint: buildGroupAcronymHint(input.groupAcronym, available) }; + } + filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = "${resolved}")`); + } if (input.voteType) filters.push(`FILTER(?type = "${input.voteType}")`); const query = `${OCD_PREFIXES} From 8ff8365fc6522522f1c36ca0039701e46a705d7f Mon Sep 17 00:00:00 2001 From: aborruso Date: Sun, 26 Jul 2026 09:29:49 +0200 Subject: [PATCH 3/4] refactor(vote-detail): togli gli esempi di sigla dai testi utente + LOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I messaggi citavano AZ-PER-RE → APERRE e la forma "M-*" delle componenti Misto: sono fatti di oggi sulla fonte, che diventerebbero testo sbagliato e branch morta se la Camera allinea le sigle. L'hint mantiene il valore diagnostico senza codificarli, perché nomina già il candidato reale e elenca le sigle restituite dalla query; la nota sul Misto ora dipende dalla stringa richiesta, non dalla forma dei dati. Co-Authored-By: Claude Opus 5 (1M context) --- LOG.md | 8 ++++++++ src/tools/vote-detail.ts | 21 ++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/LOG.md b/LOG.md index e1f41b3..0cc2106 100644 --- a/LOG.md +++ b/LOG.md @@ -1,5 +1,13 @@ # LOG +## 2026-07-26 + +- **fix `vote-detail` — `--group-acronym` non restituisce più zero righe in silenzio** (#77, PR #80) — la sigla passata dall'utente finiva dritta in un `FILTER(STR(?_sg) = "…")`: se non esisteva nel dataset votazioni il risultato era vuoto, con exit 0 e nessun avviso, indistinguibile da "il gruppo non ha votato". E le sigle divergono per davvero: `groups list` espone `AZ-PER-RE` e `IV-CR`, i voti della stessa legislatura portano `APERRE` e `IVICRE`, e il Misto lato voto è disaggregato in `M-ALT`/`M-MIN`/`M-+EUR`. Seguendo il flusso documentato (cerca la sigla in `groups`, poi filtra) si concludeva il falso. Nemmeno `group_uri` era una chiave utilizzabile: per Azione è `gr4135` nella risorsa gruppo e `gr4212` sui voti, e le componenti del Misto ce l'hanno vuoto. Ora, quando `--group-acronym` è valorizzato, una query leggera recupera le sigle **realmente presenti in quella votazione** e la richiesta viene risolta contro quelle (esatta → case-insensitive → senza punteggiatura); se risolve, la query principale è invariata, quindi performance e semantica del `LIMIT` non cambiano. Se non risolve: righe vuote più una nota (stderr in CLI, testo nell'MCP) con l'elenco delle sigle presenti, la più simile in testa (Dice sui bigrammi, soglia 0,4) e la spiegazione per il Misto. Nessun alias `AZ-PER-RE → APERRE` nel codice: il disallineamento è della fonte e si romperebbe al primo cambio di denominazione — il codice interroga il dato e rende visibile il problema, non lo compensa. +10 test sulle due funzioni pure, 199/199 verdi. Emerso dal report news-agent `2026-07-26_08-58.md`. **Residuo noto**: le sigle sono raccolte senza applicare `--vote-type`, quindi una sigla valida ma senza voti di quel tipo resta un vuoto senza nota — comportamento corretto (il gruppo ha votato, non in quel modo), fuori dallo scopo di #77. + +- **segnalata a monte la radice del problema** — punto 9 della nota `docs/note-gestori-lod/camera-assistenza-dati.md`: sigla della risorsa gruppo diversa da quella dei voti (`gr4211`: `IV-CR` nella label, `IVICRE` in `ocd:siglaGruppo`), URI referenziati dalle votazioni ma mai descritti (`gr4212` con 160.881 voti e `gr4234`: zero triple come soggetto), denominazione storica sovrascritta (`gr4135` ha oggi la label `AZ-PER-RE` ma i suoi 88.475 voti portano `AIV-RE`), componenti del Misto senza URI, e nessuna proprietà "sigla" sulla risorsa gruppo — l'acronimo è estraibile solo dalla label, che per giunta arriva troncata (punto 4 della stessa nota). Il fenomeno è generale e vale per entrambe le camere, quindi è anche scheda **B5** del dossier di campagna (`docs/campagna-parlamento-aperto/02-dossier-tecnico.md`) con riga in tabella di sintesi: al Senato il legame voto→gruppo non esiste proprio (la votazione punta ai senatori con `osr:favorevole`/`osr:contrario`/`osr:astenuto`, nessuna proprietà di gruppo) e va ricostruito dalle adesioni valide alla data. + +- **gap analysis news-driven** (`docs/news-agent/2026-07-26_08-58.md`) — 6 notizie mai testate prima (Giunta autorizzazioni/chat Delmastro, riforma forense al Senato, mozioni di sfiducia Santanchè e Nordio, DL 130/2020 immigrazione e DL 137/2020 Ristori). Copertura piena su 5 storie su 6, storico leg. 18 lato Camera senza degrado. Tre issue aperte con triage: #77 (sopra), #78 (`which` non trova "giunta per le autorizzazioni" e instrada "immunità parlamentare" verso `search find`, un falso positivo peggiore di un no-match), #79 (`confidence_vote=true` marca anche le mozioni di sfiducia individuale ex art. 115: è `ocd:richiestaFiducia` della fonte, non un valore derivato, quindi `documentation` + `gap-dataset`). Un quarto rilievo del report — quoting CSV rotto in `groups list` — **non è stato aperto**: verificato con un parser CSV vero su leg. 17, 18 e 19, tutte le righe hanno 6 colonne e il campo con le virgole è quotato correttamente. Era un artefatto del `cut -d,` usato per leggere l'output, non un difetto. + ## 2026-07-25 - **v0.26.2** — release patch. Due fix e la misura del limite di dimensione delle richieste al Senato (2047 byte accettati, 2048 rifiutati), che spiega e chiude una classe di 403 finora attribuita al blocco anti-burst. PR #75 e #76, mergiate via squash. 43 tool invariati, 189/189 test verdi. diff --git a/src/tools/vote-detail.ts b/src/tools/vote-detail.ts index 03f774a..963173b 100644 --- a/src/tools/vote-detail.ts +++ b/src/tools/vote-detail.ts @@ -14,7 +14,7 @@ const inputSchema = z.object({ .string() .optional() .describe( - "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.", + "Filtra per sigla gruppo (es. 'FDI', 'M5S'). Le sigle sono quelle registrate sui voti, che non sempre coincidono con l'acronym di `groups list`, e il gruppo Misto può comparire disaggregato nelle sue componenti. Maiuscole/minuscole e punteggiatura sono irrilevanti; se la sigla non esiste in quella votazione il risultato è vuoto e l'elenco delle sigle presenti viene restituito come nota.", ), voteType: z .enum(["Favorevole", "Contrario", "Astensione", "Non ha votato"]) @@ -31,11 +31,12 @@ function stripLegLabel(label: string): string { const columns = ["deputy_uri", "deputy_name", "vote", "group_uri", "group_acronym", "html_url"]; -// Le sigle di `ocd:siglaGruppo` (dataset votazioni) non coincidono con -// l'acronym di `groups list`: AZ-PER-RE → APERRE, IV-CR → IVICRE, e il Misto è -// disaggregato per componente. Il confronto ignora quindi maiuscole e -// punteggiatura, ma le differenze restanti non sono derivabili da una regola: -// per quelle si mostra l'elenco delle sigle realmente presenti nel voto. +// Le sigle di `ocd:siglaGruppo` (sui voti) non coincidono con l'acronym di +// `groups list`: al 26/7/2026, in leg. 19, AZ-PER-RE → APERRE, IV-CR → IVICRE, +// e il Misto è disaggregato per componente (issue #77, segnalato al gestore del +// dato). Il confronto ignora quindi maiuscole e punteggiatura, ma le differenze +// restanti non sono derivabili da una regola: per quelle si mostra l'elenco +// delle sigle realmente presenti nel voto, senza codificarne nessuna qui. function normalizeAcronym(s: string): string { return s.toUpperCase().replace(/[^A-Z0-9]/g, ""); } @@ -84,14 +85,16 @@ export function buildGroupAcronymHint(requested: string, available: string[]): s const best = ranked[0]; const parts = [ `Nessun gruppo con sigla "${requested}" in questa votazione.`, - `Le sigle del dataset votazioni non coincidono sempre con l'acronym di 'groups list' (es. AZ-PER-RE → APERRE, IV-CR → IVICRE).`, + `Le sigle registrate sui voti non coincidono sempre con l'acronym di 'groups list'.`, ]; if (similarity(norm, normalizeAcronym(best)) >= 0.4) { parts.push(`Forse cercavi "${best}".`); } parts.push(`Sigle presenti in questa votazione: ${ranked.join(", ")}.`); - if (available.some((a) => /^M-/.test(a))) { - parts.push(`Il gruppo Misto è disaggregato nelle sue componenti (le sigle "M-…"): non esiste una riga "MISTO".`); + // Condizione sulla richiesta, non sulla forma delle sigle disponibili: è un + // fatto su cosa ha chiesto l'utente, non un'assunzione sui dati della fonte. + if (norm === "MISTO") { + parts.push(`Il gruppo Misto può comparire disaggregato nelle sue componenti: cercale nell'elenco qui sopra.`); } return parts.join(" "); } From bc6949057efd3b1338e564c6d6d6db4f56d0fd64 Mon Sep 17 00:00:00 2001 From: aborruso Date: Sun, 26 Jul 2026 09:35:01 +0200 Subject: [PATCH 4/4] fix(vote-detail): escape del letterale SPARQL + test sul path execute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Da review PR #80: - la sigla risolta finiva interpolata cruda nel FILTER: ora passa per sparqlStringLiteral, estratto in src/core (era duplicato in bill-progress) con i suoi test. Il valore viene dal grafo, non dall'utente, ma una virgoletta nel dato romperebbe comunque la query. - l'hint sul caso "nessuna sigla registrata" citava il flag --group-acronym, che per un client MCP non significa nulla: formulazione neutra. - due test di integrazione su voteDetailTool.execute: match risolto (sigla minuscola → righe, nessun hint) e sigla di groups list → vuoto con hint. Co-Authored-By: Claude Opus 5 (1M context) --- src/core/sparql-literal.test.ts | 24 ++++++++++++++++++++++++ src/core/sparql-literal.ts | 20 ++++++++++++++++++++ src/tools/bill-progress.ts | 15 +-------------- src/tools/tools.test.ts | 24 ++++++++++++++++++++++++ src/tools/vote-detail.ts | 5 +++-- 5 files changed, 72 insertions(+), 16 deletions(-) create mode 100644 src/core/sparql-literal.test.ts create mode 100644 src/core/sparql-literal.ts diff --git a/src/core/sparql-literal.test.ts b/src/core/sparql-literal.test.ts new file mode 100644 index 0000000..1a8952a --- /dev/null +++ b/src/core/sparql-literal.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { sparqlStringLiteral } from "./sparql-literal.js"; + +describe("sparqlStringLiteral", () => { + it("avvolge il valore tra virgolette", () => { + expect(sparqlStringLiteral("FDI")).toBe('"FDI"'); + }); + + it("scappa le virgolette interne", () => { + expect(sparqlStringLiteral('AL"T')).toBe('"AL\\"T"'); + }); + + it("scappa i backslash", () => { + expect(sparqlStringLiteral("A\\B")).toBe('"A\\\\B"'); + }); + + it("scappa i ritorni a capo", () => { + expect(sparqlStringLiteral("a\nb\rc")).toBe('"a\\nb\\rc"'); + }); + + it("lascia intatti gli altri caratteri, accenti compresi", () => { + expect(sparqlStringLiteral("M-+EUR è così")).toBe('"M-+EUR è così"'); + }); +}); diff --git a/src/core/sparql-literal.ts b/src/core/sparql-literal.ts new file mode 100644 index 0000000..f91ddc4 --- /dev/null +++ b/src/core/sparql-literal.ts @@ -0,0 +1,20 @@ +// Escapa solo i caratteri che la grammatica STRING_LITERAL2 vieta (\, ", +// newline, CR). Non usare JSON.stringify: emette \uXXXX per i caratteri di +// controllo, sequenza che alcuni parser SPARQL rifiutano nel corpo del +// letterale (o cercano un valore diverso), rompendo il filtro invece di fare +// il match atteso. +const SPARQL_ESC: Record = { + "\\": "\\\\", + '"': '\\"', + "\n": "\\n", + "\r": "\\r", +}; + +/** + * Serializza un valore come string literal SPARQL, virgolette comprese. + * Serve ovunque un valore finisca dentro una query: anche i valori che + * arrivano dal grafo (etichette, sigle) possono contenere virgolette o + * backslash e renderebbero la query invalida. + */ +export const sparqlStringLiteral = (value: string): string => + `"${value.replace(/[\\"\n\r]/g, (c) => SPARQL_ESC[c] ?? c)}"`; diff --git a/src/tools/bill-progress.ts b/src/tools/bill-progress.ts index 1cdedb9..9bc72ea 100644 --- a/src/tools/bill-progress.ts +++ b/src/tools/bill-progress.ts @@ -6,22 +6,9 @@ import { decodeHtml } from "../core/decode-html.js"; import { htmlEntityKeywordVariants } from "../core/html-entity-variants.js"; import { ddlRssUrl } from "../core/html-url.js"; import { currentLegislature } from "../core/current-legislature.js"; +import { sparqlStringLiteral } from "../core/sparql-literal.js"; import type { Tool } from "./types.js"; -// Costruisce un letterale-stringa SPARQL valido escapando solo i caratteri che -// la grammatica STRING_LITERAL2 vieta (\, ", newline, CR). Non usare -// JSON.stringify: emette \uXXXX per i caratteri di controllo, sequenza che -// alcuni parser SPARQL rifiutano nel corpo del letterale (o cercano un valore -// diverso), rompendo il filtro keyword invece di fare il match atteso. -const SPARQL_ESC: Record = { - "\\": "\\\\", - '"': '\\"', - "\n": "\\n", - "\r": "\\r", -}; -const sparqlStringLiteral = (value: string): string => - `"${value.replace(/[\\"\n\r]/g, (c) => SPARQL_ESC[c] ?? c)}"`; - const inputSchema = z.object({ ddlUri: z .string() diff --git a/src/tools/tools.test.ts b/src/tools/tools.test.ts index 56dd08d..d9ff231 100644 --- a/src/tools/tools.test.ts +++ b/src/tools/tools.test.ts @@ -452,6 +452,30 @@ describe("Camera tools", () => { expect(result.rows[0]).toHaveProperty("group_acronym"); }, 30000); + it("vote-detail: --group-acronym resolves against the sigle present in the vote (#77)", async () => { + // Sigla scritta in minuscolo: deve risolvere e filtrare, non restare vuota. + const result = await voteDetailTool.execute({ + voteUri: "http://dati.camera.it/ocd/votazione.rdf/vs19_456_018", + groupAcronym: "fdi", + limit: 700, + }); + expect(result.rows.length).toBeGreaterThan(0); + expect(new Set(result.rows.map((r) => r.group_acronym))).toEqual(new Set(["FDI"])); + expect(result.hint).toBeUndefined(); + }, 30000); + + it("vote-detail: sigla di groups list non presente nel voto → vuoto con hint, non silenzio (#77)", async () => { + const result = await voteDetailTool.execute({ + voteUri: "http://dati.camera.it/ocd/votazione.rdf/vs19_456_018", + groupAcronym: "AZ-PER-RE", + limit: 700, + }); + expect(result.rows.length).toBe(0); + expect(result.hint).toContain("AZ-PER-RE"); + // L'elenco viene dalla query, non da una lista scritta nel codice. + expect(result.hint).toContain("APERRE"); + }, 30000); + it("group-members: returns members for legislature 19", async () => { const result = await groupMembersTool.execute({ legislature: 19, limit: 3, offset: 0 }); expect(result.rows.length).toBe(3); diff --git a/src/tools/vote-detail.ts b/src/tools/vote-detail.ts index 963173b..c25a4ff 100644 --- a/src/tools/vote-detail.ts +++ b/src/tools/vote-detail.ts @@ -3,6 +3,7 @@ import { cdQuery } from "../core/client.js"; import { OCD_PREFIXES } from "../core/prefixes.js"; import { flattenBindings } from "../core/flatten.js"; import { personHtmlUrl } from "../core/html-url.js"; +import { sparqlStringLiteral } from "../core/sparql-literal.js"; import type { Tool } from "./types.js"; const inputSchema = z.object({ @@ -76,7 +77,7 @@ function similarity(a: string, b: string): number { /** Nota mostrata quando la sigla richiesta non esiste in quella votazione. */ export function buildGroupAcronymHint(requested: string, available: string[]): string { if (available.length === 0) { - return `Nessuna sigla di gruppo registrata in questa votazione: il filtro --group-acronym non è applicabile.`; + return `Nessuna sigla di gruppo registrata in questa votazione: il filtro per gruppo non è applicabile.`; } const norm = normalizeAcronym(requested); const ranked = [...available].sort( @@ -135,7 +136,7 @@ export const voteDetailTool: Tool = { if (!resolved) { return { rows: [], columns, hint: buildGroupAcronymHint(input.groupAcronym, available) }; } - filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = "${resolved}")`); + filters.push(`?v ocd:siglaGruppo ?_sg . FILTER(STR(?_sg) = ${sparqlStringLiteral(resolved)})`); } if (input.voteType) filters.push(`FILTER(?type = "${input.voteType}")`);