Files
gooseek/backend/internal/travel/service.go
home ab48a0632b
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped
feat: CI/CD pipeline + Learning/Medicine/Travel services
- Add Gitea Actions workflow for automated build & deploy
- Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc
- Update kustomization for localhost:5000 registry
- Add ingress for gooseek.ru and api.gooseek.ru
- Learning cabinet with onboarding, courses, sandbox integration
- Medicine service with symptom analysis and doctor matching
- Travel service with itinerary planning
- Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner)

Made-with: Cursor
2026-03-02 20:25:44 +03:00

796 lines
23 KiB
Go
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.
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
}