feat(comments): palliative-mention sweep — topic:palliative tags + extra_json snippets (refs #660)
This commit is contained in:
200
dev/scripts/tag_palliative_comments.py
Normal file
200
dev/scripts/tag_palliative_comments.py
Normal file
@@ -0,0 +1,200 @@
|
||||
"""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
|
||||
if any(s <= m.start() < e for s, e in seen_spans):
|
||||
continue # sentence already captured
|
||||
# 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)
|
||||
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 _load_extra(store, key: str) -> dict:
|
||||
row = (
|
||||
store._con()
|
||||
.execute("SELECT extra_json FROM items WHERE key = ?", (key,))
|
||||
.fetchone()
|
||||
)
|
||||
return json.loads(row[0]) if row and row[0] else {}
|
||||
|
||||
|
||||
def _merge_extra(store, key: str, mentions: int, snippets: list[str]) -> None:
|
||||
extra = _load_extra(store, key)
|
||||
extra["palliative_mentions"] = mentions
|
||||
extra["palliative_snippets"] = snippets
|
||||
store.update(key, extra_json=json.dumps(extra))
|
||||
|
||||
|
||||
def _clear_extra(store, key: str) -> None:
|
||||
extra = _load_extra(store, key)
|
||||
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=Path(".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())
|
||||
53
tests/dev/test_tag_palliative_comments.py
Normal file
53
tests/dev/test_tag_palliative_comments.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user