forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLiveNotesMonitor.swift
More file actions
339 lines (285 loc) · 10.1 KB
/
Copy pathLiveNotesMonitor.swift
File metadata and controls
339 lines (285 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
import Combine
import Foundation
@preconcurrency import GRDB
protocol LiveNoteGenerating: Sendable {
func generateNote(prompt: String, systemPrompt: String) async throws -> String
}
extension GeminiClient: LiveNoteGenerating {
func generateNote(prompt: String, systemPrompt: String) async throws -> String {
try await sendTextRequest(prompt: prompt, systemPrompt: systemPrompt)
}
}
protocol LiveNoteStoring: Sendable {
func createNote(
sessionId: Int64,
text: String,
timestamp: Date,
isAiGenerated: Bool,
segmentStartOrder: Int?,
segmentEndOrder: Int?
) async throws -> LiveNoteRecord
func updateNote(id: Int64, text: String) async throws
func deleteNote(id: Int64) async throws
func getLiveNotes(sessionId: Int64) async throws -> [LiveNote]
}
extension NoteStorage: LiveNoteStoring {}
/// Dedicated monitor for live notes generation during recording sessions.
/// Accumulates transcript words and triggers AI note generation at word thresholds.
/// Only views that explicitly observe this class will update when notes change.
@MainActor
class LiveNotesMonitor: ObservableObject {
static let shared = LiveNotesMonitor()
/// Live notes for real-time display
@Published private(set) var notes: [LiveNote] = []
/// Whether AI note generation is enabled
@Published var isAiEnabled: Bool = true
/// Whether a note is currently being generated
@Published private(set) var isGenerating: Bool = false
/// Current recording session ID
private var currentSessionId: Int64?
/// Pure transcript/note policy state for deciding when AI generation should run.
private var accumulator = LiveNotesAccumulator()
/// AI note generator (lazily initialized)
private var noteGenerator: LiveNoteGenerating?
private let noteGeneratorFactory: () throws -> LiveNoteGenerating
private let noteStorage: LiveNoteStoring
/// Cancellables for subscriptions
private var cancellables = Set<AnyCancellable>()
/// AI prompt for note generation
private let noteGenerationPrompt = """
generate a single, concise note about what happened in this segment.
be factual and specific.
focus on the key point or action item.
keep it a few word sentence.
do not use quotes.
do not use wrapping words like "discussion on", jump straight into note.
avoid repeating information from existing notes.
"""
private convenience init() {
self.init(
noteGeneratorFactory: { try GeminiClient(model: ModelQoS.Gemini.lightweight, workload: .extraction) },
noteStorage: NoteStorage.shared,
subscribeToTranscript: true
)
}
init(
noteGeneratorFactory: @escaping () throws -> LiveNoteGenerating,
noteStorage: LiveNoteStoring,
subscribeToTranscript: Bool = false
) {
self.noteGeneratorFactory = noteGeneratorFactory
self.noteStorage = noteStorage
if subscribeToTranscript {
// Subscribe to transcript changes
LiveTranscriptMonitor.shared.$segments
.receive(on: DispatchQueue.main)
.sink { [weak self] segments in
self?.handleSegmentsUpdate(segments)
}
.store(in: &cancellables)
}
}
// MARK: - Session Lifecycle
/// Start a new notes session
func startSession(sessionId: Int64) {
log("LiveNotesMonitor: Starting session \(sessionId)")
currentSessionId = sessionId
notes = []
isGenerating = false
accumulator.reset()
// Initialize AI generator if not already done
if noteGenerator == nil {
do {
// Use Gemini Flash for note generation (text-only, no tool loop — Flash-safe)
noteGenerator = try noteGeneratorFactory()
log("LiveNotesMonitor: GeminiClient initialized with default model (Flash)")
} catch {
logError("LiveNotesMonitor: Failed to initialize GeminiClient", error: error)
}
}
// Load any existing notes from DB (for crash recovery)
Task {
await loadExistingNotes(for: sessionId)
}
}
/// End the current notes session
func endSession() {
log("LiveNotesMonitor: Ending session \(currentSessionId ?? -1) with \(notes.count) notes")
currentSessionId = nil
isGenerating = false
accumulator.reset()
}
/// Clear all notes (used when recording stops)
func clear() {
notes = []
isGenerating = false
accumulator.reset()
}
// MARK: - Note Operations
/// Add a manual note
func addManualNote(text: String) {
guard let sessionId = currentSessionId else {
log("LiveNotesMonitor: Cannot add note - no active session")
return
}
Task {
do {
let record = try await noteStorage.createNote(
sessionId: sessionId,
text: text,
timestamp: Date(),
isAiGenerated: false,
segmentStartOrder: accumulator.currentSegmentOrder,
segmentEndOrder: nil
)
if let note = record.toLiveNote() {
await MainActor.run {
guard self.currentSessionId == sessionId else { return }
self.notes.append(note)
self.accumulator.appendExistingNote(text)
}
}
} catch {
logError("LiveNotesMonitor: Failed to add manual note", error: error)
}
}
}
/// Update an existing note
func updateNote(id: Int64, text: String) {
Task {
do {
try await noteStorage.updateNote(id: id, text: text)
await MainActor.run {
if let index = self.notes.firstIndex(where: { $0.id == id }) {
var updatedNote = self.notes[index]
updatedNote.text = text
updatedNote.updatedAt = Date()
self.notes[index] = updatedNote
self.accumulator.seedExistingNotes(self.notes.map { $0.text })
}
}
} catch {
logError("LiveNotesMonitor: Failed to update note", error: error)
}
}
}
/// Delete a note
func deleteNote(id: Int64) {
Task {
do {
try await noteStorage.deleteNote(id: id)
await MainActor.run {
if let index = self.notes.firstIndex(where: { $0.id == id }) {
self.notes.remove(at: index)
self.accumulator.seedExistingNotes(self.notes.map { $0.text })
}
}
} catch {
logError("LiveNotesMonitor: Failed to delete note", error: error)
}
}
}
// MARK: - Private Methods
/// Load existing notes from DB (for crash recovery)
private func loadExistingNotes(for sessionId: Int64) async {
do {
let existingNotes = try await noteStorage.getLiveNotes(sessionId: sessionId)
await MainActor.run {
guard self.currentSessionId == sessionId else { return }
self.notes = existingNotes
self.accumulator.seedExistingNotes(existingNotes.map { $0.text })
}
log("LiveNotesMonitor: Loaded \(existingNotes.count) existing notes from DB")
} catch {
logError("LiveNotesMonitor: Failed to load existing notes", error: error)
}
}
/// Handle transcript segments update
func handleSegmentsUpdate(_ segments: [SpeakerSegment]) {
guard currentSessionId != nil, isAiEnabled else { return }
if let request = accumulator.handleSegmentsUpdate(segments, isGenerating: isGenerating) {
generateNote(for: request)
}
}
/// Generate an AI note from recent transcript
private func generateNote(for request: LiveNotesGenerationRequest) {
guard let sessionId = currentSessionId,
let generator = noteGenerator,
!isGenerating
else { return }
isGenerating = true
let prompt = """
Transcript segment:
\(request.recentText)
\(request.existingNotesText)
\(noteGenerationPrompt)
"""
Task {
do {
let response = try await generator.generateNote(
prompt: prompt,
systemPrompt:
"You are a concise note-taker. Generate a single short note (3-10 words) about the key point in the transcript. Do not use quotes. Be direct and specific."
)
// Clean up the response
let noteText =
response
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\"", with: "")
.replacingOccurrences(of: "'", with: "")
guard !noteText.isEmpty else {
await MainActor.run { self.finishGeneration(for: sessionId) }
return
}
// Save to DB
let record = try await noteStorage.createNote(
sessionId: sessionId,
text: noteText,
timestamp: Date(),
isAiGenerated: true,
segmentStartOrder: request.segmentStartOrder,
segmentEndOrder: request.segmentEndOrder
)
if let note = record.toLiveNote() {
await MainActor.run {
guard self.currentSessionId == sessionId else { return }
self.notes.append(note)
self.accumulator.markGenerationSucceeded(noteText: noteText)
self.isGenerating = false
}
} else {
await MainActor.run { self.finishGeneration(for: sessionId) }
}
} catch let dbError as DatabaseError where dbError.resultCode == .SQLITE_CONSTRAINT {
// Session was deleted during async AI generation — not an error
log("LiveNotesMonitor: Session \(sessionId) deleted during note generation, skipping")
await MainActor.run { self.finishGeneration(for: sessionId) }
} catch {
logError("LiveNotesMonitor: Failed to generate note", error: error)
await MainActor.run { self.finishGeneration(for: sessionId) }
}
}
}
private func finishGeneration(for sessionId: Int64) {
guard currentSessionId == sessionId else { return }
isGenerating = false
}
// MARK: - Computed Properties
/// Check if there are any notes
var isEmpty: Bool {
notes.isEmpty
}
/// Get the latest note
var latestNote: LiveNote? {
notes.last
}
/// Get notes for current session
func getNotesForCurrentSession() -> [LiveNote] {
return notes
}
// MARK: - Diagnostics
/// Word buffer size (for memory diagnostics)
var wordBufferCount: Int { accumulator.wordBuffer.count }
/// Existing notes context size (for memory diagnostics)
var existingNotesContextCount: Int { accumulator.existingNotesContext.count }
}