@gotcos/glasses-server 6.21.14 → 6.21.18
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 +167 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- package/server/lib/embedding-eviction.ts +133 -0
- package/server/lib/meeting-audio-archive.ts +314 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +279 -0
- package/server/lib/meeting-speaker-review.ts +223 -6
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/training-audio-provenance.ts +70 -0
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +19 -1
- package/server/routes/meeting.ts +508 -2
- package/server/routes/transcribe-stream.ts +59 -0
- package/server/routes/voice.ts +91 -2
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Meeting audio kept long enough for a human to review who was speaking.
|
|
2
|
+
//
|
|
3
|
+
// WHY IT DID NOT EXIST. Chunk WAVs lived in `session-audio` only until a meeting
|
|
4
|
+
// was saved, then moved to `pending-batch` for HQ re-transcription and deleted
|
|
5
|
+
// when that finished. Measured 2026-08-06: `session-audio` held 0 files. So the
|
|
6
|
+
// review panel could show a phrase but never let anyone HEAR it, and its own
|
|
7
|
+
// copy said "naming this needs its audio, which is no longer held."
|
|
8
|
+
//
|
|
9
|
+
// Miles's decision: keep a week, so review can happen on a weekend rather than
|
|
10
|
+
// only within hours of the meeting, and stay under 8 GB.
|
|
11
|
+
//
|
|
12
|
+
// SIZING IS MEASURED, NOT GUESSED. Real recording volume over the 14 days to
|
|
13
|
+
// 2026-08-06 was 3.1 h/day mean, 6.9 h peak. A 7-day window is ~22 hours, which
|
|
14
|
+
// at 16 kHz mono 16-bit (32 KB/s) is ~2.5 GB — comfortably inside 8 GB. So the
|
|
15
|
+
// audio is kept UNCOMPRESSED and stays usable for re-transcription, not just
|
|
16
|
+
// playback. The cap is a runaway backstop: it would take a sustained 10 h/day
|
|
17
|
+
// week to reach it.
|
|
18
|
+
//
|
|
19
|
+
// HARD LINKS, NOT COPIES. Archiving happens at the moment audio moves to
|
|
20
|
+
// `pending-batch`, and links the same inodes rather than duplicating them. A
|
|
21
|
+
// copy would double disk for the whole batch window — 260 MB for a two-hour
|
|
22
|
+
// meeting — and introduce an ordering hazard against the batch purge. With links
|
|
23
|
+
// the pipeline can delete its directory whenever it likes and the bytes survive.
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
copyFileSync, existsSync, linkSync, mkdirSync, readdirSync, rmSync, statSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { dataPath } from './data-dir.js'
|
|
30
|
+
|
|
31
|
+
export const MEETING_AUDIO_DIR = 'meeting-audio'
|
|
32
|
+
|
|
33
|
+
/** One week, per Miles: review should survive until a weekend. */
|
|
34
|
+
export function meetingAudioTtlMs(): number {
|
|
35
|
+
const raw = Number(process.env.COS_MEETING_AUDIO_RETENTION_DAYS)
|
|
36
|
+
const days = Number.isFinite(raw) && raw > 0 ? raw : 7
|
|
37
|
+
return days * 24 * 60 * 60 * 1000
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Total budget for retained meeting audio. Miles: stay under 8 GB. */
|
|
41
|
+
export function meetingAudioMaxBytes(): number {
|
|
42
|
+
const raw = Number(process.env.COS_MEETING_AUDIO_MAX_BYTES)
|
|
43
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 8 * 1024 * 1024 * 1024
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Master switch. Default ON — with it off, review has no audio at all. */
|
|
47
|
+
export function meetingAudioEnabled(): boolean {
|
|
48
|
+
return process.env.COS_MEETING_AUDIO !== '0'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sessionDir(sessionId: string): string | null {
|
|
52
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
53
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
54
|
+
const path = join(root, sessionId.replace(/:/g, '_'))
|
|
55
|
+
return resolve(path).startsWith(resolve(root) + '/') ? path : null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Chunk WAVs as written by the capture path (`chunk_0000.wav`). */
|
|
59
|
+
function isChunkWav(name: string): boolean {
|
|
60
|
+
return /^chunk_\d+\.wav$/.test(name)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ArchiveResult {
|
|
64
|
+
linked: number
|
|
65
|
+
/** Files that had to be copied because the link failed (e.g. cross-device). */
|
|
66
|
+
copied: number
|
|
67
|
+
failed: number
|
|
68
|
+
bytes: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Bring one session's chunk WAVs into the archive.
|
|
73
|
+
*
|
|
74
|
+
* Never throws: this runs on the save path, and losing review audio must never
|
|
75
|
+
* cost a meeting. Individual failures are counted rather than aborting the rest,
|
|
76
|
+
* because a partially-archived meeting is still partially reviewable.
|
|
77
|
+
*/
|
|
78
|
+
export function archiveSessionAudio(sessionId: string, sourceDir: string): ArchiveResult {
|
|
79
|
+
const out: ArchiveResult = { linked: 0, copied: 0, failed: 0, bytes: 0 }
|
|
80
|
+
if (!meetingAudioEnabled()) return out
|
|
81
|
+
const dest = sessionDir(sessionId)
|
|
82
|
+
if (!dest || !existsSync(sourceDir)) return out
|
|
83
|
+
let names: string[]
|
|
84
|
+
try {
|
|
85
|
+
names = readdirSync(sourceDir).filter(isChunkWav)
|
|
86
|
+
} catch {
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
if (names.length === 0) return out
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 })
|
|
92
|
+
} catch {
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const from = resolve(sourceDir, name)
|
|
97
|
+
const to = resolve(dest, name)
|
|
98
|
+
if (existsSync(to)) continue
|
|
99
|
+
try {
|
|
100
|
+
linkSync(from, to)
|
|
101
|
+
out.linked++
|
|
102
|
+
} catch {
|
|
103
|
+
// A link can fail across filesystems or if the source vanished mid-sweep.
|
|
104
|
+
// Copy rather than skip: the point is that the audio survives.
|
|
105
|
+
try { copyFileSync(from, to); out.copied++ } catch { out.failed++; continue }
|
|
106
|
+
}
|
|
107
|
+
try { out.bytes += statSync(to).size } catch { /* counted as linked regardless */ }
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Bytes and age for one archived session. */
|
|
113
|
+
function sessionSize(dir: string): { bytes: number; mtimeMs: number; files: number } {
|
|
114
|
+
let bytes = 0, mtimeMs = 0, files = 0
|
|
115
|
+
try {
|
|
116
|
+
for (const name of readdirSync(dir).filter(isChunkWav)) {
|
|
117
|
+
try {
|
|
118
|
+
const st = statSync(join(dir, name))
|
|
119
|
+
bytes += st.size
|
|
120
|
+
files++
|
|
121
|
+
mtimeMs = Math.max(mtimeMs, st.mtimeMs)
|
|
122
|
+
} catch { /* skip unreadable */ }
|
|
123
|
+
}
|
|
124
|
+
} catch { /* unreadable dir reports zero */ }
|
|
125
|
+
return { bytes, mtimeMs, files }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface SweepResult {
|
|
129
|
+
removed: string[]
|
|
130
|
+
retained: string[]
|
|
131
|
+
bytesFreed: number
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Drop sessions past the retention window.
|
|
136
|
+
*
|
|
137
|
+
* A session whose age cannot be read is RETAINED — treating an unreadable stat
|
|
138
|
+
* as ancient would delete the audio a pending review depends on.
|
|
139
|
+
*/
|
|
140
|
+
export function sweepMeetingAudio(nowMs: number, ttlMs = meetingAudioTtlMs()): SweepResult {
|
|
141
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
142
|
+
const out: SweepResult = { removed: [], retained: [], bytesFreed: 0 }
|
|
143
|
+
if (!existsSync(root)) return out
|
|
144
|
+
let names: string[]
|
|
145
|
+
try { names = readdirSync(root) } catch { return out }
|
|
146
|
+
for (const name of names) {
|
|
147
|
+
const dir = join(root, name)
|
|
148
|
+
const { bytes, mtimeMs } = sessionSize(dir)
|
|
149
|
+
if (mtimeMs <= 0) { out.retained.push(name); continue }
|
|
150
|
+
if (nowMs - mtimeMs > ttlMs) {
|
|
151
|
+
try { rmSync(dir, { recursive: true, force: true }); out.removed.push(name); out.bytesFreed += bytes }
|
|
152
|
+
catch { out.retained.push(name) }
|
|
153
|
+
} else {
|
|
154
|
+
out.retained.push(name)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export interface CapResult {
|
|
161
|
+
evicted: string[]
|
|
162
|
+
bytesBefore: number
|
|
163
|
+
bytesAfter: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Evict OLDEST SESSIONS FIRST until the archive fits its budget.
|
|
168
|
+
*
|
|
169
|
+
* Whole sessions, not individual chunks: half a meeting's audio is a confusing
|
|
170
|
+
* artefact, and predictable eviction beats squeezing in a few more megabytes.
|
|
171
|
+
*
|
|
172
|
+
* Note on accounting: while `pending-batch` still holds the same inodes, these
|
|
173
|
+
* hard-linked files are counted at full size in BOTH places, so the total reads
|
|
174
|
+
* high during the batch window. That is deliberately conservative — it can sweep
|
|
175
|
+
* slightly early, never late.
|
|
176
|
+
*/
|
|
177
|
+
export function enforceMeetingAudioCap(maxBytes = meetingAudioMaxBytes()): CapResult {
|
|
178
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
179
|
+
const out: CapResult = { evicted: [], bytesBefore: 0, bytesAfter: 0 }
|
|
180
|
+
if (!existsSync(root)) return out
|
|
181
|
+
let names: string[]
|
|
182
|
+
try { names = readdirSync(root) } catch { return out }
|
|
183
|
+
|
|
184
|
+
const entries = names.map(name => ({ name, ...sessionSize(join(root, name)) }))
|
|
185
|
+
out.bytesBefore = entries.reduce((n, e) => n + e.bytes, 0)
|
|
186
|
+
out.bytesAfter = out.bytesBefore
|
|
187
|
+
if (out.bytesAfter <= maxBytes) return out
|
|
188
|
+
|
|
189
|
+
// Oldest first. A session with an unreadable mtime sorts LAST so it is evicted
|
|
190
|
+
// only as a final resort, matching the sweeper's bias toward keeping evidence.
|
|
191
|
+
entries.sort((a, b) => (a.mtimeMs || Number.MAX_SAFE_INTEGER) - (b.mtimeMs || Number.MAX_SAFE_INTEGER))
|
|
192
|
+
for (const e of entries) {
|
|
193
|
+
if (out.bytesAfter <= maxBytes) break
|
|
194
|
+
try {
|
|
195
|
+
rmSync(join(root, e.name), { recursive: true, force: true })
|
|
196
|
+
out.evicted.push(e.name)
|
|
197
|
+
out.bytesAfter -= e.bytes
|
|
198
|
+
} catch { /* leave it counted; the next pass will try again */ }
|
|
199
|
+
}
|
|
200
|
+
return out
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Absolute path to one chunk's WAV, or null when it is not retained. */
|
|
204
|
+
export function meetingAudioChunkPath(sessionId: string, chunkIndex: number): string | null {
|
|
205
|
+
const dir = sessionDir(sessionId)
|
|
206
|
+
if (!dir) return null
|
|
207
|
+
// No integer guard: `chunkIndex` is a number, so the interpolation can never
|
|
208
|
+
// contain a path separator, and a nonsense value simply names a file that does
|
|
209
|
+
// not exist. Mutation confirmed an explicit check here is unreachable behind
|
|
210
|
+
// the existence test below.
|
|
211
|
+
const path = resolve(dir, `chunk_${String(chunkIndex).padStart(4, '0')}.wav`)
|
|
212
|
+
if (!resolve(path).startsWith(resolve(dir) + '/')) return null
|
|
213
|
+
return existsSync(path) ? path : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Chunk indices retained for a session, ascending. */
|
|
217
|
+
export function listMeetingAudioChunks(sessionId: string): number[] {
|
|
218
|
+
const dir = sessionDir(sessionId)
|
|
219
|
+
if (!dir || !existsSync(dir)) return []
|
|
220
|
+
try {
|
|
221
|
+
return readdirSync(dir)
|
|
222
|
+
.filter(isChunkWav)
|
|
223
|
+
.map(n => Number(n.slice('chunk_'.length, -'.wav'.length)))
|
|
224
|
+
.filter(n => Number.isInteger(n))
|
|
225
|
+
.sort((a, b) => a - b)
|
|
226
|
+
} catch {
|
|
227
|
+
return []
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Cached stats for /api/health.
|
|
233
|
+
*
|
|
234
|
+
* The uncached walk statSyncs EVERY retained chunk: a 7-day window at the
|
|
235
|
+
* measured rate is roughly 2,000-3,000 files, and COS Control polls health every
|
|
236
|
+
* 12 seconds — thousands of synchronous stats on the same event loop that ingests
|
|
237
|
+
* live audio. The sibling chunkEmbeddingStoreStats is one stat per session for
|
|
238
|
+
* the same reason. A 30s cache keeps the number useful without the cost.
|
|
239
|
+
*/
|
|
240
|
+
let statsCache: { at: number; value: ReturnType<typeof computeMeetingAudioStats> } | null = null
|
|
241
|
+
const STATS_TTL_MS = 30_000
|
|
242
|
+
|
|
243
|
+
export function meetingAudioStats(): ReturnType<typeof computeMeetingAudioStats> {
|
|
244
|
+
const now = Date.now()
|
|
245
|
+
if (statsCache && now - statsCache.at < STATS_TTL_MS) return statsCache.value
|
|
246
|
+
const value = computeMeetingAudioStats()
|
|
247
|
+
statsCache = { at: now, value }
|
|
248
|
+
return value
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Invalidate after a sweep or eviction so health does not report stale usage. */
|
|
252
|
+
export function invalidateMeetingAudioStats(): void {
|
|
253
|
+
statsCache = null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Counts for /api/health, so retention can be seen rather than assumed. */
|
|
257
|
+
function computeMeetingAudioStats(): {
|
|
258
|
+
enabled: boolean
|
|
259
|
+
sessions: number
|
|
260
|
+
bytes: number
|
|
261
|
+
maxBytes: number
|
|
262
|
+
retentionDays: number
|
|
263
|
+
oldestAgeHours: number | null
|
|
264
|
+
} {
|
|
265
|
+
const root = dataPath(MEETING_AUDIO_DIR)
|
|
266
|
+
const retentionDays = Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
|
|
267
|
+
const base = {
|
|
268
|
+
enabled: meetingAudioEnabled(),
|
|
269
|
+
maxBytes: meetingAudioMaxBytes(),
|
|
270
|
+
retentionDays,
|
|
271
|
+
}
|
|
272
|
+
if (!existsSync(root)) return { ...base, sessions: 0, bytes: 0, oldestAgeHours: null }
|
|
273
|
+
let sessions = 0, bytes = 0, oldest = Number.POSITIVE_INFINITY
|
|
274
|
+
try {
|
|
275
|
+
for (const name of readdirSync(root)) {
|
|
276
|
+
const { bytes: b, mtimeMs, files } = sessionSize(join(root, name))
|
|
277
|
+
if (files === 0) continue
|
|
278
|
+
sessions++
|
|
279
|
+
bytes += b
|
|
280
|
+
if (mtimeMs > 0) oldest = Math.min(oldest, mtimeMs)
|
|
281
|
+
}
|
|
282
|
+
} catch { /* report what we have */ }
|
|
283
|
+
return {
|
|
284
|
+
...base,
|
|
285
|
+
sessions,
|
|
286
|
+
bytes,
|
|
287
|
+
oldestAgeHours: Number.isFinite(oldest) ? Math.round(((Date.now() - oldest) / 3_600_000) * 10) / 10 : null,
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* One retention pass: expire first, then enforce the budget.
|
|
293
|
+
*
|
|
294
|
+
* ORDER IS LOAD-BEARING and that is why this is a named function rather than two
|
|
295
|
+
* calls inline in an interval. Enforcing the cap first would let it EVICT audio
|
|
296
|
+
* that the sweeper was about to expire anyway — counting those bytes against the
|
|
297
|
+
* budget and so evicting extra sessions that were still inside their window. Run
|
|
298
|
+
* the other way round, the cap only ever sees audio a human could still want.
|
|
299
|
+
*/
|
|
300
|
+
/** Retention window in days — a pure config read, no filesystem. */
|
|
301
|
+
export function meetingAudioRetentionDays(): number {
|
|
302
|
+
return Math.round((meetingAudioTtlMs() / (24 * 60 * 60 * 1000)) * 10) / 10
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function runMeetingAudioRetention(nowMs = Date.now()): {
|
|
306
|
+
swept: SweepResult
|
|
307
|
+
capped: CapResult
|
|
308
|
+
} {
|
|
309
|
+
const swept = sweepMeetingAudio(nowMs)
|
|
310
|
+
const capped = enforceMeetingAudioCap()
|
|
311
|
+
// Usage just changed; do not let health report the pre-sweep figure.
|
|
312
|
+
if (swept.removed.length > 0 || capped.evicted.length > 0) invalidateMeetingAudioStats()
|
|
313
|
+
return { swept, capped }
|
|
314
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The record of every speaker correction a human has made, per meeting.
|
|
2
|
+
//
|
|
3
|
+
// WHY A LEDGER AND NOT JUST A REWRITE. A relabel mutates files in place: the
|
|
4
|
+
// chunk sidecar, the attendee list, the transcript turn labels. If that is all
|
|
5
|
+
// that happens, three things become impossible:
|
|
6
|
+
//
|
|
7
|
+
// 1. UNDO. Once "Luke H" has become "Luke Henry" in the sidecar, nothing
|
|
8
|
+
// remembers it was ever anything else. A mistaken correction is permanent.
|
|
9
|
+
// 2. CRASH RECOVERY. A rewrite touches several files. Die between them and the
|
|
10
|
+
// meeting is half-corrected with no trace of what was intended.
|
|
11
|
+
// 3. TRAINING. Piece 3 turns a correction into an enrollment. It needs to know
|
|
12
|
+
// WHICH chunks a human vouched for, and to be able to retract that vouching
|
|
13
|
+
// later if the correction is undone.
|
|
14
|
+
//
|
|
15
|
+
// So the ledger is written FIRST, as intent, and the rewrite follows. A row with
|
|
16
|
+
// an intent and no outcome is a correction that did not finish — visible rather
|
|
17
|
+
// than silent.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
|
|
20
|
+
// Decisions and Action Items are LLM prose that refers to people by BARE FIRST
|
|
21
|
+
// NAME ("Jeremy pushed back", "Chris raised Beamer sentiment"). Verified on a
|
|
22
|
+
// real scribe: 6 of 12 speakers appear that way. This org has two Kyles, two
|
|
23
|
+
// Jacobuses and two Chrises, so a find/replace on a first name in narrative text
|
|
24
|
+
// would silently rewrite a sentence about a different person. Prose is left
|
|
25
|
+
// alone and flagged stale instead — `proseStale` on the applied row.
|
|
26
|
+
|
|
27
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { dataPath } from './data-dir.js'
|
|
30
|
+
|
|
31
|
+
export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A correction is recorded in two phases sharing one id.
|
|
35
|
+
* `intent` goes down before any file is touched; `applied` or `failed` closes it.
|
|
36
|
+
* An unclosed intent is an incomplete correction, not a successful one.
|
|
37
|
+
*/
|
|
38
|
+
export type CorrectionPhase = 'intent' | 'applied' | 'failed'
|
|
39
|
+
|
|
40
|
+
export interface CorrectionSurfaces {
|
|
41
|
+
/** Chunks whose `speaker` changed in the sidecar. */
|
|
42
|
+
sidecar: number
|
|
43
|
+
/** Lines changed in the markdown attendee list. */
|
|
44
|
+
attendees: number
|
|
45
|
+
/** `[Name]:` turn labels changed in the markdown transcript. */
|
|
46
|
+
transcript: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CorrectionRow {
|
|
50
|
+
id: string
|
|
51
|
+
phase: CorrectionPhase
|
|
52
|
+
/** ISO timestamp, supplied by the caller so this module stays deterministic. */
|
|
53
|
+
at: string
|
|
54
|
+
from: string
|
|
55
|
+
to: string
|
|
56
|
+
/**
|
|
57
|
+
* The exact chunk indices this correction covers. Explicit rather than derived,
|
|
58
|
+
* because piece 3 enrolls precisely these and must be able to retract
|
|
59
|
+
* precisely these. An empty array means "every chunk carrying `from`", which
|
|
60
|
+
* is resolved at apply time and written back onto the applied row.
|
|
61
|
+
*/
|
|
62
|
+
chunks: number[]
|
|
63
|
+
/**
|
|
64
|
+
* 'meeting' is the only scope. Corrections are per-meeting BY DESIGN: the
|
|
65
|
+
* identifier mishearing one voice in one room does not mean every past chunk
|
|
66
|
+
* was wrong, and rewriting history on a single correction is how a small
|
|
67
|
+
* mistake becomes an unrecoverable one.
|
|
68
|
+
*/
|
|
69
|
+
scope: 'meeting'
|
|
70
|
+
surfaces?: CorrectionSurfaces
|
|
71
|
+
/** True when narrative prose still carries the old label. See the header. */
|
|
72
|
+
proseStale?: boolean
|
|
73
|
+
/** Why an attempt failed, on a `failed` row. */
|
|
74
|
+
error?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sessionFile(sessionId: string): string | null {
|
|
78
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
79
|
+
const dir = dataPath(CORRECTIONS_DIR)
|
|
80
|
+
const path = join(dir, `${sessionId.replace(/:/g, '_')}.jsonl`)
|
|
81
|
+
return resolve(path).startsWith(resolve(dir) + '/') ? path : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Append one row. Returns false rather than throwing on a bad session id or an
|
|
86
|
+
* unwritable directory — but note the caller's contract: if the INTENT row
|
|
87
|
+
* cannot be written, the rewrite must not proceed. An unrecorded mutation is
|
|
88
|
+
* exactly what this file exists to prevent.
|
|
89
|
+
*/
|
|
90
|
+
export function appendCorrection(sessionId: string, row: CorrectionRow): boolean {
|
|
91
|
+
const path = sessionFile(sessionId)
|
|
92
|
+
if (!path) return false
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dataPath(CORRECTIONS_DIR), { recursive: true, mode: 0o700 })
|
|
95
|
+
appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
|
|
96
|
+
return true
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CorrectionReadResult {
|
|
103
|
+
rows: CorrectionRow[]
|
|
104
|
+
/** Lines that could not be parsed. Surfaced, because a correction history read
|
|
105
|
+
* partially is a correction history that lies about what a human decided. */
|
|
106
|
+
unusable: number
|
|
107
|
+
missing: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readCorrections(sessionId: string): CorrectionReadResult {
|
|
111
|
+
const path = sessionFile(sessionId)
|
|
112
|
+
if (!path || !existsSync(path)) return { rows: [], unusable: 0, missing: true }
|
|
113
|
+
let raw: string
|
|
114
|
+
try {
|
|
115
|
+
raw = readFileSync(path, 'utf-8')
|
|
116
|
+
} catch {
|
|
117
|
+
return { rows: [], unusable: 0, missing: true }
|
|
118
|
+
}
|
|
119
|
+
const rows: CorrectionRow[] = []
|
|
120
|
+
let unusable = 0
|
|
121
|
+
for (const line of raw.split('\n')) {
|
|
122
|
+
if (line.trim() === '') continue
|
|
123
|
+
try {
|
|
124
|
+
const o = JSON.parse(line) as Record<string, unknown>
|
|
125
|
+
if (typeof o.id !== 'string' || typeof o.from !== 'string' || typeof o.to !== 'string') { unusable++; continue }
|
|
126
|
+
if (o.phase !== 'intent' && o.phase !== 'applied' && o.phase !== 'failed') { unusable++; continue }
|
|
127
|
+
rows.push({
|
|
128
|
+
id: o.id,
|
|
129
|
+
phase: o.phase,
|
|
130
|
+
at: typeof o.at === 'string' ? o.at : '',
|
|
131
|
+
from: o.from,
|
|
132
|
+
to: o.to,
|
|
133
|
+
chunks: Array.isArray(o.chunks) ? o.chunks.filter((n): n is number => typeof n === 'number') : [],
|
|
134
|
+
scope: 'meeting',
|
|
135
|
+
surfaces: isSurfaces(o.surfaces) ? o.surfaces : undefined,
|
|
136
|
+
proseStale: typeof o.proseStale === 'boolean' ? o.proseStale : undefined,
|
|
137
|
+
error: typeof o.error === 'string' ? o.error : undefined,
|
|
138
|
+
})
|
|
139
|
+
} catch {
|
|
140
|
+
unusable++
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { rows, unusable, missing: false }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isSurfaces(v: unknown): v is CorrectionSurfaces {
|
|
147
|
+
if (!v || typeof v !== 'object') return false
|
|
148
|
+
const o = v as Record<string, unknown>
|
|
149
|
+
return typeof o.sidecar === 'number' && typeof o.attendees === 'number' && typeof o.transcript === 'number'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Corrections that recorded an intent and never closed it.
|
|
154
|
+
*
|
|
155
|
+
* This is the crash signal. A process that died mid-rewrite leaves exactly this,
|
|
156
|
+
* and a meeting with a pending correction should be treated as possibly
|
|
157
|
+
* half-written rather than clean.
|
|
158
|
+
*/
|
|
159
|
+
export function pendingCorrections(sessionId: string): CorrectionRow[] {
|
|
160
|
+
const { rows } = readCorrections(sessionId)
|
|
161
|
+
const closed = new Set(rows.filter(r => r.phase !== 'intent').map(r => r.id))
|
|
162
|
+
return rows.filter(r => r.phase === 'intent' && !closed.has(r.id))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Only the corrections that actually landed — the ones piece 3 may train on. */
|
|
166
|
+
export function appliedCorrections(sessionId: string): CorrectionRow[] {
|
|
167
|
+
return readCorrections(sessionId).rows.filter(r => r.phase === 'applied')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Follow a chain of applied corrections to the label a voice now carries.
|
|
172
|
+
*
|
|
173
|
+
* Chains happen: a voice labelled 'Ext' is corrected to 'Luke H', then later to
|
|
174
|
+
* 'Luke Henry'. Asking "what is Ext now?" must answer 'Luke Henry', not stop at
|
|
175
|
+
* the first hop. Cycles are possible if a human corrects A→B then B→A, so the
|
|
176
|
+
* walk is bounded by the number of corrections and returns the last label
|
|
177
|
+
* reached rather than looping.
|
|
178
|
+
*/
|
|
179
|
+
export function currentLabelFor(sessionId: string, originalLabel: string): string {
|
|
180
|
+
const applied = appliedCorrections(sessionId)
|
|
181
|
+
let label = originalLabel
|
|
182
|
+
const seen = new Set<string>([label])
|
|
183
|
+
for (let hop = 0; hop < applied.length; hop++) {
|
|
184
|
+
// Latest applied correction FROM the current label wins: a later human
|
|
185
|
+
// decision supersedes an earlier one.
|
|
186
|
+
const next = [...applied].reverse().find(r => r.from === label)
|
|
187
|
+
if (!next || seen.has(next.to)) break
|
|
188
|
+
label = next.to
|
|
189
|
+
seen.add(label)
|
|
190
|
+
}
|
|
191
|
+
return label
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Counts for /api/health — pending is the number that matters. */
|
|
195
|
+
export function correctionStoreStats(): {
|
|
196
|
+
sessions: number
|
|
197
|
+
applied: number
|
|
198
|
+
pending: number
|
|
199
|
+
failed: number
|
|
200
|
+
} {
|
|
201
|
+
const dir = dataPath(CORRECTIONS_DIR)
|
|
202
|
+
if (!existsSync(dir)) return { sessions: 0, applied: 0, pending: 0, failed: 0 }
|
|
203
|
+
let sessions = 0, applied = 0, pending = 0, failed = 0
|
|
204
|
+
try {
|
|
205
|
+
for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
|
|
206
|
+
try {
|
|
207
|
+
statSync(join(dir, name))
|
|
208
|
+
sessions++
|
|
209
|
+
const id = name.replace(/\.jsonl$/, '')
|
|
210
|
+
const { rows } = readCorrections(id)
|
|
211
|
+
applied += rows.filter(r => r.phase === 'applied').length
|
|
212
|
+
failed += rows.filter(r => r.phase === 'failed').length
|
|
213
|
+
pending += pendingCorrections(id).length
|
|
214
|
+
} catch { /* skip unreadable */ }
|
|
215
|
+
}
|
|
216
|
+
} catch { /* report what we have */ }
|
|
217
|
+
return { sessions, applied, pending, failed }
|
|
218
|
+
}
|