@gotcos/glasses-server 6.76.1 → 6.78.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/.env.example +40 -0
- package/CHANGELOG.md +56 -0
- package/README.md +12 -1
- package/managed-runtime-contract.json +17 -1
- package/package.json +10 -1
- package/server/index.ts +98 -10
- package/server/lib/agent-session-binding-registry.ts +623 -9
- package/server/lib/boot-resume.ts +552 -0
- package/server/lib/claude-bridge.ts +152 -3
- package/server/lib/claude-cli-support.ts +39 -0
- package/server/lib/claude-steer.ts +259 -0
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/env-switch.ts +17 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/managed-restart.ts +8 -0
- package/server/lib/provider-actions.ts +18 -0
- package/server/lib/query-job-coordinator.ts +300 -0
- package/server/lib/query-job-feature.ts +39 -0
- package/server/lib/query-job-resume.ts +269 -0
- package/server/lib/query-job-runtime.ts +23 -3
- package/server/lib/query-job-store.ts +516 -7
- package/server/lib/query-job-types.ts +210 -1
- package/server/lib/queue-turn-evidence.ts +213 -0
- package/server/lib/thread-turn-queue-deliver.ts +6 -1
- package/server/lib/thread-turn-queue-store.ts +13 -4
- package/server/lib/thread-turn-queue.ts +151 -1
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/agent-session-bindings.ts +169 -11
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +29 -1
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/maintenance.ts +34 -0
- package/server/routes/meeting.ts +92 -9
- package/server/routes/query-jobs.ts +107 -13
- package/server/routes/recovery.ts +4 -0
- package/server/routes/thread-turn-queue.ts +371 -10
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Screen context for a meeting (6.77.0): on-screen text lines COS Control reads during a
|
|
2
|
+
// meeting, kept so the post-save transcript cleanup can see what was on screen near a
|
|
3
|
+
// doubtful passage (a name on a slide, a participant tile, a shared document title).
|
|
4
|
+
//
|
|
5
|
+
// TEXT ONLY. Images never reach the server through this store. Every line is checked
|
|
6
|
+
// with the same secret detector the meeting image OCR uses (imageTextHasSecrets): a
|
|
7
|
+
// line that reads like a password, key or token is HELD (counted, never stored), and
|
|
8
|
+
// what is stored is also passed through redactSecrets.
|
|
9
|
+
//
|
|
10
|
+
// BOUNDED. Per request: 100 lines and 32 KB of text. Per line: 400 characters (longer
|
|
11
|
+
// lines are cut and counted). Per meeting: 2,000 lines and 400 KB; past either the rest
|
|
12
|
+
// of a request is dropped and the response says so. A line repeating one stored in the
|
|
13
|
+
// last 30 seconds is a duplicate and is not stored again (OCR re-reads a still screen).
|
|
14
|
+
//
|
|
15
|
+
// THE MEETING'S OWN DATA. One file per meeting, `transcript-cleanup/screen/<id>.jsonl`,
|
|
16
|
+
// beside the meeting's cleanup ledger. It is deleted with the meeting: by
|
|
17
|
+
// DELETE /api/meeting/:id/screen-context, by sweepTranscriptCleanupData once no saved
|
|
18
|
+
// meeting, live session or pending finalization still carries the id, and in any case
|
|
19
|
+
// after the retention window (default 30 days), because screen text is the most
|
|
20
|
+
// sensitive thing this feature keeps.
|
|
21
|
+
|
|
22
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs'
|
|
23
|
+
import { join, resolve } from 'node:path'
|
|
24
|
+
import { dataPath } from './data-dir.js'
|
|
25
|
+
import { redactSecrets } from './lens-gist-engines.js'
|
|
26
|
+
import { imageTextHasSecrets } from './meeting-image-ocr.js'
|
|
27
|
+
|
|
28
|
+
export const SCREEN_LIMITS = {
|
|
29
|
+
linesPerRequest: 100,
|
|
30
|
+
bytesPerRequest: 32 * 1024,
|
|
31
|
+
charsPerLine: 400,
|
|
32
|
+
linesPerMeeting: 2_000,
|
|
33
|
+
bytesPerMeeting: 400 * 1024,
|
|
34
|
+
duplicateWindowMs: 30_000,
|
|
35
|
+
/** A line may be stamped at most this far before the session started or after now. */
|
|
36
|
+
clockSlackMs: 5 * 60_000,
|
|
37
|
+
appChars: 40,
|
|
38
|
+
} as const
|
|
39
|
+
|
|
40
|
+
export const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
41
|
+
|
|
42
|
+
export interface ScreenLine {
|
|
43
|
+
/** Epoch ms. */
|
|
44
|
+
at: number
|
|
45
|
+
text: string
|
|
46
|
+
app?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ScreenAppendResult {
|
|
50
|
+
accepted: number
|
|
51
|
+
duplicates: number
|
|
52
|
+
/** Lines the secret detector held back. Never stored. */
|
|
53
|
+
held: number
|
|
54
|
+
truncated: number
|
|
55
|
+
/** Lines refused for a bad shape or clock. */
|
|
56
|
+
invalid: number
|
|
57
|
+
/** Lines past the per-meeting cap. */
|
|
58
|
+
dropped: number
|
|
59
|
+
stored: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class ScreenContextError extends Error {
|
|
63
|
+
constructor(readonly code: string, readonly status: number) { super(code) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function cleanupDir(...parts: string[]): string {
|
|
67
|
+
return dataPath('transcript-cleanup', ...parts)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The file for one meeting, or null for an id that could escape the directory. */
|
|
71
|
+
export function sessionFile(kind: 'screen' | 'ledger', sessionId: string): string | null {
|
|
72
|
+
if (!SESSION_ID.test(sessionId)) return null
|
|
73
|
+
const dir = cleanupDir(kind)
|
|
74
|
+
const path = join(dir, `${sessionId.replace(/:/g, '%3A')}.jsonl`)
|
|
75
|
+
return resolve(path).startsWith(resolve(dir) + '/') ? path : null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseAt(value: unknown): number | null {
|
|
79
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return Math.round(value)
|
|
80
|
+
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
|
81
|
+
const ms = Date.parse(value)
|
|
82
|
+
return Number.isFinite(ms) ? ms : null
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const norm = (text: string) => text.replace(/\s+/g, ' ').trim().toLowerCase()
|
|
88
|
+
|
|
89
|
+
export function readScreenContext(sessionId: string): ScreenLine[] {
|
|
90
|
+
const path = sessionFile('screen', sessionId)
|
|
91
|
+
if (!path || !existsSync(path)) return []
|
|
92
|
+
let raw = ''
|
|
93
|
+
try { raw = readFileSync(path, 'utf8') } catch { return [] }
|
|
94
|
+
const out: ScreenLine[] = []
|
|
95
|
+
for (const line of raw.split('\n')) {
|
|
96
|
+
if (!line.trim()) continue
|
|
97
|
+
try {
|
|
98
|
+
const o = JSON.parse(line) as Record<string, unknown>
|
|
99
|
+
if (typeof o.at !== 'number' || typeof o.text !== 'string') continue
|
|
100
|
+
out.push({ at: o.at, text: o.text, ...(typeof o.app === 'string' ? { app: o.app } : {}) })
|
|
101
|
+
} catch { /* a torn last line is skipped */ }
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function screenContextStats(sessionId: string): { lines: number; bytes: number; firstAt: number | null; lastAt: number | null } {
|
|
107
|
+
const lines = readScreenContext(sessionId)
|
|
108
|
+
const path = sessionFile('screen', sessionId)
|
|
109
|
+
let bytes = 0
|
|
110
|
+
try { if (path && existsSync(path)) bytes = statSync(path).size } catch { bytes = 0 }
|
|
111
|
+
return { lines: lines.length, bytes, firstAt: lines[0]?.at ?? null, lastAt: lines.at(-1)?.at ?? null }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate and append one request's lines. Throws ScreenContextError for a request the
|
|
116
|
+
* route must refuse whole (bad session id or body shape, too many lines or bytes); a
|
|
117
|
+
* single bad line inside a good request is counted, not fatal.
|
|
118
|
+
*/
|
|
119
|
+
export function appendScreenContext(
|
|
120
|
+
sessionId: string,
|
|
121
|
+
body: unknown,
|
|
122
|
+
opts: { now: number; sessionStartedAt?: number | null },
|
|
123
|
+
): ScreenAppendResult {
|
|
124
|
+
const path = sessionFile('screen', sessionId)
|
|
125
|
+
if (!path) throw new ScreenContextError('invalid_session_id', 400)
|
|
126
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new ScreenContextError('invalid_request', 400)
|
|
127
|
+
const lines = (body as { lines?: unknown }).lines
|
|
128
|
+
if (!Array.isArray(lines) || lines.length === 0) throw new ScreenContextError('lines_required', 400)
|
|
129
|
+
if (lines.length > SCREEN_LIMITS.linesPerRequest) throw new ScreenContextError('too_many_lines', 413)
|
|
130
|
+
let requestBytes = 0
|
|
131
|
+
for (const line of lines) {
|
|
132
|
+
const text = line && typeof line === 'object' ? (line as { text?: unknown }).text : undefined
|
|
133
|
+
if (typeof text === 'string') requestBytes += Buffer.byteLength(text, 'utf8')
|
|
134
|
+
}
|
|
135
|
+
if (requestBytes > SCREEN_LIMITS.bytesPerRequest) throw new ScreenContextError('request_too_large', 413)
|
|
136
|
+
|
|
137
|
+
const existing = readScreenContext(sessionId)
|
|
138
|
+
let storedBytes = 0
|
|
139
|
+
try { if (existsSync(path)) storedBytes = statSync(path).size } catch { storedBytes = SCREEN_LIMITS.bytesPerMeeting }
|
|
140
|
+
const result: ScreenAppendResult = { accepted: 0, duplicates: 0, held: 0, truncated: 0, invalid: 0, dropped: 0, stored: existing.length }
|
|
141
|
+
const recent = existing.slice(-50)
|
|
142
|
+
const earliest = (opts.sessionStartedAt ?? 0) - SCREEN_LIMITS.clockSlackMs
|
|
143
|
+
const latest = opts.now + SCREEN_LIMITS.clockSlackMs
|
|
144
|
+
const rows: string[] = []
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
if (!line || typeof line !== 'object' || Array.isArray(line)) { result.invalid++; continue }
|
|
147
|
+
const o = line as { at?: unknown; text?: unknown; app?: unknown }
|
|
148
|
+
const at = parseAt(o.at)
|
|
149
|
+
if (at === null || at < earliest || at > latest || typeof o.text !== 'string') { result.invalid++; continue }
|
|
150
|
+
let text = o.text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim()
|
|
151
|
+
if (!text) { result.invalid++; continue }
|
|
152
|
+
if (imageTextHasSecrets(o.text) || imageTextHasSecrets(text)) { result.held++; continue }
|
|
153
|
+
text = redactSecrets(text)
|
|
154
|
+
if (text.length > SCREEN_LIMITS.charsPerLine) { text = text.slice(0, SCREEN_LIMITS.charsPerLine); result.truncated++ }
|
|
155
|
+
const key = norm(text)
|
|
156
|
+
if (recent.some(r => norm(r.text) === key && Math.abs(r.at - at) <= SCREEN_LIMITS.duplicateWindowMs)) { result.duplicates++; continue }
|
|
157
|
+
const app = typeof o.app === 'string' ? o.app.replace(/[^\p{L}\p{N} ._-]/gu, '').trim().slice(0, SCREEN_LIMITS.appChars) : ''
|
|
158
|
+
const row = JSON.stringify({ at, text, ...(app ? { app } : {}) })
|
|
159
|
+
const rowBytes = Buffer.byteLength(row, 'utf8') + 1
|
|
160
|
+
if (result.stored >= SCREEN_LIMITS.linesPerMeeting || storedBytes + rowBytes > SCREEN_LIMITS.bytesPerMeeting) { result.dropped++; continue }
|
|
161
|
+
rows.push(row)
|
|
162
|
+
recent.push({ at, text })
|
|
163
|
+
storedBytes += rowBytes
|
|
164
|
+
result.stored++
|
|
165
|
+
result.accepted++
|
|
166
|
+
}
|
|
167
|
+
if (rows.length) {
|
|
168
|
+
mkdirSync(cleanupDir('screen'), { recursive: true, mode: 0o700 })
|
|
169
|
+
appendFileSync(path, rows.join('\n') + '\n', { mode: 0o600 })
|
|
170
|
+
}
|
|
171
|
+
return result
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function deleteScreenContext(sessionId: string): boolean {
|
|
175
|
+
const path = sessionFile('screen', sessionId)
|
|
176
|
+
if (!path) throw new ScreenContextError('invalid_session_id', 400)
|
|
177
|
+
if (!existsSync(path)) return false
|
|
178
|
+
rmSync(path, { force: true })
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The screen lines to show next to one doubtful passage: the nearest in time, then any naming one of its targets. */
|
|
183
|
+
export function screenLinesFor(
|
|
184
|
+
lines: readonly ScreenLine[],
|
|
185
|
+
atMs: number | undefined,
|
|
186
|
+
targets: readonly string[],
|
|
187
|
+
opts: { windowMs?: number; max?: number } = {},
|
|
188
|
+
): ScreenLine[] {
|
|
189
|
+
const windowMs = opts.windowMs ?? 45_000
|
|
190
|
+
const max = opts.max ?? 3
|
|
191
|
+
const picked: ScreenLine[] = []
|
|
192
|
+
if (atMs !== undefined) {
|
|
193
|
+
const near = lines.filter(l => Math.abs(l.at - atMs) <= windowMs).sort((a, b) => Math.abs(a.at - atMs) - Math.abs(b.at - atMs))
|
|
194
|
+
for (const line of near) if (picked.length < max && !picked.includes(line)) picked.push(line)
|
|
195
|
+
}
|
|
196
|
+
if (picked.length < max && targets.length) {
|
|
197
|
+
const wanted = targets.map(t => t.toLowerCase()).filter(t => t.length >= 4)
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
if (picked.length >= max) break
|
|
200
|
+
const lower = line.text.toLowerCase()
|
|
201
|
+
if (!picked.includes(line) && wanted.some(t => lower.includes(t))) picked.push(line)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return picked
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Days screen text is kept even while its meeting exists. */
|
|
208
|
+
export function screenRetentionDays(env: NodeJS.ProcessEnv = process.env): number {
|
|
209
|
+
const raw = Number(env.COS_TRANSCRIPT_SCREEN_RETENTION_DAYS)
|
|
210
|
+
return Number.isFinite(raw) && raw >= 1 && raw <= 365 ? Math.floor(raw) : 30
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Delete cleanup data whose meeting is gone, and screen text past its retention.
|
|
215
|
+
* `known(sessionId)` answers whether a saved meeting, a live session or a pending
|
|
216
|
+
* finalization still carries the id. A file younger than `graceMs` is always kept, so a
|
|
217
|
+
* capture still being recovered from quarantine does not lose its screen context.
|
|
218
|
+
*/
|
|
219
|
+
export function sweepTranscriptCleanupData(opts: {
|
|
220
|
+
now: number
|
|
221
|
+
known: (sessionId: string) => boolean
|
|
222
|
+
retentionDays?: number
|
|
223
|
+
graceMs?: number
|
|
224
|
+
}): { screenDeleted: number; ledgersDeleted: number } {
|
|
225
|
+
const graceMs = opts.graceMs ?? 72 * 3_600_000
|
|
226
|
+
const retentionMs = (opts.retentionDays ?? screenRetentionDays()) * 86_400_000
|
|
227
|
+
const out = { screenDeleted: 0, ledgersDeleted: 0 }
|
|
228
|
+
for (const kind of ['screen', 'ledger'] as const) {
|
|
229
|
+
const dir = cleanupDir(kind)
|
|
230
|
+
if (!existsSync(dir)) continue
|
|
231
|
+
let names: string[] = []
|
|
232
|
+
try { names = readdirSync(dir).filter(n => n.endsWith('.jsonl')) } catch { continue }
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
const path = join(dir, name)
|
|
235
|
+
let mtime = 0
|
|
236
|
+
try { mtime = statSync(path).mtimeMs } catch { continue }
|
|
237
|
+
const sessionId = name.slice(0, -'.jsonl'.length).replace(/%3A/g, ':')
|
|
238
|
+
const age = opts.now - mtime
|
|
239
|
+
const expired = kind === 'screen' && age > retentionMs
|
|
240
|
+
let gone = false
|
|
241
|
+
if (!expired && age > graceMs) {
|
|
242
|
+
try { gone = !opts.known(sessionId) } catch { gone = false }
|
|
243
|
+
}
|
|
244
|
+
if (!expired && !gone) continue
|
|
245
|
+
try { rmSync(path, { force: true }); kind === 'screen' ? out.screenDeleted++ : out.ledgersDeleted++ } catch { /* next sweep */ }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out
|
|
249
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): which passages are doubtful, decided with no model.
|
|
2
|
+
//
|
|
3
|
+
// Two steps, both deterministic.
|
|
4
|
+
//
|
|
5
|
+
// 1. FIXES. fuzzy-correct's own rule (fuzzyReplacementFor) over CAPITALISED words that are
|
|
6
|
+
// not already a known spelling: this meeting's screen and file terms (a temporary
|
|
7
|
+
// per-meeting vocabulary) at any confidence; the curated dictionary and the owner only
|
|
8
|
+
// for a word the engine was unsure of. Never a lowercase word, never anyone but the owner as
|
|
9
|
+
// a target (that is the model's call), never into an inflection ("Setting" is never a
|
|
10
|
+
// "Settings" button label). Why each rule exists is measured in deterministicFixes. Fixes
|
|
11
|
+
// are free and are applied before any model call, so the obvious cases never cost a token.
|
|
12
|
+
//
|
|
13
|
+
// 2. SPANS. What is left that the engine or the names say is unsure:
|
|
14
|
+
// low_confidence the decoder's own per-word probability below its engine's floor
|
|
15
|
+
// (Parakeet 0.50, the resolver's floor; Whisper 0.40)
|
|
16
|
+
// near_name a word the engine was unsure of (below 0.9) that is a capitalised word
|
|
17
|
+
// one edit past fuzzy-correct's limit from a known name, or that sounds
|
|
18
|
+
// like one (same consonant skeleton and onset); two or three short
|
|
19
|
+
// adjacent pieces joined ("U coma"), none a function word; or the
|
|
20
|
+
// leading-y rule at any confidence ("Yukoma" for Ukaoma)
|
|
21
|
+
// oov_capitalized a capitalised word mid-sentence that is no known name and no common
|
|
22
|
+
// word, within two words of a known name ("Queen Eucoma")
|
|
23
|
+
// repeat a phrase of 2 to 4 words (or one long word) said three or more
|
|
24
|
+
// times in a row: the classic decoder loop
|
|
25
|
+
// Flagged words one word apart merge; a span holds at most 6 words (12 for a repeat)
|
|
26
|
+
// and never crosses a speaker turn or a clause (a comma or a sentence end). Spans are ranked (names first, then repeats, then
|
|
27
|
+
// the least confident) and cut to the per-meeting cap.
|
|
28
|
+
|
|
29
|
+
import { canonicalFuzzyTargets, fuzzyReplacementFor, isCommonEnglishWord, levenshtein } from './fuzzy-correct.js'
|
|
30
|
+
import { closeSpelling, fold, maxFuzzyDistance, soundsAlike, type Token, type TranscriptLine } from './transcript-cleanup-text.js'
|
|
31
|
+
import type { NameTarget } from './transcript-cleanup-context.js'
|
|
32
|
+
|
|
33
|
+
export type SpanReason = 'low_confidence' | 'near_name' | 'oov_capitalized' | 'repeat'
|
|
34
|
+
|
|
35
|
+
export interface Fix {
|
|
36
|
+
line: number
|
|
37
|
+
start: number
|
|
38
|
+
end: number
|
|
39
|
+
original: string
|
|
40
|
+
replacement: string
|
|
41
|
+
/** The target came only from this meeting's screen or files. */
|
|
42
|
+
fromMeeting: boolean
|
|
43
|
+
token: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CleanupSpan {
|
|
47
|
+
id: string
|
|
48
|
+
line: number
|
|
49
|
+
start: number
|
|
50
|
+
end: number
|
|
51
|
+
text: string
|
|
52
|
+
before: string
|
|
53
|
+
after: string
|
|
54
|
+
reasons: SpanReason[]
|
|
55
|
+
/** Token range [from, to) in the flat token list. */
|
|
56
|
+
from: number
|
|
57
|
+
to: number
|
|
58
|
+
minProbability?: number
|
|
59
|
+
atMs?: number
|
|
60
|
+
/** Known names this span is near, for the prompt and the notes search. */
|
|
61
|
+
near: string[]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const SPAN_LIMITS = {
|
|
65
|
+
maxSpanWords: 6,
|
|
66
|
+
maxRepeatWords: 12,
|
|
67
|
+
contextWords: 12,
|
|
68
|
+
defaultMaxSpans: 40,
|
|
69
|
+
hardMaxSpans: 80,
|
|
70
|
+
} as const
|
|
71
|
+
|
|
72
|
+
export const LOW_CONFIDENCE: Record<string, number> = { parakeet: 0.5, whisper: 0.4, default: 0.45 }
|
|
73
|
+
export function lowConfidenceFloor(engine: string | undefined): number {
|
|
74
|
+
if (!engine) return LOW_CONFIDENCE.default
|
|
75
|
+
if (engine.startsWith('parakeet')) return LOW_CONFIDENCE.parakeet
|
|
76
|
+
if (engine.startsWith('large-v3') || engine.includes('whisper') || engine.startsWith('turbo')) return LOW_CONFIDENCE.whisper
|
|
77
|
+
return LOW_CONFIDENCE.default
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FILLERS = new Set(['um', 'uh', 'umm', 'uhm', 'erm', 'er', 'ah', 'hmm', 'mm', 'mhm', 'oh', 'huh'])
|
|
81
|
+
|
|
82
|
+
const INFLECTION = /^(?:s|es|ed|d|ing|er|ers|ly)$/
|
|
83
|
+
/** "Setting" and "Settings", "Record" and "Recording": one word's inflection of the other. */
|
|
84
|
+
export function inflectionOf(a: string, b: string): boolean {
|
|
85
|
+
const x = fold(a), y = fold(b)
|
|
86
|
+
if (x === y) return false
|
|
87
|
+
const [short, long] = x.length <= y.length ? [x, y] : [y, x]
|
|
88
|
+
return long.startsWith(short) && INFLECTION.test(long.slice(short.length))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const POSSESSIVE = /(['’]s)$/i
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Deterministic fixes over the tokens. Mutates nothing; the caller applies them.
|
|
95
|
+
*
|
|
96
|
+
* Conservative by measurement (2026-10-17, read-only, 68 real meetings, 38.5 hours):
|
|
97
|
+
* - fuzzy-correct's rule over LOWERCASE words, with 94 enrolled names and the vocabulary
|
|
98
|
+
* as targets, rewrote everyday words into names (giant, permit, companies, weight,
|
|
99
|
+
* pitch, range) 29 times in two meetings. Only a capitalised word is rewritten here.
|
|
100
|
+
* - over capitalised words with every enrolled name as a target it still swapped one real
|
|
101
|
+
* name for another (Kelsey to Kelley, Erica to Eric, Sandy to Andy) and a product for a
|
|
102
|
+
* surname (Slack to Black, 14 times). So a word that is itself a known spelling is never
|
|
103
|
+
* rewritten, people's names other than the owner's are NOT targets here (enrolled or attending: with the
|
|
104
|
+
* attendees as targets Slack still became Black twice; they reach the model as context
|
|
105
|
+
* and drive near-name spans instead), and the curated dictionary and the owner
|
|
106
|
+
* ("strong" targets) rewrite only a word the engine was unsure of. With those rules the
|
|
107
|
+
* 68 meetings got 27 fixes, all real (Manuj to Manoj 9 times, MarketPOS to MarktPOS).
|
|
108
|
+
* - a term from THIS meeting's screen or files (the per-meeting vocabulary) may rewrite a
|
|
109
|
+
* capitalised word at any confidence: it is what was on screen while it was said.
|
|
110
|
+
* Never into an inflection of the word, and a word that only opens a sentence only when the
|
|
111
|
+
* engine was unsure of it.
|
|
112
|
+
*/
|
|
113
|
+
export function deterministicFixes(
|
|
114
|
+
tokens: readonly Token[],
|
|
115
|
+
strongTargets: string[],
|
|
116
|
+
meetingTargets: readonly NameTarget[],
|
|
117
|
+
known: ReadonlySet<string> = new Set(),
|
|
118
|
+
): Fix[] {
|
|
119
|
+
const strong = canonicalFuzzyTargets(strongTargets)
|
|
120
|
+
const meeting = canonicalFuzzyTargets(meetingTargets.filter(t => t.fromMeeting).map(t => t.display))
|
|
121
|
+
const allLower = new Set([...strong, ...meeting].map(t => t.toLowerCase()))
|
|
122
|
+
const fixes: Fix[] = []
|
|
123
|
+
for (const token of tokens) {
|
|
124
|
+
const possessive = POSSESSIVE.exec(token.core)?.[1] ?? ''
|
|
125
|
+
const base = possessive ? token.core.slice(0, -possessive.length) : token.core
|
|
126
|
+
if (!/^\p{Lu}/u.test(base) || allLower.has(base.toLowerCase()) || known.has(fold(base))) continue
|
|
127
|
+
const unsure = token.probability === undefined || token.probability < NEAR_NAME_UNSURE
|
|
128
|
+
if (token.sentenceStart && !unsure) continue
|
|
129
|
+
let replacement = unsure ? fuzzyReplacementFor(base, strong, allLower) : null
|
|
130
|
+
let fromMeeting = false
|
|
131
|
+
if (replacement === null && meeting.length) {
|
|
132
|
+
replacement = fuzzyReplacementFor(base, meeting, allLower)
|
|
133
|
+
fromMeeting = replacement !== null
|
|
134
|
+
}
|
|
135
|
+
if (replacement === null || replacement === base || inflectionOf(base, replacement)) continue
|
|
136
|
+
fixes.push({ line: token.line, start: token.start, end: token.end, original: token.core, replacement: replacement + possessive, fromMeeting, token: token.i })
|
|
137
|
+
}
|
|
138
|
+
return fixes
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface Flag { reasons: Set<SpanReason>; near: Set<string> }
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Below this the engine was not sure enough of a word for "it is near a name" to count.
|
|
145
|
+
* Measured 2026-10-17 on four real Parakeet meetings (read-only, no model): with no
|
|
146
|
+
* confidence gate the edit-distance rule flagged everyday words ("lives" for Miles,
|
|
147
|
+
* "rates" for Nate) about eight times a minute.
|
|
148
|
+
*/
|
|
149
|
+
export const NEAR_NAME_UNSURE = 0.9
|
|
150
|
+
|
|
151
|
+
/** Short, frequent words that are never a fragment of a split name ("like i" is not "Mike"). */
|
|
152
|
+
const FUNCTION_WORDS = new Set(`a an the and or but if so of to in on at by for from with as is am are was were be been it its i me my we us our you
|
|
153
|
+
your he him his she her they them their this that these those there here what who how why when where not no yes yeah yep ok okay oh uh um
|
|
154
|
+
mm hmm ah like just do did does done can could would should will shall may might must have has had get got go going went say said says
|
|
155
|
+
see saw know knew think thought want one two three four five six seven eight nine ten all any some more most very too also then than
|
|
156
|
+
now well still even ever only own same out up down off over into onto about after before again once s t d ll re ve m o y`.split(/\s+/).filter(Boolean))
|
|
157
|
+
|
|
158
|
+
interface NearInput { key: string; capitalized: boolean; probability?: number; join: boolean }
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The known name `key` is plausibly a mishearing of, or null.
|
|
162
|
+
* - the leading-y rule (meeting-ask-owner.ts): "Yukoma" is one edit from "Ukaoma" once its y goes
|
|
163
|
+
* - otherwise only for a word the engine was unsure of (below NEAR_NAME_UNSURE, or unscored):
|
|
164
|
+
* a capitalised single word one edit past fuzzy-correct's own limit, or
|
|
165
|
+
* any word (or short joined pieces) that sounds like the name (soundsAlike)
|
|
166
|
+
* - joined pieces may also be within fuzzy-correct's own limit ("U coma" read as one word)
|
|
167
|
+
*/
|
|
168
|
+
function nearName(input: NearInput, targets: readonly NameTarget[], known: ReadonlySet<string>): string | null {
|
|
169
|
+
const { key } = input
|
|
170
|
+
if (key.length < 5) return null
|
|
171
|
+
if (!input.join && (known.has(key) || isCommonEnglishWord(key))) return null
|
|
172
|
+
const unsure = input.probability === undefined || input.probability < NEAR_NAME_UNSURE
|
|
173
|
+
let best: { d: number; display: string } | null = null
|
|
174
|
+
for (const t of targets) {
|
|
175
|
+
if (Math.abs(t.key.length - key.length) > 2) continue
|
|
176
|
+
const d = levenshtein(key, t.key)
|
|
177
|
+
// A single word spelled exactly like a name is the name. Pieces that join into one exactly ("Uka oma") are the
|
|
178
|
+
// strongest sign of a split name there is.
|
|
179
|
+
if (d === 0 && !input.join) return null
|
|
180
|
+
// "setting" next to a "Settings" label is the same word, not a misheard name.
|
|
181
|
+
if (inflectionOf(key, t.key)) continue
|
|
182
|
+
const md = maxFuzzyDistance(Math.max(key.length, t.key.length))
|
|
183
|
+
const yRule = /^y[aeiou]/.test(key) && !/^y/.test(t.key) && levenshtein(key.slice(1), t.key) <= 1
|
|
184
|
+
const hit = yRule || (unsure && (
|
|
185
|
+
input.join ? (d <= md || soundsAlike(key, t.key)) : ((input.capitalized && d <= md + 1) || soundsAlike(key, t.key))))
|
|
186
|
+
if (hit && (!best || d < best.d)) best = { d, display: t.display }
|
|
187
|
+
}
|
|
188
|
+
return best?.display ?? null
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Repeated phrase runs: [from, to) token ranges. */
|
|
192
|
+
export function repeatRuns(tokens: readonly Token[]): Array<[number, number]> {
|
|
193
|
+
const runs: Array<[number, number]> = []
|
|
194
|
+
let i = 0
|
|
195
|
+
while (i < tokens.length) {
|
|
196
|
+
let found: [number, number] | null = null
|
|
197
|
+
for (let unit = 4; unit >= 1 && !found; unit--) {
|
|
198
|
+
const head = tokens.slice(i, i + unit)
|
|
199
|
+
if (head.length < unit || head.some(t => t.line !== tokens[i].line || !t.key)) continue
|
|
200
|
+
if (unit === 1 && head[0].key.length < 4) continue
|
|
201
|
+
let reps = 1
|
|
202
|
+
while (true) {
|
|
203
|
+
const next = tokens.slice(i + reps * unit, i + (reps + 1) * unit)
|
|
204
|
+
if (next.length < unit || next.some((t, k) => t.line !== tokens[i].line || t.key !== head[k].key)) break
|
|
205
|
+
reps++
|
|
206
|
+
}
|
|
207
|
+
if (reps >= (unit === 1 ? 4 : 3)) found = [i, i + reps * unit]
|
|
208
|
+
}
|
|
209
|
+
if (found) { runs.push(found); i = found[1] } else i++
|
|
210
|
+
}
|
|
211
|
+
return runs
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface SelectOptions {
|
|
215
|
+
maxSpans?: number
|
|
216
|
+
/** Tokens already fixed deterministically: never flagged again. */
|
|
217
|
+
fixed?: ReadonlySet<number>
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface SpanSelection {
|
|
221
|
+
spans: CleanupSpan[]
|
|
222
|
+
/** Before the per-meeting cap. */
|
|
223
|
+
found: number
|
|
224
|
+
byReason: Record<SpanReason, number>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function snippet(tokens: readonly Token[], from: number, to: number, line: number): string {
|
|
228
|
+
return tokens.slice(Math.max(0, from), Math.max(0, to)).filter(t => t.line === line).map(t => t.raw).join(' ')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function selectSpans(
|
|
232
|
+
lines: readonly TranscriptLine[],
|
|
233
|
+
tokens: readonly Token[],
|
|
234
|
+
targets: readonly NameTarget[],
|
|
235
|
+
known: ReadonlySet<string>,
|
|
236
|
+
opts: SelectOptions = {},
|
|
237
|
+
): SpanSelection {
|
|
238
|
+
const maxSpans = Math.max(0, Math.min(opts.maxSpans ?? SPAN_LIMITS.defaultMaxSpans, SPAN_LIMITS.hardMaxSpans))
|
|
239
|
+
const fixed = opts.fixed ?? new Set<number>()
|
|
240
|
+
const flags = new Map<number, Flag>()
|
|
241
|
+
const flag = (i: number, reason: SpanReason, near?: string) => {
|
|
242
|
+
const f = flags.get(i) ?? { reasons: new Set<SpanReason>(), near: new Set<string>() }
|
|
243
|
+
f.reasons.add(reason)
|
|
244
|
+
if (near) f.near.add(near)
|
|
245
|
+
flags.set(i, f)
|
|
246
|
+
}
|
|
247
|
+
const nameKeys = new Set(targets.map(t => t.key))
|
|
248
|
+
const nearMemo = new Map<string, string | null>()
|
|
249
|
+
|
|
250
|
+
for (const t of tokens) {
|
|
251
|
+
if (fixed.has(t.i) || !t.key) continue
|
|
252
|
+
if (t.probability !== undefined && t.probability < lowConfidenceFloor(t.engine) && !FILLERS.has(t.key)) flag(t.i, 'low_confidence')
|
|
253
|
+
if (!known.has(t.key)) {
|
|
254
|
+
const memo = `${t.key}|${/^\p{Lu}/u.test(t.core) ? 1 : 0}|${t.probability === undefined || t.probability < NEAR_NAME_UNSURE ? 1 : 0}`
|
|
255
|
+
let near = nearMemo.get(memo)
|
|
256
|
+
if (near === undefined) {
|
|
257
|
+
near = nearName({ key: t.key, capitalized: /^\p{Lu}/u.test(t.core), probability: t.probability, join: false }, targets, known)
|
|
258
|
+
nearMemo.set(memo, near)
|
|
259
|
+
}
|
|
260
|
+
if (near) flag(t.i, 'near_name', near)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Two or three adjacent words joined: "U coma", "Yu ko ma". One of them must be short (a split fragment).
|
|
264
|
+
for (let i = 0; i + 1 < tokens.length; i++) {
|
|
265
|
+
for (const width of [2, 3]) {
|
|
266
|
+
const group = tokens.slice(i, i + width)
|
|
267
|
+
if (group.length < width || group.some(g => g.line !== tokens[i].line || !g.key || fixed.has(g.i) || known.has(g.key))) continue
|
|
268
|
+
// A split name is short pieces ("U coma", "Yu ko ma"); a long word plus a short one is just two words,
|
|
269
|
+
// and a function word is never a piece of a name.
|
|
270
|
+
if (!group.some(g => g.key.length <= 3) || group.some(g => g.key.length > 5 || FUNCTION_WORDS.has(g.key))) continue
|
|
271
|
+
const joined = group.map(g => g.key).join('')
|
|
272
|
+
const scores = group.map(g => g.probability).filter((p): p is number => p !== undefined)
|
|
273
|
+
const near = nearName({ key: joined, capitalized: group.some(g => /^\p{Lu}/u.test(g.core)), probability: scores.length === group.length ? Math.min(...scores) : undefined, join: true }, targets, known)
|
|
274
|
+
if (near) for (const g of group) flag(g.i, 'near_name', near)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const t of tokens) {
|
|
278
|
+
if (fixed.has(t.i) || t.sentenceStart || !/^\p{Lu}/u.test(t.core) || t.key.length < 3) continue
|
|
279
|
+
if (known.has(t.key) || isCommonEnglishWord(t.key) || /^\p{Lu}+$/u.test(t.core) || t.core === 'I' || /^I['’]/.test(t.core)) continue
|
|
280
|
+
const neighbours = tokens.slice(Math.max(0, t.i - 2), t.i + 3).filter(n => n.i !== t.i && n.line === t.line)
|
|
281
|
+
const anchor = neighbours.find(n => nameKeys.has(n.key) || (known.has(n.key) && /^\p{Lu}/u.test(n.core)))
|
|
282
|
+
if (anchor) flag(t.i, 'oov_capitalized', targets.find(x => x.key === anchor.key)?.display ?? anchor.core)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Group flagged tokens into spans inside one line.
|
|
286
|
+
type Draft = { from: number; to: number; reasons: Set<SpanReason>; near: Set<string> }
|
|
287
|
+
const drafts: Draft[] = []
|
|
288
|
+
const repeatTokens = new Set<number>()
|
|
289
|
+
for (const [from, to] of repeatRuns(tokens)) {
|
|
290
|
+
if ([...Array(to - from).keys()].some(k => fixed.has(from + k))) continue
|
|
291
|
+
const end = Math.min(to, from + SPAN_LIMITS.maxRepeatWords)
|
|
292
|
+
drafts.push({ from, to: end, reasons: new Set<SpanReason>(['repeat']), near: new Set() })
|
|
293
|
+
for (let k = from; k < end; k++) repeatTokens.add(k)
|
|
294
|
+
}
|
|
295
|
+
const flagged = [...flags.keys()].filter(i => !repeatTokens.has(i)).sort((a, b) => a - b)
|
|
296
|
+
let k = 0
|
|
297
|
+
while (k < flagged.length) {
|
|
298
|
+
const from = flagged[k]
|
|
299
|
+
let to = from + 1
|
|
300
|
+
const reasons = new Set(flags.get(from)!.reasons)
|
|
301
|
+
const near = new Set(flags.get(from)!.near)
|
|
302
|
+
k++
|
|
303
|
+
while (k < flagged.length) {
|
|
304
|
+
const next = flagged[k]
|
|
305
|
+
if (tokens[next].line !== tokens[from].line || next - to > 1 || next + 1 - from > SPAN_LIMITS.maxSpanWords) break
|
|
306
|
+
// Never across a clause: "Yukoma and eucoma, then U coma" is two passages, not one.
|
|
307
|
+
if (tokens.slice(to - 1, next).some(t => /[,.;:!?]["')\]]*$/.test(t.raw))) break
|
|
308
|
+
if ([...Array(next - to).keys()].some(g => repeatTokens.has(to + g))) break
|
|
309
|
+
for (const r of flags.get(next)!.reasons) reasons.add(r)
|
|
310
|
+
for (const n of flags.get(next)!.near) near.add(n)
|
|
311
|
+
to = next + 1
|
|
312
|
+
k++
|
|
313
|
+
}
|
|
314
|
+
drafts.push({ from, to, reasons, near })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const byReason: Record<SpanReason, number> = { low_confidence: 0, near_name: 0, oov_capitalized: 0, repeat: 0 }
|
|
318
|
+
for (const d of drafts) for (const r of d.reasons) byReason[r]++
|
|
319
|
+
const minP = (d: Draft) => {
|
|
320
|
+
const ps = tokens.slice(d.from, d.to).map(t => t.probability).filter((p): p is number => p !== undefined)
|
|
321
|
+
return ps.length ? Math.min(...ps) : undefined
|
|
322
|
+
}
|
|
323
|
+
const score = (d: Draft) => (d.reasons.has('near_name') ? 4 : 0) + (d.reasons.has('oov_capitalized') ? 3 : 0)
|
|
324
|
+
+ (d.reasons.has('repeat') ? 2 : 0) + (d.reasons.has('low_confidence') ? 1 + (1 - (minP(d) ?? 1)) : 0)
|
|
325
|
+
const kept = [...drafts].sort((a, b) => score(b) - score(a) || a.from - b.from).slice(0, maxSpans).sort((a, b) => a.from - b.from)
|
|
326
|
+
|
|
327
|
+
const spans = kept.map((d, n): CleanupSpan => {
|
|
328
|
+
const first = tokens[d.from], last = tokens[d.to - 1]
|
|
329
|
+
const line = lines.find(l => l.index === first.line)!
|
|
330
|
+
const p = minP(d)
|
|
331
|
+
const at = tokens.slice(d.from, d.to).map(t => t.atMs).find(v => v !== undefined)
|
|
332
|
+
return {
|
|
333
|
+
id: `S${n + 1}`,
|
|
334
|
+
line: first.line,
|
|
335
|
+
start: first.start,
|
|
336
|
+
end: last.end,
|
|
337
|
+
text: line.text.slice(first.start, last.end),
|
|
338
|
+
before: snippet(tokens, d.from - SPAN_LIMITS.contextWords, d.from, first.line),
|
|
339
|
+
after: snippet(tokens, d.to, d.to + SPAN_LIMITS.contextWords, first.line),
|
|
340
|
+
reasons: [...d.reasons].sort(),
|
|
341
|
+
from: d.from,
|
|
342
|
+
to: d.to,
|
|
343
|
+
...(p !== undefined ? { minProbability: Math.round(p * 1000) / 1000 } : {}),
|
|
344
|
+
...(at !== undefined ? { atMs: at } : {}),
|
|
345
|
+
near: [...d.near].slice(0, 3),
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
return { spans, found: drafts.length, byReason }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** For edit validation: is `word` plausibly one of the span's own words, misheard? */
|
|
352
|
+
export function resemblesSpan(word: string, spanWords: readonly string[]): boolean {
|
|
353
|
+
const w = fold(word)
|
|
354
|
+
if (!w) return true
|
|
355
|
+
const keys = spanWords.map(fold).filter(Boolean)
|
|
356
|
+
const joined = keys.join('')
|
|
357
|
+
return keys.some(k => k === w || closeSpelling(k, w) || soundsAlike(k, w)) || closeSpelling(joined, w) || soundsAlike(joined, w)
|
|
358
|
+
}
|