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