Spaces:
Running
Running
File size: 10,691 Bytes
b5961aa 445252b b5961aa 445252b b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e 445252b 29b313e 445252b 29b313e b5961aa 29b313e 445252b 29b313e 445252b 29b313e 445252b b5961aa 445252b b5961aa 445252b b5961aa 445252b b5961aa 445252b b5961aa 445252b b5961aa 445252b b5961aa 29b313e b5961aa 445252b b5961aa 445252b b5961aa 29b313e b5961aa 29b313e b5961aa 29b313e b5961aa 445252b 29b313e b5961aa 445252b b5961aa 29b313e b5961aa 445252b 29b313e b5961aa 29b313e 445252b |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 |
from langchain_core.pydantic_v1 import BaseModel, Field
from typing import Literal, List
from collections import defaultdict
import logging
from chatbot.agents.states.state import AgentState
from chatbot.models.llm_setup import llm
import time
# --- Cấu hình logging ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# --- DATA MODELS ---
class SelectedDish(BaseModel):
dish_id: str = Field(description="ID duy nhất của món ăn (được ghi trong dấu ngoặc vuông [ID: ...])")
meal_type: str = Field(description="Bữa ăn (sáng/trưa/tối)")
role: Literal["main", "carb", "side"] = Field(
description="Vai trò: 'main' (Món mặn/Đạm), 'carb' (Cơm/Tinh bột), 'side' (Rau/Canh)"
)
class DailyMenuStructure(BaseModel):
dishes: List[SelectedDish] = Field(description="Danh sách các món ăn được chọn")
def select_menu_structure(state: AgentState):
logger.info("---NODE: AI SELECTOR (FULL MACRO AWARE)---")
profile = state.get("user_profile", {})
full_pool = state.get("candidate_pool", [])
meals_req = state.get("meals_to_generate", [])
if len(full_pool) == 0:
logger.warning("⚠️ Danh sách ứng viên rỗng, không thể chọn món.")
return {"selected_structure": []}
# 1. TÍNH TOÁN MỤC TIÊU CHI TIẾT TỪNG BỮA
daily_targets = {
"kcal": float(profile.get('targetcalories', 0)),
"protein": float(profile.get('protein', 0)),
"totalfat": float(profile.get('totalfat', 0)),
"carbs": float(profile.get('carbohydrate', 0))
}
ratios = {"sáng": 0.25, "trưa": 0.40, "tối": 0.35}
meal_targets = {}
for meal, ratio in ratios.items():
meal_targets[meal] = {
k: int(v * ratio) for k, v in daily_targets.items()
}
# --- LOGIC TẠO HƯỚNG DẪN ĐỘNG CHO PROMPT ---
avoid_items = ", ".join(profile.get('Kiêng', []))
limit_items = ", ".join(profile.get('Hạn chế', []))
health_condition = profile.get('healthStatus', 'Bình thường')
safety_instruction = ""
if health_condition and health_condition.strip() not in ["Bình thường", "Không có", "Khỏe mạnh"]:
safety_instruction += f"- Tình trạng sức khỏe: {health_condition}.\n"
if avoid_items:
safety_instruction += f"- TUYỆT ĐỐI TRÁNH: {avoid_items}. (Nếu thấy món chứa thành phần này trong danh sách, hãy BỎ QUA ngay lập tức).\n"
if limit_items:
safety_instruction += f"- HẠN CHẾ TỐI ĐA: {limit_items}.\n"
if safety_instruction:
safety_instruction = f"\nNGUYÊN TẮC AN TOÀN:\n{safety_instruction}\n"
# 2. TIỀN XỬ LÝ & PHÂN NHÓM CANDIDATES
primary_pool = [m for m in full_pool if not m.get("is_fallback", False)]
backup_pool = [m for m in full_pool if m.get("is_fallback", False)]
primary_text = format_pool_detailed(primary_pool, "KHO MÓN ĂN NGON (Ưu tiên dùng)")
backup_text = format_pool_detailed(backup_pool, "KHO LƯƠNG THỰC CƠ BẢN")
# 3. XÂY DỰNG PROMPT
def get_target_str(meal):
t = meal_targets.get(meal, {})
return f"{t.get('kcal')} Kcal (P: {t.get('protein')}g, Fat: {t.get('totalfat')}g, Carb: {t.get('carbs')}g)"
system_prompt = f"""
Vai trò: Đầu bếp trưởng kiêm Chuyên gia dinh dưỡng.
Nhiệm vụ: Ghép thực đơn cho: {', '.join(meals_req)}.
MỤC TIÊU CỤ THỂ TỪNG BỮA (Hãy nhẩm tính để chọn món sát với mục tiêu nhất):
{f"- SÁNG: ~{get_target_str('sáng')}" if 'sáng' in meals_req else ""}
{f"- TRƯA: ~{get_target_str('trưa')}" if 'trưa' in meals_req else ""}
{f"- TỐI : ~{get_target_str('tối')}" if 'tối' in meals_req else ""}
{safety_instruction}
DỮ LIỆU ĐẦU VÀO (Định dạng: [ID] Tên món - Dinh dưỡng):
{primary_text}
{backup_text}
NGUYÊN TẮC CHỌN MÓN (QUAN TRỌNG):
1. Cấu trúc & Dinh dưỡng (Linh hoạt):
- SÁNG: 1 Món chính (Ưu tiên món nước/bánh mì).
- 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:
+ 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.
+ 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.
=> 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%).
2. Quy tắc Ưu tiên & Dự phòng:
- Luôn quét trong "KHO MÓN ĂN NGON" trước.
- Nếu chọn Cách B: Hãy tìm món canh/rau trong kho ngon trước. Chỉ khi kho ngon không có hoặc làm vỡ Target Kcal (quá cao), mới lấy Cơm/Rau từ "KHO LƯƠNG THỰC CƠ BẢN".
3. Chiến thuật ghép món:
- Nếu Target bữa thấp (<500k): Ưu tiên 1 món hỗn hợp nhẹ hoặc bộ 3 món (Cá/Hấp + Cơm ít + Canh rau).
- Nếu Target bữa cao (>700k): Ưu tiên bộ 3 món đầy đủ hoặc món hỗn hợp đậm đà.
"""
logger.info("Prompt:")
logger.info(system_prompt)
try:
logger.info("Đang gọi LLM lựa chọn món...")
llm_structured = llm.with_structured_output(DailyMenuStructure, strict=True)
time_start = time.time()
result = llm_structured.invoke(system_prompt)
time_end = time.time()
logger.info(f"LLM chọn món thành công trong {int((time_end - time_start)*1000)} ms.")
if not result or not hasattr(result, 'dishes'):
raise ValueError("LLM trả về kết quả rỗng hoặc sai định dạng object.")
except Exception as e:
logger.error(f"🔥 LỖI GỌI LLM SELECTOR: {e}")
return {"selected_structure": [], "reason": "Lỗi hệ thống khi chọn món."}
all_clean_candidates = primary_pool + backup_pool
candidate_map = {str(m.get('id') or m.get('meal_id')): m for m in all_clean_candidates}
def print_menu_by_meal(daily_menu, lookup_map):
menu_by_meal = defaultdict(list)
for dish in daily_menu.dishes:
menu_by_meal[dish.meal_type.lower()].append(dish)
meal_order = ["sáng", "trưa", "tối"]
for meal in meal_order:
if meal in menu_by_meal:
logger.info(f"\n🍽 Bữa {meal.upper()}:")
for d in menu_by_meal[meal]:
d_id = str(d.dish_id)
if d_id in lookup_map:
d_name = lookup_map[d_id]['name']
logger.info(f" - [ID:{d_id}] {d_name} ({d.role})")
else:
logger.info(f" - [ID:{d_id}] ??? (Không tìm thấy trong kho) ({d.role})")
logger.info("\n--- MENU ĐÃ CHỌN ---")
print_menu_by_meal(result, candidate_map)
# 4. HẬU XỬ LÝ (Gán Bounds)
selected_full_info = []
for choice in result.dishes:
chosen_id = str(choice.dish_id)
if chosen_id in candidate_map:
dish_data = candidate_map[chosen_id].copy()
dish_data["assigned_meal"] = choice.meal_type
d_kcal = float(dish_data.get("kcal", 0))
d_pro = float(dish_data.get("protein", 0))
t_target = meal_targets.get(choice.meal_type.lower(), {})
t_kcal = t_target.get("kcal", 500)
t_pro = t_target.get("protein", 30)
# --- GIAI ĐOẠN 1: TỰ ĐỘNG SỬA SAI VAI TRÒ ---
final_role = choice.role
# 1. Phát hiện "Carb trá hình" (Cơm chiên/Mì xào quá nhiều thịt)
if final_role == "carb" and d_pro > 15:
logger.info(f" ⚠️ Phát hiện Carb giàu đạm ({dish_data['name']}: {d_pro}g Pro). Đổi role sang 'main'.")
final_role = "main"
# 2. Phát hiện "Side giàu đạm" (Salad gà/bò, Canh sườn)
elif final_role == "side" and d_pro > 10:
logger.info(f" ⚠️ Phát hiện Side giàu đạm ({dish_data['name']}: {d_pro}g Pro). Đổi role sang 'main'.")
final_role = "main"
dish_data["role"] = final_role
# --- GIAI ĐOẠN 2: THIẾT LẬP BOUNDS CƠ BẢN ---
lower_bound = 0.5
upper_bound = 1.5
if final_role == "carb":
# Cơm/Bún thuần: Cho phép co dãn cực mạnh để bù Kcal
lower_bound, upper_bound = 0.4, 3.0
elif final_role == "side":
# Rau/Canh: Co dãn rộng để bù thể tích ăn
lower_bound, upper_bound = 0.5, 2.0
elif final_role == "main":
# Món mặn: Co dãn vừa phải để giữ hương vị
lower_bound, upper_bound = 0.6, 1.8
# --- GIAI ĐOẠN 3: KIỂM TRA AN TOÀN & GHI ĐÈ ---
# Override A: Nếu món Main có Protein quá lớn so với Target
if final_role == "main" and d_pro > t_pro:
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.")
lower_bound = 0.3
upper_bound = min(upper_bound, 1.2)
# Override B: Nếu món quá nhiều Calo (Chiếm > 80% Kcal cả bữa)
if d_kcal > (t_kcal * 0.8):
logger.info(f" ⚠️ Món {dish_data['name']} quá đậm năng lượng ({d_kcal} kcal). Siết chặt bound.")
lower_bound = 0.3
upper_bound = min(upper_bound, 1.0)
# Override C: Nếu là món Side nhưng Protein vẫn hơi cao (5-10g)
if final_role == "side" and d_pro > 5:
logger.info(f" ⚠️ Món {dish_data['name']} Side có đạm hơi cao ({d_pro}g). Hạ thấp bound.")
lower_bound = 0.2
# --- KẾT THÚC: GÁN VÀO DỮ LIỆU ---
dish_data["solver_bounds"] = (lower_bound, upper_bound)
selected_full_info.append(dish_data)
return {
"selected_structure": selected_full_info,
}
def format_pool_detailed(pool, title):
if not pool: return ""
text = f"--- {title} ---\n"
for m in pool:
d_id = m.get('id') or m.get('meal_id')
name = m['name']
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))})"
text += f"- [ID: {d_id}] {name} {stats}\n"
return text |