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:
@@ -17,6 +17,7 @@ const (
|
||||
FocusModeImages FocusMode = "images"
|
||||
FocusModeMath FocusMode = "math"
|
||||
FocusModeFinance FocusMode = "finance"
|
||||
FocusModeTravel FocusMode = "travel"
|
||||
)
|
||||
|
||||
type FocusModeConfig struct {
|
||||
@@ -165,6 +166,18 @@ Cite data sources and note when data may be delayed or historical.
|
||||
Include relevant disclaimers about investment risks.
|
||||
Reference SEC filings, analyst reports, and official company statements.`,
|
||||
},
|
||||
FocusModeTravel: {
|
||||
Mode: FocusModeTravel,
|
||||
Engines: []string{"google", "duckduckgo", "google news"},
|
||||
Categories: []string{"general", "news"},
|
||||
MaxSources: 15,
|
||||
RequiresCitation: true,
|
||||
AllowScraping: true,
|
||||
SystemPrompt: `You are a travel planning assistant.
|
||||
Help users plan trips with routes, accommodations, activities, and transport.
|
||||
Search for events, festivals, and attractions at destinations.
|
||||
Provide practical travel tips and budget estimates.`,
|
||||
},
|
||||
}
|
||||
|
||||
func GetFocusModeConfig(mode string) FocusModeConfig {
|
||||
@@ -236,6 +249,18 @@ func DetectFocusMode(query string) FocusMode {
|
||||
}
|
||||
}
|
||||
|
||||
travelKeywords := []string{
|
||||
"travel", "trip", "flight", "hotel", "vacation", "destination",
|
||||
"путешеств", "поездк", "маршрут", "отель", "перелёт", "перелет",
|
||||
"отдых", "тур", "куда поехать", "куда сходить", "достопримечательност",
|
||||
"мероприят", "билет", "бронирован",
|
||||
}
|
||||
for _, kw := range travelKeywords {
|
||||
if strings.Contains(queryLower, kw) {
|
||||
return FocusModeTravel
|
||||
}
|
||||
}
|
||||
|
||||
newsKeywords := []string{
|
||||
"news", "today", "latest", "breaking", "current events",
|
||||
"новост", "сегодня", "последн", "актуальн",
|
||||
|
||||
@@ -47,10 +47,13 @@ type OrchestratorConfig struct {
|
||||
LearningMode bool
|
||||
EnableDeepResearch bool
|
||||
EnableClarifying bool
|
||||
DiscoverSvcURL string
|
||||
Crawl4AIURL string
|
||||
CollectionSvcURL string
|
||||
FileSvcURL string
|
||||
DiscoverSvcURL string
|
||||
Crawl4AIURL string
|
||||
CollectionSvcURL string
|
||||
FileSvcURL string
|
||||
TravelSvcURL string
|
||||
TravelPayoutsToken string
|
||||
TravelPayoutsMarker string
|
||||
}
|
||||
|
||||
type DigestResponse struct {
|
||||
@@ -87,6 +90,10 @@ type OrchestratorInput struct {
|
||||
}
|
||||
|
||||
func RunOrchestrator(ctx context.Context, sess *session.Session, input OrchestratorInput) error {
|
||||
if input.Config.AnswerMode == "travel" {
|
||||
return RunTravelOrchestrator(ctx, sess, input)
|
||||
}
|
||||
|
||||
detectedLang := detectLanguage(input.FollowUp)
|
||||
isArticleSummary := strings.HasPrefix(strings.TrimSpace(input.FollowUp), "Summary: ")
|
||||
|
||||
|
||||
241
backend/internal/agent/travel_context_collector.go
Normal file
241
backend/internal/agent/travel_context_collector.go
Normal 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
|
||||
}
|
||||
454
backend/internal/agent/travel_data_client.go
Normal file
454
backend/internal/agent/travel_data_client.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
geocodeCache = make(map[string]*GeoResult)
|
||||
geocodeCacheMu sync.RWMutex
|
||||
)
|
||||
|
||||
// TravelDataClient calls the travel-svc data-layer endpoints.
|
||||
type TravelDataClient struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
maxRetries int
|
||||
}
|
||||
|
||||
func NewTravelDataClient(baseURL string) *TravelDataClient {
|
||||
return &TravelDataClient{
|
||||
baseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
maxRetries: 2,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(attempt*500) * time.Millisecond
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req.Clone(ctx))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
resp.Body.Close()
|
||||
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
return nil, fmt.Errorf("all %d retries failed: %w", c.maxRetries+1, lastErr)
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) Geocode(ctx context.Context, query string) (*GeoResult, error) {
|
||||
// Check in-memory cache first
|
||||
geocodeCacheMu.RLock()
|
||||
if cached, ok := geocodeCache[query]; ok {
|
||||
geocodeCacheMu.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
geocodeCacheMu.RUnlock()
|
||||
|
||||
u := fmt.Sprintf("%s/api/v1/travel/geocode?query=%s", c.baseURL, url.QueryEscape(query))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.doWithRetry(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geocode request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("geocode returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result GeoResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("geocode decode error: %w", err)
|
||||
}
|
||||
|
||||
geocodeCacheMu.Lock()
|
||||
geocodeCache[query] = &result
|
||||
geocodeCacheMu.Unlock()
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchPOI(ctx context.Context, lat, lng float64, radius int, categories []string) ([]POICard, error) {
|
||||
body := map[string]interface{}{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
"limit": 20,
|
||||
}
|
||||
if len(categories) > 0 {
|
||||
body["categories"] = categories
|
||||
}
|
||||
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/poi", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("poi search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("poi search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawPOIs []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Category string `json:"category"`
|
||||
Address string `json:"address"`
|
||||
Rating float64 `json:"rating"`
|
||||
Distance float64 `json:"distance"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawPOIs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]POICard, 0, len(rawPOIs))
|
||||
for _, p := range rawPOIs {
|
||||
cards = append(cards, POICard{
|
||||
ID: p.ID,
|
||||
Name: p.Name,
|
||||
Category: p.Category,
|
||||
Address: p.Address,
|
||||
Lat: p.Lat,
|
||||
Lng: p.Lng,
|
||||
Rating: p.Rating,
|
||||
})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchFlights(ctx context.Context, origin, destination, date string, adults int) ([]TransportOption, error) {
|
||||
body := map[string]interface{}{
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"departureDate": date,
|
||||
"adults": adults,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/flights", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("flights search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("flights search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawFlights []struct {
|
||||
ID string `json:"id"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flightNumber"`
|
||||
DepartureAirport string `json:"departureAirport"`
|
||||
DepartureCity string `json:"departureCity"`
|
||||
DepartureTime string `json:"departureTime"`
|
||||
ArrivalAirport string `json:"arrivalAirport"`
|
||||
ArrivalCity string `json:"arrivalCity"`
|
||||
ArrivalTime string `json:"arrivalTime"`
|
||||
Duration int `json:"duration"`
|
||||
Stops int `json:"stops"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
BookingURL string `json:"bookingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawFlights); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options := make([]TransportOption, 0, len(rawFlights))
|
||||
for _, f := range rawFlights {
|
||||
options = append(options, TransportOption{
|
||||
ID: f.ID,
|
||||
Mode: "flight",
|
||||
From: f.DepartureCity,
|
||||
To: f.ArrivalCity,
|
||||
Departure: f.DepartureTime,
|
||||
Arrival: f.ArrivalTime,
|
||||
DurationMin: f.Duration,
|
||||
Price: f.Price,
|
||||
Currency: f.Currency,
|
||||
Provider: f.Airline,
|
||||
BookingURL: f.BookingURL,
|
||||
Airline: f.Airline,
|
||||
FlightNum: f.FlightNumber,
|
||||
Stops: f.Stops,
|
||||
})
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, checkIn, checkOut string, adults int) ([]HotelCard, error) {
|
||||
body := map[string]interface{}{
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": 10,
|
||||
"checkIn": checkIn,
|
||||
"checkOut": checkOut,
|
||||
"adults": adults,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/hotels", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hotels search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("hotels search returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rawHotels []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount"`
|
||||
Stars int `json:"stars"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
Currency string `json:"currency"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
Amenities []string `json:"amenities"`
|
||||
Photos []string `json:"photos"`
|
||||
BookingURL string `json:"bookingUrl"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rawHotels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cards := make([]HotelCard, 0, len(rawHotels))
|
||||
for _, h := range rawHotels {
|
||||
cards = append(cards, HotelCard{
|
||||
ID: h.ID,
|
||||
Name: h.Name,
|
||||
Stars: h.Stars,
|
||||
Rating: h.Rating,
|
||||
ReviewCount: h.ReviewCount,
|
||||
PricePerNight: h.PricePerNight,
|
||||
TotalPrice: h.Price,
|
||||
Currency: h.Currency,
|
||||
Address: h.Address,
|
||||
Lat: h.Lat,
|
||||
Lng: h.Lng,
|
||||
BookingURL: h.BookingURL,
|
||||
Photos: h.Photos,
|
||||
Amenities: h.Amenities,
|
||||
CheckIn: h.CheckIn,
|
||||
CheckOut: h.CheckOut,
|
||||
})
|
||||
}
|
||||
return cards, nil
|
||||
}
|
||||
|
||||
// PlaceResult represents a place from 2GIS Places API.
|
||||
type PlaceResult struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {
|
||||
body := map[string]interface{}{
|
||||
"query": query,
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"radius": radius,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/places", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.doWithRetry(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("places search failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("places search returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var places []PlaceResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&places); err != nil {
|
||||
return nil, fmt.Errorf("places decode error: %w", err)
|
||||
}
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func (c *TravelDataClient) GetRoute(ctx context.Context, points []MapPoint, transport string) (*RouteDirectionResult, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("need at least 2 points for route")
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "driving"
|
||||
}
|
||||
|
||||
coords := make([]map[string]float64, len(points))
|
||||
for i, p := range points {
|
||||
coords[i] = map[string]float64{"lat": p.Lat, "lng": p.Lng}
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"points": coords,
|
||||
"profile": transport,
|
||||
}
|
||||
jsonBody, _ := json.Marshal(body)
|
||||
u := fmt.Sprintf("%s/api/v1/travel/route", c.baseURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("route request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("route returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
var result RouteDirectionResult
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetRouteSegments builds routes between each consecutive pair of points.
|
||||
func (c *TravelDataClient) GetRouteSegments(ctx context.Context, points []MapPoint, transport string) ([]RouteSegmentResult, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
segments := make([]RouteSegmentResult, 0, len(points)-1)
|
||||
for i := 0; i < len(points)-1; i++ {
|
||||
pair := []MapPoint{points[i], points[i+1]}
|
||||
dir, err := c.GetRoute(ctx, pair, transport)
|
||||
if err != nil {
|
||||
segments = append(segments, RouteSegmentResult{
|
||||
FromName: points[i].Label,
|
||||
ToName: points[i+1].Label,
|
||||
})
|
||||
continue
|
||||
}
|
||||
segments = append(segments, RouteSegmentResult{
|
||||
FromName: points[i].Label,
|
||||
ToName: points[i+1].Label,
|
||||
Distance: dir.Distance,
|
||||
Duration: dir.Duration,
|
||||
Geometry: dir.Geometry,
|
||||
})
|
||||
}
|
||||
return segments, nil
|
||||
}
|
||||
|
||||
type GeoResult struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
type RouteDirectionResult struct {
|
||||
Geometry RouteGeometryResult `json:"geometry"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []RouteStepResult `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
type RouteGeometryResult struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteStepResult struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteSegmentResult struct {
|
||||
FromName string `json:"from"`
|
||||
ToName string `json:"to"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Geometry RouteGeometryResult `json:"geometry,omitempty"`
|
||||
}
|
||||
467
backend/internal/agent/travel_events_collector.go
Normal file
467
backend/internal/agent/travel_events_collector.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CollectEventsEnriched collects real upcoming events/activities for the destination.
|
||||
// Pipeline: SearXNG (event-focused queries) -> Crawl4AI -> LLM extraction -> geocode.
|
||||
// Only returns actual events (concerts, exhibitions, festivals, etc.), NOT news articles.
|
||||
func CollectEventsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) ([]EventCard, error) {
|
||||
if cfg.SearchClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawResults := searchForEvents(ctx, cfg.SearchClient, brief)
|
||||
if len(rawResults) == 0 {
|
||||
log.Printf("[travel-events] no search results found")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
log.Printf("[travel-events] found %d raw search results", len(rawResults))
|
||||
|
||||
var crawledContent []crawledPage
|
||||
if cfg.Crawl4AIURL != "" {
|
||||
crawledContent = crawlEventPages(ctx, cfg.Crawl4AIURL, rawResults)
|
||||
}
|
||||
|
||||
events := extractEventsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
|
||||
|
||||
events = geocodeEvents(ctx, cfg, events)
|
||||
|
||||
events = deduplicateEvents(events)
|
||||
|
||||
events = filterFreshEvents(events, brief.StartDate)
|
||||
|
||||
if len(events) > 15 {
|
||||
events = events[:15]
|
||||
}
|
||||
|
||||
log.Printf("[travel-events] returning %d events", len(events))
|
||||
return events, nil
|
||||
}
|
||||
|
||||
type crawledPage struct {
|
||||
URL string
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
type eventSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
PublishedDate string
|
||||
Engine string
|
||||
}
|
||||
|
||||
func searchForEvents(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []eventSearchResult {
|
||||
var results []eventSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
queries := generateEventQueries(dest, brief.StartDate, brief.EndDate)
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] search error for '%s': %v", q, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, r := range resp.Results {
|
||||
if r.URL == "" || seen[r.URL] {
|
||||
continue
|
||||
}
|
||||
if isNewsArticleURL(r.URL) || isOldContent(r.PublishedDate) {
|
||||
continue
|
||||
}
|
||||
seen[r.URL] = true
|
||||
results = append(results, eventSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
PublishedDate: r.PublishedDate,
|
||||
Engine: r.Engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func generateEventQueries(destination, startDate, endDate string) []string {
|
||||
month := ""
|
||||
year := ""
|
||||
if len(startDate) >= 7 {
|
||||
parts := strings.Split(startDate, "-")
|
||||
if len(parts) >= 2 {
|
||||
year = parts[0]
|
||||
monthNum := parts[1]
|
||||
monthNames := map[string]string{
|
||||
"01": "январь", "02": "февраль", "03": "март",
|
||||
"04": "апрель", "05": "май", "06": "июнь",
|
||||
"07": "июль", "08": "август", "09": "сентябрь",
|
||||
"10": "октябрь", "11": "ноябрь", "12": "декабрь",
|
||||
}
|
||||
month = monthNames[monthNum]
|
||||
}
|
||||
}
|
||||
if year == "" {
|
||||
year = time.Now().Format("2006")
|
||||
}
|
||||
if month == "" {
|
||||
monthNames := []string{"", "январь", "февраль", "март", "апрель", "май", "июнь",
|
||||
"июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"}
|
||||
month = monthNames[time.Now().Month()]
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
fmt.Sprintf("афиша %s %s %s концерты выставки", destination, month, year),
|
||||
fmt.Sprintf("мероприятия %s %s %s расписание", destination, month, year),
|
||||
fmt.Sprintf("куда сходить %s %s %s", destination, month, year),
|
||||
fmt.Sprintf("site:afisha.ru %s %s", destination, month),
|
||||
fmt.Sprintf("site:kassir.ru %s %s %s", destination, month, year),
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func isNewsArticleURL(u string) bool {
|
||||
newsPatterns := []string{
|
||||
"/news/", "/novosti/", "/article/", "/stati/",
|
||||
"ria.ru", "tass.ru", "rbc.ru", "lenta.ru", "gazeta.ru",
|
||||
"interfax.ru", "kommersant.ru", "iz.ru", "mk.ru",
|
||||
"regnum.ru", "aif.ru", "kp.ru",
|
||||
}
|
||||
lower := strings.ToLower(u)
|
||||
for _, p := range newsPatterns {
|
||||
if strings.Contains(lower, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isOldContent(publishedDate string) bool {
|
||||
if publishedDate == "" {
|
||||
return false
|
||||
}
|
||||
formats := []string{
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02",
|
||||
"02.01.2006",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if t, err := time.Parse(f, publishedDate); err == nil {
|
||||
sixMonthsAgo := time.Now().AddDate(0, -6, 0)
|
||||
return t.Before(sixMonthsAgo)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterFreshEvents(events []EventCard, tripStartDate string) []EventCard {
|
||||
if tripStartDate == "" {
|
||||
return events
|
||||
}
|
||||
tripStart, err := time.Parse("2006-01-02", tripStartDate)
|
||||
if err != nil {
|
||||
return events
|
||||
}
|
||||
|
||||
cutoff := tripStart.AddDate(0, -1, 0)
|
||||
var fresh []EventCard
|
||||
for _, e := range events {
|
||||
if e.DateEnd != "" {
|
||||
if endDate, err := time.Parse("2006-01-02", e.DateEnd); err == nil {
|
||||
if endDate.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.DateStart != "" {
|
||||
if startDate, err := time.Parse("2006-01-02", e.DateStart); err == nil {
|
||||
twoMonthsAfterTrip := tripStart.AddDate(0, 2, 0)
|
||||
if startDate.After(twoMonthsAfterTrip) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
fresh = append(fresh, e)
|
||||
}
|
||||
return fresh
|
||||
}
|
||||
|
||||
func crawlEventPages(ctx context.Context, crawl4aiURL string, results []eventSearchResult) []crawledPage {
|
||||
maxCrawl := 4
|
||||
if len(results) < maxCrawl {
|
||||
maxCrawl = len(results)
|
||||
}
|
||||
|
||||
var pages []crawledPage
|
||||
|
||||
for _, r := range results[:maxCrawl] {
|
||||
crawlCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
page, err := crawlSinglePage(crawlCtx, crawl4aiURL, r.URL)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] crawl failed for %s: %v", r.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if page != nil && len(page.Content) > 100 {
|
||||
pages = append(pages, *page)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
func crawlSinglePage(ctx context.Context, crawl4aiURL, pageURL string) (*crawledPage, error) {
|
||||
reqBody := fmt.Sprintf(`{
|
||||
"urls": ["%s"],
|
||||
"crawler_config": {
|
||||
"type": "CrawlerRunConfig",
|
||||
"params": {
|
||||
"cache_mode": "default",
|
||||
"page_timeout": 15000
|
||||
}
|
||||
}
|
||||
}`, pageURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", crawl4aiURL+"/crawl", strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("crawl4ai returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content := extractCrawledMarkdown(string(body))
|
||||
title := extractCrawledTitle(string(body))
|
||||
|
||||
if len(content) > 10000 {
|
||||
content = content[:10000]
|
||||
}
|
||||
|
||||
return &crawledPage{
|
||||
URL: pageURL,
|
||||
Title: title,
|
||||
Content: content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractCrawledMarkdown(response string) string {
|
||||
var result struct {
|
||||
Results []struct {
|
||||
RawMarkdown string `json:"raw_markdown"`
|
||||
Markdown string `json:"markdown"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(response), &result); err == nil && len(result.Results) > 0 {
|
||||
if result.Results[0].RawMarkdown != "" {
|
||||
return result.Results[0].RawMarkdown
|
||||
}
|
||||
return result.Results[0].Markdown
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractCrawledTitle(response string) string {
|
||||
var result struct {
|
||||
Results []struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(response), &result); err == nil && len(result.Results) > 0 {
|
||||
return result.Results[0].Title
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractEventsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []eventSearchResult, crawled []crawledPage) []EventCard {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
contextBuilder.WriteString("Данные об афише и мероприятиях:\n\n")
|
||||
maxSearch := 10
|
||||
if len(searchResults) < maxSearch {
|
||||
maxSearch = len(searchResults)
|
||||
}
|
||||
for i := 0; i < maxSearch; i++ {
|
||||
r := searchResults[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\nURL: %s\n%s\n\n", r.Title, r.URL, truncateStr(r.Content, 300)))
|
||||
}
|
||||
|
||||
if len(crawled) > 0 {
|
||||
contextBuilder.WriteString("\nПодробности со страниц:\n\n")
|
||||
maxCrawled := 3
|
||||
if len(crawled) < maxCrawled {
|
||||
maxCrawled = len(crawled)
|
||||
}
|
||||
for i := 0; i < maxCrawled; i++ {
|
||||
p := crawled[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s (%s)\n%s\n\n", p.Title, p.URL, truncateStr(p.Content, 2000)))
|
||||
}
|
||||
}
|
||||
|
||||
currentYear := time.Now().Format("2006")
|
||||
|
||||
prompt := fmt.Sprintf(`Извлеки ТОЛЬКО реальные МЕРОПРИЯТИЯ (концерты, выставки, фестивали, спектакли, спортивные события) в %s на %s — %s.
|
||||
|
||||
%s
|
||||
|
||||
СТРОГО ЗАПРЕЩЕНО:
|
||||
- Новостные статьи, обзоры, блог-посты — это НЕ мероприятия
|
||||
- Устаревшие события (до %s года)
|
||||
- Выдуманные мероприятия
|
||||
|
||||
JSON (ТОЛЬКО массив, без текста):
|
||||
[{"id":"evt-1","title":"Название","description":"Что за мероприятие, 1 предложение","dateStart":"YYYY-MM-DD","dateEnd":"YYYY-MM-DD","price":500,"currency":"RUB","url":"https://...","address":"Город, Площадка, адрес","tags":["концерт"]}]
|
||||
|
||||
Правила:
|
||||
- ТОЛЬКО конкретные мероприятия с названием, местом и датой
|
||||
- dateStart/dateEnd в формате YYYY-MM-DD, если дата неизвестна — ""
|
||||
- price в рублях, 0 если неизвестна
|
||||
- address — точный адрес площадки для геокодинга
|
||||
- tags: концерт, выставка, фестиваль, спектакль, спорт, кино, мастер-класс, экскурсия
|
||||
- Максимум 10 мероприятий`,
|
||||
strings.Join(brief.Destinations, ", "),
|
||||
brief.StartDate,
|
||||
brief.EndDate,
|
||||
contextBuilder.String(),
|
||||
currentYear,
|
||||
)
|
||||
|
||||
llmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 3000, Temperature: 0.1},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] LLM extraction failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
log.Printf("[travel-events] no JSON array in LLM response (len=%d)", len(response))
|
||||
return nil
|
||||
}
|
||||
|
||||
var events []EventCard
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &events); err != nil {
|
||||
log.Printf("[travel-events] JSON parse error: %v", err)
|
||||
events = tryPartialEventParse(jsonMatch)
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
for i := range events {
|
||||
if events[i].ID == "" {
|
||||
events[i].ID = uuid.New().String()
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[travel-events] extracted %d events from LLM", len(events))
|
||||
return events
|
||||
}
|
||||
|
||||
func tryPartialEventParse(jsonStr string) []EventCard {
|
||||
var events []EventCard
|
||||
objRegex := regexp.MustCompile(`\{[^{}]*"title"\s*:\s*"[^"]+[^{}]*\}`)
|
||||
matches := objRegex.FindAllString(jsonStr, -1)
|
||||
for _, m := range matches {
|
||||
var e EventCard
|
||||
if err := json.Unmarshal([]byte(m), &e); err == nil && e.Title != "" {
|
||||
events = append(events, e)
|
||||
}
|
||||
}
|
||||
if len(events) > 0 {
|
||||
log.Printf("[travel-events] partial parse recovered %d events", len(events))
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func geocodeEvents(ctx context.Context, cfg TravelOrchestratorConfig, events []EventCard) []EventCard {
|
||||
for i := range events {
|
||||
if events[i].Address == "" || (events[i].Lat != 0 && events[i].Lng != 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
geo, err := cfg.TravelData.Geocode(geoCtx, events[i].Address)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-events] geocode failed for '%s': %v", events[i].Address, err)
|
||||
continue
|
||||
}
|
||||
|
||||
events[i].Lat = geo.Lat
|
||||
events[i].Lng = geo.Lng
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
func deduplicateEvents(events []EventCard) []EventCard {
|
||||
seen := make(map[string]bool)
|
||||
var unique []EventCard
|
||||
|
||||
for _, e := range events {
|
||||
key := strings.ToLower(e.Title)
|
||||
if len(key) > 50 {
|
||||
key = key[:50]
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
unique = append(unique, e)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
266
backend/internal/agent/travel_flights_collector.go
Normal file
266
backend/internal/agent/travel_flights_collector.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
iataCities map[string]string // lowercase city name -> IATA code
|
||||
iataCitiesMu sync.RWMutex
|
||||
iataLoaded bool
|
||||
)
|
||||
|
||||
type tpCityEntry struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
Coordinates struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
} `json:"coordinates"`
|
||||
}
|
||||
|
||||
func loadIATACities(ctx context.Context, token string) error {
|
||||
iataCitiesMu.Lock()
|
||||
defer iataCitiesMu.Unlock()
|
||||
|
||||
if iataLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("https://api.travelpayouts.com/data/ru/cities.json?token=%s", url.QueryEscape(token))
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch cities.json: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("cities.json returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cities.json: %w", err)
|
||||
}
|
||||
|
||||
var cities []tpCityEntry
|
||||
if err := json.Unmarshal(body, &cities); err != nil {
|
||||
return fmt.Errorf("failed to parse cities.json: %w", err)
|
||||
}
|
||||
|
||||
iataCities = make(map[string]string, len(cities))
|
||||
for _, c := range cities {
|
||||
if c.Name != "" && c.Code != "" {
|
||||
iataCities[strings.ToLower(c.Name)] = c.Code
|
||||
}
|
||||
}
|
||||
|
||||
iataLoaded = true
|
||||
log.Printf("[travel-flights] loaded %d IATA city codes", len(iataCities))
|
||||
return nil
|
||||
}
|
||||
|
||||
func cityToIATA(city string) string {
|
||||
iataCitiesMu.RLock()
|
||||
defer iataCitiesMu.RUnlock()
|
||||
|
||||
normalized := strings.ToLower(strings.TrimSpace(city))
|
||||
if code, ok := iataCities[normalized]; ok {
|
||||
return code
|
||||
}
|
||||
|
||||
aliases := map[string]string{
|
||||
"питер": "LED",
|
||||
"спб": "LED",
|
||||
"петербург": "LED",
|
||||
"мск": "MOW",
|
||||
"нск": "OVB",
|
||||
"новосиб": "OVB",
|
||||
"нижний": "GOJ",
|
||||
"екб": "SVX",
|
||||
"ростов": "ROV",
|
||||
"ростов-на-дону": "ROV",
|
||||
"красноярск": "KJA",
|
||||
"владивосток": "VVO",
|
||||
"калининград": "KGD",
|
||||
"сочи": "AER",
|
||||
"адлер": "AER",
|
||||
"симферополь": "SIP",
|
||||
"крым": "SIP",
|
||||
}
|
||||
if code, ok := aliases[normalized]; ok {
|
||||
return code
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type tpFlightResponse struct {
|
||||
Data []tpFlightData `json:"data"`
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type tpFlightData struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
OriginAirport string `json:"origin_airport"`
|
||||
DestAirport string `json:"destination_airport"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
DepartureAt string `json:"departure_at"`
|
||||
ReturnAt string `json:"return_at"`
|
||||
Transfers int `json:"transfers"`
|
||||
ReturnTransfers int `json:"return_transfers"`
|
||||
Duration int `json:"duration"`
|
||||
DurationTo int `json:"duration_to"`
|
||||
DurationBack int `json:"duration_back"`
|
||||
Link string `json:"link"`
|
||||
Gate string `json:"gate"`
|
||||
}
|
||||
|
||||
// CollectFlightsFromTP searches TravelPayouts for flights between origin and destinations.
|
||||
func CollectFlightsFromTP(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) ([]TransportOption, error) {
|
||||
if cfg.TravelPayoutsToken == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if brief.Origin == "" || len(brief.Destinations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := loadIATACities(ctx, cfg.TravelPayoutsToken); err != nil {
|
||||
log.Printf("[travel-flights] failed to load IATA cities: %v", err)
|
||||
}
|
||||
|
||||
originIATA := cityToIATA(brief.Origin)
|
||||
if originIATA == "" {
|
||||
log.Printf("[travel-flights] unknown IATA for origin '%s'", brief.Origin)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var allTransport []TransportOption
|
||||
|
||||
passengers := brief.Travelers
|
||||
if passengers < 1 {
|
||||
passengers = 1
|
||||
}
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
destIATA := cityToIATA(dest)
|
||||
if destIATA == "" {
|
||||
log.Printf("[travel-flights] unknown IATA for destination '%s'", dest)
|
||||
continue
|
||||
}
|
||||
|
||||
flights, err := searchTPFlights(ctx, cfg.TravelPayoutsToken, cfg.TravelPayoutsMarker, originIATA, destIATA, brief.StartDate, brief.EndDate)
|
||||
if err != nil {
|
||||
log.Printf("[travel-flights] search failed %s->%s: %v", originIATA, destIATA, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range flights {
|
||||
allTransport = append(allTransport, TransportOption{
|
||||
ID: uuid.New().String(),
|
||||
Mode: "flight",
|
||||
From: brief.Origin,
|
||||
To: dest,
|
||||
Departure: f.DepartureAt,
|
||||
Arrival: "",
|
||||
DurationMin: f.DurationTo,
|
||||
PricePerUnit: f.Price,
|
||||
Passengers: passengers,
|
||||
Price: f.Price * float64(passengers),
|
||||
Currency: "RUB",
|
||||
Provider: f.Gate,
|
||||
BookingURL: buildTPBookingURL(f.Link, cfg.TravelPayoutsMarker),
|
||||
Airline: f.Airline,
|
||||
FlightNum: f.FlightNumber,
|
||||
Stops: f.Transfers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(allTransport) > 10 {
|
||||
allTransport = allTransport[:10]
|
||||
}
|
||||
|
||||
return allTransport, nil
|
||||
}
|
||||
|
||||
func searchTPFlights(ctx context.Context, token, marker, origin, destination, departDate, returnDate string) ([]tpFlightData, error) {
|
||||
params := url.Values{
|
||||
"origin": {origin},
|
||||
"destination": {destination},
|
||||
"currency": {"rub"},
|
||||
"sorting": {"price"},
|
||||
"limit": {"5"},
|
||||
"token": {token},
|
||||
}
|
||||
|
||||
if departDate != "" {
|
||||
params.Set("departure_at", departDate)
|
||||
}
|
||||
if returnDate != "" && returnDate != departDate {
|
||||
params.Set("return_at", returnDate)
|
||||
}
|
||||
|
||||
u := "https://api.travelpayouts.com/aviasales/v3/prices_for_dates?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("TP flights request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("TP flights returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result tpFlightResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("TP flights decode error: %w", err)
|
||||
}
|
||||
|
||||
return result.Data, nil
|
||||
}
|
||||
|
||||
func buildTPBookingURL(link, marker string) string {
|
||||
if link == "" {
|
||||
return ""
|
||||
}
|
||||
base := "https://www.aviasales.ru" + link
|
||||
if marker != "" {
|
||||
if strings.Contains(base, "?") {
|
||||
base += "&marker=" + marker
|
||||
} else {
|
||||
base += "?marker=" + marker
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
420
backend/internal/agent/travel_hotels_collector.go
Normal file
420
backend/internal/agent/travel_hotels_collector.go
Normal file
@@ -0,0 +1,420 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CollectHotelsEnriched searches for hotels via SearXNG + Crawl4AI + LLM.
|
||||
func CollectHotelsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, destinations []destGeoEntry) ([]HotelCard, error) {
|
||||
if cfg.SearchClient == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawResults := searchForHotels(ctx, cfg.SearchClient, brief)
|
||||
if len(rawResults) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var crawledContent []crawledPage
|
||||
if cfg.Crawl4AIURL != "" {
|
||||
crawledContent = crawlHotelPages(ctx, cfg.Crawl4AIURL, rawResults)
|
||||
}
|
||||
|
||||
hotels := extractHotelsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
|
||||
|
||||
hotels = geocodeHotels(ctx, cfg, hotels)
|
||||
|
||||
hotels = deduplicateHotels(hotels)
|
||||
|
||||
if len(hotels) > 10 {
|
||||
hotels = hotels[:10]
|
||||
}
|
||||
|
||||
return hotels, nil
|
||||
}
|
||||
|
||||
type hotelSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
Engine string
|
||||
}
|
||||
|
||||
func searchForHotels(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []hotelSearchResult {
|
||||
var results []hotelSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
queries := generateHotelQueries(dest, brief.StartDate, brief.EndDate, brief.Travelers, brief.TravelStyle)
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] 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, hotelSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
Engine: r.Engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func generateHotelQueries(destination, startDate, endDate string, travelers int, style string) []string {
|
||||
dateStr := ""
|
||||
if startDate != "" {
|
||||
dateStr = startDate
|
||||
if endDate != "" && endDate != startDate {
|
||||
dateStr += " " + endDate
|
||||
}
|
||||
}
|
||||
|
||||
familyStr := ""
|
||||
if travelers >= 3 {
|
||||
familyStr = "семейный "
|
||||
}
|
||||
|
||||
queries := []string{
|
||||
fmt.Sprintf("%sотели %s цены бронирование %s", familyStr, destination, dateStr),
|
||||
fmt.Sprintf("гостиницы %s рейтинг отзывы %d гостей", destination, travelers),
|
||||
fmt.Sprintf("лучшие отели %s для туристов", destination),
|
||||
}
|
||||
|
||||
if travelers >= 3 {
|
||||
queries = append(queries, fmt.Sprintf("семейные отели %s с детьми", destination))
|
||||
}
|
||||
|
||||
if style == "luxury" {
|
||||
queries = append(queries, fmt.Sprintf("5 звезд отели %s премиум", destination))
|
||||
} else if style == "budget" {
|
||||
queries = append(queries, fmt.Sprintf("хостелы %s дешево %d человек", destination, travelers))
|
||||
} else {
|
||||
queries = append(queries, fmt.Sprintf("где остановиться %s %d человек", destination, travelers))
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func crawlHotelPages(ctx context.Context, crawl4aiURL string, results []hotelSearchResult) []crawledPage {
|
||||
maxCrawl := 5
|
||||
if len(results) < maxCrawl {
|
||||
maxCrawl = len(results)
|
||||
}
|
||||
|
||||
var pages []crawledPage
|
||||
|
||||
for _, r := range results[:maxCrawl] {
|
||||
crawlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
page, err := crawlSinglePage(crawlCtx, crawl4aiURL, r.URL)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] crawl failed for %s: %v", r.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if page != nil && len(page.Content) > 100 {
|
||||
pages = append(pages, *page)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
func extractHotelsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []hotelSearchResult, crawled []crawledPage) []HotelCard {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
contextBuilder.WriteString("Результаты поиска отелей:\n\n")
|
||||
maxSearch := 10
|
||||
if len(searchResults) < maxSearch {
|
||||
maxSearch = len(searchResults)
|
||||
}
|
||||
for i := 0; i < maxSearch; i++ {
|
||||
r := searchResults[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\nURL: %s\n%s\n\n", r.Title, r.URL, truncateStr(r.Content, 300)))
|
||||
}
|
||||
|
||||
if len(crawled) > 0 {
|
||||
contextBuilder.WriteString("\nПодробное содержание:\n\n")
|
||||
maxCrawled := 3
|
||||
if len(crawled) < maxCrawled {
|
||||
maxCrawled = len(crawled)
|
||||
}
|
||||
for i := 0; i < maxCrawled; i++ {
|
||||
p := crawled[i]
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s (%s)\n%s\n\n", p.Title, p.URL, truncateStr(p.Content, 2000)))
|
||||
}
|
||||
}
|
||||
|
||||
nightsStr := "1 ночь"
|
||||
nightsCount := calculateNights(brief.StartDate, brief.EndDate)
|
||||
if brief.StartDate != "" && brief.EndDate != "" && brief.EndDate != brief.StartDate {
|
||||
nightsStr = fmt.Sprintf("с %s по %s (%d ночей)", brief.StartDate, brief.EndDate, nightsCount)
|
||||
}
|
||||
|
||||
travelers := brief.Travelers
|
||||
if travelers < 1 {
|
||||
travelers = 1
|
||||
}
|
||||
rooms := calculateRooms(travelers)
|
||||
|
||||
prompt := fmt.Sprintf(`Извлеки до 6 отелей в %s на %s для %d чел (%d номеров).
|
||||
|
||||
%s
|
||||
|
||||
JSON массив (ТОЛЬКО JSON, без текста):
|
||||
[{"id":"hotel-1","name":"Название","stars":3,"rating":8.5,"reviewCount":120,"pricePerNight":3500,"totalPrice":0,"currency":"RUB","address":"Город, ул. Улица, д. 1","bookingUrl":"https://...","amenities":["Wi-Fi","Завтрак"],"pros":["Центр города"],"checkIn":"%s","checkOut":"%s"}]
|
||||
|
||||
Правила:
|
||||
- ТОЛЬКО реальные отели из текста
|
||||
- pricePerNight — за 1 номер за 1 ночь в рублях. Если не указана — оцени по звёздам: 1★=1500, 2★=2500, 3★=3500, 4★=5000, 5★=8000
|
||||
- totalPrice=0 (рассчитается автоматически)
|
||||
- Адрес с городом для геокодинга
|
||||
- Максимум 6 отелей, компактный JSON`,
|
||||
strings.Join(brief.Destinations, ", "),
|
||||
nightsStr,
|
||||
travelers,
|
||||
rooms,
|
||||
contextBuilder.String(),
|
||||
brief.StartDate,
|
||||
brief.EndDate,
|
||||
)
|
||||
|
||||
llmCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 3000, Temperature: 0.1},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] LLM extraction failed: %v", err)
|
||||
return fallbackHotelsFromSearch(searchResults, brief)
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
log.Printf("[travel-hotels] no JSON array found in LLM response (len=%d)", len(response))
|
||||
return fallbackHotelsFromSearch(searchResults, brief)
|
||||
}
|
||||
|
||||
var hotels []HotelCard
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &hotels); err != nil {
|
||||
log.Printf("[travel-hotels] JSON parse error: %v, response len=%d", err, len(jsonMatch))
|
||||
hotels = tryPartialHotelParse(jsonMatch)
|
||||
if len(hotels) == 0 {
|
||||
return fallbackHotelsFromSearch(searchResults, brief)
|
||||
}
|
||||
}
|
||||
|
||||
nights := nightsCount
|
||||
guestRooms := rooms
|
||||
guests := travelers
|
||||
|
||||
for i := range hotels {
|
||||
if hotels[i].ID == "" {
|
||||
hotels[i].ID = uuid.New().String()
|
||||
}
|
||||
if hotels[i].CheckIn == "" {
|
||||
hotels[i].CheckIn = brief.StartDate
|
||||
}
|
||||
if hotels[i].CheckOut == "" {
|
||||
hotels[i].CheckOut = brief.EndDate
|
||||
}
|
||||
hotels[i].Nights = nights
|
||||
hotels[i].Rooms = guestRooms
|
||||
hotels[i].Guests = guests
|
||||
|
||||
if hotels[i].PricePerNight > 0 && hotels[i].TotalPrice == 0 {
|
||||
hotels[i].TotalPrice = hotels[i].PricePerNight * float64(nights) * float64(guestRooms)
|
||||
}
|
||||
if hotels[i].TotalPrice > 0 && hotels[i].PricePerNight == 0 && nights > 0 && guestRooms > 0 {
|
||||
hotels[i].PricePerNight = hotels[i].TotalPrice / float64(nights) / float64(guestRooms)
|
||||
}
|
||||
if hotels[i].PricePerNight == 0 {
|
||||
hotels[i].PricePerNight = estimatePriceByStars(hotels[i].Stars)
|
||||
hotels[i].TotalPrice = hotels[i].PricePerNight * float64(nights) * float64(guestRooms)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[travel-hotels] extracted %d hotels from LLM", len(hotels))
|
||||
return hotels
|
||||
}
|
||||
|
||||
func tryPartialHotelParse(jsonStr string) []HotelCard {
|
||||
var hotels []HotelCard
|
||||
objRegex := regexp.MustCompile(`\{[^{}]*"name"\s*:\s*"[^"]+[^{}]*\}`)
|
||||
matches := objRegex.FindAllString(jsonStr, -1)
|
||||
for _, m := range matches {
|
||||
var h HotelCard
|
||||
if err := json.Unmarshal([]byte(m), &h); err == nil && h.Name != "" {
|
||||
hotels = append(hotels, h)
|
||||
}
|
||||
}
|
||||
if len(hotels) > 0 {
|
||||
log.Printf("[travel-hotels] partial parse recovered %d hotels", len(hotels))
|
||||
}
|
||||
return hotels
|
||||
}
|
||||
|
||||
func estimatePriceByStars(stars int) float64 {
|
||||
switch {
|
||||
case stars >= 5:
|
||||
return 8000
|
||||
case stars == 4:
|
||||
return 5000
|
||||
case stars == 3:
|
||||
return 3500
|
||||
case stars == 2:
|
||||
return 2500
|
||||
default:
|
||||
return 2000
|
||||
}
|
||||
}
|
||||
|
||||
func calculateNights(startDate, endDate string) int {
|
||||
if startDate == "" || endDate == "" {
|
||||
return 1
|
||||
}
|
||||
start, err1 := time.Parse("2006-01-02", startDate)
|
||||
end, err2 := time.Parse("2006-01-02", endDate)
|
||||
if err1 != nil || err2 != nil {
|
||||
return 1
|
||||
}
|
||||
nights := int(end.Sub(start).Hours() / 24)
|
||||
if nights < 1 {
|
||||
return 1
|
||||
}
|
||||
return nights
|
||||
}
|
||||
|
||||
func calculateRooms(travelers int) int {
|
||||
if travelers <= 2 {
|
||||
return 1
|
||||
}
|
||||
return (travelers + 1) / 2
|
||||
}
|
||||
|
||||
func fallbackHotelsFromSearch(results []hotelSearchResult, brief *TripBrief) []HotelCard {
|
||||
hotels := make([]HotelCard, 0, len(results))
|
||||
nights := calculateNights(brief.StartDate, brief.EndDate)
|
||||
travelers := brief.Travelers
|
||||
if travelers < 1 {
|
||||
travelers = 1
|
||||
}
|
||||
rooms := calculateRooms(travelers)
|
||||
|
||||
for _, r := range results {
|
||||
if len(hotels) >= 5 {
|
||||
break
|
||||
}
|
||||
name := r.Title
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
|
||||
price := extractPriceFromSnippet(r.Content)
|
||||
if price == 0 {
|
||||
price = 3000
|
||||
}
|
||||
|
||||
hotels = append(hotels, HotelCard{
|
||||
ID: uuid.New().String(),
|
||||
Name: name,
|
||||
Stars: 3,
|
||||
PricePerNight: price,
|
||||
TotalPrice: price * float64(nights) * float64(rooms),
|
||||
Rooms: rooms,
|
||||
Nights: nights,
|
||||
Guests: travelers,
|
||||
Currency: "RUB",
|
||||
CheckIn: brief.StartDate,
|
||||
CheckOut: brief.EndDate,
|
||||
BookingURL: r.URL,
|
||||
})
|
||||
}
|
||||
log.Printf("[travel-hotels] fallback: %d hotels from search results", len(hotels))
|
||||
return hotels
|
||||
}
|
||||
|
||||
func extractPriceFromSnippet(text string) float64 {
|
||||
priceRegex := regexp.MustCompile(`(\d[\d\s]*\d)\s*(?:₽|руб|RUB|р\.)`)
|
||||
match := priceRegex.FindStringSubmatch(text)
|
||||
if len(match) >= 2 {
|
||||
numStr := strings.ReplaceAll(match[1], " ", "")
|
||||
var price float64
|
||||
if _, err := fmt.Sscanf(numStr, "%f", &price); err == nil && price > 100 && price < 500000 {
|
||||
return price
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func geocodeHotels(ctx context.Context, cfg TravelOrchestratorConfig, hotels []HotelCard) []HotelCard {
|
||||
for i := range hotels {
|
||||
if hotels[i].Address == "" || (hotels[i].Lat != 0 && hotels[i].Lng != 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
geo, err := cfg.TravelData.Geocode(geoCtx, hotels[i].Address)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-hotels] geocode failed for '%s': %v", hotels[i].Address, err)
|
||||
continue
|
||||
}
|
||||
|
||||
hotels[i].Lat = geo.Lat
|
||||
hotels[i].Lng = geo.Lng
|
||||
}
|
||||
|
||||
return hotels
|
||||
}
|
||||
|
||||
func deduplicateHotels(hotels []HotelCard) []HotelCard {
|
||||
seen := make(map[string]bool)
|
||||
var unique []HotelCard
|
||||
|
||||
for _, h := range hotels {
|
||||
key := strings.ToLower(h.Name)
|
||||
if len(key) > 50 {
|
||||
key = key[:50]
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
unique = append(unique, h)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
1148
backend/internal/agent/travel_orchestrator.go
Normal file
1148
backend/internal/agent/travel_orchestrator.go
Normal file
File diff suppressed because it is too large
Load Diff
694
backend/internal/agent/travel_poi_collector.go
Normal file
694
backend/internal/agent/travel_poi_collector.go
Normal file
@@ -0,0 +1,694 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
"github.com/gooseek/backend/internal/search"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// POI category queries for 2GIS Places API — concrete organization types
|
||||
var poiCategoryQueries = map[string][]string{
|
||||
"attraction": {
|
||||
"достопримечательности",
|
||||
"памятники",
|
||||
"исторические здания",
|
||||
"смотровые площадки",
|
||||
},
|
||||
"museum": {
|
||||
"музеи",
|
||||
"галереи",
|
||||
"выставки",
|
||||
},
|
||||
"park": {
|
||||
"парки",
|
||||
"скверы",
|
||||
"сады",
|
||||
"набережные",
|
||||
},
|
||||
"restaurant": {
|
||||
"рестораны",
|
||||
"кафе",
|
||||
},
|
||||
"theater": {
|
||||
"театры",
|
||||
"кинотеатры",
|
||||
"филармония",
|
||||
},
|
||||
"entertainment": {
|
||||
"развлечения",
|
||||
"аквапарки",
|
||||
"зоопарки",
|
||||
"аттракционы",
|
||||
"боулинг",
|
||||
},
|
||||
"shopping": {
|
||||
"торговые центры",
|
||||
"рынки",
|
||||
"сувениры",
|
||||
},
|
||||
"religious": {
|
||||
"храмы",
|
||||
"церкви",
|
||||
"соборы",
|
||||
"мечети",
|
||||
},
|
||||
}
|
||||
|
||||
// CollectPOIsEnriched collects POIs using 2GIS Places API as primary source,
|
||||
// then enriches with descriptions from SearXNG + LLM.
|
||||
func CollectPOIsEnriched(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, destinations []destGeoEntry) ([]POICard, error) {
|
||||
if cfg.TravelData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var allPOIs []POICard
|
||||
|
||||
// Phase 1: Collect concrete places from 2GIS for each destination
|
||||
for _, dest := range destinations {
|
||||
if dest.Lat == 0 && dest.Lng == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
categories := selectCategoriesForBrief(brief)
|
||||
places := searchPlacesFrom2GIS(ctx, cfg, dest, categories)
|
||||
allPOIs = append(allPOIs, places...)
|
||||
}
|
||||
|
||||
log.Printf("[travel-poi] 2GIS returned %d places total", len(allPOIs))
|
||||
|
||||
// Phase 2: If 2GIS returned too few, supplement with SearXNG + LLM extraction
|
||||
if len(allPOIs) < 5 && cfg.SearchClient != nil {
|
||||
log.Printf("[travel-poi] 2GIS returned only %d places, supplementing with SearXNG+LLM", len(allPOIs))
|
||||
supplementPOIs := collectPOIsFromSearch(ctx, cfg, brief, destinations)
|
||||
allPOIs = append(allPOIs, supplementPOIs...)
|
||||
}
|
||||
|
||||
// Phase 3: Enrich POIs with descriptions from SearXNG if available
|
||||
if cfg.SearchClient != nil && len(allPOIs) > 0 {
|
||||
allPOIs = enrichPOIDescriptions(ctx, cfg, brief, allPOIs)
|
||||
}
|
||||
|
||||
// Phase 3b: Fetch photos via SearXNG images
|
||||
if cfg.SearchClient != nil && len(allPOIs) > 0 {
|
||||
allPOIs = enrichPOIPhotos(ctx, cfg, brief, allPOIs)
|
||||
}
|
||||
|
||||
// Phase 4: Fallback geocoding for POIs without coordinates
|
||||
allPOIs = geocodePOIs(ctx, cfg, allPOIs)
|
||||
|
||||
allPOIs = deduplicatePOIs(allPOIs)
|
||||
|
||||
// Filter out POIs without coordinates — they can't be shown on map
|
||||
validPOIs := make([]POICard, 0, len(allPOIs))
|
||||
for _, p := range allPOIs {
|
||||
if p.Lat != 0 || p.Lng != 0 {
|
||||
validPOIs = append(validPOIs, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validPOIs) > 25 {
|
||||
validPOIs = validPOIs[:25]
|
||||
}
|
||||
|
||||
log.Printf("[travel-poi] returning %d POIs with coordinates", len(validPOIs))
|
||||
return validPOIs, nil
|
||||
}
|
||||
|
||||
// selectCategoriesForBrief picks relevant POI categories based on user interests.
|
||||
func selectCategoriesForBrief(brief *TripBrief) []string {
|
||||
if len(brief.Interests) == 0 {
|
||||
return []string{"attraction", "museum", "park", "restaurant", "theater", "entertainment"}
|
||||
}
|
||||
|
||||
interestMapping := map[string][]string{
|
||||
"культура": {"museum", "theater", "attraction", "religious"},
|
||||
"музеи": {"museum"},
|
||||
"еда": {"restaurant"},
|
||||
"рестораны": {"restaurant"},
|
||||
"природа": {"park"},
|
||||
"парки": {"park"},
|
||||
"развлечения": {"entertainment"},
|
||||
"шопинг": {"shopping"},
|
||||
"история": {"attraction", "museum", "religious"},
|
||||
"архитектура": {"attraction", "religious"},
|
||||
"дети": {"entertainment", "park"},
|
||||
"семья": {"entertainment", "park", "museum"},
|
||||
"семейный": {"entertainment", "park", "museum"},
|
||||
"активный отдых": {"entertainment", "park"},
|
||||
"религия": {"religious"},
|
||||
"театр": {"theater"},
|
||||
"искусство": {"museum", "theater"},
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var categories []string
|
||||
|
||||
for _, interest := range brief.Interests {
|
||||
lower := strings.ToLower(interest)
|
||||
for keyword, cats := range interestMapping {
|
||||
if strings.Contains(lower, keyword) {
|
||||
for _, c := range cats {
|
||||
if !seen[c] {
|
||||
seen[c] = true
|
||||
categories = append(categories, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(categories) == 0 {
|
||||
return []string{"attraction", "museum", "park", "restaurant", "theater", "entertainment"}
|
||||
}
|
||||
|
||||
// Always include attractions as baseline
|
||||
if !seen["attraction"] {
|
||||
categories = append(categories, "attraction")
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
|
||||
// searchPlacesFrom2GIS queries 2GIS Places API for concrete organizations.
|
||||
func searchPlacesFrom2GIS(ctx context.Context, cfg TravelOrchestratorConfig, dest destGeoEntry, categories []string) []POICard {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
pois []POICard
|
||||
wg sync.WaitGroup
|
||||
seen = make(map[string]bool)
|
||||
)
|
||||
|
||||
for _, category := range categories {
|
||||
queries, ok := poiCategoryQueries[category]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
wg.Add(1)
|
||||
go func(query, cat string) {
|
||||
defer wg.Done()
|
||||
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fullQuery := fmt.Sprintf("%s %s", query, dest.Name)
|
||||
places, err := cfg.TravelData.SearchPlaces(searchCtx, fullQuery, dest.Lat, dest.Lng, 10000)
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] 2GIS search error for '%s': %v", fullQuery, err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
for _, place := range places {
|
||||
if seen[place.ID] || seen[place.Name] {
|
||||
continue
|
||||
}
|
||||
seen[place.ID] = true
|
||||
seen[place.Name] = true
|
||||
|
||||
mappedCategory := mapPurposeToCategory(place.Purpose, place.Type, cat)
|
||||
|
||||
pois = append(pois, POICard{
|
||||
ID: place.ID,
|
||||
Name: place.Name,
|
||||
Category: mappedCategory,
|
||||
Address: fmt.Sprintf("%s, %s", dest.Name, place.Address),
|
||||
Lat: place.Lat,
|
||||
Lng: place.Lng,
|
||||
Rating: place.Rating,
|
||||
ReviewCount: place.ReviewCount,
|
||||
Schedule: place.Schedule,
|
||||
})
|
||||
}
|
||||
}(q, category)
|
||||
}
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return pois
|
||||
}
|
||||
|
||||
// mapPurposeToCategory maps 2GIS purpose/type to our POI category.
|
||||
func mapPurposeToCategory(purpose, itemType, fallbackCategory string) string {
|
||||
lower := strings.ToLower(purpose)
|
||||
|
||||
switch {
|
||||
case strings.Contains(lower, "музей") || strings.Contains(lower, "галере") || strings.Contains(lower, "выставк"):
|
||||
return "museum"
|
||||
case strings.Contains(lower, "ресторан") || strings.Contains(lower, "кафе") || strings.Contains(lower, "бар"):
|
||||
return "restaurant"
|
||||
case strings.Contains(lower, "парк") || strings.Contains(lower, "сквер") || strings.Contains(lower, "сад"):
|
||||
return "park"
|
||||
case strings.Contains(lower, "театр") || strings.Contains(lower, "кинотеатр") || strings.Contains(lower, "филармон"):
|
||||
return "theater"
|
||||
case strings.Contains(lower, "храм") || strings.Contains(lower, "церков") || strings.Contains(lower, "собор") || strings.Contains(lower, "мечет"):
|
||||
return "religious"
|
||||
case strings.Contains(lower, "торгов") || strings.Contains(lower, "магазин") || strings.Contains(lower, "рынок"):
|
||||
return "shopping"
|
||||
case strings.Contains(lower, "развлеч") || strings.Contains(lower, "аквапарк") || strings.Contains(lower, "зоопарк") || strings.Contains(lower, "аттракц"):
|
||||
return "entertainment"
|
||||
case strings.Contains(lower, "памятник") || strings.Contains(lower, "достоприм"):
|
||||
return "attraction"
|
||||
}
|
||||
|
||||
if itemType == "attraction" {
|
||||
return "attraction"
|
||||
}
|
||||
|
||||
return fallbackCategory
|
||||
}
|
||||
|
||||
// collectPOIsFromSearch is the SearXNG + LLM fallback when 2GIS returns too few results.
|
||||
func collectPOIsFromSearch(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, destinations []destGeoEntry) []POICard {
|
||||
if cfg.SearchClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawResults := searchForPOIs(ctx, cfg.SearchClient, brief)
|
||||
if len(rawResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var crawledContent []crawledPage
|
||||
if cfg.Crawl4AIURL != "" {
|
||||
crawledContent = crawlPOIPages(ctx, cfg.Crawl4AIURL, rawResults)
|
||||
}
|
||||
|
||||
pois := extractPOIsWithLLM(ctx, cfg.LLM, brief, rawResults, crawledContent)
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
// enrichPOIDescriptions adds descriptions to 2GIS POIs using SearXNG + LLM.
|
||||
func enrichPOIDescriptions(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, pois []POICard) []POICard {
|
||||
needsDescription := make([]int, 0)
|
||||
for i, p := range pois {
|
||||
if p.Description == "" {
|
||||
needsDescription = append(needsDescription, i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(needsDescription) == 0 {
|
||||
return pois
|
||||
}
|
||||
|
||||
// Build a list of POI names for bulk enrichment via LLM
|
||||
var names []string
|
||||
for _, idx := range needsDescription {
|
||||
names = append(names, pois[idx].Name)
|
||||
}
|
||||
|
||||
if len(names) > 15 {
|
||||
names = names[:15]
|
||||
}
|
||||
|
||||
dest := strings.Join(brief.Destinations, ", ")
|
||||
prompt := fmt.Sprintf(`Ты — эксперт по туризму в %s. Для каждого места из списка напиши краткое описание (1-2 предложения), примерное время посещения в минутах и примерную стоимость входа в рублях (0 если бесплатно).
|
||||
|
||||
Места:
|
||||
%s
|
||||
|
||||
Верни ТОЛЬКО JSON массив:
|
||||
[
|
||||
{
|
||||
"name": "Точное название из списка",
|
||||
"description": "Краткое описание",
|
||||
"duration": число_минут,
|
||||
"price": цена_в_рублях,
|
||||
"rating": рейтинг_от_0_до_5
|
||||
}
|
||||
]
|
||||
|
||||
Правила:
|
||||
- Описание должно быть информативным и привлекательным для туриста
|
||||
- duration: музей 60-120 мин, парк 30-90 мин, ресторан 60 мин, памятник 15-30 мин
|
||||
- price: 0 для открытых мест, реальные цены для музеев/театров
|
||||
- rating: если знаешь реальный рейтинг — используй, иначе оцени по популярности (3.5-5.0)
|
||||
- Верни ТОЛЬКО JSON, без пояснений`, dest, strings.Join(names, "\n"))
|
||||
|
||||
enrichCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
response, err := cfg.LLM.GenerateText(enrichCtx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 4096, Temperature: 0.2},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] LLM enrichment failed: %v", err)
|
||||
return pois
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
return pois
|
||||
}
|
||||
|
||||
var enrichments []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Duration int `json:"duration"`
|
||||
Price float64 `json:"price"`
|
||||
Rating float64 `json:"rating"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &enrichments); err != nil {
|
||||
log.Printf("[travel-poi] enrichment JSON parse error: %v", err)
|
||||
return pois
|
||||
}
|
||||
|
||||
enrichMap := make(map[string]int)
|
||||
for i, e := range enrichments {
|
||||
enrichMap[strings.ToLower(e.Name)] = i
|
||||
}
|
||||
|
||||
for _, idx := range needsDescription {
|
||||
key := strings.ToLower(pois[idx].Name)
|
||||
if eIdx, ok := enrichMap[key]; ok {
|
||||
e := enrichments[eIdx]
|
||||
if e.Description != "" {
|
||||
pois[idx].Description = e.Description
|
||||
}
|
||||
if e.Duration > 0 {
|
||||
pois[idx].Duration = e.Duration
|
||||
}
|
||||
if e.Price > 0 {
|
||||
pois[idx].Price = e.Price
|
||||
}
|
||||
if e.Rating > 0 {
|
||||
pois[idx].Rating = e.Rating
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
func enrichPOIPhotos(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief, pois []POICard) []POICard {
|
||||
dest := ""
|
||||
if len(brief.Destinations) > 0 {
|
||||
dest = brief.Destinations[0]
|
||||
}
|
||||
|
||||
maxEnrich := 15
|
||||
if len(pois) < maxEnrich {
|
||||
maxEnrich = len(pois)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < maxEnrich; i++ {
|
||||
if len(pois[i].Photos) > 0 {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
query := pois[idx].Name
|
||||
if dest != "" {
|
||||
query = pois[idx].Name + " " + dest
|
||||
}
|
||||
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := cfg.SearchClient.Search(searchCtx, query, &search.SearchOptions{
|
||||
Categories: []string{"images"},
|
||||
PageNo: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var photos []string
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range resp.Results {
|
||||
if len(photos) >= 3 {
|
||||
break
|
||||
}
|
||||
imgURL := r.ImgSrc
|
||||
if imgURL == "" {
|
||||
imgURL = r.ThumbnailSrc
|
||||
}
|
||||
if imgURL == "" {
|
||||
imgURL = r.Thumbnail
|
||||
}
|
||||
if imgURL == "" || seen[imgURL] {
|
||||
continue
|
||||
}
|
||||
seen[imgURL] = true
|
||||
photos = append(photos, imgURL)
|
||||
}
|
||||
|
||||
if len(photos) > 0 {
|
||||
mu.Lock()
|
||||
pois[idx].Photos = photos
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
photosFound := 0
|
||||
for _, p := range pois {
|
||||
if len(p.Photos) > 0 {
|
||||
photosFound++
|
||||
}
|
||||
}
|
||||
log.Printf("[travel-poi] enriched %d/%d POIs with photos", photosFound, len(pois))
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
type poiSearchResult struct {
|
||||
Title string
|
||||
URL string
|
||||
Content string
|
||||
Engine string
|
||||
}
|
||||
|
||||
func searchForPOIs(ctx context.Context, client *search.SearXNGClient, brief *TripBrief) []poiSearchResult {
|
||||
var results []poiSearchResult
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, dest := range brief.Destinations {
|
||||
queries := generatePOIQueries(dest, brief.Interests)
|
||||
|
||||
for _, q := range queries {
|
||||
searchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
resp, err := client.Search(searchCtx, q, &search.SearchOptions{
|
||||
Categories: []string{"general"},
|
||||
PageNo: 1,
|
||||
})
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] 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, poiSearchResult{
|
||||
Title: r.Title,
|
||||
URL: r.URL,
|
||||
Content: r.Content,
|
||||
Engine: r.Engine,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
func generatePOIQueries(destination string, interests []string) []string {
|
||||
queries := []string{
|
||||
fmt.Sprintf("достопримечательности %s что посмотреть конкретные места", destination),
|
||||
fmt.Sprintf("лучшие рестораны %s рейтинг адреса", destination),
|
||||
fmt.Sprintf("музеи %s список адреса", destination),
|
||||
}
|
||||
|
||||
for _, interest := range interests {
|
||||
queries = append(queries, fmt.Sprintf("%s %s адреса", interest, destination))
|
||||
}
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
func crawlPOIPages(ctx context.Context, crawl4aiURL string, results []poiSearchResult) []crawledPage {
|
||||
maxCrawl := 5
|
||||
if len(results) < maxCrawl {
|
||||
maxCrawl = len(results)
|
||||
}
|
||||
|
||||
var pages []crawledPage
|
||||
|
||||
for _, r := range results[:maxCrawl] {
|
||||
crawlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
page, err := crawlSinglePage(crawlCtx, crawl4aiURL, r.URL)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] crawl failed for %s: %v", r.URL, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if page != nil && len(page.Content) > 100 {
|
||||
pages = append(pages, *page)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
func extractPOIsWithLLM(ctx context.Context, llmClient llm.Client, brief *TripBrief, searchResults []poiSearchResult, crawled []crawledPage) []POICard {
|
||||
var contextBuilder strings.Builder
|
||||
|
||||
contextBuilder.WriteString("Результаты поиска мест и организаций:\n\n")
|
||||
for i, r := range searchResults {
|
||||
if i >= 15 {
|
||||
break
|
||||
}
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s\nURL: %s\n%s\n\n", r.Title, r.URL, truncateStr(r.Content, 500)))
|
||||
}
|
||||
|
||||
if len(crawled) > 0 {
|
||||
contextBuilder.WriteString("\nПодробное содержание страниц:\n\n")
|
||||
for _, p := range crawled {
|
||||
contextBuilder.WriteString(fmt.Sprintf("### %s (%s)\n%s\n\n", p.Title, p.URL, truncateStr(p.Content, 3000)))
|
||||
}
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Ты — эксперт по туризму. Из предоставленного контента извлеки КОНКРЕТНЫЕ достопримечательности, рестораны, музеи, парки и интересные места в %s.
|
||||
|
||||
%s
|
||||
|
||||
КРИТИЧЕСКИ ВАЖНО:
|
||||
- Извлекай ТОЛЬКО конкретные места с названиями (не статьи, не списки, не обзоры)
|
||||
- Каждое место должно быть реальной организацией или объектом, который можно найти на карте
|
||||
- НЕ включай заголовки статей типа "ТОП-25 достопримечательностей" — это НЕ место
|
||||
- Адрес ОБЯЗАТЕЛЕН и должен включать город для геокодинга
|
||||
|
||||
Верни JSON массив:
|
||||
[
|
||||
{
|
||||
"id": "уникальный id",
|
||||
"name": "Конкретное название места (не статьи!)",
|
||||
"description": "Краткое описание (1-2 предложения)",
|
||||
"category": "attraction|restaurant|museum|park|theater|shopping|entertainment|religious|viewpoint",
|
||||
"rating": число_от_0_до_5_или_0,
|
||||
"address": "Город, улица, дом (точный адрес)",
|
||||
"duration": время_посещения_в_минутах,
|
||||
"price": цена_входа_в_рублях_или_0,
|
||||
"currency": "RUB",
|
||||
"url": "ссылка на источник"
|
||||
}
|
||||
]
|
||||
|
||||
Верни ТОЛЬКО JSON массив, без пояснений.`,
|
||||
strings.Join(brief.Destinations, ", "),
|
||||
contextBuilder.String(),
|
||||
)
|
||||
|
||||
response, err := llmClient.GenerateText(ctx, llm.StreamRequest{
|
||||
Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}},
|
||||
Options: llm.StreamOptions{MaxTokens: 4096, Temperature: 0.2},
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[travel-poi] LLM extraction failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonMatch := regexp.MustCompile(`\[[\s\S]*\]`).FindString(response)
|
||||
if jsonMatch == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pois []POICard
|
||||
if err := json.Unmarshal([]byte(jsonMatch), &pois); err != nil {
|
||||
log.Printf("[travel-poi] JSON parse error: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range pois {
|
||||
if pois[i].ID == "" {
|
||||
pois[i].ID = uuid.New().String()
|
||||
}
|
||||
}
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
func geocodePOIs(ctx context.Context, cfg TravelOrchestratorConfig, pois []POICard) []POICard {
|
||||
for i := range pois {
|
||||
if pois[i].Lat != 0 && pois[i].Lng != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Try geocoding by address first, then by name + city
|
||||
queries := []string{}
|
||||
if pois[i].Address != "" {
|
||||
queries = append(queries, pois[i].Address)
|
||||
}
|
||||
if pois[i].Name != "" {
|
||||
queries = append(queries, pois[i].Name)
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
geoCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
geo, err := cfg.TravelData.Geocode(geoCtx, query)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
pois[i].Lat = geo.Lat
|
||||
pois[i].Lng = geo.Lng
|
||||
log.Printf("[travel-poi] geocoded '%s' -> %.4f, %.4f", query, geo.Lat, geo.Lng)
|
||||
break
|
||||
}
|
||||
|
||||
if pois[i].Lat == 0 && pois[i].Lng == 0 {
|
||||
log.Printf("[travel-poi] failed to geocode POI '%s' (address: '%s')", pois[i].Name, pois[i].Address)
|
||||
}
|
||||
}
|
||||
|
||||
return pois
|
||||
}
|
||||
|
||||
func deduplicatePOIs(pois []POICard) []POICard {
|
||||
seen := make(map[string]bool)
|
||||
var unique []POICard
|
||||
|
||||
for _, p := range pois {
|
||||
key := strings.ToLower(p.Name)
|
||||
if len(key) > 50 {
|
||||
key = key[:50]
|
||||
}
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
unique = append(unique, p)
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
225
backend/internal/agent/travel_types.go
Normal file
225
backend/internal/agent/travel_types.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package agent
|
||||
|
||||
import "time"
|
||||
|
||||
// TripBrief holds the structured user intent extracted by the planner agent.
|
||||
type TripBrief struct {
|
||||
Origin string `json:"origin"`
|
||||
OriginLat float64 `json:"originLat,omitempty"`
|
||||
OriginLng float64 `json:"originLng,omitempty"`
|
||||
Destinations []string `json:"destinations"`
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
Travelers int `json:"travelers"`
|
||||
Budget float64 `json:"budget"`
|
||||
Currency string `json:"currency"`
|
||||
Interests []string `json:"interests,omitempty"`
|
||||
TravelStyle string `json:"travelStyle,omitempty"`
|
||||
Constraints []string `json:"constraints,omitempty"`
|
||||
}
|
||||
|
||||
func (b *TripBrief) IsComplete() bool {
|
||||
if b.StartDate != "" && b.EndDate == "" {
|
||||
b.EndDate = b.StartDate
|
||||
}
|
||||
return len(b.Destinations) > 0
|
||||
}
|
||||
|
||||
func (b *TripBrief) ApplyDefaults() {
|
||||
if b.StartDate == "" {
|
||||
b.StartDate = time.Now().Format("2006-01-02")
|
||||
}
|
||||
if b.EndDate == "" {
|
||||
start, err := time.Parse("2006-01-02", b.StartDate)
|
||||
if err == nil {
|
||||
b.EndDate = start.AddDate(0, 0, 3).Format("2006-01-02")
|
||||
} else {
|
||||
b.EndDate = b.StartDate
|
||||
}
|
||||
}
|
||||
if b.Travelers == 0 {
|
||||
b.Travelers = 2
|
||||
}
|
||||
if b.Currency == "" {
|
||||
b.Currency = "RUB"
|
||||
}
|
||||
}
|
||||
|
||||
func (b *TripBrief) MissingFields() []string {
|
||||
var missing []string
|
||||
if len(b.Destinations) == 0 {
|
||||
missing = append(missing, "destination")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// EventCard represents a discovered event/activity at the destination.
|
||||
type EventCard struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DateStart string `json:"dateStart,omitempty"`
|
||||
DateEnd string `json:"dateEnd,omitempty"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
ImageURL string `json:"imageUrl,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lng float64 `json:"lng,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// POICard represents a point of interest (attraction, restaurant, etc.).
|
||||
type POICard struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
ReviewCount int `json:"reviewCount,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Schedule map[string]string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
// HotelCard represents a hotel offer with booking details.
|
||||
type HotelCard struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Stars int `json:"stars,omitempty"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
ReviewCount int `json:"reviewCount,omitempty"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
TotalPrice float64 `json:"totalPrice"`
|
||||
Rooms int `json:"rooms"`
|
||||
Nights int `json:"nights"`
|
||||
Guests int `json:"guests"`
|
||||
Currency string `json:"currency"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
Amenities []string `json:"amenities,omitempty"`
|
||||
Pros []string `json:"pros,omitempty"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
}
|
||||
|
||||
// TransportOption represents a flight, train, bus, or taxi option.
|
||||
type TransportOption struct {
|
||||
ID string `json:"id"`
|
||||
Mode string `json:"mode"`
|
||||
From string `json:"from"`
|
||||
FromLat float64 `json:"fromLat,omitempty"`
|
||||
FromLng float64 `json:"fromLng,omitempty"`
|
||||
To string `json:"to"`
|
||||
ToLat float64 `json:"toLat,omitempty"`
|
||||
ToLng float64 `json:"toLng,omitempty"`
|
||||
Departure string `json:"departure,omitempty"`
|
||||
Arrival string `json:"arrival,omitempty"`
|
||||
DurationMin int `json:"durationMin"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerUnit float64 `json:"pricePerUnit"`
|
||||
Passengers int `json:"passengers"`
|
||||
Currency string `json:"currency"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
Airline string `json:"airline,omitempty"`
|
||||
FlightNum string `json:"flightNum,omitempty"`
|
||||
Stops int `json:"stops,omitempty"`
|
||||
}
|
||||
|
||||
// ItineraryItem is a single slot in a day's plan.
|
||||
type ItineraryItem struct {
|
||||
RefType string `json:"refType"`
|
||||
RefID string `json:"refId"`
|
||||
Title string `json:"title"`
|
||||
StartTime string `json:"startTime,omitempty"`
|
||||
EndTime string `json:"endTime,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
// ItineraryDay groups items for a single day.
|
||||
type ItineraryDay struct {
|
||||
Date string `json:"date"`
|
||||
Items []ItineraryItem `json:"items"`
|
||||
}
|
||||
|
||||
// MapPoint is a point rendered on the travel map widget.
|
||||
type MapPoint struct {
|
||||
ID string `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Layer string `json:"layer"`
|
||||
}
|
||||
|
||||
// TripDraft is the server-side state of an in-progress trip planning session.
|
||||
type TripDraft struct {
|
||||
ID string `json:"id"`
|
||||
Brief *TripBrief `json:"brief"`
|
||||
Phase string `json:"phase"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Candidates TripCandidates `json:"candidates"`
|
||||
Selected TripSelected `json:"selected"`
|
||||
Context *TravelContext `json:"context,omitempty"`
|
||||
}
|
||||
|
||||
// TripCandidates holds all discovered options before user selection.
|
||||
type TripCandidates struct {
|
||||
Events []EventCard `json:"events"`
|
||||
POIs []POICard `json:"pois"`
|
||||
Hotels []HotelCard `json:"hotels"`
|
||||
Transport []TransportOption `json:"transport"`
|
||||
}
|
||||
|
||||
// TripSelected holds user-chosen items forming the final itinerary.
|
||||
type TripSelected struct {
|
||||
Itinerary []ItineraryDay `json:"itinerary"`
|
||||
Hotels []HotelCard `json:"hotels"`
|
||||
Transport []TransportOption `json:"transport"`
|
||||
}
|
||||
|
||||
// BudgetBreakdown shows cost allocation across categories.
|
||||
type BudgetBreakdown struct {
|
||||
Total float64 `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
Travelers int `json:"travelers"`
|
||||
PerPerson float64 `json:"perPerson"`
|
||||
Transport float64 `json:"transport"`
|
||||
Hotels float64 `json:"hotels"`
|
||||
Activities float64 `json:"activities"`
|
||||
Food float64 `json:"food"`
|
||||
Other float64 `json:"other"`
|
||||
Remaining float64 `json:"remaining"`
|
||||
}
|
||||
|
||||
// ClarifyingQuestion is a question the planner asks the user.
|
||||
type ClarifyingQuestion struct {
|
||||
Field string `json:"field"`
|
||||
Question string `json:"question"`
|
||||
Type string `json:"type"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
}
|
||||
|
||||
// TravelAction represents a user action sent back from the UI.
|
||||
type TravelAction struct {
|
||||
Kind string `json:"kind"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -19,6 +18,9 @@ type HeatmapService struct {
|
||||
config HeatmapConfig
|
||||
}
|
||||
|
||||
// HeatmapConfig configures the heatmap service.
|
||||
// Встроенные провайдеры (без ключа): moex → MOEX ISS, crypto → CoinGecko, forex → ЦБ РФ.
|
||||
// DataProviderURL — опционально для своих рынков; GET с параметрами market, range.
|
||||
type HeatmapConfig struct {
|
||||
DataProviderURL string
|
||||
CacheTTL time.Duration
|
||||
@@ -59,6 +61,7 @@ type Sector struct {
|
||||
MarketCap float64 `json:"marketCap"`
|
||||
Volume float64 `json:"volume"`
|
||||
TickerCount int `json:"tickerCount"`
|
||||
Tickers []TickerData `json:"tickers,omitempty"`
|
||||
TopGainers []TickerData `json:"topGainers,omitempty"`
|
||||
TopLosers []TickerData `json:"topLosers,omitempty"`
|
||||
Color string `json:"color"`
|
||||
@@ -199,137 +202,72 @@ func (s *HeatmapService) GetSectorHeatmap(ctx context.Context, market, sector, t
|
||||
}
|
||||
|
||||
func (s *HeatmapService) fetchMarketData(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
|
||||
heatmap := s.generateMockMarketData(market)
|
||||
market = strings.ToLower(strings.TrimSpace(market))
|
||||
var heatmap *MarketHeatmap
|
||||
var err error
|
||||
switch market {
|
||||
case "moex":
|
||||
heatmap, err = s.fetchMOEX(ctx, timeRange)
|
||||
case "crypto":
|
||||
heatmap, err = s.fetchCoinGecko(ctx, timeRange)
|
||||
case "forex":
|
||||
heatmap, err = s.fetchForexCBR(ctx, timeRange)
|
||||
default:
|
||||
if s.config.DataProviderURL != "" {
|
||||
heatmap, err = s.fetchFromProvider(ctx, market, timeRange)
|
||||
} else {
|
||||
return nil, fmt.Errorf("неизвестный рынок %q и FINANCE_DATA_PROVIDER_URL не задан", market)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
heatmap.TimeRange = timeRange
|
||||
|
||||
heatmap.UpdatedAt = time.Now()
|
||||
if len(heatmap.Colorscale.Colors) == 0 {
|
||||
heatmap.Colorscale = DefaultColorscale
|
||||
}
|
||||
s.fillSectorTickers(heatmap)
|
||||
return heatmap, nil
|
||||
}
|
||||
|
||||
func (s *HeatmapService) generateMockMarketData(market string) *MarketHeatmap {
|
||||
sectors := []struct {
|
||||
name string
|
||||
tickers []struct{ symbol, name string }
|
||||
}{
|
||||
{"Technology", []struct{ symbol, name string }{
|
||||
{"AAPL", "Apple Inc."},
|
||||
{"MSFT", "Microsoft Corp."},
|
||||
{"GOOGL", "Alphabet Inc."},
|
||||
{"AMZN", "Amazon.com Inc."},
|
||||
{"META", "Meta Platforms"},
|
||||
{"NVDA", "NVIDIA Corp."},
|
||||
{"TSLA", "Tesla Inc."},
|
||||
}},
|
||||
{"Healthcare", []struct{ symbol, name string }{
|
||||
{"JNJ", "Johnson & Johnson"},
|
||||
{"UNH", "UnitedHealth Group"},
|
||||
{"PFE", "Pfizer Inc."},
|
||||
{"MRK", "Merck & Co."},
|
||||
{"ABBV", "AbbVie Inc."},
|
||||
}},
|
||||
{"Finance", []struct{ symbol, name string }{
|
||||
{"JPM", "JPMorgan Chase"},
|
||||
{"BAC", "Bank of America"},
|
||||
{"WFC", "Wells Fargo"},
|
||||
{"GS", "Goldman Sachs"},
|
||||
{"MS", "Morgan Stanley"},
|
||||
}},
|
||||
{"Energy", []struct{ symbol, name string }{
|
||||
{"XOM", "Exxon Mobil"},
|
||||
{"CVX", "Chevron Corp."},
|
||||
{"COP", "ConocoPhillips"},
|
||||
{"SLB", "Schlumberger"},
|
||||
}},
|
||||
{"Consumer", []struct{ symbol, name string }{
|
||||
{"WMT", "Walmart Inc."},
|
||||
{"PG", "Procter & Gamble"},
|
||||
{"KO", "Coca-Cola Co."},
|
||||
{"PEP", "PepsiCo Inc."},
|
||||
{"COST", "Costco Wholesale"},
|
||||
}},
|
||||
func (s *HeatmapService) fetchFromProvider(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) {
|
||||
u := strings.TrimSuffix(s.config.DataProviderURL, "/")
|
||||
reqURL := fmt.Sprintf("%s?market=%s&range=%s", u, market, timeRange)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("provider returned %d", resp.StatusCode)
|
||||
}
|
||||
var heatmap MarketHeatmap
|
||||
if err := json.NewDecoder(resp.Body).Decode(&heatmap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(heatmap.Tickers) == 0 && len(heatmap.Sectors) == 0 {
|
||||
return nil, fmt.Errorf("provider returned empty data")
|
||||
}
|
||||
return &heatmap, nil
|
||||
}
|
||||
|
||||
allTickers := make([]TickerData, 0)
|
||||
allSectors := make([]Sector, 0)
|
||||
|
||||
for _, sec := range sectors {
|
||||
sectorTickers := make([]TickerData, 0)
|
||||
sectorChange := 0.0
|
||||
|
||||
for _, t := range sec.tickers {
|
||||
change := (randomFloat(-5, 5))
|
||||
price := randomFloat(50, 500)
|
||||
marketCap := randomFloat(50e9, 3000e9)
|
||||
volume := randomFloat(1e6, 100e6)
|
||||
|
||||
ticker := TickerData{
|
||||
Symbol: t.symbol,
|
||||
Name: t.name,
|
||||
Price: price,
|
||||
Change: price * change / 100,
|
||||
ChangePercent: change,
|
||||
Volume: volume,
|
||||
MarketCap: marketCap,
|
||||
Sector: sec.name,
|
||||
Color: getColorForChange(change),
|
||||
Size: math.Log10(marketCap) * 10,
|
||||
func (s *HeatmapService) fillSectorTickers(heatmap *MarketHeatmap) {
|
||||
for i := range heatmap.Sectors {
|
||||
sec := &heatmap.Sectors[i]
|
||||
if len(sec.Tickers) > 0 {
|
||||
continue
|
||||
}
|
||||
for _, t := range heatmap.Tickers {
|
||||
if strings.EqualFold(t.Sector, sec.Name) {
|
||||
sec.Tickers = append(sec.Tickers, t)
|
||||
}
|
||||
|
||||
sectorTickers = append(sectorTickers, ticker)
|
||||
sectorChange += change
|
||||
}
|
||||
|
||||
if len(sectorTickers) > 0 {
|
||||
sectorChange /= float64(len(sectorTickers))
|
||||
}
|
||||
|
||||
sort.Slice(sectorTickers, func(i, j int) bool {
|
||||
return sectorTickers[i].ChangePercent > sectorTickers[j].ChangePercent
|
||||
})
|
||||
|
||||
var topGainers, topLosers []TickerData
|
||||
if len(sectorTickers) >= 2 {
|
||||
topGainers = sectorTickers[:2]
|
||||
topLosers = sectorTickers[len(sectorTickers)-2:]
|
||||
}
|
||||
|
||||
sectorMarketCap := 0.0
|
||||
sectorVolume := 0.0
|
||||
for _, t := range sectorTickers {
|
||||
sectorMarketCap += t.MarketCap
|
||||
sectorVolume += t.Volume
|
||||
}
|
||||
|
||||
sector := Sector{
|
||||
ID: strings.ToLower(strings.ReplaceAll(sec.name, " ", "_")),
|
||||
Name: sec.name,
|
||||
Change: sectorChange,
|
||||
MarketCap: sectorMarketCap,
|
||||
Volume: sectorVolume,
|
||||
TickerCount: len(sectorTickers),
|
||||
TopGainers: topGainers,
|
||||
TopLosers: topLosers,
|
||||
Color: getColorForChange(sectorChange),
|
||||
Weight: sectorMarketCap,
|
||||
}
|
||||
|
||||
allSectors = append(allSectors, sector)
|
||||
allTickers = append(allTickers, sectorTickers...)
|
||||
}
|
||||
|
||||
sort.Slice(allTickers, func(i, j int) bool {
|
||||
return allTickers[i].MarketCap > allTickers[j].MarketCap
|
||||
})
|
||||
|
||||
return &MarketHeatmap{
|
||||
ID: market,
|
||||
Title: getMarketTitle(market),
|
||||
Type: HeatmapTreemap,
|
||||
Market: market,
|
||||
Sectors: allSectors,
|
||||
Tickers: allTickers,
|
||||
Summary: *s.calculateSummaryPtr(allTickers),
|
||||
UpdatedAt: time.Now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
sec.TickerCount = len(sec.Tickers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,51 +425,6 @@ type TopMovers struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func getColorForChange(change float64) string {
|
||||
if change >= 5 {
|
||||
return "#22c55e"
|
||||
} else if change >= 3 {
|
||||
return "#4ade80"
|
||||
} else if change >= 1 {
|
||||
return "#86efac"
|
||||
} else if change >= 0 {
|
||||
return "#bbf7d0"
|
||||
} else if change >= -1 {
|
||||
return "#fecaca"
|
||||
} else if change >= -3 {
|
||||
return "#fca5a5"
|
||||
} else if change >= -5 {
|
||||
return "#f87171"
|
||||
}
|
||||
return "#ef4444"
|
||||
}
|
||||
|
||||
func getMarketTitle(market string) string {
|
||||
titles := map[string]string{
|
||||
"sp500": "S&P 500",
|
||||
"nasdaq": "NASDAQ",
|
||||
"dow": "Dow Jones",
|
||||
"moex": "MOEX",
|
||||
"crypto": "Cryptocurrency",
|
||||
"forex": "Forex",
|
||||
"commodities": "Commodities",
|
||||
}
|
||||
if title, ok := titles[strings.ToLower(market)]; ok {
|
||||
return title
|
||||
}
|
||||
return market
|
||||
}
|
||||
|
||||
var rng uint64 = uint64(time.Now().UnixNano())
|
||||
|
||||
func randomFloat(min, max float64) float64 {
|
||||
rng ^= rng << 13
|
||||
rng ^= rng >> 17
|
||||
rng ^= rng << 5
|
||||
f := float64(rng) / float64(1<<64)
|
||||
return min + f*(max-min)
|
||||
}
|
||||
|
||||
func (h *MarketHeatmap) ToJSON() ([]byte, error) {
|
||||
return json.Marshal(h)
|
||||
}
|
||||
|
||||
406
backend/internal/finance/providers.go
Normal file
406
backend/internal/finance/providers.go
Normal file
@@ -0,0 +1,406 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
moexISSBase = "https://iss.moex.com/iss"
|
||||
coinGeckoBase = "https://api.coingecko.com/api/v3"
|
||||
)
|
||||
|
||||
// fetchMOEX получает данные с Московской биржи (ISS API, бесплатно, без регистрации).
|
||||
// Документация: https://iss.moex.com/iss/reference/
|
||||
func (s *HeatmapService) fetchMOEX(ctx context.Context, _ string) (*MarketHeatmap, error) {
|
||||
// Основной режим акций Т+2, boardgroup 57
|
||||
url := moexISSBase + "/engines/stock/markets/shares/boardgroups/57/securities.json?limit=100"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("moex request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("moex returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Securities struct {
|
||||
Columns []string `json:"columns"`
|
||||
Data [][]interface{} `json:"data"`
|
||||
} `json:"securities"`
|
||||
Marketdata struct {
|
||||
Columns []string `json:"columns"`
|
||||
Data [][]interface{} `json:"data"`
|
||||
} `json:"marketdata"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("moex decode: %w", err)
|
||||
}
|
||||
|
||||
colIdx := func(cols []string, name string) int {
|
||||
for i, c := range cols {
|
||||
if c == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
getStr := func(row []interface{}, idx int) string {
|
||||
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := row[idx].(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
getFloat := func(row []interface{}, idx int) float64 {
|
||||
if idx < 0 || idx >= len(row) || row[idx] == nil {
|
||||
return 0
|
||||
}
|
||||
switch v := row[idx].(type) {
|
||||
case float64:
|
||||
return v
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(v, 64)
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
secCols := raw.Securities.Columns
|
||||
iSECID := colIdx(secCols, "SECID")
|
||||
iSHORTNAME := colIdx(secCols, "SHORTNAME")
|
||||
iSECNAME := colIdx(secCols, "SECNAME")
|
||||
iPREVPRICE := colIdx(secCols, "PREVPRICE")
|
||||
iPREVWAPRICE := colIdx(secCols, "PREVWAPRICE")
|
||||
iISSUESIZE := colIdx(secCols, "ISSUESIZE")
|
||||
iBOARDID := colIdx(secCols, "BOARDID")
|
||||
|
||||
// Только акции TQBR (основной режим), без паёв и прочего
|
||||
tickers := make([]TickerData, 0, len(raw.Securities.Data))
|
||||
marketdataBySec := make(map[string]struct{ Last, LastChangePrc float64 })
|
||||
mdCols := raw.Marketdata.Columns
|
||||
iMD_SECID := colIdx(mdCols, "SECID")
|
||||
iMD_LAST := colIdx(mdCols, "LAST")
|
||||
iMD_LASTCHANGEPRC := colIdx(mdCols, "LASTCHANGEPRC")
|
||||
for _, row := range raw.Marketdata.Data {
|
||||
sid := getStr(row, iMD_SECID)
|
||||
if sid != "" {
|
||||
marketdataBySec[sid] = struct{ Last, LastChangePrc float64 }{
|
||||
Last: getFloat(row, iMD_LAST),
|
||||
LastChangePrc: getFloat(row, iMD_LASTCHANGEPRC),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range raw.Securities.Data {
|
||||
board := getStr(row, iBOARDID)
|
||||
if board != "TQBR" {
|
||||
continue
|
||||
}
|
||||
secID := getStr(row, iSECID)
|
||||
prevPrice := getFloat(row, iPREVPRICE)
|
||||
prevWAPrice := getFloat(row, iPREVWAPRICE)
|
||||
if prevPrice <= 0 {
|
||||
prevPrice = prevWAPrice
|
||||
}
|
||||
if prevPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
issuesize := getFloat(row, iISSUESIZE)
|
||||
marketCap := prevPrice * issuesize
|
||||
price := prevPrice
|
||||
changePct := 0.0
|
||||
if md, ok := marketdataBySec[secID]; ok && md.Last > 0 {
|
||||
price = md.Last
|
||||
changePct = md.LastChangePrc
|
||||
} else if prevWAPrice > 0 && prevWAPrice != prevPrice {
|
||||
changePct = (prevWAPrice - prevPrice) / prevPrice * 100
|
||||
price = prevWAPrice
|
||||
}
|
||||
name := getStr(row, iSECNAME)
|
||||
if name == "" {
|
||||
name = getStr(row, iSHORTNAME)
|
||||
}
|
||||
tickers = append(tickers, TickerData{
|
||||
Symbol: secID,
|
||||
Name: name,
|
||||
Price: price,
|
||||
Change: price - prevPrice,
|
||||
ChangePercent: changePct,
|
||||
MarketCap: marketCap,
|
||||
Volume: 0,
|
||||
Sector: "Акции",
|
||||
Color: colorForChange(changePct),
|
||||
Size: marketCap,
|
||||
PrevClose: prevPrice,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return nil, fmt.Errorf("moex: no tickers")
|
||||
}
|
||||
|
||||
sortTickersByMarketCap(tickers)
|
||||
summary := s.calculateSummaryPtr(tickers)
|
||||
sector := Sector{
|
||||
ID: "akcii",
|
||||
Name: "Акции",
|
||||
Change: summary.AverageChange,
|
||||
MarketCap: summary.TotalMarketCap,
|
||||
Volume: summary.TotalVolume,
|
||||
TickerCount: len(tickers),
|
||||
Tickers: tickers,
|
||||
Color: colorForChange(summary.AverageChange),
|
||||
Weight: summary.TotalMarketCap,
|
||||
}
|
||||
if len(tickers) >= 5 {
|
||||
gainers := make([]TickerData, len(tickers))
|
||||
copy(gainers, tickers)
|
||||
sortTickersByChangeDesc(gainers)
|
||||
sector.TopGainers = gainers[:minInt(3, len(gainers))]
|
||||
losers := make([]TickerData, len(tickers))
|
||||
copy(losers, tickers)
|
||||
sortTickersByChangeAsc(losers)
|
||||
sector.TopLosers = losers[:minInt(3, len(losers))]
|
||||
}
|
||||
|
||||
return &MarketHeatmap{
|
||||
ID: "moex",
|
||||
Title: "MOEX",
|
||||
Type: HeatmapTreemap,
|
||||
Market: "moex",
|
||||
Sectors: []Sector{sector},
|
||||
Tickers: tickers,
|
||||
Summary: *summary,
|
||||
UpdatedAt: now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchCoinGecko получает топ криптовалют с CoinGecko (бесплатно, без API ключа).
|
||||
// Документация: https://www.coingecko.com/en/api
|
||||
func (s *HeatmapService) fetchCoinGecko(ctx context.Context, _ string) (*MarketHeatmap, error) {
|
||||
url := coinGeckoBase + "/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=50&page=1"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("coingecko request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("coingecko returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var list []struct {
|
||||
ID string `json:"id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Name string `json:"name"`
|
||||
CurrentPrice float64 `json:"current_price"`
|
||||
MarketCap float64 `json:"market_cap"`
|
||||
PriceChange24h *float64 `json:"price_change_percentage_24h"`
|
||||
TotalVolume float64 `json:"total_volume"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return nil, fmt.Errorf("coingecko decode: %w", err)
|
||||
}
|
||||
|
||||
tickers := make([]TickerData, 0, len(list))
|
||||
for _, c := range list {
|
||||
chg := 0.0
|
||||
if c.PriceChange24h != nil {
|
||||
chg = *c.PriceChange24h
|
||||
}
|
||||
sym := strings.ToUpper(c.Symbol)
|
||||
tickers = append(tickers, TickerData{
|
||||
Symbol: sym,
|
||||
Name: c.Name,
|
||||
Price: c.CurrentPrice,
|
||||
ChangePercent: chg,
|
||||
Change: c.CurrentPrice * chg / 100,
|
||||
MarketCap: c.MarketCap,
|
||||
Volume: c.TotalVolume,
|
||||
Sector: "Криптовалюты",
|
||||
Color: colorForChange(chg),
|
||||
Size: c.MarketCap,
|
||||
})
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return nil, fmt.Errorf("coingecko: no tickers")
|
||||
}
|
||||
|
||||
summary := s.calculateSummaryPtr(tickers)
|
||||
sector := Sector{
|
||||
ID: "crypto",
|
||||
Name: "Криптовалюты",
|
||||
Change: summary.AverageChange,
|
||||
MarketCap: summary.TotalMarketCap,
|
||||
Volume: summary.TotalVolume,
|
||||
TickerCount: len(tickers),
|
||||
Tickers: tickers,
|
||||
Color: colorForChange(summary.AverageChange),
|
||||
Weight: summary.TotalMarketCap,
|
||||
}
|
||||
byGain := make([]TickerData, len(tickers))
|
||||
copy(byGain, tickers)
|
||||
sortTickersByChangeDesc(byGain)
|
||||
if len(byGain) >= 3 {
|
||||
sector.TopGainers = byGain[:3]
|
||||
}
|
||||
byLoss := make([]TickerData, len(tickers))
|
||||
copy(byLoss, tickers)
|
||||
sortTickersByChangeAsc(byLoss)
|
||||
if len(byLoss) >= 3 {
|
||||
sector.TopLosers = byLoss[:3]
|
||||
}
|
||||
|
||||
return &MarketHeatmap{
|
||||
ID: "crypto",
|
||||
Title: "Криптовалюты",
|
||||
Type: HeatmapTreemap,
|
||||
Market: "crypto",
|
||||
Sectors: []Sector{sector},
|
||||
Tickers: tickers,
|
||||
Summary: *summary,
|
||||
UpdatedAt: now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchForexCBR получает курсы валют ЦБ РФ (бесплатно, без ключа).
|
||||
func (s *HeatmapService) fetchForexCBR(ctx context.Context, _ string) (*MarketHeatmap, error) {
|
||||
url := "https://www.cbr-xml-daily.ru/daily_json.js"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cbr request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("cbr returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var raw struct {
|
||||
Valute map[string]struct {
|
||||
CharCode string `json:"CharCode"`
|
||||
Name string `json:"Name"`
|
||||
Value float64 `json:"Value"`
|
||||
Previous float64 `json:"Previous"`
|
||||
} `json:"Valute"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
|
||||
return nil, fmt.Errorf("cbr decode: %w", err)
|
||||
}
|
||||
|
||||
tickers := make([]TickerData, 0, len(raw.Valute))
|
||||
for _, v := range raw.Valute {
|
||||
if v.Previous <= 0 {
|
||||
continue
|
||||
}
|
||||
chg := (v.Value - v.Previous) / v.Previous * 100
|
||||
tickers = append(tickers, TickerData{
|
||||
Symbol: v.CharCode,
|
||||
Name: v.Name,
|
||||
Price: v.Value,
|
||||
PrevClose: v.Previous,
|
||||
Change: v.Value - v.Previous,
|
||||
ChangePercent: chg,
|
||||
Sector: "Валюты",
|
||||
Color: colorForChange(chg),
|
||||
})
|
||||
}
|
||||
|
||||
if len(tickers) == 0 {
|
||||
return nil, fmt.Errorf("cbr: no rates")
|
||||
}
|
||||
|
||||
summary := s.calculateSummaryPtr(tickers)
|
||||
sector := Sector{
|
||||
ID: "forex",
|
||||
Name: "Валюты",
|
||||
Change: summary.AverageChange,
|
||||
TickerCount: len(tickers),
|
||||
Tickers: tickers,
|
||||
Color: colorForChange(summary.AverageChange),
|
||||
}
|
||||
return &MarketHeatmap{
|
||||
ID: "forex",
|
||||
Title: "Валюты (ЦБ РФ)",
|
||||
Type: HeatmapTreemap,
|
||||
Market: "forex",
|
||||
Sectors: []Sector{sector},
|
||||
Tickers: tickers,
|
||||
Summary: *summary,
|
||||
UpdatedAt: now(),
|
||||
Colorscale: DefaultColorscale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func colorForChange(change float64) string {
|
||||
if change >= 5 {
|
||||
return "#22c55e"
|
||||
}
|
||||
if change >= 3 {
|
||||
return "#4ade80"
|
||||
}
|
||||
if change >= 1 {
|
||||
return "#86efac"
|
||||
}
|
||||
if change >= 0 {
|
||||
return "#bbf7d0"
|
||||
}
|
||||
if change >= -1 {
|
||||
return "#fecaca"
|
||||
}
|
||||
if change >= -3 {
|
||||
return "#fca5a5"
|
||||
}
|
||||
if change >= -5 {
|
||||
return "#f87171"
|
||||
}
|
||||
return "#ef4444"
|
||||
}
|
||||
|
||||
func now() time.Time { return time.Now() }
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sortTickersByMarketCap(t []TickerData) {
|
||||
sort.Slice(t, func(i, j int) bool { return t[i].MarketCap > t[j].MarketCap })
|
||||
}
|
||||
func sortTickersByChangeDesc(t []TickerData) {
|
||||
sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent > t[j].ChangePercent })
|
||||
}
|
||||
func sortTickersByChangeAsc(t []TickerData) {
|
||||
sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent < t[j].ChangePercent })
|
||||
}
|
||||
368
backend/internal/travel/amadeus.go
Normal file
368
backend/internal/travel/amadeus.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AmadeusClient struct {
|
||||
apiKey string
|
||||
apiSecret string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
accessToken string
|
||||
tokenExpiry time.Time
|
||||
tokenMu sync.RWMutex
|
||||
}
|
||||
|
||||
type AmadeusConfig struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewAmadeusClient(cfg AmadeusConfig) *AmadeusClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://test.api.amadeus.com"
|
||||
}
|
||||
|
||||
return &AmadeusClient{
|
||||
apiKey: cfg.APIKey,
|
||||
apiSecret: cfg.APISecret,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) getAccessToken(ctx context.Context) (string, error) {
|
||||
c.tokenMu.RLock()
|
||||
if c.accessToken != "" && time.Now().Before(c.tokenExpiry) {
|
||||
token := c.accessToken
|
||||
c.tokenMu.RUnlock()
|
||||
return token, nil
|
||||
}
|
||||
c.tokenMu.RUnlock()
|
||||
|
||||
c.tokenMu.Lock()
|
||||
defer c.tokenMu.Unlock()
|
||||
|
||||
if c.accessToken != "" && time.Now().Before(c.tokenExpiry) {
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", "client_credentials")
|
||||
data.Set("client_id", c.apiKey)
|
||||
data.Set("client_secret", c.apiSecret)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/security/oauth2/token", bytes.NewBufferString(data.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("token request failed: %s - %s", resp.Status, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("decode token response: %w", err)
|
||||
}
|
||||
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second)
|
||||
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) doRequest(ctx context.Context, method, path string, query url.Values, body interface{}) ([]byte, error) {
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path
|
||||
if len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request body: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("originLocationCode", req.Origin)
|
||||
query.Set("destinationLocationCode", req.Destination)
|
||||
query.Set("departureDate", req.DepartureDate)
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
|
||||
if req.ReturnDate != "" {
|
||||
query.Set("returnDate", req.ReturnDate)
|
||||
}
|
||||
if req.Children > 0 {
|
||||
query.Set("children", fmt.Sprintf("%d", req.Children))
|
||||
}
|
||||
if req.CabinClass != "" {
|
||||
query.Set("travelClass", req.CabinClass)
|
||||
}
|
||||
if req.MaxPrice > 0 {
|
||||
query.Set("maxPrice", fmt.Sprintf("%d", req.MaxPrice))
|
||||
}
|
||||
if req.Currency != "" {
|
||||
query.Set("currencyCode", req.Currency)
|
||||
}
|
||||
query.Set("max", "10")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/shopping/flight-offers", query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []amadeusFlightOffer `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, data := range response.Data {
|
||||
offer := c.convertFlightOffer(data)
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("cityCode", req.CityCode)
|
||||
query.Set("checkInDate", req.CheckIn)
|
||||
query.Set("checkOutDate", req.CheckOut)
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
|
||||
if req.Rooms > 0 {
|
||||
query.Set("roomQuantity", fmt.Sprintf("%d", req.Rooms))
|
||||
}
|
||||
if req.Currency != "" {
|
||||
query.Set("currency", req.Currency)
|
||||
}
|
||||
if req.Rating > 0 {
|
||||
query.Set("ratings", fmt.Sprintf("%d", req.Rating))
|
||||
}
|
||||
query.Set("bestRateOnly", "true")
|
||||
|
||||
hotelListBody, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations/hotels/by-city", query, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch hotel list: %w", err)
|
||||
}
|
||||
|
||||
var hotelListResp struct {
|
||||
Data []struct {
|
||||
HotelID string `json:"hotelId"`
|
||||
Name string `json:"name"`
|
||||
GeoCode struct {
|
||||
Lat float64 `json:"latitude"`
|
||||
Lng float64 `json:"longitude"`
|
||||
} `json:"geoCode"`
|
||||
Address struct {
|
||||
CountryCode string `json:"countryCode"`
|
||||
} `json:"address"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(hotelListBody, &hotelListResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotel list: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]HotelOffer, 0)
|
||||
for i, h := range hotelListResp.Data {
|
||||
if i >= 10 {
|
||||
break
|
||||
}
|
||||
offer := HotelOffer{
|
||||
ID: h.HotelID,
|
||||
Name: h.Name,
|
||||
Lat: h.GeoCode.Lat,
|
||||
Lng: h.GeoCode.Lng,
|
||||
Currency: req.Currency,
|
||||
CheckIn: req.CheckIn,
|
||||
CheckOut: req.CheckOut,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) GetAirportByCode(ctx context.Context, code string) (*GeoLocation, error) {
|
||||
query := url.Values{}
|
||||
query.Set("subType", "AIRPORT")
|
||||
query.Set("keyword", code)
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations", query, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Data []struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
Name string `json:"name"`
|
||||
GeoCode struct {
|
||||
Lat float64 `json:"latitude"`
|
||||
Lng float64 `json:"longitude"`
|
||||
} `json:"geoCode"`
|
||||
Address struct {
|
||||
CountryName string `json:"countryName"`
|
||||
} `json:"address"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response.Data) == 0 {
|
||||
return nil, fmt.Errorf("airport not found: %s", code)
|
||||
}
|
||||
|
||||
loc := response.Data[0]
|
||||
return &GeoLocation{
|
||||
Lat: loc.GeoCode.Lat,
|
||||
Lng: loc.GeoCode.Lng,
|
||||
Name: loc.Name,
|
||||
Country: loc.Address.CountryName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type amadeusFlightOffer struct {
|
||||
ID string `json:"id"`
|
||||
Itineraries []struct {
|
||||
Duration string `json:"duration"`
|
||||
Segments []struct {
|
||||
Departure struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
At string `json:"at"`
|
||||
} `json:"departure"`
|
||||
Arrival struct {
|
||||
IATACode string `json:"iataCode"`
|
||||
At string `json:"at"`
|
||||
} `json:"arrival"`
|
||||
CarrierCode string `json:"carrierCode"`
|
||||
Number string `json:"number"`
|
||||
Duration string `json:"duration"`
|
||||
} `json:"segments"`
|
||||
} `json:"itineraries"`
|
||||
Price struct {
|
||||
Total string `json:"total"`
|
||||
Currency string `json:"currency"`
|
||||
} `json:"price"`
|
||||
TravelerPricings []struct {
|
||||
FareDetailsBySegment []struct {
|
||||
Cabin string `json:"cabin"`
|
||||
} `json:"fareDetailsBySegment"`
|
||||
} `json:"travelerPricings"`
|
||||
NumberOfBookableSeats int `json:"numberOfBookableSeats"`
|
||||
}
|
||||
|
||||
func (c *AmadeusClient) convertFlightOffer(data amadeusFlightOffer) FlightOffer {
|
||||
offer := FlightOffer{
|
||||
ID: data.ID,
|
||||
SeatsAvailable: data.NumberOfBookableSeats,
|
||||
}
|
||||
|
||||
if len(data.Itineraries) > 0 && len(data.Itineraries[0].Segments) > 0 {
|
||||
itin := data.Itineraries[0]
|
||||
firstSeg := itin.Segments[0]
|
||||
lastSeg := itin.Segments[len(itin.Segments)-1]
|
||||
|
||||
offer.DepartureAirport = firstSeg.Departure.IATACode
|
||||
offer.DepartureTime = firstSeg.Departure.At
|
||||
offer.ArrivalAirport = lastSeg.Arrival.IATACode
|
||||
offer.ArrivalTime = lastSeg.Arrival.At
|
||||
offer.Airline = firstSeg.CarrierCode
|
||||
offer.FlightNumber = firstSeg.CarrierCode + firstSeg.Number
|
||||
offer.Stops = len(itin.Segments) - 1
|
||||
offer.Duration = parseDuration(itin.Duration)
|
||||
}
|
||||
|
||||
if price, err := parseFloat(data.Price.Total); err == nil {
|
||||
offer.Price = price
|
||||
}
|
||||
offer.Currency = data.Price.Currency
|
||||
|
||||
if len(data.TravelerPricings) > 0 && len(data.TravelerPricings[0].FareDetailsBySegment) > 0 {
|
||||
offer.CabinClass = data.TravelerPricings[0].FareDetailsBySegment[0].Cabin
|
||||
}
|
||||
|
||||
return offer
|
||||
}
|
||||
|
||||
func parseDuration(d string) int {
|
||||
var hours, minutes int
|
||||
fmt.Sscanf(d, "PT%dH%dM", &hours, &minutes)
|
||||
return hours*60 + minutes
|
||||
}
|
||||
|
||||
func parseFloat(s string) (float64, error) {
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
57
backend/internal/travel/llm_client.go
Normal file
57
backend/internal/travel/llm_client.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gooseek/backend/internal/llm"
|
||||
)
|
||||
|
||||
type LLMClientAdapter struct {
|
||||
client llm.Client
|
||||
}
|
||||
|
||||
func NewLLMClientAdapter(client llm.Client) *LLMClientAdapter {
|
||||
return &LLMClientAdapter{client: client}
|
||||
}
|
||||
|
||||
func (a *LLMClientAdapter) StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error {
|
||||
llmMessages := make([]llm.Message, len(messages))
|
||||
for i, m := range messages {
|
||||
var role llm.Role
|
||||
switch m.Role {
|
||||
case "system":
|
||||
role = llm.RoleSystem
|
||||
case "user":
|
||||
role = llm.RoleUser
|
||||
case "assistant":
|
||||
role = llm.RoleAssistant
|
||||
default:
|
||||
role = llm.RoleUser
|
||||
}
|
||||
llmMessages[i] = llm.Message{
|
||||
Role: role,
|
||||
Content: m.Content,
|
||||
}
|
||||
}
|
||||
|
||||
req := llm.StreamRequest{
|
||||
Messages: llmMessages,
|
||||
Options: llm.StreamOptions{
|
||||
MaxTokens: 4096,
|
||||
Temperature: 0.7,
|
||||
},
|
||||
}
|
||||
|
||||
ch, err := a.client.StreamText(ctx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for chunk := range ch {
|
||||
if chunk.ContentChunk != "" {
|
||||
onChunk(chunk.ContentChunk)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
335
backend/internal/travel/openroute.go
Normal file
335
backend/internal/travel/openroute.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OpenRouteClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) HasAPIKey() bool {
|
||||
return c.apiKey != ""
|
||||
}
|
||||
|
||||
type OpenRouteConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewOpenRouteClient(cfg OpenRouteConfig) *OpenRouteClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openrouteservice.org"
|
||||
}
|
||||
|
||||
return &OpenRouteClient{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) doRequest(ctx context.Context, method, path string, query url.Values) ([]byte, error) {
|
||||
fullURL := c.baseURL + path
|
||||
if len(query) > 0 {
|
||||
fullURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) GetDirections(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("at least 2 points required")
|
||||
}
|
||||
|
||||
if profile == "" {
|
||||
profile = "driving-car"
|
||||
}
|
||||
|
||||
coords := make([]string, len(points))
|
||||
for i, p := range points {
|
||||
coords[i] = fmt.Sprintf("%f,%f", p.Lng, p.Lat)
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("start", coords[0])
|
||||
query.Set("end", coords[len(coords)-1])
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/directions/"+profile, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
} `json:"geometry"`
|
||||
Properties struct {
|
||||
Summary struct {
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
} `json:"summary"`
|
||||
Segments []struct {
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type int `json:"type"`
|
||||
} `json:"steps"`
|
||||
} `json:"segments"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal directions: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("no route found")
|
||||
}
|
||||
|
||||
feature := response.Features[0]
|
||||
direction := &RouteDirection{
|
||||
Geometry: RouteGeometry{
|
||||
Coordinates: feature.Geometry.Coordinates,
|
||||
Type: feature.Geometry.Type,
|
||||
},
|
||||
Distance: feature.Properties.Summary.Distance,
|
||||
Duration: feature.Properties.Summary.Duration,
|
||||
}
|
||||
|
||||
for _, seg := range feature.Properties.Segments {
|
||||
for _, step := range seg.Steps {
|
||||
direction.Steps = append(direction.Steps, RouteStep{
|
||||
Instruction: step.Instruction,
|
||||
Distance: step.Distance,
|
||||
Duration: step.Duration,
|
||||
Type: fmt.Sprintf("%d", step.Type),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return direction, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("text", query)
|
||||
params.Set("size", "1")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/geocode/search", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
} `json:"geometry"`
|
||||
Properties struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Label string `json:"label"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal geocode: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("location not found: %s", query)
|
||||
}
|
||||
|
||||
feature := response.Features[0]
|
||||
return &GeoLocation{
|
||||
Lng: feature.Geometry.Coordinates[0],
|
||||
Lat: feature.Geometry.Coordinates[1],
|
||||
Name: feature.Properties.Name,
|
||||
Country: feature.Properties.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("point.lat", fmt.Sprintf("%f", lat))
|
||||
params.Set("point.lon", fmt.Sprintf("%f", lng))
|
||||
params.Set("size", "1")
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/geocode/reverse", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Properties struct {
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
Label string `json:"label"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal reverse geocode: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
|
||||
}
|
||||
|
||||
feature := response.Features[0]
|
||||
return &GeoLocation{
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Name: feature.Properties.Name,
|
||||
Country: feature.Properties.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("request", "pois")
|
||||
params.Set("geometry", fmt.Sprintf(`{"geojson":{"type":"Point","coordinates":[%f,%f]},"buffer":%d}`, req.Lng, req.Lat, req.Radius))
|
||||
|
||||
if len(req.Categories) > 0 {
|
||||
params.Set("filters", fmt.Sprintf(`{"category_ids":[%s]}`, strings.Join(req.Categories, ",")))
|
||||
}
|
||||
|
||||
limit := req.Limit
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
params.Set("limit", fmt.Sprintf("%d", limit))
|
||||
|
||||
body, err := c.doRequest(ctx, "POST", "/pois", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [2]float64 `json:"coordinates"`
|
||||
} `json:"geometry"`
|
||||
Properties struct {
|
||||
OSMId int64 `json:"osm_id"`
|
||||
Name string `json:"osm_tags.name"`
|
||||
Category struct {
|
||||
ID int `json:"category_id"`
|
||||
Name string `json:"category_name"`
|
||||
} `json:"category"`
|
||||
Distance float64 `json:"distance"`
|
||||
} `json:"properties"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal POI: %w", err)
|
||||
}
|
||||
|
||||
pois := make([]POI, 0, len(response.Features))
|
||||
for _, f := range response.Features {
|
||||
poi := POI{
|
||||
ID: fmt.Sprintf("%d", f.Properties.OSMId),
|
||||
Name: f.Properties.Name,
|
||||
Lng: f.Geometry.Coordinates[0],
|
||||
Lat: f.Geometry.Coordinates[1],
|
||||
Category: f.Properties.Category.Name,
|
||||
Distance: f.Properties.Distance,
|
||||
}
|
||||
pois = append(pois, poi)
|
||||
}
|
||||
|
||||
return pois, nil
|
||||
}
|
||||
|
||||
func (c *OpenRouteClient) GetIsochrone(ctx context.Context, lat, lng float64, timeMinutes int, profile string) (*RouteGeometry, error) {
|
||||
if profile == "" {
|
||||
profile = "driving-car"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("api_key", c.apiKey)
|
||||
params.Set("locations", fmt.Sprintf("%f,%f", lng, lat))
|
||||
params.Set("range", fmt.Sprintf("%d", timeMinutes*60))
|
||||
|
||||
body, err := c.doRequest(ctx, "GET", "/v2/isochrones/"+profile, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Features []struct {
|
||||
Geometry struct {
|
||||
Coordinates [][][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
} `json:"geometry"`
|
||||
} `json:"features"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal isochrone: %w", err)
|
||||
}
|
||||
|
||||
if len(response.Features) == 0 {
|
||||
return nil, fmt.Errorf("no isochrone found")
|
||||
}
|
||||
|
||||
coords := make([][2]float64, 0)
|
||||
if len(response.Features[0].Geometry.Coordinates) > 0 {
|
||||
coords = response.Features[0].Geometry.Coordinates[0]
|
||||
}
|
||||
|
||||
return &RouteGeometry{
|
||||
Coordinates: coords,
|
||||
Type: response.Features[0].Geometry.Type,
|
||||
}, nil
|
||||
}
|
||||
501
backend/internal/travel/repository.go
Normal file
501
backend/internal/travel/repository.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) InitSchema(ctx context.Context) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS trips (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
destination VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
cover_image TEXT,
|
||||
start_date TIMESTAMP NOT NULL,
|
||||
end_date TIMESTAMP NOT NULL,
|
||||
route JSONB DEFAULT '[]',
|
||||
flights JSONB DEFAULT '[]',
|
||||
hotels JSONB DEFAULT '[]',
|
||||
total_budget DECIMAL(12,2),
|
||||
currency VARCHAR(3) DEFAULT 'RUB',
|
||||
status VARCHAR(20) DEFAULT 'planned',
|
||||
ai_summary TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_trips_start_date ON trips(start_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trip_drafts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID,
|
||||
session_id VARCHAR(255),
|
||||
brief JSONB DEFAULT '{}',
|
||||
candidates JSONB DEFAULT '{}',
|
||||
selected JSONB DEFAULT '{}',
|
||||
phase VARCHAR(50) DEFAULT 'planning',
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_drafts_user_id ON trip_drafts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_trip_drafts_session_id ON trip_drafts(session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS geocode_cache (
|
||||
query_hash VARCHAR(64) PRIMARY KEY,
|
||||
query_text TEXT NOT NULL,
|
||||
lat DOUBLE PRECISION NOT NULL,
|
||||
lng DOUBLE PRECISION NOT NULL,
|
||||
name TEXT,
|
||||
country TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at);
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreateTrip(ctx context.Context, trip *Trip) error {
|
||||
if trip.ID == "" {
|
||||
trip.ID = uuid.New().String()
|
||||
}
|
||||
trip.CreatedAt = time.Now()
|
||||
trip.UpdatedAt = time.Now()
|
||||
|
||||
routeJSON, err := json.Marshal(trip.Route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal route: %w", err)
|
||||
}
|
||||
|
||||
flightsJSON, err := json.Marshal(trip.Flights)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal flights: %w", err)
|
||||
}
|
||||
|
||||
hotelsJSON, err := json.Marshal(trip.Hotels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal hotels: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO trips (
|
||||
id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
`
|
||||
|
||||
_, err = r.db.ExecContext(ctx, query,
|
||||
trip.ID, trip.UserID, trip.Title, trip.Destination, trip.Description, trip.CoverImage,
|
||||
trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON,
|
||||
trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.CreatedAt, trip.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetTrip(ctx context.Context, id string) (*Trip, error) {
|
||||
query := `
|
||||
SELECT id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
FROM trips WHERE id = $1
|
||||
`
|
||||
|
||||
var trip Trip
|
||||
var routeJSON, flightsJSON, hotelsJSON []byte
|
||||
var description, coverImage, aiSummary sql.NullString
|
||||
var totalBudget sql.NullFloat64
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
|
||||
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
|
||||
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
trip.Description = description.String
|
||||
}
|
||||
if coverImage.Valid {
|
||||
trip.CoverImage = coverImage.String
|
||||
}
|
||||
if aiSummary.Valid {
|
||||
trip.AISummary = aiSummary.String
|
||||
}
|
||||
if totalBudget.Valid {
|
||||
trip.TotalBudget = totalBudget.Float64
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal route: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotels: %w", err)
|
||||
}
|
||||
|
||||
return &trip, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetTripsByUser(ctx context.Context, userID string, limit, offset int) ([]Trip, error) {
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
FROM trips WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var trips []Trip
|
||||
for rows.Next() {
|
||||
var trip Trip
|
||||
var routeJSON, flightsJSON, hotelsJSON []byte
|
||||
var description, coverImage, aiSummary sql.NullString
|
||||
var totalBudget sql.NullFloat64
|
||||
|
||||
err := rows.Scan(
|
||||
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
|
||||
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
|
||||
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
trip.Description = description.String
|
||||
}
|
||||
if coverImage.Valid {
|
||||
trip.CoverImage = coverImage.String
|
||||
}
|
||||
if aiSummary.Valid {
|
||||
trip.AISummary = aiSummary.String
|
||||
}
|
||||
if totalBudget.Valid {
|
||||
trip.TotalBudget = totalBudget.Float64
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal route: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotels: %w", err)
|
||||
}
|
||||
|
||||
trips = append(trips, trip)
|
||||
}
|
||||
|
||||
return trips, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateTrip(ctx context.Context, trip *Trip) error {
|
||||
trip.UpdatedAt = time.Now()
|
||||
|
||||
routeJSON, err := json.Marshal(trip.Route)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal route: %w", err)
|
||||
}
|
||||
|
||||
flightsJSON, err := json.Marshal(trip.Flights)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal flights: %w", err)
|
||||
}
|
||||
|
||||
hotelsJSON, err := json.Marshal(trip.Hotels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal hotels: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE trips SET
|
||||
title = $2, destination = $3, description = $4, cover_image = $5,
|
||||
start_date = $6, end_date = $7, route = $8, flights = $9, hotels = $10,
|
||||
total_budget = $11, currency = $12, status = $13, ai_summary = $14, updated_at = $15
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
_, err = r.db.ExecContext(ctx, query,
|
||||
trip.ID, trip.Title, trip.Destination, trip.Description, trip.CoverImage,
|
||||
trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON,
|
||||
trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteTrip(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM trips WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetTripsByStatus(ctx context.Context, userID string, status TripStatus) ([]Trip, error) {
|
||||
query := `
|
||||
SELECT id, user_id, title, destination, description, cover_image,
|
||||
start_date, end_date, route, flights, hotels,
|
||||
total_budget, currency, status, ai_summary, created_at, updated_at
|
||||
FROM trips WHERE user_id = $1 AND status = $2
|
||||
ORDER BY start_date ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var trips []Trip
|
||||
for rows.Next() {
|
||||
var trip Trip
|
||||
var routeJSON, flightsJSON, hotelsJSON []byte
|
||||
var description, coverImage, aiSummary sql.NullString
|
||||
var totalBudget sql.NullFloat64
|
||||
|
||||
err := rows.Scan(
|
||||
&trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage,
|
||||
&trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON,
|
||||
&totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if description.Valid {
|
||||
trip.Description = description.String
|
||||
}
|
||||
if coverImage.Valid {
|
||||
trip.CoverImage = coverImage.String
|
||||
}
|
||||
if aiSummary.Valid {
|
||||
trip.AISummary = aiSummary.String
|
||||
}
|
||||
if totalBudget.Valid {
|
||||
trip.TotalBudget = totalBudget.Float64
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(routeJSON, &trip.Route); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal route: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal hotels: %w", err)
|
||||
}
|
||||
|
||||
trips = append(trips, trip)
|
||||
}
|
||||
|
||||
return trips, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CountTripsByUser(ctx context.Context, userID string) (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM trips WHERE user_id = $1", userID).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
// --- Trip Draft persistence ---
|
||||
|
||||
type TripDraft struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Brief json.RawMessage `json:"brief"`
|
||||
Candidates json.RawMessage `json:"candidates"`
|
||||
Selected json.RawMessage `json:"selected"`
|
||||
Phase string `json:"phase"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (r *Repository) SaveDraft(ctx context.Context, draft *TripDraft) error {
|
||||
if draft.ID == "" {
|
||||
draft.ID = uuid.New().String()
|
||||
}
|
||||
draft.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
INSERT INTO trip_drafts (id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
brief = EXCLUDED.brief,
|
||||
candidates = EXCLUDED.candidates,
|
||||
selected = EXCLUDED.selected,
|
||||
phase = EXCLUDED.phase,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
draft.ID, draft.UserID, draft.SessionID,
|
||||
draft.Brief, draft.Candidates, draft.Selected,
|
||||
draft.Phase, draft.CreatedAt, draft.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) GetDraft(ctx context.Context, id string) (*TripDraft, error) {
|
||||
query := `
|
||||
SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at
|
||||
FROM trip_drafts WHERE id = $1
|
||||
`
|
||||
|
||||
var draft TripDraft
|
||||
var userID sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&draft.ID, &userID, &draft.SessionID,
|
||||
&draft.Brief, &draft.Candidates, &draft.Selected,
|
||||
&draft.Phase, &draft.CreatedAt, &draft.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID.Valid {
|
||||
draft.UserID = userID.String
|
||||
}
|
||||
|
||||
return &draft, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetDraftBySession(ctx context.Context, sessionID string) (*TripDraft, error) {
|
||||
query := `
|
||||
SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at
|
||||
FROM trip_drafts WHERE session_id = $1
|
||||
ORDER BY updated_at DESC LIMIT 1
|
||||
`
|
||||
|
||||
var draft TripDraft
|
||||
var userID sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, sessionID).Scan(
|
||||
&draft.ID, &userID, &draft.SessionID,
|
||||
&draft.Brief, &draft.Candidates, &draft.Selected,
|
||||
&draft.Phase, &draft.CreatedAt, &draft.UpdatedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if userID.Valid {
|
||||
draft.UserID = userID.String
|
||||
}
|
||||
|
||||
return &draft, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteDraft(ctx context.Context, id string) error {
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CleanupOldDrafts(ctx context.Context, olderThan time.Duration) error {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE updated_at < $1", cutoff)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Geocode cache ---
|
||||
|
||||
type GeocodeCacheEntry struct {
|
||||
QueryHash string `json:"queryHash"`
|
||||
QueryText string `json:"queryText"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
|
||||
func (r *Repository) GetCachedGeocode(ctx context.Context, queryHash string) (*GeocodeCacheEntry, error) {
|
||||
query := `
|
||||
SELECT query_hash, query_text, lat, lng, name, country
|
||||
FROM geocode_cache WHERE query_hash = $1
|
||||
`
|
||||
|
||||
var entry GeocodeCacheEntry
|
||||
var name, country sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, queryHash).Scan(
|
||||
&entry.QueryHash, &entry.QueryText, &entry.Lat, &entry.Lng, &name, &country,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name.Valid {
|
||||
entry.Name = name.String
|
||||
}
|
||||
if country.Valid {
|
||||
entry.Country = country.String
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *Repository) SaveGeocodeCache(ctx context.Context, entry *GeocodeCacheEntry) error {
|
||||
query := `
|
||||
INSERT INTO geocode_cache (query_hash, query_text, lat, lng, name, country, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW())
|
||||
ON CONFLICT (query_hash) DO UPDATE SET
|
||||
lat = EXCLUDED.lat, lng = EXCLUDED.lng,
|
||||
name = EXCLUDED.name, country = EXCLUDED.country
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
entry.QueryHash, entry.QueryText, entry.Lat, entry.Lng, entry.Name, entry.Country,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CleanupOldGeocodeCache(ctx context.Context, olderThan time.Duration) error {
|
||||
cutoff := time.Now().Add(-olderThan)
|
||||
_, err := r.db.ExecContext(ctx, "DELETE FROM geocode_cache WHERE created_at < $1", cutoff)
|
||||
return err
|
||||
}
|
||||
660
backend/internal/travel/service.go
Normal file
660
backend/internal/travel/service.go
Normal file
@@ -0,0 +1,660 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
amadeus *AmadeusClient
|
||||
openRoute *OpenRouteClient
|
||||
travelPayouts *TravelPayoutsClient
|
||||
twoGIS *TwoGISClient
|
||||
llmClient LLMClient
|
||||
useRussianAPIs bool
|
||||
}
|
||||
|
||||
type LLMClient interface {
|
||||
StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error
|
||||
}
|
||||
|
||||
type ChatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type ServiceConfig struct {
|
||||
Repository *Repository
|
||||
AmadeusConfig AmadeusConfig
|
||||
OpenRouteConfig OpenRouteConfig
|
||||
TravelPayoutsConfig TravelPayoutsConfig
|
||||
TwoGISConfig TwoGISConfig
|
||||
LLMClient LLMClient
|
||||
UseRussianAPIs bool
|
||||
}
|
||||
|
||||
func NewService(cfg ServiceConfig) *Service {
|
||||
return &Service{
|
||||
repo: cfg.Repository,
|
||||
amadeus: NewAmadeusClient(cfg.AmadeusConfig),
|
||||
openRoute: NewOpenRouteClient(cfg.OpenRouteConfig),
|
||||
travelPayouts: NewTravelPayoutsClient(cfg.TravelPayoutsConfig),
|
||||
twoGIS: NewTwoGISClient(cfg.TwoGISConfig),
|
||||
llmClient: cfg.LLMClient,
|
||||
useRussianAPIs: cfg.UseRussianAPIs,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PlanTrip(ctx context.Context, req TravelPlanRequest, writer io.Writer) error {
|
||||
systemPrompt := `Ты - AI планировщик путешествий GooSeek.
|
||||
|
||||
ГЛАВНОЕ ПРАВИЛО: Если пользователь упоминает ЛЮБОЕ место, город, страну, регион или маршрут - ты ОБЯЗАН сразу построить маршрут с JSON. НЕ задавай уточняющих вопросов, НЕ здоровайся - сразу давай маршрут!
|
||||
|
||||
Примеры запросов, на которые нужно СРАЗУ давать маршрут:
|
||||
- "золотое кольцо" → маршрут по городам Золотого кольца
|
||||
- "путешествие по Италии" → маршрут по Италии
|
||||
- "хочу в Париж" → маршрут по Парижу
|
||||
- "куда поехать в Крыму" → маршрут по Крыму
|
||||
|
||||
Отвечай на русском языке. В ответе:
|
||||
1. Кратко опиши маршрут (2-3 предложения)
|
||||
2. Перечисли точки с описанием каждой
|
||||
3. Укажи примерный бюджет
|
||||
4. В КОНЦЕ ОБЯЗАТЕЛЬНО добавь JSON блок
|
||||
|
||||
КООРДИНАТЫ ГОРОДОВ ЗОЛОТОГО КОЛЬЦА:
|
||||
- Сергиев Посад: lat 56.3100, lng 38.1326
|
||||
- Переславль-Залесский: lat 56.7389, lng 38.8533
|
||||
- Ростов Великий: lat 57.1848, lng 39.4142
|
||||
- Ярославль: lat 57.6261, lng 39.8845
|
||||
- Кострома: lat 57.7679, lng 40.9269
|
||||
- Иваново: lat 56.9994, lng 40.9728
|
||||
- Суздаль: lat 56.4212, lng 40.4496
|
||||
- Владимир: lat 56.1366, lng 40.3966
|
||||
|
||||
ДРУГИЕ ПОПУЛЯРНЫЕ МЕСТА:
|
||||
- Москва, Красная площадь: lat 55.7539, lng 37.6208
|
||||
- Санкт-Петербург, Эрмитаж: lat 59.9398, lng 30.3146
|
||||
- Казань, Кремль: lat 55.7982, lng 49.1064
|
||||
- Сочи, центр: lat 43.5855, lng 39.7231
|
||||
- Калининград: lat 54.7104, lng 20.4522
|
||||
|
||||
ФОРМАТ JSON (ОБЯЗАТЕЛЕН В КОНЦЕ КАЖДОГО ОТВЕТА):
|
||||
` + "```json" + `
|
||||
{
|
||||
"route": [
|
||||
{
|
||||
"name": "Название места",
|
||||
"lat": 55.7539,
|
||||
"lng": 37.6208,
|
||||
"type": "attraction",
|
||||
"aiComment": "Комментарий о месте",
|
||||
"duration": 120,
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"suggestions": [
|
||||
{
|
||||
"type": "activity",
|
||||
"title": "Название",
|
||||
"description": "Описание",
|
||||
"lat": 55.7539,
|
||||
"lng": 37.6208
|
||||
}
|
||||
]
|
||||
}
|
||||
` + "```" + `
|
||||
|
||||
ТИПЫ ТОЧЕК: airport, hotel, attraction, restaurant, transport, custom
|
||||
ТИПЫ SUGGESTIONS: destination, activity, restaurant, transport
|
||||
|
||||
ПРАВИЛА:
|
||||
1. lat и lng - ЧИСЛА (не строки!)
|
||||
2. duration - минуты (число)
|
||||
3. order - порядковый номер с 1
|
||||
4. Минимум 5 точек для маршрута
|
||||
5. JSON ОБЯЗАТЕЛЕН даже для простых вопросов!
|
||||
6. НИКОГДА не отвечай без JSON блока!`
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
}
|
||||
|
||||
for _, h := range req.History {
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: h[0]})
|
||||
messages = append(messages, ChatMessage{Role: "assistant", Content: h[1]})
|
||||
}
|
||||
|
||||
userMsg := req.Query
|
||||
if req.StartDate != "" && req.EndDate != "" {
|
||||
userMsg += fmt.Sprintf("\n\nДаты: %s - %s", req.StartDate, req.EndDate)
|
||||
}
|
||||
if req.Travelers > 0 {
|
||||
userMsg += fmt.Sprintf("\nКоличество путешественников: %d", req.Travelers)
|
||||
}
|
||||
if req.Budget > 0 {
|
||||
currency := req.Currency
|
||||
if currency == "" {
|
||||
currency = "RUB"
|
||||
}
|
||||
userMsg += fmt.Sprintf("\nБюджет: %.0f %s", req.Budget, currency)
|
||||
}
|
||||
if req.Preferences != nil {
|
||||
if req.Preferences.TravelStyle != "" {
|
||||
userMsg += fmt.Sprintf("\nСтиль путешествия: %s", req.Preferences.TravelStyle)
|
||||
}
|
||||
if len(req.Preferences.Interests) > 0 {
|
||||
userMsg += fmt.Sprintf("\nИнтересы: %s", strings.Join(req.Preferences.Interests, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: userMsg})
|
||||
|
||||
writeEvent := func(eventType string, data interface{}) {
|
||||
event := map[string]interface{}{
|
||||
"type": eventType,
|
||||
"data": data,
|
||||
}
|
||||
jsonData, _ := json.Marshal(event)
|
||||
writer.Write(jsonData)
|
||||
writer.Write([]byte("\n"))
|
||||
if bw, ok := writer.(*bufio.Writer); ok {
|
||||
bw.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
writeEvent("messageStart", nil)
|
||||
|
||||
var fullResponse strings.Builder
|
||||
err := s.llmClient.StreamChat(ctx, messages, func(chunk string) {
|
||||
fullResponse.WriteString(chunk)
|
||||
writeEvent("textChunk", map[string]string{"chunk": chunk})
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
writeEvent("error", map[string]string{"message": err.Error()})
|
||||
return err
|
||||
}
|
||||
|
||||
responseText := fullResponse.String()
|
||||
fmt.Printf("[travel] Full LLM response length: %d chars\n", len(responseText))
|
||||
|
||||
routeData := s.extractRouteFromResponse(ctx, responseText)
|
||||
|
||||
if routeData == nil || len(routeData.Route) == 0 {
|
||||
fmt.Println("[travel] No valid route in response, requesting route generation from LLM...")
|
||||
routeData = s.requestRouteGeneration(ctx, userMsg, responseText)
|
||||
}
|
||||
|
||||
if routeData != nil && len(routeData.Route) > 0 {
|
||||
routeData = s.geocodeMissingCoordinates(ctx, routeData)
|
||||
}
|
||||
|
||||
if routeData != nil && len(routeData.Route) > 0 {
|
||||
for i, p := range routeData.Route {
|
||||
fmt.Printf("[travel] Point %d: %s (lat=%.6f, lng=%.6f)\n", i+1, p.Name, p.Lat, p.Lng)
|
||||
}
|
||||
writeEvent("route", routeData)
|
||||
fmt.Printf("[travel] Sent route event with %d points\n", len(routeData.Route))
|
||||
} else {
|
||||
fmt.Println("[travel] No route data after all attempts")
|
||||
}
|
||||
|
||||
writeEvent("messageEnd", nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) requestRouteGeneration(ctx context.Context, userQuery string, originalResponse string) *RouteData {
|
||||
if s.llmClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
genPrompt := `Пользователь запросил: "` + userQuery + `"
|
||||
|
||||
Ты должен СРАЗУ создать маршрут путешествия. ВЕРНИ ТОЛЬКО JSON без пояснений:
|
||||
|
||||
{
|
||||
"route": [
|
||||
{"name": "Место 1", "lat": 56.31, "lng": 38.13, "type": "attraction", "aiComment": "Описание", "duration": 120, "order": 1},
|
||||
{"name": "Место 2", "lat": 56.74, "lng": 38.85, "type": "attraction", "aiComment": "Описание", "duration": 90, "order": 2}
|
||||
],
|
||||
"suggestions": []
|
||||
}
|
||||
|
||||
КООРДИНАТЫ ПОПУЛЯРНЫХ МЕСТ:
|
||||
Золотое кольцо: Сергиев Посад (56.31, 38.13), Переславль-Залесский (56.74, 38.85), Ростов Великий (57.18, 39.41), Ярославль (57.63, 39.88), Кострома (57.77, 40.93), Суздаль (56.42, 40.45), Владимир (56.14, 40.40)
|
||||
Москва: Красная площадь (55.75, 37.62), Арбат (55.75, 37.59), ВДНХ (55.83, 37.64)
|
||||
Питер: Эрмитаж (59.94, 30.31), Петергоф (59.88, 29.91), Невский (59.93, 30.35)
|
||||
Крым: Ялта (44.49, 34.17), Севастополь (44.62, 33.52), Бахчисарай (44.75, 33.86)
|
||||
|
||||
ВАЖНО:
|
||||
- lat и lng - ЧИСЛА
|
||||
- Минимум 5 точек
|
||||
- type: airport, hotel, attraction, restaurant, transport, custom
|
||||
- ТОЛЬКО JSON, без текста до и после!`
|
||||
|
||||
messages := []ChatMessage{
|
||||
{Role: "user", Content: genPrompt},
|
||||
}
|
||||
|
||||
var genResponse strings.Builder
|
||||
err := s.llmClient.StreamChat(ctx, messages, func(chunk string) {
|
||||
genResponse.WriteString(chunk)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Printf("[travel] LLM route generation failed: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
result := genResponse.String()
|
||||
fmt.Printf("[travel] LLM generated route: %s\n", result)
|
||||
return s.extractRouteFromResponse(ctx, result)
|
||||
}
|
||||
|
||||
func (s *Service) geocodeMissingCoordinates(ctx context.Context, data *RouteData) *RouteData {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
validPoints := make([]RoutePoint, 0, len(data.Route))
|
||||
|
||||
for _, point := range data.Route {
|
||||
if (point.Lat == 0 && point.Lng == 0) && point.Name != "" {
|
||||
loc, err := s.Geocode(ctx, point.Name)
|
||||
if err == nil && loc != nil {
|
||||
point.Lat = loc.Lat
|
||||
point.Lng = loc.Lng
|
||||
if point.Address == "" {
|
||||
point.Address = loc.Name
|
||||
}
|
||||
fmt.Printf("[travel] Geocoded '%s' -> lat=%.4f, lng=%.4f\n", point.Name, point.Lat, point.Lng)
|
||||
} else {
|
||||
fmt.Printf("[travel] Failed to geocode '%s': %v\n", point.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if point.Lat != 0 || point.Lng != 0 {
|
||||
validPoints = append(validPoints, point)
|
||||
}
|
||||
}
|
||||
|
||||
data.Route = validPoints
|
||||
return data
|
||||
}
|
||||
|
||||
type RouteData struct {
|
||||
Route []RoutePoint `json:"route"`
|
||||
Suggestions []TravelSuggestion `json:"suggestions"`
|
||||
}
|
||||
|
||||
func (s *Service) extractRouteFromResponse(_ context.Context, response string) *RouteData {
|
||||
var jsonStr string
|
||||
|
||||
start := strings.Index(response, "```json")
|
||||
if start != -1 {
|
||||
start += 7
|
||||
end := strings.Index(response[start:], "```")
|
||||
if end != -1 {
|
||||
jsonStr = strings.TrimSpace(response[start : start+end])
|
||||
}
|
||||
}
|
||||
|
||||
if jsonStr == "" {
|
||||
start = strings.Index(response, "```")
|
||||
if start != -1 {
|
||||
start += 3
|
||||
if start < len(response) {
|
||||
for start < len(response) && (response[start] == '\n' || response[start] == '\r') {
|
||||
start++
|
||||
}
|
||||
end := strings.Index(response[start:], "```")
|
||||
if end != -1 {
|
||||
candidate := strings.TrimSpace(response[start : start+end])
|
||||
if strings.HasPrefix(candidate, "{") {
|
||||
jsonStr = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jsonStr == "" {
|
||||
routeStart := strings.Index(response, `"route"`)
|
||||
if routeStart != -1 {
|
||||
braceStart := strings.LastIndex(response[:routeStart], "{")
|
||||
if braceStart != -1 {
|
||||
depth := 0
|
||||
braceEnd := -1
|
||||
for i := braceStart; i < len(response); i++ {
|
||||
if response[i] == '{' {
|
||||
depth++
|
||||
} else if response[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
braceEnd = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if braceEnd != -1 {
|
||||
jsonStr = response[braceStart:braceEnd]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rawResult struct {
|
||||
Route []struct {
|
||||
ID string `json:"id"`
|
||||
Lat interface{} `json:"lat"`
|
||||
Lng interface{} `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Type string `json:"type"`
|
||||
AIComment string `json:"aiComment,omitempty"`
|
||||
Duration interface{} `json:"duration,omitempty"`
|
||||
Order interface{} `json:"order"`
|
||||
} `json:"route"`
|
||||
Suggestions []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Lat interface{} `json:"lat,omitempty"`
|
||||
Lng interface{} `json:"lng,omitempty"`
|
||||
} `json:"suggestions"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &rawResult); err != nil {
|
||||
fmt.Printf("[travel] JSON parse error: %v, json: %s\n", err, jsonStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &RouteData{
|
||||
Route: make([]RoutePoint, 0, len(rawResult.Route)),
|
||||
Suggestions: make([]TravelSuggestion, 0, len(rawResult.Suggestions)),
|
||||
}
|
||||
|
||||
for i, raw := range rawResult.Route {
|
||||
point := RoutePoint{
|
||||
ID: raw.ID,
|
||||
Name: raw.Name,
|
||||
Address: raw.Address,
|
||||
Type: RoutePointType(raw.Type),
|
||||
AIComment: raw.AIComment,
|
||||
Order: i + 1,
|
||||
}
|
||||
|
||||
if point.ID == "" {
|
||||
point.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
point.Lat = toFloat64(raw.Lat)
|
||||
point.Lng = toFloat64(raw.Lng)
|
||||
point.Duration = toInt(raw.Duration)
|
||||
|
||||
if orderVal := toInt(raw.Order); orderVal > 0 {
|
||||
point.Order = orderVal
|
||||
}
|
||||
|
||||
if point.Type == "" {
|
||||
point.Type = RoutePointCustom
|
||||
}
|
||||
|
||||
if point.Name != "" {
|
||||
result.Route = append(result.Route, point)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range rawResult.Suggestions {
|
||||
sug := TravelSuggestion{
|
||||
ID: raw.ID,
|
||||
Type: raw.Type,
|
||||
Title: raw.Title,
|
||||
Description: raw.Description,
|
||||
}
|
||||
|
||||
if sug.ID == "" {
|
||||
sug.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
sug.Lat = toFloat64(raw.Lat)
|
||||
sug.Lng = toFloat64(raw.Lng)
|
||||
|
||||
result.Suggestions = append(result.Suggestions, sug)
|
||||
}
|
||||
|
||||
fmt.Printf("[travel] Extracted %d route points, %d suggestions\n", len(result.Route), len(result.Suggestions))
|
||||
return result
|
||||
}
|
||||
|
||||
func toFloat64(v interface{}) float64 {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case float32:
|
||||
return float64(val)
|
||||
case int:
|
||||
return float64(val)
|
||||
case int64:
|
||||
return float64(val)
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(val, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func toInt(v interface{}) int {
|
||||
if v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case int:
|
||||
return val
|
||||
case int64:
|
||||
return int(val)
|
||||
case float64:
|
||||
return int(val)
|
||||
case float32:
|
||||
return int(val)
|
||||
case string:
|
||||
i, _ := strconv.Atoi(val)
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
if s.useRussianAPIs && s.travelPayouts != nil {
|
||||
return s.travelPayouts.SearchFlights(ctx, req)
|
||||
}
|
||||
return s.amadeus.SearchFlights(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) {
|
||||
return s.amadeus.SearchHotels(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetRoute(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) {
|
||||
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
|
||||
transport := mapProfileToTwoGISTransport(profile)
|
||||
dir, err := s.twoGIS.GetRoute(ctx, points, transport)
|
||||
if err == nil {
|
||||
return dir, nil
|
||||
}
|
||||
fmt.Printf("[travel] 2GIS routing failed (transport=%s): %v, trying OpenRouteService\n", transport, err)
|
||||
}
|
||||
|
||||
if s.openRoute != nil && s.openRoute.HasAPIKey() {
|
||||
return s.openRoute.GetDirections(ctx, points, profile)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no routing provider available")
|
||||
}
|
||||
|
||||
func mapProfileToTwoGISTransport(profile string) string {
|
||||
switch profile {
|
||||
case "driving-car", "driving", "car":
|
||||
return "driving"
|
||||
case "taxi":
|
||||
return "taxi"
|
||||
case "foot-walking", "walking", "pedestrian":
|
||||
return "walking"
|
||||
case "cycling-regular", "cycling", "bicycle":
|
||||
return "bicycle"
|
||||
default:
|
||||
return "driving"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
||||
if query == "" {
|
||||
return nil, fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
|
||||
loc, err := s.twoGIS.Geocode(ctx, query)
|
||||
if err == nil && loc != nil {
|
||||
fmt.Printf("[travel] 2GIS geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng)
|
||||
return loc, nil
|
||||
}
|
||||
fmt.Printf("[travel] 2GIS geocode failed for '%s': %v\n", query, err)
|
||||
}
|
||||
|
||||
if s.openRoute != nil && s.openRoute.HasAPIKey() {
|
||||
loc, err := s.openRoute.Geocode(ctx, query)
|
||||
if err == nil && loc != nil {
|
||||
fmt.Printf("[travel] OpenRoute geocoded '%s' -> lat=%.4f, lng=%.4f\n", query, loc.Lat, loc.Lng)
|
||||
return loc, nil
|
||||
}
|
||||
fmt.Printf("[travel] OpenRoute geocode failed for '%s': %v\n", query, err)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("geocode failed for '%s': no API keys configured", query)
|
||||
}
|
||||
|
||||
func (s *Service) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) {
|
||||
return s.openRoute.SearchPOI(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) {
|
||||
if s.useRussianAPIs && s.travelPayouts != nil {
|
||||
return s.travelPayouts.GetPopularDestinations(ctx, origin)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) {
|
||||
if s.twoGIS != nil && s.twoGIS.HasAPIKey() {
|
||||
return s.twoGIS.SearchPlaces(ctx, query, lat, lng, radius)
|
||||
}
|
||||
return nil, fmt.Errorf("2GIS API key not configured")
|
||||
}
|
||||
|
||||
func (s *Service) CreateTrip(ctx context.Context, trip *Trip) error {
|
||||
return s.repo.CreateTrip(ctx, trip)
|
||||
}
|
||||
|
||||
func (s *Service) GetTrip(ctx context.Context, id string) (*Trip, error) {
|
||||
return s.repo.GetTrip(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) GetUserTrips(ctx context.Context, userID string, limit, offset int) ([]Trip, error) {
|
||||
return s.repo.GetTripsByUser(ctx, userID, limit, offset)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateTrip(ctx context.Context, trip *Trip) error {
|
||||
return s.repo.UpdateTrip(ctx, trip)
|
||||
}
|
||||
|
||||
func (s *Service) DeleteTrip(ctx context.Context, id string) error {
|
||||
return s.repo.DeleteTrip(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) GetUpcomingTrips(ctx context.Context, userID string) ([]Trip, error) {
|
||||
trips, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusPlanned)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
booked, err := s.repo.GetTripsByStatus(ctx, userID, TripStatusBooked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var upcoming []Trip
|
||||
for _, t := range append(trips, booked...) {
|
||||
if t.StartDate.After(now) {
|
||||
upcoming = append(upcoming, t)
|
||||
}
|
||||
}
|
||||
|
||||
return upcoming, nil
|
||||
}
|
||||
|
||||
func (s *Service) BuildRouteFromPoints(ctx context.Context, trip *Trip) (*RouteDirection, error) {
|
||||
if len(trip.Route) < 2 {
|
||||
return nil, fmt.Errorf("need at least 2 points for route")
|
||||
}
|
||||
|
||||
points := make([]GeoLocation, len(trip.Route))
|
||||
for i, p := range trip.Route {
|
||||
points[i] = GeoLocation{
|
||||
Lat: p.Lat,
|
||||
Lng: p.Lng,
|
||||
Name: p.Name,
|
||||
}
|
||||
}
|
||||
|
||||
return s.openRoute.GetDirections(ctx, points, "driving-car")
|
||||
}
|
||||
|
||||
func (s *Service) EnrichTripWithAI(ctx context.Context, trip *Trip) error {
|
||||
if len(trip.Route) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := range trip.Route {
|
||||
point := &trip.Route[i]
|
||||
if point.AIComment == "" {
|
||||
pois, err := s.openRoute.SearchPOI(ctx, POISearchRequest{
|
||||
Lat: point.Lat,
|
||||
Lng: point.Lng,
|
||||
Radius: 500,
|
||||
Limit: 5,
|
||||
})
|
||||
if err == nil && len(pois) > 0 {
|
||||
var nearbyNames []string
|
||||
for _, poi := range pois {
|
||||
if poi.Name != "" && poi.Name != point.Name {
|
||||
nearbyNames = append(nearbyNames, poi.Name)
|
||||
}
|
||||
}
|
||||
if len(nearbyNames) > 0 {
|
||||
point.AIComment = fmt.Sprintf("Рядом: %s", strings.Join(nearbyNames, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
276
backend/internal/travel/travelpayouts.go
Normal file
276
backend/internal/travel/travelpayouts.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TravelPayoutsClient struct {
|
||||
token string
|
||||
marker string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type TravelPayoutsConfig struct {
|
||||
Token string
|
||||
Marker string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func NewTravelPayoutsClient(cfg TravelPayoutsConfig) *TravelPayoutsClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.travelpayouts.com"
|
||||
}
|
||||
|
||||
return &TravelPayoutsClient{
|
||||
token: cfg.Token,
|
||||
marker: cfg.Marker,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) doRequest(ctx context.Context, path string, query url.Values) ([]byte, error) {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
query.Set("token", c.token)
|
||||
if c.marker != "" {
|
||||
query.Set("marker", c.marker)
|
||||
}
|
||||
|
||||
fullURL := c.baseURL + path + "?" + query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", req.Origin)
|
||||
query.Set("destination", req.Destination)
|
||||
query.Set("depart_date", req.DepartureDate)
|
||||
if req.ReturnDate != "" {
|
||||
query.Set("return_date", req.ReturnDate)
|
||||
}
|
||||
query.Set("adults", fmt.Sprintf("%d", req.Adults))
|
||||
if req.Currency != "" {
|
||||
query.Set("currency", req.Currency)
|
||||
} else {
|
||||
query.Set("currency", "rub")
|
||||
}
|
||||
query.Set("limit", "10")
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data []struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
OriginAirport string `json:"origin_airport"`
|
||||
DestAirport string `json:"destination_airport"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
FlightNumber string `json:"flight_number"`
|
||||
DepartureAt string `json:"departure_at"`
|
||||
ReturnAt string `json:"return_at"`
|
||||
Transfers int `json:"transfers"`
|
||||
ReturnTransfers int `json:"return_transfers"`
|
||||
Duration int `json:"duration"`
|
||||
Link string `json:"link"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal flights: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, d := range response.Data {
|
||||
offer := FlightOffer{
|
||||
ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartureAt),
|
||||
Airline: d.Airline,
|
||||
FlightNumber: d.FlightNumber,
|
||||
DepartureAirport: d.OriginAirport,
|
||||
DepartureCity: d.Origin,
|
||||
DepartureTime: d.DepartureAt,
|
||||
ArrivalAirport: d.DestAirport,
|
||||
ArrivalCity: d.Destination,
|
||||
ArrivalTime: d.ReturnAt,
|
||||
Duration: d.Duration,
|
||||
Stops: d.Transfers,
|
||||
Price: d.Price,
|
||||
Currency: req.Currency,
|
||||
BookingURL: "https://www.aviasales.ru" + d.Link,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetCheapestPrices(ctx context.Context, origin, destination string, currency string) ([]FlightOffer, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", origin)
|
||||
query.Set("destination", destination)
|
||||
if currency != "" {
|
||||
query.Set("currency", currency)
|
||||
} else {
|
||||
query.Set("currency", "rub")
|
||||
}
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data []struct {
|
||||
DepartDate string `json:"depart_date"`
|
||||
ReturnDate string `json:"return_date"`
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
Price float64 `json:"price"`
|
||||
Airline string `json:"airline"`
|
||||
Transfers int `json:"number_of_changes"`
|
||||
Link string `json:"link"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal prices: %w", err)
|
||||
}
|
||||
|
||||
offers := make([]FlightOffer, 0, len(response.Data))
|
||||
for _, d := range response.Data {
|
||||
offer := FlightOffer{
|
||||
ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartDate),
|
||||
Airline: d.Airline,
|
||||
DepartureCity: d.Origin,
|
||||
DepartureTime: d.DepartDate,
|
||||
ArrivalCity: d.Destination,
|
||||
ArrivalTime: d.ReturnDate,
|
||||
Stops: d.Transfers,
|
||||
Price: d.Price,
|
||||
Currency: currency,
|
||||
BookingURL: "https://www.aviasales.ru" + d.Link,
|
||||
}
|
||||
offers = append(offers, offer)
|
||||
}
|
||||
|
||||
return offers, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) {
|
||||
query := url.Values{}
|
||||
query.Set("origin", origin)
|
||||
query.Set("currency", "rub")
|
||||
|
||||
body, err := c.doRequest(ctx, "/aviasales/v3/city_directions", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Success bool `json:"success"`
|
||||
Data map[string]struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
Price float64 `json:"price"`
|
||||
Transfers int `json:"transfers"`
|
||||
Airline string `json:"airline"`
|
||||
DepartDate string `json:"departure_at"`
|
||||
ReturnDate string `json:"return_at"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal destinations: %w", err)
|
||||
}
|
||||
|
||||
suggestions := make([]TravelSuggestion, 0)
|
||||
for dest, d := range response.Data {
|
||||
suggestion := TravelSuggestion{
|
||||
ID: dest,
|
||||
Type: "destination",
|
||||
Title: dest,
|
||||
Description: fmt.Sprintf("от %s, %d пересадок", d.Airline, d.Transfers),
|
||||
Price: d.Price,
|
||||
Currency: "RUB",
|
||||
}
|
||||
suggestions = append(suggestions, suggestion)
|
||||
}
|
||||
|
||||
return suggestions, nil
|
||||
}
|
||||
|
||||
func (c *TravelPayoutsClient) GetAirportByIATA(ctx context.Context, iata string) (*GeoLocation, error) {
|
||||
query := url.Values{}
|
||||
query.Set("code", iata)
|
||||
query.Set("locale", "ru")
|
||||
|
||||
body, err := c.doRequest(ctx, "/data/ru/airports.json", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var airports []struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Coordinates []float64 `json:"coordinates"`
|
||||
Country string `json:"country_code"`
|
||||
City string `json:"city_code"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &airports); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal airports: %w", err)
|
||||
}
|
||||
|
||||
for _, a := range airports {
|
||||
if a.Code == iata && len(a.Coordinates) >= 2 {
|
||||
return &GeoLocation{
|
||||
Lng: a.Coordinates[0],
|
||||
Lat: a.Coordinates[1],
|
||||
Name: a.Name,
|
||||
Country: a.Country,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("airport not found: %s", iata)
|
||||
}
|
||||
465
backend/internal/travel/twogis.go
Normal file
465
backend/internal/travel/twogis.go
Normal file
@@ -0,0 +1,465 @@
|
||||
package travel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwoGISClient struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type TwoGISConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) HasAPIKey() bool {
|
||||
return c.apiKey != ""
|
||||
}
|
||||
|
||||
func NewTwoGISClient(cfg TwoGISConfig) *TwoGISClient {
|
||||
baseURL := cfg.BaseURL
|
||||
if baseURL == "" {
|
||||
baseURL = "https://catalog.api.2gis.com"
|
||||
}
|
||||
|
||||
return &TwoGISClient{
|
||||
apiKey: cfg.APIKey,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type twoGISResponse struct {
|
||||
Meta struct {
|
||||
Code int `json:"code"`
|
||||
} `json:"meta"`
|
||||
Result struct {
|
||||
Items []twoGISItem `json:"items"`
|
||||
Total int `json:"total"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type twoGISItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
AddressName string `json:"address_name"`
|
||||
Type string `json:"type"`
|
||||
Point *struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
} `json:"point"`
|
||||
Address *struct {
|
||||
Components []struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Country string `json:"country,omitempty"`
|
||||
} `json:"components,omitempty"`
|
||||
} `json:"address,omitempty"`
|
||||
PurposeName string `json:"purpose_name,omitempty"`
|
||||
Reviews *twoGISReviews `json:"reviews,omitempty"`
|
||||
Schedule map[string]twoGISScheduleDay `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
type twoGISReviews struct {
|
||||
GeneralRating float64 `json:"general_rating"`
|
||||
GeneralReviewCount int `json:"general_review_count"`
|
||||
OrgRating float64 `json:"org_rating"`
|
||||
OrgReviewCount int `json:"org_review_count"`
|
||||
}
|
||||
|
||||
type twoGISScheduleDay struct {
|
||||
WorkingHours []struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
} `json:"working_hours"`
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) doRequest(ctx context.Context, endpoint string, params url.Values) (*twoGISResponse, error) {
|
||||
params.Set("key", c.apiKey)
|
||||
|
||||
fullURL := c.baseURL + endpoint + "?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("2GIS API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result twoGISResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
if result.Meta.Code >= 400 {
|
||||
return nil, fmt.Errorf("2GIS API meta error %d: %s", result.Meta.Code, string(body))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("fields", "items.point")
|
||||
params.Set("type", "building,street,adm_div,adm_div.city,adm_div.place,adm_div.settlement,crossroad,attraction")
|
||||
|
||||
result, err := c.doRequest(ctx, "/3.0/items/geocode", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Result.Items) == 0 {
|
||||
return nil, fmt.Errorf("location not found: %s", query)
|
||||
}
|
||||
|
||||
item := result.Result.Items[0]
|
||||
if item.Point == nil {
|
||||
return nil, fmt.Errorf("no coordinates for: %s", query)
|
||||
}
|
||||
|
||||
country := ""
|
||||
if item.Address != nil {
|
||||
for _, comp := range item.Address.Components {
|
||||
if comp.Type == "country" {
|
||||
country = comp.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = item.FullName
|
||||
}
|
||||
|
||||
return &GeoLocation{
|
||||
Lat: item.Point.Lat,
|
||||
Lng: item.Point.Lon,
|
||||
Name: name,
|
||||
Country: country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) {
|
||||
params := url.Values{}
|
||||
params.Set("lat", fmt.Sprintf("%f", lat))
|
||||
params.Set("lon", fmt.Sprintf("%f", lng))
|
||||
params.Set("fields", "items.point")
|
||||
|
||||
result, err := c.doRequest(ctx, "/3.0/items/geocode", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result.Result.Items) == 0 {
|
||||
return nil, fmt.Errorf("location not found at %f,%f", lat, lng)
|
||||
}
|
||||
|
||||
item := result.Result.Items[0]
|
||||
|
||||
country := ""
|
||||
if item.Address != nil {
|
||||
for _, comp := range item.Address.Components {
|
||||
if comp.Type == "country" {
|
||||
country = comp.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
name := item.Name
|
||||
if name == "" {
|
||||
name = item.FullName
|
||||
}
|
||||
|
||||
return &GeoLocation{
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Name: name,
|
||||
Country: country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type TwoGISPlace struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Type string `json:"type"`
|
||||
Purpose string `json:"purpose"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount"`
|
||||
Schedule map[string]string `json:"schedule,omitempty"`
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("point", fmt.Sprintf("%f,%f", lng, lat))
|
||||
params.Set("radius", fmt.Sprintf("%d", radius))
|
||||
params.Set("fields", "items.point,items.address,items.reviews,items.schedule")
|
||||
params.Set("page_size", "10")
|
||||
|
||||
result, err := c.doRequest(ctx, "/3.0/items", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
places := make([]TwoGISPlace, 0, len(result.Result.Items))
|
||||
for _, item := range result.Result.Items {
|
||||
if item.Point == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
addr := item.AddressName
|
||||
if addr == "" {
|
||||
addr = item.FullName
|
||||
}
|
||||
|
||||
place := TwoGISPlace{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Address: addr,
|
||||
Lat: item.Point.Lat,
|
||||
Lng: item.Point.Lon,
|
||||
Type: item.Type,
|
||||
Purpose: item.PurposeName,
|
||||
}
|
||||
|
||||
if item.Reviews != nil {
|
||||
place.Rating = item.Reviews.GeneralRating
|
||||
place.ReviewCount = item.Reviews.GeneralReviewCount
|
||||
if place.Rating == 0 {
|
||||
place.Rating = item.Reviews.OrgRating
|
||||
}
|
||||
if place.ReviewCount == 0 {
|
||||
place.ReviewCount = item.Reviews.OrgReviewCount
|
||||
}
|
||||
}
|
||||
|
||||
if item.Schedule != nil {
|
||||
place.Schedule = formatSchedule(item.Schedule)
|
||||
}
|
||||
|
||||
places = append(places, place)
|
||||
}
|
||||
|
||||
return places, nil
|
||||
}
|
||||
|
||||
func formatSchedule(sched map[string]twoGISScheduleDay) map[string]string {
|
||||
dayOrder := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
dayRu := map[string]string{
|
||||
"Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт",
|
||||
"Fri": "Пт", "Sat": "Сб", "Sun": "Вс",
|
||||
}
|
||||
result := make(map[string]string, len(sched))
|
||||
for _, d := range dayOrder {
|
||||
if day, ok := sched[d]; ok && len(day.WorkingHours) > 0 {
|
||||
wh := day.WorkingHours[0]
|
||||
result[dayRu[d]] = wh.From + "–" + wh.To
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const twoGISRoutingBaseURL = "https://routing.api.2gis.com"
|
||||
|
||||
type twoGISRoutingRequest struct {
|
||||
Points []twoGISRoutePoint `json:"points"`
|
||||
Transport string `json:"transport"`
|
||||
Output string `json:"output"`
|
||||
Locale string `json:"locale"`
|
||||
}
|
||||
|
||||
type twoGISRoutePoint struct {
|
||||
Type string `json:"type"`
|
||||
Lon float64 `json:"lon"`
|
||||
Lat float64 `json:"lat"`
|
||||
}
|
||||
|
||||
type twoGISRoutingResponse struct {
|
||||
Message string `json:"message"`
|
||||
Result []twoGISRoutingResult `json:"result"`
|
||||
}
|
||||
|
||||
type twoGISRoutingResult struct {
|
||||
ID string `json:"id"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
TotalDistance int `json:"total_distance"`
|
||||
TotalDuration int `json:"total_duration"`
|
||||
Maneuvers []twoGISManeuver `json:"maneuvers"`
|
||||
}
|
||||
|
||||
type twoGISManeuver struct {
|
||||
ID string `json:"id"`
|
||||
Comment string `json:"comment"`
|
||||
Type string `json:"type"`
|
||||
OutcomingPath *twoGISOutcomingPath `json:"outcoming_path,omitempty"`
|
||||
}
|
||||
|
||||
type twoGISOutcomingPath struct {
|
||||
Distance int `json:"distance"`
|
||||
Duration int `json:"duration"`
|
||||
Geometry []twoGISPathGeometry `json:"geometry"`
|
||||
}
|
||||
|
||||
type twoGISPathGeometry struct {
|
||||
Selection string `json:"selection"`
|
||||
Length int `json:"length"`
|
||||
}
|
||||
|
||||
func (c *TwoGISClient) GetRoute(ctx context.Context, points []GeoLocation, transport string) (*RouteDirection, error) {
|
||||
if len(points) < 2 {
|
||||
return nil, fmt.Errorf("at least 2 points required for routing")
|
||||
}
|
||||
if transport == "" {
|
||||
transport = "driving"
|
||||
}
|
||||
|
||||
routePoints := make([]twoGISRoutePoint, len(points))
|
||||
for i, p := range points {
|
||||
routePoints[i] = twoGISRoutePoint{
|
||||
Type: "stop",
|
||||
Lon: p.Lng,
|
||||
Lat: p.Lat,
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := twoGISRoutingRequest{
|
||||
Points: routePoints,
|
||||
Transport: transport,
|
||||
Output: "detailed",
|
||||
Locale: "ru",
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal routing request: %w", err)
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("%s/routing/7.0.0/global?key=%s", twoGISRoutingBaseURL, url.QueryEscape(c.apiKey))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create routing request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute routing request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read routing response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("2GIS Routing API error %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var routingResp twoGISRoutingResponse
|
||||
if err := json.Unmarshal(body, &routingResp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal routing response: %w", err)
|
||||
}
|
||||
|
||||
if routingResp.Message != "" && routingResp.Message != "OK" && len(routingResp.Result) == 0 {
|
||||
return nil, fmt.Errorf("2GIS Routing error: %s", routingResp.Message)
|
||||
}
|
||||
|
||||
if len(routingResp.Result) == 0 {
|
||||
return nil, fmt.Errorf("no route found")
|
||||
}
|
||||
|
||||
route := routingResp.Result[0]
|
||||
|
||||
var allCoords [][2]float64
|
||||
var steps []RouteStep
|
||||
|
||||
for _, m := range route.Maneuvers {
|
||||
if m.OutcomingPath != nil {
|
||||
for _, geom := range m.OutcomingPath.Geometry {
|
||||
coords := parseWKTLineString(geom.Selection)
|
||||
allCoords = append(allCoords, coords...)
|
||||
}
|
||||
|
||||
if m.Comment != "" {
|
||||
steps = append(steps, RouteStep{
|
||||
Instruction: m.Comment,
|
||||
Distance: float64(m.OutcomingPath.Distance),
|
||||
Duration: float64(m.OutcomingPath.Duration),
|
||||
Type: m.Type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &RouteDirection{
|
||||
Geometry: RouteGeometry{
|
||||
Coordinates: allCoords,
|
||||
Type: "LineString",
|
||||
},
|
||||
Distance: float64(route.TotalDistance),
|
||||
Duration: float64(route.TotalDuration),
|
||||
Steps: steps,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseWKTLineString(wkt string) [][2]float64 {
|
||||
wkt = strings.TrimSpace(wkt)
|
||||
if !strings.HasPrefix(wkt, "LINESTRING(") {
|
||||
return nil
|
||||
}
|
||||
|
||||
inner := wkt[len("LINESTRING(") : len(wkt)-1]
|
||||
pairs := strings.Split(inner, ",")
|
||||
coords := make([][2]float64, 0, len(pairs))
|
||||
|
||||
for _, pair := range pairs {
|
||||
pair = strings.TrimSpace(pair)
|
||||
parts := strings.Fields(pair)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
lon, err1 := strconv.ParseFloat(parts[0], 64)
|
||||
lat, err2 := strconv.ParseFloat(parts[1], 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
coords = append(coords, [2]float64{lon, lat})
|
||||
}
|
||||
|
||||
return coords
|
||||
}
|
||||
203
backend/internal/travel/types.go
Normal file
203
backend/internal/travel/types.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package travel
|
||||
|
||||
import "time"
|
||||
|
||||
type TripStatus string
|
||||
|
||||
const (
|
||||
TripStatusPlanned TripStatus = "planned"
|
||||
TripStatusBooked TripStatus = "booked"
|
||||
TripStatusCompleted TripStatus = "completed"
|
||||
TripStatusCancelled TripStatus = "cancelled"
|
||||
)
|
||||
|
||||
type RoutePointType string
|
||||
|
||||
const (
|
||||
RoutePointAirport RoutePointType = "airport"
|
||||
RoutePointHotel RoutePointType = "hotel"
|
||||
RoutePointAttraction RoutePointType = "attraction"
|
||||
RoutePointRestaurant RoutePointType = "restaurant"
|
||||
RoutePointTransport RoutePointType = "transport"
|
||||
RoutePointCustom RoutePointType = "custom"
|
||||
)
|
||||
|
||||
type Trip struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
Title string `json:"title"`
|
||||
Destination string `json:"destination"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CoverImage string `json:"coverImage,omitempty"`
|
||||
StartDate time.Time `json:"startDate"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
Route []RoutePoint `json:"route"`
|
||||
Flights []FlightOffer `json:"flights,omitempty"`
|
||||
Hotels []HotelOffer `json:"hotels,omitempty"`
|
||||
TotalBudget float64 `json:"totalBudget,omitempty"`
|
||||
Currency string `json:"currency"`
|
||||
Status TripStatus `json:"status"`
|
||||
AISummary string `json:"aiSummary,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type RoutePoint struct {
|
||||
ID string `json:"id"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Type RoutePointType `json:"type"`
|
||||
AIComment string `json:"aiComment,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Cost float64 `json:"cost,omitempty"`
|
||||
Order int `json:"order"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
}
|
||||
|
||||
type FlightOffer struct {
|
||||
ID string `json:"id"`
|
||||
Airline string `json:"airline"`
|
||||
AirlineLogo string `json:"airlineLogo,omitempty"`
|
||||
FlightNumber string `json:"flightNumber"`
|
||||
DepartureAirport string `json:"departureAirport"`
|
||||
DepartureCity string `json:"departureCity"`
|
||||
DepartureTime string `json:"departureTime"`
|
||||
ArrivalAirport string `json:"arrivalAirport"`
|
||||
ArrivalCity string `json:"arrivalCity"`
|
||||
ArrivalTime string `json:"arrivalTime"`
|
||||
Duration int `json:"duration"`
|
||||
Stops int `json:"stops"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
CabinClass string `json:"cabinClass"`
|
||||
SeatsAvailable int `json:"seatsAvailable,omitempty"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
}
|
||||
|
||||
type HotelOffer struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Rating float64 `json:"rating"`
|
||||
ReviewCount int `json:"reviewCount,omitempty"`
|
||||
Stars int `json:"stars,omitempty"`
|
||||
Price float64 `json:"price"`
|
||||
PricePerNight float64 `json:"pricePerNight"`
|
||||
Currency string `json:"currency"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
RoomType string `json:"roomType,omitempty"`
|
||||
Amenities []string `json:"amenities,omitempty"`
|
||||
Photos []string `json:"photos,omitempty"`
|
||||
BookingURL string `json:"bookingUrl,omitempty"`
|
||||
}
|
||||
|
||||
type TravelPlanRequest struct {
|
||||
Query string `json:"query"`
|
||||
StartDate string `json:"startDate,omitempty"`
|
||||
EndDate string `json:"endDate,omitempty"`
|
||||
Travelers int `json:"travelers,omitempty"`
|
||||
Budget float64 `json:"budget,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Preferences *TravelPreferences `json:"preferences,omitempty"`
|
||||
History [][2]string `json:"history,omitempty"`
|
||||
}
|
||||
|
||||
type TravelPreferences struct {
|
||||
TravelStyle string `json:"travelStyle,omitempty"`
|
||||
Interests []string `json:"interests,omitempty"`
|
||||
AvoidTypes []string `json:"avoidTypes,omitempty"`
|
||||
TransportModes []string `json:"transportModes,omitempty"`
|
||||
}
|
||||
|
||||
type TravelSuggestion struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Image string `json:"image,omitempty"`
|
||||
Price float64 `json:"price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lng float64 `json:"lng,omitempty"`
|
||||
}
|
||||
|
||||
type GeoLocation struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
}
|
||||
|
||||
type RouteDirection struct {
|
||||
Geometry RouteGeometry `json:"geometry"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Steps []RouteStep `json:"steps,omitempty"`
|
||||
}
|
||||
|
||||
type RouteGeometry struct {
|
||||
Coordinates [][2]float64 `json:"coordinates"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type RouteStep struct {
|
||||
Instruction string `json:"instruction"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type FlightSearchRequest struct {
|
||||
Origin string `json:"origin"`
|
||||
Destination string `json:"destination"`
|
||||
DepartureDate string `json:"departureDate"`
|
||||
ReturnDate string `json:"returnDate,omitempty"`
|
||||
Adults int `json:"adults"`
|
||||
Children int `json:"children,omitempty"`
|
||||
CabinClass string `json:"cabinClass,omitempty"`
|
||||
MaxPrice int `json:"maxPrice,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type HotelSearchRequest struct {
|
||||
CityCode string `json:"cityCode"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lng float64 `json:"lng,omitempty"`
|
||||
Radius int `json:"radius,omitempty"`
|
||||
CheckIn string `json:"checkIn"`
|
||||
CheckOut string `json:"checkOut"`
|
||||
Adults int `json:"adults"`
|
||||
Rooms int `json:"rooms,omitempty"`
|
||||
MaxPrice int `json:"maxPrice,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
}
|
||||
|
||||
type POISearchRequest struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Radius int `json:"radius"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type POI struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lng float64 `json:"lng"`
|
||||
Category string `json:"category"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
OpeningHours string `json:"openingHours,omitempty"`
|
||||
Rating float64 `json:"rating,omitempty"`
|
||||
Distance float64 `json:"distance,omitempty"`
|
||||
}
|
||||
@@ -12,6 +12,17 @@ const (
|
||||
WidgetImageGallery WidgetType = "image_gallery"
|
||||
WidgetVideoEmbed WidgetType = "video_embed"
|
||||
WidgetKnowledge WidgetType = "knowledge_card"
|
||||
|
||||
WidgetTravelMap WidgetType = "travel_map"
|
||||
WidgetTravelEvents WidgetType = "travel_events"
|
||||
WidgetTravelPOI WidgetType = "travel_poi"
|
||||
WidgetTravelHotels WidgetType = "travel_hotels"
|
||||
WidgetTravelTransport WidgetType = "travel_transport"
|
||||
WidgetTravelItinerary WidgetType = "travel_itinerary"
|
||||
WidgetTravelBudget WidgetType = "travel_budget"
|
||||
WidgetTravelClarifying WidgetType = "travel_clarifying"
|
||||
WidgetTravelActions WidgetType = "travel_actions"
|
||||
WidgetTravelContext WidgetType = "travel_context"
|
||||
)
|
||||
|
||||
type ProductData struct {
|
||||
|
||||
Reference in New Issue
Block a user