@gotcos/glasses-server 6.21.14 → 6.21.18
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 +167 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- package/server/lib/embedding-eviction.ts +133 -0
- package/server/lib/meeting-audio-archive.ts +314 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +279 -0
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +19 -1
- package/server/routes/meeting.ts +508 -2
- package/server/routes/transcribe-stream.ts +59 -0
- package/server/routes/voice.ts +91 -2
package/server/routes/meeting.ts
CHANGED
|
@@ -7,6 +7,44 @@ import { resolve } from 'node:path'
|
|
|
7
7
|
import { Router } from 'express'
|
|
8
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
9
9
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
10
|
+
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
11
|
+
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
|
+
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
|
+
import {
|
|
14
|
+
listMeetingAudioChunks,
|
|
15
|
+
meetingAudioChunkPath,
|
|
16
|
+
meetingAudioRetentionDays,
|
|
17
|
+
} from '../lib/meeting-audio-archive.js'
|
|
18
|
+
import { readVoiceProfiles, retractEmbeddingsBySource } from '../lib/speaker-embeddings.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The label a de-attributed voice takes, numbered within its meeting.
|
|
22
|
+
*
|
|
23
|
+
* NOT a shared `Ext`. De-attributing to one label folded every corrected voice
|
|
24
|
+
* into a single row — on the 2026-08-06 Ditto meeting Miles named five wrong
|
|
25
|
+
* attributions, and collapsing them would have destroyed his ability to tell
|
|
26
|
+
* those five voices apart, which is precisely what he needs playback for next.
|
|
27
|
+
*
|
|
28
|
+
* `Unidentified N` is prefix-matched by isUnattributed(), so the review panel
|
|
29
|
+
* treats the row as unnamed and autoEnroll skips it — a de-attributed stretch
|
|
30
|
+
* cannot re-poison a profile — while staying separable.
|
|
31
|
+
*/
|
|
32
|
+
function nextDeattributedLabel(existing: string[]): string {
|
|
33
|
+
const used = new Set(
|
|
34
|
+
existing
|
|
35
|
+
.map(s => new RegExp(`^${DEATTRIBUTED_PREFIX} (\\d+)$`).exec(s)?.[1])
|
|
36
|
+
.filter((n): n is string => Boolean(n))
|
|
37
|
+
.map(Number),
|
|
38
|
+
)
|
|
39
|
+
let n = 1
|
|
40
|
+
while (used.has(n)) n++
|
|
41
|
+
return `${DEATTRIBUTED_PREFIX} ${n}`
|
|
42
|
+
}
|
|
43
|
+
import {
|
|
44
|
+
invalidLabelReason,
|
|
45
|
+
relabelMeetingMarkdown,
|
|
46
|
+
relabelSidecarJson,
|
|
47
|
+
} from '../lib/meeting-relabel.js'
|
|
10
48
|
import {
|
|
11
49
|
getMeetingStore,
|
|
12
50
|
MeetingStore,
|
|
@@ -69,7 +107,13 @@ import {
|
|
|
69
107
|
findCosOperationsMeetingBySessionId,
|
|
70
108
|
} from '../lib/cos-operations-meetings.js'
|
|
71
109
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
72
|
-
import {
|
|
110
|
+
import {
|
|
111
|
+
DEATTRIBUTED_PREFIX,
|
|
112
|
+
attachRawChunkIndices,
|
|
113
|
+
isUnattributed,
|
|
114
|
+
reviewMeetingSpeakers,
|
|
115
|
+
type ReviewChunk,
|
|
116
|
+
} from '../lib/meeting-speaker-review.js'
|
|
73
117
|
import {
|
|
74
118
|
acquireMaintenanceWork,
|
|
75
119
|
maintenanceAdmissionsOpen,
|
|
@@ -669,9 +713,18 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
669
713
|
return
|
|
670
714
|
}
|
|
671
715
|
|
|
672
|
-
|
|
716
|
+
// Raw capture indices, so a phrase can address its own audio. Position in
|
|
717
|
+
// `chunks` is NOT the WAV number — see attachRawChunkIndices.
|
|
718
|
+
const sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
|
|
719
|
+
const withIndices = attachRawChunkIndices(chunks as ReviewChunk[], sidecar.chunkEntries)
|
|
720
|
+
const review = reviewMeetingSpeakers(withIndices, {
|
|
673
721
|
owner: getOwnerSpeakerLabel(),
|
|
674
722
|
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
723
|
+
// The sidecar's own durationMs is the meeting's true end. Deriving it from
|
|
724
|
+
// max(elapsed) uses the START of the last chunk, which made the final
|
|
725
|
+
// timeline span end where it began — a 1.5pt sliver labelled "1s" for what
|
|
726
|
+
// may be a long closing monologue.
|
|
727
|
+
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
675
728
|
})
|
|
676
729
|
res.set('Cache-Control', 'private, no-store')
|
|
677
730
|
res.json({
|
|
@@ -685,6 +738,459 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
685
738
|
})
|
|
686
739
|
})
|
|
687
740
|
|
|
741
|
+
|
|
742
|
+
// ── Per-meeting speaker relabel (6.21.16) ─────────────────────────────
|
|
743
|
+
//
|
|
744
|
+
// Corrects who a voice was in ONE meeting. Deliberately not a global merge:
|
|
745
|
+
// Miles, on the design — "changing it doesn't mean that all previous chunks
|
|
746
|
+
// should also be moved. It should be meeting by meeting, with the goal of
|
|
747
|
+
// hardening or refining the voice profiles." The identifier mishearing a voice
|
|
748
|
+
// in one room is not evidence that every past attribution was wrong.
|
|
749
|
+
//
|
|
750
|
+
// ORDER IS THE WHOLE DESIGN. The ledger intent is written BEFORE any file is
|
|
751
|
+
// touched, and a failed intent write aborts without mutating anything. A
|
|
752
|
+
// process that dies mid-rewrite therefore leaves a visible pending correction
|
|
753
|
+
// rather than a silently half-relabelled meeting.
|
|
754
|
+
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
755
|
+
res.set('Cache-Control', 'private, no-store')
|
|
756
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
757
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
758
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
759
|
+
return
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const from = typeof req.body?.from === 'string' ? req.body.from : ''
|
|
763
|
+
const to = typeof req.body?.to === 'string' ? req.body.to : ''
|
|
764
|
+
const chunks = Array.isArray(req.body?.chunks)
|
|
765
|
+
? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
|
|
766
|
+
: []
|
|
767
|
+
if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
|
|
768
|
+
res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
772
|
+
const bad = invalidLabelReason(label)
|
|
773
|
+
if (bad) {
|
|
774
|
+
res.status(400).json({ error: `${which}: ${bad}`, reason: 'invalid_label' })
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (from === to) {
|
|
779
|
+
res.status(400).json({ error: 'from and to are the same label', reason: 'noop_relabel' })
|
|
780
|
+
return
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Same resolution as GET /speakers — operations tree first when configured,
|
|
784
|
+
// so the panel and the correction act on the same copy of the meeting.
|
|
785
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
786
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
787
|
+
: null
|
|
788
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
789
|
+
if (!operations && !saved) {
|
|
790
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
794
|
+
const meetingPath = operations?.meetingPath ?? saved!.filepath
|
|
795
|
+
const title = operations?.title ?? saved!.title
|
|
796
|
+
|
|
797
|
+
let sidecarRaw: string
|
|
798
|
+
try {
|
|
799
|
+
sidecarRaw = readFileSync(sidecarPath, 'utf-8')
|
|
800
|
+
} catch {
|
|
801
|
+
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const plan = relabelSidecarJson(sidecarRaw, from, to, chunks)
|
|
806
|
+
if (!plan.ok) {
|
|
807
|
+
res.status(422).json({ error: plan.error, reason: 'relabel_rejected' })
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// The markdown may only be rewritten when EVERY chunk carrying `from` is
|
|
812
|
+
// covered. Its turn segmentation does not match the sidecar's, so a partial
|
|
813
|
+
// relabel has no way to know which turns the selected chunks became.
|
|
814
|
+
let markdownRaw: string | null = null
|
|
815
|
+
let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
|
|
816
|
+
if (plan.value.coveredAllWithLabel) {
|
|
817
|
+
try {
|
|
818
|
+
markdownRaw = readFileSync(meetingPath, 'utf-8')
|
|
819
|
+
markdownPlan = relabelMeetingMarkdown(markdownRaw, from, to)
|
|
820
|
+
} catch {
|
|
821
|
+
markdownRaw = null // sidecar-only correction; reported in the response
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const md = markdownPlan?.ok ? markdownPlan.value : null
|
|
825
|
+
|
|
826
|
+
const surfaces = {
|
|
827
|
+
sidecar: plan.value.changed.length,
|
|
828
|
+
attendees: md?.attendees ?? 0,
|
|
829
|
+
transcript: md?.transcript ?? 0,
|
|
830
|
+
}
|
|
831
|
+
const preview = {
|
|
832
|
+
sessionId,
|
|
833
|
+
title,
|
|
834
|
+
from,
|
|
835
|
+
to,
|
|
836
|
+
scope: 'meeting' as const,
|
|
837
|
+
chunks: plan.value.changed,
|
|
838
|
+
surfaces,
|
|
839
|
+
partial: !plan.value.coveredAllWithLabel,
|
|
840
|
+
remainingWithFrom: plan.value.remainingWithFrom,
|
|
841
|
+
speakersAfter: plan.value.speakers,
|
|
842
|
+
proseStale: md?.proseStale ?? false,
|
|
843
|
+
proseHits: md?.proseHits ?? [],
|
|
844
|
+
markdownSkipped: !plan.value.coveredAllWithLabel
|
|
845
|
+
? 'partial relabel: transcript turns cannot be mapped to chunk indices'
|
|
846
|
+
: markdownRaw === null ? 'meeting markdown unreadable' : null,
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (req.body?.dryRun === true || req.body?.confirm !== true) {
|
|
850
|
+
res.status(req.body?.dryRun === true ? 200 : 400).json({
|
|
851
|
+
...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
|
|
852
|
+
message: `Relabelling ${surfaces.sidecar} chunk(s) from "${from}" to "${to}" in this meeting`
|
|
853
|
+
+ (preview.proseStale
|
|
854
|
+
? '. The summary and decisions still name the old speaker and are NOT rewritten — '
|
|
855
|
+
+ `prose refers to people by first name (${preview.proseHits.join(', ')}), `
|
|
856
|
+
+ 'so substituting it could rewrite a sentence about someone else.'
|
|
857
|
+
: '.'),
|
|
858
|
+
...preview,
|
|
859
|
+
})
|
|
860
|
+
return
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// A prior correction that never closed means this meeting's files may already
|
|
864
|
+
// be half-written. Refuse rather than layering a second rewrite on top.
|
|
865
|
+
const stalled = pendingCorrections(sessionId)
|
|
866
|
+
if (stalled.length > 0 && req.body?.force !== true) {
|
|
867
|
+
res.status(409).json({
|
|
868
|
+
error: 'a previous correction on this meeting never completed',
|
|
869
|
+
reason: 'correction_pending',
|
|
870
|
+
pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
|
|
871
|
+
message: 'Its files may be partly rewritten. Re-check the meeting, then pass { force: true } to proceed.',
|
|
872
|
+
})
|
|
873
|
+
return
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const id = `${sessionId}:${from}>${to}:${Date.now().toString(36)}`
|
|
877
|
+
const at = new Date().toISOString()
|
|
878
|
+
// Intent first. If this cannot be written, nothing is mutated — an
|
|
879
|
+
// unrecorded rewrite is the exact failure the ledger exists to prevent.
|
|
880
|
+
if (!appendCorrection(sessionId, { id, phase: 'intent', at, from, to, chunks: plan.value.changed, scope: 'meeting' })) {
|
|
881
|
+
res.status(500).json({
|
|
882
|
+
error: 'could not record the correction, so nothing was changed',
|
|
883
|
+
reason: 'ledger_unwritable',
|
|
884
|
+
})
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
|
|
890
|
+
if (md && (md.attendees > 0 || md.transcript > 0)) {
|
|
891
|
+
durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
|
|
892
|
+
}
|
|
893
|
+
} catch (err: unknown) {
|
|
894
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
895
|
+
appendCorrection(sessionId, {
|
|
896
|
+
id, phase: 'failed', at: new Date().toISOString(), from, to,
|
|
897
|
+
chunks: plan.value.changed, scope: 'meeting', error: message,
|
|
898
|
+
})
|
|
899
|
+
res.status(500).json({ error: `relabel failed: ${message}`, reason: 'write_failed' })
|
|
900
|
+
return
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
appendCorrection(sessionId, {
|
|
904
|
+
id, phase: 'applied', at: new Date().toISOString(), from, to,
|
|
905
|
+
chunks: plan.value.changed, scope: 'meeting', surfaces,
|
|
906
|
+
proseStale: preview.proseStale,
|
|
907
|
+
})
|
|
908
|
+
|
|
909
|
+
res.json({ ok: true, correctionId: id, ...preview })
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
// ── De-attribution (6.21.18) ──────────────────────────────────────────
|
|
914
|
+
//
|
|
915
|
+
// The inverse of naming an unknown voice: this voice was NOT that person.
|
|
916
|
+
//
|
|
917
|
+
// Miles, on the 2026-08-06 Ditto meeting: none of the eleven attributed
|
|
918
|
+
// voices were actually in the room, and there was no way to say so. Naming an
|
|
919
|
+
// unknown was possible; un-naming a wrong guess was not.
|
|
920
|
+
//
|
|
921
|
+
// It undoes MORE than a label. When a voice is falsely attributed the
|
|
922
|
+
// identifier may also have auto-enrolled those segments into that person's
|
|
923
|
+
// profile, so the wrong voice is now part of what the system thinks they sound
|
|
924
|
+
// like and will keep matching. Removing only the label fixes the transcript and
|
|
925
|
+
// leaves the profile poisoned — the exact mechanism that grew the phantom
|
|
926
|
+
// "Erick Hernandez" from 3 mislabelled seeds to 18 samples.
|
|
927
|
+
router.post('/meeting/:sessionId/deattribute', (req, res) => {
|
|
928
|
+
res.set('Cache-Control', 'private, no-store')
|
|
929
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
930
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
931
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
932
|
+
return
|
|
933
|
+
}
|
|
934
|
+
const from = typeof req.body?.from === 'string' ? req.body.from : ''
|
|
935
|
+
const bad = invalidLabelReason(from)
|
|
936
|
+
if (bad) {
|
|
937
|
+
res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
|
|
938
|
+
return
|
|
939
|
+
}
|
|
940
|
+
if (isUnattributed(from)) {
|
|
941
|
+
res.status(400).json({ error: `"${from}" is already unattributed`, reason: 'already_unattributed' })
|
|
942
|
+
return
|
|
943
|
+
}
|
|
944
|
+
const chunks = Array.isArray(req.body?.chunks)
|
|
945
|
+
? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
|
|
946
|
+
: []
|
|
947
|
+
if (Array.isArray(req.body?.chunks) && chunks.length !== req.body.chunks.length) {
|
|
948
|
+
res.status(400).json({ error: 'chunks must be non-negative integers', reason: 'invalid_chunks' })
|
|
949
|
+
return
|
|
950
|
+
}
|
|
951
|
+
// Retracting the training samples is the part that improves accuracy over
|
|
952
|
+
// time, so it defaults ON. Opt out to fix a transcript without touching the
|
|
953
|
+
// profile.
|
|
954
|
+
const retractTraining = req.body?.retractTraining !== false
|
|
955
|
+
|
|
956
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
957
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
958
|
+
: null
|
|
959
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
960
|
+
if (!operations && !saved) {
|
|
961
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
962
|
+
return
|
|
963
|
+
}
|
|
964
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
965
|
+
const meetingPath = operations?.meetingPath ?? saved!.filepath
|
|
966
|
+
const title = operations?.title ?? saved!.title
|
|
967
|
+
|
|
968
|
+
let sidecarRaw: string
|
|
969
|
+
try {
|
|
970
|
+
sidecarRaw = readFileSync(sidecarPath, 'utf-8')
|
|
971
|
+
} catch {
|
|
972
|
+
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
973
|
+
return
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// A NUMBERED unidentified label, unique within this meeting, so removing
|
|
977
|
+
// several wrong names does not merge those voices together.
|
|
978
|
+
let existingSpeakers: string[] = []
|
|
979
|
+
try {
|
|
980
|
+
const parsed = JSON.parse(sidecarRaw) as { speakers?: unknown; chunks?: unknown }
|
|
981
|
+
existingSpeakers = Array.isArray(parsed.speakers)
|
|
982
|
+
? parsed.speakers.filter((x): x is string => typeof x === 'string')
|
|
983
|
+
: []
|
|
984
|
+
// Also scan the chunks: a previous de-attribution may have left a label
|
|
985
|
+
// that never made it into `speakers`.
|
|
986
|
+
if (Array.isArray(parsed.chunks)) {
|
|
987
|
+
for (const c of parsed.chunks) {
|
|
988
|
+
const sp = (c as { speaker?: unknown } | null)?.speaker
|
|
989
|
+
if (typeof sp === 'string' && !existingSpeakers.includes(sp)) existingSpeakers.push(sp)
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
} catch { /* the relabel below reports a corrupt sidecar */ }
|
|
993
|
+
const DEATTRIBUTED_LABEL = nextDeattributedLabel(existingSpeakers)
|
|
994
|
+
const plan = relabelSidecarJson(sidecarRaw, from, DEATTRIBUTED_LABEL, chunks)
|
|
995
|
+
if (!plan.ok) {
|
|
996
|
+
res.status(422).json({ error: plan.error, reason: 'deattribute_rejected' })
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// What this would remove from the profile, and what it CANNOT reach.
|
|
1001
|
+
// readVoiceProfiles returns a ProfileStore, not an array.
|
|
1002
|
+
const profile = readVoiceProfiles().profiles.find(p => p.name === from)
|
|
1003
|
+
const profileSources: Array<string | undefined> = profile
|
|
1004
|
+
? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
|
|
1005
|
+
: []
|
|
1006
|
+
const traceable = profileSources.filter(sourceString => isSampleFromSession(sourceString, sessionId)).length
|
|
1007
|
+
const untraceable = untraceableSampleCount(profileSources)
|
|
1008
|
+
|
|
1009
|
+
let markdownPlan: ReturnType<typeof relabelMeetingMarkdown> | null = null
|
|
1010
|
+
let markdownUnreadable = false
|
|
1011
|
+
if (plan.value.coveredAllWithLabel) {
|
|
1012
|
+
try {
|
|
1013
|
+
markdownPlan = relabelMeetingMarkdown(
|
|
1014
|
+
readFileSync(meetingPath, 'utf-8'), from, DEATTRIBUTED_LABEL,
|
|
1015
|
+
// Remove the attendee bullet outright: renaming it would write
|
|
1016
|
+
// `- Unidentified 2` into the list as though it were a person.
|
|
1017
|
+
{ removeAttendee: true },
|
|
1018
|
+
)
|
|
1019
|
+
} catch { markdownPlan = null; markdownUnreadable = true }
|
|
1020
|
+
}
|
|
1021
|
+
const md = markdownPlan?.ok ? markdownPlan.value : null
|
|
1022
|
+
|
|
1023
|
+
const surfaces = {
|
|
1024
|
+
sidecar: plan.value.changed.length,
|
|
1025
|
+
attendees: md?.attendees ?? 0,
|
|
1026
|
+
transcript: md?.transcript ?? 0,
|
|
1027
|
+
}
|
|
1028
|
+
const preview = {
|
|
1029
|
+
sessionId,
|
|
1030
|
+
title,
|
|
1031
|
+
from,
|
|
1032
|
+
to: DEATTRIBUTED_LABEL,
|
|
1033
|
+
// 39% of operations sidecars have no .md beside them (the document was
|
|
1034
|
+
// archived). Without this the response reports sidecar changes and zero
|
|
1035
|
+
// markdown changes with no hint the document was never touched.
|
|
1036
|
+
markdownSkipped: !plan.value.coveredAllWithLabel
|
|
1037
|
+
? 'partial de-attribution: transcript turns cannot be mapped to chunk indices'
|
|
1038
|
+
: markdownUnreadable ? 'meeting markdown unreadable' : null,
|
|
1039
|
+
scope: 'meeting' as const,
|
|
1040
|
+
chunks: plan.value.changed,
|
|
1041
|
+
surfaces,
|
|
1042
|
+
partial: !plan.value.coveredAllWithLabel,
|
|
1043
|
+
speakersAfter: plan.value.speakers,
|
|
1044
|
+
training: {
|
|
1045
|
+
retract: retractTraining,
|
|
1046
|
+
wouldRetract: retractTraining ? traceable : 0,
|
|
1047
|
+
profileSamples: profileSources.length,
|
|
1048
|
+
// Honest about reach: samples written before train-g2 started stamping
|
|
1049
|
+
// the session cannot be tied to a meeting and survive this.
|
|
1050
|
+
untraceable,
|
|
1051
|
+
},
|
|
1052
|
+
proseStale: md?.proseStale ?? false,
|
|
1053
|
+
proseHits: md?.proseHits ?? [],
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (req.body?.dryRun === true || req.body?.confirm !== true) {
|
|
1057
|
+
const notes: string[] = []
|
|
1058
|
+
if (retractTraining && traceable > 0) {
|
|
1059
|
+
notes.push(`${traceable} training sample(s) from this meeting will be removed from "${from}"`)
|
|
1060
|
+
}
|
|
1061
|
+
if (retractTraining && traceable === 0) {
|
|
1062
|
+
notes.push(`no training sample from this meeting is traceable to "${from}", so the profile is unchanged`)
|
|
1063
|
+
}
|
|
1064
|
+
if (untraceable > 0) {
|
|
1065
|
+
notes.push(`${untraceable} older sample(s) on "${from}" carry no meeting provenance and cannot be retracted`)
|
|
1066
|
+
}
|
|
1067
|
+
res.status(req.body?.dryRun === true ? 200 : 400).json({
|
|
1068
|
+
...(req.body?.dryRun === true ? {} : { error: 'confirmation required', reason: 'confirmation_required' }),
|
|
1069
|
+
message: `Removing "${from}" from ${surfaces.sidecar} segment(s) of this meeting. ${notes.join('. ')}`.trim(),
|
|
1070
|
+
...preview,
|
|
1071
|
+
})
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const stalled = pendingCorrections(sessionId)
|
|
1076
|
+
if (stalled.length > 0 && req.body?.force !== true) {
|
|
1077
|
+
res.status(409).json({
|
|
1078
|
+
error: 'a previous correction on this meeting never completed',
|
|
1079
|
+
reason: 'correction_pending',
|
|
1080
|
+
pending: stalled.map(r => ({ id: r.id, at: r.at, from: r.from, to: r.to })),
|
|
1081
|
+
})
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const id = `${sessionId}:${from}>deattributed:${Date.now().toString(36)}`
|
|
1086
|
+
const at = new Date().toISOString()
|
|
1087
|
+
if (!appendCorrection(sessionId, {
|
|
1088
|
+
id, phase: 'intent', at, from, to: DEATTRIBUTED_LABEL, chunks: plan.value.changed, scope: 'meeting',
|
|
1089
|
+
})) {
|
|
1090
|
+
res.status(500).json({
|
|
1091
|
+
error: 'could not record the de-attribution, so nothing was changed',
|
|
1092
|
+
reason: 'ledger_unwritable',
|
|
1093
|
+
})
|
|
1094
|
+
return
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
let retracted = 0
|
|
1098
|
+
try {
|
|
1099
|
+
durableAtomicWriteFileSync(sidecarPath, plan.value.json, { mode: 0o600 })
|
|
1100
|
+
if (md && (md.attendees > 0 || md.transcript > 0)) {
|
|
1101
|
+
durableAtomicWriteFileSync(meetingPath, md.markdown, { mode: 0o600 })
|
|
1102
|
+
}
|
|
1103
|
+
// Profile retraction happens AFTER the transcript is corrected: if the
|
|
1104
|
+
// write fails, the ledger shows an unclosed intent and the profile has not
|
|
1105
|
+
// yet been touched, which is the recoverable order.
|
|
1106
|
+
if (retractTraining && traceable > 0) {
|
|
1107
|
+
retracted = retractEmbeddingsBySource(from, s => isSampleFromSession(s, sessionId)).removed
|
|
1108
|
+
}
|
|
1109
|
+
} catch (err: unknown) {
|
|
1110
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1111
|
+
appendCorrection(sessionId, {
|
|
1112
|
+
id, phase: 'failed', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
|
|
1113
|
+
chunks: plan.value.changed, scope: 'meeting', error: message,
|
|
1114
|
+
})
|
|
1115
|
+
res.status(500).json({ error: `de-attribution failed: ${message}`, reason: 'write_failed' })
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
appendCorrection(sessionId, {
|
|
1120
|
+
id, phase: 'applied', at: new Date().toISOString(), from, to: DEATTRIBUTED_LABEL,
|
|
1121
|
+
chunks: plan.value.changed, scope: 'meeting', surfaces, proseStale: preview.proseStale,
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
res.json({
|
|
1125
|
+
ok: true,
|
|
1126
|
+
correctionId: id,
|
|
1127
|
+
...preview,
|
|
1128
|
+
training: { ...preview.training, retracted },
|
|
1129
|
+
})
|
|
1130
|
+
})
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
// ── Review playback (6.21.18) ─────────────────────────────────────────
|
|
1134
|
+
//
|
|
1135
|
+
// Miles: "I can quickly play that, and I'm going to hear the voice and know
|
|
1136
|
+
// immediately who the speaker is. As a final confirmation."
|
|
1137
|
+
//
|
|
1138
|
+
// A phrase in the panel is a weaker signal than three seconds of the actual
|
|
1139
|
+
// voice. Retention is 7 days (see meeting-audio-archive), so this answers with
|
|
1140
|
+
// 404 + a reason once the window has passed rather than pretending the audio
|
|
1141
|
+
// was never there.
|
|
1142
|
+
router.get('/meeting/:sessionId/audio/:chunkIndex', (req, res) => {
|
|
1143
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1144
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1145
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1146
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1147
|
+
return
|
|
1148
|
+
}
|
|
1149
|
+
const chunkIndex = Number(req.params.chunkIndex)
|
|
1150
|
+
if (!Number.isInteger(chunkIndex) || chunkIndex < 0) {
|
|
1151
|
+
res.status(400).json({ error: 'Invalid chunkIndex', reason: 'invalid_chunk_index' })
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1154
|
+
const path = meetingAudioChunkPath(sessionId, chunkIndex)
|
|
1155
|
+
if (!path) {
|
|
1156
|
+
const retained = listMeetingAudioChunks(sessionId)
|
|
1157
|
+
res.status(404).json({
|
|
1158
|
+
error: retained.length === 0
|
|
1159
|
+
? 'No audio retained for this meeting'
|
|
1160
|
+
: `Chunk ${chunkIndex} is not retained`,
|
|
1161
|
+
reason: retained.length === 0 ? 'audio_not_retained' : 'chunk_not_retained',
|
|
1162
|
+
// Which chunks CAN be played, so a UI can offer the nearest instead of
|
|
1163
|
+
// just failing.
|
|
1164
|
+
retainedChunks: retained.length,
|
|
1165
|
+
firstRetained: retained[0] ?? null,
|
|
1166
|
+
lastRetained: retained[retained.length - 1] ?? null,
|
|
1167
|
+
})
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
res.type('audio/wav')
|
|
1171
|
+
res.sendFile(path)
|
|
1172
|
+
})
|
|
1173
|
+
|
|
1174
|
+
/** What audio a meeting still has, so the panel can show play buttons only
|
|
1175
|
+
* where they will work. */
|
|
1176
|
+
router.get('/meeting/:sessionId/audio', (req, res) => {
|
|
1177
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1178
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1179
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1180
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
const chunks = listMeetingAudioChunks(sessionId)
|
|
1184
|
+
res.json({
|
|
1185
|
+
sessionId,
|
|
1186
|
+
retained: chunks.length > 0,
|
|
1187
|
+
chunks,
|
|
1188
|
+
// Config read, not a filesystem walk: this route used to stat every
|
|
1189
|
+
// retained chunk to report one number.
|
|
1190
|
+
retentionDays: meetingAudioRetentionDays(),
|
|
1191
|
+
})
|
|
1192
|
+
})
|
|
1193
|
+
|
|
688
1194
|
// ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
|
|
689
1195
|
// Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
|
|
690
1196
|
// recovery on its own. It lists what the quarantine holds, and one
|
|
@@ -38,6 +38,11 @@ import {
|
|
|
38
38
|
import { transcribeWhisperMeetingPreview } from '../lib/whisper-preview.js'
|
|
39
39
|
import { dataPath } from '../lib/data-dir.js'
|
|
40
40
|
import { ageHours, partitionExpiredAudio } from '../lib/audio-retention.js'
|
|
41
|
+
import {
|
|
42
|
+
appendChunkEmbedding,
|
|
43
|
+
sweepExpiredChunkEmbeddings,
|
|
44
|
+
} from '../lib/chunk-embedding-store.js'
|
|
45
|
+
import { archiveSessionAudio, runMeetingAudioRetention } from '../lib/meeting-audio-archive.js'
|
|
41
46
|
import {
|
|
42
47
|
countChunkWavs,
|
|
43
48
|
purgeExpiredQuarantine,
|
|
@@ -750,6 +755,22 @@ setInterval(() => {
|
|
|
750
755
|
}
|
|
751
756
|
} catch {}
|
|
752
757
|
|
|
758
|
+
// Meeting audio for review: 7-day window, then an 8 GB budget as a backstop.
|
|
759
|
+
// Order matters — sweep expiry FIRST so the cap only ever evicts audio still
|
|
760
|
+
// inside its window instead of racing the sweeper for the same files.
|
|
761
|
+
try {
|
|
762
|
+
const { swept, capped } = runMeetingAudioRetention()
|
|
763
|
+
if (swept.removed.length > 0) {
|
|
764
|
+
console.log(`[meeting-audio] Retention swept ${swept.removed.length} session(s), freed ${(swept.bytesFreed / 1e6).toFixed(1)} MB`)
|
|
765
|
+
}
|
|
766
|
+
if (capped.evicted.length > 0) {
|
|
767
|
+
console.warn(
|
|
768
|
+
`[meeting-audio] Over budget — evicted ${capped.evicted.length} oldest session(s): `
|
|
769
|
+
+ `${(capped.bytesBefore / 1e9).toFixed(2)} GB -> ${(capped.bytesAfter / 1e9).toFixed(2)} GB`,
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
} catch { /* non-critical */ }
|
|
773
|
+
|
|
753
774
|
// Purge training-audio WAVs past the retention window. Per file, not per
|
|
754
775
|
// directory: chunks for one speaker accumulate over weeks, so an
|
|
755
776
|
// all-or-nothing directory check would either keep month-old audio alive
|
|
@@ -788,6 +809,16 @@ setInterval(() => {
|
|
|
788
809
|
}
|
|
789
810
|
} catch {}
|
|
790
811
|
|
|
812
|
+
// Sweep expired chunk embeddings. Same shape as the audio sweeps, but a
|
|
813
|
+
// separate window: these are non-invertible timbre vectors, not speech, and
|
|
814
|
+
// the correction loop needs them to survive a weekend.
|
|
815
|
+
try {
|
|
816
|
+
const swept = sweepExpiredChunkEmbeddings(Date.now())
|
|
817
|
+
if (swept.removed.length > 0) {
|
|
818
|
+
console.log(`[chunk-embeddings] Purged ${swept.removed.length} expired session file(s), ${swept.retained.length} retained`)
|
|
819
|
+
}
|
|
820
|
+
} catch {}
|
|
821
|
+
|
|
791
822
|
// Purge ext-audio dirs older than 72 hours
|
|
792
823
|
try {
|
|
793
824
|
if (existsSync(EXT_AUDIO_DIR)) {
|
|
@@ -1054,6 +1085,20 @@ export function moveSessionAudioToPending(sessionId: string): string | null {
|
|
|
1054
1085
|
destDir = resolve(PENDING_BATCH_DIR, `${sessionId}_${suffix}`)
|
|
1055
1086
|
}
|
|
1056
1087
|
}
|
|
1088
|
+
// Retain a reviewable copy BEFORE the rename. Hard links, so this costs no
|
|
1089
|
+
// extra disk and the audio outlives the batch pipeline's own cleanup — which
|
|
1090
|
+
// is what used to end the audio's life entirely (session-audio held 0 files
|
|
1091
|
+
// on 2026-08-06, so the review panel could never play anything back).
|
|
1092
|
+
try {
|
|
1093
|
+
const archived = archiveSessionAudio(sessionId, srcDir)
|
|
1094
|
+
if (archived.linked + archived.copied > 0) {
|
|
1095
|
+
console.log(
|
|
1096
|
+
`[meeting-audio] Retained ${archived.linked + archived.copied} chunk(s) for review: ${sessionId}`
|
|
1097
|
+
+ (archived.copied ? ` (${archived.copied} copied, link unavailable)` : '')
|
|
1098
|
+
+ (archived.failed ? ` — ${archived.failed} failed` : ''),
|
|
1099
|
+
)
|
|
1100
|
+
}
|
|
1101
|
+
} catch { /* review audio is never worth failing a save for */ }
|
|
1057
1102
|
// Rename is atomic on same filesystem
|
|
1058
1103
|
renameSync(srcDir, destDir)
|
|
1059
1104
|
try { chmodSync(destDir, 0o700) } catch {}
|
|
@@ -1498,6 +1543,20 @@ function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex
|
|
|
1498
1543
|
console.log(`[perf] identifySpeaker: ${(performance.now() - tEmb).toFixed(1)}ms`)
|
|
1499
1544
|
if (!embeddingResult) return { speaker: clientSpeaker, similarity: 0 }
|
|
1500
1545
|
|
|
1546
|
+
// Bank the embedding before anything else can return. This is the only moment
|
|
1547
|
+
// it exists: identifySpeaker computes it per chunk and nothing else keeps it,
|
|
1548
|
+
// so a correction made later has no acoustic evidence without this write.
|
|
1549
|
+
// Deliberately includes 'Ext' — an unidentified voice is the case with no
|
|
1550
|
+
// other way to be trained.
|
|
1551
|
+
if (embeddingResult.embedding) {
|
|
1552
|
+
appendChunkEmbedding(sessionId, {
|
|
1553
|
+
i: chunkIndex,
|
|
1554
|
+
speaker: embeddingResult.speaker,
|
|
1555
|
+
similarity: embeddingResult.similarity,
|
|
1556
|
+
embedding: embeddingResult.embedding,
|
|
1557
|
+
})
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1501
1560
|
let speaker = embeddingResult.speaker
|
|
1502
1561
|
if (speaker !== clientSpeaker) {
|
|
1503
1562
|
console.log(`[speaker] Embedding: ${speaker} vs Amplitude: ${clientSpeaker} (sim: ${embeddingResult.similarity.toFixed(2)})`)
|