@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.
@@ -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
+