Commit 4e045633 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

Update : display products using api of webapp

parent 540e3576
......@@ -91,23 +91,11 @@ def _neutralize_generic_print(name: str) -> str:
def format_product_results(products: list[dict]) -> list[dict]:
"""
Format products - GROUP by base SKU (magento_ref_code), with multiple colors.
Output format:
{
"sku": "1DS25S008",
"name": "Váy liền bé gái",
"colors": [
{"color": "Cam/ Orange", "color_code": "SO123", "url": "...", "thumbnail": "..."},
{"color": "Xanh/ Blue", "color_code": "SB456", "url": "...", "thumbnail": "..."}
],
"price": 349000,
"sale_price": 244000,
"description": "..."
}
Format products - flat list, sku luôn là magento_ref_code, sku_color là mã biến thể màu.
"""
max_products = 15
grouped: dict[str, dict] = {} # {magento_ref_code: {product_info}}
formatted: list[dict] = []
seen_skus: set[str] = set()
for p in products:
# Extract product info
......@@ -135,60 +123,35 @@ def format_product_results(products: list[dict]) -> list[dict]:
magento_ref = p.get("magento_ref_code", "")
product_color_code = p.get("product_color_code", "")
# Extract color code from product_color_code (VD: 1DS25S008-SO123 → SO123)
color_code_only = ""
if product_color_code and "-" in product_color_code:
parts = product_color_code.split("-", 1)
color_code_only = parts[1] if len(parts) > 1 else ""
internal_ref = p.get("internal_ref_code", "")
# Dùng internal_ref (Mã tổng) làm rễ để gom toàn bộ màu của mã đó vào 1 card duy nhất
base_sku = internal_ref or magento_ref or product_color_code
if not base_sku:
sku = magento_ref
sku_color = product_color_code or magento_ref
dedup_key = sku_color or sku
if not sku or dedup_key in seen_skus:
continue
seen_skus.add(dedup_key)
# Color variant info
color_variant = {
product_entry = {
"sku": sku,
"sku_color": sku_color,
"name": _neutralize_generic_print(name),
"color": color_name,
"color_code": color_code_only,
"price": int(original_price),
"sale_price": int(sale_price) if sale_price else int(original_price),
"url": web_url,
"thumbnail": thumb_url,
"thumbnail_image_url": thumb_url,
"description": _neutralize_generic_print(p.get("description_text") or ""),
}
size_scale = p.get("size_scale")
if size_scale:
product_entry["sizes"] = [s.strip() for s in size_scale.split("|") if s.strip()]
qty_sold = p.get("quantity_sold")
if qty_sold is not None:
product_entry["quantity_sold"] = int(qty_sold)
if base_sku in grouped:
# Add color to existing product
existing_colors = [c["color"] for c in grouped[base_sku]["colors"]]
if color_name and color_name not in existing_colors:
grouped[base_sku]["colors"].append(color_variant)
# Update price range if different
if sale_price and sale_price < grouped[base_sku].get("sale_price", float("inf")):
grouped[base_sku]["sale_price"] = int(sale_price)
else:
# New product - use first color's URL/thumbnail as default
product_entry = {
"sku": base_sku,
"sku_color": product_color_code,
"name": _neutralize_generic_print(name),
"color": color_name, # First color as default
"colors": [color_variant] if color_name else [],
"price": int(original_price),
"sale_price": int(sale_price) if sale_price else int(original_price),
"url": web_url, # First color's URL
"thumbnail_image_url": thumb_url, # First color's thumbnail
"description": _neutralize_generic_print(p.get("description_text") or ""),
}
# Include sizes if available (pipe-separated → list)
size_scale = p.get("size_scale")
if size_scale:
product_entry["sizes"] = [s.strip() for s in size_scale.split("|") if s.strip()]
# Include quantity_sold if available (for best seller)
qty_sold = p.get("quantity_sold")
if qty_sold is not None:
product_entry["quantity_sold"] = int(qty_sold)
grouped[base_sku] = product_entry
formatted = list(grouped.values())[:max_products]
logger.info(f"📦 Formatted {len(formatted)} products (grouped by SKU)")
formatted.append(product_entry)
formatted = formatted[:max_products]
logger.info(f"📦 Formatted {len(formatted)} products (flat SKU list)")
return formatted
......@@ -294,6 +257,14 @@ def extract_product_ids(messages: list) -> list[dict]:
return products
def _extract_skus_from_text(text: str) -> list[str]:
"""Extract SKU-like codes from free text, including codes not wrapped in brackets."""
if not text:
return []
matches = re.findall(r"\b[A-Z0-9]+(?:-[A-Z0-9]+)+\b", text.upper())
return list(dict.fromkeys(matches))
def parse_ai_response_fast(ai_raw_content: str) -> tuple[str, list[str], str | None]:
"""
FAST parse - Chỉ extract ai_response + product_ids từ JSON, KHÔNG query DB.
......@@ -317,8 +288,9 @@ def parse_ai_response_fast(ai_raw_content: str) -> tuple[str, list[str], str | N
explicit_skus = ai_json.get("product_ids", [])
raw_insight = ai_json.get("user_insight")
# Extract SKUs mentioned in text
mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9]+)\]", ai_text_response))
# Extract SKUs mentioned in text, including raw SKU text not wrapped in [].
mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9-]+)\]", ai_text_response))
mentioned_skus_in_text.update(_extract_skus_from_text(ai_text_response))
# Determine target SKUs
if explicit_skus and isinstance(explicit_skus, list):
......@@ -395,11 +367,13 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu
user_insight = raw_insight
# === CRITICAL: Filter/Fetch products ===
# Extract SKUs mentioned in ai_response text using regex pattern [SKU]
mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9]+)\]", ai_text_response))
# Extract SKUs mentioned in ai_response text, including raw SKU text not wrapped in [].
mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9-]+)\]", ai_text_response))
mentioned_skus_in_text.update(_extract_skus_from_text(ai_text_response))
logger.info(f"📝 SKUs mentioned in ai_response: {mentioned_skus_in_text}")
target_skus = set()
product_lookup = {str(p.get("sku") or "").strip(): p for p in all_products if p.get("sku")}
# 1. Use explicit SKUs if available and confirmed by text, OR just explicit
if explicit_skus and isinstance(explicit_skus, list):
......@@ -413,13 +387,13 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu
elif mentioned_skus_in_text:
# 2. If no explicit SKUs, use text mentions
target_skus = mentioned_skus_in_text
elif len(product_lookup) == 1:
# 3. If model forgot product_ids and context has exactly one SKU, fill it automatically.
target_skus = set(product_lookup.keys())
logger.info(f"🎯 Target SKUs to return: {target_skus}")
if target_skus:
# Build lookup from current context
product_lookup = {p["sku"]: p for p in all_products if p.get("sku")}
found_products = []
for sku in target_skus:
......
......@@ -39,7 +39,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
```json
{{
"ai_response": "Câu trả lời ngắn gọn, KHÔNG chứa mã SKU — frontend tự render product card từ product_ids",
"product_ids": ["8TS24W001", "8TS26S008"],
"product_ids": ["8TS24W001", "8TS26S008-SA718"],
"user_insight": {{
"USER": "...",
"TARGET": "...",
......@@ -53,7 +53,9 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
```
**LƯU Ý:**
- `product_ids`: **BẮT BUỘC LẤY ĐÚNG NGUYÊN GIÁ TRỊ trường `sku` từ data tool trả về** (thường là mã `internal_ref` 9 ký tự VD: `8TS26S008`). KHÔNG TỰ Ý THÊM MÃ MÀU VÀO ĐUÔI (ví dụ data là `8TS26S008`, tuyệt đối không chế thành `8TS26S008-SA718`). AI KHÔNG ĐƯỢC BỊA MÃ.
- `product_ids`: **BẮT BUỘC LẤY ĐÚNG NGUYÊN GIÁ TRỊ trường `sku` từ data tool trả về** (ví dụ: data trả về `8TS26S008-SA718` thì array phải chứa đúng chuỗi đó). KHÔNG tự ý cắt phần mã màu (cấm cắt thành `8TS26S008`). Giữ nguyên định dạng của `sku` do hệ thống cung cấp!
- Nếu tool trả nhiều SKU variant đầy đủ như `8TS26S008-SA718`, `8TS26S008-SB179` thì `product_ids` phải dùng đúng các mã đầy đủ đó. `8TS26S008` không được coi là SKU hợp lệ trừ khi tool thực sự trả đúng chuỗi đó ở trường `sku`.
- Khi user hỏi đúng một mã variant cụ thể như `8TS26S008-SB179`, nếu tool xác nhận có sản phẩm này thì `product_ids` phải là `["8TS26S008-SB179"]`. Tuyệt đối cấm rút gọn thành `["8TS26S008"]`.
- `user_insight` theo đúng format 6 tầng như mục 8
- **LUÔN DÙNG NGOẶC KÉP `{{` và `}}` CHO JSON**
......@@ -128,6 +130,28 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
---
### Example 2b: Hỏi một mã variant cụ thể
**Input:** "mã này có màu gì 8TS26S008-SB179"
**Tool trả về:** đúng 1 sản phẩm có `sku = "8TS26S008-SB179"`
{{
"ai_response": "Mẫu này hiện có màu Xanh da trời, bạn xem bên dưới nhé!",
"product_ids": ["8TS26S008-SB179"],
"user_insight": {{
"USER": "Chưa rõ.",
"TARGET": "Chính mình.",
"GOAL": "Kiểm tra thông tin của SKU variant cụ thể.",
"CONSTRAINS": "SKU: 8TS26S008-SB179 (HARD).",
"LATEST_PRODUCT_INTEREST": "8TS26S008-SB179.",
"LAST_ACTION": "Xác nhận màu của SKU variant cụ thể và show product card.",
"SUMMARY_HISTORY": "Turn 1: User hỏi SKU variant 8TS26S008-SB179 → Bot xác nhận đúng variant và show card."
}}
}}
---
### Example 3: Sản phẩm KHÔNG CÓ / SAI LOẠI
**Input:** "Shop có bikini không?" — Tool trả về: 0 sản phẩm hoặc SP sai loại
......
......@@ -335,7 +335,13 @@ CASE 7: "Set đồ công sở cho nữ" → 2 Queries:
CASE 8: "Tìm mã 6KS25S005"
→ magento_ref_code: "6KS25S005", description: "product_name: Sản phẩm. description_text: Tìm sản phẩm theo mã"
⚠️ RIÊNG TÌM THEO MÃ SKU: TUYỆT ĐỐI KHÔNG ĐƯỢC tự suy diễn màu sắc (master_color), loại sản phẩm (product_line_vn) từ các chữ cái trong mã SKU. ĐỂ NULL HẾT! (Ví dụ không được đoán TS là áo trắng).
⚠️ TÌM THEO MÃ SKU:
- KHÔNG ĐƯỢC tự suy diễn loại sản phẩm (product_line_vn) từ các chữ cái trong mã. ĐỂ NULL!
- KHÔNG ĐƯỢC đoán màu từ ký tự trong mã (VD: TS ≠ trắng, SB ≠ xanh). ĐỂ NULL!
- NHƯNG NẾU KHÁCH NÓI RÕ MÀU → BẮT BUỘC PHẢI SINH master_color!
VD: "Tìm mã 6KS25S005 màu đen" → magento_ref_code: "6KS25S005", master_color: "đen"
VD: "Mã 1DS26S001 có màu hồng không?" → magento_ref_code: "1DS26S001", master_color: "hồng"
VD: "Tìm mã 6KS25S005" (không nói màu) → master_color: null
CASE 9: "Áo cá sấu polo đi chơi"
→ description: "product_name: Áo cá sấu polo. description_text: Áo cá sấu polo đi chơi năng động trẻ trung. style: Dynamic"
......
......@@ -110,7 +110,7 @@ def _attach_variant_skus(formatted_products: list[dict], raw_products: list[dict
by_base_sku: dict[str, list[dict]] = {}
for row in raw_products:
base_sku = str(row.get("magento_ref_code") or row.get("internal_ref_code") or "").strip()
base_sku = str(row.get("magento_ref_code"))
variant_sku = str(row.get("product_color_code") or "").strip()
if not base_sku or not variant_sku:
continue
......@@ -150,7 +150,7 @@ def _attach_variant_skus(formatted_products: list[dict], raw_products: list[dict
def _resolve_stock_skus(searches: list[SearchItem], products: list[dict]) -> list[str]:
"""Resolve sku_color list from base SKU + optional color, ready for stock tool."""
"""Resolve sku_color list from returned flat products, ready for stock tool."""
resolved: list[str] = []
seen: set[str] = set()
......@@ -160,34 +160,25 @@ def _resolve_stock_skus(searches: list[SearchItem], products: list[dict]) -> lis
continue
target_color = _normalize_text(item.master_color)
candidates = [p for p in products if str(p.get("sku") or "").strip().upper() == base_code]
candidates = []
for product in products:
sku = str(product.get("sku") or "").strip().upper()
sku_color = str(product.get("sku_color") or "").strip().upper()
if not sku and not sku_color:
continue
if base_code and (sku == base_code or sku_color == base_code or sku.startswith(f"{base_code}-")):
candidates.append(product)
for product in candidates:
variants = product.get("variants") or []
sku_color = str(product.get("sku_color") or "").strip()
product_color = _normalize_text(product.get("color"))
# If user specified color, prefer matching sku_color by that color first.
if target_color:
for variant in variants:
sku_color = str(variant.get("sku_color") or "").strip()
variant_color = _normalize_text(variant.get("color"))
if not sku_color:
continue
if (target_color in variant_color or variant_color in target_color) and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
# Fallback: still return at least one variant SKU for the base code.
if not target_color or all(
s not in seen for s in [v.get("sku_color") for v in variants if v.get("sku_color")]
):
for variant in variants:
sku_color = str(variant.get("sku_color") or "").strip()
if sku_color and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
# Final fallback when no variants attached.
sku_color = str(product.get("sku_color") or "").strip()
if sku_color and (target_color in product_color or product_color in target_color) and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
continue
if sku_color and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
......@@ -293,7 +284,6 @@ async def _execute_single_search(
logger.warning("⚠️ Langfuse db-query-search span failed: %s", trace_err, exc_info=True)
formatted_products = format_product_results(products)
_attach_variant_skus(formatted_products, products)
return formatted_products, {"fallback_used": False}
except Exception as e:
logger.exception("Single search error for item %r: %s", item, e)
......@@ -356,11 +346,20 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
for item in searches
]
allowed_skus = sorted({str(p.get("sku") or "").strip() for p in combined_results if p.get("sku")})
stock_skus = _resolve_stock_skus(searches, combined_results)
output = {
"status": "success",
"message_for_ai": (
f"Quy tắc bắt buộc: `product_ids` CHỈ được lấy từ trường `sku`. "
f"Các giá trị `sku` hợp lệ trong lần search này là: {allowed_skus}. "
f"Tuyệt đối cấm dùng `sku_color` cho `product_ids`. "
f"`sku_color` chỉ là mã biến thể màu để tham chiếu nội bộ."
),
"search_input": search_inputs,
"results": combined_results,
"stock_skus": _resolve_stock_skus(searches, combined_results),
"stock_skus": stock_skus,
"filter_info": final_info,
}
......
import logging
import os
import time
from common.embedding_service import create_embedding_async
logger = logging.getLogger(__name__)
def _parse_code_search_input(raw_code: str) -> tuple[str, str | None]:
"""Chuẩn hóa mã user gửi và tách phần internal_ref_code / suffix nếu có."""
normalized = str(raw_code or "").strip().upper().replace(" ", "")
if "-" not in normalized:
return normalized, None
internal_ref_code, suffix_code = normalized.split("-", 1)
return internal_ref_code, suffix_code or None
def _get_price_clauses(params, sql_params: list) -> list[str]:
"""Lọc theo giá (Parameterized)."""
clauses = []
......@@ -76,12 +82,12 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]:
sql_params.extend([f"%{color_lower}%", f"%{color_lower}%"])
logger.info(f"🎨 [SQL FILTER] Color: {color_val}")
from agent.tools.product_mapping import PRODUCT_LINE_MAP
GENERIC_WORDS = {key.split()[0].lower() for key in PRODUCT_LINE_MAP.keys()}
name_val = getattr(params, "product_name", None)
if name_val:
from agent.tools.product_mapping import resolve_product_name, get_related_lines
from agent.tools.product_mapping import get_related_lines, resolve_product_name
# Support '/' separator: "Áo lót/Áo bra active" → ["Áo lót", "Áo bra active"]
name_parts = [p.strip() for p in name_val.split("/") if p.strip()]
......@@ -115,60 +121,95 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
Returns: (sql_string, params_list)
"""
# ============================================================
# =========================================================================================================================
# CASE 1: CODE SEARCH
# ============================================================
# ==========================================================================================================================
magento_code = getattr(params, "magento_ref_code", None)
if magento_code:
sql_params = [magento_code, magento_code, magento_code]
extra_where = ""
# GHI CHÚ:
# Subquery dò gốc internal_ref_code từ bất kỳ mã nào (tổng/màu/size).
# Nếu khách có kèm màu → filter thêm ở vòng ngoài (master_color LIKE).
# Nếu không kèm màu → trả hết tất cả biến thể màu cho AI tự chọn.
# Chuẩn hóa code user gửi và quy input về internal_ref_code trước khi lấy variants.
normalized_magento_code = str(magento_code).strip().upper().replace(" ", "")
internal_ref_hint, suffix_code = _parse_code_search_input(normalized_magento_code)
extra_filters = []
sql_params = [
internal_ref_hint,
normalized_magento_code,
normalized_magento_code,
f"{internal_ref_hint}-%",
f"{internal_ref_hint[:-1]}%" if len(internal_ref_hint) > 1 else internal_ref_hint,
f"{internal_ref_hint[:-1]}%-%" if len(internal_ref_hint) > 1 else f"{internal_ref_hint}-%",
f"{normalized_magento_code[:-1]}%" if len(normalized_magento_code) > 1 else normalized_magento_code,
f"{normalized_magento_code[:-1]}%" if len(normalized_magento_code) > 1 else normalized_magento_code,
]
# Ưu tiên màu user nói trong message; đây là filter mạnh hơn suffix trong mã.
color_val = getattr(params, "master_color", None)
if color_val:
extra_where = " AND LOWER(master_color) LIKE %s"
sql_params.append(f"%{color_val.lower()}%")
logger.info(f"🎨 [CODE SEARCH + COLOR FILTER] Code: {magento_code}, Color: {color_val}")
extra_filters.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
color_like = f"%{color_val.lower()}%"
sql_params.extend([color_like, color_like])
logger.info(
"🎨 [CODE SEARCH] Code=%s, internal_ref=%s, explicit_color=%s",
normalized_magento_code,
internal_ref_hint,
color_val,
)
# Nếu user không nói màu nhưng có suffix, dùng suffix để ưu tiên đúng variant đã copy.
elif suffix_code:
extra_filters.append("UPPER(product_color_code) LIKE %s")
sql_params.append(f"%{suffix_code}")
logger.info(
"🏷️ [CODE SEARCH] Code=%s, internal_ref=%s, suffix_fallback=%s",
normalized_magento_code,
internal_ref_hint,
suffix_code,
)
else:
logger.info("🏷️ [CODE SEARCH] Code=%s, internal_ref=%s", normalized_magento_code, internal_ref_hint)
extra_where = ""
if extra_filters:
extra_where = " AND " + " AND ".join(extra_filters)
sql = f"""
WITH resolved_family AS (
SELECT DISTINCT internal_ref_code
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE UPPER(internal_ref_code) = %s
OR UPPER(magento_ref_code) = %s
OR UPPER(product_color_code) = %s
OR UPPER(product_color_code) LIKE %s
OR UPPER(internal_ref_code) LIKE %s
OR UPPER(product_color_code) LIKE %s
OR UPPER(magento_ref_code) LIKE %s
OR UPPER(product_color_code) LIKE %s
)
SELECT
internal_ref_code,
MAX(magento_ref_code) as magento_ref_code,
magento_ref_code,
product_color_code,
MAX(product_name) as product_name,
MAX(master_color) as master_color,
MAX(product_image_url_thumbnail) as product_image_url_thumbnail,
MAX(product_web_url) as product_web_url,
MAX(description_text) as description_text,
MAX(sale_price) as sale_price,
MAX(original_price) as original_price,
MAX(discount_amount) as discount_amount,
MAX(ROUND(((original_price - sale_price) / original_price * 100), 0)) as discount_percent,
MAX(age_by_product) as age_by_product,
MAX(gender_by_product) as gender_by_product,
MAX(product_line_vn) as product_line_vn,
MAX(quantity_sold) as quantity_sold,
MAX(size_scale) as size_scale,
product_name,
master_color,
product_image_url_thumbnail,
product_web_url,
description_text,
sale_price,
original_price,
discount_amount,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
quantity_sold,
size_scale,
1.0 as similarity_score
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE internal_ref_code IN (
SELECT internal_ref_code
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE internal_ref_code = %s OR magento_ref_code = %s OR product_color_code = %s
){extra_where}
GROUP BY internal_ref_code, product_color_code
WHERE internal_ref_code IN (SELECT internal_ref_code FROM resolved_family){extra_where}
"""
return sql, sql_params
# ============================================================
# ==================================================================================================================
# CASE 2: DISCOVERY — Hàng mới / Bán chạy (Direct SQL, no embedding)
# Khác với price/gender filter: discovery cần scan TOÀN BỘ bảng
# vì top 100 vector results gần như không chứa new/best_seller
# ============================================================
# ===============================================================================================================
discovery_mode = getattr(params, "discovery_mode", None)
if discovery_mode:
discovery_mode = discovery_mode.lower().strip()
......@@ -220,7 +261,6 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
logger.info("⚡ [DISCOVERY] Direct SQL — no embedding")
return sql, sql_params
# ============================================================
# CASE 3: SEMANTIC VECTOR SEARCH
# ============================================================
......@@ -364,5 +404,4 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# except Exception as e:
# logger.error(f"Error writing to query.txt: {e}")
return sql, sql_params
......@@ -20,3 +20,4 @@ Get-NetTCPConnection -LocalPort 5000 | ForEach-Object { Stop-Process -Id $_.Owni
taskkill /F /IM python.exe
netstat -ano | findstr :5000 | ForEach-Object { $_.Split()[-1] } | Sort-Object -Unique | ForEach-Object { taskkill /F /PID $_ }
\ No newline at end of file
......@@ -306,6 +306,119 @@
flex-direction: column;
}
.product-meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.product-chip {
font-size: 0.74em;
color: #cfd8dc;
background: #2b2f36;
border: 1px solid #4b5563;
border-radius: 999px;
padding: 4px 8px;
}
.swatch-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 10px 0 12px;
}
.swatch-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76em;
color: #cfd8dc;
background: #2a2d33;
border: 1px solid #4a4f58;
border-radius: 999px;
padding: 4px 8px;
}
.swatch-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
flex-shrink: 0;
}
.size-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.size-pill {
font-size: 0.72em;
font-weight: 600;
color: #e8edf2;
background: #3b4250;
border: 1px solid #586273;
border-radius: 6px;
padding: 4px 7px;
min-width: 28px;
text-align: center;
}
.stock-breakdown {
display: flex;
flex-direction: column;
gap: 8px;
margin: 4px 0 12px;
}
.stock-row {
background: #262b33;
border: 1px solid #404856;
border-radius: 10px;
padding: 8px 10px;
}
.stock-color-label {
font-size: 0.78em;
color: #dce3ea;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 6px;
}
.stock-size-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stock-size-pill {
font-size: 0.71em;
color: #e8edf2;
background: #394150;
border: 1px solid #5a6577;
border-radius: 999px;
padding: 4px 8px;
}
.stock-size-pill.out {
color: #c3c8d1;
background: #313640;
border-color: #4c5564;
opacity: 0.7;
}
.stock-loading {
font-size: 0.76em;
color: #9fb0c5;
margin: 4px 0 12px;
}
.product-sku {
font-size: 0.75em;
color: #667eea;
......@@ -818,6 +931,8 @@
let currentPromptTab = 'system';
let selectedToolPrompt = '';
let pendingImages = []; // 📸 Experimental: images to send with next message
const CANIFA_SEARCH_API = 'https://canifa.com/v1/middleware/search_product';
const SIZE_ORDER = ['XS', 'S', 'M', 'L', 'XL', 'XXL', '3XL', '4XL'];
// Color name → CSS hex (for color dot badge)
function getColorHex(colorName) {
......@@ -846,6 +961,351 @@
return '#888';
}
function groupProductsForDisplay(products) {
const grouped = new Map();
(products || []).forEach(product => {
const parentSku = product.sku || product.sku_color;
if (!parentSku) return;
if (!grouped.has(parentSku)) {
grouped.set(parentSku, {
sku: parentSku,
name: product.name || '',
price: product.price || 0,
sale_price: product.sale_price || product.price || 0,
url: product.url || '',
thumbnail_image_url: product.thumbnail_image_url || '',
description: product.description || '',
quantity_sold: product.quantity_sold,
colors: [],
sizes: new Set()
});
}
const entry = grouped.get(parentSku);
if (!entry.thumbnail_image_url && product.thumbnail_image_url) {
entry.thumbnail_image_url = product.thumbnail_image_url;
}
if (!entry.url && product.url) {
entry.url = product.url;
}
if (!entry.name && product.name) {
entry.name = product.name;
}
if ((!entry.sale_price || entry.sale_price > (product.sale_price || product.price || 0)) && (product.sale_price || product.price)) {
entry.sale_price = product.sale_price || product.price || 0;
}
const colorKey = product.sku_color || `${product.color || ''}-${product.url || ''}`;
if (colorKey && !entry.colors.some(c => c.key === colorKey)) {
entry.colors.push({
key: colorKey,
name: product.color || 'Mặc định',
sku_color: product.sku_color || '',
url: product.url || '',
thumbnail_image_url: product.thumbnail_image_url || ''
});
}
(product.sizes || []).forEach(size => {
if (size) entry.sizes.add(size);
});
});
return Array.from(grouped.values()).map(item => ({
...item,
sizes: Array.from(item.sizes)
}));
}
function createStockBreakdown(colors) {
const stockBreakdown = document.createElement('div');
stockBreakdown.className = 'stock-breakdown';
colors.forEach(color => {
if (!Array.isArray(color.stock_by_size) || color.stock_by_size.length === 0) return;
const stockRow = document.createElement('div');
stockRow.className = 'stock-row';
const colorLabel = document.createElement('div');
colorLabel.className = 'stock-color-label';
colorLabel.innerHTML = `<span class="swatch-dot" style="background:${getColorHex(color.name)};"></span><strong>${color.name}</strong>`;
stockRow.appendChild(colorLabel);
const stockSizeList = document.createElement('div');
stockSizeList.className = 'stock-size-list';
color.stock_by_size.forEach(sizeInfo => {
const stockPill = document.createElement('div');
stockPill.className = 'stock-size-pill' + (sizeInfo.is_in_stock ? '' : ' out');
stockPill.innerText = `${sizeInfo.size}: ${sizeInfo.qty}`;
stockSizeList.appendChild(stockPill);
});
stockRow.appendChild(stockSizeList);
stockBreakdown.appendChild(stockRow);
});
return stockBreakdown.childElementCount > 0 ? stockBreakdown : null;
}
function buildCanifaSearchPayload(sku) {
return {
indexName: 'vue_storefront_catalog_2',
query: {
query: {
bool: {
must: [
{
term: {
sku: sku
}
}
],
filter: {
bool: {
must: [
{ terms: { visibility: [2, 3, 4] } },
{ terms: { status: [0, 1] } }
]
}
}
}
},
queryParams: {
from: 0,
size: 1,
sort: ''
}
}
};
}
function sortSizes(sizes) {
return [...sizes].sort((a, b) => {
const ai = SIZE_ORDER.indexOf(String(a).toUpperCase());
const bi = SIZE_ORDER.indexOf(String(b).toUpperCase());
if (ai === -1 && bi === -1) return String(a).localeCompare(String(b));
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
}
function getSizeRank(size) {
const rank = SIZE_ORDER.indexOf(String(size).toUpperCase());
return rank === -1 ? 999 : rank;
}
function extractApiStockByColor(source, parentSku) {
const variants = new Map();
const children = Array.isArray(source?.configurable_children) ? source.configurable_children : [];
children.forEach(child => {
const childSku = String(child?.sku || '').trim();
if (!childSku.startsWith(`${parentSku}-`)) return;
const suffix = childSku.slice(parentSku.length + 1);
const parts = suffix.split('-');
if (parts.length < 2) return;
const colorCode = parts[0];
const size = parts[parts.length - 1].toUpperCase();
const qty = Number(child?.logistic_qty ?? child?.stock?.qty ?? 0);
const isInStock = qty > 0 || Boolean(child?.stock?.is_in_stock);
const variantKey = `${parentSku}-${colorCode}`;
if (!variants.has(variantKey)) {
variants.set(variantKey, []);
}
variants.get(variantKey).push({
size,
qty,
is_in_stock: isInStock
});
});
for (const items of variants.values()) {
items.sort((a, b) => {
const rankA = getSizeRank(a.size);
const rankB = getSizeRank(b.size);
if (rankA === rankB) return String(a.size).localeCompare(String(b.size));
return rankA - rankB;
});
}
return variants;
}
async function fetchCanifaProductBySku(sku) {
const response = await fetch(CANIFA_SEARCH_API, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(buildCanifaSearchPayload(sku))
});
if (!response.ok) {
throw new Error(`Catalog API failed: ${response.status}`);
}
const data = await response.json();
return data?.hits?.hits?.[0]?._source || null;
}
async function enrichCardsWithCanifaApi(groupedProducts, productsContainer) {
for (const product of groupedProducts) {
const card = productsContainer.querySelector(`[data-parent-sku="${product.sku}"]`);
if (!card) continue;
const loading = card.querySelector('.stock-loading');
const stockSlot = card.querySelector('.stock-slot');
try {
const catalogProduct = await fetchCanifaProductBySku(product.sku);
if (!catalogProduct) {
if (loading) loading.remove();
continue;
}
const stockMap = extractApiStockByColor(catalogProduct, product.sku);
product.colors.forEach(color => {
color.stock_by_size = stockMap.get(color.sku_color) || [];
});
const breakdown = createStockBreakdown(product.colors);
if (stockSlot) {
stockSlot.innerHTML = '';
if (breakdown) stockSlot.appendChild(breakdown);
}
} catch (error) {
console.warn('Catalog enrich failed for SKU', product.sku, error);
} finally {
if (loading) loading.remove();
}
}
}
function renderProductCards(products, container) {
const groupedProducts = groupProductsForDisplay(products);
const productsContainer = document.createElement('div');
productsContainer.className = 'product-cards-container';
groupedProducts.forEach(product => {
const card = document.createElement('div');
card.className = 'product-card';
card.dataset.parentSku = product.sku;
const img = document.createElement('img');
img.src = product.thumbnail_image_url || 'https://via.placeholder.com/200';
img.alt = product.name;
img.onerror = function () { this.src = 'https://via.placeholder.com/200?text=No+Image'; };
card.appendChild(img);
const body = document.createElement('div');
body.className = 'product-card-body';
const sku = document.createElement('div');
sku.className = 'product-sku';
sku.innerText = product.sku;
body.appendChild(sku);
const name = document.createElement('div');
name.className = 'product-name';
name.innerText = product.name;
body.appendChild(name);
const metaRow = document.createElement('div');
metaRow.className = 'product-meta-row';
const colorChip = document.createElement('div');
colorChip.className = 'product-chip';
colorChip.innerText = `${product.colors.length} màu`;
metaRow.appendChild(colorChip);
if (product.quantity_sold !== undefined && product.quantity_sold !== null) {
const soldChip = document.createElement('div');
soldChip.className = 'product-chip';
soldChip.innerText = `Đã bán ${Number(product.quantity_sold).toLocaleString('vi-VN')}`;
metaRow.appendChild(soldChip);
}
body.appendChild(metaRow);
if (product.colors.length > 0) {
const swatches = document.createElement('div');
swatches.className = 'swatch-list';
product.colors.forEach(color => {
const swatch = document.createElement('div');
swatch.className = 'swatch-item';
swatch.innerHTML = `<span class="swatch-dot" style="background:${getColorHex(color.name)};"></span>${color.name}`;
swatches.appendChild(swatch);
});
body.appendChild(swatches);
}
if (product.sizes.length > 0) {
const sizeList = document.createElement('div');
sizeList.className = 'size-list';
product.sizes.forEach(size => {
const sizePill = document.createElement('div');
sizePill.className = 'size-pill';
sizePill.innerText = size;
sizeList.appendChild(sizePill);
});
body.appendChild(sizeList);
}
const loading = document.createElement('div');
loading.className = 'stock-loading';
loading.innerText = 'Đang lấy tồn theo màu/size từ catalog...';
body.appendChild(loading);
const stockSlot = document.createElement('div');
stockSlot.className = 'stock-slot';
body.appendChild(stockSlot);
const priceDiv = document.createElement('div');
priceDiv.className = 'product-price';
if (product.sale_price && product.price && product.sale_price < product.price) {
const originalPrice = document.createElement('span');
originalPrice.className = 'price-original';
originalPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(originalPrice);
const salePrice = document.createElement('span');
salePrice.className = 'price-sale';
salePrice.innerText = (product.sale_price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(salePrice);
} else if (product.price) {
const regularPrice = document.createElement('span');
regularPrice.className = 'price-regular';
regularPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(regularPrice);
}
body.appendChild(priceDiv);
const link = document.createElement('a');
link.className = 'product-link';
link.href = product.url || '#';
link.target = '_blank';
link.innerText = '🛍️ Xem chi tiết';
body.appendChild(link);
card.appendChild(body);
productsContainer.appendChild(card);
});
container.appendChild(productsContainer);
enrichCardsWithCanifaApi(groupedProducts, productsContainer);
}
// ==================== IMAGE HANDLING (Experimental) ====================
function handleImageSelect(event) {
const file = event.target.files[0];
......@@ -1570,84 +2030,7 @@
// Render product cards if available
if (data.product_ids && data.product_ids.length > 0) {
const productsContainer = document.createElement('div');
productsContainer.className = 'product-cards-container';
data.product_ids.forEach(product => {
const card = document.createElement('div');
card.className = 'product-card';
// Product image
const img = document.createElement('img');
img.src = product.thumbnail_image_url || 'https://via.placeholder.com/200';
img.alt = product.name;
img.onerror = function () { this.src = 'https://via.placeholder.com/200?text=No+Image'; };
card.appendChild(img);
// Product body
const body = document.createElement('div');
body.className = 'product-card-body';
// SKU
const sku = document.createElement('div');
sku.className = 'product-sku';
sku.innerText = product.sku_color || product.sku;
body.appendChild(sku);
// Color badge
if (product.color) {
const colorBadge = document.createElement('div');
colorBadge.style.cssText = 'font-size: 0.78em; color: #b0bec5; margin-bottom: 6px; display: flex; align-items: center; gap: 5px;';
colorBadge.innerHTML = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #666;background:${getColorHex(product.color)};"></span> ${product.color}`;
body.appendChild(colorBadge);
}
// Name
const name = document.createElement('div');
name.className = 'product-name';
name.innerText = product.name;
body.appendChild(name);
// Price
const priceDiv = document.createElement('div');
priceDiv.className = 'product-price';
if (product.sale_price && product.price && product.sale_price < product.price) {
const originalPrice = document.createElement('span');
originalPrice.className = 'price-original';
originalPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(originalPrice);
const salePrice = document.createElement('span');
salePrice.className = 'price-sale';
salePrice.innerText = (product.sale_price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(salePrice);
} else if (product.price) {
const regularPrice = document.createElement('span');
regularPrice.className = 'price-regular';
regularPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(regularPrice);
} else {
const noPrice = document.createElement('span');
noPrice.className = 'price-regular';
noPrice.innerText = 'Liên hệ';
priceDiv.appendChild(noPrice);
}
body.appendChild(priceDiv);
// Link button
const link = document.createElement('a');
link.className = 'product-link';
link.href = product.url;
link.target = '_blank';
link.innerText = '🛍️ Xem chi tiết';
body.appendChild(link);
card.appendChild(body);
productsContainer.appendChild(card);
});
filteredDiv.appendChild(productsContainer);
renderProductCards(data.product_ids, filteredDiv);
}
botMsgDiv1.appendChild(filteredDiv);
......@@ -1994,4 +2377,4 @@
</div> <!-- Close main-content -->
</body>
</html>
\ No newline at end of file
</html>
import os
import sys
import asyncio
from datetime import datetime
from zoneinfo import ZoneInfo
# Add parent dir to path so we can import from backend
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common.cache import redis_cache
async def main():
device_id = "b9ae861478939edad0dc3c77db8f0499"
print(f"🔄 Bắt đầu reset cho device_id: {device_id}")
await redis_cache.initialize()
client = redis_cache.get_client()
if not client:
print("❌ Lỗi: Không thể kết nối Redis")
return
try:
# 1. Clear conversation history
history_key = f"identity_key_history:{device_id}"
deleted_history = await client.delete(history_key)
print(f"✅ Đã xóa lịch sử chat: {history_key} (Keys deleted: {deleted_history})")
# 2. Clear user insight
insight_key = f"identity_key_insight:{device_id}"
deleted_insight = await client.delete(insight_key)
print(f"✅ Đã xóa user insight: {insight_key} (Keys deleted: {deleted_insight})")
# 3. Add 100 limit chat for guest
# Format msg_limit:YYYY-MM-DD:device_id
tz = ZoneInfo("Asia/Ho_Chi_Minh")
today = datetime.now(tz).strftime("%Y-%m-%d")
limit_key = f"msg_limit:{today}:{device_id}"
# Bằng cách set 'guest' trong Hash về số âm (-90), user sẽ có tổng 100 lượt chat
# (vì guest_limit mặc định là 10, guest_used = -90 -> remaining = 10 - (-90) = 100)
await client.hset(limit_key, "guest", -90)
# Set expire until midnight so it cleans up naturally
now = datetime.now(tz)
reset_time = now.replace(hour=0, minute=0, second=0, microsecond=0)
from datetime import timedelta
if now >= reset_time:
reset_time = reset_time + timedelta(days=1)
seconds_until_reset = int((reset_time - now).total_seconds())
await client.expire(limit_key, seconds_until_reset)
print(f"✅ Đã reset và thêm 100 lượt chat vào limit_key: {limit_key}")
print("🎉 Hoàn thành!")
except Exception as e:
print(f"❌ Lỗi: {e}")
finally:
# Close redis connection
await client.close()
if __name__ == "__main__":
asyncio.run(main())
import json, urllib.request
req = urllib.request.Request(
'https://canifa.com/v1/middleware/search_product',
data=json.dumps({"sku":["8TS26S008-SA718"]}).encode('utf-8'),
headers={'Content-Type': 'application/json'}
)
try:
with urllib.request.urlopen(req) as f:
print(f.read().decode('utf-8'))
except Exception as e:
print(e)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment