@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.21.22",
3
+ "version": "6.21.23",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, or Cursor Agent CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
+ }
@@ -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) => {