@gotcos/glasses-server 6.78.2 → 6.79.1
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 +75 -0
- package/README.md +28 -0
- package/managed-runtime-contract.json +10 -1
- package/package.json +4 -1
- package/server/lib/dictation-clean.ts +64 -11
- package/server/lib/dictation-fillers.ts +109 -0
- package/server/lib/dictation-polish-budget.ts +87 -0
- package/server/lib/live-cue-verdicts.ts +413 -0
- package/server/lib/live-cues-filter.ts +72 -0
- package/server/lib/live-cues-v2.ts +74 -2
- package/server/routes/live-cues.ts +79 -4
- package/server/routes/prompt-drafts.ts +27 -33
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// GET /live-cues/status -> engine snapshot + capability
|
|
7
7
|
// GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
|
|
8
8
|
// POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
|
|
9
|
+
// POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (6.79.0)
|
|
10
|
+
// GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 -> { v: 1, cues, counts, truncated, skipped, maxCuesPerMeeting, meetings? } (6.79.0)
|
|
9
11
|
|
|
10
12
|
import { Router } from 'express'
|
|
11
13
|
import { errMsg } from '../lib/utils.js'
|
|
@@ -16,7 +18,8 @@ import {
|
|
|
16
18
|
LiveCuesArmError,
|
|
17
19
|
} from '../lib/live-cues-engine.js'
|
|
18
20
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
19
|
-
import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
21
|
+
import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
22
|
+
import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
|
|
20
23
|
|
|
21
24
|
const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
22
25
|
const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
|
|
@@ -80,6 +83,8 @@ liveCuesRouter.get('/live-cues/status', (req, res) => {
|
|
|
80
83
|
capability: liveCuesCapability(),
|
|
81
84
|
sessions: getLiveCuesStatus(),
|
|
82
85
|
...(liveCuesV2Advertised() ? { v2: liveCuesV2Advertised() } : {}),
|
|
86
|
+
// 6.79.0: always, so Control can rate past cues with v2 switched off.
|
|
87
|
+
verdicts: { protocol: LIVE_CUE_VERDICTS_PROTOCOL },
|
|
83
88
|
})
|
|
84
89
|
})
|
|
85
90
|
|
|
@@ -95,17 +100,87 @@ liveCuesRouter.post('/live-cues/pause', (req, res) => {
|
|
|
95
100
|
return res.json(liveCuesV2().setPaused(sessionId, paused, seq as number | undefined))
|
|
96
101
|
})
|
|
97
102
|
|
|
98
|
-
// POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue }
|
|
103
|
+
// POST /live-cues/feedback { sessionId, cueId, vote: 'up'|'down', reason? } -> { ok, knownCue } (the lens; unchanged)
|
|
104
|
+
// POST /live-cues/feedback { sessionId, cueId, verdict, note? } -> { ok, cueId, verdict, vote } (COS Control, 6.79.0)
|
|
99
105
|
liveCuesRouter.post('/live-cues/feedback', (req, res) => {
|
|
100
|
-
const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown }
|
|
106
|
+
const body = (req.body ?? {}) as { cueId?: unknown; vote?: unknown; reason?: unknown; verdict?: unknown; note?: unknown }
|
|
101
107
|
const sessionId = bodySessionId(req.body)
|
|
102
108
|
const cueId = typeof body.cueId === 'string' ? body.cueId.trim() : ''
|
|
109
|
+
if (body.verdict !== undefined) {
|
|
110
|
+
// Exactly one of vote and verdict: a body carrying both is ambiguous, so neither is applied.
|
|
111
|
+
if (body.vote !== undefined || !SESSION_ID.test(sessionId) || !CUE_ID.test(cueId)
|
|
112
|
+
|| !(isCueVerdict(body.verdict) || body.verdict === 'clear') || (body.note !== undefined && typeof body.note !== 'string')) {
|
|
113
|
+
return res.status(400).json({ error: 'sessionId, cueId and verdict (useful|late|repeat|wrong|obvious|clear) required, without vote', reason: 'invalid_request' })
|
|
114
|
+
}
|
|
115
|
+
// Before the v2 gate: last week's cues are rated from the log. peek, never liveCuesV2(): a v2-off server must not build
|
|
116
|
+
// the engine (it constructs the Jev and SubQ clients) just to answer a rating.
|
|
117
|
+
try {
|
|
118
|
+
const result = liveCueVerdicts().rate({ sessionId, cueId, verdict: body.verdict, note: body.note as string | undefined }, liveCueOwner(cueId))
|
|
119
|
+
if (!result.ok && result.error === 'unknown_cue') return res.status(404).json({ error: 'unknown cue', reason: result.error })
|
|
120
|
+
if (!result.ok) return res.status(409).json({ error: 'this cue belongs to another meeting', reason: result.error })
|
|
121
|
+
return res.json(result.response)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
|
|
124
|
+
return res.status(500).json({ error: 'verdict_write_failed' })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
103
127
|
if (!SESSION_ID.test(sessionId) || !CUE_ID.test(cueId) || (body.vote !== 'up' && body.vote !== 'down')
|
|
104
128
|
|| (body.reason !== undefined && typeof body.reason !== 'string')) {
|
|
105
129
|
return res.status(400).json({ error: 'sessionId, cueId and vote (up|down) required', reason: 'invalid_request' })
|
|
106
130
|
}
|
|
107
131
|
if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
|
|
108
|
-
|
|
132
|
+
const result = liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined })
|
|
133
|
+
// Kept beside any verdict, never over it, for a cue known under this meeting. Best effort: the lens answer is the same.
|
|
134
|
+
try { liveCueVerdicts().vote(sessionId, cueId, body.vote, liveCueOwner(cueId)) } catch (error) { console.warn(`[live-cues] lens vote not stored: ${errMsg(error)}`) }
|
|
135
|
+
return res.json(result)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/** The meeting the running engine holds this cue for, if an engine exists. Never creates one. */
|
|
139
|
+
function liveCueOwner(cueId: string): string | null {
|
|
140
|
+
return peekLiveCuesV2()?.findCue(cueId)?.sessionId ?? null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// GET /live-cues/history?days=7&sessionId=a,b&unrated=1&limit=200 (6.79.0). Oldest first, the newest `limit` rows; counts
|
|
144
|
+
// cover the window after the sessionId filter. Not gated on the v2 switch, and no maintenance lease (a 15 s poll must
|
|
145
|
+
// never hold up an update drain). `meetings` (only with sessionId) are the durable counters, so Control never derives them.
|
|
146
|
+
function intParam(raw: unknown, fallback: number, min: number, max: number): number | null {
|
|
147
|
+
if (raw === undefined) return fallback
|
|
148
|
+
if (typeof raw !== 'string' || !/^\d{1,4}$/.test(raw)) return null
|
|
149
|
+
const n = Number(raw)
|
|
150
|
+
return n >= min && n <= max ? n : null
|
|
151
|
+
}
|
|
152
|
+
/** Absent or empty: no filter ([]). Else up to 5 comma-separated ids (a saved meeting can hold several captures). */
|
|
153
|
+
function sessionIdsParam(raw: unknown): string[] | null {
|
|
154
|
+
if (raw === undefined) return []
|
|
155
|
+
if (typeof raw !== 'string') return null
|
|
156
|
+
if (!raw.trim()) return []
|
|
157
|
+
const ids = [...new Set(raw.split(',').map(x => x.trim()))]
|
|
158
|
+
return ids.length <= LIVE_CUE_VERDICT_LIMITS.maxSessionIds && ids.every(id => SESSION_ID.test(id)) ? ids : null
|
|
159
|
+
}
|
|
160
|
+
liveCuesRouter.get('/live-cues/history', (req, res) => {
|
|
161
|
+
res.set('Cache-Control', 'private, no-store')
|
|
162
|
+
const L = LIVE_CUE_VERDICT_LIMITS
|
|
163
|
+
const days = intParam(req.query.days, L.defaultDays, 1, L.maxDays)
|
|
164
|
+
const limit = intParam(req.query.limit, L.defaultLimit, 1, L.maxLimit)
|
|
165
|
+
const sessionIds = sessionIdsParam(req.query.sessionId)
|
|
166
|
+
const unrated = req.query.unrated
|
|
167
|
+
if (days === null || limit === null || sessionIds === null || (unrated !== undefined && unrated !== '1' && unrated !== '0')) {
|
|
168
|
+
return res.status(400).json({ error: 'days 1..30, limit 1..1000, unrated 0|1 and up to 5 valid sessionIds', reason: 'invalid_request' })
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const verdicts = liveCueVerdicts()
|
|
172
|
+
const history = verdicts.history({ days, limit, unrated: unrated === '1', ...(sessionIds.length ? { sessionIds } : {}) })
|
|
173
|
+
const C = LIVE_CUES_V2_LIMITS
|
|
174
|
+
const meetings = sessionIds.length ? Object.fromEntries(sessionIds.map(id => {
|
|
175
|
+
// The engine's counters expire 24 h after a meeting; past that the log's count stands in and writerCalls is unknown.
|
|
176
|
+
const counters = liveCuesV2MeetingCounters(id)
|
|
177
|
+
return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: C.maxCuesPerMeeting, writerCalls: counters?.writerCalls ?? null, writerCap: C.maxWriterCallsPerMeeting }]
|
|
178
|
+
})) : undefined
|
|
179
|
+
return res.json({ ...history, maxCuesPerMeeting: C.maxCuesPerMeeting, ...(meetings ? { meetings } : {}) })
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
|
|
182
|
+
return res.status(500).json({ error: 'history_unavailable' })
|
|
183
|
+
}
|
|
109
184
|
})
|
|
110
185
|
|
|
111
186
|
// COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
|
|
@@ -2,8 +2,7 @@ import { isDictationCleanModel, type DictationCleanModel } from '../lib/dictatio
|
|
|
2
2
|
import { Router } from 'express'
|
|
3
3
|
import { createHash } from 'node:crypto'
|
|
4
4
|
import type { Response } from 'express'
|
|
5
|
-
import {
|
|
6
|
-
import { resolve, dirname } from 'node:path'
|
|
5
|
+
import { resolve } from 'node:path'
|
|
7
6
|
import {
|
|
8
7
|
createPromptDraft,
|
|
9
8
|
loadPromptDraftMeta,
|
|
@@ -43,11 +42,11 @@ import { transcribeWhisperPreview } from '../lib/whisper-preview.js'
|
|
|
43
42
|
import { currentDictationPlan, runParakeetDictation } from '../lib/dictation-stt.js'
|
|
44
43
|
import { dictationSlotForMode, legacyDictationKey, type DictationPlan } from '../lib/stt-settings.js'
|
|
45
44
|
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from '../lib/dictation-clean.js'
|
|
45
|
+
import { fillerPassEnabled, stripDisfluencies } from '../lib/dictation-fillers.js'
|
|
46
|
+
import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from '../lib/dictation-polish-budget.js'
|
|
46
47
|
import { getVocabulary } from '../lib/profile.js'
|
|
47
48
|
import { createBreaker } from '../lib/claude-circuit.js'
|
|
48
49
|
import { logTokenAudit } from '../lib/token-audit.js'
|
|
49
|
-
import { atomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
50
|
-
import { dataPath } from '../lib/data-dir.js'
|
|
51
50
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
52
51
|
import {
|
|
53
52
|
acquireMaintenanceWork,
|
|
@@ -83,26 +82,10 @@ const autoCleanBreakers = {
|
|
|
83
82
|
'luna-5.6-fast': createBreaker({ label: 'dictation-autoclean-luna' }),
|
|
84
83
|
haiku: createBreaker({ label: 'dictation-autoclean-haiku' }),
|
|
85
84
|
}
|
|
86
|
-
const autoCleanCountFile = () => process.env.COS_DICTATION_AUTOCLEAN_COUNT_FILE || dataPath('.dictation_autoclean_count.json')
|
|
87
85
|
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
function autoCleanCountToday(): number {
|
|
93
|
-
try {
|
|
94
|
-
const raw = JSON.parse(readFileSync(autoCleanCountFile(), 'utf-8'))
|
|
95
|
-
if (raw?.date === new Date().toISOString().slice(0, 10) && Number.isFinite(raw?.count)) return raw.count
|
|
96
|
-
} catch {}
|
|
97
|
-
return 0
|
|
98
|
-
}
|
|
99
|
-
function recordAutoCleanCall(): void {
|
|
100
|
-
try {
|
|
101
|
-
const file = autoCleanCountFile()
|
|
102
|
-
if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
|
|
103
|
-
atomicWriteFileSync(file, JSON.stringify({ date: new Date().toISOString().slice(0, 10), count: autoCleanCountToday() + 1 }), { mode: 0o600 })
|
|
104
|
-
} catch {}
|
|
105
|
-
}
|
|
86
|
+
|
|
87
|
+
/** 6.79.1, additive on finalize answers: why the polish model did not run (or did not finish). */
|
|
88
|
+
export type PolishSkipped = 'disabled' | 'too_long' | 'breaker_open' | 'daily_cap' | 'failed'
|
|
106
89
|
|
|
107
90
|
interface AutoCleanRequest { enabled?: boolean; model?: DictationCleanModel }
|
|
108
91
|
function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
@@ -114,7 +97,10 @@ function routeAutoClean(req: { body?: any; query?: any }): AutoCleanRequest {
|
|
|
114
97
|
return { enabled, model: isDictationCleanModel(rawModel) ? rawModel : undefined }
|
|
115
98
|
}
|
|
116
99
|
|
|
117
|
-
async function cleanOutboundDictation(
|
|
100
|
+
async function cleanOutboundDictation(
|
|
101
|
+
text: string,
|
|
102
|
+
opts: AutoCleanRequest & { bucket: PolishBucket; signal?: AbortSignal },
|
|
103
|
+
): Promise<{ text: string; polishSkipped?: PolishSkipped }> {
|
|
118
104
|
let cleaned = applyNegativeRules(applyCorrections(text)).replace(/\s+/g, ' ').trim() || text
|
|
119
105
|
// applyCorrections above is an EXACT string map, so it only fixes misspellings someone
|
|
120
106
|
// already hand-authored. A novel miss ("Rileyy" for Riley, "Exampel" for Example) sails
|
|
@@ -137,24 +123,28 @@ async function cleanOutboundDictation(text: string, opts: AutoCleanRequest & { s
|
|
|
137
123
|
} catch (fuzzyErr: any) {
|
|
138
124
|
console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
|
|
139
125
|
}
|
|
140
|
-
|
|
126
|
+
// 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
|
|
127
|
+
if (opts.bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripDisfluencies(cleaned)
|
|
128
|
+
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
|
|
141
129
|
const model = opts.model ?? 'haiku'
|
|
142
130
|
const autoCleanBreaker = autoCleanBreakers[model]
|
|
143
|
-
if (cleaned.length > AUTOCLEAN_MAX_CHARS
|
|
131
|
+
if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
|
|
132
|
+
if (autoCleanBreaker.isOpen()) return { text: cleaned, polishSkipped: 'breaker_open' }
|
|
133
|
+
if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
|
|
144
134
|
const startedAt = Date.now()
|
|
145
|
-
|
|
135
|
+
recordPolishCall(opts.bucket)
|
|
146
136
|
try {
|
|
147
137
|
const polished = (await autoCleanDictation(cleaned, getVocabulary(), { model, signal: opts.signal })).trim()
|
|
148
138
|
autoCleanBreaker.recordSuccess()
|
|
149
139
|
logTokenAudit({
|
|
150
140
|
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
151
|
-
durationMs: Date.now() - startedAt, caller:
|
|
141
|
+
durationMs: Date.now() - startedAt, caller: `dictation_autoclean_${opts.bucket}`,
|
|
152
142
|
})
|
|
153
|
-
return polished || cleaned
|
|
143
|
+
return { text: polished || cleaned }
|
|
154
144
|
} catch (err: any) {
|
|
155
145
|
autoCleanBreaker.recordFailure()
|
|
156
146
|
console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
|
|
157
|
-
return cleaned
|
|
147
|
+
return { text: cleaned, polishSkipped: 'failed' }
|
|
158
148
|
}
|
|
159
149
|
}
|
|
160
150
|
|
|
@@ -178,12 +168,14 @@ promptDraftsRouter.post('/dictation/finalize', async (req, res) => {
|
|
|
178
168
|
|
|
179
169
|
const abort = new AbortController()
|
|
180
170
|
res.on('close', () => { if (!res.writableEnded) abort.abort() })
|
|
181
|
-
const finalText = await cleanOutboundDictation(text, {
|
|
171
|
+
const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, {
|
|
182
172
|
enabled: true,
|
|
183
173
|
model,
|
|
174
|
+
bucket: surface,
|
|
184
175
|
signal: abort.signal,
|
|
185
176
|
})
|
|
186
|
-
|
|
177
|
+
// 6.79.1: polishSkipped only when the model did not polish, so a polished answer is unchanged.
|
|
178
|
+
res.json({ text: finalText, surface, polished: finalText !== text, ...(polishSkipped ? { polishSkipped } : {}) })
|
|
187
179
|
})
|
|
188
180
|
|
|
189
181
|
async function readRawBody(req: AsyncIterable<Buffer | Uint8Array | string>): Promise<Buffer> {
|
|
@@ -446,7 +438,7 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
446
438
|
await markPromptDraftError(draftId, 'No speech detected')
|
|
447
439
|
throw new NoSpeechDetectedError()
|
|
448
440
|
}
|
|
449
|
-
const finalText = await cleanOutboundDictation(text, { ...autoClean, signal })
|
|
441
|
+
const { text: finalText, polishSkipped } = await cleanOutboundDictation(text, { ...autoClean, bucket: 'prompt_draft', signal })
|
|
450
442
|
const finalized = await markPromptDraftFinalized(draftId, finalText)
|
|
451
443
|
const qualities = transcriptRecords.map(record => record.actualQuality)
|
|
452
444
|
const actualQuality: 'hq' | 'fast' | 'cloud' = qualities.includes('cloud')
|
|
@@ -474,6 +466,8 @@ async function finalizeDraft(draftId: string, mode: 'hq' | 'fast', autoClean: Au
|
|
|
474
466
|
// 6.73.0, additive.
|
|
475
467
|
engine: engines.length === 1 ? engines[0] : engines.length === 0 ? plan.engine : 'mixed',
|
|
476
468
|
...(engineReason ? { engineReason } : {}),
|
|
469
|
+
// 6.79.1, additive.
|
|
470
|
+
...(polishSkipped ? { polishSkipped } : {}),
|
|
477
471
|
}
|
|
478
472
|
}
|
|
479
473
|
|