Kiriten892's picture
fix(hotel,chatbot): add WiFi to hotel data + fix chatbot map waypoints
d18bdff
Raw
History Blame Contribute Delete
28.7 kB
import json
import os
import datetime
from typing import Optional
from app.models.route_optimizer.distance_calculator import DistanceCalculator
from app.models.route_optimizer.route_planner import RoutePlanner
from app.models.budget_optimizer.cost_calculator import CostCalculator
class TripOptimizer:
"""Enhanced constraint-based trip planning optimizer with distance-aware scheduling,
multi-destination support, and 3-tier budget plans."""
def __init__(self, knowledge_base_path: str):
self.destinations = []
self.destinations_map = {}
if os.path.exists(knowledge_base_path):
with open(knowledge_base_path, 'r', encoding='utf-8-sig') as f:
data = json.load(f)
self.destinations = data.get('destinations', [])
self.destinations_map = {d['id']: d for d in self.destinations}
self.route_planner = RoutePlanner()
def generate_plan(
self,
destination_id: str,
duration: dict,
budget: Optional[dict] = None,
num_people: Optional[dict] = None,
preferences: Optional[list] = None,
language: str = 'vi',
budget_priorities: Optional[dict] = None,
dietary_restrictions: Optional[list] = None,
cuisine_preferences: Optional[list] = None,
user_profile: Optional[dict] = None,
origin_id: Optional[str] = None,
) -> dict | None:
"""Generate an optimized trip plan with distance-aware scheduling and budget tiers."""
dest = self.destinations_map.get(destination_id)
if not dest:
return None
days = duration.get('days', 3)
nights = duration.get('nights', days - 1)
adults = (num_people or {}).get('adults', 2)
children = (num_people or {}).get('children', 0)
total_people = adults + children
activities = dest.get('popular_activities', [])
foods = dest.get('food_specialties', [])
# Determine budget tier
tier = self._determine_tier(budget, days, total_people, dest)
# Score and sort activities
scored_activities = self._score_activities(activities, preferences or [], user_profile)
# Cluster activities by geographic proximity
clusters = self._cluster_activities_for_days(scored_activities, days)
# Build itinerary
itinerary = []
used_activities = set()
total_cost = 0
# Get daily costs for chosen tier
daily_cost = CostCalculator.calculate_daily_cost(
dest, tier, adults, children, datetime.datetime.now().month
)
for day in range(1, days + 1):
day_plan = {'day': day, 'activities': []}
# Pick cluster for this day
cluster_idx = min(day - 1, len(clusters) - 1) if clusters else -1
day_activities = clusters[cluster_idx] if cluster_idx >= 0 and clusters else scored_activities
if day == 1:
# Arrival day
transport = dest.get('transport', {})
arrival_info = self._get_arrival_info(transport, language, origin_id)
if arrival_info:
day_plan['activities'].append({
'time': '08:00',
'type': 'transport',
'title': arrival_info['title'],
'description': arrival_info['description'],
'estimatedCost': 0
})
# Check-in
accom_cost = daily_cost['accommodation']
day_plan['activities'].append({
'time': '10:00',
'type': 'hotel',
'title': 'Check-in khách sạn' if language == 'vi' else 'Hotel check-in',
'description': daily_cost.get('tier_description', ''),
'estimatedCost': accom_cost
})
total_cost += accom_cost
# Afternoon: 1 light activity (distance-optimized)
act = self._pick_activity(day_activities, used_activities, 'light')
if act:
used_activities.add(act['name'])
act_cost = CostCalculator.calculate_activity_cost(act, adults, children)
travel_time = act.get('time_from_center_min', 15)
_coords = act.get('coordinates', {})
day_plan['activities'].append({
'time': '14:00',
'type': 'activity',
'title': act['name'] if language == 'vi' else act.get('name_en', act['name']),
'description': self._format_activity_desc(act, travel_time, language),
'estimatedCost': act_cost,
'travelTimeMin': travel_time,
'lat': _coords.get('lat'),
'lon': _coords.get('lon'),
})
total_cost += act_cost
elif day == days:
# Departure day
act = self._pick_activity(day_activities, used_activities, 'light')
if act:
used_activities.add(act['name'])
act_cost = CostCalculator.calculate_activity_cost(act, adults, children)
_coords = act.get('coordinates', {})
day_plan['activities'].append({
'time': '08:00',
'type': 'activity',
'title': act['name'] if language == 'vi' else act.get('name_en', act['name']),
'description': self._format_activity_desc(act, act.get('time_from_center_min', 10), language),
'estimatedCost': act_cost,
'lat': _coords.get('lat'),
'lon': _coords.get('lon'),
})
total_cost += act_cost
day_plan['activities'].append({
'time': '14:00',
'type': 'hotel',
'title': 'Check-out' if language == 'en' else 'Trả phòng',
'description': '',
'estimatedCost': 0
})
day_plan['activities'].append({
'time': '16:00',
'type': 'transport',
'title': 'Khởi hành về' if language == 'vi' else 'Departure',
'description': '',
'estimatedCost': 0
})
else:
# Full day
accom_cost = daily_cost['accommodation']
total_cost += accom_cost
# Optimize order of activities in this day's cluster
day_cluster = [a for a in day_activities if a['name'] not in used_activities]
if len(day_cluster) > 2:
day_cluster = self.route_planner.optimize_daily_route(day_cluster)
# Morning activity
act_morning = self._pick_activity(day_cluster, used_activities, 'any')
if act_morning:
used_activities.add(act_morning['name'])
act_cost = CostCalculator.calculate_activity_cost(act_morning, adults, children)
duration_h = act_morning.get('duration_hours', 3)
_coords_m = act_morning.get('coordinates', {})
day_plan['activities'].append({
'time': self._get_best_time_slot(act_morning, 'morning'),
'type': 'activity',
'title': act_morning['name'] if language == 'vi' else act_morning.get('name_en', act_morning['name']),
'description': self._format_activity_desc(act_morning, act_morning.get('time_from_center_min', 15), language),
'estimatedCost': act_cost,
'durationHours': duration_h,
'lat': _coords_m.get('lat'),
'lon': _coords_m.get('lon'),
})
total_cost += act_cost
# Afternoon activity
act_afternoon = self._pick_activity(day_cluster, used_activities, 'any')
if act_afternoon:
used_activities.add(act_afternoon['name'])
act_cost = CostCalculator.calculate_activity_cost(act_afternoon, adults, children)
_coords_a = act_afternoon.get('coordinates', {})
day_plan['activities'].append({
'time': self._get_best_time_slot(act_afternoon, 'afternoon'),
'type': 'activity',
'title': act_afternoon['name'] if language == 'vi' else act_afternoon.get('name_en', act_afternoon['name']),
'description': self._format_activity_desc(act_afternoon, act_afternoon.get('time_from_center_min', 15), language),
'estimatedCost': act_cost,
'durationHours': act_afternoon.get('duration_hours', 2),
'lat': _coords_a.get('lat'),
'lon': _coords_a.get('lon'),
})
total_cost += act_cost
# Add food for each day — use full daily food cost (3 meals, with child discount)
if foods:
food = foods[day % len(foods) - 1]
food_cost = daily_cost['food'] # already includes 3 meals × adults + children w/ discount
day_plan['activities'].append({
'time': '12:00' if day_plan['activities'] else '19:00',
'type': 'food',
'title': food['name'] if language == 'vi' else food.get('name_en', food['name']),
'description': f"{food.get('avg_price_vnd', 50000):,} VND/{'người' if language == 'vi' else 'person'}",
'estimatedCost': food_cost
})
total_cost += food_cost
# Sort activities by time
day_plan['activities'].sort(key=lambda x: x['time'])
itinerary.append(day_plan)
# Add misc costs
daily_misc = daily_cost['misc'] * days
transport_cost = daily_cost['transport'] * days
total_cost += daily_misc + transport_cost
dest_name = dest['name'] if language == 'vi' else dest.get('name_en', dest['name'])
# Generate 3-tier cost comparison
tier_plans = self._generate_tier_comparison(dest, days, adults, children)
# Calculate route optimization score
opt_score = self._calculate_optimization_score(itinerary, dest)
# Compute consistent cost breakdown from the same daily_cost source
accom_breakdown = int(daily_cost['accommodation'] * max(1, days - 1))
food_breakdown = int(daily_cost['food'] * days)
transport_breakdown = int(transport_cost)
misc_breakdown = int(daily_misc)
# Activities = total minus known costs (to ensure totalEstimatedCost == sum(breakdown))
activities_breakdown = int(total_cost - accom_breakdown - food_breakdown - transport_breakdown - misc_breakdown)
activities_breakdown = max(0, activities_breakdown) # Guard against negative
return {
'destination': dest_name,
'destination_id': destination_id,
'duration': {'days': days, 'nights': nights},
'travelers': num_people or {'adults': 2, 'children': 0, 'infants': 0},
'itinerary': itinerary,
'totalEstimatedCost': int(total_cost),
'currency': 'VND',
'selectedTier': tier,
'costBreakdown': {
'accommodation': accom_breakdown,
'food': food_breakdown,
'activities': activities_breakdown,
'transport': transport_breakdown,
'misc': misc_breakdown,
},
'perPerson': int(total_cost // total_people) if total_people > 0 else int(total_cost),
'perPersonPerDay': int(total_cost // total_people // days) if total_people > 0 and days > 0 else 0,
'tierComparison': tier_plans,
'optimizationScore': opt_score,
'tips': self._get_tips(dest, language, tier),
}
def generate_multi_destination_plan(
self,
destination_ids: list[str],
total_days: int,
budget: Optional[dict] = None,
num_people: Optional[dict] = None,
preferences: Optional[list] = None,
language: str = 'vi',
) -> dict | None:
"""Generate a plan visiting multiple destinations with optimized routing."""
dests = [self.destinations_map[did] for did in destination_ids if did in self.destinations_map]
if not dests:
return None
# Optimize inter-city route order
dests = self.route_planner.plan_multi_destination_route(dests)
# Allocate days per destination (proportional, minimum 2 days each)
allocation = self._allocate_days(dests, total_days)
# Generate plan for each destination
segments = []
total_cost = 0
current_day = 1
for i, dest in enumerate(dests):
days_for_dest = allocation[i]
segment_plan = self.generate_plan(
destination_id=dest['id'],
duration={'days': days_for_dest, 'nights': days_for_dest - 1},
budget=budget,
num_people=num_people,
preferences=preferences,
language=language,
)
if segment_plan:
# Adjust day numbers
for day_plan in segment_plan['itinerary']:
day_plan['day'] = current_day
current_day += 1
segments.append(segment_plan)
total_cost += segment_plan['totalEstimatedCost']
# Add inter-city travel (except after last destination)
if i < len(dests) - 1:
travel_info = self.route_planner.estimate_inter_city_travel(dest, dests[i + 1])
travel_cost = self._estimate_travel_cost(travel_info)
total_cost += travel_cost
dest_names = [
(d['name'] if language == 'vi' else d.get('name_en', d['name']))
for d in dests
]
adults = (num_people or {}).get('adults', 2)
children = (num_people or {}).get('children', 0)
total_people = adults + children
return {
'type': 'multi_destination',
'destinations': dest_names,
'destination_ids': [d['id'] for d in dests],
'duration': {'days': total_days, 'nights': total_days - 1},
'travelers': num_people or {'adults': 2, 'children': 0, 'infants': 0},
'segments': segments,
'totalEstimatedCost': int(total_cost),
'perPerson': int(total_cost // total_people) if total_people > 0 else int(total_cost),
'currency': 'VND',
'routeOrder': dest_names,
'tips': self._get_multi_dest_tips(dests, language),
}
def compare_destinations(
self,
dest_ids: list[str],
days: int = 3,
num_adults: int = 2,
num_children: int = 0,
language: str = 'vi'
) -> list[dict]:
"""Compare multiple destinations across cost, weather, activities, food."""
results = []
month = datetime.datetime.now().month
for dest_id in dest_ids:
dest = self.destinations_map.get(dest_id)
if not dest:
continue
mid_cost = CostCalculator.calculate_trip_total(
dest, days, 'mid_range', num_adults, num_children, month=month
)
name = dest['name'] if language == 'vi' else dest.get('name_en', dest['name'])
is_best_month = month in dest.get('best_months', [])
results.append({
'destination_id': dest_id,
'name': name,
'region': dest.get('region', ''),
'tags': dest.get('tags', []),
'best_months': dest.get('best_months', []),
'is_good_time_now': is_best_month,
'avg_temp_c': dest.get('avg_temp_c', 0),
'num_activities': len(dest.get('popular_activities', [])),
'num_food_specialties': len(dest.get('food_specialties', [])),
'cost_mid_range_total': mid_cost['total'],
'cost_per_person': mid_cost['per_person'],
'cost_per_person_usd': mid_cost['per_person_usd'],
'description': dest.get(f'description_{language}', dest.get('description_vi', '')),
})
results.sort(key=lambda x: x['cost_mid_range_total'])
return results
# --- Private helpers ---
def _determine_tier(self, budget: Optional[dict], days: int, total_people: int, dest: dict) -> str:
"""Determine the most appropriate budget tier."""
if not budget:
return 'mid_range'
amount = budget.get('amount', 0)
currency = budget.get('currency', 'VND')
if currency == 'USD':
amount = CostCalculator.convert_currency(amount, 'USD', 'VND')
per_person_per_day = amount / max(1, total_people) / max(1, days)
cost_tiers = dest.get('cost_tiers', {})
for tier_name in ['luxury', 'mid_range', 'budget']:
tier = cost_tiers.get(tier_name, {})
accom = tier.get('accommodation_vnd', 800000)
food = tier.get('food_per_meal_vnd', 80000) * 3
transport = tier.get('transport_per_day_vnd', 200000)
daily = accom + food + transport + 100000
if per_person_per_day >= daily:
return tier_name
return 'budget'
def _score_activities(self, activities: list, preferences: list, user_profile: Optional[dict] = None) -> list:
"""Score activities based on user preferences and profile."""
scored = []
for act in activities:
score = 1.0
act_type = act.get('type', '')
act_name = (act.get('name', '') + ' ' + act.get('name_en', '')).lower()
if preferences:
for pref in preferences:
if pref.lower() in act_type.lower() or pref.lower() in act_name:
score += 0.5
if user_profile:
from app.models.user_profiler.profiler import UserProfiler
profiler = UserProfiler()
score += profiler.score_activity_for_user(act, user_profile) - 1.0
scored.append({**act, '_score': score})
scored.sort(key=lambda x: x['_score'], reverse=True)
return scored
def _cluster_activities_for_days(self, activities: list, days: int) -> list[list]:
"""Cluster activities into day-groups by proximity."""
activity_days = max(1, days - 2) + 2
try:
clusters = self.route_planner.cluster_by_proximity(activities, activity_days)
return clusters if clusters else [activities]
except Exception:
return [activities]
def _pick_activity(self, scored_activities: list, used: set, mode: str) -> dict | None:
"""Pick next best activity not yet used."""
for act in scored_activities:
if act['name'] not in used:
if mode == 'light' and act.get('duration_hours', 3) > 4:
continue
return act
return None
def _get_best_time_slot(self, activity: dict, slot: str) -> str:
"""Get the best time slot based on activity's best_time."""
best = activity.get('best_time', 'any')
if slot == 'morning':
return '06:00' if best == 'early_morning' else '08:00'
elif slot == 'afternoon':
return '17:00' if best == 'evening' else '14:00'
return '08:00' if slot == 'morning' else '14:00'
def _format_activity_desc(self, act: dict, travel_time: int, language: str) -> str:
"""Format activity description with duration and travel time."""
duration_h = act.get('duration_hours', 2)
act_type = act.get('type', 'sightseeing')
hours = act.get('opening_hours', '')
if language == 'vi':
desc = f"~{duration_h}h - {act_type}"
if travel_time > 0:
desc += f" | Di chuyển ~{travel_time} phút"
if hours and hours != '00:00-23:59':
desc += f" | Mở cửa: {hours}"
else:
desc = f"~{duration_h}h - {act_type}"
if travel_time > 0:
desc += f" | Travel ~{travel_time}min"
if hours and hours != '00:00-23:59':
desc += f" | Open: {hours}"
return desc
# Map origin IDs to transport keys and display names
ORIGIN_TRANSPORT_KEYS = {
'hcmc': ('from_hcm', 'TP.HCM', 'HCMC'),
'hanoi': ('from_hanoi', 'Hà Nội', 'Hanoi'),
'da-nang': ('from_da_nang', 'Đà Nẵng', 'Da Nang'),
}
def _get_arrival_info(
self, transport: dict, language: str, origin_id: Optional[str] = None
) -> dict | None:
"""Get arrival transport info based on actual origin city."""
# Determine which transport route to look up
if origin_id and origin_id in self.ORIGIN_TRANSPORT_KEYS:
key, name_vi, name_en = self.ORIGIN_TRANSPORT_KEYS[origin_id]
else:
# Fallback: try all known keys, prefer from_hcm
for oid in ['hcmc', 'hanoi', 'da-nang']:
key, name_vi, name_en = self.ORIGIN_TRANSPORT_KEYS[oid]
if transport.get(key):
break
else:
key, name_vi, name_en = 'from_hcm', 'TP.HCM', 'HCMC'
route = transport.get(key, {})
flight_time = route.get('flight', '')
bus_time = route.get('bus', '')
if flight_time:
if language == 'vi':
return {
'title': f'Bay từ {name_vi} ({flight_time})',
'description': 'Di chuyển bằng máy bay'
}
else:
return {
'title': f'Flight from {name_en} ({flight_time})',
'description': 'Travel by airplane'
}
elif bus_time:
if language == 'vi':
return {
'title': f'Di chuyển từ {name_vi} ({bus_time})',
'description': 'Di chuyển bằng xe khách'
}
else:
return {
'title': f'Travel from {name_en} ({bus_time})',
'description': 'Travel by bus'
}
return {
'title': 'Di chuyển đến điểm đến' if language == 'vi' else 'Travel to destination',
'description': ''
}
def _generate_tier_comparison(self, dest: dict, days: int, adults: int, children: int) -> list[dict]:
"""Generate cost comparison for 3 tiers."""
month = datetime.datetime.now().month
tiers = []
for tier_name in ['budget', 'mid_range', 'luxury']:
cost = CostCalculator.calculate_trip_total(
dest, days, tier_name, adults, children, month=month
)
tier_data = dest.get('cost_tiers', {}).get(tier_name, {})
tiers.append({
'tier': tier_name,
'total': cost['total'],
'perPerson': cost['per_person'],
'perPersonPerDay': cost['per_person_per_day'],
'totalUsd': cost['total_usd'],
'description': tier_data.get('description_vi', ''),
'descriptionEn': tier_data.get('description_en', ''),
})
return tiers
def _calculate_optimization_score(self, itinerary: list, dest: dict) -> float:
"""Calculate how well the itinerary is optimized (0-1)."""
score = 0.7
total_activities = sum(
1 for day in itinerary for act in day['activities'] if act['type'] == 'activity'
)
if total_activities > 0:
score += 0.1
score += 0.05
types = set()
for day in itinerary:
for act in day['activities']:
if act['type'] == 'activity':
desc = act.get('description', '')
types.add(desc.split(' - ')[-1] if ' - ' in desc else '')
if len(types) >= 3:
score += 0.1
return min(1.0, round(score, 2))
def _get_tips(self, dest: dict, language: str, tier: str = 'mid_range') -> list:
"""Get travel tips for destination."""
tips = []
best_months = dest.get('best_months', [])
if best_months:
month_str = ', '.join(str(m) for m in best_months)
if language == 'vi':
tips.append(f"Thời điểm đẹp nhất: tháng {month_str}")
else:
tips.append(f"Best time to visit: months {month_str}")
local_transport = dest.get('transport', {}).get('local', [])
if local_transport:
if language == 'vi':
tips.append(f"Di chuyển nội thành: {', '.join(local_transport)}")
else:
tips.append(f"Local transport: {', '.join(local_transport)}")
if tier == 'budget':
tips.append(
"Mẹo tiết kiệm: đặt phòng sớm, ăn quán bình dân, dùng xe bus/Grab" if language == 'vi'
else "Budget tip: book early, eat at local stalls, use bus/Grab"
)
elif tier == 'luxury':
tips.append(
"Gợi ý: đặt resort có bể bơi, thử fine dining, thuê xe riêng" if language == 'vi'
else "Tip: book resort with pool, try fine dining, hire private car"
)
return tips
def _allocate_days(self, dests: list, total_days: int) -> list[int]:
"""Allocate days across destinations proportionally."""
n = len(dests)
if n == 0:
return []
min_per_dest = 2
remaining = total_days - (min_per_dest * n)
allocation = [min_per_dest] * n
if remaining > 0:
activity_counts = [len(d.get('popular_activities', [])) for d in dests]
total_acts = sum(activity_counts) or 1
for i in range(n):
extra = int(remaining * activity_counts[i] / total_acts)
allocation[i] += extra
leftover = total_days - sum(allocation)
for i in range(leftover):
allocation[i % n] += 1
return allocation
def _estimate_travel_cost(self, travel_info: dict) -> int:
"""Estimate inter-city travel cost."""
transport = travel_info.get('recommended_transport', 'bus')
distance = travel_info.get('distance_km', 0)
cost_per_km = {
'grab': 15000, 'taxi': 15000, 'bus': 3000,
'train': 5000, 'flight': 8000, 'car': 10000,
}
return int(distance * cost_per_km.get(transport, 5000))
def _get_multi_dest_tips(self, dests: list, language: str) -> list:
"""Tips for multi-destination trips."""
tips = []
if language == 'vi':
tips.append(f"Lộ trình tối ưu: {' → '.join(d['name'] for d in dests)}")
for i in range(len(dests) - 1):
info = self.route_planner.estimate_inter_city_travel(dests[i], dests[i + 1])
tips.append(
f"{dests[i]['name']}{dests[i + 1]['name']}: "
f"~{info['distance_km']}km, ~{info['travel_time_min']} phút ({info['recommended_transport']})"
)
else:
tips.append(f"Optimized route: {' → '.join(d.get('name_en', d['name']) for d in dests)}")
for i in range(len(dests) - 1):
info = self.route_planner.estimate_inter_city_travel(dests[i], dests[i + 1])
tips.append(
f"{dests[i].get('name_en', dests[i]['name'])}{dests[i + 1].get('name_en', dests[i + 1]['name'])}: "
f"~{info['distance_km']}km, ~{info['travel_time_min']}min ({info['recommended_transport']})"
)
return tips