using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; using Microsoft.Extensions.Options; public class AnalyticsService : IAnalyticsService { private readonly ElasticsearchClient _elastic; private readonly ElasticsearchOptions _options; public AnalyticsService(ElasticsearchClient elastic, IOptions options) { _elastic = elastic; _options = options.Value; } public async Task GetPopulationAsync(string code, decimal threshold, string direction, DateTimeOffset? from, DateTimeOffset? to) { var filters = new List>> { fq => fq.Term(t => t.Field(o => o.ObservationCode).Value(code)) }; if (direction == "above") filters.Add(fq => fq.Range(r => r.NumberRange(nr => nr.Field(o => o.Value).Gt((double)threshold)))); else filters.Add(fq => fq.Range(r => r.NumberRange(nr => nr.Field(o => o.Value).Lt((double)threshold)))); if (from.HasValue) filters.Add(fq => fq.Range(r => r.DateRange(dr => dr.Field(o => o.RecordedAt).Gte(from.Value.ToString("O"))))); if (to.HasValue) filters.Add(fq => fq.Range(r => r.DateRange(dr => dr.Field(o => o.RecordedAt).Lte(to.Value.ToString("O"))))); var resp = await _elastic.SearchAsync(s => s .Indices(_options.Indices.Observations) .Query(q => q.Bool(b => b.Filter(filters.ToArray()))) .Aggregations(a => a // cardinality: count of distinct patientId values — not a document count. // Two observations from the same patient count as one patient. .Add("unique_patients", agg => agg.Cardinality(c => c.Field(o => o.PatientId))) ) .Size(0)); // no raw hits — aggregation result only if (!resp.IsValidResponse) throw new InvalidOperationException("Elasticsearch query failed."); var uniquePatients = resp.Aggregations?.GetCardinality("unique_patients")?.Value ?? 0; return new { observationCode = code, threshold, direction, from, to, uniquePatientCount = uniquePatients }; } public async Task GetObservationTrendAsync(Guid encounterId, string code, DateTimeOffset? from, DateTimeOffset? to) { var filters = new List>> { fq => fq.Term(t => t.Field(o => o.EncounterId).Value(encounterId.ToString())), fq => fq.Term(t => t.Field(o => o.ObservationCode).Value(code)) }; if (from.HasValue) filters.Add(fq => fq.Range(r => r.DateRange(dr => dr.Field(o => o.RecordedAt).Gte(from.Value.ToString("O"))))); if (to.HasValue) filters.Add(fq => fq.Range(r => r.DateRange(dr => dr.Field(o => o.RecordedAt).Lte(to.Value.ToString("O"))))); var resp = await _elastic.SearchAsync(s => s .Indices(_options.Indices.Observations) .Query(q => q.Bool(b => b.Filter(filters.ToArray()))) .Aggregations(a => a .Add("trend", agg => agg.DateHistogram(h => h .Field(o => o.RecordedAt) .CalendarInterval(Elastic.Clients.Elasticsearch.Aggregations.CalendarInterval.Hour) ) ) ) .Size(0)); if (!resp.IsValidResponse) throw new InvalidOperationException("Elasticsearch query failed."); var buckets = resp.Aggregations?.GetDateHistogram("trend")?.Buckets .Select(b => new { hour = b.KeyAsString, avg = (double?)null, min = (double?)null, max = (double?)null, count = b.DocCount }) ?? Enumerable.Empty(); return new { encounterId, observationCode = code, trend = buckets }; } public async Task GetAlertSummaryAsync(string? severity, string? department, DateTimeOffset? from, DateTimeOffset? to) { var filters = new List>>(); if (from.HasValue) filters.Add(fq => fq.Range(r => r.DateRange(dr => dr.Field(a => a.TriggeredAt).Gte(from.Value.ToString("O"))))); if (to.HasValue) filters.Add(fq => fq.Range(r => r.DateRange(dr => dr.Field(a => a.TriggeredAt).Lte(to.Value.ToString("O"))))); if (!string.IsNullOrEmpty(severity)) filters.Add(fq => fq.Term(t => t.Field(a => a.Severity).Value(severity))); if (!string.IsNullOrEmpty(department)) filters.Add(fq => fq.Term(t => t.Field(a => a.Department).Value(department))); var resp = await _elastic.SearchAsync(s => s .Indices(_options.Indices.ClinicalAlerts) .Query(q => q.Bool(b => { if (filters.Count > 0) b.Filter(filters.ToArray()); else b.Must(m => m.MatchAll(_ => { })); })) .Aggregations(a => a .Add("by_department", agg => agg.Terms(t => t .Field(a => a.Department).Size(50) ) ) ) .Size(0)); if (!resp.IsValidResponse) throw new InvalidOperationException("Elasticsearch query failed."); var summary = resp.Aggregations?.GetStringTerms("by_department")?.Buckets .Select(deptBucket => new { department = deptBucket.Key, total = deptBucket.DocCount, bySeverity = Enumerable.Empty() }) ?? Enumerable.Empty(); return new { from, to, summary }; } public async Task SearchPatientsAsync(string? q, string? department, string? status, int page, int pageSize) { var filters = new List>>(); if (!string.IsNullOrEmpty(department)) filters.Add(fq => fq.Term(t => t.Field(p => p.Department).Value(department))); if (!string.IsNullOrEmpty(status)) filters.Add(fq => fq.Term(t => t.Field(p => p.Status).Value(status))); var resp = await _elastic.SearchAsync(s => s .Indices(_options.Indices.PatientEncounters) .Query(q2 => q2 .Bool(b => { if (!string.IsNullOrWhiteSpace(q)) { b.Should( // MRN: exact keyword match — boosted because MRN lookup is // the most common search pattern for clinicians who know the number s2 => s2.Term(t => t.Field(p => p.Mrn).Value(q).Boost(3)), // Patient name: full-text across the analyzed field s2 => s2.Match(m => m.Field(p => p.PatientName).Query(q)), // Department: exact filter match included in should for relevance scoring s2 => s2.Term(t => t.Field(p => p.Department).Value(q)) ); b.MinimumShouldMatch(1); } else { b.Must(m => m.MatchAll(_ => { })); } if (filters.Count > 0) b.Filter(filters.ToArray()); }) ) .From((page - 1) * pageSize) .Size(pageSize)); if (!resp.IsValidResponse) throw new InvalidOperationException("Elasticsearch query failed."); return new { total = resp.Total, page, pageSize, data = resp.Documents }; } }