From 9a7b43c97673700f74189ced1422f289b6740f2c Mon Sep 17 00:00:00 2001 From: Paul Schmiedmayer Date: Wed, 15 May 2024 03:15:50 -0700 Subject: [PATCH] Trials Loading, Keyword Search Generation, and Trials Matching (#22) # Trials Loading, Keyword Search Generation, and Trials Matching ## :gear: Release Notes - Loads FHIR resources into an LLM to determine keywords. - Uses keywords to query the NIC API. - Match NCI results with local FHIR resources to provide recommendations. ### Code of Conduct & Contributing Guidelines By submitting creating this pull request, you agree to follow our [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md): - [x] I agree to follow the [Code of Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md) and [Contributing Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md). --- OwnYourData.xcodeproj/project.pbxproj | 86 +++- .../xcshareddata/swiftpm/Package.resolved | 8 +- .../xcschemes/OwnYourData.xcscheme | 2 +- OwnYourData/Account/ResourceSelection.swift | 49 +- .../ClinicalTrials/ClinicalTrialsView.swift | 170 ------- .../ClinicalTrials/NCITrialsModel.swift | 101 ++++ .../ViewClinicalTrialsView.swift | 99 ++++ OwnYourData/Home.swift | 10 +- OwnYourData/OwnYourDataDelegate.swift | 2 + OwnYourData/Resources/ConsentDocument.md | 44 +- .../ExamplePatients/BreastCancerExample.json | 438 ++++++++++++++++++ .../BreastCancerExample.json.license | 6 + OwnYourData/Resources/Localizable.xcstrings | 114 +++++ .../Helpers/FHIRPrompt+OwnYourData.swift | 40 ++ .../Helpers/FHIRResource+Identifier.swift | 27 ++ .../Helpers/TrialDetail+LLMIndentifier.swift | 17 + .../GetFHIRResourceLLMFunction.swift | 124 +++++ .../LLMFunctions/GetTrialsLLMFunction.swift | 55 +++ .../TrialsMatching/MatchingModule.swift | 154 ++++++ .../TrialsMatching/MatchingState.swift | 31 ++ .../TrialsMatching/MatchingStateView.swift | 95 ++++ OwnYourData/TrialsMatching/MatchingView.swift | 61 +++ 22 files changed, 1535 insertions(+), 198 deletions(-) delete mode 100644 OwnYourData/ClinicalTrials/ClinicalTrialsView.swift create mode 100644 OwnYourData/ClinicalTrials/NCITrialsModel.swift create mode 100644 OwnYourData/ClinicalTrials/ViewClinicalTrialsView.swift create mode 100644 OwnYourData/Resources/ExamplePatients/BreastCancerExample.json create mode 100644 OwnYourData/Resources/ExamplePatients/BreastCancerExample.json.license create mode 100644 OwnYourData/TrialsMatching/Helpers/FHIRPrompt+OwnYourData.swift create mode 100644 OwnYourData/TrialsMatching/Helpers/FHIRResource+Identifier.swift create mode 100644 OwnYourData/TrialsMatching/Helpers/TrialDetail+LLMIndentifier.swift create mode 100644 OwnYourData/TrialsMatching/LLMFunctions/GetFHIRResourceLLMFunction.swift create mode 100644 OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift create mode 100644 OwnYourData/TrialsMatching/MatchingModule.swift create mode 100644 OwnYourData/TrialsMatching/MatchingState.swift create mode 100644 OwnYourData/TrialsMatching/MatchingStateView.swift create mode 100644 OwnYourData/TrialsMatching/MatchingView.swift diff --git a/OwnYourData.xcodeproj/project.pbxproj b/OwnYourData.xcodeproj/project.pbxproj index 72ae22c..f069a6d 100644 --- a/OwnYourData.xcodeproj/project.pbxproj +++ b/OwnYourData.xcodeproj/project.pbxproj @@ -27,7 +27,7 @@ 2F42E9E12B91BB2500D88DB7 /* PDFListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9DA2B91BB2500D88DB7 /* PDFListRow.swift */; }; 2F42E9E22B91BB2500D88DB7 /* DocumentScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9DB2B91BB2500D88DB7 /* DocumentScanner.swift */; }; 2F42E9E72B91BB8300D88DB7 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9E52B91BB8300D88DB7 /* WebView.swift */; }; - 2F42E9E82B91BB8300D88DB7 /* ClinicalTrialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9E62B91BB8300D88DB7 /* ClinicalTrialsView.swift */; }; + 2F42E9E82B91BB8300D88DB7 /* ViewClinicalTrialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9E62B91BB8300D88DB7 /* ViewClinicalTrialsView.swift */; }; 2F42E9F22B91BBF300D88DB7 /* HowItWorks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9EB2B91BBF300D88DB7 /* HowItWorks.swift */; }; 2F42E9F32B91BBF300D88DB7 /* AddRecordInstructView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9EC2B91BBF300D88DB7 /* AddRecordInstructView.swift */; }; 2F42E9F42B91BBF300D88DB7 /* Instructions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F42E9ED2B91BBF300D88DB7 /* Instructions.swift */; }; @@ -50,6 +50,17 @@ 2FB099B12A875DF100B20952 /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B02A875DF100B20952 /* FirebaseFirestore */; }; 2FB099B32A875DF100B20952 /* FirebaseFirestoreSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B22A875DF100B20952 /* FirebaseFirestoreSwift */; }; 2FB099B62A875E2B00B20952 /* HealthKitOnFHIR in Frameworks */ = {isa = PBXBuildFile; productRef = 2FB099B52A875E2B00B20952 /* HealthKitOnFHIR */; }; + 2FB4DBB82BF4781D00E68AD9 /* MatchingModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBB72BF4781D00E68AD9 /* MatchingModule.swift */; }; + 2FB4DBBB2BF479E600E68AD9 /* GetFHIRResourceLLMFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBBA2BF479E600E68AD9 /* GetFHIRResourceLLMFunction.swift */; }; + 2FB4DBC12BF47ABA00E68AD9 /* FHIRResource+Identifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBC02BF47ABA00E68AD9 /* FHIRResource+Identifier.swift */; }; + 2FB4DBC62BF48E4F00E68AD9 /* GetTrialsLLMFunction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBC52BF48E4F00E68AD9 /* GetTrialsLLMFunction.swift */; }; + 2FB4DBC92BF48E7400E68AD9 /* FHIRPrompt+OwnYourData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBC82BF48E7400E68AD9 /* FHIRPrompt+OwnYourData.swift */; }; + 2FB4DBCD2BF4915900E68AD9 /* NCITrialsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBCC2BF4915900E68AD9 /* NCITrialsModel.swift */; }; + 2FB4DBD52BF4946E00E68AD9 /* MatchingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBD42BF4946E00E68AD9 /* MatchingState.swift */; }; + 2FB4DBD72BF4948700E68AD9 /* MatchingStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBD62BF4948700E68AD9 /* MatchingStateView.swift */; }; + 2FB4DBDB2BF4A08000E68AD9 /* MatchingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBDA2BF4A08000E68AD9 /* MatchingView.swift */; }; + 2FB4DBDE2BF4A62100E68AD9 /* TrialDetail+LLMIndentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB4DBDD2BF4A62100E68AD9 /* TrialDetail+LLMIndentifier.swift */; }; + 2FB4DBE42BF4AEF200E68AD9 /* BreastCancerExample.json in Resources */ = {isa = PBXBuildFile; fileRef = 2FB4DBE22BF4AE4200E68AD9 /* BreastCancerExample.json */; }; 2FC3439229EE634B002D773C /* ConsentDocument.md in Resources */ = {isa = PBXBuildFile; fileRef = 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */; }; 2FC975A82978F11A00BA99FE /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC975A72978F11A00BA99FE /* Home.swift */; }; 2FCDFF7C2BF33E5400158BDE /* SpeziLocation in Frameworks */ = {isa = PBXBuildFile; productRef = 2FCDFF7B2BF33E5400158BDE /* SpeziLocation */; }; @@ -128,7 +139,7 @@ 2F42E9DA2B91BB2500D88DB7 /* PDFListRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PDFListRow.swift; sourceTree = ""; }; 2F42E9DB2B91BB2500D88DB7 /* DocumentScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DocumentScanner.swift; sourceTree = ""; }; 2F42E9E52B91BB8300D88DB7 /* WebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - 2F42E9E62B91BB8300D88DB7 /* ClinicalTrialsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ClinicalTrialsView.swift; sourceTree = ""; }; + 2F42E9E62B91BB8300D88DB7 /* ViewClinicalTrialsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewClinicalTrialsView.swift; sourceTree = ""; }; 2F42E9EB2B91BBF300D88DB7 /* HowItWorks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HowItWorks.swift; sourceTree = ""; }; 2F42E9EC2B91BBF300D88DB7 /* AddRecordInstructView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddRecordInstructView.swift; sourceTree = ""; }; 2F42E9ED2B91BBF300D88DB7 /* Instructions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Instructions.swift; sourceTree = ""; }; @@ -147,6 +158,17 @@ 2F6025CA29BBE70F0045459E /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 2FAEC07F297F583900C11C42 /* OwnYourData.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OwnYourData.entitlements; sourceTree = ""; }; + 2FB4DBB72BF4781D00E68AD9 /* MatchingModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchingModule.swift; sourceTree = ""; }; + 2FB4DBBA2BF479E600E68AD9 /* GetFHIRResourceLLMFunction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetFHIRResourceLLMFunction.swift; sourceTree = ""; }; + 2FB4DBC02BF47ABA00E68AD9 /* FHIRResource+Identifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FHIRResource+Identifier.swift"; sourceTree = ""; }; + 2FB4DBC52BF48E4F00E68AD9 /* GetTrialsLLMFunction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetTrialsLLMFunction.swift; sourceTree = ""; }; + 2FB4DBC82BF48E7400E68AD9 /* FHIRPrompt+OwnYourData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FHIRPrompt+OwnYourData.swift"; sourceTree = ""; }; + 2FB4DBCC2BF4915900E68AD9 /* NCITrialsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCITrialsModel.swift; sourceTree = ""; }; + 2FB4DBD42BF4946E00E68AD9 /* MatchingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchingState.swift; sourceTree = ""; }; + 2FB4DBD62BF4948700E68AD9 /* MatchingStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchingStateView.swift; sourceTree = ""; }; + 2FB4DBDA2BF4A08000E68AD9 /* MatchingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchingView.swift; sourceTree = ""; }; + 2FB4DBDD2BF4A62100E68AD9 /* TrialDetail+LLMIndentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrialDetail+LLMIndentifier.swift"; sourceTree = ""; }; + 2FB4DBE22BF4AE4200E68AD9 /* BreastCancerExample.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = BreastCancerExample.json; sourceTree = ""; }; 2FC94CD4298B0A1D009C8209 /* OwnYourData.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = OwnYourData.xctestplan; sourceTree = ""; }; 2FC975A72978F11A00BA99FE /* Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; 2FCDFF7E2BF33ED800158BDE /* LocationPermissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationPermissions.swift; sourceTree = ""; }; @@ -261,7 +283,8 @@ 2F42E9E32B91BB7000D88DB7 /* ClinicalTrials */ = { isa = PBXGroup; children = ( - 2F42E9E62B91BB8300D88DB7 /* ClinicalTrialsView.swift */, + 2FB4DBCC2BF4915900E68AD9 /* NCITrialsModel.swift */, + 2F42E9E62B91BB8300D88DB7 /* ViewClinicalTrialsView.swift */, 2FCDFF812BF3417300158BDE /* TrialView.swift */, 2F42E9E52B91BB8300D88DB7 /* WebView.swift */, 2F38F39F2BE805090002E7D5 /* NICTrialsAPIDateFormatter.swift */, @@ -295,6 +318,46 @@ path = FHIR; sourceTree = ""; }; + 2FB4DBB92BF479CF00E68AD9 /* TrialsMatching */ = { + isa = PBXGroup; + children = ( + 2FB4DBD22BF4946100E68AD9 /* Helpers */, + 2FB4DBCF2BF492F600E68AD9 /* LLMFunctions */, + 2FB4DBD42BF4946E00E68AD9 /* MatchingState.swift */, + 2FB4DBB72BF4781D00E68AD9 /* MatchingModule.swift */, + 2FB4DBD62BF4948700E68AD9 /* MatchingStateView.swift */, + 2FB4DBDA2BF4A08000E68AD9 /* MatchingView.swift */, + ); + path = TrialsMatching; + sourceTree = ""; + }; + 2FB4DBCF2BF492F600E68AD9 /* LLMFunctions */ = { + isa = PBXGroup; + children = ( + 2FB4DBBA2BF479E600E68AD9 /* GetFHIRResourceLLMFunction.swift */, + 2FB4DBC52BF48E4F00E68AD9 /* GetTrialsLLMFunction.swift */, + ); + path = LLMFunctions; + sourceTree = ""; + }; + 2FB4DBD22BF4946100E68AD9 /* Helpers */ = { + isa = PBXGroup; + children = ( + 2FB4DBC02BF47ABA00E68AD9 /* FHIRResource+Identifier.swift */, + 2FB4DBC82BF48E7400E68AD9 /* FHIRPrompt+OwnYourData.swift */, + 2FB4DBDD2BF4A62100E68AD9 /* TrialDetail+LLMIndentifier.swift */, + ); + path = Helpers; + sourceTree = ""; + }; + 2FB4DBE12BF4AE2500E68AD9 /* ExamplePatients */ = { + isa = PBXGroup; + children = ( + 2FB4DBE22BF4AE4200E68AD9 /* BreastCancerExample.json */, + ); + path = ExamplePatients; + sourceTree = ""; + }; 2FC9759D2978E30800BA99FE /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -322,6 +385,7 @@ 2FE5DC2D29EDD792004B9AB4 /* Resources */ = { isa = PBXGroup; children = ( + 2FB4DBE12BF4AE2500E68AD9 /* ExamplePatients */, 653A255428338800005D4D48 /* Assets.xcassets */, 2FA0BFEC2ACC977500E0EF83 /* Localizable.xcstrings */, 2FE5DC2C29EDD78E004B9AB4 /* ConsentDocument.md */, @@ -395,6 +459,7 @@ 2FE5DC2829EDD398004B9AB4 /* Onboarding */, 56F6F29E2AB441640022FE5A /* Contributions */, 2F42E9D32B91BB1800D88DB7 /* Documents */, + 2FB4DBB92BF479CF00E68AD9 /* TrialsMatching */, 2F42E9E32B91BB7000D88DB7 /* ClinicalTrials */, 2F42E9E92B91BBDD00D88DB7 /* Instructions */, 2FE5DC3C29EDD7DA004B9AB4 /* SharedContext */, @@ -603,6 +668,7 @@ 653A255528338800005D4D48 /* Assets.xcassets in Resources */, 2FA0BFED2ACC977500E0EF83 /* Localizable.xcstrings in Resources */, 2F6025CB29BBE70F0045459E /* GoogleService-Info.plist in Resources */, + 2FB4DBE42BF4AEF200E68AD9 /* BreastCancerExample.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -651,10 +717,12 @@ 2FE5DC4129EDD7EE004B9AB4 /* StorageKeys.swift in Sources */, 2F42E9F72B91BBF300D88DB7 /* ViewRecordsView.swift in Sources */, 2F42E9E22B91BB2500D88DB7 /* DocumentScanner.swift in Sources */, + 2FB4DBD72BF4948700E68AD9 /* MatchingStateView.swift in Sources */, 2FE5DCB129EE6107004B9AB4 /* AccountOnboarding.swift in Sources */, 2F42E9F22B91BBF300D88DB7 /* HowItWorks.swift in Sources */, 2FE5DC3A29EDD7CA004B9AB4 /* Welcome.swift in Sources */, 2F42E9DE2B91BB2500D88DB7 /* PDFView.swift in Sources */, + 2FB4DBBB2BF479E600E68AD9 /* GetFHIRResourceLLMFunction.swift in Sources */, 2F38F3A02BE805090002E7D5 /* NICTrialsAPIDateFormatter.swift in Sources */, 2F42E9F42B91BBF300D88DB7 /* Instructions.swift in Sources */, 2FCDFF7F2BF33ED800158BDE /* LocationPermissions.swift in Sources */, @@ -671,6 +739,8 @@ A9DFE8A92ABE551400428242 /* AccountButton.swift in Sources */, 2FE5DC3729EDD7CA004B9AB4 /* OnboardingFlow.swift in Sources */, 2F42E9D12B91BAB900D88DB7 /* LogoView.swift in Sources */, + 2FB4DBC12BF47ABA00E68AD9 /* FHIRResource+Identifier.swift in Sources */, + 2FB4DBDE2BF4A62100E68AD9 /* TrialDetail+LLMIndentifier.swift in Sources */, 2F42EA072B91C24700D88DB7 /* URL+Zip.swift in Sources */, 2FF53D8D2A8729D600042B76 /* OwnYourDataStandard.swift in Sources */, 2FE5DC4729EDD7F2004B9AB4 /* CodableArray+RawRepresentable.swift in Sources */, @@ -680,15 +750,20 @@ 2F42E9DD2B91BB2500D88DB7 /* DocumentGallery.swift in Sources */, 2F42E9DF2B91BB2500D88DB7 /* PDFDocument+Transferable.swift in Sources */, 2F42E9F52B91BBF300D88DB7 /* InstructionsStep.swift in Sources */, + 2FB4DBC92BF48E7400E68AD9 /* FHIRPrompt+OwnYourData.swift in Sources */, + 2FB4DBC62BF48E4F00E68AD9 /* GetTrialsLLMFunction.swift in Sources */, 2F4E23832989D51F0013F3D9 /* OwnYourDataTestingSetup.swift in Sources */, + 2FB4DBCD2BF4915900E68AD9 /* NCITrialsModel.swift in Sources */, 2F42E9DC2B91BB2500D88DB7 /* DocumentManager.swift in Sources */, 2F42E9E02B91BB2500D88DB7 /* PDFListDetailView.swift in Sources */, 2FCDFF822BF3417300158BDE /* TrialView.swift in Sources */, 2F38F3A52BE8BC890002E7D5 /* TrialDetail+Identifiable.swift in Sources */, + 2FB4DBDB2BF4A08000E68AD9 /* MatchingView.swift in Sources */, + 2FB4DBD52BF4946E00E68AD9 /* MatchingState.swift in Sources */, 2F42E9FA2B91BDD300D88DB7 /* InstructionsView.swift in Sources */, 56F6F2A02AB441930022FE5A /* ContributionsList.swift in Sources */, 2F42E9D22B91BAB900D88DB7 /* OwnYourDataButton.swift in Sources */, - 2F42E9E82B91BB8300D88DB7 /* ClinicalTrialsView.swift in Sources */, + 2F42E9E82B91BB8300D88DB7 /* ViewClinicalTrialsView.swift in Sources */, 566155292AB8447C00209B80 /* Package+LicenseType.swift in Sources */, 5680DD392AB8983D004E6D4A /* PackageCell.swift in Sources */, 2F5E32BD297E05EA003432F8 /* OwnYourDataDelegate.swift in Sources */, @@ -697,6 +772,7 @@ 653A2551283387FE005D4D48 /* OwnYourData.swift in Sources */, 2F42EA0E2B91CD7100D88DB7 /* OpenAIAPIKey.swift in Sources */, 2FE5DC3629EDD7CA004B9AB4 /* HealthKitPermissions.swift in Sources */, + 2FB4DBB82BF4781D00E68AD9 /* MatchingModule.swift in Sources */, 5661552E2AB854C000209B80 /* PackageHelper.swift in Sources */, 2F42E9FF2B91BE3100D88DB7 /* FHIRStore+Extensions.swift in Sources */, ); @@ -1231,7 +1307,7 @@ repositoryURL = "https://github.com/StanfordSpezi/SpeziLLM.git"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 0.8.1; + minimumVersion = 0.8.2; }; }; 2F42E9BE2B91B70F00D88DB7 /* XCRemoteSwiftPackageReference "SpeziFHIR" */ = { diff --git a/OwnYourData.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OwnYourData.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4ac37a5..5291c2d 100644 --- a/OwnYourData.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/OwnYourData.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -132,8 +132,8 @@ "repositoryURL": "https://github.com/StanfordBDHG/llama.cpp", "state": { "branch": null, - "revision": "7bfd6d4b5bbc9fd47bd023bdbb35f96c827977f3", - "version": "0.2.1" + "revision": "6839853a321778906e210a33ee2c6aec52f34c97", + "version": "0.3.3" } }, { @@ -249,8 +249,8 @@ "repositoryURL": "https://github.com/StanfordSpezi/SpeziLLM.git", "state": { "branch": null, - "revision": "cbaf20496e600c985dea2358f35a497fe4964116", - "version": "0.8.1" + "revision": "94f14f6a1d0fb4c7bb54efa6b6241f18dfc5004d", + "version": "0.8.2" } }, { diff --git a/OwnYourData.xcodeproj/xcshareddata/xcschemes/OwnYourData.xcscheme b/OwnYourData.xcodeproj/xcshareddata/xcschemes/OwnYourData.xcscheme index ae7e63f..65ee5e3 100644 --- a/OwnYourData.xcodeproj/xcshareddata/xcschemes/OwnYourData.xcscheme +++ b/OwnYourData.xcodeproj/xcshareddata/xcschemes/OwnYourData.xcscheme @@ -79,7 +79,7 @@ + isEnabled = "NO"> ModelsR4.Bundle { + guard let resourceURL = self.url(forResource: name, withExtension: "json") else { + fatalError("Could not find the resource \"\(name)\".json in the SpeziFHIRMockPatients Resources folder.") + } + + let loadingTask = _Concurrency.Task { + let resourceData = try Data(contentsOf: resourceURL) + return try JSONDecoder().decode(Bundle.self, from: resourceData) + } + + do { + return try await loadingTask.value + } catch { + fatalError("Could not decode the FHIR bundle named \"\(name).json\": \(error)") + } + } +} diff --git a/OwnYourData/ClinicalTrials/ClinicalTrialsView.swift b/OwnYourData/ClinicalTrials/ClinicalTrialsView.swift deleted file mode 100644 index a053d44..0000000 --- a/OwnYourData/ClinicalTrials/ClinicalTrialsView.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// This source file is part of the OwnYourData based on the Stanford Spezi Template Application project -// -// SPDX-FileCopyrightText: 2023 Stanford University -// -// SPDX-License-Identifier: MIT -// - -import CoreLocation -import OpenAPIClient -import SpeziLocation -import SpeziViews -import SwiftUI - - -struct ClinicalTrialsView: View { - @Environment(SpeziLocation.self) private var speziLocation - - @State private var viewState: ViewState = .idle - @State private var trials: [TrialDetail] = [] - @State private var zipCode: String = "10025" - @State private var searchDistance: String = "100" - - - var body: some View { - NavigationStack { - content - .task { - await fetchTrials() - } - .viewStateAlert(state: $viewState) - .navigationTitle("NCI Trials") - } - } - - @ViewBuilder @MainActor private var content: some View { -// switch viewState { -// case .processing, .error: -// VStack { -// ProgressView() -// Text("Loading NCI Trials") -// } -// case .idle: -// if trials.isEmpty { -// Text("No trials found.") -// } else { - List { - searchSection - Section { - ForEach(trials, id: \.self) { trial in - TrialView(trial: trial) - } - } - } -// } -// } - } - - @ViewBuilder @MainActor private var searchSection: some View { - Section { - LabeledContent { - TextField("Enter Zip Code", text: $zipCode) - } label: { - Text("Zip Code:") - .bold() - } - LabeledContent { - TextField("Enter Distance (mi)", text: $searchDistance) - } label: { - Text("Distance:") - .bold() - } - HStack { - Spacer() - AsyncButton("Update Search", state: $viewState) { - await fetchTrials() - } - Spacer() - } - } - .disabled(viewState == .processing) - } - - // Function to convert zip code to coordinates - private func getLocationFromZipCode(zipCode: String) async -> CLLocation? { - do { - let placemarks = try await CLGeocoder().geocodeAddressString(zipCode) - if let location = placemarks.first?.location { - return location - } - } catch { - print("Error converting zip code to coordinates: \(error)") - } - return nil - } - - // Function to convert coordinates to zip code - private func getZipCodeFromLocation(location: CLLocation) async { - do { - let placemarks = try await CLGeocoder().reverseGeocodeLocation(location) - if let postalCode = placemarks.first?.postalCode { - zipCode = postalCode - } else { - print("Postal code not found in placemark") - } - } catch { - print("Reverse geocoding failed with error: \(error.localizedDescription)") - } - } - - // Function to load the trials from NCI API - private func loadTrials(coordinate: CLLocationCoordinate2D?) async throws -> TrialResponse { - OpenAPIClientAPI.customHeaders = ["X-API-KEY": "tkMGxBkgOC4TDCUfjcPdw7eeZsuuZual632WpUnH"] - CodableHelper.dateFormatter = NICTrialsAPIDateFormatter() - - return try await withCheckedThrowingContinuation { continuation in - TrialsAPI.searchTrialsByGet( - size: 50, - keyword: "breast", - trialStatus: "OPEN", - phase: "III", - primaryPurpose: "TREATMENT", - sitesOrgCoordinatesLat: coordinate?.latitude, - sitesOrgCoordinatesLon: coordinate?.longitude, - sitesOrgCoordinatesDist: searchDistance + "mi" - ) { data, error in - guard let data else { - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(throwing: DownloadException.responseFailed) - } - return - } - - continuation.resume(returning: data) - } - } - } - - - private func fetchTrials() async { - viewState = .processing // Set loading state - - // Reload trials with updated search parameters - trials.removeAll() // Clear existing trials - - // Fetch trials with updated parameters - var coordinate: CLLocationCoordinate2D? - - if let userLocation = try? await speziLocation.getLatestLocations().first { - coordinate = userLocation.coordinate - await getZipCodeFromLocation(location: userLocation) - } else if let location = await getLocationFromZipCode(zipCode: zipCode) { - coordinate = location.coordinate - } - - do { - trials = try await loadTrials(coordinate: coordinate).data ?? [] - viewState = .idle - } catch { - viewState = .error(AnyLocalizedError(error: error)) - } - } -} - - -#Preview { - ClinicalTrialsView() -} diff --git a/OwnYourData/ClinicalTrials/NCITrialsModel.swift b/OwnYourData/ClinicalTrials/NCITrialsModel.swift new file mode 100644 index 0000000..ba1cce8 --- /dev/null +++ b/OwnYourData/ClinicalTrials/NCITrialsModel.swift @@ -0,0 +1,101 @@ +// +// This source file is part of the OwnYourData based on the SpeziFHIR project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import CoreLocation +import OpenAPIClient +import SpeziLocation + + +@Observable +class NCITrialsModel { + #warning("Insert NIC Token here to test the app.") + private static let apiKey: String = "" + + + private let locationModule: SpeziLocation + + private(set) var trials: [TrialDetail] = [] + var zipCode: String = "10025" + var searchDistance: String = "100" + + + init(locationModule: SpeziLocation) { + self.locationModule = locationModule + } + + + func fetchTrials(keywords: [String] = []) async throws { + var coordinate: CLLocationCoordinate2D? + + if let userLocation = try? await locationModule.getLatestLocations().first { + coordinate = userLocation.coordinate + await getZipCodeFromLocation(location: userLocation) + } else if let location = await getLocationFromZipCode(zipCode: zipCode) { + coordinate = location.coordinate + } + + trials = try await loadTrials(keywords: keywords, coordinate: coordinate).data ?? [] + } + + + private func getLocationFromZipCode(zipCode: String) async -> CLLocation? { + do { + let placemarks = try await CLGeocoder().geocodeAddressString(zipCode) + if let location = placemarks.first?.location { + return location + } + } catch { + print("Error converting zip code to coordinates: \(error)") + } + return nil + } + + private func getZipCodeFromLocation(location: CLLocation) async { + do { + let placemarks = try await CLGeocoder().reverseGeocodeLocation(location) + if let postalCode = placemarks.first?.postalCode { + zipCode = postalCode + } else { + print("Postal code not found in placemark") + } + } catch { + print("Reverse geocoding failed with error: \(error.localizedDescription)") + } + } + + private func loadTrials(keywords: [String], coordinate: CLLocationCoordinate2D?) async throws -> TrialResponse { + OpenAPIClientAPI.customHeaders = ["X-API-KEY": Self.apiKey] + CodableHelper.dateFormatter = NICTrialsAPIDateFormatter() + + let keywords = keywords.filter { !$0.isEmpty } + + return try await withCheckedThrowingContinuation { continuation in + TrialsAPI.searchTrialsByGet( + size: 20, + keyword: keywords.isEmpty ? nil : keywords.joined(separator: " "), + trialStatus: "OPEN", + phase: "III", + primaryPurpose: "TREATMENT", + sitesOrgCoordinatesLat: coordinate?.latitude, + sitesOrgCoordinatesLon: coordinate?.longitude, + sitesOrgCoordinatesDist: searchDistance + "mi" + ) { data, error in + guard let data else { + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(throwing: DownloadException.responseFailed) + } + return + } + + continuation.resume(returning: data) + } + } + } +} diff --git a/OwnYourData/ClinicalTrials/ViewClinicalTrialsView.swift b/OwnYourData/ClinicalTrials/ViewClinicalTrialsView.swift new file mode 100644 index 0000000..3644f74 --- /dev/null +++ b/OwnYourData/ClinicalTrials/ViewClinicalTrialsView.swift @@ -0,0 +1,99 @@ +// +// This source file is part of the OwnYourData based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import CoreLocation +import OpenAPIClient +import SpeziLocation +import SpeziViews +import SwiftUI + + +struct ViewClinicalTrialsView: View { + @Environment(NCITrialsModel.self) private var nciTrialsModel + + @State private var viewState: ViewState = .idle + + + var body: some View { + NavigationStack { + content + .task { + await fetchTrials() + } + .viewStateAlert(state: $viewState) + .navigationTitle("NCI Trials") + } + } + + @ViewBuilder @MainActor private var content: some View { + switch viewState { + case .processing, .error: + VStack { + ProgressView() + .padding() + Text("Loading NCI Trials") + } + case .idle: + if nciTrialsModel.trials.isEmpty { + Text("No trials found.") + } else { + List { + searchSection + Section { + ForEach(nciTrialsModel.trials, id: \.self) { trial in + TrialView(trial: trial) + } + } + } + } + } + } + + @ViewBuilder @MainActor private var searchSection: some View { + @Bindable var nciTrialsModel = nciTrialsModel + Section { + LabeledContent { + TextField("Enter Zip Code", text: $nciTrialsModel.zipCode) + } label: { + Text("Zip Code:") + .bold() + } + LabeledContent { + TextField("Enter Distance (mi)", text: $nciTrialsModel.searchDistance) + } label: { + Text("Distance:") + .bold() + } + HStack { + Spacer() + AsyncButton("Update Search", state: $viewState) { + await fetchTrials() + } + Spacer() + } + } + .disabled(viewState == .processing) + } + + + private func fetchTrials() async { + viewState = .processing // Set loading state + + do { + try await nciTrialsModel.fetchTrials() + viewState = .idle + } catch { + viewState = .error(AnyLocalizedError(error: error)) + } + } +} + + +#Preview { + ViewClinicalTrialsView() +} diff --git a/OwnYourData/Home.swift b/OwnYourData/Home.swift index 23fefea..f43142b 100644 --- a/OwnYourData/Home.swift +++ b/OwnYourData/Home.swift @@ -21,6 +21,7 @@ struct HomeView: View { @Environment(FHIRStore.self) var fjirStore @State private var presentingAccount = false + @State private var showMatchingView = false @State private var showClinicalTrialsView = false @@ -37,6 +38,9 @@ struct HomeView: View { InstructionsView() } OwnYourDataButton(title: "Match Me") { + showMatchingView = true + } + OwnYourDataButton(title: "Local NCI Trials") { showClinicalTrialsView = true } OwnYourDataButton( @@ -59,9 +63,11 @@ struct HomeView: View { } } } + .sheet(isPresented: $showMatchingView) { + MatchingView() + } .sheet(isPresented: $showClinicalTrialsView) { - ClinicalTrialsView() - .edgesIgnoringSafeArea(.all) + ViewClinicalTrialsView() } .sheet(isPresented: $presentingAccount) { AccountSheet() diff --git a/OwnYourData/OwnYourDataDelegate.swift b/OwnYourData/OwnYourDataDelegate.swift index b63d390..9467009 100644 --- a/OwnYourData/OwnYourDataDelegate.swift +++ b/OwnYourData/OwnYourDataDelegate.swift @@ -69,6 +69,8 @@ class OwnYourDataDelegate: SpeziAppDelegate { DocumentManager() SpeziLocation() + + MatchingModule() } } diff --git a/OwnYourData/Resources/ConsentDocument.md b/OwnYourData/Resources/ConsentDocument.md index 81ab5f9..14a5905 100644 --- a/OwnYourData/Resources/ConsentDocument.md +++ b/OwnYourData/Resources/ConsentDocument.md @@ -1,43 +1,67 @@ **Consent for OwnYourData Application** **Introduction** + Welcome to OwnYourData. To provide you with the best experience and services, we need your consent to access your health records and match them with clinical trials listed in the NCI Clinical Trial Search database. **Purpose** + The purpose of accessing your health records is to identify suitable clinical trials that may benefit you based on your health data. **Data Security** -* **Encryption**: All data pulled from the EHR via the FHIR API will reside on your mobile device and be encrypted both in flight and at rest. Any data generated and aggregated in the application is stored locally using an Apple Keychain-based encryption. Data sent to the cloud-based LLM is encrypted in flight. -* **No Sharing Without Permission**: Only minimal data will be shared with the API, and no data sharing or processes will be performed without your explicit permission. If you agree to participate in a clinical trial, only the necessary information will be shared with the trial organizers. + +**Encryption**: All data pulled from the EHR via the FHIR API will reside on your mobile device and be encrypted both in flight and at rest. Any data generated and aggregated in the application is stored locally using an Apple Keychain-based encryption. Data sent to the cloud-based LLM is encrypted in flight. + +**No Sharing Without Permission**: Only minimal data will be shared with the API, and no data sharing or processes will be performed without your explicit permission. If you agree to participate in a clinical trial, only the necessary information will be shared with the trial organizers. **Data Storage** + * Your patient data is stored in the Apple Health app. + * Users are currently responsible for providing an OpenAI account for cloud-based services. +**Modeled Consent for Recontact** + +By using OwnYourData, you agree that we may contact you in the future regarding your eligibility for additional clinical trials or research studies. We will only contact you if we believe you may be a good fit for a study based on the health data you have provided. You have the right to opt out of these communications at any time by adjusting your settings in the application or by contacting our support team. + **Withdrawal of Consent** + You can withdraw your consent at any time by adjusting your settings in the application or by contacting our support team. **Privacy Policy** + 1. **Information We Collect** + We collect personal health information that you provide or authorize us to access. This includes medical history, current medications, and other relevant health data. 2. **How We Use Your Information** - * **Clinical Trial Matching**: To match your health records with clinical trials in the NCI Clinical Trial Search database. - * **Service Improvement**: To improve our services and provide you with better matches. + + **Clinical Trial Matching**: To match your health records with clinical trials in the NCI Clinical Trial Search database. + + **Service Improvement**: To improve our services and provide you with better matches. + + **Recontact for Additional Studies*: To contact you regarding your eligibility for additional clinical trials or research studies based on your health data. 3. **Data Security** + We implement industry-standard security measures to protect your information. This includes encryption of data both in transit and at rest. 4. **Data Sharing** + Your data will not be shared with any third party without your explicit consent. If you agree to participate in a clinical trial, only the necessary information will be shared with the trial organizers. 5. **Your Rights** - * **Access**: You have the right to access your data at any time. - * **Correction**: You can correct any inaccuracies in your data. - * **Deletion**: You can request the deletion of your data. - * **Withdrawal**: You can withdraw your consent and stop using the app at any time. -6. **Contact Us** - If you have any questions or concerns about our privacy practices or your data, please contact our support team at alexa.aalmai@gmail.com + **Access**: You have the right to access your data at any time. + **Correction**: You can correct any inaccuracies in your data. + **Deletion**: You can request the deletion of your data. + + **Withdrawal**: You can withdraw your consent and stop using the app at any time. + + **Opt-Out**: You can opt out of communications regarding additional studies at any time. + +6. **Contact Us** + + If you have any questions or concerns about our privacy practices or your data, please contact our support team at alexa.aalmai@gmail.com diff --git a/OwnYourData/Resources/ExamplePatients/BreastCancerExample.json b/OwnYourData/Resources/ExamplePatients/BreastCancerExample.json new file mode 100644 index 0000000..1da2c4e --- /dev/null +++ b/OwnYourData/Resources/ExamplePatients/BreastCancerExample.json @@ -0,0 +1,438 @@ +{ + "resourceType":"Bundle", + "type":"transaction", + "entry":[ + { + "fullUrl":"urn:uuid:ad134528-56a5-35fd-c37f-466ff119c625", + "resource":{ + "resourceType":"Patient", + "id":"ad134528-56a5-35fd-c37f-466ff119c625", + "meta":{ + "profile":[ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient" + ] + }, + "text":{ + "status":"generated", + "div":"
Generated by OpenAI.
" + }, + "extension":[ + { + "url":"http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", + "extension":[ + { + "url":"ombCategory", + "valueCoding":{ + "system":"urn:oid:2.16.840.1.113883.6.238", + "code":"2106-3", + "display":"White" + } + }, + { + "url":"text", + "valueString":"White" + } + ] + }, + { + "url":"http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", + "extension":[ + { + "url":"ombCategory", + "valueCoding":{ + "system":"urn:oid:2.16.840.1.113883.6.238", + "code":"2186-5", + "display":"Not Hispanic or Latino" + } + }, + { + "url":"text", + "valueString":"Not Hispanic or Latino" + } + ] + }, + { + "url":"http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName", + "valueString":"Doe" + }, + { + "url":"http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", + "valueCode":"F" + }, + { + "url":"http://hl7.org/fhir/StructureDefinition/patient-birthPlace", + "valueAddress":{ + "city":"Springfield", + "state":"Illinois", + "country":"US" + } + }, + { + "url":"http://synthetichealth.github.io/synthea/disability-adjusted-life-years", + "valueDecimal":15.0 + }, + { + "url":"http://synthetichealth.github.io/synthea/quality-adjusted-life-years", + "valueDecimal":70.0 + } + ], + "identifier":[ + { + "system":"http://hospital.smarthealthit.org", + "value":"123456789" + }, + { + "type":{ + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/v2-0203", + "code":"MR", + "display":"Medical Record Number" + } + ], + "text":"Medical Record Number" + }, + "system":"http://hospital.smarthealthit.org", + "value":"987654321" + } + ], + "name":[ + { + "use":"official", + "family":"Smith", + "given":[ + "Jane" + ], + "prefix":[ + "Ms." + ] + } + ], + "telecom":[ + { + "system":"phone", + "value":"555-123-4567", + "use":"home" + } + ], + "gender":"female", + "birthDate":"1965-08-20", + "address":[ + { + "extension":[ + { + "url":"http://hl7.org/fhir/StructureDefinition/geolocation", + "extension":[ + { + "url":"latitude", + "valueDecimal":39.7817 + }, + { + "url":"longitude", + "valueDecimal":-89.6501 + } + ] + } + ], + "line":[ + "123 Main Street" + ], + "city":"Springfield", + "state":"IL", + "postalCode":"62701", + "country":"US" + } + ], + "maritalStatus":{ + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", + "code":"M", + "display":"Married" + } + ], + "text":"Married" + }, + "multipleBirthBoolean":false, + "communication":[ + { + "language":{ + "coding":[ + { + "system":"urn:ietf:bcp:47", + "code":"en-US", + "display":"English" + } + ], + "text":"English" + } + } + ] + }, + "request":{ + "method":"POST", + "url":"Patient" + } + }, + { + "fullUrl":"urn:uuid:78965432-1234-5678-90ab-cdef12345678", + "resource":{ + "resourceType":"Encounter", + "id":"78965432-1234-5678-90ab-cdef12345678", + "meta":{ + "profile":[ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter" + ] + }, + "identifier":[ + { + "use":"official", + "system":"http://hospital.smarthealthit.org", + "value":"78965432-1234-5678-90ab-cdef12345678" + } + ], + "status":"finished", + "class":{ + "system":"http://terminology.hl7.org/CodeSystem/v3-ActCode", + "code":"AMB" + }, + "type":[ + { + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"185345009", + "display":"Encounter for symptom" + } + ], + "text":"Encounter for symptom" + } + ], + "subject":{ + "reference":"urn:uuid:ad134528-56a5-35fd-c37f-466ff119c625", + "display":"Ms. Jane Smith" + }, + "participant":[ + { + "type":[ + { + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/v3-ParticipationType", + "code":"PPRF", + "display":"primary performer" + } + ], + "text":"primary performer" + } + ], + "individual":{ + "reference":"Practitioner/123456", + "display":"Dr. John Doe" + } + } + ], + "period":{ + "start":"2023-05-01T08:30:00-05:00", + "end":"2023-05-01T09:00:00-05:00" + }, + "reasonCode":[ + { + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"254837009", + "display":"Breast lump" + } + ] + } + ], + "location":[ + { + "location":{ + "reference":"Location/1", + "display":"General Hospital" + } + } + ], + "serviceProvider":{ + "reference":"Organization/1", + "display":"General Hospital" + } + }, + "request":{ + "method":"POST", + "url":"Encounter" + } + }, + { + "fullUrl":"urn:uuid:45678901-2345-6789-0abc-def123456789", + "resource":{ + "resourceType":"Condition", + "id":"45678901-2345-6789-0abc-def123456789", + "meta":{ + "profile":[ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition" + ] + }, + "clinicalStatus":{ + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/condition-clinical", + "code":"active" + } + ] + }, + "verificationStatus":{ + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/condition-ver-status", + "code":"confirmed" + } + ] + }, + "category":[ + { + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/condition-category", + "code":"encounter-diagnosis", + "display":"Encounter Diagnosis" + } + ] + } + ], + "code":{ + "coding":[ + { + "system":"http://snomed.info/sct", + "code":"254837009", + "display":"Breast lump" + } + ], + "text":"Breast lump" + }, + "subject":{ + "reference":"urn:uuid:ad134528-56a5-35fd-c37f-466ff119c625" + }, + "encounter":{ + "reference":"urn:uuid:78965432-1234-5678-90ab-cdef12345678" + }, + "onsetDateTime":"2023-05-01T08:00:00-05:00", + "recordedDate":"2023-05-01T08:30:00-05:00" + }, + "request":{ + "method":"POST", + "url":"Condition" + } + }, + { + "fullUrl":"urn:uuid:67890123-4567-89ab-cdef-0123456789ab", + "resource":{ + "resourceType":"DiagnosticReport", + "id":"67890123-4567-89ab-cdef-0123456789ab", + "meta":{ + "profile":[ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-diagnosticreport-note" + ] + }, + "status":"final", + "category":[ + { + "coding":[ + { + "system":"http://loinc.org", + "code":"LP29684-5", + "display":"Radiology" + } + ] + } + ], + "code":{ + "coding":[ + { + "system":"http://loinc.org", + "code":"26346-6", + "display":"Breast Mammogram" + } + ] + }, + "subject":{ + "reference":"urn:uuid:ad134528-56a5-35fd-c37f-466ff119c625" + }, + "encounter":{ + "reference":"urn:uuid:78965432-1234-5678-90ab-cdef12345678" + }, + "effectiveDateTime":"2023-05-01T09:00:00-05:00", + "issued":"2023-05-01T10:00:00-05:00", + "performer":[ + { + "reference":"Practitioner/123456", + "display":"Dr. John Doe" + } + ], + "result":[ + { + "reference":"Observation/1" + } + ] + }, + "request":{ + "method":"POST", + "url":"DiagnosticReport" + } + }, + { + "fullUrl":"urn:uuid:01234567-89ab-cdef-0123-456789abcdef", + "resource":{ + "resourceType":"Observation", + "id":"01234567-89ab-cdef-0123-456789abcdef", + "meta":{ + "profile":[ + "http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-lab" + ] + }, + "status":"final", + "category":[ + { + "coding":[ + { + "system":"http://terminology.hl7.org/CodeSystem/observation-category", + "code":"laboratory", + "display":"Laboratory" + } + ] + } + ], + "code":{ + "coding":[ + { + "system":"http://loinc.org", + "code":"33747-0", + "display":"Histopathology (tissue) study" + } + ] + }, + "subject":{ + "reference":"urn:uuid:ad134528-56a5-35fd-c37f-466ff119c625" + }, + "encounter":{ + "reference":"urn:uuid:78965432-1234-5678-90ab-cdef12345678" + }, + "effectiveDateTime":"2023-05-02T08:00:00-05:00", + "issued":"2023-05-02T10:00:00-05:00", + "performer":[ + { + "reference":"Practitioner/789012", + "display":"Dr. Alice Brown" + } + ], + "valueString":"Invasive ductal carcinoma, Grade 2" + }, + "request":{ + "method":"POST", + "url":"Observation" + } + } + ] +} diff --git a/OwnYourData/Resources/ExamplePatients/BreastCancerExample.json.license b/OwnYourData/Resources/ExamplePatients/BreastCancerExample.json.license new file mode 100644 index 0000000..03ca624 --- /dev/null +++ b/OwnYourData/Resources/ExamplePatients/BreastCancerExample.json.license @@ -0,0 +1,6 @@ + +This source file is part of the OwnYourData based on the Stanford Spezi Template Application project + +SPDX-FileCopyrightText: 2023 Stanford University + +SPDX-License-Identifier: MIT diff --git a/OwnYourData/Resources/Localizable.xcstrings b/OwnYourData/Resources/Localizable.xcstrings index 6774339..2014bd2 100644 --- a/OwnYourData/Resources/Localizable.xcstrings +++ b/OwnYourData/Resources/Localizable.xcstrings @@ -49,6 +49,9 @@ }, "Enter Zip Code" : { + }, + "Error matching you to NCI trials. Please try again." : { + }, "Export" : { @@ -93,6 +96,18 @@ }, "How It Works" : { + }, + "Identified Keywords: %@" : { + + }, + "Identifying best matching trials ..." : { + + }, + "Inspecting FHIR resources ..." : { + + }, + "Keyword Identification Prompt" : { + "comment" : "Title of the keyword identification prompt." }, "Learn More" : { @@ -102,9 +117,80 @@ }, "License Information" : { + }, + "LLM_GET_FHIR_RESOURCES_FUNCTION_DESCRIPTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retrieve specific FHIR (Fast Healthcare Interoperability Resources) from a healthcare data store based on the given identifiers. This function filters and summarizes relevant health records, providing detailed summaries of the most pertinent information. If no matching resources are found, it will notify you accordingly. Use this function to access the latest and most relevant health records efficiently." + } + } + } + }, + "LLM_GET_FHIR_RESOURCES_PARAMETER_DESCRIPTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Specify a list of resource identifiers to retrieve FHIR resources from the data store. These identifiers should correspond to the health records you need. The function will filter and fetch only the relevant resources based on these identifiers, allowing for efficient and targeted data retrieval. Use this parameter to guide the function in fetching the specific health information required for your task." + } + } + } + }, + "LLM_GET_TRIALS_FUNCTION_DESCRIPTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retrieve specific clinical trials from the NCI (National Cancer Institute) Trials API using the provided trial identifiers. This function filters the trials based on the given identifiers and returns detailed information about each trial, including titles, descriptions, and inclusion criteria. Use this function to access and summarize relevant clinical trial data efficiently." + } + } + } + }, + "LLM_GET_TRIALS_PARAMETER_DESCRIPTION" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Specify a list of trial identifiers to retrieve detailed information about clinical trials from the NCI Trials API. These identifiers should correspond to the trials you need. The function will filter and fetch the relevant trials based on these identifiers, providing comprehensive details for each trial. Use this parameter to guide the function in fetching specific clinical trial information required for your task." + } + } + } + }, + "LLM_KEYWORD_IDENTIFICATION_PROMPT" : { + "comment" : "Content of the keyword identification prompt.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your task is to identify a minimal set of distinct and unique keywords to conduct a trial search for a patient diagnosed with cancer. 
\nUtilize the \"get_resources\" function to access the patient's FHIR resources. \nThe generic patient information will be passed into this context after this prompt.\nUtilize the function call as often as needed until you have a comprehensive picture of the patient's health status and you feel confident that you can respond with a few distinct keywords for the NIC trials API search.
\nAvoid any generic terms like \"cancer\" and other elements that might appear in all trial descriptions.
Only try to provide 5 or less keywords.\nTry to be as concrete and as narrow as possible based on the relevant FHIR resources.\n
Do not engage in any conversation; only respond with a list of keywords separated by commas without any other context, introduction, or surrounding information. \nThe resulting strings will be parsed for further processing." + } + } + } + }, + "LLM_TRIAL_MATCHING_PROMPT" : { + "comment" : "Content of the trial matching prompt.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your task is to identify a set of matching trials for the patient diagnosed with cancer using the NCI trials API. 

You will be provided with a set of keywords identified in a previous run of an LLM based on the patient's FHIR health records.
\nYou can request any health records using the get_records function calling mechanisms. Utilize the function call as often as needed until you have a comprehensive picture of the patient's health status.

Utilize the get_trials function to retrieve information about the possible trials retrieved from the NCI API using the keywords identified in a previous run.
Ensure that the trial description and inclusion criteria match the patient's health records.
\nRespond with all trial identifiers that seem a good match.
The trial identifies must be separated by commas.\nIt is encouraged to return 3-5 possible matching trials to provide the patient some choice but ensure that the trials are matching the patient profile.
Only use the trial identifiers that are parameter options for the get_trials function; do not make up or combine trial identifiers.
\nDo not engage in any conversation; only respond with a list of identifiers separated by commas without any other context, introduction, or surrounding information. \nThe resulting identifiers will be parsed for further processing." + } + } + } + }, + "Loading NCI Trials" : { + + }, + "Loading NCI trials based on FHIR resources ..." : { + }, "Location Access" : { + }, + "Match Me" : { + }, "Navigate to the Browse tab." : { @@ -117,6 +203,9 @@ }, "No Documents" : { + }, + "No trials found." : { + }, "Open the Apple Health app." : { @@ -135,6 +224,9 @@ }, "Please refer to the individual repository links for packages without license labels." : { + }, + "Reload Matching" : { + }, "Repository Link" : { @@ -150,6 +242,9 @@ }, "Share your data with trial coordinators." : { + }, + "Start Matching" : { + }, "Step Content ..." : { @@ -159,21 +254,40 @@ }, "The following list contains all Swift Package dependencies of the SpeziOwnYourData." : { + }, + "The medical record does not include any FHIR resources for the search term %@." : { + }, "The OwnYourData App Icon" : { + }, + "This is the summary of the requested %@:\n\n%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "This is the summary of the requested %1$@:\n\n%2$@" + } + } + } }, "This project is licensed under the MIT License." : { }, "Trial Matching" : { + }, + "Trial Matching Prompt" : { + "comment" : "Title of the trial matching prompt." }, "Update Search" : { }, "Use HealthKit Resources" : { + }, + "Use the OwnYourData algorithm to match you to possible NCI trials." : { + }, "We automatically match you to active trials." : { diff --git a/OwnYourData/TrialsMatching/Helpers/FHIRPrompt+OwnYourData.swift b/OwnYourData/TrialsMatching/Helpers/FHIRPrompt+OwnYourData.swift new file mode 100644 index 0000000..a3900d8 --- /dev/null +++ b/OwnYourData/TrialsMatching/Helpers/FHIRPrompt+OwnYourData.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the OwnYourData based on the SpeziFHIR project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziFHIRLLM + + +extension FHIRPrompt { + static let keywordIdentification: FHIRPrompt = { + FHIRPrompt( + storageKey: "prompt.keywordIdentification", + localizedDescription: String( + localized: "Keyword Identification Prompt", + comment: "Title of the keyword identification prompt." + ), + defaultPrompt: String( + localized: "LLM_KEYWORD_IDENTIFICATION_PROMPT", + comment: "Content of the keyword identification prompt." + ) + ) + }() + + static let trialMatching: FHIRPrompt = { + FHIRPrompt( + storageKey: "prompt.trialMatching", + localizedDescription: String( + localized: "Trial Matching Prompt", + comment: "Title of the trial matching prompt." + ), + defaultPrompt: String( + localized: "LLM_TRIAL_MATCHING_PROMPT", + comment: "Content of the trial matching prompt." + ) + ) + }() +} diff --git a/OwnYourData/TrialsMatching/Helpers/FHIRResource+Identifier.swift b/OwnYourData/TrialsMatching/Helpers/FHIRResource+Identifier.swift new file mode 100644 index 0000000..6f1e5f6 --- /dev/null +++ b/OwnYourData/TrialsMatching/Helpers/FHIRResource+Identifier.swift @@ -0,0 +1,27 @@ +// +// This source file is part of the OwnYourData based on the SpeziFHIR project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziFHIR + + +extension FHIRResource { + private static let dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM-dd-yyyy" + return dateFormatter + }() + + + var functionCallIdentifier: String { + resourceType.filter { !$0.isWhitespace } + + displayName.filter { !$0.isWhitespace } + + "-" + + (date.map { FHIRResource.dateFormatter.string(from: $0) } ?? "") + } +} diff --git a/OwnYourData/TrialsMatching/Helpers/TrialDetail+LLMIndentifier.swift b/OwnYourData/TrialsMatching/Helpers/TrialDetail+LLMIndentifier.swift new file mode 100644 index 0000000..8da8cbd --- /dev/null +++ b/OwnYourData/TrialsMatching/Helpers/TrialDetail+LLMIndentifier.swift @@ -0,0 +1,17 @@ +// +// This source file is part of the OwnYourData based on the SpeziFHIR project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import OpenAPIClient + + +extension TrialDetail { + var llmIdentifier: String? { + briefTitle?.components(separatedBy: CharacterSet.alphanumerics.inverted).joined() + } +} diff --git a/OwnYourData/TrialsMatching/LLMFunctions/GetFHIRResourceLLMFunction.swift b/OwnYourData/TrialsMatching/LLMFunctions/GetFHIRResourceLLMFunction.swift new file mode 100644 index 0000000..5cb9cd0 --- /dev/null +++ b/OwnYourData/TrialsMatching/LLMFunctions/GetFHIRResourceLLMFunction.swift @@ -0,0 +1,124 @@ +// +// This source file is part of the OwnYourData based on the SpeziFHIR project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import os +import SpeziFHIR +import SpeziFHIRLLM +import SpeziLLMOpenAI + + +struct GetFHIRResourceLLMFunction: LLMFunction { + static let logger = Logger(subsystem: "edu.stanford.cs342.ownyourdata", category: "FHIRGetResourceLLMFunction") + + static let name = "get_resources" + static let description = String(localized: "LLM_GET_FHIR_RESOURCES_FUNCTION_DESCRIPTION") + + private let fhirStore: FHIRStore + private let resourceSummary: FHIRResourceSummary + + + @Parameter var resources: [String] + + + init( + fhirStore: FHIRStore, + resourceSummary: FHIRResourceSummary, + resourceCountLimit: Int, + allowedResourcesFunctionCallIdentifiers: Set? = nil // swiftlint:disable:this discouraged_optional_collection + ) { + self.fhirStore = fhirStore + self.resourceSummary = resourceSummary + + // Only take newest values of the health records + var allResourcesFunctionCallIdentifiers = Set(fhirStore.allResourcesFunctionCallIdentifier.suffix(resourceCountLimit)) + + // If identifiers are restricted, filter for only allowed function call identifiers of health records. + if let allowedResourcesFunctionCallIdentifiers { + allResourcesFunctionCallIdentifiers.formIntersection(allowedResourcesFunctionCallIdentifiers) + } + + _resources = Parameter( + description: String(localized: "LLM_GET_FHIR_RESOURCES_PARAMETER_DESCRIPTION"), + enum: Array(allResourcesFunctionCallIdentifiers) + ) + } + + + func execute() async throws -> String? { + var functionOutput: [String] = [] + + try await withThrowingTaskGroup(of: [String].self) { outerGroup in + // Iterate over all requested resources by the LLM + for requestedResource in resources { + outerGroup.addTask { + // Fetch relevant FHIR resources matching the resources requested by the LLM + var fittingResources = fhirStore.llmRelevantResources.filter { $0.functionCallIdentifier.contains(requestedResource) } + + // Stores output of nested task group summarizing fitting resources + var nestedFunctionOutputResults = [String]() + + guard !fittingResources.isEmpty else { + nestedFunctionOutputResults.append( + String( + localized: "The medical record does not include any FHIR resources for the search term \(requestedResource)." + ) + ) + return [] + } + + // Filter out fitting resources (if greater than 64 entries) + fittingResources = filterFittingResources(fittingResources) + + try await withThrowingTaskGroup(of: String.self) { innerGroup in + // Iterate over fitting resources and summarizing them + for resource in fittingResources { + innerGroup.addTask { + try await summarizeResource(fhirResource: resource, resourceType: requestedResource) + } + } + + for try await nestedResult in innerGroup { + nestedFunctionOutputResults.append(nestedResult) + } + } + + return nestedFunctionOutputResults + } + } + + for try await result in outerGroup { + functionOutput.append(contentsOf: result) + } + } + + return functionOutput.joined(separator: "\n\n") + } + + private func summarizeResource(fhirResource: FHIRResource, resourceType: String) async throws -> String { + let summary = try await resourceSummary.summarize(resource: fhirResource) + Self.logger.debug("Summary of appended FHIR resource \(resourceType): \(summary.description)") + return String(localized: "This is the summary of the requested \(resourceType):\n\n\(summary.description)") + } + + private func filterFittingResources(_ fittingResources: [FHIRResource]) -> [FHIRResource] { + Self.logger.debug("Overall fitting Resources: \(fittingResources.count)") + + var fittingResources = fittingResources + + if fittingResources.count > 64 { + fittingResources = fittingResources.lazy.sorted(by: { $0.date ?? .distantPast < $1.date ?? .distantPast }).suffix(64) + Self.logger.debug( + """ + Reduced to the following 64 resources: \(fittingResources.map { $0.functionCallIdentifier }.joined(separator: ",")) + """ + ) + } + + return fittingResources + } +} diff --git a/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift b/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift new file mode 100644 index 0000000..0bea568 --- /dev/null +++ b/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift @@ -0,0 +1,55 @@ +// +// This source file is part of the OwnYourData based on the SpeziFHIR project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import os +import SpeziFHIR +import SpeziFHIRLLM +import SpeziLLMOpenAI + + +struct GetTrialsLLMFunction: LLMFunction { + static let logger = Logger(subsystem: "edu.stanford.cs342.ownyourdata", category: "GetTrialLLMFunction") + + static let name = "get_trials" + static let description = String(localized: "LLM_GET_TRIALS_FUNCTION_DESCRIPTION") + + private let nciTrialsModel: NCITrialsModel + + + @Parameter var trailIdentifiers: [String] + + + init( + nciTrialsModel: NCITrialsModel + ) { + self.nciTrialsModel = nciTrialsModel + + _trailIdentifiers = Parameter( + description: String(localized: "LLM_GET_TRIALS_PARAMETER_DESCRIPTION"), + enum: nciTrialsModel.trials.compactMap { $0.llmIdentifier } + ) + } + + + func execute() async throws -> String? { + trailIdentifiers + .compactMap { trailIdentifier in + nciTrialsModel.trials.first(where: { $0.llmIdentifier == trailIdentifier }) + .map { trial in + """ + **Trial \(trailIdentifier)** + + Title: \(trial.briefTitle ?? "") (\(trial.officialTitle ?? "")) + Description: \(trial.detailDescription ?? "") + Incluision Criteria: \(trial.eligibility?.unstructured?.compactMap { $0.description }.joined() ?? "") + """ + } + } + .joined(separator: "\n\n\n") + } +} diff --git a/OwnYourData/TrialsMatching/MatchingModule.swift b/OwnYourData/TrialsMatching/MatchingModule.swift new file mode 100644 index 0000000..b6234db --- /dev/null +++ b/OwnYourData/TrialsMatching/MatchingModule.swift @@ -0,0 +1,154 @@ +// +// This source file is part of the OwnYourData based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import OpenAPIClient +import Spezi +import SpeziFHIR +import SpeziFHIRLLM +import SpeziLLM +import SpeziLLMOpenAI +import SpeziLocalStorage +import SpeziLocation +import SpeziViews +import SwiftUI + + +@Observable +class MatchingModule: Module, EnvironmentAccessible, DefaultInitializable { + public enum Defaults { + public static var llmSchema: LLMOpenAISchema { + LLMOpenAISchema(parameters: LLMOpenAIParameters(modelType: .gpt4_turbo_preview)) + } + } + + + @ObservationIgnored @Dependency private var localStorage: LocalStorage + @ObservationIgnored @Dependency private var llmRunner: LLMRunner + @ObservationIgnored @Dependency private var fhirStore: FHIRStore + @ObservationIgnored @Dependency private var locationModule: SpeziLocation + + @ObservationIgnored @Model private var resourceSummary: FHIRResourceSummary + @ObservationIgnored @Model private var nciTrialsModel: NCITrialsModel + + var state: MatchingState = .idle + private(set) var matchingTrials: [TrialDetail] = [] + private var keywords: [String] = [] + + + required init() {} + + + func configure() { + resourceSummary = FHIRResourceSummary( + localStorage: localStorage, + llmRunner: llmRunner, + llmSchema: Defaults.llmSchema + ) + nciTrialsModel = NCITrialsModel( + locationModule: locationModule + ) + } + + + @MainActor + func matchTrials() async { + do { + withAnimation { + self.state = .fhirInspection + } + let keywords = try await keywordIdentification() + withAnimation { + self.state = .nciLoading + } + try await nciTrialsModel.fetchTrials(keywords: keywords) + if nciTrialsModel.trials.isEmpty { + try await nciTrialsModel.fetchTrials() + } + withAnimation { + self.state = .matching + } + let matchingTrialIds = try await trialsIdentificaiton() + + matchingTrials = nciTrialsModel.trials.filter { trial in matchingTrialIds.contains(where: { $0 == trial.llmIdentifier }) } + + withAnimation { + self.state = .idle + } + } catch { + withAnimation { + self.state = .error(AnyLocalizedError(error: error)) + } + } + } + + @MainActor + private func keywordIdentification() async throws -> [String] { + let llm = llmRunner( + with: LLMOpenAISchema(parameters: .init(modelType: .gpt4_turbo_preview)) { + GetFHIRResourceLLMFunction( + fhirStore: self.fhirStore, + resourceSummary: self.resourceSummary, + resourceCountLimit: 100 + ) + } + ) + + llm.context.append(systemMessage: FHIRPrompt.keywordIdentification.prompt) + + if let patient = fhirStore.patient { + llm.context.append(systemMessage: patient.jsonDescription) + } + + guard let stream = try? await llm.generate() else { + return [] + } + + var output = "" + for try await token in stream { + output.append(token) + } + + keywords = output.components(separatedBy: ",").flatMap { $0.components(separatedBy: " ") }.filter { !$0.isEmpty } + return keywords + } + + @MainActor + private func trialsIdentificaiton() async throws -> [String] { + let llm = llmRunner( + with: LLMOpenAISchema(parameters: .init(modelType: .gpt4_turbo_preview)) { + GetFHIRResourceLLMFunction( + fhirStore: self.fhirStore, + resourceSummary: self.resourceSummary, + resourceCountLimit: 100 + ) + GetTrialsLLMFunction(nciTrialsModel: self.nciTrialsModel) + } + ) + + llm.context.append(systemMessage: FHIRPrompt.trialMatching.prompt) + + if let patient = fhirStore.patient { + llm.context.append(systemMessage: patient.jsonDescription) + } + + if !keywords.isEmpty { + llm.context.append(systemMessage: String(localized: "Identified Keywords: \(keywords.joined(separator: ", "))")) + } + + guard let stream = try? await llm.generate() else { + return [] + } + + var output = "" + for try await token in stream { + output.append(token) + } + + return output.components(separatedBy: ",") + } +} diff --git a/OwnYourData/TrialsMatching/MatchingState.swift b/OwnYourData/TrialsMatching/MatchingState.swift new file mode 100644 index 0000000..ef3a81c --- /dev/null +++ b/OwnYourData/TrialsMatching/MatchingState.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the OwnYourData based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import Foundation +import SpeziViews + + +enum MatchingState: OperationState { + case idle + case fhirInspection + case nciLoading + case matching + case error(LocalizedError) + + + var representation: ViewState { + switch self { + case .idle: + .idle + case .fhirInspection, .nciLoading, .matching: + .processing + case .error(let error): + .error(error) + } + } +} diff --git a/OwnYourData/TrialsMatching/MatchingStateView.swift b/OwnYourData/TrialsMatching/MatchingStateView.swift new file mode 100644 index 0000000..b105883 --- /dev/null +++ b/OwnYourData/TrialsMatching/MatchingStateView.swift @@ -0,0 +1,95 @@ +// +// This source file is part of the OwnYourData based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct MatchingstateLogo: View { + let image: String + let text: LocalizedStringResource + + var body: some View { + VStack(spacing: 32) { + Image(systemName: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 200) + .foregroundStyle(.accent) + .padding() + .accessibilityHidden(true) + Text(text) + .multilineTextAlignment(.center) + ProgressView() + .padding() + } + } +} + + +struct MatchingStateView: View { + @Environment(MatchingModule.self) private var matchingModule + + + var body: some View { + content + .padding(32) + } + + + @ViewBuilder private var content: some View { + switch matchingModule.state { + case .idle: + VStack { + LogoView() + Text("Use the OwnYourData algorithm to match you to possible NCI trials.") + .multilineTextAlignment(.center) + .padding() + Button("Start Matching") { + Task { + await matchingModule.matchTrials() + } + } + } + + case .fhirInspection: + MatchingstateLogo( + image: "text.magnifyingglass", + text: "Inspecting FHIR resources ..." + ) + case .nciLoading: + MatchingstateLogo( + image: "network", + text: "Loading NCI trials based on FHIR resources ..." + ) + case .matching: + MatchingstateLogo( + image: "wand.and.stars.inverse", + text: "Identifying best matching trials ..." + ) + case .error: + VStack { + Image(systemName: "x.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxHeight: 200) + .foregroundStyle(.red) + .accessibilityHidden(true) + .padding() + Text("Error matching you to NCI trials. Please try again.") + } + } + } +} + + +#Preview { + MatchingStateView() + .previewWith { + MatchingModule() + } +} diff --git a/OwnYourData/TrialsMatching/MatchingView.swift b/OwnYourData/TrialsMatching/MatchingView.swift new file mode 100644 index 0000000..ec57697 --- /dev/null +++ b/OwnYourData/TrialsMatching/MatchingView.swift @@ -0,0 +1,61 @@ +// +// This source file is part of the OwnYourData based on the Stanford Spezi Template Application project +// +// SPDX-FileCopyrightText: 2023 Stanford University +// +// SPDX-License-Identifier: MIT +// + +import SpeziViews +import SwiftUI + + +struct MatchingView: View { + @Environment(MatchingModule.self) private var matchingModule + + + var body: some View { + NavigationStack { + Group { + if matchingModule.matchingTrials.isEmpty { + MatchingStateView() + } else { + List { + Section { + ForEach(matchingModule.matchingTrials, id: \.self) { trial in + TrialView(trial: trial) + } + } + } + } + } + .navigationTitle("Match Me") + .toolbar { + if !(matchingModule.matchingTrials.isEmpty || matchingModule.state.representation == .processing) { + AsyncButton( + action: { + await matchingModule.matchTrials() + }, + label: { + Image(systemName: "arrow.counterclockwise") + .accessibilityLabel(Text("Reload Matching")) + } + ) + .disabled(matchingModule.state.representation == .processing) + } + } + .viewStateAlert(state: matchingModule.state) + .task { + await matchingModule.matchTrials() + } + } + } +} + + +#Preview { + MatchingView() + .previewWith { + MatchingModule() + } +}