@the-cascade-protocol/cli 0.2.0

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.
Files changed (165) hide show
  1. package/.dockerignore +7 -0
  2. package/.eslintrc.json +23 -0
  3. package/.prettierrc +7 -0
  4. package/DOCKER.md +36 -0
  5. package/Dockerfile +18 -0
  6. package/README.md +69 -0
  7. package/dist/commands/capabilities.d.ts +9 -0
  8. package/dist/commands/capabilities.d.ts.map +1 -0
  9. package/dist/commands/capabilities.js +194 -0
  10. package/dist/commands/capabilities.js.map +1 -0
  11. package/dist/commands/conformance.d.ts +15 -0
  12. package/dist/commands/conformance.d.ts.map +1 -0
  13. package/dist/commands/conformance.js +348 -0
  14. package/dist/commands/conformance.js.map +1 -0
  15. package/dist/commands/convert.d.ts +21 -0
  16. package/dist/commands/convert.d.ts.map +1 -0
  17. package/dist/commands/convert.js +134 -0
  18. package/dist/commands/convert.js.map +1 -0
  19. package/dist/commands/pod/export.d.ts +8 -0
  20. package/dist/commands/pod/export.d.ts.map +1 -0
  21. package/dist/commands/pod/export.js +72 -0
  22. package/dist/commands/pod/export.js.map +1 -0
  23. package/dist/commands/pod/helpers.d.ts +79 -0
  24. package/dist/commands/pod/helpers.d.ts.map +1 -0
  25. package/dist/commands/pod/helpers.js +369 -0
  26. package/dist/commands/pod/helpers.js.map +1 -0
  27. package/dist/commands/pod/index.d.ts +20 -0
  28. package/dist/commands/pod/index.d.ts.map +1 -0
  29. package/dist/commands/pod/index.js +29 -0
  30. package/dist/commands/pod/index.js.map +1 -0
  31. package/dist/commands/pod/info.d.ts +9 -0
  32. package/dist/commands/pod/info.d.ts.map +1 -0
  33. package/dist/commands/pod/info.js +196 -0
  34. package/dist/commands/pod/info.js.map +1 -0
  35. package/dist/commands/pod/init.d.ts +9 -0
  36. package/dist/commands/pod/init.d.ts.map +1 -0
  37. package/dist/commands/pod/init.js +251 -0
  38. package/dist/commands/pod/init.js.map +1 -0
  39. package/dist/commands/pod/query.d.ts +9 -0
  40. package/dist/commands/pod/query.d.ts.map +1 -0
  41. package/dist/commands/pod/query.js +169 -0
  42. package/dist/commands/pod/query.js.map +1 -0
  43. package/dist/commands/pod 2.js +1017 -0
  44. package/dist/commands/pod.d.ts +28 -0
  45. package/dist/commands/pod.d.ts 2.map +1 -0
  46. package/dist/commands/pod.d.ts.map +1 -0
  47. package/dist/commands/pod.js +1031 -0
  48. package/dist/commands/pod.js 2.map +1 -0
  49. package/dist/commands/pod.js.map +1 -0
  50. package/dist/commands/serve.d.ts +33 -0
  51. package/dist/commands/serve.d.ts.map +1 -0
  52. package/dist/commands/serve.js +74 -0
  53. package/dist/commands/serve.js.map +1 -0
  54. package/dist/commands/validate.d.ts +18 -0
  55. package/dist/commands/validate.d.ts.map +1 -0
  56. package/dist/commands/validate.js +275 -0
  57. package/dist/commands/validate.js.map +1 -0
  58. package/dist/index.d.ts +19 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +49 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts +17 -0
  63. package/dist/lib/fhir-converter/cascade-to-fhir.d.ts.map +1 -0
  64. package/dist/lib/fhir-converter/cascade-to-fhir.js +358 -0
  65. package/dist/lib/fhir-converter/cascade-to-fhir.js.map +1 -0
  66. package/dist/lib/fhir-converter/converters-clinical.d.ts +29 -0
  67. package/dist/lib/fhir-converter/converters-clinical.d.ts.map +1 -0
  68. package/dist/lib/fhir-converter/converters-clinical.js +391 -0
  69. package/dist/lib/fhir-converter/converters-clinical.js.map +1 -0
  70. package/dist/lib/fhir-converter/converters-demographics.d.ts +20 -0
  71. package/dist/lib/fhir-converter/converters-demographics.d.ts.map +1 -0
  72. package/dist/lib/fhir-converter/converters-demographics.js +242 -0
  73. package/dist/lib/fhir-converter/converters-demographics.js.map +1 -0
  74. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts +17 -0
  75. package/dist/lib/fhir-converter/fhir-to-cascade.d.ts.map +1 -0
  76. package/dist/lib/fhir-converter/fhir-to-cascade.js +63 -0
  77. package/dist/lib/fhir-converter/fhir-to-cascade.js.map +1 -0
  78. package/dist/lib/fhir-converter/index.d.ts +36 -0
  79. package/dist/lib/fhir-converter/index.d.ts.map +1 -0
  80. package/dist/lib/fhir-converter/index.js +187 -0
  81. package/dist/lib/fhir-converter/index.js.map +1 -0
  82. package/dist/lib/fhir-converter/types.d.ts +77 -0
  83. package/dist/lib/fhir-converter/types.d.ts.map +1 -0
  84. package/dist/lib/fhir-converter/types.js +236 -0
  85. package/dist/lib/fhir-converter/types.js.map +1 -0
  86. package/dist/lib/fhir-converter.d.ts +62 -0
  87. package/dist/lib/fhir-converter.d.ts.map +1 -0
  88. package/dist/lib/fhir-converter.js +1474 -0
  89. package/dist/lib/fhir-converter.js.map +1 -0
  90. package/dist/lib/mcp/audit.d.ts +24 -0
  91. package/dist/lib/mcp/audit.d.ts.map +1 -0
  92. package/dist/lib/mcp/audit.js +85 -0
  93. package/dist/lib/mcp/audit.js.map +1 -0
  94. package/dist/lib/mcp/server.d.ts +38 -0
  95. package/dist/lib/mcp/server.d.ts.map +1 -0
  96. package/dist/lib/mcp/server.js +172 -0
  97. package/dist/lib/mcp/server.js.map +1 -0
  98. package/dist/lib/mcp/tools.d.ts +47 -0
  99. package/dist/lib/mcp/tools.d.ts.map +1 -0
  100. package/dist/lib/mcp/tools.js +547 -0
  101. package/dist/lib/mcp/tools.js.map +1 -0
  102. package/dist/lib/output.d.ts +26 -0
  103. package/dist/lib/output.d.ts.map +1 -0
  104. package/dist/lib/output.js +64 -0
  105. package/dist/lib/output.js.map +1 -0
  106. package/dist/lib/shacl-validator.d.ts +53 -0
  107. package/dist/lib/shacl-validator.d.ts.map +1 -0
  108. package/dist/lib/shacl-validator.js +245 -0
  109. package/dist/lib/shacl-validator.js.map +1 -0
  110. package/dist/lib/turtle-parser.d.ts +64 -0
  111. package/dist/lib/turtle-parser.d.ts.map +1 -0
  112. package/dist/lib/turtle-parser.js +236 -0
  113. package/dist/lib/turtle-parser.js.map +1 -0
  114. package/dist/shapes/checkup.shapes.ttl +1459 -0
  115. package/dist/shapes/clinical.shapes.ttl +1350 -0
  116. package/dist/shapes/clinical.ttl +1369 -0
  117. package/dist/shapes/core.shapes.ttl +450 -0
  118. package/dist/shapes/core.ttl +603 -0
  119. package/dist/shapes/coverage.shapes.ttl +214 -0
  120. package/dist/shapes/coverage.ttl +182 -0
  121. package/dist/shapes/health.shapes.ttl +697 -0
  122. package/dist/shapes/health.ttl +859 -0
  123. package/dist/shapes/pots.shapes.ttl +481 -0
  124. package/package.json +54 -0
  125. package/src/commands/capabilities.ts +235 -0
  126. package/src/commands/conformance.ts +447 -0
  127. package/src/commands/convert.ts +164 -0
  128. package/src/commands/pod/export.ts +85 -0
  129. package/src/commands/pod/helpers.ts +449 -0
  130. package/src/commands/pod/index.ts +32 -0
  131. package/src/commands/pod/info.ts +239 -0
  132. package/src/commands/pod/init.ts +273 -0
  133. package/src/commands/pod/query.ts +224 -0
  134. package/src/commands/serve.ts +92 -0
  135. package/src/commands/validate.ts +303 -0
  136. package/src/index.ts +58 -0
  137. package/src/lib/fhir-converter/cascade-to-fhir.ts +369 -0
  138. package/src/lib/fhir-converter/converters-clinical.ts +446 -0
  139. package/src/lib/fhir-converter/converters-demographics.ts +270 -0
  140. package/src/lib/fhir-converter/fhir-to-cascade.ts +82 -0
  141. package/src/lib/fhir-converter/index.ts +215 -0
  142. package/src/lib/fhir-converter/types.ts +318 -0
  143. package/src/lib/mcp/audit.ts +107 -0
  144. package/src/lib/mcp/server.ts +192 -0
  145. package/src/lib/mcp/tools.ts +668 -0
  146. package/src/lib/output.ts +76 -0
  147. package/src/lib/shacl-validator.ts +314 -0
  148. package/src/lib/turtle-parser.ts +277 -0
  149. package/src/shapes/checkup.shapes.ttl +1459 -0
  150. package/src/shapes/clinical.shapes.ttl +1350 -0
  151. package/src/shapes/clinical.ttl +1369 -0
  152. package/src/shapes/core.shapes.ttl +450 -0
  153. package/src/shapes/core.ttl +603 -0
  154. package/src/shapes/coverage.shapes.ttl +214 -0
  155. package/src/shapes/coverage.ttl +182 -0
  156. package/src/shapes/health.shapes.ttl +697 -0
  157. package/src/shapes/health.ttl +859 -0
  158. package/src/shapes/pots.shapes.ttl +481 -0
  159. package/test-fixtures/fhir-bundle-example.json +216 -0
  160. package/test-fixtures/fhir-medication-example.json +18 -0
  161. package/tests/cli.test.ts +126 -0
  162. package/tests/fhir-converter.test.ts +874 -0
  163. package/tests/mcp-server.test.ts +396 -0
  164. package/tests/pod.test.ts +400 -0
  165. package/tsconfig.json +24 -0
@@ -0,0 +1,446 @@
1
+ /**
2
+ * FHIR -> Cascade converters for clinical record types.
3
+ *
4
+ * Converts:
5
+ * - MedicationStatement / MedicationRequest -> health:MedicationRecord
6
+ * - Condition -> health:ConditionRecord
7
+ * - AllergyIntolerance -> health:AllergyRecord
8
+ * - Observation (lab) -> health:LabResultRecord
9
+ * - Observation (vital) -> clinical:VitalSign
10
+ */
11
+
12
+ import { randomUUID } from 'node:crypto';
13
+ import type { Quad } from 'n3';
14
+
15
+ import {
16
+ type ConversionResult,
17
+ NS,
18
+ CODING_SYSTEM_MAP,
19
+ VITAL_LOINC_CODES,
20
+ VITAL_CATEGORIES,
21
+ extractCodings,
22
+ codeableConceptText,
23
+ tripleStr,
24
+ tripleBool,
25
+ tripleDouble,
26
+ tripleRef,
27
+ tripleType,
28
+ tripleDateTime,
29
+ commonTriples,
30
+ quadsToJsonLd,
31
+ } from './types.js';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Medication converter
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export function convertMedicationStatement(resource: any): ConversionResult & { _quads: Quad[] } {
38
+ const warnings: string[] = [];
39
+ const subjectUri = `urn:uuid:${randomUUID()}`;
40
+ const quads: Quad[] = [];
41
+
42
+ quads.push(tripleType(subjectUri, NS.health + 'MedicationRecord'));
43
+ quads.push(...commonTriples(subjectUri));
44
+
45
+ // Medication name
46
+ const medName = codeableConceptText(resource.medicationCodeableConcept)
47
+ ?? resource.medicationReference?.display
48
+ ?? 'Unknown Medication';
49
+ quads.push(tripleStr(subjectUri, NS.health + 'medicationName', medName));
50
+
51
+ // isActive from FHIR status
52
+ const status = resource.status as string | undefined;
53
+ const isActive = status === 'active' || status === 'intended' || status === 'on-hold';
54
+ quads.push(tripleBool(subjectUri, NS.health + 'isActive', isActive));
55
+
56
+ // Drug codes
57
+ const codings = extractCodings(resource.medicationCodeableConcept);
58
+ for (const coding of codings) {
59
+ const nsUri = CODING_SYSTEM_MAP[coding.system];
60
+ if (nsUri) {
61
+ quads.push(tripleRef(subjectUri, NS.clinical + 'drugCode', nsUri + coding.code));
62
+ if (nsUri === NS.rxnorm) {
63
+ quads.push(tripleRef(subjectUri, NS.health + 'rxNormCode', nsUri + coding.code));
64
+ }
65
+ } else {
66
+ warnings.push(`Unknown coding system: ${coding.system} (code ${coding.code})`);
67
+ }
68
+ }
69
+
70
+ // Dosage
71
+ const dosage = Array.isArray(resource.dosage) ? resource.dosage[0] : undefined;
72
+ if (dosage) {
73
+ if (dosage.text) {
74
+ quads.push(tripleStr(subjectUri, NS.health + 'dose', dosage.text));
75
+ }
76
+ if (dosage.route?.text) {
77
+ quads.push(tripleStr(subjectUri, NS.health + 'route', dosage.route.text));
78
+ } else if (dosage.route?.coding?.[0]?.display) {
79
+ quads.push(tripleStr(subjectUri, NS.health + 'route', dosage.route.coding[0].display));
80
+ }
81
+ if (dosage.timing?.repeat?.frequency) {
82
+ const freq = dosage.timing.repeat.frequency;
83
+ const periodUnit = dosage.timing.repeat.periodUnit ?? 'd';
84
+ const unitLabel = periodUnit === 'd' ? 'daily' : periodUnit === 'wk' ? 'weekly' : periodUnit;
85
+ const freqText = freq === 1 ? `once ${unitLabel}` : `${freq} times ${unitLabel}`;
86
+ quads.push(tripleStr(subjectUri, NS.health + 'frequency', freqText));
87
+ }
88
+ }
89
+
90
+ // Effective period
91
+ if (resource.effectivePeriod?.start) {
92
+ quads.push(tripleDateTime(subjectUri, NS.health + 'startDate', resource.effectivePeriod.start));
93
+ } else if (resource.effectiveDateTime) {
94
+ quads.push(tripleDateTime(subjectUri, NS.health + 'startDate', resource.effectiveDateTime));
95
+ }
96
+ if (resource.effectivePeriod?.end) {
97
+ quads.push(tripleDateTime(subjectUri, NS.health + 'endDate', resource.effectivePeriod.end));
98
+ }
99
+
100
+ // Provenance class -- based on resource type
101
+ const fhirResourceType = resource.resourceType as string;
102
+ if (fhirResourceType === 'MedicationStatement') {
103
+ quads.push(tripleStr(subjectUri, NS.clinical + 'sourceFhirResourceType', 'MedicationStatement'));
104
+ quads.push(tripleStr(subjectUri, NS.clinical + 'clinicalIntent', 'reportedUse'));
105
+ } else if (fhirResourceType === 'MedicationRequest') {
106
+ quads.push(tripleStr(subjectUri, NS.clinical + 'sourceFhirResourceType', 'MedicationRequest'));
107
+ quads.push(tripleStr(subjectUri, NS.clinical + 'clinicalIntent', 'prescribed'));
108
+ }
109
+ quads.push(tripleStr(subjectUri, NS.clinical + 'provenanceClass', 'imported'));
110
+
111
+ if (resource.id) {
112
+ quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
113
+ }
114
+
115
+ if (resource.note && Array.isArray(resource.note)) {
116
+ const noteText = resource.note.map((n: any) => n.text).filter(Boolean).join('; ');
117
+ if (noteText) quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
118
+ }
119
+
120
+ return {
121
+ turtle: '',
122
+ warnings,
123
+ resourceType: fhirResourceType,
124
+ cascadeType: 'health:MedicationRecord',
125
+ jsonld: quadsToJsonLd(quads, 'health:MedicationRecord'),
126
+ _quads: quads,
127
+ } as ConversionResult & { _quads: Quad[] };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Condition converter
132
+ // ---------------------------------------------------------------------------
133
+
134
+ export function convertCondition(resource: any): ConversionResult & { _quads: Quad[] } {
135
+ const warnings: string[] = [];
136
+ const subjectUri = `urn:uuid:${randomUUID()}`;
137
+ const quads: Quad[] = [];
138
+
139
+ quads.push(tripleType(subjectUri, NS.health + 'ConditionRecord'));
140
+ quads.push(...commonTriples(subjectUri));
141
+
142
+ const condName = codeableConceptText(resource.code) ?? 'Unknown Condition';
143
+ quads.push(tripleStr(subjectUri, NS.health + 'conditionName', condName));
144
+
145
+ const clinicalStatus = resource.clinicalStatus?.coding?.[0]?.code ?? 'active';
146
+ quads.push(tripleStr(subjectUri, NS.health + 'status', clinicalStatus));
147
+
148
+ if (resource.onsetDateTime) {
149
+ quads.push(tripleDateTime(subjectUri, NS.health + 'onsetDate', resource.onsetDateTime));
150
+ } else if (resource.onsetPeriod?.start) {
151
+ quads.push(tripleDateTime(subjectUri, NS.health + 'onsetDate', resource.onsetPeriod.start));
152
+ }
153
+
154
+ if (resource.abatementDateTime) {
155
+ quads.push(tripleDateTime(subjectUri, NS.health + 'abatementDate', resource.abatementDateTime));
156
+ }
157
+
158
+ const codings = extractCodings(resource.code);
159
+ for (const coding of codings) {
160
+ const nsUri = CODING_SYSTEM_MAP[coding.system];
161
+ if (nsUri === NS.icd10) {
162
+ quads.push(tripleRef(subjectUri, NS.health + 'icd10Code', nsUri + coding.code));
163
+ } else if (nsUri === NS.sct) {
164
+ quads.push(tripleRef(subjectUri, NS.health + 'snomedCode', nsUri + coding.code));
165
+ } else if (nsUri) {
166
+ warnings.push(`Condition code from non-standard system: ${coding.system}`);
167
+ }
168
+ }
169
+
170
+ if (resource.id) {
171
+ quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
172
+ }
173
+
174
+ if (resource.note && Array.isArray(resource.note)) {
175
+ const noteText = resource.note.map((n: any) => n.text).filter(Boolean).join('; ');
176
+ if (noteText) quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
177
+ }
178
+
179
+ return {
180
+ turtle: '',
181
+ jsonld: quadsToJsonLd(quads, 'health:ConditionRecord'),
182
+ warnings,
183
+ resourceType: 'Condition',
184
+ cascadeType: 'health:ConditionRecord',
185
+ _quads: quads,
186
+ };
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // AllergyIntolerance converter
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export function convertAllergyIntolerance(resource: any): ConversionResult & { _quads: Quad[] } {
194
+ const warnings: string[] = [];
195
+ const subjectUri = `urn:uuid:${randomUUID()}`;
196
+ const quads: Quad[] = [];
197
+
198
+ quads.push(tripleType(subjectUri, NS.health + 'AllergyRecord'));
199
+ quads.push(...commonTriples(subjectUri));
200
+
201
+ const allergen = codeableConceptText(resource.code) ?? 'Unknown Allergen';
202
+ quads.push(tripleStr(subjectUri, NS.health + 'allergen', allergen));
203
+
204
+ if (Array.isArray(resource.category) && resource.category.length > 0) {
205
+ quads.push(tripleStr(subjectUri, NS.health + 'allergyCategory', resource.category[0]));
206
+ }
207
+
208
+ if (Array.isArray(resource.reaction) && resource.reaction.length > 0) {
209
+ const manifestations = resource.reaction
210
+ .flatMap((r: any) => r.manifestation ?? [])
211
+ .map((m: any) => codeableConceptText(m))
212
+ .filter(Boolean);
213
+ if (manifestations.length > 0) {
214
+ quads.push(tripleStr(subjectUri, NS.health + 'reaction', manifestations.join(', ')));
215
+ }
216
+ const severity = resource.reaction[0]?.severity;
217
+ if (severity) {
218
+ const severityMap: Record<string, string> = { mild: 'mild', moderate: 'moderate', severe: 'severe' };
219
+ quads.push(tripleStr(subjectUri, NS.health + 'allergySeverity', severityMap[severity] ?? severity));
220
+ }
221
+ }
222
+
223
+ if (resource.criticality && !(Array.isArray(resource.reaction) && resource.reaction[0]?.severity)) {
224
+ const critMap: Record<string, string> = { low: 'mild', high: 'severe', 'unable-to-assess': 'moderate' };
225
+ quads.push(tripleStr(subjectUri, NS.health + 'allergySeverity', critMap[resource.criticality] ?? resource.criticality));
226
+ }
227
+
228
+ if (resource.onsetDateTime) {
229
+ quads.push(tripleDateTime(subjectUri, NS.health + 'onsetDate', resource.onsetDateTime));
230
+ }
231
+
232
+ if (resource.id) {
233
+ quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
234
+ }
235
+
236
+ if (resource.note && Array.isArray(resource.note)) {
237
+ const noteText = resource.note.map((n: any) => n.text).filter(Boolean).join('; ');
238
+ if (noteText) quads.push(tripleStr(subjectUri, NS.health + 'notes', noteText));
239
+ }
240
+
241
+ return {
242
+ turtle: '',
243
+ jsonld: quadsToJsonLd(quads, 'health:AllergyRecord'),
244
+ warnings,
245
+ resourceType: 'AllergyIntolerance',
246
+ cascadeType: 'health:AllergyRecord',
247
+ _quads: quads,
248
+ };
249
+ }
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // Observation: vital sign detection
253
+ // ---------------------------------------------------------------------------
254
+
255
+ export function isVitalSignObservation(resource: any): boolean {
256
+ if (Array.isArray(resource.category)) {
257
+ for (const cat of resource.category) {
258
+ if (Array.isArray(cat.coding)) {
259
+ for (const c of cat.coding) {
260
+ if (VITAL_CATEGORIES.includes(c.code)) return true;
261
+ }
262
+ }
263
+ }
264
+ }
265
+ const codings = extractCodings(resource.code);
266
+ for (const c of codings) {
267
+ if (c.system === 'http://loinc.org' && VITAL_LOINC_CODES[c.code]) return true;
268
+ }
269
+ return false;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Observation (lab) converter
274
+ // ---------------------------------------------------------------------------
275
+
276
+ export function convertObservationLab(resource: any): ConversionResult & { _quads: Quad[] } {
277
+ const warnings: string[] = [];
278
+ const subjectUri = `urn:uuid:${randomUUID()}`;
279
+ const quads: Quad[] = [];
280
+
281
+ quads.push(tripleType(subjectUri, NS.health + 'LabResultRecord'));
282
+ quads.push(...commonTriples(subjectUri));
283
+
284
+ const testName = codeableConceptText(resource.code) ?? 'Unknown Lab Test';
285
+ quads.push(tripleStr(subjectUri, NS.health + 'testName', testName));
286
+
287
+ if (resource.valueQuantity) {
288
+ quads.push(tripleStr(subjectUri, NS.health + 'resultValue', String(resource.valueQuantity.value)));
289
+ if (resource.valueQuantity.unit) {
290
+ quads.push(tripleStr(subjectUri, NS.health + 'resultUnit', resource.valueQuantity.unit));
291
+ }
292
+ } else if (resource.valueString) {
293
+ quads.push(tripleStr(subjectUri, NS.health + 'resultValue', resource.valueString));
294
+ } else if (resource.valueCodeableConcept) {
295
+ const valText = codeableConceptText(resource.valueCodeableConcept) ?? '';
296
+ quads.push(tripleStr(subjectUri, NS.health + 'resultValue', valText));
297
+ } else {
298
+ quads.push(tripleStr(subjectUri, NS.health + 'resultValue', ''));
299
+ warnings.push('No result value found in Observation resource');
300
+ }
301
+
302
+ if (resource.interpretation && Array.isArray(resource.interpretation) && resource.interpretation.length > 0) {
303
+ const interpCode = resource.interpretation[0]?.coding?.[0]?.code ?? 'unknown';
304
+ const interpMap: Record<string, string> = {
305
+ N: 'normal', H: 'abnormal', L: 'abnormal', A: 'abnormal',
306
+ HH: 'critical', LL: 'critical', AA: 'critical',
307
+ HU: 'critical', LU: 'critical',
308
+ };
309
+ quads.push(tripleStr(subjectUri, NS.health + 'interpretation', interpMap[interpCode] ?? 'unknown'));
310
+ } else {
311
+ quads.push(tripleStr(subjectUri, NS.health + 'interpretation', 'unknown'));
312
+ }
313
+
314
+ const effectiveDate = resource.effectiveDateTime ?? resource.effectivePeriod?.start ?? resource.issued;
315
+ if (effectiveDate) {
316
+ quads.push(tripleDateTime(subjectUri, NS.health + 'performedDate', effectiveDate));
317
+ } else {
318
+ warnings.push('No effective date found in Observation resource');
319
+ }
320
+
321
+ const codings = extractCodings(resource.code);
322
+ for (const c of codings) {
323
+ if (c.system === 'http://loinc.org') {
324
+ quads.push(tripleRef(subjectUri, NS.health + 'testCode', NS.loinc + c.code));
325
+ }
326
+ }
327
+
328
+ if (Array.isArray(resource.category)) {
329
+ for (const cat of resource.category) {
330
+ if (Array.isArray(cat.coding)) {
331
+ for (const c of cat.coding) {
332
+ if (c.code && c.code !== 'laboratory') {
333
+ quads.push(tripleStr(subjectUri, NS.health + 'labCategory', c.code));
334
+ }
335
+ }
336
+ }
337
+ if (cat.text) {
338
+ quads.push(tripleStr(subjectUri, NS.health + 'labCategory', cat.text));
339
+ }
340
+ }
341
+ }
342
+
343
+ if (Array.isArray(resource.referenceRange) && resource.referenceRange.length > 0) {
344
+ const rr = resource.referenceRange[0];
345
+ const parts: string[] = [];
346
+ if (rr.low?.value !== undefined) parts.push(String(rr.low.value));
347
+ if (rr.high?.value !== undefined) parts.push(String(rr.high.value));
348
+ const unit = rr.low?.unit ?? rr.high?.unit ?? '';
349
+ if (parts.length === 2) {
350
+ quads.push(tripleStr(subjectUri, NS.health + 'referenceRange', `${parts[0]}-${parts[1]} ${unit}`.trim()));
351
+ } else if (rr.text) {
352
+ quads.push(tripleStr(subjectUri, NS.health + 'referenceRange', rr.text));
353
+ }
354
+ }
355
+
356
+ if (resource.id) {
357
+ quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
358
+ }
359
+
360
+ return {
361
+ turtle: '',
362
+ jsonld: quadsToJsonLd(quads, 'health:LabResultRecord'),
363
+ warnings,
364
+ resourceType: 'Observation',
365
+ cascadeType: 'health:LabResultRecord',
366
+ _quads: quads,
367
+ };
368
+ }
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // Observation (vital sign) converter
372
+ // ---------------------------------------------------------------------------
373
+
374
+ export function convertObservationVital(resource: any): ConversionResult & { _quads: Quad[] } {
375
+ const warnings: string[] = [];
376
+ const subjectUri = `urn:uuid:${randomUUID()}`;
377
+ const quads: Quad[] = [];
378
+
379
+ quads.push(tripleType(subjectUri, NS.clinical + 'VitalSign'));
380
+ quads.push(...commonTriples(subjectUri));
381
+
382
+ const codings = extractCodings(resource.code);
383
+ let vitalInfo: { type: string; name: string; unit: string; snomedCode: string } | undefined;
384
+ for (const c of codings) {
385
+ if (c.system === 'http://loinc.org' && VITAL_LOINC_CODES[c.code]) {
386
+ vitalInfo = VITAL_LOINC_CODES[c.code];
387
+ quads.push(tripleRef(subjectUri, NS.clinical + 'loincCode', NS.loinc + c.code));
388
+ break;
389
+ }
390
+ }
391
+
392
+ if (vitalInfo) {
393
+ quads.push(tripleStr(subjectUri, NS.clinical + 'vitalType', vitalInfo.type));
394
+ quads.push(tripleStr(subjectUri, NS.clinical + 'vitalTypeName', vitalInfo.name));
395
+ quads.push(tripleRef(subjectUri, NS.clinical + 'snomedCode', NS.sct + vitalInfo.snomedCode));
396
+ } else {
397
+ const name = codeableConceptText(resource.code) ?? 'Unknown Vital';
398
+ quads.push(tripleStr(subjectUri, NS.clinical + 'vitalType', name.toLowerCase().replace(/\s+/g, '_')));
399
+ quads.push(tripleStr(subjectUri, NS.clinical + 'vitalTypeName', name));
400
+ warnings.push(`Unknown vital sign LOINC code -- using display name: ${name}`);
401
+ }
402
+
403
+ if (resource.valueQuantity) {
404
+ quads.push(tripleDouble(subjectUri, NS.clinical + 'value', resource.valueQuantity.value));
405
+ quads.push(tripleStr(subjectUri, NS.clinical + 'unit', resource.valueQuantity.unit ?? vitalInfo?.unit ?? ''));
406
+ } else {
407
+ warnings.push('No valueQuantity found in vital sign Observation');
408
+ }
409
+
410
+ const effectiveDate = resource.effectiveDateTime ?? resource.effectivePeriod?.start;
411
+ if (effectiveDate) {
412
+ quads.push(tripleDateTime(subjectUri, NS.clinical + 'effectiveDate', effectiveDate));
413
+ }
414
+
415
+ if (Array.isArray(resource.referenceRange) && resource.referenceRange.length > 0) {
416
+ const rr = resource.referenceRange[0];
417
+ if (rr.low?.value !== undefined) {
418
+ quads.push(tripleDouble(subjectUri, NS.clinical + 'referenceRangeLow', rr.low.value));
419
+ }
420
+ if (rr.high?.value !== undefined) {
421
+ quads.push(tripleDouble(subjectUri, NS.clinical + 'referenceRangeHigh', rr.high.value));
422
+ }
423
+ }
424
+
425
+ if (resource.interpretation && Array.isArray(resource.interpretation) && resource.interpretation.length > 0) {
426
+ const interpCode = resource.interpretation[0]?.coding?.[0]?.code ?? 'unknown';
427
+ const interpMap: Record<string, string> = {
428
+ N: 'normal', H: 'high', L: 'low', A: 'abnormal',
429
+ HH: 'critical', LL: 'critical',
430
+ };
431
+ quads.push(tripleStr(subjectUri, NS.clinical + 'interpretation', interpMap[interpCode] ?? interpCode));
432
+ }
433
+
434
+ if (resource.id) {
435
+ quads.push(tripleStr(subjectUri, NS.health + 'sourceRecordId', resource.id));
436
+ }
437
+
438
+ return {
439
+ turtle: '',
440
+ jsonld: quadsToJsonLd(quads, 'clinical:VitalSign'),
441
+ warnings,
442
+ resourceType: 'Observation',
443
+ cascadeType: 'clinical:VitalSign',
444
+ _quads: quads,
445
+ };
446
+ }