Download app.py from PlanetExpress2125/Look4Postdoc_v.0.3: direct link, hf CLI and curl.
- Browser
- Download file 35.2 kB
-
https://huggingface.co/spaces/PlanetExpress2125/Look4Postdoc_v.0.3/resolve/main/app.py
- Command line
-
hf download hf://spaces/PlanetExpress2125/Look4Postdoc_v.0.3/app.py
-
curl -L -o app.py https://huggingface.co/spaces/PlanetExpress2125/Look4Postdoc_v.0.3/resolve/main/app.py
35.2 kB
| import os | |
| import io | |
| import json | |
| import uuid | |
| import re | |
| from datetime import datetime, timezone | |
| from typing import Optional, Tuple, List, Any | |
| import numpy as np | |
| import pandas as pd | |
| import streamlit as st | |
| from sentence_transformers import SentenceTransformer | |
| from datasets import load_dataset | |
| from huggingface_hub import login, HfApi | |
| # ========================== | |
| # НАСТРОЙКИ ПРИЛОЖЕНИЯ | |
| # ========================== | |
| st.set_page_config(page_title="Поиск постдока", layout="wide") | |
| HF_TOKEN = os.getenv("HF_TOKEN") | |
| HF_MERGED_REPO = os.getenv("HF_MERGED_REPO") | |
| HF_EMB_REPO = os.getenv("HF_EMB_REPO") | |
| MODEL_NAME = os.getenv("MODEL_NAME") | |
| HF_REQUESTS_REPO = os.getenv("HF_REQUESTS_REPO") | |
| HF_REQUESTS_REPO_TYPE = os.getenv("HF_REQUESTS_REPO_TYPE") | |
| HF_WRITE_TOKEN = os.getenv("HF_WRITE_TOKEN") | |
| OA_ENRICH_REPO = os.getenv("OA_ENRICH_REPO") | |
| SLIDER_MIN_YEAR = 2005 | |
| COLUMN_LABELS_RU_EXCEL = { | |
| "№": "№", | |
| "score": "Сходство", | |
| "fio": "ФИО", | |
| "title": "Название диссертации", | |
| "author_org_short": "Организация", | |
| "dissertation_type": "Тип", | |
| "protection_year": "Год", | |
| "registration_number": "Регистрационный номер", | |
| "vak_link": "Ссылка ВАК", | |
| "openalex_url": "OpenAlex", | |
| "orcid_url": "ORCID", | |
| "h_index": "h-index", | |
| "i10_index": "i10-index", | |
| "works_count": "Работ", | |
| "cited_by_count": "Цитат", | |
| } | |
| DISPLAY_COLUMNS_ALL = [ | |
| "№", | |
| "score", | |
| "fio", | |
| "title", | |
| "author_org_short", | |
| "dissertation_type", | |
| "protection_year", | |
| "registration_number", | |
| ] | |
| # ========================== | |
| # UI: КОЛОНКИ ТАБЛИЦЫ + НАСТРОЙКИ ОТОБРАЖЕНИЯ | |
| # ВАЖНО: | |
| # - ФИО НЕ ссылка | |
| # - Название диссертации -> ссылка на ВАК (если есть vak_link) | |
| # - после "Год": OpenAlex (ID+ссылка), ORCID (ID+ссылка) | |
| # - Тип по умолчанию скрыт | |
| # - Регистрационный номер по умолчанию скрыт | |
| # ========================== | |
| UI_TABLE_COLUMNS = [ | |
| "Сходство", | |
| "ФИО", | |
| "Название диссертации", | |
| "Организация", | |
| "Тип", | |
| "Год", | |
| "OpenAlex", | |
| "ORCID", | |
| "Регистрационный номер", | |
| "h-index", | |
| "i10-index", | |
| "Работ", | |
| "Цитат", | |
| ] | |
| # По умолчанию: все включены, кроме "Организация", "Тип" и "Регистрационный номер" | |
| DEFAULT_VISIBLE_UI = { | |
| c: (c not in {"Организация", "Тип", "Регистрационный номер"}) | |
| for c in UI_TABLE_COLUMNS | |
| } | |
| # ========================== | |
| # ПРОВЕРКА СЕКРЕТОВ | |
| # ========================== | |
| if HF_TOKEN is None: | |
| st.error( | |
| "Не найден секрет `HF_TOKEN`. " | |
| "Задайте его в Settings → Variables and secrets вашего Space." | |
| ) | |
| st.stop() | |
| try: | |
| login(token=HF_TOKEN) | |
| except Exception: | |
| pass | |
| # ========================== | |
| # CSS: уменьшение шрифта таблицы до ~80% | |
| # ========================== | |
| st.markdown( | |
| """ | |
| <style> | |
| div[data-testid="stDataFrame"] { font-size: 80% !important; } | |
| div[data-testid="stDataFrame"] * { font-size: 80% !important; } | |
| .stDataFrame { font-size: 80% !important; } | |
| .gdg-w, .gdg-canvas { font-size: 80% !important; } | |
| div[data-testid="stDataEditor"] { font-size: 80% !important; } | |
| div[data-testid="stDataEditor"] * { font-size: 80% !important; } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ========================== | |
| # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ | |
| # ========================== | |
| def _norm_regnum(x: Any) -> str: | |
| s = "" if x is None else str(x).strip() | |
| if s.endswith(".0") and s[:-2].isdigit(): | |
| s = s[:-2] | |
| return s | |
| def _safe_text(x: Any) -> str: | |
| if x is None: | |
| return "" | |
| if isinstance(x, float) and np.isnan(x): | |
| return "" | |
| s = str(x).strip() | |
| if s.lower() in {"none", "nan", "<na>"}: | |
| return "" | |
| return s | |
| def _safe_fragment(s: Any) -> str: | |
| t = _safe_text(s).replace("#", " ").replace("\n", " ").replace("\r", " ").strip() | |
| return t | |
| def _norm_openalex_url(x: Any) -> str: | |
| s = _safe_text(x) | |
| if not s: | |
| return "" | |
| if s.startswith("http://") or s.startswith("https://"): | |
| return s | |
| m = re.search(r"(A\d+)", s) | |
| return f"https://openalex.org/{m.group(1)}" if m else "" | |
| def _norm_orcid_url(x: Any) -> str: | |
| s = _safe_text(x) | |
| if not s: | |
| return "" | |
| if s.startswith("http://") or s.startswith("https://"): | |
| return s | |
| m = re.search(r"(0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4})", s) | |
| return f"https://orcid.org/{m.group(1)}" if m else "" | |
| def _openalex_id_from_url(url: str) -> str: | |
| s = _safe_text(url) | |
| m = re.search(r"\b(A\d+)\b", s) | |
| return m.group(1) if m else "" | |
| def _orcid_id_from_url(url: str) -> str: | |
| s = _safe_text(url) | |
| m = re.search(r"\b(0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4})\b", s) | |
| return m.group(1) if m else "" | |
| def _keyify(label: str) -> str: | |
| return "k_" + "".join(ch if ch.isalnum() else "_" for ch in label).strip("_") | |
| def _show_col_key(col: str) -> str: | |
| return _keyify("show_" + col) | |
| # ========================== | |
| # ФИЛЬТРЫ (UI) | |
| # ========================== | |
| SCIENCE_LABELS = [ | |
| "Архитектура", | |
| "Биологические", | |
| "Ветеринарные", | |
| "Географические", | |
| "Геолого-минералогические", | |
| "Искусствоведение", | |
| "Исторические", | |
| "Культурология", | |
| "Медицинские", | |
| "Педагогические", | |
| "Политические", | |
| "Сельскохозяйственные", | |
| "Технические", | |
| "Фармацевтические", | |
| "Физико-математические", | |
| "Филологические", | |
| "Философские", | |
| "Химические", | |
| "Экономические", | |
| "Юридические науки", | |
| ] | |
| SCIENCE_LABELS = sorted(list(dict.fromkeys(SCIENCE_LABELS)), key=lambda s: s.casefold()) | |
| DEFAULT_SCIENCES = {"Технические", "Физико-математические", "Химические", "Биологические"} | |
| SCIENCE_PATTERNS = { | |
| "Архитектура": ["архитектур"], | |
| "Биологические": ["биолог"], | |
| "Ветеринарные": ["ветеринар"], | |
| "Географические": ["географ"], | |
| "Геолого-минералогические": ["геолого-минералог", "геол.-минералог", "геолого минералог"], | |
| "Искусствоведение": ["искусствовед"], | |
| "Исторические": ["историч"], | |
| "Культурология": ["культуролог"], | |
| "Медицинские": ["медицин"], | |
| "Педагогические": ["педагог"], | |
| "Политические": ["политич"], | |
| "Сельскохозяйственные": ["сельскохозяй"], | |
| "Технические": ["технич"], | |
| "Фармацевтические": ["фармацевт"], | |
| "Физико-математические": ["физико-математ", "физ-мат", "физмат"], | |
| "Филологические": ["филолог"], | |
| "Философские": ["философ"], | |
| "Химические": ["химич"], | |
| "Экономические": ["экономич"], | |
| "Юридические науки": ["юридич"], | |
| } | |
| # ========================== | |
| # ЗАПИСЬ ЗАЯВОК В HF REPO | |
| # ========================== | |
| def save_request_to_hub(payload: dict) -> str: | |
| token = HF_WRITE_TOKEN or HF_TOKEN | |
| if not token: | |
| raise RuntimeError("Не задан HF_WRITE_TOKEN (и нет HF_TOKEN).") | |
| api = HfApi(token=token) | |
| ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | |
| rid = uuid.uuid4().hex[:10] | |
| path_in_repo = f"requests/{ts}_{rid}.json" | |
| data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") | |
| api.upload_file( | |
| path_or_fileobj=io.BytesIO(data), | |
| path_in_repo=path_in_repo, | |
| repo_id=HF_REQUESTS_REPO, | |
| repo_type=HF_REQUESTS_REPO_TYPE, | |
| commit_message=f"New PostDoc request {ts}", | |
| ) | |
| return path_in_repo | |
| # ========================== | |
| # OA ENRICHMENT (КОМПАКТНЫЙ) | |
| # ========================== | |
| def load_oa_enrichment() -> pd.DataFrame: | |
| ds = load_dataset(OA_ENRICH_REPO, split="train") | |
| df = ds.to_pandas() | |
| if "registration_number" not in df.columns: | |
| return pd.DataFrame().set_index(pd.Index([], name="reg_norm")) | |
| df["reg_norm"] = df["registration_number"].apply(_norm_regnum) | |
| if "openalex_url" not in df.columns and "openalex" in df.columns: | |
| df["openalex_url"] = df["openalex"] | |
| if "orcid_url" not in df.columns and "orcid" in df.columns: | |
| df["orcid_url"] = df["orcid"] | |
| if "openalex_url" in df.columns: | |
| df["openalex_url"] = df["openalex_url"].map(_norm_openalex_url) | |
| if "orcid_url" in df.columns: | |
| df["orcid_url"] = df["orcid_url"].map(_norm_orcid_url) | |
| for c in ["h_index", "i10_index", "works_count", "cited_by_count"]: | |
| if c in df.columns: | |
| df[c] = pd.to_numeric(df[c], errors="coerce") | |
| keep = ["reg_norm", "openalex_url", "orcid_url", "h_index", "i10_index", "works_count", "cited_by_count"] | |
| keep = [c for c in keep if c in df.columns] | |
| df = df[keep].copy() | |
| df = df.drop_duplicates(subset=["reg_norm"]).set_index("reg_norm", drop=True) | |
| return df | |
| oa_enrich = load_oa_enrichment() | |
| # ========================== | |
| # ДАННЫЕ И МОДЕЛЬ | |
| # ========================== | |
| def load_data(): | |
| ds_meta = load_dataset(HF_MERGED_REPO, split="train") | |
| df_meta = ds_meta.to_pandas() | |
| df_meta["registration_number"] = df_meta["registration_number"].astype(str).map(_norm_regnum) | |
| df_meta = df_meta.set_index("registration_number", drop=False) | |
| ds_emb = load_dataset(HF_EMB_REPO, split="train") | |
| df_emb = ds_emb.to_pandas() | |
| df_emb["registration_number"] = df_emb["registration_number"].astype(str).map(_norm_regnum) | |
| reg_nums = df_emb["registration_number"].tolist() | |
| emb_matrix = np.vstack(df_emb["embedding"].to_list()).astype("float32") | |
| norms = np.linalg.norm(emb_matrix, axis=1, keepdims=True) | |
| emb_matrix = emb_matrix / np.maximum(norms, 1e-8) | |
| meta_aligned = df_meta.reindex(reg_nums) | |
| type_s = meta_aligned.get("dissertation_type", pd.Series([""] * len(reg_nums))).fillna("").astype(str) | |
| is_candidate = type_s.str.contains("кандид", case=False, na=False).to_numpy() | |
| is_doctor = type_s.str.contains("доктор", case=False, na=False).to_numpy() | |
| deg_s = meta_aligned.get("degree_pursued", pd.Series([""] * len(reg_nums))).fillna("").astype(str) | |
| degree_lower = np.char.lower(deg_s.to_numpy().astype(str)) | |
| if "protection_date" in meta_aligned.columns: | |
| dt = pd.to_datetime(meta_aligned["protection_date"], errors="coerce") | |
| year_arr = dt.dt.year.astype("float").to_numpy() | |
| elif "protection_year" in meta_aligned.columns: | |
| year_arr = pd.to_numeric(meta_aligned["protection_year"], errors="coerce").astype("float").to_numpy() | |
| else: | |
| year_arr = np.full(len(reg_nums), np.nan, dtype="float") | |
| return df_meta, reg_nums, emb_matrix, is_candidate, is_doctor, degree_lower, year_arr | |
| def load_model(): | |
| return SentenceTransformer(MODEL_NAME) | |
| try: | |
| df_all, reg_nums, emb_matrix, is_candidate, is_doctor, degree_lower, year_arr = load_data() | |
| model = load_model() | |
| except Exception as e: | |
| st.error(f"Ошибка при загрузке данных или модели: {e}") | |
| st.stop() | |
| # ========================== | |
| # ПОИСК | |
| # ========================== | |
| def _contains_any(deg_lower_arr: np.ndarray, patterns: List[str]) -> np.ndarray: | |
| m = np.zeros(len(deg_lower_arr), dtype=bool) | |
| for p in patterns: | |
| p = (p or "").strip().lower() | |
| if not p: | |
| continue | |
| m |= (np.char.find(deg_lower_arr, p) >= 0) | |
| return m | |
| def build_filter_mask( | |
| candidate_selected: bool, | |
| doctor_selected: bool, | |
| science_selected: List[str], | |
| year_range: Optional[Tuple[int, int]], | |
| ) -> np.ndarray: | |
| mask = np.ones(len(reg_nums), dtype=bool) | |
| type_mask = np.zeros(len(reg_nums), dtype=bool) | |
| if candidate_selected: | |
| type_mask |= is_candidate | |
| if doctor_selected: | |
| type_mask |= is_doctor | |
| mask &= type_mask | |
| if science_selected: | |
| sci_mask = np.zeros(len(reg_nums), dtype=bool) | |
| for label in science_selected: | |
| patterns = SCIENCE_PATTERNS.get(label, [label]) | |
| sci_mask |= _contains_any(degree_lower, patterns) | |
| mask &= sci_mask | |
| if year_range is not None: | |
| y0, y1 = int(year_range[0]), int(year_range[1]) | |
| yr = year_arr | |
| mask &= (np.isnan(yr) | ((yr >= y0) & (yr <= y1))) | |
| return mask | |
| def search_core(query: str, top_k: int = 10, mask=None): | |
| query = query.strip() | |
| if not query: | |
| return [] | |
| q_emb = model.encode("query: " + query, normalize_embeddings=True) | |
| idx_pool = np.arange(len(reg_nums)) if mask is None else np.flatnonzero(mask) | |
| if idx_pool.size == 0: | |
| return [] | |
| scores_pool = emb_matrix[idx_pool] @ q_emb | |
| top_k = min(int(top_k), len(scores_pool)) | |
| top_local = np.argsort(-scores_pool)[:top_k] | |
| top_idx = idx_pool[top_local] | |
| top_scores = scores_pool[top_local] | |
| return [ | |
| {"rank": i + 1, "registration_number": reg_nums[idx], "score": float(sc)} | |
| for i, (idx, sc) in enumerate(zip(top_idx, top_scores)) | |
| ] | |
| def extract_year_int(value) -> Optional[int]: | |
| if value is None: | |
| return None | |
| try: | |
| if not isinstance(value, str): | |
| if pd.isna(value): | |
| return None | |
| dt = pd.to_datetime(value, errors="coerce") | |
| if pd.isna(dt): | |
| return None | |
| return int(dt.year) | |
| except Exception: | |
| pass | |
| s = str(value).strip() | |
| if len(s) >= 4 and s[:4].isdigit(): | |
| return int(s[:4]) | |
| return None | |
| def build_result_df(results): | |
| rows = [] | |
| for r in results: | |
| reg = _norm_regnum(r["registration_number"]) | |
| score = r["score"] | |
| if reg in df_all.index: | |
| meta = df_all.loc[reg] | |
| if isinstance(meta, pd.DataFrame): | |
| meta = meta.iloc[0] | |
| else: | |
| meta = pd.Series({}, index=df_all.columns) | |
| protection_year = extract_year_int(meta.get("protection_date", None)) | |
| org_short = meta.get("author_org_short", None) | |
| if ( | |
| org_short is None | |
| or (isinstance(org_short, float) and pd.isna(org_short)) | |
| or str(org_short).lower() in {"none", "nan"} | |
| ): | |
| org_short = meta.get("author_org_name", None) | |
| rows.append( | |
| { | |
| "№": r["rank"], | |
| "score": float(round(score, 4)), | |
| "fio": meta.get("fio", None), | |
| "title": meta.get("title", None), | |
| "author_org_short": org_short, | |
| "dissertation_type": meta.get("dissertation_type", None), | |
| "protection_year": protection_year, | |
| "registration_number": meta.get("registration_number", reg), | |
| "vak_link": meta.get("vak_link", ""), | |
| } | |
| ) | |
| if not rows: | |
| return pd.DataFrame(columns=DISPLAY_COLUMNS_ALL + ["vak_link"]) | |
| return pd.DataFrame(rows).reset_index(drop=True)[DISPLAY_COLUMNS_ALL + ["vak_link"]] | |
| def run_search( | |
| query: str, | |
| top_k: int, | |
| candidate_selected: bool, | |
| doctor_selected: bool, | |
| science_selected: List[str], | |
| year_range: Optional[Tuple[int, int]], | |
| only_openalex: bool, | |
| only_orcid: bool, | |
| ): | |
| mask = build_filter_mask(candidate_selected, doctor_selected, science_selected, year_range) | |
| prefetch_k = min(max(int(top_k) * 5, int(top_k)), 500) | |
| results = search_core(query, prefetch_k, mask=mask) | |
| df_raw = build_result_df(results) | |
| if df_raw.empty: | |
| empty_ui = pd.DataFrame(columns=["Выбрать"] + UI_TABLE_COLUMNS) | |
| empty_ui.index.name = "reg_norm" | |
| out = io.BytesIO() | |
| with pd.ExcelWriter(out, engine="xlsxwriter") as writer: | |
| pd.DataFrame().to_excel(writer, index=False) | |
| out.seek(0) | |
| return empty_ui, out, df_raw | |
| df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum) | |
| if not oa_enrich.empty: | |
| en = oa_enrich.reindex(df_raw["reg_norm"]).reset_index(drop=True) | |
| def _get(col: str, default): | |
| if col in en.columns: | |
| return en[col] | |
| return pd.Series([default] * len(df_raw)) | |
| df_raw["openalex_url"] = _get("openalex_url", "").map(_norm_openalex_url) | |
| df_raw["orcid_url"] = _get("orcid_url", "").map(_norm_orcid_url) | |
| df_raw["h_index"] = pd.to_numeric(_get("h_index", np.nan), errors="coerce").astype("float64") | |
| df_raw["i10_index"] = pd.to_numeric(_get("i10_index", np.nan), errors="coerce").astype("float64") | |
| df_raw["works_count"] = pd.to_numeric(_get("works_count", np.nan), errors="coerce").astype("float64") | |
| df_raw["cited_by_count"] = pd.to_numeric(_get("cited_by_count", np.nan), errors="coerce").astype("float64") | |
| else: | |
| df_raw["openalex_url"] = "" | |
| df_raw["orcid_url"] = "" | |
| df_raw["h_index"] = np.nan | |
| df_raw["i10_index"] = np.nan | |
| df_raw["works_count"] = np.nan | |
| df_raw["cited_by_count"] = np.nan | |
| if only_openalex: | |
| df_raw = df_raw[df_raw["openalex_url"].map(_safe_text) != ""] | |
| if only_orcid: | |
| df_raw = df_raw[df_raw["orcid_url"].map(_safe_text) != ""] | |
| df_raw = df_raw.reset_index(drop=True) | |
| if len(df_raw) > int(top_k): | |
| df_raw = df_raw.iloc[: int(top_k)].copy() | |
| df_raw["reg_norm"] = df_raw["registration_number"].map(_norm_regnum) | |
| df_raw = df_raw.drop_duplicates(subset=["reg_norm"]).set_index("reg_norm", drop=True) | |
| if "№" in df_raw.columns: | |
| df_raw["№"] = np.arange(1, len(df_raw) + 1) | |
| fio_txt = df_raw["fio"].map(_safe_text) | |
| title_txt = df_raw["title"].map(_safe_text) | |
| title_frag = title_txt.map(_safe_fragment) | |
| vak_url = df_raw["vak_link"].map(_safe_text) | |
| title_cell = np.where(vak_url != "", vak_url + "#" + title_frag, title_txt) | |
| oa_url = df_raw["openalex_url"].map(_safe_text) | |
| oa_id = oa_url.map(_openalex_id_from_url) | |
| openalex_cell = np.where((oa_url != "") & (oa_id != ""), oa_url + "#" + oa_id, "") | |
| orcid_url = df_raw["orcid_url"].map(_safe_text) | |
| orcid_id = orcid_url.map(_orcid_id_from_url) | |
| orcid_cell = np.where((orcid_url != "") & (orcid_id != ""), orcid_url + "#" + orcid_id, "") | |
| df_ui = pd.DataFrame( | |
| { | |
| "Сходство": pd.to_numeric(df_raw["score"], errors="coerce").astype("float64"), | |
| "ФИО": fio_txt, | |
| "Название диссертации": title_cell, | |
| "Организация": df_raw["author_org_short"].map(_safe_text), | |
| "Тип": df_raw["dissertation_type"].map(_safe_text), | |
| "Год": pd.to_numeric(df_raw["protection_year"], errors="coerce").astype("float64"), | |
| "OpenAlex": openalex_cell, | |
| "ORCID": orcid_cell, | |
| "Регистрационный номер": df_raw["registration_number"].map(_safe_text), | |
| "h-index": df_raw["h_index"], | |
| "i10-index": df_raw["i10_index"], | |
| "Работ": df_raw["works_count"], | |
| "Цитат": df_raw["cited_by_count"], | |
| }, | |
| index=df_raw.index, | |
| ) | |
| df_ui.index.name = "reg_norm" | |
| df_excel_ru = df_raw.reset_index(drop=True).rename(columns=COLUMN_LABELS_RU_EXCEL) | |
| output = io.BytesIO() | |
| with pd.ExcelWriter(output, engine="xlsxwriter") as writer: | |
| df_excel_ru.to_excel(writer, index=False) | |
| output.seek(0) | |
| return df_ui, output, df_raw | |
| def format_selected_list_from_raw(df_raw: pd.DataFrame, selected_regnorms: List[str]) -> str: | |
| lines = [] | |
| for reg_norm in selected_regnorms: | |
| if df_raw is None or reg_norm not in df_raw.index: | |
| continue | |
| raw = df_raw.loc[reg_norm] | |
| fio = _safe_text(raw.get("fio")) | |
| title = _safe_text(raw.get("title")) | |
| year = _safe_text(raw.get("protection_year")) | |
| vak = _safe_text(raw.get("vak_link")) | |
| orcid = _safe_text(raw.get("orcid_url")) | |
| oa = _safe_text(raw.get("openalex_url")) | |
| link_parts = [] | |
| if vak: | |
| link_parts.append(f"[ВАК]({vak})") | |
| if orcid: | |
| link_parts.append(f"[ORCID]({orcid})") | |
| if oa: | |
| link_parts.append(f"[OpenAlex]({oa})") | |
| links_line = (" \n " + " ".join(link_parts)) if link_parts else "" | |
| lines.append(f"- **{fio}** — {title} ({year}){links_line}") | |
| return "\n".join(lines) | |
| # ========================== | |
| # UI | |
| # ========================== | |
| st.markdown( | |
| "<h1 style='text-align: center; margin-bottom: 0.5rem;'>Поиск постдока🎓</h1>", | |
| unsafe_allow_html=True, | |
| ) | |
| if "last_df_ui" not in st.session_state: | |
| st.session_state.last_df_ui = None | |
| if "last_df_raw" not in st.session_state: | |
| st.session_state.last_df_raw = None | |
| if "last_excel" not in st.session_state: | |
| st.session_state.last_excel = None | |
| if "selected_regnorms" not in st.session_state: | |
| st.session_state.selected_regnorms = set() | |
| if "search_id" not in st.session_state: | |
| st.session_state.search_id = 0 | |
| data_has_years = np.isfinite(year_arr).any() | |
| year_max = int(np.nanmax(year_arr)) if data_has_years else None | |
| with st.form("search_form"): | |
| top_k = st.slider("Сколько результатов показать", 1, 100, 20, 1) | |
| query = st.text_area( | |
| "Введите запрос", | |
| height=120, | |
| placeholder="Например: пластификаторы для самоуплотняющихся бетонов", | |
| key="query", | |
| ) | |
| with st.expander("Расширенные настройки", expanded=False): | |
| st.markdown("**Диссертации:**") | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| candidate_selected = st.checkbox("Кандидатские", value=True, key="dtype_candidate") | |
| with c2: | |
| doctor_selected = st.checkbox("Докторские", value=False, key="dtype_doctor") | |
| st.markdown("**Науки:**") | |
| cols = st.columns(3) | |
| science_selected = [] | |
| for i, label in enumerate(SCIENCE_LABELS): | |
| default_val = label in DEFAULT_SCIENCES | |
| with cols[i % 3]: | |
| if st.checkbox(label, value=default_val, key=_keyify("sci_" + label)): | |
| science_selected.append(label) | |
| st.markdown("**Годы защиты:**") | |
| if year_max is None: | |
| st.info("Годы защиты не найдены в данных — фильтр по годам недоступен.") | |
| year_range = None | |
| elif year_max < SLIDER_MIN_YEAR: | |
| st.info("В данных нет защит с 2005 года и позже — фильтр по годам недоступен.") | |
| year_range = None | |
| else: | |
| year_range = st.slider( | |
| "Диапазон лет", | |
| min_value=SLIDER_MIN_YEAR, | |
| max_value=year_max, | |
| value=(SLIDER_MIN_YEAR, year_max), | |
| step=1, | |
| ) | |
| st.markdown("**Фильтрация по профилям:**") | |
| only_openalex = st.checkbox("Отображать только с OpenAlex", value=False, key="only_openalex") | |
| only_orcid = st.checkbox("Отображать только с ORCID", value=False, key="only_orcid") | |
| st.markdown("**Настройки отображения:**") | |
| disp_cols = st.columns(3) | |
| for i, col in enumerate(UI_TABLE_COLUMNS): | |
| with disp_cols[i % 3]: | |
| st.checkbox( | |
| col, | |
| value=DEFAULT_VISIBLE_UI.get(col, True), | |
| key=_show_col_key(col), | |
| ) | |
| c1, c2, c3 = st.columns([1, 1, 1]) | |
| with c2: | |
| do_search = st.form_submit_button("🔍 Поиск", type="primary", use_container_width=True) | |
| visible_ui_cols = [c for c in UI_TABLE_COLUMNS if st.session_state.get(_show_col_key(c), True)] | |
| if not visible_ui_cols: | |
| visible_ui_cols = [c for c in UI_TABLE_COLUMNS if c not in {"Организация", "Тип", "Регистрационный номер"}] | |
| if do_search: | |
| if not candidate_selected and not doctor_selected: | |
| st.warning("Выключены оба типа диссертаций. Включите «Кандидатские» и/или «Докторские».") | |
| else: | |
| with st.spinner("Идёт поиск по базе диссертаций..."): | |
| df_ui, excel_bytes, df_raw = run_search( | |
| query=query, | |
| top_k=top_k, | |
| candidate_selected=candidate_selected, | |
| doctor_selected=doctor_selected, | |
| science_selected=science_selected, | |
| year_range=year_range, | |
| only_openalex=only_openalex, | |
| only_orcid=only_orcid, | |
| ) | |
| st.session_state.last_df_ui = df_ui | |
| st.session_state.last_df_raw = df_raw | |
| st.session_state.last_excel = excel_bytes | |
| if isinstance(df_ui, pd.DataFrame) and not df_ui.empty: | |
| st.session_state.selected_regnorms = set(st.session_state.selected_regnorms) & set(df_ui.index) | |
| else: | |
| st.session_state.selected_regnorms = set() | |
| st.session_state.search_id += 1 | |
| df_ui_saved = st.session_state.last_df_ui | |
| df_raw_saved = st.session_state.last_df_raw | |
| excel_saved = st.session_state.last_excel | |
| if isinstance(df_ui_saved, pd.DataFrame) and not df_ui_saved.empty: | |
| st.success(f"Найдено записей: {len(df_ui_saved)}") | |
| selected_set = set(st.session_state.selected_regnorms) & set(df_ui_saved.index) | |
| st.session_state.selected_regnorms = selected_set | |
| df_display = df_ui_saved.copy() | |
| df_display.insert(0, "Выбрать", df_display.index.map(lambda x: x in selected_set)) | |
| show_cols = [c for c in visible_ui_cols if c in df_display.columns] | |
| df_edit = df_display[["Выбрать"] + show_cols].copy() | |
| full_column_config = { | |
| "Выбрать": st.column_config.CheckboxColumn("Выбрать", width="small"), | |
| "Сходство": st.column_config.NumberColumn("Сходство", format="%.4f", width="small"), | |
| "ФИО": st.column_config.TextColumn("ФИО", width="medium"), | |
| "Название диссертации": st.column_config.LinkColumn( | |
| "Название диссертации", | |
| display_text=r"(?:.*#)?(.*)$", | |
| width="large", | |
| help="Название ведёт на ВАК (если ссылка есть).", | |
| validate=r"^https?://.+#.+$|^.+$", | |
| ), | |
| "Организация": st.column_config.TextColumn("Организация", width="large"), | |
| "Тип": st.column_config.TextColumn("Тип", width="small"), | |
| "Год": st.column_config.NumberColumn("Год", format="%.0f", width="small"), | |
| "OpenAlex": st.column_config.LinkColumn( | |
| "OpenAlex", | |
| display_text=r"(?:.*#)?(.*)$", | |
| width="small", | |
| help="ID автора в OpenAlex (если найден).", | |
| validate=r"^https?://openalex\.org/A\d+#A\d+$|^$", | |
| ), | |
| "ORCID": st.column_config.LinkColumn( | |
| "ORCID", | |
| display_text=r"(?:.*#)?(.*)$", | |
| width="small", | |
| help="ORCID автора (если найден).", | |
| validate=r"^https?://orcid\.org/0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4}#0000-[0-9X]{4}-[0-9X]{4}-[0-9X]{4}$|^$", | |
| ), | |
| "Регистрационный номер": st.column_config.TextColumn("Регистрационный номер", width="medium"), | |
| "h-index": st.column_config.NumberColumn("h-index", format="%.0f", width="small"), | |
| "i10-index": st.column_config.NumberColumn("i10-index", format="%.0f", width="small"), | |
| "Работ": st.column_config.NumberColumn("Работ", format="%.0f", width="small"), | |
| "Цитат": st.column_config.NumberColumn("Цитат", format="%.0f", width="small"), | |
| } | |
| column_config_filtered = {k: v for k, v in full_column_config.items() if k in df_edit.columns} | |
| edited = st.data_editor( | |
| df_edit, | |
| use_container_width=True, | |
| hide_index=True, | |
| num_rows="fixed", | |
| column_config=column_config_filtered, | |
| disabled=[c for c in df_edit.columns if c != "Выбрать"], | |
| key=f"editor_{st.session_state.search_id}", | |
| ) | |
| if isinstance(edited, pd.DataFrame) and "Выбрать" in edited.columns: | |
| st.session_state.selected_regnorms = set(edited.index[edited["Выбрать"] == True].tolist()) | |
| selected_regnorms = sorted(list(st.session_state.selected_regnorms)) | |
| if selected_regnorms: | |
| with st.expander("Полный текст и ссылки (для выбранных строк)", expanded=False): | |
| for reg_norm in selected_regnorms[:50]: | |
| if df_raw_saved is None or reg_norm not in df_raw_saved.index: | |
| continue | |
| raw = df_raw_saved.loc[reg_norm] | |
| fio = _safe_text(raw.get("fio")) | |
| title = _safe_text(raw.get("title")) | |
| org = _safe_text(raw.get("author_org_short")) | |
| year = _safe_text(raw.get("protection_year")) | |
| vak = _safe_text(raw.get("vak_link")) | |
| orcid = _safe_text(raw.get("orcid_url")) | |
| oa = _safe_text(raw.get("openalex_url")) | |
| links = [] | |
| if vak: | |
| links.append(f"[ВАК]({vak})") | |
| if orcid: | |
| links.append(f"[ORCID]({orcid})") | |
| if oa: | |
| links.append(f"[OpenAlex]({oa})") | |
| links_md = (" \n " + " ".join(links)) if links else "" | |
| st.markdown( | |
| f"- **{fio}** — {title}\n" | |
| f" \n {org} ({year}){links_md}" | |
| ) | |
| if excel_saved is not None: | |
| st.download_button( | |
| label="💾 Скачать результаты в Excel", | |
| data=excel_saved, | |
| file_name="search_results.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| ) | |
| st.markdown("---") | |
| st.subheader("Запрос расширенной информации") | |
| with st.form("request_form"): | |
| requester_fio = st.text_input("Ваше ФИО", placeholder="Иванов Иван Иванович") | |
| requester_email = st.text_input("Email", placeholder="name@example.com") | |
| st.markdown("**Перечень диссертаций:**") | |
| if selected_regnorms: | |
| st.markdown(format_selected_list_from_raw(df_raw_saved, selected_regnorms)) | |
| else: | |
| st.info("Отметьте диссертации чекбоксом «Выбрать» — здесь появится перечень.") | |
| comment = st.text_area( | |
| "Комментарий", | |
| height=160, | |
| placeholder=( | |
| "Какую дополнительную информацию по авторам диссертаций вы хотите получить?\n" | |
| "Какие замечания/пожелания по функционалу системы?" | |
| ), | |
| ) | |
| send_request = st.form_submit_button("📨 Отправить запрос", type="primary", use_container_width=True) | |
| if send_request: | |
| if not requester_fio.strip() or not requester_email.strip(): | |
| st.warning("Поля «Ваше ФИО» и «Email» обязательны. Заполните их, чтобы отправить запрос.") | |
| elif len(selected_regnorms) == 0: | |
| st.warning("Выберите хотя бы одну диссертацию (чекбокс «Выбрать»).") | |
| else: | |
| items = [] | |
| for reg_norm in selected_regnorms: | |
| if df_raw_saved is None or reg_norm not in df_raw_saved.index: | |
| continue | |
| raw = df_raw_saved.loc[reg_norm].to_dict() | |
| items.append( | |
| { | |
| "author_fio": raw.get("fio"), | |
| "title": raw.get("title"), | |
| "org": raw.get("author_org_short"), | |
| "year": raw.get("protection_year"), | |
| "vak_link": raw.get("vak_link"), | |
| "registration_number": raw.get("registration_number"), | |
| "score": raw.get("score"), | |
| "openalex_url": raw.get("openalex_url", ""), | |
| "orcid_url": raw.get("orcid_url", ""), | |
| "h_index": raw.get("h_index"), | |
| "i10_index": raw.get("i10_index"), | |
| "works_count": raw.get("works_count"), | |
| "cited_by_count": raw.get("cited_by_count"), | |
| } | |
| ) | |
| payload = { | |
| "created_at_utc": datetime.now(timezone.utc).isoformat(), | |
| "requester": { | |
| "fio": requester_fio.strip(), | |
| "email": requester_email.strip(), | |
| "comment": (comment or "").strip(), | |
| }, | |
| "items_count": len(items), | |
| "items": items, | |
| } | |
| try: | |
| path = save_request_to_hub(payload) | |
| st.success(f"Запрос {path} сохранен") | |
| except Exception as e: | |
| st.error( | |
| "Не удалось сохранить запрос в репозиторий.\n\n" | |
| f"Ошибка: {e}\n\n" | |
| "Проверьте HF_WRITE_TOKEN (write) и repo_type." | |
| ) | |
| else: | |
| st.info("Введите запрос и нажмите «Поиск». После этого можно выбрать диссертации и отправить запрос.") | |
| st.markdown( | |
| "<p style='font-size: 0.8rem; text-align: right; color: gray;'>(с) Антон Лощилов, 2025</p>", | |
| unsafe_allow_html=True, | |
| ) | |