feat: add generateLlmPredictionWithContext with web search geopolitical context
- New method uses web_search_20260209 server-side tool so Claude fetches 48h of oil/geopolitical news autonomously before predicting direction - Prompt uses raw prices only — no pre-computed EWMA indicators - pause_turn loop handles server-side search continuation (up to 5 iters) - generatePrediction() now tries context method first, falls back to generateLlmPrediction(), then EWMA - Default model updated to claude-sonnet-4-6 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -104,7 +104,8 @@ class OilPriceService
|
|||||||
$prediction = null;
|
$prediction = null;
|
||||||
|
|
||||||
if (config('services.anthropic.api_key')) {
|
if (config('services.anthropic.api_key')) {
|
||||||
$prediction = $this->generateLlmPrediction($prices);
|
$prediction = $this->generateLlmPredictionWithContext($prices);
|
||||||
|
$prediction ??= $this->generateLlmPrediction($prices);
|
||||||
}
|
}
|
||||||
|
|
||||||
$prediction ??= $this->generateEwmaPrediction($prices);
|
$prediction ??= $this->generateEwmaPrediction($prices);
|
||||||
@@ -204,6 +205,104 @@ class OilPriceService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM prediction with 48h geopolitical context via Anthropic web search.
|
||||||
|
* Claude searches for recent oil/geopolitical news before answering.
|
||||||
|
* Reasons from raw prices only — no pre-computed indicators in prompt.
|
||||||
|
*/
|
||||||
|
public function generateLlmPredictionWithContext(Collection $prices): ?PricePrediction
|
||||||
|
{
|
||||||
|
$priceList = $prices->sortBy('date')
|
||||||
|
->map(fn (BrentPrice $p) => "{$p->date->toDateString()}: \${$p->price_usd}")
|
||||||
|
->implode("\n");
|
||||||
|
|
||||||
|
$prompt = <<<PROMPT
|
||||||
|
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||||
|
Your goal is to 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 (Middle East, Russia, US sanctions)
|
||||||
|
- Global demand signals (China economic data, US inventory reports)
|
||||||
|
|
||||||
|
Then, combining the news context with the price history below, predict the direction.
|
||||||
|
|
||||||
|
Recent Brent crude prices (USD/barrel):
|
||||||
|
{$priceList}
|
||||||
|
|
||||||
|
Respond with JSON only, no other text:
|
||||||
|
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "one sentence combining price trend and key news factor"}
|
||||||
|
PROMPT;
|
||||||
|
|
||||||
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
|
$messages = [['role' => 'user', 'content' => $prompt]];
|
||||||
|
$response = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||||||
|
->withHeaders([
|
||||||
|
'x-api-key' => config('services.anthropic.api_key'),
|
||||||
|
'anthropic-version' => '2023-06-01',
|
||||||
|
])
|
||||||
|
->post($url, [
|
||||||
|
'model' => config('services.anthropic.model', 'claude-sonnet-4-6'),
|
||||||
|
'max_tokens' => 1024,
|
||||||
|
'tools' => [['type' => 'web_search_20260209', 'name' => 'web_search']],
|
||||||
|
'messages' => $messages,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error('OilPriceService: Anthropic context request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = collect($response->json('content') ?? [])
|
||||||
|
->firstWhere('type', 'text')['text'] ?? '';
|
||||||
|
|
||||||
|
$text = preg_replace('/^```(?:json)?\s*/m', '', trim($text));
|
||||||
|
$text = preg_replace('/```\s*$/m', '', $text);
|
||||||
|
$data = json_decode(trim($text), true);
|
||||||
|
|
||||||
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
|
Log::error('OilPriceService: unexpected context LLM response format', ['text' => $text]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = TrendDirection::tryFrom($data['direction']);
|
||||||
|
$confidence = min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE);
|
||||||
|
|
||||||
|
if ($direction === null) {
|
||||||
|
Log::error('OilPriceService: invalid direction in context LLM response', ['direction' => $data['direction']]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PricePrediction([
|
||||||
|
'predicted_for' => now()->toDateString(),
|
||||||
|
'source' => PredictionSource::Llm,
|
||||||
|
'direction' => $direction,
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'reasoning' => $data['reasoning'],
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('OilPriceService: generateLlmPredictionWithContext failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
|
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
|
||||||
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
|
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ return [
|
|||||||
|
|
||||||
'anthropic' => [
|
'anthropic' => [
|
||||||
'api_key' => env('ANTHROPIC_API_KEY'),
|
'api_key' => env('ANTHROPIC_API_KEY'),
|
||||||
'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001'),
|
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -227,3 +227,131 @@ it('returns null when there is insufficient price data', function (): void {
|
|||||||
expect($this->service->generatePrediction())->toBeNull()
|
expect($this->service->generatePrediction())->toBeNull()
|
||||||
->and(PricePrediction::count())->toBe(0);
|
->and(PricePrediction::count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- generateLlmPredictionWithContext ---
|
||||||
|
|
||||||
|
it('generates llm prediction with context using web search and raw prices', function () {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||||
|
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||||
|
'price_usd' => 80.0 + $s->index * 0.5,
|
||||||
|
])->create();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
|
'content' => [
|
||||||
|
['type' => 'text', 'text' => '{"direction":"rising","confidence":72,"reasoning":"OPEC+ extended cuts while prices trend upward."}'],
|
||||||
|
],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||||
|
$prediction = app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
expect($prediction)->not->toBeNull()
|
||||||
|
->and($prediction->direction)->toBe(TrendDirection::Rising)
|
||||||
|
->and($prediction->confidence)->toBe(72)
|
||||||
|
->and($prediction->source)->toBe(PredictionSource::Llm)
|
||||||
|
->and($prediction->reasoning)->toBe('OPEC+ extended cuts while prices trend upward.');
|
||||||
|
|
||||||
|
Http::assertSentCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends web_search tool in the context prediction request', function () {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||||
|
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
])->create();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||||
|
app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
$tools = $request->data()['tools'] ?? [];
|
||||||
|
|
||||||
|
return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not include ewma indicators in the context prediction request', function () {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||||
|
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
])->create();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||||
|
app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
$content = $request->data()['messages'][0]['content'] ?? '';
|
||||||
|
|
||||||
|
return ! str_contains($content, 'EWMA') && ! str_contains($content, 'Pre-computed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('context prediction continues on pause_turn and returns final answer', function () {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||||
|
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
])->create();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::sequence()
|
||||||
|
->push([
|
||||||
|
'content' => [['type' => 'server_tool_use', 'id' => 'sttool_1', 'name' => 'web_search', 'input' => ['query' => 'Brent crude news']]],
|
||||||
|
'stop_reason' => 'pause_turn',
|
||||||
|
], 200)
|
||||||
|
->push([
|
||||||
|
'content' => [['type' => 'text', 'text' => '{"direction":"falling","confidence":60,"reasoning":"Demand fears weigh on prices."}']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
||||||
|
$prediction = app(OilPriceService::class)->generateLlmPredictionWithContext($prices);
|
||||||
|
|
||||||
|
expect($prediction)->not->toBeNull()
|
||||||
|
->and($prediction->direction)->toBe(TrendDirection::Falling);
|
||||||
|
|
||||||
|
Http::assertSentCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generatePrediction falls through to ewma when both llm methods fail', function () {
|
||||||
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
|
BrentPrice::factory()->count(20)->sequence(fn ($s) => [
|
||||||
|
'date' => now()->subDays(20 - $s->index)->toDateString(),
|
||||||
|
'price_usd' => 80.0,
|
||||||
|
])->create();
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.anthropic.com/*' => Http::response([], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$prediction = app(OilPriceService::class)->generatePrediction();
|
||||||
|
|
||||||
|
expect($prediction)->not->toBeNull()
|
||||||
|
->and($prediction->source)->toBe(PredictionSource::Ewma);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user