Spaces:
Sleeping
Sleeping
Updated display messages for download links and names of downloaded files
Browse files
notebooks/PopulationHealthScreener.ipynb
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
}
|
| 12 |
},
|
| 13 |
"outputs": [],
|
| 14 |
-
"source": "# imports\nfrom datetime import date\nfrom dateutil.relativedelta import relativedelta\nimport ipywidgets as widgets\nfrom IPython.utils.capture import capture_output\nfrom src.utils.app_utils import silence_libraries, exec_script\nfrom src.utils.prediction_table import PredictionTable\nfrom src.utils.ui_elements import (\n inject_styles, BOX_WIDTH, BUTTON_WIDTH, QUERY_WIDTH, make_button, make_separator, make_spacer,\n make_section_header, RunButton, error_html,\n download_link, download_link_combined, download_link_predictions,\n)\nsilence_libraries()\nwith capture_output():\n from src.handlers.model_wrapper import available_models\ninject_styles()\n\n# app state\narticles_table = None; selected_model = None; model_loaded = False\n\n# model section\nmodel_dropdown = widgets.Dropdown(\n options=[m.name for m in available_models], value=available_models[0].name,\n description='', layout=widgets.Layout(width=BOX_WIDTH))\n\n# query section\n_PAPER_URL = 'https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(16)30054-X/abstract'\nquery_selector = widgets.RadioButtons(\n options=[('Use the query from the 2016 paper by the NCD-RisC', 'paper'),\n ('Use a custom query', 'custom')],\n value='paper', description='', style={'description_width': '0px'},\n layout=widgets.Layout(width='auto'))\n_paper_link = widgets.HTML(\n value=f\"<a href='{_PAPER_URL}' target='_blank' style='font-size:13px;color:inherit;text-decoration:underline;'>↗</a>\",\n layout=widgets.Layout(margin='-4px 0 0 4px'))\n_qs_widget = widgets.HBox([query_selector, _paper_link], layout=widgets.Layout(align_items='flex-start'))\nquery_input = widgets.Textarea(\n placeholder='Enter PubMed query...', disabled=True,\n layout=widgets.Layout(width=QUERY_WIDTH, height='60px'))\nquery_selector.observe(lambda c: setattr(query_input, 'disabled', c.new == 'paper'), names='value')\n\n# date pickers\n_lbl = \"text-align:right;display:inline-block;width:75px;font-size:13px;line-height:28px;padding-right:6px;\"\nstart_date = widgets.DatePicker(value=date.today() - relativedelta(months=1), description='')\nend_date = widgets.DatePicker(value=date.today(), description='')\n\n# action buttons\nrecall_target = widgets.Dropdown(\n options=[('95%', 95), ('90%', 90), ('80%', 80), ('70%', 70), ('60%', 60)],\n value=95, description='', disabled=True, layout=widgets.Layout(width='120px'))\nload_run = RunButton('Load', executing_msg='Loading...', error_label='Model')\nsearch_run = RunButton('Search', executing_msg='Searching...', error_label='Search')\npredict_run = RunButton('Screen articles',executing_msg='Screening...', error_label='Screening')\npredict_run.btn.disabled = True\n_reset_btn = widgets.Button(\n description='\\u21ba', layout=widgets.Layout(width='32px', height='28px', display='none'))\nunload_btn = make_button('Unload', width='100px'); unload_btn.layout.display = 'none'\n\n# upload — FileUpload syncs natively via ipywidgets; no custom JS required\nupload_widget = widgets.FileUpload(\n description='Upload', accept='.csv', multiple=False,\n style=widgets.ButtonStyle(font_weight='bold'),\n layout=widgets.Layout(width=BUTTON_WIDTH))\n\n# status / progress\narticles_status = widgets.HTML(value='')\nload_file_error = widgets.HTML(value='')\n_screen_progress = widgets.IntProgress(value=0, min=0, max=1)\n_screen_progress.observe(\n lambda c: predict_run.btn.disabled and setattr(\n predict_run.btn, 'description', f'Screening... {c.new}/{_screen_progress.max}'),\n names='value')\n\n# state helpers\n\ndef _set_search_disabled(d):\n query_selector.disabled = d\n _paper_link.value = (\n f\"<a href='{_PAPER_URL}' target='_blank' style='font-size:13px;\"\n f\"color:inherit;text-decoration:underline;\"\n f\"{'opacity:0.6;pointer-events:none;' if d else ''}'>↗</a>\")\n query_input.disabled = d or (query_selector.value == 'paper')\n start_date.disabled = end_date.disabled = search_run.btn.disabled = d\n\ndef _set_load_disabled(d):\n upload_widget.disabled = d\n if not d: # reset to initial state after Unload\n upload_widget.value = (); upload_widget.description = 'Upload'\n\ndef _update_predict():\n r = model_loaded and articles_table is not None\n recall_target.disabled = predict_run.btn.disabled = not r\n\ndef _set_articles(table, name):\n global articles_table\n articles_table = table\n n = len(table.df)\n articles_status.value = (\n f\"<span style='color:#28a745;font-size:13px;line-height:28px;margin-right:10px;'>\"\n f\"Loaded {n} articles to screen</span>\")\n unload_btn.layout.display = ''; _update_predict()\n\ndef _unset_articles():\n global articles_table\n articles_table = None; articles_status.value = ''\n unload_btn.layout.display = 'none'; _update_predict()\n\n# event handlers\n\ndef run_load_model(b):\n global model_loaded, selected_model\n load_run.start()\n try:\n with capture_output():\n ns = exec_script('src/choose_model.py', {'model_dropdown': model_dropdown})\n selected_model = ns['selected_model']; model_loaded = True\n load_run.done('\\u2713 Model loaded'); _update_predict()\n except Exception as e:\n model_loaded = False; load_run.fail(e)\n\ndef run_search(b):\n search_run.start(); saved = (start_date.value, end_date.value)\n _set_search_disabled(True); start_date.value, end_date.value = saved\n try:\n with capture_output():\n ns = exec_script('src/search_pubmed.py', {\n 'query_selector': query_selector, 'query_input': query_input,\n 'start_date': start_date, 'end_date': end_date, 'model_dropdown': model_dropdown,\n })\n search_run.done('\\u2713 Search completed'); _set_load_disabled(True)\n _set_articles(ns['articles_table'], 'Articles.csv')\n search_run.out.value = download_link(ns['articles_table'].df, 'Articles')\n except Exception as e:\n _set_search_disabled(False); search_run.fail(e)\n\ndef on_file_uploaded(change):\n if not change.new: return\n load_file_error.value = ''; upload_widget.description = 'Reading...'; upload_widget.disabled = True\n try:\n content = change.new[0]['content']; name = change.new[0]['name']\n if not content: raise ValueError(\"File content is empty — the upload may not have completed.\")\n table = PredictionTable.from_bytes(content)\n upload_widget.description = '\\u2713 Upload completed'\n _set_articles(table, name); _set_search_disabled(True)\n except Exception as e:\n upload_widget.description = 'Upload'; upload_widget.disabled = False\n upload_widget.value = (); load_file_error.value = error_html(e)\nupload_widget.observe(on_file_uploaded, names='value')\n\ndef on_unload(b):\n _unset_articles()\n saved = (start_date.value, end_date.value, query_input.value)\n _set_search_disabled(False)\n start_date.value, end_date.value, query_input.value = saved\n _set_load_disabled(False)\n search_run.reset(); _reset_btn.layout.display = 'none'; load_file_error.value = ''\n\ndef run_screen_articles(b):\n predict_run.start(); _reset_btn.layout.display = 'none'; _screen_progress.value = 0\n try:\n with capture_output():\n ns = exec_script('src/screen_articles.py', {\n 'selected_model': selected_model, 'recall_target': recall_target,\n 'articles_table': articles_table, '_screen_progress': _screen_progress,\n '_screen_progress_label': None,\n })\n predict_run.done('\\u2713 Screening completed'); _reset_btn.layout.display = ''\n predict_run.out.value = (\n download_link_combined(ns['review_data'], 'Screened articles') +\n download_link_predictions(ns['data'], 'Screened articles'))\n except Exception as e:\n predict_run.fail(e)\n\ndef on_predict_reset(b):\n predict_run.reset(); _reset_btn.layout.display = 'none'; _update_predict()\n\n# wire up\n_reset_btn.on_click(on_predict_reset); load_run.on_click(run_load_model); search_run.on_click(run_search)\npredict_run.on_click(run_screen_articles); unload_btn.on_click(on_unload)\n\n# layout\n_M = widgets.Layout(margin='0 0 0 12px')\ndisplay(\n widgets.HTML(\"<h2 style='margin:8px 0 12px 12px;font-size:22px;font-weight:bold;'>PopulationHealthScreener</h2>\"),\n widgets.VBox([make_section_header('Choose a model'), model_dropdown, make_spacer('20px'), load_run.row], layout=_M),\n make_separator(),\n widgets.VBox([\n widgets.HBox([\n widgets.VBox([\n make_section_header('Search PubMed'), make_spacer('8px'),\n _qs_widget, make_spacer('2px'), query_input, make_spacer('10px'),\n widgets.HBox([widgets.HTML(f\"<span style='{_lbl}'>Start date:</span>\"), start_date]),\n widgets.HBox([widgets.HTML(f\"<span style='{_lbl}'>End date:</span>\"), end_date]),\n make_spacer('12px'), search_run.row,\n ]),\n widgets.HBox([\n widgets.HTML(\"<span style='font-size:16px;'>or</span>\"),\n widgets.VBox([\n make_section_header('Load articles from a file'), make_spacer('8px'),\n widgets.HBox([upload_widget, load_file_error]),\n ], layout=widgets.Layout(margin='0 0 0 20px')),\n ], layout=widgets.Layout(align_items='flex-start', margin='0 0 0 24px')),\n ], layout=widgets.Layout(align_items='flex-start')),\n make_spacer('12px'),\n widgets.HBox([articles_status, unload_btn]),\n ], layout=_M),\n make_separator(),\n widgets.VBox([\n widgets.HBox([make_section_header('Screen articles'), _reset_btn],\n layout=widgets.Layout(align_items='center')),\n make_spacer('8px'),\n widgets.VBox([\n widgets.HBox([\n widgets.HTML(\"<span style='font-size:13px;line-height:28px;margin-right:8px;'>Target recall</span>\"),\n recall_target,\n ], layout=widgets.Layout(align_items='center')),\n widgets.HTML(\"<span style='font-size:13px;color:gray;'>Target recall is the proportion of truly relevant articles that you can expect the model to identify.</span>\"),\n ]),\n make_spacer('12px'), predict_run.row,\n ], layout=_M),\n)"
|
| 15 |
},
|
| 16 |
{
|
| 17 |
"cell_type": "code",
|
|
|
|
| 11 |
}
|
| 12 |
},
|
| 13 |
"outputs": [],
|
| 14 |
+
"source": "# imports\nfrom datetime import date\nfrom dateutil.relativedelta import relativedelta\nimport ipywidgets as widgets\nfrom IPython.utils.capture import capture_output\nfrom src.utils.app_utils import silence_libraries, exec_script\nfrom src.utils.prediction_table import PredictionTable\nfrom src.utils.ui_elements import (\n inject_styles, BOX_WIDTH, BUTTON_WIDTH, QUERY_WIDTH, make_button, make_separator, make_spacer,\n make_section_header, RunButton, error_html,\n download_link, download_link_combined, download_link_predictions,\n)\nsilence_libraries()\nwith capture_output():\n from src.handlers.model_wrapper import available_models\ninject_styles()\n\n# app state\narticles_table = None; selected_model = None; model_loaded = False\n\n# model section\nmodel_dropdown = widgets.Dropdown(\n options=[m.name for m in available_models], value=available_models[0].name,\n description='', layout=widgets.Layout(width=BOX_WIDTH))\n\n# query section\n_PAPER_URL = 'https://www.thelancet.com/journals/lancet/article/PIIS0140-6736(16)30054-X/abstract'\nquery_selector = widgets.RadioButtons(\n options=[('Use the query from the 2016 paper by the NCD-RisC', 'paper'),\n ('Use a custom query', 'custom')],\n value='paper', description='', style={'description_width': '0px'},\n layout=widgets.Layout(width='auto'))\n_paper_link = widgets.HTML(\n value=f\"<a href='{_PAPER_URL}' target='_blank' style='font-size:13px;color:inherit;text-decoration:underline;'>↗</a>\",\n layout=widgets.Layout(margin='-4px 0 0 4px'))\n_qs_widget = widgets.HBox([query_selector, _paper_link], layout=widgets.Layout(align_items='flex-start'))\nquery_input = widgets.Textarea(\n placeholder='Enter PubMed query...', disabled=True,\n layout=widgets.Layout(width=QUERY_WIDTH, height='60px'))\nquery_selector.observe(lambda c: setattr(query_input, 'disabled', c.new == 'paper'), names='value')\n\n# date pickers\n_lbl = \"text-align:right;display:inline-block;width:75px;font-size:13px;line-height:28px;padding-right:6px;\"\nstart_date = widgets.DatePicker(value=date.today() - relativedelta(months=1), description='')\nend_date = widgets.DatePicker(value=date.today(), description='')\n\n# action buttons\nrecall_target = widgets.Dropdown(\n options=[('95%', 95), ('90%', 90), ('80%', 80), ('70%', 70), ('60%', 60)],\n value=95, description='', disabled=True, layout=widgets.Layout(width='120px'))\nload_run = RunButton('Load', executing_msg='Loading...', error_label='Model')\nsearch_run = RunButton('Search', executing_msg='Searching...', error_label='Search')\npredict_run = RunButton('Screen articles',executing_msg='Screening...', error_label='Screening')\npredict_run.btn.disabled = True\n_reset_btn = widgets.Button(\n description='\\u21ba', layout=widgets.Layout(width='32px', height='28px', display='none'))\nunload_btn = widgets.Button(description='↺', layout=widgets.Layout(width='32px', height='28px', display='none'))\n_upload_refresh_btn = widgets.Button(description='↺', layout=widgets.Layout(width='32px', height='28px', display='none'))\n\n# upload — FileUpload syncs natively via ipywidgets; no custom JS required\nupload_widget = widgets.FileUpload(\n description='Upload', accept='.csv', multiple=False,\n style=widgets.ButtonStyle(font_weight='bold'),\n layout=widgets.Layout(width=BUTTON_WIDTH))\n\n# status / progress\narticles_status = widgets.HTML(value='')\nload_file_error = widgets.HTML(value='')\n_screen_progress = widgets.IntProgress(value=0, min=0, max=1)\n_screen_progress.observe(\n lambda c: predict_run.btn.disabled and setattr(\n predict_run.btn, 'description', f'Screening... {c.new}/{_screen_progress.max}'),\n names='value')\n\n# state helpers\n\ndef _set_search_disabled(d):\n query_selector.disabled = d\n _paper_link.value = (\n f\"<a href='{_PAPER_URL}' target='_blank' style='font-size:13px;\"\n f\"color:inherit;text-decoration:underline;\"\n f\"{'opacity:0.6;pointer-events:none;' if d else ''}'>↗</a>\")\n query_input.disabled = d or (query_selector.value == 'paper')\n start_date.disabled = end_date.disabled = search_run.btn.disabled = d\n\ndef _set_load_disabled(d):\n upload_widget.disabled = d\n if not d: # reset to initial state after Unload\n upload_widget.value = (); upload_widget.description = 'Upload'\n\ndef _update_predict():\n r = model_loaded and articles_table is not None\n recall_target.disabled = predict_run.btn.disabled = not r\n\ndef _set_articles(table, name):\n global articles_table\n articles_table = table\n _update_predict()\n\ndef _unset_articles():\n global articles_table\n articles_table = None\n unload_btn.layout.display = 'none'; _upload_refresh_btn.layout.display = 'none'\n _update_predict()\n\n# event handlers\n\ndef run_load_model(b):\n global model_loaded, selected_model\n load_run.start()\n try:\n with capture_output():\n ns = exec_script('src/choose_model.py', {'model_dropdown': model_dropdown})\n selected_model = ns['selected_model']; model_loaded = True\n load_run.done('\\u2713 Model loaded'); _update_predict()\n except Exception as e:\n model_loaded = False; load_run.fail(e)\n\ndef run_search(b):\n search_run.start(); saved = (start_date.value, end_date.value)\n _set_search_disabled(True); start_date.value, end_date.value = saved\n try:\n with capture_output():\n ns = exec_script('src/search_pubmed.py', {\n 'query_selector': query_selector, 'query_input': query_input,\n 'start_date': start_date, 'end_date': end_date, 'model_dropdown': model_dropdown,\n })\n search_run.done('\\u2713 Search completed'); _set_load_disabled(True)\n _set_articles(ns['articles_table'], 'Articles.csv')\n search_run.out.value = download_link(ns['articles_table'].df, 'Articles')\n unload_btn.layout.display = ''\n except Exception as e:\n _set_search_disabled(False); search_run.fail(e)\n\ndef on_file_uploaded(change):\n if not change.new: return\n load_file_error.value = ''; upload_widget.description = 'Reading...'; upload_widget.disabled = True\n try:\n content = change.new[0]['content']; name = change.new[0]['name']\n if not content: raise ValueError(\"File content is empty — the upload may not have completed.\")\n table = PredictionTable.from_bytes(content)\n upload_widget.description = '\\u2713 Upload completed'\n _set_articles(table, name); _set_search_disabled(True)\n _upload_refresh_btn.layout.display = ''\n except Exception as e:\n upload_widget.description = 'Upload'; upload_widget.disabled = False\n upload_widget.value = (); load_file_error.value = error_html(e)\nupload_widget.observe(on_file_uploaded, names='value')\n\ndef on_unload(b):\n _unset_articles()\n saved = (start_date.value, end_date.value, query_input.value)\n _set_search_disabled(False)\n start_date.value, end_date.value, query_input.value = saved\n _set_load_disabled(False)\n search_run.reset(); _reset_btn.layout.display = 'none'; load_file_error.value = ''\n\ndef run_screen_articles(b):\n predict_run.start(); _reset_btn.layout.display = 'none'; _screen_progress.value = 0\n try:\n with capture_output():\n ns = exec_script('src/screen_articles.py', {\n 'selected_model': selected_model, 'recall_target': recall_target,\n 'articles_table': articles_table, '_screen_progress': _screen_progress,\n '_screen_progress_label': None,\n })\n predict_run.done('\\u2713 Screening completed'); _reset_btn.layout.display = ''\n predict_run.out.value = (\n download_link_combined(ns['review_data'], 'Results for all articles') +\n download_link_predictions(ns['data'], 'Articles identified after screening'))\n except Exception as e:\n predict_run.fail(e)\n\ndef on_predict_reset(b):\n predict_run.reset(); _reset_btn.layout.display = 'none'; _update_predict()\n\n# wire up\n_reset_btn.on_click(on_predict_reset); load_run.on_click(run_load_model); search_run.on_click(run_search)\npredict_run.on_click(run_screen_articles); unload_btn.on_click(on_unload); _upload_refresh_btn.on_click(on_unload)\n\n# layout\n_M = widgets.Layout(margin='0 0 0 12px')\ndisplay(\n widgets.HTML(\"<h2 style='margin:8px 0 12px 12px;font-size:22px;font-weight:bold;'>PopulationHealthScreener</h2>\"),\n widgets.VBox([make_section_header('Choose a model'), model_dropdown, make_spacer('20px'), load_run.row], layout=_M),\n make_separator(),\n widgets.VBox([\n widgets.HBox([\n widgets.VBox([\n make_section_header('Search PubMed'), make_spacer('8px'),\n _qs_widget, make_spacer('2px'), query_input, make_spacer('10px'),\n widgets.HBox([widgets.HTML(f\"<span style='{_lbl}'>Start date:</span>\"), start_date]),\n widgets.HBox([widgets.HTML(f\"<span style='{_lbl}'>End date:</span>\"), end_date]),\n make_spacer('12px'), widgets.HBox([search_run.btn, search_run.out, unload_btn]),\n ]),\n widgets.HBox([\n widgets.HTML(\"<span style='font-size:16px;'>or</span>\"),\n widgets.VBox([\n make_section_header('Load articles from a file'), make_spacer('8px'),\n widgets.HBox([upload_widget, _upload_refresh_btn, load_file_error]),\n ], layout=widgets.Layout(margin='0 0 0 20px')),\n ], layout=widgets.Layout(align_items='flex-start', margin='0 0 0 24px')),\n ], layout=widgets.Layout(align_items='flex-start')),\n\n ], layout=_M),\n make_separator(),\n widgets.VBox([\n make_section_header('Screen articles'),\n make_spacer('8px'),\n widgets.VBox([\n widgets.HBox([\n widgets.HTML(\"<span style='font-size:13px;line-height:28px;margin-right:8px;'>Target recall</span>\"),\n recall_target,\n ], layout=widgets.Layout(align_items='center')),\n widgets.HTML(\"<span style='font-size:13px;color:gray;'>Target recall is the proportion of truly relevant articles that you can expect the model to identify.</span>\"),\n ]),\n make_spacer('12px'), widgets.HBox([predict_run.btn, _reset_btn, predict_run.out]),\n ], layout=_M),\n)"
|
| 15 |
},
|
| 16 |
{
|
| 17 |
"cell_type": "code",
|
notebooks/src/utils/ui_elements.py
CHANGED
|
@@ -166,10 +166,11 @@ _DL_EXT = "font-size:12px;line-height:28px;"
|
|
| 166 |
|
| 167 |
|
| 168 |
def download_link(df, stem):
|
|
|
|
| 169 |
b64 = base64.b64encode(df.to_csv(index=False).encode()).decode()
|
| 170 |
return (
|
| 171 |
f"<span style='{_DL_SPAN}'>"
|
| 172 |
-
f"<a href='data:text/csv;base64,{b64}' download='{stem}.csv' style='{_DL_LINK}'>Download articles</a>"
|
| 173 |
f"</span>"
|
| 174 |
)
|
| 175 |
|
|
@@ -214,12 +215,13 @@ def download_link_combined(df, stem):
|
|
| 214 |
f"<a href='#' style='{_DL_LINK}'"
|
| 215 |
f" onclick=\"document.getElementById('csv-{uid}').click();"
|
| 216 |
f"setTimeout(function(){{document.getElementById('ris-{uid}').click();}},150);"
|
| 217 |
-
f"return false;\">Download results for all articles</a>"
|
| 218 |
f"</span>"
|
| 219 |
)
|
| 220 |
|
| 221 |
|
| 222 |
def download_link_predictions(df, stem):
|
|
|
|
| 223 |
df = df.rename(columns={
|
| 224 |
'y_prob': 'Predicted probability of meeting inclusion criteria',
|
| 225 |
'y_pred': 'Prediction (1=meets inclusion criteria; 0=does not meet inclusion criteria)',
|
|
@@ -227,6 +229,6 @@ def download_link_predictions(df, stem):
|
|
| 227 |
b64 = base64.b64encode(df.to_csv(index=False).encode()).decode()
|
| 228 |
return (
|
| 229 |
f"<span style='{_DL_SPAN}'>"
|
| 230 |
-
f"<a href='data:text/csv;base64,{b64}' download='{stem}
|
| 231 |
f"</span>"
|
| 232 |
)
|
|
|
|
| 166 |
|
| 167 |
|
| 168 |
def download_link(df, stem):
|
| 169 |
+
n = len(df)
|
| 170 |
b64 = base64.b64encode(df.to_csv(index=False).encode()).decode()
|
| 171 |
return (
|
| 172 |
f"<span style='{_DL_SPAN}'>"
|
| 173 |
+
f"<a href='data:text/csv;base64,{b64}' download='{stem}.csv' style='{_DL_LINK}'>Download {n} articles</a>"
|
| 174 |
f"</span>"
|
| 175 |
)
|
| 176 |
|
|
|
|
| 215 |
f"<a href='#' style='{_DL_LINK}'"
|
| 216 |
f" onclick=\"document.getElementById('csv-{uid}').click();"
|
| 217 |
f"setTimeout(function(){{document.getElementById('ris-{uid}').click();}},150);"
|
| 218 |
+
f"return false;\">Download results for all {len(df)} articles</a>"
|
| 219 |
f"</span>"
|
| 220 |
)
|
| 221 |
|
| 222 |
|
| 223 |
def download_link_predictions(df, stem):
|
| 224 |
+
n = len(df)
|
| 225 |
df = df.rename(columns={
|
| 226 |
'y_prob': 'Predicted probability of meeting inclusion criteria',
|
| 227 |
'y_pred': 'Prediction (1=meets inclusion criteria; 0=does not meet inclusion criteria)',
|
|
|
|
| 229 |
b64 = base64.b64encode(df.to_csv(index=False).encode()).decode()
|
| 230 |
return (
|
| 231 |
f"<span style='{_DL_SPAN}'>"
|
| 232 |
+
f"<a href='data:text/csv;base64,{b64}' download='{stem}.csv' style='{_DL_LINK}'>Download {n} articles identified after screening</a>"
|
| 233 |
f"</span>"
|
| 234 |
)
|