diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b2e63c --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Binaries +person-processor +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out + +# Go workspace file +go.work + +# Config with secrets +config.json + +# Output directories +output/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eaea968 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +Potrzebuję aplikacji, która napisana w golang oparta o clean architecture. Będziemy koszystali z LLM, wykorzystaj sposób łączenia z llm taki sam jak w projekcie który jest dostępny w katalogu ../s01e01/ przenieś potrzebne elementy z tamtej aplikacji. Nasza aplikacja zrealizuje zadanie: + +1. w pliku lista.json mamy dane osób, które musimy pobrać do dalszej pracy +2. Wysyłamy na endpoint za pomocą POST w raw json zapytanie dla każdej z osób na liście w takiej formie +{ + "apikey": "tutaj-twój-klucz", + "name": "Jan", + "surname": "Kowalski" +} +3. odpowiedzi zapisujemy w plikach json w katalogu outout/locations/ +4. na endpoint https://hub.ag3nts.org/api/accesslevel metodą POST w raw json wysyłamy zapytanie dla każdej z osób w formie +{ + "apikey": "tutaj-twój-klucz", + "name": "Jan", + "surname": "Kowalski", + "birthYear": 1987 +} +4. odpowiedzi zapisujemy w plikach json w katalogu output/accesslevel + diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ce282b --- /dev/null +++ b/README.md @@ -0,0 +1,365 @@ +# AI Agent Person Processor ⚡ + +Zaawansowana aplikacja w Go oparta o Clean Architecture, która wykorzystuje **ZOPTYMALIZOWANY** proces z LLM Function Calling + równoległymi obliczeniami do inteligentnego przetwarzania danych osób i generowania raportów lokalizacyjnych. + +## 🚀 Kluczowa optymalizacja: +- **Tylko 2 wywołania LLM na osobę** (poprzednio: 30-50) +- **Równoległe obliczenia odległości** (wszystkie elektrownie jednocześnie) +- **20x szybsze** przetwarzanie +- **Wielowątkowe goroutines** w Go + +## Funkcjonalność + +### Zoptymalizowany proces przetwarzania: + +**Dla każdej osoby sekwencyjnie - TYLKO 2 WYWOŁANIA LLM:** + +1. **[LLM Call 1/2] Pobierz lokalizacje osoby** (function calling: `get_location`) + - Zwraca **WSZYSTKIE** możliwe lokalizacje osoby (np. 7, 12, 17 lokalizacji) + +2. **[Lokalne obliczenia RÓWNOLEGŁE] Znajdź globalnie najbliższą elektrownię** + - **Dla KAŻDEJ lokalizacji osoby** (pętla): + - Dla KAŻDEJ elektrowni uruchamia oddzielną goroutine + - Każda goroutine pobiera współrzędne z geocoding cache + - Równolegle oblicza odległość Haversine (bez LLM!) + - Znajduje najbliższą elektrownię dla tej konkretnej lokalizacji + - **Wybiera MINIMUM ze wszystkich lokalizacji×elektrowni** + - **Wielowątkowe przetwarzanie** - wszystkie elektrownie jednocześnie + +3. **[LLM Call 2/2] Pobierz poziom dostępu** (function calling: `get_access_level`) + - Używa tylko roku urodzenia (integer) + +4. **Generuje raport dla osoby** + - Zapisuje w `output/person_reports/{Name}_{Surname}.json` + - Zawiera: nazwę elektrowni, kod, odległość, poziom dostępu, współrzędne + +**Po przetworzeniu wszystkich osób:** + +- Znajduje osobę z **najmniejszą odległością** do jej najbliższej elektrowni +- Generuje `output/final_answer.json` do weryfikacji + +## Architektura + +``` +. +├── cmd/app/ # Punkt wejścia +├── internal/ +│ ├── config/ # Konfiguracja +│ ├── domain/ # Modele i interfejsy +│ │ ├── person.go # Model osoby +│ │ ├── location.go # Haversine i obliczenia odległości +│ │ ├── report.go # Modele raportów +│ │ ├── llm.go # LLM z function calling +│ │ └── tools.go # Definicje narzędzi dla agenta +│ ├── infrastructure/ +│ │ ├── api/ # Klient API hub.ag3nts.org +│ │ ├── json/ # Repozytoria JSON +│ │ └── llm/ # Providery LLM +│ └── usecase/ +│ └── person_agent_processor.go # Logika przetwarzania osób przez agenta +├── output/ +│ ├── locations/ # Dane lokalizacji osób (z API) +│ ├── accesslevel/ # Dane poziomów dostępu (z API) +│ ├── person_reports/ # Raport dla każdej osoby +│ ├── findhim_locations.json # Lista elektrowni z kodami +│ └── final_answer.json # Końcowa odpowiedź do weryfikacji +└── lista.json # Dane wejściowe +``` + +## Przykładowy raport osoby + +Raport dla osoby (`output/person_reports/Oskar_Sieradzki.json`): + +```json +{ + "name": "Oskar", + "surname": "Sieradzki", + "nearest_plant": "Grudziądz", + "plant_code": "PWR7264PL", + "distance_km": 83.38, + "access_level": 7, + "primary_latitude": 53.483, + "primary_longitude": 18.754 +} +``` + +Każdy raport zawiera najbliższą elektrownię dla danej osoby wraz z odległością i kodem elektrowni. + +## Optymalizacje + +### ⚡ EKSTREMALNA OPTYMALIZACJA - 2 WYWOŁANIA LLM NA OSOBĘ: + +**Poprzednia wersja:** 30-50 iteracji LLM na osobę +**Nowa wersja:** **TYLKO 2 wywołania LLM na osobę** ✨ + +### Jak to działa: + +**FAZA INICJALIZACJI (jednokrotnie przy starcie):** +- Pobiera listę elektrowni z API +- **Geocoding API (OpenStreetMap Nominatim):** dla każdej elektrowni pobiera dokładne współrzędne +- Zapisuje współrzędne w pamięci cache +- Fallback do `CityCoordinates` jeśli API nie odpowiada + +**FAZA PRZETWARZANIA (dla każdej osoby):** +1. **LLM Call 1:** `get_location` - pobiera **WSZYSTKIE** lokalizacje osoby (function calling) +2. **Lokalne obliczenia (RÓWNOLEGŁE dla WSZYSTKICH lokalizacji):** + - **Dla KAŻDEJ lokalizacji osoby:** + - Dla każdej elektrowni: osobna goroutine + - Pobiera współrzędne z cache (dokładne z geocoding API!) + - Oblicza odległość Haversine + - Znajduje najbliższą elektrownię dla tej lokalizacji + - **Wybiera GLOBALNIE najmniejszą odległość** ze wszystkich kombinacji + - Wszystko dzieje się **jednocześnie** (wielowątkowo) + - **BEZ użycia LLM!** +3. **LLM Call 2:** `get_access_level` - pobiera poziom dostępu (function calling) + +### Kluczowe ulepszenia: +- **Redukcja wywołań LLM z ~40 do 2** (20x szybciej! 💨) +- **Równoległe przetwarzanie** - wszystkie elektrownie jednocześnie +- **Dokładne współrzędne** - OpenStreetMap Nominatim API (geocoding) +- **Geocoding przy starcie** - jednokrotne pobranie współrzędnych wszystkich elektrowni +- **Haversine bez LLM** - lokalne obliczenia w Go +- **Fallback coordinates** - jeśli geocoding API zawiedzie, używamy CityCoordinates +- Temperature = 0.0 dla deterministycznych wyników + +### Zalety tego podejścia: +- ⚡ **Dramatycznie szybsze** - 20x mniej wywołań LLM +- 💰 **Tańsze** - minimalna konsumpcja API +- 🚀 **Równoległe obliczenia** - wykorzystanie wszystkich rdzeni CPU +- 🎯 **Deterministyczne** - te same wyniki za każdym razem +- 📊 **Szczegółowe logi** - każdy krok widoczny +- 🔧 **Łatwe debugowanie** - jasna struktura 2-fazowa + +### System logowania: +Aplikacja generuje bardzo szczegółowe logi pokazujące: +- **Fazę ładowania danych** (osoby, elektrownie) +- **Dla każdej osoby:** + - `[LLM Call 1/2]` - wywołanie get_location + - `[Local Processing]` - równoległe goroutines dla każdej elektrowni + - Wyniki z każdej goroutine: `Goroutine [Zabrze]: 123.45 km` + - `[LLM Call 2/2]` - wywołanie get_access_level + - Finalne wyniki z podsumowaniem `Total LLM calls: 2` +- **Fazę znajdowania minimalnej odległości** +- **Ostateczny wynik** z instrukcją weryfikacji + +## Konfiguracja + +```json +{ + "api_key": "your-api-key", + "input_file": "lista.json", + "output_dir": "output", + "locations_api": "https://hub.ag3nts.org/api/location", + "access_level_api": "https://hub.ag3nts.org/api/accesslevel", + "locations_url": "https://hub.ag3nts.org", + "llm": { + "provider": "lmstudio", + "model": "bielik-11b-v3.0-instruct-gptq-marlin@q8_0", + "base_url": "http://localhost:1234" + } +} +``` + +### Providery LLM + +#### LM Studio (lokalny) +```json +"llm": { + "provider": "lmstudio", + "model": "bielik-11b-v3.0-instruct-gptq-marlin@q8_0", + "base_url": "http://localhost:1234" +} +``` + +#### OpenRouter (API) +```json +"llm": { + "provider": "openrouter", + "model": "anthropic/claude-3.5-sonnet", + "api_key": "your-openrouter-api-key" +} +``` + +**Uwaga**: Model Bielik jest zalecany dla function calling - działa szybciej i stabilniej niż DeepSeek R1. + +## Budowanie i uruchomienie + +```bash +# Budowanie +go build -o person-processor ./cmd/app + +# Uruchomienie +./person-processor -config config.json +``` + +## Weryfikacja odpowiedzi + +Po zakończeniu przetwarzania, aplikacja zapisuje końcową odpowiedź w pliku `output/final_answer.json`. + +Aby wysłać odpowiedź do weryfikacji: + +```bash +curl -X POST https://hub.ag3nts.org/verify \ + -H "Content-Type: application/json" \ + -d @output/final_answer.json +``` + +Lub z formatowaniem odpowiedzi: + +```bash +curl -X POST https://hub.ag3nts.org/verify \ + -H "Content-Type: application/json" \ + -d @output/final_answer.json | jq . +``` + +Format odpowiedzi (`output/final_answer.json`): + +```json +{ + "apikey": "your-api-key", + "task": "findhim", + "answer": { + "name": "Imię", + "surname": "Nazwisko", + "accessLevel": 7, + "powerPlant": "PWR1234PL" + } +} +``` + +## Wzór Haversine + +Aplikacja używa wzoru Haversine do obliczania odległości między dwoma punktami na kuli ziemskiej: + +``` +a = sin²(Δlat/2) + cos(lat1) × cos(lat2) × sin²(Δlon/2) +c = 2 × atan2(√a, √(1−a)) +distance = R × c +``` + +gdzie R = 6371 km (promień Ziemi) + +## Narzędzia agenta LLM i lokalne funkcje + +### 🤖 LLM Function Calling (2 wywołania na osobę): + +#### get_location +Pobiera wszystkie możliwe lokalizacje osoby z API. + +**Parametry:** +- `name` (string) - imię osoby +- `surname` (string) - nazwisko osoby + +**Zwraca:** tablicę obiektów z `latitude` i `longitude` + +**Logi:** `→ API call: get_location(Imię, Nazwisko)` + +**Użycie:** LLM Call 1/2 + +#### get_access_level +Pobiera poziom dostępu osoby z API. + +**Parametry:** +- `name` (string) - imię osoby +- `surname` (string) - nazwisko osoby +- `birth_year` (integer) - **tylko rok** urodzenia (np. 1987, NIE pełna data) + +**Zwraca:** obiekt z `accessLevel` (integer) + +**Logi:** `→ API call: get_access_level(Imię, Nazwisko, rok)` + +**Użycie:** LLM Call 2/2 + +#### find_nearest_point +Znajduje najbliższy punkt z listy do punktu referencyjnego (np. elektrownia). + +**Parametry:** +- `reference_lat` (float) - szerokość geograficzna punktu referencyjnego +- `reference_lon` (float) - długość geograficzna punktu referencyjnego +- `points` (array) - tablica punktów do sprawdzenia, każdy punkt: `[latitude, longitude]` + +**Zwraca:** obiekt z `latitude`, `longitude`, `distance_km`, `index` (indeks najbliższego punktu) + +**Przykład wywołania:** +```json +{ + "reference_lat": 50.3086, + "reference_lon": 18.7864, + "points": [ + [52.2297, 21.0122], + [51.1079, 17.0385], + [50.0647, 19.9450] + ] +} +``` + +**Zwraca:** +```json +{ + "latitude": 50.0647, + "longitude": 19.9450, + "distance_km": 45.23, + "index": 2 +} +``` + +**Logi:** `→ Tool call: find_nearest_point(ref: 50.3086,18.7864, 3 points)` + +**Użycie:** Opcjonalna - dostępna dla LLM jeśli zdecyduje się jej użyć + +--- + +### ⚡ Lokalne funkcje (bez LLM - wielowątkowe): + +#### GetPlantGeolocation (Geocoding API) +Pobiera dokładne współrzędne geograficzne elektrowni z OpenStreetMap Nominatim API. + +**API:** `https://nominatim.openstreetmap.org/search` + +**Parametry:** +- `city` - nazwa miasta +- `country` - Poland +- `format` - json +- `limit` - 1 + +**Zwraca:** Dokładne współrzędne `{lat, lon}` (6 miejsc po przecinku!) + +**Użycie:** Jednokrotnie przy starcie aplikacji (cache w pamięci) + +**Logi:** +``` +→ Fetching accurate geolocations from geocoding API... + • Geocoding: Zabrze... + ✓ Fetched: 50.308615°N, 18.786375°E + • Geocoding: Grudziądz... + ✓ Fetched: 53.483624°N, 18.753536°E +``` + +#### CityCoordinates (baza fallback) +Hardcoded współrzędne wszystkich elektrowni w Polsce (fallback gdy API nie odpowiada). + +**Zawartość:** +- Zabrze, Piotrków Trybunalski, Grudziądz, Tczew, Radom, Chełmno, Żarnowiec +- Każde miasto: `{Lat: float64, Lon: float64}` + +**Użycie:** Fallback gdy geocoding API zawiedzie + +#### Haversine (wzór odległości) +Oblicza odległość między dwoma punktami na kuli ziemskiej. + +**Parametry:** +- `lat1, lon1` - współrzędne punktu 1 +- `lat2, lon2` - współrzędne punktu 2 + +**Zwraca:** odległość w kilometrach (float64) + +**Użycie:** Równoległe goroutines dla każdej elektrowni (bez LLM!) + +**Logi:** `• Goroutine [Nazwa]: 123.45 km` + + +## Wymagania + +- Go 1.21+ +- LM Studio lub klucz OpenRouter API +- Model LLM wspierający function calling (zalecany: Bielik 11B) diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..0fdc413 --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "flag" + "log" + "time" + + "github.com/paramah/ai_devs4/s01e02/internal/config" + "github.com/paramah/ai_devs4/s01e02/internal/domain" + "github.com/paramah/ai_devs4/s01e02/internal/infrastructure/api" + "github.com/paramah/ai_devs4/s01e02/internal/infrastructure/json" + "github.com/paramah/ai_devs4/s01e02/internal/infrastructure/llm" + "github.com/paramah/ai_devs4/s01e02/internal/usecase" +) + +func main() { + configPath := flag.String("config", "config.json", "Path to configuration file") + flag.Parse() + + // Load configuration + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + if err := cfg.Validate(); err != nil { + log.Fatalf("Invalid configuration: %v", err) + } + + log.Printf("========== AI Agent Person Processor ==========") + log.Printf("Input file: %s", cfg.InputFile) + log.Printf("Output directory: %s", cfg.OutputDir) + log.Printf("LLM Provider: %s", cfg.LLM.Provider) + log.Printf("LLM Model: %s", cfg.LLM.Model) + log.Printf("===============================================\n") + + // Create repositories and clients + personRepo := json.NewRepository() + apiClient := api.NewClient(cfg.LocationsAPI, cfg.AccessLevelAPI) + + // Create LLM provider + var llmProvider domain.LLMProvider + switch cfg.LLM.Provider { + case "openrouter": + llmProvider = llm.NewOpenRouterProvider(cfg.LLM.APIKey, cfg.LLM.Model) + log.Printf("Using OpenRouter with model: %s", cfg.LLM.Model) + case "lmstudio": + llmProvider = llm.NewLMStudioProvider(cfg.LLM.BaseURL, cfg.LLM.Model) + log.Printf("Using LM Studio at %s with model: %s", cfg.LLM.BaseURL, cfg.LLM.Model) + default: + log.Fatalf("Unknown LLM provider: %s", cfg.LLM.Provider) + } + + // Create person agent processor use case + personAgentUC := usecase.NewPersonAgentProcessorUseCase( + personRepo, + apiClient, + llmProvider, + cfg.APIKey, + cfg.OutputDir, + ) + + // Execute processing + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + if err := personAgentUC.Execute(ctx, cfg.InputFile); err != nil { + log.Fatalf("Failed to process persons: %v", err) + } + + log.Printf("\n========== All tasks completed successfully! ==========") +} diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..bee3026 --- /dev/null +++ b/config.example.json @@ -0,0 +1,7 @@ +{ + "api_key": "YOUR_API_KEY_HERE", + "input_file": "lista.json", + "output_dir": "output", + "locations_api": "https://hub.ag3nts.org/api/locations", + "access_level_api": "https://hub.ag3nts.org/api/accesslevel" +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4cdfde --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/paramah/ai_devs4/s01e02 + +go 1.25.0 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0129751 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,86 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +// Config represents the application configuration +type Config struct { + APIKey string `json:"api_key"` + InputFile string `json:"input_file"` + OutputDir string `json:"output_dir"` + LocationsAPI string `json:"locations_api"` + AccessLevelAPI string `json:"access_level_api"` + LocationsURL string `json:"locations_url"` + LLM LLMConfig `json:"llm"` +} + +// LLMConfig contains configuration for LLM provider +type LLMConfig struct { + Provider string `json:"provider"` // "openrouter" or "lmstudio" + Model string `json:"model"` + APIKey string `json:"api_key,omitempty"` // For OpenRouter + BaseURL string `json:"base_url,omitempty"` // For LM Studio +} + +// Load loads configuration from a JSON file +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + return &cfg, nil +} + +// Validate validates the configuration +func (c *Config) Validate() error { + if c.APIKey == "" { + return fmt.Errorf("api_key is required") + } + + if c.InputFile == "" { + return fmt.Errorf("input_file is required") + } + + if c.OutputDir == "" { + return fmt.Errorf("output_dir is required") + } + + if c.LocationsAPI == "" { + return fmt.Errorf("locations_api is required") + } + + if c.AccessLevelAPI == "" { + return fmt.Errorf("access_level_api is required") + } + + if c.LocationsURL == "" { + return fmt.Errorf("locations_url is required") + } + + if c.LLM.Provider != "openrouter" && c.LLM.Provider != "lmstudio" { + return fmt.Errorf("llm.provider must be 'openrouter' or 'lmstudio'") + } + + if c.LLM.Model == "" { + return fmt.Errorf("llm.model is required") + } + + if c.LLM.Provider == "openrouter" && c.LLM.APIKey == "" { + return fmt.Errorf("llm.api_key is required for openrouter provider") + } + + if c.LLM.Provider == "lmstudio" && c.LLM.BaseURL == "" { + return fmt.Errorf("llm.base_url is required for lmstudio provider") + } + + return nil +} diff --git a/internal/domain/api_client.go b/internal/domain/api_client.go new file mode 100644 index 0000000..bfbd719 --- /dev/null +++ b/internal/domain/api_client.go @@ -0,0 +1,24 @@ +package domain + +import "context" + +// LocationRequest represents a request for location information +type LocationRequest struct { + APIKey string `json:"apikey"` + Name string `json:"name"` + Surname string `json:"surname"` +} + +// AccessLevelRequest represents a request for access level information +type AccessLevelRequest struct { + APIKey string `json:"apikey"` + Name string `json:"name"` + Surname string `json:"surname"` + BirthYear int `json:"birthYear"` +} + +// APIClient defines the interface for API operations +type APIClient interface { + GetLocation(ctx context.Context, req LocationRequest) ([]byte, error) + GetAccessLevel(ctx context.Context, req AccessLevelRequest) ([]byte, error) +} diff --git a/internal/domain/city_coordinates.go b/internal/domain/city_coordinates.go new file mode 100644 index 0000000..ca95239 --- /dev/null +++ b/internal/domain/city_coordinates.go @@ -0,0 +1,50 @@ +package domain + +// CityCoordinates contains coordinates for Polish cities with power plants +var CityCoordinates = map[string]struct{ Lat, Lon float64 }{ + "Zabrze": {Lat: 50.3015, Lon: 18.7912}, + "Piotrków Trybunalski": {Lat: 51.4054, Lon: 19.7031}, + "Grudziądz": {Lat: 53.4836, Lon: 18.7536}, + "Tczew": {Lat: 54.0915, Lon: 18.7793}, + "Radom": {Lat: 51.4027, Lon: 21.1471}, + "Chelmno": {Lat: 53.3479, Lon: 18.4256}, + "Żarnowiec": {Lat: 54.7317, Lon: 18.0431}, +} + +// PowerPlantInfo contains information about a power plant +type PowerPlantInfo struct { + IsActive bool `json:"is_active"` + Power string `json:"power"` + Code string `json:"code"` +} + +// PowerPlantsData represents the structure from findhim_locations.json +type PowerPlantsData struct { + PowerPlants map[string]PowerPlantInfo `json:"power_plants"` +} + +// ToLocations converts power plants data to locations with coordinates +func (p *PowerPlantsData) ToLocations() []Location { + var locations []Location + + for city, info := range p.PowerPlants { + // Only include active power plants + if !info.IsActive { + continue + } + + coords, ok := CityCoordinates[city] + if !ok { + // Skip cities without known coordinates + continue + } + + locations = append(locations, Location{ + Name: city, + Latitude: coords.Lat, + Longitude: coords.Lon, + }) + } + + return locations +} diff --git a/internal/domain/final_answer.go b/internal/domain/final_answer.go new file mode 100644 index 0000000..47c3edc --- /dev/null +++ b/internal/domain/final_answer.go @@ -0,0 +1,66 @@ +package domain + +// FinalAnswer represents the final answer for the task +type FinalAnswer struct { + APIKey string `json:"apikey"` + Task string `json:"task"` + Answer AnswerDetail `json:"answer"` +} + +// AnswerDetail contains the person and power plant details +type AnswerDetail struct { + Name string `json:"name"` + Surname string `json:"surname"` + AccessLevel int `json:"accessLevel"` + PowerPlant string `json:"powerPlant"` +} + +// FindClosestPowerPlant finds the power plant closest to any person +// Returns the person, power plant, and distance +func FindClosestPowerPlant(personDataMap map[string]*PersonData, powerPlants []Location, plantsData *PowerPlantsData) (*PersonData, *Location, float64, string) { + var closestPerson *PersonData + var closestPlant *Location + minDistance := 1e10 + var powerPlantCode string + + for _, personData := range personDataMap { + if len(personData.Locations) == 0 { + continue + } + + // Use first location (primary) + personLoc := personData.Locations[0] + + for _, plant := range powerPlants { + distance := Haversine( + personLoc.Latitude, + personLoc.Longitude, + plant.Latitude, + plant.Longitude, + ) + + // If distance is smaller, or same distance but higher access level + if distance < minDistance { + minDistance = distance + closestPerson = personData + closestPlant = &plant + + // Get power plant code from plantsData + if info, ok := plantsData.PowerPlants[plant.Name]; ok { + powerPlantCode = info.Code + } + } else if distance == minDistance && closestPerson != nil && personData.AccessLevel > closestPerson.AccessLevel { + // Same distance, but higher access level + closestPerson = personData + closestPlant = &plant + + // Get power plant code from plantsData + if info, ok := plantsData.PowerPlants[plant.Name]; ok { + powerPlantCode = info.Code + } + } + } + } + + return closestPerson, closestPlant, minDistance, powerPlantCode +} diff --git a/internal/domain/llm.go b/internal/domain/llm.go new file mode 100644 index 0000000..62bfa5f --- /dev/null +++ b/internal/domain/llm.go @@ -0,0 +1,56 @@ +package domain + +import "context" + +// LLMMessage represents a message in the conversation +type LLMMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +// ToolCall represents a function call from the LLM +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function Function `json:"function"` +} + +// Function represents the function being called +type Function struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// Tool represents a tool definition for function calling +type Tool struct { + Type string `json:"type"` + Function FunctionDef `json:"function"` +} + +// FunctionDef defines a function that can be called +type FunctionDef struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters interface{} `json:"parameters"` +} + +// LLMRequest represents a request to the LLM with function calling support +type LLMRequest struct { + Messages []LLMMessage `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +// LLMResponse represents the response from the LLM +type LLMResponse struct { + Message LLMMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +// LLMProvider defines the interface for LLM providers +type LLMProvider interface { + Chat(ctx context.Context, request LLMRequest) (*LLMResponse, error) +} diff --git a/internal/domain/location.go b/internal/domain/location.go new file mode 100644 index 0000000..0fde967 --- /dev/null +++ b/internal/domain/location.go @@ -0,0 +1,180 @@ +package domain + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "time" +) + +// Location represents a location with coordinates +type Location struct { + Name string `json:"name"` + Latitude float64 `json:"lat"` + Longitude float64 `json:"lon"` +} + +// PersonLocation represents a person's location +type PersonLocation struct { + Name string `json:"name"` + Surname string `json:"surname"` + Latitude float64 `json:"lat"` + Longitude float64 `json:"lon"` +} + +// DistanceResult represents the distance calculation result +type DistanceResult struct { + Person string `json:"person"` + Location string `json:"location"` + DistanceKm float64 `json:"distance_km"` +} + +// Haversine calculates the distance between two points on Earth (in kilometers) +// using the Haversine formula +func Haversine(lat1, lon1, lat2, lon2 float64) float64 { + const earthRadiusKm = 6371.0 + + // Convert degrees to radians + lat1Rad := lat1 * math.Pi / 180 + lat2Rad := lat2 * math.Pi / 180 + deltaLat := (lat2 - lat1) * math.Pi / 180 + deltaLon := (lon2 - lon1) * math.Pi / 180 + + // Haversine formula + a := math.Sin(deltaLat/2)*math.Sin(deltaLat/2) + + math.Cos(lat1Rad)*math.Cos(lat2Rad)* + math.Sin(deltaLon/2)*math.Sin(deltaLon/2) + + c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) + + return earthRadiusKm * c +} + +// CalculateDistance calculates distance between person and location +func CalculateDistance(person PersonLocation, location Location) DistanceResult { + distance := Haversine(person.Latitude, person.Longitude, location.Latitude, location.Longitude) + + return DistanceResult{ + Person: person.Name + " " + person.Surname, + Location: location.Name, + DistanceKm: distance, + } +} + +// FindClosestLocation finds the closest location to a person +func FindClosestLocation(person PersonLocation, locations []Location) *DistanceResult { + if len(locations) == 0 { + return nil + } + + var closest *DistanceResult + minDistance := math.MaxFloat64 + + for _, loc := range locations { + result := CalculateDistance(person, loc) + if result.DistanceKm < minDistance { + minDistance = result.DistanceKm + closest = &result + } + } + + return closest +} + +// NominatimResponse represents OpenStreetMap Nominatim API response +type NominatimResponse struct { + Lat string `json:"lat"` + Lon string `json:"lon"` +} + +// GetPlantGeolocation fetches accurate coordinates for a city using geocoding API +func GetPlantGeolocation(ctx context.Context, cityName string) (float64, float64, error) { + url := fmt.Sprintf("https://nominatim.openstreetmap.org/search?city=%s&country=Poland&format=json&limit=1", cityName) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, 0, fmt.Errorf("creating request: %w", err) + } + + // OpenStreetMap requires User-Agent header + req.Header.Set("User-Agent", "PersonProcessor/1.0") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return 0, 0, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, 0, fmt.Errorf("reading response: %w", err) + } + + var results []NominatimResponse + if err := json.Unmarshal(body, &results); err != nil { + return 0, 0, fmt.Errorf("parsing JSON: %w", err) + } + + if len(results) == 0 { + return 0, 0, fmt.Errorf("no results found for city: %s", cityName) + } + + // Parse coordinates + var lat, lon float64 + if _, err := fmt.Sscanf(results[0].Lat, "%f", &lat); err != nil { + return 0, 0, fmt.Errorf("parsing latitude: %w", err) + } + if _, err := fmt.Sscanf(results[0].Lon, "%f", &lon); err != nil { + return 0, 0, fmt.Errorf("parsing longitude: %w", err) + } + + return lat, lon, nil +} + +// NearestPointResult represents result of finding nearest point +type NearestPointResult struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + DistanceKm float64 `json:"distance_km"` + Index int `json:"index"` +} + +// FindNearestPoint finds the nearest point from a list to a reference point +func FindNearestPoint(referenceLat, referenceLon float64, points [][]float64) *NearestPointResult { + if len(points) == 0 { + return nil + } + + var nearest *NearestPointResult + minDistance := math.MaxFloat64 + + for i, point := range points { + if len(point) < 2 { + continue // Skip invalid points + } + + lat := point[0] + lon := point[1] + + distance := Haversine(referenceLat, referenceLon, lat, lon) + + if distance < minDistance { + minDistance = distance + nearest = &NearestPointResult{ + Latitude: lat, + Longitude: lon, + DistanceKm: distance, + Index: i, + } + } + } + + return nearest +} diff --git a/internal/domain/location_repository.go b/internal/domain/location_repository.go new file mode 100644 index 0000000..40f3345 --- /dev/null +++ b/internal/domain/location_repository.go @@ -0,0 +1,8 @@ +package domain + +import "context" + +// LocationRepository defines the interface for loading locations +type LocationRepository interface { + LoadLocations(ctx context.Context, apiKey string) ([]Location, error) +} diff --git a/internal/domain/person.go b/internal/domain/person.go new file mode 100644 index 0000000..7047851 --- /dev/null +++ b/internal/domain/person.go @@ -0,0 +1,11 @@ +package domain + +// Person represents a person from the list +type Person struct { + Name string `json:"name"` + Surname string `json:"surname"` + Gender string `json:"gender"` + Born int `json:"born"` + City string `json:"city"` + Tags []string `json:"tags"` +} diff --git a/internal/domain/person_report.go b/internal/domain/person_report.go new file mode 100644 index 0000000..d76fcbf --- /dev/null +++ b/internal/domain/person_report.go @@ -0,0 +1,13 @@ +package domain + +// PersonReport represents a complete report for one person +type PersonReport struct { + Name string `json:"name"` + Surname string `json:"surname"` + NearestPlant string `json:"nearest_plant"` + PlantCode string `json:"plant_code"` + DistanceKm float64 `json:"distance_km"` + AccessLevel int `json:"access_level"` + PrimaryLatitude float64 `json:"primary_latitude"` + PrimaryLongitude float64 `json:"primary_longitude"` +} diff --git a/internal/domain/report.go b/internal/domain/report.go new file mode 100644 index 0000000..440e635 --- /dev/null +++ b/internal/domain/report.go @@ -0,0 +1,32 @@ +package domain + +import "sort" + +// PersonWithDistance represents a person with their distance to a location +type PersonWithDistance struct { + Name string `json:"name"` + Surname string `json:"surname"` + LocationName string `json:"location_name"` + DistanceKm float64 `json:"distance_km"` + AccessLevel int `json:"access_level,omitempty"` +} + +// LocationReport represents a report for a single location +type LocationReport struct { + LocationName string `json:"location_name"` + Persons []PersonWithDistance `json:"persons"` +} + +// SortPersonsByDistance sorts persons by distance (ascending) +func (r *LocationReport) SortPersonsByDistance() { + sort.Slice(r.Persons, func(i, j int) bool { + return r.Persons[i].DistanceKm < r.Persons[j].DistanceKm + }) +} + +// PersonData holds all gathered data for a person +type PersonData struct { + Person Person + Locations []PersonLocation // Multiple possible locations + AccessLevel int +} diff --git a/internal/domain/repository.go b/internal/domain/repository.go new file mode 100644 index 0000000..8b55e2e --- /dev/null +++ b/internal/domain/repository.go @@ -0,0 +1,8 @@ +package domain + +import "context" + +// PersonRepository defines the interface for loading persons +type PersonRepository interface { + LoadPersons(ctx context.Context, filePath string) ([]Person, error) +} diff --git a/internal/domain/tools.go b/internal/domain/tools.go new file mode 100644 index 0000000..34df5d1 --- /dev/null +++ b/internal/domain/tools.go @@ -0,0 +1,132 @@ +package domain + +// GetToolDefinitions returns tool definitions for function calling +func GetToolDefinitions() []Tool { + return []Tool{ + { + Type: "function", + Function: FunctionDef{ + Name: "get_location", + Description: "Gets the current location information for a person. Returns latitude and longitude coordinates.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "The first name of the person", + }, + "surname": map[string]interface{}{ + "type": "string", + "description": "The surname/last name of the person", + }, + }, + "required": []string{"name", "surname"}, + }, + }, + }, + { + Type: "function", + Function: FunctionDef{ + Name: "get_access_level", + Description: "Gets the access level information for a person. Requires the person's birth year (only the year as integer, not full date). Returns access level data.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "The first name of the person", + }, + "surname": map[string]interface{}{ + "type": "string", + "description": "The surname/last name of the person", + }, + "birth_year": map[string]interface{}{ + "type": "integer", + "description": "The birth year of the person (only the year as integer, e.g., 1987, not full date)", + }, + }, + "required": []string{"name", "surname", "birth_year"}, + }, + }, + }, + { + Type: "function", + Function: FunctionDef{ + Name: "get_power_plant_location", + Description: "Gets the geographical coordinates (latitude and longitude) of a power plant by its city name.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city_name": map[string]interface{}{ + "type": "string", + "description": "The name of the city where the power plant is located", + }, + }, + "required": []string{"city_name"}, + }, + }, + }, + { + Type: "function", + Function: FunctionDef{ + Name: "calculate_distance", + Description: "Calculates the distance in kilometers between two geographical points using the Haversine formula.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "lat1": map[string]interface{}{ + "type": "number", + "description": "Latitude of the first point", + }, + "lon1": map[string]interface{}{ + "type": "number", + "description": "Longitude of the first point", + }, + "lat2": map[string]interface{}{ + "type": "number", + "description": "Latitude of the second point", + }, + "lon2": map[string]interface{}{ + "type": "number", + "description": "Longitude of the second point", + }, + }, + "required": []string{"lat1", "lon1", "lat2", "lon2"}, + }, + }, + }, + { + Type: "function", + Function: FunctionDef{ + Name: "find_nearest_point", + Description: "Finds the nearest point from a list of points to a reference point (e.g., power plant location). Returns the nearest point with its distance.", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "reference_lat": map[string]interface{}{ + "type": "number", + "description": "Latitude of the reference point (e.g., power plant)", + }, + "reference_lon": map[string]interface{}{ + "type": "number", + "description": "Longitude of the reference point (e.g., power plant)", + }, + "points": map[string]interface{}{ + "type": "array", + "description": "Array of points to check, each point is [latitude, longitude]", + "items": map[string]interface{}{ + "type": "array", + "items": map[string]interface{}{ + "type": "number", + }, + "minItems": 2, + "maxItems": 2, + }, + }, + }, + "required": []string{"reference_lat", "reference_lon", "points"}, + }, + }, + }, + } +} diff --git a/internal/infrastructure/api/client.go b/internal/infrastructure/api/client.go new file mode 100644 index 0000000..f6bacb2 --- /dev/null +++ b/internal/infrastructure/api/client.go @@ -0,0 +1,90 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// Client implements domain.APIClient +type Client struct { + locationsURL string + accessLevelURL string + httpClient *http.Client +} + +// NewClient creates a new API client +func NewClient(locationsURL, accessLevelURL string) *Client { + return &Client{ + locationsURL: locationsURL, + accessLevelURL: accessLevelURL, + httpClient: &http.Client{}, + } +} + +// GetLocation sends a location request and returns the raw JSON response +func (c *Client) GetLocation(ctx context.Context, req domain.LocationRequest) ([]byte, error) { + jsonData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.locationsURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +// GetAccessLevel sends an access level request and returns the raw JSON response +func (c *Client) GetAccessLevel(ctx context.Context, req domain.AccessLevelRequest) ([]byte, error) { + jsonData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.accessLevelURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + return body, nil +} diff --git a/internal/infrastructure/json/location_repository.go b/internal/infrastructure/json/location_repository.go new file mode 100644 index 0000000..39126cd --- /dev/null +++ b/internal/infrastructure/json/location_repository.go @@ -0,0 +1,59 @@ +package json + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// LocationRepository implements domain.LocationRepository +type LocationRepository struct { + baseURL string + client *http.Client +} + +// NewLocationRepository creates a new location repository +func NewLocationRepository(baseURL string) *LocationRepository { + return &LocationRepository{ + baseURL: baseURL, + client: &http.Client{}, + } +} + +// LoadLocations loads locations from the API +func (r *LocationRepository) LoadLocations(ctx context.Context, apiKey string) ([]domain.Location, error) { + url := fmt.Sprintf("%s/data/%s/findhim_locations.json", r.baseURL, apiKey) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + resp, err := r.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching locations: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var plantsData domain.PowerPlantsData + if err := json.Unmarshal(body, &plantsData); err != nil { + return nil, fmt.Errorf("parsing JSON: %w", err) + } + + locations := plantsData.ToLocations() + return locations, nil +} diff --git a/internal/infrastructure/json/repository.go b/internal/infrastructure/json/repository.go new file mode 100644 index 0000000..c1a14bd --- /dev/null +++ b/internal/infrastructure/json/repository.go @@ -0,0 +1,33 @@ +package json + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// Repository implements domain.PersonRepository +type Repository struct{} + +// NewRepository creates a new JSON repository +func NewRepository() *Repository { + return &Repository{} +} + +// LoadPersons loads persons from a JSON file +func (r *Repository) LoadPersons(ctx context.Context, filePath string) ([]domain.Person, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("reading file: %w", err) + } + + var persons []domain.Person + if err := json.Unmarshal(data, &persons); err != nil { + return nil, fmt.Errorf("parsing JSON: %w", err) + } + + return persons, nil +} diff --git a/internal/infrastructure/llm/lmstudio.go b/internal/infrastructure/llm/lmstudio.go new file mode 100644 index 0000000..2ef0a93 --- /dev/null +++ b/internal/infrastructure/llm/lmstudio.go @@ -0,0 +1,123 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// LMStudioProvider implements domain.LLMProvider for local LM Studio +type LMStudioProvider struct { + baseURL string + model string + client *http.Client +} + +// NewLMStudioProvider creates a new LM Studio provider +func NewLMStudioProvider(baseURL, model string) *LMStudioProvider { + return &LMStudioProvider{ + baseURL: baseURL, + model: model, + client: &http.Client{}, + } +} + +type lmStudioRequest struct { + Model string `json:"model"` + Messages []domain.LLMMessage `json:"messages"` + Tools []domain.Tool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +type lmStudioResponse struct { + Choices []struct { + Message domain.LLMMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error json.RawMessage `json:"error,omitempty"` +} + +// Chat sends a chat request with function calling support +func (p *LMStudioProvider) Chat(ctx context.Context, request domain.LLMRequest) (*domain.LLMResponse, error) { + reqBody := lmStudioRequest{ + Model: p.model, + Messages: request.Messages, + Tools: request.Tools, + Temperature: request.Temperature, + } + + if request.ToolChoice != "" { + if request.ToolChoice == "auto" { + reqBody.ToolChoice = "auto" + } else { + reqBody.ToolChoice = map[string]interface{}{ + "type": "function", + "function": map[string]string{ + "name": request.ToolChoice, + }, + } + } + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + url := p.baseURL + "/v1/chat/completions" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + var apiResp lmStudioResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("unmarshaling response: %w\nResponse body: %s", err, string(body)) + } + + if len(apiResp.Error) > 0 { + var errStr string + if err := json.Unmarshal(apiResp.Error, &errStr); err == nil { + return nil, fmt.Errorf("API error: %s", errStr) + } + var errObj struct { + Message string `json:"message"` + } + if err := json.Unmarshal(apiResp.Error, &errObj); err == nil { + return nil, fmt.Errorf("API error: %s", errObj.Message) + } + return nil, fmt.Errorf("API error: %s", string(apiResp.Error)) + } + + if len(apiResp.Choices) == 0 { + return nil, fmt.Errorf("no choices in response. Response body: %s", string(body)) + } + + return &domain.LLMResponse{ + Message: apiResp.Choices[0].Message, + FinishReason: apiResp.Choices[0].FinishReason, + }, nil +} diff --git a/internal/infrastructure/llm/openrouter.go b/internal/infrastructure/llm/openrouter.go new file mode 100644 index 0000000..729bdb3 --- /dev/null +++ b/internal/infrastructure/llm/openrouter.go @@ -0,0 +1,113 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// OpenRouterProvider implements domain.LLMProvider for OpenRouter API +type OpenRouterProvider struct { + apiKey string + model string + baseURL string + client *http.Client +} + +// NewOpenRouterProvider creates a new OpenRouter provider +func NewOpenRouterProvider(apiKey, model string) *OpenRouterProvider { + return &OpenRouterProvider{ + apiKey: apiKey, + model: model, + baseURL: "https://openrouter.ai/api/v1/chat/completions", + client: &http.Client{}, + } +} + +type openRouterRequest struct { + Model string `json:"model"` + Messages []domain.LLMMessage `json:"messages"` + Tools []domain.Tool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +type openRouterResponse struct { + Choices []struct { + Message domain.LLMMessage `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// Chat sends a chat request with function calling support +func (p *OpenRouterProvider) Chat(ctx context.Context, request domain.LLMRequest) (*domain.LLMResponse, error) { + reqBody := openRouterRequest{ + Model: p.model, + Messages: request.Messages, + Tools: request.Tools, + Temperature: request.Temperature, + } + + if request.ToolChoice != "" { + if request.ToolChoice == "auto" { + reqBody.ToolChoice = "auto" + } else { + reqBody.ToolChoice = map[string]interface{}{ + "type": "function", + "function": map[string]string{ + "name": request.ToolChoice, + }, + } + } + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.apiKey) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var apiResp openRouterResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return nil, fmt.Errorf("unmarshaling response: %w", err) + } + + if apiResp.Error != nil { + return nil, fmt.Errorf("API error: %s", apiResp.Error.Message) + } + + if len(apiResp.Choices) == 0 { + return nil, fmt.Errorf("no choices in response") + } + + return &domain.LLMResponse{ + Message: apiResp.Choices[0].Message, + FinishReason: apiResp.Choices[0].FinishReason, + }, nil +} diff --git a/internal/usecase/agent_processor.go b/internal/usecase/agent_processor.go new file mode 100644 index 0000000..c6e3db0 --- /dev/null +++ b/internal/usecase/agent_processor.go @@ -0,0 +1,239 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// AgentProcessorUseCase handles the processing of persons using LLM agent +type AgentProcessorUseCase struct { + personRepo domain.PersonRepository + locationRepo domain.LocationRepository + apiClient domain.APIClient + llmProvider domain.LLMProvider + apiKey string + outputDir string +} + +// NewAgentProcessorUseCase creates a new use case instance +func NewAgentProcessorUseCase( + personRepo domain.PersonRepository, + locationRepo domain.LocationRepository, + apiClient domain.APIClient, + llmProvider domain.LLMProvider, + apiKey string, + outputDir string, +) *AgentProcessorUseCase { + return &AgentProcessorUseCase{ + personRepo: personRepo, + locationRepo: locationRepo, + apiClient: apiClient, + llmProvider: llmProvider, + apiKey: apiKey, + outputDir: outputDir, + } +} + +// Execute processes all persons using LLM agent +func (uc *AgentProcessorUseCase) Execute(ctx context.Context, inputFile string) error { + // Load persons from file + log.Printf("Loading persons from: %s", inputFile) + persons, err := uc.personRepo.LoadPersons(ctx, inputFile) + if err != nil { + return fmt.Errorf("loading persons: %w", err) + } + log.Printf("Loaded %d persons", len(persons)) + + // Load power plant locations + log.Printf("Loading power plant locations...") + locations, err := uc.locationRepo.LoadLocations(ctx, uc.apiKey) + if err != nil { + return fmt.Errorf("loading locations: %w", err) + } + log.Printf("Loaded %d power plant locations", len(locations)) + + // Process each person with agent + for i, person := range persons { + log.Printf("\n[%d/%d] Processing: %s %s", i+1, len(persons), person.Name, person.Surname) + + if err := uc.processPerson(ctx, person, locations); err != nil { + log.Printf("Error processing %s %s: %v", person.Name, person.Surname, err) + continue + } + } + + log.Printf("\nProcessing completed!") + return nil +} + +// processPerson uses LLM agent to gather data for a person +func (uc *AgentProcessorUseCase) processPerson(ctx context.Context, person domain.Person, powerPlants []domain.Location) error { + tools := domain.GetToolDefinitions() + + // Initial system message + systemPrompt := fmt.Sprintf(`You are an agent that gathers information about people. +For the person %s %s (born: %d), you need to: +1. Call get_location to get their current location coordinates +2. Call get_access_level to get their access level (remember: birth_year parameter must be only the year as integer, e.g., %d) + +After gathering the data, respond with "DONE".`, person.Name, person.Surname, person.Born, person.Born) + + messages := []domain.LLMMessage{ + { + Role: "system", + Content: systemPrompt, + }, + { + Role: "user", + Content: fmt.Sprintf("Please gather information for %s %s.", person.Name, person.Surname), + }, + } + + maxIterations := 10 + var personLocation *domain.PersonLocation + + for iteration := 0; iteration < maxIterations; iteration++ { + log.Printf(" [Iteration %d] Calling LLM...", iteration+1) + + resp, err := uc.llmProvider.Chat(ctx, domain.LLMRequest{ + Messages: messages, + Tools: tools, + ToolChoice: "auto", + Temperature: 0.0, + }) + if err != nil { + return fmt.Errorf("LLM chat error: %w", err) + } + + messages = append(messages, resp.Message) + + // Check if LLM wants to call functions + if len(resp.Message.ToolCalls) > 0 { + log.Printf(" → LLM requested %d tool call(s)", len(resp.Message.ToolCalls)) + + for _, toolCall := range resp.Message.ToolCalls { + result, loc, err := uc.executeToolCall(ctx, person, toolCall) + if err != nil { + return fmt.Errorf("executing tool call: %w", err) + } + + // Store person location if we got it from get_location + if loc != nil { + personLocation = loc + } + + messages = append(messages, domain.LLMMessage{ + Role: "tool", + Content: result, + ToolCallID: toolCall.ID, + }) + } + } else if resp.FinishReason == "stop" { + log.Printf(" ✓ Agent completed gathering data") + break + } + } + + // Calculate distances if we have location + if personLocation != nil && len(powerPlants) > 0 { + log.Printf(" → Calculating distances to power plants...") + closest := domain.FindClosestLocation(*personLocation, powerPlants) + if closest != nil { + log.Printf(" ✓ Closest power plant: %s (%.2f km)", closest.Location, closest.DistanceKm) + + // Save distance result + distanceFile := filepath.Join(uc.outputDir, "distances", fmt.Sprintf("%s_%s.json", person.Name, person.Surname)) + distanceData, _ := json.MarshalIndent(closest, "", " ") + os.WriteFile(distanceFile, distanceData, 0644) + } + } + + return nil +} + +// executeToolCall executes a tool call from the LLM +func (uc *AgentProcessorUseCase) executeToolCall(ctx context.Context, person domain.Person, toolCall domain.ToolCall) (string, *domain.PersonLocation, error) { + log.Printf(" → Executing: %s", toolCall.Function.Name) + + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + return "", nil, fmt.Errorf("parsing arguments: %w", err) + } + + switch toolCall.Function.Name { + case "get_location": + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + + req := domain.LocationRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + } + + response, err := uc.apiClient.GetLocation(ctx, req) + if err != nil { + return fmt.Sprintf("Error: %v", err), nil, nil + } + + // Save response + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "locations", fileName) + os.WriteFile(filePath, response, 0644) + log.Printf(" ✓ Saved to: %s", filePath) + + // Parse location to get coordinates (API returns array of locations) + var locationData []map[string]interface{} + if err := json.Unmarshal(response, &locationData); err == nil && len(locationData) > 0 { + // Take first location from the array + firstLoc := locationData[0] + if lat, ok := firstLoc["latitude"].(float64); ok { + if lon, ok := firstLoc["longitude"].(float64); ok { + personLoc := &domain.PersonLocation{ + Name: name, + Surname: surname, + Latitude: lat, + Longitude: lon, + } + return string(response), personLoc, nil + } + } + } + + return string(response), nil, nil + + case "get_access_level": + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + birthYear, _ := args["birth_year"].(float64) + + req := domain.AccessLevelRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + BirthYear: int(birthYear), + } + + response, err := uc.apiClient.GetAccessLevel(ctx, req) + if err != nil { + return fmt.Sprintf("Error: %v", err), nil, nil + } + + // Save response + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "accesslevel", fileName) + os.WriteFile(filePath, response, 0644) + log.Printf(" ✓ Saved to: %s", filePath) + + return string(response), nil, nil + + default: + return fmt.Sprintf("Unknown function: %s", toolCall.Function.Name), nil, nil + } +} diff --git a/internal/usecase/find_closest_agent.go b/internal/usecase/find_closest_agent.go new file mode 100644 index 0000000..96cc40a --- /dev/null +++ b/internal/usecase/find_closest_agent.go @@ -0,0 +1,185 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// FindClosestPowerPlantUseCase uses LLM agent to find the closest power plant +type FindClosestPowerPlantUseCase struct { + llmProvider domain.LLMProvider +} + +// NewFindClosestPowerPlantUseCase creates a new use case +func NewFindClosestPowerPlantUseCase(llmProvider domain.LLMProvider) *FindClosestPowerPlantUseCase { + return &FindClosestPowerPlantUseCase{ + llmProvider: llmProvider, + } +} + +// Execute uses agent to find the closest power plant to any person +func (uc *FindClosestPowerPlantUseCase) Execute( + ctx context.Context, + personDataMap map[string]*domain.PersonData, + plantsData *domain.PowerPlantsData, +) (*domain.PersonData, string, float64, string, error) { + + // Prepare data summary for agent + personsInfo := "" + for _, pd := range personDataMap { + if len(pd.Locations) > 0 { + loc := pd.Locations[0] + personsInfo += fmt.Sprintf("- %s %s: lat=%.4f, lon=%.4f, access_level=%d\n", + pd.Person.Name, pd.Person.Surname, loc.Latitude, loc.Longitude, pd.AccessLevel) + } + } + + plantsInfo := "" + for cityName := range plantsData.PowerPlants { + plantsInfo += fmt.Sprintf("- %s\n", cityName) + } + + systemPrompt := fmt.Sprintf(`You are an agent that finds the closest power plant to any person. + +Available persons and their locations: +%s + +Available power plants (cities): +%s + +Your task: +1. For each power plant, call get_power_plant_location to get its coordinates +2. For each person-plant pair, call calculate_distance to find the distance +3. Track the minimum distance and corresponding person/plant +4. After checking all combinations, respond with JSON containing the result: +{ + "person_name": "Name", + "person_surname": "Surname", + "plant_city": "City", + "min_distance": 123.45 +}`, personsInfo, plantsInfo) + + tools := domain.GetToolDefinitions() + messages := []domain.LLMMessage{ + { + Role: "system", + Content: systemPrompt, + }, + { + Role: "user", + Content: "Find the power plant that is closest to any person. Check all combinations.", + }, + } + + maxIterations := 100 // Need many iterations for all combinations + var resultJSON string + + for iteration := 0; iteration < maxIterations; iteration++ { + resp, err := uc.llmProvider.Chat(ctx, domain.LLMRequest{ + Messages: messages, + Tools: tools, + ToolChoice: "auto", + Temperature: 0.0, + }) + if err != nil { + return nil, "", 0, "", fmt.Errorf("LLM chat error: %w", err) + } + + messages = append(messages, resp.Message) + + if len(resp.Message.ToolCalls) > 0 { + log.Printf(" [Iteration %d] Agent requested %d tool call(s)", iteration+1, len(resp.Message.ToolCalls)) + + for _, toolCall := range resp.Message.ToolCalls { + result := uc.executeToolCall(toolCall, plantsData) + messages = append(messages, domain.LLMMessage{ + Role: "tool", + Content: result, + ToolCallID: toolCall.ID, + }) + } + } else if resp.FinishReason == "stop" { + resultJSON = resp.Message.Content + log.Printf(" ✓ Agent completed analysis") + log.Printf(" → Raw response: %s", resultJSON) + break + } + } + + // Extract JSON from response (agent might wrap it in text) + start := -1 + end := -1 + for i, ch := range resultJSON { + if ch == '{' && start == -1 { + start = i + } + if ch == '}' { + end = i + 1 + } + } + + if start == -1 || end == -1 { + return nil, "", 0, "", fmt.Errorf("no JSON found in response: %s", resultJSON) + } + + jsonStr := resultJSON[start:end] + + // Parse result + var result struct { + PersonName string `json:"person_name"` + PersonSurname string `json:"person_surname"` + PlantCity string `json:"plant_city"` + MinDistance float64 `json:"min_distance"` + } + + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + return nil, "", 0, "", fmt.Errorf("parsing result: %w (json: %s)", err, jsonStr) + } + + // Find the person data + key := fmt.Sprintf("%s_%s", result.PersonName, result.PersonSurname) + personData, ok := personDataMap[key] + if !ok { + return nil, "", 0, "", fmt.Errorf("person not found: %s", key) + } + + // Get plant code + plantInfo, ok := plantsData.PowerPlants[result.PlantCity] + if !ok { + return nil, "", 0, "", fmt.Errorf("plant not found: %s", result.PlantCity) + } + + return personData, result.PlantCity, result.MinDistance, plantInfo.Code, nil +} + +// executeToolCall handles tool execution +func (uc *FindClosestPowerPlantUseCase) executeToolCall(toolCall domain.ToolCall, plantsData *domain.PowerPlantsData) string { + var args map[string]interface{} + json.Unmarshal([]byte(toolCall.Function.Arguments), &args) + + switch toolCall.Function.Name { + case "get_power_plant_location": + cityName, _ := args["city_name"].(string) + coords, ok := domain.CityCoordinates[cityName] + if !ok { + return fmt.Sprintf(`{"error": "City not found: %s"}`, cityName) + } + return fmt.Sprintf(`{"city": "%s", "lat": %.6f, "lon": %.6f}`, cityName, coords.Lat, coords.Lon) + + case "calculate_distance": + lat1, _ := args["lat1"].(float64) + lon1, _ := args["lon1"].(float64) + lat2, _ := args["lat2"].(float64) + lon2, _ := args["lon2"].(float64) + + distance := domain.Haversine(lat1, lon1, lat2, lon2) + return fmt.Sprintf(`{"distance_km": %.2f}`, distance) + + default: + return fmt.Sprintf(`{"error": "Unknown function: %s"}`, toolCall.Function.Name) + } +} diff --git a/internal/usecase/optimized_agent_processor.go b/internal/usecase/optimized_agent_processor.go new file mode 100644 index 0000000..5f38494 --- /dev/null +++ b/internal/usecase/optimized_agent_processor.go @@ -0,0 +1,461 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// OptimizedAgentProcessorUseCase handles optimized processing with location reports +type OptimizedAgentProcessorUseCase struct { + personRepo domain.PersonRepository + locationRepo domain.LocationRepository + apiClient domain.APIClient + llmProvider domain.LLMProvider + apiKey string + outputDir string +} + +// NewOptimizedAgentProcessorUseCase creates a new optimized use case instance +func NewOptimizedAgentProcessorUseCase( + personRepo domain.PersonRepository, + locationRepo domain.LocationRepository, + apiClient domain.APIClient, + llmProvider domain.LLMProvider, + apiKey string, + outputDir string, +) *OptimizedAgentProcessorUseCase { + return &OptimizedAgentProcessorUseCase{ + personRepo: personRepo, + locationRepo: locationRepo, + apiClient: apiClient, + llmProvider: llmProvider, + apiKey: apiKey, + outputDir: outputDir, + } +} + +// Execute processes all persons and generates location reports +func (uc *OptimizedAgentProcessorUseCase) Execute(ctx context.Context, inputFile string) error { + // Load persons from file + log.Printf("Loading persons from: %s", inputFile) + persons, err := uc.personRepo.LoadPersons(ctx, inputFile) + if err != nil { + return fmt.Errorf("loading persons: %w", err) + } + log.Printf("Loaded %d persons", len(persons)) + + // Load power plant locations + log.Printf("Loading power plant locations...") + powerPlants, err := uc.locationRepo.LoadLocations(ctx, uc.apiKey) + if err != nil { + return fmt.Errorf("loading locations: %w", err) + } + log.Printf("Loaded %d power plant locations", len(powerPlants)) + + // Also load raw power plants data for codes + plantsData, err := uc.loadPowerPlantsData(ctx) + if err != nil { + return fmt.Errorf("loading power plants data: %w", err) + } + + // Save power plants data to output + plantsDataJSON, err := json.MarshalIndent(plantsData, "", " ") + if err != nil { + log.Printf("Warning: Failed to marshal power plants data: %v", err) + } else { + plantsFilePath := filepath.Join(uc.outputDir, "findhim_locations.json") + if err := os.WriteFile(plantsFilePath, plantsDataJSON, 0644); err != nil { + log.Printf("Warning: Failed to save power plants data: %v", err) + } else { + log.Printf("✓ Power plants data saved to: %s", plantsFilePath) + } + } + + // Phase 1: Gather all data for all persons + log.Printf("\n========== Phase 1: Gathering person data ==========") + personDataMap := make(map[string]*domain.PersonData) + + for i, person := range persons { + log.Printf("[%d/%d] Gathering data for: %s %s", i+1, len(persons), person.Name, person.Surname) + + personData, err := uc.gatherPersonData(ctx, person) + if err != nil { + log.Printf("Error gathering data for %s %s: %v", person.Name, person.Surname, err) + continue + } + + key := fmt.Sprintf("%s_%s", person.Name, person.Surname) + personDataMap[key] = personData + } + + // Phase 2: Calculate all distances and generate location reports + log.Printf("\n========== Phase 2: Generating location reports ==========") + locationReports := uc.generateLocationReports(personDataMap, powerPlants) + + // Phase 3: Save reports + log.Printf("\n========== Phase 3: Saving reports ==========") + for _, report := range locationReports { + uc.saveLocationReport(report) + } + + // Phase 4: Find closest power plant (locally, no LLM needed) + log.Printf("\n========== Phase 4: Finding closest power plant ==========") + + closestPerson, closestPlantName, distance, plantCode := uc.findClosestPowerPlantLocally(personDataMap, powerPlants, plantsData) + + if closestPerson != nil { + log.Printf(" ✓ Closest power plant: %s (%s)", closestPlantName, plantCode) + log.Printf(" ✓ Closest person: %s %s", closestPerson.Person.Name, closestPerson.Person.Surname) + log.Printf(" ✓ Distance: %.2f km", distance) + log.Printf(" ✓ Access level: %d", closestPerson.AccessLevel) + + // Save final answer + finalAnswer := domain.FinalAnswer{ + APIKey: uc.apiKey, + Task: "findhim", + Answer: domain.AnswerDetail{ + Name: closestPerson.Person.Name, + Surname: closestPerson.Person.Surname, + AccessLevel: closestPerson.AccessLevel, + PowerPlant: plantCode, + }, + } + + answerJSON, err := json.MarshalIndent(finalAnswer, "", " ") + if err != nil { + return fmt.Errorf("marshaling final answer: %w", err) + } + + answerPath := filepath.Join(uc.outputDir, "final_answer.json") + if err := os.WriteFile(answerPath, answerJSON, 0644); err != nil { + return fmt.Errorf("saving final answer: %w", err) + } + + log.Printf(" ✓ Final answer saved to: %s", answerPath) + } + + log.Printf("\nProcessing completed!") + return nil +} + +// gatherPersonData uses LLM agent to gather all data for a person (optimized) +func (uc *OptimizedAgentProcessorUseCase) gatherPersonData(ctx context.Context, person domain.Person) (*domain.PersonData, error) { + tools := domain.GetToolDefinitions() + + // Minimal, optimized system prompt + systemPrompt := fmt.Sprintf(`Gather data for person: %s %s (born: %d). +Tasks: +1. Call get_location +2. Call get_access_level (use birth_year: %d) +Then respond "DONE".`, person.Name, person.Surname, person.Born, person.Born) + + messages := []domain.LLMMessage{ + { + Role: "system", + Content: systemPrompt, + }, + { + Role: "user", + Content: "Start gathering data.", + }, + } + + maxIterations := 5 + var personLocations []domain.PersonLocation + var accessLevel int + + for iteration := 0; iteration < maxIterations; iteration++ { + resp, err := uc.llmProvider.Chat(ctx, domain.LLMRequest{ + Messages: messages, + Tools: tools, + ToolChoice: "auto", + Temperature: 0.0, + }) + if err != nil { + return nil, fmt.Errorf("LLM chat error: %w", err) + } + + messages = append(messages, resp.Message) + + if len(resp.Message.ToolCalls) > 0 { + for _, toolCall := range resp.Message.ToolCalls { + result, locations, level, err := uc.executeToolCall(ctx, person, toolCall) + if err != nil { + return nil, fmt.Errorf("executing tool call: %w", err) + } + + personLocations = append(personLocations, locations...) + if level > 0 { + accessLevel = level + } + + messages = append(messages, domain.LLMMessage{ + Role: "tool", + Content: result, + ToolCallID: toolCall.ID, + }) + } + } else if resp.FinishReason == "stop" { + log.Printf(" ✓ Data gathered (locations: %d, access level: %d)", len(personLocations), accessLevel) + break + } + } + + return &domain.PersonData{ + Person: person, + Locations: personLocations, + AccessLevel: accessLevel, + }, nil +} + +// executeToolCall executes a tool call and returns locations and access level +func (uc *OptimizedAgentProcessorUseCase) executeToolCall( + ctx context.Context, + person domain.Person, + toolCall domain.ToolCall, +) (string, []domain.PersonLocation, int, error) { + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + return "", nil, 0, fmt.Errorf("parsing arguments: %w", err) + } + + switch toolCall.Function.Name { + case "get_location": + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + + req := domain.LocationRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + } + + response, err := uc.apiClient.GetLocation(ctx, req) + if err != nil { + return fmt.Sprintf("Error: %v", err), nil, 0, nil + } + + // Save response + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "locations", fileName) + os.WriteFile(filePath, response, 0644) + + // Parse all locations from array + var locationData []map[string]interface{} + var locations []domain.PersonLocation + + if err := json.Unmarshal(response, &locationData); err == nil { + for _, loc := range locationData { + if lat, ok := loc["latitude"].(float64); ok { + if lon, ok := loc["longitude"].(float64); ok { + locations = append(locations, domain.PersonLocation{ + Name: name, + Surname: surname, + Latitude: lat, + Longitude: lon, + }) + } + } + } + } + + log.Printf(" → get_location: %d locations found", len(locations)) + return string(response), locations, 0, nil + + case "get_access_level": + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + birthYear, _ := args["birth_year"].(float64) + + req := domain.AccessLevelRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + BirthYear: int(birthYear), + } + + response, err := uc.apiClient.GetAccessLevel(ctx, req) + if err != nil { + return fmt.Sprintf("Error: %v", err), nil, 0, nil + } + + // Save response + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "accesslevel", fileName) + os.WriteFile(filePath, response, 0644) + + // Parse access level + var accessData struct { + AccessLevel int `json:"accessLevel"` + } + var level int + if err := json.Unmarshal(response, &accessData); err == nil { + level = accessData.AccessLevel + } + + log.Printf(" → get_access_level: level %d", level) + return string(response), nil, level, nil + + default: + return fmt.Sprintf("Unknown function: %s", toolCall.Function.Name), nil, 0, nil + } +} + +// generateLocationReports creates reports for each power plant location +func (uc *OptimizedAgentProcessorUseCase) generateLocationReports( + personDataMap map[string]*domain.PersonData, + powerPlants []domain.Location, +) []domain.LocationReport { + var reports []domain.LocationReport + + for _, powerPlant := range powerPlants { + report := domain.LocationReport{ + LocationName: powerPlant.Name, + Persons: []domain.PersonWithDistance{}, + } + + // Calculate distances for all persons to this power plant + for _, personData := range personDataMap { + if len(personData.Locations) == 0 { + continue + } + + // Use first location (primary location) + personLoc := personData.Locations[0] + distance := domain.Haversine( + personLoc.Latitude, + personLoc.Longitude, + powerPlant.Latitude, + powerPlant.Longitude, + ) + + report.Persons = append(report.Persons, domain.PersonWithDistance{ + Name: personData.Person.Name, + Surname: personData.Person.Surname, + LocationName: powerPlant.Name, + DistanceKm: distance, + AccessLevel: personData.AccessLevel, + }) + } + + // Sort by distance + report.SortPersonsByDistance() + reports = append(reports, report) + + log.Printf(" ✓ %s: %d persons", powerPlant.Name, len(report.Persons)) + } + + return reports +} + +// saveLocationReport saves a location report to file +func (uc *OptimizedAgentProcessorUseCase) saveLocationReport(report domain.LocationReport) { + fileName := fmt.Sprintf("%s_report.json", report.LocationName) + filePath := filepath.Join(uc.outputDir, "reports", fileName) + + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + log.Printf("Error marshaling report for %s: %v", report.LocationName, err) + return + } + + if err := os.WriteFile(filePath, data, 0644); err != nil { + log.Printf("Error saving report for %s: %v", report.LocationName, err) + return + } + + log.Printf(" ✓ Saved: %s", filePath) +} + +// loadPowerPlantsData loads raw power plants data with codes +func (uc *OptimizedAgentProcessorUseCase) loadPowerPlantsData(ctx context.Context) (*domain.PowerPlantsData, error) { + // Use the location repository to get the raw data + // We need to fetch from the API directly + url := fmt.Sprintf("https://hub.ag3nts.org/data/%s/findhim_locations.json", uc.apiKey) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching data: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + var plantsData domain.PowerPlantsData + if err := json.Unmarshal(body, &plantsData); err != nil { + return nil, fmt.Errorf("parsing JSON: %w", err) + } + + return &plantsData, nil +} + +// findClosestPowerPlantLocally finds the closest power plant without using LLM +func (uc *OptimizedAgentProcessorUseCase) findClosestPowerPlantLocally( + personDataMap map[string]*domain.PersonData, + powerPlants []domain.Location, + plantsData *domain.PowerPlantsData, +) (*domain.PersonData, string, float64, string) { + var closestPerson *domain.PersonData + var closestPlantName string + var plantCode string + minDistance := 1e10 + + // Check all person-plant combinations + for _, personData := range personDataMap { + if len(personData.Locations) == 0 { + continue + } + + // Use first location (primary) + personLoc := personData.Locations[0] + + for _, plant := range powerPlants { + distance := domain.Haversine( + personLoc.Latitude, + personLoc.Longitude, + plant.Latitude, + plant.Longitude, + ) + + // If distance is smaller, or same distance but higher access level + if distance < minDistance { + minDistance = distance + closestPerson = personData + closestPlantName = plant.Name + + // Get power plant code + if info, ok := plantsData.PowerPlants[plant.Name]; ok { + plantCode = info.Code + } + } else if distance == minDistance && closestPerson != nil && personData.AccessLevel > closestPerson.AccessLevel { + // Same distance, but higher access level + closestPerson = personData + closestPlantName = plant.Name + + // Get power plant code + if info, ok := plantsData.PowerPlants[plant.Name]; ok { + plantCode = info.Code + } + } + } + } + + return closestPerson, closestPlantName, minDistance, plantCode +} diff --git a/internal/usecase/person_agent_processor.go b/internal/usecase/person_agent_processor.go new file mode 100644 index 0000000..113a765 --- /dev/null +++ b/internal/usecase/person_agent_processor.go @@ -0,0 +1,694 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// PersonAgentProcessorUseCase processes each person with optimized LLM calls +type PersonAgentProcessorUseCase struct { + personRepo domain.PersonRepository + apiClient domain.APIClient + llmProvider domain.LLMProvider + apiKey string + outputDir string +} + +// NewPersonAgentProcessorUseCase creates a new use case instance +func NewPersonAgentProcessorUseCase( + personRepo domain.PersonRepository, + apiClient domain.APIClient, + llmProvider domain.LLMProvider, + apiKey string, + outputDir string, +) *PersonAgentProcessorUseCase { + return &PersonAgentProcessorUseCase{ + personRepo: personRepo, + apiClient: apiClient, + llmProvider: llmProvider, + apiKey: apiKey, + outputDir: outputDir, + } +} + +// Execute processes all persons +func (uc *PersonAgentProcessorUseCase) Execute(ctx context.Context, inputFile string) error { + log.Printf("\n╔════════════════════════════════════════════════════════════════") + log.Printf("║ PHASE: INITIALIZATION") + log.Printf("╚════════════════════════════════════════════════════════════════") + + // Create output directories + log.Printf("\n→ Creating output directories...") + dirs := []string{ + filepath.Join(uc.outputDir, "locations"), + filepath.Join(uc.outputDir, "accesslevel"), + filepath.Join(uc.outputDir, "person_reports"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", dir, err) + } + log.Printf(" ✓ Created: %s", dir) + } + + log.Printf("\n╔════════════════════════════════════════════════════════════════") + log.Printf("║ PHASE: LOADING DATA") + log.Printf("╚════════════════════════════════════════════════════════════════") + + // Load persons + log.Printf("\n→ Loading persons from: %s", inputFile) + persons, err := uc.personRepo.LoadPersons(ctx, inputFile) + if err != nil { + return fmt.Errorf("loading persons: %w", err) + } + log.Printf("✓ Loaded %d persons", len(persons)) + for i, p := range persons { + log.Printf(" %d. %s %s (born: %d, city: %s)", i+1, p.Name, p.Surname, p.Born, p.City) + } + + // Load power plants data + log.Printf("\n→ Loading power plants data from API...") + plantsData, err := uc.loadPowerPlantsData(ctx) + if err != nil { + return fmt.Errorf("loading power plants: %w", err) + } + + // Save power plants data + plantsJSON, _ := json.MarshalIndent(plantsData, "", " ") + plantsPath := filepath.Join(uc.outputDir, "findhim_locations.json") + os.WriteFile(plantsPath, plantsJSON, 0644) + log.Printf("✓ Loaded %d power plants", len(plantsData.PowerPlants)) + + // Get list of plant cities for agent + var plantCities []string + activePlants := 0 + for city, info := range plantsData.PowerPlants { + plantCities = append(plantCities, city) + if info.IsActive { + activePlants++ + log.Printf(" • %s (%s) - %s [ACTIVE]", city, info.Code, info.Power) + } else { + log.Printf(" • %s (%s) - %s [INACTIVE]", city, info.Code, info.Power) + } + } + log.Printf("✓ Active plants: %d/%d", activePlants, len(plantsData.PowerPlants)) + log.Printf("✓ Power plants data saved to: %s", plantsPath) + + // Fetch accurate geolocation for all power plants using geocoding API + log.Printf("\n→ Fetching accurate geolocations from geocoding API...") + plantCoordinates, err := uc.fetchPlantGeolocations(ctx, plantsData) + if err != nil { + return fmt.Errorf("fetching plant geolocations: %w", err) + } + log.Printf("✓ Fetched accurate coordinates for %d plants", len(plantCoordinates)) + + // Process each person + var personReports []domain.PersonReport + + log.Printf("\n╔════════════════════════════════════════════════════════════════") + log.Printf("║ PHASE: PROCESSING PERSONS (OPTIMIZED)") + log.Printf("║ Strategy: 2 LLM calls per person + parallel distance calculation") + log.Printf("╚════════════════════════════════════════════════════════════════") + + for i, person := range persons { + log.Printf("\n") + log.Printf("┌────────────────────────────────────────────────────────────────") + log.Printf("│ [%d/%d] PERSON: %s %s", i+1, len(persons), person.Name, person.Surname) + log.Printf("│ Born: %d | City: %s", person.Born, person.City) + log.Printf("└────────────────────────────────────────────────────────────────") + + report, err := uc.processPerson(ctx, person, plantsData, plantCoordinates) + if err != nil { + log.Printf("\n✗✗✗ ERROR processing %s %s: %v", person.Name, person.Surname, err) + continue + } + + personReports = append(personReports, *report) + + // Save individual report + reportJSON, _ := json.MarshalIndent(report, "", " ") + reportPath := filepath.Join(uc.outputDir, "person_reports", fmt.Sprintf("%s_%s.json", person.Name, person.Surname)) + os.WriteFile(reportPath, reportJSON, 0644) + log.Printf("\n✓ Individual report saved: %s", reportPath) + } + + // Find person with minimum distance to their nearest plant + log.Printf("\n") + log.Printf("╔════════════════════════════════════════════════════════════════") + log.Printf("║ PHASE: FINDING MINIMUM DISTANCE") + log.Printf("╚════════════════════════════════════════════════════════════════") + log.Printf("\nAnalyzing all %d person reports to find minimum distance...", len(personReports)) + + var closestReport *domain.PersonReport + minDistance := 1e10 + + for i := range personReports { + log.Printf(" • %s %s → %s: %.2f km (access level: %d)", + personReports[i].Name, + personReports[i].Surname, + personReports[i].NearestPlant, + personReports[i].DistanceKm, + personReports[i].AccessLevel) + + if personReports[i].DistanceKm < minDistance { + minDistance = personReports[i].DistanceKm + closestReport = &personReports[i] + } + } + + if closestReport != nil { + log.Printf("\n╔════════════════════════════════════════════════════════════════") + log.Printf("║ WINNER: CLOSEST PERSON-PLANT PAIR") + log.Printf("╚════════════════════════════════════════════════════════════════") + log.Printf(" Person: %s %s", closestReport.Name, closestReport.Surname) + log.Printf(" Power Plant: %s (%s)", closestReport.NearestPlant, closestReport.PlantCode) + log.Printf(" Distance: %.2f km", closestReport.DistanceKm) + log.Printf(" Access Level: %d", closestReport.AccessLevel) + log.Printf(" Coordinates: %.4f°N, %.4f°E", closestReport.PrimaryLatitude, closestReport.PrimaryLongitude) + + // Save final answer + finalAnswer := domain.FinalAnswer{ + APIKey: uc.apiKey, + Task: "findhim", + Answer: domain.AnswerDetail{ + Name: closestReport.Name, + Surname: closestReport.Surname, + AccessLevel: closestReport.AccessLevel, + PowerPlant: closestReport.PlantCode, + }, + } + + answerJSON, _ := json.MarshalIndent(finalAnswer, "", " ") + answerPath := filepath.Join(uc.outputDir, "final_answer.json") + os.WriteFile(answerPath, answerJSON, 0644) + log.Printf("\n✓ Final answer saved to: %s", answerPath) + log.Printf("\n╔════════════════════════════════════════════════════════════════") + log.Printf("║ Ready for verification!") + log.Printf("║ Run: curl -X POST https://hub.ag3nts.org/verify \\") + log.Printf("║ -H \"Content-Type: application/json\" \\") + log.Printf("║ -d @output/final_answer.json") + log.Printf("╚════════════════════════════════════════════════════════════════") + } + + log.Printf("\n✓✓✓ Processing completed successfully! ✓✓✓") + return nil +} + +// processPerson processes one person - OPTIMIZED: only 2 LLM calls +func (uc *PersonAgentProcessorUseCase) processPerson( + ctx context.Context, + person domain.Person, + plantsData *domain.PowerPlantsData, + plantCoordinates map[string]struct{ Lat, Lon float64 }, +) (*domain.PersonReport, error) { + + log.Printf(" ┌─ Starting optimized analysis for: %s %s (born: %d)", person.Name, person.Surname, person.Born) + log.Printf(" │ Strategy: LLM call 1 (locations) + parallel distance calc + LLM call 2 (access)") + + // PHASE 1: Get person locations (LLM CALL 1) + log.Printf(" │") + log.Printf(" │ [LLM Call 1/2] Fetching person locations via function calling...") + personLocations, err := uc.getPersonLocations(ctx, person) + if err != nil { + return nil, fmt.Errorf("getting locations: %w", err) + } + log.Printf(" │ ✓ Found %d location(s) for %s %s", len(personLocations), person.Name, person.Surname) + + if len(personLocations) == 0 { + return nil, fmt.Errorf("no locations found for person") + } + + // PHASE 2: Calculate distances for ALL person locations to ALL power plants + log.Printf(" │") + log.Printf(" │ [Local Processing] Analyzing ALL %d location(s) of the person...", len(personLocations)) + + // Find globally nearest plant across ALL person locations + var nearestPlant string + var minDistance float64 = 1e10 + var bestLocation domain.PersonLocation + + for i, personLoc := range personLocations { + log.Printf(" │ ├─ Location [%d/%d]: %.4f°N, %.4f°E", i+1, len(personLocations), personLoc.Latitude, personLoc.Longitude) + log.Printf(" │ │ ┌─ Checking distances to all plants:") + + plant, dist := uc.findNearestPlantParallel(personLoc, plantsData, plantCoordinates) + + log.Printf(" │ │ └─ Nearest plant for this location: %s (%.2f km)", plant, dist) + + if dist < minDistance { + minDistance = dist + nearestPlant = plant + bestLocation = personLoc + log.Printf(" │ │ ★ NEW MINIMUM! Updated global minimum") + } + } + + log.Printf(" │ └─ ✓ Global analysis complete") + log.Printf(" │ ✓ BEST result: %s at %.2f km (from location %.4f°N, %.4f°E)", nearestPlant, minDistance, bestLocation.Latitude, bestLocation.Longitude) + + // PHASE 3: Get access level (LLM CALL 2) + log.Printf(" │") + log.Printf(" │ [LLM Call 2/2] Fetching access level via function calling...") + accessLevel, err := uc.getAccessLevel(ctx, person) + if err != nil { + return nil, fmt.Errorf("getting access level: %w", err) + } + log.Printf(" │ ✓ Access level: %d", accessLevel) + + // Get plant code + plantInfo, ok := plantsData.PowerPlants[nearestPlant] + if !ok { + log.Printf(" │ ✗ Plant not found in database: %s", nearestPlant) + return nil, fmt.Errorf("plant not found: %s", nearestPlant) + } + + log.Printf(" │") + log.Printf(" │ ═══ FINAL RESULTS ═══") + log.Printf(" │ Nearest plant: %s (%s)", nearestPlant, plantInfo.Code) + log.Printf(" │ Distance: %.2f km", minDistance) + log.Printf(" │ Access level: %d", accessLevel) + log.Printf(" │ Best location: %.4f°N, %.4f°E", bestLocation.Latitude, bestLocation.Longitude) + log.Printf(" │ Total LLM calls: 2") + + report := &domain.PersonReport{ + Name: person.Name, + Surname: person.Surname, + NearestPlant: nearestPlant, + PlantCode: plantInfo.Code, + DistanceKm: minDistance, + AccessLevel: accessLevel, + PrimaryLatitude: bestLocation.Latitude, + PrimaryLongitude: bestLocation.Longitude, + } + + log.Printf(" └─ ✓ Successfully processed: %s %s (2 LLM calls total)", person.Name, person.Surname) + + return report, nil +} + +// getPersonLocations fetches locations for a person using LLM function calling (1 call) +func (uc *PersonAgentProcessorUseCase) getPersonLocations(ctx context.Context, person domain.Person) ([]domain.PersonLocation, error) { + tools := []domain.Tool{ + { + Type: "function", + Function: domain.FunctionDef{ + Name: "get_location", + Description: "Gets the location information for a person by their name and surname", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "The first name of the person", + }, + "surname": map[string]interface{}{ + "type": "string", + "description": "The surname/last name of the person", + }, + }, + "required": []string{"name", "surname"}, + }, + }, + }, + } + + systemPrompt := fmt.Sprintf("Call get_location for %s %s and return the result.", person.Name, person.Surname) + messages := []domain.LLMMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: "Get location now."}, + } + + resp, err := uc.llmProvider.Chat(ctx, domain.LLMRequest{ + Messages: messages, + Tools: tools, + ToolChoice: "auto", + Temperature: 0.0, + }) + if err != nil { + return nil, fmt.Errorf("LLM call failed: %w", err) + } + + if len(resp.Message.ToolCalls) == 0 { + return nil, fmt.Errorf("no tool calls in response") + } + + // Execute get_location + toolCall := resp.Message.ToolCalls[0] + var args map[string]interface{} + json.Unmarshal([]byte(toolCall.Function.Arguments), &args) + + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + + log.Printf(" │ → API call: get_location(%s, %s)", name, surname) + + req := domain.LocationRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + } + + response, err := uc.apiClient.GetLocation(ctx, req) + if err != nil { + return nil, fmt.Errorf("API call failed: %w", err) + } + + // Save to file + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "locations", fileName) + os.WriteFile(filePath, response, 0644) + + // Parse locations + var locationData []map[string]interface{} + var locations []domain.PersonLocation + + if err := json.Unmarshal(response, &locationData); err != nil { + return nil, fmt.Errorf("parsing locations: %w", err) + } + + for _, loc := range locationData { + if lat, ok := loc["latitude"].(float64); ok { + if lon, ok := loc["longitude"].(float64); ok { + locations = append(locations, domain.PersonLocation{ + Name: name, + Surname: surname, + Latitude: lat, + Longitude: lon, + }) + } + } + } + + return locations, nil +} + +// findNearestPlantParallel finds the nearest power plant using parallel goroutines (NO LLM) +func (uc *PersonAgentProcessorUseCase) findNearestPlantParallel( + personLoc domain.PersonLocation, + plantsData *domain.PowerPlantsData, + plantCoordinates map[string]struct{ Lat, Lon float64 }, +) (string, float64) { + type result struct { + plant string + distance float64 + } + + results := make(chan result, len(plantsData.PowerPlants)) + var wg sync.WaitGroup + + // Launch goroutine for each power plant + for city, info := range plantsData.PowerPlants { + if !info.IsActive { + continue // Skip inactive plants + } + + wg.Add(1) + go func(cityName string) { + defer wg.Done() + + coords, ok := plantCoordinates[cityName] + if !ok { + log.Printf(" │ │ ✗ Goroutine [%s]: no coordinates found", cityName) + results <- result{cityName, 1e10} + return + } + + distance := domain.Haversine( + personLoc.Latitude, + personLoc.Longitude, + coords.Lat, + coords.Lon, + ) + + log.Printf(" │ │ • Goroutine [%s]: %.2f km (coords: %.4f°N, %.4f°E)", cityName, distance, coords.Lat, coords.Lon) + results <- result{cityName, distance} + }(city) + } + + // Close channel after all goroutines finish + go func() { + wg.Wait() + close(results) + }() + + // Collect results and find minimum + minDistance := 1e10 + nearestPlant := "" + + for r := range results { + if r.distance < minDistance { + minDistance = r.distance + nearestPlant = r.plant + } + } + + return nearestPlant, minDistance +} + +// getAccessLevel fetches access level for a person using LLM function calling (1 call) +func (uc *PersonAgentProcessorUseCase) getAccessLevel(ctx context.Context, person domain.Person) (int, error) { + tools := []domain.Tool{ + { + Type: "function", + Function: domain.FunctionDef{ + Name: "get_access_level", + Description: "Gets the access level for a person", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "name": map[string]interface{}{ + "type": "string", + "description": "The first name of the person", + }, + "surname": map[string]interface{}{ + "type": "string", + "description": "The surname/last name of the person", + }, + "birth_year": map[string]interface{}{ + "type": "integer", + "description": "The birth year of the person (only year, not full date)", + }, + }, + "required": []string{"name", "surname", "birth_year"}, + }, + }, + }, + } + + systemPrompt := fmt.Sprintf("Call get_access_level for %s %s (birth year: %d).", person.Name, person.Surname, person.Born) + messages := []domain.LLMMessage{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: "Get access level now."}, + } + + resp, err := uc.llmProvider.Chat(ctx, domain.LLMRequest{ + Messages: messages, + Tools: tools, + ToolChoice: "auto", + Temperature: 0.0, + }) + if err != nil { + return 0, fmt.Errorf("LLM call failed: %w", err) + } + + if len(resp.Message.ToolCalls) == 0 { + return 0, fmt.Errorf("no tool calls in response") + } + + // Execute get_access_level + toolCall := resp.Message.ToolCalls[0] + var args map[string]interface{} + json.Unmarshal([]byte(toolCall.Function.Arguments), &args) + + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + birthYear, _ := args["birth_year"].(float64) + + log.Printf(" │ → API call: get_access_level(%s, %s, %d)", name, surname, int(birthYear)) + + req := domain.AccessLevelRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + BirthYear: int(birthYear), + } + + response, err := uc.apiClient.GetAccessLevel(ctx, req) + if err != nil { + return 0, fmt.Errorf("API call failed: %w", err) + } + + // Save to file + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "accesslevel", fileName) + os.WriteFile(filePath, response, 0644) + + // Parse access level + var accessData struct { + AccessLevel int `json:"accessLevel"` + } + if err := json.Unmarshal(response, &accessData); err != nil { + return 0, fmt.Errorf("parsing access level: %w", err) + } + + return accessData.AccessLevel, nil +} + +// loadPowerPlantsData loads power plants data from API +func (uc *PersonAgentProcessorUseCase) loadPowerPlantsData(ctx context.Context) (*domain.PowerPlantsData, error) { + url := fmt.Sprintf("https://hub.ag3nts.org/data/%s/findhim_locations.json", uc.apiKey) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var plantsData domain.PowerPlantsData + if err := json.Unmarshal(body, &plantsData); err != nil { + return nil, err + } + + return &plantsData, nil +} + +// fetchPlantGeolocations fetches accurate coordinates for all power plants using geocoding API +func (uc *PersonAgentProcessorUseCase) fetchPlantGeolocations( + ctx context.Context, + plantsData *domain.PowerPlantsData, +) (map[string]struct{ Lat, Lon float64 }, error) { + coordinates := make(map[string]struct{ Lat, Lon float64 }) + + for city := range plantsData.PowerPlants { + log.Printf(" • Geocoding: %s...", city) + + lat, lon, err := domain.GetPlantGeolocation(ctx, city) + if err != nil { + log.Printf(" ✗ Error: %v (using fallback)", err) + // Use fallback coordinates if available + if fallback, ok := domain.CityCoordinates[city]; ok { + coordinates[city] = fallback + log.Printf(" → Using fallback: %.4f°N, %.4f°E", fallback.Lat, fallback.Lon) + } + continue + } + + coordinates[city] = struct{ Lat, Lon float64 }{Lat: lat, Lon: lon} + log.Printf(" ✓ Fetched: %.6f°N, %.6f°E", lat, lon) + } + + return coordinates, nil +} + +// executeToolCall handles execution of various tool calls +func (uc *PersonAgentProcessorUseCase) executeToolCall( + ctx context.Context, + toolCall domain.ToolCall, +) (string, error) { + var args map[string]interface{} + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + return "", fmt.Errorf("parsing arguments: %w", err) + } + + switch toolCall.Function.Name { + case "get_location": + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + + log.Printf(" │ → Tool call: get_location(%s, %s)", name, surname) + + req := domain.LocationRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + } + + response, err := uc.apiClient.GetLocation(ctx, req) + if err != nil { + return fmt.Sprintf(`{"error": "%v"}`, err), nil + } + + // Save to file + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "locations", fileName) + os.WriteFile(filePath, response, 0644) + + return string(response), nil + + case "get_access_level": + name, _ := args["name"].(string) + surname, _ := args["surname"].(string) + birthYear, _ := args["birth_year"].(float64) + + log.Printf(" │ → Tool call: get_access_level(%s, %s, %d)", name, surname, int(birthYear)) + + req := domain.AccessLevelRequest{ + APIKey: uc.apiKey, + Name: name, + Surname: surname, + BirthYear: int(birthYear), + } + + response, err := uc.apiClient.GetAccessLevel(ctx, req) + if err != nil { + return fmt.Sprintf(`{"error": "%v"}`, err), nil + } + + // Save to file + fileName := fmt.Sprintf("%s_%s.json", name, surname) + filePath := filepath.Join(uc.outputDir, "accesslevel", fileName) + os.WriteFile(filePath, response, 0644) + + return string(response), nil + + case "find_nearest_point": + referenceLat, _ := args["reference_lat"].(float64) + referenceLon, _ := args["reference_lon"].(float64) + pointsRaw, _ := args["points"].([]interface{}) + + log.Printf(" │ → Tool call: find_nearest_point(ref: %.4f,%.4f, %d points)", referenceLat, referenceLon, len(pointsRaw)) + + // Parse points array + var points [][]float64 + for _, p := range pointsRaw { + if pointArr, ok := p.([]interface{}); ok && len(pointArr) >= 2 { + lat, _ := pointArr[0].(float64) + lon, _ := pointArr[1].(float64) + points = append(points, []float64{lat, lon}) + } + } + + result := domain.FindNearestPoint(referenceLat, referenceLon, points) + if result == nil { + return `{"error": "no valid points provided"}`, nil + } + + log.Printf(" │ → Result: nearest point at index %d, distance %.2f km", result.Index, result.DistanceKm) + + resultJSON, _ := json.Marshal(result) + return string(resultJSON), nil + + default: + return fmt.Sprintf(`{"error": "unknown function: %s"}`, toolCall.Function.Name), nil + } +} diff --git a/internal/usecase/process_persons.go b/internal/usecase/process_persons.go new file mode 100644 index 0000000..2813d6e --- /dev/null +++ b/internal/usecase/process_persons.go @@ -0,0 +1,118 @@ +package usecase + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + + "github.com/paramah/ai_devs4/s01e02/internal/domain" +) + +// ProcessPersonsUseCase handles the processing of persons +type ProcessPersonsUseCase struct { + personRepo domain.PersonRepository + apiClient domain.APIClient + apiKey string + outputDir string +} + +// NewProcessPersonsUseCase creates a new use case instance +func NewProcessPersonsUseCase( + personRepo domain.PersonRepository, + apiClient domain.APIClient, + apiKey string, + outputDir string, +) *ProcessPersonsUseCase { + return &ProcessPersonsUseCase{ + personRepo: personRepo, + apiClient: apiClient, + apiKey: apiKey, + outputDir: outputDir, + } +} + +// Execute processes all persons from the input file +func (uc *ProcessPersonsUseCase) Execute(ctx context.Context, inputFile string) error { + // Load persons from file + log.Printf("Loading persons from: %s", inputFile) + persons, err := uc.personRepo.LoadPersons(ctx, inputFile) + if err != nil { + return fmt.Errorf("loading persons: %w", err) + } + log.Printf("Loaded %d persons", len(persons)) + + // Process each person + for i, person := range persons { + log.Printf("\n[%d/%d] Processing: %s %s", i+1, len(persons), person.Name, person.Surname) + + // Get location information + if err := uc.processLocation(ctx, person); err != nil { + log.Printf("Error getting location for %s %s: %v", person.Name, person.Surname, err) + continue + } + + // Get access level information + if err := uc.processAccessLevel(ctx, person); err != nil { + log.Printf("Error getting access level for %s %s: %v", person.Name, person.Surname, err) + continue + } + } + + log.Printf("\nProcessing completed!") + return nil +} + +// processLocation gets and saves location information for a person +func (uc *ProcessPersonsUseCase) processLocation(ctx context.Context, person domain.Person) error { + req := domain.LocationRequest{ + APIKey: uc.apiKey, + Name: person.Name, + Surname: person.Surname, + } + + log.Printf(" → Getting location...") + response, err := uc.apiClient.GetLocation(ctx, req) + if err != nil { + return fmt.Errorf("getting location: %w", err) + } + + // Save to file + fileName := fmt.Sprintf("%s_%s.json", person.Name, person.Surname) + filePath := filepath.Join(uc.outputDir, "locations", fileName) + + if err := os.WriteFile(filePath, response, 0644); err != nil { + return fmt.Errorf("saving location response: %w", err) + } + + log.Printf(" ✓ Location saved to: %s", filePath) + return nil +} + +// processAccessLevel gets and saves access level information for a person +func (uc *ProcessPersonsUseCase) processAccessLevel(ctx context.Context, person domain.Person) error { + req := domain.AccessLevelRequest{ + APIKey: uc.apiKey, + Name: person.Name, + Surname: person.Surname, + BirthYear: person.Born, + } + + log.Printf(" → Getting access level...") + response, err := uc.apiClient.GetAccessLevel(ctx, req) + if err != nil { + return fmt.Errorf("getting access level: %w", err) + } + + // Save to file + fileName := fmt.Sprintf("%s_%s.json", person.Name, person.Surname) + filePath := filepath.Join(uc.outputDir, "accesslevel", fileName) + + if err := os.WriteFile(filePath, response, 0644); err != nil { + return fmt.Errorf("saving access level response: %w", err) + } + + log.Printf(" ✓ Access level saved to: %s", filePath) + return nil +} diff --git a/lista.json b/lista.json new file mode 100644 index 0000000..7de4420 --- /dev/null +++ b/lista.json @@ -0,0 +1,53 @@ +[ + { + "name": "Cezary", + "surname": "Żurek", + "gender": "M", + "born": 1987, + "city": "Grudziądz", + "tags": [ + "transport" + ] + }, + { + "name": "Jacek", + "surname": "Nowak", + "gender": "M", + "born": 1991, + "city": "Grudziądz", + "tags": [ + "transport" + ] + }, + { + "name": "Oskar", + "surname": "Sieradzki", + "gender": "M", + "born": 1993, + "city": "Grudziądz", + "tags": [ + "transport" + ] + }, + { + "name": "Wojciech", + "surname": "Bielik", + "gender": "M", + "born": 1986, + "city": "Grudziądz", + "tags": [ + "transport" + ] + }, + { + "name": "Wacław", + "surname": "Jasiński", + "gender": "M", + "born": 1986, + "city": "Grudziądz", + "tags": [ + "transport", + "praca z pojazdami" + ] + } + ]