2026-03-04 19:36:44 +00:00

308 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { serve } from "https://deno.land/std@0.168.0/http/server.ts"
import { createClient } from "https://esm.sh/@supabase/supabase-js@2"
const OPENAI_API_KEY = Deno.env.get('OPENAI_API_KEY')
const GOOGLE_MAPS_API_KEY = Deno.env.get('GOOGLE_MAPS_API_KEY')
const SUPABASE_URL = Deno.env.get('SUPABASE_URL')
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'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
.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);
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." },
];
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."
});
}
// 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
});
});
days.push({
day: i,
items: dailyItems
});
}
return { days };
}
serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
try {
const { startDate, endDate, interests, dailySchedule, preferences } = await req.json()
// Initialize Supabase client with service role
const supabase = createClient(
SUPABASE_URL!,
SUPABASE_SERVICE_ROLE_KEY!
)
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 userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.3,
response_format: { type: 'json_object' }
}),
})
if (!openaiRes.ok) {
throw new Error(`OpenAI API 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)
}
} else {
console.log('No OpenAI API key found, using mock itinerary')
itinerary = generateMockItinerary(startDate, endDate, interests)
}
// 2. Verify places with Google Places API (with cache layer)
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
.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`)
verifiedItems.push({
...item,
place_id: cachedPlace.place_id,
name: cachedPlace.name,
formatted_address: cachedPlace.formatted_address,
lat: cachedPlace.lat,
lng: cachedPlace.lng,
rating: cachedPlace.rating,
user_ratings_total: cachedPlace.user_ratings_total,
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;
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) {
const place = searchData.results[0]
placeInfo = {
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
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
}
}
} catch (err) {
console.error(`Google Places API error for ${item.place_name}:`, err)
}
}
if (placeInfo) {
// Store in cache for future use with normalized name
const cacheEntry = {
place_name_normalized: normalizedName,
...placeInfo
}
await supabase
.from('places_cache')
.insert(cacheEntry)
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
})
}
}
}
verifiedDays.push({ ...day, items: verifiedItems })
}
// 3. Add time slots (Simple heuristic)
const finalDays = verifiedDays.map(day => {
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 }
}
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
return { ...item, start_time: startTime, end_time: endTime }
})
return { ...day, items: itemsWithTime }
})
return new Response(JSON.stringify({ days: finalDays }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
console.error('Final catch error:', error)
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})