feat: explainability — décomposition des 4 composantes du score dans le SpotPanel
Expose breakdown {distance, density, velocity, trend} dans l'API /spots/{id}/score
(pour le score courant reconstruit depuis les champs stockés, et pour chaque horizon
avec confidence appliquée). Affiche les barres avec valeur/max dans le SpotPanel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -152,12 +152,20 @@ class SpotScoreController extends AbstractController
|
||||
$raw = $distanceScore + $densityScore + $velocityScore + $trendScore;
|
||||
$score = max(0, min(100, (int) round($raw * $confidence)));
|
||||
|
||||
$cf = fn(float $v): int => (int) round($v * $confidence);
|
||||
|
||||
$horizons['h' . $horizon] = [
|
||||
'score' => $score,
|
||||
'level' => $this->scoreToLevel($score),
|
||||
'distanceKm' => $distKm,
|
||||
'confidence' => $confidence,
|
||||
'validAt' => $row['valid_at'],
|
||||
'breakdown' => [
|
||||
'distance' => $cf($distanceScore),
|
||||
'density' => $cf($densityScore),
|
||||
'velocity' => $cf($velocityScore),
|
||||
'trend' => $cf($trendScore),
|
||||
],
|
||||
];
|
||||
|
||||
$prevDistKm = $distKm;
|
||||
@@ -169,18 +177,33 @@ class SpotScoreController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string}
|
||||
* @return array{value: int, level: string, distance: float|null, density: float|null, trend: string|null, etaHours: int|null, computedAt: string, breakdown: array{distance: int, density: int, velocity: int, trend: int}|null}
|
||||
*/
|
||||
private function formatScore(array $row): array
|
||||
{
|
||||
$distKm = $row['distance'] !== null ? (float) $row['distance'] : null;
|
||||
$densKm2 = $row['density'] !== null ? (float) $row['density'] : null;
|
||||
$trend = $row['trend'];
|
||||
$total = (int) $row['score'];
|
||||
|
||||
$breakdown = null;
|
||||
if ($distKm !== null && $densKm2 !== null && $trend !== null) {
|
||||
$dScore = (int) round(max(0.0, 1.0 - $distKm * 1000 / 50_000) * 40);
|
||||
$nScore = (int) round(min($densKm2 / 100, 1.0) * 30);
|
||||
$tScore = match ($trend) { 'increasing' => 10, 'stable' => 5, default => 0 };
|
||||
$vScore = max(0, $total - $dScore - $nScore - $tScore);
|
||||
$breakdown = ['distance' => $dScore, 'density' => $nScore, 'velocity' => $vScore, 'trend' => $tScore];
|
||||
}
|
||||
|
||||
return [
|
||||
'value' => (int) $row['score'],
|
||||
'value' => $total,
|
||||
'level' => $row['level'],
|
||||
'distance' => $row['distance'] !== null ? (float) $row['distance'] : null,
|
||||
'density' => $row['density'] !== null ? (float) $row['density'] : null,
|
||||
'trend' => $row['trend'],
|
||||
'distance' => $distKm,
|
||||
'density' => $densKm2,
|
||||
'trend' => $trend,
|
||||
'etaHours' => $row['eta_hours'] !== null ? (int) $row['eta_hours'] : null,
|
||||
'computedAt' => $row['computed_at'],
|
||||
'breakdown' => $breakdown,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,59 @@
|
||||
.spot-panel__error { font-size: 14px; text-align: center; padding: 12px 0; color: #777; }
|
||||
.spot-panel__error { color: #dc2626; }
|
||||
|
||||
/* Score breakdown */
|
||||
.score-breakdown {
|
||||
border-top: 1px solid rgba(0,0,0,0.08);
|
||||
padding-top: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.score-breakdown__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .07em;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.score-breakdown__row {
|
||||
display: grid;
|
||||
grid-template-columns: 16px 58px 1fr 38px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.score-breakdown__icon { font-size: 12px; text-align: center; line-height: 1; }
|
||||
.score-breakdown__label { font-size: 12px; color: #666; white-space: nowrap; }
|
||||
|
||||
.score-breakdown__track {
|
||||
height: 5px;
|
||||
background: rgba(0,0,0,0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-breakdown__fill {
|
||||
height: 100%;
|
||||
background: #f4a020;
|
||||
border-radius: 3px;
|
||||
transition: width .5s ease;
|
||||
}
|
||||
|
||||
.score-breakdown__value {
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.score-breakdown__max {
|
||||
color: #aaa;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Feedback */
|
||||
.spot-panel__feedback {
|
||||
border-top: 2px solid #e5e7eb;
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
||||
level: data.horizons[activeStep].level,
|
||||
distance: data.horizons[activeStep].distanceKm,
|
||||
confidence: data.horizons[activeStep].confidence,
|
||||
breakdown: data.horizons[activeStep].breakdown,
|
||||
} : null);
|
||||
|
||||
const lvl = displayScore?.level;
|
||||
@@ -137,6 +138,8 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
||||
<dd>{new Date(displayScore.computedAt).toLocaleString('fr-FR')}</dd></>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<ScoreBreakdown breakdown={displayScore.breakdown} />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
@@ -148,6 +151,39 @@ export default function SpotPanel({ spot, activeStep, onClose }) {
|
||||
);
|
||||
}
|
||||
|
||||
const BREAKDOWN_ITEMS = [
|
||||
{ key: 'distance', label: 'Distance', max: 40, icon: '📏' },
|
||||
{ key: 'density', label: 'Densité', max: 30, icon: '🌿' },
|
||||
{ key: 'velocity', label: 'Vitesse', max: 20, icon: '➡' },
|
||||
{ key: 'trend', label: 'Tendance', max: 10, icon: '📈' },
|
||||
];
|
||||
|
||||
function ScoreBreakdown({ breakdown }) {
|
||||
if (!breakdown) return null;
|
||||
|
||||
return (
|
||||
<div className="score-breakdown">
|
||||
<p className="score-breakdown__title">Détail du score</p>
|
||||
{BREAKDOWN_ITEMS.map(({ key, label, max, icon }) => {
|
||||
const val = breakdown[key] ?? 0;
|
||||
const pct = max > 0 ? (val / max) * 100 : 0;
|
||||
return (
|
||||
<div key={key} className="score-breakdown__row">
|
||||
<span className="score-breakdown__icon">{icon}</span>
|
||||
<span className="score-breakdown__label">{label}</span>
|
||||
<div className="score-breakdown__track">
|
||||
<div className="score-breakdown__fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="score-breakdown__value">
|
||||
{val}<span className="score-breakdown__max">/{max}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackButton({ spot }) {
|
||||
const [sent, setSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user