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

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

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

View File

@@ -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",
"новост", "сегодня", "последн", "актуальн",

View File

@@ -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: ")

View File

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

View 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"`
}

View 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
}

View 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
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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
}

View 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"`
}