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

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

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

View File

@@ -0,0 +1,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
}