Kapex13 commited on
Commit
c6f6f57
·
verified ·
1 Parent(s): 49a3d7f

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +215 -181
src/streamlit_app.py CHANGED
@@ -1,5 +1,5 @@
1
- import os
2
  import streamlit as st
 
3
  import pandas as pd
4
  import numpy as np
5
  import faiss
@@ -12,7 +12,7 @@ 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")
@@ -28,8 +28,7 @@ BAD_ACTORS = [
28
  "нет актёров", "нет актеров", "unknown", "—", ""
29
  ]
30
  BAD_PHRASE_PARTS = [
31
- "нет описания", "без описания", "неизвестно",
32
- "описание отсутствует", "пусто"
33
  ]
34
  GENRE_KEYWORDS_MAP = {
35
  "доктор": "драма", "медицина": "драма", "врач": "драма",
@@ -37,10 +36,21 @@ GENRE_KEYWORDS_MAP = {
37
  "фантастика": "научная фантастика", "боевик": "боевик",
38
  "криминал": "криминал", "приключения": "приключения",
39
  "романтика": "романтика", "прогулки": "документальный",
40
- "природа": "документальный", "война": "боевик"
 
41
  }
42
 
43
- # ====== Вспомогательные функции ======
 
 
 
 
 
 
 
 
 
 
44
  def list_str_to_text(x):
45
  try:
46
  lst = ast.literal_eval(x) if isinstance(x, str) else x
@@ -76,6 +86,7 @@ def clean_tvshows_data(path):
76
  df["tvshow_title"] = df.get("tvshow_title", "").fillna("Неизвестно")
77
  df["description"] = df.get("description", "").fillna("Нет описания").astype(str).str.strip()
78
 
 
79
  df = df[df["description"].apply(lambda x: len(str(x).split()) >= 15)]
80
 
81
  garbage_patterns = [
@@ -86,27 +97,23 @@ def clean_tvshows_data(path):
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
-
96
  def matches_garbage(text):
97
  t = str(text).lower()
98
  return any(re.search(p, t) for p in garbage_patterns)
99
-
100
  df = df[~df["description"].apply(matches_garbage)]
101
-
102
  try:
103
  to_drop_exact = df["description"].value_counts()[lambda x: x >= 3].index
104
  df = df[~df["description"].isin(to_drop_exact)]
105
  except Exception:
106
  pass
107
-
108
  df = df[~df["description"].str.lower().apply(lambda text: any(phrase in text for phrase in BAD_PHRASE_PARTS))]
109
-
110
  cols_to_ignore = {
111
  'tvshow_title','year','genres','actors','rating','description',
112
  'image_url','url','language','country','directors','page_url','num_seasons'
@@ -120,7 +127,7 @@ def clean_tvshows_data(path):
120
  df[col] = None
121
  return df.reset_index(drop=True)
122
 
123
- # ====== Кэширование и инициализация ======
124
  @st.cache_data
125
  def cached_load_data(path):
126
  return clean_tvshows_data(path)
@@ -129,45 +136,57 @@ def cached_load_data(path):
129
  def cached_init_embedder():
130
  cache_dir = os.path.join(tempfile.gettempdir(), "sbert_cache")
131
  os.makedirs(cache_dir, exist_ok=True)
 
132
  return SentenceTransformer("sberbank-ai/sbert_large_nlu_ru", cache_folder=cache_dir)
133
 
134
  @st.cache_resource
135
  def cached_load_embeddings_and_index():
 
 
 
 
136
  if not os.path.exists(EMB_PATH) or not os.path.exists(FAISS_PATH):
137
  st.warning("Файлы эмбеддингов или индекса не найдены. Создаем новые...")
138
  df = cached_load_data(CSV_PATH)
139
  embedder = cached_init_embedder()
140
-
141
- texts = df["tvshow_title"] + " " + df["description"] + " " + df["genres"] + " " + df["actors"]
142
-
143
- embeddings = embedder.encode(texts.tolist(), show_progress_bar=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  faiss.normalize_L2(embeddings)
145
  np.save(EMB_PATH, embeddings)
146
-
147
  index = faiss.IndexFlatIP(embeddings.shape[1])
148
  index.add(embeddings)
149
  faiss.write_index(index, FAISS_PATH)
150
-
151
- st.success("Новые эмбеддинги и индекс успешно созданы. Пожалуйста, обновите страницу, чтобы продолжить.")
152
-
153
  embeddings = np.load(EMB_PATH)
154
  index = faiss.read_index(FAISS_PATH)
155
  return embeddings, index
156
 
157
-
158
- @st.cache_resource
159
- def cached_init_groq_llm():
160
- api_key = os.getenv("GROQ_API_KEY")
161
- if not api_key:
162
- return None
163
- try:
164
- os.environ["GROQ_API_KEY"] = api_key
165
- return ChatGroq(model="deepseek-r1-distill-llama-70b", temperature=0, max_tokens=2000)
166
- except Exception as e:
167
- st.error(f"Ошибка инициализации Groq: {e}")
168
  return None
 
 
169
 
170
- # ====== Автоматическое определение жанра из запроса ======
171
  def infer_genre_from_query(query):
172
  query_lower = query.lower()
173
  for keyword, genre in GENRE_KEYWORDS_MAP.items():
@@ -175,87 +194,125 @@ def infer_genre_from_query(query):
175
  return genre
176
  return None
177
 
178
- # ====== Семантический поиск с улучшенной фильтрацией и ранжированием ======
179
- def semantic_search(query, embedder, index, df, genre=None, year=None, country=None, vtype=None, k=5):
 
 
 
 
180
  if not isinstance(query, str) or not query.strip():
181
  return pd.DataFrame()
182
-
183
- print(f"Пользовательский запрос: {query}") # Отладочный вывод
184
-
185
- inferred_genre = infer_genre_from_query(query)
186
  if inferred_genre and (genre is None or genre == "Все"):
187
  genre = inferred_genre
188
 
189
- query_embedding = embedder.encode([query])
190
- faiss.normalize_L2(query_embedding)
191
-
192
- n_search = 500
193
- dists, idxs = index.search(query_embedding, n_search)
194
-
195
- valid_idxs = [i for i in idxs[0] if i >= 0 and i < len(df)]
196
- if not valid_idxs:
197
- return pd.DataFrame()
198
-
199
- res = df.iloc[valid_idxs].copy()
200
- res["score"] = dists[0][:len(valid_idxs)]
201
-
202
  if genre and genre != "Все":
203
- genre_lower = genre.lower()
204
- res = res[res["basic_genres"].str.lower().str.contains(genre_lower, na=False)]
205
-
206
  if year and year != "Все":
207
  try:
208
- res = res[res["year"] == int(year)]
209
  except:
210
  pass
211
-
212
  if country and country != "Все":
213
- country_lower = country.lower()
214
- res = res[res["country"].astype(str).str.lower().str.contains(country_lower, na=False)]
215
-
216
  if vtype and vtype != "Все":
217
- res = res[res["type"].str.lower() == vtype.lower()]
 
 
 
 
 
218
 
219
- if res.empty:
220
- return res
221
 
222
- query_lower = query.lower()
223
-
224
- res['exact_match_title'] = res['tvshow_title'].str.lower() == query_lower
225
-
226
- query_words = re.findall(r'\b\w+\b', query_lower)
227
- keyword_pattern = '|'.join([re.escape(word) for word in query_words if len(word) > 2])
228
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
  if keyword_pattern:
230
  res['has_keyword'] = res.apply(
231
- lambda row: bool(re.search(keyword_pattern, str(row['tvshow_title']).lower())) or
232
- bool(re.search(keyword_pattern, str(row['description']).lower())),
233
  axis=1
234
  )
235
  else:
236
  res['has_keyword'] = False
237
-
238
- res['final_score'] = res['score']
239
-
240
- res['final_score'] = np.where(res['exact_match_title'], res['final_score'] + 1.5, res['final_score'])
241
- res['final_score'] = np.where(res['has_keyword'], res['final_score'] + 0.4, res['final_score'])
242
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  sorted_results = res.sort_values(by="final_score", ascending=False)
244
-
245
  return sorted_results.head(k)
246
 
247
-
248
- # ====== Форматирование результатов для LLM ======
249
  def format_docs_for_prompt(results_df):
250
  parts = []
251
- if results_df.empty:
252
  return "Нет подходящих результатов поиска в базе данных."
253
  for _, row in results_df.iterrows():
254
  parts.append(
255
- f"Название: {row['tvshow_title']} ({row['year']})\n"
256
  f"Жанр: {row['basic_genres']}\n"
257
  f"Рейтинг: {row['rating'] or '—'} | Тип: {row['type']} | "
258
- f"Страна: {row['country'] or '—'} | Сезонов: {row['num_seasons'] or '—'}\n"
259
  f"Актёры: {row['actors']}\nСюжет: {extract_intro_paragraph(row['description'])}"
260
  )
261
  return "\n\n".join(parts)
@@ -263,28 +320,25 @@ def format_docs_for_prompt(results_df):
263
  def generate_rag_response(user_query, search_results, llm):
264
  if llm is None:
265
  return "LLM не инициализирован."
266
-
267
- ctx = format_docs_for_prompt(search_results)
268
 
 
269
  prompt_template = """
270
- Ты — эксперт по кино и сериалам. Твоя задача — помочь пользователю, основываясь на предоставленных ниже результатах поиска.
271
 
272
- Твой основной источник информации — предоставленные результаты поиска.
273
- 1. Сначала проанализируй, насколько предоставленные результаты поиска релевантны запросу пользователя.
274
- 2. Если результаты релевантны, объясни почему и суммируй их.
275
- 3. Если результаты нерелевантны, **прямо об этом скажи** и объясни, что в базе данных не найдено ничего подходящего.
276
- 4. В любом случае, после анализа, предложи **1-2 дополнительных фильма или сериала, которые идеально подходят** под запрос пользователя, используя только свои общие знания, даже если их нет в результатах поиска.
277
 
278
- Результаты поиска:
279
- {context}
280
 
281
- Вопрос пользователя: {question}
282
 
283
- Ответ:
284
- """
285
-
286
  full_prompt = prompt_template.format(context=ctx, question=user_query)
287
-
288
  try:
289
  response = llm.invoke([
290
  SystemMessage(content="Ты — эксперт по кино и сериалам. Всегда основывайся на предоставленном контексте и не придумывай лишнего."),
@@ -294,56 +348,38 @@ def generate_rag_response(user_query, search_results, llm):
294
  except Exception as e:
295
  return f"Ошибка при генерации ответа LLM: {e}"
296
 
297
- # ====== UI: main ======
298
  def main():
299
- st.set_page_config(page_title="Поиск фильмов и сериалов + AI", layout="wide")
300
- st.title("Семантический поиск фильмов и сериалов с AI")
301
 
 
302
  if "df" not in st.session_state:
303
- try:
304
- st.session_state.df = cached_load_data(CSV_PATH)
305
- except FileNotFoundError as e:
306
- st.error(str(e))
307
- return
308
-
309
  if "embedder" not in st.session_state:
310
- try:
311
- st.session_state.embedder = cached_init_embedder()
312
- except Exception as e:
313
- st.error(f"Ошибка инициализации embedder: {e}")
314
- return
315
-
316
  if "embeddings_index" not in st.session_state:
317
- try:
318
- with st.spinner("Загрузка эмбеддингов и индекса (может занять несколько минут при первом запуске)..."):
319
- st.session_state.embeddings, st.session_state.index = cached_load_embeddings_and_index()
320
- except FileNotFoundError as e:
321
- st.error(str(e))
322
- return
323
- except Exception as e:
324
- st.error(f"Ошибка загрузки индекса/эмбеддингов: {e}")
325
- return
326
-
327
  if "llm" not in st.session_state:
328
- st.session_state.llm = cached_init_groq_llm()
 
 
 
 
329
 
330
  df = st.session_state.df
331
  embedder = st.session_state.embedder
332
  index = st.session_state.index
333
  llm = st.session_state.llm
334
 
335
- # Инициализация переменных состояния
336
- if 'last_query' not in st.session_state:
337
- st.session_state.last_query = ""
338
- if 'results' not in st.session_state:
339
- st.session_state.results = pd.DataFrame()
340
- if 'ai_clicked' not in st.session_state:
341
- st.session_state.ai_clicked = False
342
- if 'search_query' not in st.session_state:
343
- st.session_state.search_query = ""
344
-
345
 
346
- # ====== Форма поиска ======
347
  with st.container():
348
  st.markdown("---")
349
  with st.form(key='search_form'):
@@ -362,40 +398,43 @@ def main():
362
  vtypes = ["Все"] + sorted(df["type"].dropna().unique())
363
  type_filter = st.selectbox("Тип", vtypes, index=0, key="type_filter_key")
364
 
365
- k = st.slider("Количество результатов:", 1, 20, 5, key="k_slider")
366
- user_input = st.text_input("Введите ключевые слова или сюжет:", key="user_input_key")
367
 
368
  col_buttons = st.columns(4)
369
  with col_buttons[0]:
370
- random_search = st.form_submit_button("Случайный фильм/сериал")
371
  with col_buttons[1]:
372
- genre_search = st.form_submit_button("ТОП по жанру")
373
  with col_buttons[2]:
374
- new_search = st.form_submit_button("Новинки")
375
  with col_buttons[3]:
376
- text_search = st.form_submit_button("Искать")
377
 
378
- # Логика обработки нажатий кнопок
379
- if text_search and user_input:
380
- st.session_state.last_query = user_input
381
- st.session_state.results = semantic_search(
382
- user_input, embedder, index, df,
383
- genre_filter, year_filter, country_filter, type_filter, k
384
- )
385
- st.session_state.ai_clicked = False
 
 
 
386
  elif random_search:
387
  random_query = random.choice(df["tvshow_title"].tolist())
388
  st.session_state.last_query = random_query
389
  st.session_state.results = semantic_search(
390
  random_query, embedder, index, df,
391
- genre_filter, year_filter, country_filter, type_filter, k
392
  )
393
  st.session_state.ai_clicked = False
394
  elif genre_search and genre_filter != "Все":
395
  st.session_state.last_query = f"Лучшие фильмы и сериалы в жанре {genre_filter}"
396
  st.session_state.results = semantic_search(
397
  st.session_state.last_query, embedder, index, df,
398
- genre_filter, year_filter, country_filter, type_filter, k
399
  )
400
  st.session_state.ai_clicked = False
401
  elif new_search:
@@ -403,66 +442,61 @@ def main():
403
  st.session_state.last_query = new_query
404
  st.session_state.results = semantic_search(
405
  new_query, embedder, index, df,
406
- genre_filter, year_filter, country_filter, type_filter, k
407
  )
408
  st.session_state.ai_clicked = False
409
 
410
- # ====== Отрисовка результатов ======
411
  results_container = st.container()
412
  ai_response_container = st.container()
413
 
414
  with results_container:
415
- st.markdown("## Результаты поиска")
416
  results_exist = isinstance(st.session_state.get("results"), pd.DataFrame) and not st.session_state.results.empty
417
-
418
  if not results_exist:
419
  if st.session_state.last_query:
420
- st.warning(f"Ничего не найдено по запросу: '{st.session_state.last_query}'.")
421
  else:
422
- st.info("Введите запрос и нажмите «Искать», или выберите «Случайный фильм/сериал».")
423
  else:
424
  res_df = st.session_state.results
425
- st.success(f"Найдено: {len(res_df)}")
426
  for _, row in res_df.iterrows():
427
- card_cols = st.columns([1, 3])
428
- with card_cols[0]:
429
  image_url = row.get("image_url")
430
  if image_url and isinstance(image_url, str) and (image_url.startswith('http') or image_url.startswith('https')):
431
  try:
432
  st.image(image_url, width=150)
433
  except Exception:
434
- st.info("Не удалось загрузить изображение.")
435
  else:
436
- st.info("Нет изображения.")
437
- with card_cols[1]:
438
  st.markdown(f"### {row['tvshow_title']} ({row['year']})")
439
- st.caption(f"{row['basic_genres']} | {row['country'] or '—'} | {row['rating'] or '—'} | {row['type']} | {row['num_seasons']} сез.")
 
 
 
 
440
  st.write(extract_intro_paragraph(row["description"]))
441
  if row.get("actors"):
442
- st.caption(f"Актёры: {row['actors']}")
443
  if row.get("url"):
444
- st.markdown(f"[Подробнее]({row['url']})")
445
  st.divider()
446
 
447
- # Кнопка для AI-генерации вне формы
448
- if st.session_state.llm and not st.session_state.results.empty:
449
- if st.button("AI: почему эти подходят и что ещё посмотреть", key="ai_button"):
450
  st.session_state.ai_clicked = True
451
-
452
  with ai_response_container:
453
  if st.session_state.get("ai_clicked") and st.session_state.get("last_query"):
454
- st.markdown("### Рекомендации AI:")
455
  with st.spinner("Генерация ответа AI..."):
456
  rag = generate_rag_response(st.session_state.last_query, st.session_state.results, llm)
457
  st.write(rag)
458
- elif st.session_state.get("ai_clicked") and not st.session_state.get("results").empty:
459
- st.markdown("### Рекомендации AI:")
460
- with st.spinner("Генерация ответа AI..."):
461
- rag = generate_rag_response(st.session_state.last_query, st.session_state.results, llm)
462
- st.write(rag)
463
-
464
- st.sidebar.write(f"Всего записей: {len(df)}")
465
- st.sidebar.markdown(f"**Статус LLM:** {'Готов' if llm else 'Отключён (нет API-ключа)'}")
466
 
467
  if __name__ == "__main__":
468
  main()
 
 
1
  import streamlit as st
2
+ import os
3
  import pandas as pd
4
  import numpy as np
5
  import faiss
 
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")
 
28
  "нет актёров", "нет актеров", "unknown", "—", ""
29
  ]
30
  BAD_PHRASE_PARTS = [
31
+ "нет описания", "без описания", "неизвестно", "описание отсутствует", "пусто"
 
32
  ]
33
  GENRE_KEYWORDS_MAP = {
34
  "доктор": "драма", "медицина": "драма", "врач": "драма",
 
36
  "фантастика": "научная фантастика", "боевик": "боевик",
37
  "криминал": "криминал", "приключения": "приключения",
38
  "романтика": "романтика", "прогулки": "документальный",
39
+ "природа": "документальный", "война": "боевик",
40
+ "волшебство": "фэнтези", "дракон": "фэнтези"
41
  }
42
 
43
+ # --- Вспомогательные функции ---
44
+ def normalize_text(text):
45
+ """Нормализация текста перед кодированием/поиском: нижний регистр, убрать лишние пробелы и спецсимволы."""
46
+ text = "" if text is None else str(text)
47
+ text = text.strip().lower()
48
+ text = re.sub(r"\s+", " ", text)
49
+ # Оставляем буквы, цифры, пробелы и дефис
50
+ text = re.sub(r"[^\w\sа-яё\-]", " ", text)
51
+ text = re.sub(r"\s+", " ", text).strip()
52
+ return text
53
+
54
  def list_str_to_text(x):
55
  try:
56
  lst = ast.literal_eval(x) if isinstance(x, str) else x
 
86
  df["tvshow_title"] = df.get("tvshow_title", "").fillna("Неизвестно")
87
  df["description"] = df.get("description", "").fillna("Нет описания").astype(str).str.strip()
88
 
89
+ # Оставляем записи с длиной описания >= 15 слов
90
  df = df[df["description"].apply(lambda x: len(str(x).split()) >= 15)]
91
 
92
  garbage_patterns = [
 
97
  r"(нет[.,\s]*){5,}",
98
  r"(\s*15\s*лет\s*){2,}",
99
  r"(\s*ё\s*){2,}",
 
 
100
  r"(.)\1{3,}",
101
  r"(\s*[.,;!?'`~]{2,}\s*)",
102
  r"(\s*[0-9]{2,}\s*)",
103
  ]
 
104
  def matches_garbage(text):
105
  t = str(text).lower()
106
  return any(re.search(p, t) for p in garbage_patterns)
 
107
  df = df[~df["description"].apply(matches_garbage)]
108
+
109
  try:
110
  to_drop_exact = df["description"].value_counts()[lambda x: x >= 3].index
111
  df = df[~df["description"].isin(to_drop_exact)]
112
  except Exception:
113
  pass
114
+
115
  df = df[~df["description"].str.lower().apply(lambda text: any(phrase in text for phrase in BAD_PHRASE_PARTS))]
116
+
117
  cols_to_ignore = {
118
  'tvshow_title','year','genres','actors','rating','description',
119
  'image_url','url','language','country','directors','page_url','num_seasons'
 
127
  df[col] = None
128
  return df.reset_index(drop=True)
129
 
130
+ # --- Кэширование и инициализация ---
131
  @st.cache_data
132
  def cached_load_data(path):
133
  return clean_tvshows_data(path)
 
136
  def cached_init_embedder():
137
  cache_dir = os.path.join(tempfile.gettempdir(), "sbert_cache")
138
  os.makedirs(cache_dir, exist_ok=True)
139
+ # Модель русскоязычная как в оригинале
140
  return SentenceTransformer("sberbank-ai/sbert_large_nlu_ru", cache_folder=cache_dir)
141
 
142
  @st.cache_resource
143
  def cached_load_embeddings_and_index():
144
+ """
145
+ Загружает/создаёт эмбеддинги и FAISS-индекс.
146
+ При создании эмбеддингов — применяется нормализация текста и расширенный контекст.
147
+ """
148
  if not os.path.exists(EMB_PATH) or not os.path.exists(FAISS_PATH):
149
  st.warning("Файлы эмбеддингов или индекса не найдены. Создаем новые...")
150
  df = cached_load_data(CSV_PATH)
151
  embedder = cached_init_embedder()
152
+
153
+ texts = df.apply(
154
+ lambda row: (
155
+ f"название: {normalize_text(row['tvshow_title'])}. "
156
+ f"описание: {normalize_text(row['description'])}. "
157
+ f"жанр: {normalize_text(row['genres'])}. "
158
+ f"актёры: {normalize_text(row['actors'])}. "
159
+ f"год: {row['year']}. "
160
+ f"тип: {normalize_text(row['type'])}. "
161
+ f"страна: {normalize_text(row.get('country', ''))}."
162
+ ),
163
+ axis=1
164
+ ).tolist()
165
+
166
+ embeddings = embedder.encode(texts, show_progress_bar=True)
167
+ # Убедимся, что float32
168
+ embeddings = np.asarray(embeddings).astype('float32')
169
  faiss.normalize_L2(embeddings)
170
  np.save(EMB_PATH, embeddings)
171
+
172
  index = faiss.IndexFlatIP(embeddings.shape[1])
173
  index.add(embeddings)
174
  faiss.write_index(index, FAISS_PATH)
175
+
176
+ st.success("Новые эмбеддинги и индекс успешно созданы. Обновите страницу.")
177
+
178
  embeddings = np.load(EMB_PATH)
179
  index = faiss.read_index(FAISS_PATH)
180
  return embeddings, index
181
 
182
+ @st.cache_resource(ttl=3600)
183
+ def init_groq_llm():
184
+ key = st.secrets.get("GROQ_API_KEY") or st.text_input("🔐 Введите API-ключ Groq:", type="password")
185
+ if not key:
 
 
 
 
 
 
 
186
  return None
187
+ os.environ["GROQ_API_KEY"] = key
188
+ return ChatGroq(model="deepseek-r1-distill-llama-70b", temperature=0, max_tokens=2000)
189
 
 
190
  def infer_genre_from_query(query):
191
  query_lower = query.lower()
192
  for keyword, genre in GENRE_KEYWORDS_MAP.items():
 
194
  return genre
195
  return None
196
 
197
+ # --- Семантический поиск с улучшениями (нормализация, фильтрация перед поиском, гибридное ранжирование) ---
198
+ def semantic_search(query, embedder, index, df, genre=None, year=None, country=None, vtype=None, k=5, debug=False):
199
+ """
200
+ Возвращает DataFrame с top-k результатами.
201
+ Параметр debug включит печать отладочной информации в лог.
202
+ """
203
  if not isinstance(query, str) or not query.strip():
204
  return pd.DataFrame()
205
+
206
+ # Нормализуем запрос
207
+ query_norm = normalize_text(query)
208
+ inferred_genre = infer_genre_from_query(query_norm)
209
  if inferred_genre and (genre is None or genre == "Все"):
210
  genre = inferred_genre
211
 
212
+ # 1) Предварительная фильтрация по атрибутам (чтобы не терять подходящие результаты позже)
213
+ filtered_df = df
 
 
 
 
 
 
 
 
 
 
 
214
  if genre and genre != "Все":
215
+ filtered_df = filtered_df[filtered_df["basic_genres"].str.lower().str.contains(str(genre).lower(), na=False)]
 
 
216
  if year and year != "Все":
217
  try:
218
+ filtered_df = filtered_df[filtered_df["year"] == int(year)]
219
  except:
220
  pass
 
221
  if country and country != "Все":
222
+ filtered_df = filtered_df[filtered_df["country"].astype(str).str.lower().str.contains(str(country).lower(), na=False)]
 
 
223
  if vtype and vtype != "Все":
224
+ filtered_df = filtered_df[filtered_df["type"].str.lower() == vtype.lower()]
225
+
226
+ if filtered_df.empty:
227
+ if debug:
228
+ print(f"[DEBUG] После предварительной фильтрации ничего не осталось (жанр={genre}, год={year}, страна={country}, тип={vtype}).")
229
+ return pd.DataFrame()
230
 
231
+ filtered_indices = set(filtered_df.index.to_list())
 
232
 
233
+ # 2) Кодируем запрос и ищем топ-N в индексе (N с запасом)
234
+ query_embedding = embedder.encode([query_norm]).astype('float32')
235
+ faiss.normalize_L2(query_embedding)
236
+
237
+ # n_search: максимум размер индекса или 2000, чтобы взять с запасом
238
+ ntotal = index.ntotal if hasattr(index, "ntotal") else len(df)
239
+ n_search = min(max(1000, k * 50), ntotal) # разумный диапазон: минимум 1000, максимум ntotal
240
+ dists, idxs = index.search(query_embedding, n_search)
241
+
242
+ # 3) Оставляем только индексы, которые прошли предварительную фильтрацию
243
+ final_idxs = []
244
+ final_dists = []
245
+ for dist, idx in zip(dists[0], idxs[0]):
246
+ if idx < 0:
247
+ continue
248
+ if idx in filtered_indices:
249
+ final_idxs.append(idx)
250
+ final_dists.append(float(dist))
251
+ # остановка, когда набрали достаточно кандидатов (с запасом)
252
+ if len(final_idxs) >= k * 6:
253
+ break
254
+
255
+ if not final_idxs:
256
+ if debug:
257
+ print("[DEBUG] Поиск ничего не нашел среди отфильтрованных записей.")
258
+ return pd.DataFrame()
259
+
260
+ # 4) Собираем DataFrame результатов и применяем гибридное ранжирование
261
+ res = df.loc[final_idxs].copy()
262
+ res["score"] = final_dists # базовый скор от эмбеддинга (IP / косинус)
263
+ # exact title match (строгий)
264
+ res['exact_match_title'] = res['tvshow_title'].str.lower().str.strip() == query_norm
265
+ # keyword presence: конструкция из слов запроса (слова длинее 2 символов)
266
+ query_words = re.findall(r'\b\w+\b', query_norm)
267
+ keyword_pattern = '|'.join([re.escape(w) for w in query_words if len(w) > 2])
268
  if keyword_pattern:
269
  res['has_keyword'] = res.apply(
270
+ lambda row: bool(re.search(keyword_pattern, normalize_text(str(row.get('tvshow_title', ''))))) or
271
+ bool(re.search(keyword_pattern, normalize_text(str(row.get('description', ''))))),
272
  axis=1
273
  )
274
  else:
275
  res['has_keyword'] = False
276
+
277
+ # Прибавляем бонусы: более агрессивные веса для exact title и keyword
278
+ res['final_score'] = res['score'].astype(float)
279
+ res['final_score'] = np.where(res['exact_match_title'], res['final_score'] + 2.0, res['final_score'])
280
+ res['final_score'] = np.where(res['has_keyword'], res['final_score'] + 0.6, res['final_score'])
281
+
282
+ # Можно также учитывать совпадение жанра (если пользователь явно указал)
283
+ if genre and genre != "Все":
284
+ # Если basic_genres содержит целевой жанр — маленький бонус
285
+ res['genre_match'] = res['basic_genres'].str.lower().str.contains(genre.lower(), na=False)
286
+ res['final_score'] = np.where(res['genre_match'], res['final_score'] + 0.2, res['final_score'])
287
+ else:
288
+ res['genre_match'] = False
289
+
290
+ # 5) Логирование (вывод в консоль и в сайдбар, если нужно)
291
+ if debug:
292
+ print(f"[DEBUG] Запрос: {query_norm}")
293
+ print(f"[DEBUG] Количество кандидатов после initial search: {len(idxs[0])}")
294
+ print(f"[DEBUG] Количество результатов после фильтрации: {len(res)}")
295
+ print(res[['tvshow_title', 'score', 'final_score', 'exact_match_title', 'has_keyword']].head(15))
296
+ try:
297
+ st.sidebar.markdown("#### Debug: последние результаты поиска")
298
+ st.sidebar.dataframe(res[['tvshow_title', 'score', 'final_score', 'exact_match_title', 'has_keyword']].head(10))
299
+ except Exception:
300
+ pass
301
+
302
  sorted_results = res.sort_values(by="final_score", ascending=False)
 
303
  return sorted_results.head(k)
304
 
305
+ # --- Форматирование результатов для LLM и RAG ---
 
306
  def format_docs_for_prompt(results_df):
307
  parts = []
308
+ if results_df is None or results_df.empty:
309
  return "Нет подходящих результатов поиска в базе данных."
310
  for _, row in results_df.iterrows():
311
  parts.append(
312
+ f"Название: {row['tvshow_title']} ({int(row['year']) if not pd.isna(row['year']) else '—'})\n"
313
  f"Жанр: {row['basic_genres']}\n"
314
  f"Рейтинг: {row['rating'] or '—'} | Тип: {row['type']} | "
315
+ f"Страна: {row['country'] or '—'} | Сезонов: {int(row['num_seasons']) if not pd.isna(row['num_seasons']) else '—'}\n"
316
  f"Актёры: {row['actors']}\nСюжет: {extract_intro_paragraph(row['description'])}"
317
  )
318
  return "\n\n".join(parts)
 
320
  def generate_rag_response(user_query, search_results, llm):
321
  if llm is None:
322
  return "LLM не инициализирован."
 
 
323
 
324
+ ctx = format_docs_for_prompt(search_results)
325
  prompt_template = """
326
+ Ты — эксперт по кино и сериалам. Твоя задача — помочь пользователю, основываясь на предоставленных ниже результатах поиска.
327
 
328
+ Твой основной источник информации — предоставленные результаты поиска.
329
+ 1. Сначала проанализируй, насколько предоставленные результаты поиска релевантны запросу пользователя.
330
+ 2. Если результаты релевантны, объясни почему и суммируй их.
331
+ 3. Если результаты нерелевантны, прямо об этом скажи и объясни, что в базе данных не найдено ничего подходящего.
332
+ 4. В любом случае, после анализа, предложи 1-2 дополнительных фильма или сериала, которые идеально подходят под запрос пользователя, используя только свои общие знания, даже если их нет в результатах поиска.
333
 
334
+ Результаты поиска:
335
+ {context}
336
 
337
+ Вопрос пользователя: {question}
338
 
339
+ Ответ:
340
+ """
 
341
  full_prompt = prompt_template.format(context=ctx, question=user_query)
 
342
  try:
343
  response = llm.invoke([
344
  SystemMessage(content="Ты — эксперт по кино и сериалам. Всегда основывайся на предоставленном контексте и не придумывай лишнего."),
 
348
  except Exception as e:
349
  return f"Ошибка при генерации ответа LLM: {e}"
350
 
351
+ # --- UI: main ---
352
  def main():
353
+ st.set_page_config(page_title="🎬 Поиск фильмов и сериалов + Groq AI", layout="wide")
354
+ st.title("📽️ Семантический поиск фильмов и сериалов с AI")
355
 
356
+ # Инициализация данных/ресурсов
357
  if "df" not in st.session_state:
358
+ st.session_state.df = cached_load_data(CSV_PATH)
 
 
 
 
 
359
  if "embedder" not in st.session_state:
360
+ st.session_state.embedder = cached_init_embedder()
 
 
 
 
 
361
  if "embeddings_index" not in st.session_state:
362
+ with st.spinner("Загрузка эмбеддингов и индекса..."):
363
+ st.session_state.embeddings, st.session_state.index = cached_load_embeddings_and_index()
 
 
 
 
 
 
 
 
364
  if "llm" not in st.session_state:
365
+ st.session_state.llm = init_groq_llm()
366
+
367
+ if 'last_query' not in st.session_state: st.session_state.last_query = ""
368
+ if 'results' not in st.session_state: st.session_state.results = pd.DataFrame()
369
+ if 'ai_clicked' not in st.session_state: st.session_state.ai_clicked = False
370
 
371
  df = st.session_state.df
372
  embedder = st.session_state.embedder
373
  index = st.session_state.index
374
  llm = st.session_state.llm
375
 
376
+ # Sidebar: debug toggle + информация
377
+ st.sidebar.markdown("---")
378
+ debug_mode = st.sidebar.checkbox("Включить debug-логи", value=False)
379
+ st.sidebar.markdown("## ℹ️ Информация")
380
+ st.sidebar.write(f"Всего записей в базе: {len(df)}")
381
+ st.sidebar.markdown(f"**Статус Groq LLM:** {'🟢 Готов' if llm else '🔴 Отключён (нужен API-ключ)'}")
 
 
 
 
382
 
 
383
  with st.container():
384
  st.markdown("---")
385
  with st.form(key='search_form'):
 
398
  vtypes = ["Все"] + sorted(df["type"].dropna().unique())
399
  type_filter = st.selectbox("Тип", vtypes, index=0, key="type_filter_key")
400
 
401
+ k = st.slider("📊 Количество результатов:", 1, 20, 5, key="k_slider")
402
+ user_input = st.text_input("🔎 Введите ключевые слова или сюжет:", key="user_input_key")
403
 
404
  col_buttons = st.columns(4)
405
  with col_buttons[0]:
406
+ random_search = st.form_submit_button("🎲 Случайный фильм/сериал")
407
  with col_buttons[1]:
408
+ genre_search = st.form_submit_button("🔥 ТОП по жанру")
409
  with col_buttons[2]:
410
+ new_search = st.form_submit_button("🆕 Новинки")
411
  with col_buttons[3]:
412
+ text_search = st.form_submit_button("🔍 Искать")
413
 
414
+ # Обработка кнопок поиска
415
+ if text_search:
416
+ if not user_input.strip():
417
+ st.warning("Введите запрос для поиска.")
418
+ else:
419
+ st.session_state.last_query = user_input
420
+ st.session_state.results = semantic_search(
421
+ user_input, embedder, index, df,
422
+ genre_filter, year_filter, country_filter, type_filter, k, debug=debug_mode
423
+ )
424
+ st.session_state.ai_clicked = False
425
  elif random_search:
426
  random_query = random.choice(df["tvshow_title"].tolist())
427
  st.session_state.last_query = random_query
428
  st.session_state.results = semantic_search(
429
  random_query, embedder, index, df,
430
+ genre_filter, year_filter, country_filter, type_filter, k, debug=debug_mode
431
  )
432
  st.session_state.ai_clicked = False
433
  elif genre_search and genre_filter != "Все":
434
  st.session_state.last_query = f"Лучшие фильмы и сериалы в жанре {genre_filter}"
435
  st.session_state.results = semantic_search(
436
  st.session_state.last_query, embedder, index, df,
437
+ genre_filter, year_filter, country_filter, type_filter, k, debug=debug_mode
438
  )
439
  st.session_state.ai_clicked = False
440
  elif new_search:
 
442
  st.session_state.last_query = new_query
443
  st.session_state.results = semantic_search(
444
  new_query, embedder, index, df,
445
+ genre_filter, year_filter, country_filter, type_filter, k, debug=debug_mode
446
  )
447
  st.session_state.ai_clicked = False
448
 
 
449
  results_container = st.container()
450
  ai_response_container = st.container()
451
 
452
  with results_container:
453
+ st.markdown("## 🔎 Результаты поиска")
454
  results_exist = isinstance(st.session_state.get("results"), pd.DataFrame) and not st.session_state.results.empty
455
+
456
  if not results_exist:
457
  if st.session_state.last_query:
458
+ st.warning(f"🤷 Ничего не найдено по запросу: '{st.session_state.last_query}'.")
459
  else:
460
+ st.info("👋 Введите запрос или выберите один из вариантов ниже.")
461
  else:
462
  res_df = st.session_state.results
463
+ st.success(f"Найдено: {len(res_df)}")
464
  for _, row in res_df.iterrows():
465
+ col1, col2 = st.columns([1, 3])
466
+ with col1:
467
  image_url = row.get("image_url")
468
  if image_url and isinstance(image_url, str) and (image_url.startswith('http') or image_url.startswith('https')):
469
  try:
470
  st.image(image_url, width=150)
471
  except Exception:
472
+ st.info("🤷‍♂️ Нет изображения.")
473
  else:
474
+ st.info("🤷‍♂️ Нет изображения.")
475
+ with col2:
476
  st.markdown(f"### {row['tvshow_title']} ({row['year']})")
477
+ st.caption(
478
+ f"🎭 {row['basic_genres']} | 📍 {row['country'] or '—'}"
479
+ f" | ⭐ {row['rating'] or '—'}"
480
+ f" | �� {row['type']} | 📺 {row['num_seasons']} сез."
481
+ )
482
  st.write(extract_intro_paragraph(row["description"]))
483
  if row.get("actors"):
484
+ st.caption(f"👥 Актёры: {row['actors']}")
485
  if row.get("url"):
486
+ st.markdown(f"[🔗 Подробнее]({row['url']})")
487
  st.divider()
488
 
489
+ # Кнопка AI для RAG-ответа
490
+ if st.session_state.llm and isinstance(st.session_state.get("results"), pd.DataFrame) and not st.session_state.results.empty:
491
+ if st.button("🧠 AI: почему эти подходят и что ещё посмотреть", key="ai_button"):
492
  st.session_state.ai_clicked = True
493
+
494
  with ai_response_container:
495
  if st.session_state.get("ai_clicked") and st.session_state.get("last_query"):
496
+ st.markdown("### 🤖 Рекомендации AI:")
497
  with st.spinner("Генерация ответа AI..."):
498
  rag = generate_rag_response(st.session_state.last_query, st.session_state.results, llm)
499
  st.write(rag)
 
 
 
 
 
 
 
 
500
 
501
  if __name__ == "__main__":
502
  main()