feat(prisma): palliative-rfi PRISMA project — PubMed search, criteria, local-LLM screening fix (refs #642-#646, P42)

Mirrors the skin-subs (P29) systematic-review pipeline for the CY2027
NPRM's Community-Based Palliative Care RFI (CMS-1848-P, 91 FR
43949-43950, comments close 2026-09-14):

- dev/scripts/search_pubmed_palliative_rfi.py: five E-utilities
  searches, one per RFI part (a-e), relevance-sorted with per-domain
  caps (the palliative literature dwarfs skin subs; caps are printed
  and disclosed, not silent). 1,227 retrieved / 1,189 unique stored to
  bib with module:palliative-rfi + rfi:{a-e} tags.
- prisma.project: palliative-rfi STARTERS entry (criteria keyed to the
  five RFI questions, RFI-mapped extraction template, exclusion-reason
  codebook) + legacy cohort tag mapping.
- prisma.screen: long bulleted system prompts make qwen-family models
  served through Ollama return an empty response (no text, no tool
  call — bisected against qwen2.5:14b). System message is now a single
  sentence and the guidance bullets moved into the cached first user
  message, which every provider handles; also added a no-abstract
  rule. Needed because screening runs on the local server GPU
  (qwen2.5:14b-ctx8k) — the Anthropic key has no API credit and this
  work is directed to local inference.
This commit is contained in:
kert
2026-08-18 10:50:26 -04:00
parent c429a29cdd
commit 65b11ee2cb
3 changed files with 464 additions and 8 deletions

View File

@@ -0,0 +1,265 @@
"""PubMed systematic literature search for the palliative-care RFI (P42).
Queries NCBI E-utilities across five domains, one per part of the
CY2027 PFS NPRM's Community-Based Palliative Care RFI (CMS-1848-P,
91 FR 43949-43950):
a. fwa_hospice_palliative — fraud/waste/abuse focus areas
b. eligibility_prognosis — life-expectancy / prognosis gating
c. eligibility_function — ADL impact + caregiver strain criteria
d. care_management_delivery — community-based delivery models, teams
e. quality_measures — palliative quality measurement
Reuses the E-utilities plumbing from search_pubmed_skin_subs.py (P29);
adds relevance-sorted, per-domain-capped esearch because the palliative
literature is far larger than the skin-substitute corpus — caps keep the
identified set screenable within the comment window (closes 2026-09-14).
Results land in bib.sqlite via bib.store.Store with tags:
module:palliative-rfi, source:pubmed, rfi:{a|b|c|d|e},
type:{rct|review|meta-analysis|...}
Usage:
uv run python dev/scripts/search_pubmed_palliative_rfi.py --dry-run
uv run python dev/scripts/search_pubmed_palliative_rfi.py
"""
from __future__ import annotations
import argparse
import sys
import time
from datetime import datetime
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parent))
from search_pubmed_skin_subs import ( # noqa: E402
EUTILS_BASE,
RATE_LIMIT,
Article,
_params,
deduplicate,
efetch_articles,
)
from bib.item import Source # noqa: E402
from bib.store import Store # noqa: E402
# ---------------------------------------------------------------------------
# Search strategies — one domain per RFI part (91 FR 43949-43950)
# ---------------------------------------------------------------------------
# Common anchor: the RFI's subject population/service.
_ANCHOR = (
'("palliative care"[MeSH] OR "palliative"[tiab] '
'OR "serious illness"[tiab] OR "seriously ill"[tiab] '
'OR "hospice care"[MeSH] OR "supportive care"[tiab])'
)
_Q_FWA = (
'("hospice care"[MeSH] OR "hospice"[tiab] OR "palliative"[tiab]) '
'AND ("fraud"[MeSH] OR "fraud"[tiab] OR "program integrity"[tiab] '
'OR "improper payment"[tiab] OR "improper payments"[tiab] '
'OR "overutilization"[tiab] OR "upcoding"[tiab] OR "kickback"[tiab] '
'OR "billing patterns"[tiab] OR "enforcement"[tiab] '
'OR "medically unnecessary"[tiab] OR "for-profit ownership"[tiab] '
'OR "live discharge"[tiab])'
)
_Q_PROGNOSIS = (
f"{_ANCHOR} "
'AND ("life expectancy"[tiab] OR "terminal prognosis"[tiab] '
'OR "prognostication"[tiab] OR "prognostic criteria"[tiab] '
'OR "hospice eligibility"[tiab] OR "6-month prognosis"[tiab] '
'OR "survival prediction"[tiab]) '
'AND ("eligibility"[tiab] OR "identification"[tiab] '
'OR "criteria"[tiab] OR "certification"[tiab] OR "accuracy"[tiab] '
'OR "medicare"[tiab])'
)
_Q_FUNCTION = (
f"{_ANCHOR} "
'AND ("activities of daily living"[MeSH] OR "functional status"[tiab] '
'OR "functional decline"[tiab] OR "functional impairment"[tiab] '
'OR "caregiver burden"[tiab] OR "caregiver strain"[tiab] '
'OR "caregiver strain"[MeSH:noexp]) '
'AND ("eligibility"[tiab] OR "identification"[tiab] '
'OR "identifying"[tiab] OR "criteria"[tiab] OR "definition"[tiab] '
'OR "screening"[tiab] OR "medicare"[tiab])'
)
_Q_DELIVERY = (
f"{_ANCHOR} "
'AND ("community-based"[tiab] OR "home-based"[tiab] '
'OR "home visit"[tiab] OR "home visits"[tiab] '
'OR "interdisciplinary"[tiab] OR "care management"[tiab] '
'OR "care coordination"[tiab] OR "outpatient palliative"[tiab] '
'OR "ambulatory palliative"[tiab]) '
'AND ("medicare"[tiab] OR "randomized controlled trial"[pt] '
'OR "systematic review"[pt] OR "meta-analysis"[pt] '
'OR "hospitalization"[tiab] OR "utilization"[tiab] '
'OR "cost"[tiab] OR "costs"[tiab])'
)
_Q_QUALITY = (
f"{_ANCHOR} "
'AND ("quality indicators, health care"[MeSH] '
'OR "quality measure"[tiab] OR "quality measures"[tiab] '
'OR "quality indicator"[tiab] OR "quality indicators"[tiab] '
'OR "feeling heard and understood"[tiab] '
'OR "patient-reported outcome"[tiab] '
'OR "patient reported outcome"[tiab] OR "quality reporting"[tiab] '
'OR "value pathway"[tiab] OR "MIPS"[tiab])'
)
# Per-domain caps (relevance-sorted): the palliative literature dwarfs
# the skin-subs corpus, and every identified record costs a stage-2 LLM
# screening call. Caps are printed in the run log and disclosed in the
# notebook's PRISMA narrative — capped identification is a stated
# limitation, not silent truncation.
SEARCHES: dict[str, dict] = {
"fwa_hospice_palliative": {"query": _Q_FWA, "tags": ["rfi:a"], "cap": 250},
"eligibility_prognosis": {"query": _Q_PROGNOSIS, "tags": ["rfi:b"], "cap": 250},
"eligibility_function": {"query": _Q_FUNCTION, "tags": ["rfi:c"], "cap": 250},
"care_management_delivery": {"query": _Q_DELIVERY, "tags": ["rfi:d"], "cap": 300},
"quality_measures": {"query": _Q_QUALITY, "tags": ["rfi:e"], "cap": 250},
}
MODULE_TAG = "module:palliative-rfi"
def esearch_relevance(query: str, retmax: int) -> tuple[list[str], int]:
"""esearch with sort=relevance so per-domain caps keep the best hits."""
params = _params(
db="pubmed",
term=query,
retmax=str(retmax),
retmode="json",
sort="relevance",
usehistory="n",
)
resp = httpx.get(f"{EUTILS_BASE}/esearch.fcgi", params=params, timeout=30)
resp.raise_for_status()
result = resp.json().get("esearchresult", {})
ids = result.get("idlist", [])
count = int(result.get("count", 0))
print(f" esearch: {count} total results, retrieved {len(ids)} PMIDs (cap {retmax})")
return ids, count
def article_to_source(article: Article, domain_tags: list[str]) -> Source:
"""Convert an Article to a bib Source item (palliative-rfi tagging)."""
tags = [MODULE_TAG, "source:pubmed"]
if article.year:
tags.append(f"year:{article.year}")
type_tag = article.infer_type_tag()
if type_tag:
tags.append(type_tag)
tags.extend(domain_tags)
author_str = "; ".join(article.authors[:10])
if len(article.authors) > 10:
author_str += f" (+{len(article.authors) - 10} more)"
extra_parts = [f"PMID: {article.pmid}"]
if article.doi:
extra_parts.append(f"DOI: {article.doi}")
if author_str:
extra_parts.append(f"Authors: {author_str}")
if article.journal:
extra_parts.append(f"Journal: {article.journal}")
if article.pub_types:
extra_parts.append(f"PubTypes: {'; '.join(article.pub_types)}")
if article.mesh_terms:
extra_parts.append(f"MeSH: {'; '.join(article.mesh_terms[:15])}")
return Source(
title=article.title,
url=article.url,
date_published=article.year,
institution=article.journal,
abstract=article.abstract,
doc_type="journal-article",
tags=tags,
extra="\n".join(extra_parts),
)
def main() -> None:
parser = argparse.ArgumentParser(description="PubMed palliative-care RFI search")
parser.add_argument(
"--dry-run", action="store_true", help="esearch counts only, no efetch/store"
)
args = parser.parse_args()
print("=" * 70)
print("PubMed Systematic Search: Community-Based Palliative Care RFI (P42)")
print(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M')}")
print("=" * 70)
all_articles: dict[str, list[Article]] = {}
all_pmids: dict[str, list[str]] = {}
domain_totals: dict[str, int] = {}
for domain, cfg in SEARCHES.items():
print(f"\n--- Domain: {domain} (tags {cfg['tags']}) ---")
print(f" Query: {cfg['query'][:100]}...")
time.sleep(RATE_LIMIT)
pmids, total = esearch_relevance(cfg["query"], retmax=cfg["cap"])
all_pmids[domain] = pmids
domain_totals[domain] = total
if args.dry_run or not pmids:
all_articles[domain] = []
continue
all_articles[domain] = efetch_articles(pmids)
print(f" Fetched {len(all_articles[domain])} articles")
print("\n" + "=" * 70)
print("PRISMA Flow — Identification (relevance-capped)")
print("=" * 70)
total_identified = sum(len(v) for v in all_pmids.values())
for domain, pmids in all_pmids.items():
print(
f" {domain:28s}: {len(pmids):>5} retrieved "
f"(of {domain_totals[domain]} matching)"
)
print(f" {'TOTAL retrieved':28s}: {total_identified:>5}")
if args.dry_run:
print("\n[DRY RUN] Skipping efetch and bib.sqlite write")
return
merged = deduplicate(all_articles)
print(f"\n Duplicates removed: {total_identified - len(merged):>5}")
print(f" Unique articles: {len(merged):>5}")
pmid_domains: dict[str, list[str]] = {}
for domain, articles in all_articles.items():
for art in articles:
for t in SEARCHES[domain]["tags"]:
pmid_domains.setdefault(art.pmid, [])
if t not in pmid_domains[art.pmid]:
pmid_domains[art.pmid].append(t)
print(f"\nStoring {len(merged)} articles in bib.sqlite ...")
store = Store()
created = updated = 0
for pmid, article in sorted(merged.items()):
source = article_to_source(article, pmid_domains.get(pmid, []))
con = store._con()
existing = con.execute(
"SELECT key FROM items WHERE url = ?", (source.url,)
).fetchone()
if existing:
updated += 1
else:
created += 1
store.upsert(source)
print(f" Created: {created}")
print(f" Updated: {updated}")
if __name__ == "__main__":
main()

View File

@@ -190,12 +190,190 @@ Included by default; excluded only with code:
"""
_STARTER_CRITERIA_PALLIATIVE_RFI = """\
# Community-Based Palliative Care RFI — Inclusion / Exclusion Criteria
## Review question
What evidence can inform CMS's Request for Information on
Community-Based Palliative Care in the CY2027 PFS NPRM (CMS-1848-P,
91 FR 43949-43950)? The RFI asks five things, and a record is
included when it provides usable evidence for at least one:
- **(a)** where fraud, waste, and abuse concentrate in
community-based palliative care and adjacent hospice programs;
- **(b)** whether eligibility for a serious-illness service should be
gated on certified life expectancy, and what prognostic interval
the evidence supports;
- **(c)** whether eligibility is better defined by impact on daily
function (ADLs) and/or caregiver strain, and with what instruments;
- **(d)** which service elements of community-based palliative care
management (interdisciplinary teams, home visits, 24/7 access,
care plans, post-discharge follow-up) drive outcomes;
- **(e)** how quality of palliative care management should be
measured (including CBE 3665, "feeling heard and understood").
## Population
Adults with serious illness — palliative care, hospice, or
supportive-care populations. U.S. Medicare-relevant settings
preferred; non-U.S. evidence is includable when the finding
transfers (instruments, service elements, prognostic accuracy).
Exclude pediatric-only populations.
## Topic scope
Include: service-delivery models (community-based, home-based,
outpatient/ambulatory palliative care), eligibility/identification
criteria and instruments, prognostication accuracy, caregiver strain
measurement, care-management service elements, quality measures and
patient-reported outcomes, utilization/cost/hospitalization outcomes,
billing-integrity and program-integrity evidence.
Exclude: pure symptom-pharmacology efficacy trials (drug/dose
comparisons) with no service-delivery, eligibility, or quality
measurement angle; inpatient-only intervention studies with no
community/outpatient transferability; disease-specific prognostic
biomarker papers with no service-eligibility framing.
## Study designs
Include: RCT, quasi-experimental, prospective/retrospective cohort
with comparator, validation studies of instruments or prognostic
criteria, systematic review, meta-analysis, economic evaluation,
health-services/claims analyses, mixed-methods evaluations of
delivery models.
Exclude: case reports, case series without comparator, narrative
reviews, editorials, letters, commentaries, conference abstracts
without full data.
## Time / language
Any publication year (CMMI's synthesis window is 2012-2021; older
foundational instrument papers still qualify). English, or
non-English with available English abstract.
## Policy / grey sub-domain
Docs tagged with any of ``source:oig``, ``source:gao``, ``source:medpac``,
``source:court``, ``source:cms-rule``, ``source:mac-lcd`` are reference
material and bypass clinical screening. Flag with
``screen:reason:off-topic`` only if unrelated to palliative or
hospice care.
"""
_STARTER_EXTRACTION_PALLIATIVE_RFI = """\
# Data extraction template — palliative-rfi
The LLM will fill this form for every included study. Fields absent
from the paper should be returned as empty strings rather than
omitted.
## Study metadata
- **authors** — lead + et al.
- **year** — publication year (YYYY)
- **country** — setting of the study
- **funding** — industry / public / mixed / unreported
- **design** — RCT / quasi-experimental / cohort / validation /
systematic review / meta-analysis / economic / claims analysis
## RFI mapping
- **rfi_parts** — which RFI questions the study informs (a-e,
comma-separated)
- **medicare_relevance** — direct (Medicare data/population) /
transferable / weak
## Population & setting
- **n_total** — total enrolled or analyzed
- **population** — serious-illness definition used
- **setting** — home / outpatient clinic / community / mixed /
inpatient
## Eligibility evidence (parts b-c)
- **eligibility_basis** — prognosis / function-ADL / caregiver-strain /
diagnosis-count / combination
- **instrument** — named instrument or criteria set
- **prognostic_interval** — life-expectancy interval studied
- **instrument_performance** — accuracy / discrimination as reported
## Delivery-model evidence (part d)
- **service_elements** — team composition, home visits, 24/7 access,
care planning, follow-up cadence as reported
- **outcomes** — utilization, hospitalization, cost, place of death,
patient/caregiver-reported outcomes
- **effect_size** — OR / RR / HR / mean diff with 95% CI
- **p_value**
## Quality-measure evidence (part e)
- **measures_used** — named quality measures / PROMs (note CBE 3665)
- **measure_findings**
## Integrity evidence (part a)
- **fwa_findings** — billing-pattern, enforcement, or integrity
findings
## Risk of bias
- **overall** — low / some concerns / high
- **rob_notes**
"""
_STARTER_REASONS_PALLIATIVE_RFI = """\
# Exclusion reason codebook — palliative-rfi
Each code applies to a single PRISMA stage. When the LLM decides to
exclude a record it must return one of these codes plus a one-sentence
rationale.
## Stage 2 — title / abstract screening
- `off-topic` — unrelated to palliative, hospice, or serious-illness
care.
- `wrong-population` — pediatric-only, or not a serious-illness /
palliative / hospice population.
- `symptom-pharmacology` — drug/dose efficacy study with no
service-delivery, eligibility, or quality-measurement angle.
- `inpatient-only` — exclusively inpatient/ICU intervention with no
community, home, or outpatient transferability.
- `no-rfi-question` — on-topic but informs none of RFI parts a-e.
- `case-report` — case report / case series without comparator.
- `editorial` — editorial, commentary, narrative review, letter.
- `conference-abstract` — abstract only, no full data.
- `not-english` — not English and no English abstract available.
- `duplicate` — duplicate publication (same data, different venue).
- `retracted` — paper has been retracted.
## Stage 3 — full-text eligibility
Everything in Stage 2, plus:
- `no-usable-evidence` — full text reports no findings usable for any
RFI question.
- `wrong-outcome` — outcomes unrelated to eligibility, delivery,
quality, cost/utilization, or integrity.
- `unavailable-fulltext` — full text cannot be retrieved.
## Policy / grey sub-domain (OIG, GAO, MedPAC, court, CMS rules)
Included by default; excluded only with code:
- `off-topic` — unrelated to palliative or hospice care.
"""
STARTERS = {
"skin-subs": (
_STARTER_CRITERIA_SKIN_SUBS,
_STARTER_EXTRACTION,
_STARTER_REASONS,
),
"palliative-rfi": (
_STARTER_CRITERIA_PALLIATIVE_RFI,
_STARTER_EXTRACTION_PALLIATIVE_RFI,
_STARTER_REASONS_PALLIATIVE_RFI,
),
}
@@ -204,6 +382,7 @@ STARTERS = {
# onto them so the screening queue has something to chew on.
_LEGACY_COHORT_TAGS: dict[str, str] = {
"skin-subs": "module:skin-subs",
"palliative-rfi": "module:palliative-rfi",
}

View File

@@ -32,16 +32,27 @@ if TYPE_CHECKING:
log = logging.getLogger(__name__)
_SYSTEM = """\
You are a systematic-review assistant running PRISMA screening. Be
rigorous, conservative, and explicit. Cite the specific inclusion or
exclusion criterion that drives each decision.
# Kept to a single sentence deliberately: long, bulleted system
# prompts make qwen-family models served through Ollama's chat
# template emit an empty response (no text, no tool call) — the
# guidance bullets live in the first user message instead, which
# every provider handles.
_SYSTEM = (
"You are a systematic-review assistant running PRISMA screening. "
"Record each decision by calling the record_screening_decision tool."
)
For each item, call the `record_screening_decision` tool exactly once.
Do not emit prose outside the tool call.
_GUIDANCE = """\
## Screening rules
- Be rigorous, conservative, and explicit; cite the specific criterion
that drives each decision in the rationale field.
- Call the `record_screening_decision` tool exactly once per item.
- Favor `uncertain` when the abstract is ambiguous on population,
design, or outcomes — the full-text stage will resolve it.
design, or outcomes — the full-text stage will resolve it. If no
abstract is available, judge from the title and default to
`uncertain` unless the title alone cleanly matches an exclusion
code.
- Use `exclude` only when a reason code cleanly applies.
- Reason codes must come from the codebook; do not invent codes.
- Themes are optional; 0-4 short kebab-case slugs.
@@ -56,7 +67,8 @@ def _build_call(project: Project, item_markdown: str) -> LLMCall:
LLMMessage(
role="user",
content=(
"## Inclusion / exclusion criteria\n\n"
_GUIDANCE
+ "\n## Inclusion / exclusion criteria\n\n"
+ project.criteria
+ "\n\n## Exclusion reason codebook\n\n"
+ project.reasons