package agent import ( "context" "encoding/json" "fmt" "log" "regexp" "strings" "time" "github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/search" "github.com/gooseek/backend/internal/session" "github.com/gooseek/backend/internal/types" "github.com/google/uuid" "golang.org/x/sync/errgroup" ) // TravelOrchestratorConfig holds all dependencies for the travel pipeline. type TravelOrchestratorConfig struct { LLM llm.Client SearchClient *search.SearXNGClient TravelData *TravelDataClient Crawl4AIURL string Locale string TravelPayoutsToken string TravelPayoutsMarker string } // RunTravelOrchestrator executes the multi-agent travel planning pipeline. // Flow: planner → parallel collectors → itinerary builder → widget streaming. func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input OrchestratorInput) error { travelCfg := TravelOrchestratorConfig{ LLM: input.Config.LLM, SearchClient: input.Config.SearchClient, TravelData: NewTravelDataClient(input.Config.TravelSvcURL), Crawl4AIURL: input.Config.Crawl4AIURL, Locale: input.Config.Locale, TravelPayoutsToken: input.Config.TravelPayoutsToken, TravelPayoutsMarker: input.Config.TravelPayoutsMarker, } researchBlockID := uuid.New().String() sess.EmitBlock(types.NewResearchBlock(researchBlockID)) sess.UpdateBlock(researchBlockID, []session.Patch{{ Op: "replace", Path: "/data/subSteps", Value: []types.ResearchSubStep{{ ID: uuid.New().String(), Type: "reasoning", Reasoning: "Анализирую запрос и составляю план путешествия...", }}, }}) // --- Phase 1: Planner Agent — extract brief from user message --- brief, err := runPlannerAgent(ctx, travelCfg, input) if err != nil { sess.EmitError(fmt.Errorf("planner agent failed: %w", err)) return err } if !brief.IsComplete() { return emitClarifyingQuestions(sess, brief) } brief.ApplyDefaults() // Geocode origin if we have a name but no coordinates if brief.Origin != "" && brief.OriginLat == 0 && brief.OriginLng == 0 { geo, err := travelCfg.TravelData.Geocode(ctx, brief.Origin) if err == nil { brief.OriginLat = geo.Lat brief.OriginLng = geo.Lng log.Printf("[travel] geocoded origin %q -> %.4f, %.4f", brief.Origin, geo.Lat, geo.Lng) } } // --- Phase 2: Geocode destinations --- destGeo := geocodeDestinations(ctx, travelCfg, brief) sess.UpdateBlock(researchBlockID, []session.Patch{{ Op: "replace", Path: "/data/subSteps", Value: []types.ResearchSubStep{{ ID: uuid.New().String(), Type: "searching", Searching: []string{ fmt.Sprintf("мероприятия %s %s", brief.Destinations[0], brief.StartDate), fmt.Sprintf("достопримечательности %s", brief.Destinations[0]), fmt.Sprintf("отели %s", brief.Destinations[0]), fmt.Sprintf("перелёты %s %s", brief.Origin, brief.Destinations[0]), }, }}, }}) // --- Phase 3: Parallel data collection (with timeout) --- draft := &TripDraft{ ID: uuid.New().String(), Brief: brief, Phase: "collecting", CreatedAt: time.Now(), UpdatedAt: time.Now(), } collectCtx, collectCancel := context.WithTimeout(ctx, 90*time.Second) defer collectCancel() g, gctx := errgroup.WithContext(collectCtx) // Agent 3: Events (SearXNG + Crawl4AI + LLM extraction + geocoding) g.Go(func() error { events, err := CollectEventsEnriched(gctx, travelCfg, brief) if err != nil { log.Printf("[travel] events collection error: %v", err) return nil } draft.Candidates.Events = events return nil }) // Agent 2: POI collector (SearXNG + Crawl4AI + LLM) g.Go(func() error { pois, err := CollectPOIsEnriched(gctx, travelCfg, brief, destGeo) if err != nil { log.Printf("[travel] POI collection error: %v", err) return nil } draft.Candidates.POIs = pois return nil }) // Agent 4: Hotels (SearXNG + Crawl4AI + LLM) g.Go(func() error { hotels, err := CollectHotelsEnriched(gctx, travelCfg, brief, destGeo) if err != nil { log.Printf("[travel] hotels collection error: %v", err) return nil } draft.Candidates.Hotels = hotels return nil }) // Agent 5: Transport — TravelPayouts flights API g.Go(func() error { transport, err := CollectFlightsFromTP(gctx, travelCfg, brief) if err != nil { log.Printf("[travel] transport collection error: %v", err) return nil } draft.Candidates.Transport = transport return nil }) // Agent 6: Travel Context — weather, safety, restrictions g.Go(func() error { travelCtx, err := CollectTravelContext(gctx, travelCfg, brief) if err != nil { log.Printf("[travel] context collection error: %v", err) return nil } draft.Context = travelCtx return nil }) _ = g.Wait() sess.EmitResearchComplete() // --- Phase 4: Itinerary Builder Agent (with timeout) --- sess.UpdateBlock(researchBlockID, []session.Patch{{ Op: "replace", Path: "/data/subSteps", Value: []types.ResearchSubStep{{ ID: uuid.New().String(), Type: "reasoning", Reasoning: "Составляю оптимальный маршрут по дням...", }}, }}) itineraryCtx, itineraryCancel := context.WithTimeout(ctx, 60*time.Second) defer itineraryCancel() itinerary, summaryText, err := runItineraryBuilder(itineraryCtx, travelCfg, draft) if err != nil { log.Printf("[travel] itinerary builder error: %v", err) } if itinerary != nil { draft.Selected.Itinerary = itinerary } // --- Phase 5: Emit all widgets --- emitTravelWidgets(ctx, sess, &travelCfg, draft, destGeo, summaryText) sess.EmitEnd() return nil } // --- Phase 1: Planner Agent --- func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input OrchestratorInput) (*TripBrief, error) { prompt := `Ты — AI-планировщик путешествий. Проанализируй запрос пользователя и извлеки структурированную информацию. Верни ТОЛЬКО JSON без пояснений: { "origin": "город отправления", "originLat": широта_отправления_или_0, "originLng": долгота_отправления_или_0, "destinations": ["город1", "город2"], "startDate": "YYYY-MM-DD (если указана)", "endDate": "YYYY-MM-DD (если указана)", "travelers": число_путешественников, "budget": число_бюджета, "currency": "RUB", "interests": ["интерес1", "интерес2"], "travelStyle": "стиль (budget/comfort/luxury)", "constraints": ["ограничение1"] } Правила: - Если пользователь говорит "сегодня" — startDate = текущая дата (` + time.Now().Format("2006-01-02") + `) - Для однодневных поездок endDate = startDate - Если дата не указана, оставь пустую строку "" - Если бюджет не указан, поставь 0 - Если количество путешественников не указано, поставь 0 - ВАЖНО: Если в сообщении есть координаты "Моё текущее местоположение: lat, lng", используй их: - origin = название ближайшего города по координатам (определи сам) - originLat = lat из координат - originLng = lng из координат - Если пользователь уже на месте ("мы в X", "мы уже в X"), origin = X - Если origin не указан и нет координат, оставь "" (не обязательное поле) - Маршрут ВСЕГДА начинается от origin (точка отправления пользователя) - destinations — массив, даже если одно направление - Извлекай информацию из ВСЕГО контекста диалога` messages := []llm.Message{ {Role: llm.RoleSystem, Content: prompt}, } messages = append(messages, input.ChatHistory...) messages = append(messages, llm.Message{Role: llm.RoleUser, Content: input.FollowUp}) response, err := cfg.LLM.GenerateText(ctx, llm.StreamRequest{ Messages: messages, Options: llm.StreamOptions{MaxTokens: 1024, Temperature: 0.1}, }) if err != nil { return nil, fmt.Errorf("planner LLM call failed: %w", err) } jsonMatch := regexp.MustCompile(`\{[\s\S]*\}`).FindString(response) if jsonMatch == "" { return &TripBrief{ Destinations: extractDestinationsFromText(input.FollowUp), }, nil } var brief TripBrief if err := json.Unmarshal([]byte(jsonMatch), &brief); err != nil { return &TripBrief{ Destinations: extractDestinationsFromText(input.FollowUp), }, nil } if len(brief.Destinations) == 0 { brief.Destinations = extractDestinationsFromText(input.FollowUp) } if brief.Currency == "" { brief.Currency = "RUB" } parseUserLocationCoords(&brief, input.FollowUp) return &brief, nil } func parseUserLocationCoords(brief *TripBrief, text string) { re := regexp.MustCompile(`Моё текущее местоположение:\s*([\d.]+),\s*([\d.]+)`) matches := re.FindStringSubmatch(text) if len(matches) < 3 { return } var lat, lng float64 if _, err := fmt.Sscanf(matches[1], "%f", &lat); err != nil { return } if _, err := fmt.Sscanf(matches[2], "%f", &lng); err != nil { return } if lat == 0 || lng == 0 { return } if brief.OriginLat == 0 && brief.OriginLng == 0 { brief.OriginLat = lat brief.OriginLng = lng } } func extractDestinationsFromText(text string) []string { text = strings.ToLower(text) cities := map[string]bool{ "москва": true, "санкт-петербург": true, "питер": true, "казань": true, "сочи": true, "калининград": true, "воронеж": true, "нижний новгород": true, "екатеринбург": true, "новосибирск": true, "красноярск": true, "владивосток": true, "ярославль": true, "суздаль": true, "владимир": true, "крым": true, "ялта": true, "севастополь": true, } var found []string for city := range cities { if strings.Contains(text, city) { found = append(found, city) } } if len(found) == 0 && strings.Contains(text, "золотое кольцо") { found = []string{"Сергиев Посад", "Переславль-Залесский", "Ростов Великий", "Ярославль", "Кострома", "Суздаль", "Владимир"} } return found } // --- Phase 2: Geocode --- type destGeoEntry struct { Name string Lat float64 Lng float64 } func geocodeDestinations(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) []destGeoEntry { results := make([]destGeoEntry, 0, len(brief.Destinations)) for _, dest := range brief.Destinations { geo, err := cfg.TravelData.Geocode(ctx, dest) if err != nil { log.Printf("[travel] geocode failed for %s: %v", dest, err) continue } results = append(results, destGeoEntry{ Name: dest, Lat: geo.Lat, Lng: geo.Lng, }) } return results } // --- Phase 3: Collectors are now in separate files --- // - travel_poi_collector.go: CollectPOIsEnriched (SearXNG + Crawl4AI + LLM) // - travel_hotels_collector.go: CollectHotelsEnriched (SearXNG + Crawl4AI + LLM) // - travel_flights_collector.go: CollectFlightsFromTP (TravelPayouts API) // --- Phase 4: Itinerary Builder --- func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draft *TripDraft) ([]ItineraryDay, string, error) { travelers := draft.Brief.Travelers if travelers < 1 { travelers = 1 } type poiCompact struct { ID string `json:"id"` Name string `json:"name"` Category string `json:"category"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Duration int `json:"duration"` Price float64 `json:"price"` Address string `json:"address"` } type eventCompact struct { ID string `json:"id"` Title string `json:"title"` DateStart string `json:"dateStart"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Price float64 `json:"price"` Address string `json:"address"` } type hotelCompact struct { ID string `json:"id"` Name string `json:"name"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Address string `json:"address"` } compactPOIs := make([]poiCompact, 0, len(draft.Candidates.POIs)) for _, p := range draft.Candidates.POIs { dur := p.Duration if dur < 30 { dur = 60 } compactPOIs = append(compactPOIs, poiCompact{ ID: p.ID, Name: p.Name, Category: p.Category, Lat: p.Lat, Lng: p.Lng, Duration: dur, Price: p.Price, Address: p.Address, }) } compactEvents := make([]eventCompact, 0, len(draft.Candidates.Events)) for _, e := range draft.Candidates.Events { compactEvents = append(compactEvents, eventCompact{ ID: e.ID, Title: e.Title, DateStart: e.DateStart, Lat: e.Lat, Lng: e.Lng, Price: e.Price, Address: e.Address, }) } compactHotels := make([]hotelCompact, 0, len(draft.Candidates.Hotels)) for _, h := range draft.Candidates.Hotels { compactHotels = append(compactHotels, hotelCompact{ ID: h.ID, Name: h.Name, Lat: h.Lat, Lng: h.Lng, Address: h.Address, }) } candidateData := map[string]interface{}{ "destinations": draft.Brief.Destinations, "startDate": draft.Brief.StartDate, "endDate": draft.Brief.EndDate, "travelers": travelers, "budget": draft.Brief.Budget, "currency": draft.Brief.Currency, "pois": compactPOIs, "events": compactEvents, "hotels": compactHotels, } if draft.Context != nil { candidateData["context"] = map[string]interface{}{ "weather": draft.Context.Weather.Summary, "tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax), "conditions": draft.Context.Weather.Conditions, "safetyLevel": draft.Context.Safety.Level, "restrictions": draft.Context.Restrictions, } } candidatesJSON, _ := json.Marshal(candidateData) prompt := fmt.Sprintf(`Ты — AI-планировщик маршрутов для группы из %d человек. Составь оптимальный маршрут по дням. Данные (с координатами для расчёта расстояний): %s КРИТИЧЕСКИЕ ПРАВИЛА РАСЧЁТА ВРЕМЕНИ: 1. Используй координаты (lat, lng) для оценки расстояний между точками. 2. Средняя скорость передвижения по городу: 15-20 км/ч (пробки, пешком, общественный транспорт). 3. Формула: расстояние_км = 111 * sqrt((lat1-lat2)^2 + (lng1-lng2)^2 * cos(lat1*pi/180)^2). Время_мин = расстояние_км / 15 * 60. 4. МЕЖДУ КАЖДЫМИ ДВУМЯ ТОЧКАМИ добавляй время на переезд. Если точки в разных концах города (>5 км) — минимум 30-40 минут переезда. 5. Минимальное время на посещение: музей/достопримечательность — 60-90 мин, ресторан — 60 мин, парк — 45 мин, мероприятие — 90-120 мин. 6. Группируй близкие точки (расстояние < 1 км) в один блок дня. 7. НЕ ставь точки в разных концах города подряд без достаточного времени на переезд. 8. Максимум 4-5 основных активностей в день (не считая еду и переезды). 9. День начинается в 09:00, заканчивается в 21:00. С детьми — до 19:00. ПРАВИЛА ЦЕН: 1. cost — цена НА ОДНОГО человека за эту активность. 2. Для бесплатных мест (парки, площади, улицы) — cost = 0. 3. Для ресторанов: средний чек на одного ~800-1500 RUB. 4. Для музеев/достопримечательностей: входной билет на одного. Верни ответ в формате: 1. Краткое текстовое описание маршрута (2-4 абзаца, на русском). Укажи что маршрут рассчитан на %d человек. 2. JSON блок: `+"```json"+` { "days": [ { "date": "YYYY-MM-DD", "items": [ { "refType": "poi|event|hotel|transport|food|transfer", "refId": "id из данных или пустая строка", "title": "Название", "startTime": "09:00", "endTime": "10:30", "lat": 55.75, "lng": 37.62, "note": "Переезд ~20 мин, 3 км / Время на осмотр 90 мин", "cost": 500, "currency": "RUB" } ] } ] } `+"```"+` Дополнительные правила: - Между точками ОБЯЗАТЕЛЬНО вставляй элемент "transfer" с refType="transfer" если расстояние > 1 км - В note для transfer указывай расстояние и примерное время - Начинай день с отеля/завтрака - Включай обед (12:00-14:00) и ужин (18:00-20:00) - Мероприятия с конкретными датами — в нужный день - Транспорт (перелёт/поезд) — первым/последним пунктом дня - lat/lng — числа из данных`, travelers, string(candidatesJSON), travelers) response, err := cfg.LLM.GenerateText(ctx, llm.StreamRequest{ Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, Options: llm.StreamOptions{MaxTokens: 8192, Temperature: 0.3}, }) if err != nil { return nil, "", fmt.Errorf("itinerary builder LLM failed: %w", err) } summaryText := extractTextBeforeJSON(response) jsonMatch := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```").FindStringSubmatch(response) var jsonStr string if len(jsonMatch) > 1 { jsonStr = strings.TrimSpace(jsonMatch[1]) } else { jsonStr = regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response) } if jsonStr == "" { return nil, summaryText, nil } var result struct { Days []ItineraryDay `json:"days"` } if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { log.Printf("[travel] itinerary JSON parse error: %v", err) return nil, summaryText, nil } result.Days = validateItineraryTimes(result.Days) return result.Days, summaryText, nil } func validateItineraryTimes(days []ItineraryDay) []ItineraryDay { for d := range days { items := days[d].Items for i := 1; i < len(items); i++ { prev := items[i-1] curr := items[i] if prev.EndTime == "" || curr.StartTime == "" { continue } prevEnd := parseTimeMinutes(prev.EndTime) currStart := parseTimeMinutes(curr.StartTime) if currStart < prevEnd { items[i].StartTime = prev.EndTime dur := parseTimeMinutes(curr.EndTime) - parseTimeMinutes(curr.StartTime) if dur < 30 { dur = 60 } items[i].EndTime = formatMinutesTime(prevEnd + dur) } } days[d].Items = items } return days } func parseTimeMinutes(t string) int { parts := strings.Split(t, ":") if len(parts) != 2 { return 0 } h, m := 0, 0 fmt.Sscanf(parts[0], "%d", &h) fmt.Sscanf(parts[1], "%d", &m) return h*60 + m } func formatMinutesTime(minutes int) string { if minutes >= 1440 { minutes = 1380 } return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) } func extractTextBeforeJSON(response string) string { idx := strings.Index(response, "```") if idx > 0 { return strings.TrimSpace(response[:idx]) } if len(response) > 1000 { return response[:1000] } return response } // --- Phase 5: Widget Emission --- func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOrchestratorConfig, draft *TripDraft, destGeo []destGeoEntry, summaryText string) { if summaryText != "" { textBlockID := uuid.New().String() sess.EmitBlock(types.NewTextBlock(textBlockID, summaryText)) } if draft.Context != nil { sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelContext), map[string]interface{}{ "weather": draft.Context.Weather, "safety": draft.Context.Safety, "restrictions": draft.Context.Restrictions, "tips": draft.Context.Tips, "bestTimeInfo": draft.Context.BestTimeInfo, })) } mapPoints := buildMapPoints(draft, destGeo) routeMapPoints := collectItineraryMapPoints(draft) if len(routeMapPoints) < 2 { routeMapPoints = filterItineraryLayerPoints(mapPoints) } if len(routeMapPoints) < 2 { routeMapPoints = filterValidMapPoints(mapPoints) } log.Printf("[travel] routing: %d points for road route", len(routeMapPoints)) routeDir, segments := buildRoadRoute(ctx, cfg, routeMapPoints) if len(mapPoints) > 0 { var center map[string]interface{} if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { center = map[string]interface{}{ "lat": draft.Brief.OriginLat, "lng": draft.Brief.OriginLng, "name": draft.Brief.Origin, } } else if len(destGeo) > 0 { center = map[string]interface{}{ "lat": destGeo[0].Lat, "lng": destGeo[0].Lng, "name": destGeo[0].Name, } } widgetParams := map[string]interface{}{ "center": center, "points": mapPoints, } if routeDir != nil { widgetParams["routeDirection"] = map[string]interface{}{ "geometry": map[string]interface{}{ "coordinates": routeDir.Geometry.Coordinates, "type": routeDir.Geometry.Type, }, "distance": routeDir.Distance, "duration": routeDir.Duration, "steps": routeDir.Steps, } } if len(segments) > 0 { widgetParams["segments"] = segments } var polyline [][2]float64 if routeDir != nil && len(routeDir.Geometry.Coordinates) > 0 { polyline = routeDir.Geometry.Coordinates } else { if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { polyline = append(polyline, [2]float64{draft.Brief.OriginLng, draft.Brief.OriginLat}) } for _, day := range draft.Selected.Itinerary { for _, item := range day.Items { if item.Lat != 0 && item.Lng != 0 { polyline = append(polyline, [2]float64{item.Lng, item.Lat}) } } } } widgetParams["polyline"] = polyline sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelMap), widgetParams)) } // Events widget if len(draft.Candidates.Events) > 0 { sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{ "events": draft.Candidates.Events, })) } // POI widget if len(draft.Candidates.POIs) > 0 { sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{ "pois": draft.Candidates.POIs, })) } // Hotels widget if len(draft.Candidates.Hotels) > 0 { sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{ "hotels": draft.Candidates.Hotels, })) } // Transport widget if len(draft.Candidates.Transport) > 0 { flights := make([]TransportOption, 0) ground := make([]TransportOption, 0) for _, t := range draft.Candidates.Transport { if t.Mode == "flight" { flights = append(flights, t) } else { ground = append(ground, t) } } sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{ "flights": flights, "ground": ground, "passengers": draft.Brief.Travelers, })) } // Itinerary widget if len(draft.Selected.Itinerary) > 0 { budget := calculateBudget(draft) itineraryParams := map[string]interface{}{ "days": draft.Selected.Itinerary, "budget": budget, } if len(segments) > 0 { itineraryParams["segments"] = segments } sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelItinerary), itineraryParams)) } // Budget widget budget := calculateBudget(draft) if budget.Total > 0 { sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelBudget), map[string]interface{}{ "breakdown": budget, "travelers": budget.Travelers, "perPerson": budget.PerPerson, })) } // Actions widget sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelActions), map[string]interface{}{ "actions": []map[string]interface{}{ {"id": "save_trip", "label": "Сохранить поездку", "kind": "save", "payload": map[string]interface{}{}}, {"id": "modify_route", "label": "Изменить маршрут", "kind": "modify", "payload": map[string]interface{}{}}, {"id": "add_more", "label": "Найти ещё варианты", "kind": "search", "payload": map[string]interface{}{}}, }, })) } func buildMapPoints(draft *TripDraft, destGeo []destGeoEntry) []MapPoint { var points []MapPoint seen := make(map[string]bool) if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { originLabel := draft.Brief.Origin if originLabel == "" { originLabel = "Точка отправления" } key := fmt.Sprintf("%.4f,%.4f", draft.Brief.OriginLat, draft.Brief.OriginLng) seen[key] = true points = append(points, MapPoint{ ID: "origin-" + uuid.New().String(), Lat: draft.Brief.OriginLat, Lng: draft.Brief.OriginLng, Type: "origin", Label: originLabel, Layer: "itinerary", }) } for _, day := range draft.Selected.Itinerary { for _, item := range day.Items { key := fmt.Sprintf("%.4f,%.4f", item.Lat, item.Lng) if item.Lat != 0 && item.Lng != 0 && !seen[key] { seen[key] = true points = append(points, MapPoint{ ID: item.RefID, Lat: item.Lat, Lng: item.Lng, Type: item.RefType, Label: item.Title, Layer: "itinerary", }) } } } for _, event := range draft.Candidates.Events { if event.Lat != 0 && event.Lng != 0 { key := fmt.Sprintf("%.4f,%.4f", event.Lat, event.Lng) if !seen[key] { seen[key] = true points = append(points, MapPoint{ ID: event.ID, Lat: event.Lat, Lng: event.Lng, Type: "event", Label: event.Title, Layer: "candidate", }) } } } for _, poi := range draft.Candidates.POIs { key := fmt.Sprintf("%.4f,%.4f", poi.Lat, poi.Lng) if !seen[key] { seen[key] = true points = append(points, MapPoint{ ID: poi.ID, Lat: poi.Lat, Lng: poi.Lng, Type: poi.Category, Label: poi.Name, Layer: "candidate", }) } } for _, hotel := range draft.Candidates.Hotels { key := fmt.Sprintf("%.4f,%.4f", hotel.Lat, hotel.Lng) if !seen[key] { seen[key] = true points = append(points, MapPoint{ ID: hotel.ID, Lat: hotel.Lat, Lng: hotel.Lng, Type: "hotel", Label: hotel.Name, Layer: "candidate", }) } } if len(points) == 0 || (len(points) == 1 && strings.HasPrefix(points[0].ID, "origin-")) { for _, g := range destGeo { key := fmt.Sprintf("%.4f,%.4f", g.Lat, g.Lng) if !seen[key] { seen[key] = true points = append(points, MapPoint{ ID: uuid.New().String(), Lat: g.Lat, Lng: g.Lng, Type: "destination", Label: g.Name, Layer: "itinerary", }) } } } return points } func calculateBudget(draft *TripDraft) BudgetBreakdown { travelers := draft.Brief.Travelers if travelers < 1 { travelers = 1 } budget := BudgetBreakdown{ Currency: draft.Brief.Currency, Travelers: travelers, } for _, t := range draft.Candidates.Transport { budget.Transport += t.Price } if len(draft.Candidates.Hotels) > 0 { best := draft.Candidates.Hotels[0] budget.Hotels = best.TotalPrice } for _, day := range draft.Selected.Itinerary { for _, item := range day.Items { budget.Activities += item.Cost } } budget.Activities *= float64(travelers) nights := 1 if draft.Brief.StartDate != "" && draft.Brief.EndDate != "" { start, err1 := time.Parse("2006-01-02", draft.Brief.StartDate) end, err2 := time.Parse("2006-01-02", draft.Brief.EndDate) if err1 == nil && err2 == nil { n := int(end.Sub(start).Hours() / 24) if n > 0 { nights = n } } } budget.Food = float64(travelers) * float64(nights) * 1500 budget.Total = budget.Transport + budget.Hotels + budget.Activities + budget.Food + budget.Other if travelers > 0 { budget.PerPerson = budget.Total / float64(travelers) } if draft.Brief.Budget > 0 { budget.Remaining = draft.Brief.Budget - budget.Total } return budget } func emitClarifyingQuestions(sess *session.Session, brief *TripBrief) error { questions := []ClarifyingQuestion{ { Field: "destination", Question: "Куда вы хотите поехать?", Type: "text", Placeholder: "Город или регион", }, } textBlockID := uuid.New().String() sess.EmitBlock(types.NewTextBlock(textBlockID, "Я помогу спланировать путешествие! Укажите, пожалуйста, куда вы хотите поехать.")) sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelClarifying), map[string]interface{}{ "questions": questions, })) sess.EmitEnd() return nil } func truncateStr(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // --- Road Routing & Transport Costs --- func collectItineraryMapPoints(draft *TripDraft) []MapPoint { var points []MapPoint if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { points = append(points, MapPoint{ ID: "origin", Lat: draft.Brief.OriginLat, Lng: draft.Brief.OriginLng, Type: "origin", Label: draft.Brief.Origin, Layer: "itinerary", }) } for _, day := range draft.Selected.Itinerary { for _, item := range day.Items { if item.Lat != 0 && item.Lng != 0 { points = append(points, MapPoint{ ID: item.RefID, Lat: item.Lat, Lng: item.Lng, Type: item.RefType, Label: item.Title, Layer: "itinerary", }) } } } return points } func filterItineraryLayerPoints(points []MapPoint) []MapPoint { var result []MapPoint for _, p := range points { if p.Layer == "itinerary" && p.Lat != 0 && p.Lng != 0 { result = append(result, p) } } return result } func filterValidMapPoints(points []MapPoint) []MapPoint { var result []MapPoint seen := make(map[string]bool) for _, p := range points { if p.Lat == 0 || p.Lng == 0 { continue } key := fmt.Sprintf("%.4f,%.4f", p.Lat, p.Lng) if seen[key] { continue } seen[key] = true result = append(result, p) } if len(result) > 10 { result = result[:10] } return result } type routeSegmentWithCosts struct { From string `json:"from"` To string `json:"to"` Distance float64 `json:"distance"` Duration float64 `json:"duration"` TransportOptions []transportCostOption `json:"transportOptions"` } type transportCostOption struct { Mode string `json:"mode"` Label string `json:"label"` Price float64 `json:"price"` Currency string `json:"currency"` Duration float64 `json:"duration"` } func buildRoadRoute(ctx context.Context, cfg *TravelOrchestratorConfig, points []MapPoint) (*RouteDirectionResult, []routeSegmentWithCosts) { if len(points) < 2 || cfg.TravelData == nil { log.Printf("[travel] buildRoadRoute skip: points=%d, travelData=%v", len(points), cfg.TravelData != nil) return nil, nil } routeCtx, cancel := context.WithTimeout(ctx, 60*time.Second) defer cancel() log.Printf("[travel] building road route segment-by-segment for %d points", len(points)) var allCoords [][2]float64 var allSteps []RouteStepResult var totalDistance, totalDuration float64 segments := make([]routeSegmentWithCosts, 0, len(points)-1) for i := 0; i < len(points)-1; i++ { if i > 0 { select { case <-routeCtx.Done(): break case <-time.After(300 * time.Millisecond): } } pair := []MapPoint{points[i], points[i+1]} var segDir *RouteDirectionResult var err error for attempt := 0; attempt < 3; attempt++ { segDir, err = cfg.TravelData.GetRoute(routeCtx, pair, "driving") if err == nil || !strings.Contains(err.Error(), "429") { break } log.Printf("[travel] segment %d->%d rate limited, retry %d", i, i+1, attempt+1) select { case <-routeCtx.Done(): break case <-time.After(time.Duration(1+attempt) * time.Second): } } var distanceM, durationS float64 if err != nil { log.Printf("[travel] segment %d->%d routing failed: %v", i, i+1, err) } else if segDir != nil { distanceM = segDir.Distance durationS = segDir.Duration totalDistance += distanceM totalDuration += durationS if len(segDir.Geometry.Coordinates) > 0 { if len(allCoords) > 0 && len(segDir.Geometry.Coordinates) > 0 { allCoords = append(allCoords, segDir.Geometry.Coordinates[1:]...) } else { allCoords = append(allCoords, segDir.Geometry.Coordinates...) } } allSteps = append(allSteps, segDir.Steps...) } seg := routeSegmentWithCosts{ From: points[i].Label, To: points[i+1].Label, Distance: distanceM, Duration: durationS, } if distanceM > 0 { seg.TransportOptions = calculateTransportCosts(distanceM, durationS) } segments = append(segments, seg) } if len(allCoords) == 0 { log.Printf("[travel] no road coordinates collected") return nil, segments } fullRoute := &RouteDirectionResult{ Geometry: RouteGeometryResult{ Coordinates: allCoords, Type: "LineString", }, Distance: totalDistance, Duration: totalDuration, Steps: allSteps, } log.Printf("[travel] road route OK: distance=%.0fm, coords=%d, segments=%d", totalDistance, len(allCoords), len(segments)) return fullRoute, segments } func calculateTransportCosts(distanceMeters float64, durationSeconds float64) []transportCostOption { distKm := distanceMeters / 1000.0 durationMin := durationSeconds / 60.0 options := []transportCostOption{ { Mode: "car", Label: "Машина", Price: roundPrice(distKm * 8.0), Currency: "RUB", Duration: durationMin, }, { Mode: "bus", Label: "Автобус", Price: roundPrice(distKm * 2.5), Currency: "RUB", Duration: durationMin * 1.4, }, { Mode: "taxi", Label: "Такси", Price: roundPrice(100 + distKm*18.0), Currency: "RUB", Duration: durationMin, }, } for i := range options { if options[i].Price < 30 { options[i].Price = 30 } } return options } func roundPrice(v float64) float64 { return float64(int(v/10+0.5)) * 10 }