@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.
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +4 -0
- package/server/lib/atomic-fs.ts +77 -1
- package/server/lib/batch-transcript-quality.ts +243 -0
- package/server/lib/meeting-batch-persistence.ts +53 -0
- package/server/lib/meeting-batch-transcribe.ts +249 -0
- package/server/lib/meeting-store.ts +594 -0
- package/server/routes/health.ts +1 -0
- package/server/routes/meeting.ts +329 -0
- package/server/routes/meetings.ts +66 -0
- package/server/routes/transcribe-stream.ts +116 -23
|
@@ -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()
|