Kapex13 commited on
Commit
a959979
·
verified ·
1 Parent(s): 0fc7394

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +164 -246
src/streamlit_app.py CHANGED
@@ -1,5 +1,7 @@
1
- import streamlit as st
 
2
  import os
 
3
  import pandas as pd
4
  import numpy as np
5
  import faiss
@@ -10,38 +12,28 @@ from langchain_core.messages import SystemMessage, HumanMessage
10
  import ast
11
  import random
12
  import tempfile
13
- import time
14
 
15
- # --- Настройки путей и констант ---
16
  HERE = os.path.dirname(os.path.abspath(__file__))
17
  CSV_PATH = os.path.join(HERE, "tvshows_processed2.csv")
18
  EMB_PATH = os.path.join(HERE, "embeddings.npy")
19
  FAISS_PATH = os.path.join(HERE, "faiss_index.index")
20
 
21
- # --- Базовые жанры для нормализации ---
22
  BASIC_GENRES = [
23
  "комедия", "драма", "боевик", "фэнтези", "ужасы", "триллер", "романтика",
24
  "научная фантастика", "приключения", "криминал", "мюзикл",
25
- "семейный", "детектив", "биография", "документальный"
26
  ]
27
  BAD_ACTORS = [
28
  "я не знаю что делать", "я не знаю", "нет информации", "не указан",
29
  "нет актёров", "нет актеров", "unknown", "—", ""
30
  ]
31
  BAD_PHRASE_PARTS = [
32
- "нет описания", "без описания", "неизвестно", "описание отсутствует", "пусто"
 
33
  ]
34
- GENRE_KEYWORDS_MAP = {
35
- "доктор": "драма", "медицина": "драма", "врач": "драма",
36
- "школа": "драма", "комедия": "комедия", "ужас": "ужасы",
37
- "фантастика": "научная фантастика", "боевик": "боевик",
38
- "криминал": "криминал", "приключения": "приключения",
39
- "романтика": "романтика", "прогулки": "документальный",
40
- "природа": "документальный", "война": "боевик",
41
- "волшебство": "фэнтези", "дракон": "фэнтези"
42
- }
43
 
44
- # --- Вспомогательные функции ---
45
  def list_str_to_text(x):
46
  try:
47
  lst = ast.literal_eval(x) if isinstance(x, str) else x
@@ -76,40 +68,31 @@ def clean_tvshows_data(path):
76
  df["num_seasons"] = pd.to_numeric(df.get("num_seasons", 0), errors="coerce").fillna(0).astype(int)
77
  df["tvshow_title"] = df.get("tvshow_title", "").fillna("Неизвестно")
78
  df["description"] = df.get("description", "").fillna("Нет описания").astype(str).str.strip()
79
- df = df[df["description"].apply(lambda x: len(str(x).split()) >= 15)]
80
-
 
 
 
 
81
  garbage_patterns = [
82
  r"(всё в порядке[.!?~ ,]*){3,}",
83
  r"(я не знаю[^.!?]*){2,}",
84
  r"(ладно[.,\s]*){3,}",
85
  r"(о[ауе]?[^\w]*){5,}",
86
  r"(нет[.,\s]*){5,}",
87
- r"(\s*15\s*лет\s*){2,}",
88
- r"(\s*ё\s*){2,}",
89
- r"(\s*ј\s*){2,}",
90
- r"(\s*ѕј\s*){2,}",
91
- r"(.)\1{3,}",
92
- r"(\s*[.,;!?'`~]{2,}\s*)",
93
- r"(\s*[0-9]{2,}\s*)",
94
  ]
95
  def matches_garbage(text):
96
  t = str(text).lower()
97
  return any(re.search(p, t) for p in garbage_patterns)
98
  df = df[~df["description"].apply(matches_garbage)]
99
-
100
- try:
101
- to_drop_exact = df["description"].value_counts()[lambda x: x >= 3].index
102
- df = df[~df["description"].isin(to_drop_exact)]
103
- except Exception:
104
- pass
105
-
106
  df = df[~df["description"].str.lower().apply(lambda text: any(phrase in text for phrase in BAD_PHRASE_PARTS))]
107
-
108
  cols_to_ignore = {
109
  'tvshow_title','year','genres','actors','rating','description',
110
  'image_url','url','language','country','directors','page_url','num_seasons'
111
  }
112
- genre_onehots = [c for c in df.columns if c not in cols_to_ignore and df[c].nunique() <= 2]
 
 
113
  df = df.drop(columns=genre_onehots, errors="ignore")
114
  df["basic_genres"] = df["genres"].apply(filter_to_basic_genres)
115
  df["type"] = df["num_seasons"].apply(lambda x: "Сериал" if pd.notna(x) and int(x) > 1 else "Фильм")
@@ -118,7 +101,7 @@ def clean_tvshows_data(path):
118
  df[col] = None
119
  return df.reset_index(drop=True)
120
 
121
- # --- Кэширование и инициализация ---
122
  @st.cache_data
123
  def cached_load_data(path):
124
  return clean_tvshows_data(path)
@@ -132,222 +115,158 @@ def cached_init_embedder():
132
  @st.cache_resource
133
  def cached_load_embeddings_and_index():
134
  if not os.path.exists(EMB_PATH) or not os.path.exists(FAISS_PATH):
135
- st.warning("Файлы эмбеддингов или индекса не найдены. Создаем новые...")
136
- df = cached_load_data(CSV_PATH)
137
- embedder = cached_init_embedder()
138
-
139
- # Улучшенное формирование текста для эмбеддинга
140
- texts = df.apply(
141
- lambda row: f"Название: {row['tvshow_title']}. Описание: {row['description']}. Жанр: {row['genres']}. Актеры: {row['actors']}.",
142
- axis=1
143
- ).tolist()
144
-
145
- embeddings = embedder.encode(texts, show_progress_bar=True)
146
- faiss.normalize_L2(embeddings)
147
- np.save(EMB_PATH, embeddings)
148
-
149
- index = faiss.IndexFlatIP(embeddings.shape[1])
150
- index.add(embeddings)
151
- faiss.write_index(index, FAISS_PATH)
152
-
153
- st.success("Новые эмбеддинги и индекс успешно созданы. Пожалуйста, обновите страницу, чтобы продолжить.")
154
-
155
  embeddings = np.load(EMB_PATH)
156
  index = faiss.read_index(FAISS_PATH)
157
  return embeddings, index
158
 
159
- @st.cache_resource(ttl=3600)
160
- def init_groq_llm():
161
- key = st.secrets.get("GROQ_API_KEY") or st.text_input("🔐 Введите API-ключ Groq:", type="password")
162
- if not key: return None
163
- os.environ["GROQ_API_KEY"] = key
164
- return ChatGroq(model="deepseek-r1-distill-llama-70b", temperature=0, max_tokens=2000)
165
-
166
- # --- Автоматическое определение жанра из запроса ---
167
- def infer_genre_from_query(query):
168
- query_lower = query.lower()
169
- for keyword, genre in GENRE_KEYWORDS_MAP.items():
170
- if keyword in query_lower:
171
- return genre
172
- return None
173
 
174
- # --- Семантический поиск с гибридным ранжированием ---
175
  def semantic_search(query, embedder, index, df, genre=None, year=None, country=None, vtype=None, k=5):
176
  if not isinstance(query, str) or not query.strip():
177
  return pd.DataFrame()
178
-
179
- inferred_genre = infer_genre_from_query(query)
180
- if inferred_genre and (genre is None or genre == "Все"):
181
- genre = inferred_genre
182
-
183
  query_embedding = embedder.encode([query])
184
  faiss.normalize_L2(query_embedding)
185
-
186
- n_search = 500 # Увеличили количество для более широкого поиска
187
  dists, idxs = index.search(query_embedding, n_search)
188
-
189
  valid_idxs = [i for i in idxs[0] if i >= 0 and i < len(df)]
190
  if not valid_idxs:
191
  return pd.DataFrame()
192
-
193
  res = df.iloc[valid_idxs].copy()
194
  res["score"] = dists[0][:len(valid_idxs)]
195
-
196
- # Применяем фильтрацию
197
  if genre and genre != "Все":
198
- genre_lower = genre.lower()
199
- res = res[res["basic_genres"].str.lower().str.contains(genre_lower, na=False)]
200
-
201
  if year and year != "Все":
202
  try:
203
  res = res[res["year"] == int(year)]
204
  except:
205
  pass
206
-
207
  if country and country != "Все":
208
- country_lower = country.lower()
209
- res = res[res["country"].astype(str).str.lower().str.contains(country_lower, na=False)]
210
-
211
  if vtype and vtype != "Все":
212
- res = res[res["type"].str.lower() == vtype.lower()]
213
-
214
  if res.empty:
215
  return res
 
216
 
217
- # --- Гибридное ранжирование ---
218
- query_lower = query.lower()
219
-
220
- res['exact_match_title'] = res['tvshow_title'].str.lower() == query_lower
221
-
222
- query_words = re.findall(r'\b\w+\b', query_lower)
223
- keyword_pattern = '|'.join([re.escape(word) for word in query_words if len(word) > 2])
224
-
225
- if keyword_pattern:
226
- res['has_keyword'] = res.apply(
227
- lambda row: bool(re.search(keyword_pattern, str(row['tvshow_title']).lower())) or
228
- bool(re.search(keyword_pattern, str(row['description']).lower())),
229
- axis=1
230
- )
231
- else:
232
- res['has_keyword'] = False
233
-
234
- res['final_score'] = res['score']
235
- res['final_score'] = np.where(res['exact_match_title'], res['final_score'] + 1.5, res['final_score'])
236
- res['final_score'] = np.where(res['has_keyword'], res['final_score'] + 0.4, res['final_score'])
237
-
238
- sorted_results = res.sort_values(by="final_score", ascending=False)
239
-
240
- return sorted_results.head(k)
241
-
242
- # --- Форматирование результатов для LLM ---
243
  def format_docs_for_prompt(results_df):
244
  parts = []
245
- if results_df.empty:
246
- return "Нет подходящих результатов поиска в базе данных."
247
  for _, row in results_df.iterrows():
248
  parts.append(
249
- f"Название: {row['tvshow_title']} ({row['year']})\n"
250
  f"Жанр: {row['basic_genres']}\n"
251
  f"Рейтинг: {row['rating'] or '—'} | Тип: {row['type']} | "
252
- f"Страна: {row['country'] or '—'} | Сезонов: {row['num_seasons'] or '—'}\n"
253
- f"Актёры: {row['actors']}\nСюжет: {extract_intro_paragraph(row['description'])}"
254
  )
255
  return "\n\n".join(parts)
256
 
257
  def generate_rag_response(user_query, search_results, llm):
258
- if llm is None:
259
- return "LLM не инициализирован."
260
-
261
  ctx = format_docs_for_prompt(search_results)
262
-
263
- prompt_template = """
264
- Ты — эксперт по кино и сериалам. Твоя задача — помочь пользователю, основываясь на предоставленных ниже результатах поиска.
265
-
266
- Твой основной источник информации — предоставленные результаты поиска.
267
- 1. Сначала проанализируй, насколько предоставленные результаты поиска релевантны запросу пользователя.
268
- 2. Если результаты релевантны, объясни почему и суммируй их.
269
- 3. Если результаты нерелевантны, **прямо об этом скажи** и объясни, что в базе данных не найдено ничего подходящего.
270
- 4. В любом случае, после анализа, предложи **1-2 дополнительных фильма или сериала, которые идеально подходят** под запрос пользователя, используя только свои общие знания, даже если их нет в результатах поиска.
271
-
272
- Результаты поиска:
273
- {context}
274
-
275
- Вопрос пользователя: {question}
276
-
277
- Ответ:
278
- """
279
-
280
- full_prompt = prompt_template.format(context=ctx, question=user_query)
281
-
282
  try:
283
- response = llm.invoke([
284
- SystemMessage(content="Ты — эксперт по кино и сериалам. Всегда основывайся на предоставленном контексте и не придумывай лишнего."),
285
- HumanMessage(content=full_prompt)
286
- ]).content.strip()
287
- return response
288
  except Exception as e:
289
  return f"Ошибка при генерации ответа LLM: {e}"
290
 
291
- # --- UI: main ---
292
  def main():
293
- st.set_page_config(page_title="🎬 Поиск фильмов и сериалов + Groq AI", layout="wide")
294
- st.title("📽️ Семантический поиск фильмов и сериалов с AI")
295
 
 
296
  if "df" not in st.session_state:
297
- st.session_state.df = cached_load_data(CSV_PATH)
 
 
 
 
 
 
 
 
298
  if "embedder" not in st.session_state:
299
- st.session_state.embedder = cached_init_embedder()
 
 
 
 
 
300
  if "embeddings_index" not in st.session_state:
301
- with st.spinner("Загрузка эмбеддингов и индекса..."):
302
  st.session_state.embeddings, st.session_state.index = cached_load_embeddings_and_index()
 
 
 
 
 
 
 
303
  if "llm" not in st.session_state:
304
- st.session_state.llm = init_groq_llm()
 
305
 
306
- if 'last_query' not in st.session_state: st.session_state.last_query = ""
307
- if 'results' not in st.session_state: st.session_state.results = pd.DataFrame()
308
- if 'ai_clicked' not in st.session_state: st.session_state.ai_clicked = False
309
-
310
  df = st.session_state.df
311
  embedder = st.session_state.embedder
312
  index = st.session_state.index
313
  llm = st.session_state.llm
314
 
315
- with st.container():
316
- st.markdown("---")
317
- with st.form(key='search_form'):
318
- colf1, colf2, colf3, colf4 = st.columns(4)
319
- with colf1:
320
- basic_genres_list = sorted(list(set(g.strip() for g in ", ".join(df["basic_genres"].dropna().unique()).split(","))))
321
- genres = ["Все"] + [g for g in basic_genres_list if g]
322
- genre_filter = st.selectbox("Жанр", genres, index=0, key="genre_filter_key")
323
- with colf2:
324
- years = ["Все"] + [str(y) for y in sorted(df["year"].unique()) if y != 0]
325
- year_filter = st.selectbox("Год", years, index=0, key="year_filter_key")
326
- with colf3:
327
- countries = ["Все"] + sorted([c for c in df["country"].dropna().unique()])
328
- country_filter = st.selectbox("Страна", countries, index=0, key="country_filter_key")
329
- with colf4:
330
- vtypes = ["Все"] + sorted(df["type"].dropna().unique())
331
- type_filter = st.selectbox("Тип", vtypes, index=0, key="type_filter_key")
332
-
333
- k = st.slider("📊 Количество результатов:", 1, 20, 5, key="k_slider")
334
- user_input = st.text_input("🔎 Введите ключевые слова или сюжет:", key="user_input_key")
335
-
336
- col_buttons = st.columns(4)
337
- with col_buttons[0]:
338
- random_search = st.form_submit_button("🎲 Случайный фильм/сериал")
339
- with col_buttons[1]:
340
- genre_search = st.form_submit_button("🔥 ТОП по жанру")
341
- with col_buttons[2]:
342
- new_search = st.form_submit_button("🆕 Новинки")
343
- with col_buttons[3]:
344
- text_search = st.form_submit_button("🔍 Искать")
345
-
346
- if text_search:
347
- if not user_input.strip():
348
- st.warning("Введите запрос для поиска.")
349
- else:
350
- st.session_state.last_query = user_input
 
 
 
 
 
 
 
351
  st.session_state.results = semantic_search(
352
  user_input, embedder, index, df,
353
  genre_filter, year_filter, country_filter, type_filter, k
@@ -356,82 +275,81 @@ def main():
356
  elif random_search:
357
  random_query = random.choice(df["tvshow_title"].tolist())
358
  st.session_state.last_query = random_query
359
- st.session_state.results = semantic_search(
360
- random_query, embedder, index, df,
361
- genre_filter, year_filter, country_filter, type_filter, k
362
- )
363
- st.session_state.ai_clicked = False
 
 
364
  elif genre_search and genre_filter != "Все":
365
- st.session_state.last_query = f"Лучшие фильмы и сериалы в жанре {genre_filter}"
366
- st.session_state.results = semantic_search(
367
- st.session_state.last_query, embedder, index, df,
368
- genre_filter, year_filter, country_filter, type_filter, k
369
- )
370
- st.session_state.ai_clicked = False
 
 
371
  elif new_search:
372
- new_query = f"Самые новые фильмы и сериалы {df['year'].max()}"
373
  st.session_state.last_query = new_query
374
- st.session_state.results = semantic_search(
375
- new_query, embedder, index, df,
376
- genre_filter, year_filter, country_filter, type_filter, k
377
- )
378
- st.session_state.ai_clicked = False
379
-
380
- results_container = st.container()
381
- ai_response_container = st.container()
 
 
 
 
382
 
383
  with results_container:
384
- st.markdown("## 🔎 Результаты поиска")
385
  results_exist = isinstance(st.session_state.get("results"), pd.DataFrame) and not st.session_state.results.empty
386
-
387
  if not results_exist:
388
- if st.session_state.last_query:
389
- st.warning(f"🤷 Ничего не найдено по запросу: '{st.session_state.last_query}'.")
390
  else:
391
- st.info("👋 Введите запрос или выберите один из вариантов ниже.")
392
  else:
393
  res_df = st.session_state.results
394
- st.success(f"Найдено: {len(res_df)}")
395
  for _, row in res_df.iterrows():
396
- col1, col2 = st.columns([1, 3])
397
- with col1:
398
- image_url = row.get("image_url")
399
- if image_url and isinstance(image_url, str) and (image_url.startswith('http') or image_url.startswith('https')):
400
  try:
401
- st.image(image_url, width=150)
402
  except Exception:
403
- st.info("🤷‍♂️ Нет изображения.")
404
  else:
405
- st.info("🤷‍♂️ Нет изображения.")
406
- with col2:
407
  st.markdown(f"### {row['tvshow_title']} ({row['year']})")
408
- st.caption(
409
- f"🎭 {row['basic_genres']} | 📍 {row['country'] or '—'}"
410
- f" | ⭐ {row['rating'] or '—'}"
411
- f" | 🎬 {row['type']} | 📺 {row['num_seasons']} сез."
412
- )
413
  st.write(extract_intro_paragraph(row["description"]))
414
  if row.get("actors"):
415
- st.caption(f"👥 Актёры: {row['actors']}")
416
  if row.get("url"):
417
- st.markdown(f"[🔗 Подробнее]({row['url']})")
418
  st.divider()
419
 
420
- if st.session_state.llm and not st.session_state.results.empty:
421
- if st.button("🧠 AI: почему эти подходят и что ещё посмотреть", key="ai_button"):
422
- st.session_state.ai_clicked = True
423
 
424
  with ai_response_container:
425
- if st.session_state.get("ai_clicked") and st.session_state.get("last_query"):
426
- st.markdown("### 🤖 Рекомендации AI:")
427
  with st.spinner("Генерация ответа AI..."):
428
  rag = generate_rag_response(st.session_state.last_query, st.session_state.results, llm)
429
  st.write(rag)
430
 
431
- st.sidebar.markdown("---")
432
- st.sidebar.markdown("## ℹ️ Информация")
433
- st.sidebar.write(f"Всего записей в базе: {len(df)}")
434
- st.sidebar.markdown(f"**Статус Groq LLM:** {'🟢 Готов' if llm else '🔴 Отключён (нужен API-ключ)'}")
435
 
436
  if __name__ == "__main__":
437
  main()
 
1
+
2
+
3
  import os
4
+ import streamlit as st
5
  import pandas as pd
6
  import numpy as np
7
  import faiss
 
12
  import ast
13
  import random
14
  import tempfile
 
15
 
16
+ # ====== Настройки путей и констант ======
17
  HERE = os.path.dirname(os.path.abspath(__file__))
18
  CSV_PATH = os.path.join(HERE, "tvshows_processed2.csv")
19
  EMB_PATH = os.path.join(HERE, "embeddings.npy")
20
  FAISS_PATH = os.path.join(HERE, "faiss_index.index")
21
 
 
22
  BASIC_GENRES = [
23
  "комедия", "драма", "боевик", "фэнтези", "ужасы", "триллер", "романтика",
24
  "научная фантастика", "приключения", "криминал", "мюзикл",
25
+ "семейный", "детектив", "биография"
26
  ]
27
  BAD_ACTORS = [
28
  "я не знаю что делать", "я не знаю", "нет информации", "не указан",
29
  "нет актёров", "нет актеров", "unknown", "—", ""
30
  ]
31
  BAD_PHRASE_PARTS = [
32
+ "нет описания", "без описания", "неизвестно",
33
+ "описание отсутствует", "пусто"
34
  ]
 
 
 
 
 
 
 
 
 
35
 
36
+ # ====== Вспомогательные функции ======
37
  def list_str_to_text(x):
38
  try:
39
  lst = ast.literal_eval(x) if isinstance(x, str) else x
 
68
  df["num_seasons"] = pd.to_numeric(df.get("num_seasons", 0), errors="coerce").fillna(0).astype(int)
69
  df["tvshow_title"] = df.get("tvshow_title", "").fillna("Неизвестно")
70
  df["description"] = df.get("description", "").fillna("Нет описания").astype(str).str.strip()
71
+ df = df[df["description"].apply(lambda x: len(str(x).split())) >= 15]
72
+ try:
73
+ to_drop_exact = df["description"].value_counts()[lambda x: x >= 3].index
74
+ df = df[~df["description"].isin(to_drop_exact)]
75
+ except Exception:
76
+ pass
77
  garbage_patterns = [
78
  r"(всё в порядке[.!?~ ,]*){3,}",
79
  r"(я не знаю[^.!?]*){2,}",
80
  r"(ладно[.,\s]*){3,}",
81
  r"(о[ауе]?[^\w]*){5,}",
82
  r"(нет[.,\s]*){5,}",
 
 
 
 
 
 
 
83
  ]
84
  def matches_garbage(text):
85
  t = str(text).lower()
86
  return any(re.search(p, t) for p in garbage_patterns)
87
  df = df[~df["description"].apply(matches_garbage)]
 
 
 
 
 
 
 
88
  df = df[~df["description"].str.lower().apply(lambda text: any(phrase in text for phrase in BAD_PHRASE_PARTS))]
 
89
  cols_to_ignore = {
90
  'tvshow_title','year','genres','actors','rating','description',
91
  'image_url','url','language','country','directors','page_url','num_seasons'
92
  }
93
+ genre_onehots = [
94
+ c for c in df.columns if c not in cols_to_ignore and df[c].nunique() <= 2
95
+ ]
96
  df = df.drop(columns=genre_onehots, errors="ignore")
97
  df["basic_genres"] = df["genres"].apply(filter_to_basic_genres)
98
  df["type"] = df["num_seasons"].apply(lambda x: "Сериал" if pd.notna(x) and int(x) > 1 else "Фильм")
 
101
  df[col] = None
102
  return df.reset_index(drop=True)
103
 
104
+ # ====== Кэширование и инициализация (один раз) ======
105
  @st.cache_data
106
  def cached_load_data(path):
107
  return clean_tvshows_data(path)
 
115
  @st.cache_resource
116
  def cached_load_embeddings_and_index():
117
  if not os.path.exists(EMB_PATH) or not os.path.exists(FAISS_PATH):
118
+ raise FileNotFoundError("Файлы embeddings.npy или faiss_index.index не найдены.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  embeddings = np.load(EMB_PATH)
120
  index = faiss.read_index(FAISS_PATH)
121
  return embeddings, index
122
 
123
+ @st.cache_resource
124
+ def cached_init_groq_llm():
125
+ api_key = os.getenv("GROQ_API_KEY")
126
+ if not api_key:
127
+ return None # Возвращаем None, если ключ не установлен
128
+ try:
129
+ os.environ["GROQ_API_KEY"] = api_key # Убедимся, что LangChain его видит
130
+ return ChatGroq(model="deepseek-r1-distill-llama-70b", temperature=0, max_tokens=2000)
131
+ except Exception as e:
132
+ st.error(f"Ошибка инициализации Groq: {e}")
133
+ return None
 
 
 
134
 
135
+ # ====== Поисковые/вспомогательные функции ======
136
  def semantic_search(query, embedder, index, df, genre=None, year=None, country=None, vtype=None, k=5):
137
  if not isinstance(query, str) or not query.strip():
138
  return pd.DataFrame()
 
 
 
 
 
139
  query_embedding = embedder.encode([query])
140
  faiss.normalize_L2(query_embedding)
141
+ n_search = max(k*3, 1)
 
142
  dists, idxs = index.search(query_embedding, n_search)
 
143
  valid_idxs = [i for i in idxs[0] if i >= 0 and i < len(df)]
144
  if not valid_idxs:
145
  return pd.DataFrame()
 
146
  res = df.iloc[valid_idxs].copy()
147
  res["score"] = dists[0][:len(valid_idxs)]
 
 
148
  if genre and genre != "Все":
149
+ res = res[res["basic_genres"].str.contains(genre, na=False)]
 
 
150
  if year and year != "Все":
151
  try:
152
  res = res[res["year"] == int(year)]
153
  except:
154
  pass
 
155
  if country and country != "Все":
156
+ res = res[res["country"].astype(str).str.contains(country, na=False)]
 
 
157
  if vtype and vtype != "Все":
158
+ res = res[res["type"] == vtype]
 
159
  if res.empty:
160
  return res
161
+ return res.nlargest(k, "score")
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def format_docs_for_prompt(results_df):
164
  parts = []
 
 
165
  for _, row in results_df.iterrows():
166
  parts.append(
167
+ f"{row['tvshow_title']} ({row['year']})\n"
168
  f"Жанр: {row['basic_genres']}\n"
169
  f"Рейтинг: {row['rating'] or '—'} | Тип: {row['type']} | "
170
+ f"Страна: {row['country'] or '—'} | Сезонов: {row['num_seasons']}\n"
171
+ f"Актёры: {row['actors']}\n{extract_intro_paragraph(row['description'])}"
172
  )
173
  return "\n\n".join(parts)
174
 
175
  def generate_rag_response(user_query, search_results, llm):
176
+ if llm is None or search_results.empty:
177
+ return "LLM не инициализирован или нет результатов для анализа."
 
178
  ctx = format_docs_for_prompt(search_results)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  try:
180
+ return llm.invoke([SystemMessage(content="Ты — эксперт по кино и сериалам."),
181
+ HumanMessage(content=f"Запрос: {user_query}\n\n{ctx}")]).content.strip()
 
 
 
182
  except Exception as e:
183
  return f"Ошибка при генерации ответа LLM: {e}"
184
 
185
+ # ====== UI: main ======
186
  def main():
187
+ st.set_page_config(page_title="Поиск фильмов и сериалов + AI", layout="wide")
188
+ st.title("Семантический поиск фильмов и сериалов с AI")
189
 
190
+ # ====== Инициализация данных и ресурсов один раз (через session_state) ======
191
  if "df" not in st.session_state:
192
+ try:
193
+ st.session_state.df = cached_load_data(CSV_PATH)
194
+ except FileNotFoundError as e:
195
+ st.error(str(e))
196
+ st.stop()
197
+ except Exception as e:
198
+ st.error(f"Не удалось загрузить данные: {e}")
199
+ st.stop()
200
+
201
  if "embedder" not in st.session_state:
202
+ try:
203
+ st.session_state.embedder = cached_init_embedder()
204
+ except Exception as e:
205
+ st.error(f"Ошибка инициализации embedder: {e}")
206
+ st.stop()
207
+
208
  if "embeddings_index" not in st.session_state:
209
+ try:
210
  st.session_state.embeddings, st.session_state.index = cached_load_embeddings_and_index()
211
+ except FileNotFoundError as e:
212
+ st.error(str(e))
213
+ st.stop()
214
+ except Exception as e:
215
+ st.error(f"Ошибка загрузки индекса/эмбеддингов: {e}")
216
+ st.stop()
217
+
218
  if "llm" not in st.session_state:
219
+ # Инициализация LLM происходит только здесь, и результат сохраняется в session_state
220
+ st.session_state.llm = cached_init_groq_llm()
221
 
 
 
 
 
222
  df = st.session_state.df
223
  embedder = st.session_state.embedder
224
  index = st.session_state.index
225
  llm = st.session_state.llm
226
 
227
+ # ====== Форма поиска (стабильная) ======
228
+ results_container = st.container()
229
+ ai_response_container = st.container()
230
+
231
+ with st.form(key='search_form'):
232
+ colf1, colf2, colf3, colf4 = st.columns(4)
233
+ with colf1:
234
+ basic_genres_list = []
235
+ for g in df["basic_genres"].dropna().unique():
236
+ for part in str(g).split(","):
237
+ p = part.strip()
238
+ if p:
239
+ basic_genres_list.append(p)
240
+ genres = ["Все"] + sorted(set(basic_genres_list))
241
+ genre_filter = st.selectbox("Жанр", genres, index=0, key="genre_filter_key")
242
+ with colf2:
243
+ years = ["Все"] + [str(y) for y in sorted(df["year"].unique()) if y != 0]
244
+ year_filter = st.selectbox("Год", years, index=0, key="year_filter_key")
245
+ with colf3:
246
+ countries = ["Все"] + sorted([c for c in df["country"].dropna().unique()])
247
+ country_filter = st.selectbox("Страна", countries, index=0, key="country_filter_key")
248
+ with colf4:
249
+ vtypes = ["Все"] + sorted(df["type"].dropna().unique())
250
+ type_filter = st.selectbox("Тип", vtypes, index=0, key="type_filter_key")
251
+
252
+ k = st.slider("Количество результатов:", 1, 20, 5, key="k_slider")
253
+ user_input = st.text_input("Введите ключевые слова или сюжет:", key="user_input_key")
254
+
255
+ nav1, nav2, nav3, nav4 = st.columns(4)
256
+ with nav1:
257
+ random_search = st.form_submit_button("Случайный фильм/сериал")
258
+ with nav2:
259
+ genre_search = st.form_submit_button("ТОП по жанру")
260
+ with nav3:
261
+ new_search = st.form_submit_button("Новинки")
262
+ with nav4:
263
+ text_search = st.form_submit_button("Искать")
264
+
265
+ performed_search = False
266
+ if text_search and user_input:
267
+ st.session_state.last_query = user_input
268
+ performed_search = True
269
+ with st.spinner("Поиск..."):
270
  st.session_state.results = semantic_search(
271
  user_input, embedder, index, df,
272
  genre_filter, year_filter, country_filter, type_filter, k
 
275
  elif random_search:
276
  random_query = random.choice(df["tvshow_title"].tolist())
277
  st.session_state.last_query = random_query
278
+ performed_search = True
279
+ with st.spinner("Поиск..."):
280
+ st.session_state.results = semantic_search(
281
+ random_query, embedder, index, df,
282
+ genre_filter, year_filter, country_filter, type_filter, k
283
+ )
284
+ st.session_state.ai_clicked = False
285
  elif genre_search and genre_filter != "Все":
286
+ st.session_state.last_query = genre_filter
287
+ performed_search = True
288
+ with st.spinner("Поиск..."):
289
+ st.session_state.results = semantic_search(
290
+ genre_filter, embedder, index, df,
291
+ genre_filter, year_filter, country_filter, type_filter, k
292
+ )
293
+ st.session_state.ai_clicked = False
294
  elif new_search:
295
+ new_query = str(int(df["year"].max())) if not df["year"].isna().all() else ""
296
  st.session_state.last_query = new_query
297
+ performed_search = True
298
+ with st.spinner("Поиск..."):
299
+ st.session_state.results = semantic_search(
300
+ new_query, embedder, index, df,
301
+ genre_filter, year_filter, country_filter, type_filter, k
302
+ )
303
+ st.session_state.ai_clicked = False
304
+ else: # Если ничего не нажато, но session_state пуст
305
+ if 'results' not in st.session_state:
306
+ st.session_state.results = pd.DataFrame()
307
+ if 'ai_clicked' not in st.session_state:
308
+ st.session_state.ai_clicked = False
309
 
310
  with results_container:
311
+ st.markdown("## Результаты поиска")
312
  results_exist = isinstance(st.session_state.get("results"), pd.DataFrame) and not st.session_state.results.empty
 
313
  if not results_exist:
314
+ if performed_search and ('last_query' in st.session_state and st.session_state.last_query.strip() != ""):
315
+ st.warning("Ничего не найдено.")
316
  else:
317
+ st.info("Введите запрос и нажмите «Искать», или выберите «Случайный фильм/сериал».")
318
  else:
319
  res_df = st.session_state.results
320
+ st.success(f"Найдено: {len(res_df)}")
321
  for _, row in res_df.iterrows():
322
+ card_cols = st.columns([1, 3])
323
+ with card_cols[0]:
324
+ if row.get("image_url"):
 
325
  try:
326
+ st.image(row["image_url"], width=150)
327
  except Exception:
328
+ st.info("Нет изображения")
329
  else:
330
+ st.info("Нет изображения")
331
+ with card_cols[1]:
332
  st.markdown(f"### {row['tvshow_title']} ({row['year']})")
333
+ st.caption(f"{row['basic_genres']} | {row['country'] or '—'} | {row['rating'] or '—'} | {row['type']} | {row['num_seasons']} сез.")
 
 
 
 
334
  st.write(extract_intro_paragraph(row["description"]))
335
  if row.get("actors"):
336
+ st.caption(f"Актёры: {row['actors']}")
337
  if row.get("url"):
338
+ st.markdown(f"[Подробнее]({row['url']})")
339
  st.divider()
340
 
341
+ if st.session_state.llm and st.button("AI: почему эти подходят и что ещё посмотреть", key="ai_button"):
342
+ st.session_state.ai_clicked = True
 
343
 
344
  with ai_response_container:
345
+ if st.session_state.get("ai_clicked") and results_exist:
346
+ st.markdown("### Рекомендации AI:")
347
  with st.spinner("Генерация ответа AI..."):
348
  rag = generate_rag_response(st.session_state.last_query, st.session_state.results, llm)
349
  st.write(rag)
350
 
351
+ st.sidebar.write(f"Всего записей: {len(df)}")
352
+ st.sidebar.markdown(f"**Статус LLM:** {'Готов' if llm else 'Отключён (нет API-ключа)'}")
 
 
353
 
354
  if __name__ == "__main__":
355
  main()