diff --git a/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift b/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift index ddf2873..d0ab724 100644 --- a/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift +++ b/Sources/SpeziFHIR/FHIRResource/FHIRResource.swift @@ -55,8 +55,34 @@ public struct FHIRResource: Sendable, Identifiable, Hashable { switch versionedResource { case let .r4(resource): switch resource { + case let condition as ModelsR4.Condition: + guard case let .dateTime(date) = condition.onset else { + return nil + } + return try? date.value?.asNSDate() + case let diagnosticReport as ModelsR4.DiagnosticReport: + guard case let .dateTime(date) = diagnosticReport.effective else { + return nil + } + return try? date.value?.asNSDate() + case let encounter as ModelsR4.Encounter: + return try? encounter.period?.end?.value?.asNSDate() + case let immunization as ModelsR4.Immunization: + guard case let .dateTime(date) = immunization.occurrence else { + return nil + } + return try? date.value?.asNSDate() + case let medicationRequest as ModelsR4.MedicationRequest: + return try? medicationRequest.authoredOn?.value?.asNSDate() case let observation as ModelsR4.Observation: return try? observation.issued?.value?.asNSDate() + case let procedure as ModelsR4.Procedure: + guard case let .dateTime(date) = procedure.performed else { + return nil + } + return try? date.value?.asNSDate() + case is ModelsR4.Patient: + return .now default: return nil } @@ -66,8 +92,11 @@ public struct FHIRResource: Sendable, Identifiable, Hashable { return try? observation.issued?.value?.asNSDate() case let medicationOrder as ModelsDSTU2.MedicationOrder: return try? medicationOrder.dateWritten?.value?.asNSDate() - case let condition as ModelsDSTU2.MedicationOrder: - return try? condition.dateWritten?.value?.asNSDate() + case let condition as ModelsDSTU2.Condition: + guard case let .dateTime(date) = condition.onset else { + return nil + } + return try? date.value?.asNSDate() case let procedure as ModelsDSTU2.Procedure: guard case let .dateTime(date) = procedure.performed else { return nil diff --git a/Sources/SpeziFHIR/FHIRResource/ResourceProxy+DisplayName.swift b/Sources/SpeziFHIR/FHIRResource/ResourceProxy+DisplayName.swift new file mode 100644 index 0000000..64fe7cf --- /dev/null +++ b/Sources/SpeziFHIR/FHIRResource/ResourceProxy+DisplayName.swift @@ -0,0 +1,45 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import ModelsR4 + + +extension ResourceProxy { + /// Provides a best-effort human readable display name for the resource. + public var displayName: String { + switch self { + case let .condition(condition): + return condition.code?.text?.value?.string ?? resourceType + case let .diagnosticReport(diagnosticReport): + return diagnosticReport.code.coding?.first?.display?.value?.string ?? resourceType + case let .encounter(encounter): + return encounter.reasonCode?.first?.coding?.first?.display?.value?.string + ?? encounter.type?.first?.coding?.first?.display?.value?.string + ?? resourceType + case let .immunization(immunization): + return immunization.vaccineCode.text?.value?.string ?? resourceType + case let .medicationRequest(medicationRequest): + guard case let .codeableConcept(medicationCodeableConcept) = medicationRequest.medication else { + return resourceType + } + return medicationCodeableConcept.text?.value?.string ?? resourceType + case let .observation(observation): + return observation.code.text?.value?.string ?? resourceType + case let .procedure(procedure): + return procedure.code?.text?.value?.string ?? resourceType + case let .patient(patient): + let name = (patient.name?.first?.given?.first?.value?.string ?? "") + (patient.name?.first?.family?.value?.string ?? "") + guard !name.isEmpty else { + return resourceType + } + return name + default: + return resourceType + } + } +} diff --git a/Sources/SpeziFHIR/FHIRStore.swift b/Sources/SpeziFHIR/FHIRStore.swift index 63d24f0..3e2126c 100644 --- a/Sources/SpeziFHIR/FHIRStore.swift +++ b/Sources/SpeziFHIR/FHIRStore.swift @@ -110,7 +110,7 @@ public class FHIRStore: Module, EnvironmentAccessible, DefaultInitializable { let resourceProxies = bundle.entry?.compactMap { $0.resource } ?? [] for resourceProxy in resourceProxies { - insert(resource: FHIRResource(resource: resourceProxy.get(), displayName: resourceProxy.resourceType)) + insert(resource: FHIRResource(resource: resourceProxy.get(), displayName: resourceProxy.displayName)) } } diff --git a/Sources/SpeziFHIRInterpretation/FHIRResourceInterpreter.swift b/Sources/SpeziFHIRInterpretation/FHIRResourceInterpreter.swift index e9f01c6..35e4d55 100644 --- a/Sources/SpeziFHIRInterpretation/FHIRResourceInterpreter.swift +++ b/Sources/SpeziFHIRInterpretation/FHIRResourceInterpreter.swift @@ -16,7 +16,7 @@ import SpeziOpenAI /// Responsible for interpreting FHIR resources. @Observable public class FHIRResourceInterpreter { - private let resourceProcesser: FHIRResourceProcesser + private let resourceProcesser: FHIRResourceProcesser /// - Parameters: diff --git a/Sources/SpeziFHIRInterpretation/FHIRResourceProcesser.swift b/Sources/SpeziFHIRInterpretation/FHIRResourceProcesser.swift index 74ab2c7..9ac4687 100644 --- a/Sources/SpeziFHIRInterpretation/FHIRResourceProcesser.swift +++ b/Sources/SpeziFHIRInterpretation/FHIRResourceProcesser.swift @@ -13,8 +13,8 @@ import SpeziOpenAI @Observable -class FHIRResourceProcesser { - typealias Results = [FHIRResource.ID: String] +class FHIRResourceProcesser { + typealias Results = [FHIRResource.ID: Content] private let localStorage: LocalStorage @@ -49,8 +49,8 @@ class FHIRResourceProcesser { @discardableResult - func process(resource: FHIRResource, forceReload: Bool = false) async throws -> String { - if let result = results[resource.id], !result.isEmpty, !forceReload { + func process(resource: FHIRResource, forceReload: Bool = false) async throws -> Content { + if let result = results[resource.id], !result.description.isEmpty, !forceReload { return result } @@ -63,8 +63,12 @@ class FHIRResourceProcesser { } } - results[resource.id] = result - return result + guard let content = Content(result) else { + throw FHIRResourceProcesserError.notParsableAsAString + } + + results[resource.id] = content + return content } private func systemPrompt(forResource resource: FHIRResource) -> Chat { diff --git a/Sources/SpeziFHIRInterpretation/FHIRResourceProcesserError.swift b/Sources/SpeziFHIRInterpretation/FHIRResourceProcesserError.swift new file mode 100644 index 0000000..84c1bc4 --- /dev/null +++ b/Sources/SpeziFHIRInterpretation/FHIRResourceProcesserError.swift @@ -0,0 +1,26 @@ +// +// This source file is part of the Stanford Spezi open source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +enum FHIRResourceProcesserError: LocalizedError { + case notParsableAsAString + + + var errorDescription: String? { + switch self { + case .notParsableAsAString: + String( + localized: "Unable to parse result of the LLM prompt.", + bundle: .module, + comment: "Error thrown if the result can not be parsed in the underlying type." + ) + } + } +} diff --git a/Sources/SpeziFHIRInterpretation/FHIRResourceSummary.swift b/Sources/SpeziFHIRInterpretation/FHIRResourceSummary.swift index 7443662..5880fb7 100644 --- a/Sources/SpeziFHIRInterpretation/FHIRResourceSummary.swift +++ b/Sources/SpeziFHIRInterpretation/FHIRResourceSummary.swift @@ -16,7 +16,32 @@ import SpeziOpenAI /// Responsible for summarizing FHIR resources. @Observable public class FHIRResourceSummary { - private let resourceProcesser: FHIRResourceProcesser + /// Summary of a FHIR resource emited by the ``FHIRResourceSummary``. + public struct Summary: Codable, LosslessStringConvertible { + /// Title of the FHIR resource, should be shorter than 4 words. + public let title: String + /// Summary of the FHIR resource, should be a single line of text. + public let summary: String + + + public var description: String { + title + "\n" + summary + } + + + public init?(_ description: String) { + let components = description.split(separator: "\n") + guard components.count == 2, let title = components.first, let summary = components.last else { + return nil + } + + self.title = String(title) + self.summary = String(summary) + } + } + + + private let resourceProcesser: FHIRResourceProcesser /// - Parameters: @@ -39,7 +64,7 @@ public class FHIRResourceSummary { /// - forceReload: A boolean value that indicates whether to reload and reprocess the resource. /// - Returns: An asynchronous `String` representing the summarization of the resource. @discardableResult - public func summarize(resource: FHIRResource, forceReload: Bool = false) async throws -> String { + public func summarize(resource: FHIRResource, forceReload: Bool = false) async throws -> Summary { try await resourceProcesser.process(resource: resource, forceReload: forceReload) } @@ -47,7 +72,7 @@ public class FHIRResourceSummary { /// /// - Parameter resource: The resource where the cached summary should be loaded from. /// - Returns: The cached summary. Returns `nil` if the resource is not present. - public func cachedSummary(forResource resource: FHIRResource) -> String? { + public func cachedSummary(forResource resource: FHIRResource) -> Summary? { resourceProcesser.results[resource.id] } } diff --git a/Sources/SpeziFHIRInterpretation/FHIRResourceSummaryView.swift b/Sources/SpeziFHIRInterpretation/FHIRResourceSummaryView.swift index 6704fa8..b560182 100644 --- a/Sources/SpeziFHIRInterpretation/FHIRResourceSummaryView.swift +++ b/Sources/SpeziFHIRInterpretation/FHIRResourceSummaryView.swift @@ -23,38 +23,37 @@ public struct FHIRResourceSummaryView: View { public var body: some View { Group { if let summary = fhirResourceSummary.cachedSummary(forResource: resource) { - VStack(alignment: .leading, spacing: 4) { - Text(resource.displayName) - Text(summary) + VStack(alignment: .leading, spacing: 0) { + Text(summary.title) + if let date = resource.date { + Text(date, style: .date) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.top, 2) + } + Text(summary.summary) .font(.caption) + .padding(.top, 4) } .multilineTextAlignment(.leading) } else { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 0) { Text(resource.displayName) + if let date = resource.date { + Text(date, style: .date) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.top, 2) + } if viewState == .processing { ProgressView() .progressViewStyle(.circular) .padding(.vertical, 6) + .padding(.top, 4) } } .contextMenu { - Button(String(localized: "Create Resource Summary", bundle: .module)) { - Task { - viewState = .processing - do { - try await fhirResourceSummary.summarize(resource: resource) - viewState = .idle - } catch { - viewState = .error( - AnyLocalizedError( - error: error, - defaultErrorDescription: String(localized: "Could not create FHIR Summary", bundle: .module) - ) - ) - } - } - } + contextMenu } } } @@ -62,6 +61,26 @@ public struct FHIRResourceSummaryView: View { } + @ViewBuilder private var contextMenu: some View { + Button(String(localized: "Create Resource Summary", bundle: .module)) { + Task { + viewState = .processing + do { + try await fhirResourceSummary.summarize(resource: resource) + viewState = .idle + } catch { + viewState = .error( + AnyLocalizedError( + error: error, + defaultErrorDescription: String(localized: "Could not create FHIR Summary", bundle: .module) + ) + ) + } + } + } + } + + public init(resource: FHIRResource) { self.resource = resource } diff --git a/Sources/SpeziFHIRInterpretation/Resources/Localizable.xcstrings b/Sources/SpeziFHIRInterpretation/Resources/Localizable.xcstrings index 74cd538..d3c975c 100644 --- a/Sources/SpeziFHIRInterpretation/Resources/Localizable.xcstrings +++ b/Sources/SpeziFHIRInterpretation/Resources/Localizable.xcstrings @@ -48,7 +48,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your task is to interpret the following FHIR resource from the user's clinical record.\n\nInterpret the resource by explaining its data relevant to the user's health.\nExplain the relevant medical context in a language understandable by a user who is not a medical professional.\nYou should provide factual and precise information in a compact summary in short responses.\n\nThe following JSON representation defines the FHIR resource that you should interpret:\n{{FHIR_RESOURCE}}\n\nImmediately return an interpretation to the user, starting the conversation.\nDo not introduce yourself at the beginning, and start with your interpretation.\n\nChat with the user in the same language they chat in.\nChat with the user in {{LOCALE}}" + "value" : "Your task is to interpret the following FHIR resource from the user's clinical record. You should provide the title and summary in the following locale: {{LOCALE}}.\n\nInterpret the resource by explaining its data relevant to the user's health.\nExplain the relevant medical context in a language understandable by a user who is not a medical professional.\nYou should provide factual and precise information in a compact summary in short responses.\n\nImmediately return an interpretation to the user, starting the conversation.\nDo not introduce yourself at the beginning, and start with your interpretation.\n\nThe following JSON representation defines the FHIR resource that you should provide an interpretation for:\n\n{{FHIR_RESOURCE}}" } } } @@ -90,7 +90,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your task is to interpret FHIR resources from the user's clinical records.\nThroughout the conversation with the user, use the get_resource_titles function to obtain the FHIR health resources necessary to properly answer the users question. For example, if the user asks about their allergies, you must use get_resource_titles to output the FHIR resource titles for allergy records so you can then use them to answer the question. The output of get_resource_titles has to be the name of a resource or resources with the exact same title as in the list provided.\n\nAnswer the users question, the health record provided is not always related. The end goal is to answer the users question in the best way possible.\n\nInterpret the resources by explaining its data relevant to the user's health.\nExplain the relevant medical context in a language understandable by a user who is not a medical professional.\nYou should provide factual and precise information in a compact summary in short responses.\n\nTell the user that they can ask any question about their health records and then create a short summary of the main categories of health records of the user which you have access to. These are the resource titles:\n{{FHIR_RESOURCE}}\n\nImmediately return a short summary of the users health records to start the conversation.\nThe initial summary should be a short and simple summary with the following specifications:\n1. Short summary of the health records categories\n2. 5th grade reading level\n3. End with a question asking user if they have any questions. Make sure that this question is not generic but specific to their health records.\nDo not introduce yourself at the beginning, and start with your interpretation.\nMake sure your response is in the same language the user writes to you in.\nThe tense should be present. \n\nChat with the user in the same language they chat in.\nChat with the user in {{LOCALE}}" + "value" : "Your task is to private a title and compact summary for an FHIR resource from the user's clinical record. You should provide the title and summary in the following locale: {{LOCALE}}.\n\nYour response should contain two lines without headings, markdown formatting, or any other structure beyond two lines. Directly provide the content without any additional structure or an introduction. Another computer program will parse the output.\n\n1. Line: A 1-5 Word summary of the FHIR resource that immediately identifies the resource and provides the essential information at a glance. Do NOT use a complete sentence; instead, use a formatting typically used for titles in computer systems.\n\n2. Line: Provide a short one-sentence summary of the resource in less than 40 words. Ensure that the summary only focuses on the essential information that a patient would need and, e.g., excludes the person who prescribed a medication or similar metadata. Ensure that all clinically relevant data is included, which allows us to use the summary in additional prompts where using the complete JSON might not be feasible.\n\nThe following JSON representation defines the FHIR resource that you should provide a title and summary for:\n\n{{FHIR_RESOURCE}}" + } + } + } + }, + "Unable to parse result of the LLM prompt." : { + "comment" : "Error thrown if the result can not be parsed in the underlying type.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to parse result of the LLM prompt." } } }