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