@gotcos/glasses-server 6.95.0 → 6.98.1
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 +166 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +763 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +572 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
package/server/routes/meeting.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { runQualifiedMacProfilePass } from '../lib/mac-speaker-profile-finalizer.js'
|
|
2
2
|
import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-evidence.js'
|
|
3
|
+
import { isEffectiveMacSpeakerCallView, readMacSpeakerCallView } from '../lib/mac-speaker-call-view.js'
|
|
4
|
+
import { commitMacCallHumanAnnotation, isMacCallHumanName, macCallHumanPaths, macCallHumanScopes, previewMacCallHumanAnnotation, readMacCallHumanFile, recoverMacCallHumanAnnotation, type MacCallHumanFiles, type MacCallHumanScope } from '../lib/mac-speaker-call-human.js'
|
|
5
|
+
import type { MacSpeakerCallPacket, MacSpeakerCallView } from '../lib/mac-speaker-call-view.js'
|
|
6
|
+
import { buildMacIntervalHumanScopes, commitMacIntervalHumanAnnotation, inspectMacIntervalHumanJournal, macIntervalHumanPaths, previewMacIntervalHumanAnnotation, recoverMacIntervalHumanAnnotation, undoMacIntervalHumanAnnotation, type MacIntervalHumanScope } from '../lib/mac-speaker-call-interval-human.js'
|
|
7
|
+
import { commitOriginalViewTransaction, inspectOriginalViewTransaction, originalViewPaths, previewOriginalViewTransaction, recoverOriginalViewTransaction } from '../lib/mac-speaker-call-original-view.js'
|
|
8
|
+
import { checkSpeakerName } from '../lib/speaker-name.js'
|
|
3
9
|
import { dataPath } from '../lib/data-dir.js'
|
|
4
10
|
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
|
|
5
11
|
import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
|
|
12
|
+
import { bodyName, nfc, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
6
13
|
import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
7
14
|
// POST /api/meeting/save — finalize an existing transcribe-stream session into
|
|
8
15
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
@@ -12,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
|
|
|
12
19
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
13
20
|
import { basename, resolve } from 'node:path'
|
|
14
21
|
import { createHash } from 'node:crypto'
|
|
15
|
-
import { Router } from 'express'
|
|
22
|
+
import { Router, type RequestHandler } from 'express'
|
|
16
23
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
17
24
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
18
25
|
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
@@ -47,6 +54,9 @@ import {
|
|
|
47
54
|
import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
48
55
|
import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
|
|
49
56
|
import { commitMacVoiceAssignment, planMacVoiceAssignment, type MacAssignGate, type MacAssignPlan, type MacAssignSkip } from '../lib/mac-voice-assignment.js'
|
|
57
|
+
import { readVoiceDisplay, voiceDisplayScopes } from '../lib/mac-voice-display.js'
|
|
58
|
+
import { registerMacVoiceNamingBatchRoutes } from '../lib/mac-voice-naming-batch.js'
|
|
59
|
+
import { getVoiceSuggestionRunner, voiceSidecarEntries, voiceSuggestionPauseLease, voiceSuggestionRecordingLease, type VoiceSuggestionBlock, type VoiceSuggestionRunner } from '../lib/mac-voice-suggestions.js'
|
|
50
60
|
import { isMacCaptureSidecar, isVoiceLabel } from '../lib/mac-voice-labels.js'
|
|
51
61
|
|
|
52
62
|
function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
|
|
@@ -194,16 +204,20 @@ import {
|
|
|
194
204
|
} from '../lib/g2-ops-handoff.js'
|
|
195
205
|
import {
|
|
196
206
|
canonicalFinalizationIsComplete,
|
|
207
|
+
finalizationStop,
|
|
197
208
|
MeetingFinalizationJobStore,
|
|
198
209
|
markCanonicalFinalizationState,
|
|
199
210
|
readFinalizationChunkEntries,
|
|
211
|
+
stoppedFinalizationRetryDue,
|
|
200
212
|
type MeetingFinalizationJob,
|
|
201
213
|
} from '../lib/meeting-finalization-jobs.js'
|
|
202
214
|
import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
|
|
203
215
|
import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
|
|
216
|
+
import { managedServerVersion } from '../lib/managed-runtime.js'
|
|
204
217
|
import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
|
|
205
218
|
import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
|
|
206
219
|
import { notifySpeakerShadowFinalized } from '../lib/speaker-shadow.js'
|
|
220
|
+
import { macSpeakerCallCachedRefreshAvailability, notifyMacSpeakerCallFinalized, notifyMacSpeakerCallLedgerChanged } from '../lib/mac-speaker-call-producer.js'
|
|
207
221
|
import { sweepTranscriptCleanupData } from '../lib/transcript-cleanup-screen.js'
|
|
208
222
|
import type { TranscriptCleanupRouteDeps } from './transcript-cleanup.js'
|
|
209
223
|
|
|
@@ -228,13 +242,31 @@ function cosOpsPipelineConfigured(): boolean {
|
|
|
228
242
|
* A failure is logged and never fails the capture's own finalization, because a retry would
|
|
229
243
|
* re-run the standalone summary; mac_library_backfill.py files it later, idempotently.
|
|
230
244
|
*/
|
|
231
|
-
async function fileFinishedMacRecording(sessionId: string, meetingPath: string, label: string
|
|
245
|
+
export async function fileFinishedMacRecording(sessionId: string, meetingPath: string, label: string, deps: {
|
|
246
|
+
configured?: () => boolean
|
|
247
|
+
file?: typeof fileMacRecordingInLibrary
|
|
248
|
+
notify?: typeof notifyMacSpeakerCallFinalized
|
|
249
|
+
shadowNotify?: typeof notifySpeakerShadowFinalized
|
|
250
|
+
operationsRoot?: () => string | null
|
|
251
|
+
} = {}): Promise<void> {
|
|
232
252
|
// Callers pass Mac captures only (finalization and orphan recovery both check MAC_CAPTURE_ID).
|
|
233
|
-
if (!
|
|
253
|
+
if (!MAC_CAPTURE_ID.test(sessionId)) return
|
|
254
|
+
const notify = deps.notify ?? notifyMacSpeakerCallFinalized
|
|
234
255
|
try {
|
|
235
|
-
|
|
256
|
+
if (!(deps.configured ?? cosOpsPipelineConfigured)()) return
|
|
257
|
+
const filing = await (deps.file ?? fileMacRecordingInLibrary)(meetingPath)
|
|
236
258
|
if (filing.outcome === 'filed') {
|
|
237
259
|
console.log(`[${label}] Mac recording ${sessionId} filed into the library (${filing.status}): ${filing.path}`)
|
|
260
|
+
// The exact-file importer returns its chosen destination. Never guess a
|
|
261
|
+
// second copy from a title or apply one copy's packet to another BASE.
|
|
262
|
+
const root = (deps.operationsRoot ?? resolveCosOperationsDir)()
|
|
263
|
+
const filedPath = resolve(filing.path), sidecarPath = filedPath.replace(/\.md$/, '.g2-chunks.json')
|
|
264
|
+
if (root && filedPath.startsWith(`${root}/`) && filedPath.endsWith('.md')) {
|
|
265
|
+
const source = JSON.parse(readMacCallHumanFile(sidecarPath, 32 * 1024 * 1024)!.text)
|
|
266
|
+
if (source.sessionId === sessionId && source.sourceType === 'mac_system_audio') {
|
|
267
|
+
await notify({ sessionId, sidecarPath })
|
|
268
|
+
}
|
|
269
|
+
}
|
|
238
270
|
} else {
|
|
239
271
|
console.log(`[${label}] Mac recording ${sessionId} stays standalone: ${filing.reason}`)
|
|
240
272
|
}
|
|
@@ -243,6 +275,15 @@ async function fileFinishedMacRecording(sessionId: string, meetingPath: string,
|
|
|
243
275
|
`[${label}] Mac recording ${sessionId} was not filed into the library: `
|
|
244
276
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
245
277
|
)
|
|
278
|
+
} finally {
|
|
279
|
+
// The canonical standalone recording remains a registered target when
|
|
280
|
+
// filing is unavailable or fails. Notifications never make saving fail.
|
|
281
|
+
try { await notify({ sessionId, sidecarPath: meetingPath.replace(/\.md$/, '.g2-chunks.json') }) }
|
|
282
|
+
catch { console.warn(`[${label}] Closed-call speaker reconciliation deferred for ${sessionId}`) }
|
|
283
|
+
// Register the publishing consumer before the log-only consumer can start
|
|
284
|
+
// shared inference. An already-running log-only execution cannot acquire
|
|
285
|
+
// pre-native source provenance retroactively.
|
|
286
|
+
(deps.shadowNotify ?? notifySpeakerShadowFinalized)({ sessionId, sidecarPath: meetingPath.replace(/\.md$/, '.g2-chunks.json') })
|
|
246
287
|
}
|
|
247
288
|
}
|
|
248
289
|
|
|
@@ -262,7 +303,7 @@ function markdownUnreadableReason(operations: { mergedScribeConflict?: string[]
|
|
|
262
303
|
}
|
|
263
304
|
|
|
264
305
|
function requestedRecordMatches(requested: unknown, expected: string): boolean {
|
|
265
|
-
return requested == null || requested === '' || requested
|
|
306
|
+
return requested == null || requested === '' || sameRecordId(requested, expected)
|
|
266
307
|
}
|
|
267
308
|
|
|
268
309
|
interface MeetingSessionSource {
|
|
@@ -317,6 +358,8 @@ export interface MeetingRouteDependencies {
|
|
|
317
358
|
stopRequests?: StopRequestStore
|
|
318
359
|
/** 6.77.0: the post-save transcript cleanup (lib/transcript-cleanup.ts). */
|
|
319
360
|
transcriptCleanup?: TranscriptCleanupRunner
|
|
361
|
+
/** 6.96.3: on-click voice suggestions (lib/mac-voice-suggestions.ts). Tests inject a runner with a fake extractor. */
|
|
362
|
+
voiceSuggestions?: VoiceSuggestionRunner
|
|
320
363
|
/** 6.81.0: the phone's Stop, durable (lib/meeting-stop-intents.ts). */
|
|
321
364
|
stopIntents?: StopIntentStore
|
|
322
365
|
/** 6.81.0: the server's own save of a stopped meeting. Tests inject the clock, timer and gate. */
|
|
@@ -331,6 +374,8 @@ export interface MeetingRouteDependencies {
|
|
|
331
374
|
|
|
332
375
|
const activeFinalizationJobs = new Set<string>()
|
|
333
376
|
const finalizationRetryCounts = new Map<string, number>()
|
|
377
|
+
/** Retries after a job's first failure in one process, 60 s and 120 s apart. */
|
|
378
|
+
const FINALIZATION_RETRY_LIMIT = 2
|
|
334
379
|
|
|
335
380
|
interface FinalizationRuntime {
|
|
336
381
|
finalizationJobs: MeetingFinalizationJobStore
|
|
@@ -383,13 +428,17 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
|
|
|
383
428
|
allowDuringDrain: true,
|
|
384
429
|
phase: 'queued',
|
|
385
430
|
})
|
|
431
|
+
// 6.97.0: an attempt is starting, so a job that had stopped is work in progress again.
|
|
432
|
+
try { runtime.finalizationJobs.clearStopped(job.sessionId) } catch { /* the lease above still holds the drain */ }
|
|
386
433
|
const jobStartedAt = Date.now()
|
|
387
434
|
const task = Promise.resolve().then(async () => {
|
|
388
435
|
lease.setPhase('active')
|
|
389
436
|
let current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
390
437
|
if (canonicalFinalizationIsComplete(current)) {
|
|
438
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) await notifyMacSpeakerCallFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath, freezeOnly: true })
|
|
391
439
|
runtime.finalizationJobs.remove(current.sessionId)
|
|
392
440
|
bootResumeGate().forget('meeting_finalization', current.sessionId)
|
|
441
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) runtime.scheduleBackground(fileFinishedMacRecording(current.sessionId, current.meetingPath, 'meeting/resume'))
|
|
393
442
|
return
|
|
394
443
|
}
|
|
395
444
|
if (current.phase === 'capture_pending') {
|
|
@@ -492,9 +541,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
|
|
|
492
541
|
await enrichStandaloneMeeting(current.meetingPath, jobStartedAt)
|
|
493
542
|
}
|
|
494
543
|
markCanonicalFinalizationState(current.sidecarPath, 'complete', false)
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
if (MAC_CAPTURE_ID.test(current.sessionId))
|
|
544
|
+
// Capture the pre-correction source while the finalization guard still
|
|
545
|
+
// refuses human edits. A deferred producer keeps its own durable work item.
|
|
546
|
+
if (MAC_CAPTURE_ID.test(current.sessionId)) await notifyMacSpeakerCallFinalized({ sessionId: current.sessionId, sidecarPath: current.sidecarPath, freezeOnly: true })
|
|
498
547
|
// The capture is finished and its files are durable, so the merge engine may score it.
|
|
499
548
|
// Fire-and-forget by contract: the runner queues, defers under a drain or a live
|
|
500
549
|
// capture, and never throws back at the finalization path.
|
|
@@ -512,6 +561,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
|
|
|
512
561
|
}
|
|
513
562
|
}).catch(error => {
|
|
514
563
|
const current = runtime.finalizationJobs.get(job.sessionId) ?? job
|
|
564
|
+
const retryCount = finalizationRetryCounts.get(key) ?? 0
|
|
565
|
+
// 6.97.0: nothing will try this again in this process, so it stops counting as work.
|
|
566
|
+
const outOfRetries = retryCount >= FINALIZATION_RETRY_LIMIT
|
|
515
567
|
try {
|
|
516
568
|
runtime.finalizationJobs.save({
|
|
517
569
|
sessionId: current.sessionId,
|
|
@@ -522,14 +574,14 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
|
|
|
522
574
|
phase: current.phase,
|
|
523
575
|
claimPending: current.claimPending,
|
|
524
576
|
lastError: error instanceof Error ? error.message : String(error),
|
|
577
|
+
...(outOfRetries ? { stopped: { at: new Date().toISOString(), reason: 'retries_exhausted' as const, version: managedServerVersion() } } : {}),
|
|
525
578
|
})
|
|
526
579
|
} catch { /* keep the original durable job if error annotation fails */ }
|
|
527
580
|
console.error(
|
|
528
581
|
`[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
|
|
529
582
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
530
583
|
)
|
|
531
|
-
|
|
532
|
-
if (retryCount < 2) {
|
|
584
|
+
if (!outOfRetries) {
|
|
533
585
|
finalizationRetryCounts.set(key, retryCount + 1)
|
|
534
586
|
const retryRetained = () => {
|
|
535
587
|
if (!maintenanceAdmissionsOpen()) {
|
|
@@ -558,6 +610,8 @@ export function resumeMeetingFinalizationJobs(options: {
|
|
|
558
610
|
gate?: BootResumeGate
|
|
559
611
|
finalizationJobs?: MeetingFinalizationJobStore
|
|
560
612
|
schedule?: (job: MeetingFinalizationJob) => void
|
|
613
|
+
now?: number
|
|
614
|
+
version?: string | null
|
|
561
615
|
} = {}): void {
|
|
562
616
|
const finalizationJobs = options.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
563
617
|
finalizationJobs.reconcileCanonicalSidecars()
|
|
@@ -567,11 +621,25 @@ export function resumeMeetingFinalizationJobs(options: {
|
|
|
567
621
|
runBatch: runMeetingBatchPipeline,
|
|
568
622
|
scheduleBackground: task => { void task },
|
|
569
623
|
}))
|
|
624
|
+
const now = options.now ?? Date.now()
|
|
625
|
+
const version = options.version === undefined ? managedServerVersion() : options.version
|
|
570
626
|
for (const job of finalizationJobs.list()) {
|
|
571
627
|
if (options.sessionId !== undefined && job.sessionId !== options.sessionId) continue
|
|
628
|
+
// 6.97.0: a job that gave up does not take the lock back on every start. It is tried again on
|
|
629
|
+
// another server version, which may have the fix, or 6 hours after it stopped. A person's Retry
|
|
630
|
+
// (`sessionId`) always runs.
|
|
631
|
+
const stop = finalizationStop(job)
|
|
632
|
+
if (stop && options.sessionId === undefined && !stoppedFinalizationRetryDue(stop, now, version)) continue
|
|
572
633
|
// 6.78.0: counted (durably) BEFORE it runs, so a job that kills the process is parked
|
|
573
634
|
// after RESTART_LIMIT crashed boots. A parked job is skipped and stays in its store.
|
|
574
|
-
if (!gate.admit('meeting_finalization', job.sessionId, job.phase))
|
|
635
|
+
if (!gate.admit('meeting_finalization', job.sessionId, job.phase)) {
|
|
636
|
+
// 6.97.0: parked means nothing is trying it, so it must not hold Restart or Update either.
|
|
637
|
+
try { if (stop?.reason !== 'crash_parked') finalizationJobs.markStopped(job, 'crash_parked', version) } catch { /* stays as it was */ }
|
|
638
|
+
continue
|
|
639
|
+
}
|
|
640
|
+
// 6.97.0: a job that already failed gets one attempt per start, with no timers: its lock lasts as
|
|
641
|
+
// long as that attempt runs. This also covers a job that failed before 6.97.0 wrote stops down.
|
|
642
|
+
if (stop || job.lastError) finalizationRetryCounts.set(`${finalizationJobs.root}:${job.sessionId}`, FINALIZATION_RETRY_LIMIT)
|
|
575
643
|
schedule(job)
|
|
576
644
|
}
|
|
577
645
|
}
|
|
@@ -734,6 +802,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
734
802
|
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
735
803
|
const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
|
|
736
804
|
const transcriptCleanup = deps.transcriptCleanup
|
|
805
|
+
const voiceSuggestions = deps.voiceSuggestions ?? getVoiceSuggestionRunner()
|
|
737
806
|
const router = Router()
|
|
738
807
|
const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
|
|
739
808
|
const savingSessions = new Set<string>()
|
|
@@ -1578,18 +1647,285 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1578
1647
|
// Keyed on sessionId so it can reuse the store's traversal-hardened readers
|
|
1579
1648
|
function reviewRevision(meetingPath: string, sidecarPath: string): string {
|
|
1580
1649
|
const hash = createHash('sha256')
|
|
1650
|
+
let sidecarJSON: string | null = null
|
|
1581
1651
|
for (const path of [meetingPath, sidecarPath]) {
|
|
1582
1652
|
let text: string | null = null
|
|
1583
1653
|
try { text = readFileSync(path, 'utf-8') } catch { /* missing content is part of the revision */ }
|
|
1584
1654
|
hash.update(JSON.stringify(text))
|
|
1655
|
+
if (path === sidecarPath) sidecarJSON = text
|
|
1656
|
+
}
|
|
1657
|
+
// Existing clients already round-trip this opaque 64-character revision.
|
|
1658
|
+
// A packet/ledger change that withdraws a view must invalidate its preview,
|
|
1659
|
+
// even when the immutable base files have not changed at all.
|
|
1660
|
+
if (sidecarJSON) {
|
|
1661
|
+
try {
|
|
1662
|
+
const view = readMacSpeakerCallView(sidecarPath, sidecarJSON, meetingPath)
|
|
1663
|
+
if (isEffectiveMacSpeakerCallView(view)) hash.update(JSON.stringify({ packetSHA256: view.metadata.packetSHA256, generation: view.metadata.generation, revision: view.metadata.revision, mode: view.metadata.mode, humanAnnotation: view.metadata.humanAnnotation, intervalHumanAnnotation: view.metadata.intervalHumanAnnotation, intervalHumanRecoveryRequired: view.metadata.intervalHumanRecoveryRequired }))
|
|
1664
|
+
} catch { /* Preserve existing unreadable-base handling in each caller. */ }
|
|
1665
|
+
}
|
|
1666
|
+
// Include sidecar-only decisions and recovery states even after an automatic
|
|
1667
|
+
// packet is withdrawn or becomes unreadable. Old preview revisions fail CAS.
|
|
1668
|
+
if (MAC_CAPTURE_ID.test(String((() => { try { return JSON.parse(sidecarJSON ?? '{}').sessionId } catch { return '' } })()))) {
|
|
1669
|
+
const id = JSON.parse(sidecarJSON!).sessionId
|
|
1670
|
+
const files = { sidecarPath, meetingPath }, paths = macIntervalHumanPaths(files, id), legacy = macCallHumanPaths(files, id)
|
|
1671
|
+
for (const [path, limit] of [[paths.packet, 16 * 1024 * 1024], [sidecarPath.replace(/\.g2-chunks\.json$/, '.speaker-call-state.json'), 16 * 1024 * 1024], [paths.legacyAnnotation, 16 * 1024 * 1024], [paths.annotation, 16 * 1024 * 1024], [paths.journal, 40 * 1024 * 1024], [originalViewPaths(files, id).journal, 1024 * 1024], [legacy.journal, 64 * 1024 * 1024], [paths.ledger, 8 * 1024 * 1024]] as const) {
|
|
1672
|
+
try { hash.update(JSON.stringify(readMacCallHumanFile(path, limit, true)?.hash ?? null)) }
|
|
1673
|
+
catch { hash.update(JSON.stringify({ unreadable: path })) }
|
|
1674
|
+
}
|
|
1585
1675
|
}
|
|
1586
1676
|
return hash.digest('hex')
|
|
1587
1677
|
}
|
|
1588
1678
|
|
|
1679
|
+
function callViewMutationRefusal(sessionId: string): { status: number; body: Record<string, unknown> } | null {
|
|
1680
|
+
const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
1681
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
1682
|
+
const saved = operations || direct ? null : store.findBySessionId(sessionId)
|
|
1683
|
+
if (!operations && !direct && !saved) return null
|
|
1684
|
+
const sidecarPath = operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath
|
|
1685
|
+
try {
|
|
1686
|
+
if (MAC_CAPTURE_ID.test(sessionId)) {
|
|
1687
|
+
const files = { sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }
|
|
1688
|
+
const originalTransaction = inspectOriginalViewTransaction(files, sessionId)
|
|
1689
|
+
if (originalTransaction.status === 'prepared' || originalTransaction.status === 'recovery-invalid') return { status: 409, body: { reason: 'human_annotation_recovery_required', error: 'Finish the interrupted return to the original view before another correction.', mutable: false } }
|
|
1690
|
+
let pending = true
|
|
1691
|
+
try { const journal = readMacCallHumanFile(macCallHumanPaths({ sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }, sessionId).journal, 64 * 1024 * 1024, true); pending = journal !== null && JSON.parse(journal.text).phase !== 'complete' } catch { /* A malformed owned journal must not permit a competing mutation. */ }
|
|
1692
|
+
const intervalJournal = inspectMacIntervalHumanJournal({ sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }, sessionId)
|
|
1693
|
+
pending = pending || intervalJournal.status === 'prepared' || intervalJournal.status === 'recovery-invalid'
|
|
1694
|
+
if (pending) return { status: 409, body: { reason: 'human_annotation_recovery_required', error: 'Finish the interrupted scoped annotation before another correction.', mutable: false } }
|
|
1695
|
+
}
|
|
1696
|
+
const view = readMacSpeakerCallView(sidecarPath, readFileSync(sidecarPath, 'utf8'), operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath)
|
|
1697
|
+
if (view.metadata.humanAnnotationInvalid) return { status: 409, body: { reason: 'human_annotation_invalid', error: 'The saved human annotation cannot be verified; restore or recover it before another correction.', mutable: false } }
|
|
1698
|
+
if (isEffectiveMacSpeakerCallView(view)) return { status: 409, body: { reason: 'derived_call_view_requires_scoped_action', error: 'This consolidated view is read-only. Naming and voice enrollment require an exact reviewed source selection; nothing was changed.', mutable: false } }
|
|
1699
|
+
} catch { /* The existing route reports an unreadable base independently. */ }
|
|
1700
|
+
return null
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1589
1703
|
function selectedReadMatches(requested: unknown, sessionId: string, actual: string): boolean {
|
|
1590
1704
|
return requestedRecordMatches(requested, actual) || blendedRecordFor(requested, sessionId) !== undefined
|
|
1591
1705
|
}
|
|
1592
1706
|
|
|
1707
|
+
function callGroupScopes(files: MacCallHumanFiles, baseJSON: string, view: MacSpeakerCallView): MacCallHumanScope[] {
|
|
1708
|
+
// A sample/word interval is not a whole raw-chunk naming selector. Keep
|
|
1709
|
+
// this policy read-only until the consumer can review exact ranges.
|
|
1710
|
+
if (intervalPolicyPresent(view)) return []
|
|
1711
|
+
const captureId = JSON.parse(baseJSON).sessionId, paths = macCallHumanPaths(files, captureId)
|
|
1712
|
+
const packet = readMacCallHumanFile(paths.packet, 16 * 1024 * 1024, true)
|
|
1713
|
+
let parsedPacket: MacSpeakerCallPacket | null = null
|
|
1714
|
+
try { parsedPacket = packet ? JSON.parse(packet.text) as MacSpeakerCallPacket : null } catch { /* Human-authored scopes are independent of a corrupt automatic packet. */ }
|
|
1715
|
+
return macCallHumanScopes({ baseJSON, view, packet: parsedPacket,
|
|
1716
|
+
markdown: readMacCallHumanFile(files.meetingPath, 16 * 1024 * 1024)!.text,
|
|
1717
|
+
ledgerJSONL: readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true)?.text ?? '',
|
|
1718
|
+
annotationJSON: readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)?.text ?? null })
|
|
1719
|
+
}
|
|
1720
|
+
const publicCallScope = (scope: MacCallHumanScope) => ({ identityId: scope.identityId, displayLabel: scope.displayLabel, reviewBasis: scope.reviewBasis, scopeSHA256: scope.scopeSHA256, members: scope.members, fieldCount: scope.fields.length })
|
|
1721
|
+
function intervalPolicyPresent(view: MacSpeakerCallView): boolean {
|
|
1722
|
+
return view.metadata.status === 'applied' && view.metadata.mode === 'source-bound-intervals' || !!view.metadata.intervalHumanAnnotation || !!view.metadata.intervalHumanAnnotationInvalid || !!view.metadata.intervalHumanRecoveryRequired
|
|
1723
|
+
}
|
|
1724
|
+
function callGroupSource(sessionId: string) {
|
|
1725
|
+
const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
1726
|
+
if (operations?.mergedScribeConflict?.length) throw Error('meeting source conflict')
|
|
1727
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
1728
|
+
if (direct) throw Error('direct library is read-only')
|
|
1729
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
1730
|
+
if (!operations && !saved) throw Error('meeting not found')
|
|
1731
|
+
return { files: { sidecarPath: operations?.sidecarPath ?? saved!.sidecarPath, meetingPath: operations?.meetingPath ?? saved!.filepath },
|
|
1732
|
+
recordId: operations ? `ops:${operations.domain}:${operations.month}:${operations.filename}` : `standalone:${sessionId}` }
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Exact reviewed source membership is a separate action from legacy from-label
|
|
1736
|
+
// relabels. No caller-provided patch paths, global expansion or enrollment.
|
|
1737
|
+
router.get('/meeting/:sessionId/speaker-groups', (req, res) => {
|
|
1738
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1739
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1740
|
+
if (!MAC_CAPTURE_ID.test(sessionId)) { res.status(400).json({ reason: 'not_mac_capture' }); return }
|
|
1741
|
+
try {
|
|
1742
|
+
const source = callGroupSource(sessionId)
|
|
1743
|
+
if (!requestedRecordMatches(req.query.recordId, source.recordId)) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1744
|
+
const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
|
|
1745
|
+
res.json({ sessionId, recordId: source.recordId, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath), groupScopes: callGroupScopes(source.files, base, view).map(publicCallScope), humanAnnotation: view.metadata.humanAnnotation ?? null, enrollmentAllowed: false })
|
|
1746
|
+
} catch (error) { res.status(409).json({ reason: 'group_review_unavailable', error: String(error) }) }
|
|
1747
|
+
})
|
|
1748
|
+
router.post('/meeting/:sessionId/speaker-groups/recover', (req, res) => {
|
|
1749
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1750
|
+
if (!MAC_CAPTURE_ID.test(sessionId)) { res.status(400).json({ reason: 'not_mac_capture' }); return }
|
|
1751
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? finalizingRefusal(sessionId)
|
|
1752
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1753
|
+
try {
|
|
1754
|
+
const source = callGroupSource(sessionId)
|
|
1755
|
+
if (req.body?.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1756
|
+
const view = readMacSpeakerCallView(source.files.sidecarPath, readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, source.files.meetingPath)
|
|
1757
|
+
if (intervalPolicyPresent(view)) { res.status(409).json({ reason: 'interval_scoped_action_required' }); return }
|
|
1758
|
+
recoverMacCallHumanAnnotation(source.files, sessionId)
|
|
1759
|
+
notifyMacSpeakerCallLedgerChanged(sessionId)
|
|
1760
|
+
res.json({ ok: true, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
|
|
1761
|
+
} catch (error) { res.status(409).json({ reason: 'group_recovery_refused', error: String(error) }) }
|
|
1762
|
+
})
|
|
1763
|
+
router.post('/meeting/:sessionId/speaker-groups/:identityId/relabel', async (req, res) => {
|
|
1764
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1765
|
+
const sessionId = String(req.params.sessionId ?? ''), identityId = String(req.params.identityId ?? ''), body = req.body
|
|
1766
|
+
if (body && typeof body === 'object' && typeof body.to === 'string') body.to = nfc(body.to)
|
|
1767
|
+
if (!MAC_CAPTURE_ID.test(sessionId) || !body || Object.keys(body).some(k => !['recordId', 'expectedRevision', 'scopeSHA256', 'rawChunkIndices', 'to', 'dryRun', 'confirm'].includes(k)) || !isMacCallHumanName(body.to) || !Array.isArray(body.rawChunkIndices) || !body.rawChunkIndices.length || body.rawChunkIndices.length > 6000 || body.rawChunkIndices.some((i: unknown) => !Number.isSafeInteger(i) || Number(i) < 0) || new Set(body.rawChunkIndices).size !== body.rawChunkIndices.length || typeof body.expectedRevision !== 'string' || !/^[a-f0-9]{64}$/.test(body.expectedRevision)) { res.status(400).json({ reason: 'invalid_group_selection' }); return }
|
|
1768
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
|
|
1769
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1770
|
+
try {
|
|
1771
|
+
const source = callGroupSource(sessionId)
|
|
1772
|
+
if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1773
|
+
const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
|
|
1774
|
+
if (intervalPolicyPresent(view)) { res.status(409).json({ reason: 'interval_scoped_action_required' }); return }
|
|
1775
|
+
const scope = callGroupScopes(source.files, base, view).find(s => s.identityId === identityId && s.scopeSHA256 === body.scopeSHA256)
|
|
1776
|
+
if (!scope) { res.status(409).json({ reason: 'group_scope_changed', error: 'Reopen the group before naming it' }); return }
|
|
1777
|
+
const selected = new Set<number>(body.rawChunkIndices), members = scope.members.filter(m => selected.has(m.rawChunkIndex))
|
|
1778
|
+
if (members.length !== selected.size) { res.status(409).json({ reason: 'group_scope_changed' }); return }
|
|
1779
|
+
const selection = previewMacCallHumanAnnotation(base, readMacCallHumanFile(source.files.meetingPath, 16 * 1024 * 1024)!.text, view, scope, body.rawChunkIndices, body.to)
|
|
1780
|
+
const preview = { identityId, scopeSHA256: scope.scopeSHA256, ...selection, to: body.to, enrollmentAllowed: false, sourceRevision: body.expectedRevision }
|
|
1781
|
+
if (body.dryRun === true || body.confirm !== true) { res.status(body.dryRun === true ? 200 : 400).json({ ...(body.dryRun === true ? {} : { reason: 'confirmation_required' }), preview }); return }
|
|
1782
|
+
// A correction withdraws a packet bound to its prior ledger. Require the
|
|
1783
|
+
// verified retained native export before the FIRST automatic-group edit
|
|
1784
|
+
// so the remaining groups can refresh without rerunning audio inference.
|
|
1785
|
+
// Revising an existing human decision remains independent of the model.
|
|
1786
|
+
if (scope.reviewBasis !== 'human-authored') {
|
|
1787
|
+
const refresh = await macSpeakerCallCachedRefreshAvailability(sessionId, source.files.sidecarPath)
|
|
1788
|
+
// producer_disabled is a setting, not a wait: nothing is coming to finish it, so it must not ask for a retry.
|
|
1789
|
+
// It stays refused because naming now would withdraw the packet and strand every other group in this view.
|
|
1790
|
+
if (!refresh.available) { res.status(409).json({ reason: 'group_refresh_unavailable', ...(refresh.reason === 'producer_disabled'
|
|
1791
|
+
? { error: 'Naming these groups is not available while speaker reconciliation is off. Your selection was not changed.', retryable: false }
|
|
1792
|
+
: { error: 'Speaker reconciliation is not ready. Your selection was not changed; reopen this group after it finishes.', retryable: true }) }); return }
|
|
1793
|
+
// The cached receipt check awaits child IO. A finalization/resume job
|
|
1794
|
+
// may start in that interval before it changes any source bytes.
|
|
1795
|
+
const afterAdmission = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
|
|
1796
|
+
if (afterAdmission) { res.status(afterAdmission.status).json(afterAdmission.body); return }
|
|
1797
|
+
}
|
|
1798
|
+
const correction = commitMacCallHumanAnnotation({ files: source.files, captureId: sessionId, scope, selectedRawChunkIndices: body.rawChunkIndices, to: body.to })
|
|
1799
|
+
notifyMacSpeakerCallLedgerChanged(sessionId)
|
|
1800
|
+
res.json({ ok: true, confirmed: true, correction, enrollmentAllowed: false, anonymousRefreshRequired: true, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
|
|
1801
|
+
} catch (error) { res.status(409).json({ reason: 'group_correction_refused', error: String(error) }) }
|
|
1802
|
+
})
|
|
1803
|
+
|
|
1804
|
+
// Interval naming cannot reuse raw-chunk selectors. These actions write only
|
|
1805
|
+
// a separate source-bound human sidecar; they never invoke the acoustic producer,
|
|
1806
|
+
// enroll profiles, or rewrite BASE/Markdown/the shared corrections ledger.
|
|
1807
|
+
function intervalGroupScopes(files: MacCallHumanFiles, baseJSON: string, view: MacSpeakerCallView): MacIntervalHumanScope[] {
|
|
1808
|
+
if (view.metadata.humanAnnotationInvalid || view.metadata.intervalHumanRecoveryRequired) throw Error('human annotation requires recovery')
|
|
1809
|
+
const id = JSON.parse(baseJSON).sessionId, paths = macIntervalHumanPaths(files, id)
|
|
1810
|
+
const packetFile = readMacCallHumanFile(paths.packet, 16 * 1024 * 1024, true)
|
|
1811
|
+
let packet: MacSpeakerCallPacket | null = null
|
|
1812
|
+
try { packet = packetFile ? JSON.parse(packetFile.text) : null } catch { /* withdrawn automatic projection */ }
|
|
1813
|
+
return buildMacIntervalHumanScopes({ baseJSON, view, packet,
|
|
1814
|
+
markdown: readMacCallHumanFile(files.meetingPath, 16 * 1024 * 1024)!.text,
|
|
1815
|
+
ledgerJSONL: readMacCallHumanFile(paths.ledger, 8 * 1024 * 1024, true)?.text ?? '',
|
|
1816
|
+
legacyAnnotationJSON: readMacCallHumanFile(paths.legacyAnnotation, 16 * 1024 * 1024, true)?.text ?? null,
|
|
1817
|
+
annotationJSON: readMacCallHumanFile(paths.annotation, 16 * 1024 * 1024, true)?.text ?? null })
|
|
1818
|
+
}
|
|
1819
|
+
const publicIntervalScope = (scope: MacIntervalHumanScope) => ({ kind: scope.kind, reviewBasis: 'exact-hq-words', identityId: scope.identityId, displayLabel: scope.displayLabel, scopeSHA256: scope.scopeSHA256, members: scope.members, fieldCount: scope.fieldCount, ...(scope.selectionRuns ? { selectionRuns: scope.selectionRuns } : {}) })
|
|
1820
|
+
const publicIntervalAnnotation = (view: MacSpeakerCallView) => view.metadata.intervalHumanAnnotation ? {
|
|
1821
|
+
revision: view.metadata.intervalHumanAnnotation.revision, annotationSHA256: view.metadata.intervalHumanAnnotation.annotationSHA256,
|
|
1822
|
+
activeActions: view.metadata.intervalHumanAnnotation.activeActions,
|
|
1823
|
+
} : null
|
|
1824
|
+
function intervalReviewFields(files: MacCallHumanFiles, base: string, view: MacSpeakerCallView) {
|
|
1825
|
+
// Omit entirely for legacy/G2 readers; even an empty new array is a new-policy
|
|
1826
|
+
// signal to Control and must not make every old meeting read-only.
|
|
1827
|
+
const intervalPolicy = intervalPolicyPresent(view)
|
|
1828
|
+
const captureId = JSON.parse(base).sessionId
|
|
1829
|
+
if (!MAC_CAPTURE_ID.test(captureId) || !intervalPolicy && view.metadata.status === 'applied') return {}
|
|
1830
|
+
let originalAvailable = false
|
|
1831
|
+
if (!view.metadata.humanAnnotationInvalid && !view.metadata.intervalHumanRecoveryRequired && !view.metadata.intervalHumanAnnotation?.activeActions.length) {
|
|
1832
|
+
try { originalAvailable = previewOriginalViewTransaction(files, captureId).artifacts.length > 0 } catch { /* Refusal is an explicit unavailable capability. */ }
|
|
1833
|
+
}
|
|
1834
|
+
// An unreadable/withdrawn packet or a state-only remnant still blocks legacy
|
|
1835
|
+
// mutation. Offer its verified archival exit without treating that remnant
|
|
1836
|
+
// as a valid acoustic scope. Unaffected legacy views keep their old tools.
|
|
1837
|
+
if (!intervalPolicy && !originalAvailable) return {}
|
|
1838
|
+
let scopes: MacIntervalHumanScope[] = []
|
|
1839
|
+
if (intervalPolicy) try { scopes = intervalGroupScopes(files, base, view) } catch { /* read-only recovery UI */ }
|
|
1840
|
+
return { intervalGroupScopes: scopes.map(publicIntervalScope), intervalHumanAnnotation: publicIntervalAnnotation(view), intervalHumanRecoveryRequired: view.metadata.intervalHumanRecoveryRequired === true, intervalOriginalViewAvailable: originalAvailable }
|
|
1841
|
+
}
|
|
1842
|
+
function exactIntervalConfirmedWords(base: Record<string, any>, view: MacSpeakerCallView) {
|
|
1843
|
+
if (!view.metadata.intervalHumanAnnotation) return undefined
|
|
1844
|
+
return view.metadata.intervalHumanAnnotation.confirmedWords.map(proof => {
|
|
1845
|
+
const original = base.batchSegments?.[proof.batchIndex]?.speakerWords?.[proof.wordIndex], effective = view.doc.batchSegments?.[proof.batchIndex]?.speakerWords?.[proof.wordIndex]
|
|
1846
|
+
if (!original || createHash('sha256').update(JSON.stringify(original)).digest('hex') !== proof.wordSHA256 || effective?.speaker !== proof.label) throw Error('Exact human word source changed')
|
|
1847
|
+
return { batchIndex: proof.batchIndex, wordIndex: proof.wordIndex, label: proof.label }
|
|
1848
|
+
})
|
|
1849
|
+
}
|
|
1850
|
+
const validSHA = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
|
|
1851
|
+
const intervalBodyKeys = (body: any, keys: string[]) => body && typeof body === 'object' && !Array.isArray(body) && Object.keys(body).length === keys.length && Object.keys(body).every(k => keys.includes(k))
|
|
1852
|
+
router.get('/meeting/:sessionId/speaker-interval-groups', (req, res) => {
|
|
1853
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1854
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1855
|
+
if (!MAC_CAPTURE_ID.test(sessionId)) { res.status(400).json({ reason: 'not_mac_capture' }); return }
|
|
1856
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.query.recordId)
|
|
1857
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1858
|
+
try {
|
|
1859
|
+
const source = callGroupSource(sessionId)
|
|
1860
|
+
if (req.query.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1861
|
+
const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
|
|
1862
|
+
res.json({ sessionId, recordId: source.recordId, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath), ...intervalReviewFields(source.files, base, view), enrollmentAllowed: false })
|
|
1863
|
+
} catch (error) { res.status(409).json({ reason: 'interval_group_review_unavailable', error: String(error) }) }
|
|
1864
|
+
})
|
|
1865
|
+
router.post('/meeting/:sessionId/speaker-interval-groups/recover', (req, res) => {
|
|
1866
|
+
const sessionId = String(req.params.sessionId ?? ''), body = req.body
|
|
1867
|
+
if (!MAC_CAPTURE_ID.test(sessionId) || !intervalBodyKeys(body, ['recordId', 'confirm']) || body.confirm !== true) { res.status(400).json({ reason: 'confirmation_required' }); return }
|
|
1868
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? finalizingRefusal(sessionId)
|
|
1869
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1870
|
+
try {
|
|
1871
|
+
const source = callGroupSource(sessionId)
|
|
1872
|
+
if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1873
|
+
const original = inspectOriginalViewTransaction(source.files, sessionId)
|
|
1874
|
+
if (original.status === 'prepared' || original.status === 'recovery-invalid') recoverOriginalViewTransaction(source.files, sessionId)
|
|
1875
|
+
else recoverMacIntervalHumanAnnotation(source.files, sessionId)
|
|
1876
|
+
res.json({ ok: true, confirmed: true, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
|
|
1877
|
+
} catch (error) { res.status(409).json({ reason: 'interval_group_recovery_refused', error: String(error) }) }
|
|
1878
|
+
})
|
|
1879
|
+
router.post('/meeting/:sessionId/speaker-interval-groups/original', (req, res) => {
|
|
1880
|
+
const sessionId = String(req.params.sessionId ?? ''), body = req.body
|
|
1881
|
+
if (!MAC_CAPTURE_ID.test(sessionId) || !intervalBodyKeys(body, ['recordId', 'expectedRevision', 'confirm']) || body.confirm !== true || !validSHA(body.expectedRevision)) { res.status(400).json({ reason: 'invalid_original_view_confirmation' }); return }
|
|
1882
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
|
|
1883
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1884
|
+
try {
|
|
1885
|
+
const source = callGroupSource(sessionId)
|
|
1886
|
+
if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1887
|
+
const preview = previewOriginalViewTransaction(source.files, sessionId)
|
|
1888
|
+
if (!preview.artifacts.length) throw Error('No active interval view to archive')
|
|
1889
|
+
commitOriginalViewTransaction({ files: source.files, captureId: sessionId, expectedScopeSHA256: preview.scopeSHA256 })
|
|
1890
|
+
res.json({ ok: true, confirmed: true, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
|
|
1891
|
+
} catch (error) { res.status(409).json({ reason: 'interval_original_view_refused', error: String(error) }) }
|
|
1892
|
+
})
|
|
1893
|
+
router.post('/meeting/:sessionId/speaker-interval-groups/undo', (req, res) => {
|
|
1894
|
+
const sessionId = String(req.params.sessionId ?? ''), body = req.body
|
|
1895
|
+
if (!MAC_CAPTURE_ID.test(sessionId) || !intervalBodyKeys(body, ['recordId', 'expectedRevision', 'actionId', 'annotationRevision', 'annotationSHA256', 'confirm']) || body.confirm !== true || !validSHA(body.expectedRevision) || !validSHA(body.annotationSHA256) || !Number.isSafeInteger(body.annotationRevision) || body.annotationRevision < 1 || typeof body.actionId !== 'string' || !/^mac-speaker-call-interval-human-v1:[a-f0-9-]{36}$/.test(body.actionId)) { res.status(400).json({ reason: 'invalid_interval_undo' }); return }
|
|
1896
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
|
|
1897
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1898
|
+
try {
|
|
1899
|
+
const source = callGroupSource(sessionId)
|
|
1900
|
+
if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1901
|
+
const view = readMacSpeakerCallView(source.files.sidecarPath, readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, source.files.meetingPath)
|
|
1902
|
+
if (view.metadata.humanAnnotationInvalid || view.metadata.intervalHumanRecoveryRequired) throw Error('human annotation recovery required')
|
|
1903
|
+
const correction = undoMacIntervalHumanAnnotation({ files: source.files, captureId: sessionId, actionId: body.actionId, revision: body.annotationRevision, annotationSHA256: body.annotationSHA256 })
|
|
1904
|
+
res.json({ ok: true, confirmed: true, correction, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
|
|
1905
|
+
} catch (error) { res.status(409).json({ reason: 'interval_group_undo_refused', error: String(error) }) }
|
|
1906
|
+
})
|
|
1907
|
+
router.post('/meeting/:sessionId/speaker-interval-groups/:identityId/relabel', (req, res) => {
|
|
1908
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1909
|
+
const sessionId = String(req.params.sessionId ?? ''), identityId = String(req.params.identityId ?? ''), body = req.body
|
|
1910
|
+
if (body && typeof body === 'object' && typeof body.to === 'string') body.to = nfc(body.to)
|
|
1911
|
+
const previewOnly = body?.dryRun === true && body?.confirm === undefined, confirmed = body?.confirm === true && body?.dryRun === undefined
|
|
1912
|
+
const keys = ['recordId', 'expectedRevision', 'scopeSHA256', 'memberIds', 'to', previewOnly ? 'dryRun' : 'confirm']
|
|
1913
|
+
if (!MAC_CAPTURE_ID.test(sessionId) || !/^[A-Za-z0-9:_-]{1,128}$/.test(identityId) || !intervalBodyKeys(body, keys) || !previewOnly && !confirmed || !validSHA(body.expectedRevision) || !validSHA(body.scopeSHA256) || !isMacCallHumanName(body.to) || !Array.isArray(body.memberIds) || !body.memberIds.length || body.memberIds.length > 20000 || !body.memberIds.every(validSHA) || new Set(body.memberIds).size !== body.memberIds.length) { res.status(400).json({ reason: 'invalid_interval_selection' }); return }
|
|
1914
|
+
const refusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(body.recordId) ?? sourceRevisionRefusal(sessionId, body) ?? finalizingRefusal(sessionId)
|
|
1915
|
+
if (refusal) { res.status(refusal.status).json(refusal.body); return }
|
|
1916
|
+
try {
|
|
1917
|
+
const source = callGroupSource(sessionId)
|
|
1918
|
+
if (body.recordId !== source.recordId) { res.status(409).json({ reason: 'record_source_mismatch' }); return }
|
|
1919
|
+
const base = readMacCallHumanFile(source.files.sidecarPath, 32 * 1024 * 1024)!.text, view = readMacSpeakerCallView(source.files.sidecarPath, base, source.files.meetingPath)
|
|
1920
|
+
const scope = intervalGroupScopes(source.files, base, view).find(s => s.identityId === identityId && s.scopeSHA256 === body.scopeSHA256)
|
|
1921
|
+
if (!scope) { res.status(409).json({ reason: 'interval_scope_changed' }); return }
|
|
1922
|
+
const selection = previewMacIntervalHumanAnnotation(scope, body.memberIds, body.to)
|
|
1923
|
+
if (previewOnly) { res.json({ preview: { identityId, scopeSHA256: scope.scopeSHA256, ...selection, to: body.to, enrollmentAllowed: false, sourceRevision: body.expectedRevision } }); return }
|
|
1924
|
+
const correction = commitMacIntervalHumanAnnotation({ files: source.files, captureId: sessionId, scope, memberIds: body.memberIds, to: body.to })
|
|
1925
|
+
res.json({ ok: true, confirmed: true, correction, enrollmentAllowed: false, sourceRevision: reviewRevision(source.files.meetingPath, source.files.sidecarPath) })
|
|
1926
|
+
} catch (error) { res.status(409).json({ reason: 'interval_group_correction_refused', error: String(error) }) }
|
|
1927
|
+
})
|
|
1928
|
+
|
|
1593
1929
|
function sourceRevisionRefusal(sessionId: string, body: any): { status: number; body: Record<string, unknown> } | null {
|
|
1594
1930
|
const ops = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
1595
1931
|
if (ops?.mergedScribeConflict?.length) return { status: 409, body: { reason: 'record_source_mismatch', error: markdownUnreadableReason(ops) } }
|
|
@@ -1634,7 +1970,44 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1634
1970
|
} catch (error) { res.status(409).json({ error: String(error) }); }
|
|
1635
1971
|
})
|
|
1636
1972
|
|
|
1637
|
-
|
|
1973
|
+
/** The speaker review of a saved meeting, as GET /speakers answers it. Voice suggestions scope to these same rows. */
|
|
1974
|
+
function speakerReviewFor(input: { sessionId: string; sidecarPath: string; meetingPath: string; baseJSON: string; sidecar: Record<string, unknown>; chunks: unknown; phrasesPerVoice: number }) {
|
|
1975
|
+
const { sessionId, sidecarPath, meetingPath, baseJSON, sidecar, chunks, phrasesPerVoice } = input
|
|
1976
|
+
// Raw capture indices, so a phrase can address its own audio. Position in
|
|
1977
|
+
// `chunks` is NOT the WAV number — see attachRawChunkIndices.
|
|
1978
|
+
const callView = readMacSpeakerCallView(sidecarPath, baseJSON, meetingPath)
|
|
1979
|
+
const withIndices = attachRawChunkIndices(callView.doc.chunks ?? chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
1980
|
+
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1981
|
+
const review = reviewMeetingSpeakers(withIndices, {
|
|
1982
|
+
callViewAnonymousLabels: callView.metadata.groups.map(g => g.label),
|
|
1983
|
+
effectiveWordView: callView.metadata.effectiveWordView,
|
|
1984
|
+
exactConfirmedWords: exactIntervalConfirmedWords(sidecar, callView),
|
|
1985
|
+
owner: getOwnerSpeakerLabel(),
|
|
1986
|
+
// Labels a human already vouched for in this meeting. Without this the
|
|
1987
|
+
// floor re-demotes a confirmed name on every reload, and the reviewer
|
|
1988
|
+
// confirms the same voice forever.
|
|
1989
|
+
confirmed: confirmedLabels(sessionId),
|
|
1990
|
+
confirmedChunks: confirmedChunks(sessionId),
|
|
1991
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
1992
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
1993
|
+
phrasesPerVoice,
|
|
1994
|
+
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
1995
|
+
// max(elapsed) uses the START of the last chunk, which made the final
|
|
1996
|
+
// timeline span end where it began — a 1.5pt sliver labelled "1s" for what
|
|
1997
|
+
// may be a long closing monologue.
|
|
1998
|
+
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
1999
|
+
// Word-level speaker timings from the HQ batch pass, present on 82 of 92
|
|
2000
|
+
// measured sidecars. These give REAL voiced time per speaker; without them
|
|
2001
|
+
// speaking time falls back to capped chunk deltas, which still carry the
|
|
2002
|
+
// sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
|
|
2003
|
+
batchSegments: Array.isArray(callView.doc.batchSegments)
|
|
2004
|
+
? (callView.doc.batchSegments as SpeakerWordSegment[])
|
|
2005
|
+
: undefined,
|
|
2006
|
+
})
|
|
2007
|
+
return { callView, review }
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
router.get('/meeting/:sessionId/speakers', async (req, res) => {
|
|
1638
2011
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1639
2012
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1640
2013
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
@@ -1675,8 +2048,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1675
2048
|
|
|
1676
2049
|
let chunks: unknown
|
|
1677
2050
|
let sidecar: Record<string, unknown>
|
|
2051
|
+
let baseJSON: string
|
|
1678
2052
|
try {
|
|
1679
|
-
|
|
2053
|
+
baseJSON = readFileSync(sidecarPath, 'utf-8')
|
|
2054
|
+
sidecar = JSON.parse(baseJSON) as Record<string, unknown>
|
|
1680
2055
|
chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
1681
2056
|
} catch {
|
|
1682
2057
|
// Defensive: both lookups above already read this sidecar to match the
|
|
@@ -1692,35 +2067,20 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1692
2067
|
return
|
|
1693
2068
|
}
|
|
1694
2069
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
// confirms the same voice forever.
|
|
1704
|
-
confirmed: confirmedLabels(sessionId),
|
|
1705
|
-
confirmedChunks: confirmedChunks(sessionId),
|
|
1706
|
-
machineQualifiedPositions: machineEvidence.positions,
|
|
1707
|
-
machineEvidenceStatus: machineEvidence.status,
|
|
2070
|
+
const meetingPath = operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath
|
|
2071
|
+
const { callView, review } = speakerReviewFor({
|
|
2072
|
+
sessionId,
|
|
2073
|
+
sidecarPath,
|
|
2074
|
+
meetingPath,
|
|
2075
|
+
baseJSON: baseJSON!,
|
|
2076
|
+
sidecar,
|
|
2077
|
+
chunks,
|
|
1708
2078
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
1709
|
-
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
1710
|
-
// max(elapsed) uses the START of the last chunk, which made the final
|
|
1711
|
-
// timeline span end where it began — a 1.5pt sliver labelled "1s" for what
|
|
1712
|
-
// may be a long closing monologue.
|
|
1713
|
-
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
1714
|
-
// Word-level speaker timings from the HQ batch pass, present on 82 of 92
|
|
1715
|
-
// measured sidecars. These give REAL voiced time per speaker; without them
|
|
1716
|
-
// speaking time falls back to capped chunk deltas, which still carry the
|
|
1717
|
-
// sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
|
|
1718
|
-
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
1719
|
-
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
1720
|
-
: undefined,
|
|
1721
2079
|
})
|
|
1722
|
-
|
|
1723
|
-
|
|
2080
|
+
// The rows and the revision that vouches for them are read in one synchronous stretch, BEFORE the await below. A
|
|
2081
|
+
// POST /relabel landing during the await then makes this revision old too, and `expectedRevision` refuses the stale
|
|
2082
|
+
// view. Hashing after the await would pair old rows with a new revision, and that check would pass.
|
|
2083
|
+
const body = {
|
|
1724
2084
|
sessionId,
|
|
1725
2085
|
title,
|
|
1726
2086
|
domain,
|
|
@@ -1728,13 +2088,95 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1728
2088
|
...identity,
|
|
1729
2089
|
speakerPolicy: readSpeakerPolicy(sessionId),
|
|
1730
2090
|
reviewIdentityVersion: 1,
|
|
1731
|
-
sourceRevision: reviewRevision(
|
|
2091
|
+
sourceRevision: reviewRevision(meetingPath, sidecarPath),
|
|
1732
2092
|
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
1733
2093
|
...(saved ? { durationMin: saved.durationMin } : {}),
|
|
1734
2094
|
...review,
|
|
1735
|
-
|
|
2095
|
+
...(isEffectiveMacSpeakerCallView(callView) || callView.metadata.humanAnnotationInvalid ? { mutable: false, mutationBlockedReason: callView.metadata.humanAnnotationInvalid ? 'human_annotation_invalid' : 'derived_call_view_requires_scoped_action' } : {}),
|
|
2096
|
+
speakerCallView: callView.metadata,
|
|
2097
|
+
...intervalReviewFields({ sidecarPath, meetingPath }, baseJSON, callView),
|
|
2098
|
+
...(() => { try { return { groupScopes: callGroupScopes({ sidecarPath, meetingPath }, baseJSON, callView).map(publicCallScope) } } catch { return { groupScopes: [] } } })(),
|
|
2099
|
+
}
|
|
2100
|
+
// 6.96.3: display grouping from this Mac's speaker shadow log, read at request time. No shadow record, no field.
|
|
2101
|
+
// Mac captures only: anything else never yields here. A view that already groups the words stands on its own.
|
|
2102
|
+
const voiceDisplay = MAC_CAPTURE_ID.test(sessionId) && !isEffectiveMacSpeakerCallView(callView) ? await readVoiceDisplay(sessionId, review.voices.map(v => v.label)) : null
|
|
2103
|
+
res.set('Cache-Control', 'private, no-store')
|
|
2104
|
+
res.json(voiceDisplay ? { ...body, voiceDisplay } : body)
|
|
1736
2105
|
})
|
|
1737
2106
|
|
|
2107
|
+
// ── Voice suggestions (6.96.3, lib/mac-voice-suggestions.ts) ───────────────────
|
|
2108
|
+
// POST is the ONLY thing that starts a job. Both answer one JSON state; only a malformed id is a 4xx.
|
|
2109
|
+
// `refused` comes from POST and is not remembered, so GET reads idle, running, done or failed.
|
|
2110
|
+
// Read-only: nothing here or in the job writes a profile, label, sidecar or vector file.
|
|
2111
|
+
function voiceSuggestionSource(sessionId: string): { refused: string } | { sidecarPath: string; meetingPath: string } {
|
|
2112
|
+
if (assertVoiceEvidenceSource([sessionId]) || !MAC_CAPTURE_ID.test(sessionId)) return { refused: 'not_saved_mac_capture' }
|
|
2113
|
+
const operations = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
2114
|
+
const direct = operations ? null : findDirectLibraryMeetingBySessionId(sessionId)
|
|
2115
|
+
const saved = operations || direct ? null : store.findBySessionId(sessionId)
|
|
2116
|
+
if (!operations && !direct && !saved) return { refused: 'not_saved_mac_capture' }
|
|
2117
|
+
return { sidecarPath: operations?.sidecarPath ?? direct?.sidecarPath ?? saved!.sidecarPath, meetingPath: operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath }
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// 6.96.3: name the high-confidence suggestions in one step, and undo that step (lib/mac-voice-naming-batch.ts).
|
|
2121
|
+
registerMacVoiceNamingBatchRoutes(router, { store, captureActive, reviewRevision, callViewMutationRefusal, finalizingRefusal })
|
|
2122
|
+
|
|
2123
|
+
const readVoiceSuggestions: RequestHandler = (req, res) => {
|
|
2124
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
2125
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
|
|
2126
|
+
res.set('Cache-Control', 'private, no-store')
|
|
2127
|
+
// A job in flight answers from memory: Control polls this, and the revision hashes the whole meeting.
|
|
2128
|
+
const running = voiceSuggestions.running(sessionId)
|
|
2129
|
+
if (running) { res.json(running); return }
|
|
2130
|
+
const source = voiceSuggestionSource(sessionId)
|
|
2131
|
+
res.json(voiceSuggestions.read(sessionId, 'refused' in source ? '' : reviewRevision(source.meetingPath, source.sidecarPath)))
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
const startVoiceSuggestions: RequestHandler = (req, res) => {
|
|
2135
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
2136
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) { res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' }); return }
|
|
2137
|
+
res.set('Cache-Control', 'private, no-store')
|
|
2138
|
+
const source = voiceSuggestionSource(sessionId)
|
|
2139
|
+
if ('refused' in source) { res.json(voiceSuggestions.refusal(source.refused, '')); return }
|
|
2140
|
+
const { sidecarPath, meetingPath } = source
|
|
2141
|
+
let baseJSON: string
|
|
2142
|
+
let sidecar: Record<string, unknown>
|
|
2143
|
+
try {
|
|
2144
|
+
baseJSON = readFileSync(sidecarPath, 'utf-8')
|
|
2145
|
+
sidecar = JSON.parse(baseJSON) as Record<string, unknown>
|
|
2146
|
+
if (!Array.isArray(sidecar.chunks)) throw new Error('no chunk array')
|
|
2147
|
+
} catch {
|
|
2148
|
+
res.json(voiceSuggestions.refusal('sidecar_unreadable', ''))
|
|
2149
|
+
return
|
|
2150
|
+
}
|
|
2151
|
+
const sourceRevision = reviewRevision(meetingPath, sidecarPath)
|
|
2152
|
+
// Its labels are not the labels on the audio's turns, so no unit could be chosen honestly.
|
|
2153
|
+
const view = readMacSpeakerCallView(sidecarPath, baseJSON, meetingPath)
|
|
2154
|
+
if (isEffectiveMacSpeakerCallView(view) || view.metadata.humanAnnotationInvalid) { res.json(voiceSuggestions.refusal('derived_call_view', sourceRevision)); return }
|
|
2155
|
+
const entries = voiceSidecarEntries(sidecar)
|
|
2156
|
+
if (!entries) { res.json(voiceSuggestions.refusal('no_index_mapping', sourceRevision)); return }
|
|
2157
|
+
const block = (): VoiceSuggestionBlock | null => {
|
|
2158
|
+
if (captureActive() || voiceSuggestionRecordingLease()) return 'recording_active'
|
|
2159
|
+
if (!maintenanceAdmissionsOpen()) return 'maintenance'
|
|
2160
|
+
if (finalizingRefusal(sessionId)) return 'meeting_finalizing'
|
|
2161
|
+
return null
|
|
2162
|
+
}
|
|
2163
|
+
res.json(voiceSuggestions.start({
|
|
2164
|
+
sessionId,
|
|
2165
|
+
sourceRevision,
|
|
2166
|
+
entries,
|
|
2167
|
+
block,
|
|
2168
|
+
pause: () => voiceSuggestionPauseLease(),
|
|
2169
|
+
loadScopes: async () => {
|
|
2170
|
+
const { review } = speakerReviewFor({ sessionId, sidecarPath, meetingPath, baseJSON, sidecar, chunks: sidecar.chunks, phrasesPerVoice: 1 })
|
|
2171
|
+
const labels = review.voices.map(v => v.label)
|
|
2172
|
+
return voiceDisplayScopes(await readVoiceDisplay(sessionId, labels), labels)
|
|
2173
|
+
},
|
|
2174
|
+
}))
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
router.get('/meeting/:sessionId/voice-suggestions', readVoiceSuggestions)
|
|
2178
|
+
router.post('/meeting/:sessionId/voice-suggestions', startVoiceSuggestions)
|
|
2179
|
+
|
|
1738
2180
|
|
|
1739
2181
|
// ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
|
|
1740
2182
|
//
|
|
@@ -1792,8 +2234,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1792
2234
|
const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
1793
2235
|
|
|
1794
2236
|
let sidecar: Record<string, unknown>
|
|
2237
|
+
let baseJSON: string
|
|
1795
2238
|
try {
|
|
1796
|
-
|
|
2239
|
+
baseJSON = readFileSync(sidecarPath, 'utf-8')
|
|
2240
|
+
sidecar = (JSON.parse(baseJSON) ?? {}) as Record<string, unknown>
|
|
1797
2241
|
} catch {
|
|
1798
2242
|
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
1799
2243
|
return
|
|
@@ -1807,10 +2251,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1807
2251
|
res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
|
|
1808
2252
|
return
|
|
1809
2253
|
}
|
|
1810
|
-
const
|
|
2254
|
+
const callView = readMacSpeakerCallView(sidecarPath, baseJSON!, mdPath)
|
|
2255
|
+
const chunks = (callView.doc.chunks ?? rawChunks) as ReviewChunk[]
|
|
1811
2256
|
|
|
1812
2257
|
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1813
2258
|
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
2259
|
+
callViewAnonymousLabels: callView.metadata.groups.map(g => g.label),
|
|
2260
|
+
effectiveWordView: callView.metadata.effectiveWordView,
|
|
2261
|
+
exactConfirmedWords: exactIntervalConfirmedWords(sidecar, callView),
|
|
1814
2262
|
owner: getOwnerSpeakerLabel(),
|
|
1815
2263
|
confirmed: confirmedLabels(sessionId),
|
|
1816
2264
|
confirmedChunks: confirmedChunks(sessionId),
|
|
@@ -1820,8 +2268,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1820
2268
|
// discarded 48 transcript excerpts on a 16-voice meeting.
|
|
1821
2269
|
phrasesPerVoice: 1,
|
|
1822
2270
|
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
1823
|
-
batchSegments: Array.isArray(
|
|
1824
|
-
? (
|
|
2271
|
+
batchSegments: Array.isArray(callView.doc.batchSegments)
|
|
2272
|
+
? (callView.doc.batchSegments as SpeakerWordSegment[])
|
|
1825
2273
|
: undefined,
|
|
1826
2274
|
})
|
|
1827
2275
|
|
|
@@ -1841,12 +2289,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1841
2289
|
confirmedByHuman: v.confirmedByHuman,
|
|
1842
2290
|
machineQualified: v.machineQualified,
|
|
1843
2291
|
machineQualifiedSegments: v.machineQualifiedSegments,
|
|
2292
|
+
...(v.kind ? { kind: v.kind, displayName: v.displayName, identityId: v.identityId } : {}),
|
|
1844
2293
|
}))
|
|
1845
2294
|
|
|
1846
2295
|
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
1847
2296
|
// meeting, and a reviewer would rather have who-spoke than a 404.
|
|
1848
2297
|
let markdown = ''
|
|
1849
|
-
try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
2298
|
+
try { markdown = callView.markdown ?? readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
1850
2299
|
const scribe = parseScribe(markdown)
|
|
1851
2300
|
|
|
1852
2301
|
const date = meetingDate(sidecar.startTime)
|
|
@@ -1905,6 +2354,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1905
2354
|
date,
|
|
1906
2355
|
durationMin,
|
|
1907
2356
|
scribeAvailable: markdown.length > 0,
|
|
2357
|
+
speakerCallView: callView.metadata,
|
|
2358
|
+
...(review.effectiveSpeakerSummary ? { effectiveSpeakerSummary: review.effectiveSpeakerSummary } : {}),
|
|
1908
2359
|
attendees,
|
|
1909
2360
|
speakingTimeSource: review.speakingTimeSource,
|
|
1910
2361
|
voicedMs: review.voicedMs,
|
|
@@ -1920,6 +2371,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1920
2371
|
capturedChars: clip.capturedChars,
|
|
1921
2372
|
domain: clip.domain,
|
|
1922
2373
|
...identity,
|
|
2374
|
+
...(isEffectiveMacSpeakerCallView(callView) || callView.metadata.humanAnnotationInvalid ? { mutable: false, mutationBlockedReason: callView.metadata.humanAnnotationInvalid ? 'human_annotation_invalid' : 'derived_call_view_requires_scoped_action' } : {}),
|
|
1923
2375
|
reviewIdentityVersion: 1,
|
|
1924
2376
|
sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
|
|
1925
2377
|
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
@@ -1971,7 +2423,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1971
2423
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1972
2424
|
return
|
|
1973
2425
|
}
|
|
1974
|
-
const speaker =
|
|
2426
|
+
const speaker = bodyName(req.body?.speaker).trim()
|
|
1975
2427
|
if (!speaker) {
|
|
1976
2428
|
res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
|
|
1977
2429
|
return
|
|
@@ -1979,7 +2431,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1979
2431
|
// Enrolment writes to the voice store, so the same two gates as the other
|
|
1980
2432
|
// corrections: the session must be a capture, and the record aimed at must
|
|
1981
2433
|
// not be a derived one.
|
|
1982
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
2434
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
1983
2435
|
if (evidenceRefusal) {
|
|
1984
2436
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1985
2437
|
return
|
|
@@ -2004,7 +2456,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2004
2456
|
}
|
|
2005
2457
|
|
|
2006
2458
|
// Only rows that ACTUALLY landed, and only those that named this speaker.
|
|
2007
|
-
const rows = appliedCorrections(sessionId).filter(r => r.to
|
|
2459
|
+
const rows = appliedCorrections(sessionId).filter(r => sameSpeakerName(r.to, speaker) && r.chunks.length > 0)
|
|
2008
2460
|
if (rows.length === 0) {
|
|
2009
2461
|
res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
|
|
2010
2462
|
return
|
|
@@ -2045,14 +2497,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2045
2497
|
// A merged G2 session is NOT refused here: it is a real capture with real
|
|
2046
2498
|
// audio, and relabelling it is how its profile gets better. Only an imported
|
|
2047
2499
|
// or derived IDENTITY is refused.
|
|
2048
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
2500
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
2049
2501
|
if (evidenceRefusal) {
|
|
2050
2502
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2051
2503
|
return
|
|
2052
2504
|
}
|
|
2053
2505
|
|
|
2054
|
-
const from =
|
|
2055
|
-
const to =
|
|
2506
|
+
const from = bodyName(req.body?.from)
|
|
2507
|
+
const to = bodyName(req.body?.to)
|
|
2056
2508
|
const chunks = Array.isArray(req.body?.chunks)
|
|
2057
2509
|
? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
|
|
2058
2510
|
: []
|
|
@@ -2163,7 +2615,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2163
2615
|
// Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
|
|
2164
2616
|
let sidecarAfter: string | null = null
|
|
2165
2617
|
try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
|
|
2166
|
-
const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
|
|
2618
|
+
const revisionAfter = callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
2167
2619
|
if (sidecarAfter !== sidecarRaw || revisionAfter) {
|
|
2168
2620
|
res.status(409).json({ error: 'The meeting changed while the voice was being read. Reopen it and try again; nothing was changed', reason: 'meeting_changed', retryable: true })
|
|
2169
2621
|
return
|
|
@@ -2296,6 +2748,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2296
2748
|
return
|
|
2297
2749
|
}
|
|
2298
2750
|
|
|
2751
|
+
const viewBeforeWrite = callViewMutationRefusal(sessionId)
|
|
2752
|
+
if (viewBeforeWrite) { res.status(viewBeforeWrite.status).json(viewBeforeWrite.body); return }
|
|
2299
2753
|
const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
|
|
2300
2754
|
const at = new Date().toISOString()
|
|
2301
2755
|
// Intent first. If this cannot be written, nothing is mutated — an
|
|
@@ -2400,12 +2854,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2400
2854
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
2401
2855
|
return
|
|
2402
2856
|
}
|
|
2403
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
2857
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
2404
2858
|
if (evidenceRefusal) {
|
|
2405
2859
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2406
2860
|
return
|
|
2407
2861
|
}
|
|
2408
|
-
const label =
|
|
2862
|
+
const label = bodyName(req.body?.label)
|
|
2409
2863
|
const bad = invalidLabelReason(label)
|
|
2410
2864
|
if (bad) {
|
|
2411
2865
|
res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
|
|
@@ -2453,7 +2907,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2453
2907
|
const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
2454
2908
|
const rows = Array.isArray(doc.chunks) ? doc.chunks : []
|
|
2455
2909
|
carried = rows.filter(r => r && typeof r === 'object'
|
|
2456
|
-
&& (r as Record<string, unknown>).speaker
|
|
2910
|
+
&& sameSpeakerName((r as Record<string, unknown>).speaker, label)).length
|
|
2457
2911
|
} catch {
|
|
2458
2912
|
res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
|
|
2459
2913
|
return
|
|
@@ -2491,12 +2945,12 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2491
2945
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
2492
2946
|
return
|
|
2493
2947
|
}
|
|
2494
|
-
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
2948
|
+
const evidenceRefusal = assertVoiceEvidenceSource([sessionId]) ?? refuseDerivedMutation(req.body?.recordId) ?? callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body) ?? finalizingRefusal(sessionId)
|
|
2495
2949
|
if (evidenceRefusal) {
|
|
2496
2950
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2497
2951
|
return
|
|
2498
2952
|
}
|
|
2499
|
-
const from =
|
|
2953
|
+
const from = bodyName(req.body?.from)
|
|
2500
2954
|
const bad = invalidLabelReason(from)
|
|
2501
2955
|
if (bad) {
|
|
2502
2956
|
res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
|
|
@@ -2572,7 +3026,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2572
3026
|
if (Array.isArray(parsed.chunks)) {
|
|
2573
3027
|
for (const c of parsed.chunks) {
|
|
2574
3028
|
const sp = (c as { speaker?: unknown } | null)?.speaker
|
|
2575
|
-
if (typeof sp === 'string' && !existingSpeakers.
|
|
3029
|
+
if (typeof sp === 'string' && !existingSpeakers.some(x => sameSpeakerName(x, sp))) existingSpeakers.push(sp)
|
|
2576
3030
|
}
|
|
2577
3031
|
}
|
|
2578
3032
|
} catch { /* the relabel below reports a corrupt sidecar */ }
|
|
@@ -2585,7 +3039,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2585
3039
|
|
|
2586
3040
|
// What this would remove from the profile, and what it CANNOT reach.
|
|
2587
3041
|
// readVoiceProfiles returns a ProfileStore, not an array.
|
|
2588
|
-
const profile = readVoiceProfiles().profiles.find(p => p.name
|
|
3042
|
+
const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, from))
|
|
2589
3043
|
const profileSources: Array<string | undefined> = profile
|
|
2590
3044
|
? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
|
|
2591
3045
|
: []
|