- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
796 lines
23 KiB
Go
796 lines
23 KiB
Go
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")
|
||
}
|
||
|
||
// ValidateItineraryRequest is the input for itinerary validation.
|
||
type ValidateItineraryRequest struct {
|
||
Days []ValidateDay `json:"days"`
|
||
POIs []ValidatePOI `json:"pois,omitempty"`
|
||
Events []ValidateEvent `json:"events,omitempty"`
|
||
}
|
||
|
||
type ValidateDay struct {
|
||
Date string `json:"date"`
|
||
Items []ValidateItem `json:"items"`
|
||
}
|
||
|
||
type ValidateItem 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"`
|
||
}
|
||
|
||
type ValidatePOI struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Category string `json:"category"`
|
||
Schedule map[string]string `json:"schedule,omitempty"`
|
||
Lat float64 `json:"lat"`
|
||
Lng float64 `json:"lng"`
|
||
}
|
||
|
||
type ValidateEvent struct {
|
||
ID string `json:"id"`
|
||
Title string `json:"title"`
|
||
DateStart string `json:"dateStart,omitempty"`
|
||
DateEnd string `json:"dateEnd,omitempty"`
|
||
Lat float64 `json:"lat"`
|
||
Lng float64 `json:"lng"`
|
||
}
|
||
|
||
type ValidationWarning struct {
|
||
DayIdx int `json:"dayIdx"`
|
||
ItemIdx int `json:"itemIdx,omitempty"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
type ValidateItineraryResponse struct {
|
||
Valid bool `json:"valid"`
|
||
Warnings []ValidationWarning `json:"warnings"`
|
||
Suggestions []ValidationWarning `json:"suggestions"`
|
||
}
|
||
|
||
func (s *Service) ValidateItinerary(ctx context.Context, req ValidateItineraryRequest) (*ValidateItineraryResponse, error) {
|
||
if s.llmClient == nil {
|
||
return &ValidateItineraryResponse{Valid: true, Warnings: []ValidationWarning{}, Suggestions: []ValidationWarning{}}, nil
|
||
}
|
||
|
||
daysJSON, _ := json.Marshal(req.Days)
|
||
poisJSON, _ := json.Marshal(req.POIs)
|
||
eventsJSON, _ := json.Marshal(req.Events)
|
||
|
||
prompt := fmt.Sprintf(`Проверь маршрут путешествия на логистические ошибки и предложи улучшения.
|
||
|
||
Маршрут по дням: %s
|
||
|
||
Доступные POI (с расписанием): %s
|
||
|
||
Доступные события (с датами): %s
|
||
|
||
Проверь:
|
||
1. Логистику: нет ли точек в разных концах города подряд без достаточного времени на переезд
|
||
2. Расписание: если POI имеет schedule и стоит в день когда закрыт — это ошибка
|
||
3. Даты событий: если событие стоит в день вне его dateStart-dateEnd — это ошибка
|
||
4. Реалистичность: не слишком ли много активностей в день (>6 основных)
|
||
5. Время: нет ли пересечений по времени
|
||
|
||
Верни ТОЛЬКО JSON:
|
||
{
|
||
"valid": true/false,
|
||
"warnings": [{"dayIdx": 0, "itemIdx": 2, "message": "причина"}],
|
||
"suggestions": [{"dayIdx": 0, "message": "рекомендация"}]
|
||
}
|
||
|
||
Если всё хорошо — warnings пустой массив, valid=true. Suggestions — необязательные рекомендации.`, string(daysJSON), string(poisJSON), string(eventsJSON))
|
||
|
||
var fullResponse strings.Builder
|
||
err := s.llmClient.StreamChat(ctx, []ChatMessage{
|
||
{Role: "user", Content: prompt},
|
||
}, func(chunk string) {
|
||
fullResponse.WriteString(chunk)
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("LLM validation failed: %w", err)
|
||
}
|
||
|
||
responseText := fullResponse.String()
|
||
|
||
jsonStart := strings.Index(responseText, "{")
|
||
jsonEnd := strings.LastIndex(responseText, "}")
|
||
if jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart {
|
||
return &ValidateItineraryResponse{
|
||
Valid: false,
|
||
Warnings: []ValidationWarning{{
|
||
DayIdx: 0,
|
||
Message: "Не удалось проверить маршрут — повторите попытку",
|
||
}},
|
||
Suggestions: []ValidationWarning{},
|
||
}, nil
|
||
}
|
||
|
||
var result ValidateItineraryResponse
|
||
if err := json.Unmarshal([]byte(responseText[jsonStart:jsonEnd+1]), &result); err != nil {
|
||
return &ValidateItineraryResponse{
|
||
Valid: false,
|
||
Warnings: []ValidationWarning{{
|
||
DayIdx: 0,
|
||
Message: "Ошибка анализа маршрута — попробуйте ещё раз",
|
||
}},
|
||
Suggestions: []ValidationWarning{},
|
||
}, nil
|
||
}
|
||
|
||
if result.Warnings == nil {
|
||
result.Warnings = []ValidationWarning{}
|
||
}
|
||
if result.Suggestions == nil {
|
||
result.Suggestions = []ValidationWarning{}
|
||
}
|
||
|
||
return &result, nil
|
||
}
|
||
|
||
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
|
||
}
|