Come ho costruito un motore di analisi dei discorsi parlamentari italiani
Vedi il sito qui. Nota che sto ancora lavorando sul frontend, quindi alcune delle analytics non sono ancora disponibili.
Ogni volta che guardo un dibattito parlamentare mi faccio le stesse domande. Questo parla sempre degli stessi tre argomenti o cambia bandiera col vento? Il suo gruppo è compatto o dentro ci sono cinque posizioni diverse? Quanto è davvero distante il suo linguaggio da quello dell'opposizione?
Non sono domande a cui si risponde guardando un discorso. Servono migliaia di discorsi, e serve qualcosa che li legga al posto tuo.
Il progetto si chiama Parliament Speech Analyzer (fantasia al potere) e fa esattamente questo: prende l'elenco ufficiale delle sedute d'Assemblea, scarica i resoconti stenografici, li trasforma in vettori, li assegna a temi, calcola una novantina di metriche e sputa fuori un payload JSON che un frontend React trasforma in mappe e classifiche.
Questo articolo racconta come è fatto dentro. Le scelte architetturali, gli algoritmi, e soprattutto i punti in cui il dominio è più ostile di quanto sembri.
I dati esistono. Sono anche pubblici. Non sono comodi.
Camera e Senato pubblicano i resoconti stenografici integrali di ogni seduta. Ogni parola pronunciata in Aula è lì, gratis, senza registrazione. Il problema è che sono documenti pensati per essere letti da un umano interessato a una singola giornata, non elaborati da una macchina interessata a quindici mesi.
La distinzione che regge tutto il livello di ingestione è questa: quali sedute esistono e cosa è stato detto sono due domande diverse, e vanno a due fonti diverse.
La prima ha una risposta ufficiale e strutturata. Entrambe le camere pubblicano il calendario dei lavori e l'anagrafe dei parlamentari come linked data, con endpoint SPARQL aperti:
PREFIX osr: <http://dati.senato.it/osr/>
SELECT ?seduta ?data ?numero WHERE {
?seduta a osr:SedutaAssemblea ;
osr:legislatura 19 ;
osr:dataSeduta ?data ;
osr:numeroSeduta ?numero .
FILTER (?data >= "2025-05-01"^^xsd:date)
}Da dati.camera.it e dati.senato.it arrivano l'elenco completo delle sedute della XIX legislatura e i registri dei membri. Dai siti delle camere arriva solo il testo, che nei dati aperti non c'è.
Il linked data è meraviglioso finché non ci lavori. Tre cose che mi sono costate un pomeriggio ciascuna e che ora stanno scritte nei commenti di backend/ingestion/sparql.py, perché nessuno debba ritrovarle: dati.senato.it risponde 403 se non gli mandi uno User-Agent che sembri un browser; osr:legislatura è un xsd:integer, quindi filtrare sulla stringa "19" non matcha niente e non se ne lamenta nessuno; su Camera gli URI seduta.rdf/s19_<n> sono le sedute d'Assemblea, mentre i BF_19_* sono bollettini e se li includi ti ritrovi con un corpus pieno di roba che non è mai stata pronunciata in Aula.
Partire dall'elenco ufficiale ha una conseguenza che vale più di tutto il resto: so quante sedute dovrebbero esserci. Il che significa che posso misurare quante ne ho prese.
L'architettura
Il backend gira in locale o su Colab, produce file JSON statici, il frontend li consuma senza che ci sia un'API in mezzo. È una scelta deliberata: i resoconti parlamentari non cambiano in tempo reale, quindi non c'è niente da servire dinamicamente. Il frontend sta su una CDN qualsiasi, non c'è un endpoint da versionare, e per testare un caso limite dell'interfaccia mi basta scrivere a mano un JSON finto.
Il crawler, ovvero il pezzo che deve saper dire di no
Tutto ciò che non è parsing specifico di una camera sta in un solo posto: concorrenza, rate limiting, cache per singola seduta, contabilità della copertura. Camera e Senato lo ereditano gratis.
Due proprietà che ho voluto esplicitamente.
È ripartibile. I discorsi parsati vengono messi in cache seduta per seduta, non a fine corsa. Una run che muore alla seduta 400 su 443 costa 43 fetch per finire, non 443. Il primo passaggio del crawler è sempre sulla cache, quindi una ripresa non tocca la rete per ciò che ha già.
È contabile. Il CrawlReport dice, a fine run: quante sedute risultano note dai dati aperti, quante ne ha scaricate, quante venivano dalla cache, quante ne ha parsate con successo, quanti discorsi ne sono usciti, quante sono state bloccate, quante sono fallite, e la copertura in percentuale.
Serve perché il fallimento interessante in questo dominio non è l'eccezione, è la risposta educata. senato.it sta dietro una challenge JavaScript di CloudFront: a un client automatico risponde HTTP 202 con due kilobyte di interstiziale che spiegano, in inglese, che bisogna verificare di non essere un robot. È una risposta 2xx con dentro HTML valido. Un parser ingenuo la riceve, cerca dei discorsi, non ne trova, e restituisce con serenità una lista vuota.
Quindi le challenge vengono riconosciute e sollevate come ChallengeBlocked, mai inghiottite:
CHALLENGE_MARKERS = (
'challenge-container',
"verify that you're not a robot",
'JavaScript is disabled',
'Checking your browser',
)
CHALLENGE_MAX_BYTES = 8_000 # un resoconto vero pesa decine di KBSopra c'è un ResilientTransport che prova prima l'HTTP normale e passa a un browser headless (Playwright, dipendenza opzionale) soltanto quando gli viene rifiutato l'accesso. Il caso comune resta economico, la strada costosa si accende esattamente quando serve. Se Playwright non è installato la run non si spacca: riporta quante sedute sono rimaste fuori, e tu sai perché il Senato ha meno dati della Camera.
C'è un comando che serve solo a questo, da lanciare prima di una run vera:
python -m backend.ingestion.verify --source both --months 15Stampa la copertura per camera, il range di date, quanti parlamentari sono stati riconosciuti in modo ambiguo e con quale strategia. Esce con codice diverso da zero se una camera è bloccata, quindi è usabile come preflight in uno script. È la cosa più noiosa che ho scritto in questo progetto ed è quella che mi fa risparmiare più tempo.
Chi ha parlato
I resoconti nominano i parlamentari come capita: MALAN, Giuseppe Conte, CONTE Giuseppe. L'anagrafe ufficiale li registra come Cognome Nome. In mezzo c'è un matcher che indicizza il registro una volta sola (esatto, case-insensitive, per cognome) e prova le forme in ordine di affidabilità.
La parte che conta è cosa fa quando è incerto. Se due parlamentari condividono il cognome, prova a disambiguare col gruppo; se non ci riesce, sceglie il primo e marca l'attribuzione come ambigua. Quel flag arriva fino al report della run. Un'attribuzione dubbia è un dato, non un dettaglio da nascondere sotto un [0].
E se un nome non è nel registro, il matcher restituisce None. Il che è un risultato utile: scarta i falsi positivi del parser, tipo una parola maiuscola in mezzo a un intervento scambiata per un oratore.
La pipeline NLP
Dal testo grezzo al payload, in ordine:
- Pulizia — via le formule procedurali, normalizzazione. Restano fuori gli interventi della Presidenza e tutto ciò che sta sotto le 30 parole.
- Embedding — ogni discorso diventa un vettore a 384 dimensioni con
paraphrase-multilingual-MiniLM-L12-v2. - Riduzione — PCA a due dimensioni per la mappa.
- Assegnazione tematica — 14 aree definite a priori, similarità coseno, con soglia.
- Centroidi — un vettore medio per tema, che serve alle metriche di distanza.
- Nove analyzer — sul corpus globale e su ogni periodo che regge l'analisi.
- Export — manifest più chunk dentro
frontend/public/data/.
Perché sentence embeddings e non TF-IDF
TF-IDF conta le parole. Due interventi sull'economia che usano vocabolari diversi risultano lontani anche se dicono la stessa cosa. Gli embedding codificano il significato, quindi finiscono vicini nello spazio anche con lessici diversi. In un corpus dove ogni gruppo ha il proprio dialetto per parlare degli stessi identici argomenti, la differenza non è accademica.
MiniLM-L12 l'ho scelto per tre motivi concreti: parla italiano senza traduzioni intermedie, 384 dimensioni sono un buon compromesso, ed è abbastanza piccolo da girare su CPU in tempi che non rendono l'iterazione insopportabile. Modelli come multilingual-e5-large darebbero risultati migliori e una pipeline che non ho voglia di lanciare.
PCA invece di t-SNE
Ho implementato entrambi, il default è PCA. t-SNE produce cluster visivamente più separati e più belli da mettere in uno screenshot, ma le distanze tra cluster nel piano 2D non significano niente: preserva la struttura locale e distorce quella globale. Con PCA i cluster sono più sfumati, però se due punti sono lontani nel grafico sono lontani anche nello spazio semantico. Su una mappa che la gente userà per trarre conclusioni, la seconda proprietà vale più dell'estetica.
I 14 temi e il diritto di non rispondere
Il clustering K-Means classico c'è ancora come fallback (12 cluster, scelti con elbow method a suo tempo), ma il default è l'assegnazione semantica: quattordici aree definite a priori — Fisco, Lavoro, Sanità, Welfare, Ambiente, Giustizia, Immigrazione, Diritti Civili, Scuola, Agricoltura, Estera e Difesa, Infrastrutture, Premierato e Autonomia, Riforme Elettorali — ciascuna descritta da una lista di parole chiave, trasformata in embedding, e ogni discorso va all'area più vicina.
Il vantaggio è l'interpretabilità: i temi hanno sempre lo stesso significato tra una run e l'altra, il che con K-Means non è vero. Il costo è che qualcuno (io) ha deciso a monte quali sono i temi della politica italiana, il che è un bias e va detto.
Il problema tecnico interessante è che argmax risponde sempre. Non ha modo di dire «questo intervento non parla di nessuna di queste cose». E in un resoconto stenografico c'è parecchia roba che non parla di nessuna di queste cose: richiami al regolamento, comunicazioni di servizio, ringraziamenti alla Presidenza.
Quindi c'è una soglia. Sotto 0.20 di similarità coseno il discorso resta -1, «Non classificato», invece di essere attribuito all'area meno lontana. E ogni assegnazione porta con sé un margine di confidenza:
partitioned = np.partition(similarities, -2, axis=1)
confidence = partitioned[:, -1] - partitioned[:, -2] # primo meno secondoUn margine basso vuol dire che l'intervento somigliava a due temi allo stesso modo e la scelta è stata sostanzialmente una monetina. Il payload lo dice, il frontend lo mostra.
Un limite che sta scritto nella nota metodologica invece di essere nascosto: la distribuzione risultante è sbilanciata, perché il linguaggio istituzionale tende a essere attratto verso poche aree. I confronti tra temi vanno letti sapendolo.
Un tipo che rende impossibile un certo bug
Praticamente ogni metrica qui dentro fa la stessa manovra: filtra un DataFrame e usa il risultato per tagliare un array numpy della stessa lunghezza.
mask = df['group'] == 'Fratelli d\'Italia'
party_df = df[mask]
party_emb = embeddings[mask]Funziona finché il DataFrame ha un indice 0..n-1 pulito. Basta un sort_values('date'), una concatenazione, un reset_index dimenticato, e pandas continua a indicizzare per etichetta mentre numpy indicizza per posizione. Le righe e i vettori smettono di corrispondere.
E non succede niente. Nessuna eccezione, nessun warning, nessuna dimensione incompatibile: le medie si calcolano lo stesso, le cosine similarity anche. I numeri diventano semplicemente di qualcun altro. In un progetto il cui prodotto finale è un numero, è la modalità di fallimento peggiore che esista.
Per questo il frame e i suoi array non viaggiano mai separati:
@dataclass(frozen=True)
class SpeechDataset:
df: pd.DataFrame
embeddings: Optional[np.ndarray] = None
topic_scores: Optional[np.ndarray] = NoneC'è un solo modo di restringerlo, subset(), che converte qualsiasi maschera booleana in posizioni prima di usarla e ricostruisce l'indice a ogni passaggio. Il costruttore verifica che le lunghezze coincidano. Non esiste un'API con cui esprimere una fetta disallineata.
ds = SpeechDataset(df, embeddings=emb, topic_scores=scores)
camera = ds.subset(ds.df['source'] == 'camera')
for mese, bucket in ds.by_period('month'):
...L'invariante non è documentato: è strutturale. È l'unico modo in cui ho fiducia che regga a un refactoring fatto tra sei mesi da qualcuno che non ricorda perché.
La cache, che qui non è un'ottimizzazione ma un requisito
Generare gli embedding di ottomila discorsi su CPU richiede minuti. Rifarlo ogni volta che tocchi una soglia significa non toccare più nessuna soglia.
Lo store è unico e content-addressed: le chiavi sono kind / source / digest, dove il digest descrive da cosa l'artefatto è stato calcolato.
- I discorsi scaricati sono chiavati sui parametri di fetch (legislatura, mesi indietro), perché il contenuto non lo puoi conoscere prima di scaricarlo.
- Gli embedding sono chiavati su uno SHA-256 dei testi esatti che codificano, più il nome del modello.
La seconda riga è tutto il punto. Un corpus può cambiare mantenendo lo stesso numero di righe — basta una nuova scansione che pesca sedute diverse — e una cache validata sul conteggio riuserebbe felicemente i vettori di altri testi, rendendo sbagliata ogni metrica a valle senza dirlo a nessuno. Con l'impronta del contenuto, un carattere diverso significa chiave diversa, quindi la cache semplicemente non viene trovata. Non c'è nessun fallback a un file senza fingerprint, deliberatamente.
Siccome ogni cambio di contenuto scrive un file nuovo, c'è un prune() che tiene le due voci più recenti per tipo e sorgente. Con un dettaglio che ho imparato nel modo tradizionale: più scritture possono cadere nello stesso tick del timestamp del filesystem, e a mtime uguali l'ordinamento è arbitrario. Senza un parametro protect, una run può cancellare gli embedding che ha appena finito di calcolare.
I nove analyzer
Un BaseAnalyzer astratto con un metodo compute(), e ogni analyzer si registra da solo con un decoratore. L'orchestratore li scopre dal registry, inietta i dati condivisi e li esegue.
@analyzer
class AlliancesAnalyzer(BaseAnalyzer):
name = "alliances"
min_speeches = 200 # il mescolamento tra gruppi ha bisogno di volume
@classmethod
def get_dependencies(cls) -> list[str]:
return ['embeddings']Cosa calcolano, in breve:
Identity — il DNA tematico di ogni gruppo e parlamentare: quanto spazio dedica a ciascuna area, ricchezza lessicale, termini distintivi via TF-IDF. L'indice di generalismo è l'entropia della distribuzione tematica: chi parla solo di fisco ha entropia bassa, chi tocca tutto ha entropia alta.
Sentiment — tono per tema, matrice gruppo × tema, classifiche, indice Gulpease (leggibilità tarata sull'italiano: sotto 55 il testo è difficile per un lettore con licenza media) e polarizzazione «noi contro loro». È tutto lessicale, cioè conta parole marcatrici. Un modello transformer è attivabile da configurazione per chi ha tempo, ma il default resta il conteggio, che gira in millisecondi e si può spiegare.
Temporal — come i temi si muovono mese per mese, il drift semantico dei gruppi nello spazio degli embedding, un indice di crisi che conta la terminologia allarmistica, e il topic surfing, cioè i cambi bruschi di fuoco tematico.
Relations — la matrice di affinità tra gruppi, che è ancora il risultato che mi diverte di più: similarità coseno media tra tutti i loro interventi. Ogni tanto si scopre che due partiti che si prendono a male parole in televisione hanno linguaggi quasi sovrapponibili. La coesione interna misura invece quanto sono ravvicinati tra loro gli interventi di uno stesso gruppo.
Speaker — verbosità, domande retoriche, autoreferenzialità, regolarità degli interventi, entità nominate, topic leadership.
Rhetoric — populismo (popolo contro élite), anti-establishment (sistema, casta, palazzo), intensificatori emotivi, registro istituzionale. Conteggio di parole normalizzato per lunghezza. Non è NLP sofisticato, ma per questo tipo di pattern funziona sorprendentemente bene ed è infinitamente più interpretabile di una scatola nera.
Factions — conformity score rispetto al centroide del proprio gruppo, con etichette mainstream, bridge (chi sta a metà strada tra il proprio gruppo e un altro) e maverick.
Alliances — temi trasversali, coppie inattese tra schieramenti, alleanze destra-sinistra sui singoli argomenti.
Topics — etichette e parole chiave dei cluster, con filtro POS per tenere solo sostantivi e aggettivi.
La parte che mi interessa di più: quando un analyzer rifiuta
Il frontend ha un selettore di periodo, quindi le analytics vengono calcolate anche per ogni anno e ogni mese. Ma non tutte le metriche sopravvivono al taglio.
Ogni analyzer dichiara le condizioni in cui i suoi numeri significano qualcosa:
period_safe: bool = True # False se misura il cambiamento *tra* periodi
min_speeches: int = 30 # campione minimo sotto cui non vale la penatemporal ha period_safe = False, perché chiedere il drift semantico di un singolo mese è chiedere quanto è cambiata una cosa dentro una fotografia. alliances vuole 200 interventi, perché il mescolamento tra gruppi non è osservabile su un campione in cui metà dei gruppi compare tre volte. L'orchestratore chiede, l'analyzer risponde con un motivo, e il motivo finisce nel report.
Perché c'è un report. Un analyzer che esplode viene registrato invece di far saltare l'export — una metrica rotta non deve costare l'intera elaborazione — ma il fallimento deve emergere da qualche parte, o il payload esce con un buco dentro e nessuno se ne accorge. AnalyticsRunReport tiene traccia di cosa è fallito e in quale periodo, la lista finisce in stats.analytics_run, e --strict fa uscire la pipeline con codice diverso da zero se qualcosa è andato storto. Un periodo in cui tutti gli analyzer hanno declinato non produce nemmeno il file: il frontend lo nota, ricade sul dato globale e lo dichiara.
Una metrica senza unità è un'opinione con dei decimali
Diverse metriche qui contano parole marcatrici e poi devono trasformare quel conteggio in un numero da stampare accanto al nome di una persona. Come lo fai conta.
C'è una forma sola, definita in backend/scoring/normalize.py, e tutte le metriche lessicali la usano:
raw— marcatori ogni mille parole. Un'unità dichiarabile, comparabile tra corpora, senza tetto.pct— percentile all'interno del corpus. È questo che determina la lunghezza delle barre e l'ordine delle classifiche, perché «più del 90% dei colleghi» è un'affermazione che i dati sostengono, mentre «82 su 100» non vuol dire niente.n— su quanti interventi poggia il valore, così un campione sottile è visibile invece che mimetizzato.
Le etichette «bassa / media / alta» si riferiscono al percentile, non a soglie assolute. Un punteggio con un tetto è peggio che inutile: appiattisce proprio la coda in cui la misura pretende di essere informativa.
Stessa filosofia sui nomi. C'è una metrica che misura la quota di interventi di un parlamentare che cadono fuori dall'area tematica prevalente del suo gruppo. Si chiama divergence_pct, nel frontend «indipendenza tematica», e la nota metodologica specifica che misura la distanza dall'agenda del gruppo e non il dissenso politico: chi siede in una commissione diversa dalla maggioranza dei colleghi ottiene un valore alto senza aver mai contraddetto nessuno. Chiamarla «rebel score» sarebbe stato molto più divertente e molto più falso.
L'export: un manifest e dei pezzi
Il payload non è un file per camera. È un indice più risorse che il frontend chiede quando gli servono:
data/
manifest.json cosa esiste, quanto pesa, quali periodi
camera/core.json deputati, cluster, stats — primo paint
camera/speeches.json solo la mappa lo chiede
camera/analytics/global.json
camera/analytics/2025.json
camera/analytics/2025-11.json
I numeri dell'ultima elaborazione della Camera: 8.240 interventi di 270 deputati su dodici gruppi, 115 dei quali restano non classificati, quattordici mesi e due anni di analytics separate. core.json pesa 774 KB ed è tutto ciò che serve per disegnare. speeches.json pesa 8,4 MB e lo scarica solo chi apre la mappa.
ArtifactWriter scrive ogni risorsa, ne calcola il digest, lo mette nel manifest, e verifica dei budget di dimensione dichiarati: mezzo mega per il manifest, 3 MB per core, 12 per i discorsi. Sforare non blocca la run, stampa un warning. È il tipo di regressione che altrimenti scopri sei mesi dopo, quando qualcuno con una connessione normale prova ad aprire il sito.
Nessuna indentazione, tra l'altro. indent=2 su un payload di queste dimensioni costa megabyte di spazi bianchi che nessun essere umano leggerà mai.
Effetto collaterale che mi fa piacere: i chunk sono economici da tenere in git. Una nuova elaborazione riscrive solo i periodi effettivamente cambiati, gli altri restano byte-identici e git li conserva una volta sola.
Il frontend
React con Vite, react-router, Plotly per la mappa, Tailwind. Le rotte sono in italiano — /mappa, /analisi/identita, /analisi/relazioni, /analisi/tendenze, /analisi/qualita, /analisi/parlamentari — e la camera è un query param, quindi qualsiasi vista è un link che puoi mandare a qualcuno.
Il caricamento segue la struttura del payload: manifest e core.json al primo paint, discorsi e analytics del periodo alla prima vista che li chiede davvero, tutto tenuto in cache per la sessione così cambiare camera o mese non riscarica mai niente di già preso. Tutto ciò che dipende dai dati sta sotto un ChamberBoundary che possiede gli stati di caricamento ed errore, così le sei pagine non riscrivono la stessa logica sei volte.
Due scelte di visualizzazione che ho preso contro il mio istinto iniziale.
I colori delle serie nei grafici non sono i colori dei partiti. I colori istituzionali dei gruppi non passano le verifiche di distinguibilità: il verde della Lega e il rosso del PD sono quasi identici per chi ha una deficienza della visione cromatica, e sono due partiti che finiscono nello stesso grafico praticamente sempre. I colori di partito ci sono ancora, ma come pastiglia accanto a un'etichetta testuale. Le serie usano una scala verificata e con pochi elementi. E la mappa non colora quattordici temi insieme: evidenzia al massimo tre elementi per volta e tiene il resto come contesto neutro.
C'è una pagina /metodo. Un documento unico che spiega come è calcolata ogni metrica e cosa non misura: che le due camere non sono confrontabili perché coprono finestre temporali diverse, che il sentiment è lessicale e non riconosce l'ironia, che l'affinità tra gruppi indica sovrapposizione di linguaggio e non vicinanza politica, che molti pannelli vuoti significano «sotto soglia» e non «errore». E che parlare non è deliberare: qui nessuna metrica misura voti, presenze o esiti legislativi.
Un progetto che produce numeri sulla politica e non dichiara i propri limiti sta facendo qualcosa di peggio che sbagliare.
I test
219 funzioni su 22 file. Non è un numero di cui vantarsi, i test si contano male. Quello che mi interessa è cosa verificano.
I più utili sono quelli sugli invarianti, e la tecnica è semplice: costruisco array in cui la riga i contiene il valore i, poi verifico che dopo ogni fetta il contenuto dell'array corrisponda ancora alla colonna row_id del DataFrame. Se lo slicing sbaglia di una riga, il test urla invece di produrre numeri leggermente diversi. Le date nelle fixture sono volutamente disordinate, perché un sort_values è esattamente lo scenario che rompe l'allineamento tra etichette e posizioni.
Nella stessa categoria: gli embedding in cache non possono essere riusati per testi diversi nemmeno a parità di numero di righe, un fetch bloccato non può diventare un risultato vuoto, e il payload esportato rispetta il contratto — che è generato dal codice con python -m backend.tools.dump_schema, non scritto a mano in un markdown destinato a divergere dopo tre settimane.
Come si esegue
python -m venv venv
venv\Scripts\activate
pip install -r backend/requirements.txt
python -m spacy download it_core_news_sm
# prima la copertura, poi la pipeline
python -m backend.ingestion.verify --source both --months 15
python -m backend.export_data
# solo Camera, esce non-zero se un analyzer si rompe
python -m backend.export_data --source camera --strict
# quando senato.it fa il difficile
pip install playwright && playwright install chromium
cd frontend && npm install && npm run devC'è anche un notebook Colab per chi vuole la GPU: clona il repo, installa, esegue, scarica il payload.
Cosa resta
Il codice è organizzato perché aggiungere una fonte nuova — i Consigli Regionali pubblicano anche loro i resoconti — significhi scrivere una classe che implementa list_sessions e fetch_session, e niente altro. Concorrenza, cache, rate limiting, contabilità e nove analyzer arrivano gratis. Era il requisito architetturale principale fin dall'inizio: separare nettamente il problema del dato da quello dell'analisi.
Le cose aperte sono tre. La distribuzione tematica resta sbilanciata verso poche aree, perché il linguaggio procedurale è semanticamente attratto lì. Il Senato dipende da un browser headless finché CloudFront la penserà così, quindi ha una copertura molto più bassa della Camera. E alcune visualizzazioni che ho in testa non esistono ancora.
Ma il sistema, adesso, sa dire cosa non sa. Se una raccolta è incompleta lo stampa in cifre, se una metrica non regge il campione la salta e dichiara perché, se un'attribuzione è ambigua la marca. Su un progetto amatoriale che analizza il discorso politico, quella è l'unica proprietà che consideri non negoziabile.