From 8e074c8a73a36b7a1b9439ac6c3066feec59e16a Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 15:20:54 +0200 Subject: [PATCH 1/9] Add OpenAlex ETL pipeline for source-agnostic bibliometrix import - New www/services/openalex_client.py: search + cursor pagination, batch ID resolution for references, retry/backoff with Retry-After support - New www/services/openalex_mapper.py: OpenAlex -> 34-column WoS-style schema mapping (Dispatcher + declarative field mapping pattern) - New www/services/type_contracts.py: column type specs, coercion, and validation (STRING/INTEGER/STRING_LIST) - New www/services/etl_pipeline.py: end-to-end orchestrator (query -> extract -> map -> calculated fields -> validate -> DataFrame) - Patched get_historiograph.py, get_localcitedauthors.py, get_localciteddocuments.py to handle None from histNetwork() gracefully instead of crashing (pre-existing gap, not source-specific) - Fixed PY column type contract (STRING -> INTEGER): historical WoS pipeline only worked correctly by accident via pd.read_json's implicit type inference Tested end-to-end against 35/43 functions in functions/ with real OpenAlex API data. --- .gitignore | 1 + functions/get_historiograph.py | 34 + functions/get_localcitedauthors.py | 23 +- functions/get_localciteddocuments.py | 18 +- www/services/__init__.py | 4 + www/services/etl_pipeline.py | 293 +++++++ www/services/openalex_client.py | 310 +++++++ www/services/openalex_mapper.py | 1166 ++++++++++++++++++++++++++ www/services/type_contracts.py | 343 ++++++++ 9 files changed, 2189 insertions(+), 3 deletions(-) create mode 100644 www/services/etl_pipeline.py create mode 100644 www/services/openalex_client.py create mode 100644 www/services/openalex_mapper.py create mode 100644 www/services/type_contracts.py diff --git a/.gitignore b/.gitignore index 23b99e089..ca4809011 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__/ bibliovenv/ Bibenv/ +.venv/ .idea/ \ No newline at end of file diff --git a/functions/get_historiograph.py b/functions/get_historiograph.py index 089d02387..8d9bd24e2 100644 --- a/functions/get_historiograph.py +++ b/functions/get_historiograph.py @@ -25,11 +25,45 @@ def get_historiograph(df, node_label="AU1", histNodes=20, hist_isolates=True, hi hist_plot: oggetto con layout e grafo networkx hist_data: dataframe con metadati, DOI cliccabili, cluster, anni filename: nome del file HTML interattivo salvato temporaneamente + + None se la sorgente del DataFrame non e' supportata per l'analisi + citazionale diretta (vedi nota sotto), invece di sollevare TypeError. """ # Pre-elaborazione df = metaTagExtraction(df, "SR") hist_results = histNetwork(df, min_citations=0, sep=sep, network=True) + # LIMITE NOTO (debugging step, vedi anche get_local_cited_authors.py e + # get_local_cited_documents.py per lo stesso pattern): www/services/histnetwork.py::histNetwork + # supporta solo DB == "Web_of_Science" o "Scopus" (righe 37-43 di quel file); + # per qualunque altro valore di DB — incluso il nostro "OPENALEX", ma anche + # Dimensions/The_Lens/PubMed/Cochrane della pipeline storica stessa — stampa + # "Database not compatible with direct citation analysis" e ritorna None + # PRIMA di toccare la colonna CR. Non e' quindi un problema di formato di CR: + # la funzione non arriva mai a parsarlo per queste sorgenti. + # + # Deliberatamente NON abbiamo esteso histNetwork con un ramo "OPENALEX": + # 1) il ramo wos() dipende da M['SR_FULL'], colonna che la nostra pipeline + # scarta deliberatamente perche' non fa parte dello schema canonico a 34 + # colonne (vedi openalex_mapper.py::_compute_calculated_fields) — andrebbe + # comunque in KeyError; + # 2) anche risolvendo (1), wos() cerca auto-citazioni ESATTE all'interno + # della stessa collezione (un paper che cita un altro paper anch'esso nei + # risultati): su un campione generico di poche decine di risultati da una + # query testuale, la rete risultante sarebbe quasi sempre vuota — non un + # bug, ma un risultato di scarso valore che non giustifica lo sforzo; + # 3) il ramo scopus() si aspetta l'anno tra parentesi nel riferimento + # (regex r'.*\((\d{4})\).*'), formato diverso dal nostro CR + # "Autore, ANNO, Rivista" (anno non tra parentesi) — anche qui non un + # crash, ma zero citazioni valide trovate silenziosamente. + # Qui ci limitiamo quindi a intercettare il None e propagarlo pulito, con lo + # stesso significato di "nessun risultato" gia' usato ovunque in app.py per + # questo tipo di analisi (historiograph_results = reactive.Value(None), con + # ogni consumer che fa `if result is not None` e mostra un placeholder + # altrimenti) — nessuna nuova convenzione introdotta. + if hist_results is None: + return None + # 1. Costruzione iniziale del grafo hist_plot = histPlot( hist_results, diff --git a/functions/get_localcitedauthors.py b/functions/get_localcitedauthors.py index e663192bc..08eb5efd6 100644 --- a/functions/get_localcitedauthors.py +++ b/functions/get_localcitedauthors.py @@ -12,7 +12,13 @@ def get_local_cited_authors(df, num_of_cited_authors, fast_search=False): Returns: A Plotly figure object and a DataFrame of the most local cited authors. - """ + + None (non una tupla) se la sorgente del DataFrame non e' supportata + per l'analisi citazionale diretta (vedi nota sotto), invece di + sollevare TypeError — stesso singolo valore sentinella che app.py + verifica con `if result is None` al punto di chiamata + (local_cited_authors_result.set(result) senza spacchettare prima). + """ # Determine the local citation threshold if fast_search: loccit = df['TC'].quantile(0.75) @@ -21,12 +27,25 @@ def get_local_cited_authors(df, num_of_cited_authors, fast_search=False): df = metaTagExtraction(df, "SR") M = df.get() - + # Fill missing values M['TC'] = M['TC'].fillna(0) # Create a histogram network H = histNetwork(df, min_citations=loccit, sep=";", network=False) + + # LIMITE NOTO (debugging step, spiegazione completa in + # get_historiograph.py): histNetwork ritorna None per qualunque DB diverso + # da "Web_of_Science"/"Scopus" (incluso il nostro "OPENALEX"), PRIMA di + # toccare CR — non e' un problema di formato dei riferimenti. Non estendiamo + # histNetwork qui (richiederebbe M['SR_FULL'], che scartiamo deliberatamente, + # e produrrebbe comunque reti quasi sempre vuote su campioni tipici). + # Propaghiamo (None, None) invece di un TypeError: stesso sentinel None gia' + # gestito da ogni consumer in app.py (local_cited_authors_result = + # reactive.value(None), con `if result is None` -> placeholder). + if H is None: + return None + LCS = H['histData'] M = H['M'] diff --git a/functions/get_localciteddocuments.py b/functions/get_localciteddocuments.py index 1dea8d5a5..5a1ccfa31 100644 --- a/functions/get_localciteddocuments.py +++ b/functions/get_localciteddocuments.py @@ -12,6 +12,12 @@ def get_local_cited_documents(df, num_of_local_cited_docs, field_separator, fast Returns: A Plotly figure object and a DataFrame of the most local cited documents. + + None (non una tupla) se la sorgente del DataFrame non e' supportata + per l'analisi citazionale diretta (vedi nota sotto), invece di + sollevare TypeError — stesso singolo valore sentinella che app.py + verifica con `if result is None` al punto di chiamata + (local_cited_documents_results.set(result) senza spacchettare prima). """ df = metaTagExtraction(df, "SR") M = df.get() @@ -21,12 +27,22 @@ def get_local_cited_documents(df, num_of_local_cited_docs, field_separator, fast loccit = M['TC'].quantile(0.75) else: loccit = 1 - + # Fill missing values M['TC'] = M['TC'].fillna(0) # Create a histogram network H = histNetwork(df, min_citations=loccit, sep=";", network=False) + + # LIMITE NOTO (debugging step, spiegazione completa in + # get_historiograph.py): histNetwork ritorna None per qualunque DB diverso + # da "Web_of_Science"/"Scopus" (incluso il nostro "OPENALEX"), PRIMA di + # toccare CR — non e' un problema di formato dei riferimenti. Non estendiamo + # histNetwork qui (richiederebbe M['SR_FULL'], che scartiamo deliberatamente, + # e produrrebbe comunque reti quasi sempre vuote su campioni tipici). + if H is None: + return None + LCS = H['histData'] M = H['M'] diff --git a/www/services/__init__.py b/www/services/__init__.py index 28584e105..8b6b3fefe 100644 --- a/www/services/__init__.py +++ b/www/services/__init__.py @@ -1,6 +1,7 @@ from .biblionetwork import * from .cocmatrix import * from .couplingmap import * +from .etl_pipeline import * from .format_functions import * from .histnetwork import * from .histplot import * @@ -8,10 +9,13 @@ from .igraph2vis import * from .metatagextraction import * from .networkplot import * +from .openalex_client import * +from .openalex_mapper import * from .parsers import * from .plotlydownload import * from .savereport import * from .tabletag import * from .termextraction import * from .thematicmap import * +from .type_contracts import * from .utils import * \ No newline at end of file diff --git a/www/services/etl_pipeline.py b/www/services/etl_pipeline.py new file mode 100644 index 000000000..5dc9ea17e --- /dev/null +++ b/www/services/etl_pipeline.py @@ -0,0 +1,293 @@ +""" +Entry-point unico per la pipeline ETL "query utente -> OpenAlex -> schema WoS-style". + +Orchestra, in ordine: +1. query utente -> openalex_client (ricerca + paginazione cursor-based, con + risoluzione opzionale in batch degli ID in `referenced_works`) +2. openalex_client -> openalex_mapper (mapping di ciascun "work" grezzo sulle + 34 colonne dello schema bibliometrix-style) +3. calcolo dei campi derivati che richiedono l'intera collezione (es. SR, che deve + deduplicare le sigle "Autore, Anno, Rivista" ripetute nel dataset, con la stessa + logica concettuale di metatagextraction.py::SR) +4. type_contracts (coercizione e validazione dei tipi prima di finalizzare l'output) +5. assemblaggio del pandas.DataFrame finale, con lo stesso schema a 34 colonne + prodotto dalla pipeline storica basata su file (vedi + www/services/format_functions.py::process_single_file). + +Questo modulo e' pensato come punto di ingresso alternativo a +functions/get_data.py (che copre l'import da file WoS/Scopus/ecc.), cosi' che il +resto della codebase (le funzioni in functions/get_*.py, che operano sul +DataFrame condiviso `df`) possa continuare a funzionare senza sapere se i dati +provengono da un file caricato dall'utente o da una query OpenAlex. +""" + +from .utils import * +from .openalex_client import * +from .openalex_mapper import * +from .type_contracts import * + + +class ETLPipelineError(Exception): + """Sollevata quando la pipeline ETL fallisce in uno dei suoi stadi (fetch, + risoluzione referenze, mapping, validazione) in un modo che non permette di + produrre un DataFrame utilizzabile.""" + pass + + +def run_openalex_etl( + query, + filters=None, + mailto=None, + max_results=None, + resolve_references=True, + strict_validation=True, +): + """ + Entry-point principale: esegue l'intera pipeline ETL da una query utente + OpenAlex a un pandas.DataFrame nello schema a 34 colonne WoS-style. + + Args: + query: stringa di ricerca testuale libera, inoltrata a + openalex_client.search_works. + filters: dict opzionale di filtri OpenAlex aggiuntivi (es. anno, tipo + documento), vedi openalex_client.search_works. + mailto: email da usare per la polite pool di OpenAlex. + max_results: numero massimo di record da scaricare; None per scaricare + tutti i risultati della query. + resolve_references: se True, risolve in batch gli ID in `referenced_works` + per popolare la colonna CR con citazioni leggibili invece dei soli ID + OpenAlex (costo aggiuntivo in chiamate HTTP, vedi + openalex_client.get_works_by_ids). + strict_validation: passato come `strict` a type_contracts.validate_record + per ogni record dopo la coercizione dei tipi: se True (default), + eventuali colonne non riconosciute nello schema canonico contano come + errori di validazione; se False, vengono tollerate. In ENTRAMBI i + casi, qualunque altro errore residuo (tipo non conforme, colonna + obbligatoria mancante, None/NaN sopravvissuto alla coercizione) fa + comunque sollevare ETLPipelineError da _validate_records: e' una rete + di sicurezza interna che non dovrebbe mai scattare in condizioni + normali (map_work_to_record + coerce_record_types garantiscono gia' + record conformi), non un comportamento disattivabile con questo flag. + + Returns: + Tupla (df, validation_errors): + - df: pandas.DataFrame con lo schema a 34 colonne bibliometrix-style, + colonne nell'ordine di `columns` (www/services/utils.py). + - validation_errors: list[str], sempre [] nel percorso di successo + (qualunque errore di validazione residuo interrompe la pipeline con + ETLPipelineError prima di raggiungere il return — vedi + _validate_records). Il secondo elemento della tupla e' mantenuto per + stabilita' della firma pubblica, per il caso in cui in futuro + _validate_records venga reso tollerante invece che bloccante. + + Raises: + ETLPipelineError: se uno stadio non recuperabile della pipeline fallisce + (nessun risultato dalla query, errore HTTP non gestito dal client, + oppure errori di validazione residui dopo la coercizione dei tipi). + """ + works = _fetch_raw_works(query, filters, mailto, max_results) + + resolved_references = None + if resolve_references: + resolved_references = _resolve_references_for_works(works, mailto) + + records = _map_works_to_records(works, resolved_references=resolved_references) + records = _compute_calculated_fields(records) + + coerced_records, _ = _validate_records(records, strict=strict_validation) + + df = _build_dataframe(coerced_records) + + return df, [] + + +def _fetch_raw_works(query, filters, mailto, max_results): + """ + Stadio 1: recupera dalla API OpenAlex la lista grezza di oggetti "work" (dict + JSON) corrispondenti alla query utente, delegando a + openalex_client.search_works (che gia' pagina internamente con cursore fino + a max_results o esaurimento dei risultati). + + Args: + query, filters, mailto, max_results: vedi run_openalex_etl. + + Returns: + list[dict]: oggetti "work" OpenAlex grezzi. + + Raises: + ETLPipelineError: se la query non produce alcun risultato, oppure se + openalex_client.search_works solleva OpenAlexRequestError (errore + HTTP non recuperabile dopo i retry). + """ + try: + works = search_works(query, max_results=max_results, filters=filters, mailto=mailto) + except OpenAlexRequestError as exc: + raise ETLPipelineError( + f"Recupero dei work da OpenAlex fallito per la query {query!r}: {exc}" + ) from exc + + if not works: + raise ETLPipelineError(f"Nessun risultato OpenAlex per la query {query!r}.") + + return works + + +def _resolve_references_for_works(works, mailto): + """ + Stadio 2 (opzionale): raccoglie l'unione di tutti gli ID presenti nel campo + `referenced_works` dei work scaricati e li risolve in batch tramite + openalex_client.get_works_by_ids, per costruire citazioni leggibili per la + colonna CR invece dei soli ID. + + Deduplica a livello di INTERA collezione (non per singolo work) e chiama + get_works_by_ids UNA SOLA VOLTA sull'unione di tutti gli ID: get_works_by_ids + spezza gia' internamente in chunk da 50 (il suo DEFAULT_BATCH_SIZE), quindi + minimizzare qui il numero di chiamate a get_works_by_ids stessa (una sola, + con l'intera lista) minimizza a cascata il numero di richieste HTTP totali + rispetto a chiamarla una volta per work. + + Ogni referenced_works di un singolo work viene troncato a + openalex_mapper.MAX_REFERENCED_WORKS PRIMA di entrare nell'unione: sono gli + stessi ID che format_cr_column considerera' comunque (lo stesso cap), quindi + risolvere ID oltre quel limite sarebbe lavoro sprecato. + + Args: + works: list[dict] di work OpenAlex grezzi, vedi _fetch_raw_works. + mailto: email per la polite pool. + + Returns: + dict[str, dict]: mappa da ID OpenAlex a oggetto "work" risolto, passata a + openalex_mapper.map_work_to_record / format_cr_column. Dizionario vuoto + se nessun work ha referenced_works. + """ + seen = set() + all_ids = [] + for work in works: + referenced = (work.get("referenced_works") or [])[:MAX_REFERENCED_WORKS] + for ref_id in referenced: + if ref_id and ref_id not in seen: + seen.add(ref_id) + all_ids.append(ref_id) + + if not all_ids: + return {} + + return get_works_by_ids(all_ids, mailto=mailto) + + +def _map_works_to_records(works, resolved_references=None): + """ + Stadio 3: applica openalex_mapper.map_work_to_record a ciascun work grezzo, + producendo la lista di record (dict) nello schema a 34 colonne WoS-style + (esclusi i campi calcolati a livello di collezione come SR). + + Args: + works: list[dict] di work OpenAlex grezzi. + resolved_references: dict opzionale id -> work risolto, vedi + _resolve_references_for_works; None se resolve_references=False in + run_openalex_etl (CR contera' presumibilmente solo gli ID grezzi). + + Returns: + list[dict]: un record per work, con le chiavi delle 34 colonne (tranne i + campi calcolati a livello di collezione). + """ + return [ + map_work_to_record(work, resolved_references=resolved_references) + for work in works + ] + + +def _compute_calculated_fields(records): + """ + Stadio 4: calcola i campi derivati dall'intera collezione, in particolare SR + (Autore, Anno, Rivista), che richiede la deduplicazione tra tutti i record del + dataset (stessa logica concettuale di metatagextraction.py::SR, adattata per + operare su una lista di record invece che su un DataFrame reattivo wrappato da + `df.get()`/`df.set()`). + + Args: + records: list[dict] prodotta da _map_works_to_records. + + Returns: + list[dict]: nuovi record (gli originali non vengono mutati, vedi + compute_sr_for_records), arricchiti con la chiave "SR". Nota: la + funzione riusata, openalex_mapper.compute_sr_for_records, aggiunge + anche "SR_FULL" (sottoprodotto di metatagextraction.py::SR, riusata + invariata) — SR_FULL viene deliberatamente SCARTATA qui perche' non fa + parte dello schema canonico a 34 colonne (`columns` in utils.py): + decisione presa esplicitamente dopo averlo verificato con + type_contracts.validate_record durante lo sviluppo di questo modulo. + """ + enriched = compute_sr_for_records(records) + for record in enriched: + record.pop("SR_FULL", None) + return enriched + + +def _validate_records(records, strict=True): + """ + Stadi 5+6: prima coercizione (type_contracts.coerce_record_types su ogni + record, per assorbire inconsistenze di tipo note es. TC/PY come stringa), + poi validazione (type_contracts.validate_record sui record gia' coerciti). + + Se, DOPO la coercizione, restano errori di validazione, solleva + ETLPipelineError con il dettaglio invece di restituirli: coerce_record_types + e' pensata per garantire sempre output conforme, quindi un errore residuo a + questo punto significa una regressione nella pipeline stessa (es. un + format_XX_column che ha smesso di rispettare il proprio contratto di tipo), + non un problema recuperabile sui dati di un singolo work — e' la "rete di + sicurezza finale" del brief, non un controllo disattivabile. + + Args: + records: list[dict] da validare, dopo il calcolo dei campi derivati + (vedi _compute_calculated_fields). + strict: propagato a type_contracts.validate_record; se True, record con + colonne non riconosciute sono considerati invalidi. + + Returns: + Tupla (coerced_records, errors): + - coerced_records: list[dict] con i valori coerciti da + type_contracts.coerce_record_types. + - errors: list[str], SEMPRE [] quando la funzione ritorna normalmente + (qualunque errore residuo fa sollevare ETLPipelineError prima del + return). Mantenuta nella firma per stabilita' dell'API. + + Raises: + ETLPipelineError: se validate_record rileva almeno un errore su almeno + un record dopo la coercizione. + """ + coerced_records = [coerce_record_types(record) for record in records] + + errors = [] + for index, record in enumerate(coerced_records): + for error in validate_record(record, strict=strict): + errors.append(f"record {index}: {error}") + + if errors: + raise ETLPipelineError( + "Validazione fallita su record gia' coerciti (non dovrebbe accadere): " + + "; ".join(errors) + ) + + return coerced_records, errors + + +def _build_dataframe(records): + """ + Stadio 6: assembla il pandas.DataFrame finale a partire dalla lista di record + validati, garantendo che le colonne siano nello stesso ordine dello schema a + 34 colonne definito in www/services/utils.py (variabile `columns`), per + compatibilita' con le funzioni esistenti in functions/get_*.py che assumono + questo schema. + + Args: + records: list[dict] di record coerciti/validati. + + Returns: + pandas.DataFrame con lo schema a 34 colonne WoS-style, colonne ordinate + secondo `columns` (www/services/utils.py). Con records=[] restituisce un + DataFrame a 0 righe ma con tutte le 34 colonne comunque definite. + """ + df = pd.DataFrame(records) + df = df.reindex(columns=columns) + return df diff --git a/www/services/openalex_client.py b/www/services/openalex_client.py new file mode 100644 index 000000000..e14e4718d --- /dev/null +++ b/www/services/openalex_client.py @@ -0,0 +1,310 @@ +""" +Client HTTP per l'API pubblica di OpenAlex (https://api.openalex.org). + +Responsabilita' di questo modulo: +- Eseguire ricerche testuali sull'endpoint /works. +- Gestire la paginazione cursor-based per scaricare risultati oltre la singola pagina. +- Applicare retry con backoff esponenziale sugli errori transitori (rate limit 429, + errori 5xx, timeout di rete). +- Risolvere in batch una lista di ID OpenAlex (usato per popolare la colonna CR a + partire dagli ID grezzi contenuti in `referenced_works`). + +Questo modulo non fa alcun mapping verso lo schema WoS-style: restituisce sempre i +dizionari JSON grezzi cosi' come li restituisce OpenAlex. Il mapping verso le 34 +colonne e' responsabilita' di openalex_mapper.py; l'orchestrazione dei due e' +responsabilita' di etl_pipeline.py. +""" + +import logging + +from .utils import * + + +logger = logging.getLogger(__name__) + + +BASE_URL = "https://api.openalex.org" +WORKS_ENDPOINT = f"{BASE_URL}/works" + +DEFAULT_PER_PAGE = 25 +MAX_PER_PAGE = 200 # limite massimo imposto dalle API OpenAlex +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BACKOFF_FACTOR = 1.5 +DEFAULT_TIMEOUT = 10 # secondi +DEFAULT_BATCH_SIZE = 50 # limite OpenAlex per filter=openalex_id:ID1|ID2|... + + +class OpenAlexRequestError(Exception): + """Sollevata quando una richiesta a OpenAlex fallisce in modo non recuperabile + (status 4xx diverso da 429, oppure 5xx/timeout dopo l'esaurimento dei retry).""" + pass + + +def search_works(query, max_results=None, filters=None, mailto=None, per_page=MAX_PER_PAGE): + """ + Esegue una ricerca testuale completa su /works, paginando automaticamente + con cursore finche' OpenAlex non restituisce `meta.next_cursor == None` + oppure finche' non e' stato raccolto `max_results` risultati. + + Ogni singola richiesta di pagina passa da _request_with_retry (retry con + backoff esponenziale su 429/5xx/errori di rete, vedi quella funzione). + + Args: + query: stringa di ricerca libera, mappata sul parametro `search` di OpenAlex. + max_results: numero massimo di risultati da raccogliere complessivamente; + None per scaricare tutti i risultati della query (attenzione: puo' + comportare molte richieste HTTP su query con molti match). + filters: dict opzionale di filtri aggiuntivi (es. {"publication_year": "2020-2023"}), + serializzato nel parametro `filter` di OpenAlex come coppie + "chiave:valore" separate da virgola (AND tra chiavi diverse; per OR + sullo stesso campo il valore va gia' passato nel formato + "a|b|c" da chi costruisce il dict). + mailto: email da passare come parametro `mailto` per rientrare nella + "polite pool" di OpenAlex (rate limit piu' alto e prioritario). + per_page: risultati per pagina richiesti ad OpenAlex ad ogni chiamata + (di default MAX_PER_PAGE=200, il massimo consentito, per minimizzare + il numero di richieste). Esposto come parametro soprattutto per + poterlo abbassare nei test, cosi' da forzare piu' pagine anche con + max_results piccoli. + + Returns: + list[dict]: work grezzi raccolti su tutte le pagine necessarie, + nell'ordine restituito da OpenAlex, troncati a max_results se specificato. + + Raises: + OpenAlexRequestError: se una richiesta di pagina fallisce in modo non + recuperabile (propagata da _request_with_retry). + """ + if max_results is not None and max_results <= 0: + return [] + + effective_per_page = min(per_page, MAX_PER_PAGE) + + results = [] + cursor = "*" + + while cursor is not None: + params = { + "search": query, + "per-page": effective_per_page, + "cursor": cursor, + } + if mailto: + params["mailto"] = mailto + if filters: + params["filter"] = _serialize_filters(filters) + + response = _request_with_retry(WORKS_ENDPOINT, params) + payload = response.json() + + page_results = payload.get("results", []) + if not page_results: + break + + results.extend(page_results) + + if max_results is not None and len(results) >= max_results: + results = results[:max_results] + break + + cursor = (payload.get("meta") or {}).get("next_cursor") + + return results + + +def _serialize_filters(filters): + """ + Funzione interna: serializza un dict di filtri nel formato query-string + atteso dal parametro `filter` di OpenAlex: coppie "chiave:valore" separate + da virgola (semantica AND tra chiavi diverse). La sintassi OR su uno stesso + campo (`chiave:valore1|valore2`) va gia' incapsulata nel valore passato per + quella chiave da chi costruisce il dict `filters`. + + Args: + filters: dict[str, str] di filtri OpenAlex. + + Returns: + str: valore da assegnare al parametro `filter` nella query string. + """ + return ",".join(f"{key}:{value}" for key, value in filters.items()) + + +def get_work_by_id(openalex_id, mailto=None): + """ + Recupera un singolo "work" OpenAlex dato il suo ID (short form "W123..." o URL + completo "https://openalex.org/W123..."). + + Args: + openalex_id: identificatore OpenAlex del work da recuperare. + mailto: email per la polite pool. + + Returns: + dict | None: l'oggetto "work" grezzo, oppure None se non trovato (404). + + Raises: + OpenAlexRequestError: per errori diversi da 404. + """ + raise NotImplementedError + + +def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE): + """ + Risolve in batch una lista di ID OpenAlex verso i rispettivi oggetti "work" + completi, usando il filtro OR `openalex_id:ID1|ID2|...` supportato da /works + (fino a 50 ID per chiamata, limite imposto dall'API OpenAlex) per minimizzare + il numero di richieste HTTP. + + Usato tipicamente da openalex_mapper.py::format_cr_column per risolvere il + contenuto di `referenced_works` (che in OpenAlex e' solo una lista di ID) + nelle citazioni leggibili richieste dalla colonna CR dello schema WoS-style. + + Un batch che fallisce in modo persistente (dopo tutti i retry di + _request_with_retry) NON interrompe la risoluzione degli altri batch: + l'errore viene loggato e gli ID di quel batch restano semplicemente assenti + dal dizionario restituito, cosi' come gli ID non trovati da OpenAlex. Il + chiamante non puo' distinguere "non trovato" da "batch fallito" guardando + solo il dizionario risultato: se questa distinzione servisse a valle, andra' + aggiunta separatamente (es. restituendo anche la lista di ID falliti). + + Args: + ids: lista di ID OpenAlex (forma short "W123..." o URL completo + "https://openalex.org/W123...") da risolvere. Duplicati e ID + vuoti/None vengono ignorati. + mailto: email per la polite pool. + batch_size: numero massimo di ID per chiamata; la lista (deduplicata e + normalizzata) viene spezzata in chunk di questa dimensione. + + Returns: + dict[str, dict]: mappa da ID OpenAlex normalizzato (short form) al + relativo oggetto "work" grezzo. Gli ID non risolvibili (non trovati da + OpenAlex, oppure appartenenti a un batch fallito dopo i retry) sono + semplicemente assenti dal risultato. + """ + normalized_ids = [] + seen = set() + for raw_id in ids or []: + normalized = _normalize_openalex_id(raw_id) + if normalized and normalized not in seen: + seen.add(normalized) + normalized_ids.append(normalized) + + resolved = {} + for batch_start in range(0, len(normalized_ids), batch_size): + batch = normalized_ids[batch_start:batch_start + batch_size] + params = { + "filter": "openalex_id:" + "|".join(batch), + # per-page deve coprire l'intero batch, altrimenti si rischia di + # ricevere solo i primi DEFAULT_PER_PAGE risultati del filtro OR. + "per-page": len(batch), + } + if mailto: + params["mailto"] = mailto + + try: + response = _request_with_retry(WORKS_ENDPOINT, params) + except OpenAlexRequestError as exc: + logger.error( + "get_works_by_ids: batch di %d ID fallito dopo i retry (primi ID: %s): %s", + len(batch), batch[:3], exc, + ) + continue + + payload = response.json() + for work in payload.get("results", []): + work_id = _normalize_openalex_id(work.get("id")) + if work_id: + resolved[work_id] = work + + return resolved + + +def _normalize_openalex_id(openalex_id_or_url): + """ + Funzione interna: normalizza un ID OpenAlex alla forma short (es. "W123456789"), + accettando sia la forma short che l'URL completo ("https://openalex.org/W123456789"). + + Args: + openalex_id_or_url: ID OpenAlex in una qualsiasi delle due forme, oppure + None/stringa vuota. + + Returns: + str: ID in forma short, normalizzato; stringa vuota se + openalex_id_or_url e' None/vuoto. + """ + if not openalex_id_or_url: + return "" + return openalex_id_or_url.rsplit("/", 1)[-1] + + +def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_factor=DEFAULT_BACKOFF_FACTOR, timeout=DEFAULT_TIMEOUT): + """ + Funzione interna: esegue una GET HTTP con retry ed exponential backoff. + + Riprova la richiesta in caso di: + - errori di rete/timeout, + - HTTP 429 (rate limit), rispettando l'header Retry-After se presente + (altrimenti backoff esponenziale), + - HTTP 5xx (errori transitori lato server). + + Non riprova su errori 4xx diversi da 429 (es. 400/404), che vengono considerati + definitivi e propagati immediatamente come OpenAlexRequestError. + + Args: + url: URL completo della richiesta. + params: dict di query string da passare a requests.get. + max_retries: numero massimo di RI-tentativi dopo il primo (quindi al + massimo max_retries + 1 richieste HTTP totali). + backoff_factor: fattore moltiplicativo per il tempo di attesa tra un + tentativo e il successivo (attesa = backoff_factor ** tentativo). + timeout: timeout in secondi per ciascuna richiesta HTTP. + + Returns: + requests.Response: la risposta HTTP con status < 400. + + Raises: + OpenAlexRequestError: su errore 4xx diverso da 429 (nessun retry), oppure + dopo l'esaurimento dei retry per 429/5xx/errori di rete, con status + code e corpo della risposta inclusi nel messaggio per facilitare il debug. + """ + last_error = None + + for attempt in range(max_retries + 1): + try: + response = requests.get(url, params=params, timeout=timeout) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: + last_error = exc + if attempt == max_retries: + raise OpenAlexRequestError( + f"Richiesta a {url} fallita dopo {max_retries + 1} tentativi: {exc}" + ) from exc + time.sleep(backoff_factor ** attempt) + continue + + if response.status_code < 400: + return response + + if response.status_code == 429 or response.status_code >= 500: + last_error = OpenAlexRequestError( + f"HTTP {response.status_code} da {url}: {response.text[:500]}" + ) + if attempt == max_retries: + raise last_error + + retry_after = response.headers.get("Retry-After") + wait_seconds = backoff_factor ** attempt + if retry_after is not None: + try: + wait_seconds = float(retry_after) + except ValueError: + pass + time.sleep(wait_seconds) + continue + + # 4xx diverso da 429: errore considerato definitivo, nessun retry. + raise OpenAlexRequestError( + f"HTTP {response.status_code} da {url}: {response.text[:500]}" + ) + + # Non raggiungibile in condizioni normali (il loop ritorna o solleva ad ogni + # iterazione), presente solo per robustezza. + raise OpenAlexRequestError(f"Richiesta a {url} fallita: {last_error}") diff --git a/www/services/openalex_mapper.py b/www/services/openalex_mapper.py new file mode 100644 index 000000000..a1d66bbe2 --- /dev/null +++ b/www/services/openalex_mapper.py @@ -0,0 +1,1166 @@ +""" +Mapping OpenAlex -> schema a 34 colonne WoS-style. + +Speculare a www/services/format_functions.py (che copre WoS/Scopus/Dimensions/ +The_Lens/PubMed/Cochrane): qui e' definita una sola sorgente, "OpenAlex", con una +funzione format_XX_column per ciascuna delle 34 colonne dello schema bibliometrix, +piu' una funzione di orchestrazione map_work_to_record che le combina in un unico +record cosi' come fa il blocco `entry_data = {...}` in format_functions.py::process_single_file. + +Differenze principali rispetto alla pipeline WoS storica, da tenere presenti in +fase di implementazione (vedi analisi fatta in conversazione): +- authorships[].institutions[] e' gia' strutturato (niente split posizionali su + stringhe di affiliazione ne' whitelist di paesi come in metatagextraction.py::AU_CO). +- abstract_inverted_index va ricostruito in una stringa lineare prima di popolare AB. +- referenced_works e' solo una lista di ID: per popolare CR con citazioni leggibili + serve l'output di openalex_client.get_works_by_ids (parametro resolved_references). +- cited_by_count e' gia' un int (a differenza di TC in format_tc_column, che restava + stringa quando proveniva da WoS .txt/.ciw). +- alcune colonne dello schema WoS-style non hanno, per decisione esplicita presa in + fase di design, un equivalente popolato in questa pipeline: EM, FU, FX, JI, OI, + PU, RP, SC restituiscono sempre stringa vuota (vedi i rispettivi format_XX_column + per la motivazione puntuale), anche quando OpenAlex esporrebbe un dato parziale + utilizzabile (es. RP da `is_corresponding`, PU da `host_organization_name`). JI + vuota e' pero' intenzionale anche nella pipeline WoS storica per le sorgenti + senza abbreviazione (vedi format_ji_column): non e' un dato mancante, e' + il segnale che fa scattare il fallback su SO dentro metatagextraction.py::SR. +- SN (ISSN) deriva da `primary_location.source.issn_l`, spostato qui da JI dopo + aver verificato che SN e' il campo ISSN in tutte le sorgenti storiche + (format_functions.py::format_sn_column). +- SR (Short Reference) NON ha una format_sr_column(work) a livello di singolo + record, a differenza delle altre colonne: la funzione esistente riusata per + calcolarla, metatagextraction.py::SR(M), richiede l'INTERA collezione gia' + in forma di DataFrame per poter deduplicare correttamente i valori ripetuti + (suffissi "-a", "-b", ...). Vedi compute_sr_for_records più in basso e + etl_pipeline.py::_compute_calculated_fields per come viene usata. +""" + +from .utils import * +from .metatagextraction import SR + + +# Numero massimo di referenced_works considerati da format_cr_column per un +# singolo work. Decisione presa in questo punto dello sviluppo (non +# preesistente): alcuni work OpenAlex citano centinaia di referenze, e +# risolverle/formattarle tutte avrebbe un costo (chiamate batch a +# get_works_by_ids, dimensione di CR) sproporzionato rispetto al beneficio per +# un campo che nello schema WoS-style e' comunque una lista informativa, non +# esaustiva per definizione in molte fonti. +MAX_REFERENCED_WORKS = 100 + +# Mappa dichiarativa colonna WoS-style -> percorso/estrattore OpenAlex. +# Pensata come documentazione leggibile del mapping (colonna -> dove si trova il +# dato in un oggetto "work" OpenAlex), non necessariamente usata a runtime. +# Popolata in fase di implementazione con il mapping discusso in conversazione, +# es.: {"TI": "title", "PY": "publication_year", "SO": "primary_location.source.display_name", ...} +OPENALEX_FIELD_MAP: dict = {} + +# Mappa OpenAlex `type` (vocabolario controllato, vedi +# https://docs.openalex.org/api-entities/works/work-object#type) -> vocabolario +# WoS-style per la colonna DT. Copre i type piu' comuni; un type OpenAlex non +# presente qui non e' un errore: format_dt_column ricade sul valore originale +# capitalizzato invece di perdere l'informazione (vedi format_dt_column). +OPENALEX_TYPE_TO_WOS_DT: dict = { + "article": "Article", + "review": "Review", + "book-chapter": "Book Chapter", + "preprint": "Preprint", + "dataset": "Dataset", + "dissertation": "Thesis", + "book": "Book", + "editorial": "Editorial Material", + "letter": "Letter", + "erratum": "Correction", + "report": "Report", + "peer-review": "Peer Review", + "paratext": "Paratext", + "standard": "Standard", + "grant": "Grant", + "supplementary-materials": "Supplementary Materials", + "reference-entry": "Reference Entry", + "other": "Other", +} + +# Lookup ISO 3166-1 alpha-2 -> nome paese, nella STESSA convenzione di naming usata +# dalla whitelist in www/static/countries.txt (consumata da +# metatagextraction.py::AU_CO/AU1_CO/AU_UN). I nomi qui DEVONO combaciare +# esattamente con le righe di quel file, cosi' che una stringa C1 costruita da +# format_c1_column resti riconoscibile dal matching a valle +# (`c1.split(",")[-1].strip().upper()` + ricerca `\b\b` nella whitelist) +# senza dover toccare metatagextraction.py. +# +# Note sulle scelte fatte per allinearsi a countries.txt: +# - "US" -> "UNITED STATES" (non "USA": entrambe le forme sono in whitelist, ma +# AU_CO() normalizza comunque "UNITED STATES" -> "USA" a valle, quindi il +# risultato finale coincide). +# - "GB" -> "UNITED KINGDOM" (in whitelist esistono anche ENGLAND/SCOTLAND/WALES/ +# NORTH IRELAND come alias storici WoS, ma non sono codici ISO e OpenAlex non +# li restituisce mai come country_code). +# - "RU" -> "RUSSIA" (non "RUSSIAN FEDERATION", assente dalla whitelist). +# - "MK" -> "NORTH MACEDONIA" (nome ISO corrente; "MACEDONIA" resta in whitelist +# come alias storico ma non e' il target di questa lookup). +# - "TW" -> "TAIWAN" (AU_CO() normalizza comunque "TAIWAN" -> "CHINA" a valle). +# - "CD" -> "CONGO" (la whitelist ha una sola voce "CONGO", senza distinzione +# Congo-Brazzaville/Congo-Kinshasa; stessa approssimazione gia' presente li'). +ISO_COUNTRY_CODE_TO_NAME: dict = { + "AF": "AFGHANISTAN", "AL": "ALBANIA", "DZ": "ALGERIA", "AD": "ANDORRA", + "AO": "ANGOLA", "AG": "ANTIGUA", "AR": "ARGENTINA", "AM": "ARMENIA", + "AU": "AUSTRALIA", "AT": "AUSTRIA", "AZ": "AZERBAIJAN", "BS": "BAHAMAS", + "BH": "BAHRAIN", "BD": "BANGLADESH", "BB": "BARBADOS", "BY": "BELARUS", + "BE": "BELGIUM", "BZ": "BELIZE", "BJ": "BENIN", "BT": "BHUTAN", + "BO": "BOLIVIA", "BA": "BOSNIA", "BW": "BOTSWANA", "BR": "BRAZIL", + "BN": "BRUNEI", "BG": "BULGARIA", "BF": "BURKINA FASO", "BI": "BURUNDI", + "CV": "CABO VERDE", "KH": "CAMBODIA", "CM": "CAMEROON", "CA": "CANADA", + "CF": "CENTRAL AFRICAN REPUBLIC", "TD": "CHAD", "CL": "CHILE", "CN": "CHINA", + "CO": "COLOMBIA", "KM": "COMOROS", "CG": "CONGO", "CD": "CONGO", + "CR": "COSTA RICA", "CI": "COTE D'IVOIRE", "HR": "CROATIA", "CU": "CUBA", + "CY": "CYPRUS", "CZ": "CZECH REPUBLIC", "DK": "DENMARK", "DJ": "DJIBOUTI", + "DM": "DOMINICA", "DO": "DOMINICAN REPUBLIC", "EC": "ECUADOR", "EG": "EGYPT", + "SV": "EL SALVADOR", "GQ": "EQUATORIAL GUINEA", "ER": "ERITREA", + "EE": "ESTONIA", "ET": "ETHIOPIA", "FO": "FAROE", "FJ": "FIJI", + "FI": "FINLAND", "FR": "FRANCE", "GA": "GABON", "GM": "GAMBIA", + "GE": "GEORGIA", "DE": "GERMANY", "GH": "GHANA", "GR": "GREECE", + "GU": "GUAM", "GT": "GUATEMALA", "GN": "GUINEA", "GW": "GUINEA-BISSAU", + "HT": "HAITI", "HN": "HONDURAS", "HK": "HONG KONG", "HU": "HUNGARY", + "IS": "ICELAND", "IN": "INDIA", "ID": "INDONESIA", "IR": "IRAN", + "IQ": "IRAQ", "IE": "IRELAND", "IL": "ISRAEL", "IT": "ITALY", + "JM": "JAMAICA", "JP": "JAPAN", "JO": "JORDAN", "KZ": "KAZAKHSTAN", + "KE": "KENYA", "KI": "KIRIBATI", "KR": "KOREA", "XK": "KOSOVO", + "KW": "KUWAIT", "KG": "KYRGYZSTAN", "LA": "LAOS", "LV": "LATVIA", + "LB": "LEBANON", "LS": "LESOTHO", "LR": "LIBERIA", "LY": "LIBYA", + "LI": "LIECHTENSTEIN", "LT": "LITHUANIA", "LU": "LUXEMBOURG", + "MK": "NORTH MACEDONIA", "MG": "MADAGASCAR", "MW": "MALAWI", + "MY": "MALAYSIA", "MV": "MALDIVES", "ML": "MALI", "MT": "MALTA", + "MH": "MARSHALL ISLANDS", "MR": "MAURITANIA", "MU": "MAURITIUS", + "MX": "MEXICO", "FM": "MICRONESIA", "MD": "MOLDOVA", "MC": "MONACO", + "MN": "MONGOLIA", "ME": "MONTENEGRO", "MA": "MOROCCO", "MZ": "MOZAMBIQUE", + "MM": "MYANMAR", "NA": "NAMIBIA", "NR": "NAURU", "NP": "NEPAL", + "NL": "NETHERLANDS", "NZ": "NEW ZEALAND", "NI": "NICARAGUA", "NE": "NIGER", + "NG": "NIGERIA", "KP": "NORTH KOREA", "NO": "NORWAY", "OM": "OMAN", + "PK": "PAKISTAN", "PW": "PALAU", "PA": "PANAMA", "PG": "PAPUA NEW GUINEA", + "PY": "PARAGUAY", "PE": "PERU", "PH": "PHILIPPINES", "PL": "POLAND", + "PT": "PORTUGAL", "QA": "QATAR", "RO": "ROMANIA", "RU": "RUSSIA", + "RW": "RWANDA", "KN": "SAINT KITTS AND NEVIS", "LC": "SAINT LUCIA", + "WS": "SAMOA", "SM": "SAN MARINO", "ST": "SAO TOME AND PRINCIPE", + "SA": "SAUDI ARABIA", "SN": "SENEGAL", "RS": "SERBIA", "SC": "SEYCHELLES", + "SL": "SIERRA LEONE", "SG": "SINGAPORE", "SK": "SLOVAKIA", "SI": "SLOVENIA", + "SB": "SOLOMON ISLANDS", "SO": "SOMALIA", "ZA": "SOUTH AFRICA", + "SS": "SOUTH SUDAN", "ES": "SPAIN", "LK": "SRI LANKA", "SD": "SUDAN", + "SR": "SURINAME", "SZ": "SWAZILAND", "SE": "SWEDEN", "CH": "SWITZERLAND", + "SY": "SYRIA", "TW": "TAIWAN", "TJ": "TAJIKISTAN", "TZ": "TANZANIA", + "TH": "THAILAND", "TG": "TOGO", "TO": "TONGA", "TT": "TRINIDAD AND TOBAGO", + "TN": "TUNISIA", "TR": "TURKEY", "TM": "TURKMENISTAN", "UG": "UGANDA", + "UA": "UKRAINE", "AE": "UNITED ARAB EMIRATES", "GB": "UNITED KINGDOM", + "US": "UNITED STATES", "UY": "URUGUAY", "UZ": "UZBEKISTAN", "VU": "VANUATU", + "VA": "VATICANO", "VE": "VENEZUELA", "VN": "VIETNAM", "YE": "YEMEN", + "ZM": "ZAMBIA", "ZW": "ZIMBABWE", +} + + +def map_work_to_record(work, resolved_references=None): + """ + Funzione di orchestrazione: converte un singolo oggetto "work" OpenAlex grezzo + in un record (dict) nello schema a 34 colonne WoS-style, chiamando in sequenza + tutte le format_XX_column definite in questo modulo. + + Analoga al blocco `entry_data = {...}` in + www/services/format_functions.py::process_single_file, ma con un'unica sorgente + (OpenAlex) invece del branching multi-sorgente/multi-formato usato li'. + + Non calcola i campi che richiedono l'intera collezione (es. SR, che necessita + di deduplicare "Autore, Anno, Rivista" su tutto il dataset): quello e' compito + di etl_pipeline.py::_compute_calculated_fields, eseguito dopo questa funzione. + + Args: + work: dict, singolo oggetto "work" grezzo restituito da OpenAlex + (vedi openalex_client.search_works). + resolved_references: dict[str, dict] opzionale, mappa ID OpenAlex -> work + risolto, usata da format_cr_column per costruire citazioni leggibili a + partire da `referenced_works`. None se la risoluzione e' stata saltata + (in quel caso CR conterra' presumibilmente i soli ID grezzi). + + Returns: + dict: record con esattamente le chiavi di `columns` (lista canonica + definita in www/services/utils.py) meno "SR" — 33 chiavi in totale. + + Raises: + ValueError: se il dict costruito internamente non coincide esattamente + (ne' per difetto ne' per eccesso) con `set(columns) - {"SR"}| — + indica una regressione tra questa funzione e la lista canonica in + utils.py, non un problema sui dati del work in input. + """ + record = { + "AB": format_ab_column(work), + "AF": format_af_column(work), + "AU": format_au_column(work), + "AU_UN": format_au_un_column(work), + "AU1_UN": format_au1_un_column(work), + "BP": format_bp_column(work), + "EP": format_ep_column(work), + "CR": format_cr_column(work, resolved_references), + "C1": format_c1_column(work), + "DB": format_db_column(work), + "DE": format_de_column(work), + "DI": format_di_column(work), + "DT": format_dt_column(work), + "EM": format_em_column(work), + "FU": format_fu_column(work), + "FX": format_fx_column(work), + "IS": format_is_column(work), + "JI": format_ji_column(work), + "ID": format_id_column(work), + "LA": format_la_column(work), + "OA": format_oa_column(work), + "OI": format_oi_column(work), + "PMID": format_pmid_column(work), + "PU": format_pu_column(work), + "PY": format_py_column(work), + "RP": format_rp_column(work), + "SC": format_sc_column(work), + "SN": format_sn_column(work), + "SO": format_so_column(work), + "TC": format_tc_column(work), + "TI": format_ti_column(work), + "UT": format_ut_column(work), + "VL": format_vl_column(work), + } + + expected_keys = set(columns) - {"SR"} + actual_keys = set(record.keys()) + if actual_keys != expected_keys: + missing = sorted(expected_keys - actual_keys) + extra = sorted(actual_keys - expected_keys) + raise ValueError( + "map_work_to_record: il record prodotto non coincide con lo schema " + "canonico (www/services/utils.py::columns) meno SR. " + f"Mancanti: {missing}. Extra: {extra}." + ) + + return record + + +def format_ab_column(work): + """ + Colonna AB (Abstract). Ricostruisce il testo lineare dell'abstract a partire da + `work["abstract_inverted_index"]` (dict parola -> lista di posizioni), che in + OpenAlex sostituisce l'abstract come stringa unica presente in WoS. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: abstract ricostruito, oppure stringa vuota se `abstract_inverted_index` + e' assente/None (es. per motivi di copyright, come spesso accade in OpenAlex). + """ + return _reconstruct_abstract(work.get("abstract_inverted_index")) + + +def format_af_column(work): + """ + Colonna AF (Authors Full Name). Per la sorgente OpenAlex coincide + esattamente con AU (vedi format_au_column e la nota nel suo docstring): + entrambe derivano da `work["authorships"][].author.display_name` senza + alcuna trasformazione. Questa funzione delega direttamente a + format_au_column invece di duplicarne la logica. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + list[str]: identico all'output di format_au_column(work). + """ + return format_au_column(work) + + +def format_au_column(work): + """ + Colonna AU (Author/s). A differenza di format_au_column in + format_functions.py (che normalizza WoS/Scopus/ecc. nel formato "COGNOME + Iniziali"), qui si usa direttamente `work["authorships"][].author.display_name` + cosi' come restituito da OpenAlex, senza alcuna logica di split cognome/nome: + scelta deliberata per evitare euristiche fragili sui nomi (es. nomi composti, + prefissi, ordini cognome-nome non occidentali) quando il dato "display_name" + e' gia' disponibile in forma leggibile. + + Nota: con questa scelta AU e AF risultano uguali per la sorgente OpenAlex + (entrambi il nome completo dell'autore), a differenza della pipeline WoS + storica dove sono formati distinti ("Cognome I." vs "Cognome, Nome completo"). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + list[str]: un elemento per autore, pari a `author.display_name`, + nell'ordine restituito da `work["authorships"]`. Autori senza `author` o + senza `display_name` vengono omessi. + """ + authors = [] + for authorship in work.get("authorships") or []: + author = authorship.get("author") or {} + display_name = author.get("display_name") + if display_name: + authors.append(display_name) + return authors + + +def format_au_un_column(work): + """ + Colonna AU_UN (Authors University/Institution). Estrae le istituzioni da + `work["authorships"][].institutions[].display_name`, gia' strutturate e + disambiguate da OpenAlex (a differenza di AU_UN in metatagextraction.py, che + deve inferire l'istituzione da una stringa di affiliazione grezza tramite una + whitelist di tag come "UNIV", "COLL", ecc.). + + Restituisce list[str] (non una singola stringa ";"-separated), per coerenza + con cio' che si aspettano i consumer a valle sull'output della pipeline di + import diretta (es. functions/get_affiliationproductionovertime.py e + functions/get_relevantaffiliations.py, che trattano AU_UN come lista per + riga tramite `.apply(...)`/`.explode()`). + + Deduplicazione: se piu' autori condividono la stessa istituzione (stesso + `display_name`), questa compare una sola volta nell'output, preservando + l'ordine di prima apparizione. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + list[str]: nomi delle istituzioni distinte associate agli autori del work, + lista vuota se `work["authorships"]` e' assente/vuoto o nessun autore ha + institutions. + """ + seen = set() + universities = [] + for authorship in work.get("authorships") or []: + for institution in authorship.get("institutions") or []: + name = institution.get("display_name") + if name and name not in seen: + seen.add(name) + universities.append(name) + return universities + + +def format_au1_un_column(work): + """ + Colonna AU1_UN (Institution of the First Author). A differenza di AU_UN, + restituisce una singola stringa (non una lista), per coerenza con + format_au1_un_column in format_functions.py (che restituisce sempre una + stringa, es. `str(entry.get('C3','')).split("; ")[0]`) e con l'etichetta + "First Author University" (singolare) usata in functions/get_table.py. + + Il primo autore e' individuato cercando `author_position == "first"` in + `work["authorships"]`; se il campo non e' presente/valorizzato su nessun + elemento, si ricade sul primo elemento della lista `authorships` (che in + pratica coincide quasi sempre con l'autore in posizione "first"). + + Se il primo autore ha piu' institutions, viene presa solo la prima (stessa + scelta "solo il primo valore" fatta da format_au1_un_column in + format_functions.py per WoS, che tiene solo l'indice 0 dopo lo split su C3). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: nome dell'istituzione del primo autore, "" se non disponibile + (nessun autore, primo autore senza institutions, o institution senza + display_name). + """ + authorships = work.get("authorships") or [] + if not authorships: + return "" + + first_authorship = next( + (a for a in authorships if a.get("author_position") == "first"), + authorships[0], + ) + + institutions = first_authorship.get("institutions") or [] + if not institutions: + return "" + + return institutions[0].get("display_name") or "" + + +def format_bp_column(work): + """ + Colonna BP (Beginning Page). Deriva da `work["biblio"]["first_page"]`, con + accesso robusto ai campi annidati: se `work["biblio"]` e' assente/None, + viene trattato come dict vuoto invece di sollevare AttributeError/KeyError. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: numero di pagina iniziale, "" se assente (comune per preprint, + come visto nell'esempio arXiv analizzato in conversazione). + """ + biblio = work.get("biblio") or {} + return biblio.get("first_page") or "" + + +def format_ep_column(work): + """ + Colonna EP (Ending Page). Deriva da `work["biblio"]["last_page"]`, con + accesso robusto ai campi annidati: se `work["biblio"]` e' assente/None, + viene trattato come dict vuoto invece di sollevare AttributeError/KeyError. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: numero di pagina finale, "" se assente. + """ + biblio = work.get("biblio") or {} + return biblio.get("last_page") or "" + + +def format_cr_column(work, resolved_references=None): + """ + Colonna CR (Cited References). Deriva da `work["referenced_works"]`, che in + OpenAlex e' solo una lista di ID (non stringhe bibliografiche complete come + in WoS). + + Funzione di solo mapping: non fa I/O di rete. `resolved_references` deve + essere gia' stato popolato a monte (tipicamente da una singola chiamata + batch a openalex_client.get_works_by_ids su tutti gli ID di + `referenced_works` di uno o piu' work) e viene passato qui gia' pronto, + per mantenere la separazione tra mapping puro (questo modulo) e I/O di + rete (openalex_client.py). + + Per ogni ID in `work["referenced_works"]` (troncato a MAX_REFERENCED_WORKS + elementi): + - se l'ID e' presente in `resolved_references`, la citazione e' costruita + nello stesso stile "Autore, ANNO, RIVISTA" usato da + metatagextraction.py::SR, riusando format_au_column/format_py_column/ + format_so_column sul work risolto (vedi _format_reference_citation); + - se l'ID NON e' risolvibile (risoluzione fallita, batch andato in errore, + o resolved_references non fornito/None), il riferimento non viene + scartato: l'ID OpenAlex nudo viene incluso cosi' com'e' come fallback, + cosi' l'informazione "esiste un riferimento qui" non va persa in + silenzio anche se non e' stato possibile arricchirla. + + Args: + work: oggetto "work" OpenAlex grezzo. + resolved_references: dict[str, dict] opzionale, mappa ID OpenAlex + normalizzato -> work risolto (vedi openalex_client.get_works_by_ids). + None o {} equivalgono a "nessuna risoluzione disponibile": tutti i + riferimenti ricadono sul fallback ID nudo. + + Returns: + list[str]: una voce per riferimento citato (citazione leggibile o ID + nudo), nell'ordine di `work["referenced_works"]`, troncata a + MAX_REFERENCED_WORKS elementi. Lista vuota se `referenced_works` e' + assente/vuoto. + """ + referenced_ids = (work.get("referenced_works") or [])[:MAX_REFERENCED_WORKS] + resolved_references = resolved_references or {} + + citations = [] + for raw_id in referenced_ids: + if not raw_id: + continue + + normalized_id = raw_id.rsplit("/", 1)[-1] + resolved_work = resolved_references.get(normalized_id) + + if resolved_work: + citations.append(_format_reference_citation(resolved_work)) + else: + citations.append(normalized_id) + + return citations + + +def _format_reference_citation(resolved_work): + """ + Funzione interna: costruisce la citazione leggibile "Autore, ANNO, RIVISTA" + per un singolo work OpenAlex risolto, riusando le format_XX_column gia' + definite in questo modulo (format_au_column, format_py_column, + format_so_column) invece di duplicare la logica di estrazione — stesso + stile della formula "FirstAuthors, PY, J9" usata da + metatagextraction.py::SR per il riferimento breve di un documento. + + Usata da format_cr_column. + + Args: + resolved_work: dict, oggetto "work" OpenAlex grezzo del riferimento + citato (gia' risolto tramite openalex_client.get_works_by_ids). + + Returns: + str: "Autore, ANNO, RIVISTA". Il primo autore e' "NA" se il work + risolto non ha autori (stessa convenzione di metatagextraction.py::SR); + RIVISTA puo' comparire come stringa vuota se non disponibile sul work + risolto; ANNO e' sempre presente come stringa numerica (0 se il work + risolto non ha `publication_year`, vedi format_py_column). + """ + authors = format_au_column(resolved_work) + first_author = authors[0] if authors else "NA" + year = format_py_column(resolved_work) # int (vedi format_py_column) -> cast esplicito qui sotto + journal = format_so_column(resolved_work) + return f"{first_author}, {str(year)}, {journal}" + + +def format_c1_column(work): + """ + Colonna C1 (Authors Affiliation). Fonte dati: i campi strutturati OpenAlex + (`authorships[].author.display_name`, `authorships[].institutions[].display_name`, + `authorships[].institutions[].country_code`), NON `raw_affiliation_strings` + (testo grezzo non normalizzato). + + Ogni stringa di output e' costruita nel formato WoS-style + "[NomeAutore] Nome Istituzione, PAESE" (paese in maiuscolo), riproducendo la + convenzione con cui format_c1_column in format_functions.py rimuove il + prefisso "[Autori] " dalle righe C1 di WoS — qui il prefisso viene invece + generato ex novo a partire da un dato gia' strutturato. + + Il nome del paese e' ottenuto da ISO_COUNTRY_CODE_TO_NAME, che usa + ESATTAMENTE la stessa convenzione di naming della whitelist in + www/static/countries.txt: questo garantisce che + metatagextraction.py::AU_CO/AU1_CO/AU_UN (che deriva paesi/istituzioni da C1 + facendo `c1.split(",")[-1].strip().upper()` e cercando il risultato nella + whitelist) continui a funzionare invariato anche sulle righe C1 generate da + questa funzione, senza bisogno di modifiche a valle. + + Un'istituzione senza `country_code` riconosciuto in ISO_COUNTRY_CODE_TO_NAME + produce comunque una riga C1 valida, ma senza il segmento finale ", PAESE": + in quel caso il matching a valle in AU_CO/AU1_CO semplicemente non trovera' + un paese per quella affiliazione (stesso comportamento di una riga WoS con + paese mancante/non riconosciuto). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + list[str]: una stringa "[NomeAutore] Nome Istituzione, PAESE" per ogni + combinazione (autore, istituzione) presente in `work["authorships"]`. + Autori senza institutions non producono alcuna riga (nessuna affiliazione + da riportare). Lista vuota se `work["authorships"]` e' assente/vuoto. + """ + affiliations = [] + for authorship in work.get("authorships") or []: + author = authorship.get("author") or {} + author_name = author.get("display_name") + if not author_name: + continue + + for institution in authorship.get("institutions") or []: + institution_name = institution.get("display_name") + if not institution_name: + continue + + country_code = institution.get("country_code") + country_name = ISO_COUNTRY_CODE_TO_NAME.get((country_code or "").upper()) + + if country_name: + affiliation = f"[{author_name}] {institution_name}, {country_name}" + else: + affiliation = f"[{author_name}] {institution_name}" + + affiliations.append(affiliation) + + return affiliations + + +def format_db_column(work): + """ + Colonna DB (Database). Costante: tutti i record prodotti da questo mapper + provengono dalla sorgente OpenAlex. + + Args: + work: oggetto "work" OpenAlex grezzo (non usato, presente solo per + uniformita' di firma con le altre format_XX_column). + + Returns: + str: sempre "OPENALEX". + """ + return "OPENALEX" + + +def format_de_column(work): + """ + Colonna DE (Author Keywords). Deriva da `work["keywords"][].display_name` + (i keyword assegnati algoritmicamente da OpenAlex con score di confidenza, + non keyword scelte dall'autore come in WoS: da segnalare come scostamento + semantico rispetto al significato originale di DE). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + list[str]: keyword estratte da `work["keywords"]`, lista vuota se il + campo e' assente/vuoto o gli elementi non hanno `display_name`. + """ + keywords = [] + for keyword in work.get("keywords") or []: + name = keyword.get("display_name") + if name: + keywords.append(name) + return keywords + + +def format_di_column(work): + """ + Colonna DI (DOI). Deriva da `work["doi"]`, normalizzato rimuovendo il prefisso + URL "https://doi.org/" per ottenere il DOI nudo nel formato atteso dallo schema + WoS-style (es. "10.48550/arxiv.1201.0490"). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: DOI nudo, "" se `work["doi"]` e' assente/None. + """ + doi = work.get("doi") + if not doi: + return "" + prefix = "https://doi.org/" + if doi.startswith(prefix): + return doi[len(prefix):] + return doi + + +def format_dt_column(work): + """ + Colonna DT (Document Type). Deriva da `work["type"]` (vocabolario controllato + OpenAlex, es. "article", "preprint", "book-chapter"), tradotto nel vocabolario + WoS-style (es. "Article", "Review") tramite OPENALEX_TYPE_TO_WOS_DT. + + Se `work["type"]` non e' presente in OPENALEX_TYPE_TO_WOS_DT (type nuovo o + non ancora mappato), il fallback e' il valore OpenAlex originale con la + prima lettera maiuscola (`str.capitalize()`), invece di una stringa vuota: + l'informazione grezza resta comunque visibile/utilizzabile a valle anziche' + andare persa silenziosamente. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: valore DT mappato, il valore originale capitalizzato se non + presente in OPENALEX_TYPE_TO_WOS_DT, "" se `work["type"]` e' assente/None. + """ + work_type = work.get("type") + if not work_type: + return "" + return OPENALEX_TYPE_TO_WOS_DT.get(work_type, work_type.capitalize()) + + +def format_em_column(work): + """ + Colonna EM (Email). Campo non disponibile da OpenAlex (nessun indirizzo email + degli autori esposto dall'API): restituisce sempre stringa vuota per decisione + esplicita, invece di propagare KeyError/AttributeError a valle. + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_fu_column(work): + """ + Colonna FU (Funding Details). Campo non disponibile da OpenAlex per questa + pipeline: restituisce sempre stringa vuota per decisione esplicita. + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_fx_column(work): + """ + Colonna FX (Funding Text). Campo non disponibile da OpenAlex per questa + pipeline: restituisce sempre stringa vuota per decisione esplicita. + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_is_column(work): + """ + Colonna IS (Issue). Deriva da `work["biblio"]["issue"]`, con accesso robusto + ai campi annidati: se `work["biblio"]` e' assente/None, viene trattato come + dict vuoto invece di sollevare AttributeError/KeyError. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: numero di fascicolo, "" se assente. + """ + biblio = work.get("biblio") or {} + return biblio.get("issue") or "" + + +def format_ji_column(work): + """ + Colonna JI (Abbreviated Journal Name). OpenAlex non fornisce + un'abbreviazione standardizzata del nome rivista: restituisce sempre "". + + Non e' un dato perso: metatagextraction.py::SR (riusata da + compute_sr_for_records) tratta esplicitamente JI == "" come "nessuna + abbreviazione disponibile" e ricade sul nome completo della rivista (SO) + per costruire la colonna SR — esattamente lo stesso pattern gia' usato + dalla pipeline WoS storica per le sorgenti senza abbreviazione (Dimensions, + The_Lens, Cochrane in format_functions.py::format_ji_column). + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_id_column(work): + """ + Colonna ID (Index/Keywords Plus). Deriva da `work["concepts"][].display_name`. + + Nota concettuale importante: a differenza di Keywords Plus in WoS (termini + estratti algoritmicamente dai titoli delle referenze citate da un articolo), + i "concepts" di OpenAlex sono TOPIC assegnati algoritmicamente al work stesso + tramite un classificatore proprietario di OpenAlex, organizzati in una + gerarchia (`level`) con uno score di confidenza. Sono quindi concettualmente + piu' vicini a una tassonomia/categorizzazione automatica del contenuto che + non a delle "keyword aggiuntive" nel senso WoS del termine: vanno trattati + come un'approssimazione, non come un equivalente semantico di ID. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + list[str]: nomi dei concetti estratti da `work["concepts"]`, lista vuota + se il campo e' assente/vuoto o gli elementi non hanno `display_name`. + """ + concepts = [] + for concept in work.get("concepts") or []: + name = concept.get("display_name") + if name: + concepts.append(name) + return concepts + + +def format_la_column(work): + """ + Colonna LA (Language). Pass-through diretto di `work["language"]` (codice + ISO 639-1, es. "en"), a differenza di WoS dove LA e' tipicamente il nome + esteso della lingua (es. "English"): da segnalare come possibile scostamento + di formato se il resto della pipeline (es. filtri o report) si aspetta il + nome esteso. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: codice lingua ISO 639-1, "" se `work["language"]` e' assente/None. + """ + return work.get("language") or "" + + +def format_oa_column(work): + """ + Colonna OA (Open Access). Deriva da `work["open_access"]["oa_status"]` + (vocabolario controllato OpenAlex: "gold", "green", "hybrid", "bronze", + "closed", ...), pass-through diretto. + + NOTA: implementata qui solo perche' necessaria a map_work_to_record per + produrre tutte le 33 chiavi richieste (34 colonne meno SR) — senza di essa + map_work_to_record avrebbe sollevato NotImplementedError su OA. A + differenza di EM/FU/FX/OI/PU/RP/SC/SN (tutte deliberatamente "sempre + vuote" per decisione esplicita presa in conversazione), OA NON e' stata + oggetto della stessa decisione esplicita: va rivista se il formato + desiderato e' diverso da un semplice pass-through di `oa_status` + (es. un booleano derivato da `is_oa`, invece della stringa di stato). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: valore di `oa_status`, "" se `open_access`/`oa_status` sono + assenti/None. + """ + open_access = work.get("open_access") or {} + return open_access.get("oa_status") or "" + + +def format_oi_column(work): + """ + Colonna OI (ORCID). Campo non disponibile da OpenAlex per questa pipeline: + restituisce sempre stringa vuota per decisione esplicita. + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_pmid_column(work): + """ + Colonna PMID (PubMed ID). Deriva da `work["ids"]["pmid"]`, se presente (non + tutti i work OpenAlex hanno un ID PubMed associato), normalizzato all'ID nudo + (senza l'eventuale prefisso URL "https://pubmed.ncbi.nlm.nih.gov/"). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: PMID nudo, "" se `work["ids"]["pmid"]` e' assente/None. + """ + ids = work.get("ids") or {} + pmid = ids.get("pmid") + if not pmid: + return "" + return pmid.rsplit("/", 1)[-1] + + +def format_pu_column(work): + """ + Colonna PU (Publisher). Campo non disponibile da OpenAlex per questa + pipeline: restituisce sempre stringa vuota per decisione esplicita (pur + essendo `primary_location.source.host_organization_name` potenzialmente + disponibile, non viene usato in questa versione della pipeline). + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_py_column(work): + """ + Colonna PY (Publication Year). Deriva da `work["publication_year"]` + (gia' un int lato OpenAlex). + + BUG DOCUMENTATO E RISOLTO (rilevante per la relazione finale, sezione + "Weak or inconsistent type enforcement"): questa funzione restituiva in + origine str(publication_year), classificando PY come STRING in + type_contracts.py::COLUMN_SPECS. Il ragionamento iniziale era "replicare + il tipo grezzo della pipeline WoS storica", che infatti restituisce anche + li' una stringa (format_functions.py::format_py_column). Il problema: + nella pipeline storica quella stringa diventa int64 "gratis" perche' + functions/get_data.py costruisce il DataFrame con + `pd.read_json(StringIO(json))`, che applica inferenza automatica di tipo + alle stringhe numeriche; la nostra etl_pipeline.py::_build_dataframe usa + invece `pd.DataFrame(records)` diretto, che non fa questa inferenza — con + PY=str, functions/get_annualproduction.py andava in TypeError su + `range(min_year, max_year + 1)` (somma int su stringa). + + Verificato con grep su format_functions.py e su tutte le functions/*.py: + nessun consumer richiede PY come stringa (nessuno slicing, nessun + accessor .str, nessuna concatenazione diretta); numerosi consumer lo + richiedono esplicitamente numerico (min/max, range, confronti aritmetici, + groupby, np.linspace/pd.cut), e 4 di essi fanno gia' un cast difensivo + `pd.to_numeric(df['PY'], errors='coerce')` proprio per la stessa ragione + (get_authorlocalimpact.py, get_authorproductionovertime.py, + get_sourceslocalimpact.py, get_thematicevolution.py). Corretto qui + restituendo un int nativo, e classificando PY come INTEGER in + type_contracts.py::COLUMN_SPECS invece di STRING: il DataFrame prodotto + da questa pipeline ottiene cosi' lo stesso dtype numerico che la pipeline + storica ottiene indirettamente tramite il roundtrip JSON. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + int: anno di pubblicazione, 0 se `work["publication_year"]` e' + assente/None/non convertibile (stesso default "assenza" usato per TC + in format_tc_column). + """ + py = work.get("publication_year") + if py is None: + return 0 + try: + return int(py) + except (TypeError, ValueError): + return 0 + + +def format_rp_column(work): + """ + Colonna RP (Correspondence Address/Reprint Author). Per decisione esplicita, + restituisce sempre stringa vuota: OpenAlex espone un flag `is_corresponding` + per autore, ma non un indirizzo di corrispondenza strutturato/affidabile + equivalente a RP in WoS, quindi si evita di costruire un dato di bassa + qualita' a partire da quel flag. + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_sc_column(work): + """ + Colonna SC (Subject Category / Fields of Research). Campo non disponibile da + OpenAlex per questa pipeline: restituisce sempre stringa vuota per decisione + esplicita (pur essendo `topics[].field.display_name` potenzialmente + disponibile, non viene usato in questa versione della pipeline). + + Args: + work: oggetto "work" OpenAlex grezzo (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_sn_column(work): + """ + Colonna SN (ISSN). Deriva da + `work["primary_location"]["source"]["issn_l"]`, con accesso robusto ai + campi annidati: `primary_location` e/o `source` possono essere None per + interi record (es. preprint su repository senza ISSN, come l'esempio + arXiv analizzato in conversazione), nel qual caso vengono trattati come + dict vuoti invece di sollevare AttributeError/KeyError. + + Verificato contro format_functions.py::format_sn_column: SN e' il campo + ISSN in tutte le sorgenti storiche (WoS, PubMed, Scopus, The_Lens) — questo + valore era prima erroneamente assegnato a JI (Abbreviated Journal Name), + corretto qui. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: ISSN-L della sorgente, "" se `primary_location`/`source`/`issn_l` + sono assenti/None. + """ + primary_location = work.get("primary_location") or {} + source = primary_location.get("source") or {} + return source.get("issn_l") or "" + + +def format_so_column(work): + """ + Colonna SO (Journal/Source). Deriva da + `work["primary_location"]["source"]["display_name"]`, con accesso robusto ai + campi annidati: `primary_location` e/o `source` possono essere None per + interi record (es. work senza location primaria), nel qual caso vengono + trattati come dict vuoti invece di sollevare AttributeError/KeyError. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: nome della rivista/sorgente, "" se `primary_location` o `source` + sono assenti/None. + """ + primary_location = work.get("primary_location") or {} + source = primary_location.get("source") or {} + return source.get("display_name") or "" + + +def format_tc_column(work): + """ + Colonna TC (Times Cited). Deriva da `work["cited_by_count"]`, con cast + esplicito a int (a differenza di format_tc_column in format_functions.py, + dove il valore WoS restava una stringa quando presente e un int 0 come + default, generando un tipo misto nella colonna: qui l'obiettivo e' + restituire sempre int). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + int: `cited_by_count` castato a int, 0 se assente/None/non convertibile. + """ + try: + return int(work.get("cited_by_count")) + except (TypeError, ValueError): + return 0 + + +def format_ti_column(work): + """ + Colonna TI (Title). Deriva da `work["title"]`, con fallback su + `work["display_name"]` se `title` e' vuoto/assente (i due campi coincidono + nella maggior parte dei casi osservati, ma `display_name` e' piu' + costantemente popolato). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: titolo del work, "" se sia `title` che `display_name` sono + assenti/vuoti. + """ + title = work.get("title") + if title: + return title + return work.get("display_name") or "" + + +def format_ut_column(work): + """ + Colonna UT (Publication ID / accession number). Deriva da `work["id"]` + (URI OpenAlex, es. "https://openalex.org/W2101234009"), da cui viene + rimosso il prefisso URL per ottenere l'ID nudo (es. "W2101234009"). + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: ID OpenAlex nudo, "" se `work["id"]` e' assente/None. + """ + raw_id = work.get("id") + if not raw_id: + return "" + return raw_id.rsplit("/", 1)[-1] + + +def format_vl_column(work): + """ + Colonna VL (Volume). Deriva da `work["biblio"]["volume"]`, con accesso + robusto ai campi annidati: se `work["biblio"]` e' assente/None, viene + trattato come dict vuoto invece di sollevare AttributeError/KeyError. + + Args: + work: oggetto "work" OpenAlex grezzo. + + Returns: + str: numero di volume, "" se assente. + """ + biblio = work.get("biblio") or {} + return biblio.get("volume") or "" + + +def build_sr_bridge_frame(records): + """ + Costruisce il DataFrame "ponte" da passare, senza alcuna trasformazione, + a metatagextraction.py::SR(M). + + Non e' un adattamento in senso stretto: le chiavi dei record prodotti da + format_au_column/format_db_column/format_ji_column/format_so_column/ + format_py_column in questo stesso modulo si chiamano gia' AU/DB/JI/SO/PY, + esattamente come le colonne che SR(M) si aspetta. Il "ponte" e' quindi solo + l'atto di raccogliere piu' record gia' mappati in un unico pandas.DataFrame + (SR() e' intrinsecamente un'operazione di collezione, non di riga singola: + vedi il modulo docstring per il perche'). + + Args: + records: list[dict], record gia' prodotti da map_work_to_record (o + comunque contenenti almeno le chiavi AU, DB, JI, SO, PY nello + stesso formato prodotto dalle format_XX_column di questo modulo). + + Returns: + pandas.DataFrame costruito direttamente da records, una riga per record, + senza rinominare alcuna colonna. + """ + return pd.DataFrame(records) + + +def compute_sr_for_records(records): + """ + Calcola SR (e SR_FULL) per un'intera collezione di record gia' mappati, + riusando SENZA MODIFICHE metatagextraction.py::SR(M) — la stessa funzione + gia' usata altrove nella codebase per lo stesso scopo (es. couplingmap.py, + get_collaborationnetwork.py, entrambe tramite + `metaTagExtraction(df, "SR")`) — invece di riscrivere a mano la formula + "Autore, Anno, Rivista" e la sua deduplicazione. + + Questa funzione va chiamata UNA VOLTA sull'intera lista di record (tipicamente + da etl_pipeline.py::_compute_calculated_fields), MAI record-per-record: SR() + delega la deduplicazione a `Series.duplicated()`, che e' significativa solo + se valutata sull'intera collezione. Per questo, a differenza delle altre 34 + colonne, non esiste (e non deve esistere) una format_sr_column(work) a + livello di singolo record in questo modulo. + + Args: + records: list[dict], record gia' mappati con almeno le chiavi AU, DB, + JI, SO, PY (vedi build_sr_bridge_frame). + + Returns: + list[dict]: nuovi dict (i record in input non vengono mutati), ciascuno + arricchito con le chiavi "SR" e "SR_FULL" calcolate da + metatagextraction.py::SR(M) sull'intera collezione. Lista vuota se + `records` e' vuota (SR() non viene invocata: `M["DB"].iloc[0]` solleverebbe + IndexError su un DataFrame vuoto). + """ + if not records: + return [] + + bridge = build_sr_bridge_frame(records) + bridge = SR(bridge) + + enriched = [] + for original, sr_value, sr_full_value in zip(records, bridge["SR"], bridge["SR_FULL"]): + record = dict(original) + record["SR"] = sr_value + record["SR_FULL"] = sr_full_value + enriched.append(record) + return enriched + + +def _reconstruct_abstract(inverted_index): + """ + Funzione interna: ricostruisce il testo lineare di un abstract a partire dalla + struttura "inverted index" di OpenAlex (dict parola -> lista di posizioni), + riordinando le parole secondo le posizioni indicate. + + Algoritmo: determina la posizione massima presente nell'indice, alloca un + array di quella dimensione+1 (inizialmente tutto "buchi"), assegna ogni + parola a ciascuna delle sue posizioni, poi unisce l'array con uno spazio + scartando i "buchi" rimasti vuoti. + + Robustezza (nessuna eccezione sollevata): + - inverted_index assente/None/vuoto -> "". + - posizioni non intere o negative vengono ignorate silenziosamente. + - posizioni duplicate (due parole diverse rivendicano la stessa posizione, + anomalia che non dovrebbe verificarsi in un indice invertito valido ma + viene comunque gestita): vince l'ultima parola incontrata nell'ordine di + iterazione del dict, senza sollevare errori. + - "buchi" nelle posizioni (nessuna parola assegnata a una data posizione, + es. per omissioni nell'indice restituito da OpenAlex): la posizione viene + semplicemente saltata in fase di join, non riempita con placeholder. + + Usata da format_ab_column. + + Args: + inverted_index: dict[str, list[int]] | None, tipicamente + `work["abstract_inverted_index"]`. + + Returns: + str: testo dell'abstract ricostruito, stringa vuota se inverted_index e' + None, vuoto, o non contiene alcuna posizione valida. + """ + if not inverted_index: + return "" + + max_position = -1 + for positions in inverted_index.values(): + for position in positions or []: + if isinstance(position, int) and position > max_position: + max_position = position + + if max_position < 0: + return "" + + slots = [None] * (max_position + 1) + for word, positions in inverted_index.items(): + for position in positions or []: + if isinstance(position, int) and 0 <= position <= max_position: + slots[position] = word + + return " ".join(word for word in slots if word is not None) diff --git a/www/services/type_contracts.py b/www/services/type_contracts.py new file mode 100644 index 000000000..18f5d308e --- /dev/null +++ b/www/services/type_contracts.py @@ -0,0 +1,343 @@ +""" +Schema tipizzato delle 34 colonne bibliometrix-style e funzioni di validazione. + +Questo modulo e' la fonte di verita' sui tipi attesi per ciascuna colonna prodotta +dalla pipeline ETL. In pratica, oggi, l'unico chiamante pianificato e' +etl_pipeline.py sull'output di openalex_mapper.py::map_work_to_record + +compute_sr_for_records (vedi COLUMN_SPECS piu' sotto per una nota importante +sulla scelta di tipo per EM/FU/OA/OI/SC, dove la pipeline WoS storica in +format_functions.py diverge dal nostro mapper OpenAlex). + +Convenzione di tipo: +- campi multi-valore (uno o piu' elementi per pubblicazione) -> list[str] +- campi scalari testuali -> str +- campi scalari numerici -> int + +La lista dei nomi di colonna canonici e' gia' definita in www/services/utils.py +(variabile `columns`); questo modulo la arricchisce con l'informazione di tipo +associata a ciascuna colonna, senza duplicarne la definizione. +""" + +from .utils import * +from enum import Enum + + +class ColumnType(Enum): + """Tipi di dato ammessi per una colonna dello schema a 34 colonne.""" + STRING = "string" + INTEGER = "integer" + STRING_LIST = "string_list" + + +class SchemaValidationError(Exception): + """Sollevata quando un record o un DataFrame non rispetta lo schema atteso + (colonna mancante, tipo non coerente, colonna non riconosciuta in modalita' strict). + Non sollevata direttamente da validate_record/validate_dataframe (che + riportano errori come lista di stringhe, senza sollevare eccezioni): resta + disponibile per un chiamante (es. etl_pipeline.py) che voglia trasformare + una lista di errori di validazione in un'eccezione bloccante.""" + pass + + +# Specifica dichiarativa per ciascuna delle 34 colonne dello schema bibliometrix-style +# (i nomi devono restare allineati a `columns` in www/services/utils.py). +# +# NOTA su EM, FU, OA, OI, SC: nella pipeline WoS storica (format_functions.py) +# questi campi sono inizializzati come liste (es. `emails = []`, `open_access = []`) +# e possono restare multi-valore per WoS/.txt. Nel nostro mapper OpenAlex +# (openalex_mapper.py) restituiscono invece sempre una stringa scalare (EM/FU/OI/SC +# sono sempre "" per decisione esplicita; OA e' un pass-through scalare di +# `oa_status`). Decisione presa in conversazione: qui sono classificati STRING, +# allineati a cio' che produce davvero openalex_mapper.py (l'unico chiamante +# attuale di questo modulo). Se in futuro type_contracts.py dovesse validare +# anche l'output della pipeline WoS storica, questa scelta andra' rivista +# insieme a format_functions.py, non silenziosamente. +# +# "required" qui significa "la chiave deve essere presente nel record" — non +# "il valore non puo' essere vuoto": "" / [] / 0 sono rappresentazioni valide +# di "nessun dato", None/NaN non lo sono mai. +# +# BUG DOCUMENTATO E RISOLTO (rilevante per la relazione finale, sezione "Weak +# or inconsistent type enforcement"): PY era inizialmente classificato STRING +# perche' openalex_mapper.py::format_py_column restituiva str(publication_year). +# Questo replicava il tipo GREZZO prodotto dalla pipeline WoS storica +# (format_functions.py::format_py_column restituisce anch'essa una stringa), +# ma non replicava il suo effetto pratico: get_data.py costruisce il +# DataFrame storico con `pd.read_json(StringIO(json))`, che converte +# automaticamente le stringhe numeriche in int64, mentre +# etl_pipeline.py::_build_dataframe usa `pd.DataFrame(records)` diretto, che +# NON fa questa inferenza — quindi PY restava str a valle SOLO nella nostra +# pipeline, mai in quella storica. Il sintomo: functions/get_annualproduction.py +# andava in TypeError su `range(min_year, max_year + 1)` perche' min_year/ +# max_year erano stringhe. Verificato con grep su format_functions.py e su +# tutte le functions/*.py che nessun consumer richiede PY come stringa (nessuno +# slicing, nessun accessor .str, nessuna concatenazione); numerosi consumer lo +# richiedono esplicitamente numerico (min/max, range, confronti aritmetici, +# groupby, np.linspace/pd.cut), e 4 di essi (get_authorlocalimpact.py, +# get_authorproductionovertime.py, get_sourceslocalimpact.py, +# get_thematicevolution.py) fanno gia' un cast difensivo +# `pd.to_numeric(..., errors="coerce")` proprio per questo motivo. Risolto +# classificando PY come INTEGER qui e facendo restituire un int nativo da +# format_py_column (vedi openalex_mapper.py per il dettaglio completo). +COLUMN_SPECS: dict = { + "AB": {"type": ColumnType.STRING, "required": True, "description": "Abstract"}, + "AF": {"type": ColumnType.STRING_LIST, "required": True, "description": "Authors Full Name"}, + "AU": {"type": ColumnType.STRING_LIST, "required": True, "description": "Authors"}, + "AU1_UN": {"type": ColumnType.STRING, "required": True, "description": "First Author University"}, + "AU_UN": {"type": ColumnType.STRING_LIST, "required": True, "description": "Authors University"}, + "BP": {"type": ColumnType.STRING, "required": True, "description": "Begin Page"}, + "C1": {"type": ColumnType.STRING_LIST, "required": True, "description": "Authors Affiliations"}, + "CR": {"type": ColumnType.STRING_LIST, "required": True, "description": "Cited References"}, + "DB": {"type": ColumnType.STRING, "required": True, "description": "Source"}, + "DE": {"type": ColumnType.STRING_LIST, "required": True, "description": "Keywords"}, + "DI": {"type": ColumnType.STRING, "required": True, "description": "DOI"}, + "DT": {"type": ColumnType.STRING, "required": True, "description": "Document Type"}, + "EM": {"type": ColumnType.STRING, "required": True, "description": "Author Email"}, + "EP": {"type": ColumnType.STRING, "required": True, "description": "End Page"}, + "FU": {"type": ColumnType.STRING, "required": True, "description": "Funding Details"}, + "FX": {"type": ColumnType.STRING, "required": True, "description": "Acknowledgements"}, + "ID": {"type": ColumnType.STRING_LIST, "required": True, "description": "Index Keywords"}, + "IS": {"type": ColumnType.STRING, "required": True, "description": "Issue"}, + "JI": {"type": ColumnType.STRING, "required": True, "description": "Abbreviated Source Title"}, + "LA": {"type": ColumnType.STRING, "required": True, "description": "Language"}, + "OA": {"type": ColumnType.STRING, "required": True, "description": "Open Access"}, + "OI": {"type": ColumnType.STRING, "required": True, "description": "Author's ORCID"}, + "PMID": {"type": ColumnType.STRING, "required": True, "description": "PubMed ID"}, + "PU": {"type": ColumnType.STRING, "required": True, "description": "Publisher"}, + "PY": {"type": ColumnType.INTEGER, "required": True, "description": "Publication Year"}, + "RP": {"type": ColumnType.STRING, "required": True, "description": "Correspondence Address"}, + "SC": {"type": ColumnType.STRING, "required": True, "description": "Fields of Study"}, + "SN": {"type": ColumnType.STRING, "required": True, "description": "ISSN"}, + "SO": {"type": ColumnType.STRING, "required": True, "description": "Journal"}, + "SR": {"type": ColumnType.STRING, "required": True, "description": "Authors, Publication Year and Journal"}, + "TC": {"type": ColumnType.INTEGER, "required": True, "description": "Time Cited"}, + "TI": {"type": ColumnType.STRING, "required": True, "description": "Title"}, + "UT": {"type": ColumnType.STRING, "required": True, "description": "Publication ID"}, + "VL": {"type": ColumnType.STRING, "required": True, "description": "Volume"}, +} + + +def get_expected_type(column): + """ + Restituisce il ColumnType atteso per una colonna dello schema a 34 colonne. + + Args: + column: nome della colonna (es. "AU", "PY", "TC"). + + Returns: + ColumnType corrispondente, secondo COLUMN_SPECS. + + Raises: + KeyError: se la colonna non fa parte dello schema definito in COLUMN_SPECS. + """ + if column not in COLUMN_SPECS: + raise KeyError(f"Colonna non presente nello schema COLUMN_SPECS: {column!r}") + return COLUMN_SPECS[column]["type"] + + +def is_multivalue(column): + """ + Indica se una colonna e' definita come multi-valore (list[str], es. AU, C1, CR, + DE, ID) oppure scalare (str/int, es. PY, TC, TI, SO). + + Args: + column: nome della colonna. + + Returns: + bool. + + Raises: + KeyError: se la colonna non fa parte dello schema definito in COLUMN_SPECS + (propagata da get_expected_type). + """ + return get_expected_type(column) == ColumnType.STRING_LIST + + +def _is_missing_scalar(value): + """ + Funzione interna: indica se un valore scalare va considerato "mancante" nel + senso proibito dal contratto (None, o NaN in stile pandas/numpy). + + Una stringa vuota "" o una lista vuota [] NON sono considerate mancanti: + sono le rappresentazioni valide di "nessun dato" gia' stabilite nel design + di openalex_mapper.py. Non tenta di valutare la "vacuita'" di list/dict/ + tuple/set (per cui il concetto di NaN non ha senso): restituisce False per + quei tipi senza sollevare eccezioni. + + Args: + value: valore da controllare. + + Returns: + bool: True se value e' None o NaN, False altrimenti (incluse liste/dict + di qualunque contenuto). + """ + if isinstance(value, (list, dict, tuple, set)): + return False + try: + return bool(pd.isna(value)) + except (TypeError, ValueError): + return False + + +def coerce_record_types(record): + """ + Tenta una coercizione "best-effort" dei valori di un record verso i tipi + dichiarati in COLUMN_SPECS, permissiva in input ma con output SEMPRE + conforme allo schema (barriera finale anti-NaN/None richiesta dal design): + + - colonna STRING_LIST: None/NaN -> []; list/tuple gia' presente -> list(); + qualunque altro scalare (es. una singola stringa) -> wrappato in lista + di un elemento. + - colonna INTEGER: None/NaN -> 0; altrimenti tentativo di `int(value)` + (funziona anche su stringhe numeriche come "42" o float come 42.0); + se la conversione fallisce (es. stringa non numerica), fallback a 0 + invece di sollevare un'eccezione. + - colonna STRING: None/NaN -> ""; stringa gia' presente -> invariata; + qualunque altro valore (int, float, list residua, ecc.) -> convertito + con `str(value)`. + + Il record restituito contiene ESATTAMENTE le chiavi di COLUMN_SPECS (quindi + di `columns` in utils.py): colonne mancanti nell'input vengono aggiunte con + il default vuoto del loro tipo, colonne extra presenti nell'input ma non + nello schema vengono scartate. Questo rende la funzione una barriera + robusta anche contro record parziali o con chiavi sporche, non solo contro + valori None/NaN sui campi attesi. + + Args: + record: dict rappresentante una singola riga/pubblicazione. Puo' essere + parziale, avere chiavi extra, o contenere None/NaN/tipi sbagliati: + nessuno di questi casi solleva un'eccezione. + + Returns: + dict: nuovo record con esattamente le chiavi di COLUMN_SPECS, ciascuna + con un valore del tipo python atteso. + """ + coerced = {} + + for column, spec in COLUMN_SPECS.items(): + value = record.get(column) if isinstance(record, dict) else None + expected_type = spec["type"] + + if expected_type == ColumnType.STRING_LIST: + if _is_missing_scalar(value): + coerced_value = [] + elif isinstance(value, list): + coerced_value = value + elif isinstance(value, tuple): + coerced_value = list(value) + else: + coerced_value = [value] + + elif expected_type == ColumnType.INTEGER: + if _is_missing_scalar(value): + coerced_value = 0 + else: + try: + coerced_value = int(value) + except (TypeError, ValueError): + coerced_value = 0 + + else: # ColumnType.STRING + if _is_missing_scalar(value): + coerced_value = "" + elif isinstance(value, str): + coerced_value = value + else: + coerced_value = str(value) + + coerced[column] = coerced_value + + return coerced + + +def validate_record(record, strict=True): + """ + Valida un singolo record (dict colonna -> valore) contro COLUMN_SPECS, + SENZA correggerlo (a differenza di coerce_record_types): riporta soltanto + gli errori trovati. + + Verifica, per ogni colonna prevista dallo schema: + - presenza della chiave, se il campo e' marcato come obbligatorio; + - assenza di valori None/NaN residui (distinti da "" / [] / 0, che sono + rappresentazioni valide di "nessun dato"); + - coerenza del tipo python del valore con quanto dichiarato in COLUMN_SPECS + (list per i campi multi-valore, str per gli scalari testuali, int + — esplicitamente NON bool — per gli scalari numerici); + - assenza di colonne non riconosciute, se strict=True. + + Args: + record: dict rappresentante una singola riga/pubblicazione, con chiavi + attese tra le 34 colonne dello schema. + strict: se True, chiavi extra non presenti in COLUMN_SPECS sono considerate + un errore di validazione; se False, vengono ignorate. + + Returns: + list[str]: messaggi di errore (lista vuota se il record e' valido). Non + solleva eccezioni: un input non-dict produce un singolo messaggio di + errore descrittivo invece di un TypeError. + """ + if not isinstance(record, dict): + return [f"record non è un dict: {type(record).__name__}"] + + errors = [] + + for column, spec in COLUMN_SPECS.items(): + if column not in record: + if spec["required"]: + errors.append(f"{column}: colonna obbligatoria mancante") + continue + + value = record[column] + expected_type = spec["type"] + + if expected_type == ColumnType.STRING_LIST: + if not isinstance(value, list): + errors.append( + f"{column}: atteso list (STRING_LIST), trovato {type(value).__name__} ({value!r})" + ) + + elif expected_type == ColumnType.INTEGER: + if _is_missing_scalar(value): + errors.append(f"{column}: valore mancante (None/NaN) su colonna INTEGER") + elif not isinstance(value, int) or isinstance(value, bool): + errors.append( + f"{column}: atteso int (INTEGER), trovato {type(value).__name__} ({value!r})" + ) + + else: # ColumnType.STRING + if _is_missing_scalar(value): + errors.append(f"{column}: valore mancante (None/NaN) su colonna STRING") + elif not isinstance(value, str): + errors.append( + f"{column}: atteso str (STRING), trovato {type(value).__name__} ({value!r})" + ) + + if strict: + extra = set(record.keys()) - set(COLUMN_SPECS.keys()) + if extra: + errors.append(f"colonne non riconosciute nello schema: {sorted(extra)}") + + return errors + + +def validate_dataframe(df, strict=True): + """ + Valida un intero pandas.DataFrame contro COLUMN_SPECS, applicando + validate_record ad ogni riga e aggregando gli errori con riferimento + all'indice di riga originale del DataFrame (non alla posizione 0-based). + + Args: + df: pandas.DataFrame da validare, atteso con lo schema a 34 colonne + (o un suo sottoinsieme). + strict: vedi validate_record. + + Returns: + list[str]: messaggi di errore, prefissati con "riga : ..." + (lista vuota se il DataFrame e' valido). Un DataFrame vuoto (0 righe) + restituisce una lista vuota senza errori. + """ + errors = [] + for row_index, record in zip(df.index, df.to_dict(orient="records")): + for error in validate_record(record, strict=strict): + errors.append(f"riga {row_index}: {error}") + return errors From 1c7568e5be7c14cf5e7e61b66589e793775ecc5c Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 16:15:08 +0200 Subject: [PATCH 2/9] Fix multi-value column loss on Excel round-trip in get_data.py Load Bibliometrix Data (.xlsx) previously did a bare pd.read_excel() with no handling for multi-value columns (AU, C1, CR, DE, etc). Excel cannot hold native Python lists, so a naive round-trip silently turned them into their Python repr() as a literal string, surviving undetected until an analysis function tried to treat it as a list (e.g. get_relevant_authors.py crashing with an opaque ValueError). Added _split_multivalue_cell() to re-split semicolon-joined values back into list[str] after loading, per the internal delimiter convention already specified in the project brief. --- functions/get_data.py | 55 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/functions/get_data.py b/functions/get_data.py index 16baed992..7fea66374 100644 --- a/functions/get_data.py +++ b/functions/get_data.py @@ -1,6 +1,34 @@ from www.services import * +def _split_multivalue_cell(value): + """ + Ricostruisce una list[str] a partire da una cella Excel contenente una + rappresentazione "; "-separata (o ";"-separata, senza spazio) di una + colonna multi-valore (vedi type_contracts.py::COLUMN_SPECS, STRING_LIST). + + Gestisce robustamente i casi limite prodotti da un roundtrip Excel: + - cella vuota/NaN (tipico di pd.read_excel su una cella Excel vuota, + che arriva come float NaN, non come stringa vuota) -> lista vuota; + - stringa vuota o solo spazi -> lista vuota; + - elementi vuoti generati da separatori ripetuti/spazi spuri -> scartati. + + Args: + value: contenuto grezzo della cella cosi' come restituito da + pd.read_excel (str, float NaN, o altro tipo scalare). + + Returns: + list[str]: elementi ricostruiti, lista vuota se value non contiene + dati utilizzabili. + """ + if pd.isna(value): + return [] + text = str(value).strip() + if not text: + return [] + return [item.strip() for item in re.split(r";\s*", text) if item.strip()] + + def get_data(input, database, df, reset_callback=None): """ Handle the data upload and display process. @@ -67,7 +95,32 @@ def get_data(input, database, df, reset_callback=None): ) elif input.select() == "1B": - df.set(pd.read_excel(file[0]["datapath"])) + loaded = pd.read_excel(file[0]["datapath"]) + + # DEBUGGING LOG (secondo esempio di "Poor handling of missing values" + # nel codice originale, oltre al bug PY str-vs-int): i file .xlsx non + # possono contenere oggetti Python nativi. Un DataFrame con colonne + # multi-valore (AU, AF, C1, CR, DE, ID, AU_UN - list[str], vedi + # type_contracts.py::COLUMN_SPECS) scritto con df.to_excel() viene + # serializzato da pandas con str(list) per cella (es. + # "['Autore1', 'Autore2']"), e pd.read_excel() lo rilegge cosi' com'e': + # una stringa contenente il repr letterale della lista, non una lista + # vera. Verificato concretamente: senza questa conversione, + # functions/get_relevant_authors.py va in + # `ValueError: cannot convert float NaN to integer` perche' AU non e' + # piu' esplodibile come lista (get_relevant_authors.py:108). Qui + # ricostruiamo le liste dalla rappresentazione "; "-separata che il + # nostro export di test scrive al posto del repr Python (vedi + # standard del brief: multi-valore = stringa unica joinata con "; "). + multivalue_columns = [ + column for column, spec in COLUMN_SPECS.items() + if spec["type"] == ColumnType.STRING_LIST + ] + for column in multivalue_columns: + if column in loaded.columns: + loaded[column] = loaded[column].apply(_split_multivalue_cell) + + df.set(loaded) # Reset all analysis results when new dataset is loaded if reset_callback: reset_callback() From 4f0026de58777dc9eed72eea62688a53fcd53c22 Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 16:18:18 +0200 Subject: [PATCH 3/9] Ignore macOS .DS_Store files --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ca4809011..045d49b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,4 @@ __pycache__/ bibliovenv/ Bibenv/ .venv/ -.idea/ \ No newline at end of file +.idea/.DS_Store From 0073fee756ccd061e405dc97bde0499376cfb4d8 Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 16:35:13 +0200 Subject: [PATCH 4/9] Add live OpenAlex API query page to the dashboard (optional bonus) Replaces the 'under construction' placeholder in the API nav panel with a working query interface: text input + max_results, wired to the already-tested run_openalex_etl() pipeline. Follows the same UI/reactive pattern as the existing 'Import or Load' path (loading modal, error handling via notification_show, df.set() + reset_all_analyses(), results table via get_table()). Manually verified end-to-end in the live dashboard: query -> results table populated with real OpenAlex data -> CSV/Excel export working. --- app.py | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/app.py b/app.py index f0891f894..25c516965 100644 --- a/app.py +++ b/app.py @@ -854,8 +854,107 @@ def indicator_types_ui_all(): ), with ui.nav_panel("None", value="API"): - ui.h3("🚧 Warning: API is under construction 🚧") - + ui.h3("🔌 OpenAlex API", style="color: #5567BB;") + ui.p("Search OpenAlex directly and import the results as a bibliometrix-style dataset.") + + with ui.layout_sidebar(fillable=False, fill=False): + with ui.sidebar(id="sidebar_api", position="right"): + ui.h5("OpenAlex Query", style="color: #5567BB;") + ui.input_text("api_query", "Search query", placeholder="es. machine learning") + ui.input_numeric("api_max_results", "Max results", value=50, min=1, max=200) + ui.input_action_button("start_api_button", "Start", icon=ICONS["play"]) + + @reactive.effect + @reactive.event(input.start_api_button) + def run_api_query(): + # Show loading modal while querying (same style as Historiograph) + def loading_modal(): + phrases = [ + "⏳ Loading... Please wait.", + "🔎 Querying OpenAlex...", + "📥 Downloading records...", + "🧬 Standardizing metadata...", + "📊 Preparing your dataset...", + "✨ Almost there! Preparing your dashboard...", + ] + modal = ui.modal( + ui.div( + ui.img( + src="https://cisslaboral.laleynext.es/Img/loader-circle.gif", + height="150px", + style="display: block; margin: 0 auto; text-align: center;", + ), + ui.h4( + phrases[0], + id="loading-phrase", + style="font-size: 15px; text-align: center; margin-top: 20px; color: gray;", + ), + ), + easy_close=False, + footer=None, + ) + js = f""" + + """ + return ui.HTML(str(modal) + js) + + ui.modal_show(loading_modal()) + try: + query = (input.api_query() or "").strip() + max_results = int(input.api_max_results()) + + if not query: + ui.notification_show("⚠️ Please enter a search query.", type="warning", duration=5) + return + + # info@bibliometrix.org: indirizzo di progetto gia' usato + # altrove in app.py (sezione About) per la polite pool OpenAlex + result_df, validation_errors = run_openalex_etl( + query, + mailto="info@bibliometrix.org", + max_results=max_results, + ) + + df.set(result_df) + reset_all_analyses() + ui.notification_show( + f"✅ OpenAlex query completed! The dataset contains {result_df.shape[0]} rows and {result_df.shape[1]} columns.", + duration=5, + close_button=False, + ) + except (ETLPipelineError, OpenAlexRequestError) as e: + ui.notification_show(f"❌ Error querying OpenAlex: {str(e)}", type="error", duration=10) + except Exception as e: + ui.notification_show(f"❌ Unexpected error: {str(e)}", type="error", duration=10) + finally: + ui.modal_remove() + + @render.ui + @reactive.event(input.start_api_button) + def show_api_table(): + if df.get() is None: + return ui.div( + ui.p( + "No dataset loaded yet. Enter a query and click Start.", + style="text-align: center; color: #666; font-size: 16px;" + ), + style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 150px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" + ) + table_ui, _, _ = get_table("OpenAlex", df) + return table_ui + with ui.nav_panel("None", value="collections"): ui.h3("🚧 Warning: Merge Collection is under construction 🚧") From f3bbf8c31025e4bb3d82853da18e86c566c9a282 Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 18:09:37 +0200 Subject: [PATCH 5/9] Fix three real bugs found during live dashboard testing - app.py: sidebar menu wasn't appearing after API-page queries; toggle_sidebar() wasn't triggered by start_api_button (added it to the existing reactive.event trigger list) - openalex_client.py: requests' single timeout value only limits inter-chunk inactivity, not total request time; replaced with explicit (connect, read) tuple timeout, plus an overall time budget on search_works pagination - openalex_client.py/etl_pipeline.py: _request_with_retry honored Retry-After headers unconditionally, causing an ~8 hour sleep after hitting OpenAlex's rate limit during testing; capped the wait at MAX_RETRY_AFTER_WAIT_SECONDS=20, falls back to a normal OpenAlexRequestError if still failing after the capped wait --- app.py | 2 +- www/services/etl_pipeline.py | 89 +++++++++++++++-- www/services/openalex_client.py | 171 +++++++++++++++++++++++++++++--- 3 files changed, 241 insertions(+), 21 deletions(-) diff --git a/app.py b/app.py index 25c516965..af9e75484 100644 --- a/app.py +++ b/app.py @@ -8284,7 +8284,7 @@ def update_plot_settings(): # --- Sidebar Management --- @render.express() -@reactive.event(input.start_button) +@reactive.event(input.start_button, input.start_api_button) def toggle_sidebar(): with ui.tags.div(id="sidebar_2", class_="custom-sidebar"): with ui.accordion(id="sidebar_accordion_data", multiple=False, open=False): diff --git a/www/services/etl_pipeline.py b/www/services/etl_pipeline.py index 5dc9ea17e..7d99dfcfd 100644 --- a/www/services/etl_pipeline.py +++ b/www/services/etl_pipeline.py @@ -27,6 +27,13 @@ from .type_contracts import * +# Budget di default (secondi) per l'intera fase di risoluzione batch dei +# referenced_works (vedi _resolve_references_for_works). Esposto anche come +# parametro pubblico di run_openalex_etl, cosi' e' configurabile senza +# toccare il codice interno. +DEFAULT_RESOLVE_TIMEOUT_SECONDS = 30 + + class ETLPipelineError(Exception): """Sollevata quando la pipeline ETL fallisce in uno dei suoi stadi (fetch, risoluzione referenze, mapping, validazione) in un modo che non permette di @@ -41,6 +48,8 @@ def run_openalex_etl( max_results=None, resolve_references=True, strict_validation=True, + resolve_timeout_seconds=DEFAULT_RESOLVE_TIMEOUT_SECONDS, + fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS, ): """ Entry-point principale: esegue l'intera pipeline ETL da una query utente @@ -58,6 +67,25 @@ def run_openalex_etl( per popolare la colonna CR con citazioni leggibili invece dei soli ID OpenAlex (costo aggiuntivo in chiamate HTTP, vedi openalex_client.get_works_by_ids). + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERA + paginazione della ricerca iniziale (non per singola richiesta HTTP), + passato a openalex_client.search_works tramite _fetch_raw_works. + Default DEFAULT_FETCH_TIMEOUT_SECONDS (30s): oltre questo tetto, le + pagine non ancora richieste vengono saltate (nessuna eccezione) e + si procede con i work gia' raccolti fino a quel momento. None per + nessun limite (comportamento pre-esistente, sconsigliato: e' la + fase in cui e' stato diagnosticato un blocco reale di oltre due + minuti senza mai un'eccezione). + resolve_timeout_seconds: budget di tempo (secondi) per l'INTERA fase di + risoluzione dei riferimenti (non per singola chiamata HTTP), passato + a _resolve_references_for_works. Default DEFAULT_RESOLVE_TIMEOUT_SECONDS + (30s): oltre questo tetto, i batch non ancora processati vengono + saltati (nessuna eccezione) e i riferimenti corrispondenti ricadono + sul fallback a ID nudo in format_cr_column, invece di bloccare + l'intera pipeline su query con molte referenze da risolvere. + None per nessun limite (comportamento pre-esistente, sconsigliato + su query generiche/con molti risultati). Ignorato se + resolve_references=False. strict_validation: passato come `strict` a type_contracts.validate_record per ogni record dopo la coercizione dei tipi: se True (default), eventuali colonne non riconosciute nello schema canonico contano come @@ -85,11 +113,13 @@ def run_openalex_etl( (nessun risultato dalla query, errore HTTP non gestito dal client, oppure errori di validazione residui dopo la coercizione dei tipi). """ - works = _fetch_raw_works(query, filters, mailto, max_results) + works = _fetch_raw_works(query, filters, mailto, max_results, fetch_timeout_seconds=fetch_timeout_seconds) resolved_references = None if resolve_references: - resolved_references = _resolve_references_for_works(works, mailto) + resolved_references = _resolve_references_for_works( + works, mailto, resolve_timeout_seconds=resolve_timeout_seconds + ) records = _map_works_to_records(works, resolved_references=resolved_references) records = _compute_calculated_fields(records) @@ -101,18 +131,31 @@ def run_openalex_etl( return df, [] -def _fetch_raw_works(query, filters, mailto, max_results): +def _fetch_raw_works(query, filters, mailto, max_results, fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS): """ Stadio 1: recupera dalla API OpenAlex la lista grezza di oggetti "work" (dict JSON) corrispondenti alla query utente, delegando a openalex_client.search_works (che gia' pagina internamente con cursore fino a max_results o esaurimento dei risultati). + LIMITE DI SICUREZZA (debugging log): search_works riceve qui il budget di + tempo `fetch_timeout_seconds` (vedi il suo docstring per il dettaglio del + caso reale diagnosticato: una singola richiesta rimasta bloccata >2m44s + senza mai sollevare un'eccezione di timeout). Se il budget scade a meta' + paginazione, search_works restituisce i risultati raccolti fino a quel + momento invece di bloccare - _fetch_raw_works non tratta questo come un + errore: una lista parziale ma non vuota e' comunque un risultato valido + per il resto della pipeline. + Args: query, filters, mailto, max_results: vedi run_openalex_etl. + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERA + paginazione di search_works. Default DEFAULT_FETCH_TIMEOUT_SECONDS + (30s, definito in openalex_client.py). None per nessun limite. Returns: - list[dict]: oggetti "work" OpenAlex grezzi. + list[dict]: oggetti "work" OpenAlex grezzi (eventualmente parziali se + il budget di tempo e' stato superato durante la paginazione). Raises: ETLPipelineError: se la query non produce alcun risultato, oppure se @@ -120,7 +163,13 @@ def _fetch_raw_works(query, filters, mailto, max_results): HTTP non recuperabile dopo i retry). """ try: - works = search_works(query, max_results=max_results, filters=filters, mailto=mailto) + works = search_works( + query, + max_results=max_results, + filters=filters, + mailto=mailto, + fetch_timeout_seconds=fetch_timeout_seconds, + ) except OpenAlexRequestError as exc: raise ETLPipelineError( f"Recupero dei work da OpenAlex fallito per la query {query!r}: {exc}" @@ -132,7 +181,7 @@ def _fetch_raw_works(query, filters, mailto, max_results): return works -def _resolve_references_for_works(works, mailto): +def _resolve_references_for_works(works, mailto, resolve_timeout_seconds=DEFAULT_RESOLVE_TIMEOUT_SECONDS): """ Stadio 2 (opzionale): raccoglie l'unione di tutti gli ID presenti nel campo `referenced_works` dei work scaricati e li risolve in batch tramite @@ -151,15 +200,34 @@ def _resolve_references_for_works(works, mailto): stessi ID che format_cr_column considerera' comunque (lo stesso cap), quindi risolvere ID oltre quel limite sarebbe lavoro sprecato. + LIMITE DI SICUREZZA (debugging log): una query generica con molti risultati + puo' generare migliaia di ID da risolvere (es. 30 work x MAX_REFERENCED_WORKS=100 + = fino a 3000 ID, cioe' 60 batch da 50). Senza un tetto, nel caso peggiore di + errori di rete ripetuti su ogni batch, il tempo totale poteva arrivare a + decine di minuti (~45 min con i retry di default), bloccando l'intero + handler Shiny sincrono che chiama run_openalex_etl. Il cronometro parte QUI, + all'inizio di questa funzione (time.monotonic()), e viene passato a + get_works_by_ids, che lo controlla prima di iniziare ogni nuovo batch: se il + budget e' superato, i batch rimanenti vengono saltati (nessuna eccezione) e + si restituisce il dizionario parziale gia' risolto. I riferimenti non + risolti in tempo ricadono sul fallback a ID nudo gia' esistente in + openalex_mapper.py::format_cr_column - degradazione, non blocco. + Args: works: list[dict] di work OpenAlex grezzi, vedi _fetch_raw_works. mailto: email per la polite pool. + resolve_timeout_seconds: budget di tempo (secondi) per l'INTERA + risoluzione (non per singolo batch). Default + DEFAULT_RESOLVE_TIMEOUT_SECONDS (30s). None per nessun limite. Returns: dict[str, dict]: mappa da ID OpenAlex a oggetto "work" risolto, passata a openalex_mapper.map_work_to_record / format_cr_column. Dizionario vuoto - se nessun work ha referenced_works. + se nessun work ha referenced_works. Puo' essere parziale se il budget di + tempo e' stato superato prima di processare tutti i batch. """ + start_time = time.monotonic() + seen = set() all_ids = [] for work in works: @@ -172,7 +240,12 @@ def _resolve_references_for_works(works, mailto): if not all_ids: return {} - return get_works_by_ids(all_ids, mailto=mailto) + return get_works_by_ids( + all_ids, + mailto=mailto, + resolve_timeout_seconds=resolve_timeout_seconds, + start_time=start_time, + ) def _map_works_to_records(works, resolved_references=None): diff --git a/www/services/openalex_client.py b/www/services/openalex_client.py index e14e4718d..0a72e92ae 100644 --- a/www/services/openalex_client.py +++ b/www/services/openalex_client.py @@ -30,9 +30,72 @@ MAX_PER_PAGE = 200 # limite massimo imposto dalle API OpenAlex DEFAULT_MAX_RETRIES = 3 DEFAULT_BACKOFF_FACTOR = 1.5 -DEFAULT_TIMEOUT = 10 # secondi + +# Tupla (connect_timeout, read_timeout), non un singolo valore. DEBUGGING LOG: +# con un timeout singolo (era 10s), `requests` lo applica come timeout di +# INATTIVITA' tra un chunk di risposta e il successivo, NON come tetto sul +# tempo totale della richiesta - se il server (o un proxy/CDN intermedio) +# manda anche un solo byte ogni tanto entro la finestra, la richiesta puo' +# restare appesa indefinitamente senza mai sollevare ReadTimeout. Diagnosticato +# concretamente: una query "AI" e' rimasta bloccata >2m44s su una singola +# chiamata di search_works (connessione TCP ESTABLISHED verso l'infrastruttura +# OpenAlex, CPU 0%, nessun retry mai scattato) prima di essere interrotta +# manualmente. connect_timeout=5s (tempo per stabilire la connessione TCP), +# read_timeout=15s (silenzio massimo tollerato tra un byte e l'altro della +# risposta) restano lo stesso tipo di garanzia "anti-inattivita'", ma con +# margini piu' stretti; il vero argine contro un'attesa indefinita e' pero' +# il tetto di tempo complessivo aggiunto a search_works (vedi piu' sotto) e a +# get_works_by_ids, che non dipende da come si comporta il timeout di requests. +DEFAULT_TIMEOUT = (5, 15) + DEFAULT_BATCH_SIZE = 50 # limite OpenAlex per filter=openalex_id:ID1|ID2|... +# max_retries ridotto, isolato a get_works_by_ids (risoluzione batch di +# referenced_works per popolare CR). NON tocca DEFAULT_MAX_RETRIES, che resta +# a 3 per search_works e per qualunque altro chiamante generico di +# _request_with_retry. Motivazione: una query generica con molti risultati +# puo' generare decine di batch da risolvere (es. 60 batch per 30 paper con +# referenced_works al cap di 100 ciascuno); con max_retries=3 il caso peggiore +# per singolo batch (assumendo che il read_timeout scatti regolarmente) resta +# nell'ordine delle decine di secondi per tentativo, che su 60 batch si somma +# rapidamente. Con max_retries=1 (2 tentativi totali) il caso peggiore per +# singolo batch si dimezza, riducendo proporzionalmente anche il tetto +# complessivo - in combinazione con il timeout di fase in +# _resolve_references_for_works (vedi etl_pipeline.py), non con l'obiettivo +# di eliminarlo da solo. +RESOLVE_MAX_RETRIES = 1 + +# Budget di default (secondi) per l'INTERA paginazione di search_works, non +# per singola richiesta di pagina. Stesso ruolo di resolve_timeout_seconds in +# get_works_by_ids/_resolve_references_for_works: un secondo argine oltre al +# timeout a tupla di _request_with_retry, indipendente da come si comporta +# la libreria requests in casi limite (connessione tenuta viva artificialmente, +# server lento ma "vivo"). +DEFAULT_FETCH_TIMEOUT_SECONDS = 30 + +# TERZO BUG REALE DIAGNOSTICATO OGGI (debugging log, stesso stile degli altri +# due): _request_with_retry rispettava l'header Retry-After di una risposta +# 429 senza alcun tetto massimo, facendo `time.sleep(float(retry_after))`. +# Diagnosticato concretamente: dopo aver esaurito il budget giornaliero delle +# API OpenAlex durante i test di questa sessione, una richiesta ha ricevuto +# 429 con `Retry-After: 28979` (quasi 8 ore) e il processo e' rimasto +# "bloccato" per ore in un time.sleep() legittimo ma inutilizzabile in un +# contesto interattivo (Shiny). La firma era identica a un socket appeso +# (CPU 0%, stato sleeping, connessione TCP ESTABLISHED del pool keep-alive di +# requests ancora aperta) e per questo era stata scambiata inizialmente per +# un problema di timeout HTTP - non lo era: la risposta 429 arrivava in +# meno di 0.1s, il problema era tutto nello sleep successivo, non tollerato +# ne' dal timeout a tupla di _request_with_retry (si applica solo mentre si +# attende la risposta, non dopo averla ricevuta) ne' dal budget di fase di +# search_works/get_works_by_ids (controllato solo PRIMA di iniziare una nuova +# richiesta, non durante lo sleep interno a un tentativo gia' in corso). +# Nessun retry_after piu' lungo di questo tetto viene piu' onorato per intero: +# se la richiesta fallisce ancora dopo l'attesa limitata e i tentativi +# rimasti, risale normalmente come OpenAlexRequestError (comportamento gia' +# esistente, invariato), che il resto della pipeline e l'handler della pagina +# API sanno gia' gestire mostrando un errore invece di bloccarsi in silenzio. +MAX_RETRY_AFTER_WAIT_SECONDS = 20 + class OpenAlexRequestError(Exception): """Sollevata quando una richiesta a OpenAlex fallisce in modo non recuperabile @@ -40,7 +103,8 @@ class OpenAlexRequestError(Exception): pass -def search_works(query, max_results=None, filters=None, mailto=None, per_page=MAX_PER_PAGE): +def search_works(query, max_results=None, filters=None, mailto=None, per_page=MAX_PER_PAGE, + fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS, start_time=None): """ Esegue una ricerca testuale completa su /works, paginando automaticamente con cursore finche' OpenAlex non restituisce `meta.next_cursor == None` @@ -49,6 +113,19 @@ def search_works(query, max_results=None, filters=None, mailto=None, per_page=MA Ogni singola richiesta di pagina passa da _request_with_retry (retry con backoff esponenziale su 429/5xx/errori di rete, vedi quella funzione). + LIMITE DI SICUREZZA (debugging log): se `fetch_timeout_seconds` non e' + None, PRIMA di richiedere ogni nuova pagina si controlla il tempo + trascorso da `start_time` (o dall'inizio di questa chiamata, se + start_time non e' fornito): se il budget e' superato, la paginazione si + interrompe con un break (non un'eccezione) e vengono restituiti i + risultati gia' raccolti fino a quel momento (parziali ma utilizzabili), + invece di continuare a chiedere altre pagine indefinitamente. E' un + secondo argine indipendente dal timeout a tupla di _request_with_retry: + diagnosticato un caso reale in cui una singola richiesta HTTP e' rimasta + bloccata piu' di due minuti senza mai sollevare un'eccezione di timeout + (connessione tenuta viva artificialmente) - questo budget limita il danno + anche se il timeout della singola richiesta non dovesse bastare. + Args: query: stringa di ricerca libera, mappata sul parametro `search` di OpenAlex. max_results: numero massimo di risultati da raccogliere complessivamente; @@ -66,10 +143,20 @@ def search_works(query, max_results=None, filters=None, mailto=None, per_page=MA il numero di richieste). Esposto come parametro soprattutto per poterlo abbassare nei test, cosi' da forzare piu' pagine anche con max_results piccoli. + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERA + paginazione, non per singola richiesta. Default + DEFAULT_FETCH_TIMEOUT_SECONDS (30s). None per nessun limite + (comportamento pre-esistente). + start_time: istante di riferimento (da time.monotonic()) da cui + calcolare il tempo trascorso; se None, si usa l'istante di + ingresso in questa funzione. Permette al chiamante (tipicamente + etl_pipeline.py::_fetch_raw_works) di far partire il cronometro + prima ancora di chiamare search_works. Returns: - list[dict]: work grezzi raccolti su tutte le pagine necessarie, - nell'ordine restituito da OpenAlex, troncati a max_results se specificato. + list[dict]: work grezzi raccolti su tutte le pagine necessarie (o + raccolte prima dell'esaurimento del budget di tempo), nell'ordine + restituito da OpenAlex, troncati a max_results se specificato. Raises: OpenAlexRequestError: se una richiesta di pagina fallisce in modo non @@ -78,12 +165,22 @@ def search_works(query, max_results=None, filters=None, mailto=None, per_page=MA if max_results is not None and max_results <= 0: return [] + if start_time is None: + start_time = time.monotonic() + effective_per_page = min(per_page, MAX_PER_PAGE) results = [] cursor = "*" while cursor is not None: + if fetch_timeout_seconds is not None and (time.monotonic() - start_time) > fetch_timeout_seconds: + logger.warning( + "search_works: budget di %.1fs esaurito, interrotta la paginazione dopo %d risultati raccolti", + fetch_timeout_seconds, len(results), + ) + break + params = { "search": query, "per-page": effective_per_page, @@ -147,7 +244,7 @@ def get_work_by_id(openalex_id, mailto=None): raise NotImplementedError -def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE): +def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE, resolve_timeout_seconds=None, start_time=None): """ Risolve in batch una lista di ID OpenAlex verso i rispettivi oggetti "work" completi, usando il filtro OR `openalex_id:ID1|ID2|...` supportato da /works @@ -166,6 +263,22 @@ def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE): solo il dizionario risultato: se questa distinzione servisse a valle, andra' aggiunta separatamente (es. restituendo anche la lista di ID falliti). + Ogni chiamata HTTP verso un batch usa RESOLVE_MAX_RETRIES (1 ri-tentativo, + non i 3 di default) invece del default di _request_with_retry: qui i batch + possono essere decine per una singola risoluzione (vedi + etl_pipeline.py::_resolve_references_for_works), quindi il costo peggiore + per singolo batch va tenuto basso deliberatamente, a differenza di + search_works che chiama _request_with_retry con i retry di default. + + Se resolve_timeout_seconds e' specificato, PRIMA di iniziare ogni nuovo + batch si controlla il tempo trascorso da start_time (o dall'inizio di + questa chiamata, se start_time non e' fornito): se il budget e' superato, + il loop si interrompe con un break (non un'eccezione) e viene restituito + il dizionario parziale gia' risolto fino a quel momento. Gli ID dei batch + non ancora processati restano semplicemente assenti dal risultato, con lo + stesso effetto pratico di un batch fallito: format_cr_column ricadra' sul + fallback a ID nudo per quei riferimenti. + Args: ids: lista di ID OpenAlex (forma short "W123..." o URL completo "https://openalex.org/W123...") da risolvere. Duplicati e ID @@ -173,12 +286,22 @@ def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE): mailto: email per la polite pool. batch_size: numero massimo di ID per chiamata; la lista (deduplicata e normalizzata) viene spezzata in chunk di questa dimensione. + resolve_timeout_seconds: budget di tempo (secondi) per l'INTERA + risoluzione, non per singolo batch. None (default) significa + nessun limite: tutti i batch vengono processati indipendentemente + dal tempo impiegato. + start_time: istante di riferimento (da time.monotonic()) da cui + calcolare il tempo trascorso; se None, si usa l'istante di ingresso + in questa funzione. Permette al chiamante (tipicamente + etl_pipeline.py::_resolve_references_for_works) di far partire il + cronometro prima ancora di chiamare get_works_by_ids. Returns: dict[str, dict]: mappa da ID OpenAlex normalizzato (short form) al relativo oggetto "work" grezzo. Gli ID non risolvibili (non trovati da - OpenAlex, oppure appartenenti a un batch fallito dopo i retry) sono - semplicemente assenti dal risultato. + OpenAlex, appartenenti a un batch fallito dopo i retry, oppure mai + raggiunti per esaurimento del budget di tempo) sono semplicemente + assenti dal risultato. """ normalized_ids = [] seen = set() @@ -188,8 +311,20 @@ def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE): seen.add(normalized) normalized_ids.append(normalized) + if start_time is None: + start_time = time.monotonic() + resolved = {} for batch_start in range(0, len(normalized_ids), batch_size): + if resolve_timeout_seconds is not None and (time.monotonic() - start_time) > resolve_timeout_seconds: + logger.warning( + "get_works_by_ids: budget di %.1fs esaurito, interrotto dopo %d/%d ID risolti " + "(%d batch rimanenti non processati)", + resolve_timeout_seconds, len(resolved), len(normalized_ids), + (len(normalized_ids) - batch_start + batch_size - 1) // batch_size, + ) + break + batch = normalized_ids[batch_start:batch_start + batch_size] params = { "filter": "openalex_id:" + "|".join(batch), @@ -201,7 +336,7 @@ def get_works_by_ids(ids, mailto=None, batch_size=DEFAULT_BATCH_SIZE): params["mailto"] = mailto try: - response = _request_with_retry(WORKS_ENDPOINT, params) + response = _request_with_retry(WORKS_ENDPOINT, params, max_retries=RESOLVE_MAX_RETRIES) except OpenAlexRequestError as exc: logger.error( "get_works_by_ids: batch di %d ID fallito dopo i retry (primi ID: %s): %s", @@ -242,8 +377,10 @@ def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_fa Riprova la richiesta in caso di: - errori di rete/timeout, - - HTTP 429 (rate limit), rispettando l'header Retry-After se presente - (altrimenti backoff esponenziale), + - HTTP 429 (rate limit), rispettando l'header Retry-After se presente ma + con un tetto a MAX_RETRY_AFTER_WAIT_SECONDS (un server puo' chiedere + un'attesa di ore, vedi il commento su quella costante; altrimenti + backoff esponenziale), - HTTP 5xx (errori transitori lato server). Non riprova su errori 4xx diversi da 429 (es. 400/404), che vengono considerati @@ -256,7 +393,11 @@ def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_fa massimo max_retries + 1 richieste HTTP totali). backoff_factor: fattore moltiplicativo per il tempo di attesa tra un tentativo e il successivo (attesa = backoff_factor ** tentativo). - timeout: timeout in secondi per ciascuna richiesta HTTP. + timeout: tupla (connect_timeout, read_timeout) in secondi, passata + direttamente a requests.get. NON e' un tetto sul tempo totale + della richiesta: read_timeout e' il silenzio massimo tollerato + tra un chunk di risposta e il successivo (vedi DEFAULT_TIMEOUT + per il perche' di questa distinzione). Returns: requests.Response: la risposta HTTP con status < 400. @@ -294,7 +435,13 @@ def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_fa wait_seconds = backoff_factor ** attempt if retry_after is not None: try: - wait_seconds = float(retry_after) + # Tetto a MAX_RETRY_AFTER_WAIT_SECONDS: un server puo' + # legittimamente chiedere di attendere ore (visto in + # produzione con un 429 da budget esaurito e + # Retry-After: 28979), ma un'attesa cosi' lunga non e' + # utilizzabile in un contesto interattivo - vedi il + # commento su MAX_RETRY_AFTER_WAIT_SECONDS per il dettaglio. + wait_seconds = min(float(retry_after), MAX_RETRY_AFTER_WAIT_SECONDS) except ValueError: pass time.sleep(wait_seconds) From ea2f6ddcfaf1936ca31ffa755d93f3782d891320 Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 18:17:25 +0200 Subject: [PATCH 6/9] Add Open Access Analysis page (bonus) New donut chart showing OA status distribution (gold/green/hybrid/ bronze/diamond/closed/unknown), following the existing analysis page pattern (Plotly FigureWidget, Plot/Table tabs, Add in Report). This is an analysis not meaningfully available in the classic WoS schema - made possible specifically by using OpenAlex as the source. Live-verified: sidebar menu correctly shows all extended sections after loading data (also confirms the earlier toggle_sidebar fix works end-to-end), new page renders correctly with real data. --- app.py | 62 ++++++++++++++++++++++++++- functions/__init__.py | 1 + functions/get_openaccessanalysis.py | 65 +++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 functions/get_openaccessanalysis.py diff --git a/app.py b/app.py index af9e75484..479f10ceb 100644 --- a/app.py +++ b/app.py @@ -1428,7 +1428,61 @@ async def handle_user_input(user_input: str): answer = "Gemini API key not configured. Please set GEMINI_API_KEY in Settings section." await chat.append_message(answer) - + + # --- Open Access Analysis Section --- + with ui.nav_panel("None", value="open_access_analysis"): + with ui.layout_columns( + col_widths=(9, 3), + style="margin-bottom: -21px;" + ): + with ui.tags.div(style="flex: 1; bottom: 0px;"): + ui.h3("🔓 Open Access Analysis", style="color: #5567BB;") + ui.p("The Open Access status distribution of the dataset") + + with ui.tags.div(style="flex: 2; display: flex; justify-content: flex-end; gap: 5px; align-items: flex-start; bottom: 0px;"): + ui.input_action_button("open_access_report", "Add in Report", icon=ICONS["plus"]) + + todaydate = datetime.today() + todaydate = todaydate.strftime("%Y-%m-%d") + @render.download( + label='💾 Download', + filename=f"OpenAccessAnalysis-{todaydate}.png" + ) + def download_open_access(): + plot_open_access, _ = open_access_informations() + yield plotly_download( + plot_open_access, + title="Open Access Analysis", + height=height.get(), + dpi=dpi.get() + ) + + @render.ui + @reactive.event(input.open_access_report) + def show_open_access_report(): + plots, oa_counts = open_access_informations() + report_excel.set(add_to_report(report_choices, report_excel, [oa_counts], [plots], "openaccessanalysis")) + selection.set(selection.get() + (f"{list(report_choices.get().keys())[-1]}",)) + return ui.notification_show("✅ Open Access Analysis added to report", duration=5, close_button=False) + + with ui.card(full_screen=True): + @reactive.calc + def open_access_informations(): + return get_open_access_analysis(df) + + with ui.navset_underline(id="open_access_tab"): + with ui.nav_panel("Plot"): + @render_widget + def show_open_access_analysis(): + plot_open_access, oa_counts = open_access_informations() + return plot_open_access + + with ui.nav_panel("Table"): + @render.ui + def table_open_access_analysis(): + _, oa_counts = open_access_informations() + return ui.HTML(DT(oa_counts, style="width=100%;")) + # --- Average Citations per Year Section --- with ui.nav_panel("None", value="average_citations_per_year"): with ui.layout_columns( @@ -8305,6 +8359,7 @@ def toggle_sidebar(): with ui.accordion_panel("Overview", icon=ICONS["play_colored"]): ui.input_action_button("go_main", "Main Information", class_="sidebar-button", icon=ICONS["overview"]) ui.input_action_button("go_annual_scientific_production", "Annual Scientific Production", class_="sidebar-button", icon=ICONS["annual_growth_rate"]) + ui.input_action_button("go_open_access_analysis", "Open Access Analysis", class_="sidebar-button", icon=ICONS["sources"]) ui.input_action_button("go_average_citations_per_year", "Average Citations per Year", class_="sidebar-button", icon=ICONS["average_citations_per_doc"]) ui.input_action_button("go_three_field_plot", "Three-Field Plot", class_="sidebar-button", icon=ICONS["overview"]) with ui.accordion_panel("Sources", icon=ICONS["sources_colored"]): @@ -8521,6 +8576,11 @@ def _(): def _(): ui.update_navs("hidden_tabs", selected="annual_scientific_production") +@reactive.effect +@reactive.event(input.go_open_access_analysis) +def _(): + ui.update_navs("hidden_tabs", selected="open_access_analysis") + @reactive.effect @reactive.event(input.go_average_citations_per_year) def _(): diff --git a/functions/__init__.py b/functions/__init__.py index 20e24de36..3ce60d80e 100644 --- a/functions/__init__.py +++ b/functions/__init__.py @@ -20,6 +20,7 @@ from .get_localcitedsources import * from .get_lotkalaw import * from .get_maininformations import * +from .get_openaccessanalysis import * from .get_referencesspectroscopy import * from .get_relevantaffiliations import * from .get_relevantauthors import * diff --git a/functions/get_openaccessanalysis.py b/functions/get_openaccessanalysis.py new file mode 100644 index 000000000..0f47786d8 --- /dev/null +++ b/functions/get_openaccessanalysis.py @@ -0,0 +1,65 @@ +from www.services import * + + +def get_open_access_analysis(df): + """ + Generate a pie chart and table of the Open Access status distribution. + + Args: + df: A DataFrame object containing the data. + + Returns: + A Plotly figure object representing the Open Access distribution and + a DataFrame with the document count per OA status. + """ + data = df.get() + + # OA vuoto ("") indica che lo stato Open Access non e' noto/disponibile per + # quel documento (comune sia nella pipeline OpenAlex sia nelle altre fonti + # storiche quando il dato non e' popolato). Scelta: invece di scartare questi + # record dal conteggio (il che nasconderebbe quanto e' effettivamente + # completo il dataset), li raggruppiamo in una categoria esplicita + # "Unknown" - coerente con lo spirito delle tabelle di completezza gia' + # presenti altrove nell'app (vedi functions/get_table.py). + oa_status = data["OA"].fillna("").replace("", "Unknown") + oa_counts = oa_status.value_counts().reset_index() + oa_counts.columns = ["OA Status", "Freq"] + oa_counts = oa_counts.sort_values(by="Freq", ascending=False).reset_index(drop=True) + + # Create the plot + fig = px.pie( + oa_counts, names="OA Status", values="Freq", + hole=0.4, + color_discrete_sequence=px.colors.sequential.Blues_r, + ) + + # Customize the layout and tooltips (hover) + fig.update_traces( + textinfo="label+percent", + textfont=dict(size=13), + marker=dict(line=dict(color="white", width=2)), + hovertemplate=( + "%{label}
" + "Documents: %{value}" + ) + ) + + fig.update_layout( + plot_bgcolor='white', + font=dict(color="#222222", size=14, family="Segoe UI, Arial"), + margin=dict(l=50, r=30, t=60, b=50), + height=600, + legend=dict(orientation='h', yanchor='bottom', y=-0.15, xanchor='center', x=0.5), + hoverlabel=dict( + bgcolor="white", + font_size=13, + font_family="Segoe UI, Arial", + bordercolor="#1f77b4" + ) + ) + + fig = go.FigureWidget(fig) + fig._config = fig._config | {'modeBarButtonsToRemove': ['pan', 'select', 'lasso2d', 'toImage'], + 'displaylogo': False} + + return fig, oa_counts From 014de357e1bdc483af5dabfd673fa520e61d863a Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Sat, 18 Jul 2026 18:32:42 +0200 Subject: [PATCH 7/9] Fix 4th histNetwork() None-handling bug + document coupling performance issue - couplingmap.py::localCitations: same None-handling pattern as the 3 previous fixes (get_historiograph.py, get_localcitedauthors.py, get_localciteddocuments.py), adapted to this caller's expected return shape: returns LCS=0 for each document with correctly-shaped empty tables, instead of a bare None, since normalizeCitationScore downstream always expects a dict with a valid 'LCS' column. - get_clusteringcoupling.py: documented a known performance issue (Cluster by Coupling runs slowly on OpenAlex data) as out of scope, same pattern as the previously documented get_factorialanalysis.py limitation - root cause not identified in the coupling-matrix construction, not clearly related to our standardizer or CR format. --- functions/get_clusteringcoupling.py | 21 +++++++++++++++++---- www/services/couplingmap.py | 21 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/functions/get_clusteringcoupling.py b/functions/get_clusteringcoupling.py index 8263a46b3..de7517027 100644 --- a/functions/get_clusteringcoupling.py +++ b/functions/get_clusteringcoupling.py @@ -1,11 +1,24 @@ from www.services import * -def get_clustering_coupling(df, unit_of_analysis, coupling_measured, stemmer, impact_measure, - cluster_labeling, ngram, num_of_units, min_cluster_freq, - label_per_cluster, label_size, community_repulsion, +def get_clustering_coupling(df, unit_of_analysis, coupling_measured, stemmer, impact_measure, + cluster_labeling, ngram, num_of_units, min_cluster_freq, + label_per_cluster, label_size, community_repulsion, clustering_algorithm, node_shape='dot'): - + # LIMITE NOTO (non investigato oltre in questa sessione, fuori scope): + # "Cluster by Coupling" risulta lento con dati OpenAlex per una causa non + # identificata nella costruzione della matrice di coupling/coincidenza + # citazionale (couplingMap -> network -> biblionetwork/cocMatrix/ + # network_plot in www/services/couplingmap.py), indipendente dal nostro + # standardizzatore. La dimensione del calcolo principale (matrice N x N + # documenti, N ~ 30) e' teoricamente troppo piccola per giustificare una + # lentezza reale, quindi non sembra un limite "normale ma lento" per + # questo volume di dati - ma la causa esatta non e' stata isolata (analisi + # non validata, si sospetta codice preesistente del professore, non + # necessariamente legato al formato di CR). Separato dal bug gia' corretto + # in couplingmap.py::localCitations (histNetwork che ritornava None senza + # gestione), che riguarda un crash successivo, non questa lentezza. + # Generate coupling map coupling_map = couplingMap( df, diff --git a/www/services/couplingmap.py b/www/services/couplingmap.py index a2b3628d7..3887948e1 100644 --- a/www/services/couplingmap.py +++ b/www/services/couplingmap.py @@ -525,6 +525,27 @@ def localCitations(df, fast_search=False, sep=";"): loccit = 1 H = histNetwork(df, min_citations=loccit, sep=sep, network=False) + + # LIMITE NOTO (stesso pattern gia' applicato in get_historiograph.py, + # get_localcitedauthors.py, get_localciteddocuments.py): histNetwork ritorna + # None per qualunque DB diverso da "Web_of_Science"/"Scopus" (quindi anche + # per "OPENALEX"), PRIMA di calcolare alcunche' - non e' un problema di + # formato di CR, e per le stesse ragioni gia' documentate in + # get_historiograph.py non estendiamo histNetwork qui. A differenza di quei + # tre file pero', qui il chiamante (normalizeCitationScore, quindi + # couplingMap/get_clustering_coupling.py) si aspetta sempre un dict con una + # colonna 'LCS' popolata in 'M': costruiamo un fallback con LCS=0 per ogni + # documento (nessun dato di citazione locale disponibile per questa + # sorgente) invece di propagare un TypeError su H['histData']. + if H is None: + M_fallback = M.copy() + M_fallback['LCS'] = 0 + return { + 'Authors': pd.DataFrame(columns=["Authors", "N. of Local Citations"]), + 'Papers': pd.DataFrame(columns=["Paper", "DOI", "Year", "LCS", "GCS"]), + 'M': M_fallback, + } + LCS = H['histData'] M = H['M'] From d64c59379153982de87164ff7db36ceb955888bb Mon Sep 17 00:00:00 2001 From: Gennaro basile Date: Mon, 20 Jul 2026 10:52:23 +0200 Subject: [PATCH 8/9] Add PubMed as second data source, proving source-agnostic design - New www/services/http_client.py: extracted the already-generic retry/backoff/timeout logic from openalex_client.py into a shared module, reused as-is by both sources (no duplication) - New www/services/pubmed_client.py: ESearch + EFetch against NCBI E-utilities, same retry pattern as OpenAlex - New www/services/pubmed_mapper.py: PubMed XML -> 34-column schema, same format_XX_column pattern as openalex_mapper.py. Notably CR requires no extra resolution step (PubMed's ReferenceList often already contains readable citation text), and JI (journal abbreviation) is natively available, unlike OpenAlex. - etl_pipeline.py: added run_pubmed_etl (mirrors run_openalex_etl) and run_etl(source=...) as a generic dispatcher, without touching the already-tested OpenAlex path wired into app.py - Real bug fixed during implementation: format_dt_column initially picked an administrative funding-source label as the document type instead of a genuine type; fixed by filtering to only the known document-type vocabulary Tested end-to-end against real NCBI API data (query 'diabetes treatment') and against get_annualproduction, get_local_cited_refs, get_relevant_authors, get_countriesproduction, get_local_cited_authors. --- www/services/__init__.py | 3 + www/services/etl_pipeline.py | 248 ++++++- www/services/http_client.py | 136 ++++ www/services/openalex_client.py | 106 +-- www/services/pubmed_client.py | 286 ++++++++ www/services/pubmed_mapper.py | 1084 +++++++++++++++++++++++++++++++ 6 files changed, 1782 insertions(+), 81 deletions(-) create mode 100644 www/services/http_client.py create mode 100644 www/services/pubmed_client.py create mode 100644 www/services/pubmed_mapper.py diff --git a/www/services/__init__.py b/www/services/__init__.py index 8b6b3fefe..89c2b6ba6 100644 --- a/www/services/__init__.py +++ b/www/services/__init__.py @@ -6,12 +6,15 @@ from .histnetwork import * from .histplot import * from .htmldownload import * +from .http_client import * from .igraph2vis import * from .metatagextraction import * from .networkplot import * from .openalex_client import * from .openalex_mapper import * from .parsers import * +from .pubmed_client import * +from .pubmed_mapper import * from .plotlydownload import * from .savereport import * from .tabletag import * diff --git a/www/services/etl_pipeline.py b/www/services/etl_pipeline.py index 7d99dfcfd..dc5138354 100644 --- a/www/services/etl_pipeline.py +++ b/www/services/etl_pipeline.py @@ -1,7 +1,10 @@ """ -Entry-point unico per la pipeline ETL "query utente -> OpenAlex -> schema WoS-style". +Entry-point ETL "query utente -> sorgente esterna -> schema WoS-style". +Due sorgenti supportate ad oggi: OpenAlex (run_openalex_etl) e PubMed +(run_pubmed_etl), piu' un dispatcher generico (run_etl) che sceglie tra le +due in base a un parametro `source`. -Orchestra, in ordine: +Orchestra, in ordine, per OpenAlex: 1. query utente -> openalex_client (ricerca + paginazione cursor-based, con risoluzione opzionale in batch degli ID in `referenced_works`) 2. openalex_client -> openalex_mapper (mapping di ciascun "work" grezzo sulle @@ -14,18 +17,57 @@ prodotto dalla pipeline storica basata su file (vedi www/services/format_functions.py::process_single_file). +Per PubMed lo schema e' lo stesso (stadi 3-5 identici, tramite pubmed_mapper +invece di openalex_mapper), ma lo stadio 1-2 e' un solo passaggio +(pubmed_client.search_articles incapsula gia' ESearch+EFetch) e non esiste +uno stadio di risoluzione referenze separato: vedi run_pubmed_etl per il +dettaglio del perche'. + Questo modulo e' pensato come punto di ingresso alternativo a functions/get_data.py (che copre l'import da file WoS/Scopus/ecc.), cosi' che il resto della codebase (le funzioni in functions/get_*.py, che operano sul DataFrame condiviso `df`) possa continuare a funzionare senza sapere se i dati -provengono da un file caricato dall'utente o da una query OpenAlex. +provengono da un file caricato dall'utente o da una query esterna. """ from .utils import * from .openalex_client import * from .openalex_mapper import * +from .pubmed_client import * +from .pubmed_mapper import * from .type_contracts import * +# DEBUGGING LOG: openalex_mapper e pubmed_mapper definiscono ENTRAMBI una +# funzione compute_sr_for_records (e build_sr_bridge_frame) con la STESSA +# firma e la STESSA logica interna, per design (vedi i rispettivi moduli: +# entrambe delegano a metatagextraction.py::SR senza modifiche). Con i due +# `from .X import *` qui sopra, il nome bare `compute_sr_for_records` nello +# spazio dei nomi di questo modulo finisce per riferirsi SOLO all'ultimo +# importato (pubmed_mapper, che compare dopo openalex_mapper) - non produce +# un bug osservabile oggi perche' le due implementazioni sono +# byte-per-byte equivalenti, ma e' una collisione di nomi silenziosa che +# diventerebbe un bug reale nel momento in cui una delle due venisse +# modificata senza toccare l'altra. Per non fare affidamento su questa +# coincidenza, gli stage source-specific qui sotto (_compute_calculated_fields +# per OpenAlex, _compute_calculated_fields_pubmed per PubMed) chiamano la +# funzione del modulo giusto in modo esplicito, tramite questi riferimenti +# qualificati, invece del nome bare importato con `*`. +from . import openalex_mapper as _openalex_mapper +from . import pubmed_mapper as _pubmed_mapper + +# Stessa collisione, stessa motivazione, per DEFAULT_FETCH_TIMEOUT_SECONDS +# (e DEFAULT_MAX_RETRIES/DEFAULT_BACKOFF_FACTOR/DEFAULT_TIMEOUT, non usati +# qui per nome bare): openalex_client.py e pubmed_client.py definiscono +# ENTRAMBI queste costanti con lo stesso nome e, ad oggi, lo stesso valore. +# Riferimenti qualificati per gli stessi motivi di sopra. +from . import openalex_client as _openalex_client +from . import pubmed_client as _pubmed_client + +# Alias pubblico usato come default di run_pubmed_etl - non e' una nuova +# costante indipendente, e' il valore di pubmed_client.DEFAULT_FETCH_TIMEOUT_SECONDS +# esposto qui con un nome che non collide con quello (identico) di OpenAlex. +PUBMED_DEFAULT_FETCH_TIMEOUT_SECONDS = _pubmed_client.DEFAULT_FETCH_TIMEOUT_SECONDS + # Budget di default (secondi) per l'intera fase di risoluzione batch dei # referenced_works (vedi _resolve_references_for_works). Esposto anche come @@ -49,7 +91,7 @@ def run_openalex_etl( resolve_references=True, strict_validation=True, resolve_timeout_seconds=DEFAULT_RESOLVE_TIMEOUT_SECONDS, - fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS, + fetch_timeout_seconds=_openalex_client.DEFAULT_FETCH_TIMEOUT_SECONDS, ): """ Entry-point principale: esegue l'intera pipeline ETL da una query utente @@ -131,7 +173,117 @@ def run_openalex_etl( return df, [] -def _fetch_raw_works(query, filters, mailto, max_results, fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS): +def run_pubmed_etl( + query, + email=None, + api_key=None, + max_results=None, + strict_validation=True, + fetch_timeout_seconds=PUBMED_DEFAULT_FETCH_TIMEOUT_SECONDS, +): + """ + Entry-point principale per la sorgente PubMed: esegue l'intera pipeline + ETL da una query utente PubMed (E-utilities) a un pandas.DataFrame nello + schema a 34 colonne WoS-style. Speculare a run_openalex_etl, con due + differenze strutturali (non solo di nomenclatura) dovute a come e' fatta + l'API PubMed rispetto a OpenAlex - vedi il modulo docstring e + pubmed_client.py/pubmed_mapper.py per l'analisi completa: + + 1. Nessuno stadio di risoluzione referenze: il testo dei riferimenti + bibliografici (quando disponibile) e' gia' incluso nel payload di + EFetch (PubmedData/ReferenceList/Reference/Citation), a differenza di + OpenAlex dove referenced_works e' solo una lista di ID che richiede + una chiamata batch separata (openalex_client.get_works_by_ids). Non + c'e' quindi un equivalente di resolve_references/ + resolve_timeout_seconds in questa funzione. + 2. pubmed_client.search_articles incapsula gia' internamente sia la + ricerca (ESearch) che il recupero dei record completi (EFetch): un + solo stadio di fetch, invece dei due stadi distinti fetch+resolve di + run_openalex_etl. + + Args: + query: stringa di ricerca testuale libera, inoltrata a + pubmed_client.search_articles (supporta i tag di campo nativi di + PubMed, es. "diabetes[Title]"). + email: email da usare per identificare il chiamante presso NCBI + (equivalente a `mailto` in run_openalex_etl). + api_key: chiave API NCBI opzionale, alza il rate limit consentito. + max_results: numero massimo di record da scaricare; None per + scaricare tutti i risultati della query. + strict_validation: vedi run_openalex_etl (stesso significato, stessa + rete di sicurezza interna in _validate_records, condivisa tra le + due sorgenti). + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERA + raccolta (ESearch + tutte le chiamate EFetch), passato a + pubmed_client.search_articles. Default + PUBMED_DEFAULT_FETCH_TIMEOUT_SECONDS (30s, stesso valore e stessa + motivazione di DEFAULT_FETCH_TIMEOUT_SECONDS per OpenAlex). None + per nessun limite. + + Returns: + Tupla (df, validation_errors): vedi run_openalex_etl (stesso + contratto, stesso schema a 34 colonne, stesso ordine di colonne). + + Raises: + ETLPipelineError: se uno stadio non recuperabile della pipeline + fallisce (nessun risultato dalla query, errore HTTP non gestito + dal client, oppure errori di validazione residui dopo la + coercizione dei tipi). + """ + articles = _fetch_raw_pubmed_articles(query, email, api_key, max_results, fetch_timeout_seconds=fetch_timeout_seconds) + + records = _map_articles_to_records(articles) + records = _compute_calculated_fields_pubmed(records) + + coerced_records, _ = _validate_records(records, strict=strict_validation) + + df = _build_dataframe(coerced_records) + + return df, [] + + +def run_etl(source="openalex", **kwargs): + """ + Dispatcher generico: inoltra l'esecuzione a run_openalex_etl o + run_pubmed_etl in base a `source`, cosi' che un chiamante (tipicamente + una UI con un selettore di sorgente) possa dipendere da un'unica + funzione invece di scegliere quale delle due chiamare. + + NON sostituisce run_openalex_etl come punto di ingresso: app.py chiama + gia' direttamente run_openalex_etl (vedi il call site nella pagina "API" + del dashboard) e continua a funzionare invariato — questo dispatcher e' + stato aggiunto in aggiunta, non al posto di, per soddisfare l'esplicita + richiesta di un parametro `source` (default "openalex") senza toccare un + percorso gia' testato dal vivo. Un'eventuale futura UI PubMed potra' + scegliere se chiamare run_pubmed_etl direttamente (stesso pattern di + run_openalex_etl in app.py oggi) o passare da qui. + + Args: + source: "openalex" (default) o "pubmed". Qualunque altro valore + solleva ValueError immediatamente, prima di qualunque chiamata + di rete. + **kwargs: inoltrati cosi' come sono alla funzione scelta (vedi + run_openalex_etl/run_pubmed_etl per i parametri accettati da + ciascuna sorgente — NON sono intercambiabili: es. + resolve_references e resolve_timeout_seconds esistono solo per + "openalex", email/api_key solo per "pubmed"). + + Returns: + Tupla (df, validation_errors), vedi run_openalex_etl/run_pubmed_etl. + + Raises: + ValueError: se `source` non e' "openalex" ne' "pubmed". + ETLPipelineError, OpenAlexRequestError, PubMedRequestError: propagate + cosi' come sollevate dalla funzione scelta. + """ + if source == "openalex": + return run_openalex_etl(**kwargs) + if source == "pubmed": + return run_pubmed_etl(**kwargs) + raise ValueError(f"source non riconosciuta: {source!r} (attese: 'openalex', 'pubmed').") + + +def _fetch_raw_works(query, filters, mailto, max_results, fetch_timeout_seconds=_openalex_client.DEFAULT_FETCH_TIMEOUT_SECONDS): """ Stadio 1: recupera dalla API OpenAlex la lista grezza di oggetti "work" (dict JSON) corrispondenti alla query utente, delegando a @@ -291,7 +443,91 @@ def _compute_calculated_fields(records): decisione presa esplicitamente dopo averlo verificato con type_contracts.validate_record durante lo sviluppo di questo modulo. """ - enriched = compute_sr_for_records(records) + enriched = _openalex_mapper.compute_sr_for_records(records) + for record in enriched: + record.pop("SR_FULL", None) + return enriched + + +def _fetch_raw_pubmed_articles(query, email, api_key, max_results, fetch_timeout_seconds=PUBMED_DEFAULT_FETCH_TIMEOUT_SECONDS): + """ + Stadio 1 (PubMed): recupera dalle E-utilities la lista grezza di elementi + corrispondenti alla query utente, delegando a + pubmed_client.search_articles (che gia' incapsula sia ESearch che EFetch + a batch - vedi run_pubmed_etl per il contrasto con i due stadi separati + usati da OpenAlex). + + Args: + query, email, api_key, max_results: vedi run_pubmed_etl. + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERA + raccolta (ESearch + tutte le EFetch). Default + PUBMED_DEFAULT_FETCH_TIMEOUT_SECONDS (30s). None per nessun limite. + + Returns: + list[xml.etree.ElementTree.Element]: nodi grezzi + (eventualmente parziali se il budget di tempo e' stato superato). + + Raises: + ETLPipelineError: se la query non produce alcun risultato, oppure se + pubmed_client.search_articles solleva PubMedRequestError (errore + HTTP non recuperabile dopo i retry). + """ + try: + articles = search_articles( + query, + max_results=max_results, + email=email, + api_key=api_key, + fetch_timeout_seconds=fetch_timeout_seconds, + ) + except PubMedRequestError as exc: + raise ETLPipelineError( + f"Recupero degli articoli da PubMed fallito per la query {query!r}: {exc}" + ) from exc + + if not articles: + raise ETLPipelineError(f"Nessun risultato PubMed per la query {query!r}.") + + return articles + + +def _map_articles_to_records(articles): + """ + Stadio 2 (PubMed): applica pubmed_mapper.map_article_to_record a + ciascun elemento grezzo, producendo la lista di record + (dict) nello schema a 34 colonne WoS-style (esclusi i campi calcolati a + livello di collezione come SR). Analoga a _map_works_to_records, ma senza + un parametro equivalente a resolved_references: PubMed non richiede + alcuna risoluzione separata per CR (vedi run_pubmed_etl). + + Args: + articles: list[xml.etree.ElementTree.Element] di + grezzi. + + Returns: + list[dict]: un record per articolo, con le chiavi delle 34 colonne + (tranne i campi calcolati a livello di collezione). + """ + return [map_article_to_record(article) for article in articles] + + +def _compute_calculated_fields_pubmed(records): + """ + Stadio 3 (PubMed): calcola i campi derivati dall'intera collezione (SR), + identico a _compute_calculated_fields ma tramite + pubmed_mapper.compute_sr_for_records invece della versione OpenAlex - + vedi il commento in cima al modulo sul perche' questi due riferimenti + vanno tenuti espliciti invece di usare il nome bare + `compute_sr_for_records`. + + Args: + records: list[dict] prodotta da _map_articles_to_records. + + Returns: + list[dict]: nuovi record arricchiti con la chiave "SR" (SR_FULL + scartata, stessa motivazione di _compute_calculated_fields). + """ + enriched = _pubmed_mapper.compute_sr_for_records(records) for record in enriched: record.pop("SR_FULL", None) return enriched diff --git a/www/services/http_client.py b/www/services/http_client.py new file mode 100644 index 000000000..ae192eb5e --- /dev/null +++ b/www/services/http_client.py @@ -0,0 +1,136 @@ +""" +Client HTTP generico con retry/backoff/timeout robusti, condiviso da tutti i +client di sorgente (OpenAlex, PubMed, ...). Nessuna logica specifica di una +singola API vive qui: endpoint, parametri di query e la classe di eccezione +da sollevare sono responsabilita' del chiamante. + +Estratto da openalex_client.py::_request_with_retry quando e' stata aggiunta +PubMed come seconda fonte: quella funzione era gia' completamente generica +(prendeva solo url+params in input), l'unica parte "OpenAlex-specific" era il +nome della classe di eccezione sollevata direttamente al suo interno. Senza +questa estrazione, i tre bug reali diagnosticati e corretti su OpenAlex in +questa sessione - timeout singolo che non copre il tempo totale della +richiesta, header Retry-After onorato senza alcun tetto, retry ingenui su +errori transitori - sarebbero rimasti da ri-diagnosticare e ri-correggere una +seconda volta, identici, per PubMed. +""" + +import logging + +from .utils import * + + +logger = logging.getLogger(__name__) + + +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BACKOFF_FACTOR = 1.5 + +# Tupla (connect_timeout, read_timeout), non un singolo valore - vedi +# openalex_client.py per la cronaca completa del bug che ha portato a questa +# scelta: un timeout singolo passato a requests.get() e' un timeout di +# INATTIVITA' tra un chunk di risposta e il successivo, non un tetto sul +# tempo totale della richiesta. +DEFAULT_TIMEOUT = (5, 15) + +# Tetto massimo (secondi) onorato per l'header Retry-After di una risposta +# 429 - vedi openalex_client.py per la cronaca completa del bug (un server puo' +# legittimamente chiedere un'attesa di ore, inutilizzabile in un contesto +# interattivo). +MAX_RETRY_AFTER_WAIT_SECONDS = 20 + + +class ExternalAPIRequestError(Exception): + """Sollevata quando una richiesta a un'API esterna fallisce in modo non + recuperabile. Le classi di eccezione source-specific (es. + OpenAlexRequestError, PubMedRequestError) ereditano da questa: un + chiamante che vuole gestire "qualunque fonte esterna fallita" allo stesso + modo puo' intercettare solo questa base, senza conoscere quale client + l'ha sollevata.""" + pass + + +def request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, + backoff_factor=DEFAULT_BACKOFF_FACTOR, timeout=DEFAULT_TIMEOUT, + error_cls=ExternalAPIRequestError): + """ + Esegue una GET HTTP con retry ed exponential backoff. + + Riprova la richiesta in caso di: + - errori di rete/timeout, + - HTTP 429 (rate limit), rispettando l'header Retry-After se presente ma + con un tetto a MAX_RETRY_AFTER_WAIT_SECONDS (un server puo' chiedere + un'attesa di ore; altrimenti backoff esponenziale), + - HTTP 5xx (errori transitori lato server). + + Non riprova su errori 4xx diversi da 429 (es. 400/404), che vengono + considerati definitivi e propagati immediatamente come `error_cls`. + + Args: + url: URL completo della richiesta. + params: dict di query string da passare a requests.get. + max_retries: numero massimo di RI-tentativi dopo il primo (quindi al + massimo max_retries + 1 richieste HTTP totali). + backoff_factor: fattore moltiplicativo per il tempo di attesa tra un + tentativo e il successivo (attesa = backoff_factor ** tentativo). + timeout: tupla (connect_timeout, read_timeout) in secondi, passata + direttamente a requests.get. NON e' un tetto sul tempo totale + della richiesta: read_timeout e' il silenzio massimo tollerato + tra un chunk di risposta e il successivo. + error_cls: classe di eccezione da sollevare in caso di fallimento + definitivo. Deve accettare un singolo argomento posizionale + (il messaggio). Permette a ciascun client source-specific di + sollevare la propria sottoclasse (es. OpenAlexRequestError, + PubMedRequestError) riusando identica la logica di retry. + + Returns: + requests.Response: la risposta HTTP con status < 400. + + Raises: + error_cls: su errore 4xx diverso da 429 (nessun retry), oppure dopo + l'esaurimento dei retry per 429/5xx/errori di rete, con status + code e corpo della risposta inclusi nel messaggio per facilitare + il debug. + """ + last_error = None + + for attempt in range(max_retries + 1): + try: + response = requests.get(url, params=params, timeout=timeout) + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: + last_error = exc + if attempt == max_retries: + raise error_cls( + f"Richiesta a {url} fallita dopo {max_retries + 1} tentativi: {exc}" + ) from exc + time.sleep(backoff_factor ** attempt) + continue + + if response.status_code < 400: + return response + + if response.status_code == 429 or response.status_code >= 500: + last_error = error_cls( + f"HTTP {response.status_code} da {url}: {response.text[:500]}" + ) + if attempt == max_retries: + raise last_error + + retry_after = response.headers.get("Retry-After") + wait_seconds = backoff_factor ** attempt + if retry_after is not None: + try: + wait_seconds = min(float(retry_after), MAX_RETRY_AFTER_WAIT_SECONDS) + except ValueError: + pass + time.sleep(wait_seconds) + continue + + # 4xx diverso da 429: errore considerato definitivo, nessun retry. + raise error_cls( + f"HTTP {response.status_code} da {url}: {response.text[:500]}" + ) + + # Non raggiungibile in condizioni normali (il loop ritorna o solleva ad ogni + # iterazione), presente solo per robustezza. + raise error_cls(f"Richiesta a {url} fallita: {last_error}") diff --git a/www/services/openalex_client.py b/www/services/openalex_client.py index 0a72e92ae..5302754a8 100644 --- a/www/services/openalex_client.py +++ b/www/services/openalex_client.py @@ -18,6 +18,7 @@ import logging from .utils import * +from .http_client import ExternalAPIRequestError, request_with_retry logger = logging.getLogger(__name__) @@ -97,9 +98,17 @@ MAX_RETRY_AFTER_WAIT_SECONDS = 20 -class OpenAlexRequestError(Exception): +class OpenAlexRequestError(ExternalAPIRequestError): """Sollevata quando una richiesta a OpenAlex fallisce in modo non recuperabile - (status 4xx diverso da 429, oppure 5xx/timeout dopo l'esaurimento dei retry).""" + (status 4xx diverso da 429, oppure 5xx/timeout dopo l'esaurimento dei retry). + + Eredita da ExternalAPIRequestError (http_client.py) invece che direttamente + da Exception da quando e' stata aggiunta PubMed come seconda fonte: i due + punti che intercettano questa eccezione (app.py:937, etl_pipeline.py:173) + continuano a funzionare identici perche' e' ancora questo il tipo + concreto sollevato da _request_with_retry qui sotto - il cambio di base + class amplia solo cosa un chiamante PUO' intercettare, non cosa viene + sollevato.""" pass @@ -373,31 +382,19 @@ def _normalize_openalex_id(openalex_id_or_url): def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_factor=DEFAULT_BACKOFF_FACTOR, timeout=DEFAULT_TIMEOUT): """ - Funzione interna: esegue una GET HTTP con retry ed exponential backoff. - - Riprova la richiesta in caso di: - - errori di rete/timeout, - - HTTP 429 (rate limit), rispettando l'header Retry-After se presente ma - con un tetto a MAX_RETRY_AFTER_WAIT_SECONDS (un server puo' chiedere - un'attesa di ore, vedi il commento su quella costante; altrimenti - backoff esponenziale), - - HTTP 5xx (errori transitori lato server). - - Non riprova su errori 4xx diversi da 429 (es. 400/404), che vengono considerati - definitivi e propagati immediatamente come OpenAlexRequestError. - - Args: - url: URL completo della richiesta. - params: dict di query string da passare a requests.get. - max_retries: numero massimo di RI-tentativi dopo il primo (quindi al - massimo max_retries + 1 richieste HTTP totali). - backoff_factor: fattore moltiplicativo per il tempo di attesa tra un - tentativo e il successivo (attesa = backoff_factor ** tentativo). - timeout: tupla (connect_timeout, read_timeout) in secondi, passata - direttamente a requests.get. NON e' un tetto sul tempo totale - della richiesta: read_timeout e' il silenzio massimo tollerato - tra un chunk di risposta e il successivo (vedi DEFAULT_TIMEOUT - per il perche' di questa distinzione). + Funzione interna: wrapper sottile su http_client.request_with_retry, che + contiene la logica di retry/backoff/timeout vera e propria (estratta da + qui quando e' stata aggiunta PubMed come seconda fonte, per non duplicare + identica la stessa logica in un ipotetico pubmed_client.py). Preserva + 100% la firma e il comportamento esterno di questa funzione, incluso il + tipo di eccezione sollevato (OpenAlexRequestError): i due soli punti del + resto del codice che dipendono da questo tipo concreto + (app.py:937 `except (ETLPipelineError, OpenAlexRequestError)`, + etl_pipeline.py:173 `except OpenAlexRequestError`) continuano a + funzionare senza modifiche. + + Args: vedi http_client.request_with_retry (stessi parametri, stesso + significato). Returns: requests.Response: la risposta HTTP con status < 400. @@ -407,51 +404,10 @@ def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_fa dopo l'esaurimento dei retry per 429/5xx/errori di rete, con status code e corpo della risposta inclusi nel messaggio per facilitare il debug. """ - last_error = None - - for attempt in range(max_retries + 1): - try: - response = requests.get(url, params=params, timeout=timeout) - except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: - last_error = exc - if attempt == max_retries: - raise OpenAlexRequestError( - f"Richiesta a {url} fallita dopo {max_retries + 1} tentativi: {exc}" - ) from exc - time.sleep(backoff_factor ** attempt) - continue - - if response.status_code < 400: - return response - - if response.status_code == 429 or response.status_code >= 500: - last_error = OpenAlexRequestError( - f"HTTP {response.status_code} da {url}: {response.text[:500]}" - ) - if attempt == max_retries: - raise last_error - - retry_after = response.headers.get("Retry-After") - wait_seconds = backoff_factor ** attempt - if retry_after is not None: - try: - # Tetto a MAX_RETRY_AFTER_WAIT_SECONDS: un server puo' - # legittimamente chiedere di attendere ore (visto in - # produzione con un 429 da budget esaurito e - # Retry-After: 28979), ma un'attesa cosi' lunga non e' - # utilizzabile in un contesto interattivo - vedi il - # commento su MAX_RETRY_AFTER_WAIT_SECONDS per il dettaglio. - wait_seconds = min(float(retry_after), MAX_RETRY_AFTER_WAIT_SECONDS) - except ValueError: - pass - time.sleep(wait_seconds) - continue - - # 4xx diverso da 429: errore considerato definitivo, nessun retry. - raise OpenAlexRequestError( - f"HTTP {response.status_code} da {url}: {response.text[:500]}" - ) - - # Non raggiungibile in condizioni normali (il loop ritorna o solleva ad ogni - # iterazione), presente solo per robustezza. - raise OpenAlexRequestError(f"Richiesta a {url} fallita: {last_error}") + return request_with_retry( + url, params, + max_retries=max_retries, + backoff_factor=backoff_factor, + timeout=timeout, + error_cls=OpenAlexRequestError, + ) diff --git a/www/services/pubmed_client.py b/www/services/pubmed_client.py new file mode 100644 index 000000000..e39692a40 --- /dev/null +++ b/www/services/pubmed_client.py @@ -0,0 +1,286 @@ +""" +Client HTTP per le E-utilities di PubMed/NCBI (https://eutils.ncbi.nlm.nih.gov). + +Responsabilita' di questo modulo: +- Cercare PMID che corrispondono a una query testuale (ESearch, JSON). +- Recuperare i record completi corrispondenti a una lista di PMID (EFetch, XML). +- Applicare retry con backoff esponenziale sugli errori transitori, riusando + la stessa logica gia' validata su OpenAlex (vedi http_client.py). + +A differenza di OpenAlex, PubMed non espone un endpoint singolo che restituisce +gia' i record completi per una query testuale: servono due chiamate in +sequenza, ESearch (query -> lista di ID) ed EFetch (lista di ID -> XML +completo). search_articles() incapsula questa sequenza in un'unica funzione, +cosi' da esporre a etl_pipeline.py un contratto identico a quello di +openalex_client.search_works(): una query in ingresso, una lista di record +grezzi pronti per il mapping in uscita. + +A differenza di OpenAlex, il testo dei riferimenti bibliografici (quando +presente) e' gia' incluso nel payload di EFetch (ReferenceList/Reference/ +Citation): non serve alcuna risoluzione batch equivalente a +openalex_client.get_works_by_ids, quindi questo modulo non ha una funzione +corrispondente. + +Questo modulo non fa alcun mapping verso lo schema WoS-style: restituisce +sempre elementi XML grezzi (xml.etree.ElementTree.Element, un nodo + per articolo) cosi' come li restituisce PubMed. Il mapping +verso le 34 colonne e' responsabilita' di pubmed_mapper.py; l'orchestrazione +dei due e' responsabilita' di etl_pipeline.py. +""" + +import logging +import xml.etree.ElementTree as ET + +from .utils import * +from .http_client import ExternalAPIRequestError, request_with_retry + + +logger = logging.getLogger(__name__) + + +BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" +ESEARCH_ENDPOINT = f"{BASE_URL}/esearch.fcgi" +EFETCH_ENDPOINT = f"{BASE_URL}/efetch.fcgi" + +# Limite massimo di PMID per singola chiamata EFetch via GET. Le E-utilities +# accettano batch piu' grandi via POST (fino a 10000 con usehistory), ma +# questa pipeline non ha bisogno di quel volume (stesso ordine di grandezza +# di risultati richiesto lato OpenAlex) - GET con batch moderati e' piu' +# semplice e sufficiente. +DEFAULT_BATCH_SIZE = 200 + +# Risultati per singola chiamata ESearch. NCBI consente retmax fino a 10000 +# per chiamata; qui restiamo bassi e usiamo retstart per paginare, con lo +# stesso pattern di budget di tempo gia' validato su search_works. +DEFAULT_RETMAX = 200 + +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BACKOFF_FACTOR = 1.5 +# Stessa tupla (connect, read) e stessa motivazione documentata in +# openalex_client.py::DEFAULT_TIMEOUT: un timeout singolo non copre il tempo +# totale della richiesta, solo l'inattivita' tra un chunk e il successivo. +DEFAULT_TIMEOUT = (5, 15) + +DEFAULT_FETCH_TIMEOUT_SECONDS = 30 + + +class PubMedRequestError(ExternalAPIRequestError): + """Sollevata quando una richiesta a PubMed E-utilities fallisce in modo non + recuperabile (status 4xx diverso da 429, oppure 5xx/timeout dopo + l'esaurimento dei retry). Sottoclasse di ExternalAPIRequestError, stesso + ruolo di OpenAlexRequestError per la sorgente OpenAlex: un chiamante che + vuole gestire "qualunque fonte esterna fallita" allo stesso modo puo' + intercettare la classe base invece di questa.""" + pass + + +def search_articles(query, max_results=None, email=None, api_key=None, + retmax=DEFAULT_RETMAX, fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS, + start_time=None): + """ + Esegue una ricerca testuale completa su PubMed: prima ESearch (query -> + lista di PMID, paginata con retstart finche' tutti i risultati non sono + stati raccolti o max_results e' stato raggiunto), poi EFetch a batch + (lista di PMID -> XML completo) per recuperare i record. + + Stesso limite di sicurezza di openalex_client.search_works: se + `fetch_timeout_seconds` non e' None, PRIMA di ogni nuova richiesta + (ESearch o EFetch) si controlla il tempo trascorso da `start_time`; se il + budget e' superato, la raccolta si interrompe restituendo i risultati + gia' ottenuti fino a quel momento invece di continuare indefinitamente. + + Args: + query: stringa di ricerca libera, mappata sul parametro `term` di + ESearch (supporta i tag di campo nativi di PubMed, es. + "diabetes[Title]", ma qui viene passata cosi' com'e'). + max_results: numero massimo di articoli da raccogliere + complessivamente; None per scaricare tutti i risultati della + query (attenzione: puo' comportare molte richieste HTTP su query + con molti match). + email: email da passare come parametro `email` (raccomandato da NCBI + per identificare il chiamante, analogo a `mailto` su OpenAlex). + api_key: chiave API NCBI opzionale, passata come parametro `api_key` + (alza il rate limit da 3 a 10 richieste/secondo). + retmax: risultati per chiamata ESearch (default DEFAULT_RETMAX). + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERA + raccolta (ESearch + tutte le EFetch), non per singola richiesta. + None per nessun limite. + start_time: istante di riferimento (da time.monotonic()); se None, si + usa l'istante di ingresso in questa funzione. + + Returns: + list[xml.etree.ElementTree.Element]: un nodo per + articolo trovato, nell'ordine restituito da PubMed, troncati a + max_results se specificato. + + Raises: + PubMedRequestError: se una richiesta ESearch o EFetch fallisce in + modo non recuperabile (propagata da _request_with_retry). + """ + if max_results is not None and max_results <= 0: + return [] + + if start_time is None: + start_time = time.monotonic() + + pmids = _search_pmids( + query, max_results=max_results, email=email, api_key=api_key, + retmax=retmax, fetch_timeout_seconds=fetch_timeout_seconds, start_time=start_time, + ) + if not pmids: + return [] + + return fetch_articles( + pmids, email=email, api_key=api_key, + fetch_timeout_seconds=fetch_timeout_seconds, start_time=start_time, + ) + + +def _search_pmids(query, max_results=None, email=None, api_key=None, retmax=DEFAULT_RETMAX, + fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS, start_time=None): + """ + Funzione interna: esegue ESearch paginando con retstart finche' PubMed non + ha esaurito i risultati oppure e' stato raccolto max_results PMID. + + Returns: + list[str]: PMID trovati, come stringhe, nell'ordine restituito da + PubMed. + """ + if start_time is None: + start_time = time.monotonic() + + pmids = [] + retstart = 0 + + while True: + if fetch_timeout_seconds is not None and (time.monotonic() - start_time) > fetch_timeout_seconds: + logger.warning( + "_search_pmids: budget di %.1fs esaurito, interrotta la ricerca dopo %d PMID raccolti", + fetch_timeout_seconds, len(pmids), + ) + break + + params = { + "db": "pubmed", + "term": query, + "retmode": "json", + "retmax": retmax, + "retstart": retstart, + } + if email: + params["email"] = email + if api_key: + params["api_key"] = api_key + + response = _request_with_retry(ESEARCH_ENDPOINT, params) + payload = response.json() + + result = payload.get("esearchresult", {}) + page_ids = result.get("idlist", []) + if not page_ids: + break + + pmids.extend(page_ids) + + if max_results is not None and len(pmids) >= max_results: + pmids = pmids[:max_results] + break + + total_count = int(result.get("count", 0)) + retstart += len(page_ids) + if retstart >= total_count: + break + + return pmids + + +def fetch_articles(pmids, email=None, api_key=None, batch_size=DEFAULT_BATCH_SIZE, + fetch_timeout_seconds=DEFAULT_FETCH_TIMEOUT_SECONDS, start_time=None): + """ + Recupera i record XML completi per una lista di PMID, tramite EFetch a + batch (fino a batch_size PMID per chiamata). + + Un batch che fallisce in modo persistente (dopo tutti i retry di + _request_with_retry) NON interrompe il recupero degli altri batch: + l'errore viene loggato e i PMID di quel batch restano semplicemente + assenti dalla lista restituita - stesso comportamento di + openalex_client.get_works_by_ids sui batch falliti. + + Args: + pmids: lista di PMID (stringhe o interi) da recuperare. Duplicati + vengono deduplicati preservando il primo ordine di apparizione. + email: email da passare come parametro `email`. + api_key: chiave API NCBI opzionale. + batch_size: numero massimo di PMID per chiamata EFetch. + fetch_timeout_seconds: budget di tempo (secondi) per l'INTERO + recupero, non per singolo batch. None per nessun limite. + start_time: istante di riferimento (da time.monotonic()); se None, si + usa l'istante di ingresso in questa funzione. + + Returns: + list[xml.etree.ElementTree.Element]: un nodo per + articolo recuperato con successo. I PMID non risolvibili (batch + fallito dopo i retry, oppure mai raggiunti per esaurimento del budget + di tempo) sono semplicemente assenti dal risultato. + """ + seen = set() + deduped_pmids = [] + for pmid in pmids: + pmid_str = str(pmid) + if pmid_str and pmid_str not in seen: + seen.add(pmid_str) + deduped_pmids.append(pmid_str) + + if start_time is None: + start_time = time.monotonic() + + articles = [] + for batch_start in range(0, len(deduped_pmids), batch_size): + if fetch_timeout_seconds is not None and (time.monotonic() - start_time) > fetch_timeout_seconds: + logger.warning( + "fetch_articles: budget di %.1fs esaurito, interrotto dopo %d/%d PMID recuperati", + fetch_timeout_seconds, len(articles), len(deduped_pmids), + ) + break + + batch = deduped_pmids[batch_start:batch_start + batch_size] + params = { + "db": "pubmed", + "id": ",".join(batch), + "rettype": "abstract", + "retmode": "xml", + } + if email: + params["email"] = email + if api_key: + params["api_key"] = api_key + + try: + response = _request_with_retry(EFETCH_ENDPOINT, params) + except PubMedRequestError as exc: + logger.error( + "fetch_articles: batch di %d PMID fallito dopo i retry (primi PMID: %s): %s", + len(batch), batch[:3], exc, + ) + continue + + root = ET.fromstring(response.content) + articles.extend(root.findall("PubmedArticle")) + + return articles + + +def _request_with_retry(url, params, max_retries=DEFAULT_MAX_RETRIES, backoff_factor=DEFAULT_BACKOFF_FACTOR, timeout=DEFAULT_TIMEOUT): + """ + Funzione interna: wrapper sottile su http_client.request_with_retry, con + PubMedRequestError come tipo di eccezione sollevato. Stesso pattern di + openalex_client._request_with_retry (vedi quel modulo per il perche' di + questa estrazione). + """ + return request_with_retry( + url, params, + max_retries=max_retries, + backoff_factor=backoff_factor, + timeout=timeout, + error_cls=PubMedRequestError, + ) diff --git a/www/services/pubmed_mapper.py b/www/services/pubmed_mapper.py new file mode 100644 index 000000000..a4f2d3374 --- /dev/null +++ b/www/services/pubmed_mapper.py @@ -0,0 +1,1084 @@ +""" +Mapping PubMed (E-utilities EFetch XML) -> schema a 34 colonne WoS-style. + +Speculare a openalex_mapper.py (che a sua volta e' speculare a +format_functions.py per le sorgenti storiche): qui e' definita una sola +sorgente, "PubMed", con una funzione format_XX_column per ciascuna delle 34 +colonne dello schema bibliometrix, piu' una funzione di orchestrazione +map_article_to_record che le combina in un unico record. + +Ogni funzione riceve `article`, un elemento xml.etree.ElementTree.Element + cosi' come restituito da pubmed_client.search_articles/ +fetch_articles, e naviga percorsi RELATIVI ESPLICITI (es. +"MedlineCitation/Article/ArticleTitle") invece di wildcard ".//" ovunque sia +possibile un'ambiguita' strutturale: il caso reale verificato in questa +sessione e' MedlineCitation/PMID (il PMID dell'articolo stesso) contro +MedlineCitation/CommentsCorrectionsList/CommentsCorrections/PMID (PMID di +articoli CORRELATI, es. errata corrige o commenti), entrambi presenti nello +stesso documento - un ".//PMID" generico avrebbe funzionato per puro +accidente di ordine dei nodi nei campioni osservati, ma non e' un contratto +strutturale affidabile. + +Differenze principali rispetto a OpenAlex, da tenere presenti (vedi analisi +fatta in conversazione dopo l'esplorazione empirica di piu' record PubMed +reali): +- Gli autori non sono un oggetto strutturato con display_name gia' pronto: + PubMed espone LastName/ForeName/Initials separati (o CollectiveName per + autori collettivi, es. gruppi di studio clinico), quindi AU/AF vanno + RICOSTRUITI concatenando questi campi, nello stesso stile "Cognome + Iniziali"/"Cognome NomeCompleto" gia' usato dalla pipeline storica per la + sorgente PubMed (vedi format_functions.py::format_au_column/ + format_af_column, branch `source == 'PubMed'`, che legge pero' da un + export MEDLINE .txt con campi AU/FAU gia' pre-formattati dalla stessa + convenzione - qui la ricostruiamo da zero a partire dai sotto-elementi XML). +- Le affiliazioni (AffiliationInfo/Affiliation) sono un'UNICA stringa di testo + libero (indirizzo completo, a volte con email in coda), NON strutturate in + nome-istituzione/paese come in OpenAlex (authorships[].institutions[]): + C1 qui e' quindi costruita come "[Autore] testo-affiliazione-intero" senza + alcun parsing di paese (a differenza di format_c1_column in + openalex_mapper.py, che separa institution.display_name da + institution.country_code). Di conseguenza AU_UN eredita la stessa + approssimazione: contiene la stringa di affiliazione grezza per intero + (indirizzo compreso), non un nome di istituzione isolato e ripulito - + vedi format_au_un_column per il dettaglio. +- Il testo dei riferimenti bibliografici, quando presente + (PubmedData/ReferenceList/Reference/Citation), e' GIA' una stringa + leggibile in formato citazione ("Autore1, Autore2... (Anno) Titolo. Rivista + Vol:pagine"), a differenza di OpenAlex dove referenced_works e' solo una + lista di ID da risolvere con una chiamata batch separata + (openalex_client.get_works_by_ids). Verificato empiricamente: ReferenceList + e' presente solo per una parte dei record (tipicamente quelli con + full-text collegato a PMC), assente per molti altri (es. articoli piu' + vecchi, o riviste che non forniscono la bibliografia strutturata a PubMed) - + in quel caso CR e' semplicemente una lista vuota, non un errore. +- TC (Times Cited) non ha alcun equivalente in PubMed (che non e' un indice + citazionale): restituisce sempre 0, non un dato mancante recuperabile per + altra via. +- MeshHeadingList/MeshHeading/DescriptorName (i termini MeSH assegnati da + indicizzatori NLM da un vocabolario controllato) e' concettualmente PIU' + vicino al significato originale di ID (Keywords Plus: termini assegnati da + un processo editoriale/algoritmico esterno all'autore) rispetto ai + "concepts" di OpenAlex (assegnati da un classificatore automatico + proprietario) - vedi format_id_column. +- KeywordList con attributo Owner="NOTNLM" contiene le keyword scelte + dall'autore stesso (quando l'editore le fornisce a PubMed), quindi e' un + segnale piu' fedele al significato originale di DE (Author Keywords) + rispetto ai "keywords" algoritmici di OpenAlex - vedi format_de_column. +- Journal/ISOAbbreviation e' un'abbreviazione standardizzata genuina (es. + "Lancet", "N Engl J Med"), a differenza di OpenAlex che non fornisce alcuna + abbreviazione (format_ji_column li' restituisce sempre "") - vedi + format_ji_column qui sotto per il contrasto. +- Alcune colonne non hanno, per decisione esplicita, un equivalente popolato + in questa pipeline (stesso approccio gia' preso per OpenAlex): EM, FU, FX, + OA, OI (vedi nota sotto), PU, RP, SC restituiscono sempre stringa vuota. + OI e' un'eccezione parziale: PubMed espone ORCID per autore + (Author/Identifier[@Source='ORCID']) quando presente, quindi qui e' + effettivamente implementata (a differenza di OpenAlex, dove OI e' sempre + vuota per assenza del dato) - vedi format_oi_column. +""" + +import re + +from .utils import * +from .metatagextraction import SR + + +# Numero massimo di riferimenti considerati da format_cr_column per un +# singolo articolo. Stessa motivazione di MAX_REFERENCED_WORKS in +# openalex_mapper.py: alcune review citano centinaia di riferimenti, e CR +# nello schema WoS-style e' comunque pensata come lista informativa, non +# esaustiva per definizione in molte fonti - qui non c'e' nemmeno il costo di +# una chiamata di rete aggiuntiva (il testo e' gia' incluso nell'XML), ma il +# cap resta per coerenza con la stessa decisione di design presa per +# OpenAlex e per limitare la dimensione della colonna. +MAX_REFERENCES = 100 + +# Mappa dichiarativa PublicationType PubMed -> vocabolario WoS-style per DT. +# A differenza di OPENALEX_TYPE_TO_WOS_DT (un solo `type` per work), un +# articolo PubMed puo' avere PIU' PublicationType contemporaneamente (es. +# "Clinical Trial" + "Journal Article" + "Randomized Controlled Trial"), +# quasi sempre includendo il generico "Journal Article": format_dt_column +# preferisce il primo tipo NON generico presente (vedi quella funzione per +# la logica di scelta), usando questa mappa solo per la traduzione nel +# vocabolario WoS-style. Un tipo non presente qui non e' un errore: si +# ricade sul valore originale (vedi format_dt_column). +PUBMED_TYPE_TO_WOS_DT: dict = { + "Journal Article": "Article", + "Review": "Review", + "Systematic Review": "Review", + "Meta-Analysis": "Review", + "Letter": "Letter", + "Editorial": "Editorial Material", + "Comment": "Editorial Material", + "News": "News Item", + "Biography": "Biographical-Item", + "Published Erratum": "Correction", + "Retraction of Publication": "Correction", + "Case Reports": "Article", + "Clinical Trial": "Article", + "Randomized Controlled Trial": "Article", + "Multicenter Study": "Article", + "Observational Study": "Article", + "Preprint": "Preprint", + "Book": "Book", + "Book Chapter": "Book Chapter", +} + + +def map_article_to_record(article, sep=";"): + """ + Funzione di orchestrazione: converte un singolo elemento + grezzo in un record (dict) nello schema a 34 colonne WoS-style, chiamando + in sequenza tutte le format_XX_column definite in questo modulo. + + Analoga a openalex_mapper.py::map_work_to_record. Non calcola SR (che + richiede l'intera collezione, vedi compute_sr_for_records piu' in basso). + + Args: + article: xml.etree.ElementTree.Element, singolo nodo + (vedi pubmed_client.search_articles/fetch_articles). + sep: separatore usato internamente per unire valori multipli in + colonne scalari (es. OI). Non incide sulle colonne STRING_LIST, + che restano list[str] native. + + Returns: + dict: record con esattamente le chiavi di `columns` (lista canonica + definita in www/services/utils.py) meno "SR" — 33 chiavi in totale. + + Raises: + ValueError: se il dict costruito internamente non coincide + esattamente con `set(columns) - {"SR"}` — indica una regressione + tra questa funzione e la lista canonica in utils.py. + """ + record = { + "AB": format_ab_column(article), + "AF": format_af_column(article), + "AU": format_au_column(article), + "AU_UN": format_au_un_column(article), + "AU1_UN": format_au1_un_column(article), + "BP": format_bp_column(article), + "EP": format_ep_column(article), + "CR": format_cr_column(article), + "C1": format_c1_column(article), + "DB": format_db_column(article), + "DE": format_de_column(article), + "DI": format_di_column(article), + "DT": format_dt_column(article), + "EM": format_em_column(article), + "FU": format_fu_column(article), + "FX": format_fx_column(article), + "IS": format_is_column(article), + "JI": format_ji_column(article), + "ID": format_id_column(article), + "LA": format_la_column(article), + "OA": format_oa_column(article), + "OI": format_oi_column(article, sep=sep), + "PMID": format_pmid_column(article), + "PU": format_pu_column(article), + "PY": format_py_column(article), + "RP": format_rp_column(article), + "SC": format_sc_column(article), + "SN": format_sn_column(article), + "SO": format_so_column(article), + "TC": format_tc_column(article), + "TI": format_ti_column(article), + "UT": format_ut_column(article), + "VL": format_vl_column(article), + } + + expected_keys = set(columns) - {"SR"} + actual_keys = set(record.keys()) + if actual_keys != expected_keys: + missing = sorted(expected_keys - actual_keys) + extra = sorted(actual_keys - expected_keys) + raise ValueError( + "map_article_to_record: il record prodotto non coincide con lo schema " + "canonico (www/services/utils.py::columns) meno SR. " + f"Mancanti: {missing}. Extra: {extra}." + ) + + return record + + +def _author_elements(article): + """Funzione interna: lista degli elementi dell'articolo, [] se + AuthorList e' assente.""" + return article.findall("MedlineCitation/Article/AuthorList/Author") + + +def _author_display_name(author): + """ + Funzione interna: nome "leggibile" di un autore, usato nel prefisso + "[Autore] ..." di C1 (stesso ruolo di author.display_name in + openalex_mapper.py::format_c1_column). Per un autore con CollectiveName + (es. un gruppo di studio clinico, vedi il campione PMID 8366922 + analizzato in conversazione, con Diabetes Control and + Complications Trial Research Group come primo "autore"), + restituisce direttamente quel nome; altrimenti "Cognome NomeCompleto". + + Returns: + str: nome leggibile, "" se l'autore non ha ne' CollectiveName ne' + LastName. + """ + collective = author.findtext("CollectiveName") + if collective: + return collective.strip() + + last_name = (author.findtext("LastName") or "").strip() + fore_name = (author.findtext("ForeName") or "").strip() + if not last_name: + return "" + return f"{last_name} {fore_name}".strip() + + +def format_ab_column(article): + """ + Colonna AB (Abstract). Concatena tutti gli elementi + sotto Article/Abstract. Molti abstract PubMed sono + strutturati in sezioni con attributo Label (es. "BACKGROUND", "METHODS", + "RESULTS", "CONCLUSIONS" - verificato concretamente sul PMID 9742977, + con Label="BACKGROUND"/"METHODS"/"FINDINGS"/"INTERPRETATION"): quando + Label e' presente viene anteposto al testo della sezione come + "LABEL: testo", per non perdere la struttura originale; le sezioni sono + poi unite con uno spazio. + + Args: + article: elemento . + + Returns: + str: abstract ricostruito, "" se Abstract/AbstractText sono assenti + (comune per lettere, editoriali, articoli senza abstract). + """ + sections = [] + for abstract_text in article.findall("MedlineCitation/Article/Abstract/AbstractText"): + text = "".join(abstract_text.itertext()).strip() + if not text: + continue + label = abstract_text.get("Label") + sections.append(f"{label}: {text}" if label else text) + return " ".join(sections) + + +def format_af_column(article): + """ + Colonna AF (Authors Full Name), formato "Cognome NomeCompleto" (stessa + convenzione, spazio non virgola, gia' usata dalla pipeline storica per + PubMed - vedi format_functions.py::format_af_column, branch + `source == 'PubMed'`: quella legge il campo FAU gia' pronto da un export + MEDLINE .txt, qui lo ricostruiamo da LastName+ForeName). + + Args: + article: elemento . + + Returns: + list[str]: un elemento per autore (vedi _author_display_name per la + gestione di CollectiveName), nell'ordine di AuthorList. Autori senza + ne' CollectiveName ne' LastName vengono omessi. + """ + names = [] + for author in _author_elements(article): + name = _author_display_name(author) + if name: + names.append(name) + return names + + +def format_au_column(article): + """ + Colonna AU (Author/s), formato "Cognome Iniziali" (stessa convenzione + della pipeline storica per PubMed - vedi format_functions.py:: + format_au_column, branch `source == 'PubMed'`, che legge il campo AU gia' + pronto in quel formato da un export MEDLINE .txt; qui lo ricostruiamo da + LastName+Initials). + + Usa l'elemento quando presente (gia' nel formato compatto + atteso, es. "DM"); se assente, deriva le iniziali da ForeName prendendo + la prima lettera di ogni parola (fallback, raro nei campioni osservati). + Per un autore con CollectiveName, usa il nome collettivo cosi' com'e' + (nessuna iniziale da estrarre) - stessa gestione di format_af_column. + + Args: + article: elemento . + + Returns: + list[str]: un elemento per autore, nell'ordine di AuthorList. Autori + senza ne' CollectiveName ne' LastName vengono omessi. + """ + authors = [] + for author in _author_elements(article): + collective = author.findtext("CollectiveName") + if collective: + authors.append(collective.strip()) + continue + + last_name = (author.findtext("LastName") or "").strip() + if not last_name: + continue + + initials = (author.findtext("Initials") or "").strip() + if not initials: + fore_name = author.findtext("ForeName") or "" + initials = "".join(word[0] for word in fore_name.split() if word) + + authors.append(f"{last_name} {initials}".strip()) + return authors + + +def format_au_un_column(article): + """ + Colonna AU_UN (Authors University/Institution). PubMed non fornisce un + nome di istituzione isolato e strutturato come OpenAlex + (authorships[].institutions[].display_name): AffiliationInfo/Affiliation + e' un'UNICA stringa di testo libero che include tipicamente dipartimento, + istituzione, citta', paese, ed eventualmente un'email in coda (verificato + concretamente sul PMID 42472980: "Department of Neurosurgery, + Afyonkarahisar Health Sciences University Health Application and Research + Center, Afyonkarahisar, Turkey. serhatyildizhan07@gmail.com."). + + Questa funzione restituisce quindi la stringa di affiliazione GREZZA per + intero (non un nome di istituzione ripulito da indirizzo/email) - da + segnalare come approssimazione dichiarata, non un dato equivalente in + qualita' a quello di OpenAlex. Un parsing piu' fine (es. riuso della + logica a tag di metatagextraction.py::AU_UN, che individua l'istituzione + cercando marcatori come "UNIV"/"HOSP"/"INST" in una stringa di + affiliazione WoS-style) non e' stato applicato qui per decisione + esplicita di scope: quella funzione lavora sull'intera collezione (come + SR), non sul singolo record, e non e' mai invocata da alcun consumer + a valle per la sorgente OPENALEX (verificato via grep - nessuna chiamata + a metaTagExtraction(df, "AU_UN") in functions/*.py), quindi non c'e' + garanzia che lo sarebbe per PUBMED: riprodurne la logica qui avrebbe + aggiunto complessita' per un beneficio incerto. + + Deduplicazione: se piu' autori condividono la stessa stringa di + affiliazione, questa compare una sola volta, preservando l'ordine di + prima apparizione (stessa scelta di format_au_un_column in + openalex_mapper.py). + + Args: + article: elemento . + + Returns: + list[str]: stringhe di affiliazione distinte, lista vuota se nessun + autore ha AffiliationInfo. + """ + seen = set() + affiliations = [] + for author in _author_elements(article): + for affiliation_info in author.findall("AffiliationInfo"): + text = (affiliation_info.findtext("Affiliation") or "").strip() + if text and text not in seen: + seen.add(text) + affiliations.append(text) + return affiliations + + +def format_au1_un_column(article): + """ + Colonna AU1_UN (Institution of the First Author). Come AU_UN, restituisce + la stringa di affiliazione grezza (non un nome di istituzione isolato) - + vedi format_au_un_column per la motivazione completa + dell'approssimazione. A differenza di AU_UN, restituisce una singola + stringa (non una lista), stessa convenzione di format_au1_un_column in + openalex_mapper.py. + + Il primo autore e' semplicemente il primo elemento di + AuthorList (PubMed non ha un flag equivalente ad `author_position` di + OpenAlex: l'ordine nella lista E' l'ordine di autorship). Se ha piu' + AffiliationInfo, viene presa solo la prima. + + Args: + article: elemento . + + Returns: + str: stringa di affiliazione del primo autore, "" se non disponibile + (nessun autore, o primo autore senza AffiliationInfo). + """ + authors = _author_elements(article) + if not authors: + return "" + + affiliation_info = authors[0].find("AffiliationInfo") + if affiliation_info is None: + return "" + + return (affiliation_info.findtext("Affiliation") or "").strip() + + +def format_bp_column(article): + """ + Colonna BP (Beginning Page). Deriva da + Article/Pagination/StartPage. + + Args: + article: elemento . + + Returns: + str: numero di pagina iniziale, "" se assente. + """ + return (article.findtext("MedlineCitation/Article/Pagination/StartPage") or "").strip() + + +def format_ep_column(article): + """ + Colonna EP (Ending Page). Deriva da Article/Pagination/EndPage. + + NOTA: PubMed spesso abbrevia EndPage nella sola parte che differisce da + StartPage (es. StartPage="854", EndPage="65" per l'intervallo "854-65", + visto concretamente sul PMID 9742977 - MedlinePgn contiene la forma + leggibile completa "854-65", ma StartPage/EndPage restano i due valori + grezzi separati cosi' come pubblicati da PubMed). Questa funzione fa + pass-through diretto di EndPage senza ricostruire il numero di pagina + completo: stesso comportamento (nessuna normalizzazione) di + format_ep_column in openalex_mapper.py, che fa pass-through di + biblio.last_page senza ipotesi sul formato. + + Args: + article: elemento . + + Returns: + str: valore grezzo di EndPage, "" se assente. + """ + return (article.findtext("MedlineCitation/Article/Pagination/EndPage") or "").strip() + + +def format_cr_column(article): + """ + Colonna CR (Cited References). Deriva da + PubmedData/ReferenceList/Reference/Citation, gia' testo di citazione + leggibile (a differenza di OpenAlex, dove referenced_works e' solo una + lista di ID da risolvere - vedi il modulo docstring). + + ReferenceList e' presente solo per una parte dei record (verificato + empiricamente: tipicamente articoli con full-text collegato a PMC); + quando assente, restituisce lista vuota - non e' un errore ne' un dato + mancante da recuperare altrove, e' una caratteristica nota della + copertura bibliografica di PubMed (a differenza di OpenAlex, dove + referenced_works e' quasi sempre presente quando risolvibile). + + Args: + article: elemento . + + Returns: + list[str]: una voce per riferimento (testo di Citation), troncata a + MAX_REFERENCES elementi. Riferimenti senza Citation vengono omessi. + """ + citations = [] + for reference in article.findall("PubmedData/ReferenceList/Reference"): + citation = (reference.findtext("Citation") or "").strip() + if citation: + citations.append(citation) + if len(citations) >= MAX_REFERENCES: + break + return citations + + +def format_c1_column(article): + """ + Colonna C1 (Authors Affiliation). Costruita nel formato WoS-style + "[NomeAutore] testo-affiliazione-intero", senza alcun parsing di paese + (a differenza di format_c1_column in openalex_mapper.py, che separa + institution.display_name da institution.country_code): PubMed espone + l'affiliazione come un'unica stringa di testo libero, non come dato + strutturato - vedi il modulo docstring per l'analisi completa di questa + differenza. + + Conseguenza pratica per metatagextraction.py::AU_CO/AU1_CO (che deriva il + paese da C1 cercando `c1.split(",")[-1].strip().upper()` nella whitelist + www/static/countries.txt): funziona comunque "gratis" sulle righe C1 + prodotte da questa funzione quando l'affiliazione grezza termina + genuinamente con il nome del paese (caso comune, verificato sui campioni + analizzati: "..., Afyonkarahisar, Turkey." dopo lo strip del punto finale + fatto da quella funzione) - non serve una lookup dedicata come + ISO_COUNTRY_CODE_TO_NAME in openalex_mapper.py perche' il paese, quando + presente, e' gia' testo libero nella stessa lingua/convenzione che + quell'euristica si aspetta. + + Args: + article: elemento . + + Returns: + list[str]: una stringa "[NomeAutore] Affiliazione" per ogni + combinazione (autore, AffiliationInfo) presente. Autori senza + AffiliationInfo non producono alcuna riga. Lista vuota se nessun + autore ha affiliazioni. + """ + affiliations = [] + for author in _author_elements(article): + author_name = _author_display_name(author) + if not author_name: + continue + + for affiliation_info in author.findall("AffiliationInfo"): + text = (affiliation_info.findtext("Affiliation") or "").strip() + if text: + affiliations.append(f"[{author_name}] {text}") + + return affiliations + + +def format_db_column(article): + """ + Colonna DB (Database). Costante: tutti i record prodotti da questo + mapper provengono dalla sorgente PubMed. + + Args: + article: elemento (non usato). + + Returns: + str: sempre "PUBMED". + """ + return "PUBMED" + + +def format_de_column(article): + """ + Colonna DE (Author Keywords). Deriva da KeywordList/Keyword, filtrando + per l'attributo Owner="NOTNLM" del KeywordList genitore quando presente: + quel valore identifica le keyword fornite dall'editore/autore (non dal + processo di indicizzazione NLM), quindi e' il segnale piu' fedele al + significato originale di DE (Author Keywords) - vedi il modulo docstring. + + Se un KeywordList non ha l'attributo Owner (raro nei campioni osservati, + ma non escluso dallo schema), le sue keyword vengono comunque incluse: + l'assenza dell'attributo non e' un segnale che la lista sia di tipo + diverso da NOTNLM, solo che il dato non e' dichiarato esplicitamente. + + Args: + article: elemento . + + Returns: + list[str]: keyword estratte, lista vuota se KeywordList e' assente o + vuoto. Duplicati preservati cosi' come restituiti da PubMed (nessuna + deduplicazione, a differenza di AU_UN/C1: qui l'ordine e le eventuali + ripetizioni riflettono direttamente cio' che l'editore ha dichiarato). + """ + keywords = [] + for keyword_list in article.findall("MedlineCitation/KeywordList"): + owner = keyword_list.get("Owner") + if owner is not None and owner != "NOTNLM": + continue + for keyword in keyword_list.findall("Keyword"): + text = "".join(keyword.itertext()).strip() + if text: + keywords.append(text) + return keywords + + +def format_di_column(article): + """ + Colonna DI (DOI). Deriva da PubmedData/ArticleIdList/ArticleId con + IdType="doi" (posizione piu' affidabile: presente per la quasi totalita' + degli articoli moderni), con fallback su + Article/ELocationID con EIdType="doi" se il primo e' assente (verificato + che entrambi possono comparire per lo stesso articolo con lo stesso + valore, es. PMID 42472980: ELocationID doi="10.1007/s10143-026-04405-8" - + ArticleIdList e' preferita come fonte primaria perche' e' la posizione + "canonica" per gli identificatori dell'articolo nello schema PubMed). + + Args: + article: elemento . + + Returns: + str: DOI nudo, "" se non trovato in nessuna delle due posizioni. + """ + for article_id in article.findall("PubmedData/ArticleIdList/ArticleId"): + if article_id.get("IdType") == "doi" and article_id.text: + return article_id.text.strip() + + for elocation_id in article.findall("MedlineCitation/Article/ELocationID"): + if elocation_id.get("EIdType") == "doi" and elocation_id.text: + return elocation_id.text.strip() + + return "" + + +def format_dt_column(article): + """ + Colonna DT (Document Type). Deriva da + Article/PublicationTypeList/PublicationType, che a differenza di + work["type"] in OpenAlex (un solo valore) puo' contenere PIU' tipi + contemporaneamente, quasi sempre includendo il generico "Journal + Article" (verificato su tutti i campioni analizzati). + + Sceglie il primo tipo presente in PUBMED_TYPE_TO_WOS_DT diverso da + "Journal Article" (piu' informativo, es. "Review"/"Clinical + Trial"/"Letter"), tradotto nel vocabolario WoS-style; se nessun tipo + "informativo" e' presente, ricade su "Journal Article" se presente + nella lista, altrimenti sul primo tipo grezzo cosi' com'e'. + + DEBUGGING LOG: la prima versione di questa funzione sceglieva + semplicemente "il primo tipo diverso da Journal Article", SENZA + controllare se fosse presente in PUBMED_TYPE_TO_WOS_DT. Riprodotto + concretamente un caso in cui questo sceglie il tipo sbagliato: PMID + 10022014 ha PublicationTypeList = ["Journal Article", "Research Support, + Non-U.S. Gov't", "Research Support, U.S. Gov't, Non-P.H.S."] - la + versione precedente restituiva "Research Support, Non-U.S. Gov't" come + DT, un'etichetta amministrativa sulla fonte di finanziamento, non un + tipo di documento nel senso WoS del termine (l'articolo e' un normale + "Article"). Corretto qui filtrando sui soli tipi che questo modulo sa + interpretare come document type genuini (le chiavi di + PUBMED_TYPE_TO_WOS_DT); un tipo non presente li' non viene piu' + considerato "informativo" solo perche' diverso da "Journal Article". + + Args: + article: elemento . + + Returns: + str: valore DT mappato, "" se PublicationTypeList e' assente/vuoto. + """ + types = [ + (pt.text or "").strip() + for pt in article.findall("MedlineCitation/Article/PublicationTypeList/PublicationType") + if (pt.text or "").strip() + ] + if not types: + return "" + + informative = next( + (t for t in types if t != "Journal Article" and t in PUBMED_TYPE_TO_WOS_DT), + None, + ) + if informative: + return PUBMED_TYPE_TO_WOS_DT[informative] + + if "Journal Article" in types: + return PUBMED_TYPE_TO_WOS_DT["Journal Article"] + + return PUBMED_TYPE_TO_WOS_DT.get(types[0], types[0]) + + +def format_em_column(article): + """ + Colonna EM (Email). Per decisione esplicita, restituisce sempre stringa + vuota: pur essendo un'email talvolta presente in coda al testo libero di + AffiliationInfo/Affiliation (es. PMID 42472980, "...Turkey. + serhatyildizhan07@gmail.com."), estrarla in modo affidabile + richiederebbe un parsing euristico del testo libero (non un campo + strutturato dedicato), stessa scelta di scope gia' fatta per + format_em_column in openalex_mapper.py. + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_fu_column(article): + """ + Colonna FU (Funding Details). Campo non popolato per questa pipeline: + restituisce sempre stringa vuota. PubMed espone un GrantList in alcuni + record, ma verificato empiricamente in questa sessione che e' ASSENTE + nella maggioranza dei campioni analizzati (diversamente da MeshHeadingList + o AuthorList, quasi sempre presenti): implementarlo avrebbe prodotto una + colonna popolata solo sporadicamente, con beneficio incerto. + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_fx_column(article): + """ + Colonna FX (Funding Text). Campo non disponibile da PubMed (nessun + equivalente testuale libero al "Funding Text" di WoS): restituisce + sempre stringa vuota per decisione esplicita. + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_is_column(article): + """ + Colonna IS (Issue). Deriva da Journal/JournalIssue/Issue. + + Args: + article: elemento . + + Returns: + str: numero di fascicolo, "" se assente. + """ + return (article.findtext("MedlineCitation/Article/Journal/JournalIssue/Issue") or "").strip() + + +def format_ji_column(article): + """ + Colonna JI (Abbreviated Journal Name). Deriva da + Journal/ISOAbbreviation, un'abbreviazione standardizzata GENUINA + (es. "Lancet", "N Engl J Med", verificato sui campioni analizzati) - a + differenza di OpenAlex, che non fornisce alcuna abbreviazione e per cui + format_ji_column restituisce sempre "" (vedi quella funzione per il ruolo + di JI in metatagextraction.py::SR quando vuota). + + Args: + article: elemento . + + Returns: + str: abbreviazione della rivista, "" se ISOAbbreviation e' assente + (raro, ma non escluso dallo schema PubMed). + """ + return (article.findtext("MedlineCitation/Article/Journal/ISOAbbreviation") or "").strip() + + +def format_id_column(article): + """ + Colonna ID (Index/Keywords Plus). Deriva da + MeshHeadingList/MeshHeading/DescriptorName: i termini MeSH (Medical + Subject Headings) sono assegnati da indicizzatori NLM (umani o, per + IndexingMethod="Automated" come sul PMID 42472980, da un processo + automatico NLM) da un vocabolario controllato esterno all'autore - vedi + il modulo docstring per il confronto con l'equivalente OpenAlex + (concepts, assegnati da un classificatore proprietario diverso). + + A differenza di format_de_column, non filtra per alcun attributo: ogni + DescriptorName viene incluso, indipendentemente da MajorTopicYN (che + segnala solo se il termine e' un argomento "principale" dell'articolo, + non se va incluso o escluso). + + Args: + article: elemento . + + Returns: + list[str]: termini MeSH estratti, lista vuota se MeshHeadingList e' + assente/vuoto (comune per articoli non ancora indicizzati da NLM, + es. preprint o pubblicazioni molto recenti). + """ + terms = [] + for descriptor in article.findall("MedlineCitation/MeshHeadingList/MeshHeading/DescriptorName"): + text = "".join(descriptor.itertext()).strip() + if text: + terms.append(text) + return terms + + +def format_la_column(article): + """ + Colonna LA (Language). Pass-through diretto di Article/Language (codice + a 3 lettere, es. "eng", secondo la convenzione MEDLINE/ISO 639-2) - nota + granularita' diversa da OpenAlex (codice ISO 639-1 a 2 lettere, es. "en"): + stesso tipo di scostamento di formato gia' segnalato in + format_la_column di openalex_mapper.py rispetto al nome esteso usato da + WoS (es. "English"). + + Args: + article: elemento . + + Returns: + str: codice lingua a 3 lettere, "" se Language e' assente. + """ + return (article.findtext("MedlineCitation/Article/Language") or "").strip() + + +def format_oa_column(article): + """ + Colonna OA (Open Access). Campo non disponibile da PubMed per questa + pipeline: restituisce sempre stringa vuota per decisione esplicita. + L'EFetch di PubMed non espone alcun flag di stato Open Access; un dato + equivalente esisterebbe solo incrociando fonti esterne (es. Unpaywall, o + la presenza di un ArticleId con IdType="pmc" come proxy indiretto di + "testo integrale disponibile su PMC", che pero' non è sinonimo di Open + Access in senso stretto) - fuori scope per questa pipeline. + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_oi_column(article, sep=";"): + """ + Colonna OI (ORCID). Deriva da + AuthorList/Author/Identifier con attributo Source="ORCID", quando + presente (verificato sui campioni analizzati: non tutti gli autori hanno + un ORCID dichiarato, anche nello stesso AuthorList - es. PMID 42472980, + dove tutti e 4 gli autori campionati hanno un ORCID, mentre altri + campioni con autori piu' datati non ne hanno alcuno). + + A differenza di OpenAlex (dove OI e' sempre "" per assenza del dato - + vedi openalex_mapper.py::format_oi_column), qui il dato e' effettivamente + disponibile e viene riportato. Restituisce una stringa (non una lista, + coerente con type_contracts.py::COLUMN_SPECS, dove OI e' STRING non + STRING_LIST): piu' ORCID (uno per autore che li dichiara) vengono uniti + con `sep`, stesso pattern gia' usato altrove nella codebase per colonne + scalari multi-valore (es. metatagextraction.py::AU_UN con il parametro + `sep`). + + Args: + article: elemento . + sep: separatore usato per unire piu' ORCID in un'unica stringa. + + Returns: + str: ORCID uniti da `sep`, "" se nessun autore ne dichiara uno. + """ + orcids = [] + for author in _author_elements(article): + for identifier in author.findall("Identifier"): + if identifier.get("Source") == "ORCID" and identifier.text: + orcids.append(identifier.text.strip()) + return sep.join(orcids) + + +def format_pmid_column(article): + """ + Colonna PMID (PubMed ID). Deriva da MedlineCitation/PMID (percorso + esplicito, NON un ".//PMID" generico - vedi il modulo docstring per il + perche': lo stesso documento puo' contenere altri elementi per + articoli correlati dentro CommentsCorrectionsList). + + Args: + article: elemento . + + Returns: + str: PMID nudo, "" se assente (non dovrebbe verificarsi in pratica: + ogni restituito da EFetch ha un PMID). + """ + return (article.findtext("MedlineCitation/PMID") or "").strip() + + +def format_pu_column(article): + """ + Colonna PU (Publisher). Campo non disponibile da PubMed per questa + pipeline: restituisce sempre stringa vuota per decisione esplicita. + MedlineJournalInfo espone il paese di pubblicazione (Country) ma non il + nome dell'editore. + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_py_column(article): + """ + Colonna PY (Publication Year). Deriva da + Journal/JournalIssue/PubDate/Year quando presente. PubMed a volte + riporta la data come testo libero non strutturato in MedlineDate invece + che nei campi Year/Month/Day separati (es. intervalli di pubblicazione + come "1998 Sep-Oct" o date stagionali "Winter 1999"): in quel caso, + estrae il primo gruppo di 4 cifre consecutive dal testo di MedlineDate + come fallback, invece di restituire 0 e perdere un dato comunque presente + ma in formato diverso. + + Stessa scelta di tipo di format_py_column in openalex_mapper.py (vedi + quella funzione per il bug PY str-vs-int documentato e risolto in questa + sessione, non specifico di OpenAlex: la stessa classificazione INTEGER + in type_contracts.py::COLUMN_SPECS vale per tutte le sorgenti, PubMed + inclusa). + + Args: + article: elemento . + + Returns: + int: anno di pubblicazione, 0 se ne' Year ne' un pattern di 4 cifre + in MedlineDate sono disponibili. + """ + year_text = article.findtext("MedlineCitation/Article/Journal/JournalIssue/PubDate/Year") + if year_text: + try: + return int(year_text.strip()) + except ValueError: + pass + + medline_date = article.findtext("MedlineCitation/Article/Journal/JournalIssue/PubDate/MedlineDate") + if medline_date: + match = re.search(r"\d{4}", medline_date) + if match: + return int(match.group()) + + return 0 + + +def format_rp_column(article): + """ + Colonna RP (Correspondence Address/Reprint Author). Per decisione + esplicita, restituisce sempre stringa vuota: PubMed non espone un + indirizzo di corrispondenza strutturato/affidabile equivalente a RP in + WoS (stessa scelta di scope di format_rp_column in openalex_mapper.py). + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_sc_column(article): + """ + Colonna SC (Subject Category / Fields of Research). Campo non disponibile + da PubMed per questa pipeline: restituisce sempre stringa vuota per + decisione esplicita (i termini MeSH in MeshHeadingList potrebbero in + parte sovrapporsi concettualmente, ma sono gia' rappresentati in ID - + vedi format_id_column - e non hanno una gerarchia "categoria disciplinare" + diretta e affidabile senza logica aggiuntiva di mappatura). + + Args: + article: elemento (non usato). + + Returns: + str: sempre "". + """ + return "" + + +def format_sn_column(article): + """ + Colonna SN (ISSN). Deriva da Journal/ISSN (indipendentemente da + IssnType="Print"/"Electronic": un singolo Journal ha al massimo un + elemento ISSN in ciascun record EFetch, verificato sui campioni + analizzati). + + Args: + article: elemento . + + Returns: + str: ISSN, "" se assente (puo' accadere per riviste non ancora + registrate con ISSN, raro). + """ + return (article.findtext("MedlineCitation/Article/Journal/ISSN") or "").strip() + + +def format_so_column(article): + """ + Colonna SO (Journal/Source). Deriva da Journal/Title. + + Args: + article: elemento . + + Returns: + str: nome della rivista, "" se Title e' assente. + """ + return (article.findtext("MedlineCitation/Article/Journal/Title") or "").strip() + + +def format_tc_column(article): + """ + Colonna TC (Times Cited). PubMed non e' un indice citazionale: non + esiste alcun conteggio di citazioni associato a un record EFetch (a + differenza di OpenAlex, dove cited_by_count e' un campo nativo del + work). Restituisce sempre 0, non un dato mancante recuperabile da + un'altra posizione dello stesso XML - vedi il modulo docstring. + + Args: + article: elemento (non usato). + + Returns: + int: sempre 0. + """ + return 0 + + +def format_ti_column(article): + """ + Colonna TI (Title). Deriva da Article/ArticleTitle. + + Args: + article: elemento . + + Returns: + str: titolo dell'articolo, "" se ArticleTitle e' assente. + """ + return "".join( + (article.find("MedlineCitation/Article/ArticleTitle").itertext() + if article.find("MedlineCitation/Article/ArticleTitle") is not None else []) + ).strip() + + +def format_ut_column(article): + """ + Colonna UT (Publication ID / accession number). Per PubMed coincide con + PMID (stessa scelta gia' fatta dalla pipeline storica - vedi + format_functions.py::format_ut_column, branch `source == 'PubMed'`: + `publication_id = entry.get('PMID', '')`): a differenza di OpenAlex, dove + UT e PMID sono due identificatori DISTINTI (work["id"] vs + work["ids"]["pmid"], quest'ultimo spesso assente), PubMed non ha un + accession number separato dal PMID stesso. + + Args: + article: elemento . + + Returns: + str: identico all'output di format_pmid_column(article). + """ + return format_pmid_column(article) + + +def format_vl_column(article): + """ + Colonna VL (Volume). Deriva da Journal/JournalIssue/Volume. + + Args: + article: elemento . + + Returns: + str: numero di volume, "" se assente. + """ + return (article.findtext("MedlineCitation/Article/Journal/JournalIssue/Volume") or "").strip() + + +def build_sr_bridge_frame(records): + """ + Costruisce il DataFrame "ponte" da passare, senza alcuna trasformazione, + a metatagextraction.py::SR(M). Identica a + openalex_mapper.py::build_sr_bridge_frame (vedi quella funzione per la + motivazione: le chiavi AU/DB/JI/SO/PY prodotte da questo modulo si + chiamano gia' come le colonne che SR(M) si aspetta). + + Args: + records: list[dict], record gia' prodotti da map_article_to_record. + + Returns: + pandas.DataFrame costruito direttamente da records, una riga per + record. + """ + return pd.DataFrame(records) + + +def compute_sr_for_records(records): + """ + Calcola SR (e SR_FULL) per un'intera collezione di record gia' mappati, + riusando SENZA MODIFICHE metatagextraction.py::SR(M) - identica a + openalex_mapper.py::compute_sr_for_records (vedi quella funzione per il + perche' SR non ha un equivalente format_sr_column a livello di singolo + record). + + Args: + records: list[dict], record gia' mappati con almeno le chiavi AU, + DB, JI, SO, PY. + + Returns: + list[dict]: nuovi dict (i record in input non vengono mutati), + ciascuno arricchito con le chiavi "SR" e "SR_FULL". Lista vuota se + `records` e' vuota. + """ + if not records: + return [] + + bridge = build_sr_bridge_frame(records) + bridge = SR(bridge) + + enriched = [] + for original, sr_value, sr_full_value in zip(records, bridge["SR"], bridge["SR_FULL"]): + record = dict(original) + record["SR"] = sr_value + record["SR_FULL"] = sr_full_value + enriched.append(record) + return enriched From f75bff3d6afe99b086a12769c6266930e5cfdecd Mon Sep 17 00:00:00 2001 From: Genny Date: Tue, 21 Jul 2026 07:53:45 +0200 Subject: [PATCH 9/9] Extend API page to support PubMed as second query source - Add source selector (OpenAlex/PubMed) to the API sidebar - Show optional NCBI email field conditionally when PubMed is selected - Route run_api_query() to run_pubmed_etl() or run_openalex_etl() based on selection - Extend error handler to cover PubMedRequestError alongside OpenAlexRequestError - Document OA="" as deliberate final decision for PubMed (same rationale as TC=0) Co-Authored-By: Claude Sonnet 4.6 --- app.py | 47 ++++++++++++++++++++++++----------- www/services/pubmed_mapper.py | 19 ++++++++------ 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/app.py b/app.py index 479f10ceb..9c5ceb080 100644 --- a/app.py +++ b/app.py @@ -854,24 +854,35 @@ def indicator_types_ui_all(): ), with ui.nav_panel("None", value="API"): - ui.h3("🔌 OpenAlex API", style="color: #5567BB;") - ui.p("Search OpenAlex directly and import the results as a bibliometrix-style dataset.") + ui.h3("🔌 Live API Query", style="color: #5567BB;") + ui.p("Search OpenAlex or PubMed directly and import the results as a bibliometrix-style dataset.") with ui.layout_sidebar(fillable=False, fill=False): with ui.sidebar(id="sidebar_api", position="right"): - ui.h5("OpenAlex Query", style="color: #5567BB;") + ui.h5("Query Parameters", style="color: #5567BB;") + ui.input_select("api_source", "Source", choices={"openalex": "OpenAlex", "pubmed": "PubMed"}, selected="openalex") ui.input_text("api_query", "Search query", placeholder="es. machine learning") + + @render.ui + def api_email_ui(): + if input.api_source() == "pubmed": + return ui.input_text("api_email", "Email (NCBI)", placeholder="optional, e.g. user@example.com") + return ui.div() + ui.input_numeric("api_max_results", "Max results", value=50, min=1, max=200) ui.input_action_button("start_api_button", "Start", icon=ICONS["play"]) @reactive.effect @reactive.event(input.start_api_button) def run_api_query(): + source = input.api_source() + source_name = "PubMed" if source == "pubmed" else "OpenAlex" + # Show loading modal while querying (same style as Historiograph) def loading_modal(): phrases = [ "⏳ Loading... Please wait.", - "🔎 Querying OpenAlex...", + f"🔎 Querying {source_name}...", "📥 Downloading records...", "🧬 Standardizing metadata...", "📊 Preparing your dataset...", @@ -919,23 +930,31 @@ def loading_modal(): ui.notification_show("⚠️ Please enter a search query.", type="warning", duration=5) return - # info@bibliometrix.org: indirizzo di progetto gia' usato - # altrove in app.py (sezione About) per la polite pool OpenAlex - result_df, validation_errors = run_openalex_etl( - query, - mailto="info@bibliometrix.org", - max_results=max_results, - ) + if source == "pubmed": + email = (input.api_email() or "").strip() or None + result_df, validation_errors = run_pubmed_etl( + query, + email=email, + max_results=max_results, + ) + else: + # info@bibliometrix.org: indirizzo di progetto gia' usato + # altrove in app.py (sezione About) per la polite pool OpenAlex + result_df, validation_errors = run_openalex_etl( + query, + mailto="info@bibliometrix.org", + max_results=max_results, + ) df.set(result_df) reset_all_analyses() ui.notification_show( - f"✅ OpenAlex query completed! The dataset contains {result_df.shape[0]} rows and {result_df.shape[1]} columns.", + f"✅ {source_name} query completed! The dataset contains {result_df.shape[0]} rows and {result_df.shape[1]} columns.", duration=5, close_button=False, ) - except (ETLPipelineError, OpenAlexRequestError) as e: - ui.notification_show(f"❌ Error querying OpenAlex: {str(e)}", type="error", duration=10) + except (ETLPipelineError, OpenAlexRequestError, PubMedRequestError) as e: + ui.notification_show(f"❌ Error querying {source_name}: {str(e)}", type="error", duration=10) except Exception as e: ui.notification_show(f"❌ Unexpected error: {str(e)}", type="error", duration=10) finally: diff --git a/www/services/pubmed_mapper.py b/www/services/pubmed_mapper.py index a4f2d3374..46e8908c5 100644 --- a/www/services/pubmed_mapper.py +++ b/www/services/pubmed_mapper.py @@ -782,13 +782,18 @@ def format_la_column(article): def format_oa_column(article): """ - Colonna OA (Open Access). Campo non disponibile da PubMed per questa - pipeline: restituisce sempre stringa vuota per decisione esplicita. - L'EFetch di PubMed non espone alcun flag di stato Open Access; un dato - equivalente esisterebbe solo incrociando fonti esterne (es. Unpaywall, o - la presenza di un ArticleId con IdType="pmc" come proxy indiretto di - "testo integrale disponibile su PMC", che pero' non è sinonimo di Open - Access in senso stretto) - fuori scope per questa pipeline. + Colonna OA (Open Access). Restituisce sempre stringa vuota: stesso + trattamento di TC (vedi format_tc_column), per lo stesso motivo + concettuale — PubMed non e' una fonte di dati OA. + + L'EFetch non espone alcun flag di stato Open Access equivalente al + vocabolario controllato di OpenAlex (oa_status: "gold"/"green"/"hybrid"/ + "bronze"/"closed", derivato da Unpaywall). L'unico segnale presente + nell'XML e' ArticleId[@IdType="pmc"] (presenza del full-text su PubMed + Central), che e' un proxy approssimativo — "PMC disponibile" non coincide + con "Open Access" in senso Unpaywall/DOAJ: ci sono articoli OA non in PMC + e articoli in PMC non genuinamente OA. Questa approssimazione e' stata + valutata e rifiutata: preferibile "" coerente a un dato fuorviante. Args: article: elemento (non usato).