@gotcos/glasses-server 6.21.10 → 6.21.13
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 +52 -0
- package/package.json +1 -1
- package/server/lib/meeting-speaker-review.ts +268 -0
- package/server/lib/meeting-store.ts +57 -2
- package/server/lib/speaker-calibration-log.ts +60 -0
- package/server/lib/speaker-embeddings.ts +104 -0
- package/server/lib/voice-profile-store.ts +142 -0
- package/server/routes/meeting.ts +56 -1
- package/server/routes/voice.ts +81 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,55 @@
|
|
|
1
|
+
## 6.21.13
|
|
2
|
+
|
|
3
|
+
- Carry each meeting's `sessionId` on the meetings list, so a Control row can
|
|
4
|
+
open the per-meeting speaker review. The review is keyed on the session
|
|
5
|
+
because that is what lets the store's own hardened lookup find the chunk
|
|
6
|
+
sidecar again; without this field the two surfaces could not be joined.
|
|
7
|
+
- Read only the head of a sidecar to lift that one field. Sidecars run to
|
|
8
|
+
megabytes, and reading them whole would make listing cost scale with total
|
|
9
|
+
transcript size — and silently drop the id on any sidecar above the
|
|
10
|
+
whole-file size cap.
|
|
11
|
+
- Omit the field rather than invent one when a sidecar is absent, corrupt,
|
|
12
|
+
symlinked outside its month directory, or carries an implausible id. A
|
|
13
|
+
meeting with no readable sidecar is still listed.
|
|
14
|
+
|
|
15
|
+
## 6.21.12
|
|
16
|
+
|
|
17
|
+
- Add `GET /api/meeting/:sessionId/speakers`, the read surface behind COS
|
|
18
|
+
Control's speaker-naming panel. Read-only: it reports what a saved meeting's
|
|
19
|
+
chunk sidecar already holds and never writes. Naming, merging, and rebuilding
|
|
20
|
+
stay on the `/api/voice/*` routes, each with its own confirmation.
|
|
21
|
+
- Return two to three representative verbatim lines per voice, spread across the
|
|
22
|
+
meeting and timestamped. These are the primary output: a similarity score
|
|
23
|
+
cannot tell you who someone is, and a remembered sentence can.
|
|
24
|
+
- Report per-voice reliability from run length rather than similarity alone. Two
|
|
25
|
+
labels that swap every few segments are the identifier oscillating mid-turn,
|
|
26
|
+
which means those profiles cannot be told apart and any name applied to either
|
|
27
|
+
would be a guess. A high similarity score does not override that verdict.
|
|
28
|
+
- Restrict the run-length comparison to each pair of speakers, so a third person
|
|
29
|
+
interjecting cannot make two others look like they are swapping.
|
|
30
|
+
- Report a recovered meeting as unattributed rather than as a meeting with no
|
|
31
|
+
speakers, and still return phrases for it — on those meetings the phrases are
|
|
32
|
+
the only way in.
|
|
33
|
+
|
|
34
|
+
## 6.21.11
|
|
35
|
+
|
|
36
|
+
- Add `POST /api/voice/merge-profiles` for the case where one person holds two
|
|
37
|
+
profiles. The sherpa manager registers one centroid per name, so a split
|
|
38
|
+
identity has both halves competing on every search and each capped at twenty
|
|
39
|
+
samples independently — a weaker representation than either half deserves.
|
|
40
|
+
- Refuse a merge whose centroid similarity falls below the search-accept
|
|
41
|
+
threshold, since two profiles further apart than the value at which
|
|
42
|
+
identification would match them are not one voice. `force` overrides and is
|
|
43
|
+
recorded in the response.
|
|
44
|
+
- Preserve provenance through a merge and select the surviving samples for
|
|
45
|
+
acoustic diversity, so capping the union keeps both profiles represented
|
|
46
|
+
rather than silently discarding the absorbed one.
|
|
47
|
+
- Relabel the absorbed name's calibration history instead of deleting it: after
|
|
48
|
+
a merge it is one person's history, and it is the only evidence for whether
|
|
49
|
+
the merge improved identification.
|
|
50
|
+
- Refuse to absorb the owner label, which the live identification path checks
|
|
51
|
+
first on every chunk.
|
|
52
|
+
|
|
1
53
|
## 6.21.10
|
|
2
54
|
|
|
3
55
|
- Make the voice profile store durable. `voice-profiles.json` is written
|
package/package.json
CHANGED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Per-meeting speaker review — the data behind COS Control's naming panel.
|
|
2
|
+
//
|
|
3
|
+
// The panel's job is to let a human name voices from what they REMEMBER, so the
|
|
4
|
+
// primary output here is verbatim phrases, not similarity scores. A score cannot
|
|
5
|
+
// tell you who someone is; "I'll follow up with both Andrews on music targeting"
|
|
6
|
+
// can.
|
|
7
|
+
//
|
|
8
|
+
// It also carries the diagnostic that actually catches a broken profile.
|
|
9
|
+
// Similarity between profile centroids does NOT distinguish "two people who
|
|
10
|
+
// sound alike" from "one voice split across two labels" — measured on the live
|
|
11
|
+
// store, Chris Krubeck and Luke Henry sat at 0.85 while genuinely being
|
|
12
|
+
// separate rows. What discriminates is RUN LENGTH within a single meeting:
|
|
13
|
+
//
|
|
14
|
+
// MU + Chris Krubeck (known two people) : flip rate 0.069, mean run 30.0
|
|
15
|
+
// Luke H + Luke Henry : flip rate 0.361, mean run 2.79
|
|
16
|
+
// Chris Krubeck + Luke Henry : flip rate 0.381, mean run 3.47
|
|
17
|
+
// Scott Taylor + Dylan Jackson : flip rate 0.344, mean run 2.80
|
|
18
|
+
//
|
|
19
|
+
// Real conversation holds the floor for ~30 consecutive segments. A pair that
|
|
20
|
+
// swaps every ~3 is the identifier oscillating mid-turn, which means those two
|
|
21
|
+
// profiles cannot be told apart and any name applied to either is a guess.
|
|
22
|
+
//
|
|
23
|
+
// Everything here is pure over a chunk array so it can be tested by execution.
|
|
24
|
+
|
|
25
|
+
/** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
|
|
26
|
+
export interface ReviewChunk {
|
|
27
|
+
text?: string
|
|
28
|
+
speaker?: string
|
|
29
|
+
/** MILLISECONDS from meeting start. Confirmed against the writer:
|
|
30
|
+
* meeting.ts accumulates `elapsed += row.durationMs`. Treating this as
|
|
31
|
+
* seconds reports a 32-minute meeting as 30,936 minutes. */
|
|
32
|
+
elapsed?: number
|
|
33
|
+
similarity?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Labels that mean "nobody was identified", not a person. */
|
|
37
|
+
export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker 2', 'Speaker 3'])
|
|
38
|
+
|
|
39
|
+
/** Calibrated from the control pair above. A pair must be BOTH flip-happy and
|
|
40
|
+
* short-run to be called unreliable — either alone has honest explanations
|
|
41
|
+
* (a rapid-fire exchange is flip-happy; a brief interjection is short-run). */
|
|
42
|
+
export const THRASH_FLIP_RATE = 0.20
|
|
43
|
+
export const THRASH_MEAN_RUN = 8
|
|
44
|
+
/** Below the search-accept threshold a name was never asserted with confidence. */
|
|
45
|
+
export const CONFIDENT_SIMILARITY = 0.65
|
|
46
|
+
|
|
47
|
+
export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
|
|
48
|
+
|
|
49
|
+
export interface ThrashPair {
|
|
50
|
+
speaker: string
|
|
51
|
+
flipRate: number
|
|
52
|
+
meanRun: number
|
|
53
|
+
sharedSegments: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Phrase {
|
|
57
|
+
text: string
|
|
58
|
+
/** Milliseconds from meeting start, matching the sidecar. */
|
|
59
|
+
atMs: number
|
|
60
|
+
similarity: number | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface VoiceReview {
|
|
64
|
+
label: string
|
|
65
|
+
segments: number
|
|
66
|
+
meanSimilarity: number | null
|
|
67
|
+
/** Mean consecutive-run length across the WHOLE meeting. Not comparable to
|
|
68
|
+
* the pair-scoped run in `thrashesWith`: in a 13-voice meeting every
|
|
69
|
+
* speaker's whole-sequence run is short (measured: 3.36 for the owner), so
|
|
70
|
+
* this is a talkativeness texture, never a reliability signal. */
|
|
71
|
+
meanRun: number
|
|
72
|
+
longestRun: number
|
|
73
|
+
isOwner: boolean
|
|
74
|
+
reliability: Reliability
|
|
75
|
+
thrashesWith: ThrashPair[]
|
|
76
|
+
phrases: Phrase[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MeetingSpeakerReview {
|
|
80
|
+
segments: number
|
|
81
|
+
/** False when no chunk carries a real speaker — a recovered capture. */
|
|
82
|
+
attributed: boolean
|
|
83
|
+
durationMs: number
|
|
84
|
+
voices: VoiceReview[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mean(xs: number[]): number {
|
|
88
|
+
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Consecutive-run lengths for one speaker across the whole meeting. */
|
|
92
|
+
export function speakerRuns(sequence: string[], speaker: string): number[] {
|
|
93
|
+
const runs: number[] = []
|
|
94
|
+
let cur = 0
|
|
95
|
+
for (const s of sequence) {
|
|
96
|
+
if (s === speaker) cur++
|
|
97
|
+
else if (cur > 0) { runs.push(cur); cur = 0 }
|
|
98
|
+
}
|
|
99
|
+
if (cur > 0) runs.push(cur)
|
|
100
|
+
return runs
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How often the label flips between exactly two speakers, ignoring everyone
|
|
105
|
+
* else. Restricting to the pair is the point: a third person interjecting
|
|
106
|
+
* should not make two other speakers look like they are thrashing.
|
|
107
|
+
*/
|
|
108
|
+
export function pairFlipRate(
|
|
109
|
+
sequence: string[],
|
|
110
|
+
a: string,
|
|
111
|
+
b: string,
|
|
112
|
+
): { flipRate: number; meanRun: number; sharedSegments: number } | null {
|
|
113
|
+
const pair = sequence.filter(s => s === a || s === b)
|
|
114
|
+
if (pair.length < 6) return null // too little to characterise
|
|
115
|
+
if (!pair.includes(a) || !pair.includes(b)) return null
|
|
116
|
+
|
|
117
|
+
let flips = 0
|
|
118
|
+
const runs: number[] = []
|
|
119
|
+
let cur = 1
|
|
120
|
+
for (let i = 1; i < pair.length; i++) {
|
|
121
|
+
if (pair[i] === pair[i - 1]) cur++
|
|
122
|
+
else { flips++; runs.push(cur); cur = 1 }
|
|
123
|
+
}
|
|
124
|
+
runs.push(cur)
|
|
125
|
+
return {
|
|
126
|
+
flipRate: flips / (pair.length - 1),
|
|
127
|
+
meanRun: mean(runs),
|
|
128
|
+
sharedSegments: pair.length,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Words that carry no identifying information. */
|
|
133
|
+
const FILLER = /^(?:yeah|yep|okay|ok|right|sure|mm+|uh+|um+|hmm+|got it|exactly|thanks?|no|yes)[.!?]?$/i
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Score a line by how much it would help a human recognise the speaker.
|
|
137
|
+
*
|
|
138
|
+
* Length matters (a longer line carries more voice), but so does specificity:
|
|
139
|
+
* a proper noun, a number, or a domain term is what makes someone say "that's
|
|
140
|
+
* Graham." A long line of pleasantries scores lower than a short line naming a
|
|
141
|
+
* brand and a figure.
|
|
142
|
+
*/
|
|
143
|
+
export function phraseScore(text: string): number {
|
|
144
|
+
const trimmed = text.trim()
|
|
145
|
+
if (!trimmed || FILLER.test(trimmed)) return 0
|
|
146
|
+
const words = trimmed.split(/\s+/)
|
|
147
|
+
if (words.length < 6) return 0
|
|
148
|
+
|
|
149
|
+
let score = Math.min(words.length, 28)
|
|
150
|
+
// Mid-sentence capitals — names, brands, products.
|
|
151
|
+
const propers = words.slice(1).filter(w => /^[A-Z][a-zA-Z]{2,}/.test(w)).length
|
|
152
|
+
score += Math.min(propers, 4) * 6
|
|
153
|
+
// Figures, percentages, money — highly memorable.
|
|
154
|
+
if (/\d/.test(trimmed)) score += 5
|
|
155
|
+
// Penalise a line that is mostly filler even if long.
|
|
156
|
+
const fillerWords = words.filter(w => FILLER.test(w)).length
|
|
157
|
+
score -= fillerWords * 3
|
|
158
|
+
return Math.max(score, 0)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pick up to `limit` representative lines, spread across the meeting.
|
|
163
|
+
*
|
|
164
|
+
* Spread is enforced deliberately: the three best-scoring lines often sit
|
|
165
|
+
* seconds apart in one monologue, which tells you far less about a speaker than
|
|
166
|
+
* three lines from the beginning, middle, and end. Every phrase carries its
|
|
167
|
+
* timestamp so a reviewer can place it against their memory of the meeting.
|
|
168
|
+
*/
|
|
169
|
+
export function selectPhrases(
|
|
170
|
+
chunks: ReviewChunk[],
|
|
171
|
+
speaker: string,
|
|
172
|
+
limit = 3,
|
|
173
|
+
durationMs = 0,
|
|
174
|
+
): Phrase[] {
|
|
175
|
+
const owned = chunks
|
|
176
|
+
.map((c, i) => ({ c, i }))
|
|
177
|
+
.filter(({ c }) => (c.speaker ?? '') === speaker)
|
|
178
|
+
.map(({ c }) => ({
|
|
179
|
+
text: (c.text ?? '').trim(),
|
|
180
|
+
atMs: typeof c.elapsed === 'number' ? c.elapsed : 0,
|
|
181
|
+
similarity: typeof c.similarity === 'number' ? c.similarity : null,
|
|
182
|
+
score: phraseScore(c.text ?? ''),
|
|
183
|
+
}))
|
|
184
|
+
.filter(p => p.score > 0)
|
|
185
|
+
|
|
186
|
+
if (owned.length === 0) return []
|
|
187
|
+
|
|
188
|
+
const span = durationMs > 0 ? durationMs : Math.max(...owned.map(p => p.atMs), 1)
|
|
189
|
+
const minGap = span / (limit * 2) // no two picks from the same stretch
|
|
190
|
+
|
|
191
|
+
const picked: typeof owned = []
|
|
192
|
+
for (const cand of [...owned].sort((a, b) => b.score - a.score)) {
|
|
193
|
+
if (picked.length >= limit) break
|
|
194
|
+
if (picked.some(p => Math.abs(p.atMs - cand.atMs) < minGap)) continue
|
|
195
|
+
picked.push(cand)
|
|
196
|
+
}
|
|
197
|
+
// Backfill on a short meeting where the gap rule excluded everything.
|
|
198
|
+
if (picked.length < limit) {
|
|
199
|
+
for (const cand of [...owned].sort((a, b) => b.score - a.score)) {
|
|
200
|
+
if (picked.length >= limit) break
|
|
201
|
+
if (!picked.includes(cand)) picked.push(cand)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return picked
|
|
205
|
+
.sort((a, b) => a.atMs - b.atMs)
|
|
206
|
+
.map(({ text, atMs, similarity }) => ({ text, atMs, similarity }))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Build the whole review for one meeting's chunks. */
|
|
210
|
+
export function reviewMeetingSpeakers(
|
|
211
|
+
chunks: ReviewChunk[],
|
|
212
|
+
options: { owner?: string; phrasesPerVoice?: number } = {},
|
|
213
|
+
): MeetingSpeakerReview {
|
|
214
|
+
const owner = options.owner ?? 'Me'
|
|
215
|
+
const limit = options.phrasesPerVoice ?? 3
|
|
216
|
+
const sequence = chunks.map(c => c.speaker ?? '')
|
|
217
|
+
const durationMs = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
|
|
218
|
+
|
|
219
|
+
const labels = [...new Set(sequence)].filter(s => s.length > 0)
|
|
220
|
+
const named = labels.filter(l => !UNATTRIBUTED.has(l))
|
|
221
|
+
|
|
222
|
+
const voices: VoiceReview[] = labels.map(label => {
|
|
223
|
+
const own = chunks.filter(c => (c.speaker ?? '') === label)
|
|
224
|
+
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
225
|
+
const runs = speakerRuns(sequence, label)
|
|
226
|
+
const unattributed = UNATTRIBUTED.has(label)
|
|
227
|
+
|
|
228
|
+
const thrashesWith: ThrashPair[] = []
|
|
229
|
+
if (!unattributed) {
|
|
230
|
+
for (const other of named) {
|
|
231
|
+
if (other === label) continue
|
|
232
|
+
const p = pairFlipRate(sequence, label, other)
|
|
233
|
+
if (!p) continue
|
|
234
|
+
if (p.flipRate > THRASH_FLIP_RATE && p.meanRun < THRASH_MEAN_RUN) {
|
|
235
|
+
thrashesWith.push({
|
|
236
|
+
speaker: other,
|
|
237
|
+
flipRate: Math.round(p.flipRate * 1000) / 1000,
|
|
238
|
+
meanRun: Math.round(p.meanRun * 100) / 100,
|
|
239
|
+
sharedSegments: p.sharedSegments,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
thrashesWith.sort((a, b) => b.flipRate - a.flipRate)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const meanSim = sims.length ? Math.round(mean(sims) * 1000) / 1000 : null
|
|
247
|
+
const reliability: Reliability = unattributed
|
|
248
|
+
? 'unattributed'
|
|
249
|
+
: thrashesWith.length > 0
|
|
250
|
+
? 'unreliable'
|
|
251
|
+
: (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
label,
|
|
255
|
+
segments: own.length,
|
|
256
|
+
meanSimilarity: meanSim,
|
|
257
|
+
meanRun: Math.round(mean(runs) * 100) / 100,
|
|
258
|
+
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
259
|
+
isOwner: label === owner,
|
|
260
|
+
reliability,
|
|
261
|
+
thrashesWith,
|
|
262
|
+
phrases: selectPhrases(chunks, label, limit, durationMs),
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
voices.sort((a, b) => b.segments - a.segments)
|
|
267
|
+
return { segments: chunks.length, attributed: named.length > 0, durationMs, voices }
|
|
268
|
+
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
mkdirSync,
|
|
9
9
|
openSync,
|
|
10
10
|
readFileSync,
|
|
11
|
+
readSync,
|
|
11
12
|
readdirSync,
|
|
12
13
|
realpathSync,
|
|
13
14
|
unlinkSync,
|
|
@@ -43,6 +44,11 @@ export class MeetingStoreError extends Error {
|
|
|
43
44
|
|
|
44
45
|
export interface MeetingMeta {
|
|
45
46
|
filename: string
|
|
47
|
+
/** From the chunk sidecar, absent on meetings saved without one. Carried so a
|
|
48
|
+
* list row can open the per-meeting speaker review, which is keyed on the
|
|
49
|
+
* session rather than the filename — the session is what lets the store's
|
|
50
|
+
* own hardened lookup find the sidecar again. */
|
|
51
|
+
sessionId?: string
|
|
46
52
|
title: string
|
|
47
53
|
date: string
|
|
48
54
|
domain: string
|
|
@@ -314,7 +320,7 @@ export function boundedMeetingSource(content: string): { sourceContent: string;
|
|
|
314
320
|
return { sourceContent: bytes.subarray(0, end).toString('utf8'), sourceTruncated: true }
|
|
315
321
|
}
|
|
316
322
|
|
|
317
|
-
function toMeta(detail: MeetingDetail): MeetingMeta {
|
|
323
|
+
function toMeta(detail: MeetingDetail, sessionId?: string): MeetingMeta {
|
|
318
324
|
const detailCharEstimate = [
|
|
319
325
|
detail.title,
|
|
320
326
|
detail.date,
|
|
@@ -327,6 +333,7 @@ function toMeta(detail: MeetingDetail): MeetingMeta {
|
|
|
327
333
|
].join('\n\n').trim().length
|
|
328
334
|
return {
|
|
329
335
|
filename: detail.filename,
|
|
336
|
+
...(sessionId ? { sessionId } : {}),
|
|
330
337
|
title: detail.title,
|
|
331
338
|
date: detail.date,
|
|
332
339
|
domain: detail.domain,
|
|
@@ -344,6 +351,9 @@ function toMeta(detail: MeetingDetail): MeetingMeta {
|
|
|
344
351
|
}
|
|
345
352
|
}
|
|
346
353
|
|
|
354
|
+
/** Enough to clear the sidecar's leading metadata keys whatever their order. */
|
|
355
|
+
const SIDECAR_HEAD_BYTES = 4096
|
|
356
|
+
|
|
347
357
|
function isContained(parent: string, child: string): boolean {
|
|
348
358
|
return child === parent || child.startsWith(`${parent}${sep}`)
|
|
349
359
|
}
|
|
@@ -542,7 +552,7 @@ export class MeetingStore {
|
|
|
542
552
|
if (content === null) continue
|
|
543
553
|
const detail = parseMeeting(content, filename, month)
|
|
544
554
|
if (domain !== 'all' && detail.domain !== domain) continue
|
|
545
|
-
meetings.push(toMeta(detail))
|
|
555
|
+
meetings.push(toMeta(detail, this.sidecarSessionId(monthDir, monthReal, filename)))
|
|
546
556
|
} catch {
|
|
547
557
|
// One unreadable/corrupt entry must not hide the rest of the store.
|
|
548
558
|
}
|
|
@@ -601,6 +611,51 @@ export class MeetingStore {
|
|
|
601
611
|
return this.safeReadFile(monthDir, monthReal, filename)
|
|
602
612
|
}
|
|
603
613
|
|
|
614
|
+
/** Read only the first `bytes` of a file, with the same symlink,
|
|
615
|
+
* containment, and O_NOFOLLOW guards as safeReadFile.
|
|
616
|
+
*
|
|
617
|
+
* Exists so `list()` can lift one field out of a chunk sidecar without
|
|
618
|
+
* reading it whole: sidecars run to megabytes (1.3 MB for a 32-minute
|
|
619
|
+
* meeting) and would also trip safeReadFile's MAX_MEETING_BYTES cap, which
|
|
620
|
+
* is sized for markdown. */
|
|
621
|
+
private safeReadFileHead(monthDir: string, monthReal: string, filename: string, bytes: number): string | null {
|
|
622
|
+
const filepath = join(monthDir, filename)
|
|
623
|
+
let fd: number | null = null
|
|
624
|
+
try {
|
|
625
|
+
const linkStat = lstatSync(filepath)
|
|
626
|
+
if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null
|
|
627
|
+
const real = realpathSync(filepath)
|
|
628
|
+
if (!isContained(monthReal, real) || dirname(real) !== monthReal) return null
|
|
629
|
+
fd = openSync(filepath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
630
|
+
const stat = fstatSync(fd)
|
|
631
|
+
if (!stat.isFile()) return null
|
|
632
|
+
const buffer = Buffer.alloc(Math.min(bytes, stat.size))
|
|
633
|
+
if (buffer.length === 0) return ''
|
|
634
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0)
|
|
635
|
+
return buffer.subarray(0, read).toString('utf8')
|
|
636
|
+
} catch {
|
|
637
|
+
return null
|
|
638
|
+
} finally {
|
|
639
|
+
if (fd !== null) {
|
|
640
|
+
try { closeSync(fd) } catch { /* already closed */ }
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The sessionId recorded in a meeting's chunk sidecar, if it has one.
|
|
646
|
+
*
|
|
647
|
+
* Matched with a regex over the file's head rather than JSON.parse: the goal
|
|
648
|
+
* is one small field, and parsing a megabyte of chunks per list row to reach
|
|
649
|
+
* it would make listing cost scale with total transcript size. */
|
|
650
|
+
private sidecarSessionId(monthDir: string, monthReal: string, meetingFilename: string): string | undefined {
|
|
651
|
+
const sidecarName = meetingFilename.replace(/\.md$/, '.g2-chunks.json')
|
|
652
|
+
if (sidecarName === meetingFilename) return undefined
|
|
653
|
+
const head = this.safeReadFileHead(monthDir, monthReal, sidecarName, SIDECAR_HEAD_BYTES)
|
|
654
|
+
if (!head) return undefined
|
|
655
|
+
const match = head.match(/"sessionId"\s*:\s*"([A-Za-z0-9:_-]{3,96})"/)
|
|
656
|
+
return match ? match[1] : undefined
|
|
657
|
+
}
|
|
658
|
+
|
|
604
659
|
private safeReadFile(monthDir: string, monthReal: string, filename: string): string | null {
|
|
605
660
|
const filepath = join(monthDir, filename)
|
|
606
661
|
let fd: number | null = null
|
|
@@ -62,6 +62,66 @@ export function filterCalibrationRows(
|
|
|
62
62
|
return { text: kept.length > 0 ? kept.join('\n') + '\n' : '', result }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Rename a speaker across the log, for a profile merge.
|
|
67
|
+
*
|
|
68
|
+
* Relabel rather than delete: a merge means the two names were always one
|
|
69
|
+
* person, so their calibration history is one history. Dropping the absorbed
|
|
70
|
+
* name's rows would silently discard exactly the evidence needed to tell
|
|
71
|
+
* whether the merge improved identification.
|
|
72
|
+
*/
|
|
73
|
+
export function relabelCalibrationRows(
|
|
74
|
+
raw: string,
|
|
75
|
+
fromName: string,
|
|
76
|
+
toName: string,
|
|
77
|
+
): { text: string; relabeled: number; retained: number; unparsable: number } {
|
|
78
|
+
const out: string[] = []
|
|
79
|
+
let relabeled = 0, retained = 0, unparsable = 0
|
|
80
|
+
for (const line of raw.split('\n')) {
|
|
81
|
+
if (line.trim() === '') continue
|
|
82
|
+
let row: Record<string, unknown>
|
|
83
|
+
try {
|
|
84
|
+
row = JSON.parse(line) as Record<string, unknown>
|
|
85
|
+
} catch {
|
|
86
|
+
unparsable++; retained++; out.push(line); continue
|
|
87
|
+
}
|
|
88
|
+
// `retained` counts every row that survives, relabeled or not, so
|
|
89
|
+
// `retained === total input rows` is the invariant a caller can assert: a
|
|
90
|
+
// merge must never lose history. (Contrast filterCalibrationRows, where
|
|
91
|
+
// removed + retained = total.)
|
|
92
|
+
retained++
|
|
93
|
+
if (row.speaker === fromName) {
|
|
94
|
+
row.speaker = toName
|
|
95
|
+
relabeled++
|
|
96
|
+
out.push(JSON.stringify(row))
|
|
97
|
+
} else {
|
|
98
|
+
out.push(line)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { text: out.length > 0 ? out.join('\n') + '\n' : '', relabeled, retained, unparsable }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Apply a merge relabel to the log on disk. */
|
|
105
|
+
export function relabelSpeakerCalibrationRows(
|
|
106
|
+
logPath: string,
|
|
107
|
+
fromName: string,
|
|
108
|
+
toName: string,
|
|
109
|
+
options: { dryRun?: boolean } = {},
|
|
110
|
+
): { relabeled: number; retained: number; unparsable: number } {
|
|
111
|
+
if (!existsSync(logPath)) return { relabeled: 0, retained: 0, unparsable: 0 }
|
|
112
|
+
let raw: string
|
|
113
|
+
try {
|
|
114
|
+
raw = readFileSync(logPath, 'utf-8')
|
|
115
|
+
} catch {
|
|
116
|
+
return { relabeled: 0, retained: 0, unparsable: 0 }
|
|
117
|
+
}
|
|
118
|
+
const { text, relabeled, retained, unparsable } = relabelCalibrationRows(raw, fromName, toName)
|
|
119
|
+
if (relabeled > 0 && !options.dryRun) {
|
|
120
|
+
durableAtomicWriteFileSync(logPath, text, { mode: 0o600 })
|
|
121
|
+
}
|
|
122
|
+
return { relabeled, retained, unparsable }
|
|
123
|
+
}
|
|
124
|
+
|
|
65
125
|
/** Rewrite the log without a given speaker's rows. */
|
|
66
126
|
export function purgeSpeakerCalibrationRows(
|
|
67
127
|
logPath: string,
|
|
@@ -12,6 +12,8 @@ import { existsSync, appendFileSync, mkdirSync, statSync, openSync, readSync, cl
|
|
|
12
12
|
import {
|
|
13
13
|
appendEmbedding,
|
|
14
14
|
deleteProfileFromStore,
|
|
15
|
+
mergeProfilesInStore,
|
|
16
|
+
profileSimilarity,
|
|
15
17
|
describeRepairs,
|
|
16
18
|
dropOldestEmbedding,
|
|
17
19
|
hasRepairs,
|
|
@@ -463,6 +465,108 @@ export function clearSpeakerEmbeddings(name: string): boolean {
|
|
|
463
465
|
return writeProfileStore(store)
|
|
464
466
|
}
|
|
465
467
|
|
|
468
|
+
/** The floor a merge must clear on centroid cosine.
|
|
469
|
+
*
|
|
470
|
+
* Set at the search-accept threshold on purpose: if two profiles are further
|
|
471
|
+
* apart than the value at which identification would accept a match between
|
|
472
|
+
* them, they are not the same voice and merging them would poison both. */
|
|
473
|
+
export const MERGE_SIMILARITY_FLOOR = SEARCH_THRESHOLD
|
|
474
|
+
|
|
475
|
+
export interface MergeReport {
|
|
476
|
+
into: string
|
|
477
|
+
merged: string[]
|
|
478
|
+
missing: string[]
|
|
479
|
+
similarity: Record<string, number>
|
|
480
|
+
samplesBefore: number
|
|
481
|
+
samplesAfter: number
|
|
482
|
+
droppedToCap: number
|
|
483
|
+
refused?: { name: string; similarity: number; floor: number }[]
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Fold profiles together — two names for one person, e.g. "Luke H" and
|
|
488
|
+
* "Luke Henry" at 0.843.
|
|
489
|
+
*
|
|
490
|
+
* Fails closed below MERGE_SIMILARITY_FLOOR unless `force`. That guard is the
|
|
491
|
+
* whole safety story: a wrong merge destroys BOTH identities at once, and the
|
|
492
|
+
* only evidence that two names are one person is acoustic, never the names
|
|
493
|
+
* themselves.
|
|
494
|
+
*/
|
|
495
|
+
export function mergeSpeakerProfiles(
|
|
496
|
+
into: string,
|
|
497
|
+
from: string[],
|
|
498
|
+
options: { force?: boolean; dryRun?: boolean } = {},
|
|
499
|
+
): MergeReport {
|
|
500
|
+
const store = loadProfileStore()
|
|
501
|
+
const target = store.profiles.find(p => p.name === into)
|
|
502
|
+
const report: MergeReport = {
|
|
503
|
+
into,
|
|
504
|
+
merged: [],
|
|
505
|
+
missing: [],
|
|
506
|
+
similarity: {},
|
|
507
|
+
samplesBefore: target?.embeddings.length ?? 0,
|
|
508
|
+
samplesAfter: target?.embeddings.length ?? 0,
|
|
509
|
+
droppedToCap: 0,
|
|
510
|
+
}
|
|
511
|
+
if (!target) {
|
|
512
|
+
report.missing = [into]
|
|
513
|
+
return report
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Score every candidate first so a dry run and a refusal report the same
|
|
517
|
+
// numbers the real merge would act on.
|
|
518
|
+
const eligible: string[] = []
|
|
519
|
+
const refused: { name: string; similarity: number; floor: number }[] = []
|
|
520
|
+
for (const name of from) {
|
|
521
|
+
if (name === into) continue
|
|
522
|
+
const source = store.profiles.find(p => p.name === name)
|
|
523
|
+
if (!source) { report.missing.push(name); continue }
|
|
524
|
+
const similarity = profileSimilarity(target, source)
|
|
525
|
+
report.similarity[name] = Math.round(similarity * 1000) / 1000
|
|
526
|
+
if (similarity < MERGE_SIMILARITY_FLOOR && !options.force) {
|
|
527
|
+
refused.push({ name, similarity: report.similarity[name], floor: MERGE_SIMILARITY_FLOOR })
|
|
528
|
+
continue
|
|
529
|
+
}
|
|
530
|
+
eligible.push(name)
|
|
531
|
+
}
|
|
532
|
+
if (refused.length > 0) report.refused = refused
|
|
533
|
+
if (eligible.length === 0) return report
|
|
534
|
+
|
|
535
|
+
if (options.dryRun) {
|
|
536
|
+
// Report on a throwaway copy so nothing is mutated by a preview.
|
|
537
|
+
const preview = JSON.parse(JSON.stringify(store)) as ProfileStore
|
|
538
|
+
const outcome = mergeProfilesInStore(preview, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER })
|
|
539
|
+
report.merged = outcome.mergedFrom
|
|
540
|
+
report.samplesAfter = outcome.samplesAfter
|
|
541
|
+
report.droppedToCap = outcome.droppedToCap
|
|
542
|
+
return report
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const outcome = mergeProfilesInStore(store, into, eligible, { cap: MAX_EMBEDDINGS_PER_SPEAKER })
|
|
546
|
+
report.merged = outcome.mergedFrom
|
|
547
|
+
report.samplesAfter = outcome.samplesAfter
|
|
548
|
+
report.droppedToCap = outcome.droppedToCap
|
|
549
|
+
|
|
550
|
+
// Manager first: drop the absorbed names so a stale centroid cannot keep
|
|
551
|
+
// winning searches after its profile is gone, then re-register the target
|
|
552
|
+
// from its new combined sample set.
|
|
553
|
+
//
|
|
554
|
+
// Routed through rebuildSpeakerInManager rather than an inline
|
|
555
|
+
// contains()/remove() pair: that helper already removes-then-returns on an
|
|
556
|
+
// empty sample list, so this is the one manager-mutation path in the file
|
|
557
|
+
// instead of a second one that could drift. NOTE: the manager is absent in
|
|
558
|
+
// tests (no 26 MB model), so this side effect has no test seam — the reason
|
|
559
|
+
// it is expressed as a single reused call rather than bespoke logic.
|
|
560
|
+
for (const name of outcome.mergedFrom) rebuildSpeakerInManager(name, [])
|
|
561
|
+
rebuildSpeakerInManager(into, target.embeddings)
|
|
562
|
+
writeProfileStore(store)
|
|
563
|
+
console.log(
|
|
564
|
+
`[speaker] Merged ${outcome.mergedFrom.join(', ')} into "${into}"`,
|
|
565
|
+
`(${report.samplesBefore} + absorbed → ${report.samplesAfter}, ${report.droppedToCap} dropped to cap)`,
|
|
566
|
+
)
|
|
567
|
+
return report
|
|
568
|
+
}
|
|
569
|
+
|
|
466
570
|
/** Remove a person entirely: profile, embeddings, and manager registration.
|
|
467
571
|
* Returns counts so a caller can report what was actually removed. */
|
|
468
572
|
export function removeSpeakerProfile(name: string): { removedProfiles: number; removedEmbeddings: number } {
|
|
@@ -347,6 +347,148 @@ export function removeEmbeddingsBySource(
|
|
|
347
347
|
return removed
|
|
348
348
|
}
|
|
349
349
|
|
|
350
|
+
/** Cosine similarity between two raw rows. Local so this module stays free of
|
|
351
|
+
* the sherpa runtime and can be unit-tested without a 26 MB model. */
|
|
352
|
+
export function rowCosine(a: number[], b: number[]): number {
|
|
353
|
+
if (a.length !== b.length || a.length === 0) return 0
|
|
354
|
+
let dot = 0, na = 0, nb = 0
|
|
355
|
+
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i] }
|
|
356
|
+
const denom = Math.sqrt(na) * Math.sqrt(nb)
|
|
357
|
+
return denom > 0 ? dot / denom : 0
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** L2-normalized mean of the rows that share the modal dimension. */
|
|
361
|
+
export function profileCentroid(embeddings: number[][]): number[] {
|
|
362
|
+
const dim = modalDimension(embeddings)
|
|
363
|
+
if (dim === 0) return []
|
|
364
|
+
const usable = embeddings.filter(row => row.length === dim)
|
|
365
|
+
if (usable.length === 0) return []
|
|
366
|
+
const c = new Array<number>(dim).fill(0)
|
|
367
|
+
for (const row of usable) for (let i = 0; i < dim; i++) c[i] += row[i]
|
|
368
|
+
for (let i = 0; i < dim; i++) c[i] /= usable.length
|
|
369
|
+
const norm = Math.sqrt(c.reduce((sum, v) => sum + v * v, 0))
|
|
370
|
+
return norm > 0 ? c.map(v => v / norm) : c
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** How close two profiles' centroids are. This is the number a merge must be
|
|
374
|
+
* justified by: names are not evidence, and `Miles Mallard` / `Manoj Kumar`
|
|
375
|
+
* are different humans who would merge happily on a name heuristic. */
|
|
376
|
+
export function profileSimilarity(a: VoiceProfile, b: VoiceProfile): number {
|
|
377
|
+
return rowCosine(profileCentroid(a.embeddings), profileCentroid(b.embeddings))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Pick the N most acoustically diverse samples, carrying provenance along.
|
|
381
|
+
*
|
|
382
|
+
* A merge routinely produces more samples than the per-speaker cap (two
|
|
383
|
+
* capped profiles make 40 against a cap of 20), and which 20 survive matters:
|
|
384
|
+
* taking the first N would keep one profile's acoustic conditions and discard
|
|
385
|
+
* the other's, which is the opposite of what merging is for. Greedy
|
|
386
|
+
* max-min-distance keeps the spread.
|
|
387
|
+
*
|
|
388
|
+
* Returns indices so the caller can slice embeddings and sources together. */
|
|
389
|
+
export function selectDiverseIndices(embeddings: number[][], maxN: number): number[] {
|
|
390
|
+
if (embeddings.length <= maxN) return embeddings.map((_, i) => i)
|
|
391
|
+
|
|
392
|
+
// Seed with the two most dissimilar rows.
|
|
393
|
+
let worstPair = Number.POSITIVE_INFINITY, seedA = 0, seedB = 1
|
|
394
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
395
|
+
for (let j = i + 1; j < embeddings.length; j++) {
|
|
396
|
+
const sim = rowCosine(embeddings[i], embeddings[j])
|
|
397
|
+
if (sim < worstPair) { worstPair = sim; seedA = i; seedB = j }
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const chosen = [seedA, seedB]
|
|
401
|
+
const taken = new Set(chosen)
|
|
402
|
+
while (chosen.length < maxN) {
|
|
403
|
+
let best = -1, bestMinDist = -Infinity
|
|
404
|
+
for (let i = 0; i < embeddings.length; i++) {
|
|
405
|
+
if (taken.has(i)) continue
|
|
406
|
+
let minDist = Infinity
|
|
407
|
+
for (const c of chosen) {
|
|
408
|
+
const dist = 1 - rowCosine(embeddings[i], embeddings[c])
|
|
409
|
+
if (dist < minDist) minDist = dist
|
|
410
|
+
}
|
|
411
|
+
if (minDist > bestMinDist) { bestMinDist = minDist; best = i }
|
|
412
|
+
}
|
|
413
|
+
if (best === -1) break
|
|
414
|
+
chosen.push(best)
|
|
415
|
+
taken.add(best)
|
|
416
|
+
}
|
|
417
|
+
// Ascending so the surviving order still reflects enrollment order.
|
|
418
|
+
return chosen.sort((x, y) => x - y)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
export interface MergeOutcome {
|
|
422
|
+
/** Centroid cosine between the target and each source, before merging. */
|
|
423
|
+
similarity: Record<string, number>
|
|
424
|
+
samplesBefore: number
|
|
425
|
+
samplesAfter: number
|
|
426
|
+
/** Samples discarded by the cap, not by the merge itself. */
|
|
427
|
+
droppedToCap: number
|
|
428
|
+
mergedFrom: string[]
|
|
429
|
+
missing: string[]
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Fold one or more profiles into another.
|
|
434
|
+
*
|
|
435
|
+
* Provenance is deliberately PRESERVED rather than restamped `merged:*`: the
|
|
436
|
+
* per-source retraction path is what makes a poisoned `auto:<sessionId>` sample
|
|
437
|
+
* removable later, and overwriting it to record a bookkeeping event would trade
|
|
438
|
+
* a useful fact for a useless one.
|
|
439
|
+
*/
|
|
440
|
+
export function mergeProfilesInStore(
|
|
441
|
+
store: ProfileStore,
|
|
442
|
+
into: string,
|
|
443
|
+
from: string[],
|
|
444
|
+
options: { cap?: number } = {},
|
|
445
|
+
): MergeOutcome {
|
|
446
|
+
const cap = options.cap ?? 20
|
|
447
|
+
const target = store.profiles.find(p => p.name === into)
|
|
448
|
+
const outcome: MergeOutcome = {
|
|
449
|
+
similarity: {},
|
|
450
|
+
samplesBefore: target?.embeddings.length ?? 0,
|
|
451
|
+
samplesAfter: target?.embeddings.length ?? 0,
|
|
452
|
+
droppedToCap: 0,
|
|
453
|
+
mergedFrom: [],
|
|
454
|
+
missing: [],
|
|
455
|
+
}
|
|
456
|
+
if (!target) {
|
|
457
|
+
outcome.missing = [into, ...from]
|
|
458
|
+
return outcome
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const embeddings = [...target.embeddings]
|
|
462
|
+
const sources = [...(target.sources ?? [])]
|
|
463
|
+
while (sources.length < embeddings.length) sources.push(UNKNOWN_SOURCE)
|
|
464
|
+
|
|
465
|
+
for (const name of from) {
|
|
466
|
+
if (name === into) continue
|
|
467
|
+
const source = store.profiles.find(p => p.name === name)
|
|
468
|
+
if (!source) { outcome.missing.push(name); continue }
|
|
469
|
+
outcome.similarity[name] = profileSimilarity(target, source)
|
|
470
|
+
const sourceSources = [...(source.sources ?? [])]
|
|
471
|
+
while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
|
|
472
|
+
for (let i = 0; i < source.embeddings.length; i++) {
|
|
473
|
+
embeddings.push(source.embeddings[i])
|
|
474
|
+
sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
|
|
475
|
+
}
|
|
476
|
+
outcome.mergedFrom.push(name)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (outcome.mergedFrom.length === 0) return outcome
|
|
480
|
+
|
|
481
|
+
const keep = selectDiverseIndices(embeddings, cap)
|
|
482
|
+
outcome.droppedToCap = embeddings.length - keep.length
|
|
483
|
+
target.embeddings = keep.map(i => embeddings[i])
|
|
484
|
+
target.sources = keep.map(i => sources[i])
|
|
485
|
+
outcome.samplesAfter = target.embeddings.length
|
|
486
|
+
|
|
487
|
+
const removed = new Set(outcome.mergedFrom)
|
|
488
|
+
store.profiles = store.profiles.filter(p => !removed.has(p.name))
|
|
489
|
+
return outcome
|
|
490
|
+
}
|
|
491
|
+
|
|
350
492
|
/** Delete a person's profile outright. Returns what was removed so the caller
|
|
351
493
|
* can report a per-store count instead of a bare success. */
|
|
352
494
|
export function deleteProfileFromStore(
|
package/server/routes/meeting.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
3
3
|
// are durable before the session is closed; batch improvement runs afterward.
|
|
4
4
|
|
|
5
|
-
import { existsSync, readdirSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { Router } from 'express'
|
|
8
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
@@ -64,6 +64,8 @@ import {
|
|
|
64
64
|
type TranscriptGapReport,
|
|
65
65
|
} from './transcribe-stream.js'
|
|
66
66
|
import { getServerInstanceId } from '../lib/server-instance-id.js'
|
|
67
|
+
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
68
|
+
import { reviewMeetingSpeakers, type ReviewChunk } from '../lib/meeting-speaker-review.js'
|
|
67
69
|
import {
|
|
68
70
|
acquireMaintenanceWork,
|
|
69
71
|
maintenanceAdmissionsOpen,
|
|
@@ -613,6 +615,59 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
613
615
|
}
|
|
614
616
|
})
|
|
615
617
|
|
|
618
|
+
// ── Speaker review (6.21.12) ──────────────────────────────────────────
|
|
619
|
+
// Backs COS Control's naming panel. Read-only: it reports what a saved
|
|
620
|
+
// meeting's sidecar already contains and never writes. Naming, merging, and
|
|
621
|
+
// rebuilding are the /api/voice/* routes, each with its own confirmation.
|
|
622
|
+
//
|
|
623
|
+
// Keyed on sessionId so it can reuse the store's traversal-hardened readers
|
|
624
|
+
// (safeDirectoryRealpath / safeReadFile) instead of reassembling a path from
|
|
625
|
+
// client-supplied domain and filename components.
|
|
626
|
+
router.get('/meeting/:sessionId/speakers', (req, res) => {
|
|
627
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
628
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
629
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
const saved = store.findBySessionId(sessionId)
|
|
633
|
+
if (!saved) {
|
|
634
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let chunks: unknown
|
|
639
|
+
try {
|
|
640
|
+
const raw = JSON.parse(readFileSync(saved.sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
641
|
+
chunks = Array.isArray(raw) ? raw : raw.chunks
|
|
642
|
+
} catch {
|
|
643
|
+
// Defensive: findBySessionId already parsed this sidecar to match the
|
|
644
|
+
// session, so a corrupt file 404s above and never reaches here. This
|
|
645
|
+
// covers the narrow race where it becomes unreadable in between. Either
|
|
646
|
+
// way the answer is never 200-with-no-voices, which would read as
|
|
647
|
+
// "nobody spoke" and invite naming voices that were never analysed.
|
|
648
|
+
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if (!Array.isArray(chunks)) {
|
|
652
|
+
res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
|
|
657
|
+
owner: getOwnerSpeakerLabel(),
|
|
658
|
+
phrasesPerVoice: Math.max(1, Math.min(6, Number(req.query.phrases) || 3)),
|
|
659
|
+
})
|
|
660
|
+
res.set('Cache-Control', 'private, no-store')
|
|
661
|
+
res.json({
|
|
662
|
+
sessionId,
|
|
663
|
+
title: saved.title,
|
|
664
|
+
domain: saved.domain,
|
|
665
|
+
filename: saved.filename,
|
|
666
|
+
durationMin: saved.durationMin,
|
|
667
|
+
...review,
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
|
|
616
671
|
// ── Unsaved-capture recovery (6.19.0) ─────────────────────────────────
|
|
617
672
|
// Surface-only by decision (Miles, 2026-08-02): the server NEVER drives
|
|
618
673
|
// recovery on its own. It lists what the quarantine holds, and one
|
package/server/routes/voice.ts
CHANGED
|
@@ -4,12 +4,12 @@ import { Router } from 'express'
|
|
|
4
4
|
import { errMsg } from '../lib/utils.js'
|
|
5
5
|
import { readdirSync, readFileSync, unlinkSync, existsSync, rmdirSync, rmSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
|
-
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles } from '../lib/speaker-embeddings.js'
|
|
7
|
+
import { enrollSpeaker, isEnrolled, getAllSpeakerNames, identifySpeaker, extractEmbedding, enrollEmbedding, rawCosineSimilarity, getEmbeddingCount, removeSpeakerProfile, readVoiceProfiles, mergeSpeakerProfiles } from '../lib/speaker-embeddings.js'
|
|
8
8
|
import { statSync } from 'node:fs'
|
|
9
9
|
import { trainFromFireflies, getTrainingStatus } from '../lib/speaker-trainer.js'
|
|
10
10
|
import { getOwnerSpeakerLabel } from '../lib/profile.js'
|
|
11
11
|
import { dataPath } from '../lib/data-dir.js'
|
|
12
|
-
import { purgeSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
12
|
+
import { purgeSpeakerCalibrationRows, relabelSpeakerCalibrationRows } from '../lib/speaker-calibration-log.js'
|
|
13
13
|
|
|
14
14
|
// These MUST match the writer in transcribe-stream.ts, which saves under
|
|
15
15
|
// dataPath(). They previously resolved relative to __dirname — i.e. inside the
|
|
@@ -444,6 +444,85 @@ voiceRouter.get('/voice/profiles', (_req, res) => {
|
|
|
444
444
|
}
|
|
445
445
|
})
|
|
446
446
|
|
|
447
|
+
// POST /api/voice/merge-profiles — fold two names for one person together.
|
|
448
|
+
// Body: { into, from: string[]|string, confirm: true, dryRun?, force? }
|
|
449
|
+
//
|
|
450
|
+
// Two profiles for one voice is worse than it looks: the sherpa manager holds
|
|
451
|
+
// one centroid per NAME, so both compete on every search and each is capped at
|
|
452
|
+
// 20 samples independently — 40 samples of one person, split, each half a
|
|
453
|
+
// weaker representation of them than the union would be.
|
|
454
|
+
//
|
|
455
|
+
// Fails closed below the search-accept threshold. A wrong merge destroys BOTH
|
|
456
|
+
// identities at once and cannot be undone from the store alone, so the only
|
|
457
|
+
// acceptable evidence is acoustic. `force` exists for the case where Miles
|
|
458
|
+
// knows something the audio does not, and it is logged.
|
|
459
|
+
voiceRouter.post('/voice/merge-profiles', (req, res) => {
|
|
460
|
+
try {
|
|
461
|
+
const into = typeof req.body?.into === 'string' ? req.body.into.trim() : ''
|
|
462
|
+
const rawFrom = req.body?.from
|
|
463
|
+
const from = (Array.isArray(rawFrom) ? rawFrom : [rawFrom])
|
|
464
|
+
.filter((n: unknown): n is string => typeof n === 'string' && n.trim().length > 0)
|
|
465
|
+
.map((n: string) => n.trim())
|
|
466
|
+
|
|
467
|
+
if (!into || from.length === 0) {
|
|
468
|
+
return res.status(400).json({ error: 'into (string) and from (string or string[]) are required' })
|
|
469
|
+
}
|
|
470
|
+
if (from.includes(into)) {
|
|
471
|
+
return res.status(400).json({ error: 'into and from must differ' })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const owner = getOwnerSpeakerLabel()
|
|
475
|
+
if (from.includes(owner)) {
|
|
476
|
+
// Absorbing the owner label would delete the profile the live
|
|
477
|
+
// identification path checks FIRST, on every chunk.
|
|
478
|
+
return res.status(400).json({
|
|
479
|
+
error: `refusing to absorb the owner label "${owner}" — merge INTO it instead`,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const dryRun = req.body?.dryRun === true
|
|
484
|
+
const force = req.body?.force === true
|
|
485
|
+
|
|
486
|
+
if (req.body?.confirm !== true && !dryRun) {
|
|
487
|
+
const preview = mergeSpeakerProfiles(into, from, { force, dryRun: true })
|
|
488
|
+
return res.status(400).json({
|
|
489
|
+
error: 'confirmation required',
|
|
490
|
+
message: `Merging is not reversible from the store alone. Review the similarity scores, then pass { confirm: true }.`,
|
|
491
|
+
preview,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const report = mergeSpeakerProfiles(into, from, { force, dryRun })
|
|
496
|
+
|
|
497
|
+
if (report.missing.length > 0 && report.merged.length === 0) {
|
|
498
|
+
return res.status(404).json({ error: 'no such profile(s)', missing: report.missing, report })
|
|
499
|
+
}
|
|
500
|
+
if (report.refused && report.merged.length === 0) {
|
|
501
|
+
return res.status(409).json({
|
|
502
|
+
error: 'similarity below the merge floor',
|
|
503
|
+
message: 'These centroids are further apart than the threshold at which identification would '
|
|
504
|
+
+ 'accept a match between them, so they are probably different people. Pass { force: true } '
|
|
505
|
+
+ 'only if you know they are the same person.',
|
|
506
|
+
report,
|
|
507
|
+
})
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Relabel rather than drop the absorbed name's calibration history: after a
|
|
511
|
+
// merge it is one person's history, and it is the only evidence for whether
|
|
512
|
+
// the merge improved identification.
|
|
513
|
+
const calibration: Record<string, number> = {}
|
|
514
|
+
if (!dryRun) {
|
|
515
|
+
for (const name of report.merged) {
|
|
516
|
+
calibration[name] = relabelSpeakerCalibrationRows(CALIBRATION_LOG, name, into).relabeled
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
res.json({ ...report, dryRun, forced: force, calibrationRowsRelabeled: calibration })
|
|
521
|
+
} catch (err: unknown) {
|
|
522
|
+
res.status(500).json({ error: errMsg(err) })
|
|
523
|
+
}
|
|
524
|
+
})
|
|
525
|
+
|
|
447
526
|
// POST /api/voice/delete-person — remove one person from every store that
|
|
448
527
|
// carries their name. Body: { name, confirm: true, dryRun? }
|
|
449
528
|
//
|