Files
gooseek/backend/internal/travel/service.go
home 08bd41e75c 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
2026-03-01 21:58:32 +03:00

661 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}