# frozen_string_literal: true
module Healthcare
module AI
# Responsible for building a structured
# patient context for inference pipelines.
#
# Single Responsibility: knows only how
# to assemble a PatientContext value object.
class PatientContextBuilder
def initialize(patient:, records:, normalizer:)
@patient = patient
@records = records
@normalizer = normalizer
end
# @return [PatientContext]
def build
PatientContext.new(
id: @patient.id,
age: @patient.age,
diagnoses: normalize_diagnoses,
vitals: latest_vitals,
risk_score: compute_risk
)
end
private
def normalize_diagnoses
@records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d) }
.uniq
end
def latest_vitals
@records
.filter_map(&:vitals)
.max_by(&:recorded_at)
end
def compute_risk
RiskScorer.new(@patient, @records).score
end
end
end
end
module Healthcare
module AI
# Open/Closed: extend scoring by subclassing,
# not by modifying existing scorer logic.
class RiskScorer
WEIGHTS = {
age: 0.3,
comorbidity: 0.5,
vitals: 0.2
}.freeze
def initialize(patient, records)
@patient = patient
@records = records
end
# @return [Float] 0.0..1.0
def score
WEIGHTS.sum do |factor, weight|
send(:"#{factor}_score") * weight
end.clamp(0.0, 1.0)
end
private
def age_score
(@patient.age / 100.0).clamp(0.0, 1.0)
end
def comorbidity_score
count = @records
.flat_map(&:diagnoses)
.size
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score
vitals = @records
.filter_map(&:vitals)
.last
return 0.0 unless vitals
vitals.abnormality_index
end
end
end
end
# frozen_string_literal: true
module Healthcare
module AI
# Responsible for building a structured
# patient context for inference pipelines.
#
# Single Responsibility: knows only how
# to assemble a PatientContext value object.
class PatientContextBuilder
def initialize(patient:, records:, normalizer:)
@patient = patient
@records = records
@normalizer = normalizer
end
# @return [PatientContext]
def build
PatientContext.new(
id: @patient.id,
age: @patient.age,
diagnoses: normalize_diagnoses,
vitals: latest_vitals,
risk_score: compute_risk
)
end
private
def normalize_diagnoses
@records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d) }
.uniq
end
def latest_vitals
@records
.filter_map(&:vitals)
.max_by(&:recorded_at)
end
def compute_risk
RiskScorer.new(@patient, @records).score
end
end
end
end
module Healthcare
module AI
# Open/Closed: extend scoring by subclassing,
# not by modifying existing scorer logic.
class RiskScorer
WEIGHTS = {
age: 0.3,
comorbidity: 0.5,
vitals: 0.2
}.freeze
def initialize(patient, records)
@patient = patient
@records = records
end
# @return [Float] 0.0..1.0
def score
WEIGHTS.sum do |factor, weight|
send(:"#{factor}_score") * weight
end.clamp(0.0, 1.0)
end
private
def age_score
(@patient.age / 100.0).clamp(0.0, 1.0)
end
def comorbidity_score
count = @records
.flat_map(&:diagnoses)
.size
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score
vitals = @records
.filter_map(&:vitals)
.last
return 0.0 unless vitals
vitals.abnormality_index
end
end
end
end
# frozen_string_literal: true
module Healthcare
module AI
# Responsible for building a structured
# patient context for inference pipelines.
#
# Single Responsibility: knows only how
# to assemble a PatientContext value object.
class PatientContextBuilder
def initialize(patient:, records:, normalizer:)
@patient = patient
@records = records
@normalizer = normalizer
end
# @return [PatientContext]
def build
PatientContext.new(
id: @patient.id,
age: @patient.age,
diagnoses: normalize_diagnoses,
vitals: latest_vitals,
risk_score: compute_risk
)
end
private
def normalize_diagnoses
@records
.flat_map(&:diagnoses)
.map { |d| @normalizer.call(d) }
.uniq
end
def latest_vitals
@records
.filter_map(&:vitals)
.max_by(&:recorded_at)
end
def compute_risk
RiskScorer.new(@patient, @records).score
end
end
end
end
module Healthcare
module AI
# Open/Closed: extend scoring by subclassing,
# not by modifying existing scorer logic.
class RiskScorer
WEIGHTS = {
age: 0.3,
comorbidity: 0.5,
vitals: 0.2
}.freeze
def initialize(patient, records)
@patient = patient
@records = records
end
# @return [Float] 0.0..1.0
def score
WEIGHTS.sum do |factor, weight|
send(:"#{factor}_score") * weight
end.clamp(0.0, 1.0)
end
private
def age_score
(@patient.age / 100.0).clamp(0.0, 1.0)
end
def comorbidity_score
count = @records
.flat_map(&:diagnoses)
.size
(count / 10.0).clamp(0.0, 1.0)
end
def vitals_score
vitals = @records
.filter_map(&:vitals)
.last
return 0.0 unless vitals
vitals.abnormality_index
end
end
end
end
# frozen_string_literal: true
module Healthcare
module AI
# Liskov Substitution: any Notifier can be
# swapped without altering calling code.
module Notifier
NotificationError = Class.new(StandardError)
# @param context [PatientContext]
# @param event [Symbol]
# @return [void]
def notify(context:, event:)
raise NotImplementedError,
"#{self.class} must implement #notify"
end
end
class SlackNotifier
include Notifier
def initialize(client:, channel:)
@client = client
@channel = channel
end
def notify(context:, event:)
@client.post(
channel: @channel,
text: format_message(context, event)
)
rescue => e
raise NotificationError, e.message
end
private
def format_message(ctx, event)
"[#{event.upcase}] Patient #{ctx.id} " \
"— Risk: #{(ctx.risk_score * 100).round}%"
end
end
class AuditLogNotifier
include Notifier
def initialize(logger:)
@logger = logger
end
def notify(context:, event:)
@logger.info(
event: event,
patient_id: context.id,
risk_score: context.risk_score,
timestamp: Time.now.utc.iso8601
)
end
end
# Interface Segregation: pipeline steps
# depend only on #call — nothing else.
class InferencePipeline
def initialize(steps:)
@steps = steps
end
# @param context [PatientContext]
# @return [PatientContext]
def run(context)
@steps.reduce(context) do |ctx, step|
step.call(ctx)
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module AI
# Liskov Substitution: any Notifier can be
# swapped without altering calling code.
module Notifier
NotificationError = Class.new(StandardError)
# @param context [PatientContext]
# @param event [Symbol]
# @return [void]
def notify(context:, event:)
raise NotImplementedError,
"#{self.class} must implement #notify"
end
end
class SlackNotifier
include Notifier
def initialize(client:, channel:)
@client = client
@channel = channel
end
def notify(context:, event:)
@client.post(
channel: @channel,
text: format_message(context, event)
)
rescue => e
raise NotificationError, e.message
end
private
def format_message(ctx, event)
"[#{event.upcase}] Patient #{ctx.id} " \
"— Risk: #{(ctx.risk_score * 100).round}%"
end
end
class AuditLogNotifier
include Notifier
def initialize(logger:)
@logger = logger
end
def notify(context:, event:)
@logger.info(
event: event,
patient_id: context.id,
risk_score: context.risk_score,
timestamp: Time.now.utc.iso8601
)
end
end
# Interface Segregation: pipeline steps
# depend only on #call — nothing else.
class InferencePipeline
def initialize(steps:)
@steps = steps
end
# @param context [PatientContext]
# @return [PatientContext]
def run(context)
@steps.reduce(context) do |ctx, step|
step.call(ctx)
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module AI
# Liskov Substitution: any Notifier can be
# swapped without altering calling code.
module Notifier
NotificationError = Class.new(StandardError)
# @param context [PatientContext]
# @param event [Symbol]
# @return [void]
def notify(context:, event:)
raise NotImplementedError,
"#{self.class} must implement #notify"
end
end
class SlackNotifier
include Notifier
def initialize(client:, channel:)
@client = client
@channel = channel
end
def notify(context:, event:)
@client.post(
channel: @channel,
text: format_message(context, event)
)
rescue => e
raise NotificationError, e.message
end
private
def format_message(ctx, event)
"[#{event.upcase}] Patient #{ctx.id} " \
"— Risk: #{(ctx.risk_score * 100).round}%"
end
end
class AuditLogNotifier
include Notifier
def initialize(logger:)
@logger = logger
end
def notify(context:, event:)
@logger.info(
event: event,
patient_id: context.id,
risk_score: context.risk_score,
timestamp: Time.now.utc.iso8601
)
end
end
# Interface Segregation: pipeline steps
# depend only on #call — nothing else.
class InferencePipeline
def initialize(steps:)
@steps = steps
end
# @param context [PatientContext]
# @return [PatientContext]
def run(context)
@steps.reduce(context) do |ctx, step|
step.call(ctx)
end
end
end
end
end
# frozen_string_literal: true
module Healthcare
module AI
# Dependency Inversion: high-level orchestrator
# depends on abstractions, not concrete classes.
class PatientAnalysisService
def initialize(
builder:,
pipeline:,
notifiers: []
)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
end
# @param patient [Patient]
# @param records [Array<MedicalRecord>]
# @return [AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(
patient: patient,
records: records
)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
AnalysisResult.new(
context: result,
flagged: result.risk_score > 0.75,
created_at: Time.now.utc
)
rescue => e
broadcast(:analysis_failed, nil)
raise
end
private
def broadcast(event, context)
@notifiers.each do |notifier|
notifier.notify(
context: context,
event: event
)
rescue Notifier::NotificationError => e
Rails.logger.warn(
"Notifier failed: #{e.message}"
)
end
end
end
# Value object — immutable analysis result.
AnalysisResult = Data.define(
:context,
:flagged,
:created_at
)
# Value object — immutable patient snapshot.
PatientContext = Data.define(
:id,
:age,
:diagnoses,
:vitals,
:risk_score
)
end
end
# Wiring (e.g. in a Rails initializer)
#
# Healthcare::AI::PatientAnalysisService.new(
# builder: PatientContextBuilder.new(...),
# pipeline: InferencePipeline.new(steps: [...]),
# notifiers: [
# SlackNotifier.new(client: ..., channel: "#alerts"),
# AuditLogNotifier.new(logger: Rails.logger)
# ]
# )
# frozen_string_literal: true
module Healthcare
module AI
# Dependency Inversion: high-level orchestrator
# depends on abstractions, not concrete classes.
class PatientAnalysisService
def initialize(
builder:,
pipeline:,
notifiers: []
)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
end
# @param patient [Patient]
# @param records [Array<MedicalRecord>]
# @return [AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(
patient: patient,
records: records
)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
AnalysisResult.new(
context: result,
flagged: result.risk_score > 0.75,
created_at: Time.now.utc
)
rescue => e
broadcast(:analysis_failed, nil)
raise
end
private
def broadcast(event, context)
@notifiers.each do |notifier|
notifier.notify(
context: context,
event: event
)
rescue Notifier::NotificationError => e
Rails.logger.warn(
"Notifier failed: #{e.message}"
)
end
end
end
# Value object — immutable analysis result.
AnalysisResult = Data.define(
:context,
:flagged,
:created_at
)
# Value object — immutable patient snapshot.
PatientContext = Data.define(
:id,
:age,
:diagnoses,
:vitals,
:risk_score
)
end
end
# Wiring (e.g. in a Rails initializer)
#
# Healthcare::AI::PatientAnalysisService.new(
# builder: PatientContextBuilder.new(...),
# pipeline: InferencePipeline.new(steps: [...]),
# notifiers: [
# SlackNotifier.new(client: ..., channel: "#alerts"),
# AuditLogNotifier.new(logger: Rails.logger)
# ]
# )
# frozen_string_literal: true
module Healthcare
module AI
# Dependency Inversion: high-level orchestrator
# depends on abstractions, not concrete classes.
class PatientAnalysisService
def initialize(
builder:,
pipeline:,
notifiers: []
)
@builder = builder
@pipeline = pipeline
@notifiers = notifiers
end
# @param patient [Patient]
# @param records [Array<MedicalRecord>]
# @return [AnalysisResult]
def analyze(patient:, records:)
context = @builder.build(
patient: patient,
records: records
)
result = @pipeline.run(context)
broadcast(:analysis_complete, result)
AnalysisResult.new(
context: result,
flagged: result.risk_score > 0.75,
created_at: Time.now.utc
)
rescue => e
broadcast(:analysis_failed, nil)
raise
end
private
def broadcast(event, context)
@notifiers.each do |notifier|
notifier.notify(
context: context,
event: event
)
rescue Notifier::NotificationError => e
Rails.logger.warn(
"Notifier failed: #{e.message}"
)
end
end
end
# Value object — immutable analysis result.
AnalysisResult = Data.define(
:context,
:flagged,
:created_at
)
# Value object — immutable patient snapshot.
PatientContext = Data.define(
:id,
:age,
:diagnoses,
:vitals,
:risk_score
)
end
end
# Wiring (e.g. in a Rails initializer)
#
# Healthcare::AI::PatientAnalysisService.new(
# builder: PatientContextBuilder.new(...),
# pipeline: InferencePipeline.new(steps: [...]),
# notifiers: [
# SlackNotifier.new(client: ..., channel: "#alerts"),
# AuditLogNotifier.new(logger: Rails.logger)
# ]
# )