2026-03-04 18:25:09 +00:00

1232 lines
47 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 { createClient } from 'jsr:@supabase/supabase-js@2';
import { requireAuth, checkRateLimit } from '../_shared/auth.ts';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
// Place Type Weighting System
// Museums and cultural sites have higher impact on guide recommendations
// Activities have medium impact, restaurants/hotels have minimal impact
const PLACE_TYPE_WEIGHTS: Record<string, number> = {
// High cultural complexity - highest impact
'museum': 3.0,
'historical': 3.0,
// Activities - medium impact
'atv': 2.0,
'horse-riding': 2.0,
'hot-air-balloon': 2.0,
'tour': 2.0,
'viewpoint': 2.0,
'adventure': 2.0,
'valley': 2.0,
'underground_city': 2.0,
'panorama': 2.0,
// Support services - minimal impact
'restaurant': 0.5,
'hotel': 0.5,
'cafe': 0.5,
// Default weight for unknown types
'default': 1.0
};
// Get weight for a place type
function getPlaceTypeWeight(type: string): number {
const normalizedType = type.toLowerCase().trim();
return PLACE_TYPE_WEIGHTS[normalizedType] || PLACE_TYPE_WEIGHTS['default'];
}
interface Place {
name: string;
type: string;
lat?: number;
lng?: number;
duration?: string;
// Calculated metrics
distanceFromPreviousKm?: number;
travelTimeFromPreviousMinutes?: number;
visitDurationMinutes?: number;
weight?: number; // Cultural/activity weight
}
interface DayMetrics {
dayNumber: number;
date: string;
totalPlaces: number;
weightedPlaceCount: number; // Weighted count reflecting cultural complexity
totalDistanceKm: number;
totalTravelTimeMinutes: number;
totalVisitTimeMinutes: number;
totalTimeMinutes: number;
densityScore: number;
densityLevel: 'low' | 'moderate' | 'high' | 'very_high';
culturalComplexityScore: number; // Score based on place type weights
places: Place[];
}
interface DebugInfo {
dailyMetrics: DayMetrics[];
overallMetrics: {
totalDays: number;
totalPlaces: number;
weightedPlaceCount: number; // Total weighted place count
totalDistanceKm: number;
totalTimeHours: number;
averageDensityScore: number;
maxDensityScore: number;
averageCulturalComplexity: number; // Average cultural complexity across days
};
decisionFactors: {
factor: string;
value: string | number;
impact: 'positive' | 'negative' | 'neutral';
reasoning: string;
}[];
recommendation_reasoning: string;
}
interface AnalyzeTripRequest {
destination: string;
days: Array<{
date: string;
places: Place[];
}>;
startPoint?: string;
travelers: number;
interests: string[];
tripHasBalloon?: boolean; // Trip-level constraint: balloon already exists
}
interface TravelerProfile {
group_type: 'couple' | 'family' | 'friends' | 'solo';
pace: 'slow' | 'balanced' | 'fast';
budget_level: 'low' | 'mid' | 'high';
}
interface ComparisonMetrics {
distance_saved_km: number;
time_saved_hours: number;
logistics_removed: string[];
expert_value: string[];
}
interface RecommendationDisplayMetadata {
service_type_label: string;
service_type_icon?: string;
group_label?: string;
group_description?: string;
segment_message?: string;
}
interface AITourAnalysis {
recommend: boolean;
reason: string;
recommended_type: 'daily_tour' | 'private_guide' | 'driver_car' | 'activity_bundle';
ideal_duration: string;
best_time: string;
why_better_than_self: string[];
confidence: number;
comparison_metrics: ComparisonMetrics;
traveler_profile: TravelerProfile;
display_metadata: RecommendationDisplayMetadata; // AI-provided display information
daily_tour_slug?: string; // AI or rule-based recommended tour slug
debug_info?: DebugInfo; // Debug information explaining the recommendation
}
// Haversine formula to calculate distance between two coordinates (km)
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Earth's radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// Generate display metadata for UI rendering
function generateDisplayMetadata(
serviceType: string,
travelerProfile: TravelerProfile
): RecommendationDisplayMetadata {
// Service type metadata mapping
const serviceTypeMetadata: Record<string, { label: string; icon: string }> = {
'daily_tour': { label: 'Günlük Tur', icon: '🗓️' },
'private_guide': { label: 'Özel Rehber', icon: '👤' },
'driver_car': { label: 'Şoförlü Araç', icon: '🚗' },
'activity_bundle': { label: 'Aktivite Paketi', icon: '🎯' },
'hot_air_balloon': { label: 'Sıcak Hava Balonu', icon: '🎈' },
'guided_tour': { label: 'Rehberli Tur', icon: '🎫' },
};
const metadata = serviceTypeMetadata[serviceType] || { label: serviceType, icon: '📍' };
// Generate segment-specific message based on traveler profile
let segmentMessage = 'Bu günü bireysel gezmek yerine profesyonel destek almak daha mantıklı olabilir.';
const { group_type, pace } = travelerProfile;
if (group_type === 'couple') {
segmentMessage = 'Çiftler için daha rahat ve romantik bir deneyim sunabilir.';
} else if (group_type === 'family') {
segmentMessage = 'Aileniz için daha az yorucu ve çocuklarla daha uygun olabilir.';
} else if (group_type === 'friends') {
segmentMessage = 'Grup için daha verimli ve eğlenceli bir rota sağlayabilir.';
} else if (group_type === 'solo') {
segmentMessage = 'Tek başına gezerken güvenlik ve yerel bilgi açısından avantaj sağlayabilir.';
}
if (pace === 'fast') {
segmentMessage = 'Yoğun programınız için zaman kazandırabilir.';
} else if (pace === 'slow') {
segmentMessage = 'Rahat tempoda daha keyifli bir deneyim sunabilir.';
}
return {
service_type_label: metadata.label,
service_type_icon: metadata.icon,
group_label: 'Önerilen Turlar',
group_description: `${metadata.label} ile daha iyi bir deneyim`,
segment_message: segmentMessage,
};
}
// Parse duration string to minutes (e.g., "2 hours" -> 120, "1.5 hours" -> 90)
function parseDurationToMinutes(duration?: string): number {
if (!duration) return 60; // Default 1 hour
const hourMatch = duration.match(/(\d+\.?\d*)\s*(hour|hr|saat)/i);
if (hourMatch) {
return Math.round(parseFloat(hourMatch[1]) * 60);
}
const minMatch = duration.match(/(\d+)\s*(min|minute|dakika)/i);
if (minMatch) {
return parseInt(minMatch[1]);
}
return 60; // Default 1 hour
}
// Estimate travel time based on distance (assuming average speed of 40 km/h in Cappadocia)
function estimateTravelTime(distanceKm: number): number {
const avgSpeedKmh = 40; // Average speed including stops, traffic, etc.
return Math.round((distanceKm / avgSpeedKmh) * 60); // Convert to minutes
}
// Calculate density score for a day
// Formula: (total_distance_km * 5 + total_time_hours * 10) / weighted_place_count
// Uses weighted place count to reflect cultural complexity
// Higher score = more packed/intense day
function calculateDensityScore(totalDistanceKm: number, totalTimeMinutes: number, weightedPlaceCount: number): number {
if (weightedPlaceCount === 0) return 0;
const totalTimeHours = totalTimeMinutes / 60;
return (totalDistanceKm * 5 + totalTimeHours * 10) / weightedPlaceCount;
}
// Calculate cultural complexity score based on place type weights
function calculateCulturalComplexity(places: Place[]): number {
if (places.length === 0) return 0;
const totalWeight = places.reduce((sum, place) => sum + (place.weight || 1.0), 0);
return totalWeight / places.length; // Average weight per place
}
// Determine density level based on score
function getDensityLevel(score: number): 'low' | 'moderate' | 'high' | 'very_high' {
if (score >= 50) return 'very_high';
if (score >= 35) return 'high';
if (score >= 20) return 'moderate';
return 'low';
}
// Analyze trip and calculate metrics for each day
function analyzeTripMetrics(days: Array<{ date: string; places: Place[] }>): DayMetrics[] {
return days.map((day, dayIndex) => {
const places = day.places;
let totalDistanceKm = 0;
let totalTravelTimeMinutes = 0;
let totalVisitTimeMinutes = 0;
// Calculate metrics for each place
const enrichedPlaces = places.map((place, placeIndex) => {
const visitDuration = parseDurationToMinutes(place.duration);
totalVisitTimeMinutes += visitDuration;
// Calculate place weight based on type
const placeWeight = getPlaceTypeWeight(place.type || '');
let distanceFromPrevious = 0;
let travelTimeFromPrevious = 0;
// Calculate distance and travel time from previous place
if (placeIndex > 0 && place.lat && place.lng && places[placeIndex - 1].lat && places[placeIndex - 1].lng) {
distanceFromPrevious = calculateDistance(
places[placeIndex - 1].lat!,
places[placeIndex - 1].lng!,
place.lat,
place.lng
);
travelTimeFromPrevious = estimateTravelTime(distanceFromPrevious);
totalDistanceKm += distanceFromPrevious;
totalTravelTimeMinutes += travelTimeFromPrevious;
}
return {
...place,
distanceFromPreviousKm: distanceFromPrevious,
travelTimeFromPreviousMinutes: travelTimeFromPrevious,
visitDurationMinutes: visitDuration,
weight: placeWeight,
};
});
// Calculate weighted place count (sum of all weights)
const weightedPlaceCount = enrichedPlaces.reduce((sum, p) => sum + (p.weight || 1.0), 0);
// Calculate cultural complexity score
const culturalComplexityScore = calculateCulturalComplexity(enrichedPlaces);
const totalTimeMinutes = totalTravelTimeMinutes + totalVisitTimeMinutes;
const densityScore = calculateDensityScore(totalDistanceKm, totalTimeMinutes, weightedPlaceCount);
const densityLevel = getDensityLevel(densityScore);
return {
dayNumber: dayIndex + 1,
date: day.date,
totalPlaces: places.length,
weightedPlaceCount: Math.round(weightedPlaceCount * 10) / 10,
totalDistanceKm: Math.round(totalDistanceKm * 10) / 10,
totalTravelTimeMinutes: Math.round(totalTravelTimeMinutes),
totalVisitTimeMinutes: Math.round(totalVisitTimeMinutes),
totalTimeMinutes: Math.round(totalTimeMinutes),
densityScore: Math.round(densityScore * 10) / 10,
densityLevel,
culturalComplexityScore: Math.round(culturalComplexityScore * 100) / 100,
places: enrichedPlaces,
};
});
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
// Auth check
const auth = await requireAuth(req);
if (auth.error) return auth.error;
// Rate limit check (20 AI suggestions per hour)
const supabaseService = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const rateLimitResponse = await checkRateLimit(auth.userId, 'ai_suggest', supabaseService);
if (rateLimitResponse) return rateLimitResponse;
// Payload size guard
const body: AnalyzeTripRequest = await req.json();
if (body.days && body.days.length > 14) {
return new Response(
JSON.stringify({ error: 'Too many days: maximum 14 allowed' }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
const { destination, days, travelers, interests, tripHasBalloon = false } = body;
// Analyze trip metrics for each day
const dailyMetrics = analyzeTripMetrics(days);
// Calculate overall metrics
const totalDays = days.length;
const totalPlaces = days.reduce((sum, day) => sum + day.places.length, 0);
const weightedPlaceCount = dailyMetrics.reduce((sum, day) => sum + day.weightedPlaceCount, 0);
const totalDistanceKm = dailyMetrics.reduce((sum, day) => sum + day.totalDistanceKm, 0);
const totalTimeHours = dailyMetrics.reduce((sum, day) => sum + day.totalTimeMinutes, 0) / 60;
const averageDensityScore = dailyMetrics.reduce((sum, day) => sum + day.densityScore, 0) / totalDays;
const maxDensityScore = Math.max(...dailyMetrics.map(day => day.densityScore));
const averageCulturalComplexity = dailyMetrics.reduce((sum, day) => sum + day.culturalComplexityScore, 0) / totalDays;
// Initialize decision factors
const decisionFactors: DebugInfo['decisionFactors'] = [];
// Factor 1: Density Score Analysis
if (maxDensityScore >= 50) {
decisionFactors.push({
factor: 'Very High Density Day',
value: maxDensityScore,
impact: 'positive',
reasoning: 'At least one day has very high density (≥50), indicating complex logistics that would benefit from professional tour organization.'
});
} else if (maxDensityScore >= 35) {
decisionFactors.push({
factor: 'High Density Day',
value: maxDensityScore,
impact: 'positive',
reasoning: 'At least one day has high density (35-50), suggesting tour guidance would improve experience.'
});
} else if (maxDensityScore >= 20) {
decisionFactors.push({
factor: 'Moderate Density',
value: maxDensityScore,
impact: 'neutral',
reasoning: 'Days have moderate density (20-35), tour is optional but could add value.'
});
} else {
decisionFactors.push({
factor: 'Low Density',
value: maxDensityScore,
impact: 'negative',
reasoning: 'Days have low density (<20), self-planning is manageable.'
});
}
// Factor 2: Total Distance Analysis
if (totalDistanceKm > 100) {
decisionFactors.push({
factor: 'Long Distance Travel',
value: `${Math.round(totalDistanceKm)} km`,
impact: 'positive',
reasoning: 'Total distance exceeds 100km, organized transportation would save time and reduce stress.'
});
} else if (totalDistanceKm > 50) {
decisionFactors.push({
factor: 'Moderate Distance Travel',
value: `${Math.round(totalDistanceKm)} km`,
impact: 'neutral',
reasoning: 'Moderate total distance (50-100km), transportation planning is important.'
});
}
// Factor 3: Time Commitment Analysis
const avgTimePerDay = totalTimeHours / totalDays;
if (avgTimePerDay > 8) {
decisionFactors.push({
factor: 'Long Daily Duration',
value: `${Math.round(avgTimePerDay * 10) / 10} hours/day`,
impact: 'positive',
reasoning: 'Average day exceeds 8 hours, professional guidance would optimize time usage.'
});
}
// Factor 4: Group Size Analysis
if (travelers >= 4) {
decisionFactors.push({
factor: 'Large Group',
value: `${travelers} travelers`,
impact: 'positive',
reasoning: 'Group of 4+ people benefits from private guide or group tour for better coordination.'
});
} else if (travelers === 1) {
decisionFactors.push({
factor: 'Solo Traveler',
value: '1 traveler',
impact: 'neutral',
reasoning: 'Solo travelers can join group tours for social experience and cost efficiency.'
});
}
// Factor 5: Place Count Analysis
const avgPlacesPerDay = totalPlaces / totalDays;
if (avgPlacesPerDay >= 5) {
decisionFactors.push({
factor: 'High Place Count',
value: `${Math.round(avgPlacesPerDay * 10) / 10} places/day`,
impact: 'positive',
reasoning: 'Visiting 5+ places per day requires efficient routing and time management.'
});
}
// Factor 6: Cultural Complexity Analysis
if (averageCulturalComplexity >= 2.5) {
decisionFactors.push({
factor: 'High Cultural Complexity',
value: `${Math.round(averageCulturalComplexity * 100) / 100}`,
impact: 'positive',
reasoning: 'Trip includes many museums and cultural sites that benefit significantly from expert guide commentary and historical context.'
});
} else if (averageCulturalComplexity >= 2.0) {
decisionFactors.push({
factor: 'Moderate Cultural Complexity',
value: `${Math.round(averageCulturalComplexity * 100) / 100}`,
impact: 'positive',
reasoning: 'Mix of cultural sites and activities would benefit from professional guidance.'
});
}
// Validation: Check if trip qualifies for tour recommendation
// GOLDEN RULE: AI MATCHING LOGIC
// Query daily_tours from database and match based on place types overlap
let matchedDailyTour: { slug: string; confidence: number; reasoning: string; why_better: string[] } | null = null;
// Normalize destination to region_slug
let regionSlug = 'cappadocia';
if (destination.toLowerCase().includes('kapadokya') || destination.toLowerCase().includes('cappadocia')) {
regionSlug = 'cappadocia';
}
// Get all active daily tours for this region
// IMPORTANT: If trip already has balloon, exclude balloon-only tours
let toursQuery = supabase
.from('daily_tours')
.select('*')
.eq('region_slug', regionSlug)
.eq('is_active', true);
// If trip has balloon, exclude balloon_day tour
if (tripHasBalloon) {
toursQuery = toursQuery.neq('slug', 'balloon_day');
}
const { data: dailyTours, error: toursError } = await toursQuery;
if (!toursError && dailyTours && dailyTours.length > 0) {
// Extract place types from trip with weights
const tripPlaceTypes = new Map<string, number>(); // type -> weight
days.forEach(day => {
day.places.forEach(place => {
if (place.type) {
const placeType = place.type.toLowerCase();
const weight = getPlaceTypeWeight(placeType);
tripPlaceTypes.set(placeType, Math.max(tripPlaceTypes.get(placeType) || 0, weight));
}
// Also extract from place name
const name = place.name.toLowerCase();
if (name.includes('museum') || name.includes('müze')) {
const weight = getPlaceTypeWeight('museum');
tripPlaceTypes.set('museum', Math.max(tripPlaceTypes.get('museum') || 0, weight));
}
if (name.includes('valley') || name.includes('vadi')) {
const weight = getPlaceTypeWeight('valley');
tripPlaceTypes.set('valley', Math.max(tripPlaceTypes.get('valley') || 0, weight));
}
if (name.includes('underground') || name.includes('yeraltı')) {
const weight = getPlaceTypeWeight('underground_city');
tripPlaceTypes.set('underground_city', Math.max(tripPlaceTypes.get('underground_city') || 0, weight));
}
if (name.includes('church') || name.includes('kilise')) {
const weight = getPlaceTypeWeight('historical');
tripPlaceTypes.set('historical', Math.max(tripPlaceTypes.get('historical') || 0, weight));
}
if (name.includes('panorama') || name.includes('manzara')) {
const weight = getPlaceTypeWeight('panorama');
tripPlaceTypes.set('panorama', Math.max(tripPlaceTypes.get('panorama') || 0, weight));
}
if (name.includes('balloon') || name.includes('balon')) {
const weight = getPlaceTypeWeight('hot_air_balloon');
tripPlaceTypes.set('hot_air_balloon', Math.max(tripPlaceTypes.get('hot_air_balloon') || 0, weight));
}
});
});
// Score each daily tour based on weighted overlap
const scoredTours = dailyTours.map(tour => {
const tourTypes = new Set(tour.related_place_types.map((t: string) => t.toLowerCase()));
// Calculate weighted overlap
let weightedOverlap = 0;
let totalTripWeight = 0;
tripPlaceTypes.forEach((weight, type) => {
totalTripWeight += weight;
if (tourTypes.has(type)) {
weightedOverlap += weight;
}
});
// Calculate weighted score (0-1 range)
// Higher weight types (museums) contribute more to the match score
const score = totalTripWeight > 0 ? weightedOverlap / totalTripWeight : 0;
return {
slug: tour.slug,
title: tour.title,
description: tour.description,
score: score,
weightedOverlap: Math.round(weightedOverlap * 10) / 10
};
});
// Sort by score (highest first)
scoredTours.sort((a, b) => b.score - a.score);
// Get best match
const bestMatch = scoredTours[0];
if (bestMatch && bestMatch.score >= 0.3) {
// Generate reasoning based on tour
let reasoning = `Planınız ${bestMatch.title} rotasıyla %${Math.round(bestMatch.score * 100)} uyumlu.`;
let whyBetter: string[] = [];
if (bestMatch.slug === 'red_tour') {
whyBetter = [
'Profesyonel rehber eşliğinde tarihi detayları öğrenin',
'Müze giriş biletleri ve transferler dahil',
'Öğle yemeği ve park yerleri organize edilmiş',
'Kalabalık saatlerden kaçınarak zaman kazanın'
];
} else if (bestMatch.slug === 'green_tour') {
whyBetter = [
'Yeraltı şehrinde rehber olmadan kaybolma riski yok',
'Ihlara Vadisi yürüyüşü için en iyi rotalar',
'Öğle yemeği vadide nehir kenarında',
'Uzun mesafeli transferler organize edilmiş'
];
} else if (bestMatch.slug === 'blue_tour') {
whyBetter = [
'Kalabalık turistik yerlerden uzak, huzurlu deneyim',
'Yerel köy ziyaretleri ve otantik yaşam',
'Daha az bilinen ama muhteşem kiliseler',
'Küçük grup turları ile kişisel deneyim'
];
} else if (bestMatch.slug === 'balloon_day') {
whyBetter = [
'Sabah balon uçuşu için erken kalkış organizasyonu',
'Balon sonrası hafif tur ile günü tamamlayın',
'Transfer ve lojistik tamamen organize',
'Unutulmaz bir deneyim için profesyonel destek'
];
} else if (bestMatch.slug === 'private_guide') {
whyBetter = [
'Tamamen size özel program',
'İstediğiniz yerleri istediğiniz sırayla gezin',
'Esnek zaman yönetimi',
'Aile ve büyük gruplar için ideal'
];
} else {
whyBetter = [
'Profesyonel rehber desteği',
'Organize transfer ve lojistik',
'Zaman ve enerji tasarrufu',
'Yerel bilgi ve deneyim'
];
}
matchedDailyTour = {
slug: bestMatch.slug,
confidence: Math.min(bestMatch.score + 0.2, 0.95), // Boost confidence slightly
reasoning: reasoning,
why_better: whyBetter
};
}
}
// FALLBACK: If no match found, suggest private_guide for 4+ travelers
if (!matchedDailyTour && travelers >= 4) {
matchedDailyTour = {
slug: 'private_guide',
confidence: 0.75,
reasoning: 'Grup büyüklüğünüz için özel rehber hizmeti daha uygun olabilir.',
why_better: [
'Tamamen size özel program',
'İstediğiniz yerleri istediğiniz sırayla gezin',
'Esnek zaman yönetimi',
'Aile ve büyük gruplar için ideal'
]
};
}
// Check for qualified activities
const qualifiedActivities = [
'hot-air-balloon',
'atv',
'horse-riding',
'guided-tour',
'museum',
'historical',
'adventure'
];
const hasQualifiedActivity = days.some(day =>
day.places.some(place =>
qualifiedActivities.some(activity =>
place.type?.toLowerCase().includes(activity) ||
place.name?.toLowerCase().includes(activity)
)
) || day.places.filter(p => p.type === 'museum').length >= 3
);
// If matched daily tour found with sufficient confidence, use it directly
if (matchedDailyTour && matchedDailyTour.confidence >= 0.5) {
// CRITICAL: Derive recommended_type from matched service slug
// This ensures the type matches the actual service being recommended
let recommendedType: 'daily_tour' | 'private_guide' | 'driver_car' | 'activity_bundle' = 'daily_tour';
if (matchedDailyTour.slug === 'private_guide') {
recommendedType = 'private_guide';
} else if (matchedDailyTour.slug === 'driver_car') {
recommendedType = 'driver_car';
} else if (matchedDailyTour.slug === 'activity_bundle') {
recommendedType = 'activity_bundle';
} else {
// All other slugs (red_tour, green_tour, blue_tour, balloon_day, etc.) are daily tours
recommendedType = 'daily_tour';
}
// Build debug info
const debugInfo: DebugInfo = {
dailyMetrics,
overallMetrics: {
totalDays,
totalPlaces,
weightedPlaceCount: Math.round(weightedPlaceCount * 10) / 10,
totalDistanceKm: Math.round(totalDistanceKm * 10) / 10,
totalTimeHours: Math.round(totalTimeHours * 10) / 10,
averageDensityScore: Math.round(averageDensityScore * 10) / 10,
maxDensityScore: Math.round(maxDensityScore * 10) / 10,
averageCulturalComplexity: Math.round(averageCulturalComplexity * 100) / 100,
},
decisionFactors,
recommendation_reasoning: `Matched service '${matchedDailyTour.slug}' (type: ${recommendedType}) with ${Math.round(matchedDailyTour.confidence * 100)}% confidence. ${matchedDailyTour.reasoning}`
};
// Generate traveler profile
const travelerProfile: TravelerProfile = {
group_type: travelers === 1 ? 'solo' : travelers === 2 ? 'couple' : travelers <= 4 ? 'friends' : 'family',
pace: totalPlaces / totalDays <= 2 ? 'slow' : totalPlaces / totalDays <= 4 ? 'balanced' : 'fast',
budget_level: 'mid'
};
// Generate display metadata for UI
const displayMetadata = generateDisplayMetadata(recommendedType, travelerProfile);
return new Response(
JSON.stringify({
recommend: true,
reason: matchedDailyTour.reasoning,
recommended_type: recommendedType,
ideal_duration: '6-8 hours',
best_time: '09:00 - 17:00',
why_better_than_self: matchedDailyTour.why_better,
confidence: matchedDailyTour.confidence,
daily_tour_slug: matchedDailyTour.slug,
comparison_metrics: {
distance_saved_km: Math.round(totalDistanceKm * 0.3), // Tours optimize routes by ~30%
time_saved_hours: Math.round(totalTimeHours * 0.25 * 10) / 10, // Save ~25% time with organized tours
logistics_removed: ['Ticket purchasing', 'Transfer arrangements', 'Finding guides', 'Route planning'],
expert_value: ['Local expert knowledge', 'Historical insights', 'Hidden gems', 'Local recommendations']
},
traveler_profile: travelerProfile,
display_metadata: displayMetadata,
debug_info: debugInfo
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// FALLBACK RECOMMENDATIONS: Instead of rejecting, provide alternative services
// Even short trips can benefit from professional services
// Check if trip is truly trivial (only reject if absolutely no value)
const isTrivialTrip = totalPlaces <= 1 && totalDistanceKm < 5 && totalTimeHours < 2;
if (isTrivialTrip) {
// Only return recommend:false for truly trivial trips
const debugInfo: DebugInfo = {
dailyMetrics,
overallMetrics: {
totalDays,
totalPlaces,
weightedPlaceCount: Math.round(weightedPlaceCount * 10) / 10,
totalDistanceKm: Math.round(totalDistanceKm * 10) / 10,
totalTimeHours: Math.round(totalTimeHours * 10) / 10,
averageDensityScore: Math.round(averageDensityScore * 10) / 10,
maxDensityScore: Math.round(maxDensityScore * 10) / 10,
averageCulturalComplexity: Math.round(averageCulturalComplexity * 100) / 100,
},
decisionFactors,
recommendation_reasoning: `Trip is trivial: ${totalPlaces} place(s), ${Math.round(totalDistanceKm)} km, ${Math.round(totalTimeHours)} hours. Self-planning is sufficient.`
};
return new Response(
JSON.stringify({
recommend: false,
reason: 'Your trip is simple enough to manage independently',
recommended_type: '',
ideal_duration: '',
best_time: '',
why_better_than_self: [],
confidence: 0,
debug_info: debugInfo
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// FALLBACK LOGIC: Provide recommendations based on trip characteristics
let fallbackService: { slug: string; confidence: number; reasoning: string; why_better: string[] } | null = null;
// Short but dense trips (1 day, high density) → private_guide or driver_car
if (totalDays === 1 && maxDensityScore >= 30) {
decisionFactors.push({
factor: 'Short Dense Trip',
value: `1 day, density ${maxDensityScore}`,
impact: 'positive',
reasoning: 'Single day with high density benefits from professional guidance'
});
if (travelers >= 4) {
fallbackService = {
slug: 'private_guide',
confidence: 0.65,
reasoning: 'Your group size and packed itinerary would benefit from a private guide',
why_better: [
'Flexible schedule tailored to your group',
'Expert local knowledge',
'No time wasted on logistics',
'Better coordination for larger groups'
]
};
} else {
fallbackService = {
slug: 'driver_car',
confidence: 0.60,
reasoning: 'A driver service would help you maximize your limited time',
why_better: [
'Comfortable transportation between sites',
'No parking hassles',
'More time at attractions',
'Local driver knows best routes'
]
};
}
}
// Long distances → driver_car
else if (totalDistanceKm >= 50) {
decisionFactors.push({
factor: 'Long Distance Travel',
value: `${Math.round(totalDistanceKm)} km`,
impact: 'positive',
reasoning: 'Significant travel distance benefits from professional driver'
});
fallbackService = {
slug: 'driver_car',
confidence: 0.65,
reasoning: 'The distances between your destinations make a driver service valuable',
why_better: [
'Comfortable long-distance travel',
'No navigation stress',
'Flexible stops along the way',
'Arrive refreshed at each destination'
]
};
}
// Multiple places but no tour match → private_guide
else if (totalPlaces >= 3) {
decisionFactors.push({
factor: 'Multiple Destinations',
value: `${totalPlaces} places`,
impact: 'neutral',
reasoning: 'Multiple destinations could benefit from local expertise'
});
fallbackService = {
slug: 'private_guide',
confidence: 0.55,
reasoning: 'A private guide could enhance your experience across multiple sites',
why_better: [
'Personalized attention',
'Historical and cultural context',
'Insider tips and recommendations',
'Efficient time management'
]
};
}
// Large group → private_guide
else if (travelers >= 4) {
decisionFactors.push({
factor: 'Large Group',
value: `${travelers} travelers`,
impact: 'positive',
reasoning: 'Larger groups benefit from coordinated guidance'
});
fallbackService = {
slug: 'private_guide',
confidence: 0.60,
reasoning: 'Your group size makes a private guide service worthwhile',
why_better: [
'Keep everyone together',
'Customized to group interests',
'Better group coordination',
'Shared cost makes it economical'
]
};
}
// If we have a fallback service, return it
if (fallbackService) {
let recommendedType: 'daily_tour' | 'private_guide' | 'driver_car' | 'activity_bundle' = 'daily_tour';
if (fallbackService.slug === 'private_guide') {
recommendedType = 'private_guide';
} else if (fallbackService.slug === 'driver_car') {
recommendedType = 'driver_car';
} else if (fallbackService.slug === 'activity_bundle') {
recommendedType = 'activity_bundle';
}
const debugInfo: DebugInfo = {
dailyMetrics,
overallMetrics: {
totalDays,
totalPlaces,
weightedPlaceCount: Math.round(weightedPlaceCount * 10) / 10,
totalDistanceKm: Math.round(totalDistanceKm * 10) / 10,
totalTimeHours: Math.round(totalTimeHours * 10) / 10,
averageDensityScore: Math.round(averageDensityScore * 10) / 10,
maxDensityScore: Math.round(maxDensityScore * 10) / 10,
averageCulturalComplexity: Math.round(averageCulturalComplexity * 100) / 100,
},
decisionFactors,
recommendation_reasoning: `Fallback recommendation: ${fallbackService.slug} (type: ${recommendedType}) with ${Math.round(fallbackService.confidence * 100)}% confidence. ${fallbackService.reasoning}`
};
// Generate traveler profile
const travelerProfile: TravelerProfile = {
group_type: travelers === 1 ? 'solo' : travelers === 2 ? 'couple' : travelers <= 4 ? 'friends' : 'family',
pace: totalPlaces / totalDays <= 2 ? 'slow' : totalPlaces / totalDays <= 4 ? 'balanced' : 'fast',
budget_level: 'mid'
};
// Generate display metadata for UI
const displayMetadata = generateDisplayMetadata(recommendedType, travelerProfile);
return new Response(
JSON.stringify({
recommend: true,
reason: fallbackService.reasoning,
recommended_type: recommendedType,
ideal_duration: recommendedType === 'driver_car' ? 'Flexible' : '4-6 hours',
best_time: '09:00 - 17:00',
why_better_than_self: fallbackService.why_better,
confidence: fallbackService.confidence,
daily_tour_slug: fallbackService.slug,
comparison_metrics: {
distance_saved_km: Math.round(totalDistanceKm * 0.2),
time_saved_hours: Math.round(totalTimeHours * 0.15 * 10) / 10,
logistics_removed: ['Navigation', 'Parking', 'Timing coordination'],
expert_value: ['Local knowledge', 'Efficient routing', 'Stress-free travel']
},
traveler_profile: travelerProfile,
display_metadata: displayMetadata,
debug_info: debugInfo
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// Prepare context for AI with density metrics
const contextText = `
Hedef: ${destination}
Gün sayısı: ${totalDays}
Toplam yer: ${totalPlaces}
ırlıklı yer sayısı: ${Math.round(weightedPlaceCount * 10) / 10} (museums=3.0x, activities=2.0x, restaurants=0.5x)
Gezgin sayısı: ${travelers}
İlgi alanları: ${interests.join(', ')}
YOĞUNLUK ANALİZİ (Density Analysis):
- Toplam mesafe: ${Math.round(totalDistanceKm)} km
- Toplam süre: ${Math.round(totalTimeHours * 10) / 10} saat
- Ortalama yoğunluk skoru: ${Math.round(averageDensityScore * 10) / 10}
- Maksimum yoğunluk skoru: ${Math.round(maxDensityScore * 10) / 10}
- Kültürel karmaşıklık: ${Math.round(averageCulturalComplexity * 100) / 100} (1.0=basic, 2.0=mixed, 3.0=museum-heavy)
ÖNEMLI: Ağırlıklı skorlama sistemi kullanılıyor:
- Müzeler ve tarihi yerler (museum, historical): 3.0x ağırlık - rehber en çok değer katar
- Aktiviteler (atv, tour, viewpoint): 2.0x ağırlık - orta seviye rehber değeri
- Restoranlar ve oteller: 0.5x ağırlık - minimal rehber değeri
Bu sistem, kültürel karmaşıklığı yansıtır, sadece yer sayısını değil.
Yoğunluk Seviyesi Rehberi:
- <20: Düşük (self-planning manageable)
- 20-35: Orta (tour optional)
- 35-50: Yüksek (tour recommended)
- ≥50: Çok yüksek (tour highly recommended)
Günlük detaylar:
${dailyMetrics.map((day) => `
Gün ${day.dayNumber} (${day.date}) - Yoğunluk: ${day.densityScore} (${day.densityLevel}), Kültürel: ${day.culturalComplexityScore}:
Yer sayısı: ${day.totalPlaces} (ağırlıklı: ${day.weightedPlaceCount})
Toplam mesafe: ${day.totalDistanceKm} km
Seyahat süresi: ${Math.round(day.totalTravelTimeMinutes)} dakika
Ziyaret süresi: ${Math.round(day.totalVisitTimeMinutes)} dakika
Toplam süre: ${Math.round(day.totalTimeMinutes / 60 * 10) / 10} saat
Yerler:
${day.places.map((p, idx) => {
let placeInfo = ` ${idx + 1}. ${p.name} (${p.type})`;
if (p.distanceFromPreviousKm && p.distanceFromPreviousKm > 0) {
placeInfo += `\n → Önceki yerden: ${Math.round(p.distanceFromPreviousKm * 10) / 10} km, ~${p.travelTimeFromPreviousMinutes} dakika`;
}
placeInfo += `\n Ziyaret süresi: ${p.visitDurationMinutes} dakika`;
return placeInfo;
}).join('\n')}
`).join('\n')}
KARAR FAKTÖRLERİ (Decision Factors):
${decisionFactors.map(f => `- ${f.factor}: ${f.value} (${f.impact}) - ${f.reasoning}`).join('\n')}
`;
// Call AI Search API for analysis
const aiSearchUrl = 'https://app-9w9pd00g5j41-api-zYm4ze3j7XvL.gateway.appmedo.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse';
const integrationsApiKey = Deno.env.get('INTEGRATIONS_API_KEY');
const prompt = `Sen bir seyahat danışmanısın. Aşağıdaki gezi planını analiz et ve bu planın bireysel gezilmek yerine rehberli tur/rehber/araç ile yapılmasının daha mantıklı olup olmadığını değerlendir.
${contextText}
ÖNEMLİ: Kararını verirken YOĞUNLUK SKORUNU (density score) dikkate al:
- Yoğunluk skoru ≥50: Kesinlikle tur öner (confidence ≥0.85)
- Yoğunluk skoru 35-50: Tur öner (confidence 0.70-0.85)
- Yoğunluk skoru 20-35: Opsiyonel tur veya private_guide/driver_car (confidence 0.50-0.70)
- Yoğunluk skoru <20: Fallback to driver_car or private_guide (confidence 0.40-0.60)
FALLBACK STRATEGY: Even if no perfect tour match, consider:
- Short but dense trips (1 day, density ≥30) → private_guide or driver_car
- Long distances (≥50km) → driver_car
- Large groups (≥4 people) → private_guide
- Multiple places (≥3) → private_guide
ONLY return recommend:false if trip is truly trivial (1 place, <5km, <2 hours).
Lütfen aşağıdaki JSON formatında yanıt ver (sadece JSON, başka metin ekleme):
{
"recommend": true/false,
"reason": "Kısa açıklama (1-2 cümle, yoğunluk skorunu ve karar faktörlerini belirt)",
"recommended_type": "daily_tour/private_guide/driver_car/activity_bundle",
"ideal_duration": "X-Y saat",
"best_time": "HH:MM - HH:MM",
"why_better_than_self": ["Neden 1", "Neden 2", "Neden 3"],
"confidence": 0.0-1.0,
"daily_tour_slug": "red_tour/green_tour/blue_tour/mixed_custom/private_guide/driver_car/activity_bundle",
"comparison_metrics": {
"distance_saved_km": 0,
"time_saved_hours": 0,
"logistics_removed": ["Bilet satın alma", "Transfer ayarlama"],
"expert_value": ["Bölgeyi bilen rehber", "Tarihi bilgi"]
},
"traveler_profile": {
"group_type": "couple/family/friends/solo",
"pace": "slow/balanced/fast",
"budget_level": "low/mid/high"
}
}
Değerlendirme kriterleri (YOĞUNLUK SKORU ÖNCELİKLİ):
1. YOĞUNLUK SKORU: En önemli faktör! Yüksek skor = tur öner
2. Toplam mesafe: >100km → tur öner, 50-100km → driver_car, >30km → consider driver
3. Günlük süre: >8 saat/gün → tur öner, >4 saat → private_guide
4. Yer sayısı: ≥5 yer/gün → tur öner, ≥3 yer → private_guide
5. Özel aktivite var mı? (balon, atv, at safari → activity_bundle öner)
6. Müze/tarihi alan yoğunluğu: ≥3 müze → tur öner
7. Grup büyüklüğü: ≥4 kişi → private_guide veya daily_tour
8. Tek gün ama yoğun (1 day, density ≥30) → private_guide or driver_car
Confidence hesaplama (ADJUSTED FOR FALLBACKS):
- Maksimum yoğunluk skoru ≥50: confidence = 0.85 + (skor-50)/100
- Maksimum yoğunluk skoru 35-50: confidence = 0.70 + (skor-35)/100
- Maksimum yoğunluk skoru 20-35: confidence = 0.50 + (skor-20)/100
- Maksimum yoğunluk skoru 10-20: confidence = 0.40 + (skor-10)/100 (fallback services)
- Maksimum yoğunluk skoru <10: confidence = skor/25 (only if other factors strong)
- Confidence < 0.35 ise recommend: false yap (only for truly trivial trips)
Gezgin profili analizi:
- ${travelers} kişi → group_type belirle (1: solo, 2: couple, 3-4: friends, 5+: family)
- Günlük yer sayısı → pace belirle (1-2: slow, 3-4: balanced, 5+: fast)
- İlgi alanları → budget_level tahmin et
Karşılaştırma metrikleri (gerçekçi hesapla):
- distance_saved_km: Toplam mesafenin %20-30'u (tur optimize eder)
- time_saved_hours: Toplam sürenin %15-25'i (bilet kuyruğu, bekleme, kaybolma önlenir)
- logistics_removed: Hangi işler ortadan kalkar (bilet, transfer, rehber bulma, vb.)
- expert_value: Rehberle ne kazanılır (yerel bilgi, gizli yerler, tarihi anlatım, vb.)
Öneri tipi seçimi:
- daily_tour: Tam gün tur (yüksek yoğunluk, çok yer) → daily_tour_slug belirle:
* red_tour: Göreme + müzeler + vadiler (yoğunluk ≥40)
* green_tour: Yeraltı şehri + Ihlara Vadisi + doğa (mesafe >80km)
* blue_tour: Soğanlı + sakin yerler (düşük yoğunluk ama uzak)
* balloon_day: Balon uçuşu + hafif tur (balon var)
* mixed_custom: Karışık özel tur
- private_guide: Sadece rehber (esnek program, kendi hızında, 4+ kişi) → daily_tour_slug: "private_guide"
- driver_car: Şoförlü araç (uzak yerler >100km, rahat ulaşım) → daily_tour_slug: "driver_car"
- activity_bundle: Aktivite paketi (balon + atv + at safari gibi) → daily_tour_slug: "activity_bundle"
ÖNEMLİ KURAL:
- recommended_type ve daily_tour_slug UYUMLU OLMALI!
- Eğer daily_tour_slug = "private_guide" ise, recommended_type = "private_guide"
- Eğer daily_tour_slug = "driver_car" ise, recommended_type = "driver_car"
- Eğer daily_tour_slug = "red_tour/green_tour/blue_tour/balloon_day/mixed_custom" ise, recommended_type = "daily_tour"
ÖNEMLİ: daily_tour_slug sadece daily_tours tablosundaki slug'lardan biri olmalı:
- red_tour
- green_tour
- blue_tour
- balloon_day
- private_guide
- driver_car
- activity_bundle
- mixed_custom
BAŞKA SLUG UYDURMA!
Önemli: Sadece JSON yanıt ver, başka açıklama ekleme.`;
// Call AI with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 saniye timeout
try {
const aiResponse = await fetch(aiSearchUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Gateway-Authorization': `Bearer ${integrationsApiKey}`,
},
body: JSON.stringify({
contents: [
{
role: 'user',
parts: [{ text: prompt }]
}
]
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!aiResponse.ok) {
throw new Error(`AI Search API error: ${aiResponse.statusText}`);
}
// Parse streaming response
const reader = aiResponse.body?.getReader();
const decoder = new TextDecoder();
let fullText = '';
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonData = JSON.parse(line.slice(6));
if (jsonData.candidates?.[0]?.content?.parts?.[0]?.text) {
fullText += jsonData.candidates[0].content.parts[0].text;
}
} catch (e) {
// Skip invalid JSON
}
}
}
}
}
// Extract JSON from response
const jsonMatch = fullText.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
throw new Error('No valid JSON found in AI response');
}
const analysis: AITourAnalysis = JSON.parse(jsonMatch[0]);
// Adjust confidence threshold for fallback recommendations
// Only reject if confidence is truly low (<0.35)
if (analysis.confidence < 0.35) {
analysis.recommend = false;
}
// CRITICAL: Validate and derive recommended_type from daily_tour_slug
// This ensures consistency between the slug and type
if (analysis.daily_tour_slug) {
if (analysis.daily_tour_slug === 'private_guide') {
analysis.recommended_type = 'private_guide';
} else if (analysis.daily_tour_slug === 'driver_car') {
analysis.recommended_type = 'driver_car';
} else if (analysis.daily_tour_slug === 'activity_bundle') {
analysis.recommended_type = 'activity_bundle';
} else if (['red_tour', 'green_tour', 'blue_tour', 'balloon_day', 'mixed_custom'].includes(analysis.daily_tour_slug)) {
// All tour slugs should map to daily_tour type
analysis.recommended_type = 'daily_tour';
}
// If slug doesn't match any known pattern, keep AI's original recommended_type
}
// Generate display metadata for UI
if (!analysis.display_metadata) {
const displayMetadata = generateDisplayMetadata(
analysis.recommended_type,
analysis.traveler_profile
);
analysis.display_metadata = displayMetadata;
}
// Add debug info to AI response
const debugInfo: DebugInfo = {
dailyMetrics,
overallMetrics: {
totalDays,
totalPlaces,
weightedPlaceCount: Math.round(weightedPlaceCount * 10) / 10,
totalDistanceKm: Math.round(totalDistanceKm * 10) / 10,
totalTimeHours: Math.round(totalTimeHours * 10) / 10,
averageDensityScore: Math.round(averageDensityScore * 10) / 10,
maxDensityScore: Math.round(maxDensityScore * 10) / 10,
averageCulturalComplexity: Math.round(averageCulturalComplexity * 100) / 100,
},
decisionFactors,
recommendation_reasoning: `AI Analysis: ${analysis.reason}. Confidence: ${Math.round(analysis.confidence * 100)}%. Max density score: ${Math.round(maxDensityScore * 10) / 10}. Derived type: ${analysis.recommended_type} from slug: ${analysis.daily_tour_slug || 'none'}.`
};
analysis.debug_info = debugInfo;
return new Response(
JSON.stringify(analysis),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
} catch (timeoutError: any) {
clearTimeout(timeoutId);
throw timeoutError;
}
} catch (error: any) {
console.error('Error in analyze-trip:', error);
// Handle timeout errors specifically
const isTimeout = error.name === 'AbortError';
return new Response(
JSON.stringify({
error: isTimeout ? 'AI analizi zaman aşımına uğradı (30s)' : error.message,
recommend: false,
confidence: 0,
debug_info: {
error: true,
error_message: error.message,
error_type: isTimeout ? 'timeout' : 'general'
}
}),
{
status: isTimeout ? 504 : 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
);
}
});