feat(notebooks): prior-year palliative comment exhibit — counts, table, decade themes (refs #660)
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
# Palliative Comment Tagging + Notebook Summary Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Tag every prior-year PFS-rulemaking comment that references palliative care (`topic:palliative` in bib), make them citable (scoped Zotero sync), and add a summary section to `notebooks/palliative_care_rfi.py` (issue #660, P43).
|
||||
|
||||
**Architecture:** A lexical sweep script walks all regulations.gov comment items in the bib store (166,941 across ten dockets CMS-2017-0092…CMS-2026-2377); per item the text is the extracted `combined.md` body under `.state/comments/<docket>/<comment_id>/` when present, else the enriched abstract. Matches on the `palliativ` stem get the `topic:palliative` tag, mention count + sentence snippets in `extra_json`, and per-docket denominators land in `bib_meta` (the notebook container mounts `data/` but not `.state/`, so everything the notebook renders must be materialized into `bib.sqlite` at sweep time). The notebook reads bib.sqlite live via `conf.connect.bib()`.
|
||||
|
||||
**Tech Stack:** Python stdlib `re`/`json`; `bib.store.Store` (`add_tag`, `remove_tag`, `update`); `rex.comments.combine.parse_combined`; marimo + polars + altair for the notebook section.
|
||||
|
||||
**Spec:** Gitea issue #660 (https://git.fhirworx.io/homelab/stack/issues/660).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- The `llm` module and everything touching the comment corpus is local-only — no cloud LLM calls anywhere in this work.
|
||||
- Notebook rules: never mention skin substitutes in `palliative_care_rfi.py`; cast year columns through `conf.display.plain_years` (marimo renders int years as "2,027"); FR/CFR cites go through `bib.frlink`/`bib.cfrlink` (regulations.gov comment links are plain URLs, not FR cites).
|
||||
- `stack bib sync-zotero` must always be scoped `--tag topic:palliative` (unscoped sync is the P38 incident vector).
|
||||
- Never `git stash` in this worktree (container-owned `data/zotero/**` strands the stash). Stage explicit paths only; run `git status --short` before every commit; ruff-format before committing; commits with the pre-commit hook need background/600s timeouts.
|
||||
- No Claude co-author trailer on commits.
|
||||
- The sweep must be idempotent: re-running refreshes tags/extras and removes `topic:palliative` from items that no longer match.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Sweep script with tested mention extraction
|
||||
|
||||
**Files:**
|
||||
- Create: `dev/scripts/tag_palliative_comments.py`
|
||||
- Test: `tests/dev/test_tag_palliative_comments.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `bib.store.Store` (`add_tag(item_key, tag)`, `remove_tag(item_key, tag)`, `update(key, extra_json=...)`, `_con()`), `rex.comments.combine.parse_combined(text) -> (header, body)`.
|
||||
- Produces: script CLI `uv run python dev/scripts/tag_palliative_comments.py [--root PATH] [--db PATH] [--dry-run]`; pure helpers `find_mentions(text, max_snippets=3) -> tuple[int, list[str]]` and `sweep(store, root, dry_run=False) -> dict`; bib side effects: tag `topic:palliative`, `extra_json` keys `palliative_mentions` (int) and `palliative_snippets` (list[str]), `bib_meta` key `palliative_sweep` = JSON `{"dockets": {docket: {"total": int, "with_text": int, "hits": int}}, "swept_at": iso8601}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
```python
|
||||
"""Tests for dev/scripts/tag_palliative_comments.py — mention extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "dev"
|
||||
/ "scripts"
|
||||
/ "tag_palliative_comments.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("_tag_palliative", _SCRIPT)
|
||||
assert _spec and _spec.loader
|
||||
tpc = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["_tag_palliative"] = tpc
|
||||
_spec.loader.exec_module(tpc)
|
||||
|
||||
|
||||
class TestFindMentions:
|
||||
def test_no_match(self):
|
||||
assert tpc.find_mentions("Medicare payment policy comment.") == (0, [])
|
||||
|
||||
def test_counts_and_case(self):
|
||||
text = (
|
||||
"We support Palliative care. Our palliative program grew. "
|
||||
"Palliative-trained nurses matter."
|
||||
)
|
||||
count, snippets = tpc.find_mentions(text)
|
||||
assert count == 3
|
||||
assert len(snippets) >= 1
|
||||
assert all("alliativ" in s for s in snippets)
|
||||
|
||||
def test_snippets_are_sentences_with_context(self):
|
||||
text = (
|
||||
"Intro sentence. CMS should pay for palliative consultations "
|
||||
"in the home. Closing sentence."
|
||||
)
|
||||
_, snippets = tpc.find_mentions(text)
|
||||
assert snippets == [
|
||||
"CMS should pay for palliative consultations in the home."
|
||||
]
|
||||
|
||||
def test_snippet_cap(self):
|
||||
text = " ".join(f"Point {i} on palliative care." for i in range(10))
|
||||
count, snippets = tpc.find_mentions(text, max_snippets=3)
|
||||
assert count == 10
|
||||
assert len(snippets) == 3
|
||||
|
||||
def test_long_sentence_trimmed(self):
|
||||
text = "x" * 400 + " palliative " + "y" * 400
|
||||
_, snippets = tpc.find_mentions(text)
|
||||
assert snippets and len(snippets[0]) <= 320
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `uv run pytest tests/dev/test_tag_palliative_comments.py -v`
|
||||
Expected: FAIL — script file does not exist.
|
||||
|
||||
- [ ] **Step 3: Write the script**
|
||||
|
||||
`dev/scripts/tag_palliative_comments.py`:
|
||||
|
||||
```python
|
||||
"""Tag PFS-rulemaking comments that reference palliative care (#660).
|
||||
|
||||
Sweeps every regulations.gov comment item in the bib store. Per item the
|
||||
text is the extracted body (`.state/comments/<docket>/<comment_id>/combined.md`)
|
||||
when present, else the enriched abstract. Items matching the ``palliativ``
|
||||
stem get:
|
||||
|
||||
- tag ``topic:palliative``
|
||||
- ``extra_json`` keys ``palliative_mentions`` / ``palliative_snippets``
|
||||
|
||||
Per-docket sweep stats (total / with_text / hits) land in ``bib_meta``
|
||||
under key ``palliative_sweep`` so the notebook can disclose denominators
|
||||
without access to ``.state``. Idempotent: re-running refreshes extras and
|
||||
removes the tag from items that no longer match.
|
||||
|
||||
Usage:
|
||||
uv run python dev/scripts/tag_palliative_comments.py [--dry-run]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_PATTERN = re.compile(r"palliativ\w*", re.IGNORECASE)
|
||||
_COMMENT_URL_PREFIX = "https://www.regulations.gov/comment/"
|
||||
_TAG = "topic:palliative"
|
||||
_META_KEY = "palliative_sweep"
|
||||
_SNIPPET_MAX_CHARS = 320
|
||||
|
||||
|
||||
def find_mentions(text: str, max_snippets: int = 3) -> tuple[int, list[str]]:
|
||||
"""Count ``palliativ*`` matches and pull up to *max_snippets* sentences."""
|
||||
matches = list(_PATTERN.finditer(text))
|
||||
if not matches:
|
||||
return 0, []
|
||||
snippets: list[str] = []
|
||||
seen_spans: list[tuple[int, int]] = []
|
||||
for m in matches:
|
||||
if len(snippets) >= max_snippets:
|
||||
break
|
||||
# sentence-ish window: back to the previous ./!/?/newline, forward to the next
|
||||
start = max(
|
||||
text.rfind(". ", 0, m.start()),
|
||||
text.rfind("! ", 0, m.start()),
|
||||
text.rfind("? ", 0, m.start()),
|
||||
text.rfind("\n", 0, m.start()),
|
||||
)
|
||||
start = start + 2 if start > 0 else 0
|
||||
ends = [
|
||||
text.find(". ", m.end()),
|
||||
text.find("! ", m.end()),
|
||||
text.find("? ", m.end()),
|
||||
text.find("\n", m.end()),
|
||||
]
|
||||
ends = [e for e in ends if e != -1]
|
||||
end = min(ends) + 1 if ends else len(text)
|
||||
if any(s <= m.start() < e for s, e in seen_spans):
|
||||
continue # sentence already captured
|
||||
seen_spans.append((start, end))
|
||||
snippet = " ".join(text[start:end].split())
|
||||
if len(snippet) > _SNIPPET_MAX_CHARS:
|
||||
cut = snippet.lower().find("palliativ")
|
||||
lo = max(0, cut - _SNIPPET_MAX_CHARS // 2)
|
||||
snippet = ("…" if lo else "") + snippet[lo : lo + _SNIPPET_MAX_CHARS - 2] + "…"
|
||||
snippets.append(snippet)
|
||||
return len(matches), snippets
|
||||
|
||||
|
||||
def _comment_rows(store) -> list[tuple[str, str, str, str]]:
|
||||
"""(key, url, docket, comment_id) for every comment item."""
|
||||
rows = (
|
||||
store._con()
|
||||
.execute(
|
||||
"SELECT key, url FROM items WHERE url LIKE ?",
|
||||
(f"{_COMMENT_URL_PREFIX}%",),
|
||||
)
|
||||
.fetchall()
|
||||
)
|
||||
out = []
|
||||
for key, url in rows:
|
||||
comment_id = url[len(_COMMENT_URL_PREFIX) :]
|
||||
docket = comment_id.rsplit("-", 1)[0]
|
||||
out.append((key, url, docket, comment_id))
|
||||
return out
|
||||
|
||||
|
||||
def _merge_extra(store, key: str, mentions: int, snippets: list[str]) -> None:
|
||||
row = (
|
||||
store._con()
|
||||
.execute("SELECT extra_json FROM items WHERE key = ?", (key,))
|
||||
.fetchone()
|
||||
)
|
||||
extra = json.loads(row[0]) if row and row[0] else {}
|
||||
extra["palliative_mentions"] = mentions
|
||||
extra["palliative_snippets"] = snippets
|
||||
store.update(key, extra_json=json.dumps(extra))
|
||||
|
||||
|
||||
def _clear_extra(store, key: str) -> None:
|
||||
row = (
|
||||
store._con()
|
||||
.execute("SELECT extra_json FROM items WHERE key = ?", (key,))
|
||||
.fetchone()
|
||||
)
|
||||
if not (row and row[0]):
|
||||
return
|
||||
extra = json.loads(row[0])
|
||||
if "palliative_mentions" in extra or "palliative_snippets" in extra:
|
||||
extra.pop("palliative_mentions", None)
|
||||
extra.pop("palliative_snippets", None)
|
||||
store.update(key, extra_json=json.dumps(extra))
|
||||
|
||||
|
||||
def sweep(store, root: Path, dry_run: bool = False) -> dict:
|
||||
from rex.comments.combine import parse_combined
|
||||
|
||||
tagged_before = {
|
||||
r[0]
|
||||
for r in store._con().execute(
|
||||
"SELECT i.key FROM items i JOIN item_tags it ON it.item_id = i.id "
|
||||
"JOIN tags t ON t.id = it.tag_id WHERE t.name = ?",
|
||||
(_TAG,),
|
||||
)
|
||||
}
|
||||
stats: dict[str, dict[str, int]] = defaultdict(
|
||||
lambda: {"total": 0, "with_text": 0, "hits": 0}
|
||||
)
|
||||
hits: set[str] = set()
|
||||
for key, _url, docket, comment_id in _comment_rows(store):
|
||||
stats[docket]["total"] += 1
|
||||
combined = root / docket / comment_id / "combined.md"
|
||||
text = ""
|
||||
if combined.exists():
|
||||
_, body = parse_combined(combined.read_text())
|
||||
text = body
|
||||
if not text.strip():
|
||||
text = store.get(key).abstract or ""
|
||||
if not text.strip():
|
||||
continue
|
||||
stats[docket]["with_text"] += 1
|
||||
count, snippets = find_mentions(text)
|
||||
if not count:
|
||||
continue
|
||||
stats[docket]["hits"] += 1
|
||||
hits.add(key)
|
||||
if not dry_run:
|
||||
store.add_tag(key, _TAG)
|
||||
_merge_extra(store, key, count, snippets)
|
||||
|
||||
stale = tagged_before - hits
|
||||
if not dry_run:
|
||||
for key in stale:
|
||||
store.remove_tag(key, _TAG)
|
||||
_clear_extra(store, key)
|
||||
meta = {
|
||||
"dockets": dict(stats),
|
||||
"swept_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
store._con().execute(
|
||||
"INSERT INTO bib_meta (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(_META_KEY, json.dumps(meta)),
|
||||
)
|
||||
store._con().commit()
|
||||
return {"stats": dict(stats), "hits": len(hits), "untagged": len(stale)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", default=".state/comments", type=Path)
|
||||
parser.add_argument("--db", default="", help="bib.sqlite path (default: cfg)")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.db:
|
||||
from bib.store import Store
|
||||
|
||||
store = Store(args.db)
|
||||
else:
|
||||
from conf.connect import bib
|
||||
|
||||
store = bib()
|
||||
result = sweep(store, args.root, dry_run=args.dry_run)
|
||||
for docket in sorted(result["stats"]):
|
||||
s = result["stats"][docket]
|
||||
print(
|
||||
f" {docket}: {s['hits']} hits / {s['with_text']} with text "
|
||||
f"/ {s['total']} total"
|
||||
)
|
||||
print(f"tagged={result['hits']} untagged_stale={result['untagged']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
```
|
||||
|
||||
(Adjust `Store(args.db)` if the constructor signature differs — check `bib/store.py:49`. If `bib_meta` lacks a UNIQUE constraint on `key`, use `DELETE` + `INSERT` instead of upsert — check `PRAGMA index_list(bib_meta)` first.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `uv run pytest tests/dev/test_tag_palliative_comments.py -v`
|
||||
Expected: PASS. Iterate on `find_mentions` until the sentence-window tests pass exactly.
|
||||
|
||||
- [ ] **Step 5: Ruff + commit**
|
||||
|
||||
```bash
|
||||
uv run ruff format dev/scripts/tag_palliative_comments.py tests/dev/test_tag_palliative_comments.py && uv run ruff check dev/scripts/tag_palliative_comments.py tests/dev/test_tag_palliative_comments.py
|
||||
git status --short # foreign staged work check
|
||||
git add dev/scripts/tag_palliative_comments.py tests/dev/test_tag_palliative_comments.py
|
||||
git commit -m "feat(comments): palliative-mention sweep — topic:palliative tags + extra_json snippets (refs #660)"
|
||||
```
|
||||
|
||||
### Task 2: Run the sweep on the live store
|
||||
|
||||
**Files:** none (operational).
|
||||
|
||||
- [ ] **Step 1: Dry run** — `uv run python dev/scripts/tag_palliative_comments.py --dry-run`; expect ~270 hits spread over the ten dockets, heaviest CMS-2018-0076 (~71) and CMS-2023-0121 (~44). Investigate anything wildly off (e.g. 0 hits = root path wrong).
|
||||
- [ ] **Step 2: Real run** — same command without `--dry-run`.
|
||||
- [ ] **Step 3: Verify** —
|
||||
```bash
|
||||
uv run python -c "
|
||||
from conf.connect import bib
|
||||
store = bib(); con = store._con()
|
||||
print(con.execute(\"SELECT count(*) FROM items i JOIN item_tags it ON it.item_id=i.id JOIN tags t ON t.id=it.tag_id WHERE t.name='topic:palliative'\").fetchone())
|
||||
print(con.execute(\"SELECT value FROM bib_meta WHERE key='palliative_sweep'\").fetchone()[0][:200])
|
||||
"
|
||||
```
|
||||
Expected: count matches the run's `tagged=` output; meta JSON present.
|
||||
|
||||
### Task 3: Scoped Zotero sync
|
||||
|
||||
**Files:** none (operational).
|
||||
|
||||
- [ ] **Step 1:** Read `stack bib sync-zotero --help` and `src/cli/bib.py:472` to confirm flags; confirm whether it stops the zotero container itself or needs a manual stop (mail-pipeline memory says the nightly workflow stops zotero for the SQLite lock).
|
||||
- [ ] **Step 2:** Run `uv run stack bib sync-zotero --tag topic:palliative` (with the container held per Step 1's finding). NEVER run it unscoped.
|
||||
- [ ] **Step 3:** Verify in Zotero: `uv run python -c` query of `connect.zotero()` for items tagged `topic:palliative` — expect the same ~270.
|
||||
|
||||
### Task 4: Notebook section
|
||||
|
||||
**Files:**
|
||||
- Modify: `notebooks/palliative_care_rfi.py` (append a new section before the methods/appendix cells; match existing section-header style)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `conf.connect.bib()` (mounted `data/bib.sqlite`), tags `topic:palliative` / `reg-docket:*` / `year:*` / `rule:*`, `extra_json.palliative_mentions` / `.palliative_snippets`, `bib_meta.palliative_sweep`.
|
||||
- Produces: marimo cells — (a) intro md, (b) polars frame of tagged comments, (c) altair bar chart of hits per rule year with with-text denominators, (d) table of referencing comments (year, rule, commenter, mentions, regulations.gov link), (e) thematic summary prose with inline comment citations.
|
||||
|
||||
- [ ] **Step 1: Load the `dataviz` skill before writing the chart cell** (repo palette imports in the notebook — `fhirworx` AMBER/GRAY/INDIGO_MED/PLUM/TEAL — take precedence for colors).
|
||||
- [ ] **Step 2: Read the notebook's existing section/chart/table cell patterns** (`notebooks/palliative_care_rfi.py`) and mirror them exactly: same md header style, same altair theming, `plain_years` for year columns.
|
||||
- [ ] **Step 3: Write the cells.** Query shape:
|
||||
|
||||
```python
|
||||
_rows = connect.bib()._con().execute(
|
||||
"""
|
||||
SELECT i.key, i.title, i.url, i.institution, i.extra_json,
|
||||
MAX(CASE WHEN t.name LIKE 'year:%' THEN substr(t.name, 6) END) AS year,
|
||||
MAX(CASE WHEN t.name LIKE 'rule:%' THEN substr(t.name, 6) END) AS rule,
|
||||
MAX(CASE WHEN t.name LIKE 'reg-docket:%' THEN substr(t.name, 12) END) AS docket
|
||||
FROM items i
|
||||
JOIN item_tags it ON it.item_id = i.id
|
||||
JOIN tags t ON t.id = it.tag_id
|
||||
WHERE i.id IN (
|
||||
SELECT it2.item_id FROM item_tags it2
|
||||
JOIN tags t2 ON t2.id = it2.tag_id WHERE t2.name = 'topic:palliative'
|
||||
)
|
||||
GROUP BY i.id
|
||||
"""
|
||||
).fetchall()
|
||||
```
|
||||
|
||||
plus `bib_meta.palliative_sweep` for denominators. Chart: hits per rule year, with a `with_text` coverage line/label so the exhibit can't be read as "% of all comments". Table: sortable frame with markdown links `[comment_id](url)`. Summary prose: written from the actual snippets (read them during this task), citing 5–10 representative comments by linked comment id — themes to expect from the snippet scan: payment for interdisciplinary/home-based palliative care, ACP/care-management code adequacy, hospice-carve-out boundaries, workforce. The disclosure line must state the text-coverage denominator (~23.8k full texts + ~6.4k abstracts of 167k comments).
|
||||
- [ ] **Step 4: Verify headlessly** — run the single-notebook nb_integration pass inside the notebooks container (nightly pattern): write a one-notebook `nb-tests.toml` (`[ci_smoke] notebooks = ["palliative_care_rfi.py"]`), `docker cp` script+config into the `notebooks` container, `docker exec` it, and check the report JSON for zero errors.
|
||||
- [ ] **Step 5: Ruff + commit**
|
||||
|
||||
```bash
|
||||
uv run ruff format notebooks/palliative_care_rfi.py && uv run ruff check notebooks/palliative_care_rfi.py
|
||||
git status --short
|
||||
git add notebooks/palliative_care_rfi.py
|
||||
git commit -m "feat(notebooks): prior-year palliative comment exhibit — counts, table, themes (refs #660)"
|
||||
```
|
||||
|
||||
### Task 5: Close out
|
||||
|
||||
- [ ] **Step 1:** Comment results on #660 (per-docket counts, notebook section name, Zotero sync count) and close it.
|
||||
- [ ] **Step 2:** Update `palliative_rfi_project.md` memory: tag exists, sweep script path, notebook section, re-run note for when #615 (post-close re-farm) or new extractions land.
|
||||
@@ -184,9 +184,7 @@ def _(connect, mo, pl):
|
||||
OR t.name LIKE 'source:%')
|
||||
GROUP BY t.name ORDER BY t.name
|
||||
""").fetchall()
|
||||
ident_comp = pl.DataFrame(
|
||||
[{"tag": name, "records": n} for name, n in _rows]
|
||||
)
|
||||
ident_comp = pl.DataFrame([{"tag": name, "records": n} for name, n in _rows])
|
||||
mo.vstack(
|
||||
[
|
||||
mo.md("**Identification composition (live from Zotero):**"),
|
||||
@@ -506,9 +504,12 @@ def _(AMBER, RULES, TEAL, alt, family_pay, fr_md, mo, pl, proposed_for):
|
||||
.then(pl.lit("Gain"))
|
||||
.otherwise(pl.lit("Loss"))
|
||||
.alias("sign"),
|
||||
(pl.col("hcpcs") + pl.when(pl.col("mssp_attribution")).then(pl.lit(" ●")).otherwise(pl.lit(""))).alias(
|
||||
"code_label"
|
||||
),
|
||||
(
|
||||
pl.col("hcpcs")
|
||||
+ pl.when(pl.col("mssp_attribution"))
|
||||
.then(pl.lit(" ●"))
|
||||
.otherwise(pl.lit(""))
|
||||
).alias("code_label"),
|
||||
)
|
||||
_unpriced = family_pay.filter(
|
||||
pl.col("dollar_2026final").is_null() | pl.col("dollar_2027proposed").is_null()
|
||||
@@ -518,8 +519,15 @@ def _(AMBER, RULES, TEAL, alt, family_pay, fr_md, mo, pl, proposed_for):
|
||||
alt.Chart(_priced.to_pandas())
|
||||
.mark_bar()
|
||||
.encode(
|
||||
x=alt.X("pct_change:Q", title="% change, CY2026 final → CY2027 proposed (national, GPCI=1.0)"),
|
||||
y=alt.Y("code_label:N", title=None, sort=alt.SortField(field="pct_change", order="descending")),
|
||||
x=alt.X(
|
||||
"pct_change:Q",
|
||||
title="% change, CY2026 final → CY2027 proposed (national, GPCI=1.0)",
|
||||
),
|
||||
y=alt.Y(
|
||||
"code_label:N",
|
||||
title=None,
|
||||
sort=alt.SortField(field="pct_change", order="descending"),
|
||||
),
|
||||
color=alt.Color(
|
||||
"sign:N",
|
||||
title=None,
|
||||
@@ -527,7 +535,10 @@ def _(AMBER, RULES, TEAL, alt, family_pay, fr_md, mo, pl, proposed_for):
|
||||
legend=alt.Legend(orient="top"),
|
||||
),
|
||||
tooltip=[
|
||||
"hcpcs", "family", "description", "mssp_attribution",
|
||||
"hcpcs",
|
||||
"family",
|
||||
"description",
|
||||
"mssp_attribution",
|
||||
alt.Tooltip("dollar_2026final:Q", format="$.2f"),
|
||||
alt.Tooltip("dollar_2027proposed:Q", format="$.2f"),
|
||||
alt.Tooltip("pct_change:Q", format="+.2f"),
|
||||
@@ -634,7 +645,9 @@ def _(AMBER, TEAL, alt, family_pay, mo, pl, q):
|
||||
scale=alt.Scale(domainMid=0, range=[AMBER, "#EDEBE6", TEAL]),
|
||||
),
|
||||
tooltip=[
|
||||
"hcpcs", "family", "year:O",
|
||||
"hcpcs",
|
||||
"family",
|
||||
"year:O",
|
||||
alt.Tooltip("total_rvu:Q", format=".2f"),
|
||||
alt.Tooltip("rvu_yoy_pct:Q", format="+.2f"),
|
||||
],
|
||||
@@ -795,12 +808,25 @@ def _(mo, pl):
|
||||
def _(family_pay, mo, pl, proposed_codes):
|
||||
# Live valuation anchors: what the named PFS analogs pay today and
|
||||
# under the CY2027 proposal (from Section 2's family_pay frame).
|
||||
_analog_codes = ["99497", "99498", "99495", "99490", "99487", "G0556", "G0557", "G0558"]
|
||||
_analog_codes = [
|
||||
"99497",
|
||||
"99498",
|
||||
"99495",
|
||||
"99490",
|
||||
"99487",
|
||||
"G0556",
|
||||
"G0557",
|
||||
"G0558",
|
||||
]
|
||||
analog_anchor = (
|
||||
family_pay.filter(pl.col("hcpcs").is_in(_analog_codes))
|
||||
.select(
|
||||
"hcpcs", "family", "description",
|
||||
"dollar_2026final", "dollar_2027proposed", "mssp_attribution",
|
||||
"hcpcs",
|
||||
"family",
|
||||
"description",
|
||||
"dollar_2026final",
|
||||
"dollar_2027proposed",
|
||||
"mssp_attribution",
|
||||
)
|
||||
.sort("family", "hcpcs")
|
||||
)
|
||||
@@ -833,7 +859,237 @@ def _(family_pay, mo, pl, proposed_codes):
|
||||
return
|
||||
|
||||
|
||||
# ── 4. Provenance ──────────────────────────────────────────────────────
|
||||
# ── 4. Prior rulemaking comment record ─────────────────────────────────
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(mo):
|
||||
mo.md("""
|
||||
## 4. Prior Rulemaking Comment Record — Palliative Care
|
||||
|
||||
CMS asks in the RFI what it should know about community-based
|
||||
palliative care; part of the answer is that **commenters have been
|
||||
telling CMS for a decade**. The bib store holds every comment from
|
||||
the last ten PFS rulemaking dockets (CY2018-CY2027). A lexical
|
||||
sweep (`dev/scripts/tag_palliative_comments.py`, stem
|
||||
`palliativ*` over the extracted comment text, else the enriched
|
||||
abstract) tags each referencing comment `topic:palliative` in bib
|
||||
and Zotero, with mention counts and sentence snippets on the item.
|
||||
""")
|
||||
return
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(connect, mo, pl):
|
||||
import json as _json
|
||||
|
||||
# keep the Store referenced for the cell — its __del__ closes the con
|
||||
_bstore = connect.bib()
|
||||
_bcon = _bstore._con()
|
||||
_rows = _bcon.execute("""
|
||||
SELECT i.key, i.title, i.url, i.extra_json,
|
||||
(SELECT substr(t.name, 6) FROM item_tags it
|
||||
JOIN tags t ON t.id = it.tag_id
|
||||
WHERE it.item_id = i.id AND t.name LIKE 'year:%') AS year,
|
||||
(SELECT substr(t.name, 6) FROM item_tags it
|
||||
JOIN tags t ON t.id = it.tag_id
|
||||
WHERE it.item_id = i.id AND t.name LIKE 'rule:%') AS rule,
|
||||
(SELECT substr(t.name, 12) FROM item_tags it
|
||||
JOIN tags t ON t.id = it.tag_id
|
||||
WHERE it.item_id = i.id AND t.name LIKE 'reg-docket:%') AS docket
|
||||
FROM items i
|
||||
WHERE i.id IN (
|
||||
SELECT it2.item_id FROM item_tags it2
|
||||
JOIN tags t2 ON t2.id = it2.tag_id
|
||||
WHERE t2.name = 'topic:palliative'
|
||||
)
|
||||
ORDER BY year, i.title
|
||||
""").fetchall()
|
||||
|
||||
pall_comments = pl.DataFrame(
|
||||
[
|
||||
{
|
||||
"year": r["year"] or "",
|
||||
"rule": r["rule"] or "",
|
||||
"docket": r["docket"] or "",
|
||||
"comment": r["title"] or "",
|
||||
"url": r["url"] or "",
|
||||
"mentions": (
|
||||
_json.loads(r["extra_json"]).get("palliative_mentions", 0)
|
||||
if r["extra_json"]
|
||||
else 0
|
||||
),
|
||||
"snippet": (
|
||||
(_json.loads(r["extra_json"]).get("palliative_snippets") or [""])[0]
|
||||
if r["extra_json"]
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for r in _rows
|
||||
]
|
||||
)
|
||||
|
||||
_meta_row = _bcon.execute(
|
||||
"SELECT value FROM bib_meta WHERE key = 'palliative_sweep'"
|
||||
).fetchone()
|
||||
pall_sweep = _json.loads(_meta_row[0]) if _meta_row else {"dockets": {}}
|
||||
return pall_comments, pall_sweep
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(TEAL, alt, mo, pall_comments, pall_sweep, pl):
|
||||
_per_year = (
|
||||
pall_comments.group_by("year")
|
||||
.agg(pl.len().alias("comments"), pl.col("mentions").sum().alias("mentions"))
|
||||
.sort("year")
|
||||
)
|
||||
_covered = sum(d["with_text"] for d in pall_sweep["dockets"].values())
|
||||
_total = sum(d["total"] for d in pall_sweep["dockets"].values())
|
||||
|
||||
pall_year_chart = (
|
||||
alt.Chart(_per_year.to_pandas())
|
||||
.mark_bar(color=TEAL)
|
||||
.encode(
|
||||
x=alt.X("year:N", title="Comment year"),
|
||||
y=alt.Y("comments:Q", title="Comments referencing palliative care"),
|
||||
tooltip=["year", "comments", "mentions"],
|
||||
)
|
||||
.properties(
|
||||
title="Palliative-Care Mentions Across Ten PFS Comment Cycles (live)",
|
||||
width=620,
|
||||
height=220,
|
||||
)
|
||||
)
|
||||
|
||||
mo.vstack(
|
||||
[
|
||||
pall_year_chart,
|
||||
mo.md(f"""
|
||||
**Denominator disclosure:** the sweep covers the
|
||||
{_covered:,} of {_total:,} docketed comments with locally
|
||||
retrievable text (extracted attachment/inline text for the
|
||||
ten PFS dockets, enriched abstracts otherwise) — counts
|
||||
are a floor, not a census. The 2018 spike is the CY2019
|
||||
E/M office-visit code-collapse proposal, which drew an
|
||||
organized response from hospice-and-palliative-medicine
|
||||
clinicians (see themes below).
|
||||
"""),
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(mo, pall_comments, pl):
|
||||
_tbl = (
|
||||
pall_comments.sort("mentions", descending=True)
|
||||
.with_columns(
|
||||
pl.format("[{}]({})", pl.col("comment"), pl.col("url")).alias(
|
||||
"comment_link"
|
||||
)
|
||||
)
|
||||
.select(
|
||||
pl.col("year"),
|
||||
pl.col("rule"),
|
||||
pl.col("comment_link").alias("comment"),
|
||||
pl.col("mentions"),
|
||||
pl.col("snippet"),
|
||||
)
|
||||
)
|
||||
mo.vstack(
|
||||
[
|
||||
mo.md(f"""
|
||||
### The {pall_comments.height} referencing comments (live from bib)
|
||||
|
||||
Every comment tagged `topic:palliative`, most-mentions
|
||||
first, with its first extracted snippet. Each link opens
|
||||
the comment on regulations.gov; the same items are in
|
||||
Zotero under the `topic:palliative` tag for citation from
|
||||
the P44 comment draft.
|
||||
"""),
|
||||
mo.ui.table(
|
||||
_tbl.to_pandas(),
|
||||
page_size=15,
|
||||
label="Prior-cycle comments referencing palliative care",
|
||||
),
|
||||
]
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
def _(mo):
|
||||
mo.md("""
|
||||
### What prior-cycle commenters told CMS
|
||||
|
||||
Reading the tagged set, five themes recur — each maps onto an RFI
|
||||
part, and each is a decade-long thread, not a one-off:
|
||||
|
||||
1. **The specialty comments every cycle.** The American Academy of
|
||||
Hospice and Palliative Medicine appears in all nine prior
|
||||
dockets ([2017](https://www.regulations.gov/comment/CMS-2017-0092-1574),
|
||||
[2018](https://www.regulations.gov/comment/CMS-2018-0076-15128),
|
||||
[2019](https://www.regulations.gov/comment/CMS-2019-0111-40551),
|
||||
[2020](https://www.regulations.gov/comment/CMS-2020-0088-28866),
|
||||
[2021](https://www.regulations.gov/comment/CMS-2021-0119-35330),
|
||||
[2022](https://www.regulations.gov/comment/CMS-2022-0113-24293),
|
||||
[2023](https://www.regulations.gov/comment/CMS-2023-0121-20479),
|
||||
[2024](https://www.regulations.gov/comment/CMS-2024-0256-5617),
|
||||
[2025](https://www.regulations.gov/comment/CMS-2025-0304-12014)),
|
||||
joined repeatedly by the National Coalition for Hospice and
|
||||
Palliative Care
|
||||
([2018](https://www.regulations.gov/comment/CMS-2018-0076-13835),
|
||||
[2023](https://www.regulations.gov/comment/CMS-2023-0121-20746)),
|
||||
CAPC ([2017](https://www.regulations.gov/comment/CMS-2017-0092-1374),
|
||||
[2018](https://www.regulations.gov/comment/CMS-2018-0076-15030)),
|
||||
NHPCO ([2021](https://www.regulations.gov/comment/CMS-2021-0119-34010),
|
||||
[2023](https://www.regulations.gov/comment/CMS-2023-0121-21390)),
|
||||
and nursing/PA counterparts
|
||||
([HPNA 2019](https://www.regulations.gov/comment/CMS-2019-0111-8492),
|
||||
[PAHPM 2019](https://www.regulations.gov/comment/CMS-2019-0111-29255)).
|
||||
2. **Payment adequacy for long, complex visits — RFI part (d).**
|
||||
The 2018 spike is HPM clinicians opposing the CY2019 E/M
|
||||
code-collapse, citing AMA analysis of a ~20% office-visit cut
|
||||
for the specialty
|
||||
([physician letters](https://www.regulations.gov/comment/CMS-2018-0076-14483),
|
||||
[UPenn program director](https://www.regulations.gov/comment/CMS-2018-0076-13362));
|
||||
the same adequacy argument returns for the G2211-style
|
||||
complexity add-on and Principal Illness Navigation in
|
||||
[2024](https://www.regulations.gov/comment/CMS-2024-0256-6729),
|
||||
asking CMS to make "palliative care in the community
|
||||
sustainable under Part B."
|
||||
3. **Team-based and non-physician delivery — RFI parts (c)/(d).**
|
||||
Recurring asks to recognize interdisciplinary palliative teams
|
||||
(chaplains, social workers, nurses) and PA/NP practice
|
||||
([OHSU PA 2019](https://www.regulations.gov/comment/CMS-2019-0111-31124),
|
||||
[NHPCO on PAs in hospice 2019](https://www.regulations.gov/comment/CMS-2019-0111-40582)).
|
||||
4. **Quality measurement — RFI part (e).** From 2023 on,
|
||||
commenters push MIPS measure #495 ("Ambulatory Palliative Care
|
||||
Patients' Experience of Feeling Heard and Understood") and the
|
||||
NCP Clinical Practice Guidelines / NASW standards as the
|
||||
measurement backbone
|
||||
([2023](https://www.regulations.gov/comment/CMS-2023-0121-20811),
|
||||
[AGS 2024](https://www.regulations.gov/comment/CMS-2024-0256-6812),
|
||||
[2025](https://www.regulations.gov/comment/CMS-2025-0304-13362)) —
|
||||
the same instruments Section 3's PCM-3 safeguard cites.
|
||||
5. **Access framing beyond fee-for-service — RFI parts (b)/(d).**
|
||||
Telehealth equivalence for home-based palliative care
|
||||
([2025](https://www.regulations.gov/comment/CMS-2025-0304-12156)),
|
||||
palliative care as a preventive service per Hawaii's Medicaid
|
||||
state plan
|
||||
([2025](https://www.regulations.gov/comment/CMS-2025-0304-12096)),
|
||||
and underutilization despite evidence
|
||||
([2024](https://www.regulations.gov/comment/CMS-2024-0256-4853)).
|
||||
|
||||
For the P44 draft this exhibit supports one sentence CMS cannot
|
||||
easily dismiss: the RFI's questions have standing, organized,
|
||||
decade-deep constituencies on the record — and the record shows
|
||||
exactly where existing codes fell short each time.
|
||||
""")
|
||||
return
|
||||
|
||||
|
||||
# ── 5. Provenance ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.cell(hide_code=True)
|
||||
@@ -873,7 +1129,7 @@ def _(mo, q):
|
||||
mo.vstack(
|
||||
[
|
||||
mo.md("""
|
||||
## 4. Provenance
|
||||
## 5. Provenance
|
||||
|
||||
Ingest-log rows for the lake tables Section 2 reads, plus
|
||||
the evidence-base pipeline: identification —
|
||||
@@ -882,9 +1138,11 @@ def _(mo, q):
|
||||
and grey-literature standards documents; run 2026-08-18);
|
||||
screening — `stack prisma screen palliative-rfi` with the
|
||||
project's Zotero criteria anchors; flow — `prisma.flow`
|
||||
over live Zotero tags. `cms.primary_care_service_code` is
|
||||
the aco module's attribution reference list (P39), not an
|
||||
FR ingest.
|
||||
over live Zotero tags. Section 4's comment record —
|
||||
`dev/scripts/tag_palliative_comments.py` (lexical sweep,
|
||||
run 2026-08-19; stats in `bib_meta.palliative_sweep`).
|
||||
`cms.primary_care_service_code` is the aco module's
|
||||
attribution reference list (P39), not an FR ingest.
|
||||
"""),
|
||||
mo.ui.table(
|
||||
provenance.to_pandas(),
|
||||
|
||||
Reference in New Issue
Block a user