merge: P48 LLM chat code valuation — RVUs, CFs and national payments beside the references (refs #682)

This commit is contained in:
kert
2026-09-08 23:41:02 -04:00
25 changed files with 3189 additions and 40 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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
View 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]

View File

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

View File

@@ -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)",
] ]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]

View 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
View 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),
]