feat(insights): hit-rate breakdown by detector kind

Adds a "By detector kind" breakdown to the anomaly outcome report + Insights page,
answering "which directional detector actually wins?". Only directional kinds
(SuspensionFlip, SteamMove) resolve to hit/miss, so the breakdown naturally shows
just those — mirrors the existing by-sport / by-severity bucketers and rendering.

- AnomalyOutcomeReport.ByKind + EvaluateAnomalyOutcomesUseCase.BuildKindBuckets
  (keyed OutcomeBucketKeys.KindPrefix + enum name); threaded through the insights VM/service.
- Insights page: new section + BucketRenderKind.Kind label case (resolves the enum name
  to the Anomaly.Kind.* resx); en/ru section heading.
- 2 tests: empty-report ByKind + directional-kind grouping.
This commit is contained in:
2026-05-29 11:52:25 +03:00
parent f512a08772
commit 67f2ae130c
9 changed files with 89 additions and 3 deletions
@@ -106,6 +106,16 @@
<hr class="m-rule--double" />
@* ---------- By detector kind ---------- *@
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-kind">
<header class="m-insights__section-head">
<span class="m-kicker">@L["Insights.Section.ByKind"]</span>
</header>
@RenderBucketTable(vm.ByKind, BucketRenderKind.Kind)
</section>
<hr class="m-rule--double" />
@* ---------- By sport ---------- *@
<section class="m-insights__section m-rise m-rise-3" data-test="insights-by-sport">
<header class="m-insights__section-head">
@@ -630,6 +640,7 @@
Severity,
Sport,
Score,
Kind,
}
private AnomalyInsightsVm? _vm;
@@ -826,6 +837,23 @@
}
break;
}
case BucketRenderKind.Kind:
{
var trimmed = key.StartsWith(OutcomeBucketKeys.KindPrefix, StringComparison.Ordinal)
? key.Substring(OutcomeBucketKeys.KindPrefix.Length)
: key;
var locKey = trimmed switch
{
nameof(AnomalyKind.SuspensionFlip) => "Anomaly.Kind.SuspensionFlip",
nameof(AnomalyKind.SteamMove) => "Anomaly.Kind.SteamMove",
nameof(AnomalyKind.SuspensionFreeze) => "Anomaly.Kind.SuspensionFreeze",
nameof(AnomalyKind.OverroundCompression) => "Anomaly.Kind.OverroundCompression",
_ => null,
};
if (locKey is null) builder.AddContent(0, trimmed);
else builder.AddContent(0, L[locKey]);
break;
}
case BucketRenderKind.Score:
default:
{
@@ -370,6 +370,7 @@
<data name="Insights.Stat.Total"><value>Total anomalies</value></data>
<data name="Insights.Section.BySeverity"><value>By severity</value></data>
<data name="Insights.Section.BySport"><value>By sport</value></data>
<data name="Insights.Section.ByKind"><value>By detector kind</value></data>
<data name="Insights.Section.ByScore"><value>By confidence score</value></data>
<data name="Insights.Section.Resolved"><value>Resolved anomalies</value></data>
<data name="Insights.Section.Unresolved"><value>Awaiting results</value></data>
@@ -383,6 +383,7 @@
<data name="Insights.Stat.Total"><value>Всего аномалий</value></data>
<data name="Insights.Section.BySeverity"><value>По уровню</value></data>
<data name="Insights.Section.BySport"><value>По виду спорта</value></data>
<data name="Insights.Section.ByKind"><value>По типу детектора</value></data>
<data name="Insights.Section.ByScore"><value>По уверенности</value></data>
<data name="Insights.Section.Resolved"><value>Подтверждённые аномалии</value></data>
<data name="Insights.Section.Unresolved"><value>Ожидают итога</value></data>
@@ -40,6 +40,7 @@ public sealed class AnomalyInsightsService : IAnomalyInsightsService
BySeverity: report.BySeverity,
BySport: report.BySport,
ByScoreBin: report.ByScoreBin,
ByKind: report.ByKind,
Resolved: resolvedRows,
Unresolved: unresolvedRows);
}
@@ -20,6 +20,7 @@ public sealed record AnomalyInsightsVm(
IReadOnlyList<OutcomeBucket> BySeverity,
IReadOnlyList<OutcomeBucket> BySport,
IReadOnlyList<OutcomeBucket> ByScoreBin,
IReadOnlyList<OutcomeBucket> ByKind,
IReadOnlyList<ResolvedAnomalyRow> Resolved,
IReadOnlyList<ResolvedAnomalyRow> Unresolved);