Kulcare Docs
DeveloperCare plansCare planLhr

LHR Service Architecture - Implementation Guide

Overview

This document outlines the service architecture for implementing Longitudinal Health Records (LHR) in our Rails API. The approach enhances the existing patient history service while maintaining full backward compatibility with all current functionality.

Core Principle: Extend existing services without breaking changes. All current workflows continue to work exactly as before, with powerful LHR features added through background processing.

System Architecture Overview

The LHR system follows a layered architecture that builds upon our existing parts-based Rails structure:

┌─────────────────────────────────────────────────────────────┐
│                    Frontend Layer                           │
│  Enhanced Encounter Forms • LHR Dashboard • Health Summary  │
└─────────────────────────────────────────────────────────────┘
                                ↕
┌─────────────────────────────────────────────────────────────┐
│                   API Layer (Rails)                        │
│  Enhanced Existing APIs • New LHR Endpoints • Serializers  │
└─────────────────────────────────────────────────────────────┘
                                ↕
┌─────────────────────────────────────────────────────────────┐
│                 Service Layer (Interactors)                │
│     SaveHistory • LhrEnhancementJob • Background Queue     │
└─────────────────────────────────────────────────────────────┘
                                ↕
┌─────────────────────────────────────────────────────────────┐
│              Model Layer (ActiveRecord)                    │
│    Enhanced Encounters • New LHR Models • Associations     │
└─────────────────────────────────────────────────────────────┘
                                ↕
┌─────────────────────────────────────────────────────────────┐
│           Database Layer (PostgreSQL + Redis)              │
│    Enhanced Tables • New LHR Tables • Background Jobs      │
└─────────────────────────────────────────────────────────────┘

Core Service Components

1. Enhanced Patient History Service

The existing PatientHistory::SaveHistory service is extended to trigger LHR processing:

# Enhanced parts/clinic/services/patient_history/save_history.rb
module PatientHistory
  class SaveHistory < ActiveJob::Base
    queue_as :patient_history

    def self.perform(obj, key)
      # 1. MAINTAIN EXISTING FUNCTIONALITY (zero breaking changes)
      legacy_result = perform_legacy_save(obj, key)
      
      # 2. ADD LHR ENHANCEMENTS (only if legacy save succeeded)
      if legacy_result && should_enhance_longitudinally?(key)
        perform_lhr_enhancement(obj, key)
      end
      
      legacy_result
    rescue => e
      Rails.logger.error "Patient History Error: #{e.message}"
      return
    end

    private
    
    # All existing save methods remain unchanged
    def self.perform_legacy_save(obj, key)
      case key
      when 'APPOINTMENT'
        PatientHistory::SaveAppt.perform(obj)
      when 'CAREPLAN'
        PatientHistory::SaveCareplan.perform(obj)
      when 'PATIENT_PHOTO'
        PatientHistory::SavePatientPhoto.perform(obj)
      when 'PATIENT_VITAL_GROUP'
        PatientHistory::SavePatientVital.perform(obj)
      when 'PATIENT_VITAL'
        PatientHistory::SaveApptVital.perform(obj)
      when 'TELEMEDICINE'
        PatientHistory::SaveTelemedicinePrescription.perform(obj)
      when 'LAB_REPORT'
        PatientHistory::SaveLabReport.perform(obj)
      when 'THERAPY_NOTES'
        PatientHistory::SaveTherapyNotes.perform(obj)
      when 'PATIENT_NOTES'
        PatientHistory::SavePatientNotes.perform(obj)
      when 'IMMUNISATIONS'
        PatientHistory::SaveImmunisation.perform(obj)
      when 'SHARED_RECORDS'
        PatientHistory::SaveSharedRecord.perform(obj)
      when 'MEDICAL_RECORD'
        PatientHistory::SaveMedicalRecord.perform(obj)
      end
    end
    
    # NEW: Separate LHR enhancement services (follows same pattern as legacy services)
    def self.perform_lhr_enhancement(obj, key)
      case key
      when 'CAREPLAN'
        PatientHistory::LhrEncounterEnhancement.perform(obj)
      when 'PATIENT_VITAL_GROUP', 'PATIENT_VITAL'
        PatientHistory::LhrVitalEnhancement.perform(obj)
      when 'LAB_REPORT'
        PatientHistory::LhrLabReportEnhancement.perform(obj)
      when 'IMMUNISATIONS'
        PatientHistory::LhrImmunizationEnhancement.perform(obj)
      end
    end
    
    # Check if this event type should trigger LHR processing
    def self.should_enhance_longitudinally?(key)
      %w[CAREPLAN PATIENT_VITAL_GROUP PATIENT_VITAL LAB_REPORT IMMUNISATIONS].include?(key)
    end
  end
end

2. Separate LHR Enhancement Services

Following the same pattern as legacy services, we create dedicated LHR enhancement services:

# New service: parts/clinic/services/patient_history/lhr_encounter_enhancement.rb
module PatientHistory
  class LhrEncounterEnhancement
    def self.perform(encounter)
      return unless encounter.is_a?(Encounter) && encounter.persisted?
      
      # Fast field updates only (no heavy processing)
      encounter.update_columns(
        encounter_type: determine_encounter_type(encounter),
        visit_type: determine_visit_type(encounter),
        urgency: determine_urgency(encounter),
        # Note: reconciliation_completed field removed - using simple continuity
      )
      
      # Queue background job for heavy processing
      LhrEnhancementJob.perform_later(encounter.id, 'ENCOUNTER_CREATED')
    rescue => e
      Rails.logger.error "LHR encounter enhancement failed: #{e.message}"
      # Don't raise - LHR failure shouldn't break main flow
    end
    
    private
    
    def self.determine_encounter_type(encounter)
      return encounter.encounter_type if encounter.encounter_type.present?
      
      # Simple logic based on context
      if encounter.telemedicine?
        'telemedicine'
      elsif encounter.urgent_appointment?
        'emergency'
      elsif encounter.follow_up?
        'followup'
      else
        'routine'
      end
    end
    
    def self.determine_visit_type(encounter)
      return encounter.visit_type if encounter.visit_type.present?
      
      if encounter.telemedicine?
        'telemedicine'
      elsif encounter.scheduled?
        'scheduled'
      else
        'walkin'
      end
    end
    
    def self.determine_urgency(encounter)
      return encounter.urgency if encounter.urgency.present?
      
      if encounter.emergency_keywords_present?
        'emergency'
      elsif encounter.urgent_keywords_present?
        'urgent'
      else
        'routine'
      end
    end
  end
end

# New service: parts/clinic/services/patient_history/lhr_vital_enhancement.rb
module PatientHistory
  class LhrVitalEnhancement
    def self.perform(vital_obj)
      return unless vital_obj.respond_to?(:patient_id) && vital_obj.patient_id.present?
      
      # Queue background job to update health trends and scores
      LhrEnhancementJob.perform_later(vital_obj.patient_id, 'VITALS_RECORDED', { vital_id: vital_obj.id })
    rescue => e
      Rails.logger.error "LHR vitals enhancement failed: #{e.message}"
    end
  end
end

# New service: parts/clinic/services/patient_history/lhr_lab_report_enhancement.rb
module PatientHistory
  class LhrLabReportEnhancement
    def self.perform(lab_report)
      return unless lab_report.respond_to?(:patient_id) && lab_report.patient_id.present?
      
      # Queue background job for lab result processing
      LhrEnhancementJob.perform_later(lab_report.patient_id, 'LAB_RESULTS_RECEIVED', { lab_report_id: lab_report.id })
    rescue => e
      Rails.logger.error "LHR lab report enhancement failed: #{e.message}"
    end
  end
end

# New service: parts/clinic/services/patient_history/lhr_immunization_enhancement.rb
module PatientHistory
  class LhrImmunizationEnhancement
    def self.perform(immunization)
      return unless immunization.respond_to?(:patient_id) && immunization.patient_id.present?
      
      # Queue background job for immunization schedule updates
      LhrEnhancementJob.perform_later(immunization.patient_id, 'IMMUNIZATION_GIVEN', { immunization_id: immunization.id })
    rescue => e
      Rails.logger.error "LHR immunization enhancement failed: #{e.message}"
    end
  end
end

3. LHR Enhancement Background Job

Heavy LHR processing happens asynchronously to maintain system performance:

# New job: app/jobs/lhr_enhancement_job.rb
class LhrEnhancementJob < ActiveJob::Base
  queue_as :lhr_processing
  
  def perform(object_id, event_type, context = {})
    case event_type
    when 'ENCOUNTER_CREATED'
      enhance_encounter_longitudinally(object_id)
    when 'VITALS_RECORDED'
      update_vital_trends(object_id)
    when 'CHRONIC_MEDICATIONS_CONTINUED'
      # Simple tracking of chronic medication continuity
      log_medication_continuation(object_id)
    else
      Rails.logger.warn "Unknown LHR enhancement event: #{event_type}"
    end
  rescue => e
    Rails.logger.error "LHR Enhancement failed for #{object_id}: #{e.message}"
    # Don't re-raise - LHR enhancement is supplementary
  end

  private
  
  def enhance_encounter_longitudinally(encounter_id)
    encounter = Encounter.find(encounter_id)
    patient = encounter.patient
    
    ActiveRecord::Base.transaction do
      # 1. Enhance complaints with longitudinal tracking
      enhance_complaints_longitudinally(encounter)
      
      # 2. Enhance medicines with continuity tracking  
      enhance_medicines_longitudinally(encounter)
      
      # 3. Calculate patient health score
      calculate_health_score(patient.id, encounter.id)
      
      # 4. Update patient health summary
      update_patient_health_summary(patient.id)
      
      # 5. Generate clinical alerts if needed
      generate_clinical_alerts(encounter)
    end
  end
  
  def enhance_complaints_longitudinally(encounter)
    encounter.encounter_complaints.find_each do |complaint|
      next if complaint.problem_status.present? # Skip if already enhanced
      
      complaint.update!(
        problem_status: 'active',
        onset_date: encounter.created_at.to_date,
        severity_level: determine_complaint_severity(complaint.name),
        affects_health_score: true
      )
      
      # Link to similar complaints from previous encounters
      link_to_existing_problems(complaint)
      
      # Mark as chronic if this is 3rd+ occurrence
      check_chronic_condition(complaint)
    end
  end
  
  def enhance_medicines_longitudinally(encounter)
    encounter.encounter_medicines.find_each do |medicine|
      next if medicine.medication_status.present? # Skip if already enhanced
      
      medicine.update!(
        medication_status: 'active',
        prescription_type: determine_prescription_type(medicine.medicine_name),
        start_date: encounter.created_at.to_date,
        end_date: calculate_medication_end_date(medicine),
        # Note: reconciliation_status removed - using medication_status only
      )
      
      # Check for drug interactions with patient's other medications
      check_drug_interactions(medicine)
    end
  end
  
  # Calculate comprehensive health score using weighted components (0-100 scale)
  def calculate_health_score(patient_id, encounter_id = nil)
    patient = Patient.find(patient_id)
    
    # Calculate each component score (0-100 scale)
    vital_score = calculate_vital_score(patient)        # Weight: 25% - Recent vital signs
    problem_score = calculate_problem_score(patient)    # Weight: 30% - Active health problems (highest impact)
    medication_score = calculate_medication_score(patient) # Weight: 25% - Medication management  
    preventive_score = calculate_preventive_score(patient) # Weight: 20% - Preventive care
    
    # Weighted overall score (0-100 scale)
    overall_score = (
      vital_score * 0.25 +      # Vitals contribute 25%
      problem_score * 0.30 +    # Problems contribute 30% (most important)
      medication_score * 0.25 + # Medications contribute 25%
      preventive_score * 0.20   # Preventive care contributes 20%
    ).round(2)
    
    # Save health score record with detailed breakdown
    health_score = PatientHealthScore.create!(
      patient: patient,
      clinic_patient: patient.clinic_patients.last,
      encounter_id: encounter_id,
      overall_score: overall_score,
      vital_score: vital_score,
      problem_score: problem_score,
      medication_score: medication_score,
      preventive_score: preventive_score,
      calculated_date: Date.current,
      calculation_method: 'weighted_algorithm_v1',
      score_components: build_score_components(patient),
      risk_factors: identify_risk_factors(patient),
      data_completeness_percentage: calculate_data_completeness(patient)
    )
    
    # Update encounter with health score snapshot
    if encounter_id
      Encounter.where(id: encounter_id).update_all(health_score_snapshot: overall_score)
    end
    
    Rails.logger.info "Calculated health score #{overall_score} for patient #{patient_id} " \
                     "(vital: #{vital_score}, problem: #{problem_score}, medication: #{medication_score}, preventive: #{preventive_score})"
    
    health_score
  end
  
  def update_patient_health_summary(patient_id)
    patient = Patient.find(patient_id)
    
    # Find or create health summary
    summary = PatientHealthSummary.find_or_initialize_by(patient: patient)
    
    # Calculate current metrics
    active_problems = patient.encounter_complaints.where(problem_status: 'active')
    current_medications = patient.encounter_medicines.where(medication_status: 'active')
    latest_health_score = patient.patient_health_scores.recent.first
    recent_encounters = patient.encounters.recent.limit(5)
    
    summary.update!(
      clinic_patient: patient.clinic_patients.last,
      current_health_score: latest_health_score&.overall_score,
      health_score_trend: calculate_health_trend(patient),
      last_health_score_date: latest_health_score&.calculated_date,
      
      # Encounter statistics
      total_encounters_count: patient.encounters.count,
      last_encounter_date: patient.encounters.maximum(:created_at)&.to_date,
      encounters_this_year: patient.encounters.where('created_at >= ?', 1.year.ago).count,
      
      # Active problems and medications summaries
      active_problems_summary: build_problems_summary(active_problems),
      current_medications_summary: build_medications_summary(current_medications),
      
      # Vital trends over time
      vital_trends: calculate_vital_trends(patient),
      
      # Key longitudinal metrics
      key_metrics: {
        active_problems_count: active_problems.count,
        chronic_conditions_count: active_problems.where(chronic_condition: true).count,
        current_medications_count: current_medications.count,
        chronic_medications: current_medications.where(prescription_type: 'chronic').count,
        last_vital_check: patient.patient_vitals.maximum(:created_at)
      },
      
      # Care quality indicators
      # Note: reconciliation fields removed - tracking chronic medications instead
      
      # Data freshness tracking
      summary_generated_at: Time.current,
      needs_recalculation: false
    )
  end
end

Health Score Calculation System

The health scoring system provides a comprehensive 0-100 scale assessment of patient wellness using four weighted components:

Scoring Formula

Overall Score = (Vital Score × 0.25) + (Problem Score × 0.30) + (Medication Score × 0.25) + (Preventive Score × 0.20)

Component Breakdown

1. Vital Score (25% weight)

Purpose: Measures how well vital signs are within normal ranges

def calculate_vital_score(patient)
  recent_vitals = patient.patient_vitals.recent.limit(5)
  return 85.0 if recent_vitals.empty? # Default score if no vitals recorded
  
  # Count vitals within normal range
  normal_count = recent_vitals.count { |vital| vital_within_normal_range?(vital) }
  base_score = (normal_count.to_f / recent_vitals.count * 100).round(2)
  
  # Bonus for recent vitals (within 30 days) - shows active monitoring
  recent_bonus = recent_vitals.where('created_at > ?', 30.days.ago).any? ? 5 : 0
  
  # Ensure score stays within 0-100 range
  [[base_score + recent_bonus, 100].min, 0].max
end

def vital_within_normal_range?(vital)
  normal = true
  # Blood pressure ranges
  normal = false if vital.systolic_bp.present? && (vital.systolic_bp < 90 || vital.systolic_bp > 140)
  normal = false if vital.diastolic_bp.present? && (vital.diastolic_bp < 60 || vital.diastolic_bp > 90)
  # Heart rate range
  normal = false if vital.heart_rate.present? && (vital.heart_rate < 60 || vital.heart_rate > 100)
  # Temperature range (Fahrenheit)
  normal = false if vital.temperature.present? && (vital.temperature < 97.0 || vital.temperature > 99.5)
  normal
end

Examples:

  • Patient with all normal vitals in last 5 visits: 95-100 points
  • Patient with 3/5 normal vitals, recent monitoring: 65 points (60% + 5 bonus)
  • Patient with no recent vitals: 85 points (default)

2. Problem Score (30% weight - Highest Impact)

Purpose: Assesses burden of active health problems

def calculate_problem_score(patient)
  active_problems = patient.encounter_complaints.where(problem_status: 'active')
  base_score = 100.0 # Start with perfect health
  
  # Deduct points for each active problem based on severity
  active_problems.each do |problem|
    deduction = case problem.severity_level
                when 'critical' then 25    # Major health threat
                when 'severe' then 20      # Significant impact  
                when 'moderate' then 10    # Noticeable impact
                when 'mild' then 5         # Minor impact
                else 8                     # Unknown severity (conservative)
                end
    
    # Additional penalty for chronic conditions (20% more impact)
    deduction *= 1.2 if problem.chronic_condition?
    
    base_score -= deduction
  end
  
  # Ensure score stays within 0-100 range
  [[base_score, 100].min, 0].max.round(2)
end

Examples:

  • Healthy patient (no active problems): 100 points
  • Patient with mild hypertension (chronic): 94 points (100 - 5×1.2)
  • Patient with diabetes + severe arthritis: 55 points (100 - 25×1.2 - 20)

3. Medication Score (25% weight)

Purpose: Evaluates medication safety and adherence

def calculate_medication_score(patient)
  active_meds = patient.encounter_medicines.where(medication_status: 'active')
  return 95.0 if active_meds.empty? # High score if no medications needed
  
  base_score = 90.0 # Good baseline for patients on medications
  
  # Deduct for medication management issues
  chronic_count = active_meds.where(prescription_type: 'chronic').count
  base_score -= (unreconciled_count * 5) # -5 points per unreconciled med
  
  # Deduct for drug interactions
  interaction_count = active_meds.where('drug_interactions IS NOT NULL AND drug_interactions != ?', '[]').count
  base_score -= (interaction_count * 8) # -8 points per interaction
  
  # Bonus for having chronic medications tracked
  chronic_bonus = chronic_count > 0 ? 5 : 0
  
  [[base_score + chronic_bonus, 100].min, 0].max.round(2)
end

Examples:

  • Patient not on medications: 95 points
  • Patient on 3 medications, all reconciled: 95 points (90 + 5 bonus)
  • Patient with 2 drug interactions, 1 unreconciled: 77 points (90 - 16 - 5)

4. Preventive Score (20% weight)

Purpose: Measures engagement with preventive healthcare

def calculate_preventive_score(patient)
  age = patient.patient_age
  
  # Age-based baseline (reflects typical healthcare engagement patterns)
  base_score = case age
               when 0..18 then 85    # Pediatric - usually good preventive care
               when 19..40 then 75   # Young adults - often skip preventive care  
               when 41..65 then 80   # Middle age - more health conscious
               else 85               # Elderly - typically well-engaged with healthcare
               end
  
  # Bonus for recent immunizations (shows preventive care engagement)
  recent_immunizations = patient.patient_immunizations.where('created_at > ?', 2.years.ago).count
  immunization_bonus = [recent_immunizations * 2, 10].min # Max 10 points bonus
  
  [[base_score + immunization_bonus, 100].min, 0].max.round(2)
end

Examples:

  • 30-year-old with recent flu shot: 77 points (75 + 2)
  • 55-year-old with 3 recent immunizations: 86 points (80 + 6)
  • Elderly patient with full immunizations: 95 points (85 + 10)

Score Interpretation

RangeGradeClinical Meaning
90-100ExcellentOptimal health, minimal risk factors
80-89GoodWell-managed health with minor issues
70-79FairSome health concerns requiring attention
60-69Needs ImprovementMultiple health issues, active management needed
0-59PoorSignificant health risks, comprehensive intervention required

Real-World Examples

Example 1: Healthy Young Adult

  • Vitals: 95 (all normal, recent checkup)
  • Problems: 100 (no active problems)
  • Medications: 95 (no medications needed)
  • Preventive: 77 (age 28, recent flu shot)
  • Overall: 92 (Excellent) = 95×0.25 + 100×0.30 + 95×0.25 + 77×0.20

Example 2: Well-Managed Diabetic

  • Vitals: 85 (mostly normal, regular monitoring)
  • Problems: 70 (diabetes chronic moderate + mild hypertension)
  • Medications: 90 (3 meds, all reconciled, no interactions)
  • Preventive: 86 (age 55, good immunization record)
  • Overall: 80 (Good) = 85×0.25 + 70×0.30 + 90×0.25 + 86×0.20

Example 3: Complex Patient

  • Vitals: 60 (some abnormal readings, inconsistent monitoring)
  • Problems: 45 (diabetes, heart disease, arthritis - multiple chronic)
  • Medications: 65 (5+ meds, 2 interactions, 1 unreconciled)
  • Preventive: 85 (elderly, good preventive engagement)
  • Overall: 59 (Poor) = 60×0.25 + 45×0.30 + 65×0.25 + 85×0.20

Supporting Methods

# Build detailed score components for transparency
def build_score_components(patient)
  {
    vital_details: {
      recent_vitals_count: patient.patient_vitals.recent.count,
      normal_vitals_count: patient.patient_vitals.recent.count { |v| vital_within_normal_range?(v) },
      last_vital_date: patient.patient_vitals.maximum(:created_at)
    },
    problem_details: {
      active_problems_count: patient.encounter_complaints.where(problem_status: 'active').count,
      chronic_conditions_count: patient.encounter_complaints.where(chronic_condition: true, problem_status: 'active').count,
      severity_breakdown: patient.encounter_complaints.where(problem_status: 'active').group(:severity_level).count
    },
    medication_details: {
      active_medications_count: patient.encounter_medicines.where(medication_status: 'active').count,
      chronic_medications_count: patient.encounter_medicines.where(prescription_type: 'chronic', medication_status: 'active').count,
      interactions_count: patient.encounter_medicines.where('drug_interactions IS NOT NULL').count
    },
    preventive_details: {
      patient_age: patient.patient_age,
      recent_immunizations_count: patient.patient_immunizations.where('created_at > ?', 2.years.ago).count
    }
  }
end

# Identify high-risk factors for clinical attention
def identify_risk_factors(patient)
  risk_factors = {}
  
  # High blood pressure risk
  recent_bp_readings = patient.patient_vitals.recent.limit(3).select { |v| v.systolic_bp.present? }
  if recent_bp_readings.any? && recent_bp_readings.all? { |v| v.systolic_bp > 140 }
    risk_factors[:hypertension] = { severity: 'high', description: 'Consistently elevated blood pressure' }
  end
  
  # Multiple chronic conditions risk
  chronic_count = patient.encounter_complaints.where(chronic_condition: true, problem_status: 'active').count
  if chronic_count >= 3
    risk_factors[:multiple_chronic_conditions] = { severity: 'high', description: "#{chronic_count} active chronic conditions" }
  end
  
  # Medication interaction risk
  interaction_count = patient.encounter_medicines.where('drug_interactions IS NOT NULL').count
  if interaction_count > 0
    risk_factors[:drug_interactions] = { severity: 'moderate', description: "#{interaction_count} potential drug interactions" }
  end
  
  risk_factors
end

# Calculate data completeness for score reliability
def calculate_data_completeness(patient)
  total_elements = 4 # vitals, problems, medications, preventive
  present_elements = 0
  
  present_elements += 1 if patient.patient_vitals.recent.any?
  present_elements += 1 if patient.encounter_complaints.any?
  present_elements += 1 if patient.encounter_medicines.any?
  present_elements += 1 if patient.patient_immunizations.any?
  
  (present_elements.to_f / total_elements * 100).round(0)
end

4. Enhanced Model Layer

Encounter Model (Renamed from Careplan)

# app/models/encounter.rb (Enhanced from Careplan)
class Encounter < ApplicationRecord
  # Maintain backward compatibility
  self.table_name = 'encounters' # Renamed from careplans
  
  # All existing associations remain unchanged
  belongs_to :patient
  belongs_to :clinic
  has_many :encounter_complaints, foreign_key: 'encounter_id'
  has_many :encounter_medicines, foreign_key: 'encounter_id'
  
  # NEW: LHR associations
  has_many :patient_health_scores, foreign_key: 'encounter_id', dependent: :nullify
  # Note: medication_reconciliations removed - using simple continuity tracking
  
  # NEW: LHR convenience methods
  def has_lhr_enhancements?
    encounter_type.present? || health_score_snapshot.present?
  end
  
  def lhr_enhanced_complaints
    encounter_complaints.where.not(problem_status: nil)
  end
  
  def lhr_enhanced_medicines
    encounter_medicines.where.not(medication_status: nil)
  end
  
  def current_health_score
    patient&.patient_health_scores&.recent&.first&.overall_score
  end
  
  def health_score_trend
    scores = patient&.patient_health_scores&.recent&.limit(3)&.pluck(:overall_score)
    return 'unknown' if scores.blank? || scores.size < 2
    
    recent, older = scores.first, scores.last
    
    if recent > older + 5
      'improving'
    elsif recent < older - 5
      'declining'  
    else
      'stable'
    end
  end
  
  def has_chronic_medications?
    encounter_medicines.where(prescription_type: 'chronic').exists?
    
    # Check if patient has active medications from previous encounters
    patient&.encounter_medicines&.where(medication_status: 'active')&.any?
  end
  
  def clinical_alerts
    alerts = []
    
    # Drug interaction alerts
    medicines_with_interactions = encounter_medicines
      .where('drug_interactions IS NOT NULL AND drug_interactions != ?', '[]')
    
    if medicines_with_interactions.any?
      alerts << {
        type: 'drug_interaction',
        severity: 'high',
        message: "#{medicines_with_interactions.count} medication(s) have potential interactions",
        medicines: medicines_with_interactions.pluck(:medicine_name)
      }
    end
    
    # Health score alerts
    if health_score_snapshot.present? && health_score_snapshot < 60
      alerts << {
        type: 'low_health_score',
        severity: 'moderate',
        message: "Patient health score is #{health_score_snapshot}% - consider comprehensive review",
        score: health_score_snapshot
      }
    end
    
    # Chronic condition management alerts
    chronic_conditions = encounter_complaints.where(chronic_condition: true, problem_status: 'active')
    if chronic_conditions.count > 2
      alerts << {
        type: 'multiple_chronic_conditions',
        severity: 'moderate',
        message: "Patient has #{chronic_conditions.count} active chronic conditions requiring management",
        conditions: chronic_conditions.pluck(:name)
      }
    end
    
    alerts
  end
  
  # Trigger LHR enhancement after creation (only if not already enhanced)
  after_create :trigger_lhr_enhancement, unless: :has_lhr_enhancements?
  
  private
  
  def trigger_lhr_enhancement
    LhrEnhancementJob.perform_later(id, 'ENCOUNTER_CREATED')
  end
end

New LHR Models

# app/models/patient_health_score.rb
class PatientHealthScore < ApplicationRecord
  belongs_to :patient
  belongs_to :clinic_patient, optional: true
  belongs_to :encounter, optional: true
  
  validates :overall_score, presence: true, inclusion: { in: 0..100 }
  validates :calculated_date, presence: true
  
  scope :recent, -> { order(calculated_date: :desc) }
  scope :this_year, -> { where('calculated_date >= ?', 1.year.ago) }
  
  def score_grade
    case overall_score
    when 90..100 then 'Excellent'
    when 80..89 then 'Good'
    when 70..79 then 'Fair'
    when 60..69 then 'Needs Improvement'
    else 'Poor'
    end
  end
  
  def primary_risk_factors
    risk_factors.select { |factor, details| details['severity'] == 'high' }
  end
end

# app/models/patient_health_summary.rb
class PatientHealthSummary < ApplicationRecord
  belongs_to :patient
  belongs_to :clinic_patient, optional: true
  
  validates :patient_id, uniqueness: true
  
  scope :needs_update, -> { where(needs_recalculation: true) }
  scope :by_health_score, ->(min_score) { where('current_health_score >= ?', min_score) }
  
  def health_status
    return 'Unknown' unless current_health_score.present?
    
    case current_health_score
    when 85..100 then 'Excellent'
    when 70..84 then 'Good'
    when 55..69 then 'Fair'
    else 'Needs Attention'
    end
  end
  
  def summary_fresh?
    summary_generated_at.present? && summary_generated_at > 1.day.ago
  end
  
  def active_chronic_conditions
    active_problems_summary.select { |problem| problem['chronic'] == true }
  end
end

4. Enhanced Controller Layer

Encounters Controller

# parts/doctor/controllers/encounters_controller.rb (Enhanced from CareplansController)
class EncountersController < ApiController
  before_action :policy_check
  before_action :set_encounter, only: [:show, :update, :lhr_summary, :update_longitudinal_fields]

  # Enhanced to accept new UI fields
  def create
    encounter_params = params.require(:encounter).permit(
      :clinic_patient_id,    # From patient selection dropdown
      :doctor_id,            # From doctor selection dropdown  
      :visit_type,           # UI field: walkin/scheduled/telemedicine/home_visit
      :urgency,              # UI field: routine/urgent/emergency
      :chief_complaint,      # UI field: free text entry
      # ... existing fields ...
    )
    
    result = Encounters::Create.call(encounter_params)
    render_result(result, result.success? ? EncounterSerializer : nil)
  end

  def index
    result = Encounters::Index.call(params)
    render_result(result, result.success? ? EncounterSerializer : nil)
  end

  def show
    # Enhanced serialization with optional LHR data
    serializer_options = {
      include_lhr_data: params[:include_lhr] == 'true',
      include_health_score: params[:include_health_score] == 'true'
    }
    render json: EncounterSerializer.new(@encounter, serializer_options)
  end

  # NEW: LHR-specific endpoints
  def lhr_summary
    summary = {
      encounter_id: @encounter.id,
      health_score: @encounter.health_score_snapshot,
      health_score_trend: @encounter.health_score_trend,
      encounter_type: @encounter.encounter_type,
      
      enhanced_data: {
        complaints_enhanced: @encounter.lhr_enhanced_complaints.count,
        medicines_enhanced: @encounter.lhr_enhanced_medicines.count,
        total_complaints: @encounter.encounter_complaints.count,
        total_medicines: @encounter.encounter_medicines.count
      },
      
      chronic_medications: {
        present: @encounter.has_chronic_medications?,
        count: @encounter.encounter_medicines.where(prescription_type: 'chronic').count
      },
      
      clinical_alerts: @encounter.clinical_alerts,
      
      patient_summary: @encounter.patient.patient_health_summary&.as_json(
        only: [:current_health_score, :health_score_trend, :last_encounter_date],
        methods: [:health_status]
      )
    }
    
    render json: { lhr_summary: summary }
  end

  def update_longitudinal_fields
    # Allow updates to specific LHR fields only
    allowed_fields = params.slice(
      :encounter_type, 
      :visit_type,
      :urgency,
      :chief_complaint,
      # Note: reconciliation_completed removed
    ).to_h
    
    if allowed_fields.any? && @encounter.update(allowed_fields)
      # Trigger recalculation if significant fields changed
      # Note: reconciliation tracking removed
      
      render json: { 
        success: true, 
        message: 'Longitudinal fields updated successfully',
        updated_fields: allowed_fields.keys
      }
    else
      render json: { 
        errors: @encounter.errors.full_messages 
      }, status: :unprocessable_entity  
    end
  end

  # NEW: Get card-based health summary for dashboard UI
  def health_summary
    patient = @encounter.patient
    
    summary_data = {
      patient_demographics: {
        id: patient.id,
        name: patient.full_name,
        age: patient.patient_age,
        gender: patient.gender
      },
      
      health_score_card: {
        overall_score: patient.current_health_score,
        trend: patient.health_score_trend,
        last_calculated: patient.patient_health_scores.recent.first&.calculated_date
      },
      
      summary_cards: [
        {
          type: 'recent_encounters',
          title: 'Recent Encounters',
          count: patient.encounters.recent.count,
          items: patient.encounters.recent.limit(3).map do |enc|
            {
              date: enc.created_at.to_date,
              visit_type: enc.visit_type,
              urgency: enc.urgency,
              chief_complaint: enc.chief_complaint
            }
          end
        },
        {
          type: 'active_problems',
          title: 'Active Problems', 
          count: patient.encounter_complaints.where(problem_status: 'active').count,
          items: patient.encounter_complaints.where(problem_status: 'active').limit(3).map do |problem|
            {
              name: problem.name,
              severity: problem.severity_level,
              chronic: problem.chronic_condition?
            }
          end
        },
        {
          type: 'current_medications',
          title: 'Current Medications',
          count: patient.encounter_medicines.where(medication_status: 'active').count,
          items: patient.encounter_medicines.where(medication_status: 'active').limit(3).map do |med|
            {
              name: med.medicine_name,
              dosage: med.dosage,
              frequency: med.frequency
            }
          end
        }
      ]
    }
    
    render json: { health_summary: summary_data }
  end

  private

  def set_encounter
    @encounter = Encounter.find(params[:id])
  end

  def policy_check
    authorize params, policy_class: EncountersControllerPolicy
  end
end

5. Enhanced Serializers

# app/serializers/encounter_serializer.rb (Enhanced from CareplanSerializer)
class EncounterSerializer < ActiveModel::Serializer
  attributes :id, :patient_id, :clinic_id, :created_at, :updated_at
  
  # All existing attributes remain the same
  attributes :prescription, :test_description, :advice, :notes
  
  # NEW: LHR attributes (conditionally included)
  attributes :encounter_type, :visit_type, :urgency, :chief_complaint
  attributes :health_score_snapshot
  
  # Enhanced associations with LHR data
  has_many :encounter_complaints, serializer: EncounterComplaintSerializer
  has_many :encounter_medicines, serializer: EncounterMedicineSerializer
  
  # Conditionally include LHR data based on serializer options
  def include_lhr_data?
    instance_options[:include_lhr_data] == true
  end
  
  def include_health_score?
    instance_options[:include_health_score] == true
  end
  
  # LHR-specific attributes
  attribute :lhr_summary, if: :include_lhr_data?
  attribute :clinical_alerts, if: :include_lhr_data?
  attribute :patient_health_score, if: :include_health_score?
  
  def lhr_summary
    {
      has_enhancements: object.has_lhr_enhancements?,
      enhanced_complaints: object.lhr_enhanced_complaints.count,
      enhanced_medicines: object.lhr_enhanced_medicines.count,
      health_trend: object.health_score_trend,
      has_chronic_medications: object.has_chronic_medications?
    }
  end
  
  def clinical_alerts
    object.clinical_alerts
  end
  
  def patient_health_score
    object.current_health_score
  end
end

# Enhanced complaint serializer with LHR fields
class EncounterComplaintSerializer < ActiveModel::Serializer
  attributes :id, :name, :description, :created_at
  
  # NEW: LHR attributes
  attributes :problem_status, :severity_level, :onset_date, :resolved_date
  attributes :chronic_condition, :affects_health_score
  attributes :linked_complaint_ids, :problem_trend
  
  # Clinical coding attributes
  attributes :icd10_code, :icd11_code, :snomed_code
  belongs_to :primary_clinical_code, if: -> { object.primary_clinical_code.present? }
end

# Enhanced medicine serializer with LHR fields  
class EncounterMedicineSerializer < ActiveModel::Serializer
  attributes :id, :medicine_name, :dosage, :frequency, :duration_unit, :duration_frequency
  
  # NEW: LHR attributes
  attributes :medication_status, :prescription_type, :generic_name
  attributes :start_date, :end_date, :discontinuation_reason
  attributes :drug_interactions, :allergy_warnings
  attributes :medication_status, :prescription_type
  
  # Clinical coding attributes
  attributes :ndc_code
  belongs_to :rxnorm_code, if: -> { object.rxnorm_code.present? }
  belongs_to :generic_rxnorm_code, if: -> { object.generic_rxnorm_code.present? }
end

Data Flow Architecture

1. Encounter Creation Flow

User creates encounter → API Controller → Interactor
                                            ↓
                      PatientHistory::SaveHistory.perform
                                            ↓
                         ┌─────────────────────────────────────┐
                         │     EXISTING FLOW (unchanged)       │
                         │  SaveCareplan.perform(encounter)    │
                         │     ↓                               │
                         │  Core encounter data saved          │
                         │     ↓                               │
                         │  Legacy processing complete         │
                         └─────────────────────────────────────┘
                                            ↓
                         ┌─────────────────────────────────────┐
                         │      NEW LHR FLOW (added)          │
                         │  enhance_with_lhr_data(encounter)   │
                         │     ↓                               │
                         │  Basic LHR fields updated          │
                         │     ↓                               │
                         │  LhrEnhancementJob queued           │
                         └─────────────────────────────────────┘
                                            ↓
                              Response returned to user
                                            │
                                     ┌─────────────┐
                                     │ BACKGROUND  │
                                     │ PROCESSING  │
                                     └─────────────┘
                                            ↓
                              LhrEnhancementJob.perform
                                            ↓
            ┌──────────────────┬──────────────────┬──────────────────┐
            ↓                  ↓                  ↓                  ↓
   enhance_complaints   enhance_medicines   calculate_health   update_patient
                                           _score             _summary

2. Background Processing Queue Structure

Redis Queue: lhr_processing
├── High Priority: encounter_created, vitals_recorded
├── Medium Priority: chronic_medication_tracking, problem_linking
└── Low Priority: health_score_calculation, summary_updates

Processing Timeouts:
├── Encounter enhancement: 30 seconds
├── Health score calculation: 60 seconds  
└── Patient summary update: 45 seconds

Retry Policy:
├── Max retries: 3
├── Exponential backoff: 2^attempt seconds
└── Dead letter queue after max retries

Performance Optimizations

1. Database Query Optimization

# Efficient queries for longitudinal data
class Patient < ApplicationRecord
  # Cached calculations to avoid expensive recalculation
  def current_health_score
    Rails.cache.fetch("patient_#{id}_health_score", expires_in: 1.hour) do
      patient_health_scores.recent.first&.overall_score
    end
  end
  
  def active_problems_count
    Rails.cache.fetch("patient_#{id}_active_problems", expires_in: 5.minutes) do
      encounter_complaints.where(problem_status: 'active').count
    end
  end
end

2. Caching Strategy

# app/models/concerns/lhr_cacheable.rb
module LhrCacheable
  extend ActiveSupport::Concern
  
  included do
    # Cache patient health summaries
    after_update :clear_health_summary_cache
    after_create :clear_health_summary_cache
  end
  
  def cached_health_summary
    Rails.cache.fetch(health_summary_cache_key, expires_in: 5.minutes) do
      patient.patient_health_summary&.as_json(
        include: [:active_problems_summary, :current_medications_summary]
      )
    end
  end
  
  private
  
  def health_summary_cache_key
    "patient_#{patient_id}_health_summary_#{patient.patient_health_summary&.updated_at&.to_i}"
  end
  
  def clear_health_summary_cache
    Rails.cache.delete_matched("patient_#{patient_id}_*")
  end
end

3. Background Job Monitoring

# app/jobs/concerns/lhr_job_monitoring.rb
module LhrJobMonitoring
  extend ActiveSupport::Concern
  
  included do
    around_perform :monitor_performance
    rescue_from StandardError, with: :handle_lhr_job_error
  end
  
  private
  
  def monitor_performance
    start_time = Time.current
    
    yield
    
    duration = Time.current - start_time
    Rails.logger.info "LHR Job #{self.class.name} completed in #{duration}s"
    
    # Alert if job takes too long
    if duration > 30.seconds
      Rails.logger.warn "LHR Job #{self.class.name} took #{duration}s - consider optimization"
    end
  end
  
  def handle_lhr_job_error(error)
    Rails.logger.error "LHR Job #{self.class.name} failed: #{error.message}"
    Rails.logger.error error.backtrace.join("\n")
    
    # Don't re-raise for LHR jobs - they're supplementary
    # Could send to error tracking service here
  end
end

Error Handling & Resilience

1. Fail-Safe Design

# Ensure core functionality never fails due to LHR issues
module LhrSafety
  def safe_lhr_operation
    yield
  rescue => e
    Rails.logger.error "LHR operation failed safely: #{e.message}"
    # Return nil or default value, never raise
    nil
  end
end

# Usage in SaveHistory
def self.enhance_with_lhr_data(obj, key)
  include LhrSafety
  
  safe_lhr_operation do
    case key
    when 'CAREPLAN'
      enhance_encounter_with_lhr(obj)
    end
  end
end

2. Data Consistency Checks

# app/jobs/lhr_consistency_check_job.rb
class LhrConsistencyCheckJob < ActiveJob::Base
  queue_as :lhr_maintenance
  
  def perform
    check_health_score_consistency
    check_chronic_medication_consistency  
    check_problem_linking_consistency
  end
  
  private
  
  def check_health_score_consistency
    # Find encounters with health scores but missing patient health records
    orphaned_scores = Encounter
      .where.not(health_score_snapshot: nil)
      .left_joins(:patient_health_scores)
      .where(patient_health_scores: { id: nil })
    
    orphaned_scores.find_each do |encounter|
      Rails.logger.warn "Encounter #{encounter.id} has health score but no health record"
      LhrEnhancementJob.perform_later(encounter.id, 'HEALTH_SCORE_REPAIR')
    end
  end
end

Testing Strategy

1. Service Layer Tests

# spec/services/patient_history/save_history_spec.rb
RSpec.describe PatientHistory::SaveHistory do
  describe '.perform' do
    let(:encounter) { create(:encounter) }
    
    context 'when LHR enhancement is enabled' do
      it 'preserves existing functionality' do
        expect(PatientHistory::SaveCareplan).to receive(:perform).with(encounter)
        
        described_class.perform(encounter, 'CAREPLAN')
      end
      
      it 'triggers LHR enhancement after successful save' do
        allow(PatientHistory::SaveCareplan).to receive(:perform).and_return(true)
        expect(LhrEnhancementJob).to receive(:perform_later).with(encounter.id, 'ENCOUNTER_CREATED')
        
        described_class.perform(encounter, 'CAREPLAN')
      end
      
      it 'skips LHR enhancement if core save fails' do
        allow(PatientHistory::SaveCareplan).to receive(:perform).and_return(false)
        expect(LhrEnhancementJob).not_to receive(:perform_later)
        
        described_class.perform(encounter, 'CAREPLAN')
      end
    end
    
    context 'when LHR enhancement fails' do
      it 'does not affect core save operation' do
        allow(PatientHistory::SaveCareplan).to receive(:perform).and_return(true)
        allow_any_instance_of(described_class).to receive(:enhance_with_lhr_data).and_raise(StandardError)
        
        expect { described_class.perform(encounter, 'CAREPLAN') }.not_to raise_error
      end
    end
  end
end

2. Background Job Tests

# spec/jobs/lhr_enhancement_job_spec.rb
RSpec.describe LhrEnhancementJob do
  describe '#perform' do
    let(:encounter) { create(:encounter, :with_complaints_and_medicines) }
    
    it 'enhances complaints with longitudinal data' do
      described_class.new.perform(encounter.id, 'ENCOUNTER_CREATED')
      
      encounter.reload
      expect(encounter.encounter_complaints.first.problem_status).to eq('active')
      expect(encounter.encounter_complaints.first.onset_date).to be_present
    end
    
    it 'calculates patient health score' do
      expect { 
        described_class.new.perform(encounter.id, 'ENCOUNTER_CREATED') 
      }.to change(PatientHealthScore, :count).by(1)
      
      score = encounter.patient.patient_health_scores.first
      expect(score.overall_score).to be_between(0, 100)
    end
    
    it 'updates patient health summary' do
      described_class.new.perform(encounter.id, 'ENCOUNTER_CREATED')
      
      summary = encounter.patient.patient_health_summary
      expect(summary).to be_present
      expect(summary.total_encounters_count).to eq(1)
    end
  end
end

Monitoring & Observability

1. Metrics Collection

# app/services/lhr_metrics_service.rb
class LhrMetricsService
  def self.track_enhancement_performance(encounter_id, duration)
    # Track performance metrics
    Rails.logger.info "LHR Enhancement Duration", {
      encounter_id: encounter_id,
      duration_seconds: duration,
      timestamp: Time.current
    }
    
    # Could integrate with monitoring service like DataDog, NewRelic
  end
  
  def self.track_health_score_calculation(patient_id, score)
    Rails.logger.info "Health Score Calculated", {
      patient_id: patient_id,
      score: score,
      timestamp: Time.current
    }
  end
end

2. Health Checks

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  def lhr_status
    status = {
      lhr_processing_queue: check_lhr_queue,
      recent_enhancements: check_recent_enhancements,
      health_score_calculations: check_health_score_freshness,
      system_health: 'operational'
    }
    
    render json: status
  end
  
  private
  
  def check_lhr_queue
    {
      pending_jobs: Sidekiq::Queue.new('lhr_processing').size,
      failed_jobs: Sidekiq::DeadSet.new.size,
      processed_today: Sidekiq::Stats.new.processed
    }
  end
  
  def check_recent_enhancements
    {
      encounters_enhanced_today: Encounter.where('updated_at >= ?', 1.day.ago)
                                         .where.not(encounter_type: nil).count,
      health_scores_calculated_today: PatientHealthScore.where('created_at >= ?', 1.day.ago).count
    }
  end
end

Summary

This service architecture provides a robust, scalable foundation for implementing LHR features while maintaining complete backward compatibility. Key benefits include:

  • Zero Breaking Changes: All existing functionality preserved
  • Performance Optimized: Background processing keeps the system fast
  • Fail-Safe Design: LHR failures don't affect core operations
  • Comprehensive Testing: Full test coverage for reliability
  • Monitoring Ready: Built-in observability and metrics
  • Scalable Architecture: Can handle growing data volumes

The implementation follows Rails best practices and integrates seamlessly with the existing parts-based architecture of the KulCare API.


UI Integration Requirements

Frontend Form Updates

Encounter Creation Form needs these fields:

  • Patient Selection: Dropdown → clinic_patient_id
  • Doctor Selection: Dropdown → doctor_id
  • Visit Type: Select → walkin|scheduled|telemedicine|home_visit
  • Urgency: Select → routine|urgent|emergency
  • Chief Complaint: Textarea → Free text entry

Dashboard Implementation

Health Summary Cards consume data from /patients/:id/health_summary:

  • Health Score Card: Circular progress (0-100) with trend arrow
  • Recent Encounters: List of last 3 visits with visit_type, urgency
  • Active Problems: List with severity badges
  • Current Medications: List with dosage/frequency

API Response Structure:

{
  "health_summary": {
    "patient_demographics": { "name": "...", "age": 45 },
    "health_score_card": { "overall_score": 78.5, "trend": "improving" },
    "summary_cards": [
      { "type": "recent_encounters", "count": 3, "items": [...] },
      { "type": "active_problems", "count": 2, "items": [...] }
    ]
  }
}

Next Steps:

On this page