stephenhoang commited on
Commit
08d156e
·
verified ·
1 Parent(s): e540ec2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +207 -153
app.py CHANGED
@@ -5,87 +5,84 @@ import pickle
5
  import faiss
6
  import re
7
  import os
8
- import zipfile
9
  import shutil
 
10
  from rank_bm25 import BM25Okapi
11
  from sentence_transformers import SentenceTransformer
 
12
 
13
  # ============================================
14
- # 1. SETUP GIAO DIỆN & CONFIG (PHẢI ĐỂ ĐẦU TIÊN)
15
  # ============================================
16
- st.set_page_config(page_title="H&M Semantic Search", page_icon="🛍️", layout="wide")
17
 
18
  st.markdown("""
19
  <style>
20
- .main {background-color: #f5f5f5;}
21
- .stButton>button {width: 100%; background-color: #ff4b4b; color: white; border-radius: 5px;}
22
- .stImage {border-radius: 8px;}
23
- div[data-testid="stMetricValue"] {font-size: 1.2rem;}
24
  </style>
25
  """, unsafe_allow_html=True)
26
 
27
  # ============================================
28
- # 2. XỬ ẢNH (GIẢI NÉN AN TOÀN - CHẠY 1 LẦN)
29
  # ============================================
30
- @st.cache_resource
31
- def setup_images():
32
- """Hàm này giải nén file ZIP ảnh khi server khởi động"""
33
- ZIP_FILE = 'hm_10k_compressed.zip'
34
- IMG_FOLDER = 'static_images'
35
-
36
- # Chỉ chạy nếu file ZIP tồn tại
37
- if os.path.exists(ZIP_FILE):
38
- # Bước 1: Dọn dẹp folder cũ (nếu có) để tránh lỗi FileExistsError
39
- if os.path.exists(IMG_FOLDER):
40
- try:
41
- # Nếu là file (do lỗi cũ tạo ra), xóa file
42
- if not os.path.isdir(IMG_FOLDER):
43
- os.remove(IMG_FOLDER)
44
- # Nếu là folder, xóa sạch bên trong
45
- else:
46
- shutil.rmtree(IMG_FOLDER)
47
- except Exception as e:
48
- print(f"⚠️ Warning cleaning folder: {e}")
49
 
50
- # Bước 2: Tạo folder mới và giải nén
 
 
 
 
51
  try:
52
- os.makedirs(IMG_FOLDER, exist_ok=True)
53
- with zipfile.ZipFile(ZIP_FILE, 'r') as zip_ref:
54
- zip_ref.extractall(IMG_FOLDER)
 
 
 
 
 
 
 
 
 
55
  return True
56
  except Exception as e:
57
- return str(e)
58
-
59
- return False
60
 
61
- # Gọi hàm setup ngay lập tức
62
- setup_status = setup_images()
63
 
64
  # ============================================
65
- # 3. LOAD MODEL & DATA (CACHING)
66
  # ============================================
67
  @st.cache_resource
68
  def load_models():
69
  MODEL_PATH = "."
70
-
71
- print("⏳ Loading Artifacts...")
72
 
73
  # Load DataFrame
74
  with open(f'{MODEL_PATH}/df_products.pkl', 'rb') as f:
75
  df = pickle.load(f)
76
 
77
- # Load BM25 (Nếu không file thì bỏ qua phần này hoặc handle error)
78
- # Giả sử bro đã có file bm25_model.pkl, nếu chưa có thì comment đoạn này lại
79
  try:
80
  with open(f'{MODEL_PATH}/bm25_model.pkl', 'rb') as f:
81
  bm25 = pickle.load(f)
82
  except:
83
- bm25 = None # Xử lý nếu thiếu file BM25
84
 
85
  # Load Embeddings
86
  embeddings = np.load(f'{MODEL_PATH}/sbert_embeddings.npy')
87
 
88
- # Load SBERT Model
89
  sbert_model = SentenceTransformer('all-MiniLM-L6-v2')
90
 
91
  return df, bm25, embeddings, sbert_model
@@ -93,34 +90,31 @@ def load_models():
93
  try:
94
  df, bm25, embeddings, sbert_model = load_models()
95
 
96
- # Re-build FAISS Index
97
  faiss.normalize_L2(embeddings)
98
  index = faiss.IndexFlatIP(embeddings.shape[1])
99
  index.add(embeddings)
100
 
101
  except Exception as e:
102
- st.error(f"❌ Lỗi load model/data: {e}")
103
  st.stop()
104
 
105
  # ============================================
106
- # 4. SEARCH ENGINE CLASS
107
  # ============================================
108
- class StreamlitSearchEngine:
109
- def __init__(self, df, bm25, index, sbert_model):
110
  self.df = df
111
  self.bm25 = bm25
112
  self.index = index
113
  self.sbert_model = sbert_model
 
114
 
115
- # Từ điển mở rộng query
116
  self.phrase_synonyms = {
117
  'running shoes': ['trainers', 'sneakers', 'runners', 'athletic footwear'],
118
- 'running shoe': ['trainers', 'sneakers', 'runners'],
119
- 'gym shoes': ['trainers', 'sneakers'],
120
- 'joggers': ['sweatpants', 'track pants'],
121
- 'denim jeans': ['blue jeans', 'denim'],
122
- 'hoodie': ['sweatshirt', 'hooded'],
123
- 'summer dress': ['sundress', 'floral dress']
124
  }
125
 
126
  def _min_max_normalize(self, scores):
@@ -128,21 +122,19 @@ class StreamlitSearchEngine:
128
  if max_s - min_s == 0: return np.zeros_like(scores)
129
  return (scores - min_s) / (max_s - min_s)
130
 
131
- def _expand_query_phrase(self, query):
132
- query_lower = str(query).lower()
133
- expansion_terms = []
134
- for phrase, synonyms in self.phrase_synonyms.items():
135
- if phrase in query_lower:
136
- expansion_terms.extend(synonyms)
137
- if expansion_terms:
138
- return query_lower + " " + " ".join(list(set(expansion_terms)))
139
- return query_lower
140
 
141
- def search(self, query, top_k=10, alpha=0.5):
142
  # 1. Expand
143
- expanded_q = self._expand_query_phrase(query)
144
 
145
- # 2. Semantic Search (SBERT) - Luôn chạy
146
  q_vec = self.sbert_model.encode([query]).astype('float32')
147
  faiss.normalize_L2(q_vec)
148
  D, I = self.index.search(q_vec, len(self.df))
@@ -151,114 +143,176 @@ class StreamlitSearchEngine:
151
  sbert_raw[I[0]] = D[0]
152
  sbert_norm = self._min_max_normalize(sbert_raw)
153
 
154
- # 3. Lexical Search (BM25) - Chỉ chạy nếu có model BM25
155
  if self.bm25:
156
- q_lexical = re.sub(r"[^a-z0-9\s\-\%]", " ", expanded_q).split()
157
- bm25_raw = self.bm25.get_scores(q_lexical)
158
  bm25_norm = self._min_max_normalize(bm25_raw)
159
- # Fusion
160
  final_scores = (alpha * bm25_norm) + ((1 - alpha) * sbert_norm)
161
  else:
162
- # Nếu không có BM25 thì chỉ dùng SBERT
163
  final_scores = sbert_norm
164
  bm25_norm = np.zeros(len(self.df))
165
-
166
- # 4. Result Formatting
167
  top_indices = np.argsort(final_scores)[::-1][:top_k]
168
  results = self.df.iloc[top_indices].copy()
169
-
170
  results['score'] = final_scores[top_indices]
171
- results['bm25'] = bm25_norm[top_indices]
172
- results['sbert'] = sbert_norm[top_indices]
173
-
174
  return results, expanded_q
175
 
176
- engine = StreamlitSearchEngine(df, bm25, index, sbert_model)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
 
178
  # ============================================
179
- # 5. GIAO DIỆN CHÍNH (UI)
180
  # ============================================
181
- st.title("🛍️ H&M AI Hybrid Search")
182
- st.caption("Project Semantic Search - Demo")
183
 
184
- with st.sidebar:
185
- st.header("⚙️ Cấu hình")
186
- alpha = st.slider("Trọng số Hybrid (Alpha)", 0.0, 1.0, 0.5, 0.1, help="0: Chỉ Semantic, 1: Chỉ Keyword")
187
- top_k = st.slider("Số lượng kết quả", 5, 20, 10)
188
- st.markdown("---")
189
- st.info("💡 **Mẹo:** Thử tìm *'Black running shoes'* để xem AI tự động hiểu là *'Sneakers'* như thế nào!")
190
 
191
- # Search Box
192
- col1, col2 = st.columns([4, 1])
193
- with col1:
194
- query = st.text_input("Nhập mô tả sản phẩm...", placeholder="Ví dụ: Black running shoes, Floral summer dress...")
195
- with col2:
196
- st.write("")
197
- st.write("")
198
- btn_search = st.button("🔍 Tìm kiếm")
199
 
200
- # Xử khi bấm tìm kiếm
201
- if btn_search or query:
202
- with st.spinner('AI đang phân tích & tìm kiếm...'):
203
- results, expanded_q = engine.search(query, top_k=top_k, alpha=alpha)
204
-
205
- # Debug Info
206
- with st.expander("🕵️‍♂️ Xem cơ chế hoạt động của AI (Debug Info)", expanded=True):
207
- st.write(f"**Query gốc:** `{query}`")
208
- if query.lower() != expanded_q:
209
- st.success(f"**✨ Query đã mở rộng:** `{expanded_q}`")
210
- else:
211
- st.info("**Query không thay đổi** (Không tìm thấy cụm từ đồng nghĩa).")
212
 
213
- st.markdown(f"### Kết quả tìm thấy: {len(results)}")
 
 
 
 
 
 
214
 
215
- # --- VÒNG LẶP HIỂN THỊ KẾT QUẢ ---
216
- for index, row in results.iterrows():
217
- with st.container():
218
- # Chia cột giao diện
219
- col_img, col_info, col_act = st.columns([1.5, 5, 1.5])
220
-
221
- # --- QUAN TRỌNG: LẤY ID SẢN PHẨM ---
222
- # Xử an toàn để tránh lỗi KeyError/NameError
223
- # Ưu tiên cột 'article_id', nếu lỗi thì dùng index
224
- raw_id = row.get('article_id', index)
225
- article_id = str(raw_id).zfill(10) # Đảm bảo đủ 10 số
 
 
 
226
 
227
- # --- CỘT 1: ẢNH (Load từ thư mục static_images) ---
228
- with col_img:
229
- local_path = os.path.join('static_images', f"{article_id}.jpg")
230
-
231
- if os.path.exists(local_path):
232
- st.image(local_path, width=140)
233
- else:
234
- # Ảnh dự phòng
235
- st.image("https://via.placeholder.com/150x220.png?text=No+Image", width=140)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
- # --- CỘT 2: THÔNG TIN CHI TIẾT ---
238
- with col_info:
239
- # Tên sản phẩm (xử nếu thiếu cột prod_name)
240
- prod_name = row.get('prod_name', 'Unknown Product')
241
- st.subheader(prod_name)
 
 
 
 
 
 
 
242
 
243
- # Giá tiền (xử lý nếu thiếu cột price)
244
- price = row.get('price', 0)
245
- st.write(f"**Price:** ${price:.2f}")
 
 
 
 
 
246
 
247
- # ID sản phẩm
248
- st.caption(f"Article ID: {article_id}")
249
-
250
- # Mô tả ngắn
251
- desc = str(row.get('detail_desc', 'No description'))
252
- if len(desc) > 200:
253
- desc = desc[:200] + "..."
254
- st.write(desc)
 
 
 
255
 
256
- # --- CỘT 3: ĐIỂM SỐ & NÚT ---
257
- with col_act:
258
- score = row.get('score', 0)
259
- st.metric(label="Match Score", value=f"{score:.2f}")
 
 
260
 
261
- # Nút bấm (Key phải duy nhất để không lỗi)
262
- st.button("Buy Now", key=f"btn_{article_id}_{index}")
263
-
264
- st.divider()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import faiss
6
  import re
7
  import os
 
8
  import shutil
9
+ import zipfile
10
  from rank_bm25 import BM25Okapi
11
  from sentence_transformers import SentenceTransformer
12
+ from huggingface_hub import hf_hub_download
13
 
14
  # ============================================
15
+ # 1. CẤU HÌNH & CSS
16
  # ============================================
17
+ st.set_page_config(page_title="H&M AI Shop", page_icon="🛍️", layout="wide")
18
 
19
  st.markdown("""
20
  <style>
21
+ .main {background-color: #f8f9fa;}
22
+ .stButton>button {width: 100%; border-radius: 5px; font-weight: bold;}
23
+ .block-container {padding-top: 2rem;}
24
+ div[data-testid="stMetricValue"] {font-size: 1.1rem;}
25
  </style>
26
  """, unsafe_allow_html=True)
27
 
28
  # ============================================
29
+ # 2. HỆ THỐNG TẢI ẢNH TỪ DATASET (CACHE)
30
  # ============================================
31
+ # 👉 SỬA LẠI THÔNG TIN NÀY CHO ĐÚNG CỦA BRO
32
+ DATASET_REPO_ID = "stephenhoang/hm-fashion-images-demo"
33
+ ZIP_FILENAME = "hm_images_50k_optimized.zip" # Tên file zip bro đã up lên dataset
34
+ LOCAL_IMG_DIR = "/tmp/hm_images_cache" # Thư mục tạm trên Space
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ @st.cache_resource
37
+ def setup_image_cache():
38
+ """Tải và giải nén ảnh từ Hugging Face Dataset (Chỉ chạy 1 lần)"""
39
+ if not os.path.exists(LOCAL_IMG_DIR):
40
+ os.makedirs(LOCAL_IMG_DIR, exist_ok=True)
41
  try:
42
+ print(" Đang tải kho ảnh từ Dataset (Lần đầu sẽ lâu)...")
43
+ zip_path = hf_hub_download(
44
+ repo_id=DATASET_REPO_ID,
45
+ filename=ZIP_FILENAME,
46
+ repo_type="dataset",
47
+ token=os.environ.get("HF_TOKEN")
48
+ )
49
+
50
+ print(" Đang giải nén...")
51
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
52
+ zip_ref.extractall(LOCAL_IMG_DIR)
53
+ print("Kho ảnh đã sẵn sàng!")
54
  return True
55
  except Exception as e:
56
+ print(f"❌ Lỗi tải ảnh: {e}")
57
+ return False
58
+ return True
59
 
60
+ # Kích hoạt hệ thống ảnh
61
+ cache_status = setup_image_cache()
62
 
63
  # ============================================
64
+ # 3. LOAD MODEL & DATA
65
  # ============================================
66
  @st.cache_resource
67
  def load_models():
68
  MODEL_PATH = "."
69
+ print("⏳ Loading Models & Data...")
 
70
 
71
  # Load DataFrame
72
  with open(f'{MODEL_PATH}/df_products.pkl', 'rb') as f:
73
  df = pickle.load(f)
74
 
75
+ # Load BM25 (Xử nếu thiếu)
 
76
  try:
77
  with open(f'{MODEL_PATH}/bm25_model.pkl', 'rb') as f:
78
  bm25 = pickle.load(f)
79
  except:
80
+ bm25 = None
81
 
82
  # Load Embeddings
83
  embeddings = np.load(f'{MODEL_PATH}/sbert_embeddings.npy')
84
 
85
+ # Load SBERT
86
  sbert_model = SentenceTransformer('all-MiniLM-L6-v2')
87
 
88
  return df, bm25, embeddings, sbert_model
 
90
  try:
91
  df, bm25, embeddings, sbert_model = load_models()
92
 
93
+ # Build FAISS Index
94
  faiss.normalize_L2(embeddings)
95
  index = faiss.IndexFlatIP(embeddings.shape[1])
96
  index.add(embeddings)
97
 
98
  except Exception as e:
99
+ st.error(f"❌ Lỗi load model: {e}")
100
  st.stop()
101
 
102
  # ============================================
103
+ # 4. CLASS SEARCH ENGINE & RECOMMENDATION
104
  # ============================================
105
+ class ShopSearchEngine:
106
+ def __init__(self, df, bm25, index, sbert_model, embeddings):
107
  self.df = df
108
  self.bm25 = bm25
109
  self.index = index
110
  self.sbert_model = sbert_model
111
+ self.embeddings = embeddings # Lưu embeddings để dùng cho recommend
112
 
 
113
  self.phrase_synonyms = {
114
  'running shoes': ['trainers', 'sneakers', 'runners', 'athletic footwear'],
115
+ 'summer dress': ['sundress', 'floral dress', 'beachwear'],
116
+ 'hoodie': ['sweatshirt', 'hooded top'],
117
+ 'denim': ['jeans', 'blue jeans', 'trousers']
 
 
 
118
  }
119
 
120
  def _min_max_normalize(self, scores):
 
122
  if max_s - min_s == 0: return np.zeros_like(scores)
123
  return (scores - min_s) / (max_s - min_s)
124
 
125
+ def _expand_query(self, query):
126
+ q_lower = str(query).lower()
127
+ terms = []
128
+ for k, v in self.phrase_synonyms.items():
129
+ if k in q_lower: terms.extend(v)
130
+ if terms: return q_lower + " " + " ".join(list(set(terms)))
131
+ return q_lower
 
 
132
 
133
+ def search(self, query, top_k=20, alpha=0.5):
134
  # 1. Expand
135
+ expanded_q = self._expand_query(query)
136
 
137
+ # 2. Semantic Search
138
  q_vec = self.sbert_model.encode([query]).astype('float32')
139
  faiss.normalize_L2(q_vec)
140
  D, I = self.index.search(q_vec, len(self.df))
 
143
  sbert_raw[I[0]] = D[0]
144
  sbert_norm = self._min_max_normalize(sbert_raw)
145
 
146
+ # 3. Lexical Search
147
  if self.bm25:
148
+ q_tok = re.sub(r"[^a-z0-9\s]", " ", expanded_q).split()
149
+ bm25_raw = self.bm25.get_scores(q_tok)
150
  bm25_norm = self._min_max_normalize(bm25_raw)
 
151
  final_scores = (alpha * bm25_norm) + ((1 - alpha) * sbert_norm)
152
  else:
 
153
  final_scores = sbert_norm
154
  bm25_norm = np.zeros(len(self.df))
155
+
156
+ # 4. Sort & Format
157
  top_indices = np.argsort(final_scores)[::-1][:top_k]
158
  results = self.df.iloc[top_indices].copy()
 
159
  results['score'] = final_scores[top_indices]
 
 
 
160
  return results, expanded_q
161
 
162
+ def get_related_products(self, article_id, top_k=5):
163
+ """Gợi ý sản phẩm tương tự dựa trên vector"""
164
+ try:
165
+ # Tìm index của sản phẩm trong dataframe
166
+ idx = self.df[self.df['article_id'].astype(str) == str(article_id)].index[0]
167
+
168
+ # Lấy vector của nó
169
+ target_vec = self.embeddings[idx].reshape(1, -1).astype('float32')
170
+ faiss.normalize_L2(target_vec)
171
+
172
+ # Search (Lấy top_k + 1 vì kết quả đầu tiên là chính nó)
173
+ D, I = self.index.search(target_vec, top_k + 1)
174
+
175
+ # Bỏ qua kết quả đầu tiên (chính nó)
176
+ related_indices = I[0][1:]
177
+ related_products = self.df.iloc[related_indices].copy()
178
+ related_products['score'] = D[0][1:]
179
+
180
+ return related_products
181
+ except:
182
+ return None
183
+
184
+ engine = ShopSearchEngine(df, bm25, index, sbert_model, embeddings)
185
 
186
  # ============================================
187
+ # 5. QUẢN TRẠNG THÁI (SESSION STATE)
188
  # ============================================
189
+ if 'selected_product_id' not in st.session_state:
190
+ st.session_state.selected_product_id = None
191
 
192
+ def view_product(aid):
193
+ st.session_state.selected_product_id = str(aid)
 
 
 
 
194
 
195
+ def back_to_search():
196
+ st.session_state.selected_product_id = None
 
 
 
 
 
 
197
 
198
+ # Helper để lấy đường dẫn ảnh
199
+ def get_img_path(aid):
200
+ aid_str = str(aid).zfill(10)
201
+ path = os.path.join(LOCAL_IMG_DIR, f"{aid_str}.jpg")
202
+ if os.path.exists(path):
203
+ return path
204
+ return "https://via.placeholder.com/300x400.png?text=No+Image"
 
 
 
 
 
205
 
206
+ # ============================================
207
+ # 6. GIAO DIỆN CHÍNH (UI)
208
+ # ============================================
209
+
210
+ # --- MÀN HÌNH CHI TIẾT SẢN PHẨM ---
211
+ if st.session_state.selected_product_id:
212
+ aid = st.session_state.selected_product_id
213
 
214
+ # Header & Nút Back
215
+ c_back, c_title = st.columns([1, 5])
216
+ with c_back:
217
+ st.button("⬅️ Quay lại", on_click=back_to_search)
218
+
219
+ try:
220
+ # Lấy thông tin
221
+ prod = df[df['article_id'].astype(str) == aid].iloc[0]
222
+
223
+ # Layout 2 cột: Ảnh - Thông tin
224
+ c_img, c_info = st.columns([1.5, 3])
225
+
226
+ with c_img:
227
+ st.image(get_img_path(aid), use_container_width=True)
228
 
229
+ with c_info:
230
+ st.title(prod['prod_name'])
231
+ st.markdown(f"### ${prod.get('price', 0):.2f}")
232
+ st.write(f"**Màu sắc:** {prod.get('colour_group_name', 'N/A')}")
233
+ st.write(f"**Danh mục:** {prod.get('product_type_name', 'N/A')}")
234
+ st.info(prod.get('detail_desc', 'Chưa có mô tả chi tiết.'))
235
+ st.button("🛒 Thêm vào giỏ hàng", key="add_to_cart")
236
+ st.caption(f"ID: {aid}")
237
+
238
+ st.divider()
239
+ st.subheader("🔍 Sản phẩm tương tự (Có thể bạn sẽ thích)")
240
+
241
+ # Phần Recommendation
242
+ related = engine.get_related_products(aid, top_k=5)
243
+ if related is not None:
244
+ cols = st.columns(5)
245
+ for idx, (i, row) in enumerate(related.iterrows()):
246
+ r_aid = str(row['article_id']).zfill(10)
247
+ with cols[idx]:
248
+ st.image(get_img_path(r_aid), use_container_width=True)
249
+ st.caption(f"{row['prod_name'][:20]}...")
250
+ # Nút xem tiếp
251
+ st.button("Xem", key=f"rec_{r_aid}", on_click=view_product, args=(r_aid,))
252
+
253
+ except Exception as e:
254
+ st.error("Không tìm thấy thông tin sản phẩm.")
255
+ if st.button("Reset"): back_to_search()
256
 
257
+ # --- MÀN HÌNH TÌM KIẾM (TRANG CHỦ) ---
258
+ else:
259
+ st.title("H&M AI Fashion Search")
260
+ st.caption("Tìm kiếm thông minh với Hybrid Search & Recommendation")
261
+
262
+ # Sidebar Config
263
+ with st.sidebar:
264
+ st.header(" Bộ lọc")
265
+ alpha = st.slider("Alpha (Semantic vs Keyword)", 0.0, 1.0, 0.5)
266
+ top_k = st.slider("Số kết quả hiển thị", 5, 20, 10)
267
+ st.markdown("---")
268
+ st.info(" Thử tìm: 'Black running shoes', 'Floral summer dress'...")
269
 
270
+ # Search Box
271
+ c_input, c_btn = st.columns([4, 1])
272
+ with c_input:
273
+ query = st.text_input("Bạn đang tìm gì?", placeholder="Mô tả sản phẩm...", key="search_box")
274
+ with c_btn:
275
+ st.write("")
276
+ st.write("")
277
+ do_search = st.button("🔍 Tìm kiếm")
278
 
279
+ if do_search or query:
280
+ with st.spinner("AI đang tìm kiếm..."):
281
+ results, expanded_q = engine.search(query, top_k=top_k, alpha=alpha)
282
+
283
+ # # Debug Info
284
+ # with st.expander("🕵️‍♂️ Xem cơ chế AI (Debug)"):
285
+ # st.write(f"**Query gốc:** {query}")
286
+ # if query.lower() != expanded_q:
287
+ # st.success(f"**Expanded:** {expanded_q}")
288
+ # else:
289
+ # st.info("Query giữ nguyên.")
290
 
291
+ st.markdown(f"### Tìm thấy {len(results)} kết quả phù hợp")
292
+
293
+ # Vòng lặp hiển thị kết quả
294
+ for idx, row in results.iterrows():
295
+ with st.container():
296
+ c1, c2, c3 = st.columns([1.5, 4.5, 1.5])
297
 
298
+ # Lấy ID an toàn
299
+ raw_id = row.get('article_id', idx)
300
+ aid_str = str(raw_id).zfill(10)
301
+
302
+ with c1:
303
+ st.image(get_img_path(aid_str), width=150)
304
+
305
+ with c2:
306
+ st.subheader(row.get('prod_name', 'Unknown'))
307
+ st.write(f"**Giá:** ${row.get('price', 0):.2f}")
308
+ desc = str(row.get('detail_desc', ''))
309
+ st.write(desc[:200] + "..." if len(desc) > 200 else desc)
310
+ st.caption(f"ID: {aid_str}")
311
+
312
+ with c3:
313
+ score = row.get('score', 0)
314
+ st.metric("Match Score", f"{score:.2f}")
315
+ # Nút Xem Chi Tiết -> Gọi hàm chuyển view
316
+ st.button("Xem chi tiết", key=f"main_{aid_str}", on_click=view_product, args=(aid_str,))
317
+
318
+ st.divider()