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