Spaces:
Sleeping
Sleeping
feat: implement conversational chatbot with routing and chat history support
Browse files- app/api/v1/recommendations.py +11 -3
- app/services/llm_service.py +60 -15
- tests/test_llm.py +41 -7
app/api/v1/recommendations.py
CHANGED
|
@@ -12,9 +12,15 @@ from pydantic import BaseModel
|
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
class OutfitRecommendRequest(BaseModel):
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
|
| 19 |
@router.post("/outfit")
|
| 20 |
def recommend_outfit(
|
|
@@ -45,9 +51,11 @@ def recommend_outfit(
|
|
| 45 |
for item in wardrobe
|
| 46 |
]
|
| 47 |
|
| 48 |
-
recommendation = llm_service.
|
| 49 |
user_profile=body_data,
|
| 50 |
wardrobe_items=wardrobe_list,
|
|
|
|
|
|
|
| 51 |
weather=req.weather,
|
| 52 |
event=req.event
|
| 53 |
)
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
|
| 15 |
+
class ChatMessage(BaseModel):
|
| 16 |
+
role: str
|
| 17 |
+
content: str
|
| 18 |
+
|
| 19 |
class OutfitRecommendRequest(BaseModel):
|
| 20 |
+
message: str
|
| 21 |
+
history: List[ChatMessage] = []
|
| 22 |
+
weather: Optional[str] = "25C"
|
| 23 |
+
event: Optional[str] = None
|
| 24 |
|
| 25 |
@router.post("/outfit")
|
| 26 |
def recommend_outfit(
|
|
|
|
| 51 |
for item in wardrobe
|
| 52 |
]
|
| 53 |
|
| 54 |
+
recommendation = llm_service.chat_and_recommend(
|
| 55 |
user_profile=body_data,
|
| 56 |
wardrobe_items=wardrobe_list,
|
| 57 |
+
message=req.message,
|
| 58 |
+
history=[{"role": m.role, "content": m.content} for m in req.history],
|
| 59 |
weather=req.weather,
|
| 60 |
event=req.event
|
| 61 |
)
|
app/services/llm_service.py
CHANGED
|
@@ -20,12 +20,33 @@ class LLMService:
|
|
| 20 |
wardrobe_items: List[Dict[str, Any]],
|
| 21 |
weather: str,
|
| 22 |
event: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
) -> Dict[str, Any]:
|
| 24 |
if not self.enabled:
|
| 25 |
-
#
|
|
|
|
|
|
|
| 26 |
return {
|
| 27 |
-
"
|
| 28 |
-
"
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
# Structure wardrobe context
|
|
@@ -37,9 +58,16 @@ class LLMService:
|
|
| 37 |
)
|
| 38 |
wardrobe_context = "\n".join(wardrobe_desc)
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
prompt = f"""
|
| 41 |
-
Bạn là một Trợ lý thời trang cá nhân AI chuyên nghiệp.
|
| 42 |
-
Hãy phân tích dữ liệu người dùng
|
| 43 |
|
| 44 |
Thông tin người dùng:
|
| 45 |
- Giới tính: {user_profile.get('gender')}
|
|
@@ -49,21 +77,35 @@ class LLMService:
|
|
| 49 |
- Vòng eo: {user_profile.get('waist_cm') or 'Chưa cung cấp'} cm
|
| 50 |
- Vòng mông: {user_profile.get('hips_cm') or 'Chưa cung cấp'} cm
|
| 51 |
|
| 52 |
-
Ngữ cảnh hiện tại:
|
| 53 |
-
- Thời tiết: {weather}
|
| 54 |
-
- Sự kiện/Hoàn cảnh
|
| 55 |
|
| 56 |
Danh sách tủ quần áo hiện có của người dùng:
|
| 57 |
{wardrobe_context}
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
{{
|
| 66 |
-
"
|
|
|
|
| 67 |
"selected_item_ids": ["uuid-1", "uuid-2"]
|
| 68 |
}}
|
| 69 |
"""
|
|
@@ -101,10 +143,13 @@ class LLMService:
|
|
| 101 |
content_str = "\n".join(lines).strip()
|
| 102 |
|
| 103 |
data = json.loads(content_str)
|
|
|
|
|
|
|
| 104 |
return data
|
| 105 |
except Exception as e:
|
| 106 |
logging.error(f"Error calling Nvidia API: {e}")
|
| 107 |
return {
|
|
|
|
| 108 |
"recommendation": "Hệ thống gặp lỗi khi liên kết với AI gợi ý. Hãy thử lại sau.",
|
| 109 |
"selected_item_ids": []
|
| 110 |
}
|
|
|
|
| 20 |
wardrobe_items: List[Dict[str, Any]],
|
| 21 |
weather: str,
|
| 22 |
event: str
|
| 23 |
+
) -> Dict[str, Any]:
|
| 24 |
+
return self.chat_and_recommend(
|
| 25 |
+
user_profile=user_profile,
|
| 26 |
+
wardrobe_items=wardrobe_items,
|
| 27 |
+
message=event,
|
| 28 |
+
history=[],
|
| 29 |
+
weather=weather,
|
| 30 |
+
event=event
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
def chat_and_recommend(
|
| 34 |
+
self,
|
| 35 |
+
user_profile: Dict[str, Any],
|
| 36 |
+
wardrobe_items: List[Dict[str, Any]],
|
| 37 |
+
message: str,
|
| 38 |
+
history: List[Dict[str, str]],
|
| 39 |
+
weather: Optional[str] = "25C",
|
| 40 |
+
event: Optional[str] = None
|
| 41 |
) -> Dict[str, Any]:
|
| 42 |
if not self.enabled:
|
| 43 |
+
# Simple keyword-based classifier in mock mode
|
| 44 |
+
keywords = ["recommend", "gợi ý", "phối đồ", "mặc gì", "chọn đồ", "outfit", "wear"]
|
| 45 |
+
is_rec = any(kw in message.lower() for kw in keywords) or not history
|
| 46 |
return {
|
| 47 |
+
"is_recommendation": is_rec,
|
| 48 |
+
"recommendation": "Mock Outfit: Áo sơ mi trắng phối quần tây đen nhã nhặn phù hợp cho môi trường văn phòng/phỏng vấn." if is_rec else f"Tôi hiểu bạn đang nói về: '{message}'. Trong vai trò trợ lý thời trang, tôi khuyên bạn nên tự tin thể hiện phong cách của mình!",
|
| 49 |
+
"selected_item_ids": [str(item["id"]) for item in wardrobe_items[:2]] if (wardrobe_items and is_rec) else []
|
| 50 |
}
|
| 51 |
|
| 52 |
# Structure wardrobe context
|
|
|
|
| 58 |
)
|
| 59 |
wardrobe_context = "\n".join(wardrobe_desc)
|
| 60 |
|
| 61 |
+
# Structure chat history context
|
| 62 |
+
history_desc = []
|
| 63 |
+
for msg in history:
|
| 64 |
+
role_label = "User" if msg["role"] == "user" else "Stylist"
|
| 65 |
+
history_desc.append(f"{role_label}: {msg['content']}")
|
| 66 |
+
history_context = "\n".join(history_desc) if history_desc else "Chưa có lịch sử trò chuyện."
|
| 67 |
+
|
| 68 |
prompt = f"""
|
| 69 |
+
Bạn là một Trợ lý thời trang cá nhân AI chuyên nghiệp (tên là Aura).
|
| 70 |
+
Hãy phân tích dữ liệu người dùng, tủ đồ hiện có, lịch sử trò chuyện và tin nhắn mới nhất để phản hồi phù hợp.
|
| 71 |
|
| 72 |
Thông tin người dùng:
|
| 73 |
- Giới tính: {user_profile.get('gender')}
|
|
|
|
| 77 |
- Vòng eo: {user_profile.get('waist_cm') or 'Chưa cung cấp'} cm
|
| 78 |
- Vòng mông: {user_profile.get('hips_cm') or 'Chưa cung cấp'} cm
|
| 79 |
|
| 80 |
+
Ngữ cảnh hiện tại (nếu có):
|
| 81 |
+
- Thời tiết: {weather or 'Không xác định'}
|
| 82 |
+
- Sự kiện/Hoàn cảnh: {event or 'Không xác định'}
|
| 83 |
|
| 84 |
Danh sách tủ quần áo hiện có của người dùng:
|
| 85 |
{wardrobe_context}
|
| 86 |
|
| 87 |
+
Lịch sử trò chuyện gần đây:
|
| 88 |
+
{history_context}
|
| 89 |
+
|
| 90 |
+
Tin nhắn mới từ người dùng:
|
| 91 |
+
"{message}"
|
| 92 |
+
|
| 93 |
+
Nhiệm vụ của bạn:
|
| 94 |
+
1. Phân loại ý định của người dùng (User Intent Routing):
|
| 95 |
+
- Nếu người dùng muốn gợi ý phối đồ mới, phối đồ lại, thay thế/thay đổi trang phục nào đó trong set đồ hiện tại, hoặc tìm đồ phối hợp từ tủ quần áo, hãy thiết lập "is_recommendation" thành true.
|
| 96 |
+
- Nếu người dùng chỉ đang bàn luận, hỏi đáp chung về thời trang, hỏi lý do lựa chọn sản phẩm trước đó (ví dụ: "Tại sao bạn chọn áo thun này?", "Quần này hợp với giày gì?", "Có nên sơ vin không?"), hoặc tán gẫu thông thường mà không cần thay đổi hay tạo mới set đồ đề xuất, hãy thiết lập "is_recommendation" thành false.
|
| 97 |
+
2. Nếu "is_recommendation" là true:
|
| 98 |
+
- Hãy gợi ý một set phối đồ hoàn hảo và hợp thời trang nhất từ tủ đồ.
|
| 99 |
+
- Chọn chính xác các ID sản phẩm phù hợp từ danh sách tủ đồ của họ.
|
| 100 |
+
- Điền danh sách ID này vào "selected_item_ids".
|
| 101 |
+
3. Nếu "is_recommendation" là false:
|
| 102 |
+
- Trả lời người dùng một cách thân thiện, chi tiết và có kiến thức chuyên môn về thời trang dựa trên câu hỏi của họ.
|
| 103 |
+
- Đặt "selected_item_ids" là [] (mảng rỗng).
|
| 104 |
+
|
| 105 |
+
Hãy trả về kết quả dưới định dạng JSON duy nhất và hợp lệ sau (không chứa ký tự thừa bên ngoài):
|
| 106 |
{{
|
| 107 |
+
"is_recommendation": true,
|
| 108 |
+
"recommendation": "Chuỗi văn bản phản hồi người dùng",
|
| 109 |
"selected_item_ids": ["uuid-1", "uuid-2"]
|
| 110 |
}}
|
| 111 |
"""
|
|
|
|
| 143 |
content_str = "\n".join(lines).strip()
|
| 144 |
|
| 145 |
data = json.loads(content_str)
|
| 146 |
+
if "is_recommendation" not in data:
|
| 147 |
+
data["is_recommendation"] = len(data.get("selected_item_ids", [])) > 0
|
| 148 |
return data
|
| 149 |
except Exception as e:
|
| 150 |
logging.error(f"Error calling Nvidia API: {e}")
|
| 151 |
return {
|
| 152 |
+
"is_recommendation": False,
|
| 153 |
"recommendation": "Hệ thống gặp lỗi khi liên kết với AI gợi ý. Hãy thử lại sau.",
|
| 154 |
"selected_item_ids": []
|
| 155 |
}
|
tests/test_llm.py
CHANGED
|
@@ -10,6 +10,7 @@ def test_llm_service_mock_mode():
|
|
| 10 |
service = LLMService()
|
| 11 |
assert service.enabled is False
|
| 12 |
|
|
|
|
| 13 |
res = service.recommend_outfits(
|
| 14 |
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 15 |
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
|
@@ -20,6 +21,36 @@ def test_llm_service_mock_mode():
|
|
| 20 |
assert "Mock Outfit" in res["recommendation"]
|
| 21 |
assert "item1" in res["selected_item_ids"]
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
def test_llm_service_nvidia_api_success():
|
| 24 |
with patch("app.services.llm_service.settings") as mock_settings:
|
| 25 |
mock_settings.NVIDIA_API_KEY = "mock_key"
|
|
@@ -35,21 +66,23 @@ def test_llm_service_nvidia_api_success():
|
|
| 35 |
"choices": [
|
| 36 |
{
|
| 37 |
"message": {
|
| 38 |
-
"content": '{\n "recommendation": "Gợi ý: Áo thun trắng phối quần short năng động.",\n "selected_item_ids": ["item1"]\n}'
|
| 39 |
}
|
| 40 |
}
|
| 41 |
]
|
| 42 |
}
|
| 43 |
|
| 44 |
with patch("requests.post", return_value=mock_response) as mock_post:
|
| 45 |
-
res = service.
|
| 46 |
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 47 |
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
| 48 |
-
|
| 49 |
-
|
|
|
|
| 50 |
)
|
| 51 |
|
| 52 |
mock_post.assert_called_once()
|
|
|
|
| 53 |
assert res["recommendation"] == "Gợi ý: Áo thun trắng phối quần short năng động."
|
| 54 |
assert res["selected_item_ids"] == ["item1"]
|
| 55 |
|
|
@@ -62,13 +95,14 @@ def test_llm_service_nvidia_api_error_fallback():
|
|
| 62 |
assert service.enabled is True
|
| 63 |
|
| 64 |
with patch("requests.post", side_effect=Exception("API connection timeout")) as mock_post:
|
| 65 |
-
res = service.
|
| 66 |
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 67 |
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
| 68 |
-
|
| 69 |
-
|
| 70 |
)
|
| 71 |
|
| 72 |
mock_post.assert_called_once()
|
|
|
|
| 73 |
assert "Hệ thống gặp lỗi" in res["recommendation"]
|
| 74 |
assert res["selected_item_ids"] == []
|
|
|
|
| 10 |
service = LLMService()
|
| 11 |
assert service.enabled is False
|
| 12 |
|
| 13 |
+
# Test old recommend_outfits wrapper
|
| 14 |
res = service.recommend_outfits(
|
| 15 |
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 16 |
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
|
|
|
| 21 |
assert "Mock Outfit" in res["recommendation"]
|
| 22 |
assert "item1" in res["selected_item_ids"]
|
| 23 |
|
| 24 |
+
def test_chat_and_recommend_mock_routing():
|
| 25 |
+
with patch("app.services.llm_service.settings") as mock_settings:
|
| 26 |
+
mock_settings.NVIDIA_API_KEY = None
|
| 27 |
+
|
| 28 |
+
service = LLMService()
|
| 29 |
+
assert service.enabled is False
|
| 30 |
+
|
| 31 |
+
# Test recommendation intent (should be true)
|
| 32 |
+
res_rec = service.chat_and_recommend(
|
| 33 |
+
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 34 |
+
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
| 35 |
+
message="Gợi ý phối đồ đi chơi",
|
| 36 |
+
history=[],
|
| 37 |
+
weather="28C"
|
| 38 |
+
)
|
| 39 |
+
assert res_rec["is_recommendation"] is True
|
| 40 |
+
assert "item1" in res_rec["selected_item_ids"]
|
| 41 |
+
|
| 42 |
+
# Test non-recommendation/chat intent (should be false if there is history)
|
| 43 |
+
res_chat = service.chat_and_recommend(
|
| 44 |
+
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 45 |
+
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
| 46 |
+
message="Tại sao chiếc áo này lại hợp với tôi?",
|
| 47 |
+
history=[{"role": "user", "content": "Mặc gì đây"}, {"role": "assistant", "content": "Áo sơ mi"}],
|
| 48 |
+
weather="28C"
|
| 49 |
+
)
|
| 50 |
+
assert res_chat["is_recommendation"] is False
|
| 51 |
+
assert "item1" not in res_chat["selected_item_ids"]
|
| 52 |
+
assert "Tại sao chiếc áo này lại hợp với tôi?" in res_chat["recommendation"]
|
| 53 |
+
|
| 54 |
def test_llm_service_nvidia_api_success():
|
| 55 |
with patch("app.services.llm_service.settings") as mock_settings:
|
| 56 |
mock_settings.NVIDIA_API_KEY = "mock_key"
|
|
|
|
| 66 |
"choices": [
|
| 67 |
{
|
| 68 |
"message": {
|
| 69 |
+
"content": '{\n "is_recommendation": true,\n "recommendation": "Gợi ý: Áo thun trắng phối quần short năng động.",\n "selected_item_ids": ["item1"]\n}'
|
| 70 |
}
|
| 71 |
}
|
| 72 |
]
|
| 73 |
}
|
| 74 |
|
| 75 |
with patch("requests.post", return_value=mock_response) as mock_post:
|
| 76 |
+
res = service.chat_and_recommend(
|
| 77 |
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 78 |
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
| 79 |
+
message="Mặc gì hôm nay?",
|
| 80 |
+
history=[],
|
| 81 |
+
weather="28C"
|
| 82 |
)
|
| 83 |
|
| 84 |
mock_post.assert_called_once()
|
| 85 |
+
assert res["is_recommendation"] is True
|
| 86 |
assert res["recommendation"] == "Gợi ý: Áo thun trắng phối quần short năng động."
|
| 87 |
assert res["selected_item_ids"] == ["item1"]
|
| 88 |
|
|
|
|
| 95 |
assert service.enabled is True
|
| 96 |
|
| 97 |
with patch("requests.post", side_effect=Exception("API connection timeout")) as mock_post:
|
| 98 |
+
res = service.chat_and_recommend(
|
| 99 |
user_profile={"gender": "male", "height_cm": 175, "weight_kg": 70},
|
| 100 |
wardrobe_items=[{"id": "item1", "category": "shirt", "color_hex": "#ffffff"}],
|
| 101 |
+
message="Mặc gì hôm nay?",
|
| 102 |
+
history=[]
|
| 103 |
)
|
| 104 |
|
| 105 |
mock_post.assert_called_once()
|
| 106 |
+
assert res["is_recommendation"] is False
|
| 107 |
assert "Hệ thống gặp lỗi" in res["recommendation"]
|
| 108 |
assert res["selected_item_ids"] == []
|