# frozen_string_literal: true
module Healthcare
module Domain
# PatientContext is an immutable value object that
# aggregates all data required by the inference layer.
# It is assembled once per request and never mutated.
PatientContext = Data.define(
:id, # String — UUID v4
:age, # Integer — years
:diagnoses, # Array<Diagnosis>
:vitals, # Vitals | nil
:risk_score, # Float — 0.0..1.0
:flags # Set<Symbol>
) do
# Guard invariants at construction time so
# downstream consumers never see invalid state.
def initialize(*)
super
raise ArgumentError, "age must be positive" unless age.positive?
raise ArgumentError, "risk_score out of range" unless (0.0..1.0).cover?(risk_score)
end
def high_risk? = risk_score > 0.75
def flagged? = flags.any?
def primary_dx = diagnoses.first
def comorbidities = diagnoses.drop(1)
def critical_vitals? = vitals&.abnormality_index&.> 0.8
end
Diagnosis = Data.define(:code, :description, :severity, :onset_date) do
SEVERITY_RANKS = { low: 0, moderate: 1, high: 2, critical: 3 }.freeze
def rank = SEVERITY_RANKS.fetch(severity, -1)
def critical? = severity == :critical
def chronic? = onset_date < Date.today - 365
def <=>(other) = rank <=> other.rank
end
Vitals = Data.define(
:recorded_at, # Time
:heart_rate, # Integer — bpm
:systolic_bp, # Integer — mmHg
:diastolic_bp, # Integer — mmHg
:spo2, # Float — percentage
:temperature # Float — celsius
) do
NORMAL_RANGES = {
heart_rate: 60..100,
systolic_bp: 90..120,
diastolic_bp: 60..80,
spo2: 95.0..100.0,
temperature: 36.1..37.2
}.freeze
def abnormal_fields
NORMAL_RANGES.reject { |field, range| range.cover?(public_send(field)) }.keys
end
def abnormality_index
(abnormal_fields.size.to_f / NORMAL_RANGES.size).round(4)
end
def critical? = abnormality_index >= 0.6 || spo2 < 90.0
def stale? = recorded_at < 6.hours.ago
end
AnalysisResult = Data.define(
:context, # PatientContext
:recommendations, # Array<String>
:flagged, # Boolean
:model_version, # String
:created_at # Time
) do
def actionable? = flagged? && recommendations.any?
def summary
"[#{flagged ? 'FLAGGED' : 'OK'}] " \
"Patient #{context.id} — " \
"Risk #{(context.risk_score * 100).round}% — " \
"#{recommendations.size} recommendation(s)"
end
end
end
end
module Healthcare
module Domain
# Normalizer contract — any callable that maps a raw
# diagnosis string to a canonical Diagnosis struct.
module DiagnosisNormalizer
NormalizationError = Class.new(StandardError)
# @param raw [String]
# @return [Diagnosis]
def call(raw)
raise NotImplementedError, "#{self.class}#call not implemented"
end
end
class ICD10Normalizer
include DiagnosisNormalizer
def initialize(lookup_table:)
@lookup = lookup_table # Hash<String, {description:, severity:}>
end
def call(raw)
code = raw.strip.upcase
entry = @lookup.fetch(code) { raise NormalizationError, "Unknown ICD-10: #{code}" }
Diagnosis.new(
code: code,
description: entry[:description],
severity: entry[:severity],
onset_date: Date.today # placeholder — real onset parsed upstream
)
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Domain
# PatientContext is an immutable value object that
# aggregates all data required by the inference layer.
# It is assembled once per request and never mutated.
PatientContext = Data.define(
:id, # String — UUID v4
:age, # Integer — years
:diagnoses, # Array<Diagnosis>
:vitals, # Vitals | nil
:risk_score, # Float — 0.0..1.0
:flags # Set<Symbol>
) do
# Guard invariants at construction time so
# downstream consumers never see invalid state.
def initialize(*)
super
raise ArgumentError, "age must be positive" unless age.positive?
raise ArgumentError, "risk_score out of range" unless (0.0..1.0).cover?(risk_score)
end
def high_risk? = risk_score > 0.75
def flagged? = flags.any?
def primary_dx = diagnoses.first
def comorbidities = diagnoses.drop(1)
def critical_vitals? = vitals&.abnormality_index&.> 0.8
end
Diagnosis = Data.define(:code, :description, :severity, :onset_date) do
SEVERITY_RANKS = { low: 0, moderate: 1, high: 2, critical: 3 }.freeze
def rank = SEVERITY_RANKS.fetch(severity, -1)
def critical? = severity == :critical
def chronic? = onset_date < Date.today - 365
def <=>(other) = rank <=> other.rank
end
Vitals = Data.define(
:recorded_at, # Time
:heart_rate, # Integer — bpm
:systolic_bp, # Integer — mmHg
:diastolic_bp, # Integer — mmHg
:spo2, # Float — percentage
:temperature # Float — celsius
) do
NORMAL_RANGES = {
heart_rate: 60..100,
systolic_bp: 90..120,
diastolic_bp: 60..80,
spo2: 95.0..100.0,
temperature: 36.1..37.2
}.freeze
def abnormal_fields
NORMAL_RANGES.reject { |field, range| range.cover?(public_send(field)) }.keys
end
def abnormality_index
(abnormal_fields.size.to_f / NORMAL_RANGES.size).round(4)
end
def critical? = abnormality_index >= 0.6 || spo2 < 90.0
def stale? = recorded_at < 6.hours.ago
end
AnalysisResult = Data.define(
:context, # PatientContext
:recommendations, # Array<String>
:flagged, # Boolean
:model_version, # String
:created_at # Time
) do
def actionable? = flagged? && recommendations.any?
def summary
"[#{flagged ? 'FLAGGED' : 'OK'}] " \
"Patient #{context.id} — " \
"Risk #{(context.risk_score * 100).round}% — " \
"#{recommendations.size} recommendation(s)"
end
end
end
end
module Healthcare
module Domain
# Normalizer contract — any callable that maps a raw
# diagnosis string to a canonical Diagnosis struct.
module DiagnosisNormalizer
NormalizationError = Class.new(StandardError)
# @param raw [String]
# @return [Diagnosis]
def call(raw)
raise NotImplementedError, "#{self.class}#call not implemented"
end
end
class ICD10Normalizer
include DiagnosisNormalizer
def initialize(lookup_table:)
@lookup = lookup_table # Hash<String, {description:, severity:}>
end
def call(raw)
code = raw.strip.upcase
entry = @lookup.fetch(code) { raise NormalizationError, "Unknown ICD-10: #{code}" }
Diagnosis.new(
code: code,
description: entry[:description],
severity: entry[:severity],
onset_date: Date.today # placeholder — real onset parsed upstream
)
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Domain
# PatientContext is an immutable value object that
# aggregates all data required by the inference layer.
# It is assembled once per request and never mutated.
PatientContext = Data.define(
:id, # String — UUID v4
:age, # Integer — years
:diagnoses, # Array<Diagnosis>
:vitals, # Vitals | nil
:risk_score, # Float — 0.0..1.0
:flags # Set<Symbol>
) do
# Guard invariants at construction time so
# downstream consumers never see invalid state.
def initialize(*)
super
raise ArgumentError, "age must be positive" unless age.positive?
raise ArgumentError, "risk_score out of range" unless (0.0..1.0).cover?(risk_score)
end
def high_risk? = risk_score > 0.75
def flagged? = flags.any?
def primary_dx = diagnoses.first
def comorbidities = diagnoses.drop(1)
def critical_vitals? = vitals&.abnormality_index&.> 0.8
end
Diagnosis = Data.define(:code, :description, :severity, :onset_date) do
SEVERITY_RANKS = { low: 0, moderate: 1, high: 2, critical: 3 }.freeze
def rank = SEVERITY_RANKS.fetch(severity, -1)
def critical? = severity == :critical
def chronic? = onset_date < Date.today - 365
def <=>(other) = rank <=> other.rank
end
Vitals = Data.define(
:recorded_at, # Time
:heart_rate, # Integer — bpm
:systolic_bp, # Integer — mmHg
:diastolic_bp, # Integer — mmHg
:spo2, # Float — percentage
:temperature # Float — celsius
) do
NORMAL_RANGES = {
heart_rate: 60..100,
systolic_bp: 90..120,
diastolic_bp: 60..80,
spo2: 95.0..100.0,
temperature: 36.1..37.2
}.freeze
def abnormal_fields
NORMAL_RANGES.reject { |field, range| range.cover?(public_send(field)) }.keys
end
def abnormality_index
(abnormal_fields.size.to_f / NORMAL_RANGES.size).round(4)
end
def critical? = abnormality_index >= 0.6 || spo2 < 90.0
def stale? = recorded_at < 6.hours.ago
end
AnalysisResult = Data.define(
:context, # PatientContext
:recommendations, # Array<String>
:flagged, # Boolean
:model_version, # String
:created_at # Time
) do
def actionable? = flagged? && recommendations.any?
def summary
"[#{flagged ? 'FLAGGED' : 'OK'}] " \
"Patient #{context.id} — " \
"Risk #{(context.risk_score * 100).round}% — " \
"#{recommendations.size} recommendation(s)"
end
end
end
end
module Healthcare
module Domain
# Normalizer contract — any callable that maps a raw
# diagnosis string to a canonical Diagnosis struct.
module DiagnosisNormalizer
NormalizationError = Class.new(StandardError)
# @param raw [String]
# @return [Diagnosis]
def call(raw)
raise NotImplementedError, "#{self.class}#call not implemented"
end
end
class ICD10Normalizer
include DiagnosisNormalizer
def initialize(lookup_table:)
@lookup = lookup_table # Hash<String, {description:, severity:}>
end
def call(raw)
code = raw.strip.upcase
entry = @lookup.fetch(code) { raise NormalizationError, "Unknown ICD-10: #{code}" }
Diagnosis.new(
code: code,
description: entry[:description],
severity: entry[:severity],
onset_date: Date.today # placeholder — real onset parsed upstream
)
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Domain
# PatientContext is an immutable value object that
# aggregates all data required by the inference layer.
# It is assembled once per request and never mutated.
PatientContext = Data.define(
:id, # String — UUID v4
:age, # Integer — years
:diagnoses, # Array<Diagnosis>
:vitals, # Vitals | nil
:risk_score, # Float — 0.0..1.0
:flags # Set<Symbol>
) do
# Guard invariants at construction time so
# downstream consumers never see invalid state.
def initialize(*)
super
raise ArgumentError, "age must be positive" unless age.positive?
raise ArgumentError, "risk_score out of range" unless (0.0..1.0).cover?(risk_score)
end
def high_risk? = risk_score > 0.75
def flagged? = flags.any?
def primary_dx = diagnoses.first
def comorbidities = diagnoses.drop(1)
def critical_vitals? = vitals&.abnormality_index&.> 0.8
end
Diagnosis = Data.define(:code, :description, :severity, :onset_date) do
SEVERITY_RANKS = { low: 0, moderate: 1, high: 2, critical: 3 }.freeze
def rank = SEVERITY_RANKS.fetch(severity, -1)
def critical? = severity == :critical
def chronic? = onset_date < Date.today - 365
def <=>(other) = rank <=> other.rank
end
Vitals = Data.define(
:recorded_at, # Time
:heart_rate, # Integer — bpm
:systolic_bp, # Integer — mmHg
:diastolic_bp, # Integer — mmHg
:spo2, # Float — percentage
:temperature # Float — celsius
) do
NORMAL_RANGES = {
heart_rate: 60..100,
systolic_bp: 90..120,
diastolic_bp: 60..80,
spo2: 95.0..100.0,
temperature: 36.1..37.2
}.freeze
def abnormal_fields
NORMAL_RANGES.reject { |field, range| range.cover?(public_send(field)) }.keys
end
def abnormality_index
(abnormal_fields.size.to_f / NORMAL_RANGES.size).round(4)
end
def critical? = abnormality_index >= 0.6 || spo2 < 90.0
def stale? = recorded_at < 6.hours.ago
end
AnalysisResult = Data.define(
:context, # PatientContext
:recommendations, # Array<String>
:flagged, # Boolean
:model_version, # String
:created_at # Time
) do
def actionable? = flagged? && recommendations.any?
def summary
"[#{flagged ? 'FLAGGED' : 'OK'}] " \
"Patient #{context.id} — " \
"Risk #{(context.risk_score * 100).round}% — " \
"#{recommendations.size} recommendation(s)"
end
end
end
end
module Healthcare
module Domain
# Normalizer contract — any callable that maps a raw
# diagnosis string to a canonical Diagnosis struct.
module DiagnosisNormalizer
NormalizationError = Class.new(StandardError)
# @param raw [String]
# @return [Diagnosis]
def call(raw)
raise NotImplementedError, "#{self.class}#call not implemented"
end
end
class ICD10Normalizer
include DiagnosisNormalizer
def initialize(lookup_table:)
@lookup = lookup_table # Hash<String, {description:, severity:}>
end
def call(raw)
code = raw.strip.upcase
entry = @lookup.fetch(code) { raise NormalizationError, "Unknown ICD-10: #{code}" }
Diagnosis.new(
code: code,
description: entry[:description],
severity: entry[:severity],
onset_date: Date.today # placeholder — real onset parsed upstream
)
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# Assembles a PatientContext from raw ActiveRecord
# objects. Single Responsibility: knows nothing about
# scoring or notification — only about construction.
class PatientContextBuilder
def initialize(normalizer:, risk_scorer:)
@normalizer = normalizer
@risk_scorer = risk_scorer
end
# @param patient [Patient] — AR model
# @param records [Relation] — MedicalRecord AR scope
# @return [Domain::PatientContext]
def build(patient:, records:)
loaded = records.includes(:diagnoses, :vitals).to_a
Domain::PatientContext.new(
id: patient.public_id,
age: patient.age,
diagnoses: normalize_all(loaded),
vitals: latest_vitals(loaded),
risk_score: @risk_scorer.score(patient: patient, records: loaded),
flags: derive_flags(patient, loaded)
)
end
private
def normalize_all(records)
records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d.icd10_code) }
.sort
.uniq(&:code)
end
def latest_vitals(records)
records
.filter_map(&:vitals)
.max_by(&:recorded_at)
.then { |v| v&.stale? ? nil : v }
end
def derive_flags(patient, records)
Set.new.tap do |f|
f << :icu_candidate if records.any? { |r| r.vitals&.critical? }
f << :elderly if patient.age >= 65
f << :chronic if records.flat_map(&:diagnoses).any?(&:chronic?)
f << :high_risk if records.size > 5
end
end
end
# Open/Closed: new scoring strategies are added by
# subclassing WeightedRiskScorer, not by editing it.
class WeightedRiskScorer
WEIGHTS = {
age: 0.20,
comorbidity: 0.40,
vitals: 0.30,
history: 0.10
}.freeze
def score(patient:, records:)
WEIGHTS.sum { |factor, w| send(:"#{factor}_score", patient, records) * w }
.clamp(0.0, 1.0)
.round(4)
end
private
def age_score(patient, _)
case patient.age
when 0..17 then 0.1
when 18..44 then 0.2
when 45..64 then 0.5
when 65..79 then 0.7
else 1.0
end
end
def comorbidity_score(_, records)
count = records.sum { |r| r.diagnoses.count }
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score(_, records)
records
.filter_map { |r| r.vitals&.abnormality_index }
.then { |indices| indices.empty? ? 0.0 : indices.sum / indices.size }
end
def history_score(_, records)
records.any? { |r| r.hospitalization? } ? 0.8 : 0.1
end
end
class PediatricRiskScorer < WeightedRiskScorer
WEIGHTS = {
age: 0.10,
comorbidity: 0.35,
vitals: 0.45,
history: 0.10
}.freeze
private
def age_score(patient, _)
# Children are always considered elevated
patient.age < 5 ? 0.6 : 0.3
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# Assembles a PatientContext from raw ActiveRecord
# objects. Single Responsibility: knows nothing about
# scoring or notification — only about construction.
class PatientContextBuilder
def initialize(normalizer:, risk_scorer:)
@normalizer = normalizer
@risk_scorer = risk_scorer
end
# @param patient [Patient] — AR model
# @param records [Relation] — MedicalRecord AR scope
# @return [Domain::PatientContext]
def build(patient:, records:)
loaded = records.includes(:diagnoses, :vitals).to_a
Domain::PatientContext.new(
id: patient.public_id,
age: patient.age,
diagnoses: normalize_all(loaded),
vitals: latest_vitals(loaded),
risk_score: @risk_scorer.score(patient: patient, records: loaded),
flags: derive_flags(patient, loaded)
)
end
private
def normalize_all(records)
records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d.icd10_code) }
.sort
.uniq(&:code)
end
def latest_vitals(records)
records
.filter_map(&:vitals)
.max_by(&:recorded_at)
.then { |v| v&.stale? ? nil : v }
end
def derive_flags(patient, records)
Set.new.tap do |f|
f << :icu_candidate if records.any? { |r| r.vitals&.critical? }
f << :elderly if patient.age >= 65
f << :chronic if records.flat_map(&:diagnoses).any?(&:chronic?)
f << :high_risk if records.size > 5
end
end
end
# Open/Closed: new scoring strategies are added by
# subclassing WeightedRiskScorer, not by editing it.
class WeightedRiskScorer
WEIGHTS = {
age: 0.20,
comorbidity: 0.40,
vitals: 0.30,
history: 0.10
}.freeze
def score(patient:, records:)
WEIGHTS.sum { |factor, w| send(:"#{factor}_score", patient, records) * w }
.clamp(0.0, 1.0)
.round(4)
end
private
def age_score(patient, _)
case patient.age
when 0..17 then 0.1
when 18..44 then 0.2
when 45..64 then 0.5
when 65..79 then 0.7
else 1.0
end
end
def comorbidity_score(_, records)
count = records.sum { |r| r.diagnoses.count }
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score(_, records)
records
.filter_map { |r| r.vitals&.abnormality_index }
.then { |indices| indices.empty? ? 0.0 : indices.sum / indices.size }
end
def history_score(_, records)
records.any? { |r| r.hospitalization? } ? 0.8 : 0.1
end
end
class PediatricRiskScorer < WeightedRiskScorer
WEIGHTS = {
age: 0.10,
comorbidity: 0.35,
vitals: 0.45,
history: 0.10
}.freeze
private
def age_score(patient, _)
# Children are always considered elevated
patient.age < 5 ? 0.6 : 0.3
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# Assembles a PatientContext from raw ActiveRecord
# objects. Single Responsibility: knows nothing about
# scoring or notification — only about construction.
class PatientContextBuilder
def initialize(normalizer:, risk_scorer:)
@normalizer = normalizer
@risk_scorer = risk_scorer
end
# @param patient [Patient] — AR model
# @param records [Relation] — MedicalRecord AR scope
# @return [Domain::PatientContext]
def build(patient:, records:)
loaded = records.includes(:diagnoses, :vitals).to_a
Domain::PatientContext.new(
id: patient.public_id,
age: patient.age,
diagnoses: normalize_all(loaded),
vitals: latest_vitals(loaded),
risk_score: @risk_scorer.score(patient: patient, records: loaded),
flags: derive_flags(patient, loaded)
)
end
private
def normalize_all(records)
records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d.icd10_code) }
.sort
.uniq(&:code)
end
def latest_vitals(records)
records
.filter_map(&:vitals)
.max_by(&:recorded_at)
.then { |v| v&.stale? ? nil : v }
end
def derive_flags(patient, records)
Set.new.tap do |f|
f << :icu_candidate if records.any? { |r| r.vitals&.critical? }
f << :elderly if patient.age >= 65
f << :chronic if records.flat_map(&:diagnoses).any?(&:chronic?)
f << :high_risk if records.size > 5
end
end
end
# Open/Closed: new scoring strategies are added by
# subclassing WeightedRiskScorer, not by editing it.
class WeightedRiskScorer
WEIGHTS = {
age: 0.20,
comorbidity: 0.40,
vitals: 0.30,
history: 0.10
}.freeze
def score(patient:, records:)
WEIGHTS.sum { |factor, w| send(:"#{factor}_score", patient, records) * w }
.clamp(0.0, 1.0)
.round(4)
end
private
def age_score(patient, _)
case patient.age
when 0..17 then 0.1
when 18..44 then 0.2
when 45..64 then 0.5
when 65..79 then 0.7
else 1.0
end
end
def comorbidity_score(_, records)
count = records.sum { |r| r.diagnoses.count }
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score(_, records)
records
.filter_map { |r| r.vitals&.abnormality_index }
.then { |indices| indices.empty? ? 0.0 : indices.sum / indices.size }
end
def history_score(_, records)
records.any? { |r| r.hospitalization? } ? 0.8 : 0.1
end
end
class PediatricRiskScorer < WeightedRiskScorer
WEIGHTS = {
age: 0.10,
comorbidity: 0.35,
vitals: 0.45,
history: 0.10
}.freeze
private
def age_score(patient, _)
# Children are always considered elevated
patient.age < 5 ? 0.6 : 0.3
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# Assembles a PatientContext from raw ActiveRecord
# objects. Single Responsibility: knows nothing about
# scoring or notification — only about construction.
class PatientContextBuilder
def initialize(normalizer:, risk_scorer:)
@normalizer = normalizer
@risk_scorer = risk_scorer
end
# @param patient [Patient] — AR model
# @param records [Relation] — MedicalRecord AR scope
# @return [Domain::PatientContext]
def build(patient:, records:)
loaded = records.includes(:diagnoses, :vitals).to_a
Domain::PatientContext.new(
id: patient.public_id,
age: patient.age,
diagnoses: normalize_all(loaded),
vitals: latest_vitals(loaded),
risk_score: @risk_scorer.score(patient: patient, records: loaded),
flags: derive_flags(patient, loaded)
)
end
private
def normalize_all(records)
records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d.icd10_code) }
.sort
.uniq(&:code)
end
def latest_vitals(records)
records
.filter_map(&:vitals)
.max_by(&:recorded_at)
.then { |v| v&.stale? ? nil : v }
end
def derive_flags(patient, records)
Set.new.tap do |f|
f << :icu_candidate if records.any? { |r| r.vitals&.critical? }
f << :elderly if patient.age >= 65
f << :chronic if records.flat_map(&:diagnoses).any?(&:chronic?)
f << :high_risk if records.size > 5
end
end
end
# Open/Closed: new scoring strategies are added by
# subclassing WeightedRiskScorer, not by editing it.
class WeightedRiskScorer
WEIGHTS = {
age: 0.20,
comorbidity: 0.40,
vitals: 0.30,
history: 0.10
}.freeze
def score(patient:, records:)
WEIGHTS.sum { |factor, w| send(:"#{factor}_score", patient, records) * w }
.clamp(0.0, 1.0)
.round(4)
end
private
def age_score(patient, _)
case patient.age
when 0..17 then 0.1
when 18..44 then 0.2
when 45..64 then 0.5
when 65..79 then 0.7
else 1.0
end
end
def comorbidity_score(_, records)
count = records.sum { |r| r.diagnoses.count }
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score(_, records)
records
.filter_map { |r| r.vitals&.abnormality_index }
.then { |indices| indices.empty? ? 0.0 : indices.sum / indices.size }
end
def history_score(_, records)
records.any? { |r| r.hospitalization? } ? 0.8 : 0.1
end
end
class PediatricRiskScorer < WeightedRiskScorer
WEIGHTS = {
age: 0.10,
comorbidity: 0.35,
vitals: 0.45,
history: 0.10
}.freeze
private
def age_score(patient, _)
# Children are always considered elevated
patient.age < 5 ? 0.6 : 0.3
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# InferencePipeline chains callable steps.
# Each step receives a PatientContext and returns
# a (possibly enriched) PatientContext.
# Interface Segregation: steps only need #call.
class InferencePipeline
PipelineError = Class.new(StandardError)
def initialize(steps:, logger: Rails.logger)
raise ArgumentError, "steps cannot be empty" if steps.empty?
@steps = steps
@logger = logger
end
# @param context [Domain::PatientContext]
# @return [Domain::PatientContext]
def run(context)
@steps.each_with_object(context) do |step, ctx|
@logger.debug("[Pipeline] running #{step.class.name}")
result = step.call(ctx)
raise PipelineError, "#{step.class} returned nil" if result.nil?
ctx = result
end
end
end
module Steps
# Enriches context with AI-generated recommendations.
class RecommendationStep
def initialize(model_client:)
@client = model_client
end
def call(context)
recs = @client.infer(
diagnoses: context.diagnoses.map(&:code),
risk_score: context.risk_score,
flags: context.flags.to_a
)
context # pipeline returns same context; recs stored separately
end
end
# Flags the context if risk threshold is exceeded.
class FlaggingStep
THRESHOLD = 0.75
def call(context)
return context if context.risk_score < THRESHOLD
Domain::PatientContext.new(
**context.to_h.merge(flags: context.flags | [:auto_flagged])
)
end
end
end
# PatientAnalysisService is the application entry-point.
# Dependency Inversion: all collaborators are injected.
class PatientAnalysisService
def initialize(builder:, pipeline:, notifiers: [], logger: Rails.logger)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
@logger = logger
end
# @param patient [Patient]
# @param records [ActiveRecord::Relation]
# @return [Domain::AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(patient: patient, records: records)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
build_result(result)
rescue InferencePipeline::PipelineError => e
@logger.error("[AnalysisService] pipeline failure: #{e.message}")
broadcast(:analysis_failed, nil)
raise
end
private
def build_result(context)
Domain::AnalysisResult.new(
context: context,
recommendations: [], # populated by RecommendationStep
flagged: context.flagged?,
model_version: ENV.fetch("MODEL_VERSION", "1.0.0"),
created_at: Time.now.utc
)
end
def broadcast(event, context)
@notifiers.each do |n|
n.notify(context: context, event: event)
rescue Notifiers::NotificationError => e
@logger.warn("[AnalysisService] notifier #{n.class} failed: #{e.message}")
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# InferencePipeline chains callable steps.
# Each step receives a PatientContext and returns
# a (possibly enriched) PatientContext.
# Interface Segregation: steps only need #call.
class InferencePipeline
PipelineError = Class.new(StandardError)
def initialize(steps:, logger: Rails.logger)
raise ArgumentError, "steps cannot be empty" if steps.empty?
@steps = steps
@logger = logger
end
# @param context [Domain::PatientContext]
# @return [Domain::PatientContext]
def run(context)
@steps.each_with_object(context) do |step, ctx|
@logger.debug("[Pipeline] running #{step.class.name}")
result = step.call(ctx)
raise PipelineError, "#{step.class} returned nil" if result.nil?
ctx = result
end
end
end
module Steps
# Enriches context with AI-generated recommendations.
class RecommendationStep
def initialize(model_client:)
@client = model_client
end
def call(context)
recs = @client.infer(
diagnoses: context.diagnoses.map(&:code),
risk_score: context.risk_score,
flags: context.flags.to_a
)
context # pipeline returns same context; recs stored separately
end
end
# Flags the context if risk threshold is exceeded.
class FlaggingStep
THRESHOLD = 0.75
def call(context)
return context if context.risk_score < THRESHOLD
Domain::PatientContext.new(
**context.to_h.merge(flags: context.flags | [:auto_flagged])
)
end
end
end
# PatientAnalysisService is the application entry-point.
# Dependency Inversion: all collaborators are injected.
class PatientAnalysisService
def initialize(builder:, pipeline:, notifiers: [], logger: Rails.logger)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
@logger = logger
end
# @param patient [Patient]
# @param records [ActiveRecord::Relation]
# @return [Domain::AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(patient: patient, records: records)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
build_result(result)
rescue InferencePipeline::PipelineError => e
@logger.error("[AnalysisService] pipeline failure: #{e.message}")
broadcast(:analysis_failed, nil)
raise
end
private
def build_result(context)
Domain::AnalysisResult.new(
context: context,
recommendations: [], # populated by RecommendationStep
flagged: context.flagged?,
model_version: ENV.fetch("MODEL_VERSION", "1.0.0"),
created_at: Time.now.utc
)
end
def broadcast(event, context)
@notifiers.each do |n|
n.notify(context: context, event: event)
rescue Notifiers::NotificationError => e
@logger.warn("[AnalysisService] notifier #{n.class} failed: #{e.message}")
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# InferencePipeline chains callable steps.
# Each step receives a PatientContext and returns
# a (possibly enriched) PatientContext.
# Interface Segregation: steps only need #call.
class InferencePipeline
PipelineError = Class.new(StandardError)
def initialize(steps:, logger: Rails.logger)
raise ArgumentError, "steps cannot be empty" if steps.empty?
@steps = steps
@logger = logger
end
# @param context [Domain::PatientContext]
# @return [Domain::PatientContext]
def run(context)
@steps.each_with_object(context) do |step, ctx|
@logger.debug("[Pipeline] running #{step.class.name}")
result = step.call(ctx)
raise PipelineError, "#{step.class} returned nil" if result.nil?
ctx = result
end
end
end
module Steps
# Enriches context with AI-generated recommendations.
class RecommendationStep
def initialize(model_client:)
@client = model_client
end
def call(context)
recs = @client.infer(
diagnoses: context.diagnoses.map(&:code),
risk_score: context.risk_score,
flags: context.flags.to_a
)
context # pipeline returns same context; recs stored separately
end
end
# Flags the context if risk threshold is exceeded.
class FlaggingStep
THRESHOLD = 0.75
def call(context)
return context if context.risk_score < THRESHOLD
Domain::PatientContext.new(
**context.to_h.merge(flags: context.flags | [:auto_flagged])
)
end
end
end
# PatientAnalysisService is the application entry-point.
# Dependency Inversion: all collaborators are injected.
class PatientAnalysisService
def initialize(builder:, pipeline:, notifiers: [], logger: Rails.logger)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
@logger = logger
end
# @param patient [Patient]
# @param records [ActiveRecord::Relation]
# @return [Domain::AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(patient: patient, records: records)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
build_result(result)
rescue InferencePipeline::PipelineError => e
@logger.error("[AnalysisService] pipeline failure: #{e.message}")
broadcast(:analysis_failed, nil)
raise
end
private
def build_result(context)
Domain::AnalysisResult.new(
context: context,
recommendations: [], # populated by RecommendationStep
flagged: context.flagged?,
model_version: ENV.fetch("MODEL_VERSION", "1.0.0"),
created_at: Time.now.utc
)
end
def broadcast(event, context)
@notifiers.each do |n|
n.notify(context: context, event: event)
rescue Notifiers::NotificationError => e
@logger.warn("[AnalysisService] notifier #{n.class} failed: #{e.message}")
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
# InferencePipeline chains callable steps.
# Each step receives a PatientContext and returns
# a (possibly enriched) PatientContext.
# Interface Segregation: steps only need #call.
class InferencePipeline
PipelineError = Class.new(StandardError)
def initialize(steps:, logger: Rails.logger)
raise ArgumentError, "steps cannot be empty" if steps.empty?
@steps = steps
@logger = logger
end
# @param context [Domain::PatientContext]
# @return [Domain::PatientContext]
def run(context)
@steps.each_with_object(context) do |step, ctx|
@logger.debug("[Pipeline] running #{step.class.name}")
result = step.call(ctx)
raise PipelineError, "#{step.class} returned nil" if result.nil?
ctx = result
end
end
end
module Steps
# Enriches context with AI-generated recommendations.
class RecommendationStep
def initialize(model_client:)
@client = model_client
end
def call(context)
recs = @client.infer(
diagnoses: context.diagnoses.map(&:code),
risk_score: context.risk_score,
flags: context.flags.to_a
)
context # pipeline returns same context; recs stored separately
end
end
# Flags the context if risk threshold is exceeded.
class FlaggingStep
THRESHOLD = 0.75
def call(context)
return context if context.risk_score < THRESHOLD
Domain::PatientContext.new(
**context.to_h.merge(flags: context.flags | [:auto_flagged])
)
end
end
end
# PatientAnalysisService is the application entry-point.
# Dependency Inversion: all collaborators are injected.
class PatientAnalysisService
def initialize(builder:, pipeline:, notifiers: [], logger: Rails.logger)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
@logger = logger
end
# @param patient [Patient]
# @param records [ActiveRecord::Relation]
# @return [Domain::AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(patient: patient, records: records)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
build_result(result)
rescue InferencePipeline::PipelineError => e
@logger.error("[AnalysisService] pipeline failure: #{e.message}")
broadcast(:analysis_failed, nil)
raise
end
private
def build_result(context)
Domain::AnalysisResult.new(
context: context,
recommendations: [], # populated by RecommendationStep
flagged: context.flagged?,
model_version: ENV.fetch("MODEL_VERSION", "1.0.0"),
created_at: Time.now.utc
)
end
def broadcast(event, context)
@notifiers.each do |n|
n.notify(context: context, event: event)
rescue Notifiers::NotificationError => e
@logger.warn("[AnalysisService] notifier #{n.class} failed: #{e.message}")
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
module Notifiers
NotificationError = Class.new(StandardError)
# Liskov Substitution: any Notifier responds to
# #notify(context:, event:) with identical semantics.
module Base
def notify(context:, event:)
raise NotImplementedError, "#{self.class}#notify not implemented"
end
end
class SlackNotifier
include Base
EMOJI = {
analysis_complete: ":white_check_mark:",
analysis_failed: ":x:",
high_risk_alert: ":rotating_light:"
}.freeze
def initialize(client:, channel:)
@client = client # Slack::Web::Client
@channel = channel
end
def notify(context:, event:)
@client.chat_postMessage(
channel: @channel,
text: format(context, event),
username: "Healthcare AI Bot",
icon_emoji: ":hospital:"
)
rescue Slack::Web::Api::Errors::SlackError => e
raise NotificationError, "Slack error: #{e.message}"
end
private
def format(ctx, event)
emoji = EMOJI.fetch(event, ":bell:")
return "#{emoji} Analysis failed" unless ctx
"#{emoji} *#{event.to_s.humanize}* — " \
"Patient `#{ctx.id}` | " \
"Risk: *#{(ctx.risk_score * 100).round}%* | " \
"Flags: #{ctx.flags.map(&:to_s).join(', ').presence || 'none'}"
end
end
class AuditLogNotifier
include Base
def initialize(logger: Rails.logger)
@logger = logger
end
def notify(context:, event:)
@logger.info(
message: "[Audit] #{event}",
patient_id: context&.id,
risk_score: context&.risk_score,
flags: context&.flags&.to_a,
timestamp: Time.now.utc.iso8601
)
end
end
class WebhookNotifier
include Base
def initialize(url:, secret:, http: Faraday)
@url = url
@secret = secret
@http = http
end
def notify(context:, event:)
payload = build_payload(context, event)
sig = sign(payload)
@http.post(@url) do |req|
req.headers["X-Signature"] = sig
req.headers["Content-Type"] = "application/json"
req.body = payload.to_json
end
rescue Faraday::Error => e
raise NotificationError, "Webhook failed: #{e.message}"
end
private
def build_payload(ctx, event)
{
event: event,
patient_id: ctx&.id,
risk_score: ctx&.risk_score,
flagged: ctx&.flagged?,
issued_at: Time.now.utc.iso8601
}
end
def sign(payload)
OpenSSL::HMAC.hexdigest("SHA256", @secret, payload.to_json)
end
end
end
end
end
module Healthcare
module Jobs
class AnalysisJob < ApplicationJob
queue_as :critical
retry_on StandardError, wait: :polynomially_longer, attempts: 3
def perform(patient_id)
patient = Patient.find(patient_id)
records = MedicalRecord.where(patient: patient)
.recent
.with_vitals
Rails.configuration.analysis_service
.analyze(patient: patient, records: records)
.tap { |r| AnalysisResultMailer.summary(r).deliver_later if r.flagged? }
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
module Notifiers
NotificationError = Class.new(StandardError)
# Liskov Substitution: any Notifier responds to
# #notify(context:, event:) with identical semantics.
module Base
def notify(context:, event:)
raise NotImplementedError, "#{self.class}#notify not implemented"
end
end
class SlackNotifier
include Base
EMOJI = {
analysis_complete: ":white_check_mark:",
analysis_failed: ":x:",
high_risk_alert: ":rotating_light:"
}.freeze
def initialize(client:, channel:)
@client = client # Slack::Web::Client
@channel = channel
end
def notify(context:, event:)
@client.chat_postMessage(
channel: @channel,
text: format(context, event),
username: "Healthcare AI Bot",
icon_emoji: ":hospital:"
)
rescue Slack::Web::Api::Errors::SlackError => e
raise NotificationError, "Slack error: #{e.message}"
end
private
def format(ctx, event)
emoji = EMOJI.fetch(event, ":bell:")
return "#{emoji} Analysis failed" unless ctx
"#{emoji} *#{event.to_s.humanize}* — " \
"Patient `#{ctx.id}` | " \
"Risk: *#{(ctx.risk_score * 100).round}%* | " \
"Flags: #{ctx.flags.map(&:to_s).join(', ').presence || 'none'}"
end
end
class AuditLogNotifier
include Base
def initialize(logger: Rails.logger)
@logger = logger
end
def notify(context:, event:)
@logger.info(
message: "[Audit] #{event}",
patient_id: context&.id,
risk_score: context&.risk_score,
flags: context&.flags&.to_a,
timestamp: Time.now.utc.iso8601
)
end
end
class WebhookNotifier
include Base
def initialize(url:, secret:, http: Faraday)
@url = url
@secret = secret
@http = http
end
def notify(context:, event:)
payload = build_payload(context, event)
sig = sign(payload)
@http.post(@url) do |req|
req.headers["X-Signature"] = sig
req.headers["Content-Type"] = "application/json"
req.body = payload.to_json
end
rescue Faraday::Error => e
raise NotificationError, "Webhook failed: #{e.message}"
end
private
def build_payload(ctx, event)
{
event: event,
patient_id: ctx&.id,
risk_score: ctx&.risk_score,
flagged: ctx&.flagged?,
issued_at: Time.now.utc.iso8601
}
end
def sign(payload)
OpenSSL::HMAC.hexdigest("SHA256", @secret, payload.to_json)
end
end
end
end
end
module Healthcare
module Jobs
class AnalysisJob < ApplicationJob
queue_as :critical
retry_on StandardError, wait: :polynomially_longer, attempts: 3
def perform(patient_id)
patient = Patient.find(patient_id)
records = MedicalRecord.where(patient: patient)
.recent
.with_vitals
Rails.configuration.analysis_service
.analyze(patient: patient, records: records)
.tap { |r| AnalysisResultMailer.summary(r).deliver_later if r.flagged? }
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
module Notifiers
NotificationError = Class.new(StandardError)
# Liskov Substitution: any Notifier responds to
# #notify(context:, event:) with identical semantics.
module Base
def notify(context:, event:)
raise NotImplementedError, "#{self.class}#notify not implemented"
end
end
class SlackNotifier
include Base
EMOJI = {
analysis_complete: ":white_check_mark:",
analysis_failed: ":x:",
high_risk_alert: ":rotating_light:"
}.freeze
def initialize(client:, channel:)
@client = client # Slack::Web::Client
@channel = channel
end
def notify(context:, event:)
@client.chat_postMessage(
channel: @channel,
text: format(context, event),
username: "Healthcare AI Bot",
icon_emoji: ":hospital:"
)
rescue Slack::Web::Api::Errors::SlackError => e
raise NotificationError, "Slack error: #{e.message}"
end
private
def format(ctx, event)
emoji = EMOJI.fetch(event, ":bell:")
return "#{emoji} Analysis failed" unless ctx
"#{emoji} *#{event.to_s.humanize}* — " \
"Patient `#{ctx.id}` | " \
"Risk: *#{(ctx.risk_score * 100).round}%* | " \
"Flags: #{ctx.flags.map(&:to_s).join(', ').presence || 'none'}"
end
end
class AuditLogNotifier
include Base
def initialize(logger: Rails.logger)
@logger = logger
end
def notify(context:, event:)
@logger.info(
message: "[Audit] #{event}",
patient_id: context&.id,
risk_score: context&.risk_score,
flags: context&.flags&.to_a,
timestamp: Time.now.utc.iso8601
)
end
end
class WebhookNotifier
include Base
def initialize(url:, secret:, http: Faraday)
@url = url
@secret = secret
@http = http
end
def notify(context:, event:)
payload = build_payload(context, event)
sig = sign(payload)
@http.post(@url) do |req|
req.headers["X-Signature"] = sig
req.headers["Content-Type"] = "application/json"
req.body = payload.to_json
end
rescue Faraday::Error => e
raise NotificationError, "Webhook failed: #{e.message}"
end
private
def build_payload(ctx, event)
{
event: event,
patient_id: ctx&.id,
risk_score: ctx&.risk_score,
flagged: ctx&.flagged?,
issued_at: Time.now.utc.iso8601
}
end
def sign(payload)
OpenSSL::HMAC.hexdigest("SHA256", @secret, payload.to_json)
end
end
end
end
end
module Healthcare
module Jobs
class AnalysisJob < ApplicationJob
queue_as :critical
retry_on StandardError, wait: :polynomially_longer, attempts: 3
def perform(patient_id)
patient = Patient.find(patient_id)
records = MedicalRecord.where(patient: patient)
.recent
.with_vitals
Rails.configuration.analysis_service
.analyze(patient: patient, records: records)
.tap { |r| AnalysisResultMailer.summary(r).deliver_later if r.flagged? }
end
end
end
end
# frozen_string_literal: true
module Healthcare
module Application
module Notifiers
NotificationError = Class.new(StandardError)
# Liskov Substitution: any Notifier responds to
# #notify(context:, event:) with identical semantics.
module Base
def notify(context:, event:)
raise NotImplementedError, "#{self.class}#notify not implemented"
end
end
class SlackNotifier
include Base
EMOJI = {
analysis_complete: ":white_check_mark:",
analysis_failed: ":x:",
high_risk_alert: ":rotating_light:"
}.freeze
def initialize(client:, channel:)
@client = client # Slack::Web::Client
@channel = channel
end
def notify(context:, event:)
@client.chat_postMessage(
channel: @channel,
text: format(context, event),
username: "Healthcare AI Bot",
icon_emoji: ":hospital:"
)
rescue Slack::Web::Api::Errors::SlackError => e
raise NotificationError, "Slack error: #{e.message}"
end
private
def format(ctx, event)
emoji = EMOJI.fetch(event, ":bell:")
return "#{emoji} Analysis failed" unless ctx
"#{emoji} *#{event.to_s.humanize}* — " \
"Patient `#{ctx.id}` | " \
"Risk: *#{(ctx.risk_score * 100).round}%* | " \
"Flags: #{ctx.flags.map(&:to_s).join(', ').presence || 'none'}"
end
end
class AuditLogNotifier
include Base
def initialize(logger: Rails.logger)
@logger = logger
end
def notify(context:, event:)
@logger.info(
message: "[Audit] #{event}",
patient_id: context&.id,
risk_score: context&.risk_score,
flags: context&.flags&.to_a,
timestamp: Time.now.utc.iso8601
)
end
end
class WebhookNotifier
include Base
def initialize(url:, secret:, http: Faraday)
@url = url
@secret = secret
@http = http
end
def notify(context:, event:)
payload = build_payload(context, event)
sig = sign(payload)
@http.post(@url) do |req|
req.headers["X-Signature"] = sig
req.headers["Content-Type"] = "application/json"
req.body = payload.to_json
end
rescue Faraday::Error => e
raise NotificationError, "Webhook failed: #{e.message}"
end
private
def build_payload(ctx, event)
{
event: event,
patient_id: ctx&.id,
risk_score: ctx&.risk_score,
flagged: ctx&.flagged?,
issued_at: Time.now.utc.iso8601
}
end
def sign(payload)
OpenSSL::HMAC.hexdigest("SHA256", @secret, payload.to_json)
end
end
end
end
end
module Healthcare
module Jobs
class AnalysisJob < ApplicationJob
queue_as :critical
retry_on StandardError, wait: :polynomially_longer, attempts: 3
def perform(patient_id)
patient = Patient.find(patient_id)
records = MedicalRecord.where(patient: patient)
.recent
.with_vitals
Rails.configuration.analysis_service
.analyze(patient: patient, records: records)
.tap { |r| AnalysisResultMailer.summary(r).deliver_later if r.flagged? }
end
end
end
end
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Healthcare::Application::WeightedRiskScorer do
subject(:scorer) { described_class.new }
describe "#score" do
let(:patient) { build(:patient, age: age) }
let(:records) { build_list(:medical_record, record_count, :with_vitals) }
context "when patient is elderly with multiple comorbidities" do
let(:age) { 72 }
let(:record_count) { 8 }
it "returns a high risk score" do
expect(scorer.score(patient: patient, records: records)).to be > 0.7
end
end
context "when patient is young and healthy" do
let(:age) { 28 }
let(:record_count) { 1 }
it "returns a low risk score" do
expect(scorer.score(patient: patient, records: records)).to be < 0.3
end
end
context "with critical vitals" do
let(:age) { 45 }
let(:records) do
[build(:medical_record, vitals: build(:vitals, :critical))]
end
it "elevates the score significantly" do
expect(scorer.score(patient: patient, records: records)).to be >= 0.5
end
end
end
end
RSpec.describe Healthcare::Application::PatientContextBuilder do
subject(:builder) do
described_class.new(
normalizer: normalizer,
risk_scorer: scorer
)
end
let(:normalizer) { instance_double(Healthcare::Domain::ICD10Normalizer) }
let(:scorer) { instance_double(Healthcare::Application::WeightedRiskScorer, score: 0.42) }
let(:patient) { build(:patient, age: 38) }
let(:records) { build_list(:medical_record, 2, :with_diagnoses, :with_vitals) }
before do
allow(normalizer).to receive(:call).and_return(
build(:diagnosis, code: "E11.9", severity: :moderate)
)
end
describe "#build" do
subject(:context) { builder.build(patient: patient, records: records) }
it "returns a PatientContext" do
expect(context).to be_a(Healthcare::Domain::PatientContext)
end
it "sets risk_score from scorer" do
expect(context.risk_score).to eq(0.42)
end
it "deduplicates diagnoses by code" do
expect(context.diagnoses.map(&:code).uniq).to eq(context.diagnoses.map(&:code))
end
it "omits stale vitals" do
allow(records.last.vitals).to receive(:stale?).and_return(true)
expect(context.vitals).to be_nil
end
end
end
RSpec.describe Healthcare::Application::PatientAnalysisService do
subject(:service) do
described_class.new(
builder: builder,
pipeline: pipeline,
notifiers: [notifier],
logger: logger
)
end
let(:builder) { instance_double(Healthcare::Application::PatientContextBuilder) }
let(:pipeline) { instance_double(Healthcare::Application::InferencePipeline) }
let(:notifier) { instance_double(Healthcare::Application::Notifiers::AuditLogNotifier) }
let(:logger) { instance_double(ActiveSupport::Logger, info: nil, error: nil, warn: nil) }
let(:context) { build(:patient_context, risk_score: 0.8, flagged: true) }
let(:patient) { build(:patient) }
let(:records) { MedicalRecord.none }
before do
allow(builder).to receive(:build).and_return(context)
allow(pipeline).to receive(:run).and_return(context)
allow(notifier).to receive(:notify)
end
describe "#analyze" do
it "broadcasts :analysis_complete on success" do
expect(notifier).to receive(:notify).with(context: context, event: :analysis_complete)
service.analyze(patient: patient, records: records)
end
it "returns a flagged AnalysisResult when risk is high" do
result = service.analyze(patient: patient, records: records)
expect(result).to be_flagged
end
context "when pipeline raises" do
before { allow(pipeline).to receive(:run).and_raise(Healthcare::Application::InferencePipeline::PipelineError) }
it "broadcasts :analysis_failed" do
expect(notifier).to receive(:notify).with(context: nil, event: :analysis_failed)
expect { service.analyze(patient: patient, records: records) }
.to raise_error(Healthcare::Application::InferencePipeline::PipelineError)
end
end
end
end
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Healthcare::Application::WeightedRiskScorer do
subject(:scorer) { described_class.new }
describe "#score" do
let(:patient) { build(:patient, age: age) }
let(:records) { build_list(:medical_record, record_count, :with_vitals) }
context "when patient is elderly with multiple comorbidities" do
let(:age) { 72 }
let(:record_count) { 8 }
it "returns a high risk score" do
expect(scorer.score(patient: patient, records: records)).to be > 0.7
end
end
context "when patient is young and healthy" do
let(:age) { 28 }
let(:record_count) { 1 }
it "returns a low risk score" do
expect(scorer.score(patient: patient, records: records)).to be < 0.3
end
end
context "with critical vitals" do
let(:age) { 45 }
let(:records) do
[build(:medical_record, vitals: build(:vitals, :critical))]
end
it "elevates the score significantly" do
expect(scorer.score(patient: patient, records: records)).to be >= 0.5
end
end
end
end
RSpec.describe Healthcare::Application::PatientContextBuilder do
subject(:builder) do
described_class.new(
normalizer: normalizer,
risk_scorer: scorer
)
end
let(:normalizer) { instance_double(Healthcare::Domain::ICD10Normalizer) }
let(:scorer) { instance_double(Healthcare::Application::WeightedRiskScorer, score: 0.42) }
let(:patient) { build(:patient, age: 38) }
let(:records) { build_list(:medical_record, 2, :with_diagnoses, :with_vitals) }
before do
allow(normalizer).to receive(:call).and_return(
build(:diagnosis, code: "E11.9", severity: :moderate)
)
end
describe "#build" do
subject(:context) { builder.build(patient: patient, records: records) }
it "returns a PatientContext" do
expect(context).to be_a(Healthcare::Domain::PatientContext)
end
it "sets risk_score from scorer" do
expect(context.risk_score).to eq(0.42)
end
it "deduplicates diagnoses by code" do
expect(context.diagnoses.map(&:code).uniq).to eq(context.diagnoses.map(&:code))
end
it "omits stale vitals" do
allow(records.last.vitals).to receive(:stale?).and_return(true)
expect(context.vitals).to be_nil
end
end
end
RSpec.describe Healthcare::Application::PatientAnalysisService do
subject(:service) do
described_class.new(
builder: builder,
pipeline: pipeline,
notifiers: [notifier],
logger: logger
)
end
let(:builder) { instance_double(Healthcare::Application::PatientContextBuilder) }
let(:pipeline) { instance_double(Healthcare::Application::InferencePipeline) }
let(:notifier) { instance_double(Healthcare::Application::Notifiers::AuditLogNotifier) }
let(:logger) { instance_double(ActiveSupport::Logger, info: nil, error: nil, warn: nil) }
let(:context) { build(:patient_context, risk_score: 0.8, flagged: true) }
let(:patient) { build(:patient) }
let(:records) { MedicalRecord.none }
before do
allow(builder).to receive(:build).and_return(context)
allow(pipeline).to receive(:run).and_return(context)
allow(notifier).to receive(:notify)
end
describe "#analyze" do
it "broadcasts :analysis_complete on success" do
expect(notifier).to receive(:notify).with(context: context, event: :analysis_complete)
service.analyze(patient: patient, records: records)
end
it "returns a flagged AnalysisResult when risk is high" do
result = service.analyze(patient: patient, records: records)
expect(result).to be_flagged
end
context "when pipeline raises" do
before { allow(pipeline).to receive(:run).and_raise(Healthcare::Application::InferencePipeline::PipelineError) }
it "broadcasts :analysis_failed" do
expect(notifier).to receive(:notify).with(context: nil, event: :analysis_failed)
expect { service.analyze(patient: patient, records: records) }
.to raise_error(Healthcare::Application::InferencePipeline::PipelineError)
end
end
end
end
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Healthcare::Application::WeightedRiskScorer do
subject(:scorer) { described_class.new }
describe "#score" do
let(:patient) { build(:patient, age: age) }
let(:records) { build_list(:medical_record, record_count, :with_vitals) }
context "when patient is elderly with multiple comorbidities" do
let(:age) { 72 }
let(:record_count) { 8 }
it "returns a high risk score" do
expect(scorer.score(patient: patient, records: records)).to be > 0.7
end
end
context "when patient is young and healthy" do
let(:age) { 28 }
let(:record_count) { 1 }
it "returns a low risk score" do
expect(scorer.score(patient: patient, records: records)).to be < 0.3
end
end
context "with critical vitals" do
let(:age) { 45 }
let(:records) do
[build(:medical_record, vitals: build(:vitals, :critical))]
end
it "elevates the score significantly" do
expect(scorer.score(patient: patient, records: records)).to be >= 0.5
end
end
end
end
RSpec.describe Healthcare::Application::PatientContextBuilder do
subject(:builder) do
described_class.new(
normalizer: normalizer,
risk_scorer: scorer
)
end
let(:normalizer) { instance_double(Healthcare::Domain::ICD10Normalizer) }
let(:scorer) { instance_double(Healthcare::Application::WeightedRiskScorer, score: 0.42) }
let(:patient) { build(:patient, age: 38) }
let(:records) { build_list(:medical_record, 2, :with_diagnoses, :with_vitals) }
before do
allow(normalizer).to receive(:call).and_return(
build(:diagnosis, code: "E11.9", severity: :moderate)
)
end
describe "#build" do
subject(:context) { builder.build(patient: patient, records: records) }
it "returns a PatientContext" do
expect(context).to be_a(Healthcare::Domain::PatientContext)
end
it "sets risk_score from scorer" do
expect(context.risk_score).to eq(0.42)
end
it "deduplicates diagnoses by code" do
expect(context.diagnoses.map(&:code).uniq).to eq(context.diagnoses.map(&:code))
end
it "omits stale vitals" do
allow(records.last.vitals).to receive(:stale?).and_return(true)
expect(context.vitals).to be_nil
end
end
end
RSpec.describe Healthcare::Application::PatientAnalysisService do
subject(:service) do
described_class.new(
builder: builder,
pipeline: pipeline,
notifiers: [notifier],
logger: logger
)
end
let(:builder) { instance_double(Healthcare::Application::PatientContextBuilder) }
let(:pipeline) { instance_double(Healthcare::Application::InferencePipeline) }
let(:notifier) { instance_double(Healthcare::Application::Notifiers::AuditLogNotifier) }
let(:logger) { instance_double(ActiveSupport::Logger, info: nil, error: nil, warn: nil) }
let(:context) { build(:patient_context, risk_score: 0.8, flagged: true) }
let(:patient) { build(:patient) }
let(:records) { MedicalRecord.none }
before do
allow(builder).to receive(:build).and_return(context)
allow(pipeline).to receive(:run).and_return(context)
allow(notifier).to receive(:notify)
end
describe "#analyze" do
it "broadcasts :analysis_complete on success" do
expect(notifier).to receive(:notify).with(context: context, event: :analysis_complete)
service.analyze(patient: patient, records: records)
end
it "returns a flagged AnalysisResult when risk is high" do
result = service.analyze(patient: patient, records: records)
expect(result).to be_flagged
end
context "when pipeline raises" do
before { allow(pipeline).to receive(:run).and_raise(Healthcare::Application::InferencePipeline::PipelineError) }
it "broadcasts :analysis_failed" do
expect(notifier).to receive(:notify).with(context: nil, event: :analysis_failed)
expect { service.analyze(patient: patient, records: records) }
.to raise_error(Healthcare::Application::InferencePipeline::PipelineError)
end
end
end
end
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Healthcare::Application::WeightedRiskScorer do
subject(:scorer) { described_class.new }
describe "#score" do
let(:patient) { build(:patient, age: age) }
let(:records) { build_list(:medical_record, record_count, :with_vitals) }
context "when patient is elderly with multiple comorbidities" do
let(:age) { 72 }
let(:record_count) { 8 }
it "returns a high risk score" do
expect(scorer.score(patient: patient, records: records)).to be > 0.7
end
end
context "when patient is young and healthy" do
let(:age) { 28 }
let(:record_count) { 1 }
it "returns a low risk score" do
expect(scorer.score(patient: patient, records: records)).to be < 0.3
end
end
context "with critical vitals" do
let(:age) { 45 }
let(:records) do
[build(:medical_record, vitals: build(:vitals, :critical))]
end
it "elevates the score significantly" do
expect(scorer.score(patient: patient, records: records)).to be >= 0.5
end
end
end
end
RSpec.describe Healthcare::Application::PatientContextBuilder do
subject(:builder) do
described_class.new(
normalizer: normalizer,
risk_scorer: scorer
)
end
let(:normalizer) { instance_double(Healthcare::Domain::ICD10Normalizer) }
let(:scorer) { instance_double(Healthcare::Application::WeightedRiskScorer, score: 0.42) }
let(:patient) { build(:patient, age: 38) }
let(:records) { build_list(:medical_record, 2, :with_diagnoses, :with_vitals) }
before do
allow(normalizer).to receive(:call).and_return(
build(:diagnosis, code: "E11.9", severity: :moderate)
)
end
describe "#build" do
subject(:context) { builder.build(patient: patient, records: records) }
it "returns a PatientContext" do
expect(context).to be_a(Healthcare::Domain::PatientContext)
end
it "sets risk_score from scorer" do
expect(context.risk_score).to eq(0.42)
end
it "deduplicates diagnoses by code" do
expect(context.diagnoses.map(&:code).uniq).to eq(context.diagnoses.map(&:code))
end
it "omits stale vitals" do
allow(records.last.vitals).to receive(:stale?).and_return(true)
expect(context.vitals).to be_nil
end
end
end
RSpec.describe Healthcare::Application::PatientAnalysisService do
subject(:service) do
described_class.new(
builder: builder,
pipeline: pipeline,
notifiers: [notifier],
logger: logger
)
end
let(:builder) { instance_double(Healthcare::Application::PatientContextBuilder) }
let(:pipeline) { instance_double(Healthcare::Application::InferencePipeline) }
let(:notifier) { instance_double(Healthcare::Application::Notifiers::AuditLogNotifier) }
let(:logger) { instance_double(ActiveSupport::Logger, info: nil, error: nil, warn: nil) }
let(:context) { build(:patient_context, risk_score: 0.8, flagged: true) }
let(:patient) { build(:patient) }
let(:records) { MedicalRecord.none }
before do
allow(builder).to receive(:build).and_return(context)
allow(pipeline).to receive(:run).and_return(context)
allow(notifier).to receive(:notify)
end
describe "#analyze" do
it "broadcasts :analysis_complete on success" do
expect(notifier).to receive(:notify).with(context: context, event: :analysis_complete)
service.analyze(patient: patient, records: records)
end
it "returns a flagged AnalysisResult when risk is high" do
result = service.analyze(patient: patient, records: records)
expect(result).to be_flagged
end
context "when pipeline raises" do
before { allow(pipeline).to receive(:run).and_raise(Healthcare::Application::InferencePipeline::PipelineError) }
it "broadcasts :analysis_failed" do
expect(notifier).to receive(:notify).with(context: nil, event: :analysis_failed)
expect { service.analyze(patient: patient, records: records) }
.to raise_error(Healthcare::Application::InferencePipeline::PipelineError)
end
end
end
end