Spaces:
Sleeping
Sleeping
Update app.py
Browse files
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.
|
| 15 |
# ============================================
|
| 16 |
-
st.set_page_config(page_title="H&M
|
| 17 |
|
| 18 |
st.markdown("""
|
| 19 |
<style>
|
| 20 |
-
.main {background-color: #
|
| 21 |
-
.stButton>button {width: 100%;
|
| 22 |
-
.
|
| 23 |
-
div[data-testid="stMetricValue"] {font-size: 1.
|
| 24 |
</style>
|
| 25 |
""", unsafe_allow_html=True)
|
| 26 |
|
| 27 |
# ============================================
|
| 28 |
-
# 2.
|
| 29 |
# ============================================
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
try:
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return True
|
| 56 |
except Exception as e:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
return
|
| 60 |
|
| 61 |
-
#
|
| 62 |
-
|
| 63 |
|
| 64 |
# ============================================
|
| 65 |
-
# 3. LOAD MODEL & DATA
|
| 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 (
|
| 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
|
| 84 |
|
| 85 |
# Load Embeddings
|
| 86 |
embeddings = np.load(f'{MODEL_PATH}/sbert_embeddings.npy')
|
| 87 |
|
| 88 |
-
# Load SBERT
|
| 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 |
-
#
|
| 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
|
| 103 |
st.stop()
|
| 104 |
|
| 105 |
# ============================================
|
| 106 |
-
# 4. SEARCH ENGINE
|
| 107 |
# ============================================
|
| 108 |
-
class
|
| 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 |
-
'
|
| 119 |
-
'
|
| 120 |
-
'
|
| 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
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
for
|
| 135 |
-
if
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
return query_lower + " " + " ".join(list(set(expansion_terms)))
|
| 139 |
-
return query_lower
|
| 140 |
|
| 141 |
-
def search(self, query, top_k=
|
| 142 |
# 1. Expand
|
| 143 |
-
expanded_q = self.
|
| 144 |
|
| 145 |
-
# 2. Semantic Search
|
| 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
|
| 155 |
if self.bm25:
|
| 156 |
-
|
| 157 |
-
bm25_raw = self.bm25.get_scores(
|
| 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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
# ============================================
|
| 179 |
-
# 5.
|
| 180 |
# ============================================
|
| 181 |
-
|
| 182 |
-
st.
|
| 183 |
|
| 184 |
-
|
| 185 |
-
st.
|
| 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 |
-
|
| 192 |
-
|
| 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 |
-
#
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
-
#
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
| 260 |
|
| 261 |
-
#
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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ử lý 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 LÝ 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()
|