hln_ice 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 693ce96464262bed3bdbba9a9d38cc91aaaba9620c270e49994b4c0537c21b4d
4
- data.tar.gz: 74711ef35d3caf83aa5b210ad0665b152275224fbc571349b686432d0fed0e10
3
+ metadata.gz: 80a72761b4c53974d8784c926ca86d27953488ac8c6e99e9bdb386447b8a3f98
4
+ data.tar.gz: 3952414a0bf97c45cc2a7009c4b16b1a016bb866697821c1bf69c1b60bcc05ac
5
5
  SHA512:
6
- metadata.gz: 6f47f5f31184385ebf20e319a415720094a792fe2e91b3e35218771e7ff15f80ecac1956771eab2bbd773c820ae2e54b5df6dc774f1bffc97786ad7814419918
7
- data.tar.gz: ac317607ab177277cba5c32c458d3f5ddb583a6e84f0e98ab70d774128986db2c393d1a2d783f50386d93ae74ad0800967443024f20a93cd5f6e0a9eedaa9405
6
+ metadata.gz: 2389561c18f1c9d88f486214bf4bcd5eb6b9b3bc92f08b08fc7d6ac360ccc07360646cece3ddde6c26c5a6a5dddb2026bea28f211ce5f700ace3f1baad4179c8
7
+ data.tar.gz: bd197b89a993c6df99f2fad1d586a3da4ce31715388a541f4679ad96a26c2c9635cdebeeef502ba67808fbbb9427c14338bc9fcd0692ca9a84f9ef242128761c
data/.rubocop.yml CHANGED
@@ -1,5 +1,8 @@
1
+ inherit_from: .rubocop_todo.yml
2
+
1
3
  AllCops:
2
4
  TargetRubyVersion: 3.1
5
+ SuggestExtensions: false
3
6
 
4
7
  Style/StringLiterals:
5
8
  Enabled: true
@@ -11,3 +14,15 @@ Style/StringLiteralsInInterpolation:
11
14
 
12
15
  Layout/LineLength:
13
16
  Max: 120
17
+
18
+ # Methods after `private` are indented one level, Rails style.
19
+ Layout/IndentationConsistency:
20
+ EnforcedStyle: indented_internal_methods
21
+
22
+ # With indented private methods, `private` is followed directly by them.
23
+ Layout/EmptyLinesAroundAccessModifier:
24
+ Enabled: false
25
+
26
+ Metrics/BlockLength:
27
+ Exclude:
28
+ - "spec/**/*"
data/.rubocop_todo.yml ADDED
@@ -0,0 +1,43 @@
1
+ # This configuration was generated by
2
+ # `rubocop --auto-gen-config --no-auto-gen-timestamp`
3
+ # using RuboCop version 1.91.0.
4
+ # The point is for the user to remove these configuration records
5
+ # one by one as the offenses are removed from the code base.
6
+ # Note that changes in the inspected code, or installation of new
7
+ # versions of RuboCop, may require this file to be generated again.
8
+
9
+ # Offense count: 5
10
+ # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes.
11
+ Metrics/AbcSize:
12
+ Max: 70
13
+
14
+ # Offense count: 2
15
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
16
+ # AllowedMethods: refine
17
+ Metrics/BlockLength:
18
+ Max: 50
19
+
20
+ # Offense count: 1
21
+ # Configuration parameters: CountComments, CountAsOne.
22
+ Metrics/ClassLength:
23
+ Max: 113
24
+
25
+ # Offense count: 3
26
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
27
+ Metrics/CyclomaticComplexity:
28
+ Max: 15
29
+
30
+ # Offense count: 8
31
+ # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
32
+ Metrics/MethodLength:
33
+ Max: 72
34
+
35
+ # Offense count: 1
36
+ # Configuration parameters: CountKeywordArgs, MaxOptionalParameters.
37
+ Metrics/ParameterLists:
38
+ Max: 6
39
+
40
+ # Offense count: 2
41
+ # Configuration parameters: AllowedMethods, AllowedPatterns.
42
+ Metrics/PerceivedComplexity:
43
+ Max: 15
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-10-06
4
+
5
+ - Expose ICE's per-dose evaluations under `evaluations` in the result, with one entry per administered dose and vaccine group it counts toward (so a combination vaccine has several). Each entry has the caller's event id, CVX, administration date, vaccine group, dose number, validity, evaluation status, and reasons. Codes are passed through as ICE returns them. `recommendations` and `simplified_status` are unchanged.
6
+
7
+ ## [0.3.0] - 2026-09-24
8
+
9
+ - Expose schedule authorities (e.g. `ACIP_CDC`, `AAP`, `AAFP`) on each recommendation under `schedule_authorities` when the ICE server has `outputScheduleAuthorities` enabled. The key is omitted when the server does not return them.
10
+ - Fix recommendations being silently dropped when a proposal's schedule authorities observation precedes its status observation.
11
+
3
12
  ## [0.2.0] - 2026-07-02
4
13
 
5
14
  - Stop logging PHI by default. The ICE input data, generated request XML, and raw service response are no longer logged unless the new `log_payloads:` option is set to `true`. Warnings and errors are still logged.
data/README.md CHANGED
@@ -6,7 +6,8 @@ Given a patient's demographics and immunization history, `HlnIce` builds the
6
6
  VMR (Virtual Medical Record) request, calls the ICE evaluation endpoint, and
7
7
  parses the response into:
8
8
 
9
- - a full list of **recommendations** (one per vaccine group, with status, reasons, and administration intervals), and
9
+ - a full list of **recommendations** (one per vaccine group, with status, reasons, and administration intervals),
10
+ - the **evaluations** of each administered dose (per vaccine group, with dose number, validity, and reasons), and
10
11
  - a **simplified status** map keyed by vaccine group — e.g. `{ ipv_opv: "overdue", mmr: "compliant" }`.
11
12
 
12
13
  The gem has no Rails or ActiveSupport dependency; it takes an injected logger
@@ -79,6 +80,7 @@ patient_data = {
79
80
  clinicalStatements: {
80
81
  substanceAdministrationEvents: [
81
82
  {
83
+ id: "1", # echoed back as event_id on evaluations; a UUID is generated if omitted
82
84
  substance: {
83
85
  substanceCode: { code: "10", displayName: "Polio" }
84
86
  },
@@ -108,7 +110,26 @@ On success:
108
110
  vaccine: { code: "10", code_system: "...", name: "Polio Vaccine Group" },
109
111
  status: { code: "RECOMMENDED", name: "Recommended" },
110
112
  reasons: [{ code: "DUE_NOW", name: "Due Now" }],
111
- intervals: { proposed: { low: "2020-03-01", high: "2020-04-01" } }
113
+ intervals: { proposed: { low: "2020-03-01", high: "2020-04-01" } },
114
+ # Only present when the ICE server has outputScheduleAuthorities enabled
115
+ schedule_authorities: [
116
+ { code: "ACIP_CDC", name: "Advisory Committee on Immunization Practices / Centers for Disease Control and Prevention" },
117
+ { code: "AAP", name: "American Academy of Pediatrics" }
118
+ ]
119
+ }
120
+ ],
121
+ # One entry per administered dose and vaccine group it counts toward, so a
122
+ # combination vaccine has several. Codes are passed through from ICE.
123
+ evaluations: [
124
+ {
125
+ event_id: "1",
126
+ cvx: "10",
127
+ administered_on: "2020-03-01",
128
+ vaccine_group: { code: "400", name: "Polio Vaccine Group" },
129
+ dose_number: 1, # nil if ICE omits it
130
+ valid: true,
131
+ status: { code: "VALID", name: "Valid Dose" }, # e.g. VALID, INVALID, ACCEPTED
132
+ reasons: [] # e.g. [{ code: "BELOW_MINIMUM_INTERVAL", name: "Below Minimum Interval" }]
112
133
  }
113
134
  ],
114
135
  simplified_status: { ipv_opv: "overdue" }
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "date"
4
3
  require "json"
5
- require "base64"
6
4
  require "logger"
7
- require "securerandom"
8
5
  require "httparty"
9
- require "nokogiri"
10
6
 
11
7
  module HlnIce
12
8
  # Client for the HLN ICE (Immunization Calculation Engine) OpenCDS service.
@@ -14,28 +10,12 @@ module HlnIce
14
10
  # Evaluates a patient's immunization history against the ICE forecasting
15
11
  # rules and returns both the raw recommendations and a simplified status
16
12
  # mapping keyed by vaccine group.
13
+ #
14
+ # All logging happens here: RequestBuilder and ResponseParser never log, so
15
+ # this class is the only place that can write patient data to the logs.
17
16
  class Client
18
- # Mapping from ICE status codes to our status keys
19
- STATUS_MAPPING = {
20
- "CONDITIONAL" => "conditional",
21
- "FUTURE_RECOMMENDED" => "compliant",
22
- "NOT_RECOMMENDED" => "compliant",
23
- "RECOMMENDED" => "overdue",
24
- }.freeze
25
-
26
- # Mapping from ICE vaccine groups to our immunization keys
27
- VACCINE_MAPPING = {
28
- "DTP Vaccine Group" => :dtap_tdap,
29
- "Hep A Vaccine Group" => :hep_a,
30
- "Hep B Vaccine Group" => :hep_b,
31
- "Hib Vaccine Group" => :hib,
32
- "HPV Vaccine Group" => :hpv,
33
- "Meningococcal Vaccine Group" => :mcv4,
34
- "MMR Vaccine Group" => :mmr,
35
- "Pneumococcal Vaccine Group" => :pcv,
36
- "Polio Vaccine Group" => :ipv_opv,
37
- "Varicella Vaccine Group" => :var,
38
- }.freeze
17
+ STATUS_MAPPING = SimplifiedStatus::STATUS_MAPPING
18
+ VACCINE_MAPPING = SimplifiedStatus::VACCINE_MAPPING
39
19
 
40
20
  attr_reader :base_url, :timeout, :max_retries, :retry_delay, :logger, :log_payloads
41
21
 
@@ -62,7 +42,7 @@ module HlnIce
62
42
  )
63
43
 
64
44
  response.success?
65
- rescue => e
45
+ rescue StandardError => e
66
46
  logger.error("ICE service unavailable: #{e.message}")
67
47
  false
68
48
  end
@@ -95,7 +75,7 @@ module HlnIce
95
75
  else
96
76
  handle_error_response(response)
97
77
  end
98
- rescue => e
78
+ rescue StandardError => e
99
79
  retries += 1
100
80
  if retries <= max_retries
101
81
  logger.warn("Retrying ICE request (#{retries}/#{max_retries}): #{e.message}")
@@ -109,157 +89,24 @@ module HlnIce
109
89
  end
110
90
 
111
91
  private
112
- # Plain-Ruby equivalent of ActiveSupport's `blank?`.
113
- def blank?(value)
114
- return true if value.nil?
115
- return value.strip.empty? if value.is_a?(String)
116
- return value.empty? if value.respond_to?(:empty?)
117
-
118
- false
119
- end
120
-
121
- # Plain-Ruby equivalent of ActiveSupport's `present?`.
122
- def present?(value)
123
- !blank?(value)
124
- end
125
-
126
92
  def build_ice_payload(patient_data)
127
- # Extract patient information
128
- patient_id = patient_data[:patient][:id]
129
- date_of_birth = patient_data[:patient][:date_of_birth]
130
- gender = patient_data[:patient][:gender]
131
- gender = "U" if blank?(gender) # Set default to 'U' if blank
93
+ builder = RequestBuilder.new(patient_data)
132
94
 
133
95
  # Log input data for debugging
134
96
  logger.debug("ICE Input Data: #{JSON.pretty_generate(patient_data)}") if log_payloads
135
97
 
136
- # Build the VMR XML
137
- xml_payload = <<~XML
138
- <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
139
- <ns4:cdsInput xmlns:ns2="org.opencds"
140
- xmlns:ns3="org.opencds.vmr.v1_0.schema.vmr"
141
- xmlns:ns4="org.opencds.vmr.v1_0.schema.cdsinput"
142
- xmlns:ns5="org.opencds.vmr.v1_0.schema.cdsoutput">
143
- <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
144
- <cdsContext>
145
- <cdsSystemUserPreferredLanguage code="en" codeSystem="2.16.840.1.113883.6.99" displayName="English"/>
146
- </cdsContext>
147
- <vmrInput>
148
- <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
149
- <patient>
150
- <templateId root="2.16.840.1.113883.3.795.11.2.1.1"/>
151
- <id root="2.16.840.1.113883.3.795.12.100.11" extension="#{patient_id}"/>
152
- <demographics>
153
- <birthTime value="#{format_date_for_ice(date_of_birth)}"/>
154
- <gender code="#{gender}" codeSystem="2.16.840.1.113883.5.1"/>
155
- </demographics>
156
- <clinicalStatements>
157
- <substanceAdministrationEvents>
158
- XML
159
-
160
- # Add immunization records if available
161
- if present?(patient_data[:immunizations])
162
- patient_data[:immunizations].each do |imm|
163
- next unless present?(imm.dig(:clinicalStatements, :substanceAdministrationEvents))
164
-
165
- imm[:clinicalStatements][:substanceAdministrationEvents].each do |event|
166
- next unless present?(event.dig(:substance, :substanceCode))
167
-
168
- substance_code = event[:substance][:substanceCode]
169
-
170
- # Format dates to ICE format (YYYYMMDD)
171
- low_date = format_date_for_ice(event[:administrationTimeInterval][:low])
172
- high_date = format_date_for_ice(event[:administrationTimeInterval][:high])
173
-
174
- xml_payload += <<~XML
175
- <substanceAdministrationEvent>
176
- <templateId root="2.16.840.1.113883.3.795.11.9.1.1"/>
177
- <id root="#{event[:id] || SecureRandom.uuid}"/>
178
- <substanceAdministrationGeneralPurpose code="384810002" codeSystem="2.16.840.1.113883.6.5"/>
179
- <substance>
180
- <id root="#{event[:substance][:id] || SecureRandom.uuid}"/>
181
- <substanceCode code="#{substance_code[:code]}"
182
- codeSystem="2.16.840.1.113883.12.292"
183
- displayName="#{substance_code[:displayName]}"
184
- originalText="#{substance_code[:displayName]}"/>
185
- </substance>
186
- <administrationTimeInterval low="#{low_date}" high="#{high_date}"/>
187
- </substanceAdministrationEvent>
188
- XML
189
- end
190
- end
191
- end
192
-
193
- # Close the XML
194
- xml_payload += <<~XML
195
- </substanceAdministrationEvents>
196
- </clinicalStatements>
197
- </patient>
198
- </vmrInput>
199
- </ns4:cdsInput>
200
- XML
201
-
202
98
  # Log the generated XML for debugging
203
- logger.debug("Generated ICE XML: #{xml_payload}") if log_payloads
99
+ logger.debug("Generated ICE XML: #{builder.xml}") if log_payloads
204
100
 
205
- # Base64 encode the XML
206
- base64_encoded_payload = Base64.strict_encode64(xml_payload)
207
-
208
- # Build the full payload
209
- {
210
- "interactionId" => {
211
- "scopingEntityId" => "org.nyc.cir",
212
- "interactionId" => "#{patient_id}-#{Time.now.to_i}",
213
- "submissionTime" => (Time.now.to_f * 1000).to_i
214
- },
215
- "evaluationRequest" => {
216
- "clientLanguage" => "en",
217
- "clientTimeZoneOffset" => "+0000",
218
- "kmEvaluationRequest" => [
219
- {
220
- "kmId" => {
221
- "scopingEntityId" => "org.nyc.cir",
222
- "businessId" => "ICE",
223
- "version" => "1.0.0"
224
- }
225
- }
226
- ],
227
- "dataRequirementItemData" => [
228
- {
229
- "driId" => {
230
- "containingEntityId" => {
231
- "scopingEntityId" => "org.nyc.cir",
232
- "businessId" => "ICEData",
233
- "version" => "1.0.0"
234
- },
235
- "itemId" => "cdsPayload"
236
- },
237
- "data" => {
238
- "informationModelSSId" => {
239
- "scopingEntityId" => "org.opencds.vmr",
240
- "businessId" => "VMR",
241
- "version" => "1.0"
242
- },
243
- "base64EncodedPayload" => [base64_encoded_payload]
244
- }
245
- }
246
- ]
247
- }
248
- }
101
+ builder.payload
249
102
  end
250
103
 
251
104
  def parse_ice_response(response_body)
252
105
  logger.debug("ICE service response: #{response_body}") if log_payloads
253
106
 
254
- result = JSON.parse(response_body)
255
-
256
- if result.dig("finalKMEvaluationResponse", 0, "kmEvaluationResultData", 0, "data", "base64EncodedPayload", 0)
257
- base64_result = result["finalKMEvaluationResponse"][0]["kmEvaluationResultData"][0]["data"]["base64EncodedPayload"][0]
258
- decoded_result = Base64.decode64(base64_result)
259
-
260
- # Parse the XML response
261
- parsed_result = parse_xml_response(decoded_result)
107
+ parsed_result = ResponseParser.new(response_body).data
262
108
 
109
+ if parsed_result
263
110
  {
264
111
  success: true,
265
112
  data: parsed_result
@@ -271,189 +118,23 @@ module HlnIce
271
118
  error: "No valid response found in ICE output."
272
119
  }
273
120
  end
274
- rescue => e
121
+ rescue StandardError => e
275
122
  logger.error("Error parsing ICE response: #{e.message}")
276
123
  { success: false, error: "Error parsing ICE response: #{e.message}" }
277
124
  end
278
125
 
279
- def parse_xml_response(xml_string)
280
- # Parse the XML
281
- doc = Nokogiri::XML(xml_string)
282
-
283
- # Remove namespaces to simplify parsing
284
- doc.remove_namespaces!
285
-
286
- # Extract patient information
287
- patient = doc.xpath("//patient").first
288
- return { raw_xml: xml_string } unless patient
289
-
290
- patient_id = patient.xpath("./id").first["extension"] rescue nil
291
- birth_time = patient.xpath("./demographics/birthTime").first["value"] rescue nil
292
- gender = patient.xpath("./demographics/gender").first["code"] rescue nil
293
-
294
- # Format birth date if present
295
- birth_date = format_ice_date(birth_time)
296
-
297
- # Extract vaccine recommendations
298
- all_recommendations = []
299
-
300
- doc.xpath("//substanceAdministrationProposal").each do |proposal|
301
- # Get the substance code and name
302
- substance_element = proposal.xpath("./substance/substanceCode").first
303
- next unless substance_element
304
-
305
- vaccine_code = substance_element["code"]
306
- vaccine_name = substance_element["displayName"]
307
- vaccine_code_system = substance_element["codeSystem"]
308
-
309
- # Get the clinical observation result
310
- observation = proposal.xpath(".//observationResult").first
311
- next unless observation
312
-
313
- # Get recommendation status
314
- status_element = observation.xpath(".//observationValue/concept").first
315
- next unless status_element
316
-
317
- status_code = status_element["code"]
318
- status_name = status_element["displayName"]
319
-
320
- # Get interpretation reasons
321
- reasons = []
322
- observation.xpath(".//interpretation").each do |interpretation|
323
- reason = {
324
- code: interpretation["code"],
325
- name: interpretation["displayName"]
326
- }
327
-
328
- # Add supplemental text if available
329
- if interpretation["originalText"]
330
- reason[:supplemental_text] = interpretation["originalText"]
331
- end
332
-
333
- reasons << reason
334
- end
335
-
336
- # Get administration time intervals if available
337
- proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
338
- valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
339
-
340
- intervals = {}
341
- if proposed_interval
342
- intervals[:proposed] = {
343
- low: format_ice_date(proposed_interval["low"]),
344
- high: format_ice_date(proposed_interval["high"])
345
- }.compact
346
- end
347
-
348
- if valid_interval
349
- intervals[:valid] = {
350
- low: format_ice_date(valid_interval["low"]),
351
- high: format_ice_date(valid_interval["high"])
352
- }.compact
353
- end
354
-
355
- # Build the recommendation object
356
- recommendation = {
357
- vaccine: {
358
- code: vaccine_code,
359
- code_system: vaccine_code_system,
360
- name: vaccine_name
361
- },
362
- status: {
363
- code: status_code,
364
- name: status_name
365
- },
366
- reasons:
367
- }
368
-
369
- # Only add intervals if they exist
370
- recommendation[:intervals] = intervals if present?(intervals)
371
-
372
- all_recommendations << recommendation
373
- end
374
-
375
- # Create simplified immunization status mapping
376
- simplified_status = {}
377
-
378
- # Process all recommendations to create the simplified mapping
379
- all_recommendations.each do |recommendation|
380
- vaccine_name = recommendation[:vaccine][:name]
381
- status_code = recommendation[:status][:code]
382
-
383
- # Map the vaccine name to our key
384
- vaccine_key = VACCINE_MAPPING[vaccine_name]
385
- next unless vaccine_key # Skip if we don't have a mapping
386
-
387
- # Map the status code to our status
388
- status = STATUS_MAPPING[status_code] || "overdue" # Default to overdue if unknown
389
-
390
- # Special handling for conditional status based on reasons
391
- if status == "conditional"
392
- # Check if any reason indicates medical exemption
393
- has_medical_exemption = recommendation[:reasons].any? { |r| r[:code] == "MEDICAL_EXEMPTION" }
394
- if has_medical_exemption
395
- status = "medically_exempt"
396
- end
397
- end
398
-
399
- # Store in our simplified mapping
400
- simplified_status[vaccine_key] = status
401
- end
402
-
403
- # Build the final result structure
404
- {
405
- raw_xml: xml_string,
406
- patient: {
407
- id: patient_id,
408
- birth_date: birth_date || birth_time,
409
- gender:
410
- }.compact,
411
- recommendations: all_recommendations, # Keep the original flat list for backward compatibility
412
- simplified_status: # Add the simplified mapping
413
- }
414
- end
415
-
416
126
  def handle_error_response(response)
417
127
  error_message = "ICE service error: #{response.code}"
418
128
 
419
129
  begin
420
130
  error_details = JSON.parse(response.body)
421
- error_message += " - #{error_details['message'] || error_details['error'] || response.body}"
422
- rescue
131
+ error_message += " - #{error_details["message"] || error_details["error"] || response.body}"
132
+ rescue StandardError
423
133
  error_message += " - #{response.body}"
424
134
  end
425
135
 
426
136
  logger.error(error_message)
427
137
  { success: false, error: error_message }
428
138
  end
429
-
430
- # Helper method to format ICE dates to standard format
431
- def format_ice_date(ice_date)
432
- return nil unless ice_date
433
-
434
- # ICE dates are in format: YYYYMMDDHHMMSS.000+0000
435
- if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
436
- year = $1
437
- month = $2
438
- day = $3
439
- "#{year}-#{month}-#{day}"
440
- else
441
- ice_date
442
- end
443
- end
444
-
445
- # Helper method to format dates for ICE (YYYYMMDD)
446
- def format_date_for_ice(date_string)
447
- return nil unless date_string
448
-
449
- begin
450
- date = Date.parse(date_string)
451
- # Format as YYYYMMDD
452
- date.strftime("%Y%m%d")
453
- rescue
454
- # If parsing fails, try to use the original string
455
- date_string
456
- end
457
- end
458
139
  end
459
140
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Extracts ICE's evaluation of each administered dose from the vMR patient
5
+ # element. ICE nests one substanceAdministrationEvent per vaccine group under
6
+ # each input event, so a combination vaccine yields one evaluation per group
7
+ # it counts toward.
8
+ class EvaluationsParser
9
+ include Support
10
+
11
+ def initialize(patient)
12
+ @patient = patient
13
+ end
14
+
15
+ def evaluations
16
+ events = patient.xpath("./clinicalStatements/substanceAdministrationEvents/substanceAdministrationEvent")
17
+
18
+ events.flat_map do |event|
19
+ event_id = event.xpath("./id").first&.[]("root")
20
+ cvx = event.xpath("./substance/substanceCode").first&.[]("code")
21
+
22
+ event.xpath("./relatedClinicalStatement/substanceAdministrationEvent").filter_map do |component|
23
+ parse_evaluation(component, event_id:, cvx:)
24
+ end
25
+ end
26
+ end
27
+
28
+ private
29
+ attr_reader :patient
30
+
31
+ # Build the evaluation for one vaccine group from a nested event.
32
+ # Returns nil when the event has no evaluation status observation.
33
+ def parse_evaluation(component, event_id:, cvx:)
34
+ observation = component.xpath("./relatedClinicalStatement/observationResult[observationValue/concept]").first
35
+ return unless observation
36
+
37
+ focus = observation.xpath("./observationFocus").first
38
+ status = observation.xpath("./observationValue/concept").first
39
+ dose_number = component.xpath("./doseNumber").first&.[]("value")
40
+ valid = component.xpath("./isValid").first&.[]("value")
41
+
42
+ {
43
+ event_id:,
44
+ cvx:,
45
+ administered_on: format_ice_date(component.xpath("./administrationTimeInterval").first&.[]("low")),
46
+ vaccine_group: { code: focus&.[]("code"), name: focus&.[]("displayName") },
47
+ dose_number: dose_number&.to_i,
48
+ valid: valid.nil? ? nil : valid == "true",
49
+ status: { code: status["code"], name: status["displayName"] },
50
+ reasons: observation.xpath("./interpretation").map do |interpretation|
51
+ { code: interpretation["code"], name: interpretation["displayName"] }
52
+ end
53
+ }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "securerandom"
5
+
6
+ module HlnIce
7
+ # Builds the ICE evaluate request for a patient: the vMR XML, and the JSON
8
+ # payload that carries it base64 encoded.
9
+ #
10
+ # Does no logging; the Client decides what may be logged.
11
+ class RequestBuilder
12
+ include Support
13
+
14
+ def initialize(patient_data)
15
+ @patient_data = patient_data
16
+
17
+ # Extract patient information
18
+ @patient_id = patient_data[:patient][:id]
19
+ @date_of_birth = patient_data[:patient][:date_of_birth]
20
+ @gender = patient_data[:patient][:gender]
21
+ @gender = "U" if blank?(@gender) # Set default to 'U' if blank
22
+ end
23
+
24
+ def xml
25
+ @xml ||= build_xml
26
+ end
27
+
28
+ def payload
29
+ # Base64 encode the XML
30
+ base64_encoded_payload = Base64.strict_encode64(xml)
31
+
32
+ # Build the full payload
33
+ {
34
+ "interactionId" => {
35
+ "scopingEntityId" => "org.nyc.cir",
36
+ "interactionId" => "#{patient_id}-#{Time.now.to_i}",
37
+ "submissionTime" => (Time.now.to_f * 1000).to_i
38
+ },
39
+ "evaluationRequest" => {
40
+ "clientLanguage" => "en",
41
+ "clientTimeZoneOffset" => "+0000",
42
+ "kmEvaluationRequest" => [
43
+ {
44
+ "kmId" => {
45
+ "scopingEntityId" => "org.nyc.cir",
46
+ "businessId" => "ICE",
47
+ "version" => "1.0.0"
48
+ }
49
+ }
50
+ ],
51
+ "dataRequirementItemData" => [
52
+ {
53
+ "driId" => {
54
+ "containingEntityId" => {
55
+ "scopingEntityId" => "org.nyc.cir",
56
+ "businessId" => "ICEData",
57
+ "version" => "1.0.0"
58
+ },
59
+ "itemId" => "cdsPayload"
60
+ },
61
+ "data" => {
62
+ "informationModelSSId" => {
63
+ "scopingEntityId" => "org.opencds.vmr",
64
+ "businessId" => "VMR",
65
+ "version" => "1.0"
66
+ },
67
+ "base64EncodedPayload" => [base64_encoded_payload]
68
+ }
69
+ }
70
+ ]
71
+ }
72
+ }
73
+ end
74
+
75
+ private
76
+ attr_reader :patient_data, :patient_id, :date_of_birth, :gender
77
+
78
+ def build_xml
79
+ # Build the VMR XML
80
+ xml_payload = <<~XML
81
+ <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
82
+ <ns4:cdsInput xmlns:ns2="org.opencds"
83
+ xmlns:ns3="org.opencds.vmr.v1_0.schema.vmr"
84
+ xmlns:ns4="org.opencds.vmr.v1_0.schema.cdsinput"
85
+ xmlns:ns5="org.opencds.vmr.v1_0.schema.cdsoutput">
86
+ <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
87
+ <cdsContext>
88
+ <cdsSystemUserPreferredLanguage code="en" codeSystem="2.16.840.1.113883.6.99" displayName="English"/>
89
+ </cdsContext>
90
+ <vmrInput>
91
+ <templateId root="2.16.840.1.113883.3.795.11.1.1"/>
92
+ <patient>
93
+ <templateId root="2.16.840.1.113883.3.795.11.2.1.1"/>
94
+ <id root="2.16.840.1.113883.3.795.12.100.11" extension="#{patient_id}"/>
95
+ <demographics>
96
+ <birthTime value="#{format_date_for_ice(date_of_birth)}"/>
97
+ <gender code="#{gender}" codeSystem="2.16.840.1.113883.5.1"/>
98
+ </demographics>
99
+ <clinicalStatements>
100
+ <substanceAdministrationEvents>
101
+ XML
102
+
103
+ # Add immunization records if available
104
+ if present?(patient_data[:immunizations])
105
+ patient_data[:immunizations].each do |imm|
106
+ next unless present?(imm.dig(:clinicalStatements, :substanceAdministrationEvents))
107
+
108
+ imm[:clinicalStatements][:substanceAdministrationEvents].each do |event|
109
+ next unless present?(event.dig(:substance, :substanceCode))
110
+
111
+ substance_code = event[:substance][:substanceCode]
112
+
113
+ # Format dates to ICE format (YYYYMMDD)
114
+ low_date = format_date_for_ice(event[:administrationTimeInterval][:low])
115
+ high_date = format_date_for_ice(event[:administrationTimeInterval][:high])
116
+
117
+ xml_payload += <<~XML
118
+ <substanceAdministrationEvent>
119
+ <templateId root="2.16.840.1.113883.3.795.11.9.1.1"/>
120
+ <id root="#{event[:id] || SecureRandom.uuid}"/>
121
+ <substanceAdministrationGeneralPurpose code="384810002" codeSystem="2.16.840.1.113883.6.5"/>
122
+ <substance>
123
+ <id root="#{event[:substance][:id] || SecureRandom.uuid}"/>
124
+ <substanceCode code="#{substance_code[:code]}"
125
+ codeSystem="2.16.840.1.113883.12.292"
126
+ displayName="#{substance_code[:displayName]}"
127
+ originalText="#{substance_code[:displayName]}"/>
128
+ </substance>
129
+ <administrationTimeInterval low="#{low_date}" high="#{high_date}"/>
130
+ </substanceAdministrationEvent>
131
+ XML
132
+ end
133
+ end
134
+ end
135
+
136
+ # Close the XML
137
+ xml_payload + <<~XML
138
+ </substanceAdministrationEvents>
139
+ </clinicalStatements>
140
+ </patient>
141
+ </vmrInput>
142
+ </ns4:cdsInput>
143
+ XML
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "nokogiri"
6
+
7
+ module HlnIce
8
+ # Parses the body of an ICE evaluate response into the result data:
9
+ # patient, recommendations, evaluations, and simplified status.
10
+ #
11
+ # Does no logging; the Client decides what may be logged.
12
+ class ResponseParser
13
+ include Support
14
+
15
+ def initialize(response_body)
16
+ @response_body = response_body
17
+ end
18
+
19
+ # Returns nil when the response carries no vMR payload. Raises if the
20
+ # body cannot be parsed.
21
+ def data
22
+ result = JSON.parse(response_body)
23
+
24
+ base64_result = result.dig("finalKMEvaluationResponse", 0, "kmEvaluationResultData", 0, "data",
25
+ "base64EncodedPayload", 0)
26
+ return unless base64_result
27
+
28
+ # Parse the XML response
29
+ parse_xml_response(Base64.decode64(base64_result))
30
+ end
31
+
32
+ private
33
+ attr_reader :response_body
34
+
35
+ def parse_xml_response(xml_string)
36
+ # Parse the XML
37
+ doc = Nokogiri::XML(xml_string)
38
+
39
+ # Remove namespaces to simplify parsing
40
+ doc.remove_namespaces!
41
+
42
+ # Extract patient information
43
+ patient = doc.xpath("//patient").first
44
+ return { raw_xml: xml_string } unless patient
45
+
46
+ patient_id = patient.xpath("./id").first&.[]("extension")
47
+ birth_time = patient.xpath("./demographics/birthTime").first&.[]("value")
48
+ gender = patient.xpath("./demographics/gender").first&.[]("code")
49
+
50
+ # Format birth date if present
51
+ birth_date = format_ice_date(birth_time)
52
+
53
+ # Extract vaccine recommendations
54
+ all_recommendations = []
55
+
56
+ doc.xpath("//substanceAdministrationProposal").each do |proposal|
57
+ # Get the substance code and name
58
+ substance_element = proposal.xpath("./substance/substanceCode").first
59
+ next unless substance_element
60
+
61
+ vaccine_code = substance_element["code"]
62
+ vaccine_name = substance_element["displayName"]
63
+ vaccine_code_system = substance_element["codeSystem"]
64
+
65
+ # Get the clinical observation result carrying the recommendation status.
66
+ # A proposal may contain other observation results (e.g. schedule
67
+ # authorities), in any order, so select the one with a status concept.
68
+ observation = proposal.xpath(".//observationResult[observationValue/concept]").first
69
+ next unless observation
70
+
71
+ # Get recommendation status
72
+ status_element = observation.xpath(".//observationValue/concept").first
73
+
74
+ status_code = status_element["code"]
75
+ status_name = status_element["displayName"]
76
+
77
+ # Get interpretation reasons
78
+ reasons = []
79
+ observation.xpath(".//interpretation").each do |interpretation|
80
+ reason = {
81
+ code: interpretation["code"],
82
+ name: interpretation["displayName"]
83
+ }
84
+
85
+ # Add supplemental text if available
86
+ reason[:supplemental_text] = interpretation["originalText"] if interpretation["originalText"]
87
+
88
+ reasons << reason
89
+ end
90
+
91
+ # Get administration time intervals if available
92
+ proposed_interval = proposal.xpath("./proposedAdministrationTimeInterval").first
93
+ valid_interval = proposal.xpath("./validAdministrationTimeInterval").first
94
+
95
+ intervals = {}
96
+ if proposed_interval
97
+ intervals[:proposed] = {
98
+ low: format_ice_date(proposed_interval["low"]),
99
+ high: format_ice_date(proposed_interval["high"])
100
+ }.compact
101
+ end
102
+
103
+ if valid_interval
104
+ intervals[:valid] = {
105
+ low: format_ice_date(valid_interval["low"]),
106
+ high: format_ice_date(valid_interval["high"])
107
+ }.compact
108
+ end
109
+
110
+ # Build the recommendation object
111
+ recommendation = {
112
+ vaccine: {
113
+ code: vaccine_code,
114
+ code_system: vaccine_code_system,
115
+ name: vaccine_name
116
+ },
117
+ status: {
118
+ code: status_code,
119
+ name: status_name
120
+ },
121
+ reasons:
122
+ }
123
+
124
+ # Only add intervals if they exist
125
+ recommendation[:intervals] = intervals if present?(intervals)
126
+
127
+ # Only add schedule authorities if the service returned them
128
+ # (requires the ICE outputScheduleAuthorities property)
129
+ schedule_authorities = parse_schedule_authorities(proposal)
130
+ recommendation[:schedule_authorities] = schedule_authorities if present?(schedule_authorities)
131
+
132
+ all_recommendations << recommendation
133
+ end
134
+
135
+ # Build the final result structure
136
+ {
137
+ raw_xml: xml_string,
138
+ patient: {
139
+ id: patient_id,
140
+ birth_date: birth_date || birth_time,
141
+ gender:
142
+ }.compact,
143
+ recommendations: all_recommendations, # Keep the original flat list for backward compatibility
144
+ evaluations: EvaluationsParser.new(patient).evaluations,
145
+ simplified_status: SimplifiedStatus.new(all_recommendations).to_h
146
+ }
147
+ end
148
+
149
+ # Extract the schedule authorities (e.g. ACIP_CDC, AAP) for a proposal.
150
+ # Returns an empty array when the service did not include them.
151
+ def parse_schedule_authorities(proposal)
152
+ authorities = proposal.xpath(
153
+ ".//observationResult[observationFocus/@code='ICE_VACCINE_GROUP_SCHEDULE_AUTHORITIES']/interpretation"
154
+ )
155
+
156
+ authorities.map do |interpretation|
157
+ {
158
+ code: interpretation["code"],
159
+ name: interpretation["displayName"]
160
+ }
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HlnIce
4
+ # Maps parsed recommendations to a simplified status keyed by our
5
+ # immunization keys, e.g. { ipv_opv: "overdue", mmr: "compliant" }.
6
+ class SimplifiedStatus
7
+ # Mapping from ICE status codes to our status keys
8
+ STATUS_MAPPING = {
9
+ "CONDITIONAL" => "conditional",
10
+ "FUTURE_RECOMMENDED" => "compliant",
11
+ "NOT_RECOMMENDED" => "compliant",
12
+ "RECOMMENDED" => "overdue"
13
+ }.freeze
14
+
15
+ # Mapping from ICE vaccine groups to our immunization keys
16
+ VACCINE_MAPPING = {
17
+ "DTP Vaccine Group" => :dtap_tdap,
18
+ "Hep A Vaccine Group" => :hep_a,
19
+ "Hep B Vaccine Group" => :hep_b,
20
+ "Hib Vaccine Group" => :hib,
21
+ "HPV Vaccine Group" => :hpv,
22
+ "Meningococcal Vaccine Group" => :mcv4,
23
+ "MMR Vaccine Group" => :mmr,
24
+ "Pneumococcal Vaccine Group" => :pcv,
25
+ "Polio Vaccine Group" => :ipv_opv,
26
+ "Varicella Vaccine Group" => :var
27
+ }.freeze
28
+
29
+ def initialize(recommendations)
30
+ @recommendations = recommendations
31
+ end
32
+
33
+ def to_h
34
+ # Create simplified immunization status mapping
35
+ simplified_status = {}
36
+
37
+ # Process all recommendations to create the simplified mapping
38
+ recommendations.each do |recommendation|
39
+ vaccine_name = recommendation[:vaccine][:name]
40
+ status_code = recommendation[:status][:code]
41
+
42
+ # Map the vaccine name to our key
43
+ vaccine_key = VACCINE_MAPPING[vaccine_name]
44
+ next unless vaccine_key # Skip if we don't have a mapping
45
+
46
+ # Map the status code to our status
47
+ status = STATUS_MAPPING[status_code] || "overdue" # Default to overdue if unknown
48
+
49
+ # Special handling for conditional status based on reasons
50
+ if status == "conditional"
51
+ # Check if any reason indicates medical exemption
52
+ has_medical_exemption = recommendation[:reasons].any? { |r| r[:code] == "MEDICAL_EXEMPTION" }
53
+ status = "medically_exempt" if has_medical_exemption
54
+ end
55
+
56
+ # Store in our simplified mapping
57
+ simplified_status[vaccine_key] = status
58
+ end
59
+
60
+ simplified_status
61
+ end
62
+
63
+ private
64
+ attr_reader :recommendations
65
+ end
66
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module HlnIce
6
+ # Helpers shared by the request builder and response parsers. Included as
7
+ # private instance methods.
8
+ module Support
9
+ private
10
+ # Plain-Ruby equivalent of ActiveSupport's `blank?`.
11
+ def blank?(value)
12
+ return true if value.nil?
13
+ return value.strip.empty? if value.is_a?(String)
14
+ return value.empty? if value.respond_to?(:empty?)
15
+
16
+ false
17
+ end
18
+
19
+ # Plain-Ruby equivalent of ActiveSupport's `present?`.
20
+ def present?(value)
21
+ !blank?(value)
22
+ end
23
+
24
+ # Helper method to format ICE dates to standard format
25
+ def format_ice_date(ice_date)
26
+ return nil unless ice_date
27
+
28
+ # ICE dates are in format: YYYYMMDDHHMMSS.000+0000
29
+ if ice_date.match(/^(\d{4})(\d{2})(\d{2})/)
30
+ year = ::Regexp.last_match(1)
31
+ month = ::Regexp.last_match(2)
32
+ day = ::Regexp.last_match(3)
33
+ "#{year}-#{month}-#{day}"
34
+ else
35
+ ice_date
36
+ end
37
+ end
38
+
39
+ # Helper method to format dates for ICE (YYYYMMDD)
40
+ def format_date_for_ice(date_string)
41
+ return nil unless date_string
42
+
43
+ begin
44
+ date = Date.parse(date_string)
45
+ # Format as YYYYMMDD
46
+ date.strftime("%Y%m%d")
47
+ rescue StandardError
48
+ # If parsing fails, try to use the original string
49
+ date_string
50
+ end
51
+ end
52
+ end
53
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module HlnIce
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/hln_ice.rb CHANGED
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "hln_ice/version"
4
+ require_relative "hln_ice/support"
5
+ require_relative "hln_ice/request_builder"
6
+ require_relative "hln_ice/evaluations_parser"
7
+ require_relative "hln_ice/simplified_status"
8
+ require_relative "hln_ice/response_parser"
4
9
  require_relative "hln_ice/client"
5
10
 
6
11
  module HlnIce
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hln_ice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam Sohn
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -61,13 +61,18 @@ extra_rdoc_files: []
61
61
  files:
62
62
  - ".rspec"
63
63
  - ".rubocop.yml"
64
+ - ".rubocop_todo.yml"
64
65
  - CHANGELOG.md
65
66
  - LICENSE.txt
66
67
  - README.md
67
68
  - Rakefile
68
- - hln_ice.gemspec
69
69
  - lib/hln_ice.rb
70
70
  - lib/hln_ice/client.rb
71
+ - lib/hln_ice/evaluations_parser.rb
72
+ - lib/hln_ice/request_builder.rb
73
+ - lib/hln_ice/response_parser.rb
74
+ - lib/hln_ice/simplified_status.rb
75
+ - lib/hln_ice/support.rb
71
76
  - lib/hln_ice/version.rb
72
77
  - sig/hln_ice.rbs
73
78
  homepage: https://github.com/PrimaryDotHealth/hln_ice
@@ -78,6 +83,7 @@ metadata:
78
83
  homepage_uri: https://github.com/PrimaryDotHealth/hln_ice
79
84
  source_code_uri: https://github.com/PrimaryDotHealth/hln_ice
80
85
  changelog_uri: https://github.com/PrimaryDotHealth/hln_ice/blob/main/CHANGELOG.md
86
+ rubygems_mfa_required: 'true'
81
87
  rdoc_options: []
82
88
  require_paths:
83
89
  - lib
@@ -92,7 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
98
  - !ruby/object:Gem::Version
93
99
  version: '0'
94
100
  requirements: []
95
- rubygems_version: 4.0.15
101
+ rubygems_version: 3.6.5
96
102
  specification_version: 4
97
103
  summary: Ruby client for the HLN ICE (Immunization Calculation Engine) OpenCDS service.
98
104
  test_files: []
data/hln_ice.gemspec DELETED
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/hln_ice/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "hln_ice"
7
- spec.version = HlnIce::VERSION
8
- spec.authors = ["Sam Sohn"]
9
- spec.email = ["sam@primary.health"]
10
-
11
- spec.summary = "Ruby client for the HLN ICE (Immunization Calculation Engine) OpenCDS service."
12
- spec.description = "Evaluates a patient's immunization history against the HLN ICE forecasting " \
13
- "rules and returns both the raw recommendations and a simplified status mapping."
14
- spec.homepage = "https://github.com/PrimaryDotHealth/hln_ice"
15
- spec.license = "MIT"
16
- spec.required_ruby_version = ">= 3.1.0"
17
-
18
- spec.metadata["allowed_push_host"] = "https://rubygems.org"
19
-
20
- spec.metadata["homepage_uri"] = spec.homepage
21
- spec.metadata["source_code_uri"] = spec.homepage
22
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
23
-
24
- # Specify which files should be added to the gem when it is released.
25
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
26
- spec.files = Dir.chdir(__dir__) do
27
- `git ls-files -z`.split("\x0").reject do |f|
28
- (File.expand_path(f) == __FILE__) ||
29
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
30
- end
31
- end
32
- spec.bindir = "exe"
33
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
34
- spec.require_paths = ["lib"]
35
-
36
- spec.add_dependency "base64", "~> 0.2"
37
- spec.add_dependency "httparty", "~> 0.21"
38
- spec.add_dependency "nokogiri", "~> 1.15"
39
-
40
- # For more information and examples about making a new gem, check out our
41
- # guide at: https://bundler.io/guides/creating_gem.html
42
- end