@gotcos/glasses-server 6.21.22 → 6.21.23
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 +20 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-diagnostics.ts +146 -0
- package/server/routes/meeting.ts +53 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## 6.21.23
|
|
2
|
+
|
|
3
|
+
- `GET /meeting/:sessionId/embeddings` — why each chunk was labelled the way it
|
|
4
|
+
was. Reads the per-chunk embeddings the pipeline has retained since 6.21.15
|
|
5
|
+
and scores each one against every enrolled profile now, returning the top
|
|
6
|
+
matches and the margin between the best two. Until this route that store had
|
|
7
|
+
**no production reader at all**: the data was collected for weeks and never
|
|
8
|
+
looked at.
|
|
9
|
+
|
|
10
|
+
The margin is the point. It separates "missed by 0.02 against one profile"
|
|
11
|
+
from "equidistant between three" — a fixable near-miss versus a genuinely
|
|
12
|
+
ambiguous voice — and the review panel cannot tell those apart today. On a
|
|
13
|
+
face-mounted microphone that distinction is most of the available signal.
|
|
14
|
+
|
|
15
|
+
Read-only, and deliberately does NOT return the raw 192-float vectors: ~1 KB
|
|
16
|
+
of base64 per chunk that means nothing to a reader. Whole-session reads are
|
|
17
|
+
capped (default 50, max 400) because each chunk is scored against every
|
|
18
|
+
profile. A retained-but-absent chunk is reported in `missing` rather than
|
|
19
|
+
dropped, and `retained:false` stays distinguishable from "scored, no match".
|
|
20
|
+
|
|
1
21
|
## 6.21.22
|
|
2
22
|
|
|
3
23
|
- The meeting Turbo preview is ON by default. `COS_WHISPER_MEETING_PREVIEW` is
|
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Why a chunk was labelled the way it was.
|
|
2
|
+
//
|
|
3
|
+
// `chunk-embedding-store.ts` has retained a per-chunk embedding for every
|
|
4
|
+
// identified chunk, for 14 days, since 6.21.15 — and until now NOTHING in
|
|
5
|
+
// production read it back. The reader helpers existed; no route called them.
|
|
6
|
+
//
|
|
7
|
+
// That store is the difference between a reviewer (human or agent) who can only
|
|
8
|
+
// restate what the panel already shows and one who can answer the question that
|
|
9
|
+
// actually matters on a face-mounted microphone: not "who is this", but "how
|
|
10
|
+
// close did we get, and to WHAT". A row that missed its match by 0.02 against
|
|
11
|
+
// one profile is a very different problem from a row sitting equidistant
|
|
12
|
+
// between three, and the panel cannot tell them apart today.
|
|
13
|
+
//
|
|
14
|
+
// WHAT THIS DELIBERATELY DOES NOT RETURN: the raw 192-float vectors. They are
|
|
15
|
+
// ~1 KB of base64 each, they mean nothing to a reader, and a 400-chunk meeting
|
|
16
|
+
// would be 400 KB of noise. Similarity against each enrolled profile is the
|
|
17
|
+
// diagnostic; the vector is just how it is computed.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
chunkEmbeddingsForIndices,
|
|
21
|
+
readChunkEmbeddings,
|
|
22
|
+
type ChunkEmbeddingRow,
|
|
23
|
+
} from './chunk-embedding-store.js'
|
|
24
|
+
import { rawCosineSimilarity, readVoiceProfiles } from './speaker-embeddings.js'
|
|
25
|
+
|
|
26
|
+
/** Profiles scored per chunk. More than this is noise in a review context. */
|
|
27
|
+
const TOP_MATCHES = 5
|
|
28
|
+
|
|
29
|
+
export interface ProfileMatch {
|
|
30
|
+
speaker: string
|
|
31
|
+
/** Best cosine against any embedding held for that profile. */
|
|
32
|
+
similarity: number
|
|
33
|
+
/** How many embeddings that profile holds, so a strong score against a
|
|
34
|
+
* 1-sample profile is not read as equal to one against 20. */
|
|
35
|
+
embeddings: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ChunkDiagnostic {
|
|
39
|
+
chunk: number
|
|
40
|
+
/** The label the identifier chose live, which is what a correction corrects. */
|
|
41
|
+
chosen: string
|
|
42
|
+
/** The score it chose on, as recorded at capture time. */
|
|
43
|
+
chosenSimilarity: number
|
|
44
|
+
/** Best-scoring profiles NOW, recomputed against the current store — which
|
|
45
|
+
* can differ from capture time if the profile has been trained since. */
|
|
46
|
+
matches: ProfileMatch[]
|
|
47
|
+
/** Gap between the top two current matches. A small margin means the choice
|
|
48
|
+
* was nearly a coin flip, and that is invisible in the panel today. */
|
|
49
|
+
margin: number | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ChunkDiagnosticsResult {
|
|
53
|
+
sessionId: string
|
|
54
|
+
/** False when the store holds nothing for this session — aged out past the
|
|
55
|
+
* 14-day TTL, captured before 6.21.15, or embeddings disabled. Distinct from
|
|
56
|
+
* an empty result set so a caller never reads "no data" as "no match". */
|
|
57
|
+
retained: boolean
|
|
58
|
+
chunks: ChunkDiagnostic[]
|
|
59
|
+
/** Chunks asked for that the store does not hold. */
|
|
60
|
+
missing: number[]
|
|
61
|
+
/** Profiles the scores were computed against, so a reader can see the
|
|
62
|
+
* candidate pool rather than assume it. */
|
|
63
|
+
profileCount: number
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Score one embedding against every enrolled profile.
|
|
68
|
+
*
|
|
69
|
+
* Best-of rather than mean: a profile holds up to 20 embeddings spanning
|
|
70
|
+
* different rooms and microphones, and averaging them buries the one recorded
|
|
71
|
+
* in conditions like these.
|
|
72
|
+
*/
|
|
73
|
+
function scoreAgainstProfiles(embedding: Float32Array): ProfileMatch[] {
|
|
74
|
+
const store = readVoiceProfiles()
|
|
75
|
+
const scored: ProfileMatch[] = []
|
|
76
|
+
for (const profile of store.profiles) {
|
|
77
|
+
let best = -1
|
|
78
|
+
for (const candidate of profile.embeddings) {
|
|
79
|
+
// The profile store holds plain number[]; the chunk store holds
|
|
80
|
+
// Float32Array. Convert at the boundary rather than widening the
|
|
81
|
+
// similarity function, which is on the live identification hot path.
|
|
82
|
+
const value = rawCosineSimilarity(embedding, new Float32Array(candidate))
|
|
83
|
+
if (value > best) best = value
|
|
84
|
+
}
|
|
85
|
+
if (best > -1) {
|
|
86
|
+
scored.push({
|
|
87
|
+
speaker: profile.name,
|
|
88
|
+
similarity: Number(best.toFixed(4)),
|
|
89
|
+
embeddings: profile.embeddings.length,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
scored.sort((a, b) => b.similarity - a.similarity)
|
|
94
|
+
return scored.slice(0, TOP_MATCHES)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function toDiagnostic(row: ChunkEmbeddingRow): ChunkDiagnostic {
|
|
98
|
+
const matches = scoreAgainstProfiles(row.embedding)
|
|
99
|
+
return {
|
|
100
|
+
chunk: row.i,
|
|
101
|
+
chosen: row.speaker,
|
|
102
|
+
chosenSimilarity: Number(row.similarity.toFixed(4)),
|
|
103
|
+
matches,
|
|
104
|
+
margin: matches.length >= 2
|
|
105
|
+
? Number((matches[0].similarity - matches[1].similarity).toFixed(4))
|
|
106
|
+
: null,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Diagnostics for specific chunks, or for the whole session when `indices` is
|
|
112
|
+
* empty.
|
|
113
|
+
*
|
|
114
|
+
* Bounded by `limit` because a long meeting holds hundreds of chunks and each
|
|
115
|
+
* one is scored against every profile — a whole-session call on a 400-chunk
|
|
116
|
+
* meeting with 77 profiles is 30,000 cosine comparisons. Fine to ask for, worth
|
|
117
|
+
* capping by default.
|
|
118
|
+
*/
|
|
119
|
+
export function chunkDiagnostics(
|
|
120
|
+
sessionId: string,
|
|
121
|
+
indices: number[] = [],
|
|
122
|
+
limit = 50,
|
|
123
|
+
): ChunkDiagnosticsResult {
|
|
124
|
+
const profileCount = readVoiceProfiles().profiles.length
|
|
125
|
+
|
|
126
|
+
if (indices.length > 0) {
|
|
127
|
+
const rows = chunkEmbeddingsForIndices(sessionId, indices)
|
|
128
|
+
const found = new Set(rows.map(r => r.i))
|
|
129
|
+
return {
|
|
130
|
+
sessionId,
|
|
131
|
+
retained: readChunkEmbeddings(sessionId).rows.length > 0,
|
|
132
|
+
chunks: rows.slice(0, limit).map(toDiagnostic),
|
|
133
|
+
missing: indices.filter(i => !found.has(i)),
|
|
134
|
+
profileCount,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const all = readChunkEmbeddings(sessionId).rows
|
|
139
|
+
return {
|
|
140
|
+
sessionId,
|
|
141
|
+
retained: all.length > 0,
|
|
142
|
+
chunks: all.slice(0, limit).map(toDiagnostic),
|
|
143
|
+
missing: [],
|
|
144
|
+
profileCount,
|
|
145
|
+
}
|
|
146
|
+
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
|
11
11
|
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
13
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
|
+
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
15
|
+
import { errMsg } from '../lib/utils.js'
|
|
14
16
|
import {
|
|
15
17
|
extAudioChunkPath,
|
|
16
18
|
listExtAudioChunks,
|
|
@@ -1182,6 +1184,57 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1182
1184
|
sendAudioFile(res, path)
|
|
1183
1185
|
})
|
|
1184
1186
|
|
|
1187
|
+
/**
|
|
1188
|
+
* Why each chunk was labelled the way it was.
|
|
1189
|
+
*
|
|
1190
|
+
* Reads the per-chunk embeddings the pipeline has retained since 6.21.15 and
|
|
1191
|
+
* scores each one against every enrolled profile RIGHT NOW. Until this route
|
|
1192
|
+
* that store had no production reader at all — the data was collected and
|
|
1193
|
+
* never looked at.
|
|
1194
|
+
*
|
|
1195
|
+
* This is what lets a reviewer distinguish "missed by 0.02 against one
|
|
1196
|
+
* profile" from "equidistant between three", which is the difference between
|
|
1197
|
+
* a fixable near-miss and a genuinely ambiguous voice. On a face-mounted
|
|
1198
|
+
* microphone that distinction is most of the signal.
|
|
1199
|
+
*
|
|
1200
|
+
* Read-only. It scores and reports; it changes no profile and no meeting.
|
|
1201
|
+
*
|
|
1202
|
+
* ?chunks=4,17,23 specific chunks (omit for the whole session)
|
|
1203
|
+
* ?limit=50 cap, because each chunk is scored against every profile
|
|
1204
|
+
*/
|
|
1205
|
+
router.get('/meeting/:sessionId/embeddings', (req, res) => {
|
|
1206
|
+
res.set('Cache-Control', 'private, no-store')
|
|
1207
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
1208
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
1209
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1210
|
+
return
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const rawChunks = String(req.query.chunks ?? '').trim()
|
|
1214
|
+
const indices: number[] = []
|
|
1215
|
+
if (rawChunks) {
|
|
1216
|
+
for (const part of rawChunks.split(',')) {
|
|
1217
|
+
const value = Number(part.trim())
|
|
1218
|
+
// Reject the whole request rather than silently scoring a subset: a
|
|
1219
|
+
// caller asking about chunk 17 must not get an answer about chunk 4.
|
|
1220
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
1221
|
+
res.status(400).json({ error: `Invalid chunk index "${part.trim()}"`, reason: 'invalid_chunk_index' })
|
|
1222
|
+
return
|
|
1223
|
+
}
|
|
1224
|
+
indices.push(value)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const rawLimit = Number(req.query.limit ?? 50)
|
|
1229
|
+
const limit = Number.isInteger(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 400) : 50
|
|
1230
|
+
|
|
1231
|
+
try {
|
|
1232
|
+
res.json(chunkDiagnostics(sessionId, indices, limit))
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
res.status(500).json({ error: errMsg(error), reason: 'diagnostics_failed' })
|
|
1235
|
+
}
|
|
1236
|
+
})
|
|
1237
|
+
|
|
1185
1238
|
/** What audio a meeting still has, so the panel can show play buttons only
|
|
1186
1239
|
* where they will work. */
|
|
1187
1240
|
router.get('/meeting/:sessionId/audio', (req, res) => {
|