cms-icd 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.
- cms_icd/__init__.py +62 -0
- cms_icd/constants.py +4 -0
- cms_icd/exceptions.py +27 -0
- cms_icd/gems.py +330 -0
- cms_icd/guidelines.py +216 -0
- cms_icd/knowledge_base.py +474 -0
- cms_icd/models.py +217 -0
- cms_icd/parsers.py +555 -0
- cms_icd/sources.py +628 -0
- cms_icd/stores.py +328 -0
- cms_icd-0.1.0.dist-info/METADATA +203 -0
- cms_icd-0.1.0.dist-info/RECORD +16 -0
- cms_icd-0.1.0.dist-info/WHEEL +5 -0
- cms_icd-0.1.0.dist-info/licenses/LICENSE +202 -0
- cms_icd-0.1.0.dist-info/licenses/NOTICE +5 -0
- cms_icd-0.1.0.dist-info/top_level.txt +1 -0
cms_icd/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Version-aware access to official CMS ICD-10 materials.
|
|
2
|
+
|
|
3
|
+
The package separates release acquisition from parsing and keeps CM and PCS materials
|
|
4
|
+
independently lazy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .constants import ICD10_PCS_CHARACTERS
|
|
8
|
+
from .exceptions import (
|
|
9
|
+
AmbiguousReleaseError,
|
|
10
|
+
DownloadError,
|
|
11
|
+
ICDKnowledgeBaseError,
|
|
12
|
+
MaterialUnavailableError,
|
|
13
|
+
ParseError,
|
|
14
|
+
ReleaseUnavailableError,
|
|
15
|
+
)
|
|
16
|
+
from .gems import GEMKnowledgeBase, GEMSystemView
|
|
17
|
+
from .knowledge_base import (
|
|
18
|
+
ICD10CMKnowledgeBase,
|
|
19
|
+
ICD10KnowledgeBase,
|
|
20
|
+
ICD10PCSKnowledgeBase,
|
|
21
|
+
)
|
|
22
|
+
from .models import (
|
|
23
|
+
Code,
|
|
24
|
+
GEMChoiceList,
|
|
25
|
+
GEMDirection,
|
|
26
|
+
GEMEntry,
|
|
27
|
+
GEMMapping,
|
|
28
|
+
GEMProvenance,
|
|
29
|
+
GEMScenario,
|
|
30
|
+
Guideline,
|
|
31
|
+
InstructionalNote,
|
|
32
|
+
Release,
|
|
33
|
+
Term,
|
|
34
|
+
)
|
|
35
|
+
from .stores import GEMStore
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ICD10_PCS_CHARACTERS",
|
|
39
|
+
"AmbiguousReleaseError",
|
|
40
|
+
"Code",
|
|
41
|
+
"DownloadError",
|
|
42
|
+
"GEMChoiceList",
|
|
43
|
+
"GEMDirection",
|
|
44
|
+
"GEMEntry",
|
|
45
|
+
"GEMKnowledgeBase",
|
|
46
|
+
"GEMMapping",
|
|
47
|
+
"GEMProvenance",
|
|
48
|
+
"GEMScenario",
|
|
49
|
+
"GEMStore",
|
|
50
|
+
"GEMSystemView",
|
|
51
|
+
"Guideline",
|
|
52
|
+
"ICD10CMKnowledgeBase",
|
|
53
|
+
"ICD10KnowledgeBase",
|
|
54
|
+
"ICD10PCSKnowledgeBase",
|
|
55
|
+
"ICDKnowledgeBaseError",
|
|
56
|
+
"InstructionalNote",
|
|
57
|
+
"MaterialUnavailableError",
|
|
58
|
+
"ParseError",
|
|
59
|
+
"Release",
|
|
60
|
+
"ReleaseUnavailableError",
|
|
61
|
+
"Term",
|
|
62
|
+
]
|
cms_icd/constants.py
ADDED
cms_icd/exceptions.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Exceptions raised by :mod:`cms_icd`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ICDKnowledgeBaseError(RuntimeError):
|
|
7
|
+
"""Base exception for expected CMS ICD knowledge-base failures."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ReleaseUnavailableError(ICDKnowledgeBaseError):
|
|
11
|
+
"""Raised when the requested CMS release cannot be resolved."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AmbiguousReleaseError(ICDKnowledgeBaseError):
|
|
15
|
+
"""Raised when multiple CMS artifacts match one material selection."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MaterialUnavailableError(ICDKnowledgeBaseError):
|
|
19
|
+
"""Raised when a release does not provide a requested material."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DownloadError(ICDKnowledgeBaseError):
|
|
23
|
+
"""Raised when an official CMS artifact cannot be downloaded or validated."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ParseError(ICDKnowledgeBaseError):
|
|
27
|
+
"""Raised when a CMS material does not match the expected structure."""
|
cms_icd/gems.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Lazy access to official CMS General Equivalence Mappings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date
|
|
6
|
+
from itertools import pairwise
|
|
7
|
+
from threading import Lock
|
|
8
|
+
from typing import TYPE_CHECKING, Self
|
|
9
|
+
|
|
10
|
+
from .models import GEMDirection, GEMProvenance, Release
|
|
11
|
+
from .parsers import parse_gems
|
|
12
|
+
from .sources import CMSProvider, DirectoryProvider, MaterialProvider
|
|
13
|
+
from .stores import GEMStore
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GEMSystemView:
|
|
20
|
+
"""Lazy bidirectional GEMs for one ICD-10 system (CM or PCS)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
provider: MaterialProvider | None,
|
|
25
|
+
system: str,
|
|
26
|
+
*,
|
|
27
|
+
icd9_to_icd10: GEMStore | None = None,
|
|
28
|
+
icd10_to_icd9: GEMStore | None = None,
|
|
29
|
+
correction_providers: tuple[MaterialProvider, ...] = (),
|
|
30
|
+
) -> None:
|
|
31
|
+
if system not in {"cm", "pcs"}:
|
|
32
|
+
raise ValueError(f"Unsupported GEM system: {system!r}")
|
|
33
|
+
self._provider = provider
|
|
34
|
+
self._correction_providers = correction_providers
|
|
35
|
+
self.system = system
|
|
36
|
+
self._stores = {
|
|
37
|
+
GEMDirection.ICD9_TO_ICD10: icd9_to_icd10,
|
|
38
|
+
GEMDirection.ICD10_TO_ICD9: icd10_to_icd9,
|
|
39
|
+
}
|
|
40
|
+
self._locks = {direction: Lock() for direction in GEMDirection}
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_stores(
|
|
44
|
+
cls,
|
|
45
|
+
system: str,
|
|
46
|
+
*,
|
|
47
|
+
icd9_to_icd10: GEMStore | None = None,
|
|
48
|
+
icd10_to_icd9: GEMStore | None = None,
|
|
49
|
+
) -> Self:
|
|
50
|
+
"""Construct a view from prebuilt stores for custom sources or tests."""
|
|
51
|
+
return cls(
|
|
52
|
+
None,
|
|
53
|
+
system,
|
|
54
|
+
icd9_to_icd10=icd9_to_icd10,
|
|
55
|
+
icd10_to_icd9=icd10_to_icd9,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def release(self) -> Release | None:
|
|
60
|
+
"""Return release metadata, if this view is provider-backed."""
|
|
61
|
+
return self._provider.release if self._provider is not None else None
|
|
62
|
+
|
|
63
|
+
def _load(self, direction: GEMDirection) -> GEMStore:
|
|
64
|
+
store = self._stores[direction]
|
|
65
|
+
if store is None:
|
|
66
|
+
with self._locks[direction]:
|
|
67
|
+
store = self._stores[direction]
|
|
68
|
+
if store is None:
|
|
69
|
+
if self._provider is None:
|
|
70
|
+
raise RuntimeError(
|
|
71
|
+
f"{type(self).__name__} has no provider for unloaded GEMs"
|
|
72
|
+
)
|
|
73
|
+
store = self._load_provider(self._provider, direction)
|
|
74
|
+
if self._correction_providers:
|
|
75
|
+
stores = [store]
|
|
76
|
+
target_universes = [
|
|
77
|
+
set(
|
|
78
|
+
self._load_provider(
|
|
79
|
+
self._provider, _opposite(direction)
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
for provider in self._correction_providers:
|
|
84
|
+
stores.append(self._load_provider(provider, direction))
|
|
85
|
+
target_universes.append(
|
|
86
|
+
set(self._load_provider(provider, _opposite(direction)))
|
|
87
|
+
)
|
|
88
|
+
store = _backport_corrections(stores, target_universes)
|
|
89
|
+
self._stores[direction] = store
|
|
90
|
+
return store
|
|
91
|
+
|
|
92
|
+
def _load_provider(
|
|
93
|
+
self, provider: MaterialProvider, direction: GEMDirection
|
|
94
|
+
) -> GEMStore:
|
|
95
|
+
return parse_gems(
|
|
96
|
+
provider.paths(self.system, "gems"),
|
|
97
|
+
system=self.system,
|
|
98
|
+
direction=direction,
|
|
99
|
+
release=provider.release,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def icd9_to_icd10(self) -> GEMStore:
|
|
104
|
+
"""Return mappings from ICD-9-CM to ICD-10-CM or ICD-10-PCS."""
|
|
105
|
+
return self._load(GEMDirection.ICD9_TO_ICD10)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def icd10_to_icd9(self) -> GEMStore:
|
|
109
|
+
"""Return mappings from ICD-10-CM or ICD-10-PCS to ICD-9-CM."""
|
|
110
|
+
return self._load(GEMDirection.ICD10_TO_ICD9)
|
|
111
|
+
|
|
112
|
+
def __repr__(self) -> str:
|
|
113
|
+
"""Return a representation without loading mappings."""
|
|
114
|
+
loaded = [
|
|
115
|
+
direction.value
|
|
116
|
+
for direction, store in self._stores.items()
|
|
117
|
+
if store is not None
|
|
118
|
+
]
|
|
119
|
+
return (
|
|
120
|
+
f"GEMSystemView(system={self.system!r}, release={self.release!r}, "
|
|
121
|
+
f"loaded={loaded!r})"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class GEMKnowledgeBase:
|
|
126
|
+
"""A CMS fiscal-year GEM release with independently lazy CM and PCS views."""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
provider: MaterialProvider,
|
|
131
|
+
*,
|
|
132
|
+
correction_providers: tuple[MaterialProvider, ...] = (),
|
|
133
|
+
) -> None:
|
|
134
|
+
self._provider = provider
|
|
135
|
+
self._correction_providers = correction_providers
|
|
136
|
+
self._cm: GEMSystemView | None = None
|
|
137
|
+
self._pcs: GEMSystemView | None = None
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_cms(
|
|
141
|
+
cls,
|
|
142
|
+
fiscal_year: int,
|
|
143
|
+
*,
|
|
144
|
+
cache_dir: str | Path | None = None,
|
|
145
|
+
offline: bool = False,
|
|
146
|
+
) -> Self:
|
|
147
|
+
"""Create a lazy GEM selector for an official CMS fiscal year.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
fiscal_year: Official CMS GEM fiscal year.
|
|
151
|
+
cache_dir: Persistent artifact cache directory. ``None`` uses the
|
|
152
|
+
platform default cache directory.
|
|
153
|
+
offline: Require the catalog and artifacts to already be cached.
|
|
154
|
+
"""
|
|
155
|
+
release = Release(fiscal_year, date(fiscal_year - 1, 10, 1))
|
|
156
|
+
return cls(
|
|
157
|
+
CMSProvider(
|
|
158
|
+
release,
|
|
159
|
+
cache_dir=cache_dir,
|
|
160
|
+
offline=offline,
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def corrected_from_cms(
|
|
166
|
+
cls,
|
|
167
|
+
fiscal_year: int,
|
|
168
|
+
*,
|
|
169
|
+
corrections_through_fiscal_year: int = 2018,
|
|
170
|
+
cache_dir: str | Path | None = None,
|
|
171
|
+
offline: bool = False,
|
|
172
|
+
) -> Self:
|
|
173
|
+
"""Create GEMs using historical vocabulary and later safe corrections.
|
|
174
|
+
|
|
175
|
+
Each source remains on the requested fiscal year's vocabulary. Later complete
|
|
176
|
+
row sets are adopted only until that source encounters an introduced or retired
|
|
177
|
+
source/target code. Corrections are reviewed through FY2018 by default, the last
|
|
178
|
+
CMS GEM release.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
fiscal_year: Historical vocabulary fiscal year.
|
|
182
|
+
corrections_through_fiscal_year: Last GEM release considered for safe
|
|
183
|
+
corrections.
|
|
184
|
+
cache_dir: Persistent artifact cache directory shared by all releases.
|
|
185
|
+
``None`` uses the platform default cache directory.
|
|
186
|
+
offline: Require the catalog and artifacts to already be cached.
|
|
187
|
+
"""
|
|
188
|
+
if corrections_through_fiscal_year < fiscal_year:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"corrections_through_fiscal_year must not precede fiscal_year"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def provider(year: int) -> CMSProvider:
|
|
194
|
+
return CMSProvider(
|
|
195
|
+
Release(year, date(year - 1, 10, 1)),
|
|
196
|
+
cache_dir=cache_dir,
|
|
197
|
+
offline=offline,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return cls(
|
|
201
|
+
provider(fiscal_year),
|
|
202
|
+
correction_providers=tuple(
|
|
203
|
+
provider(year)
|
|
204
|
+
for year in range(fiscal_year + 1, corrections_through_fiscal_year + 1)
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def from_directory(
|
|
210
|
+
cls,
|
|
211
|
+
directory: str | Path,
|
|
212
|
+
*,
|
|
213
|
+
fiscal_year: int,
|
|
214
|
+
) -> Self:
|
|
215
|
+
"""Create a knowledge base from locally supplied CMS-format GEM files."""
|
|
216
|
+
release = Release(fiscal_year, date(fiscal_year - 1, 10, 1))
|
|
217
|
+
return cls(DirectoryProvider(directory, release))
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def release(self) -> Release:
|
|
221
|
+
"""Return the selected CMS GEM release."""
|
|
222
|
+
return self._provider.release
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def cm(self) -> GEMSystemView:
|
|
226
|
+
"""Return the lazy diagnosis GEM view."""
|
|
227
|
+
if self._cm is None:
|
|
228
|
+
self._cm = GEMSystemView(
|
|
229
|
+
self._provider,
|
|
230
|
+
"cm",
|
|
231
|
+
correction_providers=self._correction_providers,
|
|
232
|
+
)
|
|
233
|
+
return self._cm
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def pcs(self) -> GEMSystemView:
|
|
237
|
+
"""Return the lazy procedure GEM view."""
|
|
238
|
+
if self._pcs is None:
|
|
239
|
+
self._pcs = GEMSystemView(
|
|
240
|
+
self._provider,
|
|
241
|
+
"pcs",
|
|
242
|
+
correction_providers=self._correction_providers,
|
|
243
|
+
)
|
|
244
|
+
return self._pcs
|
|
245
|
+
|
|
246
|
+
def __repr__(self) -> str:
|
|
247
|
+
"""Return a representation without acquiring any GEM material."""
|
|
248
|
+
loaded = [
|
|
249
|
+
name
|
|
250
|
+
for name, view in (("cm", self._cm), ("pcs", self._pcs))
|
|
251
|
+
if view is not None
|
|
252
|
+
]
|
|
253
|
+
corrections_through = (
|
|
254
|
+
self._correction_providers[-1].release
|
|
255
|
+
if self._correction_providers
|
|
256
|
+
else None
|
|
257
|
+
)
|
|
258
|
+
return (
|
|
259
|
+
f"GEMKnowledgeBase(release={self.release!r}, "
|
|
260
|
+
f"corrections_through={corrections_through!r}, loaded={loaded!r})"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _opposite(direction: GEMDirection) -> GEMDirection:
|
|
265
|
+
if direction is GEMDirection.ICD9_TO_ICD10:
|
|
266
|
+
return GEMDirection.ICD10_TO_ICD9
|
|
267
|
+
return GEMDirection.ICD9_TO_ICD10
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _targets(entries: tuple) -> set[str]:
|
|
271
|
+
return {entry.target for entry in entries if entry.target is not None}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _backport_corrections(
|
|
275
|
+
stores: list[GEMStore], target_universes: list[set[str]]
|
|
276
|
+
) -> GEMStore:
|
|
277
|
+
"""Backport correction-only row sets without crossing code lifecycle changes."""
|
|
278
|
+
if len(stores) != len(target_universes) or not stores:
|
|
279
|
+
raise ValueError("A target universe is required for every GEM store")
|
|
280
|
+
base = stores[0]
|
|
281
|
+
if base.release is None or any(store.release is None for store in stores):
|
|
282
|
+
raise ValueError("Retrospective correction requires release metadata")
|
|
283
|
+
base_targets = target_universes[0]
|
|
284
|
+
values = dict(base.items())
|
|
285
|
+
selected = dict.fromkeys(base, base.release)
|
|
286
|
+
blocked: dict[str, Release] = {}
|
|
287
|
+
|
|
288
|
+
for index, (old, new) in enumerate(pairwise(stores)):
|
|
289
|
+
old_universe = target_universes[index]
|
|
290
|
+
new_universe = target_universes[index + 1]
|
|
291
|
+
introduced = new_universe - old_universe
|
|
292
|
+
retired = old_universe - new_universe
|
|
293
|
+
for source in base:
|
|
294
|
+
if source in blocked:
|
|
295
|
+
continue
|
|
296
|
+
if source not in old or source not in new:
|
|
297
|
+
blocked[source] = new.release
|
|
298
|
+
continue
|
|
299
|
+
old_entries = old[source]
|
|
300
|
+
new_entries = new[source]
|
|
301
|
+
if old_entries == new_entries:
|
|
302
|
+
continue
|
|
303
|
+
old_targets = _targets(old_entries)
|
|
304
|
+
new_targets = _targets(new_entries)
|
|
305
|
+
lifecycle = bool((old_targets | new_targets) & (introduced | retired))
|
|
306
|
+
historically_compatible = new_targets <= base_targets
|
|
307
|
+
lineage_matches = values[source] == old_entries
|
|
308
|
+
if lifecycle or not historically_compatible or not lineage_matches:
|
|
309
|
+
blocked[source] = new.release
|
|
310
|
+
continue
|
|
311
|
+
values[source] = new_entries
|
|
312
|
+
selected[source] = new.release
|
|
313
|
+
|
|
314
|
+
reviewed = stores[-1].release
|
|
315
|
+
provenance = {
|
|
316
|
+
source: GEMProvenance(
|
|
317
|
+
vocabulary_release=base.release,
|
|
318
|
+
selected_mapping_release=selected[source],
|
|
319
|
+
reviewed_through_release=reviewed,
|
|
320
|
+
blocked_by_code_lifecycle_release=blocked.get(source),
|
|
321
|
+
)
|
|
322
|
+
for source in base
|
|
323
|
+
}
|
|
324
|
+
return GEMStore(
|
|
325
|
+
values,
|
|
326
|
+
system=base.system,
|
|
327
|
+
direction=base.direction,
|
|
328
|
+
release=base.release,
|
|
329
|
+
provenance=provenance,
|
|
330
|
+
)
|
cms_icd/guidelines.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Parsing for official ICD-10 coding-guideline PDFs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from pypdf import PdfReader
|
|
9
|
+
|
|
10
|
+
from .exceptions import ParseError
|
|
11
|
+
from .models import Guideline
|
|
12
|
+
from .stores import GuidelineStore
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterator, Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from pypdf import PageObject
|
|
19
|
+
from pypdf.generic import Destination
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_SECTION = re.compile(r"Section\s+(IV|I{1,3})\b", re.I)
|
|
23
|
+
_SUBSECTION = re.compile(r"^([A-Z])\.\s")
|
|
24
|
+
_NUMBER = re.compile(r"^(\d+)\.\s")
|
|
25
|
+
_PAGE_FOOTER = re.compile(r"^Page\s+\d+\s+of\s+\d+$", re.I)
|
|
26
|
+
_CM_RUNNING_FOOTER = re.compile(
|
|
27
|
+
r"\s*ICD\s*-\s*10\s*-\s*CM\s+Official\s+Guidelines\s+for\s+Coding\s+and"
|
|
28
|
+
r"\s+Reporting\s+FY\s+\d{4}\s+Page\s+\d+\s+of\s+\d+\s*",
|
|
29
|
+
re.I,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _text_position(
|
|
34
|
+
text_matrix: Sequence[float], user_matrix: Sequence[float]
|
|
35
|
+
) -> tuple[float, float]:
|
|
36
|
+
"""Return text-space translation transformed into page coordinates."""
|
|
37
|
+
x = text_matrix[4] * user_matrix[0] + text_matrix[5] * user_matrix[2]
|
|
38
|
+
y = text_matrix[4] * user_matrix[1] + text_matrix[5] * user_matrix[3]
|
|
39
|
+
return x + user_matrix[4], y + user_matrix[5]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _page_text(page: PageObject) -> str:
|
|
43
|
+
footer_text: set[str] = set()
|
|
44
|
+
threshold = float(page.mediabox.height) * 0.08
|
|
45
|
+
|
|
46
|
+
def visit_text(
|
|
47
|
+
text: str,
|
|
48
|
+
user_matrix: Sequence[float],
|
|
49
|
+
text_matrix: Sequence[float],
|
|
50
|
+
_font: object,
|
|
51
|
+
_font_size: float,
|
|
52
|
+
) -> None:
|
|
53
|
+
_, y = _text_position(text_matrix, user_matrix)
|
|
54
|
+
item = text.strip()
|
|
55
|
+
if item and 0 <= y <= threshold and _PAGE_FOOTER.match(item):
|
|
56
|
+
footer_text.add(item)
|
|
57
|
+
|
|
58
|
+
text = page.extract_text(visitor_text=visit_text) or ""
|
|
59
|
+
text = _CM_RUNNING_FOOTER.sub("", text)
|
|
60
|
+
for item in footer_text:
|
|
61
|
+
text = text.replace(item, "", 1)
|
|
62
|
+
text = re.sub(
|
|
63
|
+
r"\bICD\s*-\s*10\s*-\s*(CM|PCS)\b",
|
|
64
|
+
lambda match: f"ICD-10-{match.group(1).upper()}",
|
|
65
|
+
text,
|
|
66
|
+
flags=re.I,
|
|
67
|
+
)
|
|
68
|
+
text = re.sub(r"(?<=\d)\s+(st|nd|rd|th)\b", r"\1", text) # codespell:ignore nd
|
|
69
|
+
text = re.sub(r"\b([A-Z])\s+(?=\d{2}(?:\d|\.))", r"\1", text)
|
|
70
|
+
# Some CMS fonts place artificial word boundaries inside these words.
|
|
71
|
+
text = text.replace("Tabular L ist", "Tabular List") # codespell:ignore ist
|
|
72
|
+
text = re.sub(r"\bap\s+propriate\b", "appropriate", text)
|
|
73
|
+
text = re.sub(r"\bW\s+hen\b", "When", text)
|
|
74
|
+
text = re.sub(r"\bw\s+hen\b", "when", text)
|
|
75
|
+
return re.sub(r"^(\d+\.)\s*\n\s*", r"\n\1 ", text, flags=re.M).strip()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _outline_entries(
|
|
79
|
+
document: PdfReader,
|
|
80
|
+
) -> Iterator[tuple[int, str, int]]:
|
|
81
|
+
def walk(
|
|
82
|
+
items: Sequence[Destination | list[Destination]], level: int
|
|
83
|
+
) -> Iterator[tuple[int, str, int]]:
|
|
84
|
+
for item in items:
|
|
85
|
+
if isinstance(item, list):
|
|
86
|
+
yield from walk(item, level + 1)
|
|
87
|
+
continue
|
|
88
|
+
page_number = document.get_destination_page_number(item)
|
|
89
|
+
if page_number is None:
|
|
90
|
+
raise ParseError(f"Guideline outline destination has no page: {item!s}")
|
|
91
|
+
yield level, str(item.title), page_number + 1
|
|
92
|
+
|
|
93
|
+
yield from walk(document.outline, 1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _strip_header(title: str, content: str) -> str:
|
|
97
|
+
words = [re.escape(word) for word in title.split()]
|
|
98
|
+
if not words:
|
|
99
|
+
return content
|
|
100
|
+
return re.sub(
|
|
101
|
+
r"^.*?" + r"\s+".join(words), "", content, count=1, flags=re.I | re.S
|
|
102
|
+
).strip()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _structured_cm_guidelines(document: PdfReader, path: str | Path) -> GuidelineStore:
|
|
106
|
+
entries: list[dict[str, object]] = []
|
|
107
|
+
current_section: str | None = None
|
|
108
|
+
current_subsection: str | None = None
|
|
109
|
+
for level, raw_title, page_number in _outline_entries(document):
|
|
110
|
+
if level == 1 and (match := _SECTION.search(raw_title)):
|
|
111
|
+
current_section = match.group(1).upper()
|
|
112
|
+
current_subsection = None
|
|
113
|
+
title = re.sub(
|
|
114
|
+
r"Section\s+(?:IV|I{1,3})\.\s*", "", raw_title, count=1, flags=re.I
|
|
115
|
+
).strip()
|
|
116
|
+
entries.append(
|
|
117
|
+
{
|
|
118
|
+
"key": current_section,
|
|
119
|
+
"title": title,
|
|
120
|
+
"page": page_number,
|
|
121
|
+
"level": 1,
|
|
122
|
+
"raw_title": raw_title,
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
elif level == 2 and current_section and (match := _SUBSECTION.match(raw_title)):
|
|
126
|
+
current_subsection = f"{current_section}.{match.group(1)}"
|
|
127
|
+
entries.append(
|
|
128
|
+
{
|
|
129
|
+
"key": current_subsection,
|
|
130
|
+
"title": raw_title[match.end() :].strip(),
|
|
131
|
+
"page": page_number,
|
|
132
|
+
"level": 2,
|
|
133
|
+
"raw_title": raw_title,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
elif level == 3 and current_subsection and (match := _NUMBER.match(raw_title)):
|
|
137
|
+
entries.append(
|
|
138
|
+
{
|
|
139
|
+
"key": f"{current_subsection}.{match.group(1)}",
|
|
140
|
+
"title": raw_title[match.end() :].strip(),
|
|
141
|
+
"page": page_number,
|
|
142
|
+
"level": 3,
|
|
143
|
+
"raw_title": raw_title,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
if not entries:
|
|
147
|
+
raise ParseError(f"No structured CM guideline outline found in {path}")
|
|
148
|
+
for index, entry in enumerate(entries):
|
|
149
|
+
entry["leaf"] = index == len(entries) - 1 or int(
|
|
150
|
+
entries[index + 1]["level"]
|
|
151
|
+
) <= int(entry["level"])
|
|
152
|
+
first_page = int(entries[0]["page"])
|
|
153
|
+
full_text = "\n".join(
|
|
154
|
+
_page_text(document.pages[number])
|
|
155
|
+
for number in range(first_page - 1, len(document.pages))
|
|
156
|
+
)
|
|
157
|
+
search_from = 0
|
|
158
|
+
for entry in entries:
|
|
159
|
+
words = str(entry["raw_title"]).split()[:8]
|
|
160
|
+
heading = "".join(words)
|
|
161
|
+
pattern = r"\s*".join(re.escape(character) for character in heading)
|
|
162
|
+
match = re.search(pattern, full_text[search_from:], re.I | re.M)
|
|
163
|
+
entry["position"] = search_from + match.start() if match else None
|
|
164
|
+
if match:
|
|
165
|
+
search_from = int(entry["position"])
|
|
166
|
+
titles = {str(entry["key"]): str(entry["title"]) for entry in entries}
|
|
167
|
+
guidelines: dict[str, Guideline] = {}
|
|
168
|
+
preambles: dict[str, str] = {}
|
|
169
|
+
for index, entry in enumerate(entries):
|
|
170
|
+
position = entry["position"]
|
|
171
|
+
if position is None:
|
|
172
|
+
continue
|
|
173
|
+
later = [item for item in entries[index + 1 :] if item["position"] is not None]
|
|
174
|
+
end = int(later[0]["position"]) if later else len(full_text)
|
|
175
|
+
content = full_text[int(position) : end].strip()
|
|
176
|
+
key = str(entry["key"])
|
|
177
|
+
if entry["leaf"]:
|
|
178
|
+
guidelines[key] = Guideline(
|
|
179
|
+
id=key.replace(".", "_"),
|
|
180
|
+
number=key,
|
|
181
|
+
title=str(entry["title"]),
|
|
182
|
+
content=content,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
body = _strip_header(str(entry["title"]), content)
|
|
186
|
+
if body:
|
|
187
|
+
preambles[key] = body
|
|
188
|
+
return GuidelineStore(guidelines, titles, preambles)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def parse_guidelines(path: str | Path, *, system: str) -> GuidelineStore:
|
|
192
|
+
"""Parse an official coding-guidelines PDF.
|
|
193
|
+
|
|
194
|
+
CM PDFs receive dotted section keys. PCS PDFs, whose outlines vary more by release,
|
|
195
|
+
are exposed as one deterministic ``document`` guideline.
|
|
196
|
+
"""
|
|
197
|
+
if system not in {"cm", "pcs"}:
|
|
198
|
+
raise ValueError(f"Unsupported guideline system: {system!r}")
|
|
199
|
+
try:
|
|
200
|
+
document = PdfReader(path)
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
raise ParseError(
|
|
203
|
+
f"Unable to open ICD-10-{system.upper()} guidelines {path}: {exc}"
|
|
204
|
+
) from exc
|
|
205
|
+
try:
|
|
206
|
+
if system == "pcs":
|
|
207
|
+
content = "\n".join(_page_text(page) for page in document.pages)
|
|
208
|
+
guideline = Guideline(
|
|
209
|
+
"document", "document", "Official Guidelines", content
|
|
210
|
+
)
|
|
211
|
+
return GuidelineStore(
|
|
212
|
+
{"document": guideline}, {"document": guideline.title}
|
|
213
|
+
)
|
|
214
|
+
return _structured_cm_guidelines(document, path)
|
|
215
|
+
finally:
|
|
216
|
+
document.close()
|