File size: 9,559 Bytes
768c0e1
37e80a1
768c0e1
e9f7a6f
768c0e1
37e80a1
e9f7a6f
e66288a
 
37e80a1
 
 
 
e9f7a6f
37e80a1
d13eafe
37e80a1
 
e9f7a6f
37e80a1
6b1ee78
 
 
37e80a1
50f9bde
 
 
 
e66288a
 
 
7a04a3e
6091ba1
e66288a
33b9512
50f9bde
e9f7a6f
33b9512
e66288a
 
 
 
 
 
 
777473a
e66288a
 
 
37e80a1
 
 
 
 
 
 
33b9512
e66288a
 
 
 
 
6091ba1
37e80a1
 
 
 
e16368c
37e80a1
6b1ee78
e66288a
 
 
37e80a1
e66288a
 
 
 
6b1ee78
e66288a
 
 
37e80a1
e66288a
 
 
37e80a1
e66288a
37e80a1
 
 
e66288a
e9f7a6f
 
e16368c
37e80a1
 
 
e9f7a6f
768c0e1
37e80a1
 
 
 
 
 
 
 
 
 
 
768c0e1
6091ba1
e9f7a6f
 
 
 
 
33b9512
e9f7a6f
 
 
c329945
768c0e1
6091ba1
 
 
e9f7a6f
768c0e1
6091ba1
37e80a1
 
 
6091ba1
 
37e80a1
 
768c0e1
 
 
 
6091ba1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768c0e1
 
 
 
 
 
 
 
 
 
 
 
cda924e
 
 
768c0e1
 
 
e66288a
 
 
 
cda924e
e66288a
 
768c0e1
 
 
e9f7a6f
768c0e1
e66288a
768c0e1
 
e66288a
768c0e1
 
 
6091ba1
768c0e1
 
e66288a
7a04a3e
e66288a
768c0e1
6091ba1
768c0e1
 
e66288a
 
 
 
 
33b9512
e66288a
 
33b9512
e66288a
 
 
 
 
 
7a04a3e
e66288a
 
 
 
 
 
33b9512
37e80a1
 
e66288a
37e80a1
768c0e1
e66288a
768c0e1
 
37e80a1
 
 
768c0e1
33b9512
777473a
7e0c7a1
777473a
 
 
33b9512
96019d3
 
 
 
 
 
 
 
e9f7a6f
96019d3
 
 
 
 
 
37e80a1
33b9512
e03b320
33b9512
 
 
7e0c7a1
33b9512
 
37e80a1
d13eafe
37e80a1
 
6091ba1
37e80a1
e66288a
6091ba1
37e80a1
e66288a
37e80a1
cda924e
 
 
 
 
 
e66288a
cda924e
 
 
 
 
 
 
e16368c
cda924e
 
 
 
e66288a
7a04a3e
37e80a1
cda924e
 
7a04a3e
 
 
 
 
 
 
 
 
37e80a1
 
96019d3
 
d13eafe
 
 
20c5b5c
d13eafe
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
import os
import io

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

# ==========================
# НАСТРОЙКИ ПРИЛОЖЕНИЯ
# ==========================

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")

# Отображаемые заголовки (русские имена колонок)
COLUMN_LABELS_RU = {
    "№": "№",
    "score": "Сходство",
    "fio": "ФИО",
    "title": "Название диссертации",
    "author_org_short": "Организация",
    "dissertation_type": "Тип",
    "protection_year": "Год",
    "registration_number": "Регистрационный номер",
    "vak_link": "Ссылка ВАК",   # только в Excel
}

# Порядок колонок в выдаче (для интерфейса)
DISPLAY_COLUMNS = [
    "№",
    "score",
    "fio",
    "title",
    "author_org_short",
    "dissertation_type",
    "protection_year",
    "registration_number",
]

if HF_TOKEN is None:
    st.error(
        "Не найден секрет `HF_TOKEN`. "
        "Задайте его в Settings → Variables and secrets вашего Space."
    )
    st.stop()

# Логин в Hugging Face Hub (для приватных датасетов)
try:
    login(token=HF_TOKEN)
except Exception:
    pass


# ==========================
# ЗАГРУЗКА ДАННЫХ И МОДЕЛИ
# ==========================

@st.cache_data(show_spinner="Загрузка данных...")
def load_data():
    # 1) МЕТА-ДАННЫЕ
    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)
    df_meta = df_meta.set_index("registration_number", drop=False)

    # 2) ЭМБЕДДИНГИ
    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)
    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)

    return df_meta, reg_nums, emb_matrix


@st.cache_resource(show_spinner="Загрузка модели...")
def load_model():
    model = SentenceTransformer(MODEL_NAME)
    return model


try:
    df_all, reg_nums, emb_matrix = load_data()
    model = load_model()
except Exception as e:
    st.error(f"Ошибка при загрузке данных или модели: {e}")
    st.stop()


# ==========================
# ЛОГИКА ПОИСКА
# ==========================

def search_core(query: str, top_k: int = 10):
    query = query.strip()
    if not query:
        return []

    query_text = "query: " + query

    q_emb = model.encode(
        query_text,
        normalize_embeddings=True,
    )

    scores = emb_matrix @ q_emb
    top_k = min(int(top_k), len(scores))
    top_idx = np.argsort(-scores)[:top_k]

    results = []
    for rank, idx in enumerate(top_idx, start=1):
        results.append(
            {
                "rank": rank,
                "registration_number": reg_nums[idx],
                "score": float(scores[idx]),
            }
        )

    return results


def extract_year(value):
    """Аккуратно вытаскиваем год защиты из поля protection_date."""
    if value is None:
        return None
    try:
        if not isinstance(value, str):
            if pd.isna(value):
                return None
            dt = pd.to_datetime(value)
            return str(dt.year)
    except Exception:
        pass

    s = str(value).strip()
    if len(s) >= 4 and s[:4].isdigit():
        return s[:4]
    return None


def build_result_df(results):
    rows = []

    for r in results:
        reg = r["registration_number"]
        score = r["score"]

        if reg in df_all.index:
            meta = df_all.loc[reg]
        else:
            meta = pd.Series({}, index=df_all.columns)

        protection_date = meta.get("protection_date", None)
        protection_year = extract_year(protection_date)

        row = {
            "№": r["rank"],
            "score": round(score, 4),
            "fio": meta.get("fio", None),
            "title": meta.get("title", None),
            "author_org_short": meta.get("author_org_short", None),
            "dissertation_type": meta.get("dissertation_type", None),
            "protection_year": protection_year,
            "registration_number": meta.get("registration_number", reg),
            "vak_link": meta.get("vak_link", ""),
        }

        rows.append(row)

    if not rows:
        return pd.DataFrame(columns=DISPLAY_COLUMNS + ["vak_link"])

    df_res = pd.DataFrame(rows)
    df_res = df_res[DISPLAY_COLUMNS + ["vak_link"]]
    return df_res


def run_search(query: str, top_k: int):
    query = query.strip()
    if not query:
        empty_df = pd.DataFrame(columns=DISPLAY_COLUMNS + ["vak_link"])
        empty_df_ru = empty_df.rename(columns=COLUMN_LABELS_RU)
        return empty_df_ru, None

    results = search_core(query, top_k)
    df_res = build_result_df(results)

    if df_res.empty:
        empty_df_ru = df_res.rename(columns=COLUMN_LABELS_RU)
        return empty_df_ru, None

    # --- Разделяем датафреймы для отображения и для Excel ---
    df_excel = df_res.copy()       # vak_link оставляем
    df_display = df_res.copy()

    # HTML-ссылка в колонке "Тип"
    def make_type_cell(row):
        t = row.get("dissertation_type", "")
        link = row.get("vak_link") or ""
        if isinstance(t, str) and t and link:
            return f'<a href="{link}" target="_blank">{t}</a>'
        return t

    df_display["dissertation_type"] = df_display.apply(make_type_cell, axis=1)
    df_display = df_display.drop(columns=["vak_link"])

    df_display_ru = df_display.rename(columns=COLUMN_LABELS_RU)
    df_excel_ru = df_excel.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_display_ru, output


# ==========================
# UI НА STREAMLIT
# ==========================

# Заголовок по центру
st.markdown(
    "<h1 style='text-align: center; margin-bottom: 0.5rem;'>Поиск постдока🎓</h1>",
    unsafe_allow_html=True,
)

# Форма: Ctrl+Enter = отправка
with st.form("search_form"):
    top_k = st.slider(
        "Сколько результатов показать",
        min_value=1,
        max_value=100,
        value=20,
        step=1,
    )

    query = st.text_area(
        "Введите запрос",
        height=120,
        placeholder="Например: пластификаторы для самоуплотняющихся бетонов",
        key="query",
    )

    # Кнопка по центру через три колонки
    c1, c2, c3 = st.columns([1, 1, 1])
    with c1:
        st.write("")
    with c2:
        do_search = st.form_submit_button("🔍 Поиск", type="primary",use_container_width=True )
    with c3:
        st.write("")

# Результаты
if do_search:
    with st.spinner("Идёт поиск по базе диссертаций..."):
        df_res_ru, excel_bytes = run_search(query, top_k)

    if df_res_ru.empty:
        st.warning("Ничего не найдено. Попробуйте изменить формулировку запроса.")
    else:
        st.success(f"Найдено записей: {len(df_res_ru)}")

        table_html = df_res_ru.to_html(
            escape=False,
            index=False,
            classes="result-table",
        )

        st.markdown(
            """
            <style>
            table.result-table {
                width: 100%;
                border-collapse: collapse;
            }
            table.result-table th {
                text-align: center !important;
                vertical-align: middle;
            }
            </style>
            """,
            unsafe_allow_html=True,
        )

        st.markdown(table_html, unsafe_allow_html=True)

        if excel_bytes is not None:
            st.download_button(
                label="💾 Скачать результаты в Excel",
                data=excel_bytes,
                file_name="search_results.xlsx",
                mime=(
                    "application/vnd.openxmlformats-officedocument."
                    "spreadsheetml.sheet"
                ),
            )
else:
    st.info("Введите запрос выше и нажмите кнопку «Поиск» "
            "или нажмите Ctrl+Enter в поле ввода.")

# Футер
st.markdown(
    "<p style='font-size: 0.8rem; text-align: right; color: gray;'>"
    "(с) Антон Лощилов, 2025"
    "</p>",
    unsafe_allow_html=True,
)