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)
|
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(
|
def retrieve(
|
||||||
question: str,
|
question: str,
|
||||||
*,
|
*,
|
||||||
@@ -314,10 +345,15 @@ def retrieve(
|
|||||||
since: str = "",
|
since: str = "",
|
||||||
now: date | None = None,
|
now: date | None = None,
|
||||||
mode: str = "auto",
|
mode: str = "auto",
|
||||||
|
exclude_phrases: Sequence[str] = (),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Top sources for ``question`` across all collections.
|
"""Top sources for ``question`` across all collections.
|
||||||
|
|
||||||
``since`` (ISO date) hard-filters to material dated on/after it.
|
``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,
|
``mode``: "recent" (default behaviour — similarity × recency blend,
|
||||||
byte-identical to before Task 4) or "timeline" (era-balanced —
|
byte-identical to before Task 4) or "timeline" (era-balanced —
|
||||||
@@ -332,9 +368,13 @@ def retrieve(
|
|||||||
hits = filter_since(
|
hits = filter_since(
|
||||||
_hits(vec, cfg=cfg, pool=pool, overfetch=cfg.timeline_overfetch), 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:
|
else:
|
||||||
hits = filter_since(_hits(vec, cfg=cfg, pool=pool), since)
|
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(
|
ranked = blend(
|
||||||
hits,
|
hits,
|
||||||
weight=cfg.recency_weight,
|
weight=cfg.recency_weight,
|
||||||
@@ -592,11 +632,26 @@ def stream_answer(
|
|||||||
``{"type":"done"}``.
|
``{"type":"done"}``.
|
||||||
"""
|
"""
|
||||||
resolved_mode = _resolve_mode(question, mode)
|
resolved_mode = _resolve_mode(question, mode)
|
||||||
sources = retrieve(question, cfg=cfg, pool=pool, since=since, mode=mode)
|
# Detection first: a detected family's acronym conflicts (CCM = chronic
|
||||||
n_retrieved = len(sources)
|
# care management here, cardiac contractility modulation in the CY2027
|
||||||
n_cited = n_lineage_sources = n_manual = 0
|
# NPRM) must keep the conflicting excerpts out of retrieval. A codeless
|
||||||
|
# question detects nothing, so its retrieval is unchanged.
|
||||||
lineage = lineage_evidence(question, cfg)
|
lineage = lineage_evidence(question, cfg)
|
||||||
evidence = valuation_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 ()
|
codes: tuple[str, ...] = evidence.codes if evidence is not None else ()
|
||||||
families: tuple[str, ...] = evidence.families if evidence is not None else ()
|
families: tuple[str, ...] = evidence.families if evidence is not None else ()
|
||||||
if lineage is not None:
|
if lineage is not None:
|
||||||
@@ -644,6 +699,15 @@ def stream_answer(
|
|||||||
before = len(sources)
|
before = len(sources)
|
||||||
sources = merge_sources(sources, _manual_sources(narrow_families, cfg))
|
sources = merge_sources(sources, _manual_sources(narrow_families, cfg))
|
||||||
n_manual = len(sources) - before
|
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:
|
if lineage is not None:
|
||||||
yield lineage.payload()
|
yield lineage.payload()
|
||||||
if evidence is not None:
|
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)
|
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(
|
def descriptor_runs(
|
||||||
store: Any, code: str, *, max_elements: int = 40
|
store: Any, code: str, *, max_elements: int = 40
|
||||||
) -> list[DescriptorRun]:
|
) -> list[DescriptorRun]:
|
||||||
"""Every FR paragraph that opens *code*'s descriptor, with the element
|
"""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()
|
con = store._con()
|
||||||
pat = _stem_pattern(code.upper())
|
pat = _stem_pattern(code.upper())
|
||||||
stems = con.execute(
|
stems = con.execute(
|
||||||
@@ -86,7 +127,12 @@ def descriptor_runs(
|
|||||||
for row in stems:
|
for row in stems:
|
||||||
if not pat.search(row["text"]):
|
if not pat.search(row["text"]):
|
||||||
continue
|
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(
|
following = con.execute(
|
||||||
"SELECT p_id, page, text FROM fr_anchors WHERE item_key = ? AND p_id > ? "
|
"SELECT p_id, page, text FROM fr_anchors WHERE item_key = ? AND p_id > ? "
|
||||||
"ORDER BY p_id LIMIT ?",
|
"ORDER BY p_id LIMIT ?",
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ class Family:
|
|||||||
#: are matched by their curated ``synonyms`` instead and leave this
|
#: are matched by their curated ``synonyms`` instead and leave this
|
||||||
#: ``False``.
|
#: ``False``.
|
||||||
cpt: bool = 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] = {
|
HAND_FAMILIES: dict[str, Family] = {
|
||||||
@@ -87,6 +91,11 @@ HAND_FAMILIES: dict[str, Family] = {
|
|||||||
"Chronic Care Management",
|
"Chronic Care Management",
|
||||||
("99437", "99439", "99487", "99489", "99490", "99491"),
|
("99437", "99439", "99487", "99489", "99490", "99491"),
|
||||||
("ccm", "chronic care management"),
|
("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": Family(
|
||||||
"PCM",
|
"PCM",
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
# source's date year; comment: its docket id's year)
|
# source's date year; comment: its docket id's year)
|
||||||
|
|
||||||
- id: ccm-history
|
- 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?
|
question: What is the history of CCM coding and payment?
|
||||||
mode: timeline
|
mode: timeline
|
||||||
expect_anchors:
|
expect_anchors:
|
||||||
|
|||||||
@@ -764,7 +764,7 @@ class TestStreamAnswer:
|
|||||||
# no codes in the question — pgvector is never asked for cited rules
|
# no codes in the question — pgvector is never asked for cited rules
|
||||||
mock_engine.assert_not_called()
|
mock_engine.assert_not_called()
|
||||||
mock_retrieve.assert_called_once_with(
|
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[0] == {"type": "token", "text": "Doc"}
|
||||||
assert events[1] == {"type": "token", "text": "tors"}
|
assert events[1] == {"type": "token", "text": "tors"}
|
||||||
@@ -1244,3 +1244,60 @@ class TestManualSourcesWrapper:
|
|||||||
assert rag._manual_sources(("CCM",), CFG) == []
|
assert rag._manual_sources(("CCM",), CFG) == []
|
||||||
assert "manual sources skipped" in caplog.text
|
assert "manual sources skipped" in caplog.text
|
||||||
cur.close.assert_called_once()
|
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"),
|
(2015, "A", "Chron care mgmt srvc 20 min"),
|
||||||
(2022, "A", "Chrnc care mgmt staff 1st 20"),
|
(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