using Dapper; using Npgsql; namespace Kbx.Shared.Telemetry; public sealed class KbxUxMetricsQuery(NpgsqlDataSource dataSource) { public async Task ExecuteAsync(Guid tenantId,DateOnly from,DateOnly to,string? screenId,CancellationToken ct) { await using var connection=await dataSource.OpenConnectionAsync(ct); var args=new{TenantId=tenantId,From=from.ToDateTime(TimeOnly.MinValue,DateTimeKind.Utc),To=to.AddDays(1).ToDateTime(TimeOnly.MinValue,DateTimeKind.Utc),ScreenId=screenId}; const string eventsSql=""" select event_name as EventName,count(*) as Samples, percentile_cont(0.5) within group(order by duration_ms) filter(where duration_ms is not null) as P50, percentile_cont(0.95) within group(order by duration_ms) filter(where duration_ms is not null) as P95 from kbx.ux_events where tenant_id=@TenantId and occurred_at>=@From and occurred_at<@To and (@ScreenId is null or screen_id=@ScreenId) group by event_name; """; var rows=(await connection.QueryAsync(new CommandDefinition(eventsSql,args,cancellationToken:ct))).ToDictionary(x=>x.EventName,StringComparer.Ordinal); const string outcomeSql=""" select coalesce(sum(observed_count),0) Observed,coalesce(sum(manual_intervention_count),0) Manual from kbx.ux_business_outcomes where tenant_id=@TenantId and business_date>=date(@From) and business_date(new CommandDefinition(outcomeSql,args,cancellationToken:ct)); long Count(string name)=>rows.TryGetValue(name,out var x)?x.Samples:0; double? Ratio(long n,long d)=>d==0?null:Math.Round((double)n*100d/d,2); var metrics=new List{ new("task_completion_time_ms","Task Completion Time",rows.GetValueOrDefault("task.complete")?.P50,"ms",Count("task.complete"),rows.GetValueOrDefault("task.complete")?.P50,rows.GetValueOrDefault("task.complete")?.P95), new("semantic_interactions_per_task","Interactions / Task",Count("task.complete")==0?null:Math.Round((double)Count("interaction.execute")/Count("task.complete"),2),"count",Count("task.complete")), new("manual_intervention_rate","Manual Intervention Rate",outcome.Observed==0?null:Math.Round((double)outcome.Manual*100d/outcome.Observed,2),"percent",outcome.Observed), new("validation_failure_rate","Validation Failure Rate",Ratio(Count("validation.failed"),Count("command.execute")),"percent",Count("command.execute")), new("import_failure_rate","Import Failure Rate",Ratio(Count("excel.import.failed"),Count("excel.import.start")),"percent",Count("excel.import.start")), new("exception_resolution_time_ms","Exception Resolution Time",rows.GetValueOrDefault("exception.resolved")?.P50,"ms",Count("exception.resolved"),rows.GetValueOrDefault("exception.resolved")?.P50,rows.GetValueOrDefault("exception.resolved")?.P95), new("ai_proposal_acceptance_rate","AI Proposal Acceptance Rate",Ratio(Count("ai.proposal.accept"),Count("ai.proposal.open")),"percent",Count("ai.proposal.open")), }; return new(from,to,screenId,metrics); } private sealed record EventAgg(string EventName,long Samples,double? P50,double? P95); private sealed record OutcomeAgg(long Observed,long Manual); }