Files
gooseek/backend/internal/agent/travel_context_collector.go
home ab48a0632b
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped
feat: CI/CD pipeline + Learning/Medicine/Travel services
- 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
2026-03-02 20:25:44 +03:00

318 lines
10 KiB
Go
Raw Permalink 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 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
}