From f497c3751fcf790b1f7e1f1cc0290b1f721b89fd Mon Sep 17 00:00:00 2001 From: phc007 <11081221+phc007@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:47:54 -0400 Subject: [PATCH 1/4] Remove smoking prevention check from rules --- src/main/resources/oscar/oscarPrevention/prevention.drl | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/oscar/oscarPrevention/prevention.drl b/src/main/resources/oscar/oscarPrevention/prevention.drl index cb3ac54de4a..01e9e16fe85 100644 --- a/src/main/resources/oscar/oscarPrevention/prevention.drl +++ b/src/main/resources/oscar/oscarPrevention/prevention.drl @@ -1162,7 +1162,6 @@ rule "LDCT 1" eval( prev.getAgeInYears() >= 55 ) eval( prev.getAgeInYears() <= 74 ) eval( prev.getNumberOfPreventionType("Smoking") > 0 ) - eval( !prev.isPreventionNever("Smoking") ) eval( prev.getHowManyMonthsSinceLast("LDCT") == -1 ) eval( !prev.isPreventionNever("LDCT") ) eval( !prev.isInelligible("LDCT") ) From aa3a3d41cb816e264232ff78db4d846ff9b2d75b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 16:16:01 +0000 Subject: [PATCH 2/4] fix: correct LDCT 1 smoking check using refused column instead of never column The Smoking prevention type stores the smoking answer in the `refused` column (0=Yes/current, 1=No, 2=Previous), not the `never` column. The `never` column is always 0 for all three smoking states, so the previous `!isPreventionNever("Smoking")` check was always true and failed to exclude non-smokers from LDCT recommendations. Changes: - commn/model/Prevention.java: add getRefusedRawValue() to expose raw refused char as int - PreventionItem.java: add refusedStatus field and getRefusedStatus() getter, populated from Prevention.getRefusedRawValue() - prevention/Prevention.java: add getRefusedStatus(String) and isCurrentlySmoking(String) public methods for DRL use; document the smoking refused column encoding and why isPreventionNever is incorrect - prevention.drl: replace removed eval with isCurrentlySmoking("Smoking"), update LDCT comment block and header API docs to explain the encoding Co-authored-by: phc007 --- .../carlos/commn/model/Prevention.java | 17 ++++++ .../carlos/prevention/Prevention.java | 54 +++++++++++++++++++ .../carlos/prevention/PreventionItem.java | 21 ++++++++ .../oscar/oscarPrevention/prevention.drl | 24 +++++++-- 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java index 1e08253ad2f..bc41503c82c 100644 --- a/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java @@ -235,6 +235,23 @@ public String getDeletedRawValue() { return String.valueOf(deleted); } + /** + * Returns the raw integer value of the {@code refused} field. + *

+ * Standard semantics: {@code 0}=active/completed, {@code 1}=refused, + * {@code 2}=ineligible, {@code 3}=completed externally. + *

+ * For the "Smoking" prevention type, this field is repurposed to encode + * smoking history status: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), + * {@code 2}=Previous (ex-smoker). + * + * @return int raw refused status value (0, 1, 2, or 3) + * @since 2026-03-14 + */ + public int getRefusedRawValue() { + return refused - '0'; + } + public List getPreventionExts() { return this.preventionExts; } diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java index ae2920f848a..592606e187d 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java @@ -302,6 +302,60 @@ public boolean isNotInelligible(String preventionType) { return !isInelligible(preventionType); } + /** + * Returns the raw refused status value for the most recent prevention record of the given type. + *

+ * Standard semantics: {@code 0}=active/completed, {@code 1}=refused, + * {@code 2}=ineligible, {@code 3}=completed externally. + *

+ * For the "Smoking" prevention type, the refused field is repurposed to encode + * smoking history status: + *

+ *

+ * Important note on {@code isPreventionNever("Smoking")}: The {@code never} + * column in the preventions table is separate from the {@code refused} column and is + * always {@code 0} for all three smoking states. Therefore {@code isPreventionNever} + * cannot be used to detect non-smokers; use {@code isCurrentlySmoking} instead. + * + * @param preventionType String the prevention type key (e.g. "Smoking", "LDCT") + * @return int the raw refused status value, or {@code -1} if no record exists + * @since 2026-03-14 + */ + public int getRefusedStatus(String preventionType) { + int status = -1; + Vector vec = (Vector) preventionTypes.get(preventionType); + if (vec != null && !vec.isEmpty()) { + PreventionItem p = (PreventionItem) vec.get(vec.size() - 1); + status = p.getRefusedStatus(); + } + return status; + } + + /** + * Returns {@code true} if the most recent prevention record for the given type indicates + * a "currently active" or "yes" response (refused status = 0). + *

+ * For the "Smoking" prevention type specifically, this returns {@code true} when the + * patient is a current smoker. The Smoking assessment stores its answer in the + * {@code refused} field: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), + * {@code 2}=Previous (ex-smoker). + *

+ * Use this method instead of {@code !isPreventionNever("Smoking")} for LDCT eligibility + * checks. The {@code never} column is always {@code 0} for all smoking states and cannot + * distinguish current smokers from non-smokers. + * + * @param preventionType String the prevention type key (e.g. "Smoking") + * @return boolean {@code true} if the most recent record has refused status = 0 + * @since 2026-03-14 + */ + public boolean isCurrentlySmoking(String preventionType) { + return getRefusedStatus(preventionType) == 0; + } + public int getHowManyMonthsSinceLast(String preventionType) { int retval = -1; diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java b/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java index 5b0dacdb81f..405a437e225 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java @@ -46,6 +46,9 @@ public class PreventionItem { Date nextDate = null; String never = null; boolean refused; + // Raw refused status value (0=active, 1=refused, 2=ineligible, 3=completedExternally). + // For the "Smoking" type: 0=Yes (current), 1=No (non-smoker), 2=Previous (ex-smoker). + private int refusedStatus = 0; private boolean inelligible = false; private boolean remoteEntry = false; @@ -78,6 +81,7 @@ public PreventionItem(Prevention pp) { this.never = ConversionUtils.toBoolString(pp.isNever()); this.nextDate = pp.getNextDate(); this.refused = pp.isRefused(); + this.refusedStatus = pp.getRefusedRawValue(); this.inelligible = pp.isIneligible(); } @@ -89,6 +93,23 @@ public boolean getNeverVal() { return ret; } + /** + * Returns the raw refused status value from the underlying prevention record. + *

+ * Standard semantics: {@code 0}=active/completed, {@code 1}=refused, + * {@code 2}=ineligible, {@code 3}=completed externally. + *

+ * For the "Smoking" prevention type, this field is repurposed to encode + * smoking history: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), + * {@code 2}=Previous (ex-smoker). + * + * @return int raw refused status value + * @since 2026-03-14 + */ + public int getRefusedStatus() { + return refusedStatus; + } + /** * Getter for property datePreformed. * diff --git a/src/main/resources/oscar/oscarPrevention/prevention.drl b/src/main/resources/oscar/oscarPrevention/prevention.drl index 01e9e16fe85..e3e51f6c2c5 100644 --- a/src/main/resources/oscar/oscarPrevention/prevention.drl +++ b/src/main/resources/oscar/oscarPrevention/prevention.drl @@ -51,6 +51,12 @@ // Status: isPreventionNever(String), isNextDateSet(String), isPassedNextDate(String), // isInelligible(String), isLastPreventionWithinRange(String, String, String), // isTodayinDateRange(String, String) +// Smoking: isCurrentlySmoking(String), getRefusedStatus(String) +// NOTE: isPreventionNever("Smoking") is INCORRECT for smoking checks — the +// "never" column is always 0 for all smoking states (Yes/No/Previous). +// The smoking answer is stored in the "refused" column: +// 0=Yes (current smoker), 1=No (non-smoker), 2=Previous (ex-smoker). +// Use isCurrentlySmoking("Smoking") to check for active smoking status. // Sex: isMale(), isFemale() // // Consequence methods: @@ -1144,13 +1150,20 @@ end // LDCT: Low-Dose CT Lung Cancer Screening (2 rules) // Canadian guideline (CTFPHC 2016): Annual LDCT for adults aged 55-74 who are // high-risk smokers (30+ pack-years or equivalent significant smoking history) -// Rule 1: WARNING if no LDCT on record for patients aged 55-74 with positive smoking history +// Rule 1: WARNING if no LDCT on record for patients aged 55-74 who are current smokers // Rule 2: WARNING if LDCT count is 1 or 2 and it is > 12 months old (annual x3 years) // LDCT 2 stops firing after 3rd annual screen (count >= 3). -// Precondition (heavy smoker detection): Smoking assessment must be on record AND -// not marked as "never" (non-smoker). This uses the Prevention module's "Smoking" type. -// A Smoking record exists (getNumberOfPreventionType("Smoking") > 0) AND -// the record is NOT marked as never-smoked (!isPreventionNever("Smoking")). +// +// Smoking status check (LDCT 1): +// The Smoking prevention type uses the "refused" column (NOT the "never" column) to +// store the patient's smoking answer. The "never" column is always 0 for all smoking +// states, so isPreventionNever("Smoking") cannot be used here. +// Smoking refused column encoding: +// 0 = Yes (currently smoking) <-- LDCT rule should fire +// 1 = No (not smoking) <-- LDCT rule should NOT fire +// 2 = Previous (ex-smoker) <-- LDCT rule should NOT fire +// isCurrentlySmoking("Smoking") returns true only when refused = 0. +// // Note: Full heavy-smoker detection (pack-years, cigarettes/day) requires measurement // values (NOSK > 23, POSK > 0, SKST = yes, SMK = yes, SmkS > 23) which are stored in // the measurements table and are not accessible from the Prevention DRL fact object. @@ -1162,6 +1175,7 @@ rule "LDCT 1" eval( prev.getAgeInYears() >= 55 ) eval( prev.getAgeInYears() <= 74 ) eval( prev.getNumberOfPreventionType("Smoking") > 0 ) + eval( prev.isCurrentlySmoking("Smoking") ) eval( prev.getHowManyMonthsSinceLast("LDCT") == -1 ) eval( !prev.isPreventionNever("LDCT") ) eval( !prev.isInelligible("LDCT") ) From 758ffc7ae7e500555904bcb66e30d56150affd49 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:22:36 +0000 Subject: [PATCH 3/4] fix: address code review issues in smoking prevention checks - Harden getRefusedRawValue() with char range guard (returns -1 for unexpected DB values instead of garbage int) - Initialize PreventionItem.refusedStatus to -1 sentinel instead of 0 to avoid false 'current smoker' classification for items created via constructors that do not hydrate from a Prevention record - Guard isCurrentlySmoking() to return false for non-Smoking types, preventing semantic misuse across other prevention categories Co-authored-by: phc007 --- .../github/carlos_emr/carlos/commn/model/Prevention.java | 8 ++++++-- .../github/carlos_emr/carlos/prevention/Prevention.java | 9 +++++++-- .../carlos_emr/carlos/prevention/PreventionItem.java | 4 +++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java index bc41503c82c..dde65be821c 100644 --- a/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/commn/model/Prevention.java @@ -245,11 +245,15 @@ public String getDeletedRawValue() { * smoking history status: {@code 0}=Yes (current smoker), {@code 1}=No (non-smoker), * {@code 2}=Previous (ex-smoker). * - * @return int raw refused status value (0, 1, 2, or 3) + * @return int raw refused status value (0, 1, 2, or 3), or {@code -1} if the stored + * value is outside the expected range * @since 2026-03-14 */ public int getRefusedRawValue() { - return refused - '0'; + if (refused >= '0' && refused <= '3') { + return refused - '0'; + } + return -1; } public List getPreventionExts() { diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java index 592606e187d..a31f29cca49 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java @@ -348,11 +348,16 @@ public int getRefusedStatus(String preventionType) { * checks. The {@code never} column is always {@code 0} for all smoking states and cannot * distinguish current smokers from non-smokers. * - * @param preventionType String the prevention type key (e.g. "Smoking") - * @return boolean {@code true} if the most recent record has refused status = 0 + * @param preventionType String the prevention type key; must be {@code "Smoking"} — + * returns {@code false} for any other type to prevent semantic misuse + * @return boolean {@code true} if preventionType is "Smoking" and the most recent record + * has refused status = 0 (current smoker) * @since 2026-03-14 */ public boolean isCurrentlySmoking(String preventionType) { + if (!"Smoking".equals(preventionType)) { + return false; + } return getRefusedStatus(preventionType) == 0; } diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java b/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java index 405a437e225..7e06f798e22 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/PreventionItem.java @@ -48,7 +48,9 @@ public class PreventionItem { boolean refused; // Raw refused status value (0=active, 1=refused, 2=ineligible, 3=completedExternally). // For the "Smoking" type: 0=Yes (current), 1=No (non-smoker), 2=Previous (ex-smoker). - private int refusedStatus = 0; + // Defaults to -1 (unknown/uninitialized) to avoid misclassifying items built via + // constructors that do not hydrate this field from a Prevention record. + private int refusedStatus = -1; private boolean inelligible = false; private boolean remoteEntry = false; From d39d54cd925b3f02297ce22998efb8e87c77188f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:38:36 +0000 Subject: [PATCH 4/4] fix: resolve infinite recursion in isNotPreventionNever isNotPreventionNever was calling itself instead of isPreventionNever, causing a StackOverflowError on any invocation. Co-authored-by: phc007 --- .../java/io/github/carlos_emr/carlos/prevention/Prevention.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java index a31f29cca49..e646b3c4bc3 100644 --- a/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java +++ b/src/main/java/io/github/carlos_emr/carlos/prevention/Prevention.java @@ -279,7 +279,7 @@ public boolean isPreventionNever(String preventionType) { } public boolean isNotPreventionNever(String preventionType) { - return !isNotPreventionNever(preventionType); + return !isPreventionNever(preventionType); }