Skip to content
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
__pycache__/
bibliovenv/
Bibenv/
.idea/
.venv/
.idea/.DS_Store
186 changes: 182 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -854,8 +854,126 @@ def indicator_types_ui_all():
),

with ui.nav_panel("None", value="API"):
ui.h3("🚧 Warning: API is under construction 🚧")

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("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.",
f"🔎 Querying {source_name}...",
"📥 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"""
<script>
(function() {{
var phrases = {phrases};
var idx = 0;
var el = document.getElementById('loading-phrase');
if (el) {{
setInterval(function() {{
idx = (idx + 1) % phrases.length;
el.textContent = phrases[idx];
}}, 1000);
}}
}})();
</script>
"""
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

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"✅ {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, 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:
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 🚧")

Expand Down Expand Up @@ -1329,7 +1447,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(
Expand Down Expand Up @@ -8185,7 +8357,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):
Expand All @@ -8206,6 +8378,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"]):
Expand Down Expand Up @@ -8422,6 +8595,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 _():
Expand Down
1 change: 1 addition & 0 deletions functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
21 changes: 17 additions & 4 deletions functions/get_clusteringcoupling.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
55 changes: 54 additions & 1 deletion functions/get_data.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 34 additions & 0 deletions functions/get_historiograph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading