feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService) - Add travel orchestrator with parallel collectors (events, POI, hotels, flights) - Add 2GIS road routing with transport cost calculation (car/bus/taxi) - Add TravelMap (2GIS MapGL) and TravelWidgets components - Add useTravelChat hook for streaming travel agent responses - Add finance heatmap providers refactor - Add SearXNG settings, API proxy routes, Docker compose updates - Update Dockerfiles, config, types, and all UI pages for consistency Made-with: Cursor
This commit is contained in:
241
backend/internal/agent/travel_context_collector.go
Normal file
241
backend/internal/agent/travel_context_collector.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user