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 DailyForecast struct { Date string `json:"date"` TempMin float64 `json:"tempMin"` TempMax float64 `json:"tempMax"` Conditions string `json:"conditions"` Icon string `json:"icon"` RainChance string `json:"rainChance"` Wind string `json:"wind,omitempty"` Tip string `json:"tip,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"` DailyForecast []DailyForecast `json:"dailyForecast,omitempty"` } 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") monthNames := map[string]string{ "01": "январь", "02": "февраль", "03": "март", "04": "апрель", "05": "май", "06": "июнь", "07": "июль", "08": "август", "09": "сентябрь", "10": "октябрь", "11": "ноябрь", "12": "декабрь", } tripMonth := time.Now().Format("01") if brief.StartDate != "" { if t, err := time.Parse("2006-01-02", brief.StartDate); err == nil { tripMonth = t.Format("01") } } month := monthNames[tripMonth] dateRange := "" if brief.StartDate != "" && brief.EndDate != "" { dateRange = fmt.Sprintf("%s — %s", brief.StartDate, brief.EndDate) } else if brief.StartDate != "" { dateRange = brief.StartDate } queries := []string{ fmt.Sprintf("погода %s %s %s прогноз по дням", dest, month, currentYear), fmt.Sprintf("прогноз погоды %s %s на 14 дней", dest, dateRange), 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") tripDays := computeTripDays(brief.StartDate, brief.EndDate) dailyForecastNote := "" if tripDays > 0 { dailyForecastNote = fmt.Sprintf(` ВАЖНО: Поездка длится %d дней (%s — %s). Составь прогноз погоды НА КАЖДЫЙ ДЕНЬ поездки. В "dailyForecast" должно быть ровно %d элементов — по одному на каждый день.`, tripDays, brief.StartDate, brief.EndDate, tripDays) } prompt := fmt.Sprintf(`Ты — эксперт по путешествиям. Оцени обстановку в %s для поездки %s — %s. Сегодня: %s. %s %s Верни ТОЛЬКО JSON (без текста): { "weather": { "summary": "Общее описание погоды на весь период поездки", "tempMin": число_минимум_за_весь_период, "tempMax": число_максимум_за_весь_период, "conditions": "преобладающие условия: солнечно/облачно/переменная облачность/дождливо/снежно", "clothing": "Что надеть: конкретные рекомендации по одежде", "rainChance": "низкая/средняя/высокая", "dailyForecast": [ { "date": "YYYY-MM-DD", "tempMin": число, "tempMax": число, "conditions": "солнечно/облачно/дождь/гроза/снег/туман/переменная облачность", "icon": "sun/cloud/cloud-sun/rain/storm/snow/fog/wind", "rainChance": "низкая/средняя/высокая", "wind": "слабый/умеренный/сильный", "tip": "Краткий совет на этот день (необязательно, только если есть что сказать)" } ] }, "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 года и данные из поиска - dailyForecast: прогноз НА КАЖДЫЙ ДЕНЬ поездки с конкретными температурами и условиями - Если точный прогноз недоступен — используй климатические данные для этого периода, но старайся варьировать по дням реалистично - icon: одно из значений sun/cloud/cloud-sun/rain/storm/snow/fog/wind - weather.summary: общее описание, упомяни если ожидаются дождливые дни - safety: объективная оценка, не преувеличивай - restrictions: визовые требования, медицинские ограничения, локальные правила - tips: 3-5 практичных советов - Температуры в градусах Цельсия`, dest, brief.StartDate, brief.EndDate, currentDate, dailyForecastNote, contextBuilder.String(), time.Now().Format("2006"), ) llmCtx, cancel := context.WithTimeout(ctx, 35*time.Second) defer cancel() maxTokens := 3000 if tripDays > 5 { maxTokens = 4000 } response, err := llmClient.GenerateText(llmCtx, llm.StreamRequest{ Messages: []llm.Message{{Role: llm.RoleUser, Content: prompt}}, Options: llm.StreamOptions{MaxTokens: maxTokens, Temperature: 0.3}, }) 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 (%d daily), safety=%s, restrictions=%d, tips=%d", travelCtx.Weather.Conditions, len(travelCtx.Weather.DailyForecast), travelCtx.Safety.Level, len(travelCtx.Restrictions), len(travelCtx.Tips)) return &travelCtx } func computeTripDays(startDate, endDate string) int { if startDate == "" || endDate == "" { return 0 } start, err := time.Parse("2006-01-02", startDate) if err != nil { return 0 } end, err := time.Parse("2006-01-02", endDate) if err != nil { return 0 } days := int(end.Sub(start).Hours()/24) + 1 if days < 1 { return 1 } if days > 30 { return 30 } return days }