From 08bd41e75c0ada0e881ec0e3dc28761cab7c659b Mon Sep 17 00:00:00 2001 From: home Date: Sun, 1 Mar 2026 21:58:32 +0300 Subject: [PATCH] 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 --- CONTINUE.md | 188 +-- backend/cmd/agent-svc/main.go | 7 +- backend/cmd/api-gateway/main.go | 3 + backend/cmd/chat-svc/main.go | 2 +- backend/cmd/finance-heatmap-svc/main.go | 1 + backend/cmd/travel-svc/main.go | 540 +++++++ backend/deploy/docker/Dockerfile.agent-svc | 2 +- backend/deploy/docker/Dockerfile.all | 3 +- backend/deploy/docker/Dockerfile.api-gateway | 2 +- backend/deploy/docker/Dockerfile.chat-svc | 2 +- backend/deploy/docker/Dockerfile.discover-svc | 2 +- backend/deploy/docker/Dockerfile.search-svc | 2 +- backend/deploy/docker/docker-compose.yml | 53 +- backend/deploy/docker/searxng/settings.yml | 23 + backend/internal/agent/focus_modes.go | 25 + backend/internal/agent/orchestrator.go | 15 +- .../agent/travel_context_collector.go | 241 ++++ backend/internal/agent/travel_data_client.go | 454 ++++++ .../internal/agent/travel_events_collector.go | 467 +++++++ .../agent/travel_flights_collector.go | 266 ++++ .../internal/agent/travel_hotels_collector.go | 420 ++++++ backend/internal/agent/travel_orchestrator.go | 1148 +++++++++++++++ .../internal/agent/travel_poi_collector.go | 694 +++++++++ backend/internal/agent/travel_types.go | 225 +++ backend/internal/finance/heatmap.go | 231 +-- backend/internal/finance/providers.go | 406 ++++++ backend/internal/travel/amadeus.go | 368 +++++ backend/internal/travel/llm_client.go | 57 + backend/internal/travel/openroute.go | 335 +++++ backend/internal/travel/repository.go | 501 +++++++ backend/internal/travel/service.go | 660 +++++++++ backend/internal/travel/travelpayouts.go | 276 ++++ backend/internal/travel/twogis.go | 465 +++++++ backend/internal/travel/types.go | 203 +++ backend/internal/types/widgets.go | 11 + backend/pkg/config/config.go | 9 + backend/webui/next.config.mjs | 10 +- backend/webui/package-lock.json | 63 + backend/webui/package.json | 4 + .../webui/src/app/(main)/admin/audit/page.tsx | 10 +- .../src/app/(main)/admin/discover/page.tsx | 26 +- backend/webui/src/app/(main)/admin/page.tsx | 2 +- .../webui/src/app/(main)/admin/posts/page.tsx | 18 +- .../src/app/(main)/admin/settings/page.tsx | 14 +- .../webui/src/app/(main)/admin/users/page.tsx | 22 +- backend/webui/src/app/(main)/finance/page.tsx | 2 +- .../webui/src/app/(main)/medicine/page.tsx | 22 +- .../webui/src/app/(main)/spaces/[id]/page.tsx | 4 +- .../webui/src/app/(main)/spaces/new/page.tsx | 6 +- backend/webui/src/app/(main)/spaces/page.tsx | 6 +- backend/webui/src/app/(main)/travel/page.tsx | 977 +++++++++---- backend/webui/src/app/api/chat/route.ts | 50 + .../webui/src/app/api/v1/[...path]/route.ts | 131 ++ backend/webui/src/app/globals.css | 456 +++--- backend/webui/src/app/layout.tsx | 2 +- backend/webui/src/components/ChatInput.tsx | 2 +- backend/webui/src/components/ChatMessage.tsx | 2 +- backend/webui/src/components/Citation.tsx | 32 +- backend/webui/src/components/DiscoverCard.tsx | 30 +- backend/webui/src/components/Sidebar.tsx | 2 +- backend/webui/src/components/TravelMap.tsx | 582 ++++++++ .../webui/src/components/TravelWidgets.tsx | 1236 +++++++++++++++++ .../webui/src/components/auth/AuthModal.tsx | 2 +- .../src/components/settings/AccountTab.tsx | 2 +- .../src/components/settings/BillingTab.tsx | 2 +- .../settings/ConnectorsSettings.tsx | 26 +- .../components/settings/PreferencesTab.tsx | 4 +- backend/webui/src/lib/api.ts | 347 +++++ backend/webui/src/lib/hooks/useTravelChat.ts | 478 +++++++ backend/webui/src/lib/types.ts | 402 +++++- backend/webui/tailwind.config.ts | 28 +- 71 files changed, 12364 insertions(+), 945 deletions(-) create mode 100644 backend/cmd/travel-svc/main.go create mode 100644 backend/deploy/docker/searxng/settings.yml create mode 100644 backend/internal/agent/travel_context_collector.go create mode 100644 backend/internal/agent/travel_data_client.go create mode 100644 backend/internal/agent/travel_events_collector.go create mode 100644 backend/internal/agent/travel_flights_collector.go create mode 100644 backend/internal/agent/travel_hotels_collector.go create mode 100644 backend/internal/agent/travel_orchestrator.go create mode 100644 backend/internal/agent/travel_poi_collector.go create mode 100644 backend/internal/agent/travel_types.go create mode 100644 backend/internal/finance/providers.go create mode 100644 backend/internal/travel/amadeus.go create mode 100644 backend/internal/travel/llm_client.go create mode 100644 backend/internal/travel/openroute.go create mode 100644 backend/internal/travel/repository.go create mode 100644 backend/internal/travel/service.go create mode 100644 backend/internal/travel/travelpayouts.go create mode 100644 backend/internal/travel/twogis.go create mode 100644 backend/internal/travel/types.go create mode 100644 backend/webui/src/app/api/chat/route.ts create mode 100644 backend/webui/src/app/api/v1/[...path]/route.ts create mode 100644 backend/webui/src/components/TravelMap.tsx create mode 100644 backend/webui/src/components/TravelWidgets.tsx create mode 100644 backend/webui/src/lib/hooks/useTravelChat.ts diff --git a/CONTINUE.md b/CONTINUE.md index bb3b77c..ade0c6c 100644 --- a/CONTINUE.md +++ b/CONTINUE.md @@ -1,141 +1,87 @@ # Недоделки — начать отсюда -## Последнее исправление (28.02.2026) +## Последнее изменение (01.03.2026) -### Fix: auth-svc не собирался в Docker -- Добавлена строка сборки `auth-svc` в `Dockerfile.all` -- Удалён дубликат `ratelimit_tiered.go` (конфликт типов с Redis-версией) -- Добавлен REDIS_URL в api-gateway для rate limiting -- Пересоздан тестовый пользователь через API +### СДЕЛАНО: Маршруты по дорогам + стоимость проезда + +#### Что сделано: + +**1. `backend/internal/travel/twogis.go` — 2GIS Routing API клиент:** +- Метод `GetRoute(ctx, points, transport)` — POST `routing.api.2gis.com/routing/7.0.0/global` +- Поддержка transport: `driving`, `taxi`, `walking`, `bicycle` +- Парсинг WKT LINESTRING из `outcoming_path.geometry[].selection` +- Сборка `RouteDirection` с geometry, distance, duration, steps + +**2. `backend/internal/travel/service.go` — переключение на 2GIS:** +- `GetRoute()` сначала пробует 2GIS Routing, fallback на OpenRouteService +- `mapProfileToTwoGISTransport()` — маппинг профилей + +**3. `backend/internal/agent/travel_data_client.go` — обновлённый клиент:** +- `GetRoute(ctx, points, transport)` — полный `RouteDirectionResult` с geometry +- `GetRouteSegments()` — маршруты между каждой парой точек +- Новые типы: `RouteDirectionResult`, `RouteGeometryResult`, `RouteStepResult`, `RouteSegmentResult` + +**4. `backend/internal/agent/travel_orchestrator.go` — дорожные маршруты:** +- `emitTravelWidgets()` вызывает `buildRoadRoute()` вместо прямых линий +- `buildTransportSegments()` — маршруты между каждой парой точек +- `calculateTransportCosts()` — расчёт стоимости (машина ~8₽/км, автобус ~2.5₽/км, такси ~100₽+18₽/км) +- `routeDirection` и `segments` передаются в виджеты `travel_map` и `travel_itinerary` + +**5. Фронтенд — отображение дорожных маршрутов:** +- `types.ts` — новые типы `RouteSegment`, `TransportCostOption`, расширены `TravelMapWidgetParams` и `TravelItineraryWidgetParams` +- `useTravelChat.ts` — извлечение `routeDirection` и `segments` из виджетов, новые state +- `travel/page.tsx` — передача `routeDirection` в `TravelMap` +- `TravelWidgets.tsx` — `TransportSegmentCard` между элементами маршрута с иконками машина/автобус/такси и ценами --- -## Последнее изменение (28.02.2026) +### СДЕЛАНО ранее: Переработка POI коллектора — 2GIS как основной источник -**Обновлён дизайн страниц авторизации:** - -### Сделано (полностью) - -#### Страницы авторизации в стиле сайта -- `/login` — переделана с градиентным фоном, фирменным логотипом, анимациями -- `/register` — аналогично, единый стиль с login -- `/forgot-password` — сброс пароля в новом дизайне -- `/reset-password` — установка нового пароля в новом дизайне - -Изменения: -- `bg-gradient-main` вместо простого `bg-base` -- Фирменный логотип GooSeek (font-black italic + иконка Sparkles) -- Gradient blur декорации на фоне -- Карточка с градиентной рамкой и backdrop-blur -- Анимации появления (framer-motion) -- Footer с копирайтом +(см. предыдущую версию CONTINUE.md) --- -**Ранее: Spaces как у Perplexity + коллаборация:** - -### Сделано (полностью) - -#### 1. Backend - Space Members -- Добавлена таблица `space_members` (space_id, user_id, role) -- Добавлена таблица `space_invites` (приглашения по токену) -- Методы: `GetMembers`, `AddMember`, `RemoveMember`, `IsMember` -- Методы: `CreateInvite`, `GetInviteByToken`, `DeleteInvite` -- Обновлён `GetByMemberID` - возвращает пространства где пользователь участник - -#### 2. Frontend - Types & API -- Добавлены типы: `SpaceMember`, `SpaceInvite` -- Обновлён тип `Space` с полями `members`, `memberCount`, `userId` -- API методы: `fetchSpaceMembers`, `inviteToSpace`, `removeSpaceMember`, `acceptSpaceInvite`, `fetchSpaceThreads` - -#### 3. Страница /spaces - Новый дизайн -- Карточки пространств с градиентным фоном -- Показывает: название, описание, кол-во участников, кол-во тредов -- Аватары участников на карточке -- Поиск по пространствам -- Требует авторизации - -#### 4. Страница /spaces/[id] - Детали пространства -- Заголовок с названием и описанием -- Табы: Треды / Участники -- Список тредов пространства с датами -- Список участников с ролями (owner/admin/member) -- Кнопка "Начать новый тред" -- Модалка приглашения участников по email -- Удаление участников (для admin/owner) - -#### 5. Страница /spaces/new - Создание -- Превью карточки в реальном времени -- Выбор иконки (эмодзи) -- Выбор цвета (6 вариантов) -- Переключатель приватности -- AI инструкции для пространства - -#### 6. Ранее добавлено -- Селектор модели (Auto/GooSeek 1.0) -- Ollama клиент для бесплатной модели -- Обновлённая кнопка "Войти" в сайдбаре - -### Файлы изменены/созданы - -``` -backend/internal/db/ -└── space_repo.go (UPDATED - members, invites) - -backend/webui/src/ -├── lib/ -│ ├── types.ts (UPDATED - SpaceMember, SpaceInvite) -│ └── api.ts (UPDATED - space members API) -├── app/(main)/spaces/ -│ ├── page.tsx (REWRITTEN - новый дизайн) -│ ├── new/page.tsx (REWRITTEN - с превью) -│ └── [id]/page.tsx (NEW - детали пространства) -└── components/ - ├── Sidebar.tsx (UPDATED - кнопка войти) - └── ChatInput.tsx (UPDATED - селектор модели) - -backend/internal/llm/ -├── ollama.go (NEW) -└── client.go (UPDATED) - -backend/pkg/config/config.go (UPDATED - Ollama config) -backend/cmd/agent-svc/main.go (UPDATED - Ollama support) -``` - ## Осталось сделать -### Высокий приоритет: -1. **Backend API endpoints** — добавить API для: - - `GET /api/v1/spaces/:id/members` - - `POST /api/v1/spaces/:id/invite` - - `DELETE /api/v1/spaces/:id/members/:userId` - - `POST /api/v1/spaces/invite/:token/accept` - - `GET /api/v1/spaces/:id/threads` +### Высокий приоритет -2. **Email отправка** — отправлять email с приглашением +1. **Цены отелей из SearXNG** — LLM не всегда извлекает цены (0 RUB/night). Нужно: + - Добавить fallback: если цена 0, попробовать парсить из snippet + - Файл: `backend/internal/agent/travel_hotels_collector.go` -3. **Ollama в docker-compose** — добавить сервис ollama +2. **Авиабилеты для маршрутов** — "Золотое кольцо" не имеет IATA кода. Нужно: + - Если destination не IATA, искать билеты до первого конкретного города в маршруте + - Файл: `backend/internal/agent/travel_flights_collector.go` -### Средний приоритет: -4. **OAuth провайдеры** — Google, GitHub, Yandex -5. **Подтверждение email** — отправка письма при регистрации -6. **Real-time обновления** — WebSocket для тредов в пространстве -7. **Уведомления** — когда кто-то добавляет сообщение в тред +### Средний приоритет -### Низкий приоритет: -8. **Интеграция оплаты** — ЮKassa для пополнения баланса -9. **2FA** — TOTP аутентификация -10. **Экспорт тредов** — PDF/Markdown +3. **Drag & drop в ItineraryWidget** — перетаскивание элементов между днями +4. **Кеш SearXNG результатов** — Redis кеш на 10-30 минут +5. **Сохранение draft в БД** — персистентность TripDraft через trip_drafts таблицу + +### Низкий приоритет + +6. **Экспорт маршрута** — PDF/Markdown +7. **Real-time обновления** — WebSocket для тредов + +--- ## Контекст -### Модели: -| ID | Provider | Тариф | -|----|----------|-------| -| auto | ollama | Бесплатно | -| gooseek-1.0 | timeweb | По тарифу | +### Архитектура travel pipeline: -### Роли в пространстве: -- `owner` — создатель, полные права -- `admin` — может приглашать/удалять участников -- `member` — может создавать треды +``` +User -> /travel page -> streamTravelAgent() -> api-gateway -> chat-svc -> agent-svc + -> RunTravelOrchestrator: + 1. Planner Agent (LLM) -> TripBrief + 2. Geocode destinations -> travel-svc -> 2GIS Geocoder API + 3. Parallel collectors: + - Events: SearXNG -> Crawl4AI -> LLM extraction -> geocode (2GIS) + - POI: 2GIS Places API (primary) -> LLM enrichment -> SearXNG fallback + - Hotels: SearXNG -> Crawl4AI -> LLM extraction -> geocode (2GIS) + - Transport: TravelPayouts API + 4. Itinerary Builder (LLM) -> ItineraryDay[] + 5. Road routing: 2GIS Routing API -> RouteDirection (дорожная геометрия) + 6. Transport costs: calculateTransportCosts() -> машина/автобус/такси + 7. Widget emission -> NDJSON stream -> frontend (карта 2GIS MapGL) +``` diff --git a/backend/cmd/agent-svc/main.go b/backend/cmd/agent-svc/main.go index 4c062b5..3a991ca 100644 --- a/backend/cmd/agent-svc/main.go +++ b/backend/cmd/agent-svc/main.go @@ -73,7 +73,7 @@ func main() { agents := app.Group("/api/v1/agents", middleware.JWT(middleware.JWTConfig{ Secret: cfg.JWTSecret, AuthSvcURL: cfg.AuthSvcURL, - AllowGuest: false, + AllowGuest: true, })) agents.Post("/search", func(c *fiber.Ctx) error { @@ -162,7 +162,10 @@ func main() { ResponsePrefs: responsePrefs, LearningMode: req.LearningMode, DiscoverSvcURL: cfg.DiscoverSvcURL, - Crawl4AIURL: cfg.Crawl4AIURL, + Crawl4AIURL: cfg.Crawl4AIURL, + TravelSvcURL: cfg.TravelSvcURL, + TravelPayoutsToken: cfg.TravelPayoutsToken, + TravelPayoutsMarker: cfg.TravelPayoutsMarker, }, } diff --git a/backend/cmd/api-gateway/main.go b/backend/cmd/api-gateway/main.go index 02c179c..987d239 100644 --- a/backend/cmd/api-gateway/main.go +++ b/backend/cmd/api-gateway/main.go @@ -53,6 +53,7 @@ func main() { "discover": cfg.DiscoverSvcURL, "finance": cfg.FinanceHeatmapURL, "learning": cfg.LearningSvcURL, + "travel": cfg.TravelSvcURL, "admin": cfg.AdminSvcURL, } @@ -139,6 +140,8 @@ func getTarget(path string) (base, rewrite string) { return svcURLs["finance"], path case strings.HasPrefix(path, "/api/v1/learning"): return svcURLs["learning"], path + case strings.HasPrefix(path, "/api/v1/travel"): + return svcURLs["travel"], path case strings.HasPrefix(path, "/api/v1/admin"): return svcURLs["admin"], path default: diff --git a/backend/cmd/chat-svc/main.go b/backend/cmd/chat-svc/main.go index 048ca62..1c72094 100644 --- a/backend/cmd/chat-svc/main.go +++ b/backend/cmd/chat-svc/main.go @@ -97,7 +97,7 @@ func main() { chat := app.Group("/api/v1/chat", middleware.JWT(middleware.JWTConfig{ Secret: cfg.JWTSecret, AuthSvcURL: cfg.AuthSvcURL, - AllowGuest: false, + AllowGuest: true, })) chat.Post("/", func(c *fiber.Ctx) error { diff --git a/backend/cmd/finance-heatmap-svc/main.go b/backend/cmd/finance-heatmap-svc/main.go index 5432bd3..44ce081 100644 --- a/backend/cmd/finance-heatmap-svc/main.go +++ b/backend/cmd/finance-heatmap-svc/main.go @@ -15,6 +15,7 @@ import ( func main() { heatmapSvc := finance.NewHeatmapService(finance.HeatmapConfig{ + DataProviderURL: os.Getenv("FINANCE_DATA_PROVIDER_URL"), CacheTTL: 5 * time.Minute, RefreshInterval: time.Minute, }) diff --git a/backend/cmd/travel-svc/main.go b/backend/cmd/travel-svc/main.go new file mode 100644 index 0000000..258d66d --- /dev/null +++ b/backend/cmd/travel-svc/main.go @@ -0,0 +1,540 @@ +package main + +import ( + "bufio" + "context" + "database/sql" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gooseek/backend/internal/llm" + "github.com/gooseek/backend/internal/travel" + "github.com/gooseek/backend/pkg/middleware" + _ "github.com/lib/pq" +) + +func main() { + db, err := sql.Open("postgres", os.Getenv("DATABASE_URL")) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + if err := db.Ping(); err != nil { + log.Fatalf("Failed to ping database: %v", err) + } + + repo := travel.NewRepository(db) + if err := repo.InitSchema(context.Background()); err != nil { + log.Printf("Warning: Failed to init schema: %v", err) + } + + llmCfg := llm.ProviderConfig{ + ProviderID: getEnv("LLM_PROVIDER", "timeweb"), + ModelKey: getEnv("LLM_MODEL", "gpt-4o-mini"), + BaseURL: os.Getenv("TIMEWEB_API_BASE_URL"), + APIKey: os.Getenv("TIMEWEB_API_KEY"), + AgentAccessID: os.Getenv("TIMEWEB_AGENT_ACCESS_ID"), + } + + llmBaseClient, err := llm.NewClient(llmCfg) + if err != nil { + log.Printf("Warning: Failed to create LLM client: %v", err) + } + + var llmClient travel.LLMClient + if llmBaseClient != nil { + llmClient = travel.NewLLMClientAdapter(llmBaseClient) + } + + useRussianAPIs := getEnv("USE_RUSSIAN_APIS", "true") == "true" + + svc := travel.NewService(travel.ServiceConfig{ + Repository: repo, + AmadeusConfig: travel.AmadeusConfig{ + APIKey: os.Getenv("AMADEUS_API_KEY"), + APISecret: os.Getenv("AMADEUS_API_SECRET"), + BaseURL: getEnv("AMADEUS_BASE_URL", "https://test.api.amadeus.com"), + }, + OpenRouteConfig: travel.OpenRouteConfig{ + APIKey: os.Getenv("OPENROUTE_API_KEY"), + BaseURL: getEnv("OPENROUTE_BASE_URL", "https://api.openrouteservice.org"), + }, + TravelPayoutsConfig: travel.TravelPayoutsConfig{ + Token: os.Getenv("TRAVELPAYOUTS_TOKEN"), + Marker: os.Getenv("TRAVELPAYOUTS_MARKER"), + }, + TwoGISConfig: travel.TwoGISConfig{ + APIKey: os.Getenv("TWOGIS_API_KEY"), + }, + LLMClient: llmClient, + UseRussianAPIs: useRussianAPIs, + }) + + app := fiber.New(fiber.Config{ + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + }) + + app.Use(logger.New()) + app.Use(cors.New()) + + app.Get("/health", func(c *fiber.Ctx) error { + return c.JSON(fiber.Map{"status": "ok", "service": "travel-svc"}) + }) + + jwtConfig := middleware.JWTConfig{ + Secret: os.Getenv("JWT_SECRET"), + AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"), + } + + jwtOptional := middleware.JWTConfig{ + Secret: os.Getenv("JWT_SECRET"), + AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"), + AllowGuest: true, + } + + api := app.Group("/api/v1/travel") + + api.Post("/plan", middleware.JWT(jwtOptional), handlePlanTrip(svc)) + + api.Get("/trips", middleware.JWT(jwtOptional), handleGetTrips(svc)) + api.Post("/trips", middleware.JWT(jwtConfig), handleCreateTrip(svc)) + api.Get("/trips/:id", middleware.JWT(jwtOptional), handleGetTrip(svc)) + api.Put("/trips/:id", middleware.JWT(jwtConfig), handleUpdateTrip(svc)) + api.Delete("/trips/:id", middleware.JWT(jwtConfig), handleDeleteTrip(svc)) + api.Get("/trips/upcoming", middleware.JWT(jwtOptional), handleGetUpcoming(svc)) + + api.Get("/flights", middleware.JWT(jwtOptional), handleSearchFlights(svc)) + api.Post("/flights", middleware.JWT(jwtOptional), handleSearchFlightsPost(svc)) + api.Get("/hotels", middleware.JWT(jwtOptional), handleSearchHotels(svc)) + api.Post("/hotels", middleware.JWT(jwtOptional), handleSearchHotelsPost(svc)) + api.Post("/route", middleware.JWT(jwtOptional), handleGetRoute(svc)) + api.Get("/geocode", middleware.JWT(jwtOptional), handleGeocode(svc)) + api.Get("/poi", middleware.JWT(jwtOptional), handleSearchPOI(svc)) + api.Post("/poi", middleware.JWT(jwtOptional), handleSearchPOIPost(svc)) + api.Post("/places", middleware.JWT(jwtOptional), handleSearchPlaces(svc)) + + port := getEnvInt("PORT", 3035) + log.Printf("travel-svc listening on :%d", port) + log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) +} + +func handlePlanTrip(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var req travel.TravelPlanRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) + } + + c.Set("Content-Type", "application/x-ndjson") + c.Set("Cache-Control", "no-cache") + c.Set("Transfer-Encoding", "chunked") + + c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { + ctx := context.Background() + if err := svc.PlanTrip(ctx, req, w); err != nil { + log.Printf("Plan trip error: %v", err) + } + }) + return nil + } +} + +func handleGetTrips(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + userID, _ := c.Locals("userId").(string) + if userID == "" { + return c.JSON(fiber.Map{"trips": []interface{}{}}) + } + + limit := c.QueryInt("limit", 20) + offset := c.QueryInt("offset", 0) + + trips, err := svc.GetUserTrips(c.Context(), userID, limit, offset) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"trips": trips}) + } +} + +func handleCreateTrip(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + userID := c.Locals("userId").(string) + + var trip travel.Trip + if err := c.BodyParser(&trip); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) + } + + trip.UserID = userID + if trip.Currency == "" { + trip.Currency = "RUB" + } + if trip.Status == "" { + trip.Status = travel.TripStatusPlanned + } + + if err := svc.CreateTrip(c.Context(), &trip); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(201).JSON(trip) + } +} + +func handleGetTrip(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + + trip, err := svc.GetTrip(c.Context(), id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + if trip == nil { + return c.Status(404).JSON(fiber.Map{"error": "Trip not found"}) + } + + return c.JSON(trip) + } +} + +func handleUpdateTrip(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + userID := c.Locals("userId").(string) + + existing, err := svc.GetTrip(c.Context(), id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + if existing == nil { + return c.Status(404).JSON(fiber.Map{"error": "Trip not found"}) + } + if existing.UserID != userID { + return c.Status(403).JSON(fiber.Map{"error": "Access denied"}) + } + + var trip travel.Trip + if err := c.BodyParser(&trip); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) + } + + trip.ID = id + trip.UserID = userID + + if err := svc.UpdateTrip(c.Context(), &trip); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(trip) + } +} + +func handleDeleteTrip(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + userID := c.Locals("userId").(string) + + existing, err := svc.GetTrip(c.Context(), id) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + if existing == nil { + return c.Status(404).JSON(fiber.Map{"error": "Trip not found"}) + } + if existing.UserID != userID { + return c.Status(403).JSON(fiber.Map{"error": "Access denied"}) + } + + if err := svc.DeleteTrip(c.Context(), id); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"success": true}) + } +} + +func handleGetUpcoming(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + userID, _ := c.Locals("userId").(string) + if userID == "" { + return c.JSON(fiber.Map{"trips": []interface{}{}}) + } + + trips, err := svc.GetUpcomingTrips(c.Context(), userID) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"trips": trips}) + } +} + +func handleSearchFlights(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + req := travel.FlightSearchRequest{ + Origin: c.Query("origin"), + Destination: c.Query("destination"), + DepartureDate: c.Query("departureDate"), + ReturnDate: c.Query("returnDate"), + Adults: c.QueryInt("adults", 1), + Children: c.QueryInt("children", 0), + CabinClass: c.Query("cabinClass"), + MaxPrice: c.QueryInt("maxPrice", 0), + Currency: c.Query("currency", "RUB"), + } + + if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" { + return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"}) + } + + flights, err := svc.SearchFlights(c.Context(), req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"flights": flights}) + } +} + +func handleSearchHotels(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + lat, _ := strconv.ParseFloat(c.Query("lat"), 64) + lng, _ := strconv.ParseFloat(c.Query("lng"), 64) + + req := travel.HotelSearchRequest{ + CityCode: c.Query("cityCode"), + Lat: lat, + Lng: lng, + Radius: c.QueryInt("radius", 5), + CheckIn: c.Query("checkIn"), + CheckOut: c.Query("checkOut"), + Adults: c.QueryInt("adults", 1), + Rooms: c.QueryInt("rooms", 1), + MaxPrice: c.QueryInt("maxPrice", 0), + Currency: c.Query("currency", "RUB"), + Rating: c.QueryInt("rating", 0), + } + + if req.CityCode == "" || req.CheckIn == "" || req.CheckOut == "" { + return c.Status(400).JSON(fiber.Map{"error": "cityCode, checkIn, checkOut required"}) + } + + hotels, err := svc.SearchHotels(c.Context(), req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"hotels": hotels}) + } +} + +func handleGetRoute(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Points []travel.GeoLocation `json:"points"` + Profile string `json:"profile"` + } + + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) + } + + if len(req.Points) < 2 { + return c.Status(400).JSON(fiber.Map{"error": "At least 2 points required"}) + } + + route, err := svc.GetRoute(c.Context(), req.Points, req.Profile) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(route) + } +} + +func handleGeocode(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + query := c.Query("query") + if query == "" { + query = c.Query("q") + } + if query == "" { + return c.Status(400).JSON(fiber.Map{"error": "query parameter required"}) + } + + location, err := svc.Geocode(c.Context(), query) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(location) + } +} + +func handleSearchPOI(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + lat, _ := strconv.ParseFloat(c.Query("lat"), 64) + lng, _ := strconv.ParseFloat(c.Query("lng"), 64) + + if lat == 0 || lng == 0 { + return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"}) + } + + req := travel.POISearchRequest{ + Lat: lat, + Lng: lng, + Radius: c.QueryInt("radius", 1000), + Limit: c.QueryInt("limit", 20), + } + + if categories := c.Query("categories"); categories != "" { + req.Categories = strings.Split(categories, ",") + } + + pois, err := svc.SearchPOI(c.Context(), req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{"pois": pois}) + } +} + +func handleSearchFlightsPost(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var req travel.FlightSearchRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" { + return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"}) + } + if req.Adults == 0 { + req.Adults = 1 + } + if req.Currency == "" { + req.Currency = "RUB" + } + + flights, err := svc.SearchFlights(c.Context(), req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(flights) + } +} + +func handleSearchHotelsPost(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var req travel.HotelSearchRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.CheckIn == "" || req.CheckOut == "" { + return c.Status(400).JSON(fiber.Map{"error": "checkIn, checkOut required"}) + } + if req.Adults == 0 { + req.Adults = 1 + } + if req.Rooms == 0 { + req.Rooms = 1 + } + if req.Currency == "" { + req.Currency = "RUB" + } + + hotels, err := svc.SearchHotels(c.Context(), req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(hotels) + } +} + +func handleSearchPOIPost(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var req travel.POISearchRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.Lat == 0 || req.Lng == 0 { + return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"}) + } + if req.Radius == 0 { + req.Radius = 1000 + } + if req.Limit == 0 { + req.Limit = 20 + } + + pois, err := svc.SearchPOI(c.Context(), req) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(pois) + } +} + +func handleSearchPlaces(svc *travel.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var req struct { + Query string `json:"query"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Radius int `json:"radius"` + } + if err := c.BodyParser(&req); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) + } + if req.Query == "" { + return c.Status(400).JSON(fiber.Map{"error": "query required"}) + } + if req.Lat == 0 || req.Lng == 0 { + return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"}) + } + if req.Radius == 0 { + req.Radius = 5000 + } + + places, err := svc.SearchPlaces(c.Context(), req.Query, req.Lat, req.Lng, req.Radius) + if err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(places) + } +} + +func getEnv(key, defaultValue string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if val := os.Getenv(key); val != "" { + if i, err := strconv.Atoi(val); err == nil { + return i + } + } + return defaultValue +} + diff --git a/backend/deploy/docker/Dockerfile.agent-svc b/backend/deploy/docker/Dockerfile.agent-svc index 0c2d276..fe3400a 100644 --- a/backend/deploy/docker/Dockerfile.agent-svc +++ b/backend/deploy/docker/Dockerfile.agent-svc @@ -1,5 +1,5 @@ # Dockerfile for agent-svc only -FROM golang:1.22-alpine AS builder +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git ca-certificates diff --git a/backend/deploy/docker/Dockerfile.all b/backend/deploy/docker/Dockerfile.all index 71b55e2..ac7d15e 100644 --- a/backend/deploy/docker/Dockerfile.all +++ b/backend/deploy/docker/Dockerfile.all @@ -24,6 +24,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/file-svc ./cmd/fi RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/thread-svc ./cmd/thread-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/finance-heatmap-svc ./cmd/finance-heatmap-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/learning-svc ./cmd/learning-svc +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/travel-svc ./cmd/travel-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/labs-svc ./cmd/labs-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/podcast-svc ./cmd/podcast-svc RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/admin-svc ./cmd/admin-svc @@ -42,7 +43,7 @@ COPY --from=builder /bin/* /app/ ENV SERVICE=api-gateway ENV PORT=3015 -EXPOSE 3015 3018 3005 3001 3020 3021 3002 3025 3026 3027 3040 +EXPOSE 3015 3018 3005 3001 3020 3021 3002 3025 3026 3027 3035 3040 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT}/health || exit 1 diff --git a/backend/deploy/docker/Dockerfile.api-gateway b/backend/deploy/docker/Dockerfile.api-gateway index c7695ed..33e2657 100644 --- a/backend/deploy/docker/Dockerfile.api-gateway +++ b/backend/deploy/docker/Dockerfile.api-gateway @@ -1,5 +1,5 @@ # Build stage -FROM golang:1.22-alpine AS builder +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git ca-certificates diff --git a/backend/deploy/docker/Dockerfile.chat-svc b/backend/deploy/docker/Dockerfile.chat-svc index 40eac4f..86d72ca 100644 --- a/backend/deploy/docker/Dockerfile.chat-svc +++ b/backend/deploy/docker/Dockerfile.chat-svc @@ -1,5 +1,5 @@ # Dockerfile for chat-svc only -FROM golang:1.22-alpine AS builder +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git ca-certificates diff --git a/backend/deploy/docker/Dockerfile.discover-svc b/backend/deploy/docker/Dockerfile.discover-svc index 44816b8..f6c4696 100644 --- a/backend/deploy/docker/Dockerfile.discover-svc +++ b/backend/deploy/docker/Dockerfile.discover-svc @@ -1,5 +1,5 @@ # Dockerfile for discover-svc only -FROM golang:1.22-alpine AS builder +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git ca-certificates diff --git a/backend/deploy/docker/Dockerfile.search-svc b/backend/deploy/docker/Dockerfile.search-svc index c1aca30..ddf8b23 100644 --- a/backend/deploy/docker/Dockerfile.search-svc +++ b/backend/deploy/docker/Dockerfile.search-svc @@ -1,5 +1,5 @@ # Dockerfile for search-svc only -FROM golang:1.22-alpine AS builder +FROM golang:1.24-alpine AS builder RUN apk add --no-cache git ca-certificates diff --git a/backend/deploy/docker/docker-compose.yml b/backend/deploy/docker/docker-compose.yml index 675040c..08b687b 100644 --- a/backend/deploy/docker/docker-compose.yml +++ b/backend/deploy/docker/docker-compose.yml @@ -39,6 +39,7 @@ services: - DISCOVER_SVC_URL=http://discover-svc:3002 - FINANCE_HEATMAP_SVC_URL=http://finance-heatmap-svc:3033 - LEARNING_SVC_URL=http://learning-svc:3034 + - TRAVEL_SVC_URL=http://travel-svc:3035 - ADMIN_SVC_URL=http://admin-svc:3040 - JWT_SECRET=${JWT_SECRET} - REDIS_URL=redis://redis:6379 @@ -50,6 +51,7 @@ services: - agent-svc - thread-svc - admin-svc + - travel-svc - redis networks: - gooseek @@ -61,6 +63,8 @@ services: environment: - SERVICE=chat-svc - PORT=3005 + - JWT_SECRET=${JWT_SECRET} + - AUTH_SVC_URL=http://auth-svc:3050 - MASTER_AGENTS_SVC_URL=http://agent-svc:3018 - DISCOVER_SVC_URL=http://discover-svc:3002 ports: @@ -78,9 +82,14 @@ services: environment: - SERVICE=agent-svc - PORT=3018 + - JWT_SECRET=${JWT_SECRET} + - AUTH_SVC_URL=http://auth-svc:3050 - SEARXNG_URL=http://searxng:8080 - DISCOVER_SVC_URL=http://discover-svc:3002 - CRAWL4AI_URL=http://crawl4ai:11235 + - TRAVEL_SVC_URL=http://travel-svc:3035 + - TRAVELPAYOUTS_TOKEN=${TRAVELPAYOUTS_TOKEN} + - TRAVELPAYOUTS_MARKER=${TRAVELPAYOUTS_MARKER} - OPENAI_API_KEY=${OPENAI_API_KEY} - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - GEMINI_API_KEY=${GEMINI_API_KEY} @@ -92,6 +101,9 @@ services: depends_on: - search-svc - discover-svc + - travel-svc + - searxng + - crawl4ai networks: - gooseek @@ -254,6 +266,8 @@ services: - SERVICE=finance-heatmap-svc - PORT=3033 - REDIS_URL=redis://redis:6379 + # MOEX, Крипто, Валюты работают без URL (встроенные провайдеры). Для своих рынков — URL сервиса, GET ?market=...&range=... + - FINANCE_DATA_PROVIDER_URL=${FINANCE_DATA_PROVIDER_URL:-} ports: - "3033:3033" depends_on: @@ -283,6 +297,39 @@ services: networks: - gooseek + travel-svc: + build: + context: ../.. + dockerfile: deploy/docker/Dockerfile.all + environment: + - SERVICE=travel-svc + - PORT=3035 + - DATABASE_URL=postgres://gooseek:gooseek@postgres:5432/gooseek?sslmode=disable + - JWT_SECRET=${JWT_SECRET} + - AUTH_SVC_URL=http://auth-svc:3050 + # Российские API (по умолчанию) + - USE_RUSSIAN_APIS=true + - TRAVELPAYOUTS_TOKEN=${TRAVELPAYOUTS_TOKEN} + - TRAVELPAYOUTS_MARKER=${TRAVELPAYOUTS_MARKER} + - TWOGIS_API_KEY=${TWOGIS_API_KEY} + # Международные API (опционально) + - AMADEUS_API_KEY=${AMADEUS_API_KEY} + - AMADEUS_API_SECRET=${AMADEUS_API_SECRET} + - OPENROUTE_API_KEY=${OPENROUTE_API_KEY} + # LLM (TimeWeb) + - LLM_PROVIDER=timeweb + - LLM_MODEL=${DEFAULT_LLM_MODEL:-gpt-4o-mini} + - TIMEWEB_API_BASE_URL=${TIMEWEB_API_BASE_URL} + - TIMEWEB_AGENT_ACCESS_ID=${TIMEWEB_AGENT_ACCESS_ID} + - TIMEWEB_API_KEY=${TIMEWEB_API_KEY} + ports: + - "3035:3035" + depends_on: + - postgres + - auth-svc + networks: + - gooseek + admin-svc: build: context: ../.. @@ -331,12 +378,12 @@ services: context: ../../webui dockerfile: Dockerfile args: - - NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/travel,/medicine,/finance,/learning,/spaces,/history} + - NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/medicine} environment: - NODE_ENV=production - API_URL=http://api-gateway:3015 - NEXT_PUBLIC_API_URL= - - NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/travel,/medicine,/finance,/learning,/spaces,/history} + - NEXT_PUBLIC_DISABLED_ROUTES=${NEXT_PUBLIC_DISABLED_ROUTES:-/medicine} ports: - "3000:3000" depends_on: @@ -381,7 +428,7 @@ services: searxng: image: searxng/searxng:latest volumes: - - ../../../deploy/docker/searxng:/etc/searxng:ro + - ./searxng:/etc/searxng environment: - SEARXNG_BASE_URL=http://localhost:8080 ports: diff --git a/backend/deploy/docker/searxng/settings.yml b/backend/deploy/docker/searxng/settings.yml new file mode 100644 index 0000000..96b1c5b --- /dev/null +++ b/backend/deploy/docker/searxng/settings.yml @@ -0,0 +1,23 @@ +use_default_settings: true + +general: + instance_name: "GooSeek Search" + debug: false + +search: + safe_search: 0 + autocomplete: "" + default_lang: "ru" + formats: + - html + - json + +server: + secret_key: "gooseek-searxng-secret-key-change-in-production" + bind_address: "0.0.0.0" + port: 8080 + limiter: false + image_proxy: false + +ui: + static_use_hash: true diff --git a/backend/internal/agent/focus_modes.go b/backend/internal/agent/focus_modes.go index a9f8897..7e61e9d 100644 --- a/backend/internal/agent/focus_modes.go +++ b/backend/internal/agent/focus_modes.go @@ -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", "новост", "сегодня", "последн", "актуальн", diff --git a/backend/internal/agent/orchestrator.go b/backend/internal/agent/orchestrator.go index 31ac82b..fc51ce0 100644 --- a/backend/internal/agent/orchestrator.go +++ b/backend/internal/agent/orchestrator.go @@ -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: ") diff --git a/backend/internal/agent/travel_context_collector.go b/backend/internal/agent/travel_context_collector.go new file mode 100644 index 0000000..379e174 --- /dev/null +++ b/backend/internal/agent/travel_context_collector.go @@ -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 +} diff --git a/backend/internal/agent/travel_data_client.go b/backend/internal/agent/travel_data_client.go new file mode 100644 index 0000000..6666e73 --- /dev/null +++ b/backend/internal/agent/travel_data_client.go @@ -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"` +} diff --git a/backend/internal/agent/travel_events_collector.go b/backend/internal/agent/travel_events_collector.go new file mode 100644 index 0000000..a7667b1 --- /dev/null +++ b/backend/internal/agent/travel_events_collector.go @@ -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 +} diff --git a/backend/internal/agent/travel_flights_collector.go b/backend/internal/agent/travel_flights_collector.go new file mode 100644 index 0000000..8405a65 --- /dev/null +++ b/backend/internal/agent/travel_flights_collector.go @@ -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 +} diff --git a/backend/internal/agent/travel_hotels_collector.go b/backend/internal/agent/travel_hotels_collector.go new file mode 100644 index 0000000..afbdd03 --- /dev/null +++ b/backend/internal/agent/travel_hotels_collector.go @@ -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 +} diff --git a/backend/internal/agent/travel_orchestrator.go b/backend/internal/agent/travel_orchestrator.go new file mode 100644 index 0000000..8109950 --- /dev/null +++ b/backend/internal/agent/travel_orchestrator.go @@ -0,0 +1,1148 @@ +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/gooseek/backend/internal/session" + "github.com/gooseek/backend/internal/types" + "github.com/google/uuid" + "golang.org/x/sync/errgroup" +) + +// TravelOrchestratorConfig holds all dependencies for the travel pipeline. +type TravelOrchestratorConfig struct { + LLM llm.Client + SearchClient *search.SearXNGClient + TravelData *TravelDataClient + Crawl4AIURL string + Locale string + TravelPayoutsToken string + TravelPayoutsMarker string +} + +// RunTravelOrchestrator executes the multi-agent travel planning pipeline. +// Flow: planner → parallel collectors → itinerary builder → widget streaming. +func RunTravelOrchestrator(ctx context.Context, sess *session.Session, input OrchestratorInput) error { + travelCfg := TravelOrchestratorConfig{ + LLM: input.Config.LLM, + SearchClient: input.Config.SearchClient, + TravelData: NewTravelDataClient(input.Config.TravelSvcURL), + Crawl4AIURL: input.Config.Crawl4AIURL, + Locale: input.Config.Locale, + TravelPayoutsToken: input.Config.TravelPayoutsToken, + TravelPayoutsMarker: input.Config.TravelPayoutsMarker, + } + + researchBlockID := uuid.New().String() + sess.EmitBlock(types.NewResearchBlock(researchBlockID)) + + sess.UpdateBlock(researchBlockID, []session.Patch{{ + Op: "replace", + Path: "/data/subSteps", + Value: []types.ResearchSubStep{{ + ID: uuid.New().String(), + Type: "reasoning", + Reasoning: "Анализирую запрос и составляю план путешествия...", + }}, + }}) + + // --- Phase 1: Planner Agent — extract brief from user message --- + brief, err := runPlannerAgent(ctx, travelCfg, input) + if err != nil { + sess.EmitError(fmt.Errorf("planner agent failed: %w", err)) + return err + } + + if !brief.IsComplete() { + return emitClarifyingQuestions(sess, brief) + } + + brief.ApplyDefaults() + + // Geocode origin if we have a name but no coordinates + if brief.Origin != "" && brief.OriginLat == 0 && brief.OriginLng == 0 { + geo, err := travelCfg.TravelData.Geocode(ctx, brief.Origin) + if err == nil { + brief.OriginLat = geo.Lat + brief.OriginLng = geo.Lng + log.Printf("[travel] geocoded origin %q -> %.4f, %.4f", brief.Origin, geo.Lat, geo.Lng) + } + } + + // --- Phase 2: Geocode destinations --- + destGeo := geocodeDestinations(ctx, travelCfg, brief) + + sess.UpdateBlock(researchBlockID, []session.Patch{{ + Op: "replace", + Path: "/data/subSteps", + Value: []types.ResearchSubStep{{ + ID: uuid.New().String(), + Type: "searching", + Searching: []string{ + fmt.Sprintf("мероприятия %s %s", brief.Destinations[0], brief.StartDate), + fmt.Sprintf("достопримечательности %s", brief.Destinations[0]), + fmt.Sprintf("отели %s", brief.Destinations[0]), + fmt.Sprintf("перелёты %s %s", brief.Origin, brief.Destinations[0]), + }, + }}, + }}) + + // --- Phase 3: Parallel data collection (with timeout) --- + draft := &TripDraft{ + ID: uuid.New().String(), + Brief: brief, + Phase: "collecting", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + collectCtx, collectCancel := context.WithTimeout(ctx, 90*time.Second) + defer collectCancel() + + g, gctx := errgroup.WithContext(collectCtx) + + // Agent 3: Events (SearXNG + Crawl4AI + LLM extraction + geocoding) + g.Go(func() error { + events, err := CollectEventsEnriched(gctx, travelCfg, brief) + if err != nil { + log.Printf("[travel] events collection error: %v", err) + return nil + } + draft.Candidates.Events = events + return nil + }) + + // Agent 2: POI collector (SearXNG + Crawl4AI + LLM) + g.Go(func() error { + pois, err := CollectPOIsEnriched(gctx, travelCfg, brief, destGeo) + if err != nil { + log.Printf("[travel] POI collection error: %v", err) + return nil + } + draft.Candidates.POIs = pois + return nil + }) + + // Agent 4: Hotels (SearXNG + Crawl4AI + LLM) + g.Go(func() error { + hotels, err := CollectHotelsEnriched(gctx, travelCfg, brief, destGeo) + if err != nil { + log.Printf("[travel] hotels collection error: %v", err) + return nil + } + draft.Candidates.Hotels = hotels + return nil + }) + + // Agent 5: Transport — TravelPayouts flights API + g.Go(func() error { + transport, err := CollectFlightsFromTP(gctx, travelCfg, brief) + if err != nil { + log.Printf("[travel] transport collection error: %v", err) + return nil + } + draft.Candidates.Transport = transport + return nil + }) + + // Agent 6: Travel Context — weather, safety, restrictions + g.Go(func() error { + travelCtx, err := CollectTravelContext(gctx, travelCfg, brief) + if err != nil { + log.Printf("[travel] context collection error: %v", err) + return nil + } + draft.Context = travelCtx + return nil + }) + + _ = g.Wait() + + sess.EmitResearchComplete() + + // --- Phase 4: Itinerary Builder Agent (with timeout) --- + sess.UpdateBlock(researchBlockID, []session.Patch{{ + Op: "replace", + Path: "/data/subSteps", + Value: []types.ResearchSubStep{{ + ID: uuid.New().String(), + Type: "reasoning", + Reasoning: "Составляю оптимальный маршрут по дням...", + }}, + }}) + + itineraryCtx, itineraryCancel := context.WithTimeout(ctx, 60*time.Second) + defer itineraryCancel() + + itinerary, summaryText, err := runItineraryBuilder(itineraryCtx, travelCfg, draft) + if err != nil { + log.Printf("[travel] itinerary builder error: %v", err) + } + if itinerary != nil { + draft.Selected.Itinerary = itinerary + } + + // --- Phase 5: Emit all widgets --- + emitTravelWidgets(ctx, sess, &travelCfg, draft, destGeo, summaryText) + + sess.EmitEnd() + return nil +} + +// --- Phase 1: Planner Agent --- + +func runPlannerAgent(ctx context.Context, cfg TravelOrchestratorConfig, input OrchestratorInput) (*TripBrief, error) { + prompt := `Ты — AI-планировщик путешествий. Проанализируй запрос пользователя и извлеки структурированную информацию. + +Верни ТОЛЬКО JSON без пояснений: +{ + "origin": "город отправления", + "originLat": широта_отправления_или_0, + "originLng": долгота_отправления_или_0, + "destinations": ["город1", "город2"], + "startDate": "YYYY-MM-DD (если указана)", + "endDate": "YYYY-MM-DD (если указана)", + "travelers": число_путешественников, + "budget": число_бюджета, + "currency": "RUB", + "interests": ["интерес1", "интерес2"], + "travelStyle": "стиль (budget/comfort/luxury)", + "constraints": ["ограничение1"] +} + +Правила: +- Если пользователь говорит "сегодня" — startDate = текущая дата (` + time.Now().Format("2006-01-02") + `) +- Для однодневных поездок endDate = startDate +- Если дата не указана, оставь пустую строку "" +- Если бюджет не указан, поставь 0 +- Если количество путешественников не указано, поставь 0 +- ВАЖНО: Если в сообщении есть координаты "Моё текущее местоположение: lat, lng", используй их: + - origin = название ближайшего города по координатам (определи сам) + - originLat = lat из координат + - originLng = lng из координат +- Если пользователь уже на месте ("мы в X", "мы уже в X"), origin = X +- Если origin не указан и нет координат, оставь "" (не обязательное поле) +- Маршрут ВСЕГДА начинается от origin (точка отправления пользователя) +- destinations — массив, даже если одно направление +- Извлекай информацию из ВСЕГО контекста диалога` + + messages := []llm.Message{ + {Role: llm.RoleSystem, Content: prompt}, + } + messages = append(messages, input.ChatHistory...) + messages = append(messages, llm.Message{Role: llm.RoleUser, Content: input.FollowUp}) + + response, err := cfg.LLM.GenerateText(ctx, llm.StreamRequest{ + Messages: messages, + Options: llm.StreamOptions{MaxTokens: 1024, Temperature: 0.1}, + }) + if err != nil { + return nil, fmt.Errorf("planner LLM call failed: %w", err) + } + + jsonMatch := regexp.MustCompile(`\{[\s\S]*\}`).FindString(response) + if jsonMatch == "" { + return &TripBrief{ + Destinations: extractDestinationsFromText(input.FollowUp), + }, nil + } + + var brief TripBrief + if err := json.Unmarshal([]byte(jsonMatch), &brief); err != nil { + return &TripBrief{ + Destinations: extractDestinationsFromText(input.FollowUp), + }, nil + } + + if len(brief.Destinations) == 0 { + brief.Destinations = extractDestinationsFromText(input.FollowUp) + } + if brief.Currency == "" { + brief.Currency = "RUB" + } + + parseUserLocationCoords(&brief, input.FollowUp) + + return &brief, nil +} + +func parseUserLocationCoords(brief *TripBrief, text string) { + re := regexp.MustCompile(`Моё текущее местоположение:\s*([\d.]+),\s*([\d.]+)`) + matches := re.FindStringSubmatch(text) + if len(matches) < 3 { + return + } + var lat, lng float64 + if _, err := fmt.Sscanf(matches[1], "%f", &lat); err != nil { + return + } + if _, err := fmt.Sscanf(matches[2], "%f", &lng); err != nil { + return + } + if lat == 0 || lng == 0 { + return + } + if brief.OriginLat == 0 && brief.OriginLng == 0 { + brief.OriginLat = lat + brief.OriginLng = lng + } +} + +func extractDestinationsFromText(text string) []string { + text = strings.ToLower(text) + cities := map[string]bool{ + "москва": true, "санкт-петербург": true, "питер": true, + "казань": true, "сочи": true, "калининград": true, + "воронеж": true, "нижний новгород": true, "екатеринбург": true, + "новосибирск": true, "красноярск": true, "владивосток": true, + "ярославль": true, "суздаль": true, "владимир": true, + "крым": true, "ялта": true, "севастополь": true, + } + + var found []string + for city := range cities { + if strings.Contains(text, city) { + found = append(found, city) + } + } + + if len(found) == 0 && strings.Contains(text, "золотое кольцо") { + found = []string{"Сергиев Посад", "Переславль-Залесский", "Ростов Великий", "Ярославль", "Кострома", "Суздаль", "Владимир"} + } + + return found +} + +// --- Phase 2: Geocode --- + +type destGeoEntry struct { + Name string + Lat float64 + Lng float64 +} + +func geocodeDestinations(ctx context.Context, cfg TravelOrchestratorConfig, brief *TripBrief) []destGeoEntry { + results := make([]destGeoEntry, 0, len(brief.Destinations)) + + for _, dest := range brief.Destinations { + geo, err := cfg.TravelData.Geocode(ctx, dest) + if err != nil { + log.Printf("[travel] geocode failed for %s: %v", dest, err) + continue + } + results = append(results, destGeoEntry{ + Name: dest, + Lat: geo.Lat, + Lng: geo.Lng, + }) + } + + return results +} + +// --- Phase 3: Collectors are now in separate files --- +// - travel_poi_collector.go: CollectPOIsEnriched (SearXNG + Crawl4AI + LLM) +// - travel_hotels_collector.go: CollectHotelsEnriched (SearXNG + Crawl4AI + LLM) +// - travel_flights_collector.go: CollectFlightsFromTP (TravelPayouts API) + +// --- Phase 4: Itinerary Builder --- + +func runItineraryBuilder(ctx context.Context, cfg TravelOrchestratorConfig, draft *TripDraft) ([]ItineraryDay, string, error) { + travelers := draft.Brief.Travelers + if travelers < 1 { + travelers = 1 + } + + type poiCompact struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Duration int `json:"duration"` + Price float64 `json:"price"` + Address string `json:"address"` + } + type eventCompact struct { + ID string `json:"id"` + Title string `json:"title"` + DateStart string `json:"dateStart"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Price float64 `json:"price"` + Address string `json:"address"` + } + type hotelCompact struct { + ID string `json:"id"` + Name string `json:"name"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Address string `json:"address"` + } + + compactPOIs := make([]poiCompact, 0, len(draft.Candidates.POIs)) + for _, p := range draft.Candidates.POIs { + dur := p.Duration + if dur < 30 { + dur = 60 + } + compactPOIs = append(compactPOIs, poiCompact{ + ID: p.ID, Name: p.Name, Category: p.Category, + Lat: p.Lat, Lng: p.Lng, Duration: dur, + Price: p.Price, Address: p.Address, + }) + } + + compactEvents := make([]eventCompact, 0, len(draft.Candidates.Events)) + for _, e := range draft.Candidates.Events { + compactEvents = append(compactEvents, eventCompact{ + ID: e.ID, Title: e.Title, DateStart: e.DateStart, + Lat: e.Lat, Lng: e.Lng, Price: e.Price, Address: e.Address, + }) + } + + compactHotels := make([]hotelCompact, 0, len(draft.Candidates.Hotels)) + for _, h := range draft.Candidates.Hotels { + compactHotels = append(compactHotels, hotelCompact{ + ID: h.ID, Name: h.Name, Lat: h.Lat, Lng: h.Lng, Address: h.Address, + }) + } + + candidateData := map[string]interface{}{ + "destinations": draft.Brief.Destinations, + "startDate": draft.Brief.StartDate, + "endDate": draft.Brief.EndDate, + "travelers": travelers, + "budget": draft.Brief.Budget, + "currency": draft.Brief.Currency, + "pois": compactPOIs, + "events": compactEvents, + "hotels": compactHotels, + } + if draft.Context != nil { + candidateData["context"] = map[string]interface{}{ + "weather": draft.Context.Weather.Summary, + "tempRange": fmt.Sprintf("%.0f..%.0f°C", draft.Context.Weather.TempMin, draft.Context.Weather.TempMax), + "conditions": draft.Context.Weather.Conditions, + "safetyLevel": draft.Context.Safety.Level, + "restrictions": draft.Context.Restrictions, + } + } + candidatesJSON, _ := json.Marshal(candidateData) + + prompt := fmt.Sprintf(`Ты — AI-планировщик маршрутов для группы из %d человек. Составь оптимальный маршрут по дням. + +Данные (с координатами для расчёта расстояний): %s + +КРИТИЧЕСКИЕ ПРАВИЛА РАСЧЁТА ВРЕМЕНИ: +1. Используй координаты (lat, lng) для оценки расстояний между точками. +2. Средняя скорость передвижения по городу: 15-20 км/ч (пробки, пешком, общественный транспорт). +3. Формула: расстояние_км = 111 * sqrt((lat1-lat2)^2 + (lng1-lng2)^2 * cos(lat1*pi/180)^2). Время_мин = расстояние_км / 15 * 60. +4. МЕЖДУ КАЖДЫМИ ДВУМЯ ТОЧКАМИ добавляй время на переезд. Если точки в разных концах города (>5 км) — минимум 30-40 минут переезда. +5. Минимальное время на посещение: музей/достопримечательность — 60-90 мин, ресторан — 60 мин, парк — 45 мин, мероприятие — 90-120 мин. +6. Группируй близкие точки (расстояние < 1 км) в один блок дня. +7. НЕ ставь точки в разных концах города подряд без достаточного времени на переезд. +8. Максимум 4-5 основных активностей в день (не считая еду и переезды). +9. День начинается в 09:00, заканчивается в 21:00. С детьми — до 19:00. + +ПРАВИЛА ЦЕН: +1. cost — цена НА ОДНОГО человека за эту активность. +2. Для бесплатных мест (парки, площади, улицы) — cost = 0. +3. Для ресторанов: средний чек на одного ~800-1500 RUB. +4. Для музеев/достопримечательностей: входной билет на одного. + +Верни ответ в формате: +1. Краткое текстовое описание маршрута (2-4 абзаца, на русском). Укажи что маршрут рассчитан на %d человек. +2. JSON блок: + +`+"```json"+` +{ + "days": [ + { + "date": "YYYY-MM-DD", + "items": [ + { + "refType": "poi|event|hotel|transport|food|transfer", + "refId": "id из данных или пустая строка", + "title": "Название", + "startTime": "09:00", + "endTime": "10:30", + "lat": 55.75, + "lng": 37.62, + "note": "Переезд ~20 мин, 3 км / Время на осмотр 90 мин", + "cost": 500, + "currency": "RUB" + } + ] + } + ] +} +`+"```"+` + +Дополнительные правила: +- Между точками ОБЯЗАТЕЛЬНО вставляй элемент "transfer" с refType="transfer" если расстояние > 1 км +- В note для transfer указывай расстояние и примерное время +- Начинай день с отеля/завтрака +- Включай обед (12:00-14:00) и ужин (18:00-20:00) +- Мероприятия с конкретными датами — в нужный день +- Транспорт (перелёт/поезд) — первым/последним пунктом дня +- lat/lng — числа из данных`, travelers, string(candidatesJSON), travelers) + + response, err := cfg.LLM.GenerateText(ctx, llm.StreamRequest{ + Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, + Options: llm.StreamOptions{MaxTokens: 8192, Temperature: 0.3}, + }) + if err != nil { + return nil, "", fmt.Errorf("itinerary builder LLM failed: %w", err) + } + + summaryText := extractTextBeforeJSON(response) + + jsonMatch := regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```").FindStringSubmatch(response) + var jsonStr string + if len(jsonMatch) > 1 { + jsonStr = strings.TrimSpace(jsonMatch[1]) + } else { + jsonStr = regexp.MustCompile(`\{[\s\S]*"days"[\s\S]*\}`).FindString(response) + } + + if jsonStr == "" { + return nil, summaryText, nil + } + + var result struct { + Days []ItineraryDay `json:"days"` + } + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + log.Printf("[travel] itinerary JSON parse error: %v", err) + return nil, summaryText, nil + } + + result.Days = validateItineraryTimes(result.Days) + + return result.Days, summaryText, nil +} + +func validateItineraryTimes(days []ItineraryDay) []ItineraryDay { + for d := range days { + items := days[d].Items + for i := 1; i < len(items); i++ { + prev := items[i-1] + curr := items[i] + + if prev.EndTime == "" || curr.StartTime == "" { + continue + } + + prevEnd := parseTimeMinutes(prev.EndTime) + currStart := parseTimeMinutes(curr.StartTime) + + if currStart < prevEnd { + items[i].StartTime = prev.EndTime + dur := parseTimeMinutes(curr.EndTime) - parseTimeMinutes(curr.StartTime) + if dur < 30 { + dur = 60 + } + items[i].EndTime = formatMinutesTime(prevEnd + dur) + } + } + days[d].Items = items + } + return days +} + +func parseTimeMinutes(t string) int { + parts := strings.Split(t, ":") + if len(parts) != 2 { + return 0 + } + h, m := 0, 0 + fmt.Sscanf(parts[0], "%d", &h) + fmt.Sscanf(parts[1], "%d", &m) + return h*60 + m +} + +func formatMinutesTime(minutes int) string { + if minutes >= 1440 { + minutes = 1380 + } + return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) +} + +func extractTextBeforeJSON(response string) string { + idx := strings.Index(response, "```") + if idx > 0 { + return strings.TrimSpace(response[:idx]) + } + if len(response) > 1000 { + return response[:1000] + } + return response +} + +// --- Phase 5: Widget Emission --- + +func emitTravelWidgets(ctx context.Context, sess *session.Session, cfg *TravelOrchestratorConfig, draft *TripDraft, destGeo []destGeoEntry, summaryText string) { + if summaryText != "" { + textBlockID := uuid.New().String() + sess.EmitBlock(types.NewTextBlock(textBlockID, summaryText)) + } + + if draft.Context != nil { + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelContext), map[string]interface{}{ + "weather": draft.Context.Weather, + "safety": draft.Context.Safety, + "restrictions": draft.Context.Restrictions, + "tips": draft.Context.Tips, + "bestTimeInfo": draft.Context.BestTimeInfo, + })) + } + + mapPoints := buildMapPoints(draft, destGeo) + + routeMapPoints := collectItineraryMapPoints(draft) + if len(routeMapPoints) < 2 { + routeMapPoints = filterItineraryLayerPoints(mapPoints) + } + if len(routeMapPoints) < 2 { + routeMapPoints = filterValidMapPoints(mapPoints) + } + log.Printf("[travel] routing: %d points for road route", len(routeMapPoints)) + routeDir, segments := buildRoadRoute(ctx, cfg, routeMapPoints) + if len(mapPoints) > 0 { + var center map[string]interface{} + if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { + center = map[string]interface{}{ + "lat": draft.Brief.OriginLat, + "lng": draft.Brief.OriginLng, + "name": draft.Brief.Origin, + } + } else if len(destGeo) > 0 { + center = map[string]interface{}{ + "lat": destGeo[0].Lat, + "lng": destGeo[0].Lng, + "name": destGeo[0].Name, + } + } + + widgetParams := map[string]interface{}{ + "center": center, + "points": mapPoints, + } + + if routeDir != nil { + widgetParams["routeDirection"] = map[string]interface{}{ + "geometry": map[string]interface{}{ + "coordinates": routeDir.Geometry.Coordinates, + "type": routeDir.Geometry.Type, + }, + "distance": routeDir.Distance, + "duration": routeDir.Duration, + "steps": routeDir.Steps, + } + } + + if len(segments) > 0 { + widgetParams["segments"] = segments + } + + var polyline [][2]float64 + if routeDir != nil && len(routeDir.Geometry.Coordinates) > 0 { + polyline = routeDir.Geometry.Coordinates + } else { + if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { + polyline = append(polyline, [2]float64{draft.Brief.OriginLng, draft.Brief.OriginLat}) + } + for _, day := range draft.Selected.Itinerary { + for _, item := range day.Items { + if item.Lat != 0 && item.Lng != 0 { + polyline = append(polyline, [2]float64{item.Lng, item.Lat}) + } + } + } + } + widgetParams["polyline"] = polyline + + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelMap), widgetParams)) + } + + // Events widget + if len(draft.Candidates.Events) > 0 { + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelEvents), map[string]interface{}{ + "events": draft.Candidates.Events, + })) + } + + // POI widget + if len(draft.Candidates.POIs) > 0 { + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelPOI), map[string]interface{}{ + "pois": draft.Candidates.POIs, + })) + } + + // Hotels widget + if len(draft.Candidates.Hotels) > 0 { + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelHotels), map[string]interface{}{ + "hotels": draft.Candidates.Hotels, + })) + } + + // Transport widget + if len(draft.Candidates.Transport) > 0 { + flights := make([]TransportOption, 0) + ground := make([]TransportOption, 0) + for _, t := range draft.Candidates.Transport { + if t.Mode == "flight" { + flights = append(flights, t) + } else { + ground = append(ground, t) + } + } + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelTransport), map[string]interface{}{ + "flights": flights, + "ground": ground, + "passengers": draft.Brief.Travelers, + })) + } + + // Itinerary widget + if len(draft.Selected.Itinerary) > 0 { + budget := calculateBudget(draft) + itineraryParams := map[string]interface{}{ + "days": draft.Selected.Itinerary, + "budget": budget, + } + if len(segments) > 0 { + itineraryParams["segments"] = segments + } + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelItinerary), itineraryParams)) + } + + // Budget widget + budget := calculateBudget(draft) + if budget.Total > 0 { + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelBudget), map[string]interface{}{ + "breakdown": budget, + "travelers": budget.Travelers, + "perPerson": budget.PerPerson, + })) + } + + // Actions widget + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelActions), map[string]interface{}{ + "actions": []map[string]interface{}{ + {"id": "save_trip", "label": "Сохранить поездку", "kind": "save", "payload": map[string]interface{}{}}, + {"id": "modify_route", "label": "Изменить маршрут", "kind": "modify", "payload": map[string]interface{}{}}, + {"id": "add_more", "label": "Найти ещё варианты", "kind": "search", "payload": map[string]interface{}{}}, + }, + })) +} + +func buildMapPoints(draft *TripDraft, destGeo []destGeoEntry) []MapPoint { + var points []MapPoint + seen := make(map[string]bool) + + if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { + originLabel := draft.Brief.Origin + if originLabel == "" { + originLabel = "Точка отправления" + } + key := fmt.Sprintf("%.4f,%.4f", draft.Brief.OriginLat, draft.Brief.OriginLng) + seen[key] = true + points = append(points, MapPoint{ + ID: "origin-" + uuid.New().String(), + Lat: draft.Brief.OriginLat, + Lng: draft.Brief.OriginLng, + Type: "origin", + Label: originLabel, + Layer: "itinerary", + }) + } + + for _, day := range draft.Selected.Itinerary { + for _, item := range day.Items { + key := fmt.Sprintf("%.4f,%.4f", item.Lat, item.Lng) + if item.Lat != 0 && item.Lng != 0 && !seen[key] { + seen[key] = true + points = append(points, MapPoint{ + ID: item.RefID, + Lat: item.Lat, + Lng: item.Lng, + Type: item.RefType, + Label: item.Title, + Layer: "itinerary", + }) + } + } + } + + for _, event := range draft.Candidates.Events { + if event.Lat != 0 && event.Lng != 0 { + key := fmt.Sprintf("%.4f,%.4f", event.Lat, event.Lng) + if !seen[key] { + seen[key] = true + points = append(points, MapPoint{ + ID: event.ID, + Lat: event.Lat, + Lng: event.Lng, + Type: "event", + Label: event.Title, + Layer: "candidate", + }) + } + } + } + + for _, poi := range draft.Candidates.POIs { + key := fmt.Sprintf("%.4f,%.4f", poi.Lat, poi.Lng) + if !seen[key] { + seen[key] = true + points = append(points, MapPoint{ + ID: poi.ID, + Lat: poi.Lat, + Lng: poi.Lng, + Type: poi.Category, + Label: poi.Name, + Layer: "candidate", + }) + } + } + + for _, hotel := range draft.Candidates.Hotels { + key := fmt.Sprintf("%.4f,%.4f", hotel.Lat, hotel.Lng) + if !seen[key] { + seen[key] = true + points = append(points, MapPoint{ + ID: hotel.ID, + Lat: hotel.Lat, + Lng: hotel.Lng, + Type: "hotel", + Label: hotel.Name, + Layer: "candidate", + }) + } + } + + if len(points) == 0 || (len(points) == 1 && strings.HasPrefix(points[0].ID, "origin-")) { + for _, g := range destGeo { + key := fmt.Sprintf("%.4f,%.4f", g.Lat, g.Lng) + if !seen[key] { + seen[key] = true + points = append(points, MapPoint{ + ID: uuid.New().String(), + Lat: g.Lat, + Lng: g.Lng, + Type: "destination", + Label: g.Name, + Layer: "itinerary", + }) + } + } + } + + return points +} + +func calculateBudget(draft *TripDraft) BudgetBreakdown { + travelers := draft.Brief.Travelers + if travelers < 1 { + travelers = 1 + } + + budget := BudgetBreakdown{ + Currency: draft.Brief.Currency, + Travelers: travelers, + } + + for _, t := range draft.Candidates.Transport { + budget.Transport += t.Price + } + + if len(draft.Candidates.Hotels) > 0 { + best := draft.Candidates.Hotels[0] + budget.Hotels = best.TotalPrice + } + + for _, day := range draft.Selected.Itinerary { + for _, item := range day.Items { + budget.Activities += item.Cost + } + } + + budget.Activities *= float64(travelers) + + nights := 1 + if draft.Brief.StartDate != "" && draft.Brief.EndDate != "" { + start, err1 := time.Parse("2006-01-02", draft.Brief.StartDate) + end, err2 := time.Parse("2006-01-02", draft.Brief.EndDate) + if err1 == nil && err2 == nil { + n := int(end.Sub(start).Hours() / 24) + if n > 0 { + nights = n + } + } + } + budget.Food = float64(travelers) * float64(nights) * 1500 + + budget.Total = budget.Transport + budget.Hotels + budget.Activities + budget.Food + budget.Other + + if travelers > 0 { + budget.PerPerson = budget.Total / float64(travelers) + } + + if draft.Brief.Budget > 0 { + budget.Remaining = draft.Brief.Budget - budget.Total + } + + return budget +} + +func emitClarifyingQuestions(sess *session.Session, brief *TripBrief) error { + questions := []ClarifyingQuestion{ + { + Field: "destination", + Question: "Куда вы хотите поехать?", + Type: "text", + Placeholder: "Город или регион", + }, + } + + textBlockID := uuid.New().String() + sess.EmitBlock(types.NewTextBlock(textBlockID, + "Я помогу спланировать путешествие! Укажите, пожалуйста, куда вы хотите поехать.")) + + sess.EmitBlock(types.NewWidgetBlock(uuid.New().String(), string(types.WidgetTravelClarifying), map[string]interface{}{ + "questions": questions, + })) + + sess.EmitEnd() + return nil +} + +func truncateStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// --- Road Routing & Transport Costs --- + +func collectItineraryMapPoints(draft *TripDraft) []MapPoint { + var points []MapPoint + + if draft.Brief != nil && draft.Brief.OriginLat != 0 && draft.Brief.OriginLng != 0 { + points = append(points, MapPoint{ + ID: "origin", + Lat: draft.Brief.OriginLat, + Lng: draft.Brief.OriginLng, + Type: "origin", + Label: draft.Brief.Origin, + Layer: "itinerary", + }) + } + + for _, day := range draft.Selected.Itinerary { + for _, item := range day.Items { + if item.Lat != 0 && item.Lng != 0 { + points = append(points, MapPoint{ + ID: item.RefID, + Lat: item.Lat, + Lng: item.Lng, + Type: item.RefType, + Label: item.Title, + Layer: "itinerary", + }) + } + } + } + + return points +} + +func filterItineraryLayerPoints(points []MapPoint) []MapPoint { + var result []MapPoint + for _, p := range points { + if p.Layer == "itinerary" && p.Lat != 0 && p.Lng != 0 { + result = append(result, p) + } + } + return result +} + +func filterValidMapPoints(points []MapPoint) []MapPoint { + var result []MapPoint + seen := make(map[string]bool) + for _, p := range points { + if p.Lat == 0 || p.Lng == 0 { + continue + } + key := fmt.Sprintf("%.4f,%.4f", p.Lat, p.Lng) + if seen[key] { + continue + } + seen[key] = true + result = append(result, p) + } + if len(result) > 10 { + result = result[:10] + } + return result +} + +type routeSegmentWithCosts struct { + From string `json:"from"` + To string `json:"to"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + TransportOptions []transportCostOption `json:"transportOptions"` +} + +type transportCostOption struct { + Mode string `json:"mode"` + Label string `json:"label"` + Price float64 `json:"price"` + Currency string `json:"currency"` + Duration float64 `json:"duration"` +} + +func buildRoadRoute(ctx context.Context, cfg *TravelOrchestratorConfig, points []MapPoint) (*RouteDirectionResult, []routeSegmentWithCosts) { + if len(points) < 2 || cfg.TravelData == nil { + log.Printf("[travel] buildRoadRoute skip: points=%d, travelData=%v", len(points), cfg.TravelData != nil) + return nil, nil + } + + routeCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + log.Printf("[travel] building road route segment-by-segment for %d points", len(points)) + + var allCoords [][2]float64 + var allSteps []RouteStepResult + var totalDistance, totalDuration float64 + segments := make([]routeSegmentWithCosts, 0, len(points)-1) + + for i := 0; i < len(points)-1; i++ { + if i > 0 { + select { + case <-routeCtx.Done(): + break + case <-time.After(300 * time.Millisecond): + } + } + + pair := []MapPoint{points[i], points[i+1]} + + var segDir *RouteDirectionResult + var err error + for attempt := 0; attempt < 3; attempt++ { + segDir, err = cfg.TravelData.GetRoute(routeCtx, pair, "driving") + if err == nil || !strings.Contains(err.Error(), "429") { + break + } + log.Printf("[travel] segment %d->%d rate limited, retry %d", i, i+1, attempt+1) + select { + case <-routeCtx.Done(): + break + case <-time.After(time.Duration(1+attempt) * time.Second): + } + } + + var distanceM, durationS float64 + if err != nil { + log.Printf("[travel] segment %d->%d routing failed: %v", i, i+1, err) + } else if segDir != nil { + distanceM = segDir.Distance + durationS = segDir.Duration + totalDistance += distanceM + totalDuration += durationS + + if len(segDir.Geometry.Coordinates) > 0 { + if len(allCoords) > 0 && len(segDir.Geometry.Coordinates) > 0 { + allCoords = append(allCoords, segDir.Geometry.Coordinates[1:]...) + } else { + allCoords = append(allCoords, segDir.Geometry.Coordinates...) + } + } + allSteps = append(allSteps, segDir.Steps...) + } + + seg := routeSegmentWithCosts{ + From: points[i].Label, + To: points[i+1].Label, + Distance: distanceM, + Duration: durationS, + } + if distanceM > 0 { + seg.TransportOptions = calculateTransportCosts(distanceM, durationS) + } + segments = append(segments, seg) + } + + if len(allCoords) == 0 { + log.Printf("[travel] no road coordinates collected") + return nil, segments + } + + fullRoute := &RouteDirectionResult{ + Geometry: RouteGeometryResult{ + Coordinates: allCoords, + Type: "LineString", + }, + Distance: totalDistance, + Duration: totalDuration, + Steps: allSteps, + } + log.Printf("[travel] road route OK: distance=%.0fm, coords=%d, segments=%d", totalDistance, len(allCoords), len(segments)) + + return fullRoute, segments +} + +func calculateTransportCosts(distanceMeters float64, durationSeconds float64) []transportCostOption { + distKm := distanceMeters / 1000.0 + durationMin := durationSeconds / 60.0 + + options := []transportCostOption{ + { + Mode: "car", + Label: "Машина", + Price: roundPrice(distKm * 8.0), + Currency: "RUB", + Duration: durationMin, + }, + { + Mode: "bus", + Label: "Автобус", + Price: roundPrice(distKm * 2.5), + Currency: "RUB", + Duration: durationMin * 1.4, + }, + { + Mode: "taxi", + Label: "Такси", + Price: roundPrice(100 + distKm*18.0), + Currency: "RUB", + Duration: durationMin, + }, + } + + for i := range options { + if options[i].Price < 30 { + options[i].Price = 30 + } + } + + return options +} + +func roundPrice(v float64) float64 { + return float64(int(v/10+0.5)) * 10 +} diff --git a/backend/internal/agent/travel_poi_collector.go b/backend/internal/agent/travel_poi_collector.go new file mode 100644 index 0000000..90a1bb5 --- /dev/null +++ b/backend/internal/agent/travel_poi_collector.go @@ -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 +} diff --git a/backend/internal/agent/travel_types.go b/backend/internal/agent/travel_types.go new file mode 100644 index 0000000..e41847c --- /dev/null +++ b/backend/internal/agent/travel_types.go @@ -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"` +} diff --git a/backend/internal/finance/heatmap.go b/backend/internal/finance/heatmap.go index 8cbfc80..b2bc9b0 100644 --- a/backend/internal/finance/heatmap.go +++ b/backend/internal/finance/heatmap.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "math" "net/http" "sort" "strings" @@ -19,6 +18,9 @@ type HeatmapService struct { config HeatmapConfig } +// HeatmapConfig configures the heatmap service. +// Встроенные провайдеры (без ключа): moex → MOEX ISS, crypto → CoinGecko, forex → ЦБ РФ. +// DataProviderURL — опционально для своих рынков; GET с параметрами market, range. type HeatmapConfig struct { DataProviderURL string CacheTTL time.Duration @@ -59,6 +61,7 @@ type Sector struct { MarketCap float64 `json:"marketCap"` Volume float64 `json:"volume"` TickerCount int `json:"tickerCount"` + Tickers []TickerData `json:"tickers,omitempty"` TopGainers []TickerData `json:"topGainers,omitempty"` TopLosers []TickerData `json:"topLosers,omitempty"` Color string `json:"color"` @@ -199,137 +202,72 @@ func (s *HeatmapService) GetSectorHeatmap(ctx context.Context, market, sector, t } func (s *HeatmapService) fetchMarketData(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) { - heatmap := s.generateMockMarketData(market) + market = strings.ToLower(strings.TrimSpace(market)) + var heatmap *MarketHeatmap + var err error + switch market { + case "moex": + heatmap, err = s.fetchMOEX(ctx, timeRange) + case "crypto": + heatmap, err = s.fetchCoinGecko(ctx, timeRange) + case "forex": + heatmap, err = s.fetchForexCBR(ctx, timeRange) + default: + if s.config.DataProviderURL != "" { + heatmap, err = s.fetchFromProvider(ctx, market, timeRange) + } else { + return nil, fmt.Errorf("неизвестный рынок %q и FINANCE_DATA_PROVIDER_URL не задан", market) + } + } + if err != nil { + return nil, err + } heatmap.TimeRange = timeRange - + heatmap.UpdatedAt = time.Now() + if len(heatmap.Colorscale.Colors) == 0 { + heatmap.Colorscale = DefaultColorscale + } + s.fillSectorTickers(heatmap) return heatmap, nil } -func (s *HeatmapService) generateMockMarketData(market string) *MarketHeatmap { - sectors := []struct { - name string - tickers []struct{ symbol, name string } - }{ - {"Technology", []struct{ symbol, name string }{ - {"AAPL", "Apple Inc."}, - {"MSFT", "Microsoft Corp."}, - {"GOOGL", "Alphabet Inc."}, - {"AMZN", "Amazon.com Inc."}, - {"META", "Meta Platforms"}, - {"NVDA", "NVIDIA Corp."}, - {"TSLA", "Tesla Inc."}, - }}, - {"Healthcare", []struct{ symbol, name string }{ - {"JNJ", "Johnson & Johnson"}, - {"UNH", "UnitedHealth Group"}, - {"PFE", "Pfizer Inc."}, - {"MRK", "Merck & Co."}, - {"ABBV", "AbbVie Inc."}, - }}, - {"Finance", []struct{ symbol, name string }{ - {"JPM", "JPMorgan Chase"}, - {"BAC", "Bank of America"}, - {"WFC", "Wells Fargo"}, - {"GS", "Goldman Sachs"}, - {"MS", "Morgan Stanley"}, - }}, - {"Energy", []struct{ symbol, name string }{ - {"XOM", "Exxon Mobil"}, - {"CVX", "Chevron Corp."}, - {"COP", "ConocoPhillips"}, - {"SLB", "Schlumberger"}, - }}, - {"Consumer", []struct{ symbol, name string }{ - {"WMT", "Walmart Inc."}, - {"PG", "Procter & Gamble"}, - {"KO", "Coca-Cola Co."}, - {"PEP", "PepsiCo Inc."}, - {"COST", "Costco Wholesale"}, - }}, +func (s *HeatmapService) fetchFromProvider(ctx context.Context, market, timeRange string) (*MarketHeatmap, error) { + u := strings.TrimSuffix(s.config.DataProviderURL, "/") + reqURL := fmt.Sprintf("%s?market=%s&range=%s", u, market, timeRange) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, err } + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("provider returned %d", resp.StatusCode) + } + var heatmap MarketHeatmap + if err := json.NewDecoder(resp.Body).Decode(&heatmap); err != nil { + return nil, err + } + if len(heatmap.Tickers) == 0 && len(heatmap.Sectors) == 0 { + return nil, fmt.Errorf("provider returned empty data") + } + return &heatmap, nil +} - allTickers := make([]TickerData, 0) - allSectors := make([]Sector, 0) - - for _, sec := range sectors { - sectorTickers := make([]TickerData, 0) - sectorChange := 0.0 - - for _, t := range sec.tickers { - change := (randomFloat(-5, 5)) - price := randomFloat(50, 500) - marketCap := randomFloat(50e9, 3000e9) - volume := randomFloat(1e6, 100e6) - - ticker := TickerData{ - Symbol: t.symbol, - Name: t.name, - Price: price, - Change: price * change / 100, - ChangePercent: change, - Volume: volume, - MarketCap: marketCap, - Sector: sec.name, - Color: getColorForChange(change), - Size: math.Log10(marketCap) * 10, +func (s *HeatmapService) fillSectorTickers(heatmap *MarketHeatmap) { + for i := range heatmap.Sectors { + sec := &heatmap.Sectors[i] + if len(sec.Tickers) > 0 { + continue + } + for _, t := range heatmap.Tickers { + if strings.EqualFold(t.Sector, sec.Name) { + sec.Tickers = append(sec.Tickers, t) } - - sectorTickers = append(sectorTickers, ticker) - sectorChange += change } - - if len(sectorTickers) > 0 { - sectorChange /= float64(len(sectorTickers)) - } - - sort.Slice(sectorTickers, func(i, j int) bool { - return sectorTickers[i].ChangePercent > sectorTickers[j].ChangePercent - }) - - var topGainers, topLosers []TickerData - if len(sectorTickers) >= 2 { - topGainers = sectorTickers[:2] - topLosers = sectorTickers[len(sectorTickers)-2:] - } - - sectorMarketCap := 0.0 - sectorVolume := 0.0 - for _, t := range sectorTickers { - sectorMarketCap += t.MarketCap - sectorVolume += t.Volume - } - - sector := Sector{ - ID: strings.ToLower(strings.ReplaceAll(sec.name, " ", "_")), - Name: sec.name, - Change: sectorChange, - MarketCap: sectorMarketCap, - Volume: sectorVolume, - TickerCount: len(sectorTickers), - TopGainers: topGainers, - TopLosers: topLosers, - Color: getColorForChange(sectorChange), - Weight: sectorMarketCap, - } - - allSectors = append(allSectors, sector) - allTickers = append(allTickers, sectorTickers...) - } - - sort.Slice(allTickers, func(i, j int) bool { - return allTickers[i].MarketCap > allTickers[j].MarketCap - }) - - return &MarketHeatmap{ - ID: market, - Title: getMarketTitle(market), - Type: HeatmapTreemap, - Market: market, - Sectors: allSectors, - Tickers: allTickers, - Summary: *s.calculateSummaryPtr(allTickers), - UpdatedAt: time.Now(), - Colorscale: DefaultColorscale, + sec.TickerCount = len(sec.Tickers) } } @@ -487,51 +425,6 @@ type TopMovers struct { UpdatedAt time.Time `json:"updatedAt"` } -func getColorForChange(change float64) string { - if change >= 5 { - return "#22c55e" - } else if change >= 3 { - return "#4ade80" - } else if change >= 1 { - return "#86efac" - } else if change >= 0 { - return "#bbf7d0" - } else if change >= -1 { - return "#fecaca" - } else if change >= -3 { - return "#fca5a5" - } else if change >= -5 { - return "#f87171" - } - return "#ef4444" -} - -func getMarketTitle(market string) string { - titles := map[string]string{ - "sp500": "S&P 500", - "nasdaq": "NASDAQ", - "dow": "Dow Jones", - "moex": "MOEX", - "crypto": "Cryptocurrency", - "forex": "Forex", - "commodities": "Commodities", - } - if title, ok := titles[strings.ToLower(market)]; ok { - return title - } - return market -} - -var rng uint64 = uint64(time.Now().UnixNano()) - -func randomFloat(min, max float64) float64 { - rng ^= rng << 13 - rng ^= rng >> 17 - rng ^= rng << 5 - f := float64(rng) / float64(1<<64) - return min + f*(max-min) -} - func (h *MarketHeatmap) ToJSON() ([]byte, error) { return json.Marshal(h) } diff --git a/backend/internal/finance/providers.go b/backend/internal/finance/providers.go new file mode 100644 index 0000000..c8680f6 --- /dev/null +++ b/backend/internal/finance/providers.go @@ -0,0 +1,406 @@ +package finance + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +const ( + moexISSBase = "https://iss.moex.com/iss" + coinGeckoBase = "https://api.coingecko.com/api/v3" +) + +// fetchMOEX получает данные с Московской биржи (ISS API, бесплатно, без регистрации). +// Документация: https://iss.moex.com/iss/reference/ +func (s *HeatmapService) fetchMOEX(ctx context.Context, _ string) (*MarketHeatmap, error) { + // Основной режим акций Т+2, boardgroup 57 + url := moexISSBase + "/engines/stock/markets/shares/boardgroups/57/securities.json?limit=100" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("moex request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("moex returned %d", resp.StatusCode) + } + + var raw struct { + Securities struct { + Columns []string `json:"columns"` + Data [][]interface{} `json:"data"` + } `json:"securities"` + Marketdata struct { + Columns []string `json:"columns"` + Data [][]interface{} `json:"data"` + } `json:"marketdata"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("moex decode: %w", err) + } + + colIdx := func(cols []string, name string) int { + for i, c := range cols { + if c == name { + return i + } + } + return -1 + } + getStr := func(row []interface{}, idx int) string { + if idx < 0 || idx >= len(row) || row[idx] == nil { + return "" + } + switch v := row[idx].(type) { + case string: + return v + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + default: + return "" + } + } + getFloat := func(row []interface{}, idx int) float64 { + if idx < 0 || idx >= len(row) || row[idx] == nil { + return 0 + } + switch v := row[idx].(type) { + case float64: + return v + case string: + f, _ := strconv.ParseFloat(v, 64) + return f + default: + return 0 + } + } + + secCols := raw.Securities.Columns + iSECID := colIdx(secCols, "SECID") + iSHORTNAME := colIdx(secCols, "SHORTNAME") + iSECNAME := colIdx(secCols, "SECNAME") + iPREVPRICE := colIdx(secCols, "PREVPRICE") + iPREVWAPRICE := colIdx(secCols, "PREVWAPRICE") + iISSUESIZE := colIdx(secCols, "ISSUESIZE") + iBOARDID := colIdx(secCols, "BOARDID") + + // Только акции TQBR (основной режим), без паёв и прочего + tickers := make([]TickerData, 0, len(raw.Securities.Data)) + marketdataBySec := make(map[string]struct{ Last, LastChangePrc float64 }) + mdCols := raw.Marketdata.Columns + iMD_SECID := colIdx(mdCols, "SECID") + iMD_LAST := colIdx(mdCols, "LAST") + iMD_LASTCHANGEPRC := colIdx(mdCols, "LASTCHANGEPRC") + for _, row := range raw.Marketdata.Data { + sid := getStr(row, iMD_SECID) + if sid != "" { + marketdataBySec[sid] = struct{ Last, LastChangePrc float64 }{ + Last: getFloat(row, iMD_LAST), + LastChangePrc: getFloat(row, iMD_LASTCHANGEPRC), + } + } + } + + for _, row := range raw.Securities.Data { + board := getStr(row, iBOARDID) + if board != "TQBR" { + continue + } + secID := getStr(row, iSECID) + prevPrice := getFloat(row, iPREVPRICE) + prevWAPrice := getFloat(row, iPREVWAPRICE) + if prevPrice <= 0 { + prevPrice = prevWAPrice + } + if prevPrice <= 0 { + continue + } + issuesize := getFloat(row, iISSUESIZE) + marketCap := prevPrice * issuesize + price := prevPrice + changePct := 0.0 + if md, ok := marketdataBySec[secID]; ok && md.Last > 0 { + price = md.Last + changePct = md.LastChangePrc + } else if prevWAPrice > 0 && prevWAPrice != prevPrice { + changePct = (prevWAPrice - prevPrice) / prevPrice * 100 + price = prevWAPrice + } + name := getStr(row, iSECNAME) + if name == "" { + name = getStr(row, iSHORTNAME) + } + tickers = append(tickers, TickerData{ + Symbol: secID, + Name: name, + Price: price, + Change: price - prevPrice, + ChangePercent: changePct, + MarketCap: marketCap, + Volume: 0, + Sector: "Акции", + Color: colorForChange(changePct), + Size: marketCap, + PrevClose: prevPrice, + }) + } + + if len(tickers) == 0 { + return nil, fmt.Errorf("moex: no tickers") + } + + sortTickersByMarketCap(tickers) + summary := s.calculateSummaryPtr(tickers) + sector := Sector{ + ID: "akcii", + Name: "Акции", + Change: summary.AverageChange, + MarketCap: summary.TotalMarketCap, + Volume: summary.TotalVolume, + TickerCount: len(tickers), + Tickers: tickers, + Color: colorForChange(summary.AverageChange), + Weight: summary.TotalMarketCap, + } + if len(tickers) >= 5 { + gainers := make([]TickerData, len(tickers)) + copy(gainers, tickers) + sortTickersByChangeDesc(gainers) + sector.TopGainers = gainers[:minInt(3, len(gainers))] + losers := make([]TickerData, len(tickers)) + copy(losers, tickers) + sortTickersByChangeAsc(losers) + sector.TopLosers = losers[:minInt(3, len(losers))] + } + + return &MarketHeatmap{ + ID: "moex", + Title: "MOEX", + Type: HeatmapTreemap, + Market: "moex", + Sectors: []Sector{sector}, + Tickers: tickers, + Summary: *summary, + UpdatedAt: now(), + Colorscale: DefaultColorscale, + }, nil +} + +// fetchCoinGecko получает топ криптовалют с CoinGecko (бесплатно, без API ключа). +// Документация: https://www.coingecko.com/en/api +func (s *HeatmapService) fetchCoinGecko(ctx context.Context, _ string) (*MarketHeatmap, error) { + url := coinGeckoBase + "/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=50&page=1" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("coingecko request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("coingecko returned %d", resp.StatusCode) + } + + var list []struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + Name string `json:"name"` + CurrentPrice float64 `json:"current_price"` + MarketCap float64 `json:"market_cap"` + PriceChange24h *float64 `json:"price_change_percentage_24h"` + TotalVolume float64 `json:"total_volume"` + } + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return nil, fmt.Errorf("coingecko decode: %w", err) + } + + tickers := make([]TickerData, 0, len(list)) + for _, c := range list { + chg := 0.0 + if c.PriceChange24h != nil { + chg = *c.PriceChange24h + } + sym := strings.ToUpper(c.Symbol) + tickers = append(tickers, TickerData{ + Symbol: sym, + Name: c.Name, + Price: c.CurrentPrice, + ChangePercent: chg, + Change: c.CurrentPrice * chg / 100, + MarketCap: c.MarketCap, + Volume: c.TotalVolume, + Sector: "Криптовалюты", + Color: colorForChange(chg), + Size: c.MarketCap, + }) + } + + if len(tickers) == 0 { + return nil, fmt.Errorf("coingecko: no tickers") + } + + summary := s.calculateSummaryPtr(tickers) + sector := Sector{ + ID: "crypto", + Name: "Криптовалюты", + Change: summary.AverageChange, + MarketCap: summary.TotalMarketCap, + Volume: summary.TotalVolume, + TickerCount: len(tickers), + Tickers: tickers, + Color: colorForChange(summary.AverageChange), + Weight: summary.TotalMarketCap, + } + byGain := make([]TickerData, len(tickers)) + copy(byGain, tickers) + sortTickersByChangeDesc(byGain) + if len(byGain) >= 3 { + sector.TopGainers = byGain[:3] + } + byLoss := make([]TickerData, len(tickers)) + copy(byLoss, tickers) + sortTickersByChangeAsc(byLoss) + if len(byLoss) >= 3 { + sector.TopLosers = byLoss[:3] + } + + return &MarketHeatmap{ + ID: "crypto", + Title: "Криптовалюты", + Type: HeatmapTreemap, + Market: "crypto", + Sectors: []Sector{sector}, + Tickers: tickers, + Summary: *summary, + UpdatedAt: now(), + Colorscale: DefaultColorscale, + }, nil +} + +// fetchForexCBR получает курсы валют ЦБ РФ (бесплатно, без ключа). +func (s *HeatmapService) fetchForexCBR(ctx context.Context, _ string) (*MarketHeatmap, error) { + url := "https://www.cbr-xml-daily.ru/daily_json.js" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("cbr request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("cbr returned %d", resp.StatusCode) + } + + var raw struct { + Valute map[string]struct { + CharCode string `json:"CharCode"` + Name string `json:"Name"` + Value float64 `json:"Value"` + Previous float64 `json:"Previous"` + } `json:"Valute"` + } + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return nil, fmt.Errorf("cbr decode: %w", err) + } + + tickers := make([]TickerData, 0, len(raw.Valute)) + for _, v := range raw.Valute { + if v.Previous <= 0 { + continue + } + chg := (v.Value - v.Previous) / v.Previous * 100 + tickers = append(tickers, TickerData{ + Symbol: v.CharCode, + Name: v.Name, + Price: v.Value, + PrevClose: v.Previous, + Change: v.Value - v.Previous, + ChangePercent: chg, + Sector: "Валюты", + Color: colorForChange(chg), + }) + } + + if len(tickers) == 0 { + return nil, fmt.Errorf("cbr: no rates") + } + + summary := s.calculateSummaryPtr(tickers) + sector := Sector{ + ID: "forex", + Name: "Валюты", + Change: summary.AverageChange, + TickerCount: len(tickers), + Tickers: tickers, + Color: colorForChange(summary.AverageChange), + } + return &MarketHeatmap{ + ID: "forex", + Title: "Валюты (ЦБ РФ)", + Type: HeatmapTreemap, + Market: "forex", + Sectors: []Sector{sector}, + Tickers: tickers, + Summary: *summary, + UpdatedAt: now(), + Colorscale: DefaultColorscale, + }, nil +} + +func colorForChange(change float64) string { + if change >= 5 { + return "#22c55e" + } + if change >= 3 { + return "#4ade80" + } + if change >= 1 { + return "#86efac" + } + if change >= 0 { + return "#bbf7d0" + } + if change >= -1 { + return "#fecaca" + } + if change >= -3 { + return "#fca5a5" + } + if change >= -5 { + return "#f87171" + } + return "#ef4444" +} + +func now() time.Time { return time.Now() } + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func sortTickersByMarketCap(t []TickerData) { + sort.Slice(t, func(i, j int) bool { return t[i].MarketCap > t[j].MarketCap }) +} +func sortTickersByChangeDesc(t []TickerData) { + sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent > t[j].ChangePercent }) +} +func sortTickersByChangeAsc(t []TickerData) { + sort.Slice(t, func(i, j int) bool { return t[i].ChangePercent < t[j].ChangePercent }) +} diff --git a/backend/internal/travel/amadeus.go b/backend/internal/travel/amadeus.go new file mode 100644 index 0000000..fb20689 --- /dev/null +++ b/backend/internal/travel/amadeus.go @@ -0,0 +1,368 @@ +package travel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" +) + +type AmadeusClient struct { + apiKey string + apiSecret string + baseURL string + httpClient *http.Client + accessToken string + tokenExpiry time.Time + tokenMu sync.RWMutex +} + +type AmadeusConfig struct { + APIKey string + APISecret string + BaseURL string +} + +func NewAmadeusClient(cfg AmadeusConfig) *AmadeusClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://test.api.amadeus.com" + } + + return &AmadeusClient{ + apiKey: cfg.APIKey, + apiSecret: cfg.APISecret, + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *AmadeusClient) getAccessToken(ctx context.Context) (string, error) { + c.tokenMu.RLock() + if c.accessToken != "" && time.Now().Before(c.tokenExpiry) { + token := c.accessToken + c.tokenMu.RUnlock() + return token, nil + } + c.tokenMu.RUnlock() + + c.tokenMu.Lock() + defer c.tokenMu.Unlock() + + if c.accessToken != "" && time.Now().Before(c.tokenExpiry) { + return c.accessToken, nil + } + + data := url.Values{} + data.Set("grant_type", "client_credentials") + data.Set("client_id", c.apiKey) + data.Set("client_secret", c.apiSecret) + + req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/v1/security/oauth2/token", bytes.NewBufferString(data.Encode())) + if err != nil { + return "", fmt.Errorf("create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("token request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("token request failed: %s - %s", resp.Status, string(body)) + } + + var tokenResp struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return "", fmt.Errorf("decode token response: %w", err) + } + + c.accessToken = tokenResp.AccessToken + c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-60) * time.Second) + + return c.accessToken, nil +} + +func (c *AmadeusClient) doRequest(ctx context.Context, method, path string, query url.Values, body interface{}) ([]byte, error) { + token, err := c.getAccessToken(ctx) + if err != nil { + return nil, err + } + + fullURL := c.baseURL + path + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + reqBody = bytes.NewReader(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(respBody)) + } + + return respBody, nil +} + +func (c *AmadeusClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) { + query := url.Values{} + query.Set("originLocationCode", req.Origin) + query.Set("destinationLocationCode", req.Destination) + query.Set("departureDate", req.DepartureDate) + query.Set("adults", fmt.Sprintf("%d", req.Adults)) + + if req.ReturnDate != "" { + query.Set("returnDate", req.ReturnDate) + } + if req.Children > 0 { + query.Set("children", fmt.Sprintf("%d", req.Children)) + } + if req.CabinClass != "" { + query.Set("travelClass", req.CabinClass) + } + if req.MaxPrice > 0 { + query.Set("maxPrice", fmt.Sprintf("%d", req.MaxPrice)) + } + if req.Currency != "" { + query.Set("currencyCode", req.Currency) + } + query.Set("max", "10") + + body, err := c.doRequest(ctx, "GET", "/v2/shopping/flight-offers", query, nil) + if err != nil { + return nil, err + } + + var response struct { + Data []amadeusFlightOffer `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal flights: %w", err) + } + + offers := make([]FlightOffer, 0, len(response.Data)) + for _, data := range response.Data { + offer := c.convertFlightOffer(data) + offers = append(offers, offer) + } + + return offers, nil +} + +func (c *AmadeusClient) SearchHotels(ctx context.Context, req HotelSearchRequest) ([]HotelOffer, error) { + query := url.Values{} + query.Set("cityCode", req.CityCode) + query.Set("checkInDate", req.CheckIn) + query.Set("checkOutDate", req.CheckOut) + query.Set("adults", fmt.Sprintf("%d", req.Adults)) + + if req.Rooms > 0 { + query.Set("roomQuantity", fmt.Sprintf("%d", req.Rooms)) + } + if req.Currency != "" { + query.Set("currency", req.Currency) + } + if req.Rating > 0 { + query.Set("ratings", fmt.Sprintf("%d", req.Rating)) + } + query.Set("bestRateOnly", "true") + + hotelListBody, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations/hotels/by-city", query, nil) + if err != nil { + return nil, fmt.Errorf("fetch hotel list: %w", err) + } + + var hotelListResp struct { + Data []struct { + HotelID string `json:"hotelId"` + Name string `json:"name"` + GeoCode struct { + Lat float64 `json:"latitude"` + Lng float64 `json:"longitude"` + } `json:"geoCode"` + Address struct { + CountryCode string `json:"countryCode"` + } `json:"address"` + } `json:"data"` + } + + if err := json.Unmarshal(hotelListBody, &hotelListResp); err != nil { + return nil, fmt.Errorf("unmarshal hotel list: %w", err) + } + + offers := make([]HotelOffer, 0) + for i, h := range hotelListResp.Data { + if i >= 10 { + break + } + offer := HotelOffer{ + ID: h.HotelID, + Name: h.Name, + Lat: h.GeoCode.Lat, + Lng: h.GeoCode.Lng, + Currency: req.Currency, + CheckIn: req.CheckIn, + CheckOut: req.CheckOut, + } + offers = append(offers, offer) + } + + return offers, nil +} + +func (c *AmadeusClient) GetAirportByCode(ctx context.Context, code string) (*GeoLocation, error) { + query := url.Values{} + query.Set("subType", "AIRPORT") + query.Set("keyword", code) + + body, err := c.doRequest(ctx, "GET", "/v1/reference-data/locations", query, nil) + if err != nil { + return nil, err + } + + var response struct { + Data []struct { + IATACode string `json:"iataCode"` + Name string `json:"name"` + GeoCode struct { + Lat float64 `json:"latitude"` + Lng float64 `json:"longitude"` + } `json:"geoCode"` + Address struct { + CountryName string `json:"countryName"` + } `json:"address"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, err + } + + if len(response.Data) == 0 { + return nil, fmt.Errorf("airport not found: %s", code) + } + + loc := response.Data[0] + return &GeoLocation{ + Lat: loc.GeoCode.Lat, + Lng: loc.GeoCode.Lng, + Name: loc.Name, + Country: loc.Address.CountryName, + }, nil +} + +type amadeusFlightOffer struct { + ID string `json:"id"` + Itineraries []struct { + Duration string `json:"duration"` + Segments []struct { + Departure struct { + IATACode string `json:"iataCode"` + At string `json:"at"` + } `json:"departure"` + Arrival struct { + IATACode string `json:"iataCode"` + At string `json:"at"` + } `json:"arrival"` + CarrierCode string `json:"carrierCode"` + Number string `json:"number"` + Duration string `json:"duration"` + } `json:"segments"` + } `json:"itineraries"` + Price struct { + Total string `json:"total"` + Currency string `json:"currency"` + } `json:"price"` + TravelerPricings []struct { + FareDetailsBySegment []struct { + Cabin string `json:"cabin"` + } `json:"fareDetailsBySegment"` + } `json:"travelerPricings"` + NumberOfBookableSeats int `json:"numberOfBookableSeats"` +} + +func (c *AmadeusClient) convertFlightOffer(data amadeusFlightOffer) FlightOffer { + offer := FlightOffer{ + ID: data.ID, + SeatsAvailable: data.NumberOfBookableSeats, + } + + if len(data.Itineraries) > 0 && len(data.Itineraries[0].Segments) > 0 { + itin := data.Itineraries[0] + firstSeg := itin.Segments[0] + lastSeg := itin.Segments[len(itin.Segments)-1] + + offer.DepartureAirport = firstSeg.Departure.IATACode + offer.DepartureTime = firstSeg.Departure.At + offer.ArrivalAirport = lastSeg.Arrival.IATACode + offer.ArrivalTime = lastSeg.Arrival.At + offer.Airline = firstSeg.CarrierCode + offer.FlightNumber = firstSeg.CarrierCode + firstSeg.Number + offer.Stops = len(itin.Segments) - 1 + offer.Duration = parseDuration(itin.Duration) + } + + if price, err := parseFloat(data.Price.Total); err == nil { + offer.Price = price + } + offer.Currency = data.Price.Currency + + if len(data.TravelerPricings) > 0 && len(data.TravelerPricings[0].FareDetailsBySegment) > 0 { + offer.CabinClass = data.TravelerPricings[0].FareDetailsBySegment[0].Cabin + } + + return offer +} + +func parseDuration(d string) int { + var hours, minutes int + fmt.Sscanf(d, "PT%dH%dM", &hours, &minutes) + return hours*60 + minutes +} + +func parseFloat(s string) (float64, error) { + var f float64 + _, err := fmt.Sscanf(s, "%f", &f) + return f, err +} diff --git a/backend/internal/travel/llm_client.go b/backend/internal/travel/llm_client.go new file mode 100644 index 0000000..a4372eb --- /dev/null +++ b/backend/internal/travel/llm_client.go @@ -0,0 +1,57 @@ +package travel + +import ( + "context" + + "github.com/gooseek/backend/internal/llm" +) + +type LLMClientAdapter struct { + client llm.Client +} + +func NewLLMClientAdapter(client llm.Client) *LLMClientAdapter { + return &LLMClientAdapter{client: client} +} + +func (a *LLMClientAdapter) StreamChat(ctx context.Context, messages []ChatMessage, onChunk func(string)) error { + llmMessages := make([]llm.Message, len(messages)) + for i, m := range messages { + var role llm.Role + switch m.Role { + case "system": + role = llm.RoleSystem + case "user": + role = llm.RoleUser + case "assistant": + role = llm.RoleAssistant + default: + role = llm.RoleUser + } + llmMessages[i] = llm.Message{ + Role: role, + Content: m.Content, + } + } + + req := llm.StreamRequest{ + Messages: llmMessages, + Options: llm.StreamOptions{ + MaxTokens: 4096, + Temperature: 0.7, + }, + } + + ch, err := a.client.StreamText(ctx, req) + if err != nil { + return err + } + + for chunk := range ch { + if chunk.ContentChunk != "" { + onChunk(chunk.ContentChunk) + } + } + + return nil +} diff --git a/backend/internal/travel/openroute.go b/backend/internal/travel/openroute.go new file mode 100644 index 0000000..5ccf57d --- /dev/null +++ b/backend/internal/travel/openroute.go @@ -0,0 +1,335 @@ +package travel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type OpenRouteClient struct { + apiKey string + baseURL string + httpClient *http.Client +} + +func (c *OpenRouteClient) HasAPIKey() bool { + return c.apiKey != "" +} + +type OpenRouteConfig struct { + APIKey string + BaseURL string +} + +func NewOpenRouteClient(cfg OpenRouteConfig) *OpenRouteClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.openrouteservice.org" + } + + return &OpenRouteClient{ + apiKey: cfg.APIKey, + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *OpenRouteClient) doRequest(ctx context.Context, method, path string, query url.Values) ([]byte, error) { + fullURL := c.baseURL + path + if len(query) > 0 { + fullURL += "?" + query.Encode() + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", c.apiKey) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func (c *OpenRouteClient) GetDirections(ctx context.Context, points []GeoLocation, profile string) (*RouteDirection, error) { + if len(points) < 2 { + return nil, fmt.Errorf("at least 2 points required") + } + + if profile == "" { + profile = "driving-car" + } + + coords := make([]string, len(points)) + for i, p := range points { + coords[i] = fmt.Sprintf("%f,%f", p.Lng, p.Lat) + } + + query := url.Values{} + query.Set("start", coords[0]) + query.Set("end", coords[len(coords)-1]) + + body, err := c.doRequest(ctx, "GET", "/v2/directions/"+profile, query) + if err != nil { + return nil, err + } + + var response struct { + Features []struct { + Geometry struct { + Coordinates [][2]float64 `json:"coordinates"` + Type string `json:"type"` + } `json:"geometry"` + Properties struct { + Summary struct { + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + } `json:"summary"` + Segments []struct { + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + Steps []struct { + Instruction string `json:"instruction"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + Type int `json:"type"` + } `json:"steps"` + } `json:"segments"` + } `json:"properties"` + } `json:"features"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal directions: %w", err) + } + + if len(response.Features) == 0 { + return nil, fmt.Errorf("no route found") + } + + feature := response.Features[0] + direction := &RouteDirection{ + Geometry: RouteGeometry{ + Coordinates: feature.Geometry.Coordinates, + Type: feature.Geometry.Type, + }, + Distance: feature.Properties.Summary.Distance, + Duration: feature.Properties.Summary.Duration, + } + + for _, seg := range feature.Properties.Segments { + for _, step := range seg.Steps { + direction.Steps = append(direction.Steps, RouteStep{ + Instruction: step.Instruction, + Distance: step.Distance, + Duration: step.Duration, + Type: fmt.Sprintf("%d", step.Type), + }) + } + } + + return direction, nil +} + +func (c *OpenRouteClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) { + params := url.Values{} + params.Set("api_key", c.apiKey) + params.Set("text", query) + params.Set("size", "1") + + body, err := c.doRequest(ctx, "GET", "/geocode/search", params) + if err != nil { + return nil, err + } + + var response struct { + Features []struct { + Geometry struct { + Coordinates [2]float64 `json:"coordinates"` + } `json:"geometry"` + Properties struct { + Name string `json:"name"` + Country string `json:"country"` + Label string `json:"label"` + } `json:"properties"` + } `json:"features"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal geocode: %w", err) + } + + if len(response.Features) == 0 { + return nil, fmt.Errorf("location not found: %s", query) + } + + feature := response.Features[0] + return &GeoLocation{ + Lng: feature.Geometry.Coordinates[0], + Lat: feature.Geometry.Coordinates[1], + Name: feature.Properties.Name, + Country: feature.Properties.Country, + }, nil +} + +func (c *OpenRouteClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) { + params := url.Values{} + params.Set("api_key", c.apiKey) + params.Set("point.lat", fmt.Sprintf("%f", lat)) + params.Set("point.lon", fmt.Sprintf("%f", lng)) + params.Set("size", "1") + + body, err := c.doRequest(ctx, "GET", "/geocode/reverse", params) + if err != nil { + return nil, err + } + + var response struct { + Features []struct { + Properties struct { + Name string `json:"name"` + Country string `json:"country"` + Label string `json:"label"` + } `json:"properties"` + } `json:"features"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal reverse geocode: %w", err) + } + + if len(response.Features) == 0 { + return nil, fmt.Errorf("location not found at %f,%f", lat, lng) + } + + feature := response.Features[0] + return &GeoLocation{ + Lat: lat, + Lng: lng, + Name: feature.Properties.Name, + Country: feature.Properties.Country, + }, nil +} + +func (c *OpenRouteClient) SearchPOI(ctx context.Context, req POISearchRequest) ([]POI, error) { + params := url.Values{} + params.Set("api_key", c.apiKey) + params.Set("request", "pois") + params.Set("geometry", fmt.Sprintf(`{"geojson":{"type":"Point","coordinates":[%f,%f]},"buffer":%d}`, req.Lng, req.Lat, req.Radius)) + + if len(req.Categories) > 0 { + params.Set("filters", fmt.Sprintf(`{"category_ids":[%s]}`, strings.Join(req.Categories, ","))) + } + + limit := req.Limit + if limit == 0 { + limit = 20 + } + params.Set("limit", fmt.Sprintf("%d", limit)) + + body, err := c.doRequest(ctx, "POST", "/pois", params) + if err != nil { + return nil, err + } + + var response struct { + Features []struct { + Geometry struct { + Coordinates [2]float64 `json:"coordinates"` + } `json:"geometry"` + Properties struct { + OSMId int64 `json:"osm_id"` + Name string `json:"osm_tags.name"` + Category struct { + ID int `json:"category_id"` + Name string `json:"category_name"` + } `json:"category"` + Distance float64 `json:"distance"` + } `json:"properties"` + } `json:"features"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal POI: %w", err) + } + + pois := make([]POI, 0, len(response.Features)) + for _, f := range response.Features { + poi := POI{ + ID: fmt.Sprintf("%d", f.Properties.OSMId), + Name: f.Properties.Name, + Lng: f.Geometry.Coordinates[0], + Lat: f.Geometry.Coordinates[1], + Category: f.Properties.Category.Name, + Distance: f.Properties.Distance, + } + pois = append(pois, poi) + } + + return pois, nil +} + +func (c *OpenRouteClient) GetIsochrone(ctx context.Context, lat, lng float64, timeMinutes int, profile string) (*RouteGeometry, error) { + if profile == "" { + profile = "driving-car" + } + + params := url.Values{} + params.Set("api_key", c.apiKey) + params.Set("locations", fmt.Sprintf("%f,%f", lng, lat)) + params.Set("range", fmt.Sprintf("%d", timeMinutes*60)) + + body, err := c.doRequest(ctx, "GET", "/v2/isochrones/"+profile, params) + if err != nil { + return nil, err + } + + var response struct { + Features []struct { + Geometry struct { + Coordinates [][][2]float64 `json:"coordinates"` + Type string `json:"type"` + } `json:"geometry"` + } `json:"features"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal isochrone: %w", err) + } + + if len(response.Features) == 0 { + return nil, fmt.Errorf("no isochrone found") + } + + coords := make([][2]float64, 0) + if len(response.Features[0].Geometry.Coordinates) > 0 { + coords = response.Features[0].Geometry.Coordinates[0] + } + + return &RouteGeometry{ + Coordinates: coords, + Type: response.Features[0].Geometry.Type, + }, nil +} diff --git a/backend/internal/travel/repository.go b/backend/internal/travel/repository.go new file mode 100644 index 0000000..0a09dce --- /dev/null +++ b/backend/internal/travel/repository.go @@ -0,0 +1,501 @@ +package travel + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) InitSchema(ctx context.Context) error { + query := ` + CREATE TABLE IF NOT EXISTS trips ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + title VARCHAR(255) NOT NULL, + destination VARCHAR(255) NOT NULL, + description TEXT, + cover_image TEXT, + start_date TIMESTAMP NOT NULL, + end_date TIMESTAMP NOT NULL, + route JSONB DEFAULT '[]', + flights JSONB DEFAULT '[]', + hotels JSONB DEFAULT '[]', + total_budget DECIMAL(12,2), + currency VARCHAR(3) DEFAULT 'RUB', + status VARCHAR(20) DEFAULT 'planned', + ai_summary TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id); + CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status); + CREATE INDEX IF NOT EXISTS idx_trips_start_date ON trips(start_date); + + CREATE TABLE IF NOT EXISTS trip_drafts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + session_id VARCHAR(255), + brief JSONB DEFAULT '{}', + candidates JSONB DEFAULT '{}', + selected JSONB DEFAULT '{}', + phase VARCHAR(50) DEFAULT 'planning', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_trip_drafts_user_id ON trip_drafts(user_id); + CREATE INDEX IF NOT EXISTS idx_trip_drafts_session_id ON trip_drafts(session_id); + + CREATE TABLE IF NOT EXISTS geocode_cache ( + query_hash VARCHAR(64) PRIMARY KEY, + query_text TEXT NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + name TEXT, + country TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at); + ` + + _, err := r.db.ExecContext(ctx, query) + return err +} + +func (r *Repository) CreateTrip(ctx context.Context, trip *Trip) error { + if trip.ID == "" { + trip.ID = uuid.New().String() + } + trip.CreatedAt = time.Now() + trip.UpdatedAt = time.Now() + + routeJSON, err := json.Marshal(trip.Route) + if err != nil { + return fmt.Errorf("marshal route: %w", err) + } + + flightsJSON, err := json.Marshal(trip.Flights) + if err != nil { + return fmt.Errorf("marshal flights: %w", err) + } + + hotelsJSON, err := json.Marshal(trip.Hotels) + if err != nil { + return fmt.Errorf("marshal hotels: %w", err) + } + + query := ` + INSERT INTO trips ( + id, user_id, title, destination, description, cover_image, + start_date, end_date, route, flights, hotels, + total_budget, currency, status, ai_summary, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ` + + _, err = r.db.ExecContext(ctx, query, + trip.ID, trip.UserID, trip.Title, trip.Destination, trip.Description, trip.CoverImage, + trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON, + trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.CreatedAt, trip.UpdatedAt, + ) + return err +} + +func (r *Repository) GetTrip(ctx context.Context, id string) (*Trip, error) { + query := ` + SELECT id, user_id, title, destination, description, cover_image, + start_date, end_date, route, flights, hotels, + total_budget, currency, status, ai_summary, created_at, updated_at + FROM trips WHERE id = $1 + ` + + var trip Trip + var routeJSON, flightsJSON, hotelsJSON []byte + var description, coverImage, aiSummary sql.NullString + var totalBudget sql.NullFloat64 + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage, + &trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON, + &totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if description.Valid { + trip.Description = description.String + } + if coverImage.Valid { + trip.CoverImage = coverImage.String + } + if aiSummary.Valid { + trip.AISummary = aiSummary.String + } + if totalBudget.Valid { + trip.TotalBudget = totalBudget.Float64 + } + + if err := json.Unmarshal(routeJSON, &trip.Route); err != nil { + return nil, fmt.Errorf("unmarshal route: %w", err) + } + if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil { + return nil, fmt.Errorf("unmarshal flights: %w", err) + } + if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil { + return nil, fmt.Errorf("unmarshal hotels: %w", err) + } + + return &trip, nil +} + +func (r *Repository) GetTripsByUser(ctx context.Context, userID string, limit, offset int) ([]Trip, error) { + if limit == 0 { + limit = 20 + } + + query := ` + SELECT id, user_id, title, destination, description, cover_image, + start_date, end_date, route, flights, hotels, + total_budget, currency, status, ai_summary, created_at, updated_at + FROM trips WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + + rows, err := r.db.QueryContext(ctx, query, userID, limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var trips []Trip + for rows.Next() { + var trip Trip + var routeJSON, flightsJSON, hotelsJSON []byte + var description, coverImage, aiSummary sql.NullString + var totalBudget sql.NullFloat64 + + err := rows.Scan( + &trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage, + &trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON, + &totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if description.Valid { + trip.Description = description.String + } + if coverImage.Valid { + trip.CoverImage = coverImage.String + } + if aiSummary.Valid { + trip.AISummary = aiSummary.String + } + if totalBudget.Valid { + trip.TotalBudget = totalBudget.Float64 + } + + if err := json.Unmarshal(routeJSON, &trip.Route); err != nil { + return nil, fmt.Errorf("unmarshal route: %w", err) + } + if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil { + return nil, fmt.Errorf("unmarshal flights: %w", err) + } + if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil { + return nil, fmt.Errorf("unmarshal hotels: %w", err) + } + + trips = append(trips, trip) + } + + return trips, nil +} + +func (r *Repository) UpdateTrip(ctx context.Context, trip *Trip) error { + trip.UpdatedAt = time.Now() + + routeJSON, err := json.Marshal(trip.Route) + if err != nil { + return fmt.Errorf("marshal route: %w", err) + } + + flightsJSON, err := json.Marshal(trip.Flights) + if err != nil { + return fmt.Errorf("marshal flights: %w", err) + } + + hotelsJSON, err := json.Marshal(trip.Hotels) + if err != nil { + return fmt.Errorf("marshal hotels: %w", err) + } + + query := ` + UPDATE trips SET + title = $2, destination = $3, description = $4, cover_image = $5, + start_date = $6, end_date = $7, route = $8, flights = $9, hotels = $10, + total_budget = $11, currency = $12, status = $13, ai_summary = $14, updated_at = $15 + WHERE id = $1 + ` + + _, err = r.db.ExecContext(ctx, query, + trip.ID, trip.Title, trip.Destination, trip.Description, trip.CoverImage, + trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON, + trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.UpdatedAt, + ) + return err +} + +func (r *Repository) DeleteTrip(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM trips WHERE id = $1", id) + return err +} + +func (r *Repository) GetTripsByStatus(ctx context.Context, userID string, status TripStatus) ([]Trip, error) { + query := ` + SELECT id, user_id, title, destination, description, cover_image, + start_date, end_date, route, flights, hotels, + total_budget, currency, status, ai_summary, created_at, updated_at + FROM trips WHERE user_id = $1 AND status = $2 + ORDER BY start_date ASC + ` + + rows, err := r.db.QueryContext(ctx, query, userID, status) + if err != nil { + return nil, err + } + defer rows.Close() + + var trips []Trip + for rows.Next() { + var trip Trip + var routeJSON, flightsJSON, hotelsJSON []byte + var description, coverImage, aiSummary sql.NullString + var totalBudget sql.NullFloat64 + + err := rows.Scan( + &trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage, + &trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON, + &totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt, + ) + if err != nil { + return nil, err + } + + if description.Valid { + trip.Description = description.String + } + if coverImage.Valid { + trip.CoverImage = coverImage.String + } + if aiSummary.Valid { + trip.AISummary = aiSummary.String + } + if totalBudget.Valid { + trip.TotalBudget = totalBudget.Float64 + } + + if err := json.Unmarshal(routeJSON, &trip.Route); err != nil { + return nil, fmt.Errorf("unmarshal route: %w", err) + } + if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil { + return nil, fmt.Errorf("unmarshal flights: %w", err) + } + if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil { + return nil, fmt.Errorf("unmarshal hotels: %w", err) + } + + trips = append(trips, trip) + } + + return trips, nil +} + +func (r *Repository) CountTripsByUser(ctx context.Context, userID string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM trips WHERE user_id = $1", userID).Scan(&count) + return count, err +} + +// --- Trip Draft persistence --- + +type TripDraft struct { + ID string `json:"id"` + UserID string `json:"userId"` + SessionID string `json:"sessionId"` + Brief json.RawMessage `json:"brief"` + Candidates json.RawMessage `json:"candidates"` + Selected json.RawMessage `json:"selected"` + Phase string `json:"phase"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (r *Repository) SaveDraft(ctx context.Context, draft *TripDraft) error { + if draft.ID == "" { + draft.ID = uuid.New().String() + } + draft.UpdatedAt = time.Now() + + query := ` + INSERT INTO trip_drafts (id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (id) DO UPDATE SET + brief = EXCLUDED.brief, + candidates = EXCLUDED.candidates, + selected = EXCLUDED.selected, + phase = EXCLUDED.phase, + updated_at = EXCLUDED.updated_at + ` + + _, err := r.db.ExecContext(ctx, query, + draft.ID, draft.UserID, draft.SessionID, + draft.Brief, draft.Candidates, draft.Selected, + draft.Phase, draft.CreatedAt, draft.UpdatedAt, + ) + return err +} + +func (r *Repository) GetDraft(ctx context.Context, id string) (*TripDraft, error) { + query := ` + SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at + FROM trip_drafts WHERE id = $1 + ` + + var draft TripDraft + var userID sql.NullString + + err := r.db.QueryRowContext(ctx, query, id).Scan( + &draft.ID, &userID, &draft.SessionID, + &draft.Brief, &draft.Candidates, &draft.Selected, + &draft.Phase, &draft.CreatedAt, &draft.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if userID.Valid { + draft.UserID = userID.String + } + + return &draft, nil +} + +func (r *Repository) GetDraftBySession(ctx context.Context, sessionID string) (*TripDraft, error) { + query := ` + SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at + FROM trip_drafts WHERE session_id = $1 + ORDER BY updated_at DESC LIMIT 1 + ` + + var draft TripDraft + var userID sql.NullString + + err := r.db.QueryRowContext(ctx, query, sessionID).Scan( + &draft.ID, &userID, &draft.SessionID, + &draft.Brief, &draft.Candidates, &draft.Selected, + &draft.Phase, &draft.CreatedAt, &draft.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if userID.Valid { + draft.UserID = userID.String + } + + return &draft, nil +} + +func (r *Repository) DeleteDraft(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE id = $1", id) + return err +} + +func (r *Repository) CleanupOldDrafts(ctx context.Context, olderThan time.Duration) error { + cutoff := time.Now().Add(-olderThan) + _, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE updated_at < $1", cutoff) + return err +} + +// --- Geocode cache --- + +type GeocodeCacheEntry struct { + QueryHash string `json:"queryHash"` + QueryText string `json:"queryText"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Name string `json:"name"` + Country string `json:"country"` +} + +func (r *Repository) GetCachedGeocode(ctx context.Context, queryHash string) (*GeocodeCacheEntry, error) { + query := ` + SELECT query_hash, query_text, lat, lng, name, country + FROM geocode_cache WHERE query_hash = $1 + ` + + var entry GeocodeCacheEntry + var name, country sql.NullString + + err := r.db.QueryRowContext(ctx, query, queryHash).Scan( + &entry.QueryHash, &entry.QueryText, &entry.Lat, &entry.Lng, &name, &country, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + if name.Valid { + entry.Name = name.String + } + if country.Valid { + entry.Country = country.String + } + + return &entry, nil +} + +func (r *Repository) SaveGeocodeCache(ctx context.Context, entry *GeocodeCacheEntry) error { + query := ` + INSERT INTO geocode_cache (query_hash, query_text, lat, lng, name, country, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (query_hash) DO UPDATE SET + lat = EXCLUDED.lat, lng = EXCLUDED.lng, + name = EXCLUDED.name, country = EXCLUDED.country + ` + + _, err := r.db.ExecContext(ctx, query, + entry.QueryHash, entry.QueryText, entry.Lat, entry.Lng, entry.Name, entry.Country, + ) + return err +} + +func (r *Repository) CleanupOldGeocodeCache(ctx context.Context, olderThan time.Duration) error { + cutoff := time.Now().Add(-olderThan) + _, err := r.db.ExecContext(ctx, "DELETE FROM geocode_cache WHERE created_at < $1", cutoff) + return err +} diff --git a/backend/internal/travel/service.go b/backend/internal/travel/service.go new file mode 100644 index 0000000..8b31aea --- /dev/null +++ b/backend/internal/travel/service.go @@ -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 +} diff --git a/backend/internal/travel/travelpayouts.go b/backend/internal/travel/travelpayouts.go new file mode 100644 index 0000000..93f3afd --- /dev/null +++ b/backend/internal/travel/travelpayouts.go @@ -0,0 +1,276 @@ +package travel + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +type TravelPayoutsClient struct { + token string + marker string + baseURL string + httpClient *http.Client +} + +type TravelPayoutsConfig struct { + Token string + Marker string + BaseURL string +} + +func NewTravelPayoutsClient(cfg TravelPayoutsConfig) *TravelPayoutsClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://api.travelpayouts.com" + } + + return &TravelPayoutsClient{ + token: cfg.Token, + marker: cfg.Marker, + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +func (c *TravelPayoutsClient) doRequest(ctx context.Context, path string, query url.Values) ([]byte, error) { + if query == nil { + query = url.Values{} + } + query.Set("token", c.token) + if c.marker != "" { + query.Set("marker", c.marker) + } + + fullURL := c.baseURL + path + "?" + query.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func (c *TravelPayoutsClient) SearchFlights(ctx context.Context, req FlightSearchRequest) ([]FlightOffer, error) { + query := url.Values{} + query.Set("origin", req.Origin) + query.Set("destination", req.Destination) + query.Set("depart_date", req.DepartureDate) + if req.ReturnDate != "" { + query.Set("return_date", req.ReturnDate) + } + query.Set("adults", fmt.Sprintf("%d", req.Adults)) + if req.Currency != "" { + query.Set("currency", req.Currency) + } else { + query.Set("currency", "rub") + } + query.Set("limit", "10") + + body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data []struct { + Origin string `json:"origin"` + Destination string `json:"destination"` + OriginAirport string `json:"origin_airport"` + DestAirport string `json:"destination_airport"` + Price float64 `json:"price"` + Airline string `json:"airline"` + FlightNumber string `json:"flight_number"` + DepartureAt string `json:"departure_at"` + ReturnAt string `json:"return_at"` + Transfers int `json:"transfers"` + ReturnTransfers int `json:"return_transfers"` + Duration int `json:"duration"` + Link string `json:"link"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal flights: %w", err) + } + + offers := make([]FlightOffer, 0, len(response.Data)) + for _, d := range response.Data { + offer := FlightOffer{ + ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartureAt), + Airline: d.Airline, + FlightNumber: d.FlightNumber, + DepartureAirport: d.OriginAirport, + DepartureCity: d.Origin, + DepartureTime: d.DepartureAt, + ArrivalAirport: d.DestAirport, + ArrivalCity: d.Destination, + ArrivalTime: d.ReturnAt, + Duration: d.Duration, + Stops: d.Transfers, + Price: d.Price, + Currency: req.Currency, + BookingURL: "https://www.aviasales.ru" + d.Link, + } + offers = append(offers, offer) + } + + return offers, nil +} + +func (c *TravelPayoutsClient) GetCheapestPrices(ctx context.Context, origin, destination string, currency string) ([]FlightOffer, error) { + query := url.Values{} + query.Set("origin", origin) + query.Set("destination", destination) + if currency != "" { + query.Set("currency", currency) + } else { + query.Set("currency", "rub") + } + + body, err := c.doRequest(ctx, "/aviasales/v3/prices_for_dates", query) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data []struct { + DepartDate string `json:"depart_date"` + ReturnDate string `json:"return_date"` + Origin string `json:"origin"` + Destination string `json:"destination"` + Price float64 `json:"price"` + Airline string `json:"airline"` + Transfers int `json:"number_of_changes"` + Link string `json:"link"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal prices: %w", err) + } + + offers := make([]FlightOffer, 0, len(response.Data)) + for _, d := range response.Data { + offer := FlightOffer{ + ID: fmt.Sprintf("%s-%s-%s", d.Origin, d.Destination, d.DepartDate), + Airline: d.Airline, + DepartureCity: d.Origin, + DepartureTime: d.DepartDate, + ArrivalCity: d.Destination, + ArrivalTime: d.ReturnDate, + Stops: d.Transfers, + Price: d.Price, + Currency: currency, + BookingURL: "https://www.aviasales.ru" + d.Link, + } + offers = append(offers, offer) + } + + return offers, nil +} + +func (c *TravelPayoutsClient) GetPopularDestinations(ctx context.Context, origin string) ([]TravelSuggestion, error) { + query := url.Values{} + query.Set("origin", origin) + query.Set("currency", "rub") + + body, err := c.doRequest(ctx, "/aviasales/v3/city_directions", query) + if err != nil { + return nil, err + } + + var response struct { + Success bool `json:"success"` + Data map[string]struct { + Origin string `json:"origin"` + Destination string `json:"destination"` + Price float64 `json:"price"` + Transfers int `json:"transfers"` + Airline string `json:"airline"` + DepartDate string `json:"departure_at"` + ReturnDate string `json:"return_at"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("unmarshal destinations: %w", err) + } + + suggestions := make([]TravelSuggestion, 0) + for dest, d := range response.Data { + suggestion := TravelSuggestion{ + ID: dest, + Type: "destination", + Title: dest, + Description: fmt.Sprintf("от %s, %d пересадок", d.Airline, d.Transfers), + Price: d.Price, + Currency: "RUB", + } + suggestions = append(suggestions, suggestion) + } + + return suggestions, nil +} + +func (c *TravelPayoutsClient) GetAirportByIATA(ctx context.Context, iata string) (*GeoLocation, error) { + query := url.Values{} + query.Set("code", iata) + query.Set("locale", "ru") + + body, err := c.doRequest(ctx, "/data/ru/airports.json", query) + if err != nil { + return nil, err + } + + var airports []struct { + Code string `json:"code"` + Name string `json:"name"` + Coordinates []float64 `json:"coordinates"` + Country string `json:"country_code"` + City string `json:"city_code"` + } + + if err := json.Unmarshal(body, &airports); err != nil { + return nil, fmt.Errorf("unmarshal airports: %w", err) + } + + for _, a := range airports { + if a.Code == iata && len(a.Coordinates) >= 2 { + return &GeoLocation{ + Lng: a.Coordinates[0], + Lat: a.Coordinates[1], + Name: a.Name, + Country: a.Country, + }, nil + } + } + + return nil, fmt.Errorf("airport not found: %s", iata) +} diff --git a/backend/internal/travel/twogis.go b/backend/internal/travel/twogis.go new file mode 100644 index 0000000..6321288 --- /dev/null +++ b/backend/internal/travel/twogis.go @@ -0,0 +1,465 @@ +package travel + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type TwoGISClient struct { + apiKey string + baseURL string + httpClient *http.Client +} + +type TwoGISConfig struct { + APIKey string + BaseURL string +} + +func (c *TwoGISClient) HasAPIKey() bool { + return c.apiKey != "" +} + +func NewTwoGISClient(cfg TwoGISConfig) *TwoGISClient { + baseURL := cfg.BaseURL + if baseURL == "" { + baseURL = "https://catalog.api.2gis.com" + } + + return &TwoGISClient{ + apiKey: cfg.APIKey, + baseURL: baseURL, + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + } +} + +type twoGISResponse struct { + Meta struct { + Code int `json:"code"` + } `json:"meta"` + Result struct { + Items []twoGISItem `json:"items"` + Total int `json:"total"` + } `json:"result"` +} + +type twoGISItem struct { + ID string `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` + AddressName string `json:"address_name"` + Type string `json:"type"` + Point *struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } `json:"point"` + Address *struct { + Components []struct { + Type string `json:"type"` + Name string `json:"name"` + Country string `json:"country,omitempty"` + } `json:"components,omitempty"` + } `json:"address,omitempty"` + PurposeName string `json:"purpose_name,omitempty"` + Reviews *twoGISReviews `json:"reviews,omitempty"` + Schedule map[string]twoGISScheduleDay `json:"schedule,omitempty"` +} + +type twoGISReviews struct { + GeneralRating float64 `json:"general_rating"` + GeneralReviewCount int `json:"general_review_count"` + OrgRating float64 `json:"org_rating"` + OrgReviewCount int `json:"org_review_count"` +} + +type twoGISScheduleDay struct { + WorkingHours []struct { + From string `json:"from"` + To string `json:"to"` + } `json:"working_hours"` +} + +func (c *TwoGISClient) doRequest(ctx context.Context, endpoint string, params url.Values) (*twoGISResponse, error) { + params.Set("key", c.apiKey) + + fullURL := c.baseURL + endpoint + "?" + params.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("2GIS API error %d: %s", resp.StatusCode, string(body)) + } + + var result twoGISResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + if result.Meta.Code >= 400 { + return nil, fmt.Errorf("2GIS API meta error %d: %s", result.Meta.Code, string(body)) + } + + return &result, nil +} + +func (c *TwoGISClient) Geocode(ctx context.Context, query string) (*GeoLocation, error) { + params := url.Values{} + params.Set("q", query) + params.Set("fields", "items.point") + params.Set("type", "building,street,adm_div,adm_div.city,adm_div.place,adm_div.settlement,crossroad,attraction") + + result, err := c.doRequest(ctx, "/3.0/items/geocode", params) + if err != nil { + return nil, err + } + + if len(result.Result.Items) == 0 { + return nil, fmt.Errorf("location not found: %s", query) + } + + item := result.Result.Items[0] + if item.Point == nil { + return nil, fmt.Errorf("no coordinates for: %s", query) + } + + country := "" + if item.Address != nil { + for _, comp := range item.Address.Components { + if comp.Type == "country" { + country = comp.Name + break + } + } + } + + name := item.Name + if name == "" { + name = item.FullName + } + + return &GeoLocation{ + Lat: item.Point.Lat, + Lng: item.Point.Lon, + Name: name, + Country: country, + }, nil +} + +func (c *TwoGISClient) ReverseGeocode(ctx context.Context, lat, lng float64) (*GeoLocation, error) { + params := url.Values{} + params.Set("lat", fmt.Sprintf("%f", lat)) + params.Set("lon", fmt.Sprintf("%f", lng)) + params.Set("fields", "items.point") + + result, err := c.doRequest(ctx, "/3.0/items/geocode", params) + if err != nil { + return nil, err + } + + if len(result.Result.Items) == 0 { + return nil, fmt.Errorf("location not found at %f,%f", lat, lng) + } + + item := result.Result.Items[0] + + country := "" + if item.Address != nil { + for _, comp := range item.Address.Components { + if comp.Type == "country" { + country = comp.Name + break + } + } + } + + name := item.Name + if name == "" { + name = item.FullName + } + + return &GeoLocation{ + Lat: lat, + Lng: lng, + Name: name, + Country: country, + }, nil +} + +type TwoGISPlace struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Type string `json:"type"` + Purpose string `json:"purpose"` + Rating float64 `json:"rating"` + ReviewCount int `json:"reviewCount"` + Schedule map[string]string `json:"schedule,omitempty"` +} + +func (c *TwoGISClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]TwoGISPlace, error) { + params := url.Values{} + params.Set("q", query) + params.Set("point", fmt.Sprintf("%f,%f", lng, lat)) + params.Set("radius", fmt.Sprintf("%d", radius)) + params.Set("fields", "items.point,items.address,items.reviews,items.schedule") + params.Set("page_size", "10") + + result, err := c.doRequest(ctx, "/3.0/items", params) + if err != nil { + return nil, err + } + + places := make([]TwoGISPlace, 0, len(result.Result.Items)) + for _, item := range result.Result.Items { + if item.Point == nil { + continue + } + + addr := item.AddressName + if addr == "" { + addr = item.FullName + } + + place := TwoGISPlace{ + ID: item.ID, + Name: item.Name, + Address: addr, + Lat: item.Point.Lat, + Lng: item.Point.Lon, + Type: item.Type, + Purpose: item.PurposeName, + } + + if item.Reviews != nil { + place.Rating = item.Reviews.GeneralRating + place.ReviewCount = item.Reviews.GeneralReviewCount + if place.Rating == 0 { + place.Rating = item.Reviews.OrgRating + } + if place.ReviewCount == 0 { + place.ReviewCount = item.Reviews.OrgReviewCount + } + } + + if item.Schedule != nil { + place.Schedule = formatSchedule(item.Schedule) + } + + places = append(places, place) + } + + return places, nil +} + +func formatSchedule(sched map[string]twoGISScheduleDay) map[string]string { + dayOrder := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + dayRu := map[string]string{ + "Mon": "Пн", "Tue": "Вт", "Wed": "Ср", "Thu": "Чт", + "Fri": "Пт", "Sat": "Сб", "Sun": "Вс", + } + result := make(map[string]string, len(sched)) + for _, d := range dayOrder { + if day, ok := sched[d]; ok && len(day.WorkingHours) > 0 { + wh := day.WorkingHours[0] + result[dayRu[d]] = wh.From + "–" + wh.To + } + } + return result +} + +const twoGISRoutingBaseURL = "https://routing.api.2gis.com" + +type twoGISRoutingRequest struct { + Points []twoGISRoutePoint `json:"points"` + Transport string `json:"transport"` + Output string `json:"output"` + Locale string `json:"locale"` +} + +type twoGISRoutePoint struct { + Type string `json:"type"` + Lon float64 `json:"lon"` + Lat float64 `json:"lat"` +} + +type twoGISRoutingResponse struct { + Message string `json:"message"` + Result []twoGISRoutingResult `json:"result"` +} + +type twoGISRoutingResult struct { + ID string `json:"id"` + Algorithm string `json:"algorithm"` + TotalDistance int `json:"total_distance"` + TotalDuration int `json:"total_duration"` + Maneuvers []twoGISManeuver `json:"maneuvers"` +} + +type twoGISManeuver struct { + ID string `json:"id"` + Comment string `json:"comment"` + Type string `json:"type"` + OutcomingPath *twoGISOutcomingPath `json:"outcoming_path,omitempty"` +} + +type twoGISOutcomingPath struct { + Distance int `json:"distance"` + Duration int `json:"duration"` + Geometry []twoGISPathGeometry `json:"geometry"` +} + +type twoGISPathGeometry struct { + Selection string `json:"selection"` + Length int `json:"length"` +} + +func (c *TwoGISClient) GetRoute(ctx context.Context, points []GeoLocation, transport string) (*RouteDirection, error) { + if len(points) < 2 { + return nil, fmt.Errorf("at least 2 points required for routing") + } + if transport == "" { + transport = "driving" + } + + routePoints := make([]twoGISRoutePoint, len(points)) + for i, p := range points { + routePoints[i] = twoGISRoutePoint{ + Type: "stop", + Lon: p.Lng, + Lat: p.Lat, + } + } + + reqBody := twoGISRoutingRequest{ + Points: routePoints, + Transport: transport, + Output: "detailed", + Locale: "ru", + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal routing request: %w", err) + } + + reqURL := fmt.Sprintf("%s/routing/7.0.0/global?key=%s", twoGISRoutingBaseURL, url.QueryEscape(c.apiKey)) + req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("create routing request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute routing request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read routing response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("2GIS Routing API error %d: %s", resp.StatusCode, string(body)) + } + + var routingResp twoGISRoutingResponse + if err := json.Unmarshal(body, &routingResp); err != nil { + return nil, fmt.Errorf("unmarshal routing response: %w", err) + } + + if routingResp.Message != "" && routingResp.Message != "OK" && len(routingResp.Result) == 0 { + return nil, fmt.Errorf("2GIS Routing error: %s", routingResp.Message) + } + + if len(routingResp.Result) == 0 { + return nil, fmt.Errorf("no route found") + } + + route := routingResp.Result[0] + + var allCoords [][2]float64 + var steps []RouteStep + + for _, m := range route.Maneuvers { + if m.OutcomingPath != nil { + for _, geom := range m.OutcomingPath.Geometry { + coords := parseWKTLineString(geom.Selection) + allCoords = append(allCoords, coords...) + } + + if m.Comment != "" { + steps = append(steps, RouteStep{ + Instruction: m.Comment, + Distance: float64(m.OutcomingPath.Distance), + Duration: float64(m.OutcomingPath.Duration), + Type: m.Type, + }) + } + } + } + + return &RouteDirection{ + Geometry: RouteGeometry{ + Coordinates: allCoords, + Type: "LineString", + }, + Distance: float64(route.TotalDistance), + Duration: float64(route.TotalDuration), + Steps: steps, + }, nil +} + +func parseWKTLineString(wkt string) [][2]float64 { + wkt = strings.TrimSpace(wkt) + if !strings.HasPrefix(wkt, "LINESTRING(") { + return nil + } + + inner := wkt[len("LINESTRING(") : len(wkt)-1] + pairs := strings.Split(inner, ",") + coords := make([][2]float64, 0, len(pairs)) + + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + parts := strings.Fields(pair) + if len(parts) != 2 { + continue + } + lon, err1 := strconv.ParseFloat(parts[0], 64) + lat, err2 := strconv.ParseFloat(parts[1], 64) + if err1 != nil || err2 != nil { + continue + } + coords = append(coords, [2]float64{lon, lat}) + } + + return coords +} diff --git a/backend/internal/travel/types.go b/backend/internal/travel/types.go new file mode 100644 index 0000000..920f4dd --- /dev/null +++ b/backend/internal/travel/types.go @@ -0,0 +1,203 @@ +package travel + +import "time" + +type TripStatus string + +const ( + TripStatusPlanned TripStatus = "planned" + TripStatusBooked TripStatus = "booked" + TripStatusCompleted TripStatus = "completed" + TripStatusCancelled TripStatus = "cancelled" +) + +type RoutePointType string + +const ( + RoutePointAirport RoutePointType = "airport" + RoutePointHotel RoutePointType = "hotel" + RoutePointAttraction RoutePointType = "attraction" + RoutePointRestaurant RoutePointType = "restaurant" + RoutePointTransport RoutePointType = "transport" + RoutePointCustom RoutePointType = "custom" +) + +type Trip struct { + ID string `json:"id"` + UserID string `json:"userId"` + Title string `json:"title"` + Destination string `json:"destination"` + Description string `json:"description,omitempty"` + CoverImage string `json:"coverImage,omitempty"` + StartDate time.Time `json:"startDate"` + EndDate time.Time `json:"endDate"` + Route []RoutePoint `json:"route"` + Flights []FlightOffer `json:"flights,omitempty"` + Hotels []HotelOffer `json:"hotels,omitempty"` + TotalBudget float64 `json:"totalBudget,omitempty"` + Currency string `json:"currency"` + Status TripStatus `json:"status"` + AISummary string `json:"aiSummary,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type RoutePoint struct { + ID string `json:"id"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Name string `json:"name"` + Address string `json:"address,omitempty"` + Type RoutePointType `json:"type"` + AIComment string `json:"aiComment,omitempty"` + Duration int `json:"duration,omitempty"` + Cost float64 `json:"cost,omitempty"` + Order int `json:"order"` + Date string `json:"date,omitempty"` + Photos []string `json:"photos,omitempty"` +} + +type FlightOffer struct { + ID string `json:"id"` + Airline string `json:"airline"` + AirlineLogo string `json:"airlineLogo,omitempty"` + FlightNumber string `json:"flightNumber"` + DepartureAirport string `json:"departureAirport"` + DepartureCity string `json:"departureCity"` + DepartureTime string `json:"departureTime"` + ArrivalAirport string `json:"arrivalAirport"` + ArrivalCity string `json:"arrivalCity"` + ArrivalTime string `json:"arrivalTime"` + Duration int `json:"duration"` + Stops int `json:"stops"` + Price float64 `json:"price"` + Currency string `json:"currency"` + CabinClass string `json:"cabinClass"` + SeatsAvailable int `json:"seatsAvailable,omitempty"` + BookingURL string `json:"bookingUrl,omitempty"` +} + +type HotelOffer struct { + ID string `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Rating float64 `json:"rating"` + ReviewCount int `json:"reviewCount,omitempty"` + Stars int `json:"stars,omitempty"` + Price float64 `json:"price"` + PricePerNight float64 `json:"pricePerNight"` + Currency string `json:"currency"` + CheckIn string `json:"checkIn"` + CheckOut string `json:"checkOut"` + RoomType string `json:"roomType,omitempty"` + Amenities []string `json:"amenities,omitempty"` + Photos []string `json:"photos,omitempty"` + BookingURL string `json:"bookingUrl,omitempty"` +} + +type TravelPlanRequest struct { + Query string `json:"query"` + StartDate string `json:"startDate,omitempty"` + EndDate string `json:"endDate,omitempty"` + Travelers int `json:"travelers,omitempty"` + Budget float64 `json:"budget,omitempty"` + Currency string `json:"currency,omitempty"` + Preferences *TravelPreferences `json:"preferences,omitempty"` + History [][2]string `json:"history,omitempty"` +} + +type TravelPreferences struct { + TravelStyle string `json:"travelStyle,omitempty"` + Interests []string `json:"interests,omitempty"` + AvoidTypes []string `json:"avoidTypes,omitempty"` + TransportModes []string `json:"transportModes,omitempty"` +} + +type TravelSuggestion struct { + ID string `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image,omitempty"` + Price float64 `json:"price,omitempty"` + Currency string `json:"currency,omitempty"` + Rating float64 `json:"rating,omitempty"` + Lat float64 `json:"lat,omitempty"` + Lng float64 `json:"lng,omitempty"` +} + +type GeoLocation struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Name string `json:"name,omitempty"` + Country string `json:"country,omitempty"` +} + +type RouteDirection struct { + Geometry RouteGeometry `json:"geometry"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + Steps []RouteStep `json:"steps,omitempty"` +} + +type RouteGeometry struct { + Coordinates [][2]float64 `json:"coordinates"` + Type string `json:"type"` +} + +type RouteStep struct { + Instruction string `json:"instruction"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + Type string `json:"type"` +} + +type FlightSearchRequest struct { + Origin string `json:"origin"` + Destination string `json:"destination"` + DepartureDate string `json:"departureDate"` + ReturnDate string `json:"returnDate,omitempty"` + Adults int `json:"adults"` + Children int `json:"children,omitempty"` + CabinClass string `json:"cabinClass,omitempty"` + MaxPrice int `json:"maxPrice,omitempty"` + Currency string `json:"currency,omitempty"` +} + +type HotelSearchRequest struct { + CityCode string `json:"cityCode"` + Lat float64 `json:"lat,omitempty"` + Lng float64 `json:"lng,omitempty"` + Radius int `json:"radius,omitempty"` + CheckIn string `json:"checkIn"` + CheckOut string `json:"checkOut"` + Adults int `json:"adults"` + Rooms int `json:"rooms,omitempty"` + MaxPrice int `json:"maxPrice,omitempty"` + Currency string `json:"currency,omitempty"` + Rating int `json:"rating,omitempty"` +} + +type POISearchRequest struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Radius int `json:"radius"` + Categories []string `json:"categories,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type POI struct { + ID string `json:"id"` + Name string `json:"name"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Category string `json:"category"` + Address string `json:"address,omitempty"` + Phone string `json:"phone,omitempty"` + Website string `json:"website,omitempty"` + OpeningHours string `json:"openingHours,omitempty"` + Rating float64 `json:"rating,omitempty"` + Distance float64 `json:"distance,omitempty"` +} diff --git a/backend/internal/types/widgets.go b/backend/internal/types/widgets.go index a9ccf67..70252d0 100644 --- a/backend/internal/types/widgets.go +++ b/backend/internal/types/widgets.go @@ -12,6 +12,17 @@ const ( WidgetImageGallery WidgetType = "image_gallery" WidgetVideoEmbed WidgetType = "video_embed" WidgetKnowledge WidgetType = "knowledge_card" + + WidgetTravelMap WidgetType = "travel_map" + WidgetTravelEvents WidgetType = "travel_events" + WidgetTravelPOI WidgetType = "travel_poi" + WidgetTravelHotels WidgetType = "travel_hotels" + WidgetTravelTransport WidgetType = "travel_transport" + WidgetTravelItinerary WidgetType = "travel_itinerary" + WidgetTravelBudget WidgetType = "travel_budget" + WidgetTravelClarifying WidgetType = "travel_clarifying" + WidgetTravelActions WidgetType = "travel_actions" + WidgetTravelContext WidgetType = "travel_context" ) type ProductData struct { diff --git a/backend/pkg/config/config.go b/backend/pkg/config/config.go index 9faa62f..e61bbe4 100644 --- a/backend/pkg/config/config.go +++ b/backend/pkg/config/config.go @@ -45,6 +45,11 @@ type Config struct { ComputerSvcURL string FinanceHeatmapURL string LearningSvcURL string + TravelSvcURL string + + // TravelPayouts + TravelPayoutsToken string + TravelPayoutsMarker string // MinIO / S3 storage MinioEndpoint string @@ -124,6 +129,10 @@ func Load() (*Config, error) { ComputerSvcURL: getEnv("COMPUTER_SVC_URL", "http://localhost:3030"), FinanceHeatmapURL: getEnv("FINANCE_HEATMAP_SVC_URL", "http://localhost:3033"), LearningSvcURL: getEnv("LEARNING_SVC_URL", "http://localhost:3034"), + TravelSvcURL: getEnv("TRAVEL_SVC_URL", "http://localhost:3035"), + + TravelPayoutsToken: getEnv("TRAVELPAYOUTS_TOKEN", ""), + TravelPayoutsMarker: getEnv("TRAVELPAYOUTS_MARKER", ""), MinioEndpoint: getEnv("MINIO_ENDPOINT", "minio:9000"), MinioAccessKey: getEnv("MINIO_ACCESS_KEY", "minioadmin"), diff --git a/backend/webui/next.config.mjs b/backend/webui/next.config.mjs index 7058e66..9dda544 100644 --- a/backend/webui/next.config.mjs +++ b/backend/webui/next.config.mjs @@ -4,15 +4,7 @@ const nextConfig = { reactStrictMode: true, env: { API_URL: process.env.API_URL || 'http://localhost:3015', - }, - async rewrites() { - const apiUrl = process.env.API_URL || 'http://localhost:3015'; - return [ - { - source: '/api/:path*', - destination: `${apiUrl}/api/:path*`, - }, - ]; + NEXT_PUBLIC_TWOGIS_API_KEY: process.env.NEXT_PUBLIC_TWOGIS_API_KEY || process.env.TWOGIS_API_KEY || '', }, }; diff --git a/backend/webui/package-lock.json b/backend/webui/package-lock.json index 2bbdd71..dd73fc9 100644 --- a/backend/webui/package-lock.json +++ b/backend/webui/package-lock.json @@ -8,6 +8,7 @@ "name": "gooseek-webui", "version": "1.0.0", "dependencies": { + "@2gis/mapgl": "^1.70.1", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -18,14 +19,17 @@ "@radix-ui/react-tooltip": "^1.2.8", "clsx": "^2.1.1", "framer-motion": "^12.34.3", + "leaflet": "^1.9.4", "lucide-react": "^0.454.0", "next": "^14.2.26", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-markdown": "^9.0.1", "tailwind-merge": "^2.5.4" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -35,6 +39,18 @@ "typescript": "^5.6.3" } }, + "node_modules/@2gis/mapgl": { + "version": "1.70.1", + "resolved": "https://registry.npmjs.org/@2gis/mapgl/-/mapgl-1.70.1.tgz", + "integrity": "sha512-+Kh7dUEdMNu3Nd2MO8aHpa7lgq2No9z/qdzuHUgpxQqsxs3tTQd37vWCDruzl6BaDrO9hqL16Zurs5nculOFQg==", + "license": "BSD-2-Clause", + "dependencies": { + "@types/geojson": "^7946.0.7" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -1631,6 +1647,17 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@react-leaflet/core": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz", + "integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -1671,6 +1698,12 @@ "@types/estree": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -1680,6 +1713,16 @@ "@types/unist": "*" } }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -2541,6 +2584,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3668,6 +3717,20 @@ "react": "^18.3.1" } }, + "node_modules/react-leaflet": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz", + "integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^2.1.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/react-markdown": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", diff --git a/backend/webui/package.json b/backend/webui/package.json index 99ffbcc..0f99f9f 100644 --- a/backend/webui/package.json +++ b/backend/webui/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "@2gis/mapgl": "^1.70.1", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -19,14 +20,17 @@ "@radix-ui/react-tooltip": "^1.2.8", "clsx": "^2.1.1", "framer-motion": "^12.34.3", + "leaflet": "^1.9.4", "lucide-react": "^0.454.0", "next": "^14.2.26", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-leaflet": "^4.2.1", "react-markdown": "^9.0.1", "tailwind-merge": "^2.5.4" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/backend/webui/src/app/(main)/admin/audit/page.tsx b/backend/webui/src/app/(main)/admin/audit/page.tsx index 0311266..d0b8a2e 100644 --- a/backend/webui/src/app/(main)/admin/audit/page.tsx +++ b/backend/webui/src/app/(main)/admin/audit/page.tsx @@ -83,15 +83,15 @@ export default function AdminAuditPage() { const getActionColor = (action: string) => { switch (action) { case 'create': - return 'bg-green-500/10 text-green-400'; + return 'bg-green-500/10 text-green-600'; case 'update': - return 'bg-blue-500/10 text-blue-400'; + return 'bg-blue-500/10 text-blue-600'; case 'delete': - return 'bg-red-500/10 text-red-400'; + return 'bg-red-500/10 text-red-600'; case 'publish': - return 'bg-purple-500/10 text-purple-400'; + return 'bg-purple-500/10 text-purple-600'; default: - return 'bg-gray-500/10 text-gray-400'; + return 'bg-surface text-muted'; } }; diff --git a/backend/webui/src/app/(main)/admin/discover/page.tsx b/backend/webui/src/app/(main)/admin/discover/page.tsx index 2fbc22c..ae84058 100644 --- a/backend/webui/src/app/(main)/admin/discover/page.tsx +++ b/backend/webui/src/app/(main)/admin/discover/page.tsx @@ -48,8 +48,8 @@ function CategoryModal({ category, onClose, onSave }: CategoryModalProps) { }; return ( -
-
+
+

{category ? 'Редактировать категорию' : 'Новая категория'}

@@ -138,7 +138,7 @@ function CategoryModal({ category, onClose, onSave }: CategoryModalProps) { @@ -175,8 +175,8 @@ function SourceModal({ onClose, onSave }: SourceModalProps) { }; return ( -
-
+
+

Новый источник

@@ -254,7 +254,7 @@ function SourceModal({ onClose, onSave }: SourceModalProps) { @@ -390,7 +390,7 @@ export default function AdminDiscoverPage() {

Перетащите для изменения порядка

@@ -426,7 +426,7 @@ export default function AdminDiscoverPage() { @@ -443,7 +443,7 @@ export default function AdminDiscoverPage() {

Доверенные источники новостей

diff --git a/backend/webui/src/app/(main)/admin/page.tsx b/backend/webui/src/app/(main)/admin/page.tsx index 38a3a0c..00efef7 100644 --- a/backend/webui/src/app/(main)/admin/page.tsx +++ b/backend/webui/src/app/(main)/admin/page.tsx @@ -69,7 +69,7 @@ export default function AdminDashboardPage() { if (error) { return ( -
+
{error}
); diff --git a/backend/webui/src/app/(main)/admin/posts/page.tsx b/backend/webui/src/app/(main)/admin/posts/page.tsx index f9e2b2d..d2928d9 100644 --- a/backend/webui/src/app/(main)/admin/posts/page.tsx +++ b/backend/webui/src/app/(main)/admin/posts/page.tsx @@ -46,7 +46,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) { }; return ( -
+

{post ? 'Редактировать пост' : 'Новый пост'} @@ -129,7 +129,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) { @@ -204,13 +204,13 @@ export default function AdminPostsPage() { const getStatusBadge = (status: string) => { switch (status) { case 'published': - return 'bg-green-500/10 text-green-400'; + return 'bg-green-500/10 text-green-600'; case 'draft': - return 'bg-yellow-500/10 text-yellow-400'; + return 'bg-yellow-500/10 text-yellow-600'; case 'archived': - return 'bg-gray-500/10 text-gray-400'; + return 'bg-surface text-muted'; default: - return 'bg-gray-500/10 text-gray-400'; + return 'bg-surface text-muted'; } }; @@ -236,7 +236,7 @@ export default function AdminPostsPage() {

@@ -198,7 +198,7 @@ export default function AdminUsersPage() {
- {sector.tickers.slice(0, 3).map((stock, i) => ( + {(sector.tickers ?? []).slice(0, 3).map((stock, i) => ( ))}
diff --git a/backend/webui/src/app/(main)/medicine/page.tsx b/backend/webui/src/app/(main)/medicine/page.tsx index 8db9d6f..07625d0 100644 --- a/backend/webui/src/app/(main)/medicine/page.tsx +++ b/backend/webui/src/app/(main)/medicine/page.tsx @@ -80,9 +80,9 @@ const healthArticles: Article[] = [ ]; const quickServices = [ - { icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-400' }, - { icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-400' }, - { icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-400' }, + { icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-600' }, + { icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-600' }, + { icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-600' }, { icon: Sparkles, label: 'AI Консультант', color: 'active-gradient text-gradient' }, ]; @@ -159,9 +159,9 @@ export default function MedicinePage() { {/* Disclaimer */}
- +
-

Важно

+

Важно

Информация носит справочный характер и не заменяет консультацию врача. При серьёзных симптомах обратитесь к специалисту. @@ -236,7 +236,7 @@ export default function MedicinePage() {

- +

Частые симптомы

@@ -256,7 +256,7 @@ export default function MedicinePage() {
- +

Полезные статьи

@@ -271,8 +271,8 @@ export default function MedicinePage() { {/* Emergency Info */}
- - Экстренная помощь + + Экстренная помощь

При угрозе жизни немедленно вызывайте скорую помощь @@ -280,13 +280,13 @@ export default function MedicinePage() {

📞 103 📞 112 diff --git a/backend/webui/src/app/(main)/spaces/[id]/page.tsx b/backend/webui/src/app/(main)/spaces/[id]/page.tsx index f632b19..d0800ea 100644 --- a/backend/webui/src/app/(main)/spaces/[id]/page.tsx +++ b/backend/webui/src/app/(main)/spaces/[id]/page.tsx @@ -335,7 +335,7 @@ export default function SpaceDetailPage() { - +
diff --git a/backend/webui/src/app/(main)/spaces/new/page.tsx b/backend/webui/src/app/(main)/spaces/new/page.tsx index d5fc36f..2581c01 100644 --- a/backend/webui/src/app/(main)/spaces/new/page.tsx +++ b/backend/webui/src/app/(main)/spaces/new/page.tsx @@ -105,7 +105,7 @@ export default function NewSpacePage() { `} >
-
+
{formData.icon}
@@ -183,7 +183,7 @@ export default function NewSpacePage() { onClick={() => setFormData((f) => ({ ...f, color: color.id }))} className={`w-10 h-10 rounded-xl ${color.class} transition-all ${ formData.color === color.id - ? 'ring-2 ring-white ring-offset-2 ring-offset-base' + ? 'ring-2 ring-accent ring-offset-2 ring-offset-base' : 'opacity-60 hover:opacity-100' }`} title={color.label} @@ -237,7 +237,7 @@ export default function NewSpacePage() { }`} > diff --git a/backend/webui/src/app/(main)/spaces/page.tsx b/backend/webui/src/app/(main)/spaces/page.tsx index 2cb73e8..6272320 100644 --- a/backend/webui/src/app/(main)/spaces/page.tsx +++ b/backend/webui/src/app/(main)/spaces/page.tsx @@ -160,7 +160,7 @@ export default function SpacesPage() { > {/* Header */}
-
+
@@ -173,7 +173,7 @@ export default function SpacesPage() { @@ -229,7 +229,7 @@ export default function SpacesPage() { {/* Members avatars */} {space.members && space.members.length > 0 && ( -
+
{space.members.slice(0, 4).map((member, idx) => (
void; + onDelete: () => void; } -const popularDestinations: Destination[] = [ - { - id: '1', - name: 'Сочи', - country: 'Россия', - image: '🏖️', - rating: 4.7, - price: 'от 15 000 ₽', - category: 'beach', - description: 'Черноморский курорт с горами и морем', - }, - { - id: '2', - name: 'Санкт-Петербург', - country: 'Россия', - image: '🏛️', - rating: 4.9, - price: 'от 8 000 ₽', - category: 'city', - description: 'Культурная столица России', - }, - { - id: '3', - name: 'Казань', - country: 'Россия', - image: '🕌', - rating: 4.6, - price: 'от 7 500 ₽', - category: 'city', - description: 'Древний город на Волге', - }, - { - id: '4', - name: 'Байкал', - country: 'Россия', - image: '🏔️', - rating: 4.8, - price: 'от 25 000 ₽', - category: 'nature', - description: 'Самое глубокое озеро в мире', - }, - { - id: '5', - name: 'Калининград', - country: 'Россия', - image: '⚓', - rating: 4.5, - price: 'от 12 000 ₽', - category: 'city', - description: 'Европейский колорит на Балтике', - }, - { - id: '6', - name: 'Алтай', - country: 'Россия', - image: '🗻', - rating: 4.7, - price: 'от 20 000 ₽', - category: 'mountain', - description: 'Горные пейзажи и чистый воздух', - }, -]; +function TripCard({ trip, onClick, onDelete }: TripCardProps) { + const formatDate = (date: string) => { + return new Date(date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); + }; -const categories = [ - { id: 'all', label: 'Все', icon: Globe }, - { id: 'beach', label: 'Пляж', icon: Waves }, - { id: 'city', label: 'Города', icon: Building2 }, - { id: 'nature', label: 'Природа', icon: Palmtree }, - { id: 'mountain', label: 'Горы', icon: Mountain }, -]; - -const quickActions = [ - { icon: Plane, label: 'Авиабилеты', color: 'bg-blue-500/10 text-blue-400' }, - { icon: Hotel, label: 'Отели', color: 'bg-purple-500/10 text-purple-400' }, - { icon: Car, label: 'Авто', color: 'bg-green-500/10 text-green-400' }, - { icon: Sparkles, label: 'AI Планировщик', color: 'active-gradient text-gradient' }, -]; - -function DestinationCard({ destination, delay }: { destination: Destination; delay: number }) { return ( -
- {destination.image} -
-
-
+
+
+
+ +
-

- {destination.name} -

-

- - {destination.country} -

-
-
- - {destination.rating} +

+ {trip.title || trip.destination} +

+

{trip.destination}

-

{destination.description}

-
- {destination.price} - -
+
+
+ + {formatDate(trip.startDate)} - {formatDate(trip.endDate)} +
+ {trip.route && trip.route.length > 0 && ( +
+ + {trip.route.length} точек +
+ )} ); } -export default function TravelPage() { - const [searchQuery, setSearchQuery] = useState(''); - const [selectedCategory, setSelectedCategory] = useState('all'); - const [isLoading, setIsLoading] = useState(false); - const [travelers, setTravelers] = useState(2); - const [departureDate, setDepartureDate] = useState(''); +interface AssistantMessageProps { + message: TravelChatMessage; + onAddEventToMap: (event: EventCard) => void; + onAddPOIToMap: (poi: POICard) => void; + onSelectHotel: (hotel: HotelCard) => void; + onSelectTransport: (option: TransportOption) => void; + onClarifyingAnswer: (field: string, value: string) => void; + onAction: (kind: string) => void; + selectedEventIds: Set; + selectedPOIIds: Set; + selectedHotelId?: string; + selectedTransportId?: string; +} - const handleAISearch = useCallback(async () => { - if (!searchQuery.trim()) return; - setIsLoading(true); - await new Promise((resolve) => setTimeout(resolve, 1500)); - setIsLoading(false); - }, [searchQuery]); - - const filteredDestinations = popularDestinations.filter((d) => { - if (selectedCategory !== 'all' && d.category !== selectedCategory) return false; - if (searchQuery) { - const query = searchQuery.toLowerCase(); - return ( - d.name.toLowerCase().includes(query) || - d.country.toLowerCase().includes(query) || - d.description.toLowerCase().includes(query) - ); - } - return true; - }); +function AssistantMessage({ + message, + onAddEventToMap, + onAddPOIToMap, + onSelectHotel, + onSelectTransport, + onClarifyingAnswer, + onAction, + selectedEventIds, + selectedPOIIds, + selectedHotelId, + selectedTransportId, +}: AssistantMessageProps) { + const travelWidgets = useMemo( + () => message.widgets.filter((w) => w.type.startsWith('travel_')), + [message.widgets] + ); return ( -
-
- {/* Header */} -
-

Путешествия

-

Планируйте поездки с помощью AI

+
+
+
+
- - {/* AI Search */} -
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAISearch()} - placeholder="Куда хотите поехать? Опишите своё идеальное путешествие..." - className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all" - /> -
- -
-
- - setDepartureDate(e.target.value)} - className="bg-transparent text-sm text-secondary outline-none" - /> +
+ {message.content && ( +
+ {message.content}
-
- - -
-
- - -
- - {/* Quick Actions */} -
- {quickActions.map((action) => ( - - ))} -
- - {/* Categories */} -
- {categories.map((cat) => ( - - ))} -
- - {/* Popular Destinations */} -
-
-
- -
-

Популярные направления

-
- -
- {filteredDestinations.map((dest, i) => ( - - ))} -
- - {filteredDestinations.length === 0 && ( -
- -

Направления не найдены

-

Попробуйте изменить фильтры

+ )} + {message.isStreaming && !message.content && ( +
+ + Планирую маршрут...
)}
- - {/* AI Tips */} -
-
- - Совет AI -
-

- Опишите ваши предпочтения в поиске: бюджет, тип отдыха, даты — и AI составит - оптимальный маршрут с учётом всех пожеланий. -

-
+ + {travelWidgets.length > 0 && ( +
+ {travelWidgets.map((widget) => ( + + ))} +
+ )} + + {message.isStreaming && message.content && ( +
+ + Собираю данные... +
+ )} +
+ ); +} + +export default function TravelPage() { + const [inputValue, setInputValue] = useState(''); + const [showMap, setShowMap] = useState(true); + const [trips, setTrips] = useState([]); + const [planOptions, setPlanOptions] = useState({ + startDate: '', + endDate: '', + travelers: 2, + budget: 0, + }); + const [showOptions, setShowOptions] = useState(false); + const [selectedEventIds, setSelectedEventIds] = useState>(new Set()); + const [selectedPOIIds, setSelectedPOIIds] = useState>(new Set()); + const [selectedHotelId, setSelectedHotelId] = useState(); + const [selectedTransportId, setSelectedTransportId] = useState(); + const [userLocation, setUserLocation] = useState(null); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + useEffect(() => { + if (!navigator.geolocation) return; + navigator.geolocation.getCurrentPosition( + (position) => { + setUserLocation({ + lat: position.coords.latitude, + lng: position.coords.longitude, + name: 'Моё местоположение', + }); + }, + () => {}, + { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }, + ); + }, []); + + const { + messages, + isLoading, + isResearching, + currentRoute, + routeDirection, + routeSegments, + sendMessage, + stopGeneration, + clearChat, + addRoutePoint, + answerClarifying, + handleAction, + addEventToRoute, + addPOIToRoute, + selectHotelOnRoute, + } = useTravelChat({ + onRouteUpdate: (route) => { + if (route.length > 0) setShowMap(true); + }, + }); + + useEffect(() => { + loadTrips(); + }, []); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + const loadTrips = async () => { + try { + const { trips: data } = await fetchTrips(10, 0); + setTrips(data || []); + } catch { + // silently fail + } + }; + + const handleSend = useCallback(() => { + if (!inputValue.trim() || isLoading) return; + + sendMessage(inputValue, { + startDate: planOptions.startDate || undefined, + endDate: planOptions.endDate || undefined, + travelers: planOptions.travelers || undefined, + budget: planOptions.budget || undefined, + userLocation: userLocation || undefined, + }); + setInputValue(''); + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + } + }, [inputValue, isLoading, sendMessage, planOptions, userLocation]); + + const handleQuickPrompt = useCallback((query: string) => { + sendMessage(query, { + startDate: planOptions.startDate || undefined, + endDate: planOptions.endDate || undefined, + travelers: planOptions.travelers || undefined, + budget: planOptions.budget || undefined, + userLocation: userLocation || undefined, + }); + }, [sendMessage, planOptions, userLocation]); + + const handleAddEventToMap = useCallback((event: EventCard) => { + setSelectedEventIds((prev) => { + const next = new Set(prev); + if (next.has(event.id)) { + next.delete(event.id); + } else { + next.add(event.id); + addEventToRoute(event); + } + return next; + }); + }, [addEventToRoute]); + + const handleAddPOIToMap = useCallback((poi: POICard) => { + setSelectedPOIIds((prev) => { + const next = new Set(prev); + if (next.has(poi.id)) { + next.delete(poi.id); + } else { + next.add(poi.id); + addPOIToRoute(poi); + } + return next; + }); + }, [addPOIToRoute]); + + const handleSelectHotel = useCallback((hotel: HotelCard) => { + setSelectedHotelId((prev) => { + const newId = prev === hotel.id ? undefined : hotel.id; + if (newId) selectHotelOnRoute(hotel); + return newId; + }); + }, [selectHotelOnRoute]); + + const handleSelectTransport = useCallback((option: TransportOption) => { + setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id)); + }, []); + + const handleMapClick = useCallback((location: GeoLocation) => { + const point: RoutePoint = { + id: `${Date.now()}`, + lat: location.lat, + lng: location.lng, + name: location.name || 'Новая точка', + type: 'custom', + order: currentRoute.length, + }; + addRoutePoint(point); + }, [addRoutePoint, currentRoute.length]); + + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + + const handleSaveTrip = async () => { + if (currentRoute.length === 0) return; + setSaveStatus('saving'); + + const lastMessage = messages.find((m) => m.role === 'assistant' && !m.isStreaming); + const trip: Partial = { + title: 'Новое путешествие', + destination: currentRoute[0]?.name || 'Неизвестно', + startDate: planOptions.startDate || new Date().toISOString(), + endDate: planOptions.endDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + route: currentRoute, + currency: 'RUB', + status: 'planned', + aiSummary: lastMessage?.content, + }; + + try { + const created = await createTrip(trip); + setTrips((prev) => [created, ...prev]); + setSaveStatus('saved'); + setTimeout(() => setSaveStatus('idle'), 2000); + } catch { + setSaveStatus('error'); + setTimeout(() => setSaveStatus('idle'), 3000); + } + }; + + const handleDeleteTrip = async (id: string) => { + try { + await deleteTrip(id); + setTrips((prev) => prev.filter((t) => t.id !== id)); + } catch { + // silently fail + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const handleInput = () => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`; + } + }; + + const hasMessages = messages.length > 0; + + return ( +
+ + {!hasMessages ? ( + +
+ +
+ +
+

+ Куда отправимся? +

+

+ AI спланирует идеальное путешествие с маршрутом, отелями, мероприятиями и достопримечательностями +

+
+ + +
+
+