Files
maraphon-app/src/Marathon.UI/Services/AnomalyInsightsService.cs
T
alexei.dolgolyov 67f2ae130c 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.
2026-05-29 11:52:25 +03:00

64 lines
2.4 KiB
C#

using Marathon.Application.UseCases;
using Marathon.Domain.AnomalyDetection;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
namespace Marathon.UI.Services;
/// <summary>
/// Page-facing implementation of <see cref="IAnomalyInsightsService"/>. Runs
/// the application use case and reshapes its output for the page — event title
/// strings and severity buckets are computed once from the report's payload, so
/// the service performs no repository I/O of its own.
/// </summary>
public sealed class AnomalyInsightsService : IAnomalyInsightsService
{
private readonly EvaluateAnomalyOutcomesUseCase _useCase;
public AnomalyInsightsService(EvaluateAnomalyOutcomesUseCase useCase)
{
_useCase = useCase ?? throw new ArgumentNullException(nameof(useCase));
}
public async Task<AnomalyInsightsVm> GetReportAsync(CancellationToken ct)
{
var report = await _useCase.ExecuteAsync(ct).ConfigureAwait(false);
var resolvedRows = report.Resolved
.Select(r => ToRow(r, report.EventTitles))
.ToList();
var unresolvedRows = report.Unresolved
.Select(r => ToRow(r, report.EventTitles))
.ToList();
return new AnomalyInsightsVm(
TotalAnomalies: report.TotalAnomalies,
ResolvedCount: report.ResolvedCount,
UnresolvedCount: report.UnresolvedCount,
HitCount: report.HitCount,
MissCount: report.MissCount,
HitRate: report.HitRate,
BySeverity: report.BySeverity,
BySport: report.BySport,
ByScoreBin: report.ByScoreBin,
ByKind: report.ByKind,
Resolved: resolvedRows,
Unresolved: unresolvedRows);
}
private static ResolvedAnomalyRow ToRow(
ResolvedAnomaly src,
IReadOnlyDictionary<DomainEventId, string> titles) =>
new(
AnomalyId: src.AnomalyId,
EventId: src.EventId,
EventTitle: titles.TryGetValue(src.EventId, out var t) ? t : src.EventId.Value,
DetectedAt: src.DetectedAt,
Score: src.Score,
Severity: AnomalySeverityRules.FromScore(src.Score),
Sport: src.Sport,
PreFlipFavourite: src.PreFlipFavourite,
PostFlipFavourite: src.PostFlipFavourite,
ActualWinner: src.ActualWinner,
Outcome: src.Outcome);
}