generate basic IPS summary bundle. Still missing lots of important functionality.

This commit is contained in:
Jason Kulatunga 2024-02-21 14:16:20 -08:00
parent 21d12dd312
commit bf446a0e6e
No known key found for this signature in database
5 changed files with 395 additions and 68 deletions

View File

@ -61,7 +61,7 @@ const (
IPSSectionsMedicalDevices IPSSections = "medical_devices" IPSSectionsMedicalDevices IPSSections = "medical_devices"
IPSSectionsDiagnosticResults IPSSections = "diagnostic_results" IPSSectionsDiagnosticResults IPSSections = "diagnostic_results"
IPSSectionsVitalSigns IPSSections = "vital_signs" IPSSectionsVitalSigns IPSSections = "vital_signs"
IPSSectionsHistoryOfIllnesses IPSSections = "history_of_illnesses" IPSSectionsHistoryOfIllness IPSSections = "history_of_illness"
IPSSectionsPregnancy IPSSections = "pregnancy" IPSSectionsPregnancy IPSSections = "pregnancy"
IPSSectionsSocialHistory IPSSections = "social_history" IPSSectionsSocialHistory IPSSections = "social_history"
IPSSectionsPlanOfCare IPSSections = "plan_of_care" IPSSectionsPlanOfCare IPSSections = "plan_of_care"
@ -78,7 +78,7 @@ var IPSSectionsList = []IPSSections{
IPSSectionsMedicalDevices, IPSSectionsMedicalDevices,
IPSSectionsDiagnosticResults, IPSSectionsDiagnosticResults,
IPSSectionsVitalSigns, IPSSectionsVitalSigns,
IPSSectionsHistoryOfIllnesses, IPSSectionsHistoryOfIllness,
IPSSectionsPregnancy, IPSSectionsPregnancy,
IPSSectionsSocialHistory, IPSSectionsSocialHistory,
IPSSectionsPlanOfCare, IPSSectionsPlanOfCare,

View File

@ -2,85 +2,234 @@ package database
import ( import (
"context" "context"
"encoding/json"
"fmt"
"github.com/fastenhealth/fasten-onprem/backend/pkg" "github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models" "github.com/fastenhealth/fasten-onprem/backend/pkg/models"
databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database" "github.com/fastenhealth/gofhir-models/fhir401"
"github.com/google/uuid"
"log"
"time"
) )
func (gr *GormRepository) GetInternationalPatientSummaryBundle(ctx context.Context) (interface{}, error) {
summaryTime := time.Now()
timestamp := summaryTime.Format(time.RFC3339)
//Algorithm to create the IPS bundle
// 1. Generate the IPS Section Lists (GetInternationalPatientSummarySectionResources)
// - Process each resource, generating a Markdown narrative in the text field for each resource
// - keep track of the earliest and latest date of the resources in the section
// 2. Create the Composition Section (generateIPSCompositionSection)
// - Populate it with the data from the Header
// - Generate a Section Narrative, written in Markdown, which summarizes the contents of the section at a high level, with dates and counts
// 3. Query all the Patient Resources
// - Merge the Patient resources together?
// 4. Create a Fasten Health Organization resource. This is the custodian of the IPS
// 5. Create the IPS Composition
// - Populate it with the Composition Sections and references to the Patient resource + Fasten Health Organziation resource
// - Generate a Composition Narrative, written in Markdown, which summarizes the contents of the IPS & Patient at a high level, with dates and counts
// 6. Create the IPS Bundle
//Step 1. Generate the IPS Section Lists
summarySectionResources, err := gr.GetInternationalPatientSummarySectionResources(ctx)
if err != nil {
return nil, err
}
//Step 2. Create the Composition Section
compositionSections := []fhir401.CompositionSection{}
for sectionType, sectionResources := range summarySectionResources {
compositionSection, err := generateIPSCompositionSection(sectionType, sectionResources)
if err != nil {
return nil, err
}
compositionSections = append(compositionSections, *compositionSection)
}
//TODO: Step 3. Query all the Patient Resources & merge them together
//TODO: Step 4. Create a Fasten Health Organization resource.
compositionUUID := uuid.New().String()
//Step 5. Create the IPS Composition
ipsComposition := &fhir401.Composition{
Id: stringPtr(compositionUUID),
Text: &fhir401.Narrative{
Status: fhir401.NarrativeStatusGenerated,
Div: "PLACEHOLDER NARRATIVE SUMMARY FOR COMPOSITION", //TODO
},
Identifier: &fhir401.Identifier{
System: stringPtr("https://www.fastenhealth.com"), //TODO
Value: &compositionUUID,
},
Status: fhir401.CompositionStatusFinal,
Type: fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("60591-5"),
Display: stringPtr("Patient Summary"),
},
},
},
Subject: &fhir401.Reference{
Reference: stringPtr("Patient/123"), //TODO
},
Date: timestamp,
Author: []fhir401.Reference{
{
Reference: stringPtr("Organization/fastenhealth.com"), //TODO: The type of author(s) contribute to determine the "nature"of the Patient Summary: e.g. a "human-curated" IPS Vs. an "automatically generated" IPS.
},
},
Title: fmt.Sprintf("Patient Summary as of %s", summaryTime.Format("January 2, 2006 15:04")),
Attester: []fhir401.CompositionAttester{
{
Mode: fhir401.CompositionAttestationModePersonal,
Time: &timestamp,
Party: &fhir401.Reference{
Reference: stringPtr("Patient/123"),
},
},
},
Custodian: &fhir401.Reference{
Reference: stringPtr("Organization/fastenhealth.com"),
},
Event: []fhir401.CompositionEvent{
{
Code: []fhir401.CodeableConcept{
{
Coding: []fhir401.Coding{
{
System: stringPtr("http://terminology.hl7.org/CodeSystem/v3-ActClass"),
Code: stringPtr("PCPR"),
},
},
},
},
Period: &fhir401.Period{
Start: &timestamp, //TODO: this should be the oldest record in the summary
End: &timestamp,
},
},
},
}
// Step 6. Create the IPS Bundle
bundleUUID := uuid.New().String()
ipsBundle := &fhir401.Bundle{
Id: &bundleUUID,
Timestamp: &timestamp,
Language: stringPtr("en-US"),
Entry: []fhir401.BundleEntry{},
Type: fhir401.BundleTypeDocument,
}
// Add the Composition to the bundle
ipsCompositionJson, err := json.Marshal(ipsComposition)
if err != nil {
return nil, err
}
ipsBundle.Entry = append(ipsBundle.Entry, fhir401.BundleEntry{
Resource: json.RawMessage(ipsCompositionJson),
})
// TODO: Add the Patient to the bundle
// TODO: Add the Fasten Health Organization to the bundle
// Add all the resources to the bundle
for _, sectionResources := range summarySectionResources {
for _, resource := range sectionResources {
ipsBundle.Entry = append(ipsBundle.Entry, fhir401.BundleEntry{
Resource: json.RawMessage(resource.ResourceRaw),
})
}
}
return ipsBundle, nil
}
// GetInternationalPatientSummary will generate an IPS bundle, which can then be used to generate a IPS QR code, PDF or JSON bundle // GetInternationalPatientSummary will generate an IPS bundle, which can then be used to generate a IPS QR code, PDF or JSON bundle
// The IPS bundle will contain a summary of all the data in the system, including a list of all sources, and the main Patient // The IPS bundle will contain a summary of all the data in the system, including a list of all sources, and the main Patient
// See: https://github.com/fastenhealth/fasten-onprem/issues/170 // See: https://github.com/fastenhealth/fasten-onprem/issues/170
// See: https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md // See: https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md
func (gr *GormRepository) GetInternationalPatientSummary(ctx context.Context) (*models.Summary, error) { func (gr *GormRepository) GetInternationalPatientSummarySectionResources(ctx context.Context) (map[pkg.IPSSections][]models.ResourceBase, error) {
currentUser, currentUserErr := gr.GetCurrentUser(ctx)
if currentUserErr != nil {
return nil, currentUserErr
}
// we want a count of all resources for this user by type summarySectionResources := map[pkg.IPSSections][]models.ResourceBase{}
var resourceCountResults []map[string]interface{}
resourceTypes := databaseModel.GetAllowedResourceTypes() // generate queries for each IPS Section
for _, resourceType := range resourceTypes { for ndx, _ := range pkg.IPSSectionsList {
tableName, err := databaseModel.GetTableNameByResourceType(resourceType) sectionName := pkg.IPSSectionsList[ndx]
//initialize the section
summarySectionResources[sectionName] = []models.ResourceBase{}
queries, err := generateIPSSectionQueries(sectionName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var count int64
gr.QueryResources(ctx, models.QueryResource{ for qndx, _ := range queries {
Use: "", results, err := gr.QueryResources(ctx, queries[qndx])
Select: nil, if err != nil {
From: "", return nil, err
Where: nil, }
Limit: nil,
Offset: nil,
Aggregations: nil,
})
result := gr.GormClient.WithContext(ctx). resultsList := results.([]models.ResourceBase)
Table(tableName).
Where(models.OriginBase{ //TODO: generate resource narrative
UserID: currentUser.ID, summarySectionResources[sectionName] = append(summarySectionResources[sectionName], resultsList...)
}).
Count(&count)
if result.Error != nil {
return nil, result.Error
} }
if count == 0 {
continue //don't add resource counts if the count is 0
}
resourceCountResults = append(resourceCountResults, map[string]interface{}{
"resource_type": resourceType,
"count": count,
})
} }
// we want a list of all sources (when they were last updated) return summarySectionResources, nil
sources, err := gr.GetSources(ctx) }
func generateIPSCompositionSection(sectionType pkg.IPSSections, resources []models.ResourceBase) (*fhir401.CompositionSection, error) {
sectionTitle, sectionCode, err := generateIPSSectionHeaderInfo(sectionType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// we want the main Patient for each source section := &fhir401.CompositionSection{
patients, err := gr.GetPatientForSources(ctx) Title: &sectionTitle,
if err != nil { Code: &sectionCode,
return nil, err
} }
if len(resources) == 0 {
section.EmptyReason = &fhir401.CodeableConcept{
Text: stringPtr("No data available"),
Coding: []fhir401.Coding{
{
System: stringPtr("http://terminology.hl7.org/CodeSystem/list-empty-reason"),
Code: stringPtr("unavailable"),
},
},
}
} else {
section.Entry = []fhir401.Reference{}
for _, resource := range resources {
reference := fhir401.Reference{
Reference: stringPtr(fmt.Sprintf("%s/%s", resource.SourceResourceType, resource.SourceID)),
}
if err != nil {
return nil, err
}
section.Entry = append(section.Entry, reference)
}
if resourceCountResults == nil { //TODO: Add the section narrative summary
resourceCountResults = []map[string]interface{}{} section.Text = &fhir401.Narrative{
} Status: fhir401.NarrativeStatusGenerated,
summary := &models.Summary{ Div: "PLACEHOLDER NARRATIVE SUMMARY FOR SECTION",
Sources: sources, }
ResourceTypeCounts: resourceCountResults,
Patients: patients,
}
return summary, nil }
return section, nil
} }
// https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md // https://github.com/jddamore/fhir-ips-server/blob/main/docs/Summary_Creation_Steps.md
// Generate Resource Queries for each IPS Section
func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResource, error) { func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResource, error) {
queries := []models.QueryResource{} queries := []models.QueryResource{}
@ -180,22 +329,24 @@ func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResou
}) })
break break
case pkg.IPSSectionsAdvanceDirectives: case pkg.IPSSectionsAdvanceDirectives:
queries = append(queries, models.QueryResource{ //queries = append(queries, models.QueryResource{
Select: nil, // Select: nil,
From: "Consent", // From: "Consent",
Where: map[string]interface{}{ // Where: map[string]interface{}{
"status": "active", // "status": "active",
}, // },
}) //})
log.Printf("warning: Consent FHIR resources are not supported yet. Skipping")
break break
case pkg.IPSSectionsFunctionalStatus: case pkg.IPSSectionsFunctionalStatus:
queries = append(queries, models.QueryResource{ //queries = append(queries, models.QueryResource{
Select: nil, // Select: nil,
From: "ClinicalImpression", // From: "ClinicalImpression",
Where: map[string]interface{}{ // Where: map[string]interface{}{
"status": "in-progress,completed", // "status": "in-progress,completed",
}, // },
}) //})
log.Printf("warning: ClinicalImpression FHIR resources are not supported yet. Skipping")
break break
case pkg.IPSSectionsMedicalDevices: case pkg.IPSSectionsMedicalDevices:
queries = append(queries, models.QueryResource{ queries = append(queries, models.QueryResource{
@ -206,7 +357,7 @@ func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResou
}, },
}) })
break break
case pkg.IPSSectionsHistoryOfIllnesses: case pkg.IPSSectionsHistoryOfIllness:
//TODO: last updated date should be older than 5 years (dateTime or period.high) //TODO: last updated date should be older than 5 years (dateTime or period.high)
//TODO: check if where clause with multiple modifiers for the same field works as expected //TODO: check if where clause with multiple modifiers for the same field works as expected
queries = append(queries, models.QueryResource{ queries = append(queries, models.QueryResource{
@ -235,7 +386,168 @@ func generateIPSSectionQueries(sectionType pkg.IPSSections) ([]models.QueryResou
"status:not": []string{"entered-in-error", "not-done"}, "status:not": []string{"entered-in-error", "not-done"},
}, },
}) })
break
default:
return nil, fmt.Errorf("unsupported section type: %s", sectionType)
} }
return queries, nil return queries, nil
} }
// generate header information for Composition Sections. Title & Code for each section
func generateIPSSectionHeaderInfo(sectionType pkg.IPSSections) (string, fhir401.CodeableConcept, error) {
switch sectionType {
case pkg.IPSSectionsMedicationSummary:
return "Medication Summary", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("10160-0"),
Display: stringPtr("Medication Summary"),
},
},
}, nil
case pkg.IPSSectionsAllergiesIntolerances:
return "Allergies and Intolerances", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("48765-2"),
Display: stringPtr("Allergies and Intolerances"),
},
},
}, nil
case pkg.IPSSectionsProblemList:
return "Problem List", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("11450-4"),
Display: stringPtr("Problem List"),
},
},
}, nil
case pkg.IPSSectionsImmunizations:
return "Immunizations", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("11369-6"),
Display: stringPtr("Immunizations"),
},
},
}, nil
case pkg.IPSSectionsHistoryOfProcedures:
return "History of Procedures", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("47519-4"),
Display: stringPtr("History of Procedures"),
},
},
}, nil
case pkg.IPSSectionsMedicalDevices:
return "Medical Devices", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("46264-8"),
Display: stringPtr("Medical Devices"),
},
},
}, nil
case pkg.IPSSectionsDiagnosticResults:
return "Diagnostic Results", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("30954-2"),
Display: stringPtr("Diagnostic Results"),
},
},
}, nil
case pkg.IPSSectionsVitalSigns:
return "Vital Signs", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("8716-3"),
Display: stringPtr("Vital Signs"),
},
},
}, nil
case pkg.IPSSectionsHistoryOfIllness:
return "Past History of Illness", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("11348-0"),
Display: stringPtr("History of Illness"),
},
},
}, nil
case pkg.IPSSectionsPregnancy:
return "Pregnancy History", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("10162-6"),
Display: stringPtr("Pregnancy History"),
},
},
}, nil
case pkg.IPSSectionsSocialHistory:
return "Social History", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("29762-2"),
Display: stringPtr("Social History"),
},
},
}, nil
case pkg.IPSSectionsPlanOfCare:
return "Plan of Care", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("18776-5"),
Display: stringPtr("Plan of Care"),
},
},
}, nil
case pkg.IPSSectionsFunctionalStatus:
return "Functional Status", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("47420-5"),
Display: stringPtr("Functional Status"),
},
},
}, nil
case pkg.IPSSectionsAdvanceDirectives:
return "Advance Directives", fhir401.CodeableConcept{
Coding: []fhir401.Coding{
{
System: stringPtr("http://loinc.org"),
Code: stringPtr("42348-3"),
Display: stringPtr("Advance Directives"),
},
},
}, nil
default:
return "", fhir401.CodeableConcept{}, fmt.Errorf("invalid section type: %s", sectionType)
}
}
func generateIPSSectionNarrative(sectionType pkg.IPSSections, resources []models.ResourceBase) string {
return ""
}
// helper utility
func stringPtr(s string) *string {
return &s
}

View File

@ -21,6 +21,7 @@ type DatabaseRepository interface {
//get a count of every resource type //get a count of every resource type
GetSummary(ctx context.Context) (*models.Summary, error) GetSummary(ctx context.Context) (*models.Summary, error)
GetInternationalPatientSummaryBundle(ctx context.Context) (interface{}, error)
GetResourceByResourceTypeAndId(context.Context, string, string) (*models.ResourceBase, error) GetResourceByResourceTypeAndId(context.Context, string, string) (*models.ResourceBase, error)
GetResourceBySourceId(context.Context, string, string) (*models.ResourceBase, error) GetResourceBySourceId(context.Context, string, string) (*models.ResourceBase, error)

View File

@ -20,3 +20,16 @@ func GetSummary(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary}) c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
} }
func GetIPSSummary(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
summary, err := databaseRepo.GetInternationalPatientSummaryBundle(c)
if err != nil {
logger.Errorln("An error occurred while retrieving IPS summary", err)
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": summary})
}

View File

@ -100,6 +100,7 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) {
secure.DELETE("/account/me", handler.DeleteAccount) secure.DELETE("/account/me", handler.DeleteAccount)
secure.GET("/summary", handler.GetSummary) secure.GET("/summary", handler.GetSummary)
secure.GET("/summary/ips", handler.GetIPSSummary)
secure.POST("/source", handler.CreateReconnectSource) secure.POST("/source", handler.CreateReconnectSource)
secure.POST("/source/manual", handler.CreateManualSource) secure.POST("/source/manual", handler.CreateManualSource)