@gotcos/glasses-server 6.76.1 → 6.77.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 +40 -0
- package/managed-runtime-contract.json +8 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -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/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/glossary.ts +38 -19
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/meeting.ts +65 -0
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): how it is switched on, which model it uses, and how it stops.
|
|
2
|
+
//
|
|
3
|
+
// SWITCH. Its own flag, nothing shared:
|
|
4
|
+
// 1. env COS_TRANSCRIPT_CLEANUP=1 forces it on, =0 forces it off (source 'env'); a
|
|
5
|
+
// request to change it then answers 409 env_forced.
|
|
6
|
+
// 2. the saved setting (transcript-cleanup/settings.json, atomic), which COS Control's
|
|
7
|
+
// toggle writes through POST /api/transcript-cleanup/settings (source 'setting').
|
|
8
|
+
// 3. off (source 'default'). Miles turns it on.
|
|
9
|
+
// An unreadable settings file reads as no setting: it fails toward off, never toward spending.
|
|
10
|
+
//
|
|
11
|
+
// MODEL. Haiku unless the setting or env COS_TRANSCRIPT_CLEANUP_MODEL says sonnet. Always a
|
|
12
|
+
// pinned model id, never the CLI default (a bare `claude -p` can resolve to Opus).
|
|
13
|
+
//
|
|
14
|
+
// STOPS (each one a recorded skip, correction_skipped style):
|
|
15
|
+
// switch_off nothing runs at all: no fuzzy pass, no file, no trace
|
|
16
|
+
// api_key_in_env ANTHROPIC_API_KEY is set and COS_ALLOW_API_FALLBACK is not 1: the CLI
|
|
17
|
+
// would bill the API key, not the plan
|
|
18
|
+
// claude_not_found no Claude CLI
|
|
19
|
+
// daily_calls COS_TRANSCRIPT_CLEANUP_DAILY calls today (default 30), charged BEFORE
|
|
20
|
+
// the call and never refunded
|
|
21
|
+
// daily_tokens COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS today (default 400k in plus out)
|
|
22
|
+
// breaker_open 3 failures in a row, or one provider limit, open it for 30 minutes
|
|
23
|
+
// (60 after a limit); the first meeting after that is one trial call
|
|
24
|
+
// Per meeting: one call, at most COS_TRANSCRIPT_CLEANUP_MAX_SPANS spans (default 40, hard
|
|
25
|
+
// 80), a 24k-character prompt and a 1,500-token answer. The day is the local calendar day.
|
|
26
|
+
// An unreadable or impossible state file reads as a spent day, so a corrupt ledger can
|
|
27
|
+
// stop spending but never start it.
|
|
28
|
+
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
30
|
+
import { dirname } from 'node:path'
|
|
31
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
32
|
+
import { cleanupDir } from './transcript-cleanup-screen.js'
|
|
33
|
+
import { CLEANUP_MODEL_IDS, type CleanupModelChoice } from './transcript-cleanup-model.js'
|
|
34
|
+
import { SPAN_LIMITS } from './transcript-cleanup-spans.js'
|
|
35
|
+
|
|
36
|
+
export type CleanupSettingSource = 'env' | 'setting' | 'default'
|
|
37
|
+
export interface StoredCleanupSettings { enabled: boolean | null; model: CleanupModelChoice | null }
|
|
38
|
+
|
|
39
|
+
export class TranscriptCleanupSettings {
|
|
40
|
+
private cache: StoredCleanupSettings | null = null
|
|
41
|
+
constructor(readonly file: string = cleanupDir('settings.json')) {}
|
|
42
|
+
|
|
43
|
+
stored(): StoredCleanupSettings {
|
|
44
|
+
if (this.cache) return this.cache
|
|
45
|
+
let value: StoredCleanupSettings = { enabled: null, model: null }
|
|
46
|
+
try {
|
|
47
|
+
if (existsSync(this.file)) {
|
|
48
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Record<string, unknown>
|
|
49
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
50
|
+
value = {
|
|
51
|
+
enabled: typeof raw.enabled === 'boolean' ? raw.enabled : null,
|
|
52
|
+
model: raw.model === 'haiku' || raw.model === 'sonnet' ? raw.model : null,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch { value = { enabled: null, model: null } }
|
|
57
|
+
this.cache = value
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Atomic and durable; the cached value changes only after the write. */
|
|
62
|
+
save(patch: Partial<{ enabled: boolean; model: CleanupModelChoice }>, now: Date = new Date()): StoredCleanupSettings {
|
|
63
|
+
const current = this.stored()
|
|
64
|
+
const next: StoredCleanupSettings = {
|
|
65
|
+
enabled: patch.enabled ?? current.enabled,
|
|
66
|
+
model: patch.model ?? current.model,
|
|
67
|
+
}
|
|
68
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
69
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, ...next, updatedAt: now.toISOString() }) + '\n', { mode: 0o600 })
|
|
70
|
+
this.cache = next
|
|
71
|
+
return next
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveCleanupSwitch(env: NodeJS.ProcessEnv, settings: TranscriptCleanupSettings | null): { enabled: boolean; source: CleanupSettingSource } {
|
|
76
|
+
if (env.COS_TRANSCRIPT_CLEANUP === '1') return { enabled: true, source: 'env' }
|
|
77
|
+
if (env.COS_TRANSCRIPT_CLEANUP === '0') return { enabled: false, source: 'env' }
|
|
78
|
+
let stored: boolean | null = null
|
|
79
|
+
try { stored = settings ? settings.stored().enabled : null } catch { stored = null }
|
|
80
|
+
if (stored !== null) return { enabled: stored, source: 'setting' }
|
|
81
|
+
return { enabled: false, source: 'default' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveCleanupModel(env: NodeJS.ProcessEnv, settings: TranscriptCleanupSettings | null): { choice: CleanupModelChoice; id: string; source: CleanupSettingSource } {
|
|
85
|
+
const raw = (env.COS_TRANSCRIPT_CLEANUP_MODEL ?? '').trim().toLowerCase()
|
|
86
|
+
if (raw === 'haiku' || raw === 'sonnet') return { choice: raw, id: CLEANUP_MODEL_IDS[raw], source: 'env' }
|
|
87
|
+
let stored: CleanupModelChoice | null = null
|
|
88
|
+
try { stored = settings ? settings.stored().model : null } catch { stored = null }
|
|
89
|
+
if (stored) return { choice: stored, id: CLEANUP_MODEL_IDS[stored], source: 'setting' }
|
|
90
|
+
return { choice: 'haiku', id: CLEANUP_MODEL_IDS.haiku, source: 'default' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function intEnv(env: NodeJS.ProcessEnv, name: string, fallback: number, max: number): number {
|
|
94
|
+
const raw = Number(env[name])
|
|
95
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.min(Math.floor(raw), max) : fallback
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface CleanupLimits { dailyCalls: number; dailyTokens: number; maxSpans: number; breakerFailures: number; breakerCooldownMs: number; limitCooldownMs: number }
|
|
99
|
+
export function cleanupLimits(env: NodeJS.ProcessEnv = process.env): CleanupLimits {
|
|
100
|
+
return {
|
|
101
|
+
dailyCalls: intEnv(env, 'COS_TRANSCRIPT_CLEANUP_DAILY', 30, 500),
|
|
102
|
+
dailyTokens: intEnv(env, 'COS_TRANSCRIPT_CLEANUP_DAILY_TOKENS', 400_000, 10_000_000),
|
|
103
|
+
maxSpans: Math.max(1, intEnv(env, 'COS_TRANSCRIPT_CLEANUP_MAX_SPANS', SPAN_LIMITS.defaultMaxSpans, SPAN_LIMITS.hardMaxSpans)),
|
|
104
|
+
breakerFailures: 3,
|
|
105
|
+
breakerCooldownMs: 30 * 60_000,
|
|
106
|
+
limitCooldownMs: 60 * 60_000,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface BudgetState {
|
|
111
|
+
day: string
|
|
112
|
+
calls: number
|
|
113
|
+
tokens: number
|
|
114
|
+
consecutiveFailures: number
|
|
115
|
+
breakerOpenUntil: number | null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function localDay(now: Date): string {
|
|
119
|
+
return now.toLocaleDateString('en-CA')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The day's spend and the breaker. One small JSON file, rewritten atomically on every change. */
|
|
123
|
+
export class TranscriptCleanupBudget {
|
|
124
|
+
constructor(readonly file: string = cleanupDir('state.json'), private readonly now: () => Date = () => new Date()) {}
|
|
125
|
+
|
|
126
|
+
read(): BudgetState {
|
|
127
|
+
const day = localDay(this.now())
|
|
128
|
+
const spent: BudgetState = { day, calls: Number.MAX_SAFE_INTEGER, tokens: Number.MAX_SAFE_INTEGER, consecutiveFailures: 0, breakerOpenUntil: null }
|
|
129
|
+
try {
|
|
130
|
+
if (!existsSync(this.file)) return { day, calls: 0, tokens: 0, consecutiveFailures: 0, breakerOpenUntil: null }
|
|
131
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<BudgetState>
|
|
132
|
+
if (!raw || typeof raw !== 'object' || typeof raw.day !== 'string') return spent
|
|
133
|
+
const failures = Number(raw.consecutiveFailures ?? 0)
|
|
134
|
+
const openUntil = raw.breakerOpenUntil === null || raw.breakerOpenUntil === undefined ? null : Number(raw.breakerOpenUntil)
|
|
135
|
+
const breaker = { consecutiveFailures: Number.isFinite(failures) && failures >= 0 ? failures : 0, breakerOpenUntil: openUntil !== null && Number.isFinite(openUntil) ? openUntil : null }
|
|
136
|
+
// A future day key or a negative count fails toward NOT spending.
|
|
137
|
+
if (raw.day > day) return { ...spent, ...breaker }
|
|
138
|
+
if (raw.day < day) return { day, calls: 0, tokens: 0, ...breaker }
|
|
139
|
+
const calls = Number(raw.calls), tokens = Number(raw.tokens)
|
|
140
|
+
if (!(calls >= 0) || !(tokens >= 0)) return { ...spent, ...breaker }
|
|
141
|
+
return { day, calls, tokens, ...breaker }
|
|
142
|
+
} catch {
|
|
143
|
+
return spent
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private write(state: BudgetState): void {
|
|
148
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
149
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify(state) + '\n', { mode: 0o600 })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Why a call may not run now, or null. Read-only. */
|
|
153
|
+
gate(limits: CleanupLimits): 'daily_calls' | 'daily_tokens' | 'breaker_open' | null {
|
|
154
|
+
const s = this.read()
|
|
155
|
+
if (s.breakerOpenUntil !== null && this.now().getTime() < s.breakerOpenUntil) return 'breaker_open'
|
|
156
|
+
if (s.calls >= limits.dailyCalls) return 'daily_calls'
|
|
157
|
+
if (s.tokens >= limits.dailyTokens) return 'daily_tokens'
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Charge one call BEFORE it runs. False when the day is spent. */
|
|
162
|
+
charge(limits: CleanupLimits): boolean {
|
|
163
|
+
const s = this.read()
|
|
164
|
+
if (s.calls >= limits.dailyCalls || s.tokens >= limits.dailyTokens) return false
|
|
165
|
+
this.write({ ...s, calls: s.calls + 1 })
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
recordUsage(tokens: number): void {
|
|
170
|
+
const s = this.read()
|
|
171
|
+
if (s.tokens === Number.MAX_SAFE_INTEGER) return
|
|
172
|
+
this.write({ ...s, tokens: s.tokens + Math.max(0, Math.round(tokens)) })
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
recordSuccess(): void {
|
|
176
|
+
const s = this.read()
|
|
177
|
+
if (s.consecutiveFailures === 0 && s.breakerOpenUntil === null) return
|
|
178
|
+
this.write({ ...s, consecutiveFailures: 0, breakerOpenUntil: null })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Returns true when this failure opened the breaker. */
|
|
182
|
+
recordFailure(limits: CleanupLimits, providerLimit: boolean): boolean {
|
|
183
|
+
const s = this.read()
|
|
184
|
+
const failures = s.consecutiveFailures + 1
|
|
185
|
+
const open = providerLimit || failures >= limits.breakerFailures
|
|
186
|
+
const until = open ? this.now().getTime() + (providerLimit ? limits.limitCooldownMs : limits.breakerCooldownMs) : s.breakerOpenUntil
|
|
187
|
+
this.write({ ...s, consecutiveFailures: failures, breakerOpenUntil: until })
|
|
188
|
+
return open
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let settingsSingleton: TranscriptCleanupSettings | null = null
|
|
193
|
+
let budgetSingleton: TranscriptCleanupBudget | null = null
|
|
194
|
+
export function transcriptCleanupSettings(): TranscriptCleanupSettings {
|
|
195
|
+
if (!settingsSingleton) settingsSingleton = new TranscriptCleanupSettings()
|
|
196
|
+
return settingsSingleton
|
|
197
|
+
}
|
|
198
|
+
export function transcriptCleanupBudget(): TranscriptCleanupBudget {
|
|
199
|
+
if (!budgetSingleton) budgetSingleton = new TranscriptCleanupBudget()
|
|
200
|
+
return budgetSingleton
|
|
201
|
+
}
|