sortBy('date') ->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd) ->implode("\n"); $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, ], ], ], 'messages' => [[ 'role' => 'user', 'content' => $this->prompt($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()]); return null; } } private function prompt(string $priceList): string { return <<