fix(pfs): name-phrase family detection only for CPT-named families (refs #699)

This commit is contained in:
kert
2026-09-10 09:33:43 -04:00
parent 5f31d7b645
commit e80b179f8c
2 changed files with 90 additions and 8 deletions

View File

@@ -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

View File

@@ -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 —