@gotcos/glasses-server 6.21.17 → 6.21.19
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 +92 -0
- package/package.json +1 -1
- package/server/lib/meeting-audio-archive.ts +364 -0
- package/server/lib/meeting-relabel.ts +14 -2
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/routes/health.ts +3 -0
- package/server/routes/meeting.ts +347 -2
- package/server/routes/transcribe-stream.ts +31 -0
- package/server/routes/voice.ts +91 -2
|
@@ -26,6 +26,19 @@
|
|
|
26
26
|
export interface ReviewChunk {
|
|
27
27
|
text?: string
|
|
28
28
|
speaker?: string
|
|
29
|
+
/**
|
|
30
|
+
* RAW capture index — the number in `chunk_NNNN.wav`, NOT this chunk's position
|
|
31
|
+
* in the array.
|
|
32
|
+
*
|
|
33
|
+
* These differ and the gap grows through a meeting. Measured on the 2026-08-06
|
|
34
|
+
* Ditto sidecar: 885 compacted chunks against raw indices 0..945 with 36 gaps,
|
|
35
|
+
* so array position 884 is really raw chunk 940 — a 56-chunk error, minutes of
|
|
36
|
+
* audio. `chunks` is filtered to text-bearing entries (transcribe-stream's
|
|
37
|
+
* getSessionChunks) while the WAV is written for EVERY received chunk before
|
|
38
|
+
* ASR, so the position can never address the audio. Populated from the
|
|
39
|
+
* sidecar's `chunkEntries`, which exists precisely to preserve these.
|
|
40
|
+
*/
|
|
41
|
+
chunkIndex?: number
|
|
29
42
|
/** MILLISECONDS from meeting start. Confirmed against the writer:
|
|
30
43
|
* meeting.ts accumulates `elapsed += row.durationMs`. Treating this as
|
|
31
44
|
* seconds reports a 32-minute meeting as 30,936 minutes. */
|
|
@@ -36,6 +49,24 @@ export interface ReviewChunk {
|
|
|
36
49
|
/** Labels that mean "nobody was identified", not a person. */
|
|
37
50
|
export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker 2', 'Speaker 3'])
|
|
38
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Prefix for a voice a human de-attributed, numbered so distinct people stay
|
|
54
|
+
* distinct.
|
|
55
|
+
*
|
|
56
|
+
* De-attributing to a single shared `Ext` folded every corrected voice into one
|
|
57
|
+
* row: on the 2026-08-06 Ditto meeting Miles named five wrong attributions, and
|
|
58
|
+
* collapsing them would have destroyed his ability to tell those five voices
|
|
59
|
+
* apart afterwards — which is exactly what he then needs playback for. Numbering
|
|
60
|
+
* keeps them separable while asserting no identity.
|
|
61
|
+
*/
|
|
62
|
+
export const DEATTRIBUTED_PREFIX = 'Unidentified'
|
|
63
|
+
|
|
64
|
+
/** True when a label asserts no identity — the exact set, or a numbered
|
|
65
|
+
* de-attribution. Prefix-aware so `Unidentified 3` is treated as unnamed. */
|
|
66
|
+
export function isUnattributed(label: string): boolean {
|
|
67
|
+
return UNATTRIBUTED.has(label) || new RegExp(`^${DEATTRIBUTED_PREFIX} \\d+$`).test(label)
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
/** Calibrated from the control pair above. A pair must be BOTH flip-happy and
|
|
40
71
|
* short-run to be called unreliable — either alone has honest explanations
|
|
41
72
|
* (a rapid-fire exchange is flip-happy; a brief interjection is short-run). */
|
|
@@ -44,6 +75,27 @@ export const THRASH_MEAN_RUN = 8
|
|
|
44
75
|
/** Below the search-accept threshold a name was never asserted with confidence. */
|
|
45
76
|
export const CONFIDENT_SIMILARITY = 0.65
|
|
46
77
|
|
|
78
|
+
/**
|
|
79
|
+
* FLOOR FOR PRESENTING A NAME AT ALL.
|
|
80
|
+
*
|
|
81
|
+
* The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
|
|
82
|
+
* scoring 0.55 currently arrives in the panel wearing somebody's full name. On
|
|
83
|
+
* Miles's 2026-08-06 Ditto meeting that produced Richard Jenkins (1 segment,
|
|
84
|
+
* 0.60), Luke Henry (1 segment, 0.55), Dylan Jackson (2 segments, 0.58) and
|
|
85
|
+
* Navaz Sharif (3 segments, 0.58) — and he confirmed none of them were in the
|
|
86
|
+
* room. Presenting those as names is the defect; the reviewer then has to undo
|
|
87
|
+
* an assertion the system should never have made.
|
|
88
|
+
*
|
|
89
|
+
* Standing rule this enforces: speaker identity is a SUGGESTION, never an
|
|
90
|
+
* assertion. Below the floor the row is "unidentified" and the label survives
|
|
91
|
+
* only as a scored candidate.
|
|
92
|
+
*
|
|
93
|
+
* A floor cannot catch everything — a wrong match can still score well — so this
|
|
94
|
+
* removes obvious noise rather than guaranteeing correctness.
|
|
95
|
+
*/
|
|
96
|
+
export const ASSERT_MIN_SIMILARITY = CONFIDENT_SIMILARITY
|
|
97
|
+
export const ASSERT_MIN_SEGMENTS = 3
|
|
98
|
+
|
|
47
99
|
export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
|
|
48
100
|
|
|
49
101
|
export interface ThrashPair {
|
|
@@ -58,6 +110,13 @@ export interface Phrase {
|
|
|
58
110
|
/** Milliseconds from meeting start, matching the sidecar. */
|
|
59
111
|
atMs: number
|
|
60
112
|
similarity: number | null
|
|
113
|
+
/**
|
|
114
|
+
* Raw capture index for playback, or null when the sidecar cannot supply one
|
|
115
|
+
* (pre-`chunkEntries` captures). Null means "do not offer playback" — a
|
|
116
|
+
* guessed index plays somebody else's voice, which is worse than no button
|
|
117
|
+
* on a screen whose whole purpose is confirming identity.
|
|
118
|
+
*/
|
|
119
|
+
chunkIndex: number | null
|
|
61
120
|
}
|
|
62
121
|
|
|
63
122
|
export interface VoiceReview {
|
|
@@ -72,22 +131,138 @@ export interface VoiceReview {
|
|
|
72
131
|
longestRun: number
|
|
73
132
|
isOwner: boolean
|
|
74
133
|
reliability: Reliability
|
|
134
|
+
/**
|
|
135
|
+
* Whether `label` may be shown to a human AS A NAME.
|
|
136
|
+
*
|
|
137
|
+
* False means the UI must render the row as unidentified and offer `label`
|
|
138
|
+
* only as a scored candidate. Carried as data rather than left to each client
|
|
139
|
+
* to re-derive, so the phone, the lens and Control cannot disagree about
|
|
140
|
+
* whether a name was earned.
|
|
141
|
+
*/
|
|
142
|
+
nameAsserted: boolean
|
|
143
|
+
/** Why the name is not asserted — so a UI can explain rather than just hide. */
|
|
144
|
+
assertionBlockers: string[]
|
|
75
145
|
thrashesWith: ThrashPair[]
|
|
76
146
|
phrases: Phrase[]
|
|
77
147
|
}
|
|
78
148
|
|
|
149
|
+
/** One stretch of the meeting held by a single label. */
|
|
150
|
+
export interface TimelineSpan {
|
|
151
|
+
/** The label as stored. Whether it may be SHOWN as a name is still governed by
|
|
152
|
+
* the matching voice row's `nameAsserted` — a span is not a second opinion. */
|
|
153
|
+
speaker: string
|
|
154
|
+
/** Milliseconds from meeting start. `elapsed` on a chunk is its START offset:
|
|
155
|
+
* meeting.ts assigns `elapsed` and only then does `elapsed += durationMs`. */
|
|
156
|
+
startMs: number
|
|
157
|
+
endMs: number
|
|
158
|
+
segments: number
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Collapse the chunk sequence into consecutive same-speaker spans.
|
|
163
|
+
*
|
|
164
|
+
* This exists because the ribbon needs a TIME axis. The previous ribbon drew one
|
|
165
|
+
* rectangle per voice sized by share of segments and labelled itself "who spoke,
|
|
166
|
+
* in order" — there was no ordering in it at all, so hovering could not report
|
|
167
|
+
* anything true.
|
|
168
|
+
*
|
|
169
|
+
* Non-monotonic or missing `elapsed` values are carried forward rather than
|
|
170
|
+
* trusted: a span with a negative width would render as an invisible or
|
|
171
|
+
* inverted block, which is worse than a slightly wrong boundary.
|
|
172
|
+
*/
|
|
173
|
+
export function speakerTimeline(chunks: ReviewChunk[], durationMs: number): TimelineSpan[] {
|
|
174
|
+
const spans: TimelineSpan[] = []
|
|
175
|
+
let cursor = 0
|
|
176
|
+
for (const c of chunks) {
|
|
177
|
+
const label = c.speaker ?? ''
|
|
178
|
+
const raw = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : 0
|
|
179
|
+
// ONE clamp does all three jobs, because `cursor` is monotonic and never
|
|
180
|
+
// negative: it recovers a missing value, a negative value, and a value that
|
|
181
|
+
// goes backwards. Mutation showed the extra Math.max(0, raw) and the
|
|
182
|
+
// `: cursor` fallback were both unreachable behind it.
|
|
183
|
+
const startMs = Math.max(cursor, raw)
|
|
184
|
+
cursor = startMs
|
|
185
|
+
const last = spans[spans.length - 1]
|
|
186
|
+
if (last && last.speaker === label) {
|
|
187
|
+
last.segments++
|
|
188
|
+
} else {
|
|
189
|
+
spans.push({ speaker: label, startMs, endMs: startMs, segments: 1 })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Each span ends where the next begins. The LAST one is the problem: `elapsed`
|
|
193
|
+
// is a start offset, and on real sidecars `durationMs` frequently equals the
|
|
194
|
+
// final chunk's start exactly (measured on 2026-08-06 Ditto: both 5,783,732),
|
|
195
|
+
// so taking the meeting end verbatim leaves the closing turn zero-width — a
|
|
196
|
+
// 1.5pt sliver for what may be a long monologue.
|
|
197
|
+
//
|
|
198
|
+
// The tail gets ONE TYPICAL CHUNK of width, derived from the median gap between
|
|
199
|
+
// this meeting's own chunk starts. That is measured from the data rather than
|
|
200
|
+
// invented, and it is the shortest defensible non-zero answer.
|
|
201
|
+
const gaps: number[] = []
|
|
202
|
+
for (let i = 1; i < spans.length; i++) {
|
|
203
|
+
const d = spans[i].startMs - spans[i - 1].startMs
|
|
204
|
+
if (d > 0) gaps.push(d)
|
|
205
|
+
}
|
|
206
|
+
gaps.sort((a, b) => a - b)
|
|
207
|
+
const typicalGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0
|
|
208
|
+
for (let i = 0; i < spans.length; i++) {
|
|
209
|
+
const next = spans[i + 1]
|
|
210
|
+
if (next) {
|
|
211
|
+
spans[i].endMs = Math.max(spans[i].startMs, next.startMs)
|
|
212
|
+
} else if (durationMs > spans[i].startMs) {
|
|
213
|
+
// A real meeting end, later than this span's start: use it verbatim.
|
|
214
|
+
spans[i].endMs = durationMs
|
|
215
|
+
} else {
|
|
216
|
+
// durationMs is absent, or equals the final chunk's start (the common real
|
|
217
|
+
// case). Fall back to one typical chunk so the closing turn has width.
|
|
218
|
+
spans[i].endMs = spans[i].startMs + typicalGap
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return spans
|
|
222
|
+
}
|
|
223
|
+
|
|
79
224
|
export interface MeetingSpeakerReview {
|
|
80
225
|
segments: number
|
|
81
226
|
/** False when no chunk carries a real speaker — a recovered capture. */
|
|
82
227
|
attributed: boolean
|
|
83
228
|
durationMs: number
|
|
84
229
|
voices: VoiceReview[]
|
|
230
|
+
/** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
|
|
231
|
+
timeline: TimelineSpan[]
|
|
85
232
|
}
|
|
86
233
|
|
|
87
234
|
function mean(xs: number[]): number {
|
|
88
235
|
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
|
|
89
236
|
}
|
|
90
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Attach raw capture indices to a compacted chunk array.
|
|
240
|
+
*
|
|
241
|
+
* The i-th compacted chunk is the i-th TEXT-BEARING `chunkEntries` row, whose
|
|
242
|
+
* `chunkIndex` is the raw WAV number. Verified against every 2026-07/08 sidecar
|
|
243
|
+
* that carries chunkEntries: the text-bearing count always equals the compacted
|
|
244
|
+
* count and the text lines up positionally.
|
|
245
|
+
*
|
|
246
|
+
* Returns the chunks UNCHANGED when the counts disagree or chunkEntries is
|
|
247
|
+
* absent. A partial or shifted mapping is worse than none: it would silently
|
|
248
|
+
* point playback at a neighbouring speaker, and this screen exists to confirm
|
|
249
|
+
* identity.
|
|
250
|
+
*/
|
|
251
|
+
export function attachRawChunkIndices(chunks: ReviewChunk[], chunkEntries: unknown): ReviewChunk[] {
|
|
252
|
+
if (!Array.isArray(chunkEntries)) return chunks
|
|
253
|
+
const textBearing = chunkEntries.filter(e => {
|
|
254
|
+
const chunk = (e as { chunk?: { text?: unknown } } | null)?.chunk
|
|
255
|
+
return typeof chunk?.text === 'string' && chunk.text.trim() !== ''
|
|
256
|
+
})
|
|
257
|
+
if (textBearing.length !== chunks.length) return chunks
|
|
258
|
+
return chunks.map((c, i) => {
|
|
259
|
+
const raw = (textBearing[i] as { chunkIndex?: unknown }).chunkIndex
|
|
260
|
+
return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0
|
|
261
|
+
? { ...c, chunkIndex: raw }
|
|
262
|
+
: c
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
91
266
|
/** Consecutive-run lengths for one speaker across the whole meeting. */
|
|
92
267
|
export function speakerRuns(sequence: string[], speaker: string): number[] {
|
|
93
268
|
const runs: number[] = []
|
|
@@ -179,6 +354,7 @@ export function selectPhrases(
|
|
|
179
354
|
text: (c.text ?? '').trim(),
|
|
180
355
|
atMs: typeof c.elapsed === 'number' ? c.elapsed : 0,
|
|
181
356
|
similarity: typeof c.similarity === 'number' ? c.similarity : null,
|
|
357
|
+
chunkIndex: typeof c.chunkIndex === 'number' ? c.chunkIndex : null,
|
|
182
358
|
score: phraseScore(c.text ?? ''),
|
|
183
359
|
}))
|
|
184
360
|
.filter(p => p.score > 0)
|
|
@@ -203,27 +379,34 @@ export function selectPhrases(
|
|
|
203
379
|
}
|
|
204
380
|
return picked
|
|
205
381
|
.sort((a, b) => a.atMs - b.atMs)
|
|
206
|
-
.map(({ text, atMs, similarity }) => ({ text, atMs, similarity }))
|
|
382
|
+
.map(({ text, atMs, similarity, chunkIndex }) => ({ text, atMs, similarity, chunkIndex }))
|
|
207
383
|
}
|
|
208
384
|
|
|
209
385
|
/** Build the whole review for one meeting's chunks. */
|
|
210
386
|
export function reviewMeetingSpeakers(
|
|
211
387
|
chunks: ReviewChunk[],
|
|
212
|
-
options: { owner?: string; phrasesPerVoice?: number } = {},
|
|
388
|
+
options: { owner?: string; phrasesPerVoice?: number; durationMs?: number } = {},
|
|
213
389
|
): MeetingSpeakerReview {
|
|
214
390
|
const owner = options.owner ?? 'Me'
|
|
215
391
|
const limit = options.phrasesPerVoice ?? 3
|
|
216
392
|
const sequence = chunks.map(c => c.speaker ?? '')
|
|
217
|
-
|
|
393
|
+
// The caller's durationMs (the sidecar's own) is the meeting's true end.
|
|
394
|
+
// Falling back to max(elapsed) uses the START of the last chunk, which makes
|
|
395
|
+
// the final timeline span zero-width — so prefer the real value and only
|
|
396
|
+
// derive when it is absent.
|
|
397
|
+
const lastStart = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
|
|
398
|
+
const durationMs = typeof options.durationMs === 'number' && options.durationMs > lastStart
|
|
399
|
+
? options.durationMs
|
|
400
|
+
: lastStart
|
|
218
401
|
|
|
219
402
|
const labels = [...new Set(sequence)].filter(s => s.length > 0)
|
|
220
|
-
const named = labels.filter(l => !
|
|
403
|
+
const named = labels.filter(l => !isUnattributed(l))
|
|
221
404
|
|
|
222
405
|
const voices: VoiceReview[] = labels.map(label => {
|
|
223
406
|
const own = chunks.filter(c => (c.speaker ?? '') === label)
|
|
224
407
|
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
225
408
|
const runs = speakerRuns(sequence, label)
|
|
226
|
-
const unattributed =
|
|
409
|
+
const unattributed = isUnattributed(label)
|
|
227
410
|
|
|
228
411
|
const thrashesWith: ThrashPair[] = []
|
|
229
412
|
if (!unattributed) {
|
|
@@ -250,6 +433,32 @@ export function reviewMeetingSpeakers(
|
|
|
250
433
|
? 'unreliable'
|
|
251
434
|
: (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
252
435
|
|
|
436
|
+
// What stops this label being presented as a name. Collected as reasons
|
|
437
|
+
// rather than a bare boolean: "2 segments" and "similarity 0.58" are
|
|
438
|
+
// different problems and a reviewer deserves to see which one applies.
|
|
439
|
+
const assertionBlockers: string[] = []
|
|
440
|
+
if (unattributed) {
|
|
441
|
+
assertionBlockers.push('no name was ever assigned to this voice')
|
|
442
|
+
} else if (label === owner) {
|
|
443
|
+
// The wearer is exempt. Their identity is established by wearing the
|
|
444
|
+
// device, not by cosine — and the owner is verified at exactly this floor
|
|
445
|
+
// (VERIFY_THRESHOLD 0.65), so they sit permanently on the boundary and any
|
|
446
|
+
// thrash pair flips them. Measured across the 2026-08-06 corpus: the owner
|
|
447
|
+
// row read "Unidentified voice" in 4 of 9 meetings, including one with 285
|
|
448
|
+
// of their own segments. `thrashesWith` still renders, so a mixed row is
|
|
449
|
+
// still visible — the name is asserted, the caveat is not hidden.
|
|
450
|
+
} else {
|
|
451
|
+
if (own.length < ASSERT_MIN_SEGMENTS) {
|
|
452
|
+
assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
|
|
453
|
+
}
|
|
454
|
+
if ((meanSim ?? 0) < ASSERT_MIN_SIMILARITY) {
|
|
455
|
+
assertionBlockers.push(`similarity ${(meanSim ?? 0).toFixed(2)} below ${ASSERT_MIN_SIMILARITY}`)
|
|
456
|
+
}
|
|
457
|
+
if (thrashesWith.length > 0) {
|
|
458
|
+
assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
253
462
|
return {
|
|
254
463
|
label,
|
|
255
464
|
segments: own.length,
|
|
@@ -258,11 +467,19 @@ export function reviewMeetingSpeakers(
|
|
|
258
467
|
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
259
468
|
isOwner: label === owner,
|
|
260
469
|
reliability,
|
|
470
|
+
nameAsserted: assertionBlockers.length === 0,
|
|
471
|
+
assertionBlockers,
|
|
261
472
|
thrashesWith,
|
|
262
473
|
phrases: selectPhrases(chunks, label, limit, durationMs),
|
|
263
474
|
}
|
|
264
475
|
})
|
|
265
476
|
|
|
266
477
|
voices.sort((a, b) => b.segments - a.segments)
|
|
267
|
-
return {
|
|
478
|
+
return {
|
|
479
|
+
segments: chunks.length,
|
|
480
|
+
attributed: named.length > 0,
|
|
481
|
+
durationMs,
|
|
482
|
+
voices,
|
|
483
|
+
timeline: speakerTimeline(chunks, durationMs),
|
|
484
|
+
}
|
|
268
485
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Tracing a stored voice sample back to the meeting that produced it.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NEEDED. De-attribution has to undo more than a label. When a voice
|
|
4
|
+
// is falsely attributed to someone, the identifier may ALSO have auto-enrolled
|
|
5
|
+
// those segments into that person's profile — so the wrong voice is now part of
|
|
6
|
+
// what the system thinks they sound like, and it will keep matching. Removing the
|
|
7
|
+
// label without removing the samples fixes the transcript and leaves the profile
|
|
8
|
+
// poisoned. That is the mechanism behind the phantom "Erick Hernandez": three
|
|
9
|
+
// mislabelled seeds compounded to eighteen samples through self-training.
|
|
10
|
+
//
|
|
11
|
+
// WHAT CAN AND CANNOT BE TRACED. Provenance strings differ in whether they name
|
|
12
|
+
// a session:
|
|
13
|
+
//
|
|
14
|
+
// auto:<sessionId> traceable — autoEnroll stamps it
|
|
15
|
+
// correction:<sessionId> traceable — the relabel ledger stamps it
|
|
16
|
+
// g2-training:<sessionId> traceable ONLY from 2026-08-06 onward (see below)
|
|
17
|
+
// g2-training NOT traceable — every sample written before that
|
|
18
|
+
// fireflies / manual / … NOT traceable — no session concept
|
|
19
|
+
//
|
|
20
|
+
// `train-g2` used to stamp a bare `g2-training`, discarding which meeting each
|
|
21
|
+
// sample came from even though the source WAV filename carries it
|
|
22
|
+
// (`meeting_1785190805524_uzxmn4_chunk327_sim0.57.wav`). Samples written before
|
|
23
|
+
// the stamp landed cannot be retracted per-meeting, and de-attribution reports
|
|
24
|
+
// that count rather than implying a clean sweep.
|
|
25
|
+
|
|
26
|
+
/** Session id embedded in a training-audio WAV name, or null if absent. */
|
|
27
|
+
export function sessionIdFromTrainingWav(filename: string): string | null {
|
|
28
|
+
// Session ids contain underscores (`meeting_1785190805524_uzxmn4`), so the
|
|
29
|
+
// split has to be on the `_chunk` marker, not on the last underscore.
|
|
30
|
+
const at = filename.indexOf('_chunk')
|
|
31
|
+
if (at <= 0) return null
|
|
32
|
+
const id = filename.slice(0, at)
|
|
33
|
+
return /^[A-Za-z0-9:_-]{3,96}$/.test(id) ? id : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Provenance to record for a sample trained out of `filename`. */
|
|
37
|
+
export function trainingSourceFor(filename: string): string {
|
|
38
|
+
const id = sessionIdFromTrainingWav(filename)
|
|
39
|
+
return id ? `g2-training:${id}` : 'g2-training'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Did this stored sample come from `sessionId`?
|
|
44
|
+
*
|
|
45
|
+
* Exact match on the session part — a prefix test would let
|
|
46
|
+
* `auto:meeting_123_extra` match session `meeting_123` and retract a different
|
|
47
|
+
* meeting's evidence.
|
|
48
|
+
*/
|
|
49
|
+
export function isSampleFromSession(source: string | undefined | null, sessionId: string): boolean {
|
|
50
|
+
const s = String(source ?? '')
|
|
51
|
+
if (!sessionId) return false
|
|
52
|
+
for (const prefix of ['auto:', 'correction:', 'g2-training:']) {
|
|
53
|
+
if (s.startsWith(prefix) && s.slice(prefix.length) === sessionId) return true
|
|
54
|
+
}
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Samples that belong to this speaker but cannot be tied to any meeting.
|
|
60
|
+
*
|
|
61
|
+
* Reported so a de-attribution is honest about its reach: "retracted 4, and 9
|
|
62
|
+
* older samples cannot be traced to a meeting" is actionable, while silence
|
|
63
|
+
* implies the profile was fully cleaned.
|
|
64
|
+
*/
|
|
65
|
+
export function untraceableSampleCount(sources: Array<string | undefined | null>): number {
|
|
66
|
+
return sources.filter(s => {
|
|
67
|
+
const str = String(s ?? '')
|
|
68
|
+
return !str.includes(':')
|
|
69
|
+
}).length
|
|
70
|
+
}
|
package/server/routes/health.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { isSileroAvailable } from '../lib/vad-silero.js'
|
|
|
9
9
|
import { profileProvenanceSummary, speakerModelState, speakerReadiness } from '../lib/speaker-embeddings.js'
|
|
10
10
|
import { chunkEmbeddingStoreStats } from '../lib/chunk-embedding-store.js'
|
|
11
11
|
import { correctionStoreStats } from '../lib/meeting-corrections.js'
|
|
12
|
+
import { meetingAudioStats } from '../lib/meeting-audio-archive.js'
|
|
12
13
|
import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
|
|
13
14
|
import {
|
|
14
15
|
isWhisperLocalAvailable,
|
|
@@ -124,6 +125,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
124
125
|
// `pending` is the number that matters here: an intent that never closed means
|
|
125
126
|
// some meeting's files may be half-rewritten.
|
|
126
127
|
const speakerCorrections = correctionStoreStats()
|
|
128
|
+
const reviewAudio = meetingAudioStats()
|
|
127
129
|
// `noHumanSample` is the one to read: a profile with no human-verified sample
|
|
128
130
|
// is trained entirely on labels the system chose for itself.
|
|
129
131
|
const voiceProvenance = speakerId.state === 'active' ? profileProvenanceSummary() : null
|
|
@@ -246,6 +248,7 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
246
248
|
unsaved_captures,
|
|
247
249
|
chunk_embeddings: chunkEmbeddings,
|
|
248
250
|
speaker_corrections: speakerCorrections,
|
|
251
|
+
review_audio: reviewAudio,
|
|
249
252
|
...(voiceProvenance ? { voice_provenance: voiceProvenance } : {}),
|
|
250
253
|
capabilities: {
|
|
251
254
|
transcription: {
|