feature: Elasticsearch CQRS Projection and Analytics Endpoints

This commit is contained in:
voltsrage
2026-06-17 00:05:37 +08:00
parent 84d259d10c
commit fde3d56484
21 changed files with 1281 additions and 2 deletions
@@ -0,0 +1,212 @@
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<ElasticsearchOptions> options)
{
_elastic = elastic;
_options = options.Value;
}
public async Task<object> GetPopulationAsync(string code, decimal threshold, string direction,
DateTimeOffset? from, DateTimeOffset? to)
{
var filters = new List<Action<QueryDescriptor<ObservationDocument>>>
{
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<ObservationDocument>(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<object> GetObservationTrendAsync(Guid encounterId, string code,
DateTimeOffset? from, DateTimeOffset? to)
{
var filters = new List<Action<QueryDescriptor<ObservationDocument>>>
{
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<ObservationDocument>(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<object>();
return new { encounterId, observationCode = code, trend = buckets };
}
public async Task<object> GetAlertSummaryAsync(string? severity, string? department,
DateTimeOffset? from, DateTimeOffset? to)
{
var filters = new List<Action<QueryDescriptor<ClinicalAlertDocument>>>();
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<ClinicalAlertDocument>(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<object>()
}) ?? Enumerable.Empty<object>();
return new { from, to, summary };
}
public async Task<object> SearchPatientsAsync(string? q, string? department, string? status,
int page, int pageSize)
{
var filters = new List<Action<QueryDescriptor<PatientEncounterDocument>>>();
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<PatientEncounterDocument>(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 * pageSize)
.Size(pageSize));
if (!resp.IsValidResponse)
throw new InvalidOperationException("Elasticsearch query failed.");
return new
{
total = resp.Total,
page,
pageSize,
data = resp.Documents
};
}
}