chore: retire legacy oil prediction pipeline

Removes everything that was made redundant by the new forecasting
stack. Per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md,
this was the cleanup planned at the end of Phase 4.

Deleted services and code:
- App\Services\Prediction\Signals\* (the old six-signal aggregator —
  trend, supermarket, day-of-week, brand-behaviour, stickiness,
  regional-momentum, oil — replaced by RidgeRegressionModel).
- App\Services\NationalFuelPredictionService (the post-Phase-4 thin
  shim; StationSearchService now depends on WeeklyForecastService
  directly, set up in the previous commit).
- App\Services\LlmPrediction\* (AbstractLlmPredictionProvider plus
  the four provider implementations — Anthropic, OpenAI, Gemini, and
  the OilPredictionProvider router. Replaced by LlmOverlayService).
- App\Services\BrentPricePredictor and App\Services\Ewma. The Ewma
  helper had no callers left after BrentPricePredictor went.
- App\Models\PricePrediction and its factory.
- App\Console\Commands\PredictOilPrices (the oil:predict command).
- App\Filament\Resources\OilPredictionResource and its Pages.

Schema and dashboard:
- Drop the price_predictions table via a new migration.
- Repoint the Filament StatsOverviewWidget tile from PricePrediction
  to WeeklyForecast so the dashboard reflects the new pipeline.
- Remove the OilPredictionProvider binding from AppServiceProvider.

Test cleanup:
- Delete tests for every retired service.
- Update StatsOverviewWidgetTest to seed weekly_forecasts instead of
  price_predictions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-05-03 08:40:28 +01:00
parent ddd591ad47
commit 203200acb9
32 changed files with 61 additions and 2727 deletions

View File

@@ -1,231 +0,0 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Models\PricePrediction;
use App\Services\Ewma;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
{
/**
* Tries web-search-enriched prediction first, falls back to basic tool use.
* Overrides the parent flow because Anthropic uses two phases (web search
* loop + forced tool call) and selects the source dynamically.
*/
public function predict(Collection $prices): ?PricePrediction
{
if ($this->apiKey() === null) {
return null;
}
$prediction = $this->predictWithWebContext($prices);
return $prediction ?? $this->predictBasic($prices);
}
protected function apiKey(): ?string
{
return config('services.anthropic.api_key');
}
/** {@inheritDoc} */
protected function callProvider(string $apiKey, string $priceList): ?array
{
return null;
}
/**
* Multi-turn web search phase, then a forced submit_prediction call.
* Phase 1: let the model search for recent oil/geopolitical news.
* 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 {
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(self::class.': context search request failed', ['status' => $response->status()]);
return null;
}
if ($response->json('stop_reason') !== 'pause_turn') {
break;
}
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
$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(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
return null;
}
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
return $input === null
? null
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
} catch (Throwable $e) {
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* 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 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
$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($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
return null;
}
$input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) {
Log::error(self::class.': predictBasic 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 35 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 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 35 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;
}
/** @return array<string, string> */
private function headers(): array
{
return [
'x-api-key' => $this->apiKey(),
'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;
}
}