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