Skip to content

Commit 515aaa5

Browse files
authored
session automation (#143)
# session automation ## ♻️ Current situation & Problem we'd like to have the ability to "simulate" an LLM session (i.e., run a fake session, where a synthetic patient asks a set of pre-defined questions, and we record the whole interaction and generate a report, and repeat the whole thing multiple times, to be able to better evaluate the potential results). we'll implement this by extending our already existing LLMonFHIRCLI to support a new `simulate-session` subcommand. in order to be able to support this use case, we'll also need to move a bunch of code and logic from the iOS app into the LLMonFHIR package. ## ⚙️ Release Notes *todo* ## 📚 Documentation *todo* ## ✅ Testing *todo* ### Code of Conduct & Contributing Guidelines By creating and submitting 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).
1 parent b8ce90f commit 515aaa5

File tree

56 files changed

+1720
-1070
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+1720
-1070
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
# IDE related folders
1717
.idea
18+
.vscode
1819

1920
# Xcode
2021
xcuserdata/

.swiftlint.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ line_length: # Lines should not span too many characters.
425425
ignores_urls: true # default: false
426426
ignores_function_declarations: false # default: false
427427
ignores_interpolated_strings: true # default: false
428+
ignores_multiline_strings: true
428429

429430
nesting: # Types should be nested at most 2 level deep, and functions should be nested at most 5 levels deep.
430431
type_level:

LLMonFHIR.xcodeproj/project.pbxproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
105105

106106
/* Begin PBXFileSystemSynchronizedRootGroup section */
107-
65397DF32D8159A90067F1BE /* LLMonFHIR */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (65397E232D8159A90067F1BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = ("Resources/Synthetic Patients", ); path = LLMonFHIR; sourceTree = "<group>"; };
107+
65397DF32D8159A90067F1BE /* LLMonFHIR */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (65397E232D8159A90067F1BE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LLMonFHIR; sourceTree = "<group>"; };
108108
65397E252D8159AC0067F1BE /* LLMonFHIRTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LLMonFHIRTests; sourceTree = "<group>"; };
109109
65397E292D8159AF0067F1BE /* LLMonFHIRUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LLMonFHIRUITests; sourceTree = "<group>"; };
110110
/* End PBXFileSystemSynchronizedRootGroup section */

LLMonFHIR.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LLMonFHIR.xcodeproj/xcshareddata/xcschemes/LLMonFHIR.xcscheme

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@
107107
</CommandLineArgument>
108108
<CommandLineArgument
109109
argument = "--mode study:edu.stanford.LLMonFHIR.gynStudy"
110-
isEnabled = "NO">
110+
isEnabled = "YES">
111111
</CommandLineArgument>
112112
</CommandLineArguments>
113113
</LaunchAction>

LLMonFHIR/FHIR Views/FHIRResourcesView.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// SPDX-License-Identifier: MIT
77
//
88

9+
import LLMonFHIRShared
910
import SpeziFHIR
1011
import SwiftUI
1112

@@ -186,7 +187,49 @@ extension FHIRResourcesView {
186187
let visibleResources = showAll.wrappedValue ? sortedResources : Array(sortedResources.prefix(3))
187188
ForEach(visibleResources) { resource in
188189
NavigationLink(value: resource) {
189-
FHIRResourceSummaryView(resource: resource)
190+
ResourceRow(resource: resource)
191+
}
192+
}
193+
}
194+
}
195+
196+
197+
extension FHIRResourcesView {
198+
private struct ResourceRow: View {
199+
@Environment(FHIRResourceSummarizer.self) private var resourceSummarizer
200+
let resource: FHIRResource
201+
@State private var summary: FHIRResourceSummarizer.Summary?
202+
203+
var body: some View {
204+
VStack {
205+
if let summary {
206+
VStack(alignment: .leading, spacing: 0) {
207+
Text(summary.title)
208+
if let date = resource.date {
209+
Text(date, style: .date)
210+
.font(.caption2)
211+
.foregroundStyle(.secondary)
212+
.padding(.top, 2)
213+
}
214+
Text(summary.summary)
215+
.font(.caption)
216+
.padding(.top, 4)
217+
}
218+
.multilineTextAlignment(.leading)
219+
} else {
220+
VStack(alignment: .leading, spacing: 0) {
221+
Text(resource.displayName)
222+
if let date = resource.date {
223+
Text(date, style: .date)
224+
.font(.caption2)
225+
.foregroundStyle(.secondary)
226+
.padding(.top, 2)
227+
}
228+
}
229+
}
230+
}
231+
.task {
232+
summary = await resourceSummarizer.cachedSummary(forResource: resource)
190233
}
191234
}
192235
}

LLMonFHIR/FHIR Views/InspectResourceView.swift

Lines changed: 36 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -6,70 +6,54 @@
66
// SPDX-License-Identifier: MIT
77
//
88

9+
import LLMonFHIRShared
910
import SpeziFHIR
10-
import SpeziLLM
1111
import SpeziViews
1212
import SwiftUI
1313

14+
1415
struct InspectResourceView: View {
15-
@Environment(FHIRResourceInterpreter.self) var fhirResourceInterpreter
16-
@Environment(FHIRResourceSummary.self) var fhirResourceSummary
16+
@Environment(FHIRResourceSummarizer.self) private var summarizer
17+
@Environment(SingleFHIRResourceInterpreter.self) private var interpreter
1718

18-
@State var interpreting: ViewState = .idle
19-
@State var loadingSummary: ViewState = .idle
20-
@State private var summary: FHIRResourceSummary.Summary?
19+
private let resource: FHIRResource
20+
@State private var summary: FHIRResourceSummarizer.Summary?
2121
@State private var interpretation: String?
22-
23-
var resource: FHIRResource
24-
22+
@State private var viewState: ViewState = .idle
2523

2624
var body: some View {
2725
List {
2826
summarySection
2927
interpretationSection
3028
resourceSection
3129
}
32-
.navigationTitle(resource.displayName)
33-
.viewStateAlert(state: $interpreting)
34-
.viewStateAlert(state: $loadingSummary)
30+
.navigationTitle(resource.displayName)
31+
.viewStateAlert(state: $viewState)
32+
.task {
33+
summary = await summarizer.cachedSummary(forResource: resource)
34+
interpretation = await interpreter.cachedInterpretation(forResource: resource)
35+
}
36+
.asyncButtonProcessingStyle(.listRow)
3537
}
3638

3739
@ViewBuilder private var summarySection: some View {
3840
Section("FHIR_RESOURCES_SUMMARY_SECTION") {
39-
if loadingSummary == .processing {
40-
HStack {
41-
Spacer()
42-
ProgressView()
43-
Spacer()
44-
}
45-
} else if let summary {
46-
VStack {
47-
HStack(spacing: 0) {
48-
Text(summary.title)
49-
.font(.title2)
50-
.multilineTextAlignment(.leading)
51-
.bold()
52-
Spacer()
53-
}
54-
HStack(spacing: 0) {
55-
Text(summary.summary)
56-
.multilineTextAlignment(.leading)
57-
.contextMenu {
58-
Button("FHIR_RESOURCES_SUMMARY_BUTTON") {
59-
loadSummary(forceReload: true)
60-
}
61-
}
62-
Spacer()
63-
}
64-
}
65-
} else {
66-
Button("FHIR_RESOURCES_SUMMARY_BUTTON") {
67-
loadSummary()
41+
if let summary {
42+
VStack(alignment: .leading) {
43+
Text(summary.title)
44+
.font(.headline)
45+
.multilineTextAlignment(.leading)
46+
.bold()
47+
Text(summary.summary)
48+
.multilineTextAlignment(.leading)
6849
}
6950
}
70-
}
71-
.task {
72-
summary = await fhirResourceSummary.cachedSummary(forResource: resource)
51+
AsyncButton(summary == nil ? "Load Resource Summary" : "Reload Resource Summary", state: $viewState) {
52+
summary = try await summarizer.summarize(
53+
resource: SendableFHIRResource(resource: resource),
54+
forceReload: summary != nil
55+
)
56+
}
7357
}
7458
}
7559

@@ -78,28 +62,13 @@ struct InspectResourceView: View {
7862
if let interpretation, !interpretation.isEmpty {
7963
Text(interpretation)
8064
.multilineTextAlignment(.leading)
81-
.contextMenu {
82-
Button("FHIR_RESOURCES_INTERPRETATION_BUTTON") {
83-
interpret(forceReload: true)
84-
}
85-
}
86-
} else if interpreting == .processing {
87-
VStack(alignment: .center) {
88-
Text("FHIR_RESOURCES_INTERPRETATION_LOADING")
89-
.frame(maxWidth: .infinity)
90-
ProgressView()
91-
.progressViewStyle(.circular)
92-
}
93-
} else {
94-
VStack(alignment: .center) {
95-
Button("FHIR_RESOURCES_INTERPRETATION_BUTTON") {
96-
interpret()
97-
}
98-
}
9965
}
100-
}
101-
.task {
102-
interpretation = await fhirResourceInterpreter.cachedInterpretation(forResource: resource)
66+
AsyncButton(interpretation == nil ? "Load Resource Interpretation" : "Update Resource Interpretation", state: $viewState) {
67+
interpretation = try await interpreter.interpret(
68+
resource: SendableFHIRResource(resource: resource),
69+
forceReload: interpretation != nil
70+
)
71+
}
10372
}
10473
}
10574

@@ -112,33 +81,7 @@ struct InspectResourceView: View {
11281
}
11382
}
11483

115-
private func loadSummary(forceReload: Bool = false) {
116-
loadingSummary = .processing
117-
118-
Task {
119-
do {
120-
try await fhirResourceSummary.summarize(resource: SendableFHIRResource(resource: resource), forceReload: forceReload)
121-
loadingSummary = .idle
122-
} catch let error as any LLMError {
123-
loadingSummary = .error(error)
124-
} catch {
125-
loadingSummary = .error(LLMDefaultError.unknown(error))
126-
}
127-
}
128-
}
129-
130-
private func interpret(forceReload: Bool = false) {
131-
interpreting = .processing
132-
133-
Task {
134-
do {
135-
try await fhirResourceInterpreter.interpret(resource: SendableFHIRResource(resource: resource), forceReload: forceReload)
136-
interpreting = .idle
137-
} catch let error as any LLMError {
138-
interpreting = .error(error)
139-
} catch {
140-
interpreting = .error(LLMDefaultError.unknown(error))
141-
}
142-
}
84+
init(resource: FHIRResource) {
85+
self.resource = resource
14386
}
14487
}

LLMonFHIR/FHIRInterpretation/FHIRInterpretationModule.swift

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,18 @@ import SwiftUI
2020

2121
// periphery:ignore - Properties are used through dependency injection and @Model configuration in `configure()`
2222
@Observable
23-
final class FHIRInterpretationModule: Module, EnvironmentAccessible, @unchecked Sendable {
23+
final class FHIRInterpretationModule: Module, EnvironmentAccessible, @unchecked Sendable { // maybe rename to smth Coordinator?
2424
@ObservationIgnored @MainActor @Dependency(LocalStorage.self) private var localStorage
2525
@ObservationIgnored @MainActor @Dependency(LLMRunner.self) private var llmRunner
2626
@ObservationIgnored @MainActor @Dependency(FHIRStore.self) private var fhirStore
2727

28-
@ObservationIgnored @MainActor @Model private var resourceSummary: FHIRResourceSummary
29-
@ObservationIgnored @MainActor @Model private var resourceInterpreter: FHIRResourceInterpreter
30-
@ObservationIgnored @MainActor @Model private var multipleResourceInterpreter: FHIRMultipleResourceInterpreter
28+
@ObservationIgnored @MainActor @Model private(set) var resourceSummarizer: FHIRResourceSummarizer
29+
@ObservationIgnored @MainActor @Model private(set) var singleResourceInterpreter: SingleFHIRResourceInterpreter
30+
@ObservationIgnored @MainActor @Model private(set) var multipleResourceInterpreter: FHIRMultipleResourceInterpreter
3131

3232
@ObservationIgnored @LocalPreference(.llmSource) private var llmSource
33-
@ObservationIgnored @LocalPreference(.openAIModel) private var openAIModel
34-
@ObservationIgnored @LocalPreference(.openAIModelTemperature) private var openAIModelTemperature
33+
@ObservationIgnored @LocalPreference(.openAIModel) private(set) var openAIModel
34+
@ObservationIgnored @LocalPreference(.openAIModelTemperature) private(set) var openAIModelTemperature
3535
@ObservationIgnored @LocalPreference(.fogModel) private var fogModel
3636
@ObservationIgnored @LocalPreference(.resourceLimit) private var resourceLimit
3737

@@ -40,7 +40,7 @@ final class FHIRInterpretationModule: Module, EnvironmentAccessible, @unchecked
4040
@ObservationIgnored private var updateModelsTask: Task<Void, any Error>?
4141

4242
@MainActor var singleResourceLLMSchema: any LLMSchema {
43-
switch self.llmSource {
43+
switch llmSource {
4444
case .openai:
4545
LLMOpenAISchema(
4646
parameters: .init(modelType: openAIModel.rawValue, systemPrompts: []),
@@ -64,7 +64,7 @@ final class FHIRInterpretationModule: Module, EnvironmentAccessible, @unchecked
6464
) {
6565
FHIRGetResourceLLMFunction(
6666
fhirStore: self.fhirStore,
67-
resourceSummary: self.resourceSummary,
67+
resourceSummarizer: self.resourceSummarizer,
6868
resourceCountLimit: resourceLimit
6969
)
7070
}
@@ -76,19 +76,17 @@ final class FHIRInterpretationModule: Module, EnvironmentAccessible, @unchecked
7676

7777
@MainActor
7878
func configure() {
79-
self.resourceSummary = FHIRResourceSummary(
79+
resourceSummarizer = FHIRResourceSummarizer(
8080
localStorage: localStorage,
8181
llmRunner: llmRunner,
8282
llmSchema: singleResourceLLMSchema
8383
)
84-
85-
self.resourceInterpreter = FHIRResourceInterpreter(
84+
singleResourceInterpreter = SingleFHIRResourceInterpreter(
8685
localStorage: localStorage,
8786
llmRunner: llmRunner,
8887
llmSchema: singleResourceLLMSchema
8988
)
90-
91-
self.multipleResourceInterpreter = FHIRMultipleResourceInterpreter(
89+
multipleResourceInterpreter = FHIRMultipleResourceInterpreter(
9290
localStorage: localStorage,
9391
llmRunner: llmRunner,
9492
llmSchema: multipleResourceInterpreterOpenAISchema,
@@ -112,9 +110,12 @@ final class FHIRInterpretationModule: Module, EnvironmentAccessible, @unchecked
112110
updateModelsTask?.cancel()
113111
let imp = { [self] in
114112
let summarizePrompt = currentStudy?.study.summarizeSingleResourcePrompt ?? .summarizeSingleFHIRResourceDefaultPrompt
115-
await resourceSummary.update(llmSchema: singleResourceLLMSchema, summarizationPrompt: summarizePrompt)
116-
await resourceInterpreter.update(llmSchema: singleResourceLLMSchema, summarizationPrompt: summarizePrompt)
117-
multipleResourceInterpreter.changeLLMSchema(to: multipleResourceInterpreterOpenAISchema, for: currentStudy?.study)
113+
await resourceSummarizer.update(llmSchema: singleResourceLLMSchema, summarizationPrompt: summarizePrompt)
114+
await singleResourceInterpreter.update(llmSchema: singleResourceLLMSchema, summarizationPrompt: summarizePrompt)
115+
multipleResourceInterpreter.changeLLMSchema(
116+
to: multipleResourceInterpreterOpenAISchema,
117+
using: currentStudy?.study.interpretMultipleResourcesPrompt ?? .interpretMultipleResourcesDefaultPrompt
118+
)
118119
}
119120
if forceImmediateUpdate {
120121
await imp()

0 commit comments

Comments
 (0)