diff --git a/app/Services/LlmPrediction/AbstractLlmPredictionProvider.php b/app/Services/LlmPrediction/AbstractLlmPredictionProvider.php new file mode 100644 index 0000000..d61da88 --- /dev/null +++ b/app/Services/LlmPrediction/AbstractLlmPredictionProvider.php @@ -0,0 +1,99 @@ +apiKey(); + + if ($apiKey === null) { + return null; + } + + try { + $payload = $this->callProvider($apiKey, $this->buildPriceList($prices)); + + return $payload === null ? null : $this->buildPrediction($payload); + } catch (Throwable $e) { + Log::error(static::class.': predict failed', ['error' => $e->getMessage()]); + + return null; + } + } + + /** Returns the configured API key or null if not set. */ + abstract protected function apiKey(): ?string; + + /** + * Make the provider HTTP call and return the normalised payload, or null + * on failure (already logged by the implementer). + * + * @return array{direction: string, confidence: int, reasoning: string}|null + */ + abstract protected function callProvider(string $apiKey, string $priceList): ?array; + + /** @param Collection $prices */ + protected function buildPriceList(Collection $prices): string + { + return $prices->sortBy('date') + ->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd) + ->implode("\n"); + } + + /** @param array{direction: string, confidence: int, reasoning: string} $input */ + protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction + { + $direction = TrendDirection::tryFrom($input['direction'] ?? ''); + + if ($direction === null) { + Log::error(static::class.': invalid direction', ['input' => $input]); + + return null; + } + + return new PricePrediction([ + 'predicted_for' => now()->toDateString(), + 'source' => $source, + 'direction' => $direction, + 'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE), + 'reasoning' => $input['reasoning'] ?? '', + 'generated_at' => now(), + ]); + } + + protected function defaultPrompt(string $priceList): string + { + return <<apiKey() === null) { return null; } @@ -36,10 +29,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider return $prediction ?? $this->predictBasic($prices); } + protected function apiKey(): ?string + { + return config('services.anthropic.api_key'); + } + + /** {@inheritDoc} */ + protected function callProvider(string $apiKey, string $priceList): ?array + { + return null; + } + /** * Multi-turn web search phase, then a forced submit_prediction call. - * Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop). - * Phase 2: Force submit_prediction with the full conversation context. + * Phase 1: let the model search for recent oil/geopolitical news. + * Phase 2: force submit_prediction with the full conversation context. */ private function predictWithWebContext(Collection $prices): ?PricePrediction { @@ -47,7 +51,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider $url = 'https://api.anthropic.com/v1/messages'; try { - // Phase 1: web search loop for ($i = 0, $response = null; $i < 5; $i++) { $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30) ->withHeaders($this->headers()) @@ -59,7 +62,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider ])); if (! $response->successful()) { - Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]); + Log::error(self::class.': context search request failed', ['status' => $response->status()]); return null; } @@ -71,7 +74,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; } - // Phase 2: forced submit with full context $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; $messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.']; @@ -86,22 +88,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider ])); if (! $submitResponse->successful()) { - Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]); + Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]); return null; } $input = $this->extractToolInput($submitResponse->json('content') ?? []); - if ($input === null) { - Log::error('AnthropicPredictionProvider: no tool_use block in context submit response'); + return $input === null + ? null + : $this->buildPrediction($input, PredictionSource::LlmWithContext); + } catch (Throwable $e) { + Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Single-turn prediction using a forced submit_prediction tool call. + * Guarantees structured output — no JSON parsing needed. + */ + private function predictBasic(Collection $prices): ?PricePrediction + { + $chronological = $prices->sortBy('date'); + $ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all()); + $ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all()); + $ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all()); + + $url = 'https://api.anthropic.com/v1/messages'; + + try { + $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15) + ->withHeaders($this->headers()) + ->post($url, [ + 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), + 'max_tokens' => 256, + 'tools' => [$this->submitPredictionTool()], + 'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'], + 'messages' => [[ + 'role' => 'user', + 'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14), + ]], + ])); + + if (! $response->successful()) { + Log::error(self::class.': basic request failed', ['status' => $response->status()]); return null; } - return $this->buildPrediction($input, PredictionSource::LlmWithContext); + $input = $this->extractToolInput($response->json('content') ?? []); + + return $input === null ? null : $this->buildPrediction($input); } catch (Throwable $e) { - Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]); + Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]); return null; } @@ -126,18 +167,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider PROMPT; } - private function buildPriceList(Collection $prices): string + private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string { - return $prices->sortBy('date') - ->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd) - ->implode("\n"); + return << */ private function headers(): array { return [ - 'x-api-key' => config('services.anthropic.api_key'), + 'x-api-key' => $this->apiKey(), 'anthropic-version' => '2023-06-01', ]; } @@ -178,81 +230,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider return $block['input'] ?? null; } - /** @param array{direction: string, confidence: int, reasoning: string} $input */ - private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction - { - $direction = TrendDirection::tryFrom($input['direction'] ?? ''); - - if ($direction === null) { - Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]); - - return null; - } - - return new PricePrediction([ - 'predicted_for' => now()->toDateString(), - 'source' => $source, - 'direction' => $direction, - 'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE), - 'reasoning' => $input['reasoning'], - 'generated_at' => now(), - ]); - } - - /** - * Single-turn prediction using a forced submit_prediction tool call. - * Guarantees structured output — no JSON parsing needed. - */ - private function predictBasic(Collection $prices): ?PricePrediction - { - $chronological = $prices->sortBy('date'); - $ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all()); - $ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all()); - $ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all()); - - $priceList = $this->buildPriceList($prices); - - $url = 'https://api.anthropic.com/v1/messages'; - - try { - $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15) - ->withHeaders($this->headers()) - ->post($url, [ - 'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'), - 'max_tokens' => 256, - 'tools' => [$this->submitPredictionTool()], - 'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'], - 'messages' => [[ - 'role' => 'user', - 'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14), - ]], - ])); - - if (! $response->successful()) { - Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]); - - return null; - } - - $input = $this->extractToolInput($response->json('content') ?? []); - - if ($input === null) { - Log::error('AnthropicPredictionProvider: no tool_use block in basic response'); - - return null; - } - - return $this->buildPrediction($input, PredictionSource::Llm); - } catch (Throwable $e) { - Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]); - - return null; - } - } - - /** - * @param float[] $prices Chronological order (oldest first) - */ + /** @param float[] $prices Chronological order (oldest first) */ private function computeEwma(array $prices): float { $ema = $prices[0]; @@ -263,22 +241,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider return round($ema, 4); } - - private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string - { - return <<sortBy('date') - ->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd) - ->implode("\n"); + return config('services.gemini.api_key'); + } + protected function callProvider(string $apiKey, string $priceList): ?array + { $model = config('services.gemini.model', 'gemini-2.0-flash'); $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent"; - try { - $response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15) - ->withQueryParameters(['key' => config('services.gemini.api_key')]) - ->post($url, [ - 'contents' => [[ - 'parts' => [['text' => $this->prompt($priceList)]], - ]], - 'generationConfig' => [ - 'responseMimeType' => 'application/json', - 'responseSchema' => [ - 'type' => 'OBJECT', - 'properties' => [ - 'direction' => [ - 'type' => 'STRING', - 'enum' => ['rising', 'falling', 'flat'], - ], - 'confidence' => ['type' => 'INTEGER'], - 'reasoning' => ['type' => 'STRING'], + $response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15) + ->withQueryParameters(['key' => $apiKey]) + ->post($url, [ + 'contents' => [[ + 'parts' => [['text' => $this->defaultPrompt($priceList)]], + ]], + 'generationConfig' => [ + 'responseMimeType' => 'application/json', + 'responseSchema' => [ + 'type' => 'OBJECT', + 'properties' => [ + 'direction' => [ + 'type' => 'STRING', + 'enum' => ['rising', 'falling', 'flat'], ], - 'required' => ['direction', 'confidence', 'reasoning'], + 'confidence' => ['type' => 'INTEGER'], + 'reasoning' => ['type' => 'STRING'], ], + 'required' => ['direction', 'confidence', 'reasoning'], ], - ])); + ], + ])); - if (! $response->successful()) { - Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]); - - return null; - } - - $text = $response->json('candidates.0.content.parts.0.text') ?? ''; - $data = json_decode($text, true); - - if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) { - Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]); - - return null; - } - - $direction = TrendDirection::tryFrom($data['direction']); - - if ($direction === null) { - Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]); - - return null; - } - - return new PricePrediction([ - 'predicted_for' => now()->toDateString(), - 'source' => PredictionSource::Llm, - 'direction' => $direction, - 'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE), - 'reasoning' => $data['reasoning'], - 'generated_at' => now(), - ]); - } catch (Throwable $e) { - Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]); + if (! $response->successful()) { + Log::error(self::class.': request failed', ['status' => $response->status()]); return null; } - } - private function prompt(string $priceList): string - { - return <<json('candidates.0.content.parts.0.text') ?? ''; + $data = json_decode($text, true); - Recent Brent crude prices (USD/barrel): - {$priceList} + if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) { + Log::error(self::class.': unexpected response format', ['text' => $text]); - Respond with direction (rising, falling, or flat), a confidence score (0–85), - and a one-sentence reasoning. - PROMPT; + return null; + } + + return $data; } } diff --git a/app/Services/LlmPrediction/OpenAiPredictionProvider.php b/app/Services/LlmPrediction/OpenAiPredictionProvider.php index 4ee80eb..ec69d1f 100644 --- a/app/Services/LlmPrediction/OpenAiPredictionProvider.php +++ b/app/Services/LlmPrediction/OpenAiPredictionProvider.php @@ -2,112 +2,61 @@ namespace App\Services\LlmPrediction; -use App\Enums\PredictionSource; -use App\Enums\TrendDirection; -use App\Models\BrentPrice; -use App\Models\PricePrediction; -use App\Services\ApiLogger; -use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; -use Throwable; -class OpenAiPredictionProvider implements OilPredictionProvider +class OpenAiPredictionProvider extends AbstractLlmPredictionProvider { - private const int LLM_MAX_CONFIDENCE = 85; - - public function __construct( - private readonly ApiLogger $apiLogger, - ) {} - - public function predict(Collection $prices): ?PricePrediction + protected function apiKey(): ?string { - if (! config('services.openai.api_key')) { - return null; - } - - $priceList = $prices->sortBy('date') - ->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd) - ->implode("\n"); + return config('services.openai.api_key'); + } + protected function callProvider(string $apiKey, string $priceList): ?array + { $url = 'https://api.openai.com/v1/chat/completions'; - try { - $response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15) - ->withToken(config('services.openai.api_key')) - ->post($url, [ - 'model' => config('services.openai.model', 'gpt-4o-mini'), - 'response_format' => [ - 'type' => 'json_schema', - 'json_schema' => [ - 'name' => 'oil_prediction', - 'strict' => true, - 'schema' => [ - 'type' => 'object', - 'properties' => [ - 'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']], - 'confidence' => ['type' => 'integer'], - 'reasoning' => ['type' => 'string'], - ], - 'required' => ['direction', 'confidence', 'reasoning'], - 'additionalProperties' => false, + $response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15) + ->withToken($apiKey) + ->post($url, [ + 'model' => config('services.openai.model', 'gpt-4o-mini'), + 'response_format' => [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'oil_prediction', + 'strict' => true, + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']], + 'confidence' => ['type' => 'integer'], + 'reasoning' => ['type' => 'string'], ], + 'required' => ['direction', 'confidence', 'reasoning'], + 'additionalProperties' => false, ], ], - 'messages' => [[ - 'role' => 'user', - 'content' => $this->prompt($priceList), - ]], - ])); + ], + 'messages' => [[ + 'role' => 'user', + 'content' => $this->defaultPrompt($priceList), + ]], + ])); - if (! $response->successful()) { - Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]); - - return null; - } - - $data = json_decode($response->json('choices.0.message.content') ?? '{}', true); - - if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) { - Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]); - - return null; - } - - $direction = TrendDirection::tryFrom($data['direction']); - - if ($direction === null) { - Log::error('OpenAiPredictionProvider: invalid direction', ['direction' => $data['direction']]); - - return null; - } - - return new PricePrediction([ - 'predicted_for' => now()->toDateString(), - 'source' => PredictionSource::Llm, - 'direction' => $direction, - 'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE), - 'reasoning' => $data['reasoning'], - 'generated_at' => now(), - ]); - } catch (Throwable $e) { - Log::error('OpenAiPredictionProvider: predict failed', ['error' => $e->getMessage()]); + if (! $response->successful()) { + Log::error(self::class.': request failed', ['status' => $response->status()]); return null; } - } - private function prompt(string $priceList): string - { - return <<json('choices.0.message.content') ?? '{}', true); - Recent Brent crude prices (USD/barrel): - {$priceList} + if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) { + Log::error(self::class.': unexpected response format', ['data' => $data]); - Respond with direction (rising, falling, or flat), a confidence score (0–85), - and a one-sentence reasoning. - PROMPT; + return null; + } + + return $data; } }