Skip to content

Commit 6835cdc

Browse files
joceqoclaude
andcommitted
feat(ai): per-card re-analyze menu with provider override
Lets users re-run LLM analysis on a single card using any configured provider (primary/secondary/tertiary), independent of the app's default. Used as an escape hatch when the primary output is wrong or the user wants to compare providers on the same source batch. Changes: - LLMService gets a new processBatch overload that accepts providerOverride + chatToolOverride. When an override is set, the configured-backup chain is skipped (caller's choice wins). - AnalysisManager gets a reanalyzeCard(_:providerOverride:...) method that looks up the card's batch, validates screenshots still exist, resets observations + batch status, then re-runs processBatch with the chosen provider. - New ActivityCardReanalyzeMenu + ReanalyzePickerOverlay SwiftUI views provide the UI: a sparkles button on each card opens an overlay listing the user's configured primary/secondary/tertiary providers (each with appropriate subtitle — local model ID, CLI tool, etc.) - StorageManager gets batchIdForTimelineCard(_:) helper. - LLMProviderRoutingPreferences extended with tertiary preferences. Based on feat/apfel-provider so the .apfel option works out of the box. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent f468636 commit 6835cdc

6 files changed

Lines changed: 476 additions & 5 deletions

File tree

Dayflow/Dayflow/Core/AI/LLMService.swift

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ protocol LLMServicing {
2323
func processBatch(
2424
_ batchId: Int64, progressHandler: ((LLMProcessingStep) -> Void)?,
2525
completion: @escaping (Result<ProcessedBatchResult, Error>) -> Void)
26+
/// Process a batch with an explicit provider override (bypasses user's configured primary/backup).
27+
/// Used by per-card retry so the user can pick "re-analyze with X".
28+
func processBatch(
29+
_ batchId: Int64,
30+
providerOverride: LLMProviderID?,
31+
chatToolOverride: ChatCLITool?,
32+
progressHandler: ((LLMProcessingStep) -> Void)?,
33+
completion: @escaping (Result<ProcessedBatchResult, Error>) -> Void)
2634
func generateText(prompt: String) async throws -> String
2735
func generateTextStreaming(prompt: String) -> AsyncThrowingStream<String, Error>
2836
/// Rich chat streaming with thinking, tool calls, and text events.
@@ -591,6 +599,22 @@ final class LLMService: LLMServicing {
591599
func processBatch(
592600
_ batchId: Int64, progressHandler: ((LLMProcessingStep) -> Void)? = nil,
593601
completion: @escaping (Result<ProcessedBatchResult, Error>) -> Void
602+
) {
603+
processBatch(
604+
batchId,
605+
providerOverride: nil,
606+
chatToolOverride: nil,
607+
progressHandler: progressHandler,
608+
completion: completion
609+
)
610+
}
611+
612+
func processBatch(
613+
_ batchId: Int64,
614+
providerOverride: LLMProviderID?,
615+
chatToolOverride: ChatCLITool?,
616+
progressHandler: ((LLMProcessingStep) -> Void)?,
617+
completion: @escaping (Result<ProcessedBatchResult, Error>) -> Void
594618
) {
595619
Task {
596620
// Get batch info first (outside do-catch so it's available in catch block)
@@ -606,9 +630,16 @@ final class LLMService: LLMServicing {
606630

607631
let (_, batchStartTs, batchEndTs, _) = batchInfo
608632
let processingStartTime = Date()
609-
let primaryProviderID = LLMProviderID.from(providerType)
610-
let primaryProviderLabel = providerLabel(for: primaryProviderID)
611-
let configuredBackup = configuredBackupProvider(primaryProviderID: primaryProviderID)
633+
let primaryProviderID = providerOverride ?? LLMProviderID.from(providerType)
634+
let primaryChatToolOverride = providerOverride != nil ? chatToolOverride : nil
635+
let primaryProviderLabel = providerLabel(
636+
for: primaryProviderID, chatToolOverride: primaryChatToolOverride)
637+
// When an override is used, skip the configured-backup chain: the caller
638+
// explicitly chose this provider and fallback to a different one would be surprising.
639+
let configuredBackup =
640+
providerOverride == nil
641+
? configuredBackupProvider(primaryProviderID: primaryProviderID)
642+
: nil
612643
let configuredBackupProviderName = configuredBackup?.id.analyticsName
613644
let configuredBackupProviderLabel = configuredBackup.map {
614645
providerLabel(for: $0.id, chatToolOverride: $0.chatToolOverride)
@@ -632,7 +663,8 @@ final class LLMService: LLMServicing {
632663
"llm_provider_label": primaryProviderLabel,
633664
])
634665

635-
let primaryContext = try makeTimelineProviderContext(for: primaryProviderID)
666+
let primaryContext = try makeTimelineProviderContext(
667+
for: primaryProviderID, chatToolOverride: primaryChatToolOverride)
636668
let backupContext: TimelineProviderContext? = {
637669
guard let configuredBackup else { return nil }
638670
return try? makeTimelineProviderContext(

Dayflow/Dayflow/Core/AI/LLMTypes.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,8 @@ enum LLMProviderID: String, Codable, CaseIterable {
240240
enum LLMProviderRoutingPreferences {
241241
static let backupProviderDefaultsKey = "llmBackupProviderId"
242242
static let backupChatCLIToolDefaultsKey = "llmBackupChatCLITool"
243+
static let tertiaryProviderDefaultsKey = "llmTertiaryProviderId"
244+
static let tertiaryChatCLIToolDefaultsKey = "llmTertiaryChatCLITool"
243245

244246
static func loadBackupProvider(from defaults: UserDefaults = .standard) -> LLMProviderID? {
245247
guard let rawValue = defaults.string(forKey: backupProviderDefaultsKey) else {
@@ -248,6 +250,20 @@ enum LLMProviderRoutingPreferences {
248250
return LLMProviderID(rawValue: rawValue)
249251
}
250252

253+
static func loadTertiaryProvider(from defaults: UserDefaults = .standard) -> LLMProviderID? {
254+
guard let rawValue = defaults.string(forKey: tertiaryProviderDefaultsKey) else {
255+
return nil
256+
}
257+
return LLMProviderID(rawValue: rawValue)
258+
}
259+
260+
static func loadTertiaryChatCLITool(from defaults: UserDefaults = .standard) -> ChatCLITool? {
261+
guard let rawValue = defaults.string(forKey: tertiaryChatCLIToolDefaultsKey) else {
262+
return nil
263+
}
264+
return ChatCLITool(rawValue: rawValue)
265+
}
266+
251267
static func saveBackupProvider(_ provider: LLMProviderID?, to defaults: UserDefaults = .standard)
252268
{
253269
if let provider {

Dayflow/Dayflow/Core/Analysis/AnalysisManager.swift

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ protocol AnalysisManaging {
2424
func reprocessBatch(
2525
_ batchId: Int64, stepHandler: @escaping (LLMProcessingStep) -> Void,
2626
completion: @escaping (Result<Void, Error>) -> Void)
27+
/// Re-run LLM analysis for a single card's source batch with an explicit provider override.
28+
func reanalyzeCard(
29+
_ cardId: Int64,
30+
providerOverride: LLMProviderID,
31+
chatToolOverride: ChatCLITool?,
32+
stepHandler: @escaping (LLMProcessingStep) -> Void,
33+
completion: @escaping (Result<Void, Error>) -> Void)
2734
}
2835

2936
final class AnalysisManager: AnalysisManaging {
@@ -381,6 +388,68 @@ final class AnalysisManager: AnalysisManaging {
381388
}
382389
}
383390

391+
func reanalyzeCard(
392+
_ cardId: Int64,
393+
providerOverride: LLMProviderID,
394+
chatToolOverride: ChatCLITool?,
395+
stepHandler: @escaping (LLMProcessingStep) -> Void,
396+
completion: @escaping (Result<Void, Error>) -> Void
397+
) {
398+
queue.async { [weak self] in
399+
guard let self else {
400+
DispatchQueue.main.async {
401+
completion(
402+
.failure(
403+
NSError(
404+
domain: "AnalysisManager", code: 1,
405+
userInfo: [NSLocalizedDescriptionKey: "Manager deallocated"])))
406+
}
407+
return
408+
}
409+
410+
guard let batchId = self.store.batchIdForTimelineCard(cardId) else {
411+
DispatchQueue.main.async {
412+
completion(
413+
.failure(
414+
NSError(
415+
domain: "AnalysisManager", code: 5,
416+
userInfo: [
417+
NSLocalizedDescriptionKey:
418+
"Can't re-analyze: this card has no source batch on disk."
419+
])))
420+
}
421+
return
422+
}
423+
424+
guard !self.store.screenshotsForBatch(batchId).isEmpty else {
425+
DispatchQueue.main.async {
426+
completion(
427+
.failure(
428+
NSError(
429+
domain: "AnalysisManager", code: 6,
430+
userInfo: [
431+
NSLocalizedDescriptionKey:
432+
"Can't re-analyze: screenshots for this card have been purged."
433+
])))
434+
}
435+
return
436+
}
437+
438+
self.store.deleteObservations(forBatchIds: [batchId])
439+
_ = self.store.resetBatchStatuses(forBatchIds: [batchId])
440+
441+
self.queueLLMRequest(
442+
batchId: batchId,
443+
providerOverride: providerOverride,
444+
chatToolOverride: chatToolOverride,
445+
progressHandler: stepHandler,
446+
completion: { result in
447+
DispatchQueue.main.async { completion(result) }
448+
}
449+
)
450+
}
451+
}
452+
384453
@objc private func timerFired() { triggerAnalysisNow() }
385454

386455
private func processRecordings() {
@@ -400,6 +469,8 @@ final class AnalysisManager: AnalysisManaging {
400469

401470
private func queueLLMRequest(
402471
batchId: Int64,
472+
providerOverride: LLMProviderID? = nil,
473+
chatToolOverride: ChatCLITool? = nil,
403474
progressHandler: ((LLMProcessingStep) -> Void)? = nil,
404475
completion: ((Result<Void, Error>) -> Void)? = nil
405476
) {
@@ -473,7 +544,12 @@ final class AnalysisManager: AnalysisManaging {
473544

474545
updateBatchStatus(batchId: batchId, status: "processing")
475546

476-
llmService.processBatch(batchId, progressHandler: progressHandler) {
547+
llmService.processBatch(
548+
batchId,
549+
providerOverride: providerOverride,
550+
chatToolOverride: chatToolOverride,
551+
progressHandler: progressHandler
552+
) {
477553
[weak self] (result: Result<ProcessedBatchResult, Error>) in
478554
guard let self else { return }
479555

Dayflow/Dayflow/Core/Recording/StorageManager.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ protocol StorageManaging: Sendable {
7070
func deleteTimelineCard(recordId: Int64) -> String?
7171
func fetchTimelineCards(forBatch batchId: Int64) -> [TimelineCard]
7272
func fetchTimelineCard(byId id: Int64) -> TimelineCardWithTimestamps?
73+
func batchIdForTimelineCard(_ cardId: Int64) -> Int64?
7374
func fetchLastTimelineCard(endingBefore: Date) -> TimelineCardWithTimestamps?
7475

7576
// Timeline Queries
@@ -1523,6 +1524,16 @@ final class StorageManager: StorageManaging, @unchecked Sendable {
15231524
}
15241525
}
15251526

1527+
func batchIdForTimelineCard(_ cardId: Int64) -> Int64? {
1528+
try? timedRead("batchIdForTimelineCard") { db in
1529+
try Int64.fetchOne(
1530+
db,
1531+
sql: "SELECT batch_id FROM timeline_cards WHERE id = ? AND is_deleted = 0",
1532+
arguments: [cardId]
1533+
)
1534+
} ?? nil
1535+
}
1536+
15261537
func deleteTimelineCard(recordId: Int64) -> String? {
15271538
var videoPath: String? = nil
15281539

Dayflow/Dayflow/Views/UI/MainView/ActivityCard.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ struct ActivityCard: View {
1919
false
2020

2121
@State private var showCategoryPicker = false
22+
@State private var showReanalyzePicker = false
23+
@State private var isReanalyzing = false
2224
@State private var isPreparingSlideshow = false
2325
@State private var slideshowError: String?
2426
@State private var slideshowRequestID = 0
@@ -66,12 +68,30 @@ struct ActivityCard: View {
6668
.transition(.move(edge: .top).combined(with: .opacity))
6769
.zIndex(1)
6870
}
71+
72+
if showReanalyzePicker && !isFailedCard(activity) {
73+
ReanalyzePickerOverlay(
74+
recordId: activity.recordId,
75+
onClose: {
76+
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
77+
showReanalyzePicker = false
78+
}
79+
},
80+
onRunStart: { isReanalyzing = true },
81+
onRunEnd: { _ in isReanalyzing = false },
82+
onCompleted: onRetryBatchCompleted
83+
)
84+
.transition(.move(edge: .top).combined(with: .opacity))
85+
.zIndex(2)
86+
}
6987
}
7088
.if(maxHeight != nil) { view in
7189
view.frame(maxHeight: maxHeight!)
7290
}
7391
.onChange(of: activity.id) {
7492
showCategoryPicker = false
93+
showReanalyzePicker = false
94+
isReanalyzing = false
7595
isPreparingSlideshow = false
7696
slideshowError = nil
7797
slideshowRequestID &+= 1
@@ -219,6 +239,11 @@ struct ActivityCard: View {
219239
.hoverScaleEffect(scale: 1.02)
220240
.pointingHandCursorOnHover(reassertOnPressEnd: true)
221241
.accessibilityLabel(Text("Change category"))
242+
243+
ActivityCardReanalyzeMenu(
244+
isPresented: $showReanalyzePicker,
245+
isRunning: isReanalyzing
246+
)
222247
}
223248
}
224249
}

0 commit comments

Comments
 (0)