fix(pfs): FR lineage — same-block range prefilter + letter ranges, directional replaces/replaced_by, no created-on-established (refs #686)
This commit is contained in:
@@ -96,7 +96,7 @@ def _patterns(code: str) -> list[tuple[str, re.Pattern[str], str]]:
|
|||||||
(
|
(
|
||||||
"created",
|
"created",
|
||||||
re.compile(
|
re.compile(
|
||||||
rf"(?:\b(?:creat|establish)\w*\b.{{0,80}}\b(?:HCPCS|CPT|G-?)\s*codes?\b[^.]{{0,80}}{c})"
|
rf"(?:\bcreat\w*\b.{{0,80}}\b(?:HCPCS|CPT|G-?)\s*codes?\b[^.]{{0,80}}{c})"
|
||||||
rf"|(?:new (?:HCPCS|CPT) code\s+{c})",
|
rf"|(?:new (?:HCPCS|CPT) code\s+{c})",
|
||||||
re.I,
|
re.I,
|
||||||
),
|
),
|
||||||
@@ -110,7 +110,8 @@ def _patterns(code: str) -> list[tuple[str, re.Pattern[str], str]]:
|
|||||||
(
|
(
|
||||||
"replaces",
|
"replaces",
|
||||||
re.compile(
|
re.compile(
|
||||||
rf"{c}[^.]{{0,120}}\b(?:replace|replacing|replacement for|instead of|with a descriptor identical to)\b",
|
rf"{c}(?:(?!{_CODE})[^.]){{0,120}}"
|
||||||
|
rf"\b(?:replace|replacing|replacement for|instead of|with a descriptor identical to)\b",
|
||||||
re.I,
|
re.I,
|
||||||
),
|
),
|
||||||
"from",
|
"from",
|
||||||
@@ -118,7 +119,7 @@ def _patterns(code: str) -> list[tuple[str, re.Pattern[str], str]]:
|
|||||||
(
|
(
|
||||||
"replaced_by",
|
"replaced_by",
|
||||||
re.compile(
|
re.compile(
|
||||||
rf"(?:\b(?:replace\w*|replacement)\b[^.]{{0,120}}{c})"
|
rf"(?:\b(?:replace\w*|replacement)\b(?:(?!{_CODE})[^.]){{0,120}}{c})"
|
||||||
rf"|(?:{c}[^.]{{0,80}}\b(?:as|is being|will be|which we are) (?:finaliz|replac)\w+ (?:in this rule )?as new (?:CPT|HCPCS) code\s+{_CODE})",
|
rf"|(?:{c}[^.]{{0,80}}\b(?:as|is being|will be|which we are) (?:finaliz|replac)\w+ (?:in this rule )?as new (?:CPT|HCPCS) code\s+{_CODE})",
|
||||||
re.I,
|
re.I,
|
||||||
),
|
),
|
||||||
@@ -158,10 +159,12 @@ def _patterns(code: str) -> list[tuple[str, re.Pattern[str], str]]:
|
|||||||
|
|
||||||
|
|
||||||
_RE_RANGE = re.compile(r"\b(\d{5})-(\d{5})\b")
|
_RE_RANGE = re.compile(r"\b(\d{5})-(\d{5})\b")
|
||||||
|
_RE_LETTER_RANGE = re.compile(r"\b([A-Z])(\d{4})-\1(\d{4})\b")
|
||||||
|
|
||||||
|
|
||||||
def _expand_ranges(text: str) -> str:
|
def _expand_ranges(text: str) -> str:
|
||||||
""" "99441-99443" -> "99441 99442 99443" so a range mentions each code."""
|
""" "99441-99443" -> "99441 99442 99443" and "G0008-G0010" -> "G0008
|
||||||
|
G0009 G0010" so a range mentions each code."""
|
||||||
|
|
||||||
def rep(m: re.Match[str]) -> str:
|
def rep(m: re.Match[str]) -> str:
|
||||||
a, b = int(m.group(1)), int(m.group(2))
|
a, b = int(m.group(1)), int(m.group(2))
|
||||||
@@ -169,7 +172,18 @@ def _expand_ranges(text: str) -> str:
|
|||||||
" ".join(str(n) for n in range(a, b + 1)) if 0 < b - a <= 20 else m.group(0)
|
" ".join(str(n) for n in range(a, b + 1)) if 0 < b - a <= 20 else m.group(0)
|
||||||
)
|
)
|
||||||
|
|
||||||
return _RE_RANGE.sub(rep, text)
|
def rep_letter(m: re.Match[str]) -> str:
|
||||||
|
letter = m.group(1)
|
||||||
|
a, b = int(m.group(2)), int(m.group(3))
|
||||||
|
return (
|
||||||
|
" ".join(f"{letter}{n:04d}" for n in range(a, b + 1))
|
||||||
|
if 0 < b - a <= 20
|
||||||
|
else m.group(0)
|
||||||
|
)
|
||||||
|
|
||||||
|
text = _RE_RANGE.sub(rep, text)
|
||||||
|
text = _RE_LETTER_RANGE.sub(rep_letter, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def fr_events(store: Any, code: str) -> list[EventRow]:
|
def fr_events(store: Any, code: str) -> list[EventRow]:
|
||||||
@@ -178,8 +192,9 @@ def fr_events(store: Any, code: str) -> list[EventRow]:
|
|||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT a.item_key, a.p_id, a.page, a.text, i.title, i.date_published "
|
"SELECT a.item_key, a.p_id, a.page, a.text, i.title, i.date_published "
|
||||||
"FROM fr_anchors a JOIN items i ON i.key = a.item_key WHERE a.text LIKE ? OR a.text LIKE ?",
|
"FROM fr_anchors a JOIN items i ON i.key = a.item_key WHERE a.text LIKE ? OR a.text LIKE ?",
|
||||||
(f"%{code}%", f"%{code[:5]}-%"),
|
(f"%{code}%", f"%{code[:2]}___-{code[:2]}___%"),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
patterns = _patterns(code)
|
||||||
seen: set[tuple[int, str, str, int]] = set()
|
seen: set[tuple[int, str, str, int]] = set()
|
||||||
out: list[EventRow] = []
|
out: list[EventRow] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -188,15 +203,25 @@ def fr_events(store: Any, code: str) -> list[EventRow]:
|
|||||||
continue
|
continue
|
||||||
year = rule_year_of(r["title"], r["date_published"])
|
year = rule_year_of(r["title"], r["date_published"])
|
||||||
others = tuple(c for c in find_codes(text) if c != code)
|
others = tuple(c for c in find_codes(text) if c != code)
|
||||||
for kind, pat, direction in _patterns(code):
|
for kind, pat, direction in patterns:
|
||||||
m = pat.search(text)
|
m = pat.search(text)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
frm = " ".join(others) if direction == "from" else ""
|
frm = ""
|
||||||
to = ""
|
to = ""
|
||||||
if direction == "to":
|
if direction == "from":
|
||||||
|
# "replaces": the replaced code(s) are named after the
|
||||||
|
# trigger phrase, up to the end of the sentence — e.g.
|
||||||
|
# "...99439 (with a descriptor identical to G2058)."
|
||||||
|
tail_text = text[m.end() :]
|
||||||
|
period = tail_text.find(".")
|
||||||
|
if period != -1:
|
||||||
|
tail_text = tail_text[:period]
|
||||||
|
tail_codes = tuple(tc for tc in find_codes(tail_text) if tc != code)
|
||||||
|
frm = " ".join(tail_codes) or " ".join(others)
|
||||||
|
elif direction == "to":
|
||||||
tail = find_codes(text[m.start() : m.end()])
|
tail = find_codes(text[m.start() : m.end()])
|
||||||
to = " ".join(c for c in tail if c != code) or " ".join(others)
|
to = " ".join(tc for tc in tail if tc != code) or " ".join(others)
|
||||||
# A directional kind ("replaces"/"replaced_by") with no other
|
# A directional kind ("replaces"/"replaced_by") with no other
|
||||||
# code found nearby is not a real relationship — e.g. "...
|
# code found nearby is not a real relationship — e.g. "...
|
||||||
# instead of the G code" names no actual code, so the sentence
|
# instead of the G code" names no actual code, so the sentence
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import sqlite3
|
|||||||
import duckdb
|
import duckdb
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from pfs.lineage import fr_events, lineage, rvu_events, rvu_year_span
|
from pfs.lineage import _expand_ranges, fr_events, lineage, rvu_events, rvu_year_span
|
||||||
|
|
||||||
RVU_COLS = "hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, non_fac_total DOUBLE, year INTEGER"
|
RVU_COLS = "hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, non_fac_total DOUBLE, year INTEGER"
|
||||||
|
|
||||||
@@ -145,6 +145,12 @@ def store():
|
|||||||
61652,
|
61652,
|
||||||
"The CPT Editorial Panel also deleted three codes (99441-99443) for reporting telephone E/M services. We note that CPT codes 99441, 99442, and 99443, each are assigned provisional status on the Medicare telehealth services list, and would return to bundled status when the telehealth flexibilities expire.",
|
"The CPT Editorial Panel also deleted three codes (99441-99443) for reporting telephone E/M services. We note that CPT codes 99441, 99442, and 99443, each are assigned provisional status on the Medicare telehealth services list, and would return to bundled status when the telehealth flexibilities expire.",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"XFGGRBDH",
|
||||||
|
512,
|
||||||
|
61700,
|
||||||
|
"The CPT Editorial Panel also deleted three codes (90867-90869) for reporting transcranial magnetic stimulation treatment.",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
s.con.executemany(
|
s.con.executemany(
|
||||||
"INSERT INTO fr_anchors VALUES (?,?,?,?,?)",
|
"INSERT INTO fr_anchors VALUES (?,?,?,?,?)",
|
||||||
@@ -161,17 +167,21 @@ class TestFrEvents:
|
|||||||
(2015, "adopted_cpt", "DE2VH9PD", 1251)
|
(2015, "adopted_cpt", "DE2VH9PD", 1251)
|
||||||
]
|
]
|
||||||
assert ev[0].anchored is True and ev[0].source == "fr" and ev[0].page == 67716
|
assert ev[0].anchored is True and ev[0].source == "fr" and ev[0].page == 67716
|
||||||
|
assert not any(e.kind == "replaces" for e in ev)
|
||||||
|
|
||||||
def test_replaced_by_and_crosswalk(self, store):
|
def test_replaced_by_and_crosswalk(self, store):
|
||||||
ev = fr_events(store, "G2058")
|
ev = fr_events(store, "G2058")
|
||||||
kinds = {(e.year, e.kind, e.to_codes) for e in ev}
|
kinds = {e.kind for e in ev}
|
||||||
assert (2021, "replaced_by", "99439") in kinds
|
assert "created" not in kinds
|
||||||
assert (2021, "crosswalk", "") in kinds or any(
|
assert "replaces" not in kinds
|
||||||
e.kind == "crosswalk" for e in ev
|
assert "replaced_by" in kinds and "crosswalk" in kinds
|
||||||
)
|
to_kinds = {(e.year, e.kind, e.to_codes) for e in ev}
|
||||||
|
assert (2021, "replaced_by", "99439") in to_kinds
|
||||||
|
|
||||||
def test_replaces_from_the_successor_side(self, store):
|
def test_replaces_from_the_successor_side(self, store):
|
||||||
ev = fr_events(store, "99439")
|
ev = fr_events(store, "99439")
|
||||||
|
kinds = {(e.year, e.kind) for e in ev}
|
||||||
|
assert (2021, "created") in kinds # ¶686 "new CPT code 99439"
|
||||||
rep = [e for e in ev if e.kind == "replaces"]
|
rep = [e for e in ev if e.kind == "replaces"]
|
||||||
assert rep and rep[0].from_codes == "G2058"
|
assert rep and rep[0].from_codes == "G2058"
|
||||||
|
|
||||||
@@ -179,6 +189,18 @@ class TestFrEvents:
|
|||||||
kinds = {e.kind for e in fr_events(store, "99441")}
|
kinds = {e.kind for e in fr_events(store, "99441")}
|
||||||
assert {"deleted", "bundled", "telehealth_list"} <= kinds
|
assert {"deleted", "bundled", "telehealth_list"} <= kinds
|
||||||
|
|
||||||
|
def test_deleted_via_range_prefilter_middle_member(self, store):
|
||||||
|
# 90868 is a middle member of a "90867-90869" range and appears
|
||||||
|
# nowhere else in the paragraph literally — the SQL prefilter must
|
||||||
|
# still surface the row for a range-block LIKE match.
|
||||||
|
ev = fr_events(store, "90868")
|
||||||
|
assert any(e.kind == "deleted" for e in ev)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandRanges:
|
||||||
|
def test_letter_range(self):
|
||||||
|
assert _expand_ranges("G0008-G0010") == "G0008 G0009 G0010"
|
||||||
|
|
||||||
|
|
||||||
class TestCrossCheck:
|
class TestCrossCheck:
|
||||||
def test_rvu_events_anchored_by_nearby_fr_event(self, con, store):
|
def test_rvu_events_anchored_by_nearby_fr_event(self, con, store):
|
||||||
|
|||||||
Reference in New Issue
Block a user