Laurea Magistrale in Governance e Politiche dell'Innovazione Digitale
Università di Bologna — Docente: Angelo Di Iorio — A.A. 2024/2025
EuroCity è una pipeline ETL (Extract, Transform, Load) che analizza 30 capitali europee estraendo, pulendo e trasformando dati da dump Wikivoyage (scaricati manualmente) in formato XML. L'output finale è una dashboard HTML navigabile con statistiche comparative, mappa interattiva e documentazione integrata del processo.
Appeal Score (indice composito):
Safety × 0.4 + Green Score × 0.4 + (100 − Costo della vita) × 0.2
ELABORAZIONE/
├── data/
│ ├── original_source/ # Dump Wikivoyage (MediaWiki XML) scaricati a mano
│ ├── xml_dataset/ # Output: 30 file XML validati rispetto al DTD
│ ├── json_dataset/ # Output: 30 JSON (download per città, convenzione xmltodict)
│ ├── dataset_cities.csv # Output: export tabellare aggregato delle 30 capitali
│ ├── city_report.dtd # DTD per la validazione dei file XML
│ ├── city_indices.json # Indici: safety, green score, costo della vita
│ ├── currency_rates.json # Valute locali + tassi indicativi (capitali non-euro)
│ ├── geo_regions.json # Macro-regione per capitale (geoscheme UN M49) + fonte
│ ├── nightlife.json # Locali notturni geolocalizzati (Overpass / OSM)
│ ├── wiki_text_pulito.csv # Trasporti, hotel, distretti estratti da Wikivoyage
│ ├── attrazione_descrizione_fixed.csv # Attrazioni con coordinate geografiche
│ ├── city_descriptions.json # Sintesi strategiche in inglese (generate con AI)
│ └── transport_patches.json # Patch trasporti per città con CSV mancante
├── scripts/
│ ├── extract_wiki_info.py # Step 1 — dump Wikivoyage → CSV/JSON (mwparserfromhell, spaCy)
│ ├── built_dataset.py # Precursore/variante di final_processor.py (menzionato nella sezione XPath)
│ ├── final_processor.py # Step 2 — elaborazione principale → XML (content-model misto + DTD in scrittura)
│ ├── deploy_dashboard.py # Step 3 — generazione HTML con microdata (ri-validazione DTD in lettura)
│ ├── validate.py # Step 4 — validatore DTD standalone (DOM + etree.DTD().validate())
│ ├── check_microdata.py # Verifica round-trip microdata Schema.org (libreria `microdata`)
│ ├── fetch_nightlife.py # One-shot: recupera locali da OpenStreetMap/Overpass → nightlife.json
│ ├── download_images.py # One-shot: scarica immagini landmark via Wikipedia API → assets/images/
│ └── map.py # Prototipo mappa Folium (sostituito da implementazione Leaflet.js)
├── pages/
│ ├── cities/*.html # 30 pagine città (una per file XML)
│ ├── report.html # Report statistiche + documentazione pipeline
│ └── mappa_attrazioni.html # Mappa Leaflet con attrazioni e locali
├── rag/ # Virtual Analyst: ingest, vectorstore, API (BM25 + FAISS)
├── index.html # Dashboard navigabile (output principale)
└── stile.css # Foglio di stile unico per tutti i documenti HTML
Cinque componenti Python. La pipeline trasforma i dump MediaWiki in file XML conformi al DTD (con content-model misto), li valida, genera l'HTML annotato con microdata e indicizza i contenuti per le query in linguaggio naturale.
| # | Componente | Ruolo |
|---|---|---|
| 1 | extract_wiki_info.py |
Preparazione: dump Wikivoyage → CSV/JSON (mwparserfromhell, spaCy) |
| 2 | final_processor.py |
Costruzione XML (content-model misto + <source_url>) e validazione DTD in scrittura |
| 3 | deploy_dashboard.py |
Ri-validazione DTD in lettura + generazione HTML/CSS con microdata annidati (itemid ← documento) |
| 4 | validate.py |
Validatore DTD standalone: DOM + etree.DTD().validate(), isolato dal resto |
| 5 | rag/ |
Indicizzazione FAISS + BM25 e Virtual Analyst per query in linguaggio naturale |
La validazione DTD avviene quindi in tre punti (scrittura, lettura, script standalone),
tutti con lxml.etree.DTD.
Il Virtual Analyst (cartella rag/) risponde a domande in linguaggio naturale (italiano o
inglese) sulle 30 capitali. È il punto in cui i moduli del corso si fondono: la RAG
(Retrieval-Augmented Generation) non lavora solo sul testo, ma sfrutta i metadati strutturati iniettati
negli XML (gli stessi esposti come microdata Schema.org) per filtrare in modo esatto — Metadata-filtered RAG.
data/xml_dataset/*.xml → rag/ingest.py → rag_index/ (FAISS 384-dim + docs.json: 452 chunk + meta)
→ rag/vectorstore.py FAISS + BM25Okapi + RRF + pre-filtro metadati
→ rag/api.py FastAPI · parse_filters() · rilevamento intento
- Ricerca ibrida: FAISS (
all-MiniLM-L6-v2) + BM25Okapi fusi con Reciprocal Rank Fusion (α=0.5). - Pre-filtro per metadati: la query è analizzata per dimensioni esatte e il set di candidati è ristretto
prima della ricerca semantica — macro-regione (
@region, UN M49), valuta (@currency), categoria locale (@category), soglie numeriche (appeal / safety / green / prezzo). Es. «nightclubs in non-euro cities», «attractions in northern europe», «cities with appeal over 60». - Tre vantaggi della fusione RAG × Web of Data: filtraggio esatto (niente «zuppa di tag»), disambiguazione
via categoria tipizzata (
BarOrPub/NightClub), valori numerici garantiti verbatim (GeoCoordinates,AggregateRating) — zero allucinazioni sui numeri.
Due layer di esecuzione. La RAG FastAPI è la reference implementation locale; poiché GitHub Pages è
statico e non può ospitare il backend Python, il sito esegue la stessa logica di filtraggio in un motore
client-side (metadataQuery()) direttamente nel browser, sui dati già serializzati come JSON inline.
pip install -r requirements.txt
python -m rag.ingest # costruisce l'indice dai 30 XML
uvicorn rag.api:app --host 127.0.0.1 --port 8000 # avvia l'API (reference locale)📖 Documentazione completa (schema dei chunk, dimensioni di filtro, endpoint):
README_RAG.md.
pip install lxml pandas# Passo 1 – Generazione XML validati
python scripts/final_processor.py
# Passo 2 – Generazione HTML + download JSON/CSV (index.html, pagine città, report.html, mappa)
python scripts/deploy_dashboard.pyDirectory di input come variabile. Il nome delle directory non è cablato nel codice ma sta in un'unica variabile per script, sovrascrivibile da ambiente per lavorare su un'altra collezione senza modifiche al sorgente:
# input dei dump grezzi e output XML (final_processor.py)
TEAM_INPUT_DIR=/path/ai/dump TEAM_OUTPUT_DIR=/path/out python scripts/final_processor.py
# input dei documenti XML validati (deploy_dashboard.py e validate.py)
TEAM_XML_DIR=/path/out python scripts/deploy_dashboard.pyOltre alla validazione integrata nella pipeline, è disponibile uno script dedicato e indipendente che valida la directory XML rispetto al DTD :
python scripts/validate.py # valida data/xml_dataset/
python scripts/validate.py <altra_dir> # valida un'altra directoryPer ogni file lo script: (1) costruisce il DOM con etree.parse() — intercettando i
documenti mal formati; (2) lo valida con etree.DTD(...).validate() — distinguendo i
non validi; (3) ispeziona il DOM (radice, n. elementi, attrazioni). Restituisce codice
di uscita 0 solo se tutti i file sono validi. Output:
📄 DTD : data/city_report.dtd
📂 Sorgente: data/xml_dataset
✅ amsterdam.xml VALIDO [root=<city_report> · 112 elementi · 10 attractions · appeal=63.2]
…
📋 Risultato: 30/30 validi · 0 non validi · 0 malformati
extract_wiki_info.pyè lo script di preparazione iniziale (richiedemwparserfromhell,spacy). I file CSV/JSON prodotti sono già inclusi nel repository, quindi non è necessario rieseguirlo.
| Fonte | File | Contenuto |
|---|---|---|
| Wikivoyage (MediaWiki XML, download manuale) | original_source/*.xml |
Testi, trasporti, hotel, distretti |
| Dataset pubblici (Numbeo, EEA) | city_indices.json |
Safety, green score, costo della vita |
| OpenStreetMap (Overpass API) | nightlife.json |
240 locali notturni geolocalizzati (bar/pub/nightclub) |
| Tassi di cambio indicativi (snapshot 2026-01) | currency_rates.json |
Valuta locale ISO 4217 + tasso EUR→locale delle 10 capitali non-euro |
| UN M49 geoscheme (UN Statistics Division) | geo_regions.json |
Macro-regione (Northern/Western/Southern/Eastern) di ogni capitale + nota su Cipro |
| Wikimedia Commons | landmark_image in XML (via download_images.py) |
Immagini simbolo (URL stabili via Special:FilePath) |
| Generato con AI (Gemini) | city_descriptions.json |
Sintesi strategiche in inglese |
I file in original_source/ sono dump MediaWiki XML con namespace
http://www.mediawiki.org/xml/export-0.11/. Ogni file contiene più <page>:
- la pagina principale della città (es.
<title>London</title>) - le pagine dei distretti (es.
<title>Amsterdam/Canal District</title>)
La selezione del testo intro privilegia la pagina principale (confronto del titolo
accent-insensitive) e, in sua assenza, la sotto-pagina Città/Understand. Solo quando
nessuna delle due esiste nel dump la sezione Wiki Archive viene omessa (mostrare il testo di
un distretto come intro della città sarebbe fuorviante): è il caso della sola Luxembourg,
mentre per le altre 29/30 capitali wiki_intro è popolato.
<!ELEMENT city_report (metadata, indicators, transport, accommodation, highlights, districts?, description, wiki_intro?, landmark_image?, nightlife?)>
<!ATTLIST city_report
appeal_score CDATA #REQUIRED
currency CDATA #IMPLIED <!-- valuta locale ISO 4217, solo capitali non-euro -->
region (northern | western | southern | eastern) #REQUIRED> <!-- macro-regione UN M49 (enumerato) -->
<!ELEMENT metadata (title, name_it, flag, source_url)>
<!ELEMENT title (#PCDATA)>
<!ELEMENT name_it (#PCDATA)>
<!ELEMENT flag (#PCDATA)>
<!ELEMENT source_url (#PCDATA)> <!-- URI canonico → itemid microdata -->
<!ELEMENT indicators (hotel_count, hotel_price, safety, environment, cost_index, economic_accessibility)>
<!ELEMENT hotel_count (#PCDATA)>
<!ELEMENT hotel_price (#PCDATA)>
<!ELEMENT safety EMPTY> <!-- elemento vuoto: il dato sta nell'attributo -->
<!ATTLIST safety index_score CDATA #REQUIRED>
<!ELEMENT environment EMPTY>
<!ATTLIST environment green_score CDATA #REQUIRED>
<!ELEMENT cost_index EMPTY>
<!ATTLIST cost_index value CDATA #REQUIRED>
<!ELEMENT economic_accessibility EMPTY>
<!ATTLIST economic_accessibility score CDATA #REQUIRED>
<!ELEMENT transport (#PCDATA | b | i | link)*> <!-- content-model misto -->
<!ELEMENT accommodation (hotel*)>
<!ELEMENT hotel (name, price)>
<!ELEMENT price (#PCDATA)>
<!ELEMENT highlights (attraction*)>
<!ELEMENT attraction (name, description)>
<!ATTLIST attraction lat CDATA #REQUIRED lon CDATA #REQUIRED>
<!ELEMENT districts (district*)>
<!ELEMENT district (name, description)>
<!ELEMENT name (#PCDATA)> <!-- condiviso: hotel/attraction/district/venue -->
<!ELEMENT description (#PCDATA | b | i | link)*> <!-- content-model misto -->
<!ELEMENT wiki_intro (#PCDATA | b | i | link)*> <!-- content-model misto -->
<!ELEMENT landmark_image (#PCDATA)>
<!-- Elementi inline per il content-model misto -->
<!ELEMENT b (#PCDATA)>
<!ELEMENT i (#PCDATA)>
<!ELEMENT link (#PCDATA)>
<!ATTLIST link href CDATA #REQUIRED>
<!ELEMENT nightlife (venue*)>
<!ELEMENT venue (name)>
<!ATTLIST venue lat CDATA #REQUIRED lon CDATA #REQUIRED
category (bar | pub | nightclub) #REQUIRED> <!-- attributo enumerato -->| Elemento | Content model | Attributi | Cardinalità / Note |
|---|---|---|---|
city_report |
metadata, indicators, transport, accommodation, highlights, districts?, description, wiki_intro?, landmark_image?, nightlife? |
appeal_score (REQ), currency (IMPL), region enum (REQ) |
radice |
metadata |
title, name_it, flag, source_url |
— | 1 |
title / name_it / flag |
#PCDATA |
— | nome EN / nome IT / emoji bandiera |
source_url |
#PCDATA |
— | URI canonico → itemid microdata |
indicators |
hotel_count, hotel_price, safety, environment, cost_index, economic_accessibility |
— | 1 |
hotel_count / hotel_price |
#PCDATA |
— | numerici (testo) |
safety |
EMPTY |
index_score (REQ) |
indice 0–100 |
environment |
EMPTY |
green_score (REQ) |
indice 0–100 |
cost_index |
EMPTY |
value (REQ) |
costo della vita |
economic_accessibility |
EMPTY |
score (REQ) |
100 − costo |
transport |
(#PCDATA | b | i | link)* |
— | content-model misto |
accommodation |
hotel* |
— | può essere vuoto (12 città senza hotel) |
hotel |
name, price |
— | 0+ |
price |
#PCDATA |
— | €/notte |
highlights |
attraction* |
— | — |
attraction |
name, description |
lat (REQ), lon (REQ) |
geolocalizzata su mappa |
districts |
district* |
— | opzionale (19/30) |
district |
name, description |
— | 0+ |
name |
#PCDATA |
— | condiviso (hotel/attraction/district/venue) |
description |
(#PCDATA | b | i | link)* |
— | misto; condiviso (città/attraction/district) |
wiki_intro |
(#PCDATA | b | i | link)* |
— | opzionale, misto (29/30) |
landmark_image |
#PCDATA |
— | opzionale, URL immagine |
b / i |
#PCDATA |
— | inline (grassetto / corsivo) |
link |
#PCDATA |
href (REQ) |
inline (collegamento) |
nightlife |
venue* |
— | opzionale |
venue |
name |
lat (REQ), lon (REQ), category (REQ) |
geolocalizzata; category enumerato (bar | pub | nightclub) |
(REQ) = #REQUIRED; (IMPL) = #IMPLIED (opzionale); ? = opzionale; * = zero o più; gli elementi EMPTY portano il dato in un attributo.
Valuta locale (attributo opzionale). L'attributo currency del root è #IMPLIED: è assente per le
città dell'area euro e presente (codice ISO 4217, es. SEK, GBP) solo per le 10 capitali che usano
un'altra valuta. Il sito lo legge via XPath (string(/city_report/@currency)) e mostra, accanto al prezzo
in euro, l'equivalente locale indicativo letto da currency_rates.json.
Macro-regione (attributo enumerato obbligatorio). L'attributo region del root è enumerato e
#REQUIRED: ogni capitale appartiene a una e una sola area tra northern | western | southern | eastern.
La classificazione segue il geoscheme UN M49 (UN Statistics Division); la mappa capitale→regione e la sua
fonte vivono in data/geo_regions.json. Unica eccezione documentata: Cipro
(Nicosia), che M49 colloca in Asia occidentale, è assegnata a southern in quanto Stato membro UE
all'estremo sud-est dell'Unione. Il dato è scritto da final_processor.py e riletto via XPath
(string(/city_report/@region)) da deploy_dashboard.py, che lo usa come filtro per area nell'index.html.
Essendo enumerato, un valore fuori vocabolario (es. region="central") fa fallire la validazione DTD —
verificabile sostituendolo in un file e rilanciando validate.py.
Prezzo: stima sintetica, non osservata. L'elemento <hotel_price> non è estratto dalle voci
alloggio di Wikivoyage: è derivato dall'indice Numbeo del costo della vita con hotel_price = cost_of_living × 1.85.
È un proxy in euro pensato per il confronto tra città, non un prezzo di mercato. I prezzi dei singoli
<hotel> sono invece testo grezzo verbatim da Wikivoyage (formati eterogenei).
Content-model misto. I campi transport, description e wiki_intro non sono solo
testo: contengono markup inline (<b>, <i>, <link href>) interlacciato al testo —
il content-model misto richiesto dal progetto (es. <b>Roma</b> ... (<i>Trastevere</i>)).
In particolare gli iperlink sono reali e presi dai documenti sorgente: i wikilink del
wikitext Wikivoyage ([[Pagina|testo]], [url testo]) non vengono più scartati ma
preservati come <link href="…">testo</link> (i link interni risolti su
en.wikivoyage.org, i namespace/interwiki esclusi), con un tetto di 3 link per campo per
non appesantire la prosa. Realizza l'esempio delle slide (link a Wikipedia dentro <para>):
79 elementi <link> su 29/30 città. Il vocabolario inline è poi reso in HTML semantico
(<strong>/<em>/<a>) da inline_to_html.
Scelte di obbligatorietà. Gli attributi geografici di attraction e venue sono
#REQUIRED perché entrambi gli elementi sono posizionati sulla mappa (senza coordinate non
sarebbero collocabili); hotel, che compare solo in elenco, non ha coordinate. Gli elementi
districts?, wiki_intro?, landmark_image? e nightlife? sono opzionali perché il dato
non è sempre disponibile nelle fonti; accommodation è invece sempre presente ma con
hotel* (zero o più), così le città senza hotel restano valide.
Validazione. Tutti i 30 file XML superano la validazione DTD, eseguita con
lxml.etree.DTD in tre punti: in scrittura (final_processor.py), in lettura
(deploy_dashboard.py, che distingue documenti mal formati da non validi e mostra
il contatore 30/30 DTD-valid nell'index.html) e tramite lo script standalone
validate.py (vedi sopra), che costruisce il DOM e lo valida
in modo isolato dal resto della pipeline.
Analisi critica della validazione. Nel rispetto delle linee guida del Progetto TEAM la
validazione strutturale è affidata a un DTD, scelta ottima per il content-model misto
delle sezioni narrative (trasporti, introduzioni) e per i vincoli enumerati (venue/@category,
city_report/@region), in cui il DTD è pienamente espressivo. Tuttavia, data l'abbondanza di dati puramente
strutturati e numerici nel dataset (coordinate lat/lon, indici di sicurezza, green score,
prezzi), il DTD mostra limiti di espressività: può validarli solo come stringhe generiche
(CDATA / #PCDATA). In uno scenario di produzione, per garantire la robustezza dei tipi di
dato prima ancora del parsing in Python, sarebbe tecnicamente più appropriato affiancare o
sostituire il DTD con Relax NG o XML Schema, che offrono meccanismi di verifica molto più
raffinati per i dati strutturati.
Il namespace MediaWiki viene gestito esplicitamente:
MW_NS = 'http://www.mediawiki.org/xml/export-0.11/'
ns = {'mw': MW_NS}
pages = tree.findall('.//mw:page', ns)Algoritmo a priorità decrescente per trovare il testo intro corretto:
- Pagina con titolo esatto uguale al nome della città
- Pagina
Città/Understand(sezione intro Wikivoyage) - Se nessuna delle due esiste (città suddivise solo in distretti), wiki_intro rimane vuoto
La comparazione è accent-insensitive tramite:
unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode().lower()Necessario per Reykjavík ≠ Reykjavik.
Il testo Wikitext viene pulito con sequenze di re.sub():
# Template (5 passate per nested): {{...}} → ""
# Link con testo: [[link|testo]] → "testo"
# Link semplici: [[link]] → "link"
# URL con testo: [http://url testo] → "testo"
# Header sezione: == Titolo == → ""
# Hatnote: :For other places... → ""
# Bold/italic: '''testo''' → "testo"
# Tag HTML: <tag> → ""La colonna Districts del CSV (prodotta nella fase di preparazione)
fornisce i nomi pipe-separated. I nomi vengono filtrati per rimuovere
rumore (etichette di navigazione, toponomastica storica, categorie errate).
Per ogni distretto si cerca la sotto-pagina corrispondente nel dump XML
e si estrae il testo introduttivo come descrizione.
Ogni documento HTML è annotato con microdata Schema.org generati dallo script, con
struttura annidata (come il pattern Place > Review > Person visto a lezione):
<main itemscope itemtype="https://schema.org/City"
itemid="https://en.wikivoyage.org/wiki/Rome">
<meta itemprop="name" content="Roma">
<!-- URI canonico (Wikivoyage = fonte dei documenti), reso come link visibile -->
<a itemprop="url" href="https://en.wikivoyage.org/wiki/Rome">en.wikivoyage.org/wiki/Rome</a>
...
<tr itemprop="containsPlace" itemscope itemtype="https://schema.org/TouristAttraction">
<span itemprop="name">Colosseum</span>
<div itemprop="geo" itemscope itemtype="https://schema.org/GeoCoordinates">
<meta itemprop="latitude" content="41.89">
<meta itemprop="longitude" content="12.49">
</div>
</tr>
<!-- Contenimento geografico VERSO L'ALTO (dall'attributo @region, geoscheme UN M49) -->
<div itemprop="containedInPlace" itemscope itemtype="https://schema.org/Place">
<meta itemprop="name" content="Southern Europe">
<div itemprop="identifier" itemscope itemtype="https://schema.org/PropertyValue">
<meta itemprop="propertyID" content="UN M49"><meta itemprop="value" content="039">
</div>
<div itemprop="containedInPlace" itemscope itemtype="https://schema.org/Continent">
<meta itemprop="name" content="Europe">
<link itemprop="sameAs" href="https://www.wikidata.org/wiki/Q46">
</div>
</div>
</main>Identità (itemid ↔ documento). L'itemid non è arbitrario: è l'URI canonico letto
dall'elemento <source_url> del file XML. Gli identificatori nel sito corrispondono
esattamente a quelli nei documenti, come nell'esempio dei castelli del corso
(itemid dell'HTML ↔ id del file XML).
Quantificazione (conteggio a runtime, ri-scansionando l'HTML generato — sezione
#microdata di report.html; verificato con la libreria microdata.get_items() del corso):
| Metrica | Valore |
|---|---|
Item tipizzati (itemscope) |
1.468 |
Proprietà (itemprop) |
4.200 |
Identificatori (itemid) |
60 |
| Attributi microdata totali | ≈ 7.196 |
| Tipo Schema.org | Item | Tipo Schema.org | Item | |
|---|---|---|---|---|
GeoCoordinates |
570 | City |
60 | |
TouristAttraction |
300 | AggregateRating |
30 | |
BarOrPub |
227 | Place (macro-regione) |
30 | |
PropertyValue |
120 | Continent (Europe) |
30 | |
Hotel |
88 | NightClub |
13 |
Oltre alla gerarchia discendente City > containsPlace, ogni City porta un AggregateRating (l'Appeal
Score, con ratingValue 0–100) e i sotto-indici come PropertyValue (additionalProperty: Safety, Green,
Economic Accessibility) — la forma corretta dato che un Place ammette un solo aggregateRating. La categoria
enumerata del XML (bar|pub|nightclub) è mappata sulla classe più specifica (BarOrPub / NightClub).
Il nome italiano della capitale (<name_it>) è esposto come alternateName accanto al name canonico
inglese (es. Rome → alternateName Roma), così l'entità resta identificabile in entrambe le lingue.
Specularmente, l'attributo XML region (UN M49) genera la gerarchia ascendente containedInPlace:
ogni City è contenuta in un Place (la macro-regione, col codice M49 come identifier/PropertyValue),
a sua volta contenuto nel Continent "Europe" (sameAs → Wikidata). È l'inverso esatto di containsPlace:
il documento <city_report region="…"> diventa una catena di contenimento navigabile dalla città al continente.
Teoria (slide del corso): «HTML è il linguaggio base del Web. Deve essere usato in modo semantico, evitando tag procedurali deprecati come
<font>e preferendo una chiara organizzazione logica. Gli elementi si dividono in quelli di blocco (che vanno a capo) e inline (come<em>,<strong>o il generico<span>), che si inseriscono nel flusso del testo senza spezzarlo.»
Tutto l'HTML prodotto applica questo principio.
1. Niente tag procedurali deprecati. Sulle 33 pagine: zero <font>/<center>/<strike>
e zero attributi presentazionali (align, bgcolor…). La presentazione è interamente in
stile.css.
2. Organizzazione logica con elementi di blocco semantici — non <div> generici:
<header>, <nav>, <main> (uno per pagina), <section>/<article>, <footer>, con
gerarchia h1→h2→h3.
3. Inline semantici. Il vocabolario inline del content-model misto (b/i/link nel XML)
è mappato da inline_to_html() ai tag semantici b→<strong>, i→<em>, link→<a> (l'enfasi
porta significato: nome città, termine straniero); <span> per i frammenti senza semantica. I
<b> residui sono enfasi puramente visiva — in HTML5 <b> non è deprecato ma ridefinito come
"testo stilisticamente distinto senza importanza".
4. La "traduzione" del semantico: l'accessibilità. L'HTML semantico è invisibile all'occhio
ma si traduce nell'albero di accessibilità: gli elementi semantici generano i landmark
ARIA (<header>→banner, <nav>→navigation, <main>→main, <footer>→contentinfo) tra cui uno
screen reader può saltare. Per renderli usabili sono stati aggiunti:
- skip link "Salta al contenuto" (visibile solo al focus da tastiera);
aria-labelsulle<nav>duplicate (Principale / Tra le città / Sezioni del report);aria-current="page"sul link attivo;langsul documento ealtsu tutte le 60 immagini.
Verifica: DevTools → scheda Accessibility (mostra i landmark etichettati), o la navigazione per landmark di uno screen reader.
Target tattili (tap-target). Un audit Lighthouse (accessibilità 96/100) ha segnalato
marker Leaflet con area cliccabile inferiore a 24×24 px o sovrapposti (es. Stoccolma, Oslo,
Roma). Correzioni: marker di locali/attrazioni portati a 32×32 px e marker delle capitali
raggruppati con L.markerClusterGroup, così gli overlap a zoom basso si fondono in un unico
bersaglio cliccabile (e si riaprono allo zoom).
Contrasto del colore (WCAG AA). Un controllo del contrasto ha individuato un link sotto la
soglia AA per testo piccolo (4.5:1): il link Report & Documentation nel footer (accent
#E74C3C su sfondo chiaro, 3.82:1), portato all'accent scuro #C0392B (5.44:1, conforme).
La barra di navigazione superiore, con testo chiaro ma su sfondo scuro #0B1524, è già a
10.48:1.
Albero di accessibilità (index.html). È la struttura che uno screen reader «vede» — solo
ruoli e nomi accessibili, non lo stile:
document "EuroCity Strategic Intelligence" [lang="en"]
├─ link "Salta al contenuto" (skip-link, visibile al focus)
├─ banner ‹header›
│ └─ navigation "Navigazione principale" ‹nav› → Home · Map · Report
├─ heading h1 "30 European capitals, one intelligence."
├─ region "How the data is built" ‹section[aria-label]›
│ └─ heading h2 "How the data is built"
├─ region "Filtra e ordina le capitali" ‹section.filter-toolbar[aria-label]›
│ ├─ searchbox "Search a capital by name"
│ ├─ combobox "Sort by" (8 criteri: appeal, safety, …)
│ └─ group ×3 "Profilo" · "Regione" · "Valuta" (chip filtro, aria-pressed)
├─ main "Griglia delle capitali" ‹main#city-grid›
│ ├─ article → heading h2 "Amsterdam" + link
│ ├─ article → heading h2 "Athens" + link
│ └─ … (30 article, una card per capitale)
├─ contentinfo ‹footer› → link "Report & Documentation"
├─ button "Back to top"
└─ dialog "Virtual Analyst" ‹div[role="dialog"]› ← widget flottante
button "Ask the analyst" · textbox · button "Ask" · button "Close the analyst"
5. Il sito come riflesso diretto dei documenti. Ogni dato del XML è reso visibile nella
pagina, non solo scaricabile. L'URI canonico (<source_url>) è un link cliccabile su ogni
pagina città e coincide con l'itemid dei microdata: lo stesso identificatore vive nel
documento, nel link visibile e nell'annotazione machine-readable. Il download del file XML è
un'aggiunta, non l'unico accesso ai dati.
| Principio (slide) | Costrutto HTML | Effetto reale |
|---|---|---|
| No tag procedurali | 0 <font>; stile in CSS |
separazione contenuto/presentazione |
| Organizzazione logica (blocco) | header/nav/main/section/footer |
landmark ARIA + document outline |
| Inline semantico (em/strong) | <strong>/<em>/<span> |
enfasi pronunciata dagli screen reader |
| Web come grafo di risorse | <a itemprop="url" href=URI> |
URI documento = link = itemid microdata |
La fase di lettura/estrazione dei documenti si basa su XPath, a due livelli.
| File | Espressione | Costrutto XPath |
|---|---|---|
deploy_dashboard.py |
root.xpath("string(.//safety/@index_score)") |
funzione string() + asse attributo @ |
deploy_dashboard.py |
root.xpath(".//hotel"), .//attraction, .//district, .//venue |
asse discendente // |
extract_wiki_info.py |
xpath("//mw:page", namespaces=ns), xpath("string(.//mw:text)") |
// + namespace + string() |
built_dataset.py |
xpath("string(//*[local-name()='text'])") |
predicato [local-name()=…] |
rag/ingest.py |
.//accommodation/hotel, .//highlights/attraction, .//nightlife/venue |
path relativi |
L'esempio più completo è string(.//safety/@index_score): combina path discendente,
asse attributo e funzione XPath in un'unica espressione.
Sottoinsieme di XPath di ElementTree/lxml, usato pervasivamente per navigare il DOM
(es. final_processor.py: tree.findall('.//mw:page', ns); validate.py:
root.findall('.//attraction'); deploy_dashboard.py: root.findtext(".//title")).
Nota sui due tipi di accesso agli attributi. Gli attributi enumerati/obbligatori (
@index_score,@category,@lat/@lon) si leggono con l'asse attributo XPath (string(.../@attr)) o conelement.get('attr'); gli elementi figli confindtext().
L'utilizzo di AI è dichiarato come richiesto dalle linee guida del progetto.
Utilizzato per assistenza allo sviluppo della pipeline:
- Debugging del problema di selezione della pagina principale nei dump multi-pagina
- Scrittura della funzione
advanced_wiki_cleaner()per la pulizia Wikitext - Risoluzione della gestione accent-insensitive per Reykjavík
- Correzione del bug nell'estrazione distretti in
deploy_dashboard.py - Override distretti per Luxembourg (dati CSV errati: siti Mullerthal) e Stockholm
- Introduzione del content-model misto e della ri-validazione DTD in lettura
- Espansione dei microdata Schema.org (
AggregateRating,PropertyValue,BarOrPub/NightClub,Hotel, e il contenimento geograficocontainedInPlace→Place/Continentdall'attributoregion) e scriptcheck_microdata.pyper il round-trip - Gestione della valuta locale (
currency_rates.json, attributocurrency, nota sulle capitali non-euro) e correzione della provenienza del prezzo (stima sintetica, non estratta) - Iniezione della macro-regione geografica negli XML (
geo_regions.json, attributo enumeratoregionda geoscheme UN M49) come dimensione di filtro/navigazione nell'index - Trasformazione del Virtual Analyst in widget flottante presente su ogni pagina
- Redesign "Travel 2026" delle schede città (hero full-width, metriche con anelli
conic-gradient, layout a 5 tab Overview/Sights/Nightlife/Stay/Tips) e della toolbar di ricerca/filtri dell'index, con tutta la presentazione in CSS esterno - Validazione finale rispetto alle linee guida: preservazione degli iperlink reali dal wikitext come
<link href>nel content-model misto (con fix del doppio escaping&amp;), download in formati alternativi (JSON per città via convenzionexmltodict+ CSV aggregato) e consolidamento delle directory di input in variabili sovrascrivibili da ambiente - Chiusura del progetto: fix dell'estrazione RAG dei campi misti (
full_text/itertextinvece difindtext, che fermavawiki_introal primo markup) con ricostruzione dell'indice (336 → 452 chunk) e fix di contrasto colore WCAG AA sul link del footer - Generazione di
report.htmle del presenteREADME.md
Prompt rappresentativo usato con Claude Code:
"Analizza il progetto e il parsing. Nel codice HTML ci sono ancora diversi problemi: l'estrazione del testo dai file .xml non è veramente riuscita, molte città non hanno ancora i distretti/li hanno errati, ci sono alcuni errori nelle bandiere."
Le sintesi strategiche in city_descriptions.json sono state generate con Gemini (Google AI):
Prompt tipo: "Genera una descrizione strategica in inglese (max 2 frasi) di [CITTÀ] come capitale europea, focalizzandoti su: innovazione urbana, sostenibilità, sicurezza, accessibilità economica. Tono: analitico, da report istituzionale."
| File | Descrizione |
|---|---|
data/xml_dataset/*.xml |
30 file XML, uno per capitale, validati DTD |
data/json_dataset/*.json |
30 file JSON, uno per città — download in formato alternativo, generato dall'XML con la convenzione xmltodict (@attr / #text) |
data/dataset_cities.csv |
Export tabellare aggregato: una riga per capitale con tutti gli indicatori strutturati (comodo per fogli di calcolo) |
index.html |
Dashboard navigabile: griglia di card filtrabile (profilo/regione/valuta), ordinabile e con ricerca, mappa e Virtual Analyst |
pages/cities/*.html |
30 pagine città (una per XML), con microdata, mappa, link Download XML/JSON e nota valuta per le città non-euro. Layout "Travel 2026" a tab — Overview (metriche + distretti + mappa Leaflet), Sights, Nightlife, Stay (mostrato solo per le città con hotel catalogati) e Tips — con hero full-width e card animate via CSS |
pages/report.html |
Statistiche estratte + documentazione pipeline + download CSV |
pages/mappa_attrazioni.html |
Mappa Leaflet con attrazioni e locali notturni geolocalizzati |
stile.css |
Foglio di stile unico, applicato a tutti i documenti HTML (con cache-busting ?v=hash) |
Download in altri formati (output opzionale). Oltre all'XML d'input, ogni scheda città offre il
JSON del singolo documento e il report un CSV aggregato delle 30 capitali. Il JSON è prodotto
dall'XML con la convenzione xmltodict (slide XML vs. JSON); va sottolineato che la conversione dei
campi a content-model misto è lossy — i formati per dati strutturati non catturano fedelmente
testo + markup inline, esattamente l'avvertimento delle slide del corso.
Il Virtual Analyst (RAG: BM25 + FAISS) è un widget chat flottante presente su index.html e su ogni
pagina città: segue lo scroll e resta accessibile in qualsiasi momento della navigazione.
Navigazione multi-dimensionale (index.html). La griglia delle capitali si esplora da più punti di vista:
una ricerca per nome, un ordinamento (appeal, sicurezza, green, prezzo, n. attrazioni, n. locali, A–Z)
e tre gruppi di filtri faceted combinabili — Profilo (soglie Safety/Green/Budget, in AND), Regione
(le quattro macro-aree dell'attributo region) e Valuta (euro / non-euro, dall'attributo currency), con
selezione singola per Regione e Valuta. I filtri agiscono solo lato client sui dati già resi dagli XML;
un contatore mostra "N di 30". L'ordine corrente delle capitali visibili viene propagato alle schede città
(via sessionStorage/localStorage), così la navigazione ← / → in fondo a ogni scheda segue l'insieme
filtrato anziché il puro ordine alfabetico (fallback statico se si arriva diretti alla pagina).
| Indicatore | Valore |
|---|---|
| Capitali analizzate | 30 |
| File XML generati e validati | 30 |
| Strutture ricettive catalogate | 370 |
| Attrazioni geolocalizzate | 300 |
| Città con distretti estratti | 19/30 |
| Appeal Score medio | 61.3 |
| Safety Index medio | 66.0 |
| Green Score medio | 71.8 |
Progetto TEAM — Università di Bologna — A.A. 2024/2025