1232 lines
47 KiB
TypeScript
1232 lines
47 KiB
TypeScript
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}
|
||
Ağı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' }
|
||
}
|
||
);
|
||
}
|
||
});
|