Spaces:
Running
Running
- .env +2 -2
- chatbot/agents/nodes/app_functions/find_candidates.py +5 -4
- chatbot/agents/nodes/app_functions/generate_candidates.py +7 -3
- chatbot/agents/nodes/app_functions/optimize_macros.py +1 -3
- chatbot/agents/nodes/app_functions/select_meal.py +4 -0
- chatbot/agents/nodes/app_functions/select_menu.py +40 -38
- chatbot/routes/meal_plan_route.py +0 -1
.env
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
DEEPSEEK_API_KEY=sk-0fde229ff9854ada814c2553c787d721
|
| 2 |
|
| 3 |
-
ELASTIC_API_KEY=
|
| 4 |
-
ELASTIC_CLOUD_URL=https://
|
| 5 |
FOOD_DB_INDEX=food_v2_vdb
|
| 6 |
POLICY_DB_INDEX=policy_vdb
|
| 7 |
|
|
|
|
| 1 |
DEEPSEEK_API_KEY=sk-0fde229ff9854ada814c2553c787d721
|
| 2 |
|
| 3 |
+
ELASTIC_API_KEY="U0RkcElwc0JwbGxnYkIxOVIyYVo6WlpJMXpoUXNxZVdkT295cnpZRWdLUQ=="
|
| 4 |
+
ELASTIC_CLOUD_URL=https://my-elasticsearch-project-a94f0e.es.ap-southeast-1.aws.elastic.cloud:443
|
| 5 |
FOOD_DB_INDEX=food_v2_vdb
|
| 6 |
POLICY_DB_INDEX=policy_vdb
|
| 7 |
|
chatbot/agents/nodes/app_functions/find_candidates.py
CHANGED
|
@@ -21,22 +21,23 @@ def find_replacement_candidates(state: SwapState):
|
|
| 21 |
health_status = profile.get('healthStatus', '') # VD: Suy thận
|
| 22 |
|
| 23 |
constraint_prompt = ""
|
| 24 |
-
if restrictions:
|
| 25 |
constraint_prompt += f"Yêu cầu bắt buộc: {restrictions}. "
|
| 26 |
-
if health_status:
|
| 27 |
constraint_prompt += f"Phù hợp người bệnh: {health_status}. "
|
| 28 |
-
if diet_mode:
|
| 29 |
constraint_prompt += f"Chế độ: {diet_mode}."
|
| 30 |
|
| 31 |
# 1. Trích xuất ngữ cảnh từ món cũ
|
| 32 |
role = food_old.get("role", "main") # VD: main, side, carb
|
|
|
|
| 33 |
meal_type = food_old.get("assigned_meal", "trưa") # VD: trưa
|
| 34 |
old_name = food_old.get("name", "")
|
| 35 |
numerical_query = generate_numerical_constraints(profile, meal_type)
|
| 36 |
|
| 37 |
# 2. Xây dựng Query tự nhiên để SelfQueryRetriever hiểu
|
| 38 |
query = (
|
| 39 |
-
f"Tìm các món ăn đóng vai trò '{role}' phù hợp cho bữa '{meal_type}'. "
|
| 40 |
f"Khác với món '{old_name}'. "
|
| 41 |
f"{constraint_prompt}"
|
| 42 |
)
|
|
|
|
| 21 |
health_status = profile.get('healthStatus', '') # VD: Suy thận
|
| 22 |
|
| 23 |
constraint_prompt = ""
|
| 24 |
+
if restrictions not in ["Không có"]:
|
| 25 |
constraint_prompt += f"Yêu cầu bắt buộc: {restrictions}. "
|
| 26 |
+
if health_status not in ["Khỏe mạnh", "Không có", "Bình thường", None]:
|
| 27 |
constraint_prompt += f"Phù hợp người bệnh: {health_status}. "
|
| 28 |
+
if diet_mode not in ["Bình thường"]:
|
| 29 |
constraint_prompt += f"Chế độ: {diet_mode}."
|
| 30 |
|
| 31 |
# 1. Trích xuất ngữ cảnh từ món cũ
|
| 32 |
role = food_old.get("role", "main") # VD: main, side, carb
|
| 33 |
+
vibe = food_old.get("retrieval_vibe", "Món ăn kèm cơ bản") # VD: món nhẹ nhàng, món giàu đạm
|
| 34 |
meal_type = food_old.get("assigned_meal", "trưa") # VD: trưa
|
| 35 |
old_name = food_old.get("name", "")
|
| 36 |
numerical_query = generate_numerical_constraints(profile, meal_type)
|
| 37 |
|
| 38 |
# 2. Xây dựng Query tự nhiên để SelfQueryRetriever hiểu
|
| 39 |
query = (
|
| 40 |
+
f"Tìm các món ăn đóng vai trò '{role}' phù hợp cho bữa '{meal_type}'. Phong cách: '{vibe}'. "
|
| 41 |
f"Khác với món '{old_name}'. "
|
| 42 |
f"{constraint_prompt}"
|
| 43 |
)
|
chatbot/agents/nodes/app_functions/generate_candidates.py
CHANGED
|
@@ -3,6 +3,7 @@ import logging
|
|
| 3 |
from chatbot.agents.states.state import AgentState
|
| 4 |
from chatbot.agents.tools.food_retriever import food_retriever_50, docsearch
|
| 5 |
from chatbot.knowledge.vibe import vibes_cooking, vibes_flavor, vibes_healthy, vibes_soup_veg, vibes_style
|
|
|
|
| 6 |
|
| 7 |
STAPLE_IDS = ["112", "1852", "2236", "2386", "2388"]
|
| 8 |
|
|
@@ -51,11 +52,11 @@ def generate_food_candidates(state: AgentState):
|
|
| 51 |
health_status = profile.get('healthStatus', '') # VD: Suy thận
|
| 52 |
|
| 53 |
constraint_prompt = ""
|
| 54 |
-
if restrictions:
|
| 55 |
constraint_prompt += f"Yêu cầu bắt buộc: {restrictions}. "
|
| 56 |
if health_status not in ["Khỏe mạnh", "Không có", "Bình thường", None]:
|
| 57 |
constraint_prompt += f"Phù hợp người bệnh: {health_status}. "
|
| 58 |
-
if diet_mode:
|
| 59 |
constraint_prompt += f"Chế độ: {diet_mode}."
|
| 60 |
|
| 61 |
prompt_templates = {
|
|
@@ -80,7 +81,10 @@ def generate_food_candidates(state: AgentState):
|
|
| 80 |
final_query = f"{base_prompt} Phong cách: {vibe}.{' Ràng buộc: ' + numerical_query if numerical_query else ''}"
|
| 81 |
logger.info(f"🔎 Query ({meal_type}): {final_query}")
|
| 82 |
|
|
|
|
| 83 |
docs = food_retriever_50.invoke(final_query)
|
|
|
|
|
|
|
| 84 |
if not docs:
|
| 85 |
logger.warning(f"⚠️ Retriever trả về rỗng cho bữa: {meal_type}")
|
| 86 |
continue
|
|
@@ -286,7 +290,7 @@ def rank_candidates(candidates, user_profile, meal_type):
|
|
| 286 |
scored_list.sort(key=lambda x: x["health_score"], reverse=True)
|
| 287 |
|
| 288 |
# # Debug: In Top 3
|
| 289 |
-
# logger.info("
|
| 290 |
# for i, m in enumerate(scored_list[:3]):
|
| 291 |
# logger.info(f" {i+1}. {m['name']} (Score: {m['health_score']}) | {m.get('score_reason')}")
|
| 292 |
|
|
|
|
| 3 |
from chatbot.agents.states.state import AgentState
|
| 4 |
from chatbot.agents.tools.food_retriever import food_retriever_50, docsearch
|
| 5 |
from chatbot.knowledge.vibe import vibes_cooking, vibes_flavor, vibes_healthy, vibes_soup_veg, vibes_style
|
| 6 |
+
import time
|
| 7 |
|
| 8 |
STAPLE_IDS = ["112", "1852", "2236", "2386", "2388"]
|
| 9 |
|
|
|
|
| 52 |
health_status = profile.get('healthStatus', '') # VD: Suy thận
|
| 53 |
|
| 54 |
constraint_prompt = ""
|
| 55 |
+
if restrictions not in ["Không có"]:
|
| 56 |
constraint_prompt += f"Yêu cầu bắt buộc: {restrictions}. "
|
| 57 |
if health_status not in ["Khỏe mạnh", "Không có", "Bình thường", None]:
|
| 58 |
constraint_prompt += f"Phù hợp người bệnh: {health_status}. "
|
| 59 |
+
if diet_mode not in ["Bình thường"]:
|
| 60 |
constraint_prompt += f"Chế độ: {diet_mode}."
|
| 61 |
|
| 62 |
prompt_templates = {
|
|
|
|
| 81 |
final_query = f"{base_prompt} Phong cách: {vibe}.{' Ràng buộc: ' + numerical_query if numerical_query else ''}"
|
| 82 |
logger.info(f"🔎 Query ({meal_type}): {final_query}")
|
| 83 |
|
| 84 |
+
time_start = time.time()
|
| 85 |
docs = food_retriever_50.invoke(final_query)
|
| 86 |
+
time_end = time.time()
|
| 87 |
+
logger.info(f"Thời gian thực thi: {round(time_end - time_start, 2)}s")
|
| 88 |
if not docs:
|
| 89 |
logger.warning(f"⚠️ Retriever trả về rỗng cho bữa: {meal_type}")
|
| 90 |
continue
|
|
|
|
| 290 |
scored_list.sort(key=lambda x: x["health_score"], reverse=True)
|
| 291 |
|
| 292 |
# # Debug: In Top 3
|
| 293 |
+
# logger.info("Top 3 Món Tốt Nhất (Sau khi chấm điểm):")
|
| 294 |
# for i, m in enumerate(scored_list[:3]):
|
| 295 |
# logger.info(f" {i+1}. {m['name']} (Score: {m['health_score']}) | {m.get('score_reason')}")
|
| 296 |
|
chatbot/agents/nodes/app_functions/optimize_macros.py
CHANGED
|
@@ -11,7 +11,6 @@ def optimize_portions_scipy(state: AgentState):
|
|
| 11 |
logger.info("---NODE: SCIPY OPTIMIZER (FINAL VERSION)---")
|
| 12 |
profile = state.get("user_profile", {})
|
| 13 |
menu = state.get("selected_structure", [])
|
| 14 |
-
reason = state.get("reason", "")
|
| 15 |
|
| 16 |
if not menu:
|
| 17 |
print("⚠️ Menu rỗng, bỏ qua tối ưu hóa.")
|
|
@@ -122,7 +121,7 @@ def optimize_portions_scipy(state: AgentState):
|
|
| 122 |
d = (current_meal_kcal - target_meal) / (target_meal + 1e-5)
|
| 123 |
loss_dist += (d ** 2)
|
| 124 |
|
| 125 |
-
return loss_macro +
|
| 126 |
|
| 127 |
# 5. Run Optimization
|
| 128 |
logger.info("Đang tối ưu hóa phần suất món ăn...")
|
|
@@ -206,6 +205,5 @@ def optimize_portions_scipy(state: AgentState):
|
|
| 206 |
|
| 207 |
return {
|
| 208 |
"final_menu": final_menu,
|
| 209 |
-
"reason": reason,
|
| 210 |
"user_profile": profile
|
| 211 |
}
|
|
|
|
| 11 |
logger.info("---NODE: SCIPY OPTIMIZER (FINAL VERSION)---")
|
| 12 |
profile = state.get("user_profile", {})
|
| 13 |
menu = state.get("selected_structure", [])
|
|
|
|
| 14 |
|
| 15 |
if not menu:
|
| 16 |
print("⚠️ Menu rỗng, bỏ qua tối ưu hóa.")
|
|
|
|
| 121 |
d = (current_meal_kcal - target_meal) / (target_meal + 1e-5)
|
| 122 |
loss_dist += (d ** 2)
|
| 123 |
|
| 124 |
+
return 3 * loss_macro + loss_dist
|
| 125 |
|
| 126 |
# 5. Run Optimization
|
| 127 |
logger.info("Đang tối ưu hóa phần suất món ăn...")
|
|
|
|
| 205 |
|
| 206 |
return {
|
| 207 |
"final_menu": final_menu,
|
|
|
|
| 208 |
"user_profile": profile
|
| 209 |
}
|
chatbot/agents/nodes/app_functions/select_meal.py
CHANGED
|
@@ -2,6 +2,7 @@ from langchain_core.pydantic_v1 import BaseModel, Field
|
|
| 2 |
from chatbot.models.llm_setup import llm
|
| 3 |
from chatbot.agents.states.state import SwapState
|
| 4 |
import logging
|
|
|
|
| 5 |
|
| 6 |
logging.basicConfig(level=logging.INFO)
|
| 7 |
logger = logging.getLogger(__name__)
|
|
@@ -44,7 +45,10 @@ def llm_finalize_choice(state: SwapState):
|
|
| 44 |
# 3. Gọi LLM
|
| 45 |
try:
|
| 46 |
llm_structured = llm.with_structured_output(ChefDecision)
|
|
|
|
| 47 |
decision = llm_structured.invoke(system_prompt)
|
|
|
|
|
|
|
| 48 |
target_id = decision.selected_meal_id
|
| 49 |
except Exception as e:
|
| 50 |
logger.info(f"⚠️ Lỗi LLM: {e}. Fallback về option đầu tiên.")
|
|
|
|
| 2 |
from chatbot.models.llm_setup import llm
|
| 3 |
from chatbot.agents.states.state import SwapState
|
| 4 |
import logging
|
| 5 |
+
import time
|
| 6 |
|
| 7 |
logging.basicConfig(level=logging.INFO)
|
| 8 |
logger = logging.getLogger(__name__)
|
|
|
|
| 45 |
# 3. Gọi LLM
|
| 46 |
try:
|
| 47 |
llm_structured = llm.with_structured_output(ChefDecision)
|
| 48 |
+
time_start = time.time()
|
| 49 |
decision = llm_structured.invoke(system_prompt)
|
| 50 |
+
time_end = time.time()
|
| 51 |
+
logger.info(f"⏱️ Thời gian LLM: {time_end - time_start:.2f} giây")
|
| 52 |
target_id = decision.selected_meal_id
|
| 53 |
except Exception as e:
|
| 54 |
logger.info(f"⚠️ Lỗi LLM: {e}. Fallback về option đầu tiên.")
|
chatbot/agents/nodes/app_functions/select_menu.py
CHANGED
|
@@ -4,6 +4,7 @@ from collections import defaultdict
|
|
| 4 |
import logging
|
| 5 |
from chatbot.agents.states.state import AgentState
|
| 6 |
from chatbot.models.llm_setup import llm
|
|
|
|
| 7 |
|
| 8 |
# --- Cấu hình logging ---
|
| 9 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -11,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|
| 11 |
|
| 12 |
# --- DATA MODELS ---
|
| 13 |
class SelectedDish(BaseModel):
|
| 14 |
-
|
| 15 |
meal_type: str = Field(description="Bữa ăn (sáng/trưa/tối)")
|
| 16 |
role: Literal["main", "carb", "side"] = Field(
|
| 17 |
description="Vai trò: 'main' (Món mặn/Đạm), 'carb' (Cơm/Tinh bột), 'side' (Rau/Canh)"
|
|
@@ -19,7 +20,6 @@ class SelectedDish(BaseModel):
|
|
| 19 |
|
| 20 |
class DailyMenuStructure(BaseModel):
|
| 21 |
dishes: List[SelectedDish] = Field(description="Danh sách các món ăn được chọn")
|
| 22 |
-
reason: str = Field(description="Lý do tổng quan cho thực đơn này và đánh giá thực đơn đã chọn")
|
| 23 |
|
| 24 |
def select_menu_structure(state: AgentState):
|
| 25 |
logger.info("---NODE: AI SELECTOR (FULL MACRO AWARE)---")
|
|
@@ -83,7 +83,7 @@ MỤC TIÊU CỤ THỂ TỪNG BỮA (Hãy nhẩm tính để chọn món sát v
|
|
| 83 |
{f"- TỐI : ~{get_target_str('tối')}" if 'tối' in meals_req else ""}
|
| 84 |
{safety_instruction}
|
| 85 |
|
| 86 |
-
DỮ LIỆU ĐẦU VÀO (Tên món -
|
| 87 |
{primary_text}
|
| 88 |
{backup_text}
|
| 89 |
|
|
@@ -92,7 +92,7 @@ NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
|
|
| 92 |
- SÁNG: 1 Món chính (Ưu tiên món nước/bánh mì).
|
| 93 |
- TRƯA & TỐI: Không bắt buộc phải đủ 3 món. Hãy chọn theo 1 trong 2 cách sau:
|
| 94 |
+ Cách A (Món hỗn hợp): Chọn 1-2 món nếu món đó là món hỗn hợp (VD: Bún, Mì, Nui, Cơm rang, Salad thịt...) và đã cung cấp đủ Kcal/Protein/Carb gần với Target.
|
| 95 |
-
+ Cách B (Cơm gia đình): Nếu chọn món mặn rời
|
| 96 |
=> MỤC TIÊU: Tổng Kcal của bữa ăn phải sát với Target (sai số cho phép ~10-15%).
|
| 97 |
|
| 98 |
2. Quy tắc Ưu tiên & Dự phòng:
|
|
@@ -110,7 +110,11 @@ NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
|
|
| 110 |
try:
|
| 111 |
logger.info("Đang gọi LLM lựa chọn món...")
|
| 112 |
llm_structured = llm.with_structured_output(DailyMenuStructure, strict=True)
|
|
|
|
|
|
|
| 113 |
result = llm_structured.invoke(system_prompt)
|
|
|
|
|
|
|
| 114 |
|
| 115 |
if not result or not hasattr(result, 'dishes'):
|
| 116 |
raise ValueError("LLM trả về kết quả rỗng hoặc sai định dạng object.")
|
|
@@ -119,28 +123,39 @@ NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
|
|
| 119 |
logger.error(f"🔥 LỖI GỌI LLM SELECTOR: {e}")
|
| 120 |
return {"selected_structure": [], "reason": "Lỗi hệ thống khi chọn món."}
|
| 121 |
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
menu_by_meal = defaultdict(list)
|
|
|
|
| 124 |
for dish in daily_menu.dishes:
|
| 125 |
menu_by_meal[dish.meal_type.lower()].append(dish)
|
|
|
|
| 126 |
meal_order = ["sáng", "trưa", "tối"]
|
|
|
|
| 127 |
for meal in meal_order:
|
| 128 |
if meal in menu_by_meal:
|
| 129 |
logger.info(f"\n🍽 Bữa {meal.upper()}:")
|
| 130 |
for d in menu_by_meal[meal]:
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
logger.info("\n--- MENU ĐÃ CHỌN ---")
|
| 134 |
-
print_menu_by_meal(result)
|
| 135 |
|
| 136 |
# 4. HẬU XỬ LÝ (Gán Bounds)
|
| 137 |
selected_full_info = []
|
| 138 |
-
all_clean_candidates = primary_pool + backup_pool
|
| 139 |
-
candidate_map = {m['name']: m for m in all_clean_candidates}
|
| 140 |
|
| 141 |
for choice in result.dishes:
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
dish_data["assigned_meal"] = choice.meal_type
|
| 145 |
|
| 146 |
d_kcal = float(dish_data.get("kcal", 0))
|
|
@@ -154,11 +169,11 @@ NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
|
|
| 154 |
final_role = choice.role
|
| 155 |
# 1. Phát hiện "Carb trá hình" (Cơm chiên/Mì xào quá nhiều thịt)
|
| 156 |
if final_role == "carb" and d_pro > 15:
|
| 157 |
-
logger.info(f" ⚠️ Phát hiện Carb giàu đạm ({
|
| 158 |
final_role = "main"
|
| 159 |
# 2. Phát hiện "Side giàu đạm" (Salad gà/bò, Canh sườn)
|
| 160 |
elif final_role == "side" and d_pro > 10:
|
| 161 |
-
logger.info(f" ⚠️ Phát hiện Side giàu đạm ({
|
| 162 |
final_role = "main"
|
| 163 |
|
| 164 |
dish_data["role"] = final_role
|
|
@@ -183,19 +198,19 @@ NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
|
|
| 183 |
# --- GIAI ĐOẠN 3: KIỂM TRA AN TOÀN & GHI ĐÈ ---
|
| 184 |
# Override A: Nếu món Main có Protein quá lớn so với Target
|
| 185 |
if final_role == "main" and d_pro > t_pro:
|
| 186 |
-
logger.info(f" ⚠️ Món {
|
| 187 |
lower_bound = 0.3
|
| 188 |
upper_bound = min(upper_bound, 1.2)
|
| 189 |
|
| 190 |
# Override B: Nếu món quá nhiều Calo (Chiếm > 80% Kcal cả bữa)
|
| 191 |
if d_kcal > (t_kcal * 0.8):
|
| 192 |
-
logger.info(f" ⚠️ Món {
|
| 193 |
lower_bound = 0.3
|
| 194 |
upper_bound = min(upper_bound, 1.0)
|
| 195 |
|
| 196 |
# Override C: Nếu là món Side nhưng Protein vẫn hơi cao (5-10g)
|
| 197 |
if final_role == "side" and d_pro > 5:
|
| 198 |
-
logger.info(f" ⚠️ Món {
|
| 199 |
lower_bound = 0.2
|
| 200 |
|
| 201 |
# --- KẾT THÚC: GÁN VÀO DỮ LIỆU ---
|
|
@@ -204,28 +219,15 @@ NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
|
|
| 204 |
|
| 205 |
return {
|
| 206 |
"selected_structure": selected_full_info,
|
| 207 |
-
"reason": result.reason
|
| 208 |
}
|
| 209 |
|
| 210 |
def format_pool_detailed(pool, title):
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
role_hint = "[Role: Rau/Canh]"
|
| 221 |
-
elif any(w in name_lower for w in ["cơm", "bún", "phở", "mì", "miến", "xôi", "cháo", "bánh mì"]):
|
| 222 |
-
role_hint = "[Role: Tinh Bột]"
|
| 223 |
-
else:
|
| 224 |
-
role_hint = "[Role: Món Mặn]"
|
| 225 |
-
|
| 226 |
-
stats = f"({int(m.get('kcal',0))}k, P:{int(m.get('protein',0))}, F:{int(m.get('totalfat',0))}, C:{int(m.get('carbs',0))})"
|
| 227 |
-
|
| 228 |
-
# Kết hợp: "Món A [Role] (500k, P30...)"
|
| 229 |
-
text += f"- {name} {role_hint} {stats}\n"
|
| 230 |
-
|
| 231 |
-
return text
|
|
|
|
| 4 |
import logging
|
| 5 |
from chatbot.agents.states.state import AgentState
|
| 6 |
from chatbot.models.llm_setup import llm
|
| 7 |
+
import time
|
| 8 |
|
| 9 |
# --- Cấu hình logging ---
|
| 10 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 12 |
|
| 13 |
# --- DATA MODELS ---
|
| 14 |
class SelectedDish(BaseModel):
|
| 15 |
+
dish_id: str = Field(description="ID duy nhất của món ăn (được ghi trong dấu ngoặc vuông [ID: ...])")
|
| 16 |
meal_type: str = Field(description="Bữa ăn (sáng/trưa/tối)")
|
| 17 |
role: Literal["main", "carb", "side"] = Field(
|
| 18 |
description="Vai trò: 'main' (Món mặn/Đạm), 'carb' (Cơm/Tinh bột), 'side' (Rau/Canh)"
|
|
|
|
| 20 |
|
| 21 |
class DailyMenuStructure(BaseModel):
|
| 22 |
dishes: List[SelectedDish] = Field(description="Danh sách các món ăn được chọn")
|
|
|
|
| 23 |
|
| 24 |
def select_menu_structure(state: AgentState):
|
| 25 |
logger.info("---NODE: AI SELECTOR (FULL MACRO AWARE)---")
|
|
|
|
| 83 |
{f"- TỐI : ~{get_target_str('tối')}" if 'tối' in meals_req else ""}
|
| 84 |
{safety_instruction}
|
| 85 |
|
| 86 |
+
DỮ LIỆU ĐẦU VÀO (Định dạng: [ID] Tên món - Dinh dưỡng):
|
| 87 |
{primary_text}
|
| 88 |
{backup_text}
|
| 89 |
|
|
|
|
| 92 |
- SÁNG: 1 Món chính (Ưu tiên món nước/bánh mì).
|
| 93 |
- TRƯA & TỐI: Không bắt buộc phải đủ 3 món. Hãy chọn theo 1 trong 2 cách sau:
|
| 94 |
+ Cách A (Món hỗn hợp): Chọn 1-2 món nếu món đó là món hỗn hợp (VD: Bún, Mì, Nui, Cơm rang, Salad thịt...) và đã cung cấp đủ Kcal/Protein/Carb gần với Target.
|
| 95 |
+
+ Cách B (Cơm gia đình): Nếu chọn món mặn rời ít Carb hãy ghép thêm [Tinh Bột], nếu ít Rau hãy thêm [Rau/Canh] để cân bằng.
|
| 96 |
=> MỤC TIÊU: Tổng Kcal của bữa ăn phải sát với Target (sai số cho phép ~10-15%).
|
| 97 |
|
| 98 |
2. Quy tắc Ưu tiên & Dự phòng:
|
|
|
|
| 110 |
try:
|
| 111 |
logger.info("Đang gọi LLM lựa chọn món...")
|
| 112 |
llm_structured = llm.with_structured_output(DailyMenuStructure, strict=True)
|
| 113 |
+
|
| 114 |
+
time_start = time.time()
|
| 115 |
result = llm_structured.invoke(system_prompt)
|
| 116 |
+
time_end = time.time()
|
| 117 |
+
logger.info(f"LLM chọn món thành công trong {int((time_end - time_start)*1000)} ms.")
|
| 118 |
|
| 119 |
if not result or not hasattr(result, 'dishes'):
|
| 120 |
raise ValueError("LLM trả về kết quả rỗng hoặc sai định dạng object.")
|
|
|
|
| 123 |
logger.error(f"🔥 LỖI GỌI LLM SELECTOR: {e}")
|
| 124 |
return {"selected_structure": [], "reason": "Lỗi hệ thống khi chọn món."}
|
| 125 |
|
| 126 |
+
|
| 127 |
+
all_clean_candidates = primary_pool + backup_pool
|
| 128 |
+
candidate_map = {str(m.get('id') or m.get('meal_id')): m for m in all_clean_candidates}
|
| 129 |
+
|
| 130 |
+
def print_menu_by_meal(daily_menu, lookup_map):
|
| 131 |
menu_by_meal = defaultdict(list)
|
| 132 |
+
|
| 133 |
for dish in daily_menu.dishes:
|
| 134 |
menu_by_meal[dish.meal_type.lower()].append(dish)
|
| 135 |
+
|
| 136 |
meal_order = ["sáng", "trưa", "tối"]
|
| 137 |
+
|
| 138 |
for meal in meal_order:
|
| 139 |
if meal in menu_by_meal:
|
| 140 |
logger.info(f"\n🍽 Bữa {meal.upper()}:")
|
| 141 |
for d in menu_by_meal[meal]:
|
| 142 |
+
d_id = str(d.dish_id)
|
| 143 |
+
if d_id in lookup_map:
|
| 144 |
+
d_name = lookup_map[d_id]['name']
|
| 145 |
+
logger.info(f" - [ID:{d_id}] {d_name} ({d.role})")
|
| 146 |
+
else:
|
| 147 |
+
logger.info(f" - [ID:{d_id}] ??? (Không tìm thấy trong kho) ({d.role})")
|
| 148 |
|
| 149 |
logger.info("\n--- MENU ĐÃ CHỌN ---")
|
| 150 |
+
print_menu_by_meal(result, candidate_map)
|
| 151 |
|
| 152 |
# 4. HẬU XỬ LÝ (Gán Bounds)
|
| 153 |
selected_full_info = []
|
|
|
|
|
|
|
| 154 |
|
| 155 |
for choice in result.dishes:
|
| 156 |
+
chosen_id = str(choice.dish_id)
|
| 157 |
+
if chosen_id in candidate_map:
|
| 158 |
+
dish_data = candidate_map[chosen_id].copy()
|
| 159 |
dish_data["assigned_meal"] = choice.meal_type
|
| 160 |
|
| 161 |
d_kcal = float(dish_data.get("kcal", 0))
|
|
|
|
| 169 |
final_role = choice.role
|
| 170 |
# 1. Phát hiện "Carb trá hình" (Cơm chiên/Mì xào quá nhiều thịt)
|
| 171 |
if final_role == "carb" and d_pro > 15:
|
| 172 |
+
logger.info(f" ⚠️ Phát hiện Carb giàu đạm ({dish_data['name']}: {d_pro}g Pro). Đổi role sang 'main'.")
|
| 173 |
final_role = "main"
|
| 174 |
# 2. Phát hiện "Side giàu đạm" (Salad gà/bò, Canh sườn)
|
| 175 |
elif final_role == "side" and d_pro > 10:
|
| 176 |
+
logger.info(f" ⚠️ Phát hiện Side giàu đạm ({dish_data['name']}: {d_pro}g Pro). Đổi role sang 'main'.")
|
| 177 |
final_role = "main"
|
| 178 |
|
| 179 |
dish_data["role"] = final_role
|
|
|
|
| 198 |
# --- GIAI ĐOẠN 3: KIỂM TRA AN TOÀN & GHI ĐÈ ---
|
| 199 |
# Override A: Nếu món Main có Protein quá lớn so với Target
|
| 200 |
if final_role == "main" and d_pro > t_pro:
|
| 201 |
+
logger.info(f" ⚠️ Món {dish_data['name']} thừa đạm ({d_pro}g > {t_pro}g). Mở rộng bound xuống thấp.")
|
| 202 |
lower_bound = 0.3
|
| 203 |
upper_bound = min(upper_bound, 1.2)
|
| 204 |
|
| 205 |
# Override B: Nếu món quá nhiều Calo (Chiếm > 80% Kcal cả bữa)
|
| 206 |
if d_kcal > (t_kcal * 0.8):
|
| 207 |
+
logger.info(f" ⚠️ Món {dish_data['name']} quá đậm năng lượng ({d_kcal} kcal). Siết chặt bound.")
|
| 208 |
lower_bound = 0.3
|
| 209 |
upper_bound = min(upper_bound, 1.0)
|
| 210 |
|
| 211 |
# Override C: Nếu là món Side nhưng Protein vẫn hơi cao (5-10g)
|
| 212 |
if final_role == "side" and d_pro > 5:
|
| 213 |
+
logger.info(f" ⚠️ Món {dish_data['name']} Side có đạm hơi cao ({d_pro}g). Hạ thấp bound.")
|
| 214 |
lower_bound = 0.2
|
| 215 |
|
| 216 |
# --- KẾT THÚC: GÁN VÀO DỮ LIỆU ---
|
|
|
|
| 219 |
|
| 220 |
return {
|
| 221 |
"selected_structure": selected_full_info,
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
def format_pool_detailed(pool, title):
|
| 225 |
+
if not pool: return ""
|
| 226 |
+
text = f"--- {title} ---\n"
|
| 227 |
+
for m in pool:
|
| 228 |
+
d_id = m.get('id') or m.get('meal_id')
|
| 229 |
+
name = m['name']
|
| 230 |
+
stats = f"({int(m.get('kcal',0))}k, P:{int(m.get('protein',0))}, F:{int(m.get('totalfat',0))}, C:{int(m.get('carbs',0))})"
|
| 231 |
+
text += f"- [ID: {d_id}] {name} {stats}\n"
|
| 232 |
+
|
| 233 |
+
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chatbot/routes/meal_plan_route.py
CHANGED
|
@@ -39,7 +39,6 @@ def generate_meal_plan(request: Request):
|
|
| 39 |
final_state = meal_app.invoke(initial_state)
|
| 40 |
response = {
|
| 41 |
"final_menu": final_state["final_menu"],
|
| 42 |
-
"reason": final_state["reason"]
|
| 43 |
}
|
| 44 |
|
| 45 |
if not response["final_menu"]:
|
|
|
|
| 39 |
final_state = meal_app.invoke(initial_state)
|
| 40 |
response = {
|
| 41 |
"final_menu": final_state["final_menu"],
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
if not response["final_menu"]:
|