Files
fuel-price/app/Services/LlmPrediction/AnthropicPredictionProvider.php
Ovidiu U e39618f5df 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>
2026-04-29 19:35:57 +01:00

245 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Models\PricePrediction;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
{
private const float EWMA_ALPHA = 0.3;
/**
* Tries web-search-enriched prediction first, falls back to basic tool use.
* Overrides the parent flow because Anthropic uses two phases (web search
* loop + forced tool call) and selects the source dynamically.
*/
public function predict(Collection $prices): ?PricePrediction
{
if ($this->apiKey() === null) {
return null;
}
$prediction = $this->predictWithWebContext($prices);
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.
* Phase 2: force submit_prediction with the full conversation context.
*/
private function predictWithWebContext(Collection $prices): ?PricePrediction
{
$messages = [['role' => 'user', 'content' => $this->contextPrompt($this->buildPriceList($prices))]];
$url = 'https://api.anthropic.com/v1/messages';
try {
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 1024,
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
'messages' => $messages,
]));
if (! $response->successful()) {
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
return null;
}
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
$submitResponse = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => $messages,
]));
if (! $submitResponse->successful()) {
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
return null;
}
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
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;
}
$input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) {
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
}
private function contextPrompt(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 35 days.
First, search for recent news (last 48 hours) about:
- Brent crude oil price movements
- OPEC+ production decisions or announcements
- Major geopolitical events affecting oil supply
- Global demand signals (China economic data, US inventory reports)
Recent Brent crude prices (USD/barrel):
{$priceList}
After searching, you will be asked to submit your prediction.
PROMPT;
}
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): 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 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01',
];
}
/** @return array{name: string, description: string, input_schema: array<string, mixed>} */
private function submitPredictionTool(): array
{
return [
'name' => 'submit_prediction',
'description' => 'Submit the final oil price direction prediction.',
'input_schema' => [
'type' => 'object',
'properties' => [
'direction' => [
'type' => 'string',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => [
'type' => 'integer',
'minimum' => 0,
'maximum' => self::LLM_MAX_CONFIDENCE,
],
'reasoning' => [
'type' => 'string',
'description' => 'One sentence explaining the prediction.',
],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
];
}
/** @param array<int, mixed> $content */
private function extractToolInput(array $content): ?array
{
$block = collect($content)->firstWhere('type', 'tool_use');
return $block['input'] ?? null;
}
/** @param float[] $prices Chronological order (oldest first) */
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
}