diff --git a/app/Services/OilPriceService.php b/app/Services/OilPriceService.php index 2e0d2ee..83dff3b 100644 --- a/app/Services/OilPriceService.php +++ b/app/Services/OilPriceService.php @@ -104,7 +104,8 @@ class OilPriceService $prediction = null; if (config('services.anthropic.api_key')) { - $prediction = $this->generateLlmPrediction($prices); + $prediction = $this->generateLlmPredictionWithContext($prices); + $prediction ??= $this->generateLlmPrediction($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 = << '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. * Compares the 3-day EWMA against the 7-day EWMA to detect direction. diff --git a/config/services.php b/config/services.php index 0d67c32..583e650 100644 --- a/config/services.php +++ b/config/services.php @@ -47,7 +47,7 @@ return [ 'anthropic' => [ 'api_key' => env('ANTHROPIC_API_KEY'), - 'model' => env('ANTHROPIC_MODEL', 'claude-haiku-4-5-20251001'), + 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'), ], ]; diff --git a/tests/Unit/Services/OilPriceServiceTest.php b/tests/Unit/Services/OilPriceServiceTest.php index 12f796d..aa11df9 100644 --- a/tests/Unit/Services/OilPriceServiceTest.php +++ b/tests/Unit/Services/OilPriceServiceTest.php @@ -227,3 +227,131 @@ it('returns null when there is insufficient price data', function (): void { expect($this->service->generatePrediction())->toBeNull() ->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); +});