285 lines
10 KiB
PHP
285 lines
10 KiB
PHP
<?php
|
||
|
||
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 AnthropicPredictionProvider implements OilPredictionProvider
|
||
{
|
||
private const int LLM_MAX_CONFIDENCE = 85;
|
||
|
||
private const float EWMA_ALPHA = 0.3;
|
||
|
||
public function __construct(
|
||
private readonly ApiLogger $apiLogger,
|
||
) {}
|
||
|
||
/**
|
||
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
||
*/
|
||
public function predict(Collection $prices): ?PricePrediction
|
||
{
|
||
if (! config('services.anthropic.api_key')) {
|
||
return null;
|
||
}
|
||
|
||
$prediction = $this->predictWithWebContext($prices);
|
||
|
||
return $prediction ?? $this->predictBasic($prices);
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
||
{
|
||
$messages = [['role' => 'user', 'content' => $this->contextPrompt($this->buildPriceList($prices))]];
|
||
$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())
|
||
->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('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
|
||
|
||
return null;
|
||
}
|
||
|
||
if ($response->json('stop_reason') !== 'pause_turn') {
|
||
break;
|
||
}
|
||
|
||
$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.'];
|
||
|
||
$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('AnthropicPredictionProvider: 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 null;
|
||
}
|
||
|
||
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||
} catch (Throwable $e) {
|
||
Log::error('AnthropicPredictionProvider: predictWithWebContext 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 3–5 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 buildPriceList(Collection $prices): string
|
||
{
|
||
return $prices->sortBy('date')
|
||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||
->implode("\n");
|
||
}
|
||
|
||
/** @return array<string, string> */
|
||
private function headers(): array
|
||
{
|
||
return [
|
||
'x-api-key' => config('services.anthropic.api_key'),
|
||
'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 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)
|
||
*/
|
||
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);
|
||
}
|
||
|
||
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 3–5 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;
|
||
}
|
||
}
|