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 }