diff --git a/src/cli/pfs.py b/src/cli/pfs.py
index 534dc4c..f0716f5 100644
--- a/src/cli/pfs.py
+++ b/src/cli/pfs.py
@@ -677,16 +677,29 @@ def _stored(read: Any, con: Any, family: str) -> list[Any]:
raise
-def _overlay_stances(rows: list[Any], stances: list[Any]) -> list[Any]:
+def _overlay_stances(
+ rows: list[Any], stances: list[Any], stored: list[Any], commenters: Counter
+) -> list[Any]:
"""Put the classified counts of each docket's stored stances on its row,
- so a reaction rerun never zeroes them."""
+ so a reaction rerun never zeroes them. The event order is the one the
+ docket's last ``stance`` run stored (*stored* are the family's stored
+ reaction rows); *commenters* counts ``pfs.commenter`` rows per docket (R62)."""
+ old = {r.period: r for r in stored}
out = []
for r in rows:
mine = [s for s in stances if s.docket == r.period]
- out.append(rollup(mine, base=r) if r.period_kind == "docket" and mine else r)
+ if r.period_kind != "docket" or not mine:
+ out.append(r)
+ continue
+ order = list(json.loads(old[r.period].events_json)) if r.period in old else []
+ out.append(rollup(mine, base=r, order=order, n_commenters=commenters[r.period]))
return out
+def _commenters_of(con: Any, family: str) -> list[Any]:
+ return read_commenters(con, family=family)
+
+
@app.command()
def reaction(
family: list[str] = typer.Option(
@@ -723,6 +736,12 @@ def reaction(
try:
targets = _reaction_targets(con, family, code)
stored = {key: _stored(read_stances, con, key) for key, _, _ in targets}
+ old_rows = {key: _stored(read_reaction, con, key) for key in stored}
+ n_comm = {
+ key: Counter(c.docket for c in _stored(_commenters_of, con, key))
+ for key, v in stored.items()
+ if v
+ }
finally:
con.close()
if stance_sample > 0:
@@ -753,7 +772,9 @@ def reaction(
stance_sample_n=stance_sample,
name=name,
)
- fam_rows = _overlay_stances(fam_rows, stored[key])
+ fam_rows = _overlay_stances(
+ fam_rows, stored[key], old_rows[key], n_comm.get(key, Counter())
+ )
by_family[key] = fam_rows
rows.extend(fam_rows)
@@ -1346,6 +1367,9 @@ def stance(
dry_run: bool = typer.Option(
False, "--dry-run", help="Count commenters and events; no model."
),
+ no_llm: bool = typer.Option(
+ False, "--no-llm", help="Make no model calls; the finished ledger is replayed."
+ ),
write: bool = typer.Option(False, "--write"),
) -> None:
"""Each commenter's stance (support / modify / oppose / unclear) on each
@@ -1362,7 +1386,7 @@ def stance(
year = docket_rule_year(found[0].comment_end_date)
con = _read()
try:
- events = docket_events(con, fam, year)
+ events = docket_events(con, fam, year, store)
commenter_rows = [
c for c in read_commenters(con, family=fam.key) if c.docket == docket
]
@@ -1387,7 +1411,7 @@ def stance(
f"text, {len(events)} events"
)
for e in events:
- typer.echo(f" {e.event_key}")
+ typer.echo(f" {e.event_key}: {e.text}")
if dry_run:
typer.echo("no model calls made")
return
@@ -1395,23 +1419,25 @@ def stance(
commenter_rows,
texts,
events,
- _asker(),
+ None if no_llm else _asker(),
RunLog(ledger_path("stance", fam.key)),
family=fam.key,
docket=docket,
+ model=_model_name() if no_llm else None,
limit=limit,
retry_failed=retry_failed,
progress=lambda unit, status: typer.echo(f"{unit} {status}"),
)
blank = ReactionRow(fam.key, docket, "docket", year, 0, 0, 0, 0, 0, 0, "[]")
- rolled = rollup(rows, base=blank)
+ order = [e.event_key for e in events]
+ rolled = rollup(rows, base=blank, order=order, n_commenters=len(commenter_rows))
for key, n in json.loads(rolled.events_json).items():
typer.echo(
f"{key}: support {n['support']}, modify {n['modify']}, "
f"oppose {n['oppose']}, unclear {n['unclear']}"
)
typer.echo(
- f"commenters classified: {len({r.item_key for r in rows})}; "
+ f"commenters classified: {rolled.n_classified} of {len(commenter_rows)}; "
f"support {rolled.stance_support}, modify {rolled.stance_modify}, "
f"oppose {rolled.stance_oppose}, unclear {rolled.stance_unclear}"
)
@@ -1431,7 +1457,7 @@ def stance(
(r for r in series_rows if r.period_kind == "docket" and r.period == docket),
blank,
)
- new = rollup(rows, base=base)
+ new = rollup(rows, base=base, order=order, n_commenters=len(commenter_rows))
keep = [
r
for r in (stored or series_rows)
diff --git a/src/pfs/codetables.py b/src/pfs/codetables.py
index 43664e1..deca888 100644
--- a/src/pfs/codetables.py
+++ b/src/pfs/codetables.py
@@ -169,6 +169,12 @@ class ReactionRow:
#: JSON ``{event_key: {"support": n, "modify": n, "oppose": n, "unclear": n}}``
#: from ``pfs.comment_stance`` (task 9); ``"{}"`` when none was classified
events_json: str = "{}"
+ #: R62: distinct commenters with at least one firm (non-``unclear``)
+ #: position; ``None`` = this docket was never classified
+ n_classified: int | None = None
+ #: R62: ``pfs.commenter`` rows for (family, docket); ``None`` = never
+ #: classified
+ n_commenters: int | None = None
@dataclass(frozen=True)
@@ -467,7 +473,7 @@ CREATE TABLE IF NOT EXISTS pfs.code_reaction (
family VARCHAR, period VARCHAR, period_kind VARCHAR, year INTEGER,
n_items INTEGER, n_total INTEGER, stance_support INTEGER, stance_oppose INTEGER,
stance_modify INTEGER, stance_unclear INTEGER, sample_json VARCHAR,
- events_json VARCHAR);
+ events_json VARCHAR, n_classified INTEGER, n_commenters INTEGER);
CREATE TABLE IF NOT EXISTS pfs.cpt_section (
edition_year INTEGER, item_key VARCHAR, sec_id VARCHAR, level INTEGER, title VARCHAR,
path VARCHAR[], path_key VARCHAR, code_lo VARCHAR, code_hi VARCHAR, guideline VARCHAR);
@@ -568,6 +574,12 @@ def ensure_tables(con: Any) -> None:
con.execute(
"ALTER TABLE pfs.code_reaction ADD COLUMN IF NOT EXISTS events_json VARCHAR"
)
+ con.execute(
+ "ALTER TABLE pfs.code_reaction ADD COLUMN IF NOT EXISTS n_classified INTEGER"
+ )
+ con.execute(
+ "ALTER TABLE pfs.code_reaction ADD COLUMN IF NOT EXISTS n_commenters INTEGER"
+ )
con.execute("UPDATE pfs.commenter SET family = 'HOME-VISITS' WHERE family IS NULL")
@@ -717,8 +729,10 @@ def read_reaction(con: Any, family: str) -> list[ReactionRow]:
"ORDER BY period_kind, year, period",
[family],
).fetchall()
- # rows written before ``events_json`` existed read back NULL for it
- return [ReactionRow(*r[:-1], r[-1] or "{}") for r in rows]
+ # a replica published before ``events_json``/``n_classified``/
+ # ``n_commenters`` existed has shorter rows; NULL events read as ``{}``
+ padded = [(*r, None, None, None)[:14] for r in rows]
+ return [ReactionRow(*r[:11], r[11] or "{}", r[12], r[13]) for r in padded]
_CPT_TABLES = (
diff --git a/src/pfs/curated.py b/src/pfs/curated.py
index 9176123..73a9fd3 100644
--- a/src/pfs/curated.py
+++ b/src/pfs/curated.py
@@ -42,6 +42,11 @@ class CuratedLink:
p_id: int
page: int
rvu_year: int
+ #: placeholder codes the proposed rule used for this link ("GXXX3" for
+ #: G0318), cited to the proposed-rule paragraph that introduces them
+ aliases: tuple[str, ...] = ()
+ alias_item_key: str = ""
+ alias_p_id: int = 0
@dataclass(frozen=True)
@@ -87,6 +92,14 @@ def _cite(entry: Mapping[str, Any], where: str) -> tuple[str, int, int, int]:
raise ValueError(f"{where}: ambiguous cite {sorted(keys)}")
+def _alias_cite(entry: Mapping[str, Any], where: str) -> tuple[str, int, int, int]:
+ """The ``alias_cite`` of a link with aliases: a paragraph, nothing else."""
+ cite = entry.get("alias_cite")
+ if not isinstance(cite, dict) or set(cite) != {"item_key", "p_id"}:
+ raise ValueError(f"{where}: aliases need alias_cite {{item_key, p_id}}")
+ return _cite({"cite": cite}, where)
+
+
def _year(entry: Mapping[str, Any], field: str, where: str) -> int | None:
v = entry.get(field)
return None if v is None else _int(v, where, field)
@@ -138,6 +151,14 @@ def _parse(key: str, raw: Any) -> CuratedFamily:
kind = ln.get("kind")
if kind not in KINDS:
raise ValueError(f"{where}: unknown kind {kind!r}")
+ aliases = ln.get("aliases") or []
+ if not isinstance(aliases, list) or not all(
+ isinstance(a, str) for a in aliases
+ ):
+ raise ValueError(f"{where}: aliases must be a list of strings")
+ alias_item, alias_p = "", 0
+ if aliases:
+ alias_item, alias_p, _, _ = _alias_cite(ln, where)
links.append(
CuratedLink(
kind,
@@ -145,6 +166,9 @@ def _parse(key: str, raw: Any) -> CuratedFamily:
_year(ln, "since", where),
_year(ln, "until", where),
*_cite(ln, where),
+ tuple(a.strip().upper() for a in aliases if a.strip()),
+ alias_item,
+ alias_p,
)
)
synonyms = tuple(str(s).strip().lower() for s in raw.get("synonyms") or ())
diff --git a/src/pfs/curated.yaml b/src/pfs/curated.yaml
index 03a90fe..b33728e 100644
--- a/src/pfs/curated.yaml
+++ b/src/pfs/curated.yaml
@@ -31,4 +31,4 @@ HOME-VISITS:
- {kind: add-on, target: G2211, since: 2026, cite: {item_key: NHRGIHGD, p_id: 1469}}
- {kind: modifier, target: B1, since: 2027, cite: {item_key: JCWARTJD, p_id: 1168}}
- {kind: modifier, target: B2, since: 2027, cite: {item_key: JCWARTJD, p_id: 1205}}
- - {kind: prolonged-add-on, target: G0318, since: 2023, cite: {item_key: PETM225D, p_id: 1694}}
+ - {kind: prolonged-add-on, target: G0318, since: 2023, aliases: [GXXX3], alias_cite: {item_key: MZ24MX5S, p_id: 880}, cite: {item_key: PETM225D, p_id: 1694}}
diff --git a/src/pfs/stance.py b/src/pfs/stance.py
index 8d717ec..7e8b714 100644
--- a/src/pfs/stance.py
+++ b/src/pfs/stance.py
@@ -14,6 +14,7 @@ It takes only our own ``StanceRow``s: CMS's summaries of comments
from __future__ import annotations
+import html
import json
import re
from collections import defaultdict
@@ -28,10 +29,14 @@ from pfs.codetables import (
read_events,
read_rounds,
)
+from pfs.descriptors import rule_year_of
+from pfs.families import code_pattern, codes_in
+from pfs.lineage import _expand_ranges
+from pfs.rounds import rule_items
from pfs.runlog import RunLog, input_hash, run_units
TASK = "stance"
-STANCE_PROMPT_VERSION = "stance-v1"
+STANCE_PROMPT_VERSION = "stance-v2"
STANCES = ("support", "modify", "oppose", "unclear")
_EVIDENCE_WORDS = 6
_FIELD_CHARS = 200
@@ -65,8 +70,101 @@ def _num(x: float | None) -> str:
return f"{x:.2f}"
-def docket_events(con: Any, family: Any, rule_year: int) -> list[EventChoice]:
- """The family events a docket's rule touched, always ending in ``other``."""
+def _compact(codes: Sequence[str]) -> str:
+ """ "99324-99328, 99334-99337, 99339, 99340 and 99343": runs of three or
+ more consecutive numeric codes become a range."""
+ out: list[str] = []
+ cs = sorted(set(codes))
+ i = 0
+ while i < len(cs):
+ j = i
+ while (
+ j + 1 < len(cs)
+ and cs[j].isdigit()
+ and cs[j + 1].isdigit()
+ and int(cs[j + 1]) == int(cs[j]) + 1
+ ):
+ j += 1
+ if j - i >= 2:
+ out.append(f"{cs[i]}\u2013{cs[j]}")
+ else:
+ out += cs[i : j + 1]
+ i = j + 1
+ return out[0] if len(out) == 1 else ", ".join(out[:-1]) + " and " + out[-1]
+
+
+def _paragraph(store: Any, item_key: str, p_id: int) -> str:
+ row = (
+ store._con()
+ .execute( # noqa: SLF001
+ "SELECT text FROM fr_anchors WHERE item_key = ? AND p_id = ?",
+ (item_key, p_id),
+ )
+ .fetchone()
+ )
+ return (row[0] or "") if row else ""
+
+
+def _descriptor(para: str, alias: str) -> str:
+ """The descriptor a proposed rule prints for a placeholder code."""
+ m = re.search(
+ rf"\b{re.escape(alias)}\s*\(\s*(.+?)\)\s*\.?\s*\(Do not", para, re.S
+ ) or re.search(rf"\b{re.escape(alias)}\s*\(\s*(.{{1,450}})", para, re.S)
+ if m is None:
+ return ""
+ d = " ".join(m.group(1).split())[:450]
+ return d + ")" * max(0, d.count("(") - d.count(")"))
+
+
+def _link_text(link: Any, rule_year: int, store: Any) -> str:
+ alias = link.aliases[0] if link.aliases else ""
+ desc = ""
+ if alias and store is not None and link.alias_item_key:
+ desc = _descriptor(
+ _paragraph(store, link.alias_item_key, link.alias_p_id), alias
+ )
+ head = f"Linked code {link.target} ({link.kind}), new in the CY{rule_year} rule"
+ if alias:
+ head += f"; the proposed rule called it {alias}"
+ return f"{head}: {desc}" if desc else head
+
+
+def _telehealth_text(family: Any, rule_year: int, store: Any) -> str:
+ """The proposed rule's own last telehealth sentence of the first paragraph
+ that names a family code (or a link's placeholder) together with
+ "telehealth". Empty when there is none."""
+ names = [*family.codes, *(a for ln in family.links for a in ln.aliases)]
+ pattern = code_pattern(names)
+ items = [
+ it
+ for it in rule_items(store, [])
+ if rule_year_of(it["title"], it["date_published"]) == rule_year
+ ]
+ items = [it for it in items if it["kind"] == "proposed"] or items
+ con = store._con() # noqa: SLF001
+ for it in items:
+ for p_id, text in con.execute(
+ "SELECT p_id, text FROM fr_anchors WHERE item_key = ? "
+ "AND text LIKE '%elehealth%' ORDER BY p_id",
+ (it["item_key"],),
+ ).fetchall():
+ if codes_in(_expand_ranges(text or ""), pattern):
+ sentences = [
+ x
+ for x in re.split(r"(?<=[.!?])\s+(?=[A-Z])", " ".join(text.split()))
+ if "elehealth" in x
+ ]
+ return sentences[-1][:500]
+ return ""
+
+
+def docket_events(
+ con: Any, family: Any, rule_year: int, store: Any = None
+) -> list[EventChoice]:
+ """The family events a docket's rule touched, each with a deterministic
+ text of what CMS proposed (R61), always ending in ``other``. *store*
+ (bib) supplies the FR paragraphs behind the telehealth and link events;
+ without it only stored lineage rows are used."""
key = family.key
def ev(slug: str, text: str) -> EventChoice:
@@ -86,40 +184,66 @@ def docket_events(con: Any, family: Any, rule_year: int) -> list[EventChoice]:
None,
)
bits = []
- if ruc is not None:
+ if ruc is not None and ruc == prop:
+ bits.append(f"RUC recommended and CMS proposed {_num(ruc)}")
+ elif ruc is not None:
bits.append(f"RUC recommended {_num(ruc)}")
- if prop is not None:
+ if prop is not None and ruc != prop:
bits.append(f"CMS proposed {_num(prop)}")
- parts.append(f"{code} ({', '.join(bits)})" if bits else code)
- out.append(ev("valuation", "Work RVU valuation: " + "; ".join(parts)))
+ parts.append(f"{code} ({'; '.join(bits)})" if bits else code)
+ out.append(
+ ev(
+ "valuation",
+ f"Work RVU valuation in the CY{rule_year} rule: " + ", ".join(parts),
+ )
+ )
events = [
e for c in family.codes for e in read_events(con, c) if e.year == rule_year
]
- coding = sorted({(e.code, e.kind) for e in events if e.kind in _CODING_KINDS})
- if coding:
- out.append(
- ev(
- "coding",
- "Coding changes (CPT codes created, deleted, revised or acted on by "
- "the CPT Editorial Panel): "
- + "; ".join(f"{c} {k.replace('_', ' ')}" for c, k in coding),
+ deleted = sorted(m.code for m in family.members if m.until == rule_year - 1)
+ panel = sorted({(e.code, e.kind) for e in events if e.kind in _CODING_KINDS})
+ if deleted or panel:
+ bits = []
+ if deleted:
+ kept = sorted(
+ m.code
+ for m in family.members
+ if m.role == "base" and (m.since or 0) <= rule_year
)
- )
- tele = sorted({e.code for e in events if e.kind == "telehealth_list"})
- if tele:
- out.append(
- ev("telehealth", "Medicare telehealth services list: " + ", ".join(tele))
- )
- for link in family.links:
- if link.since == rule_year:
- out.append(
- ev(
- link.target,
- f"Linked code {link.target} ({link.kind}) introduced with "
- f"this family in CY{rule_year}",
+ bits.append(
+ f"CPT deleted {_compact(deleted)}; those services are reported with "
+ f"{_compact(kept)} from CY{rule_year}"
+ )
+ if panel:
+ kinds = sorted({k for _, k in panel})
+ bits.append(
+ "CPT Editorial Panel actions on family codes that year: "
+ + ", ".join(
+ f"{sum(1 for _, k2 in panel if k2 == k)} {k.replace('_', ' ')}"
+ for k in kinds
)
)
- out.append(ev("other", "Any other aspect of the family or its payment"))
+ out.append(ev("coding", "Coding changes: " + ". ".join(bits)))
+ tele = sorted({e.code for e in events if e.kind == "telehealth_list"})
+ para = _telehealth_text(family, rule_year, store) if store is not None else ""
+ if tele or para:
+ text = "Medicare telehealth services list"
+ if para:
+ text += f", from the proposed rule: {para}"
+ if tele:
+ text += f" (lineage: telehealth list change for {', '.join(tele)})"
+ out.append(ev("telehealth", text))
+ for link in family.links:
+ if link.since == rule_year:
+ out.append(ev(link.target, _link_text(link, rule_year, store)))
+ out.append(
+ ev(
+ "other",
+ "Any other aspect of this family's own codes "
+ f"({_compact([m.code for m in family.members if m.until is None])}), "
+ "not covered above; positions on other codes or services are not given",
+ )
+ )
return out
@@ -151,28 +275,124 @@ def stance_schema(events: Sequence[EventChoice]) -> dict:
def _system(events: Sequence[EventChoice]) -> str:
listed = "\n".join(f"- {e.event_key}: {e.text}" for e in events)
return (
- "You read one public comment on a Medicare payment rule and say what the "
- "commenter's position is on each of these events, only for events the "
- "comment actually addresses:\n"
+ "You read one public comment on a Medicare payment rule. Below are the "
+ "events of one code family that the proposed rule touched, each with what "
+ "CMS proposed:\n"
f"{listed}\n"
- "For each position give event (one of the keys above), stance (support, "
- "modify when it asks for a change, oppose, or unclear), reason (one short "
- "sentence) and evidence (an exact quote of at least six words copied from "
- "the comment, or an empty string). Give no position for an event the "
- "comment does not address."
+ "Give the commenter's position on an event only when the comment itself "
+ "takes one on that event, even in a single sentence (the commenter may use "
+ "a placeholder code or a short name for it); never guess or infer a "
+ "position. stance: support = the "
+ "commenter endorses what CMS proposed for that event as written (we "
+ "support, we agree, we appreciate, finalize as proposed); oppose = the "
+ "commenter disagrees with it (does not support, urges CMS not to finalize, "
+ "says it fails its goal); modify = the commenter asks for any change, "
+ "addition, delay, retention or clarification that goes beyond or differs "
+ "from what CMS proposed, even while welcoming the idea (we recommend, we "
+ "urge CMS to add, reconsider, clarify); unclear = the comment addresses the "
+ "event but the stance cannot be determined. If the comment does not address "
+ "an event, give no position at all for it; most comments address only one or "
+ "two events. Give at most one position per event, and never use the same "
+ "quote for two events. A comment about other codes or other specialties' services "
+ "(audiology, therapy, surgery) addresses none of these events. reason: one "
+ "short sentence. evidence: an exact quote of at least six words copied from "
+ "the comment that shows the position."
)
+def clean_text(text: str) -> str:
+ """The comment as plain text: HTML entities decoded, ``
`` to a
+ newline (regulations.gov stores inline comments with both)."""
+ return re.sub(r"
", "\n", html.unescape(text or ""))
+
+
def _words(s: str) -> list[str]:
return re.findall(r"[a-z0-9]+", (s or "").lower())
-def _quoted(evidence: str, text_words: str) -> bool:
- ws = _words(evidence)
- return any(
- f" {' '.join(ws[i : i + _EVIDENCE_WORDS])} " in text_words
- for i in range(len(ws) - _EVIDENCE_WORDS + 1)
- )
+def evidence_span(quote: str, text: str) -> str:
+ """The longest verbatim run of the comment's own words that *quote*
+ matches (as the origin dossier's longest span), at least six words and
+ at most ``_FIELD_CHARS`` characters; empty when there is none."""
+ toks = text.split()
+ flat: list[str] = []
+ owner: list[int] = []
+ for i, t in enumerate(toks):
+ for w in _words(t):
+ flat.append(w)
+ owner.append(i)
+ q = _words(quote)
+ best, end = 0, 0
+ prev = [0] * (len(q) + 1)
+ for i, w in enumerate(flat):
+ cur = [0] * (len(q) + 1)
+ for j, qw in enumerate(q):
+ if w == qw:
+ cur[j + 1] = prev[j] + 1
+ if cur[j + 1] > best:
+ best, end = cur[j + 1], i
+ prev = cur
+ if best < _EVIDENCE_WORDS:
+ return ""
+ span = " ".join(toks[owner[end - best + 1] : owner[end] + 1])
+ return span if len(span) <= _FIELD_CHARS else span[:_FIELD_CHARS].rsplit(" ", 1)[0]
+
+
+def raw_positions(
+ reply: dict | None, events: Sequence[EventChoice]
+) -> list[dict] | None:
+ """The reply's valid positions, all of them: known event, known stance.
+ This is what the ledger keeps (R61 f)."""
+ if reply is None:
+ return None
+ known = {e.event_key for e in events}
+ out = []
+ for p in reply.get("positions") or []:
+ if not isinstance(p, dict) or p.get("event") not in known:
+ continue
+ if p.get("stance") not in STANCES:
+ continue
+ out.append(
+ {
+ "event": p["event"],
+ "stance": p["stance"],
+ "reason": str(p.get("reason") or "")[:_FIELD_CHARS],
+ "evidence": str(p.get("evidence") or "")[:_FIELD_CHARS],
+ }
+ )
+ return out
+
+
+def position_rows(
+ raw: Sequence[dict],
+ text: str,
+ *,
+ family: str,
+ item_key: str,
+ docket: str,
+ extractor: str,
+) -> list[StanceRow]:
+ """The kept positions of a raw reply: evidence becomes the longest
+ verbatim span of *text* (>= 6 words) or the position is dropped, then
+ the first position per event is kept, and one span backs one position
+ only (a quote copied onto several events is kept for the first)."""
+ rows: dict[str, StanceRow] = {}
+ used: set[str] = set()
+ for p in raw:
+ span = evidence_span(p["evidence"], text)
+ if span and p["event"] not in rows and span not in used:
+ used.add(span)
+ rows[p["event"]] = StanceRow(
+ family,
+ item_key,
+ docket,
+ p["event"],
+ p["stance"],
+ p["reason"],
+ span,
+ extractor,
+ )
+ return list(rows.values())
def classify_comment(
@@ -185,87 +405,63 @@ def classify_comment(
docket: str,
) -> list[StanceRow] | None:
"""Positions of one comment, or ``None`` when the model gave no usable
- reply. A position on an unknown event or with an unknown stance is
- dropped; ``evidence`` is kept only if >= 6 consecutive words of it occur
- in *text*."""
- reply = ask(_system(events), text, stance_schema(events))
- if reply is None:
+ reply."""
+ text = clean_text(text)
+ raw = raw_positions(ask(_system(events), text, stance_schema(events)), events)
+ if raw is None:
return None
- known = {e.event_key for e in events}
- hay = f" {' '.join(_words(text))} "
extractor = f"model:{getattr(ask, 'model', 'unknown')}@{STANCE_PROMPT_VERSION}"
- rows = []
- for p in reply.get("positions") or []:
- if not isinstance(p, dict) or p.get("event") not in known:
- continue
- if p.get("stance") not in STANCES:
- continue
- evidence = str(p.get("evidence") or "")
- rows.append(
- StanceRow(
- family,
- item_key,
- docket,
- p["event"],
- p["stance"],
- str(p.get("reason") or "")[:_FIELD_CHARS],
- evidence[:_FIELD_CHARS] if _quoted(evidence, hay) else "",
- extractor,
- )
- )
- return rows
+ return position_rows(
+ raw, text, family=family, item_key=item_key, docket=docket, extractor=extractor
+ )
# ── run ──────────────────────────────────────────────────────────────
-def _as_dict(r: StanceRow) -> dict:
- return {
- "event": r.event_key,
- "stance": r.stance,
- "reason": r.reason,
- "evidence": r.evidence,
- }
-
-
def stances_for(
commenters: Sequence[CommenterRow],
texts: Mapping[str, str],
events: Sequence[EventChoice],
- ask: Callable,
+ ask: Callable | None,
log: RunLog,
*,
family: str,
docket: str,
+ model: str | None = None,
limit: int = 0,
retry_failed: bool = False,
progress: Callable[[str, str], None] | None = None,
) -> tuple[list[StanceRow], dict[str, int]]:
- """Classify each commenter's comment through the resumable ledger.
- Counts are the run counts plus ``pending`` (no ledger answer yet) and
- ``no_text`` (a commenter row whose comment text is not found)."""
- model = getattr(ask, "model", "unknown")
+ """Classify each commenter's comment through the resumable ledger, which
+ keeps the validated raw reply; the evidence and one-per-event filters run
+ here on replay. With *ask* ``None`` (``--no-llm``) only the ledger of
+ *model* is read. Counts are the run counts plus ``pending`` (no ledger
+ answer yet) and ``no_text`` (a commenter whose comment text is not found)."""
+ model = model or getattr(ask, "model", "unknown")
extractor = f"model:{model}@{STANCE_PROMPT_VERSION}"
- ev_part = [f"{e.event_key}|{e.text}" for e in events]
+ prompt = _system(events) # events and rules both key the ledger
keys = [c.item_key for c in commenters if c.item_key in texts]
- hashes = {k: input_hash(texts[k], *ev_part) for k in keys}
+ clean = {k: clean_text(texts[k]) for k in keys}
+ hashes = {k: input_hash(clean[k], prompt) for k in keys}
+ counts = {"done": 0, "skipped": 0, "failed": 0, "failed_pending": 0}
+ if ask is not None:
- def unit(k: str) -> dict | None:
- rows = classify_comment(
- texts[k], events, ask, family=family, item_key=k, docket=docket
+ def unit(k: str) -> dict | None:
+ raw = raw_positions(
+ ask(_system(events), clean[k], stance_schema(events)), events
+ )
+ return None if raw is None else {"positions": raw}
+
+ counts = run_units(
+ [(k, hashes[k], lambda k=k: unit(k)) for k in keys],
+ log,
+ model=model,
+ version=STANCE_PROMPT_VERSION,
+ limit=limit,
+ retry_failed=retry_failed,
+ progress=progress,
)
- return None if rows is None else {"positions": [_as_dict(r) for r in rows]}
-
- counts = run_units(
- [(k, hashes[k], lambda k=k: unit(k)) for k in keys],
- log,
- model=model,
- version=STANCE_PROMPT_VERSION,
- limit=limit,
- retry_failed=retry_failed,
- progress=progress,
- )
- known = {e.event_key for e in events}
rows: list[StanceRow] = []
pending = 0
for k in keys:
@@ -273,47 +469,50 @@ def stances_for(
if entry is None or entry.output is None:
pending += 1
continue
- rows += [
- StanceRow(
- family,
- k,
- docket,
- p["event"],
- p["stance"],
- p["reason"],
- p["evidence"],
- extractor,
- )
- for p in entry.output["positions"]
- if p["event"] in known
- ]
+ rows += position_rows(
+ entry.output["positions"],
+ clean[k],
+ family=family,
+ item_key=k,
+ docket=docket,
+ extractor=extractor,
+ )
return rows, {**counts, "pending": pending, "no_text": len(commenters) - len(keys)}
# ── roll-up ──────────────────────────────────────────────────────────
-def _rank(event_key: str) -> tuple[int, str]:
- slug = event_key.rsplit(":", 1)[-1]
- order = {"valuation": 0, "coding": 1, "telehealth": 2, "other": 99}
- return order.get(slug, 3), event_key
-
-
def _first_firm(stances: Sequence[str]) -> str:
return next((s for s in stances if s != "unclear"), "unclear")
-def rollup(stances: Sequence[StanceRow], *, base: ReactionRow) -> ReactionRow:
+def rollup(
+ stances: Sequence[StanceRow],
+ *,
+ base: ReactionRow,
+ order: Sequence[str] = (),
+ n_commenters: int | None = None,
+) -> ReactionRow:
"""Distinct commenters by their first non-``unclear`` position in event
order (all ``unclear`` -> ``unclear``); ``events_json`` counts distinct
- commenters per event the same way. Only our own stance rows go in."""
+ commenters per event the same way. *order* is the docket's event keys in
+ ``docket_events`` order (an event not in it sorts last, by key).
+ ``n_classified`` is the commenters with a firm position and
+ ``n_commenters`` the commenter rows of the docket (R62). Only our own
+ stance rows go in; CMS's summaries never do."""
+ rank = {k: i for i, k in enumerate(order)}
+
+ def key(e: str) -> tuple[int, str]:
+ return rank.get(e, len(rank)), e
+
by_item: dict[str, dict[str, list[str]]] = defaultdict(lambda: defaultdict(list))
for s in stances:
by_item[s.item_key][s.event_key].append(s.stance)
totals = dict.fromkeys(STANCES, 0)
per_event: dict[str, dict[str, int]] = {}
for events in by_item.values():
- ordered = sorted(events, key=_rank)
+ ordered = sorted(events, key=key)
totals[_first_firm([_first_firm(events[e]) for e in ordered])] += 1
for e in ordered:
per_event.setdefault(e, dict.fromkeys(STANCES, 0))[
@@ -326,6 +525,8 @@ def rollup(stances: Sequence[StanceRow], *, base: ReactionRow) -> ReactionRow:
stance_oppose=totals["oppose"],
stance_unclear=totals["unclear"],
events_json=json.dumps(
- dict(sorted(per_event.items(), key=lambda kv: _rank(kv[0])))
+ dict(sorted(per_event.items(), key=lambda kv: key(kv[0])))
),
+ n_classified=len(by_item) - totals["unclear"],
+ n_commenters=n_commenters,
)
diff --git a/tests/cli/test_pfs_cli.py b/tests/cli/test_pfs_cli.py
index 1ec693e..7849c2b 100644
--- a/tests/cli/test_pfs_cli.py
+++ b/tests/cli/test_pfs_cli.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import json
from pathlib import Path
from unittest.mock import MagicMock
@@ -1969,6 +1970,17 @@ class TestStance:
monkeypatch.setattr(pfs_cli, "_store", lambda: store)
monkeypatch.setattr(pfs_cli, "_engine", lambda: "ENGINE")
monkeypatch.setattr(pfs_cli, "_asker", lambda: "ASK")
+ monkeypatch.setattr(pfs_cli, "_model_name", lambda: "qwen-x")
+ from pfs.stance import EventChoice
+
+ monkeypatch.setattr(
+ pfs_cli,
+ "docket_events",
+ lambda con_, fam, year, store_: [
+ EventChoice("HOME-VISITS:2023:G0318", "g", year),
+ EventChoice("HOME-VISITS:2023:other", "o", year),
+ ],
+ )
monkeypatch.setattr(
pfs_cli, "ledger_path", lambda t, f: Path("/nonexistent") / f"{t}-{f}"
)
@@ -2040,11 +2052,21 @@ class TestStance:
rows = {r.period: r for r in read_reaction(con._real, "HOME-VISITS")}
assert set(rows) == {"D1", "D0", "R1"}
assert (rows["D1"].stance_support, rows["D1"].stance_oppose) == (1, 1)
+ assert (rows["D1"].n_classified, rows["D1"].n_commenters) == (2, 2)
+ assert (rows["D0"].n_classified, rows["D0"].n_commenters) == (None, None)
+ assert list(json.loads(rows["D1"].events_json)) == ["HOME-VISITS:2023:other"]
assert rows["D1"].n_items == 2 and rows["D1"].n_total == 9
assert (rows["D0"].stance_support, rows["R1"].stance_oppose) == (0, 0)
assert rows["D0"].events_json == "{}"
assert con.published == [True]
+ def test_no_llm_replays_the_ledger(self, con, monkeypatch):
+ seen = self._patch(con, monkeypatch)
+ monkeypatch.setattr(pfs_cli, "_asker", lambda: 1 / 0)
+ res = runner.invoke(app, self._args("--no-llm"))
+ assert res.exit_code == 0, res.output
+ assert seen["ask"] is None and seen["model"] == "qwen-x"
+
def test_write_with_no_stored_reaction_rows_uses_series(self, con, monkeypatch):
self._patch(con, monkeypatch)
res = runner.invoke(app, self._args("--write"))
@@ -2145,6 +2167,22 @@ class TestStance:
from pfs.codetables import StanceRow, write_stances
self._reaction_patch(monkeypatch)
+ from pfs.codetables import CommenterRow, write_commenters, write_reaction
+
+ write_commenters(
+ con._real,
+ [
+ CommenterRow(k, "D1", k, "", "individual", "", "", 0, "regs_gov_fields", "", "regs", "CCM")
+ for k in ("K1", "K2", "K3")
+ ],
+ ) # fmt: skip
+ write_reaction(
+ con._real,
+ "CCM",
+ [
+ ReactionRow("CCM", "D1", "docket", 2023, 5, 20, 0, 0, 0, 0, "[]", '{"CCM:2023:other": {}}')
+ ],
+ ) # fmt: skip
write_stances(
con._real,
"CCM",
@@ -2158,6 +2196,9 @@ class TestStance:
assert res.exit_code == 0, res.output
rows = {r.period: r for r in read_reaction(con._real, "CCM")}
assert rows["D1"].stance_modify == 2 and rows["D1"].n_items == 5
+ assert (rows["D1"].n_classified, rows["D1"].n_commenters) == (2, 3)
+ assert (rows["D2"].n_classified, rows["D2"].n_commenters) == (None, None)
+ assert list(json.loads(rows["D1"].events_json)) == ["CCM:2023:other"]
assert rows["D2"].stance_modify == 0 and rows["D2"].events_json == "{}"
def test_stance_sample_refused_when_stances_stored(self, con, monkeypatch):
diff --git a/tests/pfs/test_curated.py b/tests/pfs/test_curated.py
index cf83f65..b66e16e 100644
--- a/tests/pfs/test_curated.py
+++ b/tests/pfs/test_curated.py
@@ -194,3 +194,39 @@ def test_home_visits_pair_phrases_are_visit_naming():
"home visits" not in fam.pair_phrases and "domiciliary" not in fam.pair_phrases
)
assert "house call" in fam.pair_phrases
+
+
+def test_link_aliases_and_their_cite():
+ fam = load_curated()["HOME-VISITS"]
+ g = next(link for link in fam.links if link.target == "G0318")
+ assert (g.aliases, g.alias_item_key, g.alias_p_id) == (("GXXX3",), "MZ24MX5S", 880)
+ b = next(link for link in fam.links if link.target == "B1")
+ assert (b.aliases, b.alias_item_key, b.alias_p_id) == ((), "", 0)
+
+
+def test_alias_parsing_and_errors(tmp_path):
+ head = "F:\n name: F\n links:\n - {kind: add-on, target: G1, since: 2023, "
+ ok = (
+ head
+ + "aliases: [' gxxx3 ', ''], alias_cite: {item_key: K, p_id: 5}, cite: {item_key: K, p_id: 1}}\n"
+ )
+ link = load_curated(_write(tmp_path, ok))["F"].links[0]
+ assert link.aliases == ("GXXX3",) and link.alias_p_id == 5
+ for bad, needle in [
+ (
+ head + "aliases: GXXX3, cite: {item_key: K, p_id: 1}}\n",
+ "aliases must be a list",
+ ),
+ (
+ head + "aliases: [1], cite: {item_key: K, p_id: 1}}\n",
+ "aliases must be a list",
+ ),
+ (head + "aliases: [X], cite: {item_key: K, p_id: 1}}\n", "alias_cite"),
+ (
+ head
+ + "aliases: [X], alias_cite: {item_key: K, page: 1}, cite: {item_key: K, p_id: 1}}\n",
+ "alias_cite",
+ ),
+ ]:
+ with pytest.raises(ValueError, match=needle):
+ load_curated(_write(tmp_path, bad))
diff --git a/tests/pfs/test_reaction.py b/tests/pfs/test_reaction.py
index db7ef21..412c8e3 100644
--- a/tests/pfs/test_reaction.py
+++ b/tests/pfs/test_reaction.py
@@ -394,9 +394,15 @@ class TestEventsJson:
ev = json.dumps(
{"F:2023:other": {"support": 1, "modify": 0, "oppose": 0, "unclear": 0}}
)
- row = ReactionRow("CCM", "D1", "docket", 2023, 5, 20, 1, 0, 0, 0, "[]", ev)
+ row = ReactionRow(
+ "CCM", "D1", "docket", 2023, 5, 20, 1, 0, 0, 0, "[]", ev, 4, 9
+ )
write_reaction(con, "CCM", [row])
assert read_reaction(con, "CCM") == [row]
+ never = ReactionRow("CCM", "D2", "docket", 2024, 1, 2, 0, 0, 0, 0, "[]")
+ assert (never.n_classified, never.n_commenters) == (None, None)
+ write_reaction(con, "CCM", [row, never])
+ assert read_reaction(con, "CCM")[1].n_classified is None
assert (
ReactionRow("CCM", "D", "docket", 1, 0, 0, 0, 0, 0, 0, "[]").events_json
== "{}"
@@ -408,6 +414,7 @@ class TestEventsJson:
"sample_json) VALUES ('OLD', 'D0', 'docket', 2020, 1, 2, 0, 0, 0, 0, '[]')"
)
assert read_reaction(con, "OLD")[0].events_json == "{}"
+ assert read_reaction(con, "OLD")[0].n_commenters is None
def test_ensure_tables_adds_the_column_to_an_old_table(self):
c = duckdb.connect(":memory:")
@@ -421,8 +428,15 @@ class TestEventsJson:
c.execute(
"INSERT INTO pfs.code_reaction VALUES ('A','D','docket',1,1,1,0,0,0,0,'[]')"
)
+ short = read_reaction(c, "A")[0] # an old replica: rows without the columns
+ assert (short.events_json, short.n_classified) == ("{}", None)
ensure_tables(c)
- assert read_reaction(c, "A")[0].events_json == "{}"
+ got = read_reaction(c, "A")[0]
+ assert (got.events_json, got.n_classified, got.n_commenters) == (
+ "{}",
+ None,
+ None,
+ )
class TestFrPairSpansRefactor:
diff --git a/tests/pfs/test_stance.py b/tests/pfs/test_stance.py
index 938d113..87da1be 100644
--- a/tests/pfs/test_stance.py
+++ b/tests/pfs/test_stance.py
@@ -4,11 +4,12 @@ from __future__ import annotations
import dataclasses
import json
-from types import SimpleNamespace
import duckdb
import pytest
+from bib.item import Rule
+from bib.store import Store
from pfs.codetables import (
CmsSummaryRow,
CommenterRow,
@@ -23,14 +24,18 @@ from pfs.codetables import (
write_rounds,
write_stances,
)
-from pfs.curated import CuratedLink
+from pfs.curated import load_curated
from pfs.runlog import RunLog
from pfs.stance import (
STANCE_PROMPT_VERSION,
EventChoice,
classify_comment,
+ clean_text,
docket_events,
docket_rule_year,
+ evidence_span,
+ position_rows,
+ raw_positions,
rollup,
stance_schema,
stances_for,
@@ -44,14 +49,7 @@ TEXT = (
)
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 = SimpleNamespace(
- key="HOME-VISITS",
- codes=("99341", "99342"),
- links=(
- CuratedLink("prolonged-add-on", "G0318", 2023, None, "PETM225D", 1694, 0, 0),
- CuratedLink("add-on", "G2211", 2026, None, "NHRGIHGD", 1469, 0, 0),
- ),
-)
+FAM = load_curated()["HOME-VISITS"]
def _round(code="99342", year=2023):
@@ -120,25 +118,90 @@ def test_docket_rule_year_matches_rag():
# ── 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."
+)
+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 additional 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 test_docket_events_cy2023_home_visits(con):
+
+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.", 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")])
- got = docket_events(con, FAM, 2023)
+ fam = _fam(key)
+ got = docket_events(con, fam, 2023, store)
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 = got[0].text
- assert "99342" in val and "RUC" in val and "2.00" in val and "1.90" in val
- assert "2008" not in val
- assert docket_events(con, FAM, 2023) == got # deterministic
+ 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 tele.endswith(
+ "GXXX3 to the Medicare Telehealth Services List on a Category 1 basis."
+ )
+ assert "99356" not in tele and "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 g.endswith("home or residence evaluation and management services)")
+ assert "99341" in other and "other codes or services are not given" in other
+ assert docket_events(con, fam, 2023, store) == got # deterministic
-def test_docket_events_telehealth_and_bare_year(con):
+def test_docket_events_without_store_uses_lineage_only(con):
write_events(
con,
"99341",
@@ -150,19 +213,75 @@ def test_docket_events_telehealth_and_bare_year(con):
"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)
+ 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)) == 1
+ assert len(docket_events(con, FAM, 2030, s2)) == 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 = _round()
r = dataclasses.replace(
- r, ruc_work_rvu=None, proposed_work_rvu=None, final_work_rvu=None
+ _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 "99342" in val.text and "RUC" not in val.text
+ 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):
@@ -177,7 +296,7 @@ def test_stance_schema(events):
]
assert item["properties"]["reason"]["maxLength"] == 200
assert item["properties"]["evidence"]["maxLength"] == 200
- assert STANCE_PROMPT_VERSION == "stance-v1"
+ assert STANCE_PROMPT_VERSION == "stance-v2"
# ── classify_comment ─────────────────────────────────────────────────
@@ -190,11 +309,20 @@ def _classify(positions, text=TEXT, events=None, ask=None):
)
-def test_prompt_lists_events_and_comment(events):
+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" in system and "New add-on G0318" in system
+ assert "HOME-VISITS:2023:G0318: New add-on G0318" in system
+ for rule in (
+ "support = the commenter endorses what CMS proposed",
+ "modify = the commenter asks for any change",
+ "oppose = the commenter disagrees",
+ "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)
@@ -216,26 +344,29 @@ def test_rows_carry_fields(events):
"HOME-VISITS:2023:valuation",
"support",
"likes increase",
- EV_VAL,
- "model:qwen-test@stance-v1",
+ "We support the proposed increase in work RVUs for CPT code 99342",
+ "model:qwen-test@stance-v2",
)
]
def test_unknown_event_in_reply_dropped(events):
rows = _classify(
- [pos("HOME-VISITS:2023:nope", "support"), pos(events[3].event_key, "oppose")],
+ [
+ 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"), "junk"])
+ 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(events):
+def test_evidence_must_occur_in_text_else_position_dropped(events):
rows = _classify(
[
pos(events[0].event_key, "support", "r", EV_VAL),
@@ -245,19 +376,109 @@ def test_evidence_must_occur_in_text(events):
],
events=events,
) # fmt: skip
- assert [r.evidence for r in rows] == [EV_VAL, "", "", ""]
- assert [r.stance for r in rows] == ["support", "oppose", "modify", "unclear"]
+ 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 == ev
+ assert rows[0].evidence.startswith("We support the proposed increase in work RVUs")
-def test_reason_and_evidence_truncated(events):
- rows = _classify([pos(events[0].event_key, "support", "x" * 500)], events=events)
+def test_clean_text_decodes_html():
+ assert (
+ clean_text("people's "x"
next
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) ─────────────────────────────────────────────
@@ -272,59 +493,68 @@ def _commenter(key, cid=None, org="individual"):
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,
+ commenters, texts, events, ask, log, family="HOME-VISITS", docket="D1", **kw
)
return out, log
-def test_stances_for_rows_from_ledger(tmp_path, events):
+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), log = _run(tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events)
+ (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.reason, r.evidence) for r in rows] == [
- (events[0].event_key, "likes it", EV_VAL),
- (events[1].event_key, "bad", ""),
+ 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}"
- # a second run replays the ledger without asking again
- (again, _), _ = _run(tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events)
- assert again == rows and len(ask.calls) == 1
+ 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")])
- (rows, counts), log = _run(tmp_path, ask, cs, texts, events, limit=2)
+ 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
- # resume: the two done are skipped, the rest run
(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
- # a failed reply is logged, stays pending-failed until --retry-failed
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")])
+ 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
)
@@ -332,7 +562,7 @@ def test_stances_for_resume_limit_failed(tmp_path, events):
def test_stances_for_event_change_reruns(tmp_path, events):
- ask = Ask([pos(events[3].event_key, "support")])
+ 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)]
@@ -342,9 +572,8 @@ def test_stances_for_event_change_reruns(tmp_path, events):
def test_stances_for_progress(tmp_path, events):
seen = []
- ask = Ask([])
_run(
- tmp_path, ask, [_commenter("K1")], {"K1": TEXT}, events,
+ tmp_path, Ask([]), [_commenter("K1")], {"K1": TEXT}, events,
progress=lambda u, s: seen.append((u, s)),
) # fmt: skip
assert seen == [("K1", "done")]
@@ -379,38 +608,60 @@ def _base(**kw):
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():
- v, c, g, o = (
- f"HOME-VISITS:2023:{s}" for s in ("valuation", "coding", "G0318", "other")
- )
rows = [
# K1: valuation unclear, coding support -> first non-unclear = support
- S("K1", v, "unclear"), S("K1", c, "support"), S("K1", g, "oppose"),
- # K2: G0318 comes before other in event order, so modify wins
- S("K2", o, "oppose"), S("K2", o, "support"), S("K2", g, "modify"),
+ 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"),
+ S("K3", V, "unclear"), S("K3", O, "unclear"),
# K4: same stance on several events counts once
- S("K4", v, "oppose"), S("K4", g, "oppose"),
+ S("K4", V, "oppose"), S("K4", G, "oppose"),
] # fmt: skip
- got = rollup(rows, base=_base())
+ 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 ev[v] == {"support": 0, "modify": 0, "oppose": 1, "unclear": 2}
- assert ev[c] == {"support": 1, "modify": 0, "oppose": 0, "unclear": 0}
- assert ev[g] == {"support": 0, "modify": 1, "oppose": 2, "unclear": 0}
- assert ev[o] == {"support": 0, "modify": 0, "oppose": 1, "unclear": 1}
+ 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))
+ 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):
@@ -420,6 +671,5 @@ def test_rollup_ignores_cms_summaries(con):
[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")]
- assert rollup(rows, base=_base()) == rollup(rows, base=_base())
got = rollup(rows, base=_base())
assert (got.stance_support, got.stance_oppose) == (0, 1)