@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,327 @@
1
+ /**
2
+ * Candidates, duplicate collapse, tiers and grouping (6.47.0, WS3).
3
+ *
4
+ * PURE. No filesystem, no network, no clock.
5
+ *
6
+ * Measured on 198 G2 recordings that already have a merge: 161 auto, 21 suggest, 16 none,
7
+ * with 155 of the 161 agreeing with the existing merge. `server/scripts/engine-canary.ts`
8
+ * reproduces those counts within ±2 before this ships.
9
+ */
10
+
11
+ import {
12
+ type FirefliesMeetingInput,
13
+ type G2RecordingInput,
14
+ type G2TimingMode,
15
+ EVIDENCE_BIN_MS,
16
+ contentEvidence,
17
+ firefliesInterval,
18
+ g2Interval,
19
+ g2TimedWords,
20
+ overlapFraction,
21
+ } from './evidence.js'
22
+
23
+ /**
24
+ * How far outside the G2 interval a Fireflies meeting may start or end and still be
25
+ * scored. An hour is generous on purpose: time only selects candidates here, and content
26
+ * refuses the wrong ones. A tight window would instead silently drop the cases where one
27
+ * side's clock or scheduled start is off.
28
+ */
29
+ export const CANDIDATE_PAD_S = 3600
30
+
31
+ /** Shared phrases needed before a merge may happen without asking. */
32
+ export const AUTO_MIN_ANCHORS = 30
33
+
34
+ /** How far the winner must beat the next distinct meeting. */
35
+ export const AUTO_DOMINANCE = 3
36
+
37
+ /** Below this, content evidence is noise and nothing is offered. */
38
+ export const SUGGEST_MIN_ANCHORS = 10
39
+
40
+ /**
41
+ * How far the offset that shared phrases imply may sit from what the two CLOCKS say.
42
+ *
43
+ * WHY A BAND. Content decides which meeting a capture belongs to, but content alone cannot
44
+ * tell a meeting from the boilerplate it shares with its neighbour: a recurring agenda read
45
+ * out in the 10:30 call stacks into one dense bin against the 10:00 capture exactly as a
46
+ * real match does. The clocks separate them.
47
+ *
48
+ * WHY FIFTEEN MINUTES AND NOT FIVE. Measured over all 198 scored recordings, not the ten
49
+ * the alignment sample used: the winning pair's skew runs median 84 s, p90 271 s, with a
50
+ * real tail — 9 of the 167 winners that ARE the recorded merge sit past 300 s and the
51
+ * worst sits at 5,877 s. At a 300 s band three genuine merges collapse from K 5,700, 3,923
52
+ * and 3,980 to K 1, and the reproduction gate fails at 159 / 19 / 20. At 900 s the gate
53
+ * holds (161 / 20 / 17) and the contaminating neighbour, 1,620 s off its clock, is still
54
+ * refused with room to spare.
55
+ *
56
+ * WHAT IT DOES NOT DO. Two meetings less than about fifteen minutes apart are not separated
57
+ * by this rule; K and dominance remain the defence there.
58
+ */
59
+ export const PAIRING_CLOCK_BAND_S = 900
60
+
61
+ /** Two Fireflies recordings of ONE meeting start within this many seconds of each other. */
62
+ export const DUPLICATE_START_S = 300
63
+
64
+ /** ...and overlap by at least this fraction of the shorter one. */
65
+ export const DUPLICATE_OVERLAP = 0.8
66
+
67
+ export type MergeTier = 'auto_merge' | 'suggest' | 'none'
68
+
69
+ export type CandidateRejectionReason =
70
+ /** Fireflies returned no usable duration, so the interval is unknown. */
71
+ | 'duration_unknown'
72
+ /** Its interval does not reach the padded G2 interval. */
73
+ | 'outside_padded_interval'
74
+ /** Shared phrases exist, but the offset they imply puts the capture outside the recording. */
75
+ | 'offset_outside_interval'
76
+
77
+ export interface CandidateScore {
78
+ firefliesId: string
79
+ k: number
80
+ offsetMs: number | null
81
+ sentenceCount: number
82
+ startMs: number
83
+ durationS: number
84
+ /** Shared phrases the clock band threw out: high means boilerplate, not a match. */
85
+ anchorsOutsideClockBand: number
86
+ }
87
+
88
+ export interface CandidateRejection {
89
+ firefliesId: string
90
+ reason: CandidateRejectionReason
91
+ k?: number
92
+ offsetMs?: number | null
93
+ }
94
+
95
+ export interface PairingResult {
96
+ sessionId: string
97
+ tier: MergeTier
98
+ /** Shared phrases with the winner. */
99
+ k1: number
100
+ /** Shared phrases with the best OTHER meeting, after duplicate collapse. */
101
+ k2: number
102
+ offsetMs: number | null
103
+ /** The Fireflies meeting a merge would be built on, or null. */
104
+ primaryFirefliesId: string | null
105
+ /** Other recordings of the same meeting; their transcripts are kept as alternates. */
106
+ duplicateFirefliesIds: string[]
107
+ candidates: CandidateScore[]
108
+ rejections: CandidateRejection[]
109
+ timingMode: G2TimingMode
110
+ }
111
+
112
+ export interface MergeGroup {
113
+ primaryFirefliesId: string
114
+ /** Duplicate recordings of the same meeting, in id order. */
115
+ alternateFirefliesIds: string[]
116
+ /** Every G2 capture of this meeting, in start order. */
117
+ sessionIds: string[]
118
+ k1: number
119
+ k2: number
120
+ /**
121
+ * The offset PAIRING measured for each capture, in ms, or null when it measured none.
122
+ *
123
+ * WHY THE GROUP HAS TO CARRY IT. Alignment needs a coarse offset to search around, and
124
+ * without one it falls back to the difference between the two CLOCKS. Pairing already knows
125
+ * better: it found the offset the shared phrases imply, which is the whole point of the
126
+ * evidence step. On the 9 of 167 winners whose clocks disagree by more than 300 s, the
127
+ * clock fallback puts the alignment band (`ALIGN_BAND_S = 300`) entirely off the real
128
+ * anchors, so alignment fails and the capture is attached without relabelling — silently,
129
+ * because "unaligned" is a legitimate outcome.
130
+ */
131
+ offsetMsBySession: Record<string, number | null>
132
+ }
133
+
134
+ export function tierFor(k1: number, k2: number): MergeTier {
135
+ if (k1 >= AUTO_MIN_ANCHORS && k1 >= AUTO_DOMINANCE * Math.max(k2, 1)) return 'auto_merge'
136
+ if (k1 >= AUTO_MIN_ANCHORS) return 'suggest'
137
+ if (k1 >= SUGGEST_MIN_ANCHORS) return 'suggest'
138
+ return 'none'
139
+ }
140
+
141
+ /** Two Fireflies recordings of one meeting. */
142
+ export function isDuplicateRecording(a: FirefliesMeetingInput, b: FirefliesMeetingInput): boolean {
143
+ return (
144
+ Math.abs(a.startMs - b.startMs) <= DUPLICATE_START_S * 1000
145
+ && overlapFraction(firefliesInterval(a), firefliesInterval(b)) >= DUPLICATE_OVERLAP
146
+ )
147
+ }
148
+
149
+ /**
150
+ * Fireflies meetings whose interval reaches the G2 interval padded by an hour.
151
+ *
152
+ * Seconds, and these exact comparisons, because the tier measurement used them; moving to
153
+ * milliseconds changes which meetings sit exactly on the boundary.
154
+ */
155
+ export function candidatesFor(
156
+ recording: G2RecordingInput,
157
+ meetings: readonly FirefliesMeetingInput[],
158
+ ): { candidates: FirefliesMeetingInput[]; rejections: CandidateRejection[] } {
159
+ const startS = recording.startMs / 1000 - CANDIDATE_PAD_S
160
+ const endS = (recording.startMs + recording.durationMs) / 1000 + CANDIDATE_PAD_S
161
+ const candidates: FirefliesMeetingInput[] = []
162
+ const rejections: CandidateRejection[] = []
163
+ for (const meeting of meetings) {
164
+ if (!(meeting.durationS > 0)) {
165
+ rejections.push({ firefliesId: meeting.id, reason: 'duration_unknown' })
166
+ continue
167
+ }
168
+ const meetingStartS = meeting.startMs / 1000
169
+ if (meetingStartS < endS && meetingStartS + meeting.durationS > startS) candidates.push(meeting)
170
+ else rejections.push({ firefliesId: meeting.id, reason: 'outside_padded_interval' })
171
+ }
172
+ return { candidates, rejections }
173
+ }
174
+
175
+ /**
176
+ * Does the offset put the G2 capture inside this recording?
177
+ *
178
+ * A contaminating meeting — the one before this one, or last week's run of the same
179
+ * recurring call — shares agenda phrases, and those phrases can stack into one dense bin.
180
+ * When the offset that bin implies would place the capture outside the recording it was
181
+ * supposedly part of, the phrases are boilerplate, not evidence.
182
+ */
183
+ export function offsetPlacesCaptureInside(
184
+ offsetMs: number,
185
+ recording: G2RecordingInput,
186
+ meeting: FirefliesMeetingInput,
187
+ padS: number = CANDIDATE_PAD_S,
188
+ ): boolean {
189
+ const padMs = padS * 1000
190
+ return offsetMs >= -padMs && offsetMs + recording.durationMs <= meeting.durationS * 1000 + padMs
191
+ }
192
+
193
+ /**
194
+ * Score one G2 capture against every Fireflies meeting.
195
+ *
196
+ * The winner's duplicates are collapsed BEFORE dominance is checked. Without that, a
197
+ * meeting recorded twice (two bots in the call) is its own strongest rival and every such
198
+ * capture falls to `suggest` — 16 of the 161 automatic merges are only automatic because
199
+ * of this step.
200
+ */
201
+ export function scoreRecording(
202
+ recording: G2RecordingInput,
203
+ meetings: readonly FirefliesMeetingInput[],
204
+ ): PairingResult {
205
+ const { words, mode } = g2TimedWords(recording)
206
+ const { candidates, rejections } = candidatesFor(recording, meetings)
207
+ const scored: CandidateScore[] = []
208
+ for (const meeting of candidates) {
209
+ // Centred on the clocks: t_ff - t_g2 equals g2Start - ffStart for a real match.
210
+ const evidence = contentEvidence(words, meeting.sentences, EVIDENCE_BIN_MS, {
211
+ centreMs: recording.startMs - meeting.startMs,
212
+ halfWidthMs: PAIRING_CLOCK_BAND_S * 1000,
213
+ })
214
+ if (evidence.offsetMs !== null && !offsetPlacesCaptureInside(evidence.offsetMs, recording, meeting)) {
215
+ rejections.push({ firefliesId: meeting.id, reason: 'offset_outside_interval', k: evidence.k, offsetMs: evidence.offsetMs })
216
+ continue
217
+ }
218
+ scored.push({
219
+ firefliesId: meeting.id,
220
+ k: evidence.k,
221
+ offsetMs: evidence.offsetMs,
222
+ sentenceCount: meeting.sentences.length,
223
+ startMs: meeting.startMs,
224
+ durationS: meeting.durationS,
225
+ anchorsOutsideClockBand: evidence.outsideBand,
226
+ })
227
+ }
228
+ // (k, id) descending: the measurement sorted the same tuples in reverse.
229
+ scored.sort((a, b) => (b.k - a.k) || (a.firefliesId < b.firefliesId ? 1 : a.firefliesId > b.firefliesId ? -1 : 0))
230
+
231
+ const empty: PairingResult = {
232
+ sessionId: recording.sessionId,
233
+ tier: 'none',
234
+ k1: 0,
235
+ k2: 0,
236
+ offsetMs: null,
237
+ primaryFirefliesId: null,
238
+ duplicateFirefliesIds: [],
239
+ candidates: scored,
240
+ rejections,
241
+ timingMode: mode,
242
+ }
243
+ if (scored.length === 0) return empty
244
+
245
+ const byId = new Map(meetings.map(m => [m.id, m]))
246
+ const top = scored[0]
247
+ const topMeeting = byId.get(top.firefliesId)!
248
+ const cluster = [top]
249
+ const rest: CandidateScore[] = []
250
+ for (const candidate of scored.slice(1)) {
251
+ const meeting = byId.get(candidate.firefliesId)!
252
+ if (isDuplicateRecording(topMeeting, meeting)) cluster.push(candidate)
253
+ else rest.push(candidate)
254
+ }
255
+ const k1 = top.k
256
+ const k2 = rest.length > 0 ? rest[0].k : 0
257
+ const tier = tierFor(k1, k2)
258
+ if (tier === 'none') return { ...empty, candidates: scored, k1, k2, offsetMs: top.offsetMs }
259
+
260
+ const primary = pickPrimary(cluster)
261
+ return {
262
+ sessionId: recording.sessionId,
263
+ tier,
264
+ k1,
265
+ k2,
266
+ offsetMs: (cluster.find(c => c.firefliesId === primary.firefliesId) ?? top).offsetMs,
267
+ primaryFirefliesId: primary.firefliesId,
268
+ duplicateFirefliesIds: cluster.filter(c => c.firefliesId !== primary.firefliesId).map(c => c.firefliesId).sort(),
269
+ candidates: scored,
270
+ rejections,
271
+ timingMode: mode,
272
+ }
273
+ }
274
+
275
+ /** The fullest transcript of a meeting leads; the others are kept as alternates. */
276
+ export function pickPrimary(cluster: readonly CandidateScore[]): CandidateScore {
277
+ return [...cluster].sort((a, b) =>
278
+ (b.sentenceCount - a.sentenceCount)
279
+ || (b.k - a.k)
280
+ || (a.startMs - b.startMs)
281
+ || (a.firefliesId < b.firefliesId ? -1 : a.firefliesId > b.firefliesId ? 1 : 0),
282
+ )[0]
283
+ }
284
+
285
+ /**
286
+ * One merged record per meeting, holding every capture of it.
287
+ *
288
+ * Blocker 10: scoring is per capture, so two G2 recordings of one meeting — a battery
289
+ * swap, a rejoin — produced two merged records of the same meeting, each claiming the same
290
+ * Fireflies transcript. Grouping by the Fireflies primary is what makes the output one
291
+ * record. A capture belongs to at most one group, because it has one primary.
292
+ */
293
+ export function groupAutoMerges(
294
+ results: readonly PairingResult[],
295
+ recordings: readonly G2RecordingInput[],
296
+ ): MergeGroup[] {
297
+ const startById = new Map(recordings.map(r => [r.sessionId, r.startMs]))
298
+ const groups = new Map<string, MergeGroup>()
299
+ for (const result of results) {
300
+ if (result.tier !== 'auto_merge' || !result.primaryFirefliesId) continue
301
+ const existing = groups.get(result.primaryFirefliesId)
302
+ if (existing) {
303
+ existing.sessionIds.push(result.sessionId)
304
+ for (const id of result.duplicateFirefliesIds) {
305
+ if (!existing.alternateFirefliesIds.includes(id)) existing.alternateFirefliesIds.push(id)
306
+ }
307
+ existing.k1 = Math.max(existing.k1, result.k1)
308
+ existing.k2 = Math.max(existing.k2, result.k2)
309
+ existing.offsetMsBySession[result.sessionId] = result.offsetMs
310
+ continue
311
+ }
312
+ groups.set(result.primaryFirefliesId, {
313
+ primaryFirefliesId: result.primaryFirefliesId,
314
+ alternateFirefliesIds: [...result.duplicateFirefliesIds],
315
+ sessionIds: [result.sessionId],
316
+ k1: result.k1,
317
+ k2: result.k2,
318
+ offsetMsBySession: { [result.sessionId]: result.offsetMs },
319
+ })
320
+ }
321
+ const ordered = [...groups.values()]
322
+ for (const group of ordered) {
323
+ group.sessionIds.sort((a, b) => (startById.get(a) ?? 0) - (startById.get(b) ?? 0) || (a < b ? -1 : a > b ? 1 : 0))
324
+ group.alternateFirefliesIds.sort()
325
+ }
326
+ return ordered.sort((a, b) => (a.primaryFirefliesId < b.primaryFirefliesId ? -1 : a.primaryFirefliesId > b.primaryFirefliesId ? 1 : 0))
327
+ }