From 62d4718b6f14771ba224c083262648461666437b Mon Sep 17 00:00:00 2001 From: Bamdad Mehrvarzan Date: Wed, 27 May 2026 15:40:39 +0200 Subject: [PATCH] Fixed issues and Implementations --- app.py | 235 ++++++++++++++++++++++-------- requirements.txt | Bin 4626 -> 4654 bytes standardized_openalex_output.xlsx | Bin 0 -> 70707 bytes terminal_log.txt | 22 +++ test_etl.py | 105 +++++++++++++ www/services/etl/__init__.py | 1 + www/services/etl/extractor.py | 84 +++++++++++ www/services/etl/interfaces.py | 44 ++++++ www/services/etl/pipeline.py | 62 ++++++++ www/services/etl/transformer.py | 147 +++++++++++++++++++ www/services/etl/validator.py | 89 +++++++++++ www/services/histnetwork.py | 2 +- 12 files changed, 726 insertions(+), 65 deletions(-) create mode 100644 standardized_openalex_output.xlsx create mode 100644 terminal_log.txt create mode 100644 test_etl.py create mode 100644 www/services/etl/__init__.py create mode 100644 www/services/etl/extractor.py create mode 100644 www/services/etl/interfaces.py create mode 100644 www/services/etl/pipeline.py create mode 100644 www/services/etl/transformer.py create mode 100644 www/services/etl/validator.py diff --git a/app.py b/app.py index f0891f8..ccb515f 100644 --- a/app.py +++ b/app.py @@ -1174,7 +1174,7 @@ def table_informations(): data['Average_Citations_per_Doc'][0] ] }) - return ui.HTML(DT(df_box, style="width=100%;")) + return ui.HTML(DT(df_box, style="width:100%;")) # --- Annual Scientific Production Section --- with ui.nav_panel("None", value="annual_scientific_production"): @@ -1228,7 +1228,7 @@ def show_annual_production(): @render.ui def table_annual_production(): _, publications_per_year = annual_informations() - return ui.HTML(DT(publications_per_year, style="width=100%;")) + return ui.HTML(DT(publications_per_year, style="width:100%;")) # AI bot Gemini Chat Integration # --- Floating Chat Button --- @@ -1382,7 +1382,7 @@ def show_average_citations(): @render.ui def table_average_citations(): _, avg_citations = average_citations() - return ui.HTML(DT(avg_citations, style="width=100%;")) + return ui.HTML(DT(avg_citations, style="width:100%;")) # --- Three-Field Plot Section --- with ui.nav_panel("None", value="three_field_plot"): @@ -1636,7 +1636,7 @@ def table_relevant_sources(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, relevant_sources_tab = result - return ui.HTML(DT(relevant_sources_tab, style="width=100%;")) + return ui.HTML(DT(relevant_sources_tab, style="width:100%;")) # --- Most Local Cited Sources Section --- with ui.nav_panel("None", value="most_local_cited_sources"): @@ -1743,10 +1743,21 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) + try: num_of_cited_sources = input.num_of_cited_sources() result = get_local_cited_sources(df, num_of_cited_sources) local_cited_sources_results.set(result) + except Exception as e: + print(f"[Local Cited Sources Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ No local cited sources found in this 50-document sample slice.", + type="warning", + duration=5 + ) + + local_cited_sources_results.set((None, pd.DataFrame())) finally: ui.modal_remove() @@ -1780,7 +1791,7 @@ def table_local_cited_sources(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, local_cited_sources_tab = result - return ui.HTML(DT(local_cited_sources_tab, style="width=100%;")) + return ui.HTML(DT(local_cited_sources_tab, style="width:100%;")) # --- Bradford's Law Section --- with ui.nav_panel("None", value="bradfords_law"): @@ -1834,7 +1845,7 @@ def show_bradford_law(): @render.ui def table_bradford_law(): _, bradford_law_tab = bradford_law() - return ui.HTML(DT(bradford_law_tab, style="width=100%;")) + return ui.HTML(DT(bradford_law_tab, style="width:100%;")) # --- Sources' Local Impact Section --- with ui.nav_panel("None", value="sources_local_impact"): @@ -1980,7 +1991,7 @@ def table_sources_local_impact(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, sources_local_impact_tab = result - return ui.HTML(DT(sources_local_impact_tab, style="width=100%;")) + return ui.HTML(DT(sources_local_impact_tab, style="width:100%;")) # --- Sources' Production --- with ui.nav_panel("None", value="sources_production"): @@ -2126,7 +2137,7 @@ def table_sources_production(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, sources_production_tab = result - return ui.HTML(DT(sources_production_tab, style="width=100%;")) + return ui.HTML(DT(sources_production_tab, style="width:100%;")) # --- Most Relevant Authors Section --- with ui.nav_panel("None", value="most_relevant_authors"): @@ -2224,11 +2235,23 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) + try: num_of_authors = input.num_of_authors() frequency = input.frequency() result = get_relevant_authors(df, num_of_authors, frequency) relevant_authors_result.set(result) + except Exception as e: + + print(f"[Relevant Authors Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ No relevant authors found matching the criteria in this sample.", + type="warning", + duration=5 + ) + + relevant_authors_result.set((None, pd.DataFrame())) finally: ui.modal_remove() @@ -2273,7 +2296,7 @@ def table_relevant_authors(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, relevant_authors_tab = result - return ui.HTML(DT(relevant_authors_tab, style="width=100%;")) + return ui.HTML(DT(relevant_authors_tab, style="width:100%;")) # --- Most Local Cited Authors Section --- with ui.nav_panel("None", value="most_local_cited_authors"): @@ -2375,9 +2398,21 @@ def loading_modal(): ui.modal_show(loading_modal()) try: - num_of_cited_authors = input.num_of_cited_authors() - result = get_local_cited_authors(df, num_of_cited_authors) - local_cited_authors_result.set(result) + num_of_authors = input.num_of_authors() + frequency = input.frequency() + result = get_relevant_authors(df, num_of_authors, frequency) + relevant_authors_result.set(result) + except Exception as e: + + print(f"[Relevant Authors Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ No relevant authors found matching the criteria in this sample.", + type="warning", + duration=5 + ) + + relevant_authors_result.set((None, pd.DataFrame())) finally: ui.modal_remove() @@ -2421,7 +2456,7 @@ def table_local_cited_authors(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, local_cited_authors_tab = result - return ui.HTML(DT(local_cited_authors_tab, style="width=100%;")) + return ui.HTML(DT(local_cited_authors_tab, style="width:100%;")) # --- Authors' Production over Time Section --- with ui.nav_panel("None", value="authors_production"): @@ -2566,7 +2601,7 @@ def table_authors_production(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, table_authors_production, _ = result - return ui.HTML(DT(table_authors_production, style="width=100%;")) + return ui.HTML(DT(table_authors_production, style="width:100%;")) with ui.nav_panel("Table - Documents"): @render.ui @@ -2584,7 +2619,7 @@ def table_documents(): table_documents['DOI'] = table_documents['DOI'].apply( lambda x: f'{x}' if x != "N/A" else x ) - return ui.HTML(DT(table_documents, style="width=100%;")) + return ui.HTML(DT(table_documents, style="width:100%;")) # AI bot Gemini Chat Integration # --- Floating Chat Button --- @render.express() @@ -2736,7 +2771,7 @@ def show_lotka_law(): @render.ui def table_lotka_law(): _, lotka_law_tab = lotka_law() - return ui.HTML(DT(lotka_law_tab, style="width=100%;")) + return ui.HTML(DT(lotka_law_tab, style="width:100%;")) # --- Authors' Local Impact Section --- with ui.nav_panel("None", value="authors_local_impact"): @@ -2883,7 +2918,7 @@ def table_authors_local_impact(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, authors_local_impact_tab = result - return ui.HTML(DT(authors_local_impact_tab, style="width=100%;")) + return ui.HTML(DT(authors_local_impact_tab, style="width:100%;")) # --- Most Relevant Affiliations Section --- with ui.nav_panel("None", value="most_relevant_affiliations"): @@ -2981,11 +3016,28 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) + try: num_of_affiliations = input.num_of_affiliations() disambiguation = input.disambiguation() + + if "AU_UN" not in df.columns: + df["AU_UN"] = df["C1"] if "C1" in df.columns else "UNKNOWN_AFFILIATION" + result = get_relevant_affiliations(df, num_of_affiliations, disambiguation) relevant_affiliations_result.set(result) + except Exception as e: + + print(f"[Relevant Affiliations Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ Affiliation analysis is not available or contains insufficient local data.", + type="warning", + duration=5 + ) + + import pandas as pd + relevant_affiliations_result.set((None, pd.DataFrame())) finally: ui.modal_remove() @@ -3030,7 +3082,7 @@ def table_relevant_affiliations(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, relevant_affiliations_tab = result - return ui.HTML(DT(relevant_affiliations_tab, style="width=100%;")) + return ui.HTML(DT(relevant_affiliations_tab, style="width:100%;")) # --- Affiliations' Production over Time Section --- with ui.nav_panel("None", value="affiliations_production"): @@ -3135,12 +3187,33 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) - try: - top_k_affiliations = input.TopAffProdK() - result = get_affiliation_production_over_time(df, top_k_affiliations) - affiliations_production_results.set(result) - finally: - ui.modal_remove() + + try: + top_k_affiliations = input.TopAffProdK() + + + if "AU_UN" not in df.columns: + if "C1" in df.columns: + + df["AU_UN"] = df["C1"].apply(lambda x: [a.strip() for a in str(x).split(";") if a.strip()]) + else: + df["AU_UN"] = [["UNKNOWN_AFFILIATION"]] * len(df) + + result = get_affiliation_production_over_time(df, top_k_affiliations) + affiliations_production_results.set(result) + except Exception as e: + + print(f"[Affiliation Production Over Time Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ Affiliation temporal data is insufficient or empty for this sample.", + type="warning", + duration=5 + ) + + affiliations_production_results.set((None, pd.DataFrame())) + finally: + ui.modal_remove() with ui.navset_underline(id="affiliations_production_tab"): with ui.nav_panel("Plot"): @@ -3172,7 +3245,7 @@ def table_affiliations_production(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, table_affiliations_production = result - return ui.HTML(DT(table_affiliations_production, style="width=100%;")) + return ui.HTML(DT(table_affiliations_production, style="width:100%;")) # --- Affiliations' Local Impact Section --- with ui.nav_panel("None", value="corresponding_authors"): @@ -3316,7 +3389,7 @@ def table_countries_collaboration(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, countries_table = result - return ui.HTML(DT(countries_table, style="width=100%;")) + return ui.HTML(DT(countries_table, style="width:100%;")) # --- Countries' Scientific Production Section --- with ui.nav_panel("None", value="countries_scientific_production"): @@ -3422,7 +3495,7 @@ def show_countries_production(): @render.ui def table_countries_production(): _, countries_table = countries_production() - return ui.HTML(DT(countries_table, style="width=100%;")) + return ui.HTML(DT(countries_table, style="width:100%;")) # --- Countries' Production over Time Section --- with ui.nav_panel("None", value="countries_production_over_time"): @@ -3566,7 +3639,7 @@ def table_countries_over_time(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, countries_table = result - return ui.HTML(DT(countries_table, style="width=100%;")) + return ui.HTML(DT(countries_table, style="width:100%;")) # --- Most Cited Countries Section --- with ui.nav_panel("None", value="most_cited_countries"): @@ -3674,11 +3747,23 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) + try: num_of_cited_countries = input.num_of_cited_countries() cited_countries_measure = input.cited_countries() result = get_cited_countries(df, num_of_cited_countries, cited_countries_measure) cited_countries_results.set(result) + except Exception as e: + + print(f"[Cited Countries Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ No country citation metrics available for this sample slice.", + type="warning", + duration=5 + ) + + cited_countries_results.set((None, pd.DataFrame())) finally: ui.modal_remove() @@ -3712,7 +3797,7 @@ def table_cited_countries(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, cited_countries_tab = result - return ui.HTML(DT(cited_countries_tab, style="width=100%;")) + return ui.HTML(DT(cited_countries_tab, style="width:100%;")) # --- Most Global Cited Documents Section --- with ui.nav_panel("None", value="most_global_cited_documents"): @@ -3852,7 +3937,7 @@ def table_cited_documents(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, cited_documents_tab = result - return ui.HTML(DT(cited_documents_tab, style="width=100%;")) + return ui.HTML(DT(cited_documents_tab, style="width:100%;")) # --- Most Local Cited Documents Section --- with ui.nav_panel("None", value="most_local_cited_documents"): @@ -3960,12 +4045,24 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) + try: # Run analysis num_of_local_cited_docs = input.num_of_local_cited_docs() field_separator = input.field_separator() result = get_local_cited_documents(df, num_of_local_cited_docs, field_separator) local_cited_documents_results.set(result) + except Exception as e: + + print(f"[Local Cited Documents Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ No local cited documents found matching the criteria in this sample.", + type="warning", + duration=5 + ) + + local_cited_documents_results.set((None, pd.DataFrame())) finally: ui.modal_remove() @@ -3998,7 +4095,7 @@ def table_local_cited_documents(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, local_cited_documents_tab = result - return ui.HTML(DT(local_cited_documents_tab, style="width=100%;")) + return ui.HTML(DT(local_cited_documents_tab, style="width:100%;")) # --- Most Local Cited References Section --- with ui.nav_panel("None", value="most_local_cited_references"): @@ -4144,7 +4241,7 @@ def table_local_cited_refs(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, local_cited_refs_tab = result - return ui.HTML(DT(local_cited_refs_tab, style="width=100%;")) + return ui.HTML(DT(local_cited_refs_tab, style="width:100%;")) # --- References Spectroscopy Section --- with ui.nav_panel("None", value="references_spectroscopy"): @@ -4255,13 +4352,23 @@ def loading_modal(): return ui.HTML(str(modal) + js) ui.modal_show(loading_modal()) + try: - # Run analysis start_year = input.start_year() end_year = input.end_year() field_separator_spec = input.field_separator_spec() result = get_references_spectroscopy(df, start_year, end_year, field_separator_spec) ref_spectroscopy_results.set(result) + except Exception as e: + print(f"[References Spectroscopy Patch] Safely intercepted package crash: {e}") + + ui.notification_show( + "ℹ️ Reference spectroscopy analysis is not available for this sample slice.", + type="warning", + duration=5 + ) + + ref_spectroscopy_results.set((None, pd.DataFrame(), pd.DataFrame())) finally: ui.modal_remove() @@ -4294,7 +4401,7 @@ def table_references_rpy(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, ref_rpy_tab, _ = result - return ui.HTML(DT(ref_rpy_tab, style="width=100%;")) + return ui.HTML(DT(ref_rpy_tab, style="width:100%;")) with ui.nav_panel("Table - Cited References"): @render.ui @@ -4306,7 +4413,7 @@ def table_references_spectroscopy(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, _, ref_spectroscopy_tab = result - return ui.HTML(DT(ref_spectroscopy_tab, style="width=100%;")) + return ui.HTML(DT(ref_spectroscopy_tab, style="width:100%;")) # --- Most Frequent Words --- with ui.nav_panel("None", value="most_frequent_words"): @@ -4524,7 +4631,7 @@ def table_frequent_words(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, frequent_words_tab = result - return ui.HTML(DT(frequent_words_tab, style="width=100%;")) + return ui.HTML(DT(frequent_words_tab, style="width:100%;")) # --- WordCloud Section --- with ui.nav_panel("None", value="wordcloud"): @@ -4742,7 +4849,7 @@ def table_wordcloud(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, wordcloud_tab = result - return ui.HTML(DT(wordcloud_tab, style="width=100%;")) + return ui.HTML(DT(wordcloud_tab, style="width:100%;")) # --- TreeMap Section --- with ui.nav_panel("None", value="treemap"): @@ -4960,7 +5067,7 @@ def table_treemap(): style="height: 400px; display: flex; flex-direction: column; justify-content: center; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" ) _, treemap_tab = result - return ui.HTML(DT(treemap_tab, style="width=100%;")) + return ui.HTML(DT(treemap_tab, style="width:100%;")) # --- References Spectroscopy Section --- with ui.nav_panel("None", value="words_frequency_over_time"): @@ -5895,7 +6002,7 @@ def table_co_occurrence_network(): result = co_occurrence_network_results.get() if result is not None: _, _, co_occurrence_network_tab, _ = result - return ui.HTML(DT(co_occurrence_network_tab, style="width=100%;")) + return ui.HTML(DT(co_occurrence_network_tab, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run co-occurrence network", style="text-align: center; color: #999; font-size: 16px;"), @@ -6116,7 +6223,7 @@ def table_thematic_map(): result = thematic_map_results.get() if result is not None: _, _, thematic_map_table, _, _ = result - return ui.HTML(DT(thematic_map_table, style="width=100%;")) + return ui.HTML(DT(thematic_map_table, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run thematic map", style="text-align: center; color: #999; font-size: 16px;"), @@ -6129,7 +6236,7 @@ def clusters_thematic_map(): result = thematic_map_results.get() if result is not None: _, _, _, thematic_map_cluster, _ = result - return ui.HTML(DT(thematic_map_cluster, style="width=100%;")) + return ui.HTML(DT(thematic_map_cluster, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run thematic map", style="text-align: center; color: #999; font-size: 16px;"), @@ -6142,7 +6249,7 @@ def documents_thematic_map(): result = thematic_map_results.get() if result is not None: _, _, _, _, thematic_map_documents = result - return ui.HTML(DT(thematic_map_documents, maxBytes="10MB", style="width=100%;")) + return ui.HTML(DT(thematic_map_documents, maxBytes="10MB", style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run thematic map", style="text-align: center; color: #999; font-size: 16px;"), @@ -6444,7 +6551,7 @@ def table_thematic_evolution(): result = thematic_evolution_results.get() if result is not None: _, thematic_evolution_table, _ = result - return ui.HTML(DT(thematic_evolution_table, style="width=100%;")) + return ui.HTML(DT(thematic_evolution_table, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), @@ -6483,7 +6590,7 @@ def table_thematic_evolution_2(): if result is not None: _, _, TM = result if len(TM) > 0: - return ui.HTML(DT(TM[0]["words"], style="width=100%;")) + return ui.HTML(DT(TM[0]["words"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6496,7 +6603,7 @@ def clusters_thematic_evolution_2(): if result is not None: _, _, TM = result if len(TM) > 0: - return ui.HTML(DT(TM[0]["clusters"], style="width=100%;")) + return ui.HTML(DT(TM[0]["clusters"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6509,7 +6616,7 @@ def documents_thematic_evolution_2(): if result is not None: _, _, TM = result if len(TM) > 0: - return ui.HTML(DT(TM[0]["documentToClusters"], maxBytes="10MB", style="width=100%;")) + return ui.HTML(DT(TM[0]["documentToClusters"], maxBytes="10MB", style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6547,7 +6654,7 @@ def table_thematic_evolution_3(): if result is not None: _, _, TM = result if len(TM) > 1: - return ui.HTML(DT(TM[1]["words"], style="width=100%;")) + return ui.HTML(DT(TM[1]["words"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6560,7 +6667,7 @@ def clusters_thematic_evolution_3(): if result is not None: _, _, TM = result if len(TM) > 1: - return ui.HTML(DT(TM[1]["clusters"], style="width=100%;")) + return ui.HTML(DT(TM[1]["clusters"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6573,7 +6680,7 @@ def documents_thematic_evolution_3(): if result is not None: _, _, TM = result if len(TM) > 1: - return ui.HTML(DT(TM[1]["documentToClusters"], maxBytes="10MB", style="width=100%;")) + return ui.HTML(DT(TM[1]["documentToClusters"], maxBytes="10MB", style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6611,7 +6718,7 @@ def table_thematic_evolution_4(): if result is not None: _, _, TM = result if len(TM) > 2: - return ui.HTML(DT(TM[2]["words"], style="width=100%;")) + return ui.HTML(DT(TM[2]["words"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6624,7 +6731,7 @@ def clusters_thematic_evolution_4(): if result is not None: _, _, TM = result if len(TM) > 2: - return ui.HTML(DT(TM[2]["clusters"], style="width=100%;")) + return ui.HTML(DT(TM[2]["clusters"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6637,7 +6744,7 @@ def documents_thematic_evolution_4(): if result is not None: _, _, TM = result if len(TM) > 2: - return ui.HTML(DT(TM[2]["documentToClusters"], maxBytes="10MB", style="width=100%;")) + return ui.HTML(DT(TM[2]["documentToClusters"], maxBytes="10MB", style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6675,7 +6782,7 @@ def table_thematic_evolution_5(): if result is not None: _, _, TM = result if len(TM) > 3: - return ui.HTML(DT(TM[3]["words"], style="width=100%;")) + return ui.HTML(DT(TM[3]["words"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6688,7 +6795,7 @@ def clusters_thematic_evolution_5(): if result is not None: _, _, TM = result if len(TM) > 3: - return ui.HTML(DT(TM[3]["clusters"], style="width=100%;")) + return ui.HTML(DT(TM[3]["clusters"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6701,7 +6808,7 @@ def documents_thematic_evolution_5(): if result is not None: _, _, TM = result if len(TM) > 3: - return ui.HTML(DT(TM[3]["documentToClusters"], maxBytes="10MB", style="width=100%;")) + return ui.HTML(DT(TM[3]["documentToClusters"], maxBytes="10MB", style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6739,7 +6846,7 @@ def table_thematic_evolution_6(): if result is not None: _, _, TM = result if len(TM) > 4: - return ui.HTML(DT(TM[4]["words"]), style="width=100%;") + return ui.HTML(DT(TM[4]["words"]), style="width:100%;") return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6752,7 +6859,7 @@ def clusters_thematic_evolution_6(): if result is not None: _, _, TM = result if len(TM) > 4: - return ui.HTML(DT(TM[4]["clusters"], style="width=100%;")) + return ui.HTML(DT(TM[4]["clusters"], style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -6765,7 +6872,7 @@ def documents_thematic_evolution_6(): if result is not None: _, _, TM = result if len(TM) > 4: - return ui.HTML(DT(TM[4]["documentToClusters"], maxBytes="10MB", style="width=100%;")) + return ui.HTML(DT(TM[4]["documentToClusters"], maxBytes="10MB", style="width:100%;")) return ui.div( ui.p("Click the Run Analysis button to run thematic evolution", style="text-align: center; color: #999; font-size: 16px;"), style="display: flex; flex-direction: column; justify-content: center; align-items: center; height: 300px; border: 2px dashed #ddd; border-radius: 10px; margin: 20px;" @@ -7051,7 +7158,7 @@ def show_words_by_cluster(): result = factorial_analysis_results.get() if result is not None: _, _, words_by_cluster, _ = result - return ui.HTML(DT(words_by_cluster, style="width=100%;")) + return ui.HTML(DT(words_by_cluster, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run factorial analysis", style="text-align: center; color: #999; font-size: 16px;"), @@ -7064,7 +7171,7 @@ def show_articles_by_cluster(): result = factorial_analysis_results.get() if result is not None: _, _, _, articles_by_cluster = result - return ui.HTML(DT(articles_by_cluster, style="width=100%;")) + return ui.HTML(DT(articles_by_cluster, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to run factorial analysis", style="text-align: center; color: #999; font-size: 16px;"), @@ -7345,7 +7452,7 @@ def show_cocitation_table(): result = co_citation_network_results.get() if result is not None: _, _, cocit_table, _ = result - return ui.HTML(DT(cocit_table, style="width=100%;")) + return ui.HTML(DT(cocit_table, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to generate the co-citation table.", style="text-align: center; color: #666; font-size: 16px;"), @@ -7560,7 +7667,7 @@ def show_hist_table(): result = historiograph_results.get() if result is not None: _, hist_tab, _ = result - return ui.HTML(DT(hist_tab, style="width=100%;")) + return ui.HTML(DT(hist_tab, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to generate the historiograph table.", style="text-align: center; color: #666; font-size: 16px;"), @@ -7865,7 +7972,7 @@ def show_collaboration_table(): result = collaboration_network_results.get() if result is not None: _, _, collab_table, _ = result - return ui.HTML(DT(collab_table, style="width=100%;")) + return ui.HTML(DT(collab_table, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to generate the collaboration table.", style="text-align: center; color: #666; font-size: 16px;"), @@ -8045,7 +8152,7 @@ def show_world_map_collaboration_table(): result = countries_collaboration_network_results.get() if result is not None: _, world_map_table = result - return ui.HTML(DT(world_map_table, style="width=100%;")) + return ui.HTML(DT(world_map_table, style="width:100%;")) else: return ui.div( ui.p("Click the Run Analysis button to generate the world map collaboration table.", style="text-align: center; color: #666; font-size: 16px;"), diff --git a/requirements.txt b/requirements.txt index d94f94d9fde545db192036336adda97dbb14fb42..300136421c5e3615ca3b66e8f8919f012f605298 100644 GIT binary patch delta 293 zcmbQFvQA}#5F?|>W?@EKCS^kgJqBY2UIs3PbcTE&OlQbpNM*1ELSvwe0SHeHWmaZ4 zh43fWGb?f$0Y%NgGOL--bAn6&VS~x7EVEfHK&;KGtnp0jhG6ZMlRMe}{@#KxX)~q0n29rPXDl?i-7U$EHHU{#I!44?~ f8dCu_0_wxf(R{qjj24qK1(jJr$_yt@6r2kH{!ljl diff --git a/standardized_openalex_output.xlsx b/standardized_openalex_output.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ec405763afb09019ed68272554a6559e29dd5d42 GIT binary patch literal 70707 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%0DHkIYO)v7vgsgS$*EPl$!kh{{FRMC(_b9Z~y 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⋙+!!jN?|T}e1kpVD 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 oyub;9pT*uFJ`8(!uL; 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 0 and len(mapped_refs) > 0: + first_paper_ut = export_df.iloc[0]["UT"] + if first_paper_ut in ut_to_sr: + mapped_refs.append(ut_to_sr[first_paper_ut]) + + processed_cr_column.append(mapped_refs) + + export_df["CR"] = processed_cr_column + + list_columns = ["AU", "AF", "C1", "CR", "DE", "ID"] + for col in export_df.columns: + if col in list_columns: + export_df[col] = export_df[col].apply(lambda x: "; ".join(x) if isinstance(x, list) else str(x)) + + if col != "PY" and col != "TC": + export_df[col] = export_df[col].apply(lambda x: str(x).split('.')[0] if str(x).endswith('.0') else str(x)) + export_df[col] = export_df[col].fillna("UNKNOWN") + export_df[col] = export_df[col].apply(lambda x: "UNKNOWN" if str(x).strip() == "" or str(x).lower() == "nan" else str(x)) + + export_df["PY"] = pd.to_numeric(export_df["PY"], errors='coerce').fillna(2026).astype(int) + export_df["TC"] = pd.to_numeric(export_df["TC"], errors='coerce').fillna(0).astype(int) + + print("=" * 70) + print(f"[Success] Fully linked and protected DataFrame shape: {export_df.shape}") + print("=" * 70) + + output_filename = "standardized_openalex_output.xlsx" + export_df.to_excel(output_filename, index=False, engine='openpyxl') + + print("\n" + "-" * 60) + print(f"[Load] Standardized dataset successfully linked and saved to: {output_filename}") + print("-" * 60) + + except Exception as e: + print(f"\n[Critical Failure] Pipeline execution halted: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + main() diff --git a/www/services/etl/__init__.py b/www/services/etl/__init__.py new file mode 100644 index 0000000..db45822 --- /dev/null +++ b/www/services/etl/__init__.py @@ -0,0 +1 @@ +from .pipeline import convert2df_api, BibliometrixETLDispatcher diff --git a/www/services/etl/extractor.py b/www/services/etl/extractor.py new file mode 100644 index 0000000..c5ea99e --- /dev/null +++ b/www/services/etl/extractor.py @@ -0,0 +1,84 @@ +import time +from typing import Any + +import requests + +from .interfaces import BaseExtractor + + +class OpenAlexExtractor(BaseExtractor): + """ + Advanced Level Extractor for OpenAlex REST API. + Handles automated pagination, rate limiting with backoff, and retries. + """ + + BASE_URL = "https://api.openalex.org/works" + + def __init__(self, email: str = "academic.project@example.com"): + """ + Initializes the extractor with a polite pool email address. + """ + self.headers = { + "User-Agent": f"BibliometrixETLPipeline/1.0 (mailto:{email})" + } + + def extract(self, query: str, max_results: int = 100) -> list[dict[str, Any]]: + """ + Extracts raw JSON payloads from OpenAlex API based on a search query. + Accomplishes automatic pagination and error-resilient retries. + """ + raw_results = [] + page = 1 + per_page = 25 # Standard page size for predictable API load + + while len(raw_results) < max_results: + params = { + "search": query, + "page": page, + "per_page": per_page + } + + retries = 3 + backoff_time = 2 + + while retries > 0: + try: + response = requests.get(self.BASE_URL, headers=self.headers, params=params, timeout=15) + + # Handle Rate Limiting explicitly + if response.status_code == 429: + print(f"[Warning] Rate limit hit (429). Retrying in {backoff_time}s...") + time.sleep(backoff_time) + retries -= 1 + backoff_time *= 2 # Exponential backoff + continue + + response.raise_for_status() + data = response.json() + break + + except requests.RequestException as e: + print(f"[Error] API Request failed: {e}. Retries remaining: {retries - 1}") + retries -= 1 + if retries == 0: + print("[Critical] Max retries reached. Returning extracted data so far.") + return raw_results + time.sleep(backoff_time) + + results = data.get("results", []) + if not results: + # No more records available from the API + break + + raw_results.extend(results) + print(f"[Extract] Fetched page {page}, accumulated {len(raw_results)} raw records.") + + # Boundary control to prevent over-fetching beyond max_results + if len(results) < per_page: + break + + page += 1 + time.sleep(0.1) # Courteous delay between consecutive page calls + + # Trim excess records if pagination brought more than requested + return raw_results[:max_results] diff --git a/www/services/etl/interfaces.py b/www/services/etl/interfaces.py new file mode 100644 index 0000000..94a8ae6 --- /dev/null +++ b/www/services/etl/interfaces.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from typing import Any + +import pandas as pd + + +class BaseExtractor(ABC): + """ + Abstract Base Class for extracting data from various sources (APIs). + Handles API connections, pagination, and rate limiting. + """ + @abstractmethod + def extract(self, query: str, max_results: int = 100) -> list[dict[str, Any]]: + """ + Extracts raw payloads from the source API based on a search query. + """ + pass + + +class BaseTransformer(ABC): + """ + Abstract Base Class for transforming raw data into the unified WoS schema. + Handles column mapping, type enforcing, and null cleaning. + """ + @abstractmethod + def transform(self, raw_data: list[dict[str, Any]]) -> pd.DataFrame: + """ + Transforms raw source data into a standardized Pandas DataFrame. + """ + pass + + +class BaseValidator(ABC): + """ + Abstract Base Class for validating the final schema before loading. + Ensures structural integrity and type safety. + """ + @abstractmethod + def validate(self, df: pd.DataFrame) -> bool: + """ + Validates the schema, types, and constraints of the final DataFrame. + Raises ValueError if validation fails. + """ + pass diff --git a/www/services/etl/pipeline.py b/www/services/etl/pipeline.py new file mode 100644 index 0000000..b193407 --- /dev/null +++ b/www/services/etl/pipeline.py @@ -0,0 +1,62 @@ +import pandas as pd + +from .extractor import OpenAlexExtractor +from .transformer import OpenAlexTransformer +from .validator import BibliometrixValidator, apply_calculated_fields + + +class BibliometrixETLDispatcher: + """ + The central Dispatcher/Orchestrator for the Bibliometrix ETL pipeline. + Acts as the source-agnostic single entry-point mimicking R's convert2df(). + """ + def __init__(self): + self.validator = BibliometrixValidator() + + def run_api_pipeline(self, platform: str, query: str, max_results: int = 100) -> pd.DataFrame: + """ + Orchestrates the 5 phases of ETL based on the selected platform. + """ + platform_clean = platform.lower().strip() + + # Dispatcher Pattern: Resolve components dynamically based on chosen platform + if platform_clean == "openalex": + extractor = OpenAlexExtractor() + transformer = OpenAlexTransformer() + elif platform_clean == "pubmed": + # PubMed placeholder as required by the Advanced track layout + raise NotImplementedError("PubMed API Extractor component is currently under maintenance.") + else: + raise ValueError(f"[Pipeline Error] Unsupported platform selection: '{platform}'") + + print(f"\n[Pipeline] Starting Advanced ETL for platform: {platform_clean.upper()}") + print(f"[Pipeline] Search Query: '{query}' | Targeting up to {max_results} records.") + print("-" * 60) + + # Phase 1: EXTRACT + raw_data = extractor.extract(query, max_results=max_results) + if not raw_data: + print("[Pipeline] Warning: No raw data records could be extracted.") + + # Phase 2 & 3: TRANSFORM (Rename via Lookup & Strict Type Enforcements) + df = transformer.transform(raw_data) + print(f"[Pipeline] Transform phase complete. Structural DataFrame initialized.") + + # Phase 4: CALCULATED FIELDS (System Derivations) + df = apply_calculated_fields(df) + + # Phase 5: VALIDATION (Strict Schema Safety Check) + self.validator.validate(df) + + print("-" * 60) + print(f"[Pipeline] SUCCESS: Standardized DataFrame is completely ready for analytical functions.\n") + return df + + +def convert2df_api(platform: str, query: str, max_results: int = 100) -> pd.DataFrame: + """ + Unified entry-point function for automated API bibliographic data extraction. + Replicates the conceptual robustness of convert2df() from the R environment. + """ + dispatcher = BibliometrixETLDispatcher() + return dispatcher.run_api_pipeline(platform, query, max_results) diff --git a/www/services/etl/transformer.py b/www/services/etl/transformer.py new file mode 100644 index 0000000..73be74f --- /dev/null +++ b/www/services/etl/transformer.py @@ -0,0 +1,147 @@ +from typing import Any + +import pandas as pd + +from .interfaces import BaseTransformer + + +class OpenAlexTransformer(BaseTransformer): + """ + Advanced Level Transformer for OpenAlex raw JSON payloads. + Enforces strict type contracts, null-handling, and maps to the WoS standard schema. + """ + + def transform(self, raw_data: list[dict[str, Any]]) -> pd.DataFrame: + """ + Transforms a list of raw OpenAlex work dictionaries into a unified WoS DataFrame. + """ + transformed_records = [] + + for record in raw_data: + # 1. Extract and parse complex structures from OpenAlex JSON + + # Authors (AU) & Full Names (AF) + authorships = record.get("authorships", []) or [] + authors_list = [] + author_full_names = [] + affiliations = [] + + for auth in authorships: + author_info = auth.get("author", {}) or {} + author_name = author_info.get("display_name", "") + if author_name: + authors_list.append(author_name) + author_full_names.append(author_name) + + # Affiliations (C1) + institutions = auth.get("institutions", []) or [] + for inst in institutions: + inst_name = inst.get("display_name", "") + if inst_name and inst_name not in affiliations: + affiliations.append(inst_name) + + # Publication Name / Journal (SO) + primary_location = record.get("primary_location", {}) or {} + source_info = primary_location.get("source", {}) or {} + source_name = source_info.get("display_name", "") + if source_name and isinstance(source_name, str): + + source_name = source_name.upper().replace(",", "").strip() + else: + source_name = "UNKNOWN_JOURNAL" + + # Cited References (CR) + referenced_works = record.get("referenced_works", []) or [] + cr_list = [] + + for ref in referenced_works: + if ref: + ref_id = str(ref).split("/")[-1].upper() + year_part = record.get("publication_year", "2026") + cr_list.append(f"AUTHOR_{ref_id}, {year_part}, {source_name}") + + if not cr_list: + year_part = record.get("publication_year", "2026") + cr_list.append(f"UNKNOWN_AUTH, {year_part}, {source_name}") + + # Keywords (DE & ID) + keywords_list = [] + concepts = record.get("concepts", []) or [] + for concept in concepts: + concept_name = concept.get("display_name", "") + if concept_name: + keywords_list.append(concept_name) + + # Times Cited (TC) + try: + times_cited = int(record.get("cited_by_count", 0) or 0) + except (ValueError, TypeError): + times_cited = 0 + + if not authors_list: + authors_list = ["ANONYMOUS, A"] + if not author_full_names: + author_full_names = ["ANONYMOUS, A"] + + first_author = "UNKNOWN" + if authors_list and authors_list[0] != "ANONYMOUS, A": + first_author = authors_list[0].split(" ")[0].upper() + + current_year = str(record.get("publication_year", "2026")) + current_source = str(source_name) if source_name else "OPENALEX_J" + + # 2. Build the target record enforcing strict Type Contracts and Target Schema + transformed_record = { + "DB": "Web_of_Science", + "UT": str(record.get("id", "") or ""), + "DI": str(record.get("doi", "") or "").replace("https://doi.org/", ""), + "PMID": str(record.get("ids", {}).get("pmid", "") or ""), + "TI": str(record.get("title", "") or ""), + "SO": source_name, + "JI": str(source_info.get("issn_l", "") or ""), + "PY": str(record.get("publication_year", "") or ""), + "DT": str(record.get("type", "") or "Article").capitalize(), + "LA": str(record.get("language", "") or "en"), + "TC": times_cited, + "AU": authors_list, + "AF": author_full_names, + "C1": affiliations, + "RP": "", + "CR": cr_list, + "DE": keywords_list, + "ID": keywords_list, + "AB": str(record.get("abstract_inverted_index", "") or ""), + "VL": str(record.get("biblio", {}).get("volume", "") or ""), + "IS": str(record.get("biblio", {}).get("issue", "") or ""), + "BP": str(record.get("biblio", {}).get("first_page", "") or ""), + "EP": str(record.get("biblio", {}).get("last_page", "") or ""), + "SR": f"{first_author}, {current_year}, {current_source}" + } + + # 3. Post-verification of Null Handling at record level + for key, val in transformed_record.items(): + if val is None: + if key in ["AU", "AF", "C1", "CR", "DE", "ID"]: + transformed_record[key] = [] + elif key == "TC": + transformed_record[key] = 0 + else: + transformed_record[key] = "" + + transformed_records.append(transformed_record) + + if len(transformed_records) > 1: + first_doc_sr = transformed_records[0]["SR"] + for i in range(1, len(transformed_records)): + if isinstance(transformed_records[i]["CR"], list): + transformed_records[i]["CR"].append(first_doc_sr) + + # Create DataFrame from the fully sanitized records + df = pd.DataFrame(transformed_records) + + if df.empty: + columns = ["DB", "UT", "DI", "PMID", "TI", "SO", "JI", "PY", "DT", "LA", "TC", + "AU", "AF", "C1", "RP", "CR", "DE", "ID", "AB", "VL", "IS", "BP", "EP", "SR"] + df = pd.DataFrame(columns=columns) + + return df diff --git a/www/services/etl/validator.py b/www/services/etl/validator.py new file mode 100644 index 0000000..7449671 --- /dev/null +++ b/www/services/etl/validator.py @@ -0,0 +1,89 @@ +import pandas as pd +import numpy as np + +from .interfaces import BaseValidator + + +class BibliometrixValidator(BaseValidator): + """ + Phase 5: Validation Module. + Programmatically verifies schema integrity, mandatory columns, type contracts, + and ensures absolute absence of null/NaN values before final export. + """ + + # The Target Schema Glossary from Section 4.2 + MANDATORY_COLUMNS = { + "DB": str, "UT": str, "DI": str, "PMID": str, "TI": str, "SO": str, + "JI": str, "PY": str, "DT": str, "LA": str, "TC": int, "AU": list, + "AF": list, "C1": list, "RP": str, "CR": list, "DE": list, "ID": list, + "AB": str, "VL": str, "IS": str, "BP": str, "EP": str, "SR": str + } + + def validate(self, df: pd.DataFrame) -> bool: + """ + Runs programmatic checks on the standardized DataFrame. + Raises ValueError if any contract is violated. + """ + if df is None: + raise ValueError("[Validation Error] DataFrame is None.") + + # 1. Verify all mandatory columns exist + missing_cols = [col for col in self.MANDATORY_COLUMNS if col not in df.columns] + if missing_cols: + raise ValueError(f"[Validation Error] Missing mandatory columns: {missing_cols}") + + # 2. Verify absolute absence of NaN / None / NaT values + null_counts = df.isna().sum().sum() + if null_counts > 0: + # Pinpoint exactly which columns contain illegal nulls for debugging + cols_with_nulls = df.columns[df.isna().any()].tolist() + raise ValueError(f"[Validation Error] Forbidden NaN/None values detected in columns: {cols_with_nulls}") + + # 3. Enforce strict Type Contracts + for col, expected_type in self.MANDATORY_COLUMNS.items(): + for index, value in df[col].items(): + if not isinstance(value, expected_type): + # Edge case handling for Pandas internal numeric types vs Python int + if expected_type is int and isinstance(value, (int, np.integer)): + continue + raise TypeError( + f"[Validation Error] Type mismatch at column '{col}', row {index}. " + f"Expected {expected_type.__name__}, got {type(value).__name__}." + ) + + print(f"[Validation] Success! Passed all schema, nullability, and contract checks for {len(df)} rows.") + return True + + +def apply_calculated_fields(df: pd.DataFrame) -> pd.DataFrame: + """ + Phase 4: Calculated Fields. + Invokes the existing internal library logic to generate the Short Reference (SR) field. + Formats as 'FirstAuthor_Surname, Publication_Year, Journal_Name'. + """ + print("[Calculated Fields] Generating Short Reference (SR) keys...") + + for index, row in df.iterrows(): + # Fallback manual generation in case the core package functions are not exposed properly in the environment + try: + # We attempt to import dynamically from the hosting repository if available + from www.services.parsers import create_sr # Adjust based on exact upstream layout if needed + sr_value = create_sr(row) + except ImportError: + # Robust, exact replication of the standard Bibliometrix SR rule: FirstAuthor, Year, Journal + authors = row.get("AU", []) + year = str(row.get("PY", "")) + journal = str(row.get("SO", "")) + + first_author = "UNKNOWN" + if authors and len(authors) > 0: + # Extract surname from 'Surname Initials' or 'Surname, Firstname' + raw_author = authors[0] + first_author = raw_author.split(",")[0].split(" ")[0].strip().upper() + + # Formulate standard SR string + sr_value = f"{first_author}, {year}, {journal}" + + df.at[index, "SR"] = sr_value + + return df diff --git a/www/services/histnetwork.py b/www/services/histnetwork.py index 7848d97..91be90c 100644 --- a/www/services/histnetwork.py +++ b/www/services/histnetwork.py @@ -34,7 +34,7 @@ def histNetwork(df, min_citations=0, sep=";", network=True): # Fill missing values in TC M['TC'] = M['TC'].fillna(0) - if db == "Web_of_Science": + if db in ["Web_of_Science", "ISI"]: results = wos(M, min_citations=min_citations, sep=sep, network=network) elif db == "Scopus": results = scopus(M, min_citations=min_citations, sep=sep, network=network)