fix(pfs,llm): descriptor stems trimmed to the code's own parenthetical (#700: G0502/G0503 no longer bridge into CCM); excerpts using a family acronym for something else are dropped (CCM = cardiac contractility modulation in the CY2027 NPRM) (refs #700 #691)
Some checks failed
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m32s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 1m2s
Infra CI / zotero (push) Successful in 15s
Infra CI / docs (push) Successful in 1m32s
Infra CI / api (push) Successful in 1m4s
Infra CI / llm (push) Successful in 55s
Infra CI / mc (push) Successful in 14s
Deploy / report (push) Successful in 13s
CI / test (push) Failing after 14m22s
Some checks failed
CI / lint (push) Successful in 29s
CI / notebooks-smoke (push) Successful in 1m32s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / llm (push) Has been skipped
Deploy / mc (push) Has been skipped
Infra CI / notebooks (push) Successful in 1m2s
Infra CI / zotero (push) Successful in 15s
Infra CI / docs (push) Successful in 1m32s
Infra CI / api (push) Successful in 1m4s
Infra CI / llm (push) Successful in 55s
Infra CI / mc (push) Successful in 14s
Deploy / report (push) Successful in 13s
CI / test (push) Failing after 14m22s
This commit is contained in:
@@ -306,6 +306,37 @@ def _source(hit: Hit) -> dict:
|
||||
return as_source(hit.metadata, hit.text, hit.score)
|
||||
|
||||
|
||||
def _conflict_phrases(families: Sequence[str]) -> tuple[str, ...]:
|
||||
"""Lower-case phrases that mark an excerpt as using one of
|
||||
*families*' acronyms for something else (``Family.conflicts``)."""
|
||||
from pfs.families import FAMILIES
|
||||
|
||||
out: list[str] = []
|
||||
for key in families:
|
||||
fam = FAMILIES.get(key)
|
||||
if fam is not None:
|
||||
out.extend(p.lower() for p in fam.conflicts)
|
||||
return tuple(dict.fromkeys(out))
|
||||
|
||||
|
||||
def _has_conflict(text: str, phrases: Sequence[str]) -> bool:
|
||||
if not phrases or not text:
|
||||
return False
|
||||
low = text.lower()
|
||||
return any(p in low for p in phrases)
|
||||
|
||||
|
||||
def _drop_conflicts(
|
||||
sources: list[dict], phrases: Sequence[str]
|
||||
) -> tuple[list[dict], int]:
|
||||
"""*sources* minus the ones whose snippet uses a detected family's
|
||||
acronym for something else; the count dropped."""
|
||||
if not phrases:
|
||||
return sources, 0
|
||||
kept = [s for s in sources if not _has_conflict(s.get("snippet", ""), phrases)]
|
||||
return kept, len(sources) - len(kept)
|
||||
|
||||
|
||||
def retrieve(
|
||||
question: str,
|
||||
*,
|
||||
@@ -314,10 +345,15 @@ def retrieve(
|
||||
since: str = "",
|
||||
now: date | None = None,
|
||||
mode: str = "auto",
|
||||
exclude_phrases: Sequence[str] = (),
|
||||
) -> list[dict]:
|
||||
"""Top sources for ``question`` across all collections.
|
||||
|
||||
``since`` (ISO date) hard-filters to material dated on/after it.
|
||||
``exclude_phrases`` (lower-case) drops any hit whose full chunk text
|
||||
contains one of them before ranking — a detected family's acronym
|
||||
conflicts (``Family.conflicts``); empty by default, so retrieval is
|
||||
unchanged for a question that detects nothing.
|
||||
|
||||
``mode``: "recent" (default behaviour — similarity × recency blend,
|
||||
byte-identical to before Task 4) or "timeline" (era-balanced —
|
||||
@@ -332,9 +368,13 @@ def retrieve(
|
||||
hits = filter_since(
|
||||
_hits(vec, cfg=cfg, pool=pool, overfetch=cfg.timeline_overfetch), since
|
||||
)
|
||||
ranked = era_balance(hits, per_era=cfg.timeline_per_era, top_n=cfg.top_n)
|
||||
else:
|
||||
hits = filter_since(_hits(vec, cfg=cfg, pool=pool), since)
|
||||
if exclude_phrases:
|
||||
hits = [h for h in hits if not _has_conflict(h.text, exclude_phrases)]
|
||||
if resolved == "timeline":
|
||||
ranked = era_balance(hits, per_era=cfg.timeline_per_era, top_n=cfg.top_n)
|
||||
else:
|
||||
ranked = blend(
|
||||
hits,
|
||||
weight=cfg.recency_weight,
|
||||
@@ -592,11 +632,26 @@ def stream_answer(
|
||||
``{"type":"done"}``.
|
||||
"""
|
||||
resolved_mode = _resolve_mode(question, mode)
|
||||
sources = retrieve(question, cfg=cfg, pool=pool, since=since, mode=mode)
|
||||
n_retrieved = len(sources)
|
||||
n_cited = n_lineage_sources = n_manual = 0
|
||||
# Detection first: a detected family's acronym conflicts (CCM = chronic
|
||||
# care management here, cardiac contractility modulation in the CY2027
|
||||
# NPRM) must keep the conflicting excerpts out of retrieval. A codeless
|
||||
# question detects nothing, so its retrieval is unchanged.
|
||||
lineage = lineage_evidence(question, cfg)
|
||||
evidence = valuation_evidence(question, cfg)
|
||||
conflict_phrases = _conflict_phrases(
|
||||
(evidence.families if evidence is not None else ())
|
||||
+ (lineage.families if lineage is not None else ())
|
||||
)
|
||||
sources = retrieve(
|
||||
question,
|
||||
cfg=cfg,
|
||||
pool=pool,
|
||||
since=since,
|
||||
mode=mode,
|
||||
exclude_phrases=conflict_phrases,
|
||||
)
|
||||
n_retrieved = len(sources)
|
||||
n_cited = n_lineage_sources = n_manual = 0
|
||||
codes: tuple[str, ...] = evidence.codes if evidence is not None else ()
|
||||
families: tuple[str, ...] = evidence.families if evidence is not None else ()
|
||||
if lineage is not None:
|
||||
@@ -644,6 +699,15 @@ def stream_answer(
|
||||
before = len(sources)
|
||||
sources = merge_sources(sources, _manual_sources(narrow_families, cfg))
|
||||
n_manual = len(sources) - before
|
||||
# Cited/lineage/manual rows come from code and family metadata, but a
|
||||
# rule paragraph can still use the acronym for something else
|
||||
# (its own text is what the model reads) — drop those too.
|
||||
sources, n_conflict = _drop_conflicts(sources, conflict_phrases)
|
||||
if n_conflict:
|
||||
log.info(
|
||||
"dropped %d excerpt(s) using a family acronym for something else",
|
||||
n_conflict,
|
||||
)
|
||||
if lineage is not None:
|
||||
yield lineage.payload()
|
||||
if evidence is not None:
|
||||
|
||||
@@ -69,11 +69,52 @@ def _stem_pattern(code: str) -> re.Pattern[str]:
|
||||
return re.compile(rf"\b{re.escape(code)}\s*\(\s*(?!codes?\b)[A-Za-z]", re.I)
|
||||
|
||||
|
||||
#: A descriptor stem is the code and the parenthetical that opens right
|
||||
#: after it — never a whole background paragraph. #700: a 5,543-char
|
||||
#: paragraph in the CY2027 NPRM recounting G0502/G0503's CY2017 descriptors
|
||||
#: inline was absorbed wholesale as G0502's descriptor, yielding
|
||||
#: self-referential addon-of rows and generic activity/population hits
|
||||
#: that bridged the psychiatric CoCM codes into the CCM family.
|
||||
MAX_STEM_CHARS = 1500
|
||||
|
||||
|
||||
def descriptor_span(text: str, code: str, *, max_chars: int = MAX_STEM_CHARS) -> str:
|
||||
"""The slice of *text* that is *code*'s own descriptor: from the code
|
||||
through the close of the parenthetical that opens after it (nested
|
||||
parens balanced), capped at *max_chars* — past the cap, cut at the
|
||||
last sentence end. The stem match itself is required (``_stem_pattern``);
|
||||
returns *text* unchanged when the code's stem is not in it."""
|
||||
m = _stem_pattern(code.upper()).search(text)
|
||||
if not m:
|
||||
return text
|
||||
start = m.start()
|
||||
open_at = text.index("(", m.start())
|
||||
depth = 0
|
||||
end = None
|
||||
for i in range(open_at, min(len(text), start + max_chars)):
|
||||
ch = text[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
end = i + 1
|
||||
break
|
||||
if end is None:
|
||||
window = text[start : start + max_chars]
|
||||
cut = max(window.rfind(". "), window.rfind(".\n"))
|
||||
end = start + (cut + 1 if cut > 0 else len(window))
|
||||
return text[start:end].strip()
|
||||
|
||||
|
||||
def descriptor_runs(
|
||||
store: Any, code: str, *, max_elements: int = 40
|
||||
) -> list[DescriptorRun]:
|
||||
"""Every FR paragraph that opens *code*'s descriptor, with the element
|
||||
paragraphs that follow it (stops at the first prose paragraph)."""
|
||||
paragraphs that follow it (stops at the first prose paragraph). The
|
||||
stem paragraph is trimmed to the code's own descriptor span
|
||||
(``descriptor_span``, #700) so a long paragraph that merely quotes
|
||||
the descriptor inline contributes only the quoted descriptor."""
|
||||
con = store._con()
|
||||
pat = _stem_pattern(code.upper())
|
||||
stems = con.execute(
|
||||
@@ -86,7 +127,12 @@ def descriptor_runs(
|
||||
for row in stems:
|
||||
if not pat.search(row["text"]):
|
||||
continue
|
||||
stem = Para(row["item_key"], row["p_id"], row["page"], row["text"])
|
||||
stem = Para(
|
||||
row["item_key"],
|
||||
row["p_id"],
|
||||
row["page"],
|
||||
descriptor_span(row["text"], code),
|
||||
)
|
||||
following = con.execute(
|
||||
"SELECT p_id, page, text FROM fr_anchors WHERE item_key = ? AND p_id > ? "
|
||||
"ORDER BY p_id LIMIT ?",
|
||||
|
||||
@@ -73,6 +73,10 @@ class Family:
|
||||
#: are matched by their curated ``synonyms`` instead and leave this
|
||||
#: ``False``.
|
||||
cpt: bool = False
|
||||
#: Lower-case phrases that mark a text as using this family's acronym
|
||||
#: for something else ("cardiac contractility modulation" for CCM);
|
||||
#: the chat drops such excerpts from a question about this family.
|
||||
conflicts: tuple[str, ...] = ()
|
||||
|
||||
|
||||
HAND_FAMILIES: dict[str, Family] = {
|
||||
@@ -87,6 +91,11 @@ HAND_FAMILIES: dict[str, Family] = {
|
||||
"Chronic Care Management",
|
||||
("99437", "99439", "99487", "99489", "99490", "99491"),
|
||||
("ccm", "chronic care management"),
|
||||
# "CCM" also names cardiac contractility modulation — the CY2027
|
||||
# NPRM (91 FR 43842 ¶330–331) calls its new 33X01–33X11 codes
|
||||
# "the CCM code family". Excerpts using the acronym that way are
|
||||
# dropped from a chronic-care-management question (rag._drop_conflicts).
|
||||
conflicts=("cardiac contractility modulation", "contractility modulation"),
|
||||
),
|
||||
"PCM": Family(
|
||||
"PCM",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
# source's date year; comment: its docket id's year)
|
||||
|
||||
- id: ccm-history
|
||||
# "CCM" in the CY2027 NPRM also names cardiac contractility modulation
|
||||
# (33X01–33X11); a chronic-care-management answer must never drift there.
|
||||
forbid:
|
||||
- pattern: 'contractility'
|
||||
unless_label: '^$'
|
||||
question: What is the history of CCM coding and payment?
|
||||
mode: timeline
|
||||
expect_anchors:
|
||||
|
||||
@@ -764,7 +764,7 @@ class TestStreamAnswer:
|
||||
# no codes in the question — pgvector is never asked for cited rules
|
||||
mock_engine.assert_not_called()
|
||||
mock_retrieve.assert_called_once_with(
|
||||
"q", cfg=CFG, pool=pool, since="", mode="auto"
|
||||
"q", cfg=CFG, pool=pool, since="", mode="auto", exclude_phrases=()
|
||||
)
|
||||
assert events[0] == {"type": "token", "text": "Doc"}
|
||||
assert events[1] == {"type": "token", "text": "tors"}
|
||||
@@ -1244,3 +1244,60 @@ class TestManualSourcesWrapper:
|
||||
assert rag._manual_sources(("CCM",), CFG) == []
|
||||
assert "manual sources skipped" in caplog.text
|
||||
cur.close.assert_called_once()
|
||||
|
||||
|
||||
class TestAcronymConflicts:
|
||||
"""CCM = chronic care management to the family registry, cardiac
|
||||
contractility modulation to the CY2027 NPRM's "CCM code family";
|
||||
excerpts using the acronym the other way are dropped."""
|
||||
|
||||
def test_phrases_come_from_the_family_registry(self):
|
||||
from llm.rag import _conflict_phrases
|
||||
|
||||
phrases = _conflict_phrases(["CCM"])
|
||||
assert "cardiac contractility modulation" in phrases
|
||||
assert _conflict_phrases(["TCM", "NOPE"]) == ()
|
||||
|
||||
def test_drop_conflicts_on_snippets(self):
|
||||
from llm.rag import _drop_conflicts
|
||||
|
||||
srcs = [
|
||||
{
|
||||
"label": "a",
|
||||
"snippet": "the CCM code family for Cardiac Contractility Modulation",
|
||||
},
|
||||
{"label": "b", "snippet": "CCM services require a comprehensive care plan"},
|
||||
]
|
||||
kept, n = _drop_conflicts(srcs, ("cardiac contractility modulation",))
|
||||
assert [s["label"] for s in kept] == ["b"] and n == 1
|
||||
assert _drop_conflicts(srcs, ()) == (srcs, 0)
|
||||
|
||||
@patch("llm.rag.PoolEmbeddings")
|
||||
@patch("llm.index.vectorstore")
|
||||
def test_retrieve_excludes_conflicting_hits_before_ranking(self, mock_vs, mock_emb):
|
||||
from llm.rag import retrieve
|
||||
|
||||
mock_emb.return_value.embed_query.return_value = [0.1]
|
||||
hits = {
|
||||
"rules": [
|
||||
(
|
||||
_doc(
|
||||
"Cardiac contractility modulation (CCM) pulse generator",
|
||||
item_key="X1",
|
||||
),
|
||||
0.1,
|
||||
),
|
||||
(_doc("CCM services: comprehensive care plan", item_key="X2"), 0.2),
|
||||
]
|
||||
}
|
||||
mock_vs.side_effect = _stores(hits)
|
||||
out = retrieve(
|
||||
"q",
|
||||
cfg=CFG,
|
||||
pool=MagicMock(),
|
||||
mode="recent",
|
||||
exclude_phrases=("cardiac contractility modulation",),
|
||||
)
|
||||
assert [s["label"] for s in out] and all(
|
||||
"contractility" not in s["snippet"].lower() for s in out
|
||||
)
|
||||
|
||||
@@ -227,3 +227,69 @@ class TestDuckDBSources:
|
||||
(2015, "A", "Chron care mgmt srvc 20 min"),
|
||||
(2022, "A", "Chrnc care mgmt staff 1st 20"),
|
||||
]
|
||||
|
||||
|
||||
class TestDescriptorSpan:
|
||||
"""#700: a long background paragraph quoting a descriptor inline must
|
||||
contribute only the quoted descriptor, not the whole paragraph."""
|
||||
|
||||
def test_trims_to_the_codes_parenthetical(self):
|
||||
from pfs.descriptors import descriptor_span
|
||||
|
||||
text = (
|
||||
"In the CY 2017 PFS final rule we created HCPCS code G0502 (Initial "
|
||||
"psychiatric collaborative care management, first 70 minutes in the "
|
||||
"first calendar month of behavioral health care manager activities "
|
||||
"(in consultation with a psychiatric consultant)). "
|
||||
+ "Background prose about chronic care management services, comprehensive "
|
||||
"care plans and multiple chronic conditions expected to last 12 months. "
|
||||
* 60
|
||||
)
|
||||
span = descriptor_span(text, "G0502")
|
||||
assert span.startswith("G0502 (Initial psychiatric")
|
||||
assert span.endswith("consultant))")
|
||||
assert "chronic care management" not in span
|
||||
assert len(span) < 300
|
||||
|
||||
def test_no_close_paren_cuts_at_a_sentence_inside_the_cap(self):
|
||||
from pfs.descriptors import descriptor_span
|
||||
|
||||
text = "HCPCS code G0502 (Initial psychiatric care. " + "More words. " * 400
|
||||
span = descriptor_span(text, "G0502", max_chars=200)
|
||||
assert len(span) <= 200
|
||||
assert span.endswith(".")
|
||||
|
||||
def test_no_stem_returns_the_text(self):
|
||||
from pfs.descriptors import descriptor_span
|
||||
|
||||
assert descriptor_span("nothing about the code here", "G0502") == (
|
||||
"nothing about the code here"
|
||||
)
|
||||
|
||||
def test_descriptor_runs_stem_is_the_span(self, store):
|
||||
from pfs.descriptors import descriptor_runs
|
||||
|
||||
con = store._con()
|
||||
con.execute(
|
||||
"INSERT INTO fr_anchors VALUES (?,?,?,?,?)",
|
||||
(
|
||||
"LONGPARA",
|
||||
9,
|
||||
1,
|
||||
1,
|
||||
"As background, HCPCS code G0502 (Initial psychiatric collaborative "
|
||||
"care management, first 70 minutes) was created in CY 2017. "
|
||||
+ "Chronic care management prose. "
|
||||
* 100,
|
||||
),
|
||||
)
|
||||
con.execute(
|
||||
"INSERT INTO items (key, title, date_published) VALUES (?,?,?)",
|
||||
("LONGPARA", "Medicare Program; CY 2027 PFS Proposed Rule", "2026-07-16"),
|
||||
)
|
||||
con.commit()
|
||||
run = [r for r in descriptor_runs(store, "G0502") if r.item_key == "LONGPARA"][
|
||||
0
|
||||
]
|
||||
assert run.stem.text.startswith("G0502 (Initial psychiatric")
|
||||
assert "Chronic care management prose" not in run.stem.text
|
||||
|
||||
Reference in New Issue
Block a user