@gotcos/glasses-server 6.18.8 → 6.20.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/.cos-profile.example.json +3 -3
- package/.env.example +18 -0
- package/CHANGELOG.md +78 -0
- package/README.md +23 -0
- package/bin/cli.cjs +156 -4
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +16 -1
- package/server/lib/hallucination-filter.ts +5 -8
- package/server/lib/maintenance-lifecycle.ts +8 -0
- package/server/lib/meeting-batch-progress.ts +150 -3
- package/server/lib/meeting-batch-transcribe.ts +29 -2
- package/server/lib/profile.ts +81 -2
- package/server/lib/unsaved-audio-quarantine.ts +314 -0
- package/server/lib/whisper-local.ts +7 -14
- package/server/lib/whisper-preview.ts +243 -0
- package/server/routes/glossary.ts +2 -11
- package/server/routes/health.ts +34 -2
- package/server/routes/meeting.ts +281 -2
- package/server/routes/prompt-drafts.ts +2 -5
- package/server/routes/transcribe-stream.ts +65 -13
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs'
|
|
6
6
|
import { basename, join } from 'node:path'
|
|
7
7
|
import { dataPath } from './data-dir.js'
|
|
8
|
+
import { listActiveRecoveries } from './unsaved-audio-quarantine.js'
|
|
8
9
|
|
|
9
10
|
export const BATCH_PROGRESS_FILENAME = '_batch_progress.json'
|
|
10
11
|
export const BATCH_PENDING_MARKER = '_batch_pending.marker'
|
|
12
|
+
export const BATCH_TERMINAL_FILENAME = '_batch_terminal.json'
|
|
11
13
|
|
|
12
14
|
export type MeetingBatchPhase =
|
|
13
15
|
| 'queued'
|
|
@@ -44,6 +46,75 @@ export interface MeetingSyncSnapshot {
|
|
|
44
46
|
label: string
|
|
45
47
|
blocksRestart: boolean
|
|
46
48
|
meetings: MeetingSyncMeeting[]
|
|
49
|
+
/** Batches that reached a terminal outcome but whose WAVs are deliberately
|
|
50
|
+
* retained for retry (rejected quality, failed persist). Additive field —
|
|
51
|
+
* older consumers ignore it. Never counts toward active/blocksRestart. */
|
|
52
|
+
retained: MeetingSyncRetainedMeeting[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type MeetingBatchOutcome = 'accepted' | 'rejected' | 'failed'
|
|
56
|
+
|
|
57
|
+
export interface MeetingBatchTerminal {
|
|
58
|
+
schemaVersion: 1
|
|
59
|
+
meetingId: string
|
|
60
|
+
outcome: MeetingBatchOutcome
|
|
61
|
+
reason?: string
|
|
62
|
+
at: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface MeetingSyncRetainedMeeting {
|
|
66
|
+
meetingId: string
|
|
67
|
+
outcome: MeetingBatchOutcome
|
|
68
|
+
reason: string | null
|
|
69
|
+
chunkFiles: number
|
|
70
|
+
at: string
|
|
71
|
+
label: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Record the batch's terminal outcome next to its retained WAVs. Before this
|
|
75
|
+
* file existed (≤6.18.8), a rejected batch's dir kept rendering as active
|
|
76
|
+
* "HQ polish · N chunks" with blocksRestart:true for the full 12h retention —
|
|
77
|
+
* the status conflated "work running" with "evidence retained". */
|
|
78
|
+
export function writeMeetingBatchTerminal(
|
|
79
|
+
audioDir: string,
|
|
80
|
+
input: { outcome: MeetingBatchOutcome; reason?: string; meetingId?: string },
|
|
81
|
+
): void {
|
|
82
|
+
const payload: MeetingBatchTerminal = {
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
meetingId: input.meetingId ?? basename(audioDir),
|
|
85
|
+
outcome: input.outcome,
|
|
86
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
87
|
+
at: new Date().toISOString(),
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
writeFileSync(join(audioDir, BATCH_TERMINAL_FILENAME), `${JSON.stringify(payload)}\n`, {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
mode: 0o600,
|
|
93
|
+
})
|
|
94
|
+
} catch {
|
|
95
|
+
// Status only — never fail the pipeline for a status write.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A retry invalidates the previous terminal state. */
|
|
100
|
+
export function clearMeetingBatchTerminal(audioDir: string): void {
|
|
101
|
+
const path = join(audioDir, BATCH_TERMINAL_FILENAME)
|
|
102
|
+
try {
|
|
103
|
+
if (existsSync(path)) unlinkSync(path)
|
|
104
|
+
} catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function readTerminalFile(dir: string): MeetingBatchTerminal | null {
|
|
108
|
+
const path = join(dir, BATCH_TERMINAL_FILENAME)
|
|
109
|
+
if (!existsSync(path)) return null
|
|
110
|
+
try {
|
|
111
|
+
const raw = JSON.parse(readFileSync(path, 'utf8')) as MeetingBatchTerminal
|
|
112
|
+
if (raw?.schemaVersion !== 1) return null
|
|
113
|
+
if (raw.outcome !== 'accepted' && raw.outcome !== 'rejected' && raw.outcome !== 'failed') return null
|
|
114
|
+
return raw
|
|
115
|
+
} catch {
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
47
118
|
}
|
|
48
119
|
|
|
49
120
|
function pendingBatchRoot(): string {
|
|
@@ -111,6 +182,11 @@ export function clearMeetingBatchProgress(audioDir: string): void {
|
|
|
111
182
|
} catch { /* ignore */ }
|
|
112
183
|
}
|
|
113
184
|
|
|
185
|
+
/** Public read for surfaces outside this module (orphan recovery progress). */
|
|
186
|
+
export function readMeetingBatchProgress(dir: string): MeetingBatchProgress | null {
|
|
187
|
+
return readProgressFile(dir)
|
|
188
|
+
}
|
|
189
|
+
|
|
114
190
|
function readProgressFile(dir: string): MeetingBatchProgress | null {
|
|
115
191
|
const path = join(dir, BATCH_PROGRESS_FILENAME)
|
|
116
192
|
if (!existsSync(path)) return null
|
|
@@ -140,8 +216,9 @@ export function getMeetingSyncSnapshot(
|
|
|
140
216
|
root: string = pendingBatchRoot(),
|
|
141
217
|
): MeetingSyncSnapshot {
|
|
142
218
|
const meetings: MeetingSyncMeeting[] = []
|
|
219
|
+
const retained: MeetingSyncRetainedMeeting[] = []
|
|
143
220
|
if (!existsSync(root)) {
|
|
144
|
-
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
|
|
221
|
+
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
|
|
145
222
|
}
|
|
146
223
|
|
|
147
224
|
let dirs: string[] = []
|
|
@@ -154,7 +231,7 @@ export function getMeetingSyncSnapshot(
|
|
|
154
231
|
}
|
|
155
232
|
})
|
|
156
233
|
} catch {
|
|
157
|
-
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings }
|
|
234
|
+
return { active: false, percent: null, label: 'Idle', blocksRestart: false, meetings, retained }
|
|
158
235
|
}
|
|
159
236
|
|
|
160
237
|
for (const name of dirs) {
|
|
@@ -165,6 +242,47 @@ export function getMeetingSyncSnapshot(
|
|
|
165
242
|
chunkFiles = readdirSync(dir).filter(f => f.endsWith('.wav')).length
|
|
166
243
|
} catch { /* ignore */ }
|
|
167
244
|
|
|
245
|
+
// A terminal outcome ends the meeting's ACTIVE life. Its WAVs stay for
|
|
246
|
+
// retry, reported as retained — never as running work. The gate is
|
|
247
|
+
// progress==null ONLY: the pending marker is refreshed every segment and
|
|
248
|
+
// every 60s during the run, so it is always fresh the moment a terminal
|
|
249
|
+
// is written — gating on marker freshness left the phantom alive for the
|
|
250
|
+
// first 15 minutes, exactly the post-meeting Update Server window. A
|
|
251
|
+
// genuine retry clears the terminal first (runMeetingBatchPipeline) and
|
|
252
|
+
// immediately writes queued progress, so progress presence is the true
|
|
253
|
+
// live signal.
|
|
254
|
+
const terminal = readTerminalFile(dir)
|
|
255
|
+
if (terminal && progress == null) {
|
|
256
|
+
const reasonSuffix = terminal.reason ? `: ${terminal.reason}` : ''
|
|
257
|
+
retained.push({
|
|
258
|
+
meetingId: terminal.meetingId || name,
|
|
259
|
+
outcome: terminal.outcome,
|
|
260
|
+
reason: terminal.reason ?? null,
|
|
261
|
+
chunkFiles,
|
|
262
|
+
at: terminal.at,
|
|
263
|
+
label: `Retained (${terminal.outcome}${reasonSuffix}) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
|
|
264
|
+
})
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Backfill: a dir with WAVs, no progress, no fresh marker, and NO terminal
|
|
269
|
+
// file is a batch that ended before 6.19.0 existed (or whose terminal
|
|
270
|
+
// write failed). Pre-6.19.0 semantics rendered these as phantom active
|
|
271
|
+
// work with blocksRestart for the rest of the 12h retention — and the
|
|
272
|
+
// first boot after an upgrade is exactly when the user watches COS
|
|
273
|
+
// Control. Classify them as retained with an honest unknown outcome.
|
|
274
|
+
if (progress == null && !markerFresh(dir) && chunkFiles > 0) {
|
|
275
|
+
retained.push({
|
|
276
|
+
meetingId: name,
|
|
277
|
+
outcome: 'failed',
|
|
278
|
+
reason: 'pre-terminal batch (ended before 6.19.0 or terminal write lost)',
|
|
279
|
+
chunkFiles,
|
|
280
|
+
at: new Date(0).toISOString(),
|
|
281
|
+
label: `Retained (unknown outcome) · ${chunkFiles} chunk${chunkFiles === 1 ? '' : 's'}`,
|
|
282
|
+
})
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
168
286
|
const active = markerFresh(dir) || progress != null
|
|
169
287
|
if (!active && chunkFiles === 0) continue
|
|
170
288
|
|
|
@@ -198,8 +316,36 @@ export function getMeetingSyncSnapshot(
|
|
|
198
316
|
meetings.push({ ...row, label: labelFor(row) })
|
|
199
317
|
}
|
|
200
318
|
|
|
319
|
+
// Active orphan recoveries decode in the quarantine root, which this scan
|
|
320
|
+
// never visits — surface them as active rows or COS Control shows "Idle"
|
|
321
|
+
// with blocksRestart:false while a 20-90 minute decode holds the
|
|
322
|
+
// maintenance lease, and an Update Server drain walks blind into its 90s
|
|
323
|
+
// timeout and hard-fails to Repair. Same contract as meeting_batch_finalization.
|
|
324
|
+
for (const recovery of listActiveRecoveries()) {
|
|
325
|
+
const progress = readProgressFile(recovery.dirPath)
|
|
326
|
+
const percent = progress && progress.segmentsTotal > 0
|
|
327
|
+
? clampPercent(progress.segmentsDone, progress.segmentsTotal)
|
|
328
|
+
: null
|
|
329
|
+
const row: Omit<MeetingSyncMeeting, 'label'> = {
|
|
330
|
+
meetingId: recovery.sessionId,
|
|
331
|
+
phase: progress?.phase ?? 'queued',
|
|
332
|
+
percent,
|
|
333
|
+
segmentsDone: progress && progress.segmentsTotal > 0 ? progress.segmentsDone : null,
|
|
334
|
+
segmentsTotal: progress && progress.segmentsTotal > 0 ? progress.segmentsTotal : null,
|
|
335
|
+
chunkFiles: progress?.chunkFiles ?? 0,
|
|
336
|
+
updatedAt: progress?.updatedAt ?? null,
|
|
337
|
+
}
|
|
338
|
+
meetings.push({
|
|
339
|
+
...row,
|
|
340
|
+
label: `Recovering unsaved capture${percent != null ? ` ${percent}%` : ''} · do not update/restart`,
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
201
344
|
if (meetings.length === 0) {
|
|
202
|
-
|
|
345
|
+
const label = retained.length > 0
|
|
346
|
+
? `Idle · ${retained.length} retained batch${retained.length === 1 ? '' : 'es'}`
|
|
347
|
+
: 'Idle'
|
|
348
|
+
return { active: false, percent: null, label, blocksRestart: false, meetings, retained }
|
|
203
349
|
}
|
|
204
350
|
|
|
205
351
|
const withPercent = meetings.filter(m => m.percent != null)
|
|
@@ -217,5 +363,6 @@ export function getMeetingSyncSnapshot(
|
|
|
217
363
|
label,
|
|
218
364
|
blocksRestart: true,
|
|
219
365
|
meetings,
|
|
366
|
+
retained,
|
|
220
367
|
}
|
|
221
368
|
}
|
|
@@ -9,7 +9,9 @@ import { transcribeHighQuality, type WhisperWord } from './whisper-local.js'
|
|
|
9
9
|
import { isMetalBatchPreempted } from './whisper-metal-gate.js'
|
|
10
10
|
import {
|
|
11
11
|
clearMeetingBatchProgress,
|
|
12
|
+
clearMeetingBatchTerminal,
|
|
12
13
|
writeMeetingBatchProgress,
|
|
14
|
+
writeMeetingBatchTerminal,
|
|
13
15
|
} from './meeting-batch-progress.js'
|
|
14
16
|
import type { IndexedTranscriptChunk } from '../routes/transcribe-stream.js'
|
|
15
17
|
import {
|
|
@@ -165,7 +167,7 @@ function mapWordsToSpeakers(
|
|
|
165
167
|
})
|
|
166
168
|
}
|
|
167
169
|
|
|
168
|
-
async function transcribeSegments(
|
|
170
|
+
export async function transcribeSegments(
|
|
169
171
|
audioDir: string,
|
|
170
172
|
segments: BatchSegment[],
|
|
171
173
|
entries: IndexedTranscriptChunk[],
|
|
@@ -237,6 +239,16 @@ async function transcribeSegments(
|
|
|
237
239
|
|
|
238
240
|
let batchQueueTail: Promise<void> = Promise.resolve()
|
|
239
241
|
|
|
242
|
+
/** Chain arbitrary HQ-decoder work onto the same serialization tail the batch
|
|
243
|
+
* pipeline uses. Orphan recovery MUST go through this: transcribeSegments has
|
|
244
|
+
* no internal queue, so calling it directly would run a second (or third)
|
|
245
|
+
* 16-thread large-v3 decoder in parallel with a live post-meeting batch. */
|
|
246
|
+
export function enqueueSerializedHqWork<T>(work: () => Promise<T>): Promise<T> {
|
|
247
|
+
const job = batchQueueTail.then(work)
|
|
248
|
+
batchQueueTail = job.then(() => undefined, () => undefined)
|
|
249
|
+
return job
|
|
250
|
+
}
|
|
251
|
+
|
|
240
252
|
/** Serialize 16-thread HQ decoders across meetings on a public user's Mac. */
|
|
241
253
|
export function runMeetingBatchPipeline(
|
|
242
254
|
audioDir: string,
|
|
@@ -246,6 +258,8 @@ export function runMeetingBatchPipeline(
|
|
|
246
258
|
// Lease immediately, including time spent behind another HQ decoder. Without
|
|
247
259
|
// this, the two-hour cleanup could delete a queued meeting before it starts.
|
|
248
260
|
refreshPendingLease(audioDir)
|
|
261
|
+
// A retry invalidates any prior terminal outcome — live signals must win.
|
|
262
|
+
clearMeetingBatchTerminal(audioDir)
|
|
249
263
|
writeMeetingBatchProgress(audioDir, {
|
|
250
264
|
phase: 'queued',
|
|
251
265
|
segmentsDone: 0,
|
|
@@ -278,7 +292,12 @@ async function runMeetingBatchPipelineNow(
|
|
|
278
292
|
return { transcriptionQuality: 'streaming' }
|
|
279
293
|
}
|
|
280
294
|
const segments = segmentTranscriptChunks(entries)
|
|
281
|
-
if (segments.length === 0)
|
|
295
|
+
if (segments.length === 0) {
|
|
296
|
+
// Terminal too: WAVs exist but nothing is transcribable. Without this,
|
|
297
|
+
// the dir re-creates the exact phantom-active state W2 removes.
|
|
298
|
+
writeMeetingBatchTerminal(audioDir, { outcome: 'failed', reason: 'no_segments' })
|
|
299
|
+
return { transcriptionQuality: 'streaming' }
|
|
300
|
+
}
|
|
282
301
|
|
|
283
302
|
writeMeetingBatchProgress(audioDir, {
|
|
284
303
|
phase: 'hq_polish',
|
|
@@ -302,12 +321,20 @@ async function runMeetingBatchPipelineNow(
|
|
|
302
321
|
+ `${qualityReport.streamingWordCount} live words, `
|
|
303
322
|
+ `${(qualityReport.duplicateWordRatio * 100).toFixed(1)}% duplicate`,
|
|
304
323
|
)
|
|
324
|
+
// Terminal: the batch RAN and lost. WAVs stay for retry, but status must
|
|
325
|
+
// stop reporting active work (pre-6.19.0 this looked like 12h of
|
|
326
|
+
// "HQ polish · N chunks" with blocksRestart:true after the work ended).
|
|
327
|
+
writeMeetingBatchTerminal(audioDir, { outcome: 'rejected', reason: qualityReport.reason })
|
|
305
328
|
return { transcriptionQuality: 'streaming', qualityReport }
|
|
306
329
|
}
|
|
307
330
|
|
|
308
331
|
return { transcriptionQuality: 'batch', batchTranscript, batchSegments, qualityReport }
|
|
309
332
|
} catch (error) {
|
|
310
333
|
console.error(`[meeting-batch] Pipeline failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
334
|
+
writeMeetingBatchTerminal(audioDir, {
|
|
335
|
+
outcome: 'failed',
|
|
336
|
+
reason: error instanceof Error ? error.message.slice(0, 200) : String(error).slice(0, 200),
|
|
337
|
+
})
|
|
311
338
|
return { transcriptionQuality: 'streaming' }
|
|
312
339
|
}
|
|
313
340
|
}
|
package/server/lib/profile.ts
CHANGED
|
@@ -8,6 +8,10 @@ import { atomicWriteFileSync } from './atomic-fs.js'
|
|
|
8
8
|
|
|
9
9
|
const APP_ROOT = resolve(import.meta.dirname, '../..')
|
|
10
10
|
|
|
11
|
+
const PLACEHOLDER_OWNER_NAMES = new Set(['your name', 'user'])
|
|
12
|
+
const PLACEHOLDER_VOCABULARY = new Set(['nameone', 'nametwo', 'yourcompany', 'productname'])
|
|
13
|
+
const PLACEHOLDER_CORRECTIONS = new Set(['soundalike\u0000yourname'])
|
|
14
|
+
|
|
11
15
|
/** The profile in the data home. Survives updates; the APP_ROOT copy does not. */
|
|
12
16
|
export function homeProfilePath(): string {
|
|
13
17
|
return resolve(homedir(), '.cos-glasses', '.cos-profile.json')
|
|
@@ -81,7 +85,8 @@ export function loadProfileField(field: string, fallback: string): string {
|
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
export function getOwnerName(): string {
|
|
84
|
-
|
|
88
|
+
const value = loadProfileField('owner_name', 'User').trim()
|
|
89
|
+
return !value || PLACEHOLDER_OWNER_NAMES.has(value.toLowerCase()) ? 'User' : value
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/** Short speaker label for the glasses wearer, used by diarization to fast-path
|
|
@@ -92,7 +97,81 @@ export function getOwnerSpeakerLabel(): string {
|
|
|
92
97
|
|
|
93
98
|
export function getVocabulary(): string[] {
|
|
94
99
|
const profile = loadProfile()
|
|
95
|
-
|
|
100
|
+
if (!Array.isArray(profile.vocabulary)) return []
|
|
101
|
+
const seen = new Set<string>()
|
|
102
|
+
return (profile.vocabulary as unknown[]).flatMap(value => {
|
|
103
|
+
if (typeof value !== 'string') return []
|
|
104
|
+
const term = value.trim()
|
|
105
|
+
const key = term.toLowerCase()
|
|
106
|
+
if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key)) return []
|
|
107
|
+
seen.add(key)
|
|
108
|
+
return [term]
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Typed correction map shared by every decoder caller. The legacy profile
|
|
113
|
+
* stores this field as a JSON string, while hand-authored profiles sometimes
|
|
114
|
+
* use an object; accept both and ignore the factory example pair. */
|
|
115
|
+
export function getWhisperCorrections(): Record<string, string> {
|
|
116
|
+
const raw = loadProfile().whisper_corrections
|
|
117
|
+
let parsed: unknown = raw
|
|
118
|
+
if (typeof raw === 'string') {
|
|
119
|
+
try { parsed = JSON.parse(raw) } catch { return {} }
|
|
120
|
+
}
|
|
121
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
|
|
122
|
+
|
|
123
|
+
const corrections: Record<string, string> = {}
|
|
124
|
+
for (const [sourceRaw, targetRaw] of Object.entries(parsed as Record<string, unknown>)) {
|
|
125
|
+
if (typeof targetRaw !== 'string') continue
|
|
126
|
+
const source = sourceRaw.trim()
|
|
127
|
+
const target = targetRaw.trim()
|
|
128
|
+
if (!source || !target) continue
|
|
129
|
+
if (PLACEHOLDER_CORRECTIONS.has(`${source.toLowerCase()}\u0000${target.toLowerCase()}`)) continue
|
|
130
|
+
corrections[source] = target
|
|
131
|
+
}
|
|
132
|
+
return corrections
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface TranscriptionProfileStatus {
|
|
136
|
+
configured: boolean
|
|
137
|
+
ownerConfigured: boolean
|
|
138
|
+
vocabularyTerms: number
|
|
139
|
+
ignoredPlaceholderTerms: number
|
|
140
|
+
ignoredPlaceholderCorrection: boolean
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Path-free setup truth for startup warnings, health, and COS Control. */
|
|
144
|
+
export function getTranscriptionProfileStatus(): TranscriptionProfileStatus {
|
|
145
|
+
const profile = loadProfile()
|
|
146
|
+
const rawOwner = typeof profile.owner_name === 'string' ? profile.owner_name.trim() : ''
|
|
147
|
+
const rawVocabulary = Array.isArray(profile.vocabulary)
|
|
148
|
+
? (profile.vocabulary as unknown[]).filter((value): value is string => typeof value === 'string')
|
|
149
|
+
: []
|
|
150
|
+
const ignoredPlaceholderTerms = rawVocabulary.filter(term => PLACEHOLDER_VOCABULARY.has(term.trim().toLowerCase())).length
|
|
151
|
+
const rawCorrections = (() => {
|
|
152
|
+
const value = profile.whisper_corrections
|
|
153
|
+
if (typeof value === 'string') {
|
|
154
|
+
try { return JSON.parse(value) as unknown } catch { return null }
|
|
155
|
+
}
|
|
156
|
+
return value
|
|
157
|
+
})()
|
|
158
|
+
const ignoredPlaceholderCorrection = Boolean(
|
|
159
|
+
rawCorrections
|
|
160
|
+
&& typeof rawCorrections === 'object'
|
|
161
|
+
&& !Array.isArray(rawCorrections)
|
|
162
|
+
&& Object.entries(rawCorrections as Record<string, unknown>).some(([source, target]) =>
|
|
163
|
+
typeof target === 'string'
|
|
164
|
+
&& PLACEHOLDER_CORRECTIONS.has(`${source.trim().toLowerCase()}\u0000${target.trim().toLowerCase()}`)),
|
|
165
|
+
)
|
|
166
|
+
const ownerConfigured = Boolean(rawOwner) && !PLACEHOLDER_OWNER_NAMES.has(rawOwner.toLowerCase())
|
|
167
|
+
const vocabularyTerms = getVocabulary().length
|
|
168
|
+
return {
|
|
169
|
+
configured: ownerConfigured || vocabularyTerms > 0 || Object.keys(getWhisperCorrections()).length > 0,
|
|
170
|
+
ownerConfigured,
|
|
171
|
+
vocabularyTerms,
|
|
172
|
+
ignoredPlaceholderTerms,
|
|
173
|
+
ignoredPlaceholderCorrection,
|
|
174
|
+
}
|
|
96
175
|
}
|
|
97
176
|
|
|
98
177
|
export function getSystemContext(): string {
|