- 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
488 lines
13 KiB
Go
488 lines
13 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
geocodeCache = make(map[string]*GeoResult)
|
|
geocodeCacheMu sync.RWMutex
|
|
)
|
|
|
|
// TravelDataClient calls the travel-svc data-layer endpoints.
|
|
type TravelDataClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
maxRetries int
|
|
}
|
|
|
|
func NewTravelDataClient(baseURL string) *TravelDataClient {
|
|
return &TravelDataClient{
|
|
baseURL: strings.TrimSuffix(baseURL, "/"),
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{
|
|
MaxIdleConns: 20,
|
|
MaxIdleConnsPerHost: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
},
|
|
},
|
|
maxRetries: 2,
|
|
}
|
|
}
|
|
|
|
func (c *TravelDataClient) doWithRetry(ctx context.Context, req *http.Request) (*http.Response, error) {
|
|
// http.Request.Clone does NOT recreate the Body. If we retry a request with a Body,
|
|
// we must be able to recreate it; otherwise retries will send an empty body and may
|
|
// fail with ContentLength/body length mismatch.
|
|
var bodyCopy []byte
|
|
if req.Body != nil && req.GetBody == nil {
|
|
b, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read request body for retry: %w", err)
|
|
}
|
|
_ = req.Body.Close()
|
|
bodyCopy = b
|
|
req.GetBody = func() (io.ReadCloser, error) {
|
|
return io.NopCloser(bytes.NewReader(bodyCopy)), nil
|
|
}
|
|
req.Body = io.NopCloser(bytes.NewReader(bodyCopy))
|
|
req.ContentLength = int64(len(bodyCopy))
|
|
}
|
|
|
|
var lastErr error
|
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
backoff := time.Duration(attempt*500) * time.Millisecond
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
|
|
reqAttempt := req.Clone(ctx)
|
|
if req.GetBody != nil {
|
|
rc, err := req.GetBody()
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
reqAttempt.Body = rc
|
|
reqAttempt.ContentLength = req.ContentLength
|
|
}
|
|
|
|
resp, err := c.httpClient.Do(reqAttempt)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode >= 500 {
|
|
resp.Body.Close()
|
|
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
|
|
continue
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
return nil, fmt.Errorf("all %d retries failed: %w", c.maxRetries+1, lastErr)
|
|
}
|
|
|
|
func (c *TravelDataClient) Geocode(ctx context.Context, query string) (*GeoResult, error) {
|
|
// Check in-memory cache first
|
|
geocodeCacheMu.RLock()
|
|
if cached, ok := geocodeCache[query]; ok {
|
|
geocodeCacheMu.RUnlock()
|
|
return cached, nil
|
|
}
|
|
geocodeCacheMu.RUnlock()
|
|
|
|
u := fmt.Sprintf("%s/api/v1/travel/geocode?query=%s", c.baseURL, url.QueryEscape(query))
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp, err := c.doWithRetry(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("geocode request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("geocode returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result GeoResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("geocode decode error: %w", err)
|
|
}
|
|
|
|
geocodeCacheMu.Lock()
|
|
geocodeCache[query] = &result
|
|
geocodeCacheMu.Unlock()
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *TravelDataClient) SearchPOI(ctx context.Context, lat, lng float64, radius int, categories []string) ([]POICard, error) {
|
|
body := map[string]interface{}{
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius": radius,
|
|
"limit": 20,
|
|
}
|
|
if len(categories) > 0 {
|
|
body["categories"] = categories
|
|
}
|
|
|
|
jsonBody, _ := json.Marshal(body)
|
|
u := fmt.Sprintf("%s/api/v1/travel/poi", c.baseURL)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("poi search failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("poi search returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var rawPOIs []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Category string `json:"category"`
|
|
Address string `json:"address"`
|
|
Rating float64 `json:"rating"`
|
|
Distance float64 `json:"distance"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&rawPOIs); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cards := make([]POICard, 0, len(rawPOIs))
|
|
for _, p := range rawPOIs {
|
|
cards = append(cards, POICard{
|
|
ID: p.ID,
|
|
Name: p.Name,
|
|
Category: p.Category,
|
|
Address: p.Address,
|
|
Lat: p.Lat,
|
|
Lng: p.Lng,
|
|
Rating: p.Rating,
|
|
})
|
|
}
|
|
return cards, nil
|
|
}
|
|
|
|
func (c *TravelDataClient) SearchFlights(ctx context.Context, origin, destination, date string, adults int) ([]TransportOption, error) {
|
|
body := map[string]interface{}{
|
|
"origin": origin,
|
|
"destination": destination,
|
|
"departureDate": date,
|
|
"adults": adults,
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
u := fmt.Sprintf("%s/api/v1/travel/flights", c.baseURL)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("flights search failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("flights search returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var rawFlights []struct {
|
|
ID string `json:"id"`
|
|
Airline string `json:"airline"`
|
|
FlightNumber string `json:"flightNumber"`
|
|
DepartureAirport string `json:"departureAirport"`
|
|
DepartureCity string `json:"departureCity"`
|
|
DepartureTime string `json:"departureTime"`
|
|
ArrivalAirport string `json:"arrivalAirport"`
|
|
ArrivalCity string `json:"arrivalCity"`
|
|
ArrivalTime string `json:"arrivalTime"`
|
|
Duration int `json:"duration"`
|
|
Stops int `json:"stops"`
|
|
Price float64 `json:"price"`
|
|
Currency string `json:"currency"`
|
|
BookingURL string `json:"bookingUrl"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&rawFlights); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
options := make([]TransportOption, 0, len(rawFlights))
|
|
for _, f := range rawFlights {
|
|
options = append(options, TransportOption{
|
|
ID: f.ID,
|
|
Mode: "flight",
|
|
From: f.DepartureCity,
|
|
To: f.ArrivalCity,
|
|
Departure: f.DepartureTime,
|
|
Arrival: f.ArrivalTime,
|
|
DurationMin: f.Duration,
|
|
Price: f.Price,
|
|
Currency: f.Currency,
|
|
Provider: f.Airline,
|
|
BookingURL: f.BookingURL,
|
|
Airline: f.Airline,
|
|
FlightNum: f.FlightNumber,
|
|
Stops: f.Stops,
|
|
})
|
|
}
|
|
return options, nil
|
|
}
|
|
|
|
func (c *TravelDataClient) SearchHotels(ctx context.Context, lat, lng float64, checkIn, checkOut string, adults int) ([]HotelCard, error) {
|
|
body := map[string]interface{}{
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius": 10,
|
|
"checkIn": checkIn,
|
|
"checkOut": checkOut,
|
|
"adults": adults,
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
u := fmt.Sprintf("%s/api/v1/travel/hotels", c.baseURL)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hotels search failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("hotels search returned %d", resp.StatusCode)
|
|
}
|
|
|
|
var rawHotels []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Address string `json:"address"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Rating float64 `json:"rating"`
|
|
ReviewCount int `json:"reviewCount"`
|
|
Stars int `json:"stars"`
|
|
Price float64 `json:"price"`
|
|
PricePerNight float64 `json:"pricePerNight"`
|
|
Currency string `json:"currency"`
|
|
CheckIn string `json:"checkIn"`
|
|
CheckOut string `json:"checkOut"`
|
|
Amenities []string `json:"amenities"`
|
|
Photos []string `json:"photos"`
|
|
BookingURL string `json:"bookingUrl"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&rawHotels); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cards := make([]HotelCard, 0, len(rawHotels))
|
|
for _, h := range rawHotels {
|
|
cards = append(cards, HotelCard{
|
|
ID: h.ID,
|
|
Name: h.Name,
|
|
Stars: h.Stars,
|
|
Rating: h.Rating,
|
|
ReviewCount: h.ReviewCount,
|
|
PricePerNight: h.PricePerNight,
|
|
TotalPrice: h.Price,
|
|
Currency: h.Currency,
|
|
Address: h.Address,
|
|
Lat: h.Lat,
|
|
Lng: h.Lng,
|
|
BookingURL: h.BookingURL,
|
|
Photos: h.Photos,
|
|
Amenities: h.Amenities,
|
|
CheckIn: h.CheckIn,
|
|
CheckOut: h.CheckOut,
|
|
})
|
|
}
|
|
return cards, nil
|
|
}
|
|
|
|
// PlaceResult represents a place from 2GIS Places API.
|
|
type PlaceResult struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Address string `json:"address"`
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Type string `json:"type"`
|
|
Purpose string `json:"purpose"`
|
|
Rating float64 `json:"rating"`
|
|
ReviewCount int `json:"reviewCount"`
|
|
Schedule map[string]string `json:"schedule,omitempty"`
|
|
}
|
|
|
|
func (c *TravelDataClient) SearchPlaces(ctx context.Context, query string, lat, lng float64, radius int) ([]PlaceResult, error) {
|
|
body := map[string]interface{}{
|
|
"query": query,
|
|
"lat": lat,
|
|
"lng": lng,
|
|
"radius": radius,
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
u := fmt.Sprintf("%s/api/v1/travel/places", c.baseURL)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.doWithRetry(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("places search failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("places search returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var places []PlaceResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&places); err != nil {
|
|
return nil, fmt.Errorf("places decode error: %w", err)
|
|
}
|
|
return places, nil
|
|
}
|
|
|
|
func (c *TravelDataClient) GetRoute(ctx context.Context, points []MapPoint, transport string) (*RouteDirectionResult, error) {
|
|
if len(points) < 2 {
|
|
return nil, fmt.Errorf("need at least 2 points for route")
|
|
}
|
|
if transport == "" {
|
|
transport = "driving"
|
|
}
|
|
|
|
coords := make([]map[string]float64, len(points))
|
|
for i, p := range points {
|
|
coords[i] = map[string]float64{"lat": p.Lat, "lng": p.Lng}
|
|
}
|
|
|
|
body := map[string]interface{}{
|
|
"points": coords,
|
|
"profile": transport,
|
|
}
|
|
jsonBody, _ := json.Marshal(body)
|
|
u := fmt.Sprintf("%s/api/v1/travel/route", c.baseURL)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", u, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("route request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("route returned %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
var result RouteDirectionResult
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// GetRouteSegments builds routes between each consecutive pair of points.
|
|
func (c *TravelDataClient) GetRouteSegments(ctx context.Context, points []MapPoint, transport string) ([]RouteSegmentResult, error) {
|
|
if len(points) < 2 {
|
|
return nil, nil
|
|
}
|
|
|
|
segments := make([]RouteSegmentResult, 0, len(points)-1)
|
|
for i := 0; i < len(points)-1; i++ {
|
|
pair := []MapPoint{points[i], points[i+1]}
|
|
dir, err := c.GetRoute(ctx, pair, transport)
|
|
if err != nil {
|
|
segments = append(segments, RouteSegmentResult{
|
|
FromName: points[i].Label,
|
|
ToName: points[i+1].Label,
|
|
})
|
|
continue
|
|
}
|
|
segments = append(segments, RouteSegmentResult{
|
|
FromName: points[i].Label,
|
|
ToName: points[i+1].Label,
|
|
Distance: dir.Distance,
|
|
Duration: dir.Duration,
|
|
Geometry: dir.Geometry,
|
|
})
|
|
}
|
|
return segments, nil
|
|
}
|
|
|
|
type GeoResult struct {
|
|
Lat float64 `json:"lat"`
|
|
Lng float64 `json:"lng"`
|
|
Name string `json:"name"`
|
|
Country string `json:"country"`
|
|
}
|
|
|
|
type RouteDirectionResult struct {
|
|
Geometry RouteGeometryResult `json:"geometry"`
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
Steps []RouteStepResult `json:"steps,omitempty"`
|
|
}
|
|
|
|
type RouteGeometryResult struct {
|
|
Coordinates [][2]float64 `json:"coordinates"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type RouteStepResult struct {
|
|
Instruction string `json:"instruction"`
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type RouteSegmentResult struct {
|
|
FromName string `json:"from"`
|
|
ToName string `json:"to"`
|
|
Distance float64 `json:"distance"`
|
|
Duration float64 `json:"duration"`
|
|
Geometry RouteGeometryResult `json:"geometry,omitempty"`
|
|
}
|