@gotcos/glasses-server 6.82.0 → 6.84.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 +224 -0
- package/docs/work-orchestrator.md +184 -0
- package/managed-runtime-contract.json +2 -1
- package/package.json +8 -1
- package/server/index.ts +12 -0
- package/server/lib/claude-bridge.ts +4 -0
- package/server/lib/dictation-clean.ts +26 -3
- package/server/lib/dictation-format.ts +119 -0
- package/server/lib/dictation-polish-need.ts +4 -2
- package/server/lib/glossary-learn.ts +88 -10
- package/server/lib/hallucination-filter.ts +5 -0
- package/server/lib/meeting-batch-transcribe.ts +12 -4
- package/server/lib/profile.ts +3 -1
- package/server/lib/query-job-coordinator.ts +5 -1
- package/server/lib/unsaved-audio-quarantine.ts +36 -5
- package/server/lib/whisper-local.ts +31 -5
- package/server/lib/work-chat.ts +76 -9
- package/server/lib/work-lens-rounds.ts +445 -0
- package/server/lib/work-orchestrator/discovery.ts +99 -0
- package/server/lib/work-orchestrator/input.ts +109 -0
- package/server/lib/work-orchestrator/pickup.ts +85 -0
- package/server/lib/work-orchestrator/plan-log.ts +38 -0
- package/server/lib/work-orchestrator/redact.ts +41 -0
- package/server/lib/work-orchestrator/runtime.ts +74 -0
- package/server/lib/work-orchestrator/session-log.ts +114 -0
- package/server/lib/work-orchestrator/snapshot.ts +87 -0
- package/server/lib/work-orchestrator/tag-index.ts +272 -0
- package/server/lib/work-orchestrator/tags.ts +52 -0
- package/server/lib/work-orchestrator/usage-events.ts +30 -0
- package/server/lib/work-orchestrator/usage.ts +341 -0
- package/server/routes/glossary.ts +14 -2
- package/server/routes/meeting.ts +76 -1
- package/server/routes/prompt-drafts.ts +15 -2
- package/server/routes/transcribe-stream.ts +127 -10
- package/server/routes/work-chat.ts +54 -11
- package/server/routes/work-lens-rounds.ts +179 -0
- package/server/routes/work-orchestrator.ts +71 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.83.1: spoken paths and links in dictated messages. Miles, 2026-10-21, dictating funnel
|
|
3
|
+
* pages: "demo, forward slash pricing, forward slash build and price". He meant /pricing and
|
|
4
|
+
* /build-and-price, and COS gave him the words.
|
|
5
|
+
*
|
|
6
|
+
* Two pieces, both pure, both used by the outbound dictation pass in routes/prompt-drafts.ts:
|
|
7
|
+
*
|
|
8
|
+
* 1. renderSpokenPaths: the ONE case a rule can do safely. "forward slash" plus a single word
|
|
9
|
+
* that ends a phrase (a comma, a sentence end, a line end, or the end of the text) becomes
|
|
10
|
+
* "/word" in lowercase: "go to forward slash pricing." becomes "go to /pricing.". Anything
|
|
11
|
+
* longer ("forward slash build and price") is left to the polish model, which can tell
|
|
12
|
+
* where a slug ends; a rule cannot. Not rewritten:
|
|
13
|
+
* - "slash" without "forward": "and slash or", "slash prices" are ordinary speech.
|
|
14
|
+
* - the character itself: "the forward slash key", "a forward slash", "forward slash
|
|
15
|
+
* character" (an article before it, or a word that names the key after it).
|
|
16
|
+
* - ordinary speech after it: "with forward slash instead", "type forward slash twice"
|
|
17
|
+
* (an adverb or function word, NOT_A_SLUG).
|
|
18
|
+
* Off with COS_DICTATION_SPOKEN_PATHS=0. Meeting lines never come here (they stay faithful).
|
|
19
|
+
*
|
|
20
|
+
* 2. polishLinkProblem: a guard on the model's answer. The polish must never invent a link
|
|
21
|
+
* and must keep a link the dictionary already wrote. It returns why an answer is refused,
|
|
22
|
+
* or null. A refused answer means the caller sends the pre-polish text instead.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { HANDLE, LINK_TLDS } from './glossary-learn.js'
|
|
26
|
+
|
|
27
|
+
const ARTICLE_BEFORE = /(?:^|[^\p{L}\p{N}])(?:the|a|an|this|that|one)[ \t]+$/iu
|
|
28
|
+
/**
|
|
29
|
+
* Words that follow "forward slash" in ordinary speech about the character, never a page name
|
|
30
|
+
* (6.83.1 QA W1): "with forward slash instead", "type forward slash twice", "two forward slash
|
|
31
|
+
* marks". The names of the key, singular and plural, adverbs, and common function words.
|
|
32
|
+
*/
|
|
33
|
+
export const NOT_A_SLUG = new Set([
|
|
34
|
+
'key', 'keys', 'character', 'characters', 'char', 'chars', 'sign', 'signs', 'symbol', 'symbols', 'mark', 'marks', 'button', 'buttons',
|
|
35
|
+
'instead', 'too', 'twice', 'once', 'again', 'everywhere', 'here', 'there', 'only', 'please', 'first', 'last', 'also', 'now', 'then',
|
|
36
|
+
'either', 'neither', 'always', 'never', 'anywhere', 'somewhere', 'before', 'after', 'later', 'yet', 'still', 'though', 'anyway',
|
|
37
|
+
'the', 'a', 'an', 'and', 'or', 'but', 'nor', 'so', 'to', 'of', 'in', 'on', 'at', 'for', 'with', 'by', 'from', 'as', 'it', 'its',
|
|
38
|
+
'this', 'that', 'these', 'those', 'is', 'are', 'was', 'were', 'be', 'if', 'not', 'no', 'yes', 'all', 'each', 'every', 'both',
|
|
39
|
+
'more', 'less', 'one', 'two', 'three', 'thanks', 'ok', 'okay',
|
|
40
|
+
])
|
|
41
|
+
/** "forward slash" (or "forward-slash"), one word, then a phrase end. */
|
|
42
|
+
const SPOKEN_PATH = /(?<![\p{L}\p{N}])forward[ \t-]+slash[ \t]+([\p{L}\p{N}]+)(?=[ \t]*(?:[,.;:!?)]|\n|$))/giu
|
|
43
|
+
|
|
44
|
+
export function spokenPathsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
45
|
+
return !['0', 'false', 'off'].includes((env.COS_DICTATION_SPOKEN_PATHS ?? '').trim().toLowerCase())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function renderSpokenPaths(text: string): string {
|
|
49
|
+
return text.replace(SPOKEN_PATH, (match: string, word: string, offset: number) => {
|
|
50
|
+
if (NOT_A_SLUG.has(word.toLowerCase())) return match
|
|
51
|
+
if (ARTICLE_BEFORE.test(text.slice(0, offset))) return match
|
|
52
|
+
return `/${word.toLowerCase()}`
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A written link, email, domain, handle or path, as a polish answer might contain one. */
|
|
57
|
+
const LINK_TOKEN = new RegExp([
|
|
58
|
+
String.raw`https?:\/\/[^\s<>"')\]]+`,
|
|
59
|
+
String.raw`\bwww\.[^\s<>"')\]]+`,
|
|
60
|
+
String.raw`[\p{L}\p{N}._%+-]+@[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)+`,
|
|
61
|
+
String.raw`\b[\p{L}\p{N}-]+(?:\.[\p{L}\p{N}-]+)*\.(?:${LINK_TLDS.join('|')})\b(?:\/[^\s<>"')\]]*)?`,
|
|
62
|
+
// A handle: "@sam", "@quilt_hq" (an email is matched whole above, first).
|
|
63
|
+
HANDLE.source,
|
|
64
|
+
// A path at the start of a token: "/pricing", "/build-and-price", "/docs/setup". Never
|
|
65
|
+
// "and/or" or "10/21" (a letter or digit right before the slash).
|
|
66
|
+
String.raw`(?<![\p{L}\p{N}\/])\/[\p{L}\p{N}_-]+(?:\/[\p{L}\p{N}_-]+)*`,
|
|
67
|
+
].join('|'), 'giu')
|
|
68
|
+
|
|
69
|
+
export function linkTokens(text: string): string[] {
|
|
70
|
+
return (text.match(LINK_TOKEN) ?? []).map(token => token.replace(/[.,;:!?]+$/, ''))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const compact = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '')
|
|
74
|
+
const spoken = (text: string, re: RegExp) => re.test(text)
|
|
75
|
+
/** An explicit request for paths: "as slugs", "as paths", "as a URL". Not any "path" ("the critical path"). */
|
|
76
|
+
const ASKS_FOR_PATHS = /\bas\s+(?:an?\s+)?(?:slugs?|paths?|urls?)\b/u
|
|
77
|
+
/** A dot spoken, or written between two letters ("docs.example"). "v6.83" or "3.5" is not one. */
|
|
78
|
+
const SPOKEN_DOT = /\bdot\b|\p{L}\.\p{L}/u
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Why a polish answer must not be sent, or null.
|
|
82
|
+
* - 'invented_link': the answer holds a link, email, handle or path whose letters the speaker
|
|
83
|
+
* never said (a word from `knownSpellings`, the dictionary's vocabulary, counts as said, so a
|
|
84
|
+
* corrected spelling passes), or whose shape they never spoke: a path without "slash" (unless
|
|
85
|
+
* they asked for slugs, paths or a URL), a domain without "dot", an email or handle without
|
|
86
|
+
* "at". A scheme or "www" nobody said fails on its letters. A path made only of NOT_A_SLUG
|
|
87
|
+
* words ("/twice", "/everywhere") is the character talked about, not a page.
|
|
88
|
+
* - 'lost_dictionary_link': the text going in held a link the dictionary wrote (a
|
|
89
|
+
* correction-only form such as "/pricing") and the answer dropped it, in any case.
|
|
90
|
+
* Not handled: a number said as words and written as digits ("three sixty dot com" as
|
|
91
|
+
* "360.com") reads as invented; the answer is refused and the pre-polish text goes out.
|
|
92
|
+
*/
|
|
93
|
+
export function polishLinkProblem(
|
|
94
|
+
input: string,
|
|
95
|
+
output: string,
|
|
96
|
+
dictionaryLinks: readonly string[] = [],
|
|
97
|
+
knownSpellings: readonly string[] = [],
|
|
98
|
+
): 'invented_link' | 'lost_dictionary_link' | null {
|
|
99
|
+
const inputFold = input.toLowerCase(), outputFold = output.toLowerCase()
|
|
100
|
+
for (const link of dictionaryLinks) {
|
|
101
|
+
const l = link.toLowerCase()
|
|
102
|
+
if (l && inputFold.includes(l) && !outputFold.includes(l)) return 'lost_dictionary_link'
|
|
103
|
+
}
|
|
104
|
+
const said = compact(input)
|
|
105
|
+
const known = new Set(knownSpellings.map(compact).filter(Boolean))
|
|
106
|
+
for (const token of linkTokens(output)) {
|
|
107
|
+
const lower = token.toLowerCase()
|
|
108
|
+
// Every letter run in the link must have been said somewhere (not necessarily in order).
|
|
109
|
+
const pieces = lower.split(/[^\p{L}\p{N}]+/u).filter(Boolean)
|
|
110
|
+
if (pieces.some(piece => !said.includes(piece) && !known.has(piece))) return 'invented_link'
|
|
111
|
+
// And its shape must have been spoken. A link already written in the input has its marks there.
|
|
112
|
+
if (lower.includes('@') && !spoken(inputFold, /@|\bat\b/)) return 'invented_link'
|
|
113
|
+
if (/\.[\p{L}]/u.test(lower.replace(/^https?:\/\//, '').split('@').pop() ?? '') && !spoken(inputFold, SPOKEN_DOT)) return 'invented_link'
|
|
114
|
+
if (lower.startsWith('/') && !spoken(inputFold, /\bslash\b|(?<![\p{L}\p{N}])\/[\p{L}\p{N}]/u) && !spoken(inputFold, ASKS_FOR_PATHS)) return 'invented_link'
|
|
115
|
+
// "/twice", "/everywhere": the speaker talked about the character, not a page (NOT_A_SLUG).
|
|
116
|
+
if (lower.startsWith('/') && !lower.includes('.') && pieces.every(piece => NOT_A_SLUG.has(piece))) return 'invented_link'
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* - a filler-like the deterministic pass leaves to the model ("like", "you know", "kind of"),
|
|
10
10
|
* or a sentence that opens on a tic ("So", "Well", "Okay")
|
|
11
11
|
* - a filler sound still present (a meeting line, or one the pass kept)
|
|
12
|
-
* - spoken formatting or punctuation ("new line", "bullet point", "comma", "question mark")
|
|
12
|
+
* - spoken formatting or punctuation ("new line", "bullet point", "comma", "question mark"), and
|
|
13
|
+
* since 6.83.1 spoken links and formatting instructions ("slash", "dot com", "underscore",
|
|
14
|
+
* "at sign", "all caps", "slugs")
|
|
13
15
|
* - text that does not read as finished: no capital at the start or no closing punctuation
|
|
14
16
|
* - more than MAX_WORDS words
|
|
15
17
|
* Each check errs toward calling the model: a false "needed" costs a model call, a false
|
|
@@ -27,7 +29,7 @@ const REPEAT = /(?<![\p{L}\p{N}'])([\p{L}\p{N}']+)[\s,]+\1(?![\p{L}\p{N}'])/iu
|
|
|
27
29
|
const FALSE_START = /(?<![\p{L}\p{N}'])([\p{L}']+)\s+[\p{L}']+(?:\s+[\p{L}']+)?,\s+\1(?![\p{L}\p{N}'])/iu
|
|
28
30
|
const FILLER_LIKE = /\b(like|you know|kind of|kinda|sort of|sorta|basically|literally|i guess)\b|^["“(]?(so|well|okay|ok|alright|anyway)\b/i
|
|
29
31
|
const FILLER = /(?<![\p{L}\p{N}'’-])(um+|uhm|uh+|erm?|ah+|hm+|mm+|mhm)(?![\p{L}\p{N}'’-])/iu
|
|
30
|
-
const FORMATTING = /\b(new ?line|new paragraph|next line|bullet( point)?|numbered|number (one|two|three|1|2|3)|comma|period|full stop|question mark|exclamation (point|mark)|colon|semicolon|open (quote|paren)|close (quote|paren)|dash)\b/i
|
|
32
|
+
const FORMATTING = /\b(new ?line|new paragraph|next line|bullet( point)?|numbered|number (one|two|three|1|2|3)|comma|period|full stop|question mark|exclamation (point|mark)|colon|semicolon|open (quote|paren)|close (quote|paren)|dash|slash|backslash|underscore|hyphen|at sign|dot (com|net|org|io|ai|co|dev|app)|all caps|slugs?)\b/i
|
|
31
33
|
|
|
32
34
|
export function polishNeed(text: string): PolishNeed | null {
|
|
33
35
|
const trimmed = text.trim()
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// One-word dictionary learning (6.73.11). "Fix a word" in COS Control, and any
|
|
2
2
|
// other surface, teaches ONE correction at a time: heard → written joins the
|
|
3
|
-
// corrections map and `written` joins the vocabulary, in one write.
|
|
3
|
+
// corrections map and `written` joins the vocabulary, in one write. Since 6.83.1 a
|
|
4
|
+
// written form that is a link, email or path ("forward slash pricing" written
|
|
5
|
+
// "/pricing") is kept as a correction ONLY: it never joins the vocabulary, so it
|
|
6
|
+
// never reaches a decoder prompt. Every learn
|
|
4
7
|
// is logged (bounded, newest first) so the person can see what COS learned, from
|
|
5
8
|
// where, and undo exactly that one entry.
|
|
6
9
|
//
|
|
@@ -42,11 +45,19 @@ export type UndoResult =
|
|
|
42
45
|
| { ok: false; status: number; error: string }
|
|
43
46
|
|
|
44
47
|
// Positive vocab is injected into the Whisper decoder prompt; URLs/emails/paths
|
|
45
|
-
// there induce ".com"/handle hallucinations on quiet audio, so they
|
|
48
|
+
// there induce ".com"/handle hallucinations on quiet audio, so they never join the
|
|
49
|
+
// vocabulary. As a correction's written form they are fine (6.83.1): corrections are
|
|
50
|
+
// applied to text after decoding and are not part of any decoder prompt.
|
|
51
|
+
// 6.83.1: one top-level-domain list for this guard and the polish link check (dictation-format.ts).
|
|
52
|
+
export const LINK_TLDS = ['com', 'net', 'org', 'io', 'ai', 'co', 'gov', 'edu', 'app', 'dev', 'us', 'uk'] as const
|
|
53
|
+
const TLD = new RegExp(`\\.(?:${LINK_TLDS.join('|')})\\b`, 'i')
|
|
54
|
+
/** "@sam" or "@quilt_hq": a handle seeds handle hallucinations in the decoder just as an email does. */
|
|
55
|
+
export const HANDLE = /(?<![\p{L}\p{N}._%+-])@[\p{L}\p{N}_]{2,}/u
|
|
46
56
|
export function looksLikeUrlEmailPath(s: string): boolean {
|
|
47
57
|
return /(?:https?:\/\/|www\.)/i.test(s)
|
|
48
58
|
|| /@[\w.-]+\.\w/.test(s)
|
|
49
|
-
||
|
|
59
|
+
|| TLD.test(s)
|
|
60
|
+
|| HANDLE.test(s)
|
|
50
61
|
|| /[/\\]/.test(s)
|
|
51
62
|
}
|
|
52
63
|
|
|
@@ -78,26 +89,30 @@ export function learnCorrection(
|
|
|
78
89
|
return { ok: false, status: 400, error: 'a correction cannot contain line breaks or control characters' }
|
|
79
90
|
}
|
|
80
91
|
if (heard === written) return { ok: false, status: 400, error: 'the written spelling is the same as what was heard' }
|
|
81
|
-
|
|
82
|
-
|
|
92
|
+
// 6.83.1: a link, email or path is a correction only (see correctionOnly).
|
|
93
|
+
const correctionOnly = isCorrectionOnly(written)
|
|
94
|
+
if (correctionOnly && !/[\p{L}\p{N}]/u.test(written)) {
|
|
95
|
+
// "/" or "@" alone rewrites every "slash" or "at" in every transcript: no word to anchor it.
|
|
96
|
+
return { ok: false, status: 400, error: 'the written spelling needs a letter or number; a bare symbol would rewrite every sentence that says it' }
|
|
83
97
|
}
|
|
84
98
|
|
|
85
99
|
const existingKey = Object.keys(words.corrections).find(key => same(key, heard)) ?? null
|
|
86
100
|
const correctionChanged = existingKey !== heard || words.corrections[heard] !== written
|
|
87
101
|
const inVocabulary = words.vocabulary.some(term => same(term, written))
|
|
88
|
-
if (!correctionChanged && inVocabulary) return { ok: true, unchanged: true }
|
|
102
|
+
if (!correctionChanged && (inVocabulary || correctionOnly)) return { ok: true, unchanged: true }
|
|
89
103
|
|
|
90
104
|
if (existingKey === null && Object.keys(words.corrections).length >= MAX_TERMS) {
|
|
91
105
|
return { ok: false, status: 409, error: `the dictionary already holds ${MAX_TERMS} corrections; remove one first` }
|
|
92
106
|
}
|
|
93
|
-
|
|
107
|
+
const addsVocabulary = !inVocabulary && !correctionOnly
|
|
108
|
+
if (addsVocabulary && words.vocabulary.length >= MAX_TERMS) {
|
|
94
109
|
return { ok: false, status: 409, error: `the vocabulary already holds ${MAX_TERMS} terms; remove one first` }
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
const corrections = { ...words.corrections }
|
|
98
113
|
if (existingKey !== null && existingKey !== heard) delete corrections[existingKey]
|
|
99
114
|
corrections[heard] = written
|
|
100
|
-
const vocabulary =
|
|
115
|
+
const vocabulary = addsVocabulary ? [...words.vocabulary, written] : [...words.vocabulary]
|
|
101
116
|
const entry: CorrectionLogEntry = {
|
|
102
117
|
id: stamp.id,
|
|
103
118
|
heard,
|
|
@@ -106,11 +121,72 @@ export function learnCorrection(
|
|
|
106
121
|
at: stamp.at,
|
|
107
122
|
replacedKey: existingKey,
|
|
108
123
|
previous: existingKey === null ? null : words.corrections[existingKey],
|
|
109
|
-
addedVocabulary:
|
|
124
|
+
addedVocabulary: addsVocabulary,
|
|
110
125
|
}
|
|
111
126
|
return { ok: true, unchanged: false, words: { vocabulary, corrections }, entry }
|
|
112
127
|
}
|
|
113
128
|
|
|
129
|
+
/**
|
|
130
|
+
* 6.83.1: a written form that is a link, email or path is kept in the corrections map
|
|
131
|
+
* and never added to the vocabulary (the decoder prompt). Same test as the vocabulary
|
|
132
|
+
* guard, so nothing the decoder refuses can reach it through a correction.
|
|
133
|
+
*/
|
|
134
|
+
export function isCorrectionOnly(written: string): boolean {
|
|
135
|
+
return looksLikeUrlEmailPath(written)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The one line a client can show after a correction-only learn. No em dashes, no arrows. */
|
|
139
|
+
export const CORRECTION_ONLY_NOTE = 'Saved as a correction. Links, emails and paths are never added to the words that guide transcription, so COS rewrites what it hears instead.'
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The written forms of correction-only entries ("/pricing"). The polish link check
|
|
143
|
+
* (routes/prompt-drafts.ts) refuses an answer that drops one of them.
|
|
144
|
+
*/
|
|
145
|
+
export function correctionOnlyForms(corrections: Record<string, string>): string[] {
|
|
146
|
+
const out: string[] = []
|
|
147
|
+
for (const value of Object.values(corrections)) {
|
|
148
|
+
const v = value.trim()
|
|
149
|
+
if (v && isCorrectionOnly(v) && !out.includes(v)) out.push(v)
|
|
150
|
+
}
|
|
151
|
+
return out
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The rolling transcript context Whisper is primed with already holds corrected text, so
|
|
156
|
+
* a correction-only form ("/pricing") would reach the decoder through it. Put the spoken
|
|
157
|
+
* form back ("forward slash pricing") before priming: the decoder hears words, never the
|
|
158
|
+
* link or path. Any case ("/Pricing" too). Longest forms first so "/build-and-price" is not
|
|
159
|
+
* split by "/build".
|
|
160
|
+
*/
|
|
161
|
+
export function decoderSafeContext(context: string, corrections: Record<string, string>): string {
|
|
162
|
+
const pairs = Object.entries(corrections)
|
|
163
|
+
.map(([heard, written]) => [heard.trim(), written.trim()] as const)
|
|
164
|
+
.filter(([heard, written]) => heard && written && isCorrectionOnly(written) && !isCorrectionOnly(heard))
|
|
165
|
+
.sort((a, b) => b[1].length - a[1].length)
|
|
166
|
+
let out = context
|
|
167
|
+
for (const [heard, written] of pairs) {
|
|
168
|
+
out = out.replace(new RegExp(written.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), () => heard)
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The decoder's priming window (6.83.1 QA W2): swap correction-only forms back to speech
|
|
175
|
+
* BEFORE taking the last `max` characters, so a dictionary link that straddles the cut can
|
|
176
|
+
* never leave its tail in the window. A first token the cut split that still reads as a
|
|
177
|
+
* link ("ample.com", "@example", "e/pricing", from a link the speaker said that is not in
|
|
178
|
+
* the dictionary) is dropped too; a split ordinary word stays, as before.
|
|
179
|
+
*/
|
|
180
|
+
export function decoderContextWindow(context: string | undefined, corrections: Record<string, string>, max = 250): string | undefined {
|
|
181
|
+
if (context === undefined) return undefined
|
|
182
|
+
const safe = decoderSafeContext(context, corrections)
|
|
183
|
+
if (safe.length <= max) return safe
|
|
184
|
+
const cut = safe.slice(-max)
|
|
185
|
+
const first = /^\S+/.exec(cut)?.[0] ?? ''
|
|
186
|
+
const split = first !== '' && /\S$/.test(safe.slice(0, safe.length - max))
|
|
187
|
+
return split && looksLikeUrlEmailPath(first) ? cut.slice(first.length).replace(/^\s+/, '') : cut
|
|
188
|
+
}
|
|
189
|
+
|
|
114
190
|
/** Newest first, bounded. */
|
|
115
191
|
export function appendLog(log: CorrectionLogEntry[], entry: CorrectionLogEntry): CorrectionLogEntry[] {
|
|
116
192
|
return [entry, ...log].slice(0, MAX_LOG)
|
|
@@ -189,7 +265,9 @@ export function addVocabulary(words: GlossaryWords, raw: unknown): EditResult {
|
|
|
189
265
|
const term = validTerm(raw, 'term')
|
|
190
266
|
if (!term.ok) return term
|
|
191
267
|
if (looksLikeUrlEmailPath(term.value)) {
|
|
192
|
-
|
|
268
|
+
// A vocabulary word has no heard side: it only steers the decoder, which is exactly
|
|
269
|
+
// where a link, email or path does harm. Point at the correction that does work.
|
|
270
|
+
return { ok: false, status: 400, error: 'a link, email or path cannot be a vocabulary word because it would steer transcription. Add it as a correction instead: what you say, then how to write it' }
|
|
193
271
|
}
|
|
194
272
|
if (words.vocabulary.some(existing => same(existing, term.value))) return { ok: true, unchanged: true }
|
|
195
273
|
if (words.vocabulary.length >= MAX_TERMS) {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// entirety (silence artifacts, caption training, foreign script, filler-only).
|
|
13
13
|
|
|
14
14
|
import { getNegativeRules, getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
|
|
15
|
+
import { isCorrectionOnly } from './glossary-learn.js'
|
|
15
16
|
|
|
16
17
|
// ── Whole-chunk silence hallucinations ─────────────────────────────────────
|
|
17
18
|
const CAPTION_CREDIT_HALLUCINATION = /^\s*(?:closed\s+)?caption(?:ing|s)?\s+(?:is\s+)?provided\s+by\b/i
|
|
@@ -344,6 +345,10 @@ function getVocabEchoMatcher(): RegExp {
|
|
|
344
345
|
// Include whisper_corrections key/value variants so the echo matches whatever
|
|
345
346
|
// spelling whisper emits ("POS Nation" ↔ "POSNation", "Jewel 360" ↔ "Jewel360").
|
|
346
347
|
for (const [key, value] of Object.entries(getWhisperCorrections())) {
|
|
348
|
+
// 6.83.1: a correction-only pair ("forward slash pricing" written "/pricing") is not in the
|
|
349
|
+
// vocabulary prompt, so it is not one of the seeded terms this detector exists for. Its heard
|
|
350
|
+
// phrase is how the person dictates a link; a chunk of just that phrase is kept as speech.
|
|
351
|
+
if (isCorrectionOnly(value)) continue
|
|
347
352
|
raw.add(key)
|
|
348
353
|
raw.add(value)
|
|
349
354
|
}
|
|
@@ -24,7 +24,15 @@ import { MAC_CAPTURE_ID } from './mac-recording.js'
|
|
|
24
24
|
import { MacParakeetUnavailableError } from './mac-recording-asr.js'
|
|
25
25
|
import { transcribeParakeet, type ParakeetResult, type ParakeetRunOptions } from './parakeet.js'
|
|
26
26
|
import { isDeletionProneEngine, resolveHypotheses, RESOLVER_VERSION, type Hypothesis } from './stt-resolver.js'
|
|
27
|
-
import { getVocabulary } from './profile.js'
|
|
27
|
+
import { getVocabulary, getWhisperCorrections } from './profile.js'
|
|
28
|
+
import { decoderContextWindow } from './glossary-learn.js'
|
|
29
|
+
|
|
30
|
+
/** 6.83.1: the decoder's priming window, with correction-only forms put back to speech before the cut. */
|
|
31
|
+
function decoderWindow(previousText?: string): string | undefined {
|
|
32
|
+
let corrections: Record<string, string> = {}
|
|
33
|
+
try { corrections = getWhisperCorrections() } catch { /* a test double without it reads as none */ }
|
|
34
|
+
return decoderContextWindow(previousText, corrections)
|
|
35
|
+
}
|
|
28
36
|
import { captureSpeakerMode, policyLabel } from './meeting-speaker-policy.js'
|
|
29
37
|
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
30
38
|
import { isMetalBatchPreempted } from './whisper-metal-gate.js'
|
|
@@ -526,7 +534,7 @@ async function decodeWhisperOnce(
|
|
|
526
534
|
audioDir: string,
|
|
527
535
|
): Promise<HighQualityTranscriptionResult> {
|
|
528
536
|
try {
|
|
529
|
-
return await deps.whisper(input, previousText
|
|
537
|
+
return await deps.whisper(input, decoderWindow(previousText), { priority: 'batch', timestamps })
|
|
530
538
|
} catch (error) {
|
|
531
539
|
// A live meeting took the GPU mid-segment. The truncated Metal output
|
|
532
540
|
// was already discarded upstream (BLOCKER contract) — it is never
|
|
@@ -539,7 +547,7 @@ async function decodeWhisperOnce(
|
|
|
539
547
|
+ 'retrying once on CPU',
|
|
540
548
|
)
|
|
541
549
|
refreshPendingLease(audioDir)
|
|
542
|
-
return deps.whisper(input, previousText
|
|
550
|
+
return deps.whisper(input, decoderWindow(previousText), { priority: 'batch', forceCpu: true, forceCpuReason: 'preempt_retry', timestamps })
|
|
543
551
|
}
|
|
544
552
|
}
|
|
545
553
|
|
|
@@ -1043,7 +1051,7 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
|
|
|
1043
1051
|
const identity = currentCheckpointIdentity()
|
|
1044
1052
|
const enhanced = await asrFrontEnd(combined, identity.frontEnd)
|
|
1045
1053
|
if (controller.signal.aborted || state.closed) return
|
|
1046
|
-
const decoded = await transcribeHighQuality(enhanced, pending.previousText
|
|
1054
|
+
const decoded = await transcribeHighQuality(enhanced, decoderWindow(pending.previousText), {
|
|
1047
1055
|
priority: 'batch',
|
|
1048
1056
|
forceCpu: true,
|
|
1049
1057
|
forceCpuReason: 'progressive_checkpoint',
|
package/server/lib/profile.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
|
|
|
5
5
|
import { homedir } from 'node:os'
|
|
6
6
|
import { resolve, dirname } from 'node:path'
|
|
7
7
|
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
8
|
+
import { looksLikeUrlEmailPath } from './glossary-learn.js'
|
|
8
9
|
|
|
9
10
|
const APP_ROOT = resolve(import.meta.dirname, '../..')
|
|
10
11
|
|
|
@@ -136,7 +137,8 @@ export function getVocabulary(): string[] {
|
|
|
136
137
|
if (typeof value !== 'string') return []
|
|
137
138
|
const term = value.trim()
|
|
138
139
|
const key = term.toLowerCase()
|
|
139
|
-
|
|
140
|
+
// 6.83.1: a link, email, handle or path never steers the decoder, even hand-written here.
|
|
141
|
+
if (!term || PLACEHOLDER_VOCABULARY.has(key) || seen.has(key) || looksLikeUrlEmailPath(term)) return []
|
|
140
142
|
seen.add(key)
|
|
141
143
|
return [term]
|
|
142
144
|
})
|
|
@@ -37,6 +37,7 @@ import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
|
37
37
|
import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
|
|
38
38
|
import { crashLoopParkedError, type BootResumeGate } from './boot-resume.js'
|
|
39
39
|
import { restartNoticeEnabled } from './query-job-feature.js'
|
|
40
|
+
import { noteJobProviderLimit } from './work-orchestrator/usage-events.js'
|
|
40
41
|
|
|
41
42
|
const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
42
43
|
|
|
@@ -836,7 +837,10 @@ export class QueryJobCoordinator {
|
|
|
836
837
|
this.finishActive(active)
|
|
837
838
|
return false
|
|
838
839
|
}
|
|
839
|
-
const
|
|
840
|
+
const failure = normalizeQueryJobError(error)
|
|
841
|
+
// 6.83.0: a plan-lane provider limit marks that provider exhausted for the Work orchestrator (until its reset).
|
|
842
|
+
noteJobProviderLimit(snapshot.provider, failure.code, error instanceof Error ? error.message : String((error as any)?.message ?? ''), active.request.model, snapshot.lane)
|
|
843
|
+
const result = await this.store.fail(active.jobId, failure)
|
|
840
844
|
const projected = isTerminalQueryJobStatus(result.job.status)
|
|
841
845
|
? await this.projectTerminal(result.job, active.request)
|
|
842
846
|
: false
|
|
@@ -43,8 +43,16 @@ export interface QuarantineManifest {
|
|
|
43
43
|
reason: string
|
|
44
44
|
chunkFiles: number
|
|
45
45
|
bytes: number
|
|
46
|
+
/** 6.83.2: a shorter clock for this one capture (a Mac capture discarded with no sound). Never longer than the global one. */
|
|
47
|
+
retentionMs?: number
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
/** 6.83.2: how long a discarded no-sound Mac capture stays quarantined: long enough to undo a wrong discard by hand. */
|
|
51
|
+
export const DISCARDED_RETENTION_MS = 24 * 60 * 60 * 1000
|
|
52
|
+
|
|
53
|
+
/** The receipt a discard writes, so the capture is terminal (`no_speech`) and never recovered on its own. */
|
|
54
|
+
export interface QuarantineReceipt { outcome: 'no_speech'; discarded: true; chunkFiles?: number; note: string }
|
|
55
|
+
|
|
48
56
|
export interface UnsavedCapture {
|
|
49
57
|
sessionId: string
|
|
50
58
|
dirName: string
|
|
@@ -57,6 +65,12 @@ export interface UnsavedCapture {
|
|
|
57
65
|
recovered: boolean
|
|
58
66
|
}
|
|
59
67
|
|
|
68
|
+
/** The clock that applies to one capture: its own when it set a shorter one, else the global retention. */
|
|
69
|
+
export function effectiveRetentionMs(manifest: { retentionMs?: unknown } | null, retentionMs: number): number {
|
|
70
|
+
const own = manifest?.retentionMs
|
|
71
|
+
return typeof own === 'number' && Number.isFinite(own) && own > 0 ? Math.min(retentionMs, own) : retentionMs
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export function unsavedAudioRoot(): string {
|
|
61
75
|
return dataPath('unsaved-audio')
|
|
62
76
|
}
|
|
@@ -99,6 +113,7 @@ export function quarantineSessionAudio(
|
|
|
99
113
|
sourceDir: string,
|
|
100
114
|
reason: string,
|
|
101
115
|
root: string = unsavedAudioRoot(),
|
|
116
|
+
options: { retentionMs?: number; receipt?: QuarantineReceipt } = {},
|
|
102
117
|
): string | null {
|
|
103
118
|
const sessionId = basename(sourceDir)
|
|
104
119
|
try {
|
|
@@ -114,6 +129,7 @@ export function quarantineSessionAudio(
|
|
|
114
129
|
reason,
|
|
115
130
|
chunkFiles: countChunkWavs(target),
|
|
116
131
|
bytes: dirBytes(target),
|
|
132
|
+
...(options.retentionMs ? { retentionMs: options.retentionMs } : {}),
|
|
117
133
|
}
|
|
118
134
|
try {
|
|
119
135
|
writeFileSync(resolve(target, QUARANTINE_MANIFEST), `${JSON.stringify(manifest)}\n`, {
|
|
@@ -121,6 +137,19 @@ export function quarantineSessionAudio(
|
|
|
121
137
|
mode: 0o600,
|
|
122
138
|
})
|
|
123
139
|
} catch { /* manifest is observability; the audio move already succeeded */ }
|
|
140
|
+
if (options.receipt) {
|
|
141
|
+
// 6.83.2: a discarded capture is terminal. The receipt is what keeps the auto-recover sweep and the
|
|
142
|
+
// unsaved-captures count off it (listUnsavedCaptures reads it as recovered, readCaptureDisposition as no_speech).
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(resolve(target, RECOVERED_RECEIPT), `${JSON.stringify({
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
recoveredAt: new Date().toISOString(),
|
|
147
|
+
chunkFiles: manifest.chunkFiles,
|
|
148
|
+
words: 0,
|
|
149
|
+
...options.receipt,
|
|
150
|
+
})}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
151
|
+
} catch { /* the audio is quarantined either way; without the receipt it is merely listed as recoverable */ }
|
|
152
|
+
}
|
|
124
153
|
return target
|
|
125
154
|
} catch {
|
|
126
155
|
// Rename failed (cross-device, permissions, race). Leave the source alone —
|
|
@@ -220,6 +249,7 @@ export function listUnsavedCaptures(
|
|
|
220
249
|
} catch { continue }
|
|
221
250
|
const manifest = readManifest(dirPath)
|
|
222
251
|
const atMs = quarantinedAtMs(dirPath, manifest)
|
|
252
|
+
const ownRetentionMs = effectiveRetentionMs(manifest, retentionMs)
|
|
223
253
|
captures.push({
|
|
224
254
|
sessionId: manifest?.sessionId ?? dir.replace(/\.\d+$/, ''),
|
|
225
255
|
dirName: dir,
|
|
@@ -228,7 +258,7 @@ export function listUnsavedCaptures(
|
|
|
228
258
|
chunkFiles: manifest?.chunkFiles ?? countChunkWavs(dirPath),
|
|
229
259
|
bytes: manifest?.bytes ?? dirBytes(dirPath),
|
|
230
260
|
reason: manifest?.reason ?? null,
|
|
231
|
-
expiresAt: atMs != null ? new Date(atMs +
|
|
261
|
+
expiresAt: atMs != null ? new Date(atMs + ownRetentionMs).toISOString() : null,
|
|
232
262
|
recovered: existsSync(resolve(dirPath, RECOVERED_RECEIPT)),
|
|
233
263
|
})
|
|
234
264
|
}
|
|
@@ -248,9 +278,10 @@ export function purgeExpiredQuarantine(
|
|
|
248
278
|
// sitting exactly at the retention boundary would otherwise lose its
|
|
249
279
|
// audio mid-run, in the module whose thesis is "never delete evidence".
|
|
250
280
|
if (isRecoveryActive(capture.dirName)) continue
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
281
|
+
// The capture's own expiry: a discarded no-sound capture sets a shorter clock (6.83.2).
|
|
282
|
+
const expiresMs = capture.expiresAt ? Date.parse(capture.expiresAt) : NaN
|
|
283
|
+
if (!Number.isFinite(expiresMs)) continue
|
|
284
|
+
if (Date.now() > expiresMs) {
|
|
254
285
|
try {
|
|
255
286
|
rmSync(resolve(root, capture.dirName), { recursive: true, force: true })
|
|
256
287
|
purged.push(capture.dirName)
|
|
@@ -433,7 +464,7 @@ function dispositionOf(sessionId: string, dirPath: string, retentionMs: number):
|
|
|
433
464
|
quarantineReason: manifest?.reason ?? null,
|
|
434
465
|
savedFilename,
|
|
435
466
|
recovering: isRecoveryActive(basename(dirPath)),
|
|
436
|
-
retainedUntil: atMs != null ? new Date(atMs + retentionMs).toISOString() : null,
|
|
467
|
+
retainedUntil: atMs != null ? new Date(atMs + effectiveRetentionMs(manifest, retentionMs)).toISOString() : null,
|
|
437
468
|
}
|
|
438
469
|
}
|
|
439
470
|
|
|
@@ -16,6 +16,7 @@ import crypto from 'node:crypto'
|
|
|
16
16
|
import { batchTimestampsEnabled, parseNativeVadTimeline, remapBatchWords } from './whisper-batch-timing.js'
|
|
17
17
|
import { readSttSettings, type FrontEndId } from './stt-settings.js'
|
|
18
18
|
import { getVocabulary, getOwnerName, getWhisperCorrections } from './profile.js'
|
|
19
|
+
import { decoderSafeContext } from './glossary-learn.js'
|
|
19
20
|
import { stripBrandUrls } from './hallucination-filter.js'
|
|
20
21
|
import {
|
|
21
22
|
batchHqMetalEnabled,
|
|
@@ -1243,8 +1244,9 @@ export async function transcribeHighQuality(
|
|
|
1243
1244
|
}
|
|
1244
1245
|
}
|
|
1245
1246
|
|
|
1246
|
-
/** Build prompt with optional previous transcript context for continuity
|
|
1247
|
-
|
|
1247
|
+
/** Build prompt with optional previous transcript context for continuity.
|
|
1248
|
+
* Exported (6.83.1) only so tests can assert on the exact decoder prompt. */
|
|
1249
|
+
export function buildPrompt(context?: string, isQuiet?: boolean): string {
|
|
1248
1250
|
// During quiet/silence audio, skip vocabulary prompt to reduce decoder bias
|
|
1249
1251
|
if (isQuiet) return ''
|
|
1250
1252
|
if (!context || context.length < 10) return getWhisperPrompt()
|
|
@@ -1259,6 +1261,9 @@ function buildPrompt(context?: string, isQuiet?: boolean): string {
|
|
|
1259
1261
|
// prior chunk would otherwise feed back through this context and re-seed the
|
|
1260
1262
|
// next decode. Strip brand URLs from the priming context (output unaffected).
|
|
1261
1263
|
if (STRIP_BRAND_URLS) sanitized = stripBrandUrls(sanitized)
|
|
1264
|
+
// 6.83.1: a correction-only form ("/pricing") came from the corrections pass, not the
|
|
1265
|
+
// speaker's audio; the decoder is primed with the spoken form instead (glossary-learn.ts).
|
|
1266
|
+
sanitized = decoderSafeContext(sanitized, getWhisperCorrections())
|
|
1262
1267
|
// Previous transcript helps Whisper maintain proper noun consistency,
|
|
1263
1268
|
// continue sentences across chunk boundaries, and reduce hallucinations
|
|
1264
1269
|
return PROMPT_V2 ? `${getWhisperPrompt()} ${sanitized}` : `${getWhisperPrompt()}. ${sanitized}`
|
|
@@ -1379,14 +1384,34 @@ function buildCorrections(): Array<[RegExp, string]> {
|
|
|
1379
1384
|
|
|
1380
1385
|
// Escape correction keys before interpolation. Names such as "A.C.M.E."
|
|
1381
1386
|
// are literal vocabulary, never regular-expression programs.
|
|
1382
|
-
|
|
1383
|
-
|
|
1387
|
+
// 6.83.1: the words of a multi-word key may be split by a run of spaces or tabs, or by one
|
|
1388
|
+
// hyphen ("forward-slash pricing"); never by a line break or a spaced dash (" - ").
|
|
1389
|
+
for (const [pattern, replacement] of correctionOrder(getWhisperCorrections())) {
|
|
1390
|
+
const escaped = pattern.trim().split(/\s+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:[ \\t]+|-)')
|
|
1384
1391
|
corrections.push([new RegExp(`\\b${escaped}\\b`, 'gi'), replacement])
|
|
1385
1392
|
}
|
|
1386
1393
|
|
|
1387
1394
|
return corrections
|
|
1388
1395
|
}
|
|
1389
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* The profile's order, except that a key goes ahead of any earlier key whose words it
|
|
1399
|
+
* contains (6.83.1): "forward slash build and price" runs before "forward slash build", or
|
|
1400
|
+
* the shorter key would cut the longer phrase short. Chains the person relies on
|
|
1401
|
+
* ("Myles" then "Miles Ukoma") keep their order, since neither key contains the other.
|
|
1402
|
+
*/
|
|
1403
|
+
export function correctionOrder(corrections: Record<string, string>): Array<[string, string]> {
|
|
1404
|
+
const norm = (key: string) => ` ${key.trim().toLowerCase().split(/[\s-]+/).join(' ')} `
|
|
1405
|
+
const out: Array<[string, string]> = []
|
|
1406
|
+
for (const entry of Object.entries(corrections)) {
|
|
1407
|
+
const mine = norm(entry[0])
|
|
1408
|
+
const at = out.findIndex(([key]) => mine !== norm(key) && mine.includes(norm(key)))
|
|
1409
|
+
if (at < 0) out.push(entry)
|
|
1410
|
+
else out.splice(at, 0, entry)
|
|
1411
|
+
}
|
|
1412
|
+
return out
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1390
1415
|
let _corrections: Array<[RegExp, string]> | null = null
|
|
1391
1416
|
function getCorrections(): Array<[RegExp, string]> {
|
|
1392
1417
|
if (!_corrections) _corrections = buildCorrections()
|
|
@@ -1399,7 +1424,8 @@ function getCorrections(): Array<[RegExp, string]> {
|
|
|
1399
1424
|
export function applyCorrections(text: string): string {
|
|
1400
1425
|
let result = text
|
|
1401
1426
|
for (const [pattern, replacement] of getCorrections()) {
|
|
1402
|
-
|
|
1427
|
+
// A function, so a written form holding "$&" or "$1" is inserted literally (6.83.1).
|
|
1428
|
+
result = result.replace(pattern, () => replacement)
|
|
1403
1429
|
}
|
|
1404
1430
|
return result
|
|
1405
1431
|
}
|