truglpk3's picture
.
445252b
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