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

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

1501 lines
47 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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
}