fix(llm): lineage collapse to first occurrence per kind, CPT labels, label cache and prompt-only URL resolution (refs #691)
This commit is contained in:
@@ -12,15 +12,22 @@ per turn — that path re-derives events from the RVU files and a live
|
|||||||
scan of ``fr_anchors``, too slow to run for every code on every turn).
|
scan of ``fr_anchors``, too slow to run for every code on every turn).
|
||||||
Events for the same ``(code, year, kind, from_codes, to_codes)`` are
|
Events for the same ``(code, year, kind, from_codes, to_codes)`` are
|
||||||
collapsed to one representative row, preferring an anchored FR
|
collapsed to one representative row, preferring an anchored FR
|
||||||
paragraph. ``pfs.code_element`` supplies element differences across the
|
paragraph; "point in time" kinds (created, adopted_cpt, appeared,
|
||||||
detected codes' newest year, and ``pfs.code_guidance`` supplies CFR/IOM/
|
deleted, disappeared, cpt_deleted) collapse further to their single
|
||||||
MLN references for the detected families. Like ``llm.evidence``, this
|
earliest occurrence per code — a code isn't "created" once per rule
|
||||||
degrades to "no evidence" on any failure — the chat never fails because
|
that happens to mention it again later. ``pfs.code_element`` supplies
|
||||||
of it.
|
element differences across the detected codes' newest year, and
|
||||||
|
``pfs.code_guidance`` supplies CFR/IOM/MLN references for the detected
|
||||||
|
families. FR jump links are resolved only for the collapsed rows that
|
||||||
|
will actually reach the prompt block (the SSE payload still carries
|
||||||
|
every collapsed row — the rest just show a plain label with no link).
|
||||||
|
Like ``llm.evidence``, this degrades to "no evidence" on any failure —
|
||||||
|
the chat never fails because of it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
@@ -53,6 +60,18 @@ _PRIORITY_KINDS = frozenset(
|
|||||||
{"created", "adopted_cpt", "replaces", "replaced_by", "deleted", "disappeared"}
|
{"created", "adopted_cpt", "replaces", "replaced_by", "deleted", "disappeared"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#: Ruling B5: "point in time" kinds — a code is created/adopted/deleted
|
||||||
|
#: once, not once per rule that happens to mention it again in a later
|
||||||
|
#: year — collapse further to a single row per (code, kind) at the
|
||||||
|
#: earliest surviving year. Directional/repeatable kinds (replaces,
|
||||||
|
#: replaced_by, crosswalk, revalued, status_change, descriptor_change,
|
||||||
|
#: cpt_changed, telehealth_list, bundled) keep one row per year — a
|
||||||
|
#: code can legitimately be replaced by different things in different
|
||||||
|
#: years, or crosswalked/revalued repeatedly.
|
||||||
|
_EARLIEST_ONLY_KINDS = frozenset(
|
||||||
|
{"created", "adopted_cpt", "appeared", "deleted", "disappeared", "cpt_deleted"}
|
||||||
|
)
|
||||||
|
|
||||||
#: Sort order for collapsed events sharing a year and code: FR event
|
#: Sort order for collapsed events sharing a year and code: FR event
|
||||||
#: kinds (their own internal order), then RVU-file kinds, then the two
|
#: kinds (their own internal order), then RVU-file kinds, then the two
|
||||||
#: CPT-codebook kinds. Anything unrecognized sorts last.
|
#: CPT-codebook kinds. Anything unrecognized sorts last.
|
||||||
@@ -148,12 +167,32 @@ def _guidance_line(g: GuidanceRef) -> str:
|
|||||||
return f"[{g.label}] {g.locator} — {g.kind.upper()}"
|
return f"[{g.label}] {g.locator} — {g.kind.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _select_for_prompt(
|
||||||
|
events: Sequence[LineageEvent], max_rows: int
|
||||||
|
) -> list[LineageEvent]:
|
||||||
|
"""Every priority-kind event, plus the earliest-sorted non-priority
|
||||||
|
events, up to *max_rows* total — priority rows are never dropped
|
||||||
|
even past the cap. Selection is by identity (``id()``), not value
|
||||||
|
equality, so it stays correct before FR urls are resolved (several
|
||||||
|
collapsed rows can otherwise compare equal on every other field).
|
||||||
|
Shared by ``LineageEvidence.prompt_block`` (final render) and
|
||||||
|
``lineage_evidence`` (deciding which rows are worth an FR url
|
||||||
|
lookup — ruling: resolve links only for what the model will see)."""
|
||||||
|
priority = [e for e in events if e.kind in _PRIORITY_KINDS]
|
||||||
|
rest = [e for e in events if e.kind not in _PRIORITY_KINDS]
|
||||||
|
budget = max(max_rows - len(priority), 0)
|
||||||
|
keep = {id(e) for e in priority} | {id(e) for e in rest[:budget]}
|
||||||
|
return [e for e in events if id(e) in keep]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LineageEvidence:
|
class LineageEvidence:
|
||||||
codes: tuple[str, ...]
|
codes: tuple[str, ...]
|
||||||
families: tuple[str, ...]
|
families: tuple[str, ...]
|
||||||
#: Every collapsed event row, sorted (year, code, kind order) — the
|
#: Every collapsed event row, sorted (year, code, kind order) — the
|
||||||
#: SSE payload carries all of these; ``prompt_block`` trims them.
|
#: SSE payload carries all of these; ``prompt_block`` trims them.
|
||||||
|
#: Only the rows ``prompt_block`` actually renders carry a resolved
|
||||||
|
#: FR ``url`` — the rest are ``""`` (the UI shows a plain label).
|
||||||
events: tuple[LineageEvent, ...]
|
events: tuple[LineageEvent, ...]
|
||||||
element_diffs: tuple[ElementDiff, ...]
|
element_diffs: tuple[ElementDiff, ...]
|
||||||
guidance: tuple[GuidanceRef, ...]
|
guidance: tuple[GuidanceRef, ...]
|
||||||
@@ -165,11 +204,7 @@ class LineageEvidence:
|
|||||||
max_prompt_rows: int = 25
|
max_prompt_rows: int = 25
|
||||||
|
|
||||||
def _prompt_events(self) -> list[LineageEvent]:
|
def _prompt_events(self) -> list[LineageEvent]:
|
||||||
priority = [e for e in self.events if e.kind in _PRIORITY_KINDS]
|
return _select_for_prompt(self.events, self.max_prompt_rows)
|
||||||
rest = [e for e in self.events if e.kind not in _PRIORITY_KINDS]
|
|
||||||
budget = max(self.max_prompt_rows - len(priority), 0)
|
|
||||||
keep = set(priority) | set(rest[:budget])
|
|
||||||
return [e for e in self.events if e in keep]
|
|
||||||
|
|
||||||
def prompt_block(self) -> str:
|
def prompt_block(self) -> str:
|
||||||
lines = [_HEADER]
|
lines = [_HEADER]
|
||||||
@@ -221,31 +256,74 @@ def _store() -> Any:
|
|||||||
return _STORE
|
return _STORE
|
||||||
|
|
||||||
|
|
||||||
def _source_label(store: Any, item_key: str, p_id: int) -> str:
|
# ── Process-global caches, all reset together whenever the replica is
|
||||||
"""``rule_label`` for the FR rule at *item_key* — falls back to the
|
# republished (a new mtime): resolved bib items (Store.get), FR jump
|
||||||
bare item key when the item can't be fetched (unresolvable/missing
|
# links, and on-demand lineage() results. Three call sites, one
|
||||||
item must never break the chat over a label)."""
|
# "forget everything on a new mtime, per key" policy. ───────────────────
|
||||||
|
|
||||||
|
_MISSING = object()
|
||||||
|
|
||||||
|
|
||||||
|
class _ReplicaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._data: dict[Any, Any] = {}
|
||||||
|
self._mtime: int | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: Any, mtime: int) -> Any:
|
||||||
|
with self._lock:
|
||||||
|
if self._mtime != mtime:
|
||||||
|
self._data.clear()
|
||||||
|
self._mtime = mtime
|
||||||
|
return self._data.get(key, _MISSING)
|
||||||
|
|
||||||
|
def put(self, key: Any, mtime: int, value: Any) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._mtime == mtime: # a reset raced us — don't resurrect a stale value
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._data.clear()
|
||||||
|
self._mtime = None
|
||||||
|
|
||||||
|
|
||||||
|
_ITEM_CACHE = _ReplicaCache() # item_key -> resolved bib Item (or None)
|
||||||
|
_URL_CACHE = _ReplicaCache() # (item_key, p_id) -> FR jump-link url
|
||||||
|
_ON_DEMAND_CACHE = _ReplicaCache() # code -> on-demand EventRow list
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_item(store: Any, item_key: str, mtime: int) -> Any:
|
||||||
|
cached = _ITEM_CACHE.get(item_key, mtime)
|
||||||
|
if cached is not _MISSING:
|
||||||
|
return cached
|
||||||
try:
|
try:
|
||||||
item = store.get(item_key)
|
item = store.get(item_key)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.warning("lineage label unresolved (%s): %s", item_key, e)
|
log.warning("lineage item unresolved (%s): %s", item_key, e)
|
||||||
|
item = None
|
||||||
|
_ITEM_CACHE.put(item_key, mtime, item)
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _source_label(store: Any, item_key: str, p_id: int, mtime: int) -> str:
|
||||||
|
"""``rule_label`` for the FR rule at *item_key* — falls back to the
|
||||||
|
bare item key when the item can't be fetched (unresolvable/missing
|
||||||
|
item must never break the chat over a label). The resolved
|
||||||
|
``Store.get`` item is cached per ``item_key`` — the same handful of
|
||||||
|
rules back dozens of events/diffs/guidance rows in one turn."""
|
||||||
|
item = _cached_item(store, item_key, mtime)
|
||||||
|
if item is None:
|
||||||
return item_key or "?"
|
return item_key or "?"
|
||||||
return rule_label(item.title, item.date_published, p_id)
|
return rule_label(item.title, item.date_published, p_id)
|
||||||
|
|
||||||
|
|
||||||
# ── FR jump links, cached per (item_key, p_id) ──────────────────────────
|
def _fr_url(store: Any, item_key: str, p_id: int, mtime: int) -> str:
|
||||||
|
|
||||||
_URL_CACHE: dict[tuple[str, int], str] = {}
|
|
||||||
_URL_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _fr_url(store: Any, item_key: str, p_id: int) -> str:
|
|
||||||
if not item_key or not p_id:
|
if not item_key or not p_id:
|
||||||
return ""
|
return ""
|
||||||
key = (item_key, p_id)
|
key = (item_key, p_id)
|
||||||
with _URL_LOCK:
|
cached = _URL_CACHE.get(key, mtime)
|
||||||
cached = _URL_CACHE.get(key)
|
if cached is not _MISSING:
|
||||||
if cached is not None:
|
|
||||||
return cached
|
return cached
|
||||||
url = ""
|
url = ""
|
||||||
try:
|
try:
|
||||||
@@ -254,23 +332,22 @@ def _fr_url(store: Any, item_key: str, p_id: int) -> str:
|
|||||||
url = frlink.resolve(f"p-{p_id}", store=store, item_key=item_key).url
|
url = frlink.resolve(f"p-{p_id}", store=store, item_key=item_key).url
|
||||||
except Exception as e: # noqa: BLE001 — a bad anchor must not break the chat
|
except Exception as e: # noqa: BLE001 — a bad anchor must not break the chat
|
||||||
log.warning("lineage FR url unresolved (%s p-%s): %s", item_key, p_id, e)
|
log.warning("lineage FR url unresolved (%s p-%s): %s", item_key, p_id, e)
|
||||||
with _URL_LOCK:
|
_URL_CACHE.put(key, mtime, url)
|
||||||
_URL_CACHE[key] = url
|
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
def _to_lineage_event(store: Any, r: EventRow) -> LineageEvent:
|
def _to_lineage_event(store: Any, r: EventRow, mtime: int) -> LineageEvent:
|
||||||
|
"""*r* as a ``LineageEvent`` with its label resolved but ``url=""``
|
||||||
|
— FR urls are resolved afterwards, only for the rows selected for
|
||||||
|
the prompt block (``_resolve_prompt_urls``)."""
|
||||||
from_codes = tuple(r.from_codes.split()) if r.from_codes else ()
|
from_codes = tuple(r.from_codes.split()) if r.from_codes else ()
|
||||||
to_codes = tuple(r.to_codes.split()) if r.to_codes else ()
|
to_codes = tuple(r.to_codes.split()) if r.to_codes else ()
|
||||||
if r.source == "fr":
|
if r.source == "fr":
|
||||||
label = _source_label(store, r.item_key, r.p_id)
|
label = _source_label(store, r.item_key, r.p_id, mtime)
|
||||||
url = _fr_url(store, r.item_key, r.p_id)
|
|
||||||
elif r.source == "cpt":
|
elif r.source == "cpt":
|
||||||
label = r.note or f"CPT Changes {r.year}"
|
label = f"CPT Changes {r.year}"
|
||||||
url = ""
|
|
||||||
else: # "rvu"
|
else: # "rvu"
|
||||||
label = f"PFS CY{r.year} RVU file"
|
label = f"PFS CY{r.year} RVU file"
|
||||||
url = ""
|
|
||||||
return LineageEvent(
|
return LineageEvent(
|
||||||
code=r.code,
|
code=r.code,
|
||||||
year=r.year,
|
year=r.year,
|
||||||
@@ -281,47 +358,73 @@ def _to_lineage_event(store: Any, r: EventRow) -> LineageEvent:
|
|||||||
item_key=r.item_key,
|
item_key=r.item_key,
|
||||||
p_id=r.p_id,
|
p_id=r.p_id,
|
||||||
page=r.page,
|
page=r.page,
|
||||||
url=url,
|
url="",
|
||||||
source=r.source,
|
source=r.source,
|
||||||
anchored=r.anchored,
|
anchored=r.anchored,
|
||||||
note=r.note,
|
note=r.note,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_prompt_urls(
|
||||||
|
store: Any, events: list[LineageEvent], mtime: int, max_rows: int
|
||||||
|
) -> tuple[LineageEvent, ...]:
|
||||||
|
"""FR jump links, resolved only for the rows ``_select_for_prompt``
|
||||||
|
would actually render — every other collapsed row keeps ``url=""``
|
||||||
|
in the payload (the UI shows a plain label for those)."""
|
||||||
|
selected_ids = {id(e) for e in _select_for_prompt(events, max_rows)}
|
||||||
|
out: list[LineageEvent] = []
|
||||||
|
for e in events:
|
||||||
|
if id(e) in selected_ids and e.source == "fr":
|
||||||
|
url = _fr_url(store, e.item_key, e.p_id, mtime)
|
||||||
|
if url:
|
||||||
|
e = dataclasses.replace(e, url=url)
|
||||||
|
out.append(e)
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _pref_key(r: EventRow) -> tuple[int, int]:
|
||||||
|
"""Collapse preference within a group: an anchored FR row first,
|
||||||
|
then the lowest ``p_id`` — shared by both ``_collapse`` passes."""
|
||||||
|
return (0 if (r.source == "fr" and r.anchored) else 1, r.p_id)
|
||||||
|
|
||||||
|
|
||||||
def _collapse(rows: Sequence[EventRow]) -> list[EventRow]:
|
def _collapse(rows: Sequence[EventRow]) -> list[EventRow]:
|
||||||
"""One representative row per ``(code, year, kind, from_codes,
|
"""One representative row per ``(code, year, kind, from_codes,
|
||||||
to_codes)`` — prefer an anchored FR row, then the lowest ``p_id`` —
|
to_codes)`` — prefer an anchored FR row, then the lowest ``p_id``.
|
||||||
sorted by ``(year, code, kind order)``."""
|
|
||||||
|
Ruling B5: "point in time" kinds (see ``_EARLIEST_ONLY_KINDS``)
|
||||||
|
collapse further to a single row per ``(code, kind)`` at the
|
||||||
|
earliest surviving year, applying the same preference among that
|
||||||
|
year's rows when more than one survives. Directional/repeatable
|
||||||
|
kinds keep one row per year.
|
||||||
|
|
||||||
|
Sorted by ``(year, code, kind order)``.
|
||||||
|
"""
|
||||||
groups: dict[tuple[str, int, str, str, str], list[EventRow]] = {}
|
groups: dict[tuple[str, int, str, str, str], list[EventRow]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
key = (r.code, r.year, r.kind, r.from_codes, r.to_codes)
|
key = (r.code, r.year, r.kind, r.from_codes, r.to_codes)
|
||||||
groups.setdefault(key, []).append(r)
|
groups.setdefault(key, []).append(r)
|
||||||
reps = [
|
reps = [min(grp, key=_pref_key) for grp in groups.values()]
|
||||||
min(
|
|
||||||
grp,
|
singleton: dict[tuple[str, str], list[EventRow]] = {}
|
||||||
key=lambda r: (0 if (r.source == "fr" and r.anchored) else 1, r.p_id),
|
other: list[EventRow] = []
|
||||||
)
|
for r in reps:
|
||||||
for grp in groups.values()
|
if r.kind in _EARLIEST_ONLY_KINDS:
|
||||||
|
singleton.setdefault((r.code, r.kind), []).append(r)
|
||||||
|
else:
|
||||||
|
other.append(r)
|
||||||
|
earliest = [
|
||||||
|
min((r for r in grp if r.year == min(x.year for x in grp)), key=_pref_key)
|
||||||
|
for grp in singleton.values()
|
||||||
]
|
]
|
||||||
return sorted(reps, key=lambda r: (r.year, r.code, _kind_rank(r.kind)))
|
|
||||||
|
|
||||||
|
final = other + earliest
|
||||||
# ── On-demand fallback for codes with no precomputed rows, cached by
|
return sorted(final, key=lambda r: (r.year, r.code, _kind_rank(r.kind)))
|
||||||
# (replica mtime, code) — see the module docstring. ─────────────────────
|
|
||||||
|
|
||||||
_ON_DEMAND_CACHE: dict[str, list[EventRow]] = {}
|
|
||||||
_ON_DEMAND_MTIME: int | None = None
|
|
||||||
_ON_DEMAND_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[EventRow]:
|
def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[EventRow]:
|
||||||
global _ON_DEMAND_MTIME
|
cached = _ON_DEMAND_CACHE.get(code, mtime)
|
||||||
with _ON_DEMAND_LOCK:
|
if cached is not _MISSING:
|
||||||
if _ON_DEMAND_MTIME != mtime:
|
|
||||||
_ON_DEMAND_CACHE.clear()
|
|
||||||
_ON_DEMAND_MTIME = mtime
|
|
||||||
cached = _ON_DEMAND_CACHE.get(code)
|
|
||||||
if cached is not None:
|
|
||||||
return cached
|
return cached
|
||||||
try:
|
try:
|
||||||
import pfs.lineage as pfs_lineage
|
import pfs.lineage as pfs_lineage
|
||||||
@@ -330,9 +433,7 @@ def _on_demand_events(cur: Any, store: Any, mtime: int, code: str) -> list[Event
|
|||||||
except Exception as e: # noqa: BLE001 — on-demand fallback must not break the chat
|
except Exception as e: # noqa: BLE001 — on-demand fallback must not break the chat
|
||||||
log.warning("on-demand lineage skipped for %s: %s", code, e)
|
log.warning("on-demand lineage skipped for %s: %s", code, e)
|
||||||
rows = []
|
rows = []
|
||||||
with _ON_DEMAND_LOCK:
|
_ON_DEMAND_CACHE.put(code, mtime, rows)
|
||||||
if _ON_DEMAND_MTIME == mtime:
|
|
||||||
_ON_DEMAND_CACHE[code] = rows
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -355,21 +456,25 @@ def _collect_events(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _element_label(store: Any, row: ElementRow) -> str:
|
def _element_label(store: Any, row: ElementRow, mtime: int) -> str:
|
||||||
if row.item_key and row.p_id:
|
if row.item_key and row.p_id:
|
||||||
return _source_label(store, row.item_key, row.p_id)
|
return _source_label(store, row.item_key, row.p_id, mtime)
|
||||||
if row.source == "cpt":
|
if row.source == "cpt":
|
||||||
return f"CPT Changes {row.year}"
|
return f"CPT Changes {row.year}"
|
||||||
return f"PFS CY{row.year} RVU file"
|
return f"PFS CY{row.year} RVU file"
|
||||||
|
|
||||||
|
|
||||||
def _element_diffs(
|
def _element_diffs(
|
||||||
cur: Any, codes: Sequence[str], store: Any
|
cur: Any, codes: Sequence[str], store: Any, mtime: int
|
||||||
) -> tuple[tuple[ElementDiff, ...], str]:
|
) -> tuple[tuple[ElementDiff, ...], str]:
|
||||||
"""Element values present for some detected codes and absent for
|
"""Element values present for some detected codes and absent for
|
||||||
others, from each code's newest ``pfs.code_element`` year. Needs
|
others, from each code's newest ``pfs.code_element`` year. Needs
|
||||||
>= 2 detected codes with any element rows at all — a single code's
|
>= 2 detected codes with any element rows at all — a single code's
|
||||||
elements have nothing to diff against (ruling 18)."""
|
elements have nothing to diff against (ruling 18). ``not_in_codes``
|
||||||
|
is relative to the codes that have *any* element rows extracted,
|
||||||
|
not to every code in *codes* — a code with no elements at all is
|
||||||
|
absent from both ``in_codes`` and ``not_in_codes``, not silently
|
||||||
|
counted as "not having" a value it was simply never extracted for."""
|
||||||
by_code: dict[str, list[ElementRow]] = {}
|
by_code: dict[str, list[ElementRow]] = {}
|
||||||
for code in codes:
|
for code in codes:
|
||||||
try:
|
try:
|
||||||
@@ -414,7 +519,7 @@ def _element_diffs(
|
|||||||
value=value_,
|
value=value_,
|
||||||
in_codes=in_codes,
|
in_codes=in_codes,
|
||||||
not_in_codes=not_in_codes,
|
not_in_codes=not_in_codes,
|
||||||
label=_element_label(store, best),
|
label=_element_label(store, best, mtime),
|
||||||
item_key=best.item_key,
|
item_key=best.item_key,
|
||||||
p_id=best.p_id,
|
p_id=best.p_id,
|
||||||
)
|
)
|
||||||
@@ -424,7 +529,7 @@ def _element_diffs(
|
|||||||
|
|
||||||
|
|
||||||
def _collect_guidance(
|
def _collect_guidance(
|
||||||
cur: Any, store: Any, families: Sequence[str], wide: Sequence[str]
|
cur: Any, store: Any, families: Sequence[str], wide: Sequence[str], mtime: int
|
||||||
) -> tuple[GuidanceRef, ...]:
|
) -> tuple[GuidanceRef, ...]:
|
||||||
wide_set = set(wide)
|
wide_set = set(wide)
|
||||||
rows: list[GuidanceRow] = []
|
rows: list[GuidanceRow] = []
|
||||||
@@ -457,7 +562,7 @@ def _collect_guidance(
|
|||||||
kind=r.kind,
|
kind=r.kind,
|
||||||
locator=r.locator,
|
locator=r.locator,
|
||||||
url=url,
|
url=url,
|
||||||
label=_source_label(store, r.item_key_src, r.p_id_src),
|
label=_source_label(store, r.item_key_src, r.p_id_src, mtime),
|
||||||
item_key_src=r.item_key_src,
|
item_key_src=r.item_key_src,
|
||||||
p_id_src=r.p_id_src,
|
p_id_src=r.p_id_src,
|
||||||
)
|
)
|
||||||
@@ -491,9 +596,12 @@ def lineage_evidence(question: str, cfg: LlmConfig) -> LineageEvidence | None:
|
|||||||
raw_events = _collect_events(
|
raw_events = _collect_events(
|
||||||
cur, store, mtime, det.codes, cfg.lineage_on_demand_max
|
cur, store, mtime, det.codes, cfg.lineage_on_demand_max
|
||||||
)
|
)
|
||||||
events = tuple(_to_lineage_event(store, r) for r in _collapse(raw_events))
|
events_list = [
|
||||||
element_diffs, elements_note = _element_diffs(cur, det.codes, store)
|
_to_lineage_event(store, r, mtime) for r in _collapse(raw_events)
|
||||||
guidance = _collect_guidance(cur, store, det.families, det.wide)
|
]
|
||||||
|
events = _resolve_prompt_urls(store, events_list, mtime, cfg.lineage_max_rows)
|
||||||
|
element_diffs, elements_note = _element_diffs(cur, det.codes, store, mtime)
|
||||||
|
guidance = _collect_guidance(cur, store, det.families, det.wide, mtime)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
log.warning("lineage evidence skipped (query): %s", e)
|
log.warning("lineage evidence skipped (query): %s", e)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -60,9 +60,9 @@ def _clear_caches():
|
|||||||
def _reset():
|
def _reset():
|
||||||
evidence._REPLICA = None
|
evidence._REPLICA = None
|
||||||
lineage._STORE = None
|
lineage._STORE = None
|
||||||
lineage._URL_CACHE.clear()
|
lineage._ITEM_CACHE.reset()
|
||||||
lineage._ON_DEMAND_CACHE.clear()
|
lineage._URL_CACHE.reset()
|
||||||
lineage._ON_DEMAND_MTIME = None
|
lineage._ON_DEMAND_CACHE.reset()
|
||||||
|
|
||||||
_reset()
|
_reset()
|
||||||
yield
|
yield
|
||||||
@@ -282,16 +282,82 @@ class TestCollapse:
|
|||||||
]
|
]
|
||||||
assert len(lineage._collapse(rows)) == 2
|
assert len(lineage._collapse(rows)) == 2
|
||||||
|
|
||||||
|
def test_created_across_three_years_collapses_to_the_earliest(self):
|
||||||
|
"""Ruling B5: G2058 is mentioned as "created" by three different
|
||||||
|
rules (2021, 2022, 2023 — each rule re-establishing the G-code
|
||||||
|
after it was crosswalked away) but it was only actually created
|
||||||
|
once."""
|
||||||
|
rows = [
|
||||||
|
_ev("G2058", 2021, "created", item_key="A", p_id=100),
|
||||||
|
_ev("G2058", 2022, "created", item_key="B", p_id=200),
|
||||||
|
_ev("G2058", 2023, "created", item_key="C", p_id=300),
|
||||||
|
]
|
||||||
|
collapsed = lineage._collapse(rows)
|
||||||
|
assert [(e.year, e.code, e.kind) for e in collapsed] == [
|
||||||
|
(2021, "G2058", "created")
|
||||||
|
]
|
||||||
|
assert collapsed[0].item_key == "A"
|
||||||
|
|
||||||
|
def test_deleted_twice_keeps_only_the_first(self):
|
||||||
|
rows = [
|
||||||
|
_ev("99213", 2019, "deleted", item_key="A", p_id=1),
|
||||||
|
_ev("99213", 2024, "deleted", item_key="B", p_id=2),
|
||||||
|
]
|
||||||
|
collapsed = lineage._collapse(rows)
|
||||||
|
assert [(e.year, e.kind) for e in collapsed] == [(2019, "deleted")]
|
||||||
|
|
||||||
|
def test_earliest_only_picks_the_preferred_row_within_that_year(self):
|
||||||
|
"""Two rows tie on the earliest year — the anchored-FR/lowest-p_id
|
||||||
|
preference still applies among them."""
|
||||||
|
rows = [
|
||||||
|
_ev(
|
||||||
|
"G2058",
|
||||||
|
2021,
|
||||||
|
"created",
|
||||||
|
item_key="LATE",
|
||||||
|
p_id=999,
|
||||||
|
source="fr",
|
||||||
|
anchored=True,
|
||||||
|
),
|
||||||
|
_ev(
|
||||||
|
"G2058",
|
||||||
|
2021,
|
||||||
|
"created",
|
||||||
|
item_key="EARLY",
|
||||||
|
p_id=100,
|
||||||
|
source="fr",
|
||||||
|
anchored=True,
|
||||||
|
),
|
||||||
|
_ev("G2058", 2022, "created", item_key="OTHER", p_id=1),
|
||||||
|
]
|
||||||
|
collapsed = lineage._collapse(rows)
|
||||||
|
assert len(collapsed) == 1
|
||||||
|
assert collapsed[0].year == 2021 and collapsed[0].item_key == "EARLY"
|
||||||
|
|
||||||
|
def test_replaced_by_rows_in_two_years_both_survive(self):
|
||||||
|
"""replaced_by is directional/repeatable, not a point-in-time
|
||||||
|
kind — a code can legitimately be replaced by different things
|
||||||
|
in different years, so both rows must survive collapse."""
|
||||||
|
rows = [
|
||||||
|
_ev("99439", 2021, "replaced_by", to="G2058", p_id=1),
|
||||||
|
_ev("99439", 2022, "replaced_by", to="G2214", p_id=2),
|
||||||
|
]
|
||||||
|
collapsed = lineage._collapse(rows)
|
||||||
|
assert [(e.year, e.to_codes) for e in collapsed] == [
|
||||||
|
(2021, "G2058"),
|
||||||
|
(2022, "G2214"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class TestElementDiffs:
|
class TestElementDiffs:
|
||||||
def test_no_diffs_when_fewer_than_two_codes_have_elements(self, con):
|
def test_no_diffs_when_fewer_than_two_codes_have_elements(self, con):
|
||||||
c, _ = con
|
c, _ = con
|
||||||
assert lineage._element_diffs(c, ["99490"], None) == ((), "")
|
assert lineage._element_diffs(c, ["99490"], None, 1) == ((), "")
|
||||||
|
|
||||||
def test_notes_when_exactly_one_code_has_elements(self, con):
|
def test_notes_when_exactly_one_code_has_elements(self, con):
|
||||||
c, _ = con
|
c, _ = con
|
||||||
write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], [])
|
write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], [])
|
||||||
diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], None)
|
diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], None, 1)
|
||||||
assert diffs == ()
|
assert diffs == ()
|
||||||
assert note == "elements extracted for 1 of 3 codes"
|
assert note == "elements extracted for 1 of 3 codes"
|
||||||
|
|
||||||
@@ -305,7 +371,7 @@ class TestElementDiffs:
|
|||||||
)
|
)
|
||||||
write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], [])
|
write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], [])
|
||||||
write_elements(c, "G0556", [_el("G0556", 2026, "telehealth", "yes")], [])
|
write_elements(c, "G0556", [_el("G0556", 2026, "telehealth", "yes")], [])
|
||||||
diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], store)
|
diffs, note = lineage._element_diffs(c, ["99490", "99491", "G0556"], store, 1)
|
||||||
assert note == ""
|
assert note == ""
|
||||||
by_pair = {(d.type, d.value): d for d in diffs}
|
by_pair = {(d.type, d.value): d for d in diffs}
|
||||||
assert set(by_pair) == {("consent", "required"), ("telehealth", "yes")}
|
assert set(by_pair) == {("consent", "required"), ("telehealth", "yes")}
|
||||||
@@ -321,7 +387,7 @@ class TestElementDiffs:
|
|||||||
c, _ = con
|
c, _ = con
|
||||||
write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], [])
|
write_elements(c, "99490", [_el("99490", 2026, "consent", "required")], [])
|
||||||
write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], [])
|
write_elements(c, "99491", [_el("99491", 2026, "consent", "required")], [])
|
||||||
diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None)
|
diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None, 1)
|
||||||
assert diffs == ()
|
assert diffs == ()
|
||||||
|
|
||||||
def test_only_the_newest_year_per_code_is_compared(self, con):
|
def test_only_the_newest_year_per_code_is_compared(self, con):
|
||||||
@@ -336,7 +402,7 @@ class TestElementDiffs:
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
write_elements(c, "99491", [_el("99491", 2026, "consent", "verbal")], [])
|
write_elements(c, "99491", [_el("99491", 2026, "consent", "verbal")], [])
|
||||||
diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None)
|
diffs, _ = lineage._element_diffs(c, ["99490", "99491"], None, 1)
|
||||||
# 2020's "verbal" for 99490 must not be compared — only 2026's
|
# 2020's "verbal" for 99490 must not be compared — only 2026's
|
||||||
# "required" — so the diff is required (99490) vs verbal (99491).
|
# "required" — so the diff is required (99490) vs verbal (99491).
|
||||||
values = {d.value: d for d in diffs}
|
values = {d.value: d for d in diffs}
|
||||||
@@ -354,7 +420,7 @@ class TestElementDiffs:
|
|||||||
# a fourth code with none of the "t*" values, so every t* pair
|
# a fourth code with none of the "t*" values, so every t* pair
|
||||||
# differs (present in the three, absent in the fourth)
|
# differs (present in the three, absent in the fourth)
|
||||||
write_elements(c, "A0004", [_el("A0004", 2026, "shared", "no")], [])
|
write_elements(c, "A0004", [_el("A0004", 2026, "shared", "no")], [])
|
||||||
diffs, _ = lineage._element_diffs(c, codes + ["A0004"], None)
|
diffs, _ = lineage._element_diffs(c, codes + ["A0004"], None, 1)
|
||||||
assert len(diffs) == 12
|
assert len(diffs) == 12
|
||||||
assert diffs[0].in_codes == ("A0001", "A0002", "A0003")
|
assert diffs[0].in_codes == ("A0001", "A0002", "A0003")
|
||||||
|
|
||||||
@@ -371,7 +437,7 @@ class TestGuidance:
|
|||||||
for i in range(6)
|
for i in range(6)
|
||||||
]
|
]
|
||||||
write_guidance(c, "CCM", rows)
|
write_guidance(c, "CCM", rows)
|
||||||
out = lineage._collect_guidance(c, store, ["CCM"], [])
|
out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
|
||||||
assert len(out) == 8
|
assert len(out) == 8
|
||||||
assert [g.kind for g in out[:6]] == ["cfr"] * 6
|
assert [g.kind for g in out[:6]] == ["cfr"] * 6
|
||||||
assert out[0].url == "https://www.ecfr.gov/current/title-42/section-410.0"
|
assert out[0].url == "https://www.ecfr.gov/current/title-42/section-410.0"
|
||||||
@@ -386,7 +452,7 @@ class TestGuidance:
|
|||||||
_gd("CCM", "99491", "cfr", "42 CFR 410.78(a)(3)", "YBM4IZUS", 686),
|
_gd("CCM", "99491", "cfr", "42 CFR 410.78(a)(3)", "YBM4IZUS", 686),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
out = lineage._collect_guidance(c, store, ["CCM"], [])
|
out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
|
||||||
assert len(out) == 1
|
assert len(out) == 1
|
||||||
|
|
||||||
def test_wide_families_are_skipped(self, con, store):
|
def test_wide_families_are_skipped(self, con, store):
|
||||||
@@ -394,7 +460,7 @@ class TestGuidance:
|
|||||||
write_guidance(
|
write_guidance(
|
||||||
c, "APCM", [_gd("APCM", "G0556", "mln", "MLN 907166", "YBM4IZUS", 686)]
|
c, "APCM", [_gd("APCM", "G0556", "mln", "MLN 907166", "YBM4IZUS", 686)]
|
||||||
)
|
)
|
||||||
out = lineage._collect_guidance(c, store, ["APCM"], ["APCM"])
|
out = lineage._collect_guidance(c, store, ["APCM"], ["APCM"], 1)
|
||||||
assert out == ()
|
assert out == ()
|
||||||
|
|
||||||
def test_iom_mln_have_no_url(self, con, store):
|
def test_iom_mln_have_no_url(self, con, store):
|
||||||
@@ -404,7 +470,7 @@ class TestGuidance:
|
|||||||
"CCM",
|
"CCM",
|
||||||
[_gd("CCM", "99490", "iom", "100-04 ch.12 §30.6.4", "YBM4IZUS", 686)],
|
[_gd("CCM", "99490", "iom", "100-04 ch.12 §30.6.4", "YBM4IZUS", 686)],
|
||||||
)
|
)
|
||||||
out = lineage._collect_guidance(c, store, ["CCM"], [])
|
out = lineage._collect_guidance(c, store, ["CCM"], [], 1)
|
||||||
assert out[0].url == ""
|
assert out[0].url == ""
|
||||||
assert out[0].label == "CY2021 PFS final ¶686"
|
assert out[0].label == "CY2021 PFS final ¶686"
|
||||||
|
|
||||||
@@ -754,6 +820,128 @@ class TestLineageEvidence:
|
|||||||
assert ev.guidance[0].locator == "42 CFR 410.78(a)(3)"
|
assert ev.guidance[0].locator == "42 CFR 410.78(a)(3)"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCptLabel:
|
||||||
|
def test_cpt_label_is_always_cpt_changes_year_note_holds_the_detail(self):
|
||||||
|
"""CPT-sourced events (cpt_changed, cpt_deleted) always label as
|
||||||
|
"CPT Changes {year}" — the note (e.g. "absent from CPT 2024")
|
||||||
|
is a separate field, not folded into the label."""
|
||||||
|
row = _ev(
|
||||||
|
"99213",
|
||||||
|
2024,
|
||||||
|
"cpt_deleted",
|
||||||
|
item_key="CPTED2024",
|
||||||
|
p_id=0,
|
||||||
|
source="cpt",
|
||||||
|
note="absent from CPT 2024",
|
||||||
|
)
|
||||||
|
event = lineage._to_lineage_event(None, row, 1)
|
||||||
|
assert event.label == "CPT Changes 2024"
|
||||||
|
assert event.note == "absent from CPT 2024"
|
||||||
|
line = lineage._event_line(event)
|
||||||
|
assert (
|
||||||
|
line == "[CPT Changes 2024] 2024 cpt_deleted 99213 — absent from CPT 2024"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
line.count("absent from CPT 2024") == 1
|
||||||
|
) # not duplicated into the label too
|
||||||
|
|
||||||
|
def test_cpt_changed_label_ignores_any_note(self):
|
||||||
|
row = _ev(
|
||||||
|
"99213",
|
||||||
|
2022,
|
||||||
|
"cpt_changed",
|
||||||
|
item_key="CPTED2022",
|
||||||
|
p_id=0,
|
||||||
|
source="cpt",
|
||||||
|
note="some other detail",
|
||||||
|
)
|
||||||
|
event = lineage._to_lineage_event(None, row, 1)
|
||||||
|
assert event.label == "CPT Changes 2022"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceLabelAndUrlDegrade:
|
||||||
|
def test_source_label_degrades_to_item_key_when_store_get_fails(self):
|
||||||
|
store = SimpleNamespace(get=lambda k: (_ for _ in ()).throw(KeyError(k)))
|
||||||
|
assert lineage._source_label(store, "UNKNOWN1", 5, 1) == "UNKNOWN1"
|
||||||
|
|
||||||
|
def test_source_label_degrades_to_question_mark_when_item_key_empty(self):
|
||||||
|
store = SimpleNamespace(get=lambda k: (_ for _ in ()).throw(KeyError(k)))
|
||||||
|
assert lineage._source_label(store, "", 5, 1) == "?"
|
||||||
|
|
||||||
|
def test_fr_url_degrades_to_empty_string_on_resolve_failure(self, store):
|
||||||
|
with patch("bib.frlink.resolve", side_effect=RuntimeError("boom")):
|
||||||
|
assert lineage._fr_url(store, "YBM4IZUS", 686, 1) == ""
|
||||||
|
|
||||||
|
def test_fr_url_empty_when_item_key_or_p_id_missing(self):
|
||||||
|
assert lineage._fr_url(None, "", 5, 1) == ""
|
||||||
|
assert lineage._fr_url(None, "K", 0, 1) == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachingAndScopedUrlResolution:
|
||||||
|
"""Ruling: Store.get results are cached per item_key (not just per
|
||||||
|
(item_key, p_id) — the same rule backs many events/diffs/guidance
|
||||||
|
rows), and FR urls are only resolved for the rows that actually
|
||||||
|
reach the prompt block."""
|
||||||
|
|
||||||
|
def test_urls_resolved_only_for_prompt_rows_labels_cached_per_item(
|
||||||
|
self, con, store
|
||||||
|
):
|
||||||
|
c, path = con
|
||||||
|
n_items = 10
|
||||||
|
rows = [
|
||||||
|
_ev(
|
||||||
|
"99999",
|
||||||
|
2000 + i,
|
||||||
|
"crosswalk", # non-priority, not an _EARLIEST_ONLY kind
|
||||||
|
item_key=f"K{i % n_items:02d}",
|
||||||
|
p_id=1000 + i,
|
||||||
|
source="fr",
|
||||||
|
anchored=True,
|
||||||
|
)
|
||||||
|
for i in range(60)
|
||||||
|
]
|
||||||
|
write_events(c, "99999", rows)
|
||||||
|
store.con.executemany(
|
||||||
|
"INSERT INTO items VALUES (?,?,?)",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
f"K{i:02d}",
|
||||||
|
f"Medicare Program; CY {2000 + i} PFS Final Rule",
|
||||||
|
f"{1999 + i}-11-01",
|
||||||
|
)
|
||||||
|
for i in range(n_items)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
c.close() # release the write handle before lineage_evidence opens read-only
|
||||||
|
cfg = replace(CFG, duckdb_replica=str(path), lineage_max_rows=25)
|
||||||
|
det = Detection(codes=("99999",), families=(), explicit=(), wide=())
|
||||||
|
real_get = store.get
|
||||||
|
seen_keys: list[str] = []
|
||||||
|
|
||||||
|
def counting_get(key):
|
||||||
|
seen_keys.append(key)
|
||||||
|
return real_get(key)
|
||||||
|
|
||||||
|
with patch("llm.lineage._store", return_value=store):
|
||||||
|
with patch("llm.lineage.detect_codes", return_value=det):
|
||||||
|
with patch.object(store, "get", side_effect=counting_get) as get_spy:
|
||||||
|
with patch("bib.frlink.resolve") as resolve_mock:
|
||||||
|
resolve_mock.return_value = SimpleNamespace(url="http://x")
|
||||||
|
ev = lineage_evidence("irrelevant", cfg)
|
||||||
|
|
||||||
|
assert ev is not None
|
||||||
|
assert len(ev.events) == 60 # payload carries every collapsed row
|
||||||
|
# exactly the 25 prompt-selected rows (budget: no priority kinds
|
||||||
|
# here, so the first 25 by year) get an FR url resolved.
|
||||||
|
assert resolve_mock.call_count == 25
|
||||||
|
with_url = [e for e in ev.events if e.url]
|
||||||
|
assert len(with_url) == 25
|
||||||
|
# every referenced item_key is fetched at most once, total calls
|
||||||
|
# bounded by the number of distinct item_keys (10), not events (60).
|
||||||
|
assert get_spy.call_count <= n_items
|
||||||
|
assert len(seen_keys) == len(set(seen_keys)) # never re-fetched
|
||||||
|
|
||||||
|
|
||||||
class TestLineageEvidenceLive:
|
class TestLineageEvidenceLive:
|
||||||
@pytest.mark.live
|
@pytest.mark.live
|
||||||
def test_median_under_300ms_on_live_replica(self):
|
def test_median_under_300ms_on_live_replica(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user