Spaces:
Running
Running
| import random | |
| import logging | |
| from chatbot.agents.states.state import AgentState | |
| from chatbot.agents.tools.food_retriever import food_retriever_50, docsearch | |
| from chatbot.knowledge.vibe import vibes_cooking, vibes_flavor, vibes_healthy, vibes_soup_veg, vibes_style | |
| import time | |
| STAPLE_IDS = ["112", "1852", "2236", "2386", "2388"] | |
| # --- Cấu hình logging --- | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| def generate_food_candidates(state: AgentState): | |
| logger.info("---NODE: RETRIEVAL CANDIDATES (ADVANCED PROFILE)---") | |
| meals = state.get("meals_to_generate", []) | |
| profile = state["user_profile"] | |
| candidates = [] | |
| # 1. NẠP KHO DỰ PHÒNG TỪ ELASTICSEARCH (BY ID) | |
| try: | |
| staples_data = fetch_staples_by_ids(docsearch, STAPLE_IDS) | |
| if not staples_data: | |
| staples_data = [] | |
| for staple in staples_data: | |
| name_lower = staple.get("name", "").lower() | |
| target_meals = [] | |
| if any(x in name_lower for x in ["cơm", "canh", "rau", "kho", "đậu"]): | |
| target_meals = ["trưa", "tối"] | |
| elif any(x in name_lower for x in ["bánh mì", "xôi", "trứng", "bún", "phở"]): | |
| target_meals = ["sáng"] | |
| else: | |
| target_meals = ["sáng", "trưa", "tối"] | |
| for meal in target_meals: | |
| if meal in meals: | |
| s_copy = staple.copy() | |
| s_copy["meal_type_tag"] = meal | |
| s_copy["retrieval_vibe"] = "Món ăn kèm cơ bản" | |
| candidates.append(s_copy) | |
| except Exception as e: | |
| logger.warning(f"⚠️ Lỗi khi nạp Staples (Kho dự phòng): {e}") | |
| # 2. XỬ LÝ DỮ LIỆU PROFILE NGƯỜI DÙNG | |
| diet_mode = profile.get('diet', '') # VD: Chế độ HighProtein | |
| restrictions = profile.get('limitFood', '') # VD: Dị ứng sữa, Thuần chay | |
| health_status = profile.get('healthStatus', '') # VD: Suy thận | |
| constraint_prompt = "" | |
| if restrictions not in ["Không có"]: | |
| constraint_prompt += f"Yêu cầu bắt buộc: {restrictions}. " | |
| if health_status not in ["Khỏe mạnh", "Không có", "Bình thường", None]: | |
| constraint_prompt += f"Phù hợp người bệnh: {health_status}. " | |
| if diet_mode not in ["Bình thường"]: | |
| constraint_prompt += f"Chế độ: {diet_mode}." | |
| prompt_templates = { | |
| "sáng": f"Món ăn sáng, điểm tâm. Ưu tiên món nước hoặc món khô dễ tiêu hóa. {constraint_prompt}", | |
| "trưa": f"Món ăn chính cho bữa trưa. {constraint_prompt}", | |
| "tối": f"Món ăn tối, nhẹ bụng. {constraint_prompt}", | |
| } | |
| for meal_type in meals: | |
| try: | |
| logger.info(meal_type) | |
| base_prompt = prompt_templates.get(meal_type, f"Món ăn {meal_type}. {constraint_prompt}") | |
| try: | |
| vibe = get_random_vibe(meal_type) | |
| numerical_query = generate_numerical_constraints(profile, meal_type) | |
| except Exception as sub_e: | |
| logger.error(f"Lỗi logic phụ (vibe/numerical) cho bữa {meal_type}: {sub_e}") | |
| vibe = "Hài hòa" | |
| numerical_query = "" | |
| final_query = f"{base_prompt} Phong cách: {vibe}.{' Ràng buộc: ' + numerical_query if numerical_query else ''}" | |
| logger.info(f"🔎 Query ({meal_type}): {final_query}") | |
| time_start = time.time() | |
| docs = food_retriever_50.invoke(final_query) | |
| time_end = time.time() | |
| logger.info(f"Thời gian thực thi: {round(time_end - time_start, 2)}s") | |
| if not docs: | |
| logger.warning(f"⚠️ Retriever trả về rỗng cho bữa: {meal_type}") | |
| continue | |
| ranked_items = rank_candidates(docs, profile, meal_type) | |
| if ranked_items: | |
| top_n_count = min(len(ranked_items), 30) | |
| top_candidates = ranked_items[:top_n_count] | |
| random.shuffle(top_candidates) | |
| k = min(20, top_n_count) if len(meals) == 1 else min(10, top_n_count) | |
| selected_docs = top_candidates[:k] | |
| for item in selected_docs: | |
| candidate = item.copy() | |
| candidate["meal_type_tag"] = meal_type | |
| candidate["retrieval_vibe"] = vibe | |
| candidates.append(candidate) | |
| except Exception as e: | |
| logger.error(f"🔥 LỖI NGHIÊM TRỌNG khi retrieve bữa {meal_type}: {e}") | |
| continue | |
| unique_candidates = {v.get('name', 'Unknown'): v for v in candidates}.values() | |
| final_pool = list(unique_candidates) | |
| logger.info(f"📚 Candidate Pool Size: {len(final_pool)} món") | |
| if len(final_pool) == 0: | |
| logger.critical("❌ KHÔNG TÌM THẤY MÓN NÀO! Check lại DB connection.") | |
| return {"candidate_pool": final_pool, "meals_to_generate": meals} | |
| def generate_numerical_constraints(user_profile, meal_type): | |
| """ | |
| Tạo chuỗi ràng buộc số liệu dinh dưỡng dựa trên cấu hình người dùng. | |
| """ | |
| ratios = {"sáng": 0.25, "trưa": 0.40, "tối": 0.35} | |
| meal_ratio = ratios.get(meal_type, 0.3) | |
| critical_nutrients = { | |
| "Protein": ("protein", "protein", "g", "range"), | |
| "Saturated fat": ("saturatedfat", "saturatedfat", "g", "max"), | |
| "Natri": ("natri", "natri", "mg", "max"), | |
| "Kali": ("kali", "kali", "mg", "range"), | |
| "Phốt pho": ("photpho", "photpho", "mg", "max"), | |
| "Sugars": ("sugar", "sugar", "g", "max"), | |
| "Carbohydrate": ("carbohydrate", "carbs", "g", "range"), | |
| } | |
| constraints = [] | |
| check_list = set(user_profile.get('Kiêng', []) + user_profile.get('Hạn chế', [])) | |
| if "thận" in user_profile.get('healthStatus', '').lower(): | |
| check_list.update(["Protein", "Natri", "Kali", "Phốt pho"]) | |
| for item_name in check_list: | |
| if item_name not in critical_nutrients: continue | |
| config = critical_nutrients.get(item_name) | |
| profile_key, db_key, unit, logic = config | |
| daily_val = float(user_profile.get(profile_key, 0)) | |
| meal_target = daily_val * meal_ratio | |
| if logic == 'max': | |
| # Nới lỏng một chút ở bước tìm kiếm (120-130% target) để không bị lọc hết | |
| threshold = round(meal_target * 1.3, 2) | |
| constraints.append(f"{db_key} < {threshold}{unit}") | |
| elif logic == 'range': | |
| # Range rộng (50% - 150%) để bắt được nhiều món | |
| min_val = round(meal_target * 0.5, 2) | |
| max_val = round(meal_target * 1.5, 2) | |
| constraints.append(f"{db_key} > {min_val}{unit} - {db_key} < {max_val}{unit}") | |
| if not constraints: return "" | |
| return ", ".join(constraints) | |
| def rank_candidates(candidates, user_profile, meal_type): | |
| """ | |
| Chấm điểm (Scoring) các món ăn dựa trên cấu hình dinh dưỡng chi tiết. | |
| """ | |
| print("---NODE: RANKING CANDIDATES (ADVANCED SCORING)---") | |
| ratios = {"sáng": 0.25, "trưa": 0.40, "tối": 0.35} | |
| meal_ratio = ratios.get(meal_type, 0.3) | |
| nutrient_config = { | |
| # --- Nhóm Đa lượng (Macro) --- | |
| "Protein": ("protein", "protein", "g", "range"), | |
| "Total Fat": ("totalfat", "totalfat", "g", "max"), | |
| "Carbohydrate": ("carbohydrate", "carbs", "g", "range"), | |
| "Saturated fat": ("saturatedfat", "saturatedfat", "g", "max"), | |
| "Monounsaturated fat": ("monounsaturatedfat", "monounsaturatedfat", "g", "max"), | |
| "Trans fat": ("transfat", "transfat", "g", "max"), | |
| "Sugars": ("sugar", "sugar", "g", "max"), | |
| "Chất xơ": ("fiber", "fiber", "g", "min"), | |
| # --- Nhóm Vi chất (Micro) --- | |
| "Vitamin A": ("vitamina", "vitamina", "mg", "min"), | |
| "Vitamin C": ("vitaminc", "vitaminc", "mg", "min"), | |
| "Vitamin D": ("vitamind", "vitamind", "mg", "min"), | |
| "Vitamin E": ("vitamine", "vitamine", "mg", "min"), | |
| "Vitamin K": ("vitamink", "vitamink", "mg", "min"), | |
| "Vitamin B6": ("vitaminb6", "vitaminb6", "mg", "min"), | |
| "Vitamin B12": ("vitaminb12", "vitaminb12", "mg", "min"), | |
| # --- Khoáng chất --- | |
| "Canxi": ("canxi", "canxi", "mg", "min"), | |
| "Sắt": ("fe", "fe", "mg", "min"), | |
| "Magie": ("magie", "magie", "mg", "min"), | |
| "Kẽm": ("zn", "zn", "mg", "min"), | |
| "Kali": ("kali", "kali", "mg", "range"), | |
| "Natri": ("natri", "natri", "mg", "max"), | |
| "Phốt pho": ("photpho", "photpho", "mg", "max"), | |
| # --- Khác --- | |
| "Cholesterol": ("cholesterol", "cholesterol", "mg", "max"), | |
| "Choline": ("choline", "choline", "mg", "min"), | |
| "Caffeine": ("caffeine", "caffeine", "mg", "max"), | |
| "Alcohol": ("alcohol", "alcohol", "g", "max"), | |
| } | |
| scored_list = [] | |
| for doc in candidates: | |
| item = doc.metadata | |
| score = 0 | |
| reasons = [] # Lưu lý do để debug hoặc giải thích cho user | |
| # --- 1. CHẤM ĐIỂM NHÓM "BỔ SUNG" (BOOST) --- | |
| # Logic: Càng nhiều càng tốt | |
| for nutrient in user_profile.get('Bổ sung', []): | |
| config = nutrient_config.get(nutrient) | |
| if not config: continue | |
| p_key, db_key, unit, logic = config | |
| # Lấy giá trị thực tế trong món ăn và mục tiêu | |
| val = float(item.get(db_key, 0)) | |
| daily_target = float(user_profile.get(p_key, 0)) | |
| meal_target = daily_target * meal_ratio | |
| if meal_target == 0: continue | |
| # Chấm điểm | |
| # Nếu đạt > 50% target bữa -> +10 điểm | |
| if val >= meal_target * 0.5: | |
| score += 10 | |
| reasons.append(f"Giàu {nutrient}") | |
| # Nếu đạt > 80% target -> +15 điểm (thưởng thêm) | |
| if val >= meal_target * 0.8: | |
| score += 5 | |
| # --- 2. CHẤM ĐIỂM NHÓM "HẠN CHẾ" & "KIÊNG" (PENALTY/REWARD) --- | |
| # Gộp chung: Càng thấp càng tốt | |
| check_list = set(user_profile.get('Hạn chế', []) + user_profile.get('Kiêng', [])) | |
| for nutrient in check_list: | |
| config = nutrient_config.get(nutrient) | |
| if not config: continue | |
| p_key, db_key, unit, logic = config | |
| val = float(item.get(db_key, 0)) | |
| daily_target = float(user_profile.get(p_key, 0)) | |
| meal_target = daily_target * meal_ratio | |
| if meal_target == 0: continue | |
| if logic == 'max': | |
| # Nếu thấp hơn target -> +10 điểm (Tốt) | |
| if val <= meal_target: | |
| score += 10 | |
| # Nếu thấp hơn hẳn (chỉ bằng 50% target) -> +15 điểm (Rất an toàn) | |
| if val <= meal_target * 0.5: | |
| score += 5 | |
| # Nếu vượt quá target -> -10 điểm (Phạt) | |
| if val > meal_target: | |
| score -= 10 | |
| elif logic == 'range': | |
| # Logic cho Kali/Protein: Tốt nhất là nằm trong khoảng, không thấp quá, không cao quá | |
| min_safe = meal_target * 0.5 | |
| max_safe = meal_target * 1.5 | |
| if min_safe <= val <= max_safe: | |
| score += 10 # Nằm trong vùng an toàn | |
| elif val > max_safe: | |
| score -= 10 # Cao quá (nguy hiểm cho thận) | |
| # Thấp quá thì không trừ điểm nặng, chỉ không được cộng | |
| # --- 3. ĐIỂM THƯỞNG CHO SỰ PHÙ HỢP CƠ BẢN (BASE HEALTH) --- | |
| if float(item.get('sugar', 0)) < 5: score += 2 | |
| if float(item.get('saturated_fat', 0)) < 3: score += 2 | |
| if float(item.get('fiber', 0)) > 3: score += 3 | |
| # Lưu kết quả | |
| item_copy = item.copy() | |
| item_copy["health_score"] = score | |
| item_copy["score_reason"] = ", ".join(reasons[:3]) # Chỉ lấy 3 lý do chính | |
| scored_list.append(item_copy) | |
| # 4. SẮP XẾP & TRẢ VỀ | |
| scored_list.sort(key=lambda x: x["health_score"], reverse=True) | |
| # # Debug: In Top 3 | |
| # logger.info("Top 3 Món Tốt Nhất (Sau khi chấm điểm):") | |
| # for i, m in enumerate(scored_list[:3]): | |
| # logger.info(f" {i+1}. {m['name']} (Score: {m['health_score']}) | {m.get('score_reason')}") | |
| return scored_list | |
| def get_random_vibe(meal_type): | |
| """ | |
| Chọn vibe thông minh với xác suất cao ra món Thanh đạm/Canh cho bữa Trưa/Tối | |
| """ | |
| # --- BỮA SÁNG --- | |
| if meal_type == "sáng": | |
| pool = [ | |
| "khởi đầu ngày mới năng lượng", | |
| "món nước nóng hổi", | |
| "chế biến nhanh gọn lẹ", | |
| "điểm tâm nhẹ nhàng", | |
| "hương vị thanh tao" | |
| ] + vibes_flavor | |
| return random.choice(pool) | |
| # --- BỮA TRƯA / TỐI --- | |
| else: | |
| roll = random.random() | |
| if roll < 0.3: | |
| # 30%: Query tập trung vào Món Mặn Đậm Đà (Thịt/Cá kho, chiên...) | |
| # "Kho tộ đậm đà mang hương vị đồng quê" | |
| v_main = random.choice(vibes_cooking) | |
| v_style = random.choice(vibes_style) | |
| return f"{v_main} mang {v_style}" | |
| elif roll < 0.6: | |
| # 30%: Query tập trung hoàn toàn vào Món Thanh Đạm/Canh | |
| # "Canh hầm thanh mát bổ dưỡng mang hương vị thanh đạm nhẹ nhàng" | |
| v_soup = random.choice(vibes_soup_veg) | |
| v_flavor = random.choice(vibes_healthy + vibes_flavor) | |
| return f"{v_soup} mang {v_flavor}" | |
| else: | |
| # 40%: Query HỖN HỢP (Kỹ thuật "Combo Keyword") | |
| # "Kho tộ đậm đà kết hợp với canh rau thanh mát" | |
| v_main = random.choice(vibes_cooking) | |
| v_soup = random.choice(vibes_soup_veg) | |
| return f"{v_main} kết hợp với {v_soup}" | |
| def fetch_staples_by_ids(vectorstore, doc_ids): | |
| """ | |
| Lấy document từ ES theo ID và map về đúng định dạng candidate_pool. | |
| """ | |
| if not doc_ids: | |
| return [] | |
| try: | |
| client = vectorstore.client | |
| # 1. Gọi API mget để lấy dữ liệu thô cực nhanh | |
| response = client.mget(index="food_v2_vdb", body={"ids": doc_ids}) | |
| fetched_items = [] | |
| for doc in response['docs']: | |
| if doc['found']: | |
| # Dữ liệu gốc trong ES | |
| src = doc['_source'] | |
| meta = src.get('metadata', src) | |
| # 2. Mapping chi tiết theo mẫu bạn cung cấp | |
| item = { | |
| # --- ĐỊNH DANH --- | |
| 'meal_id': meta.get('meal_id', doc['_id']), # Fallback về doc_id nếu ko có meal_id | |
| 'name': meta.get('name', 'Món không tên'), | |
| # --- THÀNH PHẦN --- | |
| 'ingredients': meta.get('ingredients', []), | |
| 'ingredients_text': meta.get('ingredients_text', ''), | |
| 'tags': meta.get('tags', []), | |
| # --- CÁCH LÀM --- | |
| 'preparation_steps': meta.get('preparation_steps', ''), | |
| 'cooking_steps': meta.get('cooking_steps', ''), | |
| # --- DINH DƯỠNG --- | |
| 'kcal': float(meta.get('kcal', 0.0)), | |
| 'carbs': float(meta.get('carbs', 0.0)), | |
| 'protein': float(meta.get('protein', 0.0)), | |
| 'totalfat': float(meta.get('totalfat', 0.0) or meta.get('lipid', 0.0)), # Handle alias | |
| # --- VI CHẤT --- | |
| 'sugar': float(meta.get('sugar', 0.0)), | |
| 'fiber': float(meta.get('fiber', 0.0)), | |
| 'saturatedfat': float(meta.get('saturatedfat', 0.0)), | |
| 'monounsaturatedfat': float(meta.get('monounsaturatedfat', 0.0)), | |
| 'polyunsaturatedfat': float(meta.get('polyunsaturatedfat', 0.0)), | |
| 'transfat': float(meta.get('transfat', 0.0)), | |
| 'cholesterol': float(meta.get('cholesterol', 0.0)), | |
| # Vitamin & Khoáng (Map theo mẫu) | |
| 'vitamina': float(meta.get('vitamina', 0.0)), | |
| 'vitamind': float(meta.get('vitamind', 0.0)), | |
| 'vitaminc': float(meta.get('vitaminc', 0.0)), | |
| 'vitaminb6': float(meta.get('vitaminb6', 0.0)), | |
| 'vitaminb12': float(meta.get('vitaminb12', 0.0)), | |
| 'vitamine': float(meta.get('vitamine', 0.0)), | |
| 'vitamink': float(meta.get('vitamink', 0.0)), | |
| 'choline': float(meta.get('choline', 0.0)), | |
| 'canxi': float(meta.get('canxi', 0.0)), | |
| 'fe': float(meta.get('fe', 0.0)), | |
| 'magie': float(meta.get('magie', 0.0)), | |
| 'photpho': float(meta.get('photpho', 0.0)), | |
| 'kali': float(meta.get('kali', 0.0)), | |
| 'natri': float(meta.get('natri', 0.0)), | |
| 'zn': float(meta.get('zn', 0.0)), | |
| 'water': float(meta.get('water', 0.0)), | |
| 'caffeine': float(meta.get('caffeine', 0.0)), | |
| 'alcohol': float(meta.get('alcohol', 0.0)), | |
| # --- AI LOGIC FIELDS --- | |
| 'health_score': 5, | |
| 'score_reason': 'Món ăn cơ bản (Staple Food)', | |
| 'meal_type_tag': '', # Sẽ điền sau | |
| 'retrieval_vibe': 'Món ăn kèm cơ bản', | |
| # Cờ fallback | |
| 'is_fallback': True | |
| } | |
| fetched_items.append(item) | |
| return fetched_items | |
| except Exception as e: | |
| print(f"⚠️ Lỗi fetch staples từ ES: {e}") | |
| return [] |