messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Shared, reference-driven machinery for the HL7 message generators.
|
|
4
|
+
|
|
5
|
+
Each message type (ADT, ORM, …) registers a :class:`MessageSpec` describing its
|
|
6
|
+
trigger→structure map, its segment builders, and which optional segments to sprinkle in.
|
|
7
|
+
Generation walks hl7apy's own 2.5.1 reference tree (``MESSAGES[structure]``): for each
|
|
8
|
+
structure we emit required segments in order plus a valid random subset of allow-listed
|
|
9
|
+
optionals, then gate every message through the engine's strict validator before it counts.
|
|
10
|
+
|
|
11
|
+
A type module contributes builders only for its *own* segments; the broadly shared ones
|
|
12
|
+
(MSH/EVN/PID/PV1/…) live here in :data:`SHARED_BUILDERS`. All data is synthetic — no real PHI.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import random
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime, timedelta
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from hl7apy import v2_5_1 as _ref
|
|
25
|
+
|
|
26
|
+
from messagefoundry.parsing import validate
|
|
27
|
+
from messagefoundry.generators import _hl7data as d
|
|
28
|
+
|
|
29
|
+
_MESSAGES = _ref.MESSAGES
|
|
30
|
+
_SEGMENTS = _ref.SEGMENTS
|
|
31
|
+
|
|
32
|
+
DEFAULT_SEED = "messagefoundry-hl7-2.5.1"
|
|
33
|
+
# Fixed epoch so a given seed reproduces identical bytes (no wall-clock nondeterminism).
|
|
34
|
+
BASE_DT = datetime(2026, 1, 1, 0, 0, 0)
|
|
35
|
+
|
|
36
|
+
SegmentBuilder = Callable[[random.Random, "Ctx"], str]
|
|
37
|
+
GateFn = Callable[[str, str], tuple[bool, list[str]]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --- per-message state -------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Patient:
|
|
45
|
+
"""A fabricated patient identity, shared across a message's PID/PV1/etc."""
|
|
46
|
+
|
|
47
|
+
mrn: str
|
|
48
|
+
family: str
|
|
49
|
+
given: str
|
|
50
|
+
middle: str
|
|
51
|
+
dob: str
|
|
52
|
+
sex: str
|
|
53
|
+
street: str
|
|
54
|
+
city: str
|
|
55
|
+
state: str
|
|
56
|
+
zip_code: str
|
|
57
|
+
phone: str
|
|
58
|
+
patient_class: str
|
|
59
|
+
visit_no: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Ctx:
|
|
64
|
+
"""Mutable per-message context threaded through the segment builders."""
|
|
65
|
+
|
|
66
|
+
message_code: str
|
|
67
|
+
trigger: str
|
|
68
|
+
structure: str
|
|
69
|
+
control_id: str
|
|
70
|
+
msg_dt: datetime
|
|
71
|
+
sending_app: str
|
|
72
|
+
sending_fac: str
|
|
73
|
+
receiving_app: str
|
|
74
|
+
receiving_fac: str
|
|
75
|
+
current: Patient | None = None
|
|
76
|
+
seq: dict[str, int] = field(default_factory=dict)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def next_seq(ctx: Ctx, name: str) -> int:
|
|
80
|
+
"""Monotonic set-id per segment type within a message (PID-1, NK1-1, …)."""
|
|
81
|
+
ctx.seq[name] = ctx.seq.get(name, 0) + 1
|
|
82
|
+
return ctx.seq[name]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def seg(name: str, fields: dict[int, str]) -> str:
|
|
86
|
+
"""Assemble a segment, placing each value at its 1-based field index, gaps empty."""
|
|
87
|
+
high = max(fields) if fields else 0
|
|
88
|
+
return "|".join([name, *(fields.get(i, "") for i in range(1, high + 1))])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- synthetic patient -------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _birthdate(rng: random.Random) -> str:
|
|
95
|
+
age = rng.randint(0, 95)
|
|
96
|
+
return f"{2026 - age:04d}{rng.randint(1, 12):02d}{rng.randint(1, 28):02d}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def make_patient(rng: random.Random) -> Patient:
|
|
100
|
+
city, state, zip_code = rng.choice(d.CITIES)
|
|
101
|
+
return Patient(
|
|
102
|
+
mrn=str(rng.randint(1_000_000, 9_999_999)),
|
|
103
|
+
family=rng.choice(d.FAMILY_NAMES),
|
|
104
|
+
given=rng.choice(d.GIVEN_NAMES),
|
|
105
|
+
middle=rng.choice(d.MIDDLE_INITIALS),
|
|
106
|
+
dob=_birthdate(rng),
|
|
107
|
+
sex=rng.choice(d.SEXES),
|
|
108
|
+
street=rng.choice(d.STREETS),
|
|
109
|
+
city=city,
|
|
110
|
+
state=state,
|
|
111
|
+
zip_code=zip_code,
|
|
112
|
+
# NANP reserved fictional range (exchange 555, subscriber 0100-0199) — never diallable
|
|
113
|
+
# even though the synthetic data is otherwise realistic (low-22).
|
|
114
|
+
phone=f"({rng.randint(200, 989)})555-{rng.randint(100, 199):04d}",
|
|
115
|
+
patient_class=rng.choice(d.PATIENT_CLASSES),
|
|
116
|
+
visit_no=f"V{rng.randint(1_000_000, 9_999_999)}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def patient(ctx: Ctx, rng: random.Random) -> Patient:
|
|
121
|
+
"""The message's current patient, creating one on first use (shared by PID/PV1/etc.)."""
|
|
122
|
+
if ctx.current is None:
|
|
123
|
+
ctx.current = make_patient(rng)
|
|
124
|
+
return ctx.current
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- broadly shared segment builders -----------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _build_msh(rng: random.Random, ctx: Ctx) -> str:
|
|
131
|
+
tail = "|".join(
|
|
132
|
+
[
|
|
133
|
+
ctx.sending_app,
|
|
134
|
+
ctx.sending_fac,
|
|
135
|
+
ctx.receiving_app,
|
|
136
|
+
ctx.receiving_fac,
|
|
137
|
+
d.ts(ctx.msg_dt),
|
|
138
|
+
"",
|
|
139
|
+
f"{ctx.message_code}^{ctx.trigger}^{ctx.structure}",
|
|
140
|
+
ctx.control_id,
|
|
141
|
+
"P",
|
|
142
|
+
"2.5.1",
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
return rf"MSH|^~\&|{tail}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_evn(rng: random.Random, ctx: Ctx) -> str:
|
|
149
|
+
reason = rng.choice(d.EVENT_REASONS)[0]
|
|
150
|
+
return seg("EVN", {1: ctx.trigger, 2: d.ts(ctx.msg_dt), 4: reason, 6: d.ts(ctx.msg_dt)})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _build_pid(rng: random.Random, ctx: Ctx) -> str:
|
|
154
|
+
p = make_patient(rng)
|
|
155
|
+
ctx.current = p
|
|
156
|
+
return seg(
|
|
157
|
+
"PID",
|
|
158
|
+
{
|
|
159
|
+
1: str(next_seq(ctx, "PID")),
|
|
160
|
+
3: d.cx(p.mrn),
|
|
161
|
+
5: d.xpn(p.family, p.given, p.middle),
|
|
162
|
+
7: p.dob,
|
|
163
|
+
8: p.sex,
|
|
164
|
+
11: d.xad(p.street, p.city, p.state, p.zip_code),
|
|
165
|
+
13: p.phone,
|
|
166
|
+
18: d.cx(p.visit_no, id_type="AN"),
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _build_pd1(rng: random.Random, ctx: Ctx) -> str:
|
|
172
|
+
return seg(
|
|
173
|
+
"PD1", {3: f"{rng.choice(d.FACILITIES)} CLINIC", 4: d.xcn(*rng.choice(d.CLINICIANS))}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_pv1(rng: random.Random, ctx: Ctx) -> str:
|
|
178
|
+
p = patient(ctx, rng)
|
|
179
|
+
return seg(
|
|
180
|
+
"PV1",
|
|
181
|
+
{
|
|
182
|
+
1: str(next_seq(ctx, "PV1")),
|
|
183
|
+
2: p.patient_class,
|
|
184
|
+
3: d.pl(
|
|
185
|
+
rng.choice(d.POINTS_OF_CARE),
|
|
186
|
+
rng.choice(d.ROOMS),
|
|
187
|
+
rng.choice(d.BEDS),
|
|
188
|
+
rng.choice(d.FACILITIES),
|
|
189
|
+
),
|
|
190
|
+
7: d.xcn(*rng.choice(d.CLINICIANS)),
|
|
191
|
+
10: rng.choice(d.HOSPITAL_SERVICES),
|
|
192
|
+
17: d.xcn(*rng.choice(d.CLINICIANS)),
|
|
193
|
+
19: d.cx(p.visit_no, id_type="VN"),
|
|
194
|
+
44: d.ts(ctx.msg_dt),
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _build_pv2(rng: random.Random, ctx: Ctx) -> str:
|
|
200
|
+
code, text = rng.choice(d.DIAGNOSES)
|
|
201
|
+
return seg("PV2", {3: d.cwe(code, text, "I10")})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _build_nk1(rng: random.Random, ctx: Ctx) -> str:
|
|
205
|
+
rel_code, rel_text = rng.choice(d.RELATIONSHIPS)
|
|
206
|
+
return seg(
|
|
207
|
+
"NK1",
|
|
208
|
+
{
|
|
209
|
+
1: str(next_seq(ctx, "NK1")),
|
|
210
|
+
2: d.xpn(rng.choice(d.FAMILY_NAMES), rng.choice(d.GIVEN_NAMES)),
|
|
211
|
+
3: d.cwe(rel_code, rel_text, "HL70063"),
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _build_al1(rng: random.Random, ctx: Ctx) -> str:
|
|
217
|
+
code, text = rng.choice(d.ALLERGENS)
|
|
218
|
+
severity = rng.choice(d.ALLERGY_SEVERITIES)[0]
|
|
219
|
+
return seg(
|
|
220
|
+
"AL1",
|
|
221
|
+
{
|
|
222
|
+
1: str(next_seq(ctx, "AL1")),
|
|
223
|
+
2: d.cwe("DA", "Drug allergy", "HL70127"),
|
|
224
|
+
3: d.cwe(code, text, "L"),
|
|
225
|
+
4: severity,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _build_dg1(rng: random.Random, ctx: Ctx) -> str:
|
|
231
|
+
code, text = rng.choice(d.DIAGNOSES)
|
|
232
|
+
return seg(
|
|
233
|
+
"DG1",
|
|
234
|
+
{
|
|
235
|
+
1: str(next_seq(ctx, "DG1")),
|
|
236
|
+
3: d.cwe(code, text, "I10"),
|
|
237
|
+
6: rng.choice(d.DIAGNOSIS_TYPES),
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _build_obx(rng: random.Random, ctx: Ctx) -> str:
|
|
243
|
+
code, text, value_type, value, units = rng.choice(d.OBSERVATIONS)
|
|
244
|
+
return seg(
|
|
245
|
+
"OBX",
|
|
246
|
+
{
|
|
247
|
+
1: str(next_seq(ctx, "OBX")),
|
|
248
|
+
2: value_type,
|
|
249
|
+
3: d.cwe(code, text, "LN"),
|
|
250
|
+
5: value,
|
|
251
|
+
6: units,
|
|
252
|
+
11: "F",
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _build_orc(rng: random.Random, ctx: Ctx) -> str:
|
|
258
|
+
return seg(
|
|
259
|
+
"ORC",
|
|
260
|
+
{
|
|
261
|
+
1: rng.choice(d.ORDER_CONTROLS),
|
|
262
|
+
2: d.ei(str(rng.randint(100_000, 999_999))), # placer order number
|
|
263
|
+
3: d.ei(str(rng.randint(100_000, 999_999)), "FILLER"),
|
|
264
|
+
5: rng.choice(d.ORDER_STATUSES),
|
|
265
|
+
9: d.ts(ctx.msg_dt),
|
|
266
|
+
12: d.xcn(*rng.choice(d.CLINICIANS)),
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _build_obr(rng: random.Random, ctx: Ctx) -> str:
|
|
272
|
+
code, text = rng.choice(d.SERVICES)
|
|
273
|
+
return seg(
|
|
274
|
+
"OBR",
|
|
275
|
+
{
|
|
276
|
+
1: str(next_seq(ctx, "OBR")),
|
|
277
|
+
2: d.ei(str(rng.randint(100_000, 999_999))),
|
|
278
|
+
3: d.ei(str(rng.randint(100_000, 999_999)), "FILLER"),
|
|
279
|
+
4: d.cwe(code, text, "LN"), # universal service id (required)
|
|
280
|
+
7: d.ts(ctx.msg_dt),
|
|
281
|
+
},
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _build_ft1(rng: random.Random, ctx: Ctx) -> str:
|
|
286
|
+
code, text = rng.choice(d.PROCEDURES)
|
|
287
|
+
return seg(
|
|
288
|
+
"FT1",
|
|
289
|
+
{
|
|
290
|
+
1: str(next_seq(ctx, "FT1")),
|
|
291
|
+
4: d.ts(ctx.msg_dt), # transaction date (required)
|
|
292
|
+
6: rng.choice(d.TRANSACTION_TYPES), # transaction type (required)
|
|
293
|
+
7: d.cwe(code, text, "CPT"), # transaction code (required)
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _build_pr1(rng: random.Random, ctx: Ctx) -> str:
|
|
299
|
+
code, text = rng.choice(d.PROCEDURES)
|
|
300
|
+
return seg(
|
|
301
|
+
"PR1",
|
|
302
|
+
{
|
|
303
|
+
1: str(next_seq(ctx, "PR1")),
|
|
304
|
+
3: d.cwe(code, text, "C4"), # procedure code (required)
|
|
305
|
+
5: d.ts(ctx.msg_dt), # procedure date/time (required)
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _build_gt1(rng: random.Random, ctx: Ctx) -> str:
|
|
311
|
+
return seg(
|
|
312
|
+
"GT1",
|
|
313
|
+
{
|
|
314
|
+
1: str(next_seq(ctx, "GT1")),
|
|
315
|
+
3: d.xpn(rng.choice(d.FAMILY_NAMES), rng.choice(d.GIVEN_NAMES)), # guarantor (required)
|
|
316
|
+
},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _build_in1(rng: random.Random, ctx: Ctx) -> str:
|
|
321
|
+
company_id, company = rng.choice(d.INSURANCE_COMPANIES)
|
|
322
|
+
code, text = rng.choice(d.INSURANCE_PLANS)
|
|
323
|
+
return seg(
|
|
324
|
+
"IN1",
|
|
325
|
+
{
|
|
326
|
+
1: str(next_seq(ctx, "IN1")),
|
|
327
|
+
2: d.cwe(code, text, "L"), # insurance plan id (required)
|
|
328
|
+
3: d.cx(company_id, authority=company, id_type="NI"), # company id (required)
|
|
329
|
+
4: company,
|
|
330
|
+
},
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _build_rxa(rng: random.Random, ctx: Ctx) -> str:
|
|
335
|
+
code, text = rng.choice(d.VACCINES)
|
|
336
|
+
return seg(
|
|
337
|
+
"RXA",
|
|
338
|
+
{
|
|
339
|
+
1: "0", # give sub-id counter (required)
|
|
340
|
+
2: "1", # administration sub-id counter (required)
|
|
341
|
+
3: d.ts(ctx.msg_dt), # date/time start (required)
|
|
342
|
+
4: d.ts(ctx.msg_dt), # date/time end (required)
|
|
343
|
+
5: d.cwe(code, text, "CVX"), # administered code (required)
|
|
344
|
+
6: "0.5", # administered amount (required)
|
|
345
|
+
},
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _build_rxr(rng: random.Random, ctx: Ctx) -> str:
|
|
350
|
+
code, text = rng.choice(d.ROUTES)
|
|
351
|
+
return seg("RXR", {1: d.cwe(code, text, "HL70162")}) # route (required)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
SHARED_BUILDERS: dict[str, SegmentBuilder] = {
|
|
355
|
+
"MSH": _build_msh,
|
|
356
|
+
"EVN": _build_evn,
|
|
357
|
+
"PID": _build_pid,
|
|
358
|
+
"PD1": _build_pd1,
|
|
359
|
+
"PV1": _build_pv1,
|
|
360
|
+
"PV2": _build_pv2,
|
|
361
|
+
"NK1": _build_nk1,
|
|
362
|
+
"AL1": _build_al1,
|
|
363
|
+
"DG1": _build_dg1,
|
|
364
|
+
"OBX": _build_obx,
|
|
365
|
+
"ORC": _build_orc,
|
|
366
|
+
"OBR": _build_obr,
|
|
367
|
+
"FT1": _build_ft1,
|
|
368
|
+
"PR1": _build_pr1,
|
|
369
|
+
"GT1": _build_gt1,
|
|
370
|
+
"IN1": _build_in1,
|
|
371
|
+
"RXA": _build_rxa,
|
|
372
|
+
"RXR": _build_rxr,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# --- message-type registry ---------------------------------------------------
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _no_force(structure: str) -> frozenset[str]:
|
|
380
|
+
return frozenset()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class MessageSpec:
|
|
385
|
+
"""How to generate one HL7 message family.
|
|
386
|
+
|
|
387
|
+
``builders`` are this type's own segments (merged over :data:`SHARED_BUILDERS`);
|
|
388
|
+
``force`` names optional segments to always emit for a given structure; ``gate`` overrides
|
|
389
|
+
strict validation (e.g. ADT's two-block fallback).
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
code: str
|
|
393
|
+
trigger_to_structure: dict[str, str]
|
|
394
|
+
builders: Mapping[str, SegmentBuilder] = field(default_factory=dict)
|
|
395
|
+
optional_allowlist: frozenset[str] = frozenset()
|
|
396
|
+
force: Callable[[str], frozenset[str]] = _no_force
|
|
397
|
+
gate: GateFn | None = None
|
|
398
|
+
# Optional groups to recurse into, matched by name *suffix* (e.g. "_PATIENT") so one spec
|
|
399
|
+
# covers every structure of its type (ORM_O01_PATIENT, SIU_S12_PATIENT, …).
|
|
400
|
+
group_suffixes: frozenset[str] = frozenset()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
_REGISTRY: dict[str, MessageSpec] = {}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def register(spec: MessageSpec) -> None:
|
|
407
|
+
_REGISTRY[spec.code] = spec
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def message_codes() -> list[str]:
|
|
411
|
+
"""Registered message types (e.g. ``["ADT", "ORM", …]``) — drives the harness GUI."""
|
|
412
|
+
return sorted(_REGISTRY)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def triggers_for(code: str) -> list[str]:
|
|
416
|
+
return sorted(_REGISTRY[code].trigger_to_structure)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def structure_for(code: str, trigger: str) -> str:
|
|
420
|
+
return _REGISTRY[code].trigger_to_structure[trigger]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def control_id(code: str, trigger: str, index: int) -> str:
|
|
424
|
+
"""The deterministic synthetic control id embedded in MSH-10 by :func:`generate_message`.
|
|
425
|
+
|
|
426
|
+
Single source of truth so the harness (which labels rows and matches the engine's recorded
|
|
427
|
+
control ids against it) and the generator never drift apart."""
|
|
428
|
+
return f"MEFOR{code}{trigger}{index:05d}"
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# --- reference-driven assembly ----------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _emit(
|
|
435
|
+
children: list[Any],
|
|
436
|
+
rng: random.Random,
|
|
437
|
+
ctx: Ctx,
|
|
438
|
+
spec: MessageSpec,
|
|
439
|
+
force: frozenset[str],
|
|
440
|
+
out: list[str],
|
|
441
|
+
) -> None:
|
|
442
|
+
"""Walk an hl7apy reference child list, appending built segment strings to ``out``.
|
|
443
|
+
|
|
444
|
+
Required children (min>=1) are always emitted; optional segments are emitted only if
|
|
445
|
+
allow-listed (a random 0..N for repeating ones), or if named in ``force``. Groups recurse
|
|
446
|
+
only when the group itself is required.
|
|
447
|
+
"""
|
|
448
|
+
for child in children:
|
|
449
|
+
name = child[0]
|
|
450
|
+
child_ref = child[1]
|
|
451
|
+
min_card, max_card = child[2][0], child[2][1]
|
|
452
|
+
|
|
453
|
+
if name in _SEGMENTS:
|
|
454
|
+
builder = spec.builders.get(name) or SHARED_BUILDERS.get(name)
|
|
455
|
+
if min_card >= 1 or name in force:
|
|
456
|
+
if builder is None:
|
|
457
|
+
raise RuntimeError(f"no builder for required/forced segment {name}")
|
|
458
|
+
out.append(builder(rng, ctx))
|
|
459
|
+
elif name in spec.optional_allowlist and builder is not None:
|
|
460
|
+
max_reps = 1 if max_card == 1 else 3
|
|
461
|
+
for _ in range(rng.randint(0, max_reps)):
|
|
462
|
+
out.append(builder(rng, ctx))
|
|
463
|
+
elif min_card >= 1 or any(name.endswith(s) for s in spec.group_suffixes):
|
|
464
|
+
_emit(child_ref[1], rng, ctx, spec, force, out)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def generate_message(code: str, trigger: str, index: int, *, seed: str = DEFAULT_SEED) -> str:
|
|
468
|
+
"""Build one ``\\r``-delimited HL7 message (deterministic given ``code``/``trigger``/``seed``)."""
|
|
469
|
+
spec = _REGISTRY[code]
|
|
470
|
+
structure = spec.trigger_to_structure[trigger]
|
|
471
|
+
rng = random.Random(f"{seed}|{code}|{trigger}|{index}")
|
|
472
|
+
sending_app, sending_fac = rng.choice(d.SENDING_APPS)
|
|
473
|
+
receiving_app, receiving_fac = rng.choice(d.RECEIVING_APPS)
|
|
474
|
+
ctx = Ctx(
|
|
475
|
+
message_code=code,
|
|
476
|
+
trigger=trigger,
|
|
477
|
+
structure=structure,
|
|
478
|
+
control_id=control_id(code, trigger, index),
|
|
479
|
+
msg_dt=BASE_DT + timedelta(minutes=rng.randint(0, 525_600)),
|
|
480
|
+
sending_app=sending_app,
|
|
481
|
+
sending_fac=sending_fac,
|
|
482
|
+
receiving_app=receiving_app,
|
|
483
|
+
receiving_fac=receiving_fac,
|
|
484
|
+
)
|
|
485
|
+
out: list[str] = []
|
|
486
|
+
_emit(_MESSAGES[structure][1], rng, ctx, spec, spec.force(structure), out)
|
|
487
|
+
return "\r".join(out) + "\r"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
# --- compliance gate ---------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def default_gate(msg: str, structure: str) -> tuple[bool, list[str]]:
|
|
494
|
+
"""Strict hl7apy validation (the default for types with no special-casing)."""
|
|
495
|
+
result = validate(msg, expected_version="2.5.1")
|
|
496
|
+
return result.ok, list(result.errors)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def gate(code: str, msg: str, structure: str) -> tuple[bool, list[str]]:
|
|
500
|
+
"""Return ``(ok, errors)`` using the type's gate override, else strict validation."""
|
|
501
|
+
spec = _REGISTRY[code]
|
|
502
|
+
if spec.gate is not None:
|
|
503
|
+
return spec.gate(msg, structure)
|
|
504
|
+
return default_gate(msg, structure)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# --- corpus writer (shared by the CLI and the per-type modules) --------------
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class GenerationError(RuntimeError):
|
|
511
|
+
"""A generated message failed strict validation — a generator bug, never a user error."""
|
|
512
|
+
|
|
513
|
+
def __init__(
|
|
514
|
+
self, code: str, trigger: str, index: int, structure: str, errors: list[str], message: str
|
|
515
|
+
) -> None:
|
|
516
|
+
self.code = code
|
|
517
|
+
self.trigger = trigger
|
|
518
|
+
self.index = index
|
|
519
|
+
self.structure = structure
|
|
520
|
+
self.errors = errors
|
|
521
|
+
self.message = message
|
|
522
|
+
super().__init__(
|
|
523
|
+
f"{code}^{trigger} #{index} ({structure}) failed validation: {'; '.join(errors)}"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@dataclass(frozen=True)
|
|
528
|
+
class CorpusResult:
|
|
529
|
+
"""What :func:`write_corpus` produced."""
|
|
530
|
+
|
|
531
|
+
code: str
|
|
532
|
+
out_dir: str
|
|
533
|
+
by_trigger: dict[str, int] # trigger -> messages written
|
|
534
|
+
total: int
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def write_corpus(
|
|
538
|
+
code: str,
|
|
539
|
+
*,
|
|
540
|
+
triggers: list[str] | None = None,
|
|
541
|
+
count: int = 50,
|
|
542
|
+
out: str | Path,
|
|
543
|
+
seed: str = DEFAULT_SEED,
|
|
544
|
+
corpus_root: str | Path | None = None,
|
|
545
|
+
) -> CorpusResult:
|
|
546
|
+
"""Generate + strict-validate ``count`` messages per trigger and write them as
|
|
547
|
+
``<out>/<TRIGGER>/NNNN.hl7`` (all-or-nothing per trigger; stale generated files are cleared first).
|
|
548
|
+
|
|
549
|
+
``triggers`` defaults to every trigger of ``code``. The cleanup is **scoped**: it deletes only
|
|
550
|
+
its own ``NNNN.hl7`` files, never arbitrary ``*.hl7`` a user placed there (GEN-1). When
|
|
551
|
+
``corpus_root`` is given, ``out`` must resolve **within** it — a containment guard for
|
|
552
|
+
programmatic callers (the CLI writes the user's explicit ``--out``). Raises :class:`ValueError`
|
|
553
|
+
for an out-of-root path, :class:`KeyError` for an unknown type/trigger, and
|
|
554
|
+
:class:`GenerationError` if any message fails validation.
|
|
555
|
+
"""
|
|
556
|
+
if code not in _REGISTRY:
|
|
557
|
+
raise KeyError(f"unknown message type {code!r}; registered: {', '.join(message_codes())}")
|
|
558
|
+
chosen = list(triggers) if triggers else triggers_for(code)
|
|
559
|
+
unknown = [t for t in chosen if t not in _REGISTRY[code].trigger_to_structure]
|
|
560
|
+
if unknown:
|
|
561
|
+
raise KeyError(f"unknown {code} trigger(s): {', '.join(unknown)}")
|
|
562
|
+
|
|
563
|
+
out_root = Path(out).resolve()
|
|
564
|
+
if corpus_root is not None:
|
|
565
|
+
root = Path(corpus_root).resolve()
|
|
566
|
+
if out_root != root and root not in out_root.parents:
|
|
567
|
+
raise ValueError(f"refusing to write corpus outside {root}: {out_root}")
|
|
568
|
+
by_trigger: dict[str, int] = {}
|
|
569
|
+
total = 0
|
|
570
|
+
for trigger in chosen:
|
|
571
|
+
structure = structure_for(code, trigger)
|
|
572
|
+
# Build + validate the whole batch before writing (all-or-nothing per trigger).
|
|
573
|
+
messages: list[str] = []
|
|
574
|
+
for index in range(1, count + 1):
|
|
575
|
+
msg = generate_message(code, trigger, index, seed=seed)
|
|
576
|
+
ok, errors = gate(code, msg, structure)
|
|
577
|
+
if not ok:
|
|
578
|
+
raise GenerationError(code, trigger, index, structure, errors, msg)
|
|
579
|
+
messages.append(msg)
|
|
580
|
+
trigger_dir = out_root / trigger
|
|
581
|
+
trigger_dir.mkdir(parents=True, exist_ok=True)
|
|
582
|
+
# Only clear our own generated files (NNNN.hl7), not arbitrary *.hl7 a user may have placed.
|
|
583
|
+
for stale in trigger_dir.glob("[0-9][0-9][0-9][0-9].hl7"):
|
|
584
|
+
stale.unlink()
|
|
585
|
+
for index, msg in enumerate(messages, start=1):
|
|
586
|
+
(trigger_dir / f"{index:04d}.hl7").write_bytes(msg.encode("utf-8"))
|
|
587
|
+
by_trigger[trigger] = len(messages)
|
|
588
|
+
total += len(messages)
|
|
589
|
+
return CorpusResult(code=code, out_dir=str(out_root), by_trigger=by_trigger, total=total)
|