- Add AdminPanelProvider mounting panel at `/admin` with `is_admin` auth guard - Add `is_admin` boolean column to users table - Add brent_prices and price_predictions tables with appropriate indexes - Add comprehensive admin design spec covering resources, dashboard, navigation, and build order - Configure default panel with amber primary color and standard middleware stack - Add compiled Filament assets (actions.js, app.css)
3.5 KiB
3.5 KiB
Scoring Engine (AlertScoringService)
Purpose
Produces a "fill up now or wait?" recommendation per user based on their local
station history. Output is one of: fill_up, wait, no_signal.
Never guess — stay silent (no_signal) when signals conflict or data is insufficient.
The 4 signals (in priority order)
Signal 1 — Local price trend (HIGHEST WEIGHT)
- Query
station_pricesfor user's nearest 5 stations (within 5km of user lat/lng) - Use last 14 days of history for
e10(or user's preferred fuel type) - Calculate 3-day rolling average vs 7-day rolling average
- Falling: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal
- Rising: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal
- Flat: difference < 0.5p → neutral, no signal
- Weight: 40 points max
Signal 2 — Supermarket anchor effect (HIGH WEIGHT)
- Find nearest supermarket station (is_supermarket = 1) within 10km
- Check if supermarket cut price in last 48 hours (> 1p drop)
- Check if nearest non-supermarket stations have NOT yet followed
- If supermarket cut AND independents haven't moved → strong wait signal
- Weight: 35 points max
Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data)
- Per station: average price by day-of-week over last 90 days
- Only activate if station has 56+ days of history
- If today is statistically 1.5p+ cheaper than weekly average → mild fill_up
- If today is statistically 1.5p+ more expensive → mild wait
- Weight: 15 points max
Signal 4 — Brent crude direction (LOW WEIGHT)
- Read from
price_predictionstable — never querybrent_pricesdirectly in scoring OilPriceService::generatePrediction()runs daily at 7am and writes the prediction- LLM (
source = 'llm') is preferred; EWMA (source = 'ewma') is the fallback - Direction
rising→ mild fill_up pressure;falling→ mild wait;flat→ no signal - Points awarded proportionally to confidence:
(confidence / 100) * 10 - Weight: 10 points max
Confidence thresholds
- Score 70–100: strong signal → fire recommendation + notification
- Score 40–69: weak signal → show in dashboard only, no push/SMS/WhatsApp
- Score 0–39: no_signal → stay silent entirely
Only send notifications when confidence ≥ 70. Never spam.
Output (stored in scoring_results)
[
'recommendation' => 'wait', // fill_up | wait | no_signal
'confidence' => 78, // 0-100
'signals' => [
'trend' => ['direction' => 'falling', 'points' => 32],
'supermarket' => ['triggered' => true, 'points' => 35],
'day_pattern' => ['triggered' => false, 'points' => 0],
'brent' => ['direction' => 'flat', 'points' => 0],
],
'local_avg_pence' => 14380, // 143.80p
'trend_delta' => -2.3, // pence change over 7 days
]
Human-readable reason strings
Always generate a plain-English reason for the recommendation:
- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
- "Prices are rising in your area — filling up today avoids paying more later."
- "No clear pattern this week — fill up at the cheapest station near you now."
Reason strings are stored in scoring_results.signals JSON and shown in the UI and notifications.
Accuracy self-tracking
After 3 days, check if wait recommendation was correct (prices did fall further).
Store outcome in scoring_results for future display: "This signal has been right X% of the time in your area."