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
This commit is contained in:
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
@@ -22,6 +24,7 @@ type TravelOrchestratorConfig struct {
|
||||
LLM llm.Client
|
||||
SearchClient *search.SearXNGClient
|
||||
TravelData *TravelDataClient
|
||||
PhotoCache *PhotoCacheService
|
||||
Crawl4AIURL string
|
||||
Locale string
|
||||
TravelPayoutsToken string
|
||||
@@ -35,6 +38,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
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,
|
||||
@@ -66,6 +70,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -79,6 +84,7 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
|
||||
// --- Phase 2: Geocode destinations ---
|
||||
destGeo := geocodeDestinations(ctx, travelCfg, brief)
|
||||
destGeo = enforceOneDayFeasibility(ctx, &travelCfg, brief, destGeo)
|
||||
|
||||
sess.UpdateBlock(researchBlockID, []session.Patch{{
|
||||
Op: "replace",
|
||||
@@ -103,6 +109,62 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
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()
|
||||
@@ -116,7 +178,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
log.Printf("[travel] events collection error: %v", err)
|
||||
return nil
|
||||
}
|
||||
draftMu.Lock()
|
||||
draft.Candidates.Events = events
|
||||
draftMu.Unlock()
|
||||
emitCandidatesWidget("events")
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -127,7 +192,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
log.Printf("[travel] POI collection error: %v", err)
|
||||
return nil
|
||||
}
|
||||
draftMu.Lock()
|
||||
draft.Candidates.POIs = pois
|
||||
draftMu.Unlock()
|
||||
emitCandidatesWidget("pois")
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -138,7 +206,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
log.Printf("[travel] hotels collection error: %v", err)
|
||||
return nil
|
||||
}
|
||||
draftMu.Lock()
|
||||
draft.Candidates.Hotels = hotels
|
||||
draftMu.Unlock()
|
||||
emitCandidatesWidget("hotels")
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -149,7 +220,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
log.Printf("[travel] transport collection error: %v", err)
|
||||
return nil
|
||||
}
|
||||
draftMu.Lock()
|
||||
draft.Candidates.Transport = transport
|
||||
draftMu.Unlock()
|
||||
emitCandidatesWidget("transport")
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -160,7 +234,10 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
log.Printf("[travel] context collection error: %v", err)
|
||||
return nil
|
||||
}
|
||||
draftMu.Lock()
|
||||
draft.Context = travelCtx
|
||||
draftMu.Unlock()
|
||||
emitCandidatesWidget("context")
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -197,6 +274,55 @@ func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input Orc
|
||||
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) {
|
||||
@@ -219,9 +345,13 @@ func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input Or
|
||||
}
|
||||
|
||||
Правила:
|
||||
- Если пользователь говорит "сегодня" — startDate = текущая дата (` + time.Now().Format("2006-01-02") + `)
|
||||
- Для однодневных поездок endDate = startDate
|
||||
- Если дата не указана, оставь пустую строку ""
|
||||
- Сегодняшняя дата: ` + 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", используй их:
|
||||
@@ -257,9 +387,18 @@ func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input Or
|
||||
|
||||
var brief TripBrief
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &brief); err != nil {
|
||||
return &TripBrief{
|
||||
Destinations: extractDestinationsFromText(input.FollowUp),
|
||||
}, 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 {
|
||||
@@ -362,19 +501,21 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
@@ -398,6 +539,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -405,6 +547,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -428,10 +571,26 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
"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": draft.Context.Weather.Summary,
|
||||
"tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax),
|
||||
"conditions": draft.Context.Weather.Conditions,
|
||||
"weather": weatherCtx,
|
||||
"safetyLevel": draft.Context.Safety.Level,
|
||||
"restrictions": draft.Context.Restrictions,
|
||||
}
|
||||
@@ -442,6 +601,8 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
|
||||
Данные (с координатами для расчёта расстояний): %s
|
||||
|
||||
ВАЖНО: Если startDate == endDate — это ОДНОДНЕВНЫЙ план. Верни РОВНО 1 день в массиве "days" и поставь date=startDate.
|
||||
|
||||
КРИТИЧЕСКИЕ ПРАВИЛА РАСЧЁТА ВРЕМЕНИ:
|
||||
1. Используй координаты (lat, lng) для оценки расстояний между точками.
|
||||
2. Средняя скорость передвижения по городу: 15-20 км/ч (пробки, пешком, общественный транспорт).
|
||||
@@ -453,6 +614,12 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
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.
|
||||
@@ -488,6 +655,8 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
`+"```"+`
|
||||
|
||||
Дополнительные правила:
|
||||
- Для refType="poi"|"event"|"hotel" ЗАПРЕЩЕНО выдумывать места. Используй ТОЛЬКО объекты из данных и ставь их "refId" из списка.
|
||||
- Если подходящего POI/события/отеля в данных нет — используй refType="custom" (или "food" для еды) и ставь lat/lng = 0.
|
||||
- Между точками ОБЯЗАТЕЛЬНО вставляй элемент "transfer" с refType="transfer" если расстояние > 1 км
|
||||
- В note для transfer указывай расстояние и примерное время
|
||||
- Начинай день с отеля/завтрака
|
||||
@@ -506,14 +675,7 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
jsonStr := extractJSONFromResponse(response)
|
||||
if jsonStr == "" {
|
||||
return nil, summaryText, nil
|
||||
}
|
||||
@@ -522,15 +684,119 @@ func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draf
|
||||
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
|
||||
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
|
||||
@@ -577,6 +843,58 @@ func formatMinutesTime(minutes int) string {
|
||||
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 {
|
||||
@@ -674,44 +992,39 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
|
||||
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,
|
||||
}))
|
||||
}
|
||||
// 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
|
||||
if len(draft.Candidates.POIs) > 0 {
|
||||
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{
|
||||
"pois": draft.Candidates.POIs,
|
||||
}))
|
||||
}
|
||||
// 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
|
||||
if len(draft.Candidates.Hotels) > 0 {
|
||||
sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{
|
||||
"hotels": draft.Candidates.Hotels,
|
||||
}))
|
||||
}
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
// 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,
|
||||
}))
|
||||
}
|
||||
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 {
|
||||
@@ -723,6 +1036,9 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -735,15 +1051,6 @@ func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOr
|
||||
"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 {
|
||||
@@ -1020,90 +1327,135 @@ func buildRoadRoute(ctx context.Context, cfg *TravelOrchestratorConfig, points [
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
routeCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
routeCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
log.Printf("[travel] building road route segment-by-segment for %d points", len(points))
|
||||
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
|
||||
segments := make([]routeSegmentWithCosts, 0, len(points)-1)
|
||||
batchOK := true
|
||||
|
||||
for i := 0; i < len(points)-1; i++ {
|
||||
if i > 0 {
|
||||
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
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
pair := []MapPoint{points[i], points[i+1]}
|
||||
|
||||
var segDir *RouteDirectionResult
|
||||
var batchRoute *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") {
|
||||
batchRoute, err = cfg.TravelData.GetRoute(routeCtx, batch, "driving")
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("[travel] segment %d->%d rate limited, retry %d", i, i+1, attempt+1)
|
||||
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
|
||||
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...)
|
||||
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: distanceM,
|
||||
Distance: distM,
|
||||
Duration: durationS,
|
||||
}
|
||||
if distanceM > 0 {
|
||||
seg.TransportOptions = calculateTransportCosts(distanceM, durationS)
|
||||
if distM > 0 {
|
||||
seg.TransportOptions = calculateTransportCosts(distM, durationS)
|
||||
}
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
if len(allCoords) == 0 {
|
||||
log.Printf("[travel] no road coordinates collected")
|
||||
return nil, 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
|
||||
|
||||
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
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user