sourcelock 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hc_source/__init__.py +5 -0
- hc_source/adapters/__init__.py +500 -0
- hc_source/adapters/_demo.py +258 -0
- hc_source/adapters/_demo_fixture.json +25 -0
- hc_source/adapters/_leie_sample.csv +15 -0
- hc_source/adapters/codes.py +1232 -0
- hc_source/adapters/coverage.py +1569 -0
- hc_source/adapters/hcc.py +1450 -0
- hc_source/adapters/leie.py +1310 -0
- hc_source/adapters/provider.py +1159 -0
- hc_source/cache.py +664 -0
- hc_source/cli.py +959 -0
- hc_source/cli_manifest.py +207 -0
- hc_source/data/codes/hcpcs_2026q3.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2026.csv.gz +0 -0
- hc_source/data/codes/icd10cm_fy2027.csv.gz +0 -0
- hc_source/data/codes/manifest.json +75 -0
- hc_source/data/codes/regenerate.py +291 -0
- hc_source/data/hcc/hcc_data.json.zlib +0 -0
- hc_source/doctor.py +472 -0
- hc_source/guard.py +877 -0
- hc_source/http.py +541 -0
- hc_source/interfaces.py +395 -0
- hc_source/lockfile.py +236 -0
- hc_source/manifest.py +422 -0
- hc_source/mcp_server.py +203 -0
- hc_source/npi.py +50 -0
- hc_source/receipts.py +74 -0
- hc_source/schemas.py +339 -0
- sourcelock-0.1.0.dist-info/METADATA +272 -0
- sourcelock-0.1.0.dist-info/RECORD +34 -0
- sourcelock-0.1.0.dist-info/WHEEL +4 -0
- sourcelock-0.1.0.dist-info/entry_points.txt +2 -0
- sourcelock-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1569 @@
|
|
|
1
|
+
"""CMS Medicare coverage-policy route: Coverage API + MCD bulk exports.
|
|
2
|
+
|
|
3
|
+
Upstream is the keyless CMS Coverage API (``api.coverage.cms.gov``) with the
|
|
4
|
+
Medicare Coverage Database bulk export ZIPs (``downloads.cms.gov``) as the
|
|
5
|
+
documented fallback. Document families: NCDs (national, binding in all
|
|
6
|
+
states), LCDs (per-MAC-jurisdiction), and Articles (billing/coding guidance
|
|
7
|
+
attached to LCDs -- since the 2019 MCD restructuring, CPT/HCPCS/ICD-10 codes
|
|
8
|
+
live in Articles, not LCDs, except some DME LCDs).
|
|
9
|
+
|
|
10
|
+
License posture (deliberate, load-bearing):
|
|
11
|
+
|
|
12
|
+
* Part of the API sits behind an AMA/ADA/AHA license-agreement Bearer token.
|
|
13
|
+
Fetching that token from ``/v1/metadata/license-agreement/`` legally
|
|
14
|
+
constitutes accepting the AMA CPT personal-use license. **This adapter never
|
|
15
|
+
calls that endpoint.** Automated paths use only keyless surfaces.
|
|
16
|
+
* If the operator has minted a token themselves (their own acceptance act),
|
|
17
|
+
they may export it as ``HC_SOURCE_COVERAGE_LICENSE_TOKEN``; the adapter will
|
|
18
|
+
then use the token-gated ``related-documents`` endpoint (whose payload is
|
|
19
|
+
bare ID tuples, free of licensed descriptor text) instead of downloading the
|
|
20
|
+
bulk export. Endpoints whose responses contain CPT/CDT/NUBC descriptor
|
|
21
|
+
content (``hcpc-code``, ``bill-codes``, ``revenue-code``, LCD/Article
|
|
22
|
+
narrative) are not exposed as tools at all.
|
|
23
|
+
* NCD narrative is redacted whenever the record flags AMA content
|
|
24
|
+
(``ama_statement`` non-empty -- or missing, which is the same thing minus the
|
|
25
|
+
ability to check).
|
|
26
|
+
* Everything that reaches ``data`` passes a structural ALLOWLIST of field names
|
|
27
|
+
per endpoint (``NCD_DETAIL_FIELDS`` and friends). A field CMS adds or renames
|
|
28
|
+
is quarantined: dropped, with only its NAME in a receipt warning. A denylist
|
|
29
|
+
of the known narrative keys cannot see the next licensed field coming.
|
|
30
|
+
|
|
31
|
+
The LCD <-> companion-Article linkage (the route's core query) was verified
|
|
32
|
+
live 2026-08-01 on both surfaces: API ``/v1/data/lcd/related-documents``
|
|
33
|
+
(token-gated; L33818 -> A57660) and the keyless bulk crosswalk
|
|
34
|
+
``lcd_related_documents.csv`` inside ``current_lcd.zip`` (identical column
|
|
35
|
+
set; 967 of 969 current final LCDs carry at least one companion link). See
|
|
36
|
+
the route dossier's VERIFIED ADDENDUM.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import csv
|
|
42
|
+
import dataclasses
|
|
43
|
+
import hashlib
|
|
44
|
+
import io
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import re
|
|
48
|
+
import zipfile
|
|
49
|
+
from datetime import date, datetime, timedelta
|
|
50
|
+
from typing import Any, Literal
|
|
51
|
+
|
|
52
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
53
|
+
|
|
54
|
+
from ..http import FetchResult, SourceUnreachable, as_cache_hit, fetch, fetch_json
|
|
55
|
+
from ..interfaces import Canary, CanaryObservation, SourceAdapter, ToolResult, ToolSpec
|
|
56
|
+
from ..receipts import build_receipt
|
|
57
|
+
from ..schemas import CanaryStatus, SourceContract
|
|
58
|
+
|
|
59
|
+
SOURCE_ID = "coverage"
|
|
60
|
+
TRANSFORM_VERSION = "1"
|
|
61
|
+
|
|
62
|
+
API = "https://api.coverage.cms.gov"
|
|
63
|
+
SCHEDULE_URL = f"{API}/v1/metadata/local-data-schedule/"
|
|
64
|
+
STATES_URL = f"{API}/v1/metadata/states/"
|
|
65
|
+
CONTRACT_TYPES_URL = f"{API}/v1/metadata/contract-type/"
|
|
66
|
+
NCD_REPORT_URL = f"{API}/v1/reports/national-coverage-ncd/"
|
|
67
|
+
NCD_DETAIL_URL = f"{API}/v1/data/ncd/"
|
|
68
|
+
FINAL_LCDS_URL = f"{API}/v1/reports/local-coverage-final-lcds/"
|
|
69
|
+
ARTICLES_REPORT_URL = f"{API}/v1/reports/local-coverage-articles/"
|
|
70
|
+
WHATS_NEW_LOCAL_URL = f"{API}/v1/reports/whats-new/local/"
|
|
71
|
+
WHATS_NEW_NATIONAL_URL = f"{API}/v1/reports/whats-new/national/"
|
|
72
|
+
LCD_RELATED_URL = f"{API}/v1/data/lcd/related-documents"
|
|
73
|
+
LCD_DETAIL_URL = f"{API}/v1/data/lcd/"
|
|
74
|
+
SPEC_URL = f"{API}/docs/v1/coverage-api.json"
|
|
75
|
+
|
|
76
|
+
BULK_BASE = "https://downloads.cms.gov/medicare-coverage-database/downloads/exports"
|
|
77
|
+
BULK_LCD_ZIP_URL = f"{BULK_BASE}/current_lcd.zip"
|
|
78
|
+
NCD_ZIP_URL = f"{BULK_BASE}/ncd.zip"
|
|
79
|
+
|
|
80
|
+
#: Operator-minted AMA/ADA/AHA license token. The adapter NEVER mints one.
|
|
81
|
+
LICENSE_TOKEN_ENV = "HC_SOURCE_COVERAGE_LICENSE_TOKEN"
|
|
82
|
+
|
|
83
|
+
#: Spec paths this adapter (or its canaries) actually calls. The api_contract
|
|
84
|
+
#: canary alarms only when one of these disappears; additions upstream are
|
|
85
|
+
#: informational (additive-path-growth policy).
|
|
86
|
+
REQUIRED_PATHS = (
|
|
87
|
+
"/v1/data/lcd/",
|
|
88
|
+
"/v1/data/lcd/related-documents",
|
|
89
|
+
"/v1/data/ncd/",
|
|
90
|
+
"/v1/metadata/contract-type/",
|
|
91
|
+
"/v1/metadata/local-data-schedule/",
|
|
92
|
+
"/v1/metadata/states/",
|
|
93
|
+
"/v1/reports/local-coverage-articles/",
|
|
94
|
+
"/v1/reports/local-coverage-final-lcds/",
|
|
95
|
+
"/v1/reports/national-coverage-ncd/",
|
|
96
|
+
"/v1/reports/whats-new/local/",
|
|
97
|
+
"/v1/reports/whats-new/national/",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
#: NCD narrative fields withheld when the record flags licensed AMA content.
|
|
101
|
+
NCD_NARRATIVE_FIELDS = (
|
|
102
|
+
"item_service_description",
|
|
103
|
+
"indications_limitations",
|
|
104
|
+
"cross_reference",
|
|
105
|
+
"revision_history",
|
|
106
|
+
"other_text",
|
|
107
|
+
"reasons_for_denial",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
#: Fields each Coverage API surface is allowed to emit, endpoint by endpoint.
|
|
111
|
+
#: These are ALLOWLISTS on purpose. The six narrative keys above are a denylist,
|
|
112
|
+
#: and a denylist over an upstream that can add or rename a column passes the
|
|
113
|
+
#: seventh licensed field straight through into `data` -- AMA CPT, ADA CDT, or
|
|
114
|
+
#: AHA/NUBC content SourceLock is not licensed to redistribute, published under
|
|
115
|
+
#: a receipt saying it is redistribution-clean. Anything not named here is
|
|
116
|
+
#: quarantined: dropped from `data`, with only its NAME recorded in a receipt
|
|
117
|
+
#: warning so the omission is visible and auditable. Extending a list is a
|
|
118
|
+
#: deliberate act that belongs with a contamination review, not a shrug.
|
|
119
|
+
#: Recorded live 2026-08-01 from each endpoint's ``meta.fields``, in upstream
|
|
120
|
+
#: column order so the two can be diffed by eye.
|
|
121
|
+
NCD_DETAIL_FIELDS = (
|
|
122
|
+
"document_id",
|
|
123
|
+
"document_version",
|
|
124
|
+
"document_display_id",
|
|
125
|
+
"title",
|
|
126
|
+
"publication_number",
|
|
127
|
+
"effective_date",
|
|
128
|
+
"effective_end_date",
|
|
129
|
+
"implementation_date",
|
|
130
|
+
"qr_modifier_date",
|
|
131
|
+
"benefit_category",
|
|
132
|
+
"item_service_description",
|
|
133
|
+
"indications_limitations",
|
|
134
|
+
"cross_reference",
|
|
135
|
+
"transmittal_number",
|
|
136
|
+
"transmittal_url",
|
|
137
|
+
"revision_history",
|
|
138
|
+
"other_text",
|
|
139
|
+
"ama_statement",
|
|
140
|
+
"reasons_for_denial",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
#: The three local-document report surfaces (final LCDs, Articles, whats-new
|
|
144
|
+
#: local) share one column set upstream and therefore one allowlist here.
|
|
145
|
+
LOCAL_REPORT_FIELDS = (
|
|
146
|
+
"document_id",
|
|
147
|
+
"document_version",
|
|
148
|
+
"document_display_id",
|
|
149
|
+
"document_type",
|
|
150
|
+
"note",
|
|
151
|
+
"title",
|
|
152
|
+
"contractor_name_type",
|
|
153
|
+
"updated_on",
|
|
154
|
+
"updated_on_sort",
|
|
155
|
+
"effective_date",
|
|
156
|
+
"retirement_date",
|
|
157
|
+
"url",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
NCD_REPORT_FIELDS = (
|
|
161
|
+
"document_id",
|
|
162
|
+
"document_version",
|
|
163
|
+
"document_display_id",
|
|
164
|
+
"last_updated",
|
|
165
|
+
"last_updated_sort",
|
|
166
|
+
"document_type",
|
|
167
|
+
"title",
|
|
168
|
+
"chapter",
|
|
169
|
+
"is_lab",
|
|
170
|
+
"url",
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
WHATS_NEW_NATIONAL_FIELDS = (
|
|
174
|
+
"document_id",
|
|
175
|
+
"document_version",
|
|
176
|
+
"document_display_id",
|
|
177
|
+
"document_status",
|
|
178
|
+
"last_updated",
|
|
179
|
+
"last_updated_sort",
|
|
180
|
+
"document_type",
|
|
181
|
+
"title",
|
|
182
|
+
"whats_new_description",
|
|
183
|
+
"url",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
#: Upstream statuses that mean "CMS had a bad minute", not "CMS changed
|
|
187
|
+
#: something". Every route here that passes raise_for_status=False and reads the
|
|
188
|
+
#: status itself must convert these into SourceUnreachable: doctor grades an
|
|
189
|
+
#: unreadable source UNREACHABLE and a changed value DRIFT, and the two get
|
|
190
|
+
#: different fixes. A 503 on the license-gate probe used to read as drift, and
|
|
191
|
+
#: that canary's drift remediation tells a human to STOP and open a CPT
|
|
192
|
+
#: contamination review -- over one bad minute at CMS. A 200 carrying changed
|
|
193
|
+
#: content is still drift; only these statuses (and the connect/read timeouts
|
|
194
|
+
#: `fetch` already converts, whatever raise_for_status says) are transient.
|
|
195
|
+
TRANSIENT_STATUSES = frozenset({429, 500, 502, 503, 504})
|
|
196
|
+
|
|
197
|
+
#: Bulk ZIP size band for ncd.zip (was 1,276,829 B on 2026-08-01).
|
|
198
|
+
NCD_ZIP_SIZE_BAND = (900_000, 2_500_000)
|
|
199
|
+
|
|
200
|
+
#: MCD-internal state ids are alphabetical-order integers, NOT FIPS; resolve
|
|
201
|
+
#: through /v1/metadata/states/ descriptions. This table only maps postal
|
|
202
|
+
#: abbreviations to the names those descriptions start with (public-domain).
|
|
203
|
+
STATE_NAMES = {
|
|
204
|
+
"AL": "Alabama", "AK": "Alaska", "AS": "American Samoa", "AZ": "Arizona",
|
|
205
|
+
"AR": "Arkansas", "CA": "California", "CO": "Colorado", "CT": "Connecticut",
|
|
206
|
+
"DE": "Delaware", "DC": "District of Columbia", "FL": "Florida",
|
|
207
|
+
"GA": "Georgia", "GU": "Guam", "HI": "Hawaii", "ID": "Idaho",
|
|
208
|
+
"IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas",
|
|
209
|
+
"KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",
|
|
210
|
+
"MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota",
|
|
211
|
+
"MS": "Mississippi", "MO": "Missouri", "MT": "Montana", "NE": "Nebraska",
|
|
212
|
+
"NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey",
|
|
213
|
+
"NM": "New Mexico", "NY": "New York", "NC": "North Carolina",
|
|
214
|
+
"ND": "North Dakota", "MP": "Northern Mariana Islands", "OH": "Ohio",
|
|
215
|
+
"OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania",
|
|
216
|
+
"PR": "Puerto Rico", "RI": "Rhode Island", "SC": "South Carolina",
|
|
217
|
+
"SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah",
|
|
218
|
+
"VT": "Vermont", "VA": "Virginia", "VI": "Virgin Islands",
|
|
219
|
+
"WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin",
|
|
220
|
+
"WY": "Wyoming",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
CONTRACT = SourceContract(
|
|
224
|
+
source_id=SOURCE_ID,
|
|
225
|
+
authority_url=API,
|
|
226
|
+
fallback_url=f"{BULK_BASE}/",
|
|
227
|
+
license_notes=(
|
|
228
|
+
"The coverage documents themselves are US Government works. However, parts of the "
|
|
229
|
+
"Coverage API and the bulk exports embed licensed third-party code sets: CPT "
|
|
230
|
+
"(AMA, personal-use-only license), CDT (ADA), and UB-04 bill/revenue codes "
|
|
231
|
+
"(AHA/NUBC). This adapter never accepts those licenses programmatically, never "
|
|
232
|
+
"calls the license-agreement token endpoint, and never returns licensed "
|
|
233
|
+
"descriptor content; ID tuples, titles, dates, and jurisdiction data returned "
|
|
234
|
+
"here are redistribution-clean. Operators who need descriptor surfaces must "
|
|
235
|
+
"accept the license and mint a token themselves (see "
|
|
236
|
+
"HC_SOURCE_COVERAGE_LICENSE_TOKEN)."
|
|
237
|
+
),
|
|
238
|
+
cadence=(
|
|
239
|
+
"local coverage (LCD/Article): weekly snapshot, captured Sundays 11:59pm ET, "
|
|
240
|
+
"published Thursdays ~02:00 GMT; national documents (NCD/NCA/CAL): event-driven"
|
|
241
|
+
),
|
|
242
|
+
effective_date_semantics=(
|
|
243
|
+
"For NCDs, effective_from/effective_to are the NCD's own effective and "
|
|
244
|
+
"end dates (implementation_date, when claims processing starts, may differ). "
|
|
245
|
+
"For LCDs/Articles, effective_from is the document's effective date within the "
|
|
246
|
+
"weekly MCD snapshot named in source_version; the latest version of a document "
|
|
247
|
+
"may be future-effective or retired. Documents retired over a year leave the "
|
|
248
|
+
"API and bulk exports entirely for the MCD Archive."
|
|
249
|
+
),
|
|
250
|
+
invariants=[
|
|
251
|
+
"the weekly local-data-schedule reports exactly one row, with "
|
|
252
|
+
"data_captured_through (a Sunday) strictly before refreshed_on_date",
|
|
253
|
+
"the OpenAPI spec keeps serving every path this adapter calls",
|
|
254
|
+
"internal document ids are stable: ncdid=11 is NCD 30.3 'Acupuncture' (pub 100-3)",
|
|
255
|
+
"the bulk export ZIPs stay reachable at their documented URLs and ncd.zip stays "
|
|
256
|
+
"within its size band",
|
|
257
|
+
"LCD/Article data endpoints stay behind the AMA/ADA/AHA license gate (HTTP 401 "
|
|
258
|
+
"without a token)",
|
|
259
|
+
"the MCD-internal state and contract-type keyspaces are stable (state 6 is "
|
|
260
|
+
"'California - Entire State'; contract types are exactly ids 8-13)",
|
|
261
|
+
],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
NON_CLAIMS = [
|
|
265
|
+
"DOES_NOT_PROVE_COVERAGE: an NCD/LCD/Article match does not prove Medicare will "
|
|
266
|
+
"cover or pay any particular claim; coverage turns on medical necessity, "
|
|
267
|
+
"jurisdiction, claim context, and edits outside these documents.",
|
|
268
|
+
"CODING_LIVES_IN_ARTICLES: LCDs generally carry no CPT/HCPCS/ICD-10 codes; billing "
|
|
269
|
+
"and coding specifics live in the companion Billing-and-Coding Article (some DME "
|
|
270
|
+
"LCDs excepted).",
|
|
271
|
+
"VERSIONS_ARE_NOT_ALWAYS_IN_EFFECT: the latest version of a document may be "
|
|
272
|
+
"future-effective or retired, and documents retired over a year move to the MCD "
|
|
273
|
+
"Archive; check status and effective dates before relying on a match.",
|
|
274
|
+
"NO_LICENSED_CODE_CONTENT: CPT (AMA), CDT (ADA), and UB-04/NUBC (AHA) descriptor "
|
|
275
|
+
"content is licensed and never returned by this adapter; consult those surfaces "
|
|
276
|
+
"under your own license.",
|
|
277
|
+
"DOES_NOT_PROVE_CURRENCY: local coverage answers come from the weekly MCD snapshot "
|
|
278
|
+
"(captured Sundays, published Thursdays) and may lag the MACs by up to a week.",
|
|
279
|
+
]
|
|
280
|
+
|
|
281
|
+
#: In-process cache of the parsed bulk crosswalk, bound to the weekly MCD
|
|
282
|
+
#: snapshot the rows were downloaded under:
|
|
283
|
+
#: {"snapshot_version": str, "fetch": FetchResult, "rows": list[dict]}.
|
|
284
|
+
#: The binding is the load-bearing part -- see _load_bulk_crosswalk. Tests
|
|
285
|
+
#: clear it.
|
|
286
|
+
_BULK_CACHE: dict[str, Any] = {}
|
|
287
|
+
|
|
288
|
+
_DISPLAY_DATE_RE = re.compile(r"^(\d{2})/(\d{2})/(\d{4})$")
|
|
289
|
+
_YYYYMMDD_RE = re.compile(r"^\d{8}$")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --------------------------------------------------------------------------
|
|
293
|
+
# small helpers
|
|
294
|
+
# --------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _rows(payload: Any) -> list[dict[str, Any]]:
|
|
298
|
+
if isinstance(payload, dict) and isinstance(payload.get("data"), list):
|
|
299
|
+
return payload["data"]
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _fields(payload: Any) -> list[str]:
|
|
304
|
+
if isinstance(payload, dict):
|
|
305
|
+
meta = payload.get("meta") or {}
|
|
306
|
+
fields = meta.get("fields")
|
|
307
|
+
if isinstance(fields, list):
|
|
308
|
+
return [str(f) for f in fields]
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _canonical_hash(obj: Any) -> str:
|
|
313
|
+
return hashlib.sha256(
|
|
314
|
+
json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
315
|
+
).hexdigest()
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _parse_display_date(value: Any) -> date | None:
|
|
319
|
+
"""MM/DD/YYYY display strings; 'N/A' and empty are None."""
|
|
320
|
+
if not isinstance(value, str):
|
|
321
|
+
return None
|
|
322
|
+
m = _DISPLAY_DATE_RE.match(value.strip())
|
|
323
|
+
if not m:
|
|
324
|
+
return None
|
|
325
|
+
return date(int(m.group(3)), int(m.group(1)), int(m.group(2)))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _parse_yyyymmdd(value: Any) -> date | None:
|
|
329
|
+
if isinstance(value, str) and _YYYYMMDD_RE.match(value):
|
|
330
|
+
return date(int(value[:4]), int(value[4:6]), int(value[6:8]))
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _clean_crlf(value: Any) -> Any:
|
|
335
|
+
return value.replace("\r\n", " ").replace("\r", " ") if isinstance(value, str) else value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _quarantine(
|
|
339
|
+
row: dict[str, Any], allowed: tuple[str, ...]
|
|
340
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
341
|
+
"""Project one upstream row onto its endpoint's allowlist.
|
|
342
|
+
|
|
343
|
+
Returns the kept fields (CRLF-normalized) and the NAMES of the fields that
|
|
344
|
+
were dropped. The VALUE of a dropped field is never returned, never logged,
|
|
345
|
+
and never put in a warning -- the reason it was dropped is precisely that
|
|
346
|
+
nobody has established what it contains.
|
|
347
|
+
"""
|
|
348
|
+
kept = {k: _clean_crlf(v) for k, v in row.items() if k in allowed}
|
|
349
|
+
return kept, [k for k in row if k not in allowed]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _quarantine_warning(endpoint: str, names: list[str]) -> str:
|
|
353
|
+
return (
|
|
354
|
+
f"{len(names)} field(s) returned by {endpoint} are not on this adapter's "
|
|
355
|
+
f"allowlist and were dropped from the answer: {', '.join(names)}. Names "
|
|
356
|
+
"only: an unrecognised field may carry AMA CPT, ADA CDT, or AHA/NUBC "
|
|
357
|
+
"licensed content, so its value is never emitted. Read it on the MCD "
|
|
358
|
+
"website under your own license, or extend the allowlist in "
|
|
359
|
+
"hc_source/adapters/coverage.py after a contamination review."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _quarantine_rows(
|
|
364
|
+
rows: list[dict[str, Any]],
|
|
365
|
+
allowed: tuple[str, ...],
|
|
366
|
+
endpoint: str,
|
|
367
|
+
warnings: list[str],
|
|
368
|
+
) -> list[dict[str, Any]]:
|
|
369
|
+
"""Project a list of rows and append one warning naming every dropped field."""
|
|
370
|
+
projected: list[dict[str, Any]] = []
|
|
371
|
+
dropped: set[str] = set()
|
|
372
|
+
for row in rows:
|
|
373
|
+
kept, names = _quarantine(row, allowed)
|
|
374
|
+
projected.append(kept)
|
|
375
|
+
dropped.update(names)
|
|
376
|
+
if dropped:
|
|
377
|
+
warnings.append(_quarantine_warning(endpoint, sorted(dropped)))
|
|
378
|
+
return projected
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _raise_if_transient(result: FetchResult, url: str) -> None:
|
|
382
|
+
"""Turn a transient upstream status into an unreachable source.
|
|
383
|
+
|
|
384
|
+
Called by every route that inspects a status itself. Without it a 429 or a
|
|
385
|
+
5xx becomes an observation value, which doctor then reports as drift -- a
|
|
386
|
+
false claim that CMS changed something, carrying a remediation that sends a
|
|
387
|
+
human to re-pin or to open a contamination review over an outage.
|
|
388
|
+
"""
|
|
389
|
+
if result.status in TRANSIENT_STATUSES:
|
|
390
|
+
raise SourceUnreachable(
|
|
391
|
+
url,
|
|
392
|
+
"transient upstream failure; the source was not read this run",
|
|
393
|
+
status=result.status,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _snapshot_version() -> tuple[FetchResult, str, str]:
|
|
398
|
+
"""The canonical weekly-dataset version string for local coverage data."""
|
|
399
|
+
result, payload = fetch_json(SCHEDULE_URL)
|
|
400
|
+
rows = _rows(payload)
|
|
401
|
+
if len(rows) != 1:
|
|
402
|
+
raise SourceUnreachable(SCHEDULE_URL, "local-data-schedule returned unexpected shape")
|
|
403
|
+
refreshed = str(rows[0].get("refreshed_on_date", ""))
|
|
404
|
+
captured = str(rows[0].get("data_captured_through", ""))
|
|
405
|
+
if not (_YYYYMMDD_RE.match(refreshed) and _YYYYMMDD_RE.match(captured)):
|
|
406
|
+
raise SourceUnreachable(SCHEDULE_URL, "local-data-schedule dates were not YYYYMMDD")
|
|
407
|
+
return result, refreshed, captured
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resolve_state_id(state: str) -> tuple[int | None, list[str]]:
|
|
411
|
+
"""Resolve a postal abbreviation to the MCD-internal state_id (NOT FIPS)."""
|
|
412
|
+
warnings: list[str] = []
|
|
413
|
+
name = STATE_NAMES.get(state.upper())
|
|
414
|
+
if name is None:
|
|
415
|
+
return None, [f"Unknown state abbreviation {state!r}."]
|
|
416
|
+
_, payload = fetch_json(STATES_URL)
|
|
417
|
+
matches = [
|
|
418
|
+
row
|
|
419
|
+
for row in _rows(payload)
|
|
420
|
+
if isinstance(row.get("description"), str)
|
|
421
|
+
and (row["description"] == name or row["description"].startswith(name + " "))
|
|
422
|
+
]
|
|
423
|
+
if not matches:
|
|
424
|
+
return None, [f"State {name!r} not present in /v1/metadata/states/."]
|
|
425
|
+
matches.sort(key=lambda r: int(r["state_id"]))
|
|
426
|
+
if len(matches) > 1:
|
|
427
|
+
warnings.append(
|
|
428
|
+
f"State {name!r} matched {len(matches)} MCD state entries; using "
|
|
429
|
+
f"state_id={matches[0]['state_id']} ({matches[0]['description']!r})."
|
|
430
|
+
)
|
|
431
|
+
return int(matches[0]["state_id"]), warnings
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _license_token() -> str | None:
|
|
435
|
+
token = os.environ.get(LICENSE_TOKEN_ENV, "").strip()
|
|
436
|
+
return token or None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# --------------------------------------------------------------------------
|
|
440
|
+
# typed public parameters
|
|
441
|
+
# --------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class NoParams(BaseModel):
|
|
445
|
+
model_config = ConfigDict(extra="forbid")
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
class LookupNcdParams(BaseModel):
|
|
449
|
+
model_config = ConfigDict(extra="forbid")
|
|
450
|
+
|
|
451
|
+
section: str = Field(
|
|
452
|
+
pattern=r"^\d{1,3}(\.\d{1,3}){0,2}$",
|
|
453
|
+
description="NCD manual section number, e.g. '30.3' (Publication 100-3 numbering).",
|
|
454
|
+
)
|
|
455
|
+
version: int | None = Field(
|
|
456
|
+
default=None, ge=1, description="NCD version; omit for the latest."
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class LookupLcdParams(BaseModel):
|
|
461
|
+
model_config = ConfigDict(extra="forbid")
|
|
462
|
+
|
|
463
|
+
lcd_id: int = Field(ge=1, description="LCD number without the 'L' prefix, e.g. 33818.")
|
|
464
|
+
state: str | None = Field(
|
|
465
|
+
default=None,
|
|
466
|
+
pattern=r"^[A-Za-z]{2}$",
|
|
467
|
+
description="Two-letter state/territory abbreviation to scope the jurisdiction.",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
class CompanionArticlesParams(BaseModel):
|
|
472
|
+
model_config = ConfigDict(extra="forbid")
|
|
473
|
+
|
|
474
|
+
lcd_id: int = Field(ge=1, description="LCD number without the 'L' prefix, e.g. 33818.")
|
|
475
|
+
version: int | None = Field(
|
|
476
|
+
default=None, ge=1, description="LCD version; omit for the latest in the crosswalk."
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
class SearchDocumentsParams(BaseModel):
|
|
481
|
+
model_config = ConfigDict(extra="forbid")
|
|
482
|
+
|
|
483
|
+
keyword: str = Field(
|
|
484
|
+
min_length=2,
|
|
485
|
+
max_length=80,
|
|
486
|
+
pattern=r"^[A-Za-z0-9][A-Za-z0-9 &,()\-./']{1,79}$",
|
|
487
|
+
description="Case-insensitive title keyword, e.g. 'malignant'.",
|
|
488
|
+
)
|
|
489
|
+
document_type: Literal["lcd", "article", "ncd"] = Field(
|
|
490
|
+
description="Which document family to search."
|
|
491
|
+
)
|
|
492
|
+
state: str | None = Field(
|
|
493
|
+
default=None,
|
|
494
|
+
pattern=r"^[A-Za-z]{2}$",
|
|
495
|
+
description="Two-letter state abbreviation (local documents only).",
|
|
496
|
+
)
|
|
497
|
+
contractor_id: int | None = Field(
|
|
498
|
+
default=None, ge=1, description="MAC contractor id (local documents only)."
|
|
499
|
+
)
|
|
500
|
+
status: Literal["A", "R", "F"] | None = Field(
|
|
501
|
+
default=None,
|
|
502
|
+
description="A=Active, R=Retired, F=Future Effective (local documents only).",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
@model_validator(mode="after")
|
|
506
|
+
def _local_filters_only_for_local(self) -> SearchDocumentsParams:
|
|
507
|
+
if self.document_type == "ncd" and (self.state or self.contractor_id or self.status):
|
|
508
|
+
raise ValueError(
|
|
509
|
+
"state, contractor_id, and status apply to local documents only; "
|
|
510
|
+
"NCDs are national"
|
|
511
|
+
)
|
|
512
|
+
return self
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
class WhatsChangedParams(BaseModel):
|
|
516
|
+
model_config = ConfigDict(extra="forbid")
|
|
517
|
+
|
|
518
|
+
scope: Literal["local", "national"] = Field(
|
|
519
|
+
description="local = LCD/Article whats-new feed; national = NCD/NCA/CAL feed."
|
|
520
|
+
)
|
|
521
|
+
days_back: int = Field(
|
|
522
|
+
default=7, ge=1, le=120, description="Look-back window in days (max 120)."
|
|
523
|
+
)
|
|
524
|
+
document_type: Literal["NCD", "NCA", "CAL", "MEDCAC", "TA", "MCD"] | None = Field(
|
|
525
|
+
default=None, description="National feed only: restrict to one document type."
|
|
526
|
+
)
|
|
527
|
+
contractor_id: int | None = Field(
|
|
528
|
+
default=None, ge=1, description="Local feed only: restrict to one MAC contractor."
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
@model_validator(mode="after")
|
|
532
|
+
def _filters_match_scope(self) -> WhatsChangedParams:
|
|
533
|
+
if self.scope == "local" and self.document_type:
|
|
534
|
+
raise ValueError("document_type applies to scope='national' only")
|
|
535
|
+
if self.scope == "national" and self.contractor_id:
|
|
536
|
+
raise ValueError("contractor_id applies to scope='local' only")
|
|
537
|
+
return self
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# --------------------------------------------------------------------------
|
|
541
|
+
# handlers
|
|
542
|
+
# --------------------------------------------------------------------------
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _snapshot(_: NoParams) -> ToolResult:
|
|
546
|
+
result, refreshed, captured = _snapshot_version()
|
|
547
|
+
refreshed_date = _parse_yyyymmdd(refreshed)
|
|
548
|
+
captured_date = _parse_yyyymmdd(captured)
|
|
549
|
+
data = {
|
|
550
|
+
"data_captured_through": captured,
|
|
551
|
+
"refreshed_on_date": refreshed,
|
|
552
|
+
"captured_through_date": captured_date.isoformat() if captured_date else None,
|
|
553
|
+
"refreshed_on": refreshed_date.isoformat() if refreshed_date else None,
|
|
554
|
+
}
|
|
555
|
+
return ToolResult(
|
|
556
|
+
data=data,
|
|
557
|
+
receipt=build_receipt(
|
|
558
|
+
contract=CONTRACT,
|
|
559
|
+
route="coverage.snapshot",
|
|
560
|
+
fetch=result,
|
|
561
|
+
source_version=f"mcd-weekly-{refreshed}",
|
|
562
|
+
transform_version=TRANSFORM_VERSION,
|
|
563
|
+
effective_from=refreshed_date,
|
|
564
|
+
non_claims=NON_CLAIMS,
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _lookup_ncd(params: LookupNcdParams) -> ToolResult:
|
|
570
|
+
warnings: list[str] = []
|
|
571
|
+
|
|
572
|
+
report_fetch, report = fetch_json(NCD_REPORT_URL)
|
|
573
|
+
anchor = next(
|
|
574
|
+
(r for r in _rows(report) if str(r.get("document_display_id")) == params.section),
|
|
575
|
+
None,
|
|
576
|
+
)
|
|
577
|
+
if anchor is None:
|
|
578
|
+
warnings.append(
|
|
579
|
+
"The requested NCD section was not found in the national-coverage-ncd report; "
|
|
580
|
+
"it may never have existed or may have been removed."
|
|
581
|
+
)
|
|
582
|
+
return ToolResult(
|
|
583
|
+
data=None,
|
|
584
|
+
receipt=build_receipt(
|
|
585
|
+
contract=CONTRACT,
|
|
586
|
+
route="coverage.lookup_ncd",
|
|
587
|
+
fetch=report_fetch,
|
|
588
|
+
source_version="national-coverage-ncd report",
|
|
589
|
+
transform_version=TRANSFORM_VERSION,
|
|
590
|
+
warnings=warnings,
|
|
591
|
+
non_claims=NON_CLAIMS,
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
query: dict[str, Any] = {"ncdid": int(anchor["document_id"])}
|
|
596
|
+
if params.version is not None:
|
|
597
|
+
query["ncdver"] = params.version
|
|
598
|
+
detail_fetch = fetch(NCD_DETAIL_URL, params=query, raise_for_status=False)
|
|
599
|
+
# A 5xx is not "no such NCD". Reporting it as a miss would tell a caller the
|
|
600
|
+
# section does not exist on the strength of an outage.
|
|
601
|
+
_raise_if_transient(detail_fetch, NCD_DETAIL_URL)
|
|
602
|
+
record: dict[str, Any] | None = None
|
|
603
|
+
if detail_fetch.status == 200:
|
|
604
|
+
detail_rows = _rows(detail_fetch.json())
|
|
605
|
+
record = dict(detail_rows[0]) if detail_rows else None
|
|
606
|
+
if record is None:
|
|
607
|
+
warnings.append(
|
|
608
|
+
f"The requested NCD (ncdid={query['ncdid']}"
|
|
609
|
+
+ (f", version {params.version}" if params.version is not None else "")
|
|
610
|
+
+ f") returned no record (HTTP {detail_fetch.status})."
|
|
611
|
+
)
|
|
612
|
+
return ToolResult(
|
|
613
|
+
data=None,
|
|
614
|
+
receipt=build_receipt(
|
|
615
|
+
contract=CONTRACT,
|
|
616
|
+
route="coverage.lookup_ncd",
|
|
617
|
+
fetch=detail_fetch,
|
|
618
|
+
source_version=f"ncd {params.section} (not found)",
|
|
619
|
+
transform_version=TRANSFORM_VERSION,
|
|
620
|
+
warnings=warnings,
|
|
621
|
+
non_claims=NON_CLAIMS,
|
|
622
|
+
),
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
record, dropped = _quarantine(record, NCD_DETAIL_FIELDS)
|
|
626
|
+
if dropped:
|
|
627
|
+
warnings.append(_quarantine_warning("/v1/data/ncd/", dropped))
|
|
628
|
+
|
|
629
|
+
# Withheld unless the record positively asserts an EMPTY ama_statement. A
|
|
630
|
+
# missing key is treated as flagged, not as clean: if CMS renames the flag,
|
|
631
|
+
# the failure mode of the old test (`record.get("ama_statement")` -> None ->
|
|
632
|
+
# falsy -> publish) was to redistribute licensed narrative. Failing towards
|
|
633
|
+
# withholding costs a caller some public-domain prose; failing the other way
|
|
634
|
+
# costs an AMA license SourceLock does not hold.
|
|
635
|
+
ama_statement = record.get("ama_statement")
|
|
636
|
+
if "ama_statement" not in record or str(ama_statement or "").strip():
|
|
637
|
+
for field_name in NCD_NARRATIVE_FIELDS:
|
|
638
|
+
if field_name in record:
|
|
639
|
+
record[field_name] = None
|
|
640
|
+
warnings.append(
|
|
641
|
+
"Narrative fields withheld: this NCD flags licensed AMA CPT content "
|
|
642
|
+
"(ama_statement present, or absent from a record whose narrative "
|
|
643
|
+
"cannot then be cleared). Read the narrative on the MCD website under "
|
|
644
|
+
"your own CPT license."
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
return ToolResult(
|
|
648
|
+
data=record,
|
|
649
|
+
receipt=build_receipt(
|
|
650
|
+
contract=CONTRACT,
|
|
651
|
+
route="coverage.lookup_ncd",
|
|
652
|
+
fetch=detail_fetch,
|
|
653
|
+
source_version=f"ncd {params.section} v{record.get('document_version')}",
|
|
654
|
+
transform_version=TRANSFORM_VERSION,
|
|
655
|
+
effective_from=_parse_display_date(record.get("effective_date")),
|
|
656
|
+
effective_to=_parse_display_date(record.get("effective_end_date")),
|
|
657
|
+
warnings=warnings,
|
|
658
|
+
non_claims=NON_CLAIMS,
|
|
659
|
+
),
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _parse_contractors(name_type: Any) -> list[dict[str, str]]:
|
|
664
|
+
"""Split 'Name\r\n(Types)\r\nName2\r\n(Types2)' into structured entries."""
|
|
665
|
+
contractors: list[dict[str, str]] = []
|
|
666
|
+
if not isinstance(name_type, str):
|
|
667
|
+
return contractors
|
|
668
|
+
for part in (p.strip() for p in name_type.split("\r\n") if p.strip()):
|
|
669
|
+
if part.startswith("(") and contractors:
|
|
670
|
+
contractors[-1]["contract_types"] = part.strip("()")
|
|
671
|
+
else:
|
|
672
|
+
contractors.append({"name": part, "contract_types": ""})
|
|
673
|
+
return contractors
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _lookup_lcd(params: LookupLcdParams) -> ToolResult:
|
|
677
|
+
warnings: list[str] = []
|
|
678
|
+
_, refreshed, _captured = _snapshot_version()
|
|
679
|
+
|
|
680
|
+
query: dict[str, Any] = {}
|
|
681
|
+
scope_note = ""
|
|
682
|
+
if params.state:
|
|
683
|
+
state_id, state_warnings = _resolve_state_id(params.state)
|
|
684
|
+
warnings.extend(state_warnings)
|
|
685
|
+
if state_id is None:
|
|
686
|
+
return ToolResult(
|
|
687
|
+
data=None,
|
|
688
|
+
receipt=build_receipt(
|
|
689
|
+
contract=CONTRACT,
|
|
690
|
+
route="coverage.lookup_lcd",
|
|
691
|
+
fetch=fetch(SCHEDULE_URL),
|
|
692
|
+
source_version=f"mcd-weekly-{refreshed}",
|
|
693
|
+
transform_version=TRANSFORM_VERSION,
|
|
694
|
+
warnings=warnings,
|
|
695
|
+
non_claims=NON_CLAIMS,
|
|
696
|
+
),
|
|
697
|
+
)
|
|
698
|
+
query["state_id"] = state_id
|
|
699
|
+
scope_note = f" for state {params.state.upper()}"
|
|
700
|
+
|
|
701
|
+
report_fetch, report = fetch_json(FINAL_LCDS_URL, params=query or None)
|
|
702
|
+
matches = [r for r in _rows(report) if r.get("document_id") == params.lcd_id]
|
|
703
|
+
if not matches:
|
|
704
|
+
warnings.append(
|
|
705
|
+
f"L{params.lcd_id} not found in the current final-LCDs report{scope_note}. "
|
|
706
|
+
"It may be proposed-only, outside the selected jurisdiction, or retired "
|
|
707
|
+
"over a year (in which case it lives in the MCD Archive, which has no API)."
|
|
708
|
+
)
|
|
709
|
+
return ToolResult(
|
|
710
|
+
data=None,
|
|
711
|
+
receipt=build_receipt(
|
|
712
|
+
contract=CONTRACT,
|
|
713
|
+
route="coverage.lookup_lcd",
|
|
714
|
+
fetch=report_fetch,
|
|
715
|
+
source_version=f"mcd-weekly-{refreshed}",
|
|
716
|
+
transform_version=TRANSFORM_VERSION,
|
|
717
|
+
warnings=warnings,
|
|
718
|
+
non_claims=NON_CLAIMS,
|
|
719
|
+
),
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# No _quarantine call here: this route names every field it emits, which is
|
|
723
|
+
# an allowlist by construction. A new upstream column cannot reach `data`.
|
|
724
|
+
first = matches[0]
|
|
725
|
+
contractors: list[dict[str, str]] = []
|
|
726
|
+
for row in matches:
|
|
727
|
+
contractors.extend(_parse_contractors(row.get("contractor_name_type")))
|
|
728
|
+
data = {
|
|
729
|
+
"lcd_id": params.lcd_id,
|
|
730
|
+
"display_id": first.get("document_display_id"),
|
|
731
|
+
"version": first.get("document_version"),
|
|
732
|
+
"title": first.get("title"),
|
|
733
|
+
"status_note": first.get("note") or "",
|
|
734
|
+
"effective_date": first.get("effective_date"),
|
|
735
|
+
"retirement_date": (
|
|
736
|
+
None if first.get("retirement_date") in (None, "N/A") else first.get("retirement_date")
|
|
737
|
+
),
|
|
738
|
+
"contractors": contractors,
|
|
739
|
+
"url": first.get("url"),
|
|
740
|
+
}
|
|
741
|
+
if data["status_note"] == "Future":
|
|
742
|
+
warnings.append(
|
|
743
|
+
f"L{params.lcd_id} is future-effective (effective {data['effective_date']}); "
|
|
744
|
+
"it is not in force yet."
|
|
745
|
+
)
|
|
746
|
+
|
|
747
|
+
return ToolResult(
|
|
748
|
+
data=data,
|
|
749
|
+
receipt=build_receipt(
|
|
750
|
+
contract=CONTRACT,
|
|
751
|
+
route="coverage.lookup_lcd",
|
|
752
|
+
fetch=report_fetch,
|
|
753
|
+
source_version=f"mcd-weekly-{refreshed}",
|
|
754
|
+
transform_version=TRANSFORM_VERSION,
|
|
755
|
+
effective_from=_parse_display_date(first.get("effective_date")),
|
|
756
|
+
effective_to=_parse_display_date(first.get("retirement_date")),
|
|
757
|
+
warnings=warnings,
|
|
758
|
+
non_claims=NON_CLAIMS,
|
|
759
|
+
),
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
#: current_lcd.zip measured 31.8 MB on 2026-08-01 -- the largest real full-body
|
|
764
|
+
#: fetch in the product. 128 MiB is 4x that, and the timeout of 300s below is
|
|
765
|
+
#: the other half of the same acknowledgement that this one is big.
|
|
766
|
+
BULK_ZIP_MAX_BYTES = 128 * 1024 * 1024
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _load_bulk_crosswalk(
|
|
770
|
+
snapshot_version: str,
|
|
771
|
+
) -> tuple[FetchResult, list[dict[str, str]], bool]:
|
|
772
|
+
"""Fetch and parse lcd_related_documents.csv from the keyless bulk export.
|
|
773
|
+
|
|
774
|
+
Returns ``(fetch_result, rows, served_from_cache)``. The parsed crosswalk is
|
|
775
|
+
cached in-process -- the export only changes on the weekly Thursday refresh
|
|
776
|
+
-- but the entry is bound to ``snapshot_version``, the weekly MCD release
|
|
777
|
+
observed when those rows were downloaded, and a different release refetches.
|
|
778
|
+
|
|
779
|
+
The cache used to be keyed by nothing at all (one entry, one constant URL)
|
|
780
|
+
while every answer was stamped with a freshly-fetched schedule version. A
|
|
781
|
+
process that stayed up across a Thursday refresh then served week-N rows
|
|
782
|
+
under a receipt claiming week N+1: rows from one release paired with the
|
|
783
|
+
version identifier of another, which is the one thing a receipt must never
|
|
784
|
+
do. Refetching beats serving the old rows under the old version plus a
|
|
785
|
+
staleness warning: the release moves once a week, so a long-lived process
|
|
786
|
+
pays one extra download per refresh, and what it then returns is the
|
|
787
|
+
crosswalk the caller actually asked for rather than a correctly-labelled
|
|
788
|
+
stale one.
|
|
789
|
+
"""
|
|
790
|
+
if _BULK_CACHE.get("snapshot_version") == snapshot_version:
|
|
791
|
+
return _BULK_CACHE["fetch"], _BULK_CACHE["rows"], True
|
|
792
|
+
result = fetch(BULK_LCD_ZIP_URL, timeout=300.0, max_bytes=BULK_ZIP_MAX_BYTES)
|
|
793
|
+
try:
|
|
794
|
+
with zipfile.ZipFile(io.BytesIO(result.content)) as outer:
|
|
795
|
+
inner_name = next(n for n in outer.namelist() if n.endswith("_csv.zip"))
|
|
796
|
+
with zipfile.ZipFile(io.BytesIO(outer.read(inner_name))) as inner:
|
|
797
|
+
text = inner.read("lcd_related_documents.csv").decode("utf-8-sig")
|
|
798
|
+
except (zipfile.BadZipFile, KeyError, StopIteration) as exc:
|
|
799
|
+
raise SourceUnreachable(
|
|
800
|
+
BULK_LCD_ZIP_URL,
|
|
801
|
+
f"bulk export did not contain lcd_related_documents.csv ({type(exc).__name__})",
|
|
802
|
+
) from exc
|
|
803
|
+
rows = list(csv.DictReader(io.StringIO(text)))
|
|
804
|
+
# The FetchResult object itself is cached, never rebuilt: it carries the
|
|
805
|
+
# retrieved_at stamped when these bytes were read, and a rebuilt one would
|
|
806
|
+
# make the receipt claim a read that did not happen.
|
|
807
|
+
_BULK_CACHE["snapshot_version"] = snapshot_version
|
|
808
|
+
_BULK_CACHE["fetch"] = result
|
|
809
|
+
_BULK_CACHE["rows"] = rows
|
|
810
|
+
return result, rows, False
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _relation_entries(rows: list[dict[str, Any]]) -> tuple[list[dict], list[dict]]:
|
|
814
|
+
"""Normalize crosswalk rows (API or bulk CSV) into articles + related LCDs."""
|
|
815
|
+
articles: list[dict[str, Any]] = []
|
|
816
|
+
related_lcds: list[dict[str, Any]] = []
|
|
817
|
+
for row in rows:
|
|
818
|
+
article_id = row.get("r_article_id")
|
|
819
|
+
lcd_id = row.get("r_lcd_id")
|
|
820
|
+
if article_id not in (None, ""):
|
|
821
|
+
articles.append(
|
|
822
|
+
{
|
|
823
|
+
"article_id": int(article_id),
|
|
824
|
+
"display_id": f"A{int(article_id)}",
|
|
825
|
+
"article_version": (
|
|
826
|
+
int(row["r_article_version"])
|
|
827
|
+
if row.get("r_article_version") not in (None, "")
|
|
828
|
+
else None
|
|
829
|
+
),
|
|
830
|
+
"contractor_id": (
|
|
831
|
+
int(row["r_contractor_id"])
|
|
832
|
+
if row.get("r_contractor_id") not in (None, "")
|
|
833
|
+
else None
|
|
834
|
+
),
|
|
835
|
+
}
|
|
836
|
+
)
|
|
837
|
+
elif lcd_id not in (None, ""):
|
|
838
|
+
related_lcds.append(
|
|
839
|
+
{
|
|
840
|
+
"lcd_id": int(lcd_id),
|
|
841
|
+
"display_id": f"L{int(lcd_id)}",
|
|
842
|
+
"lcd_version": (
|
|
843
|
+
int(row["r_lcd_version"])
|
|
844
|
+
if row.get("r_lcd_version") not in (None, "")
|
|
845
|
+
else None
|
|
846
|
+
),
|
|
847
|
+
}
|
|
848
|
+
)
|
|
849
|
+
articles.sort(key=lambda a: a["article_id"])
|
|
850
|
+
related_lcds.sort(key=lambda entry: entry["lcd_id"])
|
|
851
|
+
return articles, related_lcds
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _companion_articles(params: CompanionArticlesParams) -> ToolResult:
|
|
855
|
+
warnings: list[str] = []
|
|
856
|
+
_, refreshed, _captured = _snapshot_version()
|
|
857
|
+
|
|
858
|
+
relation_rows: list[dict[str, Any]] | None = None
|
|
859
|
+
result: FetchResult | None = None
|
|
860
|
+
resolved_version: int | None = params.version
|
|
861
|
+
|
|
862
|
+
token = _license_token()
|
|
863
|
+
if token is not None:
|
|
864
|
+
query: dict[str, Any] = {"lcdid": params.lcd_id}
|
|
865
|
+
if params.version is not None:
|
|
866
|
+
query["ver"] = params.version
|
|
867
|
+
try:
|
|
868
|
+
api_fetch = fetch(
|
|
869
|
+
LCD_RELATED_URL,
|
|
870
|
+
params=query,
|
|
871
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
872
|
+
raise_for_status=False,
|
|
873
|
+
)
|
|
874
|
+
except SourceUnreachable as exc:
|
|
875
|
+
warnings.append(
|
|
876
|
+
f"Token-gated related-documents endpoint unreachable ({exc.reason}); "
|
|
877
|
+
"falling back to the keyless bulk export."
|
|
878
|
+
)
|
|
879
|
+
else:
|
|
880
|
+
if api_fetch.status == 200:
|
|
881
|
+
relation_rows = _rows(api_fetch.json())
|
|
882
|
+
result = api_fetch
|
|
883
|
+
warnings.append(
|
|
884
|
+
"Fetched via the operator-supplied license token "
|
|
885
|
+
f"({LICENSE_TOKEN_ENV}). Minting that token was the operator's own "
|
|
886
|
+
"AMA/ADA/AHA license acceptance; SourceLock never accepts it for you."
|
|
887
|
+
)
|
|
888
|
+
if relation_rows and resolved_version is None:
|
|
889
|
+
resolved_version = relation_rows[0].get("lcd_version")
|
|
890
|
+
elif api_fetch.status in TRANSIENT_STATUSES:
|
|
891
|
+
# Not a token problem. Telling an operator their token expired
|
|
892
|
+
# over a 503 sends them to re-mint a token that was fine.
|
|
893
|
+
warnings.append(
|
|
894
|
+
f"Token-gated related-documents endpoint returned a transient "
|
|
895
|
+
f"upstream failure (HTTP {api_fetch.status}); that is a CMS-side "
|
|
896
|
+
"outage, and it says nothing about the token. Falling back to the "
|
|
897
|
+
"keyless bulk export."
|
|
898
|
+
)
|
|
899
|
+
else:
|
|
900
|
+
warnings.append(
|
|
901
|
+
f"Operator-supplied license token was rejected (HTTP {api_fetch.status}); "
|
|
902
|
+
"it expires one hour after minting. Falling back to the keyless bulk "
|
|
903
|
+
"export."
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
if relation_rows is None:
|
|
907
|
+
bulk_fetch, all_rows, cached = _load_bulk_crosswalk(refreshed)
|
|
908
|
+
lcd_rows = [r for r in all_rows if r.get("lcd_id") == str(params.lcd_id)]
|
|
909
|
+
if params.version is None:
|
|
910
|
+
versions = [int(r["lcd_version"]) for r in lcd_rows if r.get("lcd_version")]
|
|
911
|
+
resolved_version = max(versions) if versions else None
|
|
912
|
+
if resolved_version is not None:
|
|
913
|
+
lcd_rows = [
|
|
914
|
+
r for r in lcd_rows if r.get("lcd_version") == str(resolved_version)
|
|
915
|
+
]
|
|
916
|
+
relation_rows = lcd_rows
|
|
917
|
+
result = dataclasses.replace(
|
|
918
|
+
bulk_fetch, fallback_used=True, fallback_name="mcd-bulk-current-lcd-zip"
|
|
919
|
+
)
|
|
920
|
+
if cached:
|
|
921
|
+
# The prose warning below said "served from the in-process cache"
|
|
922
|
+
# while the structured field said cache_hit: false. A machine
|
|
923
|
+
# reading the receipt believes the field. retrieved_at and
|
|
924
|
+
# revalidated_at stay where the download put them -- reuse inside
|
|
925
|
+
# one weekly snapshot contacted nobody.
|
|
926
|
+
result = as_cache_hit(result)
|
|
927
|
+
# Reason-neutral wording: this branch is also reached when a token WAS
|
|
928
|
+
# supplied and the endpoint failed, and the warning above says why.
|
|
929
|
+
warnings.append(
|
|
930
|
+
"Used the keyless MCD bulk export crosswalk (current_lcd.zip / "
|
|
931
|
+
"lcd_related_documents.csv) rather than the token-gated related-documents "
|
|
932
|
+
f"endpoint. Set {LICENSE_TOKEN_ENV} to use the live endpoint under your own "
|
|
933
|
+
"license acceptance."
|
|
934
|
+
)
|
|
935
|
+
if cached:
|
|
936
|
+
warnings.append(
|
|
937
|
+
"Crosswalk served from the in-process cache of a bulk export downloaded "
|
|
938
|
+
f"earlier in this run under this same weekly snapshot (mcd-weekly-"
|
|
939
|
+
f"{refreshed}); receipt provenance and retrieved_at cover that download, "
|
|
940
|
+
"and cache_hit says so."
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
articles, related_lcds = _relation_entries(relation_rows)
|
|
944
|
+
if not articles:
|
|
945
|
+
warnings.append(
|
|
946
|
+
f"No companion Article found for L{params.lcd_id}"
|
|
947
|
+
+ (f" v{resolved_version}" if resolved_version is not None else "")
|
|
948
|
+
+ ". Genuine for a few LCDs (notably DME), otherwise check the LCD id."
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
assert result is not None # both branches set it or raised
|
|
952
|
+
return ToolResult(
|
|
953
|
+
data={
|
|
954
|
+
"lcd_id": params.lcd_id,
|
|
955
|
+
"display_id": f"L{params.lcd_id}",
|
|
956
|
+
"lcd_version": resolved_version,
|
|
957
|
+
"companion_articles": articles,
|
|
958
|
+
"related_lcds": related_lcds,
|
|
959
|
+
},
|
|
960
|
+
receipt=build_receipt(
|
|
961
|
+
contract=CONTRACT,
|
|
962
|
+
route="coverage.companion_articles",
|
|
963
|
+
fetch=result,
|
|
964
|
+
source_version=f"mcd-weekly-{refreshed}",
|
|
965
|
+
transform_version=TRANSFORM_VERSION,
|
|
966
|
+
warnings=warnings,
|
|
967
|
+
non_claims=NON_CLAIMS,
|
|
968
|
+
),
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
_SEARCH_MATCH_CAP = 50
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _search_documents(params: SearchDocumentsParams) -> ToolResult:
|
|
976
|
+
warnings: list[str] = []
|
|
977
|
+
|
|
978
|
+
if params.document_type == "ncd":
|
|
979
|
+
url = NCD_REPORT_URL
|
|
980
|
+
allowed = NCD_REPORT_FIELDS
|
|
981
|
+
endpoint = "/v1/reports/national-coverage-ncd/"
|
|
982
|
+
query: dict[str, Any] = {}
|
|
983
|
+
source_version = "national-coverage-ncd report"
|
|
984
|
+
else:
|
|
985
|
+
url = FINAL_LCDS_URL if params.document_type == "lcd" else ARTICLES_REPORT_URL
|
|
986
|
+
allowed = LOCAL_REPORT_FIELDS
|
|
987
|
+
endpoint = (
|
|
988
|
+
"/v1/reports/local-coverage-final-lcds/"
|
|
989
|
+
if params.document_type == "lcd"
|
|
990
|
+
else "/v1/reports/local-coverage-articles/"
|
|
991
|
+
)
|
|
992
|
+
_, refreshed, _captured = _snapshot_version()
|
|
993
|
+
source_version = f"mcd-weekly-{refreshed}"
|
|
994
|
+
query = {}
|
|
995
|
+
if params.state:
|
|
996
|
+
state_id, state_warnings = _resolve_state_id(params.state)
|
|
997
|
+
warnings.extend(state_warnings)
|
|
998
|
+
if state_id is None:
|
|
999
|
+
return ToolResult(
|
|
1000
|
+
data=None,
|
|
1001
|
+
receipt=build_receipt(
|
|
1002
|
+
contract=CONTRACT,
|
|
1003
|
+
route="coverage.search_documents",
|
|
1004
|
+
fetch=fetch(SCHEDULE_URL),
|
|
1005
|
+
source_version=source_version,
|
|
1006
|
+
transform_version=TRANSFORM_VERSION,
|
|
1007
|
+
warnings=warnings,
|
|
1008
|
+
non_claims=NON_CLAIMS,
|
|
1009
|
+
),
|
|
1010
|
+
)
|
|
1011
|
+
query["state_id"] = state_id
|
|
1012
|
+
if params.contractor_id:
|
|
1013
|
+
query["contractor_id"] = params.contractor_id
|
|
1014
|
+
if params.status:
|
|
1015
|
+
query["status"] = params.status
|
|
1016
|
+
|
|
1017
|
+
report_fetch, report = fetch_json(url, params=query or None)
|
|
1018
|
+
rows = _rows(report)
|
|
1019
|
+
needle = params.keyword.lower()
|
|
1020
|
+
matches = [r for r in rows if needle in str(r.get("title", "")).lower()]
|
|
1021
|
+
if len(matches) > _SEARCH_MATCH_CAP:
|
|
1022
|
+
warnings.append(
|
|
1023
|
+
f"{len(matches)} titles matched; returning the first {_SEARCH_MATCH_CAP}. "
|
|
1024
|
+
"Narrow the keyword or add filters."
|
|
1025
|
+
)
|
|
1026
|
+
matches = matches[:_SEARCH_MATCH_CAP]
|
|
1027
|
+
# After the cap: only fields that would actually have been emitted are
|
|
1028
|
+
# reported as quarantined.
|
|
1029
|
+
matches = _quarantine_rows(matches, allowed, endpoint, warnings)
|
|
1030
|
+
if not matches:
|
|
1031
|
+
# The keyword is NOT echoed. This warning is persisted into the evidence
|
|
1032
|
+
# receipt, which is the one artifact the product tells users to keep --
|
|
1033
|
+
# round-1 finding C2 made it a PHI sink. Length is enough for an
|
|
1034
|
+
# operator to recognise which call this was without quoting anything.
|
|
1035
|
+
warnings.append(
|
|
1036
|
+
f"No {params.document_type} titles matched the supplied keyword "
|
|
1037
|
+
f"({len(params.keyword)} characters). Title search only: the MCD full-text "
|
|
1038
|
+
"search surface is not part of this adapter."
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
return ToolResult(
|
|
1042
|
+
data={
|
|
1043
|
+
"matches": matches,
|
|
1044
|
+
"match_count": len(matches),
|
|
1045
|
+
"documents_searched": len(rows),
|
|
1046
|
+
},
|
|
1047
|
+
receipt=build_receipt(
|
|
1048
|
+
contract=CONTRACT,
|
|
1049
|
+
route="coverage.search_documents",
|
|
1050
|
+
fetch=report_fetch,
|
|
1051
|
+
source_version=source_version,
|
|
1052
|
+
transform_version=TRANSFORM_VERSION,
|
|
1053
|
+
warnings=warnings,
|
|
1054
|
+
non_claims=NON_CLAIMS,
|
|
1055
|
+
),
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _whats_changed(params: WhatsChangedParams) -> ToolResult:
|
|
1060
|
+
warnings: list[str] = []
|
|
1061
|
+
today = date.today()
|
|
1062
|
+
window_start = today - timedelta(days=params.days_back - 1)
|
|
1063
|
+
window_end = today + timedelta(days=1) # [start, end) semantics upstream
|
|
1064
|
+
|
|
1065
|
+
if params.scope == "national":
|
|
1066
|
+
query: dict[str, Any] = {"timeframe": params.days_back}
|
|
1067
|
+
if params.document_type:
|
|
1068
|
+
query["document_type"] = params.document_type
|
|
1069
|
+
result, payload = fetch_json(WHATS_NEW_NATIONAL_URL, params=query)
|
|
1070
|
+
raw_changes = _rows(payload)
|
|
1071
|
+
allowed = WHATS_NEW_NATIONAL_FIELDS
|
|
1072
|
+
endpoint = "/v1/reports/whats-new/national/"
|
|
1073
|
+
source_version = "whats-new national feed (event-driven)"
|
|
1074
|
+
else:
|
|
1075
|
+
allowed = LOCAL_REPORT_FIELDS
|
|
1076
|
+
endpoint = "/v1/reports/whats-new/local/"
|
|
1077
|
+
_, refreshed, _captured = _snapshot_version()
|
|
1078
|
+
source_version = f"mcd-weekly-{refreshed}"
|
|
1079
|
+
raw_changes = []
|
|
1080
|
+
seen: set[tuple] = set()
|
|
1081
|
+
result = None
|
|
1082
|
+
cursor = window_start
|
|
1083
|
+
window_count = 0
|
|
1084
|
+
while cursor < window_end:
|
|
1085
|
+
chunk_end = min(cursor + timedelta(days=7), window_end)
|
|
1086
|
+
query = {
|
|
1087
|
+
"start_date": cursor.strftime("%Y%m%d"),
|
|
1088
|
+
"end_date": chunk_end.strftime("%Y%m%d"),
|
|
1089
|
+
}
|
|
1090
|
+
if params.contractor_id:
|
|
1091
|
+
query["contractor_id"] = params.contractor_id
|
|
1092
|
+
result, payload = fetch_json(WHATS_NEW_LOCAL_URL, params=query)
|
|
1093
|
+
window_count += 1
|
|
1094
|
+
for row in _rows(payload):
|
|
1095
|
+
key = (
|
|
1096
|
+
row.get("document_id"),
|
|
1097
|
+
row.get("document_version"),
|
|
1098
|
+
row.get("document_type"),
|
|
1099
|
+
row.get("updated_on_sort"),
|
|
1100
|
+
)
|
|
1101
|
+
if key not in seen:
|
|
1102
|
+
seen.add(key)
|
|
1103
|
+
raw_changes.append(row)
|
|
1104
|
+
cursor = chunk_end
|
|
1105
|
+
if window_count > 1:
|
|
1106
|
+
warnings.append(
|
|
1107
|
+
f"Aggregated {window_count} weekly windows (the upstream feed caps each "
|
|
1108
|
+
"request at one week); receipt provenance covers the final window's fetch."
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
assert result is not None
|
|
1112
|
+
changes = _quarantine_rows(raw_changes, allowed, endpoint, warnings)
|
|
1113
|
+
return ToolResult(
|
|
1114
|
+
data={
|
|
1115
|
+
"scope": params.scope,
|
|
1116
|
+
"window_start": window_start.isoformat(),
|
|
1117
|
+
"window_end": today.isoformat(),
|
|
1118
|
+
"changes": changes,
|
|
1119
|
+
"change_count": len(changes),
|
|
1120
|
+
},
|
|
1121
|
+
receipt=build_receipt(
|
|
1122
|
+
contract=CONTRACT,
|
|
1123
|
+
route="coverage.whats_changed",
|
|
1124
|
+
fetch=result,
|
|
1125
|
+
source_version=source_version,
|
|
1126
|
+
transform_version=TRANSFORM_VERSION,
|
|
1127
|
+
effective_from=window_start,
|
|
1128
|
+
effective_to=today,
|
|
1129
|
+
warnings=warnings,
|
|
1130
|
+
non_claims=NON_CLAIMS,
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
# --------------------------------------------------------------------------
|
|
1136
|
+
# canaries
|
|
1137
|
+
# --------------------------------------------------------------------------
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _observe_weekly_snapshot() -> CanaryObservation:
|
|
1141
|
+
result, payload = fetch_json(SCHEDULE_URL)
|
|
1142
|
+
rows = _rows(payload)
|
|
1143
|
+
if len(rows) != 1:
|
|
1144
|
+
raise SourceUnreachable(SCHEDULE_URL, "local-data-schedule returned unexpected shape")
|
|
1145
|
+
refreshed = str(rows[0].get("refreshed_on_date", ""))
|
|
1146
|
+
captured = str(rows[0].get("data_captured_through", ""))
|
|
1147
|
+
refreshed_date = _parse_yyyymmdd(refreshed)
|
|
1148
|
+
captured_date = _parse_yyyymmdd(captured)
|
|
1149
|
+
if refreshed_date is None or captured_date is None or not captured_date < refreshed_date:
|
|
1150
|
+
raise SourceUnreachable(
|
|
1151
|
+
SCHEDULE_URL, "local-data-schedule dates malformed or out of order"
|
|
1152
|
+
)
|
|
1153
|
+
note = f"captured through {captured}"
|
|
1154
|
+
age = (date.today() - refreshed_date).days
|
|
1155
|
+
# Nine days is one weekly cycle plus two days of slack for a skipped
|
|
1156
|
+
# Thursday. Past that, "the refresh id still matches the pin" stops meaning
|
|
1157
|
+
# "nothing changed upstream" and starts meaning "the thing we watch has
|
|
1158
|
+
# stopped moving" -- which is not evidence of anything, so doctor grades it
|
|
1159
|
+
# STALE rather than OK.
|
|
1160
|
+
stale = age > 9
|
|
1161
|
+
if stale:
|
|
1162
|
+
note += f" (STALE: refresh is {age} days old; CMS occasionally skips a Thursday)"
|
|
1163
|
+
return CanaryObservation(
|
|
1164
|
+
value=refreshed,
|
|
1165
|
+
schema_hash=_canonical_hash(_fields(payload)),
|
|
1166
|
+
upstream_status=result.status,
|
|
1167
|
+
note=note,
|
|
1168
|
+
stale=stale,
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _weekly_snapshot_remediation(status: CanaryStatus, observed, expected) -> str:
|
|
1173
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1174
|
+
return (
|
|
1175
|
+
"The MCD local-data-schedule endpoint could not be read or returned a "
|
|
1176
|
+
"malformed body. Check https://www.cms.gov/medicare-coverage-database/ for "
|
|
1177
|
+
"outage banners, then re-run `hc-source doctor`."
|
|
1178
|
+
)
|
|
1179
|
+
return (
|
|
1180
|
+
f"CMS published a new weekly MCD snapshot (refreshed {observed}; source-lock.json "
|
|
1181
|
+
f"pins {expected}). Local coverage data moved with it: review whats-new "
|
|
1182
|
+
"(`hc-source call coverage.whats_changed --param scope=local`), then re-pin with "
|
|
1183
|
+
"`hc-source lock init`."
|
|
1184
|
+
)
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _observe_api_contract() -> CanaryObservation:
|
|
1188
|
+
result, payload = fetch_json(SPEC_URL)
|
|
1189
|
+
paths = payload.get("paths")
|
|
1190
|
+
if not isinstance(paths, dict):
|
|
1191
|
+
raise SourceUnreachable(SPEC_URL, "OpenAPI spec had no paths object")
|
|
1192
|
+
openapi = str(payload.get("openapi", "?"))
|
|
1193
|
+
title = str((payload.get("info") or {}).get("title", "?"))
|
|
1194
|
+
info_version = str((payload.get("info") or {}).get("version", "?"))
|
|
1195
|
+
missing = sorted(p for p in REQUIRED_PATHS if p not in paths)
|
|
1196
|
+
if missing:
|
|
1197
|
+
value = f"openapi={openapi};title={title};missing={','.join(missing)}"
|
|
1198
|
+
else:
|
|
1199
|
+
value = f"openapi={openapi};title={title};required-paths=ok"
|
|
1200
|
+
return CanaryObservation(
|
|
1201
|
+
value=value,
|
|
1202
|
+
schema_hash=_canonical_hash(sorted(p for p in REQUIRED_PATHS if p in paths)),
|
|
1203
|
+
upstream_status=result.status,
|
|
1204
|
+
note=(
|
|
1205
|
+
f"spec v{info_version}, {len(paths)} total paths (policy: additions upstream "
|
|
1206
|
+
"are informational; only removal of a load-bearing path alarms)"
|
|
1207
|
+
),
|
|
1208
|
+
)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def _api_contract_remediation(status: CanaryStatus, observed, expected) -> str:
|
|
1212
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1213
|
+
return (
|
|
1214
|
+
"The Coverage API OpenAPI spec could not be read. No path was observed to "
|
|
1215
|
+
"have moved -- the spec document itself was unavailable. Check "
|
|
1216
|
+
"https://api.coverage.cms.gov/docs/v1/coverage-api.json by hand, then "
|
|
1217
|
+
"re-run `hc-source doctor`."
|
|
1218
|
+
)
|
|
1219
|
+
return (
|
|
1220
|
+
"The CMS Coverage API OpenAPI spec changed in a way that touches a path this "
|
|
1221
|
+
f"adapter calls (observed {observed!r}, pinned {expected!r}). Read "
|
|
1222
|
+
"https://api.coverage.cms.gov/docs/release_notes, re-verify the adapter's "
|
|
1223
|
+
"endpoints against https://api.coverage.cms.gov/docs/v1/coverage-api.json, then "
|
|
1224
|
+
"re-pin with `hc-source lock init`."
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
def _observe_ncd_anchor() -> CanaryObservation:
|
|
1229
|
+
result = fetch(NCD_DETAIL_URL, params={"ncdid": 11}, raise_for_status=False)
|
|
1230
|
+
# A 5xx must not be pinned or graded as an id-scheme break: the remediation
|
|
1231
|
+
# for that sends a human to rebuild the section->ncdid mapping.
|
|
1232
|
+
_raise_if_transient(result, NCD_DETAIL_URL)
|
|
1233
|
+
if result.status != 200:
|
|
1234
|
+
return CanaryObservation(
|
|
1235
|
+
value=f"invalid-anchor-http-{result.status}",
|
|
1236
|
+
upstream_status=result.status,
|
|
1237
|
+
note="ncdid=11 no longer resolves; the internal document-id scheme may have moved",
|
|
1238
|
+
)
|
|
1239
|
+
payload = result.json()
|
|
1240
|
+
rows = _rows(payload)
|
|
1241
|
+
if not rows:
|
|
1242
|
+
raise SourceUnreachable(NCD_DETAIL_URL, "ncd detail returned no rows for ncdid=11")
|
|
1243
|
+
row = rows[0]
|
|
1244
|
+
value = (
|
|
1245
|
+
f"{row.get('document_display_id')}|{row.get('title')}|{row.get('publication_number')}"
|
|
1246
|
+
)
|
|
1247
|
+
return CanaryObservation(
|
|
1248
|
+
value=value,
|
|
1249
|
+
schema_hash=_canonical_hash(_fields(payload)),
|
|
1250
|
+
upstream_status=result.status,
|
|
1251
|
+
note=(
|
|
1252
|
+
f"document_version {row.get('document_version')} (version bumps are routine "
|
|
1253
|
+
"policy updates and deliberately excluded from the pinned value)"
|
|
1254
|
+
),
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _ncd_anchor_remediation(status: CanaryStatus, observed, expected) -> str:
|
|
1259
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1260
|
+
return (
|
|
1261
|
+
"The NCD detail endpoint could not be read. The anchor record was not "
|
|
1262
|
+
"observed this run, so nothing is known about the internal document-id "
|
|
1263
|
+
"scheme: do not rebuild the mapping on the strength of this. Check "
|
|
1264
|
+
"https://api.coverage.cms.gov/docs/release_notes for an outage or a "
|
|
1265
|
+
"planned change, then re-run `hc-source doctor`."
|
|
1266
|
+
)
|
|
1267
|
+
if status is CanaryStatus.SCHEMA_CHANGED:
|
|
1268
|
+
return (
|
|
1269
|
+
"The NCD detail column set changed while the anchor record stayed put. Review "
|
|
1270
|
+
"the new fields, bump TRANSFORM_VERSION in hc_source/adapters/coverage.py if "
|
|
1271
|
+
"the mapping changed, then re-pin with `hc-source lock init`."
|
|
1272
|
+
)
|
|
1273
|
+
return (
|
|
1274
|
+
f"NCD anchor check failed for ncdid=11 (expected NCD 30.3 'Acupuncture', pub "
|
|
1275
|
+
f"100-3; observed {observed!r}). If the id no longer resolves, CMS changed its "
|
|
1276
|
+
"internal document ids: rebuild the section->ncdid mapping from "
|
|
1277
|
+
"/v1/reports/national-coverage-ncd/, re-record the coverage fixtures, and re-pin "
|
|
1278
|
+
"with `hc-source lock init`."
|
|
1279
|
+
)
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
_CONTENT_RANGE_RE = re.compile(r"^bytes \d+-\d+/(\d+)$")
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
def _observe_bulk_export() -> CanaryObservation:
|
|
1286
|
+
result = fetch(NCD_ZIP_URL, headers={"Range": "bytes=0-0"})
|
|
1287
|
+
content_type = result.headers.get("content-type", "?").split(";")[0].strip()
|
|
1288
|
+
size: int | None = None
|
|
1289
|
+
match = _CONTENT_RANGE_RE.match(result.headers.get("content-range", ""))
|
|
1290
|
+
if match:
|
|
1291
|
+
size = int(match.group(1))
|
|
1292
|
+
elif result.headers.get("content-length", "").isdigit():
|
|
1293
|
+
size = int(result.headers["content-length"])
|
|
1294
|
+
low, high = NCD_ZIP_SIZE_BAND
|
|
1295
|
+
if size is not None and low <= size <= high:
|
|
1296
|
+
value = f"{content_type};size-band=ok"
|
|
1297
|
+
else:
|
|
1298
|
+
value = f"{content_type};size={size}"
|
|
1299
|
+
return CanaryObservation(
|
|
1300
|
+
value=value,
|
|
1301
|
+
upstream_status=result.status,
|
|
1302
|
+
note=(
|
|
1303
|
+
f"{size} bytes, last-modified {result.headers.get('last-modified', '?')} "
|
|
1304
|
+
f"(1-byte ranged probe; band {low}-{high})"
|
|
1305
|
+
),
|
|
1306
|
+
)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _bulk_export_remediation(status: CanaryStatus, observed, expected) -> str:
|
|
1310
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1311
|
+
return (
|
|
1312
|
+
"The MCD bulk export ncd.zip could not be probed. Re-derive the current "
|
|
1313
|
+
"export links from "
|
|
1314
|
+
"https://www.cms.gov/medicare-coverage-database/downloads/downloads.aspx and "
|
|
1315
|
+
"update the URL table in hc_source/adapters/coverage.py if they moved."
|
|
1316
|
+
)
|
|
1317
|
+
return (
|
|
1318
|
+
f"The MCD bulk export probe changed (observed {observed!r}, pinned {expected!r}). "
|
|
1319
|
+
"If the size left its band, the dataset shape changed: re-download, re-verify "
|
|
1320
|
+
"the CSV inventory, widen NCD_ZIP_SIZE_BAND deliberately, and re-pin with "
|
|
1321
|
+
"`hc-source lock init`."
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _observe_license_gate() -> CanaryObservation:
|
|
1326
|
+
# Deliberately unauthenticated: this canary asserts the AMA/ADA/AHA gate is
|
|
1327
|
+
# still up. It must NEVER fetch a token (fetching one = accepting the license).
|
|
1328
|
+
result = fetch(LCD_DETAIL_URL, params={"lcdid": 33818}, raise_for_status=False)
|
|
1329
|
+
# Transient statuses are unreachability, not a change of posture. Grading a
|
|
1330
|
+
# 503 as drift here fires the remediation below, which tells a human to STOP
|
|
1331
|
+
# and open a CPT-contamination review; the gate never moved.
|
|
1332
|
+
_raise_if_transient(result, LCD_DETAIL_URL)
|
|
1333
|
+
return CanaryObservation(
|
|
1334
|
+
value=str(result.status),
|
|
1335
|
+
upstream_status=result.status,
|
|
1336
|
+
note="unauthenticated LCD detail probe; 401 means the license gate is intact",
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _license_gate_remediation(status: CanaryStatus, observed, expected) -> str:
|
|
1341
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1342
|
+
# The drift text below tells a human to STOP and open a CPT-contamination
|
|
1343
|
+
# review. It must never be printed for an outage: nothing was observed
|
|
1344
|
+
# about the gate's posture this run.
|
|
1345
|
+
return (
|
|
1346
|
+
"The unauthenticated LCD detail probe could not be read (transient upstream "
|
|
1347
|
+
"failure or transport error). This says nothing about the AMA/ADA/AHA "
|
|
1348
|
+
"license gate and is NOT a reason to open a contamination review. Check "
|
|
1349
|
+
"https://www.cms.gov/medicare-coverage-database/ for outage banners, then "
|
|
1350
|
+
"re-run `hc-source doctor`."
|
|
1351
|
+
)
|
|
1352
|
+
return (
|
|
1353
|
+
f"The Coverage API license gate changed posture (unauthenticated LCD call "
|
|
1354
|
+
f"returned HTTP {observed}, pinned {expected}). If it now returns 200, CMS "
|
|
1355
|
+
"dropped the AMA/ADA/AHA token gate: STOP and re-run the CPT-contamination "
|
|
1356
|
+
"review in the coverage dossier (section 6) before exposing or redistributing "
|
|
1357
|
+
"any newly reachable field, then re-pin with `hc-source lock init`."
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def _observe_reference_tables() -> CanaryObservation:
|
|
1362
|
+
result_states, states_payload = fetch_json(STATES_URL, params={"state_id": 6})
|
|
1363
|
+
state_rows = _rows(states_payload)
|
|
1364
|
+
state6 = next((r for r in state_rows if r.get("state_id") == 6), None)
|
|
1365
|
+
state_desc = state6.get("description") if state6 else "absent"
|
|
1366
|
+
_result_types, types_payload = fetch_json(CONTRACT_TYPES_URL)
|
|
1367
|
+
type_ids = sorted(
|
|
1368
|
+
int(r["contract_type_id"])
|
|
1369
|
+
for r in _rows(types_payload)
|
|
1370
|
+
if r.get("contract_type_id") is not None
|
|
1371
|
+
)
|
|
1372
|
+
return CanaryObservation(
|
|
1373
|
+
value=(
|
|
1374
|
+
f"state6={state_desc};contract_types={','.join(str(i) for i in type_ids)}"
|
|
1375
|
+
),
|
|
1376
|
+
schema_hash=_canonical_hash([_fields(states_payload), _fields(types_payload)]),
|
|
1377
|
+
upstream_status=result_states.status,
|
|
1378
|
+
note="MCD-internal keyspaces backing the jurisdiction joins (state_id is not FIPS)",
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _reference_tables_remediation(status: CanaryStatus, observed, expected) -> str:
|
|
1383
|
+
if status is CanaryStatus.UNREACHABLE:
|
|
1384
|
+
return (
|
|
1385
|
+
"The MCD reference-table endpoints could not be read, so the keyspaces were "
|
|
1386
|
+
"not observed this run. Nothing moved as far as anyone knows: do not "
|
|
1387
|
+
"re-record fixtures on the strength of an outage. Check "
|
|
1388
|
+
"https://www.cms.gov/medicare-coverage-database/ for outage banners, then "
|
|
1389
|
+
"re-run `hc-source doctor`."
|
|
1390
|
+
)
|
|
1391
|
+
return _REFERENCE_TABLES_REMEDIATION
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
_REFERENCE_TABLES_REMEDIATION = (
|
|
1395
|
+
"The MCD reference tables moved (states/contract-types). The jurisdiction-mapping "
|
|
1396
|
+
"keyspace changed and stored contractor->state joins may be silently wrong: "
|
|
1397
|
+
"re-record tests/fixtures/coverage/states.json and contract_types.json from "
|
|
1398
|
+
"/v1/metadata/states/ and /v1/metadata/contract-type/, re-validate STATE_NAMES "
|
|
1399
|
+
"resolution in hc_source/adapters/coverage.py, then re-pin with `hc-source lock init`."
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
class CoverageAdapter(SourceAdapter):
|
|
1404
|
+
source_id = SOURCE_ID
|
|
1405
|
+
contract = CONTRACT
|
|
1406
|
+
|
|
1407
|
+
# These canaries read a live CMS service, so a single 502 is not news --
|
|
1408
|
+
# it is Tuesday. Two retries with exponential backoff, applied only to
|
|
1409
|
+
# transient failures (see hc_source.doctor._observe). A gate that goes red
|
|
1410
|
+
# on somebody else's bad afternoon gets uninstalled.
|
|
1411
|
+
canary_retries = 2
|
|
1412
|
+
|
|
1413
|
+
def canaries(self) -> list[Canary]:
|
|
1414
|
+
return [
|
|
1415
|
+
Canary(
|
|
1416
|
+
canary_id="coverage.weekly_snapshot",
|
|
1417
|
+
source_id=SOURCE_ID,
|
|
1418
|
+
description=(
|
|
1419
|
+
"Weekly MCD local-coverage snapshot identity "
|
|
1420
|
+
"(refreshed_on_date from /v1/metadata/local-data-schedule/)."
|
|
1421
|
+
),
|
|
1422
|
+
observe=_observe_weekly_snapshot,
|
|
1423
|
+
remediation=(
|
|
1424
|
+
"CMS published a new weekly MCD snapshot. Review the diff, then "
|
|
1425
|
+
"re-pin with `hc-source lock init`."
|
|
1426
|
+
),
|
|
1427
|
+
remediation_for=_weekly_snapshot_remediation,
|
|
1428
|
+
),
|
|
1429
|
+
Canary(
|
|
1430
|
+
canary_id="coverage.api_contract",
|
|
1431
|
+
source_id=SOURCE_ID,
|
|
1432
|
+
description=(
|
|
1433
|
+
"Coverage API OpenAPI contract: every load-bearing path stays "
|
|
1434
|
+
"served (additive growth upstream is informational, never an alarm)."
|
|
1435
|
+
),
|
|
1436
|
+
observe=_observe_api_contract,
|
|
1437
|
+
remediation=(
|
|
1438
|
+
"The Coverage API spec dropped a path this adapter calls. Read "
|
|
1439
|
+
"https://api.coverage.cms.gov/docs/release_notes and re-verify the "
|
|
1440
|
+
"adapter, then re-pin with `hc-source lock init`."
|
|
1441
|
+
),
|
|
1442
|
+
remediation_for=_api_contract_remediation,
|
|
1443
|
+
),
|
|
1444
|
+
Canary(
|
|
1445
|
+
canary_id="coverage.ncd_anchor",
|
|
1446
|
+
source_id=SOURCE_ID,
|
|
1447
|
+
description=(
|
|
1448
|
+
"NCD anchor-record integrity: ncdid=11 must still be NCD 30.3 "
|
|
1449
|
+
"'Acupuncture' (internal-id scheme stability)."
|
|
1450
|
+
),
|
|
1451
|
+
observe=_observe_ncd_anchor,
|
|
1452
|
+
remediation=(
|
|
1453
|
+
"The NCD anchor moved. Rebuild the section->ncdid mapping from "
|
|
1454
|
+
"/v1/reports/national-coverage-ncd/ and re-pin with "
|
|
1455
|
+
"`hc-source lock init`."
|
|
1456
|
+
),
|
|
1457
|
+
remediation_for=_ncd_anchor_remediation,
|
|
1458
|
+
),
|
|
1459
|
+
Canary(
|
|
1460
|
+
canary_id="coverage.bulk_export",
|
|
1461
|
+
source_id=SOURCE_ID,
|
|
1462
|
+
description=(
|
|
1463
|
+
"MCD bulk export availability: 1-byte ranged probe of ncd.zip "
|
|
1464
|
+
"(content type + size band; no full download)."
|
|
1465
|
+
),
|
|
1466
|
+
observe=_observe_bulk_export,
|
|
1467
|
+
remediation=(
|
|
1468
|
+
"The MCD bulk export probe failed. Re-derive export URLs from the "
|
|
1469
|
+
"MCD downloads page and re-pin with `hc-source lock init`."
|
|
1470
|
+
),
|
|
1471
|
+
remediation_for=_bulk_export_remediation,
|
|
1472
|
+
),
|
|
1473
|
+
Canary(
|
|
1474
|
+
canary_id="coverage.license_gate",
|
|
1475
|
+
source_id=SOURCE_ID,
|
|
1476
|
+
description=(
|
|
1477
|
+
"AMA/ADA/AHA license-gate posture: an unauthenticated LCD detail "
|
|
1478
|
+
"call must return HTTP 401. Never mints a token."
|
|
1479
|
+
),
|
|
1480
|
+
observe=_observe_license_gate,
|
|
1481
|
+
remediation=(
|
|
1482
|
+
"The Coverage API license gate changed posture. Re-run the "
|
|
1483
|
+
"CPT-contamination review before shipping any behavior change, then "
|
|
1484
|
+
"re-pin with `hc-source lock init`."
|
|
1485
|
+
),
|
|
1486
|
+
remediation_for=_license_gate_remediation,
|
|
1487
|
+
),
|
|
1488
|
+
Canary(
|
|
1489
|
+
canary_id="coverage.reference_tables",
|
|
1490
|
+
source_id=SOURCE_ID,
|
|
1491
|
+
description=(
|
|
1492
|
+
"MCD-internal reference keyspaces: state 6 stays 'California - "
|
|
1493
|
+
"Entire State' and contract types stay exactly ids 8-13."
|
|
1494
|
+
),
|
|
1495
|
+
observe=_observe_reference_tables,
|
|
1496
|
+
remediation=_REFERENCE_TABLES_REMEDIATION,
|
|
1497
|
+
remediation_for=_reference_tables_remediation,
|
|
1498
|
+
),
|
|
1499
|
+
]
|
|
1500
|
+
|
|
1501
|
+
def tools(self) -> list[ToolSpec]:
|
|
1502
|
+
return [
|
|
1503
|
+
ToolSpec(
|
|
1504
|
+
name="coverage.snapshot",
|
|
1505
|
+
description=(
|
|
1506
|
+
"Return the current weekly MCD local-coverage snapshot identity "
|
|
1507
|
+
"(data_captured_through / refreshed_on_date)."
|
|
1508
|
+
),
|
|
1509
|
+
params_model=NoParams,
|
|
1510
|
+
handler=_snapshot,
|
|
1511
|
+
tags=("metadata",),
|
|
1512
|
+
),
|
|
1513
|
+
ToolSpec(
|
|
1514
|
+
name="coverage.lookup_ncd",
|
|
1515
|
+
description=(
|
|
1516
|
+
"Look up a National Coverage Determination by manual section "
|
|
1517
|
+
"number (e.g. 30.3); narrative is withheld when the NCD flags "
|
|
1518
|
+
"licensed AMA CPT content."
|
|
1519
|
+
),
|
|
1520
|
+
params_model=LookupNcdParams,
|
|
1521
|
+
handler=_lookup_ncd,
|
|
1522
|
+
tags=("lookup",),
|
|
1523
|
+
),
|
|
1524
|
+
ToolSpec(
|
|
1525
|
+
name="coverage.lookup_lcd",
|
|
1526
|
+
description=(
|
|
1527
|
+
"Look up a Local Coverage Determination by L-number in the current "
|
|
1528
|
+
"weekly snapshot, jurisdiction-aware via an optional state filter."
|
|
1529
|
+
),
|
|
1530
|
+
params_model=LookupLcdParams,
|
|
1531
|
+
handler=_lookup_lcd,
|
|
1532
|
+
tags=("lookup",),
|
|
1533
|
+
),
|
|
1534
|
+
ToolSpec(
|
|
1535
|
+
name="coverage.companion_articles",
|
|
1536
|
+
description=(
|
|
1537
|
+
"Resolve an LCD's companion Billing-and-Coding Article(s) via the "
|
|
1538
|
+
"LCD<->Article crosswalk (keyless bulk export, or the live endpoint "
|
|
1539
|
+
"under an operator-supplied license token)."
|
|
1540
|
+
),
|
|
1541
|
+
params_model=CompanionArticlesParams,
|
|
1542
|
+
handler=_companion_articles,
|
|
1543
|
+
tags=("lookup",),
|
|
1544
|
+
),
|
|
1545
|
+
ToolSpec(
|
|
1546
|
+
name="coverage.search_documents",
|
|
1547
|
+
description=(
|
|
1548
|
+
"Search NCD/LCD/Article titles by keyword in the current release, "
|
|
1549
|
+
"with state/contractor/status filters for local documents."
|
|
1550
|
+
),
|
|
1551
|
+
params_model=SearchDocumentsParams,
|
|
1552
|
+
handler=_search_documents,
|
|
1553
|
+
tags=("search",),
|
|
1554
|
+
),
|
|
1555
|
+
ToolSpec(
|
|
1556
|
+
name="coverage.whats_changed",
|
|
1557
|
+
description=(
|
|
1558
|
+
"What changed in Medicare coverage policy over the last N days, "
|
|
1559
|
+
"from the MCD whats-new feeds (local weekly snapshot or national "
|
|
1560
|
+
"event stream)."
|
|
1561
|
+
),
|
|
1562
|
+
params_model=WhatsChangedParams,
|
|
1563
|
+
handler=_whats_changed,
|
|
1564
|
+
tags=("changes",),
|
|
1565
|
+
),
|
|
1566
|
+
]
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
ADAPTER = CoverageAdapter()
|