Files
gooseek/backend/internal/agent/travel_context_collector.go
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

242 lines
7.6 KiB
Go
Raw Blame History

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