@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
|
@@ -108,11 +108,35 @@ function sessionFile(sessionId: string): string | null {
|
|
|
108
108
|
return resolve(path).startsWith(resolve(dir) + '/') ? path : null
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
export type CorrectionListener = (sessionId: string, row: CorrectionRow) => void
|
|
112
|
+
|
|
113
|
+
const correctionListeners = new Set<CorrectionListener>()
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Be told when a speaker correction lands, wherever it came from.
|
|
117
|
+
*
|
|
118
|
+
* ONE PLACE, NOT THREE ROUTES. Relabel, confirm and de-attribute are three handlers today
|
|
119
|
+
* and a fourth is a plausible next release; a listener wired into each of them is a listener
|
|
120
|
+
* the fourth silently does not get. Every one of them closes its correction here, so this is
|
|
121
|
+
* where "a human changed who said what in this meeting" actually happens.
|
|
122
|
+
*
|
|
123
|
+
* A listener that throws is contained: a correction has already been recorded by the time
|
|
124
|
+
* this runs, and a downstream reaction failing must never make the correction look failed.
|
|
125
|
+
*/
|
|
126
|
+
export function onCorrectionApplied(listener: CorrectionListener): () => void {
|
|
127
|
+
correctionListeners.add(listener)
|
|
128
|
+
return () => { correctionListeners.delete(listener) }
|
|
129
|
+
}
|
|
130
|
+
|
|
111
131
|
/**
|
|
112
132
|
* Append one row. Returns false rather than throwing on a bad session id or an
|
|
113
133
|
* unwritable directory — but note the caller's contract: if the INTENT row
|
|
114
134
|
* cannot be written, the rewrite must not proceed. An unrecorded mutation is
|
|
115
135
|
* exactly what this file exists to prevent.
|
|
136
|
+
*
|
|
137
|
+
* Listeners run only AFTER the row is durable, and only when it was written: a
|
|
138
|
+
* subscriber told about a correction that never landed would act on a change
|
|
139
|
+
* that does not exist.
|
|
116
140
|
*/
|
|
117
141
|
export function appendCorrection(sessionId: string, row: CorrectionRow): boolean {
|
|
118
142
|
const path = sessionFile(sessionId)
|
|
@@ -120,10 +144,17 @@ export function appendCorrection(sessionId: string, row: CorrectionRow): boolean
|
|
|
120
144
|
try {
|
|
121
145
|
mkdirSync(dataPath(CORRECTIONS_DIR), { recursive: true, mode: 0o700 })
|
|
122
146
|
appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
|
|
123
|
-
return true
|
|
124
147
|
} catch {
|
|
125
148
|
return false
|
|
126
149
|
}
|
|
150
|
+
for (const listener of correctionListeners) {
|
|
151
|
+
try {
|
|
152
|
+
listener(sessionId, row)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.warn('[meeting-corrections] listener failed:', error)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return true
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
export interface CorrectionReadResult {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision files: one per action, the only thing the pipeline reads (6.47.0, WS4).
|
|
3
|
+
*
|
|
4
|
+
* WHY ONE FILE PER ACTION (v3 blocker 12). The first design had the server and the pipeline
|
|
5
|
+
* both writing `merge-decisions.json`. Two writers on one JSON file is a lost update waiting
|
|
6
|
+
* for a slow disk, and neither side can tell afterwards which write survived. Here the
|
|
7
|
+
* server writes `decisions/<actionId>.json` and nothing else touches it; the pipeline reads
|
|
8
|
+
* it, does its work, and reports through stdout. The server then writes the report back into
|
|
9
|
+
* the same file under `result`, so a later Revert can read what actually happened rather
|
|
10
|
+
* than what was intended.
|
|
11
|
+
*
|
|
12
|
+
* WHY THE RENDERED CONTENT LIVES HERE AND NOT IN `.actions.json`. The patch holds transcript
|
|
13
|
+
* text — the whole G2 capture, alternate transcripts. `.actions.json` is read on every
|
|
14
|
+
* status poll and every list; putting megabytes of transcript in it would make a status call
|
|
15
|
+
* cost a transcript read, and would put meeting content in a file whose purpose is
|
|
16
|
+
* bookkeeping. The action record names the decision; the decision holds the content.
|
|
17
|
+
*
|
|
18
|
+
* MODE 0600 IN A 0700 DIRECTORY. These files contain meeting transcripts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from 'node:crypto'
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
25
|
+
import { importsRoot } from './imported-meeting-library.js'
|
|
26
|
+
import type { PipelinePatch } from './meeting-engine/render.js'
|
|
27
|
+
import { securePrivateDirectory } from './secure-user-config.js'
|
|
28
|
+
|
|
29
|
+
export const DECISIONS_DIR_NAME = 'decisions'
|
|
30
|
+
export const DECISION_SCHEMA = 1
|
|
31
|
+
|
|
32
|
+
/** The prefix the pipeline prints its one report line with. */
|
|
33
|
+
export const MERGE_RESULT_PREFIX = 'COS_MERGE_RESULT='
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Exit codes, as the pipeline command defines them (WS8b).
|
|
37
|
+
*
|
|
38
|
+
* 3 is NOT a failure. It means another pipeline process held the sync lock, which happens
|
|
39
|
+
* every hour when the scheduled sync runs; the action stays `pending` and is retried.
|
|
40
|
+
*/
|
|
41
|
+
export const PIPELINE_EXIT_LOCK_BUSY = 3
|
|
42
|
+
/** The decision does not match the files on disk. Terminal: retrying cannot help. */
|
|
43
|
+
export const PIPELINE_EXIT_DECISION_INVALID = 4
|
|
44
|
+
/** A step failed part way, or the whole apply failed. Retried once. */
|
|
45
|
+
export const PIPELINE_EXIT_PARTIAL_OR_FAILED = 5
|
|
46
|
+
|
|
47
|
+
/** `a_` plus 16 hex. Enforced before the id reaches a path. */
|
|
48
|
+
export const ACTION_ID_PATTERN = /^a_[0-9a-f]{16}$/
|
|
49
|
+
|
|
50
|
+
export type MergeDecisionKind = 'merge' | 'split'
|
|
51
|
+
|
|
52
|
+
export interface MergeDecisionG2Input {
|
|
53
|
+
kind: 'g2'
|
|
54
|
+
sessionId: string
|
|
55
|
+
/** Operations-relative path of the `.g2-chunks.json`. */
|
|
56
|
+
sidecarRelPath: string
|
|
57
|
+
/** Operations-relative path of the standalone G2 scribe, when one exists. */
|
|
58
|
+
scribeRelPath?: string
|
|
59
|
+
sha256: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MergeDecisionFirefliesInput {
|
|
63
|
+
kind: 'fireflies'
|
|
64
|
+
firefliesId: string
|
|
65
|
+
/** Operations-relative path of the Fireflies scribe the patch is spliced into. */
|
|
66
|
+
scribeRelPath: string
|
|
67
|
+
/** Operations-relative path of its `.fireflies.json` sentences sidecar. */
|
|
68
|
+
sidecarRelPath: string
|
|
69
|
+
sha256: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type MergeDecisionInput = MergeDecisionG2Input | MergeDecisionFirefliesInput
|
|
73
|
+
|
|
74
|
+
export interface MergeDecisionResultOutput { path: string; sha256: string }
|
|
75
|
+
export interface MergeDecisionResultArchive { original: string; archive: string; sha256: string }
|
|
76
|
+
export interface MergeDecisionResultStamp { sidecar: string; blended_into: string }
|
|
77
|
+
|
|
78
|
+
export type MergePipelineStatus = 'applied' | 'reverted' | 'partial' | 'failed'
|
|
79
|
+
|
|
80
|
+
export interface MergePipelineResult {
|
|
81
|
+
schema: number
|
|
82
|
+
action_id: string
|
|
83
|
+
status: MergePipelineStatus
|
|
84
|
+
outputs: MergeDecisionResultOutput[]
|
|
85
|
+
archived: MergeDecisionResultArchive[]
|
|
86
|
+
retired: MergeDecisionResultArchive[]
|
|
87
|
+
stamps: MergeDecisionResultStamp[]
|
|
88
|
+
transcript_map: 'applied' | 'skipped'
|
|
89
|
+
step?: string
|
|
90
|
+
error_code?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface MergeDecision {
|
|
94
|
+
schema: number
|
|
95
|
+
actionId: string
|
|
96
|
+
kind: MergeDecisionKind
|
|
97
|
+
inputs: MergeDecisionInput[]
|
|
98
|
+
patch: PipelinePatch
|
|
99
|
+
speakerMap: PipelinePatch['speakerMap']
|
|
100
|
+
/** Operations-relative G2 scribe paths the pipeline archives and removes. */
|
|
101
|
+
retire: string[]
|
|
102
|
+
/** Written back by the server after the pipeline reports. */
|
|
103
|
+
result?: MergePipelineResult
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class DecisionError extends Error {
|
|
107
|
+
constructor(message: string, readonly code: string) {
|
|
108
|
+
super(message)
|
|
109
|
+
this.name = 'DecisionError'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function assertActionId(actionId: string): string {
|
|
114
|
+
if (!ACTION_ID_PATTERN.test(actionId)) {
|
|
115
|
+
throw new DecisionError('Invalid action id', 'invalid_action_id')
|
|
116
|
+
}
|
|
117
|
+
return actionId
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function decisionsDir(root: string = importsRoot()): string {
|
|
121
|
+
return join(root, DECISIONS_DIR_NAME)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function decisionPath(actionId: string, root?: string): string {
|
|
125
|
+
return join(decisionsDir(root), `${assertActionId(actionId)}.json`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function sha256OfFile(path: string): string | null {
|
|
129
|
+
try {
|
|
130
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
131
|
+
} catch {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Does this patch add anything at all?
|
|
138
|
+
*
|
|
139
|
+
* A patch with no sections, no rows and no markers is a decision the pipeline can apply
|
|
140
|
+
* successfully while changing nothing: it archives the Fireflies scribe, retires the G2
|
|
141
|
+
* standalone, writes the same bytes back, and reports `applied`. The capture disappears from
|
|
142
|
+
* the list and nothing takes its place. That is the one outcome an additive design must not
|
|
143
|
+
* be able to produce, and the cheapest place to make it impossible is the writer, which
|
|
144
|
+
* every apply and every re-drive goes through.
|
|
145
|
+
*/
|
|
146
|
+
export function patchIsEmpty(patch: Pick<PipelinePatch, 'sections' | 'rows' | 'markers'> | null | undefined): boolean {
|
|
147
|
+
if (!patch) return true
|
|
148
|
+
return (patch.sections?.length ?? 0) === 0
|
|
149
|
+
&& (patch.rows?.length ?? 0) === 0
|
|
150
|
+
&& (patch.markers?.length ?? 0) === 0
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Write one decision. The directory is created 0700 and the file 0600 every time. */
|
|
154
|
+
export function writeDecision(decision: MergeDecision, root?: string): string {
|
|
155
|
+
if (patchIsEmpty(decision.patch)) {
|
|
156
|
+
throw new DecisionError('A merge decision must add something to the scribe', 'patch_empty')
|
|
157
|
+
}
|
|
158
|
+
const path = decisionPath(decision.actionId, root)
|
|
159
|
+
securePrivateDirectory(decisionsDir(root))
|
|
160
|
+
durableAtomicWriteFileSync(path, `${JSON.stringify(decision, null, 2)}\n`, { mode: 0o600 })
|
|
161
|
+
return path
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function readDecision(actionId: string, root?: string): MergeDecision | null {
|
|
165
|
+
const path = decisionPath(actionId, root)
|
|
166
|
+
if (!existsSync(path)) return null
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial<MergeDecision>
|
|
169
|
+
if (parsed?.schema !== DECISION_SCHEMA || parsed.actionId !== actionId) return null
|
|
170
|
+
return parsed as MergeDecision
|
|
171
|
+
} catch {
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Record what the pipeline reported, in the decision it was given.
|
|
178
|
+
*
|
|
179
|
+
* Revert reads this, not the action record: the archive paths and their sha256 are what let
|
|
180
|
+
* a restore prove it is putting back the same bytes that were moved aside.
|
|
181
|
+
*/
|
|
182
|
+
export function writeDecisionResult(actionId: string, result: MergePipelineResult, root?: string): boolean {
|
|
183
|
+
const decision = readDecision(actionId, root)
|
|
184
|
+
if (!decision) return false
|
|
185
|
+
writeDecision({ ...decision, result }, root)
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function deleteDecision(actionId: string, root?: string): void {
|
|
190
|
+
try { unlinkSync(decisionPath(actionId, root)) } catch { /* already gone */ }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function listDecisionIds(root?: string): string[] {
|
|
194
|
+
try {
|
|
195
|
+
return readdirSync(decisionsDir(root))
|
|
196
|
+
.filter(name => name.endsWith('.json'))
|
|
197
|
+
.map(name => name.slice(0, -'.json'.length))
|
|
198
|
+
.filter(id => ACTION_ID_PATTERN.test(id))
|
|
199
|
+
.sort()
|
|
200
|
+
} catch {
|
|
201
|
+
return []
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Is this a report the server may act on?
|
|
207
|
+
*
|
|
208
|
+
* Deliberately strict about the three fields a Revert depends on and permissive about the
|
|
209
|
+
* rest: a pipeline that grows a field must not make every apply unreadable, but one that
|
|
210
|
+
* omits `archived` has not told us how to undo what it did.
|
|
211
|
+
*/
|
|
212
|
+
export function isMergePipelineResult(value: unknown): value is MergePipelineResult {
|
|
213
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
214
|
+
const row = value as Record<string, unknown>
|
|
215
|
+
if (row.schema !== DECISION_SCHEMA) return false
|
|
216
|
+
if (typeof row.action_id !== 'string' || !ACTION_ID_PATTERN.test(row.action_id)) return false
|
|
217
|
+
if (row.status !== 'applied' && row.status !== 'reverted' && row.status !== 'partial' && row.status !== 'failed') return false
|
|
218
|
+
if (row.transcript_map !== 'applied' && row.transcript_map !== 'skipped') return false
|
|
219
|
+
for (const key of ['outputs', 'archived', 'retired', 'stamps']) {
|
|
220
|
+
if (!Array.isArray(row[key])) return false
|
|
221
|
+
}
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fine alignment between a G2 capture and a Fireflies transcript (6.47.0, WS3).
|
|
3
|
+
*
|
|
4
|
+
* PURE. No filesystem, no network, no clock.
|
|
5
|
+
*
|
|
6
|
+
* Alignment exists ONLY to decide whether per-sentence relabelling is allowed. A capture
|
|
7
|
+
* that fails to align is still attached to the merged record in full — nothing is dropped,
|
|
8
|
+
* the Fireflies labels simply stand.
|
|
9
|
+
*
|
|
10
|
+
* WHY SENTENCE STARTS. Fireflies gives an exact `start_time` per sentence, while word
|
|
11
|
+
* times inside a sentence are interpolated by this engine. Anchoring on the first trigram
|
|
12
|
+
* of a sentence measures against a real timestamp; anchoring on arbitrary words measures
|
|
13
|
+
* against the interpolation and inflates the spread. Measured on 10 real pairs, sentence
|
|
14
|
+
* starts land at MAD 1.5 to 3.3 s.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type FirefliesSentenceInput,
|
|
19
|
+
type TimedWord,
|
|
20
|
+
firefliesTimedWords,
|
|
21
|
+
median,
|
|
22
|
+
medianAbsoluteDeviation,
|
|
23
|
+
normalizeWords,
|
|
24
|
+
trigramKey,
|
|
25
|
+
uniqueTrigrams,
|
|
26
|
+
} from './evidence.js'
|
|
27
|
+
|
|
28
|
+
/** How far an anchor's offset may sit from the coarse offset before it is a different conversation. */
|
|
29
|
+
export const ALIGN_BAND_S = 300
|
|
30
|
+
|
|
31
|
+
/** Anchors needed before the offsets are a measurement rather than a coincidence. */
|
|
32
|
+
export const ALIGN_MIN_ANCHORS = 8
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Spread allowed around the median offset, in ms.
|
|
36
|
+
*
|
|
37
|
+
* Chosen from the same 10 pairs it is measured on, which is a real weakness and is why
|
|
38
|
+
* the share gate and the 0.55 similarity floor, not this threshold, are what keep a wrong
|
|
39
|
+
* name off a sentence.
|
|
40
|
+
*/
|
|
41
|
+
export const ALIGN_MAX_MAD_MS = 3500
|
|
42
|
+
|
|
43
|
+
/** Clock drift is not constant over an hour, so the offset is re-measured per window. */
|
|
44
|
+
export const ALIGN_WINDOW_MS = 120_000
|
|
45
|
+
|
|
46
|
+
/** A window with fewer anchors than this has no offset of its own and inherits one. */
|
|
47
|
+
export const ALIGN_WINDOW_MIN_ANCHORS = 3
|
|
48
|
+
|
|
49
|
+
export type AttributionState = 'aligned' | 'unaligned' | 'capture_only'
|
|
50
|
+
|
|
51
|
+
export interface AlignmentAnchor {
|
|
52
|
+
/** Milliseconds from the G2 capture start. */
|
|
53
|
+
g2Ms: number
|
|
54
|
+
/** `firefliesMs - g2Ms`. */
|
|
55
|
+
offsetMs: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Alignment {
|
|
59
|
+
aligned: boolean
|
|
60
|
+
anchors: number
|
|
61
|
+
/** Null when there were too few anchors to measure. */
|
|
62
|
+
madMs: number | null
|
|
63
|
+
/** Median offset across all anchors. Null when unmeasured. */
|
|
64
|
+
offsetMs: number | null
|
|
65
|
+
samples: AlignmentAnchor[]
|
|
66
|
+
windows: Map<number, number>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Anchors: Fireflies sentence-initial trigrams that are unique in BOTH transcripts.
|
|
71
|
+
*
|
|
72
|
+
* Unique on both sides for the same reason pairing needs it — a phrase that recurs cannot
|
|
73
|
+
* say which moment this is — and sentence-initial so the Fireflies time is exact.
|
|
74
|
+
*/
|
|
75
|
+
export function sentenceStartAnchors(
|
|
76
|
+
g2Words: readonly TimedWord[],
|
|
77
|
+
sentences: readonly FirefliesSentenceInput[],
|
|
78
|
+
coarseOffsetMs: number,
|
|
79
|
+
bandS: number = ALIGN_BAND_S,
|
|
80
|
+
): AlignmentAnchor[] {
|
|
81
|
+
const g2Trigrams = uniqueTrigrams(g2Words)
|
|
82
|
+
const firefliesTrigrams = uniqueTrigrams(firefliesTimedWords(sentences))
|
|
83
|
+
const anchors: AlignmentAnchor[] = []
|
|
84
|
+
for (const sentence of sentences) {
|
|
85
|
+
const tokens = normalizeWords(sentence.text)
|
|
86
|
+
if (tokens.length < 3) continue
|
|
87
|
+
const key = trigramKey(tokens[0], tokens[1], tokens[2])
|
|
88
|
+
if (!firefliesTrigrams.has(key)) continue
|
|
89
|
+
const g2Ms = g2Trigrams.get(key)
|
|
90
|
+
if (g2Ms === undefined) continue
|
|
91
|
+
const firefliesMs = (Number(sentence.start_time ?? 0) || 0) * 1000
|
|
92
|
+
const offsetMs = firefliesMs - g2Ms
|
|
93
|
+
if (Math.abs(offsetMs - coarseOffsetMs) <= bandS * 1000) anchors.push({ g2Ms, offsetMs })
|
|
94
|
+
}
|
|
95
|
+
return anchors
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Per-window offsets over G2 time.
|
|
100
|
+
*
|
|
101
|
+
* A sparse window inherits from the nearest window that has enough anchors, earlier one
|
|
102
|
+
* first on a tie. Inheriting a neighbour keeps a quiet stretch on its neighbours' drift
|
|
103
|
+
* instead of snapping it back to the whole-meeting median, which is the larger error by
|
|
104
|
+
* the end of a long call.
|
|
105
|
+
*/
|
|
106
|
+
export function windowOffsets(
|
|
107
|
+
anchors: readonly AlignmentAnchor[],
|
|
108
|
+
windowMs: number = ALIGN_WINDOW_MS,
|
|
109
|
+
minAnchors: number = ALIGN_WINDOW_MIN_ANCHORS,
|
|
110
|
+
): Map<number, number> {
|
|
111
|
+
const byWindow = new Map<number, number[]>()
|
|
112
|
+
for (const anchor of anchors) {
|
|
113
|
+
const index = Math.floor(anchor.g2Ms / windowMs)
|
|
114
|
+
const bucket = byWindow.get(index)
|
|
115
|
+
if (bucket) bucket.push(anchor.offsetMs)
|
|
116
|
+
else byWindow.set(index, [anchor.offsetMs])
|
|
117
|
+
}
|
|
118
|
+
const resolved = new Map<number, number>()
|
|
119
|
+
for (const [index, offsets] of byWindow) {
|
|
120
|
+
if (offsets.length >= minAnchors) resolved.set(index, median(offsets))
|
|
121
|
+
}
|
|
122
|
+
if (resolved.size === 0) return resolved
|
|
123
|
+
for (const index of byWindow.keys()) {
|
|
124
|
+
if (resolved.has(index)) continue
|
|
125
|
+
let best: number | null = null
|
|
126
|
+
let bestDistance = Infinity
|
|
127
|
+
for (const candidate of resolved.keys()) {
|
|
128
|
+
const distance = Math.abs(candidate - index)
|
|
129
|
+
if (distance < bestDistance || (distance === bestDistance && best !== null && candidate < best)) {
|
|
130
|
+
best = candidate
|
|
131
|
+
bestDistance = distance
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (best !== null) resolved.set(index, resolved.get(best)!)
|
|
135
|
+
}
|
|
136
|
+
return resolved
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Measure the alignment of one capture against one transcript. */
|
|
140
|
+
export function alignRecording(
|
|
141
|
+
g2Words: readonly TimedWord[],
|
|
142
|
+
sentences: readonly FirefliesSentenceInput[],
|
|
143
|
+
coarseOffsetMs: number,
|
|
144
|
+
): Alignment {
|
|
145
|
+
const samples = sentenceStartAnchors(g2Words, sentences, coarseOffsetMs)
|
|
146
|
+
if (samples.length < ALIGN_MIN_ANCHORS) {
|
|
147
|
+
return { aligned: false, anchors: samples.length, madMs: null, offsetMs: null, samples, windows: new Map() }
|
|
148
|
+
}
|
|
149
|
+
const offsets = samples.map(s => s.offsetMs)
|
|
150
|
+
const offsetMs = median(offsets)
|
|
151
|
+
const madMs = medianAbsoluteDeviation(offsets)
|
|
152
|
+
return {
|
|
153
|
+
aligned: madMs <= ALIGN_MAX_MAD_MS,
|
|
154
|
+
anchors: samples.length,
|
|
155
|
+
madMs,
|
|
156
|
+
offsetMs,
|
|
157
|
+
samples,
|
|
158
|
+
windows: windowOffsets(samples),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The offset to use for a moment in G2 time. */
|
|
163
|
+
export function offsetAt(alignment: Alignment, g2Ms: number, windowMs: number = ALIGN_WINDOW_MS): number {
|
|
164
|
+
const fallback = alignment.offsetMs ?? 0
|
|
165
|
+
if (alignment.windows.size === 0) return fallback
|
|
166
|
+
return alignment.windows.get(Math.floor(g2Ms / windowMs)) ?? fallback
|
|
167
|
+
}
|