diff --git a/app/Enums/PredictionSource.php b/app/Enums/PredictionSource.php index 1a6c070..74dc547 100644 --- a/app/Enums/PredictionSource.php +++ b/app/Enums/PredictionSource.php @@ -5,5 +5,6 @@ namespace App\Enums; enum PredictionSource: string { case Llm = 'llm'; + case LlmWithContext = 'llm_with_context'; case Ewma = 'ewma'; } diff --git a/app/Filament/Resources/OilPredictionResource.php b/app/Filament/Resources/OilPredictionResource.php index 851312d..e302476 100644 --- a/app/Filament/Resources/OilPredictionResource.php +++ b/app/Filament/Resources/OilPredictionResource.php @@ -39,9 +39,14 @@ class OilPredictionResource extends Resource ->sortable(), TextColumn::make('source') ->badge() - ->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value)) + ->formatStateUsing(fn (PredictionSource $state) => match ($state) { + PredictionSource::Llm => 'LLM', + PredictionSource::LlmWithContext => 'LLM + Context', + PredictionSource::Ewma => 'EWMA', + }) ->color(fn (PredictionSource $state) => match ($state) { PredictionSource::Llm => 'success', + PredictionSource::LlmWithContext => 'warning', PredictionSource::Ewma => 'info', }), TextColumn::make('direction') @@ -66,6 +71,7 @@ class OilPredictionResource extends Resource SelectFilter::make('source') ->options([ PredictionSource::Llm->value => 'LLM', + PredictionSource::LlmWithContext->value => 'LLM + Context', PredictionSource::Ewma->value => 'EWMA', ]), SelectFilter::make('direction') @@ -97,9 +103,14 @@ class OilPredictionResource extends Resource TextEntry::make('predicted_for')->date('d M Y'), TextEntry::make('source') ->badge() - ->formatStateUsing(fn (PredictionSource $state) => strtoupper($state->value)) + ->formatStateUsing(fn (PredictionSource $state) => match ($state) { + PredictionSource::Llm => 'LLM', + PredictionSource::LlmWithContext => 'LLM + Context', + PredictionSource::Ewma => 'EWMA', + }) ->color(fn (PredictionSource $state) => match ($state) { PredictionSource::Llm => 'success', + PredictionSource::LlmWithContext => 'warning', PredictionSource::Ewma => 'info', }), TextEntry::make('direction') diff --git a/app/Services/OilPriceService.php b/app/Services/OilPriceService.php index 83dff3b..40b0549 100644 --- a/app/Services/OilPriceService.php +++ b/app/Services/OilPriceService.php @@ -290,7 +290,7 @@ class OilPriceService return new PricePrediction([ 'predicted_for' => now()->toDateString(), - 'source' => PredictionSource::Llm, + 'source' => PredictionSource::LlmWithContext, 'direction' => $direction, 'confidence' => $confidence, 'reasoning' => $data['reasoning'], diff --git a/composer.json b/composer.json index ba60c1f..bb336fb 100644 --- a/composer.json +++ b/composer.json @@ -6,10 +6,11 @@ "keywords": ["laravel", "framework"], "license": "MIT", "require": { - "php": "^8.3", + "php": "^8.4", "filament/filament": "^5.0", "laravel/fortify": "^1.34", "laravel/framework": "^13.0", + "laravel/sanctum": "^4.0", "laravel/tinker": "^3.0", "livewire/flux": "^2.12.0", "livewire/livewire": "^4.1" diff --git a/composer.lock b/composer.lock index 2226833..190d8ed 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7279d4f0e10b9575237a7b483de6d09e", + "content-hash": "017a8badf2a8b99d8c2de9909475415f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2468,6 +2468,69 @@ }, "time": "2026-03-23T14:35:33+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-02-07T17:19:31+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", diff --git a/database/migrations/2026_04_04_115852_create_price_predictions_table.php b/database/migrations/2026_04_04_115852_create_price_predictions_table.php index 0f08938..bc5298e 100644 --- a/database/migrations/2026_04_04_115852_create_price_predictions_table.php +++ b/database/migrations/2026_04_04_115852_create_price_predictions_table.php @@ -14,7 +14,7 @@ return new class extends Migration Schema::create('price_predictions', function (Blueprint $table) { $table->id(); $table->date('predicted_for')->comment('The date this prediction covers'); - $table->enum('source', ['llm', 'ewma']); + $table->enum('source', ['llm', 'llm_with_context', 'ewma']); $table->enum('direction', ['rising', 'falling', 'flat']); $table->tinyInteger('confidence')->unsigned()->comment('0–100 confidence score'); $table->text('reasoning')->nullable()->comment('LLM explanation or EWMA summary'); diff --git a/database/migrations/2026_04_04_184147_add_llm_with_context_to_price_predictions_source.php b/database/migrations/2026_04_04_184147_add_llm_with_context_to_price_predictions_source.php new file mode 100644 index 0000000..bf94781 --- /dev/null +++ b/database/migrations/2026_04_04_184147_add_llm_with_context_to_price_predictions_source.php @@ -0,0 +1,21 @@ +extend(TestCase::class) - // ->use(RefreshDatabase::class) + ->use(RefreshDatabase::class) ->in('Feature', 'Unit'); /* diff --git a/tests/Unit/Services/OilPriceServiceTest.php b/tests/Unit/Services/OilPriceServiceTest.php index aa11df9..7b71bac 100644 --- a/tests/Unit/Services/OilPriceServiceTest.php +++ b/tests/Unit/Services/OilPriceServiceTest.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); beforeEach(function (): void { + Http::preventStrayRequests(); $this->service = new OilPriceService(new ApiLogger); }); @@ -39,7 +40,7 @@ it('filters out FRED missing value markers', function (): void { '*/fred/series/observations*' => Http::response([ 'observations' => [ ['date' => '2026-04-01', 'value' => '75.10'], - ['date' => '2026-04-02', 'value' => '.'], // weekend/holiday + ['date' => '2026-04-02', 'value' => '.'], ['date' => '2026-04-03', 'value' => '74.20'], ], ]), @@ -105,7 +106,7 @@ it('returns flat when price movement is within threshold', function (): void { ->and($prediction->confidence)->toBe(50); }); -it('returns null when fewer than 14 prices are available', function (): void { +it('returns null when fewer than 14 prices are available for EWMA', function (): void { $prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([ 'date' => now()->subDays(10 - $i)->toDateString(), 'price_usd' => 75.0, @@ -172,9 +173,113 @@ it('returns null when LLM returns malformed JSON', function (): void { expect($this->service->generateLlmPrediction($prices))->toBeNull(); }); +// --- generateLlmPredictionWithContext --- + +it('generates LLM prediction with context and returns LlmWithContext source', function (): void { + config(['services.anthropic.api_key' => 'test-key']); + + $prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(20 - $i)->toDateString(), + 'price_usd' => 80.0 + $i * 0.5, + ])); + + 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', + ]), + ]); + + $prediction = $this->service->generateLlmPredictionWithContext($prices); + + expect($prediction)->not->toBeNull() + ->and($prediction->direction)->toBe(TrendDirection::Rising) + ->and($prediction->confidence)->toBe(72) + ->and($prediction->source)->toBe(PredictionSource::LlmWithContext) + ->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 (): void { + config(['services.anthropic.api_key' => 'test-key']); + + $prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(20 - $i)->toDateString(), + 'price_usd' => 80.0, + ])); + + Http::fake([ + 'https://api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']], + 'stop_reason' => 'end_turn', + ]), + ]); + + $this->service->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 prompt', function (): void { + config(['services.anthropic.api_key' => 'test-key']); + + $prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(20 - $i)->toDateString(), + 'price_usd' => 80.0, + ])); + + Http::fake([ + 'https://api.anthropic.com/*' => Http::response([ + 'content' => [['type' => 'text', 'text' => '{"direction":"flat","confidence":50,"reasoning":"No clear trend."}']], + 'stop_reason' => 'end_turn', + ]), + ]); + + $this->service->generateLlmPredictionWithContext($prices); + + Http::assertSent(function ($request) { + $content = $request->data()['messages'][0]['content'] ?? ''; + + return ! str_contains($content, 'EWMA') && ! str_contains($content, 'Pre-computed'); + }); +}); + +it('continues on pause_turn and returns final answer', function (): void { + config(['services.anthropic.api_key' => 'test-key']); + + $prices = collect(range(1, 20))->map(fn (int $i) => new BrentPrice([ + 'date' => now()->subDays(20 - $i)->toDateString(), + 'price_usd' => 80.0, + ])); + + 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', + ]) + ->push([ + 'content' => [['type' => 'text', 'text' => '{"direction":"falling","confidence":60,"reasoning":"Demand fears weigh on prices."}']], + 'stop_reason' => 'end_turn', + ]), + ]); + + $prediction = $this->service->generateLlmPredictionWithContext($prices); + + expect($prediction)->not->toBeNull() + ->and($prediction->direction)->toBe(TrendDirection::Falling); + + Http::assertSentCount(2); +}); + // --- generatePrediction (orchestrator) --- -it('uses LLM when API key is configured', function (): void { +it('uses LLM with context when API key is configured', function (): void { config(['services.anthropic.api_key' => 'test-key']); BrentPrice::insert( @@ -186,19 +291,42 @@ it('uses LLM when API key is configured', function (): void { Http::fake([ 'https://api.anthropic.com/*' => Http::response([ - 'content' => [ - ['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}'], - ], + 'content' => [['type' => 'text', 'text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}']], + 'stop_reason' => 'end_turn', ]), ]); $prediction = $this->service->generatePrediction(); + expect($prediction->source)->toBe(PredictionSource::LlmWithContext) + ->and(PricePrediction::count())->toBe(1); +}); + +it('falls back to plain LLM when context method fails', function (): void { + config(['services.anthropic.api_key' => 'test-key']); + + BrentPrice::insert( + collect(range(1, 20))->map(fn (int $i) => [ + 'date' => now()->subDays(20 - $i)->toDateString(), + 'price_usd' => 75.0 + ($i * 0.8), + ])->all() + ); + + Http::fake([ + 'https://api.anthropic.com/*' => Http::sequence() + ->push([], 500) + ->push([ + 'content' => [['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend up."}']], + ]), + ]); + + $prediction = $this->service->generatePrediction(); + expect($prediction->source)->toBe(PredictionSource::Llm) ->and(PricePrediction::count())->toBe(1); }); -it('falls back to EWMA when LLM fails', function (): void { +it('falls back to EWMA when both LLM methods fail', function (): void { config(['services.anthropic.api_key' => 'test-key']); BrentPrice::insert( @@ -227,131 +355,3 @@ 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); -});