67f2ae130c
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.
64 lines
2.4 KiB
C#
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);
|
|
}
|