Skip to content

Commit 7a04dc2

Browse files
execsumoclaude
andcommitted
feat(app): add None LLM provider and graceful protocol generation fallback
Add "None (Transcript Only)" option to the LLM Provider picker, allowing users to skip protocol generation entirely and save only raw transcripts. When an LLM provider (Claude CLI or OpenAI API) fails, the pipeline now saves the transcript and marks the job as done with a warning instead of failing. Transcript saving and audio copying are decoupled from protocol generation so they always succeed regardless of LLM availability. - Add `.none` case to ProtocolProvider enum - Split PipelineQueue: transcript/audio save always runs, LLM is optional - Catch protocol generation errors gracefully (warn, don't fail) - Add transcriptPath to PipelineJob for transcript-only job access - Rename Settings picker label to "LLM Provider" - Update roadmap: mark fallback as done, add re-process item Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 65698a5 commit 7a04dc2

File tree

7 files changed

+76
-41
lines changed

7 files changed

+76
-41
lines changed

app/MeetingTranscriber/Sources/AppSettings.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ enum ProtocolProvider: String, CaseIterable {
77
case claudeCLI
88
#endif
99
case openAICompatible
10+
case none
1011

1112
var label: String {
1213
switch self {
@@ -15,6 +16,7 @@ enum ProtocolProvider: String, CaseIterable {
1516
#endif
1617

1718
case .openAICompatible: "OpenAI-Compatible API"
19+
case .none: "None (Transcript Only)"
1820
}
1921
}
2022
}

app/MeetingTranscriber/Sources/MeetingTranscriberApp.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ struct MeetingTranscriberApp: App {
294294
configurePipelineCallbacks()
295295
}
296296

297-
private func makeProtocolGenerator() -> ProtocolGenerating {
297+
private func makeProtocolGenerator() -> ProtocolGenerating? {
298298
switch settings.protocolProvider {
299299
#if !APPSTORE
300300
case .claudeCLI:
@@ -315,6 +315,9 @@ struct MeetingTranscriberApp: App {
315315
language: settings.transcriptionLanguageName,
316316
customVocabulary: settings.customVocabulary,
317317
)
318+
319+
case .none:
320+
nil
318321
}
319322
}
320323

@@ -338,7 +341,8 @@ struct MeetingTranscriberApp: App {
338341
pipelineQueue.onJobStateChange = { [notifications] job, _, newState in
339342
switch newState {
340343
case .done:
341-
notifications.notify(title: "Protocol Ready", body: job.meetingTitle)
344+
let title = job.protocolPath != nil ? "Protocol Ready" : "Transcript Saved"
345+
notifications.notify(title: title, body: job.meetingTitle)
342346

343347
case .error:
344348
if let err = job.error {
@@ -388,7 +392,7 @@ struct MeetingTranscriberApp: App {
388392

389393
private func openLastProtocol() {
390394
if let job = pipelineQueue.completedJobs.last,
391-
let path = job.protocolPath {
395+
let path = job.protocolPath ?? job.transcriptPath {
392396
NSWorkspace.shared.open(path)
393397
}
394398
}

app/MeetingTranscriber/Sources/MenuBarView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ struct MenuBarView: View {
179179
jobStateLabel(job)
180180
}
181181
Spacer()
182-
if job.state == .done, let path = job.protocolPath {
182+
if job.state == .done, let path = job.protocolPath ?? job.transcriptPath {
183183
Button("Open") { onOpenProtocol(path) }
184184
.font(.caption2)
185185
}

app/MeetingTranscriber/Sources/PipelineJob.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ struct PipelineJob: Identifiable, Codable, Sendable {
3535
var state: JobState
3636
var error: String?
3737
var warnings: [String]
38+
var transcriptPath: URL?
3839
var protocolPath: URL?
3940

4041
init(
@@ -58,6 +59,7 @@ struct PipelineJob: Identifiable, Codable, Sendable {
5859
self.state = .waiting
5960
self.error = nil
6061
self.warnings = []
62+
self.transcriptPath = nil
6163
self.protocolPath = nil
6264
}
6365
}

app/MeetingTranscriber/Sources/PipelineQueue.swift

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class PipelineQueue {
1414
// Dependencies for processing
1515
let transcriptionEngine: FluidTranscriptionEngine?
1616
let diarizationFactory: (() -> DiarizationProvider)?
17-
let protocolGeneratorFactory: (() -> ProtocolGenerating)?
17+
let protocolGeneratorFactory: (() -> ProtocolGenerating?)?
1818
let outputDir: URL?
1919
let diarizeEnabled: Bool
2020
let numSpeakers: Int
@@ -96,7 +96,7 @@ class PipelineQueue {
9696
init(
9797
transcriptionEngine: FluidTranscriptionEngine,
9898
diarizationFactory: @escaping () -> DiarizationProvider,
99-
protocolGeneratorFactory: @escaping () -> ProtocolGenerating,
99+
protocolGeneratorFactory: @escaping () -> ProtocolGenerating?,
100100
outputDir: URL,
101101
logDir: URL? = nil,
102102
diarizeEnabled: Bool = false,
@@ -241,7 +241,7 @@ class PipelineQueue {
241241
isProcessing = false
242242
return
243243
}
244-
guard let transcriptionEngine, let protocolGeneratorFactory, let outputDir else {
244+
guard let transcriptionEngine, let outputDir else {
245245
logger.warning("Processing dependencies not configured — skipping")
246246
isProcessing = false
247247
return
@@ -285,23 +285,52 @@ class PipelineQueue {
285285
}
286286
}
287287

288-
// --- Protocol Generation ---
289-
updateJobState(id: job.id, to: .generatingProtocol)
290-
startElapsedTimer()
291-
let mdPath = try await generateAndSaveProtocol(
292-
job: job, finalTranscript: finalTranscript,
293-
generator: protocolGeneratorFactory(), outputDir: outputDir,
288+
// --- Save Transcript & Audio ---
289+
let protocolsDir = outputDir.appendingPathComponent("protocols")
290+
let txtPath = try ProtocolGenerator.saveTranscript(
291+
finalTranscript, title: job.meetingTitle, dir: protocolsDir,
294292
)
295-
stopElapsedTimer()
296-
297-
// Update job with protocol path and mark done
298293
if let idx = jobs.firstIndex(where: { $0.id == job.id }) {
299-
jobs[idx].protocolPath = mdPath
294+
jobs[idx].transcriptPath = txtPath
300295
}
296+
logger.info("Transcript saved: \(txtPath.lastPathComponent)")
297+
298+
let recordingsDir = outputDir.appendingPathComponent("recordings")
299+
Self.copyAudioToOutput(
300+
mixPath: job.mixPath, appPath: job.appPath, micPath: job.micPath,
301+
title: job.meetingTitle, outputDir: recordingsDir,
302+
)
303+
304+
// --- Protocol Generation (optional) ---
305+
if let generator = protocolGeneratorFactory?() {
306+
updateJobState(id: job.id, to: .generatingProtocol)
307+
startElapsedTimer()
308+
do {
309+
let mdPath = try await generateProtocol(
310+
job: job, finalTranscript: finalTranscript,
311+
generator: generator, protocolsDir: protocolsDir,
312+
)
313+
stopElapsedTimer()
314+
315+
if let idx = jobs.firstIndex(where: { $0.id == job.id }) {
316+
jobs[idx].protocolPath = mdPath
317+
}
318+
} catch is CancellationError {
319+
throw CancellationError()
320+
} catch {
321+
stopElapsedTimer()
322+
logger.warning("Protocol generation failed, transcript saved: \(error.localizedDescription)")
323+
addWarning(id: job.id, "Protocol generation failed — raw transcript saved")
324+
}
325+
} else {
326+
logger.info("No LLM provider configured — saving transcript only")
327+
}
328+
301329
updateJobState(id: job.id, to: .done)
302330
if let completed = jobs.first(where: { $0.id == job.id }), !completed.warnings.isEmpty {
331+
let title = completed.protocolPath != nil ? "Protocol Ready" : "Transcript Saved"
303332
NotificationManager.shared.notify(
304-
title: "Protocol Ready (with warnings)",
333+
title: "\(title) (with warnings)",
305334
body: completed.warnings.joined(separator: "; "),
306335
)
307336
}
@@ -582,20 +611,14 @@ class PipelineQueue {
582611
}
583612
}
584613

585-
/// Save transcript, generate protocol, save protocol, and copy audio files.
614+
/// Generate protocol via LLM and save to markdown file.
586615
/// Returns the path to the saved protocol markdown file.
587-
private func generateAndSaveProtocol(
616+
private func generateProtocol(
588617
job: PipelineJob,
589618
finalTranscript: String,
590619
generator: ProtocolGenerating,
591-
outputDir: URL,
620+
protocolsDir: URL,
592621
) async throws -> URL {
593-
let protocolsDir = outputDir.appendingPathComponent("protocols")
594-
let txtPath = try ProtocolGenerator.saveTranscript(
595-
finalTranscript, title: job.meetingTitle, dir: protocolsDir,
596-
)
597-
logger.info("Transcript saved: \(txtPath.lastPathComponent)")
598-
599622
let diarized = finalTranscript.range(of: #"\[\w[\w\s]*\]"#, options: .regularExpression) != nil
600623
let protocolMD = try await generator.generate(
601624
transcript: finalTranscript,
@@ -608,14 +631,6 @@ class PipelineQueue {
608631
fullMD, title: job.meetingTitle, dir: protocolsDir,
609632
)
610633
logger.info("Protocol saved: \(mdPath.lastPathComponent)")
611-
612-
// Move audio files to recordings subdirectory
613-
let recordingsDir = outputDir.appendingPathComponent("recordings")
614-
Self.copyAudioToOutput(
615-
mixPath: job.mixPath, appPath: job.appPath, micPath: job.micPath,
616-
title: job.meetingTitle, outputDir: recordingsDir,
617-
)
618-
619634
return mdPath
620635
}
621636

app/MeetingTranscriber/Sources/SettingsView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ struct SettingsView: View {
224224

225225
// swiftlint:disable:next closure_body_length
226226
Section("Protocol Generation") {
227-
Picker("Provider", selection: $settings.protocolProvider) {
227+
Picker("LLM Provider", selection: $settings.protocolProvider) {
228228
ForEach(ProtocolProvider.allCases, id: \.self) { provider in
229229
Text(provider.label).tag(provider)
230230
}
@@ -309,6 +309,11 @@ struct SettingsView: View {
309309
testConnection()
310310
}
311311
}
312+
313+
case .none:
314+
Text("Only the raw transcript will be saved — no LLM summarization.")
315+
.font(.caption)
316+
.foregroundStyle(.secondary)
312317
}
313318

314319
HStack {

docs/roadmap.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,17 +125,24 @@ Several pipeline failures are logged but not surfaced to the user:
125125
- Show a warning badge on completed jobs that had degraded results
126126
- Add a "warnings" field to `PipelineJob` to track what was skipped/degraded
127127

128-
### Protocol generation fallback
128+
### Protocol generation fallback — DONE
129+
130+
**Status:** Implemented
131+
132+
If the configured LLM provider (Claude CLI or OpenAI API) fails, the pipeline now saves the raw transcript and marks the job as done with a warning instead of failing the entire job. A "None (Transcript Only)" provider option is also available for users who don't want LLM summarization.
133+
134+
### Re-process transcript-only jobs with LLM
129135

130136
**Status:** Not started
131137
**Priority:** Medium
132138

133-
If the configured protocol provider (Claude CLI or OpenAI API) fails, the entire job fails with no recovery. Both providers implement the same `ProtocolGenerating` protocol.
139+
When a job completes as transcript-only (either because the LLM provider was set to "None" or because protocol generation failed), users cannot re-process it with an LLM later without manually importing the audio again.
134140

135141
**Implementation approach:**
136-
- Allow configuring a secondary/fallback provider in Settings
137-
- Wrap `protocolGeneratorFactory()` call in `PipelineQueue.processNext()` with a try/catch that attempts the fallback provider
138-
- Log which provider succeeded so the user knows
142+
- Add a "Generate Protocol" button on completed transcript-only jobs in the menu bar UI
143+
- Re-enter the pipeline at the protocol generation stage (transcript already saved)
144+
- Use the currently configured LLM provider (may differ from original run)
145+
- Need to store/locate the saved transcript path to avoid re-transcription
139146

140147
### Pipeline progress for long meetings
141148

0 commit comments

Comments
 (0)