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