package agent import ( "context" "encoding/json" "fmt" "log" "math" "regexp" "strings" "sync" "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 PhotoCache *PhotoCacheService 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), PhotoCache: input.Config.PhotoCache, 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() enforceDefaultSingleDay(brief, input.FollowUp) // 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) destGeo = enforceOneDayFeasibility(ctx, &travelCfg, brief, destGeo) 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(), } var draftMu sync.Mutex var emitMu sync.Mutex emitCandidatesWidget := func(kind string) { emitMu.Lock() defer emitMu.Unlock() draftMu.Lock() defer draftMu.Unlock() switch kind { case "context": if draft.Context == nil { return } 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, })) case "events": sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{ "events": draft.Candidates.Events, "count": len(draft.Candidates.Events), })) case "pois": sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{ "pois": draft.Candidates.POIs, "count": len(draft.Candidates.POIs), })) case "hotels": sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{ "hotels": draft.Candidates.Hotels, "count": len(draft.Candidates.Hotels), })) case "transport": 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, })) default: return } } 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 } draftMu.Lock() draft.Candidates.Events = events draftMu.Unlock() emitCandidatesWidget("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 } draftMu.Lock() draft.Candidates.POIs = pois draftMu.Unlock() emitCandidatesWidget("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 } draftMu.Lock() draft.Candidates.Hotels = hotels draftMu.Unlock() emitCandidatesWidget("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 } draftMu.Lock() draft.Candidates.Transport = transport draftMu.Unlock() emitCandidatesWidget("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 } draftMu.Lock() draft.Context = travelCtx draftMu.Unlock() emitCandidatesWidget("context") 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 } func userExplicitlyProvidedDateRange(text string) bool { t := strings.ToLower(text) isoDate := regexp.MustCompile(`\b20\d{2}-\d{2}-\d{2}\b`) if len(isoDate.FindAllString(t, -1)) >= 2 { return true } loose := regexp.MustCompile(`\b\d{1,2}[./-]\d{1,2}([./-]\d{2,4})?\b`) if strings.Contains(t, "с ") && strings.Contains(t, " по ") && len(loose.FindAllString(t, -1)) >= 2 { return true } return false } func enforceDefaultSingleDay(brief *TripBrief, userText string) { // Product rule: default to ONE day unless user explicitly provided start+end dates. if !userExplicitlyProvidedDateRange(userText) { brief.EndDate = brief.StartDate } } func enforceOneDayFeasibility(ctx context.Context, cfg *TravelOrchestratorConfig, brief *TripBrief, destGeo []destGeoEntry) []destGeoEntry { // If it's a one-day request and origin+destination are far apart, // plan locally around origin (user is already there). if brief.StartDate == "" || brief.EndDate == "" || brief.StartDate != brief.EndDate { return destGeo } if brief.Origin == "" { return destGeo } if brief.OriginLat == 0 && brief.OriginLng == 0 { return destGeo } if len(destGeo) == 0 || (destGeo[0].Lat == 0 && destGeo[0].Lng == 0) { return destGeo } d := distanceKm(brief.OriginLat, brief.OriginLng, destGeo[0].Lat, destGeo[0].Lng) if d <= 250 { return destGeo } log.Printf("[travel] one-day request but destination is far (%.0fkm) — switching destination to origin %q", d, brief.Origin) brief.Destinations = []string{brief.Origin} return geocodeDestinations(ctx, *cfg, brief) } // --- 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"] } Правила: - Сегодняшняя дата: ` + time.Now().Format("2006-01-02") + ` - Если пользователь говорит "сегодня" — startDate = сегодняшняя дата - Если пользователь говорит "завтра" — startDate = завтрашняя дата (` + time.Now().AddDate(0, 0, 1).Format("2006-01-02") + `) - Если пользователь говорит "послезавтра" — startDate = послезавтрашняя дата (` + time.Now().AddDate(0, 0, 2).Format("2006-01-02") + `) - ВАЖНО: По умолчанию планируем ОДИН день. Если пользователь не указал конечную дату явно — endDate оставь пустой строкой "" - endDate заполняй ТОЛЬКО если пользователь явно указал диапазон дат (дата начала И дата конца) - Если дата не указана вообще, оставь пустую строку "" - Если бюджет не указан, поставь 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 { repaired := repairJSON(jsonMatch) if repaired != "" { if err2 := json.Unmarshal([]byte(repaired), &brief); err2 != nil { return &TripBrief{ Destinations: extractDestinationsFromText(input.FollowUp), }, nil } } else { 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"` Schedule map[string]string `json:"schedule,omitempty"` } type eventCompact struct { ID string `json:"id"` Title string `json:"title"` DateStart string `json:"dateStart"` DateEnd string `json:"dateEnd,omitempty"` 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, Schedule: p.Schedule, }) } 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, DateEnd: e.DateEnd, 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 { weatherCtx := map[string]interface{}{ "summary": draft.Context.Weather.Summary, "tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax), "conditions": draft.Context.Weather.Conditions, } if len(draft.Context.Weather.DailyForecast) > 0 { dailyWeather := make([]map[string]interface{}, 0, len(draft.Context.Weather.DailyForecast)) for _, d := range draft.Context.Weather.DailyForecast { dailyWeather = append(dailyWeather, map[string]interface{}{ "date": d.Date, "tempMin": d.TempMin, "tempMax": d.TempMax, "conditions": d.Conditions, "rainChance": d.RainChance, }) } weatherCtx["dailyForecast"] = dailyWeather } candidateData["context"] = map[string]interface{}{ "weather": weatherCtx, "safetyLevel": draft.Context.Safety.Level, "restrictions": draft.Context.Restrictions, } } candidatesJSON, _ := json.Marshal(candidateData) prompt := fmt.Sprintf(`Ты — AI-планировщик маршрутов для группы из %d человек. Составь оптимальный маршрут по дням. Данные (с координатами для расчёта расстояний): %s ВАЖНО: Если startDate == endDate — это ОДНОДНЕВНЫЙ план. Верни РОВНО 1 день в массиве "days" и поставь date=startDate. КРИТИЧЕСКИЕ ПРАВИЛА РАСЧЁТА ВРЕМЕНИ: 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. ПРАВИЛА ПОГОДЫ (если есть dailyForecast в context): 1. В дождливые дни (conditions: "дождь"/"гроза") — ставь крытые активности: музеи, торговые центры, рестораны, театры. 2. В солнечные дни — парки, смотровые площадки, прогулки, набережные. 3. В холодные дни (tempMax < 5°C) — больше крытых мест, меньше прогулок. 4. Если есть tip для дня — учитывай его при планировании. ПРАВИЛА ЦЕН: 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" } ] } ] } `+"```"+` Дополнительные правила: - Для refType="poi"|"event"|"hotel" ЗАПРЕЩЕНО выдумывать места. Используй ТОЛЬКО объекты из данных и ставь их "refId" из списка. - Если подходящего POI/события/отеля в данных нет — используй refType="custom" (или "food" для еды) и ставь lat/lng = 0. - Между точками ОБЯЗАТЕЛЬНО вставляй элемент "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) jsonStr := extractJSONFromResponse(response) if jsonStr == "" { return nil, summaryText, nil } var result struct { Days []ItineraryDay `json:"days"` } if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { repaired := repairJSON(jsonStr) if repaired != "" { if err2 := json.Unmarshal([]byte(repaired), &result); err2 != nil { log.Printf("[travel] itinerary JSON parse error (after repair): %v", err2) return nil, summaryText, nil } } else { log.Printf("[travel] itinerary JSON parse error: %v", err) return nil, summaryText, nil } } result.Days = validateItineraryTimes(result.Days) result.Days = postValidateItinerary(result.Days, draft) if draft.Brief != nil && draft.Brief.StartDate != "" && draft.Brief.EndDate == draft.Brief.StartDate && len(result.Days) > 1 { // Defensive clamp: for one-day plans keep only the first day. result.Days = result.Days[:1] result.Days[0].Date = draft.Brief.StartDate } return result.Days, summaryText, nil } func postValidateItinerary(days []ItineraryDay, draft *TripDraft) []ItineraryDay { poiByID := make(map[string]*POICard) for i := range draft.Candidates.POIs { poiByID[draft.Candidates.POIs[i].ID] = &draft.Candidates.POIs[i] } eventByID := make(map[string]*EventCard) for i := range draft.Candidates.Events { eventByID[draft.Candidates.Events[i].ID] = &draft.Candidates.Events[i] } hotelByID := make(map[string]*HotelCard) for i := range draft.Candidates.Hotels { hotelByID[draft.Candidates.Hotels[i].ID] = &draft.Candidates.Hotels[i] } // Build a centroid of "known-good" coordinates to detect out-of-area hallucinations. var sumLat, sumLng float64 var cnt float64 addPoint := func(lat, lng float64) { if lat == 0 && lng == 0 { return } sumLat += lat sumLng += lng cnt++ } for _, p := range draft.Candidates.POIs { addPoint(p.Lat, p.Lng) } for _, e := range draft.Candidates.Events { addPoint(e.Lat, e.Lng) } for _, h := range draft.Candidates.Hotels { addPoint(h.Lat, h.Lng) } centLat, centLng := 0.0, 0.0 if cnt > 0 { centLat = sumLat / cnt centLng = sumLng / cnt } for d := range days { for i := range days[d].Items { item := &days[d].Items[i] // If refId exists, always trust coordinates from candidates (even if LLM provided something else). if item.RefID != "" { if poi, ok := poiByID[item.RefID]; ok { item.Lat, item.Lng = poi.Lat, poi.Lng } else if ev, ok := eventByID[item.RefID]; ok { item.Lat, item.Lng = ev.Lat, ev.Lng } else if h, ok := hotelByID[item.RefID]; ok { item.Lat, item.Lng = h.Lat, h.Lng } else if item.RefType == "poi" || item.RefType == "event" || item.RefType == "hotel" { // Unknown refId for these types → convert to custom to avoid cross-city junk. item.RefType = "custom" item.RefID = "" item.Lat = 0 item.Lng = 0 if item.Note == "" { item.Note = "Уточнить место: не найдено среди вариантов для города" } } } // Clamp out-of-area coordinates (e.g., another country) if we have a centroid. if centLat != 0 || centLng != 0 { if item.Lat != 0 || item.Lng != 0 { if distanceKm(item.Lat, item.Lng, centLat, centLng) > 250 { item.Lat = 0 item.Lng = 0 if item.RefType == "poi" || item.RefType == "event" || item.RefType == "hotel" { item.RefType = "custom" item.RefID = "" } if item.Note == "" { item.Note = "Уточнить место: координаты вне города/маршрута" } } } } if item.Currency == "" { item.Currency = draft.Brief.Currency } } } return days } 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 extractJSONFromResponse(response string) string { codeBlockRe := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```") if m := codeBlockRe.FindStringSubmatch(response); len(m) > 1 { return strings.TrimSpace(m[1]) } if idx := strings.Index(response, `"days"`); idx >= 0 { braceStart := strings.LastIndex(response[:idx], "{") if braceStart >= 0 { depth := 0 for i := braceStart; i < len(response); i++ { switch response[i] { case '{': depth++ case '}': depth-- if depth == 0 { return response[braceStart : i+1] } } } } } return regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response) } func repairJSON(s string) string { s = strings.TrimSpace(s) s = regexp.MustCompile(`,\s*}`).ReplaceAllString(s, "}") s = regexp.MustCompile(`,\s*]`).ReplaceAllString(s, "]") openBraces := strings.Count(s, "{") - strings.Count(s, "}") for openBraces > 0 { s += "}" openBraces-- } openBrackets := strings.Count(s, "[") - strings.Count(s, "]") for openBrackets > 0 { s += "]" openBrackets-- } var test json.RawMessage if json.Unmarshal([]byte(s), &test) == nil { return s } return "" } 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 (always emit — UI shows empty state) sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{ "events": draft.Candidates.Events, "count": len(draft.Candidates.Events), })) // POI widget (always emit) sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{ "pois": draft.Candidates.POIs, "count": len(draft.Candidates.POIs), })) // Hotels widget (always emit) sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{ "hotels": draft.Candidates.Hotels, "count": len(draft.Candidates.Hotels), })) // Transport widget (always emit) 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 } if draft.Context != nil && len(draft.Context.Weather.DailyForecast) > 0 { itineraryParams["dailyForecast"] = draft.Context.Weather.DailyForecast } 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, })) } } 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, 90*time.Second) defer cancel() segments := buildSegmentCosts(points) // 2GIS supports up to 10 waypoints per request; batch accordingly const maxWaypoints = 10 log.Printf("[travel] building batched multi-point route for %d points (batch size %d)", len(points), maxWaypoints) var allCoords [][2]float64 var allSteps []RouteStepResult var totalDistance, totalDuration float64 batchOK := true for batchStart := 0; batchStart < len(points)-1; batchStart += maxWaypoints - 1 { batchEnd := batchStart + maxWaypoints if batchEnd > len(points) { batchEnd = len(points) } batch := points[batchStart:batchEnd] if len(batch) < 2 { break } if batchStart > 0 { select { case <-routeCtx.Done(): batchOK = false case <-time.After(1500 * time.Millisecond): } if !batchOK { break } } var batchRoute *RouteDirectionResult var err error for attempt := 0; attempt < 3; attempt++ { batchRoute, err = cfg.TravelData.GetRoute(routeCtx, batch, "driving") if err == nil { break } if !strings.Contains(err.Error(), "429") { break } log.Printf("[travel] batch %d-%d rate limited, retry %d", batchStart, batchEnd-1, attempt+1) select { case <-routeCtx.Done(): batchOK = false case <-time.After(time.Duration(2+attempt*2) * time.Second): } if !batchOK { break } } if err != nil { log.Printf("[travel] batch %d-%d routing failed: %v", batchStart, batchEnd-1, err) batchOK = false break } if batchRoute == nil || len(batchRoute.Geometry.Coordinates) < 2 { log.Printf("[travel] batch %d-%d returned empty geometry", batchStart, batchEnd-1) batchOK = false break } totalDistance += batchRoute.Distance totalDuration += batchRoute.Duration if len(allCoords) > 0 { allCoords = append(allCoords, batchRoute.Geometry.Coordinates[1:]...) } else { allCoords = append(allCoords, batchRoute.Geometry.Coordinates...) } allSteps = append(allSteps, batchRoute.Steps...) log.Printf("[travel] batch %d-%d OK: +%.0fm, +%d coords", batchStart, batchEnd-1, batchRoute.Distance, len(batchRoute.Geometry.Coordinates)) } if batchOK && len(allCoords) > 1 { 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 } log.Printf("[travel] batched routing failed, no road coordinates collected") return nil, segments } func buildSegmentCosts(points []MapPoint) []routeSegmentWithCosts { segments := make([]routeSegmentWithCosts, 0, len(points)-1) for i := 0; i < len(points)-1; i++ { distKm := haversineDistance(points[i].Lat, points[i].Lng, points[i+1].Lat, points[i+1].Lng) distM := distKm * 1000 durationS := distKm / 40.0 * 3600 // ~40 km/h average seg := routeSegmentWithCosts{ From: points[i].Label, To: points[i+1].Label, Distance: distM, Duration: durationS, } if distM > 0 { seg.TransportOptions = calculateTransportCosts(distM, durationS) } segments = append(segments, seg) } return segments } func haversineDistance(lat1, lng1, lat2, lng2 float64) float64 { const R = 6371.0 dLat := (lat2 - lat1) * math.Pi / 180 dLng := (lng2 - lng1) * math.Pi / 180 lat1Rad := lat1 * math.Pi / 180 lat2Rad := lat2 * math.Pi / 180 a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1Rad)*math.Cos(lat2Rad)* math.Sin(dLng/2)*math.Sin(dLng/2) c := 2 * math.Asin(math.Sqrt(a)) return R * c } 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 }