Edit app-9xzmfic2e4g1/supabase/functions/generate-itinerary/index.ts via Editor

This commit is contained in:
Flatlogic Bot 2026-03-04 09:59:52 +00:00
parent 0f83799cff
commit 5605be97a5

View File

@ -11,148 +11,255 @@ const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
/**
* Normalize place names for consistent cache lookups.
* Handles Turkish characters, accents, and spelling variations.
*/
function normalizePlaceName(name: string): string {
return name
.toLowerCase()
.trim()
// Normalize Turkish characters to ASCII equivalents
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ş/g, 's')
.replace(/ı/g, 'i')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
// Also handle uppercase Turkish characters
.replace(/Ğ/g, 'g')
.replace(/Ü/g, 'u')
.replace(/Ş/g, 's')
.replace(/İ/g, 'i')
.replace(/Ö/g, 'o')
.replace(/Ç/g, 'c')
// Remove extra spaces
.toLowerCase().trim()
.replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's')
.replace(/ı/g, 'i').replace(/ö/g, 'o').replace(/ç/g, 'c')
.replace(/Ğ/g, 'g').replace(/Ü/g, 'u').replace(/Ş/g, 's')
.replace(/İ/g, 'i').replace(/Ö/g, 'o').replace(/Ç/g, 'c')
.replace(/\s+/g, ' ')
// Normalize common suffix variations (preserve them but ensure consistent spacing)
.replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase())
}
/**
* Generate a mock itinerary when OpenAI is unavailable.
*/
function generateMockItinerary(startDate: string, endDate: string, interests: string[]) {
const start = new Date(startDate);
const end = new Date(endDate);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
const numDays = Math.min(diffDays, 14);
// ─── Preference helpers ────────────────────────────────────────────────────────
const pool = [
{ place_name: "Göreme Open Air Museum", category: "museum", duration: 120, desc: "UNESCO World Heritage site with rock-cut churches." },
{ place_name: "Uçhisar Castle", category: "landmark", duration: 90, desc: "The highest point in Cappadocia with panoramic views." },
{ place_name: "Pasabag (Monks Valley)", category: "nature", duration: 60, desc: "Famous fairy chimneys with multiple caps." },
{ place_name: "Devrent Valley (Imagination Valley)", category: "nature", duration: 45, desc: "Unique rock formations resembling animals." },
{ place_name: "Kaymakli Underground City", category: "history", duration: 90, desc: "Ancient multi-level underground city." },
{ place_name: "Derinkuyu Underground City", category: "history", duration: 120, desc: "The deepest underground city in the region." },
{ place_name: "Ihlara Valley", category: "nature", duration: 180, desc: "A stunning canyon with a river and rock-cut churches." },
{ place_name: "Selime Monastery", category: "history", duration: 60, desc: "The largest religious structure in Cappadocia." },
{ place_name: "Pigeon Valley", category: "nature", duration: 90, desc: "Valley named after the countless man-made dovecotes." },
{ place_name: "Love Valley", category: "nature", duration: 60, desc: "Famous for its phallic-shaped fairy chimneys." },
{ place_name: "Avanos Pottery Workshop", category: "culture", duration: 60, desc: "Traditional pottery making in the Red River town." },
{ place_name: "Zelve Open Air Museum", category: "museum", duration: 120, desc: "An abandoned cave town with ancient churches." },
{ place_name: "Ortahisar Castle", category: "landmark", duration: 60, desc: "A massive rock formation used as a fortress." },
{ place_name: "Rose Valley Sunset Hike", category: "nature", duration: 120, desc: "Beautiful valley that turns pink at sunset." },
{ place_name: "Red Valley", category: "nature", duration: 90, desc: "Stunning valley with sharp ridges and red hues." },
{ place_name: "Çavuşin Village", category: "culture", duration: 60, desc: "An old Greek village with a massive rock castle." },
];
function getTravelTypeGuidance(travelType: string): string {
switch (travelType) {
case 'solo':
return 'Solo traveler: prioritize flexible, independent activities. Include hiking, photography spots, local cafes. Avoid group-only tours.'
case 'couple':
return 'Romantic couple trip: include sunset viewpoints, cave restaurants, hot air balloon, wine tasting, scenic valleys. Prioritize atmosphere and intimacy.'
case 'family':
return 'Family with children: only child-friendly activities. Avoid long strenuous hikes. Include interactive museums, easy nature walks, open-air museums. Max 4 stops per day.'
case 'friends':
return 'Friend group: include adventure activities (ATV, horse riding), nightlife, group tours, lively restaurants and social experiences.'
default:
return ''
}
}
const days = [];
for (let i = 1; i <= numDays; i++) {
const dailyItems = [];
// Always add Hot Air Balloon on Day 1 or 2 if duration is 120
if (i <= 2) {
dailyItems.push({
place_name: "Hot Air Balloon Flight",
category: "activity",
estimated_duration_minutes: 180,
description: "Breathtaking sunrise flight over the fairy chimneys."
});
}
function getBudgetGuidance(budget: string): string {
switch (budget) {
case 'budget':
return 'Budget traveler (₺500-1000/day): prioritize free or low-cost attractions (valleys, viewpoints, open-air sites). Avoid expensive private tours or luxury restaurants.'
case 'moderate':
return 'Moderate budget (₺1000-2500/day): mix of paid and free attractions. Include some paid guided tours and mid-range restaurants.'
case 'comfort':
return 'Comfort budget (₺2500-5000/day): include premium experiences like private guided tours, cave hotel visits, sunset dinners. Prioritize quality over quantity.'
case 'luxury':
return 'Luxury budget (₺5000+/day): include exclusive experiences: private balloon flight, VIP cave restaurant dinners, private guided tours, spa, premium viewpoints with private transfers.'
default:
return ''
}
}
// Pick 3-4 random items from the pool
const shuffled = [...pool].sort(() => 0.5 - Math.random());
const selected = shuffled.slice(0, 3);
selected.forEach(item => {
dailyItems.push({
place_name: item.place_name,
category: item.category,
estimated_duration_minutes: item.duration,
description: item.desc
});
});
function getTransportGuidance(transport: string): string {
switch (transport) {
case 'rental':
return 'Has a rental car: can reach remote locations. Include distant valleys (Ihlara, Soganli), off-the-beaten-path spots. No need to cluster locations geographically.'
case 'transfer':
return 'Using private transfers: comfortable but planned routes. Group locations by area per day to minimize travel time. Can reach all sites.'
case 'shuttle':
return 'Using shuttle/minibus: stick to popular tourist routes. Cluster stops near Göreme, Ürgüp, Avanos. Avoid remote locations not on standard routes.'
case 'mixed':
return 'Mixed transport: balance between accessible and remote locations. Include a mix of central and off-the-beaten-path sites.'
default:
return ''
}
}
days.push({
day: i,
items: dailyItems
});
function getInterestGuidance(interests: string[]): string {
const map: Record<string, string> = {
balloon: 'MUST include a hot air balloon flight on Day 1 or 2 at sunrise (05:00-08:00)',
nature: 'Prioritize valleys, hiking routes (Rose Valley, Red Valley, Pigeon Valley, Ihlara Valley)',
history: 'Prioritize underground cities (Derinkuyu, Kaymakli), rock churches, Selime Monastery',
photography: 'Include best photo spots: Uchisar Castle, Rose Valley sunset, Pasabag fairy chimneys, panoramic viewpoints',
adventure: 'Include ATV tours, horse riding, hiking, zip-lining where available',
gastronomy: 'Include local pottery workshop in Avanos, traditional restaurants, wine tasting in Ürgüp, local market visits',
}
return interests.map(i => map[i] || '').filter(Boolean).join('. ')
}
function getDailyPlaceCount(budget: string, travelType: string): number {
if (travelType === 'family') return 3
if (budget === 'luxury') return 3 // fewer but premium
if (budget === 'budget') return 5 // more self-guided stops
return 4
}
// ─── Mock itinerary (interest + budget aware) ─────────────────────────────────
function generateMockItinerary(
startDate: string,
endDate: string,
interests: string[],
travelType: string,
budget: string,
transport: string
) {
const diffDays = Math.ceil(
(new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000
) + 1
const numDays = Math.min(diffDays, 14)
// Place pools by category
const pools: Record<string, { place_name: string; category: string; duration: number; desc: string }[]> = {
nature: [
{ place_name: 'Rose Valley Sunset Hike', category: 'nature', duration: 120, desc: 'Beautiful valley that turns pink at sunset.' },
{ place_name: 'Pigeon Valley', category: 'nature', duration: 90, desc: 'Valley named after the countless man-made dovecotes.' },
{ place_name: 'Love Valley', category: 'nature', duration: 60, desc: 'Famous for its fairy chimneys.' },
{ place_name: 'Red Valley', category: 'nature', duration: 90, desc: 'Stunning valley with red hues.' },
{ place_name: 'Ihlara Valley', category: 'nature', duration: 180, desc: 'A stunning canyon with a river and rock-cut churches.' },
{ place_name: 'Devrent Valley', category: 'nature', duration: 45, desc: 'Unique rock formations resembling animals.' },
],
history: [
{ place_name: 'Kaymakli Underground City', category: 'history', duration: 90, desc: 'Ancient multi-level underground city.' },
{ place_name: 'Derinkuyu Underground City', category: 'history', duration: 120, desc: 'The deepest underground city in the region.' },
{ place_name: 'Selime Monastery', category: 'history', duration: 60, desc: 'The largest religious structure in Cappadocia.' },
{ place_name: 'Çavuşin Village', category: 'culture', duration: 60, desc: 'An old Greek village with a massive rock castle.' },
],
museum: [
{ place_name: 'Göreme Open Air Museum', category: 'museum', duration: 120, desc: 'UNESCO World Heritage site with rock-cut churches.' },
{ place_name: 'Zelve Open Air Museum', category: 'museum', duration: 120, desc: 'An abandoned cave town with ancient churches.' },
],
landmark: [
{ place_name: 'Uçhisar Castle', category: 'landmark', duration: 90, desc: 'The highest point in Cappadocia with panoramic views.' },
{ place_name: 'Ortahisar Castle', category: 'landmark', duration: 60, desc: 'A massive rock formation used as a fortress.' },
{ place_name: 'Pasabag (Monks Valley)', category: 'nature', duration: 60, desc: 'Famous fairy chimneys with multiple caps.' },
],
gastronomy: [
{ place_name: 'Avanos Pottery Workshop', category: 'culture', duration: 60, desc: 'Traditional pottery making in the Red River town.' },
{ place_name: 'Ürgüp Wine Tasting', category: 'gastronomy', duration: 90, desc: 'Local Cappadocian wine tasting experience.' },
{ place_name: 'Göreme Local Market', category: 'gastronomy', duration: 60, desc: 'Fresh local produce and Turkish delights.' },
],
luxury: [
{ place_name: 'Private Guided Valley Tour', category: 'activity', duration: 180, desc: 'Exclusive private tour of the most scenic valleys.' },
{ place_name: 'Cave Restaurant Dinner', category: 'gastronomy', duration: 120, desc: 'Fine dining in a traditional Cappadocian cave.' },
{ place_name: 'Turkish Bath (Hamam)', category: 'wellness', duration: 120, desc: 'Traditional Ottoman bath experience.' },
],
}
return { days };
// Build priority pool based on interests
const priorityPool: typeof pools.nature = []
if (interests.includes('nature')) priorityPool.push(...pools.nature)
if (interests.includes('history')) priorityPool.push(...pools.history)
if (interests.includes('photography')) priorityPool.push(...pools.landmark)
if (interests.includes('gastronomy')) priorityPool.push(...pools.gastronomy)
if (budget === 'luxury' || budget === 'comfort') priorityPool.push(...pools.luxury)
// Always add museums as fallback
priorityPool.push(...pools.museum, ...pools.landmark)
const usedPlaces = new Set<string>()
const maxPerDay = getDailyPlaceCount(budget, travelType)
const days = []
for (let i = 1; i <= numDays; i++) {
const dailyItems: { place_name: string; category: string; estimated_duration_minutes: number; description: string }[] = []
// Balloon on day 1 if interest selected
if (i === 1 && interests.includes('balloon')) {
dailyItems.push({
place_name: 'Hot Air Balloon Flight',
category: 'activity',
estimated_duration_minutes: budget === 'luxury' ? 90 : 60,
description: budget === 'luxury'
? 'Private luxury sunrise balloon flight over the fairy chimneys.'
: 'Breathtaking sunrise flight over the fairy chimneys.',
})
}
// Fill remaining slots
const available = priorityPool.filter(p => !usedPlaces.has(p.place_name))
const shuffled = [...available].sort(() => 0.5 - Math.random())
const slots = maxPerDay - dailyItems.length
for (let j = 0; j < Math.min(slots, shuffled.length); j++) {
usedPlaces.add(shuffled[j].place_name)
dailyItems.push({
place_name: shuffled[j].place_name,
category: shuffled[j].category,
estimated_duration_minutes: shuffled[j].duration,
description: shuffled[j].desc,
})
}
days.push({ day: i, items: dailyItems })
}
return { days }
}
// ─── Main handler ──────────────────────────────────────────────────────────────
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const { startDate, endDate, interests, dailySchedule, preferences } = await req.json()
const {
startDate,
endDate,
interests = [],
dailySchedule = 'moderate',
travelType = 'couple',
accommodation = 'center',
transport = 'mixed',
budget = 'moderate',
travelers = 2,
} = await req.json()
// Initialize Supabase client with service role
const supabase = createClient(
SUPABASE_URL!,
SUPABASE_SERVICE_ROLE_KEY!
)
const supabase = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!)
let itinerary;
let itinerary
// 1. Call OpenAI to generate itinerary (if API key exists)
if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') {
try {
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a personalized travel itinerary based on user preferences.
Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı.
Rules:
- Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2.
- Sunset valley visits after 17:30.
- Max 5 places per day.
- No duplicate places.
- Only return JSON in the specified format.
- Interests: ${interests.join(', ')}.
- Daily Schedule: ${dailySchedule}.
Response Format:
{
"days": [
{
"day": 1,
"items": [
{
"place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
}
]
}`
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a highly personalized travel itinerary strictly based on the user's preferences below.
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
=== USER PROFILE ===
Travel Type: ${travelType} ${getTravelTypeGuidance(travelType)}
Budget: ${budget} ${getBudgetGuidance(budget)}
Transport: ${transport} ${getTransportGuidance(transport)}
Group Size: ${travelers} people
Accommodation: ${accommodation}
Daily Schedule pace: ${dailySchedule}
=== INTERESTS (strictly prioritize these) ===
${getInterestGuidance(interests)}
=== RULES ===
- Max ${getDailyPlaceCount(budget, travelType)} places per day
- No duplicate places across days
- Balloon rides ONLY at sunrise (05:0008:00) on Day 1 or 2, ONLY if balloon is in interests
- Sunset visits (Rose Valley, Red Valley) after 17:30
- Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı, Ihlara
- For luxury budget: include premium/private experiences
- For budget travelers: focus on free/low-cost sites (valleys, viewpoints)
- For families: ONLY child-safe, easy-access locations
- Return ONLY valid JSON, no markdown, no explanation
=== RESPONSE FORMAT ===
{
"days": [
{
"day": 1,
"items": [
{
"place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
}
]
}`
const userPrompt = `Trip: ${startDate} to ${endDate}. Travelers: ${travelers}. Interests: ${interests.join(', ')}. Budget: ${budget}. Transport: ${transport}. Travel type: ${travelType}.`
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
@ -164,47 +271,40 @@ serve(async (req) => {
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
{ role: 'user', content: userPrompt },
],
temperature: 0.3,
response_format: { type: 'json_object' }
temperature: 0.4,
response_format: { type: 'json_object' },
}),
})
if (!openaiRes.ok) {
throw new Error(`OpenAI API error: ${openaiRes.statusText}`);
}
if (!openaiRes.ok) throw new Error(`OpenAI error: ${openaiRes.statusText}`)
const openaiData = await openaiRes.json()
itinerary = JSON.parse(openaiData.choices[0].message.content)
} catch (err) {
console.error('OpenAI failed, falling back to mock:', err)
itinerary = generateMockItinerary(startDate, endDate, interests)
itinerary = generateMockItinerary(startDate, endDate, interests, travelType, budget, transport)
}
} else {
console.log('No OpenAI API key found, using mock itinerary')
itinerary = generateMockItinerary(startDate, endDate, interests)
itinerary = generateMockItinerary(startDate, endDate, interests, travelType, budget, transport)
}
// 2. Verify places with Google Places API (with cache layer)
// ── Google Places verification with cache ──────────────────────────────────
const verifiedDays = []
for (const day of itinerary.days) {
const verifiedItems = []
for (const item of day.items) {
// Normalize place name for cache lookup using robust normalization
const normalizedName = normalizePlaceName(item.place_name)
// Check cache first
const { data: cachedPlace, error: cacheError } = await supabase
const { data: cachedPlace } = await supabase
.from('places_cache')
.select('*')
.eq('place_name_normalized', normalizedName)
.limit(1)
.maybeSingle()
if (cachedPlace && !cacheError) {
// Cache HIT - skipping Google API call
console.log(`Cache HIT for "${item.place_name}" (normalized: "${normalizedName}") - skipping Google API call`)
if (cachedPlace) {
verifiedItems.push({
...item,
place_id: cachedPlace.place_id,
@ -213,23 +313,18 @@ serve(async (req) => {
lat: cachedPlace.lat,
lng: cachedPlace.lng,
rating: cachedPlace.rating,
user_ratings_total: cachedPlace.user_ratings_total,
photo_reference: cachedPlace.photo_reference
photo_reference: cachedPlace.photo_reference,
})
} else {
// Cache MISS - fetching from Google and caching
console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - fetching from Google and caching`)
let placeInfo = null;
let placeInfo = null
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
// Text Search to get place_id and basic info
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
if (searchData.results && searchData.results.length > 0) {
if (searchData.results?.length > 0) {
const place = searchData.results[0]
placeInfo = {
place_id: place.place_id,
@ -238,58 +333,45 @@ serve(async (req) => {
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
photo_reference: place.photos?.[0]?.photo_reference || null,
}
}
} catch (err) {
console.error(`Google Places API error for ${item.place_name}:`, err)
console.error(`Google Places error for ${item.place_name}:`, err)
}
}
if (placeInfo) {
// Store in cache for future use with normalized name
const cacheEntry = {
await supabase.from('places_cache').insert({
place_name_normalized: normalizedName,
...placeInfo
}
await supabase
.from('places_cache')
.insert(cacheEntry)
verifiedItems.push({
...item,
...placeInfo
...placeInfo,
})
verifiedItems.push({ ...item, ...placeInfo })
} else {
// No Google info found or key missing, use item as is but mark as unverified
verifiedItems.push({
...item,
unverified: true
})
verifiedItems.push({ ...item, unverified: true })
}
}
}
verifiedDays.push({ ...day, items: verifiedItems })
}
// 3. Add time slots (Simple heuristic)
// ── Time slot assignment ───────────────────────────────────────────────────
const finalDays = verifiedDays.map(day => {
let currentTime = new Date(`2026-01-01T09:00:00`)
let currentTime = new Date('2026-01-01T09:00:00')
const itemsWithTime = day.items.map(item => {
// Handle special cases: Balloon
if (item.place_name.toLowerCase().includes('balloon')) {
const start = "05:00"
const end = "08:00"
return { ...item, start_time: start, end_time: end }
return { ...item, start_time: '05:00', end_time: '08:00' }
}
// Push sunset spots to evening
const isSunsetSpot = /rose valley|red valley|sunset/i.test(item.place_name)
if (isSunsetSpot) {
return { ...item, start_time: '17:30', end_time: '19:30' }
}
const startTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60))
const endTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer
currentTime.setMinutes(currentTime.getMinutes() + 30) // travel buffer
return { ...item, start_time: startTime, end_time: endTime }
})
return { ...day, items: itemsWithTime }
@ -299,7 +381,7 @@ serve(async (req) => {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
console.error('Final catch error:', error)
console.error('Error:', error)
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },