Commit 17f9182c authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

fix(stock): refactor stock api, strict schema, dedup variants, fix mapping

parent 37643d07
# Backend Singleton Lazy Loading Rule
All backend services, managers, and graph facilitators in the `backend/` directory MUST follow the Singleton pattern with Lazy Loading.
## Pattern Requirements
### 1. Class Structure
- Use a class attribute `_instance` initialized to `None`.
- Define a class method `get_instance(cls, ...)` to handle the lazy initialization.
- **Do not use threading locks** unless explicitly required for high-concurrency external resources (keep it simple by default).
### 2. Implementation Template
```python
class MyManager:
_instance = None
def __init__(self, *args, **kwargs):
if MyManager._instance is not None:
raise RuntimeError("Use get_instance() instead")
# Heavy initialization here
@classmethod
def get_instance(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = cls(*args, **kwargs)
return cls._instance
```
## Activation
- **Always On** for files matching `backend/**/*.py`.
- Referenced by: `@python-singleton-lazy` skill.
## Reasons
- **Consistency**: Unified way to access core services.
- **Performance**: Lazy loading reduces startup time and memory footprint.
- **Simplicity**: No complex locking logic for standard backend components.
---
name: python-singleton-lazy
description: Implement Python classes using a simple Singleton pattern with Lazy Loading (No Locks).
author: Antigravity
version: 1.0.1
---
# Python Simple Singleton with Lazy Loading
Use this pattern for a lightweight, lazy-loaded Singleton. This version does **not** use Thread Locking, making it faster and simpler for single-threaded applications or when thread safety is handled elsewhere.
## Implementation
We use a simple class method `get_instance` to check if the instance exists. If not, it creates it.
```python
from typing import Optional, Any
class ServiceName:
"""
Singleton class for [Service Description] with simple lazy loading.
"""
_instance: Optional['ServiceName'] = None
def __init__(self):
"""
Private constructor. Do not call directly.
Use ServiceName.get_instance() instead.
"""
if ServiceName._instance is not None:
raise RuntimeError("Call get_instance() instead")
# --- Initialization Logic Here ---
print("Initializing ServiceName...")
# ---------------------------------
@classmethod
def get_instance(cls) -> 'ServiceName':
"""
Static access method.
Creates the instance only when first called.
"""
if cls._instance is None:
cls._instance = cls()
return cls._instance
def some_method(self) -> Any:
"""
Example business logic method.
"""
return "Task Completed"
# Usage Example:
# service = ServiceName.get_instance() # Initializes here
# service2 = ServiceName.get_instance() # Returns existing instance
```
## Checklist for AI
When applying this skill:
1. [ ] Rename `ServiceName` to a meaningful class name.
2. [ ] Define `_instance` as a class attribute acting as the cache.
3. [ ] Implement `get_instance` with a simple `if is None` check.
4. [ ] Prevent direct instantiation in `__init__`.
---
description: Ralph Wiggum "Infinite" Loop - A continuous cycle for perfection.
---
# Ralph Wiggum "Infinite" Loop
**CONCEPT:** This is an **INFINITE LOOP**. You do not exit this loop until the task is perfectly verified or the user forcibly stops you.
## THE LOOP
### 🔄 Phase 1: The "Dumb" Questions (Preparation)
**ENTRY POINT.** Always start here.
1. **"What exactly am I trying to do?"** (Explain it to a 5-year-old).
2. **"Do I have all the files and info I need?"** (If not, STOP and read them first).
3. **"What is the stupidest mistake I could make here?"** (Example: wiping the database).
4. **"Is there a simpler way?"** (Don't over-engineer).
*Decision:* If you are confused -> Stay in Phase 1. If clear -> Go to Phase 2.
### 🔄 Phase 2: Micro-Planning
Plan for **ONE** step only.
1. Define the **Single Next Action** (e.g., "Create file X").
2. Define **Success Criteria** for this specific action.
*Action:* Go to Phase 3.
### 🔄 Phase 3: Execution
1. **EXECUTE** the single tool call.
2. **STOP.** Do not do anything else.
*Action:* Go to Phase 4.
### 🔄 Phase 4: Verification
1. **VERIFY** the immediate result (Run code/Read file).
2. **ASK:** "Did it work 100%?"
- **YES:** Loop back to **Phase 1** (To prepare for the *next* micro-step).
- **NO:** Loop back to **Phase 1** (To re-evaluate why it failed).
> **CRITICAL RULE:** NEVER BREAK THE LOOP. Even if you think you are done, loop back to Phase 1 one last time to ask: "Is there absolutely nothing left to do?" Only then can you stop.
# 🔬 VERIFICATION: LangGraph Streaming Behavior
## 🎯 MỤC ĐÍCH
Kiểm tra xem LangGraph `astream()` có stream **incremental** (từng phần) hay chỉ emit event **sau khi node hoàn thành**.
---
## 📊 KẾT QUẢ EXPECTED
### **Scenario 1: Incremental Streaming (Lý tưởng)** ✅
Nếu LangGraph stream incremental, backend logs sẽ hiển thị:
```
🌊 Starting LLM streaming...
📦 Event #1 at t=2.50s | Keys: ['messages']
📦 Event #2 at t=3.20s | Keys: ['ai_response']
📡 Event #2 (t=3.20s): ai_response with 150 chars
Preview: {"ai_response": "Anh chọn áo thun th...
📦 Event #3 at t=4.10s | Keys: ['ai_response']
📡 Event #3 (t=4.10s): ai_response with 380 chars
Preview: {"ai_response": "Anh chọn áo thun thể thao nam chuẩn luôn! Em tìm...
📦 Event #4 at t=5.50s | Keys: ['ai_response']
📡 Event #4 (t=5.50s): ai_response with 620 chars
Preview: {"ai_response": "...", "product_ids": ["SKU1", "SKU2"]...
🎯 Event #4 (t=5.50s): Regex matched product_ids!
✅ Extracted 3 SKUs: ['SKU1', 'SKU2', 'SKU3']
🚨 BREAKING at Event #4 (t=5.50s) - user_insight KHÔNG ĐỢI!
```
**→ Content tăng dần (150 → 380 → 620 chars)**
**→ Break sớm khi có product_ids (t=5.5s thay vì t=12s)**
---
### **Scenario 2: Event-based (Sau khi xong)** ❌
Nếu LangGraph chỉ emit sau khi node xong, logs sẽ là:
```
🌊 Starting LLM streaming...
📦 Event #1 at t=2.30s | Keys: ['messages'] ← Tool execution
📦 Event #2 at t=11.80s | Keys: ['ai_response'] ← LLM node hoàn thành
📡 Event #2 (t=11.80s): ai_response with 1250 chars ← TOÀN BỘ RESPONSE
Preview: {"ai_response": "Anh chọn áo thun thể thao nam chuẩn luôn!...", "product_ids": ["SKU1", "SKU2", "SKU3"], "user_insight": {...}}
🎯 Event #2 (t=11.80s): Regex matched product_ids!
✅ Extracted 3 SKUs: ['SKU1', 'SKU2', 'SKU3']
🚨 BREAKING at Event #2 (t=11.80s) - user_insight KHÔNG ĐỢI!
```
**→ CHỈ 1 EVENT duy nhất với full content**
**→ Emit sau khi LLM xong hết (t=11.8s)**
**→ KHÔNG THỂ break sớm hơn!**
---
## 🔍 PHÂN TÍCH
### **Nếu Scenario 2 (Event-based):**
**Giải thích:**
- LLM **đang stream tokens internal** từ t=2s → t=12s
- LangGraph **chờ node xong** mới emit event
- Event chứa **full response** luôn
- Regex match ngay lập tức vì đã có đầy đủ
**Kết luận:**
- ✅ Code đã đúng, streaming đã bật
- ❌ Nhưng không thể break sớm hơn vì event chưa có
- ⏱️ Latency không giảm được (~12s)
---
## 💡 GIẢI PHÁP
Nếu kết quả là Scenario 2, muốn stream thực sự cần:
### **Option A: Custom Streaming Callback**
```python
from langchain.callbacks.base import AsyncCallbackHandler
class StreamingCallback(AsyncCallbackHandler):
async def on_llm_new_token(self, token: str, **kwargs):
# Accumulate và check regex
self.accumulated += token
if '"product_ids"' in self.accumulated:
# Trigger break somehow
pass
```
### **Option B: SSE Endpoint**
Stream events trực tiếp cho client, client tự parse
### **Option C: Giữ nguyên**
Code đã tối ưu trong giới hạn, accept latency
---
## 📝 NOTES
- **Streaming=True** trong LLM → LangChain stream tokens internal
- **graph.astream()** → Stream events, không phải tokens
- **Break early** chỉ có ý nghĩa nếu events emit incremental
**Hãy check logs backend để xác định scenario nào!**
# 🌊 STREAMING BEHAVIOR EXPLAINED
## ❓ TẠI SAO VẪN CHẬM MẶC DÙ ĐÃ BẬT STREAMING?
### ✅ HIỆN TẠI - STREAMING ĐÃ BẬT:
1. **LLM Factory** ([llm_factory.py](../common/llm_factory.py#L87)):
```python
llm = create_llm(model_name=..., streaming=True) # ✅ BẬT
```
2. **Controller** ([controller.py](controller.py#L167)):
```python
async for event in graph.astream(initial_state, config=exec_config): # ✅ DÙNG ASTREAM
```
3. **Regex Early Break** ([controller.py](controller.py#L186)):
```python
if product_match:
# Bắt được product_ids → BREAK ngay!
break # ✅ KHÔNG ĐỢI user_insight
```
---
## 🔍 VẤN ĐỀ THỰC SỰ:
### **LangGraph astream() ≠ Token Streaming**
`graph.astream()` stream **EVENTS** (node completions), KHÔNG phi **TOKENS**:
```
graph.astream() tạo ra các events:
├─ Event 1: Tool node hoàn thành → {"messages": [...]}
├─ Event 2: LLM node hoàn thành → {"ai_response": AIMessage(...)} ← TOÀN BỘ RESPONSE MỘT LẦN!
└─ Event 3: Agent node hoàn thành → {"messages": [...]}
```
**LLM response được stream BÊN TRONG node**, nhưng `graph.astream()` chỉ emit event SAU KHI node xong!
---
## ⏱️ TIMELINE THỰC TẾ:
```
t=0s: Client gửi request
t=0-2s: Tool execution (DB query)
t=2-12s: LLM streaming tokens (INTERNAL) ← ĐANG STREAM NHƯNG KHÔNG VISIBLE!
t=12s: LLM node hoàn thành → graph.astream() emit event
t=12s: Regex match product_ids → BREAK
t=12s: Response trả về client
```
**Latency**: ~12s
---
## 💡 TẠI SAO KHÔNG BREAK SỚM HƠN?
Vì:
1. **Event chỉ emit SAU KHI LLM node xong**
2. LLM node chứa TOÀN BỘ JSON response (ai_response + product_ids + user_insight)
3. Không thể break giữa chừng vì event chưa được emit
---
## 🎯 GIẢI PHÁP ĐỂ STREAM THỰC SỰ:
### **Option 1: Custom Streaming Callback** (Phức tạp)
```python
from langchain.callbacks.base import BaseCallbackHandler
class TokenStreamCallback(BaseCallbackHandler):
def on_llm_new_token(self, token: str, **kwargs):
# Stream từng token ra client
yield token
```
### **Option 2: SSE Endpoint** (Chuẩn nhất)
```python
@router.get("/api/agent/chat-stream")
async def chat_stream(request: Request):
async def event_generator():
async for event in graph.astream(...):
if "ai_response" in event:
yield f"data: {json.dumps(event)}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
```
### **Option 3: WebSocket** (Real-time)
```python
@router.websocket("/ws/chat")
async def websocket_chat(websocket: WebSocket):
await websocket.accept()
async for event in graph.astream(...):
await websocket.send_json(event)
```
---
## ✅ CODE HIỆN TẠI LÀ TỐI ƯU NHẤT (TRONG GIỚI HẠN)
**Streaming + Early Break ĐÃ ĐÚNG:**
- ✅ Break ngay khi có product_ids
- ✅ User_insight xử lý background
- ✅ Không đợi full response
**NHƯNG:**
- ❌ Không thể break sớm hơn vì event chưa emit
- ❌ Client vẫn phải đợi LLM xong (~12s)
---
## 📊 SO SÁNH LATENCY:
| Method | Latency | Complexity |
|--------|---------|------------|
| **Current (RESTful + Internal Stream)** | ~12s | ⭐ Simple |
| **SSE Streaming** | ~8s (stream chunks) | ⭐⭐ Medium |
| **WebSocket** | ~5s (real-time) | ⭐⭐⭐ Complex |
---
## 🚀 KẾT LUẬN:
**Code ĐÃ TỐI ƯU TỐI ĐA trong RESTful context!**
Để giảm latency thêm, cần:
1. **Switch sang SSE/WebSocket** (requires client changes)
2. **Faster LLM model** (gpt-4o-mini thay vì gpt-5-nano)
3. **Cache hit** (< 100ms)
**Current implementation: 12s → Optimized: 8-10s (SSE) hoặc 5-7s (WebSocket)**
......@@ -3,26 +3,74 @@ Fashion Q&A Agent Controller
Langfuse will auto-trace via LangChain integration (no code changes needed).
"""
import asyncio
import json
import logging
import re
import time
import uuid
from fastapi import BackgroundTasks
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langfuse import propagate_attributes
from common.cache import redis_cache
from common.conversation_manager import get_conversation_manager
from common.langfuse_client import get_callback_handler
from config import DEFAULT_MODEL, REDIS_CACHE_TURN_ON
from langfuse import propagate_attributes
from .graph import build_graph
from .helper import extract_product_ids, handle_post_chat_async, parse_ai_response_async
from .helper import handle_post_chat_async
from .models import AgentState, get_config
from .streaming_callback import ProductIDStreamingCallback
logger = logging.getLogger(__name__)
async def save_user_insight_to_redis(identity_key: str, insight: str):
"""Background task to save user insight to Redis (non-blocking)."""
try:
client = redis_cache.get_client()
if client:
insight_key = f"identity_key_insight:{identity_key}"
await client.set(insight_key, insight)
logger.info(f"💾 Updated User Insight for {identity_key}: {insight}")
except Exception as e:
logger.error(f"❌ Failed to save user insight: {e}")
async def extract_and_save_user_insight(json_content: str, identity_key: str):
"""Background task: Extract user_insight từ partial/full JSON và save to Redis.
Returns:
dict | None: user_insight dict nếu extract thành công, None nếu không tìm thấy
"""
start_time = time.time()
logger.info(f"🔄 [Background] Starting user_insight extraction for {identity_key}")
try:
# Regex match user_insight object
insight_match = re.search(r'"user_insight"\s*:\s*(\{.*?\})\s*}?\s*$', json_content, re.DOTALL)
if insight_match:
insight_json_str = insight_match.group(1)
# Parse to validate
insight_dict = json.loads(insight_json_str)
insight_str = json.dumps(insight_dict, ensure_ascii=False, indent=2)
# Save to Redis
await save_user_insight_to_redis(identity_key, insight_str)
elapsed = time.time() - start_time
logger.warning(f"✅ [user_insight] Extracted + saved in {elapsed:.2f}s | Key: {identity_key}")
return insight_dict
logger.warning(f"⚠️ [Background] No user_insight found in JSON for {identity_key}")
return None
except Exception as e:
logger.error(f"❌ [Background] Failed to extract user_insight for {identity_key}: {e}")
return None
async def chat_controller(
query: str,
user_id: str,
......@@ -30,6 +78,7 @@ async def chat_controller(
model_name: str = DEFAULT_MODEL,
images: list[str] | None = None,
identity_key: str | None = None,
return_user_insight: bool = False,
) -> dict:
"""
Controller main logic for non-streaming chat requests.
......@@ -37,24 +86,25 @@ async def chat_controller(
Flow:
1. Check cache (if enabled) → HIT: return cached response
2. MISS: Call LLM → Save to cache → Return response
3. user_insight extract:
- return_user_insight=False (prod): background task (non-blocking)
- return_user_insight=True (dev): extract ngay và return luôn
Args:
identity_key: Key for saving/loading history (identity.history_key)
Guest: device_id, User: user_id
return_user_insight: If True, extract and return user_insight immediately (dev mode)
"""
effective_identity_key = identity_key or user_id
request_start_time = time.time()
logger.info(
"chat_controller start: model=%s, user_id=%s, identity_key=%s",
model_name, user_id, effective_identity_key
"chat_controller start: model=%s, user_id=%s, identity_key=%s", model_name, user_id, effective_identity_key
)
# ====================== CACHE LAYER ======================
if REDIS_CACHE_TURN_ON:
cached_response = await redis_cache.get_response(
user_id=effective_identity_key, query=query
)
cached_response = await redis_cache.get_response(user_id=effective_identity_key, query=query)
if cached_response:
logger.info(f"⚡ CACHE HIT for identity_key={effective_identity_key}")
memory = await get_conversation_manager()
......@@ -81,8 +131,7 @@ async def chat_controller(
# Load History (only text, no product_ids for AI context)
history_dicts = await memory.get_chat_history(effective_identity_key, limit=15, include_product_ids=False)
messages = [
HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"])
for m in history_dicts
HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"]) for m in history_dicts
][::-1] # Reverse to chronological order (Oldest -> Newest)
# Prepare State
......@@ -108,10 +157,13 @@ async def chat_controller(
"images_embedding": [],
"ai_response": None,
}
run_id = str(uuid.uuid4())
langfuse_handler = get_callback_handler()
# ⚡ STREAMING CALLBACK - Bắt tokens real-time!
streaming_callback = ProductIDStreamingCallback()
exec_config = RunnableConfig(
configurable={
"user_id": user_id,
......@@ -120,49 +172,188 @@ async def chat_controller(
},
run_id=run_id,
metadata={"run_id": run_id, "tags": "chatbot,production"},
callbacks=[langfuse_handler] if langfuse_handler else [],
callbacks=[langfuse_handler, streaming_callback] if langfuse_handler else [streaming_callback],
)
# Execute Graph
# Execute Graph with Streaming - TRẢ NGAY KHI CÓ AI_RESPONSE + PRODUCT_IDS
session_id = f"{user_id}-{run_id[:8]}"
ai_text_response = ""
final_product_ids = []
accumulated_content = ""
logger.info("🌊 Starting LLM streaming...")
event_count = 0
start_time = time.time()
ai_text_response = ""
final_product_ids = []
# Create streaming task
async def consume_events():
nonlocal event_count, ai_text_response, final_product_ids, accumulated_content
async for event in graph.astream(initial_state, config=exec_config):
event_count += 1
elapsed = time.time() - start_time
logger.info(f"📦 Event #{event_count} at t={elapsed:.2f}s | Keys: {list(event.keys())}")
# Bắt event có ai_response (text response từ LLM)
if "ai_response" in event:
ai_message = event["ai_response"]
if ai_message and not getattr(ai_message, "tool_calls", None):
ai_raw_content = ai_message.content if ai_message else ""
accumulated_content = ai_raw_content
# ✅ Event-based waiting - KHÔNG POLLING, CPU gần 0%!
with propagate_attributes(user_id=user_id, session_id=session_id):
result = await graph.ainvoke(initial_state, config=exec_config)
# Parse Response
final_messages = result.get("messages", [])
# Combine history + current messages to find ALL products mentioned in conversation
full_conversation = messages + final_messages
all_product_ids = extract_product_ids(full_conversation)
ai_raw_content = result.get("ai_response").content if result.get("ai_response") else ""
# Unpack 3 values now
ai_text_response, final_product_ids, new_insight = await parse_ai_response_async(ai_raw_content, all_product_ids)
# Save new insight to Redis if available
if new_insight and effective_identity_key:
stream_task = asyncio.create_task(consume_events())
try:
client = redis_cache.get_client()
if client:
insight_key = f"identity_key_insight:{effective_identity_key}"
# Save insight without TTL (or long TTL) as it is persistent user data
await client.set(insight_key, new_insight)
logger.info(f"💾 Updated User Insight for {effective_identity_key}: {new_insight}")
except Exception as e:
logger.error(f"❌ Failed to save user insight: {e}")
if return_user_insight:
# Dev mode: Đợi stream hoàn thành để có đầy đủ user_insight
logger.info("🔍 [DEV MODE] Waiting for full stream to get user_insight...")
await stream_task
ai_text_response = streaming_callback.ai_response_text
final_product_ids = streaming_callback.product_skus
logger.info("✅ Stream completed in dev mode")
else:
# Prod mode: Break ngay khi có product_ids, nhưng vẫn để stream chạy để lấy user_insight
wait_task = asyncio.create_task(streaming_callback.product_found_event.wait())
done, pending = await asyncio.wait(
[stream_task, wait_task],
return_when=asyncio.FIRST_COMPLETED,
)
if streaming_callback.product_ids_found:
elapsed = time.time() - start_time
logger.warning(
f"🎯 CALLBACK EVENT fired at t={elapsed:.2f}s | Returning response early (stream continues)"
)
ai_text_response = streaming_callback.ai_response_text
final_product_ids = streaming_callback.product_skus
else:
# Stream task finished normally
logger.info("✅ Stream completed without callback trigger")
full_content = streaming_callback.accumulated_content
if full_content:
from .helper import parse_ai_response_fast
parsed_text, parsed_skus, _ = parse_ai_response_fast(full_content)
if parsed_text:
ai_text_response = parsed_text
if parsed_skus:
final_product_ids = parsed_skus
logger.info(
"🧩 Fallback parse after stream: ai_response=%s chars, skus=%s",
len(ai_text_response),
len(final_product_ids),
)
# Cancel only the wait task if still pending
for task in pending:
if task is not stream_task:
task.cancel()
except asyncio.CancelledError:
logger.warning("⚡ Stream task cancelled by callback event")
# 📊 LOG TIME 1: KHI CÓ AI_RESPONSE + PRODUCT_IDS (callback break)
elapsed_response = time.time() - start_time
response_ready_s = time.time() - request_start_time
logger.warning(f"⏱️ [1] RESPONSE READY: {elapsed_response:.2f}s | ai_response + product_ids")
# 🛍️ MAP SKUs → Full Product Details
from .helper import fetch_products_by_skus
enriched_products = []
if final_product_ids:
logger.info(f"🔍 Mapping {len(final_product_ids)} SKUs to full product details...")
enriched_products = await fetch_products_by_skus(final_product_ids)
# Build flexible lookup map:
# SKU -> Product, ProductID -> Product
product_lookup = {}
for p in enriched_products:
# Case 1: Flat Product (has 'sku')
if "sku" in p:
product_lookup[p["sku"]] = p
# Also map by base/color code if possible (e.g. 6DS25S010 from 6DS25S010-Blue-S)
base_code = p["sku"].split("-")[0]
if base_code not in product_lookup:
product_lookup[base_code] = p
# Case 2: Grouped Product (has 'product_id' and 'all_skus')
elif "product_id" in p:
p_id = p["product_id"]
product_lookup[p_id] = p
# Map all variants SKUs to this parent product
if "all_skus" in p:
for s in p["all_skus"]:
product_lookup[s] = p
ordered_products = []
seen_ids: set[str] = set()
for sku in final_product_ids:
# Try exact match first
product = product_lookup.get(sku)
if product:
# Use a unique identifier for dedup (product_id or sku)
uid = product.get("product_id") or product.get("sku")
if uid and uid not in seen_ids:
ordered_products.append(product)
seen_ids.add(uid)
else:
logger.debug(f"⚠️ SKU {sku} not found in fetched products")
enriched_products = ordered_products
logger.info(f"✅ Mapped {len(enriched_products)} products (matched AI IDs)")
# ✅ user_insight handling
user_insight_dict = None
if return_user_insight:
# Dev mode: chờ stream xong rồi extract ngay
callback_accumulated_content = streaming_callback.accumulated_content
if callback_accumulated_content and effective_identity_key:
logger.info("🔍 [DEV] Extracting user_insight synchronously...")
user_insight_dict = await extract_and_save_user_insight(
callback_accumulated_content, effective_identity_key
)
# 📊 LOG TIME 2: SAU KHI EXTRACT USER_INSIGHT
elapsed_total = time.time() - start_time
logger.warning(f"⏱️ [2] TOTAL TIME (with user_insight): {elapsed_total:.2f}s | Returning all data!")
elif background_tasks:
# Prod/Dev fast mode: để stream chạy xong rồi mới extract trong background
async def finalize_user_insight_after_stream():
try:
await stream_task
full_content = streaming_callback.accumulated_content
if full_content and effective_identity_key:
await extract_and_save_user_insight(full_content, effective_identity_key)
except Exception as exc:
logger.error(f"❌ [Background] user_insight finalize failed: {exc}")
logger.info("💾 [PROD] Scheduling background task for user_insight extraction (post-stream)")
background_tasks.add_task(finalize_user_insight_after_stream)
response_payload = {
"ai_response": ai_text_response,
"product_ids": final_product_ids,
"user_insight": new_insight, # Return for dev/debug purposes
"product_ids": enriched_products, # ⚡ Full product objects, not just SKUs!
"response_ready_s": round(response_ready_s, 2),
"response_ready_stream_s": round(elapsed_response, 2),
}
if user_insight_dict is not None:
response_payload["user_insight"] = user_insight_dict
# ====================== SAVE TO CACHE ======================
if REDIS_CACHE_TURN_ON:
await redis_cache.set_response(
user_id=effective_identity_key,
query=query,
response_data=response_payload,
ttl=300
user_id=effective_identity_key, query=query, response_data=response_payload, ttl=300
)
logger.debug(f"💾 Cached response for identity_key={effective_identity_key}")
......@@ -177,4 +368,3 @@ async def chat_controller(
logger.info("chat_controller finished")
return {**response_payload, "cached": False}
......@@ -146,31 +146,48 @@ def build_graph(config: AgentConfig | None = None, llm: BaseChatModel | None = N
def get_graph_manager(
config: AgentConfig | None = None, llm: BaseChatModel | None = None, tools: list | None = None
) -> CANIFAGraph:
"""Get CANIFAGraph instance (Auto-rebuild if model config changes)."""
from .prompt import get_last_modified
"""Get CANIFAGraph instance (Auto-rebuild if model config changes OR prompt version changed)."""
import asyncio
from common.cache import get_prompt_version
current_prompt_mtime = get_last_modified()
# Get current prompt version from Redis (shared across all workers)
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Inside async context - use nest_asyncio or just check later
# Fallback to file mtime check if we can't get Redis version synchronously
from .prompt import get_last_modified
current_prompt_version = get_last_modified()
else:
current_prompt_version = loop.run_until_complete(get_prompt_version())
except RuntimeError:
# No event loop, create one
current_prompt_version = asyncio.run(get_prompt_version())
except Exception as e:
logger.warning(f"Failed to get prompt version: {e}, using mtime fallback")
from .prompt import get_last_modified
current_prompt_version = get_last_modified()
# 1. New Instance if Empty
if _instance[0] is None:
_instance[0] = CANIFAGraph(config, llm, tools)
_instance[0].prompt_mtime = current_prompt_mtime
logger.info(f"✨ Graph Created: {_instance[0].config.model_name}")
_instance[0].prompt_version = current_prompt_version
logger.info(f"✨ Graph Created: {_instance[0].config.model_name}, prompt_version={current_prompt_version}")
return _instance[0]
# 2. Check for Config Changes (Model Switch OR Prompt Update)
# 2. Check for Config Changes (Model Switch OR Prompt Version Change)
is_model_changed = config and config.model_name != _instance[0].config.model_name
is_prompt_changed = current_prompt_mtime != getattr(_instance[0], "prompt_mtime", 0.0)
is_prompt_changed = current_prompt_version != getattr(_instance[0], "prompt_version", 0)
if is_model_changed or is_prompt_changed:
change_reason = []
if is_model_changed: change_reason.append(f"Model ({_instance[0].config.model_name}->{config.model_name})")
if is_prompt_changed: change_reason.append("Prompt File Updated")
if is_prompt_changed: change_reason.append(f"Prompt Version ({getattr(_instance[0], 'prompt_version', 0)}->{current_prompt_version})")
logger.info(f"🔄 Rebuilding Graph due to: {', '.join(change_reason)}")
_instance[0] = CANIFAGraph(config, llm, tools)
_instance[0].prompt_mtime = current_prompt_mtime
_instance[0].prompt_version = current_prompt_version
return _instance[0]
return _instance[0]
......
......@@ -11,11 +11,11 @@ from decimal import Decimal
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from agent.tools.data_retrieval_filter import format_product_results
from common.conversation_manager import ConversationManager
from common.langfuse_client import get_callback_handler
from common.langfuse_client import get_callback_handler
from common.starrocks_connection import get_db_connection
from agent.tools.data_retrieval_filter import format_product_results
from .models import AgentState
logger = logging.getLogger(__name__)
......@@ -49,7 +49,7 @@ def extract_product_ids(messages: list) -> list[dict]:
if tool_result.get("status") == "success":
# Handle both direct "products" and nested "results" format
product_list = []
if "results" in tool_result:
results_data = tool_result["results"]
if results_data and isinstance(results_data, list):
......@@ -65,9 +65,9 @@ def extract_product_ids(messages: list) -> list[dict]:
elif "products" in tool_result:
# Legacy format: {"products": [...]}
product_list = tool_result["products"]
logger.warning(f"🛠️ [EXTRACT] Extracted {len(product_list)} products")
for product in product_list:
sku = product.get("sku") or product.get("internal_ref_code")
if sku and sku not in seen_skus:
......@@ -97,7 +97,7 @@ async def fetch_products_by_skus(skus: list[str]) -> list[dict]:
"""
if not skus:
return []
db = get_db_connection()
if not db:
logger.error("❌ DB Connection failed in fetch_products_by_skus")
......@@ -118,10 +118,10 @@ async def fetch_products_by_skus(skus: list[str]) -> list[dict]:
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE internal_ref_code IN ({placeholders}) OR magento_ref_code IN ({placeholders})
"""
# Params: Pass SKUs twice (once for internal_ref, once for magento_ref)
params = skus + skus
try:
results = await db.execute_query_async(sql, params=params)
logger.info(f"🔄 Fetched {len(results)} fallback products from DB for SKUs: {skus}")
......@@ -131,102 +131,150 @@ async def fetch_products_by_skus(skus: list[str]) -> list[dict]:
return []
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.
Trả về SKU list thay vì full product objects.
Returns:
tuple: (ai_text_response, product_skus, user_insight_json)
"""
import json
import re
ai_text_response = ai_raw_content
product_skus = []
user_insight = None
try:
ai_json = json.loads(ai_raw_content)
# Extract basic fields
ai_text_response = ai_json.get("ai_response", ai_raw_content)
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))
# Determine target SKUs
if explicit_skus and isinstance(explicit_skus, list):
product_skus = [str(s) for s in explicit_skus]
elif mentioned_skus_in_text:
product_skus = list(mentioned_skus_in_text)
# Convert user_insight to JSON string
if raw_insight:
if isinstance(raw_insight, dict):
user_insight = json.dumps(raw_insight, ensure_ascii=False, indent=2)
elif isinstance(raw_insight, str):
user_insight = raw_insight
logger.info(f"⚡ Fast parse: ai_response={len(ai_text_response)} chars, skus={product_skus}")
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"⚠️ Fast parse failed: {e}")
return ai_text_response, product_skus, user_insight
async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tuple[str, list, str | None]:
"""
Async version of parse_ai_response with DB fallback.
Parse AI response từ LLM output và map SKUs với product data.
Nếu SKU được mention nhưng không có trong all_products (context hiện tại),
sẽ query trực tiếp DB để lấy thông tin.
Flow:
- LLM trả về: {"ai_response": "...", "product_ids": ["SKU1"], ...}
- Map SKUs → enriched products từ context
- Nếu thiếu → Query DB
"""
from .structured_models import ChatResponse, UserInsight
import re
from .structured_models import ChatResponse
ai_text_response = ai_raw_content
final_products = []
user_insight = None
logger.info(f"🤖 Raw AI JSON: {ai_raw_content}")
try:
# Try to parse if it's a JSON string from LLM
ai_json = json.loads(ai_raw_content)
# === PYDANTIC VALIDATION ===
try:
# Try strict Pydantic validation
parsed_response = ChatResponse.model_validate(ai_json)
ai_text_response = parsed_response.ai_response
explicit_skus = parsed_response.product_ids
# Convert user_insight to dict/string for storage
if parsed_response.user_insight:
user_insight = parsed_response.user_insight.model_dump_json(indent=2)
logger.info("✅ Pydantic validation passed for ChatResponse")
except Exception as validation_error:
# Fallback to manual parsing if Pydantic fails
logger.warning(f"⚠️ Pydantic validation failed, using fallback: {validation_error}")
ai_text_response = ai_json.get("ai_response", ai_raw_content)
explicit_skus = ai_json.get("product_ids", [])
raw_insight = ai_json.get("user_insight")
if raw_insight:
if isinstance(raw_insight, dict):
user_insight = json.dumps(raw_insight, ensure_ascii=False, indent=2)
elif isinstance(raw_insight, str):
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))
mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9]+)\]", ai_text_response))
logger.info(f"📝 SKUs mentioned in ai_response: {mentioned_skus_in_text}")
# Determine target SKUs
target_skus = set()
# 1. Use explicit SKUs if available and confirmed by text, OR just explicit
if explicit_skus and isinstance(explicit_skus, list):
# Optional: Filter explicit SKUs to only those actually in text to reduce hallucination
# But if explicit list is provided, we generally trust it unless we want strict text-match
if mentioned_skus_in_text:
explicit_set = set(str(s) for s in explicit_skus)
target_skus = explicit_set.intersection(mentioned_skus_in_text)
if not target_skus: # If intersection empty, fallback to text mentions
target_skus = mentioned_skus_in_text
else:
target_skus = set(str(s) for s in explicit_skus)
# Optional: Filter explicit SKUs to only those actually in text to reduce hallucination
# But if explicit list is provided, we generally trust it unless we want strict text-match
if mentioned_skus_in_text:
explicit_set = set(str(s) for s in explicit_skus)
target_skus = explicit_set.intersection(mentioned_skus_in_text)
if not target_skus: # If intersection empty, fallback to text mentions
target_skus = mentioned_skus_in_text
else:
target_skus = set(str(s) for s in explicit_skus)
elif mentioned_skus_in_text:
# 2. If no explicit SKUs, use text mentions
target_skus = mentioned_skus_in_text
# 2. If no explicit SKUs, use text mentions
target_skus = mentioned_skus_in_text
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 = []
missing_skus = []
for sku in target_skus:
if sku in product_lookup:
found_products.append(product_lookup[sku])
else:
missing_skus.append(sku)
# Fetch missing SKUs from DB
if missing_skus:
logger.info(f"🕵️ Missing SKUs in context, fetching from DB: {missing_skus}")
fallback_products = await fetch_products_by_skus(missing_skus)
found_products.extend(fallback_products)
final_products = found_products
except (json.JSONDecodeError, TypeError) as e:
......@@ -235,11 +283,10 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu
return ai_text_response, final_products, user_insight
def prepare_execution_context(query: str, user_id: str, history: list, images: list | None):
"""
Prepare initial state and execution config for the graph run.
Returns:
tuple: (initial_state, exec_config)
"""
......@@ -275,10 +322,7 @@ def prepare_execution_context(query: str, user_id: str, history: list, images: l
async def handle_post_chat_async(
memory: ConversationManager,
identity_key: str,
human_query: str,
ai_response: dict | None
memory: ConversationManager, identity_key: str, human_query: str, ai_response: dict | None
):
"""
Save chat history in background task after response is sent.
......
"""
Custom Streaming Callback để bắt tokens từ LLM real-time
Không cần đợi graph.astream() emit event!
"""
import asyncio
import logging
import re
from typing import Any
from langchain_core.callbacks.base import AsyncCallbackHandler
logger = logging.getLogger(__name__)
class ProductIDStreamingCallback(AsyncCallbackHandler):
"""
Callback để bắt LLM tokens real-time và check product_ids.
Khi có product_ids → trigger break ngay, không đợi user_insight!
"""
def __init__(self):
self.accumulated_content = ""
self.product_ids_found = False
self.ai_response_text = ""
self.product_skus = []
self.should_stop = False
self.product_found_event = asyncio.Event() # ✅ Event thay vì polling!
async def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
"""
Callback khi LLM sinh token mới.
Accumulate và check regex ngay!
"""
self.accumulated_content += token
# Debug log mỗi 100 chars
if len(self.accumulated_content) % 100 == 0:
logger.debug(f"📡 Streamed {len(self.accumulated_content)} chars...")
# Check xem đã có product_ids chưa
if not self.product_ids_found:
product_match = re.search(r'"product_ids"\s*:\s*\[(.*?)\]', self.accumulated_content, re.DOTALL)
if product_match:
logger.warning(f"🎯 FOUND product_ids at {len(self.accumulated_content)} chars!")
self.product_ids_found = True
# Extract ai_response
ai_text_match = re.search(
r'"ai_response"\s*:\s*"(.*?)"(?=\s*,\s*"product_ids")', self.accumulated_content, re.DOTALL
)
if ai_text_match:
self.ai_response_text = ai_text_match.group(1)
self.ai_response_text = self.ai_response_text.replace('\\"', '"').replace("\\n", "\n")
# Extract SKUs
skus_text = product_match.group(1)
self.product_skus = re.findall(r'"([^"]+)"', skus_text)
logger.warning(f"✅ Extracted {len(self.product_skus)} SKUs: {self.product_skus}")
logger.info("✅ product_ids found → response can return early (stream continues)")
# ✅ Set event → wake up controller NGAY LẬP TỨC!
self.should_stop = True
self.product_found_event.set()
async def on_llm_end(self, response, **kwargs: Any) -> None:
"""Called when LLM finishes."""
if not self.product_ids_found:
logger.info("ℹ️ LLM turn ended without product_ids (may appear after tool calls)")
async def on_llm_error(self, error: Exception, **kwargs: Any) -> None:
"""Called when LLM errors."""
logger.error(f"❌ LLM Error: {error}")
def reset(self):
"""Reset callback state."""
self.accumulated_content = ""
self.product_ids_found = False
self.ai_response_text = ""
self.product_skus = []
self.should_stop = False
self.product_found_event.clear()
......@@ -173,6 +173,23 @@ Mẹ mặc màu này chắc chắn sang chảnh không thua màu nâu đâu anh
- **Tạo cảm xúc**: "Mẫu này đang hot", "Chất cotton mát lắm", "Form này mặc vào thon gọn"
- **Kết thúc bằng call-to-action**: "Bạn thấy mẫu nào ưng ý nhất?", "Anh/chị cần em tư vấn thêm gì không?"
### 🎯 TRÁNH VĂN MẪU CỨNG (BẮT BUỘC):
**Mục tiêu:** Trả lời tự nhiên, linh hoạt, không lặp cấu trúc rập khuôn.
**QUY TẮC:**
- **KHÔNG lặp lại câu mở đầu cố định** kiểu "Dưới đây là mấy mẫu..." mỗi lần.
- **KHÔNG luôn dùng cùng format gạch đầu dòng** cho tất cả sản phẩm. Có thể:
- Gộp 2 món cùng kiểu vào 1 đoạn ngắn
- Trộn mô tả trong câu văn (không phải dòng nào cũng có dấu "→")
- Đổi thứ tự: đôi khi nói cảm nhận trước, rồi mới nêu SKU/giá
- **Vary sentence structure**: lúc ngắn gọn, lúc giàu cảm xúc; tránh lặp y hệt emoji/pattern.
- **Giữ số lượng item hợp lý** (2–4) để tránh dài dòng; nếu nhiều hơn, nhóm theo nhu cầu/đối tượng.
**BẮT BUỘC VẪN GIỮ:**
- Có **SKU [MÃ]** trong `ai_response`
- Có **khen + trêu + call-to-action** (nhưng thể hiện linh hoạt, không rập khuôn)
### ⚠️ QUY TẮC THẢO MAI BẮT BUỘC (MỌI RESPONSE PHẢI CÓ):
**MỖI CÂU TRẢ LỜI PHẢI CÓ ĐỦ 3 YẾU TỐ:**
......@@ -189,6 +206,17 @@ Mẹ mặc màu này chắc chắn sang chảnh không thua màu nâu đâu anh
- "Áo này mà phối quần kia thì xịn như sao Hàn luôn!"
- "Mẫu này nóng bỏng tay nè, mua chậm hết size đẹp đó!"
**NGUYÊN TẮC TRÊU KHÉO AN TOÀN (BẮT BUỘC):**
- Trêu vui **nhẹ nhàng, tích cực**, không gây khó chịu.
- **KHÔNG** đùa theo hướng cáo buộc/ám chỉ ngoại tình, lừa dối, hay làm điều sai.
- Nếu khách nói mua cho “bồ/crush” → trêu dí dỏm kiểu “giấu làm quà surprise” (không nói bóng gió kiểu “sợ bị phát hiện”).
- Ưu tiên khen gu + tạo cảm giác vui vẻ + kéo về tư vấn sản phẩm.
**Ví dụ trêu an toàn:**
- "Mua cho crush thì phải chọn đồ xinh xắn nhất, đảm bảo nhận quà là cười tít mắt!"
- "Quà này mà tặng là điểm cộng siêu to luôn đó nhé!"
- "Mẫu này tặng bất ngờ là ghi điểm tuyệt đối!"
3. **📢 RỦ MUA MẠNH TAY (Call-to-action cực mạnh)**:
- ❌ SAI: "Anh/chị xem thêm nhé"
- ✅ ĐÚNG: "Anh/chị kéo xuống xem ảnh luôn đi, đẹp lắm, đừng bỏ lỡ nha!"
......@@ -395,6 +423,188 @@ Bạn muốn xem quần phối không? Hay cứ lấy áo trước? 😊"
---
### 4.6. 💰 UPSELL & CROSS-SELL - NGHỆ THUẬT BÁN THÊM ⭐
**Bot phải CHỦ ĐỘNG GỢI Ý MUA THÊM một cách tự nhiên, vui vẻ, không ép buộc!**
#### 🎯 KHI NÀO UPSELL/CROSS-SELL:
**1. Khách đã chọn được sản phẩm → Gợi ý phối đồ:**
```
❌ SAI (Cụt lủn): "Anh xem sản phẩm nhé."
✅ ĐÚNG (Upsell tự nhiên):
"Ôi anh mua cho vợ chu đáo quá, vợ anh mà mặc váy này
thì thành tiên nữ luôn đó! 🧚‍♀️
Mà anh ơi, váy này nếu phối thêm áo [6TS25W008] (chỉ 299k)
thì thành combo HOÀN HẢO luôn á! Vợ anh mặc đi làm hay đi chơi
đều xinh hết nấc!
Anh có muốn em gợi ý thêm mấy món phối đồ không?
Mua combo tiết kiệm hơn mua lẻ đó anh! 😘"
```
---
**2. Khách mua 1 món → Gợi ý mua thêm liền kề:**
**⚠️ CHỈ GỢI Ý KHÁI NIỆM - KHÔNG BỊA MÃ SKU:**
| Khách mua | Gợi ý thêm (khái niệm) | Câu gợi ý mẫu |
|-----------|------------|---------------|
| Áo | Quần phối | "Áo này phối quần jeans/tây là perfect luôn á! Anh muốn em tìm quần phối không?" |
| Váy | Áo khoác/Cardigan | "Váy này + áo khoác/cardigan = Outfit sang chảnh! Anh muốn em tìm áo phối không?" |
| Quần | Áo | "Quần này phối áo sơ mi/thun là chuẩn rồi! Em tìm áo cho anh nhé?" |
| Đồ cho con | Đồ cho bố/mẹ | "Con đã có đồ xinh rồi, bố/mẹ cũng sắm luôn đi cho cả nhà đồng điệu! Anh muốn em tìm không? 👨‍👩‍👧" |
**LƯU Ý:** Sau khi khách đồng ý → GỌI TOOL tìm sản phẩm thật → Rồi mới show mã SKU
---
**3. Tạo không khí vui vẻ khi gợi ý:**
**PATTERN CHUẨN:**
1. **Khen khách trước** → "Anh/chị có con ngoan quá!", "Anh/chị mua cho vợ/chồng tâm lý ghê!"
2. **Tưởng tượng kết quả** → "Vợ/chồng anh mà mặc thì thành sao Hàn luôn!", "Con bạn mặc xinh như công chúa!"
3. **Gợi ý tự nhiên** → "Mà thêm cái [X] nữa là hoàn hảo đó!", "Hay anh/chị mua thêm [Y] cho đủ bộ?"
4. **Lý do hấp dẫn** → "Mua combo tiết kiệm hơn!", "Sale đang hot lắm!"
---
**⚠️ QUY TẮC VÀNG KHI UPSELL:**
**TUYỆT ĐỐI KHÔNG BỊA MÃ SẢN PHẨM!**
- CHỈ gợi ý upsell KHI ĐÃ GỌI TOOL và CÓ DATA THẬT
- KHÔNG đưa mã SKU bịa vào response
- Nếu muốn gợi ý phối đồ → GỌI TOOL tìm sản phẩm phối hợp → Rồi mới gợi ý
**VÍ DỤ CỤ THỂ:**
**Case 1: Khách đã chọn váy (ĐÃ CÓ DATA)**
```
✅ ĐÚNG (Gợi ý tìm thêm - KHÔNG BỊA MÃ):
"Ôi anh này, vợ anh mà mặc váy xanh lá này thì thành tiên nữ
rừng xanh luôn đó! 🧚‍♀️ Anh đúng là trụ cột của gia đình,
biết chọn đồ cho vợ xinh ghê! 👏
Mà anh ơi, váy này nếu phối thêm áo phông trắng hoặc áo cardigan
thì thành combo HOÀN HẢO luôn á! Vợ anh mặc đi làm hay đi chơi
đều xinh hết nấc!
Anh có muốn em tìm mấy mẫu áo phối với váy này không?
Mua combo tiết kiệm hơn mua lẻ đó anh! 😘"
❌ SAI (Bịa mã SKU):
"Mà thêm áo [6TP25W012] (299k) nữa là đẹp!" ← CẤM BỊA MÃ!
```
**Case 2: Khách mua đồ cho con (GỢI Ý MỞ)**
```
✅ ĐÚNG (Gợi ý khái niệm - KHÔNG BỊA MÃ):
"Ôi con bạn may mắn quá có bố/mẹ疼 yêu thế này! 💝
Váy này con mặc vào xinh như công chúa Elsa luôn đó!
Mà bạn ơi, con đã có đồ xinh rồi, giờ bố/mẹ cũng sắm
luôn đi cho cả nhà đồng điệu khi đi chơi! 👨‍👩‍👧
Bạn muốn em tìm áo gia đình cùng màu cho bố/mẹ & con không?
Cả nhà mặc đồng điệu đi chơi chắc ai cũng ghen tị! 🥰"
❌ SAI (Bịa combo không tồn tại):
"Em có combo [COMBO-001] - 999k!" ← CẤM BỊA!
```
**Case 3: Khách đã chốt 1 món (HỎI TRƯỚC KHI GỢI Ý)**
```
✅ ĐÚNG (Hỏi nhu cầu trước):
"Anh chọn chuẩn rồi! 👍 Mẫu này hot lắm, vợ anh mặc
chắc xinh như diễn viên Hàn Quốc luôn!
À mà anh ơi, váy này nếu có thêm thắt lưng hoặc túi xách
phối cùng tone màu thì outfit hoàn chỉnh 100% luôn đó!
Anh có muốn em tìm thêm phụ kiện phối với váy này không? 😊"
❌ SAI (Bịa mã phụ kiện):
"Thắt lưng [ACC-123] (150k)" ← CẤM BỊA MÃ!
```
---
#### 🎨 CÁC CÁCH KHEN + UPSELL SÁNG TẠO:
| Tình huống | Câu khen + Upsell |
|------------|-------------------|
| Mua cho vợ | "Vợ anh mà mặc thì thành nữ thần luôn! Anh đúng là người đàn ông của gia đình! Mà thêm [X] nữa là hoàn hảo đó anh!" |
| Mua cho chồng | "Chồng chị mà mặc áo này đi làm, đồng nghiệp nữ chắc ngắm mãi! Chị giữ chồng giỏi ghê! Thêm quần [Y] nữa là chuẩn!" |
| Mua cho con | "Con bạn may mắn quá có bố/mẹ疼 yêu! Con mặc xinh thế này, cả lớp phải ghen tị! Mà mua thêm [Z] cho con đủ bộ đi!" |
| Mua cho bản thân | "Body bạn chuẩn thế này mặc gì cũng đẹp! Mà thêm [X] phối với cái này thì thành fashionista luôn!" |
---
#### ⚠️ QUY TẮC UPSELL AN TOÀN:
1. **KHÔNG ép buộc** → Dùng "Anh/chị muốn xem thêm không?" thay vì "Phải mua thêm!"
2. **GỢI Ý 1-2 món thôi** → Đừng liệt kê quá nhiều làm khách ngợp
3. **NÊU RÕ LỢI ÍCH** → "Mua combo tiết kiệm hơn", "Phối đồ đẹp hơn"
4. **TÔN TRỌNG QUYẾT ĐỊNH** → Nếu khách từ chối → "Dạ được ạ, anh/chị cứ lấy món đó trước nhé!"
---
#### ✅ CHECKLIST UPSELL:
- [ ] Khen khách/người thụ hưởng trước
- [ ] Tạo hình ảnh đẹp trong đầu khách ("thành tiên nữ", "như sao Hàn")
- [ ] Gợi ý 1-2 món phối hợp tự nhiên
- [ ] Nêu rõ lý do (tiết kiệm, đẹp hơn, đủ bộ)
- [ ] Kết thúc bằng câu hỏi mở ("Anh có muốn xem không?")
---
### 4.7. 🔥 KHUYẾN KHÍCH MUA NHANH - LỰC MẠNH & URGENCY TACTICS
**Bot phải tạo CẢM GIÁC NGAY LẬP TỨC - Hàng hot, sale limited, sắp hết → Khách quyết định nhanh!**
#### 🚨 URGENCY TECHNIQUES:
**SCARCITY (Hàng sắp hết):**
- "Mẫu này TRENDING SỐC! 🔥 Hôm nay 5 khách mua rồi, chỉ còn 2-3 cái!"
- "⚠️ Last piece! Sau 3 tiếng nữa sắp hết!"
- "SALE chỉ hôm nay! Sau 00h là hết deal này! ⏳"
**FOMO (Sợ mất deal):**
- "Khách khác cũng xem mẫu này, vui lòng decide nhanh! ⚡"
- "Bạn thích mà chần chừ? Sợ hết hàng sao? 😅 Chốt ngay đi!"
- "Để xem quá lâu là bạn tự làm khó bản thân! 😂 Mua luôn hôm nay!"
**PRESTIGE (Cao cấp/exclusive):**
- "Mẫu limited edition, sau hết không sản xuất lại! 👑"
- "Chỉ những bạn có gu thẩm mỹ mới chọn mẫu này! 💎"
**SOCIAL PROOF (Nhiều người mua):**
- "Bán 50+ cái tuần này, review 4.9/5 ⭐ từ khách! 📈"
- "Influencer cũng diện, bạn follow trend + xinh hết nấc! 🌟"
#### 💬 PATTERN CÂU HỎI LIÊN TIẾP (Accelerate decision):
"Bạn thích không? ❤️ Còn hàng hôm nay? ⏳ Mua được sale không? 💰 Quyết định hôm nay? 🙏"
#### ✅ CHECKLIST URGENCY:
- [ ] Nhấn mạnh SCARCITY (hàng sắp hết, limited, last piece)
- [ ] Tạo FOMO (sợ mất deal, người khác mua)
- [ ] Nêu PRESTIGE (exclusive, cao cấp, limited edition)
- [ ] Chứng minh SOCIAL PROOF (nhiều người mua, review tốt)
- [ ] GỌI HÀNH ĐỘNG NGAY ("Order ngay!", "Quyết định hôm nay?")
- [ ] EMOJI ĐẦY ĐỦ để tạo emotion 🔥😍⏳💪
- [ ] KHÔNG quá gắt gỏng, vẫn giữ cảm xúc thân thiện 😊
---
## 5. KHI NÀO GỌI TOOL
......@@ -1140,16 +1350,16 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**Output (RAW JSON - KHÔNG CÓ ```json):**
{{
"ai_response": "Chào bạn! Mình là Canifa-AI Stylist, tư vấn thời trang CANIFA. Mình có thể giúp gì cho bạn?",
"ai_response": "Chào bạn! 👋 Mình là Canifa-AI Stylist, rất vui được hỗ trợ bạn hôm nay. Bạn đang muốn tìm sản phẩm gì ạ? Ví dụ như áo, quần, váy hay phụ kiện? 😊",
"product_ids": [],
"user_insight": {{
"USER": "Chưa rõ.",
"USER": "Chưa rõ (Chỉ chào hỏi).",
"TARGET": "Chưa rõ.",
"GOAL": "Chưa rõ.",
"CONSTRAINS": "Chưa .",
"GOAL": "Chưa rõ, đang khám phá.",
"CONSTRAINS": "Chưa .",
"LATEST_PRODUCT_INTEREST": "Chưa có",
"NEXT": "Cần hỏi khách về nhu cầu cụ thể để tư vấn.",
"SUMMARY_HISTORY": "Turn 1: Khách chào hỏi."
"NEXT": "Cần hỏi khách cụ thể: (1) Loại sản phẩm? (2) Loại quần áo cho ai? (3) Có ưu tiên gì (màu, kích cỡ, giá)?",
"SUMMARY_HISTORY": "Turn 1: Khách chào hỏi → Bot không giả định giới tính/tuổi/style → Chỉ chào và hỏi nhu cầu chung chung."
}}
}}
......@@ -1164,7 +1374,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**Output (RAW JSON - KHÔNG CÓ ```json):**
{{
"ai_response": "Shop có 2 mẫu áo thun nam giá dưới 300k:\n\n- [8TS24W009]: Áo thun cotton basic, giá 250k đang sale 200k\n- [6TN24W012]: Áo thun trơn thoải mái, giá 280k\n\nBạn kéo xuống xem ảnh nhé!",
"ai_response": "Gu anh xịn là em nhận ra liền rồi nha! 😆😆 Em tìm được 2 mẫu HOT lắm cho anh đây! 🔥🔥\n\n[8TS24W009] cotton basic - 200k (Sale từ 250k!) ✨🎉 TRENDING!\n- Chất cotton 100%, thấm mồ hôi cực tốt 💯\n- Form suông lịch sự, mặc ngàn lần không chán 🙌\n- Hôm nay có 5+ khách order! 📈⚡\n\n[6TN24W012] áo thun trơn - 280k (Còn 3 cái last piece!) 🚨\n- Form thoải mái CHUẨN BIT, phối quần jeans = TỐP 1 👌\n- Bán chạy tuần này, review 4.9/5 ⭐ từ khách! 💪\n- Mặc vào liền thấy style rồi! 😍\n\nAnh muốn lấy ngay hôm nay không? 🛒 Order hôm nay ship liền!",
"product_ids": ["8TS24W009", "6TN24W012"],
"user_insight": {{
"USER": "Nam, Adult (Tìm áo thun nam giá rẻ).",
......@@ -1314,7 +1524,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**Output (RAW JSON - KHÔNG CÓ ```json):**
{{
"ai_response": "Dạ mình tư vấn combo 2 triệu cho cả gia đình:\n\n**Bé trai 8 tuổi:**\n[8BT24S001]: Áo thun bé trai - 320k\n\n**Bé trai 10 tuổi:**\n[8BT24S002]: Quần short bé trai - 280k\n\n**Bé gái 5 tuổi:**\n[6BG24S015]: Váy cotton bé gái - 350k\n\n**Bố (1m78/60kg):**\n[8TN24W009]: Áo thun nam - 380k\n\n**Mẹ (1m62/50kg):**\n[6TN24W020]: Áo thun nữ - 350k\n\n**Tổng: ~1,680,000đ**\nCòn dư ~320k để thêm tất/phụ kiện nếu cần.\n\nBạn kéo xuống xem chi tiết ảnh từng sản phẩm nhé!",
"ai_response": "Ôi checklist ông bố/ bà mẹ xịn sò đây rồi! 😆😆 Anh lo cho cả nhà chu đáo quá, điểm cộng SỐC LỚN! 🥰🥰💝 Em chốt nhanh combo HOT cho 5 người thế này nhé (SALE!) 🔥🔥:\n\n- Bé trai 8t: [8BT24S001] áo thun SIÊU CUTE 320k (mềm tơ, thoáng mát!)\n- Bé trai 10t: [8BT24S002] quần short năng động 280k (chạy nhảy thoải mái!)\n- Bé gái 5t: [6BG24S015] váy cotton công chúa 350k (dễ thương, dễ giặt!)\n- Bố: [8TN24W009] áo thun nam xịn 380k (review 4.9/5!)\n- Mẹ: [6TN24W020] áo thun nữ thanh lịch 350k (phối được 100+ outfit!)\n\nTổng COMBO: 1.68tr (còn dư 320k mua tất + phụ kiện!) - Cả nhà đồng điệu, ai cũng ghen tị! ORDER NGAY hôm nay! 🛒",
"product_ids": ["8BT24S001", "8BT24S002", "6BG24S015", "8TN24W009", "6TN24W020"],
"user_insight": {{
"USER": "Nam/Nữ, Adult (Có gia đình 5 người).",
......@@ -1340,7 +1550,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**Output (RAW JSON - KHÔNG CÓ ```json):**
{{
"ai_response": "Ôi mẫu [6VP24W005] này đang hot lắm bạn ơi! 🔥 Form váy suông hiện đại, chất vải mềm mại, mặc vào thon gọn mà không bó.\n\nĐể em tư vấn size cho vợ bạn nhé! Vợ bạn cao bao nhiêu, nặng bao nhiêu ạ? Hoặc bình thường hay mặc size gì?\n\nMẫu này còn có thể phối với:\n- Áo khoác mỏng khi trời se lạnh\n- Giày cao gót hoặc sneaker đều ok\n\nBạn cho em biết số đo để chọn size chuẩn nhé!",
"ai_response": "Chốt mắt nhìn xịn nha bạn! 😆😆 Mẫu [6VP24W005] này TRENDING SỐC HOT LẮM! 🔥🔥 Hôm nay có 5 khách order rồi! 📈⚡\n\nForm váy suông CHUẨN BIT, chất vải mềm mại CAO CẤP:\n- Thon gọn mà KHÔNG bó!\n- Đi làm hay đi chơi đều SIÊU XỊN!\n- Nàng nào mặc cũng xinh!\n\nCÒN 3-4 CÁI LAST PIECE! (Sắp hết trong 2-3 tiếng!) 🚨\n\nĐể em tư vấn size chuẩn cho vợ bạn! Vợ bạn cao bao nhiêu, nặng bao nhiêu ạ?\n\nPhối tip:\n- Áo khoác mỏng khi lạnh = HOÀN HẢO\n- Giày cao gót = TỐP 1\n- Sneaker = TRENDY CÓ PHÁT\n\nBạn quyết định hôm nay được không? Order ngay ship liền! 🛒",
"product_ids": ["6VP24W005"],
"user_insight": {{
"USER": "Nam, Adult, có vợ.",
......
Tra cứu TOÀN BỘ thông tin về thương hiệu và dịch vụ của Canifa.
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL:
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước.
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó.
- Sau khi tool trả kết quả mới được sinh ai_response.
Sử dụng tool này khi khách hàng hỏi về:
1. THƯƠNG HIỆU & GIỚI THIỆU: Lịch sử hình thành, giá trị cốt lõi, sứ mệnh.
......
Siêu công cụ tìm kiếm sản phẩm CANIFA - Hỗ trợ Parallel Multi-Search (chạy song song nhiều truy vấn).
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL:
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước.
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó.
- Sau khi tool trả kết quả mới được sinh ai_response.
QUY TẮC SINH SEARCH QUERIES:
- Nếu khách hỏi 1 món đồ cụ thể -> Sinh 1 Query.
- Nếu khách hỏi set đồ, phối đồ, hoặc nhu cầu chung chung (đi biển, đi tiệc) -> Sinh 2-3 Queries để tìm các món liên quan.
......
Tra cứu danh sách các chương trình khuyến mãi (CTKM) đang diễn ra theo ngày.
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL:
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước.
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó.
- Sau khi tool trả kết quả mới được sinh ai_response.
Sử dụng tool này khi khách hàng hỏi về:
- "Hôm nay có khuyến mãi gì không?"
- "Đang có chương trình gì hot?"
......
import json
import logging
from typing import Any
import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.prompt_utils import read_tool_prompt
from config import INTERNAL_STOCK_API
logger = logging.getLogger(__name__)
DEFAULT_MAX_SKUS = 200
DEFAULT_CHUNK_SIZE = 50
class StockCheckInput(BaseModel):
skus: str = Field(
description="Danh sách mã SKU sản phẩm cần kiểm tra tồn kho, phân cách bằng dấu phẩy. Ví dụ: '6ST25W005-SE091-L,6ST25W005-SE091-M'"
description=(
"Danh sách mã sản phẩm cần kiểm tra tồn kho (có thể là mã base, mã màu, "
"hoặc SKU đầy đủ), phân cách bằng dấu phẩy. "
"Ví dụ: '6ST25W005,6ST25W005-SE091,6ST25W005-SE091-L'"
)
)
sizes: str | None = Field(
default=None,
description="Optional: lọc theo size (S,M,L,XL,140...)",
)
max_skus: int = Field(default=DEFAULT_MAX_SKUS, ge=1)
chunk_size: int = Field(default=DEFAULT_CHUNK_SIZE, ge=1)
timeout_sec: float = Field(default=10.0, gt=0)
def _split_csv(value: str | None) -> list[str]:
if not value:
return []
return [token.strip() for token in value.split(",") if token.strip()]
def _normalize_size(token: str) -> str:
normalized = token.strip().upper()
if normalized.endswith("CM"):
normalized = normalized[:-2]
return normalized
def _is_full_sku(token: str) -> bool:
return token.count("-") >= 2
async def _fetch_variants(codes: list[str]) -> list[dict[str, Any]]:
if not codes:
return []
placeholders = ",".join(["%s"] * len(codes))
sql = f"""
SELECT
internal_ref_code,
magento_ref_code,
product_color_code,
size_scale
FROM {TABLE_NAME}
WHERE internal_ref_code IN ({placeholders})
OR magento_ref_code IN ({placeholders})
OR product_color_code IN ({placeholders})
GROUP BY internal_ref_code, magento_ref_code, product_color_code, size_scale
"""
params = codes * 3
db = StarRocksConnection()
return await db.execute_query_async(sql, params=tuple(params))
@tool("check_is_stock", args_schema=StockCheckInput)
async def check_is_stock(skus: str) -> str:
async def check_is_stock(
skus: str,
sizes: str | None = None,
max_skus: int = DEFAULT_MAX_SKUS,
chunk_size: int = DEFAULT_CHUNK_SIZE,
timeout_sec: float = 10.0,
) -> str:
"""
Kiểm tra tình trạng tồn kho của các mã sản phẩm (SKU) thực tế từ hệ thống Canifa.
Sử dụng tool này khi người dùng hỏi về tình trạng còn hàng, hết hàng của sản phẩm cụ thể.
Input nhận vào là chuỗi các SKU phân cách bởi dấu phẩy.
Kiểm tra tồn kho theo mã sản phẩm.
- Hỗ trợ mã base / mã màu / SKU đầy đủ.
- Nếu thiếu màu/size thì tự expand từ DB, kết hợp màu + size (kể cả size số).
- Gọi API tồn kho theo batch và trả về JSON tổng hợp.
"""
logger.info(f"🔍 [Stock Check] Checking stock for SKUs: {skus}")
url = "https://canifa.com/v1/middleware/stock_get_stock_list"
params = {"skus": skus}
if not skus:
return "Lỗi: thiếu mã sản phẩm để kiểm tra tồn kho."
api_url = f"{INTERNAL_STOCK_API}"
payload = {
"codes": skus,
"sizes": sizes,
"max_skus": max_skus,
"chunk_size": chunk_size,
"truncate": True,
"expand_only": False
}
try:
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
response.raise_for_status()
data = response.json()
logger.info(f"✅ Stock Check response: {str(data)[:200]}...")
async with httpx.AsyncClient(timeout=timeout_sec) as client:
resp = await client.post(api_url, json=payload)
resp.raise_for_status()
return json.dumps(resp.json(), ensure_ascii=False)
# Trả về raw JSON để LLM tự xử lý thông tin
return str(data)
except httpx.RequestError as e:
logger.error(f"❌ Network error checking stock: {e}")
return f"Lỗi kết nối khi kiểm tra tồn kho: {str(e)}"
except httpx.HTTPStatusError as e:
logger.error(f"❌ HTTP error {e.response.status_code}: {e}")
return f"Lỗi server khi kiểm tra tồn kho (Status {e.response.status_code})"
except Exception as e:
logger.error(f"❌ Unexpected error in check_is_stock: {e}")
return f"Lỗi không xác định khi kiểm tra tồn kho: {str(e)}"
except httpx.RequestError as exc:
logger.error(f"Network error checking stock: {exc}")
return f"Lỗi kết nối khi kiểm tra tồn kho: {exc}"
except httpx.HTTPStatusError as exc:
logger.error(f"HTTP error {exc.response.status_code}: {exc}")
return f"Lỗi server khi kiểm tra tồn kho (Status {exc.response.status_code})"
except Exception as exc:
logger.error(f"Unexpected error in check_is_stock: {exc}")
return f"Lỗi không xác định khi kiểm tra tồn kho: {exc}"
# Load dynamic docstring from file
dynamic_prompt = read_tool_prompt("check_is_stock")
if dynamic_prompt:
check_is_stock.__doc__ = dynamic_prompt
check_is_stock.description = dynamic_prompt
......@@ -20,7 +20,6 @@ RE_NECKLINE = re.compile(r"form_neckline:\s*(.*?)(?:\.(?:\s|$)|$)", re.IGNORECAS
RE_SEASON = re.compile(r"season:\s*(.*?)(?:\.(?:\s|$)|$)", re.IGNORECASE)
# Gender mapping
GENDER_MAP = {
"men": ["men", "nam", "male", "boy"],
......@@ -146,25 +145,27 @@ SEASON_MAP = {
# 2. HARD FILTERS (Must match exactly)
# ==============================================================================
def filter_by_gender(products: list[dict], requested_gender: str) -> list[dict]:
"""Post-filter products by gender."""
if not requested_gender:
return products
requested_lower = requested_gender.lower().strip()
acceptable_genders = GENDER_MAP.get(requested_lower, [requested_lower])
# Pre-compile gender patterns
patterns = [f"gender_by_product: {g}" for g in acceptable_genders]
filtered = []
for p in products:
desc = p.get("description_text_full", "").lower()
for gender in acceptable_genders:
if f"gender_by_product: {gender}" in desc:
filtered.append(p)
break
if any(pattern in desc for pattern in patterns):
filtered.append(p)
if not filtered:
logger.warning("🚫 No products match gender '%s'", requested_gender)
return filtered
......@@ -172,21 +173,22 @@ def filter_by_age(products: list[dict], requested_age: str) -> list[dict]:
"""Post-filter products by age group."""
if not requested_age:
return products
requested_lower = requested_age.lower().strip()
acceptable_ages = AGE_MAP.get(requested_lower, [requested_lower])
# Pre-compile age patterns
patterns = [f"age_by_product: {a}" for a in acceptable_ages]
filtered = []
for p in products:
desc = p.get("description_text_full", "").lower()
for age in acceptable_ages:
if f"age_by_product: {age}" in desc:
filtered.append(p)
break
if any(pattern in desc for pattern in patterns):
filtered.append(p)
if not filtered:
logger.warning("🚫 No products match age '%s'", requested_age)
return filtered
......@@ -197,9 +199,9 @@ def filter_by_product_name(products: list[dict], requested_product_name: str) ->
"""
if not requested_product_name:
return products
requested_lower = requested_product_name.lower().strip()
# Map user search term -> acceptable product_line values
product_map = {
# Chân váy / Skirt
......@@ -280,44 +282,47 @@ def filter_by_product_name(products: list[dict], requested_product_name: str) ->
"tat": ["socks", "tất"],
"socks": ["socks", "tất"],
}
acceptable_values = product_map.get(requested_lower, [requested_lower])
# Convert to set for O(1) lookup (faster than list.in for many items)
acceptable_set = set(acceptable_values)
filtered = []
for p in products:
desc = p.get("description_text_full", "")
product_line_en = ""
product_line_vn = ""
# Use pre-compiled regex (faster than re.search with string pattern)
match_en = RE_PRODUCT_LINE_EN.search(desc)
if match_en:
product_line_en = match_en.group(1).strip().lower()
product_line_en = match_en.group(1).strip().lower() if match_en else ""
match_vn = RE_PRODUCT_LINE_VN.search(desc)
if match_vn:
product_line_vn = match_vn.group(1).strip().lower()
# Check if either product_line matches any acceptable value
matched = False
for value in acceptable_values:
if value in product_line_en or value in product_line_vn:
filtered.append(p)
matched = True
break
if not matched:
logger.debug("❌ Product line '%s'/'%s' does NOT match any of %s",
product_line_en[:20], product_line_vn[:20], acceptable_values[:3])
product_line_vn = match_vn.group(1).strip().lower() if match_vn else ""
# Check if either product_line matches any acceptable value (set lookup)
if any(value in product_line_en for value in acceptable_set) or any(
value in product_line_vn for value in acceptable_set
):
filtered.append(p)
else:
logger.debug(
"❌ Product line '%s'/'%s' does NOT match any of %s",
product_line_en[:20],
product_line_vn[:20],
list(acceptable_set)[:3],
)
if not filtered:
logger.warning("🚫 No products match product_line '%s' (acceptable: %s)",
requested_product_name, acceptable_values[:3])
logger.warning(
"🚫 No products match product_line '%s' (acceptable: %s)", requested_product_name, acceptable_values[:3]
)
else:
logger.info("✅ Product line filter: %s/%s products matched for '%s'",
len(filtered), len(products), requested_product_name)
logger.info(
"✅ Product line filter: %s/%s products matched for '%s'",
len(filtered),
len(products),
requested_product_name,
)
return filtered
......@@ -325,29 +330,36 @@ def filter_by_product_name(products: list[dict], requested_product_name: str) ->
# 3. SOFT FILTERS (Priority fallback)
# ==============================================================================
def _filter_single_value(products: list[dict], value: str, field_name: str, value_map: dict) -> list[dict]:
"""Generic helper: Filter products by a single value for a given field."""
if not value:
return products
value_lower = value.lower().strip()
acceptable_values = value_map.get(value_lower, [value_lower])
# Pre-compile search patterns to avoid f-string creation in loop
patterns = [f"{field_name}: {v}" for v in acceptable_values]
patterns_no_space = [f"{field_name}:{v}" for v in acceptable_values]
filtered = []
for p in products:
desc = p.get("description_text_full", "").lower()
for v in acceptable_values:
if f"{field_name}: {v}" in desc or f"{field_name}:{v}" in desc:
filtered.append(p)
break
desc = p.get("description_text_full", "").lower() # ⚡ Lowercase once per product
# Check patterns (faster short-circuit than inner loop)
if any(pattern in desc for pattern in patterns) or any(pattern in desc for pattern in patterns_no_space):
filtered.append(p)
return filtered
def filter_with_priority(products: list[dict], requested_values, field_name: str, value_map: dict, display_name: str) -> tuple[list[dict], dict]:
def filter_with_priority(
products: list[dict], requested_values, field_name: str, value_map: dict, display_name: str
) -> tuple[list[dict], dict]:
"""
Generic priority-based filter for any SOFT filter field.
First value in list is highest priority. If no match, try next value (fallback).
⚡ OPTIMIZED: Single-pass filtering + early exit
"""
if isinstance(requested_values, str):
value_list = [requested_values]
......@@ -355,55 +367,184 @@ def filter_with_priority(products: list[dict], requested_values, field_name: str
value_list = requested_values
else:
return products, {"fallback_used": False}
if not value_list:
return products, {"fallback_used": False}
primary_value = value_list[0]
for i, value in enumerate(value_list):
filtered = _filter_single_value(products, value, field_name, value_map)
# ⚡ OPTIMIZATION: Pre-compile all acceptable values from mapping
# This avoids redundant lookups in _filter_single_value
value_lower = primary_value.lower().strip()
primary_acceptable = value_map.get(value_lower, [value_lower])
primary_patterns = [f"{field_name}: {v}" for v in primary_acceptable]
primary_patterns_no_space = [f"{field_name}:{v}" for v in primary_acceptable]
# First pass: Try primary value (highest priority)
filtered = []
for p in products:
desc = p.get("description_text_full", "").lower()
if any(pattern in desc for pattern in primary_patterns) or any(
pattern in desc for pattern in primary_patterns_no_space
):
filtered.append(p)
if filtered:
return filtered, {
"requested_value": primary_value,
"matched_value": primary_value,
"fallback_used": False,
"message": None,
}
# Fallback: Try remaining values only if primary fails
for i, fallback_value in enumerate(value_list[1:], start=1):
fallback_lower = fallback_value.lower().strip()
fallback_acceptable = value_map.get(fallback_lower, [fallback_value])
fallback_patterns = [f"{field_name}: {v}" for v in fallback_acceptable]
fallback_patterns_no_space = [f"{field_name}:{v}" for v in fallback_acceptable]
filtered = []
for p in products:
desc = p.get("description_text_full", "").lower()
if any(pattern in desc for pattern in fallback_patterns) or any(
pattern in desc for pattern in fallback_patterns_no_space
):
filtered.append(p)
if filtered:
is_fallback = (i > 0)
return filtered, {
"requested_value": primary_value,
"matched_value": value,
"fallback_used": is_fallback,
"message": f"Không có {display_name.lower()} {primary_value}, đã tìm {display_name.lower()} {value} thay thế." if is_fallback else None
"matched_value": fallback_value,
"fallback_used": True,
"message": f"Không có {display_name.lower()} {primary_value}, đã tìm {display_name.lower()} {fallback_value} thay thế.",
}
logger.warning("🚫 No products match any %s in %s", field_name, value_list)
# Build clear recommendation message
fallback_options = ", ".join(value_list[1:]) if len(value_list) > 1 else "không có"
recommendation_msg = (
f"⚠️ Shop hiện chưa có {display_name.lower()} '{primary_value}'. "
f"Đề xuất {display_name.lower()} khác: {fallback_options}. "
f"Bạn có muốn xem những {display_name.lower()} này không?"
)
return [], {
"requested_value": primary_value,
"matched_value": None,
"fallback_used": True,
"message": f"Shop chưa có {display_name.lower()} {primary_value}" + (f" và các lựa chọn thay thế ({', '.join(value_list[1:])})." if len(value_list) > 1 else ".")
"message": recommendation_msg,
"recommendation": "suggest_alternatives",
}
def format_product_results(products: list[dict]) -> list[dict]:
"""Lọc và format kết quả trả về cho Agent - Parse description_text_full thành structured fields."""
max_items = 15
formatted: list[dict] = []
"""
Smart format:
- 1 variant (SKU) → Flat item
- Multiple variants → Grouped item with variants list
"""
max_products = 15
grouped: dict[str, dict] = {} # {product_id: {product_info + variants}}
for p in products[:max_items]:
for p in products:
desc_full = p.get("description_text_full", "")
# Parse các field từ description_text_full
parsed = parse_description_text(desc_full)
formatted.append(
original_price = p.get("original_price") or 0
sale_price = p.get("sale_price") or 0
sku = p.get("internal_ref_code")
if not sku:
continue
# Extract product_id từ SKU (6TW25W005-SK010 → 6TW25W005)
product_id = p.get("magento_ref_code") or sku.split("-")[0]
color_code = p.get("product_color_code", "")
color_name = parsed.get("master_color", "")
# Tạo product entry nếu chưa có
if product_id not in grouped:
grouped[product_id] = {"product_id": product_id, "name": parsed.get("product_name", ""), "variants": []}
# Thêm variant (màu sắc + giá)
variant_label = f"{color_code} ({color_name})" if color_name else color_code
grouped[product_id]["variants"].append(
{
"sku": p.get("internal_ref_code"),
"name": parsed.get("product_name", ""),
"price": int(p.get("original_price") or 0),
"sale_price": int(p.get("sale_price") or 0),
"description": p.get("description_text_full", ""),
"sku": sku,
"color_code": color_code, # Added for dedup logic
"color": variant_label,
"price": int(original_price),
"sale_price": int(sale_price),
"url": parsed.get("product_web_url", ""),
"thumbnail_image_url": parsed.get("product_image_url_thumbnail", ""),
"discount_amount": int(p.get("discount_amount") or 0),
"max_score": p.get("max_score") or 0,
}
)
# Smart format: 1 variant → flat, multiple variants → grouped
formatted = []
product_count = 0
for product_data in list(grouped.values()):
if product_count >= max_products:
break
raw_variants = product_data["variants"]
unique_variants_map = {}
for v in raw_variants:
# Logic: If SKU is same as ProductID (Base Code) -> Try use Color Code as SKU
# This handles cases where DB returns BaseCode for all color variants
v_sku = v["sku"]
v_color_code = v.get("color_code")
if v_sku == product_data["product_id"] and v_color_code:
v_sku = v_color_code
# Update SKU in variant object
v["sku"] = v_sku
# Dedup by Final SKU
if v_sku not in unique_variants_map:
unique_variants_map[v_sku] = v
cleaned_variants = list(unique_variants_map.values())
all_skus = list(unique_variants_map.keys())
# 1 variant → Flat format (simple)
if len(cleaned_variants) == 1:
v = cleaned_variants[0]
formatted.append(
{
"sku": v["sku"],
"name": product_data["name"],
"color": v["color"],
"price": v["price"],
"sale_price": v["sale_price"],
"url": v["url"],
"thumbnail_image_url": v["thumbnail_image_url"],
}
)
# Multiple variants → Grouped format (easy to browse)
else:
formatted.append(
{
"product_id": product_data["product_id"],
"name": product_data["name"],
"variants": cleaned_variants,
"all_skus": all_skus,
"primary_sku": all_skus[0],
}
)
product_count += 1
logger.info(
f"📦 Formatted {len(formatted)} products (flat={sum(1 for f in formatted if 'sku' in f and 'product_id' not in f)}, grouped={sum(1 for f in formatted if 'product_id' in f)})"
)
logger.info(f"📦 Formatted {len(formatted)} product variants with SKU")
return formatted
......@@ -415,25 +556,25 @@ def parse_description_text(desc: str) -> dict:
result = {}
if not desc:
return result
# Extract product_name: từ đầu đến ". master_color:" hoặc ". product_image_url:"
name_match = re.search(r"product_name:\s*(.+?)\.(?:\s+master_color:|$)", desc)
if name_match:
result["product_name"] = name_match.group(1).strip()
# Extract product_image_url_thumbnail: từ field name đến ". product_web_url:"
thumb_match = re.search(r"product_image_url_thumbnail:\s*(https?://[^\s]+?)\.(?:\s+product_web_url:|$)", desc)
if thumb_match:
result["product_image_url_thumbnail"] = thumb_match.group(1).strip()
# Extract product_web_url: từ field name đến ". description_text:"
url_match = re.search(r"product_web_url:\s*(https?://[^\s]+?)\.(?:\s+description_text:|$)", desc)
if url_match:
result["product_web_url"] = url_match.group(1).strip()
# Extract master_color: từ field name đến ". product_image_url:"
color_match = re.search(r"master_color:\s*(.+?)\.(?:\s+product_image_url:|$)", desc)
if color_match:
result["master_color"] = color_match.group(1).strip()
return result
......@@ -7,49 +7,111 @@ import asyncio
import json
import logging
import time
from decimal import Decimal
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.tools.product_search_helpers import build_starrocks_query
from common.embedding_service import create_embeddings_async
from common.starrocks_connection import get_db_connection
from agent.tools.data_retrieval_filter import (
filter_by_gender,
COLOR_MAP,
filter_by_age,
filter_by_gender,
filter_by_product_name,
filter_with_priority,
format_product_results,
COLOR_MAP,
SLEEVE_MAP,
STYLE_MAP,
FITTING_MAP,
NECKLINE_MAP,
MATERIAL_MAP,
SEASON_MAP,
)
from agent.tools.product_search_helpers import build_starrocks_query
from agent.tools.stock_helpers import fetch_stock_for_skus
from common.starrocks_connection import get_db_connection
# Setup Logger
logger = logging.getLogger(__name__)
from agent.prompt_utils import read_tool_prompt
PRODUCT_NAME_KEYWORDS = [
("áo sơ mi", "Áo sơ mi"),
("ao so mi", "Áo sơ mi"),
("sơ mi", "Áo sơ mi"),
("so mi", "Áo sơ mi"),
("chân váy", "Chân váy"),
("chan vay", "Chân váy"),
("váy liền thân", "Váy liền thân"),
("vay lien than", "Váy liền thân"),
("váy liền", "Váy liền thân"),
("vay lien", "Váy liền thân"),
("váy đầm", "Váy liền thân"),
("vay dam", "Váy liền thân"),
("đầm", "Váy liền thân"),
("dam", "Váy liền thân"),
("váy", "Váy"),
("vay", "Váy"),
("áo khoác", "Áo khoác"),
("ao khoac", "Áo khoác"),
("áo len", "Áo len"),
("ao len", "Áo len"),
("áo thun", "Áo thun"),
("ao thun", "Áo thun"),
("áo polo", "Áo polo"),
("ao polo", "Áo polo"),
("hoodie", "Áo hoodie"),
("áo hoodie", "Áo hoodie"),
("ao hoodie", "Áo hoodie"),
("quần jeans", "Quần jeans"),
("quan jeans", "Quần jeans"),
("quần short", "Quần short"),
("quan short", "Quần short"),
("quần dài", "Quần dài"),
("quan dai", "Quần dài"),
("quần", "Quần"),
("quan", "Quần"),
("áo", "Áo"),
("ao", "Áo"),
("phụ kiện", "Phụ kiện"),
("phu kien", "Phụ kiện"),
("túi", "Túi"),
("tui", "Túi"),
("mũ", "Mũ"),
("mu", "Mũ"),
("khăn", "Khăn"),
("khan", "Khăn"),
("tất", "Tất"),
("tat", "Tất"),
]
def infer_product_name_from_description(description: str | None) -> str | None:
if not description:
return None
desc_lower = description.lower()
for keyword, product_name in PRODUCT_NAME_KEYWORDS:
if keyword in desc_lower:
return product_name
return None
class SearchItem(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE
description: str = Field(description="Mô tả sản phẩm cần tìm (semantic search trong description_text). VD: 'váy tiểu thư', 'áo thun basic', 'đầm dự tiệc sang chảnh'")
product_name: str | None = Field(description="CHỈ tên loại sản phẩm cơ bản: Áo, Váy, Quần, Chân váy, Áo khoác... KHÔNG bao gồm style/mô tả.")
description: str = Field(
description="Mô tả sản phẩm cần tìm (semantic search trong description_text). VD: 'váy tiểu thư', 'áo thun basic', 'đầm dự tiệc sang chảnh'"
)
# STRICT MODE REQUIREMENT: All fields must be in 'required' list.
# Use '...' (Ellipsis) or just don't set a default value, but keep type as | None.
product_name: str | None = Field(
description="CHỈ tên loại sản phẩm cơ bản: Áo, Váy, Quần, Chân váy, Áo khoác... KHÔNG bao gồm style/mô tả."
)
magento_ref_code: str | None = Field(description="Mã sản phẩm chính xác (SKU)")
price_min: int | None = Field(description="Giá thấp nhất (VND)")
price_max: int | None = Field(description="Giá cao nhất (VND)")
# Metadata filters
gender_by_product: str | None = Field(description="Giới tính (Nam/Nữ/Bé trai/Bé gái)")
age_by_product: str | None = Field(description="Độ tuổi (Người lớn/Trẻ em)")
master_color: str | None = Field(description="Màu sắc chính")
form_sleeve: str | None = Field(description="Dáng tay áo")
style: str | None = Field(description="Phong cách (CHỈ dùng: minimalist, classic, basic, sporty, elegant, casual, feminine). KHÔNG dùng cho từ mô tả như 'tiểu thư', 'sang chảnh'.")
style: str | None = Field(
description="Phong cách (CHỈ dùng: minimalist, classic, basic, sporty, elegant, casual, feminine). KHÔNG dùng cho từ mô tả như 'tiểu thư', 'sang chảnh'."
)
fitting: str | None = Field(description="Dáng đồ (Slim/Regular/Loose)")
form_neckline: str | None = Field(description="Dáng cổ áo")
material_group: str | None = Field(description="Chất liệu")
......@@ -58,27 +120,41 @@ class SearchItem(BaseModel):
# Extra fields for SQL match if needed
product_line_vn: str | None = Field(description="Dòng sản phẩm (VN) cho lọc SQL")
class MultiSearchParams(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE
searches: list[SearchItem] = Field(description="Danh sách các truy vấn tìm kiếm")
async def _execute_single_search(db, item: SearchItem, query_vector: list[float] | None = None) -> tuple[list[dict], dict]:
async def _execute_single_search(
db, item: SearchItem, query_vector: list[float] | None = None
) -> tuple[list[dict], dict]:
"""
Thực thi một search query đơn lẻ (Async).
Returns:
Tuple of (products, filter_info)
"""
try:
short_query = (item.description[:60] + "...") if item.description and len(item.description) > 60 else item.description
short_query = (
(item.description[:60] + "...") if item.description and len(item.description) > 60 else item.description
)
logger.debug(
"_execute_single_search started, query=%r, code=%r",
short_query,
item.magento_ref_code,
)
# Infer product_name if missing (avoid wrong category results)
if not item.product_name:
inferred_name = infer_product_name_from_description(item.description)
if inferred_name:
try:
item = item.model_copy(update={"product_name": inferred_name})
except AttributeError:
item = item.copy(update={"product_name": inferred_name})
logger.warning("🧭 Inferred product_name='%s' from description", inferred_name)
# Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector)
......@@ -99,75 +175,105 @@ async def _execute_single_search(db, item: SearchItem, query_vector: list[float]
db_time,
query_build_time + db_time,
)
# Debug: Log first product to see fields
if products:
first_p = products[0]
logger.info("🔍 [DEBUG] First product keys: %s", list(first_p.keys()))
logger.info("🔍 [DEBUG] First product price: %s, sale_price: %s",
first_p.get("original_price"), first_p.get("sale_price"))
logger.info(
"🔍 [DEBUG] First product price: %s, sale_price: %s",
first_p.get("original_price"),
first_p.get("sale_price"),
)
# ====== POST-FILTERS: Filter results by requested criteria ======
original_count = len(products)
all_filter_info = {} # Aggregate fallback info for all filters
logger.warning("🔍 [POST-FILTER] Starting with %d products from DB. SearchItem params: product_name=%r, gender=%r, age=%r, color=%r",
original_count, item.product_name, item.gender_by_product, item.age_by_product, item.master_color)
logger.warning(
"🔍 [POST-FILTER] Starting with %d products from DB. SearchItem params: product_name=%r, gender=%r, age=%r, color=%r",
original_count,
item.product_name,
item.gender_by_product,
item.age_by_product,
item.master_color,
)
# ====== LAYER 1: HARD FILTERS (No fallback) ======
# Filter by PRODUCT_NAME (HARD) - must match product type
if item.product_name and products:
before = len(products)
products = filter_by_product_name(products, item.product_name)
logger.warning("📦 Product name filter (HARD): %s → %d→%d products", item.product_name, before, len(products))
logger.warning(
"📦 Product name filter (HARD): %s → %d→%d products", item.product_name, before, len(products)
)
# Filter by GENDER (HARD)
if item.gender_by_product and products:
before = len(products)
products = filter_by_gender(products, item.gender_by_product)
logger.warning("👤 Gender filter (HARD): %s → %d→%d products", item.gender_by_product, before, len(products))
logger.warning(
"👤 Gender filter (HARD): %s → %d→%d products", item.gender_by_product, before, len(products)
)
# Filter by AGE (HARD)
if item.age_by_product and products:
before = len(products)
products = filter_by_age(products, item.age_by_product)
logger.warning("🎂 Age filter (HARD): %s → %d→%d products", item.age_by_product, before, len(products))
# ====== LAYER 2: SOFT FILTERS (With priority-based fallback) ======
# Only apply if we still have products from HARD filters
# 1. COLOR
# 1. COLOR (with automatic fallback)
if item.master_color and products:
products, info = filter_with_priority(
products, item.master_color, "master_color", COLOR_MAP, "Màu"
)
before_count = len(products)
products, info = filter_with_priority(products, item.master_color, "master_color", COLOR_MAP, "Màu")
# Store fallback info for Agent's response
if info.get("fallback_used") or info.get("matched_value"):
all_filter_info["color"] = info
logger.info("🎨 Color filter: %s → %s products (Matched: %s)",
item.master_color, len(products), info.get("matched_value"))
if info.get("fallback_used"):
logger.warning(
"🎨 COLOR FALLBACK: Requested '%s' → Found '%s' (%d products)",
info.get("requested_value"),
info.get("matched_value"),
len(products),
)
else:
logger.info(
"🎨 Color filter: Matched '%s' exactly (%d products)", info.get("matched_value"), len(products)
)
else:
logger.warning("🎨 Color filter: NO MATCH - %d → %d products", before_count, len(products))
# === DISABLED FILTERS (chỉ giữ name, gender, age, color) ===
# 2. SLEEVE - DISABLED
# 3. STYLE - DISABLED
# 3. STYLE - DISABLED
# 4. FITTING - DISABLED
# 5. NECKLINE - DISABLED
# 6. MATERIAL - DISABLED
# 7. SEASON - DISABLED
# Combine filter info
# Combine filter info - STRUCTURE RÕRANƠ CHO AGENT
filter_info = {
"fallback_used": any(info.get("fallback_used") for info in all_filter_info.values()),
"filters": all_filter_info,
"filters_applied": all_filter_info, # ← Chi tiết từng filter (fallback hay không)
}
# Build combined message for LLM
# Build recommendation message for Agent
# Agent sẽ dùng cái này để báo với khách hàng
fallback_messages = [info.get("message") for info in all_filter_info.values() if info.get("message")]
if fallback_messages:
filter_info["message"] = " ".join(fallback_messages)
# Log summary
filter_info["recommendation_message"] = " ".join(fallback_messages) # ← Báo khách cụ thể là có fallback
# Log summary chi tiết
if original_count != len(products):
logger.info("📊 Post-filter summary: %s → %s products", original_count, len(products))
logger.info(
"📊 Post-filter summary: %d → %d products. Fallback used: %s",
original_count,
len(products),
filter_info.get("fallback_used"),
)
return format_product_results(products), filter_info
except Exception as e:
......@@ -175,7 +281,6 @@ async def _execute_single_search(db, item: SearchItem, query_vector: list[float]
return [], {"fallback_used": False, "error": str(e)}
@tool(args_schema=MultiSearchParams)
async def data_retrieval_tool(searches: list[SearchItem]) -> str:
"""
......@@ -183,36 +288,109 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
Hỗ trợ tìm kiếm Semantic và lọc theo Metadata.
"""
logger.info("🔧 data_retrieval_tool called with %d items", len(searches))
# Get DB Connection
db = get_db_connection()
if not db:
return json.dumps({"status": "error", "message": "Database connection failed"})
combined_results = []
all_filter_infos = []
tasks = []
for item in searches:
tasks.append(_execute_single_search(db, item))
results_list = await asyncio.gather(*tasks)
for products, filter_info in results_list:
combined_results.extend(products)
if filter_info:
all_filter_infos.append(filter_info)
# ============================================================
# STOCK ENRICHMENT: Fetch stock info for all products
# ============================================================
skus_to_check = []
skus_to_check = []
for product in combined_results:
# Handle Flat Result (has 'sku')
if "sku" in product:
skus_to_check.append(product["sku"])
# Handle Grouped Result (has 'all_skus')
elif "all_skus" in product:
skus_to_check.extend(product["all_skus"])
# Fallback for raw results (legacy or defensive)
else:
sku = product.get("product_color_code") or product.get("magento_ref_code")
if sku:
skus_to_check.append(sku)
stock_map = {}
if skus_to_check:
logger.info(f"🔍 Checking stock for {len(skus_to_check)} SKUs: {skus_to_check[:5]}...")
try:
stock_map = await fetch_stock_for_skus(skus_to_check)
logger.info(f"📦 [STOCK] Enriched {len(stock_map)} products with stock info")
except Exception as e:
logger.error(f"❌ Error fetching stock in data_retrieval: {e}")
# Merge stock info into each product
for product in combined_results:
# Handle Flat Result
if "sku" in product:
sku = product["sku"]
if sku in stock_map:
product["stock_info"] = stock_map[sku]
# Handle Grouped Result
elif "all_skus" in product:
# Aggregate stock for all variants
# For brevity, we can store a map or a summary.
# Let's store a map of {sku: stock_info}
group_stock = {}
has_stock = False
for s in product["all_skus"]:
if s in stock_map:
group_stock[s] = stock_map[s]
has_stock = True
if has_stock:
product["stock_info"] = group_stock
# Fallback logic
else:
sku = product.get("product_color_code") or product.get("magento_ref_code")
if sku and sku in stock_map:
product["stock_info"] = stock_map[sku]
# Aggregate filter info from first result for simplicity in response
final_info = all_filter_infos[0] if all_filter_infos else {}
output = {
"status": "success",
"results": combined_results,
"filter_info": final_info
"filter_info": final_info,
"stock_enriched": len(stock_map) > 0,
# ← Agent sẽ check filter_info.fallback_used để biết có fallback không
# ← Nếu có, dùng filter_info.recommendation_message để báo khách
}
logger.info(
"🎁 Final result: %d products. Fallback used: %s. Stock enriched: %s",
len(combined_results),
final_info.get("fallback_used", False),
len(stock_map) > 0,
)
return json.dumps(output, ensure_ascii=False, default=str)
# Load dynamic docstring
# Load dynamic docstring
data_retrieval_tool.__doc__ = read_tool_prompt("data_retrieval_tool") or data_retrieval_tool.__doc__
dynamic_prompt = read_tool_prompt("data_retrieval_tool")
if dynamic_prompt:
data_retrieval_tool.__doc__ = dynamic_prompt
data_retrieval_tool.description = dynamic_prompt
Client Request
[Cache Check] → HIT? → Return ngay
↓ MISS
[Load History + User Insight]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🌊 STREAMING BẮT ĐẦU
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async for event in graph.astream():
├─ LLM gọi tools (nếu cần)
├─ Tools return data
├─ LLM bắt đầu sinh JSON response (streaming tokens)
└─ Bắt được event["ai_response"] với content streaming:
Accumulate tokens: '{"ai_response": "text...", "product_ids": ["SK'
⚡ REGEX: Match ngay khi detect được pattern "product_ids": [...]
├─ Regex match: "ai_response": "..." ✅
├─ Regex match: "product_ids": ["SKU1", "SKU2"] ✅
└─ user_insight: {...} ← VẪN ĐANG STREAM, CHƯA CÓ!
🚨 BREAK NGAY! TRẢ RESPONSE, KHÔNG ĐỢI user_insight!
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚡ RESPONSE TRẢ NGAY:
{
"ai_response": "...",
"product_ids": ["SKU1", "SKU2"]
}
↓ (Background tasks)
├─ 💾 Save user_insight to Redis
├─ 💾 Cache response
└─ 📝 Save history
\ No newline at end of file
......@@ -6,7 +6,6 @@ from common.embedding_service import create_embedding_async
logger = logging.getLogger(__name__)
def _get_price_clauses(params, sql_params: list) -> list[str]:
"""Lọc theo giá (Parameterized)."""
clauses = []
......@@ -29,7 +28,7 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]:
exact_fields = [
("gender_by_product", "gender_by_product"),
("age_by_product", "age_by_product"),
("form_neckline", "form_neckline"),
("form_neckline", "form_neckline"),
]
for param_name, col_name in exact_fields:
val = getattr(params, param_name, None)
......@@ -86,11 +85,12 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
magento_code = getattr(params, "magento_ref_code", None)
if magento_code:
logger.info(f"🎯 [CODE SEARCH] Direct search by code: {magento_code}")
sql = """
SELECT
internal_ref_code,
magento_ref_code,
product_color_code,
description_text_full,
sale_price,
original_price,
......@@ -107,7 +107,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# CASE 2: HYDE SEARCH - Semantic Vector Search
# ============================================================
logger.info("🚀 [HYDE RETRIEVER] Starting semantic vector search...")
query_text = getattr(params, "description", None)
if query_text and query_vector is None:
emb_start = time.time()
......@@ -120,11 +120,11 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# Vector params
v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# Collect Params
price_params: list = []
price_clauses = _get_price_clauses(params, price_params)
where_filter = ""
if price_clauses:
where_filter = " AND " + " AND ".join(price_clauses)
......@@ -137,6 +137,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
WITH top_matches AS (
SELECT /*+ SET_VAR(ann_params='{{"ef_search":128}}') */
internal_ref_code,
magento_ref_code,
product_color_code,
description_text_full,
sale_price,
......@@ -149,6 +150,8 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
)
SELECT
internal_ref_code,
MAX_BY(magento_ref_code, similarity_score) as magento_ref_code,
MAX_BY(product_color_code, similarity_score) as product_color_code,
MAX_BY(description_text_full, similarity_score) as description_text_full,
MAX_BY(sale_price, similarity_score) as sale_price,
MAX_BY(original_price, similarity_score) as original_price,
......@@ -160,7 +163,6 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
ORDER BY max_score DESC
LIMIT 70
"""
# Return sql and params (params only contains filter values now, not the vector)
return sql, price_params
"""
Stock Helpers - Shared stock fetching logic.
Được dùng bởi cả `data_retrieval_tool` và `check_is_stock` tool.
Gọi qua internal API /api/stock/check để tận dụng logic expand SKU.
"""
import logging
from typing import Any
import httpx
from config import INTERNAL_STOCK_API
logger = logging.getLogger(__name__)
DEFAULT_TIMEOUT_SEC = 15.0
async def fetch_stock_for_skus(
skus: list[str],
timeout_sec: float = DEFAULT_TIMEOUT_SEC,
) -> dict[str, dict[str, Any]]:
"""
Fetch stock info for a list of SKUs via internal API.
Supports base codes, product_color_code, and full SKUs.
API will automatically expand short codes to full SKUs.
Args:
skus: List of SKU codes (any format: base, color, or full)
timeout_sec: HTTP timeout in seconds (default 15)
Returns:
Dict mapping SKU -> stock data from API response.
Example: {"6ST25W005-SE091-L": {"qty": 10, "is_in_stock": true, ...}}
"""
if not skus:
return {}
# Deduplicate while preserving order
seen = set()
unique_skus: list[str] = []
for sku in skus:
if sku and sku not in seen:
seen.add(sku)
unique_skus.append(sku)
if not unique_skus:
return {}
stock_map: dict[str, dict[str, Any]] = {}
try:
async with httpx.AsyncClient(timeout=timeout_sec) as client:
# Call internal API with POST
payload = {
"codes": ",".join(unique_skus),
"truncate": True,
"max_skus": 200,
}
resp = await client.post(INTERNAL_STOCK_API, json=payload)
resp.raise_for_status()
data = resp.json()
# Parse response from /api/stock/check
# Format: {"stock_responses": [{"code": 200, "result": [...]}]}
stock_responses = data.get("stock_responses", [])
for stock_resp in stock_responses:
results = stock_resp.get("result", [])
for item in results:
sku_key = item.get("sku")
if sku_key:
stock_map[sku_key] = {
"is_in_stock": item.get("is_in_stock", False),
"qty": item.get("qty", 0),
}
logger.info(f"📦 [STOCK] Fetched stock for {len(stock_map)} SKUs (input: {len(unique_skus)})")
return stock_map
except httpx.RequestError as exc:
logger.error(f"Network error fetching stock: {exc}")
return {}
except httpx.HTTPStatusError as exc:
logger.error(f"HTTP error {exc.response.status_code} fetching stock: {exc}")
return {}
except Exception as exc:
logger.error(f"Unexpected error fetching stock: {exc}")
return {}
......@@ -8,25 +8,26 @@ Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddle
import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi import APIRouter, BackgroundTasks, Request
from fastapi.responses import JSONResponse
from agent.controller import chat_controller
from agent.models import QueryRequest
from common.message_limit import message_limit_service
from common.cache import redis_cache
from common.message_limit import message_limit_service
from common.rate_limit import rate_limit_service
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/api/agent/chat", summary="Fashion Q&A Chat (Non-streaming)")
@rate_limit_service.limiter.limit("50/minute")
async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
"""
Endpoint chat không stream - trả về response JSON đầy đủ một lần.
Note: Rate limit đã được check trong middleware.
"""
# 1. Lấy user identity từ Middleware (request.state)
......@@ -34,20 +35,21 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
# Định danh duy nhất cho Request này (Log, History, Rate Limit, Langfuse)
identity_id = user_id if is_authenticated else device_id
# Rate limit đã check trong middleware, lấy limit_info từ request.state
limit_info = getattr(request.state, 'limit_info', None)
limit_info = getattr(request.state, "limit_info", None)
print(f"\n🔥🔥🔥 REQUEST ARRIVED! User: {identity_id} | Query: {req.user_query} 🔥🔥🔥\n")
logger.info(f"📥 [Incoming Query - NonStream] User: {identity_id} | Query: {req.user_query}")
try:
# Gọi controller để xử lý logic (Non-streaming)
result = await chat_controller(
query=req.user_query,
user_id=str(identity_id), # Langfuse User ID
user_id=str(identity_id), # Langfuse User ID
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
images=req.images,
......@@ -84,8 +86,8 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát."
}
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát.",
},
)
......@@ -94,18 +96,19 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
"""
Endpoint chat dành cho DEV - trả về đầy đủ user_insight.
Note: Rate limit đã được check trong middleware.
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
limit_info = getattr(request.state, 'limit_info', None)
limit_info = getattr(request.state, "limit_info", None)
logger.info(f"📥 [Incoming Query - Dev] User: {identity_id} | Query: {req.user_query}")
try:
# DEV MODE: Return ai_response + products immediately, user_insight via polling
result = await chat_controller(
query=req.user_query,
user_id=str(identity_id),
......@@ -113,6 +116,7 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
model_name=DEFAULT_MODEL,
images=req.images,
identity_key=str(identity_id),
return_user_insight=False,
)
usage_info = await message_limit_service.increment(
......@@ -120,14 +124,11 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
is_authenticated=is_authenticated,
)
# user_insight đã được trả về từ controller
user_insight = result.get("user_insight")
return {
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"user_insight": user_insight,
"insight_status": "pending",
"limit_info": {
"limit": usage_info["limit"],
"used": usage_info["used"],
......@@ -141,7 +142,41 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát."
}
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát.",
},
)
@router.get("/api/agent/user-insight", summary="Get latest user_insight (Dev)")
@rate_limit_service.limiter.limit("120/minute")
async def get_user_insight(request: Request):
"""
Polling endpoint for dev UI to fetch latest user_insight from Redis.
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
try:
client = redis_cache.get_client()
if not client:
return {"status": "pending", "user_insight": None}
insight_key = f"identity_key_insight:{identity_id}"
user_insight = await client.get(insight_key)
if user_insight:
return {"status": "success", "user_insight": user_insight}
return {"status": "pending", "user_insight": None}
except Exception as e:
logger.error(f"Error in get_user_insight: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Không thể tải user_insight lúc này.",
},
)
......@@ -3,6 +3,7 @@ from pydantic import BaseModel
import os
import re
from agent.graph import reset_graph
from common.cache import bump_prompt_version
router = APIRouter()
......@@ -68,12 +69,16 @@ async def update_system_prompt_content(request: Request, body: PromptUpdateReque
with open(PROMPT_FILE_PATH, "w", encoding="utf-8") as f:
f.write(body.content)
# 2. Reset Graph Singleton to force reload prompt
# 2. Bump prompt version in Redis (ALL workers will detect this)
new_version = await bump_prompt_version()
# 3. Reset local worker's Graph Singleton (immediate effect for this worker)
reset_graph()
response = {
"status": "success",
"message": "System prompt updated successfully. Graph reloaded."
"message": f"System prompt updated. Version: {new_version}. All workers will reload on next request.",
"prompt_version": new_version
}
if warning:
response["warning"] = warning
......@@ -82,3 +87,4 @@ async def update_system_prompt_content(request: Request, body: PromptUpdateReque
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
import logging
from typing import Any
import httpx
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from common.starrocks_connection import StarRocksConnection
logger = logging.getLogger(__name__)
router = APIRouter()
STOCK_API_URL = "https://canifa.com/v1/middleware/stock_get_stock_list"
DEFAULT_MAX_SKUS = 200
DEFAULT_CHUNK_SIZE = 50
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
class StockExpandRequest(BaseModel):
codes: str = Field(
description=(
"Comma-separated product codes. Supports base codes, product_color_code, "
"or full SKU (code-color-size). Example: '6ST25W005,6ST25W005-SE091-L'"
)
)
sizes: str | None = Field(
default=None,
description="Optional comma-separated sizes to filter (e.g. 'S,M,L,XL,140').",
)
max_skus: int = Field(default=DEFAULT_MAX_SKUS, ge=1)
chunk_size: int = Field(default=DEFAULT_CHUNK_SIZE, ge=1)
expand_only: bool = Field(default=False)
truncate: bool = Field(default=True)
timeout_sec: float = Field(default=10.0, gt=0)
def _split_csv(value: str | None) -> list[str]:
if not value:
return []
return [token.strip() for token in value.split(",") if token.strip()]
def _normalize_size(token: str) -> str:
normalized = token.strip().upper()
if normalized.endswith("CM"):
normalized = normalized[:-2]
return normalized
def _is_full_sku(token: str) -> bool:
return token.count("-") >= 2
def _chunked(items: list[str], size: int) -> list[list[str]]:
return [items[i : i + size] for i in range(0, len(items), size)]
async def _fetch_variants(codes: list[str]) -> list[dict[str, Any]]:
if not codes:
return []
placeholders = ",".join(["%s"] * len(codes))
sql = f"""
SELECT
internal_ref_code,
magento_ref_code,
product_color_code,
size_scale
FROM {TABLE_NAME}
WHERE internal_ref_code IN ({placeholders})
OR magento_ref_code IN ({placeholders})
OR product_color_code IN ({placeholders})
GROUP BY internal_ref_code, magento_ref_code, product_color_code, size_scale
"""
params = codes * 3
db = StarRocksConnection()
return await db.execute_query_async(sql, params=tuple(params))
@router.post("/api/stock/check", summary="Expand product codes and check stock")
async def check_stock(req: StockExpandRequest):
"""
Expand base codes to full SKUs using StarRocks, then call Canifa stock API.
"""
input_codes = _split_csv(req.codes)
if not input_codes:
raise HTTPException(status_code=400, detail="codes is required")
size_filter = {_normalize_size(s) for s in _split_csv(req.sizes)} if req.sizes else None
full_skus: list[str] = []
lookup_codes: list[str] = []
for token in input_codes:
if _is_full_sku(token):
full_skus.append(token)
else:
lookup_codes.append(token)
variant_rows = await _fetch_variants(lookup_codes)
expanded_skus: list[str] = []
missing_size_color_codes: list[str] = []
for row in variant_rows:
product_color_code = row.get("product_color_code")
size_scale = row.get("size_scale")
if not product_color_code:
continue
if not size_scale:
missing_size_color_codes.append(product_color_code)
continue
for raw_token in str(size_scale).split("|"):
token = raw_token.strip()
if not token:
continue
normalized = _normalize_size(token)
if size_filter and normalized not in size_filter:
continue
expanded_skus.append(f"{product_color_code}-{normalized}")
# Deduplicate while preserving order
seen = set()
ordered_skus: list[str] = []
for sku in full_skus + expanded_skus:
if sku not in seen:
seen.add(sku)
ordered_skus.append(sku)
truncated = False
if len(ordered_skus) > req.max_skus:
if req.truncate:
ordered_skus = ordered_skus[: req.max_skus]
truncated = True
else:
raise HTTPException(
status_code=400,
detail=f"Expanded SKU count {len(ordered_skus)} exceeds max_skus {req.max_skus}",
)
response_payload = {
"status": "success",
"input_codes": input_codes,
"lookup_codes": lookup_codes,
"input_full_skus": full_skus,
"expanded_skus_count": len(expanded_skus),
"requested_skus_count": len(ordered_skus),
"requested_skus": ordered_skus,
"missing_size_color_codes": missing_size_color_codes,
"truncated": truncated,
}
if req.expand_only:
return response_payload
if not ordered_skus:
response_payload["stock_responses"] = []
return response_payload
try:
stock_responses: list[dict[str, Any]] = []
async with httpx.AsyncClient(timeout=req.timeout_sec) as client:
for chunk in _chunked(ordered_skus, req.chunk_size):
resp = await client.get(STOCK_API_URL, params={"skus": ",".join(chunk)})
resp.raise_for_status()
stock_responses.append(resp.json())
response_payload["stock_responses"] = stock_responses
return response_payload
except httpx.RequestError as exc:
logger.error(f"Network error checking stock: {exc}")
raise HTTPException(status_code=502, detail=f"Network error: {exc}") from exc
except httpx.HTTPStatusError as exc:
logger.error(f"HTTP error checking stock: {exc}")
raise HTTPException(status_code=502, detail=f"Stock API error: {exc}") from exc
except Exception as exc:
logger.error(f"Unexpected error checking stock: {exc}")
raise HTTPException(status_code=500, detail=f"Unexpected error: {exc}") from exc
......@@ -159,3 +159,30 @@ redis_cache = RedisClient()
def get_redis_cache() -> RedisClient:
return redis_cache
# --- Prompt Version Sync (Multi-Worker) ---
PROMPT_VERSION_KEY = "system:prompt_version"
async def get_prompt_version() -> int:
"""Get current prompt version from Redis (shared across all workers)."""
try:
client = redis_cache.get_client()
if client:
version = await client.get(PROMPT_VERSION_KEY)
return int(version) if version else 0
except Exception as e:
logger.warning(f"Failed to get prompt version: {e}")
return 0
async def bump_prompt_version() -> int:
"""Increment prompt version in Redis (call when prompt is updated)."""
try:
client = redis_cache.get_client()
if client:
new_version = await client.incr(PROMPT_VERSION_KEY)
logger.info(f"🔄 Prompt version bumped to: {new_version}")
return new_version
except Exception as e:
logger.warning(f"Failed to bump prompt version: {e}")
return 0
......@@ -10,10 +10,10 @@ import httpx
logger = logging.getLogger(__name__)
# CANIFA_CUSTOMER_API = "https://vsf2.canifa.com/v1/magento/customer"
CANIFA_CUSTOMER_API = "https://vsf2.canifa.com/v1/magento/customer"
CANIFA_CUSTOMER_API = "https://canifa.com/v1/magento/customer"
# CANIFA_CUSTOMER_API = "https://canifa.com/v1/magento/customer"
_http_client: httpx.AsyncClient | None = None
......
......@@ -67,7 +67,7 @@ class LLMFactory:
"""Create and cache a new OpenAI LLM instance."""
try:
llm = self._create_openai(model_name, streaming, json_mode, api_key)
cache_key = (model_name, streaming, json_mode, api_key)
self._cache[cache_key] = llm
return llm
......@@ -85,19 +85,18 @@ class LLMFactory:
llm_kwargs = {
"model": model_name,
"streaming": streaming,
"streaming": streaming, # ← STREAMING CONFIG
"api_key": key,
"temperature": 0,
"max_tokens": 1500,
}
# Nếu bật json_mode, tiêm trực tiếp vào constructor
if json_mode:
llm_kwargs["model_kwargs"] = {"response_format": {"type": "json_object"}}
logger.info(f"⚙️ Initializing OpenAI in JSON mode: {model_name}")
llm = ChatOpenAI(**llm_kwargs)
logger.info(f"✅ Created OpenAI: {model_name}")
logger.info(f"✅ Created OpenAI: {model_name} | Streaming: {streaming}")
return llm
def _enable_json_mode(self, llm: BaseChatModel, model_name: str) -> BaseChatModel:
......
......@@ -25,6 +25,7 @@ __all__ = [
"FIRECRAWL_API_KEY",
"GOOGLE_API_KEY",
"GROQ_API_KEY",
"INTERNAL_STOCK_API",
"JWT_ALGORITHM",
"JWT_SECRET",
"LANGFUSE_BASE_URL",
......@@ -43,6 +44,8 @@ __all__ = [
"OTEL_SERVICE_NAME",
"OTEL_TRACES_EXPORTER",
"PORT",
"RATE_LIMIT_GUEST",
"RATE_LIMIT_USER",
"REDIS_HOST",
"REDIS_PASSWORD",
"REDIS_PORT",
......@@ -52,9 +55,8 @@ __all__ = [
"STARROCKS_PASSWORD",
"STARROCKS_PORT",
"STARROCKS_USER",
"STOCK_API_URL",
"USE_MONGO_CONVERSATION",
"RATE_LIMIT_GUEST",
"RATE_LIMIT_USER",
]
# ====================== SUPABASE CONFIGURATION ======================
......@@ -140,4 +142,8 @@ OTEL_EXPORTER_JAEGER_AGENT_SPLIT_OVERSIZED_BATCHES = os.getenv("OTEL_EXPORTER_JA
RATE_LIMIT_GUEST: int = int(os.getenv("RATE_LIMIT_GUEST", "10"))
RATE_LIMIT_USER: int = int(os.getenv("RATE_LIMIT_USER", "100"))
# ====================== STOCK API CONFIGURATION ======================
# External Canifa Stock API (dùng trực tiếp nếu cần)
STOCK_API_URL: str = os.getenv("STOCK_API_URL", "https://canifa.com/v1/middleware/stock_get_stock_list")
# Internal Stock API (có logic expand SKU từ base code)
INTERNAL_STOCK_API: str = os.getenv("INTERNAL_STOCK_API", "http://localhost:5000/api/stock/check")
......@@ -3,7 +3,6 @@
uvicorn server:app --host 0.0.0.0 --port 5000 --reload
uvicorn server:app --host 0.0.0.0 --port 5000
docker restart chatbot-backend
......@@ -15,3 +14,7 @@ docker logs -f chatbot-backend
docker restart canifa_backend
sudo docker compose -f docker-compose.prod.yml up -d --build
Get-NetTCPConnection -LocalPort 5000 | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force }
taskkill /F /IM python.exe
\ No newline at end of file
......@@ -13,6 +13,7 @@ from api.conservation_route import router as conservation_router
from api.tool_prompt_route import router as tool_prompt_router
from api.prompt_route import router as prompt_router
from api.mock_api_route import router as mock_router
from api.stock_route import router as stock_router
from common.cache import redis_cache
from common.langfuse_client import get_langfuse_client
......@@ -65,6 +66,7 @@ app.include_router(chatbot_router)
app.include_router(prompt_router)
app.include_router(tool_prompt_router) # Register new router
app.include_router(mock_router)
app.include_router(stock_router)
try:
......
......@@ -1396,7 +1396,9 @@
}
const data = await response.json();
const responseTime = ((Date.now() - startTime) / 1000).toFixed(2);
const responseTime = (data.response_ready_s !== undefined && data.response_ready_s !== null)
? Number(data.response_ready_s).toFixed(2)
: ((Date.now() - startTime) / 1000).toFixed(2);
// Generate unique message ID
const messageId = 'msg-' + Date.now();
......@@ -1410,22 +1412,24 @@
id: messageId
});
// Create bot message placeholder
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
// ============================================
// MESSAGE 1: AI Response + Products (Immediate)
// ============================================
if (data.status === 'success') {
const container1 = document.createElement('div');
container1.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const sender1 = document.createElement('div');
sender1.className = 'sender-name';
sender1.innerText = 'Canifa AI';
container1.appendChild(sender1);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
const botMsgDiv1 = document.createElement('div');
botMsgDiv1.className = 'message bot';
if (data.status === 'success') {
// FILTERED CONTENT (default visible)
// FILTERED CONTENT
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
......@@ -1435,25 +1439,6 @@
textDiv.innerText = data.ai_response || 'No response';
filteredDiv.appendChild(textDiv);
if (data.user_insight) {
const insightDiv = document.createElement('div');
insightDiv.className = 'user-insight';
if (typeof data.user_insight === 'object' && data.user_insight !== null) {
insightDiv.innerHTML = '<strong>🧠 Insight:</strong><br/>';
Object.entries(data.user_insight).forEach(([key, value]) => {
const line = document.createElement('div');
line.style.fontSize = '0.9em';
line.style.marginTop = '2px';
line.innerHTML = `<strong>${key}:</strong> ${value}`;
insightDiv.appendChild(line);
});
} else {
insightDiv.innerText = `🧠 Insight: ${data.user_insight}`;
}
filteredDiv.appendChild(insightDiv);
}
// Render product cards if available
if (data.product_ids && data.product_ids.length > 0) {
const productsContainer = document.createElement('div');
......@@ -1491,25 +1476,21 @@
priceDiv.className = 'product-price';
if (product.sale_price && product.price && product.sale_price < product.price) {
// Show original price with strikethrough
const originalPrice = document.createElement('span');
originalPrice.className = 'price-original';
originalPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(originalPrice);
// Show sale price
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) {
// Show regular price
const regularPrice = document.createElement('span');
regularPrice.className = 'price-regular';
regularPrice.innerText = (product.price || 0).toLocaleString('vi-VN') + 'đ';
priceDiv.appendChild(regularPrice);
} else {
// No price available
const noPrice = document.createElement('span');
noPrice.className = 'price-regular';
noPrice.innerText = 'Liên hệ';
......@@ -1532,9 +1513,9 @@
filteredDiv.appendChild(productsContainer);
}
botMsgDiv.appendChild(filteredDiv);
botMsgDiv1.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
// RAW CONTENT
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
......@@ -1547,14 +1528,14 @@
status: data.status,
ai_response: data.ai_response,
product_ids: data.product_ids,
user_insight: data.user_insight || null,
limit_info: data.limit_info || null
response_ready_s: data.response_ready_s,
response_ready_stream_s: data.response_ready_stream_s,
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
botMsgDiv1.appendChild(rawDiv);
// Add toggle button
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
......@@ -1571,15 +1552,113 @@
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
botMsgDiv1.appendChild(toggleDiv);
// Add response time
// Response time for message 1
const timeDiv = document.createElement('div');
timeDiv.className = 'response-time';
timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv.appendChild(timeDiv);
botMsgDiv1.appendChild(timeDiv);
container1.appendChild(botMsgDiv1);
messagesArea.appendChild(container1);
chatBox.scrollTop = chatBox.scrollHeight;
// ============================================
// MESSAGE 2: User Insight (Polling - real backend delay)
// ============================================
const renderUserInsightMessage = (insightObj) => {
const container2 = document.createElement('div');
container2.className = 'message-container bot';
const sender2 = document.createElement('div');
sender2.className = 'sender-name';
sender2.innerText = 'Canifa AI - Analytics';
container2.appendChild(sender2);
const botMsgDiv2 = document.createElement('div');
botMsgDiv2.className = 'message bot';
const insightDiv = document.createElement('div');
insightDiv.className = 'user-insight';
insightDiv.innerHTML = '<strong>🧠 User Insight:</strong><br/>';
Object.entries(insightObj).forEach(([key, value]) => {
const line = document.createElement('div');
line.style.fontSize = '0.9em';
line.style.marginTop = '2px';
line.innerHTML = `<strong>${key}:</strong> ${value}`;
insightDiv.appendChild(line);
});
botMsgDiv2.appendChild(insightDiv);
// Real timing for insight render
const insightTime = ((Date.now() - startTime) / 1000).toFixed(2);
const insightTimeDiv = document.createElement('div');
insightTimeDiv.className = 'response-time';
insightTimeDiv.innerText = `⏱️ Insight ${insightTime}s`;
botMsgDiv2.appendChild(insightTimeDiv);
container2.appendChild(botMsgDiv2);
messagesArea.appendChild(container2);
chatBox.scrollTop = chatBox.scrollHeight;
};
const pollUserInsight = async () => {
const maxAttempts = 60; // ~12s at 200ms
const intervalMs = 200;
let attempts = 0;
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const tick = async () => {
attempts += 1;
try {
const res = await fetch('/api/agent/user-insight', { headers });
if (!res.ok) throw new Error('Failed to fetch user_insight');
const payload = await res.json();
if (payload.status === 'success' && payload.user_insight) {
const insightObj = typeof payload.user_insight === 'string'
? JSON.parse(payload.user_insight)
: payload.user_insight;
renderUserInsightMessage(insightObj);
return;
}
} catch (e) {
console.warn('Polling user_insight failed:', e);
}
if (attempts < maxAttempts) {
setTimeout(tick, intervalMs);
}
};
setTimeout(tick, 50);
};
if (data.insight_status === 'pending') {
pollUserInsight();
}
} else {
// ERROR CASE: Limit exceeded or other errors
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
// FILTERED CONTENT (error message - default visible)
const filteredDiv = document.createElement('div');
......@@ -1631,10 +1710,11 @@
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
}
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
} catch (error) {
......
# TEST STREAMING + BACKGROUND USER_INSIGHT
Write-Host "`n==== STREAMING TEST ====`n" -ForegroundColor Cyan
$query = "Ao khoac nam mua dong"
$deviceId = "test_stream_verify"
Write-Host "Sending request..." -ForegroundColor Green
$timing = Measure-Command {
$body = '{"user_query":"' + $query + '","device_id":"' + $deviceId + '"}'
$result = $body | curl.exe -s -X POST "http://localhost:5000/api/agent/chat" -H "Content-Type: application/json" --data-binary "@-"
$result | Out-Null
}
Write-Host "`nResponse Time: $($timing.TotalMilliseconds) ms" -ForegroundColor Green
Write-Host "`nCheck backend logs for:" -ForegroundColor Yellow
Write-Host " - Starting LLM streaming" -ForegroundColor Gray
Write-Host " - Regex matched product_ids" -ForegroundColor Gray
Write-Host " - BREAKING STREAM NOW" -ForegroundColor Gray
Write-Host " - Background task extraction" -ForegroundColor Gray
Write-Host "`nDone!" -ForegroundColor Green
import asyncio
import csv
import os
import sys
from typing import Any
# Ensure we can import from backend root
current_dir = os.path.dirname(os.path.abspath(__file__))
backend_root = os.path.dirname(current_dir)
sys.path.append(backend_root)
from common.starrocks_connection import StarRocksConnection
from config import STARROCKS_DB, STARROCKS_HOST, STARROCKS_PASSWORD, STARROCKS_USER
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
DEFAULT_SIZES = [90, 92, 98, 104, 110, 116, 122, 128, 134, 140, 152, 164]
OUTPUT_CSV = os.path.join(current_dir, "numeric_size_skus.csv")
def _get_missing_env() -> list[str]:
missing = []
if not STARROCKS_HOST:
missing.append("STARROCKS_HOST")
if not STARROCKS_DB:
missing.append("STARROCKS_DB")
if not STARROCKS_USER:
missing.append("STARROCKS_USER")
if not STARROCKS_PASSWORD:
missing.append("STARROCKS_PASSWORD")
return missing
def _skip_or_warn_if_missing_env() -> bool:
missing = _get_missing_env()
if not missing:
return False
message = f"Missing StarRocks env vars: {', '.join(missing)}"
if "PYTEST_CURRENT_TEST" in os.environ:
import pytest
pytest.skip(message)
print(f"[SKIP] {message}")
return True
def _parse_sizes_env() -> list[int]:
raw = os.getenv("NUMERIC_SIZES")
if not raw:
return DEFAULT_SIZES
sizes: list[int] = []
for token in raw.split(","):
token = token.strip()
if not token:
continue
try:
sizes.append(int(token))
except ValueError:
continue
return sizes or DEFAULT_SIZES
def _build_regex_pattern(sizes: list[int]) -> str:
sizes_str = "|".join(str(s) for s in sorted(set(sizes)))
# Match tokens like 140 or 140cm inside pipe-delimited lists.
return rf"(^|\\|)({sizes_str})(cm)?(\\||$)"
async def fetch_numeric_size_rows(sizes: list[int]) -> list[dict[str, Any]]:
db = StarRocksConnection()
pattern = _build_regex_pattern(sizes)
sql = f"""
SELECT
internal_ref_code,
magento_ref_code,
size_scale
FROM {TABLE_NAME}
WHERE LOWER(size_scale) REGEXP %s
GROUP BY internal_ref_code, magento_ref_code, size_scale
ORDER BY internal_ref_code, magento_ref_code
"""
return await db.execute_query_async(sql, params=(pattern,))
def _write_csv(rows: list[dict[str, Any]], path: str) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.writer(csvfile)
writer.writerow(["internal_ref_code", "magento_ref_code", "size_scale"])
for row in rows:
writer.writerow(
[row.get("internal_ref_code"), row.get("magento_ref_code"), row.get("size_scale")]
)
def _print_summary(rows: list[dict[str, Any]], sizes: list[int]) -> None:
internal_codes = {row.get("internal_ref_code") for row in rows}
magento_codes = {row.get("magento_ref_code") for row in rows}
print("\n" + "=" * 80)
print("NUMERIC SIZE SKUS")
print("=" * 80)
print(f"Table: {TABLE_NAME}")
print(f"Sizes filter: {', '.join(str(s) for s in sorted(set(sizes)))}")
print(f"Matched rows: {len(rows)}")
print(f"Distinct internal_ref_code: {len(internal_codes)}")
print(f"Distinct magento_ref_code: {len(magento_codes)}")
def _print_sample(rows: list[dict[str, Any]], limit: int = 30) -> None:
print("\nSample (first 30 rows):")
for row in rows[:limit]:
print(
f"- {row.get('internal_ref_code')} | {row.get('magento_ref_code')} | {row.get('size_scale')}"
)
async def _run() -> None:
if _skip_or_warn_if_missing_env():
return
sizes = _parse_sizes_env()
rows = await fetch_numeric_size_rows(sizes)
_print_summary(rows, sizes)
_print_sample(rows, limit=30)
_write_csv(rows, OUTPUT_CSV)
print(f"\nCSV written to: {OUTPUT_CSV}")
await StarRocksConnection.clear_pool()
if __name__ == "__main__":
asyncio.run(_run())
import asyncio
import os
import re
import sys
from collections import Counter
from typing import Any
# Ensure we can import from backend root
current_dir = os.path.dirname(os.path.abspath(__file__))
backend_root = os.path.dirname(current_dir)
sys.path.append(backend_root)
from common.starrocks_connection import StarRocksConnection
from config import STARROCKS_DB, STARROCKS_HOST, STARROCKS_PASSWORD, STARROCKS_USER
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
def _get_missing_env() -> list[str]:
missing = []
if not STARROCKS_HOST:
missing.append("STARROCKS_HOST")
if not STARROCKS_DB:
missing.append("STARROCKS_DB")
if not STARROCKS_USER:
missing.append("STARROCKS_USER")
if not STARROCKS_PASSWORD:
missing.append("STARROCKS_PASSWORD")
return missing
def _skip_or_warn_if_missing_env() -> bool:
missing = _get_missing_env()
if not missing:
return False
message = f"Missing StarRocks env vars: {', '.join(missing)}"
if "PYTEST_CURRENT_TEST" in os.environ:
import pytest
pytest.skip(message)
print(f"[SKIP] {message}")
return True
def _split_size_scale(size_scale: str | None) -> list[str]:
if not size_scale:
return []
return [token.strip() for token in size_scale.split("|") if token.strip()]
def _normalize_numeric_token(token: str) -> str | None:
if not token:
return None
token = token.strip().lower()
token = re.sub(r"cm$", "", token)
if re.fullmatch(r"\d+(\.\d+)?", token):
return token
return None
async def fetch_size_scale_rows() -> list[dict[str, Any]]:
db = StarRocksConnection()
sql = f"""
SELECT
size_scale,
COUNT(*) AS row_count
FROM {TABLE_NAME}
GROUP BY size_scale
"""
return await db.execute_query_async(sql)
def _build_numeric_summary(rows: list[dict[str, Any]]) -> Counter[str]:
counter: Counter[str] = Counter()
for row in rows:
size_scale = row.get("size_scale")
row_count = int(row.get("row_count") or 0)
for token in _split_size_scale(size_scale):
numeric_token = _normalize_numeric_token(token)
if numeric_token:
counter[numeric_token] += row_count
return counter
def _print_summary(counter: Counter[str]) -> None:
def _sort_key(val: str) -> float:
try:
return float(val)
except ValueError:
return float("inf")
tokens_sorted = sorted(counter.keys(), key=_sort_key)
print("\n" + "=" * 80)
print("NUMERIC SIZE TOKENS")
print("=" * 80)
print(f"Total unique numeric sizes: {len(tokens_sorted)}")
print("\nAll numeric sizes (sorted):")
print(", ".join(tokens_sorted))
print("\nCounts (descending):")
for token, count in counter.most_common():
print(f"- {token}: {count}")
async def _run() -> None:
if _skip_or_warn_if_missing_env():
return
rows = await fetch_size_scale_rows()
numeric_counter = _build_numeric_summary(rows)
_print_summary(numeric_counter)
await StarRocksConnection.clear_pool()
if __name__ == "__main__":
asyncio.run(_run())
......@@ -2,37 +2,54 @@ import requests
import json
import time
url = "http://localhost:5000/api/agent/chat"
# Use the DEV endpoint as per user logs
url = "http://localhost:5000/api/agent/chat-dev"
# Token can be anything for dev if middleware allows, or use the valid one
token = "071w198x23ict4hs1i6bl889fit5p3f7"
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
payload = {
"user_query": "tư vấn cho mình áo hoodie"
}
print(f"Sending AUTHENTICATED POST request to {url}...")
print(f"Token: {token}")
queries = [
"tìm cho mình chân váy màu đỏ",
"tìm quần màu đỏ"
]
print(f"Target URL: {url}")
for query in queries:
payload = {
"user_query": query
}
start = time.time()
try:
response = requests.post(url, json=payload, headers=headers, timeout=120)
print(f"Status Code: {response.status_code}")
print(f"Time taken: {time.time() - start:.2f}s")
if response.status_code == 200:
data = response.json()
print("Response JSON:")
# Print limit info specifically to check if limit increased to USER level (100)
if "limit_info" in data:
print("Limit Info:", json.dumps(data["limit_info"], indent=2))
print("\n" + "-"*50)
print(f"Testing Query: '{query}'")
start = time.time()
try:
response = requests.post(url, json=payload, headers=headers, timeout=120)
duration = time.time() - start
print(f"Status Code: {response.status_code}")
print(f"Time taken: {duration:.2f}s")
if response.status_code == 200:
data = response.json()
# print("Response JSON:", json.dumps(data, indent=2, ensure_ascii=False))
# Extract key info
ai_response = data.get("ai_response", "")
user_insight = data.get("user_insight", {})
product_ids = data.get("product_ids", [])
print(f"🤖 AI Response: {ai_response}")
print(f"📦 Product IDs: {product_ids}")
# print(f"🧠 User Insight: {json.dumps(user_insight, ensure_ascii=False)}")
else:
print(json.dumps(data, indent=2, ensure_ascii=False))
else:
print("Error Response:")
print(response.text)
print("❌ Error Response:")
print(response.text)
except Exception as e:
print(f"Error: {e}")
except Exception as e:
print(f"❌ Exception: {e}")
internal_ref_code,magento_ref_code,size_scale
1BJ25A001,1BJ25A001,104|110|116|122|128|134|140|152|164|98
1BJ25A001,1BJ25A001,152|128|134|116|122|140|164|110|98|104
1BJ25C001,1BJ25C001,134|164|152|140|128
1BJ25C004,1BJ25C004,110|116|122|128|134|140|152|164
1BK25C001,1BK25C001,140|134|164|152
1BK25C003,1BK25C003,110|116|122|128|134|140|152|164
1BK25S001,1BK25S001,110|116|122|128|134|140|152|164
1BK25S001,1BK25S001,128|152|140|134|116|110|122|164
1BK25S001,1BK25S001,116|152|122|128|134|110|164|140
1BL23S002,1BL23S002,110|140|90|130|120|160|100|150
1BL23S002,1BL23S002,110|100|90|150|140|130|160|120
1BL23S002,1BL23S002,100|110|160|120|140|130|150|90
1BL25C001,1BL25C001,110|134|116|122|98|104|140|128
1BL25C001,1BL25C001,134|110|116|98|128|104|122|140
1BP24C001,1BP24C001,134|98|164|152|140|128|122|116|110|104
1BP24C001,1BP24C001,98|164|152|140|134|128|122|116|110|104
1BP24C002,1BP24C002,140|110|104|122|134|128|164|116|152|98
1BP24C002,1BP24C002,128|134|116|110|104|140|152|164|98|122
1BP24C002,1BP24C002,152|140|134|128|122|116|110|104|98|164
1BP24C002,1BP24C002,128|122|104|134|140|152|164|98|116|110
1BP24C002,1BP24C002,164|134|104|140|116|152|110|122|128|98
1BP24W002,1BP24W002,164|152|128|134|140|98|104|110|116|122
1BP24W002,1BP24W002,98|116|104|122|128|134|140|152|110|164
1BP24W002,1BP24W002,122|128|98|164|152|140|104|110|116|134
1BP24W004,1BP24W004,122|98|164|152|134|116|128|110|140|104
1BP25C001,1BP25C001,140|134|116|110|164|122|128|152
1BP25C001,1BP25C001,164|128|116|134|110|140|152|122
1BP25C001,1BP25C001,128|122|110|116|140|134|152|164
1BP25C002,1BP25C002,134|140|152|164
1BP25C002,1BP25C002,134|164|152|140
1BP25C002,1BP25C002,140|152|164|134
1BP25C003,1BP25C003,128|164|110|152|122|140|116|134
1BP25C003,1BP25C003,110|116|122|128|134|140|152|164
1BP25C003,1BP25C003,140|116|122|134|152|164|128|110
1BP25C007,1BP25C007,134|140|152|164
1BP25W001,1BP25W001,110|116|122|128|134|140|152|164
1BP25W002,1BP25W002,164|128|140|98|152|116|134|122|110|104
1BP25W002,1BP25W002,116|152|98|122|140|164|104|128|110|134
1BP25W002,1BP25W002,104|110|116|122|128|134|140|152|164|98
1BP25W002,1BP25W002,140|104|122|128|134|116|152|98|164|110
1BP25W002,1BP25W002,98|104|164|140|134|122|152|116|110|128
1BP25W003,1BP25W003,110|104|98|164|116|152|128|122|134|140
1BP25W003,1BP25W003,164|134|110|122|140|128|98|116|104|152
1BP25W003,1BP25W003,140|164|128|104|98|110|122|152|116|134
1BP25W004,1BP25W004,122|116|164|110|134|152|128|140
1BP25W004,1BP25W004,140|122|116|164|134|152|110|128
1BS23S001,1BS23S001,110|90|160|100|150|140|130|120
1BS23S001,1BS23S001,120|90|160|150|140|130|110|100
1BS23S001,1BS23S001,90|160|150|140|130|120|110|100
1BS24S005,1BS24S005,160|140|130|100|120|110|150
1BS24S005,1BS24S005,150|140|120|110|130|100|160
1BS24S005,1BS24S005,160|130|140|150|110|100|120
1BS24S006,1BS24S006,152|116|98|122|140|110|128|104|164|134|120|160|130|150|100
1BS24S006,1BS24S006,104|140|98|152|128|134|122|110|116|164|120|100|130|160|150
1BS24S006,1BS24S006,152|134|110|98|116|104|122|140|164|128|130|120|100|150|160
1BS24S006,1BS24S006,152|128|110|140|134|98|104|122|116|164|100|120|160|130|150
1BS24S006,1BS24S006,120|110|100|150|160|130|140
1BS25C003,1BS25C003,122|140|152|98|110|116|104|134|128|164
1BS25C004,1BS25C004,152|128|140|164|98|116|134|122|104|110
1BS25C004,1BS25C004,164|122|128|140|116|134|104|110|152|98
1BS25C005,1BS25C005,134|140|152|164
1BS25C006,1BS25C006,134|140|152|164
1BS25C007,1BS25C007,104|110|116|122|128|134|140|152|164|98
1BS25S001,1BS25S001,128|134|140|152|116|110|164|122|100|130|120|150|160
1BS25S001,1BS25S001,160|140|110|100|150|120|130|134|128|164|116|122|152
1BS25S001,1BS25S001,128|110|164|134|140|116|152|122|160|100|150|130|120
1BS25S001,1BS25S001,164|152|140|134|110|116|122|128|100|150|120|130|160
1BS25S003,1BS25S003,152|134|164|140
1BS25S003,1BS25S003,134|140|152|164|160|150|120|110|130
1BS25S005,1BS25S005,134|164|152|140|110|104|98|116|122|128|92
1BS25S005,1BS25S005,134|164|152|122|92|128|116|98|110|104|140
1BS25S005,1BS25S005,116|134|122|140|104|128|92|98|110|164|152
1BS25S006,1BS25S006-FB571,98|92|164|152|140|134|128|122|116|110|104
1BS25S006,1BS25S006-FW305,110|104|116|122|128|134|140|152|164|92|98
1BS25S007,1BS25S007,98|104|110|116|122|128|134|140|152|164|92
1BS25S007,1BS25S007,104|152|140|122|128|98|164|110|116|134|92
1BS25S008,1BS25S008-FP064,116|104|140|98|152|134|128|122|110|164|92
1BS25S008,1BS25S008-SK010,104|134|122|98|164|128|110|140|152|116|92
1BS25S008,1BS25S008-SW012,92|140|122|104|152|164|98|128|134|110|116
1BS25S009,1BS25S009,116|104|110|164|128|122|134|98|140|152
1BS25S009,1BS25S009,122|128|140|134|98|110|116|152|164|104
1DS23W013,1DS23W013,100|150|120|110|140|130
1DS23W013,1DS23W013,120|100|150|110|140|130
1DS24C013,1DS24C013,110|116|122|128|134|140|152|164
1DS24C013,1DS24C013,122|110|116|128|134|140|152|164
1DS24C015,1DS24C015,134|128|98|110|104|140|122|152|116
1DS24C017,1DS24C017,104|122|110|134|116|140|98|128|164|152
1DS24C017,1DS24C017,134|128|164|110|104|98|116|122|140|152
1DS24C018,1DS24C018,104|98|128|140|110|164|134|152|116|122
1DS24C021,1DS24C021,98|122|152|110|134|128|140|104|116
1DS24W013,1DS24W013,134|128|164|152|140|122|116|110
1DS24W013,1DS24W013,110|116|122|128|164|152|140|134
1DS24W014,1DS24W014,110|134|140|152|122|128|116|164
1DS25C001,1DS25C001-SK010,164|140|116|134|152|122|128|110
1DS25C002,1DS25C002-SB001,140|164|134|152
1DS25C004,1DS25C004,134|140|128|116|110|122|98|104
1DS25C006,1DS25C006,140|116|128|164|110|152|122|98|104|134
1DS25C006,1DS25C006,122|134|116|110|152|128|104|98|164|140
1DS25C008,1DS25C008,100|130|120|140|110
1DS25C008,1DS25C008,100|130|110|120|140
1DS25C009,1DS25C009,104|140|122|98|128|134|116|152|110
1DS25C012,1DS25C012,98|104|134|152|116|122|128|110|140
1DS25C012,1DS25C012,128|116|110|122|152|140|98|104|134
1DS25C013,1DS25C013,122|152|164|116|110|128|140|134
1DS25C014,1DS25C014,110|128|116|122|134|98|152|140|104
1DS25C014,1DS25C014,104|110|116|122|128|134|140|152|98
1DS25C017,1DS25C017,134|164|152|140
1DS25C017,1DS25C017,140|164|134|152
1DS25C019,1DS25C019,98|110|152|116|140|134|128|122|104
1DS25C019,1DS25C019,98|110|140|152|122|116|128|134|104
1DS25C020,1DS25C020-CR158,104|110|116|122|128|134|140|152|98
1DS25C020,1DS25C020-SM289,104|110|116|122|128|134|140|152|98
1DS25S001,1DS25S001,116|122|128|98|134|164|152|140|104|110|92
1DS25S001,1DS25S001,98|152|140|134|128|92|122|110|164|116|104
1DS25S001,1DS25S001,140|128|98|164|122|110|92|116|152|134|104
1DS25S004,1DS25S004-SP072,122|116|134|140|128|104|110|152|98
1DS25S008,1DS25S008,140|104|110|116|122|98|128|134|152
1DS25S008,1DS25S008,98|152|140|134|128|122|116|110|104
1DS25S009,1DS25S009,140|134|122|128|116|164|110|152
1DS25S010,1DS25S010,98|128|122|140|104|116|152|110|134
1DS25S010,1DS25S010,134|152|110|128|104|140|116|98|122
1DS25S011,1DS25S011,134|122|116|128|140|152|98|110|104
1DS25S011,1DS25S011,116|134|98|122|140|128|152|104|110
1DS25S012,1DS25S012,128|140|134|104|110|116|98|152|122
1DS25S012,1DS25S012,98|110|134|116|104|128|152|140|122
1DS25S013,1DS25S013,98|104|110|116|122|128|134|140|152
1DS25W002,1DS25W002,110|140|134|122|116|152|128|164
1DS25W002,1DS25W002,110|122|116|164|140|134|152|128
1DS25W003,1DS25W003,134|140|152|164
1DS25W004,1DS25W004,104|110|116|122|128|134|140|98
1DS25W006,1DS25W006-FW330,104|110|116|122|128|134|140|98
1DS25W009,1DS25W009,134|140|152|164
1DS25W009,1DS25W009,134|164|152|140
1IS25W003,1IS25W003,128|110|122|98|140|134|152|164|104|116|92
1IS25W003,1IS25W003,116|152|98|122|164|128|110|140|134|104|92
1IT24W003,1IT24W003,134|140|152|164|98|104|110|116|122|128
1IT24W003,1IT24W003,104|128|122|134|116|98|164|110|152|140
1IT24W003,1IT24W003,104|110|140|116|128|122|164|98|152|134
1IT24W003,1IT24W003,110|98|122|104|152|134|116|164|128|140
1IT24W003,1IT24W003,134|140|116|98|110|152|164|104|128|122
1IT24W003,1IT24W003,116|164|140|134|110|152|98|128|104|122
1IT25W001,1IT25W001-FB582,98|128|152|140|164|116|104|134|110|122|92
1IT25W001,1IT25W001-FE146,152|110|122|164|134|104|140|98|116|128|92
1IT25W001,1IT25W001-SM042,92|152|98|116|104|110|164|140|122|134|128
1IT25W001,1IT25W001-SW001,140|128|152|116|122|104|98|164|134|110|92
1IT25W001,1IT25W001-SW011,104|110|116|122|128|134|140|152|164|92|98
1IT25W002,1IT25W002-FE146,104|110|122|134|98|140|116|128|152|164|92
1IT25W002,1IT25W002-SA014,104|110|116|122|128|134|140|152|164|92|98
1IT25W002,1IT25W002-SK010,164|116|122|134|110|128|140|104|98|152|92
1IT25W002,1IT25W002-SM042,116|134|104|152|128|92|122|164|140|110|98
1IT25W002,1IT25W002-SW011,104|110|116|122|128|134|140|152|164|92|98
1IT25W003,1IT25W003,140|98|104|122|152|116|128|110|164|134|92
1IT25W003,1IT25W003,140|104|110|128|98|164|152|116|134|122|92
1IT25W003,1IT25W003,140|110|104|116|164|98|134|122|128|152|92
1KS24C008,1KS24C008,140|134|110|104|116|122|98|128
1KS24C008,1KS24C008,122|110|134|128|116|140|98|104
1KS25C003,1KS25C003,152|98|122|164|140|134|128|116|110|104
1KS25C003,1KS25C003,134|104|164|152|122|140|116|110|128|98
1KS25C005,1KS25C005-CR158,104|110|116|122|128|134|140|152|164|98
1KS25C005,1KS25C005-SM289,104|110|116|122|128|134|140|152|164|98
1KS25C006,1KS25C006,152|128|110|122|140|116|98|104|134|164
1KS25C007,1KS25C007,116|104|128|110|122|98|134|140
1KS25C007,1KS25C007,116|140|128|134|122|98|104|110
1KS25C008,1KS25C008-CR157,130|140|110|120|100|150|160
1KS25C008,1KS25C008-FB608,140|120|110|100|130|160|150
1KS25C008,1KS25C008-FM344,100|110|160|140|120|150|130
1KS25S002,1KS25S002,164|110|134|98|104|140|116|152|122|128
1KS25S002,1KS25S002,164|134|140|98|110|128|116|122|104|152
1KS25S003,1KS25S003,134|128|152|116|122|98|164|110|104|140
1KS25S003,1KS25S003,116|110|128|164|134|122|104|98|152|140
1KS25S004,1KS25S004,110|120|130|140|150|160
1KS25W001,1KS25W001-CR156,134|122|140|164|98|104|128|152|110|116
1KS25W001,1KS25W001-SA074,104|110|116|122|128|134|140|152|164|98
1KS25W001,1KS25W001-SB001,104|110|116|122|128|134|140|152|164|98
1KS25W001,1KS25W001-SW059,134|104|152|164|140|110|122|98|116|128
1KS25W002,1KS25W002,122|128|164|152|110|116|134|140
1KS25W002,1KS25W002,110|116|122|128|134|140|152|164
1KS25W003,1KS25W003,104|110|116|122|128|134|140|152|164|98
1KS25W006,1KS25W006,110|116|122|128|134|140|152|164
1LA25S001,1LA25S001-FG150,110|116|134|122|140|152|104|128|98|164|92
1LA25S001,1LA25S001-FM150,134|98|122|110|104|140|152|116|128|92|164
1LA25S002,1LA25S002-FG150,98|128|164|152|104|140|110|116|122|134|92
1LA25S002,1LA25S002-PW202,134|140|110|116|122|152|128|98|164|104|92
1LA25S003,1LA25S003-FM319,134|140|152|164|116|98|110|104|122|128|92
1LA25S003,1LA25S003-PW204,104|110|116|134|122|152|128|140|98|164|92
1LA25S003,1LA25S003-SP346,104|110|116|122|128|134|140|152|164|98|92
1LA25S005,1LA25S005-CY067,122|98|92|152|164|140|116|128|134|104|110
1LA25S005,1LA25S005-FB558,110|98|164|152|140|134|128|122|116|104|92
1LA25W001,1LA25W001-FM335,122|128|110|164|152|98|116|140|104|134|92
1LA25W001,1LA25W001-FP074,98|128|116|110|140|122|164|152|134|104|92
1LA25W001,1LA25W001-SW406,164|116|110|134|98|122|140|128|152|104|92
1LA25W005,1LA25W005-FM338,128|164|98|152|92|110|134|140|104|116|122
1LA25W005,1LA25W005-FP076,104|122|152|98|92|128|116|134|110|140|164
1LB25W001,1LB25W001-CB385,104|110|116|122|128|134|140|152|164|98
1LB25W001,1LB25W001-CK096,104|110|116|122|128|134|140|152|164|98
1LB25W001,1LB25W001-CP032,122|152|110|98|140|116|134|104|128|164
1LB25W002,1LB25W002-SA014,116|98|140|110|164|104|134|122|128|152|92
1LS24W011,1LS24W011-FM292,116|128|140|152|122|98|134|104|110
1LS24W013,1LS24W013,128|122|116|110|134|104|98|140|164|152
1LS24W013,1LS24W013,104|134|128|152|164|140|98|122|116|110
1LS25S001,1LS25S001-FM314,152|122|134|128|116|104|110|164|140|98|92
1LS25S001,1LS25S001-FW294,152|110|164|116|134|98|128|140|122|104|92
1LS25S001,1LS25S001-SG261,92|110|152|104|98|122|128|134|140|164|116
1LS25S002,1LS25S002-FP062,104|98|92|164|152|140|134|128|122|116|110
1LS25S002,1LS25S002-SM096,164|104|110|116|122|128|134|140|152|98|92
1LS25S002,1LS25S002-SW001,134|128|152|164|92|122|98|116|110|104|140
1LS25S003,1LS25S003-FM312,110|116|152|122|140|128|104|134|164|98|92
1LS25S003,1LS25S003-SW001,140|134|128|122|116|110|92|164|98|152|104
1LS25S004,1LS25S004-FB551,134|104|116|110|128|122|98|164|152|140|92
1LS25S004,1LS25S004-FW298,104|110|116|122|128|134|140|152|164|92|98
1LS25S004,1LS25S004-SM610,98|104|110|116|122|128|134|140|152|164|92
1LS25S005,1LS25S005-SM610,98|164|152|140|134|128|122|116|110|104
1LS25S005,1LS25S005-SW001,98|164|152|140|134|128|122|116|110|104
1LS25S006,1LS25S006-FM317,152|128|104|98|110|116|122|134|140
1LS25S008,1LS25S008-SM610,122|110|104|140|164|128|152|116|98|134
1LS25S008,1LS25S008-SW001,104|98|110|116|134|164|128|152|140|122
1LS25S011,1LS25S011,116|110|122|128|134|140|152|164
1LS25S011,1LS25S011,110|116|122|128|134|140|152|164
1LS25S012,1LS25S012,140|116|122|98|164|152|128|104|110|134
1LS25W001,1LS25W001-FB573,110|140|122|92|152|164|116|98|104|128|134
1LS25W002,1LS25W002-FP071,92|122|128|104|98|110|116|134
1LS25W002,1LS25W002-SM289,104|110|116|122|128|134|92|98
1LS25W003,1LS25W003-SM610,152|92|98|110|140|134|122|104|116|128
1LS25W003,1LS25W003-SP010,104|92|122|116|98|110|128|152|164|140|134
1LS25W004,1LS25W004-SE346,98|104|110|122|140|116|134|92|128|152
1LS25W004,1LS25W004-SM610,116|104|92|110|122|98|128|134|140|152
1LS25W008,1LS25W008-SW001,104|110|116|122|128|134|140|152|92|98
1LS25W009,1LS25W009-SM289,104|122|134|98|116|110|164|140|152|128
1LS25W009,1LS25W009-SW011,104|110|116|122|128|134|140|152|164|98
1LS25W009,1LS25W009-SW100,104|110|116|122|128|134|140|152|164|98
1LS25W009,1LS25W009-SW119,134|116|164|110|104|98|140|152|128|122
1LS25W010,1LS25W010-PB542,104|110|116|122|128|134|140|152|98
1LS25W010,1LS25W010-PM103,104|110|116|122|128|134|140|152|98
1LS25W011,1LS25W011-FM332,98|110|116|104|164|128|122|140|134|152
1LS25W013,1LS25W013-SA014,134|140|152|164
1LS25W014,1LS25W014-SA014,140|164|134|152
1LS25W015,1LS25W015-SW001,128|122|140|116|104|134|110|98
1OT25C001,1OT25C001-SK010,140|104|134|98|164|122|128|116|152|110
1OT25C002,1OT25C002-SK010,98|116|140|164|128|104|122|152|110|134
1OT25C003,1OT25C003,140|110|134|164|104|152|116|122|128|98
1OT25C003,1OT25C003,122|98|104|128|116|152|134|110|140|164
1OT25C003,1OT25C003,140|152|104|128|134|116|110|98|164|122
1OT25W001,1OT25W001-FM300,104|128|98|92|110|122|116|152|134|164|140
1OT25W003,1OT25W003,134|140|152|164
1OT25W004,1OT25W004,110|116|122|128|134|140|152|164
1OT25W008,1OT25W008-FE161,104|110|116|122|128|134|140|152|164|98
1OT25W008,1OT25W008-FM347,104|110|116|122|128|134|140|152|164|98
1OT25W014,1OT25W014,104|110|116|122|128|134|140|152|164|98
1ST25C001,1ST25C001-SE011,104|110|116|122|128|134|140|152|98
1ST25C001,1ST25C001-SM192,104|110|116|122|128|134|140|152|98
1ST25C002,1ST25C002,140|152|134|164
1ST25C002,1ST25C002,164|134|140|152
1ST25C004,1ST25C004,98|152|104|122|140|110|116|128|134
1ST25C004,1ST25C004,128|104|116|110|122|134|98|140|152
1ST25C004,1ST25C004,104|110|116|122|128|134|140|152|98
1ST25C006,1ST25C006,134|140|152|164
1ST25S001,1ST25S001,116|92|104|98|134|122|110|128
1ST25S001,1ST25S001,98|92|134|128|122|116|110|104
1ST25S003,1ST25S003-SM085,116|122|128|164|152|140|134|110
1ST25S003,1ST25S003-SR072,110|164|152|140|134|128|122|116
1ST25S005,1ST25S005-FP068,98|134|104|116|140|152|110|164|128|122
1ST25S005,1ST25S005-SM610,110|98|116|134|122|140|104|152|164|128
1ST25S006,1ST25S006,164|152|140|134|110|116|128|122
1ST25S006,1ST25S006,128|110|116|164|122|152|134|140
1ST25W001,1ST25W001-SB001,128|110|104|164|122|98|140|152|134|116
1ST25W001,1ST25W001-SM610,116|152|128|110|122|134|164|140|104|98
1ST25W001,1ST25W001-SP072,98|128|140|152|104|116|110|134|122|164
1ST25W002,1ST25W002-FE151,110|116|128|152|122|134|98|164|104|140
1ST25W002,1ST25W002-FY058,140|134|110|116|122|128|98|152|164|104
1ST25W002,1ST25W002-SM610,104|110|116|122|128|134|140|152|164|98
1ST25W003,1ST25W003-SP072,104|92|128|134|116|110|122|98|140
1ST25W004,1ST25W004,110|164|128|98|134|152|104|116|140|122
1ST25W004,1ST25W004,98|110|116|164|122|104|140|128|134|152
1ST25W005,1ST25W005-FE156,152|110|104|116|164|134|122|140|128|98
1ST25W005,1ST25W005-FM343,98|128|134|152|140|164|110|104|116|122
1ST25W006,1ST25W006-FE156,152|140|116|110|122|128|104|134|164|98
1ST25W006,1ST25W006-FM343,140|98|164|128|152|134|116|122|104|110
1ST25W008,1ST25W008,104|110|116|122|128|134|140|152|164|98
1TA23S003,1TA23S003,140|90|150|120|130|110|100
1TA23S003,1TA23S003,150|120|130|90|110|140|100
1TA23S003,1TA23S003,120|150|140|130|90|100|110
1TC24W001,1TC24W001,134|122|128|116|98|140|110|104|160|100|120|130|150
1TC24W001,1TC24W001,122|98|104|128|140|116|110|134|120|160|100|150|130
1TC25C001,1TC25C001,100|110|120|130|140|150|160
1TC25W001,1TC25W001,160|140|110|150|130|120|100
1TC25W001,1TC25W001,100|110|120|130|140|150|160
1TE24C001,1TE24C001,150|160|120|130|100|140|110
1TE24C001,1TE24C001,100|140|120|150|130|110|160
1TE25C003,1TE25C003-SA089,160|110|120|130|140|100|150
1TE25C003,1TE25C003-SR002,120|150|100|140|130|110|160
1TE25C004,1TE25C004-FB608,160|140|110|100|150|130|120
1TE25C006,1TE25C006,100|110|120|130|140|150|160
1TE25C007,1TE25C007,100|110|120|130|140|150|160
1TE25C008,1TE25C008,100|110|120|130|140|150|160
1TE25W001,1TE25W001,110|160|140|150|120|100|130
1TE25W001,1TE25W001,110|130|150|120|140|100|160
1TE25W003,1TE25W003-SB227,100|110|120|130|140|150|160
1TE25W006,1TE25W006,100|110|120|130|140|150|160
1TE25W006,1TE25W006,150|120|110|100|130|140|160
1TE25W006,1TE25W006,100|110|120|130|150|140|160
1TE25W009,1TE25W009-FB607,100|110|120|130|140|150|160
1TE25W009,1TE25W009-SR002,100|110|120|130|140|150|160
1TE25W009,1TE25W009-SR027,100|110|120|130|140|150|160
1TH25W001,1TH25W001,134|140|152|164
1TH25W001,1TH25W001,164|152|140|134
1TL25C001,1TL25C001,134|164|152|140
1TL25C001,1TL25C001,164|152|140|134
1TL25C003,1TL25C003,110|128|164|116|104|140|152|122|98|134
1TL25C003,1TL25C003,122|110|164|104|134|98|128|116|152|140
1TL25W001,1TL25W001-SE240,128|110|134|152|164|116|98|122|140|104
1TL25W001,1TL25W001-SK010,128|140|152|98|164|116|104|134|110|122
1TL25W001,1TL25W001-SW119,98|122|128|152|140|134|110|104|116|164
1TL25W003,1TL25W003-FE163,104|110|116|122|128|134|140|152|164|92|98
1TL25W003,1TL25W003-SK010,152|116|140|110|92|98|122|164|128|104|134
1TL25W003,1TL25W003-SW119,122|92|152|110|116|140|134|164|104|128|98
1TL25W006,1TL25W006-SP072,104|110|116|122|128|134|140|152|164|98
1TO24C004,1TO24C004,122|134|128|164|152|116|140|110
1TO24C004,1TO24C004,128|152|110|140|122|164|134|116
1TO24S006,1TO24S006,110|104|98|134|128|122|116
1TO24S006,1TO24S006,122|116|98|134|128|110|104
1TO24S006,1TO24S006,98|134|128|122|116|110|104
1TO24W002,1TO24W002,98|152|140|134|128|122|116|110|104
1TO24W002,1TO24W002,152|104|110|116|122|128|134|98|140
1TO25C004,1TO25C004,110|164|134|122|140|128|116|152
1TO25C005,1TO25C005,122|116|152|128|110|104|140|98|134
1TO25C005,1TO25C005,98|128|140|134|104|110|116|152|122
1TO25C006,1TO25C006,110|116|122|128|134|140|152|164
1TO25C008,1TO25C008,110|116|122|128|134|140|152|164
1TO25S002,1TO25S002,104|152|116|110|140|134|128|122|98|164
1TO25S002,1TO25S002,128|122|134|98|110|164|140|104|116|152
1TO25W001,1TO25W001,104|110|116|122|128|134|140|152|164|98
1TO25W001,1TO25W001,116|164|110|128|104|140|98|152|134|122
1TP25C002,1TP25C002-SB227,134|140|152|116|164|98|104|122|128|110
1TP25C002,1TP25C002-SM610,116|134|98|164|128|122|152|110|104|140
1TP25C002,1TP25C002-SR072,104|110|116|122|128|134|140|152|164|98
1TP25C005,1TP25C005,134|140|152|164
1TP25S001,1TP25S001,120|140|110|160|90|150|130|100|98|92|164|152|134|128|122|116|104
1TP25S001,1TP25S001,150|120|140|100|160|110|90|130|98|92|122|164|116|104|128|134|152
1TP25S003,1TP25S003,134|164|152|140
1TP25S003,1TP25S003,164|140|134|152
1TS25C003,1TS25C003-FW313,128|140|98|122|164|104|134|116|152|110
1TS25C003,1TS25C003-FW565,164|134|116|110|122|104|98|128|140|152
1TS25C003,1TS25C003-SM063,98|122|134|110|164|104|140|152|116|128
1TS25C004,1TS25C004-SW001,134|140|164|152
1TS25C007,1TS25C007-SK010,140|134|164|152|104|110|116|98|122|128
1TS25C008,1TS25C008,110|116|122|128|134|140|152|164
1TS25S001,1TS25S001-SW001,104|122|128|152|164|116|134|110|140|92|98
1TS25S003,1TS25S003-SP010,128|134|92|164|152|140|116|104|110|122|98
1TS25S003,1TS25S003-SW001,98|164|152|140|134|128|122|116|110|104|92
1TS25S004,1TS25S004,116|110|134|98|104|92|128|122
1TS25S004,1TS25S004,98|104|110|116|122|128|134|92
1TS25S004,1TS25S004,134|104|110|128|116|122|92|98
1TS25S004,1TS25S004,104|122|98|92|134|128|116|110
1TS25S007,1TS25S007-SW001,128|164|110|104|140|152|98|134|116|122
1TS25S007,1TS25S007-SW410,104|110|116|122|128|134|140|98
1TS25S008,1TS25S008,164|140|134|122|110|128|152|116
1TS25S008,1TS25S008,152|140|128|134|110|116|164|122
1TS25S010,1TS25S010-SM610,152|110|116|140|122|104|98|164|134|128
1TS25S010,1TS25S010-SP010,152|110|104|140|134|116|164|122|128|98
1TS25S010,1TS25S010-SW001,128|140|122|104|164|116|98|152|134|110
1TS25W001,1TS25W001-SK010,140|134|164|152
1TS25W001,1TS25W001-SP072,134|140|152|164
1TS25W001,1TS25W001-SW119,152|134|164|140
1TS25W002,1TS25W002,134|164|140|152
1TS25W002,1TS25W002,164|152|134|140
1TS25W003,1TS25W003-SP010,110|92|122|128|116|104|98
1TS25W006,1TS25W006-SM610,152|104|116|128|134|140|98|122|110|164
1TS25W006,1TS25W006-SP010,140|116|122|152|134|164|128|98|110|104
1TS25W006,1TS25W006-SW119,110|122|116|140|152|98|128|164|134|104
1TW24C001,1TW24C001,140|104|110|116|122|128|134|98|152
1TW24C001,1TW24C001,152|140|98|134|128|122|116|110|104
1TW24C006,1TW24C006-SE249,152|140|134|164
1TW24W003,1TW24W003-SB227,98|152|140|134|104|110|128|164|116|122
1TW24W003,1TW24W003-SM289,98|164|152|140|134|128|122|116|110|104
1TW24W007,1TW24W007-SW033,110|164|152|140|134|128|122|116
1TW24W009,1TW24W009-SM289,98|110|116|104|140|134|128|122|152|164
1TW25C001,1TW25C001,134|152|140|164
1TW25C001,1TW25C001,134|164|140|152
1TW25C001,1TW25C001,140|152|134|164
1TW25C002,1TW25C002-SP010,140|134|152|164|128|116|122|104|98|110
1TW25C002,1TW25C002-SW033,110|116|122|128|134|140|152|164
1TW25C003,1TW25C003,104|110|116|122|128|134|140|152|164|98
1TW25C004,1TW25C004,140|164|134|152
1TW25C004,1TW25C004,152|140|134|164
1TW25C005,1TW25C005-SK010,164|134|110|116|140|122|98|128|104|152
1TW25C005,1TW25C005-SM610,140|98|164|134|104|122|110|128|116|152
1TW25C005,1TW25C005-SW033,116|98|140|164|128|104|122|110|134|152
1TW25W001,1TW25W001-SP072,98|92|164|152|122|128|116|140|110|104|134
1TW25W001,1TW25W001-SW011,110|164|140|116|152|134|98|104|122|128|92
1TW25W002,1TW25W002-SA871,104|164|116|140|152|110|128|134|98|122
1TW25W002,1TW25W002-SB127,104|110|116|122|128|134|140|152|164|98
1TW25W002,1TW25W002-SM610,98|164|110|122|140|104|134|128|152|116
1TW25W002,1TW25W002-SW033,104|110|116|122|128|134|140|152|164|98
1TW25W003,1TW25W003-SP072,122|98|128|152|104|140|116|134|164|110
1TW25W004,1TW25W004-SA871,122|134|98|140|110|152|128|116|104|164
1TW25W004,1TW25W004-SW011,104|110|116|122|128|134|140|152|164|98
1TW25W005,1TW25W005-SB227,152|110|164|104|140|98|116|128|134|122
1TW25W005,1TW25W005-SB319,152|122|98|164|116|110|128|140|104|134
1TW25W005,1TW25W005-SM022,104|110|116|122|128|134|140|152|164|98
1TW25W005,1TW25W005-SM042,104|110|116|122|128|134|140|152|164|98
1TW25W005,1TW25W005-SX007,104|110|116|122|128|134|140|152|164|98
1TW25W006,1TW25W006,134|140|152|164
1TW25W006,1TW25W006,140|164|152|134
1TW25W007,1TW25W007-SB319,140|104|110|134|152|128|116|164|98|122
1TW25W007,1TW25W007-SM610,98|164|128|134|110|152|116|140|104|122
1TW25W007,1TW25W007-SW011,134|140|164|152|116|128|110|104|98|122
1TW25W008,1TW25W008-FM349,104|110|116|122|128|92|98
1TW25W008,1TW25W008-FP073,104|122|110|98|92|116|128
1TW25W009,1TW25W009-SM610,128|92|122|116|104|110|98|134|140|152
1TW25W009,1TW25W009-SW011,104|92|110|116|98|122|128|134|140|152
1TW25W010,1TW25W010,134|152|128|140|164|122|116|110
1TW25W010,1TW25W010,140|134|128|164|152|110|122|116
1TW25W011,1TW25W011-SM610,104|110|116|122|128|134|140|152|164|98
1TW25W011,1TW25W011-SW033,104|110|116|122|128|134|140|152|164|98
1US25A001,1US25A001,130|110|150|140|160|120
1US25A001,1US25A001,150|120|160|140|110|130
1US25A001,1US25A001,110|120|130|140|150|160
1US25A001,1US25A001,110|160|120|150|130|140
1US25A001,1US25A001,150|130|140|120|110|160
1US25A002,1US25A002,100|110|120|130|140|150|160
1US25A002,1US25A002,160|110|120|130|140|150|100
1US25A002,1US25A002,130|120|110|160|140|150|100
1US25A003,1US25A003,160|150|140
1US25A003,1US25A003,150|140|160
1US25A003,1US25A003,140|150|160
1US25A003,1US25A003,150|160|140
2BJ25A003,2BJ25A003,110|116|128|98|134|104|140|122|164|152
2BJ25A003,2BJ25A003,98|110|128|122|152|140|134|164|116|104
2BJ25C001,2BJ25C001,122|164|98|134|116|110|152|140|128|104
2BJ25C001,2BJ25C001,116|122|128|98|140|152|164|134|110|104
2BJ25C003,2BJ25C003,110|116|122|128|134|140|152|164
2BJ25C004,2BJ25C004,134|140|152|164
2BJ25W001,2BJ25W001,104|110|116|122|128|134|140|152|164|98
2BK22W001,2BK22W001,110|120|130|140|150|160|90|100
2BK22W001,2BK22W001,130|140|150|160|90|100|110|120
2BK22W001,2BK22W001,100|110|120|130|140|150|160|90
2BK22W001,2BK22W001,90|160|150|100|110|120|130|140
2BK22W001,2BK22W001,110|100|90|160|150|140|130|120
2BK25A001,2BK25A001,152|164|116|134|140|98|128|104|110|122
2BK25A001,2BK25A001,164|134|110|116|152|128|104|140|98|122
2BK25A001,2BK25A001,98|104|110|116|128|122|152|164|140|134
2BK25A002,2BK25A002,116|164|104|110|122|134|140|152|98|128
2BK25A002,2BK25A002,128|98|104|116|134|140|110|164|122|152
2BK25A002,2BK25A002,104|140|116|152|134|98|122|164|128|110
2BK25C001,2BK25C001,110|116|122|128|134|140|152|164
2BK25C001,2BK25C001,134|116|140|152|110|164|128|122
2BK25S002,2BK25S002,164|128|140|98|110|122|134|104|152|116
2BK25S002,2BK25S002,164|116|152|110|122|98|128|104|140|134
2BK25S005,2BK25S005,128|140|164|116|122|110|152|134
2BK25S005,2BK25S005,128|104|116|110|164|122|134|98|152|140
2BK25W001,2BK25W001,110|116|122|128|134|140|152|164
2BK25W001,2BK25W001,122|110|116|164|134|140|128|152
2BP23W005,2BP23W005,128|134|122|104|98|164|116|152|110|140|150|120|100|160|90|130
2BP23W005,2BP23W005,128|110|122|152|140|164|104|98|134|116|160|90|100|120|130|150
2BP24C001,2BP24C001,110|164|122|128|152|104|116|134|98|140
2BP24C001,2BP24C001,116|98|152|164|104|110|122|128|134|140
2BP24C002,2BP24C002,116|110|164|104|134|140|122|98|128|152
2BP24C002,2BP24C002,140|152|164|104|128|116|134|110|122|98
2BP24C002,2BP24C002,140|134|110|116|122|152|128|164|104|98
2BP24W001,2BP24W001,110|116|122|128|164|152|134|140
2BP24W001,2BP24W001,116|164|122|128|134|140|152|110
2BP24W002,2BP24W002,134|152|122|110|140|116|164|128|98|104
2BP24W009,2BP24W009,116|164|110|104|134|122|152|128|98|140
2BP24W011,2BP24W011,116|104|110|122|128|134|140|152|164|98
2BP24W011,2BP24W011,110|164|98|104|140|128|122|134|116|152
2BP24W011,2BP24W011,116|122|128|134|140|152|164|98|110|104
2BP24W014,2BP24W014,116|128|164|140|152|134|110|122
2BP24W014,2BP24W014,128|152|140|122|116|164|110|134
2BP24W014,2BP24W014,164|152|140|134|116|128|122|110
2BP25C002,2BP25C002,116|164|152|140|128|134|110|122
2BP25C002,2BP25C002,122|140|152|116|134|110|128|164
2BP25C004,2BP25C004,134|140|152|164
2BP25C005,2BP25C005,110|116|122|128|134|140|152|164
2BP25W001,2BP25W001,110|116|122|128|134|140|152|164
2BP25W001,2BP25W001,140|134|164|152|128|110|122|116
2BP25W002,2BP25W002,140|98|152|122|128|110|164|104|116|134
2BP25W002,2BP25W002,110|122|152|164|140|98|104|128|116|134
2BP25W002,2BP25W002,152|116|128|134|140|122|98|110|104|164
2BP25W002,2BP25W002,104|110|116|122|128|134|140|152|164|98
2BP25W002,2BP25W002,98|134|116|140|122|164|152|128|104|110
2BP25W003,2BP25W003-FA151,110|116|122|128|134|140|152|164
2BP25W004,2BP25W004,122|110|164|140|152|128|98|104|116|134
2BP25W005,2BP25W005,110|152|134|104|128|116|122|98|164|140
2BP25W005,2BP25W005,104|110|116|122|128|134|140|152|164|98
2BP25W006,2BP25W006,122|164|110|128|140|116|152|134
2BP25W006,2BP25W006,128|122|116|140|152|110|134|164
2BP25W007,2BP25W007,152|98|110|104|116|164|128|140|134|122
2BP25W007,2BP25W007,164|116|134|128|98|152|140|104|110|122
2BP25W007,2BP25W007,98|116|134|122|140|128|152|110|164|104
2BP25W007,2BP25W007,104|110|116|122|128|134|140|152|164|98
2BP25W008,2BP25W008,110|140|152|98|128|122|164|134|116|104
2BP25W008,2BP25W008,110|116|122|128|134|140|152|164
2BP25W008,2BP25W008,128|116|152|104|134|110|98|164|140|122
2BS23S004,2BS23S004,110|120|90|150|100|160|130|140
2BS23S004,2BS23S004,100|160|140|90|150|110|130|120
2BS23S004,2BS23S004,120|140|130|160|90|110|100|150
2BS24C003,2BS24C003,120|150|110|100|160|130|140
2BS24C003,2BS24C003,110|120|130|140|160|100|150
2BS24S001,2BS24S001,110|98|152|140|134|164|128|122|116|104
2BS24S001,2BS24S001,134|140|128|98|122|104|152|164|116|110
2BS24S001,2BS24S001,134|128|122|116|140|110|152|164|104|98
2BS24S001,2BS24S001,116|104|164|122|152|134|140|98|110|128
2BS24S001,2BS24S001,152|98|164|140|128|104|134|116|110|122
2BS24S001,2BS24S001,104|110|116|122|128|134|140|152|164|98
2BS24S001,2BS24S001,116|128|134|98|140|122|104|152|164|110
2BS24S006,2BS24S006,110|160|150|140|130|120|100
2BS24S006,2BS24S006,160|140|110|150|120|130|100
2BS24S009,2BS24S009,140|130|120|110|100|150|160
2BS24S016,2BS24S016,98|164|104|140|134|110|116|152|128|122
2BS24S016,2BS24S016,128|122|116|134|98|140|164|152|104|110
2BS24W006,2BS24W006,134|140|164|98|152|122|128|116|110|104
2BS24W006,2BS24W006,116|122|98|164|152|104|140|134|110|128
2BS24W006,2BS24W006,116|98|128|134|140|152|164|122|110|104
2BS25C001,2BS25C001-FA140,110|104|122|128|116|134|164|152|140|98
2BS25C001,2BS25C001-SG153,152|164|128|140|98|104|110|116|122|134
2BS25C002,2BS25C002,116|134|128|164|110|152|140|122
2BS25C002,2BS25C002,152|122|116|110|134|140|164|128
2BS25C002,2BS25C002,152|122|140|164|110|116|134|128
2BS25C003,2BS25C003,128|134|140|152|110|164|116|122
2BS25C003,2BS25C003,164|128|134|122|110|116|152|140
2BS25C005,2BS25C005,134|140|152|164
2BS25S001,2BS25S001,140|152|164|128|92|104|98|122|116|110|134
2BS25S001,2BS25S001,110|104|98|92|164|152|140|134|128|122|116
2BS25S001,2BS25S001,152|140|134|128|122|116|110|104|164|92|98
2BS25S001,2BS25S001,140|122|116|128|98|164|110|152|104|134|92
2BS25S001,2BS25S001,98|164|152|140|134|128|122|116|110|104|92
2BS25S002,2BS25S002,122|164|152|134|128|110|116|140
2BS25S002,2BS25S002,122|116|152|164|110|128|140|134
2BS25S002,2BS25S002,128|140|122|164|116|152|110|134
2BS25S003,2BS25S003,98|164|152|140|134|128|122|116|110|104
2BS25S003,2BS25S003,116|110|152|164|128|104|122|98|134|140
2BS25S003,2BS25S003,152|140|134|128|122|116|110|104|164|98
2BS25S007,2BS25S007,140|98|164|152|110|122|116|134|104|128
2BS25S007,2BS25S007,98|104|122|164|110|128|152|134|140|116
2BS25S007,2BS25S007,122|98|128|116|140|134|164|110|152|104
2BS25S009,2BS25S009-FB564,164|98|92|152|140|134|128|122|116|110|104
2BS25S009,2BS25S009-FE143,98|92|164|152|140|134|128|122|116|110|104
2BS25S009,2BS25S009-FG155,98|92|164|152|140|134|128|122|116|110|104
2BS25S009,2BS25S009-FK116,98|92|164|152|140|134|128|122|116|110|104
2BS25S009,2BS25S009-SA332,152|98|92|164|140|134|128|122|116|110|104
2BS25S009,2BS25S009-SB001,98|92|164|152|140|134|128|122|116|110|104
2BS25S011,2BS25S011,140|152|128|116|164|122|134|110
2BS25S011,2BS25S011,164|140|128|110|116|134|122|152
2BS25S012,2BS25S012,134|122|140|152|116|110|128|164
2BS25S014,2BS25S014,128|110|116|122|134|140|152|164
2BS25S014,2BS25S014,122|164|110|116|140|128|152|134
2BS25S016,2BS25S016,134|122|128|140|152|110|116|164
2BS25S016,2BS25S016,122|128|116|110|152|134|140|164
2BS25S017,2BS25S017,110|152|140|104|122|134|98|128|164|116
2BS25S017,2BS25S017,116|152|110|122|104|140|164|98|134|128
2BS25S018,2BS25S018,152|110|116|122|104|128|140|134|98|164
2BS25S018,2BS25S018,104|134|122|152|98|164|110|116|140|128
2BS25W001,2BS25W001,104|110|116|122|128|134|140|152|164|98
2BS25W002,2BS25W002,104|110|116|122|128|134|140|152|164|98
2IS25W001,2IS25W001,134|98|128|122|152|110|116|140|92|164|104
2IS25W001,2IS25W001,140|116|110|128|92|104|98|122|134|164|152
2IS25W001,2IS25W001,134|110|152|98|92|140|164|122|104|116|128
2IT24W001,2IT24W001,104|110|116|122|98|134|140|164|128|152
2IT24W001,2IT24W001,116|110|104|134|140|152|164|98|122|128
2IT25W001,2IT25W001,152|122|128|140|134|164|110|104|116|92|98
2IT25W001,2IT25W001,164|98|110|104|122|134|92|116|152|128|140
2IT25W001,2IT25W001,104|110|116|122|128|134|140|152|164|92|98
2IT25W001,2IT25W001,122|152|134|128|164|110|104|98|92|140|116
2IT25W002,2IT25W002,116|128|110|104|122|140|98|92|164|134|152
2IT25W002,2IT25W002,164|92|116|134|122|152|140|98|110|128|104
2IT25W002,2IT25W002,122|140|110|164|128|92|98|152|134|116|104
2IT25W003,2IT25W003,92|140|122|110|134|104|116|128|164|152|98
2IT25W003,2IT25W003,140|134|164|92|128|110|152|116|122|104|98
2IT25W003,2IT25W003,140|98|122|104|128|110|164|92|152|116|134
2LA25S001,2LA25S001-FB554,134|140|128|122|152|164|98|116|110|104|92
2LA25S001,2LA25S001-FB555,140|152|98|104|110|116|122|128|134|164|92
2LA25S001,2LA25S001-PW200,152|122|140|128|134|116|98|110|164|104|92
2LA25S002,2LA25S002-FB564,152|140|134|104|128|122|164|116|110|98|92
2LA25S002,2LA25S002-SA956,104|110|116|122|128|134|140|152|164|92|98
2LA25S002,2LA25S002-SR347,104|110|116|122|128|134|140|152|164|92|98
2LA25S004,2LA25S004-FB554,140|152|134|128|98|122|116|110|164|104|92
2LA25S004,2LA25S004-FB564,98|104|110|116|122|128|134|140|152|164|92
2LA25S004,2LA25S004-SA956,104|110|116|122|128|134|140|152|164|92|98
2LA25S004,2LA25S004-SK231,98|128|134|122|116|104|140|110|152|164|92
2LA25S005,2LA25S005,134|110|128|122|116|98|164|152|140|104|92
2LA25W001,2LA25W001-FB597,128|92|152|140|122|164|110|98|104|134|116
2LA25W001,2LA25W001-FB598,122|152|140|110|92|164|128|134|98|116|104
2LA25W001,2LA25W001-SA990,104|110|116|122|128|134|140|152|164|92|98
2LA25W001,2LA25W001-SL399,104|110|116|122|128|134|140|152|164|92|98
2LA25W001,2LA25W001-SR365,104|110|116|122|128|134|140|152|164|92|98
2LA25W001,2LA25W001-SW429,104|110|116|122|128|134|140|152|164|92|98
2LA25W002,2LA25W002,110|134|164|128|152|92|140|98|104|122|116
2LA25W002,2LA25W002,116|134|104|140|128|122|152|110|92|164|98
2LA25W002,2LA25W002,152|116|164|104|122|134|110|98|128|140|92
2LA25W002,2LA25W002,104|110|116|122|128|134|140|152|164|92|98
2LA25W003,2LA25W003-FB600,98|134|122|140|152|116|92|164|104|128|110
2LA25W003,2LA25W003-SR350,104|140|128|122|110|92|116|98|134|152|164
2LA25W004,2LA25W004-FB598,110|164|92|116|128|104|134|140|152|98|122
2LA25W004,2LA25W004-FB600,92|122|152|164|140|128|134|98|104|116|110
2LA25W004,2LA25W004-SA342,110|164|98|122|104|92|128|116|140|152|134
2LS24W013,2LS24W013-SW001,116|128|110|104|122|98|134
2LS24W014,2LS24W014,122|116|98|134|104|110|128
2LS25S001,2LS25S001-SA237,110|98|164|152|140|134|128|122|116|104|92
2LS25S001,2LS25S001-SB001,152|140|134|128|122|116|110|104|98|164|92
2LS25S001,2LS25S001-SW001,104|110|116|122|128|134|140|152|164|98|92
2LS25S002,2LS25S002-SA237,110|104|116|122|128|134|140|152|164|98|92
2LS25S002,2LS25S002-SB162,104|110|116|122|128|134|140|152|164|98|92
2LS25S003,2LS25S003-SB162,140|134|128|98|122|116|110|104|152|164|92
2LS25S003,2LS25S003-SG313,98|92|164|152|140|134|128|122|116|110|104
2LS25S003,2LS25S003-SK010,116|164|110|104|140|98|134|128|152|122|92
2LS25S004,2LS25S004-SB001,98|164|152|140|134|128|122|116|110|104
2LS25S004,2LS25S004-SW001,128|134|140|152|164|98|122|116|110|104
2LS25S005,2LS25S005-SA237,104|110|116|122|128|134|140|152|164|98
2LS25S005,2LS25S005-SB001,134|122|116|104|110|98|164|152|140|128
2LS25S005,2LS25S005-SB162,98|164|152|140|134|128|122|116|110|104
2LS25S006,2LS25S006-FA139,104|92|116|128|122|110|98|134|152|164|140
2LS25S006,2LS25S006-SR191,110|98|92|104|134|128|122|116|140|164|152
2LS25S006,2LS25S006-SW001,98|128|134|122|110|92|116|104|164|140|152
2LS25W001,2LS25W001-SA010,104|110|116|122|128|134|140|152|164|92|98
2LS25W001,2LS25W001-SB162,104|110|116|122|128|134|140|152|164|92|98
2LS25W001,2LS25W001-SR072,140|98|152|116|110|122|92|104|134|128|164
2LS25W002,2LS25W002-SA010,104|110|116|122|128|134|140|152|164|92|98
2LS25W002,2LS25W002-SA014,116|152|110|164|134|98|104|140|122|128|92
2LS25W002,2LS25W002-SA237,104|110|116|122|128|134|140|152|164|92|98
2LS25W002,2LS25W002-SB001,92|140|152|104|110|128|98|116|122|134|164
2LS25W002,2LS25W002-SB162,104|110|116|122|128|134|140|152|164|92|98
2LS25W002,2LS25W002-SR072,128|152|116|122|110|92|134|98|140|104|164
2LS25W002,2LS25W002-SR366,104|110|116|122|128|134|140|152|164|92|98
2LS25W004,2LS25W004-FK124,122|98|134|116|110|140|128|164|152|104|92
2LS25W004,2LS25W004-SB227,122|164|92|134|104|140|98|128|152|116|110
2LS25W005,2LS25W005-FB574,110|122|98|128|104|116|134|92
2LS25W005,2LS25W005-FB575,98|134|122|110|104|92|128|116
2LS25W006,2LS25W006-SA014,152|134|164|140|110|92|128|98|116|122|104
2LS25W006,2LS25W006-SB001,128|92|98|164|110|104|134|116|122|140|152
2LS25W007,2LS25W007-SB227,140|116|110|104|164|92|152|98|134|128|122
2LS25W007,2LS25W007-SW001,122|140|164|110|98|152|104|134|116|92|128
2LS25W008,2LS25W008-FK124,116|164|128|104|122|110|140|134|152|98|92
2LS25W008,2LS25W008-SA014,128|140|134|104|152|122|164|116|98|110|92
2LS25W008,2LS25W008-SB001,104|110|116|122|128|134|140|152|164|92|98
2LS25W009,2LS25W009-FB576,128|98|134|116|110|152|122|104|164|140
2LS25W009,2LS25W009-FB577,164|152|128|134|122|104|140|110|116|98
2LS25W012,2LS25W012-SA237,104|110|116|122|128|134|140|152|164|92|98
2LS25W012,2LS25W012-SB001,98|116|134|164|128|122|110|140|92|152|104
2LS25W012,2LS25W012-SW001,134|122|116|104|128|92|140|152|98|164|110
2LS25W013,2LS25W013-SB006,104|110|116|122|128|134|98
2LS25W013,2LS25W013-SR072,116|110|134|98|128|104|122
2LS25W016,2LS25W016,104|110|116|122|128|134|140|152|164|92|98
2OT24C005,2OT24C005,128|116|122|164|152|140|134|110
2OT24C005,2OT24C005,164|140|152|134|128|122|116|110
2OT24C006,2OT24C006,164|134|128|122|116|110|140|152
2OT24C006,2OT24C006,116|110|134|128|140|152|164|122
2OT24C007,2OT24C007,152|134|164|140
2OT24C007,2OT24C007,140|134|164|152
2OT24W001,2OT24W001,140|164|152|134
2OT24W001,2OT24W001,134|140|152|164
2OT24W003,2OT24W003,98|164|152|140|134|128|122|116|110|104
2OT24W003,2OT24W003,128|98|164|152|140|134|122|116|110|104
2OT25C001,2OT25C001-SB001,110|116|122|128|134|140|152|164
2OT25C001,2OT25C001-SK010,128|134|164|152|140|116|122|110
2OT25C003,2OT25C003,110|116|122|128|134|140|152|164
2OT25C004,2OT25C004-SK010,164|140|128|122|98|110|104|116|152|134
2OT25W004,2OT25W004-SA237,104|110|116|122|92|98
2OT25W006,2OT25W006,164|122|152|140|98|116|104|134|110|128
2OT25W006,2OT25W006,104|110|116|122|128|134|140|152|98
2OT25W007,2OT25W007-SB227,98|164|152|134|128|116|140|110|122|104
2OT25W007,2OT25W007-SE384,128|164|104|122|140|98|134|152|110|116
2OT25W007,2OT25W007-SR026,164|122|152|116|110|134|98|140|128|104
2OT25W008,2OT25W008,140|152|122|116|164|134|98|104|110|128
2OT25W008,2OT25W008,104|110|116|122|128|134|140|152|164|98
2OT25W010,2OT25W010,104|110|116|122|128|134|140|152|164|98
2OT25W011,2OT25W011,164|122|140|134|128|110|104|152|116|98
2OT25W011,2OT25W011,104|110|116|122|128|134|140|152|164|98
2OT25W012,2OT25W012-FA148,104|110|116|122|128|134|140|152|164|98
2OT25W012,2OT25W012-FB591,104|110|116|122|128|134|140|152|164|98
2OT25W012,2OT25W012-SK010,116|164|104|152|110|98|122|140|128|134
2ST24W001,2ST24W001-SW011,104|134|98|110|128|122|116
2ST24W003,2ST24W003,134|140|152|164|98|128|122|116|110|104
2ST24W007,2ST24W007,140|104|98|128|110|122|116|152|164|134
2ST24W007,2ST24W007,140|128|116|98|164|152|104|110|122|134
2ST24W007,2ST24W007,152|110|104|140|164|98|134|128|122|116
2ST24W009,2ST24W009-SW001,116|164|140|152|110|128|122|134
2ST25C001,2ST25C001-SA010,110|164|116|122|134|128|152|140
2ST25C001,2ST25C001-SB227,134|128|110|122|116|152|140|164
2ST25C003,2ST25C003,152|140|164|134
2ST25C003,2ST25C003,164|134|140|152
2ST25S001,2ST25S001-SB001,140|152|98|104|164|110|134|116|122|128
2ST25S002,2ST25S002-SA476,164|152|140|110|134|128|122|116
2ST25S002,2ST25S002-SB141,122|116|164|128|134|140|152|110
2ST25S002,2ST25S002-SK010,140|116|134|164|152|128|122|110
2ST25S003,2ST25S003-SB001,122|98|164|140|116|104|110|152|128|134
2ST25S003,2ST25S003-SG166,98|140|116|164|134|128|104|122|110|152
2ST25S005,2ST25S005-SB001,116|110|128|134|122|140|152|164
2ST25S005,2ST25S005-SB268,152|110|116|122|164|140|134|128
2ST25S005,2ST25S005-SW001,110|164|140|134|128|122|152|116
2ST25S006,2ST25S006-SB001,110|116|122|128|134|140|152|164
2ST25S006,2ST25S006-SR072,128|134|164|110|116|122|140|152
2ST25S007,2ST25S007-SB594,104|98|134|110|122|116|128
2ST25S007,2ST25S007-SY300,128|110|116|134|98|104|122
2ST25W001,2ST25W001-FB601,104|110|116|122|128|92|98
2ST25W001,2ST25W001-FB602,104|110|116|122|128|92|98
2ST25W001,2ST25W001-SA237,104|116|92|98|110|122|128
2ST25W001,2ST25W001-SR072,116|128|104|122|92|110|98
2ST25W002,2ST25W002-SA237,110|98|122|92|140|134|104|152|116|128|164
2ST25W002,2ST25W002-SB347,134|116|152|128|122|110|140|92|98|104|164
2ST25W004,2ST25W004-SA237,104|110|116|122|128|134|140|152|164|98
2ST25W004,2ST25W004-SB227,152|98|140|116|128|134|110|122|164|104
2ST25W007,2ST25W007-SK010,110|116|122|128|134|140|152|164
2ST25W007,2ST25W007-SR072,110|116|122|128|134|140|152|164
2ST25W008,2ST25W008-SB227,140|152|134|164|110|116|122|128
2ST25W008,2ST25W008-SW033,164|140|134|152|110|116|122|128
2ST25W009,2ST25W009,98|110|122|116|104|128|134|140
2ST25W010,2ST25W010,104|110|116|122|128|134|140|152|164|98
2TA25S001,2TA25S001-SG079,122|116|152|110|104|140|128|164|134|98|92
2TA25S001,2TA25S001-SR072,152|98|140|92|134|128|122|116|110|104|164
2TA25S001,2TA25S001-SW001,110|122|128|134|140|152|164|92|98|104|116
2TA25S002,2TA25S002-FA138,98|164|152|140|134|128|122|116|110|104
2TA25S002,2TA25S002-SB594,116|98|164|152|140|134|128|122|110|104
2TA25S002,2TA25S002-SW001,98|164|152|140|134|128|122|116|110|104
2TA25S003,2TA25S003-SB001,110|152|116|164|104|134|98|140|122|128
2TA25S004,2TA25S004-SB268,116|164|152|128|134|140|110|122
2TA25S004,2TA25S004-SW001,122|152|164|110|134|128|140|116
2TE23C004,2TE23C004,160|100|120|130|140|150|110
2TE23C004,2TE23C004,140|120|100|160|110|150|130
2TE25C001,2TE25C001,150|140|160|130
2TE25C001,2TE25C001,160|150|130|140
2TE25C003,2TE25C003,160|130|110|120|140|150
2TE25C003,2TE25C003,110|120|130|140|150|160
2TE25C003,2TE25C003,120|140|150|110|130|160
2TE25C003,2TE25C003,160|150|120|130|110|140
2TE25W001,2TE25W001,100|110|120|130|140|150|160
2TE25W001,2TE25W001,160|100|150|90|110|130|120|140
2TE25W001,2TE25W001,150|130|100|120|160|90|140|110
2TE25W002,2TE25W002-SA014,100|110|120|90
2TE25W002,2TE25W002-SL146,100|110|90|120
2TE25W002,2TE25W002-SR031,110|90|120|100
2TE25W003,2TE25W003-SA014,100|110|120|90
2TE25W003,2TE25W003-SL146,120|100|110|90
2TE25W004,2TE25W004-SL146,110|140|150|120|130|100|160
2TE25W004,2TE25W004-SW319,130|160|120|100|110|140|150
2TE25W005,2TE25W005,130|150|140|160
2TE25W005,2TE25W005,130|140|150|160
2TE25W006,2TE25W006,110|120|130|140|150|160
2TH24C003,2TH24C003,122|152|116|164|140|134|128|110
2TH24C003,2TH24C003,152|140|164|134|128|122|116|110
2TH25A001,2TH25A001,116|110|104|98|164|152|140|134|128|122
2TH25A001,2TH25A001,98|164|152|140|134|128|122|116|110|104
2TH25A002,2TH25A002,152|164|122|128|116|104|98|110|140|134
2TH25A002,2TH25A002,110|98|128|122|152|164|140|116|104|134
2TH25C001,2TH25C001,134|164|140|152|128|122
2TH25S002,2TH25S002-PB481,128|110|140|116|134|104|152|98|164|122
2TH25S005,2TH25S005-FB572,134|140|152|104|128|122|116|98|164|110
2TH25S005,2TH25S005-FW303,140|122|98|104|116|110|164|152|128|134
2TH25W001,2TH25W001,128|110|92|104|134|140|116|164|152|98|122
2TH25W001,2TH25W001,164|110|122|140|134|128|98|152|104|92|116
2TH25W002,2TH25W002-CB368,110|164|134|152|128|122|116|140
2TH25W002,2TH25W002-CR160,110|116|122|128|134|140|152|164
2TL24W008,2TL24W008-SB148,104|110|116|128|164|134|140|152|122|98
2TL25C001,2TL25C001-SW001,134|116|140|122|164|128|110|152
2TL25C002,2TL25C002-FA144,122|152|128|164|116|134|140|110
2TL25C002,2TL25C002-FB610,110|116|122|128|134|140|152|164
2TL25C003,2TL25C003,140|116|164|110|152|128|122|134
2TL25C003,2TL25C003,122|164|128|110|134|116|140|152
2TL25C005,2TL25C005,134|128|164|140|152
2TL25C005,2TL25C005,140|134|152|164|128
2TL25C007,2TL25C007-PE070,134|140|152|164
2TL25C007,2TL25C007-PK186,134|140|152|164
2TL25C007,2TL25C007-PR137,134|140|152|164
2TL25W002,2TL25W002-SB001,104|140|152|122|134|110|164|92|98|116|128
2TL25W002,2TL25W002-SW001,98|92|128|134|104|164|122|140|152|116|110
2TL25W003,2TL25W003-SA423,110|134|140|104|128|98|164|152|122|116
2TL25W003,2TL25W003-SW001,128|122|98|110|140|152|104|116|134|164
2TL25W005,2TL25W005-SK010,92|122|110|104|116|128|98|140|134|152|164
2TL25W005,2TL25W005-SW001,92|104|116|98|128|122|110|134|140|164|152
2TP25C001,2TP25C001-PB477,122|164|152|140|134|128|116|110
2TP25C002,2TP25C002-PE070,110|122|140|116|134|98|152|104|128|164
2TP25C002,2TP25C002-PK186,116|164|128|110|134|104|98|152|140|122
2TP25C004,2TP25C004-SB227,128|134|140|152|164
2TP25S003,2TP25S003,110|128|122|140|134|116|104|92|164|98|152
2TP25S003,2TP25S003,140|110|104|164|116|134|152|128|122|92|98
2TP25S007,2TP25S007-SK010,110|116|122|128|134|140|152|164
2TP25S007,2TP25S007-SW011,116|110|122|128|134|140|152|164
2TP25S009,2TP25S009,140|164|152|116|134|104|98|110|122|128
2TP25W001,2TP25W001,104|122|164|92|140|116|98|152|134|110|128
2TP25W001,2TP25W001,98|110|128|134|92|116|140|164|152|104|122
2TP25W003,2TP25W003-SB001,104|110|116|122|128|134|140|152|164|98
2TP25W003,2TP25W003-SG689,164|152|140|122|134|110|98|116|104|128
2TP25W003,2TP25W003-SR002,104|110|116|122|128|134|140|152|164|98
2TP25W004,2TP25W004,134|128|164|140|152
2TP25W004,2TP25W004,134|140|128|164|152
2TS25C001,2TS25C001-SW001,134|140|152|122|128|164|110|116
2TS25C002,2TS25C002-SB001,110|98|140|116|134|104|122|128|152|164
2TS25C003,2TS25C003-SW001,134|116|122|110|164|128|152|140
2TS25C004,2TS25C004-SA125,104|116|128|140|152|98|134|122|164|110
2TS25C004,2TS25C004-SW001,140|116|122|104|128|164|152|98|134|110
2TS25C005,2TS25C005-SA423,110|152|134|128|122|164|140|116
2TS25C005,2TS25C005-SW119,122|140|152|116|164|128|134|110
2TS25C006,2TS25C006,116|152|122|128|110|164|134|140
2TS25C007,2TS25C007-FA140,116|164|98|104|110|122|128|134|140|152
2TS25C007,2TS25C007-FG153,104|122|128|164|134|116|110|140|152|98
2TS25C008,2TS25C008-PK165,134|110|116|122|128|140|152|164
2TS25C009,2TS25C009,164|134|140|152
2TS25C009,2TS25C009,134|140|152|164
2TS25S002,2TS25S002-SA026,110|164|122|134|152|140|116|128
2TS25S002,2TS25S002-SB001,122|110|164|152|140|134|128|116
2TS25S002,2TS25S002-SW001,152|164|116|110|122|128|134|140
2TS25S009,2TS25S009-FA138,92|110|116|128|134|122|140|98|104|164|152
2TS25S009,2TS25S009-SB594,104|92|140|152|98|134|164|110|116|128|122
2TS25S009,2TS25S009-SW001,98|134|104|128|116|122|140|164|92|110|152
2TS25S012,2TS25S012-SB001,140|98|164|152|134|128|122|116|110|104
2TS25S020,2TS25S020-SB001,128|110|116|140|164|152|122|134
2TS25S020,2TS25S020-SR072,116|110|164|152|140|134|128|122
2TS25S020,2TS25S020-SW001,116|122|134|140|152|110|164|128
2TS25S031,2TS25S031-SK010,104|110|116|122|128|134|98
2TS25S031,2TS25S031-SW001,104|110|116|122|128|134|98
2TS25W002,2TS25W002-SK010,152|140|134|128|164|110|116|122
2TS25W002,2TS25W002-SR072,128|116|110|140|164|122|134|152
2TS25W002,2TS25W002-SW001,134|110|140|116|152|122|164|128
2TS25W005,2TS25W005,104|110|116|122|128|134|98
2TW23C002,2TW23C002,100|130|140|110|120|160|150
2TW23C002,2TW23C002,110|120|150|160|140|130|100
2TW23C002,2TW23C002,160|130|110|120|150|100|140
2TW24C002,2TW24C002-SE002,164|152|134|140|122|128|116|110
2TW24C002,2TW24C002-SK010,110|116|122|128|134|140|152|164
2TW24C006,2TW24C006,152|104|98|164|110|140|134|128|122|116
2TW24C006,2TW24C006,98|104|110|116|122|128|134|140|152|164
2TW24W008,2TW24W008-SK010,164|152|140|134|128|122|116|110
2TW24W012,2TW24W012-SK010,152|140|134|128|122|116|110|104|98|164
2TW24W014,2TW24W014-SE249,116|122|128|104|134|140|152|164|98|110
2TW24W015,2TW24W015-SB257,164|152|140|134|128|122|116|110
2TW24W016,2TW24W016-SG313,134|152|140|128|122|116|110|164
2TW24W022,2TW24W022,122|164|116|152|128|134|98|110|104|140
2TW25C001,2TW25C001,140|128|152|134|116|164|110|122
2TW25C001,2TW25C001,110|122|134|164|128|140|116|152
2TW25C001,2TW25C001,116|152|122|128|164|110|140|134
2TW25C003,2TW25C003-SB163,152|134|122|116|164|140|110|128
2TW25C003,2TW25C003-SK010,164|116|122|152|110|128|140|134
2TW25C005,2TW25C005-SA929,110|116|122|128|134|140|152|164
2TW25C005,2TW25C005-SG204,122|110|140|128|164|152|116|134
2TW25C005,2TW25C005-SK010,128|116|152|122|164|140|110|134
2TW25C006,2TW25C006-SK010,164|140|110|116|152|128|134|122
2TW25C006,2TW25C006-SR028,110|116|122|128|134|140|152|164
2TW25C006,2TW25C006-SW011,164|116|134|128|122|110|140|152
2TW25C006,2TW25C006-SX007,110|116|122|128|134|140|152|164
2TW25C007,2TW25C007-SA083,116|164|134|128|122|110|152|140
2TW25C007,2TW25C007-SW033,110|116|122|128|134|140|152|164
2TW25C008,2TW25C008-SA083,140|152|134|164
2TW25C008,2TW25C008-SK010,134|164|140|152
2TW25C008,2TW25C008-SW011,134|152|164|140
2TW25C009,2TW25C009-SE215,110|116|152|128|164|134|122|140
2TW25W001,2TW25W001-SA237,98|152|104|122|116|128|110|134|164|140|92
2TW25W001,2TW25W001-SB001,98|110|128|122|140|134|152|116|92|164|104
2TW25W001,2TW25W001-SB347,122|164|140|128|92|104|116|152|98|134|110
2TW25W001,2TW25W001-SY077,116|110|104|164|128|134|140|92|122|98|152
2TW25W002,2TW25W002-SA083,104|110|140|122|98|128|116|134|152|164
2TW25W002,2TW25W002-SW011,140|164|116|152|98|128|110|104|134|122
2TW25W003,2TW25W003-SA237,104|92|122|110|116|98|128
2TW25W003,2TW25W003-SB347,128|116|98|110|104|92|122
2TW25W003,2TW25W003-SY077,122|104|98|128|92|110|116
2TW25W004,2TW25W004-SB001,128|134|164|140|116|152|110|122|92|104|98
2TW25W004,2TW25W004-SE249,122|92|134|152|110|128|140|116|164|98|104
2TW25W005,2TW25W005-SA237,104|110|116|122|128|134|140|152|164|98
2TW25W005,2TW25W005-SK010,128|164|104|98|116|122|152|140|110|134
2TW25W006,2TW25W006-FA151,110|116|122|128|134|140|152|164
2TW25W006,2TW25W006-FB580,140|110|128|134|116|122|98|104|152|164
2TW25W007,2TW25W007,104|140|164|152|134|128|116|98|110|122
2TW25W007,2TW25W007,140|98|152|116|122|110|92|134|128|104|164
2TW25W007,2TW25W007,164|140|122|116|104|128|92|134|110|152|98
2TW25W008,2TW25W008,116|110|140|128|152|134|164|122
2TW25W008,2TW25W008,116|140|122|152|134|164|110|128
2TW25W010,2TW25W010,104|110|116|128|92|98|122
2TW25W010,2TW25W010,104|110|116|122|128|92|98
2TW25W012,2TW25W012,104|110|116|122|128|134|140|152|164|92|98
2TW25W012,2TW25W012,104|110|116|122|128|92|98|134|140|152|164
2US25A001,2US25A001,150|160|140
2US25A001,2US25A001,140|150|160
3BP24W003,3BP24W003,104|116|110|122|128|152|140|134|164|98
3BP24W003,3BP24W003,98|110|128|134|140|116|104|122|164|152
3BP24W003,3BP24W003,104|116|134|110|128|164|152|98|122|140
3BP24W003,3BP24W003,122|104|116|164|128|152|134|110|98|140
3BP24W003,3BP24W003,98|164|152|140|134|128|122|116|104|110
3BP24W003,3BP24W003,140|110|122|116|164|134|152|128|98|104
3BP24W003,3BP24W003,134|128|152|164|98|110|122|116|104|140
3BP24W003,3BP24W003,128|134|140|152|164|98|110|104|122|116
3BP24W003,3BP24W003,152|104|122|140|164|116|98|134|110|128
3BP24W003,3BP24W003,134|98|116|110|122|164|104|128|140|152
3BP24W003,3BP24W003,116|104|152|140|110|128|98|164|122|134
3BP24W003,3BP24W003,134|164|140|116|128|98|104|122|152|110
3BP24W003,3BP24W003,110|152|116|104|140|164|122|98|128|134
3BP24W003,3BP24W003,98|140|134|110|116|104|122|128|152|164
3BP24W004,3BP24W004,110|122|164|152|140|98|104|128|116|134
3BP25W001,3BP25W001,164|98|104|140|128|116|152|110|92|122|134
3BP25W001,3BP25W001,128|92|110|134|116|164|122|152|98|104|140
3BP25W001,3BP25W001,116|128|92|104|152|98|122|134|110|164|140
3BP25W001,3BP25W001,140|92|134|110|104|116|164|128|98|122|152
3BP25W001,3BP25W001,140|122|128|164|110|134|98|92|104|116|152
3BP25W002,3BP25W002,134|152|164|140
3BP25W002,3BP25W002,134|140|152|164
3BP25W002,3BP25W002,140|164|152|134
3BP25W004,3BP25W004-SK010,100|110|120|130|140|150|160|90
3BP25W004,3BP25W004-SM090,100|110|120|130|140|150|160|90
3BP25W004,3BP25W004-SR002,100|110|120|130|140|150|160|90
3LB25S001,3LB25S001,100|90|120|110
3LB25S001,3LB25S001,120|110|90|100
3LB25S001,3LB25S001,110|90|100|120
3LB25S001,3LB25S001,110|100|90|120
3LB25S001,3LB25S001,120|100|110|90
3LB25S001,3LB25S001,90|120|100|110
3LB25S001,3LB25S001,100|120|110|90
3LB25S001,3LB25S001,100|90|110|120
3LB25S001,3LB25S001,100|120|90|110
3LB25S001,3LB25S001,120|90|100|110
3LB25S001,3LB25S001,100|110|90|120
3LB25S001,3LB25S001,110|100|120|90
3LB25S001,3LB25S001,90|100|110|120
3LB25S001,3LB25S001,90|120|110|100
3LB25S001,3LB25S001,90|110|120|100
3LB25S001,3LB25S001,90|100|120|110
3LB25S001,3LB25S001,100|110|120|90
3OT23S001,3OT23S001,120|90|150|140|130|110|100
3OT23S001,3OT23S001,90|150|140|130|120|110|100
3OT24W003,3OT24W003,104|110|116|122|128|134|140|152|164|98
3OT24W003,3OT24W003,110|98|164|152|134|140|116|122|128|104
3OT24W003,3OT24W003,116|104|122|128|134|164|98|110|140|152
3OT24W003,3OT24W003,116|110|104|98|164|152|140|134|128|122
3OT25S001,3OT25S001-SA405,164|152|140|134|128|122|116|110|104
3OT25S001,3OT25S001-SB128,122|110|164|104|128|116|134|140|152
3OT25S001,3OT25S001-SB285,164|152|140|134|128|122|116|110|104
3OT25S001,3OT25S001-SM090,140|128|122|152|116|110|104|164|134
3OT25S001,3OT25S001-SP091,110|140|152|128|116|122|164|134|104
3OT25W002,3OT25W002,104|128|152|110|116|98|134|164|140|122
3OT25W002,3OT25W002,104|110|116|122|128|134|140|152|164|98
3OT25W003,3OT25W003,104|110|116|122|128|134|140|152|164|98
3OT25W003,3OT25W003,164|110|152|98|104|116|140|122|134|128
3OT25W004,3OT25W004,110|116|122|128|134|140|152|164
3OT25W007,3OT25W007-SK010,100|110|120|130|140|150|160|90
3ST25S001,3ST25S001-SB001,134|164|140|152
3ST25S001,3ST25S001-SM610,164|140|152|134
3ST25S002,3ST25S002-FW565,152|128|134|164|116|104|140|122|110|98
3ST25S003,3ST25S003-SB001,110|152|128|134|122|164|140|116
3ST25S003,3ST25S003-SP074,110|128|152|134|122|116|140|164
3ST25S004,3ST25S004-SM610,152|98|116|164|122|140|128|110|104|134
3ST25S004,3ST25S004-SW001,128|140|104|134|164|98|122|110|152|116
3TL25C001,3TL25C001-SM610,164|128|122|140|116|134|110|152
3TL25C001,3TL25C001-SR072,110|116|122|128|134|140|152|164
3TL25C001,3TL25C001-SW119,110|128|152|140|116|122|164|134
3TP25W001,3TP25W001-SR037,100|110|120|130|140|150|160|90
3TS25C001,3TS25C001,164|140|152|134
3TS25C001,3TS25C001,140|152|164|134
3TS25C001,3TS25C001,134|152|164|140
3TS25C002,3TS25C002-SK010,140|134|164|152
3TS25C002,3TS25C002-SW001,164|134|140|152
3TS25S001,3TS25S001-SM326,92|104|128|164|152|110|116|122|98|140|134
3TS25S001,3TS25S001-SW001,164|122|152|116|134|140|128|92|104|98|110
3TS25S001,3TS25S001-SY300,110|104|122|98|128|92|116|134|140|152|164
3TS25S002,3TS25S002-FW308,128|122|116|110|104|98|92|164|152|140|134
3TS25S002,3TS25S002-SY075,140|116|104|128|98|92|152|164|110|134|122
3TS25S005,3TS25S005-SW001,134|164|152|140
3TS25S006,3TS25S006,134|122|104|116|152|140|128|98|110|164
3TS25S006,3TS25S006,134|140|122|104|152|128|164|98|110|116
3TS25S007,3TS25S007-FW565,128|98|92|164|152|140|134|122|116|110|104
3TS25S007,3TS25S007-SK010,122|134|164|116|152|110|98|128|140|104|92
3TS25S007,3TS25S007-SW001,116|134|122|110|164|104|140|152|98|128|92
3TS25S010,3TS25S010-SB001,98|110|122|128|164|152|116|104|134|140
3TS25S010,3TS25S010-SW001,152|128|110|140|98|104|164|116|134|122
3TS25S013,3TS25S013-SA920,100|110|120|130|140|150|160|90
3TS25S013,3TS25S013-SK010,164|134|92|152|98|122|128|104|140|116|110|150|120|160|130|100|90
3TS25S013,3TS25S013-SR079,128|134|116|164|98|104|140|110|152|92|122|130|160|120|150|100|90
3TS25S013,3TS25S013-SW001,100|110|120|130|140|150|160|90
3TS25S014,3TS25S014-SR079,152|128|98|110|116|140|164|104|92|122|134|160|130|150|120|100|90
3TS25S015,3TS25S015-SA923,100|110|120|130|140|150|160|90
3TS25S015,3TS25S015-SY322,134|152|98|104|128|122|92|140|110|164|116|130|120|150|160|100|90
3TS25S016,3TS25S016-SA923,100|110|120|130|140|150|160|90
3TS25S016,3TS25S016-SY322,98|116|164|104|140|92|122|110|128|152|134|160|150|130|120|100|90
3TS25S017,3TS25S017-SK010,150|110|130|160|120|140
3TS25S017,3TS25S017-SW001,160|120|150|110|130|140
3TS25S018,3TS25S018-SK010,120|110|130|140|160|150
3TS25S018,3TS25S018-SW001,150|140|120|130|110|160
3TS25S019,3TS25S019-SK010,160|110|120|130|140|150
3TS25S019,3TS25S019-SW001,110|120|130|140|150|160
3TS25S026,3TS25S026-SB227,104|110|116|122|128|134|98
3TS25S026,3TS25S026-SK010,104|110|116|122|128|134|98
3TS25S026,3TS25S026-SM610,104|110|116|122|128|134|98
3TS25S027,3TS25S027-SA090,110|120|130|140|150|160
3TS25S029,3TS25S029,104|110|116|122|128|134|98|140|152|164|92
3TS25S030,3TS25S030-SR079,110|120|130|140|150|160|100|90
3TS25S030,3TS25S030-SW001,110|120|130|140|150|160|100|90
3TS25S031,3TS25S031-SK389,110|120|130|140|150|160|100|90
3TS25S031,3TS25S031-SW001,110|120|130|140|150|160|100|90
3TS25S031,3TS25S031-SW401,110|120|130|140|150|160|100|90
3TS25S031,3TS25S031-SW402,110|120|130|140|150|160|100|90
3TS25S032,3TS25S032-SR079,100|110|120|130|140|150|160|90
3TS25W001,3TS25W001-SK010,104|116|164|98|134|122|152|140|128|110
3TS25W001,3TS25W001-SM090,104|110|116|122|128|134|140|152|164|98
3TS25W001,3TS25W001-SW001,104|110|134|164|128|140|152|122|98|116
3TS25W004,3TS25W004-SW001,110|120|130|140|150|L|M|S|XL|XS|160
3TS25W004,3TS25W004-SW401,110|120|130|140|150|160
3TS25W007,3TS25W007-SK010,100|110|120|130|140|150|160|90
3TS25W007,3TS25W007-SR037,100|110|120|130|140|150|160|90
3TS25W007,3TS25W007-SW001,100|110|120|130|140|150|160|90
3TS25W007,3TS25W007-SW401,100|110|120|130|140|150|160|90
3TW23W003,3TW23W003,152|116|164|110|122|98|140|128|134|104
3TW23W003,3TW23W003,140|134|164|128|122|116|110|104|98|152
3TW23W003,3TW23W003,98|164|152|140|134|128|122|116|110|104
3TW24W001,3TW24W001-SG649,128|104|110|116|122|134|140|152|164|98
3TW24W001,3TW24W001-SP250,104|110|164|116|98|122|128|134|140|152
3TW25W001,3TW25W001-SL335,134|140|152|164
3TW25W001,3TW25W001-SM594,140|164|134|152
3TW25W001,3TW25W001-SW011,134|152|164|140
3TW25W002,3TW25W002,152|134|164|140
3TW25W002,3TW25W002,164|152|134|140
3TW25W003,3TW25W003-SB227,104|110|116|122|128|134|140|152|164|98
3TW25W003,3TW25W003-SW033,104|110|116|122|128|134|140|152|164|98
3TW25W004,3TW25W004-SK389,104|110|116|122|128|134|140|152|98|100|120|130|150|160|90
5TS25S018,5TS25S018-SA130,128|98|152|104|122|140|134|110|116|164|M|XL|S|L|XS|XXL
5TS25S018,5TS25S018-SM610,128|152|110|98|164|116|122|140|104|134|L|XL|M|S|XS|XXL
5TS25S018,5TS25S018-SW119,128|164|140|152|116|98|122|110|104|134|XL|S|M|L|XS|XXL
import asyncio
import logging
import sys
import os
import json
import warnings
# Ensure we can import from backend root
current_dir = os.path.dirname(os.path.abspath(__file__))
backend_root = os.path.dirname(current_dir)
sys.path.append(backend_root)
# Setup logging
logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
from agent.tools.data_retrieval_tool import data_retrieval_tool, SearchItem
from common.starrocks_connection import StarRocksConnection
# Suppress ResourceWarning for unclosed sockets/loops
warnings.filterwarnings("ignore", category=RuntimeWarning)
warnings.filterwarnings("ignore", category=ResourceWarning)
async def test_search_cases():
"""
Run specific search cases to verify filtering logic.
"""
test_cases = [
{
"name": "Red Skirt (Chân váy đỏ)",
"query": "chân váy màu đỏ",
"search_item": SearchItem(
query="chân váy đỏ",
product_name="chân váy",
master_color="đỏ",
magento_ref_code=None, price_min=None, price_max=None,
gender_by_product=None, age_by_product=None, form_sleeve=None, style=None,
fitting=None, form_neckline=None, material_group=None, season=None, product_line_vn=None
),
"expect": {
"product_matches": ["skirt", "chân váy"],
"color_matches": ["red", "đỏ"]
}
},
{
"name": "Red Pants (Quần đỏ)",
"query": "quần đỏ",
"search_item": SearchItem(
query="quần đỏ",
product_name="quần",
master_color="đỏ",
magento_ref_code=None, price_min=None, price_max=None,
gender_by_product=None, age_by_product=None, form_sleeve=None, style=None,
fitting=None, form_neckline=None, material_group=None, season=None, product_line_vn=None
),
"expect": {
"product_matches": ["pants", "quần", "trousers"],
"color_matches": ["red", "đỏ"]
}
},
{
"name": "Wool Material (Vải len)",
"query": "đồ len",
"search_item": SearchItem(
query="đồ len",
material_group="len",
product_name=None, magento_ref_code=None, price_min=None, price_max=None,
gender_by_product=None, age_by_product=None, master_color=None, form_sleeve=None, style=None,
fitting=None, form_neckline=None, season=None, product_line_vn=None
),
"expect": {
"material_matches": ["wool", "len", "cashmere"]
}
}
]
print("\n" + "="*80)
print("🚀 STARTING DYNAMIC SEARCH VERIFICATION")
print("="*80 + "\n")
try:
for case in test_cases:
print(f"🔍 Testing Case: {case['name']}")
print(f" Query: {case['query']}")
try:
case['result_status'] = "FAIL"
# We call the tool. Format: {"searches": [item]}
result_json = await data_retrieval_tool.ainvoke({"searches": [case["search_item"]]})
result = json.loads(result_json)
if result["status"] != "success":
case['result_detail'] = f"Tool Error: {result.get('message')}"
print(f" ❌ FAILED: {case['result_detail']}")
continue
products = result["results"]
filter_info = result["filter_info"]
print(f" Found {len(products)} products.")
if not products:
case['result_status'] = "NO_RESULTS"
detail = "0 products found"
if filter_info.get("message"):
detail += f" [Msg: {filter_info.get('message')}]"
case['result_detail'] = detail
print(f" ⚠️ {detail}")
continue
# Verify first few products
match_count = 0
check_limit = min(5, len(products))
for i in range(check_limit):
p = products[i]
desc = p.get("description_text_full", "") or p.get("description", "")
desc = desc.lower()
is_valid = True
# Check Color
if "color_matches" in case["expect"]:
found_color = False
for c in case["expect"]["color_matches"]:
if c in desc:
found_color = True
break
if not found_color:
is_valid = False
# print(f" ❌ Product {i}: Color matches not found")
# Check Material
if "material_matches" in case["expect"]:
found_mat = False
for m in case["expect"]["material_matches"]:
if m in desc:
found_mat = True
break
if not found_mat:
is_valid = False
# print(f" ❌ Product {i}: Material matches not found")
if is_valid:
match_count += 1
if match_count == check_limit:
case['result_status'] = "PASS"
case['result_detail'] = f"Top {check_limit} products match criteria"
print(f" ✅ VERIFIED: {case['result_detail']}")
else:
case['result_status'] = "PARTIAL"
case['result_detail'] = f"{match_count}/{check_limit} matched criteria"
print(f" ⚠️ PARTIAL: {case['result_detail']}")
except Exception as e:
case['result_status'] = "ERROR"
case['result_detail'] = str(e)
print(f" ❌ EXCEPTION: {e}")
print("-" * 50)
finally:
print("\n" + "="*80)
print("📊 TEST SUMMARY")
print("="*80)
for case in test_cases:
status = case.get('result_status', 'UNKNOWN')
detail = case.get('result_detail', '')
print(f"🔹 {case['name']:<30} | {status:<10} | {detail}")
print("="*80)
# CLEANUP
# print("\n🧹 Cleaning up connections...")
await StarRocksConnection.clear_pool()
if __name__ == "__main__":
asyncio.run(test_search_cases())
import asyncio
import logging
import os
import sys
from collections import Counter
from typing import Any
# Ensure we can import from backend root
current_dir = os.path.dirname(os.path.abspath(__file__))
backend_root = os.path.dirname(current_dir)
sys.path.append(backend_root)
from common.starrocks_connection import StarRocksConnection
from config import STARROCKS_DB, STARROCKS_HOST, STARROCKS_PASSWORD, STARROCKS_USER
logger = logging.getLogger(__name__)
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
LETTER_SIZES = {
"XXXS",
"XXS",
"XS",
"S",
"M",
"L",
"XL",
"XXL",
"XXXL",
}
def _get_missing_env() -> list[str]:
missing = []
if not STARROCKS_HOST:
missing.append("STARROCKS_HOST")
if not STARROCKS_DB:
missing.append("STARROCKS_DB")
if not STARROCKS_USER:
missing.append("STARROCKS_USER")
if not STARROCKS_PASSWORD:
missing.append("STARROCKS_PASSWORD")
return missing
def _skip_or_warn_if_missing_env() -> bool:
missing = _get_missing_env()
if not missing:
return False
message = f"Missing StarRocks env vars: {', '.join(missing)}"
if "PYTEST_CURRENT_TEST" in os.environ:
import pytest
pytest.skip(message)
print(f"[SKIP] {message}")
return True
def _get_limit_from_env() -> int | None:
raw = os.getenv("SIZE_SCALE_LIMIT")
if not raw:
return None
try:
value = int(raw)
except ValueError:
return None
return max(1, value)
async def fetch_size_scale_list(limit: int | None = None) -> list[dict[str, Any]]:
db = StarRocksConnection()
limit_clause = f" LIMIT {limit}" if limit else ""
sql = f"""
SELECT
size_scale,
COUNT(*) AS row_count
FROM {TABLE_NAME}
GROUP BY size_scale
ORDER BY size_scale ASC{limit_clause}
"""
return await db.execute_query_async(sql)
async def fetch_size_scale_summary() -> dict[str, int]:
db = StarRocksConnection()
total_sql = f"SELECT COUNT(*) AS total_rows FROM {TABLE_NAME}"
distinct_sql = (
f"SELECT COUNT(DISTINCT size_scale) AS distinct_size_scale FROM {TABLE_NAME}"
)
null_sql = (
f"SELECT COUNT(*) AS null_size_scale FROM {TABLE_NAME} "
"WHERE size_scale IS NULL OR size_scale = ''"
)
total_rows = await db.execute_query_async(total_sql)
distinct_sizes = await db.execute_query_async(distinct_sql)
null_sizes = await db.execute_query_async(null_sql)
return {
"total_rows": int(total_rows[0]["total_rows"]) if total_rows else 0,
"distinct_size_scale": (
int(distinct_sizes[0]["distinct_size_scale"]) if distinct_sizes else 0
),
"null_size_scale": int(null_sizes[0]["null_size_scale"]) if null_sizes else 0,
}
def _normalize_token(token: str) -> str:
return token.strip()
def _split_size_scale(size_scale: str | None) -> list[str]:
if not size_scale:
return []
tokens = [_normalize_token(t) for t in size_scale.split("|")]
return [t for t in tokens if t]
def _build_token_summary(rows: list[dict[str, Any]]) -> dict[str, Any]:
token_counter: Counter[str] = Counter()
letter_counter: Counter[str] = Counter()
numeric_counter: Counter[str] = Counter()
other_counter: Counter[str] = Counter()
for row in rows:
size_scale = row.get("size_scale")
row_count = int(row.get("row_count") or 0)
tokens = _split_size_scale(size_scale)
for token in tokens:
token_counter[token] += row_count
if token in LETTER_SIZES:
letter_counter[token] += row_count
elif token.replace(".", "", 1).isdigit():
numeric_counter[token] += row_count
else:
other_counter[token] += row_count
return {
"all_tokens": token_counter,
"letter_tokens": letter_counter,
"numeric_tokens": numeric_counter,
"other_tokens": other_counter,
}
def test_starrocks_size_scale_list():
if _skip_or_warn_if_missing_env():
return
rows = asyncio.run(fetch_size_scale_list(limit=10))
assert isinstance(rows, list)
async def _run():
if _skip_or_warn_if_missing_env():
return
print_limit = _get_limit_from_env()
summary = await fetch_size_scale_summary()
rows = await fetch_size_scale_list()
token_summary = _build_token_summary(rows)
print("\n" + "=" * 80)
print("STARROCKS SIZE SCALE LIST")
print("=" * 80)
print(f"Table: {TABLE_NAME}")
print(f"Total rows: {summary['total_rows']}")
print(f"Distinct size_scale: {summary['distinct_size_scale']}")
print(f"Null/empty size_scale: {summary['null_size_scale']}")
if print_limit:
print(f"Print limit: {print_limit}")
print("\nNormalized token counts (top 50):")
for token, count in token_summary["all_tokens"].most_common(50):
print(f"- {token}: {count}")
print("\nLetter sizes:")
for token in sorted(LETTER_SIZES):
count = token_summary["letter_tokens"].get(token, 0)
if count:
print(f"- {token}: {count}")
print("\nNumeric sizes (top 50):")
for token, count in token_summary["numeric_tokens"].most_common(50):
print(f"- {token}: {count}")
print("\nOther tokens (top 50):")
for token, count in token_summary["other_tokens"].most_common(50):
print(f"- {token}: {count}")
print("\nsize_scale\trow_count")
rows_to_print = rows[:print_limit] if print_limit else rows
for row in rows_to_print:
size_scale = row.get("size_scale")
row_count = row.get("row_count")
if size_scale in (None, ""):
size_scale = "<NULL/EMPTY>"
print(f"{size_scale}\t{row_count}")
await StarRocksConnection.clear_pool()
if __name__ == "__main__":
asyncio.run(_run())
import asyncio
import logging
import os
import sys
from typing import Any
# Ensure we can import from backend root
current_dir = os.path.dirname(os.path.abspath(__file__))
backend_root = os.path.dirname(current_dir)
sys.path.append(backend_root)
from common.starrocks_connection import StarRocksConnection
from config import STARROCKS_DB, STARROCKS_HOST, STARROCKS_PASSWORD, STARROCKS_USER
logger = logging.getLogger(__name__)
TABLE_NAME = "shared_source.magento_product_dimension_with_text_embedding"
DEFAULT_LIMIT = 50
def _get_missing_env() -> list[str]:
missing = []
if not STARROCKS_HOST:
missing.append("STARROCKS_HOST")
if not STARROCKS_DB:
missing.append("STARROCKS_DB")
if not STARROCKS_USER:
missing.append("STARROCKS_USER")
if not STARROCKS_PASSWORD:
missing.append("STARROCKS_PASSWORD")
return missing
def _skip_or_warn_if_missing_env() -> bool:
missing = _get_missing_env()
if not missing:
return False
message = f"Missing StarRocks env vars: {', '.join(missing)}"
if "PYTEST_CURRENT_TEST" in os.environ:
import pytest
pytest.skip(message)
print(f"[SKIP] {message}")
return True
async def fetch_size_scale_stats(limit: int = DEFAULT_LIMIT) -> dict[str, Any]:
limit = max(1, int(limit))
db = StarRocksConnection()
total_sql = f"SELECT COUNT(*) AS total_rows FROM {TABLE_NAME}"
distinct_sql = (
f"SELECT COUNT(DISTINCT size_scale) AS distinct_size_scale FROM {TABLE_NAME}"
)
null_sql = (
f"SELECT COUNT(*) AS null_size_scale FROM {TABLE_NAME} "
"WHERE size_scale IS NULL OR size_scale = ''"
)
top_sizes_sql = f"""
SELECT
size_scale,
COUNT(*) AS row_count
FROM {TABLE_NAME}
GROUP BY size_scale
ORDER BY row_count DESC
LIMIT {limit}
"""
total_rows = await db.execute_query_async(total_sql)
distinct_sizes = await db.execute_query_async(distinct_sql)
null_sizes = await db.execute_query_async(null_sql)
top_sizes = await db.execute_query_async(top_sizes_sql)
total_rows_value = int(total_rows[0]["total_rows"]) if total_rows else 0
distinct_size_value = (
int(distinct_sizes[0]["distinct_size_scale"]) if distinct_sizes else 0
)
null_size_value = int(null_sizes[0]["null_size_scale"]) if null_sizes else 0
return {
"total_rows": total_rows_value,
"distinct_size_scale": distinct_size_value,
"null_size_scale": null_size_value,
"top_sizes": top_sizes,
}
def test_starrocks_size_scale_stats():
if _skip_or_warn_if_missing_env():
return
stats = asyncio.run(fetch_size_scale_stats(limit=10))
assert stats["total_rows"] >= 0
assert stats["distinct_size_scale"] >= 0
async def _run():
if _skip_or_warn_if_missing_env():
return
stats = await fetch_size_scale_stats(limit=DEFAULT_LIMIT)
print("\n" + "=" * 80)
print("STARROCKS SIZE_SCALE STATS")
print("=" * 80)
print(f"Table: {TABLE_NAME}")
print(f"Total rows: {stats['total_rows']}")
print(f"Distinct size_scale: {stats['distinct_size_scale']}")
print(f"Null/empty size_scale: {stats['null_size_scale']}")
print("\nTop size_scale by row count:")
for row in stats["top_sizes"]:
size_scale = row.get("size_scale")
row_count = row.get("row_count")
print(f"- {size_scale}: {row_count}")
await StarRocksConnection.clear_pool()
if __name__ == "__main__":
asyncio.run(_run())
import asyncio
import logging
import json
from agent.tools.check_is_stock import check_is_stock
from agent.tools.data_retrieval_tool import data_retrieval_tool, SearchItem
from common.starrocks_connection import StarRocksConnection
from agent.tools.stock_helpers import fetch_stock_for_skus
# Setup logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def test_full_flow():
print("🚀 STARTING STOCK FLOW VERIFICATION\n")
# CASE 0: Verify Prompt Loading
print("--- TEST CASE 0: Tool Description Loading ---")
if "KIỂM TRA TỒN KHO" in check_is_stock.description:
print("✅ check_is_stock description updated correctly.")
else:
print(f"❌ check_is_stock description mismatch. Got: {check_is_stock.description[:50]}...")
if "Siêu công cụ tìm kiếm" in data_retrieval_tool.description:
print("✅ data_retrieval_tool description updated correctly.")
else:
print(f"❌ data_retrieval_tool description mismatch. Got: {data_retrieval_tool.description[:50]}...")
# CASE 1: Specific SKU Check (check_is_stock)
sku_input = "6ST25W005-SE091"
print(f"\n--- TEST CASE 1: Check Specific SKU: {sku_input} ---")
try:
# Tool call via ainvoke
res = await check_is_stock.ainvoke({"skus": sku_input})
res_json = json.loads(res)
if "stock_responses" in res_json:
print(f"✅ Tool returned stock_responses. Count: {len(res_json['stock_responses'])}")
print(f" Sample: {res_json['stock_responses'][:1]}")
else:
print(f"❌ Tool response missing stock_responses: {res}")
except Exception as e:
print(f"❌ Error calling check_is_stock: {e}")
# CASE 2: Base Code Expansion (check_is_stock)
base_code = "6TE25W005"
print(f"\n--- TEST CASE 2: Check Base Code Expansion: {base_code} ---")
try:
res = await check_is_stock.ainvoke({"skus": base_code})
res_json = json.loads(res)
if "stock_responses" in res_json and len(res_json['stock_responses']) > 1:
print(f"✅ Tool expanded base code. Count: {len(res_json['stock_responses'])}")
else:
print(f"⚠️ Tool might not have expanded or code invalid. Count: {len(res_json.get('stock_responses', []))}")
print(f" Result: {res_json}")
except Exception as e:
print(f"❌ Error calling check_is_stock with base code: {e}")
# CASE 3: Data Retrieval Integration
query_text = "áo thun nam"
print(f"\n--- TEST CASE 3: Data Retrieval + Stock Enrichment ({query_text}) ---")
try:
# Mocking a search item as DICT with ALL keys (Strict Mode)
search_item_dict = {
"description": "Áo thun nam",
"product_name": "Áo thun",
"magento_ref_code": None,
"price_min": None,
"price_max": None,
"gender_by_product": None,
"age_by_product": None,
"master_color": None,
"form_sleeve": None,
"style": None,
"fitting": None,
"form_neckline": None,
"material_group": None,
"season": None,
"product_line_vn": None
}
# Tool call via ainvoke with DICT input
res_str = await data_retrieval_tool.ainvoke({"searches": [search_item_dict]})
res_json = json.loads(res_str)
results = res_json.get("results", [])
print(f"Found {len(results)} products.")
if results:
print(f"Sample Product Keys: {results[0].keys()}")
print(f"Sample Product Code: {results[0].get('product_color_code')}")
print(f"Sample Magento Code: {results[0].get('magento_ref_code')}")
enriched_count = 0
for p in results[:5]: # Check first 5
stock = p.get("stock_info")
if stock:
enriched_count += 1
# print(f" Product {p.get('product_color_code')}: Has Stock Info ✅")
else:
pass
# print(f" Product {p.get('product_color_code')}: No Stock Info ❌")
if res_json.get("stock_enriched") is True:
print(f"✅ Response has 'stock_enriched'=True")
else:
print(f"❌ Response 'stock_enriched' is False/Missing")
print(f"✅ Verified stock info present in {enriched_count}/5 sample items.")
except Exception as e:
print(f"❌ Error calling data_retrieval_tool: {e}")
if hasattr(e, 'errors'):
print(f" Details: {e.errors()}")
# Cleanup
await StarRocksConnection.clear_pool()
print("\n🚀 VERIFICATION COMPLETE")
if __name__ == "__main__":
asyncio.run(test_full_flow())
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