package travel import ( "bufio" "context" "encoding/json" "fmt" "io" "strconv" "strings" "time" "github.com/google/uuid" ) type Service struct { repo *Repository amadeus *AmadeusClient openRoute *OpenRouteClient travelPayouts *TravelPayoutsClient twoGIS *TwoGISClient llmClient LLMClient useRussianAPIs bool } type LLMClient interface { StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error } type ChatMessage struct { Role string `json:"role"` Content string `json:"content"` } type ServiceConfig struct { Repository *Repository AmadeusConfig AmadeusConfig OpenRouteConfig OpenRouteConfig TravelPayoutsConfig TravelPayoutsConfig TwoGISConfig TwoGISConfig LLMClient LLMClient UseRussianAPIs bool } func NewService(cfg ServiceConfig) *Service { return &Service{ repo: cfg.Repository, amadeus: NewAmadeusClient(cfg.AmadeusConfig), openRoute: NewOpenRouteClient(cfg.OpenRouteConfig), travelPayouts: NewTravelPayoutsClient(cfg.TravelPayoutsConfig), twoGIS: NewTwoGISClient(cfg.TwoGISConfig), llmClient: cfg.LLMClient, useRussianAPIs: cfg.UseRussianAPIs, } } func (s *Service) PlanTrip(ctx context.Context, req TravelPlanRequest, writer io.Writer) error { systemPrompt := `Ты - AI планировщик путешествий GooSeek. ГЛАВНОЕ ПРАВИЛО: Если пользователь упоминает ЛЮБОЕ место, город, страну, регион или маршрут - ты ОБЯЗАН сразу построить маршрут с JSON. НЕ задавай уточняющих вопросов, НЕ здоровайся - сразу давай маршрут! Примеры запросов, на которые нужно СРАЗУ давать маршрут: - "золотое кольцо" → маршрут по городам Золотого кольца - "путешествие по Италии" → маршрут по Италии - "хочу в Париж" → маршрут по Парижу - "куда поехать в Крыму" → маршрут по Крыму Отвечай на русском языке. В ответе: 1. Кратко опиши маршрут (2-3 предложения) 2. Перечисли точки с описанием каждой 3. Укажи примерный бюджет 4. В КОНЦЕ ОБЯЗАТЕЛЬНО добавь JSON блок КООРДИНАТЫ ГОРОДОВ ЗОЛОТОГО КОЛЬЦА: - Сергиев Посад: lat 56.3100, lng 38.1326 - Переславль-Залесский: lat 56.7389, lng 38.8533 - Ростов Великий: lat 57.1848, lng 39.4142 - Ярославль: lat 57.6261, lng 39.8845 - Кострома: lat 57.7679, lng 40.9269 - Иваново: lat 56.9994, lng 40.9728 - Суздаль: lat 56.4212, lng 40.4496 - Владимир: lat 56.1366, lng 40.3966 ДРУГИЕ ПОПУЛЯРНЫЕ МЕСТА: - Москва, Красная площадь: lat 55.7539, lng 37.6208 - Санкт-Петербург, Эрмитаж: lat 59.9398, lng 30.3146 - Казань, Кремль: lat 55.7982, lng 49.1064 - Сочи, центр: lat 43.5855, lng 39.7231 - Калининград: lat 54.7104, lng 20.4522 ФОРМАТ JSON (ОБЯЗАТЕЛЕН В КОНЦЕ КАЖДОГО ОТВЕТА): ` + "```json" + ` { "route": [ { "name": "Название места", "lat": 55.7539, "lng": 37.6208, "type": "attraction", "aiComment": "Комментарий о месте", "duration": 120, "order": 1 } ], "suggestions": [ { "type": "activity", "title": "Название", "description": "Описание", "lat": 55.7539, "lng": 37.6208 } ] } ` + "```" + ` ТИПЫ ТОЧЕК: airport, hotel, attraction, restaurant, transport, custom ТИПЫ SUGGESTIONS: destination, activity, restaurant, transport ПРАВИЛА: 1. lat и lng - ЧИСЛА (не строки!) 2. duration - минуты (число) 3. order - порядковый номер с 1 4. Минимум 5 точек для маршрута 5. JSON ОБЯЗАТЕЛЕН даже для простых вопросов! 6. НИКОГДА не отвечай без JSON блока!` messages := []ChatMessage{ {Role: "system", Content: systemPrompt}, } for _, h := range req.History { messages = append(messages, ChatMessage{Role: "user", Content: h[0]}) messages = append(messages, ChatMessage{Role: "assistant", Content: h[1]}) } userMsg := req.Query if req.StartDate != "" && req.EndDate != "" { userMsg += fmt.Sprintf("\n\nДаты: %s - %s", req.StartDate, req.EndDate) } if req.Travelers > 0 { userMsg += fmt.Sprintf("\nКоличество путешественников: %d", req.Travelers) } if req.Budget > 0 { currency := req.Currency if currency == "" { currency = "RUB" } userMsg += fmt.Sprintf("\nБюджет: %.0f %s", req.Budget, currency) } if req.Preferences != nil { if req.Preferences.TravelStyle != "" { userMsg += fmt.Sprintf("\nСтиль путешествия: %s", req.Preferences.TravelStyle) } if len(req.Preferences.Interests) > 0 { userMsg += fmt.Sprintf("\nИнтересы: %s", strings.Join(req.Preferences.Interests, ", ")) } } messages = append(messages, ChatMessage{Role: "user", Content: userMsg}) writeEvent := func(eventType string, data interface{}) { event := map[string]interface{}{ "type": eventType, "data": data, } jsonData, _ := json.Marshal(event) writer.Write(jsonData) writer.Write([]byte("\n")) if bw, ok := writer.(*bufio.Writer); ok { bw.Flush() } } writeEvent("messageStart", nil) var fullResponse strings.Builder err := s.llmClient.StreamChat(ctx, messages, func(chunk string) { fullResponse.WriteString(chunk) writeEvent("textChunk", map[string]string{"chunk": chunk}) }) if err != nil { writeEvent("error", map[string]string{"message": err.Error()}) return err } responseText := fullResponse.String() fmt.Printf("[travel] Full LLM response length: %d chars\n", len(responseText)) routeData := s.extractRouteFromResponse(ctx, responseText) if routeData == nil || len(routeData.Route) == 0 { fmt.Println("[travel] No valid route in response, requesting route generation from LLM...") routeData = s.requestRouteGeneration(ctx, userMsg, responseText) } if routeData != nil && len(routeData.Route) > 0 { routeData = s.geocodeMissingCoordinates(ctx, routeData) } if routeData != nil && len(routeData.Route) > 0 { for i, p := range routeData.Route { fmt.Printf("[travel] Point %d: %s (lat=%.6f, lng=%.6f)\n", i+1, p.Name, p.Lat, p.Lng) } writeEvent("route", routeData) fmt.Printf("[travel] Sent route event with %d points\n", len(routeData.Route)) } else { fmt.Println("[travel] No route data after all attempts") } writeEvent("messageEnd", nil) return nil } func (s *Service) requestRouteGeneration(ctx context.Context, userQuery string, originalResponse string) *RouteData { if s.llmClient == nil { return nil } genPrompt := `Пользователь запросил: "` + userQuery + `" Ты должен СРАЗУ создать маршрут путешествия. ВЕРНИ ТОЛЬКО JSON без пояснений: { "route": [ {"name": "Место 1", "lat": 56.31, "lng": 38.13, "type": "attraction", "aiComment": "Описание", "duration": 120, "order": 1}, {"name": "Место 2", "lat": 56.74, "lng": 38.85, "type": "attraction", "aiComment": "Описание", "duration": 90, "order": 2} ], "suggestions": [] } КООРДИНАТЫ ПОПУЛЯРНЫХ МЕСТ: Золотое кольцо: Сергиев Посад (56.31, 38.13), Переславль-Залесский (56.74, 38.85), Ростов Великий (57.18, 39.41), Ярославль (57.63, 39.88), Кострома (57.77, 40.93), Суздаль (56.42, 40.45), Владимир (56.14, 40.40) Москва: Красная площадь (55.75, 37.62), Арбат (55.75, 37.59), ВДНХ (55.83, 37.64) Питер: Эрмитаж (59.94, 30.31), Петергоф (59.88, 29.91), Невский (59.93, 30.35) Крым: Ялта (44.49, 34.17), Севастополь (44.62, 33.52), Бахчисарай (44.75, 33.86) ВАЖНО: - lat и lng - ЧИСЛА - Минимум 5 точек - type: airport, hotel, attraction, restaurant, transport, custom - ТОЛЬКО JSON, без текста до и после!` messages := []ChatMessage{ {Role: "user", Content: genPrompt}, } var genResponse strings.Builder err := s.llmClient.StreamChat(ctx, messages, func(chunk string) { genResponse.WriteString(chunk) }) if err != nil { fmt.Printf("[travel] LLM route generation failed: %v\n", err) return nil } result := genResponse.String() fmt.Printf("[travel] LLM generated route: %s\n", result) return s.extractRouteFromResponse(ctx, result) } func (s *Service) geocodeMissingCoordinates(ctx context.Context, data *RouteData) *RouteData { if data == nil { return nil } validPoints := make([]RoutePoint, 0, len(data.Route)) for _, point := range data.Route { if (point.Lat == 0 && point.Lng == 0) && point.Name != "" { loc, err := s.Geocode(ctx, point.Name) if err == nil && loc != nil { point.Lat = loc.Lat point.Lng = loc.Lng if point.Address == "" { point.Address = loc.Name } fmt.Printf("[travel] Geocoded '%s' -> lat=%.4f, lng=%.4f\n", point.Name, point.Lat, point.Lng) } else { fmt.Printf("[travel] Failed to geocode '%s': %v\n", point.Name, err) } } if point.Lat != 0 || point.Lng != 0 { validPoints = append(validPoints, point) } } data.Route = validPoints return data } type RouteData struct { Route []RoutePoint `json:"route"` Suggestions []TravelSuggestion `json:"suggestions"` } func (s *Service) extractRouteFromResponse(_ context.Context, response string) *RouteData { var jsonStr string start := strings.Index(response, "```json") if start != -1 { start += 7 end := strings.Index(response[start:], "```") if end != -1 { jsonStr = strings.TrimSpace(response[start : start+end]) } } if jsonStr == "" { start = strings.Index(response, "```") if start != -1 { start += 3 if start < len(response) { for start < len(response) && (response[start] == '\n' || response[start] == '\r') { start++ } end := strings.Index(response[start:], "```") if end != -1 { candidate := strings.TrimSpace(response[start : start+end]) if strings.HasPrefix(candidate, "{") { jsonStr = candidate } } } } } if jsonStr == "" { routeStart := strings.Index(response, `"route"`) if routeStart != -1 { braceStart := strings.LastIndex(response[:routeStart], "{") if braceStart != -1 { depth := 0 braceEnd := -1 for i := braceStart; i < len(response); i++ { if response[i] == '{' { depth++ } else if response[i] == '}' { depth-- if depth == 0 { braceEnd = i + 1 break } } } if braceEnd != -1 { jsonStr = response[braceStart:braceEnd] } } } } if jsonStr == "" { return nil } var rawResult struct { Route []struct { ID string `json:"id"` Lat interface{} `json:"lat"` Lng interface{} `json:"lng"` Name string `json:"name"` Address string `json:"address,omitempty"` Type string `json:"type"` AIComment string `json:"aiComment,omitempty"` Duration interface{} `json:"duration,omitempty"` Order interface{} `json:"order"` } `json:"route"` Suggestions []struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Description string `json:"description"` Lat interface{} `json:"lat,omitempty"` Lng interface{} `json:"lng,omitempty"` } `json:"suggestions"` } if err := json.Unmarshal([]byte(jsonStr), &rawResult); err != nil { fmt.Printf("[travel] JSON parse error: %v, json: %s\n", err, jsonStr) return nil } result := &RouteData{ Route: make([]RoutePoint, 0, len(rawResult.Route)), Suggestions: make([]TravelSuggestion, 0, len(rawResult.Suggestions)), } for i, raw := range rawResult.Route { point := RoutePoint{ ID: raw.ID, Name: raw.Name, Address: raw.Address, Type: RoutePointType(raw.Type), AIComment: raw.AIComment, Order: i + 1, } if point.ID == "" { point.ID = uuid.New().String() } point.Lat = toFloat64(raw.Lat) point.Lng = toFloat64(raw.Lng) point.Duration = toInt(raw.Duration) if orderVal := toInt(raw.Order); orderVal > 0 { point.Order = orderVal } if point.Type == "" { point.Type = RoutePointCustom } if point.Name != "" { result.Route = append(result.Route, point) } } for _, raw := range rawResult.Suggestions { sug := TravelSuggestion{ ID: raw.ID, Type: raw.Type, Title: raw.Title, Description: raw.Description, } if sug.ID == "" { sug.ID = uuid.New().String() } sug.Lat = toFloat64(raw.Lat) sug.Lng = toFloat64(raw.Lng) result.Suggestions = append(result.Suggestions, sug) } fmt.Printf("[travel] Extracted %d route points, %d suggestions\n", len(result.Route), len(result.Suggestions)) return result } func toFloat64(v interface{}) float64 { if v == nil { return 0 } switch val := v.(type) { case float64: return val case float32: return float64(val) case int: return float64(val) case int64: return float64(val) case string: f, _ := strconv.ParseFloat(val, 64) return f } return 0 } func toInt(v interface{}) int { if v == nil { return 0 } switch val := v.(type) { case int: return val case int64: return int(val) case float64: return int(val) case float32: return int(val) case string: i, _ := strconv.Atoi(val) return i } return 0 } func (s *Service) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) { if s.useRussianAPIs && s.travelPayouts != nil { return s.travelPayouts.SearchFlights(ctx, req) } return s.amadeus.SearchFlights(ctx, req) } func (s *Service) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) { return s.amadeus.SearchHotels(ctx, req) } func (s *Service) GetRoute(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) { if s.twoGIS != nil && s.twoGIS.HasAPIKey() { transport := mapProfileToTwoGISTransport(profile) dir, err := s.twoGIS.GetRoute(ctx, points, transport) if err == nil { return dir, nil } fmt.Printf("[travel] 2GIS routing failed (transport=%s): %v, trying OpenRouteService\n", transport, err) } if s.openRoute != nil && s.openRoute.HasAPIKey() { return s.openRoute.GetDirections(ctx, points, profile) } return nil, fmt.Errorf("no routing provider available") } func mapProfileToTwoGISTransport(profile string) string { switch profile { case "driving-car", "driving", "car": return "driving" case "taxi": return "taxi" case "foot-walking", "walking", "pedestrian": return "walking" case "cycling-regular", "cycling", "bicycle": return "bicycle" default: return "driving" } } func (s *Service) Geocode(ctx context.Context, query string) (*GeoLocation, error) { if query == "" { return nil, fmt.Errorf("empty query") } if s.twoGIS != nil && s.twoGIS.HasAPIKey() { loc, err := s.twoGIS.Geocode(ctx, query) if err == nil && loc != nil { fmt.Printf("[travel] 2GIS geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng) return loc, nil } fmt.Printf("[travel] 2GIS geocode failed for '%s': %v\n", query, err) } if s.openRoute != nil && s.openRoute.HasAPIKey() { loc, err := s.openRoute.Geocode(ctx, query) if err == nil && loc != nil { fmt.Printf("[travel] OpenRoute geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng) return loc, nil } fmt.Printf("[travel] OpenRoute geocode failed for '%s': %v\n", query, err) } return nil, fmt.Errorf("geocode failed for '%s': no API keys configured", query) } func (s *Service) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) { return s.openRoute.SearchPOI(ctx, req) } func (s *Service) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) { if s.useRussianAPIs && s.travelPayouts != nil { return s.travelPayouts.GetPopularDestinations(ctx, origin) } return nil, nil } func (s *Service) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) { if s.twoGIS != nil && s.twoGIS.HasAPIKey() { return s.twoGIS.SearchPlaces(ctx, query, lat, lng, radius) } return nil, fmt.Errorf("2GIS API key not configured") } func (s *Service) CreateTrip(ctx context.Context, trip *Trip) error { return s.repo.CreateTrip(ctx, trip) } func (s *Service) GetTrip(ctx context.Context, id string) (*Trip, error) { return s.repo.GetTrip(ctx, id) } func (s *Service) GetUserTrips(ctx context.Context, userID string, limit, offset int) ([]Trip, error) { return s.repo.GetTripsByUser(ctx, userID, limit, offset) } func (s *Service) UpdateTrip(ctx context.Context, trip *Trip) error { return s.repo.UpdateTrip(ctx, trip) } func (s *Service) DeleteTrip(ctx context.Context, id string) error { return s.repo.DeleteTrip(ctx, id) } func (s *Service) GetUpcomingTrips(ctx context.Context, userID string) ([]Trip, error) { trips, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusPlanned) if err != nil { return nil, err } booked, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusBooked) if err != nil { return nil, err } now := time.Now() var upcoming []Trip for _, t := range append(trips, booked...) { if t.StartDate.After(now) { upcoming = append(upcoming, t) } } return upcoming, nil } func (s *Service) BuildRouteFromPoints(ctx context.Context, trip *Trip) (*RouteDirection, error) { if len(trip.Route) < 2 { return nil, fmt.Errorf("need at least 2 points for route") } points := make([]GeoLocation, len(trip.Route)) for i, p := range trip.Route { points[i] = GeoLocation{ Lat: p.Lat, Lng: p.Lng, Name: p.Name, } } return s.openRoute.GetDirections(ctx, points, "driving-car") } // ValidateItineraryRequest is the input for itinerary validation. type ValidateItineraryRequest struct { Days []ValidateDay `json:"days"` POIs []ValidatePOI `json:"pois,omitempty"` Events []ValidateEvent `json:"events,omitempty"` } type ValidateDay struct { Date string `json:"date"` Items []ValidateItem `json:"items"` } type ValidateItem struct { RefType string `json:"refType"` RefID string `json:"refId"` Title string `json:"title"` StartTime string `json:"startTime,omitempty"` EndTime string `json:"endTime,omitempty"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Note string `json:"note,omitempty"` Cost float64 `json:"cost,omitempty"` Currency string `json:"currency,omitempty"` } type ValidatePOI struct { ID string `json:"id"` Name string `json:"name"` Category string `json:"category"` Schedule map[string]string `json:"schedule,omitempty"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` } type ValidateEvent struct { ID string `json:"id"` Title string `json:"title"` DateStart string `json:"dateStart,omitempty"` DateEnd string `json:"dateEnd,omitempty"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` } type ValidationWarning struct { DayIdx int `json:"dayIdx"` ItemIdx int `json:"itemIdx,omitempty"` Message string `json:"message"` } type ValidateItineraryResponse struct { Valid bool `json:"valid"` Warnings []ValidationWarning `json:"warnings"` Suggestions []ValidationWarning `json:"suggestions"` } func (s *Service) ValidateItinerary(ctx context.Context, req ValidateItineraryRequest) (*ValidateItineraryResponse, error) { if s.llmClient == nil { return &ValidateItineraryResponse{Valid: true, Warnings: []ValidationWarning{}, Suggestions: []ValidationWarning{}}, nil } daysJSON, _ := json.Marshal(req.Days) poisJSON, _ := json.Marshal(req.POIs) eventsJSON, _ := json.Marshal(req.Events) prompt := fmt.Sprintf(`Проверь маршрут путешествия на логистические ошибки и предложи улучшения. Маршрут по дням: %s Доступные POI (с расписанием): %s Доступные события (с датами): %s Проверь: 1. Логистику: нет ли точек в разных концах города подряд без достаточного времени на переезд 2. Расписание: если POI имеет schedule и стоит в день когда закрыт — это ошибка 3. Даты событий: если событие стоит в день вне его dateStart-dateEnd — это ошибка 4. Реалистичность: не слишком ли много активностей в день (>6 основных) 5. Время: нет ли пересечений по времени Верни ТОЛЬКО JSON: { "valid": true/false, "warnings": [{"dayIdx": 0, "itemIdx": 2, "message": "причина"}], "suggestions": [{"dayIdx": 0, "message": "рекомендация"}] } Если всё хорошо — warnings пустой массив, valid=true. Suggestions — необязательные рекомендации.`, string(daysJSON), string(poisJSON), string(eventsJSON)) var fullResponse strings.Builder err := s.llmClient.StreamChat(ctx, []ChatMessage{ {Role: "user", Content: prompt}, }, func(chunk string) { fullResponse.WriteString(chunk) }) if err != nil { return nil, fmt.Errorf("LLM validation failed: %w", err) } responseText := fullResponse.String() jsonStart := strings.Index(responseText, "{") jsonEnd := strings.LastIndex(responseText, "}") if jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart { return &ValidateItineraryResponse{ Valid: false, Warnings: []ValidationWarning{{ DayIdx: 0, Message: "Не удалось проверить маршрут — повторите попытку", }}, Suggestions: []ValidationWarning{}, }, nil } var result ValidateItineraryResponse if err := json.Unmarshal([]byte(responseText[jsonStart:jsonEnd+1]), &result); err != nil { return &ValidateItineraryResponse{ Valid: false, Warnings: []ValidationWarning{{ DayIdx: 0, Message: "Ошибка анализа маршрута — попробуйте ещё раз", }}, Suggestions: []ValidationWarning{}, }, nil } if result.Warnings == nil { result.Warnings = []ValidationWarning{} } if result.Suggestions == nil { result.Suggestions = []ValidationWarning{} } return &result, nil } func (s *Service) EnrichTripWithAI(ctx context.Context, trip *Trip) error { if len(trip.Route) == 0 { return nil } for i := range trip.Route { point := &trip.Route[i] if point.AIComment == "" { pois, err := s.openRoute.SearchPOI(ctx, POISearchRequest{ Lat: point.Lat, Lng: point.Lng, Radius: 500, Limit: 5, }) if err == nil && len(pois) > 0 { var nearbyNames []string for _, poi := range pois { if poi.Name != "" && poi.Name != point.Name { nearbyNames = append(nearbyNames, poi.Name) } } if len(nearbyNames) > 0 { point.AIComment = fmt.Sprintf("Рядом: %s", strings.Join(nearbyNames, ", ")) } } } } return nil }