Update app.py
Browse files
app.py
CHANGED
|
@@ -63,10 +63,13 @@ DISPLAY_COLUMNS_ALL = [
|
|
| 63 |
"registration_number",
|
| 64 |
]
|
| 65 |
|
|
|
|
| 66 |
# ==========================
|
| 67 |
# UI: КОЛОНКИ ТАБЛИЦЫ + НАСТРОЙКИ ОТОБРАЖЕНИЯ
|
| 68 |
-
# ВАЖНО:
|
| 69 |
-
#
|
|
|
|
|
|
|
| 70 |
# ==========================
|
| 71 |
|
| 72 |
UI_TABLE_COLUMNS = [
|
|
@@ -77,13 +80,14 @@ UI_TABLE_COLUMNS = [
|
|
| 77 |
"Тип",
|
| 78 |
"Год",
|
| 79 |
"Регистрационный номер",
|
| 80 |
-
"OpenAlex",
|
| 81 |
"h-index",
|
| 82 |
"i10-index",
|
| 83 |
"Работ",
|
| 84 |
"Цитат",
|
| 85 |
]
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
# ==========================
|
|
@@ -115,7 +119,6 @@ st.markdown(
|
|
| 115 |
.stDataFrame { font-size: 80% !important; }
|
| 116 |
.gdg-w, .gdg-canvas { font-size: 80% !important; }
|
| 117 |
|
| 118 |
-
/* data_editor */
|
| 119 |
div[data-testid="stDataEditor"] { font-size: 80% !important; }
|
| 120 |
div[data-testid="stDataEditor"] * { font-size: 80% !important; }
|
| 121 |
</style>
|
|
@@ -146,6 +149,12 @@ def _safe_text(x: Any) -> str:
|
|
| 146 |
return s
|
| 147 |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
def _norm_openalex_url(x: Any) -> str:
|
| 150 |
s = _safe_text(x)
|
| 151 |
if not s:
|
|
@@ -450,7 +459,6 @@ def build_result_df(results):
|
|
| 450 |
|
| 451 |
protection_year = extract_year_int(meta.get("protection_date", None))
|
| 452 |
|
| 453 |
-
# ВАЖНО: используем author_org_short (fallback на author_org_name если вдруг короткого нет)
|
| 454 |
org_short = meta.get("author_org_short", None)
|
| 455 |
if (
|
| 456 |
org_short is None
|
|
@@ -491,7 +499,7 @@ def run_search(
|
|
| 491 |
):
|
| 492 |
mask = build_filter_mask(candidate_selected, doctor_selected, science_selected, year_range)
|
| 493 |
|
| 494 |
-
#
|
| 495 |
prefetch_k = min(max(int(top_k) * 5, int(top_k)), 500)
|
| 496 |
results = search_core(query, prefetch_k, mask=mask)
|
| 497 |
|
|
@@ -506,7 +514,6 @@ def run_search(
|
|
| 506 |
out.seek(0)
|
| 507 |
return empty_ui, out, df_raw
|
| 508 |
|
| 509 |
-
# reg_norm — стабильный идентификатор строки для сохранения выбранных чекбоксов
|
| 510 |
df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum)
|
| 511 |
|
| 512 |
# OA/ORCID enrichment
|
|
@@ -533,37 +540,55 @@ def run_search(
|
|
| 533 |
df_raw["works_count"] = np.nan
|
| 534 |
df_raw["cited_by_count"] = np.nan
|
| 535 |
|
| 536 |
-
# Фильтры "только с
|
| 537 |
if only_openalex:
|
| 538 |
df_raw = df_raw[df_raw["openalex_url"].map(_safe_text) != ""]
|
| 539 |
if only_orcid:
|
| 540 |
df_raw = df_raw[df_raw["orcid_url"].map(_safe_text) != ""]
|
| 541 |
|
| 542 |
-
# Ограничиваем top_k уже после фильтрации
|
| 543 |
df_raw = df_raw.reset_index(drop=True)
|
| 544 |
if len(df_raw) > int(top_k):
|
| 545 |
df_raw = df_raw.iloc[: int(top_k)].copy()
|
| 546 |
|
| 547 |
-
# Дедуп и индекс
|
| 548 |
df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum)
|
| 549 |
df_raw = df_raw.drop_duplicates(subset=["reg_norm"]).set_index("reg_norm", drop=True)
|
| 550 |
|
| 551 |
-
# Перенумер
|
| 552 |
if "№" in df_raw.columns:
|
| 553 |
df_raw["№"] = np.arange(1, len(df_raw) + 1)
|
| 554 |
|
| 555 |
-
# UI
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
df_ui = pd.DataFrame(
|
| 558 |
{
|
| 559 |
"Сходство": pd.to_numeric(df_raw["score"], errors="coerce").astype("float64"),
|
| 560 |
-
"ФИО":
|
| 561 |
-
"Название диссертации":
|
| 562 |
"Организация": df_raw["author_org_short"].map(_safe_text),
|
| 563 |
"Тип": df_raw["dissertation_type"].map(_safe_text),
|
| 564 |
"Год": pd.to_numeric(df_raw["protection_year"], errors="coerce").astype("float64"),
|
| 565 |
"Регистрационный номер": df_raw["registration_number"].map(_safe_text),
|
| 566 |
-
"OpenAlex": df_raw["openalex_url"].map(_safe_text),
|
| 567 |
"h-index": df_raw["h_index"],
|
| 568 |
"i10-index": df_raw["i10_index"],
|
| 569 |
"Работ": df_raw["works_count"],
|
|
@@ -586,8 +611,9 @@ def run_search(
|
|
| 586 |
def format_selected_list_from_raw(df_raw: pd.DataFrame, selected_regnorms: List[str]) -> str:
|
| 587 |
lines = []
|
| 588 |
for reg_norm in selected_regnorms:
|
| 589 |
-
if reg_norm not in df_raw.index:
|
| 590 |
continue
|
|
|
|
| 591 |
raw = df_raw.loc[reg_norm]
|
| 592 |
fio = _safe_text(raw.get("fio"))
|
| 593 |
title = _safe_text(raw.get("title"))
|
|
@@ -627,11 +653,11 @@ if "last_df_raw" not in st.session_state:
|
|
| 627 |
if "last_excel" not in st.session_state:
|
| 628 |
st.session_state.last_excel = None
|
| 629 |
|
| 630 |
-
#
|
| 631 |
if "selected_regnorms" not in st.session_state:
|
| 632 |
st.session_state.selected_regnorms = set()
|
| 633 |
|
| 634 |
-
#
|
| 635 |
if "search_id" not in st.session_state:
|
| 636 |
st.session_state.search_id = 0
|
| 637 |
|
|
@@ -702,7 +728,7 @@ with st.form("search_form"):
|
|
| 702 |
# Выбранные для отображения колонки (после формы)
|
| 703 |
visible_ui_cols = [c for c in UI_TABLE_COLUMNS if st.session_state.get(_show_col_key(c), True)]
|
| 704 |
if not visible_ui_cols:
|
| 705 |
-
visible_ui_cols = [c for c in UI_TABLE_COLUMNS if c
|
| 706 |
|
| 707 |
if do_search:
|
| 708 |
if not candidate_selected and not doctor_selected:
|
|
@@ -724,14 +750,12 @@ if do_search:
|
|
| 724 |
st.session_state.last_df_raw = df_raw
|
| 725 |
st.session_state.last_excel = excel_bytes
|
| 726 |
|
| 727 |
-
#
|
| 728 |
if isinstance(df_ui, pd.DataFrame) and not df_ui.empty:
|
| 729 |
st.session_state.selected_regnorms = set(st.session_state.selected_regnorms) & set(df_ui.index)
|
| 730 |
else:
|
| 731 |
st.session_state.selected_regnorms = set()
|
| 732 |
|
| 733 |
-
# новый поиск -> новый ключ таблицы (чтобы корректно пересобрать data_editor),
|
| 734 |
-
# но выбор переносится через selected_regnorms выше
|
| 735 |
st.session_state.search_id += 1
|
| 736 |
|
| 737 |
df_ui_saved = st.session_state.last_df_ui
|
|
@@ -754,19 +778,24 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
|
| 754 |
full_column_config = {
|
| 755 |
"Выбрать": st.column_config.CheckboxColumn("Выбрать", width="small"),
|
| 756 |
"Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"),
|
| 757 |
-
"ФИО": st.column_config.
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 759 |
"Организация": st.column_config.TextColumn("Организация", width="large"),
|
| 760 |
"Тип": st.column_config.TextColumn("Тип", width="small"),
|
| 761 |
"Год": st.column_config.NumberColumn("Год", format="%.0f", width="small"),
|
| 762 |
"Регистрационный номер": st.column_config.TextColumn("Регистрационный номер", width="small"),
|
| 763 |
-
"OpenAlex": st.column_config.LinkColumn(
|
| 764 |
-
"OpenAlex",
|
| 765 |
-
display_text=r"https://openalex\.org/(A\d+)",
|
| 766 |
-
width="small",
|
| 767 |
-
help="Профиль автора в OpenAlex (если найден).",
|
| 768 |
-
validate=r"^https://openalex\.org/A\d+$|^$",
|
| 769 |
-
),
|
| 770 |
"h-index": st.column_config.NumberColumn("h-index", format="%.0f", width="small"),
|
| 771 |
"i10-index": st.column_config.NumberColumn("i10-index", format="%.0f", width="small"),
|
| 772 |
"Работ": st.column_config.NumberColumn("Работ", format="%.0f", width="small"),
|
|
@@ -806,7 +835,6 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
|
| 806 |
orcid = _safe_text(raw.get("orcid_url"))
|
| 807 |
oa = _safe_text(raw.get("openalex_url"))
|
| 808 |
|
| 809 |
-
# Ссылки показываем ТОЛЬКО если они реально есть (ничего не "выдумываем")
|
| 810 |
links = []
|
| 811 |
if vak:
|
| 812 |
links.append(f"[ВАК]({vak})")
|
|
|
|
| 63 |
"registration_number",
|
| 64 |
]
|
| 65 |
|
| 66 |
+
|
| 67 |
# ==========================
|
| 68 |
# UI: КОЛОНКИ ТАБЛИЦЫ + НАСТРОЙКИ ОТОБРАЖЕНИЯ
|
| 69 |
+
# ВАЖНО:
|
| 70 |
+
# - отдельного столбца OpenAlex НЕТ
|
| 71 |
+
# - ФИО кликабельно и ведёт на OpenAlex (если есть), иначе на ORCID (если есть), иначе на ВАК (если есть)
|
| 72 |
+
# - Название диссертации кликабельно и ведёт на ВАК
|
| 73 |
# ==========================
|
| 74 |
|
| 75 |
UI_TABLE_COLUMNS = [
|
|
|
|
| 80 |
"Тип",
|
| 81 |
"Год",
|
| 82 |
"Регистрационный номер",
|
|
|
|
| 83 |
"h-index",
|
| 84 |
"i10-index",
|
| 85 |
"Работ",
|
| 86 |
"Цитат",
|
| 87 |
]
|
| 88 |
+
|
| 89 |
+
# По умолчанию: все включены, кроме "Организация" и "Тип"
|
| 90 |
+
DEFAULT_VISIBLE_UI = {c: (c != "Организация" and c != "Тип") for c in UI_TABLE_COLUMNS}
|
| 91 |
|
| 92 |
|
| 93 |
# ==========================
|
|
|
|
| 119 |
.stDataFrame { font-size: 80% !important; }
|
| 120 |
.gdg-w, .gdg-canvas { font-size: 80% !important; }
|
| 121 |
|
|
|
|
| 122 |
div[data-testid="stDataEditor"] { font-size: 80% !important; }
|
| 123 |
div[data-testid="stDataEditor"] * { font-size: 80% !important; }
|
| 124 |
</style>
|
|
|
|
| 149 |
return s
|
| 150 |
|
| 151 |
|
| 152 |
+
def _safe_fragment(s: Any) -> str:
|
| 153 |
+
# фрагмент для вставки после # в "url#text" — убираем опасные символы
|
| 154 |
+
t = _safe_text(s).replace("#", " ").replace("\n", " ").replace("\r", " ").strip()
|
| 155 |
+
return t
|
| 156 |
+
|
| 157 |
+
|
| 158 |
def _norm_openalex_url(x: Any) -> str:
|
| 159 |
s = _safe_text(x)
|
| 160 |
if not s:
|
|
|
|
| 459 |
|
| 460 |
protection_year = extract_year_int(meta.get("protection_date", None))
|
| 461 |
|
|
|
|
| 462 |
org_short = meta.get("author_org_short", None)
|
| 463 |
if (
|
| 464 |
org_short is None
|
|
|
|
| 499 |
):
|
| 500 |
mask = build_filter_mask(candidate_selected, doctor_selected, science_selected, year_range)
|
| 501 |
|
| 502 |
+
# С запасом, чтобы после OA/ORCID фильтров осталось до top_k
|
| 503 |
prefetch_k = min(max(int(top_k) * 5, int(top_k)), 500)
|
| 504 |
results = search_core(query, prefetch_k, mask=mask)
|
| 505 |
|
|
|
|
| 514 |
out.seek(0)
|
| 515 |
return empty_ui, out, df_raw
|
| 516 |
|
|
|
|
| 517 |
df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum)
|
| 518 |
|
| 519 |
# OA/ORCID enrichment
|
|
|
|
| 540 |
df_raw["works_count"] = np.nan
|
| 541 |
df_raw["cited_by_count"] = np.nan
|
| 542 |
|
| 543 |
+
# Фильтры "только с ..."
|
| 544 |
if only_openalex:
|
| 545 |
df_raw = df_raw[df_raw["openalex_url"].map(_safe_text) != ""]
|
| 546 |
if only_orcid:
|
| 547 |
df_raw = df_raw[df_raw["orcid_url"].map(_safe_text) != ""]
|
| 548 |
|
|
|
|
| 549 |
df_raw = df_raw.reset_index(drop=True)
|
| 550 |
if len(df_raw) > int(top_k):
|
| 551 |
df_raw = df_raw.iloc[: int(top_k)].copy()
|
| 552 |
|
| 553 |
+
# Дедуп и индекс-идентификатор для стабильного выбора
|
| 554 |
df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum)
|
| 555 |
df_raw = df_raw.drop_duplicates(subset=["reg_norm"]).set_index("reg_norm", drop=True)
|
| 556 |
|
| 557 |
+
# Перенумерация "№"
|
| 558 |
if "№" in df_raw.columns:
|
| 559 |
df_raw["№"] = np.arange(1, len(df_raw) + 1)
|
| 560 |
|
| 561 |
+
# ====== UI значения со ссылками без "выдумывания" ======
|
| 562 |
+
fio_txt = df_raw["fio"].map(_safe_text)
|
| 563 |
+
fio_frag = fio_txt.map(_safe_fragment)
|
| 564 |
+
|
| 565 |
+
oa_url = df_raw["openalex_url"].map(_safe_text)
|
| 566 |
+
orcid_url = df_raw["orcid_url"].map(_safe_text)
|
| 567 |
+
vak_url = df_raw["vak_link"].map(_safe_text)
|
| 568 |
+
|
| 569 |
+
# Приоритет: OpenAlex -> ORCID -> ВАК
|
| 570 |
+
fio_href = np.where(
|
| 571 |
+
oa_url != "",
|
| 572 |
+
oa_url,
|
| 573 |
+
np.where(orcid_url != "", orcid_url, vak_url),
|
| 574 |
+
)
|
| 575 |
+
# Делаем "url#ФИО" (чтобы показывать ФИО как display_text)
|
| 576 |
+
fio_cell = np.where(fio_href != "", fio_href + "#" + fio_frag, fio_txt)
|
| 577 |
+
|
| 578 |
+
title_txt = df_raw["title"].map(_safe_text)
|
| 579 |
+
title_frag = title_txt.map(_safe_fragment)
|
| 580 |
+
# Название всегда ведёт на ВАК (если vak_link пуст — будет просто текст)
|
| 581 |
+
title_cell = np.where(vak_url != "", vak_url + "#" + title_frag, title_txt)
|
| 582 |
+
|
| 583 |
df_ui = pd.DataFrame(
|
| 584 |
{
|
| 585 |
"Сходство": pd.to_numeric(df_raw["score"], errors="coerce").astype("float64"),
|
| 586 |
+
"ФИО": fio_cell,
|
| 587 |
+
"Название диссертации": title_cell,
|
| 588 |
"Организация": df_raw["author_org_short"].map(_safe_text),
|
| 589 |
"Тип": df_raw["dissertation_type"].map(_safe_text),
|
| 590 |
"Год": pd.to_numeric(df_raw["protection_year"], errors="coerce").astype("float64"),
|
| 591 |
"Регистрационный номер": df_raw["registration_number"].map(_safe_text),
|
|
|
|
| 592 |
"h-index": df_raw["h_index"],
|
| 593 |
"i10-index": df_raw["i10_index"],
|
| 594 |
"Работ": df_raw["works_count"],
|
|
|
|
| 611 |
def format_selected_list_from_raw(df_raw: pd.DataFrame, selected_regnorms: List[str]) -> str:
|
| 612 |
lines = []
|
| 613 |
for reg_norm in selected_regnorms:
|
| 614 |
+
if df_raw is None or reg_norm not in df_raw.index:
|
| 615 |
continue
|
| 616 |
+
|
| 617 |
raw = df_raw.loc[reg_norm]
|
| 618 |
fio = _safe_text(raw.get("fio"))
|
| 619 |
title = _safe_text(raw.get("title"))
|
|
|
|
| 653 |
if "last_excel" not in st.session_state:
|
| 654 |
st.session_state.last_excel = None
|
| 655 |
|
| 656 |
+
# Выбор диссертаций хранится по reg_norm (стабильный ключ) — не сбрасывается при перерисовке
|
| 657 |
if "selected_regnorms" not in st.session_state:
|
| 658 |
st.session_state.selected_regnorms = set()
|
| 659 |
|
| 660 |
+
# Ключ для data_editor (обновляем на новый поиск)
|
| 661 |
if "search_id" not in st.session_state:
|
| 662 |
st.session_state.search_id = 0
|
| 663 |
|
|
|
|
| 728 |
# Выбранные для отображения колонки (после формы)
|
| 729 |
visible_ui_cols = [c for c in UI_TABLE_COLUMNS if st.session_state.get(_show_col_key(c), True)]
|
| 730 |
if not visible_ui_cols:
|
| 731 |
+
visible_ui_cols = [c for c in UI_TABLE_COLUMNS if c not in {"Организация", "Тип"}]
|
| 732 |
|
| 733 |
if do_search:
|
| 734 |
if not candidate_selected and not doctor_selected:
|
|
|
|
| 750 |
st.session_state.last_df_raw = df_raw
|
| 751 |
st.session_state.last_excel = excel_bytes
|
| 752 |
|
| 753 |
+
# Выбор не сбрасываем — оставляем только те, что присутствуют в новых результатах
|
| 754 |
if isinstance(df_ui, pd.DataFrame) and not df_ui.empty:
|
| 755 |
st.session_state.selected_regnorms = set(st.session_state.selected_regnorms) & set(df_ui.index)
|
| 756 |
else:
|
| 757 |
st.session_state.selected_regnorms = set()
|
| 758 |
|
|
|
|
|
|
|
| 759 |
st.session_state.search_id += 1
|
| 760 |
|
| 761 |
df_ui_saved = st.session_state.last_df_ui
|
|
|
|
| 778 |
full_column_config = {
|
| 779 |
"Выбрать": st.column_config.CheckboxColumn("Выбрать", width="small"),
|
| 780 |
"Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"),
|
| 781 |
+
"ФИО": st.column_config.LinkColumn(
|
| 782 |
+
"ФИО",
|
| 783 |
+
display_text=r"(?:.*#)?(.*)$",
|
| 784 |
+
width="medium",
|
| 785 |
+
help="Если найден OpenAlex — ведёт на OpenAlex; иначе (если есть) на ORCID; иначе (если есть) на ВАК.",
|
| 786 |
+
validate=r"^https?://.+#.+$|^.+$",
|
| 787 |
+
),
|
| 788 |
+
"Название диссертации": st.column_config.LinkColumn(
|
| 789 |
+
"Название диссертации",
|
| 790 |
+
display_text=r"(?:.*#)?(.*)$",
|
| 791 |
+
width="large",
|
| 792 |
+
help="Название ведёт на ВАК (если ссылка есть).",
|
| 793 |
+
validate=r"^https?://.+#.+$|^.+$",
|
| 794 |
+
),
|
| 795 |
"Организация": st.column_config.TextColumn("Организация", width="large"),
|
| 796 |
"Тип": st.column_config.TextColumn("Тип", width="small"),
|
| 797 |
"Год": st.column_config.NumberColumn("Год", format="%.0f", width="small"),
|
| 798 |
"Регистрационный номер": st.column_config.TextColumn("Регистрационный номер", width="small"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 799 |
"h-index": st.column_config.NumberColumn("h-index", format="%.0f", width="small"),
|
| 800 |
"i10-index": st.column_config.NumberColumn("i10-index", format="%.0f", width="small"),
|
| 801 |
"Работ": st.column_config.NumberColumn("Работ", format="%.0f", width="small"),
|
|
|
|
| 835 |
orcid = _safe_text(raw.get("orcid_url"))
|
| 836 |
oa = _safe_text(raw.get("openalex_url"))
|
| 837 |
|
|
|
|
| 838 |
links = []
|
| 839 |
if vak:
|
| 840 |
links.append(f"[ВАК]({vak})")
|