From 9493912f09f5917c6b7da33876b7438830d470b7 Mon Sep 17 00:00:00 2001 From: caezium <113233555+caezium@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:22:12 -0700 Subject: [PATCH] feat(timeline): 'How Dayflow figured this out' transparency panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces how each activity card was produced, inline in the card detail (collapsed by default): - the model's reasoning for grouping/categorizing the session - the on-screen observations it wrote, each with a downsampled screenshot from that moment, so you can see what it actually saw - which model/provider generated the card - an optional raw LLM-call view (operation, status, latency, response) Reasoning is persisted in the timeline_cards metadata JSON at save time (no schema migration — it rides the existing envelope alongside appSites), and falls back to parsing the logged llm_calls for older cards. Everything else reads already-persisted observations + llm_calls by batchId. Includes unit tests for the reasoning extraction (bare/fenced JSON and OpenAI/Gemini envelope shapes). --- Dayflow/Dayflow/Core/AI/LLMService.swift | 8 +- .../Dayflow/Core/Analysis/CardInsight.swift | 216 +++++++++++++++ .../StorageManager+TimelineCards.swift | 29 +- .../Core/Recording/StorageModels.swift | 20 +- .../Views/UI/MainView/ActivityCard.swift | 15 ++ .../UI/MainView/CardInsightSection.swift | 250 ++++++++++++++++++ .../CardInsightParsingTests.swift | 53 ++++ 7 files changed, 587 insertions(+), 4 deletions(-) create mode 100644 Dayflow/Dayflow/Core/Analysis/CardInsight.swift create mode 100644 Dayflow/Dayflow/Views/UI/MainView/CardInsightSection.swift create mode 100644 Dayflow/DayflowTests/CardInsightParsingTests.swift diff --git a/Dayflow/Dayflow/Core/AI/LLMService.swift b/Dayflow/Dayflow/Core/AI/LLMService.swift index b40744c25..5e919e7f7 100644 --- a/Dayflow/Dayflow/Core/AI/LLMService.swift +++ b/Dayflow/Dayflow/Core/AI/LLMService.swift @@ -789,7 +789,10 @@ final class LLMService: LLMServicing { let usedGemmaForCardGeneration = activeContext.fallbackState?.usedGemmaForCardGeneration == true let isBackupGenerated = usedProviderBackup || usedGemmaForCardGeneration - // Note: card generation log is not persisted per-batch yet + // Persist the model's card-generation reasoning so the timeline can show + // "how Dayflow figured this out" without re-parsing logs (which may be pruned). + let cardReasoning = CardInsight.extractReasoning( + fromModelResponse: cardsResult.value.log.output) // Replace old cards with new ones in the time range let (insertedCardIds, deletedVideoPaths) = StorageManager.shared @@ -807,7 +810,8 @@ final class LLMService: LLMServicing { detailedSummary: card.detailedSummary, distractions: card.distractions, appSites: card.appSites, - isBackupGenerated: isBackupGenerated ? true : nil + isBackupGenerated: isBackupGenerated ? true : nil, + reasoning: cardReasoning ) }, batchId: batchId diff --git a/Dayflow/Dayflow/Core/Analysis/CardInsight.swift b/Dayflow/Dayflow/Core/Analysis/CardInsight.swift new file mode 100644 index 000000000..35250af20 --- /dev/null +++ b/Dayflow/Dayflow/Core/Analysis/CardInsight.swift @@ -0,0 +1,216 @@ +// +// CardInsight.swift +// Dayflow +// +// Surfaces *how* a timeline card was produced — the on-screen observations the +// model wrote, the reasoning it gave for grouping/labeling, which model ran, and +// (for the curious) the raw LLM calls. All of this is already persisted per batch +// (`observations` + `llm_calls`); this just reads it back and shapes it for the UI +// so users can see the model's thinking and trust the result. Reasoning is parsed +// best-effort from the logged responses, so it works retroactively on old cards +// without a schema migration; observations and raw calls are always available. +// + +import Foundation + +struct CardInsight: Sendable { + struct Observation: Identifiable, Sendable { + let id = UUID() + let time: String + let text: String + let screenshotPath: String? + } + + struct ReasoningStep: Identifiable, Sendable { + let id = UUID() + let label: String + let text: String + } + + struct RawCall: Identifiable, Sendable { + let id = UUID() + let operation: String + let model: String? + let provider: String + let status: String + let latencyMs: Int? + let httpStatus: Int? + let responseBody: String? + } + + let observations: [Observation] + let reasoningSteps: [ReasoningStep] + let model: String? + let provider: String? + let rawCalls: [RawCall] + + var isEmpty: Bool { + observations.isEmpty && reasoningSteps.isEmpty && rawCalls.isEmpty + } +} + +extension CardInsight { + /// Loads everything captured for the card's source batch. Synchronous DB reads — + /// call from a background task (see ActivityCard.loadInsight). + static func load(forBatchId batchId: Int64) -> CardInsight { + let store = StorageManager.shared + let rawObs = store.fetchObservations(batchId: batchId) + let calls = store.fetchLLMCallsForBatches(batchIds: [batchId], limit: 200) + + // Observations: the model's plain-language notes on what was on screen, each + // paired with a representative screenshot from its time window for a visual. + let timeFmt = DateFormatter() + timeFmt.dateFormat = "h:mm a" + let sortedObs = rawObs.sorted { $0.startTs < $1.startTs } + let shots: [(ts: Int, path: String)] = { + guard let lo = sortedObs.first?.startTs, let hi = sortedObs.last?.endTs else { return [] } + return store.fetchScreenshotsInTimeRange(startTs: lo, endTs: hi) + .filter { !$0.isDeleted } + .map { ($0.capturedAt, $0.filePath) } + }() + func nearestShot(start: Int, end: Int) -> String? { + let within = shots.filter { $0.ts >= start && $0.ts <= end } + let pool = within.isEmpty ? shots : within + return pool.min { abs($0.ts - start) < abs($1.ts - start) }?.path + } + let observations: [Observation] = + sortedObs.compactMap { obs in + let text = obs.observation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + let when = timeFmt.string(from: Date(timeIntervalSince1970: TimeInterval(obs.startTs))) + return Observation( + time: when, text: text, screenshotPath: nearestShot(start: obs.startTs, end: obs.endTs)) + } + + // Reasoning: prefer the reasoning persisted on the card at creation time (robust, + // survives log pruning); fall back to parsing the logged llm_calls for older cards. + var steps: [(order: Int, step: ReasoningStep)] = [] + let persistedReasoning = store.fetchCardReasoning(batchId: batchId) + if let persistedReasoning, !persistedReasoning.isEmpty { + steps.append((0, ReasoningStep(label: label(for: "generate_cards"), text: persistedReasoning))) + } + for call in calls where call.status == "success" { + guard let priority = reasoningPriority(for: call.operation) else { continue } + // Card-generation reasoning is already covered by the persisted value above. + if persistedReasoning != nil && priority == 0 { continue } + guard let assistant = assistantText(from: call.responseBody), + let reasoning = reasoningField(from: assistant) + else { continue } + steps.append((priority, ReasoningStep(label: label(for: call.operation), text: reasoning))) + } + // Stable, logical order (group → summary → title → merge); dedupe repeats. + var seenLabels = Set() + let reasoningSteps = + steps + .sorted { $0.order < $1.order } + .map { $0.step } + .filter { seenLabels.insert($0.label).inserted } + + // Which model/provider actually ran (prefer a card-generating step). + let primary = + calls.first { $0.operation == "generate_summary" && $0.model != nil } + ?? calls.first { $0.model != nil } + let model = primary?.model + let provider = primary.map { friendlyProvider($0.provider) } + + let rawCalls = calls.map { + RawCall( + operation: $0.operation, model: $0.model, provider: friendlyProvider($0.provider), + status: $0.status, latencyMs: $0.latencyMs, httpStatus: $0.httpStatus, + responseBody: $0.responseBody) + } + + return CardInsight( + observations: observations, reasoningSteps: reasoningSteps, + model: model, provider: provider, rawCalls: rawCalls) + } + + // MARK: - Parsing helpers + + /// Best-effort extraction of the model's `reasoning` field from a raw card-generation + /// response. Handles a bare JSON object, a fenced one, or an OpenAI/Gemini envelope. + /// Used at card-save time to persist the reasoning alongside the card. + static func extractReasoning(fromModelResponse raw: String?) -> String? { + guard let raw, !raw.isEmpty else { return nil } + if let direct = reasoningField(from: raw) { return direct } + if let assistant = assistantText(from: raw), let nested = reasoningField(from: assistant) { + return nested + } + return nil + } + + /// Pulls the assistant's text out of a logged response body (OpenAI-compatible + /// or Gemini shapes). Returns nil if it doesn't match either. + private static func assistantText(from responseBody: String?) -> String? { + guard let body = responseBody, let data = body.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + if let choices = json["choices"] as? [[String: Any]], + let message = choices.first?["message"] as? [String: Any], + let content = message["content"] as? String + { + return content + } + if let candidates = json["candidates"] as? [[String: Any]], + let content = candidates.first?["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]] + { + let joined = parts.compactMap { $0["text"] as? String }.joined(separator: "\n") + return joined.isEmpty ? nil : joined + } + return nil + } + + /// The assistant text is usually a JSON object containing a `reasoning` string. + private static func reasoningField(from assistant: String) -> String? { + let cleaned = stripCodeFence(assistant) + guard let data = cleaned.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let reasoning = json["reasoning"] as? String + else { return nil } + let trimmed = reasoning.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private static func stripCodeFence(_ s: String) -> String { + var t = s.trimmingCharacters(in: .whitespacesAndNewlines) + if t.hasPrefix("```") { + if let firstNewline = t.firstIndex(of: "\n") { t = String(t[t.index(after: firstNewline)...]) } + if let fenceEnd = t.range(of: "```", options: .backwards) { t = String(t[.. Int? { + switch operation { + case "segment_video_activity", "generate_activity_cards", "generate_cards": return 0 + case "generate_summary": return 1 + case "generate_title": return 2 + case "evaluate_card_merge", "merge_cards": return 3 + default: return nil + } + } + + private static func label(for operation: String) -> String { + switch operation { + case "segment_video_activity", "generate_activity_cards", "generate_cards": + return "Grouped the session into activities" + case "generate_summary": return "Chose the category & wrote the summary" + case "generate_title": return "Picked the title" + case "evaluate_card_merge", "merge_cards": return "Decided whether to merge cards" + default: return operation.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + private static func friendlyProvider(_ raw: String) -> String { + switch raw.lowercased() { + case "ollama": return "Local (Ollama)" + case "lmstudio": return "Local (LM Studio)" + case "custom": return "Custom endpoint" + case "geminidirect", "gemini": return "Gemini" + case "dayflowbackend", "dayflow": return "Dayflow cloud" + default: return raw + } + } +} diff --git a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift index 63b708ece..9ee0881b2 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageManager+TimelineCards.swift @@ -861,7 +861,8 @@ extension StorageManager { distractions: card.distractions, appSites: card.appSites, isBackupGenerated: card.isBackupGenerated, - idle: card.idleMetadata + idle: card.idleMetadata, + reasoning: card.reasoning ) let metadataString: String? = (try? encoder.encode(meta)).flatMap { String(data: $0, encoding: .utf8) @@ -940,6 +941,32 @@ extension StorageManager { return (insertedIds, videoPaths) } + /// The persisted card-generation reasoning for a batch, if any was stored + /// (newer cards only). Read from the metadata JSON envelope. + func fetchCardReasoning(batchId: Int64) -> String? { + (try? timedRead("fetchCardReasoning(batchId)") { db in + let rows = try Row.fetchAll( + db, + sql: """ + SELECT metadata FROM timeline_cards + WHERE batch_id = ? AND is_deleted = 0 + ORDER BY start_ts ASC + """, + arguments: [batchId]) + let decoder = JSONDecoder() + for row in rows { + guard let metadataString: String = row["metadata"], + let data = metadataString.data(using: .utf8), + let meta = try? decoder.decode(TimelineMetadata.self, from: data), + let reasoning = meta.reasoning?.trimmingCharacters(in: .whitespacesAndNewlines), + !reasoning.isEmpty + else { continue } + return reasoning + } + return nil + }) ?? nil + } + // Note: Transcript storage methods removed in favor of Observations table } diff --git a/Dayflow/Dayflow/Core/Recording/StorageModels.swift b/Dayflow/Dayflow/Core/Recording/StorageModels.swift index 6a8b6deac..4190f72b9 100644 --- a/Dayflow/Dayflow/Core/Recording/StorageModels.swift +++ b/Dayflow/Dayflow/Core/Recording/StorageModels.swift @@ -234,6 +234,7 @@ struct TimelineCardShell: Sendable { let appSites: AppSites? let isBackupGenerated: Bool? let idleMetadata: IdleCardMetadata? + let reasoning: String? // The model's explanation for how this card was produced // No videoSummaryURL here, as it's added later // No batchId here, as it's passed as a separate parameter to the save function @@ -248,7 +249,8 @@ struct TimelineCardShell: Sendable { distractions: [Distraction]?, appSites: AppSites?, isBackupGenerated: Bool? = nil, - idleMetadata: IdleCardMetadata? = nil + idleMetadata: IdleCardMetadata? = nil, + reasoning: String? = nil ) { self.startTimestamp = startTimestamp self.endTimestamp = endTimestamp @@ -261,6 +263,7 @@ struct TimelineCardShell: Sendable { self.appSites = appSites self.isBackupGenerated = isBackupGenerated self.idleMetadata = idleMetadata + self.reasoning = reasoning } } @@ -285,6 +288,21 @@ struct TimelineMetadata: Codable { let appSites: AppSites? let isBackupGenerated: Bool? let idle: IdleCardMetadata? + let reasoning: String? // Model's explanation for the card; nil for older/onboarding cards + + init( + distractions: [Distraction]?, + appSites: AppSites?, + isBackupGenerated: Bool?, + idle: IdleCardMetadata?, + reasoning: String? = nil + ) { + self.distractions = distractions + self.appSites = appSites + self.isBackupGenerated = isBackupGenerated + self.idle = idle + self.reasoning = reasoning + } } struct AnalysisBatchDebugEntry: Sendable { diff --git a/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift b/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift index 3f01554c4..5a5714105 100644 --- a/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift +++ b/Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift @@ -29,6 +29,7 @@ struct ActivityCard: View { @State private var slideshowTitle: String? @State private var slideshowStartTime: Date? @State private var slideshowEndTime: Date? + @State private var cardInsight: CardInsight? private let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -43,6 +44,7 @@ struct ActivityCard: View { .padding(16) .allowsHitTesting(!showCategoryPicker) .id(activity.id) + .task(id: activity.id) { await loadInsight(for: activity) } .transition( .blurReplace.animation( .easeOut(duration: 0.2) @@ -322,7 +324,20 @@ struct ActivityCard: View { .textSelection(.enabled) } } + + if let insight = cardInsight, !insight.isEmpty { + CardInsightSection(insight: insight) + } + } + } + + private func loadInsight(for activity: TimelineActivity) async { + guard let batchId = activity.batchId else { + await MainActor.run { cardInsight = nil } + return } + let insight = await Task.detached { CardInsight.load(forBatchId: batchId) }.value + await MainActor.run { cardInsight = insight } } private func renderMarkdownText(_ content: String) -> Text { diff --git a/Dayflow/Dayflow/Views/UI/MainView/CardInsightSection.swift b/Dayflow/Dayflow/Views/UI/MainView/CardInsightSection.swift new file mode 100644 index 000000000..ef441b966 --- /dev/null +++ b/Dayflow/Dayflow/Views/UI/MainView/CardInsightSection.swift @@ -0,0 +1,250 @@ +// +// CardInsightSection.swift +// Dayflow +// +// The "How Dayflow figured this out" panel shown inside an activity card's detail +// view. Collapsed by default to keep the card clean; expands to show the model's +// reasoning, the on-screen observations it wrote, which model ran, and an optional +// raw LLM-call view for power users. See CardInsight for the data. +// + +import ImageIO +import SwiftUI + +struct CardInsightSection: View { + let insight: CardInsight + + @State private var expanded = false + @State private var showRaw = false + + private let labelColor = Color(red: 0.55, green: 0.55, blue: 0.55) + private let bodyColor = Color(red: 0.15, green: 0.15, blue: 0.15) + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + + if expanded { + if !insight.reasoningSteps.isEmpty { + VStack(alignment: .leading, spacing: 10) { + ForEach(insight.reasoningSteps) { step in + VStack(alignment: .leading, spacing: 2) { + Text(step.label.uppercased()) + .font(Font.custom("Figtree", size: 10).weight(.semibold)) + .foregroundColor(labelColor) + Text(step.text) + .font(Font.custom("Figtree", size: 12)) + .foregroundColor(bodyColor) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + } + } + + if !insight.observations.isEmpty { + observationsView + } + + footer + + if !insight.rawCalls.isEmpty { + rawToggle + if showRaw { + VStack(alignment: .leading, spacing: 6) { + ForEach(insight.rawCalls) { call in + RawCallRow(call: call) + } + } + } + } + } + } + .padding(.top, 2) + } + + private var header: some View { + Button { + withAnimation(.easeOut(duration: 0.18)) { expanded.toggle() } + } label: { + HStack(spacing: 6) { + Image(systemName: "sparkles") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(labelColor) + Text("HOW DAYFLOW FIGURED THIS OUT") + .font(Font.custom("Figtree", size: 12).weight(.semibold)) + .foregroundColor(labelColor) + Spacer(minLength: 6) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(labelColor) + .rotationEffect(.degrees(expanded ? 0 : -90)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointingHandCursor() + } + + private var observationsView: some View { + VStack(alignment: .leading, spacing: 8) { + Text("WHAT IT SAW ON SCREEN") + .font(Font.custom("Figtree", size: 10).weight(.semibold)) + .foregroundColor(labelColor) + ForEach(insight.observations) { obs in + HStack(alignment: .top, spacing: 9) { + if let path = obs.screenshotPath { + ObservationThumbnail(path: path) + } + VStack(alignment: .leading, spacing: 2) { + Text(obs.time) + .font(Font.custom("Figtree", size: 11).weight(.medium).monospacedDigit()) + .foregroundColor(labelColor) + Text(obs.text) + .font(Font.custom("Figtree", size: 12)) + .foregroundColor(bodyColor) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + } + } + } + } + + @ViewBuilder + private var footer: some View { + if insight.model != nil || insight.provider != nil { + let parts = [insight.model, insight.provider].compactMap { $0 } + HStack(spacing: 5) { + Image(systemName: "cpu") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(labelColor) + Text("Generated by \(parts.joined(separator: " · "))") + .font(Font.custom("Figtree", size: 11)) + .foregroundColor(labelColor) + } + } + } + + private var rawToggle: some View { + Button { + withAnimation(.easeOut(duration: 0.15)) { showRaw.toggle() } + } label: { + HStack(spacing: 5) { + Image(systemName: showRaw ? "chevron.down" : "chevron.right") + .font(.system(size: 8, weight: .semibold)) + Text(showRaw ? "Hide raw LLM calls" : "Show raw LLM calls (\(insight.rawCalls.count))") + .font(Font.custom("Figtree", size: 11).weight(.medium)) + } + .foregroundColor(labelColor) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointingHandCursor() + } +} + +/// One raw LLM call: a tappable header (operation · status · latency) that expands +/// to show the response body. Request bodies aren't shown — image payloads aren't +/// logged, and the response is what carries the model's actual output. +private struct RawCallRow: View { + let call: CardInsight.RawCall + @State private var open = false + + private let labelColor = Color(red: 0.55, green: 0.55, blue: 0.55) + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + withAnimation(.easeOut(duration: 0.12)) { open.toggle() } + } label: { + HStack(spacing: 6) { + Circle() + .fill(call.status == "success" ? Color.green.opacity(0.7) : Color.red.opacity(0.7)) + .frame(width: 6, height: 6) + Text(call.operation) + .font(Font.custom("Figtree", size: 11).weight(.semibold).monospaced()) + .foregroundColor(Color(red: 0.25, green: 0.25, blue: 0.25)) + Spacer(minLength: 6) + if let ms = call.latencyMs { + Text("\(ms) ms") + .font(Font.custom("Figtree", size: 10).monospacedDigit()) + .foregroundColor(labelColor) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointingHandCursor() + + if open, let body = displayBody { + ScrollView { + Text(body) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundColor(Color(red: 0.2, green: 0.2, blue: 0.2)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(8) + } + .frame(maxHeight: 220) + .background(Color.black.opacity(0.035)) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + } + } + + private var displayBody: String? { + guard let raw = call.responseBody?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty + else { return call.status == "success" ? nil : "(no response body)" } + let limit = 4000 + return raw.count > limit ? String(raw.prefix(limit)) + "\n… (truncated)" : raw + } +} + +/// A small downsampled screenshot for an observation row. Loads off the main +/// thread via ImageIO so the detail panel stays smooth even with many frames. +private struct ObservationThumbnail: View { + let path: String + @State private var image: NSImage? + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.black.opacity(0.05)) + if let image { + Image(nsImage: image) + .resizable() + .scaledToFill() + } + } + .frame(width: 54, height: 34) + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder(Color.black.opacity(0.08), lineWidth: 0.5) + ) + .task(id: path) { + let p = path + let loaded = await Task.detached(priority: .utility) { + ObservationThumbnail.downsample(path: p, maxPixel: 160) + }.value + await MainActor.run { image = loaded } + } + } + + static func downsample(path: String, maxPixel: CGFloat) -> NSImage? { + let url = URL(fileURLWithPath: path) + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixel, + ] + guard let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return nil + } + return NSImage(cgImage: cg, size: NSSize(width: cg.width, height: cg.height)) + } +} diff --git a/Dayflow/DayflowTests/CardInsightParsingTests.swift b/Dayflow/DayflowTests/CardInsightParsingTests.swift new file mode 100644 index 000000000..83501d785 --- /dev/null +++ b/Dayflow/DayflowTests/CardInsightParsingTests.swift @@ -0,0 +1,53 @@ +// +// CardInsightParsingTests.swift +// DayflowTests +// +// Best-effort extraction of the model's `reasoning` field from a raw +// card-generation response — bare JSON, code-fenced JSON, and the OpenAI / +// Gemini envelope shapes — plus graceful nils for junk. +// + +import XCTest + +@testable import Dayflow + +@MainActor +final class CardInsightParsingTests: XCTestCase { + + private func reasoning(_ raw: String?) -> String? { + CardInsight.extractReasoning(fromModelResponse: raw) + } + + func testExtractsFromBareJSONObject() { + XCTAssertEqual(reasoning(#"{"reasoning": "grouped by app"}"#), "grouped by app") + } + + func testExtractsFromCodeFencedJSON() { + let raw = "```json\n{\"reasoning\": \"fenced value\"}\n```" + XCTAssertEqual(reasoning(raw), "fenced value") + } + + func testExtractsFromOpenAIEnvelope() { + let raw = #"{"choices":[{"message":{"content":"{\"reasoning\":\"from openai\"}"}}]}"# + XCTAssertEqual(reasoning(raw), "from openai") + } + + func testExtractsFromGeminiEnvelope() { + let raw = #"{"candidates":[{"content":{"parts":[{"text":"{\"reasoning\":\"from gemini\"}"}]}}]}"# + XCTAssertEqual(reasoning(raw), "from gemini") + } + + func testReturnsNilForNilOrEmpty() { + XCTAssertNil(reasoning(nil)) + XCTAssertNil(reasoning("")) + } + + func testReturnsNilWhenNoReasoningField() { + XCTAssertNil(reasoning(#"{"title": "Something"}"#)) + XCTAssertNil(reasoning("not json at all")) + } + + func testReturnsNilForWhitespaceOnlyReasoning() { + XCTAssertNil(reasoning(#"{"reasoning": " "}"#)) + } +}