Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,14 @@ xcuserdata/
report.junit
report.html
*.xcresult

# Local ML artifacts
checkpoints/
data/
HealthyLLM/Supporting Files/LocalLLM/model.safetensors
HealthyLLM/Supporting Files/OpenTSLM/sleep_cot.csv
HealthyLLM/Supporting Files/OpenTSLM/ecg_qa_cot_test.csv
HealthyLLM/Supporting Files/OpenTSLM/ecg_qa_waveforms/
HealthyLLM/Supporting Files/OpenTSLM/ecg_qa_template_answers.json
HealthyLLM/Supporting Files/OpenTSLM/ecg_qa_waveforms.json
jsons/model.safetensors
12 changes: 6 additions & 6 deletions HealthyLLM.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"HealthyLLM/Supporting Files/Preview Content\"";
DEVELOPMENT_TEAM = CQRZ4E7K9U;
DEVELOPMENT_TEAM = V5G338H5V6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
Expand Down Expand Up @@ -586,7 +586,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"HealthyLLM/Supporting Files/Preview Content\"";
DEVELOPMENT_TEAM = CQRZ4E7K9U;
DEVELOPMENT_TEAM = V5G338H5V6;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
Expand Down Expand Up @@ -628,7 +628,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"HealthyLLMStudy/Supporting Files/Preview Content\"";
DEVELOPMENT_TEAM = CQRZ4E7K9U;
DEVELOPMENT_TEAM = C496LC49DH;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "HealthyLLMStudy/Supporting Files/Info.plist";
Expand All @@ -653,7 +653,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = edu.maxrosenblattl.bdhg.healthyllm;
PRODUCT_BUNDLE_IDENTIFIER = edu.maxrosenblattl.bdhg.healthyllm.co;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
Expand All @@ -675,7 +675,7 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"HealthyLLMStudy/Supporting Files/Preview Content\"";
DEVELOPMENT_TEAM = CQRZ4E7K9U;
DEVELOPMENT_TEAM = C496LC49DH;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "HealthyLLMStudy/Supporting Files/Info.plist";
Expand All @@ -700,7 +700,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = edu.maxrosenblattl.bdhg.healthyllm;
PRODUCT_BUNDLE_IDENTIFIER = edu.maxrosenblattl.bdhg.healthyllm.co;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
Expand Down
32 changes: 32 additions & 0 deletions HealthyLLM.xcodeproj/xcshareddata/xcschemes/HealthyLLM.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,38 @@
ReferencedContainer = "container:HealthyLLM.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "HEALTHYLLM_USE_CUSTOM_CHAT_TEMPLATE"
value = "0"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "HEALTHYLLM_INCLUDE_HARDCODED_ECG"
value = "0"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "HEALTHYLLM_OPEN_TSLM_MAX_SERIES_LENGTH"
value = "250"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "HEALTHYLLM_OPEN_TSLM_RUN_LLM"
value = "0"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "HEALTHYLLM_SKIP_LLM_LOAD"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "HEALTHYLLM_AUTO_ECG_SAMPLE"
value = "1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// This source file is part of the HealthyLLM based on the Stanford Spezi Template Application project
//
// SPDX-FileCopyrightText: 2026 Stanford University
//
// SPDX-License-Identifier: MIT
//

import Foundation
import HealthKit
import SpeziHealthKit

extension HealthDataFetcher {
func fetchElectrocardiograms(
_ healthKit: HealthKit,
limit: Int = 1
) async throws -> [ElectrocardiogramData] {
let samples = try await fetchElectrocardiogramSamples(healthKit, limit: limit)

var records: [ElectrocardiogramData] = []
for sample in samples {
let voltages = try await fetchVoltageMeasurements(for: sample, healthKit: healthKit)
records.append(
.init(
startDate: sample.startDate,
endDate: sample.endDate,
classification: String(describing: sample.classification),
symptomsStatus: String(describing: sample.symptomsStatus),
averageHeartRate: sample.averageHeartRate?.doubleValue(
for: .count().unitDivided(by: .minute())
),
samplingFrequency: sample.samplingFrequency?.doubleValue(for: .hertz()),
numberOfVoltageMeasurements: voltages.count,
voltages: voltages
)
)
}

return records
}

private func fetchElectrocardiogramSamples(
_ healthKit: HealthKit,
limit: Int
) async throws -> [HKElectrocardiogram] {
try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: HKObjectType.electrocardiogramType(),
predicate: nil,
limit: limit,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)]
) { _, samples, error in
if let error {
continuation.resume(throwing: error)
return
}

continuation.resume(returning: samples as? [HKElectrocardiogram] ?? [])
}

healthKit.healthStore.execute(query)
}
}

private func fetchVoltageMeasurements(
for sample: HKElectrocardiogram,
healthKit: HealthKit
) async throws -> [Double] {
try await withCheckedThrowingContinuation { continuation in
var voltages: [Double] = []
var didResume = false

let query = HKElectrocardiogramQuery(electrocardiogram: sample) { _, measurement, done, error in
if let error, !didResume {
didResume = true
continuation.resume(throwing: error)
return
}

if let measurement,
let voltageQuantity = measurement.quantity(for: .appleWatchSimilarToLeadI) {
voltages.append(voltageQuantity.doubleValue(for: .volt()))
}

if done, !didResume {
didResume = true
continuation.resume(returning: voltages)
}
}

healthKit.healthStore.execute(query)
}
}
}
3 changes: 2 additions & 1 deletion HealthyLLM/HealthyLLM/Fetcher/HealthDataFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ class HealthDataFetcher: DefaultInitializable, Module, EnvironmentAccessible {
let readTypes = Set([
HKSeriesType.activitySummaryType(),
HKSeriesType.workoutRoute(),
HKSeriesType.workoutType()
HKSeriesType.workoutType(),
HKObjectType.electrocardiogramType()
]).union(
Set(allHKQuantityTypeIdentifiers().map { HKQuantityType($0) })
)
Expand Down
88 changes: 88 additions & 0 deletions HealthyLLM/HealthyLLM/HealthContextGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,92 @@ import Spezi

class HealthContextGenerator: DefaultInitializable, Module, EnvironmentAccessible {
required init() { }

func buildSystemPrompt(userInfo: UserInfo?, electrocardiograms: [ElectrocardiogramData]) -> HealthyLLMContextEntity {
var prompt = """
You are a health assistant. Provide a careful, non-diagnostic interpretation of the user's health data.
If ECG data is present, focus on signal quality, rhythm regularity, heart rate, and any notable abnormalities.
Do not claim to diagnose a condition.
"""

if let userInfo {
prompt += "\n\nUser profile:\n"
prompt += userInfo.asJSONRepresentation(.prettyPrinted) ?? "No Data"
}

if !electrocardiograms.isEmpty {
prompt += "\n\nLatest ECG samples:\n"
prompt += electrocardiograms.enumerated().map { index, sample in
formatECGSample(sample, index: index + 1)
}.joined(separator: "\n\n")
} else {
prompt += "\n\nLatest ECG samples: No Data"
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

prompt += "\n\nRespond with a concise clinical-style summary and a safety note if the tracing looks concerning."

return .init(.system, content: prompt)
}

private func formatECGSample(_ sample: ElectrocardiogramData, index: Int) -> String {
let voltages = sample.voltages
let average = voltages.isEmpty ? nil : voltages.reduce(0, +) / Double(voltages.count)
let minimum = voltages.min()
let maximum = voltages.max()
let preview = voltages.prefix(24).map { String(format: "%.4f", $0) }.joined(separator: ", ")

let normalizedVoltages = zNormalize(voltages)
let normalizedPreview = normalizedVoltages.prefix(48).map { String(format: "%.6f", $0) }.joined(separator: ", ")

let averageHeartRateText = sample.averageHeartRate.map(String.init(describing:)) ?? "No Data"
let samplingFrequencyText = sample.samplingFrequency.map(String.init(describing:)) ?? "No Data"
let voltageMeanText = average.map(String.init(describing:)) ?? "No Data"
let voltageMinText = minimum.map(String.init(describing:)) ?? "No Data"
let voltageMaxText = maximum.map(String.init(describing:)) ?? "No Data"

let sleepCotStylePrompt = """
sleep_cot_style_sample:
pre_prompt: You are given a short single-lead ECG time series segment. Analyze rhythm, signal quality, and notable concerns conservatively.
time_series_text:
- The following is the ECG time series with mean \(String(format: "%.6f", average ?? 0)) and min/max \(String(format: "%.6f", minimum ?? 0))/\(String(format: "%.6f", maximum ?? 0)).
time_series_normalized_preview: [\(normalizedPreview)]
post_prompt: First summarize waveform quality and rhythm regularity, then provide brief safety guidance and when to seek care.
"""
Comment on lines +48 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Treat missing voltage arrays as missing data, not zeros.

When sample.voltages is empty, Line 61 still emits mean/min/max as 0.000000 with an empty preview. That fabricates a flat trace and can push the model toward a bogus safety interpretation instead of recognizing that the ECG payload is missing.

🩺 Safer handling for empty ECG samples
-        let sleepCotStylePrompt = """
-                sleep_cot_style_sample:
-                    pre_prompt: You are given a short single-lead ECG time series segment. Analyze rhythm, signal quality, and notable concerns conservatively.
-                    time_series_text:
-                        - The following is the ECG time series with mean \(String(format: "%.6f", average ?? 0)) and min/max \(String(format: "%.6f", minimum ?? 0))/\(String(format: "%.6f", maximum ?? 0)).
-                    time_series_normalized_preview: [\(normalizedPreview)]
-                    post_prompt: First summarize waveform quality and rhythm regularity, then provide brief safety guidance and when to seek care.
-                """
+        let sleepCotStylePrompt: String
+        if voltages.isEmpty {
+            sleepCotStylePrompt = """
+            sleep_cot_style_sample:
+                time_series_text:
+                    - No voltage samples available.
+            """
+        } else {
+            sleepCotStylePrompt = """
+            sleep_cot_style_sample:
+                pre_prompt: You are given a short single-lead ECG time series segment. Analyze rhythm, signal quality, and notable concerns conservatively.
+                time_series_text:
+                    - The following is the ECG time series with mean \(String(format: "%.6f", average!)) and min/max \(String(format: "%.6f", minimum!))/\(String(format: "%.6f", maximum!)).
+                time_series_normalized_preview: [\(normalizedPreview)]
+                post_prompt: First summarize waveform quality and rhythm regularity, then provide brief safety guidance and when to seek care.
+            """
+        }
🧰 Tools
🪛 GitHub Check: SwiftLint / SwiftLint

[failure] 59-59:
Line Length Violation: Line should be 150 characters or less; currently it has 159 characters (line_length)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@HealthyLLM/HealthyLLM/HealthContextGenerator.swift` around lines 48 - 64, The
prompt currently formats mean/min/max and normalizedPreview using 0 when
sample.voltages is empty, fabricating a flat trace; update the code that builds
normalizedVoltages/normalizedPreview and the sleep_cot_style_sample block to
treat empty or nil sample.voltages as missing data: use optional checks on
average/minimum/maximum (and normalizedVoltages) and emit "No Data" (or an
explicit "missing ECG data" token) instead of String(format: "%.6f", 0), and
ensure normalizedPreview is either an explicit "No Data" string when
voltages.isEmpty or constructed only from existing values; change references to
voltageMeanText/voltageMinText/voltageMaxText (and
time_series_normalized_preview) so the prompt interpolates those safe "No Data"
placeholders rather than zeros.


var lines: [String] = [
"ECG sample #\(index)",
"start_date: \(sample.startDate.formatted(date: .abbreviated, time: .shortened))",
"end_date: \(sample.endDate.formatted(date: .abbreviated, time: .shortened))",
"classification: \(sample.classification)",
"symptoms_status: \(sample.symptomsStatus)",
"average_heart_rate: \(averageHeartRateText)",
"sampling_frequency_hz: \(samplingFrequencyText)",
"voltage_count: \(sample.numberOfVoltageMeasurements)",
"voltage_mean: \(voltageMeanText)",
"voltage_min: \(voltageMinText)",
"voltage_max: \(voltageMaxText)"
]

if !preview.isEmpty {
lines.append("voltage_preview: [\(preview)]")
}

lines.append(sleepCotStylePrompt)

return lines.joined(separator: "\n")
}

private func zNormalize(_ values: [Double]) -> [Double] {
guard !values.isEmpty else {
return []
}

let mean = values.reduce(0, +) / Double(values.count)
let variance = values.reduce(0) { partial, value in
let delta = value - mean
return partial + delta * delta
} / Double(values.count)
let std = max(sqrt(variance), 1e-6)
return values.map { ($0 - mean) / std }
}
}
Loading
Loading