fix(pfs): stance-v2 — event texts state what CMS proposed, telehealth event, one verbatim span per position, raw ledger, reaction denominators

This commit is contained in:
kert
2026-11-12 13:26:43 -05:00
parent ba0fac3ad9
commit d4ec1ffc73
9 changed files with 819 additions and 213 deletions

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -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 ())

View File

@@ -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}}

View File

@@ -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, ``<br/>`` to a
newline (regulations.gov stores inline comments with both)."""
return re.sub(r"<br\s*/?>", "\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,
)

View File

@@ -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):

View File

@@ -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))

View File

@@ -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:

View File

@@ -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&#39;s &quot;x&quot;<br/>next<br />end")
== 'people\'s "x"\nnext\nend'
)
assert clean_text(None) == ""
rows = _classify(
[
pos(
"HOME-VISITS:2023:other",
"support",
"r",
"this is what the people's own words say here",
)
],
text="this is what the people&#39;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)