@gotcos/glasses-server 6.21.14 → 6.21.18
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 +167 -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-audio-archive.ts +314 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +279 -0
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +19 -1
- package/server/routes/meeting.ts +508 -2
- package/server/routes/transcribe-stream.ts +59 -0
- package/server/routes/voice.ts +91 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Rewriting a speaker label inside one meeting's files.
|
|
2
|
+
//
|
|
3
|
+
// Pure over strings: every function takes file content and returns file content,
|
|
4
|
+
// so each rule below is testable by execution rather than by reading it.
|
|
5
|
+
//
|
|
6
|
+
// THE CONSTRAINT THAT SHAPES THIS FILE. The markdown transcript is NOT an
|
|
7
|
+
// index-parallel rendering of the chunk sidecar. Measured on a real meeting
|
|
8
|
+
// (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
|
|
9
|
+
// while the markdown carries 70 turns — and they disagree on who spoke, with the
|
|
10
|
+
// markdown's 4th turn attributed to Joe Karbowski where the sidecar's 4th run
|
|
11
|
+
// says Richard Jenkins. The transcript was rendered from a different
|
|
12
|
+
// segmentation pass. So:
|
|
13
|
+
//
|
|
14
|
+
// * There is NO mapping from a chunk index to a transcript turn.
|
|
15
|
+
// * A relabel of EVERY chunk carrying a label can still be applied to the
|
|
16
|
+
// markdown, because "all X becomes Y" needs no index alignment.
|
|
17
|
+
// * A relabel of a SUBSET of chunks cannot touch the markdown transcript at
|
|
18
|
+
// all. Rewriting by label would relabel turns the human never selected.
|
|
19
|
+
//
|
|
20
|
+
// `coveredAllWithLabel` on the sidecar result is how the caller knows which case
|
|
21
|
+
// it is. It is returned as DATA rather than left as a rule to remember.
|
|
22
|
+
//
|
|
23
|
+
// AND WHAT IS NEVER REWRITTEN: the Summary / Topics / Decisions / Action Items
|
|
24
|
+
// prose. It refers to people by bare first name — 6 of 12 speakers did so on the
|
|
25
|
+
// meeting above — and this org has two Kyles, two Jacobuses and two Chrises, so
|
|
26
|
+
// a first-name substitution in narrative text would rewrite sentences about
|
|
27
|
+
// somebody else. Reported as `proseStale` instead.
|
|
28
|
+
|
|
29
|
+
/** A label that would corrupt the file formats it gets written into. */
|
|
30
|
+
export function invalidLabelReason(label: string): string | null {
|
|
31
|
+
if (label.trim() === '') return 'label is empty'
|
|
32
|
+
if (label !== label.trim()) return 'label has leading or trailing whitespace'
|
|
33
|
+
if (label.length > 120) return 'label is longer than 120 characters'
|
|
34
|
+
// `[Name]:` delimits transcript turns and `- Name` delimits attendees; a
|
|
35
|
+
// label containing these would make the file unparseable by its own readers.
|
|
36
|
+
if (/[\[\]\n\r]/.test(label)) return 'label contains a bracket or newline'
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeRegExp(s: string): string {
|
|
41
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SidecarRelabelResult {
|
|
45
|
+
/** Serialized in the same shape the pipeline writes: 2-space indent, trailing newline. */
|
|
46
|
+
json: string
|
|
47
|
+
/** Chunk indices whose speaker actually changed. */
|
|
48
|
+
changed: number[]
|
|
49
|
+
/**
|
|
50
|
+
* True when no chunk still carries `from` after this relabel. ONLY then may the
|
|
51
|
+
* caller rewrite the markdown transcript by label. See the file header.
|
|
52
|
+
*/
|
|
53
|
+
coveredAllWithLabel: boolean
|
|
54
|
+
/** The top-level `speakers` array after the relabel. */
|
|
55
|
+
speakers: string[]
|
|
56
|
+
/** Chunks still carrying `from` — non-zero for a deliberate partial relabel. */
|
|
57
|
+
remainingWithFrom: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type RelabelOutcome<T> = { ok: true; value: T } | { ok: false; error: string }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Relabel chunks in a `.g2-chunks.json` sidecar.
|
|
64
|
+
*
|
|
65
|
+
* `chunks` empty means every chunk carrying `from`. An explicit list restricts
|
|
66
|
+
* the change to those indices — the partial case, which exists because the
|
|
67
|
+
* identifier can mishear one stretch of a meeting without being wrong about the
|
|
68
|
+
* rest.
|
|
69
|
+
*/
|
|
70
|
+
export function relabelSidecarJson(
|
|
71
|
+
raw: string,
|
|
72
|
+
from: string,
|
|
73
|
+
to: string,
|
|
74
|
+
chunks: number[] = [],
|
|
75
|
+
): RelabelOutcome<SidecarRelabelResult> {
|
|
76
|
+
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
77
|
+
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
78
|
+
const bad = invalidLabelReason(label)
|
|
79
|
+
if (bad) return { ok: false, error: `${which}: ${bad}` }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let parsed: unknown
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw)
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, error: 'sidecar is not valid JSON' }
|
|
87
|
+
}
|
|
88
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
89
|
+
return { ok: false, error: 'sidecar is not an object' }
|
|
90
|
+
}
|
|
91
|
+
const doc = parsed as Record<string, unknown>
|
|
92
|
+
const rows = doc.chunks
|
|
93
|
+
if (!Array.isArray(rows)) return { ok: false, error: 'sidecar has no chunks array' }
|
|
94
|
+
|
|
95
|
+
const restrict = chunks.length > 0 ? new Set(chunks) : null
|
|
96
|
+
// A caller naming indices that do not carry `from` is confused about what it
|
|
97
|
+
// is correcting; failing loudly beats silently relabelling nothing.
|
|
98
|
+
if (restrict) {
|
|
99
|
+
const mismatched = chunks.filter(i => {
|
|
100
|
+
const row = rows[i]
|
|
101
|
+
return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker !== from
|
|
102
|
+
})
|
|
103
|
+
if (mismatched.length > 0) {
|
|
104
|
+
return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const changed: number[] = []
|
|
109
|
+
let remainingWithFrom = 0
|
|
110
|
+
rows.forEach((row, i) => {
|
|
111
|
+
// The `typeof` half guards the cast below rather than any behaviour — a
|
|
112
|
+
// truthy non-object can never satisfy `.speaker === from`, so it is
|
|
113
|
+
// deliberately not claimed as tested.
|
|
114
|
+
if (!row || typeof row !== 'object') return
|
|
115
|
+
const r = row as Record<string, unknown>
|
|
116
|
+
if (r.speaker !== from) return
|
|
117
|
+
if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
|
|
118
|
+
r.speaker = to
|
|
119
|
+
changed.push(i)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if (changed.length === 0) return { ok: false, error: `no chunk carries "${from}"` }
|
|
123
|
+
|
|
124
|
+
// Keep the top-level speaker list truthful: it is what the review panel and
|
|
125
|
+
// the attendee renderer read, so a stale entry shows a person who is no longer
|
|
126
|
+
// attributed anything.
|
|
127
|
+
const existing = Array.isArray(doc.speakers) ? doc.speakers.filter((s): s is string => typeof s === 'string') : []
|
|
128
|
+
const speakers = [...existing]
|
|
129
|
+
if (!speakers.includes(to)) {
|
|
130
|
+
// Replace in place when `from` is being fully retired, so the list keeps its
|
|
131
|
+
// original ordering rather than moving the person to the end.
|
|
132
|
+
const at = speakers.indexOf(from)
|
|
133
|
+
if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
|
|
134
|
+
else speakers.push(to)
|
|
135
|
+
} else if (remainingWithFrom === 0) {
|
|
136
|
+
// `to` already listed and `from` fully retired — merging a split identity.
|
|
137
|
+
// Drop the old entry rather than leaving a name nothing is attributed to.
|
|
138
|
+
const at = speakers.indexOf(from)
|
|
139
|
+
if (at >= 0) speakers.splice(at, 1)
|
|
140
|
+
}
|
|
141
|
+
doc.speakers = speakers
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
json: `${JSON.stringify(doc, null, 2)}\n`,
|
|
147
|
+
changed,
|
|
148
|
+
coveredAllWithLabel: remainingWithFrom === 0,
|
|
149
|
+
speakers,
|
|
150
|
+
remainingWithFrom,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface MarkdownRelabelResult {
|
|
156
|
+
markdown: string
|
|
157
|
+
/** Attendee bullet lines changed or removed. */
|
|
158
|
+
attendees: number
|
|
159
|
+
/** `[Name]:` turn labels rewritten. */
|
|
160
|
+
transcript: number
|
|
161
|
+
/**
|
|
162
|
+
* True when the old label still appears in narrative prose, which is left
|
|
163
|
+
* untouched on purpose. The caller records this so a human can be told the
|
|
164
|
+
* summary predates their correction.
|
|
165
|
+
*/
|
|
166
|
+
proseStale: boolean
|
|
167
|
+
/** The prose forms found, for an honest message rather than a bare boolean. */
|
|
168
|
+
proseHits: string[]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Byte range of a `## Heading` section, or null when absent. */
|
|
172
|
+
function sectionRange(md: string, heading: string): { start: number; end: number } | null {
|
|
173
|
+
const re = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'm')
|
|
174
|
+
const m = re.exec(md)
|
|
175
|
+
if (!m) return null
|
|
176
|
+
const start = m.index + m[0].length
|
|
177
|
+
const next = /^##\s+/m.exec(md.slice(start))
|
|
178
|
+
return { start, end: next ? start + next.index : md.length }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Rewrite the structured label positions in a meeting markdown file.
|
|
183
|
+
*
|
|
184
|
+
* ONLY sound when every chunk carrying `from` is being relabelled — this works
|
|
185
|
+
* by label, not by index, and the markdown's turn segmentation does not match
|
|
186
|
+
* the sidecar's. See the file header.
|
|
187
|
+
*/
|
|
188
|
+
export function relabelMeetingMarkdown(
|
|
189
|
+
md: string,
|
|
190
|
+
from: string,
|
|
191
|
+
to: string,
|
|
192
|
+
options: { removeAttendee?: boolean } = {},
|
|
193
|
+
): RelabelOutcome<MarkdownRelabelResult> {
|
|
194
|
+
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
195
|
+
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
196
|
+
const bad = invalidLabelReason(label)
|
|
197
|
+
if (bad) return { ok: false, error: `${which}: ${bad}` }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let out = md
|
|
201
|
+
let attendees = 0
|
|
202
|
+
let transcript = 0
|
|
203
|
+
|
|
204
|
+
// --- Attendees: exact bullet lines only, inside the Attendees section only.
|
|
205
|
+
const att = sectionRange(out, 'Attendees')
|
|
206
|
+
if (att) {
|
|
207
|
+
const body = out.slice(att.start, att.end)
|
|
208
|
+
// Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
|
|
209
|
+
// run past its own line. Without the end anchor, `- Luke H` matches the
|
|
210
|
+
// PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
|
|
211
|
+
const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
|
|
212
|
+
// A de-attribution must DELETE the attendee bullet, never rename it. Renaming
|
|
213
|
+
// writes `- Unidentified 2` into the attendee list as though it were a
|
|
214
|
+
// person, and the COS pipeline that BUILDS attendees deliberately excludes
|
|
215
|
+
// unidentified labels (sync_meetings.py filters them) while every reader —
|
|
216
|
+
// parseAttendees, extractAttendees, the Python prep generators — takes the
|
|
217
|
+
// bullet at face value. So a rename here injects a phantom attendee that
|
|
218
|
+
// downstream treats as real.
|
|
219
|
+
const alreadyListed = options.removeAttendee || new RegExp(line(to), 'm').test(body)
|
|
220
|
+
let rewritten: string
|
|
221
|
+
if (alreadyListed) {
|
|
222
|
+
// Both names present: drop the old bullet instead of creating a duplicate
|
|
223
|
+
// attendee. This is the normal case when merging a split identity.
|
|
224
|
+
rewritten = body.replace(new RegExp(`${line(from)}\\n?`, 'gm'), () => { attendees++; return '' })
|
|
225
|
+
} else {
|
|
226
|
+
rewritten = body.replace(new RegExp(line(from), 'gm'), () => { attendees++; return `- ${to}` })
|
|
227
|
+
}
|
|
228
|
+
out = out.slice(0, att.start) + rewritten + out.slice(att.end)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Transcript: `[Name]:` at line start only. A name appearing inside spoken
|
|
232
|
+
// text is a quote, not a label, and must not be touched.
|
|
233
|
+
const tr = sectionRange(out, 'Transcript')
|
|
234
|
+
if (tr) {
|
|
235
|
+
const body = out.slice(tr.start, tr.end)
|
|
236
|
+
const rewritten = body.replace(
|
|
237
|
+
new RegExp(`^\\[${escapeRegExp(from)}\\]:`, 'gm'),
|
|
238
|
+
() => { transcript++; return `[${to}]:` },
|
|
239
|
+
)
|
|
240
|
+
out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// --- Prose: detect, never rewrite.
|
|
244
|
+
const proseHits = detectProseReferences(out, from)
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
ok: true,
|
|
248
|
+
value: { markdown: out, attendees, transcript, proseStale: proseHits.length > 0, proseHits },
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Forms of `label` still present in narrative prose.
|
|
254
|
+
*
|
|
255
|
+
* Searches everything except the attendee list and the transcript — the
|
|
256
|
+
* transcript's body is spoken words, and a name said aloud is a quote rather
|
|
257
|
+
* than a stale attribution.
|
|
258
|
+
*/
|
|
259
|
+
export function detectProseReferences(md: string, label: string): string[] {
|
|
260
|
+
const ranges = [sectionRange(md, 'Attendees'), sectionRange(md, 'Transcript')].filter(
|
|
261
|
+
(r): r is { start: number; end: number } => r !== null,
|
|
262
|
+
)
|
|
263
|
+
let prose = ''
|
|
264
|
+
let cursor = 0
|
|
265
|
+
for (const r of ranges.sort((a, b) => a.start - b.start)) {
|
|
266
|
+
prose += md.slice(cursor, r.start)
|
|
267
|
+
cursor = Math.max(cursor, r.end)
|
|
268
|
+
}
|
|
269
|
+
prose += md.slice(cursor)
|
|
270
|
+
|
|
271
|
+
const hits: string[] = []
|
|
272
|
+
const full = new RegExp(`\\b${escapeRegExp(label)}\\b`)
|
|
273
|
+
if (full.test(prose)) hits.push(label)
|
|
274
|
+
const first = label.split(/\s+/)[0]
|
|
275
|
+
// Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
|
|
276
|
+
// would otherwise be reported twice for the same match.
|
|
277
|
+
if (first && first !== label && new RegExp(`\\b${escapeRegExp(first)}\\b`).test(prose)) hits.push(first)
|
|
278
|
+
return hits
|
|
279
|
+
}
|
|
@@ -26,6 +26,19 @@
|
|
|
26
26
|
export interface ReviewChunk {
|
|
27
27
|
text?: string
|
|
28
28
|
speaker?: string
|
|
29
|
+
/**
|
|
30
|
+
* RAW capture index — the number in `chunk_NNNN.wav`, NOT this chunk's position
|
|
31
|
+
* in the array.
|
|
32
|
+
*
|
|
33
|
+
* These differ and the gap grows through a meeting. Measured on the 2026-08-06
|
|
34
|
+
* Ditto sidecar: 885 compacted chunks against raw indices 0..945 with 36 gaps,
|
|
35
|
+
* so array position 884 is really raw chunk 940 — a 56-chunk error, minutes of
|
|
36
|
+
* audio. `chunks` is filtered to text-bearing entries (transcribe-stream's
|
|
37
|
+
* getSessionChunks) while the WAV is written for EVERY received chunk before
|
|
38
|
+
* ASR, so the position can never address the audio. Populated from the
|
|
39
|
+
* sidecar's `chunkEntries`, which exists precisely to preserve these.
|
|
40
|
+
*/
|
|
41
|
+
chunkIndex?: number
|
|
29
42
|
/** MILLISECONDS from meeting start. Confirmed against the writer:
|
|
30
43
|
* meeting.ts accumulates `elapsed += row.durationMs`. Treating this as
|
|
31
44
|
* seconds reports a 32-minute meeting as 30,936 minutes. */
|
|
@@ -36,6 +49,24 @@ export interface ReviewChunk {
|
|
|
36
49
|
/** Labels that mean "nobody was identified", not a person. */
|
|
37
50
|
export const UNATTRIBUTED = new Set(['Unknown', 'Ext', '', 'Speaker 1', 'Speaker 2', 'Speaker 3'])
|
|
38
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Prefix for a voice a human de-attributed, numbered so distinct people stay
|
|
54
|
+
* distinct.
|
|
55
|
+
*
|
|
56
|
+
* De-attributing to a single shared `Ext` folded every corrected voice into one
|
|
57
|
+
* row: on the 2026-08-06 Ditto meeting Miles named five wrong attributions, and
|
|
58
|
+
* collapsing them would have destroyed his ability to tell those five voices
|
|
59
|
+
* apart afterwards — which is exactly what he then needs playback for. Numbering
|
|
60
|
+
* keeps them separable while asserting no identity.
|
|
61
|
+
*/
|
|
62
|
+
export const DEATTRIBUTED_PREFIX = 'Unidentified'
|
|
63
|
+
|
|
64
|
+
/** True when a label asserts no identity — the exact set, or a numbered
|
|
65
|
+
* de-attribution. Prefix-aware so `Unidentified 3` is treated as unnamed. */
|
|
66
|
+
export function isUnattributed(label: string): boolean {
|
|
67
|
+
return UNATTRIBUTED.has(label) || new RegExp(`^${DEATTRIBUTED_PREFIX} \\d+$`).test(label)
|
|
68
|
+
}
|
|
69
|
+
|
|
39
70
|
/** Calibrated from the control pair above. A pair must be BOTH flip-happy and
|
|
40
71
|
* short-run to be called unreliable — either alone has honest explanations
|
|
41
72
|
* (a rapid-fire exchange is flip-happy; a brief interjection is short-run). */
|
|
@@ -44,6 +75,27 @@ export const THRASH_MEAN_RUN = 8
|
|
|
44
75
|
/** Below the search-accept threshold a name was never asserted with confidence. */
|
|
45
76
|
export const CONFIDENT_SIMILARITY = 0.65
|
|
46
77
|
|
|
78
|
+
/**
|
|
79
|
+
* FLOOR FOR PRESENTING A NAME AT ALL.
|
|
80
|
+
*
|
|
81
|
+
* The identifier accepts a match at SEARCH_THRESHOLD = 0.55, so a single segment
|
|
82
|
+
* scoring 0.55 currently arrives in the panel wearing somebody's full name. On
|
|
83
|
+
* Miles's 2026-08-06 Ditto meeting that produced Richard Jenkins (1 segment,
|
|
84
|
+
* 0.60), Luke Henry (1 segment, 0.55), Dylan Jackson (2 segments, 0.58) and
|
|
85
|
+
* Navaz Sharif (3 segments, 0.58) — and he confirmed none of them were in the
|
|
86
|
+
* room. Presenting those as names is the defect; the reviewer then has to undo
|
|
87
|
+
* an assertion the system should never have made.
|
|
88
|
+
*
|
|
89
|
+
* Standing rule this enforces: speaker identity is a SUGGESTION, never an
|
|
90
|
+
* assertion. Below the floor the row is "unidentified" and the label survives
|
|
91
|
+
* only as a scored candidate.
|
|
92
|
+
*
|
|
93
|
+
* A floor cannot catch everything — a wrong match can still score well — so this
|
|
94
|
+
* removes obvious noise rather than guaranteeing correctness.
|
|
95
|
+
*/
|
|
96
|
+
export const ASSERT_MIN_SIMILARITY = CONFIDENT_SIMILARITY
|
|
97
|
+
export const ASSERT_MIN_SEGMENTS = 3
|
|
98
|
+
|
|
47
99
|
export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
|
|
48
100
|
|
|
49
101
|
export interface ThrashPair {
|
|
@@ -58,6 +110,13 @@ export interface Phrase {
|
|
|
58
110
|
/** Milliseconds from meeting start, matching the sidecar. */
|
|
59
111
|
atMs: number
|
|
60
112
|
similarity: number | null
|
|
113
|
+
/**
|
|
114
|
+
* Raw capture index for playback, or null when the sidecar cannot supply one
|
|
115
|
+
* (pre-`chunkEntries` captures). Null means "do not offer playback" — a
|
|
116
|
+
* guessed index plays somebody else's voice, which is worse than no button
|
|
117
|
+
* on a screen whose whole purpose is confirming identity.
|
|
118
|
+
*/
|
|
119
|
+
chunkIndex: number | null
|
|
61
120
|
}
|
|
62
121
|
|
|
63
122
|
export interface VoiceReview {
|
|
@@ -72,22 +131,138 @@ export interface VoiceReview {
|
|
|
72
131
|
longestRun: number
|
|
73
132
|
isOwner: boolean
|
|
74
133
|
reliability: Reliability
|
|
134
|
+
/**
|
|
135
|
+
* Whether `label` may be shown to a human AS A NAME.
|
|
136
|
+
*
|
|
137
|
+
* False means the UI must render the row as unidentified and offer `label`
|
|
138
|
+
* only as a scored candidate. Carried as data rather than left to each client
|
|
139
|
+
* to re-derive, so the phone, the lens and Control cannot disagree about
|
|
140
|
+
* whether a name was earned.
|
|
141
|
+
*/
|
|
142
|
+
nameAsserted: boolean
|
|
143
|
+
/** Why the name is not asserted — so a UI can explain rather than just hide. */
|
|
144
|
+
assertionBlockers: string[]
|
|
75
145
|
thrashesWith: ThrashPair[]
|
|
76
146
|
phrases: Phrase[]
|
|
77
147
|
}
|
|
78
148
|
|
|
149
|
+
/** One stretch of the meeting held by a single label. */
|
|
150
|
+
export interface TimelineSpan {
|
|
151
|
+
/** The label as stored. Whether it may be SHOWN as a name is still governed by
|
|
152
|
+
* the matching voice row's `nameAsserted` — a span is not a second opinion. */
|
|
153
|
+
speaker: string
|
|
154
|
+
/** Milliseconds from meeting start. `elapsed` on a chunk is its START offset:
|
|
155
|
+
* meeting.ts assigns `elapsed` and only then does `elapsed += durationMs`. */
|
|
156
|
+
startMs: number
|
|
157
|
+
endMs: number
|
|
158
|
+
segments: number
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Collapse the chunk sequence into consecutive same-speaker spans.
|
|
163
|
+
*
|
|
164
|
+
* This exists because the ribbon needs a TIME axis. The previous ribbon drew one
|
|
165
|
+
* rectangle per voice sized by share of segments and labelled itself "who spoke,
|
|
166
|
+
* in order" — there was no ordering in it at all, so hovering could not report
|
|
167
|
+
* anything true.
|
|
168
|
+
*
|
|
169
|
+
* Non-monotonic or missing `elapsed` values are carried forward rather than
|
|
170
|
+
* trusted: a span with a negative width would render as an invisible or
|
|
171
|
+
* inverted block, which is worse than a slightly wrong boundary.
|
|
172
|
+
*/
|
|
173
|
+
export function speakerTimeline(chunks: ReviewChunk[], durationMs: number): TimelineSpan[] {
|
|
174
|
+
const spans: TimelineSpan[] = []
|
|
175
|
+
let cursor = 0
|
|
176
|
+
for (const c of chunks) {
|
|
177
|
+
const label = c.speaker ?? ''
|
|
178
|
+
const raw = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : 0
|
|
179
|
+
// ONE clamp does all three jobs, because `cursor` is monotonic and never
|
|
180
|
+
// negative: it recovers a missing value, a negative value, and a value that
|
|
181
|
+
// goes backwards. Mutation showed the extra Math.max(0, raw) and the
|
|
182
|
+
// `: cursor` fallback were both unreachable behind it.
|
|
183
|
+
const startMs = Math.max(cursor, raw)
|
|
184
|
+
cursor = startMs
|
|
185
|
+
const last = spans[spans.length - 1]
|
|
186
|
+
if (last && last.speaker === label) {
|
|
187
|
+
last.segments++
|
|
188
|
+
} else {
|
|
189
|
+
spans.push({ speaker: label, startMs, endMs: startMs, segments: 1 })
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Each span ends where the next begins. The LAST one is the problem: `elapsed`
|
|
193
|
+
// is a start offset, and on real sidecars `durationMs` frequently equals the
|
|
194
|
+
// final chunk's start exactly (measured on 2026-08-06 Ditto: both 5,783,732),
|
|
195
|
+
// so taking the meeting end verbatim leaves the closing turn zero-width — a
|
|
196
|
+
// 1.5pt sliver for what may be a long monologue.
|
|
197
|
+
//
|
|
198
|
+
// The tail gets ONE TYPICAL CHUNK of width, derived from the median gap between
|
|
199
|
+
// this meeting's own chunk starts. That is measured from the data rather than
|
|
200
|
+
// invented, and it is the shortest defensible non-zero answer.
|
|
201
|
+
const gaps: number[] = []
|
|
202
|
+
for (let i = 1; i < spans.length; i++) {
|
|
203
|
+
const d = spans[i].startMs - spans[i - 1].startMs
|
|
204
|
+
if (d > 0) gaps.push(d)
|
|
205
|
+
}
|
|
206
|
+
gaps.sort((a, b) => a - b)
|
|
207
|
+
const typicalGap = gaps.length ? gaps[Math.floor(gaps.length / 2)] : 0
|
|
208
|
+
for (let i = 0; i < spans.length; i++) {
|
|
209
|
+
const next = spans[i + 1]
|
|
210
|
+
if (next) {
|
|
211
|
+
spans[i].endMs = Math.max(spans[i].startMs, next.startMs)
|
|
212
|
+
} else if (durationMs > spans[i].startMs) {
|
|
213
|
+
// A real meeting end, later than this span's start: use it verbatim.
|
|
214
|
+
spans[i].endMs = durationMs
|
|
215
|
+
} else {
|
|
216
|
+
// durationMs is absent, or equals the final chunk's start (the common real
|
|
217
|
+
// case). Fall back to one typical chunk so the closing turn has width.
|
|
218
|
+
spans[i].endMs = spans[i].startMs + typicalGap
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return spans
|
|
222
|
+
}
|
|
223
|
+
|
|
79
224
|
export interface MeetingSpeakerReview {
|
|
80
225
|
segments: number
|
|
81
226
|
/** False when no chunk carries a real speaker — a recovered capture. */
|
|
82
227
|
attributed: boolean
|
|
83
228
|
durationMs: number
|
|
84
229
|
voices: VoiceReview[]
|
|
230
|
+
/** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
|
|
231
|
+
timeline: TimelineSpan[]
|
|
85
232
|
}
|
|
86
233
|
|
|
87
234
|
function mean(xs: number[]): number {
|
|
88
235
|
return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : 0
|
|
89
236
|
}
|
|
90
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Attach raw capture indices to a compacted chunk array.
|
|
240
|
+
*
|
|
241
|
+
* The i-th compacted chunk is the i-th TEXT-BEARING `chunkEntries` row, whose
|
|
242
|
+
* `chunkIndex` is the raw WAV number. Verified against every 2026-07/08 sidecar
|
|
243
|
+
* that carries chunkEntries: the text-bearing count always equals the compacted
|
|
244
|
+
* count and the text lines up positionally.
|
|
245
|
+
*
|
|
246
|
+
* Returns the chunks UNCHANGED when the counts disagree or chunkEntries is
|
|
247
|
+
* absent. A partial or shifted mapping is worse than none: it would silently
|
|
248
|
+
* point playback at a neighbouring speaker, and this screen exists to confirm
|
|
249
|
+
* identity.
|
|
250
|
+
*/
|
|
251
|
+
export function attachRawChunkIndices(chunks: ReviewChunk[], chunkEntries: unknown): ReviewChunk[] {
|
|
252
|
+
if (!Array.isArray(chunkEntries)) return chunks
|
|
253
|
+
const textBearing = chunkEntries.filter(e => {
|
|
254
|
+
const chunk = (e as { chunk?: { text?: unknown } } | null)?.chunk
|
|
255
|
+
return typeof chunk?.text === 'string' && chunk.text.trim() !== ''
|
|
256
|
+
})
|
|
257
|
+
if (textBearing.length !== chunks.length) return chunks
|
|
258
|
+
return chunks.map((c, i) => {
|
|
259
|
+
const raw = (textBearing[i] as { chunkIndex?: unknown }).chunkIndex
|
|
260
|
+
return typeof raw === 'number' && Number.isInteger(raw) && raw >= 0
|
|
261
|
+
? { ...c, chunkIndex: raw }
|
|
262
|
+
: c
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
91
266
|
/** Consecutive-run lengths for one speaker across the whole meeting. */
|
|
92
267
|
export function speakerRuns(sequence: string[], speaker: string): number[] {
|
|
93
268
|
const runs: number[] = []
|
|
@@ -179,6 +354,7 @@ export function selectPhrases(
|
|
|
179
354
|
text: (c.text ?? '').trim(),
|
|
180
355
|
atMs: typeof c.elapsed === 'number' ? c.elapsed : 0,
|
|
181
356
|
similarity: typeof c.similarity === 'number' ? c.similarity : null,
|
|
357
|
+
chunkIndex: typeof c.chunkIndex === 'number' ? c.chunkIndex : null,
|
|
182
358
|
score: phraseScore(c.text ?? ''),
|
|
183
359
|
}))
|
|
184
360
|
.filter(p => p.score > 0)
|
|
@@ -203,27 +379,34 @@ export function selectPhrases(
|
|
|
203
379
|
}
|
|
204
380
|
return picked
|
|
205
381
|
.sort((a, b) => a.atMs - b.atMs)
|
|
206
|
-
.map(({ text, atMs, similarity }) => ({ text, atMs, similarity }))
|
|
382
|
+
.map(({ text, atMs, similarity, chunkIndex }) => ({ text, atMs, similarity, chunkIndex }))
|
|
207
383
|
}
|
|
208
384
|
|
|
209
385
|
/** Build the whole review for one meeting's chunks. */
|
|
210
386
|
export function reviewMeetingSpeakers(
|
|
211
387
|
chunks: ReviewChunk[],
|
|
212
|
-
options: { owner?: string; phrasesPerVoice?: number } = {},
|
|
388
|
+
options: { owner?: string; phrasesPerVoice?: number; durationMs?: number } = {},
|
|
213
389
|
): MeetingSpeakerReview {
|
|
214
390
|
const owner = options.owner ?? 'Me'
|
|
215
391
|
const limit = options.phrasesPerVoice ?? 3
|
|
216
392
|
const sequence = chunks.map(c => c.speaker ?? '')
|
|
217
|
-
|
|
393
|
+
// The caller's durationMs (the sidecar's own) is the meeting's true end.
|
|
394
|
+
// Falling back to max(elapsed) uses the START of the last chunk, which makes
|
|
395
|
+
// the final timeline span zero-width — so prefer the real value and only
|
|
396
|
+
// derive when it is absent.
|
|
397
|
+
const lastStart = chunks.reduce((max, c) => Math.max(max, typeof c.elapsed === 'number' ? c.elapsed : 0), 0)
|
|
398
|
+
const durationMs = typeof options.durationMs === 'number' && options.durationMs > lastStart
|
|
399
|
+
? options.durationMs
|
|
400
|
+
: lastStart
|
|
218
401
|
|
|
219
402
|
const labels = [...new Set(sequence)].filter(s => s.length > 0)
|
|
220
|
-
const named = labels.filter(l => !
|
|
403
|
+
const named = labels.filter(l => !isUnattributed(l))
|
|
221
404
|
|
|
222
405
|
const voices: VoiceReview[] = labels.map(label => {
|
|
223
406
|
const own = chunks.filter(c => (c.speaker ?? '') === label)
|
|
224
407
|
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
225
408
|
const runs = speakerRuns(sequence, label)
|
|
226
|
-
const unattributed =
|
|
409
|
+
const unattributed = isUnattributed(label)
|
|
227
410
|
|
|
228
411
|
const thrashesWith: ThrashPair[] = []
|
|
229
412
|
if (!unattributed) {
|
|
@@ -250,6 +433,32 @@ export function reviewMeetingSpeakers(
|
|
|
250
433
|
? 'unreliable'
|
|
251
434
|
: (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
252
435
|
|
|
436
|
+
// What stops this label being presented as a name. Collected as reasons
|
|
437
|
+
// rather than a bare boolean: "2 segments" and "similarity 0.58" are
|
|
438
|
+
// different problems and a reviewer deserves to see which one applies.
|
|
439
|
+
const assertionBlockers: string[] = []
|
|
440
|
+
if (unattributed) {
|
|
441
|
+
assertionBlockers.push('no name was ever assigned to this voice')
|
|
442
|
+
} else if (label === owner) {
|
|
443
|
+
// The wearer is exempt. Their identity is established by wearing the
|
|
444
|
+
// device, not by cosine — and the owner is verified at exactly this floor
|
|
445
|
+
// (VERIFY_THRESHOLD 0.65), so they sit permanently on the boundary and any
|
|
446
|
+
// thrash pair flips them. Measured across the 2026-08-06 corpus: the owner
|
|
447
|
+
// row read "Unidentified voice" in 4 of 9 meetings, including one with 285
|
|
448
|
+
// of their own segments. `thrashesWith` still renders, so a mixed row is
|
|
449
|
+
// still visible — the name is asserted, the caveat is not hidden.
|
|
450
|
+
} else {
|
|
451
|
+
if (own.length < ASSERT_MIN_SEGMENTS) {
|
|
452
|
+
assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
|
|
453
|
+
}
|
|
454
|
+
if ((meanSim ?? 0) < ASSERT_MIN_SIMILARITY) {
|
|
455
|
+
assertionBlockers.push(`similarity ${(meanSim ?? 0).toFixed(2)} below ${ASSERT_MIN_SIMILARITY}`)
|
|
456
|
+
}
|
|
457
|
+
if (thrashesWith.length > 0) {
|
|
458
|
+
assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
253
462
|
return {
|
|
254
463
|
label,
|
|
255
464
|
segments: own.length,
|
|
@@ -258,11 +467,19 @@ export function reviewMeetingSpeakers(
|
|
|
258
467
|
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
259
468
|
isOwner: label === owner,
|
|
260
469
|
reliability,
|
|
470
|
+
nameAsserted: assertionBlockers.length === 0,
|
|
471
|
+
assertionBlockers,
|
|
261
472
|
thrashesWith,
|
|
262
473
|
phrases: selectPhrases(chunks, label, limit, durationMs),
|
|
263
474
|
}
|
|
264
475
|
})
|
|
265
476
|
|
|
266
477
|
voices.sort((a, b) => b.segments - a.segments)
|
|
267
|
-
return {
|
|
478
|
+
return {
|
|
479
|
+
segments: chunks.length,
|
|
480
|
+
attributed: named.length > 0,
|
|
481
|
+
durationMs,
|
|
482
|
+
voices,
|
|
483
|
+
timeline: speakerTimeline(chunks, durationMs),
|
|
484
|
+
}
|
|
268
485
|
}
|