adding a glossary endpoint which attempts to get patient-friendly descriptions from code. (#120)

This commit is contained in:
Jason Kulatunga 2023-03-21 08:04:43 -07:00 committed by GitHub
parent fa75594a47
commit 390cea6108
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 379 additions and 21 deletions

View File

@ -34,6 +34,9 @@ type DatabaseRepository interface {
GetSourceSummary(context.Context, string) (*models.SourceSummary, error)
GetSources(context.Context) ([]models.SourceCredential, error)
//used by Client
CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error
GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error)
//used by fasten-sources Clients
UpsertRawResource(ctx context.Context, sourceCredentials sourcePkg.SourceCredential, rawResource sourcePkg.RawResourceFhir) (bool, error)
}

View File

@ -57,14 +57,14 @@ func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger)
}
globalLogger.Infof("Successfully connected to fasten sqlite db: %s\n", appConfig.GetString("database.location"))
deviceRepo := SqliteRepository{
fastenRepo := SqliteRepository{
AppConfig: appConfig,
Logger: globalLogger,
GormClient: database,
}
//TODO: automigrate for now
err = deviceRepo.Migrate()
err = fastenRepo.Migrate()
if err != nil {
return nil, err
}
@ -76,7 +76,7 @@ func NewRepository(appConfig config.Interface, globalLogger logrus.FieldLogger)
return nil, fmt.Errorf("Failed to create admin user! - %v", err)
}
return &deviceRepo, nil
return &fastenRepo, nil
}
type SqliteRepository struct {
@ -91,6 +91,7 @@ func (sr *SqliteRepository) Migrate() error {
&models.User{},
&models.SourceCredential{},
&models.ResourceFhir{},
&models.Glossary{},
)
if err != nil {
return fmt.Errorf("Failed to automigrate! - %v", err)
@ -140,6 +141,24 @@ func (sr *SqliteRepository) GetCurrentUser(ctx context.Context) (*models.User, e
return &currentUser, nil
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Glossary
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (sr *SqliteRepository) CreateGlossaryEntry(ctx context.Context, glossaryEntry *models.Glossary) error {
record := sr.GormClient.Create(glossaryEntry)
if record.Error != nil {
return record.Error
}
return nil
}
func (sr *SqliteRepository) GetGlossaryEntry(ctx context.Context, code string, codeSystem string) (*models.Glossary, error) {
var foundGlossaryEntry models.Glossary
result := sr.GormClient.Where(models.Glossary{Code: code, CodeSystem: codeSystem}).First(&foundGlossaryEntry)
return &foundGlossaryEntry, result.Error
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Summary
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@ -0,0 +1,14 @@
package models
// Glossary contains patient friendly terms for medical concepts
// Can be retrieved by Code and CodeSystem
// Structured similar to ValueSet https://hl7.org/fhir/valueset.html
type Glossary struct {
ModelBase
Code string `json:"code" gorm:"uniqueIndex:idx_glossary_term"`
CodeSystem string `json:"code_system" gorm:"uniqueIndex:idx_glossary_term"`
Publisher string `json:"publisher"`
Title string `json:"title"`
Url string `json:"url"`
Description string `json:"description"`
}

View File

@ -0,0 +1,59 @@
package models
import "time"
type MedlinePlusConnectResults struct {
Feed struct {
Base string `json:"base"`
Lang string `json:"lang"`
Xsi string `json:"xsi"`
Title struct {
Type string `json:"type"`
Value string `json:"_value"`
} `json:"title"`
Updated struct {
Value time.Time `json:"_value"`
} `json:"updated"`
ID struct {
Value string `json:"_value"`
} `json:"id"`
Author struct {
Name struct {
Value string `json:"_value"`
} `json:"name"`
URI struct {
Value string `json:"_value"`
} `json:"uri"`
} `json:"author"`
Subtitle struct {
Type string `json:"type"`
Value string `json:"_value"`
} `json:"subtitle"`
Category []struct {
Scheme string `json:"scheme"`
Term string `json:"term"`
} `json:"category"`
Entry []MedlinePlusConnectResultEntry `json:"entry"`
} `json:"feed"`
}
type MedlinePlusConnectResultEntry struct {
Title struct {
Value string `json:"_value"`
Type string `json:"type"`
} `json:"title"`
Link []struct {
Href string `json:"href"`
Rel string `json:"rel"`
} `json:"link"`
ID struct {
Value string `json:"_value"`
} `json:"id"`
Summary struct {
Type string `json:"type"`
Value string `json:"_value"`
} `json:"summary"`
Updated struct {
Value time.Time `json:"_value"`
} `json:"updated"`
}

View File

@ -0,0 +1,158 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/database"
"github.com/fastenhealth/fastenhealth-onprem/backend/pkg/models"
"github.com/fastenhealth/gofhir-models/fhir401"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"log"
"net"
"net/http"
"net/url"
"strings"
"time"
)
func FindCodeSystem(codeSystem string) (string, error) {
log.Printf("codeSystem: %s", codeSystem)
if strings.HasPrefix(codeSystem, "2.16.840.1.113883.6.") {
return codeSystem, nil
}
//https://terminology.hl7.org/external_terminologies.html
codeSystemIds := map[string]string{
"http://hl7.org/fhir/sid/icd-10-cm": "2.16.840.1.113883.6.90",
"http://hl7.org/fhir/sid/icd-10": "2.16.840.1.113883.6.90",
"http://terminology.hl7.org/CodeSystem/icd9cm": "2.16.840.1.113883.6.103",
"http://snomed.info/sct": "2.16.840.1.113883.6.96",
"http://www.nlm.nih.gov/research/umls/rxnorm": "2.16.840.1.113883.6.88",
"http://hl7.org/fhir/sid/ndc": "2.16.840.1.113883.6.69",
"http://loinc.org": "2.16.840.1.113883.6.1",
"http://www.ama-assn.org/go/cpt": "2.16.840.1.113883.6.12",
}
if codeSystemId, ok := codeSystemIds[codeSystem]; ok {
return codeSystemId, nil
} else {
return "", fmt.Errorf("Code System not found")
}
}
// https://medlineplus.gov/medlineplus-connect/web-service/
// NOTE: max requests is 100/min
func GlossarySearchByCode(c *gin.Context) {
logger := c.MustGet(pkg.ContextKeyTypeLogger).(*logrus.Entry)
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
codeSystemId, err := FindCodeSystem(c.Query("code_system"))
if err != nil {
logger.Error(err)
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
})
return
}
if c.Query("code") == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": "code is required",
})
return
}
//Check if the code is in the DB cache
foundGlossaryEntry, err := databaseRepo.GetGlossaryEntry(c, c.Query("code"), codeSystemId)
if err == nil {
//found in DB cache
logger.Debugf("Found code (%s %s) in DB cache", c.Query("code"), codeSystemId)
dateStr := foundGlossaryEntry.UpdatedAt.Format(time.RFC3339)
valueSet := fhir401.ValueSet{
Title: &foundGlossaryEntry.Title,
Url: &foundGlossaryEntry.Url,
Description: &foundGlossaryEntry.Description,
Date: &dateStr,
Publisher: &foundGlossaryEntry.Publisher,
}
c.JSON(http.StatusOK, valueSet)
return
}
// Define the URL for the MedlinePlus Connect Web Service API
medlinePlusConnectEndpoint := "https://connect.medlineplus.gov/service"
// Define the query parameters
params := url.Values{
"informationRecipient.languageCode.c": []string{"en"},
"knowledgeResponseType": []string{"application/json"},
"mainSearchCriteria.v.c": []string{c.Query("code")},
"mainSearchCriteria.v.cs": []string{codeSystemId},
}
// Send the HTTP GET request to the API and retrieve the response
//TODO: when using IPV6 to communicate with MedlinePlus, we're getting timeouts. Force IPV4
var (
zeroDialer net.Dialer
httpClient = &http.Client{
Timeout: 10 * time.Second,
}
)
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return zeroDialer.DialContext(ctx, "tcp4", addr)
}
httpClient.Transport = transport
resp, err := httpClient.Get(medlinePlusConnectEndpoint + "?" + params.Encode())
if err != nil {
fmt.Println("Error sending request:", err)
return
}
defer resp.Body.Close()
// Parse the JSON response into a struct
var response models.MedlinePlusConnectResults
err = json.NewDecoder(resp.Body).Decode(&response)
if err != nil {
fmt.Println("Error parsing response:", err)
return
}
if len(response.Feed.Entry) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "No results found"})
return
} else {
foundEntry := response.Feed.Entry[0]
dateStr := foundEntry.Updated.Value.Format(time.RFC3339)
valueSet := fhir401.ValueSet{
Title: &foundEntry.Title.Value,
Url: &foundEntry.Link[0].Href,
Description: &foundEntry.Summary.Value,
Date: &dateStr,
Publisher: &response.Feed.Author.Name.Value,
}
//store in DB cache (ignore errors)
databaseRepo.CreateGlossaryEntry(c, &models.Glossary{
ModelBase: models.ModelBase{
CreatedAt: foundEntry.Updated.Value,
UpdatedAt: foundEntry.Updated.Value,
},
Code: c.Query("code"),
CodeSystem: codeSystemId,
Publisher: response.Feed.Author.Name.Value,
Title: foundEntry.Title.Value,
Url: foundEntry.Link[0].Href,
Description: foundEntry.Summary.Value,
})
c.JSON(http.StatusOK, valueSet)
}
}

View File

@ -46,6 +46,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
//r.Any("/database/*proxyPath", handler.CouchDBProxy)
//r.GET("/cors/*proxyPath", handler.CORSProxy)
//r.OPTIONS("/cors/*proxyPath", handler.CORSProxy)
api.GET("/glossary/code", handler.GlossarySearchByCode)
secure := api.Group("/secure").Use(middleware.RequireAuth())
{
@ -61,6 +62,7 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
secure.GET("/resource/graph", handler.GetResourceFhirGraph)
secure.GET("/resource/fhir/:sourceId/:resourceId", handler.GetResourceFhir)
secure.POST("/resource/composition", handler.CreateResourceComposition)
}
if ae.Config.GetBool("web.allow_unsafe_endpoints") {

View File

@ -0,0 +1,8 @@
<div *ngIf="!loading else isLoadingTemplate">
<div [innerHTML]="description"></div>
<p>Source: <a [href]="url">{{source}}</a></p>
</div>
<ng-template #isLoadingTemplate>
<app-loading-spinner [loadingTitle]="'Please wait, loading glossary entry...'"></app-loading-spinner>
</ng-template>

View File

@ -0,0 +1,5 @@
:host {
max-height:300px;
overflow-y:scroll;
display: inline-block;
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GlossaryLookupComponent } from './glossary-lookup.component';
describe('GlossaryLookupComponent', () => {
let component: GlossaryLookupComponent;
let fixture: ComponentFixture<GlossaryLookupComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ GlossaryLookupComponent ]
})
.compileComponents();
fixture = TestBed.createComponent(GlossaryLookupComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
import {Component, Input, OnInit} from '@angular/core';
import {FastenApiService} from '../../services/fasten-api.service';
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
@Component({
selector: 'app-glossary-lookup',
templateUrl: './glossary-lookup.component.html',
styleUrls: ['./glossary-lookup.component.scss']
})
export class GlossaryLookupComponent implements OnInit {
@Input() code: string
@Input() codeSystem: string
@Input() snippetLength: number = -1
description: SafeHtml = ""
url: string = ""
source: string = ""
loading: boolean = true
constructor(private fastenApi: FastenApiService, private sanitized: DomSanitizer) { }
ngOnInit(): void {
this.fastenApi.getGlossarySearchByCode(this.code, this.codeSystem).subscribe(result => {
this.loading = false
console.log(result)
this.url = result?.url
this.source = result?.publisher
this.description = this.sanitized.bypassSecurityTrustHtml(result?.description)
// this.description = result.description
}, error => {
this.loading = false
})
}
}

View File

@ -48,12 +48,8 @@
</div>
<div class="col-6 bg-gray-100">
<div class="row">
<div class="col-12 mt-3">
<p>
<small>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</small>
</p>
<div *ngIf="observationCode" class="col-12 mt-3">
<app-glossary-lookup [code]="observationCode" [codeSystem]="'http://loinc.org'"></app-glossary-lookup>
</div>
<div class="col-12">

View File

@ -32,11 +32,9 @@
</ng-container>
<div *ngIf="conditionDisplayModel" class="col-12 mt-3 mb-2">
<p class="tx-indigo">Initial Presentation</p>
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
</p>
<div *ngIf="conditionDisplayModel.code_id && conditionDisplayModel.code_system" class="col-12 mt-3 mb-2">
<p class="tx-indigo">Definition</p>
<app-glossary-lookup [code]="conditionDisplayModel.code_id" [codeSystem]="conditionDisplayModel.code_system"></app-glossary-lookup>
</div>
</div>

View File

@ -65,6 +65,7 @@ import { PractitionerComponent } from './fhir/resources/practitioner/practitione
import {PipesModule} from '../pipes/pipes.module';
import { NlmTypeaheadComponent } from './nlm-typeahead/nlm-typeahead.component';
import { DocumentReferenceComponent } from './fhir/resources/document-reference/document-reference.component';
import { GlossaryLookupComponent } from './glossary-lookup/glossary-lookup.component';
@NgModule({
imports: [
@ -138,6 +139,7 @@ import { DocumentReferenceComponent } from './fhir/resources/document-reference/
PractitionerComponent,
NlmTypeaheadComponent,
DocumentReferenceComponent,
GlossaryLookupComponent,
],
exports: [
ComponentsSidebarComponent,
@ -190,6 +192,7 @@ import { DocumentReferenceComponent } from './fhir/resources/document-reference/
PractitionerComponent,
NlmTypeaheadComponent,
DocumentReferenceComponent,
GlossaryLookupComponent
]
})

View File

@ -8,14 +8,22 @@
<span>{{resource?.source_resource_id}}</span>
</div>
<ng-container *ngIf="displayModel">
<fhir-resource [displayModel]="displayModel" [showDetails]="false"></fhir-resource>
</ng-container>
<ng-container *ngIf="resource else isLoadingTemplate">
<pre><code [highlight]="resource.resource_raw | json"></code></pre>
</ng-container>
<ng-container *ngIf="!loading else isLoadingTemplate">
<fhir-resource *ngIf="displayModel else noDisplayModel" [displayModel]="displayModel" [showDetails]="false"></fhir-resource>
<ng-template #noDisplayModel>
<p> An error occurred while parsing FHIR resource </p>
</ng-template>
<div class="alert alert-warning" role="alert">
Enable Debug mode: <input type="checkbox" [(ngModel)]="debugMode"/>
</div>
<ng-container *ngIf="resource && debugMode">
<pre><code [highlight]="resource.resource_raw | json"></code></pre>
</ng-container>
</ng-container>
<ng-template #isLoadingTemplate>
<div class="row">
<div class="col-12">

View File

@ -13,6 +13,8 @@ import {FastenDisplayModel} from '../../../lib/models/fasten/fasten-display-mode
})
export class ResourceDetailComponent implements OnInit {
loading: boolean = false
debugMode = false;
sourceId: string = ""
sourceName: string = ""

View File

@ -14,6 +14,7 @@ import {AuthService} from './auth.service';
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
import {environment} from '../../environments/environment';
import {ResourceAssociation} from '../models/fasten/resource_association';
import {ValueSet} from 'fhir/r4';
@Injectable({
providedIn: 'root'
@ -23,6 +24,24 @@ export class FastenApiService {
constructor(private _httpClient: HttpClient, private router: Router, private authService: AuthService) {
}
/*
TERMINOLOGY SERVER/GLOSSARY ENDPOINTS
*/
getGlossarySearchByCode(code: string, codeSystem: string): Observable<ValueSet> {
const endpointUrl = new URL(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/glossary/code`);
endpointUrl.searchParams.set('code', code);
endpointUrl.searchParams.set('code_system', codeSystem);
return this._httpClient.get<any>(endpointUrl.toString())
.pipe(
map((response: ValueSet) => {
console.log("Glossary RESPONSE", response)
return response
})
);
}
/*
SECURE ENDPOINTS

View File

@ -8,6 +8,9 @@ import {FastenOptions} from '../fasten/fasten-options';
export class ConditionModel extends FastenDisplayModel {
code_text: string | undefined
code_id: string | undefined
code_system: string | undefined
severity_text: string | undefined
has_asserter: boolean | undefined
asserter: ReferenceModel | undefined
@ -31,6 +34,8 @@ export class ConditionModel extends FastenDisplayModel {
_.get(fhirResource, 'code.coding.0.display') ||
_.get(fhirResource, 'code.text') ||
_.get(fhirResource, 'code.coding.0.code');
this.code_id = _.get(fhirResource, 'code.coding.0.code')
this.code_system = _.get(fhirResource, 'code.coding.0.system')
this.severity_text =
_.get(fhirResource, 'severity.coding.0.display') ||
_.get(fhirResource, 'severity.text');