- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
1501 lines
47 KiB
Go
1501 lines
47 KiB
Go
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
|
||
}
|