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 re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -64,6 +64,15 @@ class Family:
|
||||
name: str
|
||||
codes: tuple[str, ...]
|
||||
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] = {
|
||||
@@ -172,14 +181,19 @@ def _build_registry_index(
|
||||
``rebuild_index``/``refresh_from`` swap in together.
|
||||
|
||||
A hand family is matched by any of its curated ``synonyms``; a
|
||||
derived (non-hand) family is matched by its own ``name`` too, but
|
||||
only when the name is a real phrase (``_phrase_qualifies``) — a
|
||||
one-word CPT heading title must never become a chat trigger.
|
||||
derived, CPT-named family (``fam.cpt``) is matched by its own
|
||||
``name`` too, but only when the name is a real phrase
|
||||
(``_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]] = {}
|
||||
for key, fam in families.items():
|
||||
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())
|
||||
for p in phrases:
|
||||
phrase_families.setdefault(p, set()).add(key)
|
||||
@@ -894,6 +908,7 @@ def load_families(con: Any) -> dict[str, Family]:
|
||||
return {}
|
||||
raise
|
||||
out: dict[str, Family] = {}
|
||||
cpt_key: set[str] = set()
|
||||
for r in rows:
|
||||
fam = out.get(r.key)
|
||||
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
|
||||
# families keep their curated synonyms.
|
||||
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)
|
||||
for key in cpt_key:
|
||||
out[key] = replace(out[key], cpt=True)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -140,19 +140,36 @@ class TestDetectDerivedFamilies:
|
||||
assert d.codes == ("15850", "15851")
|
||||
|
||||
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(
|
||||
"WOUND-CARE", "Complex Wound Debridement Services", ("11042",), ()
|
||||
"WOUND-CARE", "Complex Wound Debridement Services", ("11042",), (), cpt=True
|
||||
)
|
||||
rebuild_index()
|
||||
# The single-word heading name never fires, even though the text
|
||||
# contains it verbatim.
|
||||
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?")
|
||||
assert "WOUND-CARE" in d.families
|
||||
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):
|
||||
synthetic = {
|
||||
f"F{i:04d}": Family(
|
||||
@@ -1311,6 +1328,47 @@ class TestLoadAndRefresh:
|
||||
finally:
|
||||
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):
|
||||
# pfs schema created (e.g. by an earlier ensure_tables call for a
|
||||
# sibling table) but pfs.code_family itself never materialized —
|
||||
|
||||
Reference in New Issue
Block a user