Files
gooseek/backend/internal/agent/travel_orchestrator.go
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

1149 lines
35 KiB
Go
Raw 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 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
}