Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
167 changes: 163 additions & 4 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<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

# 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 🚧")

Expand Down Expand Up @@ -1329,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(
Expand Down Expand Up @@ -8185,7 +8338,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 +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"]):
Expand Down Expand Up @@ -8422,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 _():
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