@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.
- package/CHANGELOG.md +21 -0
- package/package.json +6 -2
- package/server/index.ts +76 -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/g2-ops-handoff.ts +15 -1
- 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/voice-evidence-guard.ts +87 -0
- package/server/routes/fireflies-key.ts +102 -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 +205 -37
- 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
|
+
}
|