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:
@@ -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 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
|
||||
- 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 3–5 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user