generated from StanfordSpezi/SpeziTemplateApplication
-
-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add OpenAI Interactions with a Ask Questions Section and Summary Prom…
…pt (#15)
- Loading branch information
1 parent
606142a
commit 6ab3df7
Showing
21 changed files
with
1,015 additions
and
273 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
TemplateApplication/ChatView/FHIRMultipleResourceInterpreter.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
// | ||
// This source file is part of the Stanford OwnYourData Application project | ||
// File originates from the Stanford LLMonFHIR project | ||
// | ||
// SPDX-FileCopyrightText: 2023 Stanford University | ||
// | ||
// SPDX-License-Identifier: MIT | ||
// | ||
|
||
import OpenAI | ||
import Spezi | ||
import SpeziLocalStorage | ||
import SpeziOpenAI | ||
import SwiftUI | ||
|
||
|
||
private enum FHIRMultipleResourceInterpreterConstants { | ||
static let storageKey = "FHIRMultipleResourceInterpreter.Cache" | ||
} | ||
|
||
|
||
class FHIRMultipleResourceInterpreter: DefaultInitializable, Component, ObservableObject, ObservableObjectProvider { | ||
@Dependency private var localStorage: LocalStorage | ||
@Dependency private var openAIComponent: OpenAIComponent | ||
|
||
|
||
var interpretation: String? { | ||
willSet { | ||
Task { @MainActor in | ||
objectWillChange.send() | ||
} | ||
} | ||
didSet { | ||
do { | ||
try localStorage.store(interpretation, storageKey: FHIRMultipleResourceInterpreterConstants.storageKey) | ||
} catch { | ||
print(error) | ||
} | ||
} | ||
} | ||
|
||
|
||
required init() {} | ||
|
||
|
||
func configure() { | ||
guard let cachedInterpretation: String = try? localStorage.read( | ||
storageKey: FHIRMultipleResourceInterpreterConstants.storageKey | ||
) else { | ||
return | ||
} | ||
self.interpretation = cachedInterpretation | ||
} | ||
|
||
|
||
func askQuestionsChat(forResources resources: [FHIRResource]) -> [Chat] { | ||
var resourceCategories = String() | ||
|
||
for resource in resources { | ||
resourceCategories += (resource.functionCallIdentifier + "\n") | ||
} | ||
|
||
return [ | ||
Chat( | ||
role: .system, | ||
content: Prompt.allResources.prompt.replacingOccurrences(of: Prompt.promptPlaceholder, with: resourceCategories) | ||
) | ||
] | ||
} | ||
|
||
|
||
func generateSummary(forResources resources: [FHIRResource]) async throws -> String { | ||
var resourceCategories = String() | ||
|
||
for resource in resources { | ||
resourceCategories += (resource.compactJSONDescription + "\n\n") | ||
} | ||
|
||
let chat = [ | ||
Chat( | ||
role: .system, | ||
content: Prompt.summary.prompt.replacingOccurrences(of: Prompt.promptPlaceholder, with: resourceCategories) | ||
) | ||
] | ||
|
||
let chatStreamResults = try await openAIComponent.queryAPI(withChat: chat) | ||
var summary = "" | ||
for try await chatStreamResult in chatStreamResults { | ||
for choice in chatStreamResult.choices { | ||
summary += choice.delta.content ?? "" | ||
} | ||
} | ||
|
||
return summary | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
// | ||
// This source file is part of the Stanford OwnYourData Application project | ||
// File originates from the Stanford LLMonFHIR project | ||
// | ||
// SPDX-FileCopyrightText: 2023 Stanford University | ||
// | ||
// SPDX-License-Identifier: MIT | ||
// | ||
|
||
import OpenAI | ||
import SpeziOpenAI | ||
import SpeziViews | ||
import SwiftUI | ||
|
||
|
||
struct OpenAIChatView: View { | ||
@Environment(\.dismiss) private var dismiss | ||
@EnvironmentObject private var openAPIComponent: OpenAIComponent | ||
@EnvironmentObject private var fhirStandard: FHIR | ||
|
||
@State private var chat: [Chat] | ||
@State private var viewState: ViewState = .idle | ||
@State private var systemFuncMessageAdded = false | ||
|
||
private let enableFunctionCalling: Bool | ||
private let title: String | ||
|
||
|
||
private var disableInput: Binding<Bool> { | ||
Binding( | ||
get: { | ||
viewState == .processing | ||
}, | ||
set: { _ in } | ||
) | ||
} | ||
|
||
var body: some View { | ||
NavigationStack { | ||
ChatView($chat, disableInput: disableInput) | ||
.navigationTitle(title) | ||
.viewStateAlert(state: $viewState) | ||
.onChange(of: chat) { _ in | ||
getAnswer() | ||
} | ||
.onAppear { | ||
getAnswer() | ||
} | ||
} | ||
} | ||
|
||
|
||
init(chat: [Chat], title: String, enableFunctionCalling: Bool) { | ||
self._chat = State(initialValue: chat) | ||
self.title = title | ||
self.enableFunctionCalling = enableFunctionCalling | ||
} | ||
|
||
|
||
private func getAnswer() { | ||
guard viewState == .idle, chat.last?.role == .user || (chat.count == 1 && chat.first?.role == .system) else { | ||
return | ||
} | ||
|
||
Task { | ||
do { | ||
viewState = .processing | ||
|
||
if enableFunctionCalling { | ||
if systemFuncMessageAdded == false { | ||
try await addSystemFuncMessage() | ||
systemFuncMessageAdded = true | ||
} | ||
try await processFunctionCalling() | ||
} | ||
|
||
try await processChatStreamResults() | ||
|
||
viewState = .idle | ||
} catch let error as APIErrorResponse { | ||
viewState = .error(error) | ||
} catch { | ||
viewState = .error(error.localizedDescription) | ||
} | ||
} | ||
} | ||
|
||
private func addSystemFuncMessage() async throws { | ||
let resourcesArray = fhirStandard.resources | ||
|
||
var stringResourcesArray = resourcesArray.map { $0.functionCallIdentifier } | ||
stringResourcesArray.append("N/A") | ||
|
||
self.chat.append(Chat(role: .system, content: String(localized: "FUNCTION_CONTEXT") + stringResourcesArray.rawValue)) | ||
} | ||
|
||
private func processFunctionCalling() async throws { | ||
let resourcesArray = fhirStandard.resources | ||
|
||
var stringResourcesArray = resourcesArray.map { $0.functionCallIdentifier } | ||
stringResourcesArray.append("N/A") | ||
|
||
let functionCallOutputArray = try await getFunctionCallOutputArray(stringResourcesArray) | ||
|
||
processFunctionCallOutputArray(functionCallOutputArray: functionCallOutputArray, resourcesArray: resourcesArray) | ||
} | ||
|
||
private func getFunctionCallOutputArray(_ stringResourcesArray: [String]) async throws -> [String] { | ||
let functions = [ | ||
ChatFunctionDeclaration( | ||
name: "get_resource_titles", | ||
description: String(localized: "FUNCTION_DESCRIPTION"), | ||
parameters: JSONSchema( | ||
type: .object, | ||
properties: [ | ||
"resources": .init(type: .string, description: String(localized: "PARAMETER_DESCRIPTION"), enumValues: stringResourcesArray) | ||
], | ||
required: ["resources"] | ||
) | ||
) | ||
] | ||
|
||
let chatStreamResults = try await openAPIComponent.queryAPI(withChat: chat, withFunction: functions) | ||
|
||
|
||
class ChatFunctionCall { | ||
var name: String = "" | ||
var arguments: String = "" | ||
var finishReason: String = "" | ||
} | ||
|
||
let functionCall = ChatFunctionCall() | ||
|
||
for try await chatStreamResult in chatStreamResults { | ||
for choice in chatStreamResult.choices { | ||
if let deltaName = choice.delta.name { | ||
functionCall.name += deltaName | ||
} | ||
if let deltaArguments = choice.delta.functionCall?.arguments { | ||
functionCall.arguments += deltaArguments | ||
} | ||
if let finishReason = choice.finishReason { | ||
functionCall.finishReason += finishReason | ||
if finishReason == "get_resource_titles" { break } | ||
} | ||
} | ||
} | ||
|
||
guard functionCall.finishReason == "function_call" else { | ||
return [] | ||
} | ||
|
||
let trimmedArguments = functionCall.arguments.trimmingCharacters(in: .whitespacesAndNewlines) | ||
|
||
guard let resourcesRange = trimmedArguments.range(of: "\"resources\": \"([^\"]+)\"", options: .regularExpression) else { | ||
return [] | ||
} | ||
|
||
return trimmedArguments[resourcesRange] | ||
.replacingOccurrences(of: "\"resources\": \"", with: "") | ||
.replacingOccurrences(of: "\"", with: "") | ||
.components(separatedBy: ",") | ||
} | ||
|
||
private func processFunctionCallOutputArray(functionCallOutputArray: [String], resourcesArray: [FHIRResource]) { | ||
for resource in functionCallOutputArray { | ||
guard let matchingResource = resourcesArray.first(where: { $0.functionCallIdentifier == resource }) else { | ||
continue | ||
} | ||
|
||
let functionContent = """ | ||
Based on the function get_resource_titles you have requested the following health records: \(resource). | ||
This is the associated JSON data for the resources which you will use to answer the users question: | ||
\(matchingResource.jsonDescription) | ||
Use this health record to answer the users question ONLY IF the health record is applicable to the question. | ||
""" | ||
|
||
chat.append(Chat(role: .function, content: functionContent, name: "get_resource_titles")) | ||
} | ||
} | ||
|
||
private func processChatStreamResults() async throws { | ||
let chatStreamResults = try await openAPIComponent.queryAPI(withChat: chat) | ||
|
||
for try await chatStreamResult in chatStreamResults { | ||
for choice in chatStreamResult.choices { | ||
if chat.last?.role == .assistant { | ||
let previousChatMessage = chat.last ?? Chat(role: .assistant, content: "") | ||
chat[chat.count - 1] = Chat( | ||
role: .assistant, | ||
content: (previousChatMessage.content ?? "") + (choice.delta.content ?? "") | ||
) | ||
} else { | ||
chat.append(Chat(role: .assistant, content: choice.delta.content ?? "")) | ||
} | ||
} | ||
} | ||
} | ||
} |
Oops, something went wrong.