@gotcos/glasses-server 6.94.4 → 6.98.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 +147 -0
- package/README.md +52 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +19 -1
- 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/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 +36 -0
- package/server/lib/job-audit-caller.ts +20 -0
- 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 +453 -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 +570 -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 +44 -26
- 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-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-speaker-review.ts +182 -17
- 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/speaker-shadow-decide.ts +503 -0
- package/server/lib/speaker-shadow-models.ts +150 -0
- package/server/lib/speaker-shadow-numeric.ts +143 -0
- 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 +113 -0
- package/server/lib/speaker-shadow-timeline.ts +210 -0
- package/server/lib/speaker-shadow-worker.ts +312 -0
- package/server/lib/speaker-shadow.ts +788 -0
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +8 -4
- 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/health.ts +5 -0
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +506 -49
- package/server/routes/speaker-shadow.ts +79 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +3 -0
- 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,9 +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'
|
|
6
|
-
import { bodyName, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
12
|
+
import { bodyName, nfc, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
7
13
|
import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
8
14
|
// POST /api/meeting/save — finalize an existing transcribe-stream session into
|
|
9
15
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
@@ -13,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
|
|
|
13
19
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
14
20
|
import { basename, resolve } from 'node:path'
|
|
15
21
|
import { createHash } from 'node:crypto'
|
|
16
|
-
import { Router } from 'express'
|
|
22
|
+
import { Router, type RequestHandler } from 'express'
|
|
17
23
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
18
24
|
import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
|
|
19
25
|
import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
|
|
@@ -48,6 +54,9 @@ import {
|
|
|
48
54
|
import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
49
55
|
import { enrolNamedVoice } from '../lib/meeting-relabel-enrolment.js'
|
|
50
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'
|
|
51
60
|
import { isMacCaptureSidecar, isVoiceLabel } from '../lib/mac-voice-labels.js'
|
|
52
61
|
|
|
53
62
|
function requireMacRecordingForSave(sessionId: string, expectedChunkCount: unknown): void {
|
|
@@ -195,15 +204,20 @@ import {
|
|
|
195
204
|
} from '../lib/g2-ops-handoff.js'
|
|
196
205
|
import {
|
|
197
206
|
canonicalFinalizationIsComplete,
|
|
207
|
+
finalizationStop,
|
|
198
208
|
MeetingFinalizationJobStore,
|
|
199
209
|
markCanonicalFinalizationState,
|
|
200
210
|
readFinalizationChunkEntries,
|
|
211
|
+
stoppedFinalizationRetryDue,
|
|
201
212
|
type MeetingFinalizationJob,
|
|
202
213
|
} from '../lib/meeting-finalization-jobs.js'
|
|
203
214
|
import { enrichStandaloneMeeting } from '../lib/meeting-summary-persistence.js'
|
|
204
215
|
import { bootResumeGate, type BootResumeGate } from '../lib/boot-resume.js'
|
|
216
|
+
import { managedServerVersion } from '../lib/managed-runtime.js'
|
|
205
217
|
import { recordFinalPassSkip, runNemotronFinalPass, type FinalPassOptions, type FinalPassOutcome } from '../lib/nemotron-final.js'
|
|
206
218
|
import { runTranscriptCleanup, type CleanupOutcome } from '../lib/transcript-cleanup.js'
|
|
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
|
|
|
@@ -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,6 +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)
|
|
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 })
|
|
495
547
|
// The capture is finished and its files are durable, so the merge engine may score it.
|
|
496
548
|
// Fire-and-forget by contract: the runner queues, defers under a drain or a live
|
|
497
549
|
// capture, and never throws back at the finalization path.
|
|
@@ -509,6 +561,9 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
|
|
|
509
561
|
}
|
|
510
562
|
}).catch(error => {
|
|
511
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
|
|
512
567
|
try {
|
|
513
568
|
runtime.finalizationJobs.save({
|
|
514
569
|
sessionId: current.sessionId,
|
|
@@ -519,14 +574,14 @@ export function scheduleFinalizationJob(job: MeetingFinalizationJob, runtime: Fi
|
|
|
519
574
|
phase: current.phase,
|
|
520
575
|
claimPending: current.claimPending,
|
|
521
576
|
lastError: error instanceof Error ? error.message : String(error),
|
|
577
|
+
...(outOfRetries ? { stopped: { at: new Date().toISOString(), reason: 'retries_exhausted' as const, version: managedServerVersion() } } : {}),
|
|
522
578
|
})
|
|
523
579
|
} catch { /* keep the original durable job if error annotation fails */ }
|
|
524
580
|
console.error(
|
|
525
581
|
`[meeting/save] Durable finalization retained for retry (${job.sessionId}): `
|
|
526
582
|
+ `${error instanceof Error ? error.message : String(error)}`,
|
|
527
583
|
)
|
|
528
|
-
|
|
529
|
-
if (retryCount < 2) {
|
|
584
|
+
if (!outOfRetries) {
|
|
530
585
|
finalizationRetryCounts.set(key, retryCount + 1)
|
|
531
586
|
const retryRetained = () => {
|
|
532
587
|
if (!maintenanceAdmissionsOpen()) {
|
|
@@ -555,6 +610,8 @@ export function resumeMeetingFinalizationJobs(options: {
|
|
|
555
610
|
gate?: BootResumeGate
|
|
556
611
|
finalizationJobs?: MeetingFinalizationJobStore
|
|
557
612
|
schedule?: (job: MeetingFinalizationJob) => void
|
|
613
|
+
now?: number
|
|
614
|
+
version?: string | null
|
|
558
615
|
} = {}): void {
|
|
559
616
|
const finalizationJobs = options.finalizationJobs ?? new MeetingFinalizationJobStore()
|
|
560
617
|
finalizationJobs.reconcileCanonicalSidecars()
|
|
@@ -564,11 +621,25 @@ export function resumeMeetingFinalizationJobs(options: {
|
|
|
564
621
|
runBatch: runMeetingBatchPipeline,
|
|
565
622
|
scheduleBackground: task => { void task },
|
|
566
623
|
}))
|
|
624
|
+
const now = options.now ?? Date.now()
|
|
625
|
+
const version = options.version === undefined ? managedServerVersion() : options.version
|
|
567
626
|
for (const job of finalizationJobs.list()) {
|
|
568
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
|
|
569
633
|
// 6.78.0: counted (durably) BEFORE it runs, so a job that kills the process is parked
|
|
570
634
|
// after RESTART_LIMIT crashed boots. A parked job is skipped and stays in its store.
|
|
571
|
-
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)
|
|
572
643
|
schedule(job)
|
|
573
644
|
}
|
|
574
645
|
}
|
|
@@ -731,6 +802,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
731
802
|
const finalDiarize = deps.finalDiarize ?? runNemotronFinalPass
|
|
732
803
|
const qualifiedProfilePass = deps.qualifiedProfilePass ?? runQualifiedMacProfilePass
|
|
733
804
|
const transcriptCleanup = deps.transcriptCleanup
|
|
805
|
+
const voiceSuggestions = deps.voiceSuggestions ?? getVoiceSuggestionRunner()
|
|
734
806
|
const router = Router()
|
|
735
807
|
const captureActive = deps.captureActive ?? (() => getTranscriptionSessionLiveness().live > 0)
|
|
736
808
|
const savingSessions = new Set<string>()
|
|
@@ -1575,18 +1647,285 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1575
1647
|
// Keyed on sessionId so it can reuse the store's traversal-hardened readers
|
|
1576
1648
|
function reviewRevision(meetingPath: string, sidecarPath: string): string {
|
|
1577
1649
|
const hash = createHash('sha256')
|
|
1650
|
+
let sidecarJSON: string | null = null
|
|
1578
1651
|
for (const path of [meetingPath, sidecarPath]) {
|
|
1579
1652
|
let text: string | null = null
|
|
1580
1653
|
try { text = readFileSync(path, 'utf-8') } catch { /* missing content is part of the revision */ }
|
|
1581
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
|
+
}
|
|
1582
1675
|
}
|
|
1583
1676
|
return hash.digest('hex')
|
|
1584
1677
|
}
|
|
1585
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
|
+
|
|
1586
1703
|
function selectedReadMatches(requested: unknown, sessionId: string, actual: string): boolean {
|
|
1587
1704
|
return requestedRecordMatches(requested, actual) || blendedRecordFor(requested, sessionId) !== undefined
|
|
1588
1705
|
}
|
|
1589
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
|
+
|
|
1590
1929
|
function sourceRevisionRefusal(sessionId: string, body: any): { status: number; body: Record<string, unknown> } | null {
|
|
1591
1930
|
const ops = cosOperationsMeetingsConfigured() ? findCosOperationsMeetingBySessionId(sessionId) : null
|
|
1592
1931
|
if (ops?.mergedScribeConflict?.length) return { status: 409, body: { reason: 'record_source_mismatch', error: markdownUnreadableReason(ops) } }
|
|
@@ -1631,7 +1970,44 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1631
1970
|
} catch (error) { res.status(409).json({ error: String(error) }); }
|
|
1632
1971
|
})
|
|
1633
1972
|
|
|
1634
|
-
|
|
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) => {
|
|
1635
2011
|
const sessionId = String(req.params.sessionId ?? '')
|
|
1636
2012
|
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1637
2013
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
@@ -1672,8 +2048,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1672
2048
|
|
|
1673
2049
|
let chunks: unknown
|
|
1674
2050
|
let sidecar: Record<string, unknown>
|
|
2051
|
+
let baseJSON: string
|
|
1675
2052
|
try {
|
|
1676
|
-
|
|
2053
|
+
baseJSON = readFileSync(sidecarPath, 'utf-8')
|
|
2054
|
+
sidecar = JSON.parse(baseJSON) as Record<string, unknown>
|
|
1677
2055
|
chunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
1678
2056
|
} catch {
|
|
1679
2057
|
// Defensive: both lookups above already read this sidecar to match the
|
|
@@ -1689,35 +2067,20 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1689
2067
|
return
|
|
1690
2068
|
}
|
|
1691
2069
|
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
// confirms the same voice forever.
|
|
1701
|
-
confirmed: confirmedLabels(sessionId),
|
|
1702
|
-
confirmedChunks: confirmedChunks(sessionId),
|
|
1703
|
-
machineQualifiedPositions: machineEvidence.positions,
|
|
1704
|
-
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,
|
|
1705
2078
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
1706
|
-
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
1707
|
-
// max(elapsed) uses the START of the last chunk, which made the final
|
|
1708
|
-
// timeline span end where it began — a 1.5pt sliver labelled "1s" for what
|
|
1709
|
-
// may be a long closing monologue.
|
|
1710
|
-
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
1711
|
-
// Word-level speaker timings from the HQ batch pass, present on 82 of 92
|
|
1712
|
-
// measured sidecars. These give REAL voiced time per speaker; without them
|
|
1713
|
-
// speaking time falls back to capped chunk deltas, which still carry the
|
|
1714
|
-
// sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
|
|
1715
|
-
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
1716
|
-
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
1717
|
-
: undefined,
|
|
1718
2079
|
})
|
|
1719
|
-
|
|
1720
|
-
|
|
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 = {
|
|
1721
2084
|
sessionId,
|
|
1722
2085
|
title,
|
|
1723
2086
|
domain,
|
|
@@ -1725,13 +2088,95 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1725
2088
|
...identity,
|
|
1726
2089
|
speakerPolicy: readSpeakerPolicy(sessionId),
|
|
1727
2090
|
reviewIdentityVersion: 1,
|
|
1728
|
-
sourceRevision: reviewRevision(
|
|
2091
|
+
sourceRevision: reviewRevision(meetingPath, sidecarPath),
|
|
1729
2092
|
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
1730
2093
|
...(saved ? { durationMin: saved.durationMin } : {}),
|
|
1731
2094
|
...review,
|
|
1732
|
-
|
|
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)
|
|
1733
2105
|
})
|
|
1734
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
|
+
|
|
1735
2180
|
|
|
1736
2181
|
// ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
|
|
1737
2182
|
//
|
|
@@ -1789,8 +2234,10 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1789
2234
|
const mdPath = operations?.meetingPath ?? direct?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
1790
2235
|
|
|
1791
2236
|
let sidecar: Record<string, unknown>
|
|
2237
|
+
let baseJSON: string
|
|
1792
2238
|
try {
|
|
1793
|
-
|
|
2239
|
+
baseJSON = readFileSync(sidecarPath, 'utf-8')
|
|
2240
|
+
sidecar = (JSON.parse(baseJSON) ?? {}) as Record<string, unknown>
|
|
1794
2241
|
} catch {
|
|
1795
2242
|
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
1796
2243
|
return
|
|
@@ -1804,10 +2251,14 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1804
2251
|
res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
|
|
1805
2252
|
return
|
|
1806
2253
|
}
|
|
1807
|
-
const
|
|
2254
|
+
const callView = readMacSpeakerCallView(sidecarPath, baseJSON!, mdPath)
|
|
2255
|
+
const chunks = (callView.doc.chunks ?? rawChunks) as ReviewChunk[]
|
|
1808
2256
|
|
|
1809
2257
|
const machineEvidence = readMacSpeakerMachineEvidence(sidecar)
|
|
1810
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),
|
|
1811
2262
|
owner: getOwnerSpeakerLabel(),
|
|
1812
2263
|
confirmed: confirmedLabels(sessionId),
|
|
1813
2264
|
confirmedChunks: confirmedChunks(sessionId),
|
|
@@ -1817,8 +2268,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1817
2268
|
// discarded 48 transcript excerpts on a 16-voice meeting.
|
|
1818
2269
|
phrasesPerVoice: 1,
|
|
1819
2270
|
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
1820
|
-
batchSegments: Array.isArray(
|
|
1821
|
-
? (
|
|
2271
|
+
batchSegments: Array.isArray(callView.doc.batchSegments)
|
|
2272
|
+
? (callView.doc.batchSegments as SpeakerWordSegment[])
|
|
1822
2273
|
: undefined,
|
|
1823
2274
|
})
|
|
1824
2275
|
|
|
@@ -1838,12 +2289,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1838
2289
|
confirmedByHuman: v.confirmedByHuman,
|
|
1839
2290
|
machineQualified: v.machineQualified,
|
|
1840
2291
|
machineQualifiedSegments: v.machineQualifiedSegments,
|
|
2292
|
+
...(v.kind ? { kind: v.kind, displayName: v.displayName, identityId: v.identityId } : {}),
|
|
1841
2293
|
}))
|
|
1842
2294
|
|
|
1843
2295
|
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
1844
2296
|
// meeting, and a reviewer would rather have who-spoke than a 404.
|
|
1845
2297
|
let markdown = ''
|
|
1846
|
-
try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
2298
|
+
try { markdown = callView.markdown ?? readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
1847
2299
|
const scribe = parseScribe(markdown)
|
|
1848
2300
|
|
|
1849
2301
|
const date = meetingDate(sidecar.startTime)
|
|
@@ -1902,6 +2354,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1902
2354
|
date,
|
|
1903
2355
|
durationMin,
|
|
1904
2356
|
scribeAvailable: markdown.length > 0,
|
|
2357
|
+
speakerCallView: callView.metadata,
|
|
2358
|
+
...(review.effectiveSpeakerSummary ? { effectiveSpeakerSummary: review.effectiveSpeakerSummary } : {}),
|
|
1905
2359
|
attendees,
|
|
1906
2360
|
speakingTimeSource: review.speakingTimeSource,
|
|
1907
2361
|
voicedMs: review.voicedMs,
|
|
@@ -1917,6 +2371,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1917
2371
|
capturedChars: clip.capturedChars,
|
|
1918
2372
|
domain: clip.domain,
|
|
1919
2373
|
...identity,
|
|
2374
|
+
...(isEffectiveMacSpeakerCallView(callView) || callView.metadata.humanAnnotationInvalid ? { mutable: false, mutationBlockedReason: callView.metadata.humanAnnotationInvalid ? 'human_annotation_invalid' : 'derived_call_view_requires_scoped_action' } : {}),
|
|
1920
2375
|
reviewIdentityVersion: 1,
|
|
1921
2376
|
sourceRevision: reviewRevision(operations?.meetingPath ?? direct?.meetingPath ?? saved!.filepath, sidecarPath),
|
|
1922
2377
|
...(operations?.mergedScribeConflict?.length ? { mutable: false, identityConflict: operations.mergedScribeConflict } : {}),
|
|
@@ -1976,7 +2431,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1976
2431
|
// Enrolment writes to the voice store, so the same two gates as the other
|
|
1977
2432
|
// corrections: the session must be a capture, and the record aimed at must
|
|
1978
2433
|
// not be a derived one.
|
|
1979
|
-
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)
|
|
1980
2435
|
if (evidenceRefusal) {
|
|
1981
2436
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
1982
2437
|
return
|
|
@@ -2042,7 +2497,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2042
2497
|
// A merged G2 session is NOT refused here: it is a real capture with real
|
|
2043
2498
|
// audio, and relabelling it is how its profile gets better. Only an imported
|
|
2044
2499
|
// or derived IDENTITY is refused.
|
|
2045
|
-
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)
|
|
2046
2501
|
if (evidenceRefusal) {
|
|
2047
2502
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2048
2503
|
return
|
|
@@ -2160,7 +2615,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2160
2615
|
// Other requests ran while the plan yielded. Nothing may have moved: the meeting's bytes, the caller's revision, or a new capture.
|
|
2161
2616
|
let sidecarAfter: string | null = null
|
|
2162
2617
|
try { sidecarAfter = readFileSync(sidecarPath, 'utf-8') } catch { sidecarAfter = null }
|
|
2163
|
-
const revisionAfter = sourceRevisionRefusal(sessionId, req.body)
|
|
2618
|
+
const revisionAfter = callViewMutationRefusal(sessionId) ?? sourceRevisionRefusal(sessionId, req.body)
|
|
2164
2619
|
if (sidecarAfter !== sidecarRaw || revisionAfter) {
|
|
2165
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 })
|
|
2166
2621
|
return
|
|
@@ -2293,6 +2748,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2293
2748
|
return
|
|
2294
2749
|
}
|
|
2295
2750
|
|
|
2751
|
+
const viewBeforeWrite = callViewMutationRefusal(sessionId)
|
|
2752
|
+
if (viewBeforeWrite) { res.status(viewBeforeWrite.status).json(viewBeforeWrite.body); return }
|
|
2296
2753
|
const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
|
|
2297
2754
|
const at = new Date().toISOString()
|
|
2298
2755
|
// Intent first. If this cannot be written, nothing is mutated — an
|
|
@@ -2397,7 +2854,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2397
2854
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
2398
2855
|
return
|
|
2399
2856
|
}
|
|
2400
|
-
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)
|
|
2401
2858
|
if (evidenceRefusal) {
|
|
2402
2859
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2403
2860
|
return
|
|
@@ -2488,7 +2945,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2488
2945
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
2489
2946
|
return
|
|
2490
2947
|
}
|
|
2491
|
-
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)
|
|
2492
2949
|
if (evidenceRefusal) {
|
|
2493
2950
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2494
2951
|
return
|