@gotcos/glasses-server 6.7.0 → 6.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ // POST /api/meeting/save — finalize an existing transcribe-stream session into
2
+ // the standalone public meeting store. The live transcript and chunk metadata
3
+ // are durable before the session is closed; batch improvement runs afterward.
4
+
5
+ import { rmSync } from 'node:fs'
6
+ import { Router } from 'express'
7
+ import { emitDisplay } from '../lib/display-bus.js'
8
+ import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
9
+ import {
10
+ getMeetingStore,
11
+ MeetingStore,
12
+ MeetingStoreError,
13
+ type SavedMeeting,
14
+ } from '../lib/meeting-store.js'
15
+ import {
16
+ canDeletePendingBatchAudio,
17
+ persistBatchDecisionSidecar,
18
+ replaceMeetingTranscriptAtomic,
19
+ } from '../lib/meeting-batch-persistence.js'
20
+ import { runMeetingBatchPipeline } from '../lib/meeting-batch-transcribe.js'
21
+ import {
22
+ selectBatchTranscriptForPersistence,
23
+ type BatchTranscription,
24
+ } from '../lib/batch-transcript-quality.js'
25
+ import {
26
+ analyzeTranscriptGaps,
27
+ deleteSession,
28
+ drainSessionAudioWrites,
29
+ getSessionChunkEntries,
30
+ getSessionChunks,
31
+ getSessionProviderCandidates,
32
+ getSessionStartTime,
33
+ getSessionTranscript,
34
+ hasSessionAudio,
35
+ moveSessionAudioToPending,
36
+ type IndexedTranscriptChunk,
37
+ type ProviderCandidateRecord,
38
+ type TranscriptChunk,
39
+ type TranscriptGapReport,
40
+ } from './transcribe-stream.js'
41
+
42
+ interface MeetingSessionSource {
43
+ getTranscript(sessionId: string): string | null
44
+ getStartTime(sessionId: string): number | null
45
+ getChunks(sessionId: string): TranscriptChunk[] | null
46
+ getChunkEntries(sessionId: string): IndexedTranscriptChunk[] | null
47
+ getProviderCandidates(sessionId: string): Record<string, ProviderCandidateRecord>
48
+ getIntegrity(sessionId: string): TranscriptGapReport | null
49
+ drainAudioWrites(sessionId: string): Promise<void>
50
+ hasAudio(sessionId: string): boolean
51
+ moveAudioToPending(sessionId: string): string | null
52
+ delete(sessionId: string, options?: { preserveAudio?: boolean }): void
53
+ }
54
+
55
+ export interface MeetingRouteDependencies {
56
+ store?: MeetingStore
57
+ sessions?: MeetingSessionSource
58
+ runBatch?: (
59
+ audioDir: string,
60
+ entries: IndexedTranscriptChunk[],
61
+ streamingWordCount: number,
62
+ ) => Promise<BatchTranscription>
63
+ scheduleBackground?: (task: Promise<void>) => void
64
+ emit?: typeof emitDisplay
65
+ }
66
+
67
+ const defaultSessionSource: MeetingSessionSource = {
68
+ getTranscript: sessionId => getSessionTranscript(sessionId, { withGaps: true }),
69
+ getStartTime: getSessionStartTime,
70
+ getChunks: getSessionChunks,
71
+ getChunkEntries: getSessionChunkEntries,
72
+ getProviderCandidates: getSessionProviderCandidates,
73
+ getIntegrity: analyzeTranscriptGaps,
74
+ drainAudioWrites: drainSessionAudioWrites,
75
+ hasAudio: hasSessionAudio,
76
+ moveAudioToPending: moveSessionAudioToPending,
77
+ delete: deleteSession,
78
+ }
79
+
80
+ function countWords(text: string): number {
81
+ return text.trim().split(/\s+/).filter(Boolean).length
82
+ }
83
+
84
+ function cleanFinalTranscript(transcript: string): string {
85
+ try {
86
+ return process.env.COS_WHISPER_STRIP_BRAND_URLS === '0'
87
+ ? transcript
88
+ : cleanTranscriptLines(transcript)
89
+ } catch {
90
+ return transcript
91
+ }
92
+ }
93
+
94
+ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<string, unknown> {
95
+ const integrity = saved.transferIntegrity ?? null
96
+ const missingCount = integrity?.missingIndices.length ?? 0
97
+ const completenessPct = integrity
98
+ ? Math.floor(integrity.completeness * 1_000) / 10
99
+ : 100
100
+ return {
101
+ saved: true,
102
+ // Keep the build199 string field without leaking an absolute host path.
103
+ filepath: `recordings/${saved.month}/${saved.filename}`,
104
+ filename: saved.filename,
105
+ durationMin: saved.durationMin,
106
+ domain: saved.domain,
107
+ transcriptionQuality: 'streaming',
108
+ ...(replayed ? { replayed: true } : {}),
109
+ transferIntegrity: integrity ? {
110
+ completeness: completenessPct,
111
+ received: integrity.received,
112
+ expected: integrity.expected,
113
+ missingChunks: missingCount,
114
+ missingIndices: integrity.missingIndices.slice(0, 50),
115
+ } : null,
116
+ }
117
+ }
118
+
119
+ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
120
+ const store = deps.store ?? getMeetingStore()
121
+ const sessions = deps.sessions ?? defaultSessionSource
122
+ const runBatch = deps.runBatch ?? runMeetingBatchPipeline
123
+ const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
124
+ const emit = deps.emit ?? emitDisplay
125
+ const router = Router()
126
+ const savingSessions = new Set<string>()
127
+
128
+ router.post('/meeting/save', async (req, res) => {
129
+ let lockedSessionId: string | null = null
130
+ try {
131
+ const body = req.body as Record<string, unknown> | undefined
132
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
133
+ if (!sessionId) {
134
+ res.status(400).json({ error: 'sessionId required', reason: 'missing_session_id' })
135
+ return
136
+ }
137
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
138
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
139
+ return
140
+ }
141
+ if (body?.title !== undefined && typeof body.title !== 'string') {
142
+ res.status(400).json({ error: 'Invalid title', reason: 'invalid_title' })
143
+ return
144
+ }
145
+ if (body?.domain !== undefined && typeof body.domain !== 'string') {
146
+ res.status(400).json({ error: 'Invalid domain', reason: 'invalid_domain' })
147
+ return
148
+ }
149
+
150
+ // A response can be lost after both durable files were committed. Find
151
+ // the sidecar by session ID so client retry/restart is idempotent.
152
+ const alreadySaved = store.findBySessionId(sessionId)
153
+ if (alreadySaved) {
154
+ res.set('Cache-Control', 'private, no-store')
155
+ res.json(publicSaveResponse(alreadySaved, true))
156
+ return
157
+ }
158
+ if (savingSessions.has(sessionId)) {
159
+ res.status(409).json({ error: 'Meeting save already in progress', reason: 'save_in_progress' })
160
+ return
161
+ }
162
+ savingSessions.add(sessionId)
163
+ lockedSessionId = sessionId
164
+
165
+ const transcript = sessions.getTranscript(sessionId)
166
+ if (!transcript?.trim()) {
167
+ res.status(404).json({
168
+ error: `No transcript found for session ${sessionId}`,
169
+ reason: 'session_not_found',
170
+ })
171
+ return
172
+ }
173
+
174
+ const chunks = sessions.getChunks(sessionId) ?? []
175
+ const chunkEntries = sessions.getChunkEntries(sessionId)
176
+ ?? chunks.map((chunk, chunkIndex) => ({ chunkIndex, chunk }))
177
+ const startTime = sessions.getStartTime(sessionId) ?? Date.now()
178
+ const durationFromTimeline = chunks.reduce(
179
+ (maximum, chunk) => Math.max(maximum, chunk?.elapsed ?? 0),
180
+ 0,
181
+ )
182
+ const durationMs = durationFromTimeline > 0
183
+ ? durationFromTimeline
184
+ : Math.max(0, Date.now() - startTime)
185
+ const integrity = sessions.getIntegrity(sessionId)
186
+
187
+ // Initial canonical text + structured metadata are published before any
188
+ // live state is removed or background work is scheduled.
189
+ const saved = store.save({
190
+ sessionId,
191
+ title: body?.title as string | undefined,
192
+ domain: body?.domain as string | undefined,
193
+ transcript: cleanFinalTranscript(transcript),
194
+ startTime,
195
+ durationMs,
196
+ chunks,
197
+ chunkEntries,
198
+ providerCandidates: sessions.getProviderCandidates(sessionId),
199
+ transferIntegrity: integrity,
200
+ })
201
+
202
+ // Wait for every raw-WAV write before rename. If any write failed, retain
203
+ // surviving audio for recovery but do not run an incomplete batch.
204
+ let audioWritesReady = true
205
+ try {
206
+ await sessions.drainAudioWrites(sessionId)
207
+ } catch (error) {
208
+ audioWritesReady = false
209
+ console.warn(
210
+ `[meeting/save] One or more raw audio writes failed for ${sessionId}: `
211
+ + `${error instanceof Error ? error.message : String(error)}`,
212
+ )
213
+ }
214
+ const hadSessionAudio = sessions.hasAudio(sessionId)
215
+ // drainAudioWrites uses allSettled, so even its error path has no live
216
+ // writes. Move surviving evidence to the normal two-hour pending store,
217
+ // but do not batch an incomplete capture.
218
+ const pendingAudioDir = hadSessionAudio ? sessions.moveAudioToPending(sessionId) : null
219
+ const preserveSourceAudio = hadSessionAudio && !pendingAudioDir
220
+ sessions.delete(sessionId, { preserveAudio: preserveSourceAudio })
221
+ if (preserveSourceAudio) {
222
+ console.warn(`[meeting/save] Source audio for ${sessionId} retained after failed pending handoff`)
223
+ }
224
+
225
+ try {
226
+ emit({
227
+ type: 'recording_stop',
228
+ data: {
229
+ sessionId,
230
+ filename: saved.filename,
231
+ durationMin: saved.durationMin,
232
+ domain: saved.domain,
233
+ },
234
+ })
235
+ } catch (error) {
236
+ console.warn('[meeting/save] Display notification failed after durable save:', error)
237
+ }
238
+
239
+ res.set('Cache-Control', 'private, no-store')
240
+ res.json(publicSaveResponse(saved))
241
+
242
+ if (audioWritesReady && pendingAudioDir && chunkEntries.length > 0) {
243
+ const task = finalizeBatch({
244
+ audioDir: pendingAudioDir,
245
+ entries: chunkEntries.map(entry => ({ ...entry, chunk: { ...entry.chunk } })),
246
+ streamingWordCount: countWords(transcript),
247
+ meetingPath: saved.filepath,
248
+ sidecarPath: saved.sidecarPath,
249
+ runBatch,
250
+ }).catch(error => {
251
+ // Raw audio deliberately remains for the existing two-hour cleanup.
252
+ console.error(
253
+ `[meeting/save] Batch finalization failed for ${sessionId}: `
254
+ + `${error instanceof Error ? error.message : String(error)}`,
255
+ )
256
+ })
257
+ scheduleBackground(task)
258
+ }
259
+ } catch (error) {
260
+ if (error instanceof MeetingStoreError) {
261
+ res.status(error.status).json({ error: error.message, reason: error.code })
262
+ return
263
+ }
264
+ console.error('[meeting/save] Finalization failed:', error)
265
+ res.status(500).json({ error: 'Meeting save failed', reason: 'meeting_save_error' })
266
+ } finally {
267
+ if (lockedSessionId) savingSessions.delete(lockedSessionId)
268
+ }
269
+ })
270
+
271
+ return router
272
+ }
273
+
274
+ async function finalizeBatch(options: {
275
+ audioDir: string
276
+ entries: IndexedTranscriptChunk[]
277
+ streamingWordCount: number
278
+ meetingPath: string
279
+ sidecarPath: string
280
+ runBatch: NonNullable<MeetingRouteDependencies['runBatch']>
281
+ }): Promise<void> {
282
+ const result = await options.runBatch(
283
+ options.audioDir,
284
+ options.entries,
285
+ options.streamingWordCount,
286
+ )
287
+ let transcriptApplied = false
288
+ let metadataPersisted = false
289
+ let persistedResult = result
290
+
291
+ if (result.transcriptionQuality === 'batch' && result.batchTranscript) {
292
+ const selected = selectBatchTranscriptForPersistence(result.batchTranscript, result.batchSegments)
293
+ const canonicalText = cleanFinalTranscript(selected.text)
294
+ if (canonicalText.trim()) {
295
+ transcriptApplied = replaceMeetingTranscriptAtomic(options.meetingPath, canonicalText)
296
+ } else {
297
+ console.error('[meeting/save] Accepted batch candidate cleaned to empty; canonical text retained')
298
+ }
299
+ // Metadata records the exact selected text that became canonical, while
300
+ // batchSegments retain the full diagnostic evidence.
301
+ persistedResult = { ...result, batchTranscript: canonicalText }
302
+ } else if (result.qualityReport) {
303
+ console.warn(
304
+ `[meeting/save] Batch candidate rejected (${result.qualityReport.reason}); `
305
+ + 'canonical streaming transcript retained',
306
+ )
307
+ }
308
+
309
+ try {
310
+ metadataPersisted = persistBatchDecisionSidecar(
311
+ options.sidecarPath,
312
+ persistedResult,
313
+ transcriptApplied,
314
+ )
315
+ } catch (error) {
316
+ console.error(
317
+ '[meeting/save] Batch decision metadata was not durable:',
318
+ error instanceof Error ? error.message : String(error),
319
+ )
320
+ }
321
+
322
+ if (canDeletePendingBatchAudio(transcriptApplied, metadataPersisted)) {
323
+ rmSync(options.audioDir, { recursive: true, force: true })
324
+ } else {
325
+ console.warn('[meeting/save] Pending raw audio retained for bounded two-hour cleanup')
326
+ }
327
+ }
328
+
329
+ export const meetingRouter = createMeetingRouter()
@@ -0,0 +1,66 @@
1
+ // Standalone meeting archive backed only by the public server's private data
2
+ // directory. No COS operations paths, classifiers, or user-specific stores.
3
+
4
+ import { Router } from 'express'
5
+ import { getMeetingStore, MeetingStore, MeetingStoreError } from '../lib/meeting-store.js'
6
+
7
+ export function createMeetingsRouter(store: MeetingStore = getMeetingStore()): Router {
8
+ const router = Router()
9
+
10
+ // GET /api/meetings?limit=20&domain=all
11
+ router.get('/meetings', (req, res) => {
12
+ try {
13
+ const rawLimit = typeof req.query.limit === 'string' ? Number.parseInt(req.query.limit, 10) : 20
14
+ const limit = Number.isFinite(rawLimit) ? rawLimit : 20
15
+ const domain = typeof req.query.domain === 'string' ? req.query.domain : 'all'
16
+ res.set('Cache-Control', 'private, no-store')
17
+ res.json({ meetings: store.list({ limit, domain }) })
18
+ } catch (error) {
19
+ sendMeetingStoreError(res, error)
20
+ }
21
+ })
22
+
23
+ // Query-form detail is convenient for generic API consumers. Register the
24
+ // literal route before the build199 dynamic compatibility route.
25
+ router.get('/meetings/detail', (req, res) => {
26
+ try {
27
+ const domain = typeof req.query.domain === 'string' ? req.query.domain : ''
28
+ const month = typeof req.query.month === 'string' ? req.query.month : ''
29
+ const filename = typeof req.query.filename === 'string' ? req.query.filename : ''
30
+ if (!domain || !month || !filename) {
31
+ res.status(400).json({ error: 'domain, month, and filename are required', reason: 'invalid_meeting_ref' })
32
+ return
33
+ }
34
+ res.set('Cache-Control', 'private, no-store')
35
+ res.json(store.detail(domain, month, filename))
36
+ } catch (error) {
37
+ sendMeetingStoreError(res, error)
38
+ }
39
+ })
40
+
41
+ // Build199 compatibility: detail requests carry the list row's domain even
42
+ // though standalone files all live in one fixed recordings/YYYY-MM store.
43
+ router.get('/meetings/:domain/:month/:filename', (req, res) => {
44
+ try {
45
+ res.set('Cache-Control', 'private, no-store')
46
+ res.json(store.detail(req.params.domain, req.params.month, req.params.filename))
47
+ } catch (error) {
48
+ sendMeetingStoreError(res, error)
49
+ }
50
+ })
51
+
52
+ return router
53
+ }
54
+
55
+ function sendMeetingStoreError(
56
+ res: { status: (status: number) => { json: (body: unknown) => unknown } },
57
+ error: unknown,
58
+ ): unknown {
59
+ if (error instanceof MeetingStoreError) {
60
+ return res.status(error.status).json({ error: error.message, reason: error.code })
61
+ }
62
+ console.error('[meetings] Store read failed:', error)
63
+ return res.status(500).json({ error: 'Meeting store unavailable', reason: 'meeting_store_error' })
64
+ }
65
+
66
+ export const meetingsRouter = createMeetingsRouter()