@gotcos/glasses-server 6.21.14 → 6.21.17
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 +91 -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-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +267 -0
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +16 -1
- package/server/routes/meeting.ts +178 -0
- package/server/routes/transcribe-stream.ts +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
+
## 6.21.17
|
|
2
|
+
|
|
3
|
+
- Voice profiles now give up their WEAKEST sample, not their oldest. Eviction is
|
|
4
|
+
by provenance tier — automatic, then unknown, then attendee-metadata, then
|
|
5
|
+
identifier-labelled — and human-supplied samples (manual, ext-retroactive,
|
|
6
|
+
g2-enrollment, correction) are protected.
|
|
7
|
+
- Measured on the live store before changing anything, not assumed:
|
|
8
|
+
* 61 of 77 profiles were ALREADY at the 20-sample cap, so every correction
|
|
9
|
+
cost a sample.
|
|
10
|
+
* The owner profile driving owner detection read: fireflies 10, g2-training 9,
|
|
11
|
+
unknown 1 — ZERO human-verified samples.
|
|
12
|
+
* FOUR profiles at cap would have lost a human sample to plain FIFO while
|
|
13
|
+
weaker samples sat untouched, including one whose only deliberate enrollment
|
|
14
|
+
was its oldest sample with 17 weaker samples available.
|
|
15
|
+
- Cap raised 20 -> 40. Search latency measured at 1 us for 20, 40 AND 80 samples
|
|
16
|
+
per speaker across 77 speakers, so the old cap defended nothing measurable.
|
|
17
|
+
Twenty extra slots each is about 1.2 MB.
|
|
18
|
+
- Corrections are capped at HALF a profile. They come from the acoustically hard
|
|
19
|
+
tail — the segments the identifier got wrong — so a profile of nothing but
|
|
20
|
+
corrections has a centroid displaced from how the speaker actually sounds. At
|
|
21
|
+
quota a new correction replaces the OLDEST CORRECTION, never a typical-voice
|
|
22
|
+
sample.
|
|
23
|
+
- Every eviction logs which sample went and why; none disappear silently.
|
|
24
|
+
- `/api/health` gains `voice_provenance`: tier totals, profiles at cap, and
|
|
25
|
+
`noHumanSample` — the profiles trained entirely on labels the system chose for
|
|
26
|
+
itself. That number was previously invisible.
|
|
27
|
+
- An unrecognised or missing provenance string classifies as `unknown`, the
|
|
28
|
+
weakest tier, never as trusted. A sample whose provenance was lost must not
|
|
29
|
+
inherit the protection given to one a human supplied.
|
|
30
|
+
|
|
31
|
+
## 6.21.16
|
|
32
|
+
|
|
33
|
+
- `POST /api/meeting/:sessionId/relabel` — correct who a voice was in ONE
|
|
34
|
+
meeting. Body `{ from, to, chunks?, confirm | dryRun, force? }`.
|
|
35
|
+
- Per-meeting by design, not a global merge. Miles: "changing it doesn't mean
|
|
36
|
+
that all previous chunks should also be moved. It should be meeting by
|
|
37
|
+
meeting, with the goal of hardening or refining the voice profiles." The
|
|
38
|
+
identifier mishearing a voice in one room is not evidence that every past
|
|
39
|
+
attribution was wrong.
|
|
40
|
+
- Fails closed like the other destructive endpoints: no `confirm` returns 400
|
|
41
|
+
`confirmation required` with a full preview of what would change.
|
|
42
|
+
- THE LEDGER IS WRITTEN FIRST. An intent row lands before any file is touched,
|
|
43
|
+
and a ledger that cannot be written aborts the correction with the files
|
|
44
|
+
untouched — an unrecorded mutation is the failure this exists to prevent. A
|
|
45
|
+
process dying mid-rewrite therefore leaves a visible pending correction, and a
|
|
46
|
+
later correction on the same meeting refuses with 409 until `force`.
|
|
47
|
+
- A PARTIAL relabel (explicit `chunks`) never touches the meeting markdown.
|
|
48
|
+
Measured on a real meeting: 135 sidecar chunks collapse to 46 speaker runs
|
|
49
|
+
while the markdown carries 70 turns, and they disagree on who spoke — the
|
|
50
|
+
transcript comes from a different segmentation pass, so there is no
|
|
51
|
+
chunk-index-to-turn mapping. Rewriting by label there would relabel turns the
|
|
52
|
+
human never selected. The response says why rather than staying silent.
|
|
53
|
+
- Narrative prose is NEVER rewritten — only reported as `proseStale` with the
|
|
54
|
+
forms found. Verified on a real scribe: 6 of 12 speakers are referred to by
|
|
55
|
+
bare first name in the summary, and with two Kyles, two Jacobuses and two
|
|
56
|
+
Chrises in this org a first-name substitution would rewrite sentences about
|
|
57
|
+
someone else. The confirmation message says this before a human commits.
|
|
58
|
+
- `/api/health` reports `speaker_corrections` (sessions, applied, pending,
|
|
59
|
+
failed). `pending` is the one to watch.
|
|
60
|
+
|
|
61
|
+
## 6.21.15
|
|
62
|
+
|
|
63
|
+
- Persist the per-chunk voiceprint embedding, so a speaker correction can become
|
|
64
|
+
training instead of only a transcript edit. `identifySpeaker` computed a
|
|
65
|
+
192-dim embedding for every chunk, used it to pick a name, and discarded it —
|
|
66
|
+
nothing downstream kept it, so a human saying "those six segments were
|
|
67
|
+
actually me" had no acoustic evidence left to learn from. Embeddings now land
|
|
68
|
+
in `chunk-embeddings/<sessionId>.jsonl`, keyed by chunk index, which is the
|
|
69
|
+
join key to the existing chunk sidecar.
|
|
70
|
+
- This only works FORWARD. Meetings recorded before this ships can be relabelled
|
|
71
|
+
but can never harden a profile, because their embeddings are already gone.
|
|
72
|
+
- `Ext` is deliberately included. An unidentified voice is the one case with no
|
|
73
|
+
other route to being trained at all.
|
|
74
|
+
- RETENTION IS 14 DAYS, not the 8 hours used for audio snippets
|
|
75
|
+
(`COS_CHUNK_EMBEDDING_TTL_DAYS` to change it). Two reasons, both worth Miles
|
|
76
|
+
overriding if he disagrees: 8 hours cannot survive a weekend, so a Friday
|
|
77
|
+
meeting could never be corrected on Monday; and an embedding is not audio — it
|
|
78
|
+
is a non-invertible timbre vector that cannot be played back, so the privacy
|
|
79
|
+
argument behind a short audio window does not carry across unchanged.
|
|
80
|
+
- Master switch `COS_CHUNK_EMBEDDINGS=0`. Default ON, because with it off a
|
|
81
|
+
correction can never train anything. The write is append-only JSONL, never
|
|
82
|
+
throws, and is skipped silently on failure: losing an embedding must never
|
|
83
|
+
cost a chunk of transcript.
|
|
84
|
+
- Reject a wrong-DIMENSION vector on read rather than averaging it into a
|
|
85
|
+
profile. A 64-dim row decodes perfectly cleanly and nothing downstream would
|
|
86
|
+
notice, which is precisely why it needs an explicit gate.
|
|
87
|
+
- `/api/health` reports `chunk_embeddings` (sessions, bytes, TTL, oldest age) so
|
|
88
|
+
the loop can be seen banking evidence rather than assumed to be.
|
|
89
|
+
- Size, measured not estimated: ~1.1 KB per chunk, so a 400-chunk meeting is
|
|
90
|
+
under 500 KB.
|
|
91
|
+
|
|
1
92
|
## 6.21.14
|
|
2
93
|
|
|
3
94
|
- Carry `sessionId` on the COS operations meetings list as well. 6.21.13 added it
|
package/package.json
CHANGED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Per-chunk voiceprint embeddings, kept so a correction can become training.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. identifySpeaker() computes a 192-dim embedding for every
|
|
4
|
+
// audio chunk, uses it to pick a name, and throws it away. Nothing downstream
|
|
5
|
+
// keeps it: the chunk sidecar stores text/speaker/elapsed/similarity, and the
|
|
6
|
+
// calibration log stores ts/speaker/similarity/matched. So when a human later
|
|
7
|
+
// says "those six segments were actually me", the system can fix the transcript
|
|
8
|
+
// but cannot learn anything from the correction — the acoustic evidence is gone.
|
|
9
|
+
//
|
|
10
|
+
// Persisting it turns a correction into real training data:
|
|
11
|
+
// correction → these chunks' embeddings → enrollEmbedding(name, …, 'correction:<id>')
|
|
12
|
+
//
|
|
13
|
+
// This only works forward. A meeting recorded before this ships can be
|
|
14
|
+
// relabelled but can never harden a profile, because its embeddings were
|
|
15
|
+
// discarded. That is the cost of every week this is not running.
|
|
16
|
+
//
|
|
17
|
+
// NOT AUDIO. An embedding is 192 floats describing vocal timbre. It cannot be
|
|
18
|
+
// played back and speech cannot be reconstructed from it, which is why its
|
|
19
|
+
// retention is treated separately from audio snippets — see RETENTION below.
|
|
20
|
+
|
|
21
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import { dataPath } from './data-dir.js'
|
|
24
|
+
|
|
25
|
+
export const CHUNK_EMBEDDING_DIR = 'chunk-embeddings'
|
|
26
|
+
|
|
27
|
+
/** Expected dimension for the shipped 3dspeaker model. A row of any other
|
|
28
|
+
* length is recorded but flagged, never silently averaged into a profile. */
|
|
29
|
+
export const EXPECTED_EMBEDDING_DIM = 192
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* RETENTION — 14 days by default, overridable with COS_CHUNK_EMBEDDING_TTL_DAYS.
|
|
33
|
+
*
|
|
34
|
+
* This is deliberately longer than the 8 hours on record for audio snippets, and
|
|
35
|
+
* the difference is the point: 8 hours cannot survive a weekend, so a Friday
|
|
36
|
+
* meeting could never be corrected on Monday and the compounding loop would
|
|
37
|
+
* only ever work on same-day review. Embeddings are also not audio — they are
|
|
38
|
+
* non-invertible timbre vectors, so the privacy argument that motivates a short
|
|
39
|
+
* audio window does not carry across unchanged.
|
|
40
|
+
*
|
|
41
|
+
* Flagged for Miles rather than assumed: if he wants these on the 8-hour clock
|
|
42
|
+
* too, set the env var to a fraction and the correction loop becomes same-day
|
|
43
|
+
* only.
|
|
44
|
+
*/
|
|
45
|
+
export function chunkEmbeddingTtlMs(): number {
|
|
46
|
+
const raw = Number(process.env.COS_CHUNK_EMBEDDING_TTL_DAYS)
|
|
47
|
+
const days = Number.isFinite(raw) && raw > 0 ? raw : 14
|
|
48
|
+
return days * 24 * 60 * 60 * 1000
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Master switch. Default ON: with this off, a correction can never train
|
|
52
|
+
* anything, which is the whole reason the store exists. */
|
|
53
|
+
export function chunkEmbeddingsEnabled(): boolean {
|
|
54
|
+
return process.env.COS_CHUNK_EMBEDDINGS !== '0'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ChunkEmbeddingRow {
|
|
58
|
+
/** Chunk index within the session. This is the JOIN KEY to the chunk sidecar,
|
|
59
|
+
* which already carries `elapsed`, `text`, and the label. Time is deliberately
|
|
60
|
+
* NOT duplicated here: the only value available at capture time is the chunk's
|
|
61
|
+
* own duration, and storing that as an offset would be a wrong number in a
|
|
62
|
+
* field named like a right one. */
|
|
63
|
+
i: number
|
|
64
|
+
/** The label the identifier chose at capture time — what a correction corrects. */
|
|
65
|
+
speaker: string
|
|
66
|
+
similarity: number
|
|
67
|
+
embedding: Float32Array
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Base64 of the raw little-endian float32 bytes.
|
|
72
|
+
*
|
|
73
|
+
* Chosen over a JSON number array for size and fidelity: 192 floats as JSON is
|
|
74
|
+
* ~1.5 KB and rounds, while base64 is 1,024 chars and is exact. Over a
|
|
75
|
+
* 400-chunk meeting that difference is roughly 200 KB.
|
|
76
|
+
*/
|
|
77
|
+
export function encodeEmbedding(embedding: Float32Array): string {
|
|
78
|
+
return Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength).toString('base64')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function decodeEmbedding(encoded: string): Float32Array | null {
|
|
82
|
+
try {
|
|
83
|
+
const buf = Buffer.from(encoded, 'base64')
|
|
84
|
+
if (buf.byteLength === 0 || buf.byteLength % 4 !== 0) return null
|
|
85
|
+
// Copy rather than aliasing the Buffer's pool. Verified, not assumed: a
|
|
86
|
+
// 768-byte base64 decode lands at an offset inside an 8192-byte pool slab.
|
|
87
|
+
// Node's pool offset only advances, so a later allocation cannot corrupt an
|
|
88
|
+
// existing view — the cost is RETENTION. An aliased view pins the whole 8 KB
|
|
89
|
+
// slab to keep its 768 bytes, so holding a few hundred embeddings would pin
|
|
90
|
+
// ~10x the memory they occupy. The copy owns exactly its own bytes.
|
|
91
|
+
const copy = new ArrayBuffer(buf.byteLength)
|
|
92
|
+
Buffer.from(copy).set(buf)
|
|
93
|
+
return new Float32Array(copy)
|
|
94
|
+
} catch {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sessionFile(sessionId: string): string | null {
|
|
100
|
+
// Session ids come from the client; keep them to the shape the meeting store
|
|
101
|
+
// already enforces so nothing can escape the directory.
|
|
102
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
103
|
+
const dir = dataPath(CHUNK_EMBEDDING_DIR)
|
|
104
|
+
const path = join(dir, `${sessionId.replace(/:/g, '_')}.jsonl`)
|
|
105
|
+
return resolve(path).startsWith(resolve(dir) + '/') ? path : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Append one chunk's embedding. JSONL, not a JSON array, so each write is a
|
|
110
|
+
* single append with no read-modify-write of a growing file — a crash can lose
|
|
111
|
+
* at most the final partial line, and readers skip it.
|
|
112
|
+
*
|
|
113
|
+
* Never throws: this runs inside the live capture path and losing an embedding
|
|
114
|
+
* must never cost a chunk of transcript.
|
|
115
|
+
*/
|
|
116
|
+
export function appendChunkEmbedding(sessionId: string, row: ChunkEmbeddingRow): boolean {
|
|
117
|
+
if (!chunkEmbeddingsEnabled()) return false
|
|
118
|
+
const path = sessionFile(sessionId)
|
|
119
|
+
if (!path) return false
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(dataPath(CHUNK_EMBEDDING_DIR), { recursive: true, mode: 0o700 })
|
|
122
|
+
const line = JSON.stringify({
|
|
123
|
+
i: row.i,
|
|
124
|
+
speaker: row.speaker,
|
|
125
|
+
similarity: Math.round(row.similarity * 1000) / 1000,
|
|
126
|
+
dim: row.embedding.length,
|
|
127
|
+
v: encodeEmbedding(row.embedding),
|
|
128
|
+
})
|
|
129
|
+
appendFileSync(path, line + '\n', { mode: 0o600 })
|
|
130
|
+
return true
|
|
131
|
+
} catch {
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ChunkEmbeddingReadResult {
|
|
137
|
+
rows: ChunkEmbeddingRow[]
|
|
138
|
+
/** Lines that could not be parsed or decoded. Reported rather than hidden: a
|
|
139
|
+
* correction built from a partial read would train on the wrong segments. */
|
|
140
|
+
unusable: number
|
|
141
|
+
/** True when the file is absent — the meeting predates this store, or the
|
|
142
|
+
* retention window has passed. Distinct from "present but empty". */
|
|
143
|
+
missing: boolean
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function readChunkEmbeddings(sessionId: string): ChunkEmbeddingReadResult {
|
|
147
|
+
const path = sessionFile(sessionId)
|
|
148
|
+
if (!path || !existsSync(path)) return { rows: [], unusable: 0, missing: true }
|
|
149
|
+
let raw: string
|
|
150
|
+
try {
|
|
151
|
+
raw = readFileSync(path, 'utf-8')
|
|
152
|
+
} catch {
|
|
153
|
+
return { rows: [], unusable: 0, missing: true }
|
|
154
|
+
}
|
|
155
|
+
const rows: ChunkEmbeddingRow[] = []
|
|
156
|
+
let unusable = 0
|
|
157
|
+
for (const line of raw.split('\n')) {
|
|
158
|
+
if (line.trim() === '') continue
|
|
159
|
+
try {
|
|
160
|
+
const o = JSON.parse(line) as Record<string, unknown>
|
|
161
|
+
const embedding = typeof o.v === 'string' ? decodeEmbedding(o.v) : null
|
|
162
|
+
if (!embedding || typeof o.i !== 'number' || typeof o.speaker !== 'string') { unusable++; continue }
|
|
163
|
+
// A vector of the wrong dimension decodes cleanly and would be averaged
|
|
164
|
+
// into a profile as if it were a voice. The model changed, or the row is
|
|
165
|
+
// corrupt; either way it must never reach enrollment.
|
|
166
|
+
if (embedding.length !== EXPECTED_EMBEDDING_DIM) { unusable++; continue }
|
|
167
|
+
rows.push({
|
|
168
|
+
i: o.i,
|
|
169
|
+
speaker: o.speaker,
|
|
170
|
+
similarity: typeof o.similarity === 'number' ? o.similarity : 0,
|
|
171
|
+
embedding,
|
|
172
|
+
})
|
|
173
|
+
} catch {
|
|
174
|
+
unusable++
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { rows, unusable, missing: false }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The embeddings for a given label — the input to a correction. */
|
|
181
|
+
export function chunkEmbeddingsForSpeaker(sessionId: string, speaker: string): ChunkEmbeddingRow[] {
|
|
182
|
+
return readChunkEmbeddings(sessionId).rows.filter(r => r.speaker === speaker)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The embeddings for an explicit set of chunk indices, for a partial
|
|
186
|
+
* correction where only some of a voice's segments were misattributed. */
|
|
187
|
+
export function chunkEmbeddingsForIndices(sessionId: string, indices: number[]): ChunkEmbeddingRow[] {
|
|
188
|
+
const wanted = new Set(indices)
|
|
189
|
+
return readChunkEmbeddings(sessionId).rows.filter(r => wanted.has(r.i))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface SweepResult {
|
|
193
|
+
removed: string[]
|
|
194
|
+
retained: string[]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Delete session files past the retention window.
|
|
199
|
+
*
|
|
200
|
+
* A file whose mtime cannot be read is RETAINED — treating a failed stat as
|
|
201
|
+
* "ancient" would delete the evidence a pending correction depends on.
|
|
202
|
+
*/
|
|
203
|
+
export function sweepExpiredChunkEmbeddings(nowMs: number, ttlMs = chunkEmbeddingTtlMs()): SweepResult {
|
|
204
|
+
const dir = dataPath(CHUNK_EMBEDDING_DIR)
|
|
205
|
+
const result: SweepResult = { removed: [], retained: [] }
|
|
206
|
+
if (!existsSync(dir)) return result
|
|
207
|
+
let names: string[]
|
|
208
|
+
try {
|
|
209
|
+
names = readdirSync(dir).filter(n => n.endsWith('.jsonl'))
|
|
210
|
+
} catch {
|
|
211
|
+
return result
|
|
212
|
+
}
|
|
213
|
+
for (const name of names) {
|
|
214
|
+
const path = join(dir, name)
|
|
215
|
+
let mtimeMs = 0
|
|
216
|
+
try { mtimeMs = statSync(path).mtimeMs } catch { result.retained.push(name); continue }
|
|
217
|
+
if (mtimeMs > 0 && Number.isFinite(mtimeMs) && nowMs - mtimeMs > ttlMs) {
|
|
218
|
+
try { unlinkSync(path); result.removed.push(name) } catch { result.retained.push(name) }
|
|
219
|
+
} else {
|
|
220
|
+
result.retained.push(name)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return result
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Counts for /api/health, so an operator can see the loop is banking evidence
|
|
227
|
+
* rather than having to trust that it is. */
|
|
228
|
+
export function chunkEmbeddingStoreStats(): {
|
|
229
|
+
enabled: boolean
|
|
230
|
+
sessions: number
|
|
231
|
+
bytes: number
|
|
232
|
+
ttlDays: number
|
|
233
|
+
oldestAgeHours: number | null
|
|
234
|
+
} {
|
|
235
|
+
const dir = dataPath(CHUNK_EMBEDDING_DIR)
|
|
236
|
+
const ttlDays = Math.round((chunkEmbeddingTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
|
|
237
|
+
if (!existsSync(dir)) {
|
|
238
|
+
return { enabled: chunkEmbeddingsEnabled(), sessions: 0, bytes: 0, ttlDays, oldestAgeHours: null }
|
|
239
|
+
}
|
|
240
|
+
let sessions = 0, bytes = 0, oldest = Number.POSITIVE_INFINITY
|
|
241
|
+
try {
|
|
242
|
+
for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
|
|
243
|
+
try {
|
|
244
|
+
const st = statSync(join(dir, name))
|
|
245
|
+
sessions++
|
|
246
|
+
bytes += st.size
|
|
247
|
+
oldest = Math.min(oldest, st.mtimeMs)
|
|
248
|
+
} catch { /* skip unreadable */ }
|
|
249
|
+
}
|
|
250
|
+
} catch { /* report what we have */ }
|
|
251
|
+
return {
|
|
252
|
+
enabled: chunkEmbeddingsEnabled(),
|
|
253
|
+
sessions,
|
|
254
|
+
bytes,
|
|
255
|
+
ttlDays,
|
|
256
|
+
oldestAgeHours: Number.isFinite(oldest)
|
|
257
|
+
? Math.round(((Date.now() - oldest) / 3_600_000) * 10) / 10
|
|
258
|
+
: null,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Which sample gets dropped when a voice profile is full.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NOT FIFO. Measured on the live store (2026-08-06): 61 of 77
|
|
4
|
+
// profiles are already at the 20-sample cap, so every correction costs a sample
|
|
5
|
+
// today. And the samples are not interchangeable — the owner's own profile, the
|
|
6
|
+
// one driving owner detection, reads:
|
|
7
|
+
//
|
|
8
|
+
// MU: 20/20 — fireflies 10, g2-training 9, unknown 1 (zero human-verified)
|
|
9
|
+
//
|
|
10
|
+
// `fireflies` labels come from meeting ATTENDEE METADATA, never acoustically
|
|
11
|
+
// verified. That is exactly the chain that manufactured the phantom "Erick
|
|
12
|
+
// Hernandez" profile: 3 fireflies seeds compounding to 18 via g2-training. Under
|
|
13
|
+
// plain FIFO, a human correction could evict the single `manual` sample while
|
|
14
|
+
// ten unverified metadata seeds sat untouched. Age is the wrong axis. Who
|
|
15
|
+
// supplied the label is the right one.
|
|
16
|
+
//
|
|
17
|
+
// THE COUNTER-ARGUMENT, honoured by the quota below. Corrections are drawn from
|
|
18
|
+
// the acoustically HARD tail — the chunks the identifier got wrong. A profile
|
|
19
|
+
// made entirely of hard cases has a centroid displaced from the speaker's
|
|
20
|
+
// typical voice, which could make routine matching worse. So corrections are
|
|
21
|
+
// protected from eviction but capped at half a profile, keeping typical-voice
|
|
22
|
+
// mass in the centroid.
|
|
23
|
+
|
|
24
|
+
/** Ordered weakest-first: eviction walks this list and takes from the first
|
|
25
|
+
* non-empty tier. */
|
|
26
|
+
export const EVICTION_ORDER = ['automatic', 'unknown', 'metadata', 'assisted', 'human'] as const
|
|
27
|
+
|
|
28
|
+
export type ProvenanceTier = typeof EVICTION_ORDER[number]
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Classify a sample by WHO supplied its label — not by how the audio arrived.
|
|
32
|
+
*
|
|
33
|
+
* Source strings appear both bare (`g2-training`) and prefixed
|
|
34
|
+
* (`auto:meeting_1774377156653`), both verified in the live store, so matching is
|
|
35
|
+
* on the prefix before any separator.
|
|
36
|
+
*/
|
|
37
|
+
export function provenanceTier(source: string | undefined | null): ProvenanceTier {
|
|
38
|
+
const head = String(source ?? '').trim().split(/[:_]/)[0].toLowerCase()
|
|
39
|
+
switch (head) {
|
|
40
|
+
// A human deliberately supplied the audio or named the voice.
|
|
41
|
+
case 'manual':
|
|
42
|
+
case 'correction':
|
|
43
|
+
case 'ext-retroactive':
|
|
44
|
+
case 'g2-enrollment':
|
|
45
|
+
return 'human'
|
|
46
|
+
// The identifier chose the label; a human approved the batch run.
|
|
47
|
+
case 'g2-training':
|
|
48
|
+
return 'assisted'
|
|
49
|
+
// Label came from attendee metadata and was never acoustically verified.
|
|
50
|
+
case 'fireflies':
|
|
51
|
+
return 'metadata'
|
|
52
|
+
// No human involvement at any point.
|
|
53
|
+
case 'auto':
|
|
54
|
+
return 'automatic'
|
|
55
|
+
default:
|
|
56
|
+
return 'unknown'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True for a sample created by a human correcting a specific meeting. */
|
|
61
|
+
export function isCorrection(source: string | undefined | null): boolean {
|
|
62
|
+
return String(source ?? '').trim().toLowerCase().startsWith('correction')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* How many of a profile's slots corrections may occupy.
|
|
67
|
+
*
|
|
68
|
+
* Half, rounded down. Above this the profile's centroid is dominated by the
|
|
69
|
+
* acoustically difficult segments that needed correcting, which is not what the
|
|
70
|
+
* speaker usually sounds like.
|
|
71
|
+
*/
|
|
72
|
+
export function correctionQuota(cap: number): number {
|
|
73
|
+
return Math.max(1, Math.floor(cap / 2))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface EvictionChoice {
|
|
77
|
+
/** Index into embeddings[]/sources[] to drop. */
|
|
78
|
+
index: number
|
|
79
|
+
tier: ProvenanceTier
|
|
80
|
+
/** Human-readable justification, logged so an eviction is never silent. */
|
|
81
|
+
reason: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Choose which sample to drop to make room for `incomingSource`.
|
|
86
|
+
*
|
|
87
|
+
* Returns null when the incoming sample should be REFUSED rather than evicting
|
|
88
|
+
* anything — which happens only when a correction would exceed its quota and
|
|
89
|
+
* every correction present is newer than it could justify replacing.
|
|
90
|
+
*/
|
|
91
|
+
export function chooseEviction(
|
|
92
|
+
sources: Array<string | undefined | null>,
|
|
93
|
+
incomingSource: string,
|
|
94
|
+
cap: number,
|
|
95
|
+
): EvictionChoice | null {
|
|
96
|
+
// No empty-array guard: the tier walk below returns null for an empty
|
|
97
|
+
// profile on its own (every findIndex is -1), and the quota branch cannot
|
|
98
|
+
// fire because the quota floor is 1. Verified by mutation — an explicit
|
|
99
|
+
// guard here is unreachable in effect.
|
|
100
|
+
const correctionIdx = sources.map((s, i) => ({ s, i })).filter(x => isCorrection(x.s)).map(x => x.i)
|
|
101
|
+
|
|
102
|
+
// A correction arriving at or above quota replaces the OLDEST CORRECTION, not
|
|
103
|
+
// a typical-voice sample. Corrections stay protected as a class while the
|
|
104
|
+
// newest human evidence still lands, and the centroid keeps its ordinary mass.
|
|
105
|
+
if (isCorrection(incomingSource) && correctionIdx.length >= correctionQuota(cap)) {
|
|
106
|
+
return {
|
|
107
|
+
index: correctionIdx[0],
|
|
108
|
+
tier: 'human',
|
|
109
|
+
reason: `corrections at quota (${correctionIdx.length}/${correctionQuota(cap)}) — replacing the oldest correction`,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const tier of EVICTION_ORDER) {
|
|
114
|
+
const idx = sources.findIndex(s => provenanceTier(s) === tier)
|
|
115
|
+
if (idx === -1) continue
|
|
116
|
+
// Reached only when the profile is entirely human-supplied. Age decides,
|
|
117
|
+
// because there is no weaker sample to give up.
|
|
118
|
+
const reason = tier === 'human'
|
|
119
|
+
? 'every sample is human-supplied — dropping the oldest'
|
|
120
|
+
: `dropping the oldest ${tier} sample (${String(sources[idx])}) rather than a stronger one`
|
|
121
|
+
return { index: idx, tier, reason }
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Tier counts for a profile, for logs and health output. */
|
|
127
|
+
export function tierBreakdown(sources: Array<string | undefined | null>): Record<ProvenanceTier, number> {
|
|
128
|
+
const out: Record<ProvenanceTier, number> = {
|
|
129
|
+
human: 0, assisted: 0, metadata: 0, automatic: 0, unknown: 0,
|
|
130
|
+
}
|
|
131
|
+
for (const s of sources) out[provenanceTier(s)]++
|
|
132
|
+
return out
|
|
133
|
+
}
|