Update app.py
Browse files
app.py
CHANGED
|
@@ -30,8 +30,9 @@ HF_REQUESTS_REPO = os.getenv("HF_REQUESTS_REPO", "PlanetExpress2125/PostDocReque
|
|
| 30 |
HF_REQUESTS_REPO_TYPE = os.getenv("HF_REQUESTS_REPO_TYPE", "dataset") # dataset | model
|
| 31 |
HF_WRITE_TOKEN = os.getenv("HF_WRITE_TOKEN") # write token (рекомендуется)
|
| 32 |
|
| 33 |
-
# OpenAlex index
|
| 34 |
OA_INDEX_REPO = os.getenv("OA_INDEX_REPO", "yogl/diss_authors_with_oa_index")
|
|
|
|
| 35 |
|
| 36 |
SLIDER_MIN_YEAR = 2005 # нижняя граница диапазона лет
|
| 37 |
|
|
@@ -46,6 +47,11 @@ COLUMN_LABELS_RU_EXCEL = {
|
|
| 46 |
"registration_number": "Регистрационный номер",
|
| 47 |
"vak_link": "Ссылка ВАК",
|
| 48 |
"openalex_url": "OpenAlex",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
DISPLAY_COLUMNS_ALL = [
|
|
@@ -71,7 +77,6 @@ if HF_TOKEN is None:
|
|
| 71 |
)
|
| 72 |
st.stop()
|
| 73 |
|
| 74 |
-
# HF_TOKEN нужен для чтения приватных датасетов
|
| 75 |
try:
|
| 76 |
login(token=HF_TOKEN)
|
| 77 |
except Exception:
|
|
@@ -106,14 +111,8 @@ SCIENCE_LABELS = [
|
|
| 106 |
]
|
| 107 |
SCIENCE_LABELS = sorted(list(dict.fromkeys(SCIENCE_LABELS)), key=lambda s: s.casefold())
|
| 108 |
|
| 109 |
-
DEFAULT_SCIENCES = {
|
| 110 |
-
"Технические",
|
| 111 |
-
"Физико-математические",
|
| 112 |
-
"Химические",
|
| 113 |
-
"Биологические",
|
| 114 |
-
}
|
| 115 |
|
| 116 |
-
# Паттерны для сопоставления с degree_pursued (на случай разных формулировок в базе)
|
| 117 |
SCIENCE_PATTERNS = {
|
| 118 |
"Архитектура": ["архитектур"],
|
| 119 |
"Биологические": ["биолог"],
|
|
@@ -147,14 +146,6 @@ def _keyify(label: str) -> str:
|
|
| 147 |
# ==========================
|
| 148 |
|
| 149 |
def save_request_to_hub(payload: dict) -> str:
|
| 150 |
-
"""
|
| 151 |
-
Сохраняем каждую заявку отдельным JSON-файлом в:
|
| 152 |
-
<HF_REQUESTS_REPO>/requests/<timestamp>_<uuid>.json
|
| 153 |
-
|
| 154 |
-
Требуется токен с правами write:
|
| 155 |
-
- HF_WRITE_TOKEN (рекомендуется)
|
| 156 |
-
- если не задан, будет использован HF_TOKEN (если он write)
|
| 157 |
-
"""
|
| 158 |
token = HF_WRITE_TOKEN or HF_TOKEN
|
| 159 |
if not token:
|
| 160 |
raise RuntimeError("Не задан HF_WRITE_TOKEN (и нет HF_TOKEN).")
|
|
@@ -178,7 +169,7 @@ def save_request_to_hub(payload: dict) -> str:
|
|
| 178 |
|
| 179 |
|
| 180 |
# ==========================
|
| 181 |
-
# OPENALEX
|
| 182 |
# ==========================
|
| 183 |
|
| 184 |
def _normalize_regnum(x: Any) -> Optional[str]:
|
|
@@ -191,12 +182,6 @@ def _normalize_regnum(x: Any) -> Optional[str]:
|
|
| 191 |
|
| 192 |
|
| 193 |
def _iter_registration_numbers(value: Any) -> List[str]:
|
| 194 |
-
"""
|
| 195 |
-
registration_numbers может быть:
|
| 196 |
-
- list/tuple
|
| 197 |
-
- строкой (один номер или несколько через , ; пробел)
|
| 198 |
-
- строкой вида "['123','456']"
|
| 199 |
-
"""
|
| 200 |
if value is None:
|
| 201 |
return []
|
| 202 |
if isinstance(value, (list, tuple, np.ndarray)):
|
|
@@ -211,7 +196,7 @@ def _iter_registration_numbers(value: Any) -> List[str]:
|
|
| 211 |
if not s or s.lower() == "nan":
|
| 212 |
return []
|
| 213 |
|
| 214 |
-
# попытка распарсить список
|
| 215 |
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
| 216 |
try:
|
| 217 |
parsed = json.loads(s)
|
|
@@ -220,7 +205,6 @@ def _iter_registration_numbers(value: Any) -> List[str]:
|
|
| 220 |
except Exception:
|
| 221 |
pass
|
| 222 |
|
| 223 |
-
# fallback: split
|
| 224 |
parts = re.split(r"[,\s;]+", s)
|
| 225 |
return [r for r in (_normalize_regnum(p) for p in parts) if r]
|
| 226 |
|
|
@@ -231,30 +215,58 @@ def _normalize_oa_url(value: Any) -> str:
|
|
| 231 |
s = str(value).strip()
|
| 232 |
if not s or s.lower() == "nan":
|
| 233 |
return ""
|
| 234 |
-
# если уже URL
|
| 235 |
if s.startswith("http://") or s.startswith("https://"):
|
| 236 |
-
# нормализуем домен/слеши
|
| 237 |
m = re.search(r"(A\d+)", s)
|
| 238 |
if m:
|
| 239 |
return f"https://openalex.org/{m.group(1)}"
|
| 240 |
return s
|
| 241 |
-
# если только ID
|
| 242 |
-
m = re.match(r"^A\d+$", s)
|
| 243 |
-
if m:
|
| 244 |
-
return f"https://openalex.org/{s}"
|
| 245 |
-
# если вдруг мусор — попробуем вытащить A...
|
| 246 |
m = re.search(r"(A\d+)", s)
|
| 247 |
if m:
|
| 248 |
return f"https://openalex.org/{m.group(1)}"
|
| 249 |
return ""
|
| 250 |
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
try:
|
| 259 |
ds = load_dataset(OA_INDEX_REPO, split="train")
|
| 260 |
df = ds.to_pandas()
|
|
@@ -268,16 +280,76 @@ def load_openalex_map() -> Dict[str, str]:
|
|
| 268 |
for _, row in df.iterrows():
|
| 269 |
regs = row.get("registration_numbers", None)
|
| 270 |
oa = row.get("oa_author_id", None)
|
| 271 |
-
|
| 272 |
-
if not
|
| 273 |
continue
|
| 274 |
for reg in _iter_registration_numbers(regs):
|
| 275 |
if reg and reg not in mapping:
|
| 276 |
-
mapping[reg] =
|
| 277 |
return mapping
|
| 278 |
|
| 279 |
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
|
| 282 |
|
| 283 |
# ==========================
|
|
@@ -286,13 +358,11 @@ OA_MAP = load_openalex_map()
|
|
| 286 |
|
| 287 |
@st.cache_data(show_spinner="Загрузка данных...")
|
| 288 |
def load_data():
|
| 289 |
-
# 1) МЕТА-ДАННЫЕ
|
| 290 |
ds_meta = load_dataset(HF_MERGED_REPO, split="train")
|
| 291 |
df_meta = ds_meta.to_pandas()
|
| 292 |
df_meta["registration_number"] = df_meta["registration_number"].astype(str)
|
| 293 |
df_meta = df_meta.set_index("registration_number", drop=False)
|
| 294 |
|
| 295 |
-
# 2) ЭМБЕДДИНГИ
|
| 296 |
ds_emb = load_dataset(HF_EMB_REPO, split="train")
|
| 297 |
df_emb = ds_emb.to_pandas()
|
| 298 |
df_emb["registration_number"] = df_emb["registration_number"].astype(str)
|
|
@@ -303,19 +373,15 @@ def load_data():
|
|
| 303 |
norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True)
|
| 304 |
emb_matrix = emb_matrix / np.maximum(norms, 1e-8)
|
| 305 |
|
| 306 |
-
# выравниваем meta под reg_nums
|
| 307 |
meta_aligned = df_meta.reindex(reg_nums)
|
| 308 |
|
| 309 |
-
# dissertation_type masks
|
| 310 |
type_s = meta_aligned.get("dissertation_type", pd.Series([""] * len(reg_nums))).fillna("").astype(str)
|
| 311 |
is_candidate = type_s.str.contains("кандид", case=False, na=False).to_numpy()
|
| 312 |
is_doctor = type_s.str.contains("доктор", case=False, na=False).to_numpy()
|
| 313 |
|
| 314 |
-
# degree_pursued lower
|
| 315 |
deg_s = meta_aligned.get("degree_pursued", pd.Series([""] * len(reg_nums))).fillna("").astype(str)
|
| 316 |
degree_lower = np.char.lower(deg_s.to_numpy().astype(str))
|
| 317 |
|
| 318 |
-
# years
|
| 319 |
if "protection_date" in meta_aligned.columns:
|
| 320 |
dt = pd.to_datetime(meta_aligned["protection_date"], errors="coerce")
|
| 321 |
year_arr = dt.dt.year.astype("float").to_numpy()
|
|
@@ -362,15 +428,13 @@ def build_filter_mask(
|
|
| 362 |
) -> np.ndarray:
|
| 363 |
mask = np.ones(len(reg_nums), dtype=bool)
|
| 364 |
|
| 365 |
-
# Тип диссертации
|
| 366 |
type_mask = np.zeros(len(reg_nums), dtype=bool)
|
| 367 |
if candidate_selected:
|
| 368 |
type_mask |= is_candidate
|
| 369 |
if doctor_selected:
|
| 370 |
type_mask |= is_doctor
|
| 371 |
-
mask &= type_mask
|
| 372 |
|
| 373 |
-
# Науки
|
| 374 |
if science_selected:
|
| 375 |
sci_mask = np.zeros(len(reg_nums), dtype=bool)
|
| 376 |
for label in science_selected:
|
|
@@ -378,7 +442,6 @@ def build_filter_mask(
|
|
| 378 |
sci_mask |= _contains_any(degree_lower, patterns)
|
| 379 |
mask &= sci_mask
|
| 380 |
|
| 381 |
-
# Годы (NaN пропускаем)
|
| 382 |
if year_range is not None:
|
| 383 |
y0, y1 = int(year_range[0]), int(year_range[1])
|
| 384 |
yr = year_arr
|
|
@@ -479,12 +542,25 @@ def run_search(
|
|
| 479 |
results = search_core(query, top_k, mask=mask)
|
| 480 |
df_raw = build_result_df(results)
|
| 481 |
|
| 482 |
-
#
|
| 483 |
-
if not df_raw.empty and
|
| 484 |
-
df_raw["openalex_url"] = df_raw["registration_number"].astype(str).map(
|
| 485 |
else:
|
| 486 |
df_raw["openalex_url"] = ""
|
| 487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
# UI: скрываем №, reg номер, тип
|
| 489 |
df_ui = df_raw.copy().reset_index(drop=True)
|
| 490 |
df_ui = df_ui.rename(
|
|
@@ -496,14 +572,18 @@ def run_search(
|
|
| 496 |
"protection_year": "Год",
|
| 497 |
"vak_link": "ВАК",
|
| 498 |
"openalex_url": "OpenAlex",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
}
|
| 500 |
)
|
| 501 |
df_ui = df_ui.drop(columns=["№", "registration_number", "dissertation_type"])
|
| 502 |
|
| 503 |
-
# Excel: полный набор +
|
| 504 |
df_excel = df_raw.copy()
|
| 505 |
df_excel_ru = df_excel.rename(columns=COLUMN_LABELS_RU_EXCEL)
|
| 506 |
-
|
| 507 |
output = io.BytesIO()
|
| 508 |
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
|
| 509 |
df_excel_ru.to_excel(writer, index=False)
|
|
@@ -514,8 +594,8 @@ def run_search(
|
|
| 514 |
|
| 515 |
def format_selected_list_for_ui(df_ui_show: pd.DataFrame, selected_idx: List[int]) -> str:
|
| 516 |
lines = []
|
| 517 |
-
for
|
| 518 |
-
r = df_ui_show.
|
| 519 |
fio = str(r.get("ФИО", "")).strip()
|
| 520 |
title = str(r.get("Название диссертации", "")).strip()
|
| 521 |
year = r.get("Год", "")
|
|
@@ -618,38 +698,55 @@ excel_saved = st.session_state.last_excel
|
|
| 618 |
if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
| 619 |
st.success(f"Найдено записей: {len(df_ui_saved)}")
|
| 620 |
|
|
|
|
| 621 |
df_ui_show = df_ui_saved[
|
| 622 |
-
["Сходство", "ФИО", "Название диссертации", "Организация", "Год",
|
|
|
|
| 623 |
].copy()
|
|
|
|
|
|
|
| 624 |
|
| 625 |
-
#
|
|
|
|
| 626 |
event = st.dataframe(
|
| 627 |
df_ui_show,
|
| 628 |
use_container_width=True,
|
| 629 |
hide_index=True,
|
| 630 |
-
column_order=[
|
|
|
|
|
|
|
|
|
|
| 631 |
column_config={
|
| 632 |
"Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 633 |
"ФИО": st.column_config.TextColumn("ФИО", width="medium"),
|
| 634 |
"Название диссертации": st.column_config.TextColumn("Название диссертации", width="large"),
|
| 635 |
"Организация": st.column_config.TextColumn("Организация", width="medium"),
|
| 636 |
"Год": st.column_config.NumberColumn("Год", width="small"),
|
| 637 |
"ВАК": st.column_config.LinkColumn("ВАК", display_text="открыть", width="small"),
|
| 638 |
-
# Отображаем ID (A123...) из URL через regex capture group
|
| 639 |
"OpenAlex": st.column_config.LinkColumn(
|
| 640 |
"OpenAlex",
|
| 641 |
display_text=r"https://openalex\.org/(A\d+)",
|
| 642 |
width="small",
|
| 643 |
help="Профиль автора в OpenAlex (если найден)",
|
| 644 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
},
|
| 646 |
on_select="rerun",
|
| 647 |
selection_mode="multi-row",
|
| 648 |
)
|
| 649 |
|
| 650 |
-
# сохраняем выбранные строки (позиции 0..N-1 исходного df_ui_show)
|
| 651 |
sel = getattr(event, "selection", None)
|
| 652 |
if sel is not None and sel.rows is not None:
|
|
|
|
| 653 |
st.session_state.selected_rows = list(sel.rows)
|
| 654 |
|
| 655 |
if excel_saved is not None:
|
|
@@ -678,7 +775,7 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
|
| 678 |
|
| 679 |
comment = st.text_area(
|
| 680 |
"Комментарий",
|
| 681 |
-
height=
|
| 682 |
placeholder=(
|
| 683 |
"Какую дополнительную информацию по авторам диссертаций вы хотите получить?\n"
|
| 684 |
"Какие замечания/пожелания по функционалу системы?"
|
|
@@ -693,10 +790,10 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
|
| 693 |
elif len(selected_rows) == 0:
|
| 694 |
st.warning("Выберите хотя бы одну диссертацию в таблице (чекбоксами слева).")
|
| 695 |
else:
|
| 696 |
-
# items по выбранным строкам
|
| 697 |
items = []
|
| 698 |
-
for
|
| 699 |
-
|
|
|
|
| 700 |
items.append(
|
| 701 |
{
|
| 702 |
"author_fio": raw.get("fio"),
|
|
@@ -707,6 +804,11 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
|
| 707 |
"registration_number": raw.get("registration_number"),
|
| 708 |
"score": raw.get("score"),
|
| 709 |
"openalex_url": raw.get("openalex_url", ""),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
}
|
| 711 |
)
|
| 712 |
|
|
@@ -734,7 +836,6 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
|
| 734 |
else:
|
| 735 |
st.info("Введите запрос и нажмите «Поиск». После этого мо��но выбрать диссертации и отправить запрос.")
|
| 736 |
|
| 737 |
-
# Футер
|
| 738 |
st.markdown(
|
| 739 |
"<p style='font-size: 0.8rem; text-align: right; color: gray;'>(с) Антон Лощилов, 2025</p>",
|
| 740 |
unsafe_allow_html=True,
|
|
|
|
| 30 |
HF_REQUESTS_REPO_TYPE = os.getenv("HF_REQUESTS_REPO_TYPE", "dataset") # dataset | model
|
| 31 |
HF_WRITE_TOKEN = os.getenv("HF_WRITE_TOKEN") # write token (рекомендуется)
|
| 32 |
|
| 33 |
+
# OpenAlex index datasets
|
| 34 |
OA_INDEX_REPO = os.getenv("OA_INDEX_REPO", "yogl/diss_authors_with_oa_index")
|
| 35 |
+
OA_AUTHORS_REPO = os.getenv("OA_AUTHORS_REPO", "yogl/oa_authors_rus")
|
| 36 |
|
| 37 |
SLIDER_MIN_YEAR = 2005 # нижняя граница диапазона лет
|
| 38 |
|
|
|
|
| 47 |
"registration_number": "Регистрационный номер",
|
| 48 |
"vak_link": "Ссылка ВАК",
|
| 49 |
"openalex_url": "OpenAlex",
|
| 50 |
+
"orcid_url": "ORCID",
|
| 51 |
+
"h_index": "h-index",
|
| 52 |
+
"i10_index": "i10-index",
|
| 53 |
+
"works_count": "Работ",
|
| 54 |
+
"cited_by_count": "Цитат",
|
| 55 |
}
|
| 56 |
|
| 57 |
DISPLAY_COLUMNS_ALL = [
|
|
|
|
| 77 |
)
|
| 78 |
st.stop()
|
| 79 |
|
|
|
|
| 80 |
try:
|
| 81 |
login(token=HF_TOKEN)
|
| 82 |
except Exception:
|
|
|
|
| 111 |
]
|
| 112 |
SCIENCE_LABELS = sorted(list(dict.fromkeys(SCIENCE_LABELS)), key=lambda s: s.casefold())
|
| 113 |
|
| 114 |
+
DEFAULT_SCIENCES = {"Технические", "Физико-математические", "Химические", "Биологические"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
|
|
|
| 116 |
SCIENCE_PATTERNS = {
|
| 117 |
"Архитектура": ["архитектур"],
|
| 118 |
"Биологические": ["биолог"],
|
|
|
|
| 146 |
# ==========================
|
| 147 |
|
| 148 |
def save_request_to_hub(payload: dict) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
token = HF_WRITE_TOKEN or HF_TOKEN
|
| 150 |
if not token:
|
| 151 |
raise RuntimeError("Не задан HF_WRITE_TOKEN (и нет HF_TOKEN).")
|
|
|
|
| 169 |
|
| 170 |
|
| 171 |
# ==========================
|
| 172 |
+
# OPENALEX: REG->AUTHOR + AUTHOR STATS
|
| 173 |
# ==========================
|
| 174 |
|
| 175 |
def _normalize_regnum(x: Any) -> Optional[str]:
|
|
|
|
| 182 |
|
| 183 |
|
| 184 |
def _iter_registration_numbers(value: Any) -> List[str]:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
if value is None:
|
| 186 |
return []
|
| 187 |
if isinstance(value, (list, tuple, np.ndarray)):
|
|
|
|
| 196 |
if not s or s.lower() == "nan":
|
| 197 |
return []
|
| 198 |
|
| 199 |
+
# попытка распарсить json-список
|
| 200 |
if (s.startswith("[") and s.endswith("]")) or (s.startswith("(") and s.endswith(")")):
|
| 201 |
try:
|
| 202 |
parsed = json.loads(s)
|
|
|
|
| 205 |
except Exception:
|
| 206 |
pass
|
| 207 |
|
|
|
|
| 208 |
parts = re.split(r"[,\s;]+", s)
|
| 209 |
return [r for r in (_normalize_regnum(p) for p in parts) if r]
|
| 210 |
|
|
|
|
| 215 |
s = str(value).strip()
|
| 216 |
if not s or s.lower() == "nan":
|
| 217 |
return ""
|
|
|
|
| 218 |
if s.startswith("http://") or s.startswith("https://"):
|
|
|
|
| 219 |
m = re.search(r"(A\d+)", s)
|
| 220 |
if m:
|
| 221 |
return f"https://openalex.org/{m.group(1)}"
|
| 222 |
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
m = re.search(r"(A\d+)", s)
|
| 224 |
if m:
|
| 225 |
return f"https://openalex.org/{m.group(1)}"
|
| 226 |
return ""
|
| 227 |
|
| 228 |
|
| 229 |
+
def _normalize_orcid_url(value: Any) -> str:
|
| 230 |
+
if value is None:
|
| 231 |
+
return ""
|
| 232 |
+
s = str(value).strip()
|
| 233 |
+
if not s or s.lower() == "nan":
|
| 234 |
+
return ""
|
| 235 |
+
if s.startswith("http://") or s.startswith("https://"):
|
| 236 |
+
m = re.search(r"(0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4})", s)
|
| 237 |
+
if m:
|
| 238 |
+
return f"https://orcid.org/{m.group(1)}"
|
| 239 |
+
return s
|
| 240 |
+
m = re.search(r"(0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4})", s)
|
| 241 |
+
if m:
|
| 242 |
+
return f"https://orcid.org/{m.group(1)}"
|
| 243 |
+
return ""
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _safe_get_stats(summary_stats: Any) -> Tuple[Optional[int], Optional[int]]:
|
| 247 |
+
# ожидается dict {"h_index":..., "i10_index":...}
|
| 248 |
+
try:
|
| 249 |
+
if isinstance(summary_stats, dict):
|
| 250 |
+
h = summary_stats.get("h_index", None)
|
| 251 |
+
i10 = summary_stats.get("i10_index", None)
|
| 252 |
+
h = int(h) if h is not None and str(h).strip() != "" else None
|
| 253 |
+
i10 = int(i10) if i10 is not None and str(i10).strip() != "" else None
|
| 254 |
+
return h, i10
|
| 255 |
+
if isinstance(summary_stats, str) and summary_stats.strip():
|
| 256 |
+
# иногда может быть строкой json
|
| 257 |
+
try:
|
| 258 |
+
d = json.loads(summary_stats)
|
| 259 |
+
if isinstance(d, dict):
|
| 260 |
+
return _safe_get_stats(d)
|
| 261 |
+
except Exception:
|
| 262 |
+
pass
|
| 263 |
+
except Exception:
|
| 264 |
+
pass
|
| 265 |
+
return None, None
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
@st.cache_data(show_spinner="Загрузка индекса OpenAlex (registration_number → openalex)...")
|
| 269 |
+
def load_openalex_reg_map() -> Dict[str, str]:
|
| 270 |
try:
|
| 271 |
ds = load_dataset(OA_INDEX_REPO, split="train")
|
| 272 |
df = ds.to_pandas()
|
|
|
|
| 280 |
for _, row in df.iterrows():
|
| 281 |
regs = row.get("registration_numbers", None)
|
| 282 |
oa = row.get("oa_author_id", None)
|
| 283 |
+
oa_url = _normalize_oa_url(oa)
|
| 284 |
+
if not oa_url:
|
| 285 |
continue
|
| 286 |
for reg in _iter_registration_numbers(regs):
|
| 287 |
if reg and reg not in mapping:
|
| 288 |
+
mapping[reg] = oa_url
|
| 289 |
return mapping
|
| 290 |
|
| 291 |
|
| 292 |
+
@st.cache_data(show_spinner="Загрузка базы OpenAlex authors (oa_url → stats)...")
|
| 293 |
+
def load_openalex_authors_map() -> Dict[str, Dict[str, Any]]:
|
| 294 |
+
"""
|
| 295 |
+
Маппинг по ключу id (пример: https://openalex.org/A5070718545)
|
| 296 |
+
"""
|
| 297 |
+
try:
|
| 298 |
+
ds = load_dataset(OA_AUTHORS_REPO, split="train")
|
| 299 |
+
df = ds.to_pandas()
|
| 300 |
+
except Exception:
|
| 301 |
+
return {}
|
| 302 |
+
|
| 303 |
+
if "id" not in df.columns:
|
| 304 |
+
return {}
|
| 305 |
+
|
| 306 |
+
need_cols = [c for c in ["id", "orcid", "works_count", "cited_by_count", "summary_stats", "ids"] if c in df.columns]
|
| 307 |
+
df = df[need_cols].copy()
|
| 308 |
+
|
| 309 |
+
out: Dict[str, Dict[str, Any]] = {}
|
| 310 |
+
for _, row in df.iterrows():
|
| 311 |
+
oa_url = _normalize_oa_url(row.get("id"))
|
| 312 |
+
if not oa_url:
|
| 313 |
+
continue
|
| 314 |
+
|
| 315 |
+
orcid_url = _normalize_orcid_url(row.get("orcid"))
|
| 316 |
+
if not orcid_url:
|
| 317 |
+
ids = row.get("ids", None)
|
| 318 |
+
if isinstance(ids, dict):
|
| 319 |
+
orcid_url = _normalize_orcid_url(ids.get("orcid"))
|
| 320 |
+
elif isinstance(ids, str) and ids.strip():
|
| 321 |
+
try:
|
| 322 |
+
ids_d = json.loads(ids)
|
| 323 |
+
if isinstance(ids_d, dict):
|
| 324 |
+
orcid_url = _normalize_orcid_url(ids_d.get("orcid"))
|
| 325 |
+
except Exception:
|
| 326 |
+
pass
|
| 327 |
+
|
| 328 |
+
h, i10 = _safe_get_stats(row.get("summary_stats", None))
|
| 329 |
+
|
| 330 |
+
wc = row.get("works_count", None)
|
| 331 |
+
cb = row.get("cited_by_count", None)
|
| 332 |
+
try:
|
| 333 |
+
wc = int(wc) if wc is not None and str(wc).strip() != "" else None
|
| 334 |
+
except Exception:
|
| 335 |
+
wc = None
|
| 336 |
+
try:
|
| 337 |
+
cb = int(cb) if cb is not None and str(cb).strip() != "" else None
|
| 338 |
+
except Exception:
|
| 339 |
+
cb = None
|
| 340 |
+
|
| 341 |
+
out[oa_url] = {
|
| 342 |
+
"orcid_url": orcid_url,
|
| 343 |
+
"h_index": h,
|
| 344 |
+
"i10_index": i10,
|
| 345 |
+
"works_count": wc,
|
| 346 |
+
"cited_by_count": cb,
|
| 347 |
+
}
|
| 348 |
+
return out
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
OA_REG_MAP = load_openalex_reg_map()
|
| 352 |
+
OA_AUTHORS_MAP = load_openalex_authors_map()
|
| 353 |
|
| 354 |
|
| 355 |
# ==========================
|
|
|
|
| 358 |
|
| 359 |
@st.cache_data(show_spinner="Загрузка данных...")
|
| 360 |
def load_data():
|
|
|
|
| 361 |
ds_meta = load_dataset(HF_MERGED_REPO, split="train")
|
| 362 |
df_meta = ds_meta.to_pandas()
|
| 363 |
df_meta["registration_number"] = df_meta["registration_number"].astype(str)
|
| 364 |
df_meta = df_meta.set_index("registration_number", drop=False)
|
| 365 |
|
|
|
|
| 366 |
ds_emb = load_dataset(HF_EMB_REPO, split="train")
|
| 367 |
df_emb = ds_emb.to_pandas()
|
| 368 |
df_emb["registration_number"] = df_emb["registration_number"].astype(str)
|
|
|
|
| 373 |
norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True)
|
| 374 |
emb_matrix = emb_matrix / np.maximum(norms, 1e-8)
|
| 375 |
|
|
|
|
| 376 |
meta_aligned = df_meta.reindex(reg_nums)
|
| 377 |
|
|
|
|
| 378 |
type_s = meta_aligned.get("dissertation_type", pd.Series([""] * len(reg_nums))).fillna("").astype(str)
|
| 379 |
is_candidate = type_s.str.contains("кандид", case=False, na=False).to_numpy()
|
| 380 |
is_doctor = type_s.str.contains("доктор", case=False, na=False).to_numpy()
|
| 381 |
|
|
|
|
| 382 |
deg_s = meta_aligned.get("degree_pursued", pd.Series([""] * len(reg_nums))).fillna("").astype(str)
|
| 383 |
degree_lower = np.char.lower(deg_s.to_numpy().astype(str))
|
| 384 |
|
|
|
|
| 385 |
if "protection_date" in meta_aligned.columns:
|
| 386 |
dt = pd.to_datetime(meta_aligned["protection_date"], errors="coerce")
|
| 387 |
year_arr = dt.dt.year.astype("float").to_numpy()
|
|
|
|
| 428 |
) -> np.ndarray:
|
| 429 |
mask = np.ones(len(reg_nums), dtype=bool)
|
| 430 |
|
|
|
|
| 431 |
type_mask = np.zeros(len(reg_nums), dtype=bool)
|
| 432 |
if candidate_selected:
|
| 433 |
type_mask |= is_candidate
|
| 434 |
if doctor_selected:
|
| 435 |
type_mask |= is_doctor
|
| 436 |
+
mask &= type_mask
|
| 437 |
|
|
|
|
| 438 |
if science_selected:
|
| 439 |
sci_mask = np.zeros(len(reg_nums), dtype=bool)
|
| 440 |
for label in science_selected:
|
|
|
|
| 442 |
sci_mask |= _contains_any(degree_lower, patterns)
|
| 443 |
mask &= sci_mask
|
| 444 |
|
|
|
|
| 445 |
if year_range is not None:
|
| 446 |
y0, y1 = int(year_range[0]), int(year_range[1])
|
| 447 |
yr = year_arr
|
|
|
|
| 542 |
results = search_core(query, top_k, mask=mask)
|
| 543 |
df_raw = build_result_df(results)
|
| 544 |
|
| 545 |
+
# 1) registration_number -> openalex_url
|
| 546 |
+
if not df_raw.empty and OA_REG_MAP:
|
| 547 |
+
df_raw["openalex_url"] = df_raw["registration_number"].astype(str).map(OA_REG_MAP).fillna("")
|
| 548 |
else:
|
| 549 |
df_raw["openalex_url"] = ""
|
| 550 |
|
| 551 |
+
# 2) openalex_url -> orcid + stats
|
| 552 |
+
def _map_author_field(url: str, field: str):
|
| 553 |
+
d = OA_AUTHORS_MAP.get(url or "", None)
|
| 554 |
+
if not d:
|
| 555 |
+
return None if field != "orcid_url" else ""
|
| 556 |
+
return d.get(field, None if field != "orcid_url" else "")
|
| 557 |
+
|
| 558 |
+
df_raw["orcid_url"] = df_raw["openalex_url"].apply(lambda u: _map_author_field(u, "orcid_url") or "")
|
| 559 |
+
df_raw["h_index"] = df_raw["openalex_url"].apply(lambda u: _map_author_field(u, "h_index"))
|
| 560 |
+
df_raw["i10_index"] = df_raw["openalex_url"].apply(lambda u: _map_author_field(u, "i10_index"))
|
| 561 |
+
df_raw["works_count"] = df_raw["openalex_url"].apply(lambda u: _map_author_field(u, "works_count"))
|
| 562 |
+
df_raw["cited_by_count"] = df_raw["openalex_url"].apply(lambda u: _map_author_field(u, "cited_by_count"))
|
| 563 |
+
|
| 564 |
# UI: скрываем №, reg номер, тип
|
| 565 |
df_ui = df_raw.copy().reset_index(drop=True)
|
| 566 |
df_ui = df_ui.rename(
|
|
|
|
| 572 |
"protection_year": "Год",
|
| 573 |
"vak_link": "ВАК",
|
| 574 |
"openalex_url": "OpenAlex",
|
| 575 |
+
"orcid_url": "ORCID",
|
| 576 |
+
"h_index": "h-index",
|
| 577 |
+
"i10_index": "i10-index",
|
| 578 |
+
"works_count": "Работ",
|
| 579 |
+
"cited_by_count": "Цитат",
|
| 580 |
}
|
| 581 |
)
|
| 582 |
df_ui = df_ui.drop(columns=["№", "registration_number", "dissertation_type"])
|
| 583 |
|
| 584 |
+
# Excel: полный набор + OA поля
|
| 585 |
df_excel = df_raw.copy()
|
| 586 |
df_excel_ru = df_excel.rename(columns=COLUMN_LABELS_RU_EXCEL)
|
|
|
|
| 587 |
output = io.BytesIO()
|
| 588 |
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
|
| 589 |
df_excel_ru.to_excel(writer, index=False)
|
|
|
|
| 594 |
|
| 595 |
def format_selected_list_for_ui(df_ui_show: pd.DataFrame, selected_idx: List[int]) -> str:
|
| 596 |
lines = []
|
| 597 |
+
for rid in selected_idx:
|
| 598 |
+
r = df_ui_show.loc[rid]
|
| 599 |
fio = str(r.get("ФИО", "")).strip()
|
| 600 |
title = str(r.get("Название диссертации", "")).strip()
|
| 601 |
year = r.get("Год", "")
|
|
|
|
| 698 |
if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
|
| 699 |
st.success(f"Найдено записей: {len(df_ui_saved)}")
|
| 700 |
|
| 701 |
+
# Стабильный row id для корректного выбора даже после сортировки кликом
|
| 702 |
df_ui_show = df_ui_saved[
|
| 703 |
+
["Сходство", "ORCID", "ФИО", "Название диссертации", "Организация", "Год",
|
| 704 |
+
"ВАК", "OpenAlex", "h-index", "i10-index", "Работ", "Цитат"]
|
| 705 |
].copy()
|
| 706 |
+
df_ui_show.insert(0, "_rid", np.arange(len(df_ui_show)))
|
| 707 |
+
df_ui_show = df_ui_show.set_index("_rid")
|
| 708 |
|
| 709 |
+
# Примечание: "гиперссылка над ФИО" внутри одной ячейки в st.dataframe не рендерится;
|
| 710 |
+
# поэтому ORCID сделан отдельным кликабельным столбцом перед ФИО.
|
| 711 |
event = st.dataframe(
|
| 712 |
df_ui_show,
|
| 713 |
use_container_width=True,
|
| 714 |
hide_index=True,
|
| 715 |
+
column_order=[
|
| 716 |
+
"Сходство", "ORCID", "ФИО", "Название диссертации", "Организация", "Год",
|
| 717 |
+
"ВАК", "OpenAlex", "h-index", "i10-index", "Работ", "Цитат"
|
| 718 |
+
],
|
| 719 |
column_config={
|
| 720 |
"Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"),
|
| 721 |
+
"ORCID": st.column_config.LinkColumn(
|
| 722 |
+
"ORCID",
|
| 723 |
+
display_text=r"https://orcid\.org/([0-9X\-]+)",
|
| 724 |
+
width="small",
|
| 725 |
+
help="Профиль автора в ORCID (если найден)",
|
| 726 |
+
),
|
| 727 |
"ФИО": st.column_config.TextColumn("ФИО", width="medium"),
|
| 728 |
"Название диссертации": st.column_config.TextColumn("Название диссертации", width="large"),
|
| 729 |
"Организация": st.column_config.TextColumn("Организация", width="medium"),
|
| 730 |
"Год": st.column_config.NumberColumn("Год", width="small"),
|
| 731 |
"ВАК": st.column_config.LinkColumn("ВАК", display_text="открыть", width="small"),
|
|
|
|
| 732 |
"OpenAlex": st.column_config.LinkColumn(
|
| 733 |
"OpenAlex",
|
| 734 |
display_text=r"https://openalex\.org/(A\d+)",
|
| 735 |
width="small",
|
| 736 |
help="Профиль автора в OpenAlex (если найден)",
|
| 737 |
),
|
| 738 |
+
"h-index": st.column_config.NumberColumn("h-index", width="small"),
|
| 739 |
+
"i10-index": st.column_config.NumberColumn("i10-index", width="small"),
|
| 740 |
+
"Работ": st.column_config.NumberColumn("Работ", width="small"),
|
| 741 |
+
"Цитат": st.column_config.NumberColumn("Цитат", width="small"),
|
| 742 |
},
|
| 743 |
on_select="rerun",
|
| 744 |
selection_mode="multi-row",
|
| 745 |
)
|
| 746 |
|
|
|
|
| 747 |
sel = getattr(event, "selection", None)
|
| 748 |
if sel is not None and sel.rows is not None:
|
| 749 |
+
# здесь rows — значения индекса _rid
|
| 750 |
st.session_state.selected_rows = list(sel.rows)
|
| 751 |
|
| 752 |
if excel_saved is not None:
|
|
|
|
| 775 |
|
| 776 |
comment = st.text_area(
|
| 777 |
"Комментарий",
|
| 778 |
+
height=160,
|
| 779 |
placeholder=(
|
| 780 |
"Какую дополнительную информацию по авторам диссертаций вы хотите получить?\n"
|
| 781 |
"Какие замечания/пожелания по функционалу системы?"
|
|
|
|
| 790 |
elif len(selected_rows) == 0:
|
| 791 |
st.warning("Выберите хотя бы одну диссертацию в таблице (чекбоксами слева).")
|
| 792 |
else:
|
|
|
|
| 793 |
items = []
|
| 794 |
+
for rid in selected_rows:
|
| 795 |
+
# rid == позиция строки в исходном df_raw_saved (потому что _rid = range(len))
|
| 796 |
+
raw = df_raw_saved.iloc[int(rid)].to_dict()
|
| 797 |
items.append(
|
| 798 |
{
|
| 799 |
"author_fio": raw.get("fio"),
|
|
|
|
| 804 |
"registration_number": raw.get("registration_number"),
|
| 805 |
"score": raw.get("score"),
|
| 806 |
"openalex_url": raw.get("openalex_url", ""),
|
| 807 |
+
"orcid_url": raw.get("orcid_url", ""),
|
| 808 |
+
"h_index": raw.get("h_index"),
|
| 809 |
+
"i10_index": raw.get("i10_index"),
|
| 810 |
+
"works_count": raw.get("works_count"),
|
| 811 |
+
"cited_by_count": raw.get("cited_by_count"),
|
| 812 |
}
|
| 813 |
)
|
| 814 |
|
|
|
|
| 836 |
else:
|
| 837 |
st.info("Введите запрос и нажмите «Поиск». После этого мо��но выбрать диссертации и отправить запрос.")
|
| 838 |
|
|
|
|
| 839 |
st.markdown(
|
| 840 |
"<p style='font-size: 0.8rem; text-align: right; color: gray;'>(с) Антон Лощилов, 2025</p>",
|
| 841 |
unsafe_allow_html=True,
|