merge: P48 LLM chat code valuation — RVUs, CFs and national payments beside the references (refs #682)
This commit is contained in:
@@ -679,10 +679,13 @@ services:
|
|||||||
- storage
|
- storage
|
||||||
- data
|
- data
|
||||||
- observability
|
- observability
|
||||||
|
volumes:
|
||||||
|
- ./data/replica:/app/data/replica:ro # DuckDB read replica only (directory mount survives replica re-publish)
|
||||||
environment:
|
environment:
|
||||||
- LLM_OLLAMA_HOSTS=${LLM_OLLAMA_HOSTS_IN_CONTAINER:-http://ollama:11434}
|
- LLM_OLLAMA_HOSTS=${LLM_OLLAMA_HOSTS_IN_CONTAINER:-http://ollama:11434}
|
||||||
- LLM_PG_HOST=postgres
|
- LLM_PG_HOST=postgres
|
||||||
- LLM_DB_PASSWORD=${LLM_DB_PASSWORD}
|
- LLM_DB_PASSWORD=${LLM_DB_PASSWORD}
|
||||||
|
- LLM_DUCKDB_REPLICA=/app/data/replica/aco.ro.duckdb
|
||||||
- OTEL_SERVICE_NAME=llm
|
- OTEL_SERVICE_NAME=llm
|
||||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ with duckdb_batch("aco") as con: # retries, then names the lock holder
|
|||||||
## Notebooks read a replica, not the primary
|
## Notebooks read a replica, not the primary
|
||||||
|
|
||||||
Read-only opens through `conf.connect.duckdb()` resolve to the snapshot
|
Read-only opens through `conf.connect.duckdb()` resolve to the snapshot
|
||||||
`data/aco.ro.duckdb` when it exists — so notebook kernels never hold the
|
`data/replica/aco.ro.duckdb` when it exists — so notebook kernels never hold the
|
||||||
primary's lock at all, and ingests stop caring how many notebooks are open.
|
primary's lock at all, and ingests stop caring how many notebooks are open.
|
||||||
|
|
||||||
- **Refresh cadence**: every ingest republishes the snapshot as its last step
|
- **Refresh cadence**: every ingest republishes the snapshot as its last step
|
||||||
|
|||||||
1253
docs/superpowers/plans/2026-09-08-llm-chat-code-valuation.md
Normal file
1253
docs/superpowers/plans/2026-09-08-llm-chat-code-valuation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
# llm chat — code valuation evidence: RVUs and payments beside the references
|
||||||
|
|
||||||
|
Tracker: new milestone (P48) with one issue per component; refs #571 (grounded citations), P34 (LLM query).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
When a chat question names a HCPCS/CPT code or a registered code family (e.g. "APCM"), the answer keeps citing retrieved sources exactly as today **and** presents the codes' valuation: status, RVU components and totals, the conversion factor, and the national unadjusted payment (non-facility and facility) for each recent final year plus the latest proposed rule, each row labelled with a citable source. The numbers reach the user twice: woven into the prose with bracketed citations, and as a real table under the answer with provenance links. Rule paragraphs that literally mention the codes are added to the sources so the answer can cite where CMS valued them.
|
||||||
|
|
||||||
|
Success criteria (live, after rollout):
|
||||||
|
- "What is APCM and how is it valued?" streams a `valuation` event with G0556/G0557/G0558 × {CY2025 final, CY2026 final, CY2027 proposed} (9 rows), the prose cites `[PFS CY2026 Addendum B]`-style labels for every number it states, and the sources include at least one FR paragraph mentioning G0556.
|
||||||
|
- A control question with no codes produces byte-identical event sequence and prompt to today (token… → sources → done; no valuation block).
|
||||||
|
- Evidence adds < 200 ms to a chat (one DuckDB read on the replica, one pgvector SQL).
|
||||||
|
|
||||||
|
## Current state (measured 2026-09-08)
|
||||||
|
|
||||||
|
- Chat: `POST /chat {question, since}` → SSE `token`* → `sources` → `done` (`llm/rag.py:126-161`, `llm/api.py:230-252`). Retrieval merges comments/rules/corpus (`rag.py:47-106`), prompt = system + one user message with `[label] (kind, date) snippet` lines (`rag.py:109-123`). UI renders plain text with `[…]` highlighted and a sources drawer (`llm/web/chat.html:166-223`). No tool calling anywhere in `src/llm` (raw Ollama `/api/chat`).
|
||||||
|
- Valuation data already in `data/aco.duckdb` (read replica `data/aco.ro.duckdb`): `pfs.rvu` 2015–2026 (`hcpcs, mod, description, status_code, work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu, non_fac_total, fac_total, conv_factor, year`, …), `pfs.rvu_proposed` partitioned by `cms_rule_id` (CMS-1832-P = CY2026 NPRM, CMS-1848-P = CY2027 NPRM), `pfs.gpci`, `terminology.hcpcs_level_2` (short/long descriptors). Conversion factors in `pfs.rules.RULES[year].conversion_factor` and `proposed_for(year)` (pure pydantic). Payment formula in `pfs.calcs.payment` (needs narwhals — not used here; national unadjusted payment is `total × CF`).
|
||||||
|
- The only code-family registry lives in `notebooks/palliative_care_rfi.py:430-436` (`ACP, CCM, PCM, TCM, APCM`). No `code:` tag namespace; no code→rule crosswalk; `fr_anchors.text` is the only code→paragraph path in bib.
|
||||||
|
- The `llm` compose service mounts nothing from `data/` and the image lacks `narwhals`; `duckdb` is installed. pgvector chunk metadata carries no code list.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Deterministic detection**, no model tool call: explicit codes + registered family names/synonyms in the question trigger the lookup. A single family code expands to the whole family.
|
||||||
|
2. **Rows = RVUs + national unadjusted payment by vintage**: last `valuation_years` final years the code exists (default 4) plus the newest proposed rule, flagged. No locality pricing, no OPPS (out of scope).
|
||||||
|
3. **Data access = read-only bind mount of `./data`** into the `llm` container; plain DuckDB SQL on the replica; CF from `pfs.rules`. No DuckLake, no narwhals, no Postgres copy.
|
||||||
|
4. **Code-cited rule paragraphs come from pgvector**, via a `codes` metadata list stamped on every chunk at index time (rules re-indexed once with `--force`); the chat never opens bib.
|
||||||
|
5. **Presentation = prose + table**: a `valuation` SSE event before the first token; the prompt gets a "Valuation" block whose rows carry bracketed vintage labels; the system prompt forbids computing beyond the rows.
|
||||||
|
6. Request schema unchanged (no per-request code/year overrides — YAGNI).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
question ─► detect_codes (pfs/families) ─► codes? ──no──► retrieve → build_messages → generate (unchanged)
|
||||||
|
│yes
|
||||||
|
▼
|
||||||
|
valuation(con, codes, years) ── DuckDB replica (ro) + pfs.rules CF
|
||||||
|
code_cited_sources(codes) ── pgvector: chunks whose metadata.codes ∋ code (rules)
|
||||||
|
│
|
||||||
|
retrieve() sources ⊕ code-cited sources (dedupe by item_key/p_id)
|
||||||
|
│
|
||||||
|
build_messages(question, sources, evidence=ValuationEvidence)
|
||||||
|
│
|
||||||
|
SSE: {"type":"valuation", codes, rows, provenance} → token* → sources → done
|
||||||
|
│
|
||||||
|
chat.html: renderValuation() table + provenance links; prose cites [labels]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### `pfs/families.py` (new, pure)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Family:
|
||||||
|
key: str # "APCM"
|
||||||
|
name: str # "Advanced Primary Care Management"
|
||||||
|
codes: tuple[str, ...] # ("G0556", "G0557", "G0558")
|
||||||
|
synonyms: tuple[str, ...] # ("apcm", "advanced primary care management", …)
|
||||||
|
|
||||||
|
FAMILIES: dict[str, Family] # ACP, CCM, PCM, TCM, APCM (from the notebook, with names/synonyms)
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Detection:
|
||||||
|
codes: tuple[str, ...] # sorted, unique; explicit + family-expanded
|
||||||
|
families: tuple[str, ...] # family keys matched (by name/synonym or by any member code)
|
||||||
|
explicit: tuple[str, ...] # codes literally present in the question
|
||||||
|
|
||||||
|
def detect_codes(text: str) -> Detection
|
||||||
|
```
|
||||||
|
Regexes: HCPCS `\b[A-Z]\d{4}\b`, CPT `\b\d{5}\b` (excluding obvious years/FR page numbers is not attempted — validation happens at lookup: unknown codes are dropped and reported in the evidence as "not priced"). Synonym matching is case-insensitive on word boundaries. Mentioning any member code adds its family's other codes.
|
||||||
|
|
||||||
|
### `pfs/valuation.py` (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValuationRow:
|
||||||
|
code: str; description: str; vintage: str # "CY2026 final" | "CY2027 proposed"
|
||||||
|
year: int; proposed: bool; status: str
|
||||||
|
work: float; pe_nf: float; pe_f: float; mp: float
|
||||||
|
total_nf: float; total_f: float
|
||||||
|
cf: float; pay_nf: float; pay_f: float # total × cf, rounded to cents
|
||||||
|
label: str # "[PFS CY2026 Addendum B]" / "[CY2027 NPRM Addendum B]"
|
||||||
|
citation: str; url: str # FR cite text + federalregister.gov citation URL
|
||||||
|
|
||||||
|
def valuation(con, codes: Sequence[str], *, years: int = 4) -> tuple[list[ValuationRow], list[str]]
|
||||||
|
# returns rows (code, then vintage ascending) and the codes with no rows
|
||||||
|
```
|
||||||
|
SQL: `pfs.rvu` base row per (hcpcs, year) — `mod IS NULL OR mod = ''`, `QUALIFY row_number() OVER (PARTITION BY hcpcs, year ORDER BY mod NULLS FIRST) = 1` — for the last `years` distinct years the code appears; `pfs.rvu_proposed` rows for the newest `cms_rule_id` in `pfs.nprm.NPRM_SOURCES`. CF: `RULES[year].conversion_factor` (final) / `proposed_for(year).conversion_factor` (proposed); the rvu table's own `conv_factor` is ignored (per its docstring). Citation: `RULES[year].federal_register_citation` / `NPRM_SOURCES` fr_citation; URL `https://www.federalregister.gov/citation/<vol>-FR-<page>`. Description = `pfs.rvu.description` (short descriptor).
|
||||||
|
|
||||||
|
### `llm/evidence.py` (new)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValuationEvidence:
|
||||||
|
codes: tuple[str, ...]; families: tuple[str, ...]
|
||||||
|
rows: tuple[ValuationRow, ...]; unpriced: tuple[str, ...]
|
||||||
|
def prompt_block(self) -> str # "Valuation (authoritative for RVUs/payments):\n[label] CODE desc — status S; work w, PE nf/f, MP m, total nf/f, CF c → payment nf $x / f $y"
|
||||||
|
def payload(self) -> dict # for the SSE event: codes, families, rows (dicts), provenance [{label, citation, url}], unpriced
|
||||||
|
|
||||||
|
def valuation_evidence(question: str, cfg: LlmConfig) -> ValuationEvidence | None
|
||||||
|
# detect → None if no codes; else open duckdb.connect(cfg.duckdb_replica, read_only=True) → valuation(...)
|
||||||
|
|
||||||
|
def code_cited_sources(engine, codes, *, per_code: int, collection="rules") -> list[dict]
|
||||||
|
# SELECT document, cmetadata FROM langchain_pg_embedding e JOIN langchain_pg_collection c … WHERE c.name=:col AND cmetadata->'codes' ?| :codes ORDER BY cmetadata->>'date' DESC — cap per code; build sources via llm.links.for_source with a Hit-like shape; score = 0.0 (they are additive, not ranked)
|
||||||
|
```
|
||||||
|
Failure handling: a missing/unopenable replica logs a warning and returns `None` (chat proceeds without evidence); pgvector errors on the code-cited query are caught the same way.
|
||||||
|
|
||||||
|
### `llm/chunk.py` — `codes` metadata
|
||||||
|
|
||||||
|
`chunk_doc` stamps every chunk with `"codes": "G0556 G0557"` (space-joined, sorted, unique; empty string when none) derived from the chunk text with the same regexes as `detect_codes`. Stored as a string, not a JSON array, to match the all-string `metadata: dict[str, str]` convention; the pgvector query uses `string_to_array(cmetadata->>'codes',' ') && :codes`.
|
||||||
|
|
||||||
|
### `llm/rag.py`
|
||||||
|
|
||||||
|
- `_SYSTEM` appends: "A **Valuation** section may follow the excerpts. Its rows are authoritative for RVUs, conversion factors and payment amounts; when you state any of those numbers cite the row's bracketed label exactly (e.g. `[PFS CY2026 Addendum B]`). Do not compute, extrapolate or convert numbers beyond what the rows show; if a code is listed as not priced, say so."
|
||||||
|
- `build_messages(question, sources, evidence: ValuationEvidence | None = None)`: when given, the user message becomes `Excerpts:\n\n{context}\n\n{evidence.prompt_block()}\n\nQuestion: {question}`.
|
||||||
|
- `stream_answer(...)`: after `retrieve`, `ev = valuation_evidence(question, cfg)`; if `ev`, `sources = merge(sources, code_cited_sources(...))` (dedupe on `(item_key, p_id)` / label; retrieved hits keep their rank, code-cited ones append) and yield `{"type": "valuation", **ev.payload()}` before generation. Event order becomes: valuation? → token* → sources → done.
|
||||||
|
|
||||||
|
### `llm/config.py` + `stack.toml`
|
||||||
|
|
||||||
|
`[llm] duckdb_replica = "data/aco.ro.duckdb"` (env `LLM_DUCKDB_REPLICA` overrides; the container sets it to `/app/data/aco.ro.duckdb`), `valuation_years = 4`, `code_cited_per_code = 3`. `LlmConfig` gains the three fields; `load()` reads them.
|
||||||
|
|
||||||
|
### `llm/web/chat.html`
|
||||||
|
|
||||||
|
Branch `ev.type === "valuation"` → `renderValuation(ev)`: a `<table class="valuation">` (Code, Description, Vintage, Status, Work, PE NF, PE F, MP, Total NF, Total F, CF, Pay NF, Pay F), rows grouped by code, proposed rows visually flagged; below it one provenance line per vintage: `[label] — citation` linking `url`; an "not priced: …" note when applicable. Placed between the answer and the sources drawer. Rendered as soon as the event arrives (before tokens).
|
||||||
|
|
||||||
|
### `compose.yml` + image
|
||||||
|
|
||||||
|
`llm` service: `volumes: ["./data:/app/data:ro"]` and `environment: LLM_DUCKDB_REPLICA=/app/data/aco.ro.duckdb`. No dependency changes (duckdb already in the `llm` extra). Rebuild via COMMIT_SHA bump.
|
||||||
|
|
||||||
|
## Data flow for one question
|
||||||
|
|
||||||
|
"What is APCM and how is it valued?" → `detect_codes` → family APCM → codes (G0556, G0557, G0558) → `valuation` → 9 rows (2025 final, 2026 final, 2027 proposed; CF 32.3465 / 33.4009 / 32.8409) → `code_cited_sources` → e.g. 3 CY2025 final-rule paragraphs + 3 CY2027 NPRM paragraphs mentioning G0556 → SSE `valuation` event → prompt with excerpts + Valuation block → model answer cites `[PFS CY2026 Addendum B]` for "$16.37 non-facility" etc. and `[89 FR 97710 ¶123]` for the policy → `sources` event lists retrieved + code-cited paragraphs.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- No codes detected → identical behaviour to today (no event, unchanged prompt).
|
||||||
|
- Codes detected but none priced (typo, unpriced code) → evidence with empty rows and `unpriced` list; prompt block says "No valuation rows for: X"; event still emitted so the UI can show the note.
|
||||||
|
- Replica missing/unreadable, or pgvector query failure → warning log, evidence dropped, chat continues.
|
||||||
|
- Rows with NULL RVU components (status codes like `I`/`N`) are shown with blanks and the status; payment left blank when total is NULL.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Pure units (TDD): `detect_codes` (explicit HCPCS/CPT, synonyms, case/word boundaries, family expansion from one member, dedupe/order, no false trigger on "99" or years), `valuation` against a tiny in-memory DuckDB fixture (base-row selection with modifiers present, last-N years, proposed partition, CF attachment, unpriced codes, NULL components), `ValuationEvidence.prompt_block/payload` exact text, `chunk_doc` `codes` stamping, `code_cited_sources` SQL + source shape (mocked engine), `build_messages` with and without evidence (extend `tests/llm/test_rag.py::TestBuildMessages`), `stream_answer` event order with/without evidence (extend `test_yields_tokens_then_sources_then_done`), `LlmConfig` fields, API test with a codes question (patched evidence), and a chat.html assertion that `renderValuation` exists. Acceptance on live data: the two success-criteria probes via the compose-net headless probe.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Merge; `stack llm index --collection rules --force` (80 rules, ~15 min on the pool) to stamp `codes`.
|
||||||
|
2. compose: mount + env; COMMIT_SHA bump; `docker compose build llm && up -d llm`.
|
||||||
|
3. Live probes (APCM question + control) recorded on the tracker; screenshot of the table.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Locality-adjusted pricing (GPCI) and OPPS rates; model tool calling; per-request year/code overrides; retroactive `codes` stamping of the 196k comment chunks (they pick it up on change or `--force`); a `code:` bib tag namespace.
|
||||||
@@ -30,8 +30,10 @@ from conf import ROOT, cfg, path
|
|||||||
|
|
||||||
|
|
||||||
def _replica_path(name: str) -> Any:
|
def _replica_path(name: str) -> Any:
|
||||||
"""data/<name>.ro.duckdb next to the primary."""
|
"""data/replica/<name>.ro.duckdb — its own directory, so a container
|
||||||
return path(f"db.{name}").with_suffix(".ro.duckdb")
|
can mount the snapshots without seeing the primary databases."""
|
||||||
|
primary = path(f"db.{name}")
|
||||||
|
return primary.parent / "replica" / f"{primary.stem}.ro.duckdb"
|
||||||
|
|
||||||
|
|
||||||
def duckdb(
|
def duckdb(
|
||||||
@@ -46,7 +48,8 @@ def duckdb(
|
|||||||
read_only
|
read_only
|
||||||
Open in read-only mode (default ``True``).
|
Open in read-only mode (default ``True``).
|
||||||
replica
|
replica
|
||||||
Read-only opens resolve to the ``<name>.ro.duckdb`` snapshot
|
Read-only opens resolve to the ``replica/<name>.ro.duckdb``
|
||||||
|
snapshot
|
||||||
when it exists — long-running readers (notebook kernels) then
|
when it exists — long-running readers (notebook kernels) then
|
||||||
never hold the primary's single-writer lock, so ingests stop
|
never hold the primary's single-writer lock, so ingests stop
|
||||||
failing (#510). Staleness is bounded by the refresh cadence:
|
failing (#510). Staleness is bounded by the refresh cadence:
|
||||||
@@ -136,7 +139,7 @@ def duckdb_batch(
|
|||||||
|
|
||||||
|
|
||||||
def publish_replica(name: str = "aco") -> Any:
|
def publish_replica(name: str = "aco") -> Any:
|
||||||
"""Publish/refresh the read-only snapshot ``<name>.ro.duckdb``.
|
"""Publish/refresh the read-only snapshot ``replica/<name>.ro.duckdb``.
|
||||||
|
|
||||||
Called after each ingest so notebook readers (see :func:`duckdb`)
|
Called after each ingest so notebook readers (see :func:`duckdb`)
|
||||||
work from a lock-free snapshot instead of contending with writers
|
work from a lock-free snapshot instead of contending with writers
|
||||||
@@ -152,7 +155,9 @@ def publish_replica(name: str = "aco") -> Any:
|
|||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
src = path(f"db.{name}")
|
src = path(f"db.{name}")
|
||||||
dst = _replica_path(name).resolve()
|
dst = _replica_path(name)
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst = dst.resolve()
|
||||||
tmp = dst.with_suffix(".tmp")
|
tmp = dst.with_suffix(".tmp")
|
||||||
with duckdb_batch(name) as con:
|
with duckdb_batch(name) as con:
|
||||||
con.execute("CHECKPOINT")
|
con.execute("CHECKPOINT")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import hashlib
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from pfs.families import find_codes
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Paragraph:
|
class Paragraph:
|
||||||
@@ -143,6 +145,10 @@ def chunk_doc(
|
|||||||
"item_key": doc.key,
|
"item_key": doc.key,
|
||||||
"seq": str(seq),
|
"seq": str(seq),
|
||||||
"section": heading,
|
"section": heading,
|
||||||
|
# The heading is scanned with the piece: a section titled
|
||||||
|
# "## G0556 — APCM" is about G0556 all the way down, but
|
||||||
|
# only its first chunk repeats the code.
|
||||||
|
"codes": " ".join(find_codes(f"{heading} {piece}")),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for seq, (heading, piece) in enumerate(pieces)
|
for seq, (heading, piece) in enumerate(pieces)
|
||||||
@@ -184,6 +190,7 @@ def _chunk_paragraphs(doc: Doc, target: int, overlap: int) -> list[Chunk]:
|
|||||||
"item_key": doc.key,
|
"item_key": doc.key,
|
||||||
"seq": str(seq),
|
"seq": str(seq),
|
||||||
"section": "",
|
"section": "",
|
||||||
|
"codes": " ".join(find_codes(text)),
|
||||||
"p_id": str(first.p_id),
|
"p_id": str(first.p_id),
|
||||||
"p_id_last": str(last.p_id),
|
"p_id_last": str(last.p_id),
|
||||||
"page": str(first.page),
|
"page": str(first.page),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Env contract:
|
|||||||
LLM_PG_HOST beats [llm].pg_host (containers set this to "postgres")
|
LLM_PG_HOST beats [llm].pg_host (containers set this to "postgres")
|
||||||
LLM_DB_PASSWORD required for pg_url(); lives in .env, never stack.toml
|
LLM_DB_PASSWORD required for pg_url(); lives in .env, never stack.toml
|
||||||
LLM_BUILD_ANN_INDEX beats [llm].build_ann_index ("1"/"true"/"yes" = True)
|
LLM_BUILD_ANN_INDEX beats [llm].build_ann_index ("1"/"true"/"yes" = True)
|
||||||
|
LLM_DUCKDB_REPLICA beats [llm].duckdb_replica (containers set /app/data/replica/aco.ro.duckdb)
|
||||||
|
|
||||||
Ollama has no GPU-size API, so VRAM is *declared* per host; liveness is
|
Ollama has no GPU-size API, so VRAM is *declared* per host; liveness is
|
||||||
probed at request time (``HostPool.check``). Together they implement
|
probed at request time (``HostPool.check``). Together they implement
|
||||||
@@ -43,6 +44,9 @@ class LlmConfig:
|
|||||||
default_factory=lambda: dict(_DEFAULT_K_PER_KIND)
|
default_factory=lambda: dict(_DEFAULT_K_PER_KIND)
|
||||||
)
|
)
|
||||||
top_n: int = 8
|
top_n: int = 8
|
||||||
|
duckdb_replica: str = "data/replica/aco.ro.duckdb"
|
||||||
|
valuation_years: int = 4
|
||||||
|
code_cited_per_code: int = 3
|
||||||
|
|
||||||
|
|
||||||
def parse_hosts(spec: str) -> tuple[tuple[str, float], ...]:
|
def parse_hosts(spec: str) -> tuple[tuple[str, float], ...]:
|
||||||
@@ -97,6 +101,10 @@ def load() -> LlmConfig:
|
|||||||
recency_weight=float(_opt(section, "recency_weight", 0.3)),
|
recency_weight=float(_opt(section, "recency_weight", 0.3)),
|
||||||
k_per_kind=k_per_kind,
|
k_per_kind=k_per_kind,
|
||||||
top_n=int(_opt(section, "top_n", 8)),
|
top_n=int(_opt(section, "top_n", 8)),
|
||||||
|
duckdb_replica=os.environ.get("LLM_DUCKDB_REPLICA")
|
||||||
|
or str(_opt(section, "duckdb_replica", "data/replica/aco.ro.duckdb")),
|
||||||
|
valuation_years=int(_opt(section, "valuation_years", 4)),
|
||||||
|
code_cited_per_code=int(_opt(section, "code_cited_per_code", 3)),
|
||||||
embed_dim=int(section.embed_dim),
|
embed_dim=int(section.embed_dim),
|
||||||
build_ann_index=build_ann_index,
|
build_ann_index=build_ann_index,
|
||||||
pg_host=os.environ.get("LLM_PG_HOST", str(section.pg_host)),
|
pg_host=os.environ.get("LLM_PG_HOST", str(section.pg_host)),
|
||||||
|
|||||||
229
src/llm/evidence.py
Normal file
229
src/llm/evidence.py
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
"""Structured evidence for the chat: code valuations beside the excerpts.
|
||||||
|
|
||||||
|
``valuation_evidence`` detects HCPCS/CPT codes (and registered families)
|
||||||
|
in the question, reads their RVUs from the DuckDB read replica and
|
||||||
|
attaches conversion factors from ``pfs.rules``. ``code_cited_sources``
|
||||||
|
pulls rule chunks whose ``codes`` metadata mention those codes straight
|
||||||
|
from pgvector so the answer can cite where CMS valued them. Both degrade
|
||||||
|
to "no evidence" with a warning — the chat never fails because of them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from llm.config import LlmConfig
|
||||||
|
from llm.links import as_source
|
||||||
|
from pfs.families import detect_codes
|
||||||
|
from pfs.valuation import ValuationRow, valuation
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_HEADER = (
|
||||||
|
"Valuation (authoritative for RVUs, conversion factors and payments; "
|
||||||
|
"national unadjusted, no GPCI):"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _n(v: float | None, digits: int = 2, money: bool = False) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "n/a"
|
||||||
|
s = f"{v:.{digits}f}"
|
||||||
|
return f"${s}" if money else s
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValuationEvidence:
|
||||||
|
codes: tuple[str, ...]
|
||||||
|
families: tuple[str, ...]
|
||||||
|
rows: tuple[ValuationRow, ...]
|
||||||
|
unpriced: tuple[str, ...]
|
||||||
|
|
||||||
|
def prompt_block(self) -> str:
|
||||||
|
lines = [_HEADER]
|
||||||
|
for r in self.rows:
|
||||||
|
# The status note says why an unpaid code shows no dollars, and
|
||||||
|
# the CF note says which of the year's CFs the money came from.
|
||||||
|
status = f"{r.status or '?'}" + (
|
||||||
|
f"; {r.status_note}" if r.status_note else ""
|
||||||
|
)
|
||||||
|
cf = _n(r.cf, 4, money=True) + (f" [{r.cf_note}]" if r.cf_note else "")
|
||||||
|
lines.append(
|
||||||
|
f'{r.label} {r.code} "{r.description}" — status {status}; '
|
||||||
|
f"work {_n(r.work)}; PE {_n(r.pe_nf)} non-fac / {_n(r.pe_f)} fac; "
|
||||||
|
f"MP {_n(r.mp)}; total {_n(r.total_nf)} non-fac / {_n(r.total_f)} fac; "
|
||||||
|
f"CF {cf} → payment {_n(r.pay_nf, money=True)} non-fac / "
|
||||||
|
f"{_n(r.pay_f, money=True)} fac"
|
||||||
|
)
|
||||||
|
if self.unpriced:
|
||||||
|
lines.append(
|
||||||
|
"Not priced in the indexed fee schedule: " + ", ".join(self.unpriced)
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def payload(self) -> dict[str, Any]:
|
||||||
|
provenance: list[dict[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in self.rows:
|
||||||
|
if r.label not in seen:
|
||||||
|
seen.add(r.label)
|
||||||
|
provenance.append(
|
||||||
|
{
|
||||||
|
"label": r.label,
|
||||||
|
"vintage": r.vintage,
|
||||||
|
"citation": r.citation,
|
||||||
|
"url": r.url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"type": "valuation",
|
||||||
|
"codes": list(self.codes),
|
||||||
|
"families": list(self.families),
|
||||||
|
"rows": [asdict(r) for r in self.rows],
|
||||||
|
"provenance": provenance,
|
||||||
|
"unpriced": list(self.unpriced),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: The open replica, keyed by (path, mtime_ns). DuckDB pays a real cost
|
||||||
|
#: to open a database, and every chat turn that mentions a code would
|
||||||
|
#: otherwise pay it. ``publish_replica`` swaps the file with os.replace,
|
||||||
|
#: so the handle stays valid on the old inode until we notice the new
|
||||||
|
#: mtime and reopen.
|
||||||
|
_REPLICA: tuple[str, int, Any] | None = None
|
||||||
|
_REPLICA_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _mtime(path: str) -> int:
|
||||||
|
try:
|
||||||
|
return os.stat(path).st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(path: str) -> Any:
|
||||||
|
"""The cached read-only connection to *path*, reopened when the
|
||||||
|
replica file has been republished since we opened it."""
|
||||||
|
global _REPLICA
|
||||||
|
mtime = _mtime(path)
|
||||||
|
with _REPLICA_LOCK: # two chat threads racing the first open would leak one
|
||||||
|
if _REPLICA is not None:
|
||||||
|
cached_path, cached_mtime, con = _REPLICA
|
||||||
|
if (cached_path, cached_mtime) == (path, mtime):
|
||||||
|
return con
|
||||||
|
_REPLICA = None
|
||||||
|
try:
|
||||||
|
con.close()
|
||||||
|
except Exception as e: # noqa: BLE001 — a stale handle must not break the chat
|
||||||
|
log.warning("stale replica handle not closed: %s", e)
|
||||||
|
con = duckdb.connect(path, read_only=True)
|
||||||
|
_REPLICA = (path, mtime, con)
|
||||||
|
return con
|
||||||
|
|
||||||
|
|
||||||
|
def _replica_path(cfg: LlmConfig) -> str:
|
||||||
|
p = Path(cfg.duckdb_replica)
|
||||||
|
if p.is_absolute():
|
||||||
|
return str(p)
|
||||||
|
from conf import ROOT
|
||||||
|
|
||||||
|
return str(ROOT / p)
|
||||||
|
|
||||||
|
|
||||||
|
def valuation_evidence(question: str, cfg: LlmConfig) -> ValuationEvidence | None:
|
||||||
|
det = detect_codes(question)
|
||||||
|
if not det.codes:
|
||||||
|
return None
|
||||||
|
path = _replica_path(cfg)
|
||||||
|
try:
|
||||||
|
# A chat turn runs in a threadpool worker, and DuckDB gives each
|
||||||
|
# thread its own cursor off the shared connection — without one,
|
||||||
|
# concurrent turns read each other's result sets.
|
||||||
|
cur = _connect(path).cursor()
|
||||||
|
except Exception as e: # noqa: BLE001 — duckdb raises several types
|
||||||
|
log.warning("valuation evidence skipped (%s): %s", path, e)
|
||||||
|
return None
|
||||||
|
# A whole family is six codes; showing four vintages of each would
|
||||||
|
# crowd the excerpts out of the prompt.
|
||||||
|
years = cfg.valuation_years if len(det.codes) <= 3 else 2
|
||||||
|
try:
|
||||||
|
rows, unpriced = valuation(cur, list(det.codes), years=years)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("valuation evidence skipped (query): %s", e)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
cur.close() # the cached parent connection stays open
|
||||||
|
return ValuationEvidence(det.codes, det.families, tuple(rows), tuple(unpriced))
|
||||||
|
|
||||||
|
|
||||||
|
_CITED_SQL = text(
|
||||||
|
"SELECT document, cmetadata FROM ( "
|
||||||
|
"SELECT e.document, e.cmetadata, "
|
||||||
|
"row_number() OVER ( "
|
||||||
|
"PARTITION BY w.code "
|
||||||
|
"ORDER BY e.cmetadata->>'date' DESC NULLS LAST, e.cmetadata->>'item_key', e.cmetadata->>'p_id' "
|
||||||
|
") AS rn "
|
||||||
|
"FROM langchain_pg_embedding e "
|
||||||
|
"JOIN unnest(CAST(:codes AS text[])) AS w(code) "
|
||||||
|
"ON w.code = ANY(string_to_array(COALESCE(e.cmetadata->>'codes', ''), ' ')) "
|
||||||
|
"WHERE e.collection_id = (SELECT uuid FROM langchain_pg_collection "
|
||||||
|
"WHERE name = :collection) "
|
||||||
|
") t "
|
||||||
|
"WHERE rn <= :window "
|
||||||
|
"ORDER BY cmetadata->>'date' DESC NULLS LAST, cmetadata->>'item_key', cmetadata->>'p_id'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def code_cited_sources(
|
||||||
|
engine: Any, codes: Sequence[str], *, per_code: int, collection: str = "rules"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Chunks in *collection* whose ``codes`` metadata mention any of
|
||||||
|
*codes* — at most *per_code* per code, newest rule first, deduped on
|
||||||
|
(item_key, p_id). Scores are 0.0: these are additive, not ranked."""
|
||||||
|
wanted = [c.upper() for c in codes]
|
||||||
|
if not wanted:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with engine.begin() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
_CITED_SQL,
|
||||||
|
{
|
||||||
|
"collection": collection,
|
||||||
|
"codes": wanted,
|
||||||
|
"window": per_code * 2,
|
||||||
|
},
|
||||||
|
).fetchall()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.warning("code-cited sources skipped: %s", e)
|
||||||
|
return []
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
count: dict[str, int] = {c: 0 for c in wanted}
|
||||||
|
for document, md in rows:
|
||||||
|
md = {k: str(v) for k, v in (md or {}).items()}
|
||||||
|
key = (md.get("item_key", ""), md.get("p_id", ""))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
hit_codes = [
|
||||||
|
c for c in md.get("codes", "").split() if c in count and count[c] < per_code
|
||||||
|
]
|
||||||
|
if not hit_codes:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
for c in hit_codes:
|
||||||
|
count[c] += 1
|
||||||
|
out.append(as_source(md, document[:250], 0.0))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def merge_sources(retrieved: list[dict], extra: list[dict]) -> list[dict]:
|
||||||
|
labels = {s["label"] for s in retrieved}
|
||||||
|
return retrieved + [s for s in extra if s["label"] not in labels]
|
||||||
@@ -19,6 +19,7 @@ from bib.frlink import text_fragment
|
|||||||
_COMMENT = "https://www.regulations.gov/comment/{cid}"
|
_COMMENT = "https://www.regulations.gov/comment/{cid}"
|
||||||
_DOWNLOAD = "https://downloads.regulations.gov/{cid}/{name}"
|
_DOWNLOAD = "https://downloads.regulations.gov/{cid}/{name}"
|
||||||
_TITLE_MAX = 60
|
_TITLE_MAX = 60
|
||||||
|
_SNIPPET_CHARS = 500
|
||||||
|
|
||||||
|
|
||||||
def _short(title: str) -> str:
|
def _short(title: str) -> str:
|
||||||
@@ -84,3 +85,21 @@ def for_source(md: dict[str, str], snippet: str) -> tuple[str, str]:
|
|||||||
if kind == "rule":
|
if kind == "rule":
|
||||||
return _rule(md, snippet)
|
return _rule(md, snippet)
|
||||||
return _corpus(md)
|
return _corpus(md)
|
||||||
|
|
||||||
|
|
||||||
|
def as_source(md: dict[str, str], text: str, score: float) -> dict:
|
||||||
|
"""The source dict the chat sends to the prompt and the UI."""
|
||||||
|
snippet = text[:_SNIPPET_CHARS].strip()
|
||||||
|
url, label = for_source(md, snippet)
|
||||||
|
return {
|
||||||
|
"id": label,
|
||||||
|
"label": label,
|
||||||
|
"kind": md.get("kind", ""),
|
||||||
|
"url": url,
|
||||||
|
"title": md.get("title", ""),
|
||||||
|
"date": md.get("date", ""),
|
||||||
|
"docket": md.get("docket", ""),
|
||||||
|
"comment_id": md.get("comment_id", ""),
|
||||||
|
"snippet": snippet,
|
||||||
|
"score": round(score, 4),
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ _HNSW_DDL = [
|
|||||||
"ALTER TABLE langchain_pg_embedding ALTER COLUMN embedding TYPE vector({dim})",
|
"ALTER TABLE langchain_pg_embedding ALTER COLUMN embedding TYPE vector({dim})",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_embedding_hnsw "
|
"CREATE INDEX IF NOT EXISTS ix_embedding_hnsw "
|
||||||
"ON langchain_pg_embedding USING hnsw (embedding vector_cosine_ops)",
|
"ON langchain_pg_embedding USING hnsw (embedding vector_cosine_ops)",
|
||||||
|
# Code-cited sources (llm.evidence) match a code against the space-
|
||||||
|
# separated cmetadata->>'codes' inside one collection; without these
|
||||||
|
# two the query is a sequential scan of every embedding.
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_embedding_codes "
|
||||||
|
"ON langchain_pg_embedding USING gin "
|
||||||
|
"(string_to_array(coalesce(cmetadata->>'codes',''), ' '))",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_embedding_collection "
|
||||||
|
"ON langchain_pg_embedding (collection_id)",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ its own. Retrieval embeds the question once and searches the
|
|||||||
merged and re-ranked by similarity × recency (``llm.rerank``), then each
|
merged and re-ranked by similarity × recency (``llm.rerank``), then each
|
||||||
source gets a deep link (``llm.links``). Generation streams from the
|
source gets a deep link (``llm.links``). Generation streams from the
|
||||||
largest live Ollama host (``HostPool.acquire_generation``) with the
|
largest live Ollama host (``HostPool.acquire_generation``) with the
|
||||||
model tier that host can hold (``pick_model``).
|
model tier that host can hold (``pick_model``). When the question
|
||||||
|
cites HCPCS/CPT codes, ``llm.evidence`` supplies authoritative
|
||||||
|
valuation rows and rule chunks that cite those codes; the SSE event
|
||||||
|
order is ``valuation`` (only when evidence is found) → ``token``* →
|
||||||
|
``sources`` → ``done``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,14 +23,20 @@ from typing import Iterator
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from llm.config import LlmConfig
|
from llm.config import LlmConfig
|
||||||
from llm.links import for_source
|
from llm.evidence import (
|
||||||
|
ValuationEvidence,
|
||||||
|
code_cited_sources,
|
||||||
|
merge_sources,
|
||||||
|
valuation_evidence,
|
||||||
|
)
|
||||||
|
from llm.index import _engine
|
||||||
|
from llm.links import as_source
|
||||||
from llm.pool import HostPool, PoolEmbeddings, pick_model
|
from llm.pool import HostPool, PoolEmbeddings, pick_model
|
||||||
from llm.rerank import Hit, blend, filter_since
|
from llm.rerank import Hit, blend, filter_since
|
||||||
|
|
||||||
_TIMEOUT = httpx.Timeout(300.0, connect=5.0)
|
_TIMEOUT = httpx.Timeout(300.0, connect=5.0)
|
||||||
_COLLECTIONS = {"comment": "comments", "rule": "rules", "corpus": "corpus"}
|
_COLLECTIONS = {"comment": "comments", "rule": "rules", "corpus": "corpus"}
|
||||||
_OVERFETCH = 3
|
_OVERFETCH = 3
|
||||||
_SNIPPET_CHARS = 500
|
|
||||||
|
|
||||||
_SYSTEM = (
|
_SYSTEM = (
|
||||||
"You answer questions about CMS rulemaking using ONLY the excerpts "
|
"You answer questions about CMS rulemaking using ONLY the excerpts "
|
||||||
@@ -41,6 +51,12 @@ _SYSTEM = (
|
|||||||
"statement comes from when it matters. If the excerpts do not contain "
|
"statement comes from when it matters. If the excerpts do not contain "
|
||||||
"the answer, say you don't have information on that in the indexed "
|
"the answer, say you don't have information on that in the indexed "
|
||||||
"library — do not invent facts."
|
"library — do not invent facts."
|
||||||
|
" A Valuation section may follow the excerpts. Its rows are "
|
||||||
|
"authoritative for RVUs, conversion factors and payment amounts; when "
|
||||||
|
"you state any of those numbers, cite the row's bracketed label exactly, "
|
||||||
|
"e.g. [PFS CY2026 Addendum B]. Do not compute, extrapolate or convert "
|
||||||
|
"numbers beyond what the rows show; if a code is listed as not priced, "
|
||||||
|
"say so."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,21 +81,7 @@ def _hits(question_vec: list[float], *, cfg: LlmConfig, pool: HostPool) -> list[
|
|||||||
|
|
||||||
|
|
||||||
def _source(hit: Hit) -> dict:
|
def _source(hit: Hit) -> dict:
|
||||||
md = hit.metadata
|
return as_source(hit.metadata, hit.text, hit.score)
|
||||||
snippet = hit.text[:_SNIPPET_CHARS].strip()
|
|
||||||
url, label = for_source(md, snippet)
|
|
||||||
return {
|
|
||||||
"id": label,
|
|
||||||
"label": label,
|
|
||||||
"kind": md.get("kind", ""),
|
|
||||||
"url": url,
|
|
||||||
"title": md.get("title", ""),
|
|
||||||
"date": md.get("date", ""),
|
|
||||||
"docket": md.get("docket", ""),
|
|
||||||
"comment_id": md.get("comment_id", ""),
|
|
||||||
"snippet": snippet,
|
|
||||||
"score": round(hit.score, 4),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def retrieve(
|
def retrieve(
|
||||||
@@ -106,8 +108,12 @@ def retrieve(
|
|||||||
return [_source(h) for h in ranked]
|
return [_source(h) for h in ranked]
|
||||||
|
|
||||||
|
|
||||||
def build_messages(question: str, sources: list[dict]) -> list[dict]:
|
def build_messages(
|
||||||
"""Grounded chat messages: system rules + question with excerpts."""
|
question: str, sources: list[dict], evidence: ValuationEvidence | None = None
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Grounded chat messages: system rules + question with excerpts and,
|
||||||
|
when available, a Valuation block between the excerpts and the
|
||||||
|
question."""
|
||||||
if sources:
|
if sources:
|
||||||
context = "\n\n".join(
|
context = "\n\n".join(
|
||||||
f"[{s['label']}] ({s.get('kind', '')}, {s.get('date', '') or 'undated'}) "
|
f"[{s['label']}] ({s.get('kind', '')}, {s.get('date', '') or 'undated'}) "
|
||||||
@@ -116,7 +122,11 @@ def build_messages(question: str, sources: list[dict]) -> list[dict]:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
context = "(no relevant excerpts found)"
|
context = "(no relevant excerpts found)"
|
||||||
user = f"Excerpts:\n\n{context}\n\nQuestion: {question}"
|
parts = [f"Excerpts:\n\n{context}"]
|
||||||
|
if evidence is not None:
|
||||||
|
parts.append(evidence.prompt_block())
|
||||||
|
parts.append(f"Question: {question}")
|
||||||
|
user = "\n\n".join(parts)
|
||||||
return [
|
return [
|
||||||
{"role": "system", "content": _SYSTEM},
|
{"role": "system", "content": _SYSTEM},
|
||||||
{"role": "user", "content": user},
|
{"role": "user", "content": user},
|
||||||
@@ -128,13 +138,23 @@ def stream_answer(
|
|||||||
) -> Iterator[dict]:
|
) -> Iterator[dict]:
|
||||||
"""Retrieve, then stream a grounded answer from the largest live host.
|
"""Retrieve, then stream a grounded answer from the largest live host.
|
||||||
|
|
||||||
Yields ``{"type":"token","text":…}`` events as the model generates,
|
When the question cites HCPCS/CPT codes, yields a ``valuation`` event
|
||||||
then one ``{"type":"sources", "sources": […], "model": …, "host": …}``
|
first (evidence's ``payload()``) and merges rule chunks that cite
|
||||||
and a final ``{"type":"done"}``.
|
those codes into ``sources``. Then yields ``{"type":"token","text":…}``
|
||||||
|
events as the model generates, then one
|
||||||
|
``{"type":"sources", "sources": […], "model": …, "host": …}`` and a
|
||||||
|
final ``{"type":"done"}``.
|
||||||
"""
|
"""
|
||||||
sources = retrieve(question, cfg=cfg, pool=pool, since=since)
|
sources = retrieve(question, cfg=cfg, pool=pool, since=since)
|
||||||
|
evidence = valuation_evidence(question, cfg)
|
||||||
|
if evidence is not None:
|
||||||
|
cited = code_cited_sources(
|
||||||
|
_engine(cfg), evidence.codes, per_code=cfg.code_cited_per_code
|
||||||
|
)
|
||||||
|
sources = merge_sources(sources, cited)
|
||||||
|
yield evidence.payload()
|
||||||
pool.check(cfg.instruct_model)
|
pool.check(cfg.instruct_model)
|
||||||
messages = build_messages(question, sources)
|
messages = build_messages(question, sources, evidence)
|
||||||
with pool.acquire_generation() as host, httpx.Client(timeout=_TIMEOUT) as client:
|
with pool.acquire_generation() as host, httpx.Client(timeout=_TIMEOUT) as client:
|
||||||
model = pick_model(cfg, pool, host)
|
model = pick_model(cfg, pool, host)
|
||||||
with client.stream(
|
with client.stream(
|
||||||
|
|||||||
@@ -66,6 +66,24 @@
|
|||||||
background: var(--secondary); padding: 1px 5px; border-radius: 2px;
|
background: var(--secondary); padding: 1px 5px; border-radius: 2px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
table.valuation {
|
||||||
|
border-collapse: collapse; width: 100%; font-size: 13px; margin-top: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
table.valuation th, table.valuation td {
|
||||||
|
border: 1px solid var(--border); padding: 4px 6px; text-align: right; white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.valuation th { font-family: var(--font-mono); font-size: 10px; letter-spacing: .08em;
|
||||||
|
text-transform: uppercase; color: var(--muted-fg); }
|
||||||
|
table.valuation td:nth-child(-n+3), table.valuation th:nth-child(-n+3) { text-align: left; }
|
||||||
|
table.valuation tr.proposed td { font-style: italic; opacity: .85; }
|
||||||
|
table.valuation caption {
|
||||||
|
caption-side: bottom; text-align: left; font-size: 11px;
|
||||||
|
color: var(--muted-fg); padding: 4px 2px 0;
|
||||||
|
}
|
||||||
|
.valuation-wrap { overflow-x: auto; }
|
||||||
|
.provenance { font-size: 12px; color: var(--muted-fg); margin-top: 4px; }
|
||||||
|
.provenance .cite { margin-right: 4px; }
|
||||||
details.sources { margin-top: 2px; font-size: 14px; }
|
details.sources { margin-top: 2px; font-size: 14px; }
|
||||||
details.sources > summary {
|
details.sources > summary {
|
||||||
cursor: pointer; color: var(--muted-fg); font-family: var(--font-mono);
|
cursor: pointer; color: var(--muted-fg); font-family: var(--font-mono);
|
||||||
@@ -177,6 +195,79 @@
|
|||||||
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
if (last < text.length) el.appendChild(document.createTextNode(text.slice(last)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VAL_COLS = [
|
||||||
|
['code', 'Code'], ['description', 'Description'], ['vintage', 'Vintage'], ['status', 'St'],
|
||||||
|
['work', 'Work'], ['pe_nf', 'PE NF'], ['pe_f', 'PE F'], ['mp', 'MP'],
|
||||||
|
['total_nf', 'Total NF'], ['total_f', 'Total F'], ['cf', 'CF (non-APM)'],
|
||||||
|
['pay_nf', 'Pay NF'], ['pay_f', 'Pay F'],
|
||||||
|
];
|
||||||
|
function fmt(key, v) {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
if (key === 'cf') return '$' + Number(v).toFixed(4);
|
||||||
|
if (key === 'pay_nf' || key === 'pay_f') return '$' + Number(v).toFixed(2);
|
||||||
|
if (typeof v === 'number') return v.toFixed(2);
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
function renderValuation(wrap, ev) {
|
||||||
|
const rows = ev.rows || [];
|
||||||
|
const box = document.createElement('div');
|
||||||
|
box.className = 'valuation-wrap';
|
||||||
|
if (rows.length) {
|
||||||
|
const t = document.createElement('table');
|
||||||
|
t.className = 'valuation';
|
||||||
|
t.createCaption().textContent =
|
||||||
|
'National unadjusted amounts (total RVUs \u00d7 CF); no geographic (GPCI) adjustment.';
|
||||||
|
const thead = t.createTHead().insertRow();
|
||||||
|
for (const [, name] of VAL_COLS) { const th = document.createElement('th'); th.textContent = name; thead.appendChild(th); }
|
||||||
|
const tb = t.createTBody();
|
||||||
|
for (const r of rows) {
|
||||||
|
const tr = tb.insertRow();
|
||||||
|
if (r.proposed) tr.className = 'proposed';
|
||||||
|
for (const [key] of VAL_COLS) {
|
||||||
|
const td = tr.insertCell();
|
||||||
|
td.textContent = fmt(key, r[key]);
|
||||||
|
// why a status is not paid / which of the year's CFs this is
|
||||||
|
if (key === 'status' && r.status_note) td.title = r.status_note;
|
||||||
|
if (key === 'cf' && r.cf_note) td.title = r.cf_note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
box.appendChild(t);
|
||||||
|
}
|
||||||
|
const prov = document.createElement('div');
|
||||||
|
prov.className = 'provenance';
|
||||||
|
for (const p of ev.provenance || []) {
|
||||||
|
const c = document.createElement('span'); c.className = 'cite'; c.textContent = p.label;
|
||||||
|
prov.appendChild(c);
|
||||||
|
let cite;
|
||||||
|
if (p.url) {
|
||||||
|
cite = document.createElement('a');
|
||||||
|
cite.href = p.url; cite.target = '_blank'; cite.rel = 'noopener';
|
||||||
|
} else {
|
||||||
|
cite = document.createElement('span'); // an empty href reloads the chat
|
||||||
|
}
|
||||||
|
cite.textContent = p.citation || p.vintage;
|
||||||
|
prov.appendChild(cite);
|
||||||
|
prov.appendChild(document.createTextNode(' '));
|
||||||
|
}
|
||||||
|
if ((ev.unpriced || []).length) {
|
||||||
|
prov.appendChild(document.createTextNode('not priced: ' + ev.unpriced.join(', ')));
|
||||||
|
}
|
||||||
|
const notes = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!r.status_note) continue;
|
||||||
|
const line = r.code + ' (' + (r.status || '?') + '): ' + r.status_note;
|
||||||
|
if (!notes.includes(line)) notes.push(line);
|
||||||
|
}
|
||||||
|
for (const line of notes) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = line;
|
||||||
|
prov.appendChild(d);
|
||||||
|
}
|
||||||
|
box.appendChild(prov);
|
||||||
|
wrap.appendChild(box);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSources(wrap, ev) {
|
function renderSources(wrap, ev) {
|
||||||
const sources = ev.sources || [];
|
const sources = ev.sources || [];
|
||||||
if (ev.model) {
|
if (ev.model) {
|
||||||
@@ -252,6 +343,7 @@
|
|||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const ev = JSON.parse(line);
|
const ev = JSON.parse(line);
|
||||||
if (ev.type === 'token') { answer += ev.text; paint(b, answer); log.scrollTop = log.scrollHeight; }
|
if (ev.type === 'token') { answer += ev.text; paint(b, answer); log.scrollTop = log.scrollHeight; }
|
||||||
|
else if (ev.type === 'valuation') renderValuation(wrap, ev);
|
||||||
else if (ev.type === 'sources') renderSources(wrap, ev);
|
else if (ev.type === 'sources') renderSources(wrap, ev);
|
||||||
else if (ev.type === 'error') { b.className = 'bubble err'; b.textContent = 'Error: ' + ev.message; }
|
else if (ev.type === 'error') { b.className = 'bubble err'; b.textContent = 'Error: ' + ev.message; }
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/pfs/families.py
Normal file
109
src/pfs/families.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Code families and deterministic code detection for chat questions.
|
||||||
|
|
||||||
|
The registry used to live in a notebook cell
|
||||||
|
(``notebooks/palliative_care_rfi.py``); this is its home in ``src`` so
|
||||||
|
the chat, notebooks and scripts share one list. Pure: no I/O, no
|
||||||
|
DuckDB, no narwhals — it must import inside the ``llm`` container.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
# HCPCS level II (letter + 4 digits) or CPT (5 digits). Validation against
|
||||||
|
# the fee schedule happens at lookup time (pfs.valuation), not here.
|
||||||
|
CODE_RE = re.compile(r"\b(?:[A-Z]\d{4}|\d{5})\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
# "91 FR 43842" — an FR page number is five digits and so reads as a CPT
|
||||||
|
# code; citations are removed before scanning so they never become one.
|
||||||
|
_FR_CITE_RE = re.compile(r"\b\d{1,3}\s+FR\s+\d{3,6}\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def find_codes(text: str) -> tuple[str, ...]:
|
||||||
|
"""Sorted unique codes literally present in *text*, upper-cased.
|
||||||
|
|
||||||
|
Federal Register citations are stripped first: their page numbers are
|
||||||
|
not codes, and a chat answer that priced "43842" out of "91 FR 43842"
|
||||||
|
would be inventing money.
|
||||||
|
"""
|
||||||
|
text = _FR_CITE_RE.sub(" ", text)
|
||||||
|
return tuple(sorted({m.group(0).upper() for m in CODE_RE.finditer(text)}))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Family:
|
||||||
|
key: str
|
||||||
|
name: str
|
||||||
|
codes: tuple[str, ...]
|
||||||
|
synonyms: tuple[str, ...] # lower-case; matched on word boundaries
|
||||||
|
|
||||||
|
|
||||||
|
FAMILIES: dict[str, Family] = {
|
||||||
|
"ACP": Family(
|
||||||
|
"ACP",
|
||||||
|
"Advance Care Planning",
|
||||||
|
("99497", "99498"),
|
||||||
|
("acp", "advance care planning"),
|
||||||
|
),
|
||||||
|
"CCM": Family(
|
||||||
|
"CCM",
|
||||||
|
"Chronic Care Management",
|
||||||
|
("99437", "99439", "99487", "99489", "99490", "99491"),
|
||||||
|
("ccm", "chronic care management"),
|
||||||
|
),
|
||||||
|
"PCM": Family(
|
||||||
|
"PCM",
|
||||||
|
"Principal Care Management",
|
||||||
|
("99424", "99425", "99426", "99427"),
|
||||||
|
("pcm", "principal care management"),
|
||||||
|
),
|
||||||
|
"TCM": Family(
|
||||||
|
"TCM",
|
||||||
|
"Transitional Care Management",
|
||||||
|
("99495", "99496"),
|
||||||
|
("tcm", "transitional care management"),
|
||||||
|
),
|
||||||
|
"APCM": Family(
|
||||||
|
"APCM",
|
||||||
|
"Advanced Primary Care Management",
|
||||||
|
("G0556", "G0557", "G0558"),
|
||||||
|
("apcm", "advanced primary care management"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
_CODE_TO_FAMILY: dict[str, Family] = {c: f for f in FAMILIES.values() for c in f.codes}
|
||||||
|
|
||||||
|
|
||||||
|
def family_of(code: str) -> Family | None:
|
||||||
|
return _CODE_TO_FAMILY.get(code.upper())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Detection:
|
||||||
|
codes: tuple[str, ...] # sorted unique: explicit + family-expanded
|
||||||
|
families: tuple[str, ...] # family keys, sorted
|
||||||
|
explicit: tuple[str, ...] # codes literally present in the text
|
||||||
|
|
||||||
|
|
||||||
|
def detect_codes(text: str) -> Detection:
|
||||||
|
"""Codes a question is about: explicit codes plus every code of any
|
||||||
|
family named (by synonym) or touched (by one member code)."""
|
||||||
|
explicit = find_codes(text)
|
||||||
|
lowered = text.lower()
|
||||||
|
families: set[str] = set()
|
||||||
|
for fam in FAMILIES.values():
|
||||||
|
if any(re.search(rf"\b{re.escape(s)}\b", lowered) for s in fam.synonyms):
|
||||||
|
families.add(fam.key)
|
||||||
|
for code in explicit:
|
||||||
|
fam = family_of(code)
|
||||||
|
if fam is not None:
|
||||||
|
families.add(fam.key)
|
||||||
|
codes = set(explicit)
|
||||||
|
for key in families:
|
||||||
|
codes.update(FAMILIES[key].codes)
|
||||||
|
return Detection(
|
||||||
|
codes=tuple(sorted(codes)),
|
||||||
|
families=tuple(sorted(families)),
|
||||||
|
explicit=explicit,
|
||||||
|
)
|
||||||
273
src/pfs/valuation.py
Normal file
273
src/pfs/valuation.py
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
"""RVUs, conversion factor and national unadjusted payment per code and
|
||||||
|
vintage, read from the DuckDB replica (``pfs.rvu`` final years,
|
||||||
|
``pfs.rvu_proposed`` for the newest NPRM). Conversion factors come from
|
||||||
|
``pfs.rules`` — never from ``pfs.rvu.conv_factor``. Pure DuckDB SQL; no
|
||||||
|
narwhals (this module runs inside the ``llm`` container).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Sequence
|
||||||
|
|
||||||
|
from pfs.nprm import NPRM_SOURCES
|
||||||
|
from pfs.rules import RULES, ProposedRule, RuleYear, proposed_for
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValuationRow:
|
||||||
|
code: str
|
||||||
|
description: str
|
||||||
|
vintage: str # "CY2026 final" | "CY2027 proposed"
|
||||||
|
year: int
|
||||||
|
proposed: bool
|
||||||
|
status: str
|
||||||
|
work: float | None
|
||||||
|
pe_nf: float | None
|
||||||
|
pe_f: float | None
|
||||||
|
mp: float | None
|
||||||
|
total_nf: float | None
|
||||||
|
total_f: float | None
|
||||||
|
cf: float
|
||||||
|
pay_nf: float | None
|
||||||
|
pay_f: float | None
|
||||||
|
label: str # "[PFS CY2026 Addendum B]"
|
||||||
|
citation: str # "90 FR 49266"
|
||||||
|
url: str
|
||||||
|
status_note: str = "" # why a non-A/R/T status is not paid ("" when paid)
|
||||||
|
cf_note: str = "" # which of the year's several CFs this one is
|
||||||
|
|
||||||
|
|
||||||
|
def vintage_label(year: int, proposed: bool) -> str:
|
||||||
|
return f"[CY{year} NPRM Addendum B]" if proposed else f"[PFS CY{year} Addendum B]"
|
||||||
|
|
||||||
|
|
||||||
|
def fr_citation_url(cite: str) -> str:
|
||||||
|
parts = cite.split()
|
||||||
|
if len(parts) != 3 or parts[1].upper() != "FR":
|
||||||
|
return ""
|
||||||
|
return f"https://www.federalregister.gov/citation/{parts[0]}-FR-{parts[2]}"
|
||||||
|
|
||||||
|
|
||||||
|
#: Payment status codes paid as total RVUs × CF. Every other status is
|
||||||
|
#: priced some other way (or not at all), so multiplying its RVUs by the
|
||||||
|
#: CF would put a number on screen that Medicare never pays.
|
||||||
|
PAID_STATUSES = frozenset({"A", "R", "T"})
|
||||||
|
|
||||||
|
_STATUS_NOTES = {
|
||||||
|
"B": "bundled — no separate PFS payment",
|
||||||
|
"C": "carrier priced by the MAC",
|
||||||
|
"J": "anesthesia — paid on base units × the anesthesia CF",
|
||||||
|
"I": "not valid for Medicare",
|
||||||
|
"N": "non-covered",
|
||||||
|
"E": "excluded by regulation",
|
||||||
|
"M": "measurement code — not paid",
|
||||||
|
"X": "statutory exclusion",
|
||||||
|
"P": "bundled/excluded",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def status_note(status: str) -> str:
|
||||||
|
"""Why *status* is not paid from RVUs × CF — ``""`` when it is."""
|
||||||
|
code = (status or "").strip().upper()
|
||||||
|
if code in PAID_STATUSES:
|
||||||
|
return ""
|
||||||
|
if code in _STATUS_NOTES:
|
||||||
|
return _STATUS_NOTES[code]
|
||||||
|
if not code:
|
||||||
|
return "status not stated — no RVU-based payment"
|
||||||
|
return f"status {code} — no RVU-based payment"
|
||||||
|
|
||||||
|
|
||||||
|
def final_cf_note(rule: RuleYear) -> str:
|
||||||
|
"""Which of a final year's several conversion factors ``cf`` is.
|
||||||
|
|
||||||
|
Since CY2026 CMS publishes separate QP (APM) and non-QP standard
|
||||||
|
CFs, and a mid-year legislative update can split a year in two — an
|
||||||
|
unlabelled dollar figure would hide both.
|
||||||
|
"""
|
||||||
|
parts = ["non-APM standard CF"]
|
||||||
|
if rule.cf_qp is not None and rule.cf_qp != rule.conversion_factor:
|
||||||
|
parts.append(f"QP CF ${rule.cf_qp:.4f}")
|
||||||
|
segs = sorted(rule.cf_segments, key=lambda s: s.start)
|
||||||
|
if len(segs) > 1:
|
||||||
|
earlier = next(
|
||||||
|
(
|
||||||
|
(seg, nxt)
|
||||||
|
for seg, nxt in zip(segs, segs[1:])
|
||||||
|
if seg.cf != rule.conversion_factor
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if earlier is None:
|
||||||
|
parts.append("mid-year split — dominant CF shown")
|
||||||
|
else:
|
||||||
|
seg, nxt = earlier
|
||||||
|
parts.append(
|
||||||
|
f"mid-year split — dominant CF shown (${seg.cf:.4f} before {nxt.start})"
|
||||||
|
)
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def proposed_cf_note(prop: ProposedRule) -> str:
|
||||||
|
"""Which CF a proposed row shows (the NPRM's non-QP standard one)."""
|
||||||
|
parts = ["proposed; non-QP CF"]
|
||||||
|
if prop.cf_qp is not None and prop.cf_qp != prop.conversion_factor:
|
||||||
|
parts.append(f"QP CF ${prop.cf_qp:.4f}")
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _pay(total: float | None, cf: float, status: str) -> float | None:
|
||||||
|
"""National unadjusted payment, or ``None`` when the status is not
|
||||||
|
paid from RVUs (see :data:`PAID_STATUSES`)."""
|
||||||
|
if total is None or (status or "").strip().upper() not in PAID_STATUSES:
|
||||||
|
return None
|
||||||
|
return round(total * cf, 2)
|
||||||
|
|
||||||
|
|
||||||
|
_FINAL_SQL = """
|
||||||
|
WITH base AS (
|
||||||
|
SELECT hcpcs, year, description, status_code, work_rvu, non_fac_pe_rvu,
|
||||||
|
fac_pe_rvu, mp_rvu, non_fac_total, fac_total
|
||||||
|
FROM pfs.rvu
|
||||||
|
WHERE hcpcs IN (SELECT UNNEST(?::VARCHAR[]))
|
||||||
|
AND year IN (SELECT UNNEST(?::INTEGER[]))
|
||||||
|
QUALIFY row_number() OVER (
|
||||||
|
PARTITION BY hcpcs, year ORDER BY (mod IS NULL OR mod = '') DESC, mod
|
||||||
|
) = 1
|
||||||
|
)
|
||||||
|
SELECT * FROM base
|
||||||
|
QUALIFY dense_rank() OVER (PARTITION BY hcpcs ORDER BY year DESC) <= ?
|
||||||
|
ORDER BY hcpcs, year
|
||||||
|
"""
|
||||||
|
|
||||||
|
_PROPOSED_SQL = """
|
||||||
|
SELECT hcpcs, description, status_code, work_rvu, non_fac_pe_rvu, fac_pe_rvu, mp_rvu
|
||||||
|
FROM pfs.rvu_proposed
|
||||||
|
WHERE cms_rule_id = ? AND hcpcs IN (SELECT UNNEST(?::VARCHAR[]))
|
||||||
|
QUALIFY row_number() OVER (
|
||||||
|
PARTITION BY hcpcs ORDER BY (mod IS NULL OR mod = '') DESC, mod
|
||||||
|
) = 1
|
||||||
|
ORDER BY hcpcs
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
_UNKNOWN_YEARS_SQL = """
|
||||||
|
SELECT DISTINCT year FROM pfs.rvu
|
||||||
|
WHERE hcpcs IN (SELECT UNNEST(?::VARCHAR[]))
|
||||||
|
AND year NOT IN (SELECT UNNEST(?::INTEGER[]))
|
||||||
|
ORDER BY year
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _sum(*vals: float | None) -> float | None:
|
||||||
|
if any(v is None for v in vals):
|
||||||
|
return None
|
||||||
|
return round(sum(vals), 4) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def valuation(
|
||||||
|
con: Any, codes: Sequence[str], *, years: int = 4
|
||||||
|
) -> tuple[list[ValuationRow], list[str]]:
|
||||||
|
"""Rows for *codes* (last *years* final vintages + the newest NPRM),
|
||||||
|
ordered by code then year with the proposed row last per code, and the
|
||||||
|
codes that produced no rows at all."""
|
||||||
|
wanted = sorted({c.upper() for c in codes})
|
||||||
|
if not wanted:
|
||||||
|
return [], []
|
||||||
|
by_code: dict[str, list[ValuationRow]] = {c: [] for c in wanted}
|
||||||
|
rule_years = sorted(RULES)
|
||||||
|
|
||||||
|
skipped = [
|
||||||
|
int(y)
|
||||||
|
for (y,) in con.execute(_UNKNOWN_YEARS_SQL, [wanted, rule_years]).fetchall()
|
||||||
|
]
|
||||||
|
if skipped:
|
||||||
|
log.warning(
|
||||||
|
"valuation: %s priced in pfs.rvu year(s) %s with no pfs.rules entry "
|
||||||
|
"— skipped (no conversion factor)",
|
||||||
|
", ".join(wanted),
|
||||||
|
", ".join(str(y) for y in skipped),
|
||||||
|
)
|
||||||
|
|
||||||
|
for hcpcs, year, desc, status, work, pe_nf, pe_f, mp, tot_nf, tot_f in con.execute(
|
||||||
|
_FINAL_SQL, [wanted, rule_years, years]
|
||||||
|
).fetchall():
|
||||||
|
rule = RULES[int(year)]
|
||||||
|
cf = rule.conversion_factor
|
||||||
|
cite = rule.federal_register_citation
|
||||||
|
by_code[hcpcs].append(
|
||||||
|
ValuationRow(
|
||||||
|
code=hcpcs,
|
||||||
|
description=desc or "",
|
||||||
|
vintage=f"CY{year} final",
|
||||||
|
year=int(year),
|
||||||
|
proposed=False,
|
||||||
|
status=status or "",
|
||||||
|
status_note=status_note(status or ""),
|
||||||
|
work=work,
|
||||||
|
pe_nf=pe_nf,
|
||||||
|
pe_f=pe_f,
|
||||||
|
mp=mp,
|
||||||
|
total_nf=tot_nf,
|
||||||
|
total_f=tot_f,
|
||||||
|
cf=cf,
|
||||||
|
pay_nf=_pay(tot_nf, cf, status or ""),
|
||||||
|
pay_f=_pay(tot_f, cf, status or ""),
|
||||||
|
cf_note=final_cf_note(rule),
|
||||||
|
label=vintage_label(int(year), False),
|
||||||
|
citation=cite,
|
||||||
|
url=fr_citation_url(cite),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
nprm_year, _tag, rule_id, _pin = max(NPRM_SOURCES, key=lambda s: s[0])
|
||||||
|
prop = proposed_for(nprm_year)
|
||||||
|
if prop is None:
|
||||||
|
log.warning(
|
||||||
|
"valuation: no ProposedRule for CY%s — the newest NPRM vintage is "
|
||||||
|
"omitted from the table",
|
||||||
|
nprm_year,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cf = prop.conversion_factor
|
||||||
|
cite = prop.federal_register_citation
|
||||||
|
for hcpcs, desc, status, work, pe_nf, pe_f, mp in con.execute(
|
||||||
|
_PROPOSED_SQL, [rule_id, wanted]
|
||||||
|
).fetchall():
|
||||||
|
tot_nf, tot_f = _sum(work, pe_nf, mp), _sum(work, pe_f, mp)
|
||||||
|
by_code[hcpcs].append(
|
||||||
|
ValuationRow(
|
||||||
|
code=hcpcs,
|
||||||
|
description=desc or "",
|
||||||
|
vintage=f"CY{nprm_year} proposed",
|
||||||
|
year=nprm_year,
|
||||||
|
proposed=True,
|
||||||
|
status=status or "",
|
||||||
|
status_note=status_note(status or ""),
|
||||||
|
work=work,
|
||||||
|
pe_nf=pe_nf,
|
||||||
|
pe_f=pe_f,
|
||||||
|
mp=mp,
|
||||||
|
total_nf=tot_nf,
|
||||||
|
total_f=tot_f,
|
||||||
|
cf=cf,
|
||||||
|
pay_nf=_pay(tot_nf, cf, status or ""),
|
||||||
|
pay_f=_pay(tot_f, cf, status or ""),
|
||||||
|
cf_note=proposed_cf_note(prop),
|
||||||
|
label=vintage_label(nprm_year, True),
|
||||||
|
citation=cite,
|
||||||
|
url=fr_citation_url(cite),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
r
|
||||||
|
for c in wanted
|
||||||
|
for r in sorted(by_code[c], key=lambda r: (r.year, r.proposed))
|
||||||
|
]
|
||||||
|
unpriced = [c for c in wanted if not by_code[c]]
|
||||||
|
return rows, unpriced
|
||||||
@@ -124,6 +124,9 @@ pg_user = "llm"
|
|||||||
recency_half_life_days = 365
|
recency_half_life_days = 365
|
||||||
recency_weight = 0.3
|
recency_weight = 0.3
|
||||||
top_n = 8
|
top_n = 8
|
||||||
|
duckdb_replica = "data/replica/aco.ro.duckdb" # read-only DuckDB replica the chat reads valuations from
|
||||||
|
valuation_years = 4 # final-rule vintages shown per code (plus the newest NPRM)
|
||||||
|
code_cited_per_code = 3 # rule paragraphs literally citing each detected code
|
||||||
|
|
||||||
[llm.k_per_kind] # over-fetched ×3 per kind, then re-ranked
|
[llm.k_per_kind] # over-fetched ×3 per kind, then re-ranked
|
||||||
comment = 8
|
comment = 8
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ class TestReplica:
|
|||||||
|
|
||||||
dst = connect.publish_replica("custom")
|
dst = connect.publish_replica("custom")
|
||||||
assert dst.name == "custom.ro.duckdb"
|
assert dst.name == "custom.ro.duckdb"
|
||||||
|
# its own directory, so the container mount cannot see the primary
|
||||||
|
assert dst.parent == db_file.parent / "replica"
|
||||||
import duckdb as _duckdb
|
import duckdb as _duckdb
|
||||||
|
|
||||||
con = _duckdb.connect(str(dst), read_only=True)
|
con = _duckdb.connect(str(dst), read_only=True)
|
||||||
@@ -344,17 +346,16 @@ class TestReplica:
|
|||||||
self._patch_path(monkeypatch, db_file)
|
self._patch_path(monkeypatch, db_file)
|
||||||
|
|
||||||
target = shared / "custom.ro.duckdb"
|
target = shared / "custom.ro.duckdb"
|
||||||
|
link = tmp_path / "replica" / "custom.ro.duckdb"
|
||||||
connect.publish_replica("custom") # seed a real file to link to
|
connect.publish_replica("custom") # seed a real file to link to
|
||||||
(tmp_path / "custom.ro.duckdb").rename(target)
|
link.rename(target)
|
||||||
(tmp_path / "custom.ro.duckdb").symlink_to(target)
|
link.symlink_to(target)
|
||||||
|
|
||||||
with connect.duckdb_batch("custom") as con:
|
with connect.duckdb_batch("custom") as con:
|
||||||
con.execute("UPDATE t SET x = 7")
|
con.execute("UPDATE t SET x = 7")
|
||||||
dst = connect.publish_replica("custom")
|
dst = connect.publish_replica("custom")
|
||||||
|
|
||||||
assert (tmp_path / "custom.ro.duckdb").is_symlink(), (
|
assert link.is_symlink(), "publish detached the symlink into a standalone copy"
|
||||||
"publish detached the symlink into a standalone copy"
|
|
||||||
)
|
|
||||||
import duckdb as _duckdb
|
import duckdb as _duckdb
|
||||||
|
|
||||||
con = _duckdb.connect(str(target), read_only=True)
|
con = _duckdb.connect(str(target), read_only=True)
|
||||||
|
|||||||
@@ -24,6 +24,22 @@ class TestIndex:
|
|||||||
assert "Library Chat" in r.text
|
assert "Library Chat" in r.text
|
||||||
assert 'id="form"' in r.text
|
assert 'id="form"' in r.text
|
||||||
|
|
||||||
|
def test_serves_chat_page_with_valuation_renderer(self):
|
||||||
|
r = client.get("/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
html = r.text
|
||||||
|
assert "function renderValuation" in html
|
||||||
|
assert "ev.type === 'valuation'" in html
|
||||||
|
assert "table.valuation" in html
|
||||||
|
# the money on screen is national and unadjusted — say so
|
||||||
|
assert "no geographic (GPCI) adjustment" in html
|
||||||
|
assert "createCaption" in html
|
||||||
|
# notes explain an unpaid status and which CF is shown
|
||||||
|
assert "r.status_note" in html and "r.cf_note" in html
|
||||||
|
assert "'cf', 'CF (non-APM)'" in html
|
||||||
|
# a citation without a URL is not an empty <a href="">
|
||||||
|
assert "if (p.url)" in html
|
||||||
|
|
||||||
|
|
||||||
class TestWhoami:
|
class TestWhoami:
|
||||||
def test_reads_forwarded_header(self):
|
def test_reads_forwarded_header(self):
|
||||||
|
|||||||
@@ -151,3 +151,36 @@ class TestParagraphChunks:
|
|||||||
a = chunk_doc(Doc(key="R", text="same", metadata={}, paragraphs=paras))
|
a = chunk_doc(Doc(key="R", text="same", metadata={}, paragraphs=paras))
|
||||||
b = chunk_doc(Doc(key="R", text="same", metadata={}, paragraphs=paras))
|
b = chunk_doc(Doc(key="R", text="same", metadata={}, paragraphs=paras))
|
||||||
assert [c.id for c in a] == [c.id for c in b]
|
assert [c.id for c in a] == [c.id for c in b]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodesMetadata:
|
||||||
|
def test_codes_stamped_per_chunk(self):
|
||||||
|
text = "We propose G0556 and G0557.\n\n## Other\n\nNo codes here.\n\n## More\n\n99490 applies."
|
||||||
|
chunks = chunk_doc(_doc(text), target_chars=60, overlap_chars=10)
|
||||||
|
found = {c.metadata["codes"] for c in chunks}
|
||||||
|
assert "G0556 G0557" in found
|
||||||
|
assert "99490" in found
|
||||||
|
assert "" in found
|
||||||
|
|
||||||
|
def test_heading_codes_stamped_on_every_piece_of_the_section(self):
|
||||||
|
"""A section headed by the code it discusses ("## G0556 — APCM")
|
||||||
|
splits into several chunks; the ones past the first never repeat
|
||||||
|
the code, and used to be unfindable by it."""
|
||||||
|
text = "## G0556 — APCM\n\n" + "We propose to value the service. " * 20
|
||||||
|
chunks = chunk_doc(_doc(text), target_chars=120, overlap_chars=10)
|
||||||
|
assert len(chunks) > 1
|
||||||
|
assert all(c.metadata["codes"] == "G0556" for c in chunks)
|
||||||
|
|
||||||
|
def test_rule_paragraph_chunks_get_codes(self):
|
||||||
|
from llm.chunk import Paragraph
|
||||||
|
|
||||||
|
doc = Doc(
|
||||||
|
key="R1",
|
||||||
|
text="x",
|
||||||
|
metadata={"kind": "rule"},
|
||||||
|
paragraphs=(
|
||||||
|
Paragraph(1, 100, 1, "APCM code G0556 is valued at 0.25 work RVUs."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
(chunk,) = chunk_doc(doc)
|
||||||
|
assert chunk.metadata["codes"] == "G0556"
|
||||||
|
|||||||
@@ -87,3 +87,14 @@ class TestNewKnobs:
|
|||||||
cfg = llm_config.load()
|
cfg = llm_config.load()
|
||||||
assert cfg.ollama_hosts == ("http://rig:11434", "http://laptop:11434")
|
assert cfg.ollama_hosts == ("http://rig:11434", "http://laptop:11434")
|
||||||
assert cfg.host_vram == {"http://rig:11434": 24.0, "http://laptop:11434": 12.0}
|
assert cfg.host_vram == {"http://rig:11434": 24.0, "http://laptop:11434": 12.0}
|
||||||
|
|
||||||
|
def test_valuation_knobs_defaults(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("LLM_DUCKDB_REPLICA", raising=False)
|
||||||
|
cfg = llm_config.load()
|
||||||
|
assert cfg.duckdb_replica == "data/replica/aco.ro.duckdb"
|
||||||
|
assert cfg.valuation_years == 4
|
||||||
|
assert cfg.code_cited_per_code == 3
|
||||||
|
|
||||||
|
def test_duckdb_replica_env_override(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("LLM_DUCKDB_REPLICA", "/app/data/replica/aco.ro.duckdb")
|
||||||
|
assert llm_config.load().duckdb_replica == "/app/data/replica/aco.ro.duckdb"
|
||||||
|
|||||||
404
tests/llm/test_evidence.py
Normal file
404
tests/llm/test_evidence.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
"""llm.evidence — valuation prompt block, SSE payload, code-cited rule sources."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from dataclasses import replace
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from llm import evidence
|
||||||
|
from llm.config import LlmConfig
|
||||||
|
from llm.evidence import (
|
||||||
|
ValuationEvidence,
|
||||||
|
code_cited_sources,
|
||||||
|
merge_sources,
|
||||||
|
valuation_evidence,
|
||||||
|
)
|
||||||
|
from pfs.valuation import ValuationRow
|
||||||
|
|
||||||
|
CFG = LlmConfig(
|
||||||
|
ollama_hosts=("http://h1:11434",),
|
||||||
|
embed_model="e",
|
||||||
|
instruct_model="c",
|
||||||
|
embed_dim=768,
|
||||||
|
build_ann_index=False,
|
||||||
|
pg_host="x",
|
||||||
|
pg_port=5432,
|
||||||
|
pg_db="llm",
|
||||||
|
pg_user="llm",
|
||||||
|
duckdb_replica="/nonexistent/aco.ro.duckdb",
|
||||||
|
valuation_years=4,
|
||||||
|
code_cited_per_code=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_replica_cache():
|
||||||
|
"""The replica handle is process-global; no test may inherit another's."""
|
||||||
|
evidence._REPLICA = None
|
||||||
|
yield
|
||||||
|
evidence._REPLICA = None
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kw) -> ValuationRow:
|
||||||
|
base = dict(
|
||||||
|
code="G0556",
|
||||||
|
description="Adv prim care mgmt lvl 1",
|
||||||
|
vintage="CY2026 final",
|
||||||
|
year=2026,
|
||||||
|
proposed=False,
|
||||||
|
status="A",
|
||||||
|
work=0.25,
|
||||||
|
pe_nf=0.22,
|
||||||
|
pe_f=0.06,
|
||||||
|
mp=0.02,
|
||||||
|
total_nf=0.49,
|
||||||
|
total_f=0.33,
|
||||||
|
cf=33.4009,
|
||||||
|
pay_nf=16.37,
|
||||||
|
pay_f=11.02,
|
||||||
|
label="[PFS CY2026 Addendum B]",
|
||||||
|
citation="90 FR 49266",
|
||||||
|
url="https://www.federalregister.gov/citation/90-FR-49266",
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return ValuationRow(**base)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvidenceText:
|
||||||
|
def test_prompt_block_exact(self):
|
||||||
|
ev = ValuationEvidence(
|
||||||
|
codes=("G0556",), families=("APCM",), rows=(_row(),), unpriced=("Z9999",)
|
||||||
|
)
|
||||||
|
assert ev.prompt_block() == (
|
||||||
|
"Valuation (authoritative for RVUs, conversion factors and payments; "
|
||||||
|
"national unadjusted, no GPCI):\n"
|
||||||
|
'[PFS CY2026 Addendum B] G0556 "Adv prim care mgmt lvl 1" — status A; work 0.25; '
|
||||||
|
"PE 0.22 non-fac / 0.06 fac; MP 0.02; total 0.49 non-fac / 0.33 fac; "
|
||||||
|
"CF $33.4009 → payment $16.37 non-fac / $11.02 fac\n"
|
||||||
|
"Not priced in the indexed fee schedule: Z9999"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_prompt_block_labels_the_cf_and_an_unpaid_status(self):
|
||||||
|
ev = ValuationEvidence(
|
||||||
|
("G0559",),
|
||||||
|
(),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
code="G0559",
|
||||||
|
status="B",
|
||||||
|
status_note="bundled — no separate PFS payment",
|
||||||
|
cf_note="non-APM standard CF; QP CF $33.5675",
|
||||||
|
pay_nf=None,
|
||||||
|
pay_f=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
line = ev.prompt_block().splitlines()[1]
|
||||||
|
assert "— status B; bundled — no separate PFS payment; work" in line
|
||||||
|
assert "CF $33.4009 [non-APM standard CF; QP CF $33.5675] → payment" in line
|
||||||
|
assert "payment n/a non-fac / n/a fac" in line
|
||||||
|
|
||||||
|
def test_prompt_block_null_components(self):
|
||||||
|
ev = ValuationEvidence(
|
||||||
|
("G9999",),
|
||||||
|
(),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
code="G9999",
|
||||||
|
status="I",
|
||||||
|
work=None,
|
||||||
|
pe_nf=None,
|
||||||
|
pe_f=None,
|
||||||
|
mp=None,
|
||||||
|
total_nf=None,
|
||||||
|
total_f=None,
|
||||||
|
pay_nf=None,
|
||||||
|
pay_f=None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
line = ev.prompt_block().splitlines()[1]
|
||||||
|
assert (
|
||||||
|
"status I; work n/a; PE n/a non-fac / n/a fac" in line
|
||||||
|
and "payment n/a" in line
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_payload_shape(self):
|
||||||
|
ev = ValuationEvidence(
|
||||||
|
("G0556",),
|
||||||
|
("APCM",),
|
||||||
|
(
|
||||||
|
_row(),
|
||||||
|
_row(
|
||||||
|
year=2027,
|
||||||
|
proposed=True,
|
||||||
|
vintage="CY2027 proposed",
|
||||||
|
label="[CY2027 NPRM Addendum B]",
|
||||||
|
citation="91 FR 43842",
|
||||||
|
url="https://www.federalregister.gov/citation/91-FR-43842",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(),
|
||||||
|
)
|
||||||
|
p = ev.payload()
|
||||||
|
assert (
|
||||||
|
p["type"] == "valuation"
|
||||||
|
and p["codes"] == ["G0556"]
|
||||||
|
and p["families"] == ["APCM"]
|
||||||
|
)
|
||||||
|
assert p["rows"][0]["pay_nf"] == 16.37 and p["rows"][1]["proposed"] is True
|
||||||
|
assert p["rows"][0]["status_note"] == "" and p["rows"][0]["cf_note"] == ""
|
||||||
|
assert p["provenance"] == [
|
||||||
|
{
|
||||||
|
"label": "[PFS CY2026 Addendum B]",
|
||||||
|
"vintage": "CY2026 final",
|
||||||
|
"citation": "90 FR 49266",
|
||||||
|
"url": "https://www.federalregister.gov/citation/90-FR-49266",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "[CY2027 NPRM Addendum B]",
|
||||||
|
"vintage": "CY2027 proposed",
|
||||||
|
"citation": "91 FR 43842",
|
||||||
|
"url": "https://www.federalregister.gov/citation/91-FR-43842",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
assert p["unpriced"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestValuationEvidence:
|
||||||
|
def test_none_when_no_codes(self):
|
||||||
|
assert (
|
||||||
|
valuation_evidence("what did commenters say about telehealth?", CFG) is None
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("llm.evidence.valuation", return_value=([_row()], []))
|
||||||
|
@patch("llm.evidence.duckdb.connect")
|
||||||
|
def test_detects_and_queries_replica_read_only(self, mock_connect, mock_val):
|
||||||
|
ev = valuation_evidence("How is APCM valued?", CFG)
|
||||||
|
mock_connect.assert_called_once_with(
|
||||||
|
"/nonexistent/aco.ro.duckdb", read_only=True
|
||||||
|
)
|
||||||
|
assert mock_val.call_args.args[1] == ["G0556", "G0557", "G0558"]
|
||||||
|
assert mock_val.call_args.kwargs == {"years": 4}
|
||||||
|
assert (
|
||||||
|
ev is not None
|
||||||
|
and ev.codes == ("G0556", "G0557", "G0558")
|
||||||
|
and ev.families == ("APCM",)
|
||||||
|
)
|
||||||
|
mock_connect.return_value.close.assert_not_called() # cached, not closed
|
||||||
|
|
||||||
|
@patch("llm.evidence.duckdb.connect", side_effect=OSError("no replica"))
|
||||||
|
def test_unopenable_replica_yields_none(self, _c, caplog):
|
||||||
|
assert valuation_evidence("G0556?", CFG) is None
|
||||||
|
assert "valuation evidence skipped" in caplog.text
|
||||||
|
|
||||||
|
@patch("llm.evidence.valuation", return_value=([_row()], []))
|
||||||
|
@patch("llm.evidence.duckdb.connect")
|
||||||
|
def test_replica_connection_is_reused(self, mock_connect, _val):
|
||||||
|
valuation_evidence("G0556?", CFG)
|
||||||
|
valuation_evidence("G0557?", CFG)
|
||||||
|
mock_connect.assert_called_once()
|
||||||
|
mock_connect.return_value.close.assert_not_called()
|
||||||
|
|
||||||
|
@patch("llm.evidence.valuation", return_value=([_row()], []))
|
||||||
|
@patch("llm.evidence.duckdb.connect")
|
||||||
|
def test_republished_replica_reopens_and_closes_the_old_handle(
|
||||||
|
self, mock_connect, _val, monkeypatch
|
||||||
|
):
|
||||||
|
first, second = MagicMock(), MagicMock()
|
||||||
|
mock_connect.side_effect = [first, second]
|
||||||
|
mtimes = iter([1, 2])
|
||||||
|
monkeypatch.setattr(evidence, "_mtime", lambda _p: next(mtimes))
|
||||||
|
valuation_evidence("G0556?", CFG)
|
||||||
|
valuation_evidence("G0557?", CFG)
|
||||||
|
assert mock_connect.call_count == 2
|
||||||
|
first.close.assert_called_once()
|
||||||
|
second.close.assert_not_called()
|
||||||
|
|
||||||
|
@patch("llm.evidence.valuation", return_value=([], []))
|
||||||
|
@patch("llm.evidence.duckdb.connect")
|
||||||
|
def test_many_codes_shorten_the_window(self, _c, mock_val):
|
||||||
|
valuation_evidence("How is CCM valued?", CFG) # six-code family
|
||||||
|
assert mock_val.call_args.kwargs == {"years": 2}
|
||||||
|
|
||||||
|
|
||||||
|
RVU_COLS = (
|
||||||
|
"hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, "
|
||||||
|
"work_rvu DOUBLE, non_fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE, mp_rvu DOUBLE, "
|
||||||
|
"non_fac_total DOUBLE, fac_total DOUBLE, conv_factor DOUBLE, year INTEGER"
|
||||||
|
)
|
||||||
|
PROPOSED_COLS = (
|
||||||
|
"hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, "
|
||||||
|
"work_rvu DOUBLE, non_fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE, mp_rvu DOUBLE, "
|
||||||
|
"cms_rule_id VARCHAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrentChats:
|
||||||
|
"""/chat streams run in Starlette's threadpool, so several turns share
|
||||||
|
the cached replica handle. Without a per-call cursor their result sets
|
||||||
|
cross-talk and the broad except swallows it as "no valuation"."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def replica(self, tmp_path):
|
||||||
|
db = tmp_path / "aco.ro.duckdb"
|
||||||
|
con = duckdb.connect(str(db))
|
||||||
|
con.execute("CREATE SCHEMA pfs")
|
||||||
|
con.execute(f"CREATE TABLE pfs.rvu ({RVU_COLS})")
|
||||||
|
con.execute(f"CREATE TABLE pfs.rvu_proposed ({PROPOSED_COLS})")
|
||||||
|
# two unfamilied codes with different row counts, so a swapped
|
||||||
|
# result set is visible as a wrong count
|
||||||
|
con.executemany(
|
||||||
|
"INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"99213",
|
||||||
|
None,
|
||||||
|
"Office visit",
|
||||||
|
"A",
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
0.5,
|
||||||
|
0.05,
|
||||||
|
2.05,
|
||||||
|
1.55,
|
||||||
|
30.0,
|
||||||
|
2026,
|
||||||
|
),
|
||||||
|
*[
|
||||||
|
(
|
||||||
|
"99214",
|
||||||
|
None,
|
||||||
|
"Office visit",
|
||||||
|
"A",
|
||||||
|
1.5,
|
||||||
|
1.0,
|
||||||
|
0.5,
|
||||||
|
0.05,
|
||||||
|
2.55,
|
||||||
|
2.05,
|
||||||
|
30.0,
|
||||||
|
y,
|
||||||
|
)
|
||||||
|
for y in (2024, 2025, 2026)
|
||||||
|
],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
con.close()
|
||||||
|
return db
|
||||||
|
|
||||||
|
def test_parallel_turns_share_one_handle_without_cross_talk(self, replica):
|
||||||
|
cfg = replace(CFG, duckdb_replica=str(replica))
|
||||||
|
real_connect, opens = duckdb.connect, []
|
||||||
|
|
||||||
|
def counting_connect(*a, **kw):
|
||||||
|
opens.append(a)
|
||||||
|
return real_connect(*a, **kw)
|
||||||
|
|
||||||
|
expected = {"99213": 1, "99214": 3}
|
||||||
|
questions = ["What does 99213 pay?", "What does 99214 pay?"]
|
||||||
|
|
||||||
|
def one(i):
|
||||||
|
q = questions[i % 2]
|
||||||
|
ev = valuation_evidence(q, cfg)
|
||||||
|
assert ev is not None, "valuation dropped under concurrency"
|
||||||
|
(code,) = ev.codes
|
||||||
|
return code, len(ev.rows)
|
||||||
|
|
||||||
|
with patch("llm.evidence.duckdb.connect", counting_connect):
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
results = list(pool.map(one, range(160)))
|
||||||
|
|
||||||
|
assert all(n == expected[code] for code, n in results), sorted(set(results))
|
||||||
|
assert len(opens) == 1 # one handle, shared
|
||||||
|
|
||||||
|
def test_cursor_is_closed_but_the_handle_is_kept(self, replica):
|
||||||
|
cfg = replace(CFG, duckdb_replica=str(replica))
|
||||||
|
assert valuation_evidence("99213?", cfg) is not None
|
||||||
|
assert evidence._REPLICA is not None
|
||||||
|
# the cached parent still answers — it was never closed
|
||||||
|
assert valuation_evidence("99214?", cfg) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _engine(rows):
|
||||||
|
engine = MagicMock()
|
||||||
|
conn = engine.begin.return_value.__enter__.return_value
|
||||||
|
conn.execute.return_value.fetchall.return_value = rows
|
||||||
|
return engine, conn
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_md(p_id, codes, date="2024-12-09"):
|
||||||
|
return {
|
||||||
|
"kind": "rule",
|
||||||
|
"item_key": "R1",
|
||||||
|
"p_id": str(p_id),
|
||||||
|
"page": "97710",
|
||||||
|
"ordinal": "1",
|
||||||
|
"html_url": "https://www.federalregister.gov/d/2024-25382",
|
||||||
|
"fr_volume": "89",
|
||||||
|
"date": date,
|
||||||
|
"title": "CY2025 PFS final rule",
|
||||||
|
"codes": codes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCodeCitedSources:
|
||||||
|
def test_sql_and_per_code_cap_and_dedupe(self):
|
||||||
|
engine, conn = _engine(
|
||||||
|
[
|
||||||
|
("para one G0556", _rule_md(1, "G0556")),
|
||||||
|
("para two G0556", _rule_md(2, "G0556")),
|
||||||
|
("para three G0556", _rule_md(3, "G0556")), # over the cap for G0556
|
||||||
|
(
|
||||||
|
"para four G0557",
|
||||||
|
_rule_md(4, "G0557 G0556"),
|
||||||
|
), # G0557 still under cap
|
||||||
|
("para one again", _rule_md(1, "G0556")), # duplicate (item_key, p_id)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
out = code_cited_sources(engine, ["G0556", "G0557"], per_code=2)
|
||||||
|
sql = str(conn.execute.call_args.args[0])
|
||||||
|
assert "string_to_array" in sql and "langchain_pg_collection" in sql
|
||||||
|
assert "PARTITION BY" in sql
|
||||||
|
# the collection is a scalar subquery, not a join to filter after
|
||||||
|
assert "JOIN langchain_pg_collection" not in sql
|
||||||
|
assert "e.collection_id = (SELECT uuid FROM langchain_pg_collection" in sql
|
||||||
|
assert conn.execute.call_args.args[1]["collection"] == "rules"
|
||||||
|
assert conn.execute.call_args.args[1]["codes"] == ["G0556", "G0557"]
|
||||||
|
assert conn.execute.call_args.args[1]["window"] == 4
|
||||||
|
assert [s["snippet"] for s in out] == [
|
||||||
|
"para one G0556",
|
||||||
|
"para two G0556",
|
||||||
|
"para four G0557",
|
||||||
|
]
|
||||||
|
assert all(s["kind"] == "rule" and s["score"] == 0.0 for s in out)
|
||||||
|
|
||||||
|
def test_snippets_are_capped(self):
|
||||||
|
engine, _ = _engine([("x" * 900, _rule_md(1, "G0556"))])
|
||||||
|
(out,) = code_cited_sources(engine, ["G0556"], per_code=2)
|
||||||
|
assert len(out["snippet"]) == 250
|
||||||
|
|
||||||
|
def test_engine_error_yields_empty(self, caplog):
|
||||||
|
engine = MagicMock()
|
||||||
|
engine.begin.side_effect = RuntimeError("pg down")
|
||||||
|
assert code_cited_sources(engine, ["G0556"], per_code=2) == []
|
||||||
|
assert "code-cited sources skipped" in caplog.text
|
||||||
|
|
||||||
|
def test_no_codes_returns_empty_without_querying(self):
|
||||||
|
engine = MagicMock()
|
||||||
|
assert code_cited_sources(engine, [], per_code=2) == []
|
||||||
|
engine.begin.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeSources:
|
||||||
|
def test_keeps_order_and_dedupes_by_label(self):
|
||||||
|
a = [{"label": "X", "score": 0.9}, {"label": "Y", "score": 0.8}]
|
||||||
|
b = [{"label": "Y", "score": 0.0}, {"label": "Z", "score": 0.0}]
|
||||||
|
assert [s["label"] for s in merge_sources(a, b)] == ["X", "Y", "Z"]
|
||||||
|
assert merge_sources(a, b)[1]["score"] == 0.8
|
||||||
@@ -127,3 +127,34 @@ class TestCorpus:
|
|||||||
def test_unknown_kind_treated_as_corpus():
|
def test_unknown_kind_treated_as_corpus():
|
||||||
url, label = for_source({"url": "https://u.test", "title": "T"}, "s")
|
url, label = for_source({"url": "https://u.test", "title": "T"}, "s")
|
||||||
assert (url, label) == ("https://u.test", "T")
|
assert (url, label) == ("https://u.test", "T")
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsSource:
|
||||||
|
def test_shape_matches_rag_source(self):
|
||||||
|
from llm.links import as_source
|
||||||
|
|
||||||
|
s = as_source(
|
||||||
|
{
|
||||||
|
"kind": "comment",
|
||||||
|
"comment_id": "CMS-2026-2377-1",
|
||||||
|
"date": "2026-08-19",
|
||||||
|
"title": "t",
|
||||||
|
"docket": "CMS-2026-2377",
|
||||||
|
},
|
||||||
|
"body " * 200,
|
||||||
|
0.123456,
|
||||||
|
)
|
||||||
|
assert set(s) == {
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"kind",
|
||||||
|
"url",
|
||||||
|
"title",
|
||||||
|
"date",
|
||||||
|
"docket",
|
||||||
|
"comment_id",
|
||||||
|
"snippet",
|
||||||
|
"score",
|
||||||
|
}
|
||||||
|
assert s["label"] == "CMS-2026-2377-1" and s["kind"] == "comment"
|
||||||
|
assert len(s["snippet"]) <= 500 and s["score"] == 0.1235
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ class TestDdl:
|
|||||||
executed = " ".join(str(call.args[0]) for call in conn.execute.call_args_list)
|
executed = " ".join(str(call.args[0]) for call in conn.execute.call_args_list)
|
||||||
assert "vector(768)" in executed
|
assert "vector(768)" in executed
|
||||||
assert "USING hnsw" in executed
|
assert "USING hnsw" in executed
|
||||||
|
# code-cited lookups scan cmetadata->>'codes' inside one collection
|
||||||
|
assert (
|
||||||
|
"ix_embedding_codes" in executed
|
||||||
|
and "USING gin (string_to_array" in executed
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
"ix_embedding_collection" in executed
|
||||||
|
and "langchain_pg_embedding (collection_id)" in executed
|
||||||
|
)
|
||||||
|
|
||||||
def test_fingerprint_column_and_docket_state(self):
|
def test_fingerprint_column_and_docket_state(self):
|
||||||
assert "fingerprint" in migrate.INDEX_STATE_DDL
|
assert "fingerprint" in migrate.INDEX_STATE_DDL
|
||||||
|
|||||||
@@ -247,6 +247,46 @@ class TestBuildMessages:
|
|||||||
msgs = build_messages("q", [])
|
msgs = build_messages("q", [])
|
||||||
assert "no relevant excerpts" in msgs[1]["content"].lower()
|
assert "no relevant excerpts" in msgs[1]["content"].lower()
|
||||||
|
|
||||||
|
def test_evidence_block_between_excerpts_and_question(self):
|
||||||
|
from llm.evidence import ValuationEvidence
|
||||||
|
from pfs.valuation import ValuationRow
|
||||||
|
|
||||||
|
row = ValuationRow(
|
||||||
|
code="G0556",
|
||||||
|
description="d",
|
||||||
|
vintage="CY2026 final",
|
||||||
|
year=2026,
|
||||||
|
proposed=False,
|
||||||
|
status="A",
|
||||||
|
work=0.25,
|
||||||
|
pe_nf=0.22,
|
||||||
|
pe_f=0.06,
|
||||||
|
mp=0.02,
|
||||||
|
total_nf=0.49,
|
||||||
|
total_f=0.33,
|
||||||
|
cf=33.4009,
|
||||||
|
pay_nf=16.37,
|
||||||
|
pay_f=11.02,
|
||||||
|
label="[PFS CY2026 Addendum B]",
|
||||||
|
citation="90 FR 49266",
|
||||||
|
url="u",
|
||||||
|
)
|
||||||
|
ev = ValuationEvidence(("G0556",), ("APCM",), (row,), ())
|
||||||
|
msgs = build_messages("how much?", [], evidence=ev)
|
||||||
|
user = msgs[1]["content"]
|
||||||
|
assert (
|
||||||
|
user.index("Excerpts:")
|
||||||
|
< user.index("Valuation (authoritative")
|
||||||
|
< user.index("Question: how much?")
|
||||||
|
)
|
||||||
|
assert "[PFS CY2026 Addendum B] G0556" in user
|
||||||
|
assert "valuation" in msgs[0]["content"].lower()
|
||||||
|
assert "do not compute" in msgs[0]["content"].lower()
|
||||||
|
|
||||||
|
def test_no_evidence_prompt_unchanged(self):
|
||||||
|
assert build_messages("q", []) == build_messages("q", [], evidence=None)
|
||||||
|
assert "Valuation" not in build_messages("q", [])[1]["content"]
|
||||||
|
|
||||||
|
|
||||||
class TestStreamAnswer:
|
class TestStreamAnswer:
|
||||||
def _pool(self, vram=24.0, serves_big=True):
|
def _pool(self, vram=24.0, serves_big=True):
|
||||||
@@ -256,9 +296,13 @@ class TestStreamAnswer:
|
|||||||
pool.serves.return_value = serves_big
|
pool.serves.return_value = serves_big
|
||||||
return pool
|
return pool
|
||||||
|
|
||||||
|
@patch("llm.rag._engine")
|
||||||
|
@patch("llm.rag.valuation_evidence", return_value=None)
|
||||||
@patch("llm.rag.httpx.Client")
|
@patch("llm.rag.httpx.Client")
|
||||||
@patch("llm.rag.retrieve")
|
@patch("llm.rag.retrieve")
|
||||||
def test_yields_tokens_then_sources_then_done(self, mock_retrieve, MockClient):
|
def test_yields_tokens_then_sources_then_done(
|
||||||
|
self, mock_retrieve, MockClient, _ev, mock_engine
|
||||||
|
):
|
||||||
src = {
|
src = {
|
||||||
"id": "C1",
|
"id": "C1",
|
||||||
"label": "C1",
|
"label": "C1",
|
||||||
@@ -281,6 +325,8 @@ class TestStreamAnswer:
|
|||||||
events = list(stream_answer("q", cfg=CFG, pool=pool))
|
events = list(stream_answer("q", cfg=CFG, pool=pool))
|
||||||
|
|
||||||
pool.check.assert_called_once_with("chat")
|
pool.check.assert_called_once_with("chat")
|
||||||
|
# no codes in the question — pgvector is never asked for cited rules
|
||||||
|
mock_engine.assert_not_called()
|
||||||
mock_retrieve.assert_called_once_with("q", cfg=CFG, pool=pool, since="")
|
mock_retrieve.assert_called_once_with("q", cfg=CFG, pool=pool, since="")
|
||||||
assert events[0] == {"type": "token", "text": "Doc"}
|
assert events[0] == {"type": "token", "text": "Doc"}
|
||||||
assert events[1] == {"type": "token", "text": "tors"}
|
assert events[1] == {"type": "token", "text": "tors"}
|
||||||
@@ -296,9 +342,10 @@ class TestStreamAnswer:
|
|||||||
assert body["options"] == {"num_ctx": 8192}
|
assert body["options"] == {"num_ctx": 8192}
|
||||||
assert client.stream.call_args.args[1] == "http://h1:11434/api/chat"
|
assert client.stream.call_args.args[1] == "http://h1:11434/api/chat"
|
||||||
|
|
||||||
|
@patch("llm.rag.valuation_evidence", return_value=None)
|
||||||
@patch("llm.rag.httpx.Client")
|
@patch("llm.rag.httpx.Client")
|
||||||
@patch("llm.rag.retrieve")
|
@patch("llm.rag.retrieve")
|
||||||
def test_small_host_uses_baseline_model(self, mock_retrieve, MockClient):
|
def test_small_host_uses_baseline_model(self, mock_retrieve, MockClient, _ev):
|
||||||
mock_retrieve.return_value = []
|
mock_retrieve.return_value = []
|
||||||
client = MockClient.return_value.__enter__.return_value
|
client = MockClient.return_value.__enter__.return_value
|
||||||
resp = client.stream.return_value.__enter__.return_value
|
resp = client.stream.return_value.__enter__.return_value
|
||||||
@@ -307,9 +354,10 @@ class TestStreamAnswer:
|
|||||||
assert client.stream.call_args.kwargs["json"]["model"] == "chat"
|
assert client.stream.call_args.kwargs["json"]["model"] == "chat"
|
||||||
assert events[-2]["model"] == "chat"
|
assert events[-2]["model"] == "chat"
|
||||||
|
|
||||||
|
@patch("llm.rag.valuation_evidence", return_value=None)
|
||||||
@patch("llm.rag.httpx.Client")
|
@patch("llm.rag.httpx.Client")
|
||||||
@patch("llm.rag.retrieve")
|
@patch("llm.rag.retrieve")
|
||||||
def test_since_forwarded(self, mock_retrieve, MockClient):
|
def test_since_forwarded(self, mock_retrieve, MockClient, _ev):
|
||||||
mock_retrieve.return_value = []
|
mock_retrieve.return_value = []
|
||||||
client = MockClient.return_value.__enter__.return_value
|
client = MockClient.return_value.__enter__.return_value
|
||||||
resp = client.stream.return_value.__enter__.return_value
|
resp = client.stream.return_value.__enter__.return_value
|
||||||
@@ -317,12 +365,55 @@ class TestStreamAnswer:
|
|||||||
list(stream_answer("q", cfg=CFG, pool=self._pool(), since="2025-09-01"))
|
list(stream_answer("q", cfg=CFG, pool=self._pool(), since="2025-09-01"))
|
||||||
assert mock_retrieve.call_args.kwargs["since"] == "2025-09-01"
|
assert mock_retrieve.call_args.kwargs["since"] == "2025-09-01"
|
||||||
|
|
||||||
|
@patch("llm.rag.valuation_evidence", return_value=None)
|
||||||
@patch("llm.rag.httpx.Client")
|
@patch("llm.rag.httpx.Client")
|
||||||
@patch("llm.rag.retrieve")
|
@patch("llm.rag.retrieve")
|
||||||
def test_http_error_propagates(self, mock_retrieve, MockClient):
|
def test_http_error_propagates(self, mock_retrieve, MockClient, _ev):
|
||||||
mock_retrieve.return_value = []
|
mock_retrieve.return_value = []
|
||||||
client = MockClient.return_value.__enter__.return_value
|
client = MockClient.return_value.__enter__.return_value
|
||||||
resp = client.stream.return_value.__enter__.return_value
|
resp = client.stream.return_value.__enter__.return_value
|
||||||
resp.raise_for_status.side_effect = RuntimeError("ollama down")
|
resp.raise_for_status.side_effect = RuntimeError("ollama down")
|
||||||
with pytest.raises(RuntimeError, match="ollama down"):
|
with pytest.raises(RuntimeError, match="ollama down"):
|
||||||
list(stream_answer("q", cfg=CFG, pool=self._pool()))
|
list(stream_answer("q", cfg=CFG, pool=self._pool()))
|
||||||
|
|
||||||
|
@patch("llm.rag._engine")
|
||||||
|
@patch("llm.rag.code_cited_sources")
|
||||||
|
@patch("llm.rag.valuation_evidence")
|
||||||
|
@patch("llm.rag.httpx.Client")
|
||||||
|
@patch("llm.rag.retrieve")
|
||||||
|
def test_valuation_event_first_and_sources_merged(
|
||||||
|
self, mock_retrieve, MockClient, mock_ev, mock_cited, mock_engine
|
||||||
|
):
|
||||||
|
from llm.evidence import ValuationEvidence
|
||||||
|
|
||||||
|
src = {
|
||||||
|
"id": "C1",
|
||||||
|
"label": "C1",
|
||||||
|
"kind": "comment",
|
||||||
|
"snippet": "s",
|
||||||
|
"score": 0.1,
|
||||||
|
}
|
||||||
|
cited = {
|
||||||
|
"id": "89 FR 97710 ¶3",
|
||||||
|
"label": "89 FR 97710 ¶3",
|
||||||
|
"kind": "rule",
|
||||||
|
"snippet": "G0556",
|
||||||
|
"score": 0.0,
|
||||||
|
}
|
||||||
|
mock_retrieve.return_value = [src]
|
||||||
|
mock_cited.return_value = [cited]
|
||||||
|
mock_ev.return_value = ValuationEvidence(("G0556",), ("APCM",), (), ())
|
||||||
|
client = MockClient.return_value.__enter__.return_value
|
||||||
|
resp = client.stream.return_value.__enter__.return_value
|
||||||
|
resp.iter_lines.return_value = iter(['{"message":{"content":"x"},"done":true}'])
|
||||||
|
|
||||||
|
events = list(stream_answer("APCM?", cfg=CFG, pool=self._pool()))
|
||||||
|
|
||||||
|
assert events[0]["type"] == "valuation" and events[0]["codes"] == ["G0556"]
|
||||||
|
assert events[1] == {"type": "token", "text": "x"}
|
||||||
|
assert events[-2]["sources"] == [src, cited]
|
||||||
|
mock_cited.assert_called_once_with(
|
||||||
|
mock_engine.return_value, ("G0556",), per_code=CFG.code_cited_per_code
|
||||||
|
)
|
||||||
|
body = client.stream.call_args.kwargs["json"]
|
||||||
|
assert "Valuation (authoritative" in body["messages"][1]["content"]
|
||||||
|
|||||||
76
tests/pfs/test_families.py
Normal file
76
tests/pfs/test_families.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""pfs.families — code-family registry + deterministic code detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pfs.families import FAMILIES, Detection, detect_codes, family_of, find_codes
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCodes:
|
||||||
|
def test_hcpcs_and_cpt(self):
|
||||||
|
assert find_codes("Codes G0556 and 99490 apply; see g0557.") == (
|
||||||
|
"99490",
|
||||||
|
"G0556",
|
||||||
|
"G0557",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_lower_case_codes_are_normalised(self):
|
||||||
|
assert find_codes("what is g0556?") == ("G0556",)
|
||||||
|
|
||||||
|
def test_no_false_hits_on_years_and_fr_pages(self):
|
||||||
|
# A Federal Register citation is stripped before scanning, so its page
|
||||||
|
# number (43842 — a well-formed CPT code) never reads as a code.
|
||||||
|
assert find_codes("91 FR 43842, CY2026") == ()
|
||||||
|
|
||||||
|
def test_fr_citation_alone_yields_nothing(self):
|
||||||
|
assert find_codes("What does 91 FR 43842 say?") == ()
|
||||||
|
|
||||||
|
def test_dedupes_and_sorts(self):
|
||||||
|
assert find_codes("G0558 G0556 G0558") == ("G0556", "G0558")
|
||||||
|
|
||||||
|
def test_empty(self):
|
||||||
|
assert find_codes("") == ()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistry:
|
||||||
|
def test_apcm_family(self):
|
||||||
|
f = FAMILIES["APCM"]
|
||||||
|
assert f.codes == ("G0556", "G0557", "G0558")
|
||||||
|
assert "advanced primary care management" in f.synonyms
|
||||||
|
assert family_of("G0557") is f
|
||||||
|
assert family_of("00000") is None
|
||||||
|
|
||||||
|
def test_all_families_present(self):
|
||||||
|
assert set(FAMILIES) == {"ACP", "CCM", "PCM", "TCM", "APCM"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectCodes:
|
||||||
|
def test_family_name_expands_to_codes(self):
|
||||||
|
d = detect_codes("What is APCM and how is it valued?")
|
||||||
|
assert d == Detection(
|
||||||
|
codes=("G0556", "G0557", "G0558"), families=("APCM",), explicit=()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_synonym_case_insensitive_word_boundary(self):
|
||||||
|
d = detect_codes("Tell me about Advanced Primary Care Management.")
|
||||||
|
assert d.families == ("APCM",)
|
||||||
|
assert detect_codes("the apcmx code").families == ()
|
||||||
|
|
||||||
|
def test_single_member_code_expands_family(self):
|
||||||
|
d = detect_codes("How much does G0557 pay?")
|
||||||
|
assert d.explicit == ("G0557",)
|
||||||
|
assert d.families == ("APCM",)
|
||||||
|
assert d.codes == ("G0556", "G0557", "G0558")
|
||||||
|
|
||||||
|
def test_unregistered_code_stays_alone(self):
|
||||||
|
d = detect_codes("value of 99213")
|
||||||
|
assert d == Detection(codes=("99213",), families=(), explicit=("99213",))
|
||||||
|
|
||||||
|
def test_multiple_families(self):
|
||||||
|
d = detect_codes("compare CCM and TCM")
|
||||||
|
assert d.families == ("CCM", "TCM")
|
||||||
|
assert d.codes == tuple(sorted(FAMILIES["CCM"].codes + FAMILIES["TCM"].codes))
|
||||||
|
|
||||||
|
def test_nothing(self):
|
||||||
|
assert detect_codes("what did commenters say about telehealth?") == Detection(
|
||||||
|
(), (), ()
|
||||||
|
)
|
||||||
295
tests/pfs/test_valuation.py
Normal file
295
tests/pfs/test_valuation.py
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
"""pfs.valuation — RVUs + national payment by vintage from a DuckDB replica."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pfs.rules import RULES, proposed_for
|
||||||
|
from pfs.valuation import ValuationRow, fr_citation_url, valuation, vintage_label
|
||||||
|
|
||||||
|
RVU_COLS = (
|
||||||
|
"hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, "
|
||||||
|
"work_rvu DOUBLE, non_fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE, mp_rvu DOUBLE, "
|
||||||
|
"non_fac_total DOUBLE, fac_total DOUBLE, conv_factor DOUBLE, year INTEGER"
|
||||||
|
)
|
||||||
|
PROPOSED_COLS = (
|
||||||
|
"hcpcs VARCHAR, mod VARCHAR, description VARCHAR, status_code VARCHAR, "
|
||||||
|
"work_rvu DOUBLE, non_fac_pe_rvu DOUBLE, fac_pe_rvu DOUBLE, mp_rvu DOUBLE, cms_rule_id VARCHAR"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def con():
|
||||||
|
c = duckdb.connect(":memory:")
|
||||||
|
c.execute("CREATE SCHEMA pfs")
|
||||||
|
c.execute(f"CREATE TABLE pfs.rvu ({RVU_COLS})")
|
||||||
|
c.execute(f"CREATE TABLE pfs.rvu_proposed ({PROPOSED_COLS})")
|
||||||
|
rows = [
|
||||||
|
# G0556: 2025 + 2026 final, with a modifier row that must be ignored
|
||||||
|
(
|
||||||
|
"G0556",
|
||||||
|
None,
|
||||||
|
"Adv prim care mgmt lvl 1",
|
||||||
|
"A",
|
||||||
|
0.25,
|
||||||
|
0.20,
|
||||||
|
0.10,
|
||||||
|
0.02,
|
||||||
|
0.47,
|
||||||
|
0.37,
|
||||||
|
32.3465,
|
||||||
|
2025,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"G0556",
|
||||||
|
None,
|
||||||
|
"Adv prim care mgmt lvl 1",
|
||||||
|
"A",
|
||||||
|
0.25,
|
||||||
|
0.22,
|
||||||
|
0.06,
|
||||||
|
0.02,
|
||||||
|
0.49,
|
||||||
|
0.33,
|
||||||
|
33.4009,
|
||||||
|
2026,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"G0556",
|
||||||
|
"26",
|
||||||
|
"Adv prim care mgmt lvl 1",
|
||||||
|
"A",
|
||||||
|
9.0,
|
||||||
|
9.0,
|
||||||
|
9.0,
|
||||||
|
9.0,
|
||||||
|
27.0,
|
||||||
|
27.0,
|
||||||
|
33.4009,
|
||||||
|
2026,
|
||||||
|
),
|
||||||
|
# 99490: six years so the last-N cut applies (2021..2026)
|
||||||
|
*[
|
||||||
|
(
|
||||||
|
"99490",
|
||||||
|
"",
|
||||||
|
"Chrnc care mgmt srvc 20 min",
|
||||||
|
"A",
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
0.5,
|
||||||
|
0.05,
|
||||||
|
2.05,
|
||||||
|
1.55,
|
||||||
|
30.0,
|
||||||
|
y,
|
||||||
|
)
|
||||||
|
for y in range(2021, 2027)
|
||||||
|
],
|
||||||
|
# A vintage older than the first year pfs.rules knows (RULES starts
|
||||||
|
# at 2014): it has no CF, so it must not consume a window slot.
|
||||||
|
(
|
||||||
|
"G0556",
|
||||||
|
None,
|
||||||
|
"Adv prim care mgmt lvl 1",
|
||||||
|
"A",
|
||||||
|
0.25,
|
||||||
|
0.20,
|
||||||
|
0.10,
|
||||||
|
0.02,
|
||||||
|
0.47,
|
||||||
|
0.37,
|
||||||
|
30.0,
|
||||||
|
2013,
|
||||||
|
),
|
||||||
|
# B-status code with full RVUs — bundled, so never paid
|
||||||
|
(
|
||||||
|
"G0559",
|
||||||
|
None,
|
||||||
|
"Bundled service",
|
||||||
|
"B",
|
||||||
|
0.30,
|
||||||
|
0.20,
|
||||||
|
0.10,
|
||||||
|
0.02,
|
||||||
|
0.52,
|
||||||
|
0.42,
|
||||||
|
33.4009,
|
||||||
|
2026,
|
||||||
|
),
|
||||||
|
# I-status code with NULL components
|
||||||
|
(
|
||||||
|
"G9999",
|
||||||
|
None,
|
||||||
|
"Not priced",
|
||||||
|
"I",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
33.4009,
|
||||||
|
2026,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
c.executemany("INSERT INTO pfs.rvu VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", rows)
|
||||||
|
c.executemany(
|
||||||
|
"INSERT INTO pfs.rvu_proposed VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"G0556",
|
||||||
|
None,
|
||||||
|
"Adv prim care mgmt lvl 1",
|
||||||
|
"A",
|
||||||
|
0.25,
|
||||||
|
0.23,
|
||||||
|
0.07,
|
||||||
|
0.02,
|
||||||
|
"CMS-1848-P",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"G0556",
|
||||||
|
"26",
|
||||||
|
"Adv prim care mgmt lvl 1",
|
||||||
|
"A",
|
||||||
|
9.0,
|
||||||
|
9.0,
|
||||||
|
9.0,
|
||||||
|
9.0,
|
||||||
|
"CMS-1848-P",
|
||||||
|
),
|
||||||
|
("G0556", None, "older nprm", "A", 0.1, 0.1, 0.1, 0.1, "CMS-1832-P"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHelpers:
|
||||||
|
def test_vintage_label(self):
|
||||||
|
assert vintage_label(2026, False) == "[PFS CY2026 Addendum B]"
|
||||||
|
assert vintage_label(2027, True) == "[CY2027 NPRM Addendum B]"
|
||||||
|
|
||||||
|
def test_fr_citation_url(self):
|
||||||
|
assert (
|
||||||
|
fr_citation_url("90 FR 49266")
|
||||||
|
== "https://www.federalregister.gov/citation/90-FR-49266"
|
||||||
|
)
|
||||||
|
assert fr_citation_url("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestValuation:
|
||||||
|
def test_final_and_proposed_rows_with_cf_and_payment(self, con):
|
||||||
|
rows, unpriced = valuation(con, ["G0556"], years=4)
|
||||||
|
assert unpriced == []
|
||||||
|
assert [(r.year, r.proposed) for r in rows] == [
|
||||||
|
(2025, False),
|
||||||
|
(2026, False),
|
||||||
|
(2027, True),
|
||||||
|
]
|
||||||
|
r26 = rows[1]
|
||||||
|
assert isinstance(r26, ValuationRow)
|
||||||
|
assert (r26.code, r26.status, r26.work, r26.pe_nf, r26.pe_f, r26.mp) == (
|
||||||
|
"G0556",
|
||||||
|
"A",
|
||||||
|
0.25,
|
||||||
|
0.22,
|
||||||
|
0.06,
|
||||||
|
0.02,
|
||||||
|
)
|
||||||
|
assert (r26.total_nf, r26.total_f) == (0.49, 0.33)
|
||||||
|
assert r26.cf == RULES[2026].conversion_factor
|
||||||
|
assert r26.pay_nf == round(0.49 * RULES[2026].conversion_factor, 2)
|
||||||
|
assert r26.pay_f == round(0.33 * RULES[2026].conversion_factor, 2)
|
||||||
|
assert r26.label == "[PFS CY2026 Addendum B]"
|
||||||
|
assert r26.citation == RULES[2026].federal_register_citation
|
||||||
|
assert r26.url == fr_citation_url(RULES[2026].federal_register_citation)
|
||||||
|
assert r26.vintage == "CY2026 final"
|
||||||
|
p = rows[2]
|
||||||
|
assert p.label == "[CY2027 NPRM Addendum B]" and p.vintage == "CY2027 proposed"
|
||||||
|
assert p.cf == proposed_for(2027).conversion_factor
|
||||||
|
assert p.total_nf == round(0.25 + 0.23 + 0.02, 4) and p.total_f == round(
|
||||||
|
0.25 + 0.07 + 0.02, 4
|
||||||
|
)
|
||||||
|
assert p.pay_nf == round(p.total_nf * p.cf, 2)
|
||||||
|
assert p.citation == proposed_for(2027).federal_register_citation
|
||||||
|
|
||||||
|
def test_modifier_rows_are_ignored(self, con):
|
||||||
|
rows, _ = valuation(con, ["G0556"])
|
||||||
|
assert all(r.work == 0.25 for r in rows)
|
||||||
|
|
||||||
|
def test_last_n_years_only(self, con):
|
||||||
|
rows, _ = valuation(con, ["99490"], years=4)
|
||||||
|
assert [r.year for r in rows if not r.proposed] == [2023, 2024, 2025, 2026]
|
||||||
|
|
||||||
|
def test_unpriced_codes_reported(self, con):
|
||||||
|
rows, unpriced = valuation(con, ["G0556", "Z9999"])
|
||||||
|
assert unpriced == ["Z9999"]
|
||||||
|
assert {r.code for r in rows} == {"G0556"}
|
||||||
|
|
||||||
|
def test_null_components_pass_through(self, con):
|
||||||
|
rows, unpriced = valuation(con, ["G9999"])
|
||||||
|
assert unpriced == []
|
||||||
|
(r,) = rows
|
||||||
|
assert r.status == "I" and r.work is None and r.pay_nf is None
|
||||||
|
|
||||||
|
def test_bundled_status_is_not_paid_and_is_annotated(self, con):
|
||||||
|
rows, _ = valuation(con, ["G0559"])
|
||||||
|
(r,) = rows
|
||||||
|
assert (r.status, r.total_nf, r.total_f) == ("B", 0.52, 0.42)
|
||||||
|
assert r.pay_nf is None and r.pay_f is None
|
||||||
|
assert r.status_note == "bundled — no separate PFS payment"
|
||||||
|
|
||||||
|
def test_paid_statuses_have_no_note(self, con):
|
||||||
|
rows, _ = valuation(con, ["G0556"], years=1)
|
||||||
|
r = rows[0]
|
||||||
|
assert r.status == "A" and r.status_note == ""
|
||||||
|
assert r.pay_nf == round(r.total_nf * r.cf, 2)
|
||||||
|
|
||||||
|
def test_unknown_status_note_names_the_code(self, con):
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO pfs.rvu VALUES "
|
||||||
|
"('G0560',NULL,'Odd','Q',0.1,0.1,0.1,0.0,0.2,0.2,33.4009,2026)"
|
||||||
|
)
|
||||||
|
(r,) = valuation(con, ["G0560"])[0]
|
||||||
|
assert r.pay_nf is None
|
||||||
|
assert r.status_note == "status Q — no RVU-based payment"
|
||||||
|
|
||||||
|
def test_window_counts_only_years_pfs_rules_knows(self, con, caplog):
|
||||||
|
"""A vintage with no RuleYear must not eat a slot in the last-N
|
||||||
|
window — otherwise asking for one year returns nothing at all."""
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
rows, _ = valuation(con, ["G0556"], years=1)
|
||||||
|
assert [(r.year, r.proposed) for r in rows] == [(2026, False), (2027, True)]
|
||||||
|
assert "2013" in caplog.text and "G0556" in caplog.text
|
||||||
|
|
||||||
|
def test_cf_note_final_year(self, con):
|
||||||
|
rows, _ = valuation(con, ["G0556"], years=1)
|
||||||
|
r = rows[0]
|
||||||
|
assert r.cf_note == (f"non-APM standard CF; QP CF ${RULES[2026].cf_qp:.4f}")
|
||||||
|
|
||||||
|
def test_cf_note_flags_a_mid_year_split(self, con):
|
||||||
|
rows, _ = valuation(con, ["99490"], years=3)
|
||||||
|
r24 = next(r for r in rows if r.year == 2024)
|
||||||
|
assert r24.cf_note.startswith("non-APM standard CF; mid-year split")
|
||||||
|
assert "$32.7442 before 2024-03-09" in r24.cf_note
|
||||||
|
|
||||||
|
def test_cf_note_proposed(self, con):
|
||||||
|
rows, _ = valuation(con, ["G0556"], years=1)
|
||||||
|
p = rows[-1]
|
||||||
|
assert p.proposed
|
||||||
|
assert p.cf_note == (
|
||||||
|
f"proposed; non-QP CF; QP CF ${proposed_for(2027).cf_qp:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_order_is_code_then_year(self, con):
|
||||||
|
rows, _ = valuation(con, ["G0556", "99490"], years=2)
|
||||||
|
assert [(r.code, r.year) for r in rows] == [
|
||||||
|
("99490", 2025),
|
||||||
|
("99490", 2026),
|
||||||
|
("G0556", 2025),
|
||||||
|
("G0556", 2026),
|
||||||
|
("G0556", 2027),
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user