diff --git a/src/pfs/lineage.py b/src/pfs/lineage.py index 8cc063e..490ccd8 100644 --- a/src/pfs/lineage.py +++ b/src/pfs/lineage.py @@ -152,11 +152,30 @@ _SUCCESSOR = re.compile( r"|(? str: def _mask_successors(text: str) -> str: """*text* with the codes of every successor clause blanked out, so "deleted" patterns cannot reach them. A clause runs from its marker to - the end of the sentence, a ";", or the next deletion ("..., and the - deletion of ...").""" + the end of the sentence, a ";" followed by a new verb, or the next + deletion ("..., and the deletion of ..."). A deleted *reference to* a + code deletes nothing: its deleting word is replaced.""" out = [] for sent in re.split(r"(?<=\.)(\s)", text): + sent = _REF_DELETED.sub( + lambda m: m.group(0)[: m.start(1) - m.start(0)] + "struck", sent + ) fd = _FROM_DELETED.search(sent) cr = _CREATING.search(sent) if fd else None if fd and cr and cr.start() < fd.start(): @@ -190,7 +213,7 @@ def _mask_successors(text: str) -> str: + sent[fd.start() :] ) for m in list(_SUCCESSOR.finditer(sent)): - cut = next(m.start(g) for g in range(1, 10) if m.group(g)) + cut = m.start(m.lastindex or 0) tail = sent[cut:] end = _CLAUSE_END.search(tail, 1) stop = end.start() if end else len(tail) @@ -213,6 +236,24 @@ def _deletion_match( return pat.search(masked) +def deletion_readings(text: str, code: str) -> set[str]: + """Which of ``deleted`` and ``cpt_panel`` ``fr_events`` reads for + *code* in one raw *text* (a paragraph, a sentence or a summary): the + same range expansion, FR-cite stripping, successor masking and + patterns. ``{"deleted", "cpt_panel"}`` = the CPT Editorial Panel + deleting the code.""" + code = code.upper() + text = FR_CITE_RE.sub(" ", _expand_ranges(text)) + if code not in find_codes(text): + return set() + masked = _mask_successors(text) + return { + kind + for kind, pat, _direction in _patterns(code) + if kind in ("deleted", "cpt_panel") and _deletion_match(kind, pat, text, masked) + } + + def _patterns(code: str) -> list[tuple[str, re.Pattern[str], str]]: c = re.escape(code) return [ diff --git a/tests/pfs/test_lineage.py b/tests/pfs/test_lineage.py index 0d26afa..19a8cb0 100644 --- a/tests/pfs/test_lineage.py +++ b/tests/pfs/test_lineage.py @@ -11,6 +11,7 @@ from pfs.codetables import ensure_tables from pfs.lineage import ( _expand_ranges, cpt_events, + deletion_readings, fr_events, fr_events_bucketed, lineage, @@ -472,6 +473,76 @@ class TestNewKinds: for code in ("15730", "27369", "50436", "77402"): assert "deleted" not in {e.kind for e in fr_events(store, code)}, code + def test_n7_successors_references_and_bundling_are_not_deleted(self, store): + # public FR text: CY 2017 PFS proposed (P8SNUR46 ¶971) and final + # (WHNGCYHB ¶1967), CY 2023 final (PETM225D ¶781), CY 2019 final + # (9V89NVZG ¶1020) + replaced = ( + "Also we note that the intravascular codes for which we initially " + "established our base/add-on code relationship for CPT codes 88346 " + "and 88350 were deleted in CY 2016 and replaced with two new codes; " + "CPT codes 37252 and 37253. The relationship between 37252 and 37253 " + "represents a 20 percent discount for the add-on code." + ) + _add_para(store, "P8SNUR46", 971, replaced, "CY 2017 PFS Proposed Rule") + _add_para(store, "WHNGCYHB", 1967, replaced, "CY 2017 PFS Final Rule") + _add_para( + store, + "PETM225D", + 781, + "From our re-review of the AMA RUC recommendations, we do note that in " + "the text material accompanying the RUC recommendation for CPT code " + "76000 ( Fluoroscopy (separate procedure), up to 1 hour physician or " + "other qualified health care professional time) codes or language was " + "struck from the text material. It is unclear if the reference to CPT " + "code 76000 was intentionally deleted, but we note that CPT code 76000 " + "has a work RVU value of 0.30 and an intra-service time of 10.0 minutes.", + ) + _add_para( + store, + "9V89NVZG", + 1020, + "Response: Even after taking into consideration the bundling of the " + "deleted code, CPT 50395, with CPT code 74485, we believe that there are " + "efficiencies in the work that are not adequately reflected in the " + "RUC-recommended work RVU for this new code, CPT 50436.", + ) + for code in ("37252", "37253", "76000", "74485", "50436"): + assert "deleted" not in {e.kind for e in fr_events(store, code)}, code + assert fr_events_bucketed(store, [code])[code] == fr_events(store, code) + ev = fr_events(store, "50395") + assert ("deleted", "9V89NVZG", 1020) in { + (e.kind, e.item_key, e.p_id) for e in ev + } + assert fr_events_bucketed(store, ["50395"])["50395"] == ev + # a ";" with a verb after it still ends the successor clause, and + # "bundled with" names what a deleted code went into + _add_para( + store, + "PETM225D", + 1691, + "Code 99601 was replaced with 99602; 99603 was also deleted. " + "Code 99604 was deleted and bundled with 99605. " + "The reference to CPT code 99606, which is being deleted, stays.", + ) + for code in ("99601", "99603", "99604", "99606"): + assert "deleted" in {e.kind for e in fr_events(store, code)}, code + for code in ("99602", "99605"): + assert "deleted" not in {e.kind for e in fr_events(store, code)}, code + + def test_deletion_readings_of_one_text(self): + for code in ("99324", "99337", "99343"): + assert deletion_readings(P1672, code) == {"deleted", "cpt_panel"}, code + assert deletion_readings(P1672, "99341") == set() + merged = ( + "The AMA CPT Editorial Panel deleted domiciliary codes 99324-99340 and " + "merged them into the existing home-based E/M visit codes 99341-99350." + ) + assert deletion_readings(merged, "99330") == {"deleted", "cpt_panel"} + assert deletion_readings(merged, "99343") == set() + assert deletion_readings("Code 99343 was deleted.", "99343") == {"deleted"} + assert deletion_readings("Nothing about it.", "99343") == set() + def test_a_deletion_after_a_semicolon_is_not_masked(self, store): _add_para( store,