fix(pfs): name-phrase family detection only for CPT-named families (refs #699)
This commit is contained in:
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
@@ -64,6 +64,15 @@ class Family:
|
|||||||
name: str
|
name: str
|
||||||
codes: tuple[str, ...]
|
codes: tuple[str, ...]
|
||||||
synonyms: tuple[str, ...] # lower-case; matched on word boundaries
|
synonyms: tuple[str, ...] # lower-case; matched on word boundaries
|
||||||
|
#: True when this (derived) family is named for a real CPT manual
|
||||||
|
#: heading (``load_families`` sets it from a non-empty ``FamilyRow.note``
|
||||||
|
#: — the heading path) rather than a stem-derived group's raw RVU short
|
||||||
|
#: description. Ruling B1: only a CPT-named family's ``name`` is ever a
|
||||||
|
#: name-phrase chat trigger (``rebuild_index``) — a stem group's name is
|
||||||
|
#: often abbreviated billing shorthand, not a real phrase. Hand families
|
||||||
|
#: are matched by their curated ``synonyms`` instead and leave this
|
||||||
|
#: ``False``.
|
||||||
|
cpt: bool = False
|
||||||
|
|
||||||
|
|
||||||
HAND_FAMILIES: dict[str, Family] = {
|
HAND_FAMILIES: dict[str, Family] = {
|
||||||
@@ -172,14 +181,19 @@ def _build_registry_index(
|
|||||||
``rebuild_index``/``refresh_from`` swap in together.
|
``rebuild_index``/``refresh_from`` swap in together.
|
||||||
|
|
||||||
A hand family is matched by any of its curated ``synonyms``; a
|
A hand family is matched by any of its curated ``synonyms``; a
|
||||||
derived (non-hand) family is matched by its own ``name`` too, but
|
derived, CPT-named family (``fam.cpt``) is matched by its own
|
||||||
only when the name is a real phrase (``_phrase_qualifies``) — a
|
``name`` too, but only when the name is a real phrase
|
||||||
one-word CPT heading title must never become a chat trigger.
|
(``_phrase_qualifies``) — a one-word CPT heading title must never
|
||||||
|
become a chat trigger. Ruling B1: a derived family that is *not*
|
||||||
|
CPT-named (a stem-derived group, ``fam.cpt`` false) never matches by
|
||||||
|
name at all, even when its name happens to be long/multi-word — its
|
||||||
|
"name" is a raw RVU short description (billing shorthand), not a
|
||||||
|
real phrase.
|
||||||
"""
|
"""
|
||||||
phrase_families: dict[str, set[str]] = {}
|
phrase_families: dict[str, set[str]] = {}
|
||||||
for key, fam in families.items():
|
for key, fam in families.items():
|
||||||
phrases = list(fam.synonyms)
|
phrases = list(fam.synonyms)
|
||||||
if key not in HAND_FAMILIES and _phrase_qualifies(fam.name):
|
if key not in HAND_FAMILIES and fam.cpt and _phrase_qualifies(fam.name):
|
||||||
phrases.append(fam.name.lower())
|
phrases.append(fam.name.lower())
|
||||||
for p in phrases:
|
for p in phrases:
|
||||||
phrase_families.setdefault(p, set()).add(key)
|
phrase_families.setdefault(p, set()).add(key)
|
||||||
@@ -894,6 +908,7 @@ def load_families(con: Any) -> dict[str, Family]:
|
|||||||
return {}
|
return {}
|
||||||
raise
|
raise
|
||||||
out: dict[str, Family] = {}
|
out: dict[str, Family] = {}
|
||||||
|
cpt_key: set[str] = set()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
fam = out.get(r.key)
|
fam = out.get(r.key)
|
||||||
codes = (*(fam.codes if fam else ()), r.code)
|
codes = (*(fam.codes if fam else ()), r.code)
|
||||||
@@ -905,7 +920,16 @@ def load_families(con: Any) -> dict[str, Family]:
|
|||||||
# is #699's job (a precompiled alternation), not this one. Hand
|
# is #699's job (a precompiled alternation), not this one. Hand
|
||||||
# families keep their curated synonyms.
|
# families keep their curated synonyms.
|
||||||
syn = HAND_FAMILIES[r.key].synonyms if r.key in HAND_FAMILIES else ()
|
syn = HAND_FAMILIES[r.key].synonyms if r.key in HAND_FAMILIES else ()
|
||||||
|
# Ruling B1: a non-empty note is the code's own CPT heading path
|
||||||
|
# (_group_rows) — any member row carrying one marks the whole
|
||||||
|
# family as CPT-named, which is the only kind of derived family
|
||||||
|
# whose `name` is a real phrase rather than a stem group's raw
|
||||||
|
# RVU short description.
|
||||||
|
if r.note:
|
||||||
|
cpt_key.add(r.key)
|
||||||
out[r.key] = Family(r.key, r.name, tuple(sorted(set(codes))), syn)
|
out[r.key] = Family(r.key, r.name, tuple(sorted(set(codes))), syn)
|
||||||
|
for key in cpt_key:
|
||||||
|
out[key] = replace(out[key], cpt=True)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -140,19 +140,36 @@ class TestDetectDerivedFamilies:
|
|||||||
assert d.codes == ("15850", "15851")
|
assert d.codes == ("15850", "15851")
|
||||||
|
|
||||||
def test_derived_family_name_phrase_requires_two_words(self, restore_families):
|
def test_derived_family_name_phrase_requires_two_words(self, restore_families):
|
||||||
restore_families.FAMILIES["GENE"] = Family("GENE", "GENE", ("81400",), ())
|
# Both are CPT-named (cpt=True) — the single-word heading title
|
||||||
|
# must still never fire regardless of the B1 CPT-name gate.
|
||||||
|
restore_families.FAMILIES["GENE"] = Family(
|
||||||
|
"GENE", "GENE", ("81400",), (), cpt=True
|
||||||
|
)
|
||||||
restore_families.FAMILIES["WOUND-CARE"] = Family(
|
restore_families.FAMILIES["WOUND-CARE"] = Family(
|
||||||
"WOUND-CARE", "Complex Wound Debridement Services", ("11042",), ()
|
"WOUND-CARE", "Complex Wound Debridement Services", ("11042",), (), cpt=True
|
||||||
)
|
)
|
||||||
rebuild_index()
|
rebuild_index()
|
||||||
# The single-word heading name never fires, even though the text
|
# The single-word heading name never fires, even though the text
|
||||||
# contains it verbatim.
|
# contains it verbatim.
|
||||||
assert "GENE" not in detect_codes("the GENE panel result").families
|
assert "GENE" not in detect_codes("the GENE panel result").families
|
||||||
# A qualifying (>= 12 chars, >= 2 words) derived name does.
|
# A qualifying (>= 12 chars, >= 2 words) CPT-named derived name does.
|
||||||
d = detect_codes("How are Complex Wound Debridement Services valued?")
|
d = detect_codes("How are Complex Wound Debridement Services valued?")
|
||||||
assert "WOUND-CARE" in d.families
|
assert "WOUND-CARE" in d.families
|
||||||
assert "11042" in d.codes
|
assert "11042" in d.codes
|
||||||
|
|
||||||
|
def test_derived_family_name_requires_cpt_flag(self, restore_families):
|
||||||
|
# Ruling B1: a stem-derived family's "name" is a raw RVU short
|
||||||
|
# description, not a real phrase — even a long, multi-word one
|
||||||
|
# must never become a name trigger unless the family is
|
||||||
|
# CPT-named (fam.cpt). Member-code detection is unaffected.
|
||||||
|
restore_families.FAMILIES["STEM-GROUP"] = Family(
|
||||||
|
"STEM-GROUP", "Complex Remote Monitoring Setup Review", ("99999",), ()
|
||||||
|
)
|
||||||
|
rebuild_index()
|
||||||
|
d = detect_codes("How is Complex Remote Monitoring Setup Review valued?")
|
||||||
|
assert "STEM-GROUP" not in d.families
|
||||||
|
assert detect_codes("value of 99999").families == ("STEM-GROUP",)
|
||||||
|
|
||||||
def test_family_of_is_indexed(self, restore_families):
|
def test_family_of_is_indexed(self, restore_families):
|
||||||
synthetic = {
|
synthetic = {
|
||||||
f"F{i:04d}": Family(
|
f"F{i:04d}": Family(
|
||||||
@@ -1311,6 +1328,47 @@ class TestLoadAndRefresh:
|
|||||||
finally:
|
finally:
|
||||||
con.close()
|
con.close()
|
||||||
|
|
||||||
|
def test_load_families_sets_cpt_flag_from_note(self):
|
||||||
|
# Ruling B1: a non-empty `note` (the code's own CPT heading path)
|
||||||
|
# on any member row marks the whole derived family CPT-named;
|
||||||
|
# a family with no member carrying a note stays cpt=False.
|
||||||
|
con = duckdb.connect(":memory:")
|
||||||
|
try:
|
||||||
|
ensure_tables(con)
|
||||||
|
write_families(
|
||||||
|
con,
|
||||||
|
[
|
||||||
|
FamilyRow(
|
||||||
|
"CHRONIC-CARE-MANAGEMENT-SERVICES",
|
||||||
|
"Chronic Care Management Services",
|
||||||
|
"99490",
|
||||||
|
"base",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
"Evaluation and Management > Care Management "
|
||||||
|
"Services > Chronic Care Management Services",
|
||||||
|
),
|
||||||
|
FamilyRow(
|
||||||
|
"STEM-GROUP",
|
||||||
|
"Complex Remote Monitoring Setup Review",
|
||||||
|
"99999",
|
||||||
|
"base",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
"",
|
||||||
|
0,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fams = load_families(con)
|
||||||
|
assert fams["CHRONIC-CARE-MANAGEMENT-SERVICES"].cpt is True
|
||||||
|
assert fams["STEM-GROUP"].cpt is False
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
def test_schema_present_table_absent_returns_empty(self):
|
def test_schema_present_table_absent_returns_empty(self):
|
||||||
# pfs schema created (e.g. by an earlier ensure_tables call for a
|
# pfs schema created (e.g. by an earlier ensure_tables call for a
|
||||||
# sibling table) but pfs.code_family itself never materialized —
|
# sibling table) but pfs.code_family itself never materialized —
|
||||||
|
|||||||
Reference in New Issue
Block a user