Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one commit with no earlier history: the tracked files were de-identified first (synthetic claim ids and patient data only; see Context.md section 7). Credentials, run logs, screenshots, claim reports and client workbooks are gitignored and not included. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
425 lines
16 KiB
Python
425 lines
16 KiB
Python
r"""
|
|
conditions/telehealth.py — the thin detect/apply/describe wrapper around
|
|
Condition 1's existing logic.
|
|
|
|
python tests\test_conditions_telehealth.py
|
|
|
|
The actual DOM mechanics (`note_mentions_telehealth`, `set_cpt_pos_modifier`)
|
|
are already covered exhaustively in test_telehealth_fix.py and do not move —
|
|
this file only proves the wrapper calls them correctly and shapes the result
|
|
the Condition Framework's runner expects. Reuses ub_claim.html, same as
|
|
test_telehealth_fix.py, for the one case that needs a real form.
|
|
"""
|
|
|
|
import sys
|
|
import pathlib
|
|
import inspect
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT / "src"))
|
|
|
|
FIXTURE = pathlib.Path(__file__).resolve().parent / "fixtures" / "ub_claim.html"
|
|
|
|
from ecw_bot.conditions import telehealth # noqa: E402
|
|
from ecw_bot.conditions.base import ConditionResult # noqa: E402
|
|
from ecw_bot.selectors import claim_form as sel # noqa: E402
|
|
|
|
CASES = []
|
|
|
|
|
|
def case(fn):
|
|
CASES.append(fn)
|
|
return fn
|
|
|
|
|
|
@case
|
|
def detect_true_when_the_note_mentions_telehealth():
|
|
data = {"progress_notes": {"text": "Session Setting (PoS): TeleHealth"}}
|
|
assert telehealth.detect("C1", data) is True
|
|
|
|
|
|
@case
|
|
def detect_true_for_telephone_and_standalone_th_too():
|
|
assert telehealth.detect(
|
|
"C1", {"progress_notes": {"text": "Mode of Treatment: Telephone"}}
|
|
) is True
|
|
assert telehealth.detect(
|
|
"C1", {"progress_notes": {"text": "Visit type: TH"}}
|
|
) is True
|
|
|
|
|
|
@case
|
|
def detect_false_with_no_progress_notes_key_at_all():
|
|
"""A claim dict from extract() before Prog. Notes was opened — must not
|
|
raise on the missing key, just report nothing to detect."""
|
|
assert telehealth.detect("C1", {}) is False
|
|
|
|
|
|
@case
|
|
def detect_false_for_an_ordinary_note():
|
|
data = {"progress_notes": {"text": "Regular in-office visit, no video."}}
|
|
assert telehealth.detect("C1", data) is False
|
|
|
|
|
|
# --- modifier selection (condition #21 of the Charge Condition Document) --
|
|
#
|
|
# `_choose_modifier` returns `(modifier, decline_reason)` since review item
|
|
# D5 (2026-10-05), so apply() can tell "write this", "not covered"
|
|
# (Medicare) and "declined" apart.
|
|
|
|
@case
|
|
def audio_only_is_modifier_93_for_commercial_payers():
|
|
assert telehealth._choose_modifier("audio", "Aetna") == ("93", "")
|
|
|
|
|
|
@case
|
|
def video_is_modifier_95_for_commercial_payers():
|
|
assert telehealth._choose_modifier("video", "Aetna") == ("95", "")
|
|
|
|
|
|
@case
|
|
def medicaid_is_always_gt_even_for_an_audio_only_visit():
|
|
"""
|
|
The client's own expected results (claims 9123000, 9124000, 9122000 —
|
|
Medicaid-group telephone visits) keep or add GT, never 93: the
|
|
document's "93 for Audio only" applies to Commercial, not Medicaid.
|
|
"""
|
|
assert telehealth._choose_modifier("audio", "BH COLORADO ACCESS") \
|
|
== ("GT", "")
|
|
assert telehealth._choose_modifier("audio", "Medicaid") == ("GT", "")
|
|
|
|
|
|
@case
|
|
def every_rae_payer_gets_gt_like_medicaid_audio_or_video():
|
|
"""
|
|
The nine RAE payers are grouped "RAE payer", not "Medicaid", in
|
|
payer_groups.json — but they are Medicaid plans, and their telehealth
|
|
rule is the Medicaid one: POS 2 + GT (confirmed 2026-09-24).
|
|
"""
|
|
for payer in ("BH COLORADO ACCESS", "BH ROCKY MOUNTAIN HEALTH",
|
|
"BH ROCKY MTN HEALTH 2022", "BH ROCK MTN UB",
|
|
"Carelon Behavioral Health, Inc",
|
|
"CCH CO COMMUNITY HEALTH ALLIANCE", "HEALTH COLORADO INC",
|
|
"Northeast Colorado Health Department",
|
|
"Northeast Health Partners"):
|
|
for mode in ("audio", "video"):
|
|
assert telehealth._choose_modifier(mode, payer) == ("GT", ""), \
|
|
(payer, mode)
|
|
|
|
|
|
@case
|
|
def medicare_is_not_covered_in_every_mode():
|
|
"""
|
|
The actual bug this test guards: before this was fixed, _choose_modifier
|
|
checked mode == "audio" BEFORE the payer group, so an audio-only
|
|
Medicare claim got 93 from THIS condition too — while
|
|
medicare_telehealth.py also fired on the same row, double-writing it.
|
|
Payer group must be checked first, in every mode — and "not covered"
|
|
carries no decline reason: medicare_telehealth handles the claim, so
|
|
it is not a review.
|
|
"""
|
|
for mode in ("audio", "video"):
|
|
assert telehealth._choose_modifier(mode, "Humana Medicare") \
|
|
== telehealth.NOT_COVERED == (None, "")
|
|
|
|
|
|
@case
|
|
def a_medicare_advantage_plan_gets_the_commercial_modifier():
|
|
"""Ruling 2026-10-21: the dashboard's Commercial Ins — 93 / 95 (#21),
|
|
not G2025 (#22)."""
|
|
for plan in ("AARP Medicare Advantage", "Aetna Medicare ADV"):
|
|
assert telehealth._choose_modifier("audio", plan) == ("93", "")
|
|
assert telehealth._choose_modifier("video", plan) == ("95", "")
|
|
|
|
|
|
@case
|
|
def payers_with_no_rule_are_declined_with_a_reason_in_every_mode():
|
|
"""
|
|
Review item D5: the OLD expectation here was `_choose_modifier("audio",
|
|
"") == "93"` — an unmapped or blank payer on an audio visit got 93
|
|
written, because the audio branch ran before the payer lookup. An
|
|
unclassified Medicare plan then got rule #21 where #22 applies, and an
|
|
unmapped Medicaid MCO got 93 instead of GT. Now every payer with no
|
|
telehealth modifier rule is declined, audio or video, with the payer
|
|
and its group named.
|
|
"""
|
|
for payer, group in (("Some Unmapped Payer", "not in payer_groups.json"),
|
|
("OUTREACH PROGRAM", "Program"),
|
|
("DentaQuest", "Dentaquest")):
|
|
for mode in ("audio", "video"):
|
|
modifier, reason = telehealth._choose_modifier(mode, payer)
|
|
assert modifier is None, (payer, mode, modifier)
|
|
assert reason == (
|
|
f"telehealth visit, but payer {payer!r} ({group}) has no "
|
|
f"telehealth modifier rule — set POS/modifier by hand"), reason
|
|
# A blank payer is a read that found no name, not an unmapped payer:
|
|
# its reason says so instead of pointing at payer_groups.json (final
|
|
# review, 2026-10-05; this case used to expect "payer '' (not in
|
|
# payer_groups.json)").
|
|
for mode in ("audio", "video"):
|
|
for blank in ("", " "):
|
|
modifier, reason = telehealth._choose_modifier(mode, blank)
|
|
assert modifier is None, (blank, mode, modifier)
|
|
assert reason == ("telehealth visit, but no primary payer name "
|
|
"was read — set POS/modifier by hand"), reason
|
|
|
|
|
|
@case
|
|
def an_unloadable_mapping_is_declined_not_guessed():
|
|
original = telehealth.payer_groups.load_mapping
|
|
|
|
def broken():
|
|
raise ValueError("bad JSON")
|
|
telehealth.payer_groups.load_mapping = broken
|
|
try:
|
|
assert telehealth._choose_modifier("video", "Aetna") \
|
|
== (None, "payer_groups.json could not be loaded")
|
|
finally:
|
|
telehealth.payer_groups.load_mapping = original
|
|
|
|
|
|
# --- apply(), against the real fixture ------------------------------------
|
|
|
|
_MEDICAID_DATA = {
|
|
"progress_notes": {"text": "Session Setting (PoS): TeleHealth"},
|
|
"insurance": [{"Name": "BH COLORADO ACCESS"}],
|
|
}
|
|
|
|
|
|
@case
|
|
def apply_sets_pos_2_and_the_medicaid_modifier_on_the_first_row_only(page):
|
|
page.goto(FIXTURE.as_uri())
|
|
result = telehealth.apply(page, "9120000", _MEDICAID_DATA)
|
|
assert isinstance(result, ConditionResult)
|
|
assert result.condition_name == "telehealth"
|
|
assert result.changed is True
|
|
assert len(result.changes) == 1, result.changes
|
|
assert result.changes[0]["code"] == "H0023", result.changes
|
|
assert result.changes[0]["after"]["pos"] == "2", result.changes
|
|
assert result.changes[0]["after"]["mod1"] == "GT", result.changes
|
|
|
|
# Row 1 (90791) must be untouched — this condition is first-row-only.
|
|
row1_mod1 = page.locator(sel.SEL_CPT_ROWS).nth(1).locator(
|
|
sel.SEL_CPT_MOD1).first
|
|
assert row1_mod1.input_value() != "GT", \
|
|
"telehealth condition must not touch the second CPT row"
|
|
|
|
|
|
@case
|
|
def apply_keeps_gt_for_an_audio_only_visit_on_a_medicaid_payer(page):
|
|
page.goto(FIXTURE.as_uri())
|
|
data = {
|
|
"progress_notes": {"text": "Mode of Treatment: Telephone"},
|
|
"insurance": [{"Name": "BH COLORADO ACCESS"}],
|
|
}
|
|
result = telehealth.apply(page, "9120000", data)
|
|
assert result.changed is True
|
|
assert len(result.changes) == 1, result.changes
|
|
assert result.changes[0]["after"]["mod1"] == "GT", result.changes
|
|
|
|
|
|
@case
|
|
def apply_uses_93_for_an_audio_only_visit_on_a_commercial_payer(page):
|
|
page.goto(FIXTURE.as_uri())
|
|
data = {
|
|
"progress_notes": {"text": "Mode of Treatment: Telephone"},
|
|
"insurance": [{"Name": "Aetna"}],
|
|
}
|
|
result = telehealth.apply(page, "9120000", data)
|
|
assert result.changed is True
|
|
assert result.changes[0]["after"]["mod1"] == "93", result.changes
|
|
|
|
|
|
@case
|
|
def apply_declines_every_payer_with_no_telehealth_rule(page):
|
|
"""
|
|
Review item D5: unmapped, blank, Program and Dentaquest payers are
|
|
declined (a Review row with the reason), and nothing is written — the
|
|
old behaviour was a quiet none() on video and 93 written on audio.
|
|
"""
|
|
for payer in ("Some Unmapped Payer", "", "OUTREACH PROGRAM",
|
|
"DentaQuest"):
|
|
for note in ("Session Setting (PoS): TeleHealth",
|
|
"Mode of Treatment: Telephone"):
|
|
page.goto(FIXTURE.as_uri())
|
|
data = {"progress_notes": {"text": note},
|
|
"insurance": [{"Name": payer}]}
|
|
result = telehealth.apply(page, "9120000", data)
|
|
assert result.changed is False and result.changes == [], result
|
|
if payer:
|
|
assert f"payer {payer!r}" in result.review, result.review
|
|
assert "has no telehealth modifier rule" in result.review
|
|
else:
|
|
assert "no primary payer name was read" in result.review, \
|
|
result.review
|
|
row0 = page.locator(sel.SEL_CPT_ROWS).nth(0)
|
|
assert row0.locator(sel.SEL_CPT_POS).first.input_value() == "11", \
|
|
"a declined payer must leave the claim as read"
|
|
|
|
|
|
@case
|
|
def apply_uses_95_for_a_video_visit_on_a_commercial_payer(page):
|
|
page.goto(FIXTURE.as_uri())
|
|
data = {
|
|
"progress_notes": {"text": "TELEHEALTH: real-time audio and video"},
|
|
"insurance": [{"Name": "Aetna"}],
|
|
}
|
|
result = telehealth.apply(page, "9120000", data)
|
|
assert result.changed is True and result.review == "", result
|
|
assert result.changes[0]["after"]["mod1"] == "95", result.changes
|
|
|
|
|
|
@case
|
|
def apply_returns_none_not_review_for_a_medicare_payer(page):
|
|
"""medicare_telehealth fires on the same claim and handles it — a
|
|
review here would put a correctly handled claim in front of a human."""
|
|
page.goto(FIXTURE.as_uri())
|
|
data = {"progress_notes": {"text": "Mode of Treatment: Telephone"},
|
|
"insurance": [{"Name": "Humana Medicare"}]}
|
|
result = telehealth.apply(page, "9120000", data)
|
|
assert result == ConditionResult.none("telehealth"), result
|
|
|
|
|
|
@case
|
|
def apply_returns_none_for_an_already_correct_claim(page):
|
|
"""
|
|
Review item D3: a claim whose primary row already holds POS 2 (eCW
|
|
writes "02") and the right modifier is Pass, not "Corrections pending"
|
|
— none(), no review.
|
|
"""
|
|
page.goto(FIXTURE.as_uri())
|
|
row0 = page.locator(sel.SEL_CPT_ROWS).nth(0)
|
|
row0.locator(sel.SEL_CPT_POS).first.fill("02") # M1 is already GT
|
|
result = telehealth.apply(page, "9120000", _MEDICAID_DATA)
|
|
assert result == ConditionResult.none("telehealth"), result
|
|
assert row0.locator(sel.SEL_CPT_POS).first.input_value() == "02"
|
|
|
|
|
|
@case
|
|
def apply_sends_a_write_that_did_not_take_to_review(page):
|
|
"""A POS that reads back wrong after the fill (eCW reformatting it,
|
|
a field that ignores input) is kept in the result but marked review."""
|
|
page.goto(FIXTURE.as_uri())
|
|
page.evaluate("""() => {
|
|
const el = document.getElementById('uBClaimPOS0');
|
|
el.addEventListener('input', () => { el.value = '11'; });
|
|
}""")
|
|
result = telehealth.apply(page, "9120000", _MEDICAID_DATA)
|
|
assert result.changed is True, result
|
|
assert result.changes[0]["ok"] is False, result.changes
|
|
assert result.review == ("POS/modifier did not take on row 1 — set it "
|
|
"by hand"), result.review
|
|
assert "did NOT take" in telehealth.describe(result)
|
|
|
|
|
|
@case
|
|
def describe_and_summary_comment_say_what_actually_changed(page):
|
|
page.goto(FIXTURE.as_uri())
|
|
data = {"progress_notes": {"text": "TELEHEALTH: audio only visit"},
|
|
"insurance": [{"Name": "Aetna"}]}
|
|
result = telehealth.apply(page, "9120000", data)
|
|
text = telehealth.describe(result)
|
|
assert 'mode audio (matched "audio only")' in text, text
|
|
assert "93" in text and "H0023" in text, text
|
|
assert telehealth.summary_comment(result) == \
|
|
"Telehealth (POS 02, modifier 93 on H0023)"
|
|
|
|
page.goto(FIXTURE.as_uri())
|
|
result = telehealth.apply(page, "9120000", _MEDICAID_DATA)
|
|
assert "mode video (default — no audio-only evidence)" in \
|
|
telehealth.describe(result)
|
|
# The fixture's row already has M1 GT, so only POS was fixed — and only
|
|
# POS is named (final review, 2026-10-05; this used to expect
|
|
# "POS 02, modifier GT on H0023", reading the correct M1 as a fix).
|
|
assert telehealth.summary_comment(result) == \
|
|
"Telehealth (POS 02 on H0023)"
|
|
|
|
|
|
@case
|
|
def describe_mentions_the_changed_row(page):
|
|
page.goto(FIXTURE.as_uri())
|
|
result = telehealth.apply(page, "9120000", _MEDICAID_DATA)
|
|
text = telehealth.describe(result)
|
|
assert "H0023" in text, text
|
|
assert "POS" in text and "GT" in text, text
|
|
# _MEDICAID_DATA's note text is "Session Setting (PoS): TeleHealth" —
|
|
# the "telehealth" signal, not "telephone" or a TH tag — so the
|
|
# operator can see WHY this fired, not just that it did.
|
|
assert "telehealth" in text, text
|
|
|
|
|
|
@case
|
|
def a_live_grid_that_reads_empty_after_a_full_first_read_is_declined(page):
|
|
"""
|
|
Final review, 2026-10-05: the claim's first read had CPT rows, but the
|
|
live grid apply() re-read had none (not re-rendered after the tab
|
|
re-click). The fix was never applied — declined (Review), not none()
|
|
(Pass). With no CPT rows in the first read either it stays none().
|
|
"""
|
|
from ecw_bot.conditions.base import CPT_GRID_NOT_READ
|
|
page.goto(FIXTURE.as_uri())
|
|
page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]')
|
|
.forEach(r => r.remove())""")
|
|
result = telehealth.apply(page, "9120000",
|
|
dict(_MEDICAID_DATA, cpt=[{"code": "H0023"}]))
|
|
assert result == ConditionResult.declined("telehealth",
|
|
CPT_GRID_NOT_READ), result
|
|
page.goto(FIXTURE.as_uri())
|
|
page.evaluate("""() => document.querySelectorAll('tr[id^="CPTROW_"]')
|
|
.forEach(r => r.remove())""")
|
|
result = telehealth.apply(page, "9120000", _MEDICAID_DATA)
|
|
assert result == ConditionResult.none("telehealth"), result
|
|
|
|
|
|
@case
|
|
def summary_comment_names_only_the_field_that_was_fixed():
|
|
def row(pos, mod):
|
|
return {"row": 0, "code": "99213", "changed": True, "ok": True,
|
|
"before": {"pos": pos, "mod1": mod},
|
|
"after": {"pos": "2", "mod1": "GT"}, "modifier": "GT"}
|
|
sc = telehealth.summary_comment
|
|
mk = lambda c: ConditionResult("telehealth", True, [c])
|
|
assert sc(mk(row("11", "GT"))) == "Telehealth (POS 02 on 99213)"
|
|
assert sc(mk(row("02", ""))) == "Telehealth (modifier GT on 99213)"
|
|
assert sc(mk(row("11", "95"))) == \
|
|
"Telehealth (POS 02, modifier GT on 99213)"
|
|
|
|
|
|
def main() -> int:
|
|
if not FIXTURE.exists():
|
|
print(f" FAIL fixture missing: {FIXTURE}")
|
|
return 1
|
|
try:
|
|
from playwright.sync_api import sync_playwright
|
|
except ImportError:
|
|
print(" SKIP playwright is not installed")
|
|
return 0
|
|
|
|
failures = 0
|
|
with sync_playwright() as pw:
|
|
browser = pw.chromium.launch(headless=True)
|
|
page = browser.new_page()
|
|
|
|
for fn in CASES:
|
|
name = fn.__name__.replace("_", " ")
|
|
try:
|
|
if len(inspect.signature(fn).parameters) == 0:
|
|
fn()
|
|
else:
|
|
fn(page)
|
|
print(f" ok {name}")
|
|
except AssertionError as exc:
|
|
print(f" FAIL {name}: {exc}")
|
|
failures += 1
|
|
except Exception as exc:
|
|
print(f" ERROR {name}: {type(exc).__name__}: {exc}")
|
|
failures += 1
|
|
browser.close()
|
|
|
|
print(f"\n{len(CASES) - failures}/{len(CASES)} passed")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|