Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,10 @@ xcuserdata/
report.junit
report.html
*.xcresult

# Local ML artifacts
checkpoints/
data/
HealthyLLM/Supporting Files/LocalLLM/model.safetensors
HealthyLLM/Supporting Files/OpenTSLM/sleep_cot.csv
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
17 changes: 17 additions & 0 deletions HealthyLLM.xcodeproj/xcshareddata/xcschemes/HealthyLLM.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@
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 = "1"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "HEALTHYLLM_OPEN_TSLM_RUN_LLM"
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