feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul

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

Made-with: Cursor
This commit is contained in:
home
2026-03-01 21:58:32 +03:00
parent e6b9cfc60a
commit 08bd41e75c
71 changed files with 12364 additions and 945 deletions

View File

@@ -0,0 +1,241 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"time"
"github.com/gooseek/backend/internal/llm"
"github.com/gooseek/backend/internal/search"
)
// TravelContext holds assessed conditions for the travel destination.
type TravelContext struct {
Weather WeatherAssessment `json:"weather"`
Safety SafetyAssessment `json:"safety"`
Restrictions []RestrictionItem `json:"restrictions"`
Tips []TravelTip `json:"tips"`
BestTimeInfo string `json:"bestTimeInfo,omitempty"`
}
type WeatherAssessment struct {
Summary string `json:"summary"`
TempMin float64 `json:"tempMin"`
TempMax float64 `json:"tempMax"`
Conditions string `json:"conditions"`
Clothing string `json:"clothing"`
RainChance string `json:"rainChance"`
}
type SafetyAssessment struct {
Level string `json:"level"`
Summary string `json:"summary"`
Warnings []string `json:"warnings,omitempty"`
EmergencyNo string `json:"emergencyNo"`
}
type RestrictionItem struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Severity string `json:"severity"`
}
type TravelTip struct {
Category string `json:"category"`
Text string `json:"text"`
}
// CollectTravelContext gathers current weather, safety, and restriction data.
func CollectTravelContext(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) (*TravelContext, error) {
if cfg.SearchClient == nil || cfg.LLM == nil {
return nil, nil
}
rawData := searchForContext(ctx, cfg.SearchClient, brief)
if len(rawData) == 0 {
log.Printf("[travel-context] no search results, using LLM knowledge only")
}
travelCtx := extractContextWithLLM(ctx, cfg.LLM, brief, rawData)
if travelCtx == nil {
return &TravelContext{
Safety: SafetyAssessment{
Level: "normal",
Summary: "Актуальная информация недоступна",
EmergencyNo: "112",
},
}, nil
}
return travelCtx, nil
}
type contextSearchResult struct {
Title string
URL string
Content string
}
func searchForContext(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []contextSearchResult {
var results []contextSearchResult
seen := make(map[string]bool)
dest := strings.Join(brief.Destinations, ", ")
currentYear := time.Now().Format("2006")
currentMonth := time.Now().Format("01")
monthNames := map[string]string{
"01": "январь", "02": "февраль", "03": "март",
"04": "апрель", "05": "май", "06": "июнь",
"07": "июль", "08": "август", "09": "сентябрь",
"10": "октябрь", "11": "ноябрь", "12": "декабрь",
}
month := monthNames[currentMonth]
queries := []string{
fmt.Sprintf("погода %s %s %s прогноз", dest, month, currentYear),
fmt.Sprintf("безопасность туристов %s %s", dest, currentYear),
fmt.Sprintf("ограничения %s туризм %s", dest, currentYear),
fmt.Sprintf("что нужно знать туристу %s %s", dest, currentYear),
}
for _, q := range queries {
searchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
Categories: []string{"general"},
PageNo: 1,
})
cancel()
if err != nil {
log.Printf("[travel-context] search error for '%s': %v", q, err)
continue
}
for _, r := range resp.Results {
if r.URL == "" || seen[r.URL] {
continue
}
seen[r.URL] = true
results = append(results, contextSearchResult{
Title: r.Title,
URL: r.URL,
Content: r.Content,
})
if len(results) >= 12 {
break
}
}
}
return results
}
func extractContextWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []contextSearchResult) *TravelContext {
var contextBuilder strings.Builder
if len(searchResults) > 0 {
contextBuilder.WriteString("Данные из поиска:\n\n")
maxResults := 8
if len(searchResults) < maxResults {
maxResults = len(searchResults)
}
for i := 0; i < maxResults; i++ {
r := searchResults[i]
contextBuilder.WriteString(fmt.Sprintf("### %s\n%s\n\n", r.Title, truncateStr(r.Content, 400)))
}
}
dest := strings.Join(brief.Destinations, ", ")
currentDate := time.Now().Format("2006-01-02")
prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени текущую обстановку в %s для поездки %s — %s.
Сегодня: %s.
%s
Верни ТОЛЬКО JSON (без текста):
{
"weather": {
"summary": "Краткое описание погоды на период поездки",
"tempMin": число_градусов_минимум,
"tempMax": число_градусов_максимум,
"conditions": "солнечно/облачно/дождливо/снежно",
"clothing": "Что надеть: конкретные рекомендации",
"rainChance": "низкая/средняя/высокая"
},
"safety": {
"level": "safe/caution/warning/danger",
"summary": "Общая оценка безопасности для туристов",
"warnings": ["предупреждение 1", "предупреждение 2"],
"emergencyNo": "номер экстренной помощи"
},
"restrictions": [
{
"type": "visa/health/transport/local",
"title": "Название ограничения",
"description": "Подробности",
"severity": "info/warning/critical"
}
],
"tips": [
{"category": "transport/money/culture/food/safety", "text": "Полезный совет"}
],
"bestTimeInfo": "Лучшее время для посещения и почему"
}
Правила:
- Используй ТОЛЬКО актуальные данные %s года
- weather: реальный прогноз на период поездки, не среднегодовые значения
- safety: объективная оценка, не преувеличивай опасности
- restrictions: визовые требования, медицинские ограничения, локальные правила
- tips: 3-5 практичных советов для туриста
- Если данных нет — используй свои знания о регионе, но отмечай это
- Температуры в градусах Цельсия`,
dest,
brief.StartDate,
brief.EndDate,
currentDate,
contextBuilder.String(),
time.Now().Format("2006"),
)
llmCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
Options: llm.StreamOptions{MaxTokens: 2000, Temperature: 0.2},
})
if err != nil {
log.Printf("[travel-context] LLM extraction failed: %v", err)
return nil
}
jsonMatch := regexp.MustCompile(`\{[\s\S]*\}`).FindString(response)
if jsonMatch == "" {
log.Printf("[travel-context] no JSON in LLM response")
return nil
}
var travelCtx TravelContext
if err := json.Unmarshal([]byte(jsonMatch), &travelCtx); err != nil {
log.Printf("[travel-context] JSON parse error: %v", err)
return nil
}
if travelCtx.Safety.EmergencyNo == "" {
travelCtx.Safety.EmergencyNo = "112"
}
log.Printf("[travel-context] extracted context: weather=%s, safety=%s, restrictions=%d, tips=%d",
travelCtx.Weather.Conditions, travelCtx.Safety.Level,
len(travelCtx.Restrictions), len(travelCtx.Tips))
return &travelCtx
}