Skip to content

Commit aa8aff7

Browse files
committed
Add AI provenance to assisted entry saves
1 parent e334bdd commit aa8aff7

4 files changed

Lines changed: 202 additions & 83 deletions

File tree

src/app/benefits-demo/ai-assisted-entry/ai-assisted-entry.component.ts

Lines changed: 54 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -278,90 +278,66 @@ export class AiAssistedEntryComponent implements OnInit, OnDestroy {
278278
}) || null;
279279
}
280280
}
281-
282-
if (this.patientService.getCurrentPersistenceMode() === 'fhir') {
283-
const transactionResult = await this.patientService.saveAiAssistedEntryTransaction(this.patient.id, {
284-
encounter,
285-
conditions: conditionsToSave,
286-
procedures: procedureToSave ? [procedureToSave] : [],
287-
medications: medicationsToSave,
288-
allergies: []
289-
});
290-
291-
if (existingProcedureToLink && transactionResult.encounter) {
292-
existingProcedureToLink.encounter = {
293-
reference: `Encounter/${transactionResult.encounter.id}`,
294-
display: `Encounter ${transactionResult.encounter.id}`
295-
};
296-
this.patientService.updatePatientProcedure(this.patient.id, existingProcedureToLink.id, existingProcedureToLink);
297-
298-
this.snackBar.open(
299-
`Procedure "${this.selectedProcedure?.name}" already existed and was linked to the new encounter.`,
300-
'Close',
301-
{
302-
duration: 4000,
303-
horizontalPosition: 'center',
304-
verticalPosition: 'top',
305-
panelClass: ['info-snackbar']
306-
}
307-
);
308-
} else if (this.selectedProcedure && !procedureToSave && !existingProcedureToLink) {
309-
this.snackBar.open(
310-
`Procedure "${this.selectedProcedure.name}" already exists for this patient.`,
311-
'Close',
312-
{
313-
duration: 4000,
314-
horizontalPosition: 'center',
315-
verticalPosition: 'top',
316-
panelClass: ['warning-snackbar']
317-
}
318-
);
319-
}
320-
321-
this.transactionSaved.emit(transactionResult);
322-
} else {
323-
conditionsToSave.forEach(condition => this.conditionAdded.emit(condition));
324-
325-
if (procedureToSave) {
326-
this.procedureAdded.emit(procedureToSave);
327-
} else if (existingProcedureToLink && encounter) {
328-
existingProcedureToLink.encounter = {
329-
reference: `Encounter/${encounter.id}`,
330-
display: `Encounter ${encounter.id}`
331-
};
332-
this.patientService.updatePatientProcedure(this.patient!.id, existingProcedureToLink.id, existingProcedureToLink);
333-
334-
this.snackBar.open(
335-
`Procedure "${this.selectedProcedure?.name}" already exists and has been linked to this encounter.`,
336-
'Close',
281+
const aiDerivedProvenance = encounter
282+
? [
283+
...conditionsToSave,
284+
...(procedureToSave ? [procedureToSave] : []),
285+
...medicationsToSave
286+
].map((resource: any) => this.patientService.createAiDerivedProvenance(
287+
this.patient!.id,
288+
resource,
337289
{
338-
duration: 4000,
339-
horizontalPosition: 'center',
340-
verticalPosition: 'top',
341-
panelClass: ['info-snackbar']
290+
reference: `Encounter/${encounter.id}`,
291+
display: encounter.reasonCode?.[0]?.text || `Encounter ${encounter.id}`
342292
}
343-
);
344-
} else if (this.selectedProcedure) {
345-
this.snackBar.open(
346-
`Procedure "${this.selectedProcedure.name}" already exists for this patient (duplicate SNOMED CT code detected).`,
347-
'Close',
348-
{
349-
duration: 4000,
350-
horizontalPosition: 'center',
351-
verticalPosition: 'top',
352-
panelClass: ['warning-snackbar']
353-
}
354-
);
355-
}
293+
))
294+
: [];
295+
296+
const transactionResult = await this.patientService.saveAiAssistedEntryTransaction(this.patient.id, {
297+
encounter,
298+
conditions: conditionsToSave,
299+
procedures: procedureToSave ? [procedureToSave] : [],
300+
medications: medicationsToSave,
301+
allergies: [],
302+
provenance: aiDerivedProvenance
303+
});
356304

357-
medicationsToSave.forEach(medication => this.medicationAdded.emit(medication));
305+
if (existingProcedureToLink && transactionResult.encounter) {
306+
existingProcedureToLink.encounter = {
307+
reference: `Encounter/${transactionResult.encounter.id}`,
308+
display: `Encounter ${transactionResult.encounter.id}`
309+
};
310+
this.patientService.updatePatientProcedure(this.patient.id, existingProcedureToLink.id, existingProcedureToLink);
358311

359-
if (encounter) {
360-
this.encounterAdded.emit(encounter);
361-
}
312+
this.snackBar.open(
313+
`Procedure "${this.selectedProcedure?.name}" already existed and was linked to the new encounter.`,
314+
'Close',
315+
{
316+
duration: 4000,
317+
horizontalPosition: 'center',
318+
verticalPosition: 'top',
319+
panelClass: ['info-snackbar']
320+
}
321+
);
322+
} else if (this.selectedProcedure && !procedureToSave && !existingProcedureToLink) {
323+
this.snackBar.open(
324+
`Procedure "${this.selectedProcedure.name}" already exists for this patient.`,
325+
'Close',
326+
{
327+
duration: 4000,
328+
horizontalPosition: 'center',
329+
verticalPosition: 'top',
330+
panelClass: ['warning-snackbar']
331+
}
332+
);
362333
}
363334

364-
const totalSaved = conditionsToSave.length + medicationsToSave.length + (procedureToSave ? 1 : 0) + (encounter ? 1 : 0);
335+
this.transactionSaved.emit(transactionResult);
336+
337+
const totalSaved = transactionResult.conditions.length
338+
+ transactionResult.medications.length
339+
+ transactionResult.procedures.length
340+
+ (transactionResult.encounter ? 1 : 0);
365341
if (totalSaved > 0) {
366342
this.snackBar.open(
367343
`Successfully saved ${totalSaved} clinical resources.`,

src/app/benefits-demo/clinical-timeline/clinical-timeline.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ <h4>Overview</h4>
2525
@if (sortedEvents.length > 0) {
2626
<div class="vertical-timeline">
2727
<div class="timeline-line"></div>
28-
@for (event of sortedEvents; track event; let i = $index) {
28+
@for (event of sortedEvents; track event.type + ':' + event.id; let i = $index) {
2929
<div class="timeline-item"
3030
[class.left]="i % 2 === 0"
3131
[class.right]="i % 2 === 1"

src/app/services/patient-fhir-storage.service.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,9 @@ export class PatientFhirStorageService implements PatientStorageBackend {
503503
const encounterFullUrl = payload.encounter ? this.createTransactionFullUrl('encounter') : null;
504504
const conditionReferenceMap = new Map<string, string>();
505505
const resourceReferenceMap = new Map<string, string>();
506+
if (payload.encounter?.id && encounterFullUrl) {
507+
resourceReferenceMap.set(`Encounter/${payload.encounter.id}`, encounterFullUrl);
508+
}
506509
const conditionEntries = payload.conditions.map((condition) => {
507510
const fullUrl = this.createTransactionFullUrl('condition');
508511
if (condition.id) {
@@ -790,6 +793,13 @@ export class PatientFhirStorageService implements PatientStorageBackend {
790793
reference: targetReferenceMap.get(target.reference) || (target.reference === provenanceWithoutId.patient?.reference
791794
? patientReference
792795
: target.reference)
796+
})),
797+
entity: (provenanceWithoutId.entity || []).map((entity) => ({
798+
...entity,
799+
what: {
800+
...entity.what,
801+
reference: entity.what.reference ? (targetReferenceMap.get(entity.what.reference) || entity.what.reference) : entity.what.reference
802+
}
793803
}))
794804
};
795805
}

src/app/services/patient.service.ts

Lines changed: 137 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export class PatientService {
4747
public static readonly ICD10_SYSTEM = 'http://hl7.org/fhir/sid/icd-10';
4848
private static readonly EHR_LAB_LOCATION_SYSTEM = 'http://ehr-lab.demo/location';
4949
private static readonly PERSISTENCE_MODE_STORAGE_KEY = 'ehr_persistence_mode';
50+
private static readonly AI_PROVENANCE_TECHNICAL_AGENT_DISPLAY = 'AI/NLP pipeline';
51+
private static readonly AI_PROVENANCE_HUMAN_REVIEWER_DISPLAY = 'Human reviewer';
5052
private readonly FHIR_PATIENT_PAGE_SIZE = 20;
5153
private readonly ANATOMICAL_ANCHOR_POINTS: Array<{ id: string; ancestors: string[] }> = [
5254
{
@@ -2121,6 +2123,81 @@ export class PatientService {
21212123
};
21222124
}
21232125

2126+
createAiDerivedProvenance(
2127+
patientId: string,
2128+
targetResource: { resourceType: string; id: string; code?: { text?: string }; medicationCodeableConcept?: { text?: string } },
2129+
sourceReference: { reference: string; display?: string },
2130+
recorded: string = new Date().toISOString()
2131+
): Provenance {
2132+
const targetDisplay = targetResource.code?.text
2133+
|| targetResource.medicationCodeableConcept?.text
2134+
|| `${targetResource.resourceType} ${targetResource.id}`;
2135+
2136+
return {
2137+
resourceType: 'Provenance',
2138+
id: `provenance-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
2139+
recorded,
2140+
patient: {
2141+
reference: `Patient/${patientId}`,
2142+
display: `Patient ${patientId}`
2143+
},
2144+
activity: {
2145+
text: 'AI-derived clinical entity under active human review'
2146+
},
2147+
target: [
2148+
{
2149+
reference: `${targetResource.resourceType}/${targetResource.id}`,
2150+
display: targetDisplay
2151+
},
2152+
{
2153+
reference: `Patient/${patientId}`,
2154+
display: `Patient ${patientId}`
2155+
}
2156+
],
2157+
agent: [
2158+
{
2159+
type: {
2160+
text: 'assembler'
2161+
},
2162+
role: [
2163+
{
2164+
text: 'technical agent'
2165+
}
2166+
],
2167+
who: {
2168+
display: PatientService.AI_PROVENANCE_TECHNICAL_AGENT_DISPLAY
2169+
}
2170+
},
2171+
{
2172+
type: {
2173+
text: 'author'
2174+
},
2175+
role: [
2176+
{
2177+
text: 'human reviewer'
2178+
}
2179+
],
2180+
who: {
2181+
display: PatientService.AI_PROVENANCE_HUMAN_REVIEWER_DISPLAY
2182+
}
2183+
}
2184+
],
2185+
entity: [
2186+
{
2187+
role: 'derivation',
2188+
what: {
2189+
reference: sourceReference.reference,
2190+
display: sourceReference.display
2191+
}
2192+
}
2193+
],
2194+
text: {
2195+
status: 'generated',
2196+
div: '<div xmlns="http://www.w3.org/1999/xhtml"><p>Derived by AI from persisted source text and approved under active human supervision.</p></div>'
2197+
}
2198+
};
2199+
}
2200+
21242201
private normalizeAllergyForPersistence(allergy: AllergyIntolerance): AllergyIntolerance {
21252202
const normalizedCategories = Array.isArray(allergy.category)
21262203
? allergy.category
@@ -2687,16 +2764,72 @@ export class PatientService {
26872764
patientId: string,
26882765
payload: AiAssistedEntryTransactionPayload
26892766
): Promise<AiAssistedEntryTransactionResult> {
2690-
if (this.getCurrentPersistenceMode() !== 'fhir') {
2691-
throw new Error('AI-assisted entry transactions are only available in FHIR mode.');
2692-
}
2693-
26942767
await Promise.all([
26952768
...payload.conditions.map((condition) => this.enrichCondition(condition)),
26962769
...payload.procedures.map((procedure) => this.enrichProcedure(procedure)),
26972770
...payload.medications.map((medication) => this.enrichMedication(patientId, medication))
26982771
]);
26992772

2773+
if (this.getCurrentPersistenceMode() !== 'fhir') {
2774+
const savedEncounter = payload.encounter && this.addPatientEncounter(patientId, payload.encounter)
2775+
? payload.encounter
2776+
: null;
2777+
const savedConditions: Condition[] = [];
2778+
const savedProcedures: Procedure[] = [];
2779+
const savedMedications: MedicationStatement[] = [];
2780+
const savedAllergies: AllergyIntolerance[] = [];
2781+
2782+
for (const condition of payload.conditions) {
2783+
if (this.addPatientCondition(patientId, condition)) {
2784+
savedConditions.push(condition);
2785+
}
2786+
}
2787+
2788+
for (const procedure of payload.procedures) {
2789+
if (this.addPatientProcedure(patientId, procedure)) {
2790+
savedProcedures.push(procedure);
2791+
}
2792+
}
2793+
2794+
for (const medication of payload.medications) {
2795+
if (this.addPatientMedication(patientId, medication)) {
2796+
savedMedications.push(medication);
2797+
}
2798+
}
2799+
2800+
for (const allergy of payload.allergies) {
2801+
if (this.addPatientAllergy(patientId, allergy)) {
2802+
savedAllergies.push(allergy);
2803+
}
2804+
}
2805+
2806+
const savedTargetReferences = new Set<string>([
2807+
...savedConditions.map((resource) => `Condition/${resource.id}`),
2808+
...savedProcedures.map((resource) => `Procedure/${resource.id}`),
2809+
...savedMedications.map((resource) => `MedicationStatement/${resource.id}`),
2810+
...savedAllergies.map((resource) => `AllergyIntolerance/${resource.id}`)
2811+
]);
2812+
2813+
const savedProvenance: Provenance[] = [];
2814+
for (const provenance of payload.provenance || []) {
2815+
const hasSavedTarget = (provenance.target || []).some((target) => savedTargetReferences.has(target.reference));
2816+
if (!hasSavedTarget) {
2817+
continue;
2818+
}
2819+
savedProvenance.push(await this.createPatientProvenance(patientId, provenance));
2820+
}
2821+
2822+
this.notifyPatientDataChanged(patientId);
2823+
return {
2824+
encounter: savedEncounter,
2825+
conditions: savedConditions,
2826+
procedures: savedProcedures,
2827+
medications: savedMedications,
2828+
allergies: savedAllergies,
2829+
provenance: savedProvenance
2830+
};
2831+
}
2832+
27002833
const result = await this.patientFhirStorageService.saveAiAssistedEntryTransaction(patientId, payload);
27012834

27022835
this.setResourceCache(

0 commit comments

Comments
 (0)