Kapex13 commited on
Commit
36b55dc
·
verified ·
1 Parent(s): e90581b

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +205 -102
src/streamlit_app.py CHANGED
@@ -11,13 +11,12 @@ import ast
11
  import random
12
  import tempfile
13
 
14
- # Пути к файлам
15
  HERE = os.path.dirname(os.path.abspath(__file__))
16
  CSV_PATH = os.path.join(HERE, "tvshows_processed2.csv")
17
  EMB_PATH = os.path.join(HERE, "embeddings.npy")
18
  FAISS_PATH = os.path.join(HERE, "faiss_index.index")
19
 
20
- # Статические данные
21
  BASIC_GENRES = [
22
  "комедия", "драма", "боевик", "фэнтези", "ужасы", "триллер", "романтика",
23
  "научная фантастика", "приключения", "криминал", "мюзикл",
@@ -32,6 +31,7 @@ BAD_PHRASE_PARTS = [
32
  "описание отсутствует", "пусто"
33
  ]
34
 
 
35
  def list_str_to_text(x):
36
  try:
37
  lst = ast.literal_eval(x) if isinstance(x, str) else x
@@ -58,19 +58,24 @@ def extract_intro_paragraph(text, max_sentences=4):
58
 
59
  def clean_tvshows_data(path):
60
  if not os.path.exists(path):
61
- st.error(f"Файл данных не найден: {path}.")
62
- st.stop()
63
  df = pd.read_csv(path)
64
- df["actors"] = df["actors"].apply(list_str_to_text).apply(clean_actors_string)
65
- df["genres"] = df["genres"].apply(list_str_to_text)
66
- df["year"] = pd.to_numeric(df["year"], errors="coerce").fillna(0).astype(int)
67
- df["num_seasons"] = pd.to_numeric(df["num_seasons"], errors="coerce").fillna(0).astype(int)
68
- df["tvshow_title"] = df["tvshow_title"].fillna("Неизвестно")
69
- df["description"] = df["description"].fillna("Нет описания").astype(str).str.strip()
70
 
 
71
  df = df[df["description"].apply(lambda x: len(str(x).split())) >= 15]
72
- to_drop_exact = df["description"].value_counts()[lambda x: x >= 3].index
73
- df = df[~df["description"].isin(to_drop_exact)]
 
 
 
 
 
74
 
75
  garbage_patterns = [
76
  r"(всё в порядке[.!?~ ,]*){3,}",
@@ -80,19 +85,23 @@ def clean_tvshows_data(path):
80
  r"(нет[.,\s]*){5,}",
81
  ]
82
  def matches_garbage(text):
83
- return any(re.search(p, str(text).lower()) for p in garbage_patterns)
 
84
  df = df[~df["description"].apply(matches_garbage)]
85
 
86
  # фильтрация по плохим фразам
87
  df = df[~df["description"].str.lower().apply(lambda text: any(phrase in text for phrase in BAD_PHRASE_PARTS))]
88
 
 
 
 
 
 
89
  genre_onehots = [
90
- c for c in df.columns if c not in [
91
- 'tvshow_title','year','genres','actors','rating','description',
92
- 'image_url','url','language','country','directors','page_url','num_seasons'
93
- ] and df[c].nunique() <= 2
94
  ]
95
  df = df.drop(columns=genre_onehots, errors="ignore")
 
96
  df["basic_genres"] = df["genres"].apply(filter_to_basic_genres)
97
  df["type"] = df["num_seasons"].apply(lambda x: "Сериал" if pd.notna(x) and int(x) > 1 else "Фильм")
98
  for col in ["image_url", "url", "rating", "language", "country"]:
@@ -100,56 +109,66 @@ def clean_tvshows_data(path):
100
  df[col] = None
101
  return df.reset_index(drop=True)
102
 
 
103
  @st.cache_data
104
- def load_data():
105
- return clean_tvshows_data(CSV_PATH)
106
 
107
  @st.cache_resource
108
- def init_embedder():
109
  cache_dir = os.path.join(tempfile.gettempdir(), "sbert_cache")
110
  os.makedirs(cache_dir, exist_ok=True)
111
  return SentenceTransformer("sberbank-ai/sbert_large_nlu_ru", cache_folder=cache_dir)
112
 
113
  @st.cache_resource
114
- def load_embeddings_and_index():
115
  if not os.path.exists(EMB_PATH) or not os.path.exists(FAISS_PATH):
116
- st.error("Файлы embeddings.npy или faiss_index.index не найдены.")
117
- st.stop()
118
  embeddings = np.load(EMB_PATH)
119
  index = faiss.read_index(FAISS_PATH)
120
  return embeddings, index
121
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def semantic_search(query, embedder, index, df, genre=None, year=None, country=None, vtype=None, k=5):
123
- if not query.strip():
124
  return pd.DataFrame()
125
  query_embedding = embedder.encode([query])
126
  faiss.normalize_L2(query_embedding)
127
- dists, idxs = index.search(query_embedding, max(k*3, k))
128
- res = df.iloc[idxs[0]].copy()
129
- res["score"] = dists[0]
130
- if genre != "Все":
 
 
 
 
 
 
131
  res = res[res["basic_genres"].str.contains(genre, na=False)]
132
- if year != "Все":
133
- res = res[res["year"] == int(year)]
134
- if country != "Все":
 
 
 
135
  res = res[res["country"].astype(str).str.contains(country, na=False)]
136
- if vtype != "Все":
137
  res = res[res["type"] == vtype]
 
 
138
  return res.nlargest(k, "score")
139
 
140
- @st.cache_resource(ttl=3600)
141
- def init_groq_llm():
142
- key = os.environ.get("GROQ_API_KEY") or (st.secrets.get("GROQ_API_KEY") if hasattr(st, "secrets") else None) or st.text_input("🔐 Введите API-ключ Groq:", type="password")
143
- if not key:
144
- st.warning("Введите Groq API ключ.")
145
- st.stop()
146
- os.environ["GROQ_API_KEY"] = key
147
- try:
148
- return ChatGroq(model="deepseek-r1-distill-llama-70b", temperature=0, max_tokens=2000)
149
- except Exception as e:
150
- st.error(f"Ошибка инициализации Groq: {e}")
151
- st.stop()
152
-
153
  def format_docs_for_prompt(results_df):
154
  parts = []
155
  for _, row in results_df.iterrows():
@@ -163,41 +182,99 @@ def format_docs_for_prompt(results_df):
163
  return "\n\n".join(parts)
164
 
165
  def generate_rag_response(user_query, search_results, llm):
 
 
166
  ctx = format_docs_for_prompt(search_results)
167
- return llm.invoke([SystemMessage(content="Ты — эксперт по кино и сериалам."),
168
- HumanMessage(content=f"Запрос: {user_query}\n\n{ctx}")]).content.strip()
 
 
 
169
 
 
170
  def main():
171
  st.set_page_config(page_title="Поиск фильмов и сериалов + AI", layout="wide")
172
  st.title("Семантический поиск фильмов и сериалов с AI")
173
 
174
- if "results" not in st.session_state:
175
- st.session_state.results = pd.DataFrame()
176
- if "ai_clicked" not in st.session_state:
177
- st.session_state.ai_clicked = False
178
-
179
- df = load_data()
180
- embedder = init_embedder()
181
- _, index = load_embeddings_and_index()
182
- llm = init_groq_llm()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
  with st.form(key='search_form'):
185
  colf1, colf2, colf3, colf4 = st.columns(4)
186
  with colf1:
187
- genres = ["Все"] + sorted(set(sum([g.split(", ") for g in df["basic_genres"].dropna().unique()], [])))
188
- genre_filter = st.selectbox("Жанр", genres)
 
 
 
 
 
 
 
 
189
  with colf2:
190
  years = ["Все"] + [str(y) for y in sorted(df["year"].unique())]
191
- year_filter = st.selectbox("Год", years)
192
  with colf3:
193
  countries = ["Все"] + sorted([c for c in df["country"].dropna().unique()])
194
- country_filter = st.selectbox("Страна", countries)
195
  with colf4:
196
  vtypes = ["Все"] + sorted(df["type"].dropna().unique())
197
- type_filter = st.selectbox("Тип", vtypes)
198
-
199
- k = st.slider("Количество результатов:", 1, 20, 5)
200
- user_input = st.text_input("Введите ключевые слова или сюжет:")
201
 
202
  nav1, nav2, nav3, nav4 = st.columns(4)
203
  with nav1:
@@ -208,9 +285,12 @@ def main():
208
  new_search = st.form_submit_button("Новинки")
209
  with nav4:
210
  text_search = st.form_submit_button("Искать")
211
-
 
 
212
  if text_search and user_input:
213
  st.session_state.last_query = user_input
 
214
  with st.spinner("Поиск..."):
215
  st.session_state.results = semantic_search(
216
  user_input, embedder, index, df,
@@ -218,8 +298,9 @@ def main():
218
  )
219
  st.session_state.ai_clicked = False
220
  elif random_search:
221
- random_query = random.choice(df["tvshow_title"])
222
  st.session_state.last_query = random_query
 
223
  with st.spinner("Поиск..."):
224
  st.session_state.results = semantic_search(
225
  random_query, embedder, index, df,
@@ -228,6 +309,7 @@ def main():
228
  st.session_state.ai_clicked = False
229
  elif genre_search and genre_filter != "Все":
230
  st.session_state.last_query = genre_filter
 
231
  with st.spinner("Поиск..."):
232
  st.session_state.results = semantic_search(
233
  genre_filter, embedder, index, df,
@@ -235,50 +317,71 @@ def main():
235
  )
236
  st.session_state.ai_clicked = False
237
  elif new_search:
238
- new_query = str(max(df["year"]))
239
  st.session_state.last_query = new_query
 
240
  with st.spinner("Поиск..."):
241
  st.session_state.results = semantic_search(
242
  new_query, embedder, index, df,
243
  genre_filter, year_filter, country_filter, type_filter, k
244
  )
245
  st.session_state.ai_clicked = False
246
- elif text_search or random_search or genre_search or new_search:
247
- st.session_state.results = pd.DataFrame()
248
- st.session_state.ai_clicked = False
249
-
250
- if not st.session_state.results.empty:
251
- st.success(f"Найдено: {len(st.session_state.results)}")
252
- for _, row in st.session_state.results.iterrows():
253
- col1, col2 = st.columns([1, 3])
254
- with col1:
255
- if row["image_url"]:
256
- try:
257
- st.image(row["image_url"], use_container_width=True)
258
- except:
259
- st.info("Нет изображения или не удалось загрузить")
260
- else:
261
- st.info("Нет изображения")
262
- with col2:
263
- st.markdown(f"### {row['tvshow_title']} ({row['year']})")
264
- st.caption(f"{row['basic_genres']} | {row['country'] or '—'} | {row['rating'] or '—'} | {row['type']} | {row['num_seasons']} сез.")
265
- st.write(extract_intro_paragraph(row["description"]))
266
- if row["actors"]:
267
- st.caption(f"Актёры: {row['actors']}")
268
- if row["url"]:
269
- st.markdown(f"[Подробнее]({row['url']})")
270
- st.divider()
271
-
272
- if st.button("AI: почему эти подходят и что ещё посмотреть"):
273
- st.session_state.ai_clicked = True
274
- elif 'last_query' in st.session_state and st.session_state.last_query.strip() != "":
275
- st.warning("Ничего не найдено.")
276
-
277
- if st.session_state.ai_clicked and not st.session_state.results.empty and llm is not None:
278
- st.markdown("### Рекомендации AI:")
279
- with st.spinner("Генерация ответа AI..."):
280
- st.write(generate_rag_response(st.session_state.last_query, st.session_state.results, llm))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
 
 
282
  st.sidebar.write(f"Всего записей: {len(df)}")
283
 
284
  if __name__ == "__main__":
 
11
  import random
12
  import tempfile
13
 
14
+ # ====== Настройки путей и констант ======
15
  HERE = os.path.dirname(os.path.abspath(__file__))
16
  CSV_PATH = os.path.join(HERE, "tvshows_processed2.csv")
17
  EMB_PATH = os.path.join(HERE, "embeddings.npy")
18
  FAISS_PATH = os.path.join(HERE, "faiss_index.index")
19
 
 
20
  BASIC_GENRES = [
21
  "комедия", "драма", "боевик", "фэнтези", "ужасы", "триллер", "романтика",
22
  "научная фантастика", "приключения", "криминал", "мюзикл",
 
31
  "описание отсутствует", "пусто"
32
  ]
33
 
34
+ # ====== Вспомогательные функции ======
35
  def list_str_to_text(x):
36
  try:
37
  lst = ast.literal_eval(x) if isinstance(x, str) else x
 
58
 
59
  def clean_tvshows_data(path):
60
  if not os.path.exists(path):
61
+ raise FileNotFoundError(f"Файл данных не найден: {path}.")
 
62
  df = pd.read_csv(path)
63
+ df["actors"] = df.get("actors", "").apply(list_str_to_text).apply(clean_actors_string)
64
+ df["genres"] = df.get("genres", "").apply(list_str_to_text)
65
+ df["year"] = pd.to_numeric(df.get("year", 0), errors="coerce").fillna(0).astype(int)
66
+ df["num_seasons"] = pd.to_numeric(df.get("num_seasons", 0), errors="coerce").fillna(0).astype(int)
67
+ df["tvshow_title"] = df.get("tvshow_title", "").fillna("Неизвестно")
68
+ df["description"] = df.get("description", "").fillna("Нет описания").astype(str).str.strip()
69
 
70
+ # Минимальная длина описания — фильтр "мусора"
71
  df = df[df["description"].apply(lambda x: len(str(x).split())) >= 15]
72
+
73
+ # Удалим часто повторяющиеся одинаковые описания (вероятный мусор)
74
+ try:
75
+ to_drop_exact = df["description"].value_counts()[lambda x: x >= 3].index
76
+ df = df[~df["description"].isin(to_drop_exact)]
77
+ except Exception:
78
+ pass
79
 
80
  garbage_patterns = [
81
  r"(всё в порядке[.!?~ ,]*){3,}",
 
85
  r"(нет[.,\s]*){5,}",
86
  ]
87
  def matches_garbage(text):
88
+ t = str(text).lower()
89
+ return any(re.search(p, t) for p in garbage_patterns)
90
  df = df[~df["description"].apply(matches_garbage)]
91
 
92
  # фильтрация по плохим фразам
93
  df = df[~df["description"].str.lower().apply(lambda text: any(phrase in text for phrase in BAD_PHRASE_PARTS))]
94
 
95
+ # удалить бинарные столбцы жанров (one-hot), если есть
96
+ cols_to_ignore = {
97
+ 'tvshow_title','year','genres','actors','rating','description',
98
+ 'image_url','url','language','country','directors','page_url','num_seasons'
99
+ }
100
  genre_onehots = [
101
+ c for c in df.columns if c not in cols_to_ignore and df[c].nunique() <= 2
 
 
 
102
  ]
103
  df = df.drop(columns=genre_onehots, errors="ignore")
104
+
105
  df["basic_genres"] = df["genres"].apply(filter_to_basic_genres)
106
  df["type"] = df["num_seasons"].apply(lambda x: "Сериал" if pd.notna(x) and int(x) > 1 else "Фильм")
107
  for col in ["image_url", "url", "rating", "language", "country"]:
 
109
  df[col] = None
110
  return df.reset_index(drop=True)
111
 
112
+ # ====== Кэширование и инициализация (один раз) ======
113
  @st.cache_data
114
+ def cached_load_data(path):
115
+ return clean_tvshows_data(path)
116
 
117
  @st.cache_resource
118
+ def cached_init_embedder():
119
  cache_dir = os.path.join(tempfile.gettempdir(), "sbert_cache")
120
  os.makedirs(cache_dir, exist_ok=True)
121
  return SentenceTransformer("sberbank-ai/sbert_large_nlu_ru", cache_folder=cache_dir)
122
 
123
  @st.cache_resource
124
+ def cached_load_embeddings_and_index():
125
  if not os.path.exists(EMB_PATH) or not os.path.exists(FAISS_PATH):
126
+ raise FileNotFoundError("Файлы embeddings.npy или faiss_index.index не найдены.")
 
127
  embeddings = np.load(EMB_PATH)
128
  index = faiss.read_index(FAISS_PATH)
129
  return embeddings, index
130
 
131
+ def cached_init_groq_llm(api_key: str):
132
+ # Не кэшируем внутри функции Streamlit виджет — вызываем только если ключ есть.
133
+ if not api_key:
134
+ return None
135
+ os.environ["GROQ_API_KEY"] = api_key
136
+ try:
137
+ return ChatGroq(model="deepseek-r1-distill-llama-70b", temperature=0, max_tokens=2000)
138
+ except Exception as e:
139
+ st.error(f"Ошибка инициализации Groq: {e}")
140
+ return None
141
+
142
+ # ====== Поисковые/вспомогательные функции ======
143
  def semantic_search(query, embedder, index, df, genre=None, year=None, country=None, vtype=None, k=5):
144
+ if not isinstance(query, str) or not query.strip():
145
  return pd.DataFrame()
146
  query_embedding = embedder.encode([query])
147
  faiss.normalize_L2(query_embedding)
148
+ # безопасный search: index.search expects int >=1
149
+ n_search = max(k*3, 1)
150
+ dists, idxs = index.search(query_embedding, n_search)
151
+ # idxs может содержать -1 для неполных результатов — защитим себя
152
+ valid_idxs = [i for i in idxs[0] if i >= 0 and i < len(df)]
153
+ if not valid_idxs:
154
+ return pd.DataFrame()
155
+ res = df.iloc[valid_idxs].copy()
156
+ res["score"] = dists[0][:len(valid_idxs)]
157
+ if genre and genre != "Все":
158
  res = res[res["basic_genres"].str.contains(genre, na=False)]
159
+ if year and year != "Все":
160
+ try:
161
+ res = res[res["year"] == int(year)]
162
+ except:
163
+ pass
164
+ if country and country != "Все":
165
  res = res[res["country"].astype(str).str.contains(country, na=False)]
166
+ if vtype and vtype != "Все":
167
  res = res[res["type"] == vtype]
168
+ if res.empty:
169
+ return res
170
  return res.nlargest(k, "score")
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  def format_docs_for_prompt(results_df):
173
  parts = []
174
  for _, row in results_df.iterrows():
 
182
  return "\n\n".join(parts)
183
 
184
  def generate_rag_response(user_query, search_results, llm):
185
+ if llm is None or search_results.empty:
186
+ return "LLM не инициализирован или нет результатов для анализа."
187
  ctx = format_docs_for_prompt(search_results)
188
+ try:
189
+ return llm.invoke([SystemMessage(content="Ты — эксперт по кино и сериалам."),
190
+ HumanMessage(content=f"Запрос: {user_query}\n\n{ctx}")]).content.strip()
191
+ except Exception as e:
192
+ return f"Ошибка при генерации ответа LLM: {e}"
193
 
194
+ # ====== UI: main ======
195
  def main():
196
  st.set_page_config(page_title="Поиск фильмов и сериалов + AI", layout="wide")
197
  st.title("Семантический поиск фильмов и сериалов с AI")
198
 
199
+ # ====== Сайдбар: API ключ и глобальные настройки (фиксируем здесь) ======
200
+ st.sidebar.header("Настройки")
201
+ api_key = st.sidebar.text_input("Groq API ключ (если нужен):", type="password")
202
+ # Кэш��руем ключ в session_state чтобы не перерисовывать виджет внутри init-функции
203
+ if "groq_api_key" not in st.session_state:
204
+ st.session_state.groq_api_key = api_key
205
+ else:
206
+ # если поменял в сайдбаре — актуализируем
207
+ if api_key and api_key != st.session_state.groq_api_key:
208
+ st.session_state.groq_api_key = api_key
209
+
210
+ # ====== Инициализация данных и ресурсов один раз (через session_state) ======
211
+ if "df" not in st.session_state:
212
+ try:
213
+ st.session_state.df = cached_load_data(CSV_PATH)
214
+ except FileNotFoundError as e:
215
+ st.sidebar.error(str(e))
216
+ st.stop()
217
+
218
+ if "embedder" not in st.session_state:
219
+ try:
220
+ st.session_state.embedder = cached_init_embedder()
221
+ except Exception as e:
222
+ st.sidebar.error(f"Ошибка инициализации embedder: {e}")
223
+ st.stop()
224
+
225
+ if "embeddings_index" not in st.session_state:
226
+ try:
227
+ st.session_state.embeddings, st.session_state.index = cached_load_embeddings_and_index()
228
+ except FileNotFoundError as e:
229
+ st.sidebar.error(str(e))
230
+ st.stop()
231
+ except Exception as e:
232
+ st.sidebar.error(f"Ошибка загрузки индекса/эмбеддингов: {e}")
233
+ st.stop()
234
+
235
+ # LLM инициализируем только если есть ключ (и положим в st.session_state)
236
+ if st.session_state.get("groq_api_key"):
237
+ if "llm" not in st.session_state or st.session_state.get("last_groq_key") != st.session_state.groq_api_key:
238
+ st.session_state.llm = cached_init_groq_llm(st.session_state.groq_api_key)
239
+ st.session_state.last_groq_key = st.session_state.groq_api_key
240
+ else:
241
+ st.session_state.llm = None
242
+
243
+ df = st.session_state.df
244
+ embedder = st.session_state.embedder
245
+ index = st.session_state.index
246
+ llm = st.session_state.llm
247
+
248
+ # ====== Форма поиска (стабильная) ======
249
+ # Резервируем контейнер для результатов чтобы избежать прыжков layout
250
+ results_container = st.container()
251
+ ai_response_container = st.container()
252
 
253
  with st.form(key='search_form'):
254
  colf1, colf2, colf3, colf4 = st.columns(4)
255
  with colf1:
256
+ # Генерируем список жанров стабильно (сортируем и делаем set один раз)
257
+ basic_genres_list = []
258
+ for g in df["basic_genres"].dropna().unique():
259
+ # split по ", " и extend
260
+ for part in str(g).split(","):
261
+ p = part.strip()
262
+ if p:
263
+ basic_genres_list.append(p)
264
+ genres = ["Все"] + sorted(set(basic_genres_list))
265
+ genre_filter = st.selectbox("Жанр", genres, index=0, key="genre_filter_key")
266
  with colf2:
267
  years = ["Все"] + [str(y) for y in sorted(df["year"].unique())]
268
+ year_filter = st.selectbox("Год", years, index=0, key="year_filter_key")
269
  with colf3:
270
  countries = ["Все"] + sorted([c for c in df["country"].dropna().unique()])
271
+ country_filter = st.selectbox("Страна", countries, index=0, key="country_filter_key")
272
  with colf4:
273
  vtypes = ["Все"] + sorted(df["type"].dropna().unique())
274
+ type_filter = st.selectbox("Тип", vtypes, index=0, key="type_filter_key")
275
+
276
+ k = st.slider("Количество результатов:", 1, 20, 5, key="k_slider")
277
+ user_input = st.text_input("Введите ключевые слова или сюжет:", key="user_input_key")
278
 
279
  nav1, nav2, nav3, nav4 = st.columns(4)
280
  with nav1:
 
285
  new_search = st.form_submit_button("Новинки")
286
  with nav4:
287
  text_search = st.form_submit_button("Искать")
288
+
289
+ # ====== Обработка поисковых событий (логика оставлена прежней) ======
290
+ performed_search = False
291
  if text_search and user_input:
292
  st.session_state.last_query = user_input
293
+ performed_search = True
294
  with st.spinner("Поиск..."):
295
  st.session_state.results = semantic_search(
296
  user_input, embedder, index, df,
 
298
  )
299
  st.session_state.ai_clicked = False
300
  elif random_search:
301
+ random_query = random.choice(df["tvshow_title"].tolist())
302
  st.session_state.last_query = random_query
303
+ performed_search = True
304
  with st.spinner("Поиск..."):
305
  st.session_state.results = semantic_search(
306
  random_query, embedder, index, df,
 
309
  st.session_state.ai_clicked = False
310
  elif genre_search and genre_filter != "Все":
311
  st.session_state.last_query = genre_filter
312
+ performed_search = True
313
  with st.spinner("Поиск..."):
314
  st.session_state.results = semantic_search(
315
  genre_filter, embedder, index, df,
 
317
  )
318
  st.session_state.ai_clicked = False
319
  elif new_search:
320
+ new_query = str(int(df["year"].max())) if not df["year"].isna().all() else ""
321
  st.session_state.last_query = new_query
322
+ performed_search = True
323
  with st.spinner("Поиск..."):
324
  st.session_state.results = semantic_search(
325
  new_query, embedder, index, df,
326
  genre_filter, year_filter, country_filter, type_filter, k
327
  )
328
  st.session_state.ai_clicked = False
329
+ else:
330
+ # если форма была отправлена без поискового действия — не трогаем
331
+ if 'results' not in st.session_state:
332
+ st.session_state.results = pd.DataFrame()
333
+ st.session_state.ai_clicked = False
334
+
335
+ # ====== Отрисовка результатов в постоянном контейнере (чтобы не дергалось) ======
336
+ with results_container:
337
+ # всегда резервируем пространство — пустой заголовок/плейсхолдер, чтобы layout не менялся
338
+ st.markdown("## Результаты поиска")
339
+ if not st.session_state.get("results") or st.session_state.results.empty:
340
+ # Показываем либо предупреждение если был поиск и ничего не найдено,
341
+ # либо подсказку с примером — без "скачка" layout.
342
+ if performed_search and ('last_query' in st.session_state and st.session_state.last_query.strip() != ""):
343
+ st.warning("Ничего не найдено.")
344
+ else:
345
+ st.info("Введите запрос и нажмите «Искать», или выберите «Случайный фильм/сериал».")
346
+ else:
347
+ res_df = st.session_state.results
348
+ st.success(f"Найдено: {len(res_df)}")
349
+ # выводим карточки — фиксируем ширину изображения, и��пользуем колонки одинаковой структуры
350
+ for _, row in res_df.iterrows():
351
+ card_cols = st.columns([1, 3])
352
+ with card_cols[0]:
353
+ # зарезервируем пространство под изображение фиксированной ширины
354
+ if row.get("image_url"):
355
+ try:
356
+ st.image(row["image_url"], width=150)
357
+ except Exception:
358
+ st.info("Нет изображения")
359
+ else:
360
+ st.info("Нет изображения")
361
+ with card_cols[1]:
362
+ st.markdown(f"### {row['tvshow_title']} ({row['year']})")
363
+ st.caption(f"{row['basic_genres']} | {row['country'] or '—'} | {row['rating'] or '—'} | {row['type']} | {row['num_seasons']} сез.")
364
+ st.write(extract_intro_paragraph(row["description"]))
365
+ if row.get("actors"):
366
+ st.caption(f"Актёры: {row['actors']}")
367
+ if row.get("url"):
368
+ st.markdown(f"[Подробнее]({row['url']})")
369
+ st.divider()
370
+
371
+ # кнопка AI — рендерим в том же контейнере, чтобы layout был постоянным
372
+ if st.button("AI: почему эти подходят и что ещё посмотреть", key="ai_button"):
373
+ st.session_state.ai_clicked = True
374
+
375
+ # ====== AI-ответ в отдельном контейнере (резервированном) ======
376
+ with ai_response_container:
377
+ if st.session_state.get("ai_clicked") and st.session_state.get("results") is not None and not st.session_state.results.empty:
378
+ st.markdown("### Рекомендации AI:")
379
+ with st.spinner("Генерация ответа AI..."):
380
+ rag = generate_rag_response(st.session_state.last_query, st.session_state.results, llm)
381
+ # Выводим результат в обрамлённом блоке, не добавляя других виджетов
382
+ st.write(rag)
383
 
384
+ # ====== Сайдбар: статистика ======
385
  st.sidebar.write(f"Всего записей: {len(df)}")
386
 
387
  if __name__ == "__main__":