@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,126 @@
|
|
|
1
|
+
// Dictionary suggestions from transcript cleanup (6.77.0).
|
|
2
|
+
//
|
|
3
|
+
// When a cleanup edit is accepted and what it wrote is a term this meeting's CONTEXT
|
|
4
|
+
// supplied (a screen line, a dropped file, the owner card, COS people, a notes hit) and
|
|
5
|
+
// the dictionary does not already hold it, COS records a SUGGESTION: the written form,
|
|
6
|
+
// the heard forms, and where the evidence came from. Never an automatic addition. A
|
|
7
|
+
// person accepts it (the word joins the profile vocabulary through the glossary's own
|
|
8
|
+
// writer, optionally with its heard form as a logged, undoable correction) or dismisses
|
|
9
|
+
// it (that written form is never suggested again).
|
|
10
|
+
//
|
|
11
|
+
// One file, `transcript-cleanup/suggestions.json`, rewritten atomically. Deduped by the
|
|
12
|
+
// written form, case-insensitive. Bounded: 200 suggestions, 5 heard forms and 5 evidence
|
|
13
|
+
// rows each; accepted and dismissed ones are evicted before pending ones. Evidence rows
|
|
14
|
+
// carry ids, a file or app name and a time, never transcript or screen text.
|
|
15
|
+
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
import { randomBytes } from 'node:crypto'
|
|
19
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
20
|
+
import { cleanupDir } from './transcript-cleanup-screen.js'
|
|
21
|
+
import type { EvidenceSource } from './transcript-cleanup-context.js'
|
|
22
|
+
|
|
23
|
+
export const SUGGESTION_LIMITS = { suggestions: 200, heard: 5, evidence: 5 } as const
|
|
24
|
+
export const SUGGESTION_ID = /^sug_[0-9a-f]{16}$/
|
|
25
|
+
|
|
26
|
+
export interface SuggestionEvidence {
|
|
27
|
+
source: EvidenceSource
|
|
28
|
+
sessionId: string
|
|
29
|
+
heard: string
|
|
30
|
+
at: string
|
|
31
|
+
detail: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DictionarySuggestion {
|
|
35
|
+
id: string
|
|
36
|
+
written: string
|
|
37
|
+
heard: string[]
|
|
38
|
+
status: 'pending' | 'accepted' | 'dismissed'
|
|
39
|
+
count: number
|
|
40
|
+
createdAt: string
|
|
41
|
+
updatedAt: string
|
|
42
|
+
evidence: SuggestionEvidence[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const same = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase()
|
|
46
|
+
|
|
47
|
+
export class SuggestionStore {
|
|
48
|
+
constructor(readonly file: string = cleanupDir('suggestions.json')) {}
|
|
49
|
+
|
|
50
|
+
list(): DictionarySuggestion[] {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(this.file)) return []
|
|
53
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as { suggestions?: unknown }
|
|
54
|
+
if (!raw || !Array.isArray(raw.suggestions)) return []
|
|
55
|
+
return raw.suggestions.filter((s): s is DictionarySuggestion => !!s && typeof s === 'object'
|
|
56
|
+
&& typeof (s as DictionarySuggestion).id === 'string' && typeof (s as DictionarySuggestion).written === 'string'
|
|
57
|
+
&& ['pending', 'accepted', 'dismissed'].includes((s as DictionarySuggestion).status)
|
|
58
|
+
&& Array.isArray((s as DictionarySuggestion).heard) && Array.isArray((s as DictionarySuggestion).evidence))
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private write(list: DictionarySuggestion[]): void {
|
|
65
|
+
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 })
|
|
66
|
+
durableAtomicWriteFileSync(this.file, JSON.stringify({ schema: 1, suggestions: list }, null, 2) + '\n', { mode: 0o600 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get(id: string): DictionarySuggestion | null {
|
|
70
|
+
return this.list().find(s => s.id === id) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Record one piece of evidence for a written form. Returns the suggestion, or null when
|
|
75
|
+
* nothing was recorded: no evidence, the form is already in the dictionary, or it was
|
|
76
|
+
* dismissed before.
|
|
77
|
+
*/
|
|
78
|
+
record(input: { written: string; heard: string; evidence: Array<{ source: EvidenceSource; detail: string | null }>; sessionId: string; at: string; inDictionary: (written: string) => boolean }): DictionarySuggestion | null {
|
|
79
|
+
const written = input.written.replace(/\s+/g, ' ').trim()
|
|
80
|
+
if (!written || written.length > 100 || !input.evidence.length) return null
|
|
81
|
+
if (input.inDictionary(written)) return null
|
|
82
|
+
const list = this.list()
|
|
83
|
+
const existing = list.find(s => same(s.written, written))
|
|
84
|
+
if (existing && existing.status !== 'pending') return null
|
|
85
|
+
const rows: SuggestionEvidence[] = input.evidence.map(e => ({ source: e.source, sessionId: input.sessionId, heard: input.heard.slice(0, 100), at: input.at, detail: e.detail }))
|
|
86
|
+
let out: DictionarySuggestion
|
|
87
|
+
if (existing) {
|
|
88
|
+
if (!existing.heard.some(h => same(h, input.heard)) && existing.heard.length < SUGGESTION_LIMITS.heard) existing.heard.push(input.heard.slice(0, 100))
|
|
89
|
+
for (const row of rows) {
|
|
90
|
+
const dup = existing.evidence.some(e => e.source === row.source && e.sessionId === row.sessionId && same(e.heard, row.heard))
|
|
91
|
+
if (!dup && existing.evidence.length < SUGGESTION_LIMITS.evidence) existing.evidence.push(row)
|
|
92
|
+
}
|
|
93
|
+
existing.count += 1
|
|
94
|
+
existing.updatedAt = input.at
|
|
95
|
+
out = existing
|
|
96
|
+
} else {
|
|
97
|
+
out = { id: `sug_${randomBytes(8).toString('hex')}`, written, heard: [input.heard.slice(0, 100)], status: 'pending', count: 1, createdAt: input.at, updatedAt: input.at, evidence: rows.slice(0, SUGGESTION_LIMITS.evidence) }
|
|
98
|
+
list.push(out)
|
|
99
|
+
}
|
|
100
|
+
this.write(evict(list))
|
|
101
|
+
return out
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setStatus(id: string, status: 'accepted' | 'dismissed', at: string): DictionarySuggestion | null {
|
|
105
|
+
const list = this.list()
|
|
106
|
+
const s = list.find(x => x.id === id)
|
|
107
|
+
if (!s) return null
|
|
108
|
+
s.status = status
|
|
109
|
+
s.updatedAt = at
|
|
110
|
+
this.write(list)
|
|
111
|
+
return s
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function evict(list: DictionarySuggestion[]): DictionarySuggestion[] {
|
|
116
|
+
if (list.length <= SUGGESTION_LIMITS.suggestions) return list
|
|
117
|
+
const ordered = [...list].sort((a, b) => (a.status === 'pending' ? 1 : 0) - (b.status === 'pending' ? 1 : 0) || a.updatedAt.localeCompare(b.updatedAt))
|
|
118
|
+
const drop = new Set(ordered.slice(0, list.length - SUGGESTION_LIMITS.suggestions).map(s => s.id))
|
|
119
|
+
return list.filter(s => !drop.has(s.id))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let singleton: SuggestionStore | null = null
|
|
123
|
+
export function suggestionStore(): SuggestionStore {
|
|
124
|
+
if (!singleton) singleton = new SuggestionStore()
|
|
125
|
+
return singleton
|
|
126
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): the text model every other cleanup module shares.
|
|
2
|
+
//
|
|
3
|
+
// A saved meeting's `## Transcript` section is lines of `[Label]: words`. Cleanup only
|
|
4
|
+
// ever edits the WORDS of a line, never its label, never a line it cannot parse, and
|
|
5
|
+
// never across a line break: a speaker turn is the largest unit an edit can touch.
|
|
6
|
+
//
|
|
7
|
+
// Tokens are whitespace runs inside a line's text. Each carries its `core`: the token
|
|
8
|
+
// without leading and trailing punctuation, which is what is compared, flagged and
|
|
9
|
+
// replaced, so "Ukoma," becomes "Ukaoma," and the comma stays exactly where it was.
|
|
10
|
+
//
|
|
11
|
+
// No IO and no model here.
|
|
12
|
+
|
|
13
|
+
import { levenshtein } from './fuzzy-correct.js'
|
|
14
|
+
|
|
15
|
+
export interface TranscriptLine {
|
|
16
|
+
index: number
|
|
17
|
+
/** `[Label]: ` including the trailing space, or '' for a line without a label. */
|
|
18
|
+
prefix: string
|
|
19
|
+
label: string | null
|
|
20
|
+
text: string
|
|
21
|
+
/** False for blank lines and anything that is not a speaker turn. Never edited. */
|
|
22
|
+
editable: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Token {
|
|
26
|
+
/** Position in the flat token list. */
|
|
27
|
+
i: number
|
|
28
|
+
line: number
|
|
29
|
+
/** Offsets of the core inside `line.text`. */
|
|
30
|
+
start: number
|
|
31
|
+
end: number
|
|
32
|
+
raw: string
|
|
33
|
+
core: string
|
|
34
|
+
key: string
|
|
35
|
+
/** First token of its line, or after a sentence end. */
|
|
36
|
+
sentenceStart: boolean
|
|
37
|
+
probability?: number
|
|
38
|
+
/** Epoch ms of the word, when the batch words could be aligned. */
|
|
39
|
+
atMs?: number
|
|
40
|
+
engine?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const LINE = /^\[([^\]\n]{1,80})\]: ?(.*)$/
|
|
44
|
+
|
|
45
|
+
export function parseTranscriptLines(section: string): TranscriptLine[] {
|
|
46
|
+
return section.split('\n').map((raw, index) => {
|
|
47
|
+
const m = LINE.exec(raw)
|
|
48
|
+
if (!m) return { index, prefix: '', label: null, text: raw, editable: false }
|
|
49
|
+
const prefix = raw.slice(0, raw.length - m[2].length)
|
|
50
|
+
return { index, prefix, label: m[1], text: m[2], editable: m[2].trim().length > 0 }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function joinTranscriptLines(lines: readonly TranscriptLine[]): string {
|
|
55
|
+
return lines.map(line => line.prefix + line.text).join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Letters and digits only, accents folded, lowercase. The comparison form of a word. */
|
|
59
|
+
export function fold(word: string): string {
|
|
60
|
+
return word.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EDGE = /^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu
|
|
64
|
+
|
|
65
|
+
export function tokenize(lines: readonly TranscriptLine[]): Token[] {
|
|
66
|
+
const out: Token[] = []
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
if (!line.editable) continue
|
|
69
|
+
let sentenceStart = true
|
|
70
|
+
for (const m of line.text.matchAll(/\S+/g)) {
|
|
71
|
+
const raw = m[0]
|
|
72
|
+
const at = m.index ?? 0
|
|
73
|
+
const lead = /^[^\p{L}\p{N}]*/u.exec(raw)![0].length
|
|
74
|
+
const core = raw.replace(EDGE, '')
|
|
75
|
+
const start = at + lead
|
|
76
|
+
out.push({ i: out.length, line: line.index, start, end: start + core.length, raw, core, key: fold(core), sentenceStart })
|
|
77
|
+
sentenceStart = /[.!?]["')\]]*$/.test(raw)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One spoken word from the batch decode, placed on the meeting clock. */
|
|
84
|
+
export interface WordInfo {
|
|
85
|
+
word: string
|
|
86
|
+
probability?: number
|
|
87
|
+
atMs?: number
|
|
88
|
+
engine?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The batch words of a saved meeting, in transcript order, from its `.g2-chunks.json`
|
|
93
|
+
* sidecar. A segment's word times are seconds from that window's start; the window
|
|
94
|
+
* starts at `startElapsed` on the meeting clock (the same convention
|
|
95
|
+
* mapWordsToSpeakers uses), and the meeting started at `startTime` (epoch ms).
|
|
96
|
+
*/
|
|
97
|
+
export function wordInfoFromSidecar(sidecar: Record<string, unknown>): WordInfo[] {
|
|
98
|
+
const segments = Array.isArray(sidecar.batchSegments) ? sidecar.batchSegments : []
|
|
99
|
+
const startTime = typeof sidecar.startTime === 'number' && Number.isFinite(sidecar.startTime) ? sidecar.startTime : null
|
|
100
|
+
const out: WordInfo[] = []
|
|
101
|
+
for (const raw of segments) {
|
|
102
|
+
if (!raw || typeof raw !== 'object') continue
|
|
103
|
+
const segment = raw as { words?: unknown; startElapsed?: unknown; stt?: { engine?: unknown } }
|
|
104
|
+
const words = Array.isArray(segment.words) ? segment.words : []
|
|
105
|
+
const startElapsed = typeof segment.startElapsed === 'number' && Number.isFinite(segment.startElapsed) ? segment.startElapsed : null
|
|
106
|
+
const engine = typeof segment.stt?.engine === 'string' ? segment.stt.engine : undefined
|
|
107
|
+
for (const w of words) {
|
|
108
|
+
if (!w || typeof w !== 'object') continue
|
|
109
|
+
const word = typeof (w as { word?: unknown }).word === 'string' ? (w as { word: string }).word.trim() : ''
|
|
110
|
+
if (!word) continue
|
|
111
|
+
const p = (w as { probability?: unknown }).probability
|
|
112
|
+
const s = (w as { start?: unknown }).start
|
|
113
|
+
const atMs = startTime !== null && startElapsed !== null && typeof s === 'number' && Number.isFinite(s)
|
|
114
|
+
? Math.round(startTime + startElapsed + s * 1000) : undefined
|
|
115
|
+
out.push({
|
|
116
|
+
word,
|
|
117
|
+
...(typeof p === 'number' && Number.isFinite(p) ? { probability: p } : {}),
|
|
118
|
+
...(atMs !== undefined ? { atMs } : {}),
|
|
119
|
+
...(engine ? { engine } : {}),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** How far ahead the aligner looks for a transcript token among the batch words. */
|
|
127
|
+
export const ALIGN_LOOKAHEAD = 40
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Attach probability, time and engine to the tokens the batch words produced.
|
|
131
|
+
*
|
|
132
|
+
* The saved transcript IS those words (selectBatchTranscriptForPersistence joins
|
|
133
|
+
* them; the hallucination line filter may drop a line; Nemotron only relabels), so
|
|
134
|
+
* a greedy forward match finds them in order. A token with no match within the
|
|
135
|
+
* lookahead stays unaligned: it simply carries no confidence, which can only make
|
|
136
|
+
* cleanup look at it less, never more.
|
|
137
|
+
*/
|
|
138
|
+
export function alignWordInfo(tokens: Token[], words: readonly WordInfo[]): number {
|
|
139
|
+
let j = 0
|
|
140
|
+
let aligned = 0
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
const limit = Math.min(words.length, j + ALIGN_LOOKAHEAD)
|
|
143
|
+
let hit = -1
|
|
144
|
+
for (let k = j; k < limit; k++) {
|
|
145
|
+
if (words[k].word === token.raw || fold(words[k].word) === token.key) { hit = k; break }
|
|
146
|
+
}
|
|
147
|
+
if (hit < 0) continue
|
|
148
|
+
const w = words[hit]
|
|
149
|
+
if (w.probability !== undefined) token.probability = w.probability
|
|
150
|
+
if (w.atMs !== undefined) token.atMs = w.atMs
|
|
151
|
+
if (w.engine) token.engine = w.engine
|
|
152
|
+
aligned++
|
|
153
|
+
j = hit + 1
|
|
154
|
+
}
|
|
155
|
+
return aligned
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A consonant skeleton: how a word sounds with its vowels unsure. Ukaoma, Yukoma,
|
|
160
|
+
* eucoma and "U coma" all read "km". Only ever used together with an onset check and
|
|
161
|
+
* a length bound, because a two-letter skeleton alone matches far too much.
|
|
162
|
+
*/
|
|
163
|
+
export function skeleton(word: string): string {
|
|
164
|
+
let s = fold(word).replace(/[0-9]/g, '')
|
|
165
|
+
s = s.replace(/ph/g, 'f').replace(/ck/g, 'k').replace(/c(?=[eiy])/g, 's').replace(/c/g, 'k').replace(/q/g, 'k')
|
|
166
|
+
.replace(/x/g, 'ks').replace(/z/g, 's').replace(/dg/g, 'j')
|
|
167
|
+
s = s.replace(/[aeiouy]/g, '')
|
|
168
|
+
return s.replace(/(.)\1+/g, '$1')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Vowel onset, counting a leading y before a vowel ("Yukoma") as a vowel. */
|
|
172
|
+
export function vowelOnset(word: string): boolean {
|
|
173
|
+
const f = fold(word)
|
|
174
|
+
return /^[aeiou]/.test(f) || /^y[aeiou]/.test(f)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function maxFuzzyDistance(length: number): number {
|
|
178
|
+
return length <= 8 ? 1 : length <= 11 ? 2 : 3
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** True when two folded words sound alike under the skeleton rule. */
|
|
182
|
+
export function soundsAlike(a: string, b: string): boolean {
|
|
183
|
+
const fa = fold(a), fb = fold(b)
|
|
184
|
+
if (!fa || !fb || Math.abs(fa.length - fb.length) > 2) return false
|
|
185
|
+
const sa = skeleton(fa), sb = skeleton(fb)
|
|
186
|
+
if (sa.length < 2 || sa !== sb) return false
|
|
187
|
+
if (vowelOnset(fa) !== vowelOnset(fb)) return false
|
|
188
|
+
const d = levenshtein(fa, fb)
|
|
189
|
+
return d <= Math.max(maxFuzzyDistance(Math.max(fa.length, fb.length)) + 2, Math.ceil(Math.max(fa.length, fb.length) * 0.5))
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Edit distance small enough that two words are plausibly one word misheard. */
|
|
193
|
+
export function closeSpelling(a: string, b: string): boolean {
|
|
194
|
+
const fa = fold(a), fb = fold(b)
|
|
195
|
+
if (!fa || !fb) return false
|
|
196
|
+
if (fa === fb) return true
|
|
197
|
+
const longest = Math.max(fa.length, fb.length)
|
|
198
|
+
return levenshtein(fa, fb) <= Math.max(1, Math.floor(longest / 3))
|
|
199
|
+
}
|
|
200
|
+
|