From 33c96b76abd21531beba889c0c40cf4497455a3e Mon Sep 17 00:00:00 2001 From: Philipp Zagar Date: Mon, 11 Nov 2024 09:51:38 +0100 Subject: [PATCH] Fix NCI Trial API type error, update used GPT models, fix warnings and deprecations, Swift 6 language mode concurrency compatibility --- .../Classes/OpenAPIs/Extensions.swift | 2 +- .../Models/StructuredEligibility.swift | 4 +- .../OpenAPIs/OpenISO8601DateFormatter.swift | 2 +- .../docs/StructuredEligibility.md | 2 +- .../ClinicalTrials/NCITrialsModule.swift | 8 ++-- .../NICTrialsAPIDateFormatter.swift | 2 +- .../TrialDetail+Identifiable.swift | 2 +- .../Documents/PDFDocument+Transferable.swift | 2 +- .../CodableArray+RawRepresentable.swift | 2 +- OwnYourData/Home.swift | 4 +- OwnYourData/OwnYourDataDelegate.swift | 21 +++++++++- OwnYourData/OwnYourDataStandard.swift | 37 +++++++--------- OwnYourData/Resources/Localizable.xcstrings | 42 +++++++++++++++---- .../LLMFunctions/GetTrialsLLMFunction.swift | 2 +- .../TrialsMatching/MatchingModule.swift | 19 +++++---- 15 files changed, 96 insertions(+), 55 deletions(-) diff --git a/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Extensions.swift b/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Extensions.swift index 5aa8d34..192b35d 100644 --- a/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Extensions.swift +++ b/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Extensions.swift @@ -107,7 +107,7 @@ extension JSONEncodable where Self: Encodable { } } -extension String: CodingKey { +extension String: @retroactive CodingKey { public var stringValue: String { return self diff --git a/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Models/StructuredEligibility.swift b/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Models/StructuredEligibility.swift index 8686f7f..f2adb0d 100644 --- a/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Models/StructuredEligibility.swift +++ b/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/Models/StructuredEligibility.swift @@ -21,9 +21,9 @@ public struct StructuredEligibility: Codable, JSONEncodable, Hashable { public var acceptsHealthyVolunteers: Bool? public var minAge: String? public var minAgeNumber: Int? - public var minAgeInYears: Int? + public var minAgeInYears: Double? - public init(maxAge: String? = nil, maxAgeNumber: Int? = nil, minAgeUnit: String? = nil, maxAgeUnit: String? = nil, maxAgeInYears: Int? = nil, gender: String? = nil, acceptsHealthyVolunteers: Bool? = nil, minAge: String? = nil, minAgeNumber: Int? = nil, minAgeInYears: Int? = nil) { + public init(maxAge: String? = nil, maxAgeNumber: Int? = nil, minAgeUnit: String? = nil, maxAgeUnit: String? = nil, maxAgeInYears: Int? = nil, gender: String? = nil, acceptsHealthyVolunteers: Bool? = nil, minAge: String? = nil, minAgeNumber: Int? = nil, minAgeInYears: Double? = nil) { self.maxAge = maxAge self.maxAgeNumber = maxAgeNumber self.minAgeUnit = minAgeUnit diff --git a/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/OpenISO8601DateFormatter.swift b/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/OpenISO8601DateFormatter.swift index cc32888..8275bd0 100644 --- a/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/OpenISO8601DateFormatter.swift +++ b/NCIClinicalTrialsSearchAPI/OpenAPIClient/Classes/OpenAPIs/OpenISO8601DateFormatter.swift @@ -8,7 +8,7 @@ import Foundation // https://stackoverflow.com/a/50281094/976628 -public class OpenISO8601DateFormatter: DateFormatter { +public class OpenISO8601DateFormatter: DateFormatter, @unchecked Sendable { static let withoutSeconds: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .iso8601) diff --git a/NCIClinicalTrialsSearchAPI/docs/StructuredEligibility.md b/NCIClinicalTrialsSearchAPI/docs/StructuredEligibility.md index e1873d3..ae492c1 100644 --- a/NCIClinicalTrialsSearchAPI/docs/StructuredEligibility.md +++ b/NCIClinicalTrialsSearchAPI/docs/StructuredEligibility.md @@ -12,7 +12,7 @@ Name | Type | Description | Notes **acceptsHealthyVolunteers** | **Bool** | | [optional] **minAge** | **String** | | [optional] **minAgeNumber** | **Int** | | [optional] -**minAgeInYears** | **Int** | | [optional] +**minAgeInYears** | **Double** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/OwnYourData/ClinicalTrials/NCITrialsModule.swift b/OwnYourData/ClinicalTrials/NCITrialsModule.swift index 1448165..6ba440f 100644 --- a/OwnYourData/ClinicalTrials/NCITrialsModule.swift +++ b/OwnYourData/ClinicalTrials/NCITrialsModule.swift @@ -14,13 +14,13 @@ import SpeziLocation @Observable class NCITrialsModule: Module, EnvironmentAccessible { - @ObservationIgnored @Dependency private var locationModule: SpeziLocation - + @ObservationIgnored @Dependency(SpeziLocation.self) private var locationModule + private let apiKey: String private(set) var trials: [TrialDetail] = [] var zipCode: String = "10025" - var searchDistance: String = "100" - + var searchDistance: String = "50" // Miles + init(apiKey: String) { self.apiKey = apiKey diff --git a/OwnYourData/ClinicalTrials/NICTrialsAPIDateFormatter.swift b/OwnYourData/ClinicalTrials/NICTrialsAPIDateFormatter.swift index bffd2be..2267975 100644 --- a/OwnYourData/ClinicalTrials/NICTrialsAPIDateFormatter.swift +++ b/OwnYourData/ClinicalTrials/NICTrialsAPIDateFormatter.swift @@ -12,7 +12,7 @@ import Foundation -class NICTrialsAPIDateFormatter: DateFormatter { +class NICTrialsAPIDateFormatter: DateFormatter, @unchecked Sendable { static let withoutSeconds: DateFormatter = { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .iso8601) diff --git a/OwnYourData/ClinicalTrials/TrialDetail+Identifiable.swift b/OwnYourData/ClinicalTrials/TrialDetail+Identifiable.swift index a61ba6f..0ceec85 100644 --- a/OwnYourData/ClinicalTrials/TrialDetail+Identifiable.swift +++ b/OwnYourData/ClinicalTrials/TrialDetail+Identifiable.swift @@ -9,7 +9,7 @@ import OpenAPIClient -extension TrialDetail: Identifiable { +extension TrialDetail: @retroactive Identifiable { public var id: String? { self.nciId } diff --git a/OwnYourData/Documents/PDFDocument+Transferable.swift b/OwnYourData/Documents/PDFDocument+Transferable.swift index 7fc6197..c20082c 100644 --- a/OwnYourData/Documents/PDFDocument+Transferable.swift +++ b/OwnYourData/Documents/PDFDocument+Transferable.swift @@ -10,7 +10,7 @@ import PDFKit import SwiftUI -extension PDFDocument: Transferable { +extension PDFDocument: @retroactive Transferable { public static var transferRepresentation: some TransferRepresentation { DataRepresentation( contentType: .pdf, diff --git a/OwnYourData/Helper/CodableArray+RawRepresentable.swift b/OwnYourData/Helper/CodableArray+RawRepresentable.swift index 5652658..7fd531d 100644 --- a/OwnYourData/Helper/CodableArray+RawRepresentable.swift +++ b/OwnYourData/Helper/CodableArray+RawRepresentable.swift @@ -9,7 +9,7 @@ import Foundation -extension Array: RawRepresentable where Element: Codable { +extension Array: @retroactive RawRepresentable where Element: Codable { public var rawValue: String { guard let data = try? JSONEncoder().encode(self), let rawValue = String(data: data, encoding: .utf8) else { diff --git a/OwnYourData/Home.swift b/OwnYourData/Home.swift index f43142b..5be25d3 100644 --- a/OwnYourData/Home.swift +++ b/OwnYourData/Home.swift @@ -18,7 +18,7 @@ struct HomeView: View { } @Environment(Account.self) var account: Account? - @Environment(FHIRStore.self) var fjirStore + @Environment(FHIRStore.self) var fhirStore @State private var presentingAccount = false @State private var showMatchingView = false @@ -34,7 +34,7 @@ struct HomeView: View { .font(.system(size: 60).weight(.semibold)) .foregroundColor(.accentColor) .multilineTextAlignment(.center) - if fjirStore.llmRelevantResources.isEmpty { + if fhirStore.llmRelevantResources.isEmpty { InstructionsView() } OwnYourDataButton(title: "Match Me") { diff --git a/OwnYourData/OwnYourDataDelegate.swift b/OwnYourData/OwnYourDataDelegate.swift index 5954186..d20356b 100644 --- a/OwnYourData/OwnYourDataDelegate.swift +++ b/OwnYourData/OwnYourDataDelegate.swift @@ -25,6 +25,7 @@ import SwiftUI class OwnYourDataDelegate: SpeziAppDelegate { // See https://swiftpackageindex.com/stanfordspezi/spezi/documentation/spezi/speziappdelegate/configuration override var configuration: Configuration { + // swiftlint:disable:next closure_body_length Configuration(standard: OwnYourDataStandard()) { if !FeatureFlags.disableFirebase { // See https://swiftpackageindex.com/stanfordspezi/speziaccount/documentation/speziaccount/initial-setup @@ -64,8 +65,24 @@ class OwnYourDataDelegate: SpeziAppDelegate { LLMRunner { LLMOpenAIPlatform(configuration: .init(concurrentStreams: 20, apiToken: self.openAIToken)) } - FHIRInterpretationModule() - + FHIRInterpretationModule( + summaryLLMSchema: LLMOpenAISchema( + parameters: .init( + modelType: .gpt4_o, + systemPrompts: [] + ) + ), + interpretationLLMSchema: LLMOpenAISchema( + parameters: .init( + modelType: .gpt4_o, + systemPrompts: [] + ) + ), + multipleResourceInterpretationOpenAIModel: .gpt4_o, + resourceCountLimit: 250, + allowedResourcesFunctionCallIdentifiers: nil + ) + DocumentManager() SpeziLocation() diff --git a/OwnYourData/OwnYourDataStandard.swift b/OwnYourData/OwnYourDataStandard.swift index 076afd0..209d8ae 100644 --- a/OwnYourData/OwnYourDataStandard.swift +++ b/OwnYourData/OwnYourDataStandard.swift @@ -23,7 +23,7 @@ import SpeziQuestionnaire import SwiftUI -actor OwnYourDataStandard: Standard, EnvironmentAccessible, HealthKitConstraint, OnboardingConstraint, AccountStorageConstraint { +actor OwnYourDataStandard: Standard, EnvironmentAccessible, HealthKitConstraint, ConsentConstraint, AccountStorageConstraint { enum OwnYourDataStandardError: Error { case userNotAuthenticatedYet } @@ -32,8 +32,8 @@ actor OwnYourDataStandard: Standard, EnvironmentAccessible, HealthKitConstraint, Firestore.firestore().collection("users") } - @Dependency var fhirStore: FHIRStore - @Dependency var accountStorage: FirestoreAccountStorage? + @Dependency(FHIRStore.self) var fhirStore + @Dependency(FirestoreAccountStorage.self) var accountStorage: FirestoreAccountStorage? @AccountReference var account: Account @@ -106,39 +106,34 @@ actor OwnYourDataStandard: Standard, EnvironmentAccessible, HealthKitConstraint, /// Stores the given consent form in the user's document directory with a unique timestamped filename. /// - /// - Parameter consent: The consent form's data to be stored as a `PDFDocument`. - func store(consent: PDFDocument) async { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd_HHmmss" - let dateString = formatter.string(from: Date()) - + /// - Parameter consent: The consent form's data to be stored as a `SpeziOnboarding.ConsentDocumentExport`. + func store(consent: SpeziOnboarding.ConsentDocumentExport) async throws { + guard let consentData = await consent.pdf.dataRepresentation() else { + logger.error("Could not store consent form.") + return + } + guard !FeatureFlags.disableFirebase else { guard let basePath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { logger.error("Could not create path for writing consent form to user document directory.") return } - - let filePath = basePath.appending(path: "consentForm_\(dateString).pdf") - consent.write(to: filePath) - + + let filePath = await basePath.appending(path: "\(consent.documentIdentifier).pdf") + try consentData.write(to: filePath) + return } - + do { - guard let consentData = consent.dataRepresentation() else { - logger.error("Could not store consent form.") - return - } - let metadata = StorageMetadata() metadata.contentType = "application/pdf" - _ = try await userBucketReference.child("consent/\(dateString).pdf").putDataAsync(consentData, metadata: metadata) + _ = try await userBucketReference.child("consent/\(consent.documentIdentifier).pdf").putDataAsync(consentData, metadata: metadata) } catch { logger.error("Could not store consent form: \(error)") } } - func create(_ identifier: AdditionalRecordId, _ details: SignupDetails) async throws { guard let accountStorage else { preconditionFailure("Account Storage was requested although not enabled in current configuration.") diff --git a/OwnYourData/Resources/Localizable.xcstrings b/OwnYourData/Resources/Localizable.xcstrings index 50e2ec9..40ae637 100644 --- a/OwnYourData/Resources/Localizable.xcstrings +++ b/OwnYourData/Resources/Localizable.xcstrings @@ -67,7 +67,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Find National Cancer Institute supported trials.\n\nWe noticed that you haven't saved any health records on your phone yet.\n\nPlease follow the instructions to retrieve your health records: [Apple Support - View health records on your iPhone or iPod touch](https://support.apple.com/en-us/HT208680).\n\nYou can find a list of supported institutions at [Apple Support - Institutions that support health records on iPhone and iPod touch](https://platform.openai.com/account/api-keys).\n\nPlease ensure that OwnYourData has access to your health records in the Apple Health App. You can find these settings in the privacy section of your profile in Apple Health." + "value" : "Find National Cancer Institute supported trials.\n\nWe noticed that you haven’t saved any health records on your phone yet.\n\nPlease follow the instructions to retrieve your health records: [Apple Support - View health records on your iPhone or iPod touch](https://support.apple.com/en-us/HT208680).\n\nYou can find a list of supported institutions at [Apple Support - Institutions that support health records on iPhone and iPod touch](https://platform.openai.com/account/api-keys).\n\nPlease ensure that OwnYourData has access to your health records in the Apple Health App. You can find these settings in the privacy section of your profile in Apple Health." } } } @@ -101,10 +101,24 @@ }, "Identifying best matching trials ..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Identifying best matching trials …" + } + } + } }, "Inspecting FHIR resources ..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inspecting FHIR resources …" + } + } + } }, "Keyword Identification Prompt" : { "comment" : "Title of the keyword identification prompt." @@ -113,7 +127,14 @@ }, "Learn More ..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Learn More …" + } + } + } }, "License Information" : { @@ -164,7 +185,7 @@ "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." + "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." } } } @@ -175,7 +196,7 @@ "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." + "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." } } } @@ -184,7 +205,14 @@ }, "Loading NCI trials based on FHIR resources ..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Loading NCI trials based on FHIR resources …" + } + } + } }, "Location Access" : { diff --git a/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift b/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift index f06d3c0..82c9968 100644 --- a/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift +++ b/OwnYourData/TrialsMatching/LLMFunctions/GetTrialsLLMFunction.swift @@ -46,7 +46,7 @@ struct GetTrialsLLMFunction: LLMFunction { Title: \(trial.briefTitle ?? "") (\(trial.officialTitle ?? "")) Description: \(trial.detailDescription ?? "") - Incluision Criteria: \(trial.eligibility?.unstructured?.compactMap { $0.description }.joined() ?? "") + Inclusion Criteria: \(trial.eligibility?.unstructured?.compactMap { $0.description }.joined() ?? "") """ } } diff --git a/OwnYourData/TrialsMatching/MatchingModule.swift b/OwnYourData/TrialsMatching/MatchingModule.swift index 02ad3ce..a7f0b14 100644 --- a/OwnYourData/TrialsMatching/MatchingModule.swift +++ b/OwnYourData/TrialsMatching/MatchingModule.swift @@ -22,16 +22,16 @@ import SwiftUI class MatchingModule: Module, EnvironmentAccessible, DefaultInitializable { public enum Defaults { public static var llmSchema: LLMOpenAISchema { - LLMOpenAISchema(parameters: LLMOpenAIParameters(modelType: .gpt4_turbo)) + LLMOpenAISchema(parameters: LLMOpenAIParameters(modelType: .gpt4_o)) } } - @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 @Dependency private var nciTrialsModel: NCITrialsModule? + @ObservationIgnored @Dependency(LocalStorage.self) private var localStorage + @ObservationIgnored @Dependency(LLMRunner.self) private var llmRunner + @ObservationIgnored @Dependency(FHIRStore.self) private var fhirStore + @ObservationIgnored @Dependency(SpeziLocation.self) private var locationModule + @ObservationIgnored @Dependency(NCITrialsModule.self) private var nciTrialsModel: NCITrialsModule? @ObservationIgnored @Model private var resourceSummary: FHIRResourceSummary @@ -67,6 +67,7 @@ class MatchingModule: Module, EnvironmentAccessible, DefaultInitializable { self.state = .nciLoading } try await nciTrialsModel.fetchTrials(keywords: keywords) + // If no trials are returned based on the keywords, search for all trials in a 100 mile radius if nciTrialsModel.trials.isEmpty { try await nciTrialsModel.fetchTrials() } @@ -90,7 +91,7 @@ class MatchingModule: Module, EnvironmentAccessible, DefaultInitializable { @MainActor private func keywordIdentification() async throws -> [String] { let llm = llmRunner( - with: LLMOpenAISchema(parameters: .init(modelType: .gpt4_turbo)) { + with: LLMOpenAISchema(parameters: .init(modelType: .gpt4_o)) { GetFHIRResourceLLMFunction( fhirStore: self.fhirStore, resourceSummary: self.resourceSummary, @@ -121,11 +122,11 @@ class MatchingModule: Module, EnvironmentAccessible, DefaultInitializable { @MainActor private func trialsIdentificaiton() async throws -> [String] { guard let nciTrialsModel else { - fatalError("Error that NCI Trials Module was not initialized in the Spezi Configuratin.") + fatalError("Error that NCI Trials Module was not initialized in the Spezi Configuration.") } let llm = llmRunner( - with: LLMOpenAISchema(parameters: .init(modelType: .gpt4_turbo)) { + with: LLMOpenAISchema(parameters: .init(modelType: .gpt4_o)) { GetFHIRResourceLLMFunction( fhirStore: self.fhirStore, resourceSummary: self.resourceSummary,