Commit 7659d548 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat(lead-stage): hard pattern detection for price comparison queries

- Added hard_patterns.py: detect patterns (cheaper_than, more_expensive_than, similar_to) and extract reference product
- product_search_engine.py: integrate pattern detection, auto-set price_max/min based on reference price
- lead_search_tool.py: added user_insight parameter
- graph.py: pass user_insight to tool, removed product_ids filter (always return full tool result)
- prompts.py: added family/group intent examples, emphasize product_ids must include all items
- stylist_engine.py: added occasion and rule_id to matches, save to DB properly
- Added scripts: regenerate_matches.py and test_insert_one.py

Impact:
- Query 'rẻ hơn' auto sets price_max = ref_price * 0.95
- Query 'đắt hơn' auto sets price_min = ref_price * 1.05
- UI receives complete product list from tool (not truncated by AI)
parent ab00e9ad
This diff is collapsed.
This diff is collapsed.
......@@ -21,6 +21,7 @@ async def lead_search_tool(
inferred: InferredSearch,
magento_ref_code: str | None = None,
reasoning: str | None = None,
user_insight: dict | None = None, # THÊM: từ graph state
) -> str:
"""
Tim kiem san pham CANIFA theo Dual-Lane Architecture.
......@@ -34,7 +35,7 @@ async def lead_search_tool(
try:
# Gọi engine để xử lý logic dual-query / cascade
result_dict = await engine.search(req, reasoning=reasoning)
result_dict = await engine.search(req, reasoning=reasoning, user_insight=user_insight)
return json.dumps(result_dict, ensure_ascii=False, default=str)
except Exception as e:
logger.error("Lead search tool error: %s", e, exc_info=True)
......
......@@ -18,6 +18,7 @@ Architecture:
import json
import logging
import os as _os
import re
import sqlite3
import time
......@@ -27,9 +28,13 @@ from pydantic import BaseModel, Field
from common.starrocks_connection import get_db_connection
from .product_mapping import get_related_lines, resolve_product_line
from .hard_patterns import HardPatternDetector
logger = logging.getLogger(__name__)
# Global hard pattern detector
_hard_pattern_detector = HardPatternDetector()
try:
from langfuse import get_client as _get_langfuse
_LANGFUSE_AVAILABLE = True
......@@ -1334,9 +1339,13 @@ class ProductSearchEngine:
return [], 3, None
async def search(self, req: LeadSearchInput, reasoning: str | None = None) -> dict:
async def search(self, req: LeadSearchInput, reasoning: str | None = None,
user_insight: dict = None) -> dict:
"""
Entry point: Dual-Lane search + enrichment pipeline.
Args:
user_insight: User insight dict (chứa LATEST_PRODUCT_INTEREST, TARGET, ...)
"""
start = time.time()
db = get_db_connection()
......@@ -1344,6 +1353,40 @@ class ProductSearchEngine:
try:
fallback_msg = None
# ★★★ PRE-SEARCH: HARD PATTERN DETECTION & ADAPTATION ★★★
# Detect nếu query có pattern "rẻ hơn", "tương tự", ...
context_products = []
if user_insight:
# Lấy LATEST_PRODUCT_INTEREST và lookup thành product dict
latest = user_insight.get("LATEST_PRODUCT_INTEREST")
if latest:
# Parse SKU từ format "Áo Polo Nam Basic (8TP25A002)" hoặc "8TP25A002"
sku = self._parse_sku_from_insight(latest)
if sku:
# Lookup product từ DB để có giá
product = await self._lookup_product_by_sku(sku, db)
if product:
context_products = [product]
pattern = _hard_pattern_detector.detect(req.literal.raw_text, context_products)
if pattern.pattern_type == "cheaper_than" and pattern.target_price_max:
# Override price_max từ pattern
orig_price_max = req.inferred.price_max
req.inferred.price_max = int(pattern.target_price_max)
logger.info(
f"[HARD PATTERN] 'Rẻ hơn' detected: ref_price={pattern.reference_price:,.0f}đ "
f"→ set price_max={req.inferred.price_max:,.0f}đ (was {orig_price_max})"
)
elif pattern.pattern_type == "more_expensive_than" and pattern.target_price_min:
orig_price_min = req.inferred.price_min
req.inferred.price_min = int(pattern.target_price_min)
logger.info(
f"[HARD PATTERN] 'Đắt hơn' detected: ref_price={pattern.reference_price:,.0f}đ "
f"→ set price_min={req.inferred.price_min:,.0f}đ (was {orig_price_min})"
)
# Các pattern khác có thể xử lý sau...
# ★ UNDERWEAR HARD OVERRIDE — drop gender/age trước khi search ★
underwear_msg = _apply_underwear_override(req)
......@@ -1435,3 +1478,26 @@ class ProductSearchEngine:
logger.error("Lead search tool error: %s", e, exc_info=True)
return {"status": "error", "message": str(e)}
# ═══════════════════════════════════════════════════════════════
# HELPER: Hard Pattern Support
# ═══════════════════════════════════════════════════════════════
def _parse_sku_from_insight(self, insight_value: str) -> str | None:
"""Parse SKU từ LATEST_PRODUCT_INTEREST."""
if not insight_value:
return None
# Format: "Áo Polo Nam Basic (8TP25A002)" hoặc "8TP25A002"
match = re.search(r"\(?([A-Z0-9]{4,}-[A-Z0-9]{3,})\)?", str(insight_value))
return match.group(1) if match else None
async def _lookup_product_by_sku(self, sku: str, db) -> dict | None:
"""Lookup product từ DB bằng SKU."""
sql = f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE magento_ref_code = %s OR internal_ref_code = %s
LIMIT 1
"""
result = await db.execute_query_async(sql, params=(sku, sku))
return result[0] if result else None
......@@ -277,9 +277,10 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t
- **SINGLE-ITEM INTENT:** Khách HỎI ĐÍCH DANH MỘT TÊN SẢN PHẨM CỤ THỂ (VD: "mua áo gì", "nên chọn váy nào", "tìm quần lót dài") → CHỈ GỢI Ý NHỮNG SP CHÍNH, KHÔNG CẦN TỰ ĐỘNG PHỐI ĐỒ.
+ `product_ids`: Chỉ chứa SKU của các SP chính được gợi ý (1-3 sản phẩm).
+ Response: Tập trung vào tính năng, chất liệu, phù hợp dịp. KHÔNG bắt buộc phải bốc `outfit_recommendations`.
- **COMBO/OUTFIT INTENT:** Khách dùng TỪ CHUNG CHUNG ("tìm đồ đi làm", "quần áo mùa hè", "đồ đi biển", "set đồ", "combo", "phối", "mặc gì đi") → BẮT BUỘC CHUYỂN SANG CHẾ ĐỘ TƯ VẤN OUTFIT (TỰ ĐỘNG PHỐI TOP + BOTTOM TRỌN BỘ).
+ ⚠️ Khách hỏi "đồ", "quần áo" là muốn tìm CẢ BỘ (Set). Tuyệt đối KHÔNG ĐƯỢC chỉ trả về toàn Quần hoặc toàn Áo, toàn Váy.
- **COMBO/OUTFIT INTENT:** Khách dùng TỪ CHUNG CHUNG ("tìm đồ đi làm", "quần áo mùa hè", "đồ đi biển", "đi chợ", "đi chơi cho cả gia đình", "set đồ", "combo", "phối", "mặc gì đi") → BẮT BUỘC CHUYỂN SANG CHẾ ĐỘ TƯ VẤN OUTFIT (TỰ ĐỘNG PHỐI TOP + BOTTOM TRỌN BỘ).
+ ⚠️ Khách hỏi "đồ", "quần áo" là muốn tìm CẢ BỘ (Set). Tuyệt đối KHÔNG ĐƯỢC chỉ trả về toàn Quần hoặc toàn Áo, toàn Váy.
+ ⚠️ NẾU TOOL CHỈ TRẢ VỀ TOÀN QUẦN, BẠN PHẢI MỞ `outfit_recommendations` CỦA CÁI QUẦN ĐÓ RA VÀ BỐC LẤY 1 CÁI ÁO ("role": "top") ĐỂ GỢI Ý NGAY LẬP TỨC VỚI QUẦN ĐÓ! KHÔNG ĐƯỢC BẢO "BẠN MUỐN ÁO GÌ ĐỂ MÌNH TÌM".
+ ⚠️ KHI TOOL TRẢ VỀ NHIỀU SẢN PHẨM (2+ items) — ĐẶC BIỆT là "đi chơi/đi chợ cho cả gia đình": BẮT BUỘC đưa TẤT CẢ sản phẩm vào `product_ids` và đề cập đến tất cả trong response. KHÔNG ĐƯỢC bỏ sót bất kỳ món nào.
+ `product_ids`: Chứa SKU của các SP chính + SP phối từ `outfit_recommendations` (Bắt buộc phải có Áo + Quần/Váy phối hợp).
+ Response: Viết theo dạng OUTFIT với phối hợp logic giữa các món (TOP + BOTTOM / OUTERWEAR).
......@@ -367,20 +368,30 @@ Tool trả: Áo khoác blazer nam (8OT25W001) + `outfit_recommendations: [Quần
Bạn cho mình chiều cao cân nặng để mình check size nhé."
- `product_ids: ["8OT25W001", "8BP25S001", "8SM25S002"]` ← Bắt buộc phải nhét SKU của TẤT CẢ các món để hiện lên màn hình.
**VD5 — FAMILY/GROUP INTENT (đi chơi/đi chợ cho cả gia đình):**
Query: "tìm đồ đi chơi cho cả gia đình"
Tool trả: [Áo sơ mi bé trai (2TH25A001), Áo phông nam (8TS26S018-SK010), Váy bé gái (1DS26S003-SM090)]
- ❌ SAI: Chỉ nhắc Áo sơ mi + Váy, bỏ Áo phông nam → `product_ids: ["2TH25A001", "1DS26S003-SM090"]`
- ⚠️ Lý do: Bỏ sản phẩm quan trọng (áo phông) → UI không hiển thị → khách không thấy.
- ✅ ĐÚNG: Phải liệt kê TẤT CẢ sản phẩm tool trả về, phân nhóm rõ ràng:
"Bạn ơi, set đồ cả gia đình thì mình gợi ý theo 3 nhóm này nè:
• Bé trai: Áo sơ mi nam ngắn tay (2TH25A001) — phối Quần khaki bé trai (2BK25S002).
• Người lớn nam: Áo phông nam có hình in (8TS26S018-SK010) — phối Quần jeans nam.
• Bé gái: Váy trẻ em in hình động vật (1DS26S003-SM090).
Bạn cho mình biết bé nhà mình bao nhiêu tuổi và người lớn mặc size gì nhé?"
- `product_ids: ["2TH25A001", "8TS26S018-SK010", "1DS26S003-SM090"]` ← BAO GỒM TẤT CẢ.
### NHIỆM VỤ 2 — Chốt `product_ids` (HIỂN THỊ LÊN GIAO DIỆN):
**Cho SINGLE-ITEM INTENT:**
- `product_ids` chỉ chứa SKU của các SP chính được gợi ý (1-3 sản phẩm).
- BẤT BUỘC KHÔNG nhét các SP phối thêm vào danh sách này.
- VD: Khách hỏi "mua áo gì thoáng mát" → `product_ids: ["5TS25S001", "6TS25S002"]` (chỉ áo).
**💥 LUẬT TỐI CAO BẮT BUỘC (SỐNG CÒN):** BẤT KỲ MÃ SKU NÀO XUẤT HIỆN TRONG TEXT `ai_response` TRONG CẶP NGOẶC TRÒN (VD: `(8TH26A001-SW001)`) ĐỀU PHẢI NẰM TRONG MẢNG `product_ids`!
- TUYỆT ĐỐI KHÔNG BỎ SÓT BẤT CỨ MÃ NÀO! Kể cả khi đó là SP chính hay SP mà bạn gợi ý phối thêm. Nếu bạn nhắc tên nó, bạn PHẢI nhét mã nó vào `product_ids`.
- Nếu AI response nhắc đến 3 mã, `product_ids` PHẢI CÓ 3 phần tử. Nhắc 5 mã, `product_ids` PHẢI CÓ 5 phần tử.
**Cho COMBO/OUTFIT INTENT:**
- `product_ids` chứa SKU của TẤT CẢ sản phẩm được gợi ý (SP chính + SP phối).
- Chỉ nhét sản phẩm mà bạn đã bàn luận trong response.
- VD: Khách hỏi "combo đi biển" → `product_ids: ["6BS25S011", "6TS25S001", "6SS25S002"]` (quần + 2 áo phối).
**Cho KHÔNG PHÂN BIỆT SINGLE-ITEM HAY COMBO:**
- `product_ids` LÀ DANH SÁCH DUY NHẤT CHỨA TẤT CẢ CÁC MÃ SẢN PHẨM ĐƯỢC NHẮC TỚI TRONG CÂU TRẢ LỜI. MỌI MÃ XUẤT HIỆN ĐỀU PHẢI NẰM TRONG NÀY.
- VD: Khách hỏi áo, bạn giới thiệu 2 áo: `(8TS25C002-SE260)`, `(8TS26S018-SM200)`. Xong bạn tiện miệng bảo "có thể phối quần `(8TH26A001-SW001)`" → `product_ids` BẮT BUỘC phải là: `["8TS25C002-SE260", "8TS26S018-SM200", "8TH26A001-SW001"]`.
**LƯU Ý:**
- Nếu bạn gợi ý outfit nhưng nhân vật đó không xuất hiện trong ai_response hoặc không phù hợp → ĐỪNG nhét vào `product_ids`.
- Luôn đảm bảo `product_ids` phản ánh chính xác những gì bạn đã recommend.
- Luôn cẩn thận double-check vòng cuối: Quét qua toàn bộ nội dung `ai_response` bạn chuẩn bị nhả ra. Cứ thấy mã `(6xxxxxx)` hoặc `(8xxxxxx)`... là vứt thẳng vào array `product_ids`!
### NHIỆM VỤ 3 — Sinh `user_insight` (InsightJSON — bộ nhớ khách hàng):
Cập nhật 12 trường dưới đây sau mỗi turn. Cộng dồn thông tin.
......
"""
Regenerate AI outfit matches — clear old + run full batch.
Usage:
py backend/worker/scripts/regenerate_matches.py # run all
py backend/worker/scripts/regenerate_matches.py --limit 10 # dry-run 10 products
py backend/worker/scripts/regenerate_matches.py --execute --limit 50 # commit 50 products
"""
import argparse
import sqlite3
import sys
import time
from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from common.constants import SQLITE_DB_PATH
def clear_old_matches(dry_run: bool = True) -> int:
"""Xóa tất cả matches cũ. Returns số bản ghi bị xóa."""
conn = None
try:
conn = sqlite3.connect(SQLITE_DB_PATH)
cur = conn.cursor()
if dry_run:
cur.execute("SELECT COUNT(*) FROM pg__dashboard_canifa__ai_outfit_product_matches")
count = cur.fetchone()[0]
print(f"[DRY-RUN] Sẽ xóa {count} bản ghi cũ trong pg__dashboard_canifa__ai_outfit_product_matches")
return count
else:
cur.execute("DELETE FROM pg__dashboard_canifa__ai_outfit_product_matches")
deleted = cur.rowcount
conn.commit()
print(f"[EXECUTE] Đã xóa {deleted} bản ghi cũ")
return deleted
finally:
if conn:
conn.close()
def run_batch(limit: int | None = None, dry_run: bool = False):
"""Chạy StylistEngine batch."""
from worker.stylist_engine import StylistEngine, BATCH_STATE
print(f"\n{'='*60}")
print(f"🚀 CANIFA OUTFIT REGENERATION")
print(f"{'='*60}")
print(f"Mode: {'DRY-RUN' if dry_run else 'EXECUTE'}")
print(f"Limit: {limit or 'ALL'}")
print(f"{'='*60}\n")
# Clear old matches
print("🗑️ Clearing old matches...")
clear_old_matches(dry_run=dry_run)
# Get list of codes to process
print("\n📦 Loading catalog...")
engine = StylistEngine()
catalog = engine._get_catalog()
all_codes = [p["code"] for p in catalog]
if limit:
codes = all_codes[:limit]
else:
codes = all_codes
print(f" Found {len(codes)} products to process (from {len(all_codes)} total)")
# Run batch directly with codes list
print("\n🔄 Running StylistEngine batch...\n")
start = time.time()
count = engine.run_batch(codes=codes)
elapsed = time.time() - start
print(f"\n{'='*60}")
print(f"✅ Batch completed in {elapsed:.1f}s")
print(f" Processed: {count} products")
print(f" BATCH_STATE: {BATCH_STATE}")
print(f"{'='*60}\n")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--limit", type=int, default=None, help="Giới hạn số SP xử lý")
parser.add_argument("--execute", action="store_true", help="Thực sự xóa và lưu (không dry-run)")
args = parser.parse_args()
run_batch(limit=args.limit, dry_run=not args.execute)
"""
Simple test: insert matches for 1 product using only SQLite catalog.
Avoids StarRocks dependency.
"""
import sys
import io
if sys.platform == "win32":
sys.stdout.reconfigure(encoding='utf-8')
import sqlite3
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from common.constants import SQLITE_DB_PATH, TABLE_OUTFIT_RULES, TABLE_PRODUCTS
def get_catalog_from_sqlite() -> list[dict]:
"""Load catalog trực tiếp từ SQLite."""
conn = sqlite3.connect(SQLITE_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
query = f"""
SELECT magento_ref_code as code,
product_name as name,
master_color as color,
product_line_vn as product_line,
gender_by_product as gender,
age_by_product as age_group,
product_image_url_thumbnail as image
FROM {TABLE_PRODUCTS}
WHERE magento_ref_code IS NOT NULL
AND product_line_vn IS NOT NULL
LIMIT 100
"""
cur.execute(query)
rows = cur.fetchall()
conn.close()
catalog = []
for r in rows:
catalog.append({
"code": r["code"],
"name": r["name"],
"color": r["color"] or "",
"product_line": r["product_line"] or "",
"gender": r["gender"] or "",
"age_group": r["age_group"] or "",
"image": r["image"] or "",
"style_tags": [],
"occasion_tags": [],
"material_tags": [],
"season_tags": [],
})
return catalog
def fetch_rules_for_anchor(anchor_cat: str, gender: str = "") -> list[dict]:
"""Fetch rules from SQLite."""
from common.constants import normalize_gender, SQLITE_GENDER_MAP, TABLE_OUTFIT_RULES
conn = sqlite3.connect(SQLITE_DB_PATH)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
gender_key = normalize_gender(gender)
target_gender = SQLITE_GENDER_MAP.get(gender_key, "unisex")
cur.execute(
f"""SELECT id as rule_id, occasion_tag as occasion, match_role, target_category, ai_reason
FROM {TABLE_OUTFIT_RULES}
WHERE UPPER(anchor_category) = UPPER(?)
AND (gender_target = ? OR gender_target = 'unisex' OR gender_target = 'all')""",
(anchor_cat, target_gender)
)
rows = cur.fetchall()
conn.close()
rules = []
for r in rows:
rules.append({
"rule_id": r["rule_id"],
"occ": r["occasion"],
"role": r["match_role"],
"target_cat": r["target_category"],
"ai_reason": r["ai_reason"] or "",
})
return rules
def simple_score(anchor: dict, target: dict) -> int:
"""Simplified score for testing."""
score = 50
anchor_color = anchor["color"] or ""
target_color = target["color"] or ""
# Simple color harmony
if anchor_color and target_color and anchor_color.lower() == target_color.lower():
score += 20
elif target_color and "đen" in target_color.lower():
score += 10
if target.get("is_new_product"):
score += 20
return score
def generate_reason(anchor: dict, target: dict, rule: dict) -> str:
return f"Màu {target['color']} của {target['product_line']} phối cùng {anchor['color']}. {rule['ai_reason']}"
def test_insert_one_product():
"""Test insert matches for first adult product with rules."""
catalog = get_catalog_from_sqlite()
if not catalog:
print("❌ No catalog found")
return False
# Find first ADULT anchor with rules (avoid kid anchors)
anchor = None
for p in catalog:
age = (p["age_group"] or "").lower()
gender = p["gender"] or ""
# Skip kids
if any(k in age for k in ["kid", "trẻ em", "be_trai", "be_gai"]):
continue
if any(k in gender for k in ["boy", "girl", "bé trai", "bé gái"]):
continue
rules = fetch_rules_for_anchor(p["product_line"], p["gender"])
if rules:
anchor = p
break
if not anchor:
print("❌ No adult anchor with rules found")
return False
print(f"🎯 Testing with anchor: {anchor['code']} - {anchor['product_line']} ({anchor['gender']})")
rules = fetch_rules_for_anchor(anchor["product_line"], anchor["gender"])
print(f"📜 Found {len(rules)} rules")
for r in rules:
print(f" • Rule {r['rule_id']}: {anchor['product_line']} → {r['target_cat']} [{r['occ']}]")
# Build targets by category
targets_by_cat = {}
for t in catalog:
if t["code"] == anchor["code"]:
continue
cat = t["product_line"]
if cat not in targets_by_cat:
targets_by_cat[cat] = []
targets_by_cat[cat].append(t)
print(f"\n📦 Target categories found: {list(targets_by_cat.keys())[:10]}")
# Compute matches
rows = []
for rule in rules:
target_cat = rule["target_cat"]
targets = targets_by_cat.get(target_cat, [])
print(f" 🔍 Looking for target category '{target_cat}': {len(targets)} items")
for target in targets:
# Age filter
anchor_age = (anchor["age_group"] or "").strip().lower()
target_age = (target["age_group"] or "").strip().lower()
print(f" → Candidate: {target['code']} ({target['product_line']}), age_group='{target_age}'")
if anchor_age and target_age and anchor_age != target_age:
print(f" ✗ Age filter: anchor='{anchor_age}' != target='{target_age}'")
continue
score = simple_score(anchor, target)
print(f" Score: {score}")
if score < 35:
print(f" ✗ Score too low (<35)")
continue
reason = generate_reason(anchor, target, rule)
print(f" ✓ MATCH! score={score}")
rows.append((
rule["rule_id"],
anchor["code"],
target["code"],
target["name"],
target["gender"],
target["age_group"],
rule["role"],
score,
reason,
rule["occ"],
))
for target in targets:
# Age filter
anchor_age = (anchor["age_group"] or "").strip().lower()
target_age = (target["age_group"] or "").strip().lower()
if anchor_age and target_age and anchor_age != target_age:
continue
score = simple_score(anchor, target)
reason = generate_reason(anchor, target, rule)
rows.append((
rule["rule_id"],
anchor["code"],
target["code"],
target["name"],
target["gender"],
target["age_group"],
rule["role"],
score,
reason,
rule["occ"],
))
# Insert to DB
if rows:
conn = sqlite3.connect(SQLITE_DB_PATH)
cur = conn.cursor()
cur.executemany('''
INSERT INTO pg__dashboard_canifa__ai_outfit_product_matches
(rule_id, anchor_product_code, match_product_code, match_product_name, match_gender, match_age, match_role, score, ai_reason, occasion)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', rows)
conn.commit()
conn.close()
print(f"✅ Inserted {len(rows)} matches for {anchor['code']}")
return True
else:
print("⚠️ No matches generated")
return False
if __name__ == "__main__":
test_insert_one_product()
This diff is collapsed.
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