prediction with context
This commit is contained in:
@@ -5,5 +5,6 @@ namespace App\Enums;
|
|||||||
enum PredictionSource: string
|
enum PredictionSource: string
|
||||||
{
|
{
|
||||||
case Llm = 'llm';
|
case Llm = 'llm';
|
||||||
|
case LlmWithContext = 'llm_with_context';
|
||||||
case Ewma = 'ewma';
|
case Ewma = 'ewma';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,14 @@ class OilPredictionResource extends Resource
|
|||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('source')
|
TextColumn::make('source')
|
||||||
->badge()
|
->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) {
|
->color(fn (PredictionSource $state) => match ($state) {
|
||||||
PredictionSource::Llm => 'success',
|
PredictionSource::Llm => 'success',
|
||||||
|
PredictionSource::LlmWithContext => 'warning',
|
||||||
PredictionSource::Ewma => 'info',
|
PredictionSource::Ewma => 'info',
|
||||||
}),
|
}),
|
||||||
TextColumn::make('direction')
|
TextColumn::make('direction')
|
||||||
@@ -66,6 +71,7 @@ class OilPredictionResource extends Resource
|
|||||||
SelectFilter::make('source')
|
SelectFilter::make('source')
|
||||||
->options([
|
->options([
|
||||||
PredictionSource::Llm->value => 'LLM',
|
PredictionSource::Llm->value => 'LLM',
|
||||||
|
PredictionSource::LlmWithContext->value => 'LLM + Context',
|
||||||
PredictionSource::Ewma->value => 'EWMA',
|
PredictionSource::Ewma->value => 'EWMA',
|
||||||
]),
|
]),
|
||||||
SelectFilter::make('direction')
|
SelectFilter::make('direction')
|
||||||
@@ -97,9 +103,14 @@ class OilPredictionResource extends Resource
|
|||||||
TextEntry::make('predicted_for')->date('d M Y'),
|
TextEntry::make('predicted_for')->date('d M Y'),
|
||||||
TextEntry::make('source')
|
TextEntry::make('source')
|
||||||
->badge()
|
->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) {
|
->color(fn (PredictionSource $state) => match ($state) {
|
||||||
PredictionSource::Llm => 'success',
|
PredictionSource::Llm => 'success',
|
||||||
|
PredictionSource::LlmWithContext => 'warning',
|
||||||
PredictionSource::Ewma => 'info',
|
PredictionSource::Ewma => 'info',
|
||||||
}),
|
}),
|
||||||
TextEntry::make('direction')
|
TextEntry::make('direction')
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ class OilPriceService
|
|||||||
|
|
||||||
return new PricePrediction([
|
return new PricePrediction([
|
||||||
'predicted_for' => now()->toDateString(),
|
'predicted_for' => now()->toDateString(),
|
||||||
'source' => PredictionSource::Llm,
|
'source' => PredictionSource::LlmWithContext,
|
||||||
'direction' => $direction,
|
'direction' => $direction,
|
||||||
'confidence' => $confidence,
|
'confidence' => $confidence,
|
||||||
'reasoning' => $data['reasoning'],
|
'reasoning' => $data['reasoning'],
|
||||||
|
|||||||
@@ -6,10 +6,11 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.4",
|
||||||
"filament/filament": "^5.0",
|
"filament/filament": "^5.0",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/flux": "^2.12.0",
|
"livewire/flux": "^2.12.0",
|
||||||
"livewire/livewire": "^4.1"
|
"livewire/livewire": "^4.1"
|
||||||
|
|||||||
65
composer.lock
generated
65
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7279d4f0e10b9575237a7b483de6d09e",
|
"content-hash": "017a8badf2a8b99d8c2de9909475415f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -2468,6 +2468,69 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-23T14:35:33+00:00"
|
"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",
|
"name": "laravel/serializable-closure",
|
||||||
"version": "v2.0.10",
|
"version": "v2.0.10",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ return new class extends Migration
|
|||||||
Schema::create('price_predictions', function (Blueprint $table) {
|
Schema::create('price_predictions', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->date('predicted_for')->comment('The date this prediction covers');
|
$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->enum('direction', ['rising', 'falling', 'flat']);
|
||||||
$table->tinyInteger('confidence')->unsigned()->comment('0–100 confidence score');
|
$table->tinyInteger('confidence')->unsigned()->comment('0–100 confidence score');
|
||||||
$table->text('reasoning')->nullable()->comment('LLM explanation or EWMA summary');
|
$table->text('reasoning')->nullable()->comment('LLM explanation or EWMA summary');
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
DB::statement("ALTER TABLE price_predictions MODIFY COLUMN source ENUM('llm', 'llm_with_context', 'ewma') NOT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
DB::statement("ALTER TABLE price_predictions MODIFY COLUMN source ENUM('llm', 'ewma') NOT NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,7 +15,7 @@ use Tests\TestCase;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
pest()->extend(TestCase::class)
|
pest()->extend(TestCase::class)
|
||||||
// ->use(RefreshDatabase::class)
|
->use(RefreshDatabase::class)
|
||||||
->in('Feature', 'Unit');
|
->in('Feature', 'Unit');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Http;
|
|||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
|
Http::preventStrayRequests();
|
||||||
$this->service = new OilPriceService(new ApiLogger);
|
$this->service = new OilPriceService(new ApiLogger);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ it('filters out FRED missing value markers', function (): void {
|
|||||||
'*/fred/series/observations*' => Http::response([
|
'*/fred/series/observations*' => Http::response([
|
||||||
'observations' => [
|
'observations' => [
|
||||||
['date' => '2026-04-01', 'value' => '75.10'],
|
['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'],
|
['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);
|
->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([
|
$prices = collect(range(1, 10))->map(fn (int $i) => new BrentPrice([
|
||||||
'date' => now()->subDays(10 - $i)->toDateString(),
|
'date' => now()->subDays(10 - $i)->toDateString(),
|
||||||
'price_usd' => 75.0,
|
'price_usd' => 75.0,
|
||||||
@@ -172,9 +173,113 @@ it('returns null when LLM returns malformed JSON', function (): void {
|
|||||||
expect($this->service->generateLlmPrediction($prices))->toBeNull();
|
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) ---
|
// --- 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']);
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
BrentPrice::insert(
|
BrentPrice::insert(
|
||||||
@@ -186,19 +291,42 @@ it('uses LLM when API key is configured', function (): void {
|
|||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'https://api.anthropic.com/*' => Http::response([
|
'https://api.anthropic.com/*' => Http::response([
|
||||||
'content' => [
|
'content' => [['type' => 'text', 'text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}']],
|
||||||
['text' => '{"direction":"rising","confidence":70,"reasoning":"Trend is up."}'],
|
'stop_reason' => 'end_turn',
|
||||||
],
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$prediction = $this->service->generatePrediction();
|
$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)
|
expect($prediction->source)->toBe(PredictionSource::Llm)
|
||||||
->and(PricePrediction::count())->toBe(1);
|
->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']);
|
config(['services.anthropic.api_key' => 'test-key']);
|
||||||
|
|
||||||
BrentPrice::insert(
|
BrentPrice::insert(
|
||||||
@@ -227,131 +355,3 @@ 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