Commit cc9055c7 authored by Hoanganhvu123's avatar Hoanganhvu123

feat(cookbook): Lead Agent 4 pages - Fireworks SVG + 6-section multi-doc

- Journey Shopping (5 Modes): SVG funnel + 6 sections (Overview, Modes, Stage Transition, Upsell, Personalization, Troubleshooting)
- Dual-Agent Flow: SVG architecture + 6 sections (Overview, Classifier, Stylist, State, Tracing, Troubleshooting)
- Split Query & Cascading: SVG 7-tier + 6 sections (Overview, Literal, Inferred, Cascading, Enrichment, Troubleshooting)
- Modification Guide: SVG file map + 6 sections (FileMap, Tags, Tone, Upsell, NewTool, Checklist)
- Fixed mermaid.run() API call in cookbook.js (nodes param instead of querySelectorAll string)
- Bumped cookbook.js version to v8
parent 58cb378d
......@@ -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/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="cookbook.js?v=5"></script>
<script src="cookbook.js?v=8"></script>
</body>
</html>
......@@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// 1. Load Registry
try {
const response = await fetch('data/registry.json');
const response = await fetch('data/registry.json?v=' + Date.now());
recipesRegistry = await response.json();
renderSidebar(recipesRegistry);
updateProgress();
......@@ -201,17 +201,33 @@ document.addEventListener('DOMContentLoaded', async () => {
} catch(e) {
html += `<p style="color:red">Lỗi parse API JSON</p>`;
}
} else if (sec.type === 'html') {
html += `<div class="interactive-section">${sec.rawContent}</div>`;
}
});
recipeContent.innerHTML = html;
// Execute inline scripts from HTML sections
recipeContent.querySelectorAll('.interactive-section script').forEach(oldScript => {
const newScript = document.createElement('script');
newScript.textContent = oldScript.textContent;
oldScript.parentNode.replaceChild(newScript, oldScript);
});
if (window.Prism) Prism.highlightAll();
// Render mermaid diagrams
if (window.mermaid) {
try {
mermaid.run({ querySelectorAll: '.mermaid' });
const mermaidEls = recipeContent.querySelectorAll('.mermaid');
if (mermaidEls.length > 0) {
// Reset processed state so mermaid re-renders
mermaidEls.forEach(el => {
el.removeAttribute('data-processed');
});
mermaid.run({ nodes: mermaidEls });
}
} catch(e) { console.error('Mermaid render error:', e); }
}
}
......
# 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.
# 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ó.
# 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 960 650" width="960" height="650">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #1a1a1a; }
.node-title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-sub { font-size: 14px; font-weight: 400; fill: #1a1a1a; }
.arrow-label { font-size: 13px; font-weight: 600; fill: #5a5a5a; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; }
</style>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 600">
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="6" flood-color="#00000015"/>
<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>
<!-- Background -->
<rect width="960" height="650" fill="#f8f6f3"/>
<!-- Title -->
<text x="480" y="50" text-anchor="middle" class="title">Lead Stage AI - Two-Stage Architecture</text>
<!-- Layer Labels -->
<text x="30" y="320" class="layer-label">Client &amp; API</text>
<text x="310" y="510" class="layer-label">LangGraph Engine (2-Stage)</text>
<text x="790" y="510" class="layer-label">Tools &amp; Storage</text>
<!-- Layer Containers (Dashed) -->
<rect x="290" y="100" width="360" height="420" rx="8" fill="none" stroke="#6a6a6a" stroke-width="1.5" stroke-dasharray="8,4"/>
<rect x="680" y="100" width="250" height="420" rx="8" fill="none" stroke="#6a6a6a" stroke-width="1.5" stroke-dasharray="8,4"/>
<!-- Nodes -->
<!-- Client -->
<rect x="60" y="270" width="180" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="150" y="305" text-anchor="middle" class="node-title">User Chat</text>
<text x="150" y="325" text-anchor="middle" class="node-sub">• Query + device_id</text>
<!-- Controller -->
<rect x="330" y="140" width="280" height="60" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="470" y="165" text-anchor="middle" class="node-title">Lead Stage Controller</text>
<text x="470" y="185" text-anchor="middle" class="node-sub">• Orchestrator &amp; Tracing</text>
<!-- AI #1 Classifier -->
<rect x="330" y="250" width="280" height="90" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="470" y="280" text-anchor="middle" class="node-title">AI #1: Classifier &amp; Router</text>
<text x="470" y="300" text-anchor="middle" class="node-sub">• Intent recognition &amp; Stage</text>
<text x="470" y="320" text-anchor="middle" class="node-sub">• Predict Tool Args</text>
<!-- AI #2 Stylist -->
<rect x="330" y="390" width="280" height="90" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="470" y="420" text-anchor="middle" class="node-title">AI #2: Stylist</text>
<text x="470" y="440" text-anchor="middle" class="node-sub">• Generate soft response</text>
<text x="470" y="460" text-anchor="middle" class="node-sub">• Apply tone_directive</text>
<!-- Postgres DB -->
<rect x="710" y="140" width="190" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="805" y="175" text-anchor="middle" class="node-title">PostgreSQL</text>
<text x="805" y="195" text-anchor="middle" class="node-sub">• Persistent History</text>
<!-- Tools -->
<rect x="710" y="255" width="190" height="80" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="805" y="290" text-anchor="middle" class="node-title">Search / Tools</text>
<text x="805" y="310" text-anchor="middle" class="node-sub">• Lead Search, Stock</text>
<!-- Arrows -->
<!-- User to Controller -->
<path d="M 150,270 L 150,170 L 330,170" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="200" y="160" width="90" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="245" y="174" text-anchor="middle" class="arrow-label">POST /api</text>
<!-- Controller to Postgres (Load History) -->
<line x1="610" y1="170" x2="710" y2="170" stroke="#5a5a5a" stroke-width="2" stroke-dasharray="5,3" marker-end="url(#arrow-claude)"/>
<rect x="620" y="160" width="80" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="660" y="174" text-anchor="middle" class="arrow-label">Load Context</text>
<!-- Controller to AI #1 -->
<line x1="470" y1="200" x2="470" y2="250" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="420" y="215" width="100" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="470" y="229" text-anchor="middle" class="arrow-label">Route to AI #1</text>
<!-- AI #1 to Tools -->
<line x1="610" y1="295" x2="710" y2="295" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="620" y="285" width="80" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="660" y="299" text-anchor="middle" class="arrow-label">Execute Tool</text>
<!-- Tools to AI #2 -->
<path d="M 805,335 L 805,435 L 610,435" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="660" y="425" width="80" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="700" y="439" text-anchor="middle" class="arrow-label">Tool Result</text>
<!-- AI #1 to AI #2 (Direct flow without tools) -->
<line x1="470" y1="340" x2="470" y2="390" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="420" y="355" width="100" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="470" y="369" text-anchor="middle" class="arrow-label">Stage &amp; Context</text>
<!-- AI #2 to Controller -->
<path d="M 330,435 L 300,435 L 300,185 L 330,185" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="250" y="250" width="90" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="295" y="264" text-anchor="middle" class="arrow-label">Final Output</text>
<!-- Controller to User (Response) -->
<path d="M 330,150 L 150,150 L 150,270" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="200" y="140" width="80" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="240" y="154" text-anchor="middle" class="arrow-label">Response</text>
<rect width="1000" height="600" fill="#F9FAFB" rx="16"/>
<!-- Legend -->
<rect x="710" y="550" width="200" height="70" rx="8" fill="#ffffff" stroke="#4a4a4a" stroke-width="1.5"/>
<text x="725" y="575" class="node-title">Legend</text>
<line x1="725" y1="595" x2="755" y2="595" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="765" y="600" class="node-sub">Standard Data Flow</text>
<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 (LangGraph)",
"description": "Deep Dive vào hệ thống tìm kiếm khách hàng tiềm năng kết hợp logic Chatbot. Phân tích chi tiết kiến trúc Classifier ⇄ Tools → Stylist.",
"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": "arch", "title": "1. Kiến trúc (Architecture)", "type": "markdown", "file": "01_architecture.md" },
{ "id": "logic", "title": "2. Sơ đồ Luồng (Logic Flow)", "type": "markdown", "file": "02_logic_flow.md" },
{ "id": "db", "title": "3. Dữ liệu (Database)", "type": "markdown", "file": "03_database.md" },
{ "id": "api", "title": "4. API Reference", "type": "api", "file": "04_api_reference.json" }
{ "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>
# 1. Tổng quan (Overview)
Lead Search Agent là module **bán hàng tự động** của Canifa AI Platform, được thiết kế theo mô hình **Phễu tâm lý (Sales Funnel)** với 5 giai đoạn. Mục tiêu cuối cùng: thu thập Contact (SĐT, Email) hoặc chốt đơn hàng.
## Tại sao phải chia 5 Mode?
LLM vốn dĩ là "người biết tuốt" — nếu không kìm cương, nó sẽ hành xử sai hoàn toàn tại mọi thời điểm:
- **Sai lúc đầu:** Khách mới chào "Hi" nhưng AI nhảy xổ ra đòi số điện thoại → khách hoảng sợ, tắt chat ngay.
- **Sai lúc giữa:** 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ữ?" → khách bực mình, bỏ đi.
- **Sai lúc cuối:** Khách đã nói "Lấy cái đầu tiên" nhưng AI vẫn tiếp tục giới thiệu thêm 5 sản phẩm → bỏ lỡ cơ hội chốt.
Việc chia 5 Mode chính là tạo **dây cương** cho 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.
## Triết lý thiết kế
| Nguyên tắc | Giải thích |
|---|---|
| **Never say "Hết hàng"** | Trong Retail, đây là câu cấm kỵ nhất. Thà khách chê đắt còn hơn báo hết → cơ chế Upsell 7 tầng |
| **Insight trước, Search sau** | AI hỏi đủ nhu cầu rồi mới gọi Tool, tránh quăng list SP vô nghĩa |
| **Chốt sale phải nhanh** | Khi khách ưng rồi, không còn văn hoa tư vấn — chuyển ngay giọng action-oriented |
| **Đường lùi thanh lịch** | Khách từ chối → Cảm ơn, mở cửa quay lại. Tuyệt đối không chèo kéo |
## Các thành phần chính
1. **InsightJSON**: Bộ nhớ khách hàng lưu trong Redis (TTL 24h), chứa 12 trường thông tin đã thu thập.
2. **Classifier Agent**: AI nhẹ — Router, quyết định gọi Tool hay Early Exit.
3. **Stylist Agent**: AI nặng — Best Seller, sinh câu trả lời tư vấn thuyết phục.
4. **ProductSearchEngine**: Engine tìm kiếm 7 tầng (Cascading Search).
File tham chiếu: `backend/agent/lead_stage_agent/graph.py`
# 2. Chi tiết từng Mode & Ý nghĩa
Mỗi Mode đại diện cho một **trạng thái tâm lý** của khách hàng. Classifier Agent đọc tin nhắn + InsightJSON cũ để phán đoán khách đang ở Mode nào, từ đó ép Stylist Agent bơm đúng chiến thuật.
## Mode 1: BROWSE (Awareness — Dạo quanh)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái tâm lý** | Lạnh — Khách vô tình lướt qua, ấn nhầm, hoặc chỉ chào "Hi", "Alo" |
| **InsightJSON.STAGE** | `BROWSE` |
| **STAGE_NUM** | `1` |
| **TONE** | `Friendly` — Giọng bạn bè, nhẹ nhàng |
| **Chiến thuật AI** | **CẤM** 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 |
| **Câu mẫu** | "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é?" |
**Tại sao quan trọng:** Đây là bước "rã đông" — 70% khách rời đi tại bước này nếu AI quá vội vàng. Mục tiêu là giữ chân khách bằng cách tạo cảm giác an toàn.
## Mode 2: IDENTIFYING (Interest — Gom nhu cầu)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái tâm lý** | Ấm — Khách nhả ra 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` — Giọng chuyên gia, hỏi cung khéo léo |
| **Chiến thuật AI** | **HẠN CHẾ** gọi Tool tìm kiếm. Thay vào đó, đặt câu hỏi lấp đầy InsightJSON (giá, size, dịp, mục đích) |
| **Câu mẫu** | "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é?" |
**Tại sao quan trọng:** Nếu AI gọi Tool lúc này với thông tin thiếu, kết quả sẽ quá rộng (trả về 100+ sản phẩm không liên quan) → khách overwhelmed → bỏ đi. Thu thập đủ data trước rồi mới search.
## Mode 3: CONSIDERING (Cân nhắc — Xử lý từ chối)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái tâm lý** | Nóng — Khách cung cấp đủ nhu cầu, bắt đầu săm soi: chê đắt, hỏi chất liệu, xem ảnh, so sánh |
| **InsightJSON.STAGE** | `CONSIDERING` |
| **STAGE_NUM** | `3` |
| **TONE** | `Persuasive` — Giọng thuyết phục, tư vấn chuyên sâu |
| **Chiến thuật AI** | **BUNG LỤA!** Gọi ProductSearchEngine 7 tầng. Moi `ai_matches` gợi ý combo. Kích hoạt Upsell nếu hết đồ rẻ |
| **Câu mẫu** | "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!" |
**Tại sao quan trọng:** Đây là chốt chặn khốc liệt nhất — khách đã quan tâm nhưng chưa chốt. AI phải vừa tư vấn thuyết phục, vừa xử lý từ chối (chê đắt, chê xấu, hỏi chất liệu), vừa gợi ý combo phối đồ.
## Mode 4: CLOSING (Decision — Chốt hạ)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái tâm lý** | Chín muồi — Khách đã ưng ("Lấy anh cái đầu tiên", "Chị lấy màu đen size M") |
| **InsightJSON.STAGE** | `CLOSING` |
| **STAGE_NUM** | `4` |
| **TONE** | `Action-oriented` — Giọng chốt sale, gọn gàng, dứt khoát |
| **Chiến thuật AI** | **QUAY XE** đổi chiến thuật! Bỏ văn hoa tư vấn chất liệu. Chuyển sang: xin SĐT, xin Email, lên đơn |
| **Câu mẫu** | "Dạ form này mặc siêu tôn dáng! Anh cho em xin SĐT để em lưu thông tin ưu đãi và lên đơn nhé!" |
**Tại sao quan trọng:** Sai lầm lớn nhất ở bước này là **tiếp tục tư vấn khi khách đã muốn mua**. AI phải nhận diện tín hiệu chốt và chuyển ngay sang thu thập contact.
## Mode 5: RETENTION (Hậu mãi)
| Thuộc tính | Giá trị |
|---|---|
| **Trạng thái tâm lý** | Kết thúc — Khách đã để lại SĐT (thành công) hoặc từ chối thẳng thừng (thất bại) |
| **InsightJSON.STAGE** | `RETENTION` |
| **STAGE_NUM** | `5` |
| **TONE** | `Warm` — Giọng ấm áp, cảm ơn |
| **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, **TUYỆT ĐỐI KHÔNG CHÈO KÉO** |
| **Câu mẫu (OK)** | "Dạ em đã lưu thông tin. Đội tư vấn sẽ gọi lại anh trong 24h để hỗ trợ nhé!" |
| **Câu mẫu (Fail)** | "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!" |
**Tại sao quan trọng:** Đường lùi thanh lịch tạo thiện cảm — 30% khách từ chối hôm nay sẽ quay lại sau nếu trải nghiệm cuối tốt.
## InsightJSON — Bộ nhớ khách hàng
```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 → 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 SP, hỏi size, chốt đơn)
SUMMARY_HISTORY: str # Tóm tắt lịch sử hội thoại
```
InsightJSON được lưu trong **Redis** với TTL 24h theo `device_id`. Mỗi turn chat, Classifier đọc nó để phân loại Mode, Stylist cập nhật nó với thông tin mới.
# 3. Logic chuyển đổi Stage (Lên / Xuống)
Phễu **không phải một chiều** — khách có thể nhảy lên, nhảy xuống, hoặc quay vòng giữa các Mode. Classifier Agent phải nhận diện tín hiệu từ tin nhắn để quyết định chuyển đổi.
## Bảng chuyển đổi Stage
| Từ | Sang | Tín hiệu | Ví dụ tin nhắn |
|---|---|---|---|
| Mode 1 (BROWSE) | Mode 2 (IDENTIFYING) | Khách nhả manh mối sản phẩm | "Mình tìm áo khoác" |
| Mode 2 (IDENTIFYING) | Mode 3 (CONSIDERING) | InsightJSON đã đủ info (giá, dịp, size) | "Áo khoác nam, mùa đông, dưới 500k" |
| Mode 3 (CONSIDERING) | Mode 4 (CLOSING) | Khách ra tín hiệu chốt | "Lấy anh cái đầu tiên", "OK lấy luôn" |
| Mode 4 (CLOSING) | Mode 5 (RETENTION) | Khách đã cho SĐT hoặc từ chối | "SĐT 0912..." hoặc "Thôi để sau" |
| Mode 3 (CONSIDERING) | Mode 2 (IDENTIFYING) | Khách đổi ý, hỏi loại khác | "Thôi, cho mình xem quần đi" |
| Mode 4 (CLOSING) | Mode 3 (CONSIDERING) | Khách lưỡng lự, hỏi thêm | "Khoan, chất liệu này có bền không?" |
| Mode 5 (RETENTION) | Mode 2 (IDENTIFYING) | Khách quay lại hỏi tiếp | "À mà cho mình xem thêm áo polo" |
## Cơ chế nhảy ngược (Downgrade)
Đây là điểm khác biệt quan trọng so với funnel truyền thống — **khách có thể quay lại bất kỳ lúc nào**:
```mermaid
graph TD
M1["Mode 1: BROWSE"] --> M2["Mode 2: IDENTIFYING"]
M2 --> M3["Mode 3: CONSIDERING"]
M3 --> M4["Mode 4: CLOSING"]
M4 --> M5["Mode 5: RETENTION"]
M3 -.->|Đổi ý| M2
M4 -.->|Lưỡng lự| M3
M5 -.->|Quay lại| M2
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
```
## Classifier phán đoán Stage như thế nào?
Classifier Agent nhận 3 input đầu vào:
1. **Tin nhắn mới nhất** của khách
2. **InsightJSON cũ** (đọc từ Redis) — chứa `STAGE` hiện tại
3. **Chat history** (5 tin gần nhất)
Dựa vào 3 input này, Classifier xuất `ClassifierOutput` với `STAGE` mới. Logic:
```python
# Pseudo-code: Classifier reasoning
if no_product_intent(message):
stage = "BROWSE" # Vẫn ở Mode 1
elif missing_key_fields(insight):
stage = "IDENTIFYING" # Cần hỏi thêm
elif has_purchase_signal(message):
stage = "CLOSING" # Nhảy thẳng lên Mode 4
elif has_product_criteria(insight):
stage = "CONSIDERING" # Đủ info, vào Mode 3
elif has_contact_info(message) or has_rejection(message):
stage = "RETENTION" # Kết thúc
```
## Quy tắc quan trọng
- **Không bao giờ nhảy từ Mode 1 thẳng lên Mode 4.** Dù khách nói "Cho tôi mua áo khoác" ngay từ đầu, AI vẫn phải qua Mode 2 (hỏi chi tiết) và Mode 3 (search + tư vấn) trước.
- **Mode 5 là trạng thái cuối nhưng không phải dead-end.** Nếu khách nhắn tiếp sau Mode 5, hệ thống reset về Mode 2 (không phải Mode 1 vì khách đã quen).
- **InsightJSON không bị xoá khi downgrade.** Dù khách quay lại Mode 2, tất cả thông tin đã thu thập vẫn được giữ — chỉ đổi `STAGE``TONE`.
# 4. Upsell & Nghiệp vụ Bán hàng
Upsell là cơ chế **tự động nâng giá** khi hệ thống không tìm đủ sản phẩm trong tầm giá khách yêu cầu. Đây là chiến thuật kinh doanh cốt lõi, được tích hợp trực tiếp vào thuật toán tìm kiếm.
## Tại sao cần Upsell?
Trong thực tế, **60% khách hàng đưa ra budget thấp hơn khả năng chi trả thực**. Khi AI chỉ search đúng budget, hệ thống sẽ:
- Trả về ít kết quả (hoặc hết hàng)
- AI buộc phải nói "Dạ em hết hàng" → MẤT KHÁCH
Upsell giải quyết vấn đề này bằng cách **nới dần biên giá** qua 7 tầng Cascading Search.
## Cơ chế Upsell trong Cascading Search
| Tầng | Giá tối đa | Chiến thuật |
|---|---|---|
| 1-2 | `price_max` (đúng budget) | Tìm chính xác ý khách |
| 3-4 | `price_max` (nới lỏng filter) | Bỏ tags, bỏ gender nhưng giữ giá |
| **5** | `price_max * 1.5` | **Upsell nhẹ** — "Nhỉnh hơn chút xíu" |
| **6** | `price_max * 2.0` | **Upsell mạnh** — "Đắt hơn nhưng xài 3 mùa" |
| **7** | Không giới hạn | **Hail Mary** — Lôi đồ xịn nhất ra |
## Stylist AI xử lý Upsell như thế nào?
Khi ProductSearchEngine trả về sản phẩm từ tầng 5-7 (vượt budget), Stylist Agent **không bao giờ** nói thẳng "Đồ này vượt ngân sách". Thay vào đó, AI sử dụng các chiến thuật:
| Tầng | Chiến thuật Stylist | Câu mẫu |
|---|---|---|
| Tầng 5 (1.5x) | So sánh giá trị | "Dạ mẫu dưới 500k đang hết, nhưng mẫu này chỉ nhỉnh hơn 200k mà ấm gấp đôi!" |
| Tầng 6 (2x) | Đầu tư dài hạn | "Mẫu này giá cao hơn nhưng chất liệu len merino xài được 3 mùa đông luôn anh!" |
| Tầng 7 (no limit) | Premium positioning | "Đây là best-seller mùa này, chất lượng thuộc top và đang còn đủ size!" |
## Guardrails chống lạm dụng Upsell
Để tránh AI biến thành "bán hàng ép" gây phản cảm:
- **Tầng 5-6**: Stylist phải đề cập lý do vượt giá (hết hàng rẻ, chất lượng tốt hơn)
- **Tầng 7**: Chỉ gợi ý 1-2 sản phẩm, kèm disclaimer "Anh xem thử, không thích cũng không sao"
- **Nếu khách nói "đắt quá" 2 lần**: AI phải dừng Upsell, chuyển sang gợi ý đồ sale hoặc combo tiết kiệm
- **Tuyệt đối KHÔNG**: Tự ý đề cập giảm giá, voucher, hoặc khuyến mãi nếu không có trong data
## Combo & Cross-sell
Ngoài Upsell, Stylist còn sử dụng **ai_matches** (dữ liệu phối đồ từ Fashion Matching Engine) để gợi ý combo:
```
Khách tìm: Áo khoác nam
→ ai_matches gợi ý: + Quần khaki (phối đi làm) + Khăn len (phối đi Sapa)
→ Stylist: "Anh ơi áo này phối với quần khaki Canifa thì siêu thanh lịch luôn, để em gửi link cả set nhé!"
```
File tham chiếu: `backend/agent/lead_stage_agent/product_search_engine.py` — hàm `_cascading_search()`
# 5. Hướng mở rộng & Cá nhân hóa
Lead Agent được thiết kế modular — có thể mở rộng nhiều hướng mà không cần viết lại core.
## 5.1. Cá nhân hóa theo lịch sử mua hàng
**Hiện tại:** InsightJSON chỉ lưu thông tin phiên chat hiện tại (TTL 24h). Khách quay lại ngày hôm sau là bắt đầu từ đầu.
**Mở rộng:** Kết nối InsightJSON với **CRM** (thông qua SĐT/Email đã thu thập ở Mode 4):
| Trường mở rộng | Nguồn | Mục đích |
|---|---|---|
| `PURCHASE_HISTORY` | CRM Canifa | Biết khách đã mua gì trước đó → không gợi ý lại |
| `PREFERRED_SIZE` | CRM / Lịch sử | Tự động lọc đúng size, không cần hỏi lại |
| `VIP_TIER` | CRM | VIP được ưu tiên tư vấn Premium, giảm Upsell áp lực |
| `LAST_VISIT_DATE` | Redis + CRM | "Lần trước anh xem áo khoác dạ, hôm nay có size L rồi ạ!" |
```python
# Ví dụ mở rộng InsightJSON
class InsightJSON(BaseModel):
# ... 12 trường hiện tại ...
PURCHASE_HISTORY: list[str] = [] # ["SKU123 - Áo polo", "SKU456 - Quần jean"]
PREFERRED_SIZE: str = "" # "L" hoặc "XL"
VIP_TIER: str = "Standard" # "Standard" | "Silver" | "Gold" | "Platinum"
```
## 5.2. Mở rộng Multi-channel
**Hiện tại:** Chỉ hoạt động qua Chat Widget (web/Facebook Messenger).
**Mở rộng:**
| Kênh | Thay đổi cần thiết |
|---|---|
| **Zalo OA** | Thêm adapter Zalo API → Controller. InsightJSON vẫn dùng chung |
| **Instagram DM** | Thêm adapter Instagram Graph API. Cho phép khách gửi ảnh → kích hoạt AI Image Search |
| **Telesale Script** | Xuất InsightJSON thành script cho nhân viên Telesale gọi điện: "Chào anh Minh, hôm qua anh xem áo khoác dạ..." |
## 5.3. A/B Testing chiến thuật AI
**Hiện tại:** Mỗi Mode có 1 chiến thuật cố định.
**Mở rộng:** Cho phép cấu hình nhiều phiên bản prompt cho cùng 1 Mode, rồi A/B test:
```python
# Ví dụ A/B Testing config
MODE_3_VARIANTS = {
"A": "Tư vấn chi tiết chất liệu + so sánh giá trị",
"B": "Tư vấn ngắn gọn + push combo ngay",
"C": "Hỏi khách muốn xem thêm bao nhiêu SP"
}
# Random chọn variant → Track conversion rate qua Langfuse
```
## 5.4. Tự động hoá Post-Lead
Khi khách để lại SĐT (Mode 4 → 5 thành công), hiện tại chỉ lưu DB. Mở rộng:
| Bước | Công cụ | Hành động |
|---|---|---|
| 1. Lưu Lead | PostgreSQL | Lưu SĐT + InsightJSON + sản phẩm quan tâm |
| 2. Notify Telesale | n8n Webhook | Gửi notification cho nhân viên qua Slack/Zalo |
| 3. Auto Email | n8n + Mailgun | Gửi email "Cảm ơn" kèm link sản phẩm đã tư vấn |
| 4. Follow-up 24h | n8n Scheduler | Nếu chưa mua, gửi reminder kèm voucher 10% |
| 5. Analytics | Langfuse + Metabase | Track tỷ lệ chuyển đổi Lead → Sale |
## 5.5. Voice Agent
**Tương lai:** Chuyển Lead Agent thành Voice Agent cho cửa hàng offline:
- Khách bước vào → Mic thu voice → Speech-to-Text → Lead Agent → Text-to-Speech
- Giữ nguyên 5 Mode, thay Chat Widget bằng Speaker
# 6. Troubleshooting & Gotchas
## AI cứ quăng list sản phẩm khi khách mới chào
**Triệu chứng:** Khách nhắn "Hi" nhưng AI nhảy vào Mode 3 luôn, quăng ra 5 sản phẩm.
**Nguyên nhân:** Classifier Agent không phân loại đúng → `tool_name` không null khi lẽ ra phải Early Exit.
**Sửa:** Mở `prompts.py`, tìm `CLASSIFIER_SYSTEM_PROMPT`, thêm dòng CHỮ HOA:
```
- NẾU TIN NHẮN CHỈ LÀ CHÀO HỎI (hi, hello, alo, chào shop), tool_name PHẢI = null
```
## AI không chuyển sang Mode 4 (CLOSING) dù khách muốn mua
**Triệu chứng:** Khách nói "OK lấy luôn" nhưng AI tiếp tục giới thiệu thêm sản phẩm.
**Nguyên nhân:** InsightJSON.STAGE vẫn ở `CONSIDERING` vì Classifier chưa nhận ra tín hiệu chốt.
**Sửa:** Mở `prompts.py`, tìm `CLASSIFIER_SYSTEM_PROMPT`, bổ sung các ví dụ tín hiệu chốt:
```
- CÁC TÍN HIỆU CHỐT (chuyển sang CLOSING): "lấy luôn", "ok mua", "cho anh cái đó",
"lấy anh cái đầu", "chị lấy", "đặt hàng", "order", "mua ngay"
```
## Upsell quá mạnh — khách phản hồi tiêu cực
**Triệu chứng:** Khách chê đắt 2 lần nhưng AI vẫn tiếp tục gợi ý đồ đắt hơn.
**Nguyên nhân:** Stylist prompt chưa có guardrail xử lý từ chối giá lặp lại.
**Sửa:** Thêm vào `STYLIST_SYSTEM_PROMPT`:
```
- NẾU KHÁCH NÓI "đắt quá" HOẶC "không đủ tiền" >= 2 LẦN: DỪNG UPSELL.
Chuyển sang gợi ý đồ đang giảm giá hoặc combo tiết kiệm.
```
## InsightJSON bị reset giữa chừng
**Triệu chứng:** AI quên hết thông tin khách đã cung cấp, hỏi lại từ đầu.
**Nguyên nhân:**
1. Redis TTL hết hạn (default 24h)
2. `device_id` thay đổi giữa phiên (khách đổi trình duyệt)
3. Bug trong Stylist output — `updated_insight` trả về null
**Sửa:** Kiểm tra Redis key `lead_insight:{device_id}`. Nếu key tồn tại nhưng AI vẫn quên → check log `[STYLIST]` xem `updated_insight` có null không.
## Cascading Search trả về quá ít kết quả
**Triệu chứng:** AI nói "Em tìm thấy 1 sản phẩm phù hợp" dù catalog có hàng nghìn mẫu.
**Nguyên nhân:** Tags trong `InferredSearchArgs` quá hẹp, kết hợp với giá thấp → Tầng 1-4 đều empty.
**Sửa:** Kiểm tra log `[SEARCH]` xem tầng nào trả về kết quả. Nếu chỉ tầng 6-7 mới có → tags cần nới lỏng hoặc data catalog cần bổ sung tags mới.
## Performance chậm (> 5 giây)
**Nguyên nhân phổ biến:**
| Bottleneck | Metric | Ngưỡng |
|---|---|---|
| Classifier LLM | `classifier_ms` | Nên < 1500ms |
| Stylist LLM | `stylist_ms` | Nên < 2000ms |
| StarRocks query | `tool_ms` | Nên < 500ms |
| Canifa Stock API | (trong tool_ms) | Nên < 1000ms |
**Sửa:** Kiểm tra Langfuse trace. Nếu `classifier_ms` > 2000ms → LLM model quá nặng, cân nhắc dùng model nhẹ hơn cho Classifier.
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 520" width="960" height="520">
<defs>
<marker id="arrowGray" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<filter id="shadow">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000" flood-opacity="0.08"/>
</filter>
</defs>
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.title { font-size: 20px; font-weight: 700; fill: #1e293b; }
.subtitle { font-size: 12px; fill: #64748b; }
.node-label { font-size: 13px; font-weight: 700; fill: #ffffff; }
.node-sub { font-size: 10px; fill: rgba(255,255,255,0.8); }
.detail-title { font-size: 12px; font-weight: 700; fill: #1e293b; }
.detail-text { font-size: 11px; fill: #475569; }
.tag { font-size: 9px; font-weight: 600; fill: #ffffff; letter-spacing: 0.05em; }
.code-text { font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace; fill: #64748b; }
</style>
<!-- Background -->
<rect width="960" height="520" fill="#ffffff"/>
<!-- Title -->
<text x="480" y="36" text-anchor="middle" class="title">Journey Shopping — Phễu 5 Mode Tâm Lý</text>
<text x="480" y="54" text-anchor="middle" class="subtitle">Mỗi Mode ứng với 1 trạng thái tâm lý khách hàng, AI thay đổi Tone và Chiến thuật theo</text>
<!-- ============ FUNNEL NODES ============ -->
<!-- Mode 1: BROWSE -->
<rect x="30" y="80" width="160" height="64" rx="8" fill="#334155" filter="url(#shadow)"/>
<text x="110" y="106" text-anchor="middle" class="node-label">1. BROWSE</text>
<text x="110" y="122" text-anchor="middle" class="node-sub">Dạo quanh (Lạnh)</text>
<!-- Arrow 1→2 -->
<line x1="190" y1="112" x2="218" y2="112" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrowGray)"/>
<!-- Mode 2: IDENTIFYING -->
<rect x="220" y="80" width="160" height="64" rx="8" fill="#0284c7" filter="url(#shadow)"/>
<text x="300" y="106" text-anchor="middle" class="node-label">2. IDENTIFYING</text>
<text x="300" y="122" text-anchor="middle" class="node-sub">Gom nhu cầu (Ấm)</text>
<!-- Arrow 2→3 -->
<line x1="380" y1="112" x2="408" y2="112" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrowGray)"/>
<!-- Mode 3: CONSIDERING -->
<rect x="410" y="80" width="160" height="64" rx="8" fill="#7c3aed" filter="url(#shadow)"/>
<text x="490" y="106" text-anchor="middle" class="node-label">3. CONSIDERING</text>
<text x="490" y="122" text-anchor="middle" class="node-sub">Cân nhắc (Nóng)</text>
<!-- Arrow 3→4 -->
<line x1="570" y1="112" x2="598" y2="112" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrowGray)"/>
<!-- Mode 4: CLOSING -->
<rect x="600" y="80" width="160" height="64" rx="8" fill="#d97706" filter="url(#shadow)"/>
<text x="680" y="106" text-anchor="middle" class="node-label">4. CLOSING</text>
<text x="680" y="122" text-anchor="middle" class="node-sub">Chốt hạ (Chín muồi)</text>
<!-- Arrow 4→5 -->
<line x1="760" y1="112" x2="788" y2="112" stroke="#94a3b8" stroke-width="1.5" marker-end="url(#arrowGray)"/>
<!-- Mode 5: RETENTION -->
<rect x="790" y="80" width="140" height="64" rx="8" fill="#16a34a" filter="url(#shadow)"/>
<text x="860" y="106" text-anchor="middle" class="node-label">5. RETENTION</text>
<text x="860" y="122" text-anchor="middle" class="node-sub">Hậu mãi (Kết thúc)</text>
<!-- ============ DETAIL CARDS ============ -->
<!-- Card 1 -->
<rect x="30" y="170" width="170" height="160" rx="6" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="30" y="170" width="170" height="28" rx="6" fill="#334155"/>
<text x="42" y="189" class="tag">MODE 1 — BROWSE</text>
<text x="42" y="216" class="detail-title">Tone: Friendly</text>
<text x="42" y="234" class="detail-text">Chỉ chào hỏi, rã đông.</text>
<text x="42" y="250" class="detail-text">Cấm quăng list SP.</text>
<text x="42" y="270" class="detail-text">Gợi mở câu chuyện.</text>
<text x="42" y="296" class="code-text">STAGE_NUM = 1</text>
<text x="42" y="312" class="code-text">TONE = "Friendly"</text>
<!-- Card 2 -->
<rect x="210" y="170" width="170" height="160" rx="6" fill="#f0f9ff" stroke="#bae6fd" stroke-width="1"/>
<rect x="210" y="170" width="170" height="28" rx="6" fill="#0284c7"/>
<text x="222" y="189" class="tag">MODE 2 — IDENTIFYING</text>
<text x="222" y="216" class="detail-title">Tone: Consultant</text>
<text x="222" y="234" class="detail-text">Hạn chế gọi Tool.</text>
<text x="222" y="250" class="detail-text">Đặt câu hỏi lấp đầy</text>
<text x="222" y="266" class="detail-text">InsightJSON (giá, size,</text>
<text x="222" y="282" class="detail-text">dịp, mục đích).</text>
<text x="222" y="312" class="code-text">STAGE_NUM = 2</text>
<!-- Card 3 -->
<rect x="390" y="170" width="170" height="160" rx="6" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1"/>
<rect x="390" y="170" width="170" height="28" rx="6" fill="#7c3aed"/>
<text x="402" y="189" class="tag">MODE 3 — CONSIDERING</text>
<text x="402" y="216" class="detail-title">Tone: Persuasive</text>
<text x="402" y="234" class="detail-text">Gọi SearchEngine 7 tầng.</text>
<text x="402" y="250" class="detail-text">Gợi ý combo ai_matches.</text>
<text x="402" y="266" class="detail-text">Kích Upsell nếu hết</text>
<text x="402" y="282" class="detail-text">đồ rẻ (x1.5 giá).</text>
<text x="402" y="312" class="code-text">STAGE_NUM = 3</text>
<!-- Card 4 -->
<rect x="570" y="170" width="170" height="160" rx="6" fill="#fffbeb" stroke="#fde68a" stroke-width="1"/>
<rect x="570" y="170" width="170" height="28" rx="6" fill="#d97706"/>
<text x="582" y="189" class="tag">MODE 4 — CLOSING</text>
<text x="582" y="216" class="detail-title">Tone: Action-oriented</text>
<text x="582" y="234" class="detail-text">Bỏ văn hoa tư vấn.</text>
<text x="582" y="250" class="detail-text">Chuyển giọng chốt sale.</text>
<text x="582" y="266" class="detail-text">Xin SĐT, Email,</text>
<text x="582" y="282" class="detail-text">lên đơn ngay.</text>
<text x="582" y="312" class="code-text">STAGE_NUM = 4</text>
<!-- Card 5 -->
<rect x="750" y="170" width="170" height="160" rx="6" fill="#f0fdf4" stroke="#86efac" stroke-width="1"/>
<rect x="750" y="170" width="170" height="28" rx="6" fill="#16a34a"/>
<text x="762" y="189" class="tag">MODE 5 — RETENTION</text>
<text x="762" y="216" class="detail-title">Tone: Warm</text>
<text x="762" y="234" class="detail-text">OK: Lưu DB, cảm ơn,</text>
<text x="762" y="250" class="detail-text">hứa Telesale gọi.</text>
<text x="762" y="270" class="detail-text">Fail: Mở đường lùi,</text>
<text x="762" y="286" class="detail-text">không chèo kéo.</text>
<text x="762" y="312" class="code-text">STAGE_NUM = 5</text>
<!-- ============ INSIGHT JSON BOX ============ -->
<rect x="30" y="356" width="890" height="140" rx="8" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="30" y="356" width="890" height="30" rx="8" fill="#1e293b"/>
<text x="475" y="376" text-anchor="middle" class="tag">INSIGHTJSON — BỘ NHỚ KHÁCH HÀNG (LƯU REDIS, TTL 24H)</text>
<text x="50" y="410" class="code-text">USER: "Anh Minh"</text>
<text x="50" y="428" class="code-text">TARGET: "Cho bản thân"</text>
<text x="50" y="446" class="code-text">GOAL: "Đi du lịch Sapa"</text>
<text x="50" y="464" class="code-text">CONSTRAINS: "Budget &lt; 1tr"</text>
<text x="50" y="482" class="code-text">STAGE: "CONSIDERING"</text>
<text x="300" y="410" class="code-text">STAGE_NUM: 3</text>
<text x="300" y="428" class="code-text">TONE: "Persuasive"</text>
<text x="300" y="446" class="code-text">BEHAVIORAL_HINTS: ["hay hỏi giá"]</text>
<text x="300" y="464" class="code-text">LATEST_PRODUCT_INTEREST: "Áo khoác dạ"</text>
<text x="300" y="482" class="code-text">LAST_ACTION: "Xem sản phẩm"</text>
<text x="650" y="410" class="code-text">SUMMARY_HISTORY:</text>
<text x="650" y="428" class="code-text">"Khách tìm áo khoác nam</text>
<text x="650" y="446" class="code-text"> mùa đông, form rộng,</text>
<text x="650" y="464" class="code-text"> budget dưới 1 triệu,</text>
<text x="650" y="482" class="code-text"> mặc đi Sapa"</text>
<!-- Footer -->
<text x="480" y="514" text-anchor="middle" class="subtitle">File: backend/agent/lead_stage_agent/graph.py — class InsightJSON</text>
</svg>
{
"id": "08a-lead-business",
"title": "Journey Shopping (5 Modes)",
"description": "Phễu bán hàng 5 giai đoạn tâm lý khách hàng. Giải thích tại sao Lead Agent phải chia thành 5 Mode và cách AI thay đổi chiến thuật theo từng Mode.",
"diagram": "data/08a-lead-business/journey_shopping.svg",
"sections": [
{ "id": "sec0", "title": "1. Tổng quan (Overview)", "type": "markdown", "file": "01_overview.md" },
{ "id": "sec1", "title": "2. Chi tiết từng Mode & Ý nghĩa", "type": "markdown", "file": "02_modes_detail.md" },
{ "id": "sec2", "title": "3. Logic chuyển đổi Stage (Lên / Xuống)", "type": "markdown", "file": "03_stage_transition.md" },
{ "id": "sec3", "title": "4. Upsell & Nghiệp vụ Bán hàng", "type": "markdown", "file": "04_upsell.md" },
{ "id": "sec4", "title": "5. Hướng mở rộng & Cá nhân hóa", "type": "markdown", "file": "05_personalization.md" },
{ "id": "sec5", "title": "6. Troubleshooting & Gotchas", "type": "markdown", "file": "06_troubleshooting.md" }
]
}
# 1. Tổng quan Kiến trúc
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 qua LangGraph StateGraph. Thiết kế này giải quyết 2 vấn đề lớn:
## Tại sao cần 2 Agent?
| Vấn đề | 1 Agent | 2 Agent (Dual) |
|---|---|---|
| **Chi phí LLM** | Mỗi turn đều gọi model nặng (GPT-4 class) dù chỉ để route | Classifier dùng model nhẹ → tiết kiệm 60% token |
| **Latency** | Model nặng xử lý cả routing + generation = chậm | Classifier nhanh (< 1.5s), chỉ Stylist mới dùng model nặng |
| **Hallucination** | Model vừa phải route vừa phải generate → dễ ảo giác | Mỗi Agent chỉ làm 1 việc → output kiểm soát được |
| **Structured Output** | Khó ép 1 model xuất cả JSON + text | Classifier xuất JSON chuẩn, Stylist xuất text tự nhiên |
## Luồng tổng quan
```
User Message → Classifier Agent → [Route Decision]
┌─ Early Exit (chào hỏi) → Stylist (text only)
└─ Tool Call → ProductSearchEngine → Stylist (text + products)
Final Response → User
```
## Phân chia trách nhiệm
| Agent | Vai trò | Model | Output |
|---|---|---|---|
| **Classifier** | Lễ tân — Phân loại ý định, quyết định gọi Tool | LLM nhẹ | `ClassifierOutput` (JSON) |
| **Stylist** | Best Seller — Sinh câu tư vấn thuyết phục | LLM nặng | `StylistOutput` (text + insight) |
File tham chiếu: `backend/agent/lead_stage_agent/graph.py`
# 2. Classifier Agent (AI Nhẹ — Router)
Classifier đóng vai trò **Lễ tân**: đọc tin nhắn khách, phân loại ý định, quyết định gọi Tool hay trả lời trực tiếp (Early Exit).
## Input
Classifier nhận 3 input mỗi turn:
| Input | Nguồn | Mục đích |
|---|---|---|
| `message` | Tin nhắn mới nhất từ khách | Ý định hiện tại |
| `insight_json` | Redis (device_id) | Bộ nhớ khách hàng — biết khách ở Mode nào |
| `chat_history` | Redis (5 tin gần nhất) | Ngữ cảnh hội thoại |
## Output: ClassifierOutput Schema
```python
class ClassifierOutput(BaseModel):
reasoning: str # Lý luận tại sao gọi/không gọi tool
tool_name: str | None # "lead_search_tool" hoặc null (Early Exit)
lead_search_args: ClassifierLeadSearchArgs | None
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ã
```
Classifier **bắt buộc** dùng `with_structured_output()` — output là JSON chuẩn, không phải text tự do. Điều này loại bỏ hoàn toàn rủi ro ảo giác ở bước routing.
## Early Exit — Khi nào không cần gọi Tool?
| Tình huống | tool_name | ai_response |
|---|---|---|
| Khách chào "Hi", "Alo", "Chào shop" | `null` | "Dạ chào bạn! Mình là Stylist Canifa..." |
| Khách hỏi chung "Có gì mới không?" | `null` | "Dạ tuần này Canifa có bộ sưu tập..." |
| Khách nói "Cảm ơn", "Bye" | `null` | "Dạ không có gì ạ, hẹn gặp lại!" |
| Khách tâm sự "Hôm nay mệt quá" | `null` | "Dạ bạn nghỉ ngơi nhé, khi nào..." |
**Lợi ích Early Exit:** Tiết kiệm 1 lần gọi Tool + 1 lần gọi Stylist = giảm latency 50-70% cho các tin nhắn đơn giản.
## Dual-Lane Search Args
Khi Classifier quyết định gọi Tool, nó xuất `ClassifierLeadSearchArgs` gồm **2 lane**:
```python
class ClassifierLeadSearchArgs(BaseModel):
literal: LiteralSearchArgs # Lane 1: Nguyên văn
inferred: InferredSearchArgs # Lane 2: Suy luận cấu trúc
```
Chi tiết 2 lane này được giải thích ở trang **Split Query & Cascading Search**.
File tham chiếu: `graph.py` — hàm `_classifier_node()`
<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`
# 3. Stylist Agent (AI Nặng — Best Seller)
Stylist đóng vai trò **chốt sale**: nhận data sản phẩm từ Tool, lịch sử hội thoại, và InsightJSON để sinh câu trả lời tư vấn thuyết phục.
## Input
| Input | Nguồn | Mục đích |
|---|---|---|
| `tool_results` | ProductSearchEngine | Danh sách SP có stock + outfit context |
| `insight_json` | Redis (InsightJSON cũ) | Biết khách ở Mode nào → chọn đúng Tone |
| `STAGE` + `tone_directive` | Classifier Output | Ép Stylist bơm đúng giọng |
| `chat_history` | Redis | Ngữ cảnh hội thoại |
| `STYLIST_SYSTEM_PROMPT` | prompts.py | Guardrails + quy tắc ứng xử |
## Output: StylistOutput Schema
```python
class StylistOutput(BaseModel):
ai_response: str # Câu trả lời tư vấn (max 200 từ)
product_ids: list[str] # Danh sách SKU đã đề cập
user_insight: InsightJSON # InsightJSON cập nhật
```
## Guardrails (Rào chắn)
Stylist bị ràng buộc bởi `STYLIST_SYSTEM_PROMPT` với các quy tắc nghiêm ngặt:
| Quy tắc | Lý do |
|---|---|
| Không tự ý đề cập giảm giá nếu không có trong data | Tránh hứa hão → khách claim → rủi ro pháp lý |
| Không chèo kéo ở Mode 5 (Retention) | Khách đã từ chối → chèo kéo gây phản cảm |
| Không quăng list SP ở Mode 1 (Browse) | Khách chưa sẵn sàng → spam SP = mất khách |
| Tối đa 200 từ mỗi response | Tin nhắn dài → khách không đọc → mất tương tác |
| Ưu tiên gợi ý combo (áo + quần + phụ kiện) | Tăng AOV (Average Order Value) |
| Không dùng emoji quá 2 cái | Giữ tone chuyên nghiệp |
## Cách Stylist cập nhật InsightJSON
Mỗi turn, Stylist phân tích tin nhắn khách và cập nhật:
```python
# Ví dụ: Khách nói "Cho anh xem áo khoác dạ đi Sapa, budget 800k"
# Stylist cập nhật InsightJSON:
{
"USER": "Anh (nam)",
"TARGET": "Cho bản thân",
"GOAL": "Đi du lịch Sapa",
"CONSTRAINS": "Budget 800k",
"STAGE": "CONSIDERING", # Nhảy từ IDENTIFYING → CONSIDERING
"STAGE_NUM": 3,
"TONE": "Persuasive",
"LATEST_PRODUCT_INTEREST": "Áo khoác dạ",
"LAST_ACTION": "Yêu cầu tìm SP cụ thể"
}
```
File tham chiếu: `graph.py` — hàm `_stylist_node()`, `prompts.py``STYLIST_SYSTEM_PROMPT`
# 4. State Management & LangGraph Pipeline
Toàn bộ luồng Dual-Agent được quản lý bởi **LangGraph StateGraph** — mỗi node nhận state đầu vào, xử lý, và trả state mới.
## LeadGraphState Schema
```python
class LeadStageState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
user_insight: str | None # InsightJSON cũ (từ Redis)
# Internal pipeline
tool_result: str | None # Kết quả từ ProductSearchEngine
tool_name_used: str | None # Tên tool đã gọi
# Output
updated_insight: dict | None # InsightJSON mới (Stylist cập nhật)
lead_stage: dict | None # Stage transition info
product_ids: list[str] # Danh sách SKU trả về
early_exit: bool | None # True = Classifier bypass Stylist
diagnostics: Annotated[list[dict[str, Any]], lambda x, y: x + y]
```
## Graph Definition
```python
workflow = StateGraph(LeadStageState)
# 2 node chính
workflow.add_node("classifier", _classifier_node)
workflow.add_node("stylist", _stylist_node)
# Entry point
workflow.set_entry_point("classifier")
# Conditional routing: Early Exit → END, otherwise → Stylist
workflow.add_conditional_edges(
"classifier",
lambda state: END if state.get("early_exit") else "stylist"
)
# Stylist always ends
workflow.add_edge("stylist", END)
```
## Tại sao không dùng ReAct Loop?
ReAct (Reasoning + Acting) loop cho phép Agent tự quyết định gọi tool bao nhiêu lần. Lead Agent **không dùng** vì:
| ReAct Loop | Dual-Agent (No Loop) |
|---|---|
| Agent có thể gọi tool 3-5 lần → latency cao | Luôn chỉ 1 lần gọi tool → latency thấp |
| Agent tự quyết định → khó kiểm soát | Classifier ép cứng → kiểm soát 100% |
| Có thể rơi vào vòng lặp vô hạn | Không có cycle → không bao giờ loop |
| Khó debug khi lỗi | 2 node tuyến tính → debug đơn giản |
File tham chiếu: `graph.py` — cuối file, phần `StateGraph` definition
# 5. Tracing & Observability
Toàn bộ quá trình Dual-Agent được trace qua **Langfuse** — platform observability chuyên dụng cho LLM.
## Metrics theo dõi
| Metric | Mô tả | Ngưỡng tốt |
|---|---|---|
| `classifier_ms` | Thời gian Classifier xử lý | < 1500ms |
| `stylist_ms` | Thời gian Stylist xử lý | < 2000ms |
| `tool_ms` | Thời gian ProductSearchEngine chạy | < 500ms |
| `total_ms` | Tổng thời gian end-to-end | < 4000ms |
| `stage` | Mode hiện tại của khách | BROWSE → RETENTION |
| `tool_called` | Classifier có gọi Tool không | true/false |
| `token_in` | Số token input | Monitor cost |
| `token_out` | Số token output | Monitor cost |
## Cách đọc Trace trong Langfuse
```
Trace: lead_agent_v2
├── Span: classifier_node (1200ms)
│ ├── Generation: classifier_llm_call
│ │ └── Output: ClassifierOutput { tool_name: "lead_search_tool", ... }
│ └── Event: stage_transition (IDENTIFYING → CONSIDERING)
├── Span: tool_execution (450ms)
│ ├── Event: cascading_tier_1 (0 results)
│ ├── Event: cascading_tier_2 (3 results)
│ └── Event: stock_check (2 in_stock, 1 out_of_stock)
└── Span: stylist_node (1800ms)
├── Generation: stylist_llm_call
│ └── Output: StylistOutput { ai_response: "Dạ anh ơi...", ... }
└── Event: insight_updated
```
## Dashboard Metrics
Tạo dashboard Langfuse theo dõi:
| Panel | Query | Mục đích |
|---|---|---|
| **Conversion Rate** | `stage = CLOSING` / total sessions | Tỷ lệ khách đến bước chốt |
| **Early Exit Rate** | `early_exit = true` / total | Bao nhiêu % tin nhắn không cần search |
| **Avg Latency** | avg(`total_ms`) | Tốc độ phản hồi trung bình |
| **Tool Error Rate** | `tool_error = true` / total | Tỷ lệ lỗi ProductSearchEngine |
| **Token Cost / Day** | sum(`token_in` + `token_out`) * price | Chi phí LLM hàng ngày |
File tham chiếu: `graph.py` — các dòng `langfuse.trace()`, `controller.py` — metric logging
# 6. Troubleshooting & Gotchas
## Classifier trả về Structured Output lỗi
**Triệu chứng:** API trả 500, log `[CLASSIFIER]` báo `OutputParserException`.
**Nguyên nhân:** Model không tuân thủ JSON schema → `with_structured_output()` parse fail.
**Sửa:**
1. Kiểm tra model có hỗ trợ `function_calling` không (GPT-4o, Claude 3.5 hỗ trợ; GPT-3.5 không ổn định)
2. Giảm độ phức tạp `ClassifierOutput` schema nếu cần
3. Thêm `strict=True` vào `with_structured_output()` nếu dùng OpenAI
## Stylist sinh câu trả lời quá dài (> 200 từ)
**Triệu chứng:** Tin nhắn AI dài 500+ từ, khách không đọc hết.
**Nguyên nhân:** `STYLIST_SYSTEM_PROMPT` chưa enforce giới hạn từ đủ mạnh.
**Sửa:** Thêm vào prompt:
```
- GIỚI HẠN TỐI ĐA 200 TỪ. NẾU CÓ NHIỀU SP, CHỈ HIGHLIGHT 2-3 SP TỐT NHẤT.
```
## Early Exit nhưng vẫn gọi Tool
**Triệu chứng:** Classifier trả `tool_name = null` nhưng pipeline vẫn chạy vào Stylist Node và gọi Tool.
**Nguyên nhân:** Bug trong conditional edge — `state.get("early_exit")` trả `None` thay vì `True`.
**Sửa:** Kiểm tra `_classifier_node()` có set `state["early_exit"] = True` khi `tool_name is None` không.
## Stylist không cập nhật InsightJSON
**Triệu chứng:** `updated_insight` luôn null → InsightJSON không thay đổi qua các turn.
**Nguyên nhân:** Stylist model không xuất đúng `StylistOutput` schema → `user_insight` field bị skip.
**Sửa:**
1. Kiểm tra `StylistOutput` schema có match với model output không
2. Thêm explicit instruction vào prompt: "BẮT BUỘC cập nhật user_insight mỗi turn"
## 2 Agent gọi cùng 1 model (lãng phí)
**Triệu chứng:** Cả Classifier và Stylist đều dùng GPT-4o → chi phí gấp đôi.
**Sửa:** Classifier nên dùng model nhẹ hơn:
| Agent | Recommended Model | Cost |
|---|---|---|
| Classifier | GPT-4o-mini / Claude 3 Haiku | $0.15/1M tokens |
| Stylist | GPT-4o / Claude 3.5 Sonnet | $2.50/1M tokens |
<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": "08b-lead-dual-agent",
"title": "Kiến trúc Dual-Agent Flow",
"description": "Phân tách nhiệm vụ giữa Classifier (AI Nhẹ) và Stylist (AI Nặng). Giải thích cách 2 Agent phối hợp qua LangGraph StateGraph.",
"diagram": "data/08b-lead-dual-agent/lead_search_arch.svg",
"sections": [
{ "id": "sec0", "title": "1. Tổng quan Kiến trúc", "type": "markdown", "file": "01_overview.md" },
{ "id": "sec1", "title": "2. Classifier Agent (AI Nhẹ)", "type": "markdown", "file": "02_classifier.md" },
{ "id": "sec2", "title": "3. Stylist Agent (AI Nặng)", "type": "markdown", "file": "03_stylist.md" },
{ "id": "sec3", "title": "4. State & LangGraph Pipeline", "type": "markdown", "file": "04_state.md" },
{ "id": "sec4", "title": "5. Tracing & Observability", "type": "markdown", "file": "05_tracing.md" },
{ "id": "sec5", "title": "6. Troubleshooting & Gotchas", "type": "markdown", "file": "06_troubleshooting.md" }
]
}
# 1. Tổng quan Split Query
Phần lõi kỹ thuật của Lead Agent nằm trong `ProductSearchEngine`. Khi Classifier quyết định gọi Tool, Engine áp dụng 2 cơ chế chống Zero Results:
## Vấn đề: Zero Results
Trong ngành Retail, câu "Dạ em hết hàng" là **câu chết**. Mất khách vĩnh viễn.
Nguyên nhân phổ biến nhất: ném nguyên câu hỏi dài vào Database → query quá hẹp → 0 kết quả.
**Ví dụ:** Khách hỏi "Áo khoác nam mùa đông, form rộng, tầm 5 triệu để đi Sapa"
- Nếu search nguyên văn: `WHERE description LIKE '%áo khoác nam mùa đông form rộng 5 triệu Sapa%'`**0 results**
- Lý do: Không có sản phẩm nào chứa tất cả từ khóa cùng lúc
## Giải pháp: 2 cơ chế kết hợp
| Cơ chế | Mục đích |
|---|---|
| **Split Query** (Tách truy vấn) | Chia câu hỏi thành 2 lane song song: Literal + Inferred |
| **Cascading Search** (Rớt mạng 7 tầng) | Nới lỏng bộ lọc dần dần nếu tầng trước không đủ kết quả |
Kết hợp 2 cơ chế này đảm bảo **luôn có sản phẩm trên tay** để tư vấn.
## Khi nào Split Query được kích hoạt?
Split Query chỉ chạy khi Classifier trả `tool_name = "lead_search_tool"`. Nếu Early Exit → không qua Engine.
File tham chiếu: `backend/agent/lead_stage_agent/product_search_engine.py`
# 2. Lane 1: Literal Search (Nguyên văn)
Lane 1 giữ nguyên câu gốc của khách, dùng **Full-text Search** để bắt từ khóa đặc thù mà AI suy luận có thể bỏ lỡ.
## Input
```json
{
"raw_text": "áo khoác nam mùa đông form rộng 5 triệu đi Sapa"
}
```
## Khi nào Lane 1 thắng Lane 2?
| Tình huống | Literal thắng | Lý do |
|---|---|---|
| Khách dùng từ lóng | "áo bụng bia" | AI không map được "bụng bia" → tag nào |
| Khách nhắc địa danh | "đi Sapa", "đi biển" | Tags không có geo, nhưng description có |
| Khách nhắc tên sản phẩm | "áo the Coffee" | Tên riêng chỉ match được bằng full-text |
| Khách dùng mã SKU | "6TS24W002" | Mã chỉ match chính xác |
## SQL mẫu
```sql
SELECT sku, product_name, price, description
FROM products
WHERE MATCH(description, 'áo khoác nam mùa đông form rộng')
AND price <= 5000000
ORDER BY MATCH_SCORE DESC
LIMIT 10
```
**Ưu điểm:** Bắt được ngữ cảnh mà AI suy luận bỏ sót.
**Nhược điểm:** Dễ trả về kết quả nhiễu (match từ khóa nhưng không đúng ý định).
File tham chiếu: `product_search_engine.py` — Lane 1 logic
# 3. Lane 2: Inferred Search (Suy luận có cấu trúc)
Lane 2 dùng AI suy luận từ câu gốc, chuyển đổi ngôn ngữ tự nhiên thành **bộ filter chuẩn hóa**.
## Input
```json
{
"product_line_vn": ["Áo khoác"],
"gender_by_product": "men",
"tags": ["wthr:mua_dong", "fit:oversize", "occ:du_lich"],
"price_max": 5000000
}
```
## Bảng mapping từ lóng → Tags
| Ngôn ngữ khách | Tag chuẩn | Giải thích |
|---|---|---|
| "mùa đông", "lạnh", "rét" | `wthr:mua_dong` | Weather = mùa đông |
| "form rộng", "oversize" | `fit:oversize` | Fit = oversize |
| "đi du lịch", "đi chơi" | `occ:du_lich` | Occasion = du lịch |
| "đi làm", "công sở" | `occ:cong_so` | Occasion = công sở |
| "đi tiệc", "dự tiệc" | `occ:di_tiec` | Occasion = đi tiệc |
| "màu đen", "black" | `color:den` | Master color = đen |
| "budget 500k", "dưới 500" | `price_max: 500000` | Filter giá |
## SQL mẫu
```sql
SELECT sku, product_name, price
FROM products
WHERE product_line IN ('Áo khoác')
AND gender = 'men'
AND BITMAP_CONTAINS(tags, 'wthr:mua_dong')
AND BITMAP_CONTAINS(tags, 'fit:oversize')
AND price <= 5000000
ORDER BY popularity DESC
LIMIT 10
```
## Kết quả 2 Lane được gộp như thế nào?
```python
# Dedup theo SKU: Lane 1 + Lane 2
all_results = literal_results + inferred_results
unique_results = {r.sku: r for r in all_results}.values()
# Sắp xếp theo relevance score
final_results = sorted(unique_results, key=lambda r: r.score, reverse=True)
```
**Ưu điểm Lane 2:** Chính xác cao, filter chuẩn, không nhiễu.
**Nhược điểm:** Phụ thuộc vào chất lượng suy luận AI (nếu AI map sai tag → kết quả sai).
File tham chiếu: `product_search_engine.py` — Lane 2 logic, `graph.py``InferredSearchArgs`
<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`
# 4. Cascading Search 7 Tầng
Sau khi gộp kết quả 2 Lane, nếu **không đủ 3 sản phẩm**, Engine tự động kích hoạt Cascading — nới lỏng bộ lọc dần qua 7 tầng.
## Tại sao cần 7 tầng?
| Chỉ có 1 tầng | Cascading 7 tầng |
|---|---|
| Tìm đúng hoặc không tìm → "Hết hàng" | Luôn tìm được SP → Không bao giờ nói "Hết hàng" |
| Khách bỏ đi vĩnh viễn | Khách vẫn có đồ để xem → cơ hội chốt sale |
## Chi tiết từng tầng
### Tầng 1: Exact Match (Chính xác)
```sql
SELECT * FROM products
WHERE product_line IN (...) AND gender = '...'
AND price <= price_max AND BITMAP_CONTAINS(tags, ...)
LIMIT 10
```
Khớp 100% tags + giá + giới tính. Nếu đủ 3 SP → **dừng ngay**, không xuống tầng dưới.
### Tầng 2: NGRAM + Bitmap (Full-text)
```sql
SELECT * FROM products
WHERE MATCH(description, '...') AND BITMAP_CONTAINS(tags, ...)
AND price <= price_max
LIMIT 10
```
Bổ sung full-text search để bắt từ khóa đặc thù.
### Tầng 3: Drop Tags (Bỏ tags chi tiết)
Giữ `product_line` + `price`, bỏ tags → mở rộng tập kết quả.
### Tầng 4: Drop Gender (Nới lỏng giới tính)
Bỏ filter gender, bốc thêm Unisex → cross-sell thông minh.
### Tầng 5: Upsell 1.5x (Nâng giá nhẹ)
```sql
SELECT * FROM products
WHERE product_line IN (...) AND price <= (price_max * 1.5)
LIMIT 10
```
**Tâm lý:** "Chỉ nhỉnh hơn chút xíu thôi" — 40% khách chấp nhận nới thêm 50%.
### Tầng 6: Upsell 2x (Nâng giá mạnh)
```sql
SELECT * FROM products
WHERE product_line IN (...) AND price <= (price_max * 2.0)
LIMIT 10
```
**Tâm lý:** "Đắt hơn nhưng xài 3 mùa đông" — push giá trị dài hạn.
### 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
```
**Tâm lý:** Thà khách chê đắt → vẫn còn cơ hội thuyết phục. Tốt hơn là báo hết hàng → mất khách vĩnh viễn.
## Quy tắc dừng
Engine dừng cascading khi đạt **>= 3 sản phẩm** (sau dedup). Con số 3 được chọn vì:
- 1 SP: Không có lựa chọn → khách cảm thấy bị ép
- 2 SP: Ít quá → khách thấy "shop này nghèo hàng"
- **3 SP: Đủ để so sánh** → khách cảm thấy được lựa chọn
- 5+ SP: Quá nhiều → khách overwhelmed
File tham chiếu: `product_search_engine.py` — hàm `_cascading_search()`
# 5. Enrichment (Stock + Outfit)
Sau khi tìm được sản phẩm qua Cascading, Engine bồi thêm 2 bước **trước khi** gửi cho Stylist.
## Bước 1: Check Stock (Canifa API)
Gọi Canifa Stock API realtime để kiểm tra tồn kho:
```python
# Pseudo-code
for product in results:
stock = canifa_stock_api.check(product.sku)
if stock.available:
product.stock_status = "in_stock"
product.available_sizes = stock.sizes # ["S", "M", "L"]
else:
product.stock_status = "out_of_stock"
results.remove(product) # Vứt sản phẩm hết hàng
```
**Tại sao quan trọng:** Không có gì tệ hơn việc AI tư vấn sản phẩm mà khách mua không được. Mất niềm tin vĩnh viễn.
| Trường hợp | Xử lý |
|---|---|
| SP hết hàng hoàn toàn | Vứt khỏi kết quả |
| SP hết size nhưng còn size khác | Giữ lại, thêm note "còn size M, L" |
| API timeout (> 2s) | Skip check, giữ SP nhưng thêm disclaimer |
## Bước 2: Outfit Context (SQLite ai_matches)
Query bảng `ai_matches` trong SQLite để lấy gợi ý phối đồ:
```python
# Pseudo-code
for product in results:
matches = sqlite.query("""
SELECT match_product_name, match_sku, style_note
FROM ai_matches
WHERE source_sku = ?
LIMIT 2
""", product.sku)
product.outfit_suggestions = matches
```
**Ví dụ output:**
```json
{
"sku": "6AK24W001",
"product_name": "Áo khoác dạ nam",
"price": 890000,
"stock": "in_stock",
"available_sizes": ["M", "L", "XL"],
"outfit_suggestions": [
{ "match": "Quần khaki slim fit", "note": "Phối đi làm thanh lịch" },
{ "match": "Khăn len cashmere", "note": "Phối đi du lịch mùa đông" }
]
}
```
Stylist Agent sẽ dùng `outfit_suggestions` để gợi ý combo thay vì chỉ tư vấn 1 sản phẩm đơn lẻ → tăng AOV (Average Order Value).
File tham chiếu: `product_search_engine.py` — hàm `_enrich_results()`
# 6. Troubleshooting & Gotchas
## Zero Results dù catalog có hàng nghìn mẫu
**Triệu chứng:** AI trả "Em tìm thấy 1 sản phẩm phù hợp" hoặc cả 7 tầng đều trống.
**Kiểm tra:**
1. Log `[SEARCH]` — xem mỗi tầng trả bao nhiêu kết quả
2. Kiểm tra `InferredSearchArgs` — tags có quá hẹp không?
3. Kiểm tra bảng StarRocks có cột BITMAP tương ứng với tag mới không?
**Sửa:** Thêm tags mới vào BITMAP index hoặc nới lỏng tags trong `ClassifierOutput`.
## Canifa Stock API timeout
**Triệu chứng:** Response chậm > 5 giây, log báo `stock_check_timeout`.
**Nguyên nhân:** Canifa API không phản hồi kịp (server load cao, mạng chậm).
**Sửa:**
- Tăng timeout từ 2s → 3s (chỉ khi cần)
- Implement cache stock: cache 5 phút, serve from cache nếu API chậm
- Fallback: Bỏ qua stock check, gửi SP mà không confirm tồn kho (kèm disclaimer)
## Trùng lặp sản phẩm giữa 2 Lane
**Triệu chứng:** Stylist nhận 6 SP nhưng thực ra chỉ có 3 SP (mỗi SP xuất hiện 2 lần từ 2 Lane).
**Nguyên nhân:** Dedup bước gộp bị lỗi hoặc không chạy.
**Sửa:** Kiểm tra hàm merge results — dedup PHẢI theo `sku` (unique key), không dedup theo `product_name` (có thể trùng tên nhưng khác SKU).
## Tags mapping sai
**Triệu chứng:** Khách hỏi "áo đi biển" nhưng AI trả về áo mùa đông.
**Nguyên nhân:** Classifier map "đi biển" → tag sai (VD: `wthr:mua_dong` thay vì `occ:di_bien`).
**Sửa:**
1. Mở `prompts.py``CLASSIFIER_SYSTEM_PROMPT`
2. Thêm ví dụ mapping rõ ràng cho Classifier
3. Bổ sung tag mới nếu chưa có: `occ:di_bien`
## Upsell quá aggressive (toàn show đồ đắt)
**Triệu chứng:** Khách budget 300k nhưng AI chỉ giới thiệu đồ 600k+.
**Nguyên nhân:** Tầng 1-4 không có kết quả → nhảy thẳng tầng 5-7 (Upsell).
**Sửa:**
1. Kiểm tra catalog: Có SP nào thực sự dưới 300k không? Nếu không → đúng behavior
2. Nếu có mà không tìm thấy → kiểm tra tags + BITMAP index
3. Chỉnh hệ số Upsell: 1.5x → 1.2x nếu muốn nhẹ hơn
{
"id": "08c-lead-split-query",
"title": "Split Query & Cascading Search",
"description": "Cơ chế tách truy vấn làm 2 lane (Literal vs Inferred) và thuật toán rớt mạng 7 tầng (Cascading Search) với nghiệp vụ Upsell tự động.",
"diagram": "data/08c-lead-split-query/split_query_arch.svg",
"sections": [
{ "id": "sec0", "title": "1. Tổng quan Split Query", "type": "markdown", "file": "01_overview.md" },
{ "id": "sec1", "title": "2. Lane 1: Literal Search", "type": "markdown", "file": "02_literal.md" },
{ "id": "sec2", "title": "3. Lane 2: Inferred Search", "type": "markdown", "file": "03_inferred.md" },
{ "id": "sec3", "title": "4. Cascading Search 7 Tầng", "type": "markdown", "file": "04_cascading.md" },
{ "id": "sec4", "title": "5. Enrichment (Stock + Outfit)", "type": "markdown", "file": "05_enrichment.md" },
{ "id": "sec5", "title": "6. Troubleshooting & Gotchas", "type": "markdown", "file": "06_troubleshooting.md" }
]
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 700" width="960" height="700">
<defs>
<marker id="arrowBlue" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb"/>
</marker>
<marker id="arrowPurple" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#7c3aed"/>
</marker>
<marker id="arrowGreen" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#059669"/>
</marker>
<marker id="arrowOrange" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#ea580c"/>
</marker>
<marker id="arrowRed" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#dc2626"/>
</marker>
<filter id="shadow">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000" flood-opacity="0.08"/>
</filter>
</defs>
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.title { font-size: 20px; font-weight: 700; fill: #1e293b; }
.subtitle { font-size: 12px; fill: #64748b; }
.section-title { font-size: 14px; font-weight: 700; fill: #1e293b; }
.node-label { font-size: 13px; font-weight: 600; fill: #ffffff; }
.node-sub { font-size: 10px; fill: rgba(255,255,255,0.8); }
.tier-label { font-size: 12px; font-weight: 600; fill: #1e293b; }
.tier-desc { font-size: 10px; fill: #64748b; }
.tag { font-size: 9px; font-weight: 600; fill: #ffffff; letter-spacing: 0.05em; }
.arrow-label { font-size: 10px; fill: #64748b; }
.code-text { font-size: 10px; font-family: 'SF Mono', monospace; fill: #64748b; }
</style>
<!-- Background -->
<rect width="960" height="700" fill="#ffffff"/>
<!-- Title -->
<text x="480" y="36" text-anchor="middle" class="title">Split Query &amp; Cascading Search (7 Tầng)</text>
<text x="480" y="54" text-anchor="middle" class="subtitle">Tách truy vấn thành 2 Lane song song, lọc qua 7 tầng nới lỏng dần</text>
<!-- ============ SPLIT QUERY SECTION ============ -->
<!-- User Query box -->
<rect x="360" y="76" width="240" height="48" rx="8" fill="#1e293b" filter="url(#shadow)"/>
<text x="480" y="98" text-anchor="middle" class="node-label">Câu hỏi khách hàng</text>
<text x="480" y="114" text-anchor="middle" class="node-sub">"Áo khoác nam, form rộng, &lt;500k"</text>
<!-- Split arrows -->
<path d="M 420,124 L 200,160" stroke="#2563eb" stroke-width="1.5" fill="none" marker-end="url(#arrowBlue)"/>
<path d="M 540,124 L 720,160" stroke="#7c3aed" stroke-width="1.5" fill="none" marker-end="url(#arrowPurple)"/>
<!-- Lane 1: Literal -->
<rect x="60" y="160" width="280" height="80" rx="8" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5" filter="url(#shadow)"/>
<rect x="60" y="160" width="280" height="24" rx="8" fill="#2563eb"/>
<text x="200" y="177" text-anchor="middle" class="tag">LANE 1 — LITERAL SEARCH</text>
<text x="76" y="204" class="tier-label">raw_text: nguyên văn câu hỏi</text>
<text x="76" y="220" class="tier-desc">Full-text search bắt từ đặc thù (Sapa, bụng bia)</text>
<!-- Lane 2: Inferred -->
<rect x="580" y="160" width="320" height="80" rx="8" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1.5" filter="url(#shadow)"/>
<rect x="580" y="160" width="320" height="24" rx="8" fill="#7c3aed"/>
<text x="740" y="177" text-anchor="middle" class="tag">LANE 2 — INFERRED SEARCH</text>
<text x="596" y="204" class="tier-label">product_line, gender, tags, price_max</text>
<text x="596" y="220" class="tier-desc">Suy luận cấu trúc: wthr:mua_dong, fit:oversize</text>
<!-- Merge arrows into Engine -->
<path d="M 200,240 L 380,270" stroke="#2563eb" stroke-width="1.5" fill="none" marker-end="url(#arrowBlue)"/>
<path d="M 740,240 L 580,270" stroke="#7c3aed" stroke-width="1.5" fill="none" marker-end="url(#arrowPurple)"/>
<!-- ProductSearchEngine -->
<rect x="340" y="268" width="280" height="48" rx="8" fill="#059669" filter="url(#shadow)"/>
<text x="480" y="290" text-anchor="middle" class="node-label">ProductSearchEngine</text>
<text x="480" y="306" text-anchor="middle" class="node-sub">Cascading Search + Stock + Outfit</text>
<!-- ============ 7 TIERS ============ -->
<text x="480" y="346" text-anchor="middle" class="section-title">7 Tầng Rớt Mạng (Cascading Search)</text>
<!-- Tier 1 -->
<rect x="60" y="360" width="400" height="36" rx="6" fill="#f0fdf4" stroke="#86efac" stroke-width="1"/>
<rect x="60" y="360" width="30" height="36" rx="6" fill="#16a34a"/>
<text x="75" y="383" text-anchor="middle" class="tag">1</text>
<text x="106" y="378" class="tier-label">Exact Match</text>
<text x="106" y="392" class="tier-desc">Khớp 100% tags + giá + giới tính</text>
<rect x="410" y="364" width="48" height="18" rx="4" fill="#dcfce7"/>
<text x="434" y="377" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#16a34a">Chính xác</text>
<!-- Tier 2 -->
<rect x="60" y="400" width="400" height="36" rx="6" fill="#f0fdf4" stroke="#86efac" stroke-width="1"/>
<rect x="60" y="400" width="30" height="36" rx="6" fill="#16a34a"/>
<text x="75" y="423" text-anchor="middle" class="tag">2</text>
<text x="106" y="418" class="tier-label">NGRAM + Bitmap</text>
<text x="106" y="432" class="tier-desc">Full-text search + Bitmap Index trên tags</text>
<rect x="410" y="404" width="48" height="18" rx="4" fill="#dcfce7"/>
<text x="434" y="417" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#16a34a">Chính xác</text>
<!-- Tier 3 -->
<rect x="60" y="440" width="400" height="36" rx="6" fill="#eff6ff" stroke="#93c5fd" stroke-width="1"/>
<rect x="60" y="440" width="30" height="36" rx="6" fill="#2563eb"/>
<text x="75" y="463" text-anchor="middle" class="tag">3</text>
<text x="106" y="458" class="tier-label">Drop Tags</text>
<text x="106" y="472" class="tier-desc">Bỏ tags chi tiết, giữ loại SP + giá</text>
<rect x="410" y="444" width="48" height="18" rx="4" fill="#dbeafe"/>
<text x="434" y="457" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#2563eb">Nới lỏng</text>
<!-- Tier 4 -->
<rect x="60" y="480" width="400" height="36" rx="6" fill="#eff6ff" stroke="#93c5fd" stroke-width="1"/>
<rect x="60" y="480" width="30" height="36" rx="6" fill="#2563eb"/>
<text x="75" y="503" text-anchor="middle" class="tag">4</text>
<text x="106" y="498" class="tier-label">Drop Gender</text>
<text x="106" y="512" class="tier-desc">Nới lỏng giới tính, bốc Unisex</text>
<rect x="410" y="484" width="48" height="18" rx="4" fill="#dbeafe"/>
<text x="434" y="497" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#2563eb">Nới rộng</text>
<!-- Tier 5 -->
<rect x="60" y="520" width="400" height="36" rx="6" fill="#fffbeb" stroke="#fde68a" stroke-width="1"/>
<rect x="60" y="520" width="30" height="36" rx="6" fill="#d97706"/>
<text x="75" y="543" text-anchor="middle" class="tag">5</text>
<text x="106" y="538" class="tier-label">Upsell 1.5x</text>
<text x="106" y="552" class="tier-desc">Nhân price_max lên 1.5 lần</text>
<rect x="410" y="524" width="38" height="18" rx="4" fill="#fef3c7"/>
<text x="429" y="537" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#d97706">Upsell</text>
<!-- Tier 6 -->
<rect x="60" y="560" width="400" height="36" rx="6" fill="#fffbeb" stroke="#fde68a" stroke-width="1"/>
<rect x="60" y="560" width="30" height="36" rx="6" fill="#d97706"/>
<text x="75" y="583" text-anchor="middle" class="tag">6</text>
<text x="106" y="578" class="tier-label">Upsell 2x</text>
<text x="106" y="592" class="tier-desc">Nhân price_max lên 2.0 lần</text>
<rect x="410" y="564" width="38" height="18" rx="4" fill="#fef3c7"/>
<text x="429" y="577" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#d97706">Upsell</text>
<!-- Tier 7 -->
<rect x="60" y="600" width="400" height="36" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
<rect x="60" y="600" width="30" height="36" rx="6" fill="#dc2626"/>
<text x="75" y="623" text-anchor="middle" class="tag">7</text>
<text x="106" y="618" class="tier-label">Drop Price (Hail Mary)</text>
<text x="106" y="632" class="tier-desc">Vứt luôn bộ lọc giá — thà chê đắt hơn báo hết hàng</text>
<rect x="410" y="604" width="52" height="18" rx="4" fill="#fee2e2"/>
<text x="436" y="617" text-anchor="middle" style="font-size:9px;font-weight:600;fill:#dc2626">Hail Mary</text>
<!-- Cascade arrows (right side) -->
<line x1="470" y1="396" x2="470" y2="400" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,2"/>
<line x1="470" y1="436" x2="470" y2="440" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,2"/>
<line x1="470" y1="476" x2="470" y2="480" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,2"/>
<line x1="470" y1="516" x2="470" y2="520" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,2"/>
<line x1="470" y1="556" x2="470" y2="560" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,2"/>
<line x1="470" y1="596" x2="470" y2="600" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,2"/>
<!-- ============ ENRICHMENT SECTION ============ -->
<rect x="520" y="380" width="380" height="140" rx="8" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<rect x="520" y="380" width="380" height="26" rx="8" fill="#1e293b"/>
<text x="710" y="398" text-anchor="middle" class="tag">ENRICHMENT (SAU KHI TÌM ĐỦ SP)</text>
<rect x="540" y="420" width="160" height="40" rx="6" fill="#eff6ff" stroke="#93c5fd" stroke-width="1"/>
<text x="620" y="438" text-anchor="middle" class="tier-label">Check Stock</text>
<text x="620" y="452" text-anchor="middle" class="tier-desc">Canifa Stock API</text>
<rect x="720" y="420" width="160" height="40" rx="6" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1"/>
<text x="800" y="438" text-anchor="middle" class="tier-label">Outfit Context</text>
<text x="800" y="452" text-anchor="middle" class="tier-desc">SQLite ai_matches</text>
<text x="710" y="498" text-anchor="middle" class="tier-desc">Vứt SP hết hàng + Bơm gợi ý phối đồ → Truyền cho Stylist</text>
<!-- Legend -->
<rect x="520" y="540" width="380" height="96" rx="8" fill="#f8fafc" stroke="#e2e8f0" stroke-width="1"/>
<text x="536" y="562" class="section-title">Legend</text>
<rect x="536" y="572" width="12" height="12" rx="2" fill="#16a34a"/>
<text x="556" y="582" class="tier-desc">Tầng Chính xác (tìm đúng ý khách)</text>
<rect x="536" y="592" width="12" height="12" rx="2" fill="#2563eb"/>
<text x="556" y="602" class="tier-desc">Tầng Nới lỏng (mở rộng tập kết quả)</text>
<rect x="536" y="612" width="12" height="12" rx="2" fill="#d97706"/>
<text x="556" y="622" class="tier-desc">Tầng Upsell (dụ khách rướn thêm tiền)</text>
<!-- Footer -->
<text x="480" y="690" text-anchor="middle" class="subtitle">File: backend/agent/lead_stage_agent/product_search_engine.py</text>
</svg>
# 1. File Map Tổng quan
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.
## 4 file cốt lõi
| File | Chức năng | Khi nào sửa |
|---|---|---|
| `graph.py` | Core: State, InsightJSON, ClassifierOutput, Tool Registry | Thêm tag, thêm insight field, thêm tool |
| `prompts.py` | Prompt: Classifier + Stylist system prompts | Đổi giọng, thêm guardrail, sửa ví dụ |
| `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 |
## Quy tắc vàng
1. **Sửa `graph.py`** → ảnh hưởng TOÀN BỘ luồng (Classifier + Tool + Stylist)
2. **Sửa `prompts.py`** → chỉ ảnh hưởng output text, không ảnh hưởng logic
3. **Sửa `product_search_engine.py`** → ảnh hưởng kết quả tìm kiếm
4. **Sửa `controller.py`** → ảnh hưởng API layer, hiếm khi cần sửa
## Thư mục
```
backend/agent/lead_stage_agent/
├── graph.py # Core logic
├── prompts.py # System prompts
├── product_search_engine.py # Search engine
├── controller.py # API controller
└── tools/ # Thư mục tool mở rộng
```
# 2. Sửa Tags & 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.
## Thêm Tag mới
| Bước | File | Hành động |
|---|---|---|
| 1 | `graph.py` | Thêm tag vào Enum trong class `InferredSearchArgs.tags` |
| 2 | StarRocks | Thêm BITMAP column tương ứng |
| 3 | `prompts.py` | Thêm mapping vào `CLASSIFIER_SYSTEM_PROMPT` |
```python
# graph.py — Thêm tag mới
class InferredSearchArgs(BaseModel):
tags: list[str] = Field(
description="VD: wthr:mua_dong, fit:oversize, occ:du_lich, "
"occ:di_dam_cuoi, ..." # ← Thêm tag mới ở đây
)
```
```python
# prompts.py — Thêm mapping
CLASSIFIER_SYSTEM_PROMPT = """
...
Mapping từ lóng → tags:
- "đi tiệc cưới", "dự đám cưới" → occ:di_dam_cuoi
- "đi biển", "đi hè" → occ:di_bien
...
"""
```
## Thêm trường InsightJSON mới
```python
# graph.py — Thêm field
class InsightJSON(BaseModel):
# ... các field cũ ...
PET_OWNER: str = Field(default="Chưa rõ") # Field mới
SKIN_TONE: str = Field(default="Chưa rõ") # Field mới
```
**Lưu ý:** Sau khi thêm field mới, Stylist prompt cũng cần được cập nhật để biết field này tồn tại và cách dùng nó.
# 3. Sửa Giọng điệu 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á.
## File cần sửa
| Hành động | File | Vị trí |
|---|---|---|
| Đổi xưng hô | `prompts.py` | `STYLIST_SYSTEM_PROMPT` |
| Thêm guardrail | `prompts.py` | Thêm dòng CHỮ HOA vào prompt |
| Đổi tone theo Mode | `prompts.py` | Section tone_directive trong prompt |
## Ví dụ đổi xưng hô
```python
# prompts.py — Trước
STYLIST_SYSTEM_PROMPT = """
Bạn là Stylist tư vấn thời trang Canifa.
Xưng hô: "mình" và gọi khách "bạn".
"""
# prompts.py — Sau
STYLIST_SYSTEM_PROMPT = """
Bạn là Stylist Canifa chuyên nghiệp.
Xưng hô: "em" và gọi khách "anh/chị" hoặc "Quý khách".
TUYỆT ĐỐI KHÔNG xưng "mình" hoặc gọi khách "bạn".
"""
```
## Ví dụ thêm Guardrail
```python
# Thêm dòng guardrail (CHỮ HOA ĐỂ LLM CHÚ Ý)
STYLIST_SYSTEM_PROMPT = """
...
- TUYỆT ĐỐI KHÔNG BÁO GIẢM GIÁ NẾU KHÔNG CÓ TRONG DATA
- KHÔNG DÙNG EMOJI QUÁ 2 CÁI MỖI TIN NHẮN
- KHÔNG TỰ Ý HỨA GIAO HÀNG MIỄN PHÍ
- GIỚI HẠN TỐI ĐA 200 TỪ MỖI RESPONSE
...
"""
```
## Lưu ý quan trọng
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 AI. Sau khi sửa prompt, **luôn test lại** với User Simulator (50 test case) để đảm bảo không có side effect.
<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.
# 4. Đổi Logic Upsell
**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."
## File cần sửa
| Hành động | File | Vị trí |
|---|---|---|
| Chỉnh hệ số giá | `product_search_engine.py` | Hàm `_cascading_search()` |
| Thêm/bớt tầng | `product_search_engine.py` | List các tầng trong hàm trên |
| Đổi ngưỡng dừng | `product_search_engine.py` | Biến `MIN_RESULTS` (default = 3) |
## Chỉnh hệ số Upsell
```python
# Trước: Tầng 5 nhân 1.5x
tier_5_price = original_price_max * 1.5
tier_6_price = original_price_max * 2.0
# Sau: Giảm xuống 1.2x và 1.5x
tier_5_price = original_price_max * 1.2 # Nhẹ nhàng hơn
tier_6_price = original_price_max * 1.5 # Vẫn push nhưng ít hơn
```
## Thêm tầng mới
Ví dụ: Thêm tầng "Sale Priority" — ưu tiên đồ đang giảm giá:
```python
# Thêm tầng 3.5 (giữa Drop Tags và Drop Gender)
TIER_3_5 = {
"name": "Sale Priority",
"filter": "WHERE sale_priority = 1 AND product_line IN (...)",
"description": "Ưu tiên đồ đang có chương trình giảm giá"
}
```
## Bớt tầng
Nếu muốn bỏ Upsell hoàn toàn (VD: campaign "Giá thấp mỗi ngày"):
```python
# Bỏ tầng 5, 6, 7 — chỉ giữ tầng 1-4
CASCADING_TIERS = [TIER_1, TIER_2, TIER_3, TIER_4]
# Tầng 4 là cuối cùng → nếu không đủ SP → AI nói "Em chưa tìm thấy đúng ý anh"
```
## Đổi ngưỡng dừng
```python
# Trước: Dừng khi >= 3 SP
MIN_RESULTS = 3
# Sau: Muốn nhiều lựa chọn hơn
MIN_RESULTS = 5 # Sẽ cascade sâu hơn, nhưng latency tăng
```
# 5. Thêm Tool mới
**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 đồ.
## Quy trình 3 bước
### Bước 1: Viết logic tool
```python
# Tạo file: backend/agent/tools/store_locator.py
def check_store_location(sku: str, lat: float, lng: float) -> dict:
"""
Tìm cửa hàng Canifa gần nhất còn sản phẩm SKU này.
Returns: { "store_name": "...", "address": "...", "distance_km": 2.5 }
"""
stores = canifa_api.get_stores_with_stock(sku)
nearest = min(stores, key=lambda s: haversine(lat, lng, s.lat, s.lng))
return {
"store_name": nearest.name,
"address": nearest.address,
"distance_km": round(haversine(lat, lng, nearest.lat, nearest.lng), 1)
}
```
### Bước 2: Đăng ký tool vào ClassifierOutput
```python
# graph.py — Thêm tên tool vào description
class ClassifierOutput(BaseModel):
tool_name: str | None = Field(
description="Ten tool can goi. "
"lead_search_tool: tim san pham. "
"check_store_location: tim cua hang gan. " # ← MỚI
"Null neu khach chi dang tam su."
)
```
### Bước 3: Kết nối tool vào registry
```python
# graph.py — Thêm vào tool registry
self._tool_registry = {
"lead_search_tool": self._lead_search_tool,
"check_store_location": check_store_location, # ← MỚI
}
```
## Lưu ý quan trọng
- Bất kỳ sửa đổi nào ở **Classifier** (đầu vào) sẽ ảnh hưởng toàn bộ luồng phía sau
- Tool mới cần có **timeout** (max 3s) và **fallback** nếu API fail
- Sau khi thêm tool, cập nhật `CLASSIFIER_SYSTEM_PROMPT` để AI biết **khi nào** nên gọi tool mới
# 6. Checklist sau khi sửa
Mỗi lần sửa bất kỳ file nào trong Lead Agent, **bắt buộc** chạy qua checklist này:
## Pre-deploy Checklist
| # | Hành động | Tool | Bắt buộc |
|---|---|---|---|
| 1 | Chạy User Simulator 50 test case | `python simulator.py --count 50` | **Bắt buộc** |
| 2 | Kiểm tra Langfuse trace 10 request gần nhất | Langfuse Dashboard | **Bắt buộc** |
| 3 | Verify InsightJSON không bị null | Redis CLI: `GET lead_insight:*` | Bắt buộc nếu sửa graph.py |
| 4 | Verify Cascading Search trả kết quả | Log `[SEARCH]` | Bắt buộc nếu sửa engine |
| 5 | Test Early Exit (gửi "Hi") | Chat Widget | Bắt buộc nếu sửa prompts |
| 6 | Test Upsell (gửi budget thấp) | Chat Widget | Bắt buộc nếu sửa engine |
## Test Case bắt buộc (Smoke Test)
| # | Input | Expected Mode | Expected Behavior |
|---|---|---|---|
| 1 | "Hi" | Mode 1 (BROWSE) | Chào hỏi, KHÔNG search |
| 2 | "Tìm áo khoác" | Mode 2 (IDENTIFYING) | Hỏi thêm (giá, dịp, size) |
| 3 | "Áo khoác nam mùa đông dưới 500k" | Mode 3 (CONSIDERING) | Search + tư vấn SP |
| 4 | "Lấy anh cái đầu tiên" | Mode 4 (CLOSING) | Xin SĐT/Email |
| 5 | "SĐT 0912345678" | Mode 5 (RETENTION) | Cảm ơn + lưu DB |
| 6 | "Thôi để sau" | Mode 5 (RETENTION) | Mở đường lùi, KHÔNG chèo kéo |
| 7 | "Cho mình xem quần đi" | Mode 2 (IDENTIFYING) | Downgrade từ Mode 3 → 2 |
| 8 | "Đắt quá" (2 lần) | Mode 3 | DỪNG Upsell, gợi ý đồ rẻ hơn |
## Rollback Plan
Nếu sau deploy phát hiện lỗi:
1. **Sửa prompt:** Revert file `prompts.py` về commit trước → restart server
2. **Sửa logic:** Revert `graph.py` hoặc `product_search_engine.py` → restart
3. **Sửa data:** Nếu lỗi do tag mới → xoá BITMAP column trong StarRocks
```bash
# Rollback nhanh
git checkout HEAD~1 -- backend/agent/lead_stage_agent/prompts.py
# Restart
systemctl restart canifa-backend
```
{
"id": "08d-lead-guide",
"title": "Modification Guide",
"description": "Hướng dẫn cho Developer khi cần thay đổi nghiệp vụ: thêm Tags, đổi Tone AI, chỉnh thuật toán Upsell, hoặc gắn thêm Tool mới.",
"diagram": "data/08d-lead-guide/modification_guide.svg",
"sections": [
{ "id": "sec0", "title": "1. File Map Tổng quan", "type": "markdown", "file": "01_filemap.md" },
{ "id": "sec1", "title": "2. Sửa Tags & Insights", "type": "markdown", "file": "02_tags.md" },
{ "id": "sec2", "title": "3. Sửa Giọng điệu AI", "type": "markdown", "file": "03_tone.md" },
{ "id": "sec3", "title": "4. Đổi Logic Upsell", "type": "markdown", "file": "04_upsell.md" },
{ "id": "sec4", "title": "5. Thêm Tool mới", "type": "markdown", "file": "05_new_tool.md" },
{ "id": "sec5", "title": "6. Checklist sau khi sửa", "type": "markdown", "file": "06_checklist.md" }
]
}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 480" width="960" height="480">
<defs>
<marker id="arrowBlue" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb"/>
</marker>
<marker id="arrowDash" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#94a3b8"/>
</marker>
<filter id="shadow">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000" flood-opacity="0.08"/>
</filter>
</defs>
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
.title { font-size: 20px; font-weight: 700; fill: #1e293b; }
.subtitle { font-size: 12px; fill: #64748b; }
.node-label { font-size: 13px; font-weight: 700; fill: #ffffff; }
.node-sub { font-size: 10px; fill: rgba(255,255,255,0.8); }
.card-title { font-size: 12px; font-weight: 700; fill: #1e293b; }
.card-text { font-size: 10px; fill: #475569; }
.tag { font-size: 9px; font-weight: 600; fill: #ffffff; letter-spacing: 0.05em; }
.code-text { font-size: 10px; font-family: 'SF Mono', monospace; fill: #64748b; }
</style>
<!-- Background -->
<rect width="960" height="480" fill="#ffffff"/>
<!-- Title -->
<text x="480" y="36" text-anchor="middle" class="title">Modification Guide — File Map</text>
<text x="480" y="54" text-anchor="middle" class="subtitle">4 loại thay đổi nghiệp vụ phổ biến và file cần sửa tương ứng</text>
<!-- ============ CORE FILES (CENTER) ============ -->
<!-- graph.py -->
<rect x="360" y="100" width="240" height="56" rx="8" fill="#0284c7" filter="url(#shadow)"/>
<text x="480" y="126" text-anchor="middle" class="node-label">graph.py</text>
<text x="480" y="142" text-anchor="middle" class="node-sub">Core: State, InsightJSON, ClassifierOutput, Tool Registry</text>
<!-- prompts.py -->
<rect x="60" y="200" width="220" height="48" rx="8" fill="#7c3aed" filter="url(#shadow)"/>
<text x="170" y="222" text-anchor="middle" class="node-label">prompts.py</text>
<text x="170" y="238" text-anchor="middle" class="node-sub">Tone, Guardrails, System Prompts</text>
<!-- product_search_engine.py -->
<rect x="370" y="200" width="220" height="48" rx="8" fill="#059669" filter="url(#shadow)"/>
<text x="480" y="222" text-anchor="middle" class="node-label">product_search_engine.py</text>
<text x="480" y="238" text-anchor="middle" class="node-sub">Cascading Search, Enrichment</text>
<!-- tools/ -->
<rect x="680" y="200" width="220" height="48" rx="8" fill="#d97706" filter="url(#shadow)"/>
<text x="790" y="222" text-anchor="middle" class="node-label">tools/</text>
<text x="790" y="238" text-anchor="middle" class="node-sub">Mở rộng Tool mới</text>
<!-- Arrows from graph.py to others -->
<path d="M 420,156 L 220,200" stroke="#2563eb" stroke-width="1.5" fill="none" marker-end="url(#arrowBlue)"/>
<line x1="480" y1="156" x2="480" y2="198" stroke="#2563eb" stroke-width="1.5" marker-end="url(#arrowBlue)"/>
<path d="M 540,156 L 740,200" stroke="#2563eb" stroke-width="1.5" fill="none" marker-end="url(#arrowBlue)"/>
<!-- Return arrows (dashed) -->
<path d="M 200,248 L 400,156" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4,3" fill="none" marker-end="url(#arrowDash)"/>
<path d="M 770,248 L 560,156" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4,3" fill="none" marker-end="url(#arrowDash)"/>
<!-- ============ USE CASE CARDS ============ -->
<!-- Card 1: Tags -->
<rect x="40" y="290" width="200" height="100" rx="6" fill="#f0f9ff" stroke="#93c5fd" stroke-width="1"/>
<rect x="40" y="290" width="200" height="24" rx="6" fill="#0284c7"/>
<text x="52" y="307" class="tag">THÊM TAGS &amp; INSIGHTS</text>
<text x="56" y="330" class="card-title">File: graph.py</text>
<text x="56" y="346" class="card-text">Class InferredSearchArgs</text>
<text x="56" y="360" class="card-text">Class InsightJSON</text>
<text x="56" y="378" class="code-text">VD: thêm occ:di_dam_cuoi</text>
<!-- Card 2: Tone -->
<rect x="260" y="290" width="200" height="100" rx="6" fill="#f5f3ff" stroke="#c4b5fd" stroke-width="1"/>
<rect x="260" y="290" width="200" height="24" rx="6" fill="#7c3aed"/>
<text x="272" y="307" class="tag">ĐỔI GIỌNG ĐIỆU AI</text>
<text x="276" y="330" class="card-title">File: prompts.py</text>
<text x="276" y="346" class="card-text">STYLIST_SYSTEM_PROMPT</text>
<text x="276" y="360" class="card-text">Thêm guardrail CHỮ HOA</text>
<text x="276" y="378" class="code-text">VD: CẤM BÁO GIẢM GIÁ</text>
<!-- Card 3: Upsell -->
<rect x="480" y="290" width="200" height="100" rx="6" fill="#f0fdf4" stroke="#86efac" stroke-width="1"/>
<rect x="480" y="290" width="200" height="24" rx="6" fill="#059669"/>
<text x="492" y="307" class="tag">CHỈNH LOGIC UPSELL</text>
<text x="496" y="330" class="card-title">File: product_search_engine.py</text>
<text x="496" y="346" class="card-text">Hàm _cascading_search()</text>
<text x="496" y="360" class="card-text">Chỉnh hệ số 1.5x / 2x</text>
<text x="496" y="378" class="code-text">VD: 1.5x → 1.2x</text>
<!-- Card 4: Tool -->
<rect x="700" y="290" width="220" height="100" rx="6" fill="#fffbeb" stroke="#fde68a" stroke-width="1"/>
<rect x="700" y="290" width="220" height="24" rx="6" fill="#d97706"/>
<text x="712" y="307" class="tag">THÊM TOOL MỚI</text>
<text x="716" y="330" class="card-title">File: tools/ + graph.py</text>
<text x="716" y="346" class="card-text">1. Tạo file tool mới</text>
<text x="716" y="360" class="card-text">2. Đăng ký vào ClassifierOutput</text>
<text x="716" y="378" class="code-text">VD: check_store_location</text>
<!-- Warning box -->
<rect x="40" y="410" width="880" height="40" rx="6" fill="#fffbeb" stroke="#fde68a" stroke-width="1"/>
<text x="480" y="428" text-anchor="middle" class="card-title" style="fill:#92400e">⚠ Sau mỗi thay đổi: chạy User Simulator bắn 50 test case để kiểm tra AI không bị lỗi hành vi</text>
<text x="480" y="442" text-anchor="middle" class="code-text">File: backend/agent/lead_stage_agent/ — Toàn bộ file nằm trong thư mục này</text>
<!-- Footer -->
<text x="480" y="472" text-anchor="middle" class="subtitle">Xem thêm: controller.py (Redis TTL, Langfuse trace) | Không sửa controller trừ khi đổi API route</text>
</svg>
......@@ -17,6 +17,15 @@
{ "id": "04-feedback-diagram", "title": "Feedback & Diagram Agents" }
]
},
{
"name": "Lead Agent (Sales Funnel)",
"recipes": [
{ "id": "08a-lead-business", "title": "Journey Shopping (5 Modes)" },
{ "id": "08b-lead-dual-agent", "title": "Kiến trúc Dual-Agent Flow" },
{ "id": "08c-lead-split-query", "title": "Split Query & Cascading Search" },
{ "id": "08d-lead-guide", "title": "Modification Guide" }
]
},
{
"name": "Product & Fashion",
"recipes": [
......@@ -28,7 +37,6 @@
{
"name": "Search & Data Analyst",
"recipes": [
{ "id": "08-lead-search", "title": "Lead Search Agent (LangGraph)" },
{ "id": "09-image-search", "title": "AI Image Search" },
{ "id": "10-sku-store", "title": "SKU & Store Search" },
{ "id": "11-text-to-sql", "title": "Text-to-SQL & Data Analyst" }
......
......@@ -3,114 +3,353 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI Stylist - Lead Search Tool Architecture</title>
<title>Canifa AI - Lead Search Agent Documentation</title>
<style>
:root {
--primary: #0369a1;
--primary-light: #e0f2fe;
--secondary: #9d174d;
--secondary-light: #fce7f3;
--success: #166534;
--success-light: #dcfce7;
--bg: #f9fafb;
--border: #e5e7eb;
--text-main: #111827;
--text-muted: #4b5563;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background-color: #fcfcfc;
color: var(--text-main);
background-color: var(--bg);
margin: 0;
padding: 0;
}
.container {
max-width: 900px;
margin: 40px auto;
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
h1 {
font-size: 2.2em;
color: #111;
border-bottom: 2px solid #eaeaea;
color: var(--text-main);
border-bottom: 2px solid var(--border);
padding-bottom: 10px;
margin-bottom: 30px;
}
h2 {
font-size: 1.5em;
color: #222;
color: var(--primary);
margin-top: 40px;
display: flex;
align-items: center;
}
h2::before {
content: "";
display: inline-block;
width: 4px;
height: 24px;
background: var(--primary);
margin-right: 12px;
border-radius: 2px;
}
h3 {
font-size: 1.2em;
color: var(--text-main);
margin-top: 25px;
}
p {
margin-bottom: 15px;
font-size: 1.05em;
color: var(--text-muted);
}
.highlight-box {
background-color: var(--primary-light);
border-left: 4px solid var(--primary);
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 8px 8px 0;
color: var(--text-main);
}
.highlight {
background-color: #f0f7ff;
border-left: 4px solid #0066cc;
padding: 15px;
.warning-box {
background-color: #fef3c7;
border-left: 4px solid #d97706;
padding: 15px 20px;
margin: 20px 0;
border-radius: 0 4px 4px 0;
border-radius: 0 8px 8px 0;
color: #92400e;
}
ul {
ul, ol {
padding-left: 20px;
color: var(--text-muted);
}
li {
margin-bottom: 10px;
}
code {
background-color: #f4f4f4;
background-color: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
color: #be185d;
}
pre {
background-color: #1e1e1e;
color: #d4d4d4;
background-color: #1f2937;
color: #e5e7eb;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
line-height: 1.5;
}
.diagram-container {
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
background: var(--bg);
margin: 30px 0;
text-align: center;
}
.diagram-container img {
max-width: 100%;
height: auto;
}
.nav-button {
display: inline-flex;
align-items: center;
padding: 8px 16px;
background: white;
color: var(--text-main);
text-decoration: none;
border-radius: 6px;
font-weight: 500;
font-size: 0.9em;
border: 1px solid var(--border);
margin-bottom: 20px;
transition: all 0.2s;
}
.nav-button:hover {
background: var(--bg);
}
/* Tabs styling */
.tabs {
display: flex;
flex-wrap: wrap;
border-bottom: 2px solid var(--border);
margin-bottom: 30px;
}
.tab-btn {
background: none;
border: none;
padding: 12px 24px;
font-size: 1.1em;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
outline: none;
position: relative;
}
.tab-btn:hover {
color: var(--primary);
}
.tab-btn.active {
color: var(--primary);
}
.tab-btn.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background-color: var(--primary);
}
.tab-content {
display: none;
animation: fadeIn 0.3s;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<a href="/static/main.html?page=lead_flow/lead-flow.html" style="display: inline-block; margin-bottom: 20px; padding: 8px 16px; background: #f0f0f0; color: #333; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 0.9em; border: 1px solid #ddd;">
&larr; Quay lại trang Lead Flow
<div class="container">
<a href="/static/main.html?page=lead_flow/lead-flow.html" class="nav-button">
&larr; Quay lại trang hệ thống Lead
</a>
<h1>Canifa AI Stylist - Lead Search Architecture</h1>
<p class="highlight"><strong>Bản vẽ chi tiết luồng hoạt động & Kiến trúc tối ưu Token (Cập nhật T4/2026)</strong></p>
<h2>1. Tổng quan (Overview)</h2>
<p>Lead Search Tool là trung tâm truy xuất dữ liệu sản phẩm của Canifa AI Stylist. Sau đợt tái cấu trúc, hệ thống đã loại bỏ hoàn toàn OpenAI Embedding, thay thế bằng cơ chế <strong>Cascading Search 7 tầng</strong> (NGRAMBF + BITMAP Tags), mang lại tốc độ truy vấn &lt; 300ms. Đồng thời, cấu trúc dữ liệu trả về được tối ưu hóa để giảm thiểu Token tiêu thụ cho LLM.</p>
<h2>2. Kiến trúc Tối ưu Token (Token-Saving Architecture)</h2>
<p>Thay vì trả về 15-20 sản phẩm chi tiết (gây tràn Context Window), hệ thống hiện tại sử dụng chiến lược: <strong>"Lấy ít sản phẩm chính, móc nhiều sản phẩm gợi ý"</strong>.</p>
<ul>
<li><strong>Main Products (Top 3):</strong> Trả về tối đa 3 sản phẩm match nhất với đầy đủ thông tin chi tiết (Size, Giá, Mô tả ngắn &lt;200 ký tự, Tồn kho thực tế, Lý do phối đồ AI).</li>
<li><strong>Suggest Items (Phối cùng):</strong> Cho mỗi sản phẩm chính, lấy thêm tối đa 2 sản phẩm phối cùng (chỉ gồm SKU, Tên, Giá).</li>
<li><strong>Similar Items (Tương tự):</strong> Cho mỗi sản phẩm chính, lấy thêm tối đa 3 sản phẩm thay thế (chỉ gồm SKU, Tên, Giá).</li>
</ul>
<p class="highlight"><em>Kết quả: LLM vẫn có 3 lựa chọn chính và 15 lựa chọn phụ để tư vấn, nhưng số lượng Token giảm xuống 75%.</em></p>
<h2>3. Luồng dữ liệu (Data Flow)</h2>
<ol>
<li><strong>Phân tích Ý định (Input parsing):</strong> Nhận các tham số từ Agent: keywords, tags, color, size, price_min, price_max.</li>
<li><strong>Cascading Search (7 Tiers):</strong> Truy xuất StarRocks qua 7 tầng rớt mạng. Tầng 1 (Keywords + Hard Filters), Tầng 2 (Tags + Filters), Tầng 5-6 (Nới giá 1.5x - 2.0x).</li>
<li><strong>Lọc Tồn Kho (Stock API):</strong> Gửi các mã sản phẩm sang Canifa Stock API qua httpx. Chỉ giữ lại những mã đang CÒN HÀNG, kèm theo danh sách chi tiết các size còn trống.</li>
<li><strong>Bổ sung Styling (SQLite 123.db):</strong> Đọc file SQLite local để lấy <code>ai_description</code><code>ai_matches</code> (lý do phối đồ), cung cấp 'bộ não' thời trang cho LLM chốt sale.</li>
<li><strong>Định dạng Đầu ra (Output Formatter):</strong> Gom nhóm dữ liệu, cắt ngắn mô tả, fetch suggest/similar items và trả về JSON nhẹ nhất cho AI.</li>
</ol>
<h2>4. Đầu ra của hệ thống (JSON Format)</h2>
<p>Dưới đây là cấu trúc minh hoạ cho 1 sản phẩm chính sau khi được format gọn nhẹ:</p>
<pre><code>{
"sku": "8TP24S001-SB060",
"name": "Áo polo nam phom regular",
"price": 349000,
"in_stock": true,
"stock": [{"size": "M", "qty": 10}, {"size": "L", "qty": 14}],
"ai_matches": {
"phoi_voi": ["Quần khaki nam"],
"ly_do": "Màu áo hợp quần khaki tạo vẻ thanh lịch..."
},
"suggest_items": [
{"sku": "8QK24S002", "name": "Quần khaki nam", "price": 450000}
],
"similar_items": [
{"sku": "8TP24S003", "name": "Áo polo sọc", "price": 399000}
]
}</code></pre>
<h1>Lead Search Agent - Tài Liệu Core</h1>
<p>Tài liệu chuẩn (Single Source of Truth) mô tả toàn bộ kiến trúc <strong>Dual-Agent</strong> và thuật toán <strong>Tách truy vấn làm 2 (Split Query Logic)</strong>.</p>
<div class="tabs">
<button class="tab-btn active" onclick="openTab('tab-journey')">1. Business Logic (Journey Map)</button>
<button class="tab-btn" onclick="openTab('tab-flow')">2. Agent Flowchart</button>
<button class="tab-btn" onclick="openTab('tab-logic')">3. Data Extraction Tool (Logic)</button>
<button class="tab-btn" onclick="openTab('tab-guide')">4. Modification Guide</button>
</div>
<!-- TAB 1: JOURNEY MAP -->
<div id="tab-journey" class="tab-content active">
<h2>Tại sao cần Quy trình Lead? (Business Context)</h2>
<p>Lead Stage Agent đóng vai trò như một <strong>Nhân viên tư vấn (Stylist) thực thụ</strong>. Thay vì nhận 1 câu tìm kiếm rồi quăng ra 1 đống sản phẩm (như Search Box truyền thống), hệ thống Lead được thiết kế để:</p>
<ul>
<li><strong>Thu thập Context (Gom nhu cầu):</strong> Tìm hiểu khách mua cho ai? (nam/nữ/trẻ em), mặc dịp gì? (đi tiệc, mặc nhà, đi chơi), mức giá mong muốn?</li>
<li><strong>Tránh tư vấn lan man:</strong> Nếu không gom đủ Context, AI sẽ tư vấn sai, dẫn tới rớt khách.</li>
<li><strong>Chốt Sales (Lấy thông tin):</strong> Mục tiêu tối thượng của quá trình là chốt được số điện thoại/email để tạo Lead cho hệ thống Telesale/Marketing.</li>
</ul>
<h3>Lead Journey Map (Hành trình khách hàng)</h3>
<p>Quá trình tương tác của một User khi vào Lead Flow trải qua 4 bước (Stage):</p>
<ol>
<li><strong>BROWSE (Khám phá):</strong> Khách vừa vào, lướt dạo. AI trò chuyện nhẹ nhàng, khai thác nhu cầu cơ bản.</li>
<li><strong>IDENTIFYING (Xác định nhu cầu):</strong> Khách bắt đầu nói về món đồ muốn mua (VD: "Mình muốn tìm váy"). AI sẽ gạn hỏi thêm về màu sắc, size, budget để điền đầy đủ vào bộ nhớ <code>InsightJSON</code>.</li>
<li><strong>CONSIDERING (Cân nhắc):</strong> AI gọi Tool tìm kiếm (ProductSearchEngine) để đưa ra tối đa 3 lựa chọn xuất sắc nhất. Khách xem xét, hỏi thêm về chất liệu, cách phối đồ. (Ở bước này, AI sẽ được cung cấp "lý do phối đồ" từ SQLite để thuyết phục khách).</li>
<li><strong>CLOSING (Chốt):</strong> Khi khách chốt mã/thích sản phẩm, AI sẽ xin thông tin liên hệ (SĐT, Tên) hoặc hướng dẫn thêm vào giỏ hàng.</li>
</ol>
<div class="highlight-box">
<strong>Bí quyết của hệ thống:</strong> AI không bị "amnesia" (quên) vì toàn bộ thông tin được cập nhật liên tục vào đối tượng <code>InsightJSON</code> và lưu xuống Redis.
</div>
</div>
<!-- TAB 2: FLOWCHART -->
<div id="tab-flow" class="tab-content">
<h2>Kiến trúc Dual-Agent (2 AI Models)</h2>
<p>Đội ngũ đã thiết kế kiến trúc 2 Agent để giải quyết bài toán: Gọi Tool thì phải chuẩn xác (Cần AI thông minh nhưng nhanh), Trả lời thì phải hay và cá nhân hóa (Cần AI văn hay chữ tốt).</p>
<div class="diagram-container">
<img src="lead_search_diagram.svg" alt="Lead Search Flowchart Diagram">
</div>
<h3>Nhiệm vụ của từng Agent</h3>
<ul>
<li><strong>1. Classifier Agent (AI Nhẹ):</strong> Chỉ làm nhiệm vụ Đọc tin nhắn -> Định tuyến (Route).
<ul>
<li>Nếu khách chỉ chào hỏi (Small talk) -> Kích hoạt <strong>Early Exit</strong>, không gọi Tool, pass thẳng cho Stylist.</li>
<li>Nếu khách tìm đồ -> Kích hoạt <strong>Split Query Logic</strong> (Tách câu nói làm 2 phần: Nguyên văn và Suy luận), truyền vào Tool lấy dữ liệu.</li>
</ul>
</li>
<li><strong>2. Stylist Agent (AI Nặng):</strong> Nhận kết quả từ Tool (hoặc không có tool), kết hợp với Lịch sử chat (History) và Bộ nhớ (InsightJSON). Từ đó:
<ul>
<li>Sinh ra câu trả lời tự nhiên (Draft AI Reply).</li>
<li>Cập nhật lại trạng thái khách hàng (Update Insight) vào file JSON và lưu lại.</li>
</ul>
</li>
</ul>
</div>
<!-- TAB 3: DATA EXTRACTION -->
<div id="tab-logic" class="tab-content">
<h2>Data Extraction Tool & Logic Tách Truy Vấn</h2>
<p>Công cụ cốt lõi là <code>ProductSearchEngine</code>. Điểm thông minh nhất của nó là cơ chế nhận 2 luồng dữ liệu do Classifier Agent đẩy vào (Dual-Lane Input).</p>
<h3>1. Cơ chế Tách truy vấn làm 2 (Split Query Logic)</h3>
<p>Ví dụ khách nói: <em>"Mình cần tìm cái áo khoác nam mùa đông, form rộng, khoảng dưới 500k"</em></p>
<div class="highlight-box">
<ul>
<li><strong>Lane 1: Literal Search (Nguyên văn)</strong><br>
<code>raw_text: "áo khoác nam mùa đông form rộng dưới 500k"</code><br>
(Được dùng để search LIKE thuần trong trường hợp khách gõ sai chính tả hoặc cần tìm chính xác).
</li>
<li><strong>Lane 2: Inferred Search (Suy luận có cấu trúc)</strong><br>
<code>product_line_vn: ["Áo khoác"]</code><br>
<code>gender_by_product: "men"</code><br>
<code>tags: ["wthr:mua_dong", "fit:oversize"]</code><br>
<code>price_max: 500000</code><br>
(AI đã tự động mapping "mùa đông" thành thẻ chuẩn <code>wthr:mua_dong</code>, "form rộng" thành <code>fit:oversize</code>).
</li>
</ul>
</div>
<h3>2. Cascading Search (Thuật toán tìm kiếm rớt 7 tầng)</h3>
<p>Để đảm bảo AI luôn có sản phẩm trả về cho khách (không bao giờ báo "Tôi không tìm thấy"), Tool áp dụng cơ chế nới lỏng bộ lọc dần (Cascading):</p>
<ol>
<li><strong>Tầng 1:</strong> Tìm chính xác Filters (Giá, Giới tính...) + Keywords (NgramBF).</li>
<li><strong>Tầng 2:</strong> Tìm chính xác Filters + Tags (Các tag chuẩn hóa như <code>style:nang_dong</code> được quét BITMAP cực nhanh).</li>
<li><strong>Tầng 3:</strong> Chỉ dùng Filters cố định (Giá, Dòng SP, Giới tính).</li>
<li><strong>Tầng 4 (Drop Gender):</strong> Nới lỏng Giới tính (Biết đâu đồ Unisex hoặc nữ bận đồ nam cũng được).</li>
<li><strong>Tầng 5 (Price Relaxation 1.5x):</strong> Kích giá lên 1.5 lần (Khách tìm áo 500k không có, tool tìm áo 750k và <em>báo AI dụ dỗ khách mua vì áo đẹp</em>).</li>
<li><strong>Tầng 6 (Price Relaxation 2.0x):</strong> Kích giá lên gấp đôi.</li>
<li><strong>Tầng 7 (Drop Price):</strong> Bỏ hoàn toàn giới hạn giá để lấy đồ xịn nhất ra chào hàng.</li>
</ol>
<h3>3. Làm giàu dữ liệu (Data Enrichment)</h3>
<ul>
<li><strong>Canifa Stock API:</strong> Mọi SP lấy ra từ StarRocks đều bị đẩy qua <code>_fetch_stock_batch()</code> để check xem kho thực tế còn hàng và size nào. Hết hàng -> Drop ngay lập tức.</li>
<li><strong>SQLite Outfit Context:</strong> Load các gợi ý "Phối cùng cái gì" (ai_matches) từ bảng <code>pg__dashboard_canifa__ai_outfit_product_matches</code> để cung cấp thêm lý lẽ chốt sales cho Stylist Agent.</li>
</ul>
</div>
<!-- TAB 4: MODIFICATION GUIDE -->
<div id="tab-guide" class="tab-content">
<h2>Cẩm nang sửa đổi Quy trình (Modification Guide)</h2>
<p>Khi Business có yêu cầu thay đổi logic chốt sale hoặc phân loại khách, lập trình viên cần nắm được sơ đồ phân quyền để sửa đúng file.</p>
<div class="warning-box">
<strong>Nguyên tắc cốt lõi:</strong> Hãy tưởng tượng Classifier là "Lễ tân" và Stylist là "Nhân viên Sales".
</div>
<h3>1. Thêm/Sửa thuộc tính phân loại khách hàng (Tags/Insights)</h3>
<p><strong>Ví dụ:</strong> Business muốn thêm tag <code>occ:di_dam_cuoi</code> để gom khách dự tiệc cưới, hoặc muốn lưu thông tin <code>PET_OWNER</code> vào Insight khách.</p>
<ul>
<li>Sửa file <code>backend/agent/lead_stage_agent/graph.py</code>.</li>
<li>Kéo xuống class <code>InferredSearchArgs</code>: Thêm/Sửa Enum trong field <code>tags</code>.</li>
<li>Kéo xuống class <code>InsightJSON</code>: Thêm field <code>PET_OWNER: str</code>.</li>
</ul>
<h3>2. Thay đổi cách AI xưng hô, văn phong chốt Sales</h3>
<p><strong>Ví dụ:</strong> Marketing muốn AI bớt xưng "Mình/Bạn" mà phải xưng "Stylist Canifa / Quý khách", hoặc yêu cầu "Không bao giờ được tự ý báo giảm giá nếu không chắc".</p>
<ul>
<li>Sửa file <code>backend/agent/lead_stage_agent/prompts.py</code>.</li>
<li>Chỉnh sửa hằng số <code>STYLIST_SYSTEM_PROMPT</code>. Đây là bộ não quyết định thái độ và giọng điệu của AI thứ 2.</li>
</ul>
<h3>3. Thay đổi thuật toán lấy dữ liệu (Tool Logic)</h3>
<p><strong>Ví dụ:</strong> Hệ thống StarRocks có bảng mới, hoặc muốn đổi Tầng 5 từ "Nới giá 1.5x" thành "1.2x" thôi.</p>
<ul>
<li>Sửa file <code>backend/agent/lead_stage_agent/product_search_engine.py</code>.</li>
<li>Tìm hàm <code>_cascading_search()</code> để đổi cấu trúc tầng tìm kiếm.</li>
<li>Tìm hàm <code>_build_full_query()</code> để can thiệp vào SQL.</li>
</ul>
<h3>4. Thêm Tool mới cho hệ thống</h3>
<p><strong>Ví dụ:</strong> Muốn thêm Tool <code>book_fitting_room</code> (Đặt lịch thử đồ tại cửa hàng).</p>
<ul>
<li>Tạo file tool trong thư mục <code>agent/tools/</code>.</li>
<li>Vào <code>graph.py</code>, thêm tên tool vào class <code>ClassifierOutput</code> và dictionary <code>self._tool_registry</code>. Classifier sẽ tự động hiểu và định tuyến.</li>
</ul>
</div>
</div>
<script>
function openTab(tabId) {
// Hide all contents
var contents = document.getElementsByClassName('tab-content');
for (var i = 0; i < contents.length; i++) {
contents[i].classList.remove('active');
}
// Remove active class from buttons
var btns = document.getElementsByClassName('tab-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].classList.remove('active');
}
// Show current tab and add active class to button
document.getElementById(tabId).classList.add('active');
event.currentTarget.classList.add('active');
}
</script>
</body>
</html>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 820" width="1200" height="820">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Arial, sans-serif; }
.title { font-size: 20px; font-weight: 600; fill: #111827; }
.subtitle { font-size: 12px; font-weight: 400; fill: #6b7280; }
.node-label { font-size: 13px; font-weight: 500; fill: #111827; }
.node-sub { font-size: 11px; font-weight: 400; fill: #6b7280; }
.type-label { font-size: 10px; font-weight: 500; fill: #9ca3af; letter-spacing: 0.08em; }
.section-label { font-size: 11px; font-weight: 500; fill: #6b7280; letter-spacing: 0.06em; }
.arrow-label { font-size: 10px; font-weight: 500; fill: #6b7280; }
.legend-label { font-size: 11px; fill: #374151; }
.tier-num { font-size: 11px; font-weight: 600; fill: #374151; }
.tier-sub { font-size: 10px; fill: #9ca3af; }
</style>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 600">
<defs>
<marker id="arr-blue" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#3b82f6"/>
<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="arr-green" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#059669"/>
</marker>
<marker id="arr-gray" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#9ca3af"/>
<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="1200" height="820" fill="#ffffff"/>
<text x="48" y="44" class="title">Lead Search Tool — Kiến trúc 7-Tầng &amp; Tối ưu Token</text>
<text x="48" y="64" class="subtitle">Canifa AI Stylist · Cascading Search Strategy · T4/2026</text>
<rect x="32" y="82" width="1136" height="80" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="96" class="section-label">INPUT</text>
<rect x="60" y="102" width="160" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="140" y="123" class="type-label" text-anchor="middle">AGENT OUTPUT</text>
<text x="140" y="139" class="node-label" text-anchor="middle">AI Intent</text>
<rect x="260" y="102" width="140" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="330" y="123" class="type-label" text-anchor="middle">TEXT</text>
<text x="330" y="139" class="node-label" text-anchor="middle">Keywords</text>
<rect x="440" y="102" width="140" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="510" y="123" class="type-label" text-anchor="middle">ARRAY</text>
<text x="510" y="139" class="node-label" text-anchor="middle">Tags</text>
<rect x="620" y="102" width="300" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="770" y="123" class="type-label" text-anchor="middle">HARD FILTERS</text>
<text x="770" y="139" class="node-label" text-anchor="middle">Color · Size · Price · Gender · Line</text>
<line x1="220" y1="126" x2="256" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="220" y1="126" x2="436" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="220" y1="126" x2="616" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<rect x="32" y="182" width="1136" height="290" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="196" class="section-label">CASCADING SEARCH — STARROCKS</text>
<rect x="48" y="208" width="144" height="56" rx="4" fill="#dbeafe" stroke="#1d4ed8" stroke-width="1.5"/>
<text x="60" y="226" class="tier-num">T1 // Keywords</text>
<text x="60" y="242" class="tier-sub">NGRAMBF idx</text>
<line x1="192" y1="236" x2="203" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="206" y="208" width="144" height="56" rx="4" fill="#dbeafe" stroke="#1d4ed8" stroke-width="1.5"/>
<text x="218" y="226" class="tier-num">T2 // Tags</text>
<text x="218" y="242" class="tier-sub">BITMAP idx</text>
<line x1="350" y1="236" x2="361" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="364" y="208" width="144" height="56" rx="4" fill="#e0f2fe" stroke="#0369a1" stroke-width="1.5"/>
<text x="376" y="226" class="tier-num">T3 // Hard Filters</text>
<text x="376" y="242" class="tier-sub">Fixed attrs only</text>
<line x1="508" y1="236" x2="519" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="522" y="208" width="144" height="56" rx="4" fill="#fef3c7" stroke="#b45309" stroke-width="1.5"/>
<text x="534" y="226" class="tier-num">T4 // Drop Gender</text>
<text x="534" y="242" class="tier-sub">Bo gender filter</text>
<line x1="666" y1="236" x2="677" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="680" y="208" width="144" height="56" rx="4" fill="#fce7f3" stroke="#9d174d" stroke-width="1.5"/>
<text x="692" y="226" class="tier-num">T5 // Price x1.5</text>
<text x="692" y="242" class="tier-sub">Noi rong gia</text>
<line x1="824" y1="236" x2="835" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="838" y="208" width="144" height="56" rx="4" fill="#fce7f3" stroke="#9d174d" stroke-width="1.5"/>
<text x="850" y="226" class="tier-num">T6 // Price x2.0</text>
<text x="850" y="242" class="tier-sub">Noi rong hon</text>
<line x1="982" y1="236" x2="993" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="996" y="208" width="144" height="56" rx="4" fill="#f3f4f6" stroke="#374151" stroke-width="1.5"/>
<text x="1008" y="226" class="tier-num">T7 // Bo Price</text>
<text x="1008" y="242" class="tier-sub">Khong gioi han</text>
<rect x="200" y="296" width="800" height="48" rx="4" fill="#f0fdf4" stroke="#059669" stroke-width="1"/>
<text x="600" y="317" class="type-label" text-anchor="middle">RESULT</text>
<text x="600" y="333" class="node-label" text-anchor="middle">Top 12 rows tu tang co ket qua → deduplicate → top 3 base_codes</text>
<line x1="120" y1="264" x2="120" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="278" y1="264" x2="278" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="436" y1="264" x2="436" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="594" y1="264" x2="594" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="752" y1="264" x2="752" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="910" y1="264" x2="910" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="1068" y1="264" x2="1068" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<rect x="32" y="492" width="1136" height="120" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="506" class="section-label">ENRICHMENT</text>
<rect x="60" y="516" width="220" height="72" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="170" y="535" class="type-label" text-anchor="middle">EXTERNAL API</text>
<text x="170" y="551" class="node-label" text-anchor="middle">Canifa Stock API</text>
<text x="170" y="567" class="node-sub" text-anchor="middle">canifa.com/v1/middleware/...</text>
<text x="170" y="580" class="node-sub" text-anchor="middle">Loc con hang · Size · Qty</text>
<rect x="340" y="516" width="200" height="72" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="440" y="535" class="type-label" text-anchor="middle">LOCAL DB</text>
<text x="440" y="551" class="node-label" text-anchor="middle">SQLite 123.db</text>
<text x="440" y="567" class="node-sub" text-anchor="middle">ai_description</text>
<text x="440" y="580" class="node-sub" text-anchor="middle">ai_matches (phoi do)</text>
<line x1="600" y1="344" x2="600" y2="490" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="560" y1="492" x2="282" y2="514" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="600" y1="492" x2="442" y2="514" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<rect x="32" y="632" width="1136" height="160" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="646" class="section-label">JSON OUTPUT — TOKEN-SAVING STRUCTURE</text>
<rect x="60" y="658" width="240" height="120" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="180" y="676" class="type-label" text-anchor="middle">MAIN RESULTS</text>
<text x="180" y="693" class="node-label" text-anchor="middle">Main Products (Max 3)</text>
<text x="180" y="710" class="node-sub" text-anchor="middle">sku · name · price · description</text>
<text x="180" y="724" class="node-sub" text-anchor="middle">stock[ ] · in_stock: bool</text>
<text x="180" y="738" class="node-sub" text-anchor="middle">ai_matches · reason</text>
<text x="180" y="752" class="node-sub" text-anchor="middle">~900 tokens / SP</text>
<rect x="360" y="658" width="200" height="120" rx="4" fill="#f0fdf4" stroke="#059669" stroke-width="1"/>
<text x="460" y="676" class="type-label" text-anchor="middle">SUGGEST</text>
<text x="460" y="693" class="node-label" text-anchor="middle">Suggest Items</text>
<text x="460" y="710" class="node-sub" text-anchor="middle">Max 2 SP phoi cung</text>
<text x="460" y="726" class="node-sub" text-anchor="middle">sku · name · price</text>
<text x="460" y="742" class="node-sub" text-anchor="middle">~50 tokens / SP</text>
<rect x="620" y="658" width="200" height="120" rx="4" fill="#fefce8" stroke="#ca8a04" stroke-width="1"/>
<text x="720" y="676" class="type-label" text-anchor="middle">SIMILAR</text>
<text x="720" y="693" class="node-label" text-anchor="middle">Similar Items</text>
<text x="720" y="710" class="node-sub" text-anchor="middle">Max 3 SP thay the</text>
<text x="720" y="726" class="node-sub" text-anchor="middle">sku · name · price</text>
<text x="720" y="742" class="node-sub" text-anchor="middle">~50 tokens / SP</text>
<rect x="880" y="658" width="260" height="120" rx="4" fill="#fff7ed" stroke="#ea580c" stroke-width="1.5"/>
<text x="1010" y="676" class="type-label" text-anchor="middle">TOKEN SAVING</text>
<text x="1010" y="693" class="node-label" text-anchor="middle">Giam 75% Token</text>
<text x="1010" y="710" class="node-sub" text-anchor="middle">Cu: 15-20 SP full detail</text>
<text x="1010" y="726" class="node-sub" text-anchor="middle">Moi: 3 SP + 15 SP phu</text>
<text x="1010" y="742" class="node-sub" text-anchor="middle">Context Window: an toan</text>
<line x1="170" y1="588" x2="180" y2="656" stroke="#059669" stroke-width="1.5" marker-end="url(#arr-green)"/>
<line x1="440" y1="588" x2="460" y2="656" stroke="#059669" stroke-width="1.5" marker-end="url(#arr-green)"/>
<text x="48" y="808" class="legend-label">Legend:</text>
<line x1="108" y1="803" x2="138" y2="803" stroke="#3b82f6" stroke-width="2" marker-end="url(#arr-blue)"/>
<text x="144" y="808" class="legend-label">Primary data flow</text>
<line x1="278" y1="803" x2="308" y2="803" stroke="#059669" stroke-width="2" marker-end="url(#arr-green)"/>
<text x="314" y="808" class="legend-label">Enrich / Store</text>
<line x1="430" y1="803" x2="460" y2="803" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arr-gray)"/>
<text x="466" y="808" class="legend-label">Fallback (tang rot)</text>
<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
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