pfs guideline-facts writes pfs.code_guideline_fact: selection basis, MDM level, time thresholds, deletions between editions, copy-guarded scope points and the Feb 2021 Panel action, each cited by (edition, page). stance-v5 states the CPT change in the coding event and drops keyword-only coding events; v5 ships on the golden set (recall 14/27, direction 11/14 vs v4 13/27, 10/13).
1508 lines
58 KiB
Python
1508 lines
58 KiB
Python
"""pfs.stance — comment stance per family event (task 9)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import json
|
||
import re
|
||
from pathlib import Path
|
||
|
||
import duckdb
|
||
import pytest
|
||
|
||
from bib.item import Rule
|
||
from bib.store import Store
|
||
from pfs.codetables import (
|
||
CmsSummaryRow,
|
||
CommenterRow,
|
||
EventRow,
|
||
ReactionRow,
|
||
RoundRow,
|
||
StanceRow,
|
||
ensure_tables,
|
||
read_stances,
|
||
write_cms_summaries,
|
||
write_events,
|
||
write_rounds,
|
||
write_stances,
|
||
)
|
||
from pfs.curated import load_curated
|
||
from pfs.runlog import RunLog
|
||
from pfs.stance import (
|
||
DEFAULT_METHOD,
|
||
GOLDEN_PATH,
|
||
PROMPT_VERSIONS,
|
||
STANCE_PROMPT_VERSION,
|
||
EventChoice,
|
||
Subject,
|
||
classify_comment,
|
||
clean_text,
|
||
docket_events,
|
||
docket_rule_year,
|
||
evidence_span,
|
||
position_rows,
|
||
raw_positions,
|
||
rollup,
|
||
stance_schema,
|
||
stances_for,
|
||
)
|
||
|
||
TEXT = (
|
||
"We support the proposed increase in work RVUs for CPT code 99342 because "
|
||
"home visits take far longer than office visits for frail patients. "
|
||
"However we urge CMS to reconsider the new G0318 prolonged services add-on "
|
||
"code, which will be too hard to bill in practice. Sincerely, Jane Doe"
|
||
)
|
||
EV_VAL = "we support the proposed increase in work RVUs for CPT code 99342"
|
||
EV_G = "we urge CMS to reconsider the new G0318 prolonged services add-on"
|
||
FAM = load_curated()["HOME-VISITS"]
|
||
|
||
|
||
def _round(code="99342", year=2023):
|
||
return RoundRow(
|
||
"HOME-VISITS", code, year, "PETM225D", "final", 1, 0, "fr-para", "",
|
||
2.0, "", None, None, "", "", None, 1.9, 1.8, "refine", 0, "", "", "fr",
|
||
) # fmt: skip
|
||
|
||
|
||
def _event(code, year, kind):
|
||
return EventRow(code, year, kind, "", "", "", 0, 0, "fr", True, "")
|
||
|
||
|
||
@pytest.fixture
|
||
def con():
|
||
c = duckdb.connect(":memory:")
|
||
ensure_tables(c)
|
||
yield c
|
||
c.close()
|
||
|
||
|
||
@pytest.fixture
|
||
def events():
|
||
return [
|
||
EventChoice("HOME-VISITS:2023:valuation", "Work RVUs for 99342", 2023),
|
||
EventChoice("HOME-VISITS:2023:coding", "CPT Editorial Panel changes", 2023),
|
||
EventChoice("HOME-VISITS:2023:G0318", "New add-on G0318", 2023),
|
||
EventChoice("HOME-VISITS:2023:other", "Anything else", 2023),
|
||
]
|
||
|
||
|
||
class Ask:
|
||
model = "qwen-test"
|
||
|
||
def __init__(self, positions):
|
||
self.positions = positions
|
||
self.calls = []
|
||
|
||
def __call__(self, system, user, schema):
|
||
self.calls.append((system, user, schema))
|
||
if self.positions is None:
|
||
return None
|
||
return {"positions": self.positions}
|
||
|
||
|
||
def pos(event, stance, reason="r", evidence=""):
|
||
return {"event": event, "stance": stance, "reason": reason, "evidence": evidence}
|
||
|
||
|
||
def S(item, event, stance, docket="D1", reason="", evidence=""):
|
||
return StanceRow("HOME-VISITS", item, docket, event, stance, reason, evidence, "x")
|
||
|
||
|
||
# ── docket year ──────────────────────────────────────────────────────
|
||
|
||
|
||
def test_docket_rule_year_matches_rag():
|
||
from llm.rag import _docket_rule_year
|
||
|
||
for d in ("2022-09-06", "2023-03-01", "2023-07-01", "bad", "", None, "2023"):
|
||
assert docket_rule_year(d) == _docket_rule_year(d)
|
||
assert docket_rule_year("2022-09-06") == 2023
|
||
assert docket_rule_year("2023-03-01") == 2023
|
||
assert docket_rule_year("bad") == 0
|
||
|
||
|
||
# ── events ───────────────────────────────────────────────────────────
|
||
|
||
TELE_PARA = (
|
||
"For home or residence visits CPT codes 99345 and 99350 apply. "
|
||
"These services are similar to services currently on the Medicare Telehealth "
|
||
"Services List, such as CPT codes 99356 and 99357. Similarly, we believe that "
|
||
"these proposed HCPCS G codes would qualify. Therefore, we are proposing to add "
|
||
"proposed HCPCS codes GXXX1, GXXX2, and GXXX3 to the Medicare Telehealth "
|
||
"Services List on a Category 1 basis."
|
||
)
|
||
CODING_PARA = (
|
||
"The CPT guidelines now let home or residence visits (99341, 99342) be "
|
||
"leveled by medical decision making or total time."
|
||
)
|
||
ALIAS_PARA = (
|
||
"We are proposing that prolonged home or residence services would be reportable "
|
||
"under GXXX3 ( Prolonged home or residence evaluation and management service(s) "
|
||
"beyond the total time for the primary service; each extra 15 minutes "
|
||
"(list separately in addition to CPT codes 99345, 99350 for home or residence "
|
||
"evaluation and management services). (Do not report GXXX3 on the same date "
|
||
"as 99358))."
|
||
)
|
||
|
||
|
||
def _store(title, paras):
|
||
s = Store(":memory:")
|
||
key = s.create(Rule(title=title))
|
||
c = s._con() # noqa: SLF001
|
||
c.executemany(
|
||
"INSERT INTO fr_anchors (item_key, p_id, page, ordinal, text) VALUES (?,?,?,?,?)",
|
||
[(key, p, 1, p, t) for p, t in paras.items()],
|
||
)
|
||
c.commit()
|
||
return s, key
|
||
|
||
|
||
@pytest.fixture
|
||
def home_store():
|
||
s, key = _store(
|
||
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{
|
||
5: TELE_PARA,
|
||
6: "An unrelated paragraph about 99342.",
|
||
7: CODING_PARA,
|
||
880: ALIAS_PARA,
|
||
},
|
||
)
|
||
yield s, key
|
||
s.close()
|
||
|
||
|
||
def _fam(key):
|
||
links = tuple(
|
||
dataclasses.replace(ln, alias_item_key=key) if ln.aliases else ln
|
||
for ln in FAM.links
|
||
)
|
||
return dataclasses.replace(FAM, links=links)
|
||
|
||
|
||
def test_docket_events_cy2023_home_visits(con, home_store):
|
||
store, key = home_store
|
||
write_rounds(con, "HOME-VISITS", [_round(), _round(year=2008)])
|
||
write_events(con, "99342", [_event("99342", 2023, "cpt_panel")])
|
||
fam = _fam(key)
|
||
got = docket_events(con, fam, 2023, store, guidelines=False)
|
||
assert [e.event_key for e in got] == [
|
||
"HOME-VISITS:2023:valuation",
|
||
"HOME-VISITS:2023:coding",
|
||
"HOME-VISITS:2023:telehealth",
|
||
"HOME-VISITS:2023:G0318",
|
||
"HOME-VISITS:2023:other",
|
||
]
|
||
assert all(e.rule_year == 2023 for e in got)
|
||
val, coding, tele, g, other = (e.text for e in got)
|
||
assert (
|
||
"99342 (RUC recommended 2.00; CMS proposed 1.90)" in val and "2008" not in val
|
||
)
|
||
assert (
|
||
"CPT deleted 99324–99328, 99334–99337, 99339, 99340 and 99343; those "
|
||
"services are reported with 99341, 99342, 99344, 99345 and 99347–99350 "
|
||
"from CY2023" in coding
|
||
)
|
||
assert "1 cpt panel" in coding
|
||
assert "Rule text: [\u00b67] " + CODING_PARA in coding # densest first
|
||
assert coding.index("[\u00b67]") < coding.index("[\u00b6880]")
|
||
assert tele.endswith(
|
||
"GXXX3 to the Medicare Telehealth Services List on a Category 1 basis."
|
||
)
|
||
assert "unrelated" not in tele
|
||
assert g.startswith("Linked code G0318 (prolonged-add-on), new in the CY2023 rule")
|
||
assert "called it GXXX3: Prolonged home or residence evaluation" in g
|
||
assert (
|
||
"management services). Rule text: [\u00b65]" in g or "Rule text: [\u00b6" in g
|
||
)
|
||
assert "[\u00b6880]" in g
|
||
assert "99341" in other and "other codes or services are not given" in other
|
||
assert (
|
||
docket_events(con, fam, 2023, store, guidelines=False) == got
|
||
) # deterministic
|
||
|
||
|
||
def test_docket_events_without_store_uses_lineage_only(con):
|
||
write_events(
|
||
con,
|
||
"99341",
|
||
[_event("99341", 2024, "telehealth_list"), _event("99341", 2024, "created")],
|
||
)
|
||
got = docket_events(con, FAM, 2024)
|
||
assert [e.event_key.split(":")[-1] for e in got] == [
|
||
"coding",
|
||
"telehealth",
|
||
"other",
|
||
]
|
||
assert "1 created" in got[0].text and "deleted" not in got[0].text
|
||
assert got[1].text.endswith("(lineage: telehealth list change for 99341)")
|
||
assert [e.event_key for e in docket_events(con, FAM, 1999)] == [
|
||
"HOME-VISITS:1999:other"
|
||
]
|
||
|
||
|
||
def test_docket_events_link_without_alias_or_paragraph(con):
|
||
got = docket_events(con, FAM, 2026)
|
||
assert got[0].event_key == "HOME-VISITS:2026:G2211"
|
||
assert got[0].text == "Linked code G2211 (add-on), new in the CY2026 rule"
|
||
store = Store(":memory:") # alias paragraph absent
|
||
g = docket_events(con, FAM, 2023, store)[-2]
|
||
assert g.text.endswith("the proposed rule called it GXXX3")
|
||
|
||
|
||
def test_telehealth_paragraph_with_a_range_and_item_fallback(con):
|
||
s, _ = _store(
|
||
"CY 2024 Payment Policies Under the Physician Fee Schedule; Final Rule",
|
||
{
|
||
1: "Telehealth is covered for CPT codes 99324-99328 as proposed.",
|
||
2: "Telehealth for something else, 12345.",
|
||
},
|
||
)
|
||
got = docket_events(con, FAM, 2024, s, guidelines=False)
|
||
assert [e.event_key.split(":")[-1] for e in got] == ["telehealth", "other"]
|
||
assert "99324-99328" in got[0].text
|
||
s.close()
|
||
s2, _ = _store("CY 2024 Payment Policies; PFS", {1: "Telehealth for 12345."})
|
||
assert len(docket_events(con, FAM, 2024, s2, guidelines=False)) == 1
|
||
assert len(docket_events(con, FAM, 2030, s2, guidelines=False)) == 1
|
||
s2.close()
|
||
|
||
|
||
def test_descriptor_without_do_not_clause(con):
|
||
s, key = _store(
|
||
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{880: "We propose GXXX3 ( Prolonged home visit (each 15 minutes) of services."},
|
||
)
|
||
g = docket_events(con, _fam(key), 2023, s)[-2]
|
||
assert (
|
||
"called it GXXX3: Prolonged home visit (each 15 minutes) of services." in g.text
|
||
)
|
||
s.close()
|
||
s3, key3 = _store(
|
||
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{880: "no placeholder here"},
|
||
)
|
||
assert docket_events(con, _fam(key3), 2023, s3)[-2].text.endswith("called it GXXX3")
|
||
s3.close()
|
||
|
||
|
||
def test_docket_events_round_without_values(con):
|
||
r = dataclasses.replace(
|
||
_round(), ruc_work_rvu=None, proposed_work_rvu=None, final_work_rvu=None
|
||
)
|
||
write_rounds(con, "HOME-VISITS", [r])
|
||
val = docket_events(con, FAM, 2023)[0]
|
||
assert val.text.endswith("99342") and "RUC" not in val.text
|
||
r3 = dataclasses.replace(_round(), ruc_work_rvu=1.9)
|
||
write_rounds(con, "HOME-VISITS", [r3])
|
||
assert docket_events(con, FAM, 2023)[0].text.endswith(
|
||
"99342 (RUC recommended and CMS proposed 1.90)"
|
||
)
|
||
r2 = dataclasses.replace(_round(), proposed_work_rvu=None)
|
||
write_rounds(con, "HOME-VISITS", [r2])
|
||
assert docket_events(con, FAM, 2023)[0].text.endswith(
|
||
"99342 (RUC recommended 2.00)"
|
||
)
|
||
|
||
|
||
def test_stance_schema(events):
|
||
sc = stance_schema(events)
|
||
item = sc["properties"]["positions"]["items"]
|
||
assert item["properties"]["event"]["enum"] == [e.event_key for e in events]
|
||
assert item["properties"]["stance"]["enum"] == [
|
||
"support",
|
||
"modify",
|
||
"oppose",
|
||
"unclear",
|
||
]
|
||
assert item["properties"]["reason"]["maxLength"] == 200
|
||
assert item["properties"]["evidence"]["maxLength"] == 200
|
||
assert PROMPT_VERSIONS == {
|
||
"multi": "stance-v5",
|
||
"per-event": "stance-v5-per-event",
|
||
}
|
||
assert STANCE_PROMPT_VERSION == PROMPT_VERSIONS[DEFAULT_METHOD]
|
||
|
||
|
||
# ── classify_comment ─────────────────────────────────────────────────
|
||
|
||
|
||
def _classify(positions, text=TEXT, events=None, ask=None):
|
||
ask = ask or Ask(positions)
|
||
return classify_comment(
|
||
text, events, ask, family="HOME-VISITS", item_key="K1", docket="D1"
|
||
)
|
||
|
||
|
||
def test_prompt_lists_events_and_rules(events):
|
||
ask = Ask([])
|
||
_classify([], events=events, ask=ask)
|
||
system, user, schema = ask.calls[0]
|
||
assert "HOME-VISITS:2023:G0318: New add-on G0318" in system
|
||
for rule in (
|
||
"support = the commenter endorses what CMS proposed",
|
||
"oppose = the commenter rejects the proposal: asks CMS not to adopt or finalize it, to withdraw or delay it",
|
||
"modify = the commenter accepts the proposal but asks for changes or additions",
|
||
"give no position at all",
|
||
"at most one position per event",
|
||
"other codes or other specialties",
|
||
):
|
||
assert rule in system
|
||
assert user == TEXT
|
||
assert schema == stance_schema(events)
|
||
|
||
|
||
def test_none_reply_is_none(events):
|
||
assert _classify(None, events=events) is None
|
||
|
||
|
||
def test_rows_carry_fields(events):
|
||
rows = _classify(
|
||
[pos("HOME-VISITS:2023:valuation", "support", "likes increase", EV_VAL)],
|
||
events=events,
|
||
)
|
||
assert rows == [
|
||
StanceRow(
|
||
"HOME-VISITS",
|
||
"K1",
|
||
"D1",
|
||
"HOME-VISITS:2023:valuation",
|
||
"support",
|
||
"likes increase",
|
||
"We support the proposed increase in work RVUs for CPT code 99342",
|
||
"model:qwen-test@stance-v5",
|
||
)
|
||
]
|
||
|
||
|
||
def test_unknown_event_in_reply_dropped(events):
|
||
rows = _classify(
|
||
[
|
||
pos("HOME-VISITS:2023:nope", "support", "r", EV_VAL),
|
||
pos(events[3].event_key, "oppose", "r", EV_VAL),
|
||
],
|
||
events=events,
|
||
)
|
||
assert [r.event_key for r in rows] == [events[3].event_key]
|
||
|
||
|
||
def test_bad_stance_and_non_dict_dropped(events):
|
||
ask = Ask([pos(events[0].event_key, "maybe", "r", EV_VAL), "junk"])
|
||
assert _classify(None, events=events, ask=ask) == []
|
||
|
||
|
||
def test_evidence_must_occur_in_text_else_position_dropped(events):
|
||
rows = _classify(
|
||
[
|
||
pos(events[0].event_key, "support", "r", EV_VAL),
|
||
pos(events[1].event_key, "oppose", "r", "this sentence was never written anywhere"),
|
||
pos(events[2].event_key, "modify", "r", "too short quote"),
|
||
pos(events[3].event_key, "unclear", "r", ""),
|
||
],
|
||
events=events,
|
||
) # fmt: skip
|
||
assert [(r.event_key, r.stance) for r in rows] == [(events[0].event_key, "support")]
|
||
|
||
|
||
def test_evidence_is_the_longest_verbatim_span():
|
||
text = "Intro words here. We urge CMS to reconsider the new G0318 prolonged services add-on code, which will be hard."
|
||
q = "CMS to reconsider the new G0318 prolonged services add-on code, and more"
|
||
# the matched run is cut at the quote's own words, in the comment's own spelling
|
||
assert (
|
||
evidence_span(q, text)
|
||
== "CMS to reconsider the new G0318 prolonged services add-on code,"
|
||
)
|
||
assert (
|
||
evidence_span("nothing in common whatsoever with the text at all", text) == ""
|
||
)
|
||
assert evidence_span("", text) == ""
|
||
|
||
|
||
def test_evidence_span_capped_at_200_chars():
|
||
text = " ".join(f"word{i}" for i in range(80))
|
||
span = evidence_span(text, text)
|
||
assert 0 < len(span) <= 200 and text.startswith(span) and not span.endswith(" ")
|
||
|
||
|
||
def test_evidence_window_match_is_case_and_punctuation_blind(events):
|
||
ev = "WE SUPPORT, the proposed increase: in work RVUs for CPT code 99342 (and more)"
|
||
rows = _classify([pos(events[0].event_key, "support", "r", ev)], events=events)
|
||
assert rows[0].evidence.startswith("We support the proposed increase in work RVUs")
|
||
|
||
|
||
def test_clean_text_decodes_html():
|
||
assert (
|
||
clean_text("people's "x"<br/>next<br />end")
|
||
== 'people\'s "x"\nnext\nend'
|
||
)
|
||
assert clean_text(None) == ""
|
||
rows = _classify(
|
||
[
|
||
pos(
|
||
"HOME-VISITS:2023:other",
|
||
"support",
|
||
"r",
|
||
"this is what the people's own words say here",
|
||
)
|
||
],
|
||
text="this is what the people's own words say here",
|
||
events=[EventChoice("HOME-VISITS:2023:other", "o", 2023)],
|
||
)
|
||
assert rows[0].evidence == "this is what the people's own words say here"
|
||
|
||
|
||
def test_one_position_per_event_first_with_evidence_wins(events):
|
||
k = events[0].event_key
|
||
rows = _classify(
|
||
[
|
||
pos(k, "oppose", "first, no evidence", "nothing like the text of this comment here"),
|
||
pos(k, "support", "second", EV_VAL),
|
||
pos(k, "modify", "third", EV_VAL),
|
||
],
|
||
events=events,
|
||
) # fmt: skip
|
||
assert [(r.stance, r.reason) for r in rows] == [("support", "second")]
|
||
|
||
|
||
def test_one_quote_backs_one_position_only(events):
|
||
rows = _classify(
|
||
[
|
||
pos(events[0].event_key, "oppose", "a", EV_VAL),
|
||
pos(events[1].event_key, "oppose", "b", EV_VAL),
|
||
pos(events[2].event_key, "modify", "c", EV_G),
|
||
],
|
||
events=events,
|
||
)
|
||
assert [(r.event_key, r.reason) for r in rows] == [
|
||
(events[0].event_key, "a"),
|
||
(events[2].event_key, "c"),
|
||
]
|
||
|
||
|
||
def test_reason_truncated(events):
|
||
rows = _classify(
|
||
[pos(events[0].event_key, "support", "x" * 500, EV_VAL)], events=events
|
||
)
|
||
assert len(rows[0].reason) == 200
|
||
assert raw_positions(None, events) is None
|
||
assert raw_positions({}, events) == []
|
||
assert (
|
||
raw_positions(
|
||
{"positions": [pos(events[0].event_key, "oppose", None, None)]}, events
|
||
)[0]["reason"]
|
||
== ""
|
||
)
|
||
|
||
|
||
def test_position_rows_uses_given_extractor(events):
|
||
raw = [pos(events[0].event_key, "modify", "r", EV_VAL)]
|
||
(r,) = position_rows(raw, TEXT, family="F", item_key="K", docket="D", extractor="x")
|
||
assert (r.family, r.item_key, r.docket, r.extractor, r.stance) == (
|
||
"F",
|
||
"K",
|
||
"D",
|
||
"x",
|
||
"modify",
|
||
)
|
||
|
||
|
||
# ── stances_for (ledger) ─────────────────────────────────────────────
|
||
|
||
|
||
def _commenter(key, cid=None, org="individual"):
|
||
return CommenterRow(
|
||
key, "D1", cid or "C-" + key, "", org, "", "", 0, "regs_gov_fields", "", "regs", "HOME-VISITS"
|
||
) # fmt: skip
|
||
|
||
|
||
def _run(tmp_path, ask, commenters, texts, events, **kw):
|
||
log = RunLog(tmp_path / "stance.jsonl")
|
||
out = stances_for(
|
||
commenters, texts, events, ask, log, family="HOME-VISITS", docket="D1", **kw
|
||
)
|
||
return out, log
|
||
|
||
|
||
def test_ledger_keeps_the_raw_reply_and_filters_run_on_replay(tmp_path, events):
|
||
ask = Ask(
|
||
[
|
||
pos(events[0].event_key, "support", "likes it", EV_VAL),
|
||
pos(events[0].event_key, "oppose", "dup", EV_VAL),
|
||
pos(events[1].event_key, "oppose", "bad", "not a quote from the text at all"),
|
||
pos("HOME-VISITS:2023:zzz", "support"),
|
||
]
|
||
) # fmt: skip
|
||
(rows, counts), _ = _run(tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events)
|
||
assert counts == {
|
||
"done": 1, "skipped": 0, "failed": 0, "failed_pending": 0,
|
||
"pending": 0, "no_text": 0,
|
||
} # fmt: skip
|
||
assert [(r.event_key, r.stance, r.reason) for r in rows] == [
|
||
(events[0].event_key, "support", "likes it")
|
||
]
|
||
assert rows[0].extractor == f"model:qwen-test@{STANCE_PROMPT_VERSION}"
|
||
ledger = [
|
||
json.loads(line)
|
||
for line in (tmp_path / "stance.jsonl").read_text().splitlines()
|
||
]
|
||
raw = ledger[0]["output"]["positions"]
|
||
assert (
|
||
len(raw) == 3 and raw[1]["stance"] == "oppose"
|
||
) # everything valid, unfiltered
|
||
# replay with no model: same rows, the model is never asked
|
||
(again, c2), _ = _run(
|
||
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events, model="qwen-test"
|
||
)
|
||
assert again == rows and c2["done"] == 0 and c2["pending"] == 0
|
||
assert len(ask.calls) == 1
|
||
|
||
|
||
def test_no_llm_without_a_ledger_is_pending(tmp_path, events):
|
||
(rows, counts), _ = _run(
|
||
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events, model="qwen-test"
|
||
)
|
||
assert rows == [] and counts["pending"] == 1
|
||
|
||
|
||
def test_stances_for_resume_limit_failed(tmp_path, events):
|
||
cs = [_commenter(k) for k in ("K1", "K2", "K3", "K4", "K5")]
|
||
texts = {k: TEXT for k in ("K1", "K2", "K3", "K4")} # K5 has no text
|
||
ask = Ask([pos(events[3].event_key, "oppose", "r", EV_VAL)])
|
||
(rows, counts), _ = _run(tmp_path, ask, cs, texts, events, limit=2)
|
||
assert counts["done"] == 2 and counts["pending"] == 2 and counts["no_text"] == 1
|
||
assert len(ask.calls) == 2 and len(rows) == 2
|
||
(rows, counts), _ = _run(tmp_path, ask, cs, texts, events)
|
||
assert counts["skipped"] == 2 and counts["done"] == 2 and counts["pending"] == 0
|
||
assert len(rows) == 4 and len(ask.calls) == 4
|
||
bad = Ask(None)
|
||
(rows, counts), _ = _run(tmp_path, bad, [_commenter("K9")], {"K9": TEXT}, events)
|
||
assert counts["failed"] == 1 and counts["pending"] == 1 and rows == []
|
||
(rows, counts), _ = _run(tmp_path, bad, [_commenter("K9")], {"K9": TEXT}, events)
|
||
assert counts["failed_pending"] == 1 and len(bad.calls) == 1
|
||
good = Ask([pos(events[3].event_key, "support", "r", EV_VAL)])
|
||
(rows, counts), _ = _run(
|
||
tmp_path, good, [_commenter("K9")], {"K9": TEXT}, events, retry_failed=True
|
||
)
|
||
assert counts["done"] == 1 and len(rows) == 1
|
||
|
||
|
||
def test_stances_for_event_change_reruns(tmp_path, events):
|
||
ask = Ask([pos(events[3].event_key, "support", "r", EV_VAL)])
|
||
c, t = [_commenter("K1")], {"K1": TEXT}
|
||
_run(tmp_path, ask, c, t, events)
|
||
other = [*events[:3], EventChoice(events[3].event_key, "different text", 2023)]
|
||
(_rows, counts), _ = _run(tmp_path, ask, c, t, other)
|
||
assert counts["done"] == 1
|
||
|
||
|
||
def test_stances_for_progress(tmp_path, events):
|
||
seen = []
|
||
_run(
|
||
tmp_path, Ask([]), [_commenter("K1")], {"K1": TEXT}, events,
|
||
progress=lambda u, s: seen.append((u, s)),
|
||
) # fmt: skip
|
||
assert seen == [("K1", "done")]
|
||
|
||
|
||
# ── table ────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_stance_table_roundtrip(con):
|
||
a = S("K1", "HOME-VISITS:2023:other", "support", reason="r", evidence="e")
|
||
b = S("K2", "HOME-VISITS:2023:other", "oppose", docket="D2")
|
||
c = StanceRow("OTHER", "K3", "D1", "OTHER:2023:other", "modify", "", "", "x")
|
||
write_stances(con, "HOME-VISITS", "D1", [a])
|
||
write_stances(con, "HOME-VISITS", "D2", [b])
|
||
write_stances(con, "OTHER", "D1", [c])
|
||
assert read_stances(con, "HOME-VISITS") == [a, b]
|
||
assert read_stances(con, "HOME-VISITS", docket="D2") == [b]
|
||
write_stances(con, "HOME-VISITS", "D1", []) # replaces just that docket
|
||
assert read_stances(con, "HOME-VISITS") == [b]
|
||
assert read_stances(con, "OTHER") == [c]
|
||
|
||
|
||
def test_reviewed_flag_defaults_false_and_roundtrips(con):
|
||
a = S("K1", "E", "support")
|
||
assert a.reviewed is False
|
||
b = dataclasses.replace(S("K2", "E", "oppose"), reviewed=True)
|
||
write_stances(con, "HOME-VISITS", "D1", [a, b])
|
||
got = read_stances(con, "HOME-VISITS")
|
||
assert [(r.item_key, r.reviewed) for r in got] == [("K1", False), ("K2", True)]
|
||
|
||
|
||
def test_old_stance_table_gains_reviewed_false():
|
||
c = duckdb.connect(":memory:")
|
||
c.execute("CREATE SCHEMA pfs")
|
||
c.execute(
|
||
"CREATE TABLE pfs.comment_stance (family VARCHAR, item_key VARCHAR, docket VARCHAR, "
|
||
"event_key VARCHAR, stance VARCHAR, reason VARCHAR, evidence VARCHAR, extractor VARCHAR)"
|
||
)
|
||
c.execute(
|
||
"INSERT INTO pfs.comment_stance VALUES ('F','K','D','E','support','','','x')"
|
||
)
|
||
assert read_stances(c, "F")[0].reviewed is False # an old replica: short rows
|
||
ensure_tables(c)
|
||
assert read_stances(c, "F")[0].reviewed is False
|
||
c.execute("INSERT INTO pfs.comment_stance (family, item_key) VALUES ('G','K2')")
|
||
assert read_stances(c, "G")[0].reviewed is False # the column default
|
||
|
||
|
||
# ── rollup ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def _base(**kw):
|
||
d = dict(
|
||
family="HOME-VISITS", period="D1", period_kind="docket", year=2023,
|
||
n_items=9, n_total=40, stance_support=0, stance_oppose=0,
|
||
stance_modify=0, stance_unclear=0, sample_json='[{"k": 1}]',
|
||
) # fmt: skip
|
||
return ReactionRow(**{**d, **kw})
|
||
|
||
|
||
V, C, T, G, O = (
|
||
f"HOME-VISITS:2023:{s}"
|
||
for s in ("valuation", "coding", "telehealth", "G0318", "other")
|
||
)
|
||
ORDER = (V, C, T, G, O)
|
||
|
||
|
||
def test_rollup_counts_distinct_commenters():
|
||
rows = [
|
||
# K1: valuation unclear, coding support -> first non-unclear = support
|
||
S("K1", V, "unclear"), S("K1", C, "support"), S("K1", G, "oppose"),
|
||
# K2: telehealth comes before G0318 and other in docket event order
|
||
S("K2", O, "oppose"), S("K2", O, "support"), S("K2", T, "modify"), S("K2", G, "oppose"),
|
||
# K3: all unclear
|
||
S("K3", V, "unclear"), S("K3", O, "unclear"),
|
||
# K4: same stance on several events counts once
|
||
S("K4", V, "oppose"), S("K4", G, "oppose"),
|
||
] # fmt: skip
|
||
got = rollup(rows, base=_base(), order=ORDER, n_commenters=52)
|
||
assert (
|
||
got.stance_support,
|
||
got.stance_modify,
|
||
got.stance_oppose,
|
||
got.stance_unclear,
|
||
) == (1, 1, 1, 1)
|
||
assert (got.n_classified, got.n_commenters) == (3, 52)
|
||
assert got.sample_json == '[{"k": 1}]' and got.n_items == 9 and got.period == "D1"
|
||
ev = json.loads(got.events_json)
|
||
assert list(ev) == [V, C, T, G, O] # docket_events order, not key-string order
|
||
assert ev[V] == {"support": 0, "modify": 0, "oppose": 1, "unclear": 2}
|
||
assert ev[C] == {"support": 1, "modify": 0, "oppose": 0, "unclear": 0}
|
||
assert ev[T] == {"support": 0, "modify": 1, "oppose": 0, "unclear": 0}
|
||
assert ev[G] == {"support": 0, "modify": 0, "oppose": 3, "unclear": 0}
|
||
assert ev[O] == {"support": 0, "modify": 0, "oppose": 1, "unclear": 1}
|
||
|
||
|
||
def test_rollup_order_beats_key_order_and_unknown_events_sort_last():
|
||
# key-string order would put "G0318" before "telehealth"; docket order does not
|
||
rows = [
|
||
S("K1", G, "oppose"),
|
||
S("K1", T, "support"),
|
||
S("K1", "X:2023:zzz", "modify"),
|
||
]
|
||
got = rollup(rows, base=_base(), order=ORDER)
|
||
assert got.stance_support == 1 and got.n_commenters is None
|
||
assert list(json.loads(got.events_json)) == [T, G, "X:2023:zzz"]
|
||
# with no order given, the event key decides
|
||
assert rollup(rows, base=_base()).stance_oppose == 1
|
||
|
||
|
||
def test_rollup_empty_is_zero():
|
||
got = rollup([], base=_base(stance_support=3), n_commenters=4)
|
||
assert got.stance_support == 0 and got.events_json == "{}"
|
||
assert (got.n_classified, got.n_commenters) == (0, 4)
|
||
|
||
|
||
def test_rollup_ignores_cms_summaries(con):
|
||
write_cms_summaries(
|
||
con,
|
||
"HOME-VISITS",
|
||
[CmsSummaryRow("HOME-VISITS", "R1", 2023, "final", 1, 2, "99342", "a majority of commenters", "support", "finalize_as_proposed", "regex")],
|
||
) # fmt: skip
|
||
rows = [S("K1", "HOME-VISITS:2023:other", "oppose")]
|
||
got = rollup(rows, base=_base())
|
||
assert (got.stance_support, got.stance_oppose) == (0, 1)
|
||
|
||
|
||
# ── paragraphs in event texts (R64) ──────────────────────────────────
|
||
|
||
|
||
def _by_slug(evs, slug):
|
||
return next(e.text for e in evs if e.event_key.endswith(":" + slug))
|
||
|
||
|
||
def test_excerpt_window_and_short_paragraphs():
|
||
from pfs.stance import _TELEHEALTH_WORDS, _excerpt
|
||
|
||
assert (
|
||
_excerpt("short telehealth\ntext", _TELEHEALTH_WORDS)
|
||
== "short telehealth text"
|
||
)
|
||
long = (
|
||
"filler " * 200
|
||
+ "telehealth one. telehealth two. telehealth three. "
|
||
+ "tail " * 200
|
||
)
|
||
out = _excerpt(long, _TELEHEALTH_WORDS)
|
||
assert len(out) <= 600 and out.count("telehealth") == 3 and long.count(out) == 1
|
||
assert not out.startswith(" ") and not out.endswith(" ")
|
||
no_hit = "w " * 500
|
||
assert _excerpt(no_hit, _TELEHEALTH_WORDS) in no_hit
|
||
assert len(_excerpt(no_hit, _TELEHEALTH_WORDS)) <= 600
|
||
tail_hit = "x " * 400 + "telehealth end"
|
||
assert _excerpt(tail_hit, _TELEHEALTH_WORDS).endswith("telehealth end")
|
||
|
||
|
||
def test_paragraph_choice_is_by_density_and_names_the_family(con):
|
||
s, key = _store(
|
||
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{
|
||
1: "Telehealth " + "filler " * 80 + "for 99341.", # one hit, long
|
||
2: "Telehealth and telehealth for 99342.", # densest
|
||
3: "Telehealth for home or residence visits generally.", # phrase, no code
|
||
4: "Telehealth for 99999 only.", # names no family code
|
||
5: "99341 with no keyword at all.",
|
||
6: "Telehealth for 99345 too " + "w " * 40,
|
||
},
|
||
)
|
||
tele = _by_slug(docket_events(con, FAM, 2023, s, guidelines=False), "telehealth")
|
||
assert tele.index("[¶2]") < tele.index("[¶3]")
|
||
assert tele.count("[¶") == 2 # at most two paragraphs
|
||
assert "[¶4]" not in tele and "[¶5]" not in tele
|
||
s.close()
|
||
|
||
|
||
def test_time_counts_a_quarter_of_the_guideline_words(con):
|
||
s, key = _store(
|
||
"CY 2023 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{
|
||
1: "time time time time time time for 99341 " + "filler " * 60, # weak only
|
||
2: "The guideline for 99341 is new and long text follows here for density",
|
||
3: "Deleted code 99343 is gone.",
|
||
},
|
||
)
|
||
coding = _by_slug(docket_events(con, FAM, 2023, s, guidelines=False), "coding")
|
||
assert coding.index("[¶3]") < coding.index("[¶2]")
|
||
assert coding.count("[¶") == 2 and "[¶1]" not in coding
|
||
s.close()
|
||
|
||
|
||
def test_coding_event_from_paragraphs_alone_is_v4_only(con):
|
||
s, _ = _store(
|
||
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{1: "New guideline for 99341 visits."},
|
||
)
|
||
ev = docket_events(con, FAM, 2030, s, guidelines=False)
|
||
assert ev[0].event_key.endswith(":coding")
|
||
assert ev[0].text.startswith("Coding changes. Rule text: [¶1]")
|
||
s.close()
|
||
|
||
|
||
# ── per-event method ─────────────────────────────────────────────────
|
||
|
||
|
||
class EventAsk:
|
||
"""Answers per call; remembers the system prompt of each."""
|
||
|
||
model = "qwen-test"
|
||
|
||
def __init__(self, by_event):
|
||
self.by_event = by_event
|
||
self.calls = []
|
||
|
||
def __call__(self, system, user, schema):
|
||
self.calls.append((system, user, schema))
|
||
enum = schema["properties"]["positions"]["items"]["properties"]["event"]["enum"]
|
||
if len(enum) != 1:
|
||
return {"positions": []}
|
||
ans = self.by_event.get(enum[0])
|
||
return None if ans == "FAIL" else {"positions": [ans] if ans else []}
|
||
|
||
|
||
def test_per_event_asks_once_per_item_and_event(tmp_path, events):
|
||
ask = EventAsk(
|
||
{
|
||
events[0].event_key: pos(events[0].event_key, "support", "r", EV_VAL),
|
||
events[2].event_key: pos(events[2].event_key, "modify", "r", EV_G),
|
||
}
|
||
)
|
||
(rows, counts), log = _run(
|
||
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, method="per-event"
|
||
)
|
||
assert len(ask.calls) == 4 and counts["done"] == 4 and counts["pending"] == 0
|
||
assert [(r.event_key, r.stance) for r in rows] == [
|
||
(events[0].event_key, "support"),
|
||
(events[2].event_key, "modify"),
|
||
]
|
||
assert rows[0].extractor == "model:qwen-test@stance-v5-per-event"
|
||
# the focused prompt names only its own event
|
||
system = ask.calls[1][0]
|
||
assert events[1].event_key in system and events[0].event_key not in system
|
||
assert "exactly this event" in system
|
||
ledger = [
|
||
json.loads(x) for x in (tmp_path / "stance.jsonl").read_text().splitlines()
|
||
]
|
||
assert {e["unit"] for e in ledger} == {f"K1::{e.event_key}" for e in events}
|
||
assert {e["version"] for e in ledger} == {"stance-v5-per-event"}
|
||
# replay without a model reads the same rows
|
||
(again, c2), _ = _run(
|
||
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events,
|
||
method="per-event", model="qwen-test",
|
||
) # fmt: skip
|
||
assert again == rows and c2["pending"] == 0
|
||
# the multi-event ledger of the same model is a different version
|
||
(rows_m, c3), _ = _run(
|
||
tmp_path, None, [_commenter("K1")], {"K1": TEXT}, events, model="qwen-test"
|
||
)
|
||
assert rows_m == [] and c3["pending"] == 1
|
||
|
||
|
||
def test_per_event_limit_and_partial_item_is_pending(tmp_path, events):
|
||
ask = EventAsk(
|
||
{events[0].event_key: pos(events[0].event_key, "support", "r", EV_VAL)}
|
||
)
|
||
(rows, counts), _ = _run(
|
||
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, method="per-event", limit=2
|
||
) # fmt: skip
|
||
assert counts["done"] == 2 and counts["pending"] == 1 and len(ask.calls) == 2
|
||
ask.by_event[events[3].event_key] = "FAIL"
|
||
(rows, counts), _ = _run(
|
||
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, method="per-event"
|
||
) # fmt: skip
|
||
assert counts["failed"] == 1 and counts["skipped"] == 2 and counts["pending"] == 1
|
||
|
||
|
||
# ── scoring against a golden set ─────────────────────────────────────
|
||
|
||
|
||
def test_golden_set_is_packaged_with_pfs():
|
||
"""R67: production code reads the golden set from the pfs package, never
|
||
from tests/."""
|
||
import pfs
|
||
|
||
assert GOLDEN_PATH.is_file()
|
||
assert GOLDEN_PATH.parent == Path(pfs.__file__).resolve().parent / "golden"
|
||
assert GOLDEN_PATH.name == "stance_home_visits_cy2023.json"
|
||
assert "tests" not in GOLDEN_PATH.parts
|
||
|
||
|
||
def test_golden_fixture_shape():
|
||
from pfs.stance import golden_labels
|
||
|
||
golden = json.loads(GOLDEN_PATH.read_text())
|
||
labels = golden_labels(golden)
|
||
assert (golden["family"], golden["docket"]) == ("HOME-VISITS", "CMS-2022-0113")
|
||
assert len(golden["items"]) == 20 and len(labels) == 100
|
||
assert {e for _, e in labels} == set(golden["events"])
|
||
assert set(labels.values()) <= {"none", "support", "modify", "oppose", "unclear"}
|
||
assert sum(v != "none" for v in labels.values()) > 24
|
||
|
||
|
||
def test_score_perfect_and_imperfect():
|
||
from pfs.stance import LABELS, score
|
||
|
||
golden = json.loads(GOLDEN_PATH.read_text())
|
||
from pfs.stance import golden_labels
|
||
|
||
perfect = [
|
||
S(i, e, lab) for (i, e), lab in golden_labels(golden).items() if lab != "none"
|
||
]
|
||
sc = score(golden, perfect)
|
||
assert (sc.cell_agreement, sc.presence_agreement, sc.firm_accuracy) == (
|
||
1.0,
|
||
1.0,
|
||
1.0,
|
||
)
|
||
assert sc.passes() and sc.disagreements == ()
|
||
assert sc.confusion["support"]["support"] == sum(
|
||
v == "support" for v in golden_labels(golden).values()
|
||
)
|
||
assert list(sc.confusion) == list(LABELS)
|
||
assert score(golden, []).cell_agreement < 1 and not score(golden, []).passes()
|
||
|
||
|
||
def test_score_counts_and_severity():
|
||
from pfs.stance import score
|
||
|
||
g = {
|
||
"items": [
|
||
{
|
||
"item_key": "A",
|
||
"cells": {
|
||
"e1": {"label": "support"},
|
||
"e2": {"label": "support"},
|
||
"e3": {"label": "none"},
|
||
"e4": {"label": "unclear"},
|
||
"e5": {"label": "modify"},
|
||
"e6": {"label": "oppose"},
|
||
},
|
||
}
|
||
]
|
||
}
|
||
rows = [
|
||
S("A", "e1", "oppose"), # opposite direction: severity 3
|
||
S("A", "e2", "support"), # right
|
||
S("A", "e3", "modify"), # presence
|
||
S("A", "e4", "support"), # unclear golden
|
||
S("A", "e5", "oppose"), # firm vs other firm
|
||
] # e6 missed entirely
|
||
sc = score(g, rows)
|
||
assert (sc.cells, sc.agree, sc.presence_agree) == (6, 1, 4)
|
||
assert (sc.firm_cells, sc.firm_correct) == (4, 1)
|
||
assert [(d.event_key, d.severity) for d in sc.disagreements] == [
|
||
("e1", 3),
|
||
("e3", 2),
|
||
("e5", 2),
|
||
("e6", 2),
|
||
("e4", 1),
|
||
]
|
||
assert sc.confusion["oppose"]["none"] == 1
|
||
# R66a: golden positions e1 e2 e4 e5 e6; found e1 e2 e4 e5; right e2
|
||
assert (sc.positions, sc.found, sc.right) == (5, 4, 1)
|
||
assert (sc.recall, sc.direction) == (0.8, 0.25)
|
||
from pfs.stance import Scores
|
||
|
||
empty = Scores(0, 0, 0, 0, 0, {}, ())
|
||
assert (empty.cell_agreement, empty.presence_agreement, empty.firm_accuracy) == (
|
||
0,
|
||
0,
|
||
0,
|
||
)
|
||
assert (empty.recall, empty.direction) == (0, 0)
|
||
|
||
|
||
def test_eval_record_is_the_r66a_json():
|
||
from pfs.stance import eval_record, golden_labels, score
|
||
|
||
golden = json.loads(GOLDEN_PATH.read_text())
|
||
labels = golden_labels(golden)
|
||
firm = sorted(k for k, v in labels.items() if v != "none")
|
||
# find 3 of the hand-coded positions, 2 in the right direction, and add one
|
||
# position on a golden `none` cell
|
||
wrong = "oppose" if labels[firm[2]] != "oppose" else "support"
|
||
none_cell = next(k for k, v in sorted(labels.items()) if v == "none")
|
||
rows = [
|
||
S(*firm[0], labels[firm[0]]),
|
||
S(*firm[1], labels[firm[1]]),
|
||
S(*firm[2], wrong),
|
||
S(*none_cell, "support"),
|
||
]
|
||
sc = score(golden, rows)
|
||
rec = eval_record(golden, sc)
|
||
assert list(rec) == [
|
||
"cell",
|
||
"recall",
|
||
"direction",
|
||
"n_items",
|
||
"n_positions",
|
||
"family",
|
||
"docket",
|
||
"in_sample",
|
||
]
|
||
n = len(firm)
|
||
assert rec["recall"] == round(3 / n, 3) and rec["direction"] == round(2 / 3, 3)
|
||
assert rec["cell"] == round(sc.cell_agreement, 3)
|
||
assert (rec["n_items"], rec["n_positions"]) == (len(golden["items"]), n)
|
||
assert (rec["family"], rec["docket"]) == ("HOME-VISITS", "CMS-2022-0113")
|
||
assert rec["in_sample"] is True
|
||
# a held-out set says so in its own file
|
||
held = eval_record({**golden, "in_sample": False}, sc)
|
||
assert held["in_sample"] is False
|
||
|
||
|
||
# ── relevance filter (R65) ───────────────────────────────────────────
|
||
|
||
SUBJ_EVENTS = [
|
||
EventChoice(f"HOME-VISITS:2023:{s}", s, 2023)
|
||
for s in ("valuation", "coding", "telehealth", "G0318", "other")
|
||
]
|
||
|
||
|
||
def test_event_subjects_by_slug():
|
||
from pfs.stance import event_subjects
|
||
|
||
sub = event_subjects(FAM, SUBJ_EVENTS)
|
||
v, c, t, g, o = (sub[e.event_key] for e in SUBJ_EVENTS)
|
||
# R67: the event's subject keyword AND this family
|
||
assert v.named_in("the work RVU of 99342 is low")
|
||
assert v.named_in("payment for 99341-99343 falls") # a range names 99342
|
||
assert not v.named_in("the work RVU is low") # no family mention
|
||
assert not v.named_in("see 99341-99343") # no subject keyword
|
||
# R67a: valuation's family mention may be a pair phrase
|
||
assert v.named_in("home visit codes are undervalued")
|
||
assert v.named_in("the RVU of GXXX3") # or an offered link's alias
|
||
assert not v.named_in("nothing relevant at all")
|
||
assert c.named_in("codes 99339-99341 were deleted")
|
||
assert c.named_in("domiciliary care visit codes merge") # keyword and phrase
|
||
assert c.named_in("MDM for home or residence visits") and not c.named_in("MDM")
|
||
assert not c.named_in("codes were deleted") # no family mention
|
||
assert c.named_in("GXXX3 time counts") # the link alias names the family
|
||
# R67a: a family code is a coding subject too; coding|revised|descriptor are
|
||
# coding keywords
|
||
assert c.named_in("see 99342") and c.named_in("see 99341-99343")
|
||
assert not c.named_in("see GXXX3") # a link code is not a coding subject
|
||
assert c.named_in("these coding changes for home visit codes")
|
||
assert c.named_in("the revised home or residence visit descriptors")
|
||
assert c.named_in("descriptor of the home visit codes")
|
||
assert not c.named_in("these coding changes help")
|
||
assert t.named_in("audio-only home visit codes") and t.named_in("telehealth 99341")
|
||
assert t.named_in("telehealth for GXXX3")
|
||
assert not t.named_in("we support telehealth after the PHE")
|
||
assert not t.named_in("99341 and RVUs")
|
||
assert g.named_in("the new GXXX3 code") and g.named_in("G0318 is new")
|
||
assert g.named_in("prolonged home or residence visit time")
|
||
assert not g.named_in("prolonged visits") and not g.named_in("telehealth only")
|
||
assert not g.named_in("prolonged 99417 for office visits")
|
||
assert o.named_in("code 99342 is fine") and o.named_in("a home visit e/m change")
|
||
assert not o.named_in("telehealth and RVUs") and not o.named_in("G0318 only")
|
||
# no pair phrases and an unknown link: still well-formed; the link event's
|
||
# own code names the family
|
||
bare = dataclasses.replace(FAM, pair_phrases=())
|
||
other = event_subjects(
|
||
bare, [SUBJ_EVENTS[4], EventChoice("F:2023:G9999", "x", 2023)]
|
||
)
|
||
assert not other["HOME-VISITS:2023:other"].named_in("home visit")
|
||
assert other["F:2023:G9999"].named_in("prolonged G9999")
|
||
assert not other["F:2023:G9999"].named_in("prolonged")
|
||
# a link not offered that year does not name the family
|
||
no_link = event_subjects(FAM, SUBJ_EVENTS[2:3])
|
||
assert not no_link["HOME-VISITS:2023:telehealth"].named_in("telehealth G0318")
|
||
|
||
|
||
def test_window_must_name_this_family_and_the_subject():
|
||
"""R67, from the CY2023 live rows: a general telehealth endorsement is
|
||
dropped; a valuation complaint that names only "home visit codes" is kept
|
||
(R67a); a telehealth ask about the family's codes one sentence away is
|
||
kept."""
|
||
from pfs.stance import event_subjects
|
||
|
||
sub = event_subjects(FAM, SUBJ_EVENTS)
|
||
val, tele = SUBJ_EVENTS[0].event_key, SUBJ_EVENTS[2].event_key
|
||
general = (
|
||
"This is a significant hit to our ability to provide primary care. "
|
||
"We support CMS efforts to expand telehealth services and encourage these "
|
||
"efforts continue after the end of the PHE. Many patients can now get care."
|
||
)
|
||
q = "We support CMS efforts to expand telehealth services and encourage these"
|
||
assert _rows([pos(tele, "support", "r", q)], general, sub) == []
|
||
phrase_only = (
|
||
"We support the nomination of misvalued home visit codes. By combining these "
|
||
"two code sets and not increasing the value of the home visit codes our "
|
||
"practices would incur a seven percent decrease in revenue next year."
|
||
)
|
||
q2 = "By combining these two code sets and not increasing the value"
|
||
kept2 = _rows([pos(val, "oppose", "r", q2)], phrase_only, sub)
|
||
assert [(r.event_key, r.stance) for r in kept2] == [(val, "oppose")]
|
||
ask = (
|
||
"Telehealth flexibilities matter to our patients. We recommend that all "
|
||
"codes listed above be retained through the end of the year. This includes "
|
||
"the home visit codes 99347-99350 for established patients."
|
||
)
|
||
q3 = "We recommend that all codes listed above be retained through the end"
|
||
kept = _rows([pos(tele, "modify", "r", q3)], ask, sub)
|
||
assert [(r.event_key, r.stance) for r in kept] == [(tele, "modify")]
|
||
|
||
|
||
#: CY2023 comment KUAPZ8ER (BJC HealthCare), as posted on CMS-2022-0113
|
||
KUAPZ8ER_CODING = (
|
||
"Changes to beneficiary attribution protocols due to revised coding policies "
|
||
"CMS proposes updates to beneficiary attribution criteria, including updating "
|
||
"the roster of primary care service CPTs to include new prolonged services codes "
|
||
"for SNF and \u201chome\u201d settings (Gxxx2 and Gxxx3), chronic pain management "
|
||
"(Gyyy1 and Gyyy2) and revising billing protocols for those \u201chome\u201d services "
|
||
"(99341\u201499350) to identify and include primary care services provided in "
|
||
"assisted living facilities, group homes, custodial care facilities, and "
|
||
"residential substance use treatment facilities. BJC strongly supports this "
|
||
"proposal both because it will likely increase our assigned beneficiary "
|
||
"population, but also because these coding changes will help bring transparency "
|
||
"to the services provided in those settings. Under current coding and billing "
|
||
"conventions, \u201chome\u201d services are all lumped together and thus difficult to"
|
||
)
|
||
#: CY2023 comment 8U6AFHTJ (AMA RUC), as posted on CMS-2022-0113
|
||
RUC_TEMPLATE = (
|
||
"VI. Evaluation and Management (E/M) Visits [FR section II.F.] The RUC commends "
|
||
"the Agency for accepting the work RVU recommendations for the hospital inpatient "
|
||
"or observation codes, nursing facility codes, home or residence visit codes, "
|
||
"emergency department visits and prolonged services codes. This was the result "
|
||
"of significant collaboration by an AMA-convened workgroup that brought together "
|
||
"more than 30 specialty societies involved in surveying these services."
|
||
)
|
||
|
||
|
||
def test_r67a_coding_by_family_code_and_valuation_by_phrase():
|
||
"""R67a, real CY2023 text: KUAPZ8ER's coding support names 99341\u201499350
|
||
and "coding changes" (no R65 coding keyword); the RUC's valuation support
|
||
names the family only as "home or residence visit codes". Both are kept."""
|
||
from pfs.stance import event_subjects
|
||
|
||
sub = event_subjects(FAM, SUBJ_EVENTS)
|
||
val, coding = SUBJ_EVENTS[0].event_key, SUBJ_EVENTS[1].event_key
|
||
q = (
|
||
"BJC strongly supports this proposal both because it will likely increase "
|
||
"our assigned beneficiary population"
|
||
)
|
||
kept = _rows([pos(coding, "support", "r", q)], KUAPZ8ER_CODING, sub)
|
||
assert [(r.event_key, r.stance) for r in kept] == [(coding, "support")]
|
||
# the window, not only the quote, carries the family code and the keyword
|
||
assert not sub[coding].named_in(q)
|
||
q2 = (
|
||
"The RUC commends the Agency for accepting the work RVU recommendations for "
|
||
"the hospital inpatient or observation codes, nursing facility codes, home or "
|
||
"residence visit codes"
|
||
)
|
||
kept2 = _rows([pos(val, "support", "r", q2)], RUC_TEMPLATE, sub)
|
||
assert [(r.event_key, r.stance) for r in kept2] == [(val, "support")]
|
||
# the same sentence without the family names no family
|
||
other_fam = RUC_TEMPLATE.replace("home or residence visit codes, ", "")
|
||
q3 = q2.replace(", home or residence visit codes", "")
|
||
assert _rows([pos(val, "support", "r", q3)], other_fam, sub) == []
|
||
|
||
|
||
def test_holding_sentence():
|
||
from pfs.stance import _holding_sentence
|
||
|
||
text = "One is here. Two is here. Three is here. Four is here. Five is here."
|
||
assert _holding_sentence(text, "Three is here.", 0) == " Three is here."
|
||
assert (
|
||
_holding_sentence(text, "Three is here.", 1)
|
||
== " Two is here. Three is here. Four is here."
|
||
)
|
||
assert _holding_sentence(text, "One is here.", 1) == "One is here. Two is here."
|
||
assert _holding_sentence(text, "Five is here.", 1) == " Four is here. Five is here."
|
||
assert _holding_sentence(text, "absent words", 1) == "absent words"
|
||
assert _holding_sentence("no end", "no end", 1) == "no end"
|
||
|
||
|
||
def _rows(raw, text, subjects):
|
||
return position_rows(
|
||
raw,
|
||
text,
|
||
family="F",
|
||
item_key="K",
|
||
docket="D",
|
||
extractor="x",
|
||
subjects=subjects,
|
||
)
|
||
|
||
|
||
def test_position_kept_only_if_quote_or_neighbours_name_the_subject():
|
||
from pfs.stance import event_subjects
|
||
|
||
sub = event_subjects(FAM, SUBJ_EVENTS)
|
||
tele, val = SUBJ_EVENTS[2].event_key, SUBJ_EVENTS[0].event_key
|
||
text = (
|
||
"Telehealth flexibilities for home visit codes matter to our patients. We "
|
||
"recommend that all codes listed above should be retained through the end of "
|
||
"the year. Unrelated closing words about something else entirely follow in "
|
||
"this sentence here."
|
||
)
|
||
q = "We recommend that all codes listed above should be retained through the end"
|
||
kept = _rows([pos(tele, "modify", "r", q)], text, sub)
|
||
assert [r.event_key for r in kept] == [tele] # the previous sentence names it
|
||
assert _rows([pos(val, "modify", "r", q)], text, sub) == [] # not about RVUs
|
||
assert len(_rows([pos(val, "modify", "r", q)], text, None)) == 1 # no filter
|
||
# an irrelevant first position does not block a later one on the same event
|
||
two = _rows([pos(val, "modify", "r", q), pos(tele, "support", "r", q)], text, sub)
|
||
assert [(r.event_key, r.stance) for r in two] == [(tele, "support")]
|
||
|
||
|
||
def test_stances_for_applies_subjects(tmp_path, events):
|
||
from pfs.stance import event_subjects
|
||
|
||
ask = Ask([pos(events[0].event_key, "support", "r", EV_VAL)])
|
||
sub = event_subjects(FAM, events) # valuation: names RVU and 99342
|
||
(rows, _), _ = _run(
|
||
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, subjects=sub
|
||
)
|
||
assert len(rows) == 1
|
||
off = {events[0].event_key: Subject(re.compile("zzzz"))}
|
||
(rows, _), _ = _run(
|
||
tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events, subjects=off
|
||
)
|
||
assert rows == [] # same ledger entry, replayed through a different filter
|
||
assert len(ask.calls) == 1
|
||
|
||
|
||
# ── stance-v5: guideline facts, coding event rule, link kinds (R69, R74) ──
|
||
|
||
|
||
def _gf(code, year, fact, value, loc="p. 9"):
|
||
from pfs.codetables import GuidelineFactRow
|
||
|
||
return GuidelineFactRow(
|
||
"HOME-VISITS", code, year, fact, value, "B", loc, "cpt-book"
|
||
)
|
||
|
||
|
||
V5_FACTS = [
|
||
_gf("99341", 2022, "selection_basis", "history_exam_mdm"),
|
||
_gf("99341", 2024, "selection_basis", "mdm_or_time"),
|
||
_gf("99341", 2024, "time_threshold_min", "15"),
|
||
_gf("99343", 2024, "deleted_in_edition", "2022"),
|
||
_gf("99341-99350", 2024, "scope_point", "Care is at the residence.", "p. 28"),
|
||
]
|
||
|
||
|
||
def test_generations_and_versions():
|
||
from pfs.stance import GENERATIONS, guideline_text, prompt_version
|
||
|
||
assert GENERATIONS == ("stance-v4", "stance-v5", "stance-v6")
|
||
assert prompt_version("multi", "stance-v5") == "stance-v5"
|
||
assert prompt_version("per-event", "stance-v5") == "stance-v5-per-event"
|
||
assert prompt_version("multi", "stance-v4") == "stance-v4"
|
||
assert guideline_text("stance-v5") and not guideline_text("stance-v4")
|
||
|
||
|
||
def test_v5_coding_event_states_cpt_facts(con):
|
||
from pfs.codetables import write_guideline_facts
|
||
|
||
write_guideline_facts(con, "HOME-VISITS", V5_FACTS)
|
||
ev = docket_events(con, FAM, 2023, guidelines=True)
|
||
coding = _by_slug(ev, "coding")
|
||
assert "CPT books on file, 2022 and 2024" in coding
|
||
assert (
|
||
"99341 is selected by level of medical decision making or total time" in coding
|
||
)
|
||
assert "total-time thresholds 99341 15 minutes" in coding
|
||
assert "Care is at the residence. (CPT 2024, p. 28)" in coding
|
||
# v4 behaviour is unchanged: no facts text
|
||
assert "CPT books" not in " ".join(
|
||
e.text for e in docket_events(con, FAM, 2023, guidelines=False)
|
||
)
|
||
|
||
|
||
def test_v5_coding_event_needs_facts_not_keywords(con):
|
||
s, _ = _store(
|
||
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{1: "New guideline for 99341 visits."},
|
||
)
|
||
# keywords alone: v4 makes an event, v5 does not
|
||
assert [
|
||
e.event_key.rsplit(":", 1)[-1]
|
||
for e in docket_events(con, FAM, 2030, s, guidelines=True)
|
||
] == ["other"]
|
||
assert docket_events(con, FAM, 2030, s, guidelines=False)[0].event_key.endswith(
|
||
":coding"
|
||
)
|
||
# a lineage event that year makes one, and cites the paragraph too
|
||
write_events(con, "99342", [_event("99342", 2030, "created")])
|
||
ev = docket_events(con, FAM, 2030, s, guidelines=True)
|
||
assert ev[0].event_key.endswith(":coding")
|
||
assert "1 created" in ev[0].text and "[¶1]" in ev[0].text
|
||
s.close()
|
||
|
||
|
||
def test_v5_coding_event_from_facts_alone(con):
|
||
from pfs.codetables import write_guideline_facts
|
||
|
||
write_guideline_facts(con, "HOME-VISITS", V5_FACTS[:2])
|
||
ev = docket_events(con, FAM, 2023, guidelines=True)
|
||
assert ev[0].event_key.endswith(":coding")
|
||
assert "CPT books on file, 2022 and 2024" in ev[0].text
|
||
|
||
|
||
def test_facts_table_missing_is_tolerated(con):
|
||
con.execute("DROP TABLE pfs.code_guideline_fact")
|
||
got = [
|
||
e.event_key.rsplit(":", 1)[-1]
|
||
for e in docket_events(con, FAM, 2030, guidelines=True)
|
||
]
|
||
assert got == ["other"]
|
||
|
||
|
||
def test_missing_facts_error_other_than_table_propagates(con, monkeypatch):
|
||
import pfs.stance as st
|
||
|
||
def boom(c, f):
|
||
raise RuntimeError("disk gone")
|
||
|
||
monkeypatch.setattr(st, "read_guideline_facts", boom)
|
||
with pytest.raises(RuntimeError):
|
||
docket_events(con, FAM, 2023, guidelines=True)
|
||
|
||
|
||
def test_link_relevance_words_depend_on_kind():
|
||
from pfs.stance import event_subjects
|
||
|
||
fam = dataclasses.replace(
|
||
FAM,
|
||
links=tuple(ln for ln in FAM.links if ln.target in ("G2211", "B1", "G0318")),
|
||
)
|
||
evs = [
|
||
EventChoice(f"HOME-VISITS:2026:{s}", s, 2026) for s in ("G2211", "B1", "G0318")
|
||
]
|
||
sub = event_subjects(fam, evs, by_kind=True)
|
||
g2211, b1, g0318 = (sub[e.event_key] for e in evs)
|
||
assert g2211.named_in("G2211 applies to home or residence visits")
|
||
assert g2211.named_in("the add-on for home or residence visits")
|
||
assert g2211.named_in("longitudinal home visit codes")
|
||
assert g2211.named_in("visit complexity in the home visit codes")
|
||
assert not g2211.named_in("prolonged home or residence visit time")
|
||
assert b1.named_in("the modifier for home or residence visits")
|
||
assert b1.named_in("B1 for 99341")
|
||
assert not b1.named_in("prolonged home or residence visit time")
|
||
assert g0318.named_in("prolonged home or residence visit time")
|
||
assert not g0318.named_in("the add-on for home or residence visits")
|
||
# v4 used "prolonged" for every link
|
||
old = event_subjects(fam, evs, by_kind=False)
|
||
assert old[evs[0].event_key].named_in("prolonged home or residence visit time")
|
||
# the shipped default follows the shipped generation
|
||
from pfs.stance import guideline_text
|
||
|
||
dflt = event_subjects(fam, evs)
|
||
assert (
|
||
dflt[evs[0].event_key].named_in("prolonged home visit codes")
|
||
!= guideline_text()
|
||
)
|
||
|
||
|
||
# ── stance-v6: telehealth needs lineage, paragraph order, sentence cuts (R78) ──
|
||
|
||
|
||
def test_v6_generation_flags():
|
||
from pfs.stance import fixed_text, guideline_text
|
||
|
||
assert guideline_text("stance-v6") and guideline_text("stance-v5")
|
||
assert fixed_text("stance-v6") and not fixed_text("stance-v5")
|
||
assert not fixed_text("stance-v4")
|
||
|
||
|
||
def test_v6_telehealth_event_needs_lineage_not_a_paragraph(con):
|
||
s, _ = _store(
|
||
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{1: "We add 99341 as a base code billed with GADV1 to the telehealth list."},
|
||
)
|
||
slugs = lambda evs: [e.event_key.rsplit(":", 1)[-1] for e in evs] # noqa: E731
|
||
assert "telehealth" not in slugs(
|
||
docket_events(con, FAM, 2030, s, guidelines=True, fixed=True)
|
||
)
|
||
assert "telehealth" in slugs(
|
||
docket_events(con, FAM, 2030, s, guidelines=False, fixed=False)
|
||
)
|
||
# a lineage telehealth_list event for a family code makes one
|
||
write_events(con, "99342", [_event("99342", 2030, "telehealth_list")])
|
||
ev = docket_events(con, FAM, 2030, s, guidelines=True, fixed=True)
|
||
assert "(lineage: telehealth list change for 99342)" in _by_slug(ev, "telehealth")
|
||
s.close()
|
||
|
||
|
||
def test_v6_telehealth_event_for_a_link_offered_that_year(con):
|
||
write_events(con, "G0318", [_event("G0318", 2023, "telehealth_list")])
|
||
ev = docket_events(con, FAM, 2023, guidelines=True, fixed=True)
|
||
assert "telehealth" in [e.event_key.rsplit(":", 1)[-1] for e in ev]
|
||
# a link not offered that year does not count
|
||
write_events(con, "G2211", [_event("G2211", 2023, "telehealth_list")])
|
||
con.execute("DELETE FROM pfs.code_event WHERE code = 'G0318'")
|
||
ev = docket_events(con, FAM, 2023, guidelines=True, fixed=True)
|
||
assert "telehealth" not in [e.event_key.rsplit(":", 1)[-1] for e in ev]
|
||
|
||
|
||
LONG_A = (
|
||
"Zed is new for 99341. "
|
||
+ "Filler words continue here. " * 12
|
||
+ "The guideline for 99341 changed."
|
||
)
|
||
|
||
|
||
def test_v6_paragraphs_in_pid_order_cut_at_sentences(con):
|
||
long_p = (
|
||
"The guideline for 99342 changed today. "
|
||
+ "Quux filler sentence goes on. " * 30
|
||
)
|
||
s, _ = _store(
|
||
"CY 2030 Payment Policies Under the Physician Fee Schedule; Proposed Rule",
|
||
{
|
||
5: "The guideline for 99341 and the guideline for 99341 changed again.",
|
||
9: "Deleted guideline for 99343 deleted.",
|
||
12: long_p,
|
||
},
|
||
)
|
||
write_events(con, "99342", [_event("99342", 2030, "created")])
|
||
coding = _by_slug(
|
||
docket_events(con, FAM, 2030, s, guidelines=True, fixed=True), "coding"
|
||
)
|
||
assert coding.index("[¶5]") < coding.index("[¶9]") # p_id order, not density
|
||
assert "[¶12]" not in coding # only two paragraphs
|
||
old = _by_slug(
|
||
docket_events(con, FAM, 2030, s, guidelines=False, fixed=False), "coding"
|
||
)
|
||
assert old.index("[¶9]") < old.index("[¶5]") # v4: densest first
|
||
s.close()
|
||
|
||
|
||
def test_excerpt_sentences_never_cut_mid_sentence():
|
||
from pfs.stance import _CODING_WORDS, _excerpt
|
||
|
||
text = "First sentence about the guideline. " + "Plain filler sentence here. " * 30
|
||
out = _excerpt(text, _CODING_WORDS, sentences=True)
|
||
assert out.endswith(".") and len(out) <= 600
|
||
assert out.startswith("First sentence about the guideline.")
|
||
# the run with the most hits wins
|
||
text2 = (
|
||
"Plain filler sentence here. " * 25
|
||
+ "The guideline guideline guideline text ends here."
|
||
)
|
||
assert _excerpt(text2, _CODING_WORDS, sentences=True).endswith("text ends here.")
|
||
# a short paragraph is whole
|
||
assert _excerpt("Short one.", _CODING_WORDS, sentences=True) == "Short one."
|
||
# a single sentence longer than the limit is cut at a word
|
||
one = "guideline " * 100
|
||
assert len(_excerpt(one, _CODING_WORDS, sentences=True)) <= 600
|
||
|
||
|
||
# ── multi-docket golden files (R78d) ─────────────────────────────────
|
||
|
||
|
||
def test_golden_dockets_single_and_multi():
|
||
from pfs.stance import golden_dockets, item_dockets
|
||
|
||
single = {"family": "F", "docket": "D1", "items": [{"item_key": "K1", "cells": {}}]}
|
||
assert golden_dockets(single) == ["D1"] and item_dockets(single) == {"K1": "D1"}
|
||
multi = {
|
||
"family": "F",
|
||
"dockets": ["D1", "D2"],
|
||
"items": [
|
||
{"item_key": "K1", "docket": "D1"},
|
||
{"item_key": "K2", "docket": "D2"},
|
||
],
|
||
}
|
||
assert golden_dockets(multi) == ["D1", "D2"]
|
||
assert item_dockets(multi) == {"K1": "D1", "K2": "D2"}
|
||
bad = {**multi, "items": [{"item_key": "K3"}]}
|
||
with pytest.raises(ValueError, match="K3"):
|
||
item_dockets(bad)
|
||
bad = {**multi, "items": [{"item_key": "K3", "docket": "D9"}]}
|
||
with pytest.raises(ValueError, match="D9"):
|
||
item_dockets(bad)
|
||
|
||
|
||
def test_eval_record_names_every_docket():
|
||
from pfs.stance import eval_record, score
|
||
|
||
golden = {
|
||
"family": "F",
|
||
"dockets": ["D1", "D2"],
|
||
"items": [
|
||
{
|
||
"item_key": "K1",
|
||
"docket": "D1",
|
||
"cells": {"F:2023:x": {"label": "support"}},
|
||
},
|
||
{
|
||
"item_key": "K2",
|
||
"docket": "D2",
|
||
"cells": {"F:2026:x": {"label": "oppose"}},
|
||
},
|
||
],
|
||
}
|
||
rows = [
|
||
StanceRow("F", "K1", "D1", "F:2023:x", "support", "", "", "m"),
|
||
StanceRow("F", "K2", "D2", "F:2026:x", "oppose", "", "", "m"),
|
||
]
|
||
rec = eval_record(golden, score(golden, rows))
|
||
assert rec["dockets"] == ["D1", "D2"] and rec["docket"] == "D1; D2"
|
||
assert (rec["recall"], rec["direction"], rec["n_items"]) == (1.0, 1.0, 2)
|