diff --git a/Dayflow/Dayflow/Core/AI/GeminiDirectProvider.swift b/Dayflow/Dayflow/Core/AI/GeminiDirectProvider.swift index 37a9c5a5..8040a932 100644 --- a/Dayflow/Dayflow/Core/AI/GeminiDirectProvider.swift +++ b/Dayflow/Dayflow/Core/AI/GeminiDirectProvider.swift @@ -266,38 +266,10 @@ final class GeminiDirectProvider { // realDuration is available via compressionFactor if needed for debugging - let finalTranscriptionPrompt = """ - Screen Recording Transcription (Reconstruct Mode) - Watch this screen recording and create an activity log detailed enough that someone could reconstruct the session. - CRITICAL: This video is exactly \(durationString) long. ALL timestamps must be within 00:00 to \(durationString). No gaps. - Identifying the active app: On macOS, the app name is always shown in the top-left corner of the screen, right next to the Apple () menu. Check this FIRST to identify which app is being used. Do NOT guess — read the actual name from the menu bar. If you can't read it clearly, describe it generically (e.g., "code editor," "browser," "messaging app") rather than guessing a specific product name. Common code editors like Cursor, VS Code, Xcode, and Zed all look similar but have different names in the menu bar. - For each segment, ask yourself: - "What EXACTLY did they do? What SPECIFIC things can I see?" - Capture: - - Exact app/site names visible (check menu bar for app name) - - Exact file names, URLs, page titles - - Exact usernames, search queries, messages - - Exact numbers, stats, prices shown - Bad: "Checked email" - Good: "Gmail: Read email from boss@company.com 'RE: Budget approval' - replied 'Looks good'" - Bad: "Browsing Twitter" - Good: "Twitter/X: Scrolled feed - viewed posts by @pmarca about AI, @sama thread on GPT-5 (12 tweets)" - Bad: "Working on code" - Good: "Editing StorageManager.swift in [exact app name from menu bar] - fixed type error on line 47, changed String to String?" - Segments: - - 3-8 segments total - - You may use 1 segment only if the user appears idle for most of the recording - - Group by GOAL not app (IDE + Terminal + Browser for the same task = 1 segment) - - Do not create gaps; cover the full timeline - Return ONLY JSON in this format: - [ - { - "startTimestamp": "MM:SS", - "endTimestamp": "MM:SS", - "description": "1-3 sentences with specific details" - } - ] - """ + let finalTranscriptionPrompt = LLMPromptTemplates.screenRecordingTranscriptionPrompt( + durationString: durationString, + schema: LLMSchema.screenRecordingTranscriptionSchema + ) // UNIFIED RETRY LOOP - Handles ALL errors comprehensively let maxRetries = 3 @@ -323,53 +295,28 @@ final class GeminiDirectProvider { attempt: attempt + 1 ) - let videoTranscripts = try parseTranscripts(response) - - // Convert video transcripts to observations with proper Unix timestamps - // Timestamps from Gemini are in compressed video time, so we expand them - // by the compression factor to get real-world timestamps. - var hasValidationErrors = false - let observations = videoTranscripts.compactMap { chunk -> Observation? in - let compressedStartSeconds = parseVideoTimestamp(chunk.startTimestamp) - let compressedEndSeconds = parseVideoTimestamp(chunk.endTimestamp) - - // Validate timestamps are within compressed video duration (with small tolerance) - let tolerance: TimeInterval = 10.0 // 10 seconds tolerance in compressed time - if Double(compressedStartSeconds) < -tolerance - || Double(compressedEndSeconds) > videoDuration + tolerance - { - print( - "āŒ VALIDATION ERROR: Observation timestamps (\(chunk.startTimestamp) - \(chunk.endTimestamp)) exceed video duration \(durationString)!" - ) - hasValidationErrors = true - return nil - } - - // Expand timestamps by compression factor to get real-world time - let realStartSeconds = TimeInterval(compressedStartSeconds) * compressionFactor - let realEndSeconds = TimeInterval(compressedEndSeconds) * compressionFactor - - let startDate = batchStartTime.addingTimeInterval(realStartSeconds) - let endDate = batchStartTime.addingTimeInterval(realEndSeconds) - - print( - "šŸ“ Timestamp expansion: \(chunk.startTimestamp)-\(chunk.endTimestamp) → \(Int(realStartSeconds))s-\(Int(realEndSeconds))s real" - ) - - return Observation( - id: nil, - batchId: 0, // Will be set when saved - startTs: Int(startDate.timeIntervalSince1970), - endTs: Int(endDate.timeIntervalSince1970), - observation: chunk.description, - metadata: nil, - llmModel: usedModel, - createdAt: Date() - ) - } + let videoTranscripts = try LLMTranscriptUtilities.decodeTranscriptChunks(from: response) + + // Convert video transcripts to observations with proper Unix timestamps. + // Timestamps from Gemini are in compressed video time, so we expand them by `compressionFactor`. + let conversion = LLMTranscriptUtilities.observations( + from: videoTranscripts, + batchStartTime: batchStartTime, + observationBatchId: 0, // Will be set when saved + llmModel: usedModel, + compressedVideoDuration: videoDuration, + compressionFactor: compressionFactor, + tolerance: 10.0, + debugPrintExpansion: true + ) + let observations = conversion.observations + let hasValidationErrors = conversion.invalidTimestampCount > 0 // If we had validation errors, throw to trigger retry if hasValidationErrors { + print( + "āŒ VALIDATION ERROR: One or more transcript chunks exceeded video duration \(durationString) (invalidCount=\(conversion.invalidTimestampCount))" + ) AnalyticsService.shared.captureValidationFailure( provider: "gemini", operation: "transcribe", @@ -588,139 +535,20 @@ final class GeminiDirectProvider { encoder.outputFormatting = .prettyPrinted let existingCardsJSON = try encoder.encode(context.existingCards) let existingCardsString = String(data: existingCardsJSON, encoding: .utf8) ?? "[]" - let promptSections = GeminiPromptSections(overrides: GeminiPromptPreferences.load()) + let promptSections = VideoPromptSections(overrides: VideoPromptPreferences.load()) let languageBlock = LLMOutputLanguagePreferences.languageInstruction(forJSON: true) .map { "\n\n\($0)" } ?? "" - let basePrompt = """ - # Timeline Card Generation - - You're writing someone's personal work journal. You'll get raw activity logs — screenshots, app switches, URLs — and your job is to turn them into timeline cards that help this person remember what they actually did. - - The test: when they scan their timeline tomorrow morning, each card should make them go "oh right, that." - - Write as if you ARE the person jotting down notes about their day. Not an analyst writing a report. Not a manager filing a status update. - - --- - - ## Card Structure - - Each card covers one cohesive chunk of activity, roughly 15–60 minutes. - - - Minimum 10 minutes per card. If something would be shorter, fold it into the neighboring card that makes the most sense. - - Maximum 60 minutes. If a card runs longer, split it where the focus naturally shifts. - - No gaps or overlaps between cards. If there's a real gap in the source data, preserve it. Otherwise, cards should meet cleanly. - - **When to start a new card:** - 1. What's the main thing happening right now? - 2. Does the next chunk of activity continue that same thing? → Keep extending. - 3. Is there a brief unrelated detour (<5 min)? → Log it as a distraction, keep the card going. - 4. Has the focus genuinely shifted for 10+ minutes? → New card. - - **When to merge with a previous card:** - 1. Is the previous card's main activity the same as what's happening now? (same PR, same feature, same codebase, same article) → Merge. - 2. Did the person just take a 2–5 minute break (X, messages, YouTube) and come back to the same thing? → That's a distraction, not a new card. Merge. - 3. Are two adjacent cards both "scrolling X with occasional work check-ins"? → Merge. The vibe didn't change. - 4. Only start a new card if the CORE INTENT changed for 10+ minutes. - - DEFAULT TO MERGING. Two 15-minute cards about the same work stream should almost never exist. If you're unsure whether to merge or split, merge. - - --- - - \(promptSections.title) - - --- - - \(promptSections.summary) - - --- - - \(promptSections.detailedSummary) - - \(languageBlock) - - --- - - ## Category - - \(categoriesSection(from: context.categories)) - - --- - - ## Distractions - - A distraction is a brief (<5 min) unrelated interruption inside a card. Checking X for 2 minutes while debugging is a distraction. Spending 15 minutes on X is not a distraction — it's either part of the card's theme or it's a new card. - - Don't label related sub-tasks as distractions. Googling an error message while debugging isn't a distraction, it's part of debugging. - - --- - - ## App Sites - - Identify the main app or website for each card. - - - primary: the main app used in the card (canonical domain, lowercase, no protocol). - - secondary: another meaningful app used, or the enclosing app (e.g., browser). Omit if there isn't a clear one. - - Be specific: docs.google.com not google.com, mail.google.com not google.com. - - Common mappings: - - Figma → figma.com - - Notion → notion.so - - Google Docs → docs.google.com - - Gmail → mail.google.com - - VS Code → code.visualstudio.com - - Xcode → developer.apple.com/xcode - - Twitter/X → x.com - - Zoom → zoom.us - - ChatGPT → chatgpt.com - - --- - - ## Continuity Rules - - Your output cards must cover the same total time range as the previous cards plus any new observations. Think of previous cards as a draft you're revising and extending, not locked history. - - - Don't drop time segments that were previously covered. - - If new observations extend beyond the previous range, add cards to cover the new time. - - Preserve genuine gaps in the source data. - - Before generating output, review the previous cards and ask: - - Could any two adjacent previous cards be the same activity session? - - Does your first new card continue the last previous card's work? - If yes to either, merge them in your output. - - INPUTS: - Previous cards: \(existingCardsString) - New observations: \(transcriptText) - Return ONLY a JSON array with this EXACT structure: - - [ - { - "startTime": "1:12 AM", - "endTime": "1:30 AM", - "category": "", - "subcategory": "", - "title": "", - "summary": "", - "detailedSummary": "", - "distractions": [ - { - "startTime": "1:15 AM", - "endTime": "1:18 AM", - "title": "", - "summary": "" - } - ], - "appSites": { - "primary": "", - "secondary": " - } - } - ] - """ + let basePrompt = LLMPromptTemplates.activityCardsPrompt( + existingCardsString: existingCardsString, + transcriptText: transcriptText, + categoriesSection: categoriesSection(from: context.categories), + promptSections: promptSections, + languageBlock: languageBlock, + schema: LLMSchema.activityCardsSchema, + ) // UNIFIED RETRY LOOP - Handles ALL errors comprehensively let maxRetries = 4 @@ -1171,26 +999,14 @@ final class GeminiDirectProvider { fileURI: String, mimeType: String, prompt: String, batchId: Int64?, groupId: String, model: GeminiModel, attempt: Int ) async throws -> (String, String) { - let transcriptionSchema: [String: Any] = [ - "type": "ARRAY", - "items": [ - "type": "OBJECT", - "properties": [ - "startTimestamp": ["type": "STRING"], - "endTimestamp": ["type": "STRING"], - "description": ["type": "STRING"], - ], - "required": ["startTimestamp", "endTimestamp", "description"], - "propertyOrdering": ["startTimestamp", "endTimestamp", "description"], - ], - ] - + let transcriptionSchemaObject = try! JSONSerialization.jsonObject( + with: Data(LLMSchema.screenRecordingTranscriptionSchema.utf8)) let generationConfig: [String: Any] = [ "temperature": 0.3, "maxOutputTokens": 65536, "mediaResolution": "MEDIA_RESOLUTION_HIGH", "responseMimeType": "application/json", - "responseSchema": transcriptionSchema, + "responseJsonSchema": transcriptionSchemaObject, ] let requestBody: [String: Any] = [ @@ -1476,34 +1292,6 @@ final class GeminiDirectProvider { } } - // Temporary struct for parsing Gemini response - private struct VideoTranscriptChunk: Codable { - let startTimestamp: String // MM:SS - let endTimestamp: String // MM:SS - let description: String - } - - private func parseTranscripts(_ response: String) throws -> [VideoTranscriptChunk] { - guard let data = response.data(using: .utf8) else { - print( - "šŸ”Ž GEMINI DEBUG: parseTranscripts received non-UTF8 or empty response: \(truncate(response, max: 400))" - ) - throw NSError( - domain: "GeminiError", code: 8, - userInfo: [NSLocalizedDescriptionKey: "Invalid response encoding"]) - } - do { - let transcripts = try JSONDecoder().decode([VideoTranscriptChunk].self, from: data) - return transcripts - } catch { - let snippet = truncate(String(data: data, encoding: .utf8) ?? "", max: 1200) - print( - "šŸ”Ž GEMINI DEBUG: parseTranscripts JSON decode failed: \(error.localizedDescription) bodySnippet=\(snippet)" - ) - throw error - } - } - private func geminiCardsRequest( prompt: String, batchId: Int64?, groupId: String, model: GeminiModel, attempt: Int ) async throws -> String { @@ -1865,267 +1653,27 @@ final class GeminiDirectProvider { // (no local logging helpers needed; centralized via LLMLogger) - private struct TimeRange { - let start: Double // minutes from midnight - let end: Double - } - private func timeToMinutes(_ timeStr: String) -> Double { - // Handle both "10:30 AM" and "05:30" formats - if timeStr.contains("AM") || timeStr.contains("PM") { - // Clock format - parse as date - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") - - if let date = formatter.date(from: timeStr) { - let calendar = Calendar.current - let components = calendar.dateComponents([.hour, .minute], from: date) - return Double((components.hour ?? 0) * 60 + (components.minute ?? 0)) - } - return 0 - } else { - // MM:SS format - convert to minutes - let seconds = parseVideoTimestamp(timeStr) - return Double(seconds) / 60.0 - } - } - - private func mergeOverlappingRanges(_ ranges: [TimeRange]) -> [TimeRange] { - guard !ranges.isEmpty else { return [] } - - // Sort by start time - let sorted = ranges.sorted { $0.start < $1.start } - var merged: [TimeRange] = [] - - for range in sorted { - if merged.isEmpty || range.start > merged.last!.end + 1 { - // No overlap - add as new range - merged.append(range) - } else { - // Overlap or adjacent - merge with last range - let last = merged.removeLast() - merged.append(TimeRange(start: last.start, end: max(last.end, range.end))) - } - } - - return merged + LLMTimelineCardValidation.timeToMinutes(timeStr) } private func validateTimeCoverage(existingCards: [ActivityCardData], newCards: [ActivityCardData]) -> (isValid: Bool, error: String?) { - guard !existingCards.isEmpty else { - return (true, nil) - } - - // Extract time ranges from input cards - var inputRanges: [TimeRange] = [] - for card in existingCards { - let startMin = timeToMinutes(card.startTime) - var endMin = timeToMinutes(card.endTime) - if endMin < startMin { // Handle day rollover - endMin += 24 * 60 - } - inputRanges.append(TimeRange(start: startMin, end: endMin)) - } - - // Merge overlapping/adjacent ranges - let mergedInputRanges = mergeOverlappingRanges(inputRanges) - - // Extract time ranges from output cards (Fix #1: Skip zero or negative duration cards) - var outputRanges: [TimeRange] = [] - for card in newCards { - let startMin = timeToMinutes(card.startTime) - var endMin = timeToMinutes(card.endTime) - if endMin < startMin { // Handle day rollover - endMin += 24 * 60 - } - // Skip zero or very short duration cards (less than 0.1 minutes = 6 seconds) - guard endMin - startMin >= 0.1 else { - continue - } - outputRanges.append(TimeRange(start: startMin, end: endMin)) - } - - // Check coverage with 3-minute flexibility - let flexibility = 3.0 // minutes - var uncoveredSegments: [(start: Double, end: Double)] = [] - - for inputRange in mergedInputRanges { - // Check if this input range is covered by output ranges - var coveredStart = inputRange.start - var safetyCounter = 10000 // Fix #3: Safety cap to prevent infinite loops - - while coveredStart < inputRange.end && safetyCounter > 0 { - safetyCounter -= 1 - // Find an output range that covers this point - var foundCoverage = false - - for outputRange in outputRanges { - // Check if this output range covers the current point (with flexibility) - if outputRange.start - flexibility <= coveredStart - && coveredStart <= outputRange.end + flexibility - { - // Move coveredStart to the end of this output range (Fix #2: Force progress) - let newCoveredStart = outputRange.end - // Ensure we make at least minimal progress (0.01 minutes = 0.6 seconds) - coveredStart = max(coveredStart + 0.01, newCoveredStart) - foundCoverage = true - break - } - } - - if !foundCoverage { - // Find the next covered point - var nextCovered = inputRange.end - for outputRange in outputRanges { - if outputRange.start > coveredStart && outputRange.start < nextCovered { - nextCovered = outputRange.start - } - } - - // Add uncovered segment - if nextCovered > coveredStart { - uncoveredSegments.append((start: coveredStart, end: min(nextCovered, inputRange.end))) - coveredStart = nextCovered - } else { - // No more coverage found, add remaining segment and break - uncoveredSegments.append((start: coveredStart, end: inputRange.end)) - break - } - } - } - - // Check if safety counter was exhausted - if safetyCounter == 0 { - return ( - false, - "Time coverage validation loop exceeded safety limit - possible infinite loop detected" - ) - } - } - - // Check if uncovered segments are significant - if !uncoveredSegments.isEmpty { - var uncoveredDesc: [String] = [] - for segment in uncoveredSegments { - let duration = segment.end - segment.start - if duration > flexibility { // Only report significant gaps - let startTime = minutesToTimeString(segment.start) - let endTime = minutesToTimeString(segment.end) - uncoveredDesc.append("\(startTime)-\(endTime) (\(Int(duration)) min)") - } - } - - if !uncoveredDesc.isEmpty { - // Build detailed error message with input/output cards - var errorMsg = - "Missing coverage for time segments: \(uncoveredDesc.joined(separator: ", "))" - errorMsg += "\n\nšŸ“„ INPUT CARDS:" - for (i, card) in existingCards.enumerated() { - errorMsg += "\n \(i+1). \(card.startTime) - \(card.endTime): \(card.title)" - } - errorMsg += "\n\nšŸ“¤ OUTPUT CARDS:" - for (i, card) in newCards.enumerated() { - errorMsg += "\n \(i+1). \(card.startTime) - \(card.endTime): \(card.title)" - } - - return (false, errorMsg) - } - } - - return (true, nil) + LLMTimelineCardValidation.validateTimeCoverage(existingCards: existingCards, newCards: newCards) } private func validateTimeline(_ cards: [ActivityCardData]) -> (isValid: Bool, error: String?) { - for (index, card) in cards.enumerated() { - let startTime = card.startTime - let endTime = card.endTime - - var durationMinutes: Double = 0 - - // Check if times are in clock format (contains AM/PM) - if startTime.contains("AM") || startTime.contains("PM") { - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") - - if let startDate = formatter.date(from: startTime), - let endDate = formatter.date(from: endTime) - { - - var adjustedEndDate = endDate - // Handle day rollover (e.g., 11:30 PM to 12:30 AM) - if endDate < startDate { - adjustedEndDate = - Calendar.current.date(byAdding: .day, value: 1, to: endDate) ?? endDate - } - - durationMinutes = adjustedEndDate.timeIntervalSince(startDate) / 60.0 - } else { - // Failed to parse clock times - durationMinutes = 0 - } - } else { - // Parse MM:SS format - let startSeconds = parseVideoTimestamp(startTime) - let endSeconds = parseVideoTimestamp(endTime) - durationMinutes = Double(endSeconds - startSeconds) / 60.0 - } - - // Check if card is too short (except for last card) - if durationMinutes < 10 && index < cards.count - 1 { - return ( - false, - "Card \(index + 1) '\(card.title)' is only \(String(format: "%.1f", durationMinutes)) minutes long" - ) - } - } - - return (true, nil) + LLMTimelineCardValidation.validateTimeline(cards) } private func minutesToTimeString(_ minutes: Double) -> String { - let hours = (Int(minutes) / 60) % 24 // Handle > 24 hours - let mins = Int(minutes) % 60 - let period = hours < 12 ? "AM" : "PM" - var displayHour = hours % 12 - if displayHour == 0 { - displayHour = 12 - } - return String(format: "%d:%02d %@", displayHour, mins, period) - } - - private func parseVideoTimestamp(_ timestamp: String) -> Int { - let components = timestamp.components(separatedBy: ":") - - if components.count == 2 { - // MM:SS format - let minutes = Int(components[0]) ?? 0 - let seconds = Int(components[1]) ?? 0 - return minutes * 60 + seconds - } else if components.count == 3 { - // HH:MM:SS format - let hours = Int(components[0]) ?? 0 - let minutes = Int(components[1]) ?? 0 - let seconds = Int(components[2]) ?? 0 - return hours * 3600 + minutes * 60 + seconds - } else { - // Invalid format, return 0 - print("Warning: Invalid video timestamp format: \(timestamp)") - return 0 - } + LLMTimelineCardValidation.minutesToTimeString(minutes) } // Helper function to format timestamps private func formatTimestampForPrompt(_ unixTime: Int) -> String { - let date = Date(timeIntervalSince1970: TimeInterval(unixTime)) - let formatter = DateFormatter() - formatter.dateFormat = "h:mm a" - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - return formatter.string(from: date) + LLMTimelineCardValidation.formatTimestampForPrompt(unixTime) } // MARK: - Text Generation @@ -2135,9 +1683,13 @@ final class GeminiDirectProvider { { let callStart = Date() + let activityCardsSchemaObject = try? JSONSerialization.jsonObject( + with: Data(LLMSchema.activityCardsSchema.utf8)) let generationConfig: [String: Any] = [ "temperature": 0.7, "maxOutputTokens": maxOutputTokens, + "responseMimeType": "application/json", + "responseJsonSchema": activityCardsSchemaObject, ] let requestBody: [String: Any] = [ diff --git a/Dayflow/Dayflow/Core/AI/GeminiPromptPreferences.swift b/Dayflow/Dayflow/Core/AI/GeminiPromptPreferences.swift index b416a32a..1f8d2d2c 100644 --- a/Dayflow/Dayflow/Core/AI/GeminiPromptPreferences.swift +++ b/Dayflow/Dayflow/Core/AI/GeminiPromptPreferences.swift @@ -1,6 +1,6 @@ import Foundation -struct GeminiPromptOverrides: Codable, Equatable { +struct VideoPromptOverrides: Codable, Equatable { var titleBlock: String? var summaryBlock: String? var detailedBlock: String? @@ -14,21 +14,21 @@ struct GeminiPromptOverrides: Codable, Equatable { } } -enum GeminiPromptPreferences { +enum VideoPromptPreferences { private static let overridesKey = "geminiPromptOverrides" private static let store = UserDefaults.standard - static func load() -> GeminiPromptOverrides { + static func load() -> VideoPromptOverrides { guard let data = store.data(forKey: overridesKey) else { - return GeminiPromptOverrides() + return VideoPromptOverrides() } - guard let overrides = try? JSONDecoder().decode(GeminiPromptOverrides.self, from: data) else { - return GeminiPromptOverrides() + guard let overrides = try? JSONDecoder().decode(VideoPromptOverrides.self, from: data) else { + return VideoPromptOverrides() } return overrides } - static func save(_ overrides: GeminiPromptOverrides) { + static func save(_ overrides: VideoPromptOverrides) { guard let data = try? JSONEncoder().encode(overrides) else { return } store.set(data, forKey: overridesKey) } @@ -145,17 +145,17 @@ enum GeminiPromptDefaults { """ } -struct GeminiPromptSections { +struct VideoPromptSections { let title: String let summary: String let detailedSummary: String - init(overrides: GeminiPromptOverrides) { - self.title = GeminiPromptSections.compose( + init(overrides: VideoPromptOverrides) { + self.title = VideoPromptSections.compose( defaultBlock: GeminiPromptDefaults.titleBlock, custom: overrides.titleBlock) - self.summary = GeminiPromptSections.compose( + self.summary = VideoPromptSections.compose( defaultBlock: GeminiPromptDefaults.summaryBlock, custom: overrides.summaryBlock) - self.detailedSummary = GeminiPromptSections.compose( + self.detailedSummary = VideoPromptSections.compose( defaultBlock: GeminiPromptDefaults.detailedSummaryBlock, custom: overrides.detailedBlock) } @@ -164,3 +164,153 @@ struct GeminiPromptSections { return trimmed.isEmpty ? defaultBlock : trimmed } } + +/// Shared prompt templates used by multiple LLM providers. +/// +/// When prompts must remain *exactly* identical between providers, keep them here and call these helpers. +enum LLMPromptTemplates { + static func screenRecordingTranscriptionPrompt(durationString: String, schema: String) -> String { + """ + Screen Recording Transcription (Reconstruct Mode) + Watch this screen recording and create an activity log detailed enough that someone could reconstruct the session. + CRITICAL: This video is exactly \(durationString) long. ALL timestamps must be within 00:00 to \(durationString). No gaps. + Identifying the active app: On macOS, the app name is always shown in the top-left corner of the screen, right next to the Apple () menu. Check this FIRST to identify which app is being used. Do NOT guess — read the actual name from the menu bar. If you can't read it clearly, describe it generically (e.g., "code editor," "browser," "messaging app") rather than guessing a specific product name. Common code editors like Cursor, VS Code, Xcode, and Zed all look similar but have different names in the menu bar. + For each segment, ask yourself: + "What EXACTLY did they do? What SPECIFIC things can I see?" + Capture: + - Exact app/site names visible (check menu bar for app name) + - Exact file names, URLs, page titles + - Exact usernames, search queries, messages + - Exact numbers, stats, prices shown + Bad: "Checked email" + Good: "Gmail: Read email from boss@company.com 'RE: Budget approval' - replied 'Looks good'" + Bad: "Browsing Twitter" + Good: "Twitter/X: Scrolled feed - viewed posts by @pmarca about AI, @sama thread on GPT-5 (12 tweets)" + Bad: "Working on code" + Good: "Editing StorageManager.swift in [exact app name from menu bar] - fixed type error on line 47, changed String to String?" + Segments: + - 3-8 segments total + - You may use 1 segment only if the user appears idle for most of the recording + - Group by GOAL not app (IDE + Terminal + Browser for the same task = 1 segment) + - Do not create gaps; cover the full timeline + + Return a JSON array that follows the schema: \(schema) + """ + } + + static func activityCardsPrompt( + existingCardsString: String, + transcriptText: String, + categoriesSection: String, + promptSections: VideoPromptSections, + languageBlock: String, + schema: String, + ) -> String { + """ + # Timeline Card Generation + + You're writing someone's personal work journal. You'll get raw activity logs — screenshots, app switches, URLs — and your job is to turn them into timeline cards that help this person remember what they actually did. + + The test: when they scan their timeline tomorrow morning, each card should make them go "oh right, that." + + Write as if you ARE the person jotting down notes about their day. Not an analyst writing a report. Not a manager filing a status update. + + --- + + ## Card Structure + + Each card covers one cohesive chunk of activity, roughly 15–60 minutes. + + - Minimum 10 minutes per card. If something would be shorter, fold it into the neighboring card that makes the most sense. + - Maximum 60 minutes. If a card runs longer, split it where the focus naturally shifts. + - No gaps or overlaps between cards. If there's a real gap in the source data, preserve it. Otherwise, cards should meet cleanly. + + **When to start a new card:** + 1. What's the main thing happening right now? + 2. Does the next chunk of activity continue that same thing? → Keep extending. + 3. Is there a brief unrelated detour (<5 min)? → Log it as a distraction, keep the card going. + 4. Has the focus genuinely shifted for 10+ minutes? → New card. + + **When to merge with a previous card:** + 1. Is the previous card's main activity the same as what's happening now? (same PR, same feature, same codebase, same article) → Merge. + 2. Did the person just take a 2–5 minute break (X, messages, YouTube) and come back to the same thing? → That's a distraction, not a new card. Merge. + 3. Are two adjacent cards both "scrolling X with occasional work check-ins"? → Merge. The vibe didn't change. + 4. Only start a new card if the CORE INTENT changed for 10+ minutes. + + DEFAULT TO MERGING. Two 15-minute cards about the same work stream should almost never exist. If you're unsure whether to merge or split, merge. + + + --- + + \(promptSections.title) + + --- + + \(promptSections.summary) + + --- + + \(promptSections.detailedSummary) + + \(languageBlock) + + --- + + ## Category + + \(categoriesSection) + + --- + + ## Distractions + + A distraction is a brief (<5 min) unrelated interruption inside a card. Checking X for 2 minutes while debugging is a distraction. Spending 15 minutes on X is not a distraction — it's either part of the card's theme or it's a new card. + + Don't label related sub-tasks as distractions. Googling an error message while debugging isn't a distraction, it's part of debugging. + + --- + + ## App Sites + + Identify the main app or website for each card. + + - primary: the main app used in the card (canonical domain, lowercase, no protocol). + - secondary: another meaningful app used, or the enclosing app (e.g., browser). Omit if there isn't a clear one. + + Be specific: docs.google.com not google.com, mail.google.com not google.com. + + Common mappings: + - Figma → figma.com + - Notion → notion.so + - Google Docs → docs.google.com + - Gmail → mail.google.com + - VS Code → code.visualstudio.com + - Xcode → developer.apple.com/xcode + - Twitter/X → x.com + - Zoom → zoom.us + - ChatGPT → chatgpt.com + + --- + + ## Continuity Rules + + Your output cards must cover the same total time range as the previous cards plus any new observations. Think of previous cards as a draft you're revising and extending, not locked history. + + - Don't drop time segments that were previously covered. + - If new observations extend beyond the previous range, add cards to cover the new time. + - Preserve genuine gaps in the source data. + + + Before generating output, review the previous cards and ask: + - Could any two adjacent previous cards be the same activity session? + - Does your first new card continue the last previous card's work? + If yes to either, merge them in your output. + + INPUTS: + Previous cards: \(existingCardsString) + New observations: \(transcriptText) + + Return a JSON array of activity cards that follows the schema: \(schema) + """ + } +} diff --git a/Dayflow/Dayflow/Core/AI/LLMSchema.swift b/Dayflow/Dayflow/Core/AI/LLMSchema.swift new file mode 100644 index 00000000..3585cf38 --- /dev/null +++ b/Dayflow/Dayflow/Core/AI/LLMSchema.swift @@ -0,0 +1,106 @@ +import Foundation + +enum LLMSchema { + static let screenRecordingTranscriptionSchema: String = """ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTimestamp": { + "type": "string", + "description": "The start timestamp of the segment in 'MM:SS' format." + }, + "endTimestamp": { + "type": "string", + "description": "The end timestamp of the segment in 'MM:SS' format." + }, + "description": { + "type": "string", + "description": "A 1-3 sentence description of the activity in the segment." + } + }, + "required": ["startTimestamp", "endTimestamp", "description"] + } + } + """ + + static let activityCardsSchema: String = """ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTime": { + "type": "string", + "description": "The start time of the activity card in 'h:mm a' format (e.g., '1:12 AM')." + }, + "endTime": { + "type": "string", + "description": "The end time of the activity card in 'h:mm a' format (e.g., '1:30 AM')." + }, + "category": { + "type": "string", + "description": "The category of the activity." + }, + "subcategory": { + "type": "string", + "description": "The subcategory of the activity." + }, + "title": { + "type": "string", + "description": "A concise title for the activity card." + }, + "summary": { + "type": "string", + "description": "A 2-3 sentence summary of the activity." + }, + "detailedSummary": { + "type": "string", + "description": "A detailed, granular log of the activity." + }, + "distractions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startTime": { + "type": "string", + "description": "The start time of the distraction in 'h:mm a' format." + }, + "endTime": { + "type": "string", + "description": "The end time of the distraction in 'h:mm a' format." + }, + "title": { + "type": "string", + "description": "A title for the distraction." + }, + "summary": { + "type": "string", + "description": "A summary of the distraction." + } + }, + "required": ["startTime", "endTime", "title", "summary"] + } + }, + "appSites": { + "type": "object", + "properties": { + "primary": { + "type": "string", + "description": "The primary app or website used." + }, + "secondary": { + "type": "string", + "description": "The secondary app or website used." + } + }, + "required": ["primary"] + } + }, + "required": ["startTime", "endTime", "category", "title", "summary", "detailedSummary", "appSites"] + } + } + """ +} diff --git a/Dayflow/Dayflow/Core/Analysis/TimeParsing.swift b/Dayflow/Dayflow/Core/Analysis/TimeParsing.swift index ab9a357d..d9a701f9 100644 --- a/Dayflow/Dayflow/Core/Analysis/TimeParsing.swift +++ b/Dayflow/Dayflow/Core/Analysis/TimeParsing.swift @@ -33,3 +33,313 @@ func parseTimeHMMA(timeString: String) -> Int? { return nil } + +// MARK: - LLM video/timeline helpers + +/// Shared utilities for parsing timestamps emitted by LLMs during video/timelapse transcription. +/// +/// NOTE: These helpers are intentionally lightweight and avoid shared DateFormatter instances +/// because DateFormatter is not thread-safe. +enum LLMVideoTimestampUtilities { + /// Parses either `HH:MM:SS` or `MM:SS` into total seconds. + /// Returns `0` for invalid input. + static func parseVideoTimestamp(_ timestamp: String) -> Int { + let parts = + timestamp + .trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: ":") + + if parts.count == 3 { + let h = Int(parts[0]) ?? 0 + let m = Int(parts[1]) ?? 0 + let s = Int(parts[2]) ?? 0 + return h * 3600 + m * 60 + s + } + if parts.count == 2 { + let m = Int(parts[0]) ?? 0 + let s = Int(parts[1]) ?? 0 + return m * 60 + s + } + return 0 + } + + static func formatSecondsHHMMSS(_ seconds: TimeInterval) -> String { + let s = Int(seconds.rounded()) + let h = s / 3600 + let m = (s % 3600) / 60 + let sec = s % 60 + return String(format: "%02d:%02d:%02d", h, m, sec) + } +} + +/// Shared validation utilities for ensuring timeline cards fully cover time ranges and +/// don't violate minimum duration constraints. +enum LLMTimelineCardValidation { + struct TimeRange { + let start: Double + let end: Double + } + + static func formatTimestampForPrompt(_ unixTime: Int) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(unixTime)) + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + return formatter.string(from: date) + } + + static func timeToMinutes(_ timeStr: String) -> Double { + let trimmed = timeStr.trimmingCharacters(in: .whitespacesAndNewlines) + if let minutes = parseTimeHMMA(timeString: trimmed) { + return Double(minutes) + } + + // Fallback to MM:SS / HH:MM:SS video-style time. + let seconds = LLMVideoTimestampUtilities.parseVideoTimestamp(trimmed) + return Double(seconds) / 60.0 + } + + static func minutesToTimeString(_ minutes: Double) -> String { + let hours = (Int(minutes) / 60) % 24 + let mins = Int(minutes) % 60 + let period = hours < 12 ? "AM" : "PM" + var displayHour = hours % 12 + if displayHour == 0 { displayHour = 12 } + return String(format: "%d:%02d %@", displayHour, mins, period) + } + + private static func mergeOverlappingRanges(_ ranges: [TimeRange]) -> [TimeRange] { + guard !ranges.isEmpty else { return [] } + let sorted = ranges.sorted { $0.start < $1.start } + var merged: [TimeRange] = [] + for range in sorted { + if merged.isEmpty || range.start > merged.last!.end + 1 { + merged.append(range) + } else { + let last = merged.removeLast() + merged.append(TimeRange(start: last.start, end: max(last.end, range.end))) + } + } + return merged + } + + static func validateTimeCoverage(existingCards: [ActivityCardData], newCards: [ActivityCardData]) + -> (isValid: Bool, error: String?) + { + guard !existingCards.isEmpty else { return (true, nil) } + + var inputRanges: [TimeRange] = [] + for card in existingCards { + let startMin = timeToMinutes(card.startTime) + var endMin = timeToMinutes(card.endTime) + if endMin < startMin { endMin += 24 * 60 } + inputRanges.append(TimeRange(start: startMin, end: endMin)) + } + let mergedInputRanges = mergeOverlappingRanges(inputRanges) + + var outputRanges: [TimeRange] = [] + for card in newCards { + let startMin = timeToMinutes(card.startTime) + var endMin = timeToMinutes(card.endTime) + if endMin < startMin { endMin += 24 * 60 } + guard endMin - startMin >= 0.1 else { continue } + outputRanges.append(TimeRange(start: startMin, end: endMin)) + } + + let flexibility = 3.0 + var uncoveredSegments: [(start: Double, end: Double)] = [] + + for inputRange in mergedInputRanges { + var coveredStart = inputRange.start + var safetyCounter = 10000 + + while coveredStart < inputRange.end && safetyCounter > 0 { + safetyCounter -= 1 + var foundCoverage = false + + for outputRange in outputRanges { + if outputRange.start - flexibility <= coveredStart + && coveredStart <= outputRange.end + flexibility + { + let newCoveredStart = outputRange.end + coveredStart = max(coveredStart + 0.01, newCoveredStart) + foundCoverage = true + break + } + } + + if !foundCoverage { + var nextCovered = inputRange.end + for outputRange in outputRanges { + if outputRange.start > coveredStart && outputRange.start < nextCovered { + nextCovered = outputRange.start + } + } + if nextCovered > coveredStart { + uncoveredSegments.append((start: coveredStart, end: min(nextCovered, inputRange.end))) + coveredStart = nextCovered + } else { + uncoveredSegments.append((start: coveredStart, end: inputRange.end)) + break + } + } + } + + if safetyCounter == 0 { + return ( + false, + "Time coverage validation loop exceeded safety limit - possible infinite loop detected" + ) + } + } + + if !uncoveredSegments.isEmpty { + var uncoveredDesc: [String] = [] + for segment in uncoveredSegments { + let duration = segment.end - segment.start + if duration > flexibility { + uncoveredDesc.append( + "\(minutesToTimeString(segment.start))-\(minutesToTimeString(segment.end)) (\(Int(duration)) min)" + ) + } + } + + if !uncoveredDesc.isEmpty { + var errorMsg = + "Missing coverage for time segments: \(uncoveredDesc.joined(separator: ", "))" + errorMsg += "\n\nšŸ“„ INPUT CARDS:" + for (i, card) in existingCards.enumerated() { + errorMsg += "\n \(i+1). \(card.startTime) - \(card.endTime): \(card.title)" + } + errorMsg += "\n\nšŸ“¤ OUTPUT CARDS:" + for (i, card) in newCards.enumerated() { + errorMsg += "\n \(i+1). \(card.startTime) - \(card.endTime): \(card.title)" + } + return (false, errorMsg) + } + } + + return (true, nil) + } + + static func validateTimeline(_ cards: [ActivityCardData]) -> (isValid: Bool, error: String?) { + for (index, card) in cards.enumerated() { + let startTime = card.startTime + let endTime = card.endTime + + var durationMinutes: Double = 0 + + if let startMin = parseTimeHMMA(timeString: startTime), + let endMinRaw = parseTimeHMMA(timeString: endTime) + { + var endMin = endMinRaw + if endMin < startMin { endMin += 24 * 60 } + durationMinutes = Double(endMin - startMin) + } else { + let startSeconds = LLMVideoTimestampUtilities.parseVideoTimestamp(startTime) + let endSeconds = LLMVideoTimestampUtilities.parseVideoTimestamp(endTime) + durationMinutes = Double(endSeconds - startSeconds) / 60.0 + } + + if durationMinutes < 10 && index < cards.count - 1 { + return ( + false, + "Card \(index + 1) '\(card.title)' is only \(String(format: "%.1f", durationMinutes)) minutes long" + ) + } + } + return (true, nil) + } +} + +// MARK: - LLM transcript helpers + +/// Shared utilities for decoding model-generated transcript JSON and converting it into Dayflow observations. +/// +/// Providers differ in transport, but the transcript payload is intentionally normalized to the same `[{ startTimestamp, endTimestamp, description }]` array. +enum LLMTranscriptUtilities { + struct VideoTranscriptChunk: Codable { + let startTimestamp: String + let endTimestamp: String + let description: String + } + + struct ObservationConversionResult { + let observations: [Observation] + /// Count of chunks dropped due to timestamp validation failures. + let invalidTimestampCount: Int + } + + static func decodeTranscriptChunks(from output: String) throws -> [VideoTranscriptChunk] { + guard let data = output.data(using: .utf8) else { + throw NSError( + domain: "LLMTranscriptUtilities", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Invalid response encoding"] + ) + } + do { + return try JSONDecoder().decode([VideoTranscriptChunk].self, from: data) + } catch { + let snippet = String(output.prefix(400)) + print( + "šŸ”Ž LLM DEBUG: decodeTranscriptChunks JSON decode failed: \(error.localizedDescription) snippet=\(snippet)" + ) + throw error + } + } + + static func observations( + from chunks: [VideoTranscriptChunk], + batchStartTime: Date, + observationBatchId: Int64, + llmModel: String, + compressedVideoDuration: TimeInterval, + compressionFactor: TimeInterval, + tolerance: TimeInterval = 10.0, + debugPrintExpansion: Bool = false + ) -> ObservationConversionResult { + var invalidCount = 0 + let observations: [Observation] = chunks.compactMap { chunk in + let compressedStartSeconds = LLMVideoTimestampUtilities.parseVideoTimestamp( + chunk.startTimestamp) + let compressedEndSeconds = LLMVideoTimestampUtilities.parseVideoTimestamp(chunk.endTimestamp) + + if Double(compressedStartSeconds) < -tolerance + || Double(compressedEndSeconds) > compressedVideoDuration + tolerance + { + invalidCount += 1 + return nil + } + + let realStartSeconds = TimeInterval(compressedStartSeconds) * compressionFactor + let realEndSeconds = TimeInterval(compressedEndSeconds) * compressionFactor + + if debugPrintExpansion { + print( + "šŸ“ Timestamp expansion: \(chunk.startTimestamp)-\(chunk.endTimestamp) → \(Int(realStartSeconds))s-\(Int(realEndSeconds))s real" + ) + } + + let startDate = batchStartTime.addingTimeInterval(realStartSeconds) + let endDate = batchStartTime.addingTimeInterval(realEndSeconds) + let trimmed = chunk.description.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + return Observation( + id: nil, + batchId: observationBatchId, + startTs: Int(startDate.timeIntervalSince1970), + endTs: Int(endDate.timeIntervalSince1970), + observation: trimmed, + metadata: nil, + llmModel: llmModel, + createdAt: Date() + ) + } + + return ObservationConversionResult( + observations: observations, invalidTimestampCount: invalidCount) + } +} diff --git a/Dayflow/Dayflow/System/AnalyticsService.swift b/Dayflow/Dayflow/System/AnalyticsService.swift index 25ea04f0..acf305b0 100644 --- a/Dayflow/Dayflow/System/AnalyticsService.swift +++ b/Dayflow/Dayflow/System/AnalyticsService.swift @@ -12,6 +12,7 @@ import AppKit import Foundation +import OSLog import PostHog final class AnalyticsService { @@ -25,6 +26,11 @@ final class AnalyticsService { private let throttleLock = NSLock() private var throttles: [String: Date] = [:] + private let localLogger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "Dayflow", + category: "Analytics" + ) + var isOptedIn: Bool { get { if UserDefaults.standard.object(forKey: optInKey) == nil { @@ -71,7 +77,7 @@ final class AnalyticsService { payload["$set_once"] = ["install_ts": iso8601Now()] UserDefaults.standard.set(true, forKey: "installTsSent") } - PostHogSDK.shared.capture("person_props_updated", properties: payload) + captureToPostHogAndLocal("person_props_updated", properties: payload) } /// Returns the stable PostHog distinct ID used as backend auth identity. @@ -152,8 +158,14 @@ final class AnalyticsService { let payload: [String: Any] = ["$set": sanitize(["analytics_opt_in": enabled])] Task.detached(priority: .utility) { - PostHogSDK.shared.capture("person_props_updated", properties: payload) - PostHogSDK.shared.capture("analytics_opt_in_changed", properties: ["enabled": enabled]) + self.captureToPostHogAndLocal( + "person_props_updated", + properties: payload + ) + self.captureToPostHogAndLocal( + "analytics_opt_in_changed", + properties: ["enabled": enabled] + ) } return } @@ -186,9 +198,7 @@ final class AnalyticsService { func capture(_ name: String, _ props: [String: Any] = [:]) { guard isOptedIn else { return } let sanitized = sanitize(props) - Task.detached(priority: .utility) { - PostHogSDK.shared.capture(name, properties: sanitized) - } + captureToPostHogAndLocal(name, properties: sanitized) } func screen(_ name: String, _ props: [String: Any] = [:]) { @@ -224,11 +234,41 @@ final class AnalyticsService { func setPersonProperties(_ props: [String: Any]) { guard isOptedIn else { return } let payload: [String: Any] = ["$set": sanitize(props)] + captureToPostHogAndLocal("person_props_updated", properties: payload) + } + + // MARK: - Local + PostHog logging + + private func captureToPostHogAndLocal(_ name: String, properties: [String: Any]) { Task.detached(priority: .utility) { - PostHogSDK.shared.capture("person_props_updated", properties: payload) + self.logLocal(name, properties: properties) + PostHogSDK.shared.capture(name, properties: properties) } } + private func logLocal(_ event: String, properties: [String: Any]) { + let json = jsonString(properties) + let line = truncate("[Analytics] \(event) \(json)") + print(line) + localLogger.info("\(line, privacy: .public)") + } + + private func jsonString(_ object: Any) -> String { + if JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let str = String(data: data, encoding: .utf8) + { + return str + } + return String(describing: object) + } + + private func truncate(_ s: String, max: Int = 4000) -> String { + guard s.count > max else { return s } + let idx = s.index(s.startIndex, offsetBy: max) + return String(s[.. Void) { let now = Date() throttleLock.lock() diff --git a/Dayflow/Dayflow/Views/Onboarding/OnboardingLLMSelectionView.swift b/Dayflow/Dayflow/Views/Onboarding/OnboardingLLMSelectionView.swift index 4bc175c1..d5010553 100644 --- a/Dayflow/Dayflow/Views/Onboarding/OnboardingLLMSelectionView.swift +++ b/Dayflow/Dayflow/Views/Onboarding/OnboardingLLMSelectionView.swift @@ -35,7 +35,9 @@ struct OnboardingLLMSelectionView: View { // Card width calc (no min width, cap at 480) let availableWidth = windowWidth - (edgePadding * 2) - let rawCardWidth = (availableWidth - (cardGap * 2)) / 3 + let cardCount = max(1, providerCards.count) + let totalGaps = cardGap * CGFloat(max(0, cardCount - 1)) + let rawCardWidth = (availableWidth - totalGaps) / CGFloat(cardCount) let cardWidth = max(1, min(480, floor(rawCardWidth))) // Card height calc diff --git a/Dayflow/Dayflow/Views/Onboarding/TestConnectionView.swift b/Dayflow/Dayflow/Views/Onboarding/TestConnectionView.swift index d3e4c7ab..91192822 100644 --- a/Dayflow/Dayflow/Views/Onboarding/TestConnectionView.swift +++ b/Dayflow/Dayflow/Views/Onboarding/TestConnectionView.swift @@ -2,17 +2,20 @@ // TestConnectionView.swift // Dayflow // -// Test connection button for Gemini API +// Test connection button for supported API providers // +import Foundation import SwiftUI struct TestConnectionView: View { + let provider: LLMProviderID let onTestComplete: ((Bool) -> Void)? @State private var isTesting = false @State private var testResult: TestResult? - init(onTestComplete: ((Bool) -> Void)? = nil) { + init(provider: LLMProviderID = .gemini, onTestComplete: ((Bool) -> Void)? = nil) { + self.provider = provider self.onTestComplete = onTestComplete } @@ -118,37 +121,68 @@ struct TestConnectionView: View { private func testConnection() { guard !isTesting else { return } - // Get API key from keychain - guard let apiKey = KeychainManager.shared.retrieve(for: "gemini") else { - testResult = .failure("No API key found. Please enter your API key first.") + let analyticsProvider = provider.analyticsName + + func finishFailure(_ message: String, errorCode: String? = nil) { + testResult = .failure(message) onTestComplete?(false) - AnalyticsService.shared.capture( - "connection_test_failed", ["provider": "gemini", "error_code": "no_api_key"]) + var props: [String: Any] = ["provider": analyticsProvider] + if let errorCode { + props["error_code"] = errorCode + } + AnalyticsService.shared.capture("connection_test_failed", props) + } + + func finishSuccess(_ message: String) { + testResult = .success(message) + isTesting = false + onTestComplete?(true) + AnalyticsService.shared.capture("connection_test_succeeded", ["provider": analyticsProvider]) + } + + // Get API key from keychain + let keychainKey: String + switch provider { + case .gemini: + keychainKey = "gemini" + default: + finishFailure( + "This provider doesn't support connection tests yet.", errorCode: "unsupported_provider") + return + } + + guard let apiKey = KeychainManager.shared.retrieve(for: keychainKey), + !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + finishFailure("No API key found. Please enter your API key first.", errorCode: "no_api_key") return } isTesting = true testResult = nil - AnalyticsService.shared.capture("connection_test_started", ["provider": "gemini"]) + AnalyticsService.shared.capture("connection_test_started", ["provider": analyticsProvider]) Task { do { - let _ = try await GeminiAPIHelper.shared.testConnection(apiKey: apiKey) - await MainActor.run { - testResult = .success("Connection successful! Your API key is working.") - isTesting = false - onTestComplete?(true) + switch provider { + case .gemini: + let _ = try await GeminiAPIHelper.shared.testConnection(apiKey: apiKey) + await MainActor.run { + finishSuccess("Connection successful! Your API key is working.") + } + default: + await MainActor.run { + isTesting = false + finishFailure( + "This provider doesn't support connection tests yet.", + errorCode: "unsupported_provider") + } } - AnalyticsService.shared.capture("connection_test_succeeded", ["provider": "gemini"]) } catch { await MainActor.run { - testResult = .failure(error.localizedDescription) isTesting = false - onTestComplete?(false) + finishFailure(error.localizedDescription, errorCode: String((error as NSError).code)) } - AnalyticsService.shared.capture( - "connection_test_failed", - ["provider": "gemini", "error_code": String((error as NSError).code)]) } } } diff --git a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift index 1e200b3e..d265bf3f 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/ProvidersSettingsViewModel.swift @@ -768,7 +768,7 @@ final class ProvidersSettingsViewModel: ObservableObject { func loadGeminiPromptOverridesIfNeeded(force: Bool = false) { if geminiPromptOverridesLoaded && !force { return } isUpdatingGeminiPromptState = true - let overrides = GeminiPromptPreferences.load() + let overrides = VideoPromptPreferences.load() let trimmedTitle = overrides.titleBlock?.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedSummary = overrides.summaryBlock?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -792,7 +792,7 @@ final class ProvidersSettingsViewModel: ObservableObject { } func persistGeminiPromptOverrides() { - let overrides = GeminiPromptOverrides( + let overrides = VideoPromptOverrides( titleBlock: normalizedOverride( text: geminiTitlePromptText, enabled: useCustomGeminiTitlePrompt), summaryBlock: normalizedOverride( @@ -802,9 +802,9 @@ final class ProvidersSettingsViewModel: ObservableObject { ) if overrides.isEmpty { - GeminiPromptPreferences.reset() + VideoPromptPreferences.reset() } else { - GeminiPromptPreferences.save(overrides) + VideoPromptPreferences.save(overrides) } } @@ -816,7 +816,7 @@ final class ProvidersSettingsViewModel: ObservableObject { geminiTitlePromptText = GeminiPromptDefaults.titleBlock geminiSummaryPromptText = GeminiPromptDefaults.summaryBlock geminiDetailedPromptText = GeminiPromptDefaults.detailedSummaryBlock - GeminiPromptPreferences.reset() + VideoPromptPreferences.reset() isUpdatingGeminiPromptState = false geminiPromptOverridesLoaded = true } diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift index 0d8d0096..8df55140 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsProvidersTabView.swift @@ -92,7 +92,7 @@ struct SettingsProvidersTabView: View { switch viewModel.currentProvider { case "gemini": - TestConnectionView(onTestComplete: { _ in }) + TestConnectionView(provider: .gemini, onTestComplete: { _ in }) case "ollama": LocalLLMTestView( baseURL: $viewModel.localBaseURL,