yogl commited on
Commit
d227adb
·
verified ·
1 Parent(s): 8b29500

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +198 -75
app.py CHANGED
@@ -63,6 +63,29 @@ DISPLAY_COLUMNS_ALL = [
63
  "registration_number",
64
  ]
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  # ==========================
68
  # ПРОВЕРКА СЕКРЕТОВ
@@ -88,13 +111,14 @@ except Exception:
88
  st.markdown(
89
  """
90
  <style>
91
- /* уменьшение шрифта вокруг dataframes */
92
  div[data-testid="stDataFrame"] { font-size: 80% !important; }
93
  div[data-testid="stDataFrame"] * { font-size: 80% !important; }
94
-
95
- /* попытка зацепить внутренние классы грида (зависит от версии Streamlit) */
96
  .stDataFrame { font-size: 80% !important; }
97
  .gdg-w, .gdg-canvas { font-size: 80% !important; }
 
 
 
 
98
  </style>
99
  """,
100
  unsafe_allow_html=True,
@@ -152,6 +176,10 @@ def _keyify(label: str) -> str:
152
  return "k_" + "".join(ch if ch.isalnum() else "_" for ch in label).strip("_")
153
 
154
 
 
 
 
 
155
  # ==========================
156
  # ФИЛЬТРЫ (UI)
157
  # ==========================
@@ -460,14 +488,32 @@ def run_search(
460
  doctor_selected: bool,
461
  science_selected: List[str],
462
  year_range: Optional[Tuple[int, int]],
 
 
463
  ):
464
  mask = build_filter_mask(candidate_selected, doctor_selected, science_selected, year_range)
465
- results = search_core(query, top_k, mask=mask)
 
 
 
 
466
  df_raw = build_result_df(results)
467
 
468
- if not df_raw.empty and not oa_enrich.empty:
469
- regs_norm = df_raw["registration_number"].map(_norm_regnum)
470
- en = oa_enrich.reindex(regs_norm).reset_index(drop=True)
 
 
 
 
 
 
 
 
 
 
 
 
471
 
472
  def _get(col: str, default):
473
  if col in en.columns:
@@ -489,6 +535,27 @@ def run_search(
489
  df_raw["works_count"] = np.nan
490
  df_raw["cited_by_count"] = np.nan
491
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  fio_txt = df_raw["fio"].map(_safe_text)
493
  orcid_url = df_raw["orcid_url"].map(_safe_text)
494
  fio_cell = np.where(orcid_url != "", orcid_url + "#" + fio_txt.map(_safe_fragment), fio_txt)
@@ -499,22 +566,27 @@ def run_search(
499
 
500
  df_ui = pd.DataFrame(
501
  {
502
- "_rid": np.arange(len(df_raw)),
503
  "Сходство": pd.to_numeric(df_raw["score"], errors="coerce").astype("float64"),
504
  "ФИО": fio_cell,
 
505
  "Название диссертации": title_cell,
506
- # ВАЖНО: отображаем author_org_short
507
  "Организация": df_raw["author_org_short"].map(_safe_text),
 
508
  "Год": pd.to_numeric(df_raw["protection_year"], errors="coerce").astype("float64"),
 
509
  "OpenAlex": df_raw["openalex_url"].map(_safe_text),
510
  "h-index": df_raw["h_index"],
511
  "i10-index": df_raw["i10_index"],
512
  "Работ": df_raw["works_count"],
513
  "Цитат": df_raw["cited_by_count"],
514
- }
515
- ).set_index("_rid", drop=True)
 
 
516
 
517
- df_excel_ru = df_raw.rename(columns=COLUMN_LABELS_RU_EXCEL)
 
518
  output = io.BytesIO()
519
  with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
520
  df_excel_ru.to_excel(writer, index=False)
@@ -523,10 +595,12 @@ def run_search(
523
  return df_ui, output, df_raw
524
 
525
 
526
- def format_selected_list_from_raw(df_raw: pd.DataFrame, selected_rids: List[int]) -> str:
527
  lines = []
528
- for rid in selected_rids:
529
- raw = df_raw.iloc[int(rid)]
 
 
530
  fio = _safe_text(raw.get("fio"))
531
  title = _safe_text(raw.get("title"))
532
  year = _safe_text(raw.get("protection_year"))
@@ -549,8 +623,8 @@ if "last_df_raw" not in st.session_state:
549
  st.session_state.last_df_raw = None
550
  if "last_excel" not in st.session_state:
551
  st.session_state.last_excel = None
552
- if "selected_rids" not in st.session_state:
553
- st.session_state.selected_rids = []
554
  if "search_id" not in st.session_state:
555
  st.session_state.search_id = 0
556
 
@@ -600,10 +674,29 @@ with st.form("search_form"):
600
  step=1,
601
  )
602
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  c1, c2, c3 = st.columns([1, 1, 1])
604
  with c2:
605
  do_search = st.form_submit_button("🔍 Поиск", type="primary", use_container_width=True)
606
 
 
 
 
 
 
607
  if do_search:
608
  if not candidate_selected and not doctor_selected:
609
  st.warning("Выключены оба типа диссертаций. Включите «Кандидатские» и/или «Докторские».")
@@ -616,12 +709,20 @@ if do_search:
616
  doctor_selected=doctor_selected,
617
  science_selected=science_selected,
618
  year_range=year_range,
 
 
619
  )
620
 
621
  st.session_state.last_df_ui = df_ui
622
  st.session_state.last_df_raw = df_raw
623
  st.session_state.last_excel = excel_bytes
624
- st.session_state.selected_rids = []
 
 
 
 
 
 
625
  st.session_state.search_id += 1
626
 
627
  df_ui_saved = st.session_state.last_df_ui
@@ -631,61 +732,83 @@ excel_saved = st.session_state.last_excel
631
  if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
632
  st.success(f"Найдено записей: {len(df_ui_saved)}")
633
 
634
- event = st.dataframe(
635
- df_ui_saved,
636
- use_container_width=True,
637
- hide_index=True,
638
- on_select="rerun",
639
- selection_mode="multi-row",
640
- column_order=[
641
- "Сходство",
 
 
 
 
 
 
642
  "ФИО",
 
 
 
 
 
 
 
 
 
 
 
643
  "Название диссертации",
644
- "Организация",
645
- "Год",
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  "OpenAlex",
647
- "h-index",
648
- "i10-index",
649
- "Работ",
650
- "Цитат",
651
- ],
652
- column_config={
653
- "Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"),
654
- "ФИО": st.column_config.LinkColumn(
655
- "ФИО",
656
- display_text=r"(?:.*#)?(.*)$",
657
- width="medium",
658
- help="Если для автора найден ORCID — ФИО кликабельно и ведёт на ORCID.",
659
- ),
660
- "Название диссертации": st.column_config.LinkColumn(
661
- "Название диссертации",
662
- display_text=r"(?:.*#)?(.*)$",
663
- width="large",
664
- help="Если есть ссылка ВАК — название кликабельно и ведёт на ВАК.",
665
- ),
666
- "Организация": st.column_config.TextColumn("Организация", width="large"),
667
- "Год": st.column_config.NumberColumn("Год", format="%.0f", width="small"),
668
- "OpenAlex": st.column_config.LinkColumn(
669
- "OpenAlex",
670
- display_text=r"https://openalex\.org/(A\d+)",
671
- width="small",
672
- help="Профиль автора в OpenAlex (если найден).",
673
- ),
674
- "h-index": st.column_config.NumberColumn("h-index", format="%.0f", width="small"),
675
- "i10-index": st.column_config.NumberColumn("i10-index", format="%.0f", width="small"),
676
- "Работ": st.column_config.NumberColumn("Работ", format="%.0f", width="small"),
677
- "Цитат": st.column_config.NumberColumn("Цитат", format="%.0f", width="small"),
678
- },
679
  )
680
 
681
- sel = getattr(event, "selection", None)
682
- if sel is not None and sel.rows is not None:
683
- st.session_state.selected_rids = list(sel.rows)
684
 
685
- if st.session_state.selected_rids:
 
 
686
  with st.expander("Полный текст (для выбранных строк)", expanded=False):
687
- for rid in st.session_state.selected_rids[:50]:
688
- raw = df_raw_saved.iloc[int(rid)]
 
 
689
  st.markdown(
690
  f"- **{_safe_text(raw.get('fio'))}** — {_safe_text(raw.get('title'))}\n"
691
  f" \n {_safe_text(raw.get('author_org_short'))} ({_safe_text(raw.get('protection_year'))})"
@@ -702,17 +825,15 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
702
  st.markdown("---")
703
  st.subheader("Запрос расширенной информации")
704
 
705
- selected_rids = st.session_state.selected_rids or []
706
-
707
  with st.form("request_form"):
708
  requester_fio = st.text_input("Ваше ФИО", placeholder="Иванов Иван Иванович")
709
  requester_email = st.text_input("Email", placeholder="name@example.com")
710
 
711
  st.markdown("**Перечень диссертаций:**")
712
- if selected_rids:
713
- st.markdown(format_selected_list_from_raw(df_raw_saved, selected_rids))
714
  else:
715
- st.info("Отметьте диссертации чекбоксами слева в таблице — здесь появится перечень.")
716
 
717
  comment = st.text_area(
718
  "Комментарий",
@@ -728,12 +849,14 @@ if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
728
  if send_request:
729
  if not requester_fio.strip() or not requester_email.strip():
730
  st.warning("Поля «Ваше ФИО» и «Email» обязательны. Заполните их, чтобы отправить запрос.")
731
- elif len(selected_rids) == 0:
732
- st.warning("Выберите хотя бы одну диссертацию (чекбоксы слева в таблице).")
733
  else:
734
  items = []
735
- for rid in selected_rids:
736
- raw = df_raw_saved.iloc[int(rid)].to_dict()
 
 
737
  items.append(
738
  {
739
  "author_fio": raw.get("fio"),
 
63
  "registration_number",
64
  ]
65
 
66
+ # ==========================
67
+ # UI: КОЛОНКИ ТАБЛИЦЫ + НАСТРОЙКИ ОТОБРАЖЕНИЯ
68
+ # ==========================
69
+
70
+ UI_TABLE_COLUMNS = [
71
+ "Сходство",
72
+ "ФИО",
73
+ "ORCID",
74
+ "Название диссертации",
75
+ "ВАК",
76
+ "Организация",
77
+ "Тип",
78
+ "Год",
79
+ "Регистрационный номер",
80
+ "OpenAlex",
81
+ "h-index",
82
+ "i10-index",
83
+ "Работ",
84
+ "Цитат",
85
+ ]
86
+
87
+ DEFAULT_VISIBLE_UI = {c: (c != "Организация") for c in UI_TABLE_COLUMNS}
88
+
89
 
90
  # ==========================
91
  # ПРОВЕРКА СЕКРЕТОВ
 
111
  st.markdown(
112
  """
113
  <style>
 
114
  div[data-testid="stDataFrame"] { font-size: 80% !important; }
115
  div[data-testid="stDataFrame"] * { font-size: 80% !important; }
 
 
116
  .stDataFrame { font-size: 80% !important; }
117
  .gdg-w, .gdg-canvas { font-size: 80% !important; }
118
+
119
+ /* data_editor */
120
+ div[data-testid="stDataEditor"] { font-size: 80% !important; }
121
+ div[data-testid="stDataEditor"] * { font-size: 80% !important; }
122
  </style>
123
  """,
124
  unsafe_allow_html=True,
 
176
  return "k_" + "".join(ch if ch.isalnum() else "_" for ch in label).strip("_")
177
 
178
 
179
+ def _show_col_key(col: str) -> str:
180
+ return _keyify("show_" + col)
181
+
182
+
183
  # ==========================
184
  # ФИЛЬТРЫ (UI)
185
  # ==========================
 
488
  doctor_selected: bool,
489
  science_selected: List[str],
490
  year_range: Optional[Tuple[int, int]],
491
+ only_openalex: bool,
492
+ only_orcid: bool,
493
  ):
494
  mask = build_filter_mask(candidate_selected, doctor_selected, science_selected, year_range)
495
+
496
+ # Берём с запасом, чтобы после фильтров (OA/ORCID) всё равно получить до top_k
497
+ prefetch_k = min(max(int(top_k) * 5, int(top_k)), 500)
498
+ results = search_core(query, prefetch_k, mask=mask)
499
+
500
  df_raw = build_result_df(results)
501
 
502
+ if df_raw.empty:
503
+ empty_ui = pd.DataFrame(columns=["Выбрать"] + UI_TABLE_COLUMNS)
504
+ empty_ui.index.name = "reg_norm"
505
+ out = io.BytesIO()
506
+ with pd.ExcelWriter(out, engine="xlsxwriter") as writer:
507
+ pd.DataFrame().to_excel(writer, index=False)
508
+ out.seek(0)
509
+ return empty_ui, out, df_raw
510
+
511
+ # reg_norm — стабильный идентификатор строки для сохранения выбранных чекбоксов
512
+ df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum)
513
+
514
+ # OA/ORCID enrichment
515
+ if not oa_enrich.empty:
516
+ en = oa_enrich.reindex(df_raw["reg_norm"]).reset_index(drop=True)
517
 
518
  def _get(col: str, default):
519
  if col in en.columns:
 
535
  df_raw["works_count"] = np.nan
536
  df_raw["cited_by_count"] = np.nan
537
 
538
+ # Фильтры "только с OpenAlex/ORCID"
539
+ if only_openalex:
540
+ df_raw = df_raw[df_raw["openalex_url"].map(_safe_text) != ""]
541
+ if only_orcid:
542
+ df_raw = df_raw[df_raw["orcid_url"].map(_safe_text) != ""]
543
+
544
+ df_raw = df_raw.reset_index(drop=True)
545
+
546
+ # Ограничиваем top_k уже после фильтрации
547
+ if len(df_raw) > int(top_k):
548
+ df_raw = df_raw.iloc[: int(top_k)].copy()
549
+
550
+ # Перенумеруем "№"
551
+ if "№" in df_raw.columns:
552
+ df_raw["№"] = np.arange(1, len(df_raw) + 1)
553
+
554
+ # Установим индекс как стабильный id
555
+ df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum)
556
+ df_raw = df_raw.drop_duplicates(subset=["reg_norm"]).set_index("reg_norm", drop=True)
557
+
558
+ # UI-таблица
559
  fio_txt = df_raw["fio"].map(_safe_text)
560
  orcid_url = df_raw["orcid_url"].map(_safe_text)
561
  fio_cell = np.where(orcid_url != "", orcid_url + "#" + fio_txt.map(_safe_fragment), fio_txt)
 
566
 
567
  df_ui = pd.DataFrame(
568
  {
 
569
  "Сходство": pd.to_numeric(df_raw["score"], errors="coerce").astype("float64"),
570
  "ФИО": fio_cell,
571
+ "ORCID": df_raw["orcid_url"].map(_safe_text),
572
  "Название диссертации": title_cell,
573
+ "ВАК": df_raw["vak_link"].map(_safe_text),
574
  "Организация": df_raw["author_org_short"].map(_safe_text),
575
+ "Тип": df_raw["dissertation_type"].map(_safe_text),
576
  "Год": pd.to_numeric(df_raw["protection_year"], errors="coerce").astype("float64"),
577
+ "Регистрационный номер": df_raw["registration_number"].map(_safe_text),
578
  "OpenAlex": df_raw["openalex_url"].map(_safe_text),
579
  "h-index": df_raw["h_index"],
580
  "i10-index": df_raw["i10_index"],
581
  "Работ": df_raw["works_count"],
582
  "Цитат": df_raw["cited_by_count"],
583
+ },
584
+ index=df_raw.index,
585
+ )
586
+ df_ui.index.name = "reg_norm"
587
 
588
+ # Excel (как раньше)
589
+ df_excel_ru = df_raw.reset_index(drop=False).rename(columns=COLUMN_LABELS_RU_EXCEL)
590
  output = io.BytesIO()
591
  with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
592
  df_excel_ru.to_excel(writer, index=False)
 
595
  return df_ui, output, df_raw
596
 
597
 
598
+ def format_selected_list_from_raw(df_raw: pd.DataFrame, selected_regnorms: List[str]) -> str:
599
  lines = []
600
+ for reg_norm in selected_regnorms:
601
+ if reg_norm not in df_raw.index:
602
+ continue
603
+ raw = df_raw.loc[reg_norm]
604
  fio = _safe_text(raw.get("fio"))
605
  title = _safe_text(raw.get("title"))
606
  year = _safe_text(raw.get("protection_year"))
 
623
  st.session_state.last_df_raw = None
624
  if "last_excel" not in st.session_state:
625
  st.session_state.last_excel = None
626
+ if "selected_regnorms" not in st.session_state:
627
+ st.session_state.selected_regnorms = set()
628
  if "search_id" not in st.session_state:
629
  st.session_state.search_id = 0
630
 
 
674
  step=1,
675
  )
676
 
677
+ st.markdown("**Фильтрация по профилям:**")
678
+ only_openalex = st.checkbox("Отображать только с OpenAlex", value=False, key="only_openalex")
679
+ only_orcid = st.checkbox("Отображать только с ORCID", value=False, key="only_orcid")
680
+
681
+ st.markdown("**Настройки отображения:**")
682
+ disp_cols = st.columns(3)
683
+ for i, col in enumerate(UI_TABLE_COLUMNS):
684
+ with disp_cols[i % 3]:
685
+ st.checkbox(
686
+ col,
687
+ value=DEFAULT_VISIBLE_UI.get(col, True),
688
+ key=_show_col_key(col),
689
+ )
690
+
691
  c1, c2, c3 = st.columns([1, 1, 1])
692
  with c2:
693
  do_search = st.form_submit_button("🔍 Поиск", type="primary", use_container_width=True)
694
 
695
+ # Выбранные для отображения колонки (после формы)
696
+ visible_ui_cols = [c for c in UI_TABLE_COLUMNS if st.session_state.get(_show_col_key(c), True)]
697
+ if not visible_ui_cols:
698
+ visible_ui_cols = [c for c in UI_TABLE_COLUMNS if c != "Организация"]
699
+
700
  if do_search:
701
  if not candidate_selected and not doctor_selected:
702
  st.warning("Выключены оба типа диссертаций. Включите «Кандидатские» и/или «Докторские».")
 
709
  doctor_selected=doctor_selected,
710
  science_selected=science_selected,
711
  year_range=year_range,
712
+ only_openalex=only_openalex,
713
+ only_orcid=only_orcid,
714
  )
715
 
716
  st.session_state.last_df_ui = df_ui
717
  st.session_state.last_df_raw = df_raw
718
  st.session_state.last_excel = excel_bytes
719
+
720
+ # Важно: НЕ сбрасываем выбор полностью; оставляем только те, что есть в новых результатах
721
+ if isinstance(df_ui, pd.DataFrame) and not df_ui.empty:
722
+ st.session_state.selected_regnorms = set(st.session_state.selected_regnorms) & set(df_ui.index)
723
+ else:
724
+ st.session_state.selected_regnorms = set()
725
+
726
  st.session_state.search_id += 1
727
 
728
  df_ui_saved = st.session_state.last_df_ui
 
732
  if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty:
733
  st.success(f"Найдено записей: {len(df_ui_saved)}")
734
 
735
+ # Соберём отображаемую таблицу + колонку выбора
736
+ selected_set = set(st.session_state.selected_regnorms) & set(df_ui_saved.index)
737
+ st.session_state.selected_regnorms = selected_set
738
+
739
+ df_display = df_ui_saved.copy()
740
+ df_display.insert(0, "Выбрать", df_display.index.map(lambda x: x in selected_set))
741
+
742
+ show_cols = [c for c in visible_ui_cols if c in df_display.columns]
743
+ df_edit = df_display[["Выбрать"] + show_cols].copy()
744
+
745
+ full_column_config = {
746
+ "Выбрать": st.column_config.CheckboxColumn("Выбрать", width="small"),
747
+ "Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"),
748
+ "ФИО": st.column_config.LinkColumn(
749
  "ФИО",
750
+ display_text=r"(?:.*#)?(.*)$",
751
+ width="medium",
752
+ help="Если для автора найден ORCID — ФИО кликабельно и ведёт на ORCID.",
753
+ ),
754
+ "ORCID": st.column_config.LinkColumn(
755
+ "ORCID",
756
+ display_text=r"https://orcid\.org/(0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4})",
757
+ width="small",
758
+ help="Профиль автора в ORCID (если найден).",
759
+ ),
760
+ "Название диссертации": st.column_config.LinkColumn(
761
  "Название диссертации",
762
+ display_text=r"(?:.*#)?(.*)$",
763
+ width="large",
764
+ help="Если есть ссылка ВАК — название кликабельно и ведёт на ВАК.",
765
+ ),
766
+ "ВАК": st.column_config.LinkColumn(
767
+ "ВАК",
768
+ display_text=r".+",
769
+ width="small",
770
+ help="Прямая ссылка на карточку/страницу ВАК (если есть).",
771
+ ),
772
+ "Организация": st.column_config.TextColumn("Организация", width="large"),
773
+ "Тип": st.column_config.TextColumn("Тип", width="small"),
774
+ "Год": st.column_config.NumberColumn("Год", format="%.0f", width="small"),
775
+ "Регистрационный номер": st.column_config.TextColumn("Регистрационный номер", width="small"),
776
+ "OpenAlex": st.column_config.LinkColumn(
777
  "OpenAlex",
778
+ display_text=r"https://openalex\.org/(A\d+)",
779
+ width="small",
780
+ help="Профиль автора в OpenAlex (если найден).",
781
+ ),
782
+ "h-index": st.column_config.NumberColumn("h-index", format="%.0f", width="small"),
783
+ "i10-index": st.column_config.NumberColumn("i10-index", format="%.0f", width="small"),
784
+ "Работ": st.column_config.NumberColumn("Работ", format="%.0f", width="small"),
785
+ "Цитат": st.column_config.NumberColumn("Цитат", format="%.0f", width="small"),
786
+ }
787
+
788
+ column_config_filtered = {k: v for k, v in full_column_config.items() if k in df_edit.columns}
789
+
790
+ edited = st.data_editor(
791
+ df_edit,
792
+ use_container_width=True,
793
+ hide_index=True,
794
+ num_rows="fixed",
795
+ column_config=column_config_filtered,
796
+ disabled=[c for c in df_edit.columns if c != "Выбрать"],
797
+ key=f"editor_{st.session_state.search_id}",
 
 
 
 
 
 
 
 
 
 
 
 
798
  )
799
 
800
+ # Обновим выбранные reg_norm из чекбоксов — это не будет "сбрасываться" при фильтрах
801
+ if isinstance(edited, pd.DataFrame) and "Выбрать" in edited.columns:
802
+ st.session_state.selected_regnorms = set(edited.index[edited["Выбрать"] == True].tolist())
803
 
804
+ selected_regnorms = sorted(list(st.session_state.selected_regnorms))
805
+
806
+ if selected_regnorms:
807
  with st.expander("Полный текст (для выбранных строк)", expanded=False):
808
+ for reg_norm in selected_regnorms[:50]:
809
+ if df_raw_saved is None or reg_norm not in df_raw_saved.index:
810
+ continue
811
+ raw = df_raw_saved.loc[reg_norm]
812
  st.markdown(
813
  f"- **{_safe_text(raw.get('fio'))}** — {_safe_text(raw.get('title'))}\n"
814
  f" \n {_safe_text(raw.get('author_org_short'))} ({_safe_text(raw.get('protection_year'))})"
 
825
  st.markdown("---")
826
  st.subheader("Запрос расширенной информации")
827
 
 
 
828
  with st.form("request_form"):
829
  requester_fio = st.text_input("Ваше ФИО", placeholder="Иванов Иван Иванович")
830
  requester_email = st.text_input("Email", placeholder="name@example.com")
831
 
832
  st.markdown("**Перечень диссертаций:**")
833
+ if selected_regnorms:
834
+ st.markdown(format_selected_list_from_raw(df_raw_saved, selected_regnorms))
835
  else:
836
+ st.info("Отметьте диссертации чекбоксом «Выбрать» — здесь появится перечень.")
837
 
838
  comment = st.text_area(
839
  "Комментарий",
 
849
  if send_request:
850
  if not requester_fio.strip() or not requester_email.strip():
851
  st.warning("Поля «Ваше ФИО» и «Email» обязательны. Заполните их, чтобы отправить запрос.")
852
+ elif len(selected_regnorms) == 0:
853
+ st.warning("Выберите хотя бы одну диссертацию (чекбокс «Выбрать»).")
854
  else:
855
  items = []
856
+ for reg_norm in selected_regnorms:
857
+ if df_raw_saved is None or reg_norm not in df_raw_saved.index:
858
+ continue
859
+ raw = df_raw_saved.loc[reg_norm].to_dict()
860
  items.append(
861
  {
862
  "author_fio": raw.get("fio"),