@gotcos/glasses-server 6.46.1 → 6.48.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.
- package/CHANGELOG.md +25 -0
- package/README.md +24 -0
- package/bin/cli.cjs +22 -0
- package/bin/hooks/cos-session-hook +43 -0
- package/managed-runtime-contract.json +7 -1
- package/package.json +8 -2
- package/server/index.ts +85 -0
- package/server/lib/claude-hooks-installer.ts +403 -0
- package/server/lib/claude-session-registry.ts +25 -0
- package/server/lib/cos-operations-meetings.ts +99 -8
- package/server/lib/fireflies-client.ts +862 -0
- package/server/lib/fireflies-key.ts +182 -0
- package/server/lib/imported-library-rows.ts +616 -0
- package/server/lib/imported-meeting-library.ts +608 -0
- package/server/lib/maintenance-lifecycle.ts +14 -0
- package/server/lib/meeting-actions-store.ts +478 -0
- package/server/lib/meeting-actions.ts +2583 -0
- package/server/lib/meeting-corrections.ts +32 -1
- package/server/lib/meeting-decisions.ts +223 -0
- package/server/lib/meeting-engine/align.ts +167 -0
- package/server/lib/meeting-engine/attribute.ts +265 -0
- package/server/lib/meeting-engine/evidence.ts +428 -0
- package/server/lib/meeting-engine/pairing.ts +327 -0
- package/server/lib/meeting-engine/render.ts +694 -0
- package/server/lib/meeting-engine/split.ts +242 -0
- package/server/lib/meeting-engine/worker.ts +238 -0
- package/server/lib/meeting-engine-mode.ts +197 -0
- package/server/lib/meeting-file-guards.ts +141 -0
- package/server/lib/meeting-import.ts +763 -0
- package/server/lib/meeting-library-search.ts +146 -11
- package/server/lib/meeting-parse.ts +184 -0
- package/server/lib/meeting-store.ts +108 -275
- package/server/lib/meeting-suggestion-sides.ts +242 -0
- package/server/lib/morning-brief-runtime.ts +20 -8
- package/server/lib/pipeline-runner.ts +227 -0
- package/server/lib/session-hook-events.ts +200 -0
- package/server/lib/session-hook-ledger.ts +129 -0
- package/server/lib/session-hook-spool.ts +264 -0
- package/server/lib/session-hooks-runtime.ts +229 -0
- package/server/lib/session-signal-store.ts +361 -0
- package/server/lib/session-state-derive.ts +211 -0
- package/server/lib/voice-evidence-guard.ts +87 -0
- package/server/routes/agent-sessions.ts +56 -6
- package/server/routes/claude-sessions.ts +32 -5
- package/server/routes/fireflies-key.ts +102 -0
- package/server/routes/health.ts +2 -0
- package/server/routes/meeting-actions.ts +82 -0
- package/server/routes/meeting-engine.ts +52 -0
- package/server/routes/meeting-import.ts +67 -0
- package/server/routes/meeting-suggestions.ts +66 -0
- package/server/routes/meeting.ts +117 -10
- package/server/routes/meetings.ts +177 -50
- package/server/routes/session-hooks.ts +70 -0
- package/server/routes/voice.ts +18 -0
- package/server/scripts/hooks-cli.ts +48 -0
- package/server/lib/__fixtures__/query-jobs-6.43.3/2099-01-01.jsonl +0 -2
|
@@ -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
|
+
}
|