From 049207f5501b27c8147ab4fc4a41f25ab60cdf0a Mon Sep 17 00:00:00 2001 From: dfitchett <135860892+dfitchett@users.noreply.github.com> Date: Fri, 15 Nov 2024 10:51:01 -0800 Subject: [PATCH] Add flash picker for ALS with feature flag --- .../saved_claim/disability_compensation.rb | 3 +- .../claim_fast_tracking/flash_picker.rb | 79 +++++++++++++++ config/features.yml | 4 + .../disability_compensation_form_flashes.rb | 14 ++- ...sability_compensation_form_flashes_spec.rb | 49 +++++++++- .../claim_fast_tracking/flash_picker_spec.rb | 98 +++++++++++++++++++ 6 files changed, 244 insertions(+), 3 deletions(-) create mode 100644 app/services/claim_fast_tracking/flash_picker.rb create mode 100644 spec/services/claim_fast_tracking/flash_picker_spec.rb diff --git a/app/models/saved_claim/disability_compensation.rb b/app/models/saved_claim/disability_compensation.rb index 8801b7a63c0..2e017374117 100644 --- a/app/models/saved_claim/disability_compensation.rb +++ b/app/models/saved_claim/disability_compensation.rb @@ -23,6 +23,7 @@ def to_submission_data(user) form4142 = EVSS::DisabilityCompensationForm::Form4142.new(user, @form_hash.deep_dup).translate form526 = @form_hash.deep_dup dis_form = EVSS::DisabilityCompensationForm::DataTranslationAllClaim.new(user, form526, form4142.present?).translate + claimed_disabilities = dis_form.dig('form526', 'disabilities') form526_uploads = form526['form526'].delete('attachments') { @@ -33,7 +34,7 @@ def to_submission_data(user) @form_hash.deep_dup).translate, Form526Submission::FORM_8940 => EVSS::DisabilityCompensationForm::Form8940.new(user, @form_hash.deep_dup).translate, - 'flashes' => BGS::DisabilityCompensationFormFlashes.new(user, @form_hash.deep_dup).translate + 'flashes' => BGS::DisabilityCompensationFormFlashes.new(user, @form_hash.deep_dup, claimed_disabilities).translate }.to_json end end diff --git a/app/services/claim_fast_tracking/flash_picker.rb b/app/services/claim_fast_tracking/flash_picker.rb new file mode 100644 index 00000000000..2e23eb7525b --- /dev/null +++ b/app/services/claim_fast_tracking/flash_picker.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module ClaimFastTracking + class FlashPicker + DEFAULT_FUZZY_TOLERANCE = 0.2 + MIN_FUZZY_MATCH_LENGTH = 6 + MIN_LENGTH_RATIO = 0.9 + + ALS_DC = 8017 + ALS_PARTIAL_MATCH_TERMS = [ + 'amyotrophic lateral sclerosis', + '(als)' + ].freeze + ALS_MATCH_TERMS = (ALS_PARTIAL_MATCH_TERMS + [ + 'als', + 'lou gehrig disease', + 'lou gehrigs disease', + 'lou gehrig\'s disease', + 'lou gehrig', + 'lou gehrigs', + 'lou gehrig\'s' + ]).freeze + + def self.als?(claimed_disabilities) + return if claimed_disabilities.pluck('diagnosticCode').include?(ALS_DC) + + claimed_disabilities.map { |disability| disability['name']&.downcase }.compact.any? do |name| + partial_matches?(name, ALS_PARTIAL_MATCH_TERMS) || matches?(name, ALS_MATCH_TERMS) + end + end + + def self.partial_matches?(name, match_terms) + match_terms = [match_terms] unless match_terms.is_a?(Array) + + match_terms.any? { |term| name.include?(term) } + end + + def self.matches?(name, + match_terms, + tolerance = DEFAULT_FUZZY_TOLERANCE, + min_length_ratio = MIN_LENGTH_RATIO, + min_length_limit = MIN_FUZZY_MATCH_LENGTH) + match_terms = [match_terms] unless match_terms.is_a?(Array) + + match_terms.any? do |term| + # Early exact match check (case insensitive) + return true if name.casecmp?(term) + + # Prevent fuzzy matching for very short terms (e.g., less than min_length_limit) + next false if name.length < min_length_limit || term.length < min_length_limit + + # Calculate the length ratio based on the shorter and longer lengths + shorter_length = [name.length, term.length].min + longer_length = [name.length, term.length].max + + # Skip comparison if the length ratio is below minimum length ratio, indicating a significant length difference + next false if shorter_length.to_f / longer_length < min_length_ratio + + # Calculate the Levenshtein threshold based on tolerance and maximum length + return true if fuzzy_match?(name, term, longer_length, tolerance) + end + end + + def self.fuzzy_match?(name, term, longer_length, tolerance = DEFAULT_FUZZY_TOLERANCE) + threshold = (longer_length * tolerance).ceil + distance = StringHelpers.levenshtein_distance(name, term) + + if distance - 1 == threshold + Rails.logger.info( + 'FlashPicker close fuzzy match for condition', + { name: name, match_term: term, distance: distance, threshold: threshold } + ) + end + distance <= threshold + end + + private_class_method :partial_matches?, :matches?, :fuzzy_match? + end +end diff --git a/config/features.yml b/config/features.yml index 5ce533c5500..ed0c5ae15a3 100644 --- a/config/features.yml +++ b/config/features.yml @@ -481,6 +481,10 @@ features: disability_526_ep_merge_api: actor_type: user description: enables sending 526 claims with a pending EP to VRO EP Merge API for automated merging. + disability_526_ee_process_als_flash: + actor_type: user + description: enables adding applicable flashes to disability_526 prior to submission. + enable_in_development: true disability_526_toxic_exposure: actor_type: user description: enables new pages, processing, and submission of toxic exposure claims diff --git a/lib/bgs/disability_compensation_form_flashes.rb b/lib/bgs/disability_compensation_form_flashes.rb index 5ccb7ef7484..4693315afba 100644 --- a/lib/bgs/disability_compensation_form_flashes.rb +++ b/lib/bgs/disability_compensation_form_flashes.rb @@ -2,9 +2,10 @@ module BGS class DisabilityCompensationFormFlashes - def initialize(user, form_content) + def initialize(user, form_content, claimed_disabilities) @user = user @form_content = form_content['form526'] + @claimed_disabilities = claimed_disabilities @flashes = [] end @@ -32,6 +33,7 @@ def translate @flashes << 'Terminally Ill' if terminally_ill? @flashes << 'Priority Processing - Veteran over age 85' if over_85? @flashes << 'POW' if pow? + @flashes << 'Amyotrophic Lateral Sclerosis' if als? @flashes end @@ -50,5 +52,15 @@ def over_85? def pow? @form_content['confinements'].present? end + + def als? + feature_enabled = Flipper.enabled?(:disability_526_ee_process_als_flash, @user) + add_als = ClaimFastTracking::FlashPicker.als?(@claimed_disabilities) + Rails.logger.error('FlashPicker for ALS', { feature_enabled:, add_als: }) + feature_enabled && add_als + rescue => e + Rails.logger.error("Failed to determine need for ALS flash: #{e.message}.", backtrace: e.backtrace) + false + end end end diff --git a/spec/lib/bgs/disability_compensation_form_flashes_spec.rb b/spec/lib/bgs/disability_compensation_form_flashes_spec.rb index 88eabf53d7f..cc9c0fa0f60 100644 --- a/spec/lib/bgs/disability_compensation_form_flashes_spec.rb +++ b/spec/lib/bgs/disability_compensation_form_flashes_spec.rb @@ -4,7 +4,7 @@ require 'bgs/disability_compensation_form_flashes' Rspec.describe BGS::DisabilityCompensationFormFlashes do - subject { described_class.new(user, form_content) } + subject { described_class.new(user, form_content, disabilities) } let(:form_content) do JSON.parse( @@ -13,6 +13,21 @@ end let(:flashes) { ['Homeless', 'Priority Processing - Veteran over age 85', 'POW'] } + let(:disabilities) do + [ + { + 'name' => 'PTSD (post traumatic stress disorder)', + 'diagnosticCode' => 9999, + 'disabilityActionType' => 'NEW', + 'ratedDisabilityId' => '1100583' + }, + { + 'name' => 'PTSD personal trauma', + 'disabilityActionType' => 'SECONDARY', + 'serviceRelevance' => "Caused by a service-connected disability\nPTSD (post traumatic stress disorder)" + } + ] + end let(:user) { build(:disabilities_compensation_user) } before do @@ -23,5 +38,37 @@ it 'returns correctly flashes to send to async job' do expect(subject.translate).to eq flashes end + + context 'when the user has ALS condition' do + let(:disabilities) do + [ + { + 'name' => 'ALS (amyotrophic lateral sclerosis)', + 'disabilityActionType' => 'NEW', + 'serviceRelevance' => "Caused by an in-service event, injury, or exposure\ntest" + } + ] + end + + context 'when feature is enabled' do + before do + allow(Flipper).to receive(:enabled?).with(:disability_526_ee_process_als_flash, user).and_return(true) + end + + it 'returns ALS flash' do + expect(subject.translate).to include('Amyotrophic Lateral Sclerosis') + end + end + + context 'when feature is disabled' do + before do + allow(Flipper).to receive(:enabled?).with(:disability_526_ee_process_als_flash, user).and_return(false) + end + + it 'returns without flash' do + expect(subject.translate).not_to include('Amyotrophic Lateral Sclerosis') + end + end + end end end diff --git a/spec/services/claim_fast_tracking/flash_picker_spec.rb b/spec/services/claim_fast_tracking/flash_picker_spec.rb new file mode 100644 index 00000000000..5ccabffc0cc --- /dev/null +++ b/spec/services/claim_fast_tracking/flash_picker_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ClaimFastTracking::FlashPicker do + subject { described_class } + + describe '#als?' do + context 'when testing for ALS' do + context 'when the disabilities is empty' do + let(:disabilities) { [] } + + it 'returns an empty array' do + expect(subject.als?(disabilities)).to eq(false) + end + end + + context 'when the disabilities does not contain ALS' do + let(:disabilities) { [{ 'name' => 'Tinnitus', 'diagnosticCode' => 6260 }] } + + it 'returns false' do + expect(subject.als?(disabilities)).to eq(false) + end + end + + context 'when the disability name exactly matches any of the ALS_TERMS' do + described_class::ALS_MATCH_TERMS.each do |term| + it "returns true for term #{term}" do + expect(subject.als?([{ 'name' => term }])).to eq(true) + end + end + end + + context 'when the disability name has partial match' do + [ + { condition: 'amyotrophic lateral sclerosis', reason: 'full name' }, + { condition: '(als)', reason: 'acronym only in parentheses' }, + { + condition: 'amyotrophic lateral sclerosis with lower extremity weakness, abnormal speech and abnormal gait', + reason: 'full name with symptoms' + }, + { condition: 'als amyotrophic lateral sclerosis', reason: 'full name with acronym on left' }, + { condition: 'als (amyotrophic lateral sclerosis)', reason: 'full name in parentheses with acronym on left' }, + { condition: 'als amyotrophic lateral sclerosis', reason: 'full name with acronym on left' }, + { condition: '(als) amyotrophic lateral sclerosis', reason: 'full name with acronym on left in parentheses' }, + { condition: 'amyotrophic lateral sclerosis als', reason: 'full name with acronym on right' }, + { condition: '(amyotrophic lateral sclerosis) als', + reason: 'full name in parentheses with acronym on right' }, + { condition: 'amyotrophic lateral sclerosis (als)', + reason: 'full name with acronym on right in parentheses' }, + { condition: 'amyotropic lateril scerolses (als)', + reason: 'full name with several letter typo and acronym in parentheses' } + ].each do |test_case| + it "returns true for term #{test_case[:reason]}" do + disabilities = [{ 'name' => test_case[:condition] }] + expect(subject.als?(disabilities)).to eq(true) + end + end + end + + context 'when the disabilities contains a fuzzy match' do + [ + { condition: 'amyotrophic lateral scleroses', reason: 'Pluralization error' }, + { condition: 'Amyothrophic lateral sclerosis', reason: 'Minor typo' }, + { condition: 'amyotrophic lateral sclerosiss', reason: 'Minor double letter typo' }, + { condition: 'amyotropic lareral sclerosiss', reason: 'several letter typo' }, + { condition: 'amyotrophic lateral scelrosis', reason: 'Phonetic misspelling' }, + { condition: 'lou gherig disease', reason: 'Phonetic misspelling of Gehrig' }, + { condition: 'lou gehrigs desease', reason: 'Double typo' }, + { condition: 'lou gehrigs desase', reason: 'Phonetic error' }, + { condition: "lou gehrig's disease", reason: 'Included Apostrophe with disease' }, + { condition: "lou gehrig'", reason: 'Included Apostrophe without s' }, + { condition: 'lou gehrig', reason: 'Missing possessive "s"' } + ].each do |test_case| + it "returns true for term with #{test_case[:reason]}" do + disabilities = [{ 'name' => test_case[:condition] }] + expect(subject.als?(disabilities)).to eq(true) + end + end + end + + context 'when the disabilities does not contains any fuzzy match' do + [ + { condition: 'ALT', reason: 'wrong acronym but too small to fuzzy match' }, + { condition: 'sclerosis disease', reason: 'Too vague' }, + { condition: 'Lou diseases', reason: 'Doesn’t specify' }, + { condition: 'lateral disease', reason: 'Partial match with missing context' }, + { condition: 'neuro disease', reason: 'Different condition entirely' } + ].each do |test_case| + it "returns false for term with #{test_case[:reason]}" do + disabilities = [{ 'name' => test_case[:condition] }] + expect(subject.als?(disabilities)).to eq(false) + end + end + end + end + end +end