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 <<