@gotcos/glasses-server 6.46.0 → 6.47.0

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +6 -2
  3. package/server/index.ts +76 -0
  4. package/server/lib/cos-operations-meetings.ts +99 -8
  5. package/server/lib/fireflies-client.ts +862 -0
  6. package/server/lib/fireflies-key.ts +182 -0
  7. package/server/lib/g2-ops-handoff.ts +15 -1
  8. package/server/lib/imported-library-rows.ts +616 -0
  9. package/server/lib/imported-meeting-library.ts +608 -0
  10. package/server/lib/maintenance-lifecycle.ts +14 -0
  11. package/server/lib/meeting-actions-store.ts +478 -0
  12. package/server/lib/meeting-actions.ts +2583 -0
  13. package/server/lib/meeting-corrections.ts +32 -1
  14. package/server/lib/meeting-decisions.ts +223 -0
  15. package/server/lib/meeting-engine/align.ts +167 -0
  16. package/server/lib/meeting-engine/attribute.ts +265 -0
  17. package/server/lib/meeting-engine/evidence.ts +428 -0
  18. package/server/lib/meeting-engine/pairing.ts +327 -0
  19. package/server/lib/meeting-engine/render.ts +694 -0
  20. package/server/lib/meeting-engine/split.ts +242 -0
  21. package/server/lib/meeting-engine/worker.ts +238 -0
  22. package/server/lib/meeting-engine-mode.ts +197 -0
  23. package/server/lib/meeting-file-guards.ts +141 -0
  24. package/server/lib/meeting-import.ts +763 -0
  25. package/server/lib/meeting-library-search.ts +146 -11
  26. package/server/lib/meeting-parse.ts +184 -0
  27. package/server/lib/meeting-store.ts +108 -275
  28. package/server/lib/meeting-suggestion-sides.ts +242 -0
  29. package/server/lib/morning-brief-runtime.ts +20 -8
  30. package/server/lib/pipeline-runner.ts +227 -0
  31. package/server/lib/voice-evidence-guard.ts +87 -0
  32. package/server/routes/fireflies-key.ts +102 -0
  33. package/server/routes/meeting-actions.ts +82 -0
  34. package/server/routes/meeting-engine.ts +52 -0
  35. package/server/routes/meeting-import.ts +67 -0
  36. package/server/routes/meeting-suggestions.ts +66 -0
  37. package/server/routes/meeting.ts +117 -10
  38. package/server/routes/meetings.ts +205 -37
  39. package/server/routes/voice.ts +18 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Per-sentence speaker labels on a merged record (6.47.0, WS3, decision D13).
3
+ *
4
+ * PURE. No filesystem, no network, no clock.
5
+ *
6
+ * WHAT THIS MAY AND MAY NOT DO. It may put a G2 voice-match name on a Fireflies sentence
7
+ * whose label asserts no identity, and it may note that the two sides agree. It may never
8
+ * overwrite a name a person gave with a name a voiceprint guessed: on a conflict the
9
+ * Fireflies label stands and the disagreement is recorded. Speaker identity is a
10
+ * suggestion, never an assertion, and this is the layer where that rule is enforced.
11
+ *
12
+ * `ATTRIBUTE_MODE = 'capture_only'` returns the engine to the pipeline's older stance —
13
+ * attach the capture, relabel nothing — without touching any other code path.
14
+ */
15
+
16
+ import {
17
+ type FirefliesSentenceInput,
18
+ type LabelInterval,
19
+ median,
20
+ overlapMs,
21
+ } from './evidence.js'
22
+ import { type Alignment, offsetAt } from './align.js'
23
+
24
+ /** The winner must hold this much of the sentence's voiced time. */
25
+ export const ATTRIBUTE_MIN_SHARE = 0.6
26
+
27
+ /**
28
+ * Voice-match floor for naming. This is the same 0.55 that `merge-profiles` refuses below:
29
+ * the number that separates one person from another person with a similar voice.
30
+ */
31
+ export const ATTRIBUTE_MIN_SIMILARITY = 0.55
32
+
33
+ /** Tokens shorter than this ("de", "la", "jr") make two different names look equal. */
34
+ export const NAME_TOKEN_MIN_LENGTH = 3
35
+
36
+ /**
37
+ * Labels that assert no identity. A superset of the server's `UNATTRIBUTED` set and its
38
+ * numbered `Unidentified N` de-attributions, plus the shapes Fireflies produces.
39
+ */
40
+ export const GENERIC_LABEL_PATTERN = /^(speaker\s*[a-z0-9]*|unidentified(\s+speaker)?(\s*\d+)?|unknown|ext|remote speaker|voice\s*\d+|)$/i
41
+
42
+ /** `'relabel'` applies D13. `'capture_only'` keeps every Fireflies label as it is. */
43
+ export type AttributeMode = 'relabel' | 'capture_only'
44
+
45
+ export const ATTRIBUTE_MODE: AttributeMode = 'relabel'
46
+
47
+ export function isGenericLabel(label: string | undefined | null): boolean {
48
+ return GENERIC_LABEL_PATTERN.test(String(label ?? '').trim())
49
+ }
50
+
51
+ export function nameTokens(label: string | undefined | null): Set<string> {
52
+ return new Set(
53
+ String(label ?? '')
54
+ .toLowerCase()
55
+ .split(/[\s._@-]+/)
56
+ .filter(token => token.length >= NAME_TOKEN_MIN_LENGTH),
57
+ )
58
+ }
59
+
60
+ /** Two labels name the same person when they share a token of real length. */
61
+ export function namesAgree(a: string, b: string): boolean {
62
+ const left = nameTokens(a)
63
+ for (const token of nameTokens(b)) if (left.has(token)) return true
64
+ return false
65
+ }
66
+
67
+ export type SentenceOutcome =
68
+ /** The Fireflies label asserted no identity and a G2 name took its place. */
69
+ | 'replaced_generic'
70
+ /** Both sides name the same person. */
71
+ | 'agree'
72
+ /** They disagree; the Fireflies label stands. */
73
+ | 'conflict'
74
+ /** No G2 name qualified; the Fireflies label stands. */
75
+ | 'kept'
76
+
77
+ export type KeptReason = 'no_g2_audio' | 'low_share' | 'generic_g2' | 'low_similarity' | 'capture_only'
78
+
79
+ export interface SentenceLabel {
80
+ index: number
81
+ ffLabel: string
82
+ /** The G2 winner, whether or not it was applied. Null when no capture covered the sentence. */
83
+ g2Label: string | null
84
+ /** The label the render uses. */
85
+ resolvedLabel: string
86
+ /** The winner's share of the sentence's voiced time. */
87
+ share: number
88
+ /** The winner's best voice-match similarity. */
89
+ similarity: number
90
+ windowOffsetMs: number | null
91
+ outcome: SentenceOutcome
92
+ keptReason?: KeptReason
93
+ humanConfirmed: boolean
94
+ }
95
+
96
+ export interface SpeakerVerification {
97
+ name: string
98
+ medianSimilarity: number
99
+ sentences: number
100
+ humanConfirmed: boolean
101
+ }
102
+
103
+ export interface AttributionInput {
104
+ sessionId: string
105
+ alignment: Alignment
106
+ labels: readonly LabelInterval[]
107
+ }
108
+
109
+ export interface AttributionResult {
110
+ labels: SentenceLabel[]
111
+ conflicts: number[]
112
+ verification: SpeakerVerification[]
113
+ mode: AttributeMode
114
+ }
115
+
116
+ interface Winner {
117
+ speaker: string
118
+ share: number
119
+ similarity: number
120
+ humanConfirmed: boolean
121
+ windowOffsetMs: number
122
+ }
123
+
124
+ /** The G2 speaker who holds most of one sentence, across every aligned capture. */
125
+ function winnerFor(
126
+ sentence: FirefliesSentenceInput,
127
+ captures: readonly AttributionInput[],
128
+ ): Winner | null {
129
+ const startMs = (Number(sentence.start_time ?? 0) || 0) * 1000
130
+ const endMs = (Number(sentence.end_time ?? 0) || 0) * 1000
131
+ if (!(endMs > startMs)) return null
132
+ const weights = new Map<string, number>()
133
+ const similarities = new Map<string, number>()
134
+ const confirmed = new Map<string, boolean>()
135
+ let windowOffsetMs: number | null = null
136
+ for (const capture of captures) {
137
+ if (!capture.alignment.aligned) continue
138
+ const offset = offsetAt(capture.alignment, startMs - (capture.alignment.offsetMs ?? 0))
139
+ if (windowOffsetMs === null) windowOffsetMs = offset
140
+ const span: [number, number] = [startMs - offset, endMs - offset]
141
+ for (const label of capture.labels) {
142
+ const shared = overlapMs(span, [label.startMs, label.endMs])
143
+ if (shared <= 0) continue
144
+ weights.set(label.speaker, (weights.get(label.speaker) ?? 0) + shared)
145
+ similarities.set(label.speaker, Math.max(similarities.get(label.speaker) ?? 0, label.similarity))
146
+ confirmed.set(label.speaker, (confirmed.get(label.speaker) ?? false) || label.humanConfirmed)
147
+ }
148
+ }
149
+ if (weights.size === 0) return null
150
+ let speaker = ''
151
+ let best = -1
152
+ let total = 0
153
+ for (const [name, weight] of weights) {
154
+ total += weight
155
+ if (weight > best) {
156
+ best = weight
157
+ speaker = name
158
+ }
159
+ }
160
+ return {
161
+ speaker,
162
+ share: total > 0 ? best / total : 0,
163
+ similarity: similarities.get(speaker) ?? 0,
164
+ humanConfirmed: confirmed.get(speaker) ?? false,
165
+ windowOffsetMs: windowOffsetMs ?? 0,
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Resolve every Fireflies sentence against the aligned G2 captures.
171
+ *
172
+ * A name is applied only when it holds the sentence (`ATTRIBUTE_MIN_SHARE`), asserts an
173
+ * identity, and is either a confident voice match or a name a person confirmed. Human
174
+ * confirmation stands in for similarity because a person already answered the question the
175
+ * similarity score estimates.
176
+ */
177
+ export function attributeSentences(
178
+ sentences: readonly FirefliesSentenceInput[],
179
+ captures: readonly AttributionInput[],
180
+ mode: AttributeMode = ATTRIBUTE_MODE,
181
+ ): AttributionResult {
182
+ const labels: SentenceLabel[] = []
183
+ const conflicts: number[] = []
184
+ const applied = new Map<string, { similarities: number[]; humanConfirmed: boolean }>()
185
+
186
+ sentences.forEach((sentence, index) => {
187
+ const ffLabel = String(sentence.speaker_name ?? '')
188
+ if (mode === 'capture_only') {
189
+ labels.push({
190
+ index,
191
+ ffLabel,
192
+ g2Label: null,
193
+ resolvedLabel: ffLabel,
194
+ share: 0,
195
+ similarity: 0,
196
+ windowOffsetMs: null,
197
+ outcome: 'kept',
198
+ keptReason: 'capture_only',
199
+ humanConfirmed: false,
200
+ })
201
+ return
202
+ }
203
+ const winner = winnerFor(sentence, captures)
204
+ if (!winner) {
205
+ labels.push({
206
+ index,
207
+ ffLabel,
208
+ g2Label: null,
209
+ resolvedLabel: ffLabel,
210
+ share: 0,
211
+ similarity: 0,
212
+ windowOffsetMs: null,
213
+ outcome: 'kept',
214
+ keptReason: 'no_g2_audio',
215
+ humanConfirmed: false,
216
+ })
217
+ return
218
+ }
219
+ const base = {
220
+ index,
221
+ ffLabel,
222
+ g2Label: winner.speaker,
223
+ share: winner.share,
224
+ similarity: winner.similarity,
225
+ windowOffsetMs: winner.windowOffsetMs,
226
+ humanConfirmed: winner.humanConfirmed,
227
+ }
228
+ const keptReason: KeptReason | null = winner.share < ATTRIBUTE_MIN_SHARE
229
+ ? 'low_share'
230
+ : isGenericLabel(winner.speaker)
231
+ ? 'generic_g2'
232
+ : !winner.humanConfirmed && winner.similarity < ATTRIBUTE_MIN_SIMILARITY
233
+ ? 'low_similarity'
234
+ : null
235
+ if (keptReason) {
236
+ labels.push({ ...base, resolvedLabel: ffLabel, outcome: 'kept', keptReason })
237
+ return
238
+ }
239
+ const record = applied.get(winner.speaker) ?? { similarities: [], humanConfirmed: false }
240
+ record.similarities.push(winner.similarity)
241
+ record.humanConfirmed = record.humanConfirmed || winner.humanConfirmed
242
+ applied.set(winner.speaker, record)
243
+ if (isGenericLabel(ffLabel)) {
244
+ labels.push({ ...base, resolvedLabel: winner.speaker, outcome: 'replaced_generic' })
245
+ return
246
+ }
247
+ if (namesAgree(winner.speaker, ffLabel)) {
248
+ labels.push({ ...base, resolvedLabel: ffLabel, outcome: 'agree' })
249
+ return
250
+ }
251
+ conflicts.push(index)
252
+ labels.push({ ...base, resolvedLabel: ffLabel, outcome: 'conflict' })
253
+ })
254
+
255
+ const verification: SpeakerVerification[] = [...applied.entries()]
256
+ .map(([name, record]) => ({
257
+ name,
258
+ medianSimilarity: record.similarities.length > 0 ? median(record.similarities) : 0,
259
+ sentences: record.similarities.length,
260
+ humanConfirmed: record.humanConfirmed,
261
+ }))
262
+ .sort((a, b) => (b.sentences - a.sentences) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
263
+
264
+ return { labels, conflicts, verification, mode }
265
+ }
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Content evidence for the meeting merge engine (6.47.0, WS3).
3
+ *
4
+ * PURE. No filesystem, no network, no clock. Every number is a named export.
5
+ *
6
+ * WHY CONTENT AND NOT TIME. The rules design (start gap, duration fit, attendee-name
7
+ * corroboration) scored 58.1% with 9 cross-meeting pairs against Miles's existing merges.
8
+ * Scoring by shared transcript content instead scored 72.2% with 6 disputes, and all 6
9
+ * disputes look like reference errors: the picked meeting shared 1,176 to 5,251 phrases
10
+ * with the capture while the referenced one shared 0 to 5. Time now only selects
11
+ * candidates; content decides.
12
+ *
13
+ * PARITY. Every function here mirrors the measured Python (`canary_imports.py`,
14
+ * `canary_v2.py`) operation for operation, including floating-point evaluation order and
15
+ * tie-breaking, because `server/scripts/engine-canary.ts` must reproduce its tier counts
16
+ * (161 / 21 / 16 on 198 recordings, ±2). Deviations from that arithmetic are bugs, even
17
+ * when they look like cleanups.
18
+ */
19
+
20
+ /** One transcript word with the millisecond time it was spoken. */
21
+ export interface TimedWord {
22
+ token: string
23
+ timeMs: number
24
+ }
25
+
26
+ /** Where G2 word times came from. `chunks` is the fallback when no HQ batch pass ran. */
27
+ export type G2TimingMode = 'batch_relative' | 'batch_absolute' | 'chunks' | 'none'
28
+
29
+ export interface G2ChunkInput {
30
+ text?: string
31
+ speaker?: string
32
+ elapsed?: number
33
+ similarity?: number
34
+ /** Set by `meeting-speaker-labels.ts` when a human named this chunk. */
35
+ speakerCorrectionBatchId?: string
36
+ }
37
+
38
+ export interface G2BatchWordInput {
39
+ word?: string
40
+ start?: number
41
+ end?: number
42
+ }
43
+
44
+ export interface G2BatchSegmentInput {
45
+ startElapsed?: number
46
+ endElapsed?: number
47
+ words?: G2BatchWordInput[]
48
+ }
49
+
50
+ /** One G2 recording, as the runner snapshots it from `.g2-chunks.json`. */
51
+ export interface G2RecordingInput {
52
+ sessionId: string
53
+ /** Wall-clock epoch ms of the capture start. */
54
+ startMs: number
55
+ durationMs: number
56
+ chunks?: G2ChunkInput[]
57
+ batchSegments?: G2BatchSegmentInput[]
58
+ /** Fingerprint fields, carried through to the derived sidecar. */
59
+ sha256?: string
60
+ correctionRevision?: number
61
+ batchApplied?: boolean
62
+ title?: string
63
+ domain?: string
64
+ /** Epoch ms the capture finalized; the runner uses it for the D14 advisory rule. */
65
+ finalizedAtMs?: number
66
+ }
67
+
68
+ export interface FirefliesSentenceInput {
69
+ text?: string
70
+ speaker_name?: string
71
+ speaker_id?: string
72
+ /** Seconds from the recording start. */
73
+ start_time?: number
74
+ end_time?: number
75
+ }
76
+
77
+ /** One Fireflies meeting, as the importer or the advise-mode sidecar reader snapshots it. */
78
+ export interface FirefliesMeetingInput {
79
+ id: string
80
+ /** Wall-clock epoch ms of the recording start. */
81
+ startMs: number
82
+ /** Seconds. Zero or negative means unknown, which makes it ineligible as a candidate. */
83
+ durationS: number
84
+ sentences: FirefliesSentenceInput[]
85
+ title?: string
86
+ organizerEmail?: string
87
+ participants?: string[]
88
+ summary?: string
89
+ actionItems?: string[]
90
+ sha256?: string
91
+ }
92
+
93
+ /**
94
+ * Offset histogram bin width. Ten seconds is wide enough to absorb the per-word timing
95
+ * error of two independent transcribers and narrow enough that a different meeting's
96
+ * shared phrases scatter across bins instead of stacking in one.
97
+ */
98
+ export const EVIDENCE_BIN_MS = 10_000
99
+
100
+ /** Nominal length of the last chunk, which has no successor to bound it. */
101
+ export const G2_CHUNK_TAIL_MS = 6_000
102
+
103
+ /** Ceiling on how far one chunk's words may be spread in the no-batch fallback. */
104
+ export const G2_CHUNK_SPREAD_MAX_MS = 12_000
105
+
106
+ /**
107
+ * Slack when deciding whether a batch segment's word times are relative to its own start
108
+ * or absolute. A median word time inside the segment's own length (plus this) means
109
+ * relative.
110
+ */
111
+ export const G2_BATCH_RELATIVE_MARGIN_S = 5
112
+
113
+ /**
114
+ * Python's `//` on floats, exactly: `math.floor` of the quotient, with the same correction
115
+ * for a quotient that lands a hair under an integer. `Math.floor(x / y)` disagrees on
116
+ * those boundary values, which would move a trigram into the neighbouring bin and change K.
117
+ */
118
+ export function pythonFloorDiv(value: number, divisor: number): number {
119
+ const mod = value % divisor
120
+ let div = (value - mod) / divisor
121
+ if (mod !== 0 && (divisor < 0) !== (mod < 0)) div -= 1
122
+ if (div !== 0) {
123
+ const floored = Math.floor(div)
124
+ return div - floored > 0.5 ? floored + 1 : floored
125
+ }
126
+ return 0
127
+ }
128
+
129
+ /** The bin an offset falls in. */
130
+ export function offsetBin(offsetMs: number, binMs: number = EVIDENCE_BIN_MS): number {
131
+ return pythonFloorDiv(offsetMs, binMs)
132
+ }
133
+
134
+ /** `statistics.median`: the middle value, or the mean of the two middle values. */
135
+ export function median(values: readonly number[]): number {
136
+ if (values.length === 0) throw new Error('median of empty set')
137
+ const sorted = [...values].sort((a, b) => a - b)
138
+ const mid = sorted.length >> 1
139
+ return sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
140
+ }
141
+
142
+ /** Median absolute deviation about the median. */
143
+ export function medianAbsoluteDeviation(values: readonly number[]): number {
144
+ const centre = median(values)
145
+ return median(values.map(v => Math.abs(v - centre)))
146
+ }
147
+
148
+ const WORD_PATTERN = /[a-z0-9]+/g
149
+
150
+ /**
151
+ * Lowercase, drop apostrophes, split on anything that is not `[a-z0-9]`.
152
+ *
153
+ * Dropping the apostrophe before splitting keeps "we'll" one token in both transcripts;
154
+ * splitting on it first would produce "we" and "ll" on one side only.
155
+ */
156
+ export function normalizeWords(text: string | undefined | null): string[] {
157
+ return (text ?? '').toLowerCase().replace(/'/g, '').match(WORD_PATTERN) ?? []
158
+ }
159
+
160
+ const TRIGRAM_SEPARATOR = '\u0000'
161
+
162
+ /** The map key for one trigram. Shared so alignment cannot build it differently. */
163
+ export function trigramKey(a: string, b: string, c: string): string {
164
+ return `${a}${TRIGRAM_SEPARATOR}${b}${TRIGRAM_SEPARATOR}${c}`
165
+ }
166
+
167
+ /**
168
+ * Word trigrams that occur EXACTLY ONCE, timed at their middle word.
169
+ *
170
+ * Uniqueness is what makes a match evidence rather than coincidence: "let me share my"
171
+ * appears in every meeting, so a repeated trigram carries no information about WHICH
172
+ * meeting this is. Insertion order is first occurrence, which the bin tie-break depends on.
173
+ */
174
+ export function uniqueTrigrams(words: readonly TimedWord[]): Map<string, number> {
175
+ const times = new Map<string, number[]>()
176
+ for (let i = 0; i + 2 < words.length; i++) {
177
+ const key = trigramKey(words[i].token, words[i + 1].token, words[i + 2].token)
178
+ const existing = times.get(key)
179
+ if (existing) existing.push(words[i + 1].timeMs)
180
+ else times.set(key, [words[i + 1].timeMs])
181
+ }
182
+ const unique = new Map<string, number>()
183
+ for (const [key, ts] of times) if (ts.length === 1) unique.set(key, ts[0])
184
+ return unique
185
+ }
186
+
187
+ function isFiniteNumber(value: unknown): value is number {
188
+ return typeof value === 'number' && Number.isFinite(value)
189
+ }
190
+
191
+ /**
192
+ * G2 words with times, from the HQ batch pass when it ran and from chunk timings otherwise.
193
+ *
194
+ * The relative-or-absolute decision is per recording, not per segment: a mixed verdict
195
+ * would shift half the words by their segment start and leave the rest, which scatters
196
+ * every trigram offset and destroys K.
197
+ */
198
+ export function g2TimedWords(recording: G2RecordingInput): { words: TimedWord[]; mode: G2TimingMode } {
199
+ const words: TimedWord[] = []
200
+ const segments = recording.batchSegments ?? []
201
+ let mode: G2TimingMode = 'none'
202
+ if (segments.length > 0) {
203
+ let relative = 0
204
+ let absolute = 0
205
+ for (const segment of segments) {
206
+ const timed = (segment.words ?? []).filter(w => isFiniteNumber(w.start))
207
+ if (timed.length === 0) continue
208
+ const segmentLengthS = ((segment.endElapsed ?? 0) - (segment.startElapsed ?? 0)) / 1000
209
+ const middle = median(timed.map(w => w.start as number))
210
+ if (middle <= segmentLengthS + G2_BATCH_RELATIVE_MARGIN_S) relative++
211
+ else absolute++
212
+ }
213
+ mode = relative >= absolute ? 'batch_relative' : 'batch_absolute'
214
+ for (const segment of segments) {
215
+ const base = mode === 'batch_relative' ? (segment.startElapsed ?? 0) : 0
216
+ for (const word of segment.words ?? []) {
217
+ if (!isFiniteNumber(word.start)) continue
218
+ for (const token of normalizeWords(word.word)) words.push({ token, timeMs: base + word.start * 1000 })
219
+ }
220
+ }
221
+ }
222
+ if (words.length === 0) {
223
+ const chunks = (recording.chunks ?? []).filter(c => isFiniteNumber(c.elapsed))
224
+ mode = 'chunks'
225
+ for (let i = 0; i < chunks.length; i++) {
226
+ const chunk = chunks[i]
227
+ const elapsed = chunk.elapsed as number
228
+ const next = i + 1 < chunks.length ? (chunks[i + 1].elapsed as number) : elapsed + G2_CHUNK_TAIL_MS
229
+ const tokens = normalizeWords(chunk.text)
230
+ for (let k = 0; k < tokens.length; k++) {
231
+ const spread = ((k + 0.5) / Math.max(1, tokens.length)) * Math.min(G2_CHUNK_SPREAD_MAX_MS, next - elapsed)
232
+ words.push({ token: tokens[k], timeMs: elapsed + spread })
233
+ }
234
+ }
235
+ }
236
+ return { words, mode }
237
+ }
238
+
239
+ /** Fireflies words, spread evenly inside each sentence's own start and end. */
240
+ export function firefliesTimedWords(sentences: readonly FirefliesSentenceInput[]): TimedWord[] {
241
+ const words: TimedWord[] = []
242
+ for (const sentence of sentences) {
243
+ const tokens = normalizeWords(sentence.text)
244
+ const start = Number(sentence.start_time ?? 0) || 0
245
+ const end = Number(sentence.end_time ?? 0) || 0
246
+ for (let k = 0; k < tokens.length; k++) {
247
+ const timeS = start + ((k + 0.5) / Math.max(1, tokens.length)) * Math.max(0, end - start)
248
+ words.push({ token: tokens[k], timeMs: timeS * 1000 })
249
+ }
250
+ }
251
+ return words
252
+ }
253
+
254
+ /** One shared trigram: where it sits in the Fireflies recording, and the offset it implies. */
255
+ export interface AnchorSample {
256
+ /** Milliseconds from the Fireflies recording start. */
257
+ firefliesMs: number
258
+ /** Milliseconds from the G2 capture start. */
259
+ g2Ms: number
260
+ /** `firefliesMs - g2Ms`. */
261
+ offsetMs: number
262
+ }
263
+
264
+ /**
265
+ * A window of believable offsets, centred on what the two clocks say.
266
+ *
267
+ * Shared phrases whose implied offset sits outside it are boilerplate — the same agenda
268
+ * read out in a different meeting — not evidence about THIS one.
269
+ */
270
+ export interface EvidenceBand {
271
+ centreMs: number
272
+ halfWidthMs: number
273
+ }
274
+
275
+ export interface ContentEvidence {
276
+ /** Anchors in the densest offset bin plus its two neighbours. */
277
+ k: number
278
+ /** Start of the densest bin, in ms. Null when nothing is shared. */
279
+ offsetMs: number | null
280
+ /** Every shared unique trigram that counted, for the split spans. */
281
+ samples: AnchorSample[]
282
+ /** Shared trigrams the band threw out. Zero when no band was applied. */
283
+ outsideBand: number
284
+ }
285
+
286
+ /**
287
+ * Shared unique trigrams, counted in the densest offset bin and its neighbours.
288
+ *
289
+ * ±1 bin because a real match's offsets straddle a bin edge as often as not; counting the
290
+ * single densest bin alone would halve K for an unlucky recording.
291
+ *
292
+ * TIE-BREAK. `Counter.most_common(1)` returns the FIRST bin at the maximum in insertion
293
+ * order, and insertion order is the G2 trigram order. A `Map` preserves that, so ties
294
+ * resolve the way the measurement resolved them.
295
+ */
296
+ export function contentEvidence(
297
+ g2Words: readonly TimedWord[],
298
+ firefliesSentences: readonly FirefliesSentenceInput[],
299
+ binMs: number = EVIDENCE_BIN_MS,
300
+ band?: EvidenceBand,
301
+ ): ContentEvidence {
302
+ return contentEvidenceFromTrigrams(
303
+ uniqueTrigrams(g2Words),
304
+ uniqueTrigrams(firefliesTimedWords(firefliesSentences)),
305
+ binMs,
306
+ band,
307
+ )
308
+ }
309
+
310
+ /** The same, when both trigram maps are already built (the split scores many captures against one recording). */
311
+ export function contentEvidenceFromTrigrams(
312
+ g2Trigrams: Map<string, number>,
313
+ firefliesTrigrams: Map<string, number>,
314
+ binMs: number = EVIDENCE_BIN_MS,
315
+ band?: EvidenceBand,
316
+ ): ContentEvidence {
317
+ const bins = new Map<number, number>()
318
+ const samples: AnchorSample[] = []
319
+ let outsideBand = 0
320
+ for (const [key, g2Ms] of g2Trigrams) {
321
+ const firefliesMs = firefliesTrigrams.get(key)
322
+ if (firefliesMs === undefined) continue
323
+ const offsetMs = firefliesMs - g2Ms
324
+ if (band && Math.abs(offsetMs - band.centreMs) > band.halfWidthMs) {
325
+ outsideBand++
326
+ continue
327
+ }
328
+ samples.push({ firefliesMs, g2Ms, offsetMs })
329
+ const bin = offsetBin(offsetMs, binMs)
330
+ bins.set(bin, (bins.get(bin) ?? 0) + 1)
331
+ }
332
+ if (bins.size === 0) return { k: 0, offsetMs: null, samples, outsideBand }
333
+ let densest = 0
334
+ let best = -1
335
+ for (const [bin, count] of bins) {
336
+ if (count > best) {
337
+ best = count
338
+ densest = bin
339
+ }
340
+ }
341
+ const k = best + (bins.get(densest - 1) ?? 0) + (bins.get(densest + 1) ?? 0)
342
+ return { k, offsetMs: densest * binMs, samples, outsideBand }
343
+ }
344
+
345
+ /** The anchors that back an offset: the densest bin and its neighbours, by Fireflies time. */
346
+ export function anchorsInBand(samples: readonly AnchorSample[], offsetMs: number, binMs: number = EVIDENCE_BIN_MS): AnchorSample[] {
347
+ const densest = pythonFloorDiv(offsetMs, binMs)
348
+ return samples
349
+ .filter(sample => Math.abs(offsetBin(sample.offsetMs, binMs) - densest) <= 1)
350
+ .sort((a, b) => a.firefliesMs - b.firefliesMs)
351
+ }
352
+
353
+ /** A silence between consecutive sentences, in seconds from the recording start. */
354
+ export interface SpeechGap {
355
+ startS: number
356
+ endS: number
357
+ }
358
+
359
+ /**
360
+ * Silences of at least `minGapS` between CONSECUTIVE sentences.
361
+ *
362
+ * Consecutive, not against a running maximum end: that is what the measurement did, and
363
+ * the Aug 20 boundaries it produced (27, 25, 42, 42 s from Miles's manual splits) are the
364
+ * gate this must reproduce.
365
+ */
366
+ export function speechGaps(sentences: readonly FirefliesSentenceInput[], minGapS: number): SpeechGap[] {
367
+ const spans = sentences
368
+ .map(s => [Number(s.start_time ?? 0) || 0, Number(s.end_time ?? 0) || 0] as const)
369
+ .sort((a, b) => (a[0] - b[0]) || (a[1] - b[1]))
370
+ const gaps: SpeechGap[] = []
371
+ for (let i = 0; i + 1 < spans.length; i++) {
372
+ const endOfThis = spans[i][1]
373
+ const startOfNext = spans[i + 1][0]
374
+ if (startOfNext - endOfThis >= minGapS) gaps.push({ startS: endOfThis, endS: startOfNext })
375
+ }
376
+ return gaps
377
+ }
378
+
379
+ /** A G2 speaker label with the interval it covers, in ms from the capture start. */
380
+ export interface LabelInterval {
381
+ startMs: number
382
+ endMs: number
383
+ speaker: string
384
+ similarity: number
385
+ humanConfirmed: boolean
386
+ }
387
+
388
+ /**
389
+ * Chunk labels as intervals. A chunk owns the time until the next chunk starts; the last
390
+ * owns `G2_CHUNK_TAIL_MS`.
391
+ */
392
+ export function labelIntervals(recording: G2RecordingInput): LabelInterval[] {
393
+ const labelled = (recording.chunks ?? [])
394
+ .filter(c => isFiniteNumber(c.elapsed))
395
+ .map(c => ({
396
+ elapsed: c.elapsed as number,
397
+ speaker: String(c.speaker ?? ''),
398
+ similarity: Number(c.similarity ?? 0) || 0,
399
+ humanConfirmed: typeof c.speakerCorrectionBatchId === 'string' && c.speakerCorrectionBatchId.length > 0,
400
+ }))
401
+ .sort((a, b) => (a.elapsed - b.elapsed) || a.speaker.localeCompare(b.speaker))
402
+ return labelled.map((label, i) => ({
403
+ startMs: label.elapsed,
404
+ endMs: i + 1 < labelled.length ? labelled[i + 1].elapsed : label.elapsed + G2_CHUNK_TAIL_MS,
405
+ speaker: label.speaker,
406
+ similarity: label.similarity,
407
+ humanConfirmed: label.humanConfirmed,
408
+ }))
409
+ }
410
+
411
+ /** Interval helpers shared by pairing and split. */
412
+ export function overlapMs(a: readonly [number, number], b: readonly [number, number]): number {
413
+ return Math.max(0, Math.min(a[1], b[1]) - Math.max(a[0], b[0]))
414
+ }
415
+
416
+ /** Overlap as a fraction of the SHORTER interval. */
417
+ export function overlapFraction(a: readonly [number, number], b: readonly [number, number]): number {
418
+ const shorter = Math.min(a[1] - a[0], b[1] - b[0])
419
+ return shorter > 0 ? overlapMs(a, b) / shorter : 0
420
+ }
421
+
422
+ export function g2Interval(recording: G2RecordingInput): [number, number] {
423
+ return [recording.startMs, recording.startMs + recording.durationMs]
424
+ }
425
+
426
+ export function firefliesInterval(meeting: FirefliesMeetingInput): [number, number] {
427
+ return [meeting.startMs, meeting.startMs + meeting.durationS * 1000]
428
+ }