Report & Documentazione

Statistiche estratte algoritmicamente · Pipeline TEAM · UniBo A.A. 2024/2025

📊 Statistiche Comparative — Dati Estratti dai File XML

I numeri in questa pagina sono calcolati a runtime rileggendo i 30 file data/xml_dataset/*.xml già validati contro il DTD: non sono scritti a mano nel report. Gli XML, a loro volta, derivano dai dati sorgente (con alcune correzioni manuali documentate sui dati grezzi, p.es. gli override distretti per Luxembourg e Stockholm). Il report è quindi il riflesso diretto dei documenti — se cambia un XML, cambia la statistica.

30Capitali analizzate
370Strutture ricettive
300Attrazioni catalogate
19/30Città con distretti
61.3Appeal medio
66.0Safety media
71.8Green Score medio
30File XML validati

Scarica il dataset in formati alternativi all'XML (generati dalla pipeline dai documenti validati):

📥 Dataset completo (CSV — 30 capitali)

Ogni scheda città offre anche il download JSON del singolo documento (convenzione xmltodict: @attr / #text). Nota: la conversione XML→JSON dei campi a content-model misto (wiki_intro, transport, description) è lossy — come avvertono le slide del corso, i formati per dati strutturati non catturano fedelmente testo + markup inline.

🔍 Da dove viene ogni numero — provenienza degli indicatori

In ogni XML confluiscono due famiglie di dati: i contenuti testuali estratti algoritmicamente dai dump Wikivoyage (trasporti, attrazioni, distretti, locali) e gli indici numerici presi da city_indices.json (fonti internazionali). L'unico valore derivato dalla pipeline è l'Appeal Score, ricalcolato dai tre indici.

IndicatoreFonteCome si ottieneScala
Appeal Score 🧮 Derivato Ricalcolato: Safety×0.4 + Green×0.4 + (100−Costo)×0.2; salvato come attributo appeal_score di <city_report>. 0–100
Indice di Sicurezza 📊 city_indices.json Numbeo Safety Index 2024; letto da <safety index_score="…">. 0–100
Green Score 📊 city_indices.json EU Green Capital Award / EEA; letto da <environment green_score="…">. 0–100
Accessibilità economica 📊 city_indices.json Numbeo Cost of Living 2024; letto da <economic_accessibility score="…">. 0–100
Budget alloggio 🧮 Derivato 📊 city_indices.json Stima sintetica, non un prezzo osservato: <hotel_price> = cost_of_living × 1.85 (proxy in euro del costo della vita Numbeo). Serve a confrontare le città, non a prenotare. €/notte
Valuta locale 📊 currency_rates.json Codice ISO nell'attributo opzionale <city_report currency="…"> (solo capitali fuori area euro); l'equivalente locale mostrato è una conversione indicativa a tassi di riferimento. ISO 4217
Macro-regione 📊 geo_regions.json Attributo enumerato obbligatorio <city_report region="…"> secondo il geoscheme UN M49 (Northern/Western/Southern/Eastern). Cipro, classificato da M49 in Asia occidentale, è assegnato a southern come eccezione UE documentata. Usato come filtro per area nell'index. UN M49
Attrazioni · Locali · Distretti 📂 Wikivoyage Conteggio diretto degli elementi <attraction> / <venue> / <district> nei documenti. n

Le fonti complete con i link a Numbeo, EEA e l'avvertenza accademica sono nella sezione AI Usage → Fonti degli Indici Numerici.

🧮 Come si calcola l'Appeal Score

È l'unico indice composito: pesa la sicurezza e la sostenibilità al 40% ciascuna e l'accessibilità economica al 20% (il costo viene invertito con 100−Costo, così «più economico = punteggio più alto»). Determina il ranking qui sotto.

Media del dataset: Appeal 61.3 · Safety 66.0 · Green 71.8 (calcolate sui 30 file).

🍺 Locali notturni per categoria — estratti con un predicato XPath

Questi conteggi non scorrono l'intera lista filtrando in Python: usano direttamente un predicato per valore d'attributo, //venue[@category="…"], che lascia selezionare al motore XPath i soli elementi desiderati (l'attributo category è enumerato nel DTD: bar | pub | nightclub).

240Locali totali (<venue>)
98category="bar"
129category="pub"
13category="nightclub"

Dettaglio tecnico del costrutto nella sezione Uso di XPath.

🏆 Classifiche per Indicatore

Top 5 capitali per ogni indicatore. La barra è proporzionale al valore, normalizzata sul massimo del dataset; per il prezzo la scala è invertita (barra più lunga = più economica), così in ogni grafico la barra più piena è sempre la posizione migliore.

Appeal Score

composito
🥇🇸🇮 Ljubljana74.7
🥈🇪🇪 Tallinn71.1
🥉🇮🇸 Reykjavik70.6
4🇫🇮 Helsinki70.4
5🇨🇿 Prague70.0
🛡️

Indice di Sicurezza

0–100
🥇🇮🇸 Reykjavik82.3
🥈🇸🇮 Ljubljana78.4
🥉🇭🇷 Zagreb77.1
4🇫🇮 Helsinki76.8
5🇪🇪 Tallinn76.4
🌱

Green Score

sostenibilità
🥇🇳🇴 Oslo92
🥈🇫🇮 Helsinki90
🥉🇸🇮 Ljubljana88
4🇮🇸 Reykjavik88
5🇸🇪 Stockholm88
💰

Più Economiche

€/notte
🥇🇧🇬 Sofia88€
🥈🇷🇴 Bucharest90€
🥉🇭🇺 Budapest97€
4🇵🇱 Warsaw98€
5🇭🇷 Zagreb101€

⚙️ Architettura della Pipeline TEAM

Il progetto è composto da cinque componenti Python. La pipeline trasforma dump MediaWiki in file XML conformi al DTD (con content-model misto), li valida, genera le pagine HTML navigabili annotate con microdata e indicizza i contenuti in un sistema RAG per le query in linguaggio naturale.

1

extract_wiki_info.py — Preparazione dati

Analizza i dump Wikivoyage in data/original_source/. Utilizza mwparserfromhell e spaCy per estrarre testo strutturato. Produce wiki_text_pulito.csv (trasporti, hotel, distretti) e attrazione_descrizione_fixed.csv (attrazioni con coordinate geografiche). I dati di sicurezza, costo della vita e green score provengono da city_indices.json.

2

final_processor.py — Elaborazione e validazione XML

Script centrale del progetto. Per ogni capitale individua la pagina principale nel dump, pulisce il Wikitext con regex, estrae distretti con descrizioni dalle sotto-pagine, e costruisce l'albero XML. I campi transport, description e wiki_intro sono generati come content-model misto (testo + markup inline <b>/<i>/<link>): gli iperlink sono reali, presi dai wikilink del sorgente Wikivoyage e preservati come <link href> (interni risolti su en.wikivoyage.org, max 3 per campo) — 79 elementi link su 29/30 città. Ogni documento riceve l'URI canonico <source_url>. L'albero viene validato rispetto al DTD con lxml.etree.DTD prima della scrittura in data/xml_dataset/.

3

deploy_dashboard.py — Generazione HTML

Legge la directory dei 30 file XML e li ri-valida in lettura (distinguendo documenti mal formati da non validi, con contatore 30/30 nell'index.html). Produce index.html, le 30 pagine città e questo report.html, preservando il markup inline del content-model misto. Ogni documento è annotato con microdata Schema.org annidati (City > containsPlace/TouristAttraction > geo/GeoCoordinates), dove l'itemid è l'URI letto dal documento (<source_url>). I dati sono anche serializzati come JSON inline per il Virtual Analyst client-side.

4

validate.py — Validazione DTD standalone

Script indipendente dal resto della pipeline (modellato sui laboratori del corso): scorre la directory XML, costruisce il DOM con etree.parse() e lo valida con etree.DTD(...).validate(), distinguendo mal formati, non validi e validi. Stampa l'esito per file e restituisce codice di uscita 0 solo se tutti i documenti sono validi. Vedi sezione Schema DTD.

5

rag/ — Sistema RAG per query in linguaggio naturale

Modulo indipendente (ingest.py + vectorstore.py + api.py): indicizza i 30 XML in 452 chunk su un indice FAISS + BM25Okapi con Reciprocal Rank Fusion, e applica un pre-filtro per metadati (regione / valuta / categoria / soglie) prima della ricerca semantica. Documentazione completa nella sezione dedicata 🔎 Virtual Analyst (RAG).

⚠️ Dati Non Reperibili da Wikivoyage — Integrazione AI

Le seguenti informazioni non erano presenti nei dump Wikivoyage scaricati e sono state integrate con testo sintetico generato da AI (Gemini), chiaramente identificato nel dataset:

Città Sezione Motivo
Parigi Trasporti urbani Campo Transport_Text assente nel CSV estratto da Wikivoyage
Bruxelles Trasporti urbani Testo estratto dal CSV era in italiano (non dalla pagina principale Wikivoyage)
Lussemburgo Trasporti urbani Testo estratto dal CSV era in italiano (non dalla pagina principale Wikivoyage)
Lussemburgo, Stoccolma Descrizioni distretti Il CSV conteneva dati errati: siti naturali (Mullerthal) per Lussemburgo, comuni della contea per Stoccolma. Override manuali applicati.
Tutte le 30 città Strategic Summary Sintesi strategiche generate con AI (Gemini) tramite prompt strutturati; non estratte da Wikivoyage

⚠️ Tutti i testi generati da AI sono identificabili nel dataset dalla presenza della stringa (Source: AI-generated — ...) nel campo <transport> del file XML.

📄 Schema DTD — Struttura dei Documenti XML

Ogni file in xml_dataset/ è validato rispetto a city_report.dtd prima della scrittura. La validazione è eseguita a runtime con lxml.etree.DTD. I file non validi vengono segnalati ma non scritti.

Script di validazione dedicato — validate.py

Oltre alla validazione integrata nella pipeline, un script Python standalone (scripts/validate.py), indipendente dal resto del progetto, costruisce il DOM di ogni documento e lo valida rispetto al DTD. Distingue i tre esiti: mal formato (etree.parse() solleva XMLSyntaxError), non valido (dtd.validate()False) e valido.

from lxml import etree

dtd = etree.DTD(open("data/city_report.dtd", "rb"))   # carica il DTD

for filename in files:                                  # scorre la directory XML
    try:
        tree = etree.parse(path)                        # parsing → DOM (buona forma)
    except etree.XMLSyntaxError:
        ...                                             # documento MAL FORMATO
        continue
    if dtd.validate(tree):                              # validazione del DOM
        ...                                             # VALIDO
    else:
        print(dtd.error_log.filter_from_errors())       # NON VALIDO

✅ Esecuzione: python scripts/validate.py30/30 validi · 0 non validi · 0 malformati. Lo script restituisce codice di uscita 0 solo se tutti i documenti sono validi.

Struttura ad albero

city_report [@appeal_score, @currency?, @region]
├── metadata
│   ├── title          (nome EN)
│   ├── name_it        (nome IT)
│   └── flag           (emoji bandiera)
├── indicators
│   ├── hotel_count
│   ├── hotel_price
│   ├── safety         [@index_score]
│   ├── environment    [@green_score]
│   ├── cost_index     [@value]
│   └── economic_accessibility [@score]
├── transport          (testo descrittivo)
├── accommodation
│   └── hotel*
│       ├── name
│       └── price
├── highlights
│   └── attraction*    [@lat @lon]
│       ├── name
│       └── description
├── districts?
│   └── district*
│       ├── name
│       └── description
├── description        (sintesi strategica EN)
├── wiki_intro?        (intro da Wikivoyage, ripulita)
└── landmark_image?    (URL immagine simbolo)

Dichiarazione DTD (file sorgente)

<!ELEMENT city_report (metadata, indicators, transport, accommodation, highlights, districts?, description, wiki_intro?, landmark_image?, nightlife?)>
<!-- currency: codice ISO 4217 della valuta locale (es. "GBP", "SEK").
     Attributo OPZIONALE (#IMPLIED): è assente per le città dell'area euro,
     presente solo per le capitali che usano una valuta diversa dall'euro.
     region: macro-regione geografica (geoscheme UN M49). Attributo ENUMERATO
     e OBBLIGATORIO: ogni capitale appartiene a una e una sola area. -->
<!ATTLIST city_report
    appeal_score CDATA                                       #REQUIRED
    currency     CDATA                                       #IMPLIED
    region       (northern | western | southern | eastern)   #REQUIRED>

<!ELEMENT metadata (title, name_it, flag, source_url)>
<!ELEMENT title (#PCDATA)>
<!ELEMENT name_it (#PCDATA)>
<!ELEMENT flag (#PCDATA)>
<!-- Canonical URI/IRI of the city: used as microdata itemid in the HTML so that
     the identifiers in the site correspond exactly to those in the documents -->
<!ELEMENT source_url (#PCDATA)>

<!ELEMENT indicators (hotel_count, hotel_price, safety, environment, cost_index, economic_accessibility)>
<!ELEMENT hotel_count (#PCDATA)>
<!ELEMENT hotel_price (#PCDATA)>

<!ELEMENT safety EMPTY>
<!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)*>
<!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)>
<!ELEMENT description (#PCDATA | b | i | link)*>
<!ELEMENT wiki_intro (#PCDATA | b | i | link)*>
<!ELEMENT landmark_image (#PCDATA)>

<!-- Inline elements for mixed content (content-model misto) -->
<!ELEMENT b (#PCDATA)>
<!ELEMENT i (#PCDATA)>
<!ELEMENT link (#PCDATA)>
<!ATTLIST link href CDATA #REQUIRED>

<!ELEMENT nightlife (venue*)>
<!ELEMENT venue (name)>
<!-- lat/lon obbligatorie: ogni venue è geolocalizzata sulla mappa.
     category è un attributo ENUMERATO: solo questi tre valori sono ammessi. -->
<!ATTLIST venue lat CDATA #REQUIRED
               lon CDATA #REQUIRED
               category (bar | pub | nightclub) #REQUIRED>

🔬 Tecniche di Parsing e Pulizia del Testo

Parsing MediaWiki XML con lxml

I dump Wikivoyage sono file XML con namespace http://www.mediawiki.org/xml/export-0.11/. La libreria lxml.etree li analizza con tree.findall('.//mw:page', ns). Ogni dump contiene più pagine: la pagina principale della città e le pagine dei singoli distretti (Città/Distretto).

Parsing del Wikitext con mwparserfromhell

All'interno di ogni pagina MediaWiki, il contenuto non è XML ma wikitext (la sintassi con {template}, [[link]], == sezioni ==, tabelle, ecc.). mwparserfromhell (“MediaWiki Parser from Hell”) è la libreria Python — usata anche dai bot di Wikipedia — che analizza questo wikitext e lo trasforma in un albero di nodi tipizzati (template, wikilink, tag, heading…). Permette di estrarre e ripulire il testo in modo strutturato e robusto — ad es. wikicode.filter_templates() per individuare e rimuovere i template, o strip_code() per ottenere il testo leggibile — invece di affidarsi solo a fragili espressioni regolari. È impiegata in extract_wiki_info.py nella fase di preparazione del dataset.

📖 Documentazione ufficiale di mwparserfromhell

Selezione della Pagina Principale

L'algoritmo cerca nell'ordine: (1) pagina con titolo esatto uguale al nome della città; (2) pagina Città/Understand (intro Wikivoyage). Se il dump contiene solo pagine di quartiere (es. Amsterdam, Berlino, Parigi, Roma), la sezione Wiki Archive viene omessa per evitare di mostrare testo distrettuale al posto dell'intro cittadina. La comparazione è accent-insensitive tramite unicodedata.normalize('NFKD'), necessario per Reykjavík.

Pulizia del Wikitext

Il testo grezzo in formato Wikitext viene ripulito con sequenze di regex: rimozione dei template {"{"}...{"}"} (5 passate per nested), link [[...]] → testo visibile, link [http://url testo] → solo testo, header == ... ==, tag HTML, marcatori bold/italic ''', hatnote di disambiguazione (:For other...). Il testo finale viene troncato a 400 caratteri.

Estrazione dei Distretti

La colonna Districts del CSV fornisce i nomi dei distretti (estratti nella fase 1). I nomi vengono ripuliti da rumore (es. "Visitor info:", "Buses", toponomastica storica come "Laibach"). Per ogni distretto si cerca la sotto-pagina corrispondente nel dump XML e se ne estrae il testo introduttivo come descrizione. Per Luxembourg e Stockholm sono stati definiti override manuali perché il CSV conteneva dati errati (siti naturali, municipalità di contea).

Validazione DTD con lxml

Prima di ogni scrittura, l'albero XML viene validato con lxml.etree.DTD(open('city_report.dtd', 'rb')).validate(root). Tutti i 30 file superano la validazione. Gli elementi opzionali (districts?, wiki_intro?, landmark_image?) non vengono creati se i dati corrispondenti sono assenti.

Microdata Schema.org nell'HTML

Ogni card nella dashboard è annotata con itemscope itemtype="https://schema.org/City" e itemprop="name" sul nome della città. Le annotazioni microdata sono generate direttamente dallo script deploy_dashboard.py.

HTML semantico nell'output

L'HTML prodotto segue il principio dell'HTML semantico: nessun tag procedurale deprecato (<font>, <center>) né attributi presentazionali (align, bgcolor), e una struttura logica con elementi di blocco (<header>, <nav>, <main>, <section>, <article>, <footer>). Per gli elementi inline, il vocabolario del content-model misto (b, i, link nel file XML) viene mappato dalla funzione inline_to_html() ai tag semantici dell'HTML — b→<strong>, i→<em>, link→<a> — e non ai presentazionali <b>/<i>, perché in quei punti l'enfasi porta significato (nome della città, termine straniero). I rari <b> residui nei template sono enfasi puramente visiva (in HTML5 <b> non è deprecato: indica testo stilisticamente distinto senza valore semantico).

Gestione delle Eccezioni

Ogni città è elaborata in un blocco try/except indipendente: un file corrotto non blocca la pipeline. La funzione find_orig_file() usa confronto accent-insensitive per trovare i file sorgente con nomi accentati. I dati mancanti nel CSV (es. Paris transport = NaN, Reykjavik non presente) vengono gestiti con TRANSPORT_PATCH e iterando su city_indices.json anziché sul CSV.

Coordinate Geografiche e Mappa

Le attrazioni hanno attributi @lat e @lon estratti dal CSV attrazione_descrizione_fixed.csv. La dashboard genera link diretti a Google Maps (?q=lat,lon) e la mappa interattiva mappa_attrazioni.html visualizza tutti i punti su Leaflet.js.

🧭 Uso di XPath

La fase di lettura ed estrazione dei documenti XML si basa su XPath, a due livelli: il motore XPath completo di lxml (metodo .xpath()) e il suo sottoinsieme ElementPath (.find() / .findall() / .findtext()).

1. XPath completo — .xpath()

Script Espressione XPath Costrutto
deploy_dashboard.py string(.//safety/@index_score) funzione string() + asse attributo @
deploy_dashboard.py .//hotel · .//attraction · .//venue asse discendente //
extract_wiki_info.py //mw:page · string(.//mw:text) // + namespace + string()
deploy_dashboard.py .//venue[@category="pub"] predicato per valore d'attributo — filtro di precisione
deploy_dashboard.py string(/city_report/@appeal_score) path assoluto + asse attributo sull'attributo obbligatorio del root
built_dataset.py string(//*[local-name()='text']) predicato con funzione [local-name()=…]

Due esempi mostrano l'intera gamma dei costrutti. string(.//safety/@index_score) combina path discendente, asse attributo e funzione XPath per leggere gli indici memorizzati come attributi di elementi EMPTY. .//venue[@category="pub"] usa invece un predicato ([…]): il filtro per valore d'attributo che lascia selezionare al motore XPath i soli locali desiderati — più robusto del filtraggio manuale in Python e immune all'ordine degli elementi. Entrambe le espressioni alimentano numeri reali nella sezione Statistiche.

Resilienza nel casting. I valori estratti sono sempre stringhe: la conversione a numero (medie di Appeal, Safety, prezzi) passa per una funzione fv() che applica float(str(x).strip()) dentro un try/except. Così un eventuale valore corrotto (es. "dieci") non manda in crash la pipeline ma viene neutralizzato, permettendo di proseguire con gli altri documenti.

2. ElementPath — find() / findall() / findtext()

Sottoinsieme di XPath di ElementTree/lxml, usato pervasivamente per navigare il DOM — es. tree.findall('.//mw:page', ns) (final_processor.py), root.findall('.//attraction') (validate.py), root.findtext(".//title") (deploy_dashboard.py). Gli attributi (es. @category, @lat) si leggono invece con element.get('attr').

🏷️ Microdata Schema.org — Quantificazione

Lo script deploy_dashboard.py annota i documenti HTML con microdata Schema.org (vincolo del progetto). Le annotazioni sono generate dallo script e i conteggi qui sotto sono calcolati a runtime ri-scansionando i file HTML appena prodotti (index.html + 30 pagine città), quindi non sono valori statici.

1468Item tipizzati (itemscope)
4200Proprietà (itemprop)
60Identificatori (itemid)
7196Attributi microdata totali

Item per tipo Schema.org

schema.org/GeoCoordinates570
schema.org/TouristAttraction300
schema.org/BarOrPub227
schema.org/PropertyValue120
schema.org/Hotel88
schema.org/City60
schema.org/AggregateRating30
schema.org/Place30
schema.org/Continent30
schema.org/NightClub13

Proprietà annotate (itemprop)

name 838 · containsPlace 628 · geo 570 · latitude 570 · longitude 570 · description 330 · value 120 · additionalProperty 90 · priceRange 88 · containedInPlace 60 · alternateName 36 · aggregateRating 30 · ratingValue 30 · bestRating 30 · worstRating 30 · ratingCount 30 · identifier 30 · propertyID 30 · sameAs 30 · image 30 · url 30

Struttura annidata e identità (itemid ↔ documento)

La gerarchia rispecchia il pattern multi-livello visto a lezione (lab_microdata.py): ogni <city report> diventa una City che contiene (containsPlace) le entità annidate del documento — TouristAttraction, Hotel e i locali notturni — ognuna con il proprio blocco geo/GeoCoordinates (latitude/longitude), e che a sua volta è contenuta (containedInPlace) nella propria macro-regione e nel continente:

City [itemid = source_url]
 ├─ containedInPlace → Place "… Europe"  [identifier → PropertyValue "UN M49"]
 │   └─ containedInPlace → Continent "Europe"  (sameAs → wikidata.org/Q46)
 ├─ aggregateRating → AggregateRating   (ratingValue = appeal_score, best/worst 100/0)
 ├─ additionalProperty → PropertyValue  (Safety Index · Green Score · Economic Accessibility)
 ├─ containsPlace → TouristAttraction   (name, description, geo → GeoCoordinates)
 ├─ containsPlace → Hotel               (name, priceRange)
 └─ containsPlace → BarOrPub | NightClub (name, geo → GeoCoordinates)

Due scelte semantiche degne di nota: (1) l'attributo XML enumerato category (bar | pub | nightclub) viene mappato sulla classe Schema.org più specifica — NightClub per le discoteche, BarOrPub altrimenti; (2) gli indicatori numerici sono modellati correttamente: l'Appeal Score (composito) come AggregateRating — l'unico aggregateRating ammesso su un Place — e i tre sotto-indici come PropertyValue via additionalProperty, evitando di forzare più rating distinti su una sola proprietà; (3) l'attributo XML region (UN M49) diventa la relazione di contenimento geografico containedInPlace — speculare a containsPlace — risalendo dalla città alla macro-regione (Place, con il codice M49 come identifier) fino al Continent "Europe" (sameAs Wikidata).

Ogni itemid è l'URI canonico letto dal documento XML (elemento <source_url>): gli identificatori nel sito corrispondono esattamente a quelli nei documenti, come nell'esempio dei castelli del corso (itemidid del file XML).

Verifica round-trip. Lo script standalone scripts/check_microdata.py ri-estrae i microdata dalle pagine con la libreria microdata (microdata.get_items(), la stessa del laboratorio lab_microdata.py) e controlla che: ogni pagina esponga una City top-level; l'itemid ri-letto dall'HTML coincida con il <source_url> del relativo XML; gli oggetti annidati (TouristAttraction/Hotel/BarOrPub) portino GeoCoordinates e l'AggregateRating il suo ratingValue. Esito: round-trip coerente su 30/30 pagine, con conteggi per tipo identici a quelli del contatore qui sopra — due metodi indipendenti (regex di scrittura ↔ parser di rilettura) che concordano.

♿ HTML Semantico e Accessibilità

«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 da deploy_dashboard.py applica questo principio. Di seguito come la teoria si traduce nel codice generato e, soprattutto, in un effetto reale.

1. Niente tag procedurali deprecati

Sulle 33 pagine generate: zero <font>, <center>, <marquee>, <strike> e zero attributi presentazionali (align, bgcolor, valign…). La presentazione è interamente delegata al foglio di stile stile.css.

2. Organizzazione logica con elementi di blocco

Ogni pagina è strutturata con elementi di blocco semantici — non <div> generici — che descrivono il ruolo del contenuto: <header> (testata), <nav> (navigazione), <main> (contenuto principale, uno per pagina), <section> / <article> (blocchi tematici), <footer> (piè di pagina), con una gerarchia coerente di heading h1→h2→h3.

3. Inline semantici (strong / em / span)

Il vocabolario inline del content-model misto nel XML (b, i, link) viene mappato dalla funzione inline_to_html() ai tag semantici dell'HTML — b→<strong>, i→<em>, link→<a> — perché in quei punti l'enfasi porta significato (nome città, termine straniero). Il generico <span> è usato per i frammenti senza semantica propria (es. valori delle statistiche). I rari <b> residui sono enfasi puramente visiva: in HTML5 <b> non è deprecato ma ridefinito come «testo stilisticamente distinto senza importanza semantica».

4. La «traduzione» del semantico: l'accessibilità

L'HTML semantico è volutamente invisibile all'occhio (<strong> si vede come un grassetto), ma si traduce in significato leggibile dalle macchine. Il canale principale è l'albero di accessibilità: gli elementi semantici generano automaticamente dei landmark ARIA<header>banner, <nav>navigation, <main>main, <footer>contentinfo — tra i quali uno screen reader può saltare. Per rendere questa struttura realmente usabile abbiamo aggiunto:

Verifica: DevTools del browser → scheda Accessibility (mostra i landmark etichettati), oppure la navigazione per landmark di uno screen reader (VoiceOver / NVDA).

5. Il sito come riflesso diretto dei documenti

Coerentemente con la semantica, ogni dato del documento XML è reso visibile nella pagina, non solo scaricabile. In particolare l'URI canonico del documento (elemento <source_url>) è ora un link cliccabile in 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 resta un'aggiunta, non l'unico modo di accedere ai dati.

Sintesi: principio → costrutto → effetto

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

6. Target tattili (tap-target) sulla mappa

Un audit Lighthouse (accessibilità 96/100) ha segnalato che alcuni marker Leaflet della mappa (es. Stoccolma, Oslo, Roma) avevano un'area tattile inferiore a 24×24 px o parzialmente sovrapposta — un problema per chi naviga da touch o ha difficoltà motorie. Interventi correttivi:

7. Contrasto del colore (WCAG AA)

Un controllo del contrasto ha rilevato un link sotto la soglia AA per testo piccolo (4.5:1): il link Report & Documentation nel footer usava l'accent #E74C3C su sfondo chiaro (3.82:1). È stato portato all'accent scuro #C0392B (5.44:1, conforme AA). La barra di navigazione superiore, pur avendo testo chiaro, è invece su sfondo scuro #0B1524 (10.48:1): già ampiamente conforme.

7. Albero di accessibilità — index.html

È la struttura che uno screen reader «vede»: solo ruoli e nomi accessibili, non lo stile. I landmark (banner / navigation / main / contentinfo) e i heading h1→h2 offrono una mappa navigabile della pagina; il widget chat flottante è esposto come dialog. Questa è una copia dell'albero reale della home:

document  "EuroCity Strategic Intelligence"            [lang="en"]
├─ link        "Salta al contenuto"                    (skip-link, visibile al focus)
├─ banner                                              <header>
│  └─ navigation  "Navigazione principale"             <nav>  → Home · Report · Map
├─ heading h1  "30 European capitals, one intelligence."
├─ region      "How the data is built"                 <section aria-label>
│  └─ heading h2  "How the data is built"
├─ main        "Griglia delle capitali"                <main id="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"

🎨 Architettura Frontend — CSS & JavaScript

L'interfaccia Travel 2026 applica una rigorosa separazione delle competenze: tutta la presentazione vive in un unico file esterno, stile.css. Il JavaScript è limitato a tre soli compiti — commutare le classi dei tab, leggere gli attributi data-pct per alimentare le custom property CSS e chiamare map.invalidateSize() quando il contenitore della mappa torna visibile. Nel markup generato non viene scritto alcun attributo style="" inline.

1. Foglio di stile esterno — stile.css

Tutte le 30 pagine città condividono un unico foglio di stile, versionato con un hash MD5 del suo contenuto (stile.css?v=hash): così ogni modifica forza l'invalidazione della cache del browser. Le nuove regole delle pagine città sono confinate sotto i prefissi .city-detail e .city-hero per non intaccare index.html né questo report.html.

Palette Travel 2026 — custom property CSS

Quattro variabili di brand aggiunte a :root: --terra: #E07A5F (terracotta), --crema: #F4F1DE, --night: #3D405B (blu notte), --gold: #E9C46A. I titoli hero e questo report usano Playfair Display (serif) via Google Fonts, caricato insieme a Inter.

Anello di progresso — conic-gradient

Gli anelli di Safety e Green sono puro CSS: conic-gradient(currentColor calc(var(--pct,0) * 3.6deg), var(--slate-200) 0deg). Il fattore 3.6 mappa un punteggio 0–100 su 0–360°. Uno pseudo-elemento ::after (inset 7 px) maschera il centro creando la forma a ciambella.

Barre animate — var(--bar-w)

Le barre orizzontali usano width: var(--bar-w, 0%) con transition: width 0.8s cubic-bezier(.4,0,.2,1). La proprietà parte da 0; il JS imposta il valore finale dopo il render, innescando l'animazione senza scrivere alcuno style="" inline.

Sistema a tab — solo toggle di classe

.city-tab-panel { display: none } nasconde tutti i pannelli per default. .city-tab-panel.active { display: block; animation: tabIn 0.22s } mostra quello attivo con una dissolvenza in entrata. Il JS si limita ad aggiungere o togliere la classe active — niente innerHTML, niente stili inline.

2. I tab della scheda città

Ogni scheda città è organizzata in cinque tab — Overview, Sights, Nightlife, Stay (mostrato solo se la città ha hotel catalogati) e Tips. Il gestore dei click commuta soltanto le classi active:

var btns   = document.querySelectorAll('.city-tab-btn');
var panels = document.querySelectorAll('.city-tab-panel');
btns.forEach(function(btn){
  btn.addEventListener('click', function(){
    btns.forEach(function(b){ b.classList.remove('active'); });
    panels.forEach(function(p){ p.classList.remove('active'); });
    this.classList.add('active');
    var panel = document.getElementById('tab-' + this.dataset.tab);
    if(panel){ panel.classList.add('active'); }
    // Leaflet deve ricalcolare le dimensioni quando il tab Overview torna attivo
    if(this.dataset.tab === 'overview' && cityMap){
      setTimeout(function(){ cityMap.invalidateSize(); }, 50);
    }
  });
});

3. data-pct → custom property CSS

Le metric card portano un attributo HTML data-pct con il valore numerico (0–100). Quando il DOM è pronto, un unico ciclo legge ciascun attributo e ne riversa il valore nella custom property CSS corrispondente — l'unica eccezione ammessa alla regola «zero stili inline» (impostare una custom property con style.setProperty alimenta una variabile, non una regola di presentazione):

document.querySelectorAll('[data-pct]').forEach(function(el){
  var v = el.dataset.pct;
  if(el.classList.contains('circular-ring')){
    el.style.setProperty('--pct', v);         // pilota l'angolo del conic-gradient
  } else {
    el.style.setProperty('--bar-w', v + '%'); // pilota la larghezza della barra → parte la transizione CSS
  }
});

4. Leaflet — il pattern invalidateSize()

La mappa Leaflet si trova nel tab Overview (il pannello attivo di default), quindi viene renderizzata correttamente al primo caricamento. Quando l'utente esce e torna, il contenitore è già stato dimensionato una volta; la chiamata setTimeout(..., 50) lascia al browser il tempo di completare il ciclo di paint del display: block prima che Leaflet rilegga le dimensioni. La variabile cityMap è dichiarata fuori dalla IIFE così che il gestore dei tab possa raggiungerla.

5. Soglie dei badge hero (calcolate in fase di build)

I badge sono calcolati da deploy_dashboard.py durante la rigenerazione delle pagine, tarati sugli intervalli reali del dataset (appeal 48–68, safety 47–75, green 60–88, prezzo 88–163 €):

BadgeCondizioneEsempio verificato
🌿 Green Citygreen > 75Stoccolma 88 ✓
🛡️ Ultra Safesafety > 70Lussemburgo 73.5 ✓
⭐ Top Ratedappeal > 60Copenaghen 66 ✓
💰 Budget Pickprice < 130 €Lisbona 112 € ✓

🔎 Virtual Analyst — l'assistente che risponde alle domande

Il Virtual Analyst è l'assistente del sito: risponde a domande scritte in linguaggio naturale (italiano o inglese) sulle 30 capitali — per esempio «bar a Dublino» o «discoteche nelle città fuori dall'euro». È un sistema RAG (Retrieval-Augmented Generation): invece di "sapere" le risposte, le va a cercare nei 30 file XML e le mostra in modo ordinato. Non inventa: riporta solo ciò che è nel dataset.

Come trova la risposta giusta

Per ogni domanda cerca in due modi complementari allo stesso tempo: per significato (capisce il senso anche con parole diverse — «dove dormire» trova gli hotel) e per parole chiave (cerca i termini esatti). Poi fonde le due classifiche in un'unica graduatoria: un risultato che convince entrambi i metodi finisce in cima.

La marcia in più: cercare tra i metadati

La ricerca nel testo va bene per le domande discorsive, ma fa fatica con quelle precise (es. «discoteche nelle città che non usano l'euro»). Per questo l'analista sfrutta i metadati già iniettati in ogni città — macro-regione, valuta, tipo di locale, punteggi numerici — esattamente come i filtri di un sito di e-commerce: prima restringe il campo a colpo sicuro (es. valuta ≠ euro e tipo = discoteca), poi cerca nel testo solo dentro quel sottoinsieme. È il punto in cui RAG e Web of Data (i microdata Schema.org) si fondono: la stessa informazione che descrive le città per i motori di ricerca serve anche a rispondere meglio.

I tre vantaggi della fusione RAG × Web of Data

I due "motori": uno in locale, uno nel browser

La versione completa è un piccolo server Python (FastAPI) con gli indici di ricerca veri: è la reference implementation, si avvia sul proprio computer. Ma il sito è pubblicato su GitHub Pages, che ospita solo file statici e non può eseguire un server Python: perciò, quando non riesce a contattare il server locale, la pagina usa una versione che gira interamente nel browser (metadataQuery()) sui dati già inclusi nella pagina. I due motori usano la stessa logica di filtraggio: quello del browser è ciò che i visitatori usano davvero, e funziona senza alcun server.

Dettagli tecnici (approfondimento)

Le sezioni precedenti bastano per capire il sistema; qui sotto il "sotto il cofano".

data/xml_dataset/*.xml  →  rag/ingest.py  →  rag_index/  (index.faiss 384-dim · docs.json 452 chunk + meta)
                                              ↓
                       rag/vectorstore.py   FAISS + BM25Okapi → Reciprocal Rank Fusion + pre-filtro metadati
                                              ↓
                       rag/api.py           FastAPI (127.0.0.1:8000) · parse_filters() · rilevamento intento

ingest.py allega a ogni chunk i metadati strutturati dell'XML, così la ricerca filtra su valori esatti invece di cercarli nel testo:

{
  "text": "[BERLIN] Bar e locali notturni: ...",
  "city": "BERLIN", "section": "nightlife", "source": "Berlin.xml",
  "meta": { "appeal": 63.2, "currency": "EUR", "region": "western",
           "safety": 72.5, "green": 78.0, "cost": 85.0, "economy": 15.0, "price": 157.25 },
  "categories": ["bar", "pub", "nightclub"]
}

Dimensioni di filtro riconosciute

Dimensione Sorgente metadato Esempio di domanda Filtro applicato
Macro-regione@region (UN M49)attractions in northern europeregion = northern
Valuta@currencynightclubs in non-euro citiescurrency ≠ EUR
Categoria locale@categorypubs in eastern europecategory = pub
Soglie numericheappeal/safety/green/pricecities with appeal over 60appeal ≥ 60

I vincoli si combinano in AND (es. eurozone + green over 70). Lato server la risposta /query include il campo filters con le dimensioni applicate (trasparenza); lato client il motore restituisce in più i valori garantiti verbatim dai metadati.

Limiti noti: alcune città (Londra, Budapest, Parigi…) mancano di dati hotel nelle sorgenti Wikivoyage; la sintesi delle risposte testuali è euristica (estrazione di frasi), non generativa.

🤖 Dichiarazione Utilizzo Strumenti AI

Claude Code (Anthropic) — Assistenza allo Sviluppo

Claude Code / claude-sonnet-4-6

Lo strumento Claude Code è stato utilizzato per:

AI per i Contenuti del Dataset

city_descriptions.json

Le sintesi strategiche in inglese in city_descriptions.json (mostrate come Strategic Summary nel tab Tips delle schede città) sono state generate con Gemini (Google AI) tramite prompt strutturati. Fonte: testo a scopo didattico, non estratto da una singola fonte primaria.

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.

Fonti degli Indici Numerici

city_indices.json
Indicatore Fonte originale Rielaborazione Nota
Safety Index Numbeo Safety Index 2024 AI (Gemini) Valori adattati; scarto stimato ±5–10 pt rispetto ai dati Numbeo originali
Cost of Living Numbeo Cost of Living Index 2024 AI (Gemini) Normalizzato sulla scala europea (Numbeo usa NYC = 100 come riferimento)
Green Score EU Green Capital Award + EEA City Statistics Stima sintetica AI (Gemini) ⚠️ Non validato da un indice ufficiale 0–100. Stima composita basata su: EU Green Capital Award, aree verdi pro capite, emissioni CO₂, politiche mobilità.

⚠️ Avvertenza accademica: i valori numerici in city_indices.json sono stime rielaborate con AI a partire da fonti pubbliche. Non sostituiscono la consultazione diretta dei dataset originali Numbeo o EEA per usi di ricerca.

📁 Elenco Completo dei File e Fonti

Inventario completo del repository, con link diretto a ogni file su GitHub e alla fonte originale. I conteggi qui sotto sono calcolati a runtime scandendo le cartelle del progetto.

12File sorgente / dati
9Script Python
33Pagine HTML generate
30XML validati DTD
File Contenuto Fonte Licenza
data/original_source/*.xml Dump MediaWiki delle 30 capitali (testi, trasporti, hotel, distretti) Wikivoyage — download manuale CC-BY-SA 3.0
data/wiki_text_pulito.csv Trasporti, hotel e distretti estratti dai dump tramite extract_wiki_info.py Derivato da Wikivoyage CC-BY-SA 3.0
data/attrazione_descrizione_fixed.csv 300 attrazioni con nome, descrizione e coordinate geografiche Derivato da Wikivoyage + curato manualmente CC-BY-SA 3.0
data/city_indices.json Safety Index, Cost of Living, Green Score per 30 città Numbeo 2024, EU Green Capital Award, EEA — rielaborati con AI (Gemini) Stima — non ufficiale
data/city_descriptions.json Sintesi strategiche in inglese per 30 città (campo Strategic Summary) Generato con AI (Gemini) — testo originale Testo didattico
data/nightlife.json 240 locali notturni (bar, pub, nightclub) con coordinate geografiche OpenStreetMap via Overpass API ODbL (Open Database Licence)
data/xml_dataset/*.xml 30 file XML output, validati DTD, uno per capitale Generato dalla pipeline — derivato dalle fonti sopra
data/transport_patches.json Testi trasporto AI per 5 città (Paris, Brussels, Luxembourg, Lisbon, Nicosia) Generato con AI (Gemini) — testo originale Testo didattico
data/currency_rates.json Valuta locale ISO 4217 + tassi EUR→locale (snapshot 2026-01) per 10 capitali non-euro Tassi di cambio indicativi raccolti manualmente
data/geo_regions.json Macro-regione UN M49 per ogni capitale (fonte + nota su Cipro) UN Statistics Division (geoscheme M49)
data/city_report.dtd Schema DTD per la validazione dei file XML Creato originalmente per il progetto
Script Python
scripts/extract_wiki_info.py Step 1 pipeline — dump Wikivoyage → CSV/JSON (usa mwparserfromhell, spaCy) Progetto originale
scripts/built_dataset.py Precursore / variante di final_processor.py — costruisce gli XML dal dataset; menzionato nella sezione XPath Progetto originale
scripts/final_processor.py Step 2 pipeline — elaborazione principale: XML con content-model misto + validazione DTD in scrittura Progetto originale
scripts/deploy_dashboard.py Step 3 pipeline — ri-validazione DTD in lettura + generazione HTML con microdata Schema.org Progetto originale
scripts/validate.py Step 4 pipeline — validatore DTD standalone: DOM + etree.DTD().validate() Progetto originale
scripts/check_microdata.py Verifica round-trip microdata: ri-estrae gli item schema.org dalle pagine HTML con microdata.get_items() e li confronta con i dati XML sorgente Progetto originale
scripts/fetch_nightlife.py One-shot: recupera bar/pub/nightclub da OpenStreetMap via Overpass API per le 30 capitali → produce nightlife.json OpenStreetMap (ODbL)
scripts/download_images.py One-shot: scarica immagini landmark per le 30 città tramite Wikipedia pageimages API → assets/images/*.jpg Wikimedia Commons (CC-BY-SA) CC-BY-SA
scripts/map.py Prototipo iniziale della mappa interattiva con Folium; sostituito dall'implementazione Leaflet.js Progetto originale

👥 Il Team

Progetto sviluppato nell'ambito della Laurea Magistrale in Governance e Politiche dell'Innovazione Digitale (GEPID) — Università di Bologna, A.A. 2024/2025.