refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate, chronological price-list building, response validation (direction/confidence/reasoning), TrendDirection::tryFrom, confidence cap at 85, and the top-level try/catch + Log::error. Now in AbstractLlmPredictionProvider: - LLM_MAX_CONFIDENCE constant - buildPriceList(Collection) helper - buildPrediction(input, ?source) — handles direction validation, confidence cap, model construction - defaultPrompt(priceList) — shared by Gemini and OpenAi - Default predict() flow (apiKey + callProvider + buildPrediction + try/catch). Gemini and OpenAi only implement apiKey() and callProvider(). Anthropic overrides predict() because of its multi-phase web-search + forced-tool flow but reuses the helpers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,110 +2,59 @@
|
||||
|
||||
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 GeminiPredictionProvider implements OilPredictionProvider
|
||||
class GeminiPredictionProvider 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.gemini.api_key')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$priceList = $prices->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 <<<PROMPT
|
||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||
Predict the short-term direction over the next 3–5 days.
|
||||
$text = $response->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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user