yogl commited on
Commit
6f4efd5
·
verified ·
1 Parent(s): 4df857a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +171 -70
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 dataset
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 INDEX
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
- @st.cache_data(show_spinner="Загрузка индекса OpenAlex...")
253
- def load_openalex_map() -> Dict[str, str]:
254
- """
255
- Строим маппинг: registration_number -> openalex_url
256
- из yogl/diss_authors_with_oa_index (поле registration_numbers + oa_author_id).
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
- url = _normalize_oa_url(oa)
272
- if not url:
273
  continue
274
  for reg in _iter_registration_numbers(regs):
275
  if reg and reg not in mapping:
276
- mapping[reg] = url
277
  return mapping
278
 
279
 
280
- OA_MAP = load_openalex_map()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 # если оба выключены -> всё False
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
- # OpenAlex URL по registration_number
483
- if not df_raw.empty and OA_MAP:
484
- df_raw["openalex_url"] = df_raw["registration_number"].astype(str).map(OA_MAP).fillna("")
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: полный набор + OpenAlex
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 i in selected_idx:
518
- r = df_ui_show.iloc[i]
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
- ["Сходство", "ФИО", "Название диссертации", "Организация", "Год", "ВАК", "OpenAlex"]
 
623
  ].copy()
 
 
624
 
625
- # Таблица: сортировка по клику по заголовку + мультивыбор строк (чекбоксы)
 
626
  event = st.dataframe(
627
  df_ui_show,
628
  use_container_width=True,
629
  hide_index=True,
630
- column_order=["Сходство", "ФИО", "Название диссертации", "Организация", "Год", "ВАК", "OpenAlex"],
 
 
 
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=140,
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 i in selected_rows:
699
- raw = df_raw_saved.iloc[i].to_dict()
 
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,