Commit 93dbdfe7 authored by Hoanganhvu123's avatar Hoanganhvu123

chore(cookbook): cleanup orphans + fix duplicate titles + auto-count progress

- Removed orphan folder 08-lead-search/ (replaced by 08a/08b/08c/08d)
- Removed 8 orphan HTML/MD files from lead folders
- Fixed duplicate section titles (JS h2 + MD heading)
- Progress counter now auto-counts from registry.json
- Bumped cookbook.js to v9
parent cc9055c7
...@@ -30,6 +30,8 @@ def ensure_sql_tables() -> None: ...@@ -30,6 +30,8 @@ def ensure_sql_tables() -> None:
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ completed_at TIMESTAMPTZ
); );
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_sql_trace_conv_id CREATE INDEX IF NOT EXISTS idx_sql_trace_conv_id
ON dashboard_canifa.sql_trace_sessions(conversation_id); ON dashboard_canifa.sql_trace_sessions(conversation_id);
""") """)
......
...@@ -84,6 +84,6 @@ ...@@ -84,6 +84,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.2/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.0/mermaid.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.0/mermaid.min.js"></script>
<script src="cookbook.js?v=8"></script> <script src="cookbook.js?v=9"></script>
</body> </body>
</html> </html>
...@@ -155,7 +155,7 @@ document.addEventListener('DOMContentLoaded', async () => { ...@@ -155,7 +155,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
loadedSections.forEach(sec => { loadedSections.forEach(sec => {
html += `<h2 class="section-title" id="sec-${sec.id}" style="padding-top: 20px;">${sec.title}</h2>`; html += `<div class="section-anchor" id="sec-${sec.id}" style="padding-top: 20px;"></div>`;
if (sec.type === 'markdown') { if (sec.type === 'markdown') {
if (window.marked) { if (window.marked) {
...@@ -373,7 +373,7 @@ document.addEventListener('DOMContentLoaded', async () => { ...@@ -373,7 +373,7 @@ document.addEventListener('DOMContentLoaded', async () => {
function updateProgress() { function updateProgress() {
const read = JSON.parse(localStorage.getItem('cb_read') || '[]'); const read = JSON.parse(localStorage.getItem('cb_read') || '[]');
const total = 26; const total = recipesRegistry.groups ? recipesRegistry.groups.reduce((sum, g) => sum + g.recipes.length, 0) : 26;
const percent = Math.min(100, Math.round((read.length / total) * 100)); const percent = Math.min(100, Math.round((read.length / total) * 100));
const progressEl = document.getElementById('progressPercent'); const progressEl = document.getElementById('progressPercent');
if (progressEl) progressEl.textContent = `${percent}%`; if (progressEl) progressEl.textContent = `${percent}%`;
......
Kiến trúc Lead Stage AI được thiết kế theo dạng **Micro-modular pipeline** dựa trên LangGraph, tách biệt hoàn toàn giữa việc *phân loại ý định* (Classifier) và *sinh ngôn ngữ* (Stylist).
<div class="mermaid">
graph TD
%% Styling
classDef user fill:#a5d8ff,stroke:#4a9eed,stroke-width:2px,color:#000,rx:8,ry:8
classDef classifier fill:#d0bfff,stroke:#8b5cf6,stroke-width:2px,color:#000,rx:8,ry:8
classDef tools fill:#fff3bf,stroke:#f59e0b,stroke-width:2px,color:#000,rx:8,ry:8
classDef stylist fill:#b2f2bb,stroke:#22c55e,stroke-width:2px,color:#000,rx:8,ry:8
U[👤 User Chat]:::user
C[🧠 Classifier Agent<br/>(Fast Router)]:::classifier
T[🔍 Search Tools<br/>(Meilisearch / DB)]:::tools
S[👗 Stylist Agent<br/>(Combo Maker)]:::stylist
U -- "1. Gửi tin nhắn" --> C
C -- "2. Rút trích tham số<br/>(Gọi Tool)" --> T
T -- "3. Trả về Danh sách<br/>sản phẩm thô" --> S
S -- "4. Tổng hợp &<br/>Gợi ý Combo" --> U
</div>
### Thiết kế hai tầng (Two-Stage Agent)
Thay vì dùng một Agent to (monolith) để làm mọi thứ, hệ thống chia làm 2 AI:
1. **AI #1 (Classifier + Tool Router):** Chuyên trách đọc câu hỏi của user, lịch sử chat (`user_insight`, `chat_history_summary`) để quyết định xem user đang ở Stage nào (Awareness, Interest, Consideration, Decision, Retention) và gọi Tool tương ứng (ví dụ: `lead search tool`, `check is stock`).
2. **AI #2 (Stylist):** Nhận kết quả từ Tool và kết luận của Classifier, tập trung hoàn toàn vào việc sinh ra câu trả lời mềm mỏng, đúng `tone_directive` và đúng nghiệp vụ chốt sale.
### Tracing & Observability
Toàn bộ quá trình được trace qua **Langfuse** thông qua controller `lead_stage_chat_controller`. Điều này cho phép monitoring chi tiết thời gian thực thi của từng node (`classifier_ms`, `stylist_ms`, `total_ms`).
### Quản lý phiên (Identity & Persistence)
Dùng `device_id` (persistent từ localStorage hoặc middleware) hoặc `user_id` (nếu đã login) để duy trì lịch sử chat qua Postgres. Điều này giúp ngăn chặn các vấn đề bị reset session như đối với memory-cache.
\ No newline at end of file
# 1. Business Logic & 5 Mode Tâm Lý
Lead Search Agent không đơn thuần là một công cụ Search (hỏi gì đáp nấy), mà được thiết kế để đóng vai trò như một **Nhân viên Sales (Stylist) xuất sắc**. Mục tiêu tối thượng của hệ thống là **thu thập Contact (Lead)** hoặc **Chốt đơn (Sales)**.
Để làm được điều này, hệ thống áp dụng triệt để **Phễu bán hàng (Sales Funnel)** và băm nhỏ hành trình của khách hàng thành **5 Mode (5 Giai đoạn tâm lý)**.
---
## 🛑 Tại sao lại phải chia ra 5 Mode?
Nếu hệ thống không biết khách đang ở trạng thái tâm lý nào, AI sẽ cư xử rất "ngáo":
- Khách mới lướt ngang nói "Hi" ➔ AI nhảy xổ ra đòi số điện thoại (Gây hoảng sợ, mất khách).
- Khách đang hỏi kĩ về chất vải để chốt ➔ AI lại đi hỏi "Dạ anh tìm đồ cho nam hay nữ ạ?" (Gây bực mình).
Việc sinh ra 5 Mode chính là để tạo ra **"Dây cương"** kìm hãm LLM, ép nó bơm đúng **Tone giọng (Giọng điệu)****Chiến thuật (Chỉ thị)** phù hợp với từng nấc của phễu.
---
## 🎯 5 Mode Của Lead Journey (AIDA + R)
Dưới đây là 5 trạng thái được track liên tục qua đối tượng `InsightJSON.STAGE`:
### Mode 1: Awareness (Nhận biết / Dạo quanh)
- **Tâm lý khách:** Khách vô tình lướt qua, ấn nhầm hoặc chỉ chào "Hi", "Alo", "Có gì mới không". Trạng thái cực kỳ **Lạnh**.
- **Nghiệp vụ AI (Chỉ thị):** Cấm tuyệt đối việc quăng list sản phẩm hay chốt đơn. Nhiệm vụ duy nhất là **Rã đông** bằng cách chào hỏi nhẹ nhàng và gợi mở câu chuyện: *"Dạ bạn đang tìm đồ cho nam, nữ hay bé ạ?"*.
### Mode 2: Interest (Quan tâm / Gom nhu cầu)
- **Tâm lý khách:** Đã nhả ra một chút manh mối (VD: "Mình tìm áo khoác"). Tuy nhiên, thông tin còn quá lỏng lẻo để có thể tìm được đồ vừa vặn. Trạng thái **Ấm dần**.
- **Nghiệp vụ AI (Chỉ thị):** AI đóng vai **Chuyên gia hỏi cung khéo léo**. Ở mode này, AI hạn chế gọi Tool tìm kiếm, thay vào đó là nán lại đặt câu hỏi để lấp đầy hồ sơ `InsightJSON`: *"Dạ áo khoác mặc đi làm hay đi Sapa ạ? Tầm giá bao nhiêu để em lọc cho chuẩn?"*.
### Mode 3: Consideration (Cân nhắc / Xử lý từ chối)
- **Tâm lý khách:** Đã cung cấp đủ nhu cầu (Giới tính, màu, dịp, ngân sách). Khách bắt đầu săm soi sản phẩm, chê đắt, hỏi chất liệu, hoặc hỏi cách phối đồ. Đây là chốt chặn khốc liệt nhất. Trạng thái **Nóng**.
- **Nghiệp vụ AI (Chỉ thị):** Bung lụa! Gọi Tool **ProductSearchEngine** 7 tầng. Moi data từ bảng `ai_matches` ra để gợi ý combo (áo này phối với quần kia).
> [!TIP]
> **Nghiệp vụ Upsell:** Ở mode này, nếu khách đòi mua áo khoác 500k mà hết hàng, AI sẽ tự động kích `price_max` lên 750k và thuyết phục khéo: *"Dạ mẫu dưới 500k đang hết size, nhưng có mẫu này nhỉnh hơn chút xíu mặc cực kỳ ấm, anh xem thử nhé!"*.
### Mode 4: Decision (Chốt hạ / Action)
- **Tâm lý khách:** Khách đã ưng cái bụng ("Lấy anh cái đầu tiên", "Chị lấy màu đen size M"). Trạng thái **Chín muồi**.
- **Nghiệp vụ AI (Chỉ thị):** Quay xe đổi chiến thuật! Lập tức bỏ qua các đoạn văn hoa tư vấn chất liệu, chuyển sang giọng điệu **Chốt Sale (Săn mồi)** gọn gàng: *"Dạ form này mặc siêu tôn dáng. Anh cho em xin số điện thoại để em lưu thông tin giảm giá và lên đơn nhé!"*.
### Mode 5: Retention (Chăm sóc / Hậu mãi)
- **Tâm lý khách:** Khách đã để lại SĐT, hoặc từ chối thẳng thừng bỏ đi.
- **Nghiệp vụ AI (Chỉ thị):**
- Nếu thành công: Lưu Data vào DB, cảm ơn, hứa hẹn Telesale sẽ gọi xác nhận.
- Nếu thất bại: Mở đường lùi thanh lịch, không chèo kéo gây phản cảm: *"Dạ không sao ạ, khi nào có nhu cầu anh cứ nhắn lại em nhé"*. Kết thúc luồng mượt mà.
---
> [!IMPORTANT]
> **Tóm lại:** Toàn bộ code phức tạp ở tầng dưới (Classifier, LLM Router) đều chỉ phục vụ một mục đích duy nhất là **"Nhét đúng khách vào 1 trong 5 Mode này"**. Khi đã xác định đúng Mode, AI sẽ lột xác từ một cỗ máy trả lời tự động thành một Best Seller thực thụ.
# 2. Kiến trúc Dual-Agent Flow
Để xử lý mượt mà 5 Mode tâm lý khách hàng, hệ thống Lead Search không sử dụng 1 Agent nguyên khối (Monolith) cồng kềnh, mà chia ra làm **Hai Agent phối hợp** với nhau (Classifier và Stylist).
Sự phân cực này giúp hệ thống:
1. **Tiết kiệm chi phí (Cost):** Chỉ có Agent làm nhiệm vụ chốt sale cuối cùng mới dùng Model xịn (Nặng), còn lại Model định tuyến dùng con nhỏ (Nhẹ).
2. **Tốc độ (Latency):** Định tuyến siêu tốc, không bị chèn ép Context Window.
---
## 🏛 Sơ đồ Luồng Hai Tầng (Dual-Agent Architecture)
![Kiến trúc Dual-Agent](lead_search_arch.svg)
> Sơ đồ trên minh họa quá trình một tin nhắn của người dùng đi qua 2 trạm kiểm soát trước khi tạo ra câu trả lời cuối cùng.
---
## 🕵️ AI #1: Classifier Agent (Ngòi nổ)
**Vị trí:** `backend/agent/lead_stage_agent/graph.py` -> `_classifier_node()`
**Đặc điểm:** Dùng Model nhỏ, nhẹ, output ép kiểu JSON (Structured Output).
Nhiệm vụ của Classifier giống như một **Lễ tân / Người chẩn bệnh**:
1. Đọc tin nhắn mới nhất và `InsightJSON` (Hồ sơ cũ) của khách.
2. Quyết định khách đang ở Mode nào (1 trong 5 Mode).
3. **Quyết định gọi Tool hay không:**
- Nếu khách chỉ chào hỏi (Mode 1) -> **Early Exit**: Pass thẳng qua cho Stylist để vỗ về khách, KHÔNG chọc vào Database tìm đồ.
- Nếu khách tìm đồ -> Kích hoạt cơ chế **Split Query** (Tách truy vấn) và truyền vào `ProductSearchEngine`.
---
## 👗 AI #2: Stylist Agent (Kẻ chốt sale)
**Vị trí:** `backend/agent/lead_stage_agent/graph.py` -> `_stylist_node()`
**Đặc điểm:** Dùng Model lớn, Nặng, sở hữu `STYLIST_SYSTEM_PROMPT` với hàng loạt quy tắc cấm kỵ (Guardrails).
Nhiệm vụ của Stylist giống như một **Best Seller thực thụ**:
1. Nhận toàn bộ data đổ về từ `ProductSearchEngine` (Nếu Classifier có gọi Tool).
2. Nhận tone giọng/chỉ thị dựa trên 5 Mode.
3. Sinh ra đoạn văn bản tư vấn (Draft AI Reply) thuyết phục nhất có thể, sử dụng data `ai_matches` để gợi ý combo.
4. **Cập nhật Bộ nhớ (Update Insight):** Ghi chép lại xem khách vừa xem cái gì (`LATEST_PRODUCT_INTEREST`), ngân sách khách có bao nhiêu (`CONSTRAINS`), để ván sau Classifier đọc mà định tuyến tiếp.
---
> [!NOTE]
> Sự tương tác giữa Classifier và Stylist thông qua `LeadStageState` là hạt nhân của LangGraph. Bất cứ khi nào bạn thấy AI hành xử sai (ví dụ: đòi số điện thoại quá sớm), 90% lỗi nằm ở việc Classifier chẩn đoán sai Mode, dẫn đến Stylist chốt sale sai thời điểm.
Sơ đồ Kiến trúc ở trên mô tả chính xác luồng xử lý của Lead Flow, từ lúc nhận Request đến lúc trả kết quả.
### Các node xử lý trong Frontend Pipeline (Tracking trực quan)
Dữ liệu tracking được trả về qua field `pipeline` và được vẽ realtime ở Admin Dashboard:
1. **User Node (`flowUser`):** Nhận query đầu vào.
2. **Classifier Node (`flowClassifier`):** Phân tích ý định, suy luận logic (Reasoning). Sinh ra metadata như: `confidence`, `stage`, `tone_directive`.
3. **Tool Node (`flowTool`):** Trả về `search_mode` (tier 1, tier 2, tier 3), số lượng kết quả (`count`), và mảng chi tiết `products`.
4. **Stylist Node (`flowStylist`):** Lắp ghép kết quả vào Prompt để sinh ra nội dung chat cuối cùng, tối ưu cho tỷ lệ chuyển đổi. Render Markdown trực tiếp qua thư viện `marked.js` ở client.
\ No newline at end of file
Hệ thống Lead Flow sử dụng **Postgres Persistence Layer** (`common.lead_flow_postgres`) thay vì Redis (có TTL ngắn). Điều này đảm bảo lịch sử chat của khách hàng không bị mất, giúp AI #1 luôn có context dài hạn về toàn bộ "hành trình mua sắm" của user.
### Cấu trúc định danh (Identity keys)
- **Mỗi thiết bị** được định danh qua `device_id` (sinh ra bằng hàm `uuidv4()` lưu ở `localStorage` trên frontend với key: `canifa_lead_device_id`).
- **Mỗi cuộc hội thoại** dùng `conversation_id` để gom nhóm các tin nhắn (Turn) cho một phiên độc lập.
### Cơ chế lưu trữ
Khi có một Request đến, middleware sẽ đọc Header `device_id`, ghi nhận vào `request.state`.
Trong quá trình Controller chạy, tất cả các tin nhắn In/Out đều được sync thẳng vào Table Postgres chuyên dụng cho Lead Flow.
```javascript
// Đoạn logic Frontend tạo persistent ID
let LEAD_DEVICE_ID = (() => {
const KEY = 'canifa_lead_device_id';
let id = localStorage.getItem(KEY);
if (!id) { id = uuidv4(); localStorage.setItem(KEY, id); }
return id;
})();
```
\ No newline at end of file
# 3. Split Query & Cascading Search
Phần lõi kỹ thuật đỉnh cao nhất của hệ thống Lead Agent nằm trong `ProductSearchEngine`. Khi nhận được lệnh từ Classifier, Engine này áp dụng cơ chế **Tách truy vấn làm 2 (Split Query)** và lọc rớt 7 tầng **(Cascading Search)**.
---
## ✂️ 1. Logic Tách Truy Vấn (Split Query Logic)
Thay vì ném nguyên một câu dài ngoằng của khách vào Database (dẫn đến tỷ lệ Không tìm thấy - Zero Results rất cao), Classifier chia câu đó thành 2 luồng song song:
Ví dụ câu hỏi: *"Anh muốn mua áo khoác nam mùa đông, form rộng che bụng bia, tầm 5 loét để đi Sapa"*
### Lane 1: Literal Search (Nguyên văn)
- **Data:** `raw_text: "áo khoác nam mùa đông form rộng 5 loét đi Sapa"`
- **Mục đích:** Dùng để tìm kiếm toàn văn (Full-text) phòng khi database có mô tả khớp chính xác các từ khóa đặc thù (VD: "Sapa", "bụng bia").
### Lane 2: Inferred Search (Suy luận có cấu trúc)
- **Data:**
- `product_line_vn`: `["Áo khoác"]`
- `gender_by_product`: `"men"`
- `tags`: `["wthr:mua_dong", "fit:oversize", "occ:du_lich"]`
- `price_max`: `500000`
- **Mục đích:** Đưa những từ lóng, mơ hồ về các **Hằng số chuẩn mực** của Canifa. (VD: "mùa đông" thành `wthr:mua_dong`, "5 loét" thành `500000`).
Sự kết hợp giữa 2 Lane này giúp SQL Builder của StarRocks query ra kết quả chính xác 99%.
---
## 🌊 2. Thuật toán rớt mạng 7 tầng (Cascading Search)
Trong ngành Retail, cấm kỵ nhất là nói câu: *"Dạ em hết hàng"*.
Để ép AI luôn có đồ trên tay ra chào khách, `ProductSearchEngine` được thiết kế đánh SQL qua 7 Tầng nới lỏng dần dần:
- **Tầng 1 & 2 (Chính xác tuyệt đối):** Tìm đúng ý khách 100%. (Đúng giá, đúng giới tính, đúng form dáng, đúng mùa). Dùng BITMAP tags để quét cực nhanh.
- **Tầng 3 (Bớt khó tính):** Bỏ qua các tags chi tiết (form, mùa), chỉ giữ lại loại sản phẩm và giá tiền.
- **Tầng 4 (Thả cửa giới tính - Drop Gender):** Nới lỏng giới tính để hệ thống bốc các đồ Unisex (hoặc nữ mặc đồ nam vẫn đẹp) ra chào khách.
- **Tầng 5 & 6 (Nghiệp vụ Upsell - Price Relaxation 1.5x đến 2x):**
- Khách đòi áo 500k không có. Code sẽ tự động kích `price_max` lên 1.5 lần (750k).
- *Mục đích Business:* Dụ khách rướn thêm tiền. Stylist AI sẽ nói: *"Dạ mẫu dưới 500k đang hết, nhưng có mẫu nhỉnh hơn ngân sách chút xíu mặc đi Sapa siêu ấm..."*.
- **Tầng 7 (Bỏ luôn giá - Drop Price):** Vứt luôn bộ lọc giá, lôi những đồ xịn nhất ra chào hàng. Thà khách chê đắt rồi bỏ đi còn hơn là báo không có đồ.
---
## 🌟 3. Làm giàu Dữ liệu (Enrichment)
Tìm ra đồ rồi vẫn chưa xong, Tool sẽ bồi thêm 2 cú đấm trước khi gửi cho Stylist:
1. **Canifa Stock API:** Ném đống đồ vừa tìm sang API Tồn kho. Món nào thực tế hết hàng/hết size -> Vứt ngay lập tức. Đảm bảo tư vấn là mua được.
2. **SQLite Outfit Context:** Chọc vào Local DB lấy cột `ai_matches`. Bơm vào đầu con Stylist cái lý do: *"Cái áo này anh mặc chung với quần khaki là chuẩn bài thanh lịch"*. Mọi gợi ý phối đồ đều có cơ sở khoa học, không phải AI chém gió.
{
"endpoints": [
{
"method": "POST",
"path": "/api/agent/chat-lead-flow",
"description": "Endpoint chính nhận query của user, chạy LangGraph Controller và trả về kết quả kèm pipeline tracking."
},
{
"method": "POST",
"path": "/api/agent/lead-stage",
"description": "Debug endpoint: Chỉ chạy AI #1 Classifier để test prompt mà không cần gọi Stylist."
},
{
"method": "GET",
"path": "/api/lead/history",
"description": "Lấy danh sách (limit mặc định: 50) lịch sử chat của một conversation_id cụ thể từ Postgres để render lại phiên."
},
{
"method": "GET",
"path": "/api/lead/conversations",
"description": "Liệt kê danh sách các phiên chat của device_id hiện tại (dùng để làm dropdown ở sidebar)."
},
{
"method": "GET",
"path": "/api/lead/dashboard",
"description": "Admin endpoint: Monitor toàn bộ history của mọi user để đánh giá tỷ lệ Lead conversion trên diện rộng."
}
]
}
\ No newline at end of file
# 4. Modification Guide (Cẩm nang thay đổi luồng)
Khi có yêu cầu từ phía Business (VD: Thêm 1 dịp lễ mới, đổi cách AI xưng hô, thêm 1 tool tính tiền), Developer cần biết chính xác điểm "phẫu thuật" để không làm hỏng toàn bộ quy trình.
Dưới đây là sơ đồ phân quyền các file chịu trách nhiệm:
---
## 🛠 1. Sửa Tags, Insights (Phân loại khách)
**Yêu cầu:** Marketing muốn thêm tag `occ:di_dam_cuoi` (Đi tiệc cưới), hoặc muốn lưu thêm thông tin `PET_OWNER` vào bộ nhớ khách hàng để sau này retargeting.
**File sửa:** `backend/agent/lead_stage_agent/graph.py`
- Để thêm tag: Tìm class `InferredSearchArgs` -> Sửa/thêm Enum trong field `tags` mô tả cho LLM biết.
- Để thêm Insight: Tìm class `InsightJSON` -> Thêm field mới `PET_OWNER: str`. Sau đó LLM sẽ tự động update nếu nhận diện được.
---
## 🗣 2. Sửa Giọng điệu, Văn phong (Tone/Voice)
**Yêu cầu:** Sếp bắt AI phải xưng "Stylist Canifa / Quý khách" thay vì "Mình / Bạn". Bắt AI không bao giờ được tự ý đề cập đến việc giảm giá.
**File sửa:** `backend/agent/lead_stage_agent/prompts.py`
- Tìm hằng số `STYLIST_SYSTEM_PROMPT`.
- Đây là "Cương lĩnh" của con AI Stylist. Thêm một gạch đầu dòng mạnh tay (Dùng CHỮ HOA) để ép AI tuân thủ. Ví dụ: `- TUYỆT ĐỐI KHÔNG BÁO GIẢM GIÁ NẾU KHÔNG CÓ TRONG DATA`.
---
## 🛒 3. Đổi Thuật toán Rớt tầng (Upsell Logic)
**Yêu cầu:** Đội Sales bảo: "Nới giá 1.5x cao quá khách chạy mất, chỉnh xuống 1.2x thôi".
**File sửa:** `backend/agent/lead_stage_agent/product_search_engine.py`
- Tìm hàm `_cascading_search()`.
- Chỉnh sửa cấu trúc Tầng 5 và Tầng 6 (giảm multiplier từ 1.5 xuống 1.2).
- Có thể thêm/bớt các Tầng tùy theo ý đồ kinh doanh (Ví dụ: Thêm tầng chỉ lọc đồ theo một Category nhất định đang cần xả kho).
---
## ⚙️ 4. Thêm Tool mới
**Yêu cầu:** Tích hợp tool `check_store_location` để AI báo cho khách biết cửa hàng nào gần nhà khách nhất còn đồ.
**File sửa:**
1. `backend/agent/tools/` -> Viết code logic cho tool mới ở đây.
2. `backend/agent/lead_stage_agent/graph.py` -> Mở class `ClassifierOutput`, thêm tên tool vào mô tả để Classifier biết. Thêm tool vào dictionary `self._tool_registry` để LangGraph tự kích hoạt.
> [!WARNING]
> Bất kỳ sửa đổi nào ở Classifier (đầu vào) sẽ ảnh hưởng toàn bộ luồng phía sau. Sau khi sửa, hãy luôn dùng tính năng **User Simulator** trong Cookbook để bắn thử 50 test case xem AI có bị "ngáo" hay quên học thuộc bài không.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 600">
<defs>
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.05" flood-color="#000"/>
</filter>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6B7280"/>
</marker>
<marker id="arrow-dashed" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#9CA3AF"/>
</marker>
</defs>
<rect width="1000" height="600" fill="#F9FAFB" rx="16"/>
<!-- Legend -->
<rect x="30" y="30" width="280" height="90" rx="8" fill="white" stroke="#E5E7EB" filter="url(#shadow)"/>
<text x="45" y="55" fill="#111827" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600">Legend (Dual-Agent Architecture)</text>
<circle cx="55" cy="75" r="6" fill="#38BDF8"/>
<text x="70" y="80" fill="#4B5563" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12">Classifier: Routes &amp; Extracts Intents</text>
<circle cx="55" cy="95" r="6" fill="#F472B6"/>
<text x="70" y="100" fill="#4B5563" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12">Stylist: Generates Empathic Response</text>
<!-- User Input -->
<rect x="30" y="270" width="120" height="60" rx="8" fill="#111827" filter="url(#shadow)"/>
<text x="90" y="305" fill="white" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" text-anchor="middle">User Message</text>
<path d="M 150 300 L 210 300" stroke="#6B7280" stroke-width="2" marker-end="url(#arrow)"/>
<!-- Classifier Box -->
<rect x="220" y="190" width="220" height="220" rx="12" fill="#E0F2FE" stroke="#38BDF8" stroke-width="2" filter="url(#shadow)"/>
<text x="330" y="225" fill="#0369A1" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="16" font-weight="bold" text-anchor="middle">Classifier Agent</text>
<text x="330" y="245" fill="#0C4A6E" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" text-anchor="middle">(Light LLM / Structured Output)</text>
<!-- Split Query -->
<text x="330" y="275" fill="#0284C7" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" text-anchor="middle">Tách truy vấn (Split Query)</text>
<rect x="240" y="290" width="180" height="40" rx="6" fill="#BAE6FD"/>
<text x="330" y="315" fill="#0369A1" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" text-anchor="middle">Literal Lane (Nguyên văn)</text>
<rect x="240" y="340" width="180" height="40" rx="6" fill="#BAE6FD"/>
<text x="330" y="365" fill="#0369A1" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" text-anchor="middle">Inferred Lane (Suy luận Tags)</text>
<!-- Line to Early Exit -->
<path d="M 330 190 L 330 110 L 780 110 L 780 180" stroke="#9CA3AF" stroke-width="2" stroke-dasharray="5,5" marker-end="url(#arrow-dashed)" fill="none"/>
<rect x="500" y="95" width="120" height="30" rx="15" fill="#F3F4F6" stroke="#D1D5DB" stroke-width="1"/>
<text x="560" y="115" fill="#4B5563" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" font-weight="600" text-anchor="middle">Early Exit (No Tool)</text>
<!-- Line to Tool -->
<path d="M 440 300 L 500 300" stroke="#6B7280" stroke-width="2" marker-end="url(#arrow)"/>
<!-- Product Search Engine Tool -->
<rect x="510" y="180" width="220" height="240" rx="12" fill="#DCFCE7" stroke="#4ADE80" stroke-width="2" filter="url(#shadow)"/>
<text x="620" y="215" fill="#166534" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="16" font-weight="bold" text-anchor="middle">ProductSearchEngine</text>
<text x="620" y="235" fill="#14532D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" text-anchor="middle">Data Extraction Tool</text>
<rect x="530" y="260" width="180" height="30" rx="4" fill="#BBF7D0"/>
<text x="620" y="280" fill="#14532D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" font-weight="500" text-anchor="middle">Cascading Search (7 Tiers)</text>
<rect x="530" y="300" width="180" height="30" rx="4" fill="#BBF7D0"/>
<text x="620" y="320" fill="#14532D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" font-weight="500" text-anchor="middle">StarRocks Vectors &amp; SQL</text>
<rect x="530" y="340" width="180" height="30" rx="4" fill="#BBF7D0"/>
<text x="620" y="360" fill="#14532D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" font-weight="500" text-anchor="middle">Canifa Stock API</text>
<rect x="530" y="380" width="180" height="30" rx="4" fill="#BBF7D0"/>
<text x="620" y="400" fill="#14532D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" font-weight="500" text-anchor="middle">SQLite Outfit Context</text>
<!-- Line to Stylist -->
<path d="M 730 300 L 790 300" stroke="#6B7280" stroke-width="2" marker-end="url(#arrow)"/>
<text x="760" y="290" fill="#6B7280" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="11" font-weight="500" text-anchor="middle">Tool Result</text>
<!-- Stylist Box -->
<rect x="800" y="190" width="160" height="220" rx="12" fill="#FCE7F3" stroke="#F472B6" stroke-width="2" filter="url(#shadow)"/>
<text x="880" y="225" fill="#9D174D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="16" font-weight="bold" text-anchor="middle">Stylist Agent</text>
<text x="880" y="245" fill="#831843" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="12" text-anchor="middle">(Heavy LLM)</text>
<rect x="820" y="280" width="120" height="40" rx="6" fill="#FBCFE8"/>
<text x="880" y="305" fill="#9D174D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" text-anchor="middle">Draft AI Reply</text>
<rect x="820" y="330" width="120" height="40" rx="6" fill="#FBCFE8"/>
<text x="880" y="355" fill="#9D174D" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" text-anchor="middle">Update Insight</text>
<!-- Output -->
<path d="M 880 410 L 880 460" stroke="#6B7280" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="820" y="470" width="120" height="40" rx="8" fill="#111827" filter="url(#shadow)"/>
<text x="880" y="495" fill="white" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" text-anchor="middle">Final Response</text>
<!-- Output Insight Database -->
<path d="M 800 350 L 740 350" stroke="#6B7280" stroke-width="2" marker-end="url(#arrow)"/>
<rect x="620" y="470" width="120" height="40" rx="8" fill="#F3F4F6" stroke="#D1D5DB" filter="url(#shadow)"/>
<text x="680" y="495" fill="#4B5563" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" text-anchor="middle">Redis (Insight)</text>
<path d="M 680 470 L 680 440" stroke="#9CA3AF" stroke-width="1.5" stroke-dasharray="4,4"/>
<text x="680" y="430" fill="#6B7280" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="10" text-anchor="middle">Load history/insight</text>
<path d="M 620 490 L 330 490 L 330 410" stroke="#9CA3AF" stroke-width="1.5" stroke-dasharray="4,4" marker-end="url(#arrow-dashed)" fill="none"/>
</svg>
\ No newline at end of file
{
"id": "08-lead-search",
"title": "Lead Search Agent & 5 Modes",
"description": "Deep Dive vào hệ thống tìm kiếm khách hàng tiềm năng. Giải thích cặn kẽ Nghiệp vụ phễu Sales 5 Mode tâm lý, kiến trúc Dual-Agent và thuật toán Tách truy vấn (Split Query).",
"diagram": "data/08-lead-search/lead_search_arch.svg",
"sections": [
{ "id": "business", "title": "1. Business Logic & 5 Mode Tâm Lý", "type": "markdown", "file": "01_business_logic.md" },
{ "id": "arch", "title": "2. Kiến trúc Dual-Agent Flow", "type": "markdown", "file": "02_dual_agent.md" },
{ "id": "logic", "title": "3. Split Query & Cascading Search", "type": "markdown", "file": "03_split_query.md" },
{ "id": "guide", "title": "4. Modification Guide", "type": "markdown", "file": "04_guide.md" }
]
}
\ No newline at end of file
# Journey Shopping — Phễu 5 Mode Tâm Lý Khách Hàng
Lead Search Agent được thiết kế theo mô hình **Phễu bán hàng (Sales Funnel)**, chia hành trình khách hàng thành 5 giai đoạn tâm lý. Mục tiêu cuối cùng: thu thập Contact (Lead) hoặc chốt đơn (Sales).
## Tại sao phải chia 5 Mode?
Nếu hệ thống không nhận biết khách đang ở trạng thái tâm lý nào, AI sẽ hành xử sai hoàn toàn:
- Khách mới chào "Hi" nhưng AI nhảy xổ ra đòi số điện thoại — gây hoảng sợ, mất khách.
- Khách đang hỏi kỹ chất vải để chốt nhưng AI lại hỏi "Anh tìm đồ cho nam hay nữ?" — gây bực mình.
Việc chia 5 Mode là để tạo **dây cương** kìm hãm LLM, ép nó bơm đúng Tone giọng và Chiến thuật phù hợp với từng nấc của phễu.
## Sơ đồ phễu (Funnel Diagram)
```mermaid
graph LR
M1["1. BROWSE (Lạnh)"] --> M2["2. IDENTIFYING (Ấm)"]
M2 --> M3["3. CONSIDERING (Nóng)"]
M3 --> M4["4. CLOSING (Chín muồi)"]
M4 --> M5["5. RETENTION (Kết thúc)"]
style M1 fill:#334155,stroke:#64748b,color:#e2e8f0
style M2 fill:#0c4a6e,stroke:#0ea5e9,color:#e0f2fe
style M3 fill:#5b21b6,stroke:#8b5cf6,color:#ede9fe
style M4 fill:#92400e,stroke:#f59e0b,color:#fef3c7
style M5 fill:#14532d,stroke:#22c55e,color:#dcfce7
```
## Chi tiết từng Mode
### Mode 1: BROWSE (Awareness — Dạo quanh)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái** | Lạnh — Khách vô tình lướt qua, chưa có ý định mua |
| **InsightJSON.STAGE** | `BROWSE` |
| **STAGE_NUM** | `1` |
| **TONE** | `Friendly` |
| **Chiến thuật AI** | Cấm quăng list sản phẩm. Chỉ chào hỏi, rã đông, gợi mở câu chuyện |
| **Ví dụ AI** | "Dạ chào bạn! Bạn đang tìm đồ cho nam, nữ hay bé ạ?" |
### Mode 2: IDENTIFYING (Interest — Gom nhu cầu)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái** | Ấm — Khách nhả manh mối ("Mình tìm áo khoác") nhưng thông tin còn thiếu |
| **InsightJSON.STAGE** | `IDENTIFYING` |
| **STAGE_NUM** | `2` |
| **TONE** | `Consultant` |
| **Chiến thuật AI** | Hạn chế gọi Tool tìm kiếm. Đặt câu hỏi lấp đầy InsightJSON |
| **Ví dụ AI** | "Dạ áo khoác mặc đi làm hay đi du lịch ạ? Tầm giá bao nhiêu?" |
### Mode 3: CONSIDERING (Cân nhắc — Xử lý từ chối)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái** | Nóng — Khách cung cấp đủ nhu cầu, bắt đầu soi xét, chê đắt, hỏi chất liệu |
| **InsightJSON.STAGE** | `CONSIDERING` |
| **STAGE_NUM** | `3` |
| **TONE** | `Persuasive` |
| **Chiến thuật AI** | Gọi ProductSearchEngine 7 tầng. Gợi ý combo từ `ai_matches`. Kích hoạt Upsell (nhân giá 1.5x) nếu hết đồ rẻ |
| **Ví dụ AI** | "Dạ mẫu dưới 500k đang hết size, nhưng có mẫu này nhỉnh hơn chút xíu mặc cực kỳ ấm!" |
### Mode 4: CLOSING (Decision — Chốt hạ)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái** | Chín muồi — Khách đã ưng ("Lấy anh cái đầu tiên") |
| **InsightJSON.STAGE** | `CLOSING` |
| **STAGE_NUM** | `4` |
| **TONE** | `Action-oriented` |
| **Chiến thuật AI** | Bỏ văn hoa tư vấn. Chuyển sang giọng chốt sale: xin SĐT, xin Email, lên đơn |
| **Ví dụ AI** | "Dạ form này mặc siêu tôn dáng! Anh cho em xin SĐT để lưu thông tin ưu đãi nhé!" |
### Mode 5: RETENTION (Hậu mãi)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái** | Kết thúc — Khách đã để lại SĐT hoặc từ chối |
| **InsightJSON.STAGE** | `RETENTION` |
| **STAGE_NUM** | `5` |
| **TONE** | `Warm` |
| **Chiến thuật AI** | Thành công: Lưu DB, cảm ơn, hứa Telesale gọi. Thất bại: Mở đường lùi, không chèo kéo |
| **Ví dụ AI** | "Dạ không sao ạ, khi nào có nhu cầu anh cứ nhắn lại em nhé!" |
## InsightJSON — Bộ nhớ khách hàng
Đây là đối tượng lưu trữ toàn bộ thông tin đã thu thập được về khách trong suốt phiên chat. Classifier đọc nó để phân loại Mode, Stylist cập nhật nó sau mỗi turn.
```python
class InsightJSON(BaseModel):
USER: str # Thông tin người dùng (tên, xưng hô)
TARGET: str # Đối tượng mua hàng (cho bản thân, cho con, cho vợ)
GOAL: str # Mục đích mua (đi làm, đi du lịch, đi tiệc)
CONSTRAINS: str # Ràng buộc rõ ràng (budget < 500k, chỉ màu đen)
STAGE: str # Mode hiện tại (BROWSE / IDENTIFYING / CONSIDERING / CLOSING / RETENTION)
STAGE_NUM: int # Số thứ tự Mode (1-5)
TONE: str # Giọng điệu AI đang dùng
BEHAVIORAL_HINTS: list[str] # Dấu hiệu hành vi (hay hỏi giá, thích xem ảnh)
LATEST_PRODUCT_INTEREST: str # Sản phẩm vừa xem gần nhất
LAST_ACTION: str # Hành động cuối (xem sản phẩm, hỏi size, chốt đơn)
SUMMARY_HISTORY: str # Tóm tắt lịch sử hội thoại
```
File tham chiếu: `backend/agent/lead_stage_agent/graph.py` — class `InsightJSON`
<style>
.journey-map{position:relative;padding:20px 0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.journey-title{text-align:center;margin-bottom:32px}
.journey-title h2{font-size:1.6em;color:#e2e8f0;margin:0}
.journey-title p{color:#94a3b8;font-size:.9em;margin-top:6px}
.journey-track{display:flex;align-items:flex-start;gap:0;position:relative;overflow-x:auto;padding:20px 0 40px}
.journey-track::before{content:'';position:absolute;top:72px;left:40px;right:40px;height:3px;background:linear-gradient(90deg,#334155,#0ea5e9,#8b5cf6,#f59e0b,#22c55e);border-radius:2px;z-index:0}
.mode-node{flex:1;min-width:170px;display:flex;flex-direction:column;align-items:center;position:relative;z-index:1;cursor:pointer;transition:transform .3s}
.mode-node:hover{transform:translateY(-4px)}
.mode-icon{width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;border:3px solid #1e293b;transition:all .4s;box-shadow:0 4px 20px rgba(0,0,0,.3)}
.mode-node.active .mode-icon{border-color:#fff;transform:scale(1.15);box-shadow:0 0 30px rgba(14,165,233,.5)}
.mode-label{margin-top:12px;font-weight:700;font-size:.82em;color:#cbd5e1;text-transform:uppercase;letter-spacing:.05em;text-align:center;transition:color .3s}
.mode-node.active .mode-label{color:#fff}
.mode-tag{font-size:.7em;color:#64748b;margin-top:2px;font-weight:400;text-transform:none;letter-spacing:0}
.mode-detail{margin-top:24px;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;animation:slideUp .4s ease;max-width:700px;margin-left:auto;margin-right:auto}
.mode-detail h3{margin:0 0 8px;font-size:1.1em;display:flex;align-items:center;gap:8px}
.mode-detail .badge{font-size:.65em;padding:3px 10px;border-radius:20px;font-weight:600;text-transform:uppercase}
.mode-detail p{color:#94a3b8;font-size:.88em;line-height:1.6;margin:8px 0}
.mode-detail .ai-says{background:#0f172a;border-left:3px solid #0ea5e9;padding:10px 14px;border-radius:0 8px 8px 0;margin-top:12px;font-style:italic;color:#7dd3fc;font-size:.85em}
@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.m1 .mode-icon{background:linear-gradient(135deg,#334155,#475569)}
.m2 .mode-icon{background:linear-gradient(135deg,#0c4a6e,#0ea5e9)}
.m3 .mode-icon{background:linear-gradient(135deg,#5b21b6,#8b5cf6)}
.m4 .mode-icon{background:linear-gradient(135deg,#92400e,#f59e0b)}
.m5 .mode-icon{background:linear-gradient(135deg,#14532d,#22c55e)}
</style>
<div class="journey-map" id="journeyMap">
<div class="journey-title">
<h2>🛍️ Lead Shopping Journey — 5 Modes</h2>
<p>Bấm vào từng Mode để xem tâm lý khách hàng & chiến thuật AI</p>
</div>
<div class="journey-track">
<div class="mode-node m1 active" onclick="showMode(0)">
<div class="mode-icon">👋</div>
<div class="mode-label">Awareness<br><span class="mode-tag">Dạo quanh</span></div>
</div>
<div class="mode-node m2" onclick="showMode(1)">
<div class="mode-icon">🎯</div>
<div class="mode-label">Interest<br><span class="mode-tag">Quan tâm</span></div>
</div>
<div class="mode-node m3" onclick="showMode(2)">
<div class="mode-icon">🔥</div>
<div class="mode-label">Consideration<br><span class="mode-tag">Cân nhắc</span></div>
</div>
<div class="mode-node m4" onclick="showMode(3)">
<div class="mode-icon">🎉</div>
<div class="mode-label">Decision<br><span class="mode-tag">Chốt hạ</span></div>
</div>
<div class="mode-node m5" onclick="showMode(4)">
<div class="mode-icon">💚</div>
<div class="mode-label">Retention<br><span class="mode-tag">Hậu mãi</span></div>
</div>
</div>
<div id="modeDetail"></div>
</div>
<script>
var modeData = [
{
title: "Mode 1: Awareness", color: "#475569",
badge: "Trạng thái: LẠNH", badgeColor: "#334155",
psychology: "Khách vô tình lướt qua, ấn nhầm hoặc chỉ chào "Hi", "Alo". Chưa có ý định mua bất cứ thứ gì.",
strategy: "CẤM tuyệt đối quăng list sản phẩm. Nhiệm vụ duy nhất: Rã đông bằng cách chào hỏi và gợi mở câu chuyện.",
aiSays: "Dạ chào bạn! Mình là Stylist Canifa. Bạn đang tìm đồ cho nam, nữ hay bé để mình tư vấn nhé? 😊",
insight: "InsightJSON.STAGE = 'BROWSE' | STAGE_NUM = 1 | TONE = 'Friendly'"
},
{
title: "Mode 2: Interest", color: "#0ea5e9",
badge: "Trạng thái: ẤM", badgeColor: "#0c4a6e",
psychology: "Khách nhả ra manh mối ("Mình tìm áo khoác"). Thông tin còn thiếu, chưa đủ để filter đồ chính xác.",
strategy: "AI đóng vai Chuyên gia hỏi cung khéo léo. HẠN CHẾ gọi Tool, thay vào đó là đặt câu hỏi lấp đầy InsightJSON.",
aiSays: "Dạ áo khoác mặc đi làm hay đi du lịch ạ? Tầm giá bao nhiêu để em lọc cho chuẩn nhé?",
insight: "InsightJSON.STAGE = 'IDENTIFYING' | STAGE_NUM = 2 | TONE = 'Consultant'"
},
{
title: "Mode 3: Consideration", color: "#8b5cf6",
badge: "Trạng thái: NÓNG 🔥", badgeColor: "#5b21b6",
psychology: "Khách đã cung cấp đủ nhu cầu. Bắt đầu săm soi sản phẩm, chê đắt, hỏi chất liệu, cách phối đồ. Chốt chặn khốc liệt nhất.",
strategy: "BUNG LỤA! Gọi ProductSearchEngine 7 tầng. Moi ai_matches gợi ý combo. Kích hoạt nghiệp vụ UPSELL nếu hết đồ rẻ (nhân giá 1.5x).",
aiSays: "Dạ mẫu dưới 500k đang hết size, nhưng có mẫu này nhỉnh hơn chút xíu mặc đi Sapa siêu ấm. Anh xem thử nhé!",
insight: "InsightJSON.STAGE = 'CONSIDERING' | STAGE_NUM = 3 | TONE = 'Persuasive'"
},
{
title: "Mode 4: Decision", color: "#f59e0b",
badge: "Trạng thái: CHÍN MUỒI", badgeColor: "#92400e",
psychology: "Khách đã ưng cái bụng ("Ly anh cái đầu tiên", "Ch ly màu đen size M"). Sẵn sàng chốt.",
strategy: "QUAY XE đổi chiến thuật! Bỏ văn hoa tư vấn chất liệu. Chuyển sang giọng CHỐT SALE gọn gàng: Xin SĐT, xin Email, lên đơn.",
aiSays: "Dạ form này mặc siêu tôn dáng! Anh cho em xin số điện thoại để em lưu thông tin ưu đãi và lên đơn nhé!",
insight: "InsightJSON.STAGE = 'CLOSING' | STAGE_NUM = 4 | TONE = 'Action-oriented'"
},
{
title: "Mode 5: Retention", color: "#22c55e",
badge: "Trạng thái: KẾT THÚC", badgeColor: "#14532d",
psychology: "Khách đã để lại SĐT (thành công), hoặc từ chối thẳng thừng (thất bại).",
strategy: "Thành công → Lưu DB, cảm ơn, hứa Telesale sẽ gọi. Thất bại → Mở đường lùi thanh lịch, KHÔNG chèo kéo.",
aiSays: "Dạ không sao ạ, khi nào có nhu cầu anh cứ nhắn lại em nhé. Chúc anh một ngày tốt lành! 💚",
insight: "InsightJSON.STAGE = 'RETENTION' | STAGE_NUM = 5 | TONE = 'Warm'"
}
];
function showMode(idx) {
document.querySelectorAll('.mode-node').forEach(function(n,i){
n.classList.toggle('active', i === idx);
});
var d = modeData[idx];
document.getElementById('modeDetail').innerHTML =
'<div class="mode-detail">' +
'<h3 style="color:' + d.color + '">' + d.title +
' <span class="badge" style="background:' + d.badgeColor + ';color:#e2e8f0">' + d.badge + '</span>' +
'</h3>' +
'<p><strong>🧠 Tâm lý khách:</strong> ' + d.psychology + '</p>' +
'<p><strong>⚡ Chiến thuật AI:</strong> ' + d.strategy + '</p>' +
'<div class="ai-says">💬 "' + d.aiSays + '"</div>' +
'<p style="margin-top:14px;font-size:.78em;color:#64748b;font-family:monospace">📦 ' + d.insight + '</p>' +
'</div>';
}
showMode(0);
</script>
<style>
.agent-flow{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:20px 0}
.agent-flow-title{text-align:center;margin-bottom:28px}
.agent-flow-title h2{font-size:1.5em;color:#e2e8f0;margin:0}
.agent-flow-title p{color:#94a3b8;font-size:.88em;margin-top:6px}
.flow-diagram{display:flex;align-items:center;justify-content:center;gap:16px;flex-wrap:wrap;padding:16px 0}
.flow-arrow{color:#475569;font-size:28px;font-weight:700;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:.4}50%{opacity:1}}
.flow-node{border-radius:14px;padding:20px;width:200px;min-height:120px;cursor:pointer;transition:all .4s;border:2px solid transparent;position:relative;text-align:center}
.flow-node:hover{transform:translateY(-6px)}
.flow-node.active{border-color:#fff;box-shadow:0 0 40px rgba(255,255,255,.1)}
.flow-node .node-emoji{font-size:32px;margin-bottom:8px;display:block}
.flow-node .node-title{font-weight:700;font-size:1em;color:#fff;margin-bottom:4px}
.flow-node .node-sub{font-size:.75em;color:rgba(255,255,255,.7)}
.node-user{background:linear-gradient(135deg,#1e293b,#334155)}
.node-classifier{background:linear-gradient(135deg,#0c4a6e,#0284c7)}
.node-tools{background:linear-gradient(135deg,#14532d,#16a34a)}
.node-stylist{background:linear-gradient(135deg,#7e22ce,#a855f7)}
.node-output{background:linear-gradient(135deg,#1e293b,#334155)}
.flow-info{margin-top:24px;background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;animation:slideUp .4s ease;max-width:700px;margin-left:auto;margin-right:auto}
.flow-info h3{margin:0 0 10px;font-size:1.1em}
.flow-info p{color:#94a3b8;font-size:.88em;line-height:1.7;margin:6px 0}
.flow-info .code-ref{background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:8px 14px;font-family:"Fira Code",monospace;font-size:.78em;color:#7dd3fc;margin-top:10px}
.early-exit{position:absolute;top:-28px;right:-10px;font-size:.65em;background:#f59e0b;color:#1e293b;padding:2px 8px;border-radius:10px;font-weight:700;opacity:0;transition:opacity .3s}
.flow-node.active .early-exit{opacity:1}
@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
</style>
<div class="agent-flow">
<div class="agent-flow-title">
<h2>🏛️ Dual-Agent Architecture Flow</h2>
<p>Bấm vào từng node để hiểu vai trò và cách phối hợp</p>
</div>
<div class="flow-diagram">
<div class="flow-node node-user" onclick="showFlow(0)">
<span class="node-emoji">👤</span>
<div class="node-title">User Message</div>
<div class="node-sub">Tin nhắn khách hàng</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-node node-classifier" onclick="showFlow(1)">
<span class="early-exit">⚡ Early Exit</span>
<span class="node-emoji">🧠</span>
<div class="node-title">Classifier</div>
<div class="node-sub">AI Nhẹ — Router</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-node node-tools" onclick="showFlow(2)">
<span class="node-emoji">🔧</span>
<div class="node-title">Search Tool</div>
<div class="node-sub">ProductSearchEngine</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-node node-stylist" onclick="showFlow(3)">
<span class="node-emoji">👗</span>
<div class="node-title">Stylist</div>
<div class="node-sub">AI Nặng — Sales</div>
</div>
<div class="flow-arrow"></div>
<div class="flow-node node-output" onclick="showFlow(4)">
<span class="node-emoji">💬</span>
<div class="node-title">Response</div>
<div class="node-sub">Câu trả lời cuối</div>
</div>
</div>
<div id="flowInfo"></div>
</div>
<script>
var flowData = [
{
title: "👤 User Message", color: "#94a3b8",
desc: "Tin nhắn từ khách hàng gửi qua chat widget (Facebook Messenger, Website Widget, hoặc API). Mỗi tin nhắn sẽ kèm theo <strong>device_id</strong> hoặc <strong>user_id</strong> để hệ thống nhận diện và load lịch sử InsightJSON cũ từ Redis.",
detail: "Input bao gồm: message text, conversation_id, device_id, chat_history (5 tin gần nhất)",
code: "controller.py → lead_stage_chat_controller()"
},
{
title: "🧠 Classifier Agent (Lễ Tân)", color: "#0ea5e9",
desc: "Dùng Model LLM nhỏ, nhanh. Output ép kiểu JSON (Structured Output). Nhiệm vụ:<br>① Đọc tin nhắn + InsightJSON cũ<br>② Phân loại khách vào 1/5 Mode<br>③ Quyết định gọi Tool hay <strong>Early Exit</strong> (trả lời trực tiếp không cần DB)",
detail: "⚡ Early Exit: Khi khách chỉ chào hỏi (Mode 1), Classifier bypass Tool và pass thẳng cho Stylist → Tiết kiệm latency + cost.",
code: "graph.py → _classifier_node() | Output: ClassifierOutput(tool_name, lead_search_args, ai_response)"
},
{
title: "🔧 ProductSearchEngine (Công cụ)", color: "#22c55e",
desc: "Nhận tham số từ Classifier (Split Query: Literal + Inferred), chạy SQL qua <strong>StarRocks</strong> với thuật toán rớt 7 tầng (Cascading Search).<br><br>Sau khi tìm được sản phẩm → Gọi <strong>Canifa Stock API</strong> kiểm tra tồn kho thực tế → Gọi <strong>SQLite</strong> lấy ai_matches (gợi ý phối đồ).",
detail: "Output: Danh sách sản phẩm (có stock, có outfit context) → Truyền cho Stylist",
code: "product_search_engine.py → search() → _cascading_search() → _enrich_with_stock()"
},
{
title: "👗 Stylist Agent (Best Seller)", color: "#a855f7",
desc: "Dùng Model LLM lớn, nặng. Nhận data sản phẩm từ Tool + Mode hiện tại. Nhiệm vụ:<br>① Sinh câu trả lời tư vấn thuyết phục (max 200 từ)<br>② Gợi ý combo phối đồ dựa trên ai_matches<br>③ Cập nhật InsightJSON (ghi nhớ khách vừa xem gì, ngân sách bao nhiêu)",
detail: "Guardrails: Không tự ý giảm giá, không chèo kéo ở Mode 5, không quăng list ở Mode 1",
code: "graph.py → _stylist_node() | Output: StylistOutput(ai_response, product_ids, user_insight)"
},
{
title: "💬 Final Response", color: "#94a3b8",
desc: "Câu trả lời cuối cùng được gửi về cho khách hàng qua chat widget. Đồng thời:<br>① InsightJSON mới được lưu vào <strong>Redis</strong> (TTL 24h)<br>② Toàn bộ trace được ghi vào <strong>Langfuse</strong> (latency: classifier_ms, stylist_ms, total_ms)",
detail: "Khách nhận được tin nhắn tư vấn chuyên nghiệp, kèm link sản phẩm và ảnh minh hoạ",
code: "controller.py → Langfuse trace → Redis set → return StreamingResponse"
}
];
function showFlow(idx) {
document.querySelectorAll('.flow-node').forEach(function(n,i){
n.classList.toggle('active', i === idx);
});
var d = flowData[idx];
document.getElementById('flowInfo').innerHTML =
'<div class="flow-info">' +
'<h3 style="color:' + d.color + '">' + d.title + '</h3>' +
'<p>' + d.desc + '</p>' +
'<p style="color:#fbbf24;font-size:.85em">💡 ' + d.detail + '</p>' +
'<div class="code-ref">📁 ' + d.code + '</div>' +
'</div>';
}
showFlow(1);
</script>
# Kiến trúc Dual-Agent (Classifier + Stylist)
Hệ thống Lead Search không dùng 1 Agent nguyên khối mà chia làm 2 Agent phối hợp: **Classifier** (phân loại ý định) và **Stylist** (sinh ngôn ngữ chốt sale). Thiết kế này giúp tiết kiệm chi phí LLM và giảm latency.
## Luồng xử lý chi tiết (Sequence Diagram)
```mermaid
sequenceDiagram
autonumber
participant User as User Chat
participant Ctrl as Controller
participant CLS as Classifier Agent
participant Tool as ProductSearchEngine
participant STY as Stylist Agent
participant Redis as Redis Cache
User->>Ctrl: Gửi tin nhắn
Ctrl->>Redis: Load InsightJSON (device_id)
Redis-->>Ctrl: Trả về InsightJSON cũ + chat_history
Ctrl->>CLS: Truyền message + InsightJSON + history
alt Khách chỉ chào hỏi (Early Exit)
CLS-->>STY: tool_name = null, pass trực tiếp
else Khách tìm sản phẩm
CLS->>Tool: tool_name = lead_search_tool + Split Query Args
Tool->>Tool: Cascading Search 7 tầng (StarRocks)
Tool->>Tool: Check Stock (Canifa API)
Tool->>Tool: Enrich Outfit (SQLite ai_matches)
Tool-->>STY: Danh sách sản phẩm có stock + outfit context
end
STY->>STY: Sinh AI Response (max 200 từ) + Update InsightJSON
STY-->>Ctrl: StylistOutput (ai_response, product_ids, user_insight)
Ctrl->>Redis: Lưu InsightJSON mới (TTL 24h)
Ctrl-->>User: Trả về câu trả lời + link sản phẩm
```
## Classifier Agent (AI Nhẹ — Router)
Classifier đóng vai trò Lễ tân, chuyên trách phân loại ý định khách hàng.
| Thuộc tính | Chi tiết |
|---|---|
| **Model** | LLM nhẹ, output Structured JSON |
| **Input** | message + InsightJSON + chat_history_summary |
| **Output** | `ClassifierOutput(reasoning, tool_name, lead_search_args, ai_response)` |
| **Nhiệm vụ chính** | Xác định khách ở Mode nào (1-5), quyết định gọi Tool hay Early Exit |
| **File** | `graph.py` — hàm `_classifier_node()` |
**Early Exit:** Khi khách chỉ chào hỏi (Mode 1), Classifier bypass Tool và pass thẳng cho Stylist. Tiết kiệm latency và cost vì không cần query Database.
### ClassifierOutput Schema
```python
class ClassifierOutput(BaseModel):
reasoning: str # Lý luận gọi tool hay trả lời trực tiếp
tool_name: str | None # "lead_search_tool" hoặc null (Early Exit)
lead_search_args: ClassifierLeadSearchArgs | None # Args cho search tool
ai_response: str | None # Câu trả lời khi Early Exit
product_ids: list[str] | None # Mã SKU nếu khách hỏi trúng mã
```
## Stylist Agent (AI Nặng — Best Seller)
Stylist đóng vai trò chốt sale, nhận data sản phẩm từ Tool và sinh câu trả lời thuyết phục.
| Thuộc tính | Chi tiết |
|---|---|
| **Model** | LLM lớn, nặng |
| **Input** | Tool results + InsightJSON + STAGE + tone_directive |
| **Output** | `StylistOutput(ai_response, product_ids, user_insight)` |
| **Nhiệm vụ chính** | Sinh câu trả lời tư vấn, gợi ý combo phối đồ, cập nhật InsightJSON |
| **File** | `graph.py` — hàm `_stylist_node()` |
### Guardrails (Rào chắn)
Stylist bị ràng buộc bởi `STYLIST_SYSTEM_PROMPT` trong `prompts.py`:
- Không tự ý đề cập giảm giá nếu không có trong data
- Không chèo kéo ở Mode 5 (Retention)
- Không quăng list sản phẩm ở Mode 1 (Browse)
- Tối đa 200 từ mỗi response
- Ưu tiên gợi ý combo (áo + quần + phụ kiện) thay vì chỉ 1 sản phẩm
## State Management (LangGraph StateGraph)
Toàn bộ luồng được quản lý bởi **LangGraph StateGraph**. State chứa:
```python
class LeadGraphState(TypedDict):
user_message: str
chat_history: list[dict]
insight_json: InsightJSON
classifier_output: ClassifierOutput
tool_results: list[dict] # Kết quả từ ProductSearchEngine
stylist_output: StylistOutput
final_response: str
```
## Tracing và Observability
Toàn bộ quá trình được trace qua **Langfuse**. Metrics theo dõi:
| Metric | Mô tả |
|---|---|
| `classifier_ms` | Thời gian Classifier xử lý |
| `stylist_ms` | Thời gian Stylist xử lý |
| `tool_ms` | Thời gian ProductSearchEngine chạy |
| `total_ms` | Tổng thời gian end-to-end |
| `stage` | Mode hiện tại của khách |
| `tool_called` | Classifier có gọi Tool không |
File tham chiếu: `backend/agent/lead_stage_agent/graph.py`
<style>
.cascade-flow{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:20px 0}
.cascade-title{text-align:center;margin-bottom:28px}
.cascade-title h2{font-size:1.5em;color:#e2e8f0;margin:0}
.cascade-title p{color:#94a3b8;font-size:.88em;margin-top:6px}
.split-demo{display:flex;gap:16px;margin-bottom:28px;flex-wrap:wrap}
.split-lane{flex:1;min-width:260px;background:#1e293b;border-radius:12px;padding:18px;border:2px solid #334155;cursor:pointer;transition:all .3s}
.split-lane:hover{border-color:#0ea5e9;transform:translateY(-3px)}
.split-lane.active{border-color:#0ea5e9;box-shadow:0 0 25px rgba(14,165,233,.2)}
.split-lane h4{margin:0 0 8px;font-size:.95em;display:flex;align-items:center;gap:6px}
.split-lane p{color:#94a3b8;font-size:.82em;line-height:1.6;margin:0}
.split-lane code{background:#0f172a;padding:2px 6px;border-radius:4px;font-size:.8em;color:#7dd3fc}
.cascade-container{position:relative;max-width:600px;margin:0 auto}
.tier{display:flex;align-items:center;gap:12px;padding:12px 16px;border-radius:10px;margin-bottom:8px;cursor:pointer;transition:all .3s;border:1px solid transparent}
.tier:hover{transform:translateX(6px)}
.tier.active{border-color:rgba(255,255,255,.2);box-shadow:0 0 20px rgba(255,255,255,.05)}
.tier-num{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;font-size:.85em;color:#fff;flex-shrink:0}
.tier-body{flex:1}
.tier-label{font-weight:700;font-size:.88em;color:#e2e8f0}
.tier-desc{font-size:.75em;color:#94a3b8;margin-top:2px}
.tier-tag{font-size:.65em;padding:2px 8px;border-radius:10px;font-weight:600;margin-left:8px}
.t1{background:linear-gradient(90deg,rgba(34,197,94,.1),transparent)}.t1 .tier-num{background:#16a34a}
.t2{background:linear-gradient(90deg,rgba(34,197,94,.08),transparent)}.t2 .tier-num{background:#15803d}
.t3{background:linear-gradient(90deg,rgba(14,165,233,.08),transparent)}.t3 .tier-num{background:#0284c7}
.t4{background:linear-gradient(90deg,rgba(139,92,246,.08),transparent)}.t4 .tier-num{background:#7c3aed}
.t5{background:linear-gradient(90deg,rgba(245,158,11,.08),transparent)}.t5 .tier-num{background:#d97706}
.t6{background:linear-gradient(90deg,rgba(245,158,11,.1),transparent)}.t6 .tier-num{background:#b45309}
.t7{background:linear-gradient(90deg,rgba(239,68,68,.08),transparent)}.t7 .tier-num{background:#dc2626}
.tier-detail{margin-top:16px;background:#0f172a;border:1px solid #1e293b;border-radius:10px;padding:18px;animation:slideUp .4s ease;max-width:600px;margin-left:auto;margin-right:auto}
.tier-detail h4{margin:0 0 8px;font-size:1em}
.tier-detail p{color:#94a3b8;font-size:.85em;line-height:1.6;margin:6px 0}
.tier-detail .sql-hint{background:#1e293b;border-radius:6px;padding:8px 12px;font-family:monospace;font-size:.78em;color:#a78bfa;margin-top:8px}
@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.cascade-label{text-align:center;font-size:.82em;color:#64748b;margin-bottom:14px;font-weight:600;text-transform:uppercase;letter-spacing:.05em}
</style>
<div class="cascade-flow">
<div class="cascade-title">
<h2>✂️ Split Query & 🌊 Cascading Search</h2>
<p>Bấm vào từng Lane hoặc từng Tầng để xem logic chi tiết</p>
</div>
<div class="split-demo">
<div class="split-lane active" onclick="showLane(0)" id="lane0">
<h4>📝 Lane 1: Literal Search</h4>
<p>Tìm kiếm nguyên văn câu hỏi của khách.<br>VD: <code>raw_text: "áo khoác nam mùa đông form rộng đi Sapa"</code><br><br>Dùng Full-text search để khớp từ khóa đặc thù.</p>
</div>
<div class="split-lane" onclick="showLane(1)" id="lane1">
<h4>🧩 Lane 2: Inferred Search</h4>
<p>Suy luận câu hỏi thành cấu trúc chuẩn.<br>VD: <code>product_line: "Áo khoác"</code> | <code>gender: "men"</code><br><code>tags: ["wthr:mua_dong", "fit:oversize"]</code><br><code>price_max: 500000</code></p>
</div>
</div>
<div class="cascade-label">⬇ 7 TẦNG RỚT MẠNG (CASCADING SEARCH) ⬇</div>
<div class="cascade-container">
<div class="tier t1" onclick="showTier(0)"><div class="tier-num">1</div><div class="tier-body"><div class="tier-label">Exact Match <span class="tier-tag" style="background:#14532d;color:#86efac">Chính xác</span></div><div class="tier-desc">Khớp 100% tags + giá + giới tính</div></div></div>
<div class="tier t2" onclick="showTier(1)"><div class="tier-num">2</div><div class="tier-body"><div class="tier-label">NGRAM + Bitmap <span class="tier-tag" style="background:#14532d;color:#86efac">Chính xác</span></div><div class="tier-desc">Full-text search + Bitmap index tags</div></div></div>
<div class="tier t3" onclick="showTier(2)"><div class="tier-num">3</div><div class="tier-body"><div class="tier-label">Drop Tags <span class="tier-tag" style="background:#0c4a6e;color:#7dd3fc">Nới lỏng</span></div><div class="tier-desc">Bỏ tags chi tiết, giữ loại SP + giá</div></div></div>
<div class="tier t4" onclick="showTier(3)"><div class="tier-num">4</div><div class="tier-body"><div class="tier-label">Drop Gender <span class="tier-tag" style="background:#5b21b6;color:#c4b5fd">Nới rộng</span></div><div class="tier-desc">Nới lỏng giới tính → bốc Unisex</div></div></div>
<div class="tier t5" onclick="showTier(4)"><div class="tier-num">5</div><div class="tier-body"><div class="tier-label">Upsell 1.5x <span class="tier-tag" style="background:#92400e;color:#fcd34d">Upsell</span></div><div class="tier-desc">Nhân price_max lên 1.5 lần</div></div></div>
<div class="tier t6" onclick="showTier(5)"><div class="tier-num">6</div><div class="tier-body"><div class="tier-label">Upsell 2x <span class="tier-tag" style="background:#78350f;color:#fcd34d">Upsell</span></div><div class="tier-desc">Nhân price_max lên 2 lần</div></div></div>
<div class="tier t7" onclick="showTier(6)"><div class="tier-num">7</div><div class="tier-body"><div class="tier-label">Drop Price <span class="tier-tag" style="background:#7f1d1d;color:#fca5a5">Hail Mary</span></div><div class="tier-desc">Vứt luôn bộ lọc giá</div></div></div>
</div>
<div id="tierDetail"></div>
</div>
<script>
var tierData = [
{title:"Tầng 1: Exact Match",desc:"Tìm chính xác 100% theo ý khách: đúng giá, đúng giới tính, đúng form dáng, đúng mùa, đúng dịp. Nếu tìm thấy ≥3 sản phẩm → dừng ngay, không cần xuống tầng dưới.",sql:"WHERE product_line IN (...) AND gender = '...' AND price <= ... AND BITMAP_CONTAINS(tags, ...)"},
{title:"Tầng 2: NGRAM + Bitmap",desc:"Kết hợp Full-text search (NGRAM Index) với Bitmap Index trên tags. Dùng cho các từ khóa đặc thù mà Tầng 1 có thể bỏ lỡ (VD: 'bụng bia', 'Sapa').",sql:"WHERE MATCH(description, '...') AND BITMAP_CONTAINS(tags, ...) AND price <= ..."},
{title:"Tầng 3: Drop Tags",desc:"Bỏ các tags chi tiết (form, mùa, dịp). Chỉ giữ lại loại sản phẩm (áo khoác) và giá tiền. Mục đích: mở rộng tập kết quả khi tags quá hẹp.",sql:"WHERE product_line IN (...) AND price <= ... -- Không lọc tags"},
{title:"Tầng 4: Drop Gender",desc:"Nới lỏng giới tính. Cho phép bốc đồ Unisex hoặc đồ khác giới (nữ mặc đồ nam oversize vẫn đẹp). Đây là chiến thuật Cross-sell thông minh.",sql:"WHERE product_line IN (...) AND price <= ... -- Không lọc gender"},
{title:"Tầng 5: Upsell 1.5x 💰",desc:"Nghiệp vụ Upsell bắt đầu! Khách đòi áo 500k nhưng hết → Code tự kích price_max lên 750k. Stylist AI sẽ thuyết phục: 'Dạ mẫu dưới 500k đang hết, nhưng có mẫu nhỉnh hơn ngân sách chút xíu...'",sql:"WHERE product_line IN (...) AND price <= (price_max * 1.5)"},
{title:"Tầng 6: Upsell 2x 💰💰",desc:"Đẩy thêm biên lợi nhuận! Nhân price_max lên 2 lần. Chỉ kích hoạt khi Tầng 5 vẫn không đủ kết quả. AI sẽ dùng chiến thuật so sánh giá trị (đắt hơn nhưng xài 3 mùa).",sql:"WHERE product_line IN (...) AND price <= (price_max * 2.0)"},
{title:"Tầng 7: Drop Price (Hail Mary) 🚨",desc:"Bước cuối cùng! Vứt luôn bộ lọc giá, lôi những đồ xịn nhất ra chào hàng. Triết lý: Thà khách chê đắt rồi bỏ đi còn hơn là AI báo 'Dạ em hết hàng' (Đó là điều cấm kỵ nhất trong Retail).",sql:"WHERE product_line IN (...) -- Không lọc giá, không lọc gì cả"}
];
function showLane(idx) {
document.getElementById('lane0').classList.toggle('active', idx===0);
document.getElementById('lane1').classList.toggle('active', idx===1);
}
function showTier(idx) {
document.querySelectorAll('.tier').forEach(function(t,i){ t.classList.toggle('active', i===idx); });
var d = tierData[idx];
document.getElementById('tierDetail').innerHTML =
'<div class="tier-detail">' +
'<h4>' + d.title + '</h4>' +
'<p>' + d.desc + '</p>' +
'<div class="sql-hint">🗃️ SQL: ' + d.sql + '</div>' +
'</div>';
}
</script>
# Split Query & Cascading Search
Phần lõi kỹ thuật của Lead Agent nằm trong `ProductSearchEngine`. Khi nhận lệnh từ Classifier, Engine áp dụng cơ chế **tách truy vấn làm 2 lane (Split Query)** và lọc qua **7 tầng rớt mạng (Cascading Search)**.
## 1. Logic tách truy vấn (Split Query)
Thay vì ném nguyên câu hỏi dài của khách vào Database (dẫn đến tỷ lệ Zero Results rất cao), Classifier chia câu đó thành 2 luồng song song.
**Ví dụ:** Khách hỏi "Anh muốn mua áo khoác nam mùa đông, form rộng, tầm 5 triệu để đi Sapa"
### Lane 1: Literal Search (Nguyên văn)
```json
{
"raw_text": "áo khoác nam mùa đông form rộng 5 triệu đi Sapa"
}
```
Dùng Full-text search để khớp từ khóa đặc thù mà Inferred có thể bỏ lỡ (VD: "Sapa", "bụng bia").
### Lane 2: Inferred Search (Suy luận có cấu trúc)
```json
{
"product_line_vn": ["Áo khoác"],
"gender_by_product": "men",
"tags": ["wthr:mua_dong", "fit:oversize", "occ:du_lich"],
"price_max": 5000000
}
```
Đưa từ lóng, mơ hồ về các hằng số chuẩn mực. VD: "mùa đông" thành `wthr:mua_dong`, "form rộng" thành `fit:oversize`.
### Sơ đồ Split Query
```mermaid
graph TD
Q["Câu hỏi khách hàng"] --> L["Lane 1: Literal Search"]
Q --> I["Lane 2: Inferred Search"]
L --> E["ProductSearchEngine"]
I --> E
E --> R["Kết quả gộp + Dedup"]
style Q fill:#1e293b,stroke:#94a3b8,color:#e2e8f0
style L fill:#0c4a6e,stroke:#0ea5e9,color:#e0f2fe
style I fill:#5b21b6,stroke:#8b5cf6,color:#ede9fe
style E fill:#14532d,stroke:#22c55e,color:#dcfce7
style R fill:#1e293b,stroke:#94a3b8,color:#e2e8f0
```
## 2. Thuật toán rớt mạng 7 tầng (Cascading Search)
Trong ngành Retail, cấm kỵ nhất là nói câu "Dạ em hết hàng". Để ép AI luôn có đồ trên tay, `ProductSearchEngine` đánh SQL qua 7 tầng nới lỏng dần dần. Nếu tầng trên tìm đủ kết quả (>=3 sản phẩm), dừng ngay và không xuống tầng dưới.
| Tầng | Tên | Hành động | Mục đích |
|---|---|---|---|
| 1 | Exact Match | Khớp 100% tags + giá + giới tính | Tìm đúng ý khách |
| 2 | NGRAM + Bitmap | Full-text search + Bitmap Index | Bắt từ khóa đặc thù |
| 3 | Drop Tags | Bỏ tags chi tiết, giữ loại SP + giá | Mở rộng tập kết quả |
| 4 | Drop Gender | Nới lỏng giới tính, bốc Unisex | Cross-sell thông minh |
| 5 | Upsell 1.5x | Nhân `price_max` lên 1.5 lần | Dụ khách rướn thêm tiền |
| 6 | Upsell 2x | Nhân `price_max` lên 2 lần | Đẩy biên lợi nhuận |
| 7 | Drop Price | Vứt luôn bộ lọc giá | Hail Mary — thà khách chê đắt còn hơn báo hết hàng |
### Sơ đồ Cascading
```mermaid
graph TD
T1["Tầng 1: Exact Match"] -->|Thiếu kết quả| T2["Tầng 2: NGRAM + Bitmap"]
T2 -->|Thiếu| T3["Tầng 3: Drop Tags"]
T3 -->|Thiếu| T4["Tầng 4: Drop Gender"]
T4 -->|Thiếu| T5["Tầng 5: Upsell 1.5x"]
T5 -->|Thiếu| T6["Tầng 6: Upsell 2x"]
T6 -->|Thiếu| T7["Tầng 7: Drop Price (Hail Mary)"]
style T1 fill:#14532d,stroke:#22c55e,color:#dcfce7
style T2 fill:#14532d,stroke:#22c55e,color:#dcfce7
style T3 fill:#0c4a6e,stroke:#0ea5e9,color:#e0f2fe
style T4 fill:#0c4a6e,stroke:#0ea5e9,color:#e0f2fe
style T5 fill:#92400e,stroke:#f59e0b,color:#fef3c7
style T6 fill:#92400e,stroke:#f59e0b,color:#fef3c7
style T7 fill:#7f1d1d,stroke:#ef4444,color:#fecaca
```
### Chi tiết SQL từng tầng
**Tầng 1 — Exact Match:**
```sql
SELECT * FROM products
WHERE product_line IN (...)
AND gender = '...'
AND price <= ...
AND BITMAP_CONTAINS(tags, ...)
LIMIT 10
```
**Tầng 2 — NGRAM + Bitmap:**
```sql
SELECT * FROM products
WHERE MATCH(description, '...')
AND BITMAP_CONTAINS(tags, ...)
AND price <= ...
LIMIT 10
```
**Tầng 5 — Upsell 1.5x:**
```sql
SELECT * FROM products
WHERE product_line IN (...)
AND price <= (price_max * 1.5)
LIMIT 10
```
**Tầng 7 — Drop Price (Hail Mary):**
```sql
SELECT * FROM products
WHERE product_line IN (...)
-- Không lọc giá, không lọc gì cả
LIMIT 10
```
## 3. Làm giàu dữ liệu (Enrichment)
Sau khi tìm được sản phẩm, Tool bồi thêm 2 bước trước khi gửi cho Stylist:
| Bước | Nguồn dữ liệu | Mục đích |
|---|---|---|
| Check Stock | Canifa Stock API | Vứt sản phẩm hết hàng/hết size. Đảm bảo tư vấn là mua được |
| Outfit Context | SQLite `ai_matches` | Bơm gợi ý phối đồ cho Stylist. VD: "Áo này phối với quần khaki thanh lịch" |
File tham chiếu: `backend/agent/lead_stage_agent/product_search_engine.py`
<style>
.mod-guide{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:20px 0}
.mod-guide-title{text-align:center;margin-bottom:28px}
.mod-guide-title h2{font-size:1.5em;color:#e2e8f0;margin:0}
.mod-guide-title p{color:#94a3b8;font-size:.88em;margin-top:6px}
.mod-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;margin-bottom:24px}
.mod-card{background:#1e293b;border:2px solid #334155;border-radius:12px;padding:18px;cursor:pointer;transition:all .4s;position:relative;overflow:hidden}
.mod-card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;border-radius:12px 12px 0 0;transition:height .3s}
.mod-card:hover{transform:translateY(-4px);border-color:#475569}
.mod-card.active{border-color:rgba(255,255,255,.3)}
.mod-card.active::before{height:4px}
.mod-card .card-icon{font-size:28px;margin-bottom:10px}
.mod-card .card-title{font-weight:700;font-size:.95em;color:#e2e8f0;margin-bottom:4px}
.mod-card .card-sub{font-size:.78em;color:#64748b;line-height:1.4}
.c1::before{background:#0ea5e9}.c1.active{box-shadow:0 0 30px rgba(14,165,233,.15)}
.c2::before{background:#a855f7}.c2.active{box-shadow:0 0 30px rgba(168,85,247,.15)}
.c3::before{background:#f59e0b}.c3.active{box-shadow:0 0 30px rgba(245,158,11,.15)}
.c4::before{background:#22c55e}.c4.active{box-shadow:0 0 30px rgba(34,197,94,.15)}
.mod-detail{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:24px;animation:slideUp .4s ease}
.mod-detail h3{margin:0 0 12px;font-size:1.1em}
.mod-detail p{color:#94a3b8;font-size:.88em;line-height:1.7;margin:6px 0}
.mod-detail .file-path{background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:10px 14px;font-family:"Fira Code",monospace;font-size:.8em;color:#7dd3fc;margin:10px 0}
.mod-detail .warning{background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:8px;padding:10px 14px;font-size:.82em;color:#fcd34d;margin-top:12px}
@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
</style>
<div class="mod-guide">
<div class="mod-guide-title">
<h2>🛠️ Modification Guide</h2>
<p>Bấm vào từng nghiệp vụ để xem file cần sửa và cách sửa</p>
</div>
<div class="mod-grid">
<div class="mod-card c1 active" onclick="showMod(0)">
<div class="card-icon">🏷️</div>
<div class="card-title">Sửa Tags & Insights</div>
<div class="card-sub">Thêm tag mùa lễ, lưu thêm thông tin khách</div>
</div>
<div class="mod-card c2" onclick="showMod(1)">
<div class="card-icon">🗣️</div>
<div class="card-title">Sửa Giọng điệu AI</div>
<div class="card-sub">Đổi xưng hô, thêm guardrails</div>
</div>
<div class="mod-card c3" onclick="showMod(2)">
<div class="card-icon">💰</div>
<div class="card-title">Đổi Logic Upsell</div>
<div class="card-sub">Chỉnh hệ số giá rớt tầng</div>
</div>
<div class="mod-card c4" onclick="showMod(3)">
<div class="card-icon">⚙️</div>
<div class="card-title">Thêm Tool mới</div>
<div class="card-sub">Gắn thêm tool cho Classifier</div>
</div>
</div>
<div id="modDetail"></div>
</div>
<script>
var modData = [
{
title: "🏷️ Sửa Tags & Insights (Phân loại khách)",
color: "#0ea5e9",
scenario: "Marketing muốn thêm tag <strong>occ:di_dam_cuoi</strong> (Đi tiệc cưới), hoặc muốn lưu thêm thông tin <strong>PET_OWNER</strong> vào bộ nhớ khách hàng.",
files: [
{ path: "backend/agent/lead_stage_agent/graph.py", action: "Tìm class <strong>InferredSearchArgs</strong> → thêm Enum trong field <code>tags</code>.<br>Tìm class <strong>InsightJSON</strong> → thêm field mới (VD: <code>PET_OWNER: str</code>)." }
],
warning: "Sau khi thêm tag mới, nhớ kiểm tra bảng StarRocks có cột BITMAP tương ứng chưa."
},
{
title: "🗣️ Sửa Giọng điệu, Văn phong AI",
color: "#a855f7",
scenario: "Sếp bắt AI phải xưng <strong>'Stylist Canifa / Quý khách'</strong> thay vì 'Mình / Bạn'. Hoặc cấm AI tự ý đề cập đến giảm giá.",
files: [
{ path: "backend/agent/lead_stage_agent/prompts.py", action: "Tìm hằng số <strong>STYLIST_SYSTEM_PROMPT</strong>.<br>Thêm gạch đầu dòng bằng CHỮ HOA: <code>- TUYỆT ĐỐI KHÔNG BÁO GIẢM GIÁ NẾU KHÔNG CÓ TRONG DATA</code>" }
],
warning: "Prompt LLM rất nhạy cảm. Thêm 1 dòng sai có thể ảnh hưởng toàn bộ hành vi. Nên test lại với User Simulator sau khi sửa."
},
{
title: "💰 Đổi Thuật toán Rớt tầng (Upsell Logic)",
color: "#f59e0b",
scenario: "Đội Sales bảo: <strong>'Nới giá 1.5x cao quá khách chạy mất, chỉnh xuống 1.2x thôi'</strong>.",
files: [
{ path: "backend/agent/lead_stage_agent/product_search_engine.py", action: "Tìm hàm <strong>_cascading_search()</strong>.<br>Chỉnh sửa Tầng 5: <code>price_max * 1.5</code> → <code>price_max * 1.2</code><br>Chỉnh sửa Tầng 6: <code>price_max * 2.0</code> → <code>price_max * 1.5</code>" }
],
warning: "Có thể thêm/bớt các Tầng tùy ý. VD: Thêm tầng chỉ lọc đồ đang cần xả kho (sale_priority = 1)."
},
{
title: "⚙️ Thêm Tool mới cho Classifier",
color: "#22c55e",
scenario: "Tích hợp tool <strong>check_store_location</strong> để AI báo khách cửa hàng nào gần nhà còn đồ.",
files: [
{ path: "backend/agent/tools/", action: "Tạo file mới: <code>store_locator.py</code> — viết logic tool ở đây." },
{ path: "backend/agent/lead_stage_agent/graph.py", action: "Mở class <strong>ClassifierOutput</strong> → thêm tên tool vào description.<br>Thêm tool vào dictionary <code>self._tool_registry</code> để LangGraph tự kích hoạt." }
],
warning: "Bất kỳ sửa đổi nào ở Classifier (đầu vào) sẽ ảnh hưởng TOÀN BỘ luồng phía sau. Sau khi sửa, luôn dùng User Simulator bắn 50 test case."
}
];
function showMod(idx) {
document.querySelectorAll('.mod-card').forEach(function(c,i){ c.classList.toggle('active', i===idx); });
var d = modData[idx];
var filesHtml = d.files.map(function(f){
return '<div class="file-path">📁 <strong>' + f.path + '</strong><br><br>' + f.action + '</div>';
}).join('');
document.getElementById('modDetail').innerHTML =
'<div class="mod-detail">' +
'<h3 style="color:' + d.color + '">' + d.title + '</h3>' +
'<p><strong>📋 Yêu cầu Business:</strong> ' + d.scenario + '</p>' +
filesHtml +
'<div class="warning">⚠️ ' + d.warning + '</div>' +
'</div>';
}
showMod(0);
</script>
# Modification Guide — Cẩm nang thay đổi luồng
Khi có yêu cầu từ phía Business (thêm tag mùa lễ, đổi cách AI xưng hô, thêm tool mới), Developer cần biết chính xác điểm phẫu thuật để không làm hỏng toàn bộ quy trình.
## Tổng hợp File Map
```mermaid
graph LR
G["graph.py (Core)"] --> P["prompts.py (Tone)"]
G --> E["product_search_engine.py (Search)"]
G --> T["tools/ (Mở rộng)"]
P -.-> G
E -.-> G
T -.-> G
style G fill:#0c4a6e,stroke:#0ea5e9,color:#e0f2fe
style P fill:#5b21b6,stroke:#8b5cf6,color:#ede9fe
style E fill:#14532d,stroke:#22c55e,color:#dcfce7
style T fill:#92400e,stroke:#f59e0b,color:#fef3c7
```
| File | Chức năng | Khi nào sửa |
|---|---|---|
| `graph.py` | Core: State, InsightJSON, ClassifierOutput, Tool Registry | Thêm tag, thêm insight, thêm tool |
| `prompts.py` | Prompt: Classifier + Stylist system prompts | Đổi giọng, thêm guardrail |
| `product_search_engine.py` | Engine: Cascading Search, Enrichment | Đổi logic Upsell, thêm/bớt tầng |
| `controller.py` | Controller: Redis, Langfuse, API route | Đổi TTL cache, thêm metric |
## 1. Sửa Tags và Insights (Phân loại khách)
**Yêu cầu mẫu:** Marketing muốn thêm tag `occ:di_dam_cuoi` (Đi tiệc cưới), hoặc lưu thêm thông tin `PET_OWNER` vào bộ nhớ khách.
| Hành động | File | Vị trí trong code |
|---|---|---|
| Thêm tag mới | `graph.py` | Class `InferredSearchArgs` — thêm Enum trong field `tags` |
| Thêm trường Insight | `graph.py` | Class `InsightJSON` — thêm field mới |
```python
# Ví dụ thêm field mới vào InsightJSON
class InsightJSON(BaseModel):
# ... các field cũ ...
PET_OWNER: str = Field(default="Chưa rõ") # Field mới
```
**Lưu ý:** Sau khi thêm tag mới, kiểm tra bảng StarRocks có cột BITMAP tương ứng chưa.
## 2. Sửa giọng điệu, văn phong AI
**Yêu cầu mẫu:** Sếp bắt AI phải xưng "Stylist Canifa / Quý khách" thay vì "Mình / Bạn". Hoặc cấm AI tự ý đề cập giảm giá.
| Hành động | File | Vị trí trong code |
|---|---|---|
| Đổi xưng hô | `prompts.py` | Hằng số `STYLIST_SYSTEM_PROMPT` |
| Thêm guardrail | `prompts.py` | Thêm gạch đầu dòng CHỮ HOA vào prompt |
```python
# Ví dụ thêm guardrail
STYLIST_SYSTEM_PROMPT = """
...
- TUYỆT ĐỐI KHÔNG BÁO GIẢM GIÁ NẾU KHÔNG CÓ TRONG DATA
- LUÔN XƯNG "Stylist Canifa" VÀ GỌI KHÁCH "Quý khách"
...
"""
```
**Lưu ý:** Prompt LLM rất nhạy cảm. Thêm 1 dòng sai có thể ảnh hưởng toàn bộ hành vi. Nên test lại với User Simulator sau khi sửa.
## 3. Đổi thuật toán rớt tầng (Upsell Logic)
**Yêu cầu mẫu:** Đội Sales bảo "Nới giá 1.5x cao quá khách chạy mất, chỉnh xuống 1.2x thôi".
| Hành động | File | Vị trí trong code |
|---|---|---|
| Chỉnh hệ số giá | `product_search_engine.py` | Hàm `_cascading_search()` |
| Thêm/bớt tầng | `product_search_engine.py` | Cấu trúc list các tầng trong hàm trên |
```python
# Ví dụ chỉnh hệ số Upsell
# Trước: price_max * 1.5
# Sau: price_max * 1.2
tier_5_price = original_price_max * 1.2
tier_6_price = original_price_max * 1.5
```
Có thể thêm tầng mới tuỳ ý. VD: Thêm tầng chỉ lọc đồ đang cần xả kho (`sale_priority = 1`).
## 4. Thêm Tool mới cho Classifier
**Yêu cầu mẫu:** Tích hợp tool `check_store_location` để AI báo khách cửa hàng nào gần nhà còn đồ.
| Bước | File | Hành động |
|---|---|---|
| 1. Viết logic tool | `backend/agent/tools/store_locator.py` | Tạo file mới, implement tool function |
| 2. Đăng ký tool | `graph.py` | Thêm tên tool vào ClassifierOutput description |
| 3. Kết nối tool | `graph.py` | Thêm tool vào dictionary `self._tool_registry` |
```python
# Bước 2: Thêm tên tool vào ClassifierOutput
class ClassifierOutput(BaseModel):
tool_name: str | None = Field(
description="Ten tool (lead_search_tool, check_store_location). "
"Null neu khach chi dang tam su."
)
```
**Lưu ý:** Bất kỳ sửa đổi nào ở Classifier (đầu vào) sẽ ảnh hưởng toàn bộ luồng phía sau. Sau khi sửa, luôn dùng User Simulator bắn 50 test case.
# Comprehensive Lead Search Agent Documentation
## 📌 Idea
Quy hoạch và tạo ra một tài liệu duy nhất (Single Source of Truth) cho chế độ Lead Search Agent của Canifa AI. Tài liệu sẽ giải thích chi tiết Business Logic (Journey Map), Flowchart 2 Agent (Classifier + Stylist), và Split Query Logic của Tool lấy dữ liệu (Cascading Search 7 tầng).
## 🗂 Codebase Scope
- **Root:** `./backend/agent/lead_stage_agent/` & `./backend/static/lead_flow/`
- **Total files:** ~5-10 files
- **Affected:** `backend/static/lead_flow/lead_docs.html`, `backend/static/lead_flow/lead_search_diagram.svg`
- **Languages:** HTML, SVG, Markdown
- **Size:** S
## 📊 Current State Analysis
Hiện tại tài liệu cho chế độ Lead Search nằm phân tán trong `static/lead_flow/` (html, svg, docx, v.v.). Sơ đồ hiện tại đã cũ và không bám sát kiến trúc 2 Agent (Classifier + Stylist) mới, cũng như chưa có giải thích tường tận về cơ chế tách truy vấn làm 2 (Literal vs Inferred) và luồng rớt mạng 7 tầng (Cascading Search).
## 🎯 Goal / Definition of Done
- Có file `lead_docs.html` với giao diện chia Tabs rõ ràng, giải thích cặn kẽ 4 phần:
1. Lead Journey Maps (Business Context).
2. Agent Flowchart.
3. The Data Extraction Tool & Split Query Logic.
4. Modification Guide.
- Có sơ đồ `lead_search_diagram.svg` được vẽ lại bằng thiết kế Premium, thể hiện rõ cấu trúc Dual-Agent và sự tách truy vấn của ProductSearchEngine.
## 📋 Execution Plan
### Phase 1: Idea Capture
- [x] Create this idea document.
### Phase 2: Visual Diagram
- [ ] Redraw `lead_search_diagram.svg` using standard SVG highlighting the dual-agent architecture, the dual-lane input (literal/inferred), and the 7-tier cascading search.
### Phase 3: Comprehensive HTML Documentation
- [ ] Rewrite `lead_docs.html` using a tabbed interface and clear sections (Journey, Flow, Logic, Maintenance).
## ⚠️ Risks & Side Effects
- **Risk 1:** Sơ đồ SVG có thể không hiển thị đúng trên một số trình duyệt. -> **Mitigation:** Dùng SVG standard, các viewBox chuẩn và inline CSS an toàn.
## 🔧 Tools / Scripts Needed
- HTML/CSS/SVG design skills (UI/UX Pro Max constraint).
## ⏱ Effort Estimate
**S** — < 2 hours, < 20 files
---
_Captured: 2026-05-04 | Type: Codebase-Aware_
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